Skip to main content

ratatui_interact/components/
select.rs

1//! Select component - Dropdown select box
2//!
3//! A dropdown select component that renders as a compact closed state and opens
4//! a popup overlay with selectable options when activated.
5//!
6//! # Example
7//!
8//! ```rust
9//! use ratatui_interact::components::{Select, SelectState, SelectStyle};
10//! use ratatui::layout::Rect;
11//!
12//! // Create options
13//! let options = vec!["Option A", "Option B", "Option C"];
14//!
15//! // Create state
16//! let mut state = SelectState::new(options.len());
17//!
18//! // Create select widget
19//! let select = Select::new(&options, &state)
20//!     .label("Choose")
21//!     .placeholder("Select an option...");
22//!
23//! // Render and handle events (see handle_select_key, handle_select_mouse)
24//! ```
25
26use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
27use ratatui::{
28    Frame,
29    buffer::Buffer,
30    layout::Rect,
31    style::{Color, Modifier, Style},
32    text::{Line, Span},
33    widgets::{Block, Borders, Clear, Paragraph, Widget},
34};
35
36use crate::traits::{ClickRegion, FocusId};
37
38/// Actions a select component can emit.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum SelectAction {
41    /// Focus the select (from click).
42    Focus,
43    /// Open the dropdown.
44    Open,
45    /// Close the dropdown without selection.
46    Close,
47    /// An option was selected (index).
48    Select(usize),
49}
50
51/// State for a select component.
52#[derive(Debug, Clone)]
53pub struct SelectState {
54    /// Currently selected index (None if nothing selected).
55    pub selected_index: Option<usize>,
56    /// Whether the dropdown is open.
57    pub is_open: bool,
58    /// Whether the select has focus.
59    pub focused: bool,
60    /// Whether the select is enabled.
61    pub enabled: bool,
62    /// Highlighted index in dropdown (for keyboard navigation).
63    pub highlighted_index: usize,
64    /// Scroll offset for long option lists.
65    pub scroll_offset: u16,
66    /// Total number of options.
67    pub total_options: usize,
68}
69
70impl Default for SelectState {
71    fn default() -> Self {
72        Self {
73            selected_index: None,
74            is_open: false,
75            focused: false,
76            enabled: true,
77            highlighted_index: 0,
78            scroll_offset: 0,
79            total_options: 0,
80        }
81    }
82}
83
84impl SelectState {
85    /// Create a new select state with given number of options.
86    pub fn new(total_options: usize) -> Self {
87        Self {
88            total_options,
89            ..Default::default()
90        }
91    }
92
93    /// Create with a pre-selected index.
94    pub fn with_selected(total_options: usize, selected: usize) -> Self {
95        let mut state = Self::new(total_options);
96        if selected < total_options {
97            state.selected_index = Some(selected);
98            state.highlighted_index = selected;
99        }
100        state
101    }
102
103    /// Open the dropdown.
104    pub fn open(&mut self) {
105        if self.enabled {
106            self.is_open = true;
107            // Start highlight at selected item if any
108            if let Some(idx) = self.selected_index {
109                self.highlighted_index = idx;
110            }
111        }
112    }
113
114    /// Close the dropdown.
115    pub fn close(&mut self) {
116        self.is_open = false;
117    }
118
119    /// Toggle dropdown open/closed.
120    pub fn toggle(&mut self) {
121        if self.is_open {
122            self.close();
123        } else {
124            self.open();
125        }
126    }
127
128    /// Move highlight up.
129    pub fn highlight_prev(&mut self) {
130        if self.highlighted_index > 0 {
131            self.highlighted_index -= 1;
132        }
133    }
134
135    /// Move highlight down.
136    pub fn highlight_next(&mut self) {
137        if self.highlighted_index + 1 < self.total_options {
138            self.highlighted_index += 1;
139        }
140    }
141
142    /// Move highlight to first option.
143    pub fn highlight_first(&mut self) {
144        self.highlighted_index = 0;
145        self.scroll_offset = 0;
146    }
147
148    /// Move highlight to last option.
149    pub fn highlight_last(&mut self) {
150        if self.total_options > 0 {
151            self.highlighted_index = self.total_options - 1;
152        }
153    }
154
155    /// Select the currently highlighted option and close.
156    pub fn select_highlighted(&mut self) {
157        if self.total_options > 0 {
158            self.selected_index = Some(self.highlighted_index);
159        }
160        self.close();
161    }
162
163    /// Select a specific index.
164    pub fn select(&mut self, index: usize) {
165        if index < self.total_options {
166            self.selected_index = Some(index);
167            self.highlighted_index = index;
168        }
169        self.close();
170    }
171
172    /// Clear the selection.
173    pub fn clear_selection(&mut self) {
174        self.selected_index = None;
175    }
176
177    /// Update total options count.
178    pub fn set_total(&mut self, total: usize) {
179        self.total_options = total;
180        if let Some(idx) = self.selected_index {
181            if idx >= total {
182                self.selected_index = if total > 0 { Some(total - 1) } else { None };
183            }
184        }
185        if self.highlighted_index >= total && total > 0 {
186            self.highlighted_index = total - 1;
187        }
188    }
189
190    /// Ensure highlighted item is visible in viewport.
191    pub fn ensure_visible(&mut self, viewport_height: usize) {
192        if viewport_height == 0 {
193            return;
194        }
195        if self.highlighted_index < self.scroll_offset as usize {
196            self.scroll_offset = self.highlighted_index as u16;
197        } else if self.highlighted_index >= self.scroll_offset as usize + viewport_height {
198            self.scroll_offset = (self.highlighted_index - viewport_height + 1) as u16;
199        }
200    }
201
202    /// Get the selected index.
203    pub fn selected(&self) -> Option<usize> {
204        self.selected_index
205    }
206
207    /// Check if an option is selected.
208    pub fn has_selection(&self) -> bool {
209        self.selected_index.is_some()
210    }
211}
212
213/// Style configuration for select component.
214#[derive(Debug, Clone)]
215pub struct SelectStyle {
216    /// Border color when focused.
217    pub focused_border: Color,
218    /// Border color when unfocused.
219    pub unfocused_border: Color,
220    /// Border color when disabled.
221    pub disabled_border: Color,
222    /// Text color for selected value.
223    pub text_fg: Color,
224    /// Placeholder text color.
225    pub placeholder_fg: Color,
226    /// Dropdown indicator (e.g., "▼").
227    pub dropdown_indicator: &'static str,
228    /// Highlighted option style in dropdown.
229    pub highlight_style: Style,
230    /// Normal option style in dropdown.
231    pub option_style: Style,
232    /// Selected option indicator.
233    pub selected_indicator: &'static str,
234    /// Unselected option indicator (padding).
235    pub unselected_indicator: &'static str,
236    /// Dropdown border color.
237    pub dropdown_border: Color,
238    /// Max visible options in dropdown.
239    pub max_visible_options: u16,
240}
241
242impl Default for SelectStyle {
243    fn default() -> Self {
244        Self {
245            focused_border: Color::Yellow,
246            unfocused_border: Color::Gray,
247            disabled_border: Color::DarkGray,
248            text_fg: Color::White,
249            placeholder_fg: Color::DarkGray,
250            dropdown_indicator: "▼",
251            highlight_style: Style::default()
252                .fg(Color::Black)
253                .bg(Color::Yellow)
254                .add_modifier(Modifier::BOLD),
255            option_style: Style::default().fg(Color::White),
256            selected_indicator: "✓ ",
257            unselected_indicator: "  ",
258            dropdown_border: Color::Cyan,
259            max_visible_options: 8,
260        }
261    }
262}
263
264impl SelectStyle {
265    /// Minimal style without heavy highlighting.
266    pub fn minimal() -> Self {
267        Self {
268            highlight_style: Style::default()
269                .fg(Color::Yellow)
270                .add_modifier(Modifier::BOLD),
271            ..Default::default()
272        }
273    }
274
275    /// Style with arrow indicator.
276    pub fn arrow() -> Self {
277        Self {
278            selected_indicator: "→ ",
279            unselected_indicator: "  ",
280            ..Default::default()
281        }
282    }
283
284    /// Style with bracket indicator.
285    pub fn bracket() -> Self {
286        Self {
287            selected_indicator: "[x] ",
288            unselected_indicator: "[ ] ",
289            ..Default::default()
290        }
291    }
292
293    /// Set max visible options in dropdown.
294    pub fn max_options(mut self, max: u16) -> Self {
295        self.max_visible_options = max;
296        self
297    }
298
299    /// Set the focused border color.
300    pub fn focused_border(mut self, color: Color) -> Self {
301        self.focused_border = color;
302        self
303    }
304
305    /// Set the unfocused border color.
306    pub fn unfocused_border(mut self, color: Color) -> Self {
307        self.unfocused_border = color;
308        self
309    }
310
311    /// Set the dropdown indicator.
312    pub fn indicator(mut self, indicator: &'static str) -> Self {
313        self.dropdown_indicator = indicator;
314        self
315    }
316
317    /// Set the highlight style for dropdown options.
318    pub fn highlight(mut self, style: Style) -> Self {
319        self.highlight_style = style;
320        self
321    }
322}
323
324/// Default render function type for options.
325type DefaultRenderFn<T> = fn(&T) -> String;
326
327/// Select widget - dropdown select box.
328///
329/// A dropdown select component that renders as a compact closed state and opens
330/// a popup overlay with selectable options when activated.
331pub struct Select<'a, T, F = DefaultRenderFn<T>>
332where
333    F: Fn(&T) -> String,
334{
335    options: &'a [T],
336    state: &'a SelectState,
337    style: SelectStyle,
338    placeholder: &'a str,
339    label: Option<&'a str>,
340    render_option: F,
341    focus_id: FocusId,
342}
343
344impl<'a, T: std::fmt::Display> Select<'a, T, DefaultRenderFn<T>> {
345    /// Create a new select widget with default option rendering.
346    pub fn new(options: &'a [T], state: &'a SelectState) -> Self {
347        Self {
348            options,
349            state,
350            style: SelectStyle::default(),
351            placeholder: "Please select an option",
352            label: None,
353            render_option: |opt| opt.to_string(),
354            focus_id: FocusId::default(),
355        }
356    }
357}
358
359impl<'a, T, F> Select<'a, T, F>
360where
361    F: Fn(&T) -> String,
362{
363    /// Set a custom option renderer.
364    pub fn render_option<G>(self, render_fn: G) -> Select<'a, T, G>
365    where
366        G: Fn(&T) -> String,
367    {
368        Select {
369            options: self.options,
370            state: self.state,
371            style: self.style,
372            placeholder: self.placeholder,
373            label: self.label,
374            render_option: render_fn,
375            focus_id: self.focus_id,
376        }
377    }
378
379    /// Set the placeholder text.
380    pub fn placeholder(mut self, placeholder: &'a str) -> Self {
381        self.placeholder = placeholder;
382        self
383    }
384
385    /// Set the label (border title).
386    pub fn label(mut self, label: &'a str) -> Self {
387        self.label = Some(label);
388        self
389    }
390
391    /// Set the style.
392    pub fn style(mut self, style: SelectStyle) -> Self {
393        self.style = style;
394        self
395    }
396
397    /// Set the focus ID.
398    pub fn focus_id(mut self, id: FocusId) -> Self {
399        self.focus_id = id;
400        self
401    }
402
403    /// Render the closed select box and return click region.
404    ///
405    /// This renders the compact closed state of the select box.
406    /// Call `render_dropdown` separately when the dropdown is open.
407    pub fn render_stateful(self, frame: &mut Frame, area: Rect) -> ClickRegion<SelectAction> {
408        let border_color = if !self.state.enabled {
409            self.style.disabled_border
410        } else if self.state.focused {
411            self.style.focused_border
412        } else {
413            self.style.unfocused_border
414        };
415
416        let mut block = Block::default()
417            .borders(Borders::ALL)
418            .border_style(Style::default().fg(border_color));
419
420        if let Some(label) = self.label {
421            block = block.title(format!(" {} ", label));
422        }
423
424        let inner = block.inner(area);
425        frame.render_widget(block, area);
426
427        // Build display text
428        let display_text = if let Some(idx) = self.state.selected_index {
429            if idx < self.options.len() {
430                let text = (self.render_option)(&self.options[idx]);
431                Span::styled(text, Style::default().fg(self.style.text_fg))
432            } else {
433                Span::styled(
434                    self.placeholder,
435                    Style::default().fg(self.style.placeholder_fg),
436                )
437            }
438        } else {
439            Span::styled(
440                self.placeholder,
441                Style::default().fg(self.style.placeholder_fg),
442            )
443        };
444
445        // Add dropdown indicator on the right
446        let indicator_color = if self.state.focused {
447            self.style.focused_border
448        } else {
449            self.style.unfocused_border
450        };
451
452        let indicator = Span::styled(
453            format!(" {}", self.style.dropdown_indicator),
454            Style::default().fg(indicator_color),
455        );
456
457        let line = Line::from(vec![display_text, indicator]);
458        let paragraph = Paragraph::new(line);
459        frame.render_widget(paragraph, inner);
460
461        ClickRegion::new(area, SelectAction::Focus)
462    }
463
464    /// Render the dropdown overlay.
465    ///
466    /// Call this when `state.is_open` is true. Returns click regions for each option.
467    ///
468    /// # Arguments
469    ///
470    /// * `frame` - The frame to render to
471    /// * `anchor` - The area of the closed select box (dropdown positions below this)
472    /// * `screen` - The full screen area (for bounds checking)
473    pub fn render_dropdown(
474        &self,
475        frame: &mut Frame,
476        anchor: Rect,
477        screen: Rect,
478    ) -> Vec<ClickRegion<SelectAction>> {
479        let mut regions = Vec::new();
480
481        if self.options.is_empty() {
482            return regions;
483        }
484
485        let visible_count = (self.options.len() as u16).min(self.style.max_visible_options);
486        let dropdown_height = visible_count + 2; // +2 for borders
487
488        let dropdown_width = anchor.width;
489
490        // Position dropdown below the anchor, but flip up if not enough space
491        let space_below = screen.height.saturating_sub(anchor.y + anchor.height);
492        let space_above = anchor.y.saturating_sub(screen.y);
493
494        let (dropdown_y, flip_up) = if space_below >= dropdown_height {
495            (anchor.y + anchor.height, false)
496        } else if space_above >= dropdown_height {
497            (anchor.y.saturating_sub(dropdown_height), true)
498        } else {
499            // Not enough space either way, use below and clip
500            (anchor.y + anchor.height, false)
501        };
502
503        let dropdown_area = Rect::new(
504            anchor.x,
505            dropdown_y,
506            dropdown_width,
507            dropdown_height.min(if flip_up { space_above } else { space_below }),
508        );
509
510        // Clear background
511        frame.render_widget(Clear, dropdown_area);
512
513        // Render border
514        let block = Block::default()
515            .borders(Borders::ALL)
516            .border_style(Style::default().fg(self.style.dropdown_border));
517
518        let inner = block.inner(dropdown_area);
519        frame.render_widget(block, dropdown_area);
520
521        // Render options
522        let actual_visible = inner.height as usize;
523        let scroll = self.state.scroll_offset as usize;
524
525        for (i, option) in self
526            .options
527            .iter()
528            .enumerate()
529            .skip(scroll)
530            .take(actual_visible)
531        {
532            let y = inner.y + (i - scroll) as u16;
533            let option_area = Rect::new(inner.x, y, inner.width, 1);
534
535            let is_highlighted = i == self.state.highlighted_index;
536            let is_selected = self.state.selected_index == Some(i);
537
538            let style = if is_highlighted {
539                self.style.highlight_style
540            } else {
541                self.style.option_style
542            };
543
544            let prefix = if is_selected {
545                self.style.selected_indicator
546            } else {
547                self.style.unselected_indicator
548            };
549
550            let text = format!("{}{}", prefix, (self.render_option)(option));
551
552            // Truncate if too long
553            let max_width = inner.width as usize;
554            let display_text: String = text.chars().take(max_width).collect();
555
556            let paragraph = Paragraph::new(Span::styled(display_text, style));
557            frame.render_widget(paragraph, option_area);
558
559            // Register click region for this option
560            regions.push(ClickRegion::new(option_area, SelectAction::Select(i)));
561        }
562
563        regions
564    }
565
566    /// Render the select box using Buffer (Widget-style rendering).
567    ///
568    /// This is useful when you need to render without a Frame reference.
569    pub fn render_to_buffer(self, area: Rect, buf: &mut Buffer) -> ClickRegion<SelectAction> {
570        let border_color = if !self.state.enabled {
571            self.style.disabled_border
572        } else if self.state.focused {
573            self.style.focused_border
574        } else {
575            self.style.unfocused_border
576        };
577
578        let mut block = Block::default()
579            .borders(Borders::ALL)
580            .border_style(Style::default().fg(border_color));
581
582        if let Some(label) = self.label {
583            block = block.title(format!(" {} ", label));
584        }
585
586        let inner = block.inner(area);
587        block.render(area, buf);
588
589        // Build display text
590        let display_text = if let Some(idx) = self.state.selected_index {
591            if idx < self.options.len() {
592                let text = (self.render_option)(&self.options[idx]);
593                Span::styled(text, Style::default().fg(self.style.text_fg))
594            } else {
595                Span::styled(
596                    self.placeholder,
597                    Style::default().fg(self.style.placeholder_fg),
598                )
599            }
600        } else {
601            Span::styled(
602                self.placeholder,
603                Style::default().fg(self.style.placeholder_fg),
604            )
605        };
606
607        let indicator_color = if self.state.focused {
608            self.style.focused_border
609        } else {
610            self.style.unfocused_border
611        };
612
613        let indicator = Span::styled(
614            format!(" {}", self.style.dropdown_indicator),
615            Style::default().fg(indicator_color),
616        );
617
618        let line = Line::from(vec![display_text, indicator]);
619        let paragraph = Paragraph::new(line);
620        paragraph.render(inner, buf);
621
622        ClickRegion::new(area, SelectAction::Focus)
623    }
624}
625
626/// Handle keyboard events for select component.
627///
628/// Returns `Some(SelectAction)` if an action was triggered, `None` otherwise.
629///
630/// # Key Bindings
631///
632/// When closed:
633/// - `Enter`, `Space`, `Down` - Open dropdown
634///
635/// When open:
636/// - `Esc` - Close without selection
637/// - `Enter`, `Space` - Select highlighted option
638/// - `Up` - Move highlight up
639/// - `Down` - Move highlight down
640/// - `Home` - Move to first option
641/// - `End` - Move to last option
642/// - `PageUp` - Move up by 5
643/// - `PageDown` - Move down by 5
644pub fn handle_select_key(key: &KeyEvent, state: &mut SelectState) -> Option<SelectAction> {
645    if !state.enabled {
646        return None;
647    }
648
649    if state.is_open {
650        // Dropdown is open - handle navigation
651        match key.code {
652            KeyCode::Esc => {
653                state.close();
654                Some(SelectAction::Close)
655            }
656            KeyCode::Enter | KeyCode::Char(' ') => {
657                let idx = state.highlighted_index;
658                state.select_highlighted();
659                Some(SelectAction::Select(idx))
660            }
661            KeyCode::Up => {
662                state.highlight_prev();
663                state.ensure_visible(8); // Use default visible count
664                None
665            }
666            KeyCode::Down => {
667                state.highlight_next();
668                state.ensure_visible(8);
669                None
670            }
671            KeyCode::Home => {
672                state.highlight_first();
673                None
674            }
675            KeyCode::End => {
676                state.highlight_last();
677                state.ensure_visible(8);
678                None
679            }
680            KeyCode::PageUp => {
681                for _ in 0..5 {
682                    state.highlight_prev();
683                }
684                state.ensure_visible(8);
685                None
686            }
687            KeyCode::PageDown => {
688                for _ in 0..5 {
689                    state.highlight_next();
690                }
691                state.ensure_visible(8);
692                None
693            }
694            _ => None,
695        }
696    } else {
697        // Dropdown is closed
698        match key.code {
699            KeyCode::Enter | KeyCode::Char(' ') | KeyCode::Down => {
700                state.open();
701                Some(SelectAction::Open)
702            }
703            _ => None,
704        }
705    }
706}
707
708/// Handle mouse events for select component.
709///
710/// Returns `Some(SelectAction)` if an action was triggered, `None` otherwise.
711///
712/// # Arguments
713///
714/// * `mouse` - The mouse event
715/// * `state` - Mutable reference to select state
716/// * `select_area` - The area of the closed select box
717/// * `dropdown_regions` - Click regions from `render_dropdown` (empty if closed)
718pub fn handle_select_mouse(
719    mouse: &MouseEvent,
720    state: &mut SelectState,
721    select_area: Rect,
722    dropdown_regions: &[ClickRegion<SelectAction>],
723) -> Option<SelectAction> {
724    if !state.enabled {
725        return None;
726    }
727
728    if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
729        let col = mouse.column;
730        let row = mouse.row;
731
732        if state.is_open {
733            // Check if clicked on an option
734            for region in dropdown_regions {
735                if region.contains(col, row) {
736                    if let SelectAction::Select(idx) = region.data {
737                        state.select(idx);
738                        return Some(SelectAction::Select(idx));
739                    }
740                }
741            }
742
743            // Check if clicked on the select box itself (toggle/close)
744            if col >= select_area.x
745                && col < select_area.x + select_area.width
746                && row >= select_area.y
747                && row < select_area.y + select_area.height
748            {
749                state.close();
750                return Some(SelectAction::Close);
751            }
752
753            // Clicked outside - close
754            state.close();
755            Some(SelectAction::Close)
756        } else {
757            // Dropdown is closed - check if clicked on select box
758            if col >= select_area.x
759                && col < select_area.x + select_area.width
760                && row >= select_area.y
761                && row < select_area.y + select_area.height
762            {
763                state.open();
764                return Some(SelectAction::Open);
765            }
766            None
767        }
768    } else {
769        None
770    }
771}
772
773/// Calculate the height needed for the select dropdown.
774///
775/// Useful for layout calculations.
776pub fn calculate_dropdown_height(option_count: usize, max_visible: u16) -> u16 {
777    let visible = (option_count as u16).min(max_visible);
778    visible + 2 // +2 for borders
779}
780
781#[cfg(test)]
782mod tests {
783    use super::*;
784
785    #[test]
786    fn test_state_default() {
787        let state = SelectState::default();
788        assert!(state.selected_index.is_none());
789        assert!(!state.is_open);
790        assert!(!state.focused);
791        assert!(state.enabled);
792        assert_eq!(state.highlighted_index, 0);
793    }
794
795    #[test]
796    fn test_state_new() {
797        let state = SelectState::new(5);
798        assert_eq!(state.total_options, 5);
799        assert!(state.selected_index.is_none());
800    }
801
802    #[test]
803    fn test_state_with_selected() {
804        let state = SelectState::with_selected(5, 2);
805        assert_eq!(state.selected_index, Some(2));
806        assert_eq!(state.highlighted_index, 2);
807    }
808
809    #[test]
810    fn test_state_with_selected_out_of_bounds() {
811        let state = SelectState::with_selected(5, 10);
812        assert!(state.selected_index.is_none());
813        assert_eq!(state.highlighted_index, 0);
814    }
815
816    #[test]
817    fn test_open_close() {
818        let mut state = SelectState::new(5);
819
820        state.open();
821        assert!(state.is_open);
822
823        state.close();
824        assert!(!state.is_open);
825
826        state.toggle();
827        assert!(state.is_open);
828
829        state.toggle();
830        assert!(!state.is_open);
831    }
832
833    #[test]
834    fn test_open_disabled() {
835        let mut state = SelectState::new(5);
836        state.enabled = false;
837
838        state.open();
839        assert!(!state.is_open);
840    }
841
842    #[test]
843    fn test_highlight_navigation() {
844        let mut state = SelectState::new(5);
845
846        state.highlight_next();
847        assert_eq!(state.highlighted_index, 1);
848
849        state.highlight_next();
850        assert_eq!(state.highlighted_index, 2);
851
852        state.highlight_prev();
853        assert_eq!(state.highlighted_index, 1);
854
855        state.highlight_first();
856        assert_eq!(state.highlighted_index, 0);
857
858        state.highlight_last();
859        assert_eq!(state.highlighted_index, 4);
860    }
861
862    #[test]
863    fn test_highlight_bounds() {
864        let mut state = SelectState::new(3);
865
866        // Should not go below 0
867        state.highlight_prev();
868        assert_eq!(state.highlighted_index, 0);
869
870        // Should not go above total - 1
871        state.highlighted_index = 2;
872        state.highlight_next();
873        assert_eq!(state.highlighted_index, 2);
874    }
875
876    #[test]
877    fn test_select() {
878        let mut state = SelectState::new(5);
879        state.is_open = true;
880
881        state.select(2);
882        assert_eq!(state.selected_index, Some(2));
883        assert_eq!(state.highlighted_index, 2);
884        assert!(!state.is_open); // Should close after selection
885    }
886
887    #[test]
888    fn test_select_highlighted() {
889        let mut state = SelectState::new(5);
890        state.is_open = true;
891        state.highlighted_index = 3;
892
893        state.select_highlighted();
894        assert_eq!(state.selected_index, Some(3));
895        assert!(!state.is_open);
896    }
897
898    #[test]
899    fn test_clear_selection() {
900        let mut state = SelectState::with_selected(5, 2);
901        assert!(state.has_selection());
902
903        state.clear_selection();
904        assert!(!state.has_selection());
905        assert!(state.selected_index.is_none());
906    }
907
908    #[test]
909    fn test_set_total() {
910        let mut state = SelectState::with_selected(10, 8);
911        state.highlighted_index = 9;
912
913        state.set_total(5);
914        assert_eq!(state.total_options, 5);
915        assert_eq!(state.selected_index, Some(4)); // Clamped
916        assert_eq!(state.highlighted_index, 4); // Clamped
917    }
918
919    #[test]
920    fn test_ensure_visible() {
921        let mut state = SelectState::new(20);
922        state.highlighted_index = 15;
923        state.scroll_offset = 0;
924
925        state.ensure_visible(10);
926        assert!(state.scroll_offset >= 6); // 15 - 10 + 1 = 6
927    }
928
929    #[test]
930    fn test_style_default() {
931        let style = SelectStyle::default();
932        assert_eq!(style.focused_border, Color::Yellow);
933        assert_eq!(style.max_visible_options, 8);
934    }
935
936    #[test]
937    fn test_style_builders() {
938        let style = SelectStyle::minimal();
939        assert_eq!(style.highlight_style.add_modifier, Modifier::BOLD);
940
941        let style = SelectStyle::arrow();
942        assert_eq!(style.selected_indicator, "→ ");
943
944        let style = SelectStyle::bracket();
945        assert_eq!(style.selected_indicator, "[x] ");
946    }
947
948    #[test]
949    fn test_style_builder_methods() {
950        let style = SelectStyle::default()
951            .max_options(10)
952            .focused_border(Color::Cyan)
953            .indicator("↓");
954
955        assert_eq!(style.max_visible_options, 10);
956        assert_eq!(style.focused_border, Color::Cyan);
957        assert_eq!(style.dropdown_indicator, "↓");
958    }
959
960    #[test]
961    fn test_handle_key_closed() {
962        let mut state = SelectState::new(5);
963
964        // Enter should open
965        let key = KeyEvent::from(KeyCode::Enter);
966        let action = handle_select_key(&key, &mut state);
967        assert_eq!(action, Some(SelectAction::Open));
968        assert!(state.is_open);
969    }
970
971    #[test]
972    fn test_handle_key_open_navigation() {
973        let mut state = SelectState::new(5);
974        state.open();
975
976        // Down should move highlight
977        let key = KeyEvent::from(KeyCode::Down);
978        handle_select_key(&key, &mut state);
979        assert_eq!(state.highlighted_index, 1);
980
981        // Up should move highlight back
982        let key = KeyEvent::from(KeyCode::Up);
983        handle_select_key(&key, &mut state);
984        assert_eq!(state.highlighted_index, 0);
985    }
986
987    #[test]
988    fn test_handle_key_open_select() {
989        let mut state = SelectState::new(5);
990        state.open();
991        state.highlighted_index = 2;
992
993        let key = KeyEvent::from(KeyCode::Enter);
994        let action = handle_select_key(&key, &mut state);
995
996        assert_eq!(action, Some(SelectAction::Select(2)));
997        assert_eq!(state.selected_index, Some(2));
998        assert!(!state.is_open);
999    }
1000
1001    #[test]
1002    fn test_handle_key_open_escape() {
1003        let mut state = SelectState::new(5);
1004        state.open();
1005
1006        let key = KeyEvent::from(KeyCode::Esc);
1007        let action = handle_select_key(&key, &mut state);
1008
1009        assert_eq!(action, Some(SelectAction::Close));
1010        assert!(!state.is_open);
1011    }
1012
1013    #[test]
1014    fn test_handle_key_disabled() {
1015        let mut state = SelectState::new(5);
1016        state.enabled = false;
1017
1018        let key = KeyEvent::from(KeyCode::Enter);
1019        let action = handle_select_key(&key, &mut state);
1020
1021        assert!(action.is_none());
1022        assert!(!state.is_open);
1023    }
1024
1025    #[test]
1026    fn test_calculate_dropdown_height() {
1027        assert_eq!(calculate_dropdown_height(3, 8), 5); // 3 + 2
1028        assert_eq!(calculate_dropdown_height(10, 8), 10); // 8 + 2 (clamped)
1029        assert_eq!(calculate_dropdown_height(0, 8), 2); // 0 + 2
1030    }
1031
1032    #[test]
1033    fn test_click_region_contains() {
1034        let region = ClickRegion::new(Rect::new(10, 5, 20, 3), SelectAction::Select(0));
1035
1036        assert!(region.contains(10, 5));
1037        assert!(region.contains(29, 7));
1038        assert!(!region.contains(9, 5));
1039        assert!(!region.contains(30, 5));
1040    }
1041}