Skip to main content

mermaid_cli/render/widgets/
slash_palette.rs

1//! Slash-command palette widget — renders a filter-as-you-type list of
2//! available commands with the selected row highlighted. Visible
3//! whenever the input starts with `/`; replaces the bottom status bar
4//! while open (same screen region — see `render.rs::render_ui`).
5//!
6//! Keyboard handling lives in `event_handler.rs::handle_palette_key`.
7//! This widget is purely presentational — it consumes a pre-filtered
8//! slice and a selection index.
9
10use 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
21/// Hard cap on visible rows — anything beyond is hidden until the user
22/// narrows the filter. Current registry has 9 entries; cap at 8 means
23/// at most one row is hidden when filter is empty. If the registry
24/// grows past ~12 we should add scrolling.
25const MAX_VISIBLE_ROWS: usize = 8;
26
27pub struct SlashPaletteWidget<'a> {
28    pub theme: &'a Theme,
29    /// Already-filtered (and ordered) list of rows to display — built-ins
30    /// plus plugin prompt commands, from `filter_entries` so indices agree
31    /// with the reducer's cursor.
32    pub entries: Vec<PaletteEntry<'a>>,
33    /// Index into `commands` of the highlighted row. `render` clamps it to
34    /// the valid range (or 0 when empty), so an out-of-range value from the
35    /// caller can't panic the row slice (#103).
36    pub selected_index: usize,
37}
38
39impl<'a> Widget for SlashPaletteWidget<'a> {
40    fn render(self, area: Rect, buf: &mut Buffer) {
41        // Scroll window: when selected row falls outside the visible
42        // 8-row band, slide the window so selected stays in view.
43        // "Anchor at bottom" — once selected goes past row 7, the
44        // selection sits at the bottom row of the visible window. Same
45        // pattern as most terminal palettes (fzf, less +F).
46        let total = self.entries.len();
47        // Clamp defensively: an out-of-range `selected_index` would drive
48        // `scroll_offset` past `visible_end` and panic the
49        // `commands[scroll_offset..visible_end]` slice below (#103).
50        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        // Title: show total count + indicator when scrolled, so users
59        // know there's content above/below the visible window.
60        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        // Empty filter result: render one line of explanatory text so
80        // the user understands their typed prefix matched nothing.
81        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            // Recover the absolute index for selection comparison.
93            let absolute_index = scroll_offset + offset;
94            let is_selected = absolute_index == selected;
95
96            // Build the `/name [arg_hint]` chunk. The arg_hint is in a
97            // softer color so the eye lands on the command name first.
98            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            // Pad command column so descriptions align.
122            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        // #103: a caller that lets `selected_index` exceed the filtered list
143        // must not panic the `commands[scroll_offset..visible_end]` slice.
144        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}