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