refactor: derive terminal regions from Ratatui layout
This commit is contained in:
+145
-84
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user