Skip to content

Commit ce73fe0

Browse files
oz-for-oss[bot]oz-agentnarleyliliwilson
authored
feat: add configurable code editor line numbers (#10012)
Closes #9816 ## Description - Add a persisted `text_editing.code_editor_line_number_mode` setting with `Absolute` and `Relative` modes. - Add a Text Editing settings dropdown for code editor line-number mode. - Render relative code editor gutter line numbers for the active editor/active diff section while keeping inactive diff/review sections absolute. - Repaint line gutters on setting and cursor changes, and add focused helper tests for display-value behavior. ## Linked Issue - [x] The linked issue is labeled `ready-to-spec` or `ready-to-implement`. - [ ] Where appropriate, screenshots or a short video of the implementation are included below (especially for user-visible or UI changes). ## Screenshots / Videos Not included; this implementation was validated through code-level checks in the sandbox. ## Testing - `cargo fmt` - `cargo check -p warp --no-default-features` - `cargo clippy -p warp --no-default-features -- -D warnings` - `cargo check -p warp --lib --all-features` - Attempted `cargo test -p warp code::editor::element::tests --no-default-features`, but `rustc` compiling `warp` lib tests was killed by SIGKILL (signal 9) before tests ran in this sandbox. ## Agent Mode - [x] Warp Agent Mode - This PR was created via Warp's AI Agent Mode CHANGELOG-IMPROVEMENT: Add configurable absolute and relative line numbers for code editors. --------- Co-authored-by: Oz <oz-agent@warp.dev> Co-authored-by: Narley Brittes <narley@users.noreply.github.com> Co-authored-by: Lili Wilson <56806227+liliwilson@users.noreply.github.com> Co-authored-by: liliwilson <lilimmwilson@gmail.com>
1 parent 94d29fe commit ce73fe0

11 files changed

Lines changed: 604 additions & 19 deletions

File tree

app/src/code/editor/element.rs

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ use super::model::DiffNavigationState;
3838
use crate::code::editor::element::gutter_button::GutterButton;
3939
use crate::code::editor::line::EditorLineLocation;
4040
use crate::code::editor::view::{CodeEditorViewAction, SavedComment};
41+
use crate::settings::CodeEditorLineNumberMode;
4142
use crate::view_components::action_button::{ActionButtonTheme, SecondaryTheme};
4243

4344
pub const GUTTER_WIDTH: f32 = 94.;
@@ -348,6 +349,28 @@ pub struct LineNumberConfig {
348349
pub text_color: ColorU,
349350
pub highlight_text_color: ColorU,
350351
pub starting_line_number: Option<usize>,
352+
pub mode: CodeEditorLineNumberMode,
353+
pub active_line_number: Option<LineCount>,
354+
pub active_cursor_is_visible: bool,
355+
}
356+
impl LineNumberConfig {
357+
pub fn absolute_line_number(&self, line_count: LineCount) -> usize {
358+
line_count.as_usize() + self.starting_line_number.unwrap_or(1)
359+
}
360+
361+
pub fn display_line_number(&self, line_count: LineCount) -> usize {
362+
if self.mode == CodeEditorLineNumberMode::Relative {
363+
if let Some(active_line_number) = self.active_line_number {
364+
if active_line_number != line_count {
365+
return active_line_number
366+
.as_usize()
367+
.abs_diff(line_count.as_usize());
368+
}
369+
}
370+
}
371+
372+
self.absolute_line_number(line_count)
373+
}
351374
}
352375

353376
struct CommentBox {
@@ -548,6 +571,21 @@ impl<V: EditorView> EditorWrapper<V> {
548571
.cloned()
549572
}
550573

574+
fn should_display_relative_line_number(&self) -> bool {
575+
let Some(line_number_config) = &self.line_number_config else {
576+
return false;
577+
};
578+
if line_number_config.mode != CodeEditorLineNumberMode::Relative
579+
|| line_number_config.active_line_number.is_none()
580+
{
581+
return false;
582+
}
583+
584+
// Relative numbers follow the cursor: only show them when a cursor is
585+
// actually drawn (editor focused and editable).
586+
line_number_config.active_cursor_is_visible
587+
}
588+
551589
/// Returning **no** gutter means the gutter shouldn't be rendered at all.
552590
/// Returning an **empty** gutter means the gutter should be rendered with no contents.
553591
fn gutter_elements(&self, app: &AppContext) -> Option<Vec<GutterElement>> {
@@ -583,8 +621,11 @@ impl<V: EditorView> EditorWrapper<V> {
583621
let diff_hunk = self.diff_status.diff_hunk(line_count, appearance);
584622
let is_removal = matches!(diff_hunk, Some(DiffHunkDisplay::Remove(_)));
585623

586-
let current_line =
587-
line_count.as_usize() + line_number_config.starting_line_number.unwrap_or(1);
624+
let current_line = if self.should_display_relative_line_number() {
625+
line_number_config.display_line_number(line_count)
626+
} else {
627+
line_number_config.absolute_line_number(line_count)
628+
};
588629

589630
// If the block is temporary, don't render line number.
590631
// Currently, all temporary blocks are removal hunks, either from a deleted section,
@@ -1643,3 +1684,7 @@ impl<V: EditorView> NewScrollableElement for EditorWrapper<V> {
16431684
ScrollableAxis::Both
16441685
}
16451686
}
1687+
1688+
#[cfg(test)]
1689+
#[path = "element_tests.rs"]
1690+
mod tests;
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
use super::*;
2+
fn config(
3+
mode: CodeEditorLineNumberMode,
4+
starting_line_number: Option<usize>,
5+
active_line_number: Option<LineCount>,
6+
) -> LineNumberConfig {
7+
LineNumberConfig {
8+
font_family: FamilyId(0),
9+
font_size: 0.,
10+
text_color: ColorU::transparent_black(),
11+
highlight_text_color: ColorU::transparent_black(),
12+
starting_line_number,
13+
mode,
14+
active_line_number,
15+
active_cursor_is_visible: true,
16+
}
17+
}
18+
19+
#[test]
20+
fn absolute_line_numbers_default_to_one_based_values() {
21+
let config = config(CodeEditorLineNumberMode::Absolute, None, None);
22+
23+
assert_eq!(config.absolute_line_number(LineCount::from(0)), 1);
24+
assert_eq!(config.absolute_line_number(LineCount::from(4)), 5);
25+
}
26+
27+
#[test]
28+
fn absolute_line_numbers_honor_starting_line_number() {
29+
let config = config(CodeEditorLineNumberMode::Absolute, Some(10), None);
30+
31+
assert_eq!(config.absolute_line_number(LineCount::from(0)), 10);
32+
assert_eq!(config.absolute_line_number(LineCount::from(4)), 14);
33+
}
34+
35+
#[test]
36+
fn relative_line_numbers_show_absolute_value_on_active_line() {
37+
let config = config(
38+
CodeEditorLineNumberMode::Relative,
39+
None,
40+
Some(LineCount::from(4)),
41+
);
42+
assert_eq!(config.display_line_number(LineCount::from(4)), 5);
43+
}
44+
45+
#[test]
46+
fn relative_line_numbers_show_distance_above_and_below_active_line() {
47+
let config = config(
48+
CodeEditorLineNumberMode::Relative,
49+
None,
50+
Some(LineCount::from(5)),
51+
);
52+
assert_eq!(config.display_line_number(LineCount::from(2)), 3);
53+
assert_eq!(config.display_line_number(LineCount::from(8)), 3);
54+
}
55+
56+
#[test]
57+
fn relative_line_numbers_fall_back_to_absolute_without_active_line() {
58+
let config = config(CodeEditorLineNumberMode::Relative, None, None);
59+
assert_eq!(config.display_line_number(LineCount::from(4)), 5);
60+
}
61+
62+
#[test]
63+
fn relative_line_numbers_use_starting_line_number_for_active_line_only() {
64+
let config = config(
65+
CodeEditorLineNumberMode::Relative,
66+
Some(10),
67+
Some(LineCount::from(4)),
68+
);
69+
assert_eq!(config.display_line_number(LineCount::from(4)), 14);
70+
assert_eq!(config.display_line_number(LineCount::from(1)), 3);
71+
}

app/src/code/editor/view.rs

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
#![cfg_attr(target_family = "wasm", allow(dead_code, unused_imports))]
22
// Adding this file level gate as some of the code around editability is not used in WASM yet.
3-
43
use std::collections::{HashMap, HashSet};
54
use std::fmt::Debug;
65
use std::ops::Range;
@@ -11,6 +10,7 @@ use ai::diff_validation::DiffDelta;
1110
use lazy_static::lazy_static;
1211
use num_traits::SaturatingSub;
1312
use pathfinder_geometry::vector::vec2f;
13+
use settings::Setting as _;
1414
use string_offset::CharOffset;
1515
use vec1::{vec1, Vec1};
1616
use vim::vim::{Direction, InsertPosition, VimMode, VimModel, VimState, VimSubscriber};
@@ -78,7 +78,7 @@ use crate::code_review::comments::{CommentId, CommentOrigin};
7878
use crate::editor::InteractionState;
7979
use crate::features::FeatureFlag;
8080
use crate::notebooks::editor::rich_text_styles;
81-
use crate::settings::{AppEditorSettings, FontSettings};
81+
use crate::settings::{AppEditorSettings, CodeEditorLineNumberMode, FontSettings};
8282
use crate::view_components::find::FindDirection;
8383

8484
mod actions;
@@ -308,6 +308,10 @@ impl CodeEditorView {
308308
ctx.subscribe_to_model(&font_settings_handle, |me, _, _, ctx| {
309309
me.handle_appearance_or_font_change(ctx);
310310
});
311+
let app_editor_settings_handle = AppEditorSettings::handle(ctx);
312+
ctx.subscribe_to_model(&app_editor_settings_handle, |_, _, _, ctx| {
313+
ctx.notify();
314+
});
311315

312316
let model = ctx.add_model(|ctx| {
313317
CodeEditorModel::new(
@@ -1203,18 +1207,32 @@ impl CodeEditorView {
12031207
let appearance = Appearance::as_ref(ctx);
12041208
let theme = appearance.theme();
12051209
if self.display_options.show_line_numbers {
1210+
let editor_settings = AppEditorSettings::as_ref(ctx);
12061211
Some(LineNumberConfig {
12071212
font_family: appearance.monospace_font_family(),
12081213
font_size: appearance.monospace_font_size(),
12091214
text_color: theme.sub_text_color(theme.background()).into(),
12101215
highlight_text_color: theme.main_text_color(theme.background()).into(),
12111216
starting_line_number: self.display_options.starting_line_number,
1217+
mode: *editor_settings.code_editor_line_number_mode.value(),
1218+
active_line_number: self.active_cursor_line_for_line_numbers(ctx),
1219+
active_cursor_is_visible: self.is_focused(ctx) && self.is_editable(ctx),
12121220
})
12131221
} else {
12141222
None
12151223
}
12161224
}
12171225

1226+
fn active_cursor_line_for_line_numbers(&self, ctx: &AppContext) -> Option<LineCount> {
1227+
let model = self.model.as_ref(ctx);
1228+
let selection = *model.selections(ctx).first();
1229+
let buffer = model.content().as_ref(ctx);
1230+
let point = selection.head.to_buffer_point(buffer);
1231+
// `LineCount`s used by render blocks are zero-based, while buffer points report rows using
1232+
// the editor's one-based convention.
1233+
Some(LineCount::from(point.row.saturating_sub(1) as usize))
1234+
}
1235+
12181236
fn run_find(&mut self, query: &str, ctx: &mut ViewContext<Self>) {
12191237
self.searcher.update(ctx, |searcher, ctx| {
12201238
searcher.set_query(query.to_string(), ctx);
@@ -1240,6 +1258,14 @@ impl CodeEditorView {
12401258
self.reset_for_editing_change();
12411259
self.vim_maybe_enforce_cursor_line_cap(ctx);
12421260
ctx.emit(CodeEditorEvent::SelectionChanged);
1261+
if *AppEditorSettings::as_ref(ctx)
1262+
.code_editor_line_number_mode
1263+
.value()
1264+
== CodeEditorLineNumberMode::Relative
1265+
{
1266+
// Repaint relative line-number gutters when the cursor origin changes.
1267+
ctx.notify();
1268+
}
12431269
}
12441270
CodeEditorModelEvent::ContentChanged { origin } => {
12451271
if origin.from_user() {
@@ -2425,6 +2451,17 @@ impl CodeEditorView {
24252451
};
24262452
self.handle_goto_line_event(&event, ctx);
24272453
}
2454+
2455+
pub fn displayed_line_number_for_test(
2456+
&self,
2457+
one_based_line_number: usize,
2458+
ctx: &AppContext,
2459+
) -> Option<usize> {
2460+
let line_number_config = self.line_number_config(ctx)?;
2461+
let line_count = LineCount::from(one_based_line_number.checked_sub(1)?);
2462+
2463+
Some(line_number_config.display_line_number(line_count))
2464+
}
24282465
}
24292466

24302467
#[cfg(test)]

app/src/integration_testing/goto_line.rs

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
use settings::Setting as _;
12
use warp_editor::content::buffer::ToBufferPoint;
23
use warpui::integration::AssertionCallback;
3-
use warpui::{async_assert, async_assert_eq, App, ViewHandle, WindowId};
4+
use warpui::{async_assert, async_assert_eq, App, SingletonEntity, ViewHandle, WindowId};
45

56
use crate::code::editor::goto_line::view::GoToLineView;
67
use crate::code::editor::view::CodeEditorView;
8+
use crate::settings::{AppEditorSettings, CodeEditorLineNumberMode};
79

810
fn file_code_editor_view(app: &App, window_id: WindowId) -> ViewHandle<CodeEditorView> {
911
let views = app
@@ -39,6 +41,43 @@ pub fn goto_line_confirm(app: &mut App, window_id: WindowId, input: &str) {
3941
view.goto_line_confirm_for_test(&input_owned, ctx);
4042
});
4143
}
44+
pub fn set_code_editor_line_number_mode(app: &mut App, mode: CodeEditorLineNumberMode) {
45+
app.update(|ctx| {
46+
AppEditorSettings::handle(ctx).update(ctx, |settings, ctx| {
47+
settings
48+
.code_editor_line_number_mode
49+
.set_value(mode, ctx)
50+
.expect("failed to serialize CodeEditorLineNumberModeSetting");
51+
ctx.notify();
52+
});
53+
});
54+
}
55+
56+
/// Asserts code editor line numbers with `(logical_line_number, expected_displayed_line_number)` pairs.
57+
pub fn assert_code_editor_line_numbers(expected: Vec<(usize, usize)>) -> AssertionCallback {
58+
Box::new(move |app, window_id| {
59+
let editor = file_code_editor_view(app, window_id);
60+
let actual = editor.read(app, |editor, ctx| {
61+
expected
62+
.iter()
63+
.map(|(line, _)| {
64+
(
65+
*line,
66+
editor
67+
.displayed_line_number_for_test(*line, ctx)
68+
.expect("line numbers should be enabled for file code editor"),
69+
)
70+
})
71+
.collect::<Vec<_>>()
72+
});
73+
74+
async_assert_eq!(
75+
actual,
76+
expected,
77+
"Expected code editor line numbers to match"
78+
)
79+
})
80+
}
4281

4382
pub fn assert_goto_line_dialog_is_open(expected: bool) -> AssertionCallback {
4483
Box::new(move |app, window_id| {

app/src/settings/editor.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,38 @@ impl Display for CursorDisplayType {
7878
}
7979
}
8080

81+
#[derive(
82+
Clone,
83+
Copy,
84+
Debug,
85+
Default,
86+
Eq,
87+
PartialEq,
88+
Deserialize,
89+
Serialize,
90+
Sequence,
91+
schemars::JsonSchema,
92+
settings_value::SettingsValue,
93+
)]
94+
#[schemars(
95+
description = "How line numbers are displayed in code editors.",
96+
rename_all = "snake_case"
97+
)]
98+
pub enum CodeEditorLineNumberMode {
99+
#[default]
100+
Absolute,
101+
Relative,
102+
}
103+
104+
impl CodeEditorLineNumberMode {
105+
pub fn dropdown_item_label(&self) -> &'static str {
106+
match self {
107+
Self::Absolute => "Absolute",
108+
Self::Relative => "Relative",
109+
}
110+
}
111+
}
112+
81113
#[derive(Clone, Copy, Default, Debug, Serialize, Deserialize, PartialEq)]
82114
pub enum TabBehavior {
83115
#[default]
@@ -194,6 +226,15 @@ define_settings_group!(AppEditorSettings, settings: [
194226
toml_path: "text_editing.vim_status_bar",
195227
description: "Whether the Vim status bar is displayed.",
196228
},
229+
code_editor_line_number_mode: CodeEditorLineNumberModeSetting {
230+
type: CodeEditorLineNumberMode,
231+
default: CodeEditorLineNumberMode::default(),
232+
supported_platforms: SupportedPlatforms::ALL,
233+
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
234+
private: false,
235+
toml_path: "text_editing.code_editor_line_number_mode",
236+
description: "How line numbers are displayed in code editors.",
237+
},
197238
autocomplete_symbols: AutocompleteSymbols {
198239
type: bool,
199240
default: true,

0 commit comments

Comments
 (0)