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