Compare commits

...

9 Commits

Author SHA1 Message Date
Conrad Irwin
fbf860f90d Fix test
Co-authored-By:  Thomas Heartman <thomasheartman+github@gmail.com>
2025-10-21 10:53:20 -06:00
Thomas Heartman
51e9cf22e1 fix issue with not rendering correctly when wrapped relative: false 2025-10-21 18:38:29 +02:00
Thomas Heartman
1095b709e2 add setting to vscode_import.rs 2025-10-21 17:14:44 +02:00
Thomas Heartman
0e933afa28 don't show line numbers on wrapped lines unless that setting is on 2025-10-21 15:43:33 +02:00
Thomas Heartman
ffa83fd605 add editor config 2025-10-21 15:43:33 +02:00
Thomas Heartman
300bec9303 more fixes 2025-10-21 15:42:21 +02:00
Thomas Heartman
da7811ee89 display line numbers on the current, wrapped line 2025-10-21 15:42:21 +02:00
Thomas Heartman
ae263b78e2 update line layout to include multiple segments 2025-10-21 15:42:21 +02:00
Thomas Heartman
38d4a7b7c0 feat: add support for relative line numbers on wrapped lines 2025-10-21 15:37:41 +02:00
8 changed files with 260 additions and 109 deletions

View File

@@ -593,6 +593,8 @@
// happens when a user holds the alt or option key while scrolling.
"fast_scroll_sensitivity": 4.0,
"relative_line_numbers": false,
// Whether to show relative line numbers for wrapped lines as well as buffer lines.
"relative_line_numbers_for_wrapped_lines": false,
// If 'search_wrap' is disabled, search result do not wrap around the end of the file.
"search_wrap": true,
// Search options to enable by default when opening new project and buffer searches.

View File

