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 <kjb@initialcapacity.io>
Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
This commit is contained in:
Tim Vermeulen
2025-11-10 11:24:30 -07:00
committed by GitHub
co-authored by KyleBarton Conrad Irwin
parent d896af2f15
commit aa6270e658
10 changed files with 773 additions and 7 deletions
+4
View File
@@ -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,
+48 -2
View File
@@ -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<LspColorData>,
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<Vec<OutlineItem<Anchor>>> {
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<MultiBuffer>,
+10
View File
@@ -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(),
+210
View File
@@ -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::<Vec<_>>()
})
};
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] = &[
+458 -5
View File
@@ -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<ScrollPixelOffset>,
content_origin: gpui::Point<Pixels>,
gutter_dimensions: &GutterDimensions,
gutter_hitbox: &Hitbox,
text_hitbox: &Hitbox,
window: &mut Window,
cx: &mut App,
) -> Option<StickyHeaders> {
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::<StickyHeaderLine>::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<StickyHeader> {
let scroll_top = snapshot.scroll_position().y;
let mut end_rows = Vec::<DisplayRow>::new();
let mut rows = Vec::<StickyHeader>::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<Hitbox> = 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<ScrollPixelOffset>,
content_origin: gpui::Point<Pixels>,
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<Pixels>,
line_y: Pixels,
whitespace_setting: ShowWhitespaceSetting,
selection_ranges: &[Range<DisplayPoint>],
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<AnyElement>,
sticky_headers: Option<StickyHeaders>,
document_colors: Option<(DocumentColorsRenderMode, Vec<(Range<DisplayPoint>, Hsla)>)>,
}
struct StickyHeaders {
lines: Vec<StickyHeaderLine>,
gutter_background: Hsla,
content_background: Hsla,
gutter_right_padding: Pixels,
}
struct StickyHeaderLine {
row: DisplayRow,
offset: Pixels,
line: LineWithInvisibles,
line_number: Option<ShapedLine>,
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<ShapedLine>,
target_anchor: Anchor,
line_height: Pixels,
scroll_pixel_position: gpui::Point<ScrollPixelOffset>,
content_origin: gpui::Point<Pixels>,
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<Pixels>,
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<Anchor>,
pub sticky_row: DisplayRow,
pub start_point: Point,
pub offset: ScrollOffset,
}
enum CursorPopoverType {
CodeContextMenu,
EditPrediction,
+3
View File
@@ -2613,6 +2613,9 @@ pub fn rust_lang() -> Arc<Language> {
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#"
[
@@ -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<f32>,
/// Settings for sticking scopes to the top of the editor.
///
/// Default: sticky scroll is disabled
pub sticky_scroll: Option<StickyScrollContent>,
/// 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<ScrollbarAxesContent>,
}
/// 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<bool>,
}
/// Minimap related settings
#[skip_serializing_none]
#[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)]
+7
View File
@@ -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<StickyScrollContent> {
skip_default(StickyScrollContent {
enabled: self.read_bool("editor.stickyScroll.enabled"),
})
}
fn gutter_content(&self) -> Option<GutterContent> {
skip_default(GutterContent {
line_numbers: self.read_enum("editor.lineNumbers", |s| match s {
+15
View File
@@ -1352,6 +1352,21 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
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",
+4
View File
@@ -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.
}
```