diff --git a/Cargo.lock b/Cargo.lock index a49362ce..a276f256 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,6 +116,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + [[package]] name = "aead" version = "0.6.1" @@ -1007,6 +1013,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.0" @@ -1152,6 +1164,15 @@ dependencies = [ "shlex 2.0.1", ] +[[package]] +name = "cedarwood" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0524a528a6a0288df1863c3c20fe92c301875b4941e7b6c4b394ab08c5a4c55" +dependencies = [ + "smallvec", +] + [[package]] name = "cesu8" version = "1.1.0" @@ -1814,6 +1835,12 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" + [[package]] name = "dashmap" version = "6.2.1" @@ -4035,6 +4062,39 @@ version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" +[[package]] +name = "include-flate" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f173716febb1ad596c16ea5637b5f1790ea32de8e627493ff82bc73b0876ce" +dependencies = [ + "include-flate-codegen", + "include-flate-compress", +] + +[[package]] +name = "include-flate-codegen" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a7875b62a72ad3f3203cdd8950d4cf9947db036030b974b8b37ceae90c8d8c0" +dependencies = [ + "include-flate-compress", + "proc-macro-error3", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "include-flate-compress" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fbb9c5ccb9a5b67b4afa2974c27e5507ea1bf6d22828cef418e4dfaeca51dd" +dependencies = [ + "libflate", + "zstd", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -4221,6 +4281,30 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jieba-macros" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38fc0f3831de71556de69643b80a08a5c8cd260a23c6b8dbeb7cd923c779cac5" +dependencies = [ + "phf_codegen 0.13.1", +] + +[[package]] +name = "jieba-rs" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a813bbf185c8c62eb6fcf54a223177b644824d91612045dfd80bb779acd080eb" +dependencies = [ + "bytecount", + "cedarwood", + "include-flate", + "jieba-macros", + "phf 0.13.1", + "regex", + "rustc-hash 2.1.2", +] + [[package]] name = "jni" version = "0.21.1" @@ -4476,6 +4560,30 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libflate" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd96e993e5f3368b0cb8497dae6c860c22af8ff18388c61c6c0b86c58d86b5df" +dependencies = [ + "adler32", + "crc32fast", + "dary_heap", + "libflate_lz77", + "no_std_io2", +] + +[[package]] +name = "libflate_lz77" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7a10e427698aef6eef269482776debfef63384d30f13aad39a1a95e0e098fd" +dependencies = [ + "hashbrown 0.16.1", + "no_std_io2", + "rle-decode-fast", +] + [[package]] name = "libfuzzer-sys" version = "0.4.13" @@ -4758,7 +4866,7 @@ checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" dependencies = [ "log", "phf 0.11.3", - "phf_codegen", + "phf_codegen 0.11.3", "string_cache", "string_cache_codegen", "tendril", @@ -5950,6 +6058,16 @@ dependencies = [ "phf_shared 0.11.3", ] +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + [[package]] name = "phf_generator" version = "0.11.3" @@ -6370,6 +6488,16 @@ dependencies = [ "quote", ] +[[package]] +name = "proc-macro-error-attr3" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34e4dd828515431dd6c4a030d26f7eaed7dd4778226e9d2bb968d65ca4ec3d4d" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "proc-macro-error2" version = "2.0.1" @@ -6382,6 +6510,18 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "proc-macro-error3" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee475e440453418ff1335189eddf7101ba502cd818ab7ae04209bc83aa925aa" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -6956,6 +7096,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rle-decode-fast" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" + [[package]] name = "ropey" version = "2.0.0-beta.1" @@ -9026,12 +9172,14 @@ version = "26.7.1" dependencies = [ "alacritty_terminal", "anyhow", + "core-foundation 0.10.0", "getrandom 0.3.4", "gpui", "gpui-component", "gpui-component-assets", "gpui_platform", "image", + "jieba-rs", "keyring", "ksni", "libc", @@ -9045,6 +9193,7 @@ dependencies = [ "objc2-foundation 0.3.2", "plist", "portable-pty", + "regex", "reqwest_client", "resvg", "russh", @@ -11110,6 +11259,34 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "ztracing" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index cbdea1e3..89c0ed7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,9 @@ gpui-component-assets = { workspace = true } anyhow.workspace = true log.workspace = true +# Smart double-click selection patterns (URL/email/path). Already in the tree +# transitively, so pinning it here adds no new native code. +regex = "1" smol.workspace = true smallvec.workspace = true serde = { workspace = true } @@ -171,12 +174,26 @@ winresource = "0.1" # app appearance to match the active theme (see `ui::theme::sync_native_appearance`) # so the native traffic-light buttons render in the right light/dark style. [target.'cfg(target_os = "macos")'.dependencies] +# CFStringTokenizer FFI for dictionary-based CJK word segmentation on +# double-click (`terminal::smart_select`). Already in the tree transitively, +# and it makes jieba unnecessary here — see the non-macos section below. +core-foundation = "0.10" objc2 = "0.6" objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSResponder", "NSAppearance", "NSGraphics", "NSImage"] } # NSData feeds the runtime Dock icon for bare (non-bundled) binaries — see # `set_dock_icon_for_bare_binary` in main.rs. objc2-foundation = { version = "0.3", features = ["NSData"] } +# Dictionary-based Chinese word segmentation for double-click selection +# (`terminal::smart_select`), as a *fallback* where the OS has no tokenizer of +# its own. macOS is excluded on purpose: CFStringTokenizer segments Chinese +# about as well (and Japanese/Korean far better) at zero cost, while jieba's +# table costs ~55 MB resident once built and ~2 MB of binary for the embedded +# dictionary. Keeping the dep off macOS means that dictionary isn't even +# linked into the build that can't use it. +[target.'cfg(not(target_os = "macos"))'.dependencies] +jieba-rs = "0.10" + # x11/wayland are the Linux windowing backends; only pull them on Linux. The # Windows backend (`gpui_windows`) and macOS backend are selected by gpui_platform # itself via `cfg`, so no feature is needed for them. diff --git a/docs/features.md b/docs/features.md index 4e42bfa0..d687f237 100644 --- a/docs/features.md +++ b/docs/features.md @@ -18,6 +18,7 @@ - **Repo-grouped sidebar** — the left tab sidebar groups rows under a header per git repository, non-repo tabs in a trailing *Scratch* section; branch switches and in-repo `cd`s never move a row (`sidebar_grouping` in `config.json`: `repo` default, `none` for a flat list) - **Command palette** ⌘ P · scrollback search ⌘ F - **⌘-click links** · desktop notifications · copy on select (opt-in, Settings → Terminal → Clipboard) +- **Smart double-click selection** — double-click grabs the whole URL, file path, bracket/quote pair, or dictionary-segmented CJK word under the cursor; Shift-click extends a selection (toggle in Settings → Terminal → Mouse; word separators via `word_separators` in `config.json`) - **Eight themes, plus your own** — YAML seed themes with solid, gradient, or image backgrounds; iTerm2 `.itermcolors` import; in-app color editor with a background-image picker - **Sync with system** — Settings → Appearance; pick separate light and dark themes and tty7 follows the OS appearance live (`theme_follow_system`, `theme_preset_light` / `theme_preset_dark` in `config.json`) - **Window opacity & blur** — Settings → Appearance → Window; applies to every theme, *Follow theme* returns to the theme's own `opacity` / `blur` diff --git a/docs/features.zh-CN.md b/docs/features.zh-CN.md index 0a7e2222..e9582526 100644 --- a/docs/features.zh-CN.md +++ b/docs/features.zh-CN.md @@ -18,6 +18,7 @@ - **侧栏按仓库分组** —— 左侧标签栏按 git 仓库分组、每组一个标题行,不在仓库里的标签归入末尾的 *Scratch* 组;切分支、仓库内 `cd` 都不会挪动行(`config.json` 的 `sidebar_grouping`:默认 `repo`,`none` 恢复扁平列表) - **命令面板** ⌘ P · 回滚搜索 ⌘ F - **⌘ 点击打开链接** · 桌面通知 · 划选即复制(可选,设置 → 终端 → 剪贴板) +- **智能双击选中** —— 双击直接选中整条 URL、文件路径、括号/引号对,中文按词典分词出词;Shift 点击扩展选区(设置 → 终端 → 鼠标可开关;分隔符用 `config.json` 的 `word_separators` 配置) - **8 套主题,也能自定义** — YAML 种子主题,背景支持纯色、渐变或图片;可导入 iTerm2 `.itermcolors`;应用内颜色编辑器带背景图选择 - **跟随系统外观** — 设置 → Appearance;分别选好浅色和深色主题,tty7 随系统深浅模式实时切换(`config.json` 中的 `theme_follow_system`、`theme_preset_light` / `theme_preset_dark`) - **窗口透明与模糊** — 设置 → Appearance → Window;对所有主题生效,*Follow theme* 恢复主题自带的 `opacity` / `blur` diff --git a/src/core/config.rs b/src/core/config.rs index 68cfc5ac..bb16b895 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -182,6 +182,19 @@ pub struct Config { /// the clipboard is never overwritten by a stray selection unless opted /// into. pub copy_on_select: bool, + /// Double-click smart selection: expand the selection to the whole URL, + /// email address, file path, or matching bracket pair under the cursor + /// when the plain word sits inside one. On by default; off restores the + /// bare word-boundary double-click. + #[serde(default = "default_true")] + pub smart_select: bool, + /// Characters (besides whitespace) that end a double-click word + /// selection, in both the terminal grid and the prompt's command editor. + /// The default mirrors alacritty's semantic escape set — note `/ . - _` + /// are *not* separators, so paths select as one word. JSON-only (no GUI + /// widget yet). + #[serde(default = "default_word_separators")] + pub word_separators: String, /// Window state at launch: normal / maximized / fullscreen. #[serde(default, deserialize_with = "de_lenient")] pub startup_mode: StartupMode, @@ -494,6 +507,8 @@ impl Default for Config { mouse_reporting: true, clipboard_trim_trailing_spaces: false, copy_on_select: false, + smart_select: true, + word_separators: default_word_separators(), startup_mode: StartupMode::Normal, remember_window_size: true, working_directory: WorkingDirectory::default(), @@ -762,6 +777,13 @@ fn default_true() -> bool { true } +/// Serde default for [`Config::word_separators`]: alacritty's stock semantic +/// escape set, the boundary characters double-click word selection used +/// before this was configurable. +fn default_word_separators() -> String { + ",│`|:\"' ()[]{}<>\t".to_string() +} + /// Serde default for [`Config::notify_threshold_secs`]: the 10-second floor a /// command had to cross before this was configurable. fn default_notify_threshold_secs() -> u64 { diff --git a/src/terminal/cmd_editor.rs b/src/terminal/cmd_editor.rs index e002dadf..971bf46f 100644 --- a/src/terminal/cmd_editor.rs +++ b/src/terminal/cmd_editor.rs @@ -222,24 +222,76 @@ impl CmdEditor { self.cursor = self.chars.len(); } - /// Bounds `(start, end)` of the word (run of non-whitespace) containing char - /// index `idx`. On whitespace this collapses to `(idx, idx)`. - pub fn word_bounds(&self, idx: usize) -> (usize, usize) { + /// Bounds `(start, end)` of the word containing char index `idx`: the run + /// of chars that are neither whitespace nor in `separators` (the + /// configured word-separator set, shared with the grid's semantic + /// selection). A separator char is its own one-char word, matching the + /// grid; on whitespace the run collapses and the leftward walk snaps to + /// the previous word's start. + /// + /// `smart` mirrors `Config::smart_select`: with it off this is exactly + /// [`Self::plain_word_bounds`], so the Settings toggle governs the prompt + /// editor and the grid alike. + pub fn word_bounds(&self, idx: usize, separators: &str, smart: bool) -> (usize, usize) { let idx = idx.min(self.chars.len()); + if !smart { + return self.plain_word_bounds(idx, separators, smart); + } + // A bracket or quote selects through its match, same as the grid. + // Checked before CJK segmentation so full-width `()`/`“”` pair + // instead of being segmented as lone punctuation tokens. Only for + // the double-click itself — drags use `plain_word_bounds` so the + // selection doesn't lurch when the pointer crosses a quote. + if let Some((s, e)) = super::smart_select::pair_range(&self.chars, idx) { + return (s, e + 1); + } + // CJK prose has no separators between words: segment it with the + // platform dictionary instead of selecting the whole unbroken run. + if let Some(&c) = self.chars.get(idx) + && super::smart_select::is_cjk(c) + { + let text: String = self.chars.iter().collect(); + if let Some((s, e)) = super::smart_select::cjk_word_range(&text, idx) { + return (s, e + 1); + } + } + self.plain_word_bounds(idx, separators, smart) + } + + /// [`Self::word_bounds`] without the pair/segmentation smarts: the plain + /// separator-walk word. Used for word-granular drags, where pair matching + /// would make the selection jump around as the pointer crosses a quote. + /// `smart` still governs the mixed-script narrowing, so a drag matches + /// what the double-click that started it selected. + fn plain_word_bounds(&self, idx: usize, separators: &str, smart: bool) -> (usize, usize) { + let idx = idx.min(self.chars.len()); + if let Some(&c) = self.chars.get(idx) + && !c.is_whitespace() + && separators.contains(c) + { + return (idx, idx + 1); + } + let boundary = |c: char| c.is_whitespace() || separators.contains(c); let mut s = idx; - while s > 0 && !self.chars[s - 1].is_whitespace() { + while s > 0 && !boundary(self.chars[s - 1]) { s -= 1; } let mut e = idx; - while e < self.chars.len() && !self.chars[e].is_whitespace() { + while e < self.chars.len() && !boundary(self.chars[e]) { e += 1; } + // Mixed-script runs (a Latin word glued to CJK text) shrink to the + // clicked char's script class — same correction as the grid's. + if smart && idx < e { + let (ns, ne) = super::smart_select::narrow_to_script(&self.chars, idx, s, e - 1); + return (ns, ne + 1); + } (s, e) } - /// Select the word (run of non-whitespace) containing char index `idx`. - pub fn select_word_at(&mut self, idx: usize) { - let (s, e) = self.word_bounds(idx); + /// Select the word containing char index `idx` (see [`Self::word_bounds`]). + pub fn select_word_at(&mut self, idx: usize, separators: &str, smart: bool) { + let (s, e) = self.word_bounds(idx, separators, smart); self.anchor = Some(s); self.cursor = e; } @@ -249,8 +301,15 @@ impl CmdEditor { /// selection grows by whole words: dragging past the anchor word selects /// forward to the far edge of the word under `idx`, dragging before it selects /// backward to that word's near edge. The cursor sits at the moving edge. - pub fn extend_word_to(&mut self, anchor_start: usize, anchor_end: usize, idx: usize) { - let (ws, we) = self.word_bounds(idx.min(self.chars.len())); + pub fn extend_word_to( + &mut self, + anchor_start: usize, + anchor_end: usize, + idx: usize, + separators: &str, + smart: bool, + ) { + let (ws, we) = self.plain_word_bounds(idx.min(self.chars.len()), separators, smart); if we >= anchor_end { self.anchor = Some(anchor_start); self.cursor = we; @@ -573,15 +632,34 @@ mod tests { assert_eq!(e.selection(), None); } + /// The default word-separator set (mirrors `Config::word_separators`). + const SEPS: &str = ",│`|:\"' ()[]{}<>\t"; + #[test] fn select_word_and_all() { let mut e = ed("git push origin", 6); - e.select_word_at(6); // cursor on "push" + e.select_word_at(6, SEPS, true); // cursor on "push" assert_eq!(e.selected_text().as_deref(), Some("push")); e.select_all(); assert_eq!(e.selection(), Some((0, 15))); } + #[test] + fn select_word_stops_at_separators() { + // Quotes and commas bound a word; a separator char is its own word. + let mut e = ed("echo 'a,b'", 0); + e.select_word_at(6, SEPS, true); // on "a" + assert_eq!(e.selected_text().as_deref(), Some("a")); + e.select_word_at(7, SEPS, true); // on the comma itself + assert_eq!(e.selected_text().as_deref(), Some(",")); + e.select_word_at(5, SEPS, true); // on the opening quote: pairs to the close + assert_eq!(e.selected_text().as_deref(), Some("'a,b'")); + // `/ . - _ =` are not separators: a path stays one word. + let mut e = ed("cat ./a-b/c_d.txt", 0); + e.select_word_at(8, SEPS, true); + assert_eq!(e.selected_text().as_deref(), Some("./a-b/c_d.txt")); + } + #[test] fn extend_to_keeps_anchor() { let mut e = ed("abcdef", 2); @@ -595,20 +673,20 @@ mod tests { fn extend_word_to_grows_by_whole_words_both_directions() { // Double-click "push" (chars 4..8), then drag over later/earlier words. let mut e = ed("git push origin main", 4); - e.select_word_at(6); + e.select_word_at(6, SEPS, true); let (s, a) = e.selection().unwrap(); // (4, 8) == "push" assert_eq!((s, a), (4, 8)); // Drag forward into "origin": selection reaches that word's far edge. - e.extend_word_to(s, a, 10); + e.extend_word_to(s, a, 10, SEPS, true); assert_eq!(e.selected_text().as_deref(), Some("push origin")); // Drag on into "main": grows to its end. - e.extend_word_to(s, a, 18); + e.extend_word_to(s, a, 18, SEPS, true); assert_eq!(e.selected_text().as_deref(), Some("push origin main")); // Drag backward before the anchor word into "git": anchor flips to the // word's far edge, selection covers "git push". - e.extend_word_to(s, a, 1); + e.extend_word_to(s, a, 1, SEPS, true); assert_eq!(e.selected_text().as_deref(), Some("git push")); } @@ -673,15 +751,15 @@ mod tests { // that word — the same left-scan that makes a double-click at the end // of the line select the last word. let mut e = ed("ab cd", 0); - e.select_word_at(2); // the space between the words + e.select_word_at(2, SEPS, true); // the space between the words assert_eq!(e.selected_text().as_deref(), Some("ab")); // Index at/past the end selects the trailing word, clamped. - e.select_word_at(99); + e.select_word_at(99, SEPS, true); assert_eq!(e.selected_text().as_deref(), Some("cd")); // On a gap wider than one cell there is no adjacent word to the left of // the clicked cell: the empty range collapses to no selection. let mut e = ed("ab cd", 0); - e.select_word_at(3); // second space: both neighbours are whitespace + e.select_word_at(3, SEPS, true); // second space: both neighbours are whitespace assert_eq!(e.selection(), None); } diff --git a/src/terminal/element.rs b/src/terminal/element.rs index 78de9894..d54fe708 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -1209,7 +1209,7 @@ impl TerminalElement { return; } if button == MouseButton::Left { - v.on_select_start(col, row, left, clicks, cx); + v.on_select_start(col, row, left, clicks, mods.shift, cx); } }); }); diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 34f06527..f2294ce3 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -34,6 +34,7 @@ mod reverse_search; pub mod search; mod signature; mod size; +mod smart_select; mod typeahead; pub mod view; diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 0031248c..05aa96b1 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -1511,6 +1511,7 @@ fn terminal_config_from_user(user_config: &crate::core::config::Config) -> Confi Config { scrolling_history: user_config.scrollback_limit, default_cursor_style: alacritty_cursor_style(user_config.cursor_style), + semantic_escape_chars: user_config.word_separators.clone(), ..Config::default() } } diff --git a/src/terminal/smart_select.rs b/src/terminal/smart_select.rs new file mode 100644 index 00000000..d71cbf84 --- /dev/null +++ b/src/terminal/smart_select.rs @@ -0,0 +1,1117 @@ +//! Double-click smart selection (à la iTerm2): when a double-click's +//! plain word selection sits inside a larger semantic object — a URL, an +//! email address, a file path, a matching bracket pair, or an OSC 8 +//! hyperlink — expand the selection to cover the whole object. +//! +//! The expansion is strictly additive: a candidate is only applied when it +//! *contains* the plain word the double-click would have selected, so the +//! feature can never shrink a selection below what alacritty's semantic +//! (word) selection yields. With no candidate the caller falls back to the +//! stock `SelectionType::Semantic` behavior unchanged. + +use std::sync::OnceLock; + +use alacritty_terminal::event::EventListener; +use alacritty_terminal::grid::Dimensions; +use alacritty_terminal::index::{Column, Line, Point}; +use alacritty_terminal::term::Term; +use alacritty_terminal::term::cell::Flags; +use regex::Regex; + +/// How many soft-wrapped rows to join on each side of the clicked row when +/// reconstructing the logical line. Caps the text a pathological fully-wrapped +/// scrollback line (minified JS piped to `cat`) can feed the regexes. +const MAX_WRAP_ROWS: usize = 32; + +/// How many chars around the click offset the regex window covers on each +/// side. Matches never straddle real whitespace anyway, so a bounded window +/// only drops matches on absurdly long unbroken runs. +const MATCH_WINDOW: usize = 2000; + +/// Bracket pairs a double-click on either half expands across (with +/// nesting): the ASCII pairs plus the full-width/CJK ones. +const BRACKET_PAIRS: [(char, char); 15] = [ + ('(', ')'), + ('[', ']'), + ('{', '}'), + ('<', '>'), + ('(', ')'), + ('[', ']'), + ('{', '}'), + ('〈', '〉'), + ('《', '》'), + ('「', '」'), + ('『', '』'), + ('【', '】'), + ('〔', '〕'), + ('“', '”'), + ('‘', '’'), +]; + +/// Symmetric quotes: open and close are the same char, so pairing needs the +/// parity heuristic in [`quote_range`] instead of the bracket scan. +const SYMMETRIC_QUOTES: [char; 3] = ['\'', '"', '`']; + +/// A resolved smart selection: an inclusive grid-point span, plus whether the +/// span is `exact`. Exact spans have endpoints that may sit mid-word-run (CJK +/// prose, a candidate glued to non-separator text), so the caller must select +/// them with `SelectionType::Simple` — a `Semantic` anchor would re-expand the +/// endpoints across the very boundary the smart range established. Non-exact +/// spans end on run boundaries and can keep `Semantic` for word-wise dragging. +pub(super) struct SmartRange { + pub start: Point, + pub end: Point, + pub exact: bool, +} + +/// Resolve a smart selection range for a double-click at `click` (grid +/// coordinates). `None` means "no candidate beats the plain word" and the +/// caller should keep the stock semantic selection. +pub(super) fn grid_smart_range( + term: &Term, + click: Point, +) -> Option { + // 1) An explicit OSC 8 hyperlink run wins outright — the program told us + // the exact extent, no guessing needed. + if let Some((start, end)) = hyperlink_run(term, click) { + return Some(SmartRange { + start, + end, + exact: true, + }); + } + + let (text, points, click_idx) = logical_line_at(term, click)?; + let chars: Vec = text.chars().collect(); + let separators = term.semantic_escape_chars(); + // A span whose flanks are separator chars ends exactly where alacritty's + // semantic re-expansion would stop anyway; anything else must stay exact. + // Only the separator set counts here — alacritty stops at nothing else, + // so a flank of e.g. U+3000 ideographic space would still re-expand. + let resolved = |s: usize, e: usize| SmartRange { + start: points[s], + end: points[e], + exact: !(s == 0 || separators.contains(chars[s - 1])) + || !(e + 1 == chars.len() || separators.contains(chars[e + 1])), + }; + + // 2) Double-click on a bracket or quote selects through its match. + if let Some((s, e)) = pair_range(&chars, click_idx) { + return Some(resolved(s, e)); + } + + // 3) CJK prose has no separators to walk — the whole clause is one run — + // so segment it with a dictionary instead of selecting the entire + // unbroken run. No segmenter available means the run stands as-is. + if is_cjk(chars[click_idx]) + && let Some((s, e)) = cjk_word_range(&text, click_idx) + { + return Some(resolved(s, e)); + } + + // 4) URL / email / path / identifier patterns around the click. + let (s, e) = smart_range(&text, &chars, click_idx, separators)?; + Some(resolved(s, e)) +} + +/// Whether a char belongs to a CJK script (Han, Kana, Hangul, or the +/// full-width/CJK punctuation blocks) — text whose words aren't delimited by +/// whitespace or the separator set. +pub(super) fn is_cjk(c: char) -> bool { + matches!( + u32::from(c), + 0x1100..=0x11FF // Hangul Jamo + | 0x2E80..=0x9FFF // CJK radicals, punctuation, Kana, ideographs + | 0xAC00..=0xD7AF // Hangul syllables + | 0xF900..=0xFAFF // CJK compatibility ideographs + | 0xFF00..=0xFFEF // full-width forms + | 0x20000..=0x3134F // ideograph extensions + ) +} + +/// Kana or Hangul — the scripts jieba has no dictionary for. A run holding +/// either is left unsegmented rather than handed to jieba, which shreds it +/// into single characters (`です` → `で` `す`); selecting the whole run is the +/// friendlier failure. +#[cfg(not(target_os = "macos"))] +fn is_kana_or_hangul(c: char) -> bool { + matches!( + u32::from(c), + 0x1100..=0x11FF // Hangul Jamo + | 0x3040..=0x30FF // Hiragana + Katakana + | 0x31F0..=0x31FF // Katakana phonetic extensions + | 0xA960..=0xA97F // Hangul Jamo Extended-A + | 0xAC00..=0xD7FF // Hangul syllables + Jamo Extended-B + | 0xFF66..=0xFF9F // half-width Katakana + ) +} + +/// The jieba segmenter, built once on a background thread. The table costs +/// ~55 MB resident and ~130 ms to build, so it is constructed only if a CJK +/// double-click actually happens — see [`jieba_word_range`]. +#[cfg(not(target_os = "macos"))] +static JIEBA: OnceLock = OnceLock::new(); + +/// Kick off dictionary construction on a background thread (idempotent). +/// Never called eagerly: the first CJK double-click triggers it and settles +/// for the unsegmented run, so the UI thread never blocks on the build. +#[cfg(not(target_os = "macos"))] +fn warm() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + std::thread::spawn(|| { + let _ = JIEBA.get_or_init(jieba_rs::Jieba::new); + }); + }); +} + +/// Dictionary-based word bounds for CJK text: the inclusive char range of the +/// word containing char index `click`, or `None` to keep the whole run. +/// +/// The OS tokenizer wins wherever there is one. macOS's CFStringTokenizer +/// carries a Chinese lexicon that matches jieba on most prose, is locale- +/// independent, handles Japanese and Korean properly, and costs nothing — +/// jieba is only worth its ~55 MB on platforms with no such API. +pub(super) fn cjk_word_range(text: &str, click: usize) -> Option<(usize, usize)> { + #[cfg(target_os = "macos")] + { + tokenizer::word_range(text, click) + } + #[cfg(not(target_os = "macos"))] + { + let chars: Vec = text.chars().collect(); + chars.get(click)?; + jieba_word_range(&chars, click) + } +} + +/// Segment the contiguous CJK run around `click` with jieba and return the +/// token containing it. `None` — meaning "select the whole run" — when the +/// dictionary isn't built yet or the run isn't Chinese. +#[cfg(not(target_os = "macos"))] +fn jieba_word_range(chars: &[char], click: usize) -> Option<(usize, usize)> { + let mut rs = click; + while rs > 0 && is_cjk(chars[rs - 1]) { + rs -= 1; + } + let mut re = click; + while re + 1 < chars.len() && is_cjk(chars[re + 1]) { + re += 1; + } + // Japanese/Korean: jieba's Chinese dictionary would cut the run into + // single characters, which is worse than not segmenting at all. + if chars[rs..=re].iter().copied().any(is_kana_or_hangul) { + return None; + } + // Building the table takes ~130 ms — far too long to hold the UI thread + // on a click. Start it in the background and let this one click select + // the whole run; every later click finds the table ready. + let Some(jieba) = JIEBA.get() else { + warm(); + return None; + }; + let run: String = chars[rs..=re].iter().collect(); + // Token start/end are Unicode char offsets into `run`. + let rel = click - rs; + jieba + .cut(&run, true) + .iter() + .find(|tok| rel < tok.end) + .map(|tok| (rs + tok.start, rs + tok.end - 1)) +} + +/// CFStringTokenizer FFI. The tokenizer functions aren't wrapped by the +/// `core-foundation` crate, so declare them directly against its types. +#[cfg(target_os = "macos")] +mod tokenizer { + use core_foundation::base::{CFIndex, CFRange, TCFType}; + use core_foundation::string::{CFString, CFStringRef}; + use std::os::raw::c_void; + + type CFStringTokenizerRef = *mut c_void; + type CFLocaleRef = *const c_void; + + /// `kCFStringTokenizerUnitWordBoundary`: every position belongs to a + /// token (words, punctuation runs, whitespace runs alike), which is the + /// double-click contract. + const UNIT_WORD_BOUNDARY: u64 = 4; + + unsafe extern "C" { + fn CFStringTokenizerCreate( + alloc: *const c_void, + string: CFStringRef, + range: CFRange, + options: u64, + locale: CFLocaleRef, + ) -> CFStringTokenizerRef; + fn CFStringTokenizerGoToTokenAtIndex( + tokenizer: CFStringTokenizerRef, + index: CFIndex, + ) -> u64; + fn CFStringTokenizerGetCurrentTokenRange(tokenizer: CFStringTokenizerRef) -> CFRange; + fn CFLocaleCopyCurrent() -> CFLocaleRef; + fn CFRelease(cf: *const c_void); + } + + /// Inclusive char range of the token containing char index `click`. + /// CFString ranges are UTF-16 code-unit offsets, so map through a + /// per-char offset table both ways. + pub(super) fn word_range(text: &str, click: usize) -> Option<(usize, usize)> { + let mut u16_of: Vec = Vec::new(); + let mut total: CFIndex = 0; + for c in text.chars() { + u16_of.push(total); + total += c.len_utf16() as CFIndex; + } + let click_u16 = *u16_of.get(click)?; + + let cf = CFString::new(text); + let range = unsafe { + let locale = CFLocaleCopyCurrent(); + let tok = CFStringTokenizerCreate( + std::ptr::null(), + cf.as_concrete_TypeRef(), + CFRange::init(0, total), + UNIT_WORD_BOUNDARY, + locale, + ); + let token_type = CFStringTokenizerGoToTokenAtIndex(tok, click_u16); + let range = (token_type != 0).then(|| CFStringTokenizerGetCurrentTokenRange(tok)); + CFRelease(tok); + if !locale.is_null() { + CFRelease(locale); + } + range? + }; + if range.location < 0 || range.length <= 0 { + return None; + } + let start = u16_of.binary_search(&range.location).ok()?; + let end = u16_of.partition_point(|&v| v < range.location + range.length) - 1; + (start <= click && click <= end).then_some((start, end)) + } +} + +/// The contiguous run of cells carrying the same OSC 8 hyperlink URI as the +/// clicked cell, following soft wraps in both directions (a long link wraps +/// across rows; stopping at the row edge would truncate the selection). +fn hyperlink_run(term: &Term, click: Point) -> Option<(Point, Point)> { + let grid = term.grid(); + let cols = term.columns(); + if click.column.0 >= cols { + return None; + } + let uri = grid[click.line][click.column] + .hyperlink()? + .uri() + .to_string(); + let same = |p: Point| { + grid[p.line][p.column] + .hyperlink() + .is_some_and(|h| h.uri() == uri) + }; + let wraps = |line: Line| grid[line][Column(cols - 1)].flags.contains(Flags::WRAPLINE); + let top = term.topmost_line(); + let bottom = term.bottommost_line(); + + let mut start = click; + let mut rows = 0; + loop { + let prev = if start.column.0 > 0 { + Point::new(start.line, Column(start.column.0 - 1)) + } else if start.line > top && rows < MAX_WRAP_ROWS && wraps(start.line - 1) { + rows += 1; + Point::new(start.line - 1, Column(cols - 1)) + } else { + break; + }; + if !same(prev) { + break; + } + start = prev; + } + let mut end = click; + rows = 0; + loop { + let next = if end.column.0 + 1 < cols { + Point::new(end.line, Column(end.column.0 + 1)) + } else if end.line < bottom && rows < MAX_WRAP_ROWS && wraps(end.line) { + rows += 1; + Point::new(end.line + 1, Column(0)) + } else { + break; + }; + if !same(next) { + break; + } + end = next; + } + Some((start, end)) +} + +/// Reconstruct the logical (soft-wrap-joined) line containing `click`: +/// the text with wide-char spacers dropped, a per-char grid point, and the +/// char index the click landed on. `None` when the click maps to no char +/// (out-of-bounds column). +fn logical_line_at( + term: &Term, + click: Point, +) -> Option<(String, Vec, usize)> { + let cols = term.columns(); + if click.column.0 >= cols { + return None; + } + let grid = term.grid(); + let last_col = Column(cols - 1); + let wraps = |line: Line| grid[line][last_col].flags.contains(Flags::WRAPLINE); + + let mut start_line = click.line; + let top = term.topmost_line(); + let mut guard = 0; + while start_line > top && guard < MAX_WRAP_ROWS && wraps(start_line - 1) { + start_line -= 1; + guard += 1; + } + let mut end_line = click.line; + let bottom = term.bottommost_line(); + guard = 0; + while end_line < bottom && guard < MAX_WRAP_ROWS && wraps(end_line) { + end_line += 1; + guard += 1; + } + + let mut text = String::new(); + let mut points = Vec::new(); + let mut click_idx = None; + let mut line = start_line; + while line <= end_line { + for col in 0..cols { + let cell = &grid[line][Column(col)]; + let p = Point::new(line, Column(col)); + // Spacer cells pad wide (CJK/emoji) glyphs. A trailing spacer + // follows its wide char; a leading spacer pads the end of a row + // whose wide char wrapped to the next row, so it belongs to the + // *next* pushed char. + if cell.flags.contains(Flags::LEADING_WIDE_CHAR_SPACER) { + if p == click { + click_idx = Some(points.len()); + } + continue; + } + if cell.flags.contains(Flags::WIDE_CHAR_SPACER) { + if p == click && !points.is_empty() { + click_idx = Some(points.len() - 1); + } + continue; + } + if p == click { + click_idx = Some(points.len()); + } + text.push(cell.c); + points.push(p); + } + line += 1; + } + // A leading spacer at the very end of the collected range can point one + // past the last char; treat that as no hit. + let click_idx = click_idx.filter(|&i| i < points.len())?; + Some((text, points, click_idx)) +} + +/// Double-click on a paired delimiter — bracket or quote — selects through +/// its match. `None` when the clicked char is neither, or has no match on +/// the logical line. +pub(super) fn pair_range(chars: &[char], click: usize) -> Option<(usize, usize)> { + bracket_range(chars, click).or_else(|| quote_range(chars, click)) +} + +/// Whether the `'` at `i` is a contraction apostrophe rather than a quote. +/// +/// A delimiter has whitespace, punctuation, or a line edge on at least one +/// side; a contraction is welded into a word on both (`it's`, `isn't`, +/// `won't`). Only `'` needs this — `"` and `` ` `` don't appear inside words. +fn is_contraction(chars: &[char], i: usize) -> bool { + if chars[i] != '\'' { + return false; + } + let flanked = |j: Option| { + j.and_then(|j| chars.get(j)) + .is_some_and(|c| c.is_alphanumeric()) + }; + flanked(i.checked_sub(1)) && flanked(Some(i + 1)) +} + +/// Select through a matching symmetric quote (`'`, `"`, `` ` ``). Open and +/// close are the same char, so direction comes from parity: an even count of +/// that quote before the click means it opens (match forward), odd means it +/// closes (match backward). +/// +/// Contraction apostrophes are excluded throughout — clicking one falls +/// through to the stock word, and they count neither toward the parity nor as +/// a candidate match. Without that, `it's a test, isn't it` pairs the two +/// contractions and a double-click on either selects `'s a test, isn'`. This +/// path returns before the `extends` guard that keeps other candidates +/// additive (see [`pair_is_plausible`]), so a bad match here has no safety net. +pub(super) fn quote_range(chars: &[char], click: usize) -> Option<(usize, usize)> { + let q = *chars.get(click)?; + if !SYMMETRIC_QUOTES.contains(&q) || is_contraction(chars, click) { + return None; + } + let quote_at = |i: usize| chars[i] == q && !is_contraction(chars, i); + let before = (0..click).filter(|&i| quote_at(i)).count(); + if before % 2 == 0 { + let close = (click + 1..chars.len()).find(|&i| quote_at(i))?; + Some((click, close)) + } else { + let open = (0..click).rev().find(|&i| quote_at(i))?; + Some((open, click)) + } +} + +/// Whether a candidate span is an acceptable match for its bracket pair. +/// +/// Every pair but `<>` is accepted outright — `( a )` is a legitimate subshell, +/// `[ 1 ]` a legitimate index. `<` and `>` are different: they are comparison +/// and redirection operators at least as often as delimiters, and the bracket +/// path returns before the `extends` guard that keeps every other candidate +/// additive, so a bad match here has no safety net. Require the span to hug its +/// contents, which real delimiters do (`Vec`, `
`, ``, +/// ``) and a comparison doesn't (`a < b > c`, `x <= 0 || y > 9`). +/// +/// Redirections need no special handling: `2>&1` or `cmd > out` have no +/// partner to match, so the scan already fails. +fn pair_is_plausible(chars: &[char], open: char, s: usize, e: usize) -> bool { + if open != '<' { + return true; + } + e > s + 1 && !chars[s + 1].is_whitespace() && !chars[e - 1].is_whitespace() +} + +/// Select through a matching bracket: `click` on an opener scans forward, +/// on a closer scans backward, nesting-aware. Inclusive char range covering +/// both brackets, or `None` when the clicked char isn't a bracket, the match +/// isn't on the logical line, or the span fails [`pair_is_plausible`]. +pub(super) fn bracket_range(chars: &[char], click: usize) -> Option<(usize, usize)> { + let c = *chars.get(click)?; + if let Some((open, close)) = BRACKET_PAIRS.iter().find(|(o, _)| *o == c) { + let mut depth = 0usize; + for (i, &ch) in chars.iter().enumerate().skip(click) { + if ch == *open { + depth += 1; + } else if ch == *close { + depth -= 1; + if depth == 0 { + return pair_is_plausible(chars, *open, click, i).then_some((click, i)); + } + } + } + return None; + } + if let Some((open, close)) = BRACKET_PAIRS.iter().find(|(_, c2)| *c2 == c) { + let mut depth = 0usize; + for i in (0..=click).rev() { + let ch = chars[i]; + if ch == *close { + depth += 1; + } else if ch == *open { + depth -= 1; + if depth == 0 { + return pair_is_plausible(chars, *open, i, click).then_some((i, click)); + } + } + } + } + None +} + +/// Patterns tried in specificity order after the URL detector: email, +/// scientific-notation number, file path, dotted/hyphenated identifier. +/// (URLs go through `search::url_span_at` first — it handles scheme +/// detection, wrapper stripping and trailing-punctuation trimming better +/// than a lone regex.) +fn regexes() -> &'static [Regex] { + static RE: OnceLock> = OnceLock::new(); + RE.get_or_init(|| { + [ + // Email address. + r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", + // Scientific notation (6.02e+23). + r"\b[0-9]+(?:\.[0-9]+)?[eE][+-]?[0-9]+\b", + // File path: at least two segments, or ~/.-anchored. + r"[A-Za-z0-9._+@%~-]*(?:/[A-Za-z0-9._+@%~-]+)+/?", + // Identifier chained with `.`/`-` (foo-bar.baz, 10.0.0.1). + // ASCII classes only: the regex crate's `\w` matches Han + // ideographs, which would swallow CJK text glued to a Latin + // word and defeat the script narrowing. + r"[0-9A-Za-z_]+(?:[.-][0-9A-Za-z_]+)*", + ] + .iter() + .map(|p| Regex::new(p).expect("static smart-select regex")) + .collect() + }) +} + +/// Find a semantic object containing char index `click` in `text` that +/// strictly extends the plain word selection the configured `separators` +/// would produce. Inclusive char range, or `None` to keep the stock word. +pub(super) fn smart_range( + text: &str, + chars: &[char], + click: usize, + separators: &str, +) -> Option<(usize, usize)> { + if click >= chars.len() || chars[click].is_whitespace() { + return None; + } + + // The plain word the double-click would select: the run of chars around + // the click that are neither whitespace nor configured separators. + // (Mirrors alacritty's semantic expansion over the same separator set.) + let boundary = |c: char| c.is_whitespace() || separators.contains(c); + let (mut pws, mut pwe) = (click, click); + if !boundary(chars[click]) { + while pws > 0 && !boundary(chars[pws - 1]) { + pws -= 1; + } + while pwe + 1 < chars.len() && !boundary(chars[pwe + 1]) { + pwe += 1; + } + } + // CJK chars/punctuation glue onto Latin runs (`分支name,已` is one + // separator-free run), so the word the user *means* is the same-script + // sub-run around the click. Candidates are judged against that; if + // nothing beats it, the narrowed run itself is the answer. + let (ws, we) = narrow_to_script(chars, click, pws, pwe); + // Applied only when the candidate strictly contains the (narrowed) word, + // so smart select can grow the meant word but never shrink it. + let extends = |s: usize, e: usize| s <= ws && e >= we && (s < ws || e > we); + + if let Some((s, e, _url)) = super::search::url_span_at(text, click) + && extends(s, e) + { + return Some((s, e)); + } + + // Regexes run over a bounded byte window around the click. + let byte_of: Vec = text.char_indices().map(|(b, _)| b).collect(); + let w_start = click.saturating_sub(MATCH_WINDOW); + let w_end = (click + MATCH_WINDOW).min(chars.len() - 1); + let wb_start = byte_of[w_start]; + let wb_end = byte_of[w_end] + chars[w_end].len_utf8(); + let window = &text[wb_start..wb_end]; + let click_byte = byte_of[click] - wb_start; + + for re in regexes() { + let Some(m) = re + .find_iter(window) + .find(|m| m.range().contains(&click_byte)) + else { + continue; + }; + let s = text[..wb_start + m.start()].chars().count(); + let e = text[..wb_start + m.end()].chars().count() - 1; + if extends(s, e) { + return Some((s, e)); + } + } + // No pattern beat the meant word — but if script narrowing shrank the + // raw run (Latin word glued to CJK text), that narrowed word *is* the + // correction. + ((ws, we) != (pws, pwe)).then_some((ws, we)) +} + +/// Shrink the inclusive run `[lo, hi]` to the chars sharing `click`'s script +/// class (CJK vs not) — the sub-run a double-click on mixed-script text +/// means. A no-op on single-script runs. +pub(super) fn narrow_to_script( + chars: &[char], + click: usize, + lo: usize, + hi: usize, +) -> (usize, usize) { + let class = is_cjk(chars[click]); + let mut s = click; + while s > lo && is_cjk(chars[s - 1]) == class { + s -= 1; + } + let mut e = click; + while e < hi && is_cjk(chars[e + 1]) == class { + e += 1; + } + (s, e) +} + +#[cfg(test)] +mod tests { + use super::*; + use alacritty_terminal::event::VoidListener; + + /// alacritty's stock separator set, which is also the config default. + const SEPS: &str = ",│`|:\"' ()[]{}<>\t"; + + /// In production the jieba table builds lazily off-thread and the racing + /// click settles for the whole run; tests want it ready up front. No-op on + /// macOS, where CFStringTokenizer needs no warm-up. + fn ensure_segmenter() { + #[cfg(not(target_os = "macos"))] + let _ = JIEBA.get_or_init(jieba_rs::Jieba::new); + } + + fn range(text: &str, click: usize) -> Option<(usize, usize)> { + let chars: Vec = text.chars().collect(); + smart_range(text, &chars, click, SEPS) + } + + // ---- Grid-level tests ---- + // + // The functions above operate on a plain `&str`; everything below drives a + // real `Term` through the VT parser instead, because the grid is where the + // index arithmetic actually gets hard: wide CJK glyphs occupy two cells + // (the second a spacer), soft-wrapped rows have to be stitched back into + // one logical line, and OSC 8 runs can straddle both. + + /// A `cols`×`rows` terminal with `input` fed through the VT parser, so the + /// grid holds exactly what a PTY would have produced. + fn term_with(cols: usize, rows: usize, input: &str) -> Term { + let config = alacritty_terminal::term::Config { + semantic_escape_chars: SEPS.to_string(), + ..Default::default() + }; + let mut term = Term::new( + config, + &crate::terminal::size::TermSize::new(cols, rows), + VoidListener, + ); + let mut parser: alacritty_terminal::vte::ansi::Processor = + alacritty_terminal::vte::ansi::Processor::new(); + parser.advance(&mut term, input.as_bytes()); + term + } + + /// The text a double-click at `(line, col)` would select, or `None` when + /// no smart candidate applies and the caller keeps the stock word. + fn grid_select(term: &Term, line: i32, col: usize) -> Option { + let r = grid_smart_range(term, Point::new(Line(line), Column(col)))?; + Some(term.bounds_to_string(r.start, r.end)) + } + + /// Column of the first occurrence of `needle` on row 0 — keeps the tests + /// from hard-coding offsets that shift when the fixture text changes. + fn col_of(row: &str, needle: &str) -> usize { + row.find(needle).expect("needle in fixture") + } + + #[test] + fn osc8_hyperlink_selects_the_declared_extent_not_the_visible_word() { + // The link text has a space in it: only the OSC 8 run knows where the + // link really ends, which is the whole point of checking it first. + let term = term_with( + 40, + 3, + "go \x1b]8;;https://example.com/x\x1b\\click here\x1b]8;;\x1b\\ now", + ); + let line = "go click here now"; + assert_eq!( + grid_select(&term, 0, col_of(line, "here")).as_deref(), + Some("click here"), + ); + // A cell outside the run must not pick the link up. + assert_ne!( + grid_select(&term, 0, col_of(line, "now")).as_deref(), + Some("click here"), + ); + } + + #[test] + fn osc8_hyperlink_follows_a_soft_wrap() { + // 30 chars of link text in 20 columns: the run fills row 0 and spills + // 10 cells onto row 1. Stopping at the row edge would truncate the + // selection to the visible first half. + let term = term_with( + 20, + 4, + "\x1b]8;;https://e.com\x1b\\aaaaaaaaaabbbbbbbbbbcccccccccc\x1b]8;;\x1b\\", + ); + let whole = "aaaaaaaaaabbbbbbbbbbcccccccccc"; + // Click on the wrapped remainder (row 1) — walks backwards over the wrap. + assert_eq!(grid_select(&term, 1, 2).as_deref(), Some(whole)); + // ...and from the first row, walking forwards over it. + assert_eq!(grid_select(&term, 0, 3).as_deref(), Some(whole)); + } + + #[test] + fn soft_wrapped_url_is_stitched_back_into_one_selection() { + // No OSC 8 here — the URL is recovered from the joined logical line, + // so this exercises `logical_line_at`'s wrap walk rather than the + // hyperlink path. + let term = term_with(20, 4, "see https://example.com/deep/path here"); + let whole = "https://example.com/deep/path"; + // Row 0 holds "see https://example.", row 1 the "com/deep/path here" + // remainder. Clicking the head joins forwards over the wrap... + assert_eq!( + grid_select(&term, 0, col_of("see https://example", "example")).as_deref(), + Some(whole), + ); + // ...and clicking the tail joins backwards, which is the direction a + // click on a continuation row depends on entirely. + assert_eq!( + grid_select(&term, 1, col_of("com/deep/path here", "deep")).as_deref(), + Some(whole), + ); + } + + #[test] + fn wide_glyph_and_its_spacer_resolve_to_the_same_word() { + // Each Han char occupies two cells; the second carries WIDE_CHAR_SPACER + // and has no `c` of its own. Clicking either half must select the same + // segmented word — an off-by-one in the spacer branch shows up here. + ensure_segmenter(); + let term = term_with(40, 3, "run 北京欢迎你 done"); + // "run " is 4 cells, then 北 at col 4 (spacer at 5), 京 at 6 (spacer 7). + let expected = grid_select(&term, 0, 4); + assert_eq!(expected.as_deref(), Some("北京"), "click on 北"); + assert_eq!( + grid_select(&term, 0, 5).as_deref(), + expected.as_deref(), + "spacer of 北" + ); + assert_eq!( + grid_select(&term, 0, 6).as_deref(), + Some("北京"), + "click on 京" + ); + assert_eq!( + grid_select(&term, 0, 7).as_deref(), + Some("北京"), + "spacer of 京" + ); + } + + #[test] + fn wide_glyph_wrapping_to_the_next_row_keeps_its_word_intact() { + // An odd column count leaves one cell at the end of the row: the wide + // char can't fit, so alacritty pads with LEADING_WIDE_CHAR_SPACER and + // moves the glyph to the next row. The logical line must still join. + ensure_segmenter(); + let term = term_with(9, 4, "abcdefgh北京欢迎你"); + // 北 is pushed to row 1 col 0 by the leading spacer at row 0 col 8. + assert_eq!(grid_select(&term, 1, 0).as_deref(), Some("北京")); + } + + #[test] + fn click_past_the_last_column_yields_no_range() { + let term = term_with(10, 2, "hello"); + assert!(grid_smart_range(&term, Point::new(Line(0), Column(10))).is_none()); + assert!(grid_smart_range(&term, Point::new(Line(0), Column(99))).is_none()); + } + + fn selected(text: &str, click: usize) -> Option { + let chars: Vec = text.chars().collect(); + range(text, click).map(|(s, e)| chars[s..=e].iter().collect()) + } + + #[test] + fn url_expands_past_scheme_colon() { + let text = "fetch https://example.com/a/b?q=1 done"; + // Click inside "example" — the plain word starts after the `:` + // separator; smart select recovers the whole URL. + let click = text.find("example").unwrap(); + assert_eq!( + selected(text, click).as_deref(), + Some("https://example.com/a/b?q=1") + ); + } + + #[test] + fn url_trailing_comma_excluded() { + let text = "see https://a.com/x, then"; + let click = text.find("a.com").unwrap(); + assert_eq!(selected(text, click).as_deref(), Some("https://a.com/x")); + } + + #[test] + fn email_only_fires_when_it_extends_the_word() { + // With the default separators the plain word already covers the whole + // address (`@` and `.` are word chars) — the candidate equals the word + // and must be rejected, keeping the stock selection. + let text = "author:dev@example.com pushed"; + let click = text.find("example").unwrap(); + assert_eq!(range(text, click), None); + // With `@` configured as a separator, the email regex reassembles the + // full address across it. + let chars: Vec = text.chars().collect(); + let got = smart_range(text, &chars, click, ",@:() "); + let (s, e) = got.expect("email should match"); + let sel: String = chars[s..=e].iter().collect(); + assert_eq!(sel, "dev@example.com"); + } + + #[test] + fn plain_word_yields_none() { + let text = "just some words"; + let click = text.find("some").unwrap(); + assert_eq!(range(text, click), None); + } + + #[test] + fn path_across_quote_boundary_stays_plain() { + // The whole path is one plain word already (no separators inside); + // candidates equal to the word are rejected → stock selection. + let text = "cat /usr/local/bin/tool"; + let click = text.find("local").unwrap(); + assert_eq!(range(text, click), None); + } + + #[test] + fn path_glued_to_colon_expands() { + // `error:/tmp/x/y` — the word starts after `:`; the path regex + // must not leak left past the colon but the URL/identifier ones + // must not shrink it either. Path candidate is `/tmp/x/y`, equal + // to the plain word → None. Click on `error` side: word `error`. + let text = "error:/tmp/x/y"; + let click = text.find("tmp").unwrap(); + assert_eq!(range(text, click), None); + } + + #[test] + fn whitespace_click_yields_none() { + assert_eq!(range("a b", 1), None); + } + + #[test] + fn scientific_notation_with_custom_separators() { + // With `.` and `+` configured as separators (finer-grained + // boundaries), the sci-notation regex reassembles the number. + let text = "n = 6.02e+23 mol"; + let chars: Vec = text.chars().collect(); + let click = text.find("02").unwrap(); + let got = smart_range(text, &chars, click, ",.+():"); + let (s, e) = got.expect("sci notation should match"); + let sel: String = chars[s..=e].iter().collect(); + assert_eq!(sel, "6.02e+23"); + } + + #[test] + fn identifier_with_custom_separators() { + // Fine-grained separators split `foo-bar.baz`; the identifier + // regex restores the full dotted chain. + let text = "run foo-bar.baz now"; + let chars: Vec = text.chars().collect(); + let click = text.find("bar").unwrap(); + let got = smart_range(text, &chars, click, ",.-():"); + let (s, e) = got.expect("identifier should match"); + let sel: String = chars[s..=e].iter().collect(); + assert_eq!(sel, "foo-bar.baz"); + } + + #[test] + fn latin_word_glued_directly_to_han_narrows_without_punctuation() { + // No separator or punctuation between the scripts at all — the + // identifier regex must not reassemble the mixed run (`\w` would). + let text = "已合并到main分支"; + let chars: Vec = text.chars().collect(); + let click = chars.iter().position(|&c| c == 'm').unwrap(); + let (s, e) = smart_range(text, &chars, click, SEPS).expect("narrowed word"); + let sel: String = chars[s..=e].iter().collect(); + assert_eq!(sel, "main"); + } + + #[test] + fn latin_word_glued_to_cjk_narrows_to_the_latin_run() { + // `,` and `已` are not separators, so the raw run is + // `worktree-feat-smart-select,已`; the meant word is the Latin part. + let text = "分支 worktree-feat-smart-select,已 rebase"; + let chars: Vec = text.chars().collect(); + let click = chars.iter().position(|&c| c == 'w').unwrap() + 10; + let (s, e) = smart_range(text, &chars, click, SEPS).expect("narrowed word"); + let sel: String = chars[s..=e].iter().collect(); + assert_eq!(sel, "worktree-feat-smart-select"); + } + + #[test] + fn symmetric_quotes_pair_by_parity() { + let chars: Vec = r#"echo 'a,b' "c d" x"#.chars().collect(); + // First ' opens (0 quotes before), second closes. + assert_eq!(quote_range(&chars, 5), Some((5, 9))); + assert_eq!(quote_range(&chars, 9), Some((5, 9))); + // Double quotes pair independently of the single ones. + assert_eq!(quote_range(&chars, 11), Some((11, 15))); + assert_eq!(quote_range(&chars, 15), Some((11, 15))); + // An unmatched opener finds nothing. + let chars: Vec = "say 'oops".chars().collect(); + assert_eq!(quote_range(&chars, 4), None); + // Non-quote chars never match. + assert_eq!(quote_range(&chars, 1), None); + } + + #[test] + fn contraction_apostrophes_do_not_pair() { + let chars: Vec = "it's a test, isn't it".chars().collect(); + // Clicking either contraction falls through to the stock word. + assert_eq!(quote_range(&chars, 2), None); + assert_eq!(quote_range(&chars, 16), None); + // And the whole line yields no smart candidate at all, so the + // double-click keeps alacritty's `it's`. + let text = "it's a test, isn't it"; + assert_eq!(range(text, 2), None); + } + + #[test] + fn contractions_do_not_skew_a_real_quote() { + // The apostrophes inside the quoted span must not flip the parity or + // steal the match from the genuine delimiters. + let chars: Vec = "echo 'it isn't so' done".chars().collect(); + let open = 5; + let close = chars.iter().rposition(|&c| c == '\'').unwrap(); + assert_eq!(quote_range(&chars, open), Some((open, close))); + assert_eq!(quote_range(&chars, close), Some((open, close))); + } + + #[test] + fn trailing_apostrophe_still_closes() { + // `dogs'` — the apostrophe has a word char only on its left, so it is + // a delimiter, not a contraction. + let chars: Vec = "the 'dogs' bark".chars().collect(); + assert_eq!(quote_range(&chars, 4), Some((4, 9))); + assert_eq!(quote_range(&chars, 9), Some((4, 9))); + } + + #[test] + fn directional_cjk_quotes_pair_like_brackets() { + let chars: Vec = "他说“你好”了".chars().collect(); + assert_eq!(bracket_range(&chars, 2), Some((2, 5))); + assert_eq!(bracket_range(&chars, 5), Some((2, 5))); + } + + #[test] + fn fullwidth_brackets_pair() { + let chars: Vec = "说(worktree 分支)好".chars().collect(); + let open = chars.iter().position(|&c| c == '(').unwrap(); + let close = chars.iter().position(|&c| c == ')').unwrap(); + assert_eq!(bracket_range(&chars, open), Some((open, close))); + assert_eq!(bracket_range(&chars, close), Some((open, close))); + let chars: Vec = "书名《三体》完".chars().collect(); + assert_eq!(bracket_range(&chars, 2), Some((2, 5))); + } + + #[test] + fn bracket_forward_and_backward_with_nesting() { + let chars: Vec = "f(a(b)c) x".chars().collect(); + assert_eq!(bracket_range(&chars, 1), Some((1, 7))); + assert_eq!(bracket_range(&chars, 7), Some((1, 7))); + assert_eq!(bracket_range(&chars, 3), Some((3, 5))); + assert_eq!(bracket_range(&chars, 0), None); + } + + #[test] + fn angle_brackets_pair_only_when_they_hug_their_contents() { + // Real delimiters: generics, tags, placeholders, bracketed addresses. + for (text, want) in [ + ("let v: Vec = x", ""), + ("
hi", "
"), + ("usage: tty7 [opts]", ""), + ("From: Jo ok", ""), + ("map: HashMap here", ""), + ] { + let chars: Vec = text.chars().collect(); + let click = chars.iter().position(|&c| c == '<').unwrap(); + let (s, e) = bracket_range(&chars, click).unwrap_or_else(|| panic!("{text}")); + let got: String = chars[s..=e].iter().collect(); + assert_eq!(got, want, "{text}"); + } + // Comparison operators must not pair across half a line — a space just + // inside either end is the tell. + for text in [ + "if a < b then c > d", + "awk '{ if ($1 > 100 && $2 < 5) print }'", + "WHERE a < 10 AND b > 20", + "empty <> pair", + ] { + let chars: Vec = text.chars().collect(); + for (i, &c) in chars.iter().enumerate() { + if c == '<' || c == '>' { + assert_eq!(bracket_range(&chars, i), None, "{text} at {i}"); + } + } + } + // Redirections never had a partner to match in the first place. + for text in ["cargo build 2>&1 | tee out", "grep -rn foo src/ > /tmp/o"] { + let chars: Vec = text.chars().collect(); + for (i, &c) in chars.iter().enumerate() { + if c == '<' || c == '>' { + assert_eq!(bracket_range(&chars, i), None, "{text} at {i}"); + } + } + } + } + + #[test] + fn unmatched_bracket_yields_none() { + let chars: Vec = "f(a".chars().collect(); + assert_eq!(bracket_range(&chars, 1), None); + } + + #[test] + fn cjk_segmentation_selects_a_dictionary_word_not_the_whole_run() { + ensure_segmenter(); + let text = "run 北京欢迎你 done"; + let chars: Vec = text.chars().collect(); + let click = chars.iter().position(|&c| c == '京').unwrap(); + let (s, e) = cjk_word_range(text, click).expect("segmented range"); + let sel: String = chars[s..=e].iter().collect(); + assert_eq!(sel, "北京"); + } + + #[test] + fn cjk_segmentation_survives_surrogate_pairs_before_the_click() { + // The emoji is two UTF-16 units: a tokenizer offset table that counted + // chars instead would shift every index after it. Both backends agree + // on `世界`, so a skewed mapping shows up as a different token. + ensure_segmenter(); + for text in ["你好世界", "🙂 你好世界", "🙂🙂🙂 你好世界"] { + let chars: Vec = text.chars().collect(); + let click = chars.iter().position(|&c| c == '世').unwrap(); + let (s, e) = cjk_word_range(text, click).expect("segmented range"); + let sel: String = chars[s..=e].iter().collect(); + assert_eq!(sel, "世界", "{text:?} segmented wrong"); + } + } + + #[test] + fn cjk_punctuation_is_its_own_token() { + ensure_segmenter(); + let text = "比赛,天气"; + let chars: Vec = text.chars().collect(); + let click = chars.iter().position(|&c| c == ',').unwrap(); + let (s, e) = cjk_word_range(text, click).expect("segmented range"); + let sel: String = chars[s..=e].iter().collect(); + assert_eq!(sel, ","); + } + + /// Japanese must not be run through jieba's Chinese dictionary — it cuts + /// kana into single characters, which is worse than leaving the run whole. + /// macOS hands it to CFStringTokenizer, which segments it properly. + #[test] + fn japanese_is_not_shredded_into_single_kana() { + ensure_segmenter(); + let text = "日本語の文章です"; + let chars: Vec = text.chars().collect(); + let click = chars.iter().position(|&c| c == 'で').unwrap(); + // macOS yields a real token, never a lone kana; elsewhere the run + // comes back unsegmented and the caller selects all of it. + if let Some((s, e)) = cjk_word_range(text, click) { + let sel: String = chars[s..=e].iter().collect(); + assert_eq!(sel, "です"); + } + } + + #[test] + fn is_cjk_covers_han_kana_hangul_fullwidth() { + for c in ['中', 'あ', 'ア', '한', ',', '('] { + assert!(is_cjk(c), "{c} should be CJK"); + } + for c in ['a', '1', '-', '/', 'é'] { + assert!(!is_cjk(c), "{c} should not be CJK"); + } + } +} diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 7bf44e45..2fc21a5f 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -2849,7 +2849,9 @@ impl TerminalView { self.editor_drag_word = None; } 2 => { - self.cmd.select_word_at(idx); + let cfg = cx.global::(); + let (seps, smart) = (cfg.word_separators.clone(), cfg.smart_select); + self.cmd.select_word_at(idx, &seps, smart); // Drag now grows the selection by whole words around this one. self.editor_selecting = true; self.editor_drag_word = self.cmd.selection(); @@ -2880,7 +2882,9 @@ impl TerminalView { }; // A drag begun on a double-click extends by whole words; otherwise by char. if let Some((s, e)) = self.editor_drag_word { - self.cmd.extend_word_to(s, e, idx); + let cfg = cx.global::(); + let (seps, smart) = (cfg.word_separators.clone(), cfg.smart_select); + self.cmd.extend_word_to(s, e, idx, &seps, smart); } else { self.cmd.extend_to(idx); } @@ -3637,18 +3641,51 @@ impl TerminalView { row: usize, left: bool, clicks: usize, + shift: bool, cx: &mut Context, ) { + let smart = cx.global::().smart_select; let mut term = self.terminal.term.lock(); let display_offset = term.grid().display_offset() as i32; let point = Point::new(Line(row as i32 - display_offset), Column(col)); let side = if left { Side::Left } else { Side::Right }; + // Shift+click extends the existing selection to the click instead of + // starting over (à la iTerm2). A plain click always leaves a + // collapsed Simple selection behind, so the anchor is wherever the + // last gesture ended. + if shift && clicks == 1 && term.selection.is_some() { + if let Some(sel) = term.selection.as_mut() { + sel.update(point, side); + } + drop(term); + self.selecting = true; + cx.notify(); + return; + } let ty = match clicks { 2 => SelectionType::Semantic, // word n if n >= 3 => SelectionType::Lines, _ => SelectionType::Simple, }; - term.selection = Some(Selection::new(ty, point, side)); + let mut selection = Selection::new(ty, point, side); + // Double-click smart selection: a URL / path / email / bracket pair / + // CJK word containing the clicked word replaces the plain word span. + // Boundary-flanked candidates anchor a Semantic selection (keeping + // the drag gesture word-wise); exact ones use Simple so alacritty + // can't re-expand the endpoints past the smart boundary. + if clicks == 2 + && smart + && let Some(r) = super::smart_select::grid_smart_range(&term, point) + { + let ty = if r.exact { + SelectionType::Simple + } else { + SelectionType::Semantic + }; + selection = Selection::new(ty, r.start, Side::Left); + selection.update(r.end, Side::Right); + } + term.selection = Some(selection); drop(term); self.selecting = true; cx.notify(); @@ -6908,7 +6945,7 @@ mod gpui_tests { let drag_hello = |cx: &mut TestAppContext| { window .update(cx, |view, _, cx| { - view.on_select_start(0, 0, true, 1, cx); + view.on_select_start(0, 0, true, 1, false, cx); view.on_select_update(4, 0, false, cx); view.on_select_end(cx); }) @@ -6973,7 +7010,7 @@ mod gpui_tests { .update(cx, |view, window, cx| { // Mouse-select "hello" (copy-on-select is off by default, so // the selection survives mouse-up). - view.on_select_start(0, 0, true, 1, cx); + view.on_select_start(0, 0, true, 1, false, cx); view.on_select_update(4, 0, false, cx); view.on_select_end(cx); assert!(view.has_selection(), "the drag must leave a selection"); diff --git a/src/ui/app.rs b/src/ui/app.rs index fdcab0dc..e8908fc1 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1992,6 +1992,10 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.copy_on_select = on); } + pub(crate) fn set_smart_select(&mut self, on: bool, cx: &mut Context) { + self.update_config(cx, |cfg| cfg.smart_select = on); + } + pub(crate) fn set_startup_mode( &mut self, mode: crate::core::config::StartupMode, diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 02ad4ae3..1e237067 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -177,6 +177,11 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: "Forward SSH loopback links", keywords: "ssh remote port tunnel localhost forward", }, + SearchEntry { + section: Terminal, + title: "Smart selection", + keywords: "double click word url path select semantic", + }, SearchEntry { section: Terminal, title: "Copy on select", @@ -2763,6 +2768,7 @@ impl Tty7App { let clip_trim = cfg.clipboard_trim_trailing_spaces; let copy_on_select = cfg.copy_on_select; let mouse_reporting = cfg.mouse_reporting; + let smart_select = cfg.smart_select; let bell = cfg.bell; // Map the persisted threshold onto its preset radio index (nearest slot // for any off-preset value a hand-edit might leave). @@ -2848,6 +2854,10 @@ impl Tty7App { .checked(mouse_reporting) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_mouse_reporting(*on, cx))) .into_any_element(); + let smart_select_switch = Switch::new("term-smart-select") + .checked(smart_select) + .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_smart_select(*on, cx))) + .into_any_element(); let bell_idx = match bell { BellMode::None => 0, BellMode::Visual => 1, @@ -2948,6 +2958,12 @@ impl Tty7App { mouse_report_switch, cx, )) + .child(self.settings_row( + "Smart selection", + "Double-click selects the whole URL, file path, email, or bracket pair under the cursor.", + smart_select_switch, + cx, + )) .when_some(option_alt_row, |v, row| { v.child(self.section_rule(cx)) .child(self.section_header("Keyboard", cx))