Files
vamp/src/tui.rs
T
2026-10-12 10:46:39 +02:00

11799 lines
446 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, Instant},
};
use crossterm::{
cursor::Show,
event::{
self, DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
EnableFocusChange, 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, Wrap},
};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::{
diff::{ChangedFile, DiffState, DisplayRow, FileStatus, Focus, PaneLayout, Phase, RowKind},
editor::{
Completion, Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic,
},
files::{DockSide, FilePicker, FilePresentation, FileView},
panes::{PaneRect, SplitAxis, split_rect},
plugin::{
Diagnostic, DiagnosticSeverity, SemanticToken, SemanticTokenKind, SemanticTokenModifier,
},
session::EditorSession,
syntax::{SyntaxCache, SyntaxKind, SyntaxSpan},
terminal_colors::{TerminalColors, ThemeColors},
wrap::{Segment, WrapCache, caret_segment},
};
pub const CAPABILITIES: &[&str] = &["text", "styled_text", "diagnostics"];
pub(crate) 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>>,
}
struct ColorSchemeNotifications(bool);
impl crossterm::Command for ColorSchemeNotifications {
fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result {
f.write_str(if self.0 { "\x1b[?2031h" } else { "\x1b[?2031l" })
}
#[cfg(windows)]
fn execute_winapi(&self) -> io::Result<()> {
Ok(())
}
}
fn restore_terminal() {
let _ = disable_raw_mode();
let _ = execute!(
io::stdout(),
EndSynchronizedUpdate,
DisableBracketedPaste,
DisableMouseCapture,
DisableFocusChange,
ColorSchemeNotifications(false),
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,
EnableFocusChange,
ColorSchemeNotifications(true)
)?;
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>,
text_width: usize,
horizontal_extent: Option<(usize, u64, usize)>,
wrap: WrapCache,
continuations: BTreeMap<usize, usize>,
presentation: Option<(usize, bool, usize, usize)>,
visible: Vec<(usize, usize, Segment)>,
anchor: Option<(usize, u64, Cursor)>,
}
#[derive(Clone)]
struct ManualViewport {
buffer_id: usize,
revision: u64,
caret_reveal_revision: u64,
mode: Mode,
cursor: Cursor,
carets: Vec<Cursor>,
selections: Vec<Selection>,
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
struct VisualRow {
source: usize,
continuation: usize,
}
fn next_visual(cache: &mut WrapCache, lines: &[String], position: VisualRow) -> Option<VisualRow> {
if position.continuation + 1
< cache
.segments(position.source, &lines[position.source])
.len()
{
Some(VisualRow {
continuation: position.continuation + 1,
..position
})
} else if position.source + 1 < lines.len() {
Some(VisualRow {
source: position.source + 1,
continuation: 0,
})
} else {
None
}
}
fn previous_visual(
cache: &mut WrapCache,
lines: &[String],
position: VisualRow,
) -> Option<VisualRow> {
if position.continuation > 0 {
Some(VisualRow {
continuation: position.continuation - 1,
..position
})
} else if position.source > 0 {
let source = position.source - 1;
Some(VisualRow {
source,
continuation: cache.segments(source, &lines[source]).len() - 1,
})
} else {
None
}
}
fn visual_page(
cache: &mut WrapCache,
lines: &[String],
start: VisualRow,
height: usize,
) -> (VisualRow, usize) {
let mut end = start;
let mut count = usize::from(height > 0);
while count < height {
let Some(next) = next_visual(cache, lines, end) else {
break;
};
end = next;
count += 1;
}
(end, count)
}
fn clamp_visual_bottom(
cache: &mut WrapCache,
lines: &[String],
mut start: VisualRow,
height: usize,
) -> VisualRow {
let (_, count) = visual_page(cache, lines, start, height);
for _ in count..height {
let Some(previous) = previous_visual(cache, lines, start) else {
break;
};
start = previous;
}
start
}
#[derive(Default)]
struct BufferViewports {
offsets: BTreeMap<usize, (usize, usize)>,
syntax: BTreeMap<usize, SyntaxCache>,
document: DocumentViewport,
document_buffer: Option<usize>,
documents: BTreeMap<usize, DocumentViewport>,
panels: WheelPanels,
pane: Option<usize>,
parked: BTreeMap<usize, PaneViewport>,
rects: Vec<(usize, Rect)>,
theme: Option<ThemeColors>,
diff_visible: bool,
}
#[derive(Default)]
struct PaneViewport {
offsets: BTreeMap<usize, (usize, usize)>,
document: DocumentViewport,
document_buffer: Option<usize>,
documents: BTreeMap<usize, DocumentViewport>,
panels: WheelPanels,
}
fn pane_rect(area: Rect) -> PaneRect {
PaneRect {
x: area.x,
y: area.y,
width: area.width,
height: area.height,
}
}
fn terminal_rect(area: PaneRect) -> Rect {
Rect::new(area.x, area.y, area.width, area.height)
}
impl BufferViewports {
fn set_theme(&mut self, theme: Option<ThemeColors>) {
if self.theme == theme {
return;
}
self.theme = theme;
let colors = DiffColors::new(theme);
self.panels.diff_colors = colors;
for pane in self.parked.values_mut() {
pane.panels.diff_colors = colors;
}
}
fn activate(&mut self, id: usize, editor: &Editor) {
if self.pane == Some(id) {
return;
}
if self.pane.is_none() {
self.pane = Some(id);
return;
}
if !self.parked.contains_key(&id) {
let mut inherited = (|| {
let buffer = editor.pane_buffer_id(id)?;
let source = editor.pane_origin(id);
let (offsets, document, document_buffer, documents) = if source == self.pane {
(
&self.offsets,
&self.document,
self.document_buffer,
&self.documents,
)
} else {
let source = self.parked.get(&source?)?;
(
&source.offsets,
&source.document,
source.document_buffer,
&source.documents,
)
};
let document = if document_buffer == Some(buffer) {
Some(document)
} else {
documents.get(&buffer)
};
Some(PaneViewport {
offsets: offsets
.get(&buffer)
.map_or_else(BTreeMap::new, |offset| BTreeMap::from([(buffer, *offset)])),
document: DocumentViewport {
continuations: document
.and_then(|document| document.continuations.get(&buffer))
.map_or_else(BTreeMap::new, |continuation| {
BTreeMap::from([(buffer, *continuation)])
}),
manual: document.and_then(|document| document.manual.clone()),
presentation: document.and_then(|document| document.presentation),
..DocumentViewport::default()
},
..PaneViewport::default()
})
})()
.unwrap_or_default();
inherited.panels.diff_colors = self.panels.diff_colors;
self.parked.insert(id, inherited);
}
let outgoing = self
.parked
.entry(self.pane.expect("current pane exists"))
.or_default();
std::mem::swap(&mut self.offsets, &mut outgoing.offsets);
std::mem::swap(&mut self.document, &mut outgoing.document);
std::mem::swap(&mut self.document_buffer, &mut outgoing.document_buffer);
std::mem::swap(&mut self.documents, &mut outgoing.documents);
std::mem::swap(&mut self.panels, &mut outgoing.panels);
let incoming = self.parked.get_mut(&id).expect("incoming pane initialized");
std::mem::swap(&mut self.offsets, &mut incoming.offsets);
std::mem::swap(&mut self.document, &mut incoming.document);
std::mem::swap(&mut self.document_buffer, &mut incoming.document_buffer);
std::mem::swap(&mut self.documents, &mut incoming.documents);
std::mem::swap(&mut self.panels, &mut incoming.panels);
self.pane = Some(id);
}
fn activate_document(&mut self, buffer: usize) {
if self.document_buffer == Some(buffer) {
return;
}
if self.document_buffer.is_none() {
self.document_buffer = Some(buffer);
return;
}
let previous = self.document_buffer.expect("current document exists");
std::mem::swap(
&mut self.document,
self.documents.entry(previous).or_default(),
);
std::mem::swap(
&mut self.document,
self.documents.entry(buffer).or_default(),
);
self.document_buffer = Some(buffer);
}
fn draw(&mut self, frame: &mut Frame, editor: &mut Editor) {
let focused = editor.pane_id();
self.activate(focused, editor);
self.parked
.retain(|id, _| editor.pane_buffer_id(*id).is_some());
self.syntax.retain(|id, _| editor.has_buffer(*id));
let area = frame.area();
let area = draw_tab_bar(frame, editor, area);
let multiple = editor.pane_layout().len() > 1;
let chrome = if !multiple && editor.diff().is_some() {
None
} else {
Some(document_layout(area, editor))
};
let full_workspace = chrome.map_or(area, |areas| areas[0]);
let diff_header = editor
.diff()
.filter(|diff| !multiple && diff.phase == Phase::View)
.map(|_| {
Rect::new(
full_workspace.x,
full_workspace.y,
full_workspace.width,
full_workspace.height.min(1),
)
});
let (workspace, dock) = match editor.file_picker() {
Some(picker) if picker.presentation == FilePresentation::Docked => dock_layout(
full_workspace,
if editor.diff().is_some() {
DockSide::Right
} else {
editor.file_explorer_side()
},
picker.focused,
),
_ => (full_workspace, None),
};
let dock = dock.map(|mut panel| {
if let Some(header) = diff_header {
panel.y = panel.y.saturating_add(header.height);
panel.height = panel.height.saturating_sub(header.height);
}
panel
});
editor.set_pane_bounds(pane_rect(workspace));
self.rects.clear();
editor
.pane_layout()
.visit_rects(pane_rect(workspace), |id, area| {
self.rects.push((id, terminal_rect(area)));
});
// Paint focused overlays last, so an adjacent leaf cannot erase them.
for active_pass in [false, true] {
for index in 0..self.rects.len() {
let (id, mut leaf) = self.rects[index];
if (id == focused) != active_pass {
continue;
}
self.activate(id, editor);
self.panels.workspace_managed = true;
if multiple {
let block =
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(if id == focused {
Color::Cyan
} else {
Color::DarkGray
}));
let inner = block.inner(leaf);
frame.render_widget(block, leaf);
leaf = inner;
}
let status = chrome
.filter(|_| id == focused)
.map_or(Rect::default(), |a| a[1]);
let prompt = chrome
.filter(|_| id == focused)
.map_or(Rect::default(), |a| a[2]);
editor.with_render_pane(id, |view| {
self.activate_document(view.buffer_id());
self.documents.retain(|id, _| view.has_buffer(*id));
self.offsets.retain(|id, _| view.has_buffer(*id));
self.document
.continuations
.retain(|id, _| view.has_buffer(*id));
let (top, left) = self.offsets.entry(view.buffer_id()).or_default();
let syntax = self.syntax.entry(view.buffer_id()).or_default();
draw_document(
frame,
view,
[leaf, status, prompt],
area,
top,
left,
syntax,
&mut self.document,
&mut self.panels,
);
});
if let Some(geometry) = &self.panels.diff {
editor.set_diff_pane_bounds(
geometry
.regions
.map(|(area, focus)| (focus, pane_rect(area))),
);
}
}
}
self.activate(focused, editor);
self.panels.workspace_managed = false;
if let Some(header) = diff_header
&& let Some(diff) = editor.diff()
{
draw_diff_header(frame, diff, Some(editor), header);
}
if let Some(panel) = dock
&& let Some(picker) = editor.file_picker()
{
draw_file_list(
frame,
picker,
panel,
editor.file_navigation_hint(),
&mut self.panels,
editor
.diff()
.and_then(|diff| diff.comparison.as_ref())
.map(|comparison| comparison.files.as_slice()),
);
if let Some(geometry) = &mut self.panels.diff {
geometry.regions[0] = (panel, Focus::Files);
editor.set_diff_pane_bounds(
geometry
.regions
.map(|(area, focus)| (focus, pane_rect(area))),
);
}
}
if let Some(diff) = editor.diff().filter(|diff| diff.phase != Phase::View) {
draw_diff(
frame,
diff,
area,
editor.selection_navigation_hint(),
editor.file_navigation_hint(),
&mut self.panels,
Some(editor),
);
} else {
draw_overlays(frame, editor, area, self.panels.caret, &mut self.panels);
}
self.diff_visible = self
.panels
.diff
.iter()
.chain(
self.parked
.iter()
.filter(|(id, _)| editor.pane_layout().contains(**id))
.filter_map(|(_, pane)| pane.panels.diff.as_ref()),
)
.any(|geometry| geometry.viewport.is_some());
}
fn draw_session(&mut self, frame: &mut Frame, session: &mut EditorSession) {
self.draw(frame, session.editor_mut());
if let Some((comparison_id, file, top, left_before, left_after)) = self
.panels
.diff
.as_ref()
.and_then(|geometry| geometry.viewport)
{
session.set_diff_viewport(comparison_id, file, top, left_before, left_after);
}
for (pane_id, _) in &self.rects {
let Some(pane) = self.parked.get(pane_id) else {
continue;
};
if let Some((id, file, top, before, after)) = pane
.panels
.diff
.as_ref()
.and_then(|geometry| geometry.viewport)
{
session.set_diff_viewport(id, file, top, before, after);
}
}
}
// Shift-wheel is the fallback for terminals without native horizontal reports.
// Keep classification shared with popup routing.
fn scroll(&mut self, session: &mut EditorSession, mouse: MouseEvent) -> bool {
if wheel_kind(&mouse).is_none() {
return false;
}
if self.panels.scroll(session, mouse) {
return true;
}
let focused = session.editor().pane_id();
let Some(target) = self.rects.iter().find_map(|(id, area)| {
area.contains(Position::new(mouse.column, mouse.row))
.then_some(*id)
}) else {
return false;
};
self.activate(target, session.editor());
let handled = if self.panels.diff.is_some() {
self.panels.scroll_comparison(session, mouse, target);
true
} else {
session
.editor_mut()
.with_render_pane(target, |editor| self.scroll_document(editor, mouse))
};
self.activate(focused, session.editor());
handled
}
fn scroll_document(&mut self, editor: &Editor, mouse: MouseEvent) -> bool {
let Some(kind) = wheel_kind(&mouse) else {
return false;
};
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, left) = self.offsets.entry(buffer_id).or_default();
if editor.word_wrap() {
if matches!(
kind,
MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight
) {
return true;
}
let continuation = self.document.continuations.entry(buffer_id).or_default();
let mut position = VisualRow {
source: *top,
continuation: *continuation,
};
for _ in 0..3 {
let next = if kind == MouseEventKind::ScrollDown {
next_visual(&mut self.document.wrap, editor.lines(), position)
} else {
previous_visual(&mut self.document.wrap, editor.lines(), position)
};
let Some(next) = next else { break };
position = next;
}
position = clamp_visual_bottom(
&mut self.document.wrap,
editor.lines(),
position,
usize::from(viewport.height),
);
*top = position.source;
*continuation = position.continuation;
} else if matches!(
kind,
MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight
) {
let key = (buffer_id, editor.text_revision());
let columns = match self.document.horizontal_extent {
Some((id, revision, columns)) if (id, revision) == key => columns,
_ => {
let columns = editor
.lines()
.iter()
.map(|line| display_column(line, usize::MAX))
.max()
.unwrap_or(0);
self.document.horizontal_extent = Some((key.0, key.1, columns));
columns
}
};
*left = if kind == MouseEventKind::ScrollRight {
left.saturating_add(3)
} else {
left.saturating_sub(3)
}
.min(
columns
.saturating_add(1)
.saturating_sub(self.document.text_width.max(1)),
);
} else {
let last_page = editor
.lines()
.len()
.saturating_sub(usize::from(viewport.height));
*top = if kind == MouseEventKind::ScrollDown {
top.saturating_add(3)
} else {
top.saturating_sub(3)
}
.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 wheel_kind(mouse: &MouseEvent) -> Option<MouseEventKind> {
Some(match mouse.kind {
MouseEventKind::ScrollUp if mouse.modifiers.contains(KeyModifiers::SHIFT) => {
MouseEventKind::ScrollLeft
}
MouseEventKind::ScrollDown if mouse.modifiers.contains(KeyModifiers::SHIFT) => {
MouseEventKind::ScrollRight
}
kind @ (MouseEventKind::ScrollUp
| MouseEventKind::ScrollDown
| MouseEventKind::ScrollLeft
| MouseEventKind::ScrollRight) => kind,
_ => return 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 colors = TerminalColors::default();
let mut query_theme = true;
let mut redraw = true;
let mut pending = None;
while !session.editor().should_quit() {
redraw |= session.poll();
colors.expire(Instant::now());
if viewports.theme != colors.colors {
viewports.set_theme(colors.colors);
redraw = true;
}
if redraw {
draw_frame(&mut terminal, &mut output, |frame| {
viewports.draw_session(frame, session)
})?;
redraw = false;
}
// Start the reply deadline after painting, not before expensive syntax initialization.
if query_theme || viewports.diff_visible {
let now = Instant::now();
if query_theme || colors.refresh_due(now) {
colors.query(&mut output, now)?;
query_theme = false;
}
}
let input = if let Some(input) = pending.take() {
input
} else {
let Some(input) = next_terminal_event(&mut colors, Duration::from_millis(25))?
else {
continue;
};
input
};
match input {
Event::Key(key) if key.kind != KeyEventKind::Release => {
if session.editor().mode() == Mode::Insert && typing_key(&key) {
viewports.panels.follow_files();
pending = typing_ready(session, key, || {
next_terminal_event(&mut colors, Duration::ZERO)
})?;
redraw = true;
} else 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, || {
next_terminal_event(&mut colors, Duration::ZERO)
})?;
redraw |= changed;
pending = following;
}
Event::Resize(_, _) => redraw = true,
Event::FocusGained | Event::ColorSchemeChanged => query_theme = true,
_ => {}
}
}
Ok(())
})
}
fn next_terminal_event(
colors: &mut TerminalColors,
mut timeout: Duration,
) -> io::Result<Option<Event>> {
colors.expire(Instant::now());
if let Some(input) = colors.pop() {
return Ok(Some(input));
}
while event::poll(timeout)? {
colors.push(event::read()?, Instant::now());
if let Some(input) = colors.pop() {
return Ok(Some(input));
}
timeout = Duration::ZERO;
}
colors.expire(Instant::now());
Ok(colors.pop())
}
fn typing_key(key: &KeyEvent) -> bool {
key.kind != KeyEventKind::Release
&& matches!(key.code, KeyCode::Char(ch) if !ch.is_control())
&& !key.modifiers.intersects(
KeyModifiers::CONTROL
| KeyModifiers::ALT
| KeyModifiers::SUPER
| KeyModifiers::HYPER
| KeyModifiers::META,
)
}
// Coalesce frames, not edits: bindings, snippets and history still see each key.
fn typing_ready(
session: &mut EditorSession,
mut key: KeyEvent,
mut next: impl FnMut() -> io::Result<Option<Event>>,
) -> io::Result<Option<Event>> {
for handled in 1..=64 {
session.handle(translate(key).expect("plain typing key"));
if handled == 64
|| session.editor().mode() != Mode::Insert
|| session.editor().should_quit()
{
return Ok(None);
}
match next()? {
Some(Event::Key(next)) if typing_key(&next) => key = next,
pending => return Ok(pending),
}
}
unreachable!()
}
fn translate(key: KeyEvent) -> Option<Input> {
if key
.modifiers
.intersects(KeyModifiers::SUPER | KeyModifiers::HYPER | KeyModifiers::META)
{
return None;
}
if key.modifiers.contains(KeyModifiers::ALT) {
return match key.code {
KeyCode::Char(c) => Some(if key.modifiers.contains(KeyModifiers::CONTROL) {
Input::CtrlAlt(c.to_ascii_lowercase())
} else {
Input::Alt(c.to_ascii_lowercase())
}),
_ => 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_tab_bar(frame: &mut Frame, editor: &Editor, area: Rect) -> Rect {
if editor.tab_count() <= 1 || area.height == 0 {
return area;
}
let bar = Rect::new(area.x, area.y, area.width, 1);
let workspace = Rect::new(
area.x,
area.y.saturating_add(1),
area.width,
area.height - 1,
);
if bar.width == 0 {
return workspace;
}
let labels: Vec<_> = (0..editor.tab_count())
.map(|index| {
let buffer = editor.tab_buffer(index);
let name = buffer
.as_ref()
.and_then(|buffer| buffer.path)
.map(|path| {
path.file_name()
.unwrap_or(path.as_os_str())
.to_string_lossy()
})
.unwrap_or(Cow::Borrowed("[No Name]"));
display_text(&format!(
"{} {name}{} ",
index + 1,
if buffer.is_some_and(|buffer| buffer.dirty) {
" [+]"
} else {
""
},
))
})
.collect();
let active = editor.tab_index();
let mut first = active;
let padding = u16::from(bar.width > 1);
let fade = usize::from(bar.width) >= labels[active].width() + usize::from(padding) + 6;
let mut width = labels[active].width() + usize::from(padding) + if fade { 6 } else { 0 };
while first > 0 && width + labels[first - 1].width() <= usize::from(bar.width) {
first -= 1;
width += labels[first].width();
}
let inactive = Style::default().fg(Color::Gray);
let mut x = bar.x + padding;
for (index, label) in labels.iter().enumerate().skip(first) {
let decorated = index == active && fade;
let width = (label.width() + if decorated { 6 } else { 0 })
.min(usize::from(bar.right() - x)) as u16;
let highlight = Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let edge = Style::default().fg(Color::Cyan);
let line = if decorated {
Line::from(vec![
Span::styled("░▒▓", edge),
Span::styled(label.as_str(), highlight),
Span::styled("▓▒░", edge),
])
} else {
Line::from(Span::styled(
label.as_str(),
if index == active { highlight } else { inactive },
))
};
frame.render_widget(Paragraph::new(line), Rect::new(x, bar.y, width, 1));
x += width;
if x == bar.right() {
break;
}
}
workspace
}
fn document_layout(area: Rect, editor: &Editor) -> [Rect; 3] {
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);
Layout::vertical([
Constraint::Fill(1),
Constraint::Length(u16::from(area.height >= 3)),
Constraint::Length(u16::from(area.height >= 2 || (prompting && !dock_focused))),
])
.areas(area)
}
#[cfg(test)]
fn draw(
frame: &mut Frame,
editor: &Editor,
top: &mut usize,
left: &mut usize,
syntax: &mut SyntaxCache,
document: &mut DocumentViewport,
panels: &mut WheelPanels,
) {
let area = frame.area();
let area = draw_tab_bar(frame, editor, area);
let areas = if editor.diff().is_some() {
[area, Rect::default(), Rect::default()]
} else {
document_layout(area, editor)
};
draw_document(
frame, editor, areas, area, top, left, syntax, document, panels,
);
}
#[allow(clippy::too_many_arguments)]
fn draw_document(
frame: &mut Frame,
editor: &Editor,
[workspace, status_area, prompt_area]: [Rect; 3],
overlay_area: Rect,
top: &mut usize,
left: &mut usize,
syntax: &mut SyntaxCache,
document: &mut DocumentViewport,
panels: &mut WheelPanels,
) {
panels.begin_frame();
document.geometry = None;
let area = workspace;
let focused = editor.render_pane_focused();
if let Some(diff) = editor.diff() {
draw_status(frame, editor, syntax, status_area);
if panels.workspace_managed && diff.phase != Phase::View {
return;
}
draw_diff(
frame,
diff,
area,
editor.selection_navigation_hint(),
editor.file_navigation_hint(),
panels,
Some(editor),
);
return;
}
panels.diff_syntax = None;
if overlay_area.width == 0 || overlay_area.height == 0 {
return;
}
let prompting = matches!(editor.mode(), Mode::Command | Mode::Search);
let (viewport, dock) = match editor.file_picker() {
Some(picker)
if !panels.workspace_managed
&& focused
&& 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);
document.text_width = width;
let cursor = editor.cursor();
let mut document_caret = None;
let carets = editor.carets();
let selections = editor.selections();
let wrapped = editor.word_wrap();
let presentation = (editor.buffer_id(), wrapped, width, height);
let identity = (editor.buffer_id(), editor.text_revision(), cursor);
if document.anchor != Some(identity)
|| document
.presentation
.is_some_and(|previous| previous.2 != width || previous.3 != height)
{
document.anchor = None;
}
let toggle_row = document
.presentation
.filter(|previous| previous.0 == editor.buffer_id() && previous.1 != wrapped)
.and_then(|previous| {
let column = display_column(&editor.lines()[cursor.row], cursor.column);
let part = if previous.1 {
caret_segment(
document
.wrap
.segments(cursor.row, &editor.lines()[cursor.row]),
column,
)
} else {
0
};
document
.visible
.iter()
.position(|&(row, continuation, segment)| {
row == cursor.row
&& continuation == part
&& (previous.1 || (column >= segment.start && column < segment.end))
})
})
.map(|row| row.min(height.saturating_sub(1)));
if toggle_row.is_some() {
document.anchor = Some(identity);
}
if document.presentation.is_some_and(|previous| {
previous.0 != presentation.0
|| previous.1 != wrapped
|| (wrapped && previous != presentation)
}) {
document.manual = None;
}
document.presentation = Some(presentation);
document.visible.clear();
if !focused && let Some(manual) = &mut document.manual {
// Shared text edits alone must not pull an untouched pane back to its caret.
manual.revision = editor.text_revision();
manual.mode = editor.mode();
manual.cursor = cursor;
if manual.carets != carets {
manual.carets.clone_from(&carets);
}
if manual.selections != selections {
manual.selections.clone_from(&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,
),
));
if !wrapped {
let limit = if document.anchor.is_some() {
editor.lines().len() - 1
} else {
editor.lines().len().saturating_sub(height)
};
*top = (*top).min(limit);
if let Some(row) = toggle_row {
*top = cursor.row.saturating_sub(row);
}
if document.manual.is_some()
&& let Some((id, revision, columns)) = document.horizontal_extent
&& id == editor.buffer_id()
&& revision == editor.text_revision()
{
*left = (*left).min(columns.saturating_add(1).saturating_sub(width.max(1)));
}
}
if height > 0 && width > 0 {
if wrapped {
document
.wrap
.prepare((editor.buffer_id(), editor.text_revision()), width);
let continuation = document
.continuations
.entry(editor.buffer_id())
.or_default();
*top = (*top).min(editor.lines().len() - 1);
*continuation =
(*continuation).min(document.wrap.segments(*top, &editor.lines()[*top]).len() - 1);
let mut start = VisualRow {
source: *top,
continuation: *continuation,
};
if document.manual.is_none() {
let column = display_column(&editor.lines()[cursor.row], cursor.column);
let caret = VisualRow {
source: cursor.row,
continuation: caret_segment(
document
.wrap
.segments(cursor.row, &editor.lines()[cursor.row]),
column,
),
};
if let Some(row) = toggle_row {
start = caret;
for _ in 0..row {
let Some(previous) =
previous_visual(&mut document.wrap, editor.lines(), start)
else {
break;
};
start = previous;
}
} else if caret < start {
start = caret;
} else if caret > visual_page(&mut document.wrap, editor.lines(), start, height).0 {
start = caret;
for _ in 1..height {
let Some(previous) =
previous_visual(&mut document.wrap, editor.lines(), start)
else {
break;
};
start = previous;
}
}
}
if document.anchor.is_none() {
start = clamp_visual_bottom(&mut document.wrap, editor.lines(), start, height);
}
*top = start.source;
*continuation = start.continuation;
let mut position = start;
for _ in 0..height {
let segment = document
.wrap
.segments(position.source, &editor.lines()[position.source])
[position.continuation];
document
.visible
.push((position.source, position.continuation, segment));
let Some(next) = next_visual(&mut document.wrap, editor.lines(), position) else {
break;
};
position = next;
}
} else {
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;
}
}
document.visible.extend(
(*top..top.saturating_add(height).min(editor.lines().len())).map(|row| {
(
row,
0,
Segment {
start: *left,
end: left.saturating_add(width),
},
)
}),
);
}
let last = document.visible.last().map_or(*top, |(row, _, _)| *row);
syntax.update_visible(
editor.lines(),
editor.path(),
editor.text_revision(),
std::slice::from_ref(&(*top..last + 1)),
);
if gutter.width > 0 {
let numbers: Vec<Line> = document
.visible
.iter()
.map(|&(row, continuation, _)| {
if continuation > 0 {
return Line::default();
}
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> = document
.visible
.iter()
.map(|&(row, _, segment)| {
Line::from(visible_line(
&editor.lines()[row],
segment.start,
segment.end.saturating_sub(segment.start).min(width),
))
})
.collect();
frame.render_widget(Paragraph::new(lines), viewport);
for (screen_row, &(row, continuation, segment)) in document.visible.iter().enumerate() {
let line = &editor.lines()[row];
let cells: usize = if wrapped {
segment
.end
.saturating_sub(segment.start)
.saturating_add(usize::from(
continuation + 1 == document.wrap.segments(row, line).len(),
))
.min(width)
} else {
width
};
let area = Rect::new(viewport.x, viewport.y + screen_row as u16, cells as u16, 1);
paint_syntax_line(frame, line, syntax.line(row), area, segment.start);
paint_semantic_line(
frame,
line,
editor.semantic_tokens(),
row,
area,
segment.start,
);
paint_editor_line(
frame,
editor,
row,
area,
segment.start,
&carets,
&selections,
);
}
}
draw_status(frame, editor, syntax, 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 focused && !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);
let continuation = if wrapped {
caret_segment(
document
.wrap
.segments(cursor.row, &editor.lines()[cursor.row]),
column,
)
} else {
0
};
if let Some((screen_row, (_, _, segment))) =
document
.visible
.iter()
.enumerate()
.find(|(_, (row, part, segment))| {
*row == cursor.row
&& *part == continuation
&& column >= segment.start
&& column < segment.start.saturating_add(width)
})
{
let position = Position::new(
viewport.x + (column - segment.start) as u16,
viewport.y + screen_row as u16,
);
if focused {
frame.set_cursor_position(position);
}
document_caret = Some(position);
}
}
}
if !focused {
return;
}
panels.caret = document_caret.map(|caret| (viewport, caret));
if !panels.workspace_managed {
if let Some(panel) = dock
&& let Some(picker) = editor.file_picker()
{
draw_file_list(
frame,
picker,
panel,
editor.file_navigation_hint(),
panels,
None,
);
}
draw_overlays(frame, editor, overlay_area, panels.caret, panels);
}
}
fn draw_overlays(
frame: &mut Frame,
editor: &Editor,
area: Rect,
caret: Option<(Rect, Position)>,
panels: &mut WheelPanels,
) {
if let Some(completion) = editor.completion()
&& let Some((_, caret)) = caret
{
panels.completion = draw_completion(frame, completion, area, caret);
}
if let Some(picker) = editor.file_picker()
&& 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,
editor
.diff()
.and_then(|diff| diff.comparison.as_ref())
.map(|comparison| comparison.files.as_slice()),
);
}
if let Some(palette) = editor.palette() {
draw_palette(
frame,
palette,
area,
editor.mode(),
editor.selection_navigation_hint(),
);
}
if let Some(hover) = editor.hover() {
draw_hover(frame, hover, area, panels);
}
if let Some(diagnostic) = editor.startup_diagnostic() {
draw_startup_diagnostic(frame, diagnostic, area);
}
}
fn draw_status(frame: &mut Frame, editor: &Editor, syntax: &SyntaxCache, status_area: Rect) {
if status_area.width == 0 || status_area.height == 0 {
return;
}
let cursor = editor.cursor();
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())
};
let mode_end = status.len();
if let Some(sequence) = editor.sequence_pending() {
write!(status, " {sequence}").unwrap();
}
if editor.g_pending() {
status.push_str(" g");
}
if let Some(operator) = editor.line_operator_pending() {
write!(status, " {operator}").unwrap();
}
if let Some(seek) = editor.seek_pending() {
write!(status, " {seek}").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 { "" },
)
.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();
}
let highlight = Style::default().fg(Color::Black).bg(Color::Cyan);
let edge = Style::default().fg(Color::Cyan);
let position = format!(" {}:{} ", cursor.row + 1, cursor.column + 1);
let right_width = (position.width() + 3) as u16;
let show_right = status_area.width >= right_width + 12;
let left = Rect::new(
status_area.x,
status_area.y,
status_area.width - if show_right { right_width } else { 0 },
status_area.height,
);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(&status[..mode_end], highlight),
Span::styled("▓▒░", edge),
Span::raw(&status[mode_end..]),
])),
left,
);
if show_right {
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled("░▒▓", edge),
Span::styled(position, highlight),
])),
Rect::new(left.right(), status_area.y, right_width, status_area.height),
);
}
}
struct DiffGeometry {
regions: [(Rect, Focus); 3],
viewport: Option<(u64, usize, usize, usize, usize)>,
text_widths: [u16; 2],
wrapping: bool,
}
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 paint_cells(frame: &mut Frame, area: Rect, left: usize, start: usize, end: usize, style: Style) {
if area.height == 0 {
return;
}
for column in start.max(left)..end.min(left.saturating_add(usize::from(area.width))) {
frame.buffer_mut()[(area.x + (column - left) as u16, area.y)].set_style(style);
}
}
fn paint_grapheme_range(
frame: &mut Frame,
area: Rect,
left: usize,
line: &str,
range: std::ops::Range<usize>,
style: Style,
) {
let right = left.saturating_add(usize::from(area.width));
let mut column = 0;
let mut 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 range.contains(&index) && (grapheme == "\t" || (column >= left && next <= right)) {
paint_cells(frame, area, left, column, next, style);
}
column = next;
count = index + 1;
if column >= right {
break;
}
}
if range.start <= count && range.end > count {
paint_cells(frame, area, left, column, column.saturating_add(1), style);
}
}
fn paint_semantic_line(
frame: &mut Frame,
line: &str,
semantic: &[SemanticToken],
row: usize,
area: Rect,
left: usize,
) {
let mut token = semantic.partition_point(|token| token.start.row < row);
let mut token_style = None;
let mut column = 0;
let right = left.saturating_add(usize::from(area.width));
for (index, grapheme) in line.graphemes(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 token < semantic.len()
&& semantic[token].start.row == row
&& semantic[token].end.column <= index
{
token += 1;
token_style = None;
}
if let Some(item) = semantic
.get(token)
.filter(|token| token.start.row == row && token.start.column <= index)
&& (grapheme == "\t" || (column >= left && end <= right))
{
let style = *token_style.get_or_insert_with(|| semantic_style(item));
paint_cells(frame, area, left, column, end, style);
}
column = end;
}
}
fn paint_editor_line(
frame: &mut Frame,
editor: &Editor,
row: usize,
area: Rect,
left: usize,
carets: &[Cursor],
selections: &[Selection],
) {
let Some(line) = editor.lines().get(row) else {
return;
};
for severity in [
DiagnosticSeverity::Hint,
DiagnosticSeverity::Information,
DiagnosticSeverity::Warning,
DiagnosticSeverity::Error,
] {
for diagnostic in editor
.diagnostics()
.filter(|item| item.severity == severity && diagnostic_on_row(item, row))
{
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 style = Style::default()
.underline_color(diagnostic_severity(severity).1)
.add_modifier(Modifier::UNDERLINED);
paint_grapheme_range(frame, area, left, line, start..end, style);
}
}
if let Some(found) = editor.search_match().filter(|found| found.start.row == row) {
paint_cells(
frame,
area,
left,
display_column(line, found.start.column),
display_column(line, found.end_column),
Style::default().fg(Color::Black).bg(Color::Yellow),
);
}
let selection_style = Style::default().fg(Color::White).bg(Color::Blue);
if editor.linewise_rows().any(|rows| rows.contains(&row)) {
paint_cells(
frame,
area,
left,
left,
display_column(line, usize::MAX).saturating_add(1),
selection_style,
);
}
for selection in selections
.iter()
.filter(|_| !editor.visual_linewise())
.chain(editor.snippet_selections())
{
if selection.start.row <= row && row <= selection.end.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).saturating_add(1)
};
paint_cells(frame, area, left, start, end, selection_style);
}
}
for caret in carets.iter().filter(|caret| {
editor.render_pane_focused() && caret.row == row && **caret != editor.cursor()
}) {
let column = display_column(line, caret.column);
paint_cells(
frame,
area,
left,
column,
column.saturating_add(1),
Style::default().fg(Color::Black).bg(Color::Magenta),
);
}
}
fn paint_diff_editor(
frame: &mut Frame,
editor: &Editor,
diff: &DiffState,
area: Rect,
focus: Focus,
viewport: (usize, usize),
wrapped: Option<&[WrappedDiffRow]>,
) -> Option<Position> {
let (top, left) = viewport;
let carets = editor.carets();
let selections = editor.selections();
let source_cursor = editor.cursor();
let cursor_column = display_column(&editor.lines()[source_cursor.row], source_cursor.column);
let mut cursor = None;
for (screen, display, segments) in diff_screen_rows(diff, top, area.height, wrapped) {
let DisplayRow::Line(aligned) = diff.display_rows[display] else {
continue;
};
let Some(source) = (match focus {
Focus::Before => diff.rows[aligned].before,
Focus::After => diff.rows[aligned].after,
Focus::Files => None,
}) else {
continue;
};
let mut line_area = Rect::new(area.x, area.y + screen as u16, area.width, 1);
let segment = segments.and_then(|segments| segments[usize::from(focus == Focus::After)]);
if segments.is_some() && segment.is_none() {
continue;
}
let left = segment.map_or(left, |segment| segment.start);
if let Some(segment) = segment {
let final_segment = wrapped.is_some_and(|rows| {
rows[screen].final_segments[usize::from(focus == Focus::After)]
});
line_area.width =
segment_paint_width(segment, final_segment, usize::from(area.width)) as u16;
}
paint_editor_line(frame, editor, source, line_area, left, &carets, &selections);
if source == source_cursor.row && editor.diff_interacting() {
let column = cursor_column;
if column >= left && column < left.saturating_add(usize::from(line_area.width)) {
cursor = Some(Position::new(area.x + (column - left) as u16, line_area.y));
}
}
}
if let Some(position) = cursor.filter(|_| editor.render_pane_focused())
&& !matches!(editor.mode(), Mode::Command | Mode::Search)
&& editor.palette().is_none()
{
frame.set_cursor_position(position);
}
cursor
}
fn draw_diff_popups(
frame: &mut Frame,
editor: &Editor,
area: Rect,
caret: Option<(Rect, Position)>,
panels: &mut WheelPanels,
) {
if !editor.render_pane_focused() {
return;
}
if area.width > 0 && area.height > 0 {
let footer = Rect::new(area.x, area.bottom() - 1, area.width, 1);
if matches!(editor.mode(), Mode::Command | Mode::Search) {
let prefix = if editor.mode() == Mode::Search {
'/'
} else {
':'
};
let command = format!("{prefix}{}", editor.command_line());
let column = display_column(&command, usize::MAX);
let offset = column.saturating_sub(usize::from(area.width).saturating_sub(1));
frame.render_widget(Clear, footer);
frame.render_widget(
Paragraph::new(visible_line(&command, offset, usize::from(area.width))),
footer,
);
frame.set_cursor_position((footer.x + (column - offset) as u16, footer.y));
} else if !editor.message().is_empty() {
frame.render_widget(Clear, footer);
frame.render_widget(Paragraph::new(display_text(editor.message())), footer);
}
}
panels.caret = caret;
if !panels.workspace_managed {
draw_overlays(frame, editor, area, caret, panels);
}
}
fn draw_diff_header(frame: &mut Frame, diff: &DiffState, editor: Option<&Editor>, header: Rect) {
if header.width == 0 || header.height == 0 {
return;
}
let (before, after) = diff.comparison.as_ref().map_or(("", ""), |comparison| {
(comparison.before.as_str(), comparison.after.as_str())
});
frame.render_widget(
Paragraph::new(format!(
"Diff · {} → {} · {:?} focus · {}",
display_text(before),
display_text(after),
diff.focus,
editor
.filter(|editor| editor.diff_interacting())
.map_or_else(
|| "read-only".into(),
|editor| format!(
"{} · {}{} · {}:{}",
if editor.diff_editing() {
"editable"
} else {
"read-only"
},
if editor.visual_linewise() {
"VISUAL LINE".into()
} else {
editor.mode().to_string()
},
if editor.dirty() { " [+]" } else { "" },
editor.cursor().row + 1,
editor.cursor().column + 1,
),
)
))
.style(Style::default().fg(Color::Black).bg(Color::Cyan)),
header,
);
}
fn draw_diff(
frame: &mut Frame,
diff: &DiffState,
area: Rect,
navigation_hint: &str,
file_navigation_hint: &str,
panels: &mut WheelPanels,
editor: Option<&Editor>,
) {
let comparison = diff.comparison.as_ref();
let selected = comparison.and_then(|comparison| comparison.files.get(diff.file));
if diff.phase != Phase::View || !diff.selected_file_loaded() {
panels.diff_syntax = None;
}
if area.width == 0 || area.height == 0 {
return;
}
if diff.phase != Phase::View {
let panel = floating_panel(area);
frame.render_widget(Clear, panel);
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
&& editor.is_none_or(|editor| editor.render_pane_focused())
{
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/clear · :diff.close"
))
.style(Style::default().fg(Color::DarkGray)),
help,
);
panels.diff = Some(DiffGeometry {
regions: [
(panel, Focus::Files),
(Rect::default(), Focus::Before),
(Rect::default(), Focus::After),
],
viewport: None,
text_widths: [0; 2],
wrapping: diff.word_wrap,
});
if let Some(editor) = editor {
draw_diff_popups(frame, editor, area, None, panels);
}
return;
}
frame.render_widget(Clear, area);
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 file…")
} 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);
if !panels.workspace_managed || editor.is_none_or(|editor| editor.pane_layout().len() > 1) {
draw_diff_header(frame, diff, editor, 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 !panels.workspace_managed
&& diff.files_visible
&& diff
.files
.as_ref()
.is_some_and(|files| files.presentation == FilePresentation::Docked)
{
dock_layout(workspace, DockSide::Right, diff.focus == Focus::Files)
} else {
(workspace, None)
};
let pane_layout = diff.pane_layout();
let [before_area, after_area] = match pane_layout {
PaneLayout::Split => split_rect(pane_rect(panes), SplitAxis::Vertical).map(terminal_rect),
PaneLayout::Before | PaneLayout::Empty => [panes, Rect::default()],
PaneLayout::After => [Rect::default(), panes],
};
let mut document_caret = None;
let mut top = diff.top;
let mut before_left = diff.left_before;
let mut after_left = diff.left_after;
let wrapping = diff.word_wrap;
let toggle_row = panels
.previous_diff
.as_ref()
.filter(|previous| {
previous.wrapping != wrapping
&& previous.viewport.is_some_and(|(id, file, _, _, _)| {
Some(id) == diff.comparison_id && file == diff.file
})
})
.and_then(|previous| {
let editor = editor?;
let focus = editor.diff_buffer_focus()?;
let cursor = editor.cursor();
if previous.wrapping {
let file = selected?;
let viewport = panels.diff_wrap.as_mut()?;
let position = viewport.caret_position(diff, file, focus, cursor)?;
viewport
.rows
.iter()
.position(|row| row.position == position)
} else {
let display = diff.source_display_row(focus, cursor.row)?;
let row = display.checked_sub(previous.viewport?.2)?;
let side = usize::from(focus == Focus::After) + 1;
(row < usize::from(diff_inner(previous.regions[side].0).height)).then_some(row)
}
});
if wrapping && let Some(file) = selected.filter(|file| file.loaded) {
let caret = editor
.filter(|editor| editor.diff_interacting() && diff.follow_caret)
.and_then(|editor| {
editor
.diff_buffer_focus()
.map(|focus| (focus, editor.cursor()))
});
let viewport = panels
.diff_wrap
.get_or_insert_with(DiffWrapViewport::default);
viewport.update(diff, file, [before_area, after_area], caret, toggle_row);
top = viewport.top.display;
} else {
panels.diff_wrap = None;
}
if let Some(editor) =
editor.filter(|editor| !wrapping && editor.diff_interacting() && diff.follow_caret)
&& let Some(focus) = editor.diff_buffer_focus()
{
let (pane, left) = match focus {
Focus::Before => (before_area, &mut before_left),
Focus::After => (after_area, &mut after_left),
Focus::Files => unreachable!("native diff buffers belong to a text pane"),
};
let height = usize::from(pane.height.saturating_sub(2));
if let Some(row) = diff.source_display_row(focus, editor.cursor().row) {
if let Some(anchor) = toggle_row {
top = row.saturating_sub(anchor.min(height.saturating_sub(1)));
}
if row < top {
top = row;
} else if height > 0 && row >= top.saturating_add(height) {
top = row + 1 - height;
}
}
let source_len = selected.map_or(0, |file| match focus {
Focus::Before => file.before.len(),
Focus::After => file.after.len(),
Focus::Files => unreachable!("native buffer belongs to a text pane"),
});
let digits = source_len.max(1).ilog10() as usize + 1;
let content_width = usize::from(pane.width.saturating_sub(2));
let gutter = if content_width > digits + 2 {
digits + 2
} else {
0
};
let width = content_width.saturating_sub(gutter);
let column = display_column(&editor.lines()[editor.cursor().row], editor.cursor().column);
if column < *left {
*left = column;
} else if width > 0 && column >= left.saturating_add(width) {
*left = column + 1 - width;
}
}
if let Some(id) = diff.comparison_id
&& let Some(file) = selected.filter(|file| file.loaded)
{
let wrapped = panels
.diff_wrap
.as_ref()
.map(|viewport| viewport.rows.as_slice());
panels
.diff_syntax
.get_or_insert_with(DiffSyntaxCache::default)
.update(
id,
diff.file,
file,
diff.live_revision,
Some(DiffSyntaxViewport {
diff,
top,
areas: [before_area, after_area],
wrapped,
}),
);
}
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()),
);
}
let mut text_widths = [0; 2];
let previous_text_widths = panels
.previous_diff
.as_ref()
.filter(|geometry| {
geometry.viewport.is_some_and(|(id, file, _, _, _)| {
Some(id) == diff.comparison_id && file == diff.file
})
})
.map(|geometry| geometry.text_widths);
let wrapped = panels
.diff_wrap
.as_ref()
.map(|viewport| viewport.rows.as_slice());
if let Some(file) = selected.filter(|file| file.loaded)
&& let Some(syntax) = &panels.diff_syntax
{
if pane_layout == PaneLayout::Empty {
diff_panel(frame, before_area, "", diff.focus == Focus::Before);
} else {
if before_area != Rect::default() {
let text_area = draw_diff_pane(
frame,
diff,
file,
before_area,
true,
&syntax.before,
editor.map_or(&[], |editor| editor.diff_semantic_tokens(Focus::Before)),
(
top,
&mut before_left,
previous_text_widths.map(|widths| widths[0]),
),
wrapped,
panels.diff_colors,
);
text_widths[0] = text_area.width;
if let Some(editor) =
editor.filter(|editor| editor.diff_buffer_focus() == Some(Focus::Before))
{
document_caret = paint_diff_editor(
frame,
editor,
diff,
text_area,
Focus::Before,
(top, before_left),
wrapped,
)
.map(|position| (text_area, position));
}
}
if after_area != Rect::default() {
let text_area = draw_diff_pane(
frame,
diff,
file,
after_area,
false,
&syntax.after,
editor.map_or(&[], |editor| editor.diff_semantic_tokens(Focus::After)),
(
top,
&mut after_left,
previous_text_widths.map(|widths| widths[1]),
),
wrapped,
panels.diff_colors,
);
text_widths[1] = text_area.width;
if let Some(editor) =
editor.filter(|editor| editor.diff_buffer_focus() == Some(Focus::After))
{
document_caret = paint_diff_editor(
frame,
editor,
diff,
text_area,
Focus::After,
(top, after_left),
wrapped,
)
.map(|position| (text_area, position));
}
}
}
} else {
frame.render_widget(
Paragraph::new(if diff.loading {
"Loading file…"
} else if selected.is_some() {
"File details unavailable · select the file to retry"
} else {
"No changed files"
}),
panes,
);
}
frame.render_widget(
Paragraph::new(
"Native buffer bindings · Tab focus · [/] expand above/below in Normal · :diff.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),
],
viewport: diff
.comparison_id
.map(|id| (id, diff.file, top, before_left, after_left)),
text_widths,
wrapping,
});
if let Some(editor) = editor {
draw_diff_popups(frame, editor, area, document_caret, panels);
}
}
fn diff_status(status: FileStatus) -> &'static str {
match status {
FileStatus::Added => "A",
FileStatus::Removed => "D",
FileStatus::Modified => "M",
FileStatus::Renamed => "R",
}
}
#[allow(clippy::too_many_arguments)]
fn draw_diff_pane(
frame: &mut Frame,
diff: &DiffState,
file: &ChangedFile,
area: Rect,
before: bool,
syntax: &SyntaxCache,
semantic: &[SemanticToken],
viewport: (usize, &mut usize, Option<u16>),
wrapped: Option<&[WrappedDiffRow]>,
colors: DiffColors,
) -> Rect {
if area.width == 0 || area.height == 0 {
return area;
}
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 content;
}
let source = if before { &file.before } else { &file.after };
let (top, left, previous_width) = viewport;
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);
if wrapped.is_none() && previous_width.is_some_and(|previous| usize::from(previous) != width) {
*left = diff.bound_horizontal(focus, *left, width);
}
let left = *left;
for (screen, display_row, segments) in diff_screen_rows(diff, top, content.height, wrapped) {
let row = &diff.display_rows[display_row];
let row_area = Rect::new(content.x, content.y + screen 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 segment = segments.and_then(|segments| segments[usize::from(!before)]);
if segments.is_some() && segment.is_none() {
continue;
}
let left = segment.map_or(left, |segment| segment.start);
let text_width = segment.map_or(width, |segment| segment.end - segment.start);
let paint_width = segment.map_or(width, |segment| {
let final_segment =
wrapped.is_some_and(|rows| rows[screen].final_segments[usize::from(!before)]);
segment_paint_width(segment, final_segment, width)
});
let text_area = Rect::new(content.x + gutter as u16, row_area.y, paint_width as u16, 1);
let changed = matches!(row.kind, RowKind::Changed)
|| (before && row.kind == RowKind::Removed)
|| (!before && row.kind == RowKind::Added);
let background =
if changed && !matches!(file.status, FileStatus::Added | FileStatus::Removed) {
colors.row[usize::from(!before)]
} 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, text_width.min(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 && wrapped.is_none_or(|rows| rows[screen].position.continuation == 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), text_area, left);
paint_semantic_line(frame, source_text, semantic, index, text_area, left);
if !matches!(file.status, FileStatus::Added | FileStatus::Removed) {
let style = Style::default().bg(colors.character[usize::from(!before)]);
for change in &row.changes {
let range = &change[usize::from(!before)];
if !range.is_empty() {
paint_grapheme_range(frame, text_area, left, source_text, range.clone(), style);
}
}
}
}
Rect::new(
content.x + gutter as u16,
content.y,
width as u16,
content.height,
)
}
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));
}
}
fn segment_paint_width(segment: crate::wrap::Segment, final_segment: bool, width: usize) -> usize {
let cells = segment.end - segment.start + usize::from(final_segment);
width.min(cells.max(1))
}
#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
struct DiffWrapPosition {
display: usize,
continuation: usize,
}
#[derive(Clone, Copy)]
struct WrappedDiffRow {
position: DiffWrapPosition,
segments: [Option<crate::wrap::Segment>; 2],
final_segments: [bool; 2],
}
#[derive(Default)]
struct DiffWrapViewport {
key: Option<(u64, usize, Option<u64>)>,
revision: u64,
before: crate::wrap::WrapCache,
after: crate::wrap::WrapCache,
widths: [usize; 2],
top: DiffWrapPosition,
rows: Vec<WrappedDiffRow>,
}
fn diff_inner(area: Rect) -> Rect {
if area.width >= 4 && area.height >= 4 {
Rect::new(area.x + 1, area.y + 1, area.width - 2, area.height - 2)
} else {
area
}
}
fn diff_text_width(area: Rect, source_len: usize) -> usize {
let width = usize::from(diff_inner(area).width);
let digits = source_len.max(1).ilog10() as usize + 1;
width.saturating_sub(if width > digits + 2 { digits + 2 } else { 0 })
}
impl DiffWrapViewport {
fn row(
&mut self,
diff: &DiffState,
file: &ChangedFile,
position: DiffWrapPosition,
) -> WrappedDiffRow {
let mut segments = [None; 2];
let mut final_segments = [false; 2];
if let Some(DisplayRow::Line(index)) = diff.display_rows.get(position.display) {
let aligned = &diff.rows[*index];
for (side, source) in [aligned.before, aligned.after].into_iter().enumerate() {
if let Some(source) = source.filter(|_| self.widths[side] > 0) {
let (cache, lines) = if side == 0 {
(&mut self.before, &file.before)
} else {
(&mut self.after, &file.after)
};
let text = lines.get(source).map_or("", String::as_str);
let parts = cache.segments(source, text);
segments[side] = parts.get(position.continuation).copied();
final_segments[side] = position.continuation + 1 == parts.len();
}
}
}
WrappedDiffRow {
position,
segments,
final_segments,
}
}
fn height(&mut self, diff: &DiffState, file: &ChangedFile, display: usize) -> usize {
let Some(DisplayRow::Line(index)) = diff.display_rows.get(display) else {
return 1;
};
let aligned = &diff.rows[*index];
let mut height = 1;
for (side, source) in [aligned.before, aligned.after].into_iter().enumerate() {
if let Some(source) = source.filter(|_| self.widths[side] > 0) {
let (cache, lines) = if side == 0 {
(&mut self.before, &file.before)
} else {
(&mut self.after, &file.after)
};
height = height.max(
cache
.segments(source, lines.get(source).map_or("", String::as_str))
.len(),
);
}
}
height
}
fn step(
&mut self,
diff: &DiffState,
file: &ChangedFile,
mut position: DiffWrapPosition,
down: bool,
) -> DiffWrapPosition {
if down {
if position.continuation + 1 < self.height(diff, file, position.display) {
position.continuation += 1;
} else if position.display + 1 < diff.display_rows.len() {
position.display += 1;
position.continuation = 0;
}
} else if position.continuation > 0 {
position.continuation -= 1;
} else if position.display > 0 {
position.display -= 1;
position.continuation = self.height(diff, file, position.display) - 1;
}
position
}
fn fill(&mut self, diff: &DiffState, file: &ChangedFile, height: usize) {
self.rows.clear();
if diff.display_rows.is_empty() {
return;
}
let mut position = self.top;
for _ in 0..height {
let row = self.row(diff, file, position);
self.rows.push(row);
let next = self.step(diff, file, position, true);
if next == position {
break;
}
position = next;
}
}
fn caret_position(
&mut self,
diff: &DiffState,
file: &ChangedFile,
focus: Focus,
cursor: Cursor,
) -> Option<DiffWrapPosition> {
let display = diff.source_display_row(focus, cursor.row)?;
let (cache, source) = if focus == Focus::Before {
(&mut self.before, &file.before)
} else {
(&mut self.after, &file.after)
};
let text = source.get(cursor.row).map_or("", String::as_str);
let column = display_column(text, cursor.column);
let continuation = caret_segment(cache.segments(cursor.row, text), column);
Some(DiffWrapPosition {
display,
continuation,
})
}
fn update(
&mut self,
diff: &DiffState,
file: &ChangedFile,
areas: [Rect; 2],
caret: Option<(Focus, Cursor)>,
anchor_row: Option<usize>,
) {
let Some(id) = diff.comparison_id else { return };
let key = Some((id, diff.file, diff.live_revision));
if self.key.map(|(id, file, _)| (id, file)) != Some((id, diff.file))
|| self.top.display != diff.top
{
self.top = DiffWrapPosition {
display: diff.top,
continuation: 0,
};
}
if self.key != key {
self.key = key;
self.revision = self
.revision
.checked_add(1)
.expect("wrap revisions exhausted");
}
self.widths = [
diff_text_width(areas[0], file.before.len()),
diff_text_width(areas[1], file.after.len()),
];
self.before.prepare((diff.file, id), self.widths[0]);
self.after
.prepare((diff.file, self.revision), self.widths[1]);
self.top.display = self
.top
.display
.min(diff.display_rows.len().saturating_sub(1));
self.top.continuation = self
.top
.continuation
.min(self.height(diff, file, self.top.display) - 1);
let height = usize::from(diff_inner(areas[0]).height.max(diff_inner(areas[1]).height));
self.fill(diff, file, height);
if let Some((focus, cursor)) = caret
&& let Some(position) = self.caret_position(diff, file, focus, cursor)
{
if let Some(row) = anchor_row {
self.top = position;
for _ in 0..row.min(height.saturating_sub(1)) {
self.top = self.step(diff, file, self.top, false);
}
self.fill(diff, file, height);
} else if position < self.top {
self.top = position;
self.fill(diff, file, height);
} else if !self.rows.iter().any(|row| row.position == position) {
self.top = position;
for _ in 1..height {
self.top = self.step(diff, file, self.top, false);
}
self.fill(diff, file, height);
}
}
}
}
fn diff_screen_rows<'a>(
diff: &'a DiffState,
top: usize,
height: u16,
wrapped: Option<&'a [WrappedDiffRow]>,
) -> impl Iterator<Item = (usize, usize, Option<[Option<crate::wrap::Segment>; 2]>)> + 'a {
(0..usize::from(height)).filter_map(move |screen| {
if let Some(rows) = wrapped {
rows.get(screen)
.map(|row| (screen, row.position.display, Some(row.segments)))
} else {
(top + screen < diff.display_rows.len()).then_some((screen, top + screen, None))
}
})
}
struct DiffSyntaxViewport<'a> {
diff: &'a DiffState,
top: usize,
areas: [Rect; 2],
wrapped: Option<&'a [WrappedDiffRow]>,
}
#[derive(Default)]
struct DiffSyntaxCache {
key: Option<(u64, usize, Option<u64>)>,
revision: u64,
before_revision: u64,
before: SyntaxCache,
after: SyntaxCache,
before_rows: Vec<std::ops::Range<usize>>,
after_rows: Vec<std::ops::Range<usize>>,
}
impl DiffSyntaxCache {
fn update(
&mut self,
comparison_id: u64,
file_index: usize,
file: &ChangedFile,
live_revision: Option<u64>,
visible: Option<DiffSyntaxViewport<'_>>,
) {
let key = Some((comparison_id, file_index, live_revision));
if self.key.map(|(id, index, _)| (id, index)) != Some((comparison_id, file_index)) {
self.before_revision = self
.before_revision
.checked_add(1)
.expect("diff syntax revisions exhausted");
}
if self.key != key {
self.key = key;
self.revision = self
.revision
.checked_add(1)
.expect("diff syntax revisions exhausted");
}
if let Some(DiffSyntaxViewport {
diff,
top,
areas,
wrapped,
}) = visible
{
diff_visible_rows(diff, top, areas[0], true, &mut self.before_rows, wrapped);
diff_visible_rows(diff, top, areas[1], false, &mut self.after_rows, wrapped);
self.before.update_visible(
&file.before,
Some(file.old_path.as_deref().unwrap_or(&file.path)),
self.before_revision,
&self.before_rows,
);
self.after.update_visible(
&file.after,
Some(&file.path),
self.revision,
&self.after_rows,
);
} else {
self.before.update(
&file.before,
Some(file.old_path.as_deref().unwrap_or(&file.path)),
self.before_revision,
);
self.after
.update(&file.after, Some(&file.path), self.revision);
}
}
}
fn diff_visible_rows(
diff: &DiffState,
top: usize,
pane: Rect,
before: bool,
ranges: &mut Vec<std::ops::Range<usize>>,
wrapped: Option<&[WrappedDiffRow]>,
) {
ranges.clear();
let height = if pane.width >= 4 && pane.height >= 4 {
pane.height - 2
} else {
pane.height
};
let count = wrapped.map_or(usize::from(height), |rows| {
rows.last().map_or(0, |row| row.position.display - top + 1)
});
for row in diff.display_rows.iter().skip(top).take(count) {
let DisplayRow::Line(index) = *row else {
continue;
};
let source = if before {
diff.rows[index].before
} else {
diff.rows[index].after
};
if let Some(source) = source {
if let Some(last) = ranges.last_mut()
&& last.end == source
{
last.end += 1;
} else {
ranges.push(source..source + 1);
}
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct DiffColors {
row: [Color; 2],
character: [Color; 2],
}
impl DiffColors {
fn new(theme: Option<ThemeColors>) -> Self {
let Some(theme) = theme else {
return Self {
row: [Color::Reset; 2],
character: [Color::Reset; 2],
};
};
let blend = |tint: [u8; 3], weight: u16| {
let channels = std::array::from_fn::<_, 3, _>(|index| {
((u16::from(theme.background[index]) * (100 - weight)
+ u16::from(tint[index]) * weight
+ 50)
/ 100) as u8
});
Color::Rgb(channels[0], channels[1], channels[2])
};
Self {
row: [blend(theme.red, 18), blend(theme.green, 18)],
character: [blend(theme.red, 38), blend(theme.green, 38)],
}
}
}
impl Default for DiffColors {
fn default() -> Self {
Self::new(None)
}
}
#[derive(Default)]
struct WheelPanels {
workspace_managed: bool,
caret: Option<(Rect, Position)>,
completion: Option<Rect>,
hover: Option<(Rect, usize)>,
hover_syntax: SyntaxCache,
files: Option<(Rect, usize, usize)>,
manual_files: Option<FileWheelOffset>,
diff: Option<DiffGeometry>,
diff_colors: DiffColors,
diff_syntax: Option<DiffSyntaxCache>,
previous_diff: Option<DiffGeometry>,
diff_wrap: Option<DiffWrapViewport>,
}
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.caret = None;
self.completion = None;
self.files = None;
self.previous_diff = self.diff.take();
}
// 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
}
// A horizontal report over a popup must not pan the underlying buffer.
fn scroll(&mut self, session: &mut EditorSession, mouse: MouseEvent) -> bool {
let Some(kind) = wheel_kind(&mouse) else {
return false;
};
let horizontal = matches!(
kind,
MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight
);
let down = matches!(
kind,
MouseEventKind::ScrollDown | MouseEventKind::ScrollRight
);
let position = Position::new(mouse.column, mouse.row);
if horizontal
&& (session.editor().palette().is_some()
|| session.editor().startup_diagnostic().is_some()
|| self.completion.is_some_and(|area| area.contains(position))
|| self.hover.is_some_and(|(area, _)| area.contains(position))
|| self
.files
.is_some_and(|(area, _, _)| area.contains(position)))
{
return true;
}
let editor = session.editor();
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;
}
let pane = session.editor().pane_id();
let floating_files = editor.file_picker().is_some_and(|picker| {
picker.presentation == FilePresentation::Floating
&& self
.files
.is_some_and(|(area, _, _)| area.contains(position))
});
if !floating_files && self.diff.is_some() && self.scroll_comparison(session, mouse, pane) {
return true;
}
let editor = session.editor();
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 scroll_comparison(
&mut self,
session: &mut EditorSession,
mouse: MouseEvent,
pane: usize,
) -> bool {
let Some(kind) = wheel_kind(&mouse) else {
return false;
};
let horizontal = matches!(
kind,
MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight
);
let down = matches!(
kind,
MouseEventKind::ScrollDown | MouseEventKind::ScrollRight
);
let Some(focus) = self.diff.as_ref().and_then(|geometry| {
geometry.regions.iter().find_map(|(area, focus)| {
area.contains(Position::new(mouse.column, mouse.row))
.then_some(*focus)
})
}) else {
return false;
};
let (wrapping, wrapped_viewport) = session.editor_mut().with_render_pane(pane, |editor| {
let wrapping = editor.word_wrap();
let viewport = if !horizontal && wrapping && focus != Focus::Files {
editor.diff().and_then(|diff| {
let file = diff.comparison.as_ref()?.files.get(diff.file)?;
if !file.loaded {
return None;
}
let id = diff.comparison_id?;
let wrap = self.diff_wrap.as_mut()?;
for _ in 0..3 {
wrap.top = wrap.step(diff, file, wrap.top, down);
}
Some((
id,
diff.file,
wrap.top.display,
diff.left_before,
diff.left_after,
))
})
} else {
None
};
(wrapping, viewport)
});
if horizontal && !wrapping && focus != Focus::Files {
let width = self.diff.as_ref().unwrap().text_widths[usize::from(focus == Focus::After)];
if width > 0 {
session.scroll_diff_horizontal(focus, down, usize::from(width));
}
} else if let Some((id, file, top, before, after)) = wrapped_viewport {
session.scroll_diff(focus, down);
session.set_diff_viewport(id, file, top, before, after);
} else if !horizontal {
for _ in 0..3 {
session.scroll_diff(focus, down);
}
}
true
}
}
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 = changed_files.is_none() && content.height >= 5 + u16::from(show_error);
let root = show_root.then(|| {
Paragraph::new(display_text(&picker.root.to_string_lossy()))
.style(Style::default().fg(Color::DarkGray))
.wrap(Wrap { trim: false })
});
// Reserve the query, optional notices/footer, and at least one file row.
let root_rows = root.as_ref().map_or(0, |root| {
root.line_count(content.width).min(usize::from(
content
.height
.saturating_sub(2 + u16::from(show_error) + u16::from(show_help)),
)) as u16
});
let [query_area, root_area, error_area, results_area, help_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(root_rows),
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 let Some(root) = root {
frame.render_widget(root, 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(" "));
}
if let Some(file) = changed
&& file.status == FileStatus::Renamed
&& let Some(old_path) = &file.old_path
{
spans.push(Span::raw(display_text(&old_path.to_string_lossy())));
spans.push(Span::raw(" -> "));
spans.push(Span::raw(display_text(&file.path.to_string_lossy())));
} else {
spans.push(Span::raw(entry.label));
if entry.is_dir && picker.view == FileView::Tree {
spans.push(Span::raw("/"));
}
}
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,
));
}
}
if !show_help {
return;
}
let help = if picker.focused {
let controls = 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 {
"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,
area: Rect,
mode: Mode,
navigation_hint: &str,
) {
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 {
loaded: true,
before_eof: false,
after_eof: false,
path: path.into(),
old_path: None,
status: FileStatus::Modified,
hunks: vec![crate::diff::Hunk {
before_start: 0,
before_len: before.len(),
after_start: 0,
after_len: after.len(),
}],
before,
after,
notice: None,
}
}
#[test]
fn diff_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}");
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);
}
}
#[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, None, None);
assert_eq!(syntax.before.language(), Some("Python"));
assert_eq!(syntax.after.language(), Some("Rust"));
assert_eq!(syntax.before.line(0)[0].kind, SyntaxKind::Keyword);
assert_eq!(syntax.after.line(0)[0].kind, SyntaxKind::Keyword);
file.path = "unknown.extension".into();
syntax.update(1, 0, &file, None, None);
assert_eq!(syntax.after.language(), None);
assert!(syntax.after.line(0).is_empty());
let mut file = syntax_diff_file(
"script",
"```rust\nfn old() {}\n```",
"#!/usr/bin/env python3\nreturn 42",
);
file.old_path = Some("history.md".into());
let mut diff = diff_comparison(vec![file]);
diff.files_visible = false;
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap();
let mut panels = WheelPanels::default();
render_diff(&mut terminal, &diff, &mut panels);
let syntax = panels.diff_syntax.as_ref().unwrap();
assert_eq!(syntax.before.language(), Some("Markdown"));
assert_eq!(syntax.after.language(), Some("Python"));
assert!(syntax.before.parse_counts().1 > 0);
assert_eq!(syntax.before.line(1)[0].kind, SyntaxKind::Keyword);
assert_eq!(syntax.after.line(1)[0].kind, SyntaxKind::Keyword);
for region in [
panels.diff.as_ref().unwrap().regions[1].0,
panels.diff.as_ref().unwrap().regions[2].0,
] {
let cell = &terminal.backend().buffer()[(region.x + 4, region.y + 2)];
assert_eq!(cell.fg, Color::Magenta);
}
}
#[test]
fn diff_syntax_parses_selected_snapshots_not_scroll_focus_or_redraw() {
let first = syntax_diff_file("a.rs", "fn old() {}\n// old", "fn main() {}\n// new");
let second = syntax_diff_file("b.rs", "// prior file", "// selected file");
let mut diff = diff_comparison(vec![first, second]);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap();
let mut panels = WheelPanels::default();
render_diff(&mut terminal, &diff, &mut panels);
let syntax = panels.diff_syntax.as_ref().unwrap();
let counts = (syntax.before.parse_counts(), syntax.after.parse_counts());
assert_eq!(counts, ((1, 0), (1, 0)));
for focus in [Focus::Before, Focus::After, Focus::Files] {
diff.focus = focus;
diff.top = 1;
diff.left_before = 1;
diff.left_after = 2;
render_diff(&mut terminal, &diff, &mut panels);
let syntax = panels.diff_syntax.as_ref().unwrap();
assert_eq!(
(syntax.before.parse_counts(), syntax.after.parse_counts()),
counts
);
let after = panels.diff.as_ref().unwrap().regions[2].0;
assert_eq!(
terminal.backend().buffer()[(after.x + 4, after.y + 1)].fg,
Color::DarkGray,
);
}
diff.files
.as_mut()
.unwrap()
.reveal_path(std::path::Path::new("b.rs"));
diff.handle(Input::Enter);
render_diff(&mut terminal, &diff, &mut panels);
let syntax = panels.diff_syntax.as_ref().unwrap();
assert_eq!(syntax.before.parse_counts(), (2, 0));
assert_eq!(syntax.after.parse_counts(), (2, 0));
let after = panels.diff.as_ref().unwrap().regions[2].0;
assert_eq!(
terminal.backend().buffer()[(after.x + 4, after.y + 1)].fg,
Color::DarkGray,
);
let mut editor = Editor::open(None, vec![]).unwrap();
let mut views = BufferViewports {
panels,
..BufferViewports::default()
};
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert!(views.panels.diff_syntax.is_none());
}
fn diff_comparison(files: Vec<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 {
root: std::path::PathBuf::new(),
editable_paths: Default::default(),
before: "old revision".into(),
after: "Working checkout".into(),
files,
})),
);
diff
}
fn deletion_file() -> ChangedFile {
ChangedFile {
loaded: true,
before_eof: false,
after_eof: false,
path: "deleted-lines.txt".into(),
old_path: None,
status: FileStatus::Modified,
before: ["keep", "gone", "tail"].map(String::from).to_vec(),
after: ["keep", "tail"].map(String::from).to_vec(),
hunks: vec![crate::diff::Hunk {
before_start: 1,
before_len: 1,
after_start: 1,
after_len: 0,
}],
notice: None,
}
}
#[test]
fn diff_status_layouts_hide_absent_panes_and_leave_unchanged_renames_blank() {
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 12)).unwrap();
let mut panels = WheelPanels {
diff_colors: DiffColors::new(Some(LIGHT_DIFF_THEME)),
..WheelPanels::default()
};
for status in [FileStatus::Added, FileStatus::Removed] {
let added = status == FileStatus::Added;
let mut file = deletion_file();
file.status = status;
file.before = if added {
vec![]
} else {
vec!["removed content".into()]
};
file.after = if added {
vec!["added content".into()]
} else {
vec![]
};
file.hunks = vec![crate::diff::Hunk {
before_start: 0,
before_len: usize::from(!added),
after_start: 0,
after_len: usize::from(added),
}];
let mut diff = diff_comparison(vec![file]);
diff.files_visible = false;
diff.focus = if added { Focus::After } else { Focus::Before };
diff.left_after = 2;
render_diff(&mut terminal, &diff, &mut panels);
let geometry = panels.diff.as_ref().unwrap();
let present = geometry.regions[if added { 2 } else { 1 }].0;
assert_eq!(
geometry.regions[if added { 1 } else { 2 }].0,
Rect::default()
);
assert_eq!(present.x, 0);
assert_eq!(present.width, 160);
assert_eq!(geometry.viewport.unwrap().3, 0);
assert_eq!(geometry.viewport.unwrap().4, 2);
assert_eq!(geometry.regions[if added { 2 } else { 1 }].1, diff.focus);
assert_eq!(
terminal.backend().buffer()[(present.x, present.y)].fg,
Color::Cyan
);
for x in present.x + 1..present.right() - 1 {
assert_eq!(
terminal.backend().buffer()[(x, present.y + 1)].bg,
Color::Reset
);
}
let text = screen_text(&terminal);
assert!(text.contains(if added {
"ded content"
} else {
"removed content"
}));
assert!(text.contains(if added { "After · A" } else { "Before · D" }));
assert!(!text.contains(if added { "Before ·" } else { "After ·" }));
assert!(!text.contains('╱'));
}
let mut empty_added = deletion_file();
empty_added.status = FileStatus::Added;
empty_added.before.clear();
empty_added.after.clear();
empty_added.hunks.clear();
empty_added.notice = Some("metadata-only addition".into());
let mut diff = diff_comparison(vec![empty_added]);
diff.files_visible = false;
diff.focus = Focus::After;
render_diff(&mut terminal, &diff, &mut panels);
let geometry = panels.diff.as_ref().unwrap();
assert_eq!(geometry.regions[1].0, Rect::default());
assert_eq!(geometry.regions[2].0.width, 160);
assert!(screen_text(&terminal).contains("metadata-only addition"));
let mut renamed = deletion_file();
renamed.status = FileStatus::Renamed;
renamed.old_path = Some("old/path.txt".into());
renamed.path = "new/path.txt".into();
renamed.before = vec!["unchanged rename content".into()];
renamed.after = renamed.before.clone();
renamed.hunks.clear();
let mut diff = diff_comparison(vec![renamed]);
render_diff(&mut terminal, &diff, &mut panels);
assert!(screen_text(&terminal).contains("old/path.txt -> new/path.txt"));
assert_eq!(
diff.files.as_mut().unwrap().accept(),
Some("new/path.txt".into())
);
diff.files_visible = false;
render_diff(&mut terminal, &diff, &mut panels);
let geometry = panels.diff.as_ref().unwrap();
let blank = geometry.regions[1].0;
assert_eq!(blank.width, 160);
assert_eq!(geometry.regions[2].0, Rect::default());
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(blank.x, blank.y)].symbol(), "┌");
for y in blank.y + 1..blank.bottom() - 1 {
for x in blank.x + 1..blank.right() - 1 {
assert_eq!(buffer[(x, y)].symbol(), " ");
assert_eq!(buffer[(x, y)].bg, Color::Reset);
}
}
let text = screen_text(&terminal);
assert!(!text.contains("Before ·"));
assert!(!text.contains("After ·"));
assert!(!text.contains("unchanged rename content"));
assert!(!text.contains('╱'));
let mut modified = diff_comparison(vec![deletion_file()]);
modified.files_visible = false;
render_diff(&mut terminal, &modified, &mut panels);
let regions = panels.diff.as_ref().unwrap().regions;
assert_eq!(regions[1].0.width, 80);
assert_eq!(regions[2].0.width, 80);
let text = screen_text(&terminal);
assert!(text.contains("Before · M"));
assert!(text.contains("After · M"));
}
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, None);
})
.unwrap();
}
const LIGHT_DIFF_THEME: ThemeColors = ThemeColors {
background: [250, 245, 240],
red: [210, 30, 50],
green: [20, 150, 60],
};
const DARK_DIFF_THEME: ThemeColors = ThemeColors {
background: [20, 25, 30],
red: [220, 60, 80],
green: [40, 180, 100],
};
#[test]
fn character_diff_backgrounds_preserve_common_unicode_and_syntax_across_viewports() {
for theme in [Some(LIGHT_DIFF_THEME), Some(DARK_DIFF_THEME), None] {
let mut diff = diff_comparison(vec![syntax_diff_file(
"changes.rs",
"let 界e\u{301} = old + old;",
"let 界e\u{301} = new + new;",
)]);
diff.files_visible = false;
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
let mut panels = WheelPanels {
diff_colors: DiffColors::new(theme),
..WheelPanels::default()
};
render_diff(&mut terminal, &diff, &mut panels);
let row = panels.diff_colors.row;
let character = panels.diff_colors.character;
if let Some(theme) = theme {
for (index, accent) in [theme.red, theme.green].into_iter().enumerate() {
let Color::Rgb(r, g, b) = row[index] else {
panic!("theme row background must be RGB");
};
let Color::Rgb(cr, cg, cb) = character[index] else {
panic!("theme character background must be RGB");
};
for ((common, changed), (background, accent)) in [r, g, b]
.into_iter()
.zip([cr, cg, cb])
.zip(theme.background.into_iter().zip(accent))
{
assert!(
(background.min(accent)..=background.max(accent)).contains(&common)
);
assert!(
(background.min(accent)..=background.max(accent)).contains(&changed)
);
if background != accent {
assert!(changed.abs_diff(accent) < common.abs_diff(accent));
}
}
}
} else {
assert_eq!(row, [Color::Reset; 2]);
assert_eq!(character, [Color::Reset; 2]);
}
for index in 0..2 {
let pane = panels.diff.as_ref().unwrap().regions[index + 1].0;
let buffer = terminal.backend().buffer();
for column in [0, 3, 4, 6, 8, 14, 19] {
assert_eq!(buffer[(pane.x + 4 + column, pane.y + 1)].bg, row[index]);
}
for column in [10, 11, 12, 16, 17, 18] {
let cell = &buffer[(pane.x + 4 + column, pane.y + 1)];
assert_eq!(cell.bg, character[index]);
assert_eq!(cell.fg, Color::Reset);
}
assert_eq!(buffer[(pane.x + 4, pane.y + 1)].fg, Color::Magenta);
assert_eq!(buffer[(pane.x + 8, pane.y + 1)].symbol(), "界");
assert_eq!(buffer[(pane.x + 10, pane.y + 1)].symbol(), "e\u{301}");
}
diff.left_before = 11;
diff.left_after = 11;
render_diff(&mut terminal, &diff, &mut panels);
let after = panels.diff.as_ref().unwrap().regions[2].0;
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(after.x + 4, after.y + 1)].symbol(), "e");
assert_eq!(buffer[(after.x + 4, after.y + 1)].bg, character[1]);
assert_eq!(buffer[(after.x + 7, after.y + 1)].bg, row[1]);
diff.word_wrap = true;
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 12)).unwrap();
render_diff(&mut terminal, &diff, &mut panels);
let after = panels.diff.as_ref().unwrap().regions[2].0;
let buffer = terminal.backend().buffer();
let mut words = 0;
for y in after.y + 1..after.bottom() - 1 {
for x in after.x + 4..after.right().saturating_sub(3) {
if buffer[(x, y)].symbol() == "n"
&& buffer[(x + 1, y)].symbol() == "e"
&& buffer[(x + 2, y)].symbol() == "w"
{
words += 1;
for column in x..x + 3 {
assert_eq!(buffer[(column, y)].bg, character[1]);
assert_eq!(buffer[(column, y)].fg, Color::Reset);
}
}
}
}
assert_eq!(words, 2);
}
}
#[test]
fn diff_picker_keeps_working_pinned_while_windowing_searching_and_changing_stage() {
let mut diff = DiffState::new();
diff.take_request();
diff.apply_result(1, Ok(crate::diff::Outcome::Catalog(diff_catalog())));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap();
let mut panels = WheelPanels::default();
diff.handle(Input::End);
render_diff(&mut terminal, &diff, &mut panels);
let text = screen_text(&terminal);
assert!(text.contains("Choose Before"));
assert!(text.contains("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 {
loaded: true,
before_eof: false,
after_eof: false,
path: "excerpts.rs".into(),
old_path: None,
status: FileStatus::Modified,
before,
after,
hunks: [30, 60]
.map(|index| crate::diff::Hunk {
before_start: index,
before_len: 1,
after_start: index,
after_len: 1,
})
.into(),
notice: None,
}]);
diff.set_context_options(1, 3);
diff.files_visible = false;
diff.focus = Focus::Before;
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 24)).unwrap();
let mut panels = WheelPanels::default();
render_diff(&mut terminal, &diff, &mut panels);
let text = screen_text(&terminal);
for visible in [
"30 let row_029",
"31- let row_030",
"31+ let first_change",
"61+ let second_change",
] {
assert!(text.contains(visible), "missing {visible}: {text}");
}
for hidden in ["row_028", "row_032", "row_058", "row_062"] {
assert!(!text.contains(hidden), "unexpected {hidden}: {text}");
}
let before_area = panels.diff.as_ref().unwrap().regions[1].0;
let buffer = terminal.backend().buffer();
assert_eq!(
buffer[(before_area.x + 5, before_area.y + 3)].fg,
Color::Magenta
);
diff.handle(Input::Char('['));
diff.handle(Input::Home);
render_diff(&mut terminal, &diff, &mut panels);
let text = screen_text(&terminal);
assert!(text.contains("row_026"));
assert!(!text.contains("row_025"));
assert!(text.contains("first_change"));
assert!(text.contains("second_change"));
}
#[test]
fn diff_view_aligns_deletion_holes_numbers_colors_and_shared_vertical_scroll() {
let mut diff = diff_comparison(vec![deletion_file()]);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
let mut panels = WheelPanels {
diff_colors: DiffColors::new(Some(LIGHT_DIFF_THEME)),
..WheelPanels::default()
};
render_diff(&mut terminal, &diff, &mut panels);
let geometry = panels.diff.as_ref().unwrap();
let before = geometry.regions[1].0;
let after = geometry.regions[2].0;
let buffer = terminal.backend().buffer();
let y = before.y + 2;
assert_eq!(buffer[(before.x + 4, y)].symbol(), "g");
assert_eq!(buffer[(before.x + 4, y)].fg, Color::Reset);
assert_eq!(buffer[(before.x + 2, y)].symbol(), "-");
for x in before.x + 1..before.right() - 1 {
assert_eq!(buffer[(x, y + 1)].bg, Color::Reset);
}
let gap_symbols: Vec<_> = (after.x + 1..after.right() - 1)
.map(|x| {
let cell = &buffer[(x, y)];
assert_eq!(cell.symbol(), "╱");
assert_eq!(cell.bg, Color::Reset);
assert_eq!(cell.fg, 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 {
loaded: true,
before_eof: false,
after_eof: false,
path: "added.txt".into(),
old_path: None,
status: FileStatus::Modified,
before: vec![],
after: vec!["first addition".into(), String::new()],
hunks: vec![crate::diff::Hunk {
before_start: 0,
before_len: 0,
after_start: 0,
after_len: 2,
}],
notice: None,
}]);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
let mut panels = WheelPanels {
diff_colors: DiffColors::new(Some(LIGHT_DIFF_THEME)),
..WheelPanels::default()
};
render_diff(&mut terminal, &diff, &mut panels);
let geometry = panels.diff.as_ref().unwrap();
let before = geometry.regions[1].0;
let after = geometry.regions[2].0;
let buffer = terminal.backend().buffer();
for offset in 0..2 {
let y = before.y + 1 + offset;
for x in before.x + 1..before.right() - 1 {
let cell = &buffer[(x, y)];
assert_eq!(cell.symbol(), "╱");
assert_eq!(cell.bg, Color::Reset);
assert_eq!(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 {
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 {
loaded: true,
before_eof: false,
after_eof: false,
path: "changed.rs".into(),
old_path: None,
status: FileStatus::Modified,
before: [
"fn old() {",
"\tlet word = \"界e\u{301}old\";",
"\tlet count = 42; //\u{1b}note",
"",
"}",
]
.map(String::from)
.to_vec(),
after: [
"fn new() {",
"\tlet word = \"界e\u{301}new\";",
"\tlet count = 24; //\u{1b}note",
"",
"}",
]
.map(String::from)
.to_vec(),
hunks: vec![crate::diff::Hunk {
before_start: 0,
before_len: 5,
after_start: 0,
after_len: 5,
}],
notice: None,
}]);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 12)).unwrap();
let mut panels = WheelPanels::default();
render_diff(&mut terminal, &diff, &mut panels);
let geometry = panels.diff.as_ref().unwrap();
let before = geometry.regions[1].0;
let after = geometry.regions[2].0;
let buffer = terminal.backend().buffer();
for pane in [before, after] {
for (column, row, foreground) in [
(0, 0, Color::Magenta),
(3, 0, Color::LightBlue),
(4, 1, Color::Magenta),
(15, 1, Color::Green),
(16, 1, Color::Green),
(18, 1, Color::Green),
(16, 2, Color::Yellow),
(22, 2, Color::DarkGray),
(23, 2, Color::DarkGray),
] {
assert_eq!(
buffer[(pane.x + 4 + column, pane.y + 1 + 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 + 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 + 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 {
loaded: true,
before_eof: false,
after_eof: false,
path: "new-name.txt".into(),
old_path: Some("old-name.txt".into()),
status: FileStatus::Renamed,
before: vec!["\t界e\u{301}OLD".into()],
after: vec!["\t界e\u{301}NEW".into()],
hunks: vec![crate::diff::Hunk {
before_start: 0,
before_len: 1,
after_start: 0,
after_len: 1,
}],
notice: None,
};
let mut diff = diff_comparison(vec![deletion_file(), added]);
diff.handle(Input::Down);
diff.handle(Input::Enter);
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);
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()
.enumerate()
.map(|(index, file)| (index, file.path.clone())),
);
picker.reveal_path(std::path::Path::new("chain/a/m.txt"));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap();
for view in [FileView::Tree, FileView::List] {
picker.set_view(view);
for focused in [true, false] {
picker.focused = focused;
for index in 0..files.len() {
picker.reveal_path(&files[index].path);
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 = if file.status == FileStatus::Renamed {
format!("R old�name.txt -> {}", file.path.to_string_lossy())
} else {
format!("{} {}", diff_status(file.status), path.to_string_lossy())
};
let (x, y) = (0..16)
.find_map(|y| {
let row = (0..100)
.map(|x| buffer[(x, y)].symbol())
.collect::<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_eq!(picker.accept(), Some(files[index].path.clone()));
assert!(picker.query.is_empty());
}
}
}
// A reverted first file disappears without shifting the remaining status markers.
picker.replace_paths(
files
.iter()
.enumerate()
.skip(1)
.map(|(index, file)| (index, file.path.clone())),
);
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.files_visible = false;
diff.focus = Focus::Before;
diff.files.as_mut().unwrap().focused = false;
render_diff(&mut terminal, &diff, &mut panels);
let hidden = panels.diff.as_ref().unwrap().regions;
assert_eq!(hidden[0].0, Rect::default());
assert!(panels.files.is_none());
assert_eq!(hidden[1].0.x, 0);
assert_eq!(hidden[1].0.right(), hidden[2].0.x);
assert_eq!(hidden[2].0.right(), 120);
assert!(hidden[1].0.width + hidden[2].0.width > original[1].0.width + original[2].0.width);
assert!(!screen_text(&terminal).contains("Explorer"));
diff.files_visible = true;
diff.focus = Focus::Files;
diff.files.as_mut().unwrap().focused = true;
render_diff(&mut terminal, &diff, &mut panels);
assert_eq!(panels.diff.as_ref().unwrap().regions, original);
assert_eq!(panels.files.unwrap().0, original[0].0);
}
#[test]
fn 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 {
loaded: true,
before_eof: false,
after_eof: false,
path: "image.bin".into(),
old_path: None,
status: FileStatus::Added,
before: vec![],
after: vec![],
hunks: vec![],
notice: Some("Binary file; text comparison unavailable".into()),
}]);
render_diff(&mut terminal, &binary, &mut panels);
assert!(screen_text(&terminal).contains("Binary file"));
let blank = diff_comparison(vec![ChangedFile {
loaded: true,
before_eof: false,
after_eof: false,
path: "empty.txt".into(),
old_path: None,
status: FileStatus::Added,
before: vec![],
after: vec![],
hunks: vec![],
notice: None,
}]);
render_diff(&mut terminal, &blank, &mut panels);
assert!(screen_text(&terminal).contains("Empty file"));
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);
if before.width > 0 && after.width > 0 {
assert!(before.right() <= after.x);
}
if navigator.width > 0 {
assert!(before.right().max(after.right()) <= navigator.x);
}
}
}
}
}
#[test]
fn immutable_diff_draw_path_preserves_document_until_explicit_close() {
let directory = Directory::new();
let mut editor = Editor::open(
Some(directory.file("source.txt", "original\nsecond")),
vec![],
)
.unwrap();
let original = editor.lines().to_vec();
let cursor = editor.cursor();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
editor.execute("diff");
let (id, _) = editor.take_diff_request().unwrap();
editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog())));
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert!(screen_text(&terminal).contains("Choose Before"));
assert!(viewports.document.geometry.is_none());
editor.handle(Input::Enter);
editor.handle(Input::Enter);
let (id, _) = editor.take_diff_request().unwrap();
editor.apply_diff_result(
id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
root: std::path::PathBuf::new(),
editable_paths: Default::default(),
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, &mut editor))
.unwrap();
assert!(screen_text(&terminal).contains("deleted-lines.txt"));
assert!(viewports.document.geometry.is_none());
editor.handle(Input::Escape);
assert!(editor.diff().is_some());
editor.execute("diff.close");
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert!(screen_text(&terminal).contains("original"));
assert!(viewports.document.geometry.is_some());
assert_eq!(editor.lines(), original);
assert_eq!(editor.cursor(), cursor);
}
fn pane_screen(terminal: &Terminal<ratatui::backend::TestBackend>, area: Rect) -> String {
let buffer = terminal.backend().buffer();
(area.y..area.bottom())
.map(|y| {
(area.x..area.right())
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn tab_bar_shows_shared_dirty_titles_and_reserves_document_and_overlay_rows() {
let directory = Directory::new();
let mut editor =
Editor::open(Some(directory.file("a\tname.txt", "DOCUMENT")), vec![]).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(views.document.geometry.unwrap().1.y, 0);
assert!(pane_screen(&terminal, Rect::new(0, 0, 80, 1)).contains("DOCUMENT"));
editor.execute("tab.new");
editor.handle(Input::Char('i'));
editor.handle(Input::Paste("changed ".into()));
editor.handle(Input::Escape);
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let titles = pane_screen(&terminal, Rect::new(0, 0, 80, 1));
assert!(titles.contains("1 a�name.txt [+]"), "{titles}");
assert!(titles.contains("2 a�name.txt [+]"), "{titles}");
assert_eq!(views.document.geometry.unwrap().1.y, 1);
assert!(terminal.get_cursor_position().unwrap().y >= 1);
assert!(pane_screen(&terminal, views.rects[0].1).contains("changed DOCUMENT"));
editor.handle(Input::Ctrl('p'));
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(pane_screen(&terminal, Rect::new(0, 0, 80, 1)), titles);
assert!(terminal.get_cursor_position().unwrap().y >= 1);
assert!(screen_text(&terminal).contains("Commands"));
}
#[test]
fn narrow_tab_bar_keeps_the_active_title_visible_and_handles_empty_areas() {
let directory = Directory::new();
let mut editor = Editor::open(
Some(directory.file("a-very-long-inactive-title.txt", "first")),
vec![],
)
.unwrap();
editor.execute("tab.new");
editor.execute("tab.new");
editor.execute(&format!(
"edit {}",
directory.file("active.txt", "ACTIVE").display()
));
let mut views = BufferViewports::default();
for (width, height) in [(12, 9), (7, 9), (1, 9), (0, 9), (12, 1), (12, 0)] {
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
if width > 0 && height > 0 {
let title = pane_screen(&terminal, Rect::new(0, 0, width, 1));
assert!(title.trim_start().starts_with('3'), "{title}");
}
}
let mut editor = Editor::open(None, vec![]).unwrap();
editor.execute("tab.new");
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 6)).unwrap();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let titles = pane_screen(&terminal, Rect::new(0, 0, 40, 1));
assert!(titles.contains("1 [No Name]"), "{titles}");
assert!(titles.contains("2 [No Name]"), "{titles}");
}
#[test]
fn tab_switches_restore_independent_pane_viewports_and_retire_only_closed_panes() {
let directory = Directory::new();
let text = (0..80)
.map(|row| format!("row-{row:02} {}", "long ".repeat(30)))
.collect::<Vec<_>>()
.join("\n");
let mut session =
EditorSession::open(Some(directory.file("rows.txt", &text)), vec![], None).unwrap();
let buffer = session.editor().buffer_id();
session.execute("tab.new");
let other = session.editor().pane_id();
session.execute("tab.previous");
session.execute("pane.split_vertical");
let split = session.editor().pane_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
let area = views.rects.iter().find(|(id, _)| *id == split).unwrap().1;
assert_eq!(area.y, 1);
assert!(!views.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 2, 0)
));
for _ in 0..2 {
for kind in [MouseEventKind::ScrollDown, MouseEventKind::ScrollRight] {
assert!(views.scroll(&mut session, document_wheel(kind, area.x + 2, area.y + 2)));
}
}
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
let split_offset = views.offsets[&buffer];
assert!(split_offset.0 > 0 && split_offset.1 > 0);
session.execute("tab.next");
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert_eq!(views.rects.len(), 1);
assert_eq!(views.parked[&split].offsets[&buffer], split_offset);
for kind in [MouseEventKind::ScrollDown, MouseEventKind::ScrollRight] {
assert!(views.scroll(&mut session, document_wheel(kind, 2, 2)));
}
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
let other_offset = views.offsets[&buffer];
assert_ne!(other_offset, split_offset);
session.execute("tab.previous");
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert_eq!(session.editor().pane_id(), split);
assert_eq!(views.rects.len(), 2);
assert_eq!(views.offsets[&buffer], split_offset);
assert_eq!(views.parked[&other].offsets[&buffer], other_offset);
session.execute("tab.next");
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert_eq!(views.offsets[&buffer], other_offset);
session.execute("tab.close");
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert!(!views.parked.contains_key(&other));
assert_eq!(views.offsets[&buffer], split_offset);
}
#[test]
fn hidden_tab_diff_caches_do_not_poll_colors_or_report_stale_viewports() {
let directory = Directory::new();
let mut session = EditorSession::open(
Some(directory.file("source.txt", "WORKSPACE")),
vec![],
None,
)
.unwrap();
let source = (0..80)
.map(|row| format!("old-{row:02} {}", "x ".repeat(30)))
.collect::<Vec<_>>()
.join("\n");
install_readonly_diff(
session.editor_mut(),
syntax_diff_file("history.txt", &source, &source.replace("old", "new")),
);
let owner = session.editor().pane_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert!(views.diff_visible);
let (id, file, _, _, _) = views.panels.diff.as_ref().unwrap().viewport.unwrap();
session.set_diff_viewport(id, file, 24, 7, 8);
session.execute("tab.new");
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert!(!views.diff_visible);
assert!(views.parked[&owner].panels.diff.is_some());
assert!(views.rects.iter().all(|(id, _)| *id != owner));
session.execute("tab.previous");
let diff = session.editor().diff().unwrap();
assert_eq!((diff.top, diff.left_before, diff.left_after), (24, 7, 8));
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert!(views.diff_visible);
assert_eq!(terminal.backend().buffer()[(0, 1)].bg, Color::Cyan);
assert_eq!(views.panels.files.unwrap().0.y, 2);
session.execute("diff");
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert!(views.panels.diff.as_ref().unwrap().regions[0].0.y >= 1);
}
#[test]
fn explorer_dock_reserves_space_outside_all_workspace_panes() {
let directory = Directory::new();
let mut editor =
Editor::open(Some(directory.file("shared.txt", "first\nsecond")), vec![]).unwrap();
editor.set_project_root(directory.0.clone());
editor.handle(Input::Char('|'));
editor.handle(Input::Char('_'));
editor.handle(Input::Char(' '));
editor.handle(Input::Char('e'));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let dock = views.panels.files.unwrap().0;
assert!(views.rects.iter().all(|(_, pane)| pane.right() <= dock.x));
assert_eq!(dock.right(), 120);
assert_eq!(dock.y, 0);
assert_eq!(dock.height, 28);
}
#[test]
fn native_diff_dock_stays_below_the_full_width_header() {
let mut editor = readonly_diff_editor(deletion_file());
let mut views = BufferViewports::default();
for (width, height) in [(120, 30), (32, 9)] {
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let dock = views.panels.files.unwrap().0;
let before = views.panels.diff.as_ref().unwrap().regions[1].0;
assert_eq!(dock.y, before.y);
assert_eq!(dock.y, 1);
assert_eq!(dock.right(), width);
for x in 0..width {
assert_eq!(terminal.backend().buffer()[(x, 0)].bg, Color::Cyan);
}
}
}
#[test]
fn revision_selector_overlays_and_clears_workspace_pane_borders() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.handle(Input::Char('|'));
editor.execute("diff");
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert!(screen_text(&terminal).contains("Choose Before"));
let results = views.panels.diff.as_ref().unwrap().regions[0].0;
assert!(results.x < 60 && results.right() > 60);
for x in [59, 60] {
assert_eq!(terminal.backend().buffer()[(x, 20)].symbol(), " ");
}
}
#[test]
fn diff_floating_picker_overlays_the_whole_workspace() {
let directory = Directory::new();
let mut editor =
Editor::open(Some(directory.file("source.txt", "workspace")), vec![]).unwrap();
editor.handle(Input::Char('|'));
install_readonly_diff(&mut editor, syntax_diff_file("history.txt", "old", "new"));
editor.handle(Input::Char(' '));
editor.handle(Input::Char('o'));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(
views.panels.files.unwrap().0,
floating_panel(Rect::new(0, 0, 120, 30))
);
}
#[test]
fn nested_workspace_panes_share_edits_and_undo_but_not_carets() {
let directory = Directory::new();
let mut editor = Editor::open(
Some(directory.file("shared.rs", "fn original() {}\nsecond")),
vec![],
)
.unwrap();
let root = editor.pane_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(90, 22)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
editor.handle(Input::Char('|'));
let right = editor.pane_id();
editor.handle(Input::Char('_'));
let bottom = editor.pane_id();
assert_ne!(root, right);
assert_ne!(right, bottom);
editor.handle(Input::Right);
assert_eq!(
editor.with_render_pane(root, |view| view.cursor()),
Cursor::default()
);
assert_eq!(
editor.with_render_pane(right, |view| view.cursor()),
Cursor::default()
);
assert_eq!(editor.cursor().column, 1);
editor.handle(Input::Home);
editor.handle(Input::Char('i'));
editor.handle(Input::Paste("SHARED ".into()));
editor.handle(Input::Escape);
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(views.rects.len(), 3);
for (_, area) in &views.rects {
assert!(pane_screen(&terminal, *area).contains("SHARED"));
}
let focused = views.rects.iter().find(|(id, _)| *id == bottom).unwrap().1;
assert!(focused.contains(terminal.get_cursor_position().unwrap()));
assert_eq!(
terminal.backend().buffer()[(focused.x, focused.y)].fg,
Color::Cyan
);
assert_eq!(views.syntax.len(), 1);
editor.handle(Input::Char('u'));
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert!(!screen_text(&terminal).contains("SHARED"));
editor.execute("pane.close");
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(views.rects.len(), 2);
assert!(!views.parked.contains_key(&bottom));
}
#[test]
fn wheel_targets_inactive_workspace_pane_and_split_inherits_offsets() {
let directory = Directory::new();
let text = (0..80)
.map(|row| format!("row-{row:02} {}", "long ".repeat(30)))
.collect::<Vec<_>>()
.join("\n");
let mut session =
EditorSession::open(Some(directory.file("rows.txt", &text)), vec![], None).unwrap();
let root = session.editor().pane_id();
let buffer = session.editor().buffer_id();
for _ in 0..3 {
session.handle(Input::Right);
}
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 20)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert!(views.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 2, 1)
));
session.handle(Input::Char('|'));
let focused = session.editor().pane_id();
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert_eq!(views.offsets[&buffer], views.parked[&root].offsets[&buffer]);
let focused_offset = views.offsets[&buffer];
let root_area = views.rects.iter().find(|(id, _)| *id == root).unwrap().1;
let cursor = session.editor().cursor();
assert!(views.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, root_area.x + 2, root_area.y + 2)
));
assert!(views.scroll(
&mut session,
document_wheel(
MouseEventKind::ScrollRight,
root_area.x + 2,
root_area.y + 2
)
));
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert_eq!(session.editor().pane_id(), focused);
assert_eq!(session.editor().cursor(), cursor);
assert_eq!(views.offsets[&buffer], focused_offset);
assert!(views.parked[&root].offsets[&buffer].0 > focused_offset.0);
assert!(views.parked[&root].offsets[&buffer].1 > focused_offset.1);
let inactive_offset = views.parked[&root].offsets[&buffer];
let inactive_cursor = session
.editor_mut()
.with_render_pane(root, |view| view.cursor());
// Even a motion that leaves the focused caret in place requests reveal.
session.handle(Input::Home);
session.handle(Input::Home);
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert_eq!(views.parked[&root].offsets[&buffer], inactive_offset);
session.handle(Input::Down);
session.handle(Input::End);
session.handle(Input::Char('i'));
session.handle(Input::Paste(" typed".into()));
session.handle(Input::Escape);
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert_eq!(
session
.editor_mut()
.with_render_pane(root, |view| view.cursor()),
inactive_cursor
);
assert_eq!(views.parked[&root].offsets[&buffer], inactive_offset);
session.handle(Input::Up);
session.handle(Input::Home);
session.handle(Input::Char('i'));
session.handle(Input::Paste("PREFIX ".into()));
session.handle(Input::Escape);
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
let rebased = session
.editor_mut()
.with_render_pane(root, |view| view.cursor());
assert_eq!(rebased.row, inactive_cursor.row);
assert!(rebased.column > inactive_cursor.column);
assert_eq!(views.parked[&root].offsets[&buffer], inactive_offset);
let focused_after_typing = views.offsets[&buffer];
session.handle(Input::Alt('h'));
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert_eq!(session.editor().pane_id(), root);
assert!(views.offsets[&buffer].0 > focused_offset.0);
assert_eq!(
views.parked[&focused].offsets[&buffer],
focused_after_typing
);
}
#[test]
fn wrapped_workspace_panes_scroll_independently_and_keep_toggle_anchor() {
let directory = Directory::new();
let text = (0..40)
.map(|row| format!("row-{row:02} {}", "word ".repeat(35)))
.collect::<Vec<_>>()
.join("\n");
let mut session =
EditorSession::open(Some(directory.file("wrap.txt", &text)), vec![], None).unwrap();
session.execute("word_wrap.toggle");
let root = session.editor().pane_id();
let buffer = session.editor().buffer_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(70, 24)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert!(views.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 2, 1)
));
session.handle(Input::Char('|'));
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
let inherited = (
views.offsets[&buffer].0,
views.document.continuations[&buffer],
);
assert_eq!(
inherited,
(
views.parked[&root].offsets[&buffer].0,
views.parked[&root].document.continuations[&buffer]
)
);
let root_area = views.rects.iter().find(|(id, _)| *id == root).unwrap().1;
assert!(views.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, root_area.x + 2, root_area.y + 2)
));
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert_eq!(
(
views.offsets[&buffer].0,
views.document.continuations[&buffer]
),
inherited
);
assert_ne!(
(
views.parked[&root].offsets[&buffer].0,
views.parked[&root].document.continuations[&buffer]
),
inherited
);
session.handle(Input::Right);
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
let caret = terminal.get_cursor_position().unwrap();
session.execute("word_wrap.toggle");
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert_eq!(terminal.get_cursor_position().unwrap().y, caret.y);
}
#[test]
fn comparison_owner_coexists_with_workspace_and_inactive_diff_wheel_keeps_focus() {
let directory = Directory::new();
let mut session = EditorSession::open(
Some(directory.file("source.txt", "WORKSPACE\nsecond")),
vec![],
None,
)
.unwrap();
let source = (0..80)
.map(|row| format!("old-{row:02} {}", "x ".repeat(30)))
.collect::<Vec<_>>()
.join("\n");
let after = source.replace("old", "new");
let workspace = session.editor().buffer_id();
install_readonly_diff(
session.editor_mut(),
syntax_diff_file("history.txt", &source, &after),
);
let owner = session.editor().pane_id();
session.execute("pane.split_horizontal");
let focused = session.editor().pane_id();
session.execute(&format!("buffer {workspace}"));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 34)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert!(session.editor().diff().is_none());
assert!(screen_text(&terminal).contains("WORKSPACE"));
assert!(screen_text(&terminal).contains("Before"));
assert!(screen_text(&terminal).contains("After"));
let geometry = views.parked[&owner].panels.diff.as_ref().unwrap();
let before = geometry.regions[1].0;
let cursor = session.editor().cursor();
let offsets = views.offsets.clone();
assert!(views.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, before.x + 1, before.y + 1)
));
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert_eq!(session.editor().pane_id(), focused);
assert_eq!(session.editor().cursor(), cursor);
assert_eq!(views.offsets, offsets);
assert!(
session
.editor_mut()
.with_render_pane(owner, |view| view.diff().unwrap().top > 0)
);
let area = views.rects.iter().find(|(id, _)| *id == focused).unwrap().1;
assert!(area.contains(terminal.get_cursor_position().unwrap()));
}
fn readonly_diff_editor(file: ChangedFile) -> Editor {
let mut editor = Editor::open(None, vec![]).unwrap();
install_readonly_diff(&mut editor, file);
editor
}
fn install_readonly_diff(editor: &mut Editor, file: ChangedFile) {
editor.set_clipboard_mode("separate").unwrap();
editor.execute("diff");
let (id, _) = editor.take_diff_request().unwrap();
editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog())));
editor.handle(Input::Down);
editor.handle(Input::Enter);
editor.handle(Input::Down);
editor.handle(Input::Enter);
let (id, _) = editor.take_diff_request().unwrap();
editor.apply_diff_result(
id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
root: std::path::PathBuf::new(),
editable_paths: Default::default(),
before: "baseline".into(),
after: "historic".into(),
files: vec![file],
})),
);
}
fn render_native_diff(
terminal: &mut Terminal<ratatui::backend::TestBackend>,
views: &mut BufferViewports,
editor: &mut Editor,
) -> Position {
terminal.draw(|frame| views.draw(frame, editor)).unwrap();
let (id, file, top, before, after) = views.panels.diff.as_ref().unwrap().viewport.unwrap();
editor.set_diff_viewport(id, file, top, before, after);
terminal.get_cursor_position().unwrap()
}
#[test]
fn modified_sequences_navigate_diff_files_and_readonly_panes() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.handle(Input::Char('|'));
install_readonly_diff(
&mut editor,
syntax_diff_file("history.txt", "before", "after"),
);
editor
.bind("normal", "ctrl+w h", Some("core.pane.focus_left".into()))
.unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
let mut views = BufferViewports::default();
render_native_diff(&mut terminal, &mut views, &mut editor);
assert_eq!(editor.diff().unwrap().focus, Focus::Files);
editor.handle(Input::Ctrl('w'));
assert_eq!(editor.sequence_pending(), Some("Ctrl+w"));
assert!(editor.diff().unwrap().files_visible);
editor.handle(Input::Char('h'));
assert_eq!(editor.diff().unwrap().focus, Focus::After);
render_native_diff(&mut terminal, &mut views, &mut editor);
editor.handle(Input::Ctrl('w'));
editor.handle(Input::Char('h'));
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
render_native_diff(&mut terminal, &mut views, &mut editor);
editor.handle(Input::Ctrl('w'));
editor.handle(Input::Char('h'));
assert_eq!(editor.pane_id(), 1);
assert_eq!(editor.sequence_pending(), None);
}
#[test]
fn wrap_toggle_preserves_caret_screen_row_in_normal_buffers() {
let directory = Directory::new();
let text = (0..35)
.map(|row| format!("line{row} {}", "word ".repeat(15)))
.collect::<Vec<_>>()
.join("\n");
let mut editor = Editor::open(Some(directory.file("anchor.txt", &text)), vec![]).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 10)).unwrap();
let mut views = BufferViewports::default();
for _ in 0..12 {
editor.handle(Input::Down);
}
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
for _ in 0..3 {
editor.handle(Input::Up);
}
for _ in 0..30 {
editor.handle(Input::Right);
}
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let row = terminal.get_cursor_position().unwrap().y;
let source = editor.cursor();
for _ in 0..4 {
editor.execute("word_wrap.toggle");
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(terminal.get_cursor_position().unwrap().y, row);
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(terminal.get_cursor_position().unwrap().y, row);
assert_eq!(editor.cursor(), source);
assert_eq!(editor.lines().join("\n"), text);
}
for _ in 0..35 {
editor.handle(Input::Down);
}
editor.handle(Input::Home);
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
editor.execute("word_wrap.toggle");
editor.handle(Input::Right);
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let near_eof_row = terminal.get_cursor_position().unwrap().y;
let near_eof_source = editor.cursor();
editor.execute("word_wrap.toggle");
for _ in 0..2 {
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(terminal.get_cursor_position().unwrap().y, near_eof_row);
assert_eq!(editor.cursor(), near_eof_source);
}
}
#[test]
fn wrap_toggle_preserves_caret_screen_row_in_diff_buffers() {
let before = (0..35)
.map(|row| format!("old{row} {}", "word ".repeat(15)))
.collect::<Vec<_>>()
.join("\n");
let after = before.replace("old", "new");
let mut editor = readonly_diff_editor(syntax_diff_file("anchor.txt", &before, &after));
editor.handle(Input::Char('\t'));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap();
let mut views = BufferViewports::default();
for _ in 0..12 {
editor.handle(Input::Down);
}
render_native_diff(&mut terminal, &mut views, &mut editor);
for _ in 0..3 {
editor.handle(Input::Up);
}
for _ in 0..30 {
editor.handle(Input::Right);
}
let row = render_native_diff(&mut terminal, &mut views, &mut editor).y;
let source = editor.cursor();
for _ in 0..4 {
editor.execute("word_wrap.toggle");
assert_eq!(
render_native_diff(&mut terminal, &mut views, &mut editor).y,
row
);
assert_eq!(
render_native_diff(&mut terminal, &mut views, &mut editor).y,
row
);
assert_eq!(editor.cursor(), source);
}
}
#[test]
fn wrapped_diff_aligns_continuations_and_preserves_native_selection_and_caret() {
let before = format!("// {}BEFORE_END\nsentinel", "old ".repeat(70));
let after = format!("// {}AFTER_END\nsentinel", "new ".repeat(20));
let mut editor = readonly_diff_editor(syntax_diff_file("history.rs", &before, &after));
editor.execute("word_wrap.toggle");
editor.handle(Input::Char('\t'));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(140, 20)).unwrap();
let mut views = BufferViewports::default();
render_native_diff(&mut terminal, &mut views, &mut editor);
let geometry = views.panels.diff.as_ref().unwrap();
let before_area = diff_inner(geometry.regions[1].0);
let after_area = diff_inner(geometry.regions[2].0);
let buffer = terminal.backend().buffer();
let find_sentinel = |area: Rect| {
(area.y..area.bottom())
.find(|y| {
(area.x..area.right())
.map(|x| buffer[(x, *y)].symbol())
.collect::<String>()
.contains("sentinel")
})
.unwrap()
};
assert_eq!(find_sentinel(before_area), find_sentinel(after_area));
editor.handle(Input::End);
let caret = render_native_diff(&mut terminal, &mut views, &mut editor);
assert!(before_area.contains(caret));
assert_eq!(
terminal.backend().buffer()[(caret.x, caret.y)].symbol(),
"D"
);
assert_eq!(
terminal.backend().buffer()[(caret.x, caret.y)].fg,
Color::DarkGray
);
editor.handle(Input::Char('v'));
editor.handle(Input::Home);
render_native_diff(&mut terminal, &mut views, &mut editor);
let wrap = views.panels.diff_wrap.as_ref().unwrap();
let continuation = wrap
.rows
.iter()
.position(|row| row.position.display == 0 && row.position.continuation == 1)
.unwrap();
let x = before_area.x
+ (before_area.width - views.panels.diff.as_ref().unwrap().text_widths[0]);
assert_eq!(
terminal.backend().buffer()[(x, before_area.y + continuation as u16)].bg,
Color::Blue
);
let selection = editor.selections();
let text = editor.lines().to_vec();
editor.execute("word_wrap.toggle");
render_native_diff(&mut terminal, &mut views, &mut editor);
assert!(views.panels.diff_wrap.is_none());
assert_eq!(editor.selections(), selection);
assert_eq!(editor.lines(), text);
assert_eq!(editor.mode(), Mode::Visual);
}
#[test]
fn readonly_diff_panes_render_native_caret_visual_search_and_paired_focus() {
let mut editor = readonly_diff_editor(syntax_diff_file(
"history.rs",
"fn old() {}\nlet value = \"old\";",
"fn new() {}\nlet value = \"new\";",
));
editor.handle(Input::Char('\t'));
assert!(editor.diff_interacting());
assert!(!editor.diff_editing());
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap();
let mut views = BufferViewports::default();
views.set_theme(Some(LIGHT_DIFF_THEME));
let caret = render_native_diff(&mut terminal, &mut views, &mut editor);
let before = views.panels.diff.as_ref().unwrap().regions[1].0;
assert!(before.contains(caret));
assert_eq!(
terminal.backend().buffer()[(caret.x, caret.y)].symbol(),
"f"
);
assert_eq!(
terminal.backend().buffer()[(caret.x, caret.y)].fg,
Color::Magenta
);
editor.handle(Input::Char('v'));
editor.handle(Input::Right);
render_native_diff(&mut terminal, &mut views, &mut editor);
assert_eq!(
terminal.backend().buffer()[(caret.x, caret.y)].bg,
Color::Blue
);
editor.handle(Input::Escape);
editor.handle(Input::Down);
editor.handle(Input::Home);
editor.handle(Input::Char('V'));
render_native_diff(&mut terminal, &mut views, &mut editor);
assert_eq!(
terminal.backend().buffer()[(caret.x, caret.y + 1)].bg,
Color::Blue
);
assert_eq!(
terminal.backend().buffer()[(caret.x + 16, caret.y + 1)].bg,
Color::Blue
);
editor.handle(Input::Escape);
editor.handle(Input::Char('/'));
for ch in "value".chars() {
editor.handle(Input::Char(ch));
}
render_native_diff(&mut terminal, &mut views, &mut editor);
assert_eq!(terminal.get_cursor_position().unwrap().y, 13);
editor.handle(Input::Enter);
let searched = render_native_diff(&mut terminal, &mut views, &mut editor);
assert!(before.contains(searched));
assert_eq!(
terminal.backend().buffer()[(searched.x, searched.y)].bg,
Color::Yellow
);
assert_eq!(
terminal.backend().buffer()[(searched.x, searched.y)].fg,
Color::Black
);
let before_row = editor
.diff()
.unwrap()
.source_display_row(Focus::Before, editor.cursor().row)
.unwrap();
let column = display_column(&editor.lines()[editor.cursor().row], editor.cursor().column);
editor.handle(Input::Char('\t'));
assert!(editor.diff_interacting());
assert!(!editor.diff_editing());
let switched = render_native_diff(&mut terminal, &mut views, &mut editor);
let after = views.panels.diff.as_ref().unwrap().regions[2].0;
assert!(after.contains(switched));
assert_eq!(switched.y, searched.y);
assert_eq!(switched.x - after.x, searched.x - before.x);
assert_eq!(
editor
.diff()
.unwrap()
.source_display_row(Focus::After, editor.cursor().row),
Some(before_row)
);
assert_eq!(
display_column(&editor.lines()[editor.cursor().row], editor.cursor().column),
column
);
editor.handle(Input::Right);
let moved = render_native_diff(&mut terminal, &mut views, &mut editor);
editor.handle(Input::Char('\t'));
editor.handle(Input::Char('\t'));
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
let returned = render_native_diff(&mut terminal, &mut views, &mut editor);
assert!(before.contains(returned));
assert_eq!(returned.y, moved.y);
assert_eq!(returned.x - before.x, moved.x - after.x);
assert_eq!(returned.x, searched.x + 1);
}
#[test]
fn readonly_diff_viewport_retains_both_offsets_on_direction_and_focus_reversal() {
let before = (0..80)
.map(|row| format!("old row {row:03} {}", "x".repeat(100)))
.collect::<Vec<_>>()
.join("\n");
let after = before.replace("old row", "new row");
let mut editor = readonly_diff_editor(syntax_diff_file("history.txt", &before, &after));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap();
let mut views = BufferViewports::default();
let mut retained_before_left = 0;
for focus in [Focus::Before, Focus::After] {
editor.handle(Input::Char('\t'));
assert_eq!(editor.diff().unwrap().focus, focus);
assert!(editor.diff_interacting());
assert!(!editor.diff_editing());
render_native_diff(&mut terminal, &mut views, &mut editor);
for _ in 0..40 {
editor.handle(Input::Down);
render_native_diff(&mut terminal, &mut views, &mut editor);
}
let top = editor.diff().unwrap().top;
assert!(top > 0);
let bottom = render_native_diff(&mut terminal, &mut views, &mut editor);
editor.handle(Input::Up);
let reversed = render_native_diff(&mut terminal, &mut views, &mut editor);
assert_eq!(editor.diff().unwrap().top, top);
assert_eq!(reversed.y + 1, bottom.y);
while editor
.diff()
.unwrap()
.source_display_row(focus, editor.cursor().row)
.unwrap()
> top
{
editor.handle(Input::Up);
render_native_diff(&mut terminal, &mut views, &mut editor);
assert_eq!(editor.diff().unwrap().top, top);
}
editor.handle(Input::Up);
render_native_diff(&mut terminal, &mut views, &mut editor);
assert!(editor.diff().unwrap().top < top);
for _ in 0..80 {
editor.handle(Input::Right);
render_native_diff(&mut terminal, &mut views, &mut editor);
}
let left = if focus == Focus::Before {
editor.diff().unwrap().left_before
} else {
editor.diff().unwrap().left_after
};
assert!(left > 0);
let right = render_native_diff(&mut terminal, &mut views, &mut editor);
editor.handle(Input::Left);
let reversed = render_native_diff(&mut terminal, &mut views, &mut editor);
let offsets = views.panels.diff.as_ref().unwrap().viewport.unwrap();
assert_eq!(
if focus == Focus::Before {
offsets.3
} else {
offsets.4
},
left
);
assert_eq!(reversed.x + 1, right.x);
while editor.cursor().column > left {
editor.handle(Input::Left);
render_native_diff(&mut terminal, &mut views, &mut editor);
let diff = editor.diff().unwrap();
assert_eq!(
if focus == Focus::Before {
diff.left_before
} else {
diff.left_after
},
left
);
}
editor.handle(Input::Left);
render_native_diff(&mut terminal, &mut views, &mut editor);
if focus == Focus::Before {
retained_before_left = editor.diff().unwrap().left_before;
assert!(retained_before_left < left);
} else {
assert!(editor.diff().unwrap().left_after < left);
assert_eq!(editor.diff().unwrap().left_before, retained_before_left);
}
}
}
#[test]
fn diff_semantic_colors_stay_buffer_local_across_focus_changes() {
for writable in [false, true] {
let directory = Directory::new();
let path = directory.file("live.rs", "let value = 2;");
let mut editor = Editor::open(Some(path), vec![]).unwrap();
editor.execute("diff");
let (id, _) = editor.take_diff_request().unwrap();
editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog())));
editor.handle(Input::Down);
editor.handle(Input::Enter);
editor.handle(Input::Enter);
let (id, _) = editor.take_diff_request().unwrap();
editor.apply_diff_result(
id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
root: directory.0.clone(),
editable_paths: if writable {
[std::path::PathBuf::from("live.rs")].into()
} else {
Default::default()
},
before: "baseline".into(),
after: "Working checkout".into(),
files: vec![syntax_diff_file(
"live.rs",
"let value = 1;",
"let value = 2;",
)],
})),
);
editor.handle(Input::Char('\t'));
assert_eq!(editor.diff_buffer_focus(), Some(Focus::Before));
editor
.replace_semantic_tokens(
editor.buffer_id(),
editor.text_revision(),
Some(Arc::from([semantic_token(
0,
4,
9,
SemanticTokenKind::Parameter,
0,
)])),
)
.unwrap();
editor.handle(Input::Char('\t'));
editor
.replace_semantic_tokens(
editor.buffer_id(),
editor.text_revision(),
Some(Arc::from([semantic_token(
0,
4,
9,
SemanticTokenKind::Function,
SemanticTokenModifier::Declaration as u16,
)])),
)
.unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap();
let mut views = BufferViewports::default();
views.set_theme(Some(LIGHT_DIFF_THEME));
for _ in 0..4 {
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let regions = views.panels.diff.as_ref().unwrap().regions;
for (index, color, bold) in
[(1, Color::LightCyan, false), (2, Color::LightBlue, true)]
{
let pane = regions[index].0;
let cell = &terminal.backend().buffer()[(pane.x + 8, pane.y + 1)];
assert_eq!(cell.symbol(), "v");
assert_eq!(cell.fg, color);
assert_eq!(cell.modifier.contains(ratatui::style::Modifier::BOLD), bold);
}
editor.handle(Input::Char('\t'));
}
editor
.replace_semantic_tokens(editor.buffer_id(), editor.text_revision(), None)
.unwrap();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let regions = views.panels.diff.as_ref().unwrap().regions;
let before = regions[1].0;
let after = regions[2].0;
assert_eq!(
terminal.backend().buffer()[(before.x + 8, before.y + 1)].fg,
Color::LightCyan
);
let value = &terminal.backend().buffer()[(after.x + 8, after.y + 1)];
assert_eq!(value.fg, Color::Reset);
assert!(!value.modifier.contains(ratatui::style::Modifier::BOLD));
assert_eq!(
terminal.backend().buffer()[(after.x + 4, after.y + 1)].fg,
Color::Magenta
);
}
}
#[test]
fn editable_after_renders_live_syntax_caret_visual_selection_and_command_prompt() {
let directory = Directory::new();
let path = directory.file("live.rs", "let value = \"new\";");
let mut editor = Editor::open(Some(path), vec![]).unwrap();
editor.set_clipboard_mode("separate").unwrap();
editor.execute("diff");
let (id, _) = editor.take_diff_request().unwrap();
editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog())));
editor.handle(Input::Down);
editor.handle(Input::Enter);
editor.handle(Input::Enter);
let (id, _) = editor.take_diff_request().unwrap();
editor.apply_diff_result(
id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
root: directory.0.clone(),
editable_paths: [std::path::PathBuf::from("live.rs")].into(),
before: "baseline".into(),
after: "Working checkout".into(),
files: vec![syntax_diff_file(
"live.rs",
"let value = \"old\";",
"let value = \"new\";",
)],
})),
);
editor.handle(Input::Char('\t'));
editor.handle(Input::Char('\t'));
assert!(editor.diff_editing());
editor.handle(Input::Char('c'));
editor.handle(Input::Char('c'));
editor.handle(Input::Paste("// live comment".into()));
editor.handle(Input::Escape);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let after = views.panels.diff.as_ref().unwrap().regions[2].0;
let caret = terminal.get_cursor_position().unwrap();
assert!(after.contains(caret));
let text_x = after.x + 4;
assert_eq!(terminal.backend().buffer()[(text_x, caret.y)].symbol(), "/");
assert_eq!(
terminal.backend().buffer()[(text_x, caret.y)].fg,
Color::DarkGray
);
editor.handle(Input::Home);
editor.handle(Input::Char('v'));
editor.handle(Input::Right);
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(
terminal.backend().buffer()[(text_x, caret.y)].bg,
Color::Blue
);
editor.handle(Input::Escape);
editor.handle(Input::Char(':'));
for ch in "diff.close".chars() {
editor.handle(Input::Char(ch));
}
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(terminal.get_cursor_position().unwrap().y, 13);
assert!(screen_text(&terminal).contains(":diff.close"));
editor.handle(Input::Enter);
assert!(editor.diff().is_none());
assert_eq!(editor.lines(), ["// live comment"]);
assert!(editor.dirty());
}
#[test]
fn session_diff_draw_retains_rendered_offsets_until_caret_crosses_edges() {
let directory = Directory::new();
let original = (0..80)
.map(|row| format!("old row {row:03} {}", "x".repeat(100)))
.collect::<Vec<_>>()
.join("\n");
let path = directory.file("source.txt", &original);
let git = |args: &[&str]| {
let output = std::process::Command::new("git")
.args(args)
.current_dir(&directory.0)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_AUTHOR_NAME", "Diff fixture")
.env("GIT_AUTHOR_EMAIL", "fixture@example.invalid")
.env("GIT_COMMITTER_NAME", "Diff fixture")
.env("GIT_COMMITTER_EMAIL", "fixture@example.invalid")
.env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z")
.output()
.unwrap();
assert!(output.status.success(), "{output:?}");
};
git(&["init", "-q"]);
git(&["add", "."]);
git(&["commit", "-qm", "base"]);
directory.file("source.txt", &original.replace("old row", "new row"));
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
let ready = |session: &mut EditorSession, phase| {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
session.poll();
let diff = session.editor().diff().unwrap();
assert!(diff.error.is_none(), "{:?}", diff.error);
if diff.phase == phase && !diff.loading {
break;
}
assert!(std::time::Instant::now() < deadline, "{diff:?}");
std::thread::sleep(Duration::from_millis(1));
}
};
session.execute("diff");
ready(&mut session, Phase::Before);
session.handle(Input::Down);
session.handle(Input::Enter);
session.handle(Input::Enter);
ready(&mut session, Phase::View);
session.handle(Input::Char('\t'));
session.handle(Input::Char('\t'));
assert!(session.editor().diff_editing());
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap();
let mut views = BufferViewports::default();
let mut render = |session: &mut EditorSession| {
terminal
.draw(|frame| views.draw_session(frame, session))
.unwrap();
terminal.get_cursor_position().unwrap()
};
render(&mut session);
for _ in 0..40 {
session.handle(Input::Down);
render(&mut session);
}
let top = session.editor().diff().unwrap().top;
assert!(top > 0);
let bottom_caret = render(&mut session);
session.handle(Input::Up);
let reversed_caret = render(&mut session);
assert_eq!(session.editor().diff().unwrap().top, top);
assert_eq!(reversed_caret.y + 1, bottom_caret.y);
while session
.editor()
.diff()
.unwrap()
.after_display_row(session.editor().cursor().row)
.unwrap()
> top
{
session.handle(Input::Up);
render(&mut session);
assert_eq!(session.editor().diff().unwrap().top, top);
}
session.handle(Input::Up);
render(&mut session);
assert!(session.editor().diff().unwrap().top < top);
for _ in 0..80 {
session.handle(Input::Right);
render(&mut session);
}
let left = session.editor().diff().unwrap().left_after;
assert!(left > 0);
let right_caret = render(&mut session);
session.handle(Input::Left);
let reversed_caret = render(&mut session);
assert_eq!(session.editor().diff().unwrap().left_after, left);
assert_eq!(reversed_caret.x + 1, right_caret.x);
while session.editor().cursor().column > left {
session.handle(Input::Left);
render(&mut session);
assert_eq!(session.editor().diff().unwrap().left_after, left);
}
session.handle(Input::Left);
render(&mut session);
assert!(session.editor().diff().unwrap().left_after < left);
}
#[test]
fn diff_wheel_routes_right_tree_and_both_panes_without_touching_document() {
let directory = Directory::new();
let path = directory.file("source.txt", &["original"; 60].join("\n"));
let git = |args: &[&str]| {
let output = std::process::Command::new("git")
.args(args)
.current_dir(&directory.0)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_AUTHOR_NAME", "Diff fixture")
.env("GIT_AUTHOR_EMAIL", "fixture@example.invalid")
.env("GIT_COMMITTER_NAME", "Diff fixture")
.env("GIT_COMMITTER_EMAIL", "fixture@example.invalid")
.env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z")
.output()
.unwrap();
assert!(output.status.success(), "{:?}", output);
};
git(&["init", "-q"]);
for index in 0..8 {
directory.file(
&format!("file-{index}.txt"),
&format!("{}\nsecond\nthird\n", "before.".repeat(30)),
);
}
git(&["add", "."]);
git(&["commit", "-qm", "base"]);
for index in 0..8 {
directory.file(
&format!("file-{index}.txt"),
&format!("{}\nsecond\nthird\n", "after.".repeat(30)),
);
}
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
let original = session.editor().lines().to_vec();
let cursor = session.editor().cursor();
let ready = |session: &mut EditorSession, phase| {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
session.poll();
let diff = session.editor().diff().unwrap();
assert!(diff.error.is_none(), "{:?}", diff.error);
if diff.phase == phase && !diff.loading {
break;
}
assert!(std::time::Instant::now() < deadline, "{diff:?}");
std::thread::sleep(Duration::from_millis(1));
}
};
session.execute("diff");
ready(&mut session, Phase::Before);
session.handle(Input::Enter);
session.handle(Input::Down);
session.handle(Input::Enter);
ready(&mut session, Phase::View);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
let regions = viewports.panels.diff.as_ref().unwrap().regions;
let tree = regions[0].0;
let before = regions[1].0;
let after = regions[2].0;
let initial_focus = session.editor().diff().unwrap().focus;
let initial_cursor = session.editor().cursor();
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollRight, before.x + 1, before.y + 1)
));
terminal
.draw(|frame| viewports.draw_session(frame, &mut session))
.unwrap();
assert!(screen_text(&terminal).contains("ore.before."));
assert_eq!(session.editor().diff().unwrap().left_before, 3);
assert_eq!(session.editor().diff().unwrap().left_after, 3);
let mut shift = document_wheel(MouseEventKind::ScrollDown, after.x + 1, after.y + 1);
shift.modifiers = KeyModifiers::SHIFT;
assert!(viewports.scroll(&mut session, shift));
terminal
.draw(|frame| viewports.draw_session(frame, &mut session))
.unwrap();
assert!(screen_text(&terminal).contains("after.after."));
assert_eq!(session.editor().diff().unwrap().left_before, 6);
assert_eq!(session.editor().diff().unwrap().left_after, 6);
assert_eq!(session.editor().diff().unwrap().top, 0);
assert_eq!(session.editor().diff().unwrap().focus, initial_focus);
assert_eq!(session.editor().cursor(), initial_cursor);
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, tree.x + 1, tree.y + 1),
));
let diff = session.editor().diff().unwrap();
assert_eq!(diff.focus, Focus::Files);
assert_eq!(diff.files.as_ref().unwrap().selected, 3);
assert_eq!(diff.file, 0);
session.handle(Input::Enter);
assert_eq!(session.editor().diff().unwrap().file, 3);
ready(&mut session, Phase::View);
terminal
.draw(|frame| viewports.draw_session(frame, &mut session))
.unwrap();
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, before.x + 1, before.y + 1),
));
let diff = session.editor().diff().unwrap();
assert_eq!(diff.focus, Focus::Before);
assert_eq!(diff.top, 2);
assert_eq!(diff.file, 3);
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollUp, after.x + 1, after.y + 1),
));
let diff = session.editor().diff().unwrap();
assert_eq!(diff.focus, Focus::After);
assert_eq!(diff.top, 0);
assert_eq!(diff.file, 3);
let selected = diff.files.as_ref().unwrap().selected;
session.handle(Input::Char(' '));
session.handle(Input::Char('e'));
session.handle(Input::Ctrl('w'));
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, tree.x + 1, tree.y + 1),
));
let diff = session.editor().diff().unwrap();
assert_eq!(diff.focus, Focus::After);
assert_eq!(diff.files.as_ref().unwrap().selected, selected);
assert!(!diff.files.as_ref().unwrap().focused);
assert!(viewports.panels.files.is_none());
assert!(viewports.document.geometry.is_none());
session.execute("diff.close");
assert_eq!(session.editor().lines(), original);
assert_eq!(session.editor().cursor(), cursor);
}
#[test]
fn diff_wheel_cannot_scroll_the_hidden_document_even_outside_the_picker() {
let directory = Directory::new();
let path = directory.file("rows.txt", &["original"; 60].join("\n"));
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
let id = session.editor().buffer_id();
let offset = viewports.offsets[&id];
let cursor = session.editor().cursor();
session.execute("diff");
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 0, 0)
));
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 60, 15)
));
assert_eq!(viewports.offsets[&id], offset);
assert_eq!(session.editor().cursor(), cursor);
assert!(viewports.document.geometry.is_none());
session.execute("diff.close");
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
assert!(viewports.document.geometry.is_some());
}
#[test]
fn completion_windows_selected_unicode_without_overwriting_gutter_dock_or_cursor() {
let mut completion = Completion {
items: (0..20)
.map(|index| crate::plugin::CompletionItem {
label: if index == 19 {
"界e\u{301}👩‍🚀\u{1b}".into()
} else {
format!("item-{index:02}")
},
detail: Some("\tTAIL".into()),
primary_edit: 0,
edits: vec![],
snippet: None,
})
.collect(),
selected: 20,
};
for (width, height) in [(14, 6), (9, 1), (5, 1)] {
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
let workspace = Rect::new(2, 0, width - 4, height);
let caret = Position::new(2, 0);
let mut panel = None;
completion.selected = 20;
terminal
.draw(|frame| {
let rows: Vec<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, &mut editor))
.unwrap();
let buffer = terminal.backend().buffer();
// A wide glyph's leading cell owns its terminal style.
for x in (0..28).filter(|x| *x != 8) {
assert_eq!(
buffer[(x, 0)].bg == Color::Blue,
selected.contains(&x),
"column {x}, selection {:?}, screen:\n{}",
editor.snippet_selections(),
screen_text(&terminal),
);
}
assert_eq!(terminal.get_cursor_position().unwrap(), caret);
}
editor.handle(Input::Escape);
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
for x in 0..28 {
assert_ne!(terminal.backend().buffer()[(x, 0)].bg, Color::Blue);
}
}
#[test]
fn hover_shows_more_documentation_with_markdown_and_fenced_code_colors() {
let mut editor = Editor::open(None, vec![]).unwrap();
let title = format!("# {}TAIL", "Heading ".repeat(11));
let details = (5..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();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(6, 1)).unwrap();
let mut syntax = SyntaxCache::new();
let (mut top, mut left) = (0, 0);
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
);
})
.unwrap();
let document_cursor = terminal.get_cursor_position().unwrap();
editor.show_hover("x界e\u{301}👩‍🚀z\nlast".into()).unwrap();
for _ in 0..2 {
editor.handle(Input::Right);
}
let mut panels = WheelPanels::default();
let rendered = terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut panels,
)
})
.unwrap();
let origin = panels.hover.unwrap().0;
assert_eq!(rendered.buffer[(origin.x, origin.y)].symbol(), "e\u{301}");
assert_eq!(rendered.buffer[(origin.x + 1, origin.y)].symbol(), "👩‍🚀");
assert_eq!(rendered.buffer[(origin.x + 3, origin.y)].symbol(), "z");
assert_eq!(terminal.get_cursor_position().unwrap(), document_cursor);
assert_eq!((top, left), (0, 0));
editor.handle(Input::Down);
editor.handle(Input::Home);
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
assert!(screen_text(&terminal).trim().ends_with("last"));
for (width, height) in [(1, 1), (2, 2), (3, 3), (4, 4)] {
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
}
assert_eq!(editor.cursor(), crate::editor::Cursor::default());
assert_eq!(editor.lines(), &[""]);
}
fn annotation(
start: (usize, usize),
end: (usize, usize),
severity: DiagnosticSeverity,
message: &str,
) -> Diagnostic {
Diagnostic {
start: crate::plugin::TextPosition {
row: start.0,
column: start.1,
},
end: crate::plugin::TextPosition {
row: end.0,
column: end.1,
},
severity,
message: message.into(),
}
}
fn semantic_token(
row: usize,
start: usize,
end: usize,
kind: SemanticTokenKind,
modifiers: u16,
) -> SemanticToken {
SemanticToken {
start: crate::plugin::TextPosition { row, column: start },
end: crate::plugin::TextPosition { row, column: end },
kind,
modifiers,
}
}
#[test]
fn diagnostic_underlines_preserve_syntax_and_whole_graphemes_at_viewport_edges() {
let directory = Directory::new();
let source = "#a\t界e\u{301}👩‍🚀z";
let mut editor = Editor::open(Some(directory.file("unicode.py", source)), vec![]).unwrap();
editor
.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, &mut editor))
.unwrap();
let buffer = terminal.backend().buffer();
for (position, color) in [
((3, 0), Color::Yellow),
((4, 0), Color::Red),
((5, 0), Color::Yellow),
((2, 1), Color::Yellow),
((2, 2), Color::Yellow),
((3, 2), Color::Yellow),
((4, 2), Color::Yellow),
((2, 3), Color::DarkGray),
((6, 3), Color::Cyan),
] {
assert!(buffer[position].modifier.contains(Modifier::UNDERLINED));
assert_eq!(buffer[position].underline_color, color);
}
for (row, color) in [
(0, Color::Red),
(1, Color::Yellow),
(2, Color::Yellow),
(3, Color::Cyan),
] {
assert_eq!(buffer[(0, row)].fg, color);
}
assert!(!buffer[(6, 0)].modifier.contains(Modifier::UNDERLINED));
let text = screen_text(&terminal);
assert!(text.contains("E:1 W:1 I:1 H:1"), "{text}");
assert!(
text.contains("write failed: permission denied | E: point"),
"{text}"
);
assert_eq!(
terminal.get_cursor_position().unwrap(),
ratatui::layout::Position::new(4, 0),
);
editor.handle(Input::Char('i'));
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert_eq!(editor.diagnostics().count(), 0);
assert!(!screen_text(&terminal).contains("E:1"));
assert!(!screen_text(&terminal).contains("E: point"));
assert_ne!(terminal.backend().buffer()[(0, 0)].fg, Color::Red);
assert!(
!terminal.backend().buffer()[(4, 0)]
.modifier
.contains(Modifier::UNDERLINED)
);
for message in ["intermediate", "latest"] {
editor
.replace_diagnostics(
"fixture",
editor.buffer_id(),
editor.text_revision(),
vec![annotation(
(0, 0),
(0, 3),
DiagnosticSeverity::Error,
message,
)],
)
.unwrap();
}
editor.handle(Input::Escape);
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert!(screen_text(&terminal).contains("E:1"));
assert!(screen_text(&terminal).contains("E: latest"));
assert!(!screen_text(&terminal).contains("intermediate"));
assert_eq!(terminal.backend().buffer()[(0, 0)].fg, Color::Red);
assert!(
terminal.backend().buffer()[(3, 0)]
.modifier
.contains(Modifier::UNDERLINED)
);
editor.handle(Input::Char('i'));
editor.handle(Input::Char('!'));
assert_eq!(editor.mode(), Mode::Insert);
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert_eq!(editor.diagnostics().count(), 0);
assert!(!screen_text(&terminal).contains("E:1"));
assert!(
!terminal.backend().buffer()[(4, 0)]
.modifier
.contains(Modifier::UNDERLINED)
);
editor.handle(Input::Escape);
assert_eq!(editor.diagnostics().count(), 0);
}
#[test]
fn idle_session_updates_redraw_annotations_and_navigation_while_native_editing_continues() {
let directory = Directory::new();
let source = directory.file("source.txt", "broken TARGET");
let target_text = format!("{}{}TARGET", "# line\n".repeat(15), "#".repeat(40));
let target = directory.file("target.py", &target_text);
directory.file(
"semantic.rb",
r#"
Vamp.plugin "semantic", requires: ["text", "diagnostics"], async: true
work = Queue.new
Vamp.on_document { |event| work << ["document", event] }
Vamp.command "fixture_definition" do |editor, _|
raise "No target reference" unless editor.text.include?("TARGET")
work << ["definition", editor.buffer_id, editor.revision, editor.request_id]
end
Thread.new do
documents = {}
loop do
kind, *args = work.pop
if kind == "document"
event = args[0]
document = event.fetch("document")
if event.fetch("event") == "close"
documents.delete(document.fetch("id"))
next
end
documents[document.fetch("id")] = document
next unless ["open", "change"].include?(event.fetch("event"))
items = []
document.fetch("text").split("\n", -1).each_with_index do |line, row|
graphemes = line.each_grapheme_cluster.to_a
graphemes.each_cons(6).with_index do |token, column|
next unless token.join == "broken"
items << {start: {row: row, column: column},
end: {row: row, column: column + 6},
severity: "error", message: "Unresolved token"}
end
end
Vamp.publish([{type: "diagnostics", buffer_id: document.fetch("id"),
revision: document.fetch("revision"), items: items},
{type: "message", text: "diagnostic revision #{document.fetch('revision')}"}])
else
target = documents.values.find { |document| document["path"] == Vamp.options.fetch("target") }
raise "Target document is not open" unless target
lines = target.fetch("text").split("\n", -1)
row = lines.index { |line| line.include?("TARGET") }
raise "Target definition missing" unless row
column = lines[row].each_grapheme_cluster.to_a.each_cons(6).find_index { |token| token.join == "TARGET" }
Vamp.publish([{type: "navigate", buffer_id: args[0], revision: args[1],
path: target.fetch("path"), row: row, column: column}],
request_id: args[2])
end
end
end
"#,
);
let config = directory.file(
"config.rb",
&format!(
"Vamp.configure do |config|\n config.clipboard_mode = \"separate\"\n config.plugin \"semantic.rb\", options: {{target: {}}}\nend\n",
serde_json::to_string(&target.to_string_lossy()).unwrap(),
),
);
let mut session = EditorSession::open(
Some(source),
CAPABILITIES
.iter()
.map(|capability| (*capability).into())
.collect(),
Some(&config),
)
.unwrap();
let source_id = session.editor().buffer_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(18, 6)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
fn poll_until(
session: &mut EditorSession,
terminal: &mut Terminal<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_mut()))
.unwrap();
}
if ready(session.editor()) {
return;
}
std::thread::sleep(Duration::from_millis(1));
}
panic!(
"async fixture did not update editor: {}",
session.editor().message()
);
}
session.handle(Input::Char('A'));
session.handle(Input::Char('!'));
assert_eq!(session.editor().mode(), Mode::Insert);
assert_eq!(session.editor().lines(), &["broken TARGET!"]);
poll_until(&mut session, &mut terminal, &mut viewports, |editor| {
editor.message() == "diagnostic revision 1"
});
assert_eq!(session.editor().diagnostics().count(), 0);
assert!(!screen_text(&terminal).contains("E:1"));
assert!(
!terminal.backend().buffer()[(2, 0)]
.modifier
.contains(Modifier::UNDERLINED)
);
assert_eq!(session.editor().mode(), Mode::Insert);
session.handle(Input::Escape);
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
assert!(screen_text(&terminal).contains("E:1"));
assert!(
terminal.backend().buffer()[(2, 0)]
.modifier
.contains(Modifier::UNDERLINED)
);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["broken TARGET"]);
assert_eq!(session.editor().diagnostics().count(), 0);
session.execute(&format!("edit {}", target.display()));
let target_id = session.editor().buffer_id();
session.handle(Input::Char('i'));
session.handle(Input::Char('!'));
session.handle(Input::Escape);
session.execute(&format!("buffer {source_id}"));
session.execute("fixture_definition");
session.handle(Input::Char('l'));
assert_eq!(session.editor().buffer_id(), source_id);
poll_until(&mut session, &mut terminal, &mut viewports, |editor| {
editor.buffer_id() == target_id
});
assert_eq!(session.editor().buffer_count(), 2);
assert!(session.editor().dirty());
assert_eq!(session.editor().lines()[0], "!# line");
assert_eq!(session.editor().cursor().row, 15);
assert_eq!(session.editor().cursor().column, 40);
assert_eq!(session.editor().mode(), Mode::Normal);
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, &mut editor))
.unwrap();
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow);
let revision = editor.text_revision();
editor.handle(Input::Right);
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert_eq!(editor.text_revision(), revision);
assert_eq!(editor.lines(), &["42"]);
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow);
editor.handle(Input::Home);
editor.handle(Input::Char('v'));
editor.handle(Input::Right);
editor.handle(Input::Char('c'));
for ch in "\"ok\"".chars() {
editor.handle(Input::Char(ch));
}
editor.handle(Input::Escape);
assert_eq!(editor.lines(), &["\"ok\""]);
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Green);
assert!(editor.text_revision() > revision);
editor.handle(Input::Char('u'));
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert_eq!(editor.lines(), &["42"]);
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow);
editor
.apply(vec![crate::editor::Effect::Replace(
"fn main() { let n = 42; let s = \"ok\"; } // note".into(),
)])
.unwrap();
editor.handle(Input::Home);
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
let buffer = terminal.backend().buffer();
let source = &editor.lines()[0];
for (token, color) in [
("fn", Color::Magenta),
("main", Color::LightBlue),
("let", Color::Magenta),
("42", Color::Yellow),
("\"ok\"", Color::Green),
] {
let x = 2 + source.find(token).unwrap() as u16;
assert_eq!(buffer[(x, 0)].fg, color, "{token}");
assert_eq!(buffer[(x, 0)].bg, Color::Reset);
}
}
#[test]
fn syntax_continuations_above_viewport_refresh_when_the_opening_line_changes() {
let directory = Directory::new();
for (name, before, after, color) in [
(
"comment.rs",
"/*\nhidden\nvisible\n*/",
"//\nhidden\nvisible\n//",
Color::DarkGray,
),
(
"string.py",
"text = \"\"\"\nhidden\nvisible\n\"\"\"",
"text = \"\"\nhidden\nvisible\n\"\"",
Color::Green,
),
] {
let path = directory.file(name, before);
let mut editor = Editor::open(Some(path), vec![]).unwrap();
editor.handle(Input::Down);
editor.handle(Input::Down);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(30, 3)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
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, &mut 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, &mut 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, &mut editor))
.unwrap();
let buffer = terminal.backend().buffer();
let foregrounds = [0, 3, 8, 14, 21, 27, 33, 39].map(|column| buffer[(column + 2, 0)].fg);
assert_ne!(foregrounds[0], foregrounds[1]);
assert_ne!(foregrounds[1], foregrounds[2]);
assert_ne!(foregrounds[2], foregrounds[4]);
assert_eq!(foregrounds[2], foregrounds[3]);
assert_eq!(foregrounds[4], foregrounds[5]);
assert_eq!(foregrounds[6], foregrounds[7]);
for (column, is_readonly) in [
(0, false),
(3, false),
(8, false),
(14, true),
(21, false),
(27, true),
(33, false),
(39, true),
] {
assert_eq!(
buffer[(column + 2, 0)].modifier.contains(Modifier::ITALIC),
is_readonly,
);
}
}
#[test]
fn semantic_colors_modifiers_and_syntax_fallback_cover_whole_graphemes_and_clipped_tabs() {
let directory = Directory::new();
let source = "#a\t界e\u{301}👩‍🚀z";
let mut editor = Editor::open(Some(directory.file("unicode.py", source)), vec![]).unwrap();
editor.set_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, &mut editor))
.unwrap();
let found = editor.search_match().unwrap();
let y = found.start.row as u16;
for x in 4..8 {
assert_eq!(terminal.backend().buffer()[(x, y)].fg, Color::Black);
assert_eq!(terminal.backend().buffer()[(x, y)].bg, Color::Yellow);
assert!(
terminal.backend().buffer()[(x, y)]
.modifier
.contains(Modifier::UNDERLINED)
);
}
editor.handle(Input::Char('v'));
editor.handle(Input::Char('e'));
editor.handle(Input::Ctrl('a'));
assert_eq!(editor.selection_count(), 3);
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
let buffer = terminal.backend().buffer();
for selection in editor.selections() {
let y = selection.start.row as u16;
let start =
display_column(&editor.lines()[selection.start.row], selection.start.column);
let end = display_column(&editor.lines()[selection.end.row], selection.end.column);
for column in start..end {
let x = 2 + column as u16;
if buffer[(x, y)].bg == Color::Magenta {
assert_eq!(buffer[(x, y)].fg, Color::Black);
} else {
assert_eq!(buffer[(x, y)].fg, Color::White);
assert_eq!(buffer[(x, y)].bg, Color::Blue);
}
assert!(buffer[(x, y)].modifier.contains(Modifier::UNDERLINED));
}
assert_eq!(buffer[(2, y)].fg, Color::DarkGray);
}
for caret in editor
.carets()
.into_iter()
.filter(|caret| *caret != editor.cursor())
{
let x = 2 + display_column(&editor.lines()[caret.row], caret.column) as u16;
assert_eq!(buffer[(x, caret.row as u16)].fg, Color::Black);
assert_eq!(buffer[(x, caret.row as u16)].bg, Color::Magenta);
assert!(
buffer[(x, caret.row as u16)]
.modifier
.contains(Modifier::UNDERLINED)
);
}
}
#[test]
fn syntax_redetects_saved_paths_without_text_changes_and_plain_text_keeps_default_color() {
let directory = Directory::new();
let mut editor =
Editor::open(Some(directory.file("source.rs", "let value = 42;")), vec![]).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Magenta);
let revision = editor.text_revision();
for name in ["source.vampunknown", "source.txt", "again.rs"] {
editor.execute(&format!("w {}", directory.0.join(name).display()));
assert_eq!(editor.text_revision(), revision);
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
if name.ends_with(".rs") {
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Magenta);
assert_eq!(
viewports.syntax[&editor.buffer_id()].language(),
Some("Rust")
);
} else {
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Reset);
assert!(viewports.syntax[&editor.buffer_id()].line(0).is_empty());
assert!(viewports.syntax[&editor.buffer_id()].language().is_none());
}
assert_eq!(editor.lines(), &["let value = 42;"]);
}
}
#[test]
fn syntax_caches_stay_independent_across_buffer_edits_and_retire_closed_ids() {
let directory = Directory::new();
let mut editor = Editor::open(Some(directory.file("a.rs", "42")), vec![]).unwrap();
let a_id = editor.buffer_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow);
editor.execute(&format!(
"edit {}",
directory.file("b.py", "# note").display()
));
let b_id = editor.buffer_id();
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::DarkGray);
editor
.apply(vec![crate::editor::Effect::Replace("\"ok\"".into())])
.unwrap();
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Green);
editor.execute(&format!("buffer {a_id}"));
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow);
assert_eq!(viewports.syntax[&a_id].language(), Some("Rust"));
assert_eq!(viewports.syntax[&b_id].language(), Some("Python"));
editor.execute(&format!("buffer {b_id}"));
editor.execute(&format!("bdelete {a_id}"));
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Green);
assert!(!viewports.syntax.contains_key(&a_id));
assert!(!viewports.offsets.contains_key(&a_id));
assert_eq!(viewports.syntax.len(), 1);
}
#[test]
fn translate_distinguishes_control_j_from_enter_and_normalizes_control_letters() {
for (code, modifiers, expected) in [
(
KeyCode::Char('j'),
KeyModifiers::CONTROL,
Some(Input::Ctrl('j')),
),
(
KeyCode::Char('J'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
Some(Input::Ctrl('j')),
),
(
KeyCode::Char('k'),
KeyModifiers::CONTROL,
Some(Input::Ctrl('k')),
),
(
KeyCode::Char('h'),
KeyModifiers::CONTROL,
Some(Input::Ctrl('h')),
),
(
KeyCode::Char('l'),
KeyModifiers::CONTROL,
Some(Input::Ctrl('l')),
),
(
KeyCode::Backspace,
KeyModifiers::NONE,
Some(Input::Backspace),
),
(KeyCode::Enter, KeyModifiers::NONE, Some(Input::Enter)),
(KeyCode::Enter, KeyModifiers::CONTROL, Some(Input::Enter)),
(
KeyCode::Char('J'),
KeyModifiers::SHIFT,
Some(Input::Char('J')),
),
(KeyCode::Char('j'), KeyModifiers::ALT, Some(Input::Alt('j'))),
(
KeyCode::Char('H'),
KeyModifiers::ALT | KeyModifiers::SHIFT,
Some(Input::Alt('h')),
),
(
KeyCode::Char('j'),
KeyModifiers::ALT | KeyModifiers::CONTROL,
Some(Input::CtrlAlt('j')),
),
(
KeyCode::Char('H'),
KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT,
Some(Input::CtrlAlt('h')),
),
(
KeyCode::Char('L'),
KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT,
Some(Input::CtrlAlt('l')),
),
(
KeyCode::Char('j'),
KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER,
None,
),
(
KeyCode::Left,
KeyModifiers::CONTROL | KeyModifiers::ALT,
None,
),
(KeyCode::Char('j'), KeyModifiers::SUPER, None),
(KeyCode::Left, KeyModifiers::ALT, None),
] {
assert_eq!(translate(KeyEvent::new(code, modifiers)), expected);
}
}
#[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 explorer_wraps_only_root_path_and_reserves_file_rows_in_short_panes() {
let root = "/a/very/long/path/to/project/root";
let picker = FilePicker::from_paths(root.into(), [(0, "entry.txt".into())]);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 12)).unwrap();
let mut panels = WheelPanels::default();
terminal
.draw(|frame| draw_file_list(frame, &picker, frame.area(), "", &mut panels, None))
.unwrap();
let buffer = terminal.backend().buffer();
let path = (2..4)
.map(|y| (1..19).map(|x| buffer[(x, y)].symbol()).collect::<String>())
.collect::<String>();
assert_eq!(path.trim_end(), root);
assert!(
screen_text(&terminal)
.lines()
.nth(4)
.unwrap()
.contains("entry.txt")
);
terminal.backend_mut().resize(20, 6);
terminal
.draw(|frame| draw_file_list(frame, &picker, frame.area(), "", &mut panels, None))
.unwrap();
assert!(screen_text(&terminal).contains("entry.txt"));
}
#[test]
fn list_footers_show_remapped_and_disabled_selection_keys() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_file_explorer_view("list").unwrap();
editor.set_file_picker_view("list").unwrap();
for key in ["ctrl+j", "ctrl+n", "down", "ctrl+k", "up"] {
editor.bind("selection", key, None).unwrap();
}
editor
.bind("selection", "x", Some("core.down".into()))
.unwrap();
let hint = editor.selection_navigation_hint().to_owned();
assert_eq!(hint, "x next · unbound previous");
editor.set_file_explorer_side("bottom").unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap();
let (mut top, mut left) = (0, 0);
let mut syntax = SyntaxCache::new();
for key in ['o', 'e'] {
editor.handle(Input::Char(' '));
editor.handle(Input::Char(key));
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains(&hint), "{text}");
assert!(!text.contains("Ctrl+j"), "{text}");
editor.handle(Input::Escape);
}
editor.handle(Input::Ctrl('p'));
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains(&hint), "{text}");
assert!(!text.contains("Ctrl+j"), "{text}");
}
#[test]
fn file_list_scrolls_selection_and_sanitizes_display_without_losing_paths() {
let directory = Directory::new();
for index in 0..18 {
directory.file(&format!("file-{index:02}"), "text");
}
let special = directory.file("界e\u{301} space\nname", "text");
let editor = Editor::open(None, vec![]).unwrap();
let mut picker = FilePicker::scan(
directory.0.clone(),
FilePresentation::Floating,
FileView::List,
)
.unwrap();
assert!(
picker
.entries()
.any(|entry| directory.0.join(entry.path) == special)
);
let count = picker.entries().len();
picker.selected = count - 1;
let label = picker.entries().last().unwrap().label;
assert_eq!(label, "界e\u{301} space�name");
picker.query = "long\t界e\u{301}z".into();
let area = Rect::new(3, 2, 24, 9);
let mut terminal = Terminal::with_options(
ratatui::backend::TestBackend::new(40, 15),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(area),
},
)
.unwrap();
terminal
.draw(|frame| {
draw_file_list(
frame,
&picker,
area,
editor.selection_navigation_hint(),
&mut WheelPanels::default(),
None,
)
})
.unwrap();
let text = screen_text(&terminal);
assert!(!text.contains("file-00"), "{text}");
let cursor = terminal.get_cursor_position().unwrap();
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(area.x + 7, area.y + 1)].symbol(), "�");
assert_eq!(buffer[(area.x + 8, area.y + 1)].symbol(), "界");
assert_eq!(buffer[(area.x + 10, area.y + 1)].symbol(), "e\u{301}");
let selected_y = (area.y + 2..area.bottom() - 1)
.find(|&y| buffer[(area.x + 1, y)].symbol() == ">")
.unwrap();
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);
}
}
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_mut()))
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains("Configuration error"));
assert!(text.contains("config.rb"));
assert!(text.contains("default settings and bindings"));
assert!(text.contains("last reason"));
assert!(!text.contains("界END"));
for _ in 0..100 {
session.handle(Input::Right);
}
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
let scrolled = screen_text(&terminal);
assert!(
scrolled.contains("界") && scrolled.contains("END"),
"{scrolled}"
);
for (width, height) in [(4, 4), (1, 1)] {
let mut tiny =
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
tiny.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
}
session.handle(Input::Enter);
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
assert!(!screen_text(&terminal).contains("Configuration error"));
assert_eq!(session.editor().lines(), &["keep"]);
assert_eq!(session.editor().cursor(), crate::editor::Cursor::default());
}
#[test]
fn picker_conversion_focus_palette_and_closure_preserve_buffer_viewport_cache() {
let directory = Directory::new();
let a = directory.file("a", &vec!["a".repeat(80); 30].join("\n"));
let b = directory.file("b", "other");
let mut editor = Editor::open(Some(a), vec![]).unwrap();
let a_id = editor.buffer_id();
for _ in 0..20 {
editor.handle(Input::Down);
}
editor.handle(Input::End);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(50, 10)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
let before = viewports.offsets[&a_id];
editor.handle(Input::Char(' '));
editor.handle(Input::Char('o'));
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert_eq!(viewports.offsets[&a_id], before);
assert!(screen_text(&terminal).contains("Files"));
editor.handle(Input::Escape);
assert!(editor.file_picker().unwrap().focused);
editor.handle(Input::Escape);
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert_eq!(viewports.offsets[&a_id], before);
editor.handle(Input::Char(' '));
editor.handle(Input::Char('e'));
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert!(screen_text(&terminal).contains("Explorer"));
editor.handle(Input::Escape);
let docked = editor.file_picker().unwrap();
assert!(!docked.focused);
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
let dock_offsets = viewports.offsets[&a_id];
editor.handle(Input::Ctrl('p'));
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert!(screen_text(&terminal).contains("Commands"));
assert_eq!(terminal.get_cursor_position().unwrap().y, 1);
assert_eq!(viewports.offsets[&a_id], dock_offsets);
editor.handle(Input::Escape);
editor.execute(&format!("edit {}", b.display()));
let b_id = editor.buffer_id();
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert!(editor.file_picker().is_some());
assert_eq!(viewports.offsets[&b_id], (0, 0));
editor.execute(&format!("buffer {a_id}"));
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert_eq!(viewports.offsets[&a_id], dock_offsets);
editor.handle(Input::Char(' '));
editor.handle(Input::Char('o'));
assert_eq!(
editor.file_picker().unwrap().presentation,
FilePresentation::Floating
);
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert!(screen_text(&terminal).contains("Files"));
editor.handle(Input::Ctrl('w'));
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert!(editor.file_picker().is_none());
assert_eq!(viewports.offsets[&b_id], (0, 0));
assert!(screen_text(&terminal).contains("a"));
}
#[test]
fn docked_search_highlight_and_cursor_survive_focus_and_offset_resizes() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace(
"0123456789界e\u{301}z\nsecond".into(),
)])
.unwrap();
editor.handle(Input::Char('/'));
editor.handle(Input::Char('界'));
editor.handle(Input::Enter);
assert!(editor.search_match().is_some());
editor.handle(Input::Char(' '));
editor.handle(Input::Char('e'));
let mut terminal = Terminal::with_options(
ratatui::backend::TestBackend::new(100, 32),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(Rect::new(3, 2, 90, 26)),
},
)
.unwrap();
let (mut top, mut left) = (0, 0);
let mut syntax = SyntaxCache::new();
for (name, side) in [
("left", DockSide::Left),
("right", DockSide::Right),
("top", DockSide::Top),
("bottom", DockSide::Bottom),
] {
editor.set_file_explorer_side(name).unwrap();
for (width, height) in [
(90, 26),
(12, 6),
(8, 2),
(1, 1),
(1, 2),
(0, 1),
(1, 0),
(90, 26),
] {
let area = Rect::new(3, 2, width, height);
terminal.resize(area).unwrap();
let area = terminal.get_frame().area();
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
if width > 0 && height > 0 {
let workspace = Rect::new(
area.x,
area.y,
width,
height - u16::from(height >= 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 palette_advertises_effective_leader_sequences_after_remapping() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_leader(",").unwrap();
editor.handle(Input::Ctrl('p'));
for ch in "bnext".chars() {
editor.handle(Input::Char(ch));
}
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 10)).unwrap();
let (mut top, mut left) = (0, 0);
let mut syntax = SyntaxCache::new();
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
assert!(screen_text(&terminal).contains(", b n"));
assert!(!screen_text(&terminal).contains("Space b n"));
editor
.bind("normal", ",", Some("core.command".into()))
.unwrap();
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
assert!(screen_text(&terminal).contains("unbound"));
assert!(!screen_text(&terminal).contains(", b n"));
}
fn document_wheel(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent {
MouseEvent {
kind,
column,
row,
modifiers: KeyModifiers::NONE,
}
}
#[test]
fn horizontal_wheel_preserves_selection_bounds_display_cells_and_survives_redraw() {
let directory = Directory::new();
let text = format!("abc\t界e\u{301}{}", "xyz".repeat(20));
let path = directory.file("wide.txt", &text);
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
session.handle(Input::Char('v'));
session.handle(Input::Char('l'));
let cursor = session.editor().cursor();
let selections = session.editor().selections();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(16, 6)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw_session(frame, &mut session))
.unwrap();
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollRight, 0, 0)
));
terminal
.draw(|frame| viewports.draw_session(frame, &mut session))
.unwrap();
assert_eq!(terminal.backend().buffer()[(3, 0)].symbol(), "界");
assert_eq!(terminal.backend().buffer()[(5, 0)].symbol(), "e\u{301}");
let mut shift = document_wheel(MouseEventKind::ScrollDown, 0, 0);
shift.modifiers = KeyModifiers::SHIFT;
assert!(viewports.scroll(&mut session, shift));
terminal
.draw(|frame| viewports.draw_session(frame, &mut session))
.unwrap();
assert_eq!(viewports.offsets[&session.editor().buffer_id()], (0, 6));
for _ in 0..100 {
viewports.scroll(&mut session, shift);
}
terminal
.draw(|frame| viewports.draw_session(frame, &mut session))
.unwrap();
assert_eq!(
viewports.offsets[&session.editor().buffer_id()].1,
display_column(&text, usize::MAX) + 1 - viewports.document.text_width
);
assert_eq!(session.editor().cursor(), cursor);
assert_eq!(session.editor().selections(), selections);
assert_eq!(session.editor().lines(), std::slice::from_ref(&text));
terminal.backend_mut().resize(100, 6);
terminal
.draw(|frame| viewports.draw_session(frame, &mut session))
.unwrap();
assert_eq!(viewports.offsets[&session.editor().buffer_id()], (0, 0));
assert!(screen_text(&terminal).contains("abc"));
session.handle(Input::Escape);
session.handle(Input::Char('l'));
terminal
.draw(|frame| viewports.draw_session(frame, &mut session))
.unwrap();
assert!(terminal.backend().cursor_visible());
}
#[test]
fn wheel_scrolls_rendered_document_three_rows_without_changing_any_selection() {
let directory = Directory::new();
let path = directory.file(
"rows.txt",
&(0..12)
.map(|row| format!("abc row-{row:02}"))
.collect::<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_mut()))
.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_mut()))
.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_mut()))
.unwrap();
assert!(
screen_text(&terminal)
.lines()
.next()
.unwrap()
.contains("row-00")
);
assert!(terminal.backend().cursor_visible());
}
#[test]
fn queued_typing_preserves_graphemes_history_and_mode_barriers() {
let directory = Directory::new();
let path = directory.file("input.txt", "seed");
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
session.handle(Input::Char('i'));
let mut events = [
Event::Key(KeyEvent::new(KeyCode::Char('界'), KeyModifiers::NONE)),
Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)),
]
.into_iter();
let pending = typing_ready(
&mut session,
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
|| Ok(events.next()),
)
.unwrap();
assert_eq!(session.editor().lines(), ["a界seed"]);
assert_eq!(session.editor().mode(), Mode::Insert);
assert!(matches!(pending, Some(Event::Key(key)) if key.code == KeyCode::Esc));
session.handle(Input::Escape);
let Some(Event::Key(key)) = events.next() else {
panic!("remaining command")
};
session.handle(translate(key).unwrap());
assert_eq!(session.editor().lines(), ["aseed"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), ["a界seed"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), ["seed"]);
}
#[test]
fn queued_wheel_input_renders_latest_view_without_crossing_key_or_resize_barriers() {
let directory = Directory::new();
let text = (0..24)
.map(|row| format!("row-{row:03}\n"))
.collect::<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_mut()))
.unwrap();
let mut queued = [
down.clone(),
up.clone(),
down.clone(),
barrier.clone(),
down.clone(),
]
.into_iter();
let (redraw, pending) = viewports
.scroll_ready(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 1, 1),
|| Ok(queued.next()),
)
.unwrap();
assert!(redraw);
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
assert!(
screen_text(&terminal)
.lines()
.next()
.unwrap()
.contains("row-006")
);
assert_eq!(pending, Some(barrier));
assert_eq!(queued.next(), Some(down.clone()));
assert_eq!(session.editor().cursor(), Cursor::default());
assert_eq!(session.editor().mode(), Mode::Normal);
assert!(!terminal.backend().cursor_visible());
session.handle(Input::Up);
}
assert_eq!(session.editor().lines().join("\n"), text);
}
#[test]
fn no_op_navigation_restores_caret_following_through_core_dispatch() {
let directory = Directory::new();
let path = directory.file("rows.txt", &["row"; 12].join("\n"));
let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 6)).unwrap();
let mut viewports = BufferViewports::default();
for motion in [Input::Char('k'), Input::Up, Input::Home] {
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 2, 0)
));
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
assert!(!terminal.backend().cursor_visible());
session.handle(motion);
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
assert_eq!(session.editor().cursor(), Cursor::default());
assert_eq!(viewports.offsets[&session.editor().buffer_id()].0, 0);
assert!(terminal.backend().cursor_visible());
}
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
// A remapped motion and a disabled default demonstrate that intent is
// emitted by the resolved core action, not frontend key spelling.
editor.bind("normal", "x", Some("core.up".into())).unwrap();
editor.bind("normal", "k", None).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 6)).unwrap();
let mut viewports = BufferViewports::default();
for navigation in 0..3 {
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
let buffer_id = editor.buffer_id();
viewports.offsets.get_mut(&buffer_id).unwrap().0 = 3;
viewports.document.manual = Some(ManualViewport {
buffer_id,
revision: editor.text_revision(),
caret_reveal_revision: editor.caret_reveal_revision(),
mode: editor.mode(),
cursor: editor.cursor(),
carets: editor.carets(),
selections: editor.selections(),
});
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert!(!terminal.backend().cursor_visible());
editor.handle(Input::Char('k'));
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert!(!terminal.backend().cursor_visible());
match navigation {
0 => {
editor.handle(Input::Char('x'));
}
1 => editor
.navigate_to(
path.clone(),
crate::plugin::TextPosition { row: 0, column: 0 },
)
.unwrap(),
2 => {
editor.execute(&format!("buffer {buffer_id}"));
}
_ => unreachable!(),
}
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert_eq!(editor.cursor(), Cursor::default());
assert_eq!(viewports.offsets[&buffer_id].0, 0);
assert!(terminal.backend().cursor_visible());
assert!(
screen_text(&terminal)
.lines()
.next()
.unwrap()
.contains("row")
);
}
}
#[test]
fn manual_document_view_survives_idle_resize_and_palette_then_follows_navigation_and_edits() {
let directory = Directory::new();
let path = directory.file(
"rows.txt",
&(0..20)
.map(|row| format!("row-{row:02}"))
.collect::<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_mut()))
.unwrap();
for _ in 0..2 {
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 4, 0)
));
}
session.poll();
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
assert!(
screen_text(&terminal)
.lines()
.next()
.unwrap()
.contains("row-06")
);
assert!(!terminal.backend().cursor_visible());
terminal.backend_mut().resize(30, 8);
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
assert!(
screen_text(&terminal)
.lines()
.next()
.unwrap()
.contains("row-06")
);
session.handle(Input::Ctrl('p'));
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 4, 0)
));
session.handle(Input::Escape);
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
assert!(
screen_text(&terminal)
.lines()
.next()
.unwrap()
.contains("row-06")
);
assert_eq!(session.editor().cursor(), Cursor::default());
session.handle(Input::Down);
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
assert!(
screen_text(&terminal)
.lines()
.next()
.unwrap()
.contains("row-01")
);
assert!(terminal.backend().cursor_visible());
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 4, 0)
));
session.handle(Input::Char('i'));
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
assert!(terminal.backend().cursor_visible());
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 4, 0)
));
session.handle(Input::Char('z'));
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
assert!(terminal.backend().cursor_visible());
session.handle(Input::Escape);
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 4, 0)
));
session.execute(&format!("edit {}", other.display()));
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
assert!(screen_text(&terminal).starts_with("1 other"));
session.execute(&format!("buffer {buffer_id}"));
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
assert!(terminal.backend().cursor_visible());
assert!(
screen_text(&terminal)
.lines()
.next()
.unwrap()
.contains("zrow-01")
);
}
#[test]
fn manual_document_view_hides_horizontal_offscreen_caret_and_clamps_tiny_viewports() {
let directory = Directory::new();
let path = directory.file("long.txt", "abcdefghijk");
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
for _ in 0..8 {
session.handle(Input::Right);
}
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 6)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 2, 0)
));
terminal.backend_mut().resize(8, 6);
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
assert!(!terminal.backend().cursor_visible());
assert!(screen_text(&terminal).starts_with("1 abcdef"));
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 8 });
session.handle(Input::Left);
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
assert!(terminal.backend().cursor_visible());
assert_eq!(terminal.backend().cursor_position().x, 7);
for (width, height) in [(1, 1), (2, 2), (0, 0)] {
terminal.backend_mut().resize(width, height);
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
assert_eq!(
viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 0, 0)
),
width > 0 && height > 0,
);
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
assert_eq!(viewports.offsets[&session.editor().buffer_id()].0, 0);
}
}
#[test]
fn wrapped_document_renders_continuations_selection_and_insertion_caret() {
let directory = Directory::new();
let source = "abcdefghijklmnop\ntail";
let path = directory.file("wrapped.txt", source);
let mut editor = Editor::open(Some(path), vec![]).unwrap();
editor.execute("word_wrap.toggle");
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(10, 6)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
let screen = screen_text(&terminal);
let rows: Vec<_> = screen.lines().collect();
assert_eq!(rows[0], "1 abcdefgh");
assert_eq!(rows[1], " ijklmnop");
assert_eq!(rows[2], " ");
assert_eq!(rows[3], "1 tail ");
editor.handle(Input::Char('v'));
for _ in 0..10 {
editor.handle(Input::Right);
}
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert_eq!(terminal.get_cursor_position().unwrap(), Position::new(4, 1));
assert_eq!(terminal.backend().buffer()[(2, 1)].bg, Color::Blue);
assert_eq!(terminal.backend().buffer()[(3, 1)].bg, Color::Blue);
editor.handle(Input::Escape);
editor.handle(Input::End);
editor.handle(Input::Char('a'));
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert_eq!(terminal.get_cursor_position().unwrap(), Position::new(2, 2));
assert_eq!(editor.lines().join("\n"), source);
assert!(!editor.dirty());
}
#[test]
fn wrapped_wheel_moves_visual_rows_and_resize_and_toggle_restore_following() {
let directory = Directory::new();
let source = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let path = directory.file("wrapped.txt", source);
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
let id = session.editor().buffer_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(10, 6)).unwrap();
let mut viewports = BufferViewports::default();
for _ in 0..12 {
session.handle(Input::Right);
}
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
let original_left = viewports.offsets[&id].1;
assert!(original_left > 0);
session.execute("word_wrap.toggle");
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollRight, 4, 0)
));
assert_eq!(viewports.offsets[&id].1, original_left);
assert!(viewports.document.manual.is_none());
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 4, 0)
));
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
assert!(!terminal.backend().cursor_visible());
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 4, 0)
));
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
terminal.backend_mut().resize(14, 6);
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
assert!(viewports.document.manual.is_none());
assert!(terminal.backend().cursor_visible());
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 12 });
session.execute("word_wrap.toggle");
terminal
.draw(|frame| viewports.draw(frame, session.editor_mut()))
.unwrap();
assert_eq!(viewports.offsets[&id].1, original_left);
assert_eq!(session.editor().lines().join("\n"), source);
}
#[test]
fn wrapped_unicode_uses_original_tab_stops_and_queries_visible_source_rows() {
let directory = Directory::new();
let source = format!("#a\t界e\u{301} z\n{}", vec!["# unseen"; 100].join("\n"));
let path = directory.file("wrapped.py", &source);
let mut editor = Editor::open(Some(path), vec![]).unwrap();
editor
.replace_semantic_tokens(
editor.buffer_id(),
editor.text_revision(),
Some(Arc::from([semantic_token(
0,
3,
5,
SemanticTokenKind::Property,
0,
)])),
)
.unwrap();
editor
.replace_diagnostics(
"fixture",
editor.buffer_id(),
editor.text_revision(),
vec![annotation(
(0, 2),
(0, 5),
DiagnosticSeverity::Warning,
"wrapped range",
)],
)
.unwrap();
editor.execute("word_wrap.toggle");
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 6)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert_eq!(viewports.document.text_width, 4);
assert_eq!(
viewports
.document
.visible
.iter()
.map(|&(row, part, _)| (row, part))
.collect::<Vec<_>>(),
[(0, 0), (0, 1), (0, 2), (1, 0)]
);
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(4, 0)].symbol(), "#");
assert_eq!(buffer[(6, 0)].symbol(), " ");
assert_eq!(buffer[(4, 1)].symbol(), "界");
assert_eq!(buffer[(6, 1)].symbol(), "e\u{301}");
for x in [4, 6] {
assert_eq!(buffer[(x, 1)].fg, Color::Blue);
assert!(buffer[(x, 1)].modifier.contains(Modifier::UNDERLINED));
assert_eq!(buffer[(x, 1)].underline_color, Color::Yellow);
}
assert_eq!(buffer[(4, 2)].fg, Color::DarkGray);
assert!(viewports.syntax[&editor.buffer_id()].line(2).is_empty());
assert_eq!(buffer[(4, 2)].symbol(), "z");
assert_eq!(editor.lines().join("\n"), source);
editor.handle(Input::Down);
assert_eq!(editor.cursor().row, 1);
}
#[test]
fn buffer_switches_restore_independent_vertical_and_horizontal_viewports() {
let directory = Directory::new();
let a = directory.file("a", &vec!["a".repeat(50); 20].join("\n"));
let b = directory.file("b", &vec!["b".repeat(40); 20].join("\n"));
let mut editor = Editor::open(Some(a), vec![]).unwrap();
let a_id = editor.buffer_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(16, 6)).unwrap();
let mut viewports = BufferViewports::default();
for _ in 0..14 {
editor.handle(Input::Down);
}
editor.handle(Input::End);
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
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, &mut 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, &mut editor))
.unwrap();
assert_eq!(viewports.offsets[&b_id], (0, 0));
for _ in 0..8 {
editor.handle(Input::Down);
}
editor.handle(Input::End);
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
editor.handle(Input::Up);
editor.handle(Input::Left);
editor.handle(Input::Left);
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
let b_viewport = viewports.offsets[&b_id];
let b_cursor = terminal.get_cursor_position().unwrap();
assert_eq!(b_viewport, (5, 27));
editor.execute(&format!("buffer {a_id}"));
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert_eq!(viewports.offsets[&a_id], a_viewport);
assert_eq!(terminal.get_cursor_position().unwrap(), a_cursor);
assert_eq!(
terminal.backend().buffer()[(a_cursor.x, a_cursor.y)].symbol(),
"a"
);
editor.execute("bnext");
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert_eq!(editor.buffer_id(), b_id);
assert_eq!(viewports.offsets[&b_id], b_viewport);
assert_eq!(terminal.get_cursor_position().unwrap(), b_cursor);
}
#[test]
fn closing_inactive_buffer_retires_its_viewport_without_moving_active_buffer() {
let directory = Directory::new();
let a = directory.file("a", &vec!["a".repeat(50); 20].join("\n"));
let b = directory.file("b", &vec!["b".repeat(40); 20].join("\n"));
let c = directory.file("c", "new buffer");
let mut editor = Editor::open(Some(a), vec![]).unwrap();
let a_id = editor.buffer_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(16, 6)).unwrap();
let mut viewports = BufferViewports::default();
for _ in 0..14 {
editor.handle(Input::Down);
}
editor.handle(Input::End);
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
editor.execute(&format!("edit {}", b.display()));
let b_id = editor.buffer_id();
for _ in 0..8 {
editor.handle(Input::Down);
}
editor.handle(Input::End);
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
let b_viewport = viewports.offsets[&b_id];
let b_cursor = terminal.get_cursor_position().unwrap();
assert_eq!(viewports.offsets.len(), 2);
editor.execute(&format!("bdelete {a_id}"));
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert_eq!(editor.buffer_id(), b_id);
assert!(!editor.has_buffer(a_id));
assert!(!viewports.offsets.contains_key(&a_id));
assert_eq!(viewports.offsets.len(), 1);
assert_eq!(viewports.offsets[&b_id], b_viewport);
assert_eq!(terminal.get_cursor_position().unwrap(), b_cursor);
editor.execute(&format!("edit {}", c.display()));
let c_id = editor.buffer_id();
assert!(c_id > b_id);
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert_eq!(viewports.offsets[&c_id], (0, 0));
assert!(!viewports.offsets.contains_key(&a_id));
}
#[test]
fn restoring_buffer_after_narrow_resize_clamps_caret_inside_text_region() {
let directory = Directory::new();
let a = directory.file("a", &vec!["a".repeat(50); 20].join("\n"));
let b = directory.file("b", "other");
let mut editor = Editor::open(Some(a), vec![]).unwrap();
let a_id = editor.buffer_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 8)).unwrap();
let mut viewports = BufferViewports::default();
for _ in 0..14 {
editor.handle(Input::Down);
}
editor.handle(Input::End);
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert_eq!(viewports.offsets[&a_id], (9, 29));
editor.execute(&format!("edit {}", b.display()));
let b_id = editor.buffer_id();
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
terminal.backend_mut().resize(4, 4);
terminal
.resize(ratatui::layout::Rect::new(0, 0, 4, 4))
.unwrap();
editor.execute(&format!("buffer {a_id}"));
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
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, &mut editor))
.unwrap();
let cursor = terminal.get_cursor_position().unwrap();
assert!(area.contains(cursor), "{area:?}: {cursor:?}");
assert_eq!(
terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(),
"a"
);
assert_eq!(viewports.offsets[&b_id], (0, 0));
}
}
#[test]
fn status_identifies_active_buffer_and_restored_visual_selection_count() {
let directory = Directory::new();
let a = directory.file("a", "word\nword\nword");
let b = directory.file("b", "other");
let mut editor = Editor::open(Some(a), vec![]).unwrap();
let a_id = editor.buffer_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(240, 6)).unwrap();
let mut viewports = BufferViewports::default();
editor.handle(Input::Char('v'));
editor.handle(Input::Ctrl('a'));
assert_eq!(editor.selection_count(), 3);
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
assert!(!screen_text(&terminal).contains("[buffer"));
editor.execute(&format!("edit {}", b.display()));
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
let text = screen_text(&terminal);
assert!(!text.contains("3 selections"), "{text}");
editor.execute(&format!("buffer {a_id}"));
terminal
.draw(|frame| viewports.draw(frame, &mut editor))
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains("3 selections"), "{text}");
let (top, left) = viewports.offsets[&a_id];
for selection in editor.selections() {
let x = 2 + (selection.start.column - left) as u16;
let y = (selection.start.row - top) as u16;
assert!(matches!(
terminal.backend().buffer()[(x, y)].bg,
Color::Blue | Color::Magenta
));
}
}
#[test]
fn pending_motion_count_is_visible_and_escape_cancels_without_moving() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.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,
area,
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| {
let area = frame.area();
draw_palette(
frame,
&palette,
area,
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, &mut editor)
})
.unwrap();
}
drop(terminal);
let output = std::str::from_utf8(&output).unwrap();
for supports_sync in [false, true] {
let (mut visible, mut synchronized) = (true, false);
let mut screen = [[b' '; 20]; 5];
let (mut x, mut y) = (0, 0);
for escape in output.split('\x1b').skip(1) {
let rest = escape.strip_prefix('[').unwrap();
let end = rest.find(|ch| ('@'..='~').contains(&ch)).unwrap();
let (command, text) = rest.split_at(end + 1);
match command {
"?25h" => visible = true,
"?25l" => visible = false,
"?2026h" => synchronized = supports_sync,
"?2026l" => synchronized = false,
_ if command.ends_with('H') => {
let (row, column) = command[..command.len() - 1].split_once(';').unwrap();
y = row.parse::<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!(!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);
}
}
}