2412 lines
93 KiB
Rust
2412 lines
93 KiB
Rust
use std::{
|
|
collections::BTreeMap,
|
|
fmt::Write as _,
|
|
io::{self, Stdout},
|
|
panic::{self, AssertUnwindSafe, PanicHookInfo},
|
|
sync::{Arc, Mutex},
|
|
};
|
|
|
|
use crossterm::{
|
|
cursor::Show,
|
|
event::{
|
|
self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent, KeyEventKind,
|
|
KeyModifiers,
|
|
},
|
|
execute,
|
|
terminal::{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::{Editor, Input, Mode, Palette},
|
|
files::{DockSide, FilePicker, FilePresentation},
|
|
session::EditorSession,
|
|
};
|
|
|
|
pub const CAPABILITIES: &[&str] = &["text", "styled_text"];
|
|
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(),
|
|
DisableBracketedPaste,
|
|
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)?;
|
|
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 BufferViewports {
|
|
offsets: BTreeMap<usize, (usize, usize)>,
|
|
}
|
|
|
|
impl BufferViewports {
|
|
fn draw(&mut self, frame: &mut Frame, editor: &Editor) {
|
|
self.offsets.retain(|id, _| editor.has_buffer(*id));
|
|
let (top, left) = self.offsets.entry(editor.buffer_id()).or_default();
|
|
draw(frame, editor, top, left);
|
|
}
|
|
}
|
|
|
|
/// 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 terminal: Terminal<CrosstermBackend<Stdout>> =
|
|
Terminal::new(CrosstermBackend::new(io::stdout()))?;
|
|
let mut viewports = BufferViewports::default();
|
|
while !session.editor().should_quit() {
|
|
terminal.draw(|frame| viewports.draw(frame, session.editor()))?;
|
|
match event::read()? {
|
|
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
|
if let Some(input) = translate(key) {
|
|
session.handle(input);
|
|
}
|
|
}
|
|
Event::Paste(text) => session.handle(Input::Paste(text)),
|
|
_ => {}
|
|
}
|
|
}
|
|
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 draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
|
|
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();
|
|
if height > 0 && width > 0 {
|
|
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 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);
|
|
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);
|
|
}
|
|
}
|
|
for selection in editor.selections() {
|
|
let first = selection.start.row.max(*top);
|
|
let last = selection.end.row.min(top.saturating_add(height - 1));
|
|
let style = Style::default().fg(Color::White).bg(Color::Blue);
|
|
for row in first..=last {
|
|
let line = &editor.lines()[row];
|
|
let start = if row == selection.start.row {
|
|
display_column(line, selection.start.column)
|
|
} else {
|
|
0
|
|
};
|
|
let end = if row == selection.end.row {
|
|
display_column(line, selection.end.column)
|
|
} else {
|
|
display_column(line, usize::MAX) + 1
|
|
};
|
|
let start = start.max(*left);
|
|
let end = end.min(left.saturating_add(width));
|
|
let y = viewport.y + (row - *top) as u16;
|
|
let buffer = frame.buffer_mut();
|
|
for column in start..end {
|
|
buffer[(viewport.x + (column - *left) as u16, y)].set_style(style);
|
|
}
|
|
}
|
|
}
|
|
for caret in editor.carets().into_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 = format!(" {}", editor.mode());
|
|
if let Some(sequence) = editor.leader_pending() {
|
|
write!(status, " leader: {sequence}").unwrap();
|
|
}
|
|
if editor.buffer_count() > 1 {
|
|
write!(
|
|
status,
|
|
" [buffer {}/{}]",
|
|
editor.buffer_id(),
|
|
editor.buffer_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();
|
|
}
|
|
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,
|
|
);
|
|
frame.set_cursor_position((
|
|
prompt_area.x + (command_width - offset) as u16,
|
|
prompt_area.y,
|
|
));
|
|
} else {
|
|
frame.render_widget(Paragraph::new(editor.message()), prompt_area);
|
|
if height > 0 && width > 0 {
|
|
let column = display_column(&editor.lines()[cursor.row], cursor.column);
|
|
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 [_, column, _] = Layout::horizontal([
|
|
Constraint::Fill(1),
|
|
Constraint::Length(area.width.min(84)),
|
|
Constraint::Fill(1),
|
|
])
|
|
.areas(area);
|
|
let [_, panel, _] = Layout::vertical([
|
|
Constraint::Fill(1),
|
|
Constraint::Length(area.height.min(18)),
|
|
Constraint::Fill(1),
|
|
])
|
|
.areas(column);
|
|
frame.render_widget(Clear, panel);
|
|
draw_file_list(frame, picker, panel, editor.selection_navigation_hint());
|
|
} else if let Some(panel) = dock {
|
|
draw_file_list(frame, picker, panel, editor.selection_navigation_hint());
|
|
}
|
|
}
|
|
if let Some(palette) = editor.palette() {
|
|
draw_palette(
|
|
frame,
|
|
palette,
|
|
editor.mode(),
|
|
editor.selection_navigation_hint(),
|
|
);
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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 title = match picker.presentation {
|
|
FilePresentation::Floating => " Files ",
|
|
FilePresentation::Docked => " Explorer ",
|
|
};
|
|
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 query = format!("> {}", display_text(&picker.query));
|
|
let query_width = display_column(&query, usize::MAX);
|
|
let width = usize::from(query_area.width);
|
|
let offset = 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 {
|
|
frame.set_cursor_position((query_area.x + (query_width - offset) 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 first = picker.selected.saturating_sub(count - 1);
|
|
let lines: Vec<Line> = entries
|
|
.enumerate()
|
|
.skip(first)
|
|
.take(count)
|
|
.map(|(index, entry)| {
|
|
let selected = index == picker.selected;
|
|
let style = if selected {
|
|
Style::default()
|
|
.bg(if picker.focused {
|
|
Color::Blue
|
|
} else {
|
|
Color::DarkGray
|
|
})
|
|
.add_modifier(Modifier::BOLD)
|
|
} else {
|
|
Style::default()
|
|
};
|
|
Line::styled(
|
|
format!("{} {}", if selected { ">" } else { " " }, entry.label),
|
|
style,
|
|
)
|
|
})
|
|
.collect();
|
|
frame.render_widget(Paragraph::new(lines), results_area);
|
|
}
|
|
let help = if picker.focused {
|
|
let controls = match picker.presentation {
|
|
FilePresentation::Floating => "Enter · Esc/^W close · ^R scan",
|
|
FilePresentation::Docked => "Enter · Esc/Tab edit · ^W · ^R",
|
|
};
|
|
format!("{navigation_hint} · {controls}")
|
|
} else {
|
|
"Editor focus · invoke explorer to search".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,
|
|
);
|
|
}
|
|
|
|
#[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::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 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::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 file_list_window_follows_control_j_k_selection() {
|
|
let mut editor = Editor::open(None, vec![]).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.clone();
|
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 9)).unwrap();
|
|
terminal
|
|
.draw(|frame| {
|
|
draw_file_list(
|
|
frame,
|
|
picker,
|
|
frame.area(),
|
|
editor.selection_navigation_hint(),
|
|
)
|
|
})
|
|
.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();
|
|
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);
|
|
for key in ['o', 'e'] {
|
|
editor.handle(Input::Char(' '));
|
|
editor.handle(Input::Char(key));
|
|
terminal
|
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
|
.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))
|
|
.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).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.clone();
|
|
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()))
|
|
.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).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()))
|
|
.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());
|
|
})
|
|
.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()))
|
|
.unwrap();
|
|
assert!(screen_text(&terminal).contains("Files"));
|
|
assert!(screen_text(&terminal).contains("Esc/^W"));
|
|
assert_eq!(terminal.backend().buffer()[(0, 0)].fg, Color::Cyan);
|
|
}
|
|
|
|
#[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).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())
|
|
})
|
|
.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(),
|
|
)
|
|
})
|
|
.unwrap();
|
|
assert!(
|
|
dock_layout(Rect::new(3, 2, width, height), DockSide::Right, true)
|
|
.1
|
|
.is_none()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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);
|
|
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))
|
|
.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))
|
|
.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);
|
|
terminal
|
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
|
.unwrap();
|
|
let before = (top, left);
|
|
editor.handle(Input::Char(' '));
|
|
editor.handle(Input::Char('o'));
|
|
terminal
|
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
|
.unwrap();
|
|
let panel = Rect::new(area.x + 13, area.y + 4, 84, 18);
|
|
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 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);
|
|
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);
|
|
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))
|
|
.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))
|
|
.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);
|
|
editor.handle(Input::Char(' '));
|
|
editor.handle(Input::Char('b'));
|
|
terminal
|
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
|
.unwrap();
|
|
assert!(screen_text(&terminal).contains("leader: Space b"));
|
|
editor.handle(Input::Char('x'));
|
|
terminal
|
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
|
.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);
|
|
terminal
|
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
|
.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))
|
|
.unwrap();
|
|
assert!(screen_text(&terminal).contains("unbound"));
|
|
assert!(!screen_text(&terminal).contains(", b n"));
|
|
}
|
|
|
|
#[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);
|
|
editor.handle(Input::Char('1'));
|
|
editor.handle(Input::Char('0'));
|
|
terminal
|
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
|
.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))
|
|
.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);
|
|
terminal
|
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
|
.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))
|
|
.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))
|
|
.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))
|
|
.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);
|
|
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))
|
|
.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))
|
|
.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);
|
|
terminal
|
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
|
.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))
|
|
.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);
|
|
terminal
|
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
|
.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);
|
|
terminal
|
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
|
.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))
|
|
.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);
|
|
terminal
|
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
|
.unwrap();
|
|
let viewport = (top, left);
|
|
editor.handle(Input::Ctrl('p'));
|
|
terminal
|
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
|
.unwrap();
|
|
assert_eq!((top, left), viewport);
|
|
editor.handle(Input::Ctrl('p'));
|
|
terminal
|
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
|
.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);
|
|
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))
|
|
.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);
|
|
terminal
|
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
|
.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);
|
|
terminal
|
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
|
.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 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);
|
|
terminal
|
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
|
.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))
|
|
.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))
|
|
.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))
|
|
.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);
|
|
terminal
|
|
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
|
.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))
|
|
.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))
|
|
.unwrap();
|
|
assert_eq!(
|
|
terminal.get_cursor_position().unwrap(),
|
|
ratatui::layout::Position::new(4, 4)
|
|
);
|
|
}
|
|
|
|
#[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" => 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::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 "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 => {
|
|
assert!(editor.palette().is_none());
|
|
assert_eq!(editor.mode(), Mode::Command);
|
|
assert_eq!(editor.command_line(), "q! ");
|
|
assert!(!editor.should_quit());
|
|
}
|
|
5 => 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:?}"
|
|
);
|
|
if mode == "panic" {
|
|
assert!(
|
|
before.find("\x1b[?1049l").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\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);
|
|
}
|
|
}
|
|
}
|