mermaid_cli/render/widgets/
slash_palette.rs1use ratatui::{
11 buffer::Buffer,
12 layout::Rect,
13 style::{Modifier, Style},
14 text::{Line, Span},
15 widgets::{Block, Borders, Paragraph, Widget},
16};
17
18use crate::domain::slash_commands::SlashCommand;
19use crate::render::theme::Theme;
20
21const MAX_VISIBLE_ROWS: usize = 8;
26
27pub struct SlashPaletteWidget<'a> {
28 pub theme: &'a Theme,
29 pub commands: Vec<&'static SlashCommand>,
31 pub selected_index: usize,
35}
36
37impl<'a> Widget for SlashPaletteWidget<'a> {
38 fn render(self, area: Rect, buf: &mut Buffer) {
39 let total = self.commands.len();
45 let selected = self.selected_index.min(total.saturating_sub(1));
49 let scroll_offset = if selected >= MAX_VISIBLE_ROWS {
50 selected + 1 - MAX_VISIBLE_ROWS
51 } else {
52 0
53 };
54 let visible_end = (scroll_offset + MAX_VISIBLE_ROWS).min(total);
55
56 let title = if total > MAX_VISIBLE_ROWS {
59 format!(
60 " Commands ({}-{} of {}) ↑↓ navigate · Tab complete · Esc dismiss ",
61 scroll_offset + 1,
62 visible_end,
63 total
64 )
65 } else {
66 format!(
67 " Commands ({}) ↑↓ navigate · Tab complete · Esc dismiss ",
68 total
69 )
70 };
71
72 let block = Block::default()
73 .borders(Borders::ALL)
74 .border_style(Style::new().fg(self.theme.colors.border.to_color()))
75 .title(title);
76
77 if self.commands.is_empty() {
80 let line = Line::from(vec![Span::styled(
81 " No matching commands",
82 Style::new().fg(self.theme.colors.text_disabled.to_color()),
83 )]);
84 Paragraph::new(vec![line]).block(block).render(area, buf);
85 return;
86 }
87
88 let mut lines: Vec<Line> = Vec::with_capacity(MAX_VISIBLE_ROWS);
89 for (offset, cmd) in self.commands[scroll_offset..visible_end].iter().enumerate() {
90 let absolute_index = scroll_offset + offset;
92 let is_selected = absolute_index == selected;
93
94 let mut name_part = format!("/{}", cmd.name);
97 if let Some(hint) = cmd.arg_hint {
98 name_part.push(' ');
99 name_part.push_str(hint);
100 }
101
102 let name_style = if is_selected {
103 Style::new()
104 .fg(self.theme.colors.text_highlight.to_color())
105 .add_modifier(Modifier::BOLD | Modifier::REVERSED)
106 } else {
107 Style::new()
108 .fg(self.theme.colors.info.to_color())
109 .add_modifier(Modifier::BOLD)
110 };
111 let desc_style = if is_selected {
112 Style::new()
113 .fg(self.theme.colors.text_primary.to_color())
114 .add_modifier(Modifier::REVERSED)
115 } else {
116 Style::new().fg(self.theme.colors.text_secondary.to_color())
117 };
118
119 let padded_name = format!(" {:<22}", name_part);
121 lines.push(Line::from(vec![
122 Span::styled(padded_name, name_style),
123 Span::styled(format!(" {}", cmd.description), desc_style),
124 ]));
125 }
126
127 Paragraph::new(lines).block(block).render(area, buf);
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134 use ratatui::Terminal;
135 use ratatui::backend::TestBackend;
136 use ratatui::layout::Rect;
137
138 #[test]
139 fn out_of_bounds_selection_does_not_panic() {
140 let theme = Theme::dark();
143 let commands = crate::domain::slash_commands::filter_by_prefix("");
144 assert!(!commands.is_empty(), "registry should expose commands");
145 let widget = SlashPaletteWidget {
146 theme: &theme,
147 commands,
148 selected_index: 9999,
149 };
150 let backend = TestBackend::new(80, 12);
151 let mut term = Terminal::new(backend).expect("terminal");
152 term.draw(|f| f.render_widget(widget, Rect::new(0, 0, 80, 12)))
153 .expect("render must not panic on OOB selection");
154 }
155}