hi", "
"),
+ ("usage: tty7 [opts]", ""),
+ ("From: Jo ok", ""),
+ ("map: HashMap here", ""),
+ ] {
+ let chars: Vec = text.chars().collect();
+ let click = chars.iter().position(|&c| c == '<').unwrap();
+ let (s, e) = bracket_range(&chars, click).unwrap_or_else(|| panic!("{text}"));
+ let got: String = chars[s..=e].iter().collect();
+ assert_eq!(got, want, "{text}");
+ }
+ // Comparison operators must not pair across half a line — a space just
+ // inside either end is the tell.
+ for text in [
+ "if a < b then c > d",
+ "awk '{ if ($1 > 100 && $2 < 5) print }'",
+ "WHERE a < 10 AND b > 20",
+ "empty <> pair",
+ ] {
+ let chars: Vec = text.chars().collect();
+ for (i, &c) in chars.iter().enumerate() {
+ if c == '<' || c == '>' {
+ assert_eq!(bracket_range(&chars, i), None, "{text} at {i}");
+ }
+ }
+ }
+ // Redirections never had a partner to match in the first place.
+ for text in ["cargo build 2>&1 | tee out", "grep -rn foo src/ > /tmp/o"] {
+ let chars: Vec = text.chars().collect();
+ for (i, &c) in chars.iter().enumerate() {
+ if c == '<' || c == '>' {
+ assert_eq!(bracket_range(&chars, i), None, "{text} at {i}");
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn unmatched_bracket_yields_none() {
+ let chars: Vec = "f(a".chars().collect();
+ assert_eq!(bracket_range(&chars, 1), None);
+ }
+
+ #[test]
+ fn cjk_segmentation_selects_a_dictionary_word_not_the_whole_run() {
+ ensure_segmenter();
+ let text = "run 北京欢迎你 done";
+ let chars: Vec = text.chars().collect();
+ let click = chars.iter().position(|&c| c == '京').unwrap();
+ let (s, e) = cjk_word_range(text, click).expect("segmented range");
+ let sel: String = chars[s..=e].iter().collect();
+ assert_eq!(sel, "北京");
+ }
+
+ #[test]
+ fn cjk_segmentation_survives_surrogate_pairs_before_the_click() {
+ // The emoji is two UTF-16 units: a tokenizer offset table that counted
+ // chars instead would shift every index after it. Both backends agree
+ // on `世界`, so a skewed mapping shows up as a different token.
+ ensure_segmenter();
+ for text in ["你好世界", "🙂 你好世界", "🙂🙂🙂 你好世界"] {
+ let chars: Vec = text.chars().collect();
+ let click = chars.iter().position(|&c| c == '世').unwrap();
+ let (s, e) = cjk_word_range(text, click).expect("segmented range");
+ let sel: String = chars[s..=e].iter().collect();
+ assert_eq!(sel, "世界", "{text:?} segmented wrong");
+ }
+ }
+
+ #[test]
+ fn cjk_punctuation_is_its_own_token() {
+ ensure_segmenter();
+ let text = "比赛,天气";
+ let chars: Vec = text.chars().collect();
+ let click = chars.iter().position(|&c| c == ',').unwrap();
+ let (s, e) = cjk_word_range(text, click).expect("segmented range");
+ let sel: String = chars[s..=e].iter().collect();
+ assert_eq!(sel, ",");
+ }
+
+ /// Japanese must not be run through jieba's Chinese dictionary — it cuts
+ /// kana into single characters, which is worse than leaving the run whole.
+ /// macOS hands it to CFStringTokenizer, which segments it properly.
+ #[test]
+ fn japanese_is_not_shredded_into_single_kana() {
+ ensure_segmenter();
+ let text = "日本語の文章です";
+ let chars: Vec = text.chars().collect();
+ let click = chars.iter().position(|&c| c == 'で').unwrap();
+ // macOS yields a real token, never a lone kana; elsewhere the run
+ // comes back unsegmented and the caller selects all of it.
+ if let Some((s, e)) = cjk_word_range(text, click) {
+ let sel: String = chars[s..=e].iter().collect();
+ assert_eq!(sel, "です");
+ }
+ }
+
+ #[test]
+ fn is_cjk_covers_han_kana_hangul_fullwidth() {
+ for c in ['中', 'あ', 'ア', '한', ',', '('] {
+ assert!(is_cjk(c), "{c} should be CJK");
+ }
+ for c in ['a', '1', '-', '/', 'é'] {
+ assert!(!is_cjk(c), "{c} should not be CJK");
+ }
+ }
+}
diff --git a/src/terminal/view.rs b/src/terminal/view.rs
index 7bf44e45..2fc21a5f 100644
--- a/src/terminal/view.rs
+++ b/src/terminal/view.rs
@@ -2849,7 +2849,9 @@ impl TerminalView {
self.editor_drag_word = None;
}
2 => {
- self.cmd.select_word_at(idx);
+ let cfg = cx.global::();
+ let (seps, smart) = (cfg.word_separators.clone(), cfg.smart_select);
+ self.cmd.select_word_at(idx, &seps, smart);
// Drag now grows the selection by whole words around this one.
self.editor_selecting = true;
self.editor_drag_word = self.cmd.selection();
@@ -2880,7 +2882,9 @@ impl TerminalView {
};
// A drag begun on a double-click extends by whole words; otherwise by char.
if let Some((s, e)) = self.editor_drag_word {
- self.cmd.extend_word_to(s, e, idx);
+ let cfg = cx.global::();
+ let (seps, smart) = (cfg.word_separators.clone(), cfg.smart_select);
+ self.cmd.extend_word_to(s, e, idx, &seps, smart);
} else {
self.cmd.extend_to(idx);
}
@@ -3637,18 +3641,51 @@ impl TerminalView {
row: usize,
left: bool,
clicks: usize,
+ shift: bool,
cx: &mut Context,
) {
+ let smart = cx.global::().smart_select;
let mut term = self.terminal.term.lock();
let display_offset = term.grid().display_offset() as i32;
let point = Point::new(Line(row as i32 - display_offset), Column(col));
let side = if left { Side::Left } else { Side::Right };
+ // Shift+click extends the existing selection to the click instead of
+ // starting over (à la iTerm2). A plain click always leaves a
+ // collapsed Simple selection behind, so the anchor is wherever the
+ // last gesture ended.
+ if shift && clicks == 1 && term.selection.is_some() {
+ if let Some(sel) = term.selection.as_mut() {
+ sel.update(point, side);
+ }
+ drop(term);
+ self.selecting = true;
+ cx.notify();
+ return;
+ }
let ty = match clicks {
2 => SelectionType::Semantic, // word
n if n >= 3 => SelectionType::Lines,
_ => SelectionType::Simple,
};
- term.selection = Some(Selection::new(ty, point, side));
+ let mut selection = Selection::new(ty, point, side);
+ // Double-click smart selection: a URL / path / email / bracket pair /
+ // CJK word containing the clicked word replaces the plain word span.
+ // Boundary-flanked candidates anchor a Semantic selection (keeping
+ // the drag gesture word-wise); exact ones use Simple so alacritty
+ // can't re-expand the endpoints past the smart boundary.
+ if clicks == 2
+ && smart
+ && let Some(r) = super::smart_select::grid_smart_range(&term, point)
+ {
+ let ty = if r.exact {
+ SelectionType::Simple
+ } else {
+ SelectionType::Semantic
+ };
+ selection = Selection::new(ty, r.start, Side::Left);
+ selection.update(r.end, Side::Right);
+ }
+ term.selection = Some(selection);
drop(term);
self.selecting = true;
cx.notify();
@@ -6908,7 +6945,7 @@ mod gpui_tests {
let drag_hello = |cx: &mut TestAppContext| {
window
.update(cx, |view, _, cx| {
- view.on_select_start(0, 0, true, 1, cx);
+ view.on_select_start(0, 0, true, 1, false, cx);
view.on_select_update(4, 0, false, cx);
view.on_select_end(cx);
})
@@ -6973,7 +7010,7 @@ mod gpui_tests {
.update(cx, |view, window, cx| {
// Mouse-select "hello" (copy-on-select is off by default, so
// the selection survives mouse-up).
- view.on_select_start(0, 0, true, 1, cx);
+ view.on_select_start(0, 0, true, 1, false, cx);
view.on_select_update(4, 0, false, cx);
view.on_select_end(cx);
assert!(view.has_selection(), "the drag must leave a selection");
diff --git a/src/ui/app.rs b/src/ui/app.rs
index fdcab0dc..e8908fc1 100644
--- a/src/ui/app.rs
+++ b/src/ui/app.rs
@@ -1992,6 +1992,10 @@ impl Tty7App {
self.update_config(cx, |cfg| cfg.copy_on_select = on);
}
+ pub(crate) fn set_smart_select(&mut self, on: bool, cx: &mut Context) {
+ self.update_config(cx, |cfg| cfg.smart_select = on);
+ }
+
pub(crate) fn set_startup_mode(
&mut self,
mode: crate::core::config::StartupMode,
diff --git a/src/ui/settings.rs b/src/ui/settings.rs
index 02ad4ae3..1e237067 100644
--- a/src/ui/settings.rs
+++ b/src/ui/settings.rs
@@ -177,6 +177,11 @@ fn settings_search_entries() -> &'static [SearchEntry] {
title: "Forward SSH loopback links",
keywords: "ssh remote port tunnel localhost forward",
},
+ SearchEntry {
+ section: Terminal,
+ title: "Smart selection",
+ keywords: "double click word url path select semantic",
+ },
SearchEntry {
section: Terminal,
title: "Copy on select",
@@ -2763,6 +2768,7 @@ impl Tty7App {
let clip_trim = cfg.clipboard_trim_trailing_spaces;
let copy_on_select = cfg.copy_on_select;
let mouse_reporting = cfg.mouse_reporting;
+ let smart_select = cfg.smart_select;
let bell = cfg.bell;
// Map the persisted threshold onto its preset radio index (nearest slot
// for any off-preset value a hand-edit might leave).
@@ -2848,6 +2854,10 @@ impl Tty7App {
.checked(mouse_reporting)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_mouse_reporting(*on, cx)))
.into_any_element();
+ let smart_select_switch = Switch::new("term-smart-select")
+ .checked(smart_select)
+ .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_smart_select(*on, cx)))
+ .into_any_element();
let bell_idx = match bell {
BellMode::None => 0,
BellMode::Visual => 1,
@@ -2948,6 +2958,12 @@ impl Tty7App {
mouse_report_switch,
cx,
))
+ .child(self.settings_row(
+ "Smart selection",
+ "Double-click selects the whole URL, file path, email, or bracket pair under the cursor.",
+ smart_select_switch,
+ cx,
+ ))
.when_some(option_alt_row, |v, row| {
v.child(self.section_rule(cx))
.child(self.section_header("Keyboard", cx))