From aa6270e658754f0e1fc5ec27135c467fdea41b3d Mon Sep 17 00:00:00 2001 From: Tim Vermeulen Date: Mon, 10 Nov 2025 19:24:30 +0100 Subject: [PATCH] editor: Add sticky scroll (#42242) Closes #5344 https://github.com/user-attachments/assets/37ec58b0-7cf6-4eea-9b34-dccf03d3526b Release Notes: - Added a setting to stick scopes to the top of the editor --------- Co-authored-by: KyleBarton Co-authored-by: Conrad Irwin --- assets/settings/default.json | 4 + crates/editor/src/editor.rs | 50 +- crates/editor/src/editor_settings.rs | 10 + crates/editor/src/editor_tests.rs | 210 ++++++++ crates/editor/src/element.rs | 463 +++++++++++++++++- crates/language/src/language.rs | 3 + .../settings/src/settings_content/editor.rs | 14 + crates/settings/src/vscode_import.rs | 7 + crates/settings_ui/src/page_data.rs | 15 + docs/src/visual-customization.md | 4 + 10 files changed, 773 insertions(+), 7 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 4b7e8b1533001a550acb658b076eacf45aabe2f0..7fb583f95b0d6d39146ffe9e406201e958598905 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -605,6 +605,10 @@ // to both the horizontal and vertical delta values while scrolling. Fast scrolling // happens when a user holds the alt or option key while scrolling. "fast_scroll_sensitivity": 4.0, + "sticky_scroll": { + // Whether to stick scopes to the top of the editor. + "enabled": false + }, "relative_line_numbers": "disabled", // If 'search_wrap' is disabled, search result do not wrap around the end of the file. "search_wrap": true, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index c7c6fee74216cf872be3e201034bb139458afa45..8c015d09c0717e2df52f8c5f85cead07be95bf50 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -117,8 +117,8 @@ use language::{ AutoindentMode, BlockCommentConfig, BracketMatch, BracketPair, Buffer, BufferRow, BufferSnapshot, Capability, CharClassifier, CharKind, CharScopeContext, CodeLabel, CursorShape, DiagnosticEntryRef, DiffOptions, EditPredictionsMode, EditPreview, HighlightedText, IndentKind, - IndentSize, Language, OffsetRangeExt, Point, Runnable, RunnableRange, Selection, SelectionGoal, - TextObject, TransactionId, TreeSitterOptions, WordsQuery, + IndentSize, Language, OffsetRangeExt, OutlineItem, Point, Runnable, RunnableRange, Selection, + SelectionGoal, TextObject, TransactionId, TreeSitterOptions, WordsQuery, language_settings::{ self, LspInsertMode, RewrapBehavior, WordsCompletionMode, all_language_settings, language_settings, @@ -1183,6 +1183,7 @@ pub struct Editor { hide_mouse_mode: HideMouseMode, pub change_list: ChangeList, inline_value_cache: InlineValueCache, + selection_drag_state: SelectionDragState, colors: Option, post_scroll_update: Task<()>, @@ -1764,6 +1765,51 @@ impl Editor { Editor::new_internal(mode, buffer, project, None, window, cx) } + pub fn sticky_headers(&self, cx: &App) -> Option>> { + let multi_buffer = self.buffer().read(cx); + let multi_buffer_snapshot = multi_buffer.snapshot(cx); + let multi_buffer_visible_start = self + .scroll_manager + .anchor() + .anchor + .to_point(&multi_buffer_snapshot); + let max_row = multi_buffer_snapshot.max_point().row; + + let start_row = (multi_buffer_visible_start.row).min(max_row); + let end_row = (multi_buffer_visible_start.row + 10).min(max_row); + + if let Some((excerpt_id, buffer_id, buffer)) = multi_buffer.read(cx).as_singleton() { + let outline_items = buffer + .outline_items_containing( + Point::new(start_row, 0)..Point::new(end_row, 0), + true, + self.style().map(|style| style.syntax.as_ref()), + ) + .into_iter() + .map(|outline_item| OutlineItem { + depth: outline_item.depth, + range: Anchor::range_in_buffer(*excerpt_id, buffer_id, outline_item.range), + source_range_for_text: Anchor::range_in_buffer( + *excerpt_id, + buffer_id, + outline_item.source_range_for_text, + ), + text: outline_item.text, + highlight_ranges: outline_item.highlight_ranges, + name_ranges: outline_item.name_ranges, + body_range: outline_item + .body_range + .map(|range| Anchor::range_in_buffer(*excerpt_id, buffer_id, range)), + annotation_range: outline_item + .annotation_range + .map(|range| Anchor::range_in_buffer(*excerpt_id, buffer_id, range)), + }); + return Some(outline_items.collect()); + } + + None + } + fn new_internal( mode: EditorMode, multi_buffer: Entity, diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index 9e78ca4ef7f829d74907d3fdb33c561d55f9d2dc..de4198493a9ba2722aef58276ee385a117749fa0 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -33,6 +33,7 @@ pub struct EditorSettings { pub horizontal_scroll_margin: f32, pub scroll_sensitivity: f32, pub fast_scroll_sensitivity: f32, + pub sticky_scroll: StickyScroll, pub relative_line_numbers: RelativeLineNumbers, pub seed_search_query_from_cursor: SeedQuerySetting, pub use_smartcase_search: bool, @@ -65,6 +66,11 @@ pub struct Jupyter { pub enabled: bool, } +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct StickyScroll { + pub enabled: bool, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct Toolbar { pub breadcrumbs: bool, @@ -185,6 +191,7 @@ impl Settings for EditorSettings { let toolbar = editor.toolbar.unwrap(); let search = editor.search.unwrap(); let drag_and_drop_selection = editor.drag_and_drop_selection.unwrap(); + let sticky_scroll = editor.sticky_scroll.unwrap(); Self { cursor_blink: editor.cursor_blink.unwrap(), cursor_shape: editor.cursor_shape.map(Into::into), @@ -235,6 +242,9 @@ impl Settings for EditorSettings { horizontal_scroll_margin: editor.horizontal_scroll_margin.unwrap(), scroll_sensitivity: editor.scroll_sensitivity.unwrap(), fast_scroll_sensitivity: editor.fast_scroll_sensitivity.unwrap(), + sticky_scroll: StickyScroll { + enabled: sticky_scroll.enabled.unwrap(), + }, relative_line_numbers: editor.relative_line_numbers.unwrap(), seed_search_query_from_cursor: editor.seed_search_query_from_cursor.unwrap(), use_smartcase_search: editor.use_smartcase_search.unwrap(), diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index bf11a9661cc327c1187a47b4995fe044fcdeb060..c1fbc9053882d9e6a74e27a8cd7fb788289d1fa7 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -3,6 +3,7 @@ use crate::{ JoinLines, code_context_menus::CodeContextMenu, edit_prediction_tests::FakeEditPredictionProvider, + element::StickyHeader, linked_editing_ranges::LinkedEditingRanges, scroll::scroll_amount::ScrollAmount, test::{ @@ -27003,6 +27004,215 @@ async fn test_end_of_editor_context(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_sticky_scroll(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let buffer = indoc! {" + ˇfn foo() { + let abc = 123; + } + struct Bar; + impl Bar { + fn new() -> Self { + Self + } + } + fn baz() { + } + "}; + cx.set_state(&buffer); + + cx.update_editor(|e, _, cx| { + e.buffer() + .read(cx) + .as_singleton() + .unwrap() + .update(cx, |buffer, cx| { + buffer.set_language(Some(rust_lang()), cx); + }) + }); + + let mut sticky_headers = |offset: ScrollOffset| { + cx.update_editor(|e, window, cx| { + e.scroll(gpui::Point { x: 0., y: offset }, None, window, cx); + EditorElement::sticky_headers(&e, &e.snapshot(window, cx), cx) + .into_iter() + .map( + |StickyHeader { + start_point, + offset, + .. + }| { (start_point, offset) }, + ) + .collect::>() + }) + }; + + let fn_foo = Point { row: 0, column: 0 }; + let impl_bar = Point { row: 4, column: 0 }; + let fn_new = Point { row: 5, column: 4 }; + + assert_eq!(sticky_headers(0.0), vec![]); + assert_eq!(sticky_headers(0.5), vec![(fn_foo, 0.0)]); + assert_eq!(sticky_headers(1.0), vec![(fn_foo, 0.0)]); + assert_eq!(sticky_headers(1.5), vec![(fn_foo, -0.5)]); + assert_eq!(sticky_headers(2.0), vec![]); + assert_eq!(sticky_headers(2.5), vec![]); + assert_eq!(sticky_headers(3.0), vec![]); + assert_eq!(sticky_headers(3.5), vec![]); + assert_eq!(sticky_headers(4.0), vec![]); + assert_eq!(sticky_headers(4.5), vec![(impl_bar, 0.0), (fn_new, 1.0)]); + assert_eq!(sticky_headers(5.0), vec![(impl_bar, 0.0), (fn_new, 1.0)]); + assert_eq!(sticky_headers(5.5), vec![(impl_bar, 0.0), (fn_new, 0.5)]); + assert_eq!(sticky_headers(6.0), vec![(impl_bar, 0.0)]); + assert_eq!(sticky_headers(6.5), vec![(impl_bar, 0.0)]); + assert_eq!(sticky_headers(7.0), vec![(impl_bar, 0.0)]); + assert_eq!(sticky_headers(7.5), vec![(impl_bar, -0.5)]); + assert_eq!(sticky_headers(8.0), vec![]); + assert_eq!(sticky_headers(8.5), vec![]); + assert_eq!(sticky_headers(9.0), vec![]); + assert_eq!(sticky_headers(9.5), vec![]); + assert_eq!(sticky_headers(10.0), vec![]); +} + +#[gpui::test] +async fn test_scroll_by_clicking_sticky_header(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.sticky_scroll = Some(settings::StickyScrollContent { + enabled: Some(true), + }) + }); + }); + }); + let mut cx = EditorTestContext::new(cx).await; + + let line_height = cx.editor(|editor, window, _cx| { + editor + .style() + .unwrap() + .text + .line_height_in_pixels(window.rem_size()) + }); + + let buffer = indoc! {" + ˇfn foo() { + let abc = 123; + } + struct Bar; + impl Bar { + fn new() -> Self { + Self + } + } + fn baz() { + } + "}; + cx.set_state(&buffer); + + cx.update_editor(|e, _, cx| { + e.buffer() + .read(cx) + .as_singleton() + .unwrap() + .update(cx, |buffer, cx| { + buffer.set_language(Some(rust_lang()), cx); + }) + }); + + let fn_foo = || empty_range(0, 0); + let impl_bar = || empty_range(4, 0); + let fn_new = || empty_range(5, 4); + + let mut scroll_and_click = |scroll_offset: ScrollOffset, click_offset: ScrollOffset| { + cx.update_editor(|e, window, cx| { + e.scroll( + gpui::Point { + x: 0., + y: scroll_offset, + }, + None, + window, + cx, + ); + }); + cx.simulate_click( + gpui::Point { + x: px(0.), + y: click_offset as f32 * line_height, + }, + Modifiers::none(), + ); + cx.update_editor(|e, _, cx| (e.scroll_position(cx), display_ranges(e, cx))) + }; + + assert_eq!( + scroll_and_click( + 4.5, // impl Bar is halfway off the screen + 0.0 // click top of screen + ), + // scrolled to impl Bar + (gpui::Point { x: 0., y: 4. }, vec![impl_bar()]) + ); + + assert_eq!( + scroll_and_click( + 4.5, // impl Bar is halfway off the screen + 0.25 // click middle of impl Bar + ), + // scrolled to impl Bar + (gpui::Point { x: 0., y: 4. }, vec![impl_bar()]) + ); + + assert_eq!( + scroll_and_click( + 4.5, // impl Bar is halfway off the screen + 1.5 // click below impl Bar (e.g. fn new()) + ), + // scrolled to fn new() - this is below the impl Bar header which has persisted + (gpui::Point { x: 0., y: 4. }, vec![fn_new()]) + ); + + assert_eq!( + scroll_and_click( + 5.5, // fn new is halfway underneath impl Bar + 0.75 // click on the overlap of impl Bar and fn new() + ), + (gpui::Point { x: 0., y: 4. }, vec![impl_bar()]) + ); + + assert_eq!( + scroll_and_click( + 5.5, // fn new is halfway underneath impl Bar + 1.25 // click on the visible part of fn new() + ), + (gpui::Point { x: 0., y: 4. }, vec![fn_new()]) + ); + + assert_eq!( + scroll_and_click( + 1.5, // fn foo is halfway off the screen + 0.0 // click top of screen + ), + (gpui::Point { x: 0., y: 0. }, vec![fn_foo()]) + ); + + assert_eq!( + scroll_and_click( + 1.5, // fn foo is halfway off the screen + 0.75 // click visible part of let abc... + ) + .0, + // no change in scroll + // 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 }) + ); +} + #[gpui::test] async fn test_next_prev_reference(cx: &mut TestAppContext) { const CYCLE_POSITIONS: &[&'static str] = &[ diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 99a4743eb9d5e1ef8dfc99fbee7b2a74490c7356..67f6350ce625e96fcbe8734bf690fb557b86046c 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -8,8 +8,8 @@ use crate::{ HandleInput, HoveredCursor, InlayHintRefreshReason, JumpData, LineDown, LineHighlight, LineUp, MAX_LINE_LEN, MINIMAP_FONT_SIZE, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, OpenExcerpts, OpenExcerptsSplit, PageDown, PageUp, PhantomBreakpointIndicator, Point, RowExt, RowRangeExt, - SelectPhase, SelectedTextHighlight, Selection, SelectionDragState, SizingBehavior, SoftWrap, - StickyHeaderExcerpt, ToPoint, ToggleFold, ToggleFoldAll, + SelectPhase, SelectedTextHighlight, Selection, SelectionDragState, SelectionEffects, + SizingBehavior, SoftWrap, StickyHeaderExcerpt, ToPoint, ToggleFold, ToggleFoldAll, code_context_menus::{CodeActionsMenu, MENU_ASIDE_MAX_WIDTH, MENU_ASIDE_MIN_WIDTH, MENU_GAP}, display_map::{ Block, BlockContext, BlockStyle, ChunkRendererId, DisplaySnapshot, EditorMargins, @@ -29,7 +29,7 @@ use crate::{ items::BufferSearchHighlights, mouse_context_menu::{self, MenuPosition}, scroll::{ - ActiveScrollbarState, ScrollOffset, ScrollPixelOffset, ScrollbarThumbState, + ActiveScrollbarState, Autoscroll, ScrollOffset, ScrollPixelOffset, ScrollbarThumbState, scroll_amount::ScrollAmount, }, }; @@ -4555,6 +4555,138 @@ impl EditorElement { header } + fn layout_sticky_headers( + &self, + snapshot: &EditorSnapshot, + editor_width: Pixels, + is_row_soft_wrapped: impl Copy + Fn(usize) -> bool, + line_height: Pixels, + scroll_pixel_position: gpui::Point, + content_origin: gpui::Point, + gutter_dimensions: &GutterDimensions, + gutter_hitbox: &Hitbox, + text_hitbox: &Hitbox, + window: &mut Window, + cx: &mut App, + ) -> Option { + let show_line_numbers = snapshot + .show_line_numbers + .unwrap_or_else(|| EditorSettings::get_global(cx).gutter.line_numbers); + + let rows = Self::sticky_headers(self.editor.read(cx), snapshot, cx); + + let mut lines = Vec::::new(); + + for StickyHeader { + item, + sticky_row, + start_point, + offset, + } in rows.into_iter().rev() + { + let line = layout_line( + sticky_row, + snapshot, + &self.style, + editor_width, + is_row_soft_wrapped, + window, + cx, + ); + + let line_number = show_line_numbers.then(|| { + let number = (start_point.row + 1).to_string(); + let color = cx.theme().colors().editor_line_number; + self.shape_line_number(SharedString::from(number), color, window) + }); + + lines.push(StickyHeaderLine::new( + sticky_row, + line_height * offset as f32, + line, + line_number, + item.range.start, + line_height, + scroll_pixel_position, + content_origin, + gutter_hitbox, + text_hitbox, + window, + cx, + )); + } + + lines.reverse(); + if lines.is_empty() { + return None; + } + + Some(StickyHeaders { + lines, + gutter_background: cx.theme().colors().editor_gutter_background, + content_background: self.style.background, + gutter_right_padding: gutter_dimensions.right_padding, + }) + } + + pub(crate) fn sticky_headers( + editor: &Editor, + snapshot: &EditorSnapshot, + cx: &App, + ) -> Vec { + let scroll_top = snapshot.scroll_position().y; + + let mut end_rows = Vec::::new(); + let mut rows = Vec::::new(); + + let items = editor.sticky_headers(cx).unwrap_or_default(); + + for item in items { + let start_point = item.range.start.to_point(snapshot.buffer_snapshot()); + let end_point = item.range.end.to_point(snapshot.buffer_snapshot()); + + let sticky_row = snapshot + .display_snapshot + .point_to_display_point(start_point, Bias::Left) + .row(); + let end_row = snapshot + .display_snapshot + .point_to_display_point(end_point, Bias::Left) + .row(); + let max_sticky_row = end_row.previous_row(); + if max_sticky_row <= sticky_row { + continue; + } + + while end_rows + .last() + .is_some_and(|&last_end| last_end < sticky_row) + { + end_rows.pop(); + } + let depth = end_rows.len(); + let adjusted_scroll_top = scroll_top + depth as f64; + + if sticky_row.as_f64() >= adjusted_scroll_top || end_row.as_f64() <= adjusted_scroll_top + { + continue; + } + + let max_scroll_offset = max_sticky_row.as_f64() - scroll_top; + let offset = (depth as f64).min(max_scroll_offset); + + end_rows.push(end_row); + rows.push(StickyHeader { + item, + sticky_row, + start_point, + offset, + }); + } + + rows + } + fn layout_cursor_popovers( &self, line_height: Pixels, @@ -6407,6 +6539,89 @@ impl EditorElement { } } + fn paint_sticky_headers( + &mut self, + layout: &mut EditorLayout, + window: &mut Window, + cx: &mut App, + ) { + let Some(mut sticky_headers) = layout.sticky_headers.take() else { + return; + }; + + if sticky_headers.lines.is_empty() { + layout.sticky_headers = Some(sticky_headers); + return; + } + + let whitespace_setting = self + .editor + .read(cx) + .buffer + .read(cx) + .language_settings(cx) + .show_whitespaces; + sticky_headers.paint(layout, whitespace_setting, window, cx); + + let sticky_header_hitboxes: Vec = sticky_headers + .lines + .iter() + .map(|line| line.hitbox.clone()) + .collect(); + let hovered_hitbox = sticky_header_hitboxes + .iter() + .find_map(|hitbox| hitbox.is_hovered(window).then_some(hitbox.id)); + + window.on_mouse_event(move |_: &MouseMoveEvent, phase, window, _cx| { + if !phase.bubble() { + return; + } + + let current_hover = sticky_header_hitboxes + .iter() + .find_map(|hitbox| hitbox.is_hovered(window).then_some(hitbox.id)); + if hovered_hitbox != current_hover { + window.refresh(); + } + }); + + 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; + window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { + if !phase.bubble() { + return; + } + + if event.button == MouseButton::Left && hitbox.is_hovered(window) { + editor.update(cx, |editor, cx| { + editor.change_selections( + SelectionEffects::scroll(Autoscroll::top_relative(line_index)), + window, + cx, + |selections| selections.select_ranges([target_anchor..target_anchor]), + ); + cx.stop_propagation(); + }); + } + }); + } + + let text_bounds = layout.position_map.text_hitbox.bounds; + let border_top = text_bounds.top() + + sticky_headers.lines.last().unwrap().offset + + layout.position_map.line_height; + let separator_height = px(1.); + let border_bounds = Bounds::from_corners( + point(layout.gutter_hitbox.bounds.left(), border_top), + point(text_bounds.right(), border_top + separator_height), + ); + window.paint_quad(fill(border_bounds, cx.theme().colors().border_variant)); + + layout.sticky_headers = Some(sticky_headers); + } + fn paint_lines_background( &mut self, layout: &mut EditorLayout, @@ -8107,6 +8322,27 @@ impl LineWithInvisibles { cx: &mut App, ) { let line_y = f32::from(line_height) * Pixels::from(row.as_f64() - scroll_position.y); + self.prepaint_with_custom_offset( + line_height, + scroll_pixel_position, + content_origin, + line_y, + line_elements, + window, + cx, + ); + } + + fn prepaint_with_custom_offset( + &mut self, + line_height: Pixels, + scroll_pixel_position: gpui::Point, + content_origin: gpui::Point, + line_y: Pixels, + line_elements: &mut SmallVec<[AnyElement; 1]>, + window: &mut Window, + cx: &mut App, + ) { let mut fragment_origin = content_origin + gpui::point(Pixels::from(-scroll_pixel_position.x), line_y); for fragment in &mut self.fragments { @@ -8141,9 +8377,31 @@ impl LineWithInvisibles { window: &mut Window, cx: &mut App, ) { - let line_height = layout.position_map.line_height; - let line_y = line_height * (row.as_f64() - layout.position_map.scroll_position.y) as f32; + self.draw_with_custom_offset( + layout, + row, + content_origin, + layout.position_map.line_height + * (row.as_f64() - layout.position_map.scroll_position.y) as f32, + whitespace_setting, + selection_ranges, + window, + cx, + ); + } + fn draw_with_custom_offset( + &self, + layout: &EditorLayout, + row: DisplayRow, + content_origin: gpui::Point, + line_y: Pixels, + whitespace_setting: ShowWhitespaceSetting, + selection_ranges: &[Range], + window: &mut Window, + cx: &mut App, + ) { + let line_height = layout.position_map.line_height; let mut fragment_origin = content_origin + gpui::point( Pixels::from(-layout.position_map.scroll_pixel_position.x), @@ -8582,6 +8840,7 @@ impl Element for EditorElement { }; let is_minimap = self.editor.read(cx).mode.is_minimap(); + let is_singleton = self.editor.read(cx).buffer_kind(cx) == ItemBufferKind::Singleton; if !is_minimap { let focus_handle = self.editor.focus_handle(cx); @@ -9228,6 +9487,26 @@ impl Element for EditorElement { scroll_position.x * f64::from(em_advance), scroll_position.y * f64::from(line_height), ); + let sticky_headers = if !is_minimap + && is_singleton + && EditorSettings::get_global(cx).sticky_scroll.enabled + { + self.layout_sticky_headers( + &snapshot, + editor_width, + is_row_soft_wrapped, + line_height, + scroll_pixel_position, + content_origin, + &gutter_dimensions, + &gutter_hitbox, + &text_hitbox, + window, + cx, + ) + } else { + None + }; let indent_guides = self.layout_indent_guides( content_origin, text_hitbox.origin, @@ -9697,6 +9976,7 @@ impl Element for EditorElement { tab_invisible, space_invisible, sticky_buffer_header, + sticky_headers, expand_toggles, } }) @@ -9767,6 +10047,7 @@ impl Element for EditorElement { } }); + self.paint_sticky_headers(layout, window, cx); self.paint_minimap(layout, window, cx); self.paint_scrollbars(layout, window, cx); self.paint_edit_prediction_popover(layout, window, cx); @@ -9875,15 +10156,180 @@ pub struct EditorLayout { tab_invisible: ShapedLine, space_invisible: ShapedLine, sticky_buffer_header: Option, + sticky_headers: Option, document_colors: Option<(DocumentColorsRenderMode, Vec<(Range, Hsla)>)>, } +struct StickyHeaders { + lines: Vec, + gutter_background: Hsla, + content_background: Hsla, + gutter_right_padding: Pixels, +} + +struct StickyHeaderLine { + row: DisplayRow, + offset: Pixels, + line: LineWithInvisibles, + line_number: Option, + elements: SmallVec<[AnyElement; 1]>, + available_text_width: Pixels, + target_anchor: Anchor, + hitbox: Hitbox, +} + impl EditorLayout { fn line_end_overshoot(&self) -> Pixels { 0.15 * self.position_map.line_height } } +impl StickyHeaders { + fn paint( + &mut self, + layout: &mut EditorLayout, + whitespace_setting: ShowWhitespaceSetting, + window: &mut Window, + cx: &mut App, + ) { + let line_height = layout.position_map.line_height; + + for line in self.lines.iter_mut().rev() { + window.paint_layer( + Bounds::new( + layout.gutter_hitbox.origin + point(Pixels::ZERO, line.offset), + size(line.hitbox.size.width, line_height), + ), + |window| { + let gutter_bounds = Bounds::new( + layout.gutter_hitbox.origin + point(Pixels::ZERO, line.offset), + size(layout.gutter_hitbox.size.width, line_height), + ); + window.paint_quad(fill(gutter_bounds, self.gutter_background)); + + let text_bounds = Bounds::new( + layout.position_map.text_hitbox.origin + point(Pixels::ZERO, line.offset), + size(line.available_text_width, line_height), + ); + window.paint_quad(fill(text_bounds, self.content_background)); + + if line.hitbox.is_hovered(window) { + let hover_overlay = cx.theme().colors().panel_overlay_hover; + window.paint_quad(fill(gutter_bounds, hover_overlay)); + window.paint_quad(fill(text_bounds, hover_overlay)); + } + + line.paint( + layout, + self.gutter_right_padding, + line.available_text_width, + layout.content_origin, + line_height, + whitespace_setting, + window, + cx, + ); + }, + ); + + window.set_cursor_style(CursorStyle::PointingHand, &line.hitbox); + } + } +} + +impl StickyHeaderLine { + fn new( + row: DisplayRow, + offset: Pixels, + mut line: LineWithInvisibles, + line_number: Option, + target_anchor: Anchor, + line_height: Pixels, + scroll_pixel_position: gpui::Point, + content_origin: gpui::Point, + gutter_hitbox: &Hitbox, + text_hitbox: &Hitbox, + window: &mut Window, + cx: &mut App, + ) -> Self { + let mut elements = SmallVec::<[AnyElement; 1]>::new(); + line.prepaint_with_custom_offset( + line_height, + scroll_pixel_position, + content_origin, + offset, + &mut elements, + window, + cx, + ); + + let hitbox_bounds = Bounds::new( + gutter_hitbox.origin + point(Pixels::ZERO, offset), + size(text_hitbox.right() - gutter_hitbox.left(), line_height), + ); + let available_text_width = + (hitbox_bounds.size.width - gutter_hitbox.size.width).max(Pixels::ZERO); + + Self { + row, + offset, + line, + line_number, + elements, + available_text_width, + target_anchor, + hitbox: window.insert_hitbox(hitbox_bounds, HitboxBehavior::BlockMouseExceptScroll), + } + } + + fn paint( + &mut self, + layout: &EditorLayout, + gutter_right_padding: Pixels, + available_text_width: Pixels, + content_origin: gpui::Point, + line_height: Pixels, + whitespace_setting: ShowWhitespaceSetting, + window: &mut Window, + cx: &mut App, + ) { + window.with_content_mask( + Some(ContentMask { + bounds: Bounds::new( + layout.position_map.text_hitbox.bounds.origin + + point(Pixels::ZERO, self.offset), + size(available_text_width, line_height), + ), + }), + |window| { + self.line.draw_with_custom_offset( + layout, + self.row, + content_origin, + self.offset, + whitespace_setting, + &[], + window, + cx, + ); + for element in &mut self.elements { + element.paint(window, cx); + } + }, + ); + + if let Some(line_number) = &self.line_number { + let gutter_origin = layout.gutter_hitbox.origin + point(Pixels::ZERO, self.offset); + let gutter_width = layout.gutter_hitbox.size.width; + let origin = point( + gutter_origin.x + gutter_width - gutter_right_padding - line_number.width, + gutter_origin.y, + ); + line_number.paint(origin, line_height, window, cx).log_err(); + } + } +} + #[derive(Debug)] struct LineNumberSegment { shaped_line: ShapedLine, @@ -10730,6 +11176,13 @@ impl HighlightedRange { } } +pub(crate) struct StickyHeader { + pub item: language::OutlineItem, + pub sticky_row: DisplayRow, + pub start_point: Point, + pub offset: ScrollOffset, +} + enum CursorPopoverType { CodeContextMenu, EditPrediction, diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 75f53524efc40e2cfaf06c5bbe893b7c5af5883c..2a2f870d6b55abc57a14e623375f77b9fb2d5dbc 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -2613,6 +2613,9 @@ pub fn rust_lang() -> Arc { Some(tree_sitter_rust::LANGUAGE.into()), ) .with_queries(LanguageQueries { + outline: Some(Cow::from(include_str!( + "../../languages/src/rust/outline.scm" + ))), indents: Some(Cow::from( r#" [ diff --git a/crates/settings/src/settings_content/editor.rs b/crates/settings/src/settings_content/editor.rs index 5c33dbc2af48a55e176a5f093afcc83437054932..2dc3c6c0fdc78bf470e78e0577cc886d1471e8b2 100644 --- a/crates/settings/src/settings_content/editor.rs +++ b/crates/settings/src/settings_content/editor.rs @@ -96,6 +96,10 @@ pub struct EditorSettingsContent { /// Default: 4.0 #[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")] pub fast_scroll_sensitivity: Option, + /// Settings for sticking scopes to the top of the editor. + /// + /// Default: sticky scroll is disabled + pub sticky_scroll: Option, /// Whether the line numbers on editors gutter are relative or not. /// When "enabled" shows relative number of buffer lines, when "wrapped" shows /// relative number of display lines. @@ -312,6 +316,16 @@ pub struct ScrollbarContent { pub axes: Option, } +/// Sticky scroll related settings +#[skip_serializing_none] +#[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)] +pub struct StickyScrollContent { + /// Whether sticky scroll is enabled. + /// + /// Default: false + pub enabled: Option, +} + /// Minimap related settings #[skip_serializing_none] #[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)] diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index a8fd15c32acc130a9cde4948cc2aa66f898708d0..36bd84e1a145a9a64eadbaec9411f904b9a881c9 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -255,6 +255,7 @@ impl VsCodeSettings { excerpt_context_lines: None, expand_excerpt_lines: None, fast_scroll_sensitivity: self.read_f32("editor.fastScrollSensitivity"), + sticky_scroll: self.sticky_scroll_content(), go_to_definition_fallback: None, gutter: self.gutter_content(), hide_mouse: None, @@ -303,6 +304,12 @@ impl VsCodeSettings { } } + fn sticky_scroll_content(&self) -> Option { + skip_default(StickyScrollContent { + enabled: self.read_bool("editor.stickyScroll.enabled"), + }) + } + fn gutter_content(&self) -> Option { skip_default(GutterContent { line_numbers: self.read_enum("editor.lineNumbers", |s| match s { diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 98db1a7efee6b333d258f3db29142532c514aca3..392796c091d379429f2ae787b14a75841bef12fa 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -1352,6 +1352,21 @@ pub(crate) fn settings_data(cx: &App) -> Vec { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Sticky Scroll", + description: "Whether to stick scopes to the top of the editor", + field: Box::new(SettingField { + json_path: Some("sticky_scroll.enabled"), + pick: |settings_content| { + settings_content.editor.sticky_scroll.as_ref().and_then(|sticky_scroll| sticky_scroll.enabled.as_ref()) + }, + write: |settings_content, value| { + settings_content.editor.sticky_scroll.get_or_insert_default().enabled = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SectionHeader("Signature Help"), SettingsPageItem::SettingItem(SettingItem { title: "Auto Signature Help", diff --git a/docs/src/visual-customization.md b/docs/src/visual-customization.md index 77d3ac2d87fe95f9b9f6836a2a14ae58f6ef4c9a..98b07797a2f7904acd10fe54b04ab39fe0854667 100644 --- a/docs/src/visual-customization.md +++ b/docs/src/visual-customization.md @@ -218,6 +218,10 @@ TBD: Centered layout related settings "active_line_width": 1, // Width of active guide in pixels [1-10] "coloring": "fixed", // disabled, fixed, indent_aware "background_coloring": "disabled" // disabled, indent_aware + }, + + "sticky_scroll": { + "enabled": false // Whether to stick scopes to the top of the editor. Disabled by default. } ```