diff --git a/README.md b/README.md index ca9342d..1381182 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,8 @@ Frontends inspect `selections()` for document-ordered, end-exclusive Visual rang The terminal adapter owns raw mode, alternate-screen state, bracketed paste, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active. +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. + 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. diff --git a/src/tui.rs b/src/tui.rs index 432fe24..5853bcd 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -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 = 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 = 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";