fresh/view/controls/dropdown/
mod.rs

1//! Dropdown selection control
2//!
3//! Renders as: `Label: [Selected Option ▼]`
4//!
5//! This module provides a complete dropdown component with:
6//! - State management (`DropdownState`)
7//! - Rendering (`render_dropdown`, `render_dropdown_aligned`)
8//! - Input handling (`DropdownState::handle_mouse`, `handle_key`)
9//! - Layout/hit testing (`DropdownLayout`)
10
11mod input;
12mod render;
13
14use ratatui::layout::Rect;
15use ratatui::style::Color;
16
17pub use input::DropdownEvent;
18pub use render::{render_dropdown, render_dropdown_aligned};
19
20use super::FocusState;
21
22/// State for a dropdown control
23#[derive(Debug, Clone)]
24pub struct DropdownState {
25    /// Currently selected index
26    pub selected: usize,
27    /// Display names for options (shown in UI)
28    pub options: Vec<String>,
29    /// Actual values for options (stored in config)
30    /// If empty, options are used as values
31    pub values: Vec<String>,
32    /// Label displayed before the dropdown
33    pub label: String,
34    /// Whether the dropdown is currently open
35    pub open: bool,
36    /// Focus state
37    pub focus: FocusState,
38    /// Original selection when dropdown opened (for cancel/restore)
39    original_selected: Option<usize>,
40    /// Scroll offset for long option lists
41    pub scroll_offset: usize,
42    /// Maximum visible options (set during render)
43    pub max_visible: usize,
44}
45
46impl DropdownState {
47    /// Create a new dropdown state where display names equal values
48    pub fn new(options: Vec<String>, label: impl Into<String>) -> Self {
49        Self {
50            selected: 0,
51            options,
52            values: Vec::new(),
53            label: label.into(),
54            open: false,
55            focus: FocusState::Normal,
56            original_selected: None,
57            scroll_offset: 0,
58            max_visible: 5, // Conservative default to ensure visibility
59        }
60    }
61
62    /// Create a dropdown with separate display names and values
63    pub fn with_values(
64        options: Vec<String>,
65        values: Vec<String>,
66        label: impl Into<String>,
67    ) -> Self {
68        debug_assert_eq!(options.len(), values.len());
69        Self {
70            selected: 0,
71            options,
72            values,
73            label: label.into(),
74            open: false,
75            focus: FocusState::Normal,
76            original_selected: None,
77            scroll_offset: 0,
78            max_visible: 5, // Conservative default to ensure visibility
79        }
80    }
81
82    /// Set the initially selected index
83    pub fn with_selected(mut self, index: usize) -> Self {
84        if index < self.options.len() {
85            self.selected = index;
86        }
87        self
88    }
89
90    /// Set the focus state
91    pub fn with_focus(mut self, focus: FocusState) -> Self {
92        self.focus = focus;
93        self
94    }
95
96    /// Check if the control is enabled
97    pub fn is_enabled(&self) -> bool {
98        self.focus != FocusState::Disabled
99    }
100
101    /// Get the currently selected value (for storing in config)
102    pub fn selected_value(&self) -> Option<&str> {
103        if self.values.is_empty() {
104            self.options.get(self.selected).map(|s| s.as_str())
105        } else {
106            self.values.get(self.selected).map(|s| s.as_str())
107        }
108    }
109
110    /// Get the currently selected display name (for UI)
111    pub fn selected_option(&self) -> Option<&str> {
112        self.options.get(self.selected).map(|s| s.as_str())
113    }
114
115    /// Find the index of a value
116    pub fn index_of_value(&self, value: &str) -> Option<usize> {
117        if self.values.is_empty() {
118            self.options.iter().position(|o| o == value)
119        } else {
120            self.values.iter().position(|v| v == value)
121        }
122    }
123
124    /// Toggle the dropdown open/closed
125    pub fn toggle_open(&mut self) {
126        if self.is_enabled() {
127            if !self.open {
128                self.original_selected = Some(self.selected);
129            } else {
130                self.original_selected = None;
131            }
132            self.open = !self.open;
133        }
134    }
135
136    /// Cancel the dropdown (restore original selection and close)
137    pub fn cancel(&mut self) {
138        if let Some(original) = self.original_selected.take() {
139            self.selected = original;
140        }
141        self.open = false;
142    }
143
144    /// Confirm the selection and close
145    pub fn confirm(&mut self) {
146        self.original_selected = None;
147        self.open = false;
148    }
149
150    /// Select the next option
151    pub fn select_next(&mut self) {
152        if self.is_enabled() && !self.options.is_empty() {
153            self.selected = (self.selected + 1) % self.options.len();
154            self.ensure_visible();
155        }
156    }
157
158    /// Select the previous option
159    pub fn select_prev(&mut self) {
160        if self.is_enabled() && !self.options.is_empty() {
161            self.selected = if self.selected == 0 {
162                self.options.len() - 1
163            } else {
164                self.selected - 1
165            };
166            self.ensure_visible();
167        }
168    }
169
170    /// Select an option by index
171    pub fn select(&mut self, index: usize) {
172        if self.is_enabled() && index < self.options.len() {
173            self.selected = index;
174            self.original_selected = None;
175            self.open = false;
176        }
177    }
178
179    /// Ensure the selected item is visible within the scroll view
180    pub fn ensure_visible(&mut self) {
181        if self.max_visible == 0 || self.options.len() <= self.max_visible {
182            self.scroll_offset = 0;
183            return;
184        }
185
186        // If selected is above visible area, scroll up
187        if self.selected < self.scroll_offset {
188            self.scroll_offset = self.selected;
189        }
190        // If selected is below visible area, scroll down
191        else if self.selected >= self.scroll_offset + self.max_visible {
192            self.scroll_offset = self.selected.saturating_sub(self.max_visible - 1);
193        }
194    }
195
196    /// Scroll the dropdown by a delta (positive = down, negative = up)
197    pub fn scroll_by(&mut self, delta: i32) {
198        if self.options.len() <= self.max_visible {
199            return;
200        }
201
202        let max_offset = self.options.len().saturating_sub(self.max_visible);
203        if delta > 0 {
204            self.scroll_offset = (self.scroll_offset + delta as usize).min(max_offset);
205        } else {
206            self.scroll_offset = self.scroll_offset.saturating_sub((-delta) as usize);
207        }
208    }
209
210    /// Check if scrollbar should be shown
211    pub fn needs_scrollbar(&self) -> bool {
212        self.open && self.options.len() > self.max_visible
213    }
214
215    /// Get the scroll position as a fraction (0.0 to 1.0) for scrollbar rendering
216    pub fn scroll_fraction(&self) -> f32 {
217        if self.options.len() <= self.max_visible {
218            return 0.0;
219        }
220        let max_offset = self.options.len().saturating_sub(self.max_visible);
221        if max_offset == 0 {
222            return 0.0;
223        }
224        self.scroll_offset as f32 / max_offset as f32
225    }
226}
227
228/// Colors for the dropdown control
229#[derive(Debug, Clone, Copy)]
230pub struct DropdownColors {
231    /// Label color
232    pub label: Color,
233    /// Selected option text color
234    pub selected: Color,
235    /// Border/bracket color
236    pub border: Color,
237    /// Arrow indicator color
238    pub arrow: Color,
239    /// Option text in dropdown menu
240    pub option: Color,
241    /// Highlighted option background
242    pub highlight_bg: Color,
243    /// Focused highlight color
244    pub focused: Color,
245    /// Disabled color
246    pub disabled: Color,
247}
248
249impl Default for DropdownColors {
250    fn default() -> Self {
251        Self {
252            label: Color::White,
253            selected: Color::Cyan,
254            border: Color::Gray,
255            arrow: Color::DarkGray,
256            option: Color::White,
257            highlight_bg: Color::DarkGray,
258            focused: Color::Cyan,
259            disabled: Color::DarkGray,
260        }
261    }
262}
263
264impl DropdownColors {
265    /// Create colors from theme
266    pub fn from_theme(theme: &crate::view::theme::Theme) -> Self {
267        Self {
268            label: theme.editor_fg,
269            // Use editor_fg for selected value to ensure visibility
270            // menu_active_fg can be hard to see against some backgrounds
271            selected: theme.editor_fg,
272            border: theme.line_number_fg,
273            arrow: theme.line_number_fg,
274            option: theme.editor_fg,
275            highlight_bg: theme.selection_bg,
276            // Use menu_highlight_fg for focus indicator (bright, visible color)
277            focused: theme.menu_highlight_fg,
278            disabled: theme.line_number_fg,
279        }
280    }
281}
282
283/// Layout information returned after rendering for hit testing
284#[derive(Debug, Clone, Default)]
285pub struct DropdownLayout {
286    /// The main dropdown button area
287    pub button_area: Rect,
288    /// Areas for each option when open (empty if closed)
289    pub option_areas: Vec<Rect>,
290    /// The full control area
291    pub full_area: Rect,
292    /// Scroll offset used during rendering (for mapping visible to actual indices)
293    pub scroll_offset: usize,
294}
295
296impl DropdownLayout {
297    /// Check if a point is on the dropdown button
298    pub fn is_button(&self, x: u16, y: u16) -> bool {
299        x >= self.button_area.x
300            && x < self.button_area.x + self.button_area.width
301            && y >= self.button_area.y
302            && y < self.button_area.y + self.button_area.height
303    }
304
305    /// Get the option index at a point, if any
306    /// Returns the actual option index (accounting for scroll offset)
307    pub fn option_at(&self, x: u16, y: u16) -> Option<usize> {
308        for (i, area) in self.option_areas.iter().enumerate() {
309            if x >= area.x && x < area.x + area.width && y >= area.y && y < area.y + area.height {
310                return Some(self.scroll_offset + i);
311            }
312        }
313        None
314    }
315
316    /// Check if a point is within the full control area
317    pub fn contains(&self, x: u16, y: u16) -> bool {
318        x >= self.full_area.x
319            && x < self.full_area.x + self.full_area.width
320            && y >= self.full_area.y
321            && y < self.full_area.y + self.full_area.height
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use ratatui::backend::TestBackend;
329    use ratatui::Terminal;
330
331    fn test_frame<F>(width: u16, height: u16, f: F)
332    where
333        F: FnOnce(&mut ratatui::Frame, Rect),
334    {
335        let backend = TestBackend::new(width, height);
336        let mut terminal = Terminal::new(backend).unwrap();
337        terminal
338            .draw(|frame| {
339                let area = Rect::new(0, 0, width, height);
340                f(frame, area);
341            })
342            .unwrap();
343    }
344
345    #[test]
346    fn test_dropdown_renders() {
347        test_frame(40, 1, |frame, area| {
348            let state = DropdownState::new(
349                vec!["Option A".to_string(), "Option B".to_string()],
350                "Choice",
351            );
352            let colors = DropdownColors::default();
353            let layout = render_dropdown(frame, area, &state, &colors);
354
355            assert!(layout.button_area.width > 0);
356            assert!(layout.option_areas.is_empty());
357        });
358    }
359
360    #[test]
361    fn test_dropdown_open() {
362        test_frame(40, 5, |frame, area| {
363            let mut state = DropdownState::new(
364                vec!["Option A".to_string(), "Option B".to_string()],
365                "Choice",
366            );
367            state.open = true;
368            let colors = DropdownColors::default();
369            let layout = render_dropdown(frame, area, &state, &colors);
370
371            assert_eq!(layout.option_areas.len(), 2);
372        });
373    }
374
375    #[test]
376    fn test_dropdown_selection() {
377        let mut state = DropdownState::new(
378            vec!["A".to_string(), "B".to_string(), "C".to_string()],
379            "Test",
380        );
381
382        assert_eq!(state.selected, 0);
383        state.select_next();
384        assert_eq!(state.selected, 1);
385        state.select_next();
386        assert_eq!(state.selected, 2);
387        state.select_next();
388        assert_eq!(state.selected, 0);
389
390        state.select_prev();
391        assert_eq!(state.selected, 2);
392    }
393
394    #[test]
395    fn test_dropdown_select_by_index() {
396        let mut state = DropdownState::new(
397            vec!["A".to_string(), "B".to_string(), "C".to_string()],
398            "Test",
399        );
400        state.open = true;
401        state.select(2);
402        assert_eq!(state.selected, 2);
403        assert!(!state.open);
404    }
405
406    #[test]
407    fn test_dropdown_disabled() {
408        let mut state = DropdownState::new(vec!["A".to_string(), "B".to_string()], "Test")
409            .with_focus(FocusState::Disabled);
410
411        state.toggle_open();
412        assert!(!state.open);
413
414        state.select_next();
415        assert_eq!(state.selected, 0);
416    }
417
418    #[test]
419    fn test_dropdown_cancel_restores_original() {
420        let mut state = DropdownState::new(
421            vec!["A".to_string(), "B".to_string(), "C".to_string()],
422            "Test",
423        )
424        .with_selected(1);
425
426        state.toggle_open();
427        assert!(state.open);
428        assert_eq!(state.selected, 1);
429
430        state.select_next();
431        assert_eq!(state.selected, 2);
432
433        state.cancel();
434        assert!(!state.open);
435        assert_eq!(state.selected, 1);
436    }
437
438    #[test]
439    fn test_dropdown_confirm_commits_selection() {
440        let mut state = DropdownState::new(
441            vec!["A".to_string(), "B".to_string(), "C".to_string()],
442            "Test",
443        )
444        .with_selected(0);
445
446        state.toggle_open();
447        assert!(state.open);
448
449        state.select_next();
450        assert_eq!(state.selected, 1);
451
452        state.confirm();
453        assert!(!state.open);
454        assert_eq!(state.selected, 1);
455    }
456
457    #[test]
458    fn test_dropdown_toggle_close_confirms() {
459        let mut state = DropdownState::new(
460            vec!["A".to_string(), "B".to_string(), "C".to_string()],
461            "Test",
462        )
463        .with_selected(0);
464
465        state.toggle_open();
466        assert!(state.open);
467
468        state.select_next();
469        assert_eq!(state.selected, 1);
470
471        state.toggle_open();
472        assert!(!state.open);
473        assert_eq!(state.selected, 1);
474    }
475
476    #[test]
477    fn test_dropdown_scrolling() {
478        // Create dropdown with many options
479        let options: Vec<String> = (0..20).map(|i| format!("Option {}", i)).collect();
480        let mut state = DropdownState::new(options, "Long List");
481        state.max_visible = 5; // Only show 5 options at a time
482
483        assert_eq!(state.scroll_offset, 0);
484
485        // Select option beyond visible area
486        state.selected = 10;
487        state.ensure_visible();
488
489        // Should have scrolled down
490        assert!(state.scroll_offset > 0);
491        assert!(state.selected >= state.scroll_offset);
492        assert!(state.selected < state.scroll_offset + state.max_visible);
493    }
494
495    #[test]
496    fn test_dropdown_scroll_by() {
497        let options: Vec<String> = (0..20).map(|i| format!("Option {}", i)).collect();
498        let mut state = DropdownState::new(options, "Long List");
499        state.max_visible = 5;
500
501        // Scroll down
502        state.scroll_by(3);
503        assert_eq!(state.scroll_offset, 3);
504
505        // Scroll up
506        state.scroll_by(-2);
507        assert_eq!(state.scroll_offset, 1);
508
509        // Scroll up past beginning
510        state.scroll_by(-10);
511        assert_eq!(state.scroll_offset, 0);
512
513        // Scroll down past end
514        state.scroll_by(100);
515        assert_eq!(state.scroll_offset, 15); // 20 - 5 = 15 max
516    }
517
518    #[test]
519    fn test_dropdown_needs_scrollbar() {
520        let options: Vec<String> = (0..10).map(|i| format!("Option {}", i)).collect();
521        let mut state = DropdownState::new(options, "Test");
522
523        // When closed, no scrollbar needed
524        state.max_visible = 5;
525        assert!(!state.needs_scrollbar());
526
527        // When open with more options than visible, scrollbar needed
528        state.open = true;
529        assert!(state.needs_scrollbar());
530
531        // When all options fit, no scrollbar needed
532        state.max_visible = 20;
533        assert!(!state.needs_scrollbar());
534    }
535
536    #[test]
537    fn test_dropdown_keyboard_nav_scrolls() {
538        let options: Vec<String> = (0..10).map(|i| format!("Option {}", i)).collect();
539        let mut state = DropdownState::new(options, "Test");
540        state.max_visible = 3;
541        state.open = true;
542
543        // Navigate down past visible area
544        for _ in 0..5 {
545            state.select_next();
546        }
547
548        assert_eq!(state.selected, 5);
549        // Selected should be visible
550        assert!(state.selected >= state.scroll_offset);
551        assert!(state.selected < state.scroll_offset + state.max_visible);
552    }
553
554    #[test]
555    fn test_dropdown_selection_always_visible() {
556        // Simulate locale dropdown with 13 options and small viewport
557        let options: Vec<String> = vec![
558            "Auto-detect",
559            "Czech",
560            "German",
561            "English",
562            "Spanish",
563            "French",
564            "Japanese",
565            "Korean",
566            "Portuguese",
567            "Russian",
568            "Thai",
569            "Ukrainian",
570            "Chinese",
571        ]
572        .into_iter()
573        .map(String::from)
574        .collect();
575
576        let mut state = DropdownState::new(options, "Locale");
577        state.max_visible = 5; // Small viewport like in settings
578        state.open = true;
579
580        // Helper to check visibility invariant
581        let check_visible = |state: &DropdownState| {
582            assert!(
583                state.selected >= state.scroll_offset,
584                "selected {} below scroll_offset {}",
585                state.selected,
586                state.scroll_offset
587            );
588            assert!(
589                state.selected < state.scroll_offset + state.max_visible,
590                "selected {} above visible area (scroll_offset={}, max_visible={})",
591                state.selected,
592                state.scroll_offset,
593                state.max_visible
594            );
595        };
596
597        // Navigate all the way down
598        for i in 0..12 {
599            state.select_next();
600            check_visible(&state);
601            assert_eq!(state.selected, i + 1);
602        }
603
604        // Should be at last item
605        assert_eq!(state.selected, 12);
606        check_visible(&state);
607
608        // Navigate all the way back up
609        for i in (0..12).rev() {
610            state.select_prev();
611            check_visible(&state);
612            assert_eq!(state.selected, i);
613        }
614
615        // Should be at first item
616        assert_eq!(state.selected, 0);
617        check_visible(&state);
618
619        // Test Home key behavior
620        state.selected = 8;
621        state.ensure_visible();
622        state.selected = 0;
623        state.ensure_visible();
624        check_visible(&state);
625
626        // Test End key behavior
627        state.selected = 12;
628        state.ensure_visible();
629        check_visible(&state);
630    }
631}