use super::*; use crate::diff::RowKind; use crate::plugin::TextPosition; 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); } } #[allow(clippy::too_many_arguments)] fn rendered_whitespace_line( source: &str, left: usize, width: u16, characters: &BTreeMap, scope: WhitespaceScope, strings: &[SyntaxSpan], newline: bool, sanitize_controls: bool, ) -> ratatui::buffer::Buffer { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 1)).unwrap(); terminal .draw(|frame| { let area = frame.area(); frame.render_widget( Paragraph::new(visible_text( source, left, usize::from(width), sanitize_controls, )) .style( Style::default() .fg(Color::Green) .bg(Color::Yellow) .add_modifier(Modifier::UNDERLINED), ), area, ); paint_whitespace( frame, source, area, left, characters, scope, strings, newline, sanitize_controls, ); }) .unwrap(); terminal.backend().buffer().clone() } #[test] fn whitespace_scope_uses_the_source_prefix_and_dims_only_marker_cells() { let characters = BTreeMap::from([(' ', '·'), ('\t', '→'), ('\u{3000}', '_'), ('\n', '↵')]); for (scope, source, left, width, expected) in [ (WhitespaceScope::All, "\t x \t ", 0, 10, "→ ·x·→·↵"), (WhitespaceScope::Leading, "\t x \t ", 0, 10, "→ ·x ↵"), (WhitespaceScope::Leading, "\t \u{3000}", 0, 8, "→ ·_ ↵"), (WhitespaceScope::Leading, " x ", 5, 4, "·x ↵"), (WhitespaceScope::Leading, "x ", 1, 3, " ↵"), (WhitespaceScope::Leading, "", 0, 1, "↵"), ] { let buffer = rendered_whitespace_line(source, left, width, &characters, scope, &[], true, false); for (x, symbol) in expected.chars().enumerate() { let cell = &buffer[(x as u16, 0)]; assert_eq!( cell.symbol(), symbol.to_string(), "{scope:?}, {source:?}, left={left}" ); let marker = ['·', '→', '_', '↵'].contains(&symbol); assert_eq!(cell.modifier.contains(Modifier::DIM), marker); assert_eq!(cell.fg, if marker { Color::Reset } else { Color::Green }); assert_eq!(cell.bg, Color::Yellow); assert!(cell.modifier.contains(Modifier::UNDERLINED)); } } for (source, left, newline, expected) in [ ("x", 0, false, "x "), ("x", 1, true, "↵ "), ("x", 2, true, " "), ] { let buffer = rendered_whitespace_line( source, left, 2, &characters, WhitespaceScope::All, &[], newline, false, ); assert_eq!( buffer .content .iter() .map(|cell| cell.symbol()) .collect::(), expected, ); } } #[test] fn buffer_newline_markers_use_final_wrap_caret_cells_not_soft_wrap_or_eof() { let directory = Directory::new(); for tail in ["last", ""] { for scope in [WhitespaceScope::All, WhitespaceScope::Leading] { let source = format!("abcd\n\n x \n{tail}"); let path = directory.file("newlines.txt", &source); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor.set_gutter_style(crate::gutter::GutterStyle::None); editor.set_whitespace_characters(BTreeMap::from([(' ', '·'), ('\n', '↵')])); editor.set_whitespace_scope(scope); editor.set_highlight_trailing_whitespace(true); editor.execute("word_wrap.toggle"); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(6, 12)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let area = views.document.geometry.unwrap().1; let area = Rect::new( area.right() - views.document.text_width as u16, area.y, views.document.text_width as u16, area.height, ); assert_eq!(area.width, 4); let buffer = terminal.backend().buffer(); for row in [1, 2, 4] { let cell = &buffer[(area.x, area.y + row)]; assert_eq!(cell.symbol(), "↵"); assert!(cell.modifier.contains(Modifier::DIM)); assert_eq!(cell.fg, Color::Reset); assert_ne!(cell.bg, Color::Red); } assert_eq!( buffer .content .iter() .filter(|cell| cell.symbol() == "↵") .count(), 3, ); assert_eq!(buffer[(area.x + 3, area.y)].symbol(), "d"); assert!( !buffer[(area.x + 3, area.y)] .modifier .contains(Modifier::DIM) ); assert_eq!( buffer[(area.x, area.y + 5)].symbol(), if tail.is_empty() { " " } else { "l" } ); for offset in [0, 1] { assert_eq!(buffer[(area.x + offset, area.y + 3)].symbol(), "·"); } let trailing = &buffer[(area.x + 3, area.y + 3)]; assert_eq!( trailing.symbol(), if scope == WhitespaceScope::All { "·" } else { " " } ); assert_eq!(trailing.bg, Color::Red); assert_eq!( trailing.modifier.contains(Modifier::DIM), scope == WhitespaceScope::All ); editor.handle(Input::Char('V')); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let newline = &terminal.backend().buffer()[(area.x, area.y + 1)]; assert_eq!(newline.symbol(), "↵"); assert_eq!(newline.bg, Color::Blue); assert!(newline.modifier.contains(Modifier::DIM)); assert_eq!(editor.lines().join("\n"), source); assert!(!editor.dirty()); assert_eq!(fs::read_to_string(path).unwrap(), source); } } } #[test] fn historical_buffer_markers_retain_real_final_newlines_and_scope() { let mut file = syntax_diff_file("history.txt", "old\n tail ", "new\n tail "); file.before_eof = true; file.after_eof = true; file.hunks = crate::diff::line_hunks(&file.before, true, &file.after, true); let mut editor = readonly_diff_editor(file); editor.set_gutter_style(crate::gutter::GutterStyle::None); editor.set_whitespace_characters(BTreeMap::from([(' ', '·'), ('\n', '↵')])); editor.set_whitespace_scope(WhitespaceScope::Leading); editor.set_highlight_trailing_whitespace(true); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 15)).unwrap(); let mut views = BufferViewports::default(); render_native_diff(&mut terminal, &mut views, &mut editor); let path = editor .interaction_documents() .find(|document| document.id == editor.buffer_id()) .unwrap() .path .unwrap() .to_owned(); editor .navigate_lsp_to(path, definition_point(1, 0), None) .unwrap(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert!(editor.diff().is_none()); assert!(editor.mutation_blocked()); let area = views.document.geometry.unwrap().1; let area = Rect::new( area.right() - views.document.text_width as u16, area.y, views.document.text_width as u16, area.height, ); let buffer = terminal.backend().buffer(); for (x, y, symbol) in [(3, 0, "↵"), (0, 1, "·"), (1, 1, "·"), (7, 1, "↵")] { let cell = &buffer[(area.x + x, area.y + y)]; assert_eq!(cell.symbol(), symbol); assert!(cell.modifier.contains(Modifier::DIM)); } assert_eq!(buffer[(area.x + 6, area.y + 1)].symbol(), " "); assert_eq!(buffer[(area.x + 6, area.y + 1)].bg, Color::Red); assert!( !buffer[(area.x + 6, area.y + 1)] .modifier .contains(Modifier::DIM) ); assert_eq!(buffer[(area.x, area.y + 2)].symbol(), " "); assert_eq!(editor.lines().join("\n"), "old\n tail \n"); assert!(!editor.dirty()); } #[test] fn diff_newline_markers_cover_both_sides_without_hatch_or_soft_wrap_markers() { for mode in [ViewMode::Split, ViewMode::Unified] { for wrapped in [false, true] { for (before_eof, after_eof) in [(false, true), (true, false)] { for scope in [WhitespaceScope::All, WhitespaceScope::Leading] { let length = if wrapped { 220 } else { 60 }; let before = format!(" old{} \t", "x".repeat(length)); let after = format!("\0 new{} \t\n \nlast", "y".repeat(length)); let mut file = syntax_diff_file("newlines.txt", &before, &after); file.before_eof = before_eof; file.after_eof = after_eof; let mut editor = Editor::open(None, vec![]).unwrap(); editor.set_diff_display_options(mode, SplitAxis::Vertical); install_readonly_diff(&mut editor, file); editor.set_whitespace_characters(BTreeMap::from([ (' ', '␣'), ('\t', '→'), ('\n', '↵'), ])); editor.set_whitespace_scope(scope); editor.set_highlight_trailing_whitespace(true); if wrapped { editor.execute("word_wrap.toggle"); } let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(240, 40)).unwrap(); let mut views = BufferViewports::default(); render_native_diff(&mut terminal, &mut views, &mut editor); let buffer = terminal.backend().buffer(); let geometry = views.panels.diff.as_ref().unwrap(); let mut text_cells = Vec::new(); for (region, &width) in geometry.regions[1..].iter().zip(&geometry.text_widths) { let inner = diff_inner(region.0); for y in inner.y..inner.bottom() { for x in inner.right() - width..inner.right() { text_cells.push(&buffer[(x, y)]); } } } // The unwrapped pane must expose each line end too. if !wrapped { assert!( views .panels .diff .as_ref() .unwrap() .text_widths .iter() .filter(|&&width| width > 0) .all(|&width| width >= 72) ); } assert_eq!( text_cells .iter() .filter(|cell| cell.symbol() == "↵") .count(), 2 + usize::from(before_eof) + usize::from(after_eof), "{mode:?}, wrap={wrapped}, before={before_eof}, after={after_eof}", ); for cell in text_cells .iter() .filter(|cell| ["␣", "→", "↵"].contains(&cell.symbol())) { assert!(cell.modifier.contains(Modifier::DIM)); assert_eq!(cell.fg, Color::Reset); } for cell in text_cells.iter().filter(|cell| cell.symbol() == "↵") { assert_ne!(cell.bg, Color::Red); } assert!(text_cells.iter().any(|cell| cell.symbol() == "�")); assert_eq!( text_cells .iter() .filter(|cell| cell.symbol() == "→") .count(), if scope == WhitespaceScope::All { 2 } else { 0 }, ); assert_eq!( text_cells .iter() .filter(|cell| cell.symbol() == "␣") .count(), if scope == WhitespaceScope::All { 8 } else { 4 }, ); if mode == ViewMode::Split { assert!(buffer.content.iter().any(|cell| cell.symbol() == "╱")); } } } } } } #[test] fn exact_width_diff_newlines_use_the_existing_extra_caret_segment() { for mode in [ViewMode::Split, ViewMode::Unified] { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 20)).unwrap(); let mut reference = Editor::open(None, vec![]).unwrap(); reference.set_diff_display_options(mode, SplitAxis::Vertical); install_readonly_diff(&mut reference, syntax_diff_file("exact.txt", "old", "new")); let mut views = BufferViewports::default(); render_native_diff(&mut terminal, &mut views, &mut reference); let mut widths = views.panels.diff.as_ref().unwrap().text_widths; if mode == ViewMode::Unified { let width = widths[0].max(widths[1]); widths.fill(width); } let mut file = syntax_diff_file( "exact.txt", &"o".repeat(usize::from(widths[0])), &"n".repeat(usize::from(widths[1])), ); file.before_eof = true; file.after_eof = true; let mut editor = Editor::open(None, vec![]).unwrap(); editor.set_diff_display_options(mode, SplitAxis::Vertical); install_readonly_diff(&mut editor, file); editor.set_whitespace_characters(BTreeMap::from([('\n', '↵')])); editor.execute("word_wrap.toggle"); let mut views = BufferViewports::default(); render_native_diff(&mut terminal, &mut views, &mut editor); let buffer = terminal.backend().buffer(); let newlines: Vec<_> = buffer .content .iter() .enumerate() .filter_map(|(index, cell)| (cell.symbol() == "↵").then_some(index)) .collect(); assert_eq!(newlines.len(), 2, "{mode:?}: {}", screen_text(&terminal)); for index in newlines { let x = (index % 80) as u16; let y = (index / 80) as u16; let symbol = buffer[(x, y - 1)].symbol(); assert!(["o", "n"].contains(&symbol)); let width = widths[usize::from(symbol == "n")]; for offset in 0..width { assert_eq!(buffer[(x + offset, y - 1)].symbol(), symbol); } assert!(buffer.content[index].modifier.contains(Modifier::DIM)); } } } #[test] fn literal_newlines_stay_unmarked_in_buffers_and_each_diff_source() { let directory = Directory::new(); let closed = "text = \"\"\"a b\nc d\"\"\""; let second = "text = \"\"\"e f\ng h\"\"\""; for source in [closed, second] { let mut editor = Editor::open( Some(directory.file("literal.py", &format!("{source}\n"))), vec![], ) .unwrap(); editor.set_whitespace_characters(BTreeMap::from([(' ', '·'), ('\n', '↵')])); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 15)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert_eq!( terminal .backend() .buffer() .content .iter() .filter(|cell| cell.symbol() == "↵") .count(), 1, ); assert!(screen_text(&terminal).contains(if source == closed { "c d\"\"\"↵" } else { "g h\"\"\"↵" })); } for mode in [ViewMode::Split, ViewMode::Unified] { let mut file = syntax_diff_file("literal.py", closed, second); file.before_eof = true; file.after_eof = true; let mut editor = Editor::open(None, vec![]).unwrap(); editor.set_diff_display_options(mode, SplitAxis::Vertical); install_readonly_diff(&mut editor, file); editor.set_whitespace_characters(BTreeMap::from([(' ', '·'), ('\n', '↵')])); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 20)).unwrap(); let mut views = BufferViewports::default(); render_native_diff(&mut terminal, &mut views, &mut editor); assert_eq!( terminal .backend() .buffer() .content .iter() .filter(|cell| cell.symbol() == "↵") .count(), 2, "{mode:?}: {}", screen_text(&terminal), ); for expected in ["a b", "c d\"\"\"↵", "e f", "g h\"\"\"↵"] { assert!(screen_text(&terminal).contains(expected)); } } } #[test] fn whitespace_markers_preserve_multiline_raw_and_injected_strings() { let directory = Directory::new(); for (path, source, expected) in [ ( "raw.rs", "let text = r#\"a b\"#; ", &["let·text·=·r#\"a b\"#;·"][..], ), ( "unicode.rs", "let text = \"α β\"; ", &["let·text·=·\"α β\";·"][..], ), ( "multiline.py", "text = \"\"\"a b\nc d\"\"\" ", &["text·=·\"\"\"a b", "c d\"\"\"·"][..], ), ( "template.js", "const text = `a b ${ x + y }`; ", &["const·text·=·`a b ${ x + y }`;·"][..], ), ( "injected.cpp", "auto text = R\"cpp(int x = 1;)cpp\"; ", &["auto·text·=·R\"cpp(int x = 1;)cpp\";·"][..], ), ( "heredoc.rb", "text = <<~TEXT\n a b\nTEXT\n", &["text·=·<<~TEXT", " a b"][..], ), ] { let mut editor = Editor::open(Some(directory.file(path, source)), vec![]).unwrap(); editor.set_whitespace_characters(BTreeMap::from([(' ', '·')])); editor.set_gutter_style(crate::gutter::GutterStyle::None); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 15)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let rendered: String = terminal .backend() .buffer() .content .iter() .map(|cell| cell.symbol()) .collect(); for expected in expected { assert!(rendered.contains(expected), "{path}: {rendered}"); } } let characters = BTreeMap::from([(' ', '·'), ('\t', '→'), ('\u{a0}', '⍽'), ('\u{3000}', '_')]); for (source, left, width, cells) in [ ( "let text = \"α β\"; ", 12, 3, vec![(0, "α"), (1, " "), (2, "β")], ), ( "let s = \"\t \u{3000}\u{a0} \"; ", 0, 20, vec![ (3, "·"), (5, "·"), (7, "·"), (9, " "), (10, " "), (11, " "), (12, " "), (13, "\u{3000}"), (15, "\u{a0}"), (16, " "), (19, "·"), ], ), ( "let s = \"\t \u{3000}\u{a0} \"; ", 10, 7, vec![ (0, " "), (1, " "), (2, " "), (3, "\u{3000}"), (5, "\u{a0}"), (6, " "), ], ), ] { let mut syntax = SyntaxCache::new(); syntax.update_text(source, std::path::Path::new("strings.rs")); let buffer = rendered_whitespace_line( source, left, width, &characters, WhitespaceScope::All, syntax.string_spans(0), false, false, ); for (x, symbol) in cells { assert_eq!( buffer[(x, 0)].symbol(), symbol, "{source:?}, left={left}, x={x}" ); assert_eq!( buffer[(x, 0)].modifier.contains(Modifier::DIM), symbol == "·" ); } } } #[test] fn whitespace_markers_leave_string_contents_literal_in_buffers_and_diffs() { let directory = Directory::new(); let source = "let text = \"a b\"; "; let mut editor = Editor::open(Some(directory.file("strings.rs", source)), vec![]).unwrap(); editor.set_whitespace_characters(BTreeMap::from([(' ', '·')])); editor.set_gutter_style(crate::gutter::GutterStyle::None); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 15)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let rendered: String = terminal .backend() .buffer() .content .iter() .map(|cell| cell.symbol()) .collect(); assert!(rendered.contains("let·text·=·\"a b\";·"), "{rendered}"); let area = views.document.geometry.unwrap().1; let area = Rect::new( area.right() - views.document.text_width as u16, area.y, views.document.text_width as u16, area.height, ); for column in 11..16 { let cell = &terminal.backend().buffer()[(area.x + column, area.y)]; assert_eq!(cell.fg, Color::Green); assert!(!cell.modifier.contains(Modifier::DIM)); } let marker = &terminal.backend().buffer()[(area.x + 3, area.y)]; assert_eq!(marker.fg, Color::Reset); assert!(marker.modifier.contains(Modifier::DIM)); editor .apply(vec![crate::editor::Effect::Replace( "let text = a + b; ".into(), )]) .unwrap(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let rendered: String = terminal .backend() .buffer() .content .iter() .map(|cell| cell.symbol()) .collect(); assert!(rendered.contains("let·text·=·a·+·b;·"), "{rendered}"); for mode in [ViewMode::Split, ViewMode::Unified] { let mut editor = Editor::open(None, vec![]).unwrap(); editor.set_diff_display_options(mode, SplitAxis::Vertical); install_readonly_diff( &mut editor, syntax_diff_file("strings.rs", source, "\0let text = \"c d\"; "), ); editor.set_whitespace_characters(BTreeMap::from([(' ', '·')])); let mut views = BufferViewports::default(); render_native_diff(&mut terminal, &mut views, &mut editor); let rendered: String = terminal .backend() .buffer() .content .iter() .map(|cell| cell.symbol()) .collect(); for expected in ["let·text·=·\"a b\";·", "�let·text·=·\"c d\";·"] { assert!(rendered.contains(expected), "{mode:?}: {rendered}"); } for (index, cell) in terminal.backend().buffer().content.iter().enumerate() { if cell.symbol() == "\"" { let literal_space = &terminal.backend().buffer().content[index + 2]; // Opening quotes are followed by a letter and its literal space. if literal_space.symbol() == " " { assert_eq!(literal_space.fg, Color::Green); assert!(!literal_space.modifier.contains(Modifier::DIM)); } } } } } #[test] fn whitespace_characters_preserve_source_geometry_and_overlay_styles() { let characters = BTreeMap::from([(' ', '·'), ('\t', '→'), ('\u{a0}', '⍽'), ('\u{3000}', '_')]); let source = "a \tb\u{a0}\u{3000} c"; for (source, left, width, expected) in [ ( source, 0, 10, vec!["a", "·", "→", " ", "b", "⍽", "_", " ", "·", "c"], ), (source, 3, 6, vec![" ", "b", "⍽", "_", " ", "·"]), (source, 7, 2, vec![" ", "·"]), (source, 6, 1, vec!["_"]), (" \u{301}", 0, 1, vec![" \u{301}"]), ("", 0, 1, vec![" "]), ] { let buffer = rendered_whitespace_line( source, left, width, &characters, WhitespaceScope::All, &[], false, false, ); for (x, symbol) in expected.into_iter().enumerate() { let cell = &buffer[(x as u16, 0)]; assert_eq!(cell.symbol(), symbol, "{source:?}, left={left}, x={x}"); let marker = ["·", "→", "⍽", "_"].contains(&symbol); assert_eq!(cell.modifier.contains(Modifier::DIM), marker); assert!(cell.modifier.contains(Modifier::UNDERLINED)); assert_eq!(cell.fg, if marker { Color::Reset } else { Color::Green }); assert_eq!(cell.bg, Color::Yellow); } } let directory = Directory::new(); let source = "a b\t \u{3000}\n \t\n\nabcdefg "; let path = directory.file("markers.txt", source); let config = directory.file( "config.rb", r#"Vamp.configure { |c| c.whitespace_characters = { " " => "·", "\t" => "→", "\u3000" => "_" }; c.highlight_trailing_whitespace = true; c.git_gutter = "none" }"#, ); let mut session = EditorSession::open(Some(path.clone()), vec![], Some(&config)).unwrap(); session.execute("word_wrap.toggle"); let token = semantic_token( 0, 0, 6, SemanticTokenKind::Type, SemanticTokenModifier::Deprecated as u16, ); let editor = session.editor_mut(); editor .replace_semantic_tokens( editor.buffer_id(), editor.text_revision(), Some(Arc::from([token])), ) .unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(10, 10)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw(frame, session.editor_mut())) .unwrap(); let buffer = terminal.backend().buffer(); for (x, y, marker) in [ (3, 0, "·"), (5, 0, "→"), (6, 0, "·"), (7, 0, "_"), (2, 1, "·"), (3, 1, "→"), (9, 3, "·"), (2, 4, "·"), ] { assert_eq!(buffer[(x, y)].symbol(), marker, "({x}, {y})"); assert!(buffer[(x, y)].modifier.contains(Modifier::DIM)); if y == 0 { assert_eq!(buffer[(x, y)].fg, Color::Reset); assert!(buffer[(x, y)].modifier.contains(Modifier::CROSSED_OUT)); } } assert_eq!(buffer[(8, 0)].symbol(), " "); assert!(!buffer[(8, 0)].modifier.contains(Modifier::DIM)); assert!(!buffer[(4, 0)].modifier.contains(Modifier::DIM)); assert_eq!(buffer[(4, 0)].fg, semantic_style(&token).fg.unwrap()); assert!(buffer[(4, 0)].modifier.contains(Modifier::CROSSED_OUT)); assert!(!buffer[(2, 2)].modifier.contains(Modifier::DIM)); assert_eq!(buffer[(7, 0)].bg, Color::Red); assert_eq!(buffer[(2, 2)].symbol(), " "); session.handle(Input::End); session.handle(Input::Char('v')); session.handle(Input::Left); terminal .draw(|frame| views.draw(frame, session.editor_mut())) .unwrap(); assert_eq!(terminal.backend().buffer()[(6, 0)].symbol(), "·"); assert_eq!(terminal.backend().buffer()[(6, 0)].bg, Color::Blue); assert!( terminal.backend().buffer()[(6, 0)] .modifier .contains(Modifier::DIM) ); assert_eq!(session.editor().lines().join("\n"), source); assert!(!session.editor().dirty()); assert_eq!(fs::read_to_string(path).unwrap(), source); session .editor_mut() .set_whitespace_characters(BTreeMap::new()); terminal .draw(|frame| views.draw(frame, session.editor_mut())) .unwrap(); assert_eq!(terminal.backend().buffer()[(6, 0)].symbol(), " "); assert_eq!(terminal.backend().buffer()[(7, 0)].symbol(), "\u{3000}"); } #[test] fn trailing_whitespace_is_opt_in_and_preserves_wrapping_and_selection() { let directory = Directory::new(); let source = "a b\t \u{3000}\n \t\n\nabcdefg "; let path = directory.file("whitespace.txt", source); let config = directory.file( "config.rb", "Vamp.configure { |c| c.highlight_trailing_whitespace = true; c.git_gutter = 'none' }", ); let mut session = EditorSession::open(Some(path), vec![], Some(&config)).unwrap(); session.execute("word_wrap.toggle"); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(10, 10)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw(frame, session.editor_mut())) .unwrap(); let buffer = terminal.backend().buffer(); for (y, range) in [(0, 5..9), (1, 2..6), (3, 9..10), (4, 2..3)] { for x in 2..10 { // The backend skips the covered cell of a wide grapheme. if (x, y) == (8, 0) { continue; } assert_eq!( buffer[(x, y)].bg == Color::Red, range.contains(&x), "({x}, {y})" ); } } assert_eq!(buffer[(2, 2)].bg, Color::Reset); session.handle(Input::End); session.handle(Input::Char('v')); session.handle(Input::Left); terminal .draw(|frame| views.draw(frame, session.editor_mut())) .unwrap(); assert_eq!(terminal.backend().buffer()[(6, 0)].bg, Color::Blue); assert_eq!(session.editor().lines().join("\n"), source); assert!(!session.editor().dirty()); let mut editor = Editor::open(None, vec![]).unwrap(); editor .apply(vec![crate::editor::Effect::Replace("x \t".into())]) .unwrap(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert_ne!(terminal.backend().buffer()[(7, 0)].bg, Color::Red); } #[test] fn trailing_whitespace_covers_both_diff_sides_and_unified_rows() { for mode in [ViewMode::Split, ViewMode::Unified] { for wrapped in [false, true] { let mut editor = Editor::open(None, vec![]).unwrap(); editor.set_diff_display_options(mode, crate::panes::SplitAxis::Vertical); install_readonly_diff( &mut editor, syntax_diff_file("whitespace.txt", "old \t", "new \t"), ); editor.set_highlight_trailing_whitespace(true); editor.set_whitespace_characters(BTreeMap::from([(' ', '·'), ('\t', '→')])); if wrapped { editor.execute("word_wrap.toggle"); } let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 15)).unwrap(); let mut views = BufferViewports::default(); render_native_diff(&mut terminal, &mut views, &mut editor); let buffer = terminal.backend().buffer(); for word in ["old", "new"] { let start = buffer .content .iter() .enumerate() .find_map(|(index, cell)| { (cell.symbol() == &word[..1] && buffer .content .get(index + 1) .is_some_and(|cell| cell.symbol() == &word[1..2]) && buffer .content .get(index + 2) .is_some_and(|cell| cell.symbol() == &word[2..3])) .then_some(index) }) .unwrap(); for offset in 3..8 { assert_eq!( buffer.content[start + offset].bg, Color::Red, "{mode:?} {wrapped} {word}" ); } assert_ne!(buffer.content[start + 8].bg, Color::Red); assert_eq!(buffer.content[start + 3].symbol(), "·"); assert_eq!(buffer.content[start + 4].symbol(), "→"); } } } } #[test] fn scroll_distance_follows_document_and_diff_visual_rows_and_columns() { let directory = Directory::new(); let text = (0..80) .map(|row| format!("row-{row:02} {}", "x".repeat(160))) .collect::>() .join("\n"); for diff in [false, true] { for wrapped in [false, true] { for distance in [Some(0), Some(2), None, Some(usize::MAX)] { let mut editor = if diff { let mut editor = readonly_diff_editor(syntax_diff_file( "history.txt", &text, &text.replace('x', "y"), )); editor.handle(Input::Char('\t')); editor } else { Editor::open(Some(directory.file("document.txt", &text)), vec![]).unwrap() }; editor.set_scroll_distance(distance); if wrapped { editor.execute("word_wrap.toggle"); } let mut views = BufferViewports::default(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 16)).unwrap(); let render = |terminal: &mut Terminal, views: &mut BufferViewports, editor: &mut Editor| { if diff { render_native_diff(terminal, views, editor) } else { terminal.draw(|frame| views.draw(frame, editor)).unwrap(); terminal.get_cursor_position().unwrap() } }; render(&mut terminal, &mut views, &mut editor); for _ in 0..30 { editor.handle(Input::Down); render(&mut terminal, &mut views, &mut editor); } let viewport = if diff { diff_inner(views.panels.diff.as_ref().unwrap().regions[1].0) } else { views.document.geometry.unwrap().1 }; let height = usize::from(viewport.height); let expected = if distance.is_none_or(|distance| distance >= height / 2) { height / 2 } else { height - distance.unwrap() - 1 }; let caret = terminal.get_cursor_position().unwrap(); assert_eq!( usize::from(caret.y - viewport.y), expected, "diff={diff}, wrap={wrapped}, distance={distance:?}" ); if !wrapped && distance == Some(2) { editor.handle(Input::Up); let reversed = render(&mut terminal, &mut views, &mut editor); assert_eq!(reversed.y + 1, caret.y); } if !wrapped { for _ in 0..120 { editor.handle(Input::Right); render(&mut terminal, &mut views, &mut editor); } let left = if diff { editor.diff().unwrap().left_before } else { views.offsets[&editor.buffer_id()].1 }; let width = if diff { usize::from(views.panels.diff.as_ref().unwrap().text_widths[0]) } else { views.document.text_width }; let expected = if distance.is_none_or(|distance| distance >= width / 2) { width / 2 } else { width - distance.unwrap() - 1 }; assert_eq!(editor.cursor().column - left, expected); } if distance == Some(usize::MAX) { terminal.backend_mut().resize(60, 12); terminal.resize(Rect::new(0, 0, 60, 12)).unwrap(); let caret = render(&mut terminal, &mut views, &mut editor); let viewport = if diff { diff_inner(views.panels.diff.as_ref().unwrap().regions[1].0) } else { views.document.geometry.unwrap().1 }; assert_eq!(caret.y - viewport.y, viewport.height / 2); } } } } } fn screen_text(terminal: &Terminal) -> String { let buffer = terminal.backend().buffer(); let area = buffer.area; (area.y..area.bottom()) .map(|y| { (area.x..area.right()) .map(|x| buffer[(x, y)].symbol()) .collect::() }) .collect::>() .join("\n") } #[test] fn resolver_renders_aligned_native_draft_alternative_and_separate_history() { let directory = Directory::new(); let jj = |args: &[&str]| { let output = std::process::Command::new("jj") .current_dir(&directory.0) .env("JJ_CONFIG", "/dev/null") .args([ "--config=user.name=Resolver", "--config=user.email=resolver@example.test", "--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"", "--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"", ]) .args(args) .output() .unwrap(); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); String::from_utf8(output.stdout).unwrap().trim().to_owned() }; let text = |side: &str| { format!( "value = {side};\n{}{}last = {side};\n", if side == "third" { "third-only third-only third-only third-only third-only third-only third-only\n" } else { "" }, (0..60) .map(|row| format!("context {row:02}\n")) .collect::() ) }; jj(&["git", "init", "--no-colocate", "."]); jj(&["config", "set", "--repo", "user.name", "Resolver"]); jj(&[ "config", "set", "--repo", "user.email", "resolver@example.test", ]); for path in ["first.txt", "second.txt"] { directory.file(path, &text("base")); } fs::write(directory.0.join("binary.bin"), b"base\0").unwrap(); jj(&["describe", "-m", "base"]); let base = jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]); jj(&["new", &base]); for path in ["first.txt", "second.txt"] { directory.file(path, &text("left")); } fs::write(directory.0.join("binary.bin"), b"left\0").unwrap(); jj(&["describe", "-m", "left"]); let left = jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]); jj(&["new", &base]); for path in ["first.txt", "second.txt"] { directory.file(path, &text("right")); } fs::write(directory.0.join("binary.bin"), b"right\0").unwrap(); jj(&["describe", "-m", "right"]); let right = jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]); jj(&["new", &base]); for path in ["first.txt", "second.txt"] { directory.file(path, &text("third")); } fs::write(directory.0.join("binary.bin"), b"third\0").unwrap(); jj(&["describe", "-m", "third"]); let third = jj(&["log", "--no-graph", "-r", "@", "-T", "commit_id"]); jj(&["new", &left, &right, &third, "-m", "conflicts"]); let operation = || { jj(&[ "--ignore-working-copy", "op", "log", "--no-graph", "-n", "1", "-T", "id", ]) }; let before_browsing = operation(); let config = directory.file( "config.rb", "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\nend\n", ); let mut session = EditorSession::open( Some(directory.0.join("first.txt")), vec!["text".into()], Some(&config), ) .unwrap(); let ready = |session: &mut EditorSession| { for _ in 0..15_000 { session.poll(); if session.editor().resolver().is_some_and(|resolver| { !resolver.loading && !resolver .files .iter() .any(|file| file.status == ResolverStatus::Checking) }) { assert!( session.editor().resolver().unwrap().error.is_none(), "{}", session.editor().message() ); return; } std::thread::sleep(Duration::from_millis(1)); } panic!("Resolver did not settle: {}", session.editor().message()); }; session.handle(Input::Char('/')); for ch in "context 00".chars() { session.handle(Input::Char(ch)); } session.handle(Input::Enter); session.handle(Input::Home); session.handle(Input::Char('i')); session.handle(Input::Paste("opening ".into())); session.handle(Input::Escape); session.execute("resolve"); ready(&mut session); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); let mut views = BufferViewports::default(); views.set_theme(Some(DARK_DIFF_THEME)); let render = |terminal: &mut Terminal, views: &mut BufferViewports, session: &mut EditorSession| { terminal .draw(|frame| views.draw_session(frame, session)) .unwrap(); terminal.get_cursor_position().unwrap() }; let workspace = |terminal: &Terminal, views: &BufferViewports, editor: &Editor| { let syntax = &views.syntax[&editor.buffer_id()]; let visible = [editor.top_bar_config(), editor.bottom_bar_config()] .map(|line| chrome_visible(editor, &chrome_groups(editor, line, syntax))); document_layout(terminal.backend().buffer().area, visible)[1] }; let caret = render(&mut terminal, &mut views, &mut session); let resolver = session.editor().resolver().unwrap(); assert_eq!(resolver.files.len(), 3); let current_file = resolver.current_file; let draft_pane = resolver.draft_pane; let alternative_pane = resolver.alternative_pane.unwrap(); let alternative_index = resolver.selected_source; let original_draft = session.editor().lines().to_vec(); let geometry = views.panels.diff.as_ref().unwrap(); let draft = geometry.regions[1].0; let alternative = geometry.regions[2].0; assert!(draft.contains(caret)); assert_eq!(views.rects.len(), 2); assert_eq!((draft.y, draft.height), (alternative.y, alternative.height)); assert_eq!(draft.right(), alternative.x); assert!(draft.height > 20); assert!(views.panels.files.is_none()); let screen = screen_text(&terminal); assert!(screen.contains("unchanged lines hidden")); assert!(!screen.contains("context 30")); assert!(!screen.contains("<<<<<<<") && !screen.contains("%%%%%%%")); let line_at = |terminal: &Terminal, area: Rect, y: u16| { (area.x..area.right()) .map(|x| terminal.backend().buffer()[(x, y)].symbol()) .collect::() }; let left_heading = line_at(&terminal, draft, draft.y); let right_heading = line_at(&terminal, alternative, alternative.y); assert!(left_heading.contains("Resolution") && left_heading.contains("editable")); assert!(right_heading.contains("Other version") && right_heading.contains("read-only")); for heading in [&left_heading, &right_heading] { assert!(!heading.contains("Branch changes")); assert!(!heading.contains(resolver.files[current_file].path.to_string_lossy().as_ref())); assert!(!heading.split_whitespace().any(|word| word == "M")); } assert!( line_at(&terminal, alternative, alternative.bottom() - 1) .contains(&resolver.files[current_file].sources[alternative_index].label) ); assert!( line_at(&terminal, draft, draft.bottom() - 1) .chars() .all(|ch| !ch.is_alphanumeric()), "The output must not repeat its seed source metadata" ); for (side, area) in [draft, alternative].into_iter().enumerate() { let content = diff_inner(area); let x = content.right() - geometry.text_widths[side]; let buffer = terminal.backend().buffer(); assert_eq!(buffer[(x, content.y)].symbol(), "v"); assert_eq!( buffer[(x, content.y)].bg, views.panels.diff_colors.row[side] ); assert_eq!( buffer[(x + 8, content.y)].bg, views.panels.diff_colors.character[side] ); } assert_ne!( line_at(&terminal, diff_inner(draft), draft.y + 1), line_at(&terminal, diff_inner(alternative), alternative.y + 1) ); for area in [draft, alternative] { assert!( line_at(&terminal, diff_inner(area), area.y + 2).contains("opening context 00"), "Preexisting unsaved working-buffer edits must appear in both projections" ); } let diff = session.editor().diff().unwrap(); let file = &diff.comparison.as_ref().unwrap().files[diff.file]; let mut reference_file = syntax_diff_file( &file.path.to_string_lossy(), &file.before.join("\n"), &file.after.join("\n"), ); reference_file.before_eof = file.before_eof; reference_file.after_eof = file.after_eof; reference_file.hunks = crate::diff::line_hunks( &reference_file.before, reference_file.before_eof, &reference_file.after, reference_file.after_eof, ); let mut reference = Editor::open(None, vec![]).unwrap(); for _ in 1..session.editor().tab_count() { reference.execute("tab.new"); } reference .set_chrome_config( session.editor().top_bar_config().clone(), session.editor().bottom_bar_config().clone(), BTreeMap::new(), ) .unwrap(); install_readonly_diff(&mut reference, reference_file); reference.handle(Input::Ctrl('w')); let cursor = session.editor().cursor(); for ch in format!("{}g", cursor.row + 1).chars() { reference.handle(Input::Char(ch)); } reference.handle(Input::Home); for _ in 0..cursor.column { reference.handle(Input::Right); } assert_eq!(reference.cursor(), cursor); assert_eq!( reference.diff_buffer_focus(), session.editor().diff_buffer_focus() ); let mut reference_terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); let mut reference_views = BufferViewports::default(); reference_views.set_theme(Some(DARK_DIFF_THEME)); for command in [ None, Some("diff.orientation.toggle"), Some("diff.view.toggle"), Some("diff.view.toggle"), Some("diff.orientation.toggle"), ] { if let Some(command) = command { session.execute(command); reference.execute(command); } render(&mut terminal, &mut views, &mut session); render_native_diff( &mut reference_terminal, &mut reference_views, &mut reference, ); let actual = views.panels.diff.as_ref().unwrap(); let expected = reference_views.panels.diff.as_ref().unwrap(); assert_eq!(actual.view_mode, expected.view_mode); assert_eq!(actual.split_axis, expected.split_axis); assert_eq!(actual.text_widths, expected.text_widths); for (side, ((actual_pane, _), (expected_pane, _))) in actual.regions[1..] .iter() .zip(&expected.regions[1..]) .enumerate() { assert_eq!( ( actual_pane.x, actual_pane.y, actual_pane.width, actual_pane.height ), ( expected_pane.x, expected_pane.y, expected_pane.width, expected_pane.height ) ); let actual_text = Rect { x: diff_inner(*actual_pane).right() - actual.text_widths[side], width: actual.text_widths[side], ..diff_inner(*actual_pane) }; let expected_text = Rect { x: diff_inner(*expected_pane).right() - expected.text_widths[side], width: expected.text_widths[side], ..diff_inner(*expected_pane) }; for row in 0..actual_text.height { assert_eq!( line_at(&terminal, actual_text, actual_text.y + row), line_at(&reference_terminal, expected_text, expected_text.y + row,), "{command:?}, row {row}, full rows: {:?} / {:?}", line_at(&terminal, diff_inner(*actual_pane), actual_text.y + row), line_at( &reference_terminal, diff_inner(*expected_pane), expected_text.y + row, ), ); } } } let folded_context_rows = screen_text(&terminal).matches("context").count(); session.handle(Input::Char(']')); reference.handle(Input::Char(']')); render(&mut terminal, &mut views, &mut session); render_native_diff( &mut reference_terminal, &mut reference_views, &mut reference, ); let folds = |editor: &Editor| { editor .diff() .unwrap() .display_rows .iter() .filter_map(|row| match row { DisplayRow::Fold { start, end } => Some((*start, *end)), _ => None, }) .collect::>() }; assert_eq!(folds(session.editor()), folds(&reference)); let expanded_context_rows = screen_text(&terminal).matches("context").count(); assert!(expanded_context_rows > folded_context_rows); assert_eq!( expanded_context_rows, screen_text(&reference_terminal).matches("context").count() ); let third_source = session.editor().resolver().unwrap().files[current_file] .sources .iter() .position(|source| !source.base && source.text.starts_with("value = third;")) .unwrap(); session.execute(&format!("resolve.source {}", third_source + 1)); render(&mut terminal, &mut views, &mut session); assert!( line_at(&terminal, diff_inner(alternative), alternative.y + 1).contains("value = third;") ); let right_content = diff_inner(alternative); assert!( (right_content.y..right_content.bottom()) .any(|y| { line_at(&terminal, right_content, y).contains("opening context 00") }) ); session.execute("resolve.draft"); assert_eq!(session.editor().lines(), original_draft); session.execute(&format!("resolve.source {}", alternative_index + 1)); session.execute("resolve.draft"); session.handle(Input::Char('\t')); let caret = render(&mut terminal, &mut views, &mut session); assert_eq!(session.editor().pane_id(), alternative_pane); assert!(alternative.contains(caret)); let readonly = session.editor().lines().to_vec(); session.handle(Input::Char('i')); session.handle(Input::Char('X')); session.handle(Input::Escape); assert_eq!(session.editor().lines(), readonly); session.execute("resolve.draft"); session.handle(Input::Home); for _ in 0..8 { session.handle(Input::Right); } session.handle(Input::Char('i')); session.handle(Input::Paste("\ndraft-only".into())); session.handle(Input::Escape); render(&mut terminal, &mut views, &mut session); let geometry = views.panels.diff.as_ref().unwrap(); let right = diff_inner(geometry.regions[2].0); assert!( (right.y..right.bottom()) .any(|y| { line_at(&terminal, right, y).chars().all(|ch| ch == '╱') }), "The shorter alternative must render an aligned gap" ); let context_rows: Vec<_> = geometry.regions[1..] .iter() .map(|(area, _)| { let content = diff_inner(*area); (content.y..content.bottom()) .find(|&y| line_at(&terminal, content, y).contains("context 00")) .unwrap() }) .collect(); assert_eq!(context_rows[0], context_rows[1]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), original_draft); session.handle(Input::Down); session.handle(Input::Home); session.handle(Input::Char('i')); session.handle(Input::Paste("manual ".into())); session.handle(Input::Escape); render(&mut terminal, &mut views, &mut session); let geometry = views.panels.diff.as_ref().unwrap(); let left = diff_inner(geometry.regions[1].0); let right = diff_inner(geometry.regions[2].0); let left_heading = line_at(&terminal, geometry.regions[1].0, geometry.regions[1].0.y); let right_heading = line_at(&terminal, geometry.regions[2].0, geometry.regions[2].0.y); assert!(left_heading.contains("unsaved")); assert!(!right_heading.contains("unsaved")); assert!(right_heading.contains("read-only")); assert!(line_at(&terminal, left, left.y + 1).contains("manual opening context 00")); assert!(line_at(&terminal, right, right.y + 1).contains("opening context 00")); assert!(!line_at(&terminal, right, right.y + 1).contains("manual")); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), original_draft); session.handle(Input::Up); session.execute(&format!("resolve.use_source {}", alternative_index + 1)); render(&mut terminal, &mut views, &mut session); let taken = session.editor().lines().to_vec(); assert_ne!(taken[0], original_draft[0]); assert_eq!( taken[61], original_draft[61], "Taking a decision must not replace another hunk" ); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), original_draft); session.handle(Input::Char(']')); render(&mut terminal, &mut views, &mut session); assert!(screen_text(&terminal).contains("context 10")); let cursor = session.editor().cursor(); let before_scroll = views.panels.diff.as_ref().unwrap().viewport.unwrap().2; assert!(views.scroll( &mut session, document_wheel( MouseEventKind::ScrollDown, alternative.x + 1, alternative.y + 1, ) )); render(&mut terminal, &mut views, &mut session); assert!(views.panels.diff.as_ref().unwrap().viewport.unwrap().2 > before_scroll); assert_eq!(session.editor().pane_id(), alternative_pane); assert_eq!(session.editor().diff_buffer_focus(), Some(Focus::After)); assert_eq!(session.editor().cursor(), cursor); let geometry = views.panels.diff.as_ref().unwrap(); assert!(line_at(&terminal, diff_inner(geometry.regions[1].0), draft.y + 1).contains("context")); assert!( line_at( &terminal, diff_inner(geometry.regions[2].0), alternative.y + 1 ) .contains("context") ); session.execute("resolve.draft"); let search_row = session .editor() .lines() .iter() .position(|line| line == "context 30") .unwrap(); for ch in format!("{}g", search_row + 1).chars() { session.handle(Input::Char(ch)); } session.handle(Input::Char('/')); for ch in "context 30".chars() { session.handle(Input::Char(ch)); } session.handle(Input::Enter); let searched_caret = render(&mut terminal, &mut views, &mut session); assert!(screen_text(&terminal).contains("context 30")); assert_eq!( terminal.backend().buffer()[(searched_caret.x, searched_caret.y)].bg, Color::Yellow, "source {:?} ({:?}), terminal {searched_caret:?}, caret {:?}, viewport {:?}, focus {:?}", session.editor().cursor(), session.editor().lines()[session.editor().cursor().row], views.panels.caret, views.panels.diff.as_ref().unwrap().viewport, session.editor().diff_buffer_focus(), ); session.handle(Input::Char('v')); session.handle(Input::Right); render(&mut terminal, &mut views, &mut session); assert_eq!( terminal.backend().buffer()[(searched_caret.x, searched_caret.y)].bg, Color::Blue ); session.handle(Input::Escape); session.handle(Input::Left); let searched_caret = render(&mut terminal, &mut views, &mut session); let draft_cursor = session.editor().cursor(); let draft_viewport = views.panels.diff.as_ref().unwrap().viewport; session.execute("resolve.history"); render(&mut terminal, &mut views, &mut session); assert_eq!(views.rects.len(), 3); for (key, title) in [ ('e', "Explorer · Tree NORMAL"), ('o', "Files · Tree INSERT"), ] { session.handle(Input::Char(' ')); session.handle(Input::Char(key)); let picker_caret = render(&mut terminal, &mut views, &mut session); let panel = views.panels.files.unwrap().0; assert!(panel.contains(picker_caret)); assert!(screen_text(&terminal).contains(title)); if key == 'e' { assert_eq!(views.panels.diff.as_ref().unwrap().regions[0].0, panel); assert!(views.rects.iter().all(|(_, area)| area.right() <= panel.x)); } else { assert_eq!( panel, floating_panel(workspace(&terminal, &views, session.editor())) ); } session.handle(Input::Ctrl('w')); render(&mut terminal, &mut views, &mut session); assert!(views.panels.files.is_none()); assert_eq!(views.rects.len(), 3); } assert!( views .rects .iter() .all(|(pane, _)| *pane != draft_pane && *pane != alternative_pane) ); let source_rows: Vec<_> = views .rects .iter() .map(|(_, area)| line_at(&terminal, *area, area.y)) .collect(); assert!(source_rows.iter().any(|row| row.contains("base"))); assert!(source_rows.iter().any(|row| row.contains("left"))); assert!(source_rows.iter().any(|row| row.contains("right"))); let history = &views.history[&views.history_key.unwrap()]; for (column, (_, content)) in views.rects.iter().enumerate() { let x = content.right() - history.widths[column] as u16; let side = usize::from(column != 1); let buffer = terminal.backend().buffer(); assert_eq!(buffer[(x, content.y)].symbol(), "v"); assert_eq!( buffer[(x, content.y)].bg, views.panels.diff_colors.row[side] ); assert_eq!( buffer[(x + 8, content.y)].bg, views.panels.diff_colors.character[side] ); assert_eq!( buffer[(x, content.y + 1)].bg, Color::Reset, "Shared historical context must not be highlighted as changed" ); } let readonly = session.editor().lines().to_vec(); session.handle(Input::Char('i')); session.handle(Input::Char('X')); session.handle(Input::Escape); assert_eq!(session.editor().lines(), readonly); let resolver = session.editor().resolver().unwrap(); let right_history = resolver.history_panes[resolver.history_sources[1]]; session.editor_mut().focus_resolver_pane(right_history); session.execute(&format!("resolve.source {}", third_source + 1)); render(&mut terminal, &mut views, &mut session); assert!( views .rects .iter() .any(|(_, area)| { line_at(&terminal, *area, area.y).contains("value = third;") }) ); let history_areas = [views.rects[0].1, views.rects[1].1, views.rects[2].1]; for content in history_areas.into_iter().take(2) { assert!( line_at(&terminal, content, content.y + 1) .chars() .all(|ch| ch == '╱'), "The historical input insertion must leave aligned gaps in input/base" ); } assert!(line_at(&terminal, history_areas[2], history_areas[2].y + 1).contains("third-only")); let context_rows: Vec<_> = history_areas .into_iter() .map(|content| { (content.y..content.bottom()) .find(|&y| line_at(&terminal, content, y).contains("context 00")) .unwrap() }) .collect(); assert!(context_rows.iter().all(|row| *row == context_rows[0])); session.handle(Input::Down); session.handle(Input::End); session.execute("word_wrap.toggle"); let history_caret = render(&mut terminal, &mut views, &mut session); assert!(history_areas[2].contains(history_caret)); let history = &views.history[&views.history_key.unwrap()]; assert!( history.visible[2] .iter() .any(|row| row.position.continuation > 0) ); let continuation = history.visible[2] .iter() .position(|row| row.position.continuation > 0) .unwrap(); for content in history_areas.into_iter().take(2) { assert!( line_at(&terminal, content, content.y + continuation as u16) .chars() .all(|ch| ch == '╱') ); } session.execute("word_wrap.toggle"); session.handle(Input::Home); session.handle(Input::Up); render(&mut terminal, &mut views, &mut session); let history_pane = session.editor().pane_id(); let history_cursor = session.editor().cursor(); assert!(views.scroll( &mut session, document_wheel( MouseEventKind::ScrollDown, history_areas[1].x, history_areas[1].y ) )); render(&mut terminal, &mut views, &mut session); for content in history_areas { assert!(line_at(&terminal, content, content.y).contains("context 01")); } assert_eq!(session.editor().pane_id(), history_pane); assert_eq!(session.editor().cursor(), history_cursor); assert!(views.scroll( &mut session, document_wheel( MouseEventKind::ScrollUp, history_areas[1].x, history_areas[1].y ) )); session.execute("resolve.base"); terminal.backend_mut().resize(44, 20); let caret = render(&mut terminal, &mut views, &mut session); assert_eq!(views.rects.len(), 1); assert!(views.rects[0].1.contains(caret)); assert!(screen_text(&terminal).contains("value = base;")); terminal.backend_mut().resize(120, 30); session.execute("resolve.draft"); let restored_caret = render(&mut terminal, &mut views, &mut session); assert_eq!(session.editor().cursor(), draft_cursor); assert_eq!(views.panels.diff.as_ref().unwrap().viewport, draft_viewport); assert_eq!(restored_caret, searched_caret); { let config = directory.file( "base-below.rb", "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.resolve_history_layout = 'base_below'\nend\n", ); let mut bottom = EditorSession::open( Some(directory.0.join("first.txt")), vec!["text".into()], Some(&config), ) .unwrap(); bottom.execute("resolve"); ready(&mut bottom); let draft = bottom.editor().context().text; bottom.execute("resolve.history"); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); let mut views = BufferViewports::default(); render(&mut terminal, &mut views, &mut bottom); let resolver = bottom.editor().resolver().unwrap(); let left = resolver.history_panes[resolver.history_sources[0]]; let right = resolver.history_panes[resolver.history_sources[1]]; let base = resolver.history_panes[resolver.selected_base.unwrap()]; assert_eq!(views.rects.len(), 3); assert_eq!( views .rects .iter() .map(|(pane, _)| *pane) .collect::>(), [left, right, base] ); let [left_area, right_area, base_area] = std::array::from_fn(|index| views.rects[index].1); assert_eq!(left_area.right(), right_area.x); assert_eq!(left_area.y, right_area.y); assert_eq!(base_area.x, left_area.x); assert_eq!(base_area.right(), right_area.right()); assert!(base_area.y >= left_area.bottom()); for (area, value) in [ (left_area, "value = left;"), (right_area, "value = right;"), (base_area, "value = base;"), ] { assert!(line_at(&terminal, area, area.y).contains(value)); } bottom.handle(Input::Char('\t')); assert_eq!(bottom.editor().pane_id(), right); bottom.handle(Input::Char('\t')); assert_eq!(bottom.editor().pane_id(), base); bottom.handle(Input::BackTab); assert_eq!(bottom.editor().pane_id(), right); bottom.execute("pane.focus_down"); let caret = render(&mut terminal, &mut views, &mut bottom); assert_eq!(bottom.editor().pane_id(), base); assert!(base_area.contains(caret)); assert!(views.scroll( &mut bottom, document_wheel(MouseEventKind::ScrollDown, base_area.x, base_area.y) )); render(&mut terminal, &mut views, &mut bottom); for area in [left_area, right_area, base_area] { assert!(line_at(&terminal, area, area.y).contains("context 02")); } let readonly = bottom.editor().context().text; bottom.handle(Input::Char('i')); bottom.handle(Input::Paste("must not edit base".into())); bottom.handle(Input::Escape); assert_eq!(bottom.editor().context().text, readonly); terminal.backend_mut().resize(44, 20); render(&mut terminal, &mut views, &mut bottom); assert_eq!(views.rects.len(), 1); assert_eq!(views.rects[0].0, base); terminal.backend_mut().resize(120, 6); render(&mut terminal, &mut views, &mut bottom); assert_eq!(views.rects.len(), 1); assert_eq!(views.rects[0].0, base); bottom.execute("resolve.draft"); assert_eq!(bottom.editor().context().text, draft); } for (key, action) in [ ("ctrl+e", "core.file_explorer"), ("ctrl+o", "core.file_picker"), ] { session .editor_mut() .bind("normal", key, Some(action.into())) .unwrap(); } session.editor_mut().set_file_explorer_view("list").unwrap(); for key in ["ctrl+j", "ctrl+n", "down"] { session.editor_mut().bind("selection", key, None).unwrap(); } session .editor_mut() .bind("selection", "ctrl+t", Some("core.down".into())) .unwrap(); session.handle(Input::Ctrl('e')); let picker_caret = render(&mut terminal, &mut views, &mut session); let dock = views.panels.files.unwrap().0; assert_eq!( dock, dock_layout( workspace(&terminal, &views, session.editor()), DockSide::Right, true ) .1 .unwrap() ); assert_eq!(views.panels.diff.as_ref().unwrap().regions[0].0, dock); assert!(dock.contains(picker_caret)); assert!(screen_text(&terminal).contains("Explorer · List")); let selected = session.editor().file_picker().unwrap().selected; assert_eq!( session .editor() .file_picker() .unwrap() .entries() .nth(selected) .unwrap() .path, session.editor().resolver().unwrap().files[current_file] .path .as_path(), ); session.handle(Input::Ctrl('t')); let moved_caret = render(&mut terminal, &mut views, &mut session); assert_ne!(session.editor().file_picker().unwrap().selected, selected); assert_eq!(moved_caret, picker_caret); let selected_row = (dock.y..dock.bottom()) .find(|&row| line_at(&terminal, dock, row).contains("second.txt")) .unwrap(); assert_eq!( terminal.backend().buffer()[(dock.x + 1, selected_row)].bg, Color::Blue ); for ch in "second".chars() { session.handle(Input::Char(ch)); } render(&mut terminal, &mut views, &mut session); assert!(screen_text(&terminal).contains("second.txt")); assert!(!screen_text(&terminal).contains("binary.bin")); session.handle(Input::Enter); render(&mut terminal, &mut views, &mut session); let resolver = session.editor().resolver().unwrap(); assert_eq!( resolver.files[resolver.current_file].path, std::path::Path::new("second.txt") ); session.handle(Input::Ctrl('o')); session.handle(Input::Ctrl('u')); for ch in "first".chars() { session.handle(Input::Char(ch)); } render(&mut terminal, &mut views, &mut session); let floating = views.panels.files.unwrap().0; assert_eq!( floating, floating_panel(workspace(&terminal, &views, session.editor())) ); assert!(screen_text(&terminal).contains("Files · Tree INSERT")); session.handle(Input::Enter); session.execute("resolve.draft"); render(&mut terminal, &mut views, &mut session); assert_eq!( session.editor().resolver().unwrap().current_file, current_file ); assert!(views.panels.files.is_none()); assert_eq!( operation(), before_browsing, "Rendering/navigation must not snapshot jj" ); session.handle(Input::Home); session.handle(Input::Char('i')); session.handle(Input::Paste("wrapped ".repeat(12))); session.handle(Input::Escape); session.execute("word_wrap.toggle"); terminal.backend_mut().resize(44, 20); let caret = render(&mut terminal, &mut views, &mut session); let geometry = views.panels.diff.as_ref().unwrap(); assert!(geometry.regions[1].0.contains(caret)); assert!( views .panels .diff_wrap .as_ref() .unwrap() .rows .iter() .any(|row| row.position.continuation > 0) ); session.handle(Input::Home); let caret = render(&mut terminal, &mut views, &mut session); let geometry = views.panels.diff.as_ref().unwrap(); assert!(geometry.regions[1].0.contains(caret)); let content = diff_inner(geometry.regions[1].0); assert!( (content.y..content.bottom()) .filter(|&y| { line_at(&terminal, content, y).contains("wrapped") }) .count() > 1, "cursor {:?}, source {:?}, wrapped frame:\n{}", session.editor().cursor(), session.editor().lines()[session.editor().cursor().row], screen_text(&terminal), ); let right = diff_inner(geometry.regions[2].0); assert!((right.y..right.bottom()).all(|y| !line_at(&terminal, right, y).contains("wrapped"))); session.handle(Input::Char('u')); session.execute("word_wrap.toggle"); session.execute("w"); ready(&mut session); terminal.backend_mut().resize(120, 30); render(&mut terminal, &mut views, &mut session); let resolver = session.editor().resolver().unwrap(); assert_eq!( resolver.files[current_file].status, ResolverStatus::Resolved ); let binary = resolver .files .iter() .position(|file| file.path == std::path::Path::new("binary.bin")) .unwrap(); for (width, height) in [(19, 12), (4, 4), (1, 1), (0, 0)] { terminal.backend_mut().resize(width, height); render(&mut terminal, &mut views, &mut session); for (_, area) in &views.rects { assert!(area.right() <= width && area.bottom() <= height); } } terminal.backend_mut().resize(120, 30); session.editor_mut().select_resolver_file(binary); render(&mut terminal, &mut views, &mut session); assert!(screen_text(&terminal).contains("Whole-file conflict")); assert!(views.rects.is_empty()); let resolver = session.editor().resolver().unwrap(); let side = resolver.files[binary] .sources .iter() .position(|source| !source.base) .unwrap() + 1; session.execute(&format!("resolve.use_source {side}")); render(&mut terminal, &mut views, &mut session); assert!(screen_text(&terminal).contains("Selected:")); for (width, height) in [(4, 4), (1, 1), (0, 0)] { terminal.backend_mut().resize(width, height); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); } } 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 { review_revisions: None, stats: None, loaded: true, before_eof: false, after_eof: false, 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_scope_anchor_keeps_nearest_top_when_declarations_reclaim_rows() { let before = (0..4) .map(|index| { format!( "impl Container{index} {{\n fn run(&self) {{\n old_call();\n keep();\n }}\n}}\n" ) }) .collect::(); let after = before .replace("Container", "Renamed") .replace("old_call", "new_call"); let mut diff = diff_comparison(vec![syntax_diff_file("scope.rs", &before, &after)]); diff.focus = Focus::After; let file = &diff.comparison.as_ref().unwrap().files[0]; let areas = split_rect(pane_rect(Rect::new(0, 0, 120, 35)), SplitAxis::Horizontal).map(terminal_rect); let mut syntax = DiffSyntaxCache::default(); syntax.update( diff.comparison_id.unwrap(), 0, file, &file.after, None, None, ); let mut viewport = DiffWrapViewport::default(); let cursor = Cursor { row: 15, column: 7 }; viewport.update( &diff, file, areas, Some((Focus::After, cursor)), Some(15), Some(0), &syntax, ); let position = viewport .caret_position(&diff, file, Focus::After, cursor) .unwrap(); assert_eq!(viewport.screen_row(position), Some(15)); assert_eq!( diff.display_source_row(Focus::After, viewport.top.display), Some(2), "Keep the nearest enclosing function's sticky context, not earlier declarations" ); } #[test] fn diff_scope_anchor_stops_at_first_target_across_unified_visibility_changes() { let before = (0..12) .map(|index| { format!( "impl Container{index} {{\n fn run(&self) {{\n old_call();\n keep();\n }}\n}}\n" ) }) .collect::(); let after = before .replace("Container", "Renamed") .replace("old_call", "new_call"); let mut file = syntax_diff_file("scope.rs", &before, &after); file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); let mut diff = diff_comparison(vec![file]); diff.set_context_options(3, 15); diff.set_view_mode(ViewMode::Unified); diff.focus = Focus::Before; let file = &diff.comparison.as_ref().unwrap().files[0]; let mut syntax = DiffSyntaxCache::default(); syntax.update( diff.comparison_id.unwrap(), 0, file, &file.after, None, None, ); let mut viewport = DiffWrapViewport::default(); viewport.update( &diff, file, [Rect::new(0, 0, 120, 35); 2], Some((Focus::Before, Cursor { row: 12, column: 7 })), Some(16), Some(0), &syntax, ); assert_eq!( diff.display_source_row(Focus::After, viewport.top.display), Some(2), "Do not move past the first anchor when an earlier declaration hides two labels" ); assert_eq!(viewport.rows[0].scope, [false, true]); } #[test] fn diff_scope_horizontal_scroll_keeps_source_rows_fixed() { let before = "keep_previous_code();\nfn review() {\n old_call_with_a_long_argument_name();\n}\n"; let after = before.replace("old_call", "new_call"); for (mode, axis, focus) in [ (ViewMode::Split, SplitAxis::Vertical, Focus::After), (ViewMode::Split, SplitAxis::Horizontal, Focus::After), (ViewMode::Unified, SplitAxis::Vertical, Focus::Before), (ViewMode::Unified, SplitAxis::Vertical, Focus::After), ] { let mut file = syntax_diff_file("scope.rs", before, &after); file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); let mut diff = diff_comparison(vec![file]); diff.files_visible = false; diff.split_axis = axis; diff.set_view_mode(mode); diff.focus = focus; let declaration = diff.source_display_row(Focus::After, 1).unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); let mut panels = WheelPanels::default(); // A declaration at the top has no room for an overlay; leave its border intact. for top in [0, declaration] { diff.top = top; let mut original = None; let mut borders = [None, None]; for lefts in [[0, 0], [8, 0], [0, 8], [8, 8], [0, 0]] { [diff.left_before, diff.left_after] = lefts; render_diff(&mut terminal, &diff, &mut panels); let viewport = panels.diff_wrap.as_ref().unwrap(); let source_rows = viewport .rows .iter() .enumerate() .filter(|(_, row)| row.scope == [false; 2]) .map(|(screen, row)| (screen, row.position)) .collect::>(); if let Some(original) = &original { assert_eq!( &source_rows, original, "Horizontal scrolling must not move source rows: {mode:?}, {axis:?}" ); } else { original = Some(source_rows); } let screen = viewport .screen_row(DiffWrapPosition { display: declaration, continuation: 0, }) .unwrap(); for (pane, left) in lefts.into_iter().enumerate() { if mode == ViewMode::Unified && (focus == Focus::Before) != (pane == 0) { continue; } let area = diff_inner(panels.diff.as_ref().unwrap().regions[pane + 1].0); let above = pane_screen( &terminal, Rect::new(area.x, area.y + screen as u16 - 1, area.width, 1), ); assert_eq!( above.contains("review"), left > 0 && screen > 0, "Clipped names belong above the declaration, without adding a row" ); if screen == 0 { let border = borders[pane].get_or_insert_with(|| above.clone()); assert_eq!( &above, border, "Horizontal scrolling must preserve the border" ); } else if left > 0 { assert_eq!( above.trim(), if mode == ViewMode::Unified { "± review" } else { "› review" }, "The overlay must clear source text and its line number" ); } } } } } } #[test] fn diff_scope_overlay_preserves_visible_declaration_above() { let before = "pub fn old_name() {\n shared_call_with_a_long_argument_name();\n}\n"; let after = "fn new_name() {\n shared_call_with_a_long_argument_name();\n}\n"; for focus in [Focus::Before, Focus::After] { let mut file = syntax_diff_file("scope.rs", before, after); file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); let mut diff = diff_comparison(vec![file]); diff.files_visible = false; diff.set_view_mode(ViewMode::Unified); diff.focus = focus; diff.top = diff.source_display_row(Focus::Before, 0).unwrap(); diff.left_before = 4; diff.left_after = 4; let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 20)).unwrap(); let mut panels = WheelPanels::default(); render_diff(&mut terminal, &diff, &mut panels); let pane = usize::from(focus == Focus::After); let area = diff_inner(panels.diff.as_ref().unwrap().regions[pane + 1].0); let first = pane_screen(&terminal, Rect::new(area.x, area.y, area.width, 1)); assert!( first.contains("fn old_name()"), "Visible declaration was covered: {first}" ); assert!(!first.contains("+ new_name")); } } #[test] fn diff_scope_sticky_row_keeps_adjacent_declaration_numbered() { let after = "\n#[allow(clippy::too_many_arguments)]\nfn draw_diff_scope_labels(\n frame: &mut Frame,\n diff: &DiffState,\n) {\n work();\n}\n"; for (mode, axis, wrap) in [ (ViewMode::Split, SplitAxis::Vertical, false), (ViewMode::Split, SplitAxis::Horizontal, false), (ViewMode::Split, SplitAxis::Vertical, true), (ViewMode::Unified, SplitAxis::Vertical, false), (ViewMode::Unified, SplitAxis::Vertical, true), ] { let mut file = syntax_diff_file("tui.rs", "", after); file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); let mut diff = diff_comparison(vec![file]); diff.files_visible = false; diff.focus = Focus::After; diff.split_axis = axis; diff.word_wrap = wrap; diff.set_view_mode(mode); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 24)).unwrap(); let mut panels = WheelPanels::default(); for source in [0, 1, 2, 3, 4, 3, 2] { diff.top = diff.source_display_row(Focus::After, source).unwrap(); render_diff(&mut terminal, &diff, &mut panels); let area = diff_inner(panels.diff.as_ref().unwrap().regions[2].0); let text = pane_screen(&terminal, area); if source <= 3 { let declaration = text .lines() .find(|line| line.contains("fn draw_diff_scope_labels(")); assert!( declaration.is_some_and(|line| line.contains("3+")), "The declaration must keep its source number, not become an unnumbered sticky label at top source {source}:\n{text}" ); } else { assert!(text.lines().any(|line| { line.contains("draw_diff_scope_labels") && !line.contains("fn ") })); } } } } #[test] fn diff_scope_labels_follow_declaration_visibility() { let before = "@decorate()\nclass PolicyActiveStatusEnum(BaseEnum):\n ACTIVE = 1\n EXPIRED = 2\n FUTURE = 3\n UNKNOWN = 4\n"; let after = before.replace(" FUTURE", " CANCELLED = 5\n FUTURE"); for (mode, axis, wrap) in [ (ViewMode::Split, SplitAxis::Vertical, false), (ViewMode::Split, SplitAxis::Horizontal, true), (ViewMode::Unified, SplitAxis::Vertical, true), ] { let mut file = syntax_diff_file("status.py", before, &after); file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); let mut diff = diff_comparison(vec![file]); diff.files_visible = false; diff.split_axis = axis; diff.word_wrap = wrap; diff.set_view_mode(mode); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 32)).unwrap(); let mut panels = WheelPanels::default(); for (source, visible) in [(0, false), (2, false), (3, true), (1, false), (2, false)] { diff.top = diff.source_display_row(Focus::After, source).unwrap(); render_diff(&mut terminal, &diff, &mut panels); let text = screen_text(&terminal); let count = ["› PolicyActiveStatusEnum", "± PolicyActiveStatusEnum"] .iter() .map(|label| text.matches(label).count()) .sum::(); assert_eq!( count, if visible { if mode == ViewMode::Split { 2 } else { 1 } } else { 0 } ); assert!(text.contains("EXPIRED = 2")); } } let before = "def old_name():\n shared()\n tail()\n"; let after = before.replace("old_name", "new_name"); let mut file = syntax_diff_file("renamed.py", before, &after); file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); let mut diff = diff_comparison(vec![file]); diff.files_visible = false; diff.set_view_mode(ViewMode::Unified); diff.top = diff.source_display_row(Focus::After, 0).unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap(); let mut panels = WheelPanels::default(); render_diff(&mut terminal, &diff, &mut panels); let text = screen_text(&terminal); assert!(text.contains("def new_name():")); assert!(text.contains("− old_name")); assert!(!text.contains("+ new_name")); } #[test] fn diff_scope_labels_do_not_repeat_across_hunks_in_one_excerpt() { let before = "def retry_cancellation_email():\n first()\n shared_one()\n old()\n shared_two()\n shared_three()\n tail()\n"; let after = "def retry_cancellation_email():\n first()\n inserted_one()\n inserted_two()\n shared_one()\n shared_two()\n inserted_three()\n shared_three()\n tail()\n"; for mode in [ViewMode::Split, ViewMode::Unified] { let mut file = syntax_diff_file("retry.py", before, after); file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); let mut diff = diff_comparison(vec![file]); diff.files_visible = false; diff.set_view_mode(mode); diff.top = diff.source_display_row(Focus::After, 4).unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 40)).unwrap(); let mut panels = WheelPanels::default(); render_diff(&mut terminal, &diff, &mut panels); if mode == ViewMode::Split { for top in [diff.top, diff.source_display_row(Focus::After, 3).unwrap()] { diff.top = top; render_diff(&mut terminal, &diff, &mut panels); for side in [1, 2] { let area = panels.diff.as_ref().unwrap().regions[side].0; assert_eq!( pane_screen(&terminal, area) .matches("› retry_cancellation_email") .count(), 1 ); } } diff.set_context_options(0, 15); render_diff(&mut terminal, &diff, &mut panels); for (side, expected) in [(1, 1), (2, 2)] { let area = panels.diff.as_ref().unwrap().regions[side].0; assert_eq!( pane_screen(&terminal, area) .matches("› retry_cancellation_email") .count(), expected ); } } else { assert_eq!( screen_text(&terminal) .matches("± retry_cancellation_email") .count(), 1 ); assert!(!screen_text(&terminal).contains("+ retry_cancellation_email")); assert!(!screen_text(&terminal).contains("− retry_cancellation_email")); } } } #[test] fn diff_scope_labels_identify_multiple_scopes_and_sticky_renamed_sides() { let before = "impl Review {\n fn old_name(&self) {\n let first = 1;\n let second = 2;\n }\n fn next(&self) {\n let third = 3;\n }\n}"; let after = before .replace("old_name", "new_name") .replace("first = 1", "first = 9"); for axis in [SplitAxis::Vertical, SplitAxis::Horizontal] { for wrapping in [false, true] { let mut diff = diff_comparison(vec![syntax_diff_file("scope.rs", before, &after)]); diff.files_visible = false; diff.split_axis = axis; diff.word_wrap = wrapping; diff.top = diff.source_display_row(Focus::Before, 3).unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 48)).unwrap(); let mut panels = WheelPanels::default(); render_diff(&mut terminal, &diff, &mut panels); let rows = &panels.diff_wrap.as_ref().unwrap().rows; for (side, name, other) in [(0, "old_name", "new_name"), (1, "new_name", "old_name")] { let area = diff_inner(panels.diff.as_ref().unwrap().regions[side + 1].0); let labels = rows .iter() .enumerate() .filter(|(_, row)| row.scope[side]) .map(|(screen, _)| { (area.x..area.right()) .map(|x| { terminal.backend().buffer()[(x, area.y + screen as u16)].symbol() }) .collect::() }) .collect::>(); assert!(labels.iter().any(|label| label.contains(name))); assert!(!labels.iter().any(|label| label.contains("next"))); assert!(pane_screen(&terminal, area).contains("fn next")); assert!(!labels.iter().any(|label| label.contains(other))); } let syntax = panels.diff_syntax.as_ref().unwrap(); let parses = (syntax.before.parse_counts(), syntax.after.parse_counts()); diff.top = diff.source_display_row(Focus::Before, 3).unwrap(); render_diff(&mut terminal, &diff, &mut panels); let rows = &panels.diff_wrap.as_ref().unwrap().rows; assert_eq!(rows[0].scope, [true; 2]); assert_eq!(rows[1].scope, [false; 2]); assert_eq!(rows[0].position, rows[1].position); let first = diff_screen_rows(diff.display_rows.len(), diff.top, 48, Some(rows)) .next() .unwrap(); assert_eq!((first.0, first.1), (1, diff.top)); let syntax = panels.diff_syntax.as_ref().unwrap(); assert_eq!( (syntax.before.parse_counts(), syntax.after.parse_counts()), parses ); } } let mut narrow = diff_comparison(vec![syntax_diff_file("scope.rs", before, &after)]); narrow.files_visible = false; narrow.top = narrow.source_display_row(Focus::Before, 3).unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 30)).unwrap(); let mut panels = WheelPanels::default(); render_diff(&mut terminal, &narrow, &mut panels); assert!(screen_text(&terminal).contains("› old_name")); assert!(screen_text(&terminal).contains("› new_name")); let diff = diff_comparison(vec![syntax_diff_file("scope.txt", before, &after)]); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap(); let mut panels = WheelPanels::default(); render_diff(&mut terminal, &diff, &mut panels); assert!( panels .diff_wrap .as_ref() .unwrap() .rows .iter() .all(|row| row.scope == [false; 2]) ); } #[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 source_row = panels .diff_wrap .as_ref() .unwrap() .rows .iter() .position(|row| row.scope == [false; 2]) .unwrap() as u16; let after = panels.diff.as_ref().unwrap().regions[2].0; let cell = &terminal.backend().buffer()[(after.x + 4, after.y + 1 + source_row)]; assert_eq!(cell.fg, foreground, "{source}"); let before = panels.diff.as_ref().unwrap().regions[1].0; let cell = &terminal.backend().buffer()[(before.x + 4, before.y + 1 + source_row)]; assert_eq!(cell.fg, Color::Magenta); } } #[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, &file.after, None, None); 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, &file.after, None, None); 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 (side, region) in [ panels.diff.as_ref().unwrap().regions[1].0, panels.diff.as_ref().unwrap().regions[2].0, ] .into_iter() .enumerate() { let focus = if side == 0 { Focus::Before } else { Focus::After }; let screen = panels .diff_wrap .as_ref() .unwrap() .rows .iter() .position(|row| { row.scope == [false; 2] && diff.display_source_row(focus, row.position.display) == Some(1) }) .unwrap(); let cell = &terminal.backend().buffer()[(region.x + 4, region.y + 1 + screen as u16)]; assert_eq!(cell.fg, Color::Magenta); } } #[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 mut editor = Editor::open(None, vec![]).unwrap(); let mut views = BufferViewports { panels, ..BufferViewports::default() }; terminal .draw(|frame| views.draw(frame, &mut 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 { blame: Default::default(), root: std::path::PathBuf::new(), editable_paths: Default::default(), before: "old revision".into(), after: "Working checkout".into(), revisions: ["before-snapshot".into(), "after-snapshot".into()], files, })), ); diff } fn deletion_file() -> ChangedFile { ChangedFile { review_revisions: None, stats: None, loaded: true, before_eof: false, after_eof: false, 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, } } #[test] fn diff_borders_keep_full_revisions_and_each_panes_own_editability() { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(180, 12)).unwrap(); let mut panels = WheelPanels::default(); for case in ["working", "checked_out", "unlisted", "historical", "binary"] { let mut file = syntax_diff_file("new.rs", "fn old() {}", "fn new() {}"); file.status = FileStatus::Renamed; file.old_path = Some("old.rs".into()); if case == "binary" { file.notice = Some("Binary or non-UTF-8 file; text comparison unavailable".into()); } let mut diff = diff_comparison(vec![file]); diff.files_visible = false; let comparison = diff.comparison.as_mut().unwrap(); comparison.before = "abcdef0123456789 (baseline)".into(); comparison.after = "fedcba9876543210 (destination)".into(); if matches!(case, "working" | "checked_out" | "binary") { comparison.editable_paths.insert("new.rs".into()); } if matches!(case, "historical" | "checked_out") { diff.after.as_mut().unwrap().target = crate::diff::RevisionTarget::Commit("fedcba9876543210".into()); } let writable = matches!(case, "working" | "checked_out"); assert_eq!(diff.after_editable(), writable, "{case}"); for focus in [Focus::Before, Focus::After] { diff.focus = focus; render_diff(&mut terminal, &diff, &mut panels); let regions = panels.diff.as_ref().unwrap().regions; for (index, label, path, state) in [ (1, "abcdef0123456789 (baseline)", "old.rs", "read-only"), ( 2, "fedcba9876543210 (destination)", "new.rs", if writable { "editable" } else { "read-only" }, ), ] { let pane = regions[index].0; let top = pane_screen(&terminal, Rect::new(pane.x, pane.y, pane.width, 1)); let bottom = pane_screen( &terminal, Rect::new(pane.x, pane.bottom() - 1, pane.width, 1), ); assert!(top.contains(path), "{case}: {top}"); assert!( pane_screen(&terminal, Rect::new(pane.right() - 13, pane.y, 12, 1),) .contains(state), "{case}: {top}", ); assert!( bottom .find(label) .is_some_and(|offset| { UnicodeWidthStr::width(&bottom[..offset]) <= 2 }), "{bottom}", ); assert!(!top.contains(label)); assert!(!bottom.contains('→')); assert_eq!(screen_text(&terminal).matches(label).count(), 1); assert_eq!( terminal.backend().buffer()[(pane.x, pane.y)].fg, if regions[index].1 == focus { Color::Cyan } else { Color::DarkGray }, ); } assert!(!screen_text(&terminal).contains("Native buffer bindings")); assert!(!screen_text(&terminal).contains("focus ·")); } } } #[test] fn diff_workspace_reclaims_header_and_footer_without_hiding_notices() { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap(); let mut panels = WheelPanels::default(); let mut diff = diff_comparison(vec![deletion_file()]); for notice in [None, Some("comparison failed"), Some("loading")] { diff.error = notice .filter(|notice| *notice != "loading") .map(str::to_owned); diff.loading = notice == Some("loading"); render_diff(&mut terminal, &diff, &mut panels); let area = terminal.backend().buffer().area; let regions = panels.diff.as_ref().unwrap().regions; for (pane, _) in regions { assert_eq!(pane.y, area.y + u16::from(notice.is_some())); assert_eq!(pane.bottom(), area.bottom()); } if let Some(notice) = notice { let row = pane_screen(&terminal, Rect::new(area.x, area.y, area.width, 1)); assert!(row.contains(if notice == "loading" { "Loading file" } else { notice })); } let [_, before, after] = regions.map(|(pane, _)| pane); assert_eq!(before.right(), after.x); assert_eq!( diff_inner(before).height, area.height - 2 - u16::from(notice.is_some()), ); } } #[test] fn diff_border_titles_clip_unicode_without_overwriting_state_or_text() { let mut file = syntax_diff_file( &format!("界e\u{301}👩‍🚀\n\t{}", "long-path".repeat(12)), "old", "new", ); file.old_path = Some(format!("e\u{301}界👩‍🚀\u{1b}{}", "old-path".repeat(12)).into()); let mut diff = diff_comparison(vec![file]); diff.files_visible = false; let comparison = diff.comparison.as_mut().unwrap(); comparison.before = "before 界e\u{301}👩‍🚀\n\t\u{1b}".into(); comparison.after = "after e\u{301}界👩‍🚀\r\u{7f}".into(); let mut panels = WheelPanels::default(); for (width, height) in [(180, 10), (64, 8), (36, 6), (8, 4), (3, 3), (1, 1)] { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); render_diff(&mut terminal, &diff, &mut panels); for (pane, _) in panels.diff.as_ref().unwrap().regions.into_iter().skip(1) { assert!(pane.right() <= width && pane.bottom() <= height); let buffer = terminal.backend().buffer(); for y in pane.y..pane.bottom() { for x in pane.x..pane.right() { let symbol = buffer[(x, y)].symbol(); assert!(!symbol.chars().any(char::is_control), "{symbol:?}"); assert!(!symbol.starts_with('\u{301}'), "{symbol:?}"); assert!(!symbol.starts_with('\u{200d}'), "{symbol:?}"); if symbol.contains('👩') || symbol.contains('🚀') { assert_eq!(symbol, "👩‍🚀"); } } } if width >= 36 { assert!( pane_screen(&terminal, Rect::new(pane.right() - 13, pane.y, 12, 1)) .contains("read-only") ); let inner = diff_inner(pane); assert_eq!(buffer[(inner.x, inner.y)].symbol(), "1"); assert_eq!( buffer[(inner.x + 3, inner.y)].symbol(), if pane.x == 0 { "o" } else { "n" }, ); } if width == 180 { let bottom = pane_screen( &terminal, Rect::new(pane.x, pane.bottom() - 1, pane.width, 1), ); for grapheme in [ if pane.x == 0 { "before" } else { "after" }, "界", "e\u{301}", "👩‍🚀", ] { assert!(bottom.contains(grapheme), "{bottom}"); } assert_eq!(bottom.matches('�').count(), if pane.x == 0 { 3 } else { 2 }); } } } } #[test] fn diff_status_layouts_hide_absent_panes_and_leave_unchanged_renames_blank() { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 12)).unwrap(); let mut panels = WheelPanels { diff_colors: DiffColors::new(Some(LIGHT_DIFF_THEME)), ..WheelPanels::default() }; for status in [FileStatus::Added, FileStatus::Removed] { let added = status == FileStatus::Added; let mut file = deletion_file(); file.status = status; file.before = if added { vec![] } else { vec!["removed content".into()] }; file.after = if added { vec!["added content".into()] } else { vec![] }; file.hunks = vec![crate::diff::Hunk { before_start: 0, before_len: usize::from(!added), after_start: 0, after_len: usize::from(added), }]; let mut diff = diff_comparison(vec![file]); diff.files_visible = false; diff.comparison .as_mut() .unwrap() .editable_paths .insert("deleted-lines.txt".into()); diff.focus = if added { Focus::After } else { Focus::Before }; diff.left_after = 2; render_diff(&mut terminal, &diff, &mut panels); let geometry = panels.diff.as_ref().unwrap(); let present = geometry.regions[if added { 2 } else { 1 }].0; assert_eq!( geometry.regions[if added { 1 } else { 2 }].0, Rect::default() ); assert_eq!(present.x, 0); assert_eq!(present.width, 160); assert_eq!(geometry.viewport.unwrap().3, 0); assert_eq!(geometry.viewport.unwrap().4, 2); assert_eq!(geometry.regions[if added { 2 } else { 1 }].1, diff.focus); assert_eq!( terminal.backend().buffer()[(present.x, present.y)].fg, Color::Cyan ); for x in present.x + 1..present.right() - 1 { assert_eq!( terminal.backend().buffer()[(x, present.y + 1)].bg, Color::Reset ); } let text = screen_text(&terminal); assert!(text.contains(if added { "ded content" } else { "removed content" })); assert!(text.contains(if added { "After · A" } else { "Before · D" })); assert!(!text.contains(if added { "Before ·" } else { "After ·" })); let bottom = pane_screen( &terminal, Rect::new(present.x, present.bottom() - 1, present.width, 1), ); assert!(bottom.contains("old revision → Working checkout")); assert_eq!(text.matches("old revision").count(), 1); assert_eq!(text.matches("Working checkout").count(), 1); assert!( pane_screen(&terminal, Rect::new(present.right() - 13, present.y, 12, 1),) .contains(if added { "editable" } else { "read-only" }) ); assert!(!text.contains('╱')); } let mut empty_added = deletion_file(); empty_added.status = FileStatus::Added; empty_added.before.clear(); empty_added.after.clear(); empty_added.hunks.clear(); empty_added.notice = Some("metadata-only addition".into()); let mut diff = diff_comparison(vec![empty_added]); diff.files_visible = false; diff.focus = Focus::After; render_diff(&mut terminal, &diff, &mut panels); let geometry = panels.diff.as_ref().unwrap(); assert_eq!(geometry.regions[1].0, Rect::default()); assert_eq!(geometry.regions[2].0.width, 160); assert!(screen_text(&terminal).contains("metadata-only addition")); let pane = geometry.regions[2].0; assert!( pane_screen( &terminal, Rect::new(pane.x, pane.bottom() - 1, pane.width, 1), ) .contains("old revision → Working checkout") ); let mut renamed = deletion_file(); renamed.status = FileStatus::Renamed; renamed.old_path = Some("old/path.txt".into()); renamed.path = "new/path.txt".into(); renamed.before = vec!["unchanged rename content".into()]; renamed.after = renamed.before.clone(); renamed.hunks.clear(); let mut diff = diff_comparison(vec![renamed]); render_diff(&mut terminal, &diff, &mut panels); assert!(screen_text(&terminal).contains("old/path.txt -> new/path.txt")); assert_eq!( diff.files.as_mut().unwrap().accept(), Some("new/path.txt".into()) ); diff.files_visible = false; render_diff(&mut terminal, &diff, &mut panels); let geometry = panels.diff.as_ref().unwrap(); let blank = geometry.regions[1].0; assert_eq!(blank.width, 160); assert_eq!(geometry.regions[2].0, Rect::default()); let buffer = terminal.backend().buffer(); assert_eq!(buffer[(blank.x, blank.y)].symbol(), "┌"); for y in blank.y + 1..blank.bottom() - 1 { for x in blank.x + 1..blank.right() - 1 { assert_eq!(buffer[(x, y)].symbol(), " "); assert_eq!(buffer[(x, y)].bg, Color::Reset); } } let text = screen_text(&terminal); assert!( pane_screen( &terminal, Rect::new(blank.x, blank.bottom() - 1, blank.width, 1), ) .contains("old revision → Working checkout") ); assert!(!text.contains("unchanged rename content")); assert!(!text.contains('╱')); let mut modified = diff_comparison(vec![deletion_file()]); modified.files_visible = false; render_diff(&mut terminal, &modified, &mut panels); let regions = panels.diff.as_ref().unwrap().regions; assert_eq!(regions[1].0.width, 80); assert_eq!(regions[2].0.width, 80); let text = screen_text(&terminal); assert!(text.contains("Before · M")); assert!(text.contains("After · M")); } #[test] fn source_review_markers_highlight_exact_text_and_reject_stale_ranges() { use crate::editor::Effect; use crate::plugin::TextPosition; use crate::review::{Anchor, Comment, Review, Side}; for wrapped in [false, true] { let directory = Directory::new(); let text = format!("aé界{}\nsecond\n", " trailing".repeat(10)); let path = directory.file("source.txt", &text); let mut editor = Editor::open(Some(path), vec!["text".into()]).unwrap(); editor.local_review.document = Review::new(directory.0.canonicalize().unwrap()); editor.local_review.document.comments.push(Comment { id: 1, anchor: Anchor { revision: "working:source-snapshot".into(), path: "source.txt".into(), side: Side::After, start: TextPosition { row: 0, column: 1 }, end: TextPosition { row: 0, column: 3 }, content: text, }, body: "Feedback".into(), resolved: false, }); editor.local_review.selected = Some(1); if wrapped { editor.execute("word_wrap.toggle"); } let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 16)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert!(screen_text(&terminal).contains('●')); assert!( terminal .backend() .buffer() .content .iter() .filter(|cell| cell.bg == Color::Magenta) .count() >= 2 ); let anchor = editor.local_review.document.comments[0].anchor.clone(); editor .apply(vec![Effect::Replace("changed entirely\n".into())]) .unwrap(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert!(!screen_text(&terminal).contains('●')); assert_eq!(editor.local_review.document.comments[0].anchor, anchor); } } 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, None, None, ); }) .unwrap(); } fn render_review_diff( terminal: &mut Terminal, diff: &DiffState, review: &crate::review::ReviewState, panels: &mut WheelPanels, ) { terminal .draw(|frame| { panels.begin_frame(); draw_diff( frame, diff, frame.area(), "↑/↓", "h/j/k/l", panels, None, Some(review), ); }) .unwrap(); } fn commented_diff() -> (DiffState, crate::review::ReviewState) { use crate::review::{Anchor, Comment, Side}; let before = format!( "{}\nold e\u{301}界 second\nkeep", "old e\u{301}界 ".repeat(12) ); let after = format!( "{}\nnew e\u{301}界 second\nkeep", "new e\u{301}界 ".repeat(12) ); let mut file = syntax_diff_file("new.txt", &before, &after); file.hunks = crate::diff::line_hunks(&file.before, file.before_eof, &file.after, file.after_eof); file.old_path = Some("old.txt".into()); let mut diff = diff_comparison(vec![file]); diff.files_visible = false; let mut review = crate::review::ReviewState::default(); review.document.root = diff.comparison.as_ref().unwrap().root.clone(); review.document.comments = vec![ Comment { id: 1, anchor: Anchor { revision: "before-snapshot".into(), path: "old.txt".into(), side: Side::Before, start: crate::plugin::TextPosition { row: 0, column: 0 }, end: crate::plugin::TextPosition { row: 2, column: 0 }, content: before, }, body: "Before range".into(), resolved: false, }, Comment { id: 2, anchor: Anchor { revision: "after-snapshot".into(), path: "new.txt".into(), side: Side::After, start: crate::plugin::TextPosition { row: 0, column: 0 }, end: crate::plugin::TextPosition { row: 1, column: 0 }, content: after, }, body: "After line".into(), resolved: false, }, ]; review.selected = Some(1); (diff, review) } #[test] fn local_review_highlights_exact_graphemes_newlines_clips_and_wrap_continuations() { use crate::plugin::TextPosition; use crate::review::Side; for mode in [ViewMode::Split, ViewMode::Unified] { for side in [Side::Before, Side::After] { for (wrapping, horizontal) in [(false, 0), (false, 6), (true, 0)] { for (start, end) in [((0, 4), (0, 6)), ((0, 5), (1, 5)), ((0, 84), (1, 0))] { let (mut diff, mut review) = commented_diff(); diff.set_view_mode(mode); diff.top = 0; diff.word_wrap = wrapping; diff.left_before = horizontal; diff.left_after = horizontal; let comment = review .document .comments .iter_mut() .find(|comment| comment.anchor.side == side) .unwrap(); comment.anchor.start = TextPosition { row: start.0, column: start.1, }; comment.anchor.end = TextPosition { row: end.0, column: end.1, }; let anchor = comment.anchor.clone(); review.selected = Some(comment.id); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(90, 40)).unwrap(); let mut panels = WheelPanels::default(); render_review_diff(&mut terminal, &diff, &review, &mut panels); let file = &diff.comparison.as_ref().unwrap().files[0]; let wrapped = panels .diff_wrap .as_ref() .map(|viewport| viewport.rows.as_slice()); let buffer = terminal.backend().buffer(); for (area, focus) in panels.diff.as_ref().unwrap().regions.into_iter().skip(1) { let content = diff_inner(area); let gutter = diff_gutter(&diff, file, content.width as usize, focus); let width = content.width as usize - gutter; for (screen, display, segments) in diff_screen_rows(diff.display_rows.len(), 0, content.height, wrapped) { let mut expected = vec![false; width]; if let DisplayRow::Line(aligned) = diff.display_rows[display] { let row = &diff.rows[aligned]; let actual_side = if mode == ViewMode::Unified { diff.display_focus(display).unwrap_or(focus) } else { focus }; let anchor_side = if side == Side::Before { Focus::Before } else { Focus::After }; if actual_side == anchor_side || (mode == ViewMode::Unified && row.kind == RowKind::Equal) { let source_row = if side == Side::Before { row.before } else { row.after }; if let Some(source_row) = source_row { let line = if side == Side::Before { &file.before[source_row] } else { &file.after[source_row] }; let segment = segments.and_then(|segments| { segments[usize::from(actual_side == Focus::After)] }); let left = segment.map_or(horizontal, |segment| segment.start); let paint_width = segment.map_or(width, |segment| { segment_paint_width( segment, wrapped.unwrap()[screen].final_segments [usize::from(actual_side == Focus::After)], width, ) }); for column in 0..=line.graphemes(true).count() { let position = TextPosition { row: source_row, column, }; let cell_start = display_column(line, column); let cell_end = if column == line.graphemes(true).count() { cell_start + 1 } else { display_column(line, column + 1) }; if anchor.start <= position && position < anchor.end && cell_start >= left && cell_end <= left + paint_width { expected[cell_start - left..cell_end - left] .fill(true); } } } } } let mut continuation = 0; for (column, expected) in expected.into_iter().enumerate() { if continuation > 0 { continuation -= 1; continue; } continuation = UnicodeWidthStr::width( buffer[( content.x + gutter as u16 + column as u16, content.y + screen as u16, )] .symbol(), ) .saturating_sub(1); assert_eq!( buffer[( content.x + gutter as u16 + column as u16, content.y + screen as u16, )] .bg == Color::Magenta, expected, "{mode:?} {side:?} wrap={wrapping} left={horizontal} \ {start:?}..{end:?}, screen={screen} column={column}" ); } } } } } } } } #[test] fn local_review_markers_preserve_diff_signs_ranges_and_wrapped_geometry() { for mode in [ViewMode::Split, ViewMode::Unified] { for wrapping in [false, true] { let (mut diff, review) = commented_diff(); diff.set_view_mode(mode); diff.top = 0; diff.word_wrap = wrapping; let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(90, 40)).unwrap(); let mut panels = WheelPanels::default(); render_review_diff(&mut terminal, &diff, &review, &mut panels); let buffer = terminal.backend().buffer(); let markers: Vec<_> = buffer .content .iter() .enumerate() .filter(|(_, cell)| matches!(cell.symbol(), "●" | "○")) .collect(); assert_eq!(markers.len(), 3, "{mode:?} {wrapping}"); assert_eq!( markers .iter() .filter(|(_, cell)| cell.symbol() == "●") .count(), 2 ); for &(offset, cell) in &markers { assert_eq!(cell.fg, Color::Magenta); assert_eq!(cell.modifier.contains(Modifier::BOLD), cell.symbol() == "●"); assert_eq!( buffer.content[offset - 1].symbol(), if cell.symbol() == "●" { "-" } else { "+" } ); } if wrapping { let rows = &panels.diff_wrap.as_ref().unwrap().rows; assert!(rows.iter().any(|row| row.position.continuation > 0)); for (area, _) in panels.diff.as_ref().unwrap().regions.into_iter().skip(1) { let area = diff_inner(area); for (screen, row) in rows.iter().take(area.height as usize).enumerate() { if row.position.continuation > 0 { for x in area.x..area.right() { assert!(!matches!( buffer[(x, area.y + screen as u16)].symbol(), "●" | "○" )); } } } } } } } } #[test] fn local_review_markers_reject_stale_identity_text_and_rename_paths() { for mode in [ViewMode::Split, ViewMode::Unified] { for mismatch in ["revision", "content", "path", "side", "root"] { let (mut diff, mut review) = commented_diff(); diff.set_view_mode(mode); review.document.comments.truncate(1); let anchor = &mut review.document.comments[0].anchor; match mismatch { "revision" => anchor.revision.push_str("-newer"), "content" => anchor.content.replace_range(..1, "X"), "path" => anchor.path = "new.txt".into(), "side" => anchor.side = crate::review::Side::After, "root" => review.document.root = "another-repository".into(), _ => unreachable!(), } let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(90, 20)).unwrap(); render_review_diff(&mut terminal, &diff, &review, &mut WheelPanels::default()); assert!( terminal .backend() .buffer() .content .iter() .all(|cell| !matches!(cell.symbol(), "●" | "○")), "{mode:?} {mismatch}" ); assert!( terminal .backend() .buffer() .content .iter() .all(|cell| cell.bg != Color::Magenta), "{mode:?} {mismatch}" ); } } } #[test] fn unified_context_shows_comments_from_either_snapshot_side() { for side in [crate::review::Side::Before, crate::review::Side::After] { let (mut diff, mut review) = commented_diff(); diff.set_view_mode(ViewMode::Unified); review .document .comments .retain(|comment| comment.anchor.side == side); let comment = &mut review.document.comments[0]; comment.anchor.start = crate::plugin::TextPosition { row: 2, column: 1 }; comment.anchor.end = crate::plugin::TextPosition { row: 2, column: 3 }; review.selected = Some(comment.id); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(90, 20)).unwrap(); render_review_diff(&mut terminal, &diff, &review, &mut WheelPanels::default()); assert!( screen_text(&terminal).contains("●keep"), "{side:?}: {}", screen_text(&terminal) ); let buffer = terminal.backend().buffer(); let marker = buffer .content .iter() .position(|cell| cell.symbol() == "●") .unwrap(); for offset in 1..=4 { assert_eq!( buffer.content[marker + offset].bg == Color::Magenta, (2..=3).contains(&offset), "{side:?}: source column {}", offset - 1 ); } } } #[test] fn local_review_markers_yield_to_text_on_narrow_surfaces() { for mode in [ViewMode::Split, ViewMode::Unified] { for (width, height) in [(1, 1), (2, 2), (3, 3), (8, 4), (16, 8), (30, 12)] { let (mut diff, review) = commented_diff(); diff.set_view_mode(mode); diff.word_wrap = true; let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); let mut panels = WheelPanels::default(); render_review_diff(&mut terminal, &diff, &review, &mut panels); for (area, focus) in panels .diff .as_ref() .into_iter() .flat_map(|geometry| geometry.regions.into_iter().skip(1)) { assert!(area.right() <= width && area.bottom() <= height); let content = diff_inner(area); let file = &diff.comparison.as_ref().unwrap().files[0]; let gutter = diff_gutter(&diff, file, content.width as usize, focus); for y in content.y..content.bottom() { for x in content.x..content.right() { if matches!(terminal.backend().buffer()[(x, y)].symbol(), "●" | "○") { assert!(gutter > 0); assert_eq!(x, content.x + gutter as u16 - 1); } } } } } } } const LIGHT_DIFF_THEME: ThemeColors = ThemeColors { background: [250, 245, 240], red: [210, 30, 50], green: [20, 150, 60], }; const DARK_DIFF_THEME: ThemeColors = ThemeColors { background: [20, 25, 30], red: [220, 60, 80], green: [40, 180, 100], }; #[test] fn character_diff_backgrounds_preserve_common_unicode_and_syntax_across_viewports() { for theme in [Some(LIGHT_DIFF_THEME), Some(DARK_DIFF_THEME), None] { let mut diff = diff_comparison(vec![syntax_diff_file( "changes.rs", "let 界e\u{301} = old + old;", "let 界e\u{301} = new + new;", )]); diff.files_visible = false; let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); let mut panels = WheelPanels { diff_colors: DiffColors::new(theme), ..WheelPanels::default() }; render_diff(&mut terminal, &diff, &mut panels); let row = panels.diff_colors.row; let character = panels.diff_colors.character; if let Some(theme) = theme { for (index, accent) in [theme.red, theme.green].into_iter().enumerate() { let Color::Rgb(r, g, b) = row[index] else { panic!("theme row background must be RGB"); }; let Color::Rgb(cr, cg, cb) = character[index] else { panic!("theme character background must be RGB"); }; for ((common, changed), (background, accent)) in [r, g, b] .into_iter() .zip([cr, cg, cb]) .zip(theme.background.into_iter().zip(accent)) { assert!((background.min(accent)..=background.max(accent)).contains(&common)); assert!((background.min(accent)..=background.max(accent)).contains(&changed)); if background != accent { assert!(changed.abs_diff(accent) < common.abs_diff(accent)); } } } } else { assert_eq!(row, [Color::Reset; 2]); assert_eq!(character, [Color::Reset; 2]); } for index in 0..2 { let pane = panels.diff.as_ref().unwrap().regions[index + 1].0; let buffer = terminal.backend().buffer(); for column in [0, 3, 4, 6, 8, 14, 19] { assert_eq!(buffer[(pane.x + 4 + column, pane.y + 1)].bg, row[index]); } for column in [10, 11, 12, 16, 17, 18] { let cell = &buffer[(pane.x + 4 + column, pane.y + 1)]; assert_eq!(cell.bg, character[index]); assert_eq!(cell.fg, Color::Reset); } assert_eq!(buffer[(pane.x + 4, pane.y + 1)].fg, Color::Magenta); assert_eq!(buffer[(pane.x + 8, pane.y + 1)].symbol(), "界"); assert_eq!(buffer[(pane.x + 10, pane.y + 1)].symbol(), "e\u{301}"); } diff.left_before = 11; diff.left_after = 11; render_diff(&mut terminal, &diff, &mut panels); let after = panels.diff.as_ref().unwrap().regions[2].0; let buffer = terminal.backend().buffer(); assert_eq!(buffer[(after.x + 4, after.y + 1)].symbol(), "e"); assert_eq!(buffer[(after.x + 4, after.y + 1)].bg, character[1]); assert_eq!(buffer[(after.x + 7, after.y + 1)].bg, row[1]); diff.word_wrap = true; let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 12)).unwrap(); render_diff(&mut terminal, &diff, &mut panels); let after = panels.diff.as_ref().unwrap().regions[2].0; let buffer = terminal.backend().buffer(); let mut words = 0; for y in after.y + 1..after.bottom() - 1 { for x in after.x + 4..after.right().saturating_sub(3) { if buffer[(x, y)].symbol() == "n" && buffer[(x + 1, y)].symbol() == "e" && buffer[(x + 2, y)].symbol() == "w" { words += 1; for column in x..x + 3 { assert_eq!(buffer[(column, y)].bg, character[1]); assert_eq!(buffer[(column, y)].fg, Color::Reset); } } } } assert_eq!(words, 2); } } #[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("Tab categories")); assert!(text.contains("Enter select")); 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); let text = screen_text(&terminal); assert!(text.contains("Choose After")); assert!(text.contains("Branch changes · Ctrl+b toggle · Before: commit-19")); assert!(screen_text(&terminal).contains("Tab categories")); assert!(screen_text(&terminal).contains("Enter select")); 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 pull_request_picker_renders_all_rows_without_a_pinned_offset() { use crate::diff::{Category, PullRequestPage, RepoKind, Revision, RevisionTarget}; for (width, height) in [(100, 16), (44, 12)] { let mut diff = DiffState::pull_requests("origin".into()); diff.take_request(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); let mut panels = WheelPanels::default(); render_diff(&mut terminal, &diff, &mut panels); assert!(screen_text(&terminal).contains("loading")); assert!(screen_text(&terminal).contains("[partial]")); diff.apply_result(1, Err("offline".into())); render_diff(&mut terminal, &diff, &mut panels); assert!(screen_text(&terminal).contains("offline")); assert!(!screen_text(&terminal).contains("No matching pull requests")); diff.handle(Input::Enter); diff.take_request(); diff.apply_result( 2, Ok(crate::diff::Outcome::PullRequestPage { closed: false, result: Ok(PullRequestPage { kind: RepoKind::Git, remote: "origin".into(), entries: (0..20) .map(|index| { ( Revision { target: RevisionTarget::PullRequest { remote: "origin".into(), number: index, }, label: format!("#{index:02} Pull request"), description: "open · main ← topic".into(), category: if index == 1 { Category::Drafts } else { Category::Open }, }, crate::diff::PullRequestMetadata { created_at: format!("2026-01-{:02}T00:00:00Z", 20 - index), updated_at: format!("2026-01-{:02}T00:00:00Z", 20 - index), ..Default::default() }, ) }) .collect(), has_more: true, }), }), ); render_diff(&mut terminal, &diff, &mut panels); let text = screen_text(&terminal); assert!(text.contains("Pull requests")); assert!(text.contains("Open") && text.contains("Closed") && text.contains("All")); assert!(text.contains("> #00 Pull request")); assert!(!text.contains("pinned")); assert!(text.contains("[partial]")); diff.handle(Input::End); render_diff(&mut terminal, &diff, &mut panels); let text = screen_text(&terminal); assert!(text.contains("> #19 Pull request")); assert!(!text.contains("#00 Pull request")); diff.handle(Input::Home); render_diff(&mut terminal, &diff, &mut panels); assert!(screen_text(&terminal).contains("> #00 Pull request")); diff.handle(Input::Char('\t')); diff.apply_result( 2, Ok(crate::diff::Outcome::PullRequestPage { closed: true, result: Ok(PullRequestPage { kind: RepoKind::Git, remote: "origin".into(), entries: vec![], has_more: false, }), }), ); render_diff(&mut terminal, &diff, &mut panels); assert!(screen_text(&terminal).contains("No matching pull requests")); diff.handle(Input::Char('\t')); render_diff(&mut terminal, &diff, &mut panels); let text = screen_text(&terminal); assert!(text.contains("> #01 Pull request")); assert!(!text.contains("#00 Pull request") && !text.contains("#19 Pull request")); } } #[test] fn pull_request_menus_replace_picker_contents_and_show_explicit_choices() { for (width, height) in [(100, 24), (44, 12)] { let mut diff = DiffState::pull_requests("origin".into()); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); let mut panels = WheelPanels::default(); diff.handle(Input::Ctrl('s')); render_diff(&mut terminal, &diff, &mut panels); let text = screen_text(&terminal); assert!(text.contains("Sort pull requests")); assert!(text.contains("[–] Requested from me")); assert!(text.contains("Updated at") && text.contains("Created at")); assert!(text.contains("newest first")); assert!(!text.contains("Loading pull requests") && !text.contains("[partial]")); assert!(text.contains("Enter Apply") && text.contains("Esc Cancel")); diff.handle(Input::Char('3')); render_diff(&mut terminal, &diff, &mut panels); let text = screen_text(&terminal); let row = |label: &str| text.lines().position(|line| line.contains(label)).unwrap(); assert!(row("Updated at") < row("Approved by me")); assert!(row("Approved by me") < row("Requested from me")); assert!(row("Requested from me") < row("Created at")); assert!(text.contains("> [3] Requested from me")); diff.handle(Input::Backspace); render_diff(&mut terminal, &diff, &mut panels); assert!(screen_text(&terminal).contains("> [–] Requested from me")); diff.handle(Input::Down); diff.handle(Input::Char('4')); diff.handle(Input::Left); render_diff(&mut terminal, &diff, &mut panels); let text = screen_text(&terminal); assert!(text.contains("> [4] Updated at · oldest first")); let row = |label: &str| text.lines().position(|line| line.contains(label)).unwrap(); assert!(row("Requested from me") < row("Approved by me")); assert!(row("Approved by me") < row("Created at")); assert!(row("Created at") < row("Updated at")); diff.handle(Input::Backspace); render_diff(&mut terminal, &diff, &mut panels); let text = screen_text(&terminal); assert!(text.contains("> [–] Updated at · oldest first")); let row = |label: &str| text.lines().position(|line| line.contains(label)).unwrap(); assert!(row("Requested from me") < row("Updated at")); assert!(row("Updated at") < row("Approved by me")); assert!(row("Approved by me") < row("Created at")); diff.handle(Input::Right); render_diff(&mut terminal, &diff, &mut panels); assert!(screen_text(&terminal).contains("> [–] Updated at · newest first")); diff.handle(Input::Ctrl('f')); diff.handle(Input::Char(' ')); render_diff(&mut terminal, &diff, &mut panels); let text = screen_text(&terminal); assert!(text.contains("Filter pull requests")); assert!(text.contains("[ ] Requested from me")); assert!(text.contains("[x] Approved by me")); assert!(text.contains("[x] Other / unavailable")); assert!(!text.contains("Updated at")); diff.handle(Input::Escape); render_diff(&mut terminal, &diff, &mut panels); assert!(screen_text(&terminal).contains("Pull requests")); assert!(screen_text(&terminal).contains("[partial]")); } } #[test] fn pull_request_review_symbols_use_terminal_cells_and_keep_the_selection_arrow_independent() { use crate::diff::{ Category, Outcome, PullRequestPage, PullRequestReviewState, RepoKind, Revision, RevisionTarget, }; let mut diff = DiffState::pull_requests("origin".into()); diff.take_request(); diff.apply_result( 1, Ok(Outcome::PullRequestPage { closed: false, result: Ok(PullRequestPage { kind: RepoKind::Git, remote: "origin".into(), has_more: false, entries: [ PullRequestReviewState::Requested, PullRequestReviewState::Approved, PullRequestReviewState::None, ] .into_iter() .enumerate() .map(|(index, state)| { ( Revision { target: RevisionTarget::PullRequest { remote: "origin".into(), number: index as u64, }, label: format!("#{index} title"), description: format!("metadata{index}"), category: Category::Open, }, crate::diff::PullRequestMetadata { created_at: (3 - index).to_string(), updated_at: (3 - index).to_string(), requested: state == PullRequestReviewState::Requested, approved: state != PullRequestReviewState::None, }, ) }) .collect(), }), }), ); for (requested, approved, with_editor) in [ ("🙏", "👍", false), ("🙏", "👍", true), ("👩‍🚀 R", "界", true), ("ask", "ok", true), ("", "👍", true), ("🙏", "", true), ("", "", true), ] { let mut editor = Editor::open(None, vec![]).unwrap(); editor.pull_request_review_requested_symbol = requested.into(); editor.pull_request_approved_symbol = approved.into(); let slot = requested.width().max(approved.width()); let slot = slot + usize::from(slot > 0); for width in [100, 44] { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 24)).unwrap(); let mut panels = WheelPanels::default(); terminal .draw(|frame| { panels.begin_frame(); draw_diff( frame, &diff, frame.area(), "↑/↓", "h/j/k/l", &mut panels, with_editor.then_some(&editor), None, ); }) .unwrap(); let buffer = terminal.backend().buffer(); let mut number_column = None; for (index, symbol) in [requested, approved, ""].into_iter().enumerate() { let (x, y) = (0..24) .find_map(|y| { (0..width).find_map(|x| { (buffer[(x, y)].symbol() == "#" && buffer[(x + 1, y)].symbol() == index.to_string()) .then_some((x, y)) }) }) .expect("all review states render"); assert_eq!(*number_column.get_or_insert(x), x); let arrow_x = x - (2 + slot) as u16; assert_eq!( buffer[(arrow_x, y)].symbol(), if index == 0 { ">" } else { " " } ); if !symbol.is_empty() { assert_eq!( buffer[(arrow_x + 2, y)].symbol(), symbol.graphemes(true).next().unwrap() ); } else { for blank in arrow_x + 2..x { assert_eq!(buffer[(blank, y)].symbol(), " "); } } if width == 100 { assert_eq!( buffer[(x, y + 1)].symbol(), "m", "metadata aligns with #number" ); } } assert!(!screen_text(&terminal).contains('✓')); } } } #[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 { review_revisions: None, stats: None, loaded: true, before_eof: false, after_eof: false, 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.files_visible = false; diff.focus = Focus::Before; 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 buffer = terminal.backend().buffer(); assert_eq!( buffer[(before_area.x + 5, before_area.y + 3)].fg, Color::Magenta ); 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 { diff_colors: DiffColors::new(Some(LIGHT_DIFF_THEME)), ..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 + 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, buffer[(after.x + 1, y + 1)].fg); 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_eq!(diff.focus, Focus::Before); } #[test] fn diff_row_tint_covers_blank_lines_and_wrapped_gutters() { for wrapped in [false, true] { let mut diff = diff_comparison(vec![syntax_diff_file( "changed.txt", "old old old old old old old old old old old old old old old old old old old old\n", "new\n", )]); diff.files_visible = false; diff.word_wrap = wrapped; let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 20)).unwrap(); let mut panels = WheelPanels { diff_colors: DiffColors::new(Some(LIGHT_DIFF_THEME)), ..WheelPanels::default() }; render_diff(&mut terminal, &diff, &mut panels); for side in 0..2 { let area = diff_inner(panels.diff.as_ref().unwrap().regions[side + 1].0); let buffer = terminal.backend().buffer(); let empty = (area.y..area.bottom()) .find(|&y| buffer[(area.x, y)].symbol() == "2") .unwrap(); for y in area.y..=empty { for x in area.x..area.x + 3 { assert_eq!( buffer[(x, y)].bg, panels.diff_colors.row[side], "{wrapped} {side} {x} {y}" ); } } for x in area.x..area.right() { assert_eq!(buffer[(x, empty)].bg, panels.diff_colors.row[side]); } } } } #[test] fn diff_added_rows_hatch_missing_before_but_tint_real_empty_after_lines() { let mut diff = diff_comparison(vec![ChangedFile { review_revisions: None, stats: None, loaded: true, before_eof: false, after_eof: false, path: "added.txt".into(), old_path: None, status: FileStatus::Modified, 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 { diff_colors: DiffColors::new(Some(LIGHT_DIFF_THEME)), ..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!(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 { 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 { review_revisions: None, stats: None, loaded: true, before_eof: false, after_eof: false, 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 source_row = panels .diff_wrap .as_ref() .unwrap() .rows .iter() .position(|row| row.scope == [false; 2]) .unwrap() as u16; let buffer = terminal.backend().buffer(); for pane in [before, after] { 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 + source_row + row)].fg, foreground ); } assert_eq!( buffer[(pane.x + 20, pane.y + 2 + source_row)].symbol(), "界" ); assert_eq!( buffer[(pane.x + 22, pane.y + 2 + source_row)].symbol(), "e\u{301}" ); assert_eq!(buffer[(pane.x + 26, pane.y + 3 + source_row)].symbol(), "�"); assert_eq!(buffer[(pane.x + 27, pane.y + 3 + source_row)].symbol(), "n"); for x in pane.x + 4..pane.right() - 1 { assert_eq!(buffer[(x, pane.y + 4 + source_row)].symbol(), " "); assert_eq!(buffer[(x, pane.y + 4 + source_row)].fg, Color::Reset); } } diff.top = 1; diff.left_before = 17; diff.left_after = 16; render_diff(&mut terminal, &diff, &mut panels); let source_row = panels .diff_wrap .as_ref() .unwrap() .rows .iter() .position(|row| row.scope == [false; 2]) .unwrap() as u16; let buffer = terminal.backend().buffer(); assert_eq!( buffer[(before.x + 4, before.y + 1 + source_row)].symbol(), " " ); assert_eq!( buffer[(before.x + 4, before.y + 1 + source_row)].fg, Color::Reset ); assert_eq!( buffer[(before.x + 5, before.y + 1 + source_row)].symbol(), "e\u{301}" ); assert_eq!( buffer[(before.x + 5, before.y + 1 + source_row)].fg, Color::Green ); assert_eq!( buffer[(after.x + 4, after.y + 1 + source_row)].symbol(), "界" ); assert_eq!( buffer[(after.x + 4, after.y + 1 + source_row)].fg, Color::Green ); assert_eq!( buffer[(after.x + 6, after.y + 1 + source_row)].symbol(), "e\u{301}" ); assert_eq!( buffer[(after.x + 6, after.y + 1 + source_row)].fg, Color::Green ); } #[test] fn diff_view_changes_files_focus_and_keeps_horizontal_offsets_independent() { let added = ChangedFile { review_revisions: None, stats: None, loaded: true, before_eof: false, after_eof: false, 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); diff.handle(Input::BackTab); 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 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); diff.handle(Input::Char('\t')); render_diff(&mut terminal, &diff, &mut panels); assert_eq!(diff.focus, Focus::After); 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 = terminal.backend().buffer().area; 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() .enumerate() .map(|(index, file)| (index, 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); let reviewed = BTreeSet::from([index]); terminal .draw(|frame| { draw_file_list( frame, &picker, frame.area(), "", &mut WheelPanels::default(), Some((&files, &reviewed)), ); }) .unwrap(); let buffer = terminal.backend().buffer(); for (catalog_index, (file, color)) in files .iter() .zip([Color::Green, Color::Yellow, Color::Red, Color::Cyan]) .enumerate() { let path = if view == FileView::Tree { std::path::Path::new(file.path.file_name().unwrap()) } else { file.path.as_path() }; let expected = if file.status == FileStatus::Renamed { format!("R old�name.txt -> {}", file.path.to_string_lossy()) } else { 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_eq!( buffer[(x - 2, y)].symbol(), if catalog_index == index { "✓" } else { " " } ); assert_eq!(buffer[(x - 1, y)].symbol(), " "); let depth = picker .entries() .find(|entry| entry.catalog_index == Some(catalog_index)) .unwrap() .depth; assert_eq!( x, 3 + if view == FileView::Tree { depth as u16 * 2 } else { 0 } ); if catalog_index == index { assert_eq!(buffer[(x - 2, y)].fg, Color::Green); assert_eq!( buffer[(x - 2, y)].bg, if focused { Color::Blue } else { Color::DarkGray } ); } } for row in screen_text(&terminal) .lines() .filter(|row| row.contains("chain/a/")) { if view == FileView::Tree { assert!(!row.contains('✓')); } } assert_eq!(picker.accept(), Some(files[index].path.clone())); assert!(picker.query.is_empty()); } } } // A reverted first file disappears without shifting the remaining status markers. picker.replace_paths( files .iter() .enumerate() .skip(1) .map(|(index, file)| (index, file.path.clone())), ); for view in [FileView::Tree, FileView::List] { picker.set_view(view); picker.query = "m.txt".into(); picker.refilter(); terminal .draw(|frame| { draw_file_list( frame, &picker, frame.area(), "", &mut WheelPanels::default(), Some((&files, &BTreeSet::from([1]))), ); }) .unwrap(); let text = screen_text(&terminal); assert_eq!(text.matches('✓').count(), 1); assert!(!text.contains("R r.txt")); assert_eq!( picker .entries() .find(|entry| !entry.is_dir) .unwrap() .catalog_index, Some(1) ); } 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")); assert!(!screen_text(&terminal).contains('✓')); } #[test] fn collapsed_diff_folders_reflect_all_catalog_descendants() { let files: Vec<_> = [ "src/top.rs", "src/deep/leaf.rs", "src/deep/other.rs", "srcish/unreviewed.rs", "src", "compact/a/b/only.rs", ] .into_iter() .map(|path| { let mut file = deletion_file(); file.path = path.into(); file }) .collect(); let paths = || { files .iter() .enumerate() .map(|(index, file)| (index, file.path.clone())) }; let mut picker = FilePicker::from_paths(PathBuf::new(), paths()); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 18)).unwrap(); let mut check = |picker: &FilePicker, reviewed: &BTreeSet, label: &str, marked| { terminal .draw(|frame| { draw_file_list( frame, picker, frame.area(), "", &mut WheelPanels::default(), Some((&files, reviewed)), ); }) .unwrap(); let text = screen_text(&terminal); let row = text.lines().find(|row| row.contains(label)).unwrap(); assert_eq!(row.contains('✓'), marked, "{row}"); if marked { let buffer = terminal.backend().buffer(); let y = text.lines().position(|row| row.contains(label)).unwrap() as u16; let x = (0..100).find(|&x| buffer[(x, y)].symbol() == "✓").unwrap(); assert_eq!(buffer[(x, y)].fg, Color::Green); } }; let mut reviewed = BTreeSet::from([0, 1]); check(&picker, &reviewed, "src/", false); reviewed.insert(2); check(&picker, &reviewed, "src/", true); check(&picker, &reviewed, "srcish/", false); check(&picker, &reviewed, "compact/a/b/", false); reviewed.insert(5); check(&picker, &reviewed, "compact/a/b/", true); let selected = picker .entries() .position(|row| row.is_dir && row.path == std::path::Path::new("src")) .unwrap(); picker.selected = selected; picker.right(); check(&picker, &reviewed, "src/", false); check(&picker, &reviewed, "deep/", true); reviewed.remove(&2); picker.query = "leaf.rs".into(); picker.refilter(); let selected = picker .entries() .position(|row| row.is_dir && row.path == std::path::Path::new("src/deep")) .unwrap(); picker.selected = selected; picker.accept(); check(&picker, &reviewed, "deep/", false); reviewed.insert(2); check(&picker, &reviewed, "deep/", true); reviewed.remove(&2); check(&picker, &reviewed, "deep/", false); // A reverted file no longer in the picker must not block its folder's mark. picker.replace_paths(paths().filter(|(index, _)| *index != 2)); check(&picker, &reviewed, "deep/", true); picker.accept(); check(&picker, &reviewed, "deep/", false); } #[test] fn normal_file_markers_keep_clean_rows_and_match_diff_colors_in_every_view() { use crate::repository::WorkingFileChange; let directory = Directory::new(); let changes: Vec<_> = [ ("z.txt", FileStatus::Added), ("chain/a/m.txt", FileStatus::Modified), ("gone/deep/d.txt", FileStatus::Removed), ("r.txt", FileStatus::Renamed), ] .into_iter() .map(|(path, status)| { if status != FileStatus::Removed { directory.file(path, ""); } WorkingFileChange { path: path.into(), status, old_path: (status == FileStatus::Renamed).then(|| "old\nname.txt".into()), } }) .collect(); directory.file("clean.txt", ""); let mut picker = FilePicker::scan( directory.0.clone(), FilePresentation::Floating, FileView::List, ) .unwrap(); picker.set_repository_changes(changes.clone()); for change in &changes { picker.reveal_path(&change.path); } let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); for presentation in [FilePresentation::Floating, FilePresentation::Docked] { picker.presentation = presentation; for view in [FileView::Tree, FileView::List] { picker.set_view(view); for focused in [true, false] { picker.focused = focused; for selected in changes .iter() .map(|change| change.path.as_path()) .chain(std::iter::once(std::path::Path::new("clean.txt"))) { picker.reveal_path(selected); terminal .draw(|frame| { draw_file_list( frame, &picker, frame.area(), "", &mut WheelPanels::default(), None, ); }) .unwrap(); let text = screen_text(&terminal); assert!(text.contains("clean.txt")); assert!(!text.contains('✓')); assert!(!text.contains("old\nname.txt")); let buffer = terminal.backend().buffer(); for (change, color) in changes .iter() .zip([Color::Green, Color::Yellow, Color::Red, Color::Cyan]) { let path = if view == FileView::Tree { std::path::Path::new(change.path.file_name().unwrap()) } else { change.path.as_path() }; let expected = if change.status == FileStatus::Renamed { "R old�name.txt -> r.txt".to_owned() } else { format!("{} {}", diff_status(change.status), path.to_string_lossy()) }; let (x, y) = (0..24) .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_eq!( buffer[(x, y)].bg, if change.path == selected { if focused { Color::Blue } else { Color::DarkGray } } else { Color::Reset } ); } let clean_row = text.lines().find(|row| row.contains("clean.txt")).unwrap(); assert!( clean_row.contains(if selected == std::path::Path::new("clean.txt") { "> clean.txt" } else { " clean.txt" }) ); } for query in ["m.txt", "clean"] { picker.query = query.into(); picker.refilter(); terminal .draw(|frame| { draw_file_list( frame, &picker, frame.area(), "", &mut WheelPanels::default(), None, ); }) .unwrap(); let text = screen_text(&terminal); if query == "m.txt" { assert!(text.contains("M ")); assert!(!text.contains("clean.txt")); } else { assert!(text.contains("> clean.txt")); assert!(!text.contains("M ")); } assert!(!text.contains('✓')); picker.clear_filter(); } } } } picker.refresh(); assert!( picker .entries() .any(|row| row.status == Some(FileStatus::Renamed)) ); picker.set_repository_changes(Vec::new()); picker.set_view(FileView::List); picker.reveal_path(std::path::Path::new("clean.txt")); terminal .draw(|frame| { draw_file_list( frame, &picker, frame.area(), "", &mut WheelPanels::default(), None, ); }) .unwrap(); let text = screen_text(&terminal); assert!(text.contains("r.txt")); assert!(!text.contains("old�name.txt")); assert!(!text.contains("d.txt")); assert!(!text.contains('✓')); } #[test] fn native_explorer_marks_only_confirmed_conflicts() { let directory = Directory::new(); directory.file("file.txt", ""); let mut picker = FilePicker::scan( directory.0.clone(), FilePresentation::Floating, FileView::List, ) .unwrap(); picker.set_repository_changes(vec![crate::repository::WorkingFileChange { path: "file.txt".into(), status: FileStatus::Modified, old_path: None, }]); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); for view in [FileView::List, FileView::Tree] { picker.set_view(view); for conflicted in [true, false] { picker.set_conflicted(std::path::Path::new("file.txt"), conflicted); terminal .draw(|frame| { draw_file_list( frame, &picker, frame.area(), "", &mut WheelPanels::default(), None, ); }) .unwrap(); let text = screen_text(&terminal); let row = text.lines().find(|row| row.contains("file.txt")).unwrap(); assert!(row.contains(if conflicted { "C file.txt" } else { "M file.txt" })); if conflicted { let buffer = terminal.backend().buffer(); let (x, y) = (0..buffer.area.height) .find_map(|y| { (0..buffer.area.width) .find(|&x| buffer[(x, y)].symbol() == "C") .map(|x| (x, y)) }) .unwrap(); assert_eq!(buffer[(x, y)].fg, Color::Yellow); } } } } #[test] fn normal_file_names_stay_put_when_status_arrives_or_clears() { let directory = Directory::new(); directory.file("file.txt", ""); let mut picker = FilePicker::scan( directory.0.clone(), FilePresentation::Floating, FileView::List, ) .unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); for view in [FileView::List, FileView::Tree] { picker.set_view(view); let mut columns = Vec::new(); for status in [None, Some(FileStatus::Modified), None] { picker.set_repository_changes( status .into_iter() .map(|status| crate::repository::WorkingFileChange { path: "file.txt".into(), status, old_path: None, }) .collect(), ); terminal .draw(|frame| { draw_file_list( frame, &picker, frame.area(), "", &mut WheelPanels::default(), None, ); }) .unwrap(); let text = screen_text(&terminal); columns.push(text.lines().find_map(|row| row.find("file.txt")).unwrap()); } assert_eq!(columns[0], columns[1], "status arrival moved the filename"); assert_eq!(columns[1], columns[2], "clearing status moved the filename"); } } #[test] fn normal_status_prefix_clips_without_review_columns_or_painting_outside_tiny_bounds() { let directory = Directory::new(); directory.file("界.txt", ""); let mut picker = FilePicker::scan( directory.0.clone(), FilePresentation::Floating, FileView::List, ) .unwrap(); picker.set_repository_changes(vec![crate::repository::WorkingFileChange { path: "界.txt".into(), status: FileStatus::Modified, old_path: None, }]); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(12, 4)).unwrap(); for focused in [true, false] { picker.focused = focused; for height in 0..=3 { for width in 0..=8 { terminal .draw(|frame| { frame.render_widget( Paragraph::new( "XXXXXXXXXXXX\nXXXXXXXXXXXX\nXXXXXXXXXXXX\nXXXXXXXXXXXX", ), frame.area(), ); draw_file_list( frame, &picker, Rect::new(0, 0, width, height), "", &mut WheelPanels::default(), None, ); }) .unwrap(); let buffer = terminal.backend().buffer(); for y in 0..4 { for x in 0..12 { if x >= width || y >= height { assert_eq!(buffer[(x, y)].symbol(), "X"); } } } if height < 2 { continue; } for (x, symbol) in "> M ".chars().take(usize::from(width)).enumerate() { assert_eq!(buffer[(x as u16, 1)].symbol(), symbol.to_string()); } if width >= 3 { assert_eq!(buffer[(2, 1)].fg, Color::Yellow); assert_eq!( buffer[(2, 1)].bg, if focused { Color::Blue } else { Color::DarkGray } ); } if width >= 6 { assert_eq!(buffer[(4, 1)].symbol(), "界"); } } } } } #[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.files_visible = false; diff.focus = Focus::Before; diff.files.as_mut().unwrap().focused = false; 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.files_visible = true; diff.focus = Focus::Files; diff.files.as_mut().unwrap().focused = true; 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 unavailable_file_documents_show_the_actual_error_in_managed_workspaces() { let mut file = deletion_file(); file.loaded = false; let mut diff = diff_comparison(vec![file]); diff.loading = false; diff.error = Some("parser.c exceeds 128 MiB file limit".into()); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); let mut panels = WheelPanels { workspace_managed: true, ..WheelPanels::default() }; terminal .draw(|frame| { panels.draw_documents(frame, &diff, frame.area(), None, None, None); }) .unwrap(); let text = screen_text(&terminal); assert!(text.contains("parser.c exceeds 128 MiB file limit")); assert!(!text.contains("select the file to retry")); } #[test] fn large_generated_additions_render_first_scroll_and_final_rows_without_reparsing() { let after = (0..25_000) .map(|row| format!("state_row_{row:05} = 1234567890123456789012345678901234567890;")) .collect::>(); let hunks = crate::diff::line_hunks(&[], false, &after, true); let mut diff = diff_comparison(vec![ChangedFile { review_revisions: None, stats: None, path: "parser.c".into(), old_path: None, status: FileStatus::Added, loaded: true, before: Vec::new(), after, before_eof: false, after_eof: true, hunks, notice: None, }]); diff.files_visible = false; diff.focus = Focus::After; let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); let mut panels = WheelPanels::default(); let mut counts = None; for row in [0, 3, 12_500, 24_999] { diff.reveal_source_row(Focus::After, row); render_diff(&mut terminal, &diff, &mut panels); assert!(screen_text(&terminal).contains(&format!("state_row_{row:05}"))); let syntax = panels.diff_syntax.as_ref().unwrap(); assert_eq!( *counts.get_or_insert(syntax.after.parse_counts()), syntax.after.parse_counts() ); assert!(syntax.after.error().is_some()); assert_eq!( diff.comparison.as_ref().unwrap().files[0].after.len(), 25_000 ); } } #[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 { review_revisions: None, stats: None, loaded: true, before_eof: false, after_eof: false, 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 { review_revisions: None, stats: None, loaded: true, before_eof: false, after_eof: false, 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")); let mut unloaded_file = deletion_file(); unloaded_file.loaded = false; let mut unloaded = diff_comparison(vec![unloaded_file]); unloaded.loading = false; render_diff(&mut terminal, &unloaded, &mut panels); assert!(screen_text(&terminal).contains("File details unavailable")); for state in [&empty, &binary, &blank, &unloaded] { render_diff(&mut terminal, state, &mut panels); let pane = panels .diff .as_ref() .unwrap() .regions .into_iter() .skip(1) .find(|(pane, _)| *pane != Rect::default()) .unwrap() .0; assert!( pane_screen( &terminal, Rect::new(pane.x, pane.bottom() - 1, pane.width, 1), ) .contains("old revision → Working checkout") ); assert!( pane_screen(&terminal, Rect::new(pane.right() - 13, pane.y, 12, 1),) .contains("read-only") ); } 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, &unloaded] { 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); if before.width > 0 && after.width > 0 { assert!(before.right() <= after.x); } if navigator.width > 0 { assert!(before.right().max(after.right()) <= navigator.x); } } } } } #[test] fn immutable_diff_draw_path_preserves_document_until_explicit_close() { 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, &mut 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, &mut 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 { blame: Default::default(), root: std::path::PathBuf::new(), editable_paths: Default::default(), before: "old revision".into(), after: "Working checkout".into(), revisions: ["before-snapshot".into(), "after-snapshot".into()], files: vec![deletion_file()], })), ); editor.handle(Input::Paste("must not edit".into())); editor.handle(Input::Char('i')); terminal .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert!(screen_text(&terminal).contains("deleted-lines.txt")); assert!(viewports.document.geometry.is_none()); editor.handle(Input::Escape); assert!(editor.diff().is_some()); editor.execute("diff.close"); terminal .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert!(screen_text(&terminal).contains("original")); assert!(viewports.document.geometry.is_some()); assert_eq!(editor.lines(), original); assert_eq!(editor.cursor(), cursor); } fn pane_screen(terminal: &Terminal, area: Rect) -> String { let buffer = terminal.backend().buffer(); (area.y..area.bottom()) .map(|y| { (area.x..area.right()) .map(|x| buffer[(x, y)].symbol()) .collect::() }) .collect::>() .join("\n") } #[test] fn tab_bar_shows_shared_dirty_titles_and_reserves_document_and_overlay_rows() { let directory = Directory::new(); let mut editor = Editor::open(Some(directory.file("a\tname.txt", "DOCUMENT")), vec![]).unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert_eq!(views.document.geometry.unwrap().1.y, 0); assert!(pane_screen(&terminal, Rect::new(0, 0, 80, 1)).contains("DOCUMENT")); editor.execute("tab.new"); editor.handle(Input::Char('i')); editor.handle(Input::Paste("changed ".into())); editor.handle(Input::Escape); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let titles = pane_screen(&terminal, Rect::new(0, 0, 80, 1)); assert!(titles.contains("1 a�name.txt [+]"), "{titles}"); assert!(titles.contains("2 a�name.txt [+]"), "{titles}"); assert_eq!(views.document.geometry.unwrap().1.y, 1); assert!(terminal.get_cursor_position().unwrap().y >= 1); assert!(pane_screen(&terminal, views.rects[0].1).contains("changed DOCUMENT")); editor.handle(Input::Ctrl('p')); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert_eq!(pane_screen(&terminal, Rect::new(0, 0, 80, 1)), titles); assert!(terminal.get_cursor_position().unwrap().y >= 1); assert!(screen_text(&terminal).contains("Commands")); } fn configure_chrome(editor: &mut Editor, update: impl FnOnce(&mut ChromeLine, &mut ChromeLine)) { let mut top = editor.top_bar_config().clone(); let mut bottom = editor.bottom_bar_config().clone(); update(&mut top, &mut bottom); editor .set_chrome_config(top, bottom, BTreeMap::new()) .unwrap(); } #[test] fn home_abbreviation_matches_components_and_only_changes_display() { use std::path::Path; let home = Path::new("/users/λ"); for (path, expected) in [ ("/users/λ", "~"), ("/users/λ/", "~"), ("/users/λ/src/file.rs", "~/src/file.rs"), ("/users/λ-other/file.rs", "/users/λ-other/file.rs"), ("/else/users/λ/file.rs", "/else/users/λ/file.rs"), ("users/λ/file.rs", "users/λ/file.rs"), ] { assert_eq!(abbreviate_home(Path::new(path), Some(home)), expected); assert_eq!(abbreviate_home(Path::new(path), None), path); } let directory = Directory::new(); let path = directory.file("λ\t{index}.rs", "source"); let editor = Editor::open(Some(path.clone()), vec![]).unwrap(); let groups = chrome_groups(&editor, editor.bottom_bar_config(), &SyntaxCache::new()); let title = chrome_title("{path}", 2, Some(&path), false, None); assert_eq!(title, display_text(&display_path(&path))); assert!(groups.iter().flatten().any(|item| item.text == title)); assert_eq!(editor.path(), Some(path.as_path())); assert_eq!(std::fs::read_to_string(&path).unwrap(), "source"); } #[test] fn native_lsp_names_are_borrowed_in_either_bar_and_ignore_configured_labels() { let mut editor = Editor::open(None, vec![]).unwrap(); configure_chrome(&mut editor, |top, bottom| { for line in [top, bottom] { line.left = vec!["lsp".into()]; line.center.clear(); line.right.clear(); } }); editor .set_label("lsp".into(), "not a running server".into()) .unwrap(); for line in [editor.top_bar_config(), editor.bottom_bar_config()] { assert!(chrome_groups(&editor, line, &SyntaxCache::new())[0].is_empty()); } editor.set_lsp_names(vec!["server one".into(), "server two".into()]); for line in [editor.top_bar_config(), editor.bottom_bar_config()] { let groups = chrome_groups(&editor, line, &SyntaxCache::new()); assert_eq!(groups[0].len(), 1); assert!(matches!( groups[0][0].text, Cow::Borrowed("server one + server two") )); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(25, 1)).unwrap(); terminal .draw(|frame| draw_chrome(frame, groups, Rect::new(0, 0, 25, 1))) .unwrap(); assert_eq!( pane_screen(&terminal, Rect::new(0, 0, 25, 1)), " server one + server two " ); for x in [0, 24] { assert_eq!(terminal.backend().buffer()[(x, 0)].bg, Color::Cyan); } } } #[test] fn chrome_separates_visible_segments_once_in_every_anchor() { let mut editor = Editor::open(None, vec![]).unwrap(); for anchor in 0..3 { configure_chrome(&mut editor, |top, bottom| { for line in [top, bottom] { line.left.clear(); line.center.clear(); line.right.clear(); line.segments.entry("lsp".into()).or_default().padding = 0; let names = vec![ "before".into(), "lsp".into(), "after".into(), "position".into(), ]; match anchor { 0 => line.left = names, 1 => line.center = names, _ => line.right = names, } } }); editor .set_label("before".into(), "Markdown".into()) .unwrap(); editor.set_label("after".into(), "tail".into()).unwrap(); for servers in [vec![], vec!["server".into()]] { editor.set_lsp_names(servers); let expected = if editor.lsp_label().is_empty() { "Markdown tail ░▓ 1:1 " } else { "Markdown server tail ░▓ 1:1 " }; let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(50, 2)).unwrap(); terminal .draw(|frame| { for (line, y) in [ (editor.top_bar_config(), 0), (editor.bottom_bar_config(), 1), ] { draw_chrome( frame, chrome_groups(&editor, line, &SyntaxCache::new()), Rect::new(0, y, 50, 1), ); } }) .unwrap(); let start = match anchor { 0 => 0, 1 => (50 - expected.width()) / 2, _ => 50 - expected.width(), }; for y in 0..2 { assert_eq!( pane_screen( &terminal, Rect::new(start as u16, y, expected.width() as u16, 1) ), expected ); } } } } #[test] fn bar_margins_surround_styling_and_keep_anchors_and_narrow_content() { let directory = Directory::new(); let config = directory.file( "margins.rb", r#" Vamp.configure do |config| options = {left: [:badge], center: [:lsp], right: [:badge], badge: {padding: 0, margin: 2, leading_edge: "<", trailing_edge: ">"}, lsp: {padding: 0, margin: 1}} config.bar.top = options config.bar.bottom = options end "#, ); let config = crate::ruby::load_config(&config).unwrap(); let mut editor = Editor::open(None, vec![]).unwrap(); editor .set_chrome_config(config.top_bar, config.bottom_bar, config.labels) .unwrap(); editor.set_label("badge".into(), "界".into()).unwrap(); editor.set_lsp_names(vec!["server".into()]); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(41, 3)).unwrap(); terminal .draw(|frame| { for (line, y) in [ (editor.top_bar_config(), 0), (editor.bottom_bar_config(), 2), ] { draw_chrome( frame, chrome_groups(&editor, line, &SyntaxCache::new()), Rect::new(0, y, 41, 1), ); } }) .unwrap(); let buffer = terminal.backend().buffer(); for y in [0, 2] { for x in [0, 1, 6, 7, 16, 23, 33, 34, 39, 40] { assert_eq!(buffer[(x, y)].symbol(), " "); assert_eq!(buffer[(x, y)].bg, Color::Reset); } for x in [3, 36] { assert_eq!(buffer[(x, y)].symbol(), "界"); assert_eq!(buffer[(x, y)].bg, Color::Cyan); } for (x, symbol) in [(2, "<"), (5, ">"), (35, "<"), (38, ">")] { assert_eq!(buffer[(x, y)].symbol(), symbol); assert_eq!(buffer[(x, y)].fg, Color::Cyan); } for (offset, ch) in "server".chars().enumerate() { assert_eq!(buffer[(17 + offset as u16, y)].symbol(), ch.to_string()); assert_eq!(buffer[(17 + offset as u16, y)].bg, Color::Cyan); } } configure_chrome(&mut editor, |top, bottom| { for line in [top, bottom] { line.left = vec!["lsp".into()]; line.center.clear(); line.right.clear(); let lsp = line.segments.get_mut("lsp").unwrap(); lsp.margin = [64; 2]; lsp.padding = 64; } }); let mut narrow = Terminal::new(ratatui::backend::TestBackend::new(6, 2)).unwrap(); narrow .draw(|frame| { for (line, y) in [ (editor.top_bar_config(), 0), (editor.bottom_bar_config(), 1), ] { draw_chrome( frame, chrome_groups(&editor, line, &SyntaxCache::new()), Rect::new(0, y, 6, 1), ); } }) .unwrap(); for y in [0, 1] { assert_eq!(pane_screen(&narrow, Rect::new(0, y, 6, 1)), "server"); } editor.set_lsp_names(Vec::new()); for line in [editor.top_bar_config(), editor.bottom_bar_config()] { assert!(chrome_groups(&editor, line, &SyntaxCache::new())[0].is_empty()); } } #[test] fn floating_fields_use_border_titles_and_inner_horizontal_padding() { for (prefix, title) in [(':', "Command"), ('/', "Search")] { let mut editor = Editor::open(None, vec![]).unwrap(); editor.handle(Input::Char(prefix)); for ch in "needle".chars() { editor.handle(Input::Char(ch)); } let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); let workspace = Rect::new(3, 2, 74, 8); let mut panel = Rect::default(); terminal .draw(|frame| { panel = draw_floating_input(frame, &editor, workspace).unwrap(); }) .unwrap(); assert_eq!(panel.x, workspace.x); assert_eq!(panel.bottom(), workspace.bottom()); assert!( pane_screen(&terminal, Rect::new(panel.x, panel.y, panel.width, 1)).contains(title) ); let buffer = terminal.backend().buffer(); assert_eq!(buffer[(panel.x + 1, panel.y + 1)].symbol(), " "); assert_eq!(buffer[(panel.x + 2, panel.y + 1)].symbol(), "n"); assert_eq!(buffer[(panel.right() - 2, panel.y + 1)].symbol(), " "); assert_eq!( terminal.get_cursor_position().unwrap(), Position::new(panel.x + 8, panel.y + 1) ); assert_eq!( pane_screen( &terminal, Rect::new(panel.x + 1, panel.y + 1, panel.width - 2, 1) ) .trim(), "needle" ); } let mut editor = Editor::open(None, vec![]).unwrap(); editor.set_message("operation complete"); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); let mut panel = Rect::default(); terminal .draw(|frame| { panel = draw_notification(frame, &editor, frame.area()).unwrap(); }) .unwrap(); let buffer = terminal.backend().buffer(); assert_eq!(buffer[(panel.x + 1, panel.y + 1)].symbol(), " "); assert_eq!(buffer[(panel.x + 2, panel.y + 1)].symbol(), "o"); assert_eq!(buffer[(panel.right() - 2, panel.y + 1)].symbol(), " "); assert_eq!(panel.right(), 80); assert_eq!(panel.bottom(), 12); } #[test] fn configured_floating_inputs_paint_each_anchor_independently_inside_the_workspace() { let anchors = [ (PopupAnchor::TopLeft, 0, 0), (PopupAnchor::TopCenter, 14, 0), (PopupAnchor::TopRight, 29, 0), (PopupAnchor::CenterLeft, 0, 5), (PopupAnchor::Center, 14, 5), (PopupAnchor::CenterRight, 29, 5), (PopupAnchor::BottomLeft, 0, 10), (PopupAnchor::BottomCenter, 14, 10), (PopupAnchor::BottomRight, 29, 10), ]; for (index, &(command_anchor, command_x, command_y)) in anchors.iter().enumerate() { let (search_anchor, search_x, search_y) = anchors[(index + 1) % anchors.len()]; for (prefix, title, x, y) in [ (':', "Command", command_x, command_y), ('/', "Search", search_x, search_y), ] { let mut editor = Editor::open(None, vec![]).unwrap(); let mut popup = *editor.popup_config(); popup.command.anchor = command_anchor; popup.search.anchor = search_anchor; editor.set_popup_config(popup); editor.set_message("hidden notification"); editor.handle(Input::Char(prefix)); for ch in "needle".chars() { editor.handle(Input::Char(ch)); } for (width, height) in [ (101, 13), (12, 3), (3, 2), (2, 1), (1, 1), (0, 1), (1, 0), (0, 0), ] { let workspace = Rect::new(5, 4, width, height); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 24)).unwrap(); let mut panels = WheelPanels::default(); terminal .draw(|frame| { frame.render_widget( Paragraph::new(vec![Line::from(".".repeat(120)); 24]), frame.area(), ); draw_overlays(frame, &editor, workspace, None, &mut panels); }) .unwrap(); assert!(panels.notification.is_none()); if width == 0 || height == 0 { assert!(panels.input.is_none()); assert_eq!( terminal.backend().buffer()[(workspace.x, workspace.y)].symbol(), "." ); continue; } let expected = if width == 101 { Rect::new(workspace.x + x, workspace.y + y, 72, 3) } else { workspace }; let panel = panels.input.unwrap(); assert_eq!( panel, expected, "{prefix} {command_anchor:?} {search_anchor:?}" ); assert_eq!(panel.intersection(workspace), panel); let cursor = terminal.get_cursor_position().unwrap(); assert!(panel.contains(cursor)); let buffer = terminal.backend().buffer(); assert_eq!(buffer[(0, 0)].symbol(), "."); assert_eq!(buffer[(cursor.x, cursor.y)].symbol(), " "); if width >= 12 { assert_eq!(buffer[(panel.x, panel.y)].symbol(), "┌"); assert_eq!(buffer[(panel.right() - 1, panel.y)].symbol(), "┐"); assert_eq!(buffer[(panel.x, panel.bottom() - 1)].symbol(), "└"); assert_eq!( buffer[(panel.right() - 1, panel.bottom() - 1)].symbol(), "┘" ); assert!( pane_screen(&terminal, Rect::new(panel.x, panel.y, panel.width, 1)) .contains(title) ); assert_eq!(buffer[(panel.x + 1, panel.y + 1)].symbol(), " "); assert_eq!(buffer[(panel.x + 2, panel.y + 1)].symbol(), "n"); assert_eq!( pane_screen( &terminal, Rect::new(panel.x + 1, panel.y + 1, panel.width - 2, 1) ) .trim(), "needle" ); assert_eq!(cursor, Position::new(panel.x + 8, panel.y + 1)); } else { let cursor_x = u16::from(width > 1); assert_eq!(cursor, Position::new(panel.x + cursor_x, panel.y)); if width == 2 { assert_eq!(buffer[(panel.x, panel.y)].symbol(), "e"); } } } } } } #[test] fn floating_inputs_share_workspace_geometry_across_documents_splits_and_diffs() { for context in ["document", "split", "diff", "split-diff", "selector"] { for prefix in [':', '/'] { if context == "selector" && prefix == '/' { continue; } let mut editor = Editor::open(None, vec![]).unwrap(); editor .apply(vec![crate::editor::Effect::Replace("DOCUMENT".into())]) .unwrap(); if context.starts_with("split") { editor.handle(Input::Char('|')); } if context.ends_with("diff") { install_readonly_diff(&mut editor, deletion_file()); editor.handle(Input::Char('\t')); assert!(editor.diff_interacting()); } else if context == "selector" { editor.execute("diff"); let (id, _) = editor.take_diff_request().unwrap(); editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); } editor.set_message("saved notification"); if context == "selector" { editor .bind("normal", "ctrl+t", Some("core.command".into())) .unwrap(); editor.handle(Input::Ctrl('t')); } else { editor.handle(Input::Char(prefix)); } let query = format!("{}TAIL", "界e\u{301}👩‍🚀".repeat(30)); for ch in query.chars() { editor.handle(Input::Char(ch)); } assert!(matches!(editor.mode(), Mode::Command | Mode::Search)); assert_eq!(editor.command_line(), query); let before = (editor.lines().to_vec(), editor.cursor(), editor.carets()); let mut views = BufferViewports::default(); for (width, height) in [(100, 9), (12, 3), (3, 2), (1, 1), (0, 1), (1, 0)] { 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| views.draw(frame, &mut editor)) .unwrap(); assert!(views.panels.notification.is_none()); if width == 0 || height == 0 { assert!(views.panels.input.is_none()); assert!(views.document.geometry.is_none()); continue; } let panel = views.panels.input.unwrap(); let cursor = terminal.get_cursor_position().unwrap(); assert_eq!(panel.intersection(area), panel); assert!( panel.contains(cursor), "{context} {area:?} {panel:?} {cursor:?}" ); assert!(panel.width <= 72 && panel.height <= 3); assert_eq!( terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), " " ); if width >= 12 { assert!(pane_screen(&terminal, panel).contains("TAIL")); } assert_eq!(terminal.backend().buffer()[(0, 0)].symbol(), " "); assert_eq!( before, (editor.lines().to_vec(), editor.cursor(), editor.carets()) ); } editor.handle(Input::Escape); assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.message(), "saved notification"); } } } #[test] fn cancelling_floating_inputs_restores_obscured_document_cells() { let mut editor = Editor::open(None, vec![]).unwrap(); editor .apply(vec![crate::editor::Effect::Replace( vec!["DOCUMENT".repeat(15); 20].join("\n"), )]) .unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let baseline = screen_text(&terminal); let geometry = views.document.geometry; let caret = views.panels.caret; for prefix in [':', '/'] { editor.handle(Input::Char(prefix)); for ch in "long input 界e\u{301}👩‍🚀".chars() { editor.handle(Input::Char(ch)); } terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert!(views.panels.input.is_some()); assert_eq!(views.document.geometry, geometry); assert_eq!(views.panels.caret, caret); editor.handle(Input::Escape); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert!(views.panels.input.is_none()); assert_eq!(screen_text(&terminal), baseline); assert_eq!(views.document.geometry, geometry); assert_eq!(views.panels.caret, caret); } } #[test] fn notification_popup_persists_blocks_wheels_and_restores_real_document_carets() { let mut session = EditorSession::open(None, vec![], None).unwrap(); session.editor_mut().set_message(""); session .editor_mut() .apply(vec![crate::editor::Effect::Replace( "DOCUMENT\n".repeat(20), )]) .unwrap(); session.handle(Input::Ctrl('e')); session.handle(Input::Ctrl('e')); assert_eq!(session.editor().selection_count(), 3); let area = Rect::new(3, 2, 100, 12); let mut terminal = Terminal::with_options( ratatui::backend::TestBackend::new(110, 18), ratatui::TerminalOptions { viewport: ratatui::Viewport::Fixed(area), }, ) .unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); let baseline = screen_text(&terminal); let geometry = views.document.geometry; let caret = terminal.get_cursor_position().unwrap(); let carets = session.editor().carets(); let offsets = views.offsets.clone(); let pane = session.editor().pane_id(); session .editor_mut() .set_message("write failed: permission denied"); for _ in 0..3 { terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); let popup = views.panels.notification.unwrap(); assert_eq!(popup.right(), area.right()); assert_eq!(popup.bottom(), views.rects[0].1.bottom()); assert!(popup.bottom() < area.bottom()); assert!(popup.x > geometry.unwrap().1.x); assert!(pane_screen(&terminal, popup).contains("write failed: permission denied")); assert_eq!(views.document.geometry, geometry); assert_eq!(terminal.get_cursor_position().unwrap(), caret); for kind in [ MouseEventKind::ScrollUp, MouseEventKind::ScrollDown, MouseEventKind::ScrollLeft, MouseEventKind::ScrollRight, ] { assert!(views.scroll(&mut session, document_wheel(kind, popup.x, popup.y))); } assert_eq!(views.offsets, offsets); assert_eq!(session.editor().pane_id(), pane); assert_eq!(session.editor().carets(), carets); } session.handle(Input::Escape); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert!(views.panels.notification.is_none()); assert_eq!(session.editor().carets(), carets); assert_eq!( session.editor().message(), "write failed: permission denied" ); assert_eq!(screen_text(&terminal), baseline); session.handle(Input::Escape); assert_eq!(session.editor().selection_count(), 1); session.editor_mut().set_message("new operation"); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert!(views.panels.notification.is_some()); for prefix in [':', '/'] { session.handle(Input::Char(prefix)); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); let input = views.panels.input.unwrap(); assert!(views.panels.notification.is_none()); let caret = terminal.get_cursor_position().unwrap(); let mode = session.editor().mode(); for kind in [ MouseEventKind::ScrollUp, MouseEventKind::ScrollDown, MouseEventKind::ScrollLeft, MouseEventKind::ScrollRight, ] { assert!(views.scroll(&mut session, document_wheel(kind, input.x, input.y))); } assert_eq!(views.offsets, offsets); assert_eq!(session.editor().mode(), mode); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert_eq!(terminal.get_cursor_position().unwrap(), caret); session.handle(Input::Escape); } } #[test] fn notification_hides_covered_hardware_cursor_without_changing_its_document_geometry() { let mut editor = Editor::open(None, vec![]).unwrap(); editor .apply(vec![crate::editor::Effect::Replace("DOCUMENT".into())]) .unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(12, 4)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let caret = views.panels.caret; let geometry = views.document.geometry; assert!(terminal.backend().cursor_visible()); editor.set_message("A long operation notification wraps over the document"); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert!( views .panels .notification .unwrap() .contains(caret.unwrap().1) ); assert!(!terminal.backend().cursor_visible()); assert_eq!(views.panels.caret, caret); assert_eq!(views.document.geometry, geometry); editor.handle(Input::Escape); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert!(terminal.backend().cursor_visible()); assert_eq!(terminal.get_cursor_position().unwrap(), caret.unwrap().1); assert_eq!(views.document.geometry, geometry); } #[test] fn notifications_yield_to_existing_overlays_and_stay_bounded_when_tiny() { for overlay in [ "palette", "hover", "completion", "files", "startup", "selector", ] { let mut editor = Editor::open(None, vec![]).unwrap(); editor.set_message("LAST OPERATION"); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 20)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert!(views.panels.notification.is_some()); match overlay { "palette" => { editor.handle(Input::Ctrl('p')); } "hover" => { editor.show_hover("Hover details".into()).unwrap(); } "completion" => { use crate::plugin::{CompletionEdit, CompletionItem, TextPosition}; editor.handle(Input::Char('i')); editor .show_completion(vec![CompletionItem { label: "completion".into(), detail: None, primary_edit: 0, snippet: None, edits: vec![CompletionEdit { start: TextPosition { row: 0, column: 0 }, end: TextPosition { row: 0, column: 0 }, text: "completion".into(), }], }]) .unwrap(); } "files" => { editor.handle(Input::Char(' ')); editor.handle(Input::Char('o')); } "startup" => editor.show_startup_diagnostic("Configuration error".into()), _ => { editor.execute("diff"); } } terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert!(views.panels.notification.is_none(), "{overlay}"); assert!( !screen_text(&terminal).contains("LAST OPERATION"), "{overlay}" ); } let mut editor = Editor::open(None, vec![]).unwrap(); editor.set_message(format!("{}\tEND", "界e\u{301} operation ".repeat(50))); for (width, height) in [(100, 20), (12, 3), (3, 2), (1, 1), (0, 1), (1, 0)] { let area = Rect::new(3, 2, width, height); let mut terminal = Terminal::with_options( ratatui::backend::TestBackend::new(110, 26), ratatui::TerminalOptions { viewport: ratatui::Viewport::Fixed(area), }, ) .unwrap(); terminal .draw(|frame| { let popup = draw_notification(frame, &editor, area); if width == 0 || height == 0 { assert!(popup.is_none()); } else { let popup = popup.unwrap(); assert_eq!(popup.intersection(area), popup); assert_eq!(popup.right(), area.right()); assert_eq!(popup.bottom(), area.bottom()); assert!(popup.width <= 60 && popup.height <= 8); } }) .unwrap(); assert!(!screen_text(&terminal).contains("END")); } } #[test] fn comparison_titles_follow_loaded_counts_and_each_tabs_context() { let directory = Directory::new(); let path = directory.file("source.rs", "fn main() {}"); for phase in ["loading", "before", "after", "unloaded", "view"] { let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor.execute("tab.new"); editor.execute("diff"); if phase != "loading" { let (id, _) = editor.take_diff_request().unwrap(); editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); } if matches!(phase, "after" | "unloaded" | "view") { editor.handle(Input::Down); editor.handle(Input::Enter); } if matches!(phase, "unloaded" | "view") { editor.handle(Input::Down); editor.handle(Input::Enter); let (id, _) = editor.take_diff_request().unwrap(); let mut file = syntax_diff_file( "source.rs", "old\ncontext\nremove", "new\ncontext\nextra\nmore", ); file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); if phase == "unloaded" { file.loaded = false; file.before.clear(); file.after.clear(); file.hunks.clear(); } editor.apply_diff_result( id, Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { blame: Default::default(), root: directory.0.clone(), editable_paths: Default::default(), before: "baseline".into(), after: "historic".into(), revisions: ["before-snapshot".into(), "after-snapshot".into()], files: vec![file], })), ); } let changes = if phase == "view" { " +3 -2" } else { "" }; let default_tabs = editor.top_bar_config().segment("tabs").clone(); for (template, ordinary, prefix, uses_view) in [ (None, "1 source.rs", "2 ", true), (Some("{index} {name}{modified}"), "1 source.rs", "2 ", false), ( Some("T{index}:{view}{name}{changes}{modified}"), "T1:source.rs", "T2:", true, ), ] { configure_chrome(&mut editor, |top, bottom| { top.segments.insert("tabs".into(), default_tabs.clone()); if let Some(template) = template { top.segments.get_mut("tabs").unwrap().title = template.into(); } bottom.left = vec!["mode".into()]; bottom.center.clear(); bottom.right.clear(); bottom.segments.get_mut("mode").unwrap().title = "Current:{view}{name}{changes}".into(); }); for comparison_active in [true, false] { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 2)).unwrap(); terminal .draw(|frame| { for (line, y) in [ (editor.top_bar_config(), 0), (editor.bottom_bar_config(), 1), ] { draw_chrome( frame, chrome_groups(&editor, line, &SyntaxCache::new()), Rect::new(0, y, 160, 1), ); } }) .unwrap(); let titles = pane_screen(&terminal, Rect::new(0, 0, 160, 1)); assert!(titles.contains(ordinary), "{phase}: {titles}"); let comparison = if uses_view { format!("{prefix}Diff · source.rs{changes}") } else { format!("{prefix}source.rs") }; assert!(titles.contains(&comparison), "{phase}: {titles}"); assert_eq!( titles.matches("Diff · ").count(), usize::from(uses_view), "{phase}: {titles}" ); if matches!(phase, "view" | "unloaded") && comparison_active { let total = if phase == "view" { "Diff +3 -2" } else { "Diff …" }; assert!(titles.contains(total), "{phase}: {titles}"); } else { assert!( !titles.contains("Diff +") && !titles.contains("Diff …"), "{phase}: {titles}" ); } let current = pane_screen(&terminal, Rect::new(0, 1, 160, 1)); let expected = if comparison_active { format!("Current:Diff · source.rs{changes}") } else { "Current:source.rs".into() }; assert!(current.contains(&expected), "{phase}: {current}"); editor.execute(if comparison_active { "tab.previous" } else { "tab.next" }); } } } } #[test] fn comparison_title_counts_follow_live_edits_and_undo_in_hidden_tabs() { let directory = Directory::new(); let path = directory.file("live.rs", "new"); let mut editor = Editor::open(Some(path), vec![]).unwrap(); editor.execute("diff"); let (id, _) = editor.take_diff_request().unwrap(); editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); editor.handle(Input::Down); editor.handle(Input::Enter); editor.handle(Input::Enter); let (id, _) = editor.take_diff_request().unwrap(); let mut unopened = syntax_diff_file("unopened.rs", "", ""); unopened.loaded = false; unopened.before.clear(); unopened.after.clear(); unopened.hunks.clear(); unopened.stats = Some((7, 4)); editor.apply_diff_result( id, Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { blame: Default::default(), root: directory.0.clone(), editable_paths: ["live.rs".into()].into(), before: "baseline".into(), after: "Working checkout".into(), revisions: ["before-snapshot".into(), "after-snapshot".into()], files: vec![syntax_diff_file("live.rs", "old", "new"), unopened], })), ); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap(); let mut views = BufferViewports::default(); for input in [Input::Down, Input::Char('l'), Input::Ctrl('u'), Input::Home] { editor.handle(input); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let titles = pane_screen(&terminal, Rect::new(0, 0, 120, 1)); assert!(titles.contains("Diff +8 -5"), "{titles}"); } editor.handle(Input::Char('\t')); editor.handle(Input::Char('\t')); assert!(editor.diff_editing()); editor.execute("tab.new"); for (inputs, counts, total) in [ (vec![], "+1 -1", "+8 -5"), ( vec![ Input::Char('i'), Input::Paste("extra\n".into()), Input::Escape, ], "+2 -1", "+9 -5", ), (vec![Input::Char('u')], "+1 -1", "+8 -5"), (vec![Input::Char('U')], "+2 -1", "+9 -5"), ] { for input in inputs { editor.handle(input); } editor.refresh_live_diff(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let titles = pane_screen(&terminal, Rect::new(0, 0, 120, 1)); assert!( titles.contains(&format!("1 Diff · live.rs {counts}")), "{titles}" ); assert!(titles.contains("2 live.rs"), "{titles}"); assert!(!titles.contains("Diff +"), "{titles}"); editor.execute("tab.previous"); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let titles = pane_screen(&terminal, Rect::new(0, 0, 120, 1)); assert!(titles.contains(&format!("Diff {total}")), "{titles}"); editor.execute("tab.next"); } } #[test] fn tab_titles_update_templates_without_reinterpreting_file_names() { let directory = Directory::new(); let path = directory.file("λ{index}\t.rs", "source"); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor.execute("tab.new"); editor.handle(Input::Char('i')); editor.handle(Input::Paste("changed ".into())); editor.handle(Input::Escape); configure_chrome(&mut editor, |top, _| { let tabs = top.segments.get_mut("tabs").unwrap(); tabs.title = "T{index}:{name}{modified}".into(); tabs.padding = 0; tabs.leading_edge.clear(); tabs.trailing_edge.clear(); }); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 3)).unwrap(); terminal .draw(|frame| { draw_chrome( frame, chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()), frame.area(), ); }) .unwrap(); let titles = pane_screen(&terminal, Rect::new(0, 0, 160, 1)); assert!(titles.contains("T1:λ{index}�.rs [+]"), "{titles}"); assert!(titles.contains("T2:λ{index}�.rs [+]"), "{titles}"); configure_chrome(&mut editor, |top, _| { top.segments.get_mut("tabs").unwrap().title = "{index}:{path}{modified}".into(); }); terminal .draw(|frame| { draw_chrome( frame, chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()), frame.area(), ); }) .unwrap(); let titles = pane_screen(&terminal, Rect::new(0, 0, 160, 1)); let expected = display_text(&format!("2:{} [+]", path.display())); assert!(titles.contains(&expected), "{titles}"); assert!(!titles.contains("T2:"), "{titles}"); } #[test] fn chrome_anchors_center_on_the_full_row_and_preserve_right_order() { let mut editor = Editor::open(None, vec![]).unwrap(); configure_chrome(&mut editor, |top, bottom| { top.left = vec!["tabs".into(), "left".into()]; top.center = vec!["middle".into()]; top.right = vec!["one".into(), "two".into()]; bottom.left = vec!["left".into()]; bottom.center = top.center.clone(); bottom.right = top.right.clone(); for line in [top, bottom] { for name in ["left", "middle", "one", "two"] { line.segments.insert( name.into(), SegmentConfig { title: String::new(), padding: 0, margin: [0; 2], leading_edge: String::new(), trailing_edge: String::new(), first_leading_edge: None, }, ); } } }); for (name, text) in [ ("left", "LEFTEND"), ("middle", "MID"), ("one", "A"), ("two", "BC"), ] { editor.set_label(name.into(), text.into()).unwrap(); } let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(51, 4)).unwrap(); let syntax = SyntaxCache::new(); terminal .draw(|frame| { draw_chrome( frame, chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()), frame.area(), ); draw_chrome( frame, chrome_groups(&editor, editor.bottom_bar_config(), &syntax), Rect::new(3, 2, 45, 1), ); }) .unwrap(); let buffer = terminal.backend().buffer(); for y in [0, 2] { assert_eq!(buffer[(24, y)].symbol(), "M"); assert_eq!(buffer[(25, y)].symbol(), "I"); assert_eq!(buffer[(26, y)].symbol(), "D"); } assert!(pane_screen(&terminal, Rect::new(0, 0, 51, 1)).ends_with("A BC")); assert!(pane_screen(&terminal, Rect::new(3, 2, 45, 1)).ends_with("A BC")); editor.set_label("left".into(), "L".repeat(30)).unwrap(); terminal .draw(|frame| { draw_chrome( frame, chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()), frame.area(), ); draw_chrome( frame, chrome_groups(&editor, editor.bottom_bar_config(), &syntax), Rect::new(3, 2, 45, 1), ); }) .unwrap(); for (y, x, width) in [(0, 0, 51), (2, 3, 45)] { let row = pane_screen(&terminal, Rect::new(x, y, width, 1)); assert!(!row.contains("MID"), "{row}"); assert!(row.ends_with("A BC"), "{row}"); } } #[test] fn active_tabs_keep_their_anchor_and_text_when_optional_groups_collide() { let mut editor = Editor::open(None, vec![]).unwrap(); editor.execute("tab.new"); configure_chrome(&mut editor, |top, bottom| { top.left = vec!["prefix".into()]; top.center = vec!["tabs".into()]; top.right = vec!["suffix".into()]; let tabs = top.segments.get_mut("tabs").unwrap(); tabs.title = "{index}:active".into(); tabs.padding = 64; tabs.leading_edge = "界".repeat(8); tabs.trailing_edge = "界".repeat(8); *bottom = top.clone(); }); editor .set_label("prefix".into(), "LEFT".repeat(32)) .unwrap(); editor .set_label("suffix".into(), "RIGHT".repeat(32)) .unwrap(); for width in [40, 8, 1, 0] { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 2)).unwrap(); terminal .draw(|frame| { for (y, line) in [editor.top_bar_config(), editor.bottom_bar_config()] .into_iter() .enumerate() { draw_chrome( frame, chrome_groups(&editor, line, &SyntaxCache::new()), Rect::new(0, y as u16, width, 1), ); } }) .unwrap(); for y in 0..2 { if width == 0 { continue; } let row = pane_screen(&terminal, Rect::new(0, y, width, 1)); assert!( row.trim() .starts_with(if width == 1 { "2" } else { "2:active" }), "{row}" ); assert_eq!( terminal.backend().buffer()[((width - width.min(8)) / 2, y)].symbol(), "2" ); assert!(!row.contains("LEFT") && !row.contains("RIGHT"), "{row}"); assert!(!row.contains('界'), "{row}"); } } configure_chrome(&mut editor, |top, bottom| { let tabs = top.segments.get_mut("tabs").unwrap(); tabs.padding = 0; tabs.leading_edge.clear(); tabs.trailing_edge.clear(); *bottom = top.clone(); }); editor .set_label("prefix".into(), "LEFT".repeat(32)) .unwrap(); editor .set_label("suffix".into(), "RIGHT".repeat(32)) .unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 2)).unwrap(); terminal .draw(|frame| { for (y, line) in [editor.top_bar_config(), editor.bottom_bar_config()] .into_iter() .enumerate() { draw_chrome( frame, chrome_groups(&editor, line, &SyntaxCache::new()), Rect::new(0, y as u16, 40, 1), ); } }) .unwrap(); for y in 0..2 { let row = pane_screen(&terminal, Rect::new(0, y, 40, 1)); assert_eq!(&row[16..24], "2:active"); } } #[test] fn chrome_uses_cell_widths_for_independent_edges_and_internal_padding() { let mut editor = Editor::open(None, vec![]).unwrap(); editor.execute("tab.new"); configure_chrome(&mut editor, |top, bottom| { top.right = vec!["badge".into()]; top.segments.insert( "tabs".into(), SegmentConfig { title: "{index}".into(), padding: 2, margin: [0; 2], leading_edge: "界▌".into(), trailing_edge: "▐界".into(), first_leading_edge: Some("<".into()), }, ); top.segments.insert( "badge".into(), SegmentConfig { title: String::new(), padding: 2, margin: [0; 2], leading_edge: "界(".into(), trailing_edge: ")界".into(), first_leading_edge: None, }, ); bottom.left.clear(); bottom.center.clear(); bottom.right = vec!["badge".into()]; bottom .segments .insert("badge".into(), top.segments["badge"].clone()); }); editor.set_label("badge".into(), "e\u{301}".into()).unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 3)).unwrap(); let syntax = SyntaxCache::new(); terminal .draw(|frame| { draw_chrome( frame, chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()), frame.area(), ); draw_chrome( frame, chrome_groups(&editor, editor.bottom_bar_config(), &syntax), Rect::new(0, 2, 40, 1), ); }) .unwrap(); let buffer = terminal.backend().buffer(); for (x, text) in [ (2, "1"), (6, "界"), (8, "▌"), (11, "2"), (14, "▐"), (15, "界"), (29, "界"), (31, "("), (34, "e\u{301}"), (37, ")"), (38, "界"), ] { assert_eq!(buffer[(x, 0)].symbol(), text, "cell {x}"); } for (x, text) in [ (29, "界"), (31, "("), (34, "e\u{301}"), (37, ")"), (38, "界"), ] { assert_eq!(buffer[(x, 2)].symbol(), text, "status cell {x}"); } for x in [32, 33, 35, 36] { assert_eq!(buffer[(x, 2)].symbol(), " "); assert_eq!(buffer[(x, 2)].bg, Color::Cyan); } assert_eq!(buffer[(2, 0)].bg, Color::Reset); assert_eq!(buffer[(11, 0)].bg, Color::Cyan); assert_eq!(buffer[(5, 0)].bg, Color::Reset); for x in [9, 10, 12, 13, 32, 33, 35, 36] { assert_eq!(buffer[(x, 0)].symbol(), " "); assert_eq!(buffer[(x, 0)].bg, Color::Cyan); } editor.execute("tab.previous"); terminal .draw(|frame| { draw_chrome( frame, chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()), frame.area(), ); }) .unwrap(); let buffer = terminal.backend().buffer(); assert_eq!(buffer[(0, 0)].symbol(), "<"); assert_eq!(buffer[(3, 0)].symbol(), "1"); assert_eq!(buffer[(3, 0)].bg, Color::Cyan); assert_eq!(buffer[(12, 0)].symbol(), "2"); assert_eq!(buffer[(12, 0)].bg, Color::Reset); } #[test] fn empty_bar_content_reclaims_both_rows_even_while_prompting() { let mut editor = Editor::open(None, vec![]).unwrap(); editor.execute("tab.new"); configure_chrome(&mut editor, |top, bottom| { top.left.clear(); top.center = vec!["empty".into()]; top.right.clear(); top.segments.insert( "empty".into(), SegmentConfig { title: "Not visible without a value".into(), padding: 64, margin: [0; 2], leading_edge: "░▒▓".into(), trailing_edge: "▓▒░".into(), first_leading_edge: None, }, ); *bottom = top.clone(); }); let mut syntax = SyntaxCache::new(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap(); terminal .draw(|frame| { let workspace = draw_bars(frame, &editor, frame.area(), &mut syntax); assert_eq!(workspace, frame.area()); }) .unwrap(); editor.set_label("empty".into(), "present".into()).unwrap(); terminal .draw(|frame| { let workspace = draw_bars(frame, &editor, frame.area(), &mut syntax); assert_eq!(workspace, Rect::new(0, 1, 40, 3)); }) .unwrap(); for y in [0, 4] { let title = pane_screen(&terminal, Rect::new(0, y, 40, 1)); assert!(title.contains("Not visible without a value"), "{title}"); assert!(!title.contains('▓'), "{title}"); } editor.handle(Input::Char(':')); terminal .draw(|frame| { let workspace = draw_bars(frame, &editor, frame.area(), &mut syntax); assert_eq!(workspace, Rect::new(0, 1, 40, 3)); }) .unwrap(); } #[test] fn tiny_bars_yield_to_document_and_prompt_identically_in_either_position() { let directory = Directory::new(); let path = directory.file("source.txt", "DOCUMENT"); for segment in ["tabs", "mode"] { for at_top in [false, true] { for prompting in [false, true] { let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor.execute("tab.new"); configure_chrome(&mut editor, |top, bottom| { for line in [&mut *top, &mut *bottom] { line.left.clear(); line.center.clear(); line.right.clear(); } let line = if at_top { top } else { bottom }; line.left = vec![segment.into()]; }); if prompting { editor.handle(Input::Char(':')); } for height in 1..=4 { let area = Rect::new(3, 2, 80, height); let mut terminal = Terminal::with_options( ratatui::backend::TestBackend::new(100, 10), ratatui::TerminalOptions { viewport: ratatui::Viewport::Fixed(area), }, ) .unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let bar_text = match segment { "tabs" => "2 source.txt", _ if prompting => "COMMAND", _ => "NORMAL", }; let visible = height >= 2; assert_eq!(screen_text(&terminal).contains(bar_text), visible); let cursor = terminal.get_cursor_position().unwrap(); assert!( area.contains(cursor), "{segment} {at_top} prompting={prompting} {area:?}: {cursor:?}" ); let viewport = views.document.geometry.unwrap().1; assert_eq!(viewport.height, height - u16::from(visible)); if prompting { let input = views.panels.input.unwrap(); assert!(input.contains(cursor)); } else { assert!(viewport.contains(cursor)); assert!(pane_screen(&terminal, viewport).contains("DOCUMENT")); } if visible { let y = if at_top { area.y } else { area.bottom() - 1 }; assert!( pane_screen(&terminal, Rect::new(area.x, y, area.width, 1)) .contains(bar_text) ); } } } } } } #[test] fn tabs_only_singletons_hide_in_either_bar_and_reappear_with_another_tab() { let mut editor = Editor::open(None, vec![]).unwrap(); configure_chrome(&mut editor, |top, bottom| *bottom = top.clone()); let mut views = BufferViewports::default(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert_eq!(views.rects[0].1, Rect::new(0, 0, 80, 12)); assert!(views.scroll_document(&editor, document_wheel(MouseEventKind::ScrollDown, 2, 0),)); editor.execute("tab.new"); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert_eq!(views.rects[0].1, Rect::new(0, 1, 80, 10)); for y in [0, 11] { assert!(!views.scroll_document(&editor, document_wheel(MouseEventKind::ScrollDown, 2, y),)); } let caret = terminal.get_cursor_position().unwrap(); assert!(views.rects[0].1.contains(caret)); for y in [0, 11] { let bar = pane_screen(&terminal, Rect::new(0, y, 80, 1)); assert!( bar.contains("1 [No Name]") && bar.contains("2 [No Name]"), "{bar}" ); } editor.execute("tab.close"); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert_eq!(views.rects[0].1, Rect::new(0, 0, 80, 12)); assert!(!screen_text(&terminal).contains("[No Name]")); for y in [0, 11] { assert!(views.scroll_document(&editor, document_wheel(MouseEventKind::ScrollDown, 2, y),)); } } #[test] fn swapped_bars_render_active_metadata_and_language_on_the_first_paint() { let directory = Directory::new(); let path = directory.file("active.rs", "fn main() {}"); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor.execute("tab.new"); editor.handle(Input::Char('i')); editor.handle(Input::Paste("// changed\n".into())); editor.handle(Input::Escape); configure_chrome(&mut editor, std::mem::swap); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(240, 12)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let top = pane_screen(&terminal, Rect::new(0, 0, 240, 1)); assert!(top.contains("NORMAL"), "{top}"); assert!(top.contains(&path.display().to_string()), "{top}"); assert!(top.contains("[+]") && top.contains("Rust"), "{top}"); let cursor = editor.cursor(); assert!( top.contains(&format!("{}:{}", cursor.row + 1, cursor.column + 1)), "{top}" ); let bottom = pane_screen(&terminal, Rect::new(0, 11, 240, 1)); assert!( bottom.contains("1 active.rs [+]") && bottom.contains("2 active.rs [+]"), "{bottom}" ); assert_eq!(views.document.geometry.unwrap().1.y, 1); let before = views.rects[0].1; editor.handle(Input::Char(':')); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert_eq!(views.rects[0].1, before); let input = views.panels.input.unwrap(); assert!(input.contains(terminal.get_cursor_position().unwrap())); assert_eq!(pane_screen(&terminal, Rect::new(0, 11, 240, 1)), bottom); } #[test] fn both_bars_bound_palettes_diff_selectors_docks_and_diff_prompts() { let directory = Directory::new(); let mut editor = Editor::open(Some(directory.file("source.rs", "fn main() {}")), vec![]).unwrap(); editor.execute("tab.new"); configure_chrome(&mut editor, |top, bottom| { std::mem::swap(top, bottom); top.left = vec!["mode".into(), "language".into()]; top.center = vec!["workspace".into()]; top.right = vec!["position".into()]; bottom.right = vec!["battery".into()]; }); editor .set_label("workspace".into(), "WORKSPACE".into()) .unwrap(); editor.set_label("battery".into(), "BOTTOM".into()).unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); let mut views = BufferViewports::default(); let assert_bars = |terminal: &Terminal, views: &BufferViewports| { assert!(pane_screen(terminal, Rect::new(0, 0, 120, 1)).contains("WORKSPACE")); let bottom_y = 29; assert!(pane_screen(terminal, Rect::new(0, bottom_y, 120, 1)).contains("BOTTOM")); for (_, rect) in &views.rects { assert!(rect.y >= 1 && rect.bottom() <= bottom_y); } if let Some(geometry) = &views.panels.diff { for (rect, _) in geometry.regions { if rect.height > 0 { assert!(rect.y >= 1 && rect.bottom() <= bottom_y); } } } if let Some((rect, _, _)) = views.panels.files { assert!(rect.y >= 1 && rect.bottom() <= bottom_y); } }; terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert!(pane_screen(&terminal, Rect::new(0, 0, 120, 1)).contains("Rust")); assert_bars(&terminal, &views); editor.handle(Input::Ctrl('p')); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert!(screen_text(&terminal).contains("Commands")); assert_bars(&terminal, &views); editor.handle(Input::Escape); 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| views.draw(frame, &mut editor)) .unwrap(); assert!(screen_text(&terminal).contains("Choose Before")); assert!(views.document.geometry.is_none()); assert_bars(&terminal, &views); 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 { blame: Default::default(), root: directory.0.clone(), editable_paths: Default::default(), before: "baseline".into(), after: "Working checkout".into(), revisions: ["before-snapshot".into(), "after-snapshot".into()], files: vec![deletion_file()], })), ); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert!(views.panels.files.is_some()); assert_bars(&terminal, &views); editor.handle(Input::Char(':')); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let cursor = terminal.get_cursor_position().unwrap(); assert!(Rect::new(0, 1, 120, 28).contains(cursor), "{cursor:?}"); assert_bars(&terminal, &views); editor.handle(Input::Escape); editor.handle(Input::Char(' ')); editor.handle(Input::Char('o')); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert_eq!( editor.file_picker().unwrap().presentation, FilePresentation::Floating ); assert_bars(&terminal, &views); } #[test] fn narrow_tab_bar_keeps_the_active_title_visible_and_handles_empty_areas() { let directory = Directory::new(); let mut editor = Editor::open( Some(directory.file("a-very-long-inactive-title.txt", "first")), vec![], ) .unwrap(); editor.execute("tab.new"); editor.execute("tab.new"); editor.execute(&format!( "edit {}", directory.file("active.txt", "ACTIVE").display() )); editor.set_battery_charge(Some(27)); let mut views = BufferViewports::default(); for (width, height) in [(12, 9), (7, 9), (1, 9), (0, 9), (12, 1), (12, 0)] { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); if width > 0 && height >= 3 { let title = pane_screen(&terminal, Rect::new(0, 0, width, 1)); assert!(title.trim_start().starts_with('3'), "{title}"); assert!(!title.contains("bat 27%"), "{title}"); } if width > 0 && height == 1 { let (_, viewport) = views.document.geometry.unwrap(); assert!(viewport.contains(terminal.get_cursor_position().unwrap())); assert!(pane_screen(&terminal, viewport).contains("ACTIVE")); } } let mut editor = Editor::open(None, vec![]).unwrap(); editor.execute("tab.new"); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 6)).unwrap(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let titles = pane_screen(&terminal, Rect::new(0, 0, 40, 1)); assert!(titles.contains("1 [No Name]"), "{titles}"); assert!(titles.contains("2 [No Name]"), "{titles}"); } #[test] fn inline_blame_is_visible_in_focused_diff_buffers() { for unified in [false, true] { for before in [false, true] { let mut editor = readonly_diff_editor(deletion_file()); if unified { editor.execute("diff.view.toggle"); } editor.handle(Input::Char('\t')); if editor.diff_buffer_focus() != Some(if before { Focus::Before } else { Focus::After }) { editor.handle(Input::Char('\t')); } let source = editor.blame_source().unwrap(); let blame = Arc::new(crate::blame::LineBlame { commit: "0123456789abcdef".into(), author: "Author".into(), summary: "Original".into(), uncommitted: false, }); editor.set_line_blame(source.clone(), blame.clone()); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(180, 12)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let screen = screen_text(&terminal); assert!(screen.contains("Author · Original (01234567)"), "{screen}"); let caret = terminal.get_cursor_position().unwrap(); assert!(pane_screen(&terminal, Rect::new(0, caret.y, 180, 1)).contains("Author")); editor.handle(Input::Char('\t')); assert!(!editor.set_line_blame(source, blame)); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert!(!screen_text(&terminal).contains("Author · Original")); } } } #[test] fn inline_blame_remains_visible_on_lines_with_room_for_partial_metadata() { let directory = Directory::new(); for (context, width) in [("document", 80), ("split", 180), ("unified", 120)] { let text = "short\nlet meaningful_identifier = call_with_arguments();\ntail"; let mut editor = if context == "document" { Editor::open(Some(directory.file("file.txt", text)), vec![]).unwrap() } else { let mut editor = readonly_diff_editor(syntax_diff_file("file.txt", text, "changed")); if context == "unified" { editor.execute("diff.view.toggle"); } editor.handle(Input::Char('\t')); editor }; let source = editor.blame_source().unwrap(); let blame = Arc::new(crate::blame::LineBlame { commit: "0123456789abcdef".into(), author: "界 e\u{301} Author".into(), summary: "A detailed commit summary describing the source change".into(), uncommitted: false, }); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 12)).unwrap(); let mut views = BufferViewports::default(); for row in 0..3 { assert_eq!(editor.cursor().row, row); editor.set_cached_line_blame(&source, blame.clone()); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let caret = terminal.get_cursor_position().unwrap(); let line = pane_screen(&terminal, Rect::new(0, caret.y, width, 1)); assert!( line.contains("Author") && line.contains("e\u{301}"), "{context}, source row={row}: {line}" ); assert!(line.contains(editor.lines()[row].as_str()), "{line}"); editor.handle(Input::Down); } } } #[test] fn diff_blame_uses_final_wrap_and_survives_a_historical_tab() { for unified in [false, true] { let text = "x".repeat(200); let mut editor = readonly_diff_editor(syntax_diff_file("file.txt", &text, "after")); if unified { editor.execute("diff.view.toggle"); } editor.handle(Input::Char('\t')); editor.execute("word_wrap.toggle"); let source = editor.blame_source().unwrap(); let blame = Arc::new(crate::blame::LineBlame { commit: String::new(), author: String::new(), summary: String::new(), uncommitted: true, }); editor.set_line_blame(source.clone(), blame.clone()); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(180, 14)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let caret = terminal.get_cursor_position().unwrap(); let screen = screen_text(&terminal); assert_eq!(screen.matches("Uncommitted").count(), 1, "{screen}"); assert!(!pane_screen(&terminal, Rect::new(0, caret.y, 180, 1)).contains("Uncommitted")); let mut narrow = Terminal::new(ratatui::backend::TestBackend::new(12, 14)).unwrap(); narrow.draw(|frame| views.draw(frame, &mut editor)).unwrap(); assert!(!screen_text(&narrow).contains("Uncommitted")); editor.execute("tab.new"); assert!(editor.diff().is_none()); if !editor.word_wrap() { editor.execute("word_wrap.toggle"); } editor.set_cached_line_blame(&source, blame); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert!(screen_text(&terminal).contains("Uncommitted")); editor.handle(Input::Char('i')); editor.handle(Input::Char('z')); assert_eq!(editor.lines().join("\n"), text); } } #[test] fn inline_blame_loads_real_historical_and_working_diff_sources() { let directory = Directory::new(); let git = |args: &[&str]| { let output = std::process::Command::new("git") .current_dir(&directory.0) .args(args) .env("GIT_CONFIG_GLOBAL", "/dev/null") .env("GIT_CONFIG_NOSYSTEM", "1") .env("GIT_AUTHOR_NAME", "Author") .env("GIT_AUTHOR_EMAIL", "author@example.test") .env("GIT_COMMITTER_NAME", "Author") .env("GIT_COMMITTER_EMAIL", "author@example.test") .env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z") .env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z") .output() .unwrap(); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); String::from_utf8(output.stdout).unwrap().trim().to_owned() }; git(&["init", "-q", "--template="]); let path = directory.file("file.txt", "original\n"); git(&["add", "file.txt"]); git(&["commit", "-qm", "Original"]); let original = git(&["rev-parse", "HEAD"]); directory.file("file.txt", "historical\n"); git(&["commit", "-qam", "Historical"]); let historical = git(&["rev-parse", "HEAD"]); git(&["rm", "-q", "file.txt"]); git(&["commit", "-qm", "Deleted"]); let config = directory.file( ".git/vamp-config.rb", "Vamp.configure { |c| c.git_gutter = 'none' }", ); let mut session = EditorSession::open(Some(path.clone()), vec![], Some(&config)).unwrap(); let ready = |session: &mut EditorSession, predicate: &dyn Fn(&Editor) -> bool| { let deadline = std::time::Instant::now() + Duration::from_secs(10); while !predicate(session.editor()) { assert!( std::time::Instant::now() < deadline, "message={:?}, state={:?}, blame={:?}, text={:?}", session.editor().message(), session.editor().diff().map(|diff| ( diff.phase, diff.focus, diff.file, &diff.error )), session.editor().line_blame(), session.editor().lines() ); session.poll(); std::thread::sleep(Duration::from_millis(1)); } }; session.execute("diff"); ready(&mut session, &|editor| { editor.diff().is_some_and(|diff| diff.catalog.is_some()) }); for revision in [&original, &historical] { for ch in revision.chars() { session.handle(Input::Char(ch)); } session.handle(Input::Down); session.handle(Input::Enter); } ready(&mut session, &|editor| { editor .diff() .is_some_and(|diff| diff.selected_file_loaded()) }); session.handle(Input::Char('\t')); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(180, 12)).unwrap(); let mut views = BufferViewports::default(); for expected in ["Original", "Historical"] { ready(&mut session, &|editor| { editor .line_blame() .is_some_and(|label| label.contains(expected)) }); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); let screen = screen_text(&terminal); assert!(screen.contains(&format!("Author · {expected}")), "{screen}"); assert!(!screen.contains("Uncommitted"), "{screen}"); session.handle(Input::Char('\t')); } assert!(!path.exists()); session.execute("diff.close"); directory.file("file.txt", "restored\n"); git(&["add", "file.txt"]); git(&["commit", "-qm", "Restored"]); directory.file("file.txt", "unsaved baseline\n"); session.editor_mut().refresh_files(); session.execute("diff"); ready(&mut session, &|editor| { editor.diff().is_some_and(|diff| diff.catalog.is_some()) }); for ch in git(&["rev-parse", "HEAD"]).chars() { session.handle(Input::Char(ch)); } session.handle(Input::Down); session.handle(Input::Enter); session.handle(Input::Enter); ready(&mut session, &|editor| { editor .diff() .is_some_and(|diff| diff.selected_file_loaded()) }); session.handle(Input::Char('\t')); session.handle(Input::Char('\t')); ready(&mut session, &|editor| { editor.line_blame() == Some("Uncommitted") }); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert!(screen_text(&terminal).contains("Uncommitted")); assert_eq!(fs::read_to_string(path).unwrap(), "unsaved baseline\n"); } #[test] fn inline_blame_sanitizes_metadata_preserves_text_and_hides_stale_labels() { let directory = Directory::new(); let mut editor = Editor::open( Some(directory.file("source.txt", "界 e\u{301}\nsecond")), vec![], ) .unwrap(); let source = editor.blame_source().unwrap(); assert!(editor.set_line_blame( source, Arc::new(crate::blame::LineBlame { commit: "0123456789abcdef".into(), author: "A\tuthor".into(), summary: "Summary\n\u{1b}done".into(), uncommitted: false, }) )); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 8)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let screen = screen_text(&terminal); assert!( screen.contains("界") && screen.contains("e\u{301}"), "{screen}" ); assert!( screen.contains("A�uthor · Summary��done (01234567)"), "{screen}" ); let caret = terminal.get_cursor_position().unwrap(); let cell = terminal.backend().buffer()[(caret.x, caret.y)].clone(); editor.handle(Input::Down); assert!(editor.line_blame().is_none()); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert!(!screen_text(&terminal).contains("Summary")); editor.handle(Input::Up); assert_eq!( editor.line_blame(), Some("A�uthor · Summary��done (01234567)") ); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert!(screen_text(&terminal).contains("A�uthor · Summary��done (01234567)")); assert_eq!(terminal.get_cursor_position().unwrap(), caret); assert_eq!(terminal.backend().buffer()[(caret.x, caret.y)], cell); } #[test] fn inline_blame_uses_last_wrap_continuation_and_never_overflows_narrow_panes() { let directory = Directory::new(); let mut editor = Editor::open(Some(directory.file("source.txt", &"x".repeat(60))), vec![]).unwrap(); editor.set_gutter_style(crate::gutter::GutterStyle::None); editor.execute("word_wrap.toggle"); let source = editor.blame_source().unwrap(); editor.set_line_blame( source, Arc::new(crate::blame::LineBlame { commit: String::new(), author: String::new(), summary: String::new(), uncommitted: true, }), ); let mut views = BufferViewports::default(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(50, 8)).unwrap(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let caret = terminal.get_cursor_position().unwrap(); assert!(!pane_screen(&terminal, Rect::new(0, caret.y, 50, 1)).contains("Uncommitted")); assert!(pane_screen(&terminal, Rect::new(0, caret.y + 1, 50, 1)).contains("Uncommitted")); let cell = terminal.backend().buffer()[(16, caret.y + 1)].clone(); assert_eq!(cell.symbol(), "U"); assert_eq!(cell.fg, Color::DarkGray); let mut narrow = Terminal::new(ratatui::backend::TestBackend::new(12, 8)).unwrap(); narrow.draw(|frame| views.draw(frame, &mut editor)).unwrap(); assert!(!screen_text(&narrow).contains("Uncommitted")); assert!(screen_text(&narrow).contains("xxx")); editor.execute("git_blame.toggle"); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert!(!screen_text(&terminal).contains("Uncommitted")); } #[test] fn inline_blame_disappears_when_wheel_scroll_hides_the_primary_caret() { let directory = Directory::new(); let text = (0..40) .map(|row| format!("row {row}")) .collect::>() .join("\n"); let mut session = EditorSession::open(Some(directory.file("source.txt", &text)), vec![], None).unwrap(); let source = session.editor().blame_source().unwrap(); session.editor_mut().set_line_blame( source, Arc::new(crate::blame::LineBlame { commit: String::new(), author: String::new(), summary: String::new(), uncommitted: true, }), ); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 8)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert!(screen_text(&terminal).contains("Uncommitted")); let area = views.document.geometry.unwrap().1; assert!(views.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, area.x + 2, area.y + 2) )); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert!(!screen_text(&terminal).contains("Uncommitted")); assert_eq!(session.editor().cursor().row, 0); } #[test] fn default_native_battery_and_custom_override_redraw_through_clear() { let directory = Directory::new(); let mut editor = Editor::open(Some(directory.file("source.txt", "DOCUMENT")), vec![]).unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let original = screen_text(&terminal); assert_eq!(views.document.geometry.unwrap().1.y, 0); assert_eq!(editor.tab_count(), 1); assert!(!screen_text(&terminal).contains("bat ")); assert!(editor.set_battery_charge(Some(27))); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let title = pane_screen(&terminal, Rect::new(0, 0, 80, 1)); assert!(title.trim_end().ends_with("bat 27%"), "{title}"); assert_eq!(views.document.geometry.unwrap().1.y, 1); editor .set_label("battery".into(), "電池 e\u{301}\t90%".into()) .unwrap(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let title = pane_screen(&terminal, Rect::new(0, 0, 80, 1)); assert!(title.contains("1 source.txt"), "{title}"); assert!(title.contains("電") && title.contains("池"), "{title}"); assert!(title.contains("e\u{301}�90%"), "{title}"); assert!(!title.contains("bat 27%"), "{title}"); let buffer = terminal.backend().buffer(); assert_eq!(buffer[(78, 0)].symbol(), "%"); assert_eq!(buffer[(79, 0)].symbol(), " "); assert_eq!(views.document.geometry.unwrap().1.y, 1); assert!(pane_screen(&terminal, views.rects[0].1).contains("DOCUMENT")); assert!(terminal.get_cursor_position().unwrap().y >= 1); editor.set_label("battery".into(), "live".into()).unwrap(); assert!(editor.set_battery_charge(Some(28))); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert!( pane_screen(&terminal, Rect::new(0, 0, 80, 1)) .trim_end() .ends_with("live") ); assert!(!screen_text(&terminal).contains("90%")); editor.set_label("battery".into(), String::new()).unwrap(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let title = pane_screen(&terminal, Rect::new(0, 0, 80, 1)); assert!(title.trim_end().ends_with("bat 28%"), "{title}"); assert!(!title.contains("live"), "{title}"); assert_eq!(views.document.geometry.unwrap().1.y, 1); assert!(editor.set_battery_charge(None)); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert_eq!(views.document.geometry.unwrap().1.y, 0); assert_eq!(screen_text(&terminal), original); } #[test] fn native_battery_renders_at_each_configured_anchor_in_either_bar() { let mut editor = Editor::open(None, vec![]).unwrap(); editor.set_battery_charge(Some(27)); for at_top in [true, false] { for anchor in 0..3 { configure_chrome(&mut editor, |top, bottom| { for line in [&mut *top, &mut *bottom] { line.left.clear(); line.center.clear(); line.right.clear(); } let line = if at_top { top } else { bottom }; [&mut line.left, &mut line.center, &mut line.right][anchor].push("battery".into()); line.segments.insert( "battery".into(), SegmentConfig { padding: 0, ..SegmentConfig::default() }, ); }); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(48, 6)).unwrap(); terminal .draw(|frame| { draw_bars(frame, &editor, frame.area(), &mut SyntaxCache::new()); }) .unwrap(); let y = if at_top { 0 } else { 5 }; let row = pane_screen(&terminal, Rect::new(0, y, 48, 1)); assert_eq!(row.trim(), "bat 27%"); let x = [0, 20, 41][anchor]; assert_eq!(terminal.backend().buffer()[(x, y)].symbol(), "b"); } } configure_chrome(&mut editor, |top, bottom| { top.right.clear(); bottom.right.clear(); }); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(48, 6)).unwrap(); terminal .draw(|frame| { let workspace = draw_bars(frame, &editor, frame.area(), &mut SyntaxCache::new()); assert_eq!(workspace, frame.area()); }) .unwrap(); assert!(!screen_text(&terminal).contains("bat 27%")); } #[test] fn custom_value_keeps_a_gap_and_yields_to_the_full_active_title() { let directory = Directory::new(); let mut editor = Editor::open( Some(directory.file("a-very-long-inactive-title.txt", "first")), vec![], ) .unwrap(); editor.execute("tab.new"); editor.execute("tab.new"); editor.execute(&format!( "edit {}", directory.file("active.txt", "ACTIVE").display() )); configure_chrome(&mut editor, |top, _| { let tabs = top.segments.get_mut("tabs").unwrap(); tabs.leading_edge = "<<<".into(); tabs.trailing_edge = ">>>".into(); top.segments.get_mut("battery").unwrap().leading_edge = "<<<".into(); }); editor.set_label("battery".into(), "90%".into()).unwrap(); for width in [28, 29, 80] { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 2)).unwrap(); terminal .draw(|frame| { let area = frame.area(); draw_chrome( frame, chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()), area, ); }) .unwrap(); let title = pane_screen(&terminal, Rect::new(0, 0, width, 1)); assert!(title.contains("3 active.txt"), "{title}"); if width == 28 { assert!(!title.contains("90%"), "{title}"); } else { assert!(title.ends_with(" <<< 90% "), "{title}"); } } editor .set_label("battery".into(), "界".repeat(1024)) .unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 2)).unwrap(); terminal .draw(|frame| { let area = frame.area(); draw_chrome( frame, chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()), area, ); }) .unwrap(); let title = pane_screen(&terminal, Rect::new(0, 0, 80, 1)); assert!(title.contains("3 active.txt"), "{title}"); assert!(!title.contains('界'), "{title}"); } #[test] fn custom_value_stays_above_split_overlays_and_diff_geometry() { let directory = Directory::new(); let mut editor = Editor::open(Some(directory.file("source.txt", "DOCUMENT")), vec![]).unwrap(); editor.execute("pane.split_vertical"); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let original = views.rects.clone(); editor.set_label("battery".into(), "live".into()).unwrap(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); for ((id, before), (after_id, after)) in original.iter().zip(&views.rects) { assert_eq!(id, after_id); assert_eq!( *after, Rect::new(before.x, before.y + 1, before.width, before.height - 1) ); } let title = pane_screen(&terminal, Rect::new(0, 0, 120, 1)); editor.handle(Input::Ctrl('p')); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert!(screen_text(&terminal).contains("Commands")); assert_eq!(pane_screen(&terminal, Rect::new(0, 0, 120, 1)), title); let mut editor = readonly_diff_editor(deletion_file()); configure_chrome(&mut editor, |top, _| top.right = vec!["battery".into()]); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let regions = views.panels.diff.as_ref().unwrap().regions; editor.set_label("battery".into(), "live".into()).unwrap(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); for ((before, focus), (after, after_focus)) in regions .into_iter() .zip(views.panels.diff.as_ref().unwrap().regions) { assert_eq!(focus, after_focus); if before == Rect::default() { assert_eq!(after, before); } else { assert_eq!( after, Rect::new(before.x, before.y + 1, before.width, before.height - 1) ); } } assert_eq!(views.panels.files.unwrap().0.y, views.rects[0].1.y); let title = pane_screen(&terminal, Rect::new(0, 0, 120, 1)); editor.handle(Input::Char(' ')); editor.handle(Input::Char('o')); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert_eq!(pane_screen(&terminal, Rect::new(0, 0, 120, 1)), title); } #[test] fn tab_switches_restore_independent_pane_viewports_and_retire_only_closed_panes() { let directory = Directory::new(); let text = (0..80) .map(|row| format!("row-{row:02} {}", "long ".repeat(30))) .collect::>() .join("\n"); let mut session = EditorSession::open(Some(directory.file("rows.txt", &text)), vec![], None).unwrap(); let buffer = session.editor().buffer_id(); session.execute("tab.new"); let other = session.editor().pane_id(); session.execute("tab.previous"); session.execute("pane.split_vertical"); let split = session.editor().pane_id(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); let area = views.rects.iter().find(|(id, _)| *id == split).unwrap().1; assert_eq!(area.y, 1); assert!(!views.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, 2, 0) )); for _ in 0..2 { for kind in [MouseEventKind::ScrollDown, MouseEventKind::ScrollRight] { assert!(views.scroll(&mut session, document_wheel(kind, area.x + 2, area.y + 2))); } } terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); let split_offset = views.offsets[&buffer]; assert!(split_offset.0 > 0 && split_offset.1 > 0); session.execute("tab.next"); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert_eq!(views.rects.len(), 1); assert_eq!(views.parked[&split].offsets[&buffer], split_offset); for kind in [MouseEventKind::ScrollDown, MouseEventKind::ScrollRight] { assert!(views.scroll(&mut session, document_wheel(kind, 2, 2))); } terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); let other_offset = views.offsets[&buffer]; assert_ne!(other_offset, split_offset); session.execute("tab.previous"); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert_eq!(session.editor().pane_id(), split); assert_eq!(views.rects.len(), 2); assert_eq!(views.offsets[&buffer], split_offset); assert_eq!(views.parked[&other].offsets[&buffer], other_offset); session.execute("tab.next"); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert_eq!(views.offsets[&buffer], other_offset); session.execute("tab.close"); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert!(!views.parked.contains_key(&other)); assert_eq!(views.offsets[&buffer], split_offset); } #[test] fn hidden_tab_diff_caches_do_not_poll_colors_or_report_stale_viewports() { let directory = Directory::new(); let mut session = EditorSession::open( Some(directory.file("source.txt", "WORKSPACE")), vec![], None, ) .unwrap(); let source = (0..80) .map(|row| format!("old-{row:02} {}", "x ".repeat(30))) .collect::>() .join("\n"); install_readonly_diff( session.editor_mut(), syntax_diff_file("history.txt", &source, &source.replace("old", "new")), ); let owner = session.editor().pane_id(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert!(views.diff_visible); let (id, file, _, _, _) = views.panels.diff.as_ref().unwrap().viewport.unwrap(); session.set_diff_viewport(id, file, 24, 7, 8); session.execute("tab.new"); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert!(!views.diff_visible); assert!(views.parked[&owner].panels.diff.is_some()); assert!(views.rects.iter().all(|(id, _)| *id != owner)); session.execute("tab.previous"); let diff = session.editor().diff().unwrap(); assert_eq!((diff.top, diff.left_before, diff.left_after), (24, 7, 8)); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert!(views.diff_visible); session.execute("diff"); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert!(views.panels.diff.as_ref().unwrap().regions[0].0.y >= 1); } #[test] fn explorer_dock_reserves_space_outside_all_workspace_panes() { let directory = Directory::new(); let mut editor = Editor::open(Some(directory.file("shared.txt", "first\nsecond")), vec![]).unwrap(); editor.set_project_root(directory.0.clone()); editor.handle(Input::Char('|')); editor.handle(Input::Char('_')); editor.handle(Input::Char(' ')); editor.handle(Input::Char('e')); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let dock = views.panels.files.unwrap().0; assert!(views.rects.iter().all(|(_, pane)| pane.right() <= dock.x)); assert_eq!(dock.right(), 120); assert_eq!(dock.y, 0); assert_eq!( Some(dock.bottom()), views.rects.iter().map(|(_, pane)| pane.bottom()).max() ); assert_eq!(dock.height + 1, terminal.backend().buffer().area.height); } #[test] fn native_diff_dock_and_panes_fill_the_workspace_below_only_configured_chrome() { for (width, height) in [(120, 30), (32, 9)] { for chrome in [false, true] { let mut editor = readonly_diff_editor(deletion_file()); configure_chrome(&mut editor, |top, _| top.right = vec!["battery".into()]); if chrome { editor.set_label("battery".into(), "live".into()).unwrap(); } let mut views = BufferViewports::default(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let dock = views.panels.files.unwrap().0; let workspace = views.rects[0].1; let regions = views.panels.diff.as_ref().unwrap().regions; assert_eq!(dock.y, workspace.y); assert_eq!(dock.right(), width); assert_eq!(dock.bottom(), workspace.bottom()); for (pane, _) in regions.into_iter().skip(1) { assert_eq!(pane.y, workspace.y); assert_eq!(pane.bottom(), workspace.bottom()); assert_eq!(pane.y, u16::from(chrome)); } } } } #[test] fn revision_selector_overlays_and_clears_workspace_pane_borders() { let mut editor = Editor::open(None, vec![]).unwrap(); editor.handle(Input::Char('|')); editor.execute("diff"); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert!(screen_text(&terminal).contains("Choose Before")); let results = views.panels.diff.as_ref().unwrap().regions[0].0; assert!(results.x < 60 && results.right() > 60); for x in [59, 60] { assert_eq!(terminal.backend().buffer()[(x, 20)].symbol(), " "); } } #[test] fn diff_floating_picker_overlays_the_whole_workspace() { let directory = Directory::new(); let mut editor = Editor::open(Some(directory.file("source.txt", "workspace")), vec![]).unwrap(); editor.handle(Input::Char('|')); install_readonly_diff(&mut editor, syntax_diff_file("history.txt", "old", "new")); editor.handle(Input::Char(' ')); editor.handle(Input::Char('o')); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let panel = views.panels.files.unwrap().0; assert!(panel.x < 60 && panel.right() > 60); assert!(panel.y >= views.rects[0].1.y); assert!(panel.bottom() <= views.rects[0].1.bottom()); } #[test] fn nested_workspace_panes_share_edits_and_undo_but_not_carets() { let directory = Directory::new(); let mut editor = Editor::open( Some(directory.file("shared.rs", "fn original() {}\nsecond")), vec![], ) .unwrap(); let root = editor.pane_id(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(90, 22)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); editor.handle(Input::Char('|')); let right = editor.pane_id(); editor.handle(Input::Char('_')); let bottom = editor.pane_id(); assert_ne!(root, right); assert_ne!(right, bottom); editor.handle(Input::Right); assert_eq!( editor.with_render_pane(root, |view| view.cursor()), Cursor::default() ); assert_eq!( editor.with_render_pane(right, |view| view.cursor()), Cursor::default() ); assert_eq!(editor.cursor().column, 1); editor.handle(Input::Home); editor.handle(Input::Char('i')); editor.handle(Input::Paste("SHARED ".into())); editor.handle(Input::Escape); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert_eq!(views.rects.len(), 3); for (_, area) in &views.rects { assert!(pane_screen(&terminal, *area).contains("SHARED")); } let focused = views.rects.iter().find(|(id, _)| *id == bottom).unwrap().1; assert!(focused.contains(terminal.get_cursor_position().unwrap())); assert_eq!( terminal.backend().buffer()[(focused.x, focused.y)].fg, Color::Cyan ); assert_eq!(views.syntax.len(), 1); editor.handle(Input::Char('u')); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert!(!screen_text(&terminal).contains("SHARED")); editor.execute("pane.close"); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert_eq!(views.rects.len(), 2); assert!(!views.parked.contains_key(&bottom)); } #[test] fn keyboard_viewport_scroll_preserves_targets_and_reveals_on_motion() { let directory = Directory::new(); let text = (0..80) .map(|row| format!("row-{row:02} {}", "word ".repeat(24))) .collect::>() .join("\n"); for diff in [false, true] { for wrapped in [false, true] { for visual in [false, true] { let mut editor = if diff { readonly_diff_editor(syntax_diff_file( "rows.txt", &text, &text.replace("word", "text"), )) } else { Editor::open(Some(directory.file("rows.txt", &text)), vec![]).unwrap() }; if wrapped { editor.execute("word_wrap.toggle"); } editor .bind("normal", "alt+j", Some("core.scroll_down".into())) .unwrap(); editor .bind("normal", "alt+k", Some("core.scroll_up".into())) .unwrap(); if diff { editor.handle(Input::Char('\t')); } editor.handle(Input::Ctrl('e')); if visual { editor.handle(Input::Char('v')); editor.handle(Input::Right); } let buffer = editor.buffer_id(); let carets = editor.carets(); let selections = editor.selections(); let cursor = editor.cursor(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 14)).unwrap(); let mut views = BufferViewports::default(); let render = |terminal: &mut Terminal, views: &mut BufferViewports, editor: &mut Editor| { terminal.draw(|frame| views.draw(frame, editor)).unwrap(); if diff { let (id, file, top, before, after) = views.panels.diff.as_ref().unwrap().viewport.unwrap(); editor.set_diff_viewport(id, file, top, before, after); } }; let offset = |views: &BufferViewports| { if diff { let top = views.panels.diff_wrap.as_ref().unwrap().top; (top.display, top.continuation) } else { ( views.offsets[&buffer].0, *views.document.continuations.get(&buffer).unwrap_or(&0), ) } }; render(&mut terminal, &mut views, &mut editor); let initial = offset(&views); let height = if diff { views.panels.diff_wrap.as_ref().unwrap().heights[0] } else { usize::from(views.document.geometry.unwrap().1.height) }; editor.handle(Input::Ctrl('d')); render(&mut terminal, &mut views, &mut editor); let scrolled = offset(&views); assert!( scrolled > initial, "diff={diff}, wrap={wrapped}, visual={visual}" ); if !wrapped { assert_eq!(scrolled, (initial.0 + height / 2, 0)); } assert_eq!(editor.carets(), carets); assert_eq!(editor.selections(), selections); render(&mut terminal, &mut views, &mut editor); assert_eq!(offset(&views), scrolled); editor.handle(Input::Ctrl('u')); render(&mut terminal, &mut views, &mut editor); assert_eq!(offset(&views), initial); editor.handle(Input::Ctrl('u')); render(&mut terminal, &mut views, &mut editor); assert_eq!(offset(&views), initial); editor.set_scroll_step_lines(std::num::NonZeroUsize::new(3)); editor.handle(Input::Char('2')); editor.handle(Input::Ctrl('d')); render(&mut terminal, &mut views, &mut editor); if !wrapped { assert_eq!(offset(&views), (initial.0 + 6, 0)); } assert_eq!(editor.carets(), carets); assert_eq!(editor.selections(), selections); editor.handle(Input::Char('2')); editor.handle(Input::Ctrl('u')); render(&mut terminal, &mut views, &mut editor); assert_eq!(offset(&views), initial); if !visual { editor.handle(Input::Char('3')); editor.handle(Input::Alt('j')); render(&mut terminal, &mut views, &mut editor); assert!(offset(&views) > initial); if !wrapped { assert_eq!(offset(&views), (initial.0 + 3, 0)); } editor.handle(Input::Char('3')); editor.handle(Input::Alt('k')); render(&mut terminal, &mut views, &mut editor); assert_eq!(offset(&views), initial); } editor.handle(Input::Ctrl('d')); render(&mut terminal, &mut views, &mut editor); editor.handle(Input::Home); render(&mut terminal, &mut views, &mut editor); assert!(views.panels.caret.is_some()); assert_eq!(editor.cursor().row, cursor.row); } } } } #[test] fn wheel_targets_inactive_workspace_pane_and_split_inherits_offsets() { let directory = Directory::new(); let text = (0..80) .map(|row| format!("row-{row:02} {}", "long ".repeat(30))) .collect::>() .join("\n"); let mut session = EditorSession::open(Some(directory.file("rows.txt", &text)), vec![], None).unwrap(); let root = session.editor().pane_id(); let buffer = session.editor().buffer_id(); for _ in 0..3 { session.handle(Input::Right); } let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 20)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert!(views.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, 2, 1) )); session.handle(Input::Char('|')); let focused = session.editor().pane_id(); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert_eq!(views.offsets[&buffer], views.parked[&root].offsets[&buffer]); let focused_offset = views.offsets[&buffer]; let root_area = views.rects.iter().find(|(id, _)| *id == root).unwrap().1; let cursor = session.editor().cursor(); assert!(views.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, root_area.x + 2, root_area.y + 2) )); assert!(views.scroll( &mut session, document_wheel( MouseEventKind::ScrollRight, root_area.x + 2, root_area.y + 2 ) )); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert_eq!(session.editor().pane_id(), focused); assert_eq!(session.editor().cursor(), cursor); assert_eq!(views.offsets[&buffer], focused_offset); assert!(views.parked[&root].offsets[&buffer].0 > focused_offset.0); assert!(views.parked[&root].offsets[&buffer].1 > focused_offset.1); let inactive_offset = views.parked[&root].offsets[&buffer]; let inactive_cursor = session .editor_mut() .with_render_pane(root, |view| view.cursor()); // Even a motion that leaves the focused caret in place requests reveal. session.handle(Input::Home); session.handle(Input::Home); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert_eq!(views.parked[&root].offsets[&buffer], inactive_offset); session.handle(Input::Down); session.handle(Input::End); session.handle(Input::Char('i')); session.handle(Input::Paste(" typed".into())); session.handle(Input::Escape); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert_eq!( session .editor_mut() .with_render_pane(root, |view| view.cursor()), inactive_cursor ); assert_eq!(views.parked[&root].offsets[&buffer], inactive_offset); session.handle(Input::Up); session.handle(Input::Home); session.handle(Input::Char('i')); session.handle(Input::Paste("PREFIX ".into())); session.handle(Input::Escape); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); let rebased = session .editor_mut() .with_render_pane(root, |view| view.cursor()); assert_eq!(rebased.row, inactive_cursor.row); assert!(rebased.column > inactive_cursor.column); assert_eq!(views.parked[&root].offsets[&buffer], inactive_offset); let focused_after_typing = views.offsets[&buffer]; session.handle(Input::Alt('h')); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert_eq!(session.editor().pane_id(), root); assert!(views.offsets[&buffer].0 > focused_offset.0); assert_eq!( views.parked[&focused].offsets[&buffer], focused_after_typing ); } #[test] fn wrapped_workspace_panes_scroll_independently_and_keep_toggle_anchor() { let directory = Directory::new(); let text = (0..40) .map(|row| format!("row-{row:02} {}", "word ".repeat(35))) .collect::>() .join("\n"); let mut session = EditorSession::open(Some(directory.file("wrap.txt", &text)), vec![], None).unwrap(); session.execute("word_wrap.toggle"); let root = session.editor().pane_id(); let buffer = session.editor().buffer_id(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(70, 24)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert!(views.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, 2, 1) )); session.handle(Input::Char('|')); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); let inherited = ( views.offsets[&buffer].0, views.document.continuations[&buffer], ); assert_eq!( inherited, ( views.parked[&root].offsets[&buffer].0, views.parked[&root].document.continuations[&buffer] ) ); let root_area = views.rects.iter().find(|(id, _)| *id == root).unwrap().1; assert!(views.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, root_area.x + 2, root_area.y + 2) )); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert_eq!( ( views.offsets[&buffer].0, views.document.continuations[&buffer] ), inherited ); assert_ne!( ( views.parked[&root].offsets[&buffer].0, views.parked[&root].document.continuations[&buffer] ), inherited ); session.handle(Input::Right); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); let caret = terminal.get_cursor_position().unwrap(); session.execute("word_wrap.toggle"); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert_eq!(terminal.get_cursor_position().unwrap().y, caret.y); } #[test] fn comparison_owner_coexists_with_workspace_and_inactive_diff_wheel_keeps_focus() { let directory = Directory::new(); let mut session = EditorSession::open( Some(directory.file("source.txt", "WORKSPACE\nsecond")), vec![], None, ) .unwrap(); let source = (0..80) .map(|row| format!("old-{row:02} {}", "x ".repeat(30))) .collect::>() .join("\n"); let after = source.replace("old", "new"); let workspace = session.editor().buffer_id(); install_readonly_diff( session.editor_mut(), syntax_diff_file("history.txt", &source, &after), ); let owner = session.editor().pane_id(); session.execute("pane.split_horizontal"); let focused = session.editor().pane_id(); session.execute(&format!("buffer {workspace}")); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 34)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert!(session.editor().diff().is_none()); assert!(screen_text(&terminal).contains("WORKSPACE")); assert!(screen_text(&terminal).contains("Before")); assert!(screen_text(&terminal).contains("After")); let geometry = views.parked[&owner].panels.diff.as_ref().unwrap(); let before = geometry.regions[1].0; let cursor = session.editor().cursor(); let offsets = views.offsets.clone(); assert!(views.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, before.x + 1, before.y + 1) )); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert_eq!(session.editor().pane_id(), focused); assert_eq!(session.editor().cursor(), cursor); assert_eq!(views.offsets, offsets); assert!( session .editor_mut() .with_render_pane(owner, |view| view.diff().unwrap().top > 0) ); let area = views.rects.iter().find(|(id, _)| *id == focused).unwrap().1; assert!(area.contains(terminal.get_cursor_position().unwrap())); } fn definition_point(row: usize, column: usize) -> crate::plugin::Utf16Range { crate::plugin::Utf16Range { start: (row, column), end: (row, column), } } #[test] fn lsp_definitions_center_in_actual_document_panes_once_and_preserve_dirty_targets() { for (layout, target_kind, row) in [ ("single", "same", 0), ("single", "same", 2), ("single", "new", 50), ("split", "same", 50), ("dock", "dirty", 50), ("single", "same", 99), ] { let directory = Directory::new(); let text = (0..100) .map(|row| format!("definition_{row:03}")) .collect::>() .join("\n"); let source = directory.file("source.txt", &text); let target = if target_kind == "same" { source.clone() } else { directory.file("target.txt", &text) }; let mut editor = Editor::open(Some(source.clone()), vec![]).unwrap(); if target_kind == "dirty" { editor .navigate_to(target.clone(), TextPosition { row: 0, column: 0 }) .unwrap(); editor.handle(Input::Char('i')); editor.handle(Input::Paste("UNSAVED ".into())); editor.handle(Input::Escape); editor .navigate_to(source, TextPosition { row: 0, column: 0 }) .unwrap(); } if layout == "split" { editor.execute("pane.split_horizontal"); } else if layout == "dock" { editor.set_project_root(directory.0.clone()); editor.set_file_explorer_side("bottom").unwrap(); editor.handle(Input::Char(' ')); editor.handle(Input::Char('e')); editor.handle(Input::Escape); } let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); editor .navigate_lsp_to(target.clone(), definition_point(row, 0), None) .unwrap(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let viewport = views.document.geometry.unwrap().1; if layout != "single" { assert!(viewport.height < 22, "{layout}"); } let centered = viewport.y + (row as u16).min(viewport.height / 2); assert_eq!( terminal.get_cursor_position().unwrap().y, centered, "{layout}/{target_kind}/{row}" ); assert_eq!(editor.cursor().row, row); assert_eq!(editor.path(), Some(target.as_path())); if target_kind == "dirty" { assert!(editor.lines()[0].starts_with("UNSAVED ")); assert_eq!(fs::read_to_string(&target).unwrap(), text); } terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert_eq!(terminal.get_cursor_position().unwrap().y, centered); if row == 50 { editor.handle(Input::Down); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert_eq!(terminal.get_cursor_position().unwrap().y, centered + 1); let before = ( views.offsets.clone(), screen_text(&terminal), editor.cursor(), ); for (path, range) in [ (target.clone(), definition_point(1000, 0)), (directory.0.join("missing.txt"), definition_point(0, 0)), ] { assert!(editor.navigate_lsp_to(path, range, None).is_err()); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert_eq!(views.offsets, before.0); assert_eq!(screen_text(&terminal), before.1); assert_eq!(editor.cursor(), before.2); assert_eq!(terminal.get_cursor_position().unwrap().y, centered + 1); } } } } #[test] fn lsp_definition_centers_wrapped_utf16_grapheme_destination_visually() { let directory = Directory::new(); let mut lines = (0..80) .map(|row| format!("row_{row:03}")) .collect::>(); lines[40] = format!("{}😀e\u{301}ZTAIL", "a".repeat(80)); let path = directory.file("wrapped.txt", &lines.join("\n")); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); editor.execute("word_wrap.toggle"); let mut views = BufferViewports::default(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 12)).unwrap(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); editor .navigate_lsp_to(path, definition_point(40, 84), None) .unwrap(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let viewport = views.document.geometry.unwrap().1; let caret = terminal.get_cursor_position().unwrap(); assert_eq!( editor.cursor(), Cursor { row: 40, column: 82 } ); assert_eq!(caret.y, viewport.y + viewport.height / 2); assert_eq!( terminal.backend().buffer()[(caret.x, caret.y)].symbol(), "Z" ); assert_eq!(editor.lines(), lines); } #[test] fn markdown_search_and_plugin_navigation_keep_ordinary_caret_following() { for navigation in ["markdown", "search", "plugin"] { let directory = Directory::new(); let mut lines = (0..80) .map(|row| format!("row_{row:03}")) .collect::>(); lines[0] = "[destination](#destination)".into(); lines[50] = "# Destination".into(); let path = directory.file("links.md", &lines.join("\n")); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); let mut views = BufferViewports::default(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 16)).unwrap(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); match navigation { "markdown" => { editor.execute("follow_link"); } "search" => { editor.handle(Input::Char('/')); for ch in "Destination".chars() { editor.handle(Input::Char(ch)); } editor.handle(Input::Enter); } "plugin" => editor .navigate_to(path, TextPosition { row: 50, column: 0 }) .unwrap(), _ => unreachable!(), } terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let viewport = views.document.geometry.unwrap().1; assert_eq!(editor.cursor().row, 50, "{navigation}"); assert_eq!( terminal.get_cursor_position().unwrap().y, viewport.bottom() - 1, "{navigation}" ); } } #[test] fn lsp_definition_centers_revealed_diff_symbol_in_each_actual_focused_pane() { for (mode, axis, focus) in [ ( ViewMode::Split, crate::panes::SplitAxis::Vertical, Focus::Before, ), ( ViewMode::Split, crate::panes::SplitAxis::Horizontal, Focus::After, ), ( ViewMode::Unified, crate::panes::SplitAxis::Vertical, Focus::After, ), ] { let before = (0..90) .map(|row| format!("line_{row:03}")) .collect::>(); let mut after = before.clone(); for row in 10..31 { after[row] = format!("changed_{row:03}"); } after[70] = "changed_body".into(); let mut file = syntax_diff_file("symbol.txt", &before.join("\n"), &after.join("\n")); file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); let mut editor = Editor::open(None, vec![]).unwrap(); editor.set_diff_display_options(mode, axis); editor.set_diff_context_options(1, 3); install_readonly_diff(&mut editor, file); editor.handle(Input::Char('\t')); if focus == Focus::After { editor.handle(Input::Char('\t')); } let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); let mut views = BufferViewports::default(); render_native_diff(&mut terminal, &mut views, &mut editor); let diff = editor.diff().unwrap(); let comparison = diff.comparison_id; let side = usize::from(focus == Focus::After); let path = diff.snapshot_roots[side] .as_ref() .unwrap() .join("symbol.txt"); assert!(diff.source_display_row(focus, 40).is_none()); editor .navigate_lsp_to( path.clone(), definition_point(40, 2), Some(crate::plugin::Utf16Range { start: (40, 0), end: (71, 0), }), ) .unwrap(); let caret = render_native_diff(&mut terminal, &mut views, &mut editor); let geometry = views.panels.diff.as_ref().unwrap(); let pane = diff_inner(geometry.regions[if focus == Focus::Before { 1 } else { 2 }].0); assert_eq!( caret.y, pane.y + pane.height / 2, "{mode:?}/{axis:?}/{focus:?}" ); assert!(pane.contains(caret)); assert_eq!(editor.cursor(), Cursor { row: 40, column: 2 }); assert_eq!(editor.diff().unwrap().comparison_id, comparison); assert!( editor .diff() .unwrap() .source_display_row(focus, 40) .is_some() ); assert!(editor.mutation_blocked()); editor.handle(Input::Down); assert!(render_native_diff(&mut terminal, &mut views, &mut editor).y > caret.y); editor .navigate_lsp_to(path, definition_point(80, 1), None) .unwrap(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let viewport = views.document.geometry.unwrap().1; assert!(editor.diff().is_none()); assert_eq!(editor.tab_count(), 2); assert_eq!( terminal.get_cursor_position().unwrap().y, viewport.y + viewport.height / 2 ); assert!(editor.mutation_blocked()); } } #[test] fn deferred_lsp_definition_centers_only_successful_current_diff_results() { for outcome in ["success", "stale", "failed"] { let directory = Directory::new(); let before = (0..90) .map(|row| format!("line_{row:03}")) .collect::>(); let mut after = before.clone(); for row in 10..31 { after[row] = format!("changed_{row:03}"); } after[70] = "changed_body".into(); let mut source = syntax_diff_file("source.txt", &before.join("\n"), &after.join("\n")); source.hunks = crate::diff::line_hunks(&source.before, false, &source.after, false); let mut target = syntax_diff_file("target.txt", &before.join("\n"), &after.join("\n")); target.hunks = crate::diff::line_hunks(&target.before, false, &target.after, false); directory.file("target.txt", &after.join("\n")); let mut unloaded = syntax_diff_file("target.txt", "", ""); unloaded.loaded = false; unloaded.before.clear(); unloaded.after.clear(); unloaded.hunks.clear(); let mut editor = Editor::open(None, vec![]).unwrap(); editor.set_diff_context_options(1, 3); editor.execute("diff"); let (id, _) = editor.take_diff_request().unwrap(); editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); editor.handle(Input::Down); editor.handle(Input::Enter); editor.handle(Input::Down); editor.handle(Input::Enter); let (id, _) = editor.take_diff_request().unwrap(); editor.apply_diff_result( id, Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { blame: Default::default(), root: directory.0.clone(), editable_paths: Default::default(), before: "baseline".into(), after: "historic".into(), revisions: ["before-snapshot".into(), "after-snapshot".into()], files: vec![source, unloaded], })), ); editor.handle(Input::Char('\t')); let mut views = BufferViewports::default(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); render_native_diff(&mut terminal, &mut views, &mut editor); let comparison_id = editor.diff().unwrap().comparison_id.unwrap(); editor .navigate_lsp_to( directory.0.join("target.txt"), definition_point(40, 2), Some(crate::plugin::Utf16Range { start: (40, 0), end: (71, 0), }), ) .unwrap(); let (id, _) = editor.take_diff_request().unwrap(); if outcome == "stale" { editor.handle(Input::Right); editor.handle(Input::Left); } let before_caret = render_native_diff(&mut terminal, &mut views, &mut editor); let before_pane = diff_inner(views.panels.diff.as_ref().unwrap().regions[1].0); let before_viewport = views.panels.diff.as_ref().unwrap().viewport; let before_buffer = editor.buffer_id(); let before_source = editor.cursor(); editor.apply_diff_result( id, if outcome == "failed" { Err("definition file unavailable".into()) } else { Ok(crate::diff::Outcome::File { comparison_id, file_index: 1, file: target, }) }, ); let caret = render_native_diff(&mut terminal, &mut views, &mut editor); if outcome == "success" { let pane = diff_inner(views.panels.diff.as_ref().unwrap().regions[1].0); assert_eq!(editor.cursor(), Cursor { row: 40, column: 2 }); assert_eq!(editor.diff().unwrap().file, 1); assert_eq!(caret.y, pane.y + pane.height / 2); editor.handle(Input::Down); assert!(render_native_diff(&mut terminal, &mut views, &mut editor).y > caret.y); } else { assert_eq!(editor.buffer_id(), before_buffer, "{outcome}"); assert_eq!(editor.cursor(), before_source, "{outcome}"); assert_eq!(editor.diff().unwrap().file, 0, "{outcome}"); assert_eq!( views.panels.diff.as_ref().unwrap().viewport, before_viewport, "{outcome}" ); let pane = diff_inner(views.panels.diff.as_ref().unwrap().regions[1].0); assert_eq!( (caret.x - pane.x, caret.y - pane.y), ( before_caret.x - before_pane.x, before_caret.y - before_pane.y ), "{outcome}" ); assert_eq!( render_native_diff(&mut terminal, &mut views, &mut editor), caret ); } } } fn readonly_diff_editor(file: ChangedFile) -> Editor { let mut editor = Editor::open(None, vec![]).unwrap(); install_readonly_diff(&mut editor, file); editor } fn install_readonly_diff(editor: &mut Editor, file: ChangedFile) { editor.set_clipboard_mode("separate").unwrap(); editor.execute("diff"); let (id, _) = editor.take_diff_request().unwrap(); editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); editor.handle(Input::Down); editor.handle(Input::Enter); editor.handle(Input::Down); editor.handle(Input::Enter); let (id, _) = editor.take_diff_request().unwrap(); editor.apply_diff_result( id, Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { blame: std::array::from_fn(|_| { Some(crate::diff::BlameRevision { store: editor.project_root().join(".git"), commit: "0123456789abcdef0123456789abcdef01234567".into(), keepalive: None, }) }), root: editor.project_root().to_path_buf(), editable_paths: Default::default(), before: "baseline".into(), after: "historic".into(), revisions: ["before-snapshot".into(), "after-snapshot".into()], files: vec![file], })), ); } fn render_native_diff( terminal: &mut Terminal, views: &mut BufferViewports, editor: &mut Editor, ) -> Position { terminal.draw(|frame| views.draw(frame, editor)).unwrap(); let (id, file, top, before, after) = views.panels.diff.as_ref().unwrap().viewport.unwrap(); editor.set_diff_viewport(id, file, top, before, after); terminal.get_cursor_position().unwrap() } #[test] fn command_input_keeps_characterwise_and_linewise_selection_highlights() { for context in ["document", "split", "unified"] { for selector in ['v', 'V'] { let mut editor = if context == "document" { let mut editor = Editor::open(None, vec![]).unwrap(); editor .apply(vec![crate::editor::Effect::Replace( "alpha beta\nsecond line".into(), )]) .unwrap(); editor } else { let mut editor = readonly_diff_editor(syntax_diff_file( "file.txt", "alpha beta\nold line", "alpha beta\nnew line", )); if context == "unified" { editor.execute("diff.view.toggle"); } editor.handle(Input::Char('\t')); editor }; let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(140, 24)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let source = terminal.get_cursor_position().unwrap(); editor.handle(Input::Char(selector)); editor.handle(Input::Right); let ranges = editor.selections(); editor.handle(Input::Char(':')); for ch in "diff.comment".chars() { editor.handle(Input::Char(ch)); } terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert_eq!(editor.mode(), Mode::Command); assert_eq!(editor.selections(), ranges); let buffer = terminal.backend().buffer(); assert_eq!( buffer[(source.x, source.y)].bg, Color::Blue, "{context} {selector}" ); assert_eq!( buffer[(source.x + if selector == 'V' { 9 } else { 1 }, source.y)].bg, Color::Blue, "{context} {selector}" ); editor.handle(Input::Escape); assert_eq!(editor.selections(), ranges); } } } #[test] fn native_visual_selection_overrides_selected_review_range_background() { for unified in [false, true] { let mut editor = readonly_diff_editor(syntax_diff_file( "file.txt", "old e\u{301}界 text", "new e\u{301}界 text", )); if unified { editor.execute("diff.view.toggle"); } editor.handle(Input::Char('\t')); editor.execute("diff.comment"); let directory = Directory::new(); editor.execute(&format!( "diff.review.save {}", directory.0.join("review.json").display() )); editor.handle(Input::Escape); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 20)).unwrap(); let mut views = BufferViewports::default(); let source = render_native_diff(&mut terminal, &mut views, &mut editor); assert_eq!( terminal.backend().buffer()[(source.x, source.y)].bg, Color::Magenta ); editor.handle(Input::Char('v')); editor.handle(Input::Right); render_native_diff(&mut terminal, &mut views, &mut editor); let buffer = terminal.backend().buffer(); assert_eq!(buffer[(source.x, source.y)].bg, Color::Blue); assert!(buffer.content.iter().any(|cell| cell.bg == Color::Magenta)); } } #[test] fn review_editor_floats_over_the_diff_without_changing_workspace_layout() { for unified in [false, true] { let mut editor = readonly_diff_editor(syntax_diff_file( "file.txt", "old first\nold last", "new first\nnew last", )); if unified { editor.execute("diff.view.toggle"); } editor.handle(Input::Char('\t')); let owner = editor.pane_id(); let source_text = editor.context().text; let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 30)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let source_area = views .rects .iter() .find(|(pane, _)| *pane == owner) .unwrap() .1; editor.execute("diff.comment"); let panel = editor.review_panel().expect("focused floating review"); assert_eq!(editor.pane_layout().len(), 1); assert!(!editor.pane_layout().contains(panel)); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert_eq!(views.rects[0], (panel, floating_panel(source_area))); assert_eq!( views .rects .iter() .find(|(pane, _)| *pane == owner) .unwrap() .1, source_area ); let area = views.rects[0].1; let cursor = terminal.get_cursor_position().unwrap(); assert!(area.contains(cursor)); for input in [ Input::Char('i'), Input::Paste("Retained draft".into()), Input::Escape, ] { editor.handle(input); } assert_eq!(editor.context().text, "Retained draft"); assert_eq!(editor.review_panel(), Some(panel)); let directory = Directory::new(); editor.execute(&format!( "diff.review.save {}", directory.0.join("review.json").display() )); editor.handle(Input::Escape); assert!(editor.review_panel().is_none()); assert_eq!(editor.pane_id(), owner); assert_eq!(editor.context().text, source_text); editor.execute("diff.review.edit 1"); assert_eq!( editor.context().text, "Retained draft", "{}", editor.message() ); assert_eq!(editor.review_panel(), Some(panel)); editor.execute("tab.new"); editor.execute("tab.previous"); editor.execute("diff.review.edit 1"); assert_eq!(editor.review_panel(), Some(panel)); assert_eq!(editor.context().text, "Retained draft"); editor.execute("pane.close"); assert!(editor.review_panel().is_none()); assert_eq!(editor.pane_layout().len(), 1); } } #[test] fn review_prose_wraps_by_default_without_changing_text_or_source_wrapping() { for unified in [false, true] { let mut editor = readonly_diff_editor(syntax_diff_file("file.txt", "old", "new")); if unified { editor.execute("diff.view.toggle"); } editor.handle(Input::Char('\t')); let text = format!("{}wrapped_tail", "prose ".repeat(20)); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 30)).unwrap(); let mut views = BufferViewports::default(); for command in ["diff.comment", "diff.review.summary"] { editor.execute(command); editor.handle(Input::Char('i')); editor.handle(Input::Paste(text.clone())); editor.handle(Input::Escape); editor.handle(Input::Home); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let screen = screen_text(&terminal); let first = screen .lines() .position(|line| line.contains("prose")) .unwrap(); let tail = screen .lines() .position(|line| line.contains("wrapped_tail")) .expect("comment tail must be visible without horizontal scrolling"); assert!(tail > first, "{command}: {screen}"); assert_eq!(editor.context().text, text); let directory = Directory::new(); editor.execute(&format!( "diff.review.save {}", directory.0.join("review.json").display() )); editor.handle(Input::Escape); assert!(!editor.word_wrap()); } editor.execute("diff.review.overview"); assert!(editor.word_wrap()); editor.handle(Input::Escape); editor.execute("diff.review.edit 1"); editor.execute("word_wrap.toggle"); assert!(!editor.word_wrap()); editor.handle(Input::Escape); editor.execute("diff.review.edit 1"); assert!( !editor.word_wrap(), "reopening must retain the user's toggle" ); assert_eq!(editor.context().text, text); editor.handle(Input::Escape); assert!(!editor.word_wrap()); } } #[test] fn scope_projection_keeps_unified_wrapped_caret_and_selection_source_only() { let before = format!( "fn old_name() {{\n // {}\n}}\nfn second() {{}}\n", "word ".repeat(45) ); let after = before.replace("old_name", "new_name"); for axis in [SplitAxis::Vertical, SplitAxis::Horizontal] { let mut editor = readonly_diff_editor(syntax_diff_file("scope.rs", &before, &after)); if axis == SplitAxis::Horizontal { editor.execute("diff.orientation.toggle"); } editor.handle(Input::Char('\t')); editor.handle(Input::Down); editor.handle(Input::Home); editor.handle(Input::Char('v')); editor.handle(Input::End); let source = editor.cursor(); let selections = editor.selections(); let text = editor.lines().to_vec(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 40)).unwrap(); let mut views = BufferViewports::default(); for command in [ "", "word_wrap.toggle", "diff.view.toggle", "word_wrap.toggle", "diff.view.toggle", ] { if !command.is_empty() { editor.execute(command); } let caret = render_native_diff(&mut terminal, &mut views, &mut editor); assert_eq!(editor.cursor(), source); assert_eq!(editor.selections(), selections); assert_eq!(editor.lines(), text); let geometry = views.panels.diff.as_ref().unwrap(); let diff = editor.diff().unwrap(); let active = usize::from(diff.focus == Focus::After); let area = diff_inner(geometry.regions[active + 1].0); assert!(area.contains(caret)); let rows = &views.panels.diff_wrap.as_ref().unwrap().rows; let caret_row = usize::from(caret.y - area.y); assert_eq!(rows[caret_row].scope, [false; 2]); assert_eq!( diff.display_source_row(diff.focus, rows[caret_row].position.display), Some(source.row) ); for (screen, row) in rows.iter().take(usize::from(area.height)).enumerate() { if row.scope == [false; 2] { continue; } let y = area.y + screen as u16; for x in area.x..area.right() { assert_ne!(terminal.backend().buffer()[(x, y)].bg, Color::Blue); } for x in area.x..area.right() - geometry.text_widths[active] { assert_eq!(terminal.backend().buffer()[(x, y)].symbol(), " "); } } } } } #[test] fn tiny_horizontal_scope_diff_keeps_active_source_visible() { let mut editor = readonly_diff_editor(syntax_diff_file( "small.rs", "fn old_name() {}", "fn new_name() {}", )); editor.execute("diff.orientation.toggle"); editor.handle(Input::Char('\t')); editor.handle(Input::Char('\t')); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 9)).unwrap(); let mut views = BufferViewports::default(); let caret = render_native_diff(&mut terminal, &mut views, &mut editor); let area = diff_inner(views.panels.diff.as_ref().unwrap().regions[2].0); assert!( area.contains(caret), "source caret {caret:?} outside {area:?}" ); assert_eq!( views.panels.diff_wrap.as_ref().unwrap().rows[usize::from(caret.y - area.y)].scope, [false; 2] ); } #[test] fn modified_sequences_navigate_diff_files_and_readonly_panes() { let mut editor = Editor::open(None, vec![]).unwrap(); editor.handle(Input::Char('|')); install_readonly_diff( &mut editor, syntax_diff_file("history.txt", "before", "after"), ); editor .bind("normal", "ctrl+w h", Some("core.pane.focus_left".into())) .unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); let mut views = BufferViewports::default(); render_native_diff(&mut terminal, &mut views, &mut editor); assert_eq!(editor.diff().unwrap().focus, Focus::Files); editor.handle(Input::Ctrl('w')); assert_eq!(editor.sequence_pending(), Some("Ctrl+w")); assert!(editor.diff().unwrap().files_visible); editor.handle(Input::Char('h')); assert_eq!(editor.diff().unwrap().focus, Focus::After); render_native_diff(&mut terminal, &mut views, &mut editor); editor.handle(Input::Ctrl('w')); editor.handle(Input::Char('h')); assert_eq!(editor.diff().unwrap().focus, Focus::Before); render_native_diff(&mut terminal, &mut views, &mut editor); editor.handle(Input::Ctrl('w')); editor.handle(Input::Char('h')); assert_eq!(editor.pane_id(), 1); assert_eq!(editor.sequence_pending(), None); } #[test] fn wrap_toggle_preserves_caret_screen_row_in_normal_buffers() { let directory = Directory::new(); let text = (0..35) .map(|row| format!("line{row} {}", "word ".repeat(15))) .collect::>() .join("\n"); let mut editor = Editor::open(Some(directory.file("anchor.txt", &text)), vec![]).unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 10)).unwrap(); let mut views = BufferViewports::default(); for _ in 0..12 { editor.handle(Input::Down); } terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); for _ in 0..3 { editor.handle(Input::Up); } for _ in 0..30 { editor.handle(Input::Right); } terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let row = terminal.get_cursor_position().unwrap().y; let source = editor.cursor(); for _ in 0..4 { editor.execute("word_wrap.toggle"); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert_eq!(terminal.get_cursor_position().unwrap().y, row); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert_eq!(terminal.get_cursor_position().unwrap().y, row); assert_eq!(editor.cursor(), source); assert_eq!(editor.lines().join("\n"), text); } for _ in 0..35 { editor.handle(Input::Down); } editor.handle(Input::Home); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); editor.execute("word_wrap.toggle"); editor.handle(Input::Right); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let near_eof_row = terminal.get_cursor_position().unwrap().y; let near_eof_source = editor.cursor(); editor.execute("word_wrap.toggle"); for _ in 0..2 { terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert_eq!(terminal.get_cursor_position().unwrap().y, near_eof_row); assert_eq!(editor.cursor(), near_eof_source); } } #[test] fn wrap_toggle_preserves_caret_screen_row_in_diff_buffers() { let before = (0..35) .map(|row| format!("old{row} {}", "word ".repeat(15))) .collect::>() .join("\n"); let after = before.replace("old", "new"); let mut editor = readonly_diff_editor(syntax_diff_file("anchor.txt", &before, &after)); editor.handle(Input::Char('\t')); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap(); let mut views = BufferViewports::default(); for _ in 0..12 { editor.handle(Input::Down); } render_native_diff(&mut terminal, &mut views, &mut editor); for _ in 0..3 { editor.handle(Input::Up); } for _ in 0..30 { editor.handle(Input::Right); } let row = render_native_diff(&mut terminal, &mut views, &mut editor).y; let source = editor.cursor(); for _ in 0..4 { editor.execute("word_wrap.toggle"); assert_eq!( render_native_diff(&mut terminal, &mut views, &mut editor).y, row ); assert_eq!( render_native_diff(&mut terminal, &mut views, &mut editor).y, row ); assert_eq!(editor.cursor(), source); } } #[test] fn horizontal_diff_split_keeps_after_caret_in_smaller_bottom_pane() { let before = (0..35) .map(|row| format!("old{row}")) .collect::>() .join("\n"); let after = before.replace("old", "new"); let mut editor = readonly_diff_editor(syntax_diff_file("axis.txt", &before, &after)); editor.handle(Input::Char('\t')); editor.handle(Input::Char('\t')); for _ in 0..20 { editor.handle(Input::Down); } let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 17)).unwrap(); let mut views = BufferViewports::default(); render_native_diff(&mut terminal, &mut views, &mut editor); editor.execute("diff.orientation.toggle"); let caret = render_native_diff(&mut terminal, &mut views, &mut editor); let geometry = views.panels.diff.as_ref().unwrap(); assert_eq!(geometry.regions[1].0.x, geometry.regions[2].0.x); assert!(geometry.regions[1].0.bottom() <= geometry.regions[2].0.y); assert!(diff_inner(geometry.regions[2].0).contains(caret)); editor.execute("word_wrap.toggle"); let caret = render_native_diff(&mut terminal, &mut views, &mut editor); assert!(diff_inner(views.panels.diff.as_ref().unwrap().regions[2].0).contains(caret)); } #[test] fn unified_diff_renders_ordered_coordinates_and_switches_logical_focus() { let mut file = syntax_diff_file("unified.txt", "same\nold\nend", "same\nnew\nend"); file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); let mut editor = readonly_diff_editor(file); editor.execute("diff.view.toggle"); editor.handle(Input::Char('\t')); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 20)).unwrap(); let mut views = BufferViewports::default(); render_native_diff(&mut terminal, &mut views, &mut editor); let geometry = views.panels.diff.as_ref().unwrap(); assert_eq!(geometry.regions[2].0, Rect::default()); let area = diff_inner(geometry.regions[1].0); let buffer = terminal.backend().buffer(); let lines = (area.y..area.bottom()) .map(|y| { (area.x..area.right()) .map(|x| buffer[(x, y)].symbol()) .collect::() }) .collect::>(); let tokens: Vec> = lines[..4] .iter() .map(|line| line.split_whitespace().collect()) .collect(); assert_eq!( tokens, [ vec!["1", "1", "same"], vec!["2", "-", "old"], vec!["2+", "new"], vec!["3", "3", "end"] ] ); assert!(!lines.iter().any(|line| line.contains('╱'))); editor.handle(Input::Char('\t')); render_native_diff(&mut terminal, &mut views, &mut editor); let geometry = views.panels.diff.as_ref().unwrap(); assert_eq!(geometry.regions[1].0, Rect::default()); assert_eq!(diff_inner(geometry.regions[2].0), area); } #[test] fn unified_wrapping_uses_only_the_display_rows_source_side() { let before = format!("{}\nend", "old ".repeat(40)); let mut file = syntax_diff_file("wrap.txt", &before, "new\nend"); file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false); let mut editor = readonly_diff_editor(file); editor.execute("diff.view.toggle"); editor.execute("word_wrap.toggle"); editor.handle(Input::Char('\t')); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap(); let mut views = BufferViewports::default(); render_native_diff(&mut terminal, &mut views, &mut editor); let wrap = views.panels.diff_wrap.as_ref().unwrap(); let removed = wrap .rows .iter() .filter(|row| row.position.display == 0) .collect::>(); assert!(removed.len() > 1); assert!( removed .iter() .all(|row| row.segments[0].is_some() && row.segments[1].is_none()) ); let added = wrap .rows .iter() .filter(|row| row.position.display == 1) .collect::>(); assert_eq!(added.len(), 1); assert!(added[0].segments[0].is_none()); assert!(added[0].segments[1].is_some()); } #[test] fn wrapped_diff_aligns_continuations_and_preserves_native_selection_and_caret() { let before = format!("// {}BEFORE_END\nsentinel", "old ".repeat(70)); let after = format!("// {}AFTER_END\nsentinel", "new ".repeat(20)); let mut editor = readonly_diff_editor(syntax_diff_file("history.rs", &before, &after)); editor.execute("word_wrap.toggle"); editor.handle(Input::Char('\t')); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(140, 20)).unwrap(); let mut views = BufferViewports::default(); render_native_diff(&mut terminal, &mut views, &mut editor); let geometry = views.panels.diff.as_ref().unwrap(); let before_area = diff_inner(geometry.regions[1].0); let after_area = diff_inner(geometry.regions[2].0); let buffer = terminal.backend().buffer(); let find_sentinel = |area: Rect| { (area.y..area.bottom()) .find(|y| { (area.x..area.right()) .map(|x| buffer[(x, *y)].symbol()) .collect::() .contains("sentinel") }) .unwrap() }; assert_eq!(find_sentinel(before_area), find_sentinel(after_area)); editor.handle(Input::End); let caret = render_native_diff(&mut terminal, &mut views, &mut editor); assert!(before_area.contains(caret)); assert_eq!( terminal.backend().buffer()[(caret.x, caret.y)].symbol(), "D" ); assert_eq!( terminal.backend().buffer()[(caret.x, caret.y)].fg, Color::DarkGray ); editor.handle(Input::Char('v')); editor.handle(Input::Home); render_native_diff(&mut terminal, &mut views, &mut editor); let wrap = views.panels.diff_wrap.as_ref().unwrap(); let continuation = wrap .rows .iter() .position(|row| row.position.display == 0 && row.position.continuation == 1) .unwrap(); let x = before_area.x + (before_area.width - views.panels.diff.as_ref().unwrap().text_widths[0]); assert_eq!( terminal.backend().buffer()[(x, before_area.y + continuation as u16)].bg, Color::Blue ); let selection = editor.selections(); let text = editor.lines().to_vec(); editor.execute("word_wrap.toggle"); render_native_diff(&mut terminal, &mut views, &mut editor); assert!(!editor.word_wrap()); assert!( views .panels .diff_wrap .as_ref() .unwrap() .rows .iter() .all(|row| row.position.continuation == 0) ); assert_eq!(editor.selections(), selection); assert_eq!(editor.lines(), text); assert_eq!(editor.mode(), Mode::Visual); } #[test] fn readonly_diff_panes_render_native_caret_visual_search_and_paired_focus() { let mut editor = readonly_diff_editor(syntax_diff_file( "history.rs", "fn old() {}\nlet value = \"old\";", "fn new() {}\nlet value = \"new\";", )); editor.handle(Input::Char('\t')); assert!(editor.diff_interacting()); assert!(!editor.diff_editing()); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap(); let mut views = BufferViewports::default(); views.set_theme(Some(LIGHT_DIFF_THEME)); let caret = render_native_diff(&mut terminal, &mut views, &mut editor); let before = views.panels.diff.as_ref().unwrap().regions[1].0; assert!(before.contains(caret)); assert_eq!( terminal.backend().buffer()[(caret.x, caret.y)].symbol(), "f" ); assert_eq!( terminal.backend().buffer()[(caret.x, caret.y)].fg, Color::Magenta ); editor.handle(Input::Char('v')); editor.handle(Input::Right); render_native_diff(&mut terminal, &mut views, &mut editor); assert_eq!( terminal.backend().buffer()[(caret.x, caret.y)].bg, Color::Blue ); editor.handle(Input::Escape); editor.handle(Input::Down); editor.handle(Input::Home); editor.handle(Input::Char('V')); render_native_diff(&mut terminal, &mut views, &mut editor); assert_eq!( terminal.backend().buffer()[(caret.x, caret.y + 1)].bg, Color::Blue ); assert_eq!( terminal.backend().buffer()[(caret.x + 16, caret.y + 1)].bg, Color::Blue ); editor.handle(Input::Escape); editor.handle(Input::Char('/')); for ch in "value".chars() { editor.handle(Input::Char(ch)); } render_native_diff(&mut terminal, &mut views, &mut editor); editor.handle(Input::Enter); let searched = render_native_diff(&mut terminal, &mut views, &mut editor); assert!(before.contains(searched)); assert_eq!( terminal.backend().buffer()[(searched.x, searched.y)].bg, Color::Yellow ); assert_eq!( terminal.backend().buffer()[(searched.x, searched.y)].fg, Color::Black ); let before_row = editor .diff() .unwrap() .source_display_row(Focus::Before, editor.cursor().row) .unwrap(); let column = display_column(&editor.lines()[editor.cursor().row], editor.cursor().column); editor.handle(Input::Char('\t')); assert!(editor.diff_interacting()); assert!(!editor.diff_editing()); let switched = render_native_diff(&mut terminal, &mut views, &mut editor); let after = views.panels.diff.as_ref().unwrap().regions[2].0; assert!(after.contains(switched)); assert_eq!(switched.y, searched.y); assert_eq!(switched.x - after.x, searched.x - before.x); assert_eq!( editor .diff() .unwrap() .source_display_row(Focus::After, editor.cursor().row), Some(before_row) ); assert_eq!( display_column(&editor.lines()[editor.cursor().row], editor.cursor().column), column ); editor.handle(Input::Right); let moved = render_native_diff(&mut terminal, &mut views, &mut editor); editor.handle(Input::Char('\t')); editor.handle(Input::Char('\t')); assert_eq!(editor.diff().unwrap().focus, Focus::Before); let returned = render_native_diff(&mut terminal, &mut views, &mut editor); assert!(before.contains(returned)); assert_eq!(returned.y, moved.y); assert_eq!(returned.x - before.x, moved.x - after.x); assert_eq!(returned.x, searched.x + 1); } #[test] fn readonly_diff_viewport_retains_both_offsets_on_direction_and_focus_reversal() { let before = (0..80) .map(|row| format!("old row {row:03} {}", "x".repeat(100))) .collect::>() .join("\n"); let after = before.replace("old row", "new row"); let mut editor = readonly_diff_editor(syntax_diff_file("history.txt", &before, &after)); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap(); let mut views = BufferViewports::default(); let mut retained_before_left = 0; for focus in [Focus::Before, Focus::After] { editor.handle(Input::Char('\t')); assert_eq!(editor.diff().unwrap().focus, focus); assert!(editor.diff_interacting()); assert!(!editor.diff_editing()); render_native_diff(&mut terminal, &mut views, &mut editor); for _ in 0..40 { editor.handle(Input::Down); render_native_diff(&mut terminal, &mut views, &mut editor); } let top = editor.diff().unwrap().top; assert!(top > 0); let bottom = render_native_diff(&mut terminal, &mut views, &mut editor); editor.handle(Input::Up); let reversed = render_native_diff(&mut terminal, &mut views, &mut editor); assert_eq!(editor.diff().unwrap().top, top); assert_eq!(reversed.y + 1, bottom.y); while editor .diff() .unwrap() .source_display_row(focus, editor.cursor().row) .unwrap() > top { editor.handle(Input::Up); render_native_diff(&mut terminal, &mut views, &mut editor); assert_eq!(editor.diff().unwrap().top, top); } editor.handle(Input::Up); render_native_diff(&mut terminal, &mut views, &mut editor); assert!(editor.diff().unwrap().top < top); for _ in 0..80 { editor.handle(Input::Right); render_native_diff(&mut terminal, &mut views, &mut editor); } let left = if focus == Focus::Before { editor.diff().unwrap().left_before } else { editor.diff().unwrap().left_after }; assert!(left > 0); let right = render_native_diff(&mut terminal, &mut views, &mut editor); editor.handle(Input::Left); let reversed = render_native_diff(&mut terminal, &mut views, &mut editor); let offsets = views.panels.diff.as_ref().unwrap().viewport.unwrap(); assert_eq!( if focus == Focus::Before { offsets.3 } else { offsets.4 }, left ); assert_eq!(reversed.x + 1, right.x); while editor.cursor().column > left { editor.handle(Input::Left); render_native_diff(&mut terminal, &mut views, &mut editor); let diff = editor.diff().unwrap(); assert_eq!( if focus == Focus::Before { diff.left_before } else { diff.left_after }, left ); } editor.handle(Input::Left); render_native_diff(&mut terminal, &mut views, &mut editor); if focus == Focus::Before { retained_before_left = editor.diff().unwrap().left_before; assert!(retained_before_left < left); } else { assert!(editor.diff().unwrap().left_after < left); assert_eq!(editor.diff().unwrap().left_before, retained_before_left); } } } #[test] fn diff_semantic_colors_stay_buffer_local_across_focus_changes() { for writable in [false, true] { let directory = Directory::new(); let path = directory.file("live.rs", "let value = 2;"); let mut editor = Editor::open(Some(path), vec![]).unwrap(); editor.execute("diff"); let (id, _) = editor.take_diff_request().unwrap(); editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); editor.handle(Input::Down); 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 { blame: Default::default(), root: directory.0.clone(), editable_paths: if writable { [std::path::PathBuf::from("live.rs")].into() } else { Default::default() }, before: "baseline".into(), after: "Working checkout".into(), revisions: ["before-snapshot".into(), "after-snapshot".into()], files: vec![syntax_diff_file( "live.rs", "let value = 1;", "let value = 2;", )], })), ); editor.handle(Input::Char('\t')); assert_eq!(editor.diff_buffer_focus(), Some(Focus::Before)); editor .replace_semantic_tokens( editor.buffer_id(), editor.text_revision(), Some(Arc::from([semantic_token( 0, 4, 9, SemanticTokenKind::Parameter, 0, )])), ) .unwrap(); editor.handle(Input::Char('\t')); editor .replace_semantic_tokens( editor.buffer_id(), editor.text_revision(), Some(Arc::from([semantic_token( 0, 4, 9, SemanticTokenKind::Function, SemanticTokenModifier::Declaration as u16, )])), ) .unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap(); let mut views = BufferViewports::default(); views.set_theme(Some(LIGHT_DIFF_THEME)); for _ in 0..4 { terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let regions = views.panels.diff.as_ref().unwrap().regions; for (index, color, bold) in [(1, Color::LightCyan, false), (2, Color::LightBlue, true)] { let pane = regions[index].0; let cell = &terminal.backend().buffer()[(pane.x + 8, pane.y + 1)]; assert_eq!(cell.symbol(), "v"); assert_eq!(cell.fg, color); assert_eq!(cell.modifier.contains(ratatui::style::Modifier::BOLD), bold); } editor.handle(Input::Char('\t')); } if writable { while editor.diff_buffer_focus() != Some(Focus::After) || editor.diff().unwrap().focus != Focus::After { editor.handle(Input::Char('\t')); } assert!(editor.diff_editing()); editor.handle(Input::Char('A')); assert_eq!(editor.mode(), Mode::Insert); for input in " // typing".chars().map(Input::Char).chain([ Input::Escape, Input::Char('u'), Input::Char('U'), ]) { editor.handle(input.clone()); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let regions = views.panels.diff.as_ref().unwrap().regions; for (index, color) in [(1, Color::LightCyan), (2, Color::LightBlue)] { let pane = regions[index].0; let cell = &terminal.backend().buffer()[(pane.x + 8, pane.y + 1)]; assert_eq!(cell.symbol(), "v"); assert_eq!(cell.fg, color, "semantic color flashed after {input:?}"); } } assert_eq!(editor.lines(), ["let value = 2; // typing"]); } editor .replace_semantic_tokens(editor.buffer_id(), editor.text_revision(), None) .unwrap(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let regions = views.panels.diff.as_ref().unwrap().regions; let before = regions[1].0; let after = regions[2].0; assert_eq!( terminal.backend().buffer()[(before.x + 8, before.y + 1)].fg, Color::LightCyan ); let value = &terminal.backend().buffer()[(after.x + 8, after.y + 1)]; assert_eq!(value.fg, Color::Reset); assert!(!value.modifier.contains(ratatui::style::Modifier::BOLD)); assert_eq!( terminal.backend().buffer()[(after.x + 4, after.y + 1)].fg, Color::Magenta ); } } #[test] fn editable_after_renders_live_syntax_caret_visual_selection_and_command_prompt() { let directory = Directory::new(); let path = directory.file("live.rs", "let value = \"new\";"); let mut editor = Editor::open(Some(path), vec![]).unwrap(); editor.set_clipboard_mode("separate").unwrap(); editor.execute("diff"); let (id, _) = editor.take_diff_request().unwrap(); editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); editor.handle(Input::Down); 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 { blame: Default::default(), root: directory.0.clone(), editable_paths: [std::path::PathBuf::from("live.rs")].into(), before: "baseline".into(), after: "Working checkout".into(), revisions: ["before-snapshot".into(), "after-snapshot".into()], files: vec![syntax_diff_file( "live.rs", "let value = \"old\";", "let value = \"new\";", )], })), ); editor.handle(Input::Char('\t')); editor.handle(Input::Char('\t')); assert!(editor.diff_editing()); editor.handle(Input::Char('c')); editor.handle(Input::Char('c')); editor.handle(Input::Paste("// live comment".into())); editor.handle(Input::Escape); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let after = views.panels.diff.as_ref().unwrap().regions[2].0; let caret = terminal.get_cursor_position().unwrap(); assert!(after.contains(caret)); let text_x = after.x + 4; assert_eq!(terminal.backend().buffer()[(text_x, caret.y)].symbol(), "/"); assert_eq!( terminal.backend().buffer()[(text_x, caret.y)].fg, Color::DarkGray ); editor.handle(Input::Home); editor.handle(Input::Char('v')); editor.handle(Input::Right); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert_eq!( terminal.backend().buffer()[(text_x, caret.y)].bg, Color::Blue ); editor.handle(Input::Escape); editor.handle(Input::Char(':')); for ch in "diff.close".chars() { editor.handle(Input::Char(ch)); } terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); editor.handle(Input::Enter); assert!(editor.diff().is_none()); assert_eq!(editor.lines(), ["// live comment"]); assert!(editor.dirty()); } #[test] fn session_diff_draw_retains_rendered_offsets_until_caret_crosses_edges() { let directory = Directory::new(); let original = (0..80) .map(|row| format!("old row {row:03} {}", "x".repeat(100))) .collect::>() .join("\n"); let path = directory.file("source.txt", &original); 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"]); git(&["add", "."]); git(&["commit", "-qm", "base"]); directory.file("source.txt", &original.replace("old row", "new row")); let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); 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); session.handle(Input::Char('\t')); session.handle(Input::Char('\t')); assert!(session.editor().diff_editing()); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap(); let mut views = BufferViewports::default(); let mut render = |session: &mut EditorSession| { terminal .draw(|frame| views.draw_session(frame, session)) .unwrap(); terminal.get_cursor_position().unwrap() }; render(&mut session); for _ in 0..40 { session.handle(Input::Down); render(&mut session); } let top = session.editor().diff().unwrap().top; assert!(top > 0); let bottom_caret = render(&mut session); session.handle(Input::Up); let reversed_caret = render(&mut session); assert_eq!(session.editor().diff().unwrap().top, top); assert_eq!(reversed_caret.y + 1, bottom_caret.y); while session .editor() .diff() .unwrap() .after_display_row(session.editor().cursor().row) .unwrap() > top { session.handle(Input::Up); render(&mut session); assert_eq!(session.editor().diff().unwrap().top, top); } session.handle(Input::Up); render(&mut session); assert!(session.editor().diff().unwrap().top < top); for _ in 0..80 { session.handle(Input::Right); render(&mut session); } let left = session.editor().diff().unwrap().left_after; assert!(left > 0); let right_caret = render(&mut session); session.handle(Input::Left); let reversed_caret = render(&mut session); assert_eq!(session.editor().diff().unwrap().left_after, left); assert_eq!(reversed_caret.x + 1, right_caret.x); while session.editor().cursor().column > left { session.handle(Input::Left); render(&mut session); assert_eq!(session.editor().diff().unwrap().left_after, left); } session.handle(Input::Left); render(&mut session); assert!(session.editor().diff().unwrap().left_after < left); } #[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"), &format!("{}\nsecond\nthird\n", "before.".repeat(30)), ); } git(&["add", "."]); git(&["commit", "-qm", "base"]); git(&["commit", "--allow-empty", "-qm", "unmatched checkout"]); for index in 0..8 { directory.file( &format!("file-{index}.txt"), &format!("{}\nsecond\nthird\n", "after.".repeat(30)), ); } 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::Enter); session.handle(Input::Down); session.handle(Input::Down); 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_mut())) .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; let initial_focus = session.editor().diff().unwrap().focus; let initial_cursor = session.editor().cursor(); assert!(viewports.scroll( &mut session, document_wheel(MouseEventKind::ScrollRight, before.x + 1, before.y + 1) )); terminal .draw(|frame| viewports.draw_session(frame, &mut session)) .unwrap(); assert!(screen_text(&terminal).contains("ore.before.")); assert_eq!(session.editor().diff().unwrap().left_before, 3); assert_eq!(session.editor().diff().unwrap().left_after, 3); let mut shift = document_wheel(MouseEventKind::ScrollDown, after.x + 1, after.y + 1); shift.modifiers = KeyModifiers::SHIFT; assert!(viewports.scroll(&mut session, shift)); terminal .draw(|frame| viewports.draw_session(frame, &mut session)) .unwrap(); assert!(screen_text(&terminal).contains("after.after.")); assert_eq!(session.editor().diff().unwrap().left_before, 6); assert_eq!(session.editor().diff().unwrap().left_after, 6); assert_eq!(session.editor().diff().unwrap().top, 0); assert_eq!(session.editor().diff().unwrap().focus, initial_focus); assert_eq!(session.editor().cursor(), initial_cursor); 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); ready(&mut session, Phase::View); terminal .draw(|frame| viewports.draw_session(frame, &mut session)) .unwrap(); 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::Char(' ')); session.handle(Input::Char('e')); session.handle(Input::Ctrl('w')); terminal .draw(|frame| viewports.draw(frame, session.editor_mut())) .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!(viewports.document.geometry.is_none()); session.execute("diff.close"); assert_eq!(session.editor().lines(), original); assert_eq!(session.editor().cursor(), cursor); } #[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_mut())) .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_mut())) .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.execute("diff.close"); terminal .draw(|frame| viewports.draw(frame, session.editor_mut())) .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, &mut WheelPanels::default(), ); }) .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, &mut WheelPanels::default(), ) .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.set_gutter_style(crate::gutter::GutterStyle::None); 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, &mut 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, &mut 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..60) .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, 48)).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-36")); assert!(!screen_text(&terminal).contains("detail-37")); 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(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(6, 1)).unwrap(); let mut syntax = SyntaxCache::new(); 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(); let document_cursor = terminal.get_cursor_position().unwrap(); editor.show_hover("x界e\u{301}👩‍🚀z\nlast".into()).unwrap(); for _ in 0..2 { editor.handle(Input::Right); } let mut panels = WheelPanels::default(); let rendered = terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut panels, ) }) .unwrap(); let origin = panels.hover.unwrap().0; assert_eq!(rendered.buffer[(origin.x, origin.y)].symbol(), "e\u{301}"); assert_eq!(rendered.buffer[(origin.x + 1, origin.y)].symbol(), "👩‍🚀"); assert_eq!(rendered.buffer[(origin.x + 3, origin.y)].symbol(), "z"); assert_eq!(terminal.get_cursor_position().unwrap(), document_cursor); 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).trim().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.set_gutter_style(crate::gutter::GutterStyle::None); 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); } if editor.notification_visible() && editor.diagnostic_at_cursor().is_some() { editor.handle(Input::Escape); } 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.set_gutter_style(crate::gutter::GutterStyle::None); 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, &mut 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, &mut 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, &mut 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, &mut 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(); session .editor_mut() .set_gutter_style(crate::gutter::GutterStyle::None); 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_mut())) .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_mut())) .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_mut())) .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); if session.editor().notification_visible() && !session.editor().message().is_empty() { session.handle(Input::Escape); terminal .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); } let text_viewport = viewports.panels.caret.unwrap().0; let offsets = viewports.offsets[&target_id]; assert_eq!( offsets.0, session.editor().cursor().row + 1 - usize::from(text_viewport.height) ); assert!(offsets.1 > 0); assert!(text_viewport.contains(terminal.get_cursor_position().unwrap())); 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_gutter_style(crate::gutter::GutterStyle::None); 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, &mut 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, &mut 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, &mut 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, &mut 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, &mut 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, &mut editor)) .unwrap(); let (viewport, caret) = viewports.panels.caret.unwrap(); assert!(viewports.offsets[&editor.buffer_id()].0 > 0); assert!(viewport.contains(caret)); assert_eq!( terminal.backend().buffer()[(caret.x, caret.y)].symbol(), "v" ); assert_eq!( terminal.backend().buffer()[(caret.x, caret.y)].fg, color, "{name}" ); editor .apply(vec![crate::editor::Effect::Replace(after.into())]) .unwrap(); terminal .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!( terminal.backend().buffer()[(caret.x, caret.y)].symbol(), "v" ); assert_eq!( terminal.backend().buffer()[(caret.x, caret.y)].fg, Color::Reset, "{name}" ); editor.handle(Input::Char('u')); terminal .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!( terminal.backend().buffer()[(caret.x, caret.y)].fg, color, "{name}" ); } } #[test] fn semantic_roles_remain_distinct_when_values_and_fields_are_readonly() { let mut editor = Editor::open(None, vec![]).unwrap(); editor.set_gutter_style(crate::gutter::GutterStyle::None); 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, &mut 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_gutter_style(crate::gutter::GutterStyle::None); 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); } if editor.notification_visible() && editor.diagnostic_at_cursor().is_some() { editor.handle(Input::Escape); } 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 .replace_semantic_tokens(editor.buffer_id(), editor.text_revision(), None) .unwrap(); 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.set_gutter_style(crate::gutter::GutterStyle::None); 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, &mut 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, &mut 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(); editor.set_gutter_style(crate::gutter::GutterStyle::None); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap(); let mut viewports = BufferViewports::default(); terminal .draw(|frame| viewports.draw(frame, &mut 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, &mut 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(); editor.set_gutter_style(crate::gutter::GutterStyle::None); 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, &mut 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, &mut 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, &mut editor)) .unwrap(); assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Green); editor.execute(&format!("buffer {a_id}")); terminal .draw(|frame| viewports.draw(frame, &mut 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, &mut 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 diff_control_i_restores_jump_location_without_cycling_panes() { for unified in [false, true] { for focus in [Focus::Before, Focus::After] { for code in [KeyCode::Char('i'), KeyCode::Tab] { let mut editor = readonly_diff_editor(syntax_diff_file( "jump.txt", "old\nmiddle\ntarget", "new\nmiddle\ntarget", )); if unified { editor.execute("diff.view.toggle"); } for _ in 0..3 { if editor.diff().unwrap().focus == focus { break; } editor.handle(Input::Char('\t')); } assert_eq!(editor.diff().unwrap().focus, focus); let buffer = editor.buffer_id(); for ch in "/target".chars() { editor.handle(Input::Char(ch)); } editor.handle(Input::Enter); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); editor.handle(Input::Ctrl('o')); assert_eq!(editor.cursor(), Cursor::default()); editor.handle(translate(KeyEvent::new(code, KeyModifiers::CONTROL)).unwrap()); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); assert_eq!(editor.diff().unwrap().focus, focus); assert_eq!(editor.buffer_id(), buffer); assert!(!editor.dirty()); editor.handle(translate(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)).unwrap()); assert_ne!(editor.diff().unwrap().focus, focus); for _ in 0..3 { if editor.diff().unwrap().focus == Focus::Files { break; } editor.handle(Input::Char('\t')); } assert_eq!(editor.diff().unwrap().focus, Focus::Files); editor.handle(Input::Ctrl('o')); assert_eq!(editor.cursor(), Cursor::default()); assert_eq!(editor.diff().unwrap().focus, focus); for _ in 0..3 { if editor.diff().unwrap().focus == Focus::Files { break; } editor.handle(Input::Char('\t')); } assert_eq!(editor.diff().unwrap().focus, Focus::Files); editor.handle(translate(KeyEvent::new(code, KeyModifiers::CONTROL)).unwrap()); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); assert_eq!(editor.diff().unwrap().focus, Focus::Files); } } } } #[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, Some(Input::Alt('j'))), ( KeyCode::Char('H'), KeyModifiers::ALT | KeyModifiers::SHIFT, Some(Input::Alt('h')), ), ( KeyCode::Char('j'), KeyModifiers::ALT | KeyModifiers::CONTROL, Some(Input::CtrlAlt('j')), ), ( KeyCode::Char('H'), KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT, Some(Input::CtrlAlt('h')), ), ( KeyCode::Char('L'), KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT, Some(Input::CtrlAlt('l')), ), ( KeyCode::Char('j'), KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER, None, ), ( KeyCode::Left, KeyModifiers::CONTROL | KeyModifiers::ALT, None, ), (KeyCode::Char('j'), KeyModifiers::SUPER, None), (KeyCode::Left, KeyModifiers::ALT, None), ] { assert_eq!(translate(KeyEvent::new(code, modifiers)), expected); } } #[cfg(target_os = "macos")] #[test] fn macos_modified_backspace_edits_words_and_line_prefix_from_terminal_events() { let directory = Directory::new(); let path = directory.file("input.txt", "before\nalpha βeta SUFFIX\nlast"); let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); session.handle(Input::Down); session.handle(Input::Home); session.handle(Input::Char('i')); for _ in 0..13 { session.handle(Input::Right); } session.handle(translate(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT)).unwrap()); assert_eq!(session.editor().lines(), ["before", "alpha SUFFIX", "last"]); session.handle(translate(KeyEvent::new(KeyCode::Backspace, KeyModifiers::SUPER)).unwrap()); assert_eq!(session.editor().lines(), ["before", "SUFFIX", "last"]); for modifiers in [ KeyModifiers::META, KeyModifiers::HYPER, KeyModifiers::ALT | KeyModifiers::CONTROL, KeyModifiers::ALT | KeyModifiers::SHIFT, KeyModifiers::SUPER | KeyModifiers::CONTROL, KeyModifiers::SUPER | KeyModifiers::SHIFT, KeyModifiers::SUPER | KeyModifiers::ALT, KeyModifiers::SUPER | KeyModifiers::META, ] { assert_eq!( translate(KeyEvent::new(KeyCode::Backspace, modifiers)), None ); } assert_eq!( translate(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SUPER)), None ); } #[cfg(not(target_os = "macos"))] #[test] fn modified_backspace_keeps_non_macos_terminal_defaults() { for modifiers in [KeyModifiers::ALT, KeyModifiers::SUPER] { assert_eq!( translate(KeyEvent::new(KeyCode::Backspace, modifiers)), None ); } assert_eq!( translate(KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL)), Some(Input::Backspace) ); } #[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 ); terminal .draw(|frame| { draw_file_list( frame, session.editor().file_picker().unwrap(), area, "", &mut panels, None, ) }) .unwrap(); assert_eq!(panels.files.unwrap().2, entry_count - height); panels.follow_files(); 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 picker_viewport_preserves_direction_and_uses_visual_row_margins() { for row_height in [1, 2] { let mut viewport = None; let height = 4 * row_height; for selected in 0..8 { PickerViewport::follow(&mut viewport, 1, selected, height, row_height, Some(0)); } for selected in (4..8).rev() { assert_eq!( PickerViewport::follow(&mut viewport, 1, selected, height, row_height, Some(0)), 4, ); } assert_eq!( PickerViewport::follow(&mut viewport, 1, 3, height, row_height, Some(0)), 3, ); assert_eq!( PickerViewport::follow(&mut viewport, 2, 3, height, row_height, Some(0)), 0, ); for distance in [None, Some(usize::MAX)] { assert_eq!( PickerViewport::follow(&mut viewport, 2, 7, height, row_height, distance), 5, ); assert_eq!( PickerViewport::follow(&mut viewport, 2, 0, height, row_height, distance), 0, ); } } let mut viewport = None; assert_eq!( PickerViewport::follow(&mut viewport, 1, 7, 8, 2, Some(1)), 5 ); assert_eq!( PickerViewport::follow(&mut viewport, 1, 6, 8, 2, Some(1)), 5 ); assert_eq!( PickerViewport::follow(&mut viewport, 1, 7, 8, 2, Some(3)), 5 ); assert_eq!(PickerViewport::follow(&mut viewport, 1, 7, 7, 2, None), 6); assert_eq!(PickerViewport::follow(&mut viewport, 1, 7, 3, 1, None), 6); assert_eq!(PickerViewport::follow(&mut viewport, 1, 7, 0, 1, None), 6); } #[test] fn file_viewport_keeps_manual_wheel_until_selection_or_catalog_changes() { 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::List, ) .unwrap(); let mut panels = WheelPanels::default(); let area = Rect::new(0, 0, 40, 8); picker.selected = 7; assert_eq!(panels.file_top(&picker, area, 5), 3); picker.selected = 6; assert_eq!(panels.file_top(&picker, area, 5), 3); panels.picker_scroll.distance = None; panels.manual_files = Some(FileWheelOffset { top: 0, root: picker.root.clone(), query: picker.query.clone(), state: FileWheelOffset::state(&picker), }); picker.focused = !picker.focused; assert_eq!(panels.file_top(&picker, area, 3), 0); assert!(panels.manual_files.is_some()); picker.selected = 7; assert_eq!(panels.file_top(&picker, area, 5), 5); assert!(panels.manual_files.is_none()); panels.manual_files = Some(FileWheelOffset { top: 0, root: picker.root.clone(), query: picker.query.clone(), state: FileWheelOffset::state(&picker), }); std::fs::rename(directory.0.join("file-00"), directory.0.join("new-file")).unwrap(); picker = FilePicker::scan( directory.0.clone(), FilePresentation::Docked, FileView::List, ) .unwrap(); picker.selected = 7; assert_eq!(picker.entries().len(), 12); assert_eq!(panels.file_top(&picker, area, 5), 5); assert!(panels.manual_files.is_none()); panels.begin_frame(); panels.begin_frame(); assert!(panels.picker_scroll.files.is_none()); } #[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.lines() .any(|row| row.contains(label) && row.contains('>')), "{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 explorer_wraps_only_root_path_and_reserves_file_rows_in_short_panes() { let root = "/a/very/long/path/to/project/root"; let picker = FilePicker::from_paths(root.into(), [(0, "entry.txt".into())]); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 12)).unwrap(); let mut panels = WheelPanels::default(); terminal .draw(|frame| draw_file_list(frame, &picker, frame.area(), "", &mut panels, None)) .unwrap(); let buffer = terminal.backend().buffer(); let path = (2..4) .map(|y| (1..19).map(|x| buffer[(x, y)].symbol()).collect::()) .collect::(); assert_eq!(path.trim_end(), root); assert!( screen_text(&terminal) .lines() .nth(4) .unwrap() .contains("entry.txt") ); terminal.backend_mut().resize(20, 6); terminal .draw(|frame| draw_file_list(frame, &picker, frame.area(), "", &mut panels, None)) .unwrap(); assert!(screen_text(&terminal).contains("entry.txt")); } #[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(); let selected_x = (area.x + 2..area.right()) .find(|&x| buffer[(x, selected_y)].symbol() == "界") .unwrap(); assert_eq!(buffer[(selected_x + 2, 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", " 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())); } const PANE_CLOSE_CONFIG: &str = r#" Vamp.configure do |config| config.file_explorer_view = "list" config.file_explorer_side = "right" config.file_picker_view = "tree" config.bind "ctrl+o", config.core.file_picker config.bind "ctrl+x", config.core.pane.close config.bind "ctrl+y", config.core.pane.force_close end "#; #[test] fn bound_pane_close_reclaims_focused_dock_without_losing_dirty_source_or_diff() { let directory = Directory::new(); let path = directory.file("source.txt", "SOURCE\nsecond"); let config = directory.file("config.rb", PANE_CLOSE_CONFIG); for comparison in [false, true] { for key in ['x', 'y'] { let mut session = EditorSession::open(Some(path.clone()), vec![], Some(&config)).unwrap(); session.editor_mut().set_project_root(directory.0.clone()); let source = session.editor().buffer_id(); session.handle(Input::Char('i')); session.handle(Input::Paste("DIRTY ".into())); session.handle(Input::Escape); let text = session.editor().lines().to_vec(); if comparison { install_readonly_diff( session.editor_mut(), syntax_diff_file("history.txt", "OLD", "NEW"), ); } else { session.handle(Input::Char(' ')); session.handle(Input::Char('e')); } let pane = session.editor().pane_id(); let tab = session.editor().tab_id(); let buffers = session.editor().buffer_count(); let comparison_id = session.editor().diff().and_then(|diff| diff.comparison_id); let picker = session.editor().file_picker().unwrap(); assert!(picker.focused); assert_eq!(picker.presentation, FilePresentation::Docked); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 20)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); let dock = views.panels.files.unwrap().0; let workspace = views.rects[0].1; let regions = views.panels.diff.as_ref().map(|geometry| geometry.regions); assert_eq!(workspace.width + dock.width, 120); assert!(dock.contains(terminal.get_cursor_position().unwrap())); session.handle( translate(KeyEvent::new(KeyCode::Char(key), KeyModifiers::CONTROL)).unwrap(), ); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert!(!session.editor().should_quit(), "{comparison} Ctrl+{key}"); assert_eq!(session.editor().pane_id(), pane); assert_eq!(session.editor().tab_id(), tab); assert_eq!(session.editor().pane_layout().len(), 1); assert_eq!(session.editor().tab_count(), 1); assert_eq!(session.editor().buffer_count(), buffers); assert!(session.editor().file_picker().is_none()); assert!(views.panels.files.is_none()); assert_eq!(views.rects[0].1.width, 120); assert!(!screen_text(&terminal).contains("Explorer")); assert!( session .editor() .buffers() .find(|buffer| buffer.id == source) .unwrap() .dirty ); assert_eq!( session .editor() .documents() .find(|document| document.id == source) .unwrap() .lines, text ); let (caret_area, caret) = views.panels.caret.unwrap(); assert!(caret_area.contains(caret)); assert_eq!(terminal.get_cursor_position().unwrap(), caret); if comparison { let diff = session.editor().diff().unwrap(); assert_eq!(diff.comparison_id, comparison_id); assert!(!diff.files_visible); assert!(!diff.files.as_ref().unwrap().focused); let expanded = views.panels.diff.as_ref().unwrap().regions; assert_eq!(expanded[0].0, Rect::default()); for index in [1, 2] { assert!(expanded[index].0.width > regions.unwrap()[index].0.width); assert_eq!( terminal.backend().buffer()[(expanded[index].0.x, expanded[index].0.y)] .symbol(), "┌" ); } let screen = screen_text(&terminal); assert!(screen.contains("Before") && screen.contains("After")); assert!(screen.contains("OLD") && screen.contains("NEW")); session.execute("diff.close"); } assert_eq!(session.editor().buffer_id(), source); assert_eq!(session.editor().lines(), text); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), ["SOURCE", "second"]); assert!(!session.editor().dirty()); } } } #[test] fn bound_pane_close_with_unfocused_dock_removes_editor_leaf_not_the_list() { let directory = Directory::new(); let path = directory.file("source.txt", "SOURCE"); let config = directory.file("config.rb", PANE_CLOSE_CONFIG); for comparison in [false, true] { let mut session = EditorSession::open(Some(path.clone()), vec![], Some(&config)).unwrap(); session.editor_mut().set_project_root(directory.0.clone()); let root = session.editor().pane_id(); let source = session.editor().buffer_id(); session.handle(Input::Char('i')); session.handle(Input::Paste("DIRTY ".into())); session.handle(Input::Escape); session.execute("pane.split_vertical"); let closing = session.editor().pane_id(); if comparison { install_readonly_diff( session.editor_mut(), syntax_diff_file("history.txt", "OLD", "NEW"), ); } else { session.handle(Input::Char(' ')); session.handle(Input::Char('e')); } session.handle(Input::Char('\t')); assert!(!session.editor().file_picker().unwrap().focused); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 20)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert_eq!(views.rects.len(), 2); let dock = views.panels.files.unwrap().0; session.handle(Input::Ctrl('x')); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert!(!session.editor().should_quit()); assert_eq!(session.editor().pane_id(), root); assert!(!session.editor().pane_layout().contains(closing)); assert_eq!(views.rects.len(), 1); assert_eq!(session.editor().buffer_id(), source); assert_eq!(session.editor().lines(), ["DIRTY SOURCE"]); assert!(session.editor().dirty()); assert!(session.editor().diff().is_none()); assert!(!screen_text(&terminal).contains("Before")); if comparison { assert!(views.panels.files.is_none()); assert_eq!(views.rects[0].1.width, 120); } else { assert!(!session.editor().file_picker().unwrap().focused); assert_eq!(views.panels.files.unwrap().0, dock); assert_eq!(views.rects[0].1.width + dock.width, 120); assert!(screen_text(&terminal).contains("Explorer")); } assert!( views.rects[0] .1 .contains(terminal.get_cursor_position().unwrap()) ); } } #[test] fn bound_pane_close_under_floating_picker_obeys_dirty_guard_force_and_escape() { let directory = Directory::new(); let source = directory.file("source.txt", "SURVIVING DOCUMENT"); let owner = directory.file("owner.txt", "POPUP OWNER"); let config = directory.file("config.rb", PANE_CLOSE_CONFIG); for comparison in [false, true] { for dirty in [false, true] { let mut session = EditorSession::open(Some(source.clone()), vec![], Some(&config)).unwrap(); session.editor_mut().set_project_root(directory.0.clone()); let root = session.editor().pane_id(); let source_buffer = session.editor().buffer_id(); session.execute("tab.new"); session.execute(&format!("edit {}", owner.display())); let owner_buffer = session.editor().buffer_id(); let owner_pane = session.editor().pane_id(); let owner_tab = session.editor().tab_id(); if dirty { session.handle(Input::Char('i')); session.handle(Input::Paste("DIRTY ".into())); session.handle(Input::Escape); } let text = session.editor().lines().to_vec(); if comparison { install_readonly_diff( session.editor_mut(), syntax_diff_file("history.txt", "OLD", "NEW"), ); } let comparison_id = session.editor().diff().and_then(|diff| diff.comparison_id); session.handle(Input::Ctrl('o')); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 20)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); let popup = views.panels.files.unwrap().0; assert_eq!( session.editor().file_picker().unwrap().presentation, FilePresentation::Floating ); assert!(popup.contains(terminal.get_cursor_position().unwrap())); session.handle(Input::Ctrl('x')); if dirty { terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert_eq!(session.editor().tab_count(), 2); assert_eq!(session.editor().tab_id(), owner_tab); assert_eq!(session.editor().pane_id(), owner_pane); assert!(!session.editor().should_quit()); assert!(session.editor().message().contains("Unsaved changes")); assert!(session.editor().file_picker().unwrap().focused); assert_eq!(views.panels.files.unwrap().0, popup); assert!(popup.contains(terminal.get_cursor_position().unwrap())); assert_eq!( session .editor() .documents() .find(|document| document.id == owner_buffer) .unwrap() .lines, text ); session.handle(Input::Escape); assert!(session.editor().file_picker().unwrap().focused); assert!(!session.editor().file_picker().unwrap().searching); session.handle(Input::Escape); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert!(session.editor().file_picker().is_none()); assert!(views.panels.files.is_none()); assert_eq!(session.editor().pane_id(), owner_pane); assert_eq!(session.editor().tab_count(), 2); assert_eq!( session.editor().diff().and_then(|diff| diff.comparison_id), comparison_id ); if comparison { assert!(screen_text(&terminal).contains("Before")); } else { assert!(screen_text(&terminal).contains("DIRTY POPUP OWNER")); } session.handle(Input::Ctrl('o')); session.handle(Input::Ctrl('y')); } terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert!(!session.editor().should_quit()); assert_eq!(session.editor().tab_count(), 1); assert_eq!(session.editor().pane_id(), root); assert_eq!(session.editor().buffer_id(), source_buffer); assert!(!session.editor().has_buffer(owner_buffer)); assert_eq!(session.editor().lines(), ["SURVIVING DOCUMENT"]); assert!(session.editor().diff().is_none()); assert!(session.editor().file_picker().is_none()); assert!(views.panels.files.is_none()); assert_eq!(views.rects.len(), 1); assert_eq!(views.rects[0].1.width, 120); assert!(screen_text(&terminal).contains("SURVIVING DOCUMENT")); let (caret_area, caret) = views.panels.caret.unwrap(); assert!(caret_area.contains(caret)); assert_eq!(terminal.get_cursor_position().unwrap(), caret); } } } #[test] fn all_dock_sides_clip_editor_gutter_selection_carets_and_cursor() { let mut editor = Editor::open(None, vec![]).unwrap(); editor.set_gutter_style(crate::gutter::GutterStyle::None); 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 - 1); 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); } } 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.set_gutter_style(crate::gutter::GutterStyle::None); 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')); let mut panels = WheelPanels::default(); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut panels, ) }) .unwrap(); let panel = panels.files.unwrap().0; assert_eq!(panel.x - area.x, area.right() - panel.right()); assert!(panel.y >= area.y && panel.bottom() < area.bottom()); 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[(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_mut())) .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_mut())) .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_mut())) .unwrap(); } session.handle(Input::Enter); terminal .draw(|frame| viewports.draw(frame, session.editor_mut())) .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, &mut editor)) .unwrap(); let before = viewports.offsets[&a_id]; editor.handle(Input::Char(' ')); editor.handle(Input::Char('o')); terminal .draw(|frame| viewports.draw(frame, &mut 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, &mut editor)) .unwrap(); assert_eq!(viewports.offsets[&a_id], before); editor.handle(Input::Char(' ')); editor.handle(Input::Char('e')); terminal .draw(|frame| viewports.draw(frame, &mut 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, &mut editor)) .unwrap(); let dock_offsets = viewports.offsets[&a_id]; editor.handle(Input::Ctrl('p')); terminal .draw(|frame| viewports.draw(frame, &mut 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, &mut 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, &mut 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, &mut editor)) .unwrap(); assert!(screen_text(&terminal).contains("Files")); editor.handle(Input::Ctrl('w')); terminal .draw(|frame| viewports.draw(frame, &mut 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 >= 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 >= 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 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 horizontal_wheel_preserves_selection_bounds_display_cells_and_survives_redraw() { let directory = Directory::new(); let text = format!("abc\t界e\u{301}{}", "xyz".repeat(20)); let path = directory.file("wide.txt", &text); let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); session .editor_mut() .set_gutter_style(crate::gutter::GutterStyle::None); session.handle(Input::Char('v')); session.handle(Input::Char('l')); let cursor = session.editor().cursor(); let selections = session.editor().selections(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(16, 6)).unwrap(); let mut viewports = BufferViewports::default(); terminal .draw(|frame| viewports.draw_session(frame, &mut session)) .unwrap(); assert!(viewports.scroll( &mut session, document_wheel(MouseEventKind::ScrollRight, 0, 0) )); terminal .draw(|frame| viewports.draw_session(frame, &mut session)) .unwrap(); assert_eq!(terminal.backend().buffer()[(3, 0)].symbol(), "界"); assert_eq!(terminal.backend().buffer()[(5, 0)].symbol(), "e\u{301}"); let mut shift = document_wheel(MouseEventKind::ScrollDown, 0, 0); shift.modifiers = KeyModifiers::SHIFT; assert!(viewports.scroll(&mut session, shift)); terminal .draw(|frame| viewports.draw_session(frame, &mut session)) .unwrap(); assert_eq!(viewports.offsets[&session.editor().buffer_id()], (0, 6)); for _ in 0..100 { viewports.scroll(&mut session, shift); } terminal .draw(|frame| viewports.draw_session(frame, &mut session)) .unwrap(); assert_eq!( viewports.offsets[&session.editor().buffer_id()].1, display_column(&text, usize::MAX) + 1 - viewports.document.text_width ); assert_eq!(session.editor().cursor(), cursor); assert_eq!(session.editor().selections(), selections); assert_eq!(session.editor().lines(), std::slice::from_ref(&text)); terminal.backend_mut().resize(100, 6); terminal .draw(|frame| viewports.draw_session(frame, &mut session)) .unwrap(); assert_eq!(viewports.offsets[&session.editor().buffer_id()], (0, 0)); assert!(screen_text(&terminal).contains("abc")); session.handle(Input::Escape); session.handle(Input::Char('l')); terminal .draw(|frame| viewports.draw_session(frame, &mut session)) .unwrap(); assert!(terminal.backend().cursor_visible()); } #[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_mut())) .unwrap(); assert!(terminal.backend().cursor_visible()); let height = usize::from(viewports.document.geometry.unwrap().1.height); let limit = session.editor().lines().len().saturating_sub(height); let mut top = 0usize; for kind in [ MouseEventKind::ScrollDown, MouseEventKind::ScrollDown, MouseEventKind::ScrollDown, MouseEventKind::ScrollDown, MouseEventKind::ScrollUp, MouseEventKind::ScrollUp, MouseEventKind::ScrollUp, MouseEventKind::ScrollUp, ] { top = if kind == MouseEventKind::ScrollDown { top.saturating_add(3).min(limit) } else { top.saturating_sub(3) }; // 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_mut())) .unwrap(); assert_eq!(viewports.offsets[&session.editor().buffer_id()].0, top); assert!( screen_text(&terminal) .lines() .next() .unwrap() .contains(&format!("row-{top:02}")) ); assert_eq!( terminal.backend().cursor_visible(), cursor.row >= top && cursor.row < top + height ); 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_mut())) .unwrap(); assert!( screen_text(&terminal) .lines() .next() .unwrap() .contains("row-00") ); assert!(terminal.backend().cursor_visible()); } #[test] fn queued_scrolls_coalesce_but_preserve_reversals_and_motion_barriers() { let directory = Directory::new(); let text = (0..100) .map(|row| format!("row-{row}\n")) .collect::(); for (first, repeated, barrier) in [ ( Input::Ctrl('u'), 'u', KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), ), ( Input::Ctrl('d'), 'd', KeyEvent::new(KeyCode::Home, KeyModifiers::NONE), ), ( Input::Down, 'j', KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), ), ] { let mut session = EditorSession::open(Some(directory.file("rows.txt", &text)), vec![], None).unwrap(); let mut views = BufferViewports::default(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); let modifiers = if repeated == 'j' { KeyModifiers::NONE } else { KeyModifiers::CONTROL }; let mut events = std::iter::repeat_n( Event::Key(KeyEvent::new(KeyCode::Char(repeated), modifiers)), 7, ) .chain([Event::Key(barrier)]); assert_eq!( keys_ready(&mut session, first, || Ok(events.next())).unwrap(), Some(Event::Key(barrier)) ); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); if repeated == 'u' { assert_eq!(views.offsets[&session.editor().buffer_id()].0, 0); session.handle(Input::Ctrl('d')); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert_eq!( views.offsets[&session.editor().buffer_id()].0, usize::from(views.document.geometry.unwrap().1.height) / 2 ); } else if repeated == 'd' { assert_eq!(views.offsets[&session.editor().buffer_id()].0, 40); session.handle(Input::Home); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert_eq!(views.offsets[&session.editor().buffer_id()].0, 0); } else { assert_eq!(session.editor().cursor().row, 8); } } } #[test] fn queued_navigation_preserves_counted_motions_visual_ranges_and_remaps() { let directory = Directory::new(); let mut rows = (0..100) .map(|row| format!("row-{row:03}")) .collect::>(); rows[1].clear(); let text = rows.join("\n"); let path = directory.file("rows.txt", &text); for mode in [Mode::Normal, Mode::Visual] { let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap(); session .editor_mut() .bind("normal", "z", Some("core.down".into())) .unwrap(); session .editor_mut() .bind("visual", "z", Some("core.down".into())) .unwrap(); session.handle(Input::End); if mode == Mode::Visual { session.handle(Input::Char('v')); } session.handle(Input::Char('3')); let key = |code| Event::Key(KeyEvent::new(code, KeyModifiers::NONE)); let mut events = std::iter::once(key(KeyCode::Right)) .chain(std::iter::repeat_n(key(KeyCode::Down), 18)) .chain([ key(KeyCode::Up), Event::Key(KeyEvent::new_with_kind( KeyCode::Char('z'), KeyModifiers::NONE, KeyEventKind::Repeat, )), key(KeyCode::Char('i')), key(KeyCode::Char('x')), ]); let pending = keys_ready(&mut session, Input::Down, || Ok(events.next())).unwrap(); assert_eq!(session.editor().cursor(), Cursor { row: 21, column: 1 }); assert_eq!(session.editor().mode(), mode); assert_eq!(session.editor().lines(), rows); if mode == Mode::Visual { assert_eq!( session.editor().selections(), vec![Selection { start: Cursor { row: 0, column: 6 }, end: Cursor { row: 21, column: 2 }, }] ); } assert_eq!(pending, Some(key(KeyCode::Char('i')))); assert_eq!(events.next(), Some(key(KeyCode::Char('x')))); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(30, 8)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert!(screen_text(&terminal).contains("row-021")); assert!(terminal.backend().cursor_visible()); } } #[test] fn queued_navigation_stops_at_event_binding_and_batch_boundaries() { let directory = Directory::new(); let text = (0..100) .map(|row| format!("row-{row:03}\n")) .collect::(); let path = directory.file("rows.txt", &text); let down = Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); for barrier in [ Event::Resize(40, 12), Event::Paste("literal".into()), Event::Mouse(document_wheel(MouseEventKind::ScrollDown, 1, 1)), Event::Key(KeyEvent::new_with_kind( KeyCode::Down, KeyModifiers::NONE, KeyEventKind::Release, )), Event::Key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)), Event::Key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE)), Event::Key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)), ] { let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap(); session .editor_mut() .bind("normal", "j", Some("core.insert".into())) .unwrap(); session .editor_mut() .bind("normal", "z", Some("core.pane.split_horizontal".into())) .unwrap(); let mut events = [barrier.clone(), down.clone()].into_iter(); let pending = keys_ready(&mut session, Input::Down, || Ok(events.next())).unwrap(); assert_eq!(pending, Some(barrier)); assert_eq!(events.next(), Some(down.clone())); assert_eq!(session.editor().cursor(), Cursor { row: 1, column: 0 }); assert_eq!(session.editor().mode(), Mode::Normal); assert_eq!(session.editor().pane_layout().len(), 1); assert_eq!(session.editor().lines().join("\n"), text); } let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); let mut events = std::iter::repeat_n(down, 80); assert_eq!( keys_ready(&mut session, Input::Down, || Ok(events.next())).unwrap(), None ); assert_eq!(session.editor().cursor(), Cursor { row: 64, column: 0 }); assert_eq!(events.count(), 17); } #[test] fn queued_typing_preserves_graphemes_history_and_mode_barriers() { let directory = Directory::new(); let path = directory.file("input.txt", "seed"); let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); session.handle(Input::Char('i')); let mut events = [ Event::Key(KeyEvent::new(KeyCode::Char('界'), KeyModifiers::NONE)), Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), ] .into_iter(); let pending = keys_ready(&mut session, Input::Char('a'), || Ok(events.next())).unwrap(); assert_eq!(session.editor().lines(), ["a界seed"]); assert_eq!(session.editor().mode(), Mode::Insert); assert!(matches!(pending, Some(Event::Key(key)) if key.code == KeyCode::Esc)); session.handle(Input::Escape); let Some(Event::Key(key)) = events.next() else { panic!("remaining command") }; session.handle(translate(key).unwrap()); assert_eq!(session.editor().lines(), ["aseed"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), ["a界seed"]); session.handle(Input::Char('u')); assert_eq!(session.editor().lines(), ["seed"]); } #[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_mut())) .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_mut())) .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_mut())) .unwrap(); assert!(viewports.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, 2, 0) )); terminal .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!(!terminal.backend().cursor_visible()); session.handle(motion); terminal .draw(|frame| viewports.draw(frame, session.editor_mut())) .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, &mut 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, &mut editor)) .unwrap(); assert!(!terminal.backend().cursor_visible()); editor.handle(Input::Char('k')); terminal .draw(|frame| viewports.draw(frame, &mut 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, &mut 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(); session .editor_mut() .set_gutter_style(crate::gutter::GutterStyle::None); 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_mut())) .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_mut())) .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_mut())) .unwrap(); assert!( screen_text(&terminal) .lines() .next() .unwrap() .contains("row-06") ); session.handle(Input::Ctrl('p')); terminal .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!(viewports.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, 4, 0) )); session.handle(Input::Escape); terminal .draw(|frame| viewports.draw(frame, session.editor_mut())) .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_mut())) .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_mut())) .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_mut())) .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_mut())) .unwrap(); assert!(screen_text(&terminal).starts_with("1 other")); session.execute(&format!("buffer {buffer_id}")); terminal .draw(|frame| viewports.draw(frame, session.editor_mut())) .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(); session .editor_mut() .set_gutter_style(crate::gutter::GutterStyle::None); 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_mut())) .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_mut())) .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_mut())) .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_mut())) .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_mut())) .unwrap(); assert_eq!(viewports.offsets[&session.editor().buffer_id()].0, 0); } } #[test] fn wrapped_document_renders_continuations_selection_and_insertion_caret() { let directory = Directory::new(); let source = "abcdefghijklmnop\ntail"; let path = directory.file("wrapped.txt", source); let mut editor = Editor::open(Some(path), vec![]).unwrap(); editor.set_gutter_style(crate::gutter::GutterStyle::None); editor.execute("word_wrap.toggle"); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(10, 6)).unwrap(); let mut viewports = BufferViewports::default(); terminal .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); let screen = screen_text(&terminal); let rows: Vec<_> = screen.lines().collect(); assert_eq!(rows[0], "1 abcdefgh"); assert_eq!(rows[1], " ijklmnop"); assert_eq!(rows[2], " "); assert_eq!(rows[3], "1 tail "); editor.handle(Input::Char('v')); for _ in 0..10 { editor.handle(Input::Right); } terminal .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(terminal.get_cursor_position().unwrap(), Position::new(4, 1)); assert_eq!(terminal.backend().buffer()[(2, 1)].bg, Color::Blue); assert_eq!(terminal.backend().buffer()[(3, 1)].bg, Color::Blue); editor.handle(Input::Escape); editor.handle(Input::End); editor.handle(Input::Char('a')); terminal .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(terminal.get_cursor_position().unwrap(), Position::new(2, 2)); assert_eq!(editor.lines().join("\n"), source); assert!(!editor.dirty()); } #[test] fn wrapped_wheel_moves_visual_rows_and_resize_and_toggle_restore_following() { let directory = Directory::new(); let source = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; let path = directory.file("wrapped.txt", source); let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); let id = session.editor().buffer_id(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(10, 6)).unwrap(); let mut viewports = BufferViewports::default(); for _ in 0..12 { session.handle(Input::Right); } terminal .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); let original_left = viewports.offsets[&id].1; assert!(original_left > 0); session.execute("word_wrap.toggle"); terminal .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!(viewports.scroll( &mut session, document_wheel(MouseEventKind::ScrollRight, 4, 0) )); assert_eq!(viewports.offsets[&id].1, original_left); assert!(viewports.document.manual.is_none()); assert!(viewports.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, 4, 0) )); terminal .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!(!terminal.backend().cursor_visible()); assert!(viewports.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, 4, 0) )); terminal .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); terminal.backend_mut().resize(14, 6); terminal .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!(viewports.document.manual.is_none()); assert!(terminal.backend().cursor_visible()); assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 12 }); session.execute("word_wrap.toggle"); terminal .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert_eq!(viewports.offsets[&id].1, original_left); assert_eq!(session.editor().lines().join("\n"), source); } #[test] fn wrapped_unicode_uses_original_tab_stops_and_queries_visible_source_rows() { let directory = Directory::new(); let source = format!("#a\t界e\u{301} z\n{}", vec!["# unseen"; 100].join("\n")); let path = directory.file("wrapped.py", &source); let mut editor = Editor::open(Some(path), vec![]).unwrap(); editor.set_gutter_style(crate::gutter::GutterStyle::None); editor .replace_semantic_tokens( editor.buffer_id(), editor.text_revision(), Some(Arc::from([semantic_token( 0, 3, 5, SemanticTokenKind::Property, 0, )])), ) .unwrap(); editor .replace_diagnostics( "fixture", editor.buffer_id(), editor.text_revision(), vec![annotation( (0, 2), (0, 5), DiagnosticSeverity::Warning, "wrapped range", )], ) .unwrap(); editor.execute("word_wrap.toggle"); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 6)).unwrap(); let mut viewports = BufferViewports::default(); terminal .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(viewports.document.text_width, 4); let visible = viewports .document .visible .iter() .map(|&(row, part, _)| (row, part)) .collect::>(); assert!(visible.starts_with(&[(0, 0), (0, 1), (0, 2), (1, 0)])); assert_eq!( visible.len(), usize::from(viewports.document.geometry.unwrap().1.height) ); let buffer = terminal.backend().buffer(); assert_eq!(buffer[(4, 0)].symbol(), "#"); assert_eq!(buffer[(6, 0)].symbol(), " "); assert_eq!(buffer[(4, 1)].symbol(), "界"); assert_eq!(buffer[(6, 1)].symbol(), "e\u{301}"); for x in [4, 6] { assert_eq!(buffer[(x, 1)].fg, Color::Blue); assert!(buffer[(x, 1)].modifier.contains(Modifier::UNDERLINED)); assert_eq!(buffer[(x, 1)].underline_color, Color::Yellow); } assert_eq!(buffer[(4, 2)].fg, Color::DarkGray); assert!(viewports.syntax[&editor.buffer_id()].line(2).is_empty()); assert_eq!(buffer[(4, 2)].symbol(), "z"); assert_eq!(editor.lines().join("\n"), source); editor.handle(Input::Down); assert_eq!(editor.cursor().row, 1); } #[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, &mut editor)) .unwrap(); let initial = viewports.offsets[&a_id]; assert_eq!( initial.0, editor.cursor().row + 1 - usize::from(viewports.document.geometry.unwrap().1.height) ); assert_eq!( initial.1, display_column(&editor.lines()[editor.cursor().row], editor.cursor().column) + 1 - viewports.document.text_width ); for _ in 0..2 { editor.handle(Input::Up); } for _ in 0..3 { editor.handle(Input::Left); } terminal .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); let a_viewport = viewports.offsets[&a_id]; let a_cursor = terminal.get_cursor_position().unwrap(); assert_eq!(a_viewport, initial); editor.execute(&format!("edit {}", b.display())); let b_id = editor.buffer_id(); assert_ne!(a_id, b_id); terminal .draw(|frame| viewports.draw(frame, &mut 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, &mut editor)) .unwrap(); editor.handle(Input::Up); editor.handle(Input::Left); editor.handle(Input::Left); terminal .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); let b_viewport = viewports.offsets[&b_id]; let b_cursor = terminal.get_cursor_position().unwrap(); editor.execute(&format!("buffer {a_id}")); terminal .draw(|frame| viewports.draw(frame, &mut 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, &mut 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, &mut 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, &mut 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, &mut 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, &mut 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(); editor.set_gutter_style(crate::gutter::GutterStyle::None); 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, &mut editor)) .unwrap(); assert_eq!( viewports.offsets[&a_id].0, editor.cursor().row + 1 - usize::from(viewports.document.geometry.unwrap().1.height) ); editor.execute(&format!("edit {}", b.display())); let b_id = editor.buffer_id(); terminal .draw(|frame| viewports.draw(frame, &mut 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, &mut editor)) .unwrap(); let viewport = viewports.document.geometry.unwrap().1; assert_eq!( viewports.offsets[&a_id], (editor.cursor().row + 1 - usize::from(viewport.height), 49,) ); assert_eq!(viewports.offsets[&b_id], (0, 0)); assert!(viewport.contains(terminal.get_cursor_position().unwrap())); 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, &mut 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(); editor.set_gutter_style(crate::gutter::GutterStyle::None); 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, &mut editor)) .unwrap(); assert!(!screen_text(&terminal).contains("[buffer")); editor.execute(&format!("edit {}", b.display())); terminal .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); let text = screen_text(&terminal); assert!(!text.contains("3 selections"), "{text}"); editor.execute(&format!("buffer {a_id}")); terminal .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); let text = screen_text(&terminal); 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.set_gutter_style(crate::gutter::GutterStyle::None); 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 git_gutter_modes_colors_geometry_wrap_scroll_and_tiny_panes() { use crate::gutter::{Change, GutterStyle}; let directory = Directory::new(); let path = directory.file("gutter.txt", "abcdefghijklmnop\nsecond\nthird\nfourth"); let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); assert!(editor.set_gutter_changes( editor.buffer_id(), editor.text_revision(), &path, vec![ Some(Change::Added), Some(Change::Removed), Some(Change::Modified), None ], )); for (mode, marker) in [ (GutterStyle::Bar, "▌"), (GutterStyle::Symbols, "+"), (GutterStyle::LineNumbers, ""), (GutterStyle::None, ""), ] { editor.set_gutter_style(mode); let mut views = BufferViewports::default(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 6)).unwrap(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let viewport = views.panels.caret.unwrap().0; let markers = matches!(mode, GutterStyle::Bar | GutterStyle::Symbols); assert_eq!(viewport.x, if markers { 4 } else { 2 }); let buffer = terminal.backend().buffer(); assert_eq!(buffer[(viewport.x - 1, viewport.y)].symbol(), " "); if markers { assert_eq!(buffer[(1, viewport.y)].symbol(), " "); assert_eq!(buffer[(2, viewport.y)].symbol(), marker); for (offset, color) in [Color::Green, Color::Red, Color::Yellow] .into_iter() .enumerate() { assert_eq!(buffer[(1, viewport.y + offset as u16)].symbol(), " "); assert_eq!(buffer[(2, viewport.y + offset as u16)].fg, color); } if mode == GutterStyle::Symbols { assert_eq!(buffer[(2, viewport.y + 1)].symbol(), "-"); assert_eq!(buffer[(2, viewport.y + 2)].symbol(), "~"); } } else if mode == GutterStyle::LineNumbers { assert_eq!(buffer[(0, viewport.y)].fg, Color::Cyan); assert_eq!(buffer[(0, viewport.y + 1)].fg, Color::Red); assert_eq!(buffer[(0, viewport.y + 2)].fg, Color::Yellow); } assert_eq!(buffer[(viewport.x, viewport.y)].symbol(), "a"); } editor .replace_diagnostics( "fixture", editor.buffer_id(), editor.text_revision(), vec![ annotation((0, 0), (0, 1), DiagnosticSeverity::Error, "current"), annotation((1, 0), (1, 1), DiagnosticSeverity::Warning, "warning"), annotation((2, 0), (2, 1), DiagnosticSeverity::Error, "error"), ], ) .unwrap(); editor.set_gutter_style(GutterStyle::LineNumbers); let mut views = BufferViewports::default(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 6)).unwrap(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let viewport = views.panels.caret.unwrap().0; let buffer = terminal.backend().buffer(); assert_eq!(buffer[(0, viewport.y)].fg, Color::Cyan); assert!(buffer[(0, viewport.y)].modifier.contains(Modifier::BOLD)); assert_eq!(buffer[(0, viewport.y + 1)].fg, Color::Yellow); assert_eq!(buffer[(0, viewport.y + 2)].fg, Color::Red); assert_eq!(buffer[(0, viewport.y + 3)].fg, Color::DarkGray); editor .replace_diagnostics( "fixture", editor.buffer_id(), editor.text_revision(), vec![], ) .unwrap(); editor.set_gutter_style(GutterStyle::Symbols); editor.handle(Input::End); let mut scrolled_views = BufferViewports::default(); let mut scrolled_terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 4)).unwrap(); scrolled_terminal .draw(|frame| scrolled_views.draw(frame, &mut editor)) .unwrap(); assert!(scrolled_views.offsets[&editor.buffer_id()].1 > 0); let text_area = scrolled_views.panels.caret.unwrap().0; assert_eq!( scrolled_terminal.backend().buffer()[(2, text_area.y)].symbol(), "+" ); assert_eq!(text_area.x, 4); editor.handle(Input::Home); editor.execute("word_wrap.toggle"); let mut views = BufferViewports::default(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 8)).unwrap(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let viewport = views.panels.caret.unwrap().0; for (screen, &(_, continuation, _)) in views.document.visible.iter().enumerate() { if continuation > 0 { for x in 0..viewport.x { assert_eq!( terminal.backend().buffer()[(x, viewport.y + screen as u16)].symbol(), " " ); } } } editor.execute("word_wrap.toggle"); editor.handle(Input::Down); editor.handle(Input::Down); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 3)).unwrap(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let viewport = views.panels.caret.unwrap().0; for (screen, &(row, _, _)) in views.document.visible.iter().enumerate() { let expected = match row { 0 => "+", 1 => "-", 2 => "~", _ => " ", }; assert_eq!( terminal.backend().buffer()[(2, viewport.y + screen as u16)].symbol(), expected ); } for width in [1, 2, 3, 4] { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 3)).unwrap(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert_eq!(views.panels.caret.unwrap().0.x, 0); } editor.execute("pane.split_vertical"); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 8)).unwrap(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert!(screen_text(&terminal).contains('~')); } #[test] fn gutter_tracks_scrolled_rows_without_scrolling_with_text() { let mut editor = Editor::open(None, vec![]).unwrap(); editor.set_gutter_style(crate::gutter::GutterStyle::None); 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 views = BufferViewports::default(); for input in [ None, Some(Input::Down), Some(Input::Up), Some(Input::Up), Some(Input::Ctrl('e')), ] { if let Some(input) = input { editor.handle(input); } terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let viewport = views.panels.caret.unwrap().0; assert_eq!(viewport.height, area.height - 1); assert_eq!(viewport.x, area.x + 3); let (top, left) = views.offsets[&editor.buffer_id()]; assert_eq!(views.document.visible[0].0, top); let buffer = terminal.backend().buffer(); for (screen_row, &(row, _, _)) in views.document.visible.iter().enumerate() { let y = viewport.y + screen_row as u16; let number: String = (area.x..viewport.x) .map(|x| buffer[(x, y)].symbol()) .collect(); let expected = if row == editor.cursor().row { row + 1 } else { row.abs_diff(editor.cursor().row) }; assert_eq!(number, format!("{expected:>2} ")); assert_eq!(buffer[(area.x, y)].bg, Color::Reset); assert_eq!( buffer[(area.x, y)].modifier.contains(Modifier::BOLD), row == editor.cursor().row ); } let cursor = terminal.get_cursor_position().unwrap(); assert!(viewport.contains(cursor)); assert_eq!( cursor.x, viewport.x + (display_column(&editor.lines()[editor.cursor().row], editor.cursor().column) - left) as u16 ); assert_eq!(cursor.y, viewport.y + (editor.cursor().row - top) as u16); } assert_eq!(editor.selection_count(), 2); } #[test] fn gutter_resizes_at_digit_boundaries_and_yields_to_narrow_text() { let mut editor = Editor::open(None, vec![]).unwrap(); editor.set_gutter_style(crate::gutter::GutterStyle::None); 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(); } #[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 buffer_picker_keeps_selected_status_and_paths_visible_after_resize_and_filter() { let mut editor = Editor::open(None, vec![]).unwrap(); for id in 2..=12 { editor.execute(&format!("edit picker-{id}.txt")); } editor .apply(vec![crate::editor::Effect::Replace("unsaved".into())]) .unwrap(); editor.execute("buffers"); editor.handle(Input::Ctrl('k')); for (width, height) in [(80, 12), (32, 6), (1, 1), (4, 4), (80, 12)] { let area = Rect::new(3, 2, width, height); let mut terminal = Terminal::with_options( ratatui::backend::TestBackend::new(90, 30), ratatui::TerminalOptions { viewport: ratatui::Viewport::Fixed(area), }, ) .unwrap(); terminal .draw(|frame| { draw_palette( frame, editor.palette().unwrap(), area, editor.mode(), editor.selection_navigation_hint(), &mut WheelPanels::default(), ) }) .unwrap(); assert!(area.contains(terminal.get_cursor_position().unwrap())); if width >= 32 { let buffer = terminal.backend().buffer(); let selected_y = (area.y..area.bottom()) .find(|&y| { pane_screen(&terminal, Rect::new(area.x, y, width, 1)).contains("picker-12.txt") }) .unwrap(); assert_eq!(buffer[(area.x + 4, selected_y)].fg, Color::Cyan); assert_eq!(buffer[(area.x + 8, selected_y)].symbol(), "+"); assert_eq!(buffer[(area.x + 8, selected_y)].fg, Color::Yellow); assert_eq!(buffer[(area.x + 10, selected_y)].bg, Color::Blue); assert_eq!( buffer[(area.x + 6, selected_y)].fg, buffer[(area.x + 10, selected_y)].fg, ); assert!( pane_screen(&terminal, Rect::new(area.x, selected_y, width, 1)) .contains("picker-12.txt") ); } } editor.handle(Input::Char('2')); editor.handle(Input::Ctrl('j')); editor.handle(Input::Enter); assert_eq!(editor.buffer_id(), 12); assert_eq!(editor.context().text, "unsaved"); assert!(editor.dirty()); } #[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, buffers: false, }; 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, area, Mode::Visual, editor.selection_navigation_hint(), &mut WheelPanels::default(), ) }) .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| { let area = frame.area(); draw_palette( frame, &palette, area, Mode::Normal, editor.selection_navigation_hint(), &mut WheelPanels::default(), ) }) .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!(buffer[(cursor.x, cursor.y)].symbol(), " "); } else { assert_eq!(buffer[(cursor.x, cursor.y)].symbol(), "z"); } 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.set_gutter_style(crate::gutter::GutterStyle::None); 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)); } } 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.set_gutter_style(crate::gutter::GutterStyle::None); 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.set_gutter_style(crate::gutter::GutterStyle::None); 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; 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.set_gutter_style(crate::gutter::GutterStyle::None); 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 search_highlights_all_occurrences_without_moving_on_first_star() { let mut editor = Editor::open(None, vec![]).unwrap(); editor.set_gutter_style(crate::gutter::GutterStyle::None); configure_chrome(&mut editor, |top, bottom| { for bar in [top, bottom] { bar.left.clear(); bar.center.clear(); bar.right.clear(); } }); editor .apply(vec![crate::editor::Effect::Replace( "cat cat_cat cat\ncat".into(), )]) .unwrap(); editor.handle(Input::Char('*')); assert_eq!(editor.cursor(), Cursor::default()); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(30, 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 (row, start, end) in [(0, 0, 3), (0, 12, 15), (1, 0, 3)] { for column in start..end { assert_eq!(buffer[(column + 2, row)].bg, Color::Yellow); } } assert_ne!(buffer[(6, 0)].bg, Color::Yellow); editor.handle(Input::Char('/')); for ch in "cat".chars() { editor.handle(Input::Char(ch)); } editor.handle(Input::Enter); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut WheelPanels::default(), ); }) .unwrap(); for column in [0, 4, 8, 12] { assert_eq!( terminal.backend().buffer()[(column + 2, 0)].bg, Color::Yellow ); } } #[test] fn active_search_highlight_uses_display_cells_and_clips_with_horizontal_scroll() { let mut editor = Editor::open(None, vec![]).unwrap(); editor.set_gutter_style(crate::gutter::GutterStyle::None); 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)); } let mut panels = WheelPanels::default(); terminal .draw(|frame| { draw( frame, &editor, &mut top, &mut left, &mut syntax, &mut DocumentViewport::default(), &mut panels, ) }) .unwrap(); let buffer = terminal.backend().buffer(); let input = panels.input.unwrap(); let content = Rect::new(input.x + 2, input.y + 1, input.width - 4, 1); assert_eq!(buffer[(content.x, content.y)].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 panels, ) }) .unwrap(); assert!( panels .input .unwrap() .contains(terminal.get_cursor_position().unwrap()) ); assert!(content.contains(terminal.get_cursor_position().unwrap())); } #[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, false, |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, false, |frame| { viewports.draw(frame, &mut 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!(TerminalSession::run(false, || run(&mut editor, false)).is_err()); } else { let result = panic::catch_unwind(AssertUnwindSafe(|| { TerminalSession::run(false, || { 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(), false, |_| { 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!(!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 sequence in ["\x1b[>1u", "\x1b[<1u"] { assert_eq!( before.matches(sequence).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); } } #[cfg(test)] fn draw( frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize, syntax: &mut SyntaxCache, document: &mut DocumentViewport, panels: &mut WheelPanels, ) { let area = frame.area(); let workspace = draw_bars(frame, editor, area, syntax); draw_document( frame, editor, workspace, workspace, top, left, syntax, document, panels, ); } #[test] fn shell_output_popup_routes_wheels_and_hides_document_cursor() { let mut session = EditorSession::open(None, vec![], None).unwrap(); session.editor_mut().set_message(""); let source = session.editor().lines().to_vec(); let cursor = session.editor().cursor(); session.editor_mut().show_command_output( "printf output".into(), Err((0..40) .map(|row| format!("OUTPUT {row} {}", "wide ".repeat(30))) .collect::>() .join("\n")), ); let area = Rect::new(3, 2, 80, 16); let mut terminal = Terminal::with_options( ratatui::backend::TestBackend::new(90, 22), ratatui::TerminalOptions { viewport: ratatui::Viewport::Fixed(area), }, ) .unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); let text = screen_text(&terminal); assert!(text.contains("printf output")); assert!(text.contains("OUTPUT 0")); assert!(text.contains("Enter/Esc close")); assert!(!terminal.backend().cursor_visible()); let offsets = views.offsets.clone(); assert!(views.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, area.x, area.y) )); assert_eq!(session.editor().command_output().unwrap().content.top, 3); assert!(views.scroll( &mut session, document_wheel(MouseEventKind::ScrollRight, area.x, area.y) )); assert_eq!(session.editor().command_output().unwrap().content.left, 3); assert_eq!(views.offsets, offsets); assert_eq!(session.editor().lines(), source.as_slice()); assert_eq!(session.editor().cursor(), cursor); session.handle(Input::Escape); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert!(session.editor().command_output().is_none()); assert!(terminal.backend().cursor_visible()); } #[test] fn shell_output_panel_clips_sanitized_content_in_offset_and_tiny_areas() { let mut editor = Editor::open(None, vec![]).unwrap(); editor.show_command_output( "shell title".into(), Err("SKIPPED\nabcdVISIBLE\u{1b} tail".into()), ); editor.handle(Input::Down); for _ in 0..4 { editor.handle(Input::Right); } let diagnostic = &editor.command_output().unwrap().content; for (width, height) in [(50, 12), (3, 2), (1, 1), (0, 1), (1, 0)] { let area = Rect::new(3, 2, width, height); let mut terminal = Terminal::with_options( ratatui::backend::TestBackend::new(60, 18), ratatui::TerminalOptions { viewport: ratatui::Viewport::Fixed(area), }, ) .unwrap(); terminal .draw(|frame| { draw_diagnostic_panel( frame, diagnostic, area, "shell title", editor.command_output(), ); }) .unwrap(); assert!(!terminal.backend().cursor_visible()); let text = screen_text(&terminal); assert!(!text.contains("SKIPPED")); assert!(!text.contains('\u{1b}')); if width == 50 { assert!(text.contains("shell title")); assert!(text.contains("VISIBLE")); assert!(!text.contains("abcd")); } } } #[test] fn shell_output_wrap_toggle_reflows_and_scrolls_without_changing_source() { let mut session = EditorSession::open(None, vec![], None).unwrap(); session .editor_mut() .bind("normal", "leader z w", Some("core.word_wrap.toggle".into())) .unwrap(); let source_wrap = session.editor().word_wrap(); let source = session.editor().lines().to_vec(); session.editor_mut().show_command_output( "long output".into(), Err(format!("{}TAIL", "abcdefghij".repeat(30))), ); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(42, 14)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert!(!screen_text(&terminal).contains("TAIL")); session.handle(Input::Char('w')); assert!(!session.editor().command_output().unwrap().wrapping); for ch in " zw".chars() { session.handle(Input::Char(ch)); } terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert!(screen_text(&terminal).contains("TAIL")); session.handle(Input::Down); assert_eq!(session.editor().command_output().unwrap().content.top, 1); session.handle(Input::Right); assert_eq!(session.editor().command_output().unwrap().content.left, 0); for ch in " zw".chars() { session.handle(Input::Char(ch)); } terminal .draw(|frame| views.draw_session(frame, &mut session)) .unwrap(); assert!(!screen_text(&terminal).contains("TAIL")); assert_eq!(session.editor().word_wrap(), source_wrap); assert_eq!(session.editor().lines(), source); } #[test] fn shell_output_wrap_bindings_respect_remaps_disabled_keys_and_sequence_cancellation() { let mut editor = Editor::open(None, vec![]).unwrap(); editor .bind("normal", "ctrl+y", Some("core.word_wrap.toggle".into())) .unwrap(); editor .bind("normal", "ctrl+w z", Some("core.word_wrap.toggle".into())) .unwrap(); editor .bind("normal", "leader x", Some("core.delete".into())) .unwrap(); editor.show_command_output("command".into(), Err("output".into())); let source = editor.lines().to_vec(); editor.handle(Input::Ctrl('y')); assert!(editor.command_output().unwrap().wrapping); editor.bind("normal", "ctrl+y", None).unwrap(); editor.handle(Input::Ctrl('y')); assert!(editor.command_output().unwrap().wrapping); editor.handle(Input::Ctrl('w')); editor.handle(Input::Escape); assert!(editor.command_output().is_some()); assert!(editor.sequence_pending().is_none()); editor.handle(Input::Ctrl('w')); editor.handle(Input::Char('z')); assert!(!editor.command_output().unwrap().wrapping); editor.handle(Input::Char(' ')); editor.handle(Input::Char('x')); assert_eq!(editor.lines(), source); assert!(editor.command_output().is_some()); assert!(editor.sequence_pending().is_none()); editor.handle(Input::Escape); assert!(editor.command_output().is_none()); } #[test] fn shared_shell_status_is_a_separate_segment_left_of_the_battery() { let mut editor = Editor::open(None, vec![]).unwrap(); editor.set_battery_charge(Some(27)); editor.set_shell_label("sle... | pwd /".into()); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap(); let mut views = BufferViewports::default(); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let top = pane_screen(&terminal, Rect::new(0, 0, 80, 1)); assert!(top.contains("░▓ sle... | pwd / ▓░ ░▓ bat 27%"), "{top}"); editor.set_shell_label(String::new()); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); let top = pane_screen(&terminal, Rect::new(0, 0, 80, 1)); assert!(!top.contains("sle...")); assert!(top.trim_end().ends_with("bat 27%"), "{top}"); }