use std::{ borrow::Cow, collections::BTreeMap, fmt::Write as _, io::{self, Stdout}, panic::{self, AssertUnwindSafe, PanicHookInfo}, sync::{Arc, Mutex}, time::Duration, }; use crossterm::{ cursor::Show, event::{ self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind, }, execute, terminal::{ BeginSynchronizedUpdate, EndSynchronizedUpdate, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, }, }; use ratatui::{ Frame, Terminal, backend::CrosstermBackend, layout::{Constraint, Layout, Position, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, Paragraph}, }; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use crate::{ diff::{ChangedFile, DiffState, DisplayRow, FileStatus, Focus, Phase, RowKind}, editor::{ Completion, Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic, }, files::{DockSide, FilePicker, FilePresentation, FileView}, plugin::{ Diagnostic, DiagnosticSeverity, SemanticToken, SemanticTokenKind, SemanticTokenModifier, }, session::EditorSession, syntax::{SyntaxCache, SyntaxKind, SyntaxSpan}, }; pub const CAPABILITIES: &[&str] = &["text", "styled_text", "diagnostics"]; 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(), EndSynchronizedUpdate, DisableBracketedPaste, DisableMouseCapture, 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, EnableMouseCapture )?; 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 DocumentViewport { geometry: Option<(usize, Rect)>, manual: Option, } struct ManualViewport { buffer_id: usize, revision: u64, caret_reveal_revision: u64, mode: Mode, cursor: Cursor, carets: Vec, selections: Vec, } #[derive(Default)] struct BufferViewports { offsets: BTreeMap, syntax: BTreeMap, document: DocumentViewport, panels: WheelPanels, } impl BufferViewports { fn draw(&mut self, frame: &mut Frame, editor: &Editor) { self.offsets.retain(|id, _| editor.has_buffer(*id)); self.syntax.retain(|id, _| editor.has_buffer(*id)); let (top, left) = self.offsets.entry(editor.buffer_id()).or_default(); let syntax = self.syntax.entry(editor.buffer_id()).or_default(); draw( frame, editor, top, left, syntax, &mut self.document, &mut self.panels, ); } fn scroll(&mut self, session: &mut EditorSession, mouse: MouseEvent) -> bool { if !matches!( mouse.kind, MouseEventKind::ScrollUp | MouseEventKind::ScrollDown ) { return false; } if self.panels.scroll(session, mouse) { return true; } let editor = session.editor(); if editor.palette().is_some() || editor.startup_diagnostic().is_some() { return true; } let Some((buffer_id, viewport)) = self.document.geometry else { return false; }; if buffer_id != editor.buffer_id() || viewport.width == 0 || viewport.height == 0 || !viewport.contains((mouse.column, mouse.row).into()) { return false; } let (top, _) = self.offsets.entry(buffer_id).or_default(); let last_page = editor .lines() .len() .saturating_sub(usize::from(viewport.height)); *top = match mouse.kind { MouseEventKind::ScrollUp => top.saturating_sub(3), MouseEventKind::ScrollDown => top.saturating_add(3), _ => unreachable!(), } .min(last_page); if self.document.manual.is_none() { self.document.manual = Some(ManualViewport { buffer_id, revision: editor.text_revision(), caret_reveal_revision: editor.caret_reveal_revision(), mode: editor.mode(), cursor: editor.cursor(), carets: editor.carets(), selections: editor.selections(), }); } true } fn scroll_ready( &mut self, session: &mut EditorSession, mut mouse: MouseEvent, mut next: impl FnMut() -> io::Result>, ) -> io::Result<(bool, Option)> { let mut redraw = false; // Bound input work so drawing and background replies cannot starve. for index in 0..64 { redraw |= self.scroll(session, mouse); if index == 63 { break; } match next()? { Some(Event::Mouse(report)) => mouse = report, pending => return Ok((redraw, pending)), } } Ok((redraw, None)) } } fn draw_frame( terminal: &mut Terminal>>, output: &mut W, render: impl FnOnce(&mut Frame), ) -> io::Result<()> { // Do not suspend the terminal's presentation while computing the next frame. terminal.backend_mut().writer_mut().clear(); crossterm::queue!(terminal.backend_mut(), BeginSynchronizedUpdate)?; // Terminals without synchronized updates still hide the painting cursor. let result = terminal .hide_cursor() .and_then(|()| terminal.draw(render).map(|_| ())); let finish = execute!(terminal.backend_mut(), EndSynchronizedUpdate); result.and(finish)?; output.write_all(terminal.backend().writer())?; output.flush() } /// 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 output: Stdout = io::stdout(); let mut terminal = Terminal::new(CrosstermBackend::new(Vec::new()))?; let mut viewports = BufferViewports::default(); let mut redraw = true; let mut pending = None; while !session.editor().should_quit() { redraw |= session.poll(); if redraw { draw_frame(&mut terminal, &mut output, |frame| { viewports.draw(frame, session.editor()) })?; redraw = false; } let input = if let Some(input) = pending.take() { input } else { if !event::poll(Duration::from_millis(25))? { continue; } event::read()? }; match input { Event::Key(key) if key.kind != KeyEventKind::Release => { if let Some(input) = translate(key) { viewports.panels.follow_files(); session.handle(input); redraw = true; } } Event::Paste(text) => { viewports.panels.follow_files(); session.handle(Input::Paste(text)); redraw = true; } Event::Mouse(mouse) => { let (changed, following) = viewports.scroll_ready(session, mouse, || { if event::poll(Duration::ZERO)? { event::read().map(Some) } else { Ok(None) } })?; redraw |= changed; pending = following; } Event::Resize(_, _) => redraw = true, _ => {} } } 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'), KeyCode::BackTab => Input::BackTab, _ => 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 diagnostic_severity(severity: DiagnosticSeverity) -> (usize, Color, &'static str) { match severity { DiagnosticSeverity::Error => (0, Color::Red, "E"), DiagnosticSeverity::Warning => (1, Color::Yellow, "W"), DiagnosticSeverity::Information => (2, Color::Cyan, "I"), DiagnosticSeverity::Hint => (3, Color::DarkGray, "H"), } } fn diagnostic_on_row(diagnostic: &Diagnostic, row: usize) -> bool { diagnostic.start.row <= row && row <= diagnostic.end.row && (row < diagnostic.end.row || diagnostic.end.column > 0 || diagnostic.start == diagnostic.end) } fn draw( frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize, syntax: &mut SyntaxCache, document: &mut DocumentViewport, panels: &mut WheelPanels, ) { panels.begin_frame(); document.geometry = None; let area = frame.area(); if let Some(diff) = editor.diff() { draw_diff( frame, diff, area, editor.selection_navigation_hint(), editor.file_navigation_hint(), panels, ); return; } panels.diff_syntax = None; syntax.update(editor.lines(), editor.path(), editor.text_revision()); 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(); let mut document_caret = None; let carets = editor.carets(); let selections = editor.selections(); if document.manual.as_ref().is_some_and(|manual| { manual.buffer_id != editor.buffer_id() || manual.revision != editor.text_revision() || manual.caret_reveal_revision != editor.caret_reveal_revision() || manual.mode != editor.mode() || manual.cursor != cursor || manual.carets != carets || manual.selections != selections }) { document.manual = None; } document.geometry = Some(( editor.buffer_id(), Rect::new( gutter.x, viewport.y, gutter.width + viewport.width, viewport.height, ), )); *top = (*top).min(editor.lines().len().saturating_sub(height)); if height > 0 && width > 0 { if document.manual.is_none() { 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 style = match editor .diagnostics() .filter(|diagnostic| diagnostic_on_row(diagnostic, row)) .map(|diagnostic| diagnostic_severity(diagnostic.severity)) .min_by_key(|(severity, _, _)| *severity) { Some((_, color, _)) => style.fg(color), None => style, }; 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); let buffer = frame.buffer_mut(); let semantic = editor.semantic_tokens(); let mut semantic_index = semantic.partition_point(|token| token.start.row < *top); for (row, line) in editor.lines().iter().enumerate().skip(*top).take(height) { let spans = syntax.line(row); while semantic_index < semantic.len() && semantic[semantic_index].start.row < row { semantic_index += 1; } if spans.is_empty() && semantic .get(semantic_index) .is_none_or(|token| token.start.row != row) { continue; } let mut span_index = 0; let mut token_style = None; let mut column = 0; let right = left.saturating_add(width); for (index, (byte, grapheme)) in line.grapheme_indices(true).enumerate() { if column >= right { break; } let cells = if grapheme == "\t" { TAB_WIDTH - column % TAB_WIDTH } else { UnicodeWidthStr::width(grapheme) }; let end = column + cells; while span_index < spans.len() && spans[span_index].end_byte <= byte { span_index += 1; } while semantic_index < semantic.len() && semantic[semantic_index].start.row == row && semantic[semantic_index].end.column <= index { semantic_index += 1; token_style = None; } let style = semantic .get(semantic_index) .filter(|token| token.start.row == row && token.start.column <= index) .map(|token| *token_style.get_or_insert_with(|| semantic_style(token))) .or_else(|| { spans .get(span_index) .filter(|span| span.start_byte <= byte) .map(|span| syntax_style(span.kind)) }); if let Some(style) = style && end > *left && (grapheme == "\t" || (column >= *left && end <= right)) { let y = viewport.y + (row - *top) as u16; for cell in column.max(*left)..end.min(right) { buffer[(viewport.x + (cell - *left) as u16, y)].set_style(style); } } column = end; } } for severity in [ DiagnosticSeverity::Hint, DiagnosticSeverity::Information, DiagnosticSeverity::Warning, DiagnosticSeverity::Error, ] { let style = Style::default() .underline_color(diagnostic_severity(severity).1) .add_modifier(Modifier::UNDERLINED); for diagnostic in editor .diagnostics() .filter(|item| item.severity == severity) { let first = diagnostic.start.row.max(*top); let last = diagnostic.end.row.min(top.saturating_add(height - 1)); for row in first..=last { if !diagnostic_on_row(diagnostic, row) { continue; } let start = if row == diagnostic.start.row { diagnostic.start.column } else { 0 }; let end = if row == diagnostic.end.row { diagnostic .end .column .saturating_add(usize::from(diagnostic.start == diagnostic.end)) } else { usize::MAX }; let line = &editor.lines()[row]; let y = viewport.y + (row - *top) as u16; let right = left.saturating_add(width); let mut column = 0; let mut grapheme_count = 0; for (index, grapheme) in line.graphemes(true).enumerate() { let cells = if grapheme == "\t" { TAB_WIDTH - column % TAB_WIDTH } else { UnicodeWidthStr::width(grapheme) }; let next = column + cells; if index >= start && index < end && (grapheme == "\t" || (column >= *left && next <= right)) { for cell in column.max(*left)..next.min(right) { frame.buffer_mut()[(viewport.x + (cell - *left) as u16, y)] .set_style(style); } } column = next; grapheme_count = index + 1; if column >= right { break; } } if start <= grapheme_count && end > grapheme_count && column >= *left && column < right { frame.buffer_mut()[(viewport.x + (column - *left) as u16, y)] .set_style(style); } } } } 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); } } let selection_style = Style::default().fg(Color::White).bg(Color::Blue); for rows in editor.linewise_rows() { let first = (*rows.start()).max(*top); let last = (*rows.end()).min(top.saturating_add(height - 1)); for row in first..=last { let end = display_column(&editor.lines()[row], usize::MAX) .saturating_add(1) .min(left.saturating_add(width)); let y = viewport.y + (row - *top) as u16; for column in *left..end { frame.buffer_mut()[(viewport.x + (column - *left) as u16, y)] .set_style(selection_style); } } } for selection in selections .iter() .filter(|_| !editor.visual_linewise()) .chain(editor.snippet_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 carets.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 = if editor.visual_linewise() { " VISUAL LINE".into() } else { format!(" {}", editor.mode()) }; if let Some(sequence) = editor.leader_pending() { write!(status, " leader: {sequence}").unwrap(); } if editor.g_pending() { status.push_str(" g"); } if let Some(operator) = editor.line_operator_pending() { write!(status, " {operator}").unwrap(); } if editor.buffer_count() > 1 { write!( status, " [buffer {}/{}]", editor.buffer_id(), editor.buffer_count() ) .unwrap(); } let mut diagnostic_counts = [0; 4]; for diagnostic in editor.diagnostics() { diagnostic_counts[diagnostic_severity(diagnostic.severity).0] += 1; } for (label, count) in ["E", "W", "I", "H"].into_iter().zip(diagnostic_counts) { if count > 0 { write!(status, " {label}:{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(); } if let Some(error) = syntax.error() { write!(status, " syntax: {error}").unwrap(); } else if let Some(language) = syntax.language() { write!(status, " {language}").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, ); if !editor.file_picker().is_some_and(|picker| picker.focused) { frame.set_cursor_position(( prompt_area.x + (command_width - offset) as u16, prompt_area.y, )); } } else { let message = if let Some(diagnostic) = editor.diagnostic_at_cursor() { let (_, _, label) = diagnostic_severity(diagnostic.severity); let diagnostic = display_text(&diagnostic.message); Cow::Owned(if editor.message().is_empty() { format!("{label}: {diagnostic}") } else { format!("{} | {label}: {diagnostic}", editor.message()) }) } else { Cow::Borrowed(editor.message()) }; frame.render_widget(Paragraph::new(message), prompt_area); if height > 0 && width > 0 && !editor.file_picker().is_some_and(|picker| picker.focused) { let column = display_column(&editor.lines()[cursor.row], cursor.column); if cursor.row >= *top && cursor.row < top.saturating_add(height) && column >= *left && column < left.saturating_add(width) { let position = Position::new( viewport.x + (column - *left) as u16, viewport.y + (cursor.row - *top) as u16, ); frame.set_cursor_position(position); document_caret = Some(position); } } } if let Some(completion) = editor.completion() && let Some(caret) = document_caret { panels.completion = draw_completion(frame, completion, viewport, caret); } if let Some(picker) = editor.file_picker() { if picker.presentation == FilePresentation::Floating { let panel = floating_panel(area); frame.render_widget(Clear, panel); draw_file_list( frame, picker, panel, editor.file_navigation_hint(), panels, None, ); } else if let Some(panel) = dock { draw_file_list( frame, picker, panel, editor.file_navigation_hint(), panels, None, ); } } if let Some(palette) = editor.palette() { draw_palette( frame, palette, editor.mode(), editor.selection_navigation_hint(), ); } if let Some(hover) = editor.hover() { draw_hover(frame, hover, viewport, panels); } if let Some(diagnostic) = editor.startup_diagnostic() { draw_startup_diagnostic(frame, diagnostic, area); } } struct DiffGeometry { regions: [(Rect, Focus); 3], } fn diff_panel(frame: &mut Frame, area: Rect, title: &str, focused: bool) -> Rect { if area.width < 4 || area.height < 4 { return area; } let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(if focused { Color::Cyan } else { Color::DarkGray })) .title(title); let inner = block.inner(area); frame.render_widget(block, area); inner } fn draw_diff( frame: &mut Frame, diff: &DiffState, area: Rect, navigation_hint: &str, file_navigation_hint: &str, panels: &mut WheelPanels, ) { let comparison = diff.comparison.as_ref(); let selected = comparison.and_then(|comparison| comparison.files.get(diff.file)); if diff.phase == Phase::View && let Some(id) = diff.comparison_id && let Some(file) = selected { panels .diff_syntax .get_or_insert_with(DiffSyntaxCache::default) .update(id, diff.file, file); } else { panels.diff_syntax = None; } if area.width == 0 || area.height == 0 { return; } frame.render_widget(Clear, area); if diff.phase != Phase::View { let panel = floating_panel(area); let stage = if diff.phase == Phase::Before { "Before" } else { "After" }; let title = format!(" Diff · Choose {stage} "); let content = diff_panel(frame, panel, &title, true); let notice = diff.error.as_deref().or_else(|| { if diff.loading { Some(if diff.after.is_some() { "Loading comparison…" } else { "Loading repository…" }) } else { diff.catalog .as_ref() .and_then(|catalog| catalog.notice.as_deref()) } }); let [ stage_area, tabs_area, query_area, notice_area, pinned_area, results, help, ] = Layout::vertical([ Constraint::Length(u16::from(content.height >= 5)), Constraint::Length(u16::from(content.height >= 4)), Constraint::Length(1), Constraint::Length(u16::from(notice.is_some() && content.height >= 3)), Constraint::Length(u16::from(content.height >= 2)), Constraint::Fill(1), Constraint::Length(u16::from(content.height >= 7)), ]) .areas(content); let before = diff .before .as_ref() .map_or("not selected", |revision| revision.label.as_str()); frame.render_widget( Paragraph::new(format!("Choose {stage} · Before: {}", display_text(before))), stage_area, ); if let Some(catalog) = &diff.catalog { let tabs: Vec = catalog .kind .categories() .iter() .enumerate() .map(|(index, category)| { Span::styled( format!(" {} ", category.label()), if index == diff.category { Style::default() .fg(Color::Black) .bg(Color::Cyan) .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) }, ) }) .collect(); frame.render_widget(Paragraph::new(Line::from(tabs)), tabs_area); } let query = format!("> {}", display_text(&diff.query)); let width = usize::from(query_area.width); let query_width = display_column(&query, usize::MAX); let offset = query_width.saturating_sub(width.saturating_sub(1)); frame.render_widget( Paragraph::new(visible_line(&query, offset, width)) .style(Style::default().fg(Color::Yellow)), query_area, ); if query_area.width > 0 && query_area.height > 0 { frame.set_cursor_position((query_area.x + (query_width - offset) as u16, query_area.y)); } if let Some(notice) = notice { frame.render_widget( Paragraph::new(display_text(notice)).style(Style::default().fg( if diff.error.is_some() { Color::Red } else { Color::Yellow }, )), notice_area, ); } let mut entries = diff.entries(); if let Some(working) = entries.next() { let label = format!( "{} {} · pinned", if diff.selected == 0 { ">" } else { " " }, display_text(&working.label) ); frame.render_widget( Paragraph::new(visible_line(&label, 0, usize::from(pinned_area.width))).style( if diff.selected == 0 { Style::default() .bg(Color::Blue) .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::Cyan) }, ), pinned_area, ); } let row_height = if results.width >= 50 && results.height >= 4 { 2 } else { 1 }; let count = usize::from(results.height) / row_height; let first = diff.selected.saturating_sub(count.saturating_sub(1)).max(1); let mut lines = Vec::with_capacity(count * row_height); for (index, revision) in entries .enumerate() .map(|(index, revision)| (index + 1, revision)) .skip(first - 1) .take(count) { let style = if index == diff.selected { Style::default() .bg(Color::Blue) .add_modifier(Modifier::BOLD) } else { Style::default() }; let label = format!( "{} {}", if index == diff.selected { ">" } else { " " }, display_text(&revision.label) ); let width = usize::from(results.width); if row_height == 2 { lines.push(Line::styled(visible_line(&label, 0, width), style)); lines.push(Line::styled( visible_line( &format!(" {}", display_text(&revision.description)), 0, width, ), style.fg(Color::DarkGray), )); } else { lines.push(Line::styled( visible_line( &format!("{label} · {}", display_text(&revision.description)), 0, width, ), style, )); } } if lines.is_empty() && !diff.loading { frame.render_widget(Paragraph::new("No matching revisions"), results); } else { frame.render_widget(Paragraph::new(lines), results); } frame.render_widget( Paragraph::new(format!( "{navigation_hint} · Tab categories · type search · Enter select · Esc back/cancel" )) .style(Style::default().fg(Color::DarkGray)), help, ); panels.diff = Some(DiffGeometry { regions: [ (panel, Focus::Files), (Rect::default(), Focus::Before), (Rect::default(), Focus::After), ], }); return; } let syntax_error = panels .diff_syntax .as_ref() .and_then(|syntax| syntax.before.error().or_else(|| syntax.after.error())); let notice = diff.error.as_deref().or_else(|| { if diff.loading { Some("Loading comparison…") } else { syntax_error.or_else(|| selected.and_then(|file| file.notice.as_deref())) } }); let [header, notice_area, workspace, help] = Layout::vertical([ Constraint::Length(1), Constraint::Length(u16::from(notice.is_some() && area.height >= 3)), Constraint::Fill(1), Constraint::Length(u16::from(area.height >= 3)), ]) .areas(area); let (before, after) = comparison.map_or(("", ""), |comparison| { (comparison.before.as_str(), comparison.after.as_str()) }); frame.render_widget( Paragraph::new(format!( "Diff · {} → {} · {:?} focus · read-only", display_text(before), display_text(after), diff.focus )) .style(Style::default().fg(Color::Black).bg(Color::Cyan)), header, ); if let Some(notice) = notice { frame.render_widget( Paragraph::new(display_text(notice)).style(Style::default().fg( if diff.error.is_some() || syntax_error.is_some() { Color::Red } else { Color::Yellow }, )), notice_area, ); } let (panes, navigator) = if diff.files_visible && diff.files.is_some() { dock_layout(workspace, DockSide::Right, diff.focus == Focus::Files) } else { (workspace, None) }; let [before_area, after_area] = Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).areas(panes); if let Some(picker) = &diff.files && let Some(navigator) = navigator { draw_file_list( frame, picker, navigator, file_navigation_hint, panels, comparison.map(|comparison| comparison.files.as_slice()), ); } if let Some(file) = selected && let Some(syntax) = &panels.diff_syntax { draw_diff_pane(frame, diff, file, before_area, true, &syntax.before); draw_diff_pane(frame, diff, file, after_area, false, &syntax.after); } else { frame.render_widget( Paragraph::new(if diff.loading { "Loading comparison…" } else { "No changed files" }), panes, ); } frame.render_widget( Paragraph::new( "^E explorer · Tab focus · i filter · Enter open/toggle · ↑/↓ scroll/tree · [/] expand above/below · ←/→ horizontal · Esc close", ) .style(Style::default().fg(Color::DarkGray)), help, ); panels.diff = Some(DiffGeometry { regions: [ (navigator.unwrap_or_default(), Focus::Files), (before_area, Focus::Before), (after_area, Focus::After), ], }); } fn diff_status(status: FileStatus) -> &'static str { match status { FileStatus::Added => "A", FileStatus::Removed => "D", FileStatus::Modified => "M", FileStatus::Renamed => "R", } } fn draw_diff_pane( frame: &mut Frame, diff: &DiffState, file: &ChangedFile, area: Rect, before: bool, syntax: &SyntaxCache, ) { if area.width == 0 || area.height == 0 { return; } let label = if before { "Before" } else { "After" }; let path = if before { file.old_path.as_ref().unwrap_or(&file.path) } else { &file.path }; let title = format!( " {label} · {} · {} ", diff_status(file.status), display_text(&path.to_string_lossy()) ); let focus = if before { Focus::Before } else { Focus::After }; let content = diff_panel(frame, area, &title, diff.focus == focus); if diff.display_rows.is_empty() { frame.render_widget( Paragraph::new(file.notice.as_deref().map(display_text).unwrap_or_else(|| { if diff.rows.is_empty() { "Empty file" } else { "No text changes" } .into() })), content, ); return; } let source = if before { &file.before } else { &file.after }; let left = if before { diff.left_before } else { diff.left_after }; let digits = source.len().max(1).ilog10() as usize + 1; let gutter = if usize::from(content.width) > digits + 2 { digits + 2 } else { 0 }; let width = usize::from(content.width).saturating_sub(gutter); for (display_row, row) in diff .display_rows .iter() .enumerate() .skip(diff.top) .take(usize::from(content.height)) { let row_area = Rect::new( content.x, content.y + (display_row - diff.top) as u16, content.width, 1, ); let aligned_row = match row { DisplayRow::Line(index) => *index, DisplayRow::Fold { start, end } => { frame.render_widget( Paragraph::new(format!( "… {} unchanged lines hidden · [/] expand context", end - start )) .style(Style::default().fg(Color::DarkGray)), row_area, ); continue; } }; let row = &diff.rows[aligned_row]; let index = if before { row.before } else { row.after }; let Some(index) = index else { let style = Style::default().fg(Color::Rgb(72, 72, 72)).bg(Color::Reset); let buffer = frame.buffer_mut(); for x in 0..content.width { buffer[(content.x + x, row_area.y)] .set_symbol("╱") .set_style(style); } continue; }; let Some(source_text) = source.get(index) else { continue; }; let changed = matches!(row.kind, RowKind::Changed) || (before && row.kind == RowKind::Removed) || (!before && row.kind == RowKind::Added); let background = if changed { if before { Color::Rgb(64, 24, 24) } else { Color::Rgb(24, 48, 32) } } else { Color::Reset }; let text = if source_text.chars().any(|ch| ch.is_control() && ch != '\t') { Cow::Owned( source_text .chars() .map(|ch| { if ch.is_control() && ch != '\t' { '�' } else { ch } }) .collect::(), ) } else { Cow::Borrowed(source_text.as_str()) }; frame.render_widget( Paragraph::new(visible_line(&text, left, width)) .style(Style::default().fg(Color::Reset).bg(background)), Rect::new(content.x + gutter as u16, row_area.y, width as u16, 1), ); if gutter > 0 { let marker = if changed { if before { '-' } else { '+' } } else { ' ' }; let color = if changed { if before { Color::Red } else { Color::Green } } else { Color::DarkGray }; frame.render_widget( Paragraph::new(format!("{:>digits$}{marker} ", index + 1)) .style(Style::default().fg(color).bg(background)), Rect::new(content.x, row_area.y, gutter as u16, 1), ); } paint_syntax_line( frame, source_text, syntax.line(index), Rect::new(content.x + gutter as u16, row_area.y, width as u16, 1), left, ); } } fn floating_panel(area: Rect) -> Rect { 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(24)), Constraint::Fill(1), ]) .areas(column); panel } fn draw_startup_diagnostic(frame: &mut Frame, diagnostic: &StartupDiagnostic, area: Rect) { let panel = floating_panel(area); frame.render_widget(Clear, panel); let bordered = panel.width >= 4 && panel.height >= 4; let block = Block::default() .borders(if bordered { Borders::ALL } else { Borders::NONE }) .border_style(Style::default().fg(Color::Red)) .title(" Configuration error "); let content = if bordered { block.inner(panel) } else { panel }; if bordered { frame.render_widget(block, panel); } let [text_area, help_area] = Layout::vertical([ Constraint::Fill(1), Constraint::Length(u16::from(content.height >= 3)), ]) .areas(content); let lines: Vec = diagnostic .lines .iter() .skip(diagnostic.top) .take(usize::from(text_area.height)) .map(|line| { let line = display_text(line); Line::from(visible_line( &line, display_column(&line, diagnostic.left), usize::from(text_area.width), )) }) .collect(); frame.render_widget(Paragraph::new(lines), text_area); frame.render_widget( Paragraph::new("Arrows scroll · Home reset · Enter/Esc continue") .style(Style::default().fg(Color::DarkGray)), help_area, ); if text_area.width > 0 && text_area.height > 0 { frame.set_cursor_position((text_area.x, text_area.y)); } } #[derive(Default)] struct DiffSyntaxCache { key: Option<(u64, usize)>, revision: u64, before: SyntaxCache, after: SyntaxCache, } impl DiffSyntaxCache { fn update(&mut self, comparison_id: u64, file_index: usize, file: &ChangedFile) { let key = Some((comparison_id, file_index)); if self.key != key { self.key = key; self.revision = self .revision .checked_add(1) .expect("diff syntax revisions exhausted"); } self.before.update( &file.before, Some(file.old_path.as_deref().unwrap_or(&file.path)), self.revision, ); self.after .update(&file.after, Some(&file.path), self.revision); } } #[derive(Default)] struct WheelPanels { completion: Option, hover: Option<(Rect, usize)>, hover_syntax: SyntaxCache, files: Option<(Rect, usize, usize)>, manual_files: Option, diff: Option, diff_syntax: Option, } struct FileWheelOffset { top: usize, root: std::path::PathBuf, query: String, state: (usize, usize, FilePresentation, FileView, bool, bool), } impl FileWheelOffset { fn state(picker: &FilePicker) -> (usize, usize, FilePresentation, FileView, bool, bool) { ( picker.selected, picker.entries().len(), picker.presentation, picker.view, picker.searching, picker.focused, ) } fn matches(&self, picker: &FilePicker) -> bool { self.state == Self::state(picker) && self.root == picker.root && self.query == picker.query } } impl WheelPanels { fn begin_frame(&mut self) { if self.files.is_none() { self.manual_files = None; } self.hover = None; self.completion = None; self.files = None; self.diff = None; } // Keyboard/paste may change the catalog without changing its length. fn follow_files(&mut self) { self.manual_files = None; } fn file_top(&mut self, picker: &FilePicker, panel: Rect, height: usize) -> usize { if self .manual_files .as_ref() .is_some_and(|offset| !offset.matches(picker)) { self.manual_files = None; } let count = picker.entries().len(); let following = picker .selected .min(count.saturating_sub(1)) .saturating_sub(height.saturating_sub(1)); let top = self.manual_files.as_mut().map_or(following, |offset| { offset.top = offset.top.min(count.saturating_sub(height.max(1))); offset.top }); self.files = Some((panel, height, top)); top } fn scroll(&mut self, session: &mut EditorSession, mouse: MouseEvent) -> bool { let down = match mouse.kind { MouseEventKind::ScrollDown => true, MouseEventKind::ScrollUp => false, _ => return false, }; let editor = session.editor(); if editor.diff().is_some() { let position = Position::new(mouse.column, mouse.row); let focus = self.diff.as_ref().and_then(|geometry| { geometry .regions .iter() .find_map(|(area, focus)| area.contains(position).then_some(*focus)) }); if let Some(focus) = focus { if session .editor() .diff() .is_some_and(|diff| diff.phase == Phase::View) { for _ in 0..3 { if session .editor() .diff() .is_some_and(|diff| diff.focus == focus) { break; } session.handle(Input::Char('\t')); } } for _ in 0..3 { session.handle(if down { Input::Down } else { Input::Up }); } } return true; } if editor.palette().is_some() || editor.startup_diagnostic().is_some() { return true; } let position = ratatui::layout::Position::new(mouse.column, mouse.row); if editor.completion().is_some() && self .completion .is_some_and(|panel| panel.contains(position)) { return true; } if let Some(hover) = editor.hover() && let Some((panel, height)) = self.hover && panel.contains(position) { if height > 0 { let top = hover.top; let target = if down { top.saturating_add(3) } else { top.saturating_sub(3) } .min(hover.lines().len().saturating_sub(height)); let input = if target < top { Input::Up } else { Input::Down }; for _ in 0..top.abs_diff(target) { session.handle(input.clone()); } } return true; } if let Some(picker) = editor.file_picker() && let Some((panel, height, drawn_top)) = self.files && panel.contains(position) { if height > 0 { if self .manual_files .as_ref() .is_some_and(|offset| !offset.matches(picker)) { self.manual_files = None; } let offset = self.manual_files.get_or_insert_with(|| FileWheelOffset { top: drawn_top, root: picker.root.clone(), query: picker.query.clone(), state: FileWheelOffset::state(picker), }); offset.top = if down { offset.top.saturating_add(3) } else { offset.top.saturating_sub(3) } .min(picker.entries().len().saturating_sub(height)); } return true; } false } } fn draw_completion( frame: &mut Frame, completion: &Completion, area: Rect, caret: Position, ) -> Option { if area.width == 0 || area.height == 0 || !area.contains(caret) { return None; } let count = completion.items.len().saturating_add(1); let selected = completion.selected.min(count - 1); let desired_height = count.min(8) as u16; let below = area.bottom() - caret.y - 1; let above = caret.y - area.y; let downward = below >= desired_height || below >= above; let height = desired_height.min(if downward { below } else { above }.max(1)); let first = selected.saturating_sub(usize::from(height) - 1); let lines: Vec = (first..count) .take(usize::from(height)) .map(|index| { let label = if index == 0 { "Original text".to_owned() } else { let item = &completion.items[index - 1]; let mut label = display_text(&item.label); if let Some(detail) = &item.detail { label.push_str(" · "); label.push_str(&display_text(detail)); } label }; let style = if index == selected { Style::default().fg(Color::Black).bg(Color::Cyan) } else { Style::default().fg(Color::Gray).bg(Color::Black) }; Line::styled( visible_line(&label, 0, usize::from(area.width.min(60))), style, ) }) .collect(); let width = lines.iter().map(Line::width).max().unwrap_or(1).max(1) as u16; let panel = Rect::new( caret.x.min(area.right() - width), if below == 0 && above == 0 { caret.y } else if downward { caret.y + 1 } else { caret.y - height }, width, height, ); frame.render_widget(Clear, panel); frame.render_widget( Paragraph::new(lines).style(Style::default().fg(Color::Gray).bg(Color::Black)), panel, ); Some(panel) } fn draw_hover(frame: &mut Frame, hover: &Hover, area: Rect, panels: &mut WheelPanels) { 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(hover.lines().len().saturating_add(3).min(24) as u16), ), 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 }) .border_style(Style::default().fg(Color::Cyan)) .title(" Hover "); let content = if bordered { block.inner(panel) } else { panel }; if bordered { frame.render_widget(block, panel); } let [text_area, help_area] = Layout::vertical([ Constraint::Fill(1), Constraint::Length(u16::from(content.height >= 3)), ]) .areas(content); panels.hover = Some((panel, usize::from(text_area.height))); let lines: Vec = hover .lines() .skip(hover.top) .take(usize::from(text_area.height)) .map(|line| { Line::from(visible_line( line, display_column(line, hover.left), usize::from(text_area.width), )) }) .collect(); frame.render_widget(Paragraph::new(lines), text_area); panels .hover_syntax .update_text(hover.text(), std::path::Path::new("hover.md")); let width = usize::from(text_area.width); for (row, line) in hover .lines() .enumerate() .skip(hover.top) .take(usize::from(text_area.height)) { let spans = panels.hover_syntax.line(row); if spans.is_empty() { continue; } let left = display_column(line, hover.left); let right = left.saturating_add(width); let mut column = 0; let mut span_index = 0; for (byte, grapheme) in line.grapheme_indices(true) { if column >= right { break; } let cells = if grapheme == "\t" { TAB_WIDTH - column % TAB_WIDTH } else { UnicodeWidthStr::width(grapheme) }; let end = column + cells; while span_index < spans.len() && spans[span_index].end_byte <= byte { span_index += 1; } if let Some(span) = spans.get(span_index).filter(|span| span.start_byte <= byte) && end > left && (grapheme == "\t" || (column >= left && end <= right)) { let style = syntax_style(span.kind); let y = text_area.y + (row - hover.top) as u16; for cell in column.max(left)..end.min(right) { frame.buffer_mut()[(text_area.x + (cell - left) as u16, y)].set_style(style); } } column = end; } } frame.render_widget( Paragraph::new("Arrows/Ctrl+h/j/k/l scroll · Esc close") .style(Style::default().fg(Color::DarkGray)), help_area, ); } 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, panels: &mut WheelPanels, changed_files: Option<&[ChangedFile]>, ) { 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 browsing = picker.view == FileView::Tree && !picker.searching; let title = match (picker.presentation, picker.view, browsing) { (FilePresentation::Floating, FileView::List, _) => " Files · List ", (FilePresentation::Docked, FileView::List, _) => " Explorer · List ", (FilePresentation::Floating, FileView::Tree, true) => " Files · Tree NORMAL ", (FilePresentation::Docked, FileView::Tree, true) => " Explorer · Tree NORMAL ", (FilePresentation::Floating, FileView::Tree, false) => " Files · Tree INSERT ", (FilePresentation::Docked, FileView::Tree, false) => " Explorer · Tree INSERT ", }; 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 && !picker.root.as_os_str().is_empty(); 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 first = panels.file_top(picker, panel, usize::from(results_area.height)); let query = if browsing && picker.query.is_empty() { "i filter · ^U clear · ^T view".into() } else if browsing { format!("filter: {} · ^U clear", display_text(&picker.query)) } else { format!("> {}", display_text(&picker.query)) }; let query_width = display_column(&query, usize::MAX); let width = usize::from(query_area.width); let offset = if browsing { 0 } else { 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 && (!browsing || picker.entries().len() == 0 || results_area.height == 0) { let cursor_x = if browsing { 0 } else { query_width - offset }; frame.set_cursor_position((query_area.x + cursor_x 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 selected_index = picker.selected.min(entries.len() - 1); let lines: Vec = entries .enumerate() .skip(first) .take(count) .map(|(index, entry)| { let selected = index == selected_index; let mut style = if entry.is_dir { Style::default().fg(Color::Cyan) } else { Style::default() }; if selected { style = style .bg(if picker.focused { Color::Blue } else { Color::DarkGray }) .add_modifier(Modifier::BOLD); } let changed = entry .catalog_index .and_then(|index| changed_files.and_then(|files| files.get(index))); let prefix = format!( "{} {:indent$}", if selected { ">" } else { " " }, "", indent = if picker.view == FileView::Tree { entry.depth * 2 } else { 0 }, ); let mut spans = Vec::with_capacity(if changed.is_some() { 6 } else { 3 }); spans.push(Span::raw(prefix)); if let Some(file) = changed { let color = match file.status { FileStatus::Added => Color::Green, FileStatus::Removed => Color::Red, FileStatus::Modified => Color::Yellow, FileStatus::Renamed => Color::Cyan, }; spans.push(Span::styled( diff_status(file.status), Style::default().fg(color), )); spans.push(Span::raw(" ")); } spans.push(Span::raw(entry.label)); if entry.is_dir && picker.view == FileView::Tree { spans.push(Span::raw("/")); } if let Some(file) = changed && file.status == FileStatus::Renamed && let Some(old_path) = &file.old_path { spans.push(Span::raw(" ← ")); spans.push(Span::raw(display_text(&old_path.to_string_lossy()))); } Line::from(spans).style(style) }) .collect(); frame.render_widget(Paragraph::new(lines), results_area); if browsing && picker.focused && results_area.width > 0 && selected_index >= first && selected_index - first < count { frame.set_cursor_position(( results_area.x, results_area.y + (selected_index - first) as u16, )); } } let help = if changed_files.is_some() { "A added · M modified · D deleted · R renamed · ^E hide".into() } else if picker.focused { let controls = if picker.root.as_os_str().is_empty() { if browsing { "Enter · i filter · Esc close · Tab panes · ^T view" } else { "Enter · Esc · Tab panes · ^T view" } } else { match (picker.presentation, browsing) { (FilePresentation::Floating, true) => "Enter · i filter · Esc/^W close · ^R · ^T", (FilePresentation::Docked, true) => { "Enter · i filter · Esc/Tab edit · ^W · ^R · ^T" } (_, false) if picker.view == FileView::Tree => "Enter · Esc NORMAL · ^W · ^R · ^T", (FilePresentation::Floating, false) => "Enter · Esc/^W close · ^R · ^T", (FilePresentation::Docked, false) => "Enter · Esc/Tab edit · ^W · ^R · ^T", } }; format!("^U clear · {navigation_hint} · {controls}") } else if picker.root.as_os_str().is_empty() { "Editor focus · Tab to browse changed files".into() } else { "Editor focus · invoke explorer to browse".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, ); } fn paint_syntax_line( frame: &mut Frame, source: &str, spans: &[SyntaxSpan], area: Rect, left: usize, ) { if spans.is_empty() || area.width == 0 || area.height == 0 { return; } let right = left.saturating_add(usize::from(area.width)); let mut span_index = 0; let mut column = 0; for (byte, grapheme) in source.grapheme_indices(true) { if column >= right { break; } let cells = if grapheme == "\t" { TAB_WIDTH - column % TAB_WIDTH } else { UnicodeWidthStr::width(grapheme) + grapheme.chars().filter(|ch| ch.is_control()).count() }; let end = column + cells; while span_index < spans.len() && spans[span_index].end_byte <= byte { span_index += 1; } if let Some(span) = spans.get(span_index).filter(|span| span.start_byte <= byte) && end > left && (grapheme == "\t" || (column >= left && end <= right)) { let style = syntax_style(span.kind); for cell in column.max(left)..end.min(right) { frame.buffer_mut()[(area.x + (cell - left) as u16, area.y)].set_style(style); } } column = end; } } fn syntax_style(kind: SyntaxKind) -> Style { Style::default().fg(match kind { SyntaxKind::Comment => Color::DarkGray, SyntaxKind::String => Color::Green, SyntaxKind::Keyword => Color::Magenta, SyntaxKind::Number => Color::Yellow, SyntaxKind::Type | SyntaxKind::Markup => Color::Cyan, SyntaxKind::Function => Color::LightBlue, }) } fn semantic_style(token: &SemanticToken) -> Style { use SemanticTokenKind::*; let readonly = token.modifiers & SemanticTokenModifier::Readonly as u16 != 0; let color = match token.kind { Namespace => Color::Gray, Type | Class | Enum | Interface | Struct | TypeParameter => Color::Cyan, Function | Method | Event | Macro => Color::LightBlue, Parameter | Variable => Color::LightCyan, Property => Color::Blue, EnumMember | Number => Color::Yellow, Keyword | Modifier | Operator | Decorator => Color::Magenta, String | Regexp => Color::Green, Comment => Color::DarkGray, }; let mut style = Style::default().fg(color); if readonly && matches!(token.kind, Parameter | Variable | Property) { style = style.add_modifier(ratatui::style::Modifier::ITALIC); } if token.modifiers & (SemanticTokenModifier::Declaration as u16 | SemanticTokenModifier::Definition as u16) != 0 { style = style.add_modifier(ratatui::style::Modifier::BOLD); } if token.kind == Comment && token.modifiers & SemanticTokenModifier::Documentation as u16 != 0 { style = style.add_modifier(ratatui::style::Modifier::ITALIC); } if token.modifiers & SemanticTokenModifier::Deprecated as u16 != 0 { style = style.add_modifier(ratatui::style::Modifier::CROSSED_OUT); } style } #[cfg(test)] // These fixtures intentionally contain ranges, not lists of indices. #[allow(clippy::single_range_in_vec_init)] 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::create_dir_all(path.parent().unwrap()).unwrap(); 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") } fn diff_catalog() -> crate::diff::Catalog { use crate::diff::{Catalog, Category, RepoKind, Revision, RevisionTarget}; let mut entries = vec![Revision { target: RevisionTarget::Working, label: "Working checkout".into(), description: "On-disk files".into(), category: Category::Commits, }]; entries.extend((0..20).map(|index| Revision { target: RevisionTarget::Commit(format!("commit-{index:02}")), label: format!("commit-{index:02}"), description: if index == 19 { "needle description".into() } else { "ordinary change".into() }, category: Category::Commits, })); entries.push(Revision { target: RevisionTarget::Commit("branch-tip".into()), label: "feature-branch".into(), description: "branch description".into(), category: Category::Branches, }); Catalog { kind: RepoKind::Git, entries, notice: None, } } fn syntax_diff_file(path: &str, before: &str, after: &str) -> ChangedFile { let before: Vec = before.split('\n').map(str::to_owned).collect(); let after: Vec = after.split('\n').map(str::to_owned).collect(); ChangedFile { path: path.into(), old_path: None, status: FileStatus::Modified, hunks: vec![crate::diff::Hunk { before_start: 0, before_len: before.len(), after_start: 0, after_len: after.len(), }], before, after, notice: None, } } #[test] fn diff_syntax_refreshes_same_path_working_snapshots_without_live_buffer_styles() { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); let mut panels = WheelPanels::default(); for (source, foreground) in [ ("fn main() {}", Color::Magenta), ("// current snapshot", Color::DarkGray), ("const VALUE: u8 = 3;", Color::Magenta), ] { let mut diff = diff_comparison(vec![syntax_diff_file("working.rs", "fn old() {}", source)]); diff.files_visible = false; render_diff(&mut terminal, &diff, &mut panels); let after = panels.diff.as_ref().unwrap().regions[2].0; let cell = &terminal.backend().buffer()[(after.x + 4, after.y + 1)]; assert_eq!(cell.fg, foreground, "{source}"); assert_eq!(cell.bg, Color::Rgb(24, 48, 32)); let before = panels.diff.as_ref().unwrap().regions[1].0; let cell = &terminal.backend().buffer()[(before.x + 4, before.y + 1)]; assert_eq!(cell.fg, Color::Magenta); assert_eq!(cell.bg, Color::Rgb(64, 24, 24)); } } #[test] fn diff_syntax_uses_each_historic_path_shebang_and_injected_language() { let mut file = syntax_diff_file("new.rs", "return 42", "fn main() {}"); file.old_path = Some("old.py".into()); let mut syntax = DiffSyntaxCache::default(); syntax.update(1, 0, &file); assert_eq!(syntax.before.language(), Some("Python")); assert_eq!(syntax.after.language(), Some("Rust")); assert_eq!(syntax.before.line(0)[0].kind, SyntaxKind::Keyword); assert_eq!(syntax.after.line(0)[0].kind, SyntaxKind::Keyword); file.path = "unknown.extension".into(); syntax.update(1, 0, &file); assert_eq!(syntax.after.language(), None); assert!(syntax.after.line(0).is_empty()); let mut file = syntax_diff_file( "script", "```rust\nfn old() {}\n```", "#!/usr/bin/env python3\nreturn 42", ); file.old_path = Some("history.md".into()); let mut diff = diff_comparison(vec![file]); diff.files_visible = false; let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); let mut panels = WheelPanels::default(); render_diff(&mut terminal, &diff, &mut panels); let syntax = panels.diff_syntax.as_ref().unwrap(); assert_eq!(syntax.before.language(), Some("Markdown")); assert_eq!(syntax.after.language(), Some("Python")); assert!(syntax.before.parse_counts().1 > 0); assert_eq!(syntax.before.line(1)[0].kind, SyntaxKind::Keyword); assert_eq!(syntax.after.line(1)[0].kind, SyntaxKind::Keyword); for (region, background) in [ ( panels.diff.as_ref().unwrap().regions[1].0, Color::Rgb(64, 24, 24), ), ( panels.diff.as_ref().unwrap().regions[2].0, Color::Rgb(24, 48, 32), ), ] { let cell = &terminal.backend().buffer()[(region.x + 4, region.y + 2)]; assert_eq!(cell.fg, Color::Magenta); assert_eq!(cell.bg, background); } } #[test] fn diff_syntax_parses_selected_snapshots_not_scroll_focus_or_redraw() { let first = syntax_diff_file("a.rs", "fn old() {}\n// old", "fn main() {}\n// new"); let second = syntax_diff_file("b.rs", "// prior file", "// selected file"); let mut diff = diff_comparison(vec![first, second]); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); let mut panels = WheelPanels::default(); render_diff(&mut terminal, &diff, &mut panels); let syntax = panels.diff_syntax.as_ref().unwrap(); let counts = (syntax.before.parse_counts(), syntax.after.parse_counts()); assert_eq!(counts, ((1, 0), (1, 0))); for focus in [Focus::Before, Focus::After, Focus::Files] { diff.focus = focus; diff.top = 1; diff.left_before = 1; diff.left_after = 2; render_diff(&mut terminal, &diff, &mut panels); let syntax = panels.diff_syntax.as_ref().unwrap(); assert_eq!( (syntax.before.parse_counts(), syntax.after.parse_counts()), counts ); let after = panels.diff.as_ref().unwrap().regions[2].0; assert_eq!( terminal.backend().buffer()[(after.x + 4, after.y + 1)].fg, Color::DarkGray, ); } diff.files .as_mut() .unwrap() .reveal_path(std::path::Path::new("b.rs")); diff.handle(Input::Enter); render_diff(&mut terminal, &diff, &mut panels); let syntax = panels.diff_syntax.as_ref().unwrap(); assert_eq!(syntax.before.parse_counts(), (2, 0)); assert_eq!(syntax.after.parse_counts(), (2, 0)); let after = panels.diff.as_ref().unwrap().regions[2].0; assert_eq!( terminal.backend().buffer()[(after.x + 4, after.y + 1)].fg, Color::DarkGray, ); let editor = Editor::open(None, vec![]).unwrap(); let mut views = BufferViewports { panels, ..BufferViewports::default() }; terminal.draw(|frame| views.draw(frame, &editor)).unwrap(); assert!(views.panels.diff_syntax.is_none()); } fn diff_comparison(files: Vec) -> DiffState { static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); let id = NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let mut diff = DiffState::new(); diff.take_request(); diff.apply_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); diff.handle(Input::Enter); diff.handle(Input::Enter); diff.take_request(); diff.apply_result( id, Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { before: "old revision".into(), after: "Working checkout".into(), files, })), ); diff } fn deletion_file() -> ChangedFile { ChangedFile { path: "deleted-lines.txt".into(), old_path: None, status: FileStatus::Modified, before: ["keep", "gone", "tail"].map(String::from).to_vec(), after: ["keep", "tail"].map(String::from).to_vec(), hunks: vec![crate::diff::Hunk { before_start: 1, before_len: 1, after_start: 1, after_len: 0, }], notice: None, } } fn render_diff( terminal: &mut Terminal, diff: &DiffState, panels: &mut WheelPanels, ) { terminal .draw(|frame| { panels.begin_frame(); draw_diff(frame, diff, frame.area(), "↑/↓", "h/j/k/l", panels); }) .unwrap(); } #[test] fn diff_picker_keeps_working_pinned_while_windowing_searching_and_changing_stage() { let mut diff = DiffState::new(); diff.take_request(); diff.apply_result(1, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap(); let mut panels = WheelPanels::default(); diff.handle(Input::End); render_diff(&mut terminal, &diff, &mut panels); let text = screen_text(&terminal); assert!(text.contains("Choose Before")); assert!(text.contains("Working checkout · pinned")); assert!(text.contains("> commit-19")); assert!(!text.contains("commit-00")); for ch in "needle".chars() { diff.handle(Input::Char(ch)); } diff.handle(Input::Down); render_diff(&mut terminal, &diff, &mut panels); let text = screen_text(&terminal); assert!(text.contains("> needle")); assert!(text.contains("Working checkout · pinned")); assert!(text.contains("> commit-19")); diff.handle(Input::Enter); render_diff(&mut terminal, &diff, &mut panels); assert!(screen_text(&terminal).contains("Choose After · Before: commit-19")); diff.handle(Input::Escape); render_diff(&mut terminal, &diff, &mut panels); assert!(screen_text(&terminal).contains("> needle")); assert!(screen_text(&terminal).contains("> commit-19")); diff.handle(Input::Ctrl('u')); diff.handle(Input::Char('\t')); render_diff(&mut terminal, &diff, &mut panels); let text = screen_text(&terminal); assert!(text.contains("feature-branch")); assert!(!text.contains("ordinary change")); assert!(text.contains("Working checkout · pinned")); let buffer = terminal.backend().buffer(); assert!( buffer .content .iter() .any(|cell| cell.symbol() == "B" && cell.bg == Color::Cyan) ); } #[test] fn diff_excerpts_render_original_lines_and_expand_without_losing_changes() { let before: Vec<_> = (0..80) .map(|index| format!("let row_{index:03} = {index};")) .collect(); let mut after = before.clone(); after[30] = "let first_change = 900;".into(); after[60] = "let second_change = 901;".into(); let mut diff = diff_comparison(vec![ChangedFile { path: "excerpts.rs".into(), old_path: None, status: FileStatus::Modified, before, after, hunks: [30, 60] .map(|index| crate::diff::Hunk { before_start: index, before_len: 1, after_start: index, after_len: 1, }) .into(), notice: None, }]); diff.set_context_options(1, 3); diff.handle(Input::Ctrl('e')); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 24)).unwrap(); let mut panels = WheelPanels::default(); render_diff(&mut terminal, &diff, &mut panels); let text = screen_text(&terminal); for visible in [ "30 let row_029", "31- let row_030", "31+ let first_change", "61+ let second_change", ] { assert!(text.contains(visible), "missing {visible}: {text}"); } for hidden in ["row_028", "row_032", "row_058", "row_062"] { assert!(!text.contains(hidden), "unexpected {hidden}: {text}"); } let before_area = panels.diff.as_ref().unwrap().regions[1].0; let after_area = panels.diff.as_ref().unwrap().regions[2].0; let buffer = terminal.backend().buffer(); assert_eq!( buffer[(before_area.x + 5, before_area.y + 3)].fg, Color::Magenta ); assert_eq!( buffer[(before_area.x + 5, before_area.y + 3)].bg, Color::Rgb(64, 24, 24) ); assert_eq!( buffer[(after_area.x + 5, after_area.y + 3)].bg, Color::Rgb(24, 48, 32) ); diff.handle(Input::Char('[')); diff.handle(Input::Home); render_diff(&mut terminal, &diff, &mut panels); let text = screen_text(&terminal); assert!(text.contains("row_026")); assert!(!text.contains("row_025")); assert!(text.contains("first_change")); assert!(text.contains("second_change")); } #[test] fn diff_view_aligns_deletion_holes_numbers_colors_and_shared_vertical_scroll() { let mut diff = diff_comparison(vec![deletion_file()]); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); let mut panels = WheelPanels::default(); render_diff(&mut terminal, &diff, &mut panels); let geometry = panels.diff.as_ref().unwrap(); let before = geometry.regions[1].0; let after = geometry.regions[2].0; let buffer = terminal.backend().buffer(); let y = before.y + 2; assert_eq!(buffer[(before.x + 4, y)].symbol(), "g"); assert_eq!(buffer[(before.x + 4, y)].fg, Color::Reset); assert_eq!(buffer[(before.x + 2, y)].symbol(), "-"); for x in before.x + 1..before.right() - 1 { assert_eq!(buffer[(x, y)].bg, Color::Rgb(64, 24, 24)); assert_eq!(buffer[(x, y + 1)].bg, Color::Reset); } let gap_symbols: Vec<_> = (after.x + 1..after.right() - 1) .map(|x| { let cell = &buffer[(x, y)]; assert_eq!(cell.symbol(), "╱"); assert_eq!(cell.bg, Color::Reset); assert_eq!(cell.fg, Color::Rgb(72, 72, 72)); cell.symbol().to_owned() }) .collect(); assert_eq!(buffer[(before.x + 1, y + 1)].symbol(), "3"); assert_eq!(buffer[(after.x + 1, y + 1)].symbol(), "2"); assert_eq!(buffer[(before.x + 4, y + 1)].symbol(), "t"); assert_eq!(buffer[(after.x + 4, y + 1)].symbol(), "t"); diff.handle(Input::Char('\t')); diff.handle(Input::Down); render_diff(&mut terminal, &diff, &mut panels); let buffer = terminal.backend().buffer(); assert_eq!(buffer[(before.x + 4, before.y + 1)].symbol(), "g"); for (x, symbol) in (after.x + 1..after.right() - 1).zip(gap_symbols) { assert_eq!(buffer[(x, after.y + 1)].symbol(), symbol); } assert_eq!(buffer[(before.x, before.y)].fg, Color::Cyan); assert_eq!(buffer[(after.x, after.y)].fg, Color::DarkGray); assert!(screen_text(&terminal).contains("Before focus")); } #[test] fn diff_added_rows_hatch_missing_before_but_tint_real_empty_after_lines() { let mut diff = diff_comparison(vec![ChangedFile { path: "added.txt".into(), old_path: None, status: FileStatus::Added, before: vec![], after: vec!["first addition".into(), String::new()], hunks: vec![crate::diff::Hunk { before_start: 0, before_len: 0, after_start: 0, after_len: 2, }], notice: None, }]); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); let mut panels = WheelPanels::default(); render_diff(&mut terminal, &diff, &mut panels); let geometry = panels.diff.as_ref().unwrap(); let before = geometry.regions[1].0; let after = geometry.regions[2].0; let buffer = terminal.backend().buffer(); for offset in 0..2 { let y = before.y + 1 + offset; for x in before.x + 1..before.right() - 1 { let cell = &buffer[(x, y)]; assert_eq!(cell.symbol(), "╱"); assert_eq!(cell.bg, Color::Reset); assert_eq!(cell.fg, Color::Rgb(72, 72, 72)); } assert_eq!(buffer[(after.x + 1, y)].symbol(), (offset + 1).to_string()); assert_eq!(buffer[(after.x + 2, y)].symbol(), "+"); assert_eq!(buffer[(after.x + 2, y)].fg, Color::Green); assert_eq!(buffer[(after.x + 4, y)].fg, Color::Reset); for x in after.x + 1..after.right() - 1 { assert_eq!(buffer[(x, y)].bg, Color::Rgb(24, 48, 32)); if offset == 1 && x >= after.x + 4 { assert_eq!(buffer[(x, y)].symbol(), " "); } } } diff.top = 1; diff.left_before = 1; render_diff(&mut terminal, &diff, &mut panels); let buffer = terminal.backend().buffer(); for x in before.x + 1..before.right() - 1 { assert_eq!(buffer[(x, before.y + 1)].symbol(), "╱"); } assert_eq!(buffer[(before.x, before.y + 1)].symbol(), "│"); assert_eq!(buffer[(before.right() - 1, before.y + 1)].symbol(), "│"); } #[test] fn diff_changed_backgrounds_preserve_syntax_and_clipped_source_cells() { let mut diff = diff_comparison(vec![ChangedFile { path: "changed.rs".into(), old_path: None, status: FileStatus::Modified, before: [ "fn old() {", "\tlet word = \"界e\u{301}old\";", "\tlet count = 42; //\u{1b}note", "", "}", ] .map(String::from) .to_vec(), after: [ "fn new() {", "\tlet word = \"界e\u{301}new\";", "\tlet count = 24; //\u{1b}note", "", "}", ] .map(String::from) .to_vec(), hunks: vec![crate::diff::Hunk { before_start: 0, before_len: 5, after_start: 0, after_len: 5, }], notice: None, }]); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 12)).unwrap(); let mut panels = WheelPanels::default(); render_diff(&mut terminal, &diff, &mut panels); let geometry = panels.diff.as_ref().unwrap(); let before = geometry.regions[1].0; let after = geometry.regions[2].0; let buffer = terminal.backend().buffer(); for (pane, background) in [ (before, Color::Rgb(64, 24, 24)), (after, Color::Rgb(24, 48, 32)), ] { for row in 0..5 { // TestBackend emits the wide glyph, not its continuation cells. let mut x = pane.x + 1; while x < pane.right() - 1 { assert_eq!( buffer[(x, pane.y + 1 + row)].bg, background, "pane={pane:?} x={x} row={row} symbol={:?}", buffer[(x, pane.y + 1 + row)].symbol() ); x += UnicodeWidthStr::width(buffer[(x, pane.y + 1 + row)].symbol()).max(1) as u16; } } for (column, row, foreground) in [ (0, 0, Color::Magenta), (3, 0, Color::LightBlue), (4, 1, Color::Magenta), (15, 1, Color::Green), (16, 1, Color::Green), (18, 1, Color::Green), (16, 2, Color::Yellow), (22, 2, Color::DarkGray), (23, 2, Color::DarkGray), ] { assert_eq!( buffer[(pane.x + 4 + column, pane.y + 1 + row)].fg, foreground ); } assert_eq!(buffer[(pane.x + 20, pane.y + 2)].symbol(), "界"); assert_eq!(buffer[(pane.x + 22, pane.y + 2)].symbol(), "e\u{301}"); assert_eq!(buffer[(pane.x + 26, pane.y + 3)].symbol(), "�"); assert_eq!(buffer[(pane.x + 27, pane.y + 3)].symbol(), "n"); for x in pane.x + 4..pane.right() - 1 { assert_eq!(buffer[(x, pane.y + 4)].symbol(), " "); assert_eq!(buffer[(x, pane.y + 4)].fg, Color::Reset); } } diff.top = 1; diff.left_before = 17; diff.left_after = 16; render_diff(&mut terminal, &diff, &mut panels); let buffer = terminal.backend().buffer(); assert_eq!(buffer[(before.x + 4, before.y + 1)].symbol(), " "); assert_eq!(buffer[(before.x + 4, before.y + 1)].fg, Color::Reset); assert_eq!( buffer[(before.x + 4, before.y + 1)].bg, Color::Rgb(64, 24, 24) ); assert_eq!(buffer[(before.x + 5, before.y + 1)].symbol(), "e\u{301}"); assert_eq!(buffer[(before.x + 5, before.y + 1)].fg, Color::Green); assert_eq!(buffer[(after.x + 4, after.y + 1)].symbol(), "界"); assert_eq!(buffer[(after.x + 4, after.y + 1)].fg, Color::Green); assert_eq!( buffer[(after.x + 4, after.y + 1)].bg, Color::Rgb(24, 48, 32) ); assert_eq!(buffer[(after.x + 6, after.y + 1)].symbol(), "e\u{301}"); assert_eq!(buffer[(after.x + 6, after.y + 1)].fg, Color::Green); } #[test] fn diff_view_changes_files_focus_and_keeps_horizontal_offsets_independent() { let added = ChangedFile { path: "new-name.txt".into(), old_path: Some("old-name.txt".into()), status: FileStatus::Renamed, before: vec!["\t界e\u{301}OLD".into()], after: vec!["\t界e\u{301}NEW".into()], hunks: vec![crate::diff::Hunk { before_start: 0, before_len: 1, after_start: 0, after_len: 1, }], notice: None, }; let mut diff = diff_comparison(vec![deletion_file(), added]); diff.handle(Input::Down); diff.handle(Input::Enter); for _ in 0..4 { diff.handle(Input::Right); } let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); let mut panels = WheelPanels::default(); render_diff(&mut terminal, &diff, &mut panels); let text = screen_text(&terminal); assert!(text.contains("Before · R · old-name.txt")); assert!(text.contains("After · R · new-name.txt")); let geometry = panels.diff.as_ref().unwrap(); let before = geometry.regions[1].0; let after = geometry.regions[2].0; let buffer = terminal.backend().buffer(); assert_eq!(buffer[(before.x + 4, before.y + 1)].symbol(), "界"); assert_eq!(buffer[(before.x + 6, before.y + 1)].symbol(), "e\u{301}"); assert_eq!(buffer[(after.x + 4, after.y + 1)].symbol(), " "); assert_eq!(buffer[(after.x + 8, after.y + 1)].symbol(), "界"); assert_eq!(buffer[(after.x + 8, after.y + 1)].fg, Color::Reset); assert_eq!( buffer[(after.x + 8, after.y + 1)].bg, Color::Rgb(24, 48, 32) ); assert_eq!( buffer[(before.x + 4, before.y + 1)].bg, Color::Rgb(64, 24, 24) ); diff.handle(Input::Char('\t')); render_diff(&mut terminal, &diff, &mut panels); assert!(screen_text(&terminal).contains("After focus")); assert_eq!( terminal.backend().buffer()[(after.x, after.y)].fg, Color::Cyan ); } #[test] fn diff_reuses_right_explorer_compact_tree_and_tracks_browse_focus() { let mut leaf = deletion_file(); leaf.path = "chain/a/b/leaf.txt".into(); let mut root = deletion_file(); root.path = "root.txt".into(); let mut diff = diff_comparison(vec![leaf, root]); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap(); let mut panels = WheelPanels::default(); render_diff(&mut terminal, &diff, &mut panels); let regions = panels.diff.as_ref().unwrap().regions; let navigator = regions[0].0; let before = regions[1].0; let after = regions[2].0; let workspace = Rect::new(0, 1, 120, 16); let (panes, dock) = dock_layout(workspace, DockSide::Right, true); assert_eq!(Some(navigator), dock); assert_eq!(before.x, panes.x); assert_eq!(before.right(), after.x); assert_eq!(after.right(), navigator.x); assert_eq!(navigator.right(), workspace.right()); assert_eq!(panels.files.unwrap().0, navigator); let tree_text = |terminal: &Terminal| { let buffer = terminal.backend().buffer(); (navigator.y..navigator.bottom()) .map(|y| { (navigator.x..navigator.right()) .map(|x| buffer[(x, y)].symbol()) .collect::() }) .collect::>() .join("\n") }; let text = tree_text(&terminal); assert!(text.contains("Explorer · Tree")); assert!(text.contains("chain/a/b/")); assert!(text.contains("leaf.txt")); assert!(!text.contains("chain/a/b/leaf.txt")); let cursor = terminal.get_cursor_position().unwrap(); assert!(navigator.contains(cursor)); assert_eq!( terminal.backend().buffer()[(cursor.x, cursor.y)].bg, Color::Blue ); assert_eq!( terminal.backend().buffer()[(navigator.x, navigator.y)].fg, Color::Cyan ); diff.handle(Input::Left); diff.handle(Input::Enter); render_diff(&mut terminal, &diff, &mut panels); let text = tree_text(&terminal); assert!(!text.contains("leaf.txt")); assert!(text.contains("chain/a/b/")); assert_eq!(diff.file, 0); diff.handle(Input::Char('\t')); render_diff(&mut terminal, &diff, &mut panels); assert!(!diff.files.as_ref().unwrap().focused); assert_eq!( terminal.backend().buffer()[(navigator.x, navigator.y)].fg, Color::DarkGray ); assert_eq!( terminal.backend().buffer()[(before.x, before.y)].fg, Color::Cyan ); } #[test] fn changed_file_markers_follow_catalog_indices_in_every_explorer_view() { let files: Vec<_> = [ ("z.txt", FileStatus::Added), ("chain/a/m.txt", FileStatus::Modified), ("d.txt", FileStatus::Removed), ("r.txt", FileStatus::Renamed), ] .into_iter() .map(|(path, status)| { let mut file = deletion_file(); file.path = path.into(); file.status = status; file.old_path = (status == FileStatus::Renamed).then(|| "old\nname.txt".into()); file }) .collect(); let mut picker = FilePicker::from_paths(PathBuf::new(), files.iter().map(|file| file.path.clone())); picker.reveal_path(std::path::Path::new("chain/a/m.txt")); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap(); for view in [FileView::Tree, FileView::List] { picker.set_view(view); for focused in [true, false] { picker.focused = focused; for index in 0..files.len() { picker.reveal_path(&files[index].path); terminal .draw(|frame| { draw_file_list( frame, &picker, frame.area(), "", &mut WheelPanels::default(), Some(&files), ); }) .unwrap(); let buffer = terminal.backend().buffer(); for (file, color) in files .iter() .zip([Color::Green, Color::Yellow, Color::Red, Color::Cyan]) { let path = if view == FileView::Tree { std::path::Path::new(file.path.file_name().unwrap()) } else { file.path.as_path() }; let expected = format!("{} {}", diff_status(file.status), path.to_string_lossy()); let (x, y) = (0..16) .find_map(|y| { let row = (0..100) .map(|x| buffer[(x, y)].symbol()) .collect::(); row.find(&expected).map(|offset| { (UnicodeWidthStr::width(&row[..offset]) as u16, y) }) }) .unwrap_or_else(|| panic!("missing {expected}")); assert_eq!(buffer[(x, y)].fg, color); } assert!(screen_text(&terminal).contains("r.txt ← old�name.txt")); assert_eq!(picker.accept(), Some(files[index].path.clone())); assert!(picker.query.is_empty()); } } } picker.query = "m.txt".into(); picker.refilter(); terminal .draw(|frame| { draw_file_list( frame, &picker, frame.area(), "", &mut WheelPanels::default(), Some(&files), ); }) .unwrap(); assert!(screen_text(&terminal).contains("M chain/a/m.txt")); assert!(!screen_text(&terminal).contains("R r.txt")); terminal .draw(|frame| { draw_file_list( frame, &picker, frame.area(), "", &mut WheelPanels::default(), None, ); }) .unwrap(); assert!(!screen_text(&terminal).contains("M chain/a/m.txt")); } #[test] fn hiding_diff_explorer_reclaims_workspace_and_restores_its_geometry() { let mut diff = diff_comparison(vec![deletion_file()]); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap(); let mut panels = WheelPanels::default(); render_diff(&mut terminal, &diff, &mut panels); let original = panels.diff.as_ref().unwrap().regions; diff.handle(Input::Ctrl('e')); render_diff(&mut terminal, &diff, &mut panels); let hidden = panels.diff.as_ref().unwrap().regions; assert_eq!(hidden[0].0, Rect::default()); assert!(panels.files.is_none()); assert_eq!(hidden[1].0.x, 0); assert_eq!(hidden[1].0.right(), hidden[2].0.x); assert_eq!(hidden[2].0.right(), 120); assert!(hidden[1].0.width + hidden[2].0.width > original[1].0.width + original[2].0.width); assert!(!screen_text(&terminal).contains("Explorer")); diff.handle(Input::Ctrl('e')); render_diff(&mut terminal, &diff, &mut panels); assert_eq!(panels.diff.as_ref().unwrap().regions, original); assert_eq!(panels.files.unwrap().0, original[0].0); } #[test] fn diff_notices_empty_comparisons_and_tiny_layouts_remain_renderable() { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); let mut panels = WheelPanels::default(); let mut diff = DiffState::new(); render_diff(&mut terminal, &diff, &mut panels); assert!(screen_text(&terminal).contains("Loading repository")); diff.take_request(); diff.apply_result(1, Err("permission denied".into())); render_diff(&mut terminal, &diff, &mut panels); assert!(screen_text(&terminal).contains("permission denied")); let empty = diff_comparison(vec![]); render_diff(&mut terminal, &empty, &mut panels); assert!(screen_text(&terminal).contains("No changed files")); assert!(screen_text(&terminal).contains("Explorer · Tree")); let binary = diff_comparison(vec![ChangedFile { path: "image.bin".into(), old_path: None, status: FileStatus::Added, before: vec![], after: vec![], hunks: vec![], notice: Some("Binary file; text comparison unavailable".into()), }]); render_diff(&mut terminal, &binary, &mut panels); assert!(screen_text(&terminal).contains("Binary file")); let blank = diff_comparison(vec![ChangedFile { path: "empty.txt".into(), old_path: None, status: FileStatus::Added, before: vec![], after: vec![], hunks: vec![], notice: None, }]); render_diff(&mut terminal, &blank, &mut panels); assert!(screen_text(&terminal).contains("Empty file")); for (width, height) in [(1, 1), (2, 2), (3, 3), (8, 4), (39, 5)] { let mut tiny = Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); for state in [&diff, &empty, &binary, &blank] { render_diff(&mut tiny, state, &mut panels); if let Some(geometry) = &panels.diff { for (rect, _) in geometry.regions { assert!(rect.right() <= width && rect.bottom() <= height); } let [navigator, before, after] = geometry.regions.map(|(rect, _)| rect); assert!(before.right() <= after.x); if navigator.width > 0 { assert!(after.right() <= navigator.x); } } } } } #[test] fn actual_diff_draw_path_is_modal_and_escape_restores_document_viewport() { let directory = Directory::new(); let mut editor = Editor::open( Some(directory.file("source.txt", "original\nsecond")), vec![], ) .unwrap(); let original = editor.lines().to_vec(); let cursor = editor.cursor(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); let mut viewports = BufferViewports::default(); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); editor.execute("diff"); let (id, _) = editor.take_diff_request().unwrap(); editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert!(screen_text(&terminal).contains("Choose Before")); assert!(viewports.document.geometry.is_none()); editor.handle(Input::Enter); editor.handle(Input::Enter); let (id, _) = editor.take_diff_request().unwrap(); editor.apply_diff_result( id, Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { before: "old revision".into(), after: "Working checkout".into(), files: vec![deletion_file()], })), ); editor.handle(Input::Paste("must not edit".into())); editor.handle(Input::Char('i')); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert!(screen_text(&terminal).contains("deleted-lines.txt")); assert!(viewports.document.geometry.is_none()); assert_eq!(editor.lines(), original); editor.handle(Input::Escape); assert!(editor.diff().is_some()); editor.handle(Input::Escape); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert!(screen_text(&terminal).contains("original")); assert!(viewports.document.geometry.is_some()); assert_eq!(editor.cursor(), cursor); } #[test] fn diff_wheel_routes_right_tree_and_both_panes_without_touching_document() { let directory = Directory::new(); let path = directory.file("source.txt", &["original"; 60].join("\n")); let git = |args: &[&str]| { let output = std::process::Command::new("git") .args(args) .current_dir(&directory.0) .env("GIT_CONFIG_NOSYSTEM", "1") .env("GIT_CONFIG_GLOBAL", "/dev/null") .env("GIT_AUTHOR_NAME", "Diff fixture") .env("GIT_AUTHOR_EMAIL", "fixture@example.invalid") .env("GIT_COMMITTER_NAME", "Diff fixture") .env("GIT_COMMITTER_EMAIL", "fixture@example.invalid") .env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z") .env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z") .output() .unwrap(); assert!(output.status.success(), "{:?}", output); }; git(&["init", "-q"]); for index in 0..8 { directory.file(&format!("file-{index}.txt"), "first\nsecond\nthird\n"); } git(&["add", "."]); git(&["commit", "-qm", "base"]); for index in 0..8 { fs::remove_file(directory.0.join(format!("file-{index}.txt"))).unwrap(); } let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); let original = session.editor().lines().to_vec(); let cursor = session.editor().cursor(); let ready = |session: &mut EditorSession, phase| { let deadline = std::time::Instant::now() + Duration::from_secs(10); loop { session.poll(); let diff = session.editor().diff().unwrap(); assert!(diff.error.is_none(), "{:?}", diff.error); if diff.phase == phase && !diff.loading { break; } assert!(std::time::Instant::now() < deadline, "{diff:?}"); std::thread::sleep(Duration::from_millis(1)); } }; session.execute("diff"); ready(&mut session, Phase::Before); session.handle(Input::Down); session.handle(Input::Enter); session.handle(Input::Enter); ready(&mut session, Phase::View); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap(); let mut viewports = BufferViewports::default(); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); let regions = viewports.panels.diff.as_ref().unwrap().regions; let tree = regions[0].0; let before = regions[1].0; let after = regions[2].0; assert!(viewports.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, tree.x + 1, tree.y + 1), )); let diff = session.editor().diff().unwrap(); assert_eq!(diff.focus, Focus::Files); assert_eq!(diff.files.as_ref().unwrap().selected, 3); assert_eq!(diff.file, 0); session.handle(Input::Enter); assert_eq!(session.editor().diff().unwrap().file, 3); assert!(viewports.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, before.x + 1, before.y + 1), )); let diff = session.editor().diff().unwrap(); assert_eq!(diff.focus, Focus::Before); assert_eq!(diff.top, 2); assert_eq!(diff.file, 3); assert!(viewports.scroll( &mut session, document_wheel(MouseEventKind::ScrollUp, after.x + 1, after.y + 1), )); let diff = session.editor().diff().unwrap(); assert_eq!(diff.focus, Focus::After); assert_eq!(diff.top, 0); assert_eq!(diff.file, 3); let selected = diff.files.as_ref().unwrap().selected; session.handle(Input::Ctrl('e')); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); assert!(viewports.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, tree.x + 1, tree.y + 1), )); let diff = session.editor().diff().unwrap(); assert_eq!(diff.focus, Focus::After); assert_eq!(diff.files.as_ref().unwrap().selected, selected); assert!(!diff.files.as_ref().unwrap().focused); assert!(viewports.panels.files.is_none()); assert_eq!(session.editor().lines(), original); assert_eq!(session.editor().cursor(), cursor); assert!(viewports.document.geometry.is_none()); } #[test] fn diff_wheel_cannot_scroll_the_hidden_document_even_outside_the_picker() { let directory = Directory::new(); let path = directory.file("rows.txt", &["original"; 60].join("\n")); let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); let mut viewports = BufferViewports::default(); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); let id = session.editor().buffer_id(); let offset = viewports.offsets[&id]; let cursor = session.editor().cursor(); session.execute("diff"); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); assert!(viewports.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, 0, 0) )); assert!(viewports.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, 60, 15) )); assert_eq!(viewports.offsets[&id], offset); assert_eq!(session.editor().cursor(), cursor); assert!(viewports.document.geometry.is_none()); session.handle(Input::Escape); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); assert!(viewports.document.geometry.is_some()); } #[test] fn completion_windows_selected_unicode_without_overwriting_gutter_dock_or_cursor() { let mut completion = Completion { items: (0..20) .map(|index| crate::plugin::CompletionItem { label: if index == 19 { "界e\u{301}👩‍🚀\u{1b}".into() } else { format!("item-{index:02}") }, detail: Some("\tTAIL".into()), primary_edit: 0, edits: vec![], snippet: None, }) .collect(), selected: 20, }; for (width, height) in [(14, 6), (9, 1), (5, 1)] { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); let workspace = Rect::new(2, 0, width - 4, height); let caret = Position::new(2, 0); let mut panel = None; completion.selected = 20; terminal .draw(|frame| { let rows: Vec = (0..height) .map(|_| Line::from(format!("GG{}EE", ".".repeat(usize::from(width - 4))))) .collect(); frame.render_widget(Paragraph::new(rows), frame.area()); frame.set_cursor_position(caret); panel = draw_completion(frame, &completion, workspace, caret); }) .unwrap(); let mut panel = panel.unwrap(); let selected_y = panel.bottom() - 1; let buffer = terminal.backend().buffer(); assert_eq!(buffer[(panel.x, selected_y)].bg, Color::Cyan); assert!(!screen_text(&terminal).contains("item-00")); for y in 0..height { for x in [0, 1] { assert_eq!(buffer[(x, y)].symbol(), "G"); } for x in [width - 2, width - 1] { assert_eq!(buffer[(x, y)].symbol(), "E"); } } if workspace.width >= 5 { assert_eq!(buffer[(panel.x, selected_y)].symbol(), "界"); assert_eq!(buffer[(panel.x + 2, selected_y)].symbol(), "e\u{301}"); assert_eq!(buffer[(panel.x + 3, selected_y)].symbol(), "👩‍🚀"); } else { assert_eq!(buffer[(panel.x, selected_y)].symbol(), " "); } if workspace.width >= 6 { assert_eq!(buffer[(panel.x + 5, selected_y)].symbol(), "�"); } assert_eq!(terminal.get_cursor_position().unwrap(), caret); completion.selected = 0; terminal .draw(|frame| { frame.set_cursor_position(caret); panel = draw_completion(frame, &completion, workspace, caret).unwrap(); }) .unwrap(); assert_eq!( terminal.backend().buffer()[(panel.x, panel.y)].symbol(), "O" ); assert_eq!( terminal.backend().buffer()[(panel.x, panel.y)].bg, Color::Cyan ); assert_eq!(terminal.get_cursor_position().unwrap(), caret); } } #[test] fn snippet_highlight_tracks_argument_graphemes_without_filling_the_row() { use crate::plugin::{ CompletionEdit, CompletionItem, CompletionSnippet, SnippetTabstop, TextPosition, }; let mut editor = Editor::open(None, vec![]).unwrap(); editor.handle(Input::Char('i')); editor .show_completion(vec![CompletionItem { label: "call".into(), detail: None, primary_edit: 0, edits: vec![CompletionEdit { start: TextPosition { row: 0, column: 0 }, end: TextPosition { row: 0, column: 0 }, text: "call(界e\u{301}, tail)".into(), }], snippet: Some(CompletionSnippet { tabstops: vec![ SnippetTabstop { index: 1, ranges: vec![5..11], ancestors: vec![vec![]], }, SnippetTabstop { index: 2, ranges: vec![13..17], ancestors: vec![vec![]], }, SnippetTabstop { index: 0, ranges: vec![18..18], ancestors: vec![vec![]], }, ], }), }]) .unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(28, 6)).unwrap(); let mut viewports = BufferViewports::default(); for (key, selected, caret) in [ (None, 7..10, Position::new(7, 0)), (Some(Input::Char('\t')), 12..16, Position::new(12, 0)), ] { if let Some(key) = key { editor.handle(key); } terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); let buffer = terminal.backend().buffer(); // A wide glyph's leading cell owns its terminal style. for x in (0..28).filter(|x| *x != 8) { assert_eq!( buffer[(x, 0)].bg == Color::Blue, selected.contains(&x), "column {x}, selection {:?}, screen:\n{}", editor.snippet_selections(), screen_text(&terminal), ); } assert_eq!(terminal.get_cursor_position().unwrap(), caret); } editor.handle(Input::Escape); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); for x in 0..28 { assert_ne!(terminal.backend().buffer()[(x, 0)].bg, Color::Blue); } } #[test] fn hover_shows_more_documentation_with_markdown_and_fenced_code_colors() { let mut editor = Editor::open(None, vec![]).unwrap(); let title = format!("# {}TAIL", "Heading ".repeat(11)); let details = (5..36) .map(|row| format!("detail-{row:02}")) .collect::>() .join("\n"); editor .show_hover(format!( "{title}\n\n```rust\nfn answer() -> i32 {{ 42 }}\n```\n{details}" )) .unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 32)).unwrap(); let mut panels = WheelPanels::default(); terminal .draw(|frame| draw_hover(frame, editor.hover().unwrap(), frame.area(), &mut panels)) .unwrap(); assert!(screen_text(&terminal).contains("TAIL")); assert!(screen_text(&terminal).contains("detail-20")); let (panel, _) = panels.hover.unwrap(); let (x, y) = (panel.x + 1, panel.y + 1); let buffer = terminal.backend().buffer(); assert_eq!(buffer[(x, y)].fg, Color::Cyan); assert_eq!(buffer[(x, y + 3)].symbol(), "f"); assert_eq!(buffer[(x, y + 3)].fg, Color::Magenta); assert_eq!(buffer[(x + 6, y + 3)].fg, Color::LightBlue); assert_eq!(buffer[(x + 21, y + 3)].fg, Color::Yellow); for _ in 0..3 { editor.handle(Input::Down); } editor.handle(Input::Right); terminal .draw(|frame| draw_hover(frame, editor.hover().unwrap(), frame.area(), &mut panels)) .unwrap(); let buffer = terminal.backend().buffer(); assert_eq!(buffer[(x, y)].symbol(), "n"); assert_eq!(buffer[(x, y)].fg, Color::Magenta); assert_eq!(buffer[(x + 20, y)].fg, Color::Yellow); editor.show_hover("# Replacement".into()).unwrap(); terminal .draw(|frame| draw_hover(frame, editor.hover().unwrap(), frame.area(), &mut panels)) .unwrap(); assert!(screen_text(&terminal).contains("# Replacement")); assert!(!screen_text(&terminal).contains("answer")); } #[test] fn hover_scroll_renders_whole_graphemes_in_tiny_workspace() { let mut editor = Editor::open(None, vec![]).unwrap(); editor.show_hover("x界e\u{301}👩‍🚀z\nlast".into()).unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(6, 1)).unwrap(); let mut syntax = SyntaxCache::new(); let (mut top, mut left) = (0, 0); for _ in 0..2 { editor.handle(Input::Right); } let rendered = terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .unwrap(); assert_eq!(rendered.buffer[(2, 0)].symbol(), "e\u{301}"); assert_eq!(rendered.buffer[(3, 0)].symbol(), "👩‍🚀"); assert_eq!(rendered.buffer[(5, 0)].symbol(), "z"); assert_eq!( terminal.get_cursor_position().unwrap(), ratatui::layout::Position::new(2, 0) ); assert_eq!((top, left), (0, 0)); editor.handle(Input::Down); editor.handle(Input::Home); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .unwrap(); assert!(screen_text(&terminal).ends_with("last")); for (width, height) in [(1, 1), (2, 2), (3, 3), (4, 4)] { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .unwrap(); } assert_eq!(editor.cursor(), crate::editor::Cursor::default()); assert_eq!(editor.lines(), &[""]); } fn annotation( start: (usize, usize), end: (usize, usize), severity: DiagnosticSeverity, message: &str, ) -> Diagnostic { Diagnostic { start: crate::plugin::TextPosition { row: start.0, column: start.1, }, end: crate::plugin::TextPosition { row: end.0, column: end.1, }, severity, message: message.into(), } } fn semantic_token( row: usize, start: usize, end: usize, kind: SemanticTokenKind, modifiers: u16, ) -> SemanticToken { SemanticToken { start: crate::plugin::TextPosition { row, column: start }, end: crate::plugin::TextPosition { row, column: end }, kind, modifiers, } } #[test] fn diagnostic_underlines_preserve_syntax_and_whole_graphemes_at_viewport_edges() { let directory = Directory::new(); let source = "#a\t界e\u{301}👩‍🚀z"; let mut editor = Editor::open(Some(directory.file("unicode.py", source)), vec![]).unwrap(); editor .replace_diagnostics( "fixture", editor.buffer_id(), editor.text_revision(), vec![ annotation((0, 2), (0, 6), DiagnosticSeverity::Warning, "range"), annotation((0, 4), (0, 5), DiagnosticSeverity::Error, "combining"), ], ) .unwrap(); let mut syntax = SyntaxCache::new(); for (offset, width, caret, underlines) in [ ( 2, 5, 4, vec![ Some(Color::Yellow), Some(Color::Yellow), Some(Color::Yellow), Some(Color::Yellow), Some(Color::Red), ], ), (5, 2, 4, vec![None, Some(Color::Red)]), (7, 2, 5, vec![Some(Color::Yellow), Some(Color::Yellow)]), (8, 2, 6, vec![None, None]), ( 0, 5, 2, vec![None, None, Some(Color::Yellow), Some(Color::Yellow), None], ), ] { editor.handle(Input::Home); for _ in 0..caret { editor.handle(Input::Right); } let area = Rect::new(3, 2, width + 2, 4); let mut terminal = Terminal::with_options( ratatui::backend::TestBackend::new(20, 8), ratatui::TerminalOptions { viewport: ratatui::Viewport::Fixed(area), }, ) .unwrap(); let (mut top, mut left) = (0, offset); let rendered = terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .unwrap() .buffer .clone(); assert_eq!(left, offset); for (cell, underline) in underlines.into_iter().enumerate() { let position = (area.x + 2 + cell as u16, area.y); assert_eq!( rendered[position].modifier.contains(Modifier::UNDERLINED), underline.is_some(), "left={offset}, cell={cell}", ); if let Some(color) = underline { assert_eq!(rendered[position].underline_color, color); assert_eq!(rendered[position].fg, Color::DarkGray); } assert_eq!(rendered[position].bg, Color::Reset); } assert_eq!(rendered[(area.x, area.y)].fg, Color::Red); assert_eq!( terminal.backend().buffer()[(0, 0)].modifier, Modifier::empty() ); assert_eq!(editor.lines(), &[source]); } } #[test] fn diagnostics_render_newlines_anchors_counts_and_messages_without_hiding_core_errors() { let mut editor = Editor::open(None, vec![]).unwrap(); editor .apply(vec![crate::editor::Effect::Replace( "abc\n\nxy\nlast".into(), )]) .unwrap(); editor .replace_diagnostics( "fixture", editor.buffer_id(), editor.text_revision(), vec![ annotation((0, 2), (0, 2), DiagnosticSeverity::Error, "point"), annotation((0, 1), (3, 0), DiagnosticSeverity::Warning, "multiline"), annotation((3, 4), (3, 4), DiagnosticSeverity::Information, "end"), annotation((3, 0), (3, 4), DiagnosticSeverity::Hint, "hint"), ], ) .unwrap(); editor.handle(Input::Home); editor.handle(Input::Right); editor.handle(Input::Right); editor.set_message("write failed: permission denied"); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 7)).unwrap(); let mut viewports = BufferViewports::default(); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); let buffer = terminal.backend().buffer(); for (position, color) in [ ((3, 0), Color::Yellow), ((4, 0), Color::Red), ((5, 0), Color::Yellow), ((2, 1), Color::Yellow), ((2, 2), Color::Yellow), ((3, 2), Color::Yellow), ((4, 2), Color::Yellow), ((2, 3), Color::DarkGray), ((6, 3), Color::Cyan), ] { assert!(buffer[position].modifier.contains(Modifier::UNDERLINED)); assert_eq!(buffer[position].underline_color, color); } for (row, color) in [ (0, Color::Red), (1, Color::Yellow), (2, Color::Yellow), (3, Color::Cyan), ] { assert_eq!(buffer[(0, row)].fg, color); } assert!(!buffer[(6, 0)].modifier.contains(Modifier::UNDERLINED)); let text = screen_text(&terminal); assert!(text.contains("E:1 W:1 I:1 H:1"), "{text}"); assert!( text.contains("write failed: permission denied | E: point"), "{text}" ); assert_eq!( terminal.get_cursor_position().unwrap(), ratatui::layout::Position::new(4, 0), ); editor.handle(Input::Char('i')); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert_eq!(editor.diagnostics().count(), 0); assert!(!screen_text(&terminal).contains("E:1")); assert!(!screen_text(&terminal).contains("E: point")); assert_ne!(terminal.backend().buffer()[(0, 0)].fg, Color::Red); assert!( !terminal.backend().buffer()[(4, 0)] .modifier .contains(Modifier::UNDERLINED) ); for message in ["intermediate", "latest"] { editor .replace_diagnostics( "fixture", editor.buffer_id(), editor.text_revision(), vec![annotation( (0, 0), (0, 3), DiagnosticSeverity::Error, message, )], ) .unwrap(); } editor.handle(Input::Escape); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert!(screen_text(&terminal).contains("E:1")); assert!(screen_text(&terminal).contains("E: latest")); assert!(!screen_text(&terminal).contains("intermediate")); assert_eq!(terminal.backend().buffer()[(0, 0)].fg, Color::Red); assert!( terminal.backend().buffer()[(3, 0)] .modifier .contains(Modifier::UNDERLINED) ); editor.handle(Input::Char('i')); editor.handle(Input::Char('!')); assert_eq!(editor.mode(), Mode::Insert); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert_eq!(editor.diagnostics().count(), 0); assert!(!screen_text(&terminal).contains("E:1")); assert!( !terminal.backend().buffer()[(4, 0)] .modifier .contains(Modifier::UNDERLINED) ); editor.handle(Input::Escape); assert_eq!(editor.diagnostics().count(), 0); } #[test] fn idle_session_updates_redraw_annotations_and_navigation_while_native_editing_continues() { let directory = Directory::new(); let source = directory.file("source.txt", "broken TARGET"); let target_text = format!("{}{}TARGET", "# line\n".repeat(15), "#".repeat(40)); let target = directory.file("target.py", &target_text); directory.file( "semantic.rb", r#" Vamp.plugin "semantic", requires: ["text", "diagnostics"], async: true work = Queue.new Vamp.on_document { |event| work << ["document", event] } Vamp.command "fixture_definition" do |editor, _| raise "No target reference" unless editor.text.include?("TARGET") work << ["definition", editor.buffer_id, editor.revision, editor.request_id] end Thread.new do documents = {} loop do kind, *args = work.pop if kind == "document" event = args[0] document = event.fetch("document") if event.fetch("event") == "close" documents.delete(document.fetch("id")) next end documents[document.fetch("id")] = document next unless ["open", "change"].include?(event.fetch("event")) items = [] document.fetch("text").split("\n", -1).each_with_index do |line, row| graphemes = line.each_grapheme_cluster.to_a graphemes.each_cons(6).with_index do |token, column| next unless token.join == "broken" items << {start: {row: row, column: column}, end: {row: row, column: column + 6}, severity: "error", message: "Unresolved token"} end end Vamp.publish([{type: "diagnostics", buffer_id: document.fetch("id"), revision: document.fetch("revision"), items: items}, {type: "message", text: "diagnostic revision #{document.fetch('revision')}"}]) else target = documents.values.find { |document| document["path"] == Vamp.options.fetch("target") } raise "Target document is not open" unless target lines = target.fetch("text").split("\n", -1) row = lines.index { |line| line.include?("TARGET") } raise "Target definition missing" unless row column = lines[row].each_grapheme_cluster.to_a.each_cons(6).find_index { |token| token.join == "TARGET" } Vamp.publish([{type: "navigate", buffer_id: args[0], revision: args[1], path: target.fetch("path"), row: row, column: column}], request_id: args[2]) end end end "#, ); let config = directory.file( "config.rb", &format!( "Vamp.configure do |config|\n config.clipboard_mode = \"separate\"\n config.plugin \"semantic.rb\", options: {{target: {}}}\nend\n", serde_json::to_string(&target.to_string_lossy()).unwrap(), ), ); let mut session = EditorSession::open( Some(source), CAPABILITIES .iter() .map(|capability| (*capability).into()) .collect(), Some(&config), ) .unwrap(); let source_id = session.editor().buffer_id(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(18, 6)).unwrap(); let mut viewports = BufferViewports::default(); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); fn poll_until( session: &mut EditorSession, terminal: &mut Terminal, viewports: &mut BufferViewports, ready: impl Fn(&Editor) -> bool, ) { for _ in 0..2_000 { if session.poll() { terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); } if ready(session.editor()) { return; } std::thread::sleep(Duration::from_millis(1)); } panic!( "async fixture did not update editor: {}", session.editor().message() ); } session.handle(Input::Char('A')); session.handle(Input::Char('!')); assert_eq!(session.editor().mode(), Mode::Insert); assert_eq!(session.editor().lines(), &["broken TARGET!"]); poll_until(&mut session, &mut terminal, &mut viewports, |editor| { editor.message() == "diagnostic revision 1" }); assert_eq!(session.editor().diagnostics().count(), 0); assert!(!screen_text(&terminal).contains("E:1")); assert!( !terminal.backend().buffer()[(2, 0)] .modifier .contains(Modifier::UNDERLINED) ); assert_eq!(session.editor().mode(), Mode::Insert); session.handle(Input::Escape); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); assert!(screen_text(&terminal).contains("E:1")); assert!( terminal.backend().buffer()[(2, 0)] .modifier .contains(Modifier::UNDERLINED) ); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), &["broken TARGET"]); assert_eq!(session.editor().diagnostics().count(), 0); session.execute(&format!("edit {}", target.display())); let target_id = session.editor().buffer_id(); session.handle(Input::Char('i')); session.handle(Input::Char('!')); session.handle(Input::Escape); session.execute(&format!("buffer {source_id}")); session.execute("fixture_definition"); session.handle(Input::Char('l')); assert_eq!(session.editor().buffer_id(), source_id); poll_until(&mut session, &mut terminal, &mut viewports, |editor| { editor.buffer_id() == target_id }); assert_eq!(session.editor().buffer_count(), 2); assert!(session.editor().dirty()); assert_eq!(session.editor().lines()[0], "!# line"); assert_eq!(session.editor().cursor().row, 15); assert_eq!(session.editor().cursor().column, 40); assert_eq!(session.editor().mode(), Mode::Normal); assert_eq!(viewports.offsets[&target_id], (12, 26)); let cursor = terminal.get_cursor_position().unwrap(); assert_eq!( terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), "T" ); assert_eq!( terminal.backend().buffer()[(cursor.x, cursor.y)].fg, Color::DarkGray ); assert_eq!(fs::read_to_string(target).unwrap(), target_text); } #[test] fn syntax_tokens_refresh_after_native_edits_and_undo_without_changing_text_on_draw() { let directory = Directory::new(); let path = directory.file("number.rs", "42"); let mut editor = Editor::open(Some(path), vec![]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap(); let mut viewports = BufferViewports::default(); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow); let revision = editor.text_revision(); editor.handle(Input::Right); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert_eq!(editor.text_revision(), revision); assert_eq!(editor.lines(), &["42"]); assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow); editor.handle(Input::Home); editor.handle(Input::Char('v')); editor.handle(Input::Right); editor.handle(Input::Char('c')); for ch in "\"ok\"".chars() { editor.handle(Input::Char(ch)); } editor.handle(Input::Escape); assert_eq!(editor.lines(), &["\"ok\""]); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Green); assert!(editor.text_revision() > revision); editor.handle(Input::Char('u')); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert_eq!(editor.lines(), &["42"]); assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow); editor .apply(vec![crate::editor::Effect::Replace( "fn main() { let n = 42; let s = \"ok\"; } // note".into(), )]) .unwrap(); editor.handle(Input::Home); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); let buffer = terminal.backend().buffer(); let source = &editor.lines()[0]; for (token, color) in [ ("fn", Color::Magenta), ("main", Color::LightBlue), ("let", Color::Magenta), ("42", Color::Yellow), ("\"ok\"", Color::Green), ] { let x = 2 + source.find(token).unwrap() as u16; assert_eq!(buffer[(x, 0)].fg, color, "{token}"); assert_eq!(buffer[(x, 0)].bg, Color::Reset); } } #[test] fn syntax_continuations_above_viewport_refresh_when_the_opening_line_changes() { let directory = Directory::new(); for (name, before, after, color) in [ ( "comment.rs", "/*\nhidden\nvisible\n*/", "//\nhidden\nvisible\n//", Color::DarkGray, ), ( "string.py", "text = \"\"\"\nhidden\nvisible\n\"\"\"", "text = \"\"\nhidden\nvisible\n\"\"", Color::Green, ), ] { let path = directory.file(name, before); let mut editor = Editor::open(Some(path), vec![]).unwrap(); editor.handle(Input::Down); editor.handle(Input::Down); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(30, 3)).unwrap(); let mut viewports = BufferViewports::default(); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert_eq!(viewports.offsets[&editor.buffer_id()].0, 2); assert_eq!(terminal.backend().buffer()[(2, 0)].symbol(), "v"); assert_eq!(terminal.backend().buffer()[(2, 0)].fg, color, "{name}"); editor .apply(vec![crate::editor::Effect::Replace(after.into())]) .unwrap(); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert_eq!(terminal.backend().buffer()[(2, 0)].symbol(), "v"); assert_eq!( terminal.backend().buffer()[(2, 0)].fg, Color::Reset, "{name}" ); editor.handle(Input::Char('u')); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert_eq!(terminal.backend().buffer()[(2, 0)].fg, color, "{name}"); } } #[test] fn semantic_roles_remain_distinct_when_values_and_fields_are_readonly() { let mut editor = Editor::open(None, vec![]).unwrap(); editor .apply(vec![crate::editor::Effect::Replace( "ns Type local frozen field fixed param frozenparam".into(), )]) .unwrap(); let readonly = SemanticTokenModifier::Readonly as u16; editor .replace_semantic_tokens( editor.buffer_id(), editor.text_revision(), Some(Arc::from([ semantic_token(0, 0, 2, SemanticTokenKind::Namespace, 0), semantic_token(0, 3, 7, SemanticTokenKind::Type, 0), semantic_token(0, 8, 13, SemanticTokenKind::Variable, 0), semantic_token(0, 14, 20, SemanticTokenKind::Variable, readonly), semantic_token(0, 21, 26, SemanticTokenKind::Property, 0), semantic_token(0, 27, 32, SemanticTokenKind::Property, readonly), semantic_token(0, 33, 38, SemanticTokenKind::Parameter, 0), semantic_token(0, 39, 50, SemanticTokenKind::Parameter, readonly), ])), ) .unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 4)).unwrap(); terminal .draw(|frame| BufferViewports::default().draw(frame, &editor)) .unwrap(); let buffer = terminal.backend().buffer(); let foregrounds = [0, 3, 8, 14, 21, 27, 33, 39].map(|column| buffer[(column + 2, 0)].fg); assert_ne!(foregrounds[0], foregrounds[1]); assert_ne!(foregrounds[1], foregrounds[2]); assert_ne!(foregrounds[2], foregrounds[4]); assert_eq!(foregrounds[2], foregrounds[3]); assert_eq!(foregrounds[4], foregrounds[5]); assert_eq!(foregrounds[6], foregrounds[7]); for (column, is_readonly) in [ (0, false), (3, false), (8, false), (14, true), (21, false), (27, true), (33, false), (39, true), ] { assert_eq!( buffer[(column + 2, 0)].modifier.contains(Modifier::ITALIC), is_readonly, ); } } #[test] fn semantic_colors_modifiers_and_syntax_fallback_cover_whole_graphemes_and_clipped_tabs() { let directory = Directory::new(); let source = "#a\t界e\u{301}👩‍🚀z"; let mut editor = Editor::open(Some(directory.file("unicode.py", source)), vec![]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); editor .replace_semantic_tokens( editor.buffer_id(), editor.text_revision(), Some(Arc::from([ semantic_token( 0, 0, 1, SemanticTokenKind::Comment, SemanticTokenModifier::Documentation as u16, ), semantic_token( 0, 2, 6, SemanticTokenKind::Property, SemanticTokenModifier::Readonly as u16 | SemanticTokenModifier::Definition as u16 | SemanticTokenModifier::Deprecated as u16, ), semantic_token(0, 2, 3, SemanticTokenKind::Variable, 0), ])), ) .unwrap(); editor .replace_diagnostics( "fixture", editor.buffer_id(), editor.text_revision(), vec![annotation( (0, 2), (0, 6), DiagnosticSeverity::Warning, "semantic", )], ) .unwrap(); let mut syntax = SyntaxCache::new(); for (offset, width, caret, coverage) in [ (2, 5, 4, vec![true; 5]), (5, 2, 4, vec![false, true]), (7, 2, 5, vec![true; 2]), (8, 2, 6, vec![false; 2]), (0, 5, 2, vec![false, false, true, true, false]), ] { editor.handle(Input::Home); for _ in 0..caret { editor.handle(Input::Right); } let area = Rect::new(3, 2, width + 2, 4); let mut terminal = Terminal::with_options( ratatui::backend::TestBackend::new(20, 8), ratatui::TerminalOptions { viewport: ratatui::Viewport::Fixed(area), }, ) .unwrap(); let (mut top, mut left) = (0, offset); let rendered = terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .unwrap() .buffer .clone(); assert_eq!(left, offset); let text_area = Rect::new(area.x + 2, area.y, width, 1); let mut expected = ratatui::buffer::Buffer::empty(text_area); use ratatui::widgets::Widget; Paragraph::new(visible_line(source, offset, usize::from(width))) .render(text_area, &mut expected); let buffer = terminal.backend().buffer(); for (cell, semantic_cell) in coverage.into_iter().enumerate() { let position = (text_area.x + cell as u16, text_area.y); assert_eq!(buffer[position].symbol(), expected[position].symbol()); assert_eq!(rendered[position].bg, Color::Reset); assert_eq!( rendered[position].modifier.contains(Modifier::BOLD), semantic_cell ); assert_eq!( rendered[position].modifier.contains(Modifier::CROSSED_OUT), semantic_cell ); assert_eq!( rendered[position].modifier.contains(Modifier::UNDERLINED), semantic_cell ); if semantic_cell { assert_eq!(rendered[position].underline_color, Color::Yellow); } assert_eq!( rendered[position].modifier.contains(Modifier::ITALIC), semantic_cell || (offset == 0 && cell == 0) ); } assert_eq!(buffer[(area.x, area.y)].fg, Color::Yellow); assert_eq!(buffer[(0, 0)].fg, Color::Reset); assert_eq!(editor.lines(), &[source]); } editor.handle(Input::Char('x')); assert!(editor.semantic_tokens().is_empty()); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 4)).unwrap(); let (mut top, mut left) = (0, 0); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .unwrap(); assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::DarkGray); assert!( !terminal.backend().buffer()[(2, 0)] .modifier .contains(Modifier::ITALIC) ); } #[test] fn search_visual_selection_and_secondary_carets_override_semantic_foreground() { let directory = Directory::new(); let mut editor = Editor::open( Some(directory.file("overlays.py", "# word\n# word\n# word")), vec![], ) .unwrap(); editor .replace_semantic_tokens( editor.buffer_id(), editor.text_revision(), Some(Arc::from( (0..3) .map(|row| { semantic_token( row, 2, 6, SemanticTokenKind::Function, SemanticTokenModifier::Declaration as u16, ) }) .collect::>(), )), ) .unwrap(); editor .replace_diagnostics( "fixture", editor.buffer_id(), editor.text_revision(), vec![annotation( (0, 2), (2, 6), DiagnosticSeverity::Error, "overlays", )], ) .unwrap(); editor.handle(Input::Char('/')); for ch in "word".chars() { editor.handle(Input::Char(ch)); } editor.handle(Input::Enter); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 6)).unwrap(); let mut viewports = BufferViewports::default(); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); let found = editor.search_match().unwrap(); let y = found.start.row as u16; for x in 4..8 { assert_eq!(terminal.backend().buffer()[(x, y)].fg, Color::Black); assert_eq!(terminal.backend().buffer()[(x, y)].bg, Color::Yellow); assert!( terminal.backend().buffer()[(x, y)] .modifier .contains(Modifier::UNDERLINED) ); } editor.handle(Input::Char('v')); editor.handle(Input::Char('e')); editor.handle(Input::Ctrl('a')); assert_eq!(editor.selection_count(), 3); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); let buffer = terminal.backend().buffer(); for selection in editor.selections() { let y = selection.start.row as u16; let start = display_column(&editor.lines()[selection.start.row], selection.start.column); let end = display_column(&editor.lines()[selection.end.row], selection.end.column); for column in start..end { let x = 2 + column as u16; if buffer[(x, y)].bg == Color::Magenta { assert_eq!(buffer[(x, y)].fg, Color::Black); } else { assert_eq!(buffer[(x, y)].fg, Color::White); assert_eq!(buffer[(x, y)].bg, Color::Blue); } assert!(buffer[(x, y)].modifier.contains(Modifier::UNDERLINED)); } assert_eq!(buffer[(2, y)].fg, Color::DarkGray); } for caret in editor .carets() .into_iter() .filter(|caret| *caret != editor.cursor()) { let x = 2 + display_column(&editor.lines()[caret.row], caret.column) as u16; assert_eq!(buffer[(x, caret.row as u16)].fg, Color::Black); assert_eq!(buffer[(x, caret.row as u16)].bg, Color::Magenta); assert!( buffer[(x, caret.row as u16)] .modifier .contains(Modifier::UNDERLINED) ); } } #[test] fn syntax_redetects_saved_paths_without_text_changes_and_plain_text_keeps_default_color() { let directory = Directory::new(); let mut editor = Editor::open(Some(directory.file("source.rs", "let value = 42;")), vec![]).unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap(); let mut viewports = BufferViewports::default(); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Magenta); let revision = editor.text_revision(); for name in ["source.vampunknown", "source.txt", "again.rs"] { editor.execute(&format!("w {}", directory.0.join(name).display())); assert_eq!(editor.text_revision(), revision); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); if name.ends_with(".rs") { assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Magenta); assert_eq!( viewports.syntax[&editor.buffer_id()].language(), Some("Rust") ); } else { assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Reset); assert!(viewports.syntax[&editor.buffer_id()].line(0).is_empty()); assert!(viewports.syntax[&editor.buffer_id()].language().is_none()); } assert_eq!(editor.lines(), &["let value = 42;"]); } } #[test] fn syntax_caches_stay_independent_across_buffer_edits_and_retire_closed_ids() { let directory = Directory::new(); let mut editor = Editor::open(Some(directory.file("a.rs", "42")), vec![]).unwrap(); let a_id = editor.buffer_id(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap(); let mut viewports = BufferViewports::default(); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow); editor.execute(&format!( "edit {}", directory.file("b.py", "# note").display() )); let b_id = editor.buffer_id(); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::DarkGray); editor .apply(vec![crate::editor::Effect::Replace("\"ok\"".into())]) .unwrap(); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Green); editor.execute(&format!("buffer {a_id}")); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow); assert_eq!(viewports.syntax[&a_id].language(), Some("Rust")); assert_eq!(viewports.syntax[&b_id].language(), Some("Python")); editor.execute(&format!("buffer {b_id}")); editor.execute(&format!("bdelete {a_id}")); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Green); assert!(!viewports.syntax.contains_key(&a_id)); assert!(!viewports.offsets.contains_key(&a_id)); assert_eq!(viewports.syntax.len(), 1); } #[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::Char('h'), KeyModifiers::CONTROL, Some(Input::Ctrl('h')), ), ( KeyCode::Char('l'), KeyModifiers::CONTROL, Some(Input::Ctrl('l')), ), ( KeyCode::Backspace, KeyModifiers::NONE, Some(Input::Backspace), ), (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 wheel_routes_to_floating_and_unfocused_docked_files_without_changing_selection() { let directory = Directory::new(); let config = directory.file( "config.rb", "Vamp.configure do |config|\n config.file_picker_view = 'list'\n config.file_explorer_view = 'list'\nend\n", ); for (key, focused) in [('o', true), ('e', true), ('e', false)] { let mut session = EditorSession::open(None, vec![], Some(&config)).unwrap(); session.handle(Input::Char(' ')); session.handle(Input::Char(key)); if !focused { session.handle(Input::Char('\t')); } let picker = session.editor().file_picker().unwrap(); assert!(picker.entries().len() > 5); let selected = picker.selected; let selected_path = picker.entries().nth(selected).unwrap().path.to_owned(); let query = picker.query.clone(); let carets = session.editor().carets(); let revision = session.editor().text_revision(); let mut panels = WheelPanels::default(); let area = Rect::new(3, 2, 40, 6); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 12)).unwrap(); terminal .draw(|frame| draw_file_list(frame, picker, area, "", &mut panels, None)) .unwrap(); let mouse = MouseEvent { kind: MouseEventKind::ScrollDown, column: area.x, row: area.y, modifiers: KeyModifiers::NONE, }; assert!(!panels.scroll( &mut session, MouseEvent { column: 0, row: 0, ..mouse } )); assert!(panels.scroll(&mut session, mouse)); assert_eq!(panels.manual_files.as_ref().unwrap().top, 3); terminal .draw(|frame| { panels.begin_frame(); draw_file_list( frame, session.editor().file_picker().unwrap(), area, "", &mut panels, None, ); }) .unwrap(); let picker = session.editor().file_picker().unwrap(); let text = screen_text(&terminal); assert!( text.contains(picker.entries().nth(3).unwrap().label), "{text}" ); assert!( !text.contains(&format!( "> {}", picker.entries().nth(selected).unwrap().label )), "{text}" ); assert_eq!(picker.selected, selected); assert_eq!(picker.entries().nth(selected).unwrap().path, selected_path); assert_eq!(picker.query, query); assert_eq!(picker.focused, focused); assert_eq!(session.editor().carets(), carets); assert_eq!(session.editor().text_revision(), revision); let entry_count = picker.entries().len(); assert!(!panels.scroll( &mut session, MouseEvent { kind: MouseEventKind::Moved, ..mouse } )); for _ in 0..entry_count { assert!(panels.scroll(&mut session, mouse)); } let (_, height, _) = panels.files.unwrap(); assert_eq!( panels.manual_files.as_ref().unwrap().top, session.editor().file_picker().unwrap().entries().len() - height ); panels.follow_files(); terminal .draw(|frame| { draw_file_list( frame, session.editor().file_picker().unwrap(), area, "", &mut panels, None, ) }) .unwrap(); assert_eq!(panels.files.unwrap().2, 0); session.handle(Input::Ctrl('p')); assert!(session.editor().palette().is_some()); assert!(panels.scroll(&mut session, mouse)); assert!(panels.manual_files.is_none()); session.handle(Input::Escape); session.handle(Input::Char(' ')); session.handle(Input::Char(key)); session.handle(Input::Ctrl('w')); assert!(session.editor().file_picker().is_none()); assert!(!panels.scroll(&mut session, mouse)); } } #[test] fn manually_scrolled_tree_does_not_place_cursor_or_highlight_offscreen_selection() { let directory = Directory::new(); for index in 0..12 { directory.file(&format!("file-{index:02}"), ""); } let mut picker = FilePicker::scan( directory.0.clone(), FilePresentation::Docked, FileView::Tree, ) .unwrap(); picker.selected = 11; let mut panels = WheelPanels { manual_files: Some(FileWheelOffset { top: 0, root: picker.root.clone(), query: picker.query.clone(), state: FileWheelOffset::state(&picker), }), ..WheelPanels::default() }; let area = Rect::new(3, 2, 40, 6); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 12)).unwrap(); terminal .draw(|frame| { frame.set_cursor_position((59, 11)); draw_file_list(frame, &picker, area, "", &mut panels, None); }) .unwrap(); let text = screen_text(&terminal); assert!(text.contains("file-00"), "{text}"); assert!(!text.contains("file-11"), "{text}"); assert_eq!( terminal.get_cursor_position().unwrap(), ratatui::layout::Position::new(59, 11) ); for y in area.y..area.bottom() { for x in area.x..area.right() { assert_ne!(terminal.backend().buffer()[(x, y)].bg, Color::Blue); } } picker.navigate(false); terminal .draw(|frame| draw_file_list(frame, &picker, area, "", &mut panels, None)) .unwrap(); assert!(panels.manual_files.is_none()); assert!(screen_text(&terminal).contains("> file-10")); } #[test] fn file_list_window_follows_control_j_k_selection() { let mut editor = Editor::open(None, vec![]).unwrap(); editor.set_file_picker_view("list").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; let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 9)).unwrap(); terminal .draw(|frame| { draw_file_list( frame, picker, frame.area(), editor.file_navigation_hint(), &mut WheelPanels::default(), None, ) }) .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(); editor.set_file_explorer_view("list").unwrap(); editor.set_file_picker_view("list").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); let mut syntax = SyntaxCache::new(); for key in ['o', 'e'] { editor.handle(Input::Char(' ')); editor.handle(Input::Char(key)); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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, FileView::List, ) .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; 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(), &mut WheelPanels::default(), None, ) }) .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, FileView::List, ) .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(), &mut WheelPanels::default(), None, ) }) .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(), &mut WheelPanels::default(), None, ); }) .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(), &mut WheelPanels::default(), None, ) }) .unwrap(); assert!(screen_text(&terminal).contains("Files")); assert!(screen_text(&terminal).contains("Esc/^W")); assert_eq!(terminal.backend().buffer()[(0, 0)].fg, Color::Cyan); picker.set_view(FileView::Tree); picker.finish_search(); terminal .draw(|frame| { draw_file_list( frame, &picker, area, editor.file_navigation_hint(), &mut WheelPanels::default(), None, ) }) .unwrap(); let text = screen_text(&terminal); assert!(text.contains("No matching files"), "{text}"); assert!(text.contains("filter: missing"), "{text}"); assert_eq!( terminal.get_cursor_position().unwrap(), ratatui::layout::Position::new(1, 1) ); picker.focused = false; terminal .draw(|frame| { frame.set_cursor_position((99, 11)); draw_file_list( frame, &picker, area, editor.file_navigation_hint(), &mut WheelPanels::default(), None, ); }) .unwrap(); assert_eq!( terminal.get_cursor_position().unwrap(), ratatui::layout::Position::new(99, 11) ); } #[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, FileView::List, ) .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(), &mut WheelPanels::default(), None, ) }) .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(), &mut WheelPanels::default(), None, ) }) .unwrap(); assert!( dock_layout(Rect::new(3, 2, width, height), DockSide::Right, true) .1 .is_none() ); } } #[test] fn tree_rows_render_directory_chains_hierarchy_and_basename_files_in_both_presentations() { use std::path::Path; let directory = Directory::new(); directory.file("chain/a/b/leaf.txt", ""); directory.file("mixed/nested/inner.txt", ""); directory.file("mixed/sibling.txt", ""); directory.file("single/only.txt", ""); directory.file("root.txt", ""); fs::create_dir(directory.0.join("empty")).unwrap(); let editor = Editor::open(None, vec![]).unwrap(); for presentation in [FilePresentation::Floating, FilePresentation::Docked] { let mut picker = FilePicker::scan(directory.0.clone(), presentation, FileView::Tree).unwrap(); assert_eq!(picker.entries().next().unwrap().label, "chain/a/b"); for path in ["chain/a/b", "mixed", "mixed/nested", "single"] { let selected = picker .entries() .position(|row| row.path == Path::new(path)) .unwrap(); picker.selected = selected; picker.right(); } let area = Rect::new(3, 2, 100, 22); let mut terminal = Terminal::with_options( ratatui::backend::TestBackend::new(110, 28), ratatui::TerminalOptions { viewport: ratatui::Viewport::Fixed(area), }, ) .unwrap(); terminal .draw(|frame| { draw_file_list( frame, &picker, area, editor.file_navigation_hint(), &mut WheelPanels::default(), None, ) }) .unwrap(); let text = screen_text(&terminal); for label in [ " chain/a/b/", " leaf.txt", " empty/", " mixed/", " nested/", " inner.txt", " sibling.txt", "> single/", " only.txt", " root.txt", ] { assert!(text.contains(label), "{label}: {text}"); } assert!(!text.contains('▸') && !text.contains('▾'), "{text}"); let buffer = terminal.backend().buffer(); for (index, entry) in picker.entries().enumerate() { let y = area.y + 3 + index as u16; assert_eq!( buffer[(area.x + 1, y)].fg, if entry.is_dir { Color::Cyan } else { Color::Reset }, ); } assert!(!text.contains("single/only.txt"), "{text}"); assert!(!text.contains("mixed/nested/inner.txt"), "{text}"); let cursor = terminal.get_cursor_position().unwrap(); assert_eq!( terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), ">" ); assert_eq!( terminal.backend().buffer()[(cursor.x, cursor.y)].bg, Color::Blue ); picker.set_view(FileView::List); terminal .draw(|frame| { draw_file_list( frame, &picker, area, editor.file_navigation_hint(), &mut WheelPanels::default(), None, ) }) .unwrap(); let text = screen_text(&terminal); assert!(text.contains("chain/a/b/leaf.txt"), "{text}"); assert!(text.contains("single/only.txt"), "{text}"); assert!(!text.contains("▸"), "{text}"); assert_eq!(terminal.get_cursor_position().unwrap().y, area.y + 1); } } #[test] fn tree_browse_cursor_tracks_window_and_bounds_empty_search_and_tiny_offset_panels() { let directory = Directory::new(); for index in 0..18 { directory.file(&format!("dir-{index:02}/file.txt"), ""); } let editor = Editor::open(None, vec![]).unwrap(); let mut picker = FilePicker::scan( directory.0.clone(), FilePresentation::Docked, FileView::Tree, ) .unwrap(); for _ in 0..17 { picker.navigate(true); } picker.right(); picker.right(); let long_query = "long界e\u{301}z".repeat(20); for (width, height) in [(100, 9), (12, 6), (8, 2), (1, 1), (1, 2), (8, 1), (100, 9)] { let area = Rect::new(3, 2, width, height); let mut terminal = Terminal::with_options( ratatui::backend::TestBackend::new(110, 16), ratatui::TerminalOptions { viewport: ratatui::Viewport::Fixed(area), }, ) .unwrap(); terminal .draw(|frame| { draw_file_list( frame, &picker, area, editor.file_navigation_hint(), &mut WheelPanels::default(), None, ) }) .unwrap(); let cursor = terminal.get_cursor_position().unwrap(); assert!(area.contains(cursor), "{area:?}: {cursor:?}"); let has_results = height > if width >= 4 && height >= 4 { 3 } else { 1 }; if has_results { assert_eq!( terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), ">" ); } else { assert_eq!(cursor, ratatui::layout::Position::new(area.x, area.y)); } if width == 100 { let text = screen_text(&terminal); assert!(text.contains("> file.txt"), "{text}"); assert!(!text.contains("dir-00"), "{text}"); } for query in ["missing", long_query.as_str()] { picker.start_search(); picker.query = query.into(); picker.refilter(); terminal .draw(|frame| { draw_file_list( frame, &picker, area, editor.file_navigation_hint(), &mut WheelPanels::default(), None, ) }) .unwrap(); let cursor = terminal.get_cursor_position().unwrap(); assert!(area.contains(cursor), "{area:?}: {cursor:?}"); assert_eq!(cursor.y, area.y + u16::from(width >= 4 && height >= 4)); picker.finish_search(); assert_eq!(picker.query, query); assert_eq!(picker.entries().len(), 0); terminal .draw(|frame| { draw_file_list( frame, &picker, area, editor.file_navigation_hint(), &mut WheelPanels::default(), None, ) }) .unwrap(); let cursor = terminal.get_cursor_position().unwrap(); assert!(area.contains(cursor), "{area:?}: {cursor:?}"); let query_x = area.x + u16::from(width >= 4 && height >= 4); assert_eq!(cursor.x, query_x); let query_y = area.y + u16::from(width >= 4 && height >= 4); let query_width = width - 2 * u16::from(width >= 4 && height >= 4); let header = format!("filter: {query} · ^U clear"); let mut expected = ratatui::buffer::Buffer::empty(Rect::new(query_x, query_y, query_width, 1)); use ratatui::widgets::Widget; Paragraph::new(visible_line(&header, 0, usize::from(query_width))) .render(expected.area, &mut expected); let mut continuation = 0; for x in query_x..query_x + query_width { if continuation > 0 { continuation -= 1; continue; } continuation = expected[(x, query_y)].symbol().width().saturating_sub(1); assert_eq!( terminal.backend().buffer()[(x, query_y)].symbol(), expected[(x, query_y)].symbol() ); } if width == 100 { assert!(screen_text(&terminal).contains("filter: "), "{query}"); assert!( screen_text(&terminal).contains("No matching files"), "{query}" ); } picker.clear_filter(); assert!(!picker.searching); assert_eq!( picker.entries().nth(picker.selected).unwrap().path, std::path::Path::new("dir-17/file.txt") ); } } } #[test] fn tree_filter_escape_resume_and_clear_keep_selection_and_cursor_ownership() { use std::path::Path; let directory = Directory::new(); directory.file("src/inner/needle.txt", ""); directory.file("src/other.txt", ""); directory.file("root.txt", ""); for presentation in [FilePresentation::Floating, FilePresentation::Docked] { let mut editor = Editor::open(None, vec![]).unwrap(); editor.set_project_root(directory.0.clone()); editor.set_file_picker_view("tree").unwrap(); editor .apply(vec![crate::editor::Effect::Replace("unchanged".into())]) .unwrap(); editor.handle(Input::Char('v')); let mode = editor.mode(); let selections = editor.selections(); editor.handle(Input::Char(' ')); editor.handle(Input::Char(if presentation == FilePresentation::Floating { 'o' } else { 'e' })); if !editor.file_picker().unwrap().searching { editor.handle(Input::Char('i')); } let query = "src/inner/needle.txt"; for ch in query.chars() { editor.handle(Input::Char(ch)); } editor.handle(Input::Ctrl('h')); let picker = editor.file_picker().unwrap(); assert_eq!(picker.query, query); assert_eq!( picker.entries().nth(picker.selected).unwrap().path, Path::new("src/inner") ); editor.handle(Input::Ctrl('l')); let picker = editor.file_picker().unwrap(); assert_eq!( picker.entries().nth(picker.selected).unwrap().path, Path::new(query) ); let filtered: Vec<_> = picker .entries() .map(|row| (row.path.to_owned(), row.expanded)) .collect(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); let (mut top, mut left) = (0, 0); let mut syntax = SyntaxCache::new(); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .unwrap(); let query_cursor = terminal.get_cursor_position().unwrap(); assert!(screen_text(&terminal).contains(&format!("> {query}"))); assert!(screen_text(&terminal).contains("^U clear")); editor.handle(Input::Escape); let picker = editor.file_picker().unwrap(); assert!(picker.focused && !picker.searching); assert_eq!(picker.query, query); assert_eq!( picker.entries().nth(picker.selected).unwrap().path, Path::new(query) ); assert_eq!( picker .entries() .map(|row| (row.path.to_owned(), row.expanded)) .collect::>(), filtered ); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .unwrap(); let text = screen_text(&terminal); assert!(text.contains(&format!("filter: {query}")), "{text}"); assert!( text.contains("needle.txt") && !text.contains("other.txt"), "{text}" ); let selected_cursor = terminal.get_cursor_position().unwrap(); assert_ne!(selected_cursor.y, query_cursor.y); assert_eq!( terminal.backend().buffer()[(selected_cursor.x, selected_cursor.y)].symbol(), ">" ); assert_eq!( terminal.backend().buffer()[(selected_cursor.x, selected_cursor.y)].bg, Color::Blue ); editor.handle(Input::Char('i')); assert_eq!(editor.file_picker().unwrap().query, query); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .unwrap(); assert_eq!(terminal.get_cursor_position().unwrap(), query_cursor); editor.handle(Input::Escape); editor.handle(Input::Ctrl('u')); let picker = editor.file_picker().unwrap(); assert!(picker.focused && !picker.searching && picker.query.is_empty()); assert_eq!( picker.entries().nth(picker.selected).unwrap().path, Path::new(query) ); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .unwrap(); let cursor = terminal.get_cursor_position().unwrap(); assert_eq!( terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), ">" ); assert!(screen_text(&terminal).contains("other.txt")); editor.handle(Input::Char('i')); for ch in query.chars() { editor.handle(Input::Char(ch)); } editor.handle(Input::Ctrl('u')); let picker = editor.file_picker().unwrap(); assert!(picker.focused && picker.searching && picker.query.is_empty()); assert_eq!( picker.entries().nth(picker.selected).unwrap().path, Path::new(query) ); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .unwrap(); let cursor = terminal.get_cursor_position().unwrap(); assert_eq!(cursor.y, query_cursor.y); assert_eq!(cursor.x, query_cursor.x - query.len() as u16); editor.handle(Input::Ctrl('t')); for ch in query.chars() { editor.handle(Input::Char(ch)); } editor.handle(Input::Ctrl('u')); let picker = editor.file_picker().unwrap(); assert_eq!(picker.view, FileView::List); assert!(picker.focused && picker.query.is_empty()); assert_eq!( picker.entries().nth(picker.selected).unwrap().path, Path::new(query) ); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .unwrap(); assert_eq!(terminal.get_cursor_position().unwrap(), cursor); editor.handle(Input::Ctrl('t')); editor.handle(Input::Escape); editor.handle(Input::Escape); if presentation == FilePresentation::Floating { assert!(editor.file_picker().is_none()); } else { assert!(!editor.file_picker().unwrap().focused); } assert_eq!(editor.mode(), mode); assert_eq!(editor.selections(), selections); assert_eq!(editor.lines(), ["unchanged"]); } } #[test] fn explorer_retains_insert_filter_through_focus_and_view_switches() { let directory = Directory::new(); directory.file("src/inner/needle.txt", ""); directory.file("src/other.txt", ""); let mut editor = Editor::open(None, vec![]).unwrap(); editor.set_project_root(directory.0.clone()); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); let (mut top, mut left) = (0, 0); let mut syntax = SyntaxCache::new(); editor.handle(Input::Char(' ')); editor.handle(Input::Char('e')); editor.handle(Input::Char('i')); editor.handle(Input::Char('n')); editor.handle(Input::Char('\t')); assert!(!editor.file_picker().unwrap().focused); editor.handle(Input::Char(' ')); editor.handle(Input::Char('e')); assert!(editor.file_picker().unwrap().searching); assert_eq!(editor.file_picker().unwrap().query, "n"); editor.handle(Input::Ctrl('t')); assert_eq!(editor.file_picker().unwrap().view, FileView::List); assert_eq!(editor.file_picker().unwrap().query, "n"); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .unwrap(); assert!(screen_text(&terminal).contains("List")); assert_eq!(terminal.get_cursor_position().unwrap().y, 1); editor.handle(Input::Ctrl('t')); assert_eq!(editor.file_picker().unwrap().view, FileView::Tree); assert!(editor.file_picker().unwrap().searching); assert_eq!(editor.file_picker().unwrap().query, "n"); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .unwrap(); assert_eq!(terminal.get_cursor_position().unwrap().y, 1); editor.handle(Input::Escape); assert!(editor.file_picker().unwrap().focused); assert!(!editor.file_picker().unwrap().searching); assert_eq!(editor.file_picker().unwrap().query, "n"); editor.handle(Input::Ctrl('t')); editor.handle(Input::Ctrl('t')); assert!(!editor.file_picker().unwrap().searching); assert_eq!(editor.file_picker().unwrap().query, "n"); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .unwrap(); assert!(screen_text(&terminal).contains("filter: n")); let cursor = terminal.get_cursor_position().unwrap(); assert_eq!( terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), ">" ); } #[test] fn configured_views_and_view_toggle_render_in_both_presentations_with_scoped_hints() { let directory = Directory::new(); directory.file("folder/file.txt", ""); let mut editor = Editor::open(None, vec![]).unwrap(); editor.set_project_root(directory.0.clone()); editor.set_file_picker_view("tree").unwrap(); editor.set_file_explorer_view("list").unwrap(); editor.set_file_explorer_side("bottom").unwrap(); editor.bind("tree", "ctrl+l", None).unwrap(); editor.bind("tree", "right", None).unwrap(); editor.bind("tree", "x", Some("core.right".into())).unwrap(); for key in ["ctrl+j", "ctrl+n", "down", "ctrl+k", "up"] { editor.bind("selection", key, None).unwrap(); } editor .bind("selection", "j", Some("core.down".into())) .unwrap(); editor .bind("selection", "k", Some("core.up".into())) .unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(200, 30)).unwrap(); let (mut top, mut left) = (0, 0); let mut syntax = SyntaxCache::new(); for (key, expected) in [('o', FileView::Tree), ('e', FileView::List)] { editor.handle(Input::Char(' ')); editor.handle(Input::Char(key)); assert_eq!(editor.file_picker().unwrap().view, expected); if expected == FileView::Tree { assert!(editor.file_picker().unwrap().searching); editor.handle(Input::Escape); } for view in [ expected, if expected == FileView::Tree { FileView::List } else { FileView::Tree }, ] { assert_eq!(editor.file_picker().unwrap().view, view); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .unwrap(); let text = screen_text(&terminal); assert!(text.contains(editor.file_navigation_hint()), "{text}"); if view == FileView::Tree { assert_ne!( editor.file_navigation_hint(), editor.selection_navigation_hint() ); let cursor = terminal.get_cursor_position().unwrap(); assert_eq!( terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), ">" ); editor.handle(Input::Char('x')); assert_eq!(editor.file_picker().unwrap().query, ""); assert!( editor .file_picker() .unwrap() .entries() .next() .unwrap() .expanded ); let picker = editor.file_picker().unwrap(); let was_file = !picker.entries().nth(picker.selected).unwrap().is_dir; editor.handle(Input::Ctrl('h')); if was_file { let picker = editor.file_picker().unwrap(); assert!(picker.entries().nth(picker.selected).unwrap().is_dir); assert!(picker.entries().next().unwrap().expanded); editor.handle(Input::Ctrl('h')); } assert!( !editor .file_picker() .unwrap() .entries() .next() .unwrap() .expanded ); } else { assert_eq!( editor.file_navigation_hint(), editor.selection_navigation_hint() ); assert!(text.contains("folder/file.txt"), "{text}"); } editor.handle(Input::Ctrl('t')); } editor.handle(Input::Escape); } editor.handle(Input::Ctrl('p')); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .unwrap(); assert!(screen_text(&terminal).contains(editor.selection_navigation_hint())); } #[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); let mut syntax = SyntaxCache::new(); 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, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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); let mut syntax = SyntaxCache::new(); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .unwrap(); let before = (top, left); editor.handle(Input::Char(' ')); editor.handle(Input::Char('o')); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .unwrap(); let panel = Rect::new(area.x + 5, area.y + 1, 100, 24); 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 startup_diagnostic_scrolls_long_unicode_reasons_and_dismisses_without_editing() { let directory = Directory::new(); let source = directory.file("buffer.txt", "keep"); let config = directory.file("config.rb", "raise \"#{'x' * 160}界END\\nlast reason\""); let mut session = EditorSession::open(Some(source), vec!["text".into()], Some(&config)).unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(110, 26)).unwrap(); let mut viewports = BufferViewports::default(); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); let text = screen_text(&terminal); assert!(text.contains("Configuration error")); assert!(text.contains("config.rb")); assert!(text.contains("default settings and bindings")); assert!(text.contains("last reason")); assert!(!text.contains("界END")); for _ in 0..100 { session.handle(Input::Right); } terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); let scrolled = screen_text(&terminal); assert!( scrolled.contains("界") && scrolled.contains("END"), "{scrolled}" ); for (width, height) in [(4, 4), (1, 1)] { let mut tiny = Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); tiny.draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); } session.handle(Input::Enter); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); assert!(!screen_text(&terminal).contains("Configuration error")); assert_eq!(session.editor().lines(), &["keep"]); assert_eq!(session.editor().cursor(), crate::editor::Cursor::default()); } #[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); assert!(editor.file_picker().unwrap().focused); 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); let mut syntax = SyntaxCache::new(); 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, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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); let mut syntax = SyntaxCache::new(); editor.handle(Input::Char(' ')); editor.handle(Input::Char('b')); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .unwrap(); assert!(screen_text(&terminal).contains("leader: Space b")); editor.handle(Input::Char('x')); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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); let mut syntax = SyntaxCache::new(); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .unwrap(); assert!(screen_text(&terminal).contains("unbound")); assert!(!screen_text(&terminal).contains(", b n")); } fn document_wheel(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent { MouseEvent { kind, column, row, modifiers: KeyModifiers::NONE, } } #[test] fn wheel_scrolls_rendered_document_three_rows_without_changing_any_selection() { let directory = Directory::new(); let path = directory.file( "rows.txt", &(0..12) .map(|row| format!("abc row-{row:02}")) .collect::>() .join("\n"), ); let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); session.handle(Input::Char('v')); session.handle(Input::Char('e')); session.handle(Input::Ctrl('a')); assert_eq!(session.editor().selection_count(), 12); let carets = session.editor().carets(); let selections = session.editor().selections(); let cursor = session.editor().cursor(); let revision = session.editor().text_revision(); let text = session.editor().lines().to_vec(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 6)).unwrap(); let mut viewports = BufferViewports::default(); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); assert!(terminal.backend().cursor_visible()); for (kind, top) in [ (MouseEventKind::ScrollDown, 3), (MouseEventKind::ScrollDown, 6), (MouseEventKind::ScrollDown, 8), (MouseEventKind::ScrollDown, 8), (MouseEventKind::ScrollUp, 5), (MouseEventKind::ScrollUp, 2), (MouseEventKind::ScrollUp, 0), (MouseEventKind::ScrollUp, 0), ] { // The gutter belongs to the rendered document hit region too. assert!(viewports.scroll(&mut session, document_wheel(kind, 0, 0))); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); assert!( screen_text(&terminal) .lines() .next() .unwrap() .contains(&format!("row-{top:02}")) ); assert_eq!( terminal.backend().cursor_visible(), cursor.row >= top && cursor.row < top + 4 ); assert_eq!(session.editor().cursor(), cursor); assert_eq!(session.editor().carets(), carets); assert_eq!(session.editor().selections(), selections); assert_eq!(session.editor().text_revision(), revision); assert_eq!(session.editor().lines(), text); } assert!(!viewports.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, 0, 5) )); assert!(!viewports.scroll(&mut session, document_wheel(MouseEventKind::Moved, 0, 0))); assert!(viewports.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, 0, 0) )); session.handle(Input::Escape); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); assert!( screen_text(&terminal) .lines() .next() .unwrap() .contains("row-00") ); assert!(terminal.backend().cursor_visible()); } #[test] fn queued_wheel_input_renders_latest_view_without_crossing_key_or_resize_barriers() { let directory = Directory::new(); let text = (0..24) .map(|row| format!("row-{row:03}\n")) .collect::(); let path = directory.file("rows.txt", &text); let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 6)).unwrap(); let mut viewports = BufferViewports::default(); let down = Event::Mouse(document_wheel(MouseEventKind::ScrollDown, 1, 1)); let up = Event::Mouse(document_wheel(MouseEventKind::ScrollUp, 1, 1)); for barrier in [ Event::Key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)), Event::Resize(40, 12), ] { terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); let mut queued = [ down.clone(), up.clone(), down.clone(), barrier.clone(), down.clone(), ] .into_iter(); let (redraw, pending) = viewports .scroll_ready( &mut session, document_wheel(MouseEventKind::ScrollDown, 1, 1), || Ok(queued.next()), ) .unwrap(); assert!(redraw); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); assert!( screen_text(&terminal) .lines() .next() .unwrap() .contains("row-006") ); assert_eq!(pending, Some(barrier)); assert_eq!(queued.next(), Some(down.clone())); assert_eq!(session.editor().cursor(), Cursor::default()); assert_eq!(session.editor().mode(), Mode::Normal); assert!(!terminal.backend().cursor_visible()); session.handle(Input::Up); } assert_eq!(session.editor().lines().join("\n"), text); } #[test] fn no_op_navigation_restores_caret_following_through_core_dispatch() { let directory = Directory::new(); let path = directory.file("rows.txt", &["row"; 12].join("\n")); let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 6)).unwrap(); let mut viewports = BufferViewports::default(); for motion in [Input::Char('k'), Input::Up, Input::Home] { terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); assert!(viewports.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, 2, 0) )); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); assert!(!terminal.backend().cursor_visible()); session.handle(motion); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); assert_eq!(session.editor().cursor(), Cursor::default()); assert_eq!(viewports.offsets[&session.editor().buffer_id()].0, 0); assert!(terminal.backend().cursor_visible()); } let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); // A remapped motion and a disabled default demonstrate that intent is // emitted by the resolved core action, not frontend key spelling. editor.bind("normal", "x", Some("core.up".into())).unwrap(); editor.bind("normal", "k", None).unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 6)).unwrap(); let mut viewports = BufferViewports::default(); for navigation in 0..3 { terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); let buffer_id = editor.buffer_id(); viewports.offsets.get_mut(&buffer_id).unwrap().0 = 3; viewports.document.manual = Some(ManualViewport { buffer_id, revision: editor.text_revision(), caret_reveal_revision: editor.caret_reveal_revision(), mode: editor.mode(), cursor: editor.cursor(), carets: editor.carets(), selections: editor.selections(), }); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert!(!terminal.backend().cursor_visible()); editor.handle(Input::Char('k')); terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert!(!terminal.backend().cursor_visible()); match navigation { 0 => { editor.handle(Input::Char('x')); } 1 => editor .navigate_to( path.clone(), crate::plugin::TextPosition { row: 0, column: 0 }, ) .unwrap(), 2 => { editor.execute(&format!("buffer {buffer_id}")); } _ => unreachable!(), } terminal .draw(|frame| viewports.draw(frame, &editor)) .unwrap(); assert_eq!(editor.cursor(), Cursor::default()); assert_eq!(viewports.offsets[&buffer_id].0, 0); assert!(terminal.backend().cursor_visible()); assert!( screen_text(&terminal) .lines() .next() .unwrap() .contains("row") ); } } #[test] fn manual_document_view_survives_idle_resize_and_palette_then_follows_navigation_and_edits() { let directory = Directory::new(); let path = directory.file( "rows.txt", &(0..20) .map(|row| format!("row-{row:02}")) .collect::>() .join("\n"), ); let other = directory.file("other.txt", "other"); let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); let buffer_id = session.editor().buffer_id(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(30, 6)).unwrap(); let mut viewports = BufferViewports::default(); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); for _ in 0..2 { assert!(viewports.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, 4, 0) )); } session.poll(); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); assert!( screen_text(&terminal) .lines() .next() .unwrap() .contains("row-06") ); assert!(!terminal.backend().cursor_visible()); terminal.backend_mut().resize(30, 8); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); assert!( screen_text(&terminal) .lines() .next() .unwrap() .contains("row-06") ); session.handle(Input::Ctrl('p')); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); assert!(viewports.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, 4, 0) )); session.handle(Input::Escape); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); assert!( screen_text(&terminal) .lines() .next() .unwrap() .contains("row-06") ); assert_eq!(session.editor().cursor(), Cursor::default()); session.handle(Input::Down); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); assert!( screen_text(&terminal) .lines() .next() .unwrap() .contains("row-01") ); assert!(terminal.backend().cursor_visible()); assert!(viewports.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, 4, 0) )); session.handle(Input::Char('i')); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); assert!(terminal.backend().cursor_visible()); assert!(viewports.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, 4, 0) )); session.handle(Input::Char('z')); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); assert!(terminal.backend().cursor_visible()); session.handle(Input::Escape); assert!(viewports.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, 4, 0) )); session.execute(&format!("edit {}", other.display())); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); assert!(screen_text(&terminal).starts_with("1 other")); session.execute(&format!("buffer {buffer_id}")); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); assert!(terminal.backend().cursor_visible()); assert!( screen_text(&terminal) .lines() .next() .unwrap() .contains("zrow-01") ); } #[test] fn manual_document_view_hides_horizontal_offscreen_caret_and_clamps_tiny_viewports() { let directory = Directory::new(); let path = directory.file("long.txt", "abcdefghijk"); let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); for _ in 0..8 { session.handle(Input::Right); } let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 6)).unwrap(); let mut viewports = BufferViewports::default(); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); assert!(viewports.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, 2, 0) )); terminal.backend_mut().resize(8, 6); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); assert!(!terminal.backend().cursor_visible()); assert!(screen_text(&terminal).starts_with("1 abcdef")); assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 8 }); session.handle(Input::Left); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); assert!(terminal.backend().cursor_visible()); assert_eq!(terminal.backend().cursor_position().x, 7); for (width, height) in [(1, 1), (2, 2), (0, 0)] { terminal.backend_mut().resize(width, height); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); assert_eq!( viewports.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, 0, 0) ), width > 0 && height > 0, ); terminal .draw(|frame| viewports.draw(frame, session.editor())) .unwrap(); assert_eq!(viewports.offsets[&session.editor().buffer_id()].0, 0); } } #[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); let mut syntax = SyntaxCache::new(); editor.handle(Input::Char('1')); editor.handle(Input::Char('0')); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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); let mut syntax = SyntaxCache::new(); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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); let mut syntax = SyntaxCache::new(); 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, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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); let mut syntax = SyntaxCache::new(); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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); let mut syntax = SyntaxCache::new(); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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); let mut syntax = SyntaxCache::new(); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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); let mut syntax = SyntaxCache::new(); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .unwrap(); let viewport = (top, left); editor.handle(Input::Ctrl('p')); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .unwrap(); assert_eq!((top, left), viewport); editor.handle(Input::Ctrl('p')); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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); let mut syntax = SyntaxCache::new(); 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, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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); let mut syntax = SyntaxCache::new(); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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); let mut syntax = SyntaxCache::new(); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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 linewise_selection_highlights_copied_text_and_newlines_without_filling_rows() { let mut editor = Editor::open(None, vec![]).unwrap(); editor .apply(vec![crate::editor::Effect::Replace( "\t界e\u{301} \n\n".into(), )]) .unwrap(); editor.handle(Input::Char('V')); editor.handle(Input::Down); editor.handle(Input::Down); let before = (editor.lines().to_vec(), editor.cursor(), editor.carets()); for width in [16, 3, 1] { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 6)).unwrap(); let (mut top, mut left) = (0, 0); let frame = terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut SyntaxCache::new(), &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .unwrap(); let buffer = frame.buffer; if width == 16 { let status: String = (0..width).map(|x| buffer[(x, 4)].symbol()).collect(); assert!(status.contains("VISUAL LINE"), "{status}"); } let gutter = if width > 2 { 2 } else { 0 }; for row in 0..3 { for x in 0..gutter { assert_ne!(buffer[(x, row)].bg, Color::Blue); } let selected_width = if row == 0 { 10 } else { 1 }; for x in gutter..width { if x - gutter < selected_width { assert_eq!( buffer[(x, row)].bg, Color::Blue, "width {width}, row {row}, x {x}" ); } else { assert_ne!(buffer[(x, row)].bg, Color::Blue); } } } for x in 0..width { assert_ne!(buffer[(x, 3)].bg, Color::Blue); } } assert_eq!( before, (editor.lines().to_vec(), editor.cursor(), editor.carets()) ); editor.handle(Input::Up); editor.handle(Input::Up); editor.handle(Input::End); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(5, 3)).unwrap(); let (mut top, mut left) = (0, 0); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut SyntaxCache::new(), &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .unwrap(); assert!(left > 0); let buffer = terminal.backend().buffer(); for x in 0..2 { assert_ne!(buffer[(x, 0)].bg, Color::Blue); } for x in 2..5 { assert_eq!(buffer[(x, 0)].bg, Color::Blue); } } #[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); let mut syntax = SyntaxCache::new(); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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); let mut syntax = SyntaxCache::new(); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .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, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ) }) .unwrap(); assert_eq!( terminal.get_cursor_position().unwrap(), ratatui::layout::Position::new(4, 4) ); } #[test] fn terminal_can_present_previous_frame_while_next_frame_is_computed() { use std::{cell::Cell, rc::Rc}; struct Output { bytes: Vec, synchronized: Rc>, } impl io::Write for Output { fn write(&mut self, bytes: &[u8]) -> io::Result { self.bytes.extend_from_slice(bytes); Ok(bytes.len()) } fn flush(&mut self) -> io::Result<()> { for command in self.bytes.split(|byte| *byte == b'\x1b') { if command.starts_with(b"[?2026h") { self.synchronized.set(true); } else if command.starts_with(b"[?2026l") { self.synchronized.set(false); } } self.bytes.clear(); Ok(()) } } let synchronized = Rc::new(Cell::new(false)); let mut output = Output { bytes: Vec::new(), synchronized: Rc::clone(&synchronized), }; let mut terminal = Terminal::with_options( CrosstermBackend::new(Vec::new()), ratatui::TerminalOptions { viewport: ratatui::Viewport::Fixed(Rect::new(0, 0, 20, 5)), }, ) .unwrap(); for text in ["first frame", "second frame", "third frame"] { draw_frame(&mut terminal, &mut output, |frame| { assert!( !synchronized.get(), "terminal presentation suspended during frame computation" ); frame.render_widget(Paragraph::new(text), frame.area()); }) .unwrap(); assert!(!synchronized.get()); } } #[test] fn scrolling_never_paints_with_an_exposed_cursor_even_without_synchronized_updates() { let mut editor = Editor::open(None, vec![]).unwrap(); editor .apply(vec![crate::editor::Effect::Replace( (0..40).map(|row| format!("row {row}\n")).collect(), )]) .unwrap(); let mut output = Vec::new(); let mut terminal = Terminal::with_options( CrosstermBackend::new(Vec::new()), ratatui::TerminalOptions { viewport: ratatui::Viewport::Fixed(Rect::new(0, 0, 20, 5)), }, ) .unwrap(); let mut viewports = BufferViewports::default(); for key in std::iter::repeat_n('j', 20).chain(std::iter::repeat_n('k', 20)) { editor.handle(Input::Char(key)); draw_frame(&mut terminal, &mut output, |frame| { viewports.draw(frame, &editor) }) .unwrap(); } drop(terminal); let output = std::str::from_utf8(&output).unwrap(); for supports_sync in [false, true] { let (mut visible, mut synchronized) = (true, false); let mut screen = [[b' '; 20]; 5]; let (mut x, mut y) = (0, 0); for escape in output.split('\x1b').skip(1) { let rest = escape.strip_prefix('[').unwrap(); let end = rest.find(|ch| ('@'..='~').contains(&ch)).unwrap(); let (command, text) = rest.split_at(end + 1); match command { "?25h" => visible = true, "?25l" => visible = false, "?2026h" => synchronized = supports_sync, "?2026l" => synchronized = false, _ if command.ends_with('H') => { let (row, column) = command[..command.len() - 1].split_once(';').unwrap(); y = row.parse::().unwrap() - 1; x = column.parse::().unwrap() - 1; } _ => {} } for byte in text.bytes().filter(|&byte| byte >= b' ' && byte != 127) { assert!( if supports_sync { synchronized } else { !visible }, "partial frame exposed during redraw (sync={supports_sync}): {text:?}", ); if y < screen.len() && x < screen[y].len() { screen[y][x] = byte; } x += 1; } } assert!(visible); assert!(!synchronized); for (row, expected) in [(0, "row 0"), (1, "row 1"), (2, "row 2")] { let line = std::str::from_utf8(&screen[row]).unwrap().trim_end(); assert!(line.ends_with(expected), "{line:?}"); } assert_eq!(screen[y][x], b'r', "caret was not restored to the source"); } } #[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" => { let mut terminal = Terminal::with_options( CrosstermBackend::new(Vec::new()), ratatui::TerminalOptions { viewport: ratatui::Viewport::Fixed(Rect::new(0, 0, 20, 5)), }, )?; draw_frame(&mut terminal, &mut io::stdout(), |_| { 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::Ctrl('h'), Input::Ctrl('l'), Input::Backspace, Input::Backspace, 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 "ab".chars() { editor.handle(Input::Char(ch)); } } else if index == 8 { 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 | 5 => assert_eq!(editor.palette().unwrap().query, "ab"), 6 => assert_eq!(editor.palette().unwrap().query, "a"), 7 => assert_eq!(editor.palette().unwrap().query, ""), 8 => { assert!(editor.palette().is_none()); assert_eq!(editor.mode(), Mode::Command); assert_eq!(editor.command_line(), "q! "); assert!(!editor.should_quit()); } 9 => 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:?}" ); for protocol in [1000, 1002, 1003, 1015, 1006] { assert_eq!( before.matches(&format!("\x1b[?{protocol}h")).count(), entered, "{mode}: mouse capture was not enabled: {before:?}", ); assert_eq!( before.matches(&format!("\x1b[?{protocol}l")).count(), entered, "{mode}: mouse capture was not restored: {before:?}", ); } if mode == "panic" { let end = before.find("\x1b[?2026l").unwrap(); assert!(end < before.find("CALLER_HOOK").unwrap()); assert!( before.find("\x1b[?1049l").unwrap() < before.find("CALLER_HOOK").unwrap(), "{before:?}", ); assert!( before.find("\x1b[?1006l").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\b\f\x7f\x7f\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); } } }