feat: add line-number gutter

This commit is contained in:
Valdemar Lorenzen
2026-10-08 11:17:03 +02:00
parent 619c180316
commit a3a0da2586
2 changed files with 163 additions and 29 deletions
+161 -29
View File
@@ -210,6 +210,17 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
Constraint::Length(u16::from(area.height >= 2 || prompting)),
])
.areas(area);
let digits = editor.lines().len().ilog10() as usize + 1;
let gutter_width = (digits + 1) as u16;
let [gutter, viewport] = Layout::horizontal([
Constraint::Length(if viewport.width > gutter_width {
gutter_width
} else {
0
}),
Constraint::Fill(1),
])
.areas(viewport);
let height = usize::from(viewport.height);
let width = usize::from(viewport.width);
let cursor = editor.cursor();
@@ -225,6 +236,21 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
} else if column >= left.saturating_add(width) {
*left = column + 1 - width;
}
if gutter.width > 0 {
let numbers: Vec<Line> = (*top..top.saturating_add(height).min(editor.lines().len()))
.map(|row| {
let style = if row == cursor.row {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
Line::styled(format!("{:>digits$} ", row + 1), style)
})
.collect();
frame.render_widget(Paragraph::new(numbers), gutter);
}
let lines: Vec<Line> = editor
.lines()
.iter()
@@ -457,6 +483,111 @@ mod tests {
.join("\n")
}
#[test]
fn gutter_tracks_scrolled_rows_without_scrolling_with_text() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace(
"0123456789界z\n".repeat(11),
)])
.unwrap();
for _ in 0..10 {
editor.handle(Input::Down);
}
editor.handle(Input::End);
let area = ratatui::layout::Rect::new(3, 2, 8, 5);
let mut terminal = Terminal::with_options(
ratatui::backend::TestBackend::new(20, 10),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(area),
},
)
.unwrap();
let (mut top, mut left) = (0, 0);
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
assert_eq!((top, left), (8, 8));
let buffer = terminal.backend().buffer();
for (y, expected) in [(2, " 9 "), (3, "10 "), (4, "11 ")] {
let number: String = (3..6).map(|x| buffer[(x, y)].symbol()).collect();
assert_eq!(number, expected);
assert_eq!(buffer[(6, y)].symbol(), "8");
assert_eq!(buffer[(3, y)].bg, Color::Reset);
}
assert!(buffer[(3, 4)].modifier.contains(Modifier::BOLD));
assert!(!buffer[(3, 3)].modifier.contains(Modifier::BOLD));
assert_eq!(
terminal.get_cursor_position().unwrap(),
ratatui::layout::Position::new(10, 4)
);
editor.handle(Input::Down);
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
let buffer = terminal.backend().buffer();
let last_number: String = (3..6).map(|x| buffer[(x, 4)].symbol()).collect();
assert_eq!(last_number, "12 ");
assert_eq!(buffer[(6, 4)].symbol(), " ");
assert_eq!(
terminal.get_cursor_position().unwrap(),
ratatui::layout::Position::new(6, 4)
);
}
#[test]
fn gutter_resizes_at_digit_boundaries_and_yields_to_narrow_text() {
let mut editor = Editor::open(None, vec![]).unwrap();
let (mut top, mut left) = (0, 0);
for count in [9usize, 10, 100] {
editor
.apply(vec![crate::editor::Effect::Replace(
vec!["x"; count].join("\n"),
)])
.unwrap();
let digits = count.ilog10() as u16 + 1;
let gutter_width = digits + 1;
for width in [12, gutter_width, gutter_width + 1, 1] {
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(width, 4)).unwrap();
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
let cursor = terminal.get_cursor_position().unwrap();
let buffer = terminal.backend().buffer();
let text_x = if width > gutter_width {
gutter_width
} else {
0
};
assert_eq!(buffer[(text_x, 0)].symbol(), "x");
assert_eq!(cursor, ratatui::layout::Position::new(text_x, 0));
if text_x > 0 {
let number: String = (0..text_x).map(|x| buffer[(x, 0)].symbol()).collect();
assert_eq!(
number,
format!("{:>digits$} ", 1, digits = usize::from(digits))
);
}
}
}
editor
.apply(vec![crate::editor::Effect::Replace(String::new())])
.unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 5)).unwrap();
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(0, 0)].symbol(), "1");
assert_eq!(buffer[(0, 1)].symbol(), " ");
assert_eq!(buffer[(0, 2)].symbol(), " ");
assert_eq!(
terminal.get_cursor_position().unwrap(),
ratatui::layout::Position::new(2, 0)
);
}
#[test]
fn palette_renders_query_no_matches_and_restores_underlying_prompt() {
let mut editor = Editor::open(None, vec![]).unwrap();
@@ -737,14 +868,14 @@ mod tests {
for selection in editor.selections() {
if selection.start.row >= top && selection.start.row < top + 2 {
let y = (selection.start.row - top) as u16;
assert!(matches!(buffer[(0, y)].bg, Color::Blue | Color::Magenta));
assert!(matches!(buffer[(2, y)].bg, Color::Blue | Color::Magenta));
}
}
let status: String = (0..48).map(|x| buffer[(x, 2)].symbol()).collect();
assert!(status.contains("5 selections"), "{status}");
assert_eq!(
terminal.get_cursor_position().unwrap(),
ratatui::layout::Position::new(0, (editor.cursor().row - top) as u16)
ratatui::layout::Position::new(2, (editor.cursor().row - top) as u16)
);
}
@@ -766,7 +897,7 @@ mod tests {
.unwrap();
let buffer = terminal.backend().buffer();
for caret in editor.carets() {
let x = display_column(&editor.lines()[caret.row], caret.column) as u16;
let x = 2 + display_column(&editor.lines()[caret.row], caret.column) as u16;
assert_eq!(buffer[(x, caret.row as u16)].symbol(), " ");
if caret != editor.cursor() {
assert_eq!(buffer[(x, caret.row as u16)].bg, Color::Magenta);
@@ -775,7 +906,8 @@ mod tests {
assert_eq!(
terminal.get_cursor_position().unwrap(),
ratatui::layout::Position::new(
display_column(&editor.lines()[editor.cursor().row], editor.cursor().column) as u16,
2 + display_column(&editor.lines()[editor.cursor().row], editor.cursor().column)
as u16,
editor.cursor().row as u16
)
);
@@ -796,54 +928,54 @@ mod tests {
editor.handle(Input::Enter);
editor.handle(Input::Char('v'));
editor.handle(Input::Left);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(10, 6)).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(12, 6)).unwrap();
let (mut top, mut left) = (0, 0);
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
let buffer = terminal.backend().buffer();
for x in [1, 2, 3, 4] {
assert_eq!(buffer[(x, 0)].bg, Color::Blue);
assert_eq!(buffer[(2 + x, 0)].bg, Color::Blue);
}
assert_eq!(buffer[(4, 0)].symbol(), "界");
assert_eq!(buffer[(6, 0)].bg, Color::Yellow);
assert_eq!(buffer[(6, 0)].symbol(), "界");
assert_eq!(buffer[(8, 0)].bg, Color::Yellow);
editor.handle(Input::Down);
editor.handle(Input::Down);
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(8, 0)].symbol(), " ");
assert_eq!(buffer[(8, 0)].bg, Color::Blue);
assert_eq!(buffer[(0, 1)].bg, Color::Blue);
assert_eq!(buffer[(0, 2)].bg, Color::Blue);
assert_ne!(buffer[(1, 2)].bg, Color::Blue);
assert_eq!(buffer[(10, 0)].symbol(), " ");
assert_eq!(buffer[(10, 0)].bg, Color::Blue);
assert_eq!(buffer[(2, 1)].bg, Color::Blue);
assert_eq!(buffer[(2, 2)].bg, Color::Blue);
assert_ne!(buffer[(3, 2)].bg, Color::Blue);
editor.handle(Input::Up);
editor.handle(Input::Up);
editor.handle(Input::End);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(5, 6)).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(7, 6)).unwrap();
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
let buffer = terminal.backend().buffer();
assert_eq!(left, 4);
assert_eq!(buffer[(0, 0)].symbol(), "界");
assert_eq!(buffer[(2, 0)].symbol(), "e\u{301}");
assert_eq!(buffer[(4, 0)].symbol(), " ");
assert_eq!(buffer[(2, 0)].symbol(), "界");
assert_eq!(buffer[(4, 0)].symbol(), "e\u{301}");
assert_eq!(buffer[(6, 0)].symbol(), " ");
for x in [0, 2, 3, 4] {
assert_eq!(buffer[(x, 0)].bg, Color::Blue);
assert_eq!(buffer[(2 + x, 0)].bg, Color::Blue);
}
assert_ne!(buffer[(0, 1)].bg, Color::Blue);
assert_ne!(buffer[(2, 1)].bg, Color::Blue);
editor.handle(Input::Escape);
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(0, 0)].bg, Color::Yellow);
assert_eq!(buffer[(2, 0)].bg, Color::Yellow);
assert_ne!(buffer[(3, 0)].bg, Color::Blue);
assert_ne!(buffer[(4, 0)].bg, Color::Blue);
assert_eq!(buffer[(4, 0)].bg, Color::Yellow);
assert_ne!(buffer[(5, 0)].bg, Color::Blue);
assert_ne!(buffer[(6, 0)].bg, Color::Blue);
}
#[test]
@@ -860,20 +992,20 @@ mod tests {
}
editor.handle(Input::Enter);
editor.handle(Input::End);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(5, 5)).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(7, 5)).unwrap();
let (mut top, mut left) = (0, 0);
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
let buffer = terminal.backend().buffer();
assert_eq!(left, 4);
assert_eq!(buffer[(0, 0)].symbol(), "界");
assert_eq!(buffer[(2, 0)].symbol(), "e\u{301}");
assert_eq!(buffer[(2, 0)].symbol(), "界");
assert_eq!(buffer[(4, 0)].symbol(), "e\u{301}");
for x in [0, 2] {
assert_eq!(buffer[(x, 0)].bg, Color::Yellow);
assert_eq!(buffer[(2 + x, 0)].bg, Color::Yellow);
}
for x in 3..5 {
assert_ne!(buffer[(x, 0)].bg, Color::Yellow);
assert_ne!(buffer[(2 + x, 0)].bg, Color::Yellow);
}
editor.handle(Input::Char('/'));
for ch in "界界".chars() {
@@ -883,8 +1015,8 @@ mod tests {
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(0, 4)].symbol(), "界");
assert_eq!(buffer[(2, 4)].symbol(), "界");
assert_eq!(buffer[(1, 4)].symbol(), "界");
assert_eq!(buffer[(3, 4)].symbol(), "界");
editor.handle(Input::Backspace);
editor.handle(Input::Backspace);
editor.handle(Input::Char('\t'));