Files
vamp/src/tui.rs
T
2026-10-08 11:17:03 +02:00

1015 lines
38 KiB
Rust

use std::{
io::{self, Stdout},
panic::{self, AssertUnwindSafe, PanicHookInfo},
sync::{Arc, Mutex},
};
use crossterm::{
cursor::Show,
event::{
self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent, KeyEventKind,
KeyModifiers,
},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Constraint, Layout},
style::{Color, Modifier, Style},
text::Line,
widgets::{Block, Borders, Clear, Paragraph},
};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::{
editor::{Editor, Input, Mode, Palette},
session::EditorSession,
};
pub const CAPABILITIES: &[&str] = &["text", "styled_text"];
const TAB_WIDTH: usize = 4;
type PanicHook = Box<dyn Fn(&PanicHookInfo<'_>) + Send + Sync + 'static>;
struct HookState {
previous: Option<PanicHook>,
active: bool,
}
struct TerminalSession {
hook: Arc<Mutex<HookState>>,
}
fn restore_terminal() {
let _ = disable_raw_mode();
let _ = execute!(
io::stdout(),
DisableBracketedPaste,
LeaveAlternateScreen,
Show
);
}
impl TerminalSession {
fn run(body: impl FnOnce() -> io::Result<()>) -> io::Result<()> {
let hook = Arc::new(Mutex::new(HookState {
previous: Some(panic::take_hook()),
active: false,
}));
let during_session = Arc::clone(&hook);
panic::set_hook(Box::new(move |info| {
let mut state = during_session
.lock()
.unwrap_or_else(|error| error.into_inner());
if state.active {
restore_terminal();
state.active = false;
}
if let Some(previous) = &state.previous {
previous(info);
}
}));
let session = Self { hook };
// set_hook is forbidden on a panicking thread. Catch first, drop the
// owner while not panicking, then resume the original panic unchanged.
let result = panic::catch_unwind(AssertUnwindSafe(|| {
enable_raw_mode()?;
session
.hook
.lock()
.unwrap_or_else(|error| error.into_inner())
.active = true;
execute!(io::stdout(), EnterAlternateScreen, EnableBracketedPaste)?;
body()
}));
drop(session);
match result {
Ok(result) => result,
Err(payload) => panic::resume_unwind(payload),
}
}
}
impl Drop for TerminalSession {
fn drop(&mut self) {
let mut state = self.hook.lock().unwrap_or_else(|error| error.into_inner());
if state.active {
restore_terminal();
state.active = false;
}
let previous = state
.previous
.take()
.expect("terminal session owns its prior hook");
drop(state);
panic::set_hook(previous);
}
}
/// Renders session state and translates terminal input; the session owns plugin
/// execution and retirement, so the adapter needs no lifecycle callback.
pub fn run(session: &mut EditorSession) -> io::Result<()> {
TerminalSession::run(|| {
let mut terminal: Terminal<CrosstermBackend<Stdout>> =
Terminal::new(CrosstermBackend::new(io::stdout()))?;
let (mut top, mut left) = (0, 0);
while !session.editor().should_quit() {
terminal.draw(|frame| draw(frame, session.editor(), &mut top, &mut left))?;
match event::read()? {
Event::Key(key) if key.kind != KeyEventKind::Release => {
if let Some(input) = translate(key) {
session.handle(input);
}
}
Event::Paste(text) => session.handle(Input::Paste(text)),
_ => {}
}
}
Ok(())
})
}
fn translate(key: KeyEvent) -> Option<Input> {
if key
.modifiers
.intersects(KeyModifiers::ALT | KeyModifiers::SUPER)
{
return None;
}
Some(match key.code {
KeyCode::Char(c) if key.modifiers.contains(KeyModifiers::CONTROL) => Input::Ctrl(c),
KeyCode::Char(c) => Input::Char(c),
KeyCode::Esc => Input::Escape,
KeyCode::Enter => Input::Enter,
KeyCode::Backspace => Input::Backspace,
KeyCode::Delete => Input::Delete,
KeyCode::Left => Input::Left,
KeyCode::Right => Input::Right,
KeyCode::Up => Input::Up,
KeyCode::Down => Input::Down,
KeyCode::Home => Input::Home,
KeyCode::End => Input::End,
KeyCode::Tab => Input::Char('\t'),
_ => return None,
})
}
fn display_column(line: &str, grapheme_column: usize) -> usize {
line.graphemes(true)
.take(grapheme_column)
.fold(0, |column, grapheme| {
column
+ if grapheme == "\t" {
TAB_WIDTH - column % TAB_WIDTH
} else {
UnicodeWidthStr::width(grapheme)
}
})
}
fn visible_line(line: &str, left: usize, width: usize) -> String {
let mut visible = String::new();
let mut column = 0;
let right = left.saturating_add(width);
for grapheme in line.graphemes(true) {
let cells = if grapheme == "\t" {
TAB_WIDTH - column % TAB_WIDTH
} else {
UnicodeWidthStr::width(grapheme)
};
let end = column + cells;
if column >= right {
break;
}
if end > left || (cells == 0 && column >= left) {
if grapheme == "\t" || column < left || end > right {
for _ in column.max(left)..end.min(right) {
visible.push(' ');
}
} else {
visible.push_str(grapheme);
}
}
column = end;
}
visible
}
fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
let area = frame.area();
if area.width == 0 || area.height == 0 {
return;
}
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 height > 0 {
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), 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[(viewport.x + (column - *left) as u16, y)].set_style(style);
}
}
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);
}
}
}
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
.path()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| "[No Name]".into());
let path: String = path
.chars()
.map(|c| if c.is_control() { '�' } else { c })
.collect();
let status = format!(
" {} {}{} {}:{}{}",
editor.mode(),
path,
if editor.dirty() { " [+]" } else { "" },
cursor.row + 1,
cursor.column + 1,
if editor.selection_count() > 1 {
format!(" {} selections", editor.selection_count())
} else {
String::new()
}
);
frame.render_widget(
Paragraph::new(status).style(Style::default().fg(Color::Black).bg(Color::Cyan)),
status_area,
);
if prompting && prompt_area.height > 0 {
let prefix = if editor.mode() == Mode::Search {
'/'
} else {
':'
};
let command = format!("{prefix}{}", editor.command_line());
let command_width = display_column(&command, usize::MAX);
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, prompt_width)),
prompt_area,
);
frame.set_cursor_position((
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,
));
}
}
if let Some(palette) = editor.palette() {
draw_palette(frame, palette, editor.mode());
}
}
fn draw_palette(frame: &mut Frame, palette: &Palette, mode: Mode) {
let area = frame.area();
if area.width == 0 || area.height == 0 {
return;
}
let [_, column, _] = Layout::horizontal([
Constraint::Fill(1),
Constraint::Length(area.width.min(100)),
Constraint::Fill(1),
])
.areas(area);
let [_, panel, _] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(area.height.min(16)),
Constraint::Fill(1),
])
.areas(column);
frame.render_widget(Clear, panel);
let bordered = panel.height >= 4 && panel.width >= 4;
let block = Block::default()
.borders(if bordered {
Borders::ALL
} else {
Borders::NONE
})
.title(format!(" Commands · {mode} "));
let content = if bordered { block.inner(panel) } else { panel };
if bordered {
frame.render_widget(block, panel);
}
let [query_area, results_area, help_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Length(u16::from(content.height >= 4)),
])
.areas(content);
let query = format!("> {}", palette.query);
let query_width = display_column(&query, usize::MAX);
let width = usize::from(query_area.width);
let offset = query_width.saturating_sub(width - 1);
frame.render_widget(
Paragraph::new(visible_line(&query, offset, width))
.style(Style::default().fg(Color::Yellow)),
query_area,
);
frame.set_cursor_position((query_area.x + (query_width - offset) as u16, query_area.y));
if palette.entries.is_empty() {
frame.render_widget(Paragraph::new("No matching commands"), results_area);
} else if results_area.height > 0 {
let row_height = usize::from(results_area.height >= 2) + 1;
let count = usize::from(results_area.height) / row_height;
let first = palette.selected.saturating_sub(count - 1);
let mut lines = Vec::with_capacity(count * row_height);
for (index, entry) in palette.entries.iter().enumerate().skip(first).take(count) {
let mut style = Style::default();
if !entry.enabled {
style = style.add_modifier(Modifier::DIM);
}
if index == palette.selected {
style = style.bg(Color::Blue).add_modifier(Modifier::BOLD);
}
let bindings = if entry.bindings.is_empty() {
"unbound".into()
} else {
entry.bindings.join(", ")
};
let modes = entry
.modes
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("/");
let metadata = format!("{} | {bindings} | {modes}", entry.owner);
let label = format!(
"{} {} ({})",
if index == palette.selected { ">" } else { " " },
entry.label,
entry.name
);
if row_height == 2 {
lines.push(Line::from(label).style(style));
lines.push(Line::from(format!(" {metadata}")).style(style));
} else {
lines.push(Line::from(format!("{label} | {metadata}")).style(style));
}
}
frame.render_widget(Paragraph::new(lines), results_area);
}
frame.render_widget(
Paragraph::new(format!(
"{mode} · ↑↓/Ctrl+k/n select · Enter accept · Esc/Ctrl+p cancel"
)),
help_area,
);
}
#[cfg(test)]
mod tests {
use super::*;
fn screen_text(terminal: &Terminal<ratatui::backend::TestBackend>) -> String {
let buffer = terminal.backend().buffer();
let area = buffer.area;
(area.y..area.bottom())
.map(|y| {
(area.x..area.right())
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn palette_renders_query_no_matches_and_restores_underlying_prompt() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.handle(Input::Char('/'));
for ch in "saved search".chars() {
editor.handle(Input::Char(ch));
}
editor.handle(Input::Ctrl('p'));
for ch in "no-such-command".chars() {
editor.handle(Input::Char(ch));
}
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap();
let (mut top, mut left) = (0, 0);
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains("> no-such-command"), "{text}");
assert!(text.contains("No matching commands"), "{text}");
assert!(text.contains("SEARCH"), "{text}");
assert_eq!(terminal.get_cursor_position().unwrap().y, 1);
editor.handle(Input::Enter);
assert!(editor.palette().is_some());
editor.handle(Input::Escape);
assert_eq!(editor.mode(), Mode::Search);
assert_eq!(editor.command_line(), "saved search");
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
assert!(screen_text(&terminal).contains("/saved search"));
}
#[test]
fn palette_window_follows_keyboard_selection() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.handle(Input::Ctrl('p'));
for _ in 0..20 {
editor.handle(Input::Down);
}
editor.handle(Input::Ctrl('k'));
editor.handle(Input::Ctrl('n'));
let palette = editor.palette().unwrap();
assert_eq!(palette.selected, 20);
let selected = &palette.entries[palette.selected];
let first_name = palette.entries[0].name.clone();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 10)).unwrap();
let (mut top, mut left) = (0, 0);
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains(&selected.name), "{text}");
assert!(!text.contains(&format!("({first_name})")), "{text}");
let buffer = terminal.backend().buffer();
let selected_y = (2..10).find(|&y| buffer[(1, y)].symbol() == ">").unwrap();
assert_eq!(buffer[(1, selected_y)].bg, Color::Blue);
assert!(buffer[(1, selected_y)].modifier.contains(Modifier::BOLD));
assert_eq!(terminal.get_cursor_position().unwrap().y, 1);
}
#[test]
fn palette_shows_effective_bindings_and_dimmed_mode_ineligible_commands() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.register(crate::editor::Feature {
name: "sample".into(),
required_capabilities: vec![],
commands: vec!["greet".into()],
core: false,
})
.unwrap();
editor.bind("normal", "g", Some("greet".into())).unwrap();
editor.bind("normal", "g", None).unwrap();
editor
.bind("normal", "ctrl+x", Some("greet".into()))
.unwrap();
editor.handle(Input::Ctrl('p'));
for ch in "greet".chars() {
editor.handle(Input::Char(ch));
}
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 10)).unwrap();
let (mut top, mut left) = (0, 0);
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains("sample | Ctrl+x | NORMAL"), "{text}");
assert!(!text.contains("unbound"), "{text}");
editor.handle(Input::Escape);
editor.handle(Input::Char('i'));
editor.handle(Input::Ctrl('p'));
for ch in "greet".chars() {
editor.handle(Input::Char(ch));
}
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains("sample | unbound | NORMAL"), "{text}");
assert!(text.contains("INSERT"), "{text}");
let cell = &terminal.backend().buffer()[(1, 2)];
assert!(cell.modifier.contains(Modifier::DIM));
assert_eq!(cell.bg, Color::Blue);
editor.handle(Input::Enter);
assert!(editor.palette().is_some());
assert_eq!(editor.mode(), Mode::Insert);
}
#[test]
fn palette_query_scrolls_graphemes_tabs_and_wide_cells_on_tiny_offset_areas() {
let palette = Palette {
query: "long\t界e\u{301}z".into(),
entries: vec![],
selected: 0,
};
for (width, height) in [(12, 6), (8, 2), (1, 1), (1, 2), (8, 1), (12, 6)] {
let area = ratatui::layout::Rect::new(3, 2, width, height);
let mut terminal = Terminal::with_options(
ratatui::backend::TestBackend::new(24, 12),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(area),
},
)
.unwrap();
terminal
.draw(|frame| draw_palette(frame, &palette, Mode::Visual))
.unwrap();
let cursor = terminal.get_cursor_position().unwrap();
assert!(area.contains(cursor), "{area:?}: {cursor:?}");
let bordered = height >= 4 && width >= 4;
let query_x = area.x + u16::from(bordered);
let query_y = area.y + u16::from(bordered);
let query_width = width - 2 * u16::from(bordered);
assert_eq!(cursor.x, query_x + query_width - 1);
assert_eq!(cursor.y, query_y);
let query = format!("> {}", palette.query);
let offset =
display_column(&query, usize::MAX).saturating_sub(usize::from(query_width) - 1);
let mut expected = ratatui::buffer::Buffer::empty(ratatui::layout::Rect::new(
query_x,
query_y,
query_width,
1,
));
use ratatui::widgets::Widget;
Paragraph::new(visible_line(&query, offset, usize::from(query_width)))
.render(expected.area, &mut expected);
for x in query_x..query_x + query_width {
assert_eq!(
terminal.backend().buffer()[(x, query_y)].symbol(),
expected[(x, query_y)].symbol()
);
}
assert_eq!(terminal.backend().buffer()[(0, 0)].symbol(), " ");
}
for (width, height) in [(0, 1), (1, 0), (0, 0)] {
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| draw_palette(frame, &palette, Mode::Normal))
.unwrap();
}
}
#[test]
fn opening_palette_does_not_scroll_underlying_editor_viewport() {
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 terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 4)).unwrap();
let (mut top, mut left) = (0, 0);
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
let viewport = (top, left);
editor.handle(Input::Ctrl('p'));
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
assert_eq!((top, left), viewport);
editor.handle(Input::Ctrl('p'));
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
assert_eq!((top, left), viewport);
let cursor = terminal.get_cursor_position().unwrap();
assert_eq!(
terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(),
"z"
);
}
#[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";
assert_eq!(display_column(text, 4), 7);
assert_eq!(visible_line(text, 2, 5), " 界e\u{301}");
assert_eq!(visible_line(text, 5, 2), " e\u{301}");
}
#[test]
fn selection_set_renders_all_ranges_and_counts_offscreen_targets() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace(
"界\n界\n界\n界\n界".into(),
)])
.unwrap();
editor.handle(Input::Home);
editor.handle(Input::Char('v'));
editor.handle(Input::Ctrl('a'));
assert_eq!(editor.selection_count(), 5);
assert_eq!(editor.selections().len(), 5);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(48, 4)).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 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));
}
}
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)
);
}
#[test]
fn secondary_carets_render_on_empty_lines_and_at_line_end() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace("a\n\n界".into())])
.unwrap();
editor.handle(Input::Home);
editor.handle(Input::Ctrl('e'));
editor.handle(Input::Ctrl('e'));
assert_eq!(editor.selection_count(), 3);
editor.handle(Input::Char('A'));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(48, 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();
for caret in editor.carets() {
let x = 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);
}
}
assert_eq!(
terminal.get_cursor_position().unwrap(),
ratatui::layout::Position::new(
display_column(&editor.lines()[editor.cursor().row], editor.cursor().column) as u16,
editor.cursor().row as u16
)
);
}
#[test]
fn visual_selection_highlights_graphemes_newlines_and_clipped_ranges_over_search() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace(
"a\t界e\u{301}z\n\nxy".into(),
)])
.unwrap();
editor.handle(Input::Char('/'));
for ch in "界e\u{301}".chars() {
editor.handle(Input::Char(ch));
}
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 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[(4, 0)].symbol(), "界");
assert_eq!(buffer[(6, 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);
editor.handle(Input::Up);
editor.handle(Input::Up);
editor.handle(Input::End);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(5, 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(), " ");
for x in [0, 2, 3, 4] {
assert_eq!(buffer[(x, 0)].bg, Color::Blue);
}
assert_ne!(buffer[(0, 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);
}
#[test]
fn active_search_highlight_uses_display_cells_and_clips_with_horizontal_scroll() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace(
"a\t界e\u{301}zz".into(),
)])
.unwrap();
editor.handle(Input::Char('/'));
for ch in "\t界e\u{301}".chars() {
editor.handle(Input::Char(ch));
}
editor.handle(Input::Enter);
editor.handle(Input::End);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(5, 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}");
for x in [0, 2] {
assert_eq!(buffer[(x, 0)].bg, Color::Yellow);
}
for x in 3..5 {
assert_ne!(buffer[(x, 0)].bg, Color::Yellow);
}
editor.handle(Input::Char('/'));
for ch in "界界".chars() {
editor.handle(Input::Char(ch));
}
terminal
.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(), "界");
editor.handle(Input::Backspace);
editor.handle(Input::Backspace);
editor.handle(Input::Char('\t'));
terminal
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
.unwrap();
assert_eq!(
terminal.get_cursor_position().unwrap(),
ratatui::layout::Position::new(4, 4)
);
}
#[cfg(unix)]
#[test]
fn terminal_session_scopes_the_callers_panic_hook() {
const CHILD: &str = "VAMP_TERMINAL_HOOK_PROBE";
if let Ok(mode) = std::env::var(CHILD) {
std::panic::set_hook(Box::new(|_| {
assert!(!crossterm::terminal::is_raw_mode_enabled().unwrap());
println!("CALLER_HOOK");
}));
if mode == "entry" {
let mut editor = EditorSession::open(None, vec![], None).unwrap();
assert!(run(&mut editor).is_err());
} else {
let result = panic::catch_unwind(|| {
TerminalSession::run(|| {
assert!(crossterm::terminal::is_raw_mode_enabled().unwrap());
match mode.as_str() {
"normal" => Ok(()),
"error" => Err(io::Error::from(io::ErrorKind::BrokenPipe)),
"panic" => panic::panic_any(73_u64),
_ => unreachable!(),
}
})
});
match mode.as_str() {
"normal" => result.unwrap().unwrap(),
"error" => assert_eq!(
result.unwrap().unwrap_err().kind(),
io::ErrorKind::BrokenPipe
),
"panic" => assert_eq!(result.unwrap_err().downcast_ref::<u64>(), Some(&73)),
_ => unreachable!(),
}
}
assert!(!crossterm::terminal::is_raw_mode_enabled().unwrap());
println!("AFTER_TERMINAL_RETURN");
let _ = panic::catch_unwind(|| panic!("outside the terminal session"));
return;
}
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};
unsafe extern "C" {
fn setsid() -> i32;
}
let mut command = Command::new(std::env::current_exe().unwrap());
command
.args([
"--exact",
"tui::tests::terminal_session_scopes_the_callers_panic_hook",
"--nocapture",
"--color",
"never",
])
.env(CHILD, "entry")
.stdin(Stdio::null());
// SAFETY: setsid is async-signal-safe and detaches the child from any
// controlling terminal without touching inherited Rust state.
unsafe {
command.pre_exec(|| {
if setsid() == -1 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
});
}
let check = |output: std::process::Output| {
assert!(output.status.success(), "{output:?}");
let stdout = String::from_utf8(output.stdout).unwrap();
let after = stdout.split_once("AFTER_TERMINAL_RETURN").unwrap().1;
assert!(after.contains("CALLER_HOOK"), "{after:?}");
assert!(
!after.contains('\u{1b}'),
"terminal hook outlived its session: {after:?}"
);
};
check(command.output().unwrap());
// Ruby's standard-library PTY gives each positive-entry scenario a real
// terminal, keeping process-global hooks isolated from the test runner.
for mode in ["normal", "error", "panic"] {
let output =
Command::new(std::env::var_os("VAMP_RUBY").unwrap_or_else(|| "ruby".into()))
.args([
"-rpty",
"-e",
r#"
PTY.spawn(*ARGV) do |output, input, pid|
input.close
begin
loop { STDOUT.write(output.readpartial(4096)) }
rescue EOFError, Errno::EIO
end
Process.wait(pid)
exit($?.exitstatus || 1)
end
"#,
"--",
])
.arg(std::env::current_exe().unwrap())
.args([
"--exact",
"tui::tests::terminal_session_scopes_the_callers_panic_hook",
"--nocapture",
"--color",
"never",
])
.env(CHILD, mode)
.output()
.unwrap();
check(output);
}
}
}