Files
vamp/src/tui.rs
T

6206 lines
234 KiB
Rust

use std::{
borrow::Cow,
collections::BTreeMap,
fmt::Write as _,
io::{self, Stdout},
panic::{self, AssertUnwindSafe, PanicHookInfo},
sync::{Arc, Mutex},
time::Duration,
};
use crossterm::{
cursor::Show,
event::{
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind,
},
execute,
terminal::{
BeginSynchronizedUpdate, EndSynchronizedUpdate, EnterAlternateScreen, LeaveAlternateScreen,
disable_raw_mode, enable_raw_mode,
},
};
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::Line,
widgets::{Block, Borders, Clear, Paragraph},
};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::{
editor::{Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic},
files::{DockSide, FilePicker, FilePresentation, FileView},
plugin::{
Diagnostic, DiagnosticSeverity, SemanticToken, SemanticTokenKind, SemanticTokenModifier,
},
session::EditorSession,
syntax::{SyntaxCache, SyntaxKind},
};
pub const CAPABILITIES: &[&str] = &["text", "styled_text", "diagnostics"];
const TAB_WIDTH: usize = 4;
type PanicHook = Box<dyn Fn(&PanicHookInfo<'_>) + Send + Sync + 'static>;
struct HookState {
previous: Option<PanicHook>,
active: bool,
}
struct TerminalSession {
hook: Arc<Mutex<HookState>>,
}
fn restore_terminal() {
let _ = disable_raw_mode();
let _ = execute!(
io::stdout(),
EndSynchronizedUpdate,
DisableBracketedPaste,
DisableMouseCapture,
LeaveAlternateScreen,
Show
);
}
impl TerminalSession {
fn run(body: impl FnOnce() -> io::Result<()>) -> io::Result<()> {
let hook = Arc::new(Mutex::new(HookState {
previous: Some(panic::take_hook()),
active: false,
}));
let during_session = Arc::clone(&hook);
panic::set_hook(Box::new(move |info| {
let mut state = during_session
.lock()
.unwrap_or_else(|error| error.into_inner());
if state.active {
restore_terminal();
state.active = false;
}
if let Some(previous) = &state.previous {
previous(info);
}
}));
let session = Self { hook };
// set_hook is forbidden on a panicking thread. Catch first, drop the
// owner while not panicking, then resume the original panic unchanged.
let result = panic::catch_unwind(AssertUnwindSafe(|| {
enable_raw_mode()?;
session
.hook
.lock()
.unwrap_or_else(|error| error.into_inner())
.active = true;
execute!(
io::stdout(),
EnterAlternateScreen,
EnableBracketedPaste,
EnableMouseCapture
)?;
body()
}));
drop(session);
match result {
Ok(result) => result,
Err(payload) => panic::resume_unwind(payload),
}
}
}
impl Drop for TerminalSession {
fn drop(&mut self) {
let mut state = self.hook.lock().unwrap_or_else(|error| error.into_inner());
if state.active {
restore_terminal();
state.active = false;
}
let previous = state
.previous
.take()
.expect("terminal session owns its prior hook");
drop(state);
panic::set_hook(previous);
}
}
#[derive(Default)]
struct DocumentViewport {
geometry: Option<(usize, Rect)>,
manual: Option<ManualViewport>,
}
struct ManualViewport {
buffer_id: usize,
revision: u64,
caret_reveal_revision: u64,
mode: Mode,
cursor: Cursor,
carets: Vec<Cursor>,
selections: Vec<Selection>,
}
#[derive(Default)]
struct BufferViewports {
offsets: BTreeMap<usize, (usize, usize)>,
syntax: BTreeMap<usize, SyntaxCache>,
document: DocumentViewport,
panels: WheelPanels,
}
impl BufferViewports {
fn draw(&mut self, frame: &mut Frame, editor: &Editor) {
self.offsets.retain(|id, _| editor.has_buffer(*id));
self.syntax.retain(|id, _| editor.has_buffer(*id));
let (top, left) = self.offsets.entry(editor.buffer_id()).or_default();
let syntax = self.syntax.entry(editor.buffer_id()).or_default();
draw(
frame,
editor,
top,
left,
syntax,
&mut self.document,
&mut self.panels,
);
}
fn scroll(&mut self, session: &mut EditorSession, mouse: MouseEvent) -> bool {
if !matches!(
mouse.kind,
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
) {
return false;
}
if self.panels.scroll(session, mouse) {
return true;
}
let editor = session.editor();
if editor.palette().is_some() || editor.startup_diagnostic().is_some() {
return true;
}
let Some((buffer_id, viewport)) = self.document.geometry else {
return false;
};
if buffer_id != editor.buffer_id()
|| viewport.width == 0
|| viewport.height == 0
|| !viewport.contains((mouse.column, mouse.row).into())
{
return false;
}
let (top, _) = self.offsets.entry(buffer_id).or_default();
let last_page = editor
.lines()
.len()
.saturating_sub(usize::from(viewport.height));
*top = match mouse.kind {
MouseEventKind::ScrollUp => top.saturating_sub(3),
MouseEventKind::ScrollDown => top.saturating_add(3),
_ => unreachable!(),
}
.min(last_page);
if self.document.manual.is_none() {
self.document.manual = Some(ManualViewport {
buffer_id,
revision: editor.text_revision(),
caret_reveal_revision: editor.caret_reveal_revision(),
mode: editor.mode(),
cursor: editor.cursor(),
carets: editor.carets(),
selections: editor.selections(),
});
}
true
}
fn scroll_ready(
&mut self,
session: &mut EditorSession,
mut mouse: MouseEvent,
mut next: impl FnMut() -> io::Result<Option<Event>>,
) -> io::Result<(bool, Option<Event>)> {
let mut redraw = false;
// Bound input work so drawing and background replies cannot starve.
for index in 0..64 {
redraw |= self.scroll(session, mouse);
if index == 63 {
break;
}
match next()? {
Some(Event::Mouse(report)) => mouse = report,
pending => return Ok((redraw, pending)),
}
}
Ok((redraw, None))
}
}
fn draw_frame<W: io::Write>(
terminal: &mut Terminal<CrosstermBackend<Vec<u8>>>,
output: &mut W,
render: impl FnOnce(&mut Frame),
) -> io::Result<()> {
// Do not suspend the terminal's presentation while computing the next frame.
terminal.backend_mut().writer_mut().clear();
crossterm::queue!(terminal.backend_mut(), BeginSynchronizedUpdate)?;
// Terminals without synchronized updates still hide the painting cursor.
let result = terminal
.hide_cursor()
.and_then(|()| terminal.draw(render).map(|_| ()));
let finish = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
result.and(finish)?;
output.write_all(terminal.backend().writer())?;
output.flush()
}
/// Renders session state and translates terminal input; the session owns plugin
/// execution and retirement, so the adapter needs no lifecycle callback.
pub fn run(session: &mut EditorSession) -> io::Result<()> {
TerminalSession::run(|| {
let mut output: Stdout = io::stdout();
let mut terminal = Terminal::new(CrosstermBackend::new(Vec::new()))?;
let mut viewports = BufferViewports::default();
let mut redraw = true;
let mut pending = None;
while !session.editor().should_quit() {
redraw |= session.poll();
if redraw {
draw_frame(&mut terminal, &mut output, |frame| {
viewports.draw(frame, session.editor())
})?;
redraw = false;
}
let input = if let Some(input) = pending.take() {
input
} else {
if !event::poll(Duration::from_millis(25))? {
continue;
}
event::read()?
};
match input {
Event::Key(key) if key.kind != KeyEventKind::Release => {
if let Some(input) = translate(key) {
viewports.panels.follow_files();
session.handle(input);
redraw = true;
}
}
Event::Paste(text) => {
viewports.panels.follow_files();
session.handle(Input::Paste(text));
redraw = true;
}
Event::Mouse(mouse) => {
let (changed, following) = viewports.scroll_ready(session, mouse, || {
if event::poll(Duration::ZERO)? {
event::read().map(Some)
} else {
Ok(None)
}
})?;
redraw |= changed;
pending = following;
}
Event::Resize(_, _) => redraw = true,
_ => {}
}
}
Ok(())
})
}
fn translate(key: KeyEvent) -> Option<Input> {
if key
.modifiers
.intersects(KeyModifiers::ALT | KeyModifiers::SUPER)
{
return None;
}
Some(match key.code {
KeyCode::Char(c) if key.modifiers.contains(KeyModifiers::CONTROL) => {
Input::Ctrl(c.to_ascii_lowercase())
}
KeyCode::Char(c) => Input::Char(c),
KeyCode::Esc => Input::Escape,
KeyCode::Enter => Input::Enter,
KeyCode::Backspace => Input::Backspace,
KeyCode::Delete => Input::Delete,
KeyCode::Left => Input::Left,
KeyCode::Right => Input::Right,
KeyCode::Up => Input::Up,
KeyCode::Down => Input::Down,
KeyCode::Home => Input::Home,
KeyCode::End => Input::End,
KeyCode::Tab => Input::Char('\t'),
_ => return None,
})
}
fn display_column(line: &str, grapheme_column: usize) -> usize {
line.graphemes(true)
.take(grapheme_column)
.fold(0, |column, grapheme| {
column
+ if grapheme == "\t" {
TAB_WIDTH - column % TAB_WIDTH
} else {
UnicodeWidthStr::width(grapheme)
}
})
}
fn visible_line(line: &str, left: usize, width: usize) -> String {
let mut visible = String::new();
let mut column = 0;
let right = left.saturating_add(width);
for grapheme in line.graphemes(true) {
let cells = if grapheme == "\t" {
TAB_WIDTH - column % TAB_WIDTH
} else {
UnicodeWidthStr::width(grapheme)
};
let end = column + cells;
if column >= right {
break;
}
if end > left || (cells == 0 && column >= left) {
if grapheme == "\t" || column < left || end > right {
for _ in column.max(left)..end.min(right) {
visible.push(' ');
}
} else {
visible.push_str(grapheme);
}
}
column = end;
}
visible
}
fn diagnostic_severity(severity: DiagnosticSeverity) -> (usize, Color, &'static str) {
match severity {
DiagnosticSeverity::Error => (0, Color::Red, "E"),
DiagnosticSeverity::Warning => (1, Color::Yellow, "W"),
DiagnosticSeverity::Information => (2, Color::Cyan, "I"),
DiagnosticSeverity::Hint => (3, Color::DarkGray, "H"),
}
}
fn diagnostic_on_row(diagnostic: &Diagnostic, row: usize) -> bool {
diagnostic.start.row <= row
&& row <= diagnostic.end.row
&& (row < diagnostic.end.row
|| diagnostic.end.column > 0
|| diagnostic.start == diagnostic.end)
}
fn draw(
frame: &mut Frame,
editor: &Editor,
top: &mut usize,
left: &mut usize,
syntax: &mut SyntaxCache,
document: &mut DocumentViewport,
panels: &mut WheelPanels,
) {
panels.begin_frame();
document.geometry = None;
syntax.update(editor.lines(), editor.path(), editor.text_revision());
let area = frame.area();
if area.width == 0 || area.height == 0 {
return;
}
let prompting = matches!(editor.mode(), Mode::Command | Mode::Search);
let dock_focused = editor
.file_picker()
.is_some_and(|picker| picker.focused && picker.presentation == FilePresentation::Docked);
let [workspace, status_area, prompt_area] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(u16::from(area.height >= 3)),
Constraint::Length(u16::from(area.height >= 2 || (prompting && !dock_focused))),
])
.areas(area);
let (viewport, dock) = match editor.file_picker() {
Some(picker) if picker.presentation == FilePresentation::Docked => {
dock_layout(workspace, editor.file_explorer_side(), picker.focused)
}
_ => (workspace, None),
};
let digits = editor.lines().len().ilog10() as usize + 1;
let gutter_width = (digits + 1) as u16;
let [gutter, viewport] = Layout::horizontal([
Constraint::Length(if viewport.width > gutter_width {
gutter_width
} else {
0
}),
Constraint::Fill(1),
])
.areas(viewport);
let height = usize::from(viewport.height);
let width = usize::from(viewport.width);
let cursor = editor.cursor();
let carets = editor.carets();
let selections = editor.selections();
if document.manual.as_ref().is_some_and(|manual| {
manual.buffer_id != editor.buffer_id()
|| manual.revision != editor.text_revision()
|| manual.caret_reveal_revision != editor.caret_reveal_revision()
|| manual.mode != editor.mode()
|| manual.cursor != cursor
|| manual.carets != carets
|| manual.selections != selections
}) {
document.manual = None;
}
document.geometry = Some((
editor.buffer_id(),
Rect::new(
gutter.x,
viewport.y,
gutter.width + viewport.width,
viewport.height,
),
));
*top = (*top).min(editor.lines().len().saturating_sub(height));
if height > 0 && width > 0 {
if document.manual.is_none() {
if cursor.row < *top {
*top = cursor.row;
} else if cursor.row >= top.saturating_add(height) {
*top = cursor.row + 1 - height;
}
let column = display_column(&editor.lines()[cursor.row], cursor.column);
if column < *left {
*left = column;
} else if column >= left.saturating_add(width) {
*left = column + 1 - width;
}
}
if gutter.width > 0 {
let numbers: Vec<Line> = (*top..top.saturating_add(height).min(editor.lines().len()))
.map(|row| {
let style = if row == cursor.row {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let style = match editor
.diagnostics()
.filter(|diagnostic| diagnostic_on_row(diagnostic, row))
.map(|diagnostic| diagnostic_severity(diagnostic.severity))
.min_by_key(|(severity, _, _)| *severity)
{
Some((_, color, _)) => style.fg(color),
None => style,
};
let number = if row == cursor.row {
row + 1
} else {
row.abs_diff(cursor.row)
};
Line::styled(format!("{number:>digits$} "), style)
})
.collect();
frame.render_widget(Paragraph::new(numbers), gutter);
}
let lines: Vec<Line> = editor
.lines()
.iter()
.skip(*top)
.take(height)
.map(|line| Line::from(visible_line(line, *left, width)))
.collect();
frame.render_widget(Paragraph::new(lines), viewport);
let buffer = frame.buffer_mut();
let semantic = editor.semantic_tokens();
let mut semantic_index = semantic.partition_point(|token| token.start.row < *top);
for (row, line) in editor.lines().iter().enumerate().skip(*top).take(height) {
let spans = syntax.line(row);
while semantic_index < semantic.len() && semantic[semantic_index].start.row < row {
semantic_index += 1;
}
if spans.is_empty()
&& semantic
.get(semantic_index)
.is_none_or(|token| token.start.row != row)
{
continue;
}
let mut span_index = 0;
let mut token_style = None;
let mut column = 0;
let right = left.saturating_add(width);
for (index, (byte, grapheme)) in line.grapheme_indices(true).enumerate() {
if column >= right {
break;
}
let cells = if grapheme == "\t" {
TAB_WIDTH - column % TAB_WIDTH
} else {
UnicodeWidthStr::width(grapheme)
};
let end = column + cells;
while span_index < spans.len() && spans[span_index].end_byte <= byte {
span_index += 1;
}
while semantic_index < semantic.len()
&& semantic[semantic_index].start.row == row
&& semantic[semantic_index].end.column <= index
{
semantic_index += 1;
token_style = None;
}
let style = semantic
.get(semantic_index)
.filter(|token| token.start.row == row && token.start.column <= index)
.map(|token| *token_style.get_or_insert_with(|| semantic_style(token)))
.or_else(|| {
spans
.get(span_index)
.filter(|span| span.start_byte <= byte)
.map(|span| syntax_style(span.kind))
});
if let Some(style) = style
&& end > *left
&& (grapheme == "\t" || (column >= *left && end <= right))
{
let y = viewport.y + (row - *top) as u16;
for cell in column.max(*left)..end.min(right) {
buffer[(viewport.x + (cell - *left) as u16, y)].set_style(style);
}
}
column = end;
}
}
for severity in [
DiagnosticSeverity::Hint,
DiagnosticSeverity::Information,
DiagnosticSeverity::Warning,
DiagnosticSeverity::Error,
] {
let style = Style::default()
.underline_color(diagnostic_severity(severity).1)
.add_modifier(Modifier::UNDERLINED);
for diagnostic in editor
.diagnostics()
.filter(|item| item.severity == severity)
{
let first = diagnostic.start.row.max(*top);
let last = diagnostic.end.row.min(top.saturating_add(height - 1));
for row in first..=last {
if !diagnostic_on_row(diagnostic, row) {
continue;
}
let start = if row == diagnostic.start.row {
diagnostic.start.column
} else {
0
};
let end = if row == diagnostic.end.row {
diagnostic
.end
.column
.saturating_add(usize::from(diagnostic.start == diagnostic.end))
} else {
usize::MAX
};
let line = &editor.lines()[row];
let y = viewport.y + (row - *top) as u16;
let right = left.saturating_add(width);
let mut column = 0;
let mut grapheme_count = 0;
for (index, grapheme) in line.graphemes(true).enumerate() {
let cells = if grapheme == "\t" {
TAB_WIDTH - column % TAB_WIDTH
} else {
UnicodeWidthStr::width(grapheme)
};
let next = column + cells;
if index >= start
&& index < end
&& (grapheme == "\t" || (column >= *left && next <= right))
{
for cell in column.max(*left)..next.min(right) {
frame.buffer_mut()[(viewport.x + (cell - *left) as u16, y)]
.set_style(style);
}
}
column = next;
grapheme_count = index + 1;
if column >= right {
break;
}
}
if start <= grapheme_count
&& end > grapheme_count
&& column >= *left
&& column < right
{
frame.buffer_mut()[(viewport.x + (column - *left) as u16, y)]
.set_style(style);
}
}
}
}
if let Some(found) = editor.search_match()
&& found.start.row >= *top
&& found.start.row < top.saturating_add(height)
{
let line = &editor.lines()[found.start.row];
let start = display_column(line, found.start.column).max(*left);
let end = display_column(line, found.end_column).min(left.saturating_add(width));
let y = viewport.y + (found.start.row - *top) as u16;
let style = Style::default().fg(Color::Black).bg(Color::Yellow);
let buffer = frame.buffer_mut();
for column in start..end {
buffer[(viewport.x + (column - *left) as u16, y)].set_style(style);
}
}
let selection_style = Style::default().fg(Color::White).bg(Color::Blue);
for rows in editor.linewise_rows() {
let first = (*rows.start()).max(*top);
let last = (*rows.end()).min(top.saturating_add(height - 1));
for row in first..=last {
let end = display_column(&editor.lines()[row], usize::MAX)
.saturating_add(1)
.min(left.saturating_add(width));
let y = viewport.y + (row - *top) as u16;
for column in *left..end {
frame.buffer_mut()[(viewport.x + (column - *left) as u16, y)]
.set_style(selection_style);
}
}
}
for selection in selections.iter().filter(|_| !editor.visual_linewise()) {
let first = selection.start.row.max(*top);
let last = selection.end.row.min(top.saturating_add(height - 1));
let style = Style::default().fg(Color::White).bg(Color::Blue);
for row in first..=last {
let line = &editor.lines()[row];
let start = if row == selection.start.row {
display_column(line, selection.start.column)
} else {
0
};
let end = if row == selection.end.row {
display_column(line, selection.end.column)
} else {
display_column(line, usize::MAX) + 1
};
let start = start.max(*left);
let end = end.min(left.saturating_add(width));
let y = viewport.y + (row - *top) as u16;
let buffer = frame.buffer_mut();
for column in start..end {
buffer[(viewport.x + (column - *left) as u16, y)].set_style(style);
}
}
}
for caret in carets.iter().filter(|caret| **caret != cursor) {
if caret.row < *top || caret.row >= top.saturating_add(height) {
continue;
}
let column = display_column(&editor.lines()[caret.row], caret.column);
if column >= *left && column < left.saturating_add(width) {
frame.buffer_mut()[(
viewport.x + (column - *left) as u16,
viewport.y + (caret.row - *top) as u16,
)]
.set_style(Style::default().fg(Color::Black).bg(Color::Magenta));
}
}
}
let path = editor
.path()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| "[No Name]".into());
let path: String = path
.chars()
.map(|c| if c.is_control() { '�' } else { c })
.collect();
let mut status = if editor.visual_linewise() {
" VISUAL LINE".into()
} else {
format!(" {}", editor.mode())
};
if let Some(sequence) = editor.leader_pending() {
write!(status, " leader: {sequence}").unwrap();
}
if editor.g_pending() {
status.push_str(" g");
}
if editor.buffer_count() > 1 {
write!(
status,
" [buffer {}/{}]",
editor.buffer_id(),
editor.buffer_count()
)
.unwrap();
}
let mut diagnostic_counts = [0; 4];
for diagnostic in editor.diagnostics() {
diagnostic_counts[diagnostic_severity(diagnostic.severity).0] += 1;
}
for (label, count) in ["E", "W", "I", "H"].into_iter().zip(diagnostic_counts) {
if count > 0 {
write!(status, " {label}:{count}").unwrap();
}
}
write!(
status,
" {}{} {}:{}",
path,
if editor.dirty() { " [+]" } else { "" },
cursor.row + 1,
cursor.column + 1,
)
.unwrap();
if editor.selection_count() > 1 {
write!(status, " {} selections", editor.selection_count()).unwrap();
}
if let Some(count) = editor.count() {
write!(status, " count: {count}").unwrap();
}
if let Some(error) = syntax.error() {
write!(status, " syntax: {error}").unwrap();
} else if let Some(language) = syntax.language() {
write!(status, " {language}").unwrap();
}
frame.render_widget(
Paragraph::new(status).style(Style::default().fg(Color::Black).bg(Color::Cyan)),
status_area,
);
if prompting && prompt_area.height > 0 && prompt_area.width > 0 {
let prefix = if editor.mode() == Mode::Search {
'/'
} else {
':'
};
let command = format!("{prefix}{}", editor.command_line());
let command_width = display_column(&command, usize::MAX);
let prompt_width = usize::from(prompt_area.width);
let offset = command_width.saturating_sub(prompt_width.saturating_sub(1));
frame.render_widget(
Paragraph::new(visible_line(&command, offset, prompt_width)),
prompt_area,
);
if !editor.file_picker().is_some_and(|picker| picker.focused) {
frame.set_cursor_position((
prompt_area.x + (command_width - offset) as u16,
prompt_area.y,
));
}
} else {
let message = if let Some(diagnostic) = editor.diagnostic_at_cursor() {
let (_, _, label) = diagnostic_severity(diagnostic.severity);
let diagnostic = display_text(&diagnostic.message);
Cow::Owned(if editor.message().is_empty() {
format!("{label}: {diagnostic}")
} else {
format!("{} | {label}: {diagnostic}", editor.message())
})
} else {
Cow::Borrowed(editor.message())
};
frame.render_widget(Paragraph::new(message), prompt_area);
if height > 0 && width > 0 && !editor.file_picker().is_some_and(|picker| picker.focused) {
let column = display_column(&editor.lines()[cursor.row], cursor.column);
if cursor.row >= *top
&& cursor.row < top.saturating_add(height)
&& column >= *left
&& column < left.saturating_add(width)
{
frame.set_cursor_position((
viewport.x + (column - *left) as u16,
viewport.y + (cursor.row - *top) as u16,
));
}
}
}
if let Some(picker) = editor.file_picker() {
if picker.presentation == FilePresentation::Floating {
let panel = floating_panel(area);
frame.render_widget(Clear, panel);
draw_file_list(frame, picker, panel, editor.file_navigation_hint(), panels);
} else if let Some(panel) = dock {
draw_file_list(frame, picker, panel, editor.file_navigation_hint(), panels);
}
}
if let Some(palette) = editor.palette() {
draw_palette(
frame,
palette,
editor.mode(),
editor.selection_navigation_hint(),
);
}
if let Some(hover) = editor.hover() {
draw_hover(frame, hover, viewport, panels);
}
if let Some(diagnostic) = editor.startup_diagnostic() {
draw_startup_diagnostic(frame, diagnostic, area);
}
}
fn floating_panel(area: Rect) -> Rect {
let [_, column, _] = Layout::horizontal([
Constraint::Fill(1),
Constraint::Length(area.width.min(100)),
Constraint::Fill(1),
])
.areas(area);
let [_, panel, _] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(area.height.min(24)),
Constraint::Fill(1),
])
.areas(column);
panel
}
fn draw_startup_diagnostic(frame: &mut Frame, diagnostic: &StartupDiagnostic, area: Rect) {
let panel = floating_panel(area);
frame.render_widget(Clear, panel);
let bordered = panel.width >= 4 && panel.height >= 4;
let block = Block::default()
.borders(if bordered {
Borders::ALL
} else {
Borders::NONE
})
.border_style(Style::default().fg(Color::Red))
.title(" Configuration error ");
let content = if bordered { block.inner(panel) } else { panel };
if bordered {
frame.render_widget(block, panel);
}
let [text_area, help_area] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(u16::from(content.height >= 3)),
])
.areas(content);
let lines: Vec<Line> = diagnostic
.lines
.iter()
.skip(diagnostic.top)
.take(usize::from(text_area.height))
.map(|line| {
let line = display_text(line);
Line::from(visible_line(
&line,
display_column(&line, diagnostic.left),
usize::from(text_area.width),
))
})
.collect();
frame.render_widget(Paragraph::new(lines), text_area);
frame.render_widget(
Paragraph::new("Arrows scroll · Home reset · Enter/Esc continue")
.style(Style::default().fg(Color::DarkGray)),
help_area,
);
if text_area.width > 0 && text_area.height > 0 {
frame.set_cursor_position((text_area.x, text_area.y));
}
}
#[derive(Default)]
struct WheelPanels {
hover: Option<(Rect, usize)>,
hover_syntax: SyntaxCache,
files: Option<(Rect, usize, usize)>,
manual_files: Option<FileWheelOffset>,
}
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.files = None;
}
// Keyboard/paste may change the catalog without changing its length.
fn follow_files(&mut self) {
self.manual_files = None;
}
fn file_top(&mut self, picker: &FilePicker, panel: Rect, height: usize) -> usize {
if self
.manual_files
.as_ref()
.is_some_and(|offset| !offset.matches(picker))
{
self.manual_files = None;
}
let count = picker.entries().len();
let following = picker
.selected
.min(count.saturating_sub(1))
.saturating_sub(height.saturating_sub(1));
let top = self.manual_files.as_mut().map_or(following, |offset| {
offset.top = offset.top.min(count.saturating_sub(height.max(1)));
offset.top
});
self.files = Some((panel, height, top));
top
}
fn scroll(&mut self, session: &mut EditorSession, mouse: MouseEvent) -> bool {
let down = match mouse.kind {
MouseEventKind::ScrollDown => true,
MouseEventKind::ScrollUp => false,
_ => return false,
};
let editor = session.editor();
if editor.palette().is_some() || editor.startup_diagnostic().is_some() {
return true;
}
let position = ratatui::layout::Position::new(mouse.column, mouse.row);
if let Some(hover) = editor.hover()
&& let Some((panel, height)) = self.hover
&& panel.contains(position)
{
if height > 0 {
let top = hover.top;
let target = if down {
top.saturating_add(3)
} else {
top.saturating_sub(3)
}
.min(hover.lines().len().saturating_sub(height));
let input = if target < top { Input::Up } else { Input::Down };
for _ in 0..top.abs_diff(target) {
session.handle(input.clone());
}
}
return true;
}
if let Some(picker) = editor.file_picker()
&& let Some((panel, height, drawn_top)) = self.files
&& panel.contains(position)
{
if height > 0 {
if self
.manual_files
.as_ref()
.is_some_and(|offset| !offset.matches(picker))
{
self.manual_files = None;
}
let offset = self.manual_files.get_or_insert_with(|| FileWheelOffset {
top: drawn_top,
root: picker.root.clone(),
query: picker.query.clone(),
state: FileWheelOffset::state(picker),
});
offset.top = if down {
offset.top.saturating_add(3)
} else {
offset.top.saturating_sub(3)
}
.min(picker.entries().len().saturating_sub(height));
}
return true;
}
false
}
}
fn draw_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,
) {
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;
let show_error = picker.error.is_some() && content.height >= 2;
let show_help = content.height >= 5 + u16::from(show_error);
let [query_area, root_area, error_area, results_area, help_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(u16::from(show_root)),
Constraint::Length(u16::from(show_error)),
Constraint::Fill(1),
Constraint::Length(u16::from(show_help)),
])
.areas(content);
let first = panels.file_top(picker, panel, usize::from(results_area.height));
let query = if browsing && picker.query.is_empty() {
"i filter · ^U clear · ^T view".into()
} else if browsing {
format!("filter: {} · ^U clear", display_text(&picker.query))
} else {
format!("> {}", display_text(&picker.query))
};
let query_width = display_column(&query, usize::MAX);
let width = usize::from(query_area.width);
let offset = if browsing {
0
} else {
query_width.saturating_sub(width.saturating_sub(1))
};
frame.render_widget(
Paragraph::new(visible_line(&query, offset, width)).style(Style::default().fg(
if picker.focused {
Color::Yellow
} else {
Color::DarkGray
},
)),
query_area,
);
if picker.focused
&& query_area.width > 0
&& query_area.height > 0
&& (!browsing || picker.entries().len() == 0 || results_area.height == 0)
{
let cursor_x = if browsing { 0 } else { query_width - offset };
frame.set_cursor_position((query_area.x + cursor_x as u16, query_area.y));
}
if show_root {
frame.render_widget(
Paragraph::new(display_text(&picker.root.to_string_lossy()))
.style(Style::default().fg(Color::DarkGray)),
root_area,
);
}
if show_error {
frame.render_widget(
Paragraph::new(display_text(picker.error.as_deref().unwrap()))
.style(Style::default().fg(Color::Red)),
error_area,
);
}
let entries = picker.entries();
if entries.len() == 0 {
frame.render_widget(
Paragraph::new(if picker.query.is_empty() {
"No project files"
} else {
"No matching files"
}),
results_area,
);
} else if results_area.height > 0 {
let count = usize::from(results_area.height);
let selected_index = picker.selected.min(entries.len() - 1);
let lines: Vec<Line> = entries
.enumerate()
.skip(first)
.take(count)
.map(|(index, entry)| {
let selected = index == selected_index;
let mut style = if entry.is_dir {
Style::default().fg(Color::Cyan)
} else {
Style::default()
};
if selected {
style = style
.bg(if picker.focused {
Color::Blue
} else {
Color::DarkGray
})
.add_modifier(Modifier::BOLD);
}
let label = match picker.view {
FileView::List => {
format!("{} {}", if selected { ">" } else { " " }, entry.label)
}
FileView::Tree => format!(
"{} {:indent$}{}{}",
if selected { ">" } else { " " },
"",
entry.label,
if entry.is_dir { "/" } else { "" },
indent = entry.depth * 2,
),
};
Line::styled(label, style)
})
.collect();
frame.render_widget(Paragraph::new(lines), results_area);
if browsing
&& picker.focused
&& results_area.width > 0
&& selected_index >= first
&& selected_index - first < count
{
frame.set_cursor_position((
results_area.x,
results_area.y + (selected_index - first) as u16,
));
}
}
let help = if 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, mode: Mode, navigation_hint: &str) {
let area = frame.area();
if area.width == 0 || area.height == 0 {
return;
}
let [_, column, _] = Layout::horizontal([
Constraint::Fill(1),
Constraint::Length(area.width.min(100)),
Constraint::Fill(1),
])
.areas(area);
let [_, panel, _] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(area.height.min(16)),
Constraint::Fill(1),
])
.areas(column);
frame.render_widget(Clear, panel);
let bordered = panel.height >= 4 && panel.width >= 4;
let block = Block::default()
.borders(if bordered {
Borders::ALL
} else {
Borders::NONE
})
.title(format!(" Commands · {mode} "));
let content = if bordered { block.inner(panel) } else { panel };
if bordered {
frame.render_widget(block, panel);
}
let [query_area, results_area, help_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Length(u16::from(content.height >= 4)),
])
.areas(content);
let query = format!("> {}", palette.query);
let query_width = display_column(&query, usize::MAX);
let width = usize::from(query_area.width);
let offset = query_width.saturating_sub(width - 1);
frame.render_widget(
Paragraph::new(visible_line(&query, offset, width))
.style(Style::default().fg(Color::Yellow)),
query_area,
);
frame.set_cursor_position((query_area.x + (query_width - offset) as u16, query_area.y));
if palette.entries.is_empty() {
frame.render_widget(Paragraph::new("No matching commands"), results_area);
} else if results_area.height > 0 {
let row_height = usize::from(results_area.height >= 2) + 1;
let count = usize::from(results_area.height) / row_height;
let first = palette.selected.saturating_sub(count - 1);
let mut lines = Vec::with_capacity(count * row_height);
for (index, entry) in palette.entries.iter().enumerate().skip(first).take(count) {
let mut style = Style::default();
if !entry.enabled {
style = style.add_modifier(Modifier::DIM);
}
if index == palette.selected {
style = style.bg(Color::Blue).add_modifier(Modifier::BOLD);
}
let bindings = if entry.bindings.is_empty() {
"unbound".into()
} else {
entry.bindings.join(", ")
};
let modes = entry
.modes
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("/");
let metadata = format!("{} | {bindings} | {modes}", entry.owner);
let label = format!(
"{} {} ({})",
if index == palette.selected { ">" } else { " " },
entry.label,
entry.name
);
if row_height == 2 {
lines.push(Line::from(label).style(style));
lines.push(Line::from(format!(" {metadata}")).style(style));
} else {
lines.push(Line::from(format!("{label} | {metadata}")).style(style));
}
}
frame.render_widget(Paragraph::new(lines), results_area);
}
frame.render_widget(
Paragraph::new(format!(
"{mode} · {navigation_hint} · Enter accept · Esc/Ctrl+p cancel"
)),
help_area,
);
}
fn 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)]
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")
}
#[test]
fn hover_shows_more_documentation_with_markdown_and_fenced_code_colors() {
let mut editor = Editor::open(None, vec![]).unwrap();
let title = format!("# {}TAIL", "Heading ".repeat(11));
let details = (5..36)
.map(|row| format!("detail-{row:02}"))
.collect::<Vec<_>>()
.join("\n");
editor
.show_hover(format!(
"{title}\n\n```rust\nfn answer() -> i32 {{ 42 }}\n```\n{details}"
))
.unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 32)).unwrap();
let mut panels = WheelPanels::default();
terminal
.draw(|frame| draw_hover(frame, editor.hover().unwrap(), frame.area(), &mut panels))
.unwrap();
assert!(screen_text(&terminal).contains("TAIL"));
assert!(screen_text(&terminal).contains("detail-20"));
let (panel, _) = panels.hover.unwrap();
let (x, y) = (panel.x + 1, panel.y + 1);
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(x, y)].fg, Color::Cyan);
assert_eq!(buffer[(x, y + 3)].symbol(), "f");
assert_eq!(buffer[(x, y + 3)].fg, Color::Magenta);
assert_eq!(buffer[(x + 6, y + 3)].fg, Color::LightBlue);
assert_eq!(buffer[(x + 21, y + 3)].fg, Color::Yellow);
for _ in 0..3 {
editor.handle(Input::Down);
}
editor.handle(Input::Right);
terminal
.draw(|frame| draw_hover(frame, editor.hover().unwrap(), frame.area(), &mut panels))
.unwrap();
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(x, y)].symbol(), "n");
assert_eq!(buffer[(x, y)].fg, Color::Magenta);
assert_eq!(buffer[(x + 20, y)].fg, Color::Yellow);
editor.show_hover("# Replacement".into()).unwrap();
terminal
.draw(|frame| draw_hover(frame, editor.hover().unwrap(), frame.area(), &mut panels))
.unwrap();
assert!(screen_text(&terminal).contains("# Replacement"));
assert!(!screen_text(&terminal).contains("answer"));
}
#[test]
fn hover_scroll_renders_whole_graphemes_in_tiny_workspace() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.show_hover("x界e\u{301}👩‍🚀z\nlast".into()).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(6, 1)).unwrap();
let mut syntax = SyntaxCache::new();
let (mut top, mut left) = (0, 0);
for _ in 0..2 {
editor.handle(Input::Right);
}
let rendered = terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
assert_eq!(rendered.buffer[(2, 0)].symbol(), "e\u{301}");
assert_eq!(rendered.buffer[(3, 0)].symbol(), "👩‍🚀");
assert_eq!(rendered.buffer[(5, 0)].symbol(), "z");
assert_eq!(
terminal.get_cursor_position().unwrap(),
ratatui::layout::Position::new(2, 0)
);
assert_eq!((top, left), (0, 0));
editor.handle(Input::Down);
editor.handle(Input::Home);
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
assert!(screen_text(&terminal).ends_with("last"));
for (width, height) in [(1, 1), (2, 2), (3, 3), (4, 4)] {
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
}
assert_eq!(editor.cursor(), crate::editor::Cursor::default());
assert_eq!(editor.lines(), &[""]);
}
fn annotation(
start: (usize, usize),
end: (usize, usize),
severity: DiagnosticSeverity,
message: &str,
) -> Diagnostic {
Diagnostic {
start: crate::plugin::TextPosition {
row: start.0,
column: start.1,
},
end: crate::plugin::TextPosition {
row: end.0,
column: end.1,
},
severity,
message: message.into(),
}
}
fn semantic_token(
row: usize,
start: usize,
end: usize,
kind: SemanticTokenKind,
modifiers: u16,
) -> SemanticToken {
SemanticToken {
start: crate::plugin::TextPosition { row, column: start },
end: crate::plugin::TextPosition { row, column: end },
kind,
modifiers,
}
}
#[test]
fn diagnostic_underlines_preserve_syntax_and_whole_graphemes_at_viewport_edges() {
let directory = Directory::new();
let source = "#a\t界e\u{301}👩‍🚀z";
let mut editor = Editor::open(Some(directory.file("unicode.py", source)), vec![]).unwrap();
editor
.replace_diagnostics(
"fixture",
editor.buffer_id(),
editor.text_revision(),
vec![
annotation((0, 2), (0, 6), DiagnosticSeverity::Warning, "range"),
annotation((0, 4), (0, 5), DiagnosticSeverity::Error, "combining"),
],
)
.unwrap();
let mut syntax = SyntaxCache::new();
for (offset, width, caret, underlines) in [
(
2,
5,
4,
vec![
Some(Color::Yellow),
Some(Color::Yellow),
Some(Color::Yellow),
Some(Color::Yellow),
Some(Color::Red),
],
),
(5, 2, 4, vec![None, Some(Color::Red)]),
(7, 2, 5, vec![Some(Color::Yellow), Some(Color::Yellow)]),
(8, 2, 6, vec![None, None]),
(
0,
5,
2,
vec![None, None, Some(Color::Yellow), Some(Color::Yellow), None],
),
] {
editor.handle(Input::Home);
for _ in 0..caret {
editor.handle(Input::Right);
}
let area = Rect::new(3, 2, width + 2, 4);
let mut terminal = Terminal::with_options(
ratatui::backend::TestBackend::new(20, 8),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(area),
},
)
.unwrap();
let (mut top, mut left) = (0, offset);
let rendered = terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap()
.buffer
.clone();
assert_eq!(left, offset);
for (cell, underline) in underlines.into_iter().enumerate() {
let position = (area.x + 2 + cell as u16, area.y);
assert_eq!(
rendered[position].modifier.contains(Modifier::UNDERLINED),
underline.is_some(),
"left={offset}, cell={cell}",
);
if let Some(color) = underline {
assert_eq!(rendered[position].underline_color, color);
assert_eq!(rendered[position].fg, Color::DarkGray);
}
assert_eq!(rendered[position].bg, Color::Reset);
}
assert_eq!(rendered[(area.x, area.y)].fg, Color::Red);
assert_eq!(
terminal.backend().buffer()[(0, 0)].modifier,
Modifier::empty()
);
assert_eq!(editor.lines(), &[source]);
}
}
#[test]
fn diagnostics_render_newlines_anchors_counts_and_messages_without_hiding_core_errors() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace(
"abc\n\nxy\nlast".into(),
)])
.unwrap();
editor
.replace_diagnostics(
"fixture",
editor.buffer_id(),
editor.text_revision(),
vec![
annotation((0, 2), (0, 2), DiagnosticSeverity::Error, "point"),
annotation((0, 1), (3, 0), DiagnosticSeverity::Warning, "multiline"),
annotation((3, 4), (3, 4), DiagnosticSeverity::Information, "end"),
annotation((3, 0), (3, 4), DiagnosticSeverity::Hint, "hint"),
],
)
.unwrap();
editor.handle(Input::Home);
editor.handle(Input::Right);
editor.handle(Input::Right);
editor.set_message("write failed: permission denied");
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 7)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
let buffer = terminal.backend().buffer();
for (position, color) in [
((3, 0), Color::Yellow),
((4, 0), Color::Red),
((5, 0), Color::Yellow),
((2, 1), Color::Yellow),
((2, 2), Color::Yellow),
((3, 2), Color::Yellow),
((4, 2), Color::Yellow),
((2, 3), Color::DarkGray),
((6, 3), Color::Cyan),
] {
assert!(buffer[position].modifier.contains(Modifier::UNDERLINED));
assert_eq!(buffer[position].underline_color, color);
}
for (row, color) in [
(0, Color::Red),
(1, Color::Yellow),
(2, Color::Yellow),
(3, Color::Cyan),
] {
assert_eq!(buffer[(0, row)].fg, color);
}
assert!(!buffer[(6, 0)].modifier.contains(Modifier::UNDERLINED));
let text = screen_text(&terminal);
assert!(text.contains("E:1 W:1 I:1 H:1"), "{text}");
assert!(
text.contains("write failed: permission denied | E: point"),
"{text}"
);
assert_eq!(
terminal.get_cursor_position().unwrap(),
ratatui::layout::Position::new(4, 0),
);
editor.handle(Input::Char('i'));
editor.handle(Input::Char('!'));
assert_eq!(editor.mode(), Mode::Insert);
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert_eq!(editor.diagnostics().count(), 0);
assert!(!screen_text(&terminal).contains("E:1"));
assert!(
!terminal.backend().buffer()[(4, 0)]
.modifier
.contains(Modifier::UNDERLINED)
);
}
#[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}])
else
target = documents.values.find { |document| document["path"] == Vamp.options.fetch("target") }
raise "Target document is not open" unless target
lines = target.fetch("text").split("\n", -1)
row = lines.index { |line| line.include?("TARGET") }
raise "Target definition missing" unless row
column = lines[row].each_grapheme_cluster.to_a.each_cons(6).find_index { |token| token.join == "TARGET" }
Vamp.publish([{type: "navigate", buffer_id: args[0], revision: args[1],
path: target.fetch("path"), row: row, column: column}],
request_id: args[2])
end
end
end
"#,
);
let config = directory.file(
"config.rb",
&format!(
"Vamp.configure do |config|\n config.clipboard_mode = \"separate\"\n config.plugin \"semantic.rb\", options: {{target: {}}}\nend\n",
serde_json::to_string(&target.to_string_lossy()).unwrap(),
),
);
let mut session = EditorSession::open(
Some(source),
CAPABILITIES
.iter()
.map(|capability| (*capability).into())
.collect(),
Some(&config),
)
.unwrap();
let source_id = session.editor().buffer_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(18, 6)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
fn poll_until(
session: &mut EditorSession,
terminal: &mut Terminal<ratatui::backend::TestBackend>,
viewports: &mut BufferViewports,
ready: impl Fn(&Editor) -> bool,
) {
for _ in 0..2_000 {
if session.poll() {
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
}
if ready(session.editor()) {
return;
}
std::thread::sleep(Duration::from_millis(1));
}
panic!(
"async fixture did not update editor: {}",
session.editor().message()
);
}
session.handle(Input::Char('A'));
session.handle(Input::Char('!'));
assert_eq!(session.editor().mode(), Mode::Insert);
assert_eq!(session.editor().lines(), &["broken TARGET!"]);
poll_until(&mut session, &mut terminal, &mut viewports, |editor| {
editor.diagnostics().count() == 1
});
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);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["broken TARGET"]);
assert_eq!(session.editor().diagnostics().count(), 0);
session.execute(&format!("edit {}", target.display()));
let target_id = session.editor().buffer_id();
session.handle(Input::Char('i'));
session.handle(Input::Char('!'));
session.handle(Input::Escape);
session.execute(&format!("buffer {source_id}"));
session.execute("fixture_definition");
session.handle(Input::Char('l'));
assert_eq!(session.editor().buffer_id(), source_id);
poll_until(&mut session, &mut terminal, &mut viewports, |editor| {
editor.buffer_id() == target_id
});
assert_eq!(session.editor().buffer_count(), 2);
assert!(session.editor().dirty());
assert_eq!(session.editor().lines()[0], "!# line");
assert_eq!(session.editor().cursor().row, 15);
assert_eq!(session.editor().cursor().column, 40);
assert_eq!(session.editor().mode(), Mode::Normal);
assert_eq!(viewports.offsets[&target_id], (12, 26));
let cursor = terminal.get_cursor_position().unwrap();
assert_eq!(
terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(),
"T"
);
assert_eq!(
terminal.backend().buffer()[(cursor.x, cursor.y)].fg,
Color::DarkGray
);
assert_eq!(fs::read_to_string(target).unwrap(), target_text);
}
#[test]
fn syntax_tokens_refresh_after_native_edits_and_undo_without_changing_text_on_draw() {
let directory = Directory::new();
let path = directory.file("number.rs", "42");
let mut editor = Editor::open(Some(path), vec![]).unwrap();
editor.set_clipboard_mode("separate").unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow);
let revision = editor.text_revision();
editor.handle(Input::Right);
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert_eq!(editor.text_revision(), revision);
assert_eq!(editor.lines(), &["42"]);
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow);
editor.handle(Input::Home);
editor.handle(Input::Char('v'));
editor.handle(Input::Right);
editor.handle(Input::Char('c'));
for ch in "\"ok\"".chars() {
editor.handle(Input::Char(ch));
}
editor.handle(Input::Escape);
assert_eq!(editor.lines(), &["\"ok\""]);
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Green);
assert!(editor.text_revision() > revision);
editor.handle(Input::Char('u'));
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert_eq!(editor.lines(), &["42"]);
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow);
editor
.apply(vec![crate::editor::Effect::Replace(
"fn main() { let n = 42; let s = \"ok\"; } // note".into(),
)])
.unwrap();
editor.handle(Input::Home);
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
let buffer = terminal.backend().buffer();
let source = &editor.lines()[0];
for (token, color) in [
("fn", Color::Magenta),
("main", Color::LightBlue),
("let", Color::Magenta),
("42", Color::Yellow),
("\"ok\"", Color::Green),
] {
let x = 2 + source.find(token).unwrap() as u16;
assert_eq!(buffer[(x, 0)].fg, color, "{token}");
assert_eq!(buffer[(x, 0)].bg, Color::Reset);
}
}
#[test]
fn syntax_continuations_above_viewport_refresh_when_the_opening_line_changes() {
let directory = Directory::new();
for (name, before, after, color) in [
(
"comment.rs",
"/*\nhidden\nvisible\n*/",
"//\nhidden\nvisible\n//",
Color::DarkGray,
),
(
"string.py",
"text = \"\"\"\nhidden\nvisible\n\"\"\"",
"text = \"\"\nhidden\nvisible\n\"\"",
Color::Green,
),
] {
let path = directory.file(name, before);
let mut editor = Editor::open(Some(path), vec![]).unwrap();
editor.handle(Input::Down);
editor.handle(Input::Down);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(30, 3)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert_eq!(viewports.offsets[&editor.buffer_id()].0, 2);
assert_eq!(terminal.backend().buffer()[(2, 0)].symbol(), "v");
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, color, "{name}");
editor
.apply(vec![crate::editor::Effect::Replace(after.into())])
.unwrap();
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert_eq!(terminal.backend().buffer()[(2, 0)].symbol(), "v");
assert_eq!(
terminal.backend().buffer()[(2, 0)].fg,
Color::Reset,
"{name}"
);
editor.handle(Input::Char('u'));
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, color, "{name}");
}
}
#[test]
fn semantic_roles_remain_distinct_when_values_and_fields_are_readonly() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace(
"ns Type local frozen field fixed param frozenparam".into(),
)])
.unwrap();
let readonly = SemanticTokenModifier::Readonly as u16;
editor
.replace_semantic_tokens(
editor.buffer_id(),
editor.text_revision(),
Some(Arc::from([
semantic_token(0, 0, 2, SemanticTokenKind::Namespace, 0),
semantic_token(0, 3, 7, SemanticTokenKind::Type, 0),
semantic_token(0, 8, 13, SemanticTokenKind::Variable, 0),
semantic_token(0, 14, 20, SemanticTokenKind::Variable, readonly),
semantic_token(0, 21, 26, SemanticTokenKind::Property, 0),
semantic_token(0, 27, 32, SemanticTokenKind::Property, readonly),
semantic_token(0, 33, 38, SemanticTokenKind::Parameter, 0),
semantic_token(0, 39, 50, SemanticTokenKind::Parameter, readonly),
])),
)
.unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 4)).unwrap();
terminal
.draw(|frame| BufferViewports::default().draw(frame, &editor))
.unwrap();
let buffer = terminal.backend().buffer();
let foregrounds = [0, 3, 8, 14, 21, 27, 33, 39].map(|column| buffer[(column + 2, 0)].fg);
assert_ne!(foregrounds[0], foregrounds[1]);
assert_ne!(foregrounds[1], foregrounds[2]);
assert_ne!(foregrounds[2], foregrounds[4]);
assert_eq!(foregrounds[2], foregrounds[3]);
assert_eq!(foregrounds[4], foregrounds[5]);
assert_eq!(foregrounds[6], foregrounds[7]);
for (column, is_readonly) in [
(0, false),
(3, false),
(8, false),
(14, true),
(21, false),
(27, true),
(33, false),
(39, true),
] {
assert_eq!(
buffer[(column + 2, 0)].modifier.contains(Modifier::ITALIC),
is_readonly,
);
}
}
#[test]
fn semantic_colors_modifiers_and_syntax_fallback_cover_whole_graphemes_and_clipped_tabs() {
let directory = Directory::new();
let source = "#a\t界e\u{301}👩‍🚀z";
let mut editor = Editor::open(Some(directory.file("unicode.py", source)), vec![]).unwrap();
editor.set_clipboard_mode("separate").unwrap();
editor
.replace_semantic_tokens(
editor.buffer_id(),
editor.text_revision(),
Some(Arc::from([
semantic_token(
0,
0,
1,
SemanticTokenKind::Comment,
SemanticTokenModifier::Documentation as u16,
),
semantic_token(
0,
2,
6,
SemanticTokenKind::Property,
SemanticTokenModifier::Readonly as u16
| SemanticTokenModifier::Definition as u16
| SemanticTokenModifier::Deprecated as u16,
),
semantic_token(0, 2, 3, SemanticTokenKind::Variable, 0),
])),
)
.unwrap();
editor
.replace_diagnostics(
"fixture",
editor.buffer_id(),
editor.text_revision(),
vec![annotation(
(0, 2),
(0, 6),
DiagnosticSeverity::Warning,
"semantic",
)],
)
.unwrap();
let mut syntax = SyntaxCache::new();
for (offset, width, caret, coverage) in [
(2, 5, 4, vec![true; 5]),
(5, 2, 4, vec![false, true]),
(7, 2, 5, vec![true; 2]),
(8, 2, 6, vec![false; 2]),
(0, 5, 2, vec![false, false, true, true, false]),
] {
editor.handle(Input::Home);
for _ in 0..caret {
editor.handle(Input::Right);
}
let area = Rect::new(3, 2, width + 2, 4);
let mut terminal = Terminal::with_options(
ratatui::backend::TestBackend::new(20, 8),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(area),
},
)
.unwrap();
let (mut top, mut left) = (0, offset);
let rendered = terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap()
.buffer
.clone();
assert_eq!(left, offset);
let text_area = Rect::new(area.x + 2, area.y, width, 1);
let mut expected = ratatui::buffer::Buffer::empty(text_area);
use ratatui::widgets::Widget;
Paragraph::new(visible_line(source, offset, usize::from(width)))
.render(text_area, &mut expected);
let buffer = terminal.backend().buffer();
for (cell, semantic_cell) in coverage.into_iter().enumerate() {
let position = (text_area.x + cell as u16, text_area.y);
assert_eq!(buffer[position].symbol(), expected[position].symbol());
assert_eq!(rendered[position].bg, Color::Reset);
assert_eq!(
rendered[position].modifier.contains(Modifier::BOLD),
semantic_cell
);
assert_eq!(
rendered[position].modifier.contains(Modifier::CROSSED_OUT),
semantic_cell
);
assert_eq!(
rendered[position].modifier.contains(Modifier::UNDERLINED),
semantic_cell
);
if semantic_cell {
assert_eq!(rendered[position].underline_color, Color::Yellow);
}
assert_eq!(
rendered[position].modifier.contains(Modifier::ITALIC),
semantic_cell || (offset == 0 && cell == 0)
);
}
assert_eq!(buffer[(area.x, area.y)].fg, Color::Yellow);
assert_eq!(buffer[(0, 0)].fg, Color::Reset);
assert_eq!(editor.lines(), &[source]);
}
editor.handle(Input::Char('x'));
assert!(editor.semantic_tokens().is_empty());
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 4)).unwrap();
let (mut top, mut left) = (0, 0);
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::DarkGray);
assert!(
!terminal.backend().buffer()[(2, 0)]
.modifier
.contains(Modifier::ITALIC)
);
}
#[test]
fn search_visual_selection_and_secondary_carets_override_semantic_foreground() {
let directory = Directory::new();
let mut editor = Editor::open(
Some(directory.file("overlays.py", "# word\n# word\n# word")),
vec![],
)
.unwrap();
editor
.replace_semantic_tokens(
editor.buffer_id(),
editor.text_revision(),
Some(Arc::from(
(0..3)
.map(|row| {
semantic_token(
row,
2,
6,
SemanticTokenKind::Function,
SemanticTokenModifier::Declaration as u16,
)
})
.collect::<Vec<_>>(),
)),
)
.unwrap();
editor
.replace_diagnostics(
"fixture",
editor.buffer_id(),
editor.text_revision(),
vec![annotation(
(0, 2),
(2, 6),
DiagnosticSeverity::Error,
"overlays",
)],
)
.unwrap();
editor.handle(Input::Char('/'));
for ch in "word".chars() {
editor.handle(Input::Char(ch));
}
editor.handle(Input::Enter);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 6)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
let found = editor.search_match().unwrap();
let y = found.start.row as u16;
for x in 4..8 {
assert_eq!(terminal.backend().buffer()[(x, y)].fg, Color::Black);
assert_eq!(terminal.backend().buffer()[(x, y)].bg, Color::Yellow);
assert!(
terminal.backend().buffer()[(x, y)]
.modifier
.contains(Modifier::UNDERLINED)
);
}
editor.handle(Input::Char('v'));
editor.handle(Input::Char('e'));
editor.handle(Input::Ctrl('a'));
assert_eq!(editor.selection_count(), 3);
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
let buffer = terminal.backend().buffer();
for selection in editor.selections() {
let y = selection.start.row as u16;
let start =
display_column(&editor.lines()[selection.start.row], selection.start.column);
let end = display_column(&editor.lines()[selection.end.row], selection.end.column);
for column in start..end {
let x = 2 + column as u16;
if buffer[(x, y)].bg == Color::Magenta {
assert_eq!(buffer[(x, y)].fg, Color::Black);
} else {
assert_eq!(buffer[(x, y)].fg, Color::White);
assert_eq!(buffer[(x, y)].bg, Color::Blue);
}
assert!(buffer[(x, y)].modifier.contains(Modifier::UNDERLINED));
}
assert_eq!(buffer[(2, y)].fg, Color::DarkGray);
}
for caret in editor
.carets()
.into_iter()
.filter(|caret| *caret != editor.cursor())
{
let x = 2 + display_column(&editor.lines()[caret.row], caret.column) as u16;
assert_eq!(buffer[(x, caret.row as u16)].fg, Color::Black);
assert_eq!(buffer[(x, caret.row as u16)].bg, Color::Magenta);
assert!(
buffer[(x, caret.row as u16)]
.modifier
.contains(Modifier::UNDERLINED)
);
}
}
#[test]
fn syntax_redetects_saved_paths_without_text_changes_and_plain_text_keeps_default_color() {
let directory = Directory::new();
let mut editor =
Editor::open(Some(directory.file("source.rs", "let value = 42;")), vec![]).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Magenta);
let revision = editor.text_revision();
for name in ["source.vampunknown", "source.txt", "again.rs"] {
editor.execute(&format!("w {}", directory.0.join(name).display()));
assert_eq!(editor.text_revision(), revision);
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
if name.ends_with(".rs") {
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Magenta);
assert_eq!(
viewports.syntax[&editor.buffer_id()].language(),
Some("Rust")
);
} else {
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Reset);
assert!(viewports.syntax[&editor.buffer_id()].line(0).is_empty());
assert!(viewports.syntax[&editor.buffer_id()].language().is_none());
}
assert_eq!(editor.lines(), &["let value = 42;"]);
}
}
#[test]
fn syntax_caches_stay_independent_across_buffer_edits_and_retire_closed_ids() {
let directory = Directory::new();
let mut editor = Editor::open(Some(directory.file("a.rs", "42")), vec![]).unwrap();
let a_id = editor.buffer_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow);
editor.execute(&format!(
"edit {}",
directory.file("b.py", "# note").display()
));
let b_id = editor.buffer_id();
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::DarkGray);
editor
.apply(vec![crate::editor::Effect::Replace("\"ok\"".into())])
.unwrap();
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Green);
editor.execute(&format!("buffer {a_id}"));
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow);
assert_eq!(viewports.syntax[&a_id].language(), Some("Rust"));
assert_eq!(viewports.syntax[&b_id].language(), Some("Python"));
editor.execute(&format!("buffer {b_id}"));
editor.execute(&format!("bdelete {a_id}"));
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Green);
assert!(!viewports.syntax.contains_key(&a_id));
assert!(!viewports.offsets.contains_key(&a_id));
assert_eq!(viewports.syntax.len(), 1);
}
#[test]
fn translate_distinguishes_control_j_from_enter_and_normalizes_control_letters() {
for (code, modifiers, expected) in [
(
KeyCode::Char('j'),
KeyModifiers::CONTROL,
Some(Input::Ctrl('j')),
),
(
KeyCode::Char('J'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
Some(Input::Ctrl('j')),
),
(
KeyCode::Char('k'),
KeyModifiers::CONTROL,
Some(Input::Ctrl('k')),
),
(
KeyCode::Char('h'),
KeyModifiers::CONTROL,
Some(Input::Ctrl('h')),
),
(
KeyCode::Char('l'),
KeyModifiers::CONTROL,
Some(Input::Ctrl('l')),
),
(
KeyCode::Backspace,
KeyModifiers::NONE,
Some(Input::Backspace),
),
(KeyCode::Enter, KeyModifiers::NONE, Some(Input::Enter)),
(KeyCode::Enter, KeyModifiers::CONTROL, Some(Input::Enter)),
(
KeyCode::Char('J'),
KeyModifiers::SHIFT,
Some(Input::Char('J')),
),
(KeyCode::Char('j'), KeyModifiers::ALT, None),
] {
assert_eq!(translate(KeyEvent::new(code, modifiers)), expected);
}
}
#[test]
fn wheel_routes_to_floating_and_unfocused_docked_files_without_changing_selection() {
let directory = Directory::new();
let config = directory.file(
"config.rb",
"Vamp.configure do |config|\n config.file_picker_view = 'list'\n config.file_explorer_view = 'list'\nend\n",
);
for (key, focused) in [('o', true), ('e', true), ('e', false)] {
let mut session = EditorSession::open(None, vec![], Some(&config)).unwrap();
session.handle(Input::Char(' '));
session.handle(Input::Char(key));
if !focused {
session.handle(Input::Char('\t'));
}
let picker = session.editor().file_picker().unwrap();
assert!(picker.entries().len() > 5);
let selected = picker.selected;
let selected_path = picker.entries().nth(selected).unwrap().path.to_owned();
let query = picker.query.clone();
let carets = session.editor().carets();
let revision = session.editor().text_revision();
let mut panels = WheelPanels::default();
let area = Rect::new(3, 2, 40, 6);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 12)).unwrap();
terminal
.draw(|frame| draw_file_list(frame, picker, area, "", &mut panels))
.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,
);
})
.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,
)
})
.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);
})
.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))
.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(),
)
})
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains(&format!("> {label}")), "{text}");
let buffer = terminal.backend().buffer();
let selected_y = (3..8).find(|&y| buffer[(1, y)].symbol() == ">").unwrap();
assert_eq!(buffer[(1, selected_y)].bg, Color::Blue);
}
#[test]
fn list_footers_show_remapped_and_disabled_selection_keys() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_file_explorer_view("list").unwrap();
editor.set_file_picker_view("list").unwrap();
for key in ["ctrl+j", "ctrl+n", "down", "ctrl+k", "up"] {
editor.bind("selection", key, None).unwrap();
}
editor
.bind("selection", "x", Some("core.down".into()))
.unwrap();
let hint = editor.selection_navigation_hint().to_owned();
assert_eq!(hint, "x next · unbound previous");
editor.set_file_explorer_side("bottom").unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap();
let (mut top, mut left) = (0, 0);
let mut syntax = SyntaxCache::new();
for key in ['o', 'e'] {
editor.handle(Input::Char(' '));
editor.handle(Input::Char(key));
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains(&hint), "{text}");
assert!(!text.contains("Ctrl+j"), "{text}");
editor.handle(Input::Escape);
}
editor.handle(Input::Ctrl('p'));
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains(&hint), "{text}");
assert!(!text.contains("Ctrl+j"), "{text}");
}
#[test]
fn file_list_scrolls_selection_and_sanitizes_display_without_losing_paths() {
let directory = Directory::new();
for index in 0..18 {
directory.file(&format!("file-{index:02}"), "text");
}
let special = directory.file("界e\u{301} space\nname", "text");
let editor = Editor::open(None, vec![]).unwrap();
let mut picker = FilePicker::scan(
directory.0.clone(),
FilePresentation::Floating,
FileView::List,
)
.unwrap();
assert!(
picker
.entries()
.any(|entry| directory.0.join(entry.path) == special)
);
let count = picker.entries().len();
picker.selected = count - 1;
let label = picker.entries().last().unwrap().label;
assert_eq!(label, "界e\u{301} space�name");
picker.query = "long\t界e\u{301}z".into();
let area = Rect::new(3, 2, 24, 9);
let mut terminal = Terminal::with_options(
ratatui::backend::TestBackend::new(40, 15),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(area),
},
)
.unwrap();
terminal
.draw(|frame| {
draw_file_list(
frame,
&picker,
area,
editor.selection_navigation_hint(),
&mut WheelPanels::default(),
)
})
.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(),
)
})
.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(),
);
})
.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(),
)
})
.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(),
)
})
.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(),
);
})
.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(),
)
})
.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(),
)
})
.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(),
)
})
.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(),
)
})
.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(),
)
})
.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(),
)
})
.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(),
)
})
.unwrap();
let cursor = terminal.get_cursor_position().unwrap();
assert!(area.contains(cursor), "{area:?}: {cursor:?}");
let query_x = area.x + u16::from(width >= 4 && height >= 4);
assert_eq!(cursor.x, query_x);
let query_y = area.y + u16::from(width >= 4 && height >= 4);
let query_width = width - 2 * u16::from(width >= 4 && height >= 4);
let header = format!("filter: {query} · ^U clear");
let mut expected =
ratatui::buffer::Buffer::empty(Rect::new(query_x, query_y, query_width, 1));
use ratatui::widgets::Widget;
Paragraph::new(visible_line(&header, 0, usize::from(query_width)))
.render(expected.area, &mut expected);
let mut continuation = 0;
for x in query_x..query_x + query_width {
if continuation > 0 {
continuation -= 1;
continue;
}
continuation = expected[(x, query_y)].symbol().width().saturating_sub(1);
assert_eq!(
terminal.backend().buffer()[(x, query_y)].symbol(),
expected[(x, query_y)].symbol()
);
}
if width == 100 {
assert!(screen_text(&terminal).contains("filter: "), "{query}");
assert!(
screen_text(&terminal).contains("No matching files"),
"{query}"
);
}
picker.clear_filter();
assert!(!picker.searching);
assert_eq!(
picker.entries().nth(picker.selected).unwrap().path,
std::path::Path::new("dir-17/file.txt")
);
}
}
}
#[test]
fn tree_filter_escape_resume_and_clear_keep_selection_and_cursor_ownership() {
use std::path::Path;
let directory = Directory::new();
directory.file("src/inner/needle.txt", "");
directory.file("src/other.txt", "");
directory.file("root.txt", "");
for presentation in [FilePresentation::Floating, FilePresentation::Docked] {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_project_root(directory.0.clone());
editor.set_file_picker_view("tree").unwrap();
editor
.apply(vec![crate::editor::Effect::Replace("unchanged".into())])
.unwrap();
editor.handle(Input::Char('v'));
let mode = editor.mode();
let selections = editor.selections();
editor.handle(Input::Char(' '));
editor.handle(Input::Char(if presentation == FilePresentation::Floating {
'o'
} else {
'e'
}));
if !editor.file_picker().unwrap().searching {
editor.handle(Input::Char('i'));
}
let query = "src/inner/needle.txt";
for ch in query.chars() {
editor.handle(Input::Char(ch));
}
editor.handle(Input::Ctrl('h'));
let picker = editor.file_picker().unwrap();
assert_eq!(picker.query, query);
assert_eq!(
picker.entries().nth(picker.selected).unwrap().path,
Path::new("src/inner")
);
editor.handle(Input::Ctrl('l'));
let picker = editor.file_picker().unwrap();
assert_eq!(
picker.entries().nth(picker.selected).unwrap().path,
Path::new(query)
);
let filtered: Vec<_> = picker
.entries()
.map(|row| (row.path.to_owned(), row.expanded))
.collect();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap();
let (mut top, mut left) = (0, 0);
let mut syntax = SyntaxCache::new();
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let query_cursor = terminal.get_cursor_position().unwrap();
assert!(screen_text(&terminal).contains(&format!("> {query}")));
assert!(screen_text(&terminal).contains("^U clear"));
editor.handle(Input::Escape);
let picker = editor.file_picker().unwrap();
assert!(picker.focused && !picker.searching);
assert_eq!(picker.query, query);
assert_eq!(
picker.entries().nth(picker.selected).unwrap().path,
Path::new(query)
);
assert_eq!(
picker
.entries()
.map(|row| (row.path.to_owned(), row.expanded))
.collect::<Vec<_>>(),
filtered
);
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains(&format!("filter: {query}")), "{text}");
assert!(
text.contains("needle.txt") && !text.contains("other.txt"),
"{text}"
);
let selected_cursor = terminal.get_cursor_position().unwrap();
assert_ne!(selected_cursor.y, query_cursor.y);
assert_eq!(
terminal.backend().buffer()[(selected_cursor.x, selected_cursor.y)].symbol(),
">"
);
assert_eq!(
terminal.backend().buffer()[(selected_cursor.x, selected_cursor.y)].bg,
Color::Blue
);
editor.handle(Input::Char('i'));
assert_eq!(editor.file_picker().unwrap().query, query);
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
assert_eq!(terminal.get_cursor_position().unwrap(), query_cursor);
editor.handle(Input::Escape);
editor.handle(Input::Ctrl('u'));
let picker = editor.file_picker().unwrap();
assert!(picker.focused && !picker.searching && picker.query.is_empty());
assert_eq!(
picker.entries().nth(picker.selected).unwrap().path,
Path::new(query)
);
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let cursor = terminal.get_cursor_position().unwrap();
assert_eq!(
terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(),
">"
);
assert!(screen_text(&terminal).contains("other.txt"));
editor.handle(Input::Char('i'));
for ch in query.chars() {
editor.handle(Input::Char(ch));
}
editor.handle(Input::Ctrl('u'));
let picker = editor.file_picker().unwrap();
assert!(picker.focused && picker.searching && picker.query.is_empty());
assert_eq!(
picker.entries().nth(picker.selected).unwrap().path,
Path::new(query)
);
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let cursor = terminal.get_cursor_position().unwrap();
assert_eq!(cursor.y, query_cursor.y);
assert_eq!(cursor.x, query_cursor.x - query.len() as u16);
editor.handle(Input::Ctrl('t'));
for ch in query.chars() {
editor.handle(Input::Char(ch));
}
editor.handle(Input::Ctrl('u'));
let picker = editor.file_picker().unwrap();
assert_eq!(picker.view, FileView::List);
assert!(picker.focused && picker.query.is_empty());
assert_eq!(
picker.entries().nth(picker.selected).unwrap().path,
Path::new(query)
);
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
assert_eq!(terminal.get_cursor_position().unwrap(), cursor);
editor.handle(Input::Ctrl('t'));
editor.handle(Input::Escape);
editor.handle(Input::Escape);
if presentation == FilePresentation::Floating {
assert!(editor.file_picker().is_none());
} else {
assert!(!editor.file_picker().unwrap().focused);
}
assert_eq!(editor.mode(), mode);
assert_eq!(editor.selections(), selections);
assert_eq!(editor.lines(), ["unchanged"]);
}
}
#[test]
fn explorer_retains_insert_filter_through_focus_and_view_switches() {
let directory = Directory::new();
directory.file("src/inner/needle.txt", "");
directory.file("src/other.txt", "");
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_project_root(directory.0.clone());
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap();
let (mut top, mut left) = (0, 0);
let mut syntax = SyntaxCache::new();
editor.handle(Input::Char(' '));
editor.handle(Input::Char('e'));
editor.handle(Input::Char('i'));
editor.handle(Input::Char('n'));
editor.handle(Input::Char('\t'));
assert!(!editor.file_picker().unwrap().focused);
editor.handle(Input::Char(' '));
editor.handle(Input::Char('e'));
assert!(editor.file_picker().unwrap().searching);
assert_eq!(editor.file_picker().unwrap().query, "n");
editor.handle(Input::Ctrl('t'));
assert_eq!(editor.file_picker().unwrap().view, FileView::List);
assert_eq!(editor.file_picker().unwrap().query, "n");
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
assert!(screen_text(&terminal).contains("List"));
assert_eq!(terminal.get_cursor_position().unwrap().y, 1);
editor.handle(Input::Ctrl('t'));
assert_eq!(editor.file_picker().unwrap().view, FileView::Tree);
assert!(editor.file_picker().unwrap().searching);
assert_eq!(editor.file_picker().unwrap().query, "n");
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
assert_eq!(terminal.get_cursor_position().unwrap().y, 1);
editor.handle(Input::Escape);
assert!(editor.file_picker().unwrap().focused);
assert!(!editor.file_picker().unwrap().searching);
assert_eq!(editor.file_picker().unwrap().query, "n");
editor.handle(Input::Ctrl('t'));
editor.handle(Input::Ctrl('t'));
assert!(!editor.file_picker().unwrap().searching);
assert_eq!(editor.file_picker().unwrap().query, "n");
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
assert!(screen_text(&terminal).contains("filter: n"));
let cursor = terminal.get_cursor_position().unwrap();
assert_eq!(
terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(),
">"
);
}
#[test]
fn configured_views_and_view_toggle_render_in_both_presentations_with_scoped_hints() {
let directory = Directory::new();
directory.file("folder/file.txt", "");
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_project_root(directory.0.clone());
editor.set_file_picker_view("tree").unwrap();
editor.set_file_explorer_view("list").unwrap();
editor.set_file_explorer_side("bottom").unwrap();
editor.bind("tree", "ctrl+l", None).unwrap();
editor.bind("tree", "right", None).unwrap();
editor.bind("tree", "x", Some("core.right".into())).unwrap();
for key in ["ctrl+j", "ctrl+n", "down", "ctrl+k", "up"] {
editor.bind("selection", key, None).unwrap();
}
editor
.bind("selection", "j", Some("core.down".into()))
.unwrap();
editor
.bind("selection", "k", Some("core.up".into()))
.unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(200, 30)).unwrap();
let (mut top, mut left) = (0, 0);
let mut syntax = SyntaxCache::new();
for (key, expected) in [('o', FileView::Tree), ('e', FileView::List)] {
editor.handle(Input::Char(' '));
editor.handle(Input::Char(key));
assert_eq!(editor.file_picker().unwrap().view, expected);
if expected == FileView::Tree {
assert!(editor.file_picker().unwrap().searching);
editor.handle(Input::Escape);
}
for view in [
expected,
if expected == FileView::Tree {
FileView::List
} else {
FileView::Tree
},
] {
assert_eq!(editor.file_picker().unwrap().view, view);
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains(editor.file_navigation_hint()), "{text}");
if view == FileView::Tree {
assert_ne!(
editor.file_navigation_hint(),
editor.selection_navigation_hint()
);
let cursor = terminal.get_cursor_position().unwrap();
assert_eq!(
terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(),
">"
);
editor.handle(Input::Char('x'));
assert_eq!(editor.file_picker().unwrap().query, "");
assert!(
editor
.file_picker()
.unwrap()
.entries()
.next()
.unwrap()
.expanded
);
let picker = editor.file_picker().unwrap();
let was_file = !picker.entries().nth(picker.selected).unwrap().is_dir;
editor.handle(Input::Ctrl('h'));
if was_file {
let picker = editor.file_picker().unwrap();
assert!(picker.entries().nth(picker.selected).unwrap().is_dir);
assert!(picker.entries().next().unwrap().expanded);
editor.handle(Input::Ctrl('h'));
}
assert!(
!editor
.file_picker()
.unwrap()
.entries()
.next()
.unwrap()
.expanded
);
} else {
assert_eq!(
editor.file_navigation_hint(),
editor.selection_navigation_hint()
);
assert!(text.contains("folder/file.txt"), "{text}");
}
editor.handle(Input::Ctrl('t'));
}
editor.handle(Input::Escape);
}
editor.handle(Input::Ctrl('p'));
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
assert!(screen_text(&terminal).contains(editor.selection_navigation_hint()));
}
#[test]
fn all_dock_sides_clip_editor_gutter_selection_carets_and_cursor() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace(
vec!["word ".repeat(40); 20].join("\n"),
)])
.unwrap();
editor.handle(Input::Char('v'));
editor.handle(Input::Ctrl('a'));
assert!(editor.selection_count() > 1);
let area = Rect::new(3, 2, 90, 26);
let mut terminal = Terminal::with_options(
ratatui::backend::TestBackend::new(100, 32),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(area),
},
)
.unwrap();
let (mut top, mut left) = (0, 0);
let mut syntax = SyntaxCache::new();
editor.handle(Input::Char(' '));
editor.handle(Input::Char('e'));
assert!(editor.file_picker().unwrap().focused);
for (name, side) in [
("left", DockSide::Left),
("right", DockSide::Right),
("top", DockSide::Top),
("bottom", DockSide::Bottom),
] {
editor.set_file_explorer_side(name).unwrap();
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let workspace = Rect::new(area.x, area.y, area.width, area.height - 2);
let (editor_area, panel) = dock_layout(workspace, side, true);
let panel = panel.unwrap();
assert_eq!(editor_area.area() + panel.area(), workspace.area());
assert_eq!(editor_area.intersection(panel).area(), 0);
match side {
DockSide::Left => assert_eq!(panel.right(), editor_area.x),
DockSide::Right => assert_eq!(editor_area.right(), panel.x),
DockSide::Top => assert_eq!(panel.bottom(), editor_area.y),
DockSide::Bottom => assert_eq!(editor_area.bottom(), panel.y),
}
assert!(panel.contains(terminal.get_cursor_position().unwrap()));
editor.handle(Input::Escape);
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
assert!(editor_area.contains(terminal.get_cursor_position().unwrap()));
let buffer = terminal.backend().buffer();
for y in panel.y..panel.bottom() {
for x in panel.x..panel.right() {
assert!(
!matches!(
buffer[(x, y)].bg,
Color::Blue | Color::Magenta | Color::Yellow
),
"{side:?}: {x},{y}"
);
}
}
assert_eq!(buffer[(editor_area.x + 3, editor_area.y)].symbol(), "w");
assert!(matches!(
buffer[(editor_area.x + 3, editor_area.y)].bg,
Color::Blue | Color::Magenta
));
for y in editor_area.y..editor_area.bottom() {
for x in editor_area.x..editor_area.x + 3 {
assert_eq!(buffer[(x, y)].bg, Color::Reset);
}
}
for x in area.x..area.right() {
assert_eq!(buffer[(x, area.bottom() - 2)].bg, Color::Cyan);
}
assert_eq!(buffer[(0, 0)].symbol(), " ");
editor.handle(Input::Char(' '));
editor.handle(Input::Char('e'));
assert!(editor.file_picker().unwrap().focused);
}
}
#[test]
fn floating_picker_clears_a_centered_overlay_without_moving_editor_regions() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace(
vec!["x".repeat(120); 30].join("\n"),
)])
.unwrap();
let area = Rect::new(3, 2, 110, 26);
let mut terminal = Terminal::with_options(
ratatui::backend::TestBackend::new(120, 32),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(area),
},
)
.unwrap();
let (mut top, mut left) = (0, 0);
let mut syntax = SyntaxCache::new();
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let before = (top, left);
editor.handle(Input::Char(' '));
editor.handle(Input::Char('o'));
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let panel = Rect::new(area.x + 5, area.y + 1, 100, 24);
assert_eq!((top, left), before);
assert_eq!(
terminal.get_cursor_position().unwrap(),
ratatui::layout::Position::new(panel.x + 3, panel.y + 1)
);
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(panel.x, panel.y)].fg, Color::Cyan);
assert_eq!(buffer[(panel.right() - 2, panel.y + 1)].symbol(), " ");
assert_eq!(buffer[(area.x + 3, area.y)].symbol(), "x");
assert_eq!(buffer[(area.x, area.bottom() - 2)].bg, Color::Cyan);
assert_eq!(buffer[(0, 0)].symbol(), " ");
}
#[test]
fn startup_diagnostic_scrolls_long_unicode_reasons_and_dismisses_without_editing() {
let directory = Directory::new();
let source = directory.file("buffer.txt", "keep");
let config = directory.file("config.rb", "raise \"#{'x' * 160}界END\\nlast reason\"");
let mut session =
EditorSession::open(Some(source), vec!["text".into()], Some(&config)).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(110, 26)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains("Configuration error"));
assert!(text.contains("config.rb"));
assert!(text.contains("default settings and bindings"));
assert!(text.contains("last reason"));
assert!(!text.contains("界END"));
for _ in 0..100 {
session.handle(Input::Right);
}
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
let scrolled = screen_text(&terminal);
assert!(
scrolled.contains("界") && scrolled.contains("END"),
"{scrolled}"
);
for (width, height) in [(4, 4), (1, 1)] {
let mut tiny =
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
tiny.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
}
session.handle(Input::Enter);
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert!(!screen_text(&terminal).contains("Configuration error"));
assert_eq!(session.editor().lines(), &["keep"]);
assert_eq!(session.editor().cursor(), crate::editor::Cursor::default());
}
#[test]
fn picker_conversion_focus_palette_and_closure_preserve_buffer_viewport_cache() {
let directory = Directory::new();
let a = directory.file("a", &vec!["a".repeat(80); 30].join("\n"));
let b = directory.file("b", "other");
let mut editor = Editor::open(Some(a), vec![]).unwrap();
let a_id = editor.buffer_id();
for _ in 0..20 {
editor.handle(Input::Down);
}
editor.handle(Input::End);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(50, 10)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
let before = viewports.offsets[&a_id];
editor.handle(Input::Char(' '));
editor.handle(Input::Char('o'));
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert_eq!(viewports.offsets[&a_id], before);
assert!(screen_text(&terminal).contains("Files"));
editor.handle(Input::Escape);
assert!(editor.file_picker().unwrap().focused);
editor.handle(Input::Escape);
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert_eq!(viewports.offsets[&a_id], before);
editor.handle(Input::Char(' '));
editor.handle(Input::Char('e'));
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert!(screen_text(&terminal).contains("Explorer"));
editor.handle(Input::Escape);
let docked = editor.file_picker().unwrap();
assert!(!docked.focused);
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
let dock_offsets = viewports.offsets[&a_id];
editor.handle(Input::Ctrl('p'));
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert!(screen_text(&terminal).contains("Commands"));
assert_eq!(terminal.get_cursor_position().unwrap().y, 1);
assert_eq!(viewports.offsets[&a_id], dock_offsets);
editor.handle(Input::Escape);
editor.execute(&format!("edit {}", b.display()));
let b_id = editor.buffer_id();
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert!(editor.file_picker().is_some());
assert_eq!(viewports.offsets[&b_id], (0, 0));
editor.execute(&format!("buffer {a_id}"));
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert_eq!(viewports.offsets[&a_id], dock_offsets);
editor.handle(Input::Char(' '));
editor.handle(Input::Char('o'));
assert_eq!(
editor.file_picker().unwrap().presentation,
FilePresentation::Floating
);
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert!(screen_text(&terminal).contains("Files"));
editor.handle(Input::Ctrl('w'));
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert!(editor.file_picker().is_none());
assert_eq!(viewports.offsets[&b_id], (0, 0));
assert!(screen_text(&terminal).contains("a"));
}
#[test]
fn docked_search_highlight_and_cursor_survive_focus_and_offset_resizes() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace(
"0123456789界e\u{301}z\nsecond".into(),
)])
.unwrap();
editor.handle(Input::Char('/'));
editor.handle(Input::Char('界'));
editor.handle(Input::Enter);
assert!(editor.search_match().is_some());
editor.handle(Input::Char(' '));
editor.handle(Input::Char('e'));
let mut terminal = Terminal::with_options(
ratatui::backend::TestBackend::new(100, 32),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(Rect::new(3, 2, 90, 26)),
},
)
.unwrap();
let (mut top, mut left) = (0, 0);
let mut syntax = SyntaxCache::new();
for (name, side) in [
("left", DockSide::Left),
("right", DockSide::Right),
("top", DockSide::Top),
("bottom", DockSide::Bottom),
] {
editor.set_file_explorer_side(name).unwrap();
for (width, height) in [
(90, 26),
(12, 6),
(8, 2),
(1, 1),
(1, 2),
(0, 1),
(1, 0),
(90, 26),
] {
let area = Rect::new(3, 2, width, height);
terminal.resize(area).unwrap();
let area = terminal.get_frame().area();
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
if width > 0 && height > 0 {
let workspace = Rect::new(
area.x,
area.y,
width,
height - u16::from(height >= 3) - u16::from(height >= 2),
);
let (_, panel) = dock_layout(workspace, side, true);
let cursor = terminal.get_cursor_position().unwrap();
assert!(
panel.unwrap().contains(cursor),
"{side:?} {area:?} {panel:?}: {cursor:?}"
);
}
editor.handle(Input::Escape);
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
if width > 0 && height > 0 {
let cursor = terminal.get_cursor_position().unwrap();
assert!(area.contains(cursor), "{side:?} {area:?}: {cursor:?}");
let workspace = Rect::new(
area.x,
area.y,
width,
height - u16::from(height >= 3) - u16::from(height >= 2),
);
let (editor_area, panel) = dock_layout(workspace, side, false);
assert!(editor_area.contains(cursor), "{side:?}: {cursor:?}");
let buffer = terminal.backend().buffer();
if width == 90 {
let mut yellow = 0;
for y in editor_area.y..editor_area.bottom() {
for x in editor_area.x..editor_area.right() {
yellow += usize::from(buffer[(x, y)].bg == Color::Yellow);
}
}
assert!(yellow > 0);
}
if let Some(panel) = panel {
for y in panel.y..panel.bottom() {
for x in panel.x..panel.right() {
assert_ne!(buffer[(x, y)].bg, Color::Yellow);
}
}
}
assert_eq!(buffer[(0, 0)].symbol(), " ");
}
editor.handle(Input::Char(' '));
editor.handle(Input::Char('e'));
assert!(editor.file_picker().unwrap().focused);
}
}
}
#[test]
fn pending_leader_is_visible_before_long_paths_and_unknown_keys_do_not_edit() {
let directory = Directory::new();
let path = directory.file(&"long-path-".repeat(12), "keep");
let mut editor = Editor::open(Some(path), vec![]).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 6)).unwrap();
let (mut top, mut left) = (0, 0);
let mut syntax = SyntaxCache::new();
editor.handle(Input::Char(' '));
editor.handle(Input::Char('b'));
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
assert!(screen_text(&terminal).contains("leader: Space b"));
editor.handle(Input::Char('x'));
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
assert!(!screen_text(&terminal).contains("leader:"));
assert_eq!(editor.lines(), &["keep"]);
assert_eq!(editor.cursor(), crate::editor::Cursor::default());
assert_eq!(editor.mode(), Mode::Normal);
}
#[test]
fn palette_advertises_effective_leader_sequences_after_remapping() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.set_leader(",").unwrap();
editor.handle(Input::Ctrl('p'));
for ch in "bnext".chars() {
editor.handle(Input::Char(ch));
}
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 10)).unwrap();
let (mut top, mut left) = (0, 0);
let mut syntax = SyntaxCache::new();
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
assert!(screen_text(&terminal).contains(", b n"));
assert!(!screen_text(&terminal).contains("Space b n"));
editor
.bind("normal", ",", Some("core.command".into()))
.unwrap();
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
assert!(screen_text(&terminal).contains("unbound"));
assert!(!screen_text(&terminal).contains(", b n"));
}
fn document_wheel(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent {
MouseEvent {
kind,
column,
row,
modifiers: KeyModifiers::NONE,
}
}
#[test]
fn wheel_scrolls_rendered_document_three_rows_without_changing_any_selection() {
let directory = Directory::new();
let path = directory.file(
"rows.txt",
&(0..12)
.map(|row| format!("abc row-{row:02}"))
.collect::<Vec<_>>()
.join("\n"),
);
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
session.handle(Input::Char('v'));
session.handle(Input::Char('e'));
session.handle(Input::Ctrl('a'));
assert_eq!(session.editor().selection_count(), 12);
let carets = session.editor().carets();
let selections = session.editor().selections();
let cursor = session.editor().cursor();
let revision = session.editor().text_revision();
let text = session.editor().lines().to_vec();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 6)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert!(terminal.backend().cursor_visible());
for (kind, top) in [
(MouseEventKind::ScrollDown, 3),
(MouseEventKind::ScrollDown, 6),
(MouseEventKind::ScrollDown, 8),
(MouseEventKind::ScrollDown, 8),
(MouseEventKind::ScrollUp, 5),
(MouseEventKind::ScrollUp, 2),
(MouseEventKind::ScrollUp, 0),
(MouseEventKind::ScrollUp, 0),
] {
// The gutter belongs to the rendered document hit region too.
assert!(viewports.scroll(&mut session, document_wheel(kind, 0, 0)));
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert!(
screen_text(&terminal)
.lines()
.next()
.unwrap()
.contains(&format!("row-{top:02}"))
);
assert_eq!(
terminal.backend().cursor_visible(),
cursor.row >= top && cursor.row < top + 4
);
assert_eq!(session.editor().cursor(), cursor);
assert_eq!(session.editor().carets(), carets);
assert_eq!(session.editor().selections(), selections);
assert_eq!(session.editor().text_revision(), revision);
assert_eq!(session.editor().lines(), text);
}
assert!(!viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 0, 5)
));
assert!(!viewports.scroll(&mut session, document_wheel(MouseEventKind::Moved, 0, 0)));
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 0, 0)
));
session.handle(Input::Escape);
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert!(
screen_text(&terminal)
.lines()
.next()
.unwrap()
.contains("row-00")
);
assert!(terminal.backend().cursor_visible());
}
#[test]
fn queued_wheel_input_renders_latest_view_without_crossing_key_or_resize_barriers() {
let directory = Directory::new();
let text = (0..24)
.map(|row| format!("row-{row:03}\n"))
.collect::<String>();
let path = directory.file("rows.txt", &text);
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 6)).unwrap();
let mut viewports = BufferViewports::default();
let down = Event::Mouse(document_wheel(MouseEventKind::ScrollDown, 1, 1));
let up = Event::Mouse(document_wheel(MouseEventKind::ScrollUp, 1, 1));
for barrier in [
Event::Key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)),
Event::Resize(40, 12),
] {
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
let mut queued = [
down.clone(),
up.clone(),
down.clone(),
barrier.clone(),
down.clone(),
]
.into_iter();
let (redraw, pending) = viewports
.scroll_ready(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 1, 1),
|| Ok(queued.next()),
)
.unwrap();
assert!(redraw);
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert!(
screen_text(&terminal)
.lines()
.next()
.unwrap()
.contains("row-006")
);
assert_eq!(pending, Some(barrier));
assert_eq!(queued.next(), Some(down.clone()));
assert_eq!(session.editor().cursor(), Cursor::default());
assert_eq!(session.editor().mode(), Mode::Normal);
assert!(!terminal.backend().cursor_visible());
session.handle(Input::Up);
}
assert_eq!(session.editor().lines().join("\n"), text);
}
#[test]
fn no_op_navigation_restores_caret_following_through_core_dispatch() {
let directory = Directory::new();
let path = directory.file("rows.txt", &["row"; 12].join("\n"));
let mut session = EditorSession::open(Some(path.clone()), vec![], None).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 6)).unwrap();
let mut viewports = BufferViewports::default();
for motion in [Input::Char('k'), Input::Up, Input::Home] {
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 2, 0)
));
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert!(!terminal.backend().cursor_visible());
session.handle(motion);
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert_eq!(session.editor().cursor(), Cursor::default());
assert_eq!(viewports.offsets[&session.editor().buffer_id()].0, 0);
assert!(terminal.backend().cursor_visible());
}
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
// A remapped motion and a disabled default demonstrate that intent is
// emitted by the resolved core action, not frontend key spelling.
editor.bind("normal", "x", Some("core.up".into())).unwrap();
editor.bind("normal", "k", None).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 6)).unwrap();
let mut viewports = BufferViewports::default();
for navigation in 0..3 {
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
let buffer_id = editor.buffer_id();
viewports.offsets.get_mut(&buffer_id).unwrap().0 = 3;
viewports.document.manual = Some(ManualViewport {
buffer_id,
revision: editor.text_revision(),
caret_reveal_revision: editor.caret_reveal_revision(),
mode: editor.mode(),
cursor: editor.cursor(),
carets: editor.carets(),
selections: editor.selections(),
});
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert!(!terminal.backend().cursor_visible());
editor.handle(Input::Char('k'));
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert!(!terminal.backend().cursor_visible());
match navigation {
0 => {
editor.handle(Input::Char('x'));
}
1 => editor
.navigate_to(
path.clone(),
crate::plugin::TextPosition { row: 0, column: 0 },
)
.unwrap(),
2 => {
editor.execute(&format!("buffer {buffer_id}"));
}
_ => unreachable!(),
}
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert_eq!(editor.cursor(), Cursor::default());
assert_eq!(viewports.offsets[&buffer_id].0, 0);
assert!(terminal.backend().cursor_visible());
assert!(
screen_text(&terminal)
.lines()
.next()
.unwrap()
.contains("row")
);
}
}
#[test]
fn manual_document_view_survives_idle_resize_and_palette_then_follows_navigation_and_edits() {
let directory = Directory::new();
let path = directory.file(
"rows.txt",
&(0..20)
.map(|row| format!("row-{row:02}"))
.collect::<Vec<_>>()
.join("\n"),
);
let other = directory.file("other.txt", "other");
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
let buffer_id = session.editor().buffer_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(30, 6)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
for _ in 0..2 {
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 4, 0)
));
}
session.poll();
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert!(
screen_text(&terminal)
.lines()
.next()
.unwrap()
.contains("row-06")
);
assert!(!terminal.backend().cursor_visible());
terminal.backend_mut().resize(30, 8);
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert!(
screen_text(&terminal)
.lines()
.next()
.unwrap()
.contains("row-06")
);
session.handle(Input::Ctrl('p'));
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 4, 0)
));
session.handle(Input::Escape);
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert!(
screen_text(&terminal)
.lines()
.next()
.unwrap()
.contains("row-06")
);
assert_eq!(session.editor().cursor(), Cursor::default());
session.handle(Input::Down);
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert!(
screen_text(&terminal)
.lines()
.next()
.unwrap()
.contains("row-01")
);
assert!(terminal.backend().cursor_visible());
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 4, 0)
));
session.handle(Input::Char('i'));
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert!(terminal.backend().cursor_visible());
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 4, 0)
));
session.handle(Input::Char('z'));
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert!(terminal.backend().cursor_visible());
session.handle(Input::Escape);
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 4, 0)
));
session.execute(&format!("edit {}", other.display()));
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert!(screen_text(&terminal).starts_with("1 other"));
session.execute(&format!("buffer {buffer_id}"));
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert!(terminal.backend().cursor_visible());
assert!(
screen_text(&terminal)
.lines()
.next()
.unwrap()
.contains("zrow-01")
);
}
#[test]
fn manual_document_view_hides_horizontal_offscreen_caret_and_clamps_tiny_viewports() {
let directory = Directory::new();
let path = directory.file("long.txt", "abcdefghijk");
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
for _ in 0..8 {
session.handle(Input::Right);
}
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 6)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert!(viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 2, 0)
));
terminal.backend_mut().resize(8, 6);
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert!(!terminal.backend().cursor_visible());
assert!(screen_text(&terminal).starts_with("1 abcdef"));
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 8 });
session.handle(Input::Left);
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert!(terminal.backend().cursor_visible());
assert_eq!(terminal.backend().cursor_position().x, 7);
for (width, height) in [(1, 1), (2, 2), (0, 0)] {
terminal.backend_mut().resize(width, height);
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert_eq!(
viewports.scroll(
&mut session,
document_wheel(MouseEventKind::ScrollDown, 0, 0)
),
width > 0 && height > 0,
);
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert_eq!(viewports.offsets[&session.editor().buffer_id()].0, 0);
}
}
#[test]
fn buffer_switches_restore_independent_vertical_and_horizontal_viewports() {
let directory = Directory::new();
let a = directory.file("a", &vec!["a".repeat(50); 20].join("\n"));
let b = directory.file("b", &vec!["b".repeat(40); 20].join("\n"));
let mut editor = Editor::open(Some(a), vec![]).unwrap();
let a_id = editor.buffer_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(16, 6)).unwrap();
let mut viewports = BufferViewports::default();
for _ in 0..14 {
editor.handle(Input::Down);
}
editor.handle(Input::End);
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert_eq!(viewports.offsets[&a_id], (11, 37));
for _ in 0..2 {
editor.handle(Input::Up);
}
for _ in 0..3 {
editor.handle(Input::Left);
}
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
let a_viewport = viewports.offsets[&a_id];
let a_cursor = terminal.get_cursor_position().unwrap();
assert_eq!(a_viewport, (11, 37));
editor.execute(&format!("edit {}", b.display()));
let b_id = editor.buffer_id();
assert_ne!(a_id, b_id);
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert_eq!(viewports.offsets[&b_id], (0, 0));
for _ in 0..8 {
editor.handle(Input::Down);
}
editor.handle(Input::End);
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
editor.handle(Input::Up);
editor.handle(Input::Left);
editor.handle(Input::Left);
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
let b_viewport = viewports.offsets[&b_id];
let b_cursor = terminal.get_cursor_position().unwrap();
assert_eq!(b_viewport, (5, 27));
editor.execute(&format!("buffer {a_id}"));
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert_eq!(viewports.offsets[&a_id], a_viewport);
assert_eq!(terminal.get_cursor_position().unwrap(), a_cursor);
assert_eq!(
terminal.backend().buffer()[(a_cursor.x, a_cursor.y)].symbol(),
"a"
);
editor.execute("bnext");
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert_eq!(editor.buffer_id(), b_id);
assert_eq!(viewports.offsets[&b_id], b_viewport);
assert_eq!(terminal.get_cursor_position().unwrap(), b_cursor);
}
#[test]
fn closing_inactive_buffer_retires_its_viewport_without_moving_active_buffer() {
let directory = Directory::new();
let a = directory.file("a", &vec!["a".repeat(50); 20].join("\n"));
let b = directory.file("b", &vec!["b".repeat(40); 20].join("\n"));
let c = directory.file("c", "new buffer");
let mut editor = Editor::open(Some(a), vec![]).unwrap();
let a_id = editor.buffer_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(16, 6)).unwrap();
let mut viewports = BufferViewports::default();
for _ in 0..14 {
editor.handle(Input::Down);
}
editor.handle(Input::End);
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
editor.execute(&format!("edit {}", b.display()));
let b_id = editor.buffer_id();
for _ in 0..8 {
editor.handle(Input::Down);
}
editor.handle(Input::End);
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
let b_viewport = viewports.offsets[&b_id];
let b_cursor = terminal.get_cursor_position().unwrap();
assert_eq!(viewports.offsets.len(), 2);
editor.execute(&format!("bdelete {a_id}"));
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert_eq!(editor.buffer_id(), b_id);
assert!(!editor.has_buffer(a_id));
assert!(!viewports.offsets.contains_key(&a_id));
assert_eq!(viewports.offsets.len(), 1);
assert_eq!(viewports.offsets[&b_id], b_viewport);
assert_eq!(terminal.get_cursor_position().unwrap(), b_cursor);
editor.execute(&format!("edit {}", c.display()));
let c_id = editor.buffer_id();
assert!(c_id > b_id);
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert_eq!(viewports.offsets[&c_id], (0, 0));
assert!(!viewports.offsets.contains_key(&a_id));
}
#[test]
fn restoring_buffer_after_narrow_resize_clamps_caret_inside_text_region() {
let directory = Directory::new();
let a = directory.file("a", &vec!["a".repeat(50); 20].join("\n"));
let b = directory.file("b", "other");
let mut editor = Editor::open(Some(a), vec![]).unwrap();
let a_id = editor.buffer_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 8)).unwrap();
let mut viewports = BufferViewports::default();
for _ in 0..14 {
editor.handle(Input::Down);
}
editor.handle(Input::End);
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert_eq!(viewports.offsets[&a_id], (9, 29));
editor.execute(&format!("edit {}", b.display()));
let b_id = editor.buffer_id();
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
terminal.backend_mut().resize(4, 4);
terminal
.resize(ratatui::layout::Rect::new(0, 0, 4, 4))
.unwrap();
editor.execute(&format!("buffer {a_id}"));
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert_eq!(viewports.offsets[&a_id], (13, 49));
assert_eq!(viewports.offsets[&b_id], (0, 0));
assert_eq!(
terminal.get_cursor_position().unwrap(),
ratatui::layout::Position::new(3, 1)
);
for (width, height) in [(1, 1), (24, 8)] {
let area = ratatui::layout::Rect::new(0, 0, width, height);
terminal.backend_mut().resize(width, height);
terminal.resize(area).unwrap();
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
let cursor = terminal.get_cursor_position().unwrap();
assert!(area.contains(cursor), "{area:?}: {cursor:?}");
assert_eq!(
terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(),
"a"
);
assert_eq!(viewports.offsets[&b_id], (0, 0));
}
}
#[test]
fn status_identifies_active_buffer_and_restored_visual_selection_count() {
let directory = Directory::new();
let a = directory.file("a", "word\nword\nword");
let b = directory.file("b", "other");
let mut editor = Editor::open(Some(a), vec![]).unwrap();
let a_id = editor.buffer_id();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(240, 6)).unwrap();
let mut viewports = BufferViewports::default();
editor.handle(Input::Char('v'));
editor.handle(Input::Ctrl('a'));
assert_eq!(editor.selection_count(), 3);
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
assert!(!screen_text(&terminal).contains("[buffer"));
editor.execute(&format!("edit {}", b.display()));
let b_id = editor.buffer_id();
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
let text = screen_text(&terminal);
assert!(
text.contains(&format!("NORMAL [buffer {b_id}/2]")),
"{text}"
);
assert!(!text.contains("3 selections"), "{text}");
editor.execute(&format!("buffer {a_id}"));
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
let text = screen_text(&terminal);
assert!(
text.contains(&format!("VISUAL [buffer {a_id}/2]")),
"{text}"
);
assert!(text.contains("3 selections"), "{text}");
let (top, left) = viewports.offsets[&a_id];
for selection in editor.selections() {
let x = 2 + (selection.start.column - left) as u16;
let y = (selection.start.row - top) as u16;
assert!(matches!(
terminal.backend().buffer()[(x, y)].bg,
Color::Blue | Color::Magenta
));
}
}
#[test]
fn pending_motion_count_is_visible_and_escape_cancels_without_moving() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace(["row"; 12].join("\n"))])
.unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 6)).unwrap();
let (mut top, mut left) = (0, 0);
let mut syntax = SyntaxCache::new();
editor.handle(Input::Char('1'));
editor.handle(Input::Char('0'));
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
assert!(screen_text(&terminal).contains("count: 10"));
editor.handle(Input::Escape);
editor.handle(Input::Down);
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
assert!(!screen_text(&terminal).contains("count:"));
assert_eq!(
terminal.get_cursor_position().unwrap(),
ratatui::layout::Position::new(3, 1)
);
}
#[test]
fn gutter_tracks_scrolled_rows_without_scrolling_with_text() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace(
"0123456789界z\n".repeat(11),
)])
.unwrap();
for _ in 0..10 {
editor.handle(Input::Down);
}
editor.handle(Input::End);
let area = ratatui::layout::Rect::new(3, 2, 8, 5);
let mut terminal = Terminal::with_options(
ratatui::backend::TestBackend::new(20, 10),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(area),
},
)
.unwrap();
let (mut top, mut left) = (0, 0);
let mut syntax = SyntaxCache::new();
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
assert_eq!((top, left), (8, 8));
let buffer = terminal.backend().buffer();
for (y, expected) in [(2, " 2 "), (3, " 1 "), (4, "11 ")] {
let number: String = (3..6).map(|x| buffer[(x, y)].symbol()).collect();
assert_eq!(number, expected);
assert_eq!(buffer[(6, y)].symbol(), "8");
assert_eq!(buffer[(3, y)].bg, Color::Reset);
}
assert!(buffer[(3, 4)].modifier.contains(Modifier::BOLD));
assert!(!buffer[(3, 3)].modifier.contains(Modifier::BOLD));
assert_eq!(
terminal.get_cursor_position().unwrap(),
ratatui::layout::Position::new(10, 4)
);
editor.handle(Input::Down);
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let buffer = terminal.backend().buffer();
let last_number: String = (3..6).map(|x| buffer[(x, 4)].symbol()).collect();
assert_eq!(last_number, "12 ");
assert_eq!(buffer[(6, 4)].symbol(), " ");
assert_eq!(
terminal.get_cursor_position().unwrap(),
ratatui::layout::Position::new(6, 4)
);
editor.handle(Input::Up);
editor.handle(Input::Up);
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let buffer = terminal.backend().buffer();
for (y, expected) in [(2, "10 "), (3, " 1 "), (4, " 2 ")] {
let number: String = (3..6).map(|x| buffer[(x, y)].symbol()).collect();
assert_eq!(number, expected);
}
editor.handle(Input::Ctrl('e'));
assert_eq!(editor.selection_count(), 2);
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let buffer = terminal.backend().buffer();
for (y, expected) in [(2, " 1 "), (3, "11 "), (4, " 1 ")] {
let number: String = (3..6).map(|x| buffer[(x, y)].symbol()).collect();
assert_eq!(number, expected);
}
}
#[test]
fn gutter_resizes_at_digit_boundaries_and_yields_to_narrow_text() {
let mut editor = Editor::open(None, vec![]).unwrap();
let (mut top, mut left) = (0, 0);
let mut syntax = SyntaxCache::new();
for count in [9usize, 10, 100] {
editor
.apply(vec![crate::editor::Effect::Replace(
vec!["x"; count].join("\n"),
)])
.unwrap();
let digits = count.ilog10() as u16 + 1;
let gutter_width = digits + 1;
for width in [12, gutter_width, gutter_width + 1, 1] {
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(width, 4)).unwrap();
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let cursor = terminal.get_cursor_position().unwrap();
let buffer = terminal.backend().buffer();
let text_x = if width > gutter_width {
gutter_width
} else {
0
};
assert_eq!(buffer[(text_x, 0)].symbol(), "x");
assert_eq!(cursor, ratatui::layout::Position::new(text_x, 0));
if text_x > 0 {
let number: String = (0..text_x).map(|x| buffer[(x, 0)].symbol()).collect();
assert_eq!(
number,
format!("{:>digits$} ", 1, digits = usize::from(digits))
);
}
}
}
editor
.apply(vec![crate::editor::Effect::Replace(String::new())])
.unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 5)).unwrap();
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(0, 0)].symbol(), "1");
assert_eq!(buffer[(0, 1)].symbol(), " ");
assert_eq!(buffer[(0, 2)].symbol(), " ");
assert_eq!(
terminal.get_cursor_position().unwrap(),
ratatui::layout::Position::new(2, 0)
);
}
#[test]
fn palette_renders_query_no_matches_and_restores_underlying_prompt() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.handle(Input::Char('/'));
for ch in "saved search".chars() {
editor.handle(Input::Char(ch));
}
editor.handle(Input::Ctrl('p'));
for ch in "no-such-command".chars() {
editor.handle(Input::Char(ch));
}
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap();
let (mut top, mut left) = (0, 0);
let mut syntax = SyntaxCache::new();
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains("> no-such-command"), "{text}");
assert!(text.contains("No matching commands"), "{text}");
assert!(text.contains("SEARCH"), "{text}");
assert_eq!(terminal.get_cursor_position().unwrap().y, 1);
editor.handle(Input::Enter);
assert!(editor.palette().is_some());
editor.handle(Input::Escape);
assert_eq!(editor.mode(), Mode::Search);
assert_eq!(editor.command_line(), "saved search");
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
assert!(screen_text(&terminal).contains("/saved search"));
}
#[test]
fn palette_window_follows_keyboard_selection() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.handle(Input::Ctrl('p'));
for _ in 0..20 {
editor.handle(Input::Ctrl('j'));
}
editor.handle(Input::Ctrl('k'));
editor.handle(Input::Ctrl('n'));
let palette = editor.palette().unwrap();
assert_eq!(palette.selected, 20);
let selected = &palette.entries[palette.selected];
let first_name = palette.entries[0].name.clone();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 10)).unwrap();
let (mut top, mut left) = (0, 0);
let mut syntax = SyntaxCache::new();
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains(&selected.name), "{text}");
assert!(!text.contains(&format!("({first_name})")), "{text}");
let buffer = terminal.backend().buffer();
let selected_y = (2..10).find(|&y| buffer[(1, y)].symbol() == ">").unwrap();
assert_eq!(buffer[(1, selected_y)].bg, Color::Blue);
assert!(buffer[(1, selected_y)].modifier.contains(Modifier::BOLD));
assert_eq!(terminal.get_cursor_position().unwrap().y, 1);
}
#[test]
fn palette_shows_effective_bindings_and_dimmed_mode_ineligible_commands() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.register(crate::editor::Feature {
name: "sample".into(),
required_capabilities: vec![],
commands: vec!["greet".into()],
core: false,
})
.unwrap();
editor.bind("normal", "g", Some("greet".into())).unwrap();
editor.bind("normal", "g", None).unwrap();
editor
.bind("normal", "ctrl+x", Some("greet".into()))
.unwrap();
editor.handle(Input::Ctrl('p'));
for ch in "greet".chars() {
editor.handle(Input::Char(ch));
}
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 10)).unwrap();
let (mut top, mut left) = (0, 0);
let mut syntax = SyntaxCache::new();
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains("sample | Ctrl+x | NORMAL"), "{text}");
assert!(!text.contains("unbound"), "{text}");
editor.handle(Input::Escape);
editor.handle(Input::Char('i'));
editor.handle(Input::Ctrl('p'));
for ch in "greet".chars() {
editor.handle(Input::Char(ch));
}
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains("sample | unbound | NORMAL"), "{text}");
assert!(text.contains("INSERT"), "{text}");
let cell = &terminal.backend().buffer()[(1, 2)];
assert!(cell.modifier.contains(Modifier::DIM));
assert_eq!(cell.bg, Color::Blue);
editor.handle(Input::Enter);
assert!(editor.palette().is_some());
assert_eq!(editor.mode(), Mode::Insert);
}
#[test]
fn palette_query_scrolls_graphemes_tabs_and_wide_cells_on_tiny_offset_areas() {
let editor = Editor::open(None, vec![]).unwrap();
let palette = Palette {
query: "long\t界e\u{301}z".into(),
entries: vec![],
selected: 0,
};
for (width, height) in [(12, 6), (8, 2), (1, 1), (1, 2), (8, 1), (12, 6)] {
let area = ratatui::layout::Rect::new(3, 2, width, height);
let mut terminal = Terminal::with_options(
ratatui::backend::TestBackend::new(24, 12),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(area),
},
)
.unwrap();
terminal
.draw(|frame| {
draw_palette(
frame,
&palette,
Mode::Visual,
editor.selection_navigation_hint(),
)
})
.unwrap();
let cursor = terminal.get_cursor_position().unwrap();
assert!(area.contains(cursor), "{area:?}: {cursor:?}");
let bordered = height >= 4 && width >= 4;
let query_x = area.x + u16::from(bordered);
let query_y = area.y + u16::from(bordered);
let query_width = width - 2 * u16::from(bordered);
assert_eq!(cursor.x, query_x + query_width - 1);
assert_eq!(cursor.y, query_y);
let query = format!("> {}", palette.query);
let offset =
display_column(&query, usize::MAX).saturating_sub(usize::from(query_width) - 1);
let mut expected = ratatui::buffer::Buffer::empty(ratatui::layout::Rect::new(
query_x,
query_y,
query_width,
1,
));
use ratatui::widgets::Widget;
Paragraph::new(visible_line(&query, offset, usize::from(query_width)))
.render(expected.area, &mut expected);
for x in query_x..query_x + query_width {
assert_eq!(
terminal.backend().buffer()[(x, query_y)].symbol(),
expected[(x, query_y)].symbol()
);
}
assert_eq!(terminal.backend().buffer()[(0, 0)].symbol(), " ");
}
for (width, height) in [(0, 1), (1, 0), (0, 0)] {
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| {
draw_palette(
frame,
&palette,
Mode::Normal,
editor.selection_navigation_hint(),
)
})
.unwrap();
}
}
#[test]
fn opening_palette_does_not_scroll_underlying_editor_viewport() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace(
"first\nsecond\n0123456789界z".into(),
)])
.unwrap();
editor.handle(Input::Down);
editor.handle(Input::Down);
editor.handle(Input::End);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 4)).unwrap();
let (mut top, mut left) = (0, 0);
let mut syntax = SyntaxCache::new();
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let viewport = (top, left);
editor.handle(Input::Ctrl('p'));
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
assert_eq!((top, left), viewport);
editor.handle(Input::Ctrl('p'));
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
assert_eq!((top, left), viewport);
let cursor = terminal.get_cursor_position().unwrap();
assert_eq!(
terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(),
"z"
);
}
#[test]
fn layout_keeps_scrolled_content_and_prompts_inside_offset_regions() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace(
"first\nsecond\n0123456789界z".into(),
)])
.unwrap();
editor.handle(Input::Down);
editor.handle(Input::Down);
editor.handle(Input::End);
let (mut top, mut left) = (0, 0);
let mut syntax = SyntaxCache::new();
for prompting in [false, true] {
if prompting {
editor.handle(Input::Char('/'));
for ch in "long query".chars() {
editor.handle(Input::Char(ch));
}
}
for height in [4, 2, 1, 4] {
let area = ratatui::layout::Rect::new(3, 2, 8, height);
let mut terminal = Terminal::with_options(
ratatui::backend::TestBackend::new(20, 10),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(area),
},
)
.unwrap();
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let cursor = terminal.get_cursor_position().unwrap();
assert!(area.contains(cursor), "{area:?}: {cursor:?}");
let buffer = terminal.backend().buffer();
if prompting {
assert_eq!(cursor.y, area.bottom() - 1);
assert_eq!(buffer[(area.x, cursor.y)].symbol(), "g");
} else {
assert_eq!(buffer[(cursor.x, cursor.y)].symbol(), "z");
}
if height >= 3 {
assert_eq!(buffer[(area.x, area.bottom() - 2)].bg, Color::Cyan);
} else {
for y in area.y..area.bottom() {
assert_ne!(buffer[(area.x, y)].bg, Color::Cyan);
}
}
assert_eq!(buffer[(0, 0)].symbol(), " ");
}
}
}
#[test]
fn tabs_and_wide_graphemes_scroll_in_terminal_cells() {
let text = "a\t界e\u{301}z";
assert_eq!(display_column(text, 4), 7);
assert_eq!(visible_line(text, 2, 5), " 界e\u{301}");
assert_eq!(visible_line(text, 5, 2), " e\u{301}");
}
#[test]
fn selection_set_renders_all_ranges_and_counts_offscreen_targets() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace(
"界\n界\n界\n界\n界".into(),
)])
.unwrap();
editor.handle(Input::Home);
editor.handle(Input::Char('v'));
editor.handle(Input::Ctrl('a'));
assert_eq!(editor.selection_count(), 5);
assert_eq!(editor.selections().len(), 5);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(48, 4)).unwrap();
let (mut top, mut left) = (0, 0);
let mut syntax = SyntaxCache::new();
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let buffer = terminal.backend().buffer();
for selection in editor.selections() {
if selection.start.row >= top && selection.start.row < top + 2 {
let y = (selection.start.row - top) as u16;
assert!(matches!(buffer[(2, y)].bg, Color::Blue | Color::Magenta));
}
}
let status: String = (0..48).map(|x| buffer[(x, 2)].symbol()).collect();
assert!(status.contains("5 selections"), "{status}");
assert_eq!(
terminal.get_cursor_position().unwrap(),
ratatui::layout::Position::new(2, (editor.cursor().row - top) as u16)
);
}
#[test]
fn secondary_carets_render_on_empty_lines_and_at_line_end() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace("a\n\n界".into())])
.unwrap();
editor.handle(Input::Home);
editor.handle(Input::Ctrl('e'));
editor.handle(Input::Ctrl('e'));
assert_eq!(editor.selection_count(), 3);
editor.handle(Input::Char('A'));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(48, 5)).unwrap();
let (mut top, mut left) = (0, 0);
let mut syntax = SyntaxCache::new();
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let buffer = terminal.backend().buffer();
for caret in editor.carets() {
let x = 2 + display_column(&editor.lines()[caret.row], caret.column) as u16;
assert_eq!(buffer[(x, caret.row as u16)].symbol(), " ");
if caret != editor.cursor() {
assert_eq!(buffer[(x, caret.row as u16)].bg, Color::Magenta);
}
}
assert_eq!(
terminal.get_cursor_position().unwrap(),
ratatui::layout::Position::new(
2 + display_column(&editor.lines()[editor.cursor().row], editor.cursor().column)
as u16,
editor.cursor().row as u16
)
);
}
#[test]
fn linewise_selection_highlights_copied_text_and_newlines_without_filling_rows() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace(
"\t界e\u{301} \n\n".into(),
)])
.unwrap();
editor.handle(Input::Char('V'));
editor.handle(Input::Down);
editor.handle(Input::Down);
let before = (editor.lines().to_vec(), editor.cursor(), editor.carets());
for width in [16, 3, 1] {
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 6)).unwrap();
let (mut top, mut left) = (0, 0);
let frame = terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut SyntaxCache::new(),
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let buffer = frame.buffer;
if width == 16 {
let status: String = (0..width).map(|x| buffer[(x, 4)].symbol()).collect();
assert!(status.contains("VISUAL LINE"), "{status}");
}
let gutter = if width > 2 { 2 } else { 0 };
for row in 0..3 {
for x in 0..gutter {
assert_ne!(buffer[(x, row)].bg, Color::Blue);
}
let selected_width = if row == 0 { 10 } else { 1 };
for x in gutter..width {
if x - gutter < selected_width {
assert_eq!(
buffer[(x, row)].bg,
Color::Blue,
"width {width}, row {row}, x {x}"
);
} else {
assert_ne!(buffer[(x, row)].bg, Color::Blue);
}
}
}
for x in 0..width {
assert_ne!(buffer[(x, 3)].bg, Color::Blue);
}
}
assert_eq!(
before,
(editor.lines().to_vec(), editor.cursor(), editor.carets())
);
editor.handle(Input::Up);
editor.handle(Input::Up);
editor.handle(Input::End);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(5, 3)).unwrap();
let (mut top, mut left) = (0, 0);
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut SyntaxCache::new(),
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
assert!(left > 0);
let buffer = terminal.backend().buffer();
for x in 0..2 {
assert_ne!(buffer[(x, 0)].bg, Color::Blue);
}
for x in 2..5 {
assert_eq!(buffer[(x, 0)].bg, Color::Blue);
}
}
#[test]
fn visual_selection_highlights_graphemes_newlines_and_clipped_ranges_over_search() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace(
"a\t界e\u{301}z\n\nxy".into(),
)])
.unwrap();
editor.handle(Input::Char('/'));
for ch in "界e\u{301}".chars() {
editor.handle(Input::Char(ch));
}
editor.handle(Input::Enter);
editor.handle(Input::Char('v'));
editor.handle(Input::Left);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(12, 6)).unwrap();
let (mut top, mut left) = (0, 0);
let mut syntax = SyntaxCache::new();
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let buffer = terminal.backend().buffer();
for x in [1, 2, 3, 4] {
assert_eq!(buffer[(2 + x, 0)].bg, Color::Blue);
}
assert_eq!(buffer[(6, 0)].symbol(), "界");
assert_eq!(buffer[(8, 0)].bg, Color::Yellow);
editor.handle(Input::Down);
editor.handle(Input::Down);
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(10, 0)].symbol(), " ");
assert_eq!(buffer[(10, 0)].bg, Color::Blue);
assert_eq!(buffer[(2, 1)].bg, Color::Blue);
assert_eq!(buffer[(2, 2)].bg, Color::Blue);
assert_ne!(buffer[(3, 2)].bg, Color::Blue);
editor.handle(Input::Up);
editor.handle(Input::Up);
editor.handle(Input::End);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(7, 6)).unwrap();
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let buffer = terminal.backend().buffer();
assert_eq!(left, 4);
assert_eq!(buffer[(2, 0)].symbol(), "界");
assert_eq!(buffer[(4, 0)].symbol(), "e\u{301}");
assert_eq!(buffer[(6, 0)].symbol(), " ");
for x in [0, 2, 3, 4] {
assert_eq!(buffer[(2 + x, 0)].bg, Color::Blue);
}
assert_ne!(buffer[(2, 1)].bg, Color::Blue);
editor.handle(Input::Escape);
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(2, 0)].bg, Color::Yellow);
assert_eq!(buffer[(4, 0)].bg, Color::Yellow);
assert_ne!(buffer[(5, 0)].bg, Color::Blue);
assert_ne!(buffer[(6, 0)].bg, Color::Blue);
}
#[test]
fn active_search_highlight_uses_display_cells_and_clips_with_horizontal_scroll() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace(
"a\t界e\u{301}zz".into(),
)])
.unwrap();
editor.handle(Input::Char('/'));
for ch in "\t界e\u{301}".chars() {
editor.handle(Input::Char(ch));
}
editor.handle(Input::Enter);
editor.handle(Input::End);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(7, 5)).unwrap();
let (mut top, mut left) = (0, 0);
let mut syntax = SyntaxCache::new();
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let buffer = terminal.backend().buffer();
assert_eq!(left, 4);
assert_eq!(buffer[(2, 0)].symbol(), "界");
assert_eq!(buffer[(4, 0)].symbol(), "e\u{301}");
for x in [0, 2] {
assert_eq!(buffer[(2 + x, 0)].bg, Color::Yellow);
}
for x in 3..5 {
assert_ne!(buffer[(2 + x, 0)].bg, Color::Yellow);
}
editor.handle(Input::Char('/'));
for ch in "界界".chars() {
editor.handle(Input::Char(ch));
}
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(1, 4)].symbol(), "界");
assert_eq!(buffer[(3, 4)].symbol(), "界");
editor.handle(Input::Backspace);
editor.handle(Input::Backspace);
editor.handle(Input::Char('\t'));
terminal
.draw(|frame| {
draw(
frame,
&editor,
&mut top,
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
)
})
.unwrap();
assert_eq!(
terminal.get_cursor_position().unwrap(),
ratatui::layout::Position::new(4, 4)
);
}
#[test]
fn terminal_can_present_previous_frame_while_next_frame_is_computed() {
use std::{cell::Cell, rc::Rc};
struct Output {
bytes: Vec<u8>,
synchronized: Rc<Cell<bool>>,
}
impl io::Write for Output {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.bytes.extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
for command in self.bytes.split(|byte| *byte == b'\x1b') {
if command.starts_with(b"[?2026h") {
self.synchronized.set(true);
} else if command.starts_with(b"[?2026l") {
self.synchronized.set(false);
}
}
self.bytes.clear();
Ok(())
}
}
let synchronized = Rc::new(Cell::new(false));
let mut output = Output {
bytes: Vec::new(),
synchronized: Rc::clone(&synchronized),
};
let mut terminal = Terminal::with_options(
CrosstermBackend::new(Vec::new()),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(Rect::new(0, 0, 20, 5)),
},
)
.unwrap();
for text in ["first frame", "second frame", "third frame"] {
draw_frame(&mut terminal, &mut output, |frame| {
assert!(
!synchronized.get(),
"terminal presentation suspended during frame computation"
);
frame.render_widget(Paragraph::new(text), frame.area());
})
.unwrap();
assert!(!synchronized.get());
}
}
#[test]
fn scrolling_never_paints_with_an_exposed_cursor_even_without_synchronized_updates() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace(
(0..40).map(|row| format!("row {row}\n")).collect(),
)])
.unwrap();
let mut output = Vec::new();
let mut terminal = Terminal::with_options(
CrosstermBackend::new(Vec::new()),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(Rect::new(0, 0, 20, 5)),
},
)
.unwrap();
let mut viewports = BufferViewports::default();
for key in std::iter::repeat_n('j', 20).chain(std::iter::repeat_n('k', 20)) {
editor.handle(Input::Char(key));
draw_frame(&mut terminal, &mut output, |frame| {
viewports.draw(frame, &editor)
})
.unwrap();
}
drop(terminal);
let output = std::str::from_utf8(&output).unwrap();
for supports_sync in [false, true] {
let (mut visible, mut synchronized) = (true, false);
let mut screen = [[b' '; 20]; 5];
let (mut x, mut y) = (0, 0);
for escape in output.split('\x1b').skip(1) {
let rest = escape.strip_prefix('[').unwrap();
let end = rest.find(|ch| ('@'..='~').contains(&ch)).unwrap();
let (command, text) = rest.split_at(end + 1);
match command {
"?25h" => visible = true,
"?25l" => visible = false,
"?2026h" => synchronized = supports_sync,
"?2026l" => synchronized = false,
_ if command.ends_with('H') => {
let (row, column) = command[..command.len() - 1].split_once(';').unwrap();
y = row.parse::<usize>().unwrap() - 1;
x = column.parse::<usize>().unwrap() - 1;
}
_ => {}
}
for byte in text.bytes().filter(|&byte| byte >= b' ' && byte != 127) {
assert!(
if supports_sync {
synchronized
} else {
!visible
},
"partial frame exposed during redraw (sync={supports_sync}): {text:?}",
);
if y < screen.len() && x < screen[y].len() {
screen[y][x] = byte;
}
x += 1;
}
}
assert!(visible);
assert!(!synchronized);
for (row, expected) in [(0, "row 0"), (1, "row 1"), (2, "row 2")] {
let line = std::str::from_utf8(&screen[row]).unwrap().trim_end();
assert!(line.ends_with(expected), "{line:?}");
}
assert_eq!(screen[y][x], b'r', "caret was not restored to the source");
}
}
#[cfg(unix)]
#[test]
fn terminal_session_scopes_the_callers_panic_hook() {
use std::io::Write;
const CHILD: &str = "VAMP_TERMINAL_HOOK_PROBE";
if let Ok(mode) = std::env::var(CHILD) {
std::panic::set_hook(Box::new(|info| {
assert!(!crossterm::terminal::is_raw_mode_enabled().unwrap());
println!("CALLER_HOOK");
eprintln!("{info}");
}));
if mode == "entry" {
let mut editor = EditorSession::open(None, vec![], None).unwrap();
assert!(run(&mut editor).is_err());
} else {
let result = panic::catch_unwind(AssertUnwindSafe(|| {
TerminalSession::run(|| {
assert!(crossterm::terminal::is_raw_mode_enabled().unwrap());
match mode.as_str() {
"normal" => Ok(()),
"error" => Err(io::Error::from(io::ErrorKind::BrokenPipe)),
"panic" => {
let mut terminal = Terminal::with_options(
CrosstermBackend::new(Vec::new()),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(Rect::new(0, 0, 20, 5)),
},
)?;
draw_frame(&mut terminal, &mut io::stdout(), |_| {
panic::panic_any(73_u64)
})
}
"keys" => {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.handle(Input::Ctrl('p'));
let original = editor.lines().to_vec();
println!("READING_KEYS");
io::stdout().flush()?;
for (index, expected) in [
Input::Ctrl('j'),
Input::Ctrl('k'),
Input::Ctrl('j'),
Input::Ctrl('k'),
Input::Ctrl('h'),
Input::Ctrl('l'),
Input::Backspace,
Input::Backspace,
Input::Enter,
Input::Enter,
]
.into_iter()
.enumerate()
{
let Event::Key(key) = event::read()? else {
panic!("expected a keyboard event");
};
let input = translate(key).unwrap();
assert_eq!(input, expected);
if index == 4 {
for ch in "ab".chars() {
editor.handle(Input::Char(ch));
}
} else if index == 8 {
for ch in "q!".chars() {
editor.handle(Input::Char(ch));
}
}
editor.handle(input);
match index {
0 | 2 => assert_eq!(editor.palette().unwrap().selected, 1),
1 | 3 => assert_eq!(editor.palette().unwrap().selected, 0),
4 | 5 => assert_eq!(editor.palette().unwrap().query, "ab"),
6 => assert_eq!(editor.palette().unwrap().query, "a"),
7 => assert_eq!(editor.palette().unwrap().query, ""),
8 => {
assert!(editor.palette().is_none());
assert_eq!(editor.mode(), Mode::Command);
assert_eq!(editor.command_line(), "q! ");
assert!(!editor.should_quit());
}
9 => assert!(editor.should_quit()),
_ => unreachable!(),
}
assert_eq!(editor.lines(), original);
}
Ok(())
}
_ => unreachable!(),
}
})
}));
match mode.as_str() {
"normal" | "keys" => result.unwrap().unwrap(),
"error" => assert_eq!(
result.unwrap().unwrap_err().kind(),
io::ErrorKind::BrokenPipe
),
"panic" => assert_eq!(result.unwrap_err().downcast_ref::<u64>(), Some(&73)),
_ => unreachable!(),
}
}
assert!(!crossterm::terminal::is_raw_mode_enabled().unwrap());
println!("AFTER_TERMINAL_RETURN");
let _ = panic::catch_unwind(|| panic!("outside the terminal session"));
return;
}
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};
unsafe extern "C" {
fn setsid() -> i32;
}
let mut command = Command::new(std::env::current_exe().unwrap());
command
.args([
"--exact",
"tui::tests::terminal_session_scopes_the_callers_panic_hook",
"--nocapture",
"--color",
"never",
])
.env(CHILD, "entry")
.stdin(Stdio::null());
// SAFETY: setsid is async-signal-safe and detaches the child from any
// controlling terminal without touching inherited Rust state.
unsafe {
command.pre_exec(|| {
if setsid() == -1 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
});
}
let check = |output: std::process::Output, mode: &str| {
assert!(output.status.success(), "{mode}: {output:?}");
let stdout = String::from_utf8(output.stdout).unwrap();
let (before, after) = stdout.split_once("AFTER_TERMINAL_RETURN").unwrap();
let entered = usize::from(mode != "entry");
assert_eq!(
before.matches("\x1b[?1049h").count(),
entered,
"{mode}: {before:?}"
);
assert_eq!(
before.matches("\x1b[?1049l").count(),
entered,
"{mode}: {before:?}"
);
for protocol in [1000, 1002, 1003, 1015, 1006] {
assert_eq!(
before.matches(&format!("\x1b[?{protocol}h")).count(),
entered,
"{mode}: mouse capture was not enabled: {before:?}",
);
assert_eq!(
before.matches(&format!("\x1b[?{protocol}l")).count(),
entered,
"{mode}: mouse capture was not restored: {before:?}",
);
}
if mode == "panic" {
let end = before.find("\x1b[?2026l").unwrap();
assert!(end < before.find("CALLER_HOOK").unwrap());
assert!(
before.find("\x1b[?1049l").unwrap() < before.find("CALLER_HOOK").unwrap(),
"{before:?}",
);
assert!(
before.find("\x1b[?1006l").unwrap() < before.find("CALLER_HOOK").unwrap(),
"{before:?}",
);
}
assert!(after.contains("CALLER_HOOK"), "{after:?}");
assert!(
!after.contains('\u{1b}'),
"terminal hook outlived its session: {after:?}"
);
};
check(command.output().unwrap(), "entry");
// Ruby's standard-library PTY gives each positive-entry scenario a real
// terminal, keeping process-global hooks isolated from the test runner.
for mode in ["normal", "error", "panic", "keys"] {
let output =
Command::new(std::env::var_os("VAMP_RUBY").unwrap_or_else(|| "ruby".into()))
.args([
"-rpty",
"-rtimeout",
"-e",
r#"
PTY.spawn(*ARGV) do |output, input, pid|
sent_keys = false
captured = +""
begin
Timeout.timeout(10) do
loop do
chunk = output.readpartial(4096)
STDOUT.write(chunk)
captured << chunk
if !sent_keys && captured.include?("READING_KEYS")
input.write("\e[106;5u\e[107;5u\n\v\b\f\x7f\x7f\e[13u\r")
input.flush
sent_keys = true
end
end
end
rescue EOFError, Errno::EIO
rescue Timeout::Error
Process.kill("TERM", pid)
Process.wait(pid)
abort "terminal lifecycle probe timed out"
ensure
input.close
end
Process.wait(pid)
exit($?.exitstatus || 1)
end
"#,
"--",
])
.arg(std::env::current_exe().unwrap())
.args([
"--exact",
"tui::tests::terminal_session_scopes_the_callers_panic_hook",
"--nocapture",
"--color",
"never",
])
.env(CHILD, mode)
.output()
.unwrap();
check(output, mode);
}
}
}