use std::{ collections::BTreeMap, fmt::Write as _, 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, Rect}, 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}, files::{DockSide, FilePicker, FilePresentation}, session::EditorSession, }; pub const CAPABILITIES: &[&str] = &["text", "styled_text"]; const TAB_WIDTH: usize = 4; type PanicHook = Box) + Send + Sync + 'static>; struct HookState { previous: Option, active: bool, } struct TerminalSession { hook: Arc>, } 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); } } #[derive(Default)] struct BufferViewports { offsets: BTreeMap, } impl BufferViewports { fn draw(&mut self, frame: &mut Frame, editor: &Editor) { self.offsets.retain(|id, _| editor.has_buffer(*id)); let (top, left) = self.offsets.entry(editor.buffer_id()).or_default(); draw(frame, editor, top, left); } } /// 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> = Terminal::new(CrosstermBackend::new(io::stdout()))?; let mut viewports = BufferViewports::default(); while !session.editor().should_quit() { terminal.draw(|frame| viewports.draw(frame, session.editor()))?; 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 { 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.to_ascii_lowercase()) } 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 dock_focused = editor .file_picker() .is_some_and(|picker| picker.focused && picker.presentation == FilePresentation::Docked); let [workspace, status_area, prompt_area] = Layout::vertical([ Constraint::Fill(1), Constraint::Length(u16::from(area.height >= 3)), Constraint::Length(u16::from(area.height >= 2 || (prompting && !dock_focused))), ]) .areas(area); let (viewport, dock) = match editor.file_picker() { Some(picker) if picker.presentation == FilePresentation::Docked => { dock_layout(workspace, editor.file_explorer_side(), picker.focused) } _ => (workspace, None), }; 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 width = usize::from(viewport.width); let cursor = editor.cursor(); if height > 0 && width > 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; } if gutter.width > 0 { let numbers: Vec = (*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) }; let number = if row == cursor.row { row + 1 } else { row.abs_diff(cursor.row) }; Line::styled(format!("{number:>digits$} "), style) }) .collect(); frame.render_widget(Paragraph::new(numbers), gutter); } 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[(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 mut status = format!(" {}", editor.mode()); if let Some(sequence) = editor.leader_pending() { write!(status, " leader: {sequence}").unwrap(); } if editor.buffer_count() > 1 { write!( status, " [buffer {}/{}]", editor.buffer_id(), editor.buffer_count() ) .unwrap(); } write!( status, " {}{} {}:{}", path, if editor.dirty() { " [+]" } else { "" }, cursor.row + 1, cursor.column + 1, ) .unwrap(); if editor.selection_count() > 1 { write!(status, " {} selections", editor.selection_count()).unwrap(); } if let Some(count) = editor.count() { write!(status, " count: {count}").unwrap(); } frame.render_widget( Paragraph::new(status).style(Style::default().fg(Color::Black).bg(Color::Cyan)), status_area, ); if prompting && prompt_area.height > 0 && prompt_area.width > 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.saturating_sub(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 && width > 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(picker) = editor.file_picker() { if picker.presentation == FilePresentation::Floating { let [_, column, _] = Layout::horizontal([ Constraint::Fill(1), Constraint::Length(area.width.min(84)), Constraint::Fill(1), ]) .areas(area); let [_, panel, _] = Layout::vertical([ Constraint::Fill(1), Constraint::Length(area.height.min(18)), Constraint::Fill(1), ]) .areas(column); frame.render_widget(Clear, panel); draw_file_list(frame, picker, panel, editor.selection_navigation_hint()); } else if let Some(panel) = dock { draw_file_list(frame, picker, panel, editor.selection_navigation_hint()); } } if let Some(palette) = editor.palette() { draw_palette( frame, palette, editor.mode(), editor.selection_navigation_hint(), ); } } fn dock_layout(area: Rect, side: DockSide, focused: bool) -> (Rect, Option) { if area.width == 0 || area.height == 0 { return (area, None); } let horizontal = matches!(side, DockSide::Left | DockSide::Right); let available = if horizontal { area.width } else { area.height }; if !focused && available < if horizontal { 24 } else { 8 } { return (area, None); } let extent = if available < if horizontal { 20 } else { 6 } { available } else if horizontal { (available / 3).clamp(12, 38).min(available - 8) } else { (available / 3).clamp(4, 12).min(available - 2) }; let leading = matches!(side, DockSide::Left | DockSide::Top); let constraints = if leading { [Constraint::Length(extent), Constraint::Fill(1)] } else { [Constraint::Fill(1), Constraint::Length(extent)] }; let [first, second] = if horizontal { Layout::horizontal(constraints).areas(area) } else { Layout::vertical(constraints).areas(area) }; if leading { (second, Some(first)) } else { (first, Some(second)) } } fn display_text(text: &str) -> String { text.chars() .map(|ch| if ch.is_control() { '�' } else { ch }) .collect() } fn draw_file_list(frame: &mut Frame, picker: &FilePicker, panel: Rect, navigation_hint: &str) { if panel.width == 0 || panel.height == 0 { return; } let color = if picker.focused { Color::Cyan } else { Color::DarkGray }; let bordered = panel.width >= 4 && panel.height >= 4; let title = match picker.presentation { FilePresentation::Floating => " Files ", FilePresentation::Docked => " Explorer ", }; let block = Block::default() .borders(if bordered { Borders::ALL } else { Borders::NONE }) .border_style(Style::default().fg(color)) .title(title); let content = if bordered { block.inner(panel) } else { panel }; if bordered { frame.render_widget(block, panel); } let show_root = content.height >= 4; let show_error = picker.error.is_some() && content.height >= 2; let show_help = content.height >= 5 + u16::from(show_error); let [query_area, root_area, error_area, results_area, help_area] = Layout::vertical([ Constraint::Length(1), Constraint::Length(u16::from(show_root)), Constraint::Length(u16::from(show_error)), Constraint::Fill(1), Constraint::Length(u16::from(show_help)), ]) .areas(content); let query = format!("> {}", display_text(&picker.query)); let query_width = display_column(&query, usize::MAX); let width = usize::from(query_area.width); let offset = query_width.saturating_sub(width.saturating_sub(1)); frame.render_widget( Paragraph::new(visible_line(&query, offset, width)).style(Style::default().fg( if picker.focused { Color::Yellow } else { Color::DarkGray }, )), query_area, ); if picker.focused && query_area.width > 0 && query_area.height > 0 { frame.set_cursor_position((query_area.x + (query_width - offset) as u16, query_area.y)); } if show_root { frame.render_widget( Paragraph::new(display_text(&picker.root.to_string_lossy())) .style(Style::default().fg(Color::DarkGray)), root_area, ); } if show_error { frame.render_widget( Paragraph::new(display_text(picker.error.as_deref().unwrap())) .style(Style::default().fg(Color::Red)), error_area, ); } let entries = picker.entries(); if entries.len() == 0 { frame.render_widget( Paragraph::new(if picker.query.is_empty() { "No project files" } else { "No matching files" }), results_area, ); } else if results_area.height > 0 { let count = usize::from(results_area.height); let first = picker.selected.saturating_sub(count - 1); let lines: Vec = entries .enumerate() .skip(first) .take(count) .map(|(index, entry)| { let selected = index == picker.selected; let style = if selected { Style::default() .bg(if picker.focused { Color::Blue } else { Color::DarkGray }) .add_modifier(Modifier::BOLD) } else { Style::default() }; Line::styled( format!("{} {}", if selected { ">" } else { " " }, entry.label), style, ) }) .collect(); frame.render_widget(Paragraph::new(lines), results_area); } let help = if picker.focused { let controls = match picker.presentation { FilePresentation::Floating => "Enter · Esc/^W close · ^R scan", FilePresentation::Docked => "Enter · Esc/Tab edit · ^W · ^R", }; format!("{navigation_hint} · {controls}") } else { "Editor focus · invoke explorer to search".into() }; frame.render_widget( Paragraph::new(help).style(Style::default().fg(Color::DarkGray)), help_area, ); } fn draw_palette(frame: &mut Frame, palette: &Palette, mode: Mode, navigation_hint: &str) { 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::>() .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} · {navigation_hint} · Enter accept · Esc/Ctrl+p cancel" )), help_area, ); } #[cfg(test)] mod tests { use super::*; use std::{ fs, path::PathBuf, sync::atomic::{AtomicUsize, Ordering}, }; struct Directory(PathBuf); impl Directory { fn new() -> Self { static NEXT_ID: AtomicUsize = AtomicUsize::new(0); let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); let path = std::env::temp_dir().join(format!("vamp-tui-test-{}-{id}", std::process::id())); fs::create_dir(&path).unwrap(); Self(path) } fn file(&self, name: &str, text: &str) -> PathBuf { let path = self.0.join(name); fs::write(&path, text).unwrap(); path } } impl Drop for Directory { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } fn screen_text(terminal: &Terminal) -> 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::() }) .collect::>() .join("\n") } #[test] fn translate_distinguishes_control_j_from_enter_and_normalizes_control_letters() { for (code, modifiers, expected) in [ ( KeyCode::Char('j'), KeyModifiers::CONTROL, Some(Input::Ctrl('j')), ), ( KeyCode::Char('J'), KeyModifiers::CONTROL | KeyModifiers::SHIFT, Some(Input::Ctrl('j')), ), ( KeyCode::Char('k'), KeyModifiers::CONTROL, Some(Input::Ctrl('k')), ), (KeyCode::Enter, KeyModifiers::NONE, Some(Input::Enter)), (KeyCode::Enter, KeyModifiers::CONTROL, Some(Input::Enter)), ( KeyCode::Char('J'), KeyModifiers::SHIFT, Some(Input::Char('J')), ), (KeyCode::Char('j'), KeyModifiers::ALT, None), ] { assert_eq!(translate(KeyEvent::new(code, modifiers)), expected); } } #[test] fn file_list_window_follows_control_j_k_selection() { let mut editor = Editor::open(None, vec![]).unwrap(); editor.handle(Input::Char(' ')); editor.handle(Input::Char('o')); let count = editor.file_picker().unwrap().entries().len(); assert!(count > 1); for _ in 0..count - 1 { editor.handle( translate(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL)).unwrap(), ); } assert_eq!(editor.file_picker().unwrap().selected, count - 1); editor.handle(translate(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)).unwrap()); assert_eq!(editor.file_picker().unwrap().selected, count - 2); let picker = editor.file_picker().unwrap(); let label = picker.entries().nth(picker.selected).unwrap().label.clone(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 9)).unwrap(); terminal .draw(|frame| { draw_file_list( frame, picker, frame.area(), editor.selection_navigation_hint(), ) }) .unwrap(); let text = screen_text(&terminal); assert!(text.contains(&format!("> {label}")), "{text}"); let buffer = terminal.backend().buffer(); let selected_y = (3..8).find(|&y| buffer[(1, y)].symbol() == ">").unwrap(); assert_eq!(buffer[(1, selected_y)].bg, Color::Blue); } #[test] fn list_footers_show_remapped_and_disabled_selection_keys() { let mut editor = Editor::open(None, vec![]).unwrap(); for key in ["ctrl+j", "ctrl+n", "down", "ctrl+k", "up"] { editor.bind("selection", key, None).unwrap(); } editor .bind("selection", "x", Some("core.down".into())) .unwrap(); let hint = editor.selection_navigation_hint().to_owned(); assert_eq!(hint, "x next · unbound previous"); editor.set_file_explorer_side("bottom").unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); let (mut top, mut left) = (0, 0); for key in ['o', 'e'] { editor.handle(Input::Char(' ')); editor.handle(Input::Char(key)); terminal .draw(|frame| draw(frame, &editor, &mut top, &mut left)) .unwrap(); let text = screen_text(&terminal); assert!(text.contains(&hint), "{text}"); assert!(!text.contains("Ctrl+j"), "{text}"); editor.handle(Input::Escape); } editor.handle(Input::Ctrl('p')); terminal .draw(|frame| draw(frame, &editor, &mut top, &mut left)) .unwrap(); let text = screen_text(&terminal); assert!(text.contains(&hint), "{text}"); assert!(!text.contains("Ctrl+j"), "{text}"); } #[test] fn file_list_scrolls_selection_and_sanitizes_display_without_losing_paths() { let directory = Directory::new(); for index in 0..18 { directory.file(&format!("file-{index:02}"), "text"); } let special = directory.file("界e\u{301} space\nname", "text"); let editor = Editor::open(None, vec![]).unwrap(); let mut picker = FilePicker::scan(directory.0.clone(), FilePresentation::Floating).unwrap(); assert!( picker .entries() .any(|entry| directory.0.join(&entry.path) == special) ); let count = picker.entries().len(); picker.selected = count - 1; let label = picker.entries().last().unwrap().label.clone(); assert_eq!(label, "界e\u{301} space�name"); picker.query = "long\t界e\u{301}z".into(); let area = Rect::new(3, 2, 24, 9); let mut terminal = Terminal::with_options( ratatui::backend::TestBackend::new(40, 15), ratatui::TerminalOptions { viewport: ratatui::Viewport::Fixed(area), }, ) .unwrap(); terminal .draw(|frame| draw_file_list(frame, &picker, area, editor.selection_navigation_hint())) .unwrap(); let text = screen_text(&terminal); assert!(!text.contains("file-00"), "{text}"); let cursor = terminal.get_cursor_position().unwrap(); let buffer = terminal.backend().buffer(); assert_eq!(buffer[(area.x + 7, area.y + 1)].symbol(), "�"); assert_eq!(buffer[(area.x + 8, area.y + 1)].symbol(), "界"); assert_eq!(buffer[(area.x + 10, area.y + 1)].symbol(), "e\u{301}"); let selected_y = (area.y + 2..area.bottom() - 1) .find(|&y| buffer[(area.x + 1, y)].symbol() == ">") .unwrap(); assert_eq!(buffer[(area.x + 3, selected_y)].symbol(), "界"); assert_eq!(buffer[(area.x + 5, selected_y)].symbol(), "e\u{301}"); assert_eq!(buffer[(area.x + 1, selected_y)].bg, Color::Blue); assert!( buffer[(area.x + 1, selected_y)] .modifier .contains(Modifier::BOLD) ); assert_eq!(cursor.y, area.y + 1); assert_eq!(buffer[(0, 0)].symbol(), " "); } #[test] fn file_list_renders_root_empty_filter_errors_and_idle_focus() { let directory = Directory::new(); let editor = Editor::open(None, vec![]).unwrap(); let mut picker = FilePicker::scan(directory.0.clone(), FilePresentation::Docked).unwrap(); let area = Rect::new(0, 0, 100, 12); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); terminal .draw(|frame| draw_file_list(frame, &picker, area, editor.selection_navigation_hint())) .unwrap(); let text = screen_text(&terminal); assert!(text.contains("Explorer"), "{text}"); assert!( text.contains(&directory.0.to_string_lossy().to_string()), "{text}" ); assert!(text.contains("No project files"), "{text}"); assert!(text.contains("Esc/Tab"), "{text}"); picker.query = "missing".into(); picker.error = Some("Unreadable\npath\u{1b}".into()); picker.focused = false; terminal .draw(|frame| { frame.set_cursor_position((99, 11)); draw_file_list(frame, &picker, area, editor.selection_navigation_hint()); }) .unwrap(); let text = screen_text(&terminal); assert!(text.contains("No matching files"), "{text}"); assert!(text.contains("Unreadable�path�"), "{text}"); assert!(text.contains("Editor focus"), "{text}"); assert_eq!(terminal.backend().buffer()[(1, 3)].fg, Color::Red); assert_eq!(terminal.backend().buffer()[(0, 0)].fg, Color::DarkGray); assert_eq!( terminal.get_cursor_position().unwrap(), ratatui::layout::Position::new(99, 11) ); picker.presentation = FilePresentation::Floating; picker.focused = true; terminal .draw(|frame| draw_file_list(frame, &picker, area, editor.selection_navigation_hint())) .unwrap(); assert!(screen_text(&terminal).contains("Files")); assert!(screen_text(&terminal).contains("Esc/^W")); assert_eq!(terminal.backend().buffer()[(0, 0)].fg, Color::Cyan); } #[test] fn file_query_cursor_and_dock_layout_survive_tiny_offset_resizes() { let directory = Directory::new(); let editor = Editor::open(None, vec![]).unwrap(); let mut picker = FilePicker::scan(directory.0.clone(), FilePresentation::Docked).unwrap(); picker.query = "long界e\u{301}z".into(); for (width, height) in [(12, 6), (8, 2), (1, 1), (1, 2), (8, 1), (12, 6)] { let area = 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_file_list(frame, &picker, area, editor.selection_navigation_hint()) }) .unwrap(); let cursor = terminal.get_cursor_position().unwrap(); assert!(area.contains(cursor), "{area:?}: {cursor:?}"); let bordered = width >= 4 && height >= 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!("> {}", picker.query); let offset = display_column(&query, usize::MAX).saturating_sub(usize::from(query_width) - 1); let mut expected = ratatui::buffer::Buffer::empty(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 side in [ DockSide::Left, DockSide::Right, DockSide::Top, DockSide::Bottom, ] { for focused in [false, true] { let (editor, panel) = dock_layout(area, side, focused); for rect in [Some(editor), panel].into_iter().flatten() { assert!(rect.x >= area.x && rect.y >= area.y); assert!(rect.right() <= area.right() && rect.bottom() <= area.bottom()); } if focused { assert!(panel.is_some()); } else { assert!(panel.is_none()); } } } } 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_file_list( frame, &picker, frame.area(), editor.selection_navigation_hint(), ) }) .unwrap(); assert!( dock_layout(Rect::new(3, 2, width, height), DockSide::Right, true) .1 .is_none() ); } } #[test] fn all_dock_sides_clip_editor_gutter_selection_carets_and_cursor() { let mut editor = Editor::open(None, vec![]).unwrap(); editor .apply(vec![crate::editor::Effect::Replace( vec!["word ".repeat(40); 20].join("\n"), )]) .unwrap(); editor.handle(Input::Char('v')); editor.handle(Input::Ctrl('a')); assert!(editor.selection_count() > 1); let area = Rect::new(3, 2, 90, 26); let mut terminal = Terminal::with_options( ratatui::backend::TestBackend::new(100, 32), ratatui::TerminalOptions { viewport: ratatui::Viewport::Fixed(area), }, ) .unwrap(); let (mut top, mut left) = (0, 0); editor.handle(Input::Char(' ')); editor.handle(Input::Char('e')); assert!(editor.file_picker().unwrap().focused); for (name, side) in [ ("left", DockSide::Left), ("right", DockSide::Right), ("top", DockSide::Top), ("bottom", DockSide::Bottom), ] { editor.set_file_explorer_side(name).unwrap(); terminal .draw(|frame| draw(frame, &editor, &mut top, &mut left)) .unwrap(); let workspace = Rect::new(area.x, area.y, area.width, area.height - 2); let (editor_area, panel) = dock_layout(workspace, side, true); let panel = panel.unwrap(); assert_eq!(editor_area.area() + panel.area(), workspace.area()); assert_eq!(editor_area.intersection(panel).area(), 0); match side { DockSide::Left => assert_eq!(panel.right(), editor_area.x), DockSide::Right => assert_eq!(editor_area.right(), panel.x), DockSide::Top => assert_eq!(panel.bottom(), editor_area.y), DockSide::Bottom => assert_eq!(editor_area.bottom(), panel.y), } assert!(panel.contains(terminal.get_cursor_position().unwrap())); editor.handle(Input::Escape); terminal .draw(|frame| draw(frame, &editor, &mut top, &mut left)) .unwrap(); assert!(editor_area.contains(terminal.get_cursor_position().unwrap())); let buffer = terminal.backend().buffer(); for y in panel.y..panel.bottom() { for x in panel.x..panel.right() { assert!( !matches!( buffer[(x, y)].bg, Color::Blue | Color::Magenta | Color::Yellow ), "{side:?}: {x},{y}" ); } } assert_eq!(buffer[(editor_area.x + 3, editor_area.y)].symbol(), "w"); assert!(matches!( buffer[(editor_area.x + 3, editor_area.y)].bg, Color::Blue | Color::Magenta )); for y in editor_area.y..editor_area.bottom() { for x in editor_area.x..editor_area.x + 3 { assert_eq!(buffer[(x, y)].bg, Color::Reset); } } for x in area.x..area.right() { assert_eq!(buffer[(x, area.bottom() - 2)].bg, Color::Cyan); } assert_eq!(buffer[(0, 0)].symbol(), " "); editor.handle(Input::Char(' ')); editor.handle(Input::Char('e')); assert!(editor.file_picker().unwrap().focused); } } #[test] fn floating_picker_clears_a_centered_overlay_without_moving_editor_regions() { let mut editor = Editor::open(None, vec![]).unwrap(); editor .apply(vec![crate::editor::Effect::Replace( vec!["x".repeat(120); 30].join("\n"), )]) .unwrap(); let area = Rect::new(3, 2, 110, 26); let mut terminal = Terminal::with_options( ratatui::backend::TestBackend::new(120, 32), 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(); let before = (top, left); editor.handle(Input::Char(' ')); editor.handle(Input::Char('o')); terminal .draw(|frame| draw(frame, &editor, &mut top, &mut left)) .unwrap(); let panel = Rect::new(area.x + 13, area.y + 4, 84, 18); assert_eq!((top, left), before); assert_eq!( terminal.get_cursor_position().unwrap(), ratatui::layout::Position::new(panel.x + 3, panel.y + 1) ); let buffer = terminal.backend().buffer(); assert_eq!(buffer[(panel.x, panel.y)].fg, Color::Cyan); assert_eq!(buffer[(panel.right() - 2, panel.y + 1)].symbol(), " "); assert_eq!(buffer[(area.x + 3, area.y)].symbol(), "x"); assert_eq!(buffer[(area.x, area.bottom() - 2)].bg, Color::Cyan); assert_eq!(buffer[(0, 0)].symbol(), " "); } #[test] fn picker_conversion_focus_palette_and_closure_preserve_buffer_viewport_cache() { let directory = Directory::new(); let a = directory.file("a", &vec!["a".repeat(80); 30].join("\n")); let b = directory.file("b", "other"); let mut editor = Editor::open(Some(a), vec![]).unwrap(); let a_id = editor.buffer_id(); for _ in 0..20 { editor.handle(Input::Down); } editor.handle(Input::End); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(50, 10)).unwrap(); let mut viewports = BufferViewports::default(); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); let before = viewports.offsets[&a_id]; editor.handle(Input::Char(' ')); editor.handle(Input::Char('o')); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert_eq!(viewports.offsets[&a_id], before); assert!(screen_text(&terminal).contains("Files")); editor.handle(Input::Escape); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert_eq!(viewports.offsets[&a_id], before); editor.handle(Input::Char(' ')); editor.handle(Input::Char('e')); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert!(screen_text(&terminal).contains("Explorer")); editor.handle(Input::Escape); let docked = editor.file_picker().unwrap(); assert!(!docked.focused); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); let dock_offsets = viewports.offsets[&a_id]; editor.handle(Input::Ctrl('p')); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert!(screen_text(&terminal).contains("Commands")); assert_eq!(terminal.get_cursor_position().unwrap().y, 1); assert_eq!(viewports.offsets[&a_id], dock_offsets); editor.handle(Input::Escape); editor.execute(&format!("edit {}", b.display())); let b_id = editor.buffer_id(); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert!(editor.file_picker().is_some()); assert_eq!(viewports.offsets[&b_id], (0, 0)); editor.execute(&format!("buffer {a_id}")); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert_eq!(viewports.offsets[&a_id], dock_offsets); editor.handle(Input::Char(' ')); editor.handle(Input::Char('o')); assert_eq!( editor.file_picker().unwrap().presentation, FilePresentation::Floating ); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert!(screen_text(&terminal).contains("Files")); editor.handle(Input::Ctrl('w')); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert!(editor.file_picker().is_none()); assert_eq!(viewports.offsets[&b_id], (0, 0)); assert!(screen_text(&terminal).contains("a")); } #[test] fn docked_search_highlight_and_cursor_survive_focus_and_offset_resizes() { let mut editor = Editor::open(None, vec![]).unwrap(); editor .apply(vec![crate::editor::Effect::Replace( "0123456789界e\u{301}z\nsecond".into(), )]) .unwrap(); editor.handle(Input::Char('/')); editor.handle(Input::Char('界')); editor.handle(Input::Enter); assert!(editor.search_match().is_some()); editor.handle(Input::Char(' ')); editor.handle(Input::Char('e')); let mut terminal = Terminal::with_options( ratatui::backend::TestBackend::new(100, 32), ratatui::TerminalOptions { viewport: ratatui::Viewport::Fixed(Rect::new(3, 2, 90, 26)), }, ) .unwrap(); let (mut top, mut left) = (0, 0); for (name, side) in [ ("left", DockSide::Left), ("right", DockSide::Right), ("top", DockSide::Top), ("bottom", DockSide::Bottom), ] { editor.set_file_explorer_side(name).unwrap(); for (width, height) in [ (90, 26), (12, 6), (8, 2), (1, 1), (1, 2), (0, 1), (1, 0), (90, 26), ] { let area = Rect::new(3, 2, width, height); terminal.resize(area).unwrap(); let area = terminal.get_frame().area(); terminal .draw(|frame| draw(frame, &editor, &mut top, &mut left)) .unwrap(); if width > 0 && height > 0 { let workspace = Rect::new( area.x, area.y, width, height - u16::from(height >= 3) - u16::from(height >= 2), ); let (_, panel) = dock_layout(workspace, side, true); let cursor = terminal.get_cursor_position().unwrap(); assert!( panel.unwrap().contains(cursor), "{side:?} {area:?} {panel:?}: {cursor:?}" ); } editor.handle(Input::Escape); terminal .draw(|frame| draw(frame, &editor, &mut top, &mut left)) .unwrap(); if width > 0 && height > 0 { let cursor = terminal.get_cursor_position().unwrap(); assert!(area.contains(cursor), "{side:?} {area:?}: {cursor:?}"); let workspace = Rect::new( area.x, area.y, width, height - u16::from(height >= 3) - u16::from(height >= 2), ); let (editor_area, panel) = dock_layout(workspace, side, false); assert!(editor_area.contains(cursor), "{side:?}: {cursor:?}"); let buffer = terminal.backend().buffer(); if width == 90 { let mut yellow = 0; for y in editor_area.y..editor_area.bottom() { for x in editor_area.x..editor_area.right() { yellow += usize::from(buffer[(x, y)].bg == Color::Yellow); } } assert!(yellow > 0); } if let Some(panel) = panel { for y in panel.y..panel.bottom() { for x in panel.x..panel.right() { assert_ne!(buffer[(x, y)].bg, Color::Yellow); } } } assert_eq!(buffer[(0, 0)].symbol(), " "); } editor.handle(Input::Char(' ')); editor.handle(Input::Char('e')); assert!(editor.file_picker().unwrap().focused); } } } #[test] fn pending_leader_is_visible_before_long_paths_and_unknown_keys_do_not_edit() { let directory = Directory::new(); let path = directory.file(&"long-path-".repeat(12), "keep"); let mut editor = Editor::open(Some(path), vec![]).unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 6)).unwrap(); let (mut top, mut left) = (0, 0); editor.handle(Input::Char(' ')); editor.handle(Input::Char('b')); terminal .draw(|frame| draw(frame, &editor, &mut top, &mut left)) .unwrap(); assert!(screen_text(&terminal).contains("leader: Space b")); editor.handle(Input::Char('x')); terminal .draw(|frame| draw(frame, &editor, &mut top, &mut left)) .unwrap(); assert!(!screen_text(&terminal).contains("leader:")); assert_eq!(editor.lines(), &["keep"]); assert_eq!(editor.cursor(), crate::editor::Cursor::default()); assert_eq!(editor.mode(), Mode::Normal); } #[test] fn palette_advertises_effective_leader_sequences_after_remapping() { let mut editor = Editor::open(None, vec![]).unwrap(); editor.set_leader(",").unwrap(); editor.handle(Input::Ctrl('p')); for ch in "bnext".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(); assert!(screen_text(&terminal).contains(", b n")); assert!(!screen_text(&terminal).contains("Space b n")); editor .bind("normal", ",", Some("core.command".into())) .unwrap(); terminal .draw(|frame| draw(frame, &editor, &mut top, &mut left)) .unwrap(); assert!(screen_text(&terminal).contains("unbound")); assert!(!screen_text(&terminal).contains(", b n")); } #[test] fn buffer_switches_restore_independent_vertical_and_horizontal_viewports() { let directory = Directory::new(); let a = directory.file("a", &vec!["a".repeat(50); 20].join("\n")); let b = directory.file("b", &vec!["b".repeat(40); 20].join("\n")); let mut editor = Editor::open(Some(a), vec![]).unwrap(); let a_id = editor.buffer_id(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(16, 6)).unwrap(); let mut viewports = BufferViewports::default(); for _ in 0..14 { editor.handle(Input::Down); } editor.handle(Input::End); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert_eq!(viewports.offsets[&a_id], (11, 37)); for _ in 0..2 { editor.handle(Input::Up); } for _ in 0..3 { editor.handle(Input::Left); } terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); let a_viewport = viewports.offsets[&a_id]; let a_cursor = terminal.get_cursor_position().unwrap(); assert_eq!(a_viewport, (11, 37)); editor.execute(&format!("edit {}", b.display())); let b_id = editor.buffer_id(); assert_ne!(a_id, b_id); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert_eq!(viewports.offsets[&b_id], (0, 0)); for _ in 0..8 { editor.handle(Input::Down); } editor.handle(Input::End); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); editor.handle(Input::Up); editor.handle(Input::Left); editor.handle(Input::Left); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); let b_viewport = viewports.offsets[&b_id]; let b_cursor = terminal.get_cursor_position().unwrap(); assert_eq!(b_viewport, (5, 27)); editor.execute(&format!("buffer {a_id}")); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert_eq!(viewports.offsets[&a_id], a_viewport); assert_eq!(terminal.get_cursor_position().unwrap(), a_cursor); assert_eq!( terminal.backend().buffer()[(a_cursor.x, a_cursor.y)].symbol(), "a" ); editor.execute("bnext"); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert_eq!(editor.buffer_id(), b_id); assert_eq!(viewports.offsets[&b_id], b_viewport); assert_eq!(terminal.get_cursor_position().unwrap(), b_cursor); } #[test] fn closing_inactive_buffer_retires_its_viewport_without_moving_active_buffer() { let directory = Directory::new(); let a = directory.file("a", &vec!["a".repeat(50); 20].join("\n")); let b = directory.file("b", &vec!["b".repeat(40); 20].join("\n")); let c = directory.file("c", "new buffer"); let mut editor = Editor::open(Some(a), vec![]).unwrap(); let a_id = editor.buffer_id(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(16, 6)).unwrap(); let mut viewports = BufferViewports::default(); for _ in 0..14 { editor.handle(Input::Down); } editor.handle(Input::End); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); editor.execute(&format!("edit {}", b.display())); let b_id = editor.buffer_id(); for _ in 0..8 { editor.handle(Input::Down); } editor.handle(Input::End); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); let b_viewport = viewports.offsets[&b_id]; let b_cursor = terminal.get_cursor_position().unwrap(); assert_eq!(viewports.offsets.len(), 2); editor.execute(&format!("bdelete {a_id}")); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert_eq!(editor.buffer_id(), b_id); assert!(!editor.has_buffer(a_id)); assert!(!viewports.offsets.contains_key(&a_id)); assert_eq!(viewports.offsets.len(), 1); assert_eq!(viewports.offsets[&b_id], b_viewport); assert_eq!(terminal.get_cursor_position().unwrap(), b_cursor); editor.execute(&format!("edit {}", c.display())); let c_id = editor.buffer_id(); assert!(c_id > b_id); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert_eq!(viewports.offsets[&c_id], (0, 0)); assert!(!viewports.offsets.contains_key(&a_id)); } #[test] fn restoring_buffer_after_narrow_resize_clamps_caret_inside_text_region() { let directory = Directory::new(); let a = directory.file("a", &vec!["a".repeat(50); 20].join("\n")); let b = directory.file("b", "other"); let mut editor = Editor::open(Some(a), vec![]).unwrap(); let a_id = editor.buffer_id(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 8)).unwrap(); let mut viewports = BufferViewports::default(); for _ in 0..14 { editor.handle(Input::Down); } editor.handle(Input::End); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert_eq!(viewports.offsets[&a_id], (9, 29)); editor.execute(&format!("edit {}", b.display())); let b_id = editor.buffer_id(); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); terminal.backend_mut().resize(4, 4); terminal .resize(ratatui::layout::Rect::new(0, 0, 4, 4)) .unwrap(); editor.execute(&format!("buffer {a_id}")); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert_eq!(viewports.offsets[&a_id], (13, 49)); assert_eq!(viewports.offsets[&b_id], (0, 0)); assert_eq!( terminal.get_cursor_position().unwrap(), ratatui::layout::Position::new(3, 1) ); for (width, height) in [(1, 1), (24, 8)] { let area = ratatui::layout::Rect::new(0, 0, width, height); terminal.backend_mut().resize(width, height); terminal.resize(area).unwrap(); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); let cursor = terminal.get_cursor_position().unwrap(); assert!(area.contains(cursor), "{area:?}: {cursor:?}"); assert_eq!( terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), "a" ); assert_eq!(viewports.offsets[&b_id], (0, 0)); } } #[test] fn status_identifies_active_buffer_and_restored_visual_selection_count() { let directory = Directory::new(); let a = directory.file("a", "word\nword\nword"); let b = directory.file("b", "other"); let mut editor = Editor::open(Some(a), vec![]).unwrap(); let a_id = editor.buffer_id(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(240, 6)).unwrap(); let mut viewports = BufferViewports::default(); editor.handle(Input::Char('v')); editor.handle(Input::Ctrl('a')); assert_eq!(editor.selection_count(), 3); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert!(!screen_text(&terminal).contains("[buffer")); editor.execute(&format!("edit {}", b.display())); let b_id = editor.buffer_id(); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); let text = screen_text(&terminal); assert!( text.contains(&format!("NORMAL [buffer {b_id}/2]")), "{text}" ); assert!(!text.contains("3 selections"), "{text}"); editor.execute(&format!("buffer {a_id}")); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); let text = screen_text(&terminal); assert!( text.contains(&format!("VISUAL [buffer {a_id}/2]")), "{text}" ); assert!(text.contains("3 selections"), "{text}"); let (top, left) = viewports.offsets[&a_id]; for selection in editor.selections() { let x = 2 + (selection.start.column - left) as u16; let y = (selection.start.row - top) as u16; assert!(matches!( terminal.backend().buffer()[(x, y)].bg, Color::Blue | Color::Magenta )); } } #[test] fn pending_motion_count_is_visible_and_escape_cancels_without_moving() { let mut editor = Editor::open(None, vec![]).unwrap(); editor .apply(vec![crate::editor::Effect::Replace(["row"; 12].join("\n"))]) .unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 6)).unwrap(); let (mut top, mut left) = (0, 0); editor.handle(Input::Char('1')); editor.handle(Input::Char('0')); terminal .draw(|frame| draw(frame, &editor, &mut top, &mut left)) .unwrap(); assert!(screen_text(&terminal).contains("count: 10")); editor.handle(Input::Escape); editor.handle(Input::Down); terminal .draw(|frame| draw(frame, &editor, &mut top, &mut left)) .unwrap(); assert!(!screen_text(&terminal).contains("count:")); assert_eq!( terminal.get_cursor_position().unwrap(), ratatui::layout::Position::new(3, 1) ); } #[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, " 2 "), (3, " 1 "), (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) ); editor.handle(Input::Up); editor.handle(Input::Up); terminal .draw(|frame| draw(frame, &editor, &mut top, &mut left)) .unwrap(); let buffer = terminal.backend().buffer(); for (y, expected) in [(2, "10 "), (3, " 1 "), (4, " 2 ")] { let number: String = (3..6).map(|x| buffer[(x, y)].symbol()).collect(); assert_eq!(number, expected); } editor.handle(Input::Ctrl('e')); assert_eq!(editor.selection_count(), 2); terminal .draw(|frame| draw(frame, &editor, &mut top, &mut left)) .unwrap(); let buffer = terminal.backend().buffer(); for (y, expected) in [(2, " 1 "), (3, "11 "), (4, " 1 ")] { let number: String = (3..6).map(|x| buffer[(x, y)].symbol()).collect(); assert_eq!(number, expected); } } #[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] 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::Ctrl('j')); } 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 editor = Editor::open(None, vec![]).unwrap(); 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, editor.selection_navigation_hint(), ) }) .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, editor.selection_navigation_hint(), ) }) .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[(2, 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(2, (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 = 2 + 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( 2 + 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(12, 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[(2 + x, 0)].bg, Color::Blue); } assert_eq!(buffer[(6, 0)].symbol(), "界"); assert_eq!(buffer[(8, 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[(10, 0)].symbol(), " "); assert_eq!(buffer[(10, 0)].bg, Color::Blue); assert_eq!(buffer[(2, 1)].bg, Color::Blue); assert_eq!(buffer[(2, 2)].bg, Color::Blue); assert_ne!(buffer[(3, 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(7, 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[(2, 0)].symbol(), "界"); assert_eq!(buffer[(4, 0)].symbol(), "e\u{301}"); assert_eq!(buffer[(6, 0)].symbol(), " "); for x in [0, 2, 3, 4] { assert_eq!(buffer[(2 + x, 0)].bg, Color::Blue); } assert_ne!(buffer[(2, 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[(2, 0)].bg, Color::Yellow); assert_eq!(buffer[(4, 0)].bg, Color::Yellow); assert_ne!(buffer[(5, 0)].bg, Color::Blue); assert_ne!(buffer[(6, 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(7, 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[(2, 0)].symbol(), "界"); assert_eq!(buffer[(4, 0)].symbol(), "e\u{301}"); for x in [0, 2] { assert_eq!(buffer[(2 + x, 0)].bg, Color::Yellow); } for x in 3..5 { assert_ne!(buffer[(2 + 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[(1, 4)].symbol(), "界"); assert_eq!(buffer[(3, 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() { use std::io::Write; const CHILD: &str = "VAMP_TERMINAL_HOOK_PROBE"; if let Ok(mode) = std::env::var(CHILD) { std::panic::set_hook(Box::new(|info| { assert!(!crossterm::terminal::is_raw_mode_enabled().unwrap()); println!("CALLER_HOOK"); eprintln!("{info}"); })); if mode == "entry" { let mut editor = EditorSession::open(None, vec![], None).unwrap(); assert!(run(&mut editor).is_err()); } else { let result = panic::catch_unwind(AssertUnwindSafe(|| { 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), "keys" => { let mut editor = Editor::open(None, vec![]).unwrap(); editor.handle(Input::Ctrl('p')); let original = editor.lines().to_vec(); println!("READING_KEYS"); io::stdout().flush()?; for (index, expected) in [ Input::Ctrl('j'), Input::Ctrl('k'), Input::Ctrl('j'), Input::Ctrl('k'), Input::Enter, Input::Enter, ] .into_iter() .enumerate() { let Event::Key(key) = event::read()? else { panic!("expected a keyboard event"); }; let input = translate(key).unwrap(); assert_eq!(input, expected); if index == 4 { for ch in "q!".chars() { editor.handle(Input::Char(ch)); } } editor.handle(input); match index { 0 | 2 => assert_eq!(editor.palette().unwrap().selected, 1), 1 | 3 => assert_eq!(editor.palette().unwrap().selected, 0), 4 => { assert!(editor.palette().is_none()); assert_eq!(editor.mode(), Mode::Command); assert_eq!(editor.command_line(), "q! "); assert!(!editor.should_quit()); } 5 => assert!(editor.should_quit()), _ => unreachable!(), } assert_eq!(editor.lines(), original); } Ok(()) } _ => unreachable!(), } }) })); match mode.as_str() { "normal" | "keys" => result.unwrap().unwrap(), "error" => assert_eq!( result.unwrap().unwrap_err().kind(), io::ErrorKind::BrokenPipe ), "panic" => assert_eq!(result.unwrap_err().downcast_ref::(), 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, mode: &str| { assert!(output.status.success(), "{mode}: {output:?}"); let stdout = String::from_utf8(output.stdout).unwrap(); let (before, after) = stdout.split_once("AFTER_TERMINAL_RETURN").unwrap(); let entered = usize::from(mode != "entry"); assert_eq!( before.matches("\x1b[?1049h").count(), entered, "{mode}: {before:?}" ); assert_eq!( before.matches("\x1b[?1049l").count(), entered, "{mode}: {before:?}" ); if mode == "panic" { assert!( before.find("\x1b[?1049l").unwrap() < before.find("CALLER_HOOK").unwrap(), "{before:?}", ); } assert!(after.contains("CALLER_HOOK"), "{after:?}"); assert!( !after.contains('\u{1b}'), "terminal hook outlived its session: {after:?}" ); }; check(command.output().unwrap(), "entry"); // 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", "keys"] { let output = Command::new(std::env::var_os("VAMP_RUBY").unwrap_or_else(|| "ruby".into())) .args([ "-rpty", "-rtimeout", "-e", r#" PTY.spawn(*ARGV) do |output, input, pid| sent_keys = false captured = +"" begin Timeout.timeout(10) do loop do chunk = output.readpartial(4096) STDOUT.write(chunk) captured << chunk if !sent_keys && captured.include?("READING_KEYS") input.write("\e[106;5u\e[107;5u\n\v\e[13u\r") input.flush sent_keys = true end end end rescue EOFError, Errno::EIO rescue Timeout::Error Process.kill("TERM", pid) Process.wait(pid) abort "terminal lifecycle probe timed out" ensure input.close 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, mode); } } }