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 whenever
3//! `input_kind::palette_rows` yields rows; replaces the bottom status bar
4//! while open (same screen region — see `render.rs::render_ui`).
5//!
6//! It has no empty state, because it is never shown empty: a `/` that
7//! matches nothing closes the palette and puts a "No commands match" note
8//! above the composer instead. An empty palette over the hidden status bar,
9//! silently eating Up/Down/Tab/Esc, was the bug.
10//!
11//! Keyboard handling lives in `event_handler.rs::handle_palette_key`.
12//! This widget is purely presentational — it consumes a pre-filtered
13//! slice and a selection index.
14
15use ratatui::{
16    buffer::Buffer,
17    layout::Rect,
18    style::{Modifier, Style},
19    text::{Line, Span},
20    widgets::{Block, Borders, Paragraph, Widget},
21};
22
23use crate::render::theme::Theme;
24use mermaid_domain::slash_commands::PaletteEntry;
25
26/// Hard cap on visible rows — anything beyond is hidden until the user
27/// narrows the filter. Current registry has 9 entries; cap at 8 means
28/// at most one row is hidden when filter is empty. If the registry
29/// grows past ~12 we should add scrolling.
30const MAX_VISIBLE_ROWS: usize = 8;
31
32pub struct SlashPaletteWidget<'a> {
33    pub theme: &'a Theme,
34    /// Already-filtered (and ordered) list of rows to display — built-ins
35    /// plus plugin prompt commands, from `filter_entries` so indices agree
36    /// with the reducer's cursor.
37    pub entries: Vec<PaletteEntry<'a>>,
38    /// Index into `commands` of the highlighted row. `render` clamps it to
39    /// the valid range (or 0 when empty), so an out-of-range value from the
40    /// caller can't panic the row slice (#103).
41    pub selected_index: usize,
42}
43
44impl<'a> Widget for SlashPaletteWidget<'a> {
45    fn render(self, area: Rect, buf: &mut Buffer) {
46        // Scroll window: when selected row falls outside the visible
47        // 8-row band, slide the window so selected stays in view.
48        // "Anchor at bottom" — once selected goes past row 7, the
49        // selection sits at the bottom row of the visible window. Same
50        // pattern as most terminal palettes (fzf, less +F).
51        let total = self.entries.len();
52        // Clamp defensively: an out-of-range `selected_index` would drive
53        // `scroll_offset` past `visible_end` and panic the
54        // `commands[scroll_offset..visible_end]` slice below (#103).
55        let selected = self.selected_index.min(total.saturating_sub(1));
56        let scroll_offset = if selected >= MAX_VISIBLE_ROWS {
57            selected + 1 - MAX_VISIBLE_ROWS
58        } else {
59            0
60        };
61        let visible_end = (scroll_offset + MAX_VISIBLE_ROWS).min(total);
62
63        // Title: show total count + indicator when scrolled, so users
64        // know there's content above/below the visible window.
65        let title = if total > MAX_VISIBLE_ROWS {
66            format!(
67                " Commands ({}-{} of {})  ↑↓ navigate · Tab complete · Esc dismiss ",
68                scroll_offset + 1,
69                visible_end,
70                total
71            )
72        } else {
73            format!(" Commands ({total})  ↑↓ navigate · Tab complete · Esc dismiss ")
74        };
75
76        let block = Block::default()
77            .borders(Borders::ALL)
78            .border_style(Style::new().fg(self.theme.colors.border.to_color()))
79            .title(title);
80
81        let mut lines: Vec<Line> = Vec::with_capacity(MAX_VISIBLE_ROWS);
82        for (offset, entry) in self.entries[scroll_offset..visible_end].iter().enumerate() {
83            // Recover the absolute index for selection comparison.
84            let absolute_index = scroll_offset + offset;
85            let is_selected = absolute_index == selected;
86
87            // Build the `/name [arg_hint]` chunk. The arg_hint is in a
88            // softer color so the eye lands on the command name first.
89            let mut name_part = format!("/{}", entry.name());
90            if let Some(hint) = entry.arg_hint() {
91                name_part.push(' ');
92                name_part.push_str(hint);
93            }
94
95            let name_style = if is_selected {
96                Style::new()
97                    .fg(self.theme.colors.text_highlight.to_color())
98                    .add_modifier(Modifier::BOLD | Modifier::REVERSED)
99            } else {
100                Style::new()
101                    .fg(self.theme.colors.info.to_color())
102                    .add_modifier(Modifier::BOLD)
103            };
104            let desc_style = if is_selected {
105                Style::new()
106                    .fg(self.theme.colors.text_primary.to_color())
107                    .add_modifier(Modifier::REVERSED)
108            } else {
109                Style::new().fg(self.theme.colors.text_secondary.to_color())
110            };
111
112            // Pad command column so descriptions align.
113            let padded_name = format!(" {name_part:<22}");
114            lines.push(Line::from(vec![
115                Span::styled(padded_name, name_style),
116                Span::styled(format!(" {}", entry.description()), desc_style),
117            ]));
118        }
119
120        Paragraph::new(lines).block(block).render(area, buf);
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use ratatui::Terminal;
128    use ratatui::backend::TestBackend;
129    use ratatui::layout::Rect;
130
131    #[test]
132    fn out_of_bounds_selection_does_not_panic() {
133        // #103: a caller that lets `selected_index` exceed the filtered list
134        // must not panic the `commands[scroll_offset..visible_end]` slice.
135        let theme = Theme::dark();
136        let entries = mermaid_domain::slash_commands::filter_entries("", &[]);
137        assert!(!entries.is_empty(), "registry should expose commands");
138        let widget = SlashPaletteWidget {
139            theme: &theme,
140            entries,
141            selected_index: 9999,
142        };
143        let backend = TestBackend::new(80, 12);
144        let mut term = Terminal::new(backend).expect("terminal");
145        term.draw(|f| f.render_widget(widget, Rect::new(0, 0, 80, 12)))
146            .expect("render must not panic on OOB selection");
147    }
148}