1015 lines
38 KiB
Rust
1015 lines
38 KiB
Rust
use std::{
|
|
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},
|
|
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},
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// 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 top, mut left) = (0, 0);
|
|
while !session.editor().should_quit() {
|
|
terminal.draw(|frame| draw(frame, session.editor(), &mut top, &mut left))?;
|
|
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),
|
|
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 [viewport, status_area, prompt_area] = Layout::vertical([
|
|
Constraint::Fill(1),
|
|
Constraint::Length(u16::from(area.height >= 3)),
|
|
Constraint::Length(u16::from(area.height >= 2 || prompting)),
|
|
])
|
|
.areas(area);
|
|
let height = usize::from(viewport.height);
|
|
let width = usize::from(viewport.width);
|
|
let cursor = editor.cursor();
|
|
if height > 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;
|
|
}
|
|
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 status = format!(
|
|
" {} {}{} {}:{}{}",
|
|
editor.mode(),
|
|
path,
|
|
if editor.dirty() { " [+]" } else { "" },
|
|
cursor.row + 1,
|
|
cursor.column + 1,
|
|
if editor.selection_count() > 1 {
|
|
format!(" {} selections", editor.selection_count())
|
|
} else {
|
|
String::new()
|
|
}
|
|
);
|
|
frame.render_widget(
|
|
Paragraph::new(status).style(Style::default().fg(Color::Black).bg(Color::Cyan)),
|
|
status_area,
|
|
);
|
|
if prompting && prompt_area.height > 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 - 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 {
|
|
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(palette) = editor.palette() {
|
|
draw_palette(frame, palette, editor.mode());
|
|
}
|
|
}
|
|
|
|
fn draw_palette(frame: &mut Frame, palette: &Palette, mode: Mode) {
|
|
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} · ↑↓/Ctrl+k/n select · Enter accept · Esc/Ctrl+p cancel"
|
|
)),
|
|
help_area,
|
|
);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
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 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::Down);
|
|
}
|
|
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 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))
|
|
.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))
|
|
.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[(0, 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(0, (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 = 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(
|
|
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(10, 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[(x, 0)].bg, Color::Blue);
|
|
}
|
|
assert_eq!(buffer[(4, 0)].symbol(), "界");
|
|
assert_eq!(buffer[(6, 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[(8, 0)].symbol(), " ");
|
|
assert_eq!(buffer[(8, 0)].bg, Color::Blue);
|
|
assert_eq!(buffer[(0, 1)].bg, Color::Blue);
|
|
assert_eq!(buffer[(0, 2)].bg, Color::Blue);
|
|
assert_ne!(buffer[(1, 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(5, 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[(0, 0)].symbol(), "界");
|
|
assert_eq!(buffer[(2, 0)].symbol(), "e\u{301}");
|
|
assert_eq!(buffer[(4, 0)].symbol(), " ");
|
|
for x in [0, 2, 3, 4] {
|
|
assert_eq!(buffer[(x, 0)].bg, Color::Blue);
|
|
}
|
|
assert_ne!(buffer[(0, 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[(0, 0)].bg, Color::Yellow);
|
|
assert_eq!(buffer[(2, 0)].bg, Color::Yellow);
|
|
assert_ne!(buffer[(3, 0)].bg, Color::Blue);
|
|
assert_ne!(buffer[(4, 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(5, 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[(0, 0)].symbol(), "界");
|
|
assert_eq!(buffer[(2, 0)].symbol(), "e\u{301}");
|
|
for x in [0, 2] {
|
|
assert_eq!(buffer[(x, 0)].bg, Color::Yellow);
|
|
}
|
|
for x in 3..5 {
|
|
assert_ne!(buffer[(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[(0, 4)].symbol(), "界");
|
|
assert_eq!(buffer[(2, 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() {
|
|
const CHILD: &str = "VAMP_TERMINAL_HOOK_PROBE";
|
|
if let Ok(mode) = std::env::var(CHILD) {
|
|
std::panic::set_hook(Box::new(|_| {
|
|
assert!(!crossterm::terminal::is_raw_mode_enabled().unwrap());
|
|
println!("CALLER_HOOK");
|
|
}));
|
|
if mode == "entry" {
|
|
let mut editor = EditorSession::open(None, vec![], None).unwrap();
|
|
assert!(run(&mut editor).is_err());
|
|
} else {
|
|
let result = panic::catch_unwind(|| {
|
|
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),
|
|
_ => unreachable!(),
|
|
}
|
|
})
|
|
});
|
|
match mode.as_str() {
|
|
"normal" => 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| {
|
|
assert!(output.status.success(), "{output:?}");
|
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
|
let after = stdout.split_once("AFTER_TERMINAL_RETURN").unwrap().1;
|
|
assert!(after.contains("CALLER_HOOK"), "{after:?}");
|
|
assert!(
|
|
!after.contains('\u{1b}'),
|
|
"terminal hook outlived its session: {after:?}"
|
|
);
|
|
};
|
|
check(command.output().unwrap());
|
|
|
|
// 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"] {
|
|
let output =
|
|
Command::new(std::env::var_os("VAMP_RUBY").unwrap_or_else(|| "ruby".into()))
|
|
.args([
|
|
"-rpty",
|
|
"-e",
|
|
r#"
|
|
PTY.spawn(*ARGV) do |output, input, pid|
|
|
input.close
|
|
begin
|
|
loop { STDOUT.write(output.readpartial(4096)) }
|
|
rescue EOFError, Errno::EIO
|
|
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);
|
|
}
|
|
}
|
|
}
|