zero_tui/widgets/
picker.rs1use 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#[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 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 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 let picker = SlashPicker::from_prompt_line("/xyzzyq").expect("picker");
173 assert!(!picker.is_active());
174 let lines = render(&picker, 60, 3);
175 for line in &lines {
177 assert!(
178 line.chars().all(|c| c == ' '),
179 "expected all-blank row, got {line:?}"
180 );
181 }
182 }
183}