feat: add floating and docked searchable file picker
This commit is contained in:
+671
-7
@@ -18,7 +18,7 @@ use crossterm::{
|
||||
use ratatui::{
|
||||
Frame, Terminal,
|
||||
backend::CrosstermBackend,
|
||||
layout::{Constraint, Layout},
|
||||
layout::{Constraint, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::Line,
|
||||
widgets::{Block, Borders, Clear, Paragraph},
|
||||
@@ -28,6 +28,7 @@ use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::{
|
||||
editor::{Editor, Input, Mode, Palette},
|
||||
files::{DockSide, FilePicker, FilePresentation},
|
||||
session::EditorSession,
|
||||
};
|
||||
|
||||
@@ -219,12 +220,21 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
|
||||
return;
|
||||
}
|
||||
let prompting = matches!(editor.mode(), Mode::Command | Mode::Search);
|
||||
let [viewport, status_area, prompt_area] = Layout::vertical([
|
||||
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)),
|
||||
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([
|
||||
@@ -239,7 +249,7 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
|
||||
let height = usize::from(viewport.height);
|
||||
let width = usize::from(viewport.width);
|
||||
let cursor = editor.cursor();
|
||||
if height > 0 {
|
||||
if height > 0 && width > 0 {
|
||||
if cursor.row < *top {
|
||||
*top = cursor.row;
|
||||
} else if cursor.row >= top.saturating_add(height) {
|
||||
@@ -372,7 +382,7 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
|
||||
Paragraph::new(status).style(Style::default().fg(Color::Black).bg(Color::Cyan)),
|
||||
status_area,
|
||||
);
|
||||
if prompting && prompt_area.height > 0 {
|
||||
if prompting && prompt_area.height > 0 && prompt_area.width > 0 {
|
||||
let prefix = if editor.mode() == Mode::Search {
|
||||
'/'
|
||||
} else {
|
||||
@@ -381,7 +391,7 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
|
||||
let command = format!("{prefix}{}", editor.command_line());
|
||||
let command_width = display_column(&command, usize::MAX);
|
||||
let prompt_width = usize::from(prompt_area.width);
|
||||
let offset = command_width.saturating_sub(prompt_width - 1);
|
||||
let offset = command_width.saturating_sub(prompt_width.saturating_sub(1));
|
||||
frame.render_widget(
|
||||
Paragraph::new(visible_line(&command, offset, prompt_width)),
|
||||
prompt_area,
|
||||
@@ -392,7 +402,7 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
|
||||
));
|
||||
} else {
|
||||
frame.render_widget(Paragraph::new(editor.message()), prompt_area);
|
||||
if height > 0 {
|
||||
if height > 0 && width > 0 {
|
||||
let column = display_column(&editor.lines()[cursor.row], cursor.column);
|
||||
frame.set_cursor_position((
|
||||
viewport.x + (column - *left) as u16,
|
||||
@@ -400,11 +410,191 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(picker) = editor.file_picker() {
|
||||
if picker.presentation == FilePresentation::Floating {
|
||||
let [_, column, _] = Layout::horizontal([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(area.width.min(84)),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(area);
|
||||
let [_, panel, _] = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(area.height.min(18)),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(column);
|
||||
frame.render_widget(Clear, panel);
|
||||
draw_file_list(frame, picker, panel);
|
||||
} else if let Some(panel) = dock {
|
||||
draw_file_list(frame, picker, panel);
|
||||
}
|
||||
}
|
||||
if let Some(palette) = editor.palette() {
|
||||
draw_palette(frame, palette, editor.mode());
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if panel.width == 0 || panel.height == 0 {
|
||||
return;
|
||||
}
|
||||
let color = if picker.focused {
|
||||
Color::Cyan
|
||||
} else {
|
||||
Color::DarkGray
|
||||
};
|
||||
let bordered = panel.width >= 4 && panel.height >= 4;
|
||||
let title = match picker.presentation {
|
||||
FilePresentation::Floating => " Files ",
|
||||
FilePresentation::Docked => " Explorer ",
|
||||
};
|
||||
let block = Block::default()
|
||||
.borders(if bordered {
|
||||
Borders::ALL
|
||||
} else {
|
||||
Borders::NONE
|
||||
})
|
||||
.border_style(Style::default().fg(color))
|
||||
.title(title);
|
||||
let content = if bordered { block.inner(panel) } else { panel };
|
||||
if bordered {
|
||||
frame.render_widget(block, panel);
|
||||
}
|
||||
let show_root = content.height >= 4;
|
||||
let show_error = picker.error.is_some() && content.height >= 2;
|
||||
let show_help = content.height >= 5 + u16::from(show_error);
|
||||
let [query_area, root_area, error_area, results_area, help_area] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(u16::from(show_root)),
|
||||
Constraint::Length(u16::from(show_error)),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(u16::from(show_help)),
|
||||
])
|
||||
.areas(content);
|
||||
let query = format!("> {}", display_text(&picker.query));
|
||||
let query_width = display_column(&query, usize::MAX);
|
||||
let width = usize::from(query_area.width);
|
||||
let offset = query_width.saturating_sub(width.saturating_sub(1));
|
||||
frame.render_widget(
|
||||
Paragraph::new(visible_line(&query, offset, width)).style(Style::default().fg(
|
||||
if picker.focused {
|
||||
Color::Yellow
|
||||
} else {
|
||||
Color::DarkGray
|
||||
},
|
||||
)),
|
||||
query_area,
|
||||
);
|
||||
if picker.focused && query_area.width > 0 && query_area.height > 0 {
|
||||
frame.set_cursor_position((query_area.x + (query_width - offset) as u16, query_area.y));
|
||||
}
|
||||
if show_root {
|
||||
frame.render_widget(
|
||||
Paragraph::new(display_text(&picker.root.to_string_lossy()))
|
||||
.style(Style::default().fg(Color::DarkGray)),
|
||||
root_area,
|
||||
);
|
||||
}
|
||||
if show_error {
|
||||
frame.render_widget(
|
||||
Paragraph::new(display_text(picker.error.as_deref().unwrap()))
|
||||
.style(Style::default().fg(Color::Red)),
|
||||
error_area,
|
||||
);
|
||||
}
|
||||
let entries = picker.entries();
|
||||
if entries.len() == 0 {
|
||||
frame.render_widget(
|
||||
Paragraph::new(if picker.query.is_empty() {
|
||||
"No project files"
|
||||
} else {
|
||||
"No matching files"
|
||||
}),
|
||||
results_area,
|
||||
);
|
||||
} else if results_area.height > 0 {
|
||||
let count = usize::from(results_area.height);
|
||||
let first = picker.selected.saturating_sub(count - 1);
|
||||
let lines: Vec<Line> = entries
|
||||
.enumerate()
|
||||
.skip(first)
|
||||
.take(count)
|
||||
.map(|(index, entry)| {
|
||||
let selected = index == picker.selected;
|
||||
let style = if selected {
|
||||
Style::default()
|
||||
.bg(if picker.focused {
|
||||
Color::Blue
|
||||
} else {
|
||||
Color::DarkGray
|
||||
})
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
Line::styled(
|
||||
format!("{} {}", if selected { ">" } else { " " }, entry.label),
|
||||
style,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
frame.render_widget(Paragraph::new(lines), results_area);
|
||||
}
|
||||
let help = if picker.focused {
|
||||
match picker.presentation {
|
||||
FilePresentation::Floating => "↑↓ Enter · Esc/^W close · ^R scan",
|
||||
FilePresentation::Docked => "↑↓ Enter · Esc/Tab edit · ^W · ^R",
|
||||
}
|
||||
} else {
|
||||
"Editor focus · invoke explorer to search"
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(help).style(Style::default().fg(Color::DarkGray)),
|
||||
help_area,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_palette(frame: &mut Frame, palette: &Palette, mode: Mode) {
|
||||
let area = frame.area();
|
||||
if area.width == 0 || area.height == 0 {
|
||||
@@ -548,6 +738,480 @@ mod tests {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[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 mut picker = FilePicker::scan(directory.0.clone(), FilePresentation::Floating).unwrap();
|
||||
assert!(
|
||||
picker
|
||||
.entries()
|
||||
.any(|entry| directory.0.join(&entry.path) == special)
|
||||
);
|
||||
let count = picker.entries().len();
|
||||
picker.selected = count - 1;
|
||||
let label = picker.entries().last().unwrap().label.clone();
|
||||
assert_eq!(label, "界e\u{301} space�name");
|
||||
picker.query = "long\t界e\u{301}z".into();
|
||||
let area = Rect::new(3, 2, 24, 9);
|
||||
let mut terminal = Terminal::with_options(
|
||||
ratatui::backend::TestBackend::new(40, 15),
|
||||
ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Fixed(area),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw_file_list(frame, &picker, area))
|
||||
.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 mut picker = FilePicker::scan(directory.0.clone(), FilePresentation::Docked).unwrap();
|
||||
let area = Rect::new(0, 0, 100, 12);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw_file_list(frame, &picker, area))
|
||||
.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);
|
||||
})
|
||||
.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))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("Files"));
|
||||
assert!(screen_text(&terminal).contains("Esc/^W"));
|
||||
assert_eq!(terminal.backend().buffer()[(0, 0)].fg, Color::Cyan);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_query_cursor_and_dock_layout_survive_tiny_offset_resizes() {
|
||||
let directory = Directory::new();
|
||||
let mut picker = FilePicker::scan(directory.0.clone(), FilePresentation::Docked).unwrap();
|
||||
picker.query = "long界e\u{301}z".into();
|
||||
for (width, height) in [(12, 6), (8, 2), (1, 1), (1, 2), (8, 1), (12, 6)] {
|
||||
let area = Rect::new(3, 2, width, height);
|
||||
let mut terminal = Terminal::with_options(
|
||||
ratatui::backend::TestBackend::new(24, 12),
|
||||
ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Fixed(area),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw_file_list(frame, &picker, area))
|
||||
.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()))
|
||||
.unwrap();
|
||||
assert!(
|
||||
dock_layout(Rect::new(3, 2, width, height), DockSide::Right, true)
|
||||
.1
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_dock_sides_clip_editor_gutter_selection_carets_and_cursor() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor
|
||||
.apply(vec![crate::editor::Effect::Replace(
|
||||
vec!["word ".repeat(40); 20].join("\n"),
|
||||
)])
|
||||
.unwrap();
|
||||
editor.handle(Input::Char('v'));
|
||||
editor.handle(Input::Ctrl('a'));
|
||||
assert!(editor.selection_count() > 1);
|
||||
let area = Rect::new(3, 2, 90, 26);
|
||||
let mut terminal = Terminal::with_options(
|
||||
ratatui::backend::TestBackend::new(100, 32),
|
||||
ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Fixed(area),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('e'));
|
||||
assert!(editor.file_picker().unwrap().focused);
|
||||
for (name, side) in [
|
||||
("left", DockSide::Left),
|
||||
("right", DockSide::Right),
|
||||
("top", DockSide::Top),
|
||||
("bottom", DockSide::Bottom),
|
||||
] {
|
||||
editor.set_file_explorer_side(name).unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
let workspace = Rect::new(area.x, area.y, area.width, area.height - 2);
|
||||
let (editor_area, panel) = dock_layout(workspace, side, true);
|
||||
let panel = panel.unwrap();
|
||||
assert_eq!(editor_area.area() + panel.area(), workspace.area());
|
||||
assert_eq!(editor_area.intersection(panel).area(), 0);
|
||||
match side {
|
||||
DockSide::Left => assert_eq!(panel.right(), editor_area.x),
|
||||
DockSide::Right => assert_eq!(editor_area.right(), panel.x),
|
||||
DockSide::Top => assert_eq!(panel.bottom(), editor_area.y),
|
||||
DockSide::Bottom => assert_eq!(editor_area.bottom(), panel.y),
|
||||
}
|
||||
assert!(panel.contains(terminal.get_cursor_position().unwrap()));
|
||||
editor.handle(Input::Escape);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
assert!(editor_area.contains(terminal.get_cursor_position().unwrap()));
|
||||
let buffer = terminal.backend().buffer();
|
||||
for y in panel.y..panel.bottom() {
|
||||
for x in panel.x..panel.right() {
|
||||
assert!(
|
||||
!matches!(
|
||||
buffer[(x, y)].bg,
|
||||
Color::Blue | Color::Magenta | Color::Yellow
|
||||
),
|
||||
"{side:?}: {x},{y}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(buffer[(editor_area.x + 3, editor_area.y)].symbol(), "w");
|
||||
assert!(matches!(
|
||||
buffer[(editor_area.x + 3, editor_area.y)].bg,
|
||||
Color::Blue | Color::Magenta
|
||||
));
|
||||
for y in editor_area.y..editor_area.bottom() {
|
||||
for x in editor_area.x..editor_area.x + 3 {
|
||||
assert_eq!(buffer[(x, y)].bg, Color::Reset);
|
||||
}
|
||||
}
|
||||
for x in area.x..area.right() {
|
||||
assert_eq!(buffer[(x, area.bottom() - 2)].bg, Color::Cyan);
|
||||
}
|
||||
assert_eq!(buffer[(0, 0)].symbol(), " ");
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('e'));
|
||||
assert!(editor.file_picker().unwrap().focused);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_picker_clears_a_centered_overlay_without_moving_editor_regions() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor
|
||||
.apply(vec![crate::editor::Effect::Replace(
|
||||
vec!["x".repeat(120); 30].join("\n"),
|
||||
)])
|
||||
.unwrap();
|
||||
let area = Rect::new(3, 2, 110, 26);
|
||||
let mut terminal = Terminal::with_options(
|
||||
ratatui::backend::TestBackend::new(120, 32),
|
||||
ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Fixed(area),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
let before = (top, left);
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('o'));
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
let panel = Rect::new(area.x + 13, area.y + 4, 84, 18);
|
||||
assert_eq!((top, left), before);
|
||||
assert_eq!(
|
||||
terminal.get_cursor_position().unwrap(),
|
||||
ratatui::layout::Position::new(panel.x + 3, panel.y + 1)
|
||||
);
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert_eq!(buffer[(panel.x, panel.y)].fg, Color::Cyan);
|
||||
assert_eq!(buffer[(panel.right() - 2, panel.y + 1)].symbol(), " ");
|
||||
assert_eq!(buffer[(area.x + 3, area.y)].symbol(), "x");
|
||||
assert_eq!(buffer[(area.x, area.bottom() - 2)].bg, Color::Cyan);
|
||||
assert_eq!(buffer[(0, 0)].symbol(), " ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_conversion_focus_palette_and_closure_preserve_buffer_viewport_cache() {
|
||||
let directory = Directory::new();
|
||||
let a = directory.file("a", &vec!["a".repeat(80); 30].join("\n"));
|
||||
let b = directory.file("b", "other");
|
||||
let mut editor = Editor::open(Some(a), vec![]).unwrap();
|
||||
let a_id = editor.buffer_id();
|
||||
for _ in 0..20 {
|
||||
editor.handle(Input::Down);
|
||||
}
|
||||
editor.handle(Input::End);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(50, 10)).unwrap();
|
||||
let mut viewports = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
let before = viewports.offsets[&a_id];
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('o'));
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(viewports.offsets[&a_id], before);
|
||||
assert!(screen_text(&terminal).contains("Files"));
|
||||
editor.handle(Input::Escape);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(viewports.offsets[&a_id], before);
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('e'));
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("Explorer"));
|
||||
editor.handle(Input::Escape);
|
||||
let docked = editor.file_picker().unwrap();
|
||||
assert!(!docked.focused);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
let dock_offsets = viewports.offsets[&a_id];
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("Commands"));
|
||||
assert_eq!(terminal.get_cursor_position().unwrap().y, 1);
|
||||
assert_eq!(viewports.offsets[&a_id], dock_offsets);
|
||||
editor.handle(Input::Escape);
|
||||
editor.execute(&format!("edit {}", b.display()));
|
||||
let b_id = editor.buffer_id();
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert!(editor.file_picker().is_some());
|
||||
assert_eq!(viewports.offsets[&b_id], (0, 0));
|
||||
editor.execute(&format!("buffer {a_id}"));
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert_eq!(viewports.offsets[&a_id], dock_offsets);
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('o'));
|
||||
assert_eq!(
|
||||
editor.file_picker().unwrap().presentation,
|
||||
FilePresentation::Floating
|
||||
);
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("Files"));
|
||||
editor.handle(Input::Ctrl('w'));
|
||||
terminal
|
||||
.draw(|frame| viewports.draw(frame, &editor))
|
||||
.unwrap();
|
||||
assert!(editor.file_picker().is_none());
|
||||
assert_eq!(viewports.offsets[&b_id], (0, 0));
|
||||
assert!(screen_text(&terminal).contains("a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docked_search_highlight_and_cursor_survive_focus_and_offset_resizes() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor
|
||||
.apply(vec![crate::editor::Effect::Replace(
|
||||
"0123456789界e\u{301}z\nsecond".into(),
|
||||
)])
|
||||
.unwrap();
|
||||
editor.handle(Input::Char('/'));
|
||||
editor.handle(Input::Char('界'));
|
||||
editor.handle(Input::Enter);
|
||||
assert!(editor.search_match().is_some());
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('e'));
|
||||
let mut terminal = Terminal::with_options(
|
||||
ratatui::backend::TestBackend::new(100, 32),
|
||||
ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Fixed(Rect::new(3, 2, 90, 26)),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
for (name, side) in [
|
||||
("left", DockSide::Left),
|
||||
("right", DockSide::Right),
|
||||
("top", DockSide::Top),
|
||||
("bottom", DockSide::Bottom),
|
||||
] {
|
||||
editor.set_file_explorer_side(name).unwrap();
|
||||
for (width, height) in [
|
||||
(90, 26),
|
||||
(12, 6),
|
||||
(8, 2),
|
||||
(1, 1),
|
||||
(1, 2),
|
||||
(0, 1),
|
||||
(1, 0),
|
||||
(90, 26),
|
||||
] {
|
||||
let area = Rect::new(3, 2, width, height);
|
||||
terminal.resize(area).unwrap();
|
||||
let area = terminal.get_frame().area();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
if width > 0 && height > 0 {
|
||||
let workspace = Rect::new(
|
||||
area.x,
|
||||
area.y,
|
||||
width,
|
||||
height - u16::from(height >= 3) - u16::from(height >= 2),
|
||||
);
|
||||
let (_, panel) = dock_layout(workspace, side, true);
|
||||
let cursor = terminal.get_cursor_position().unwrap();
|
||||
assert!(
|
||||
panel.unwrap().contains(cursor),
|
||||
"{side:?} {area:?} {panel:?}: {cursor:?}"
|
||||
);
|
||||
}
|
||||
editor.handle(Input::Escape);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
if width > 0 && height > 0 {
|
||||
let cursor = terminal.get_cursor_position().unwrap();
|
||||
assert!(area.contains(cursor), "{side:?} {area:?}: {cursor:?}");
|
||||
let workspace = Rect::new(
|
||||
area.x,
|
||||
area.y,
|
||||
width,
|
||||
height - u16::from(height >= 3) - u16::from(height >= 2),
|
||||
);
|
||||
let (editor_area, panel) = dock_layout(workspace, side, false);
|
||||
assert!(editor_area.contains(cursor), "{side:?}: {cursor:?}");
|
||||
let buffer = terminal.backend().buffer();
|
||||
if width == 90 {
|
||||
let mut yellow = 0;
|
||||
for y in editor_area.y..editor_area.bottom() {
|
||||
for x in editor_area.x..editor_area.right() {
|
||||
yellow += usize::from(buffer[(x, y)].bg == Color::Yellow);
|
||||
}
|
||||
}
|
||||
assert!(yellow > 0);
|
||||
}
|
||||
if let Some(panel) = panel {
|
||||
for y in panel.y..panel.bottom() {
|
||||
for x in panel.x..panel.right() {
|
||||
assert_ne!(buffer[(x, y)].bg, Color::Yellow);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(buffer[(0, 0)].symbol(), " ");
|
||||
}
|
||||
editor.handle(Input::Char(' '));
|
||||
editor.handle(Input::Char('e'));
|
||||
assert!(editor.file_picker().unwrap().focused);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_leader_is_visible_before_long_paths_and_unknown_keys_do_not_edit() {
|
||||
let directory = Directory::new();
|
||||
|
||||
Reference in New Issue
Block a user