From d9a51aee09f71c7b918eb114e78a318abed0e9c4 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Fri, 2 Oct 2026 00:21:41 +0800 Subject: [PATCH] fix(markdown): copy the characters a drag selection highlights A drag ending on the right half of a character highlights it, but copy resolved the end with TextLayout::index_for_position, which returns the start of the character under the pointer, so the last highlighted character was dropped (and a start on a character's right half copied one extra). Resolve endpoint offsets to the nearest character boundary using gpui-base's midpoint band rule. --- crates/ui/src/markdown/selection_adapter.rs | 50 ++++++++++++++++----- crates/ui/src/markdown/state.rs | 27 +++++++++++ 2 files changed, 66 insertions(+), 11 deletions(-) diff --git a/crates/ui/src/markdown/selection_adapter.rs b/crates/ui/src/markdown/selection_adapter.rs index edc45fad..df709344 100644 --- a/crates/ui/src/markdown/selection_adapter.rs +++ b/crates/ui/src/markdown/selection_adapter.rs @@ -7,9 +7,9 @@ use std::{ }; use gpui::{ - AnyWindowHandle, App, AppContext as _, Bounds, EntityId, Hitbox, Hsla, Modifiers, Pixels, - PlatformInput, Point, ScrollDelta, ScrollWheelEvent, Task, TextLayout, WeakEntity, Window, - point, px, + AnyWindowHandle, App, AppContext as _, Bounds, EntityId, Half as _, Hitbox, Hsla, Modifiers, + Pixels, PlatformInput, Point, ScrollDelta, ScrollWheelEvent, Task, TextLayout, WeakEntity, + Window, point, px, }; use gpui_base::{ TextSelectionContentKey, TextSelectionCoverage, TextSelectionEndpoint, TextSelectionEvent, @@ -355,7 +355,7 @@ impl MarkdownSelectionAdapter { } /// The inline states painted last frame in rows at `window_point`'s - /// height, with the text offset nearest to it in each. + /// height, with the character boundary nearest to it in each. pub(super) fn runs_at( &self, window_point: Point, @@ -369,13 +369,7 @@ impl MarkdownSelectionAdapter { let bounds = run.bounds(); bounds.top() <= window_point.y && window_point.y < bounds.bottom() }) - .map(|(run, state)| { - let offset = run - .layout() - .index_for_position(window_point) - .unwrap_or_else(|nearest| nearest); - (state.clone(), offset) - }) + .map(|(run, state)| (state.clone(), nearest_boundary(run, window_point))) .collect() } @@ -443,6 +437,40 @@ impl MarkdownSelectionAdapter { } } +/// The character boundary in `run` nearest to `window_point`. +/// +/// gpui-base paints a character as selected when the selection covers its +/// horizontal midpoint, while `TextLayout::index_for_position` returns the +/// start of the character under the point, so an end on a character's right +/// half would copy one character less than is painted. The midpoint and the +/// width of a character that ends its row follow gpui-base's band test. +fn nearest_boundary(run: &TextSelectionRun, window_point: Point) -> usize { + let layout = run.layout(); + let offset = match layout.index_for_position(window_point) { + Ok(offset) => offset, + Err(nearest) => return nearest, + }; + let Some(next_offset) = run.text()[offset..] + .chars() + .next() + .map(|character| offset + character.len_utf8()) + else { + return offset; + }; + let Some(position) = layout.position_for_index(offset) else { + return offset; + }; + let width = layout + .position_for_index(next_offset) + .filter(|next| next.y == position.y) + .map_or_else(|| layout.line_height().half(), |next| next.x - position.x); + if window_point.x >= position.x + width.half() { + next_offset + } else { + offset + } +} + fn start_auto_scroll(state: &Rc>, cx: &mut App) { if state.borrow().task.is_some() { return; diff --git a/crates/ui/src/markdown/state.rs b/crates/ui/src/markdown/state.rs index d6f3a6c0..ec1cd6f9 100644 --- a/crates/ui/src/markdown/state.rs +++ b/crates/ui/src/markdown/state.rs @@ -791,4 +791,31 @@ mod tests { "intro\nfirst line\nsecond line\nname value\nalpha beta\n" ); } + + #[gpui::test] + fn drag_copies_exactly_the_highlighted_text(cx: &mut TestAppContext) { + use gpui::{Modifiers, MouseButton, point}; + + cx.update(crate::theme::init); + cx.update(super::super::init); + let (view, cx) = cx.add_window_view(|_, cx| SelectAllRoot::new("Hello world", cx)); + let cx: &mut VisualTestContext = cx; + cx.update(|window, cx| _ = window.draw(cx)); + + // The sweep ends the drag on both halves of every character it crosses. + for x in 1..120 { + let (start, end) = (point(px(1.), px(12.)), point(px(x as f32), px(12.))); + cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); + cx.update(|window, cx| _ = window.draw(cx)); + cx.simulate_mouse_move(end, Some(MouseButton::Left), Modifiers::default()); + cx.update(|window, cx| _ = window.draw(cx)); + cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default()); + cx.update(|window, cx| _ = window.draw(cx)); + + let highlighted = + view.read_with(cx, |root, cx| root.markdown.read(cx).parsed.selected_text()); + let copied = cx.update(gpui_base::TextSelection::selected_text); + assert_eq!(copied, highlighted, "drag to x = {x}"); + } + } }