use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::app::Command;
use crate::ui::Render;
use crate::ui::widgets::overlay;
const WIDTH: u16 = 64;
const MAX_RESULTS: usize = 12;
pub fn draw_palette(frame: &mut Frame, r: &Render<'_>) {
let app = r.app;
let area = frame.area();
let result_rows = app.palette.filtered.len().clamp(1, MAX_RESULTS) as u16;
let height = (result_rows + 5).min(area.height);
let width = WIDTH.min(area.width.saturating_sub(2)).max(20);
let popup = overlay::centered(width, height, area);
let title = if app.command_mode() {
"Command"
} else {
"Command Palette"
};
let inner = overlay::popup(frame, popup, title, r.theme, r.glyphs);
if inner.height == 0 || inner.width == 0 {
return;
}
let [input_area, results_area, hint_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Length(1),
])
.areas(inner);
draw_input(frame, input_area, r);
if app.palette.filtered.is_empty() {
frame.render_widget(
Paragraph::new(Line::from(vec![Span::styled(
" No matches",
r.theme.dim(),
)])),
results_area,
);
} else {
draw_results(frame, results_area, r);
}
draw_hint(frame, hint_area, r);
}
fn draw_input(frame: &mut Frame, area: Rect, r: &Render<'_>) {
let app = r.app;
let prompt = if app.command_mode() { ":" } else { ">" };
let mut spans = vec![
Span::styled(format!("{prompt} "), r.theme.accent()),
Span::styled(app.palette.input.clone(), r.theme.body()),
Span::styled(r.glyphs.cursor.to_string(), r.theme.accent()),
];
if let Some(arg) = app.palette.arg.as_ref()
&& !arg.is_empty()
{
spans.push(Span::styled(
format!(" {} {arg}", r.glyphs.chevron),
r.theme.subtle(),
));
}
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn draw_results(frame: &mut Frame, area: Rect, r: &Render<'_>) {
let app = r.app;
let visible = (area.height as usize).min(app.palette.filtered.len());
if visible == 0 {
return;
}
let scroll = app
.palette
.selected
.saturating_sub(visible.saturating_sub(1));
for (i, &(cmd, _)) in app
.palette
.filtered
.iter()
.skip(scroll)
.take(visible)
.enumerate()
{
let selected = scroll + i == app.palette.selected;
let row = Rect {
x: area.x,
y: area.y + i as u16,
width: area.width,
height: 1,
};
frame.render_widget(
Paragraph::new(result_line(cmd, selected, row.width, r)),
row,
);
}
}
fn result_line(cmd: Command, selected: bool, width: u16, r: &Render<'_>) -> Line<'static> {
let theme = r.theme;
let base = if selected {
theme.selected_row()
} else {
theme.body()
};
let shortcut = cmd.shortcut();
let label = cmd.label();
let edge_w = 2usize;
let shortcut_w = shortcut.chars().count() + 2;
let label_w = (width as usize).saturating_sub(edge_w + shortcut_w);
let mut spans = Vec::with_capacity(6);
spans.push(Span::styled(
if selected {
format!("{} ", r.glyphs.sel_edge)
} else {
" ".to_string()
},
if selected {
theme.level_style(crate::ui::theme::Level::Info)
} else {
base
},
));
let query = if r.app.palette.arg.is_some() {
String::new()
} else {
r.app.palette.input.to_lowercase()
};
let highlight = base.fg(theme.accent_primary);
let truncated = r.glyphs.truncate(label, label_w);
let mut rendered = 0usize;
let mut q = query.chars().peekable();
let mut run = String::new();
let mut run_hit = false;
for ch in truncated.chars() {
let hit = q
.peek()
.is_some_and(|c| c.eq_ignore_ascii_case(&ch.to_ascii_lowercase()));
if hit {
q.next();
}
if hit != run_hit && !run.is_empty() {
spans.push(Span::styled(
std::mem::take(&mut run),
if run_hit { highlight } else { base },
));
}
run_hit = hit;
run.push(ch);
rendered += 1;
}
if !run.is_empty() {
spans.push(Span::styled(run, if run_hit { highlight } else { base }));
}
spans.push(Span::styled(
" ".repeat(label_w.saturating_sub(rendered)),
base,
));
spans.push(Span::styled(
format!("{shortcut:>w$} ", w = shortcut_w.saturating_sub(2)),
if selected { base } else { theme.subtle() },
));
Line::from(spans)
}
fn draw_hint(frame: &mut Frame, area: Rect, r: &Render<'_>) {
let hint = if r.app.command_mode() {
Line::from(vec![
Span::styled(" try ", r.theme.subtle()),
Span::styled("kill firefox", r.theme.dim()),
Span::styled(" ", r.theme.subtle()),
Span::styled("sort mem", r.theme.dim()),
Span::styled(" ", r.theme.subtle()),
Span::styled("theme mono", r.theme.dim()),
])
} else {
Line::from(vec![
Span::styled(" Enter ", r.theme.key()),
Span::styled(" run ", r.theme.key_desc()),
Span::styled(" : ", r.theme.key()),
Span::styled(" command with arguments ", r.theme.key_desc()),
Span::styled(" Esc ", r.theme.key()),
Span::styled(" close ", r.theme.key_desc()),
])
};
frame.render_widget(Paragraph::new(hint), area);
}
#[cfg(test)]
mod tests {
use crate::app::{AppState, Overlay, Tab};
use crate::terminal::ColorSupport;
use crate::ui::test_support::*;
fn palette_text(app: &AppState) -> String {
all_text(&render_with(app, 100, 30))
}
fn open(input: &str) -> AppState {
let mut app = app_with_data();
app.overlay = Overlay::Palette;
app.palette.input = input.to_string();
app.palette.refilter_ctx(&[], Some(app.tab));
app
}
#[test]
fn palette_renders_and_names_itself() {
let app = open("");
assert!(palette_text(&app).contains("Command Palette"));
}
#[test]
fn palette_lists_commands_with_their_shortcuts() {
let app = open("");
let text = palette_text(&app);
assert!(text.contains("Quit"));
assert!(text.contains("Help"));
}
#[test]
fn palette_says_when_nothing_matches() {
let app = open("zzzzzz");
assert!(palette_text(&app).contains("No matches"));
}
#[test]
fn palette_hides_argument_forms_until_their_verb_is_typed() {
let empty = palette_text(&open(""));
assert!(
!empty.contains("Kill process by name"),
"an argument command with no argument is noise"
);
let typed = palette_text(&open("kill firefox"));
assert!(typed.contains("Kill process by name"));
}
#[test]
fn palette_shows_the_parsed_argument() {
let text = palette_text(&open("kill firefox"));
assert!(
text.contains("firefox"),
"the target must be visible before Enter:\n{text}"
);
}
#[test]
fn command_mode_advertises_the_argument_forms() {
let mut app = open("");
app.overlay = Overlay::Command;
let text = palette_text(&app);
assert!(text.contains("Command"));
assert!(text.contains("kill firefox"), "usage hint missing:\n{text}");
}
#[test]
fn palette_ranks_the_active_tab_first() {
let mut app = app_with_data();
app.tab = Tab::Containers;
app.overlay = Overlay::Palette;
app.palette.input = "sort".to_string();
app.palette.refilter_ctx(&[], Some(Tab::Containers));
let first = app.palette.filtered.first().map(|&(c, _)| c.label());
assert!(
first.is_some_and(|l| l.contains("container")),
"on the Containers tab, a sort query should surface container sorts first, got {first:?}"
);
}
#[test]
fn palette_offers_the_kube_commands_that_had_no_entry_before() {
let app = open("kube");
let text = palette_text(&app);
assert!(
text.contains("namespace scope") || text.contains("Pods"),
"kube sub-views were unreachable from the 0.4 palette:\n{text}"
);
}
#[test]
fn palette_survives_tiny_terminals_and_ascii() {
let mut app = open("s");
for (w, h) in [(1u16, 1u16), (10, 4), (24, 8), (40, 12)] {
let _ = render_with(&app, w, h);
}
let buf = render_caps(&mut app, 80, 24, ColorSupport::Basic, false);
assert!(all_text(&buf).is_ascii());
}
#[test]
fn palette_scrolls_to_keep_the_selection_visible() {
let mut app = open("");
app.palette.selected = app.palette.filtered.len() - 1;
let text = palette_text(&app);
let last = app.palette.filtered.last().unwrap().0.label();
assert!(text.contains(last), "selection scrolled out of view");
}
#[test]
fn palette_is_absent_when_closed() {
let app = app_with_data();
assert!(!palette_text(&app).contains("Command Palette"));
}
}