Skip to main content

koda_cli/widgets/
dropdown.rs

1//! Generic dropdown widget — rendered inside the ratatui viewport.
2//!
3//! Reusable dropdown with type-to-filter, scroll, and fixed-height
4//! rendering. Used by slash commands, `/model`, `/provider`, etc.
5//!
6//! See DESIGN.md (Interaction section) for the interaction system design.
7
8use ratatui::{
9    style::{Color, Modifier, Style},
10    text::{Line, Span},
11};
12
13// ── Styles ────────────────────────────────────────────
14
15const 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
21/// Max visible items in the dropdown (scroll for more).
22pub const MAX_VISIBLE: usize = 6;
23
24// ── Trait ────────────────────────────────────────────
25
26/// Trait for items that can be displayed in a dropdown.
27pub trait DropdownItem: Clone {
28    /// Primary label shown in the list.
29    fn label(&self) -> &str;
30    /// Optional description shown after the label.
31    fn description(&self) -> String;
32    /// Whether this item matches a filter string.
33    fn matches_filter(&self, filter: &str) -> bool;
34}
35
36// ── Built-in item types ────────────────────────────────
37
38/// Simple label+description pair (for static command lists, providers, etc.).
39#[derive(Clone, Debug)]
40#[allow(dead_code)] // Used in Phase 2 (/model, /provider conversions)
41pub struct SimpleItem {
42    pub label: String,
43    pub description: String,
44}
45
46impl SimpleItem {
47    #[allow(dead_code)] // Used in Phase 2
48    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// ── State ────────────────────────────────────────────
71
72/// Generic dropdown state. Owns the filtered item list, selection,
73/// and scroll offset. Type parameter `T` must implement `DropdownItem`.
74#[derive(Clone)]
75pub struct DropdownState<T: DropdownItem> {
76    /// All items (unfiltered source).
77    all_items: Vec<T>,
78    /// Currently visible items after filtering.
79    pub filtered: Vec<T>,
80    /// Index into `filtered`.
81    pub selected: usize,
82    /// Scroll offset for the visible window.
83    pub scroll_offset: usize,
84    /// Title shown above the dropdown.
85    pub title: String,
86}
87
88impl<T: DropdownItem> DropdownState<T> {
89    /// Create a new dropdown with the given items and title.
90    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    /// Apply a filter string. Resets selection to 0.
102    /// Returns `false` if no items match (caller can dismiss).
103    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    /// Move selection up.
116    pub fn up(&mut self) {
117        self.selected = self.selected.saturating_sub(1);
118        self.recenter();
119    }
120
121    /// Move selection down (wraps around).
122    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    /// Keep the selected item vertically centred in the visible window.
133    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    /// Get the currently selected item, if any.
145    pub fn selected_item(&self) -> Option<&T> {
146        self.filtered.get(self.selected)
147    }
148
149    /// Check if the dropdown has any items to show.
150    #[allow(dead_code)] // Used in Phase 2
151    pub fn is_empty(&self) -> bool {
152        self.filtered.is_empty()
153    }
154
155    /// Number of visible rows this dropdown will occupy (including title + padding).
156    pub fn visible_count(&self) -> usize {
157        MAX_VISIBLE.min(self.filtered.len()) + 2 // items + title + padding
158    }
159}
160
161// ── Rendering ─────────────────────────────────────────
162
163/// Build dropdown lines for rendering in the viewport.
164/// Always returns exactly `MAX_VISIBLE + 2` lines (fixed height).
165pub 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    // Title with scroll indicator
175    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    // Visible options
183    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    // Pad empty slots to maintain fixed height
208    for _ in visible..MAX_VISIBLE {
209        lines.push(Line::from(""));
210    }
211
212    // Hint with scroll indicator
213    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); // /model
250        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        // 4 more downs reaches the last item (/model at index 5 of 6)
274        for _ in 0..4 {
275            dd.down();
276        }
277        assert_eq!(dd.selected_item().unwrap().label(), "/model");
278        dd.down(); // wraps
279        assert_eq!(dd.selected_item().unwrap().label(), "/agent");
280        dd.up(); // saturates at 0
281        assert_eq!(dd.selected_item().unwrap().label(), "/agent");
282    }
283
284    /// Items that intentionally overflow the 6-visible-slot viewport so the
285    /// scroll indicator test can assert ▼ is shown. Kept separate from
286    /// `test_items()` so `new_contains_all` stays authoritative about the
287    /// real command count.
288    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        // 7 items, 6 visible → should show ▼ scroll indicator
299        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); // title + 6 slots + hint
314
315        // Filtered to 2 items — still 8 lines
316        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}