use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
};
use super::theme::Theme;
use crate::app::AppState;
pub fn draw_palette(frame: &mut Frame, app: &AppState, theme: &Theme) {
let area = frame.area();
let width = 60.min(area.width.saturating_sub(4));
let max_results = 10;
let result_rows = app.palette.filtered.len().min(max_results);
let height = (3
+ result_rows as u16
+ if app.palette.filtered.is_empty() {
1
} else {
0
})
.min(area.height.saturating_sub(2));
if width < 10 || height < 3 {
return; }
let popup = centered_rect(width, height, area);
frame.render_widget(Clear, popup);
let block = Block::default()
.title(" Command Palette ")
.title_style(
Style::default()
.fg(theme.bg)
.bg(theme.accent_primary)
.add_modifier(Modifier::BOLD),
)
.borders(Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(Style::default().fg(theme.accent_primary).bg(theme.bg));
let inner = block.inner(popup);
frame.render_widget(block, popup);
if inner.height == 0 || inner.width == 0 {
return;
}
let chunks = Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).split(inner);
let input_area = chunks[0];
let results_area = chunks[1];
let input_text = format!("> {}\u{2588}", app.palette.input); let input_line = Paragraph::new(Line::from(Span::styled(
input_text,
Style::default().fg(theme.fg),
)));
frame.render_widget(input_line, input_area);
if app.palette.filtered.is_empty() {
if results_area.height > 0 {
let no_match = Paragraph::new(Line::from(Span::styled(
" No matches",
Style::default().fg(theme.text_dim),
)));
frame.render_widget(no_match, results_area);
}
return;
}
let visible_count = (results_area.height as usize).min(app.palette.filtered.len());
let scroll_offset = if app.palette.selected >= visible_count {
app.palette.selected - visible_count + 1
} else {
0
};
for (i, &(cmd, _score)) in app
.palette
.filtered
.iter()
.skip(scroll_offset)
.enumerate()
.take(visible_count)
{
let list_idx = scroll_offset + i;
let is_selected = list_idx == app.palette.selected;
let row_area = Rect {
x: results_area.x,
y: results_area.y + i as u16,
width: results_area.width,
height: 1,
};
let label = cmd.label();
let shortcut = cmd.shortcut();
let (label_style, shortcut_style) = if is_selected {
(
Style::default()
.fg(theme.selection_fg)
.add_modifier(Modifier::BOLD)
.bg(theme.selection_bg),
Style::default()
.fg(theme.selection_fg)
.bg(theme.selection_bg),
)
} else {
(
Style::default().fg(theme.fg),
Style::default().fg(theme.text_dim),
)
};
let label_str = format!(" {}", label);
let shortcut_str = format!(" {}", shortcut);
let padding_len = row_area
.width
.saturating_sub(label_str.len() as u16 + shortcut_str.len() as u16);
let padding = " ".repeat(padding_len as usize);
let line = Line::from(vec![
Span::styled(label_str, label_style),
Span::styled(
padding,
if is_selected {
Style::default().bg(theme.selection_bg)
} else {
Style::default()
},
),
Span::styled(shortcut_str, shortcut_style),
]);
if is_selected {
let bg = Paragraph::new(Line::from(" ".repeat(row_area.width as usize)))
.style(Style::default().bg(theme.selection_bg));
frame.render_widget(bg, row_area);
}
frame.render_widget(Paragraph::new(line), row_area);
}
}
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
Rect::new(x, y, width.min(area.width), height.min(area.height))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::AppState;
use ratatui::{Terminal, backend::TestBackend};
fn render_with(app: &AppState, width: u16, height: u16) -> ratatui::buffer::Buffer {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
crate::ui::draw_root(frame, app);
})
.unwrap();
terminal.backend().buffer().clone()
}
fn buffer_contains(buf: &ratatui::buffer::Buffer, needle: &str) -> bool {
let height = buf.area.height;
(0..height).any(|row| {
let width = buf.area.width;
let line: String = (0..width)
.map(|col| buf.cell((col, row)).map(|c| c.symbol()).unwrap_or(" "))
.collect();
line.contains(needle)
})
}
fn buffer_line_text(buf: &ratatui::buffer::Buffer, row: u16) -> String {
let width = buf.area.width;
(0..width)
.map(|col| buf.cell((col, row)).map(|c| c.symbol()).unwrap_or(" "))
.collect::<String>()
.trim_end()
.to_string()
}
#[test]
fn test_palette_renders_overlay() {
let mut app = AppState::new();
app.show_palette = true;
app.palette.refilter();
let buf = render_with(&app, 80, 24);
assert!(
buffer_contains(&buf, "Command Palette"),
"Palette title should be visible"
);
}
#[test]
fn test_palette_shows_all_commands_when_empty() {
let mut app = AppState::new();
app.show_palette = true;
app.palette.refilter();
let buf = render_with(&app, 80, 24);
assert!(buffer_contains(&buf, "Quit"), "Should show Quit command");
assert!(
buffer_contains(&buf, "Toggle tree view"),
"Should show Toggle tree view"
);
}
#[test]
fn test_palette_shows_shortcuts() {
let mut app = AppState::new();
app.show_palette = true;
app.palette.refilter();
let buf = render_with(&app, 80, 24);
assert!(
buffer_contains(&buf, "F3"),
"Should show F3 shortcut for Sort by CPU"
);
}
#[test]
fn test_palette_no_matches() {
let mut app = AppState::new();
app.show_palette = true;
app.palette.input = "zzzzz".to_string();
app.palette.refilter();
let buf = render_with(&app, 80, 24);
assert!(
buffer_contains(&buf, "No matches"),
"Should show 'No matches' message"
);
}
#[test]
fn test_palette_centered() {
let mut app = AppState::new();
app.show_palette = true;
app.palette.refilter();
let buf = render_with(&app, 80, 24);
for row in 0..24 {
let line = buffer_line_text(&buf, row);
if line.contains("Command Palette") {
assert!(
line.starts_with(' '),
"Palette should be centered, not left-aligned"
);
break;
}
}
}
#[test]
fn test_palette_not_shown_when_closed() {
let app = AppState::new();
let buf = render_with(&app, 80, 24);
assert!(
!buffer_contains(&buf, "Command Palette"),
"Palette should not be visible when closed"
);
}
#[test]
fn test_palette_minimal_terminal_no_panic() {
let mut app = AppState::new();
app.show_palette = true;
app.palette.refilter();
let _buf = render_with(&app, 10, 5);
let _buf = render_with(&app, 1, 1);
}
#[test]
fn test_centered_rect() {
let area = Rect::new(0, 0, 80, 24);
let r = centered_rect(60, 16, area);
assert_eq!(r.x, 10);
assert_eq!(r.y, 4);
assert_eq!(r.width, 60);
assert_eq!(r.height, 16);
}
#[test]
fn test_centered_rect_small_area() {
let area = Rect::new(0, 0, 20, 10);
let r = centered_rect(60, 16, area);
assert_eq!(r.width, 20);
assert_eq!(r.height, 10);
}
}