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::SlashCommand;
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 commands to display.
30    pub commands: Vec<&'static SlashCommand>,
31    /// Index into `commands` of the highlighted row. Caller is
32    /// responsible for keeping it in bounds (or setting 0 on empty).
33    pub selected_index: usize,
34}
35
36impl<'a> Widget for SlashPaletteWidget<'a> {
37    fn render(self, area: Rect, buf: &mut Buffer) {
38        // Scroll window: when selected row falls outside the visible
39        // 8-row band, slide the window so selected stays in view.
40        // "Anchor at bottom" — once selected goes past row 7, the
41        // selection sits at the bottom row of the visible window. Same
42        // pattern as most terminal palettes (fzf, less +F).
43        let total = self.commands.len();
44        let scroll_offset = if self.selected_index >= MAX_VISIBLE_ROWS {
45            self.selected_index + 1 - MAX_VISIBLE_ROWS
46        } else {
47            0
48        };
49        let visible_end = (scroll_offset + MAX_VISIBLE_ROWS).min(total);
50
51        // Title: show total count + indicator when scrolled, so users
52        // know there's content above/below the visible window.
53        let title = if total > MAX_VISIBLE_ROWS {
54            format!(
55                " Commands ({}-{} of {})  ↑↓ navigate · Tab complete · Esc dismiss ",
56                scroll_offset + 1,
57                visible_end,
58                total
59            )
60        } else {
61            format!(
62                " Commands ({})  ↑↓ navigate · Tab complete · Esc dismiss ",
63                total
64            )
65        };
66
67        let block = Block::default()
68            .borders(Borders::ALL)
69            .border_style(Style::new().fg(self.theme.colors.border.to_color()))
70            .title(title);
71
72        // Empty filter result: render one line of explanatory text so
73        // the user understands their typed prefix matched nothing.
74        if self.commands.is_empty() {
75            let line = Line::from(vec![Span::styled(
76                "  No matching commands",
77                Style::new().fg(self.theme.colors.text_disabled.to_color()),
78            )]);
79            Paragraph::new(vec![line]).block(block).render(area, buf);
80            return;
81        }
82
83        let mut lines: Vec<Line> = Vec::with_capacity(MAX_VISIBLE_ROWS);
84        for (offset, cmd) in self.commands[scroll_offset..visible_end].iter().enumerate() {
85            // Recover the absolute index for selection comparison.
86            let absolute_index = scroll_offset + offset;
87            let is_selected = absolute_index == self.selected_index;
88
89            // Build the `/name [arg_hint]` chunk. The arg_hint is in a
90            // softer color so the eye lands on the command name first.
91            let mut name_part = format!("/{}", cmd.name);
92            if let Some(hint) = cmd.arg_hint {
93                name_part.push(' ');
94                name_part.push_str(hint);
95            }
96
97            let name_style = if is_selected {
98                Style::new()
99                    .fg(self.theme.colors.text_highlight.to_color())
100                    .add_modifier(Modifier::BOLD | Modifier::REVERSED)
101            } else {
102                Style::new()
103                    .fg(self.theme.colors.info.to_color())
104                    .add_modifier(Modifier::BOLD)
105            };
106            let desc_style = if is_selected {
107                Style::new()
108                    .fg(self.theme.colors.text_primary.to_color())
109                    .add_modifier(Modifier::REVERSED)
110            } else {
111                Style::new().fg(self.theme.colors.text_secondary.to_color())
112            };
113
114            // Pad command column so descriptions align.
115            let padded_name = format!(" {:<22}", name_part);
116            lines.push(Line::from(vec![
117                Span::styled(padded_name, name_style),
118                Span::styled(format!(" {}", cmd.description), desc_style),
119            ]));
120        }
121
122        Paragraph::new(lines).block(block).render(area, buf);
123    }
124}