9376 lines
355 KiB
Rust
9376 lines
355 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, Wrap},
|
||
};
|
||
use unicode_segmentation::UnicodeSegmentation;
|
||
use unicode_width::UnicodeWidthStr;
|
||
|
||
use crate::{
|
||
diff::{ChangedFile, DiffState, DisplayRow, FileStatus, Focus, PaneLayout, Phase, RowKind},
|
||
editor::{
|
||
Completion, Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic,
|
||
},
|
||
files::{DockSide, FilePicker, FilePresentation, FileView},
|
||
plugin::{
|
||
Diagnostic, DiagnosticSeverity, SemanticToken, SemanticTokenKind, SemanticTokenModifier,
|
||
},
|
||
session::EditorSession,
|
||
syntax::{SyntaxCache, SyntaxKind, SyntaxSpan},
|
||
};
|
||
|
||
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>,
|
||
text_width: usize,
|
||
horizontal_extent: Option<(usize, u64, usize)>,
|
||
}
|
||
|
||
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 draw_session(&mut self, frame: &mut Frame, session: &mut EditorSession) {
|
||
self.draw(frame, session.editor());
|
||
if let Some((comparison_id, file, top, left_before, left_after)) = self
|
||
.panels
|
||
.diff
|
||
.as_ref()
|
||
.and_then(|geometry| geometry.viewport)
|
||
{
|
||
session.set_diff_viewport(comparison_id, file, top, left_before, left_after);
|
||
}
|
||
}
|
||
|
||
// Shift-wheel is the fallback for terminals without native horizontal reports.
|
||
// Keep classification shared with popup routing.
|
||
fn scroll(&mut self, session: &mut EditorSession, mouse: MouseEvent) -> bool {
|
||
let Some(kind) = wheel_kind(&mouse) else {
|
||
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, left) = self.offsets.entry(buffer_id).or_default();
|
||
if matches!(
|
||
kind,
|
||
MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight
|
||
) {
|
||
let key = (buffer_id, editor.text_revision());
|
||
let columns = match self.document.horizontal_extent {
|
||
Some((id, revision, columns)) if (id, revision) == key => columns,
|
||
_ => {
|
||
let columns = editor
|
||
.lines()
|
||
.iter()
|
||
.map(|line| display_column(line, usize::MAX))
|
||
.max()
|
||
.unwrap_or(0);
|
||
self.document.horizontal_extent = Some((key.0, key.1, columns));
|
||
columns
|
||
}
|
||
};
|
||
*left = if kind == MouseEventKind::ScrollRight {
|
||
left.saturating_add(3)
|
||
} else {
|
||
left.saturating_sub(3)
|
||
}
|
||
.min(
|
||
columns
|
||
.saturating_add(1)
|
||
.saturating_sub(self.document.text_width.max(1)),
|
||
);
|
||
} else {
|
||
let last_page = editor
|
||
.lines()
|
||
.len()
|
||
.saturating_sub(usize::from(viewport.height));
|
||
*top = if kind == MouseEventKind::ScrollDown {
|
||
top.saturating_add(3)
|
||
} else {
|
||
top.saturating_sub(3)
|
||
}
|
||
.min(last_page);
|
||
}
|
||
if self.document.manual.is_none() {
|
||
self.document.manual = Some(ManualViewport {
|
||
buffer_id,
|
||
revision: editor.text_revision(),
|
||
caret_reveal_revision: editor.caret_reveal_revision(),
|
||
mode: editor.mode(),
|
||
cursor: editor.cursor(),
|
||
carets: editor.carets(),
|
||
selections: editor.selections(),
|
||
});
|
||
}
|
||
true
|
||
}
|
||
|
||
fn scroll_ready(
|
||
&mut self,
|
||
session: &mut EditorSession,
|
||
mut mouse: MouseEvent,
|
||
mut next: impl FnMut() -> io::Result<Option<Event>>,
|
||
) -> io::Result<(bool, Option<Event>)> {
|
||
let mut redraw = false;
|
||
// Bound input work so drawing and background replies cannot starve.
|
||
for index in 0..64 {
|
||
redraw |= self.scroll(session, mouse);
|
||
if index == 63 {
|
||
break;
|
||
}
|
||
match next()? {
|
||
Some(Event::Mouse(report)) => mouse = report,
|
||
pending => return Ok((redraw, pending)),
|
||
}
|
||
}
|
||
Ok((redraw, None))
|
||
}
|
||
}
|
||
|
||
fn wheel_kind(mouse: &MouseEvent) -> Option<MouseEventKind> {
|
||
Some(match mouse.kind {
|
||
MouseEventKind::ScrollUp if mouse.modifiers.contains(KeyModifiers::SHIFT) => {
|
||
MouseEventKind::ScrollLeft
|
||
}
|
||
MouseEventKind::ScrollDown if mouse.modifiers.contains(KeyModifiers::SHIFT) => {
|
||
MouseEventKind::ScrollRight
|
||
}
|
||
kind @ (MouseEventKind::ScrollUp
|
||
| MouseEventKind::ScrollDown
|
||
| MouseEventKind::ScrollLeft
|
||
| MouseEventKind::ScrollRight) => kind,
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
fn draw_frame<W: io::Write>(
|
||
terminal: &mut Terminal<CrosstermBackend<Vec<u8>>>,
|
||
output: &mut W,
|
||
render: impl FnOnce(&mut Frame),
|
||
) -> io::Result<()> {
|
||
// Do not suspend the terminal's presentation while computing the next frame.
|
||
terminal.backend_mut().writer_mut().clear();
|
||
crossterm::queue!(terminal.backend_mut(), BeginSynchronizedUpdate)?;
|
||
// Terminals without synchronized updates still hide the painting cursor.
|
||
let result = terminal
|
||
.hide_cursor()
|
||
.and_then(|()| terminal.draw(render).map(|_| ()));
|
||
let finish = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
|
||
result.and(finish)?;
|
||
output.write_all(terminal.backend().writer())?;
|
||
output.flush()
|
||
}
|
||
|
||
/// Renders session state and translates terminal input; the session owns plugin
|
||
/// execution and retirement, so the adapter needs no lifecycle callback.
|
||
pub fn run(session: &mut EditorSession) -> io::Result<()> {
|
||
TerminalSession::run(|| {
|
||
let mut output: Stdout = io::stdout();
|
||
let mut terminal = Terminal::new(CrosstermBackend::new(Vec::new()))?;
|
||
let mut viewports = BufferViewports::default();
|
||
let mut 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_session(frame, session)
|
||
})?;
|
||
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(),
|
||
editor.file_navigation_hint(),
|
||
panels,
|
||
Some(editor),
|
||
);
|
||
return;
|
||
}
|
||
panels.diff_syntax = None;
|
||
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);
|
||
document.text_width = 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 document.manual.is_some()
|
||
&& let Some((id, revision, columns)) = document.horizontal_extent
|
||
&& id == editor.buffer_id()
|
||
&& revision == editor.text_revision()
|
||
{
|
||
*left = (*left).min(columns.saturating_add(1).saturating_sub(width.max(1)));
|
||
}
|
||
if height > 0 && width > 0 {
|
||
if 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);
|
||
for (row, line) in editor.lines().iter().enumerate().skip(*top).take(height) {
|
||
let area = Rect::new(
|
||
viewport.x,
|
||
viewport.y + (row - *top) as u16,
|
||
viewport.width,
|
||
1,
|
||
);
|
||
paint_syntax_line(frame, line, syntax.line(row), area, *left);
|
||
paint_semantic_line(frame, line, editor.semantic_tokens(), row, area, *left);
|
||
paint_editor_line(frame, editor, row, area, *left, &carets, &selections);
|
||
}
|
||
}
|
||
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 let Some(seek) = editor.seek_pending() {
|
||
write!(status, " {seek}").unwrap();
|
||
}
|
||
if editor.buffer_count() > 1 {
|
||
write!(
|
||
status,
|
||
" [buffer {}/{}]",
|
||
editor.buffer_id(),
|
||
editor.buffer_count()
|
||
)
|
||
.unwrap();
|
||
}
|
||
let mut diagnostic_counts = [0; 4];
|
||
for diagnostic in editor.diagnostics() {
|
||
diagnostic_counts[diagnostic_severity(diagnostic.severity).0] += 1;
|
||
}
|
||
for (label, count) in ["E", "W", "I", "H"].into_iter().zip(diagnostic_counts) {
|
||
if count > 0 {
|
||
write!(status, " {label}:{count}").unwrap();
|
||
}
|
||
}
|
||
write!(
|
||
status,
|
||
" {}{} {}:{}",
|
||
path,
|
||
if editor.dirty() { " [+]" } else { "" },
|
||
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,
|
||
None,
|
||
);
|
||
} else if let Some(panel) = dock {
|
||
draw_file_list(
|
||
frame,
|
||
picker,
|
||
panel,
|
||
editor.file_navigation_hint(),
|
||
panels,
|
||
None,
|
||
);
|
||
}
|
||
}
|
||
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],
|
||
viewport: Option<(u64, usize, usize, usize, usize)>,
|
||
text_widths: [u16; 2],
|
||
}
|
||
|
||
fn diff_panel(frame: &mut Frame, area: Rect, title: &str, focused: bool) -> Rect {
|
||
if area.width < 4 || area.height < 4 {
|
||
return area;
|
||
}
|
||
let block = Block::default()
|
||
.borders(Borders::ALL)
|
||
.border_style(Style::default().fg(if focused {
|
||
Color::Cyan
|
||
} else {
|
||
Color::DarkGray
|
||
}))
|
||
.title(title);
|
||
let inner = block.inner(area);
|
||
frame.render_widget(block, area);
|
||
inner
|
||
}
|
||
|
||
fn paint_cells(frame: &mut Frame, area: Rect, left: usize, start: usize, end: usize, style: Style) {
|
||
if area.height == 0 {
|
||
return;
|
||
}
|
||
for column in start.max(left)..end.min(left.saturating_add(usize::from(area.width))) {
|
||
frame.buffer_mut()[(area.x + (column - left) as u16, area.y)].set_style(style);
|
||
}
|
||
}
|
||
|
||
fn paint_grapheme_range(
|
||
frame: &mut Frame,
|
||
area: Rect,
|
||
left: usize,
|
||
line: &str,
|
||
range: std::ops::Range<usize>,
|
||
style: Style,
|
||
) {
|
||
let right = left.saturating_add(usize::from(area.width));
|
||
let mut column = 0;
|
||
let mut count = 0;
|
||
for (index, grapheme) in line.graphemes(true).enumerate() {
|
||
let cells = if grapheme == "\t" {
|
||
TAB_WIDTH - column % TAB_WIDTH
|
||
} else {
|
||
UnicodeWidthStr::width(grapheme)
|
||
};
|
||
let next = column + cells;
|
||
if range.contains(&index) && (grapheme == "\t" || (column >= left && next <= right)) {
|
||
paint_cells(frame, area, left, column, next, style);
|
||
}
|
||
column = next;
|
||
count = index + 1;
|
||
if column >= right {
|
||
break;
|
||
}
|
||
}
|
||
if range.start <= count && range.end > count {
|
||
paint_cells(frame, area, left, column, column.saturating_add(1), style);
|
||
}
|
||
}
|
||
|
||
fn paint_semantic_line(
|
||
frame: &mut Frame,
|
||
line: &str,
|
||
semantic: &[SemanticToken],
|
||
row: usize,
|
||
area: Rect,
|
||
left: usize,
|
||
) {
|
||
let mut token = semantic.partition_point(|token| token.start.row < row);
|
||
let mut token_style = None;
|
||
let mut column = 0;
|
||
let right = left.saturating_add(usize::from(area.width));
|
||
for (index, grapheme) in line.graphemes(true).enumerate() {
|
||
if column >= right {
|
||
break;
|
||
}
|
||
let cells = if grapheme == "\t" {
|
||
TAB_WIDTH - column % TAB_WIDTH
|
||
} else {
|
||
UnicodeWidthStr::width(grapheme)
|
||
};
|
||
let end = column + cells;
|
||
while token < semantic.len()
|
||
&& semantic[token].start.row == row
|
||
&& semantic[token].end.column <= index
|
||
{
|
||
token += 1;
|
||
token_style = None;
|
||
}
|
||
if let Some(item) = semantic
|
||
.get(token)
|
||
.filter(|token| token.start.row == row && token.start.column <= index)
|
||
&& (grapheme == "\t" || (column >= left && end <= right))
|
||
{
|
||
let style = *token_style.get_or_insert_with(|| semantic_style(item));
|
||
paint_cells(frame, area, left, column, end, style);
|
||
}
|
||
column = end;
|
||
}
|
||
}
|
||
|
||
fn paint_editor_line(
|
||
frame: &mut Frame,
|
||
editor: &Editor,
|
||
row: usize,
|
||
area: Rect,
|
||
left: usize,
|
||
carets: &[Cursor],
|
||
selections: &[Selection],
|
||
) {
|
||
let Some(line) = editor.lines().get(row) else {
|
||
return;
|
||
};
|
||
for severity in [
|
||
DiagnosticSeverity::Hint,
|
||
DiagnosticSeverity::Information,
|
||
DiagnosticSeverity::Warning,
|
||
DiagnosticSeverity::Error,
|
||
] {
|
||
for diagnostic in editor
|
||
.diagnostics()
|
||
.filter(|item| item.severity == severity && diagnostic_on_row(item, row))
|
||
{
|
||
let start = if row == diagnostic.start.row {
|
||
diagnostic.start.column
|
||
} else {
|
||
0
|
||
};
|
||
let end = if row == diagnostic.end.row {
|
||
diagnostic
|
||
.end
|
||
.column
|
||
.saturating_add(usize::from(diagnostic.start == diagnostic.end))
|
||
} else {
|
||
usize::MAX
|
||
};
|
||
let style = Style::default()
|
||
.underline_color(diagnostic_severity(severity).1)
|
||
.add_modifier(Modifier::UNDERLINED);
|
||
paint_grapheme_range(frame, area, left, line, start..end, style);
|
||
}
|
||
}
|
||
if let Some(found) = editor.search_match().filter(|found| found.start.row == row) {
|
||
paint_cells(
|
||
frame,
|
||
area,
|
||
left,
|
||
display_column(line, found.start.column),
|
||
display_column(line, found.end_column),
|
||
Style::default().fg(Color::Black).bg(Color::Yellow),
|
||
);
|
||
}
|
||
let selection_style = Style::default().fg(Color::White).bg(Color::Blue);
|
||
if editor.linewise_rows().any(|rows| rows.contains(&row)) {
|
||
paint_cells(
|
||
frame,
|
||
area,
|
||
left,
|
||
left,
|
||
display_column(line, usize::MAX).saturating_add(1),
|
||
selection_style,
|
||
);
|
||
}
|
||
for selection in selections
|
||
.iter()
|
||
.filter(|_| !editor.visual_linewise())
|
||
.chain(editor.snippet_selections())
|
||
{
|
||
if selection.start.row <= row && row <= selection.end.row {
|
||
let start = if row == selection.start.row {
|
||
display_column(line, selection.start.column)
|
||
} else {
|
||
0
|
||
};
|
||
let end = if row == selection.end.row {
|
||
display_column(line, selection.end.column)
|
||
} else {
|
||
display_column(line, usize::MAX).saturating_add(1)
|
||
};
|
||
paint_cells(frame, area, left, start, end, selection_style);
|
||
}
|
||
}
|
||
for caret in carets
|
||
.iter()
|
||
.filter(|caret| caret.row == row && **caret != editor.cursor())
|
||
{
|
||
let column = display_column(line, caret.column);
|
||
paint_cells(
|
||
frame,
|
||
area,
|
||
left,
|
||
column,
|
||
column.saturating_add(1),
|
||
Style::default().fg(Color::Black).bg(Color::Magenta),
|
||
);
|
||
}
|
||
}
|
||
|
||
fn paint_diff_editor(
|
||
frame: &mut Frame,
|
||
editor: &Editor,
|
||
diff: &DiffState,
|
||
area: Rect,
|
||
focus: Focus,
|
||
viewport: (usize, usize),
|
||
) -> Option<Position> {
|
||
let (top, left) = viewport;
|
||
let carets = editor.carets();
|
||
let selections = editor.selections();
|
||
let mut cursor = None;
|
||
for (display, row) in diff
|
||
.display_rows
|
||
.iter()
|
||
.enumerate()
|
||
.skip(top)
|
||
.take(usize::from(area.height))
|
||
{
|
||
let DisplayRow::Line(aligned) = *row else {
|
||
continue;
|
||
};
|
||
let Some(source) = (match focus {
|
||
Focus::Before => diff.rows[aligned].before,
|
||
Focus::After => diff.rows[aligned].after,
|
||
Focus::Files => None,
|
||
}) else {
|
||
continue;
|
||
};
|
||
let line_area = Rect::new(area.x, area.y + (display - top) as u16, area.width, 1);
|
||
paint_editor_line(frame, editor, source, line_area, left, &carets, &selections);
|
||
if source == editor.cursor().row && editor.diff_interacting() {
|
||
let column = display_column(&editor.lines()[source], editor.cursor().column);
|
||
if column >= left && column < left.saturating_add(usize::from(area.width)) {
|
||
cursor = Some(Position::new(area.x + (column - left) as u16, line_area.y));
|
||
}
|
||
}
|
||
}
|
||
if let Some(position) = cursor
|
||
&& !matches!(editor.mode(), Mode::Command | Mode::Search)
|
||
&& editor.palette().is_none()
|
||
{
|
||
frame.set_cursor_position(position);
|
||
}
|
||
cursor
|
||
}
|
||
|
||
fn draw_diff_popups(
|
||
frame: &mut Frame,
|
||
editor: &Editor,
|
||
area: Rect,
|
||
caret: Option<(Rect, Position)>,
|
||
panels: &mut WheelPanels,
|
||
) {
|
||
if let Some((text_area, caret)) = caret
|
||
&& let Some(completion) = editor.completion()
|
||
{
|
||
panels.completion = draw_completion(frame, completion, text_area, caret);
|
||
}
|
||
if area.width > 0 && area.height > 0 {
|
||
let footer = Rect::new(area.x, area.bottom() - 1, area.width, 1);
|
||
if matches!(editor.mode(), Mode::Command | Mode::Search) {
|
||
let prefix = if editor.mode() == Mode::Search {
|
||
'/'
|
||
} else {
|
||
':'
|
||
};
|
||
let command = format!("{prefix}{}", editor.command_line());
|
||
let column = display_column(&command, usize::MAX);
|
||
let offset = column.saturating_sub(usize::from(area.width).saturating_sub(1));
|
||
frame.render_widget(Clear, footer);
|
||
frame.render_widget(
|
||
Paragraph::new(visible_line(&command, offset, usize::from(area.width))),
|
||
footer,
|
||
);
|
||
frame.set_cursor_position((footer.x + (column - offset) as u16, footer.y));
|
||
} else if !editor.message().is_empty() {
|
||
frame.render_widget(Clear, footer);
|
||
frame.render_widget(Paragraph::new(display_text(editor.message())), footer);
|
||
}
|
||
}
|
||
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, caret.map_or(area, |(area, _)| area), panels);
|
||
}
|
||
if let Some(diagnostic) = editor.startup_diagnostic() {
|
||
draw_startup_diagnostic(frame, diagnostic, area);
|
||
}
|
||
}
|
||
|
||
fn draw_diff(
|
||
frame: &mut Frame,
|
||
diff: &DiffState,
|
||
area: Rect,
|
||
navigation_hint: &str,
|
||
file_navigation_hint: &str,
|
||
panels: &mut WheelPanels,
|
||
editor: Option<&Editor>,
|
||
) {
|
||
let comparison = diff.comparison.as_ref();
|
||
let selected = comparison.and_then(|comparison| comparison.files.get(diff.file));
|
||
if diff.phase == Phase::View
|
||
&& let Some(id) = diff.comparison_id
|
||
&& let Some(file) = selected
|
||
{
|
||
panels
|
||
.diff_syntax
|
||
.get_or_insert_with(DiffSyntaxCache::default)
|
||
.update(id, diff.file, file, diff.live_revision);
|
||
} else {
|
||
panels.diff_syntax = None;
|
||
}
|
||
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/clear · :diff.close"
|
||
))
|
||
.style(Style::default().fg(Color::DarkGray)),
|
||
help,
|
||
);
|
||
panels.diff = Some(DiffGeometry {
|
||
regions: [
|
||
(panel, Focus::Files),
|
||
(Rect::default(), Focus::Before),
|
||
(Rect::default(), Focus::After),
|
||
],
|
||
viewport: None,
|
||
text_widths: [0; 2],
|
||
});
|
||
if let Some(editor) = editor {
|
||
draw_diff_popups(frame, editor, area, None, panels);
|
||
}
|
||
return;
|
||
}
|
||
|
||
let syntax_error = panels
|
||
.diff_syntax
|
||
.as_ref()
|
||
.and_then(|syntax| syntax.before.error().or_else(|| syntax.after.error()));
|
||
let notice = diff.error.as_deref().or_else(|| {
|
||
if diff.loading {
|
||
Some("Loading comparison…")
|
||
} else {
|
||
syntax_error.or_else(|| selected.and_then(|file| file.notice.as_deref()))
|
||
}
|
||
});
|
||
let [header, notice_area, workspace, help] = Layout::vertical([
|
||
Constraint::Length(1),
|
||
Constraint::Length(u16::from(notice.is_some() && area.height >= 3)),
|
||
Constraint::Fill(1),
|
||
Constraint::Length(u16::from(area.height >= 3)),
|
||
])
|
||
.areas(area);
|
||
let (before, after) = comparison.map_or(("", ""), |comparison| {
|
||
(comparison.before.as_str(), comparison.after.as_str())
|
||
});
|
||
frame.render_widget(
|
||
Paragraph::new(format!(
|
||
"Diff · {} → {} · {:?} focus · {}",
|
||
display_text(before),
|
||
display_text(after),
|
||
diff.focus,
|
||
editor
|
||
.filter(|editor| editor.diff_interacting())
|
||
.map_or_else(
|
||
|| "read-only".into(),
|
||
|editor| format!(
|
||
"{} · {}{} · {}:{}",
|
||
if editor.diff_editing() {
|
||
"editable"
|
||
} else {
|
||
"read-only"
|
||
},
|
||
if editor.visual_linewise() {
|
||
"VISUAL LINE".into()
|
||
} else {
|
||
editor.mode().to_string()
|
||
},
|
||
if editor.dirty() { " [+]" } else { "" },
|
||
editor.cursor().row + 1,
|
||
editor.cursor().column + 1,
|
||
),
|
||
)
|
||
))
|
||
.style(Style::default().fg(Color::Black).bg(Color::Cyan)),
|
||
header,
|
||
);
|
||
if let Some(notice) = notice {
|
||
frame.render_widget(
|
||
Paragraph::new(display_text(notice)).style(Style::default().fg(
|
||
if diff.error.is_some() || syntax_error.is_some() {
|
||
Color::Red
|
||
} else {
|
||
Color::Yellow
|
||
},
|
||
)),
|
||
notice_area,
|
||
);
|
||
}
|
||
let (panes, navigator) = if diff.files_visible
|
||
&& diff
|
||
.files
|
||
.as_ref()
|
||
.is_some_and(|files| files.presentation == FilePresentation::Docked)
|
||
{
|
||
dock_layout(workspace, DockSide::Right, diff.focus == Focus::Files)
|
||
} else {
|
||
(workspace, None)
|
||
};
|
||
let pane_layout = diff.pane_layout();
|
||
let [before_area, after_area] = match pane_layout {
|
||
PaneLayout::Split => {
|
||
Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).areas(panes)
|
||
}
|
||
PaneLayout::Before | PaneLayout::Empty => [panes, Rect::default()],
|
||
PaneLayout::After => [Rect::default(), panes],
|
||
};
|
||
let mut document_caret = None;
|
||
let mut top = diff.top;
|
||
let mut before_left = diff.left_before;
|
||
let mut after_left = diff.left_after;
|
||
if let Some(editor) = editor.filter(|editor| editor.diff_interacting() && diff.follow_caret)
|
||
&& let Some(focus) = editor.diff_buffer_focus()
|
||
{
|
||
let (pane, left) = match focus {
|
||
Focus::Before => (before_area, &mut before_left),
|
||
Focus::After => (after_area, &mut after_left),
|
||
Focus::Files => unreachable!("native diff buffers belong to a text pane"),
|
||
};
|
||
let height = usize::from(pane.height.saturating_sub(2));
|
||
if let Some(row) = diff.source_display_row(focus, editor.cursor().row) {
|
||
if row < top {
|
||
top = row;
|
||
} else if height > 0 && row >= top.saturating_add(height) {
|
||
top = row + 1 - height;
|
||
}
|
||
}
|
||
let source_len = selected.map_or(0, |file| match focus {
|
||
Focus::Before => file.before.len(),
|
||
Focus::After => file.after.len(),
|
||
Focus::Files => unreachable!("native buffer belongs to a text pane"),
|
||
});
|
||
let digits = source_len.max(1).ilog10() as usize + 1;
|
||
let content_width = usize::from(pane.width.saturating_sub(2));
|
||
let gutter = if content_width > digits + 2 {
|
||
digits + 2
|
||
} else {
|
||
0
|
||
};
|
||
let width = content_width.saturating_sub(gutter);
|
||
let column = display_column(&editor.lines()[editor.cursor().row], editor.cursor().column);
|
||
if column < *left {
|
||
*left = column;
|
||
} else if width > 0 && column >= left.saturating_add(width) {
|
||
*left = column + 1 - width;
|
||
}
|
||
}
|
||
if let Some(picker) = &diff.files
|
||
&& let Some(navigator) = navigator
|
||
{
|
||
draw_file_list(
|
||
frame,
|
||
picker,
|
||
navigator,
|
||
file_navigation_hint,
|
||
panels,
|
||
comparison.map(|comparison| comparison.files.as_slice()),
|
||
);
|
||
}
|
||
let mut text_widths = [0; 2];
|
||
let previous_text_widths = panels
|
||
.previous_diff
|
||
.as_ref()
|
||
.filter(|geometry| {
|
||
geometry.viewport.is_some_and(|(id, file, _, _, _)| {
|
||
Some(id) == diff.comparison_id && file == diff.file
|
||
})
|
||
})
|
||
.map(|geometry| geometry.text_widths);
|
||
if let Some(file) = selected
|
||
&& let Some(syntax) = &panels.diff_syntax
|
||
{
|
||
if pane_layout == PaneLayout::Empty {
|
||
diff_panel(frame, before_area, "", diff.focus == Focus::Before);
|
||
} else {
|
||
if before_area != Rect::default() {
|
||
let text_area = draw_diff_pane(
|
||
frame,
|
||
diff,
|
||
file,
|
||
before_area,
|
||
true,
|
||
&syntax.before,
|
||
editor.map_or(&[], |editor| editor.diff_semantic_tokens(Focus::Before)),
|
||
(
|
||
top,
|
||
&mut before_left,
|
||
previous_text_widths.map(|widths| widths[0]),
|
||
),
|
||
);
|
||
text_widths[0] = text_area.width;
|
||
if let Some(editor) =
|
||
editor.filter(|editor| editor.diff_buffer_focus() == Some(Focus::Before))
|
||
{
|
||
document_caret = paint_diff_editor(
|
||
frame,
|
||
editor,
|
||
diff,
|
||
text_area,
|
||
Focus::Before,
|
||
(top, before_left),
|
||
)
|
||
.map(|position| (text_area, position));
|
||
}
|
||
}
|
||
if after_area != Rect::default() {
|
||
let text_area = draw_diff_pane(
|
||
frame,
|
||
diff,
|
||
file,
|
||
after_area,
|
||
false,
|
||
&syntax.after,
|
||
editor.map_or(&[], |editor| editor.diff_semantic_tokens(Focus::After)),
|
||
(
|
||
top,
|
||
&mut after_left,
|
||
previous_text_widths.map(|widths| widths[1]),
|
||
),
|
||
);
|
||
text_widths[1] = text_area.width;
|
||
if let Some(editor) =
|
||
editor.filter(|editor| editor.diff_buffer_focus() == Some(Focus::After))
|
||
{
|
||
document_caret = paint_diff_editor(
|
||
frame,
|
||
editor,
|
||
diff,
|
||
text_area,
|
||
Focus::After,
|
||
(top, after_left),
|
||
)
|
||
.map(|position| (text_area, position));
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
frame.render_widget(
|
||
Paragraph::new(if diff.loading {
|
||
"Loading comparison…"
|
||
} else {
|
||
"No changed files"
|
||
}),
|
||
panes,
|
||
);
|
||
}
|
||
frame.render_widget(
|
||
Paragraph::new(
|
||
"Native buffer bindings · Tab focus · [/] expand above/below in Normal · :diff.close",
|
||
)
|
||
.style(Style::default().fg(Color::DarkGray)),
|
||
help,
|
||
);
|
||
panels.diff = Some(DiffGeometry {
|
||
regions: [
|
||
(navigator.unwrap_or_default(), Focus::Files),
|
||
(before_area, Focus::Before),
|
||
(after_area, Focus::After),
|
||
],
|
||
viewport: diff
|
||
.comparison_id
|
||
.map(|id| (id, diff.file, top, before_left, after_left)),
|
||
text_widths,
|
||
});
|
||
if let Some(picker) = &diff.files
|
||
&& diff.files_visible
|
||
&& picker.presentation == FilePresentation::Floating
|
||
{
|
||
let panel = floating_panel(area);
|
||
frame.render_widget(Clear, panel);
|
||
draw_file_list(
|
||
frame,
|
||
picker,
|
||
panel,
|
||
file_navigation_hint,
|
||
panels,
|
||
comparison.map(|comparison| comparison.files.as_slice()),
|
||
);
|
||
}
|
||
if let Some(editor) = editor {
|
||
draw_diff_popups(frame, editor, area, document_caret, panels);
|
||
}
|
||
}
|
||
|
||
fn diff_status(status: FileStatus) -> &'static str {
|
||
match status {
|
||
FileStatus::Added => "A",
|
||
FileStatus::Removed => "D",
|
||
FileStatus::Modified => "M",
|
||
FileStatus::Renamed => "R",
|
||
}
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn draw_diff_pane(
|
||
frame: &mut Frame,
|
||
diff: &DiffState,
|
||
file: &ChangedFile,
|
||
area: Rect,
|
||
before: bool,
|
||
syntax: &SyntaxCache,
|
||
semantic: &[SemanticToken],
|
||
viewport: (usize, &mut usize, Option<u16>),
|
||
) -> Rect {
|
||
if area.width == 0 || area.height == 0 {
|
||
return area;
|
||
}
|
||
let label = if before { "Before" } else { "After" };
|
||
let path = if before {
|
||
file.old_path.as_ref().unwrap_or(&file.path)
|
||
} else {
|
||
&file.path
|
||
};
|
||
let title = format!(
|
||
" {label} · {} · {} ",
|
||
diff_status(file.status),
|
||
display_text(&path.to_string_lossy())
|
||
);
|
||
let focus = if before { Focus::Before } else { Focus::After };
|
||
let content = diff_panel(frame, area, &title, diff.focus == focus);
|
||
if diff.display_rows.is_empty() {
|
||
frame.render_widget(
|
||
Paragraph::new(file.notice.as_deref().map(display_text).unwrap_or_else(|| {
|
||
if diff.rows.is_empty() {
|
||
"Empty file"
|
||
} else {
|
||
"No text changes"
|
||
}
|
||
.into()
|
||
})),
|
||
content,
|
||
);
|
||
return content;
|
||
}
|
||
let source = if before { &file.before } else { &file.after };
|
||
let (top, left, previous_width) = viewport;
|
||
let digits = source.len().max(1).ilog10() as usize + 1;
|
||
let gutter = if usize::from(content.width) > digits + 2 {
|
||
digits + 2
|
||
} else {
|
||
0
|
||
};
|
||
let width = usize::from(content.width).saturating_sub(gutter);
|
||
if previous_width.is_some_and(|previous| usize::from(previous) != width) {
|
||
*left = diff.bound_horizontal(focus, *left, width);
|
||
}
|
||
let left = *left;
|
||
for (display_row, row) in diff
|
||
.display_rows
|
||
.iter()
|
||
.enumerate()
|
||
.skip(top)
|
||
.take(usize::from(content.height))
|
||
{
|
||
let row_area = Rect::new(
|
||
content.x,
|
||
content.y + (display_row - top) as u16,
|
||
content.width,
|
||
1,
|
||
);
|
||
let aligned_row = match row {
|
||
DisplayRow::Line(index) => *index,
|
||
DisplayRow::Fold { start, end } => {
|
||
frame.render_widget(
|
||
Paragraph::new(format!(
|
||
"… {} unchanged lines hidden · [/] expand context",
|
||
end - start
|
||
))
|
||
.style(Style::default().fg(Color::DarkGray)),
|
||
row_area,
|
||
);
|
||
continue;
|
||
}
|
||
};
|
||
let row = &diff.rows[aligned_row];
|
||
let index = if before { row.before } else { row.after };
|
||
let Some(index) = index else {
|
||
let style = Style::default().fg(Color::Rgb(72, 72, 72)).bg(Color::Reset);
|
||
let buffer = frame.buffer_mut();
|
||
for x in 0..content.width {
|
||
buffer[(content.x + x, row_area.y)]
|
||
.set_symbol("╱")
|
||
.set_style(style);
|
||
}
|
||
continue;
|
||
};
|
||
let Some(source_text) = source.get(index) else {
|
||
continue;
|
||
};
|
||
let changed = matches!(row.kind, RowKind::Changed)
|
||
|| (before && row.kind == RowKind::Removed)
|
||
|| (!before && row.kind == RowKind::Added);
|
||
let background =
|
||
if changed && !matches!(file.status, FileStatus::Added | FileStatus::Removed) {
|
||
if before {
|
||
Color::Rgb(64, 24, 24)
|
||
} else {
|
||
Color::Rgb(24, 48, 32)
|
||
}
|
||
} else {
|
||
Color::Reset
|
||
};
|
||
let text = if source_text.chars().any(|ch| ch.is_control() && ch != '\t') {
|
||
Cow::Owned(
|
||
source_text
|
||
.chars()
|
||
.map(|ch| {
|
||
if ch.is_control() && ch != '\t' {
|
||
'�'
|
||
} else {
|
||
ch
|
||
}
|
||
})
|
||
.collect::<String>(),
|
||
)
|
||
} else {
|
||
Cow::Borrowed(source_text.as_str())
|
||
};
|
||
frame.render_widget(
|
||
Paragraph::new(visible_line(&text, left, width))
|
||
.style(Style::default().fg(Color::Reset).bg(background)),
|
||
Rect::new(content.x + gutter as u16, row_area.y, width as u16, 1),
|
||
);
|
||
if gutter > 0 {
|
||
let marker = if changed {
|
||
if before { '-' } else { '+' }
|
||
} else {
|
||
' '
|
||
};
|
||
let color = if changed {
|
||
if before { Color::Red } else { Color::Green }
|
||
} else {
|
||
Color::DarkGray
|
||
};
|
||
frame.render_widget(
|
||
Paragraph::new(format!("{:>digits$}{marker} ", index + 1))
|
||
.style(Style::default().fg(color).bg(background)),
|
||
Rect::new(content.x, row_area.y, gutter as u16, 1),
|
||
);
|
||
}
|
||
paint_syntax_line(
|
||
frame,
|
||
source_text,
|
||
syntax.line(index),
|
||
Rect::new(content.x + gutter as u16, row_area.y, width as u16, 1),
|
||
left,
|
||
);
|
||
paint_semantic_line(
|
||
frame,
|
||
source_text,
|
||
semantic,
|
||
index,
|
||
Rect::new(content.x + gutter as u16, row_area.y, width as u16, 1),
|
||
left,
|
||
);
|
||
}
|
||
Rect::new(
|
||
content.x + gutter as u16,
|
||
content.y,
|
||
width as u16,
|
||
content.height,
|
||
)
|
||
}
|
||
|
||
fn floating_panel(area: Rect) -> Rect {
|
||
let [_, column, _] = Layout::horizontal([
|
||
Constraint::Fill(1),
|
||
Constraint::Length(area.width.min(100)),
|
||
Constraint::Fill(1),
|
||
])
|
||
.areas(area);
|
||
let [_, panel, _] = Layout::vertical([
|
||
Constraint::Fill(1),
|
||
Constraint::Length(area.height.min(24)),
|
||
Constraint::Fill(1),
|
||
])
|
||
.areas(column);
|
||
panel
|
||
}
|
||
|
||
fn draw_startup_diagnostic(frame: &mut Frame, diagnostic: &StartupDiagnostic, area: Rect) {
|
||
let panel = floating_panel(area);
|
||
frame.render_widget(Clear, panel);
|
||
let bordered = panel.width >= 4 && panel.height >= 4;
|
||
let block = Block::default()
|
||
.borders(if bordered {
|
||
Borders::ALL
|
||
} else {
|
||
Borders::NONE
|
||
})
|
||
.border_style(Style::default().fg(Color::Red))
|
||
.title(" Configuration error ");
|
||
let content = if bordered { block.inner(panel) } else { panel };
|
||
if bordered {
|
||
frame.render_widget(block, panel);
|
||
}
|
||
let [text_area, help_area] = Layout::vertical([
|
||
Constraint::Fill(1),
|
||
Constraint::Length(u16::from(content.height >= 3)),
|
||
])
|
||
.areas(content);
|
||
let lines: Vec<Line> = diagnostic
|
||
.lines
|
||
.iter()
|
||
.skip(diagnostic.top)
|
||
.take(usize::from(text_area.height))
|
||
.map(|line| {
|
||
let line = display_text(line);
|
||
Line::from(visible_line(
|
||
&line,
|
||
display_column(&line, diagnostic.left),
|
||
usize::from(text_area.width),
|
||
))
|
||
})
|
||
.collect();
|
||
frame.render_widget(Paragraph::new(lines), text_area);
|
||
frame.render_widget(
|
||
Paragraph::new("Arrows scroll · Home reset · Enter/Esc continue")
|
||
.style(Style::default().fg(Color::DarkGray)),
|
||
help_area,
|
||
);
|
||
if text_area.width > 0 && text_area.height > 0 {
|
||
frame.set_cursor_position((text_area.x, text_area.y));
|
||
}
|
||
}
|
||
|
||
#[derive(Default)]
|
||
struct DiffSyntaxCache {
|
||
key: Option<(u64, usize, Option<u64>)>,
|
||
revision: u64,
|
||
before_revision: u64,
|
||
before: SyntaxCache,
|
||
after: SyntaxCache,
|
||
}
|
||
|
||
impl DiffSyntaxCache {
|
||
fn update(
|
||
&mut self,
|
||
comparison_id: u64,
|
||
file_index: usize,
|
||
file: &ChangedFile,
|
||
live_revision: Option<u64>,
|
||
) {
|
||
let key = Some((comparison_id, file_index, live_revision));
|
||
if self.key.map(|(id, index, _)| (id, index)) != Some((comparison_id, file_index)) {
|
||
self.before_revision = self
|
||
.before_revision
|
||
.checked_add(1)
|
||
.expect("diff syntax revisions exhausted");
|
||
}
|
||
if self.key != key {
|
||
self.key = key;
|
||
self.revision = self
|
||
.revision
|
||
.checked_add(1)
|
||
.expect("diff syntax revisions exhausted");
|
||
}
|
||
self.before.update(
|
||
&file.before,
|
||
Some(file.old_path.as_deref().unwrap_or(&file.path)),
|
||
self.before_revision,
|
||
);
|
||
self.after
|
||
.update(&file.after, Some(&file.path), self.revision);
|
||
}
|
||
}
|
||
|
||
#[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>,
|
||
diff_syntax: Option<DiffSyntaxCache>,
|
||
previous_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.previous_diff = self.diff.take();
|
||
}
|
||
|
||
// Keyboard/paste may change the catalog without changing its length.
|
||
fn follow_files(&mut self) {
|
||
self.manual_files = None;
|
||
}
|
||
|
||
fn file_top(&mut self, picker: &FilePicker, panel: Rect, height: usize) -> usize {
|
||
if self
|
||
.manual_files
|
||
.as_ref()
|
||
.is_some_and(|offset| !offset.matches(picker))
|
||
{
|
||
self.manual_files = None;
|
||
}
|
||
let count = picker.entries().len();
|
||
let following = picker
|
||
.selected
|
||
.min(count.saturating_sub(1))
|
||
.saturating_sub(height.saturating_sub(1));
|
||
let top = self.manual_files.as_mut().map_or(following, |offset| {
|
||
offset.top = offset.top.min(count.saturating_sub(height.max(1)));
|
||
offset.top
|
||
});
|
||
self.files = Some((panel, height, top));
|
||
top
|
||
}
|
||
|
||
// A horizontal report over a popup must not pan the underlying buffer.
|
||
fn scroll(&mut self, session: &mut EditorSession, mouse: MouseEvent) -> bool {
|
||
let Some(kind) = wheel_kind(&mouse) else {
|
||
return false;
|
||
};
|
||
let horizontal = matches!(
|
||
kind,
|
||
MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight
|
||
);
|
||
let down = matches!(
|
||
kind,
|
||
MouseEventKind::ScrollDown | MouseEventKind::ScrollRight
|
||
);
|
||
let position = Position::new(mouse.column, mouse.row);
|
||
if horizontal
|
||
&& (session.editor().palette().is_some()
|
||
|| session.editor().startup_diagnostic().is_some()
|
||
|| self.completion.is_some_and(|area| area.contains(position))
|
||
|| self.hover.is_some_and(|(area, _)| area.contains(position))
|
||
|| self
|
||
.files
|
||
.is_some_and(|(area, _, _)| area.contains(position)))
|
||
{
|
||
return true;
|
||
}
|
||
let editor = session.editor();
|
||
if editor.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 horizontal {
|
||
let width =
|
||
self.diff.as_ref().unwrap().text_widths[usize::from(focus == Focus::After)];
|
||
if width > 0 {
|
||
session.scroll_diff_horizontal(focus, down, usize::from(width));
|
||
}
|
||
} else {
|
||
for _ in 0..3 {
|
||
session.scroll_diff(focus, down);
|
||
}
|
||
}
|
||
}
|
||
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,
|
||
changed_files: Option<&[ChangedFile]>,
|
||
) {
|
||
if panel.width == 0 || panel.height == 0 {
|
||
return;
|
||
}
|
||
let color = if picker.focused {
|
||
Color::Cyan
|
||
} else {
|
||
Color::DarkGray
|
||
};
|
||
let bordered = panel.width >= 4 && panel.height >= 4;
|
||
let browsing = picker.view == FileView::Tree && !picker.searching;
|
||
let title = match (picker.presentation, picker.view, browsing) {
|
||
(FilePresentation::Floating, FileView::List, _) => " Files · List ",
|
||
(FilePresentation::Docked, FileView::List, _) => " Explorer · List ",
|
||
(FilePresentation::Floating, FileView::Tree, true) => " Files · Tree NORMAL ",
|
||
(FilePresentation::Docked, FileView::Tree, true) => " Explorer · Tree NORMAL ",
|
||
(FilePresentation::Floating, FileView::Tree, false) => " Files · Tree INSERT ",
|
||
(FilePresentation::Docked, FileView::Tree, false) => " Explorer · Tree INSERT ",
|
||
};
|
||
let block = Block::default()
|
||
.borders(if bordered {
|
||
Borders::ALL
|
||
} else {
|
||
Borders::NONE
|
||
})
|
||
.border_style(Style::default().fg(color))
|
||
.title(title);
|
||
let content = if bordered { block.inner(panel) } else { panel };
|
||
if bordered {
|
||
frame.render_widget(block, panel);
|
||
}
|
||
let show_root = content.height >= 4 && !picker.root.as_os_str().is_empty();
|
||
let show_error = picker.error.is_some() && content.height >= 2;
|
||
let show_help = content.height >= 5 + u16::from(show_error);
|
||
let root = show_root.then(|| {
|
||
Paragraph::new(display_text(&picker.root.to_string_lossy()))
|
||
.style(Style::default().fg(Color::DarkGray))
|
||
.wrap(Wrap { trim: false })
|
||
});
|
||
// Reserve the query, optional notices/footer, and at least one file row.
|
||
let root_rows = root.as_ref().map_or(0, |root| {
|
||
root.line_count(content.width).min(usize::from(
|
||
content
|
||
.height
|
||
.saturating_sub(2 + u16::from(show_error) + u16::from(show_help)),
|
||
)) as u16
|
||
});
|
||
let [query_area, root_area, error_area, results_area, help_area] = Layout::vertical([
|
||
Constraint::Length(1),
|
||
Constraint::Length(root_rows),
|
||
Constraint::Length(u16::from(show_error)),
|
||
Constraint::Fill(1),
|
||
Constraint::Length(u16::from(show_help)),
|
||
])
|
||
.areas(content);
|
||
let first = panels.file_top(picker, panel, usize::from(results_area.height));
|
||
let query = if browsing && picker.query.is_empty() {
|
||
"i filter · ^U clear · ^T view".into()
|
||
} else if browsing {
|
||
format!("filter: {} · ^U clear", display_text(&picker.query))
|
||
} else {
|
||
format!("> {}", display_text(&picker.query))
|
||
};
|
||
let query_width = display_column(&query, usize::MAX);
|
||
let width = usize::from(query_area.width);
|
||
let offset = if browsing {
|
||
0
|
||
} else {
|
||
query_width.saturating_sub(width.saturating_sub(1))
|
||
};
|
||
frame.render_widget(
|
||
Paragraph::new(visible_line(&query, offset, width)).style(Style::default().fg(
|
||
if picker.focused {
|
||
Color::Yellow
|
||
} else {
|
||
Color::DarkGray
|
||
},
|
||
)),
|
||
query_area,
|
||
);
|
||
if picker.focused
|
||
&& query_area.width > 0
|
||
&& query_area.height > 0
|
||
&& (!browsing || picker.entries().len() == 0 || results_area.height == 0)
|
||
{
|
||
let cursor_x = if browsing { 0 } else { query_width - offset };
|
||
frame.set_cursor_position((query_area.x + cursor_x as u16, query_area.y));
|
||
}
|
||
if let Some(root) = root {
|
||
frame.render_widget(root, root_area);
|
||
}
|
||
if show_error {
|
||
frame.render_widget(
|
||
Paragraph::new(display_text(picker.error.as_deref().unwrap()))
|
||
.style(Style::default().fg(Color::Red)),
|
||
error_area,
|
||
);
|
||
}
|
||
let entries = picker.entries();
|
||
if entries.len() == 0 {
|
||
frame.render_widget(
|
||
Paragraph::new(if picker.query.is_empty() {
|
||
"No project files"
|
||
} else {
|
||
"No matching files"
|
||
}),
|
||
results_area,
|
||
);
|
||
} else if results_area.height > 0 {
|
||
let count = usize::from(results_area.height);
|
||
let selected_index = picker.selected.min(entries.len() - 1);
|
||
let lines: Vec<Line> = entries
|
||
.enumerate()
|
||
.skip(first)
|
||
.take(count)
|
||
.map(|(index, entry)| {
|
||
let selected = index == selected_index;
|
||
let mut style = if entry.is_dir {
|
||
Style::default().fg(Color::Cyan)
|
||
} else {
|
||
Style::default()
|
||
};
|
||
if selected {
|
||
style = style
|
||
.bg(if picker.focused {
|
||
Color::Blue
|
||
} else {
|
||
Color::DarkGray
|
||
})
|
||
.add_modifier(Modifier::BOLD);
|
||
}
|
||
let changed = entry
|
||
.catalog_index
|
||
.and_then(|index| changed_files.and_then(|files| files.get(index)));
|
||
let prefix = format!(
|
||
"{} {:indent$}",
|
||
if selected { ">" } else { " " },
|
||
"",
|
||
indent = if picker.view == FileView::Tree {
|
||
entry.depth * 2
|
||
} else {
|
||
0
|
||
},
|
||
);
|
||
let mut spans = Vec::with_capacity(if changed.is_some() { 6 } else { 3 });
|
||
spans.push(Span::raw(prefix));
|
||
if let Some(file) = changed {
|
||
let color = match file.status {
|
||
FileStatus::Added => Color::Green,
|
||
FileStatus::Removed => Color::Red,
|
||
FileStatus::Modified => Color::Yellow,
|
||
FileStatus::Renamed => Color::Cyan,
|
||
};
|
||
spans.push(Span::styled(
|
||
diff_status(file.status),
|
||
Style::default().fg(color),
|
||
));
|
||
spans.push(Span::raw(" "));
|
||
}
|
||
if let Some(file) = changed
|
||
&& file.status == FileStatus::Renamed
|
||
&& let Some(old_path) = &file.old_path
|
||
{
|
||
spans.push(Span::raw(display_text(&old_path.to_string_lossy())));
|
||
spans.push(Span::raw(" -> "));
|
||
spans.push(Span::raw(display_text(&file.path.to_string_lossy())));
|
||
} else {
|
||
spans.push(Span::raw(entry.label));
|
||
if entry.is_dir && picker.view == FileView::Tree {
|
||
spans.push(Span::raw("/"));
|
||
}
|
||
}
|
||
Line::from(spans).style(style)
|
||
})
|
||
.collect();
|
||
frame.render_widget(Paragraph::new(lines), results_area);
|
||
if browsing
|
||
&& picker.focused
|
||
&& results_area.width > 0
|
||
&& selected_index >= first
|
||
&& selected_index - first < count
|
||
{
|
||
frame.set_cursor_position((
|
||
results_area.x,
|
||
results_area.y + (selected_index - first) as u16,
|
||
));
|
||
}
|
||
}
|
||
let help = if changed_files.is_some() {
|
||
"A added · M modified · D deleted · R renamed · ^E hide".into()
|
||
} else if picker.focused {
|
||
let controls = if picker.root.as_os_str().is_empty() {
|
||
if browsing {
|
||
"Enter · i filter · Esc close · Tab panes · ^T view"
|
||
} else {
|
||
"Enter · Esc · Tab panes · ^T view"
|
||
}
|
||
} else {
|
||
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 if picker.root.as_os_str().is_empty() {
|
||
"Editor focus · Tab to browse changed files".into()
|
||
} 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 paint_syntax_line(
|
||
frame: &mut Frame,
|
||
source: &str,
|
||
spans: &[SyntaxSpan],
|
||
area: Rect,
|
||
left: usize,
|
||
) {
|
||
if spans.is_empty() || area.width == 0 || area.height == 0 {
|
||
return;
|
||
}
|
||
let right = left.saturating_add(usize::from(area.width));
|
||
let mut span_index = 0;
|
||
let mut column = 0;
|
||
for (byte, grapheme) in source.grapheme_indices(true) {
|
||
if column >= right {
|
||
break;
|
||
}
|
||
let cells = if grapheme == "\t" {
|
||
TAB_WIDTH - column % TAB_WIDTH
|
||
} else {
|
||
UnicodeWidthStr::width(grapheme) + grapheme.chars().filter(|ch| ch.is_control()).count()
|
||
};
|
||
let end = column + cells;
|
||
while span_index < spans.len() && spans[span_index].end_byte <= byte {
|
||
span_index += 1;
|
||
}
|
||
if let Some(span) = spans.get(span_index).filter(|span| span.start_byte <= byte)
|
||
&& end > left
|
||
&& (grapheme == "\t" || (column >= left && end <= right))
|
||
{
|
||
let style = syntax_style(span.kind);
|
||
for cell in column.max(left)..end.min(right) {
|
||
frame.buffer_mut()[(area.x + (cell - left) as u16, area.y)].set_style(style);
|
||
}
|
||
}
|
||
column = end;
|
||
}
|
||
}
|
||
|
||
fn syntax_style(kind: SyntaxKind) -> Style {
|
||
Style::default().fg(match kind {
|
||
SyntaxKind::Comment => Color::DarkGray,
|
||
SyntaxKind::String => Color::Green,
|
||
SyntaxKind::Keyword => Color::Magenta,
|
||
SyntaxKind::Number => Color::Yellow,
|
||
SyntaxKind::Type | SyntaxKind::Markup => Color::Cyan,
|
||
SyntaxKind::Function => Color::LightBlue,
|
||
})
|
||
}
|
||
|
||
fn semantic_style(token: &SemanticToken) -> Style {
|
||
use SemanticTokenKind::*;
|
||
let readonly = token.modifiers & SemanticTokenModifier::Readonly as u16 != 0;
|
||
let color = match token.kind {
|
||
Namespace => Color::Gray,
|
||
Type | Class | Enum | Interface | Struct | TypeParameter => Color::Cyan,
|
||
Function | Method | Event | Macro => Color::LightBlue,
|
||
Parameter | Variable => Color::LightCyan,
|
||
Property => Color::Blue,
|
||
EnumMember | Number => Color::Yellow,
|
||
Keyword | Modifier | Operator | Decorator => Color::Magenta,
|
||
String | Regexp => Color::Green,
|
||
Comment => Color::DarkGray,
|
||
};
|
||
let mut style = Style::default().fg(color);
|
||
if readonly && matches!(token.kind, Parameter | Variable | Property) {
|
||
style = style.add_modifier(ratatui::style::Modifier::ITALIC);
|
||
}
|
||
if token.modifiers
|
||
& (SemanticTokenModifier::Declaration as u16 | SemanticTokenModifier::Definition as u16)
|
||
!= 0
|
||
{
|
||
style = style.add_modifier(ratatui::style::Modifier::BOLD);
|
||
}
|
||
if token.kind == Comment && token.modifiers & SemanticTokenModifier::Documentation as u16 != 0 {
|
||
style = style.add_modifier(ratatui::style::Modifier::ITALIC);
|
||
}
|
||
if token.modifiers & SemanticTokenModifier::Deprecated as u16 != 0 {
|
||
style = style.add_modifier(ratatui::style::Modifier::CROSSED_OUT);
|
||
}
|
||
style
|
||
}
|
||
|
||
#[cfg(test)]
|
||
// These fixtures intentionally contain ranges, not lists of indices.
|
||
#[allow(clippy::single_range_in_vec_init)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::{
|
||
fs,
|
||
path::PathBuf,
|
||
sync::atomic::{AtomicUsize, Ordering},
|
||
};
|
||
|
||
struct Directory(PathBuf);
|
||
|
||
impl Directory {
|
||
fn new() -> Self {
|
||
static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
|
||
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||
let path =
|
||
std::env::temp_dir().join(format!("vamp-tui-test-{}-{id}", std::process::id()));
|
||
fs::create_dir(&path).unwrap();
|
||
Self(path)
|
||
}
|
||
|
||
fn file(&self, name: &str, text: &str) -> PathBuf {
|
||
let path = self.0.join(name);
|
||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||
fs::write(&path, text).unwrap();
|
||
path
|
||
}
|
||
}
|
||
|
||
impl Drop for Directory {
|
||
fn drop(&mut self) {
|
||
let _ = fs::remove_dir_all(&self.0);
|
||
}
|
||
}
|
||
|
||
fn screen_text(terminal: &Terminal<ratatui::backend::TestBackend>) -> String {
|
||
let buffer = terminal.backend().buffer();
|
||
let area = buffer.area;
|
||
(area.y..area.bottom())
|
||
.map(|y| {
|
||
(area.x..area.right())
|
||
.map(|x| buffer[(x, y)].symbol())
|
||
.collect::<String>()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
}
|
||
|
||
fn diff_catalog() -> crate::diff::Catalog {
|
||
use crate::diff::{Catalog, Category, RepoKind, Revision, RevisionTarget};
|
||
let mut entries = vec![Revision {
|
||
target: RevisionTarget::Working,
|
||
label: "Working checkout".into(),
|
||
description: "On-disk files".into(),
|
||
category: Category::Commits,
|
||
}];
|
||
entries.extend((0..20).map(|index| Revision {
|
||
target: RevisionTarget::Commit(format!("commit-{index:02}")),
|
||
label: format!("commit-{index:02}"),
|
||
description: if index == 19 {
|
||
"needle description".into()
|
||
} else {
|
||
"ordinary change".into()
|
||
},
|
||
category: Category::Commits,
|
||
}));
|
||
entries.push(Revision {
|
||
target: RevisionTarget::Commit("branch-tip".into()),
|
||
label: "feature-branch".into(),
|
||
description: "branch description".into(),
|
||
category: Category::Branches,
|
||
});
|
||
Catalog {
|
||
kind: RepoKind::Git,
|
||
entries,
|
||
notice: None,
|
||
}
|
||
}
|
||
|
||
fn syntax_diff_file(path: &str, before: &str, after: &str) -> ChangedFile {
|
||
let before: Vec<String> = before.split('\n').map(str::to_owned).collect();
|
||
let after: Vec<String> = after.split('\n').map(str::to_owned).collect();
|
||
ChangedFile {
|
||
before_eof: false,
|
||
after_eof: false,
|
||
path: path.into(),
|
||
old_path: None,
|
||
status: FileStatus::Modified,
|
||
hunks: vec![crate::diff::Hunk {
|
||
before_start: 0,
|
||
before_len: before.len(),
|
||
after_start: 0,
|
||
after_len: after.len(),
|
||
}],
|
||
before,
|
||
after,
|
||
notice: None,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn diff_syntax_refreshes_same_path_working_snapshots_without_live_buffer_styles() {
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap();
|
||
let mut panels = WheelPanels::default();
|
||
for (source, foreground) in [
|
||
("fn main() {}", Color::Magenta),
|
||
("// current snapshot", Color::DarkGray),
|
||
("const VALUE: u8 = 3;", Color::Magenta),
|
||
] {
|
||
let mut diff =
|
||
diff_comparison(vec![syntax_diff_file("working.rs", "fn old() {}", source)]);
|
||
diff.files_visible = false;
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
let after = panels.diff.as_ref().unwrap().regions[2].0;
|
||
let cell = &terminal.backend().buffer()[(after.x + 4, after.y + 1)];
|
||
assert_eq!(cell.fg, foreground, "{source}");
|
||
assert_eq!(cell.bg, Color::Rgb(24, 48, 32));
|
||
let before = panels.diff.as_ref().unwrap().regions[1].0;
|
||
let cell = &terminal.backend().buffer()[(before.x + 4, before.y + 1)];
|
||
assert_eq!(cell.fg, Color::Magenta);
|
||
assert_eq!(cell.bg, Color::Rgb(64, 24, 24));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn diff_syntax_uses_each_historic_path_shebang_and_injected_language() {
|
||
let mut file = syntax_diff_file("new.rs", "return 42", "fn main() {}");
|
||
file.old_path = Some("old.py".into());
|
||
let mut syntax = DiffSyntaxCache::default();
|
||
syntax.update(1, 0, &file, None);
|
||
assert_eq!(syntax.before.language(), Some("Python"));
|
||
assert_eq!(syntax.after.language(), Some("Rust"));
|
||
assert_eq!(syntax.before.line(0)[0].kind, SyntaxKind::Keyword);
|
||
assert_eq!(syntax.after.line(0)[0].kind, SyntaxKind::Keyword);
|
||
file.path = "unknown.extension".into();
|
||
syntax.update(1, 0, &file, None);
|
||
assert_eq!(syntax.after.language(), None);
|
||
assert!(syntax.after.line(0).is_empty());
|
||
|
||
let mut file = syntax_diff_file(
|
||
"script",
|
||
"```rust\nfn old() {}\n```",
|
||
"#!/usr/bin/env python3\nreturn 42",
|
||
);
|
||
file.old_path = Some("history.md".into());
|
||
let mut diff = diff_comparison(vec![file]);
|
||
diff.files_visible = false;
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap();
|
||
let mut panels = WheelPanels::default();
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
let syntax = panels.diff_syntax.as_ref().unwrap();
|
||
assert_eq!(syntax.before.language(), Some("Markdown"));
|
||
assert_eq!(syntax.after.language(), Some("Python"));
|
||
assert!(syntax.before.parse_counts().1 > 0);
|
||
assert_eq!(syntax.before.line(1)[0].kind, SyntaxKind::Keyword);
|
||
assert_eq!(syntax.after.line(1)[0].kind, SyntaxKind::Keyword);
|
||
for (region, background) in [
|
||
(
|
||
panels.diff.as_ref().unwrap().regions[1].0,
|
||
Color::Rgb(64, 24, 24),
|
||
),
|
||
(
|
||
panels.diff.as_ref().unwrap().regions[2].0,
|
||
Color::Rgb(24, 48, 32),
|
||
),
|
||
] {
|
||
let cell = &terminal.backend().buffer()[(region.x + 4, region.y + 2)];
|
||
assert_eq!(cell.fg, Color::Magenta);
|
||
assert_eq!(cell.bg, background);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn diff_syntax_parses_selected_snapshots_not_scroll_focus_or_redraw() {
|
||
let first = syntax_diff_file("a.rs", "fn old() {}\n// old", "fn main() {}\n// new");
|
||
let second = syntax_diff_file("b.rs", "// prior file", "// selected file");
|
||
let mut diff = diff_comparison(vec![first, second]);
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap();
|
||
let mut panels = WheelPanels::default();
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
let syntax = panels.diff_syntax.as_ref().unwrap();
|
||
let counts = (syntax.before.parse_counts(), syntax.after.parse_counts());
|
||
assert_eq!(counts, ((1, 0), (1, 0)));
|
||
for focus in [Focus::Before, Focus::After, Focus::Files] {
|
||
diff.focus = focus;
|
||
diff.top = 1;
|
||
diff.left_before = 1;
|
||
diff.left_after = 2;
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
let syntax = panels.diff_syntax.as_ref().unwrap();
|
||
assert_eq!(
|
||
(syntax.before.parse_counts(), syntax.after.parse_counts()),
|
||
counts
|
||
);
|
||
let after = panels.diff.as_ref().unwrap().regions[2].0;
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(after.x + 4, after.y + 1)].fg,
|
||
Color::DarkGray,
|
||
);
|
||
}
|
||
diff.files
|
||
.as_mut()
|
||
.unwrap()
|
||
.reveal_path(std::path::Path::new("b.rs"));
|
||
diff.handle(Input::Enter);
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
let syntax = panels.diff_syntax.as_ref().unwrap();
|
||
assert_eq!(syntax.before.parse_counts(), (2, 0));
|
||
assert_eq!(syntax.after.parse_counts(), (2, 0));
|
||
let after = panels.diff.as_ref().unwrap().regions[2].0;
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(after.x + 4, after.y + 1)].fg,
|
||
Color::DarkGray,
|
||
);
|
||
let editor = Editor::open(None, vec![]).unwrap();
|
||
let mut views = BufferViewports {
|
||
panels,
|
||
..BufferViewports::default()
|
||
};
|
||
terminal.draw(|frame| views.draw(frame, &editor)).unwrap();
|
||
assert!(views.panels.diff_syntax.is_none());
|
||
}
|
||
|
||
fn diff_comparison(files: Vec<ChangedFile>) -> DiffState {
|
||
static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||
let id = NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||
let mut diff = DiffState::new();
|
||
diff.take_request();
|
||
diff.apply_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog())));
|
||
diff.handle(Input::Enter);
|
||
diff.handle(Input::Enter);
|
||
diff.take_request();
|
||
diff.apply_result(
|
||
id,
|
||
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
|
||
root: std::path::PathBuf::new(),
|
||
editable_paths: Default::default(),
|
||
before: "old revision".into(),
|
||
after: "Working checkout".into(),
|
||
files,
|
||
})),
|
||
);
|
||
diff
|
||
}
|
||
|
||
fn deletion_file() -> ChangedFile {
|
||
ChangedFile {
|
||
before_eof: false,
|
||
after_eof: false,
|
||
path: "deleted-lines.txt".into(),
|
||
old_path: None,
|
||
status: FileStatus::Modified,
|
||
before: ["keep", "gone", "tail"].map(String::from).to_vec(),
|
||
after: ["keep", "tail"].map(String::from).to_vec(),
|
||
hunks: vec![crate::diff::Hunk {
|
||
before_start: 1,
|
||
before_len: 1,
|
||
after_start: 1,
|
||
after_len: 0,
|
||
}],
|
||
notice: None,
|
||
}
|
||
}
|
||
#[test]
|
||
fn diff_status_layouts_hide_absent_panes_and_leave_unchanged_renames_blank() {
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 12)).unwrap();
|
||
let mut panels = WheelPanels::default();
|
||
for status in [FileStatus::Added, FileStatus::Removed] {
|
||
let added = status == FileStatus::Added;
|
||
let mut file = deletion_file();
|
||
file.status = status;
|
||
file.before = if added {
|
||
vec![]
|
||
} else {
|
||
vec!["removed content".into()]
|
||
};
|
||
file.after = if added {
|
||
vec!["added content".into()]
|
||
} else {
|
||
vec![]
|
||
};
|
||
file.hunks = vec![crate::diff::Hunk {
|
||
before_start: 0,
|
||
before_len: usize::from(!added),
|
||
after_start: 0,
|
||
after_len: usize::from(added),
|
||
}];
|
||
let mut diff = diff_comparison(vec![file]);
|
||
diff.files_visible = false;
|
||
diff.focus = if added { Focus::After } else { Focus::Before };
|
||
diff.left_after = 2;
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
let geometry = panels.diff.as_ref().unwrap();
|
||
let present = geometry.regions[if added { 2 } else { 1 }].0;
|
||
assert_eq!(
|
||
geometry.regions[if added { 1 } else { 2 }].0,
|
||
Rect::default()
|
||
);
|
||
assert_eq!(present.x, 0);
|
||
assert_eq!(present.width, 160);
|
||
assert_eq!(geometry.viewport.unwrap().3, 0);
|
||
assert_eq!(geometry.viewport.unwrap().4, 2);
|
||
assert_eq!(geometry.regions[if added { 2 } else { 1 }].1, diff.focus);
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(present.x, present.y)].fg,
|
||
Color::Cyan
|
||
);
|
||
let text = screen_text(&terminal);
|
||
assert!(text.contains(if added {
|
||
"ded content"
|
||
} else {
|
||
"removed content"
|
||
}));
|
||
assert!(text.contains(if added { "After · A" } else { "Before · D" }));
|
||
assert!(!text.contains(if added { "Before ·" } else { "After ·" }));
|
||
assert!(!text.contains('╱'));
|
||
}
|
||
let mut empty_added = deletion_file();
|
||
empty_added.status = FileStatus::Added;
|
||
empty_added.before.clear();
|
||
empty_added.after.clear();
|
||
empty_added.hunks.clear();
|
||
empty_added.notice = Some("metadata-only addition".into());
|
||
let mut diff = diff_comparison(vec![empty_added]);
|
||
diff.files_visible = false;
|
||
diff.focus = Focus::After;
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
let geometry = panels.diff.as_ref().unwrap();
|
||
assert_eq!(geometry.regions[1].0, Rect::default());
|
||
assert_eq!(geometry.regions[2].0.width, 160);
|
||
assert!(screen_text(&terminal).contains("metadata-only addition"));
|
||
|
||
let mut renamed = deletion_file();
|
||
renamed.status = FileStatus::Renamed;
|
||
renamed.old_path = Some("old/path.txt".into());
|
||
renamed.path = "new/path.txt".into();
|
||
renamed.before = vec!["unchanged rename content".into()];
|
||
renamed.after = renamed.before.clone();
|
||
renamed.hunks.clear();
|
||
let mut diff = diff_comparison(vec![renamed]);
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
assert!(screen_text(&terminal).contains("old/path.txt -> new/path.txt"));
|
||
assert_eq!(
|
||
diff.files.as_mut().unwrap().accept(),
|
||
Some("new/path.txt".into())
|
||
);
|
||
diff.files_visible = false;
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
let geometry = panels.diff.as_ref().unwrap();
|
||
let blank = geometry.regions[1].0;
|
||
assert_eq!(blank.width, 160);
|
||
assert_eq!(geometry.regions[2].0, Rect::default());
|
||
let buffer = terminal.backend().buffer();
|
||
assert_eq!(buffer[(blank.x, blank.y)].symbol(), "┌");
|
||
for y in blank.y + 1..blank.bottom() - 1 {
|
||
for x in blank.x + 1..blank.right() - 1 {
|
||
assert_eq!(buffer[(x, y)].symbol(), " ");
|
||
assert_eq!(buffer[(x, y)].bg, Color::Reset);
|
||
}
|
||
}
|
||
let text = screen_text(&terminal);
|
||
assert!(!text.contains("Before ·"));
|
||
assert!(!text.contains("After ·"));
|
||
assert!(!text.contains("unchanged rename content"));
|
||
assert!(!text.contains('╱'));
|
||
|
||
let mut modified = diff_comparison(vec![deletion_file()]);
|
||
modified.files_visible = false;
|
||
render_diff(&mut terminal, &modified, &mut panels);
|
||
let regions = panels.diff.as_ref().unwrap().regions;
|
||
assert_eq!(regions[1].0.width, 80);
|
||
assert_eq!(regions[2].0.width, 80);
|
||
let text = screen_text(&terminal);
|
||
assert!(text.contains("Before · M"));
|
||
assert!(text.contains("After · M"));
|
||
}
|
||
|
||
fn render_diff(
|
||
terminal: &mut Terminal<ratatui::backend::TestBackend>,
|
||
diff: &DiffState,
|
||
panels: &mut WheelPanels,
|
||
) {
|
||
terminal
|
||
.draw(|frame| {
|
||
panels.begin_frame();
|
||
draw_diff(frame, diff, frame.area(), "↑/↓", "h/j/k/l", panels, None);
|
||
})
|
||
.unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn diff_picker_keeps_working_pinned_while_windowing_searching_and_changing_stage() {
|
||
let mut diff = DiffState::new();
|
||
diff.take_request();
|
||
diff.apply_result(1, Ok(crate::diff::Outcome::Catalog(diff_catalog())));
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap();
|
||
let mut panels = WheelPanels::default();
|
||
diff.handle(Input::End);
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
let text = screen_text(&terminal);
|
||
assert!(text.contains("Choose Before"));
|
||
assert!(text.contains("Working checkout · pinned"));
|
||
assert!(text.contains("> commit-19"));
|
||
assert!(!text.contains("commit-00"));
|
||
for ch in "needle".chars() {
|
||
diff.handle(Input::Char(ch));
|
||
}
|
||
diff.handle(Input::Down);
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
let text = screen_text(&terminal);
|
||
assert!(text.contains("> needle"));
|
||
assert!(text.contains("Working checkout · pinned"));
|
||
assert!(text.contains("> commit-19"));
|
||
diff.handle(Input::Enter);
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
assert!(screen_text(&terminal).contains("Choose After · Before: commit-19"));
|
||
diff.handle(Input::Escape);
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
assert!(screen_text(&terminal).contains("> needle"));
|
||
assert!(screen_text(&terminal).contains("> commit-19"));
|
||
diff.handle(Input::Ctrl('u'));
|
||
diff.handle(Input::Char('\t'));
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
let text = screen_text(&terminal);
|
||
assert!(text.contains("feature-branch"));
|
||
assert!(!text.contains("ordinary change"));
|
||
assert!(text.contains("Working checkout · pinned"));
|
||
let buffer = terminal.backend().buffer();
|
||
assert!(
|
||
buffer
|
||
.content
|
||
.iter()
|
||
.any(|cell| cell.symbol() == "B" && cell.bg == Color::Cyan)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn diff_excerpts_render_original_lines_and_expand_without_losing_changes() {
|
||
let before: Vec<_> = (0..80)
|
||
.map(|index| format!("let row_{index:03} = {index};"))
|
||
.collect();
|
||
let mut after = before.clone();
|
||
after[30] = "let first_change = 900;".into();
|
||
after[60] = "let second_change = 901;".into();
|
||
let mut diff = diff_comparison(vec![ChangedFile {
|
||
before_eof: false,
|
||
after_eof: false,
|
||
path: "excerpts.rs".into(),
|
||
old_path: None,
|
||
status: FileStatus::Modified,
|
||
before,
|
||
after,
|
||
hunks: [30, 60]
|
||
.map(|index| crate::diff::Hunk {
|
||
before_start: index,
|
||
before_len: 1,
|
||
after_start: index,
|
||
after_len: 1,
|
||
})
|
||
.into(),
|
||
notice: None,
|
||
}]);
|
||
diff.set_context_options(1, 3);
|
||
diff.files_visible = false;
|
||
diff.focus = Focus::Before;
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 24)).unwrap();
|
||
let mut panels = WheelPanels::default();
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
let text = screen_text(&terminal);
|
||
for visible in [
|
||
"30 let row_029",
|
||
"31- let row_030",
|
||
"31+ let first_change",
|
||
"61+ let second_change",
|
||
] {
|
||
assert!(text.contains(visible), "missing {visible}: {text}");
|
||
}
|
||
for hidden in ["row_028", "row_032", "row_058", "row_062"] {
|
||
assert!(!text.contains(hidden), "unexpected {hidden}: {text}");
|
||
}
|
||
let before_area = panels.diff.as_ref().unwrap().regions[1].0;
|
||
let after_area = panels.diff.as_ref().unwrap().regions[2].0;
|
||
let buffer = terminal.backend().buffer();
|
||
assert_eq!(
|
||
buffer[(before_area.x + 5, before_area.y + 3)].fg,
|
||
Color::Magenta
|
||
);
|
||
assert_eq!(
|
||
buffer[(before_area.x + 5, before_area.y + 3)].bg,
|
||
Color::Rgb(64, 24, 24)
|
||
);
|
||
assert_eq!(
|
||
buffer[(after_area.x + 5, after_area.y + 3)].bg,
|
||
Color::Rgb(24, 48, 32)
|
||
);
|
||
diff.handle(Input::Char('['));
|
||
diff.handle(Input::Home);
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
let text = screen_text(&terminal);
|
||
assert!(text.contains("row_026"));
|
||
assert!(!text.contains("row_025"));
|
||
assert!(text.contains("first_change"));
|
||
assert!(text.contains("second_change"));
|
||
}
|
||
|
||
#[test]
|
||
fn diff_view_aligns_deletion_holes_numbers_colors_and_shared_vertical_scroll() {
|
||
let mut diff = diff_comparison(vec![deletion_file()]);
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
|
||
let mut panels = WheelPanels::default();
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
let geometry = panels.diff.as_ref().unwrap();
|
||
let before = geometry.regions[1].0;
|
||
let after = geometry.regions[2].0;
|
||
let buffer = terminal.backend().buffer();
|
||
let y = before.y + 2;
|
||
assert_eq!(buffer[(before.x + 4, y)].symbol(), "g");
|
||
assert_eq!(buffer[(before.x + 4, y)].fg, Color::Reset);
|
||
assert_eq!(buffer[(before.x + 2, y)].symbol(), "-");
|
||
for x in before.x + 1..before.right() - 1 {
|
||
assert_eq!(buffer[(x, y)].bg, Color::Rgb(64, 24, 24));
|
||
assert_eq!(buffer[(x, y + 1)].bg, Color::Reset);
|
||
}
|
||
let gap_symbols: Vec<_> = (after.x + 1..after.right() - 1)
|
||
.map(|x| {
|
||
let cell = &buffer[(x, y)];
|
||
assert_eq!(cell.symbol(), "╱");
|
||
assert_eq!(cell.bg, Color::Reset);
|
||
assert_eq!(cell.fg, Color::Rgb(72, 72, 72));
|
||
cell.symbol().to_owned()
|
||
})
|
||
.collect();
|
||
assert_eq!(buffer[(before.x + 1, y + 1)].symbol(), "3");
|
||
assert_eq!(buffer[(after.x + 1, y + 1)].symbol(), "2");
|
||
assert_eq!(buffer[(before.x + 4, y + 1)].symbol(), "t");
|
||
assert_eq!(buffer[(after.x + 4, y + 1)].symbol(), "t");
|
||
diff.handle(Input::Char('\t'));
|
||
diff.handle(Input::Down);
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
let buffer = terminal.backend().buffer();
|
||
assert_eq!(buffer[(before.x + 4, before.y + 1)].symbol(), "g");
|
||
for (x, symbol) in (after.x + 1..after.right() - 1).zip(gap_symbols) {
|
||
assert_eq!(buffer[(x, after.y + 1)].symbol(), symbol);
|
||
}
|
||
assert_eq!(buffer[(before.x, before.y)].fg, Color::Cyan);
|
||
assert_eq!(buffer[(after.x, after.y)].fg, Color::DarkGray);
|
||
assert!(screen_text(&terminal).contains("Before focus"));
|
||
}
|
||
|
||
#[test]
|
||
fn diff_added_rows_hatch_missing_before_but_tint_real_empty_after_lines() {
|
||
let mut diff = diff_comparison(vec![ChangedFile {
|
||
before_eof: false,
|
||
after_eof: false,
|
||
path: "added.txt".into(),
|
||
old_path: None,
|
||
status: FileStatus::Modified,
|
||
before: vec![],
|
||
after: vec!["first addition".into(), String::new()],
|
||
hunks: vec![crate::diff::Hunk {
|
||
before_start: 0,
|
||
before_len: 0,
|
||
after_start: 0,
|
||
after_len: 2,
|
||
}],
|
||
notice: None,
|
||
}]);
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
|
||
let mut panels = WheelPanels::default();
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
let geometry = panels.diff.as_ref().unwrap();
|
||
let before = geometry.regions[1].0;
|
||
let after = geometry.regions[2].0;
|
||
let buffer = terminal.backend().buffer();
|
||
for offset in 0..2 {
|
||
let y = before.y + 1 + offset;
|
||
for x in before.x + 1..before.right() - 1 {
|
||
let cell = &buffer[(x, y)];
|
||
assert_eq!(cell.symbol(), "╱");
|
||
assert_eq!(cell.bg, Color::Reset);
|
||
assert_eq!(cell.fg, Color::Rgb(72, 72, 72));
|
||
}
|
||
assert_eq!(buffer[(after.x + 1, y)].symbol(), (offset + 1).to_string());
|
||
assert_eq!(buffer[(after.x + 2, y)].symbol(), "+");
|
||
assert_eq!(buffer[(after.x + 2, y)].fg, Color::Green);
|
||
assert_eq!(buffer[(after.x + 4, y)].fg, Color::Reset);
|
||
for x in after.x + 1..after.right() - 1 {
|
||
assert_eq!(buffer[(x, y)].bg, Color::Rgb(24, 48, 32));
|
||
if offset == 1 && x >= after.x + 4 {
|
||
assert_eq!(buffer[(x, y)].symbol(), " ");
|
||
}
|
||
}
|
||
}
|
||
diff.top = 1;
|
||
diff.left_before = 1;
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
let buffer = terminal.backend().buffer();
|
||
for x in before.x + 1..before.right() - 1 {
|
||
assert_eq!(buffer[(x, before.y + 1)].symbol(), "╱");
|
||
}
|
||
assert_eq!(buffer[(before.x, before.y + 1)].symbol(), "│");
|
||
assert_eq!(buffer[(before.right() - 1, before.y + 1)].symbol(), "│");
|
||
}
|
||
|
||
#[test]
|
||
fn diff_changed_backgrounds_preserve_syntax_and_clipped_source_cells() {
|
||
let mut diff = diff_comparison(vec![ChangedFile {
|
||
before_eof: false,
|
||
after_eof: false,
|
||
path: "changed.rs".into(),
|
||
old_path: None,
|
||
status: FileStatus::Modified,
|
||
before: [
|
||
"fn old() {",
|
||
"\tlet word = \"界e\u{301}old\";",
|
||
"\tlet count = 42; //\u{1b}note",
|
||
"",
|
||
"}",
|
||
]
|
||
.map(String::from)
|
||
.to_vec(),
|
||
after: [
|
||
"fn new() {",
|
||
"\tlet word = \"界e\u{301}new\";",
|
||
"\tlet count = 24; //\u{1b}note",
|
||
"",
|
||
"}",
|
||
]
|
||
.map(String::from)
|
||
.to_vec(),
|
||
hunks: vec![crate::diff::Hunk {
|
||
before_start: 0,
|
||
before_len: 5,
|
||
after_start: 0,
|
||
after_len: 5,
|
||
}],
|
||
notice: None,
|
||
}]);
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 12)).unwrap();
|
||
let mut panels = WheelPanels::default();
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
let geometry = panels.diff.as_ref().unwrap();
|
||
let before = geometry.regions[1].0;
|
||
let after = geometry.regions[2].0;
|
||
let buffer = terminal.backend().buffer();
|
||
for (pane, background) in [
|
||
(before, Color::Rgb(64, 24, 24)),
|
||
(after, Color::Rgb(24, 48, 32)),
|
||
] {
|
||
for row in 0..5 {
|
||
// TestBackend emits the wide glyph, not its continuation cells.
|
||
let mut x = pane.x + 1;
|
||
while x < pane.right() - 1 {
|
||
assert_eq!(
|
||
buffer[(x, pane.y + 1 + row)].bg,
|
||
background,
|
||
"pane={pane:?} x={x} row={row} symbol={:?}",
|
||
buffer[(x, pane.y + 1 + row)].symbol()
|
||
);
|
||
x += UnicodeWidthStr::width(buffer[(x, pane.y + 1 + row)].symbol()).max(1)
|
||
as u16;
|
||
}
|
||
}
|
||
for (column, row, foreground) in [
|
||
(0, 0, Color::Magenta),
|
||
(3, 0, Color::LightBlue),
|
||
(4, 1, Color::Magenta),
|
||
(15, 1, Color::Green),
|
||
(16, 1, Color::Green),
|
||
(18, 1, Color::Green),
|
||
(16, 2, Color::Yellow),
|
||
(22, 2, Color::DarkGray),
|
||
(23, 2, Color::DarkGray),
|
||
] {
|
||
assert_eq!(
|
||
buffer[(pane.x + 4 + column, pane.y + 1 + row)].fg,
|
||
foreground
|
||
);
|
||
}
|
||
assert_eq!(buffer[(pane.x + 20, pane.y + 2)].symbol(), "界");
|
||
assert_eq!(buffer[(pane.x + 22, pane.y + 2)].symbol(), "e\u{301}");
|
||
assert_eq!(buffer[(pane.x + 26, pane.y + 3)].symbol(), "�");
|
||
assert_eq!(buffer[(pane.x + 27, pane.y + 3)].symbol(), "n");
|
||
for x in pane.x + 4..pane.right() - 1 {
|
||
assert_eq!(buffer[(x, pane.y + 4)].symbol(), " ");
|
||
assert_eq!(buffer[(x, pane.y + 4)].fg, Color::Reset);
|
||
}
|
||
}
|
||
diff.top = 1;
|
||
diff.left_before = 17;
|
||
diff.left_after = 16;
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
let buffer = terminal.backend().buffer();
|
||
assert_eq!(buffer[(before.x + 4, before.y + 1)].symbol(), " ");
|
||
assert_eq!(buffer[(before.x + 4, before.y + 1)].fg, Color::Reset);
|
||
assert_eq!(
|
||
buffer[(before.x + 4, before.y + 1)].bg,
|
||
Color::Rgb(64, 24, 24)
|
||
);
|
||
assert_eq!(buffer[(before.x + 5, before.y + 1)].symbol(), "e\u{301}");
|
||
assert_eq!(buffer[(before.x + 5, before.y + 1)].fg, Color::Green);
|
||
assert_eq!(buffer[(after.x + 4, after.y + 1)].symbol(), "界");
|
||
assert_eq!(buffer[(after.x + 4, after.y + 1)].fg, Color::Green);
|
||
assert_eq!(
|
||
buffer[(after.x + 4, after.y + 1)].bg,
|
||
Color::Rgb(24, 48, 32)
|
||
);
|
||
assert_eq!(buffer[(after.x + 6, after.y + 1)].symbol(), "e\u{301}");
|
||
assert_eq!(buffer[(after.x + 6, after.y + 1)].fg, Color::Green);
|
||
}
|
||
|
||
#[test]
|
||
fn diff_view_changes_files_focus_and_keeps_horizontal_offsets_independent() {
|
||
let added = ChangedFile {
|
||
before_eof: false,
|
||
after_eof: false,
|
||
path: "new-name.txt".into(),
|
||
old_path: Some("old-name.txt".into()),
|
||
status: FileStatus::Renamed,
|
||
before: vec!["\t界e\u{301}OLD".into()],
|
||
after: vec!["\t界e\u{301}NEW".into()],
|
||
hunks: vec![crate::diff::Hunk {
|
||
before_start: 0,
|
||
before_len: 1,
|
||
after_start: 0,
|
||
after_len: 1,
|
||
}],
|
||
notice: None,
|
||
};
|
||
let mut diff = diff_comparison(vec![deletion_file(), added]);
|
||
diff.handle(Input::Down);
|
||
diff.handle(Input::Enter);
|
||
for _ in 0..4 {
|
||
diff.handle(Input::Right);
|
||
}
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
|
||
let mut panels = WheelPanels::default();
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
let text = screen_text(&terminal);
|
||
assert!(text.contains("Before · R · old-name.txt"));
|
||
assert!(text.contains("After · R · new-name.txt"));
|
||
let geometry = panels.diff.as_ref().unwrap();
|
||
let before = geometry.regions[1].0;
|
||
let after = geometry.regions[2].0;
|
||
let buffer = terminal.backend().buffer();
|
||
assert_eq!(buffer[(before.x + 4, before.y + 1)].symbol(), "界");
|
||
assert_eq!(buffer[(before.x + 6, before.y + 1)].symbol(), "e\u{301}");
|
||
assert_eq!(buffer[(after.x + 4, after.y + 1)].symbol(), " ");
|
||
assert_eq!(buffer[(after.x + 8, after.y + 1)].symbol(), "界");
|
||
assert_eq!(buffer[(after.x + 8, after.y + 1)].fg, Color::Reset);
|
||
assert_eq!(
|
||
buffer[(after.x + 8, after.y + 1)].bg,
|
||
Color::Rgb(24, 48, 32)
|
||
);
|
||
assert_eq!(
|
||
buffer[(before.x + 4, before.y + 1)].bg,
|
||
Color::Rgb(64, 24, 24)
|
||
);
|
||
diff.handle(Input::Char('\t'));
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
assert!(screen_text(&terminal).contains("After focus"));
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(after.x, after.y)].fg,
|
||
Color::Cyan
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn diff_reuses_right_explorer_compact_tree_and_tracks_browse_focus() {
|
||
let mut leaf = deletion_file();
|
||
leaf.path = "chain/a/b/leaf.txt".into();
|
||
let mut root = deletion_file();
|
||
root.path = "root.txt".into();
|
||
let mut diff = diff_comparison(vec![leaf, root]);
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap();
|
||
let mut panels = WheelPanels::default();
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
let regions = panels.diff.as_ref().unwrap().regions;
|
||
let navigator = regions[0].0;
|
||
let before = regions[1].0;
|
||
let after = regions[2].0;
|
||
let workspace = Rect::new(0, 1, 120, 16);
|
||
let (panes, dock) = dock_layout(workspace, DockSide::Right, true);
|
||
assert_eq!(Some(navigator), dock);
|
||
assert_eq!(before.x, panes.x);
|
||
assert_eq!(before.right(), after.x);
|
||
assert_eq!(after.right(), navigator.x);
|
||
assert_eq!(navigator.right(), workspace.right());
|
||
assert_eq!(panels.files.unwrap().0, navigator);
|
||
let tree_text = |terminal: &Terminal<ratatui::backend::TestBackend>| {
|
||
let buffer = terminal.backend().buffer();
|
||
(navigator.y..navigator.bottom())
|
||
.map(|y| {
|
||
(navigator.x..navigator.right())
|
||
.map(|x| buffer[(x, y)].symbol())
|
||
.collect::<String>()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
};
|
||
let text = tree_text(&terminal);
|
||
assert!(text.contains("Explorer · Tree"));
|
||
assert!(text.contains("chain/a/b/"));
|
||
assert!(text.contains("leaf.txt"));
|
||
assert!(!text.contains("chain/a/b/leaf.txt"));
|
||
let cursor = terminal.get_cursor_position().unwrap();
|
||
assert!(navigator.contains(cursor));
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(cursor.x, cursor.y)].bg,
|
||
Color::Blue
|
||
);
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(navigator.x, navigator.y)].fg,
|
||
Color::Cyan
|
||
);
|
||
diff.handle(Input::Left);
|
||
diff.handle(Input::Enter);
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
let text = tree_text(&terminal);
|
||
assert!(!text.contains("leaf.txt"));
|
||
assert!(text.contains("chain/a/b/"));
|
||
assert_eq!(diff.file, 0);
|
||
diff.handle(Input::Char('\t'));
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
assert!(!diff.files.as_ref().unwrap().focused);
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(navigator.x, navigator.y)].fg,
|
||
Color::DarkGray
|
||
);
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(before.x, before.y)].fg,
|
||
Color::Cyan
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn changed_file_markers_follow_catalog_indices_in_every_explorer_view() {
|
||
let files: Vec<_> = [
|
||
("z.txt", FileStatus::Added),
|
||
("chain/a/m.txt", FileStatus::Modified),
|
||
("d.txt", FileStatus::Removed),
|
||
("r.txt", FileStatus::Renamed),
|
||
]
|
||
.into_iter()
|
||
.map(|(path, status)| {
|
||
let mut file = deletion_file();
|
||
file.path = path.into();
|
||
file.status = status;
|
||
file.old_path = (status == FileStatus::Renamed).then(|| "old\nname.txt".into());
|
||
file
|
||
})
|
||
.collect();
|
||
let mut picker = FilePicker::from_paths(
|
||
PathBuf::new(),
|
||
files
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(index, file)| (index, file.path.clone())),
|
||
);
|
||
picker.reveal_path(std::path::Path::new("chain/a/m.txt"));
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap();
|
||
for view in [FileView::Tree, FileView::List] {
|
||
picker.set_view(view);
|
||
for focused in [true, false] {
|
||
picker.focused = focused;
|
||
for index in 0..files.len() {
|
||
picker.reveal_path(&files[index].path);
|
||
terminal
|
||
.draw(|frame| {
|
||
draw_file_list(
|
||
frame,
|
||
&picker,
|
||
frame.area(),
|
||
"",
|
||
&mut WheelPanels::default(),
|
||
Some(&files),
|
||
);
|
||
})
|
||
.unwrap();
|
||
let buffer = terminal.backend().buffer();
|
||
for (file, color) in
|
||
files
|
||
.iter()
|
||
.zip([Color::Green, Color::Yellow, Color::Red, Color::Cyan])
|
||
{
|
||
let path = if view == FileView::Tree {
|
||
std::path::Path::new(file.path.file_name().unwrap())
|
||
} else {
|
||
file.path.as_path()
|
||
};
|
||
let expected = if file.status == FileStatus::Renamed {
|
||
format!("R old�name.txt -> {}", file.path.to_string_lossy())
|
||
} else {
|
||
format!("{} {}", diff_status(file.status), path.to_string_lossy())
|
||
};
|
||
let (x, y) = (0..16)
|
||
.find_map(|y| {
|
||
let row = (0..100)
|
||
.map(|x| buffer[(x, y)].symbol())
|
||
.collect::<String>();
|
||
row.find(&expected).map(|offset| {
|
||
(UnicodeWidthStr::width(&row[..offset]) as u16, y)
|
||
})
|
||
})
|
||
.unwrap_or_else(|| panic!("missing {expected}"));
|
||
assert_eq!(buffer[(x, y)].fg, color);
|
||
}
|
||
assert_eq!(picker.accept(), Some(files[index].path.clone()));
|
||
assert!(picker.query.is_empty());
|
||
}
|
||
}
|
||
}
|
||
// A reverted first file disappears without shifting the remaining status markers.
|
||
picker.replace_paths(
|
||
files
|
||
.iter()
|
||
.enumerate()
|
||
.skip(1)
|
||
.map(|(index, file)| (index, file.path.clone())),
|
||
);
|
||
picker.query = "m.txt".into();
|
||
picker.refilter();
|
||
terminal
|
||
.draw(|frame| {
|
||
draw_file_list(
|
||
frame,
|
||
&picker,
|
||
frame.area(),
|
||
"",
|
||
&mut WheelPanels::default(),
|
||
Some(&files),
|
||
);
|
||
})
|
||
.unwrap();
|
||
assert!(screen_text(&terminal).contains("M chain/a/m.txt"));
|
||
assert!(!screen_text(&terminal).contains("R r.txt"));
|
||
terminal
|
||
.draw(|frame| {
|
||
draw_file_list(
|
||
frame,
|
||
&picker,
|
||
frame.area(),
|
||
"",
|
||
&mut WheelPanels::default(),
|
||
None,
|
||
);
|
||
})
|
||
.unwrap();
|
||
assert!(!screen_text(&terminal).contains("M chain/a/m.txt"));
|
||
}
|
||
|
||
#[test]
|
||
fn hiding_diff_explorer_reclaims_workspace_and_restores_its_geometry() {
|
||
let mut diff = diff_comparison(vec![deletion_file()]);
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap();
|
||
let mut panels = WheelPanels::default();
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
let original = panels.diff.as_ref().unwrap().regions;
|
||
diff.files_visible = false;
|
||
diff.focus = Focus::Before;
|
||
diff.files.as_mut().unwrap().focused = false;
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
let hidden = panels.diff.as_ref().unwrap().regions;
|
||
assert_eq!(hidden[0].0, Rect::default());
|
||
assert!(panels.files.is_none());
|
||
assert_eq!(hidden[1].0.x, 0);
|
||
assert_eq!(hidden[1].0.right(), hidden[2].0.x);
|
||
assert_eq!(hidden[2].0.right(), 120);
|
||
assert!(hidden[1].0.width + hidden[2].0.width > original[1].0.width + original[2].0.width);
|
||
assert!(!screen_text(&terminal).contains("Explorer"));
|
||
diff.files_visible = true;
|
||
diff.focus = Focus::Files;
|
||
diff.files.as_mut().unwrap().focused = true;
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
assert_eq!(panels.diff.as_ref().unwrap().regions, original);
|
||
assert_eq!(panels.files.unwrap().0, original[0].0);
|
||
}
|
||
|
||
#[test]
|
||
fn diff_notices_empty_comparisons_and_tiny_layouts_remain_renderable() {
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
|
||
let mut panels = WheelPanels::default();
|
||
let mut diff = DiffState::new();
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
assert!(screen_text(&terminal).contains("Loading repository"));
|
||
diff.take_request();
|
||
diff.apply_result(1, Err("permission denied".into()));
|
||
render_diff(&mut terminal, &diff, &mut panels);
|
||
assert!(screen_text(&terminal).contains("permission denied"));
|
||
let empty = diff_comparison(vec![]);
|
||
render_diff(&mut terminal, &empty, &mut panels);
|
||
assert!(screen_text(&terminal).contains("No changed files"));
|
||
assert!(screen_text(&terminal).contains("Explorer · Tree"));
|
||
let binary = diff_comparison(vec![ChangedFile {
|
||
before_eof: false,
|
||
after_eof: false,
|
||
path: "image.bin".into(),
|
||
old_path: None,
|
||
status: FileStatus::Added,
|
||
before: vec![],
|
||
after: vec![],
|
||
hunks: vec![],
|
||
notice: Some("Binary file; text comparison unavailable".into()),
|
||
}]);
|
||
render_diff(&mut terminal, &binary, &mut panels);
|
||
assert!(screen_text(&terminal).contains("Binary file"));
|
||
let blank = diff_comparison(vec![ChangedFile {
|
||
before_eof: false,
|
||
after_eof: false,
|
||
path: "empty.txt".into(),
|
||
old_path: None,
|
||
status: FileStatus::Added,
|
||
before: vec![],
|
||
after: vec![],
|
||
hunks: vec![],
|
||
notice: None,
|
||
}]);
|
||
render_diff(&mut terminal, &blank, &mut panels);
|
||
assert!(screen_text(&terminal).contains("Empty file"));
|
||
for (width, height) in [(1, 1), (2, 2), (3, 3), (8, 4), (39, 5)] {
|
||
let mut tiny =
|
||
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
|
||
for state in [&diff, &empty, &binary, &blank] {
|
||
render_diff(&mut tiny, state, &mut panels);
|
||
if let Some(geometry) = &panels.diff {
|
||
for (rect, _) in geometry.regions {
|
||
assert!(rect.right() <= width && rect.bottom() <= height);
|
||
}
|
||
let [navigator, before, after] = geometry.regions.map(|(rect, _)| rect);
|
||
if before.width > 0 && after.width > 0 {
|
||
assert!(before.right() <= after.x);
|
||
}
|
||
if navigator.width > 0 {
|
||
assert!(before.right().max(after.right()) <= navigator.x);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn immutable_diff_draw_path_preserves_document_until_explicit_close() {
|
||
let directory = Directory::new();
|
||
let mut editor = Editor::open(
|
||
Some(directory.file("source.txt", "original\nsecond")),
|
||
vec![],
|
||
)
|
||
.unwrap();
|
||
let original = editor.lines().to_vec();
|
||
let cursor = editor.cursor();
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
|
||
let mut viewports = BufferViewports::default();
|
||
terminal
|
||
.draw(|frame| viewports.draw(frame, &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 {
|
||
root: std::path::PathBuf::new(),
|
||
editable_paths: Default::default(),
|
||
before: "old revision".into(),
|
||
after: "Working checkout".into(),
|
||
files: vec![deletion_file()],
|
||
})),
|
||
);
|
||
editor.handle(Input::Paste("must not edit".into()));
|
||
editor.handle(Input::Char('i'));
|
||
terminal
|
||
.draw(|frame| viewports.draw(frame, &editor))
|
||
.unwrap();
|
||
assert!(screen_text(&terminal).contains("deleted-lines.txt"));
|
||
assert!(viewports.document.geometry.is_none());
|
||
editor.handle(Input::Escape);
|
||
assert!(editor.diff().is_some());
|
||
editor.execute("diff.close");
|
||
terminal
|
||
.draw(|frame| viewports.draw(frame, &editor))
|
||
.unwrap();
|
||
assert!(screen_text(&terminal).contains("original"));
|
||
assert!(viewports.document.geometry.is_some());
|
||
assert_eq!(editor.lines(), original);
|
||
assert_eq!(editor.cursor(), cursor);
|
||
}
|
||
|
||
fn readonly_diff_editor(file: ChangedFile) -> Editor {
|
||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||
editor.set_clipboard_mode("separate").unwrap();
|
||
editor.execute("diff");
|
||
let (id, _) = editor.take_diff_request().unwrap();
|
||
editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog())));
|
||
editor.handle(Input::Down);
|
||
editor.handle(Input::Enter);
|
||
editor.handle(Input::Down);
|
||
editor.handle(Input::Enter);
|
||
let (id, _) = editor.take_diff_request().unwrap();
|
||
editor.apply_diff_result(
|
||
id,
|
||
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
|
||
root: std::path::PathBuf::new(),
|
||
editable_paths: Default::default(),
|
||
before: "baseline".into(),
|
||
after: "historic".into(),
|
||
files: vec![file],
|
||
})),
|
||
);
|
||
editor
|
||
}
|
||
|
||
fn render_native_diff(
|
||
terminal: &mut Terminal<ratatui::backend::TestBackend>,
|
||
views: &mut BufferViewports,
|
||
editor: &mut Editor,
|
||
) -> Position {
|
||
terminal.draw(|frame| views.draw(frame, editor)).unwrap();
|
||
let (id, file, top, before, after) = views.panels.diff.as_ref().unwrap().viewport.unwrap();
|
||
editor.set_diff_viewport(id, file, top, before, after);
|
||
terminal.get_cursor_position().unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn readonly_diff_panes_render_native_caret_visual_search_and_paired_focus() {
|
||
let mut editor = readonly_diff_editor(syntax_diff_file(
|
||
"history.rs",
|
||
"fn old() {}\nlet value = \"old\";",
|
||
"fn new() {}\nlet value = \"new\";",
|
||
));
|
||
editor.handle(Input::Char('\t'));
|
||
assert!(editor.diff_interacting());
|
||
assert!(!editor.diff_editing());
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap();
|
||
let mut views = BufferViewports::default();
|
||
let caret = render_native_diff(&mut terminal, &mut views, &mut editor);
|
||
let before = views.panels.diff.as_ref().unwrap().regions[1].0;
|
||
assert!(before.contains(caret));
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(caret.x, caret.y)].symbol(),
|
||
"f"
|
||
);
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(caret.x, caret.y)].fg,
|
||
Color::Magenta
|
||
);
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(caret.x, caret.y)].bg,
|
||
Color::Rgb(64, 24, 24)
|
||
);
|
||
editor.handle(Input::Char('v'));
|
||
editor.handle(Input::Right);
|
||
render_native_diff(&mut terminal, &mut views, &mut editor);
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(caret.x, caret.y)].bg,
|
||
Color::Blue
|
||
);
|
||
editor.handle(Input::Escape);
|
||
editor.handle(Input::Down);
|
||
editor.handle(Input::Home);
|
||
editor.handle(Input::Char('V'));
|
||
render_native_diff(&mut terminal, &mut views, &mut editor);
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(caret.x, caret.y + 1)].bg,
|
||
Color::Blue
|
||
);
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(caret.x + 16, caret.y + 1)].bg,
|
||
Color::Blue
|
||
);
|
||
editor.handle(Input::Escape);
|
||
editor.handle(Input::Char('/'));
|
||
for ch in "value".chars() {
|
||
editor.handle(Input::Char(ch));
|
||
}
|
||
render_native_diff(&mut terminal, &mut views, &mut editor);
|
||
assert_eq!(terminal.get_cursor_position().unwrap().y, 13);
|
||
editor.handle(Input::Enter);
|
||
let searched = render_native_diff(&mut terminal, &mut views, &mut editor);
|
||
assert!(before.contains(searched));
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(searched.x, searched.y)].bg,
|
||
Color::Yellow
|
||
);
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(searched.x, searched.y)].fg,
|
||
Color::Black
|
||
);
|
||
let before_row = editor
|
||
.diff()
|
||
.unwrap()
|
||
.source_display_row(Focus::Before, editor.cursor().row)
|
||
.unwrap();
|
||
let column = display_column(&editor.lines()[editor.cursor().row], editor.cursor().column);
|
||
editor.handle(Input::Char('\t'));
|
||
assert!(editor.diff_interacting());
|
||
assert!(!editor.diff_editing());
|
||
let switched = render_native_diff(&mut terminal, &mut views, &mut editor);
|
||
let after = views.panels.diff.as_ref().unwrap().regions[2].0;
|
||
assert!(after.contains(switched));
|
||
assert_eq!(switched.y, searched.y);
|
||
assert_eq!(switched.x - after.x, searched.x - before.x);
|
||
assert_eq!(
|
||
editor
|
||
.diff()
|
||
.unwrap()
|
||
.source_display_row(Focus::After, editor.cursor().row),
|
||
Some(before_row)
|
||
);
|
||
assert_eq!(
|
||
display_column(&editor.lines()[editor.cursor().row], editor.cursor().column),
|
||
column
|
||
);
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(switched.x, switched.y)].bg,
|
||
Color::Rgb(24, 48, 32)
|
||
);
|
||
editor.handle(Input::Right);
|
||
let moved = render_native_diff(&mut terminal, &mut views, &mut editor);
|
||
editor.handle(Input::Char('\t'));
|
||
editor.handle(Input::Char('\t'));
|
||
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
|
||
let returned = render_native_diff(&mut terminal, &mut views, &mut editor);
|
||
assert!(before.contains(returned));
|
||
assert_eq!(returned.y, moved.y);
|
||
assert_eq!(returned.x - before.x, moved.x - after.x);
|
||
assert_eq!(returned.x, searched.x + 1);
|
||
}
|
||
|
||
#[test]
|
||
fn readonly_diff_viewport_retains_both_offsets_on_direction_and_focus_reversal() {
|
||
let before = (0..80)
|
||
.map(|row| format!("old row {row:03} {}", "x".repeat(100)))
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
let after = before.replace("old row", "new row");
|
||
let mut editor = readonly_diff_editor(syntax_diff_file("history.txt", &before, &after));
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap();
|
||
let mut views = BufferViewports::default();
|
||
let mut retained_before_left = 0;
|
||
for focus in [Focus::Before, Focus::After] {
|
||
editor.handle(Input::Char('\t'));
|
||
assert_eq!(editor.diff().unwrap().focus, focus);
|
||
assert!(editor.diff_interacting());
|
||
assert!(!editor.diff_editing());
|
||
render_native_diff(&mut terminal, &mut views, &mut editor);
|
||
for _ in 0..40 {
|
||
editor.handle(Input::Down);
|
||
render_native_diff(&mut terminal, &mut views, &mut editor);
|
||
}
|
||
let top = editor.diff().unwrap().top;
|
||
assert!(top > 0);
|
||
let bottom = render_native_diff(&mut terminal, &mut views, &mut editor);
|
||
editor.handle(Input::Up);
|
||
let reversed = render_native_diff(&mut terminal, &mut views, &mut editor);
|
||
assert_eq!(editor.diff().unwrap().top, top);
|
||
assert_eq!(reversed.y + 1, bottom.y);
|
||
while editor
|
||
.diff()
|
||
.unwrap()
|
||
.source_display_row(focus, editor.cursor().row)
|
||
.unwrap()
|
||
> top
|
||
{
|
||
editor.handle(Input::Up);
|
||
render_native_diff(&mut terminal, &mut views, &mut editor);
|
||
assert_eq!(editor.diff().unwrap().top, top);
|
||
}
|
||
editor.handle(Input::Up);
|
||
render_native_diff(&mut terminal, &mut views, &mut editor);
|
||
assert!(editor.diff().unwrap().top < top);
|
||
for _ in 0..80 {
|
||
editor.handle(Input::Right);
|
||
render_native_diff(&mut terminal, &mut views, &mut editor);
|
||
}
|
||
let left = if focus == Focus::Before {
|
||
editor.diff().unwrap().left_before
|
||
} else {
|
||
editor.diff().unwrap().left_after
|
||
};
|
||
assert!(left > 0);
|
||
let right = render_native_diff(&mut terminal, &mut views, &mut editor);
|
||
editor.handle(Input::Left);
|
||
let reversed = render_native_diff(&mut terminal, &mut views, &mut editor);
|
||
let offsets = views.panels.diff.as_ref().unwrap().viewport.unwrap();
|
||
assert_eq!(
|
||
if focus == Focus::Before {
|
||
offsets.3
|
||
} else {
|
||
offsets.4
|
||
},
|
||
left
|
||
);
|
||
assert_eq!(reversed.x + 1, right.x);
|
||
while editor.cursor().column > left {
|
||
editor.handle(Input::Left);
|
||
render_native_diff(&mut terminal, &mut views, &mut editor);
|
||
let diff = editor.diff().unwrap();
|
||
assert_eq!(
|
||
if focus == Focus::Before {
|
||
diff.left_before
|
||
} else {
|
||
diff.left_after
|
||
},
|
||
left
|
||
);
|
||
}
|
||
editor.handle(Input::Left);
|
||
render_native_diff(&mut terminal, &mut views, &mut editor);
|
||
if focus == Focus::Before {
|
||
retained_before_left = editor.diff().unwrap().left_before;
|
||
assert!(retained_before_left < left);
|
||
} else {
|
||
assert!(editor.diff().unwrap().left_after < left);
|
||
assert_eq!(editor.diff().unwrap().left_before, retained_before_left);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn diff_semantic_colors_stay_buffer_local_across_focus_changes() {
|
||
for writable in [false, true] {
|
||
let directory = Directory::new();
|
||
let path = directory.file("live.rs", "let value = 2;");
|
||
let mut editor = Editor::open(Some(path), vec![]).unwrap();
|
||
editor.execute("diff");
|
||
let (id, _) = editor.take_diff_request().unwrap();
|
||
editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog())));
|
||
editor.handle(Input::Down);
|
||
editor.handle(Input::Enter);
|
||
editor.handle(Input::Enter);
|
||
let (id, _) = editor.take_diff_request().unwrap();
|
||
editor.apply_diff_result(
|
||
id,
|
||
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
|
||
root: directory.0.clone(),
|
||
editable_paths: if writable {
|
||
[std::path::PathBuf::from("live.rs")].into()
|
||
} else {
|
||
Default::default()
|
||
},
|
||
before: "baseline".into(),
|
||
after: "Working checkout".into(),
|
||
files: vec![syntax_diff_file(
|
||
"live.rs",
|
||
"let value = 1;",
|
||
"let value = 2;",
|
||
)],
|
||
})),
|
||
);
|
||
editor.handle(Input::Char('\t'));
|
||
assert_eq!(editor.diff_buffer_focus(), Some(Focus::Before));
|
||
editor
|
||
.replace_semantic_tokens(
|
||
editor.buffer_id(),
|
||
editor.text_revision(),
|
||
Some(Arc::from([semantic_token(
|
||
0,
|
||
4,
|
||
9,
|
||
SemanticTokenKind::Parameter,
|
||
0,
|
||
)])),
|
||
)
|
||
.unwrap();
|
||
editor.handle(Input::Char('\t'));
|
||
editor
|
||
.replace_semantic_tokens(
|
||
editor.buffer_id(),
|
||
editor.text_revision(),
|
||
Some(Arc::from([semantic_token(
|
||
0,
|
||
4,
|
||
9,
|
||
SemanticTokenKind::Function,
|
||
SemanticTokenModifier::Declaration as u16,
|
||
)])),
|
||
)
|
||
.unwrap();
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap();
|
||
let mut views = BufferViewports::default();
|
||
for _ in 0..4 {
|
||
terminal.draw(|frame| views.draw(frame, &editor)).unwrap();
|
||
let regions = views.panels.diff.as_ref().unwrap().regions;
|
||
for (index, color, bold) in
|
||
[(1, Color::LightCyan, false), (2, Color::LightBlue, true)]
|
||
{
|
||
let pane = regions[index].0;
|
||
let cell = &terminal.backend().buffer()[(pane.x + 8, pane.y + 1)];
|
||
assert_eq!(cell.symbol(), "v");
|
||
assert_eq!(cell.fg, color);
|
||
assert_eq!(cell.modifier.contains(ratatui::style::Modifier::BOLD), bold);
|
||
assert_eq!(
|
||
cell.bg,
|
||
if index == 1 {
|
||
Color::Rgb(64, 24, 24)
|
||
} else {
|
||
Color::Rgb(24, 48, 32)
|
||
}
|
||
);
|
||
}
|
||
editor.handle(Input::Char('\t'));
|
||
}
|
||
editor
|
||
.replace_semantic_tokens(editor.buffer_id(), editor.text_revision(), None)
|
||
.unwrap();
|
||
terminal.draw(|frame| views.draw(frame, &editor)).unwrap();
|
||
let regions = views.panels.diff.as_ref().unwrap().regions;
|
||
let before = regions[1].0;
|
||
let after = regions[2].0;
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(before.x + 8, before.y + 1)].fg,
|
||
Color::LightCyan
|
||
);
|
||
let value = &terminal.backend().buffer()[(after.x + 8, after.y + 1)];
|
||
assert_eq!(value.fg, Color::Reset);
|
||
assert!(!value.modifier.contains(ratatui::style::Modifier::BOLD));
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(after.x + 4, after.y + 1)].fg,
|
||
Color::Magenta
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn editable_after_renders_live_syntax_caret_visual_selection_and_command_prompt() {
|
||
let directory = Directory::new();
|
||
let path = directory.file("live.rs", "let value = \"new\";");
|
||
let mut editor = Editor::open(Some(path), vec![]).unwrap();
|
||
editor.set_clipboard_mode("separate").unwrap();
|
||
editor.execute("diff");
|
||
let (id, _) = editor.take_diff_request().unwrap();
|
||
editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog())));
|
||
editor.handle(Input::Down);
|
||
editor.handle(Input::Enter);
|
||
editor.handle(Input::Enter);
|
||
let (id, _) = editor.take_diff_request().unwrap();
|
||
editor.apply_diff_result(
|
||
id,
|
||
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
|
||
root: directory.0.clone(),
|
||
editable_paths: [std::path::PathBuf::from("live.rs")].into(),
|
||
before: "baseline".into(),
|
||
after: "Working checkout".into(),
|
||
files: vec![syntax_diff_file(
|
||
"live.rs",
|
||
"let value = \"old\";",
|
||
"let value = \"new\";",
|
||
)],
|
||
})),
|
||
);
|
||
editor.handle(Input::Char('\t'));
|
||
editor.handle(Input::Char('\t'));
|
||
assert!(editor.diff_editing());
|
||
editor.handle(Input::Char('c'));
|
||
editor.handle(Input::Char('c'));
|
||
editor.handle(Input::Paste("// live comment".into()));
|
||
editor.handle(Input::Escape);
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap();
|
||
let mut views = BufferViewports::default();
|
||
terminal.draw(|frame| views.draw(frame, &editor)).unwrap();
|
||
let after = views.panels.diff.as_ref().unwrap().regions[2].0;
|
||
let caret = terminal.get_cursor_position().unwrap();
|
||
assert!(after.contains(caret));
|
||
let text_x = after.x + 4;
|
||
assert_eq!(terminal.backend().buffer()[(text_x, caret.y)].symbol(), "/");
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(text_x, caret.y)].fg,
|
||
Color::DarkGray
|
||
);
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(text_x, caret.y)].bg,
|
||
Color::Rgb(24, 48, 32)
|
||
);
|
||
editor.handle(Input::Home);
|
||
editor.handle(Input::Char('v'));
|
||
editor.handle(Input::Right);
|
||
terminal.draw(|frame| views.draw(frame, &editor)).unwrap();
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(text_x, caret.y)].bg,
|
||
Color::Blue
|
||
);
|
||
editor.handle(Input::Escape);
|
||
editor.handle(Input::Char(':'));
|
||
for ch in "diff.close".chars() {
|
||
editor.handle(Input::Char(ch));
|
||
}
|
||
terminal.draw(|frame| views.draw(frame, &editor)).unwrap();
|
||
assert_eq!(terminal.get_cursor_position().unwrap().y, 13);
|
||
assert!(screen_text(&terminal).contains(":diff.close"));
|
||
editor.handle(Input::Enter);
|
||
assert!(editor.diff().is_none());
|
||
assert_eq!(editor.lines(), ["// live comment"]);
|
||
assert!(editor.dirty());
|
||
}
|
||
|
||
#[test]
|
||
fn session_diff_draw_retains_rendered_offsets_until_caret_crosses_edges() {
|
||
let directory = Directory::new();
|
||
let original = (0..80)
|
||
.map(|row| format!("old row {row:03} {}", "x".repeat(100)))
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
let path = directory.file("source.txt", &original);
|
||
let git = |args: &[&str]| {
|
||
let output = std::process::Command::new("git")
|
||
.args(args)
|
||
.current_dir(&directory.0)
|
||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||
.env("GIT_AUTHOR_NAME", "Diff fixture")
|
||
.env("GIT_AUTHOR_EMAIL", "fixture@example.invalid")
|
||
.env("GIT_COMMITTER_NAME", "Diff fixture")
|
||
.env("GIT_COMMITTER_EMAIL", "fixture@example.invalid")
|
||
.env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z")
|
||
.env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z")
|
||
.output()
|
||
.unwrap();
|
||
assert!(output.status.success(), "{output:?}");
|
||
};
|
||
git(&["init", "-q"]);
|
||
git(&["add", "."]);
|
||
git(&["commit", "-qm", "base"]);
|
||
directory.file("source.txt", &original.replace("old row", "new row"));
|
||
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
|
||
let ready = |session: &mut EditorSession, phase| {
|
||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||
loop {
|
||
session.poll();
|
||
let diff = session.editor().diff().unwrap();
|
||
assert!(diff.error.is_none(), "{:?}", diff.error);
|
||
if diff.phase == phase && !diff.loading {
|
||
break;
|
||
}
|
||
assert!(std::time::Instant::now() < deadline, "{diff:?}");
|
||
std::thread::sleep(Duration::from_millis(1));
|
||
}
|
||
};
|
||
session.execute("diff");
|
||
ready(&mut session, Phase::Before);
|
||
session.handle(Input::Down);
|
||
session.handle(Input::Enter);
|
||
session.handle(Input::Enter);
|
||
ready(&mut session, Phase::View);
|
||
session.handle(Input::Char('\t'));
|
||
session.handle(Input::Char('\t'));
|
||
assert!(session.editor().diff_editing());
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap();
|
||
let mut views = BufferViewports::default();
|
||
let mut render = |session: &mut EditorSession| {
|
||
terminal
|
||
.draw(|frame| views.draw_session(frame, session))
|
||
.unwrap();
|
||
terminal.get_cursor_position().unwrap()
|
||
};
|
||
render(&mut session);
|
||
for _ in 0..40 {
|
||
session.handle(Input::Down);
|
||
render(&mut session);
|
||
}
|
||
let top = session.editor().diff().unwrap().top;
|
||
assert!(top > 0);
|
||
let bottom_caret = render(&mut session);
|
||
session.handle(Input::Up);
|
||
let reversed_caret = render(&mut session);
|
||
assert_eq!(session.editor().diff().unwrap().top, top);
|
||
assert_eq!(reversed_caret.y + 1, bottom_caret.y);
|
||
while session
|
||
.editor()
|
||
.diff()
|
||
.unwrap()
|
||
.after_display_row(session.editor().cursor().row)
|
||
.unwrap()
|
||
> top
|
||
{
|
||
session.handle(Input::Up);
|
||
render(&mut session);
|
||
assert_eq!(session.editor().diff().unwrap().top, top);
|
||
}
|
||
session.handle(Input::Up);
|
||
render(&mut session);
|
||
assert!(session.editor().diff().unwrap().top < top);
|
||
for _ in 0..80 {
|
||
session.handle(Input::Right);
|
||
render(&mut session);
|
||
}
|
||
let left = session.editor().diff().unwrap().left_after;
|
||
assert!(left > 0);
|
||
let right_caret = render(&mut session);
|
||
session.handle(Input::Left);
|
||
let reversed_caret = render(&mut session);
|
||
assert_eq!(session.editor().diff().unwrap().left_after, left);
|
||
assert_eq!(reversed_caret.x + 1, right_caret.x);
|
||
while session.editor().cursor().column > left {
|
||
session.handle(Input::Left);
|
||
render(&mut session);
|
||
assert_eq!(session.editor().diff().unwrap().left_after, left);
|
||
}
|
||
session.handle(Input::Left);
|
||
render(&mut session);
|
||
assert!(session.editor().diff().unwrap().left_after < left);
|
||
}
|
||
|
||
#[test]
|
||
fn diff_wheel_routes_right_tree_and_both_panes_without_touching_document() {
|
||
let directory = Directory::new();
|
||
let path = directory.file("source.txt", &["original"; 60].join("\n"));
|
||
let git = |args: &[&str]| {
|
||
let output = std::process::Command::new("git")
|
||
.args(args)
|
||
.current_dir(&directory.0)
|
||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||
.env("GIT_AUTHOR_NAME", "Diff fixture")
|
||
.env("GIT_AUTHOR_EMAIL", "fixture@example.invalid")
|
||
.env("GIT_COMMITTER_NAME", "Diff fixture")
|
||
.env("GIT_COMMITTER_EMAIL", "fixture@example.invalid")
|
||
.env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z")
|
||
.env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z")
|
||
.output()
|
||
.unwrap();
|
||
assert!(output.status.success(), "{:?}", output);
|
||
};
|
||
git(&["init", "-q"]);
|
||
for index in 0..8 {
|
||
directory.file(
|
||
&format!("file-{index}.txt"),
|
||
&format!("{}\nsecond\nthird\n", "before.".repeat(30)),
|
||
);
|
||
}
|
||
git(&["add", "."]);
|
||
git(&["commit", "-qm", "base"]);
|
||
for index in 0..8 {
|
||
directory.file(
|
||
&format!("file-{index}.txt"),
|
||
&format!("{}\nsecond\nthird\n", "after.".repeat(30)),
|
||
);
|
||
}
|
||
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
|
||
let original = session.editor().lines().to_vec();
|
||
let cursor = session.editor().cursor();
|
||
let ready = |session: &mut EditorSession, phase| {
|
||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||
loop {
|
||
session.poll();
|
||
let diff = session.editor().diff().unwrap();
|
||
assert!(diff.error.is_none(), "{:?}", diff.error);
|
||
if diff.phase == phase && !diff.loading {
|
||
break;
|
||
}
|
||
assert!(std::time::Instant::now() < deadline, "{diff:?}");
|
||
std::thread::sleep(Duration::from_millis(1));
|
||
}
|
||
};
|
||
session.execute("diff");
|
||
ready(&mut session, Phase::Before);
|
||
session.handle(Input::Enter);
|
||
session.handle(Input::Down);
|
||
session.handle(Input::Enter);
|
||
ready(&mut session, Phase::View);
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap();
|
||
let mut viewports = BufferViewports::default();
|
||
terminal
|
||
.draw(|frame| viewports.draw(frame, session.editor()))
|
||
.unwrap();
|
||
let regions = viewports.panels.diff.as_ref().unwrap().regions;
|
||
let tree = regions[0].0;
|
||
let before = regions[1].0;
|
||
let after = regions[2].0;
|
||
let initial_focus = session.editor().diff().unwrap().focus;
|
||
let initial_cursor = session.editor().cursor();
|
||
assert!(viewports.scroll(
|
||
&mut session,
|
||
document_wheel(MouseEventKind::ScrollRight, before.x + 1, before.y + 1)
|
||
));
|
||
terminal
|
||
.draw(|frame| viewports.draw_session(frame, &mut session))
|
||
.unwrap();
|
||
assert!(screen_text(&terminal).contains("ore.before."));
|
||
assert_eq!(session.editor().diff().unwrap().left_before, 3);
|
||
assert_eq!(session.editor().diff().unwrap().left_after, 0);
|
||
let mut shift = document_wheel(MouseEventKind::ScrollDown, after.x + 1, after.y + 1);
|
||
shift.modifiers = KeyModifiers::SHIFT;
|
||
assert!(viewports.scroll(&mut session, shift));
|
||
terminal
|
||
.draw(|frame| viewports.draw_session(frame, &mut session))
|
||
.unwrap();
|
||
assert!(screen_text(&terminal).contains("er.after."));
|
||
assert_eq!(session.editor().diff().unwrap().left_before, 3);
|
||
assert_eq!(session.editor().diff().unwrap().left_after, 3);
|
||
assert_eq!(session.editor().diff().unwrap().top, 0);
|
||
assert_eq!(session.editor().diff().unwrap().focus, initial_focus);
|
||
assert_eq!(session.editor().cursor(), initial_cursor);
|
||
assert!(viewports.scroll(
|
||
&mut session,
|
||
document_wheel(MouseEventKind::ScrollDown, tree.x + 1, tree.y + 1),
|
||
));
|
||
let diff = session.editor().diff().unwrap();
|
||
assert_eq!(diff.focus, Focus::Files);
|
||
assert_eq!(diff.files.as_ref().unwrap().selected, 3);
|
||
assert_eq!(diff.file, 0);
|
||
session.handle(Input::Enter);
|
||
assert_eq!(session.editor().diff().unwrap().file, 3);
|
||
assert!(viewports.scroll(
|
||
&mut session,
|
||
document_wheel(MouseEventKind::ScrollDown, before.x + 1, before.y + 1),
|
||
));
|
||
let diff = session.editor().diff().unwrap();
|
||
assert_eq!(diff.focus, Focus::Before);
|
||
assert_eq!(diff.top, 2);
|
||
assert_eq!(diff.file, 3);
|
||
assert!(viewports.scroll(
|
||
&mut session,
|
||
document_wheel(MouseEventKind::ScrollUp, after.x + 1, after.y + 1),
|
||
));
|
||
let diff = session.editor().diff().unwrap();
|
||
assert_eq!(diff.focus, Focus::After);
|
||
assert_eq!(diff.top, 0);
|
||
assert_eq!(diff.file, 3);
|
||
let selected = diff.files.as_ref().unwrap().selected;
|
||
session.handle(Input::Char(' '));
|
||
session.handle(Input::Char('e'));
|
||
session.handle(Input::Ctrl('w'));
|
||
terminal
|
||
.draw(|frame| viewports.draw(frame, session.editor()))
|
||
.unwrap();
|
||
assert!(viewports.scroll(
|
||
&mut session,
|
||
document_wheel(MouseEventKind::ScrollDown, tree.x + 1, tree.y + 1),
|
||
));
|
||
let diff = session.editor().diff().unwrap();
|
||
assert_eq!(diff.focus, Focus::After);
|
||
assert_eq!(diff.files.as_ref().unwrap().selected, selected);
|
||
assert!(!diff.files.as_ref().unwrap().focused);
|
||
assert!(viewports.panels.files.is_none());
|
||
assert!(viewports.document.geometry.is_none());
|
||
session.execute("diff.close");
|
||
assert_eq!(session.editor().lines(), original);
|
||
assert_eq!(session.editor().cursor(), cursor);
|
||
}
|
||
|
||
#[test]
|
||
fn diff_wheel_cannot_scroll_the_hidden_document_even_outside_the_picker() {
|
||
let directory = Directory::new();
|
||
let path = directory.file("rows.txt", &["original"; 60].join("\n"));
|
||
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
|
||
let mut viewports = BufferViewports::default();
|
||
terminal
|
||
.draw(|frame| viewports.draw(frame, session.editor()))
|
||
.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.execute("diff.close");
|
||
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, None))
|
||
.unwrap();
|
||
let mouse = MouseEvent {
|
||
kind: MouseEventKind::ScrollDown,
|
||
column: area.x,
|
||
row: area.y,
|
||
modifiers: KeyModifiers::NONE,
|
||
};
|
||
assert!(!panels.scroll(
|
||
&mut session,
|
||
MouseEvent {
|
||
column: 0,
|
||
row: 0,
|
||
..mouse
|
||
}
|
||
));
|
||
assert!(panels.scroll(&mut session, mouse));
|
||
assert_eq!(panels.manual_files.as_ref().unwrap().top, 3);
|
||
terminal
|
||
.draw(|frame| {
|
||
panels.begin_frame();
|
||
draw_file_list(
|
||
frame,
|
||
session.editor().file_picker().unwrap(),
|
||
area,
|
||
"",
|
||
&mut panels,
|
||
None,
|
||
);
|
||
})
|
||
.unwrap();
|
||
let picker = session.editor().file_picker().unwrap();
|
||
let text = screen_text(&terminal);
|
||
assert!(
|
||
text.contains(picker.entries().nth(3).unwrap().label),
|
||
"{text}"
|
||
);
|
||
assert!(
|
||
!text.contains(&format!(
|
||
"> {}",
|
||
picker.entries().nth(selected).unwrap().label
|
||
)),
|
||
"{text}"
|
||
);
|
||
assert_eq!(picker.selected, selected);
|
||
assert_eq!(picker.entries().nth(selected).unwrap().path, selected_path);
|
||
assert_eq!(picker.query, query);
|
||
assert_eq!(picker.focused, focused);
|
||
assert_eq!(session.editor().carets(), carets);
|
||
assert_eq!(session.editor().text_revision(), revision);
|
||
let entry_count = picker.entries().len();
|
||
assert!(!panels.scroll(
|
||
&mut session,
|
||
MouseEvent {
|
||
kind: MouseEventKind::Moved,
|
||
..mouse
|
||
}
|
||
));
|
||
for _ in 0..entry_count {
|
||
assert!(panels.scroll(&mut session, mouse));
|
||
}
|
||
let (_, height, _) = panels.files.unwrap();
|
||
assert_eq!(
|
||
panels.manual_files.as_ref().unwrap().top,
|
||
session.editor().file_picker().unwrap().entries().len() - height
|
||
);
|
||
panels.follow_files();
|
||
terminal
|
||
.draw(|frame| {
|
||
draw_file_list(
|
||
frame,
|
||
session.editor().file_picker().unwrap(),
|
||
area,
|
||
"",
|
||
&mut panels,
|
||
None,
|
||
)
|
||
})
|
||
.unwrap();
|
||
assert_eq!(panels.files.unwrap().2, 0);
|
||
session.handle(Input::Ctrl('p'));
|
||
assert!(session.editor().palette().is_some());
|
||
assert!(panels.scroll(&mut session, mouse));
|
||
assert!(panels.manual_files.is_none());
|
||
session.handle(Input::Escape);
|
||
session.handle(Input::Char(' '));
|
||
session.handle(Input::Char(key));
|
||
session.handle(Input::Ctrl('w'));
|
||
assert!(session.editor().file_picker().is_none());
|
||
assert!(!panels.scroll(&mut session, mouse));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn manually_scrolled_tree_does_not_place_cursor_or_highlight_offscreen_selection() {
|
||
let directory = Directory::new();
|
||
for index in 0..12 {
|
||
directory.file(&format!("file-{index:02}"), "");
|
||
}
|
||
let mut picker = FilePicker::scan(
|
||
directory.0.clone(),
|
||
FilePresentation::Docked,
|
||
FileView::Tree,
|
||
)
|
||
.unwrap();
|
||
picker.selected = 11;
|
||
let mut panels = WheelPanels {
|
||
manual_files: Some(FileWheelOffset {
|
||
top: 0,
|
||
root: picker.root.clone(),
|
||
query: picker.query.clone(),
|
||
state: FileWheelOffset::state(&picker),
|
||
}),
|
||
..WheelPanels::default()
|
||
};
|
||
let area = Rect::new(3, 2, 40, 6);
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 12)).unwrap();
|
||
terminal
|
||
.draw(|frame| {
|
||
frame.set_cursor_position((59, 11));
|
||
draw_file_list(frame, &picker, area, "", &mut panels, None);
|
||
})
|
||
.unwrap();
|
||
let text = screen_text(&terminal);
|
||
assert!(text.contains("file-00"), "{text}");
|
||
assert!(!text.contains("file-11"), "{text}");
|
||
assert_eq!(
|
||
terminal.get_cursor_position().unwrap(),
|
||
ratatui::layout::Position::new(59, 11)
|
||
);
|
||
for y in area.y..area.bottom() {
|
||
for x in area.x..area.right() {
|
||
assert_ne!(terminal.backend().buffer()[(x, y)].bg, Color::Blue);
|
||
}
|
||
}
|
||
picker.navigate(false);
|
||
terminal
|
||
.draw(|frame| draw_file_list(frame, &picker, area, "", &mut panels, None))
|
||
.unwrap();
|
||
assert!(panels.manual_files.is_none());
|
||
assert!(screen_text(&terminal).contains("> file-10"));
|
||
}
|
||
|
||
#[test]
|
||
fn file_list_window_follows_control_j_k_selection() {
|
||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||
editor.set_file_picker_view("list").unwrap();
|
||
editor.handle(Input::Char(' '));
|
||
editor.handle(Input::Char('o'));
|
||
let count = editor.file_picker().unwrap().entries().len();
|
||
assert!(count > 1);
|
||
for _ in 0..count - 1 {
|
||
editor.handle(
|
||
translate(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL)).unwrap(),
|
||
);
|
||
}
|
||
assert_eq!(editor.file_picker().unwrap().selected, count - 1);
|
||
editor.handle(translate(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)).unwrap());
|
||
assert_eq!(editor.file_picker().unwrap().selected, count - 2);
|
||
let picker = editor.file_picker().unwrap();
|
||
let label = picker.entries().nth(picker.selected).unwrap().label;
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 9)).unwrap();
|
||
terminal
|
||
.draw(|frame| {
|
||
draw_file_list(
|
||
frame,
|
||
picker,
|
||
frame.area(),
|
||
editor.file_navigation_hint(),
|
||
&mut WheelPanels::default(),
|
||
None,
|
||
)
|
||
})
|
||
.unwrap();
|
||
let text = screen_text(&terminal);
|
||
assert!(text.contains(&format!("> {label}")), "{text}");
|
||
let buffer = terminal.backend().buffer();
|
||
let selected_y = (3..8).find(|&y| buffer[(1, y)].symbol() == ">").unwrap();
|
||
assert_eq!(buffer[(1, selected_y)].bg, Color::Blue);
|
||
}
|
||
|
||
#[test]
|
||
fn explorer_wraps_only_root_path_and_reserves_file_rows_in_short_panes() {
|
||
let root = "/a/very/long/path/to/project/root";
|
||
let picker = FilePicker::from_paths(root.into(), [(0, "entry.txt".into())]);
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 12)).unwrap();
|
||
let mut panels = WheelPanels::default();
|
||
terminal
|
||
.draw(|frame| draw_file_list(frame, &picker, frame.area(), "", &mut panels, None))
|
||
.unwrap();
|
||
let buffer = terminal.backend().buffer();
|
||
let path = (2..4)
|
||
.map(|y| (1..19).map(|x| buffer[(x, y)].symbol()).collect::<String>())
|
||
.collect::<String>();
|
||
assert_eq!(path.trim_end(), root);
|
||
assert!(
|
||
screen_text(&terminal)
|
||
.lines()
|
||
.nth(4)
|
||
.unwrap()
|
||
.contains("entry.txt")
|
||
);
|
||
terminal.backend_mut().resize(20, 6);
|
||
terminal
|
||
.draw(|frame| draw_file_list(frame, &picker, frame.area(), "", &mut panels, None))
|
||
.unwrap();
|
||
assert!(screen_text(&terminal).contains("entry.txt"));
|
||
}
|
||
|
||
#[test]
|
||
fn list_footers_show_remapped_and_disabled_selection_keys() {
|
||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||
editor.set_file_explorer_view("list").unwrap();
|
||
editor.set_file_picker_view("list").unwrap();
|
||
for key in ["ctrl+j", "ctrl+n", "down", "ctrl+k", "up"] {
|
||
editor.bind("selection", key, None).unwrap();
|
||
}
|
||
editor
|
||
.bind("selection", "x", Some("core.down".into()))
|
||
.unwrap();
|
||
let hint = editor.selection_navigation_hint().to_owned();
|
||
assert_eq!(hint, "x next · unbound previous");
|
||
editor.set_file_explorer_side("bottom").unwrap();
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap();
|
||
let (mut top, mut left) = (0, 0);
|
||
let mut syntax = SyntaxCache::new();
|
||
for key in ['o', 'e'] {
|
||
editor.handle(Input::Char(' '));
|
||
editor.handle(Input::Char(key));
|
||
terminal
|
||
.draw(|frame| {
|
||
draw(
|
||
frame,
|
||
&editor,
|
||
&mut top,
|
||
&mut left,
|
||
&mut syntax,
|
||
&mut DocumentViewport::default(),
|
||
&mut WheelPanels::default(),
|
||
)
|
||
})
|
||
.unwrap();
|
||
let text = screen_text(&terminal);
|
||
assert!(text.contains(&hint), "{text}");
|
||
assert!(!text.contains("Ctrl+j"), "{text}");
|
||
editor.handle(Input::Escape);
|
||
}
|
||
editor.handle(Input::Ctrl('p'));
|
||
terminal
|
||
.draw(|frame| {
|
||
draw(
|
||
frame,
|
||
&editor,
|
||
&mut top,
|
||
&mut left,
|
||
&mut syntax,
|
||
&mut DocumentViewport::default(),
|
||
&mut WheelPanels::default(),
|
||
)
|
||
})
|
||
.unwrap();
|
||
let text = screen_text(&terminal);
|
||
assert!(text.contains(&hint), "{text}");
|
||
assert!(!text.contains("Ctrl+j"), "{text}");
|
||
}
|
||
|
||
#[test]
|
||
fn file_list_scrolls_selection_and_sanitizes_display_without_losing_paths() {
|
||
let directory = Directory::new();
|
||
for index in 0..18 {
|
||
directory.file(&format!("file-{index:02}"), "text");
|
||
}
|
||
let special = directory.file("界e\u{301} space\nname", "text");
|
||
let editor = Editor::open(None, vec![]).unwrap();
|
||
let mut picker = FilePicker::scan(
|
||
directory.0.clone(),
|
||
FilePresentation::Floating,
|
||
FileView::List,
|
||
)
|
||
.unwrap();
|
||
assert!(
|
||
picker
|
||
.entries()
|
||
.any(|entry| directory.0.join(entry.path) == special)
|
||
);
|
||
let count = picker.entries().len();
|
||
picker.selected = count - 1;
|
||
let label = picker.entries().last().unwrap().label;
|
||
assert_eq!(label, "界e\u{301} space�name");
|
||
picker.query = "long\t界e\u{301}z".into();
|
||
let area = Rect::new(3, 2, 24, 9);
|
||
let mut terminal = Terminal::with_options(
|
||
ratatui::backend::TestBackend::new(40, 15),
|
||
ratatui::TerminalOptions {
|
||
viewport: ratatui::Viewport::Fixed(area),
|
||
},
|
||
)
|
||
.unwrap();
|
||
terminal
|
||
.draw(|frame| {
|
||
draw_file_list(
|
||
frame,
|
||
&picker,
|
||
area,
|
||
editor.selection_navigation_hint(),
|
||
&mut WheelPanels::default(),
|
||
None,
|
||
)
|
||
})
|
||
.unwrap();
|
||
let text = screen_text(&terminal);
|
||
assert!(!text.contains("file-00"), "{text}");
|
||
let cursor = terminal.get_cursor_position().unwrap();
|
||
let buffer = terminal.backend().buffer();
|
||
assert_eq!(buffer[(area.x + 7, area.y + 1)].symbol(), "�");
|
||
assert_eq!(buffer[(area.x + 8, area.y + 1)].symbol(), "界");
|
||
assert_eq!(buffer[(area.x + 10, area.y + 1)].symbol(), "e\u{301}");
|
||
let selected_y = (area.y + 2..area.bottom() - 1)
|
||
.find(|&y| buffer[(area.x + 1, y)].symbol() == ">")
|
||
.unwrap();
|
||
assert_eq!(buffer[(area.x + 3, selected_y)].symbol(), "界");
|
||
assert_eq!(buffer[(area.x + 5, selected_y)].symbol(), "e\u{301}");
|
||
assert_eq!(buffer[(area.x + 1, selected_y)].bg, Color::Blue);
|
||
assert!(
|
||
buffer[(area.x + 1, selected_y)]
|
||
.modifier
|
||
.contains(Modifier::BOLD)
|
||
);
|
||
assert_eq!(cursor.y, area.y + 1);
|
||
assert_eq!(buffer[(0, 0)].symbol(), " ");
|
||
}
|
||
|
||
#[test]
|
||
fn file_list_renders_root_empty_filter_errors_and_idle_focus() {
|
||
let directory = Directory::new();
|
||
let editor = Editor::open(None, vec![]).unwrap();
|
||
let mut picker = FilePicker::scan(
|
||
directory.0.clone(),
|
||
FilePresentation::Docked,
|
||
FileView::List,
|
||
)
|
||
.unwrap();
|
||
let area = Rect::new(0, 0, 100, 12);
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
|
||
terminal
|
||
.draw(|frame| {
|
||
draw_file_list(
|
||
frame,
|
||
&picker,
|
||
area,
|
||
editor.selection_navigation_hint(),
|
||
&mut WheelPanels::default(),
|
||
None,
|
||
)
|
||
})
|
||
.unwrap();
|
||
let text = screen_text(&terminal);
|
||
assert!(text.contains("Explorer"), "{text}");
|
||
assert!(
|
||
text.contains(&directory.0.to_string_lossy().to_string()),
|
||
"{text}"
|
||
);
|
||
assert!(text.contains("No project files"), "{text}");
|
||
assert!(text.contains("Esc/Tab"), "{text}");
|
||
picker.query = "missing".into();
|
||
picker.error = Some("Unreadable\npath\u{1b}".into());
|
||
picker.focused = false;
|
||
terminal
|
||
.draw(|frame| {
|
||
frame.set_cursor_position((99, 11));
|
||
draw_file_list(
|
||
frame,
|
||
&picker,
|
||
area,
|
||
editor.selection_navigation_hint(),
|
||
&mut WheelPanels::default(),
|
||
None,
|
||
);
|
||
})
|
||
.unwrap();
|
||
let text = screen_text(&terminal);
|
||
assert!(text.contains("No matching files"), "{text}");
|
||
assert!(text.contains("Unreadable�path�"), "{text}");
|
||
assert!(text.contains("Editor focus"), "{text}");
|
||
assert_eq!(terminal.backend().buffer()[(1, 3)].fg, Color::Red);
|
||
assert_eq!(terminal.backend().buffer()[(0, 0)].fg, Color::DarkGray);
|
||
assert_eq!(
|
||
terminal.get_cursor_position().unwrap(),
|
||
ratatui::layout::Position::new(99, 11)
|
||
);
|
||
picker.presentation = FilePresentation::Floating;
|
||
picker.focused = true;
|
||
terminal
|
||
.draw(|frame| {
|
||
draw_file_list(
|
||
frame,
|
||
&picker,
|
||
area,
|
||
editor.selection_navigation_hint(),
|
||
&mut WheelPanels::default(),
|
||
None,
|
||
)
|
||
})
|
||
.unwrap();
|
||
assert!(screen_text(&terminal).contains("Files"));
|
||
assert!(screen_text(&terminal).contains("Esc/^W"));
|
||
assert_eq!(terminal.backend().buffer()[(0, 0)].fg, Color::Cyan);
|
||
picker.set_view(FileView::Tree);
|
||
picker.finish_search();
|
||
terminal
|
||
.draw(|frame| {
|
||
draw_file_list(
|
||
frame,
|
||
&picker,
|
||
area,
|
||
editor.file_navigation_hint(),
|
||
&mut WheelPanels::default(),
|
||
None,
|
||
)
|
||
})
|
||
.unwrap();
|
||
let text = screen_text(&terminal);
|
||
assert!(text.contains("No matching files"), "{text}");
|
||
assert!(text.contains("filter: missing"), "{text}");
|
||
assert_eq!(
|
||
terminal.get_cursor_position().unwrap(),
|
||
ratatui::layout::Position::new(1, 1)
|
||
);
|
||
picker.focused = false;
|
||
terminal
|
||
.draw(|frame| {
|
||
frame.set_cursor_position((99, 11));
|
||
draw_file_list(
|
||
frame,
|
||
&picker,
|
||
area,
|
||
editor.file_navigation_hint(),
|
||
&mut WheelPanels::default(),
|
||
None,
|
||
);
|
||
})
|
||
.unwrap();
|
||
assert_eq!(
|
||
terminal.get_cursor_position().unwrap(),
|
||
ratatui::layout::Position::new(99, 11)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn file_query_cursor_and_dock_layout_survive_tiny_offset_resizes() {
|
||
let directory = Directory::new();
|
||
let editor = Editor::open(None, vec![]).unwrap();
|
||
let mut picker = FilePicker::scan(
|
||
directory.0.clone(),
|
||
FilePresentation::Docked,
|
||
FileView::List,
|
||
)
|
||
.unwrap();
|
||
picker.query = "long界e\u{301}z".into();
|
||
for (width, height) in [(12, 6), (8, 2), (1, 1), (1, 2), (8, 1), (12, 6)] {
|
||
let area = Rect::new(3, 2, width, height);
|
||
let mut terminal = Terminal::with_options(
|
||
ratatui::backend::TestBackend::new(24, 12),
|
||
ratatui::TerminalOptions {
|
||
viewport: ratatui::Viewport::Fixed(area),
|
||
},
|
||
)
|
||
.unwrap();
|
||
terminal
|
||
.draw(|frame| {
|
||
draw_file_list(
|
||
frame,
|
||
&picker,
|
||
area,
|
||
editor.selection_navigation_hint(),
|
||
&mut WheelPanels::default(),
|
||
None,
|
||
)
|
||
})
|
||
.unwrap();
|
||
let cursor = terminal.get_cursor_position().unwrap();
|
||
assert!(area.contains(cursor), "{area:?}: {cursor:?}");
|
||
let bordered = width >= 4 && height >= 4;
|
||
let query_x = area.x + u16::from(bordered);
|
||
let query_y = area.y + u16::from(bordered);
|
||
let query_width = width - 2 * u16::from(bordered);
|
||
assert_eq!(cursor.x, query_x + query_width - 1);
|
||
assert_eq!(cursor.y, query_y);
|
||
let query = format!("> {}", picker.query);
|
||
let offset =
|
||
display_column(&query, usize::MAX).saturating_sub(usize::from(query_width) - 1);
|
||
let mut expected =
|
||
ratatui::buffer::Buffer::empty(Rect::new(query_x, query_y, query_width, 1));
|
||
use ratatui::widgets::Widget;
|
||
Paragraph::new(visible_line(&query, offset, usize::from(query_width)))
|
||
.render(expected.area, &mut expected);
|
||
for x in query_x..query_x + query_width {
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(x, query_y)].symbol(),
|
||
expected[(x, query_y)].symbol()
|
||
);
|
||
}
|
||
assert_eq!(terminal.backend().buffer()[(0, 0)].symbol(), " ");
|
||
for side in [
|
||
DockSide::Left,
|
||
DockSide::Right,
|
||
DockSide::Top,
|
||
DockSide::Bottom,
|
||
] {
|
||
for focused in [false, true] {
|
||
let (editor, panel) = dock_layout(area, side, focused);
|
||
for rect in [Some(editor), panel].into_iter().flatten() {
|
||
assert!(rect.x >= area.x && rect.y >= area.y);
|
||
assert!(rect.right() <= area.right() && rect.bottom() <= area.bottom());
|
||
}
|
||
if focused {
|
||
assert!(panel.is_some());
|
||
} else {
|
||
assert!(panel.is_none());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for (width, height) in [(0, 1), (1, 0), (0, 0)] {
|
||
let mut terminal =
|
||
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
|
||
terminal
|
||
.draw(|frame| {
|
||
draw_file_list(
|
||
frame,
|
||
&picker,
|
||
frame.area(),
|
||
editor.selection_navigation_hint(),
|
||
&mut WheelPanels::default(),
|
||
None,
|
||
)
|
||
})
|
||
.unwrap();
|
||
assert!(
|
||
dock_layout(Rect::new(3, 2, width, height), DockSide::Right, true)
|
||
.1
|
||
.is_none()
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn tree_rows_render_directory_chains_hierarchy_and_basename_files_in_both_presentations() {
|
||
use std::path::Path;
|
||
let directory = Directory::new();
|
||
directory.file("chain/a/b/leaf.txt", "");
|
||
directory.file("mixed/nested/inner.txt", "");
|
||
directory.file("mixed/sibling.txt", "");
|
||
directory.file("single/only.txt", "");
|
||
directory.file("root.txt", "");
|
||
fs::create_dir(directory.0.join("empty")).unwrap();
|
||
let editor = Editor::open(None, vec![]).unwrap();
|
||
for presentation in [FilePresentation::Floating, FilePresentation::Docked] {
|
||
let mut picker =
|
||
FilePicker::scan(directory.0.clone(), presentation, FileView::Tree).unwrap();
|
||
assert_eq!(picker.entries().next().unwrap().label, "chain/a/b");
|
||
for path in ["chain/a/b", "mixed", "mixed/nested", "single"] {
|
||
let selected = picker
|
||
.entries()
|
||
.position(|row| row.path == Path::new(path))
|
||
.unwrap();
|
||
picker.selected = selected;
|
||
picker.right();
|
||
}
|
||
let area = Rect::new(3, 2, 100, 22);
|
||
let mut terminal = Terminal::with_options(
|
||
ratatui::backend::TestBackend::new(110, 28),
|
||
ratatui::TerminalOptions {
|
||
viewport: ratatui::Viewport::Fixed(area),
|
||
},
|
||
)
|
||
.unwrap();
|
||
terminal
|
||
.draw(|frame| {
|
||
draw_file_list(
|
||
frame,
|
||
&picker,
|
||
area,
|
||
editor.file_navigation_hint(),
|
||
&mut WheelPanels::default(),
|
||
None,
|
||
)
|
||
})
|
||
.unwrap();
|
||
let text = screen_text(&terminal);
|
||
for label in [
|
||
" chain/a/b/",
|
||
" leaf.txt",
|
||
" empty/",
|
||
" mixed/",
|
||
" nested/",
|
||
" inner.txt",
|
||
" sibling.txt",
|
||
"> single/",
|
||
" only.txt",
|
||
" root.txt",
|
||
] {
|
||
assert!(text.contains(label), "{label}: {text}");
|
||
}
|
||
assert!(!text.contains('▸') && !text.contains('▾'), "{text}");
|
||
let buffer = terminal.backend().buffer();
|
||
for (index, entry) in picker.entries().enumerate() {
|
||
let y = area.y + 3 + index as u16;
|
||
assert_eq!(
|
||
buffer[(area.x + 1, y)].fg,
|
||
if entry.is_dir {
|
||
Color::Cyan
|
||
} else {
|
||
Color::Reset
|
||
},
|
||
);
|
||
}
|
||
assert!(!text.contains("single/only.txt"), "{text}");
|
||
assert!(!text.contains("mixed/nested/inner.txt"), "{text}");
|
||
let cursor = terminal.get_cursor_position().unwrap();
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(),
|
||
">"
|
||
);
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(cursor.x, cursor.y)].bg,
|
||
Color::Blue
|
||
);
|
||
picker.set_view(FileView::List);
|
||
terminal
|
||
.draw(|frame| {
|
||
draw_file_list(
|
||
frame,
|
||
&picker,
|
||
area,
|
||
editor.file_navigation_hint(),
|
||
&mut WheelPanels::default(),
|
||
None,
|
||
)
|
||
})
|
||
.unwrap();
|
||
let text = screen_text(&terminal);
|
||
assert!(text.contains("chain/a/b/leaf.txt"), "{text}");
|
||
assert!(text.contains("single/only.txt"), "{text}");
|
||
assert!(!text.contains("▸"), "{text}");
|
||
assert_eq!(terminal.get_cursor_position().unwrap().y, area.y + 1);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn tree_browse_cursor_tracks_window_and_bounds_empty_search_and_tiny_offset_panels() {
|
||
let directory = Directory::new();
|
||
for index in 0..18 {
|
||
directory.file(&format!("dir-{index:02}/file.txt"), "");
|
||
}
|
||
let editor = Editor::open(None, vec![]).unwrap();
|
||
let mut picker = FilePicker::scan(
|
||
directory.0.clone(),
|
||
FilePresentation::Docked,
|
||
FileView::Tree,
|
||
)
|
||
.unwrap();
|
||
for _ in 0..17 {
|
||
picker.navigate(true);
|
||
}
|
||
picker.right();
|
||
picker.right();
|
||
let long_query = "long界e\u{301}z".repeat(20);
|
||
for (width, height) in [(100, 9), (12, 6), (8, 2), (1, 1), (1, 2), (8, 1), (100, 9)] {
|
||
let area = Rect::new(3, 2, width, height);
|
||
let mut terminal = Terminal::with_options(
|
||
ratatui::backend::TestBackend::new(110, 16),
|
||
ratatui::TerminalOptions {
|
||
viewport: ratatui::Viewport::Fixed(area),
|
||
},
|
||
)
|
||
.unwrap();
|
||
terminal
|
||
.draw(|frame| {
|
||
draw_file_list(
|
||
frame,
|
||
&picker,
|
||
area,
|
||
editor.file_navigation_hint(),
|
||
&mut WheelPanels::default(),
|
||
None,
|
||
)
|
||
})
|
||
.unwrap();
|
||
let cursor = terminal.get_cursor_position().unwrap();
|
||
assert!(area.contains(cursor), "{area:?}: {cursor:?}");
|
||
let has_results = height > if width >= 4 && height >= 4 { 3 } else { 1 };
|
||
if has_results {
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(),
|
||
">"
|
||
);
|
||
} else {
|
||
assert_eq!(cursor, ratatui::layout::Position::new(area.x, area.y));
|
||
}
|
||
if width == 100 {
|
||
let text = screen_text(&terminal);
|
||
assert!(text.contains("> file.txt"), "{text}");
|
||
assert!(!text.contains("dir-00"), "{text}");
|
||
}
|
||
for query in ["missing", long_query.as_str()] {
|
||
picker.start_search();
|
||
picker.query = query.into();
|
||
picker.refilter();
|
||
terminal
|
||
.draw(|frame| {
|
||
draw_file_list(
|
||
frame,
|
||
&picker,
|
||
area,
|
||
editor.file_navigation_hint(),
|
||
&mut WheelPanels::default(),
|
||
None,
|
||
)
|
||
})
|
||
.unwrap();
|
||
let cursor = terminal.get_cursor_position().unwrap();
|
||
assert!(area.contains(cursor), "{area:?}: {cursor:?}");
|
||
assert_eq!(cursor.y, area.y + u16::from(width >= 4 && height >= 4));
|
||
picker.finish_search();
|
||
assert_eq!(picker.query, query);
|
||
assert_eq!(picker.entries().len(), 0);
|
||
terminal
|
||
.draw(|frame| {
|
||
draw_file_list(
|
||
frame,
|
||
&picker,
|
||
area,
|
||
editor.file_navigation_hint(),
|
||
&mut WheelPanels::default(),
|
||
None,
|
||
)
|
||
})
|
||
.unwrap();
|
||
let cursor = terminal.get_cursor_position().unwrap();
|
||
assert!(area.contains(cursor), "{area:?}: {cursor:?}");
|
||
let query_x = area.x + u16::from(width >= 4 && height >= 4);
|
||
assert_eq!(cursor.x, query_x);
|
||
let query_y = area.y + u16::from(width >= 4 && height >= 4);
|
||
let query_width = width - 2 * u16::from(width >= 4 && height >= 4);
|
||
let header = format!("filter: {query} · ^U clear");
|
||
let mut expected =
|
||
ratatui::buffer::Buffer::empty(Rect::new(query_x, query_y, query_width, 1));
|
||
use ratatui::widgets::Widget;
|
||
Paragraph::new(visible_line(&header, 0, usize::from(query_width)))
|
||
.render(expected.area, &mut expected);
|
||
let mut continuation = 0;
|
||
for x in query_x..query_x + query_width {
|
||
if continuation > 0 {
|
||
continuation -= 1;
|
||
continue;
|
||
}
|
||
continuation = expected[(x, query_y)].symbol().width().saturating_sub(1);
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(x, query_y)].symbol(),
|
||
expected[(x, query_y)].symbol()
|
||
);
|
||
}
|
||
if width == 100 {
|
||
assert!(screen_text(&terminal).contains("filter: "), "{query}");
|
||
assert!(
|
||
screen_text(&terminal).contains("No matching files"),
|
||
"{query}"
|
||
);
|
||
}
|
||
picker.clear_filter();
|
||
assert!(!picker.searching);
|
||
assert_eq!(
|
||
picker.entries().nth(picker.selected).unwrap().path,
|
||
std::path::Path::new("dir-17/file.txt")
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn tree_filter_escape_resume_and_clear_keep_selection_and_cursor_ownership() {
|
||
use std::path::Path;
|
||
let directory = Directory::new();
|
||
directory.file("src/inner/needle.txt", "");
|
||
directory.file("src/other.txt", "");
|
||
directory.file("root.txt", "");
|
||
for presentation in [FilePresentation::Floating, FilePresentation::Docked] {
|
||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||
editor.set_project_root(directory.0.clone());
|
||
editor.set_file_picker_view("tree").unwrap();
|
||
editor
|
||
.apply(vec![crate::editor::Effect::Replace("unchanged".into())])
|
||
.unwrap();
|
||
editor.handle(Input::Char('v'));
|
||
let mode = editor.mode();
|
||
let selections = editor.selections();
|
||
editor.handle(Input::Char(' '));
|
||
editor.handle(Input::Char(if presentation == FilePresentation::Floating {
|
||
'o'
|
||
} else {
|
||
'e'
|
||
}));
|
||
if !editor.file_picker().unwrap().searching {
|
||
editor.handle(Input::Char('i'));
|
||
}
|
||
let query = "src/inner/needle.txt";
|
||
for ch in query.chars() {
|
||
editor.handle(Input::Char(ch));
|
||
}
|
||
editor.handle(Input::Ctrl('h'));
|
||
let picker = editor.file_picker().unwrap();
|
||
assert_eq!(picker.query, query);
|
||
assert_eq!(
|
||
picker.entries().nth(picker.selected).unwrap().path,
|
||
Path::new("src/inner")
|
||
);
|
||
editor.handle(Input::Ctrl('l'));
|
||
let picker = editor.file_picker().unwrap();
|
||
assert_eq!(
|
||
picker.entries().nth(picker.selected).unwrap().path,
|
||
Path::new(query)
|
||
);
|
||
let filtered: Vec<_> = picker
|
||
.entries()
|
||
.map(|row| (row.path.to_owned(), row.expanded))
|
||
.collect();
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap();
|
||
let (mut top, mut left) = (0, 0);
|
||
let mut syntax = SyntaxCache::new();
|
||
terminal
|
||
.draw(|frame| {
|
||
draw(
|
||
frame,
|
||
&editor,
|
||
&mut top,
|
||
&mut left,
|
||
&mut syntax,
|
||
&mut DocumentViewport::default(),
|
||
&mut WheelPanels::default(),
|
||
)
|
||
})
|
||
.unwrap();
|
||
let query_cursor = terminal.get_cursor_position().unwrap();
|
||
assert!(screen_text(&terminal).contains(&format!("> {query}")));
|
||
assert!(screen_text(&terminal).contains("^U clear"));
|
||
editor.handle(Input::Escape);
|
||
let picker = editor.file_picker().unwrap();
|
||
assert!(picker.focused && !picker.searching);
|
||
assert_eq!(picker.query, query);
|
||
assert_eq!(
|
||
picker.entries().nth(picker.selected).unwrap().path,
|
||
Path::new(query)
|
||
);
|
||
assert_eq!(
|
||
picker
|
||
.entries()
|
||
.map(|row| (row.path.to_owned(), row.expanded))
|
||
.collect::<Vec<_>>(),
|
||
filtered
|
||
);
|
||
terminal
|
||
.draw(|frame| {
|
||
draw(
|
||
frame,
|
||
&editor,
|
||
&mut top,
|
||
&mut left,
|
||
&mut syntax,
|
||
&mut DocumentViewport::default(),
|
||
&mut WheelPanels::default(),
|
||
)
|
||
})
|
||
.unwrap();
|
||
let text = screen_text(&terminal);
|
||
assert!(text.contains(&format!("filter: {query}")), "{text}");
|
||
assert!(
|
||
text.contains("needle.txt") && !text.contains("other.txt"),
|
||
"{text}"
|
||
);
|
||
let selected_cursor = terminal.get_cursor_position().unwrap();
|
||
assert_ne!(selected_cursor.y, query_cursor.y);
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(selected_cursor.x, selected_cursor.y)].symbol(),
|
||
">"
|
||
);
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(selected_cursor.x, selected_cursor.y)].bg,
|
||
Color::Blue
|
||
);
|
||
editor.handle(Input::Char('i'));
|
||
assert_eq!(editor.file_picker().unwrap().query, query);
|
||
terminal
|
||
.draw(|frame| {
|
||
draw(
|
||
frame,
|
||
&editor,
|
||
&mut top,
|
||
&mut left,
|
||
&mut syntax,
|
||
&mut DocumentViewport::default(),
|
||
&mut WheelPanels::default(),
|
||
)
|
||
})
|
||
.unwrap();
|
||
assert_eq!(terminal.get_cursor_position().unwrap(), query_cursor);
|
||
editor.handle(Input::Escape);
|
||
editor.handle(Input::Ctrl('u'));
|
||
let picker = editor.file_picker().unwrap();
|
||
assert!(picker.focused && !picker.searching && picker.query.is_empty());
|
||
assert_eq!(
|
||
picker.entries().nth(picker.selected).unwrap().path,
|
||
Path::new(query)
|
||
);
|
||
terminal
|
||
.draw(|frame| {
|
||
draw(
|
||
frame,
|
||
&editor,
|
||
&mut top,
|
||
&mut left,
|
||
&mut syntax,
|
||
&mut DocumentViewport::default(),
|
||
&mut WheelPanels::default(),
|
||
)
|
||
})
|
||
.unwrap();
|
||
let cursor = terminal.get_cursor_position().unwrap();
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(),
|
||
">"
|
||
);
|
||
assert!(screen_text(&terminal).contains("other.txt"));
|
||
editor.handle(Input::Char('i'));
|
||
for ch in query.chars() {
|
||
editor.handle(Input::Char(ch));
|
||
}
|
||
editor.handle(Input::Ctrl('u'));
|
||
let picker = editor.file_picker().unwrap();
|
||
assert!(picker.focused && picker.searching && picker.query.is_empty());
|
||
assert_eq!(
|
||
picker.entries().nth(picker.selected).unwrap().path,
|
||
Path::new(query)
|
||
);
|
||
terminal
|
||
.draw(|frame| {
|
||
draw(
|
||
frame,
|
||
&editor,
|
||
&mut top,
|
||
&mut left,
|
||
&mut syntax,
|
||
&mut DocumentViewport::default(),
|
||
&mut WheelPanels::default(),
|
||
)
|
||
})
|
||
.unwrap();
|
||
let cursor = terminal.get_cursor_position().unwrap();
|
||
assert_eq!(cursor.y, query_cursor.y);
|
||
assert_eq!(cursor.x, query_cursor.x - query.len() as u16);
|
||
editor.handle(Input::Ctrl('t'));
|
||
for ch in query.chars() {
|
||
editor.handle(Input::Char(ch));
|
||
}
|
||
editor.handle(Input::Ctrl('u'));
|
||
let picker = editor.file_picker().unwrap();
|
||
assert_eq!(picker.view, FileView::List);
|
||
assert!(picker.focused && picker.query.is_empty());
|
||
assert_eq!(
|
||
picker.entries().nth(picker.selected).unwrap().path,
|
||
Path::new(query)
|
||
);
|
||
terminal
|
||
.draw(|frame| {
|
||
draw(
|
||
frame,
|
||
&editor,
|
||
&mut top,
|
||
&mut left,
|
||
&mut syntax,
|
||
&mut DocumentViewport::default(),
|
||
&mut WheelPanels::default(),
|
||
)
|
||
})
|
||
.unwrap();
|
||
assert_eq!(terminal.get_cursor_position().unwrap(), cursor);
|
||
editor.handle(Input::Ctrl('t'));
|
||
editor.handle(Input::Escape);
|
||
editor.handle(Input::Escape);
|
||
if presentation == FilePresentation::Floating {
|
||
assert!(editor.file_picker().is_none());
|
||
} else {
|
||
assert!(!editor.file_picker().unwrap().focused);
|
||
}
|
||
assert_eq!(editor.mode(), mode);
|
||
assert_eq!(editor.selections(), selections);
|
||
assert_eq!(editor.lines(), ["unchanged"]);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn explorer_retains_insert_filter_through_focus_and_view_switches() {
|
||
let directory = Directory::new();
|
||
directory.file("src/inner/needle.txt", "");
|
||
directory.file("src/other.txt", "");
|
||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||
editor.set_project_root(directory.0.clone());
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap();
|
||
let (mut top, mut left) = (0, 0);
|
||
let mut syntax = SyntaxCache::new();
|
||
editor.handle(Input::Char(' '));
|
||
editor.handle(Input::Char('e'));
|
||
editor.handle(Input::Char('i'));
|
||
editor.handle(Input::Char('n'));
|
||
editor.handle(Input::Char('\t'));
|
||
assert!(!editor.file_picker().unwrap().focused);
|
||
editor.handle(Input::Char(' '));
|
||
editor.handle(Input::Char('e'));
|
||
assert!(editor.file_picker().unwrap().searching);
|
||
assert_eq!(editor.file_picker().unwrap().query, "n");
|
||
editor.handle(Input::Ctrl('t'));
|
||
assert_eq!(editor.file_picker().unwrap().view, FileView::List);
|
||
assert_eq!(editor.file_picker().unwrap().query, "n");
|
||
terminal
|
||
.draw(|frame| {
|
||
draw(
|
||
frame,
|
||
&editor,
|
||
&mut top,
|
||
&mut left,
|
||
&mut syntax,
|
||
&mut DocumentViewport::default(),
|
||
&mut WheelPanels::default(),
|
||
)
|
||
})
|
||
.unwrap();
|
||
assert!(screen_text(&terminal).contains("List"));
|
||
assert_eq!(terminal.get_cursor_position().unwrap().y, 1);
|
||
editor.handle(Input::Ctrl('t'));
|
||
assert_eq!(editor.file_picker().unwrap().view, FileView::Tree);
|
||
assert!(editor.file_picker().unwrap().searching);
|
||
assert_eq!(editor.file_picker().unwrap().query, "n");
|
||
terminal
|
||
.draw(|frame| {
|
||
draw(
|
||
frame,
|
||
&editor,
|
||
&mut top,
|
||
&mut left,
|
||
&mut syntax,
|
||
&mut DocumentViewport::default(),
|
||
&mut WheelPanels::default(),
|
||
)
|
||
})
|
||
.unwrap();
|
||
assert_eq!(terminal.get_cursor_position().unwrap().y, 1);
|
||
editor.handle(Input::Escape);
|
||
assert!(editor.file_picker().unwrap().focused);
|
||
assert!(!editor.file_picker().unwrap().searching);
|
||
assert_eq!(editor.file_picker().unwrap().query, "n");
|
||
editor.handle(Input::Ctrl('t'));
|
||
editor.handle(Input::Ctrl('t'));
|
||
assert!(!editor.file_picker().unwrap().searching);
|
||
assert_eq!(editor.file_picker().unwrap().query, "n");
|
||
terminal
|
||
.draw(|frame| {
|
||
draw(
|
||
frame,
|
||
&editor,
|
||
&mut top,
|
||
&mut left,
|
||
&mut syntax,
|
||
&mut DocumentViewport::default(),
|
||
&mut WheelPanels::default(),
|
||
)
|
||
})
|
||
.unwrap();
|
||
assert!(screen_text(&terminal).contains("filter: n"));
|
||
let cursor = terminal.get_cursor_position().unwrap();
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(),
|
||
">"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn configured_views_and_view_toggle_render_in_both_presentations_with_scoped_hints() {
|
||
let directory = Directory::new();
|
||
directory.file("folder/file.txt", "");
|
||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||
editor.set_project_root(directory.0.clone());
|
||
editor.set_file_picker_view("tree").unwrap();
|
||
editor.set_file_explorer_view("list").unwrap();
|
||
editor.set_file_explorer_side("bottom").unwrap();
|
||
editor.bind("tree", "ctrl+l", None).unwrap();
|
||
editor.bind("tree", "right", None).unwrap();
|
||
editor.bind("tree", "x", Some("core.right".into())).unwrap();
|
||
for key in ["ctrl+j", "ctrl+n", "down", "ctrl+k", "up"] {
|
||
editor.bind("selection", key, None).unwrap();
|
||
}
|
||
editor
|
||
.bind("selection", "j", Some("core.down".into()))
|
||
.unwrap();
|
||
editor
|
||
.bind("selection", "k", Some("core.up".into()))
|
||
.unwrap();
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(200, 30)).unwrap();
|
||
let (mut top, mut left) = (0, 0);
|
||
let mut syntax = SyntaxCache::new();
|
||
for (key, expected) in [('o', FileView::Tree), ('e', FileView::List)] {
|
||
editor.handle(Input::Char(' '));
|
||
editor.handle(Input::Char(key));
|
||
assert_eq!(editor.file_picker().unwrap().view, expected);
|
||
if expected == FileView::Tree {
|
||
assert!(editor.file_picker().unwrap().searching);
|
||
editor.handle(Input::Escape);
|
||
}
|
||
for view in [
|
||
expected,
|
||
if expected == FileView::Tree {
|
||
FileView::List
|
||
} else {
|
||
FileView::Tree
|
||
},
|
||
] {
|
||
assert_eq!(editor.file_picker().unwrap().view, view);
|
||
terminal
|
||
.draw(|frame| {
|
||
draw(
|
||
frame,
|
||
&editor,
|
||
&mut top,
|
||
&mut left,
|
||
&mut syntax,
|
||
&mut DocumentViewport::default(),
|
||
&mut WheelPanels::default(),
|
||
)
|
||
})
|
||
.unwrap();
|
||
let text = screen_text(&terminal);
|
||
assert!(text.contains(editor.file_navigation_hint()), "{text}");
|
||
if view == FileView::Tree {
|
||
assert_ne!(
|
||
editor.file_navigation_hint(),
|
||
editor.selection_navigation_hint()
|
||
);
|
||
let cursor = terminal.get_cursor_position().unwrap();
|
||
assert_eq!(
|
||
terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(),
|
||
">"
|
||
);
|
||
editor.handle(Input::Char('x'));
|
||
assert_eq!(editor.file_picker().unwrap().query, "");
|
||
assert!(
|
||
editor
|
||
.file_picker()
|
||
.unwrap()
|
||
.entries()
|
||
.next()
|
||
.unwrap()
|
||
.expanded
|
||
);
|
||
let picker = editor.file_picker().unwrap();
|
||
let was_file = !picker.entries().nth(picker.selected).unwrap().is_dir;
|
||
editor.handle(Input::Ctrl('h'));
|
||
if was_file {
|
||
let picker = editor.file_picker().unwrap();
|
||
assert!(picker.entries().nth(picker.selected).unwrap().is_dir);
|
||
assert!(picker.entries().next().unwrap().expanded);
|
||
editor.handle(Input::Ctrl('h'));
|
||
}
|
||
assert!(
|
||
!editor
|
||
.file_picker()
|
||
.unwrap()
|
||
.entries()
|
||
.next()
|
||
.unwrap()
|
||
.expanded
|
||
);
|
||
} else {
|
||
assert_eq!(
|
||
editor.file_navigation_hint(),
|
||
editor.selection_navigation_hint()
|
||
);
|
||
assert!(text.contains("folder/file.txt"), "{text}");
|
||
}
|
||
editor.handle(Input::Ctrl('t'));
|
||
}
|
||
editor.handle(Input::Escape);
|
||
}
|
||
editor.handle(Input::Ctrl('p'));
|
||
terminal
|
||
.draw(|frame| {
|
||
draw(
|
||
frame,
|
||
&editor,
|
||
&mut top,
|
||
&mut left,
|
||
&mut syntax,
|
||
&mut DocumentViewport::default(),
|
||
&mut WheelPanels::default(),
|
||
)
|
||
})
|
||
.unwrap();
|
||
assert!(screen_text(&terminal).contains(editor.selection_navigation_hint()));
|
||
}
|
||
|
||
#[test]
|
||
fn all_dock_sides_clip_editor_gutter_selection_carets_and_cursor() {
|
||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||
editor
|
||
.apply(vec![crate::editor::Effect::Replace(
|
||
vec!["word ".repeat(40); 20].join("\n"),
|
||
)])
|
||
.unwrap();
|
||
editor.handle(Input::Char('v'));
|
||
editor.handle(Input::Ctrl('a'));
|
||
assert!(editor.selection_count() > 1);
|
||
let area = Rect::new(3, 2, 90, 26);
|
||
let mut terminal = Terminal::with_options(
|
||
ratatui::backend::TestBackend::new(100, 32),
|
||
ratatui::TerminalOptions {
|
||
viewport: ratatui::Viewport::Fixed(area),
|
||
},
|
||
)
|
||
.unwrap();
|
||
let (mut top, mut left) = (0, 0);
|
||
let mut syntax = SyntaxCache::new();
|
||
editor.handle(Input::Char(' '));
|
||
editor.handle(Input::Char('e'));
|
||
assert!(editor.file_picker().unwrap().focused);
|
||
for (name, side) in [
|
||
("left", DockSide::Left),
|
||
("right", DockSide::Right),
|
||
("top", DockSide::Top),
|
||
("bottom", DockSide::Bottom),
|
||
] {
|
||
editor.set_file_explorer_side(name).unwrap();
|
||
terminal
|
||
.draw(|frame| {
|
||
draw(
|
||
frame,
|
||
&editor,
|
||
&mut top,
|
||
&mut left,
|
||
&mut syntax,
|
||
&mut DocumentViewport::default(),
|
||
&mut WheelPanels::default(),
|
||
)
|
||
})
|
||
.unwrap();
|
||
let workspace = Rect::new(area.x, area.y, area.width, area.height - 2);
|
||
let (editor_area, panel) = dock_layout(workspace, side, true);
|
||
let panel = panel.unwrap();
|
||
assert_eq!(editor_area.area() + panel.area(), workspace.area());
|
||
assert_eq!(editor_area.intersection(panel).area(), 0);
|
||
match side {
|
||
DockSide::Left => assert_eq!(panel.right(), editor_area.x),
|
||
DockSide::Right => assert_eq!(editor_area.right(), panel.x),
|
||
DockSide::Top => assert_eq!(panel.bottom(), editor_area.y),
|
||
DockSide::Bottom => assert_eq!(editor_area.bottom(), panel.y),
|
||
}
|
||
assert!(panel.contains(terminal.get_cursor_position().unwrap()));
|
||
editor.handle(Input::Escape);
|
||
terminal
|
||
.draw(|frame| {
|
||
draw(
|
||
frame,
|
||
&editor,
|
||
&mut top,
|
||
&mut left,
|
||
&mut syntax,
|
||
&mut DocumentViewport::default(),
|
||
&mut WheelPanels::default(),
|
||
)
|
||
})
|
||
.unwrap();
|
||
assert!(editor_area.contains(terminal.get_cursor_position().unwrap()));
|
||
let buffer = terminal.backend().buffer();
|
||
for y in panel.y..panel.bottom() {
|
||
for x in panel.x..panel.right() {
|
||
assert!(
|
||
!matches!(
|
||
buffer[(x, y)].bg,
|
||
Color::Blue | Color::Magenta | Color::Yellow
|
||
),
|
||
"{side:?}: {x},{y}"
|
||
);
|
||
}
|
||
}
|
||
assert_eq!(buffer[(editor_area.x + 3, editor_area.y)].symbol(), "w");
|
||
assert!(matches!(
|
||
buffer[(editor_area.x + 3, editor_area.y)].bg,
|
||
Color::Blue | Color::Magenta
|
||
));
|
||
for y in editor_area.y..editor_area.bottom() {
|
||
for x in editor_area.x..editor_area.x + 3 {
|
||
assert_eq!(buffer[(x, y)].bg, Color::Reset);
|
||
}
|
||
}
|
||
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 horizontal_wheel_preserves_selection_bounds_display_cells_and_survives_redraw() {
|
||
let directory = Directory::new();
|
||
let text = format!("abc\t界e\u{301}{}", "xyz".repeat(20));
|
||
let path = directory.file("wide.txt", &text);
|
||
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
|
||
session.handle(Input::Char('v'));
|
||
session.handle(Input::Char('l'));
|
||
let cursor = session.editor().cursor();
|
||
let selections = session.editor().selections();
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(16, 6)).unwrap();
|
||
let mut viewports = BufferViewports::default();
|
||
terminal
|
||
.draw(|frame| viewports.draw_session(frame, &mut session))
|
||
.unwrap();
|
||
assert!(viewports.scroll(
|
||
&mut session,
|
||
document_wheel(MouseEventKind::ScrollRight, 0, 0)
|
||
));
|
||
terminal
|
||
.draw(|frame| viewports.draw_session(frame, &mut session))
|
||
.unwrap();
|
||
assert_eq!(terminal.backend().buffer()[(3, 0)].symbol(), "界");
|
||
assert_eq!(terminal.backend().buffer()[(5, 0)].symbol(), "e\u{301}");
|
||
let mut shift = document_wheel(MouseEventKind::ScrollDown, 0, 0);
|
||
shift.modifiers = KeyModifiers::SHIFT;
|
||
assert!(viewports.scroll(&mut session, shift));
|
||
terminal
|
||
.draw(|frame| viewports.draw_session(frame, &mut session))
|
||
.unwrap();
|
||
assert_eq!(viewports.offsets[&session.editor().buffer_id()], (0, 6));
|
||
for _ in 0..100 {
|
||
viewports.scroll(&mut session, shift);
|
||
}
|
||
terminal
|
||
.draw(|frame| viewports.draw_session(frame, &mut session))
|
||
.unwrap();
|
||
assert_eq!(
|
||
viewports.offsets[&session.editor().buffer_id()].1,
|
||
display_column(&text, usize::MAX) + 1 - viewports.document.text_width
|
||
);
|
||
assert_eq!(session.editor().cursor(), cursor);
|
||
assert_eq!(session.editor().selections(), selections);
|
||
assert_eq!(session.editor().lines(), std::slice::from_ref(&text));
|
||
terminal.backend_mut().resize(100, 6);
|
||
terminal
|
||
.draw(|frame| viewports.draw_session(frame, &mut session))
|
||
.unwrap();
|
||
assert_eq!(viewports.offsets[&session.editor().buffer_id()], (0, 0));
|
||
assert!(screen_text(&terminal).contains("abc"));
|
||
session.handle(Input::Escape);
|
||
session.handle(Input::Char('l'));
|
||
terminal
|
||
.draw(|frame| viewports.draw_session(frame, &mut session))
|
||
.unwrap();
|
||
assert!(terminal.backend().cursor_visible());
|
||
}
|
||
|
||
#[test]
|
||
fn wheel_scrolls_rendered_document_three_rows_without_changing_any_selection() {
|
||
let directory = Directory::new();
|
||
let path = directory.file(
|
||
"rows.txt",
|
||
&(0..12)
|
||
.map(|row| format!("abc row-{row:02}"))
|
||
.collect::<Vec<_>>()
|
||
.join("\n"),
|
||
);
|
||
let mut session = EditorSession::open(Some(path), vec![], None).unwrap();
|
||
session.handle(Input::Char('v'));
|
||
session.handle(Input::Char('e'));
|
||
session.handle(Input::Ctrl('a'));
|
||
assert_eq!(session.editor().selection_count(), 12);
|
||
let carets = session.editor().carets();
|
||
let selections = session.editor().selections();
|
||
let cursor = session.editor().cursor();
|
||
let revision = session.editor().text_revision();
|
||
let text = session.editor().lines().to_vec();
|
||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 6)).unwrap();
|
||
let mut viewports = BufferViewports::default();
|
||
terminal
|
||
.draw(|frame| viewports.draw(frame, session.editor()))
|
||
.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!(!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);
|
||
}
|
||
}
|
||
}
|