Skip to main content

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