feat: add searchable command palette
This commit is contained in:
+313
-3
@@ -17,15 +17,15 @@ use ratatui::{
|
||||
Frame, Terminal,
|
||||
backend::CrosstermBackend,
|
||||
layout::{Constraint, Layout},
|
||||
style::{Color, Style},
|
||||
style::{Color, Modifier, Style},
|
||||
text::Line,
|
||||
widgets::Paragraph,
|
||||
widgets::{Block, Borders, Clear, Paragraph},
|
||||
};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::{
|
||||
editor::{Editor, Input, Mode},
|
||||
editor::{Editor, Input, Mode, Palette},
|
||||
session::EditorSession,
|
||||
};
|
||||
|
||||
@@ -339,12 +339,322 @@ fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(palette) = editor.palette() {
|
||||
draw_palette(frame, palette, editor.mode());
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_palette(frame: &mut Frame, palette: &Palette, mode: Mode) {
|
||||
let area = frame.area();
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
let [_, column, _] = Layout::horizontal([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(area.width.min(100)),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(area);
|
||||
let [_, panel, _] = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(area.height.min(16)),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(column);
|
||||
frame.render_widget(Clear, panel);
|
||||
let bordered = panel.height >= 4 && panel.width >= 4;
|
||||
let block = Block::default()
|
||||
.borders(if bordered {
|
||||
Borders::ALL
|
||||
} else {
|
||||
Borders::NONE
|
||||
})
|
||||
.title(format!(" Commands · {mode} "));
|
||||
let content = if bordered { block.inner(panel) } else { panel };
|
||||
if bordered {
|
||||
frame.render_widget(block, panel);
|
||||
}
|
||||
let [query_area, results_area, help_area] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(u16::from(content.height >= 4)),
|
||||
])
|
||||
.areas(content);
|
||||
let query = format!("> {}", palette.query);
|
||||
let query_width = display_column(&query, usize::MAX);
|
||||
let width = usize::from(query_area.width);
|
||||
let offset = query_width.saturating_sub(width - 1);
|
||||
frame.render_widget(
|
||||
Paragraph::new(visible_line(&query, offset, width))
|
||||
.style(Style::default().fg(Color::Yellow)),
|
||||
query_area,
|
||||
);
|
||||
frame.set_cursor_position((query_area.x + (query_width - offset) as u16, query_area.y));
|
||||
if palette.entries.is_empty() {
|
||||
frame.render_widget(Paragraph::new("No matching commands"), results_area);
|
||||
} else if results_area.height > 0 {
|
||||
let row_height = usize::from(results_area.height >= 2) + 1;
|
||||
let count = usize::from(results_area.height) / row_height;
|
||||
let first = palette.selected.saturating_sub(count - 1);
|
||||
let mut lines = Vec::with_capacity(count * row_height);
|
||||
for (index, entry) in palette.entries.iter().enumerate().skip(first).take(count) {
|
||||
let mut style = Style::default();
|
||||
if !entry.enabled {
|
||||
style = style.add_modifier(Modifier::DIM);
|
||||
}
|
||||
if index == palette.selected {
|
||||
style = style.bg(Color::Blue).add_modifier(Modifier::BOLD);
|
||||
}
|
||||
let bindings = if entry.bindings.is_empty() {
|
||||
"unbound".into()
|
||||
} else {
|
||||
entry.bindings.join(", ")
|
||||
};
|
||||
let modes = entry
|
||||
.modes
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
let metadata = format!("{} | {bindings} | {modes}", entry.owner);
|
||||
let label = format!(
|
||||
"{} {} ({})",
|
||||
if index == palette.selected { ">" } else { " " },
|
||||
entry.label,
|
||||
entry.name
|
||||
);
|
||||
if row_height == 2 {
|
||||
lines.push(Line::from(label).style(style));
|
||||
lines.push(Line::from(format!(" {metadata}")).style(style));
|
||||
} else {
|
||||
lines.push(Line::from(format!("{label} | {metadata}")).style(style));
|
||||
}
|
||||
}
|
||||
frame.render_widget(Paragraph::new(lines), results_area);
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!(
|
||||
"{mode} · ↑↓/Ctrl+k/n select · Enter accept · Esc/Ctrl+p cancel"
|
||||
)),
|
||||
help_area,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn screen_text(terminal: &Terminal<ratatui::backend::TestBackend>) -> String {
|
||||
let buffer = terminal.backend().buffer();
|
||||
let area = buffer.area;
|
||||
(area.y..area.bottom())
|
||||
.map(|y| {
|
||||
(area.x..area.right())
|
||||
.map(|x| buffer[(x, y)].symbol())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_renders_query_no_matches_and_restores_underlying_prompt() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor.handle(Input::Char('/'));
|
||||
for ch in "saved search".chars() {
|
||||
editor.handle(Input::Char(ch));
|
||||
}
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
for ch in "no-such-command".chars() {
|
||||
editor.handle(Input::Char(ch));
|
||||
}
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("> no-such-command"), "{text}");
|
||||
assert!(text.contains("No matching commands"), "{text}");
|
||||
assert!(text.contains("SEARCH"), "{text}");
|
||||
assert_eq!(terminal.get_cursor_position().unwrap().y, 1);
|
||||
editor.handle(Input::Enter);
|
||||
assert!(editor.palette().is_some());
|
||||
editor.handle(Input::Escape);
|
||||
assert_eq!(editor.mode(), Mode::Search);
|
||||
assert_eq!(editor.command_line(), "saved search");
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
assert!(screen_text(&terminal).contains("/saved search"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_window_follows_keyboard_selection() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
for _ in 0..20 {
|
||||
editor.handle(Input::Down);
|
||||
}
|
||||
editor.handle(Input::Ctrl('k'));
|
||||
editor.handle(Input::Ctrl('n'));
|
||||
let palette = editor.palette().unwrap();
|
||||
assert_eq!(palette.selected, 20);
|
||||
let selected = &palette.entries[palette.selected];
|
||||
let first_name = palette.entries[0].name.clone();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 10)).unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains(&selected.name), "{text}");
|
||||
assert!(!text.contains(&format!("({first_name})")), "{text}");
|
||||
let buffer = terminal.backend().buffer();
|
||||
let selected_y = (2..10).find(|&y| buffer[(1, y)].symbol() == ">").unwrap();
|
||||
assert_eq!(buffer[(1, selected_y)].bg, Color::Blue);
|
||||
assert!(buffer[(1, selected_y)].modifier.contains(Modifier::BOLD));
|
||||
assert_eq!(terminal.get_cursor_position().unwrap().y, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_shows_effective_bindings_and_dimmed_mode_ineligible_commands() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor
|
||||
.register(crate::editor::Feature {
|
||||
name: "sample".into(),
|
||||
required_capabilities: vec![],
|
||||
commands: vec!["greet".into()],
|
||||
core: false,
|
||||
})
|
||||
.unwrap();
|
||||
editor.bind("normal", "g", Some("greet".into())).unwrap();
|
||||
editor.bind("normal", "g", None).unwrap();
|
||||
editor
|
||||
.bind("normal", "ctrl+x", Some("greet".into()))
|
||||
.unwrap();
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
for ch in "greet".chars() {
|
||||
editor.handle(Input::Char(ch));
|
||||
}
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 10)).unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("sample | Ctrl+x | NORMAL"), "{text}");
|
||||
assert!(!text.contains("unbound"), "{text}");
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::Char('i'));
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
for ch in "greet".chars() {
|
||||
editor.handle(Input::Char(ch));
|
||||
}
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("sample | unbound | NORMAL"), "{text}");
|
||||
assert!(text.contains("INSERT"), "{text}");
|
||||
let cell = &terminal.backend().buffer()[(1, 2)];
|
||||
assert!(cell.modifier.contains(Modifier::DIM));
|
||||
assert_eq!(cell.bg, Color::Blue);
|
||||
editor.handle(Input::Enter);
|
||||
assert!(editor.palette().is_some());
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_query_scrolls_graphemes_tabs_and_wide_cells_on_tiny_offset_areas() {
|
||||
let palette = Palette {
|
||||
query: "long\t界e\u{301}z".into(),
|
||||
entries: vec![],
|
||||
selected: 0,
|
||||
};
|
||||
for (width, height) in [(12, 6), (8, 2), (1, 1), (1, 2), (8, 1), (12, 6)] {
|
||||
let area = ratatui::layout::Rect::new(3, 2, width, height);
|
||||
let mut terminal = Terminal::with_options(
|
||||
ratatui::backend::TestBackend::new(24, 12),
|
||||
ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Fixed(area),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw_palette(frame, &palette, Mode::Visual))
|
||||
.unwrap();
|
||||
let cursor = terminal.get_cursor_position().unwrap();
|
||||
assert!(area.contains(cursor), "{area:?}: {cursor:?}");
|
||||
let bordered = height >= 4 && width >= 4;
|
||||
let query_x = area.x + u16::from(bordered);
|
||||
let query_y = area.y + u16::from(bordered);
|
||||
let query_width = width - 2 * u16::from(bordered);
|
||||
assert_eq!(cursor.x, query_x + query_width - 1);
|
||||
assert_eq!(cursor.y, query_y);
|
||||
let query = format!("> {}", palette.query);
|
||||
let offset =
|
||||
display_column(&query, usize::MAX).saturating_sub(usize::from(query_width) - 1);
|
||||
let mut expected = ratatui::buffer::Buffer::empty(ratatui::layout::Rect::new(
|
||||
query_x,
|
||||
query_y,
|
||||
query_width,
|
||||
1,
|
||||
));
|
||||
use ratatui::widgets::Widget;
|
||||
Paragraph::new(visible_line(&query, offset, usize::from(query_width)))
|
||||
.render(expected.area, &mut expected);
|
||||
for x in query_x..query_x + query_width {
|
||||
assert_eq!(
|
||||
terminal.backend().buffer()[(x, query_y)].symbol(),
|
||||
expected[(x, query_y)].symbol()
|
||||
);
|
||||
}
|
||||
assert_eq!(terminal.backend().buffer()[(0, 0)].symbol(), " ");
|
||||
}
|
||||
for (width, height) in [(0, 1), (1, 0), (0, 0)] {
|
||||
let mut terminal =
|
||||
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw_palette(frame, &palette, Mode::Normal))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_palette_does_not_scroll_underlying_editor_viewport() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor
|
||||
.apply(vec![crate::editor::Effect::Replace(
|
||||
"first\nsecond\n0123456789界z".into(),
|
||||
)])
|
||||
.unwrap();
|
||||
editor.handle(Input::Down);
|
||||
editor.handle(Input::Down);
|
||||
editor.handle(Input::End);
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 4)).unwrap();
|
||||
let (mut top, mut left) = (0, 0);
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
let viewport = (top, left);
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
assert_eq!((top, left), viewport);
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &editor, &mut top, &mut left))
|
||||
.unwrap();
|
||||
assert_eq!((top, left), viewport);
|
||||
let cursor = terminal.get_cursor_position().unwrap();
|
||||
assert_eq!(
|
||||
terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(),
|
||||
"z"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_keeps_scrolled_content_and_prompts_inside_offset_regions() {
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user