koda_cli/widgets/
dropdown.rs1use ratatui::{
9 style::{Color, Modifier, Style},
10 text::{Line, Span},
11};
12
13const DIM: Style = Style::new().fg(Color::Rgb(124, 111, 100));
16const SELECTED: Style = Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD);
17const UNSELECTED: Style = Style::new().fg(Color::Rgb(124, 111, 100));
18const DESC: Style = Style::new().fg(Color::Rgb(198, 165, 106));
19const HINT: Style = Style::new().fg(Color::Rgb(124, 111, 100));
20
21pub const MAX_VISIBLE: usize = 6;
23
24pub trait DropdownItem: Clone {
28 fn label(&self) -> &str;
30 fn description(&self) -> String;
32 fn matches_filter(&self, filter: &str) -> bool;
34}
35
36#[derive(Clone, Debug)]
40#[allow(dead_code)] pub struct SimpleItem {
42 pub label: String,
43 pub description: String,
44}
45
46impl SimpleItem {
47 #[allow(dead_code)] pub fn new(label: impl Into<String>, desc: impl Into<String>) -> Self {
49 Self {
50 label: label.into(),
51 description: desc.into(),
52 }
53 }
54}
55
56impl DropdownItem for SimpleItem {
57 fn label(&self) -> &str {
58 &self.label
59 }
60 fn description(&self) -> String {
61 self.description.clone()
62 }
63 fn matches_filter(&self, filter: &str) -> bool {
64 let lower = self.label.to_lowercase();
65 let filter_lower = filter.to_lowercase();
66 lower.contains(&filter_lower)
67 }
68}
69
70#[derive(Clone)]
75pub struct DropdownState<T: DropdownItem> {
76 all_items: Vec<T>,
78 pub filtered: Vec<T>,
80 pub selected: usize,
82 pub scroll_offset: usize,
84 pub title: String,
86}
87
88impl<T: DropdownItem> DropdownState<T> {
89 pub fn new(items: Vec<T>, title: impl Into<String>) -> Self {
91 let filtered = items.clone();
92 Self {
93 all_items: items,
94 filtered,
95 selected: 0,
96 scroll_offset: 0,
97 title: title.into(),
98 }
99 }
100
101 pub fn apply_filter(&mut self, filter: &str) -> bool {
104 self.filtered = self
105 .all_items
106 .iter()
107 .filter(|item| item.matches_filter(filter))
108 .cloned()
109 .collect();
110 self.selected = 0;
111 self.scroll_offset = 0;
112 !self.filtered.is_empty()
113 }
114
115 pub fn up(&mut self) {
117 self.selected = self.selected.saturating_sub(1);
118 self.recenter();
119 }
120
121 pub fn down(&mut self) {
123 if self.selected + 1 < self.filtered.len() {
124 self.selected += 1;
125 } else {
126 self.selected = 0;
127 self.scroll_offset = 0;
128 }
129 self.recenter();
130 }
131
132 fn recenter(&mut self) {
134 let visible = MAX_VISIBLE.min(self.filtered.len());
135 if visible == 0 {
136 return;
137 }
138 let half = visible / 2;
139 let ideal = self.selected.saturating_sub(half);
140 let max_offset = self.filtered.len().saturating_sub(visible);
141 self.scroll_offset = ideal.min(max_offset);
142 }
143
144 pub fn selected_item(&self) -> Option<&T> {
146 self.filtered.get(self.selected)
147 }
148
149 #[allow(dead_code)] pub fn is_empty(&self) -> bool {
152 self.filtered.is_empty()
153 }
154
155 pub fn visible_count(&self) -> usize {
157 MAX_VISIBLE.min(self.filtered.len()) + 2 }
159}
160
161pub fn build_dropdown_lines<T: DropdownItem>(state: &DropdownState<T>) -> Vec<Line<'static>> {
166 let visible = MAX_VISIBLE.min(state.filtered.len());
167 let end = (state.scroll_offset + visible).min(state.filtered.len());
168 let window = &state.filtered[state.scroll_offset..end];
169 let has_above = state.scroll_offset > 0;
170 let has_below = end < state.filtered.len();
171
172 let mut lines = Vec::with_capacity(MAX_VISIBLE + 2);
173
174 let title = if has_above {
176 format!(" {} \u{25b2} more", state.title)
177 } else {
178 format!(" {}", state.title)
179 };
180 lines.push(Line::from(Span::styled(title, DIM)));
181
182 for (i, item) in window.iter().enumerate() {
184 let absolute_idx = state.scroll_offset + i;
185 let is_selected = absolute_idx == state.selected;
186 let label = item.label().to_string();
187 let desc = item.description();
188 let mut spans = Vec::with_capacity(4);
189
190 if is_selected {
191 spans.push(Span::styled(
192 " \u{203a} ",
193 Style::default().fg(Color::Cyan),
194 ));
195 spans.push(Span::styled(label, SELECTED));
196 } else {
197 spans.push(Span::raw(" "));
198 spans.push(Span::styled(label, UNSELECTED));
199 }
200 if !desc.is_empty() {
201 spans.push(Span::styled(format!(" {desc}"), DESC));
202 }
203
204 lines.push(Line::from(spans));
205 }
206
207 for _ in visible..MAX_VISIBLE {
209 lines.push(Line::from(""));
210 }
211
212 let hint = if has_below {
214 " \u{2191}/\u{2193} navigate \u{00b7} enter select \u{00b7} esc cancel \u{25bc} more"
215 } else {
216 " \u{2191}/\u{2193} navigate \u{00b7} enter select \u{00b7} esc cancel"
217 };
218 lines.push(Line::from(Span::styled(hint, HINT)));
219
220 lines
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 fn test_items() -> Vec<SimpleItem> {
228 vec![
229 SimpleItem::new("/agent", "Agents"),
230 SimpleItem::new("/compact", "Compact"),
231 SimpleItem::new("/diff", "Diff"),
232 SimpleItem::new("/exit", "Quit"),
233 SimpleItem::new("/expand", "Expand"),
234 SimpleItem::new("/model", "Pick model"),
235 ]
236 }
237
238 #[test]
239 fn new_contains_all() {
240 let dd = DropdownState::new(test_items(), "Test");
241 assert_eq!(dd.filtered.len(), 6);
242 assert_eq!(dd.selected, 0);
243 }
244
245 #[test]
246 fn filter_narrows() {
247 let mut dd = DropdownState::new(test_items(), "Test");
248 assert!(dd.apply_filter("/m"));
249 assert_eq!(dd.filtered.len(), 1); assert_eq!(dd.filtered[0].label(), "/model");
251 }
252
253 #[test]
254 fn filter_no_match() {
255 let mut dd = DropdownState::new(test_items(), "Test");
256 assert!(!dd.apply_filter("/z"));
257 assert!(dd.is_empty());
258 }
259
260 #[test]
261 fn filter_case_insensitive() {
262 let mut dd = DropdownState::new(test_items(), "Test");
263 assert!(dd.apply_filter("/MODEL"));
264 assert_eq!(dd.filtered.len(), 1);
265 }
266
267 #[test]
268 fn navigation() {
269 let mut dd = DropdownState::new(test_items(), "Test");
270 assert_eq!(dd.selected_item().unwrap().label(), "/agent");
271 dd.down();
272 assert_eq!(dd.selected_item().unwrap().label(), "/compact");
273 for _ in 0..4 {
275 dd.down();
276 }
277 assert_eq!(dd.selected_item().unwrap().label(), "/model");
278 dd.down(); assert_eq!(dd.selected_item().unwrap().label(), "/agent");
280 dd.up(); assert_eq!(dd.selected_item().unwrap().label(), "/agent");
282 }
283
284 fn overflow_items() -> Vec<SimpleItem> {
289 let mut items = test_items();
290 items.push(SimpleItem::new("/sessions", "Sessions"));
291 items
292 }
293
294 #[test]
295 fn scroll_indicators() {
296 let dd = DropdownState::new(overflow_items(), "Test");
297 let lines = build_dropdown_lines(&dd);
298 let hint: String = lines
300 .last()
301 .unwrap()
302 .spans
303 .iter()
304 .map(|s| s.content.as_ref())
305 .collect();
306 assert!(hint.contains('\u{25bc}'), "should show scroll-down: {hint}");
307 }
308
309 #[test]
310 fn fixed_height() {
311 let dd = DropdownState::new(test_items(), "Test");
312 let lines = build_dropdown_lines(&dd);
313 assert_eq!(lines.len(), 8); let mut dd2 = DropdownState::new(test_items(), "Test");
317 dd2.apply_filter("/e");
318 let lines = build_dropdown_lines(&dd2);
319 assert_eq!(lines.len(), 8);
320 }
321
322 #[test]
323 fn selected_marker() {
324 let dd = DropdownState::new(test_items(), "Test");
325 let lines = build_dropdown_lines(&dd);
326 let first: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect();
327 assert!(first.contains('\u{203a}'), "got: {first}");
328 let second: String = lines[2].spans.iter().map(|s| s.content.as_ref()).collect();
329 assert!(!second.contains('\u{203a}'), "got: {second}");
330 }
331
332 #[test]
333 fn selected_item_empty() {
334 let mut dd = DropdownState::new(test_items(), "Test");
335 dd.apply_filter("/zzz");
336 assert!(dd.selected_item().is_none());
337 }
338}