feat: add line-number gutter
This commit is contained in:
@@ -91,6 +91,8 @@ The terminal adapter owns raw mode, alternate-screen state, bracketed paste, and
|
|||||||
|
|
||||||
The TUI uses Ratatui constraint-based layout for a flexible buffer viewport, a one-row status bar, and a one-row prompt/message area. Scrolling, clipping, highlights, and cursor placement use the allocated regions, not terminal-wide offsets. Below three rows the status bar is hidden; at one row the buffer remains visible unless an active command/search prompt takes priority. Zero-sized terminals render nothing. Layout and terminal-cell text mapping remain frontend-owned.
|
The TUI uses Ratatui constraint-based layout for a flexible buffer viewport, a one-row status bar, and a one-row prompt/message area. Scrolling, clipping, highlights, and cursor placement use the allocated regions, not terminal-wide offsets. Below three rows the status bar is hidden; at one row the buffer remains visible unless an active command/search prompt takes priority. Zero-sized terminals render nothing. Layout and terminal-cell text mapping remain frontend-owned.
|
||||||
|
|
||||||
|
The buffer viewport includes a fixed left gutter with right-aligned, one-based absolute line numbers and a highlighted primary cursor line number. Gutter width follows the total line count plus one separator cell, including an empty final row after a trailing newline. Horizontal scrolling affects only text; search/selection highlights and carets never paint over the gutter. Rows beyond the buffer have no number. The gutter is hidden when it would leave no text column.
|
||||||
|
|
||||||
Built-in Ex commands register as the protected `core` feature in the same command registry used by plugins. External plugins cannot claim core status, replace existing commands, or disable core. User configuration can override any keybinding without replacing the core implementation. `:plugins` lists active features and frontend capabilities.
|
Built-in Ex commands register as the protected `core` feature in the same command registry used by plugins. External plugins cannot claim core status, replace existing commands, or disable core. User configuration can override any keybinding without replacing the core implementation. `:plugins` lists active features and frontend capabilities.
|
||||||
|
|
||||||
A feature declares all capabilities it needs. Registration is atomic: if any requirement is unavailable or any command conflicts, none of its commands are installed. For example, `Vamp.plugin "canvas", requires: ["graphics"]` will be refused by this TUI. Capability names are extensible strings, not a terminal-vs-GUI switch. Capability declarations express compatibility; they are not security permissions.
|
A feature declares all capabilities it needs. Registration is atomic: if any requirement is unavailable or any command conflicts, none of its commands are installed. For example, `Vamp.plugin "canvas", requires: ["graphics"]` will be refused by this TUI. Capability names are extensible strings, not a terminal-vs-GUI switch. Capability declarations express compatibility; they are not security permissions.
|
||||||
|
|||||||
+161
-29
@@ -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)),
|
Constraint::Length(u16::from(area.height >= 2 || prompting)),
|
||||||
])
|
])
|
||||||
.areas(area);
|
.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 height = usize::from(viewport.height);
|
||||||
let width = usize::from(viewport.width);
|
let width = usize::from(viewport.width);
|
||||||
let cursor = editor.cursor();
|
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) {
|
} else if column >= left.saturating_add(width) {
|
||||||
*left = column + 1 - 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
|
let lines: Vec<Line> = editor
|
||||||
.lines()
|
.lines()
|
||||||
.iter()
|
.iter()
|
||||||
@@ -457,6 +483,111 @@ mod tests {
|
|||||||
.join("\n")
|
.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]
|
#[test]
|
||||||
fn palette_renders_query_no_matches_and_restores_underlying_prompt() {
|
fn palette_renders_query_no_matches_and_restores_underlying_prompt() {
|
||||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||||
@@ -737,14 +868,14 @@ mod tests {
|
|||||||
for selection in editor.selections() {
|
for selection in editor.selections() {
|
||||||
if selection.start.row >= top && selection.start.row < top + 2 {
|
if selection.start.row >= top && selection.start.row < top + 2 {
|
||||||
let y = (selection.start.row - top) as u16;
|
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();
|
let status: String = (0..48).map(|x| buffer[(x, 2)].symbol()).collect();
|
||||||
assert!(status.contains("5 selections"), "{status}");
|
assert!(status.contains("5 selections"), "{status}");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
terminal.get_cursor_position().unwrap(),
|
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();
|
.unwrap();
|
||||||
let buffer = terminal.backend().buffer();
|
let buffer = terminal.backend().buffer();
|
||||||
for caret in editor.carets() {
|
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(), " ");
|
assert_eq!(buffer[(x, caret.row as u16)].symbol(), " ");
|
||||||
if caret != editor.cursor() {
|
if caret != editor.cursor() {
|
||||||
assert_eq!(buffer[(x, caret.row as u16)].bg, Color::Magenta);
|
assert_eq!(buffer[(x, caret.row as u16)].bg, Color::Magenta);
|
||||||
@@ -775,7 +906,8 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
terminal.get_cursor_position().unwrap(),
|
terminal.get_cursor_position().unwrap(),
|
||||||
ratatui::layout::Position::new(
|
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
|
editor.cursor().row as u16
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -796,54 +928,54 @@ mod tests {
|
|||||||
editor.handle(Input::Enter);
|
editor.handle(Input::Enter);
|
||||||
editor.handle(Input::Char('v'));
|
editor.handle(Input::Char('v'));
|
||||||
editor.handle(Input::Left);
|
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);
|
let (mut top, mut left) = (0, 0);
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let buffer = terminal.backend().buffer();
|
let buffer = terminal.backend().buffer();
|
||||||
for x in [1, 2, 3, 4] {
|
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)].symbol(), "界");
|
||||||
assert_eq!(buffer[(6, 0)].bg, Color::Yellow);
|
assert_eq!(buffer[(8, 0)].bg, Color::Yellow);
|
||||||
editor.handle(Input::Down);
|
editor.handle(Input::Down);
|
||||||
editor.handle(Input::Down);
|
editor.handle(Input::Down);
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let buffer = terminal.backend().buffer();
|
let buffer = terminal.backend().buffer();
|
||||||
assert_eq!(buffer[(8, 0)].symbol(), " ");
|
assert_eq!(buffer[(10, 0)].symbol(), " ");
|
||||||
assert_eq!(buffer[(8, 0)].bg, Color::Blue);
|
assert_eq!(buffer[(10, 0)].bg, Color::Blue);
|
||||||
assert_eq!(buffer[(0, 1)].bg, Color::Blue);
|
assert_eq!(buffer[(2, 1)].bg, Color::Blue);
|
||||||
assert_eq!(buffer[(0, 2)].bg, Color::Blue);
|
assert_eq!(buffer[(2, 2)].bg, Color::Blue);
|
||||||
assert_ne!(buffer[(1, 2)].bg, Color::Blue);
|
assert_ne!(buffer[(3, 2)].bg, Color::Blue);
|
||||||
|
|
||||||
editor.handle(Input::Up);
|
editor.handle(Input::Up);
|
||||||
editor.handle(Input::Up);
|
editor.handle(Input::Up);
|
||||||
editor.handle(Input::End);
|
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
|
terminal
|
||||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let buffer = terminal.backend().buffer();
|
let buffer = terminal.backend().buffer();
|
||||||
assert_eq!(left, 4);
|
assert_eq!(left, 4);
|
||||||
assert_eq!(buffer[(0, 0)].symbol(), "界");
|
assert_eq!(buffer[(2, 0)].symbol(), "界");
|
||||||
assert_eq!(buffer[(2, 0)].symbol(), "e\u{301}");
|
assert_eq!(buffer[(4, 0)].symbol(), "e\u{301}");
|
||||||
assert_eq!(buffer[(4, 0)].symbol(), " ");
|
assert_eq!(buffer[(6, 0)].symbol(), " ");
|
||||||
for x in [0, 2, 3, 4] {
|
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);
|
editor.handle(Input::Escape);
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let buffer = terminal.backend().buffer();
|
let buffer = terminal.backend().buffer();
|
||||||
assert_eq!(buffer[(0, 0)].bg, Color::Yellow);
|
|
||||||
assert_eq!(buffer[(2, 0)].bg, Color::Yellow);
|
assert_eq!(buffer[(2, 0)].bg, Color::Yellow);
|
||||||
assert_ne!(buffer[(3, 0)].bg, Color::Blue);
|
assert_eq!(buffer[(4, 0)].bg, Color::Yellow);
|
||||||
assert_ne!(buffer[(4, 0)].bg, Color::Blue);
|
assert_ne!(buffer[(5, 0)].bg, Color::Blue);
|
||||||
|
assert_ne!(buffer[(6, 0)].bg, Color::Blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -860,20 +992,20 @@ mod tests {
|
|||||||
}
|
}
|
||||||
editor.handle(Input::Enter);
|
editor.handle(Input::Enter);
|
||||||
editor.handle(Input::End);
|
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);
|
let (mut top, mut left) = (0, 0);
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let buffer = terminal.backend().buffer();
|
let buffer = terminal.backend().buffer();
|
||||||
assert_eq!(left, 4);
|
assert_eq!(left, 4);
|
||||||
assert_eq!(buffer[(0, 0)].symbol(), "界");
|
assert_eq!(buffer[(2, 0)].symbol(), "界");
|
||||||
assert_eq!(buffer[(2, 0)].symbol(), "e\u{301}");
|
assert_eq!(buffer[(4, 0)].symbol(), "e\u{301}");
|
||||||
for x in [0, 2] {
|
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 {
|
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('/'));
|
editor.handle(Input::Char('/'));
|
||||||
for ch in "界界".chars() {
|
for ch in "界界".chars() {
|
||||||
@@ -883,8 +1015,8 @@ mod tests {
|
|||||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let buffer = terminal.backend().buffer();
|
let buffer = terminal.backend().buffer();
|
||||||
assert_eq!(buffer[(0, 4)].symbol(), "界");
|
assert_eq!(buffer[(1, 4)].symbol(), "界");
|
||||||
assert_eq!(buffer[(2, 4)].symbol(), "界");
|
assert_eq!(buffer[(3, 4)].symbol(), "界");
|
||||||
editor.handle(Input::Backspace);
|
editor.handle(Input::Backspace);
|
||||||
editor.handle(Input::Backspace);
|
editor.handle(Input::Backspace);
|
||||||
editor.handle(Input::Char('\t'));
|
editor.handle(Input::Char('\t'));
|
||||||
|
|||||||
Reference in New Issue
Block a user