feat: add searchable command palette

This commit is contained in:
Valdemar Lorenzen
2026-10-08 11:06:09 +02:00
parent 6ae5ccb220
commit 619c180316
6 changed files with 1171 additions and 73 deletions
+313 -3
View File
@@ -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();