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 From<&crate::theme::Theme> for SelectStyle {
265    fn from(theme: &crate::theme::Theme) -> Self {
266        let p = &theme.palette;
267        Self {
268            focused_border: p.border_focused,
269            unfocused_border: p.border,
270            disabled_border: p.border_disabled,
271            text_fg: p.text,
272            placeholder_fg: p.text_placeholder,
273            dropdown_indicator: "▼",
274            highlight_style: Style::default()
275                .fg(p.highlight_fg)
276                .bg(p.highlight_bg)
277                .add_modifier(Modifier::BOLD),
278            option_style: Style::default().fg(p.text),
279            selected_indicator: "✓ ",
280            unselected_indicator: "  ",
281            dropdown_border: p.border_accent,
282            max_visible_options: 8,
283        }
284    }
285}
286
287impl SelectStyle {
288    /// Minimal style without heavy highlighting.
289    pub fn minimal() -> Self {
290        Self {
291            highlight_style: Style::default()
292                .fg(Color::Yellow)
293                .add_modifier(Modifier::BOLD),
294            ..Default::default()
295        }
296    }
297
298    /// Style with arrow indicator.
299    pub fn arrow() -> Self {
300        Self {
301            selected_indicator: "→ ",
302            unselected_indicator: "  ",
303            ..Default::default()
304        }
305    }
306
307    /// Style with bracket indicator.
308    pub fn bracket() -> Self {
309        Self {
310            selected_indicator: "[x] ",
311            unselected_indicator: "[ ] ",
312            ..Default::default()
313        }
314    }
315
316    /// Set max visible options in dropdown.
317    pub fn max_options(mut self, max: u16) -> Self {
318        self.max_visible_options = max;
319        self
320    }
321
322    /// Set the focused border color.
323    pub fn focused_border(mut self, color: Color) -> Self {
324        self.focused_border = color;
325        self
326    }
327
328    /// Set the unfocused border color.
329    pub fn unfocused_border(mut self, color: Color) -> Self {
330        self.unfocused_border = color;
331        self
332    }
333
334    /// Set the dropdown indicator.
335    pub fn indicator(mut self, indicator: &'static str) -> Self {
336        self.dropdown_indicator = indicator;
337        self
338    }
339
340    /// Set the highlight style for dropdown options.
341    pub fn highlight(mut self, style: Style) -> Self {
342        self.highlight_style = style;
343        self
344    }
345}
346
347/// Default render function type for options.
348type DefaultRenderFn<T> = fn(&T) -> String;
349
350/// Select widget - dropdown select box.
351///
352/// A dropdown select component that renders as a compact closed state and opens
353/// a popup overlay with selectable options when activated.
354pub struct Select<'a, T, F = DefaultRenderFn<T>>
355where
356    F: Fn(&T) -> String,
357{
358    options: &'a [T],
359    state: &'a SelectState,
360    style: SelectStyle,
361    placeholder: &'a str,
362    label: Option<&'a str>,
363    render_option: F,
364    focus_id: FocusId,
365}
366
367impl<'a, T: std::fmt::Display> Select<'a, T, DefaultRenderFn<T>> {
368    /// Create a new select widget with default option rendering.
369    pub fn new(options: &'a [T], state: &'a SelectState) -> Self {
370        Self {
371            options,
372            state,
373            style: SelectStyle::default(),
374            placeholder: "Please select an option",
375            label: None,
376            render_option: |opt| opt.to_string(),
377            focus_id: FocusId::default(),
378        }
379    }
380}
381
382impl<'a, T, F> Select<'a, T, F>
383where
384    F: Fn(&T) -> String,
385{
386    /// Set a custom option renderer.
387    pub fn render_option<G>(self, render_fn: G) -> Select<'a, T, G>
388    where
389        G: Fn(&T) -> String,
390    {
391        Select {
392            options: self.options,
393            state: self.state,
394            style: self.style,
395            placeholder: self.placeholder,
396            label: self.label,
397            render_option: render_fn,
398            focus_id: self.focus_id,
399        }
400    }
401
402    /// Set the placeholder text.
403    pub fn placeholder(mut self, placeholder: &'a str) -> Self {
404        self.placeholder = placeholder;
405        self
406    }
407
408    /// Set the label (border title).
409    pub fn label(mut self, label: &'a str) -> Self {
410        self.label = Some(label);
411        self
412    }
413
414    /// Set the style.
415    pub fn style(mut self, style: SelectStyle) -> Self {
416        self.style = style;
417        self
418    }
419
420    /// Apply a theme to this select.
421    pub fn theme(self, theme: &crate::theme::Theme) -> Self {
422        self.style(SelectStyle::from(theme))
423    }
424
425    /// Set the focus ID.
426    pub fn focus_id(mut self, id: FocusId) -> Self {
427        self.focus_id = id;
428        self
429    }
430
431    /// Render the closed select box and return click region.
432    ///
433    /// This renders the compact closed state of the select box.
434    /// Call `render_dropdown` separately when the dropdown is open.
435    pub fn render_stateful(self, frame: &mut Frame, area: Rect) -> ClickRegion<SelectAction> {
436        let border_color = if !self.state.enabled {
437            self.style.disabled_border
438        } else if self.state.focused {
439            self.style.focused_border
440        } else {
441            self.style.unfocused_border
442        };
443
444        let mut block = Block::default()
445            .borders(Borders::ALL)
446            .border_style(Style::default().fg(border_color));
447
448        if let Some(label) = self.label {
449            block = block.title(format!(" {} ", label));
450        }
451
452        let inner = block.inner(area);
453        frame.render_widget(block, area);
454
455        // Build display text
456        let display_text = if let Some(idx) = self.state.selected_index {
457            if idx < self.options.len() {
458                let text = (self.render_option)(&self.options[idx]);
459                Span::styled(text, Style::default().fg(self.style.text_fg))
460            } else {
461                Span::styled(
462                    self.placeholder,
463                    Style::default().fg(self.style.placeholder_fg),
464                )
465            }
466        } else {
467            Span::styled(
468                self.placeholder,
469                Style::default().fg(self.style.placeholder_fg),
470            )
471        };
472
473        // Add dropdown indicator on the right
474        let indicator_color = if self.state.focused {
475            self.style.focused_border
476        } else {
477            self.style.unfocused_border
478        };
479
480        let indicator = Span::styled(
481            format!(" {}", self.style.dropdown_indicator),
482            Style::default().fg(indicator_color),
483        );
484
485        let line = Line::from(vec![display_text, indicator]);
486        let paragraph = Paragraph::new(line);
487        frame.render_widget(paragraph, inner);
488
489        ClickRegion::new(area, SelectAction::Focus)
490    }
491
492    /// Render the dropdown overlay.
493    ///
494    /// Call this when `state.is_open` is true. Returns click regions for each option.
495    ///
496    /// # Arguments
497    ///
498    /// * `frame` - The frame to render to
499    /// * `anchor` - The area of the closed select box (dropdown positions below this)
500    /// * `screen` - The full screen area (for bounds checking)
501    pub fn render_dropdown(
502        &self,
503        frame: &mut Frame,
504        anchor: Rect,
505        screen: Rect,
506    ) -> Vec<ClickRegion<SelectAction>> {
507        let mut regions = Vec::new();
508
509        if self.options.is_empty() {
510            return regions;
511        }
512
513        let visible_count = (self.options.len() as u16).min(self.style.max_visible_options);
514        let dropdown_height = visible_count + 2; // +2 for borders
515
516        let dropdown_width = anchor.width;
517
518        // Position dropdown below the anchor, but flip up if not enough space
519        let space_below = screen.height.saturating_sub(anchor.y + anchor.height);
520        let space_above = anchor.y.saturating_sub(screen.y);
521
522        let (dropdown_y, flip_up) = if space_below >= dropdown_height {
523            (anchor.y + anchor.height, false)
524        } else if space_above >= dropdown_height {
525            (anchor.y.saturating_sub(dropdown_height), true)
526        } else {
527            // Not enough space either way, use below and clip
528            (anchor.y + anchor.height, false)
529        };
530
531        let dropdown_area = Rect::new(
532            anchor.x,
533            dropdown_y,
534            dropdown_width,
535            dropdown_height.min(if flip_up { space_above } else { space_below }),
536        );
537
538        // Clear background
539        frame.render_widget(Clear, dropdown_area);
540
541        // Render border
542        let block = Block::default()
543            .borders(Borders::ALL)
544            .border_style(Style::default().fg(self.style.dropdown_border));
545
546        let inner = block.inner(dropdown_area);
547        frame.render_widget(block, dropdown_area);
548
549        // Render options
550        let actual_visible = inner.height as usize;
551        let scroll = self.state.scroll_offset as usize;
552
553        for (i, option) in self
554            .options
555            .iter()
556            .enumerate()
557            .skip(scroll)
558            .take(actual_visible)
559        {
560            let y = inner.y + (i - scroll) as u16;
561            let option_area = Rect::new(inner.x, y, inner.width, 1);
562
563            let is_highlighted = i == self.state.highlighted_index;
564            let is_selected = self.state.selected_index == Some(i);
565
566            let style = if is_highlighted {
567                self.style.highlight_style
568            } else {
569                self.style.option_style
570            };
571
572            let prefix = if is_selected {
573                self.style.selected_indicator
574            } else {
575                self.style.unselected_indicator
576            };
577
578            let text = format!("{}{}", prefix, (self.render_option)(option));
579
580            // Truncate if too long
581            let max_width = inner.width as usize;
582            let display_text: String = text.chars().take(max_width).collect();
583
584            let paragraph = Paragraph::new(Span::styled(display_text, style));
585            frame.render_widget(paragraph, option_area);
586
587            // Register click region for this option
588            regions.push(ClickRegion::new(option_area, SelectAction::Select(i)));
589        }
590
591        regions
592    }
593
594    /// Render the select box using Buffer (Widget-style rendering).
595    ///
596    /// This is useful when you need to render without a Frame reference.
597    pub fn render_to_buffer(self, area: Rect, buf: &mut Buffer) -> ClickRegion<SelectAction> {
598        let border_color = if !self.state.enabled {
599            self.style.disabled_border
600        } else if self.state.focused {
601            self.style.focused_border
602        } else {
603            self.style.unfocused_border
604        };
605
606        let mut block = Block::default()
607            .borders(Borders::ALL)
608            .border_style(Style::default().fg(border_color));
609
610        if let Some(label) = self.label {
611            block = block.title(format!(" {} ", label));
612        }
613
614        let inner = block.inner(area);
615        block.render(area, buf);
616
617        // Build display text
618        let display_text = if let Some(idx) = self.state.selected_index {
619            if idx < self.options.len() {
620                let text = (self.render_option)(&self.options[idx]);
621                Span::styled(text, Style::default().fg(self.style.text_fg))
622            } else {
623                Span::styled(
624                    self.placeholder,
625                    Style::default().fg(self.style.placeholder_fg),
626                )
627            }
628        } else {
629            Span::styled(
630                self.placeholder,
631                Style::default().fg(self.style.placeholder_fg),
632            )
633        };
634
635        let indicator_color = if self.state.focused {
636            self.style.focused_border
637        } else {
638            self.style.unfocused_border
639        };
640
641        let indicator = Span::styled(
642            format!(" {}", self.style.dropdown_indicator),
643            Style::default().fg(indicator_color),
644        );
645
646        let line = Line::from(vec![display_text, indicator]);
647        let paragraph = Paragraph::new(line);
648        paragraph.render(inner, buf);
649
650        ClickRegion::new(area, SelectAction::Focus)
651    }
652}
653
654/// Handle keyboard events for select component.
655///
656/// Returns `Some(SelectAction)` if an action was triggered, `None` otherwise.
657///
658/// # Key Bindings
659///
660/// When closed:
661/// - `Enter`, `Space`, `Down` - Open dropdown
662///
663/// When open:
664/// - `Esc` - Close without selection
665/// - `Enter`, `Space` - Select highlighted option
666/// - `Up` - Move highlight up
667/// - `Down` - Move highlight down
668/// - `Home` - Move to first option
669/// - `End` - Move to last option
670/// - `PageUp` - Move up by 5
671/// - `PageDown` - Move down by 5
672pub fn handle_select_key(key: &KeyEvent, state: &mut SelectState) -> Option<SelectAction> {
673    if !state.enabled {
674        return None;
675    }
676
677    if state.is_open {
678        // Dropdown is open - handle navigation
679        match key.code {
680            KeyCode::Esc => {
681                state.close();
682                Some(SelectAction::Close)
683            }
684            KeyCode::Enter | KeyCode::Char(' ') => {
685                let idx = state.highlighted_index;
686                state.select_highlighted();
687                Some(SelectAction::Select(idx))
688            }
689            KeyCode::Up => {
690                state.highlight_prev();
691                state.ensure_visible(8); // Use default visible count
692                None
693            }
694            KeyCode::Down => {
695                state.highlight_next();
696                state.ensure_visible(8);
697                None
698            }
699            KeyCode::Home => {
700                state.highlight_first();
701                None
702            }
703            KeyCode::End => {
704                state.highlight_last();
705                state.ensure_visible(8);
706                None
707            }
708            KeyCode::PageUp => {
709                for _ in 0..5 {
710                    state.highlight_prev();
711                }
712                state.ensure_visible(8);
713                None
714            }
715            KeyCode::PageDown => {
716                for _ in 0..5 {
717                    state.highlight_next();
718                }
719                state.ensure_visible(8);
720                None
721            }
722            _ => None,
723        }
724    } else {
725        // Dropdown is closed
726        match key.code {
727            KeyCode::Enter | KeyCode::Char(' ') | KeyCode::Down => {
728                state.open();
729                Some(SelectAction::Open)
730            }
731            _ => None,
732        }
733    }
734}
735
736/// Handle mouse events for select component.
737///
738/// Returns `Some(SelectAction)` if an action was triggered, `None` otherwise.
739///
740/// # Arguments
741///
742/// * `mouse` - The mouse event
743/// * `state` - Mutable reference to select state
744/// * `select_area` - The area of the closed select box
745/// * `dropdown_regions` - Click regions from `render_dropdown` (empty if closed)
746pub fn handle_select_mouse(
747    mouse: &MouseEvent,
748    state: &mut SelectState,
749    select_area: Rect,
750    dropdown_regions: &[ClickRegion<SelectAction>],
751) -> Option<SelectAction> {
752    if !state.enabled {
753        return None;
754    }
755
756    if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
757        let col = mouse.column;
758        let row = mouse.row;
759
760        if state.is_open {
761            // Check if clicked on an option
762            for region in dropdown_regions {
763                if region.contains(col, row) {
764                    if let SelectAction::Select(idx) = region.data {
765                        state.select(idx);
766                        return Some(SelectAction::Select(idx));
767                    }
768                }
769            }
770
771            // Check if clicked on the select box itself (toggle/close)
772            if col >= select_area.x
773                && col < select_area.x + select_area.width
774                && row >= select_area.y
775                && row < select_area.y + select_area.height
776            {
777                state.close();
778                return Some(SelectAction::Close);
779            }
780
781            // Clicked outside - close
782            state.close();
783            Some(SelectAction::Close)
784        } else {
785            // Dropdown is closed - check if clicked on select box
786            if col >= select_area.x
787                && col < select_area.x + select_area.width
788                && row >= select_area.y
789                && row < select_area.y + select_area.height
790            {
791                state.open();
792                return Some(SelectAction::Open);
793            }
794            None
795        }
796    } else {
797        None
798    }
799}
800
801/// Calculate the height needed for the select dropdown.
802///
803/// Useful for layout calculations.
804pub fn calculate_dropdown_height(option_count: usize, max_visible: u16) -> u16 {
805    let visible = (option_count as u16).min(max_visible);
806    visible + 2 // +2 for borders
807}
808
809#[cfg(test)]
810mod tests {
811    use super::*;
812
813    #[test]
814    fn test_state_default() {
815        let state = SelectState::default();
816        assert!(state.selected_index.is_none());
817        assert!(!state.is_open);
818        assert!(!state.focused);
819        assert!(state.enabled);
820        assert_eq!(state.highlighted_index, 0);
821    }
822
823    #[test]
824    fn test_state_new() {
825        let state = SelectState::new(5);
826        assert_eq!(state.total_options, 5);
827        assert!(state.selected_index.is_none());
828    }
829
830    #[test]
831    fn test_state_with_selected() {
832        let state = SelectState::with_selected(5, 2);
833        assert_eq!(state.selected_index, Some(2));
834        assert_eq!(state.highlighted_index, 2);
835    }
836
837    #[test]
838    fn test_state_with_selected_out_of_bounds() {
839        let state = SelectState::with_selected(5, 10);
840        assert!(state.selected_index.is_none());
841        assert_eq!(state.highlighted_index, 0);
842    }
843
844    #[test]
845    fn test_open_close() {
846        let mut state = SelectState::new(5);
847
848        state.open();
849        assert!(state.is_open);
850
851        state.close();
852        assert!(!state.is_open);
853
854        state.toggle();
855        assert!(state.is_open);
856
857        state.toggle();
858        assert!(!state.is_open);
859    }
860
861    #[test]
862    fn test_open_disabled() {
863        let mut state = SelectState::new(5);
864        state.enabled = false;
865
866        state.open();
867        assert!(!state.is_open);
868    }
869
870    #[test]
871    fn test_highlight_navigation() {
872        let mut state = SelectState::new(5);
873
874        state.highlight_next();
875        assert_eq!(state.highlighted_index, 1);
876
877        state.highlight_next();
878        assert_eq!(state.highlighted_index, 2);
879
880        state.highlight_prev();
881        assert_eq!(state.highlighted_index, 1);
882
883        state.highlight_first();
884        assert_eq!(state.highlighted_index, 0);
885
886        state.highlight_last();
887        assert_eq!(state.highlighted_index, 4);
888    }
889
890    #[test]
891    fn test_highlight_bounds() {
892        let mut state = SelectState::new(3);
893
894        // Should not go below 0
895        state.highlight_prev();
896        assert_eq!(state.highlighted_index, 0);
897
898        // Should not go above total - 1
899        state.highlighted_index = 2;
900        state.highlight_next();
901        assert_eq!(state.highlighted_index, 2);
902    }
903
904    #[test]
905    fn test_select() {
906        let mut state = SelectState::new(5);
907        state.is_open = true;
908
909        state.select(2);
910        assert_eq!(state.selected_index, Some(2));
911        assert_eq!(state.highlighted_index, 2);
912        assert!(!state.is_open); // Should close after selection
913    }
914
915    #[test]
916    fn test_select_highlighted() {
917        let mut state = SelectState::new(5);
918        state.is_open = true;
919        state.highlighted_index = 3;
920
921        state.select_highlighted();
922        assert_eq!(state.selected_index, Some(3));
923        assert!(!state.is_open);
924    }
925
926    #[test]
927    fn test_clear_selection() {
928        let mut state = SelectState::with_selected(5, 2);
929        assert!(state.has_selection());
930
931        state.clear_selection();
932        assert!(!state.has_selection());
933        assert!(state.selected_index.is_none());
934    }
935
936    #[test]
937    fn test_set_total() {
938        let mut state = SelectState::with_selected(10, 8);
939        state.highlighted_index = 9;
940
941        state.set_total(5);
942        assert_eq!(state.total_options, 5);
943        assert_eq!(state.selected_index, Some(4)); // Clamped
944        assert_eq!(state.highlighted_index, 4); // Clamped
945    }
946
947    #[test]
948    fn test_ensure_visible() {
949        let mut state = SelectState::new(20);
950        state.highlighted_index = 15;
951        state.scroll_offset = 0;
952
953        state.ensure_visible(10);
954        assert!(state.scroll_offset >= 6); // 15 - 10 + 1 = 6
955    }
956
957    #[test]
958    fn test_style_default() {
959        let style = SelectStyle::default();
960        assert_eq!(style.focused_border, Color::Yellow);
961        assert_eq!(style.max_visible_options, 8);
962    }
963
964    #[test]
965    fn test_style_builders() {
966        let style = SelectStyle::minimal();
967        assert_eq!(style.highlight_style.add_modifier, Modifier::BOLD);
968
969        let style = SelectStyle::arrow();
970        assert_eq!(style.selected_indicator, "→ ");
971
972        let style = SelectStyle::bracket();
973        assert_eq!(style.selected_indicator, "[x] ");
974    }
975
976    #[test]
977    fn test_style_builder_methods() {
978        let style = SelectStyle::default()
979            .max_options(10)
980            .focused_border(Color::Cyan)
981            .indicator("↓");
982
983        assert_eq!(style.max_visible_options, 10);
984        assert_eq!(style.focused_border, Color::Cyan);
985        assert_eq!(style.dropdown_indicator, "↓");
986    }
987
988    #[test]
989    fn test_handle_key_closed() {
990        let mut state = SelectState::new(5);
991
992        // Enter should open
993        let key = KeyEvent::from(KeyCode::Enter);
994        let action = handle_select_key(&key, &mut state);
995        assert_eq!(action, Some(SelectAction::Open));
996        assert!(state.is_open);
997    }
998
999    #[test]
1000    fn test_handle_key_open_navigation() {
1001        let mut state = SelectState::new(5);
1002        state.open();
1003
1004        // Down should move highlight
1005        let key = KeyEvent::from(KeyCode::Down);
1006        handle_select_key(&key, &mut state);
1007        assert_eq!(state.highlighted_index, 1);
1008
1009        // Up should move highlight back
1010        let key = KeyEvent::from(KeyCode::Up);
1011        handle_select_key(&key, &mut state);
1012        assert_eq!(state.highlighted_index, 0);
1013    }
1014
1015    #[test]
1016    fn test_handle_key_open_select() {
1017        let mut state = SelectState::new(5);
1018        state.open();
1019        state.highlighted_index = 2;
1020
1021        let key = KeyEvent::from(KeyCode::Enter);
1022        let action = handle_select_key(&key, &mut state);
1023
1024        assert_eq!(action, Some(SelectAction::Select(2)));
1025        assert_eq!(state.selected_index, Some(2));
1026        assert!(!state.is_open);
1027    }
1028
1029    #[test]
1030    fn test_handle_key_open_escape() {
1031        let mut state = SelectState::new(5);
1032        state.open();
1033
1034        let key = KeyEvent::from(KeyCode::Esc);
1035        let action = handle_select_key(&key, &mut state);
1036
1037        assert_eq!(action, Some(SelectAction::Close));
1038        assert!(!state.is_open);
1039    }
1040
1041    #[test]
1042    fn test_handle_key_disabled() {
1043        let mut state = SelectState::new(5);
1044        state.enabled = false;
1045
1046        let key = KeyEvent::from(KeyCode::Enter);
1047        let action = handle_select_key(&key, &mut state);
1048
1049        assert!(action.is_none());
1050        assert!(!state.is_open);
1051    }
1052
1053    #[test]
1054    fn test_calculate_dropdown_height() {
1055        assert_eq!(calculate_dropdown_height(3, 8), 5); // 3 + 2
1056        assert_eq!(calculate_dropdown_height(10, 8), 10); // 8 + 2 (clamped)
1057        assert_eq!(calculate_dropdown_height(0, 8), 2); // 0 + 2
1058    }
1059
1060    #[test]
1061    fn test_click_region_contains() {
1062        let region = ClickRegion::new(Rect::new(10, 5, 20, 3), SelectAction::Select(0));
1063
1064        assert!(region.contains(10, 5));
1065        assert!(region.contains(29, 7));
1066        assert!(!region.contains(9, 5));
1067        assert!(!region.contains(30, 5));
1068    }
1069}