From e5150abe6f6ed1f7c8a5420ce66f20921ba19110 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Thu, 19 Mar 2026 14:49:30 +0100 Subject: [PATCH] editor: Place cursor at clicked column when clicking sticky headers (#51911) When clicking on a sticky header line, the cursor is now placed at the column corresponding to the click position, rather than always at the start of the outline item's range. The hover cursor is also changed from a pointing hand to an I-beam to reflect that clicking behaves like clicking in normal editor text. Release Notes: - Clicking a sticky header now puts the cursor at the clicked column --- crates/editor/src/editor_tests.rs | 32 +++++++++++++++- crates/editor/src/element.rs | 63 +++++++++++++++++++++++++------ 2 files changed, 82 insertions(+), 13 deletions(-) diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 05880f9c2a3de2325b8826af0eb3641da0162a4a..fefebb67a6f655c5b81b013c1d447b713e72575d 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -30828,7 +30828,7 @@ async fn test_scroll_by_clicking_sticky_header(cx: &mut TestAppContext) { let fn_foo = || empty_range(0, 0); let impl_bar = || empty_range(4, 0); - let fn_new = || empty_range(5, 4); + let fn_new = || empty_range(5, 0); let mut scroll_and_click = |scroll_offset: ScrollOffset, click_offset: ScrollOffset| { cx.update_editor(|e, window, cx| { @@ -30914,6 +30914,36 @@ async fn test_scroll_by_clicking_sticky_header(cx: &mut TestAppContext) { // we don't assert on the visible_range because if we clicked the gutter, our line is fully selected (gpui::Point { x: 0., y: 1.5 }) ); + + // Verify clicking at a specific x position within a sticky header places + // the cursor at the corresponding column. + let (text_origin_x, em_width) = cx.update_editor(|editor, _, _| { + let position_map = editor.last_position_map.as_ref().unwrap(); + ( + position_map.text_hitbox.bounds.origin.x, + position_map.em_layout_width, + ) + }); + + // Click on "impl Bar {" sticky header at column 5 (the 'B' in 'Bar'). + // The text "impl Bar {" starts at column 0, so column 5 = 'B'. + let click_x = text_origin_x + em_width * 5.5; + cx.update_editor(|e, window, cx| { + e.scroll(gpui::Point { x: 0., y: 4.5 }, None, window, cx); + }); + cx.run_until_parked(); + cx.simulate_click( + gpui::Point { + x: click_x, + y: 0.25 * line_height, + }, + Modifiers::none(), + ); + cx.run_until_parked(); + let (scroll_pos, selections) = + cx.update_editor(|e, _, cx| (e.scroll_position(cx), display_ranges(e, cx))); + assert_eq!(scroll_pos, gpui::Point { x: 0., y: 4. }); + assert_eq!(selections, vec![empty_range(4, 5)]); } #[gpui::test] diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index b94add33f04a456bd4e5ad8a887f36bc0e04e29b..59b474b1c91c0ad62eb9c260facb2ab46ef4f9c6 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -4597,7 +4597,6 @@ impl EditorElement { let mut lines = Vec::::new(); for StickyHeader { - item, sticky_row, start_point, offset, @@ -4637,7 +4636,6 @@ impl EditorElement { line_height * offset as f32, line, line_number, - item.range.start, line_height, scroll_pixel_position, content_origin, @@ -4703,7 +4701,6 @@ impl EditorElement { end_rows.push(end_row); rows.push(StickyHeader { - item: item.clone(), sticky_row, start_point, offset, @@ -6701,22 +6698,33 @@ impl EditorElement { } }); + let position_map = layout.position_map.clone(); + for (line_index, line) in sticky_headers.lines.iter().enumerate() { let editor = self.editor.clone(); let hitbox = line.hitbox.clone(); - let target_anchor = line.target_anchor; + let row = line.row; + let line_layout = line.line.clone(); + let position_map = position_map.clone(); window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { if !phase.bubble() { return; } if event.button == MouseButton::Left && hitbox.is_hovered(window) { + let point_for_position = + position_map.point_for_position_on_line(event.position, row, &line_layout); + editor.update(cx, |editor, cx| { + let snapshot = editor.snapshot(window, cx); + let anchor = snapshot + .display_snapshot + .display_point_to_anchor(point_for_position.previous_valid, Bias::Left); editor.change_selections( SelectionEffects::scroll(Autoscroll::top_relative(line_index)), window, cx, - |selections| selections.select_ranges([target_anchor..target_anchor]), + |selections| selections.select_ranges([anchor..anchor]), ); cx.stop_propagation(); }); @@ -11261,11 +11269,10 @@ struct StickyHeaders { struct StickyHeaderLine { row: DisplayRow, offset: Pixels, - line: LineWithInvisibles, + line: Rc, line_number: Option, elements: SmallVec<[AnyElement; 1]>, available_text_width: Pixels, - target_anchor: Anchor, hitbox: Hitbox, } @@ -11323,7 +11330,7 @@ impl StickyHeaders { }, ); - window.set_cursor_style(CursorStyle::PointingHand, &line.hitbox); + window.set_cursor_style(CursorStyle::IBeam, &line.hitbox); } } } @@ -11334,7 +11341,6 @@ impl StickyHeaderLine { offset: Pixels, mut line: LineWithInvisibles, line_number: Option, - target_anchor: Anchor, line_height: Pixels, scroll_pixel_position: gpui::Point, content_origin: gpui::Point, @@ -11364,11 +11370,10 @@ impl StickyHeaderLine { Self { row, offset, - line, + line: Rc::new(line), line_number, elements, available_text_width, - target_anchor, hitbox: window.insert_hitbox(hitbox_bounds, HitboxBehavior::BlockMouseExceptScroll), } } @@ -11950,6 +11955,41 @@ impl PositionMap { column_overshoot_after_line_end, } } + + fn point_for_position_on_line( + &self, + position: gpui::Point, + row: DisplayRow, + line: &LineWithInvisibles, + ) -> PointForPosition { + let text_bounds = self.text_hitbox.bounds; + let scroll_position = self.snapshot.scroll_position(); + let position = position - text_bounds.origin; + let x = position.x + (scroll_position.x as f32 * self.em_layout_width); + + let alignment_offset = line.alignment_offset(self.text_align, self.content_width); + let x_relative_to_text = x - alignment_offset; + let (column, x_overshoot_after_line_end) = + if let Some(ix) = line.index_for_x(x_relative_to_text) { + (ix as u32, px(0.)) + } else { + (line.len as u32, px(0.).max(x_relative_to_text - line.width)) + }; + + let mut exact_unclipped = DisplayPoint::new(row, column); + let previous_valid = self.snapshot.clip_point(exact_unclipped, Bias::Left); + let next_valid = self.snapshot.clip_point(exact_unclipped, Bias::Right); + + let column_overshoot_after_line_end = + (x_overshoot_after_line_end / self.em_layout_width) as u32; + *exact_unclipped.column_mut() += column_overshoot_after_line_end; + PointForPosition { + previous_valid, + next_valid, + exact_unclipped, + column_overshoot_after_line_end, + } + } } pub(crate) struct BlockLayout { @@ -12286,7 +12326,6 @@ impl HighlightedRange { } pub(crate) struct StickyHeader { - pub item: language::OutlineItem, pub sticky_row: DisplayRow, pub start_point: Point, pub offset: ScrollOffset,