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()))); }