@@ -1024,6 +1024,7 @@ impl Iterator for WrapRows<'_> {
multibuffer_row: None,
diff_status,
expand_info: None,
wrapped_buffer_row: buffer_row.buffer_row,
}
} else {
buffer_row

View File

@@ -34,6 +34,7 @@ pub struct EditorSettings {
pub scroll_sensitivity: f32,
pub fast_scroll_sensitivity: f32,
pub relative_line_numbers: bool,
pub relative_line_numbers_for_wrapped_lines: bool,
pub seed_search_query_from_cursor: SeedQuerySetting,
pub use_smartcase_search: bool,
pub multi_cursor_modifier: MultiCursorModifier,
@@ -234,6 +235,9 @@ impl Settings for EditorSettings {
scroll_sensitivity: editor.scroll_sensitivity.unwrap(),
fast_scroll_sensitivity: editor.fast_scroll_sensitivity.unwrap(),
relative_line_numbers: editor.relative_line_numbers.unwrap(),
relative_line_numbers_for_wrapped_lines: editor
.relative_line_numbers_for_wrapped_lines
.unwrap(),
seed_search_query_from_cursor: editor.seed_search_query_from_cursor.unwrap(),
use_smartcase_search: editor.use_smartcase_search.unwrap(),
multi_cursor_modifier: editor.multi_cursor_modifier.unwrap(),

View File

@@ -763,8 +763,14 @@ impl EditorElement {
.row;
if line_numbers
.get(&MultiBufferRow(multi_buffer_row))
.and_then(|line_number| line_number.hitbox.as_ref())
.is_some_and(|hitbox| hitbox.contains(&event.position))
.is_some_and(|line_layout| {
line_layout.segments.iter().any(|segment| {
segment
.hitbox
.as_ref()
.is_some_and(|hitbox| hitbox.contains(&event.position))
})
})
{
let line_offset_from_top = display_row - position_map.scroll_position.y as u32;
@@ -3143,6 +3149,7 @@ impl EditorElement {
snapshot: &EditorSnapshot,
rows: &Range<DisplayRow>,
relative_to: Option<DisplayRow>,
use_display_offset: bool,
) -> HashMap<DisplayRow, DisplayRowDelta> {
let mut relative_rows: HashMap<DisplayRow, DisplayRowDelta> = Default::default();
let Some(relative_to) = relative_to else {
@@ -3152,6 +3159,7 @@ impl EditorElement {
let start = rows.start.min(relative_to);
let end = rows.end.max(relative_to);
// todo!() can we pass this in?
let buffer_rows = snapshot
.row_infos(start)
.take(1 + end.minus(start) as usize)
@@ -3160,8 +3168,15 @@ impl EditorElement {
let head_idx = relative_to.minus(start);
let mut delta = 1;
let mut i = head_idx + 1;
let should_count_line = |row_info: &RowInfo| {
if use_display_offset {
row_info.buffer_row.is_some() || row_info.wrapped_buffer_row.is_some()
} else {
row_info.buffer_row.is_some()
}
};
while i < buffer_rows.len() as u32 {
if buffer_rows[i as usize].buffer_row.is_some() {
if should_count_line(&buffer_rows[i as usize]) {
if rows.contains(&DisplayRow(i + start.0)) {
relative_rows.insert(DisplayRow(i + start.0), delta);
}
@@ -3171,13 +3186,13 @@ impl EditorElement {
}
delta = 1;
i = head_idx.min(buffer_rows.len() as u32 - 1);
while i > 0 && buffer_rows[i as usize].buffer_row.is_none() {
while i > 0 && buffer_rows[i as usize].buffer_row.is_none() && !use_display_offset {
i -= 1;
}
while i > 0 {
i -= 1;
if buffer_rows[i as usize].buffer_row.is_some() {
if should_count_line(&buffer_rows[i as usize]) {
if rows.contains(&DisplayRow(i + start.0)) {
relative_rows.insert(DisplayRow(i + start.0), delta);
}
@@ -3209,95 +3224,120 @@ impl EditorElement {
return Arc::default();
}
let (newest_selection_head, is_relative) = self.editor.update(cx, |editor, cx| {
let newest_selection_head = newest_selection_head.unwrap_or_else(|| {
let newest = editor
.selections
.newest::<Point>(&editor.display_snapshot(cx));
SelectionLayout::new(
newest,
editor.selections.line_mode(),
editor.cursor_shape,
&snapshot.display_snapshot,
true,
true,
None,
let (newest_selection_head, is_relative, use_relative_for_wrapped_lines) =
self.editor.update(cx, |editor, cx| {
let newest_selection_head = newest_selection_head.unwrap_or_else(|| {
let newest = editor
.selections
.newest::<Point>(&editor.display_snapshot(cx));
SelectionLayout::new(
newest,
editor.selections.line_mode(),
editor.cursor_shape,
&snapshot.display_snapshot,
true,
true,
None,
)
.head
});
let is_relative = editor.should_use_relative_line_numbers(cx);
let use_relative_for_wrapped_lines = is_relative
&& EditorSettings::get_global(cx).relative_line_numbers_for_wrapped_lines;
(
newest_selection_head,
is_relative,
use_relative_for_wrapped_lines,
)
.head
});
let is_relative = editor.should_use_relative_line_numbers(cx);
(newest_selection_head, is_relative)
});
let relative_to = if is_relative {
Some(newest_selection_head.row())
} else {
None
};
let relative_rows = self.calculate_relative_line_numbers(snapshot, &rows, relative_to);
let relative_rows = self.calculate_relative_line_numbers(
snapshot,
&rows,
relative_to,
use_relative_for_wrapped_lines,
);
let mut line_number = String::new();
let line_numbers = buffer_rows
.iter()
.enumerate()
.flat_map(|(ix, row_info)| {
let display_row = DisplayRow(rows.start.0 + ix as u32);
line_number.clear();
let non_relative_number = row_info.buffer_row? + 1;
let number = relative_rows
.get(&display_row)
.unwrap_or(&non_relative_number);
write!(&mut line_number, "{number}").unwrap();
if row_info
.diff_status
.is_some_and(|status| status.is_deleted())
{
return None;
}
let segments = buffer_rows.iter().enumerate().flat_map(|(ix, row_info)| {
let display_row = DisplayRow(rows.start.0 + ix as u32);
line_number.clear();
let non_relative_number = if use_relative_for_wrapped_lines {
row_info.buffer_row.or(row_info.wrapped_buffer_row)? + 1
} else {
row_info.buffer_row? + 1
};
let number = relative_rows
.get(&display_row)
.unwrap_or(&non_relative_number);
write!(&mut line_number, "{number}").unwrap();
if row_info
.diff_status
.is_some_and(|status| status.is_deleted())
{
return None;
}
let color = active_rows
.get(&display_row)
.map(|spec| {
if spec.breakpoint {
cx.theme().colors().debugger_accent
} else {
cx.theme().colors().editor_active_line_number
}
})
.unwrap_or_else(|| cx.theme().colors().editor_line_number);
let shaped_line =
self.shape_line_number(SharedString::from(&line_number), color, window);
let scroll_top = scroll_position.y * ScrollPixelOffset::from(line_height);
let line_origin = gutter_hitbox.map(|hitbox| {
hitbox.origin
+ point(
hitbox.size.width - shaped_line.width - gutter_dimensions.right_padding,
ix as f32 * line_height
- Pixels::from(scroll_top % ScrollPixelOffset::from(line_height)),
)
});
#[cfg(not(test))]
let hitbox = line_origin.map(|line_origin| {
window.insert_hitbox(
Bounds::new(line_origin, size(shaped_line.width, line_height)),
HitboxBehavior::Normal,
let color = active_rows
.get(&display_row)
.map(|spec| {
if spec.breakpoint {
cx.theme().colors().debugger_accent
} else {
cx.theme().colors().editor_active_line_number
}
})
.unwrap_or_else(|| cx.theme().colors().editor_line_number);
let shaped_line =
self.shape_line_number(SharedString::from(&line_number), color, window);
let scroll_top = scroll_position.y * ScrollPixelOffset::from(line_height);
let line_origin = gutter_hitbox.map(|hitbox| {
hitbox.origin
+ point(
hitbox.size.width - shaped_line.width - gutter_dimensions.right_padding,
ix as f32 * line_height
- Pixels::from(scroll_top % ScrollPixelOffset::from(line_height)),
)
});
#[cfg(test)]
let hitbox = {
let _ = line_origin;
None
};
});
let multi_buffer_row = DisplayPoint::new(display_row, 0).to_point(snapshot).row;
let multi_buffer_row = MultiBufferRow(multi_buffer_row);
let line_number = LineNumberLayout {
shaped_line,
hitbox,
};
Some((multi_buffer_row, line_number))
})
.collect();
#[cfg(not(test))]
let hitbox = line_origin.map(|line_origin| {
window.insert_hitbox(
Bounds::new(line_origin, size(shaped_line.width, line_height)),
HitboxBehavior::Normal,
)
});
#[cfg(test)]
let hitbox = {
let _ = line_origin;
None
};
let segment = LineNumberSegment {
shaped_line,
hitbox,
};
let buffer_row = DisplayPoint::new(display_row, 0).to_point(snapshot).row;
let multi_buffer_row = MultiBufferRow(buffer_row);
Some((multi_buffer_row, segment))
});
let mut line_numbers: HashMap<MultiBufferRow, LineNumberLayout> = HashMap::default();
for (buffer_row, segment) in segments {
line_numbers
.entry(buffer_row)
.or_insert_with(|| LineNumberLayout {
segments: Default::default(),
})
.segments
.push(segment);
}
Arc::new(line_numbers)
}
@@ -5846,34 +5886,36 @@ impl EditorElement {
let line_height = layout.position_map.line_height;
window.set_cursor_style(CursorStyle::Arrow, &layout.gutter_hitbox);
for LineNumberLayout {
shaped_line,
hitbox,
} in layout.line_numbers.values()
{
let Some(hitbox) = hitbox else {
continue;
};
for line_layout in layout.line_numbers.values() {
for LineNumberSegment {
shaped_line,
hitbox,
} in &line_layout.segments
{
let Some(hitbox) = hitbox else {
continue;
};
let Some(()) = (if !is_singleton && hitbox.is_hovered(window) {
let color = cx.theme().colors().editor_hover_line_number;
let Some(()) = (if !is_singleton && hitbox.is_hovered(window) {
let color = cx.theme().colors().editor_hover_line_number;
let line = self.shape_line_number(shaped_line.text.clone(), color, window);
line.paint(hitbox.origin, line_height, window, cx).log_err()
} else {
shaped_line
.paint(hitbox.origin, line_height, window, cx)
.log_err()
}) else {
continue;
};
let line = self.shape_line_number(shaped_line.text.clone(), color, window);
line.paint(hitbox.origin, line_height, window, cx).log_err()
} else {
shaped_line
.paint(hitbox.origin, line_height, window, cx)
.log_err()
}) else {
continue;
};
// In singleton buffers, we select corresponding lines on the line number click, so use | -like cursor.
// In multi buffers, we open file at the line number clicked, so use a pointing hand cursor.
if is_singleton {
window.set_cursor_style(CursorStyle::IBeam, hitbox);
} else {
window.set_cursor_style(CursorStyle::PointingHand, hitbox);
// In singleton buffers, we select corresponding lines on the line number click, so use | -like cursor.
// In multi buffers, we open file at the line number clicked, so use a pointing hand cursor.
if is_singleton {
window.set_cursor_style(CursorStyle::IBeam, hitbox);
} else {
window.set_cursor_style(CursorStyle::PointingHand, hitbox);
}
}
}
}
@@ -9783,11 +9825,17 @@ impl EditorLayout {
}
}
struct LineNumberLayout {
#[derive(Debug)]
struct LineNumberSegment {
shaped_line: ShapedLine,
hitbox: Option<Hitbox>,
}
#[derive(Debug)]
struct LineNumberLayout {
segments: SmallVec<[LineNumberSegment; 1]>,
}
struct ColoredRange<T> {
start: T,
end: T,
@@ -10851,6 +10899,7 @@ mod tests {
&snapshot,
&(DisplayRow(0)..DisplayRow(6)),
Some(DisplayRow(3)),
false,
)
})
.unwrap();
@@ -10869,6 +10918,7 @@ mod tests {
&snapshot,
&(DisplayRow(3)..DisplayRow(6)),
Some(DisplayRow(1)),
false,
)
})
.unwrap();
@@ -10885,6 +10935,7 @@ mod tests {
&snapshot,
&(DisplayRow(0)..DisplayRow(3)),
Some(DisplayRow(6)),
false,
)
})
.unwrap();
@@ -10894,6 +10945,81 @@ mod tests {
assert_eq!(relative_rows[&DisplayRow(2)], 3);
}
#[gpui::test]
fn test_shape_line_numbers_wrapping(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let window = cx.add_window(|window, cx| {
let buffer = MultiBuffer::build_simple(&sample_text(6, 6, 'a'), cx);
Editor::new(EditorMode::full(), buffer, None, window, cx)
});
update_test_language_settings(cx, |s| {
s.defaults.preferred_line_length = Some(5 as u32);
s.defaults.soft_wrap = Some(language_settings::SoftWrap::PreferredLineLength);
});
let editor = window.root(cx).unwrap();
let style = cx.update(|cx| editor.read(cx).style().unwrap().clone());
let line_height = window
.update(cx, |_, window, _| {
style.text.line_height_in_pixels(window.rem_size())
})
.unwrap();
let element = EditorElement::new(&editor, style);
let snapshot = window
.update(cx, |editor, window, cx| editor.snapshot(window, cx))
.unwrap();
let layouts = cx
.update_window(*window, |_, window, cx| {
element.layout_line_numbers(
None,
GutterDimensions {
left_padding: Pixels::ZERO,
right_padding: Pixels::ZERO,
width: px(30.0),
margin: Pixels::ZERO,
git_blame_entries_width: None,
},
line_height,
gpui::Point::default(),
DisplayRow(0)..DisplayRow(6),
&(0..6)
.map(|row| RowInfo {
buffer_row: Some(row),
..Default::default()
})
.collect::<Vec<_>>(),
&BTreeMap::default(),
Some(DisplayPoint::new(DisplayRow(0), 0)),
&snapshot,
window,
cx,
)
})
.unwrap();
assert_eq!(layouts.len(), 3);
let relative_rows = window
.update(cx, |editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
element.calculate_relative_line_numbers(
&snapshot,
&(DisplayRow(0)..DisplayRow(6)),
Some(DisplayRow(3)),
true,
)
})
.unwrap();
assert_eq!(relative_rows[&DisplayRow(0)], 3);
assert_eq!(relative_rows[&DisplayRow(1)], 2);
assert_eq!(relative_rows[&DisplayRow(2)], 1);
// current line has no relative number
assert_eq!(relative_rows[&DisplayRow(4)], 1);
assert_eq!(relative_rows[&DisplayRow(5)], 2);
}
#[gpui::test]
async fn test_vim_visual_selections(cx: &mut TestAppContext) {
init_test(cx, |_| {});
@@ -11007,7 +11133,13 @@ mod tests {
state
.line_numbers
.get(&MultiBufferRow(0))
.map(|line_number| line_number.shaped_line.text.as_ref()),
.map(|line_number| line_number
.segments
.first()
.unwrap()
.shaped_line
.text
.as_ref()),
Some("1")
);
}

View File

@@ -395,6 +395,7 @@ pub struct RowInfo {
pub multibuffer_row: Option<MultiBufferRow>,
pub diff_status: Option<buffer_diff::DiffHunkStatus>,
pub expand_info: Option<ExpandInfo>,
pub wrapped_buffer_row: Option<u32>,
}
/// A slice into a [`Buffer`] that is being edited in a [`MultiBuffer`].
@@ -7497,6 +7498,7 @@ impl Iterator for MultiBufferRows<'_> {
multibuffer_row: Some(MultiBufferRow(0)),
diff_status: None,
expand_info: None,
wrapped_buffer_row: None,
});
}
@@ -7554,6 +7556,7 @@ impl Iterator for MultiBufferRows<'_> {
buffer_row: Some(last_row),
multibuffer_row: Some(multibuffer_row),
diff_status: None,
wrapped_buffer_row: None,
expand_info,
});
} else {
@@ -7598,6 +7601,7 @@ impl Iterator for MultiBufferRows<'_> {
.diff_hunk_status
.filter(|_| self.point < region.range.end),
expand_info,
wrapped_buffer_row: None,
});
self.point += Point::new(1, 0);
result

View File

@@ -31,6 +31,7 @@ fn test_empty_singleton(cx: &mut App) {
multibuffer_row: Some(MultiBufferRow(0)),
diff_status: None,
expand_info: None,
wrapped_buffer_row: None,
}]
);
}
@@ -2431,6 +2432,8 @@ impl ReferenceMultibuffer {
buffer_id: region.buffer_id,
diff_status: region.status,
buffer_row,
wrapped_buffer_row: None,
multibuffer_row: Some(MultiBufferRow(
text[..ix].matches('\n').count() as u32
)),

View File

@@ -94,6 +94,10 @@ pub struct EditorSettingsContent {
///
/// Default: false
pub relative_line_numbers: Option<bool>,
/// Whether to show relative line numbers for wrapped lines (visual lines) rather than just buffer lines.
///
/// Default: false
pub relative_line_numbers_for_wrapped_lines: Option<bool>,
/// When to populate a new search's query based on the text under the cursor.
///
/// Default: always

View File

@@ -278,6 +278,7 @@ impl VsCodeSettings {
"relative" => Some(true),
_ => None,
}),
relative_line_numbers_for_wrapped_lines: None,
rounded_selection: self.read_bool("editor.roundedSelection"),
scroll_beyond_last_line: None,
scroll_sensitivity: self.read_f32("editor.mouseWheelScrollSensitivity"),