Skip to main content

zero_tui/widgets/
picker.rs

1//! Slash-command picker widget — small popup above the prompt
2//! listing fuzzy-matched commands with their risk badge and
3//! summary.
4//!
5//! Layout convention: the picker occupies the bottom rows of the
6//! conversation pane, directly above the prompt. It is *not* a
7//! modal — operators can ignore it entirely and press Enter to
8//! submit whatever they typed; the picker exists purely as
9//! discovery. Tab is the only key that commits a selection into
10//! the buffer.
11//!
12//! Matched characters in each row's name are bolded so the
13//! operator can see why a result ranked; unmatched characters
14//! render dim. The selected row gets a reversed-video background
15//! and a leading chevron (`›`) so screen-reader users can also
16//! tell which row is active (a paired ARIA-role pass lands with
17//! the `screen-reader` mode addition).
18
19use ratatui::buffer::Buffer;
20use ratatui::layout::Rect;
21use ratatui::style::{Modifier, Style};
22use ratatui::text::{Line, Span};
23use ratatui::widgets::Widget;
24use zero_commands::RiskDirection;
25
26use crate::app::picker::{PICKER_MAX_VISIBLE, SlashMatch, SlashPicker};
27use crate::theme::Theme;
28
29/// Rows the picker will actually consume in the layout at the
30/// caller's current width. Never more than `PICKER_MAX_VISIBLE`;
31/// never more than the number of matches.
32#[must_use]
33pub fn picker_rows(picker: &SlashPicker) -> u16 {
34    let n = picker.matches().len().min(PICKER_MAX_VISIBLE);
35    u16::try_from(n).unwrap_or(0)
36}
37
38#[derive(Debug)]
39pub struct PickerWidget<'a> {
40    pub picker: &'a SlashPicker,
41    pub theme: Theme,
42}
43
44impl Widget for PickerWidget<'_> {
45    fn render(self, area: Rect, buf: &mut Buffer) {
46        if area.height == 0 || area.width == 0 || !self.picker.is_active() {
47            return;
48        }
49        for y in area.top()..area.bottom() {
50            for x in area.left()..area.right() {
51                buf[(x, y)].set_char(' ');
52            }
53        }
54
55        let matches = self.picker.matches();
56        let visible = usize::from(area.height).min(matches.len());
57        // Window slide: keep the selected row in view. Small list
58        // so a naive center-scroll is overkill; if the selected
59        // row is past the visible window, slide the window down.
60        let sel = self.picker.selected_index();
61        let start = if sel < visible { 0 } else { sel + 1 - visible };
62
63        for (i, m) in matches.iter().enumerate().skip(start).take(visible) {
64            let visible_row = i - start;
65            let y = area.top() + u16::try_from(visible_row).unwrap_or(u16::MAX);
66            if y >= area.bottom() {
67                break;
68            }
69            let row_area = Rect {
70                x: area.x,
71                y,
72                width: area.width,
73                height: 1,
74            };
75            render_row(m, i == sel, &self.theme, row_area, buf);
76        }
77    }
78}
79
80fn render_row(m: &SlashMatch, selected: bool, theme: &Theme, area: Rect, buf: &mut Buffer) {
81    let base_style = if selected {
82        Style::default()
83            .fg(theme.primary)
84            .add_modifier(Modifier::REVERSED)
85    } else {
86        Style::default().fg(theme.primary)
87    };
88    let dim = Style::default()
89        .fg(theme.metadata)
90        .add_modifier(Modifier::DIM);
91    let bold = base_style.add_modifier(Modifier::BOLD);
92    let risk_style = match m.info.risk {
93        RiskDirection::Reduces => Style::default().fg(theme.primary),
94        RiskDirection::Neutral => dim,
95        RiskDirection::Increases => Style::default()
96            .fg(theme.alert)
97            .add_modifier(Modifier::BOLD),
98    };
99
100    let chevron = if selected { "› " } else { "  " };
101    let mut spans: Vec<Span<'static>> = Vec::with_capacity(m.info.name.chars().count() + 4);
102    spans.push(Span::styled(chevron.to_string(), base_style));
103    for (i, c) in m.info.name.chars().enumerate() {
104        let styled = if m.matched_chars.contains(&i) {
105            bold
106        } else {
107            base_style
108        };
109        spans.push(Span::styled(c.to_string(), styled));
110    }
111    spans.push(Span::styled(
112        format!(" [{}] ", risk_label(m.info.risk)),
113        risk_style,
114    ));
115    spans.push(Span::styled(m.info.summary.to_string(), dim));
116    Line::from(spans).render(area, buf);
117}
118
119const fn risk_label(r: RiskDirection) -> &'static str {
120    match r {
121        RiskDirection::Reduces => "reduce",
122        RiskDirection::Neutral => "read",
123        RiskDirection::Increases => "trade",
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use ratatui::Terminal;
131    use ratatui::backend::TestBackend;
132
133    fn render(picker: &SlashPicker, width: u16, height: u16) -> Vec<String> {
134        let backend = TestBackend::new(width, height);
135        let mut term = Terminal::new(backend).expect("term");
136        term.draw(|f| {
137            let w = PickerWidget {
138                picker,
139                theme: Theme::default(),
140            };
141            f.render_widget(w, f.area());
142        })
143        .expect("draw");
144        let buf = term.backend().buffer().clone();
145        (0..buf.area.height)
146            .map(|y| {
147                (0..buf.area.width)
148                    .map(|x| buf[(x, y)].symbol().to_string())
149                    .collect::<String>()
150            })
151            .collect()
152    }
153
154    #[test]
155    fn renders_selected_row_with_chevron() {
156        let picker = SlashPicker::from_prompt_line("/h").expect("picker");
157        let rows = picker_rows(&picker);
158        assert!(rows >= 1);
159        let lines = render(&picker, 60, rows);
160        // First row is selected by default.
161        assert!(
162            lines[0].starts_with('›'),
163            "selected row should lead with chevron: {:?}",
164            lines[0]
165        );
166    }
167
168    #[test]
169    fn inactive_picker_renders_nothing_visible() {
170        // Build an inactive picker by filtering against a string
171        // with no subsequence match.
172        let picker = SlashPicker::from_prompt_line("/xyzzyq").expect("picker");
173        assert!(!picker.is_active());
174        let lines = render(&picker, 60, 3);
175        // All cells blank — no chevron, no name.
176        for line in &lines {
177            assert!(
178                line.chars().all(|c| c == ' '),
179                "expected all-blank row, got {line:?}"
180            );
181        }
182    }
183}