From a9102bdc4092e53885481d8e3be1142f821424cd Mon Sep 17 00:00:00 2001 From: Graham Barber Date: Thu, 30 Jul 2026 13:53:05 -0700 Subject: [PATCH] fix(editor): click places the cursor at the clicked character; double-click selects the word --- crates/trawler/src/editor.rs | 232 ++++++++++++++++++++++++++--------- crates/trawler/src/main.rs | 39 +++++- 2 files changed, 204 insertions(+), 67 deletions(-) diff --git a/crates/trawler/src/editor.rs b/crates/trawler/src/editor.rs index 684fe37..0dc9eb2 100644 --- a/crates/trawler/src/editor.rs +++ b/crates/trawler/src/editor.rs @@ -205,6 +205,11 @@ pub struct BlockEditor { /// `WrappedLine::wrap_boundaries`). last_layout: Vec, last_bounds: Option>, + /// The row height `last_layout` was painted with (the window's line + /// height at paint time, not the font's ascent+descent — the line box + /// includes leading). Every position<->offset mapping against + /// `last_layout` must use this same value or wrapped rows drift. + last_line_height: Pixels, is_selecting: bool, completion: Option, /// Query blocks get Scheme syntax highlighting and matching-paren @@ -246,6 +251,12 @@ pub struct BlockEditor { /// A departing editor's caret rectangle (window coordinates) for this /// editor's caret glide to start from; consumed on first prepaint. incoming_caret: Option>, + /// The window-space point of the click that attached this editor (a + /// click on the rendered, read-only view of the block). The rendered + /// view has no source-text layout to hit-test, so the editor resolves + /// the point itself — against its first prepaint's shaped lines — and + /// places the cursor there instead of the default end-of-content. + pending_click_position: Option>, caret_anim: Option, _blur_subscription: Subscription, } @@ -339,6 +350,7 @@ impl BlockEditor { marked_range: None, last_layout: Vec::new(), last_bounds: None, + last_line_height: px(0.), is_selecting: false, completion: None, highlight_scheme: false, @@ -347,6 +359,7 @@ impl BlockEditor { reveal_attempts: 0, last_caret_bounds: None, incoming_caret: None, + pending_click_position: None, caret_anim: None, _blur_subscription: blur_subscription, } @@ -369,6 +382,14 @@ impl BlockEditor { } } + /// Seed the cursor from the window position of the click that focused + /// this block (see the field doc on `pending_click_position`). Owners + /// call this right after attaching a fresh editor in response to a + /// click on the block's rendered view. + pub fn seed_click_cursor(&mut self, position: Point) { + self.pending_click_position = Some(position); + } + /// Mark the caret's current position as already revealed, suppressing /// the reveal this editor's next prepaint would otherwise issue. Used /// by history navigation: the restored scroll offset must win over @@ -779,13 +800,79 @@ impl BlockEditor { } fn on_mouse_down(&mut self, event: &MouseDownEvent, _: &mut Window, cx: &mut Context) { - self.is_selecting = true; let offset = self.index_for_position(event.position); - if event.modifiers.shift { - self.select_to(offset, cx); - } else { - self.move_to(offset, cx); + match event.click_count { + // Double-click selects the clicked word, triple-click its whole + // logical line. `is_selecting` stays false for both: a pixel of + // jitter before the button releases would otherwise drag the + // selection down to a caret via `on_mouse_move`. + 2 => self.select_word_at(offset, cx), + n if n >= 3 => { + self.selection_reversed = false; + self.selected_range = self.line_bounds(offset); + cx.notify(); + } + _ => { + self.is_selecting = true; + if event.modifiers.shift { + self.select_to(offset, cx); + } else { + self.move_to(offset, cx); + } + } + } + } + + /// Select the contiguous run of same-class characters (word + /// characters, whitespace, or other punctuation) around `offset` — + /// the double-click selection. A click at a run boundary prefers the + /// word side, so double-clicking just past the last character of a + /// word still selects the word rather than the space after it. + fn select_word_at(&mut self, offset: usize, cx: &mut Context) { + #[derive(Clone, Copy, PartialEq)] + enum Class { + Word, + Space, + Punct, + } + fn class_of(ch: char) -> Class { + if ch.is_alphanumeric() || ch == '_' { + Class::Word + } else if ch.is_whitespace() { + Class::Space + } else { + Class::Punct + } + } + let offset = self.snap_to_char_boundary(offset); + let before = self.content[..offset].chars().next_back().map(class_of); + let after = self.content[offset..].chars().next().map(class_of); + let class = match (before, after) { + (Some(Class::Word), _) | (_, Some(Class::Word)) => Class::Word, + (_, Some(c)) => c, + (Some(c), None) => c, + (None, None) => return, + }; + let mut start = offset; + for (ix, ch) in self.content[..offset].char_indices().rev() { + if class_of(ch) != class { + break; + } + start = ix; + } + let mut end = offset; + for (ix, ch) in self.content[offset..].char_indices() { + if class_of(ch) != class { + break; + } + end = offset + ix + ch.len_utf8(); + } + if start == end { + return; } + self.selection_reversed = false; + self.selected_range = start..end; + cx.notify(); } fn on_mouse_up(&mut self, _: &MouseUpEvent, _: &mut Window, _: &mut Context) { @@ -799,32 +886,13 @@ impl BlockEditor { } } - /// `position` in window space -> byte offset. Walks logical lines - /// accumulating each one's full *visual* height (1 row, or more if - /// wrapped — see `line_visual_height`) rather than assuming every - /// logical line is exactly one row tall. + /// `position` in window space -> byte offset, against the last paint's + /// layout (see `index_for_position_in`). fn index_for_position(&self, position: Point) -> usize { let Some(bounds) = self.last_bounds else { return 0; }; - if self.last_layout.is_empty() { - return 0; - } - let line_height = bounds_line_height(&self.last_layout); - let mut relative_y = (position.y - bounds.top()).max(px(0.)); - let relative_x = (position.x - bounds.left()).max(px(0.)); - let last_ix = self.last_layout.len() - 1; - for (line_ix, line) in self.last_layout.iter().enumerate() { - let visual_height = line_visual_height(line, line_height); - if relative_y < visual_height || line_ix == last_ix { - let local = line - .closest_index_for_position(point(relative_x, relative_y), line_height) - .unwrap_or_else(|clamped| clamped); - return self.line_start_offset(line_ix) + local; - } - relative_y -= visual_height; - } - self.content.len() + index_for_position_in(&self.last_layout, bounds, self.last_line_height, position) } fn line_start_offset(&self, line_ix: usize) -> usize { @@ -843,7 +911,7 @@ impl BlockEditor { if self.last_layout.is_empty() { return None; } - let line_height = bounds_line_height(&self.last_layout); + let line_height = self.last_line_height; let cursor = self.cursor_offset(); let line_ix = self.content[..cursor].matches('\n').count(); let line = self.last_layout.get(line_ix)?; @@ -905,11 +973,36 @@ fn editor_font(style: &gpui::TextStyle, highlight_scheme: bool) -> gpui::Font { } } -fn bounds_line_height(lines: &[WrappedLine]) -> Pixels { - lines - .first() - .map(|l| (l.ascent() + l.descent()).max(px(1.))) - .unwrap_or(px(20.)) +/// `position` in window space -> byte offset into the text shaped as +/// `lines` and painted at `bounds` with `line_height` rows. Walks logical +/// lines accumulating each one's full *visual* height (1 row, or more if +/// wrapped — see `line_visual_height`) rather than assuming every logical +/// line is exactly one row tall. +fn index_for_position_in( + lines: &[WrappedLine], + bounds: Bounds, + line_height: Pixels, + position: Point, +) -> usize { + if lines.is_empty() { + return 0; + } + let mut relative_y = (position.y - bounds.top()).max(px(0.)); + let relative_x = (position.x - bounds.left()).max(px(0.)); + let last_ix = lines.len() - 1; + let mut line_start = 0usize; + for (line_ix, line) in lines.iter().enumerate() { + let visual_height = line_visual_height(line, line_height); + if relative_y < visual_height || line_ix == last_ix { + let local = line + .closest_index_for_position(point(relative_x, relative_y), line_height) + .unwrap_or_else(|clamped| clamped); + return line_start + local; + } + relative_y -= visual_height; + line_start += line.len() + 1; + } + unreachable!("the last line always matches above"); } /// Total visual height of one logical (`\n`-delimited) line: one row, plus @@ -1029,7 +1122,7 @@ impl EntityInputHandler for BlockEditor { // *start*'s row keeps this simple rather than handling a range // that itself spans a wrap boundary. let range = self.range_from_utf16(&range_utf16); - let line_height = bounds_line_height(&self.last_layout); + let line_height = self.last_line_height; let line_ix = self.content[..range.start].matches('\n').count(); let line = self.last_layout.get(line_ix)?; let line_start = self.line_start_offset(line_ix); @@ -1302,35 +1395,8 @@ impl Element for BlockTextElement { window: &mut Window, cx: &mut App, ) -> Self::PrepaintState { - // Keep the focused editor in view (see `autoscroll_key`'s doc): - // the containing gpui `list` honors autoscroll requests made - // during an item's prepaint by scrolling — synchronously, this - // same frame — to reveal the requesting bounds. This is what keeps - // a block split below the fold (or a caret walked past the - // viewport edge) visible AND interactive: no deferred scroll to - // race the next keystroke. The request itself is issued *after* - // the caret is laid out below, so it can target the caret's own - // line plus the typeahead margin rather than the whole element. - let (is_focused, request_autoscroll, reveal_attempt) = - self.editor.update(cx, |editor, _| { - let is_focused = editor.focus_handle.is_focused(window); - let key = (editor.cursor_offset(), editor.content.len()); - let request = is_focused && editor.autoscroll_key != Some(key); - if request { - editor.autoscroll_key = Some(key); - if editor.reveal_key == Some(key) { - editor.reveal_attempts += 1; - } else { - editor.reveal_key = Some(key); - editor.reveal_attempts = 0; - } - } - (is_focused, request, editor.reveal_attempts) - }); let editor = self.editor.read(cx); let content = editor.content.clone(); - let selected_range = editor.selected_range.clone(); - let cursor = editor.cursor_offset(); let highlight_scheme = editor.highlight_scheme; let style = window.text_style(); let font_size = style.font_size.to_pixels(window.rem_size()); @@ -1373,6 +1439,48 @@ impl Element for BlockTextElement { y_offset += line_visual_height(line, line_height); } + // Resolve a click-seeded cursor (see `pending_click_position`) + // against this frame's shaped lines — before the autoscroll key + // below reads the cursor and before the caret quad is laid out, + // so the editor's very first painted frame already has the caret + // on the clicked character. + self.editor.update(cx, |editor, _| { + if let Some(position) = editor.pending_click_position.take() { + let offset = index_for_position_in(&lines, bounds, line_height, position); + editor.selection_reversed = false; + editor.selected_range = offset..offset; + } + }); + + // Keep the focused editor in view (see `autoscroll_key`'s doc): + // the containing gpui `list` honors autoscroll requests made + // during an item's prepaint by scrolling — synchronously, this + // same frame — to reveal the requesting bounds. This is what keeps + // a block split below the fold (or a caret walked past the + // viewport edge) visible AND interactive: no deferred scroll to + // race the next keystroke. The request itself is issued *after* + // the caret is laid out below, so it can target the caret's own + // line plus the typeahead margin rather than the whole element. + let (is_focused, request_autoscroll, reveal_attempt) = + self.editor.update(cx, |editor, _| { + let is_focused = editor.focus_handle.is_focused(window); + let key = (editor.cursor_offset(), editor.content.len()); + let request = is_focused && editor.autoscroll_key != Some(key); + if request { + editor.autoscroll_key = Some(key); + if editor.reveal_key == Some(key) { + editor.reveal_attempts += 1; + } else { + editor.reveal_key = Some(key); + editor.reveal_attempts = 0; + } + } + (is_focused, request, editor.reveal_attempts) + }); + let editor = self.editor.read(cx); + let selected_range = editor.selected_range.clone(); + let cursor = editor.cursor_offset(); + let mut cursor_quad = None; // The caret's visual row as (element-local top, height) — the // reveal target for the autoscroll request below, so a tall @@ -1626,9 +1734,11 @@ impl Element for BlockTextElement { } let lines = prepaint.lines.clone(); + let line_height = prepaint.line_height; self.editor.update(cx, |editor, _cx| { editor.last_layout = lines; editor.last_bounds = Some(bounds); + editor.last_line_height = line_height; }); } } diff --git a/crates/trawler/src/main.rs b/crates/trawler/src/main.rs index 71de090..9e67373 100644 --- a/crates/trawler/src/main.rs +++ b/crates/trawler/src/main.rs @@ -1727,6 +1727,31 @@ impl TrawlerApp { self.focus_block_at(id, None, window, cx); } + /// [`Self::focus_block`] for a click on the block's rendered (read- + /// only) view: the cursor lands on the clicked character instead of + /// the default end. The rendered view has no source-text layout to + /// hit-test here, so the click's window position is handed to the + /// fresh editor, which resolves it against its first laid-out frame + /// (see `BlockEditor::seed_click_cursor`). A keyboard-synthesized + /// click has no position and keeps the end-of-content default. + fn focus_block_at_click( + &mut self, + id: TreeID, + event: &ClickEvent, + window: &mut Window, + cx: &mut Context, + ) { + self.focus_block(id, window, cx); + if let (Some(position), Some(editor)) = ( + event.mouse_position(), + self.editor.as_ref().filter(|e| e.block == id), + ) { + editor + .input + .update(cx, |input, _| input.seed_click_cursor(position)); + } + } + /// [`Self::focus_block`], with the cursor placed at `cursor` (a byte /// offset into the block's content) instead of the default end. fn focus_block_at( @@ -4130,8 +4155,10 @@ impl Render for TrawlerApp { .min_h(line_height) .cursor_pointer() .on_click(cx.listener( - move |this, _event: &ClickEvent, window, cx| { - this.focus_block(id, window, cx); + move |this, event: &ClickEvent, window, cx| { + this.focus_block_at_click( + id, event, window, cx, + ); }, )) .children(render_blocks(blocks, ix, cx)), @@ -4471,8 +4498,8 @@ impl Render for TrawlerApp { .gap_1() .cursor_pointer() .on_click(cx.listener( - move |this, _event: &ClickEvent, window, cx| { - this.focus_block(id, window, cx); + move |this, event: &ClickEvent, window, cx| { + this.focus_block_at_click(id, event, window, cx); }, )) .children(render_blocks(blocks, ix, cx)), @@ -4484,8 +4511,8 @@ impl Render for TrawlerApp { .min_h(line_height) .cursor_pointer() .on_click(cx.listener( - move |this, _event: &ClickEvent, window, cx| { - this.focus_block(id, window, cx); + move |this, event: &ClickEvent, window, cx| { + this.focus_block_at_click(id, event, window, cx); }, )) .child(render_scheme_source(source)), -- 2.51.2