From b803b70aeba04ea6c44e8b211105bbd8071544ac Mon Sep 17 00:00:00 2001 From: David Frnoch Date: Tue, 29 Sep 2026 14:28:49 +0200 Subject: [PATCH] Transcript: double-click drags keep selecting whole words A double click selected the word, but the selection was stored as an ordinary drag anchored at the word's start. The next mouse move with the button still down, even a pixel of tremor before release, re-resolved it as a character range from that anchor to the pointer, so the word collapsed to a few letters and the gesture behaved like a plain drag. Record the multi-click unit with the drag. While it lasts, the head snaps to word (double click) or element (triple click) boundaries and the anchor unit stays selected in either direction, matching the composer and native text. The virtualized path snaps the head the same way once the anchor row scrolls away. Rendered markdown in the file preview uses the same code and gets the same fix. Co-Authored-By: Claude Opus 5.5 (1M context) Co-Authored-By: Claude Sonnet 5.5 --- crates/ui/src/composer.rs | 7 +- .../ui/src/composer/modal_selection_tests.rs | 7 +- crates/ui/src/markdown/render.rs | 25 ++++- crates/ui/src/markdown/selection.rs | 98 ++++++++++++++++++- crates/ui/src/shell/navigation_tests.rs | 7 +- 5 files changed, 134 insertions(+), 10 deletions(-) diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index 3e5e16c21f..6913e117bf 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -11344,7 +11344,12 @@ mod tests { input.set_text("Unselected draft", cx); assert_eq!(input.key_context, MESSAGE_COMPOSER_CONTEXT); assert!(input.selected_range.is_empty()); - crate::markdown::selection::begin_with_span(key, text, 0..text.len()); + crate::markdown::selection::begin_with_span( + key, + text, + 0..text.len(), + crate::markdown::selection::Unit::Element, + ); crate::markdown::selection::end_active_drag(); input.copy(&Copy, window, cx); let copied = cx.read_from_clipboard().and_then(|item| item.text()); diff --git a/crates/ui/src/composer/modal_selection_tests.rs b/crates/ui/src/composer/modal_selection_tests.rs index b445466324..8c3758c72d 100644 --- a/crates/ui/src/composer/modal_selection_tests.rs +++ b/crates/ui/src/composer/modal_selection_tests.rs @@ -177,7 +177,12 @@ fn modal_input_copy_does_not_copy_transcript_selection(cx: &mut TestAppContext) // A selection made before opening the popup must not become the field's // Copy fallback. Seed it independently of the mouse-isolation regression. - selection::begin_with_span(BACKGROUND_KEY, BACKGROUND_TEXT, 0..BACKGROUND_TEXT.len()); + selection::begin_with_span( + BACKGROUND_KEY, + BACKGROUND_TEXT, + 0..BACKGROUND_TEXT.len(), + selection::Unit::Element, + ); selection::end_active_drag(); assert_eq!(selection::selected_text().as_deref(), Some(BACKGROUND_TEXT)); view.update(cx, |view, cx| { diff --git a/crates/ui/src/markdown/render.rs b/crates/ui/src/markdown/render.rs index 9dced2fb3b..98f2e9aa23 100644 --- a/crates/ui/src/markdown/render.rs +++ b/crates/ui/src/markdown/render.rs @@ -1611,10 +1611,20 @@ fn register_selection_listeners( match e.click_count { 2 => { let range = super::selection::word_range(&text, ix); - super::selection::begin_with_span(&key, &text, range); + super::selection::begin_with_span( + &key, + &text, + range, + super::selection::Unit::Word, + ); } n if n >= 3 => { - super::selection::begin_with_span(&key, &text, 0..text.len()); + super::selection::begin_with_span( + &key, + &text, + 0..text.len(), + super::selection::Unit::Element, + ); } _ => super::selection::begin(&key, ix), } @@ -2591,6 +2601,17 @@ mod tests { super::super::selection::selected_text().as_deref(), Some("selectable") ); + // Pointer tremor between the second press and release must not + // demote the word to a character selection. + cx.simulate_event(gpui::MouseMoveEvent { + position: first_bounds.origin + point(px(12.0), px(9.0)), + pressed_button: Some(gpui::MouseButton::Left), + ..Default::default() + }); + assert_eq!( + super::super::selection::selected_text().as_deref(), + Some("selectable") + ); super::super::selection::end_active_drag(); super::super::selection::clear_if_owner(first_key); diff --git a/crates/ui/src/markdown/selection.rs b/crates/ui/src/markdown/selection.rs index 74639c573e..4f96d0f856 100644 --- a/crates/ui/src/markdown/selection.rs +++ b/crates/ui/src/markdown/selection.rs @@ -28,6 +28,22 @@ pub struct Span { pub text: String, } +/// Multi-click granularity. Like native text, a drag that starts with a double +/// or triple click keeps extending by that unit. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Unit { + Word, + /// A whole text element: a paragraph, list item or code line. + Element, +} + +fn unit_range(unit: Unit, text: &str, ix: usize) -> Range { + match unit { + Unit::Word => word_range(text, ix), + Unit::Element => 0..text.len(), + } +} + #[derive(Clone, Default)] struct MdSelection { /// Element that owns the drag (where the mouse went down). @@ -35,6 +51,9 @@ struct MdSelection { /// Byte offset of the anchor within its element. anchor_ix: usize, dragging: bool, + /// Granularity and the anchor's unit range for multi-click drags. The + /// anchor unit stays selected whichever way the head moves. + unit: Option<(Unit, Range)>, /// Document direction established while the anchor is still painted. /// Once virtualization moves it out of the registry, this tells us which /// side of the accumulated spans to preserve while extending the head. @@ -91,17 +110,19 @@ pub fn begin(key: &str, ix: usize) { anchor_key: key.to_string(), anchor_ix: ix, dragging: true, + unit: None, forward: None, spans: Vec::new(), }); } /// Begin with an immediate span (double/triple click inside one element). -pub fn begin_with_span(key: &str, text: &str, range: Range) { +pub fn begin_with_span(key: &str, text: &str, range: Range, unit: Unit) { *state().lock().unwrap() = Some(MdSelection { anchor_key: key.to_string(), anchor_ix: range.start, dragging: true, + unit: Some((unit, range.clone())), forward: None, spans: vec![Span { key: key.to_string(), @@ -147,17 +168,42 @@ pub fn update_drag(elements: &[(&str, &str)], head: (usize, usize)) -> bool { let Some(selection) = guard.as_mut().filter(|selection| selection.dragging) else { return false; }; + let head_unit = selection.unit.as_ref().map(|(unit, _)| { + elements + .get(head.0) + .map_or(head.1..head.1, |(_, text)| unit_range(*unit, text, head.1)) + }); let spans = if let Some(anchor_ei) = elements .iter() .position(|(key, _)| *key == selection.anchor_key) { - let anchor = (anchor_ei, selection.anchor_ix); - selection.forward = Some(anchor <= head); - resolve_spans(elements, anchor, head) + match (&selection.unit, head_unit) { + (Some((_, anchor)), Some(head_unit)) => { + let start = (anchor_ei, anchor.start); + let end = (anchor_ei, anchor.end); + if (head.0, head_unit.start) < start { + selection.forward = Some(false); + resolve_spans(elements, (head.0, head_unit.start), end) + } else { + selection.forward = Some(true); + resolve_spans(elements, start, end.max((head.0, head_unit.end))) + } + } + _ => { + let anchor = (anchor_ei, selection.anchor_ix); + selection.forward = Some(anchor <= head); + resolve_spans(elements, anchor, head) + } + } } else { let Some(forward) = selection.forward else { return false; }; + let head = match head_unit { + Some(unit) if forward => (head.0, unit.end), + Some(unit) => (head.0, unit.start), + None => head, + }; let Some(spans) = extend_virtualized_drag(&selection.spans, elements, head, forward) else { return false; }; @@ -433,11 +479,53 @@ mod tests { #[test] fn double_click_span() { let _state = test_state_lock(); - begin_with_span("p1", "hello world", 6..11); + begin_with_span("p1", "hello world", 6..11, Unit::Word); assert_eq!(wash_range("p1"), Some(6..11)); assert_eq!(end_drag("p1").as_deref(), Some("world")); } + #[test] + fn double_click_drag_extends_by_whole_words() { + let _state = test_state_lock(); + let elements = [("p1", "hello big world"), ("p2", "foo bar")]; + begin_with_span("p1", "hello big world", 6..9, Unit::Word); + assert!(!update_drag(&elements, (0, 7)), "jitter keeps the word"); + assert_eq!(selected_text().as_deref(), Some("big")); + assert!(update_drag(&elements, (0, 12))); + assert_eq!(selected_text().as_deref(), Some("big world")); + assert!(update_drag(&elements, (0, 1))); + assert_eq!(selected_text().as_deref(), Some("hello big")); + assert!(update_drag(&elements, (1, 1))); + assert_eq!(selected_text().as_deref(), Some("big world\nfoo")); + end_drag("p1"); + clear_if_owner("p1"); + } + + #[test] + fn triple_click_drag_extends_by_whole_elements() { + let _state = test_state_lock(); + let elements = [("p1", "first"), ("p2", "second line")]; + begin_with_span("p1", "first", 0..5, Unit::Element); + assert!(!update_drag(&elements, (0, 2))); + assert!(update_drag(&elements, (1, 3))); + assert_eq!(selected_text().as_deref(), Some("first\nsecond line")); + end_drag("p1"); + clear_if_owner("p1"); + } + + #[test] + fn word_drag_snaps_after_the_anchor_virtualizes() { + let _state = test_state_lock(); + let before = [("p1", "hello world"), ("p2", "foo bar")]; + begin_with_span("p1", "hello world", 6..11, Unit::Word); + assert!(update_drag(&before, (1, 1))); + let after = [("p2", "foo bar"), ("p3", "baz qux")]; + assert!(update_drag(&after, (1, 5))); + assert_eq!(selected_text().as_deref(), Some("world\nfoo bar\nbaz qux")); + end_active_drag(); + clear_if_owner("p1"); + } + #[test] fn word_ranges() { let t = "let foo_bar = 12;"; diff --git a/crates/ui/src/shell/navigation_tests.rs b/crates/ui/src/shell/navigation_tests.rs index e509ae9d7f..8549141e39 100644 --- a/crates/ui/src/shell/navigation_tests.rs +++ b/crates/ui/src/shell/navigation_tests.rs @@ -450,7 +450,12 @@ fn each_session_cycles_only_its_own_tabs(cx: &mut TestAppContext) { /// Settles a transcript selection and seeds the clipboard with a sentinel, so /// a keystroke that copies nothing is distinguishable from one that does. fn select_transcript_text(cx: &mut VisualTestContext) { - crate::markdown::selection::begin_with_span("transcript-row:0", "selected reply", 0..8); + crate::markdown::selection::begin_with_span( + "transcript-row:0", + "selected reply", + 0..8, + crate::markdown::selection::Unit::Word, + ); crate::markdown::selection::end_active_drag(); cx.update(|_, cx| cx.write_to_clipboard(ClipboardItem::new_string("sentinel".into()))); }