From 88c694df9e51fb7145b7a66687846f542c4e481b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:55:13 +0800 Subject: [PATCH 1/5] feat(terminal): smart double-click selection with CJK segmentation - Double-click expands to the whole URL, email, file path, sci-notation number, identifier chain, OSC 8 hyperlink run, or matching bracket/quote pair containing the clicked word. Candidates only ever grow the plain word selection, so nothing regresses below the stock word behavior. - Chinese segments with jieba's dictionary on all platforms; Kana/Hangul use CFStringTokenizer on macOS. The table builds lazily on a background thread so the first double-click never pays the cost. - Latin words glued to CJK text narrow to the clicked script's sub-run instead of selecting the mixed blob. - Bracket pairs (ASCII and full-width) and symmetric quotes (parity matched) select through their match, in the grid and prompt editor alike. - Shift+click extends the existing grid selection instead of restarting. - Word separators are configurable (word_separators, shared by grid and prompt editor); new 'Smart selection' toggle in Settings > Terminal. --- Cargo.lock | 179 +++++++- Cargo.toml | 10 + docs/features.md | 1 + docs/features.zh-CN.md | 1 + src/core/config.rs | 22 + src/terminal/cmd_editor.rs | 105 ++++- src/terminal/element.rs | 2 +- src/terminal/mod.rs | 1 + src/terminal/remote.rs | 1 + src/terminal/smart_select.rs | 808 +++++++++++++++++++++++++++++++++++ src/terminal/view.rs | 48 ++- src/ui/app.rs | 4 + src/ui/settings.rs | 16 + 13 files changed, 1173 insertions(+), 25 deletions(-) create mode 100644 src/terminal/smart_select.rs 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..20b739d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,13 @@ 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" +# Dictionary-based Chinese word segmentation for double-click selection +# (`terminal::smart_select`). The default dict ships deflate-compressed inside +# the binary (~1.8 MB); the table is built lazily on a background thread. +jieba-rs = "0.10" smol.workspace = true smallvec.workspace = true serde = { workspace = true } @@ -171,6 +178,9 @@ 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. +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 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..ec551f8a 100644 --- a/src/terminal/cmd_editor.rs +++ b/src/terminal/cmd_editor.rs @@ -222,24 +222,68 @@ 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. + pub fn word_bounds(&self, idx: usize, separators: &str) -> (usize, usize) { let idx = idx.min(self.chars.len()); + // 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 (jieba for + // Chinese, the OS tokenizer for Kana/Hangul on macOS) 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) + } + + /// [`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. + fn plain_word_bounds(&self, idx: usize, separators: &str) -> (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 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) { + let (s, e) = self.word_bounds(idx, separators); self.anchor = Some(s); self.cursor = e; } @@ -249,8 +293,14 @@ 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, + ) { + let (ws, we) = self.plain_word_bounds(idx.min(self.chars.len()), separators); if we >= anchor_end { self.anchor = Some(anchor_start); self.cursor = we; @@ -573,15 +623,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); // 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); // on "a" + assert_eq!(e.selected_text().as_deref(), Some("a")); + e.select_word_at(7, SEPS); // on the comma itself + assert_eq!(e.selected_text().as_deref(), Some(",")); + e.select_word_at(5, SEPS); // 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); + 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 +664,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); 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); 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); 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); assert_eq!(e.selected_text().as_deref(), Some("git push")); } @@ -673,15 +742,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); // 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); 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); // 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..14968390 --- /dev/null +++ b/src/terminal/smart_select.rs @@ -0,0 +1,808 @@ +//! 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 the OS tokenizer (dictionary-based on macOS) + // instead of selecting the entire unbroken run. + 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 + ) +} + +/// Whether the char routes to jieba: Han ideographs and CJK punctuation, +/// where jieba's Chinese dictionary beats the system tokenizer. Kana and +/// Hangul stay with the platform tokenizer (jieba has no Japanese/Korean +/// dictionary and would return the whole run). +fn prefers_jieba(c: char) -> bool { + matches!( + u32::from(c), + 0x3400..=0x9FFF // Han ideographs (unified + ext A) + | 0xF900..=0xFAFF // compatibility ideographs + | 0x20000..=0x3134F // ideograph extensions + | 0x3000..=0x303F // CJK punctuation + | 0xFF00..=0xFFEF // full-width forms + ) +} + +/// The jieba segmenter, built once. Building the 350k-entry table takes a +/// few hundred ms, hence [`warm`] to move that off the first double-click. +static JIEBA: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Kick off dictionary construction on a background thread (idempotent). +/// Called when a terminal view is created, so the table is ready long before +/// the first CJK double-click; a click that races it just blocks briefly. +pub(crate) 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`. Chinese goes through jieba (full +/// dictionary, all platforms); Kana/Hangul fall back to the platform +/// tokenizer (CFStringTokenizer on macOS), and elsewhere the caller keeps +/// the whole run. +pub(super) fn cjk_word_range(text: &str, click: usize) -> Option<(usize, usize)> { + let chars: Vec = text.chars().collect(); + let c = *chars.get(click)?; + if prefers_jieba(c) + && let Some(r) = jieba_word_range(&chars, click) + { + return Some(r); + } + #[cfg(target_os = "macos")] + if let Some(r) = tokenizer::word_range(text, click) { + return Some(r); + } + None +} + +/// Segment the contiguous CJK run around `click` with jieba and return the +/// token containing it. +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; + } + let run: String = chars[rs..=re].iter().collect(); + let jieba = JIEBA.get_or_init(jieba_rs::Jieba::new); + // 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)) +} + +/// 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). Apostrophes in prose skew the parity, but a +/// missing match just falls through to `None`. +pub(super) fn quote_range(chars: &[char], click: usize) -> Option<(usize, usize)> { + let q = *chars.get(click)?; + if !SYMMETRIC_QUOTES.contains(&q) { + return None; + } + let before = chars[..click].iter().filter(|&&c| c == q).count(); + if before % 2 == 0 { + let close = (click + 1..chars.len()).find(|&i| chars[i] == q)?; + Some((click, close)) + } else { + let open = (0..click).rev().find(|&i| chars[i] == q)?; + Some((open, click)) + } +} + +/// 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 or the +/// match isn't on the logical line. +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 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 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::*; + + /// alacritty's stock separator set, which is also the config default. + const SEPS: &str = ",│`|:\"' ()[]{}<>\t"; + + fn range(text: &str, click: usize) -> Option<(usize, usize)> { + let chars: Vec = text.chars().collect(); + smart_range(text, &chars, click, SEPS) + } + + 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 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 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() { + 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 before the run must not skew the char↔offset mapping. + 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, "你好"); + } + + #[test] + fn cjk_punctuation_is_its_own_token() { + 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, ","); + } + + #[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..8e047cd5 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -711,6 +711,9 @@ impl TerminalView { window: &mut Window, cx: &mut Context, ) -> anyhow::Result { + // Build the CJK segmentation dictionary off-thread now, so the first + // double-click on Chinese text doesn't pay the ~0.5s table build. + super::smart_select::warm(); // Provisional size; corrected on the first prepaint once we can measure. // The PTY lives in the daemon now. On session restore (`restore_pane`), // re-`attach` to the still-running pane so its process + scrollback come @@ -2849,7 +2852,8 @@ impl TerminalView { self.editor_drag_word = None; } 2 => { - self.cmd.select_word_at(idx); + let seps = &cx.global::().word_separators; + self.cmd.select_word_at(idx, seps); // Drag now grows the selection by whole words around this one. self.editor_selecting = true; self.editor_drag_word = self.cmd.selection(); @@ -2880,7 +2884,8 @@ 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 seps = &cx.global::().word_separators; + self.cmd.extend_word_to(s, e, idx, seps); } else { self.cmd.extend_to(idx); } @@ -3637,18 +3642,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 +6946,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 +7011,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 a4fb4435..c9b7ad0c 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)) From ceb303aa6ccee23096342eb30978dd93aa32334d Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:41:58 +0800 Subject: [PATCH 2/5] fix(terminal): prefer the OS tokenizer over jieba for CJK selection jieba's dictionary costs ~55 MB resident and ~130 ms to build, and it was built eagerly on every terminal-view creation regardless of the `smart_select` setting or whether the user ever selects CJK text. macOS already ships a Chinese lexicon in CFStringTokenizer that matches jieba on most prose, is locale-independent (identical output for current / NULL / zh_CN / en_US), and segments Japanese and Korean properly where jieba shreds them into single characters. Make the OS tokenizer the primary path and keep jieba only as the fallback for platforms with no such API: - gate the dependency behind `cfg(not(target_os = "macos"))` so the embedded dictionary isn't even linked into the macOS build - never warm eagerly; the first CJK double-click kicks the build off in the background and settles for the unsegmented run, so the UI thread never blocks on it - skip jieba for runs containing kana or hangul, where selecting the whole run beats per-character tokens Also honor `Config::smart_select` in the prompt's command editor, which did bracket pairing, CJK segmentation and mixed-script narrowing even with the Settings toggle off. --- Cargo.toml | 17 +++-- src/terminal/cmd_editor.rs | 53 ++++++++------ src/terminal/smart_select.rs | 133 ++++++++++++++++++++++++----------- src/terminal/view.rs | 13 ++-- 4 files changed, 140 insertions(+), 76 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 20b739d5..89c0ed7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,10 +24,6 @@ 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" -# Dictionary-based Chinese word segmentation for double-click selection -# (`terminal::smart_select`). The default dict ships deflate-compressed inside -# the binary (~1.8 MB); the table is built lazily on a background thread. -jieba-rs = "0.10" smol.workspace = true smallvec.workspace = true serde = { workspace = true } @@ -179,7 +175,8 @@ winresource = "0.1" # 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. +# 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"] } @@ -187,6 +184,16 @@ objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSResponder", " # `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/src/terminal/cmd_editor.rs b/src/terminal/cmd_editor.rs index ec551f8a..971bf46f 100644 --- a/src/terminal/cmd_editor.rs +++ b/src/terminal/cmd_editor.rs @@ -228,8 +228,15 @@ impl CmdEditor { /// 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. - pub fn word_bounds(&self, idx: usize, separators: &str) -> (usize, usize) { + /// + /// `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 @@ -238,9 +245,8 @@ impl CmdEditor { 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 (jieba for - // Chinese, the OS tokenizer for Kana/Hangul on macOS) instead of - // selecting the whole unbroken run. + // 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) { @@ -249,13 +255,15 @@ impl CmdEditor { return (s, e + 1); } } - self.plain_word_bounds(idx, separators) + 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. - fn plain_word_bounds(&self, idx: usize, separators: &str) -> (usize, usize) { + /// `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() @@ -274,7 +282,7 @@ impl CmdEditor { } // 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 idx < e { + if smart && idx < e { let (ns, ne) = super::smart_select::narrow_to_script(&self.chars, idx, s, e - 1); return (ns, ne + 1); } @@ -282,8 +290,8 @@ impl CmdEditor { } /// Select the word containing char index `idx` (see [`Self::word_bounds`]). - pub fn select_word_at(&mut self, idx: usize, separators: &str) { - let (s, e) = self.word_bounds(idx, separators); + 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; } @@ -299,8 +307,9 @@ impl CmdEditor { anchor_end: usize, idx: usize, separators: &str, + smart: bool, ) { - let (ws, we) = self.plain_word_bounds(idx.min(self.chars.len()), separators); + 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; @@ -629,7 +638,7 @@ mod tests { #[test] fn select_word_and_all() { let mut e = ed("git push origin", 6); - e.select_word_at(6, SEPS); // 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))); @@ -639,15 +648,15 @@ mod tests { 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); // on "a" + e.select_word_at(6, SEPS, true); // on "a" assert_eq!(e.selected_text().as_deref(), Some("a")); - e.select_word_at(7, SEPS); // on the comma itself + 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); // on the opening quote: pairs to the close + 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); + e.select_word_at(8, SEPS, true); assert_eq!(e.selected_text().as_deref(), Some("./a-b/c_d.txt")); } @@ -664,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, SEPS); + 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, SEPS); + 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, SEPS); + 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, SEPS); + e.extend_word_to(s, a, 1, SEPS, true); assert_eq!(e.selected_text().as_deref(), Some("git push")); } @@ -742,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, SEPS); // 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, SEPS); + 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, SEPS); // 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/smart_select.rs b/src/terminal/smart_select.rs index 14968390..291ba194 100644 --- a/src/terminal/smart_select.rs +++ b/src/terminal/smart_select.rs @@ -101,8 +101,8 @@ pub(super) fn grid_smart_range( } // 3) CJK prose has no separators to walk — the whole clause is one run — - // so segment it with the OS tokenizer (dictionary-based on macOS) - // instead of selecting the entire unbroken 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) { @@ -129,29 +129,34 @@ pub(super) fn is_cjk(c: char) -> bool { ) } -/// Whether the char routes to jieba: Han ideographs and CJK punctuation, -/// where jieba's Chinese dictionary beats the system tokenizer. Kana and -/// Hangul stay with the platform tokenizer (jieba has no Japanese/Korean -/// dictionary and would return the whole run). -fn prefers_jieba(c: char) -> bool { +/// 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), - 0x3400..=0x9FFF // Han ideographs (unified + ext A) - | 0xF900..=0xFAFF // compatibility ideographs - | 0x20000..=0x3134F // ideograph extensions - | 0x3000..=0x303F // CJK punctuation - | 0xFF00..=0xFFEF // full-width forms + 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. Building the 350k-entry table takes a -/// few hundred ms, hence [`warm`] to move that off the first double-click. -static JIEBA: std::sync::OnceLock = std::sync::OnceLock::new(); +/// 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). -/// Called when a terminal view is created, so the table is ready long before -/// the first CJK double-click; a click that races it just blocks briefly. -pub(crate) fn warm() { +/// 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(|| { @@ -160,28 +165,30 @@ pub(crate) fn warm() { }); } -/// Dictionary-based word bounds for CJK text: the inclusive char range of -/// the word containing char index `click`. Chinese goes through jieba (full -/// dictionary, all platforms); Kana/Hangul fall back to the platform -/// tokenizer (CFStringTokenizer on macOS), and elsewhere the caller keeps -/// the whole run. +/// 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)> { - let chars: Vec = text.chars().collect(); - let c = *chars.get(click)?; - if prefers_jieba(c) - && let Some(r) = jieba_word_range(&chars, click) - { - return Some(r); - } #[cfg(target_os = "macos")] - if let Some(r) = tokenizer::word_range(text, click) { - return Some(r); + { + tokenizer::word_range(text, click) + } + #[cfg(not(target_os = "macos"))] + { + let chars: Vec = text.chars().collect(); + chars.get(click)?; + jieba_word_range(&chars, click) } - None } /// Segment the contiguous CJK run around `click` with jieba and return the -/// token containing it. +/// 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]) { @@ -191,8 +198,19 @@ fn jieba_word_range(chars: &[char], click: usize) -> Option<(usize, usize)> { 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(); - let jieba = JIEBA.get_or_init(jieba_rs::Jieba::new); // Token start/end are Unicode char offsets into `run`. let rel = click - rs; jieba @@ -588,6 +606,14 @@ mod tests { /// 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) @@ -767,6 +793,7 @@ mod tests { #[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(); @@ -777,17 +804,22 @@ mod tests { #[test] fn cjk_segmentation_survives_surrogate_pairs_before_the_click() { - // The emoji before the run must not skew the char↔offset mapping. - 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, "你好"); + // 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(); @@ -796,6 +828,23 @@ mod tests { 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 ['中', 'あ', 'ア', '한', ',', '('] { diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 8e047cd5..2fc21a5f 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -711,9 +711,6 @@ impl TerminalView { window: &mut Window, cx: &mut Context, ) -> anyhow::Result { - // Build the CJK segmentation dictionary off-thread now, so the first - // double-click on Chinese text doesn't pay the ~0.5s table build. - super::smart_select::warm(); // Provisional size; corrected on the first prepaint once we can measure. // The PTY lives in the daemon now. On session restore (`restore_pane`), // re-`attach` to the still-running pane so its process + scrollback come @@ -2852,8 +2849,9 @@ impl TerminalView { self.editor_drag_word = None; } 2 => { - let seps = &cx.global::().word_separators; - self.cmd.select_word_at(idx, seps); + 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(); @@ -2884,8 +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 { - let seps = &cx.global::().word_separators; - self.cmd.extend_word_to(s, e, idx, seps); + 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); } From 142a4f109c9912f7205fa53aa9d83460282cc26c Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:50:56 +0800 Subject: [PATCH 3/5] test(terminal): cover the grid side of smart selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing tests all drove the pure string helpers; the grid path — where the index arithmetic is actually hard — had none. Add tests that feed a real `Term` through the VT parser and assert on the resolved selection: - OSC 8 hyperlink runs, including one spilling across a soft wrap in both directions, and a cell outside the run not picking the link up - a URL split by a soft wrap, clicked from the head (joins forwards) and from the continuation row (joins backwards) - wide CJK glyphs, where clicking the glyph cell and its trailing spacer must resolve to the same word, plus a glyph pushed onto the next row by a leading spacer - a click past the last column resolving to no range Verified by mutation: breaking the trailing-spacer offset, the hyperlink wrap walk, or the logical-line backward join each turns one of these red. --- src/terminal/smart_select.rs | 144 +++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/src/terminal/smart_select.rs b/src/terminal/smart_select.rs index 291ba194..9e2a66a4 100644 --- a/src/terminal/smart_select.rs +++ b/src/terminal/smart_select.rs @@ -602,6 +602,7 @@ pub(super) fn narrow_to_script( #[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"; @@ -619,6 +620,149 @@ mod tests { 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()) From 80af90614650fda7913390f5060734bb451383a4 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:19:04 +0800 Subject: [PATCH 4/5] fix(terminal): require angle-bracket pairs to hug their contents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `<` and `>` are comparison and redirection operators at least as often as they are delimiters, and the bracket path returns before the `extends` guard that keeps every other smart candidate additive — so a bad match there has no safety net and can select half a line. Accept an angle pair only when neither delimiter is followed (resp. preceded) by whitespace. Measured against the repo's own source plus a corpus of shell commands, logs and diagnostics, that keeps every real delimiter — `Vec`, `
`, ``, ``, `` — and drops the operator matches (`a < b > c`, `x <= 0 || y > 9`, `WHERE a < 10 AND b > 20`). Redirections need no handling: `2>&1` and `cmd > out` have no partner to match, so the scan already failed on them. Other pairs are unaffected — `( a )` is a legitimate subshell and `[ 1 ]` a legitimate index. --- src/terminal/smart_select.rs | 69 +++++++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 4 deletions(-) diff --git a/src/terminal/smart_select.rs b/src/terminal/smart_select.rs index 9e2a66a4..ad076369 100644 --- a/src/terminal/smart_select.rs +++ b/src/terminal/smart_select.rs @@ -445,10 +445,29 @@ pub(super) fn quote_range(chars: &[char], click: usize) -> Option<(usize, usize) } } +/// 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 or the -/// match isn't on the logical line. +/// 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) { @@ -459,7 +478,7 @@ pub(super) fn bracket_range(chars: &[char], click: usize) -> Option<(usize, usiz } else if ch == *close { depth -= 1; if depth == 0 { - return Some((click, i)); + return pair_is_plausible(chars, *open, click, i).then_some((click, i)); } } } @@ -474,7 +493,7 @@ pub(super) fn bracket_range(chars: &[char], click: usize) -> Option<(usize, usiz } else if ch == *open { depth -= 1; if depth == 0 { - return Some((i, click)); + return pair_is_plausible(chars, *open, i, click).then_some((i, click)); } } } @@ -929,6 +948,48 @@ mod tests { 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(); From 2dbeb21b19dc5bebc5e6365363378720deab6ba1 Mon Sep 17 00:00:00 2001 From: thomas Date: Sun, 19 Jul 2026 18:49:08 +0800 Subject: [PATCH 5/5] fix(terminal): keep contraction apostrophes out of quote pairing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `quote_range` paired quotes by parity, so English prose broke it: in `it's a test, isn't it` the apostrophe in `it's` reads as an opener and pairs with the one in `isn't`, and a double-click on either selects `'s a test, isn'` instead of the stock `it's`. The existing comment anticipated this and claimed a missing match would fall through to `None`, but that only holds when the line has a single apostrophe — prose usually has two. This path also returns before the `extends` guard, so there was no safety net. Exclude contraction apostrophes (alphanumeric on both sides) throughout: clicking one falls through to the stock word, and they count neither toward the parity nor as a candidate match. `"` and `` ` `` are unaffected — they don't occur inside words. Genuine possessives like `the 'dogs' bark` still pair, since a delimiter always has a non-word char or a line edge on one side. Co-Authored-By: Claude Opus 4.8 --- src/terminal/smart_select.rs | 67 ++++++++++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 6 deletions(-) diff --git a/src/terminal/smart_select.rs b/src/terminal/smart_select.rs index ad076369..d71cbf84 100644 --- a/src/terminal/smart_select.rs +++ b/src/terminal/smart_select.rs @@ -425,22 +425,45 @@ 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). Apostrophes in prose skew the parity, but a -/// missing match just falls through to `None`. +/// 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) { + if !SYMMETRIC_QUOTES.contains(&q) || is_contraction(chars, click) { return None; } - let before = chars[..click].iter().filter(|&&c| c == q).count(); + 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| chars[i] == q)?; + let close = (click + 1..chars.len()).find(|&i| quote_at(i))?; Some((click, close)) } else { - let open = (0..click).rev().find(|&i| chars[i] == q)?; + let open = (0..click).rev().find(|&i| quote_at(i))?; Some((open, click)) } } @@ -921,6 +944,38 @@ mod tests { 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();