Files
vamp/src/tui.rs
T

8204 lines
310 KiB
Rust
Raw Blame History

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