refactor: derive terminal regions from Ratatui layout

This commit is contained in:
Valdemar Lorenzen
2026-10-08 10:59:26 +02:00
parent 8ef92a7b15
commit 6ae5ccb220
2 changed files with 147 additions and 84 deletions
+145 -84
View File
@@ -16,7 +16,7 @@ use crossterm::{
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::Rect,
layout::{Constraint, Layout},
style::{Color, Style},
text::Line,
widgets::Paragraph,
@@ -200,84 +200,90 @@ fn visible_line(line: &str, left: usize, width: usize) -> String {
fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
let area = frame.area();
if area.width == 0 || area.height < 3 {
if area.width == 0 || area.height == 0 {
return;
}
let height = usize::from(area.height - 2);
let width = usize::from(area.width);
let prompting = matches!(editor.mode(), Mode::Command | Mode::Search);
let [viewport, status_area, prompt_area] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(u16::from(area.height >= 3)),
Constraint::Length(u16::from(area.height >= 2 || prompting)),
])
.areas(area);
let height = usize::from(viewport.height);
let width = usize::from(viewport.width);
let cursor = editor.cursor();
if cursor.row < *top {
*top = cursor.row;
} else if cursor.row >= top.saturating_add(height) {
*top = cursor.row + 1 - height;
}
let column = display_column(&editor.lines()[cursor.row], cursor.column);
if column < *left {
*left = column;
} else if column >= left.saturating_add(width) {
*left = column + 1 - width;
}
let lines: Vec<Line> = editor
.lines()
.iter()
.skip(*top)
.take(height)
.map(|line| Line::from(visible_line(line, *left, width)))
.collect();
frame.render_widget(
Paragraph::new(lines),
Rect::new(area.x, area.y, area.width, area.height - 2),
);
if let Some(found) = editor.search_match()
&& found.start.row >= *top
&& found.start.row < top.saturating_add(height)
{
let line = &editor.lines()[found.start.row];
let start = display_column(line, found.start.column).max(*left);
let end = display_column(line, found.end_column).min(left.saturating_add(width));
let y = area.y + (found.start.row - *top) as u16;
let style = Style::default().fg(Color::Black).bg(Color::Yellow);
let buffer = frame.buffer_mut();
for column in start..end {
buffer[(area.x + (column - *left) as u16, y)].set_style(style);
if height > 0 {
if cursor.row < *top {
*top = cursor.row;
} else if cursor.row >= top.saturating_add(height) {
*top = cursor.row + 1 - height;
}
}
for selection in editor.selections() {
let first = selection.start.row.max(*top);
let last = selection.end.row.min(top.saturating_add(height - 1));
let style = Style::default().fg(Color::White).bg(Color::Blue);
for row in first..=last {
let line = &editor.lines()[row];
let start = if row == selection.start.row {
display_column(line, selection.start.column)
} else {
0
};
let end = if row == selection.end.row {
display_column(line, selection.end.column)
} else {
display_column(line, usize::MAX) + 1
};
let start = start.max(*left);
let end = end.min(left.saturating_add(width));
let y = area.y + (row - *top) as u16;
let column = display_column(&editor.lines()[cursor.row], cursor.column);
if column < *left {
*left = column;
} else if column >= left.saturating_add(width) {
*left = column + 1 - width;
}
let lines: Vec<Line> = editor
.lines()
.iter()
.skip(*top)
.take(height)
.map(|line| Line::from(visible_line(line, *left, width)))
.collect();
frame.render_widget(Paragraph::new(lines), viewport);
if let Some(found) = editor.search_match()
&& found.start.row >= *top
&& found.start.row < top.saturating_add(height)
{
let line = &editor.lines()[found.start.row];
let start = display_column(line, found.start.column).max(*left);
let end = display_column(line, found.end_column).min(left.saturating_add(width));
let y = viewport.y + (found.start.row - *top) as u16;
let style = Style::default().fg(Color::Black).bg(Color::Yellow);
let buffer = frame.buffer_mut();
for column in start..end {
buffer[(area.x + (column - *left) as u16, y)].set_style(style);
buffer[(viewport.x + (column - *left) as u16, y)].set_style(style);
}
}
}
for caret in editor.carets().into_iter().filter(|caret| *caret != cursor) {
if caret.row < *top || caret.row >= top.saturating_add(height) {
continue;
for selection in editor.selections() {
let first = selection.start.row.max(*top);
let last = selection.end.row.min(top.saturating_add(height - 1));
let style = Style::default().fg(Color::White).bg(Color::Blue);
for row in first..=last {
let line = &editor.lines()[row];
let start = if row == selection.start.row {
display_column(line, selection.start.column)
} else {
0
};
let end = if row == selection.end.row {
display_column(line, selection.end.column)
} else {
display_column(line, usize::MAX) + 1
};
let start = start.max(*left);
let end = end.min(left.saturating_add(width));
let y = viewport.y + (row - *top) as u16;
let buffer = frame.buffer_mut();
for column in start..end {
buffer[(viewport.x + (column - *left) as u16, y)].set_style(style);
}
}
}
let column = display_column(&editor.lines()[caret.row], caret.column);
if column >= *left && column < left.saturating_add(width) {
frame.buffer_mut()[(
area.x + (column - *left) as u16,
area.y + (caret.row - *top) as u16,
)]
.set_style(Style::default().fg(Color::Black).bg(Color::Magenta));
for caret in editor.carets().into_iter().filter(|caret| *caret != cursor) {
if caret.row < *top || caret.row >= top.saturating_add(height) {
continue;
}
let column = display_column(&editor.lines()[caret.row], caret.column);
if column >= *left && column < left.saturating_add(width) {
frame.buffer_mut()[(
viewport.x + (column - *left) as u16,
viewport.y + (caret.row - *top) as u16,
)]
.set_style(Style::default().fg(Color::Black).bg(Color::Magenta));
}
}
}
let path = editor
@@ -303,10 +309,9 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
);
frame.render_widget(
Paragraph::new(status).style(Style::default().fg(Color::Black).bg(Color::Cyan)),
Rect::new(area.x, area.y + area.height - 2, area.width, 1),
status_area,
);
let bottom = area.y + area.height - 1;
if matches!(editor.mode(), Mode::Command | Mode::Search) {
if prompting && prompt_area.height > 0 {
let prefix = if editor.mode() == Mode::Search {
'/'
} else {
@@ -314,21 +319,25 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
};
let command = format!("{prefix}{}", editor.command_line());
let command_width = display_column(&command, usize::MAX);
let offset = command_width.saturating_sub(width - 1);
let prompt_width = usize::from(prompt_area.width);
let offset = command_width.saturating_sub(prompt_width - 1);
frame.render_widget(
Paragraph::new(visible_line(&command, offset, width)),
Rect::new(area.x, bottom, area.width, 1),
);
frame.set_cursor_position((area.x + (command_width - offset) as u16, bottom));
} else {
frame.render_widget(
Paragraph::new(editor.message()),
Rect::new(area.x, bottom, area.width, 1),
Paragraph::new(visible_line(&command, offset, prompt_width)),
prompt_area,
);
frame.set_cursor_position((
area.x + (column - *left) as u16,
area.y + (cursor.row - *top) as u16,
prompt_area.x + (command_width - offset) as u16,
prompt_area.y,
));
} else {
frame.render_widget(Paragraph::new(editor.message()), prompt_area);
if height > 0 {
let column = display_column(&editor.lines()[cursor.row], cursor.column);
frame.set_cursor_position((
viewport.x + (column - *left) as u16,
viewport.y + (cursor.row - *top) as u16,
));
}
}
}
@@ -336,6 +345,58 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
mod tests {
use super::*;
#[test]
fn layout_keeps_scrolled_content_and_prompts_inside_offset_regions() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace(
"first\nsecond\n0123456789界z".into(),
)])
.unwrap();
editor.handle(Input::Down);
editor.handle(Input::Down);
editor.handle(Input::End);
let (mut top, mut left) = (0, 0);
for prompting in [false, true] {
if prompting {
editor.handle(Input::Char('/'));
for ch in "long query".chars() {
editor.handle(Input::Char(ch));
}
}
for height in [4, 2, 1, 4] {
let area = ratatui::layout::Rect::new(3, 2, 8, height);
let mut terminal = Terminal::with_options(
ratatui::backend::TestBackend::new(20, 10),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(area),
},
)
.unwrap();
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
let cursor = terminal.get_cursor_position().unwrap();
assert!(area.contains(cursor), "{area:?}: {cursor:?}");
let buffer = terminal.backend().buffer();
if prompting {
assert_eq!(cursor.y, area.bottom() - 1);
assert_eq!(buffer[(area.x, cursor.y)].symbol(), "g");
} else {
assert_eq!(buffer[(cursor.x, cursor.y)].symbol(), "z");
}
if height >= 3 {
assert_eq!(buffer[(area.x, area.bottom() - 2)].bg, Color::Cyan);
} else {
for y in area.y..area.bottom() {
assert_ne!(buffer[(area.x, y)].bg, Color::Cyan);
}
}
assert_eq!(buffer[(0, 0)].symbol(), " ");
}
}
}
#[test]
fn tabs_and_wide_graphemes_scroll_in_terminal_cells() {
let text = "a\t界e\u{301}z";