Skip to main content

presentar_widgets/
select.rs

1//! Select/Dropdown widget for choosing from options.
2
3use presentar_core::{
4    widget::{AccessibleRole, Brick, BrickAssertion, BrickBudget, BrickVerification, LayoutResult},
5    Canvas, Color, Constraints, Event, MouseButton, Rect, Size, TypeId, Widget,
6};
7use serde::{Deserialize, Serialize};
8use std::any::Any;
9use std::time::Duration;
10
11/// A selectable option.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct SelectOption {
14    /// Unique value for this option
15    pub value: String,
16    /// Display label
17    pub label: String,
18    /// Whether this option is disabled
19    pub disabled: bool,
20}
21
22impl SelectOption {
23    /// Create a new option.
24    #[must_use]
25    pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
26        Self {
27            value: value.into(),
28            label: label.into(),
29            disabled: false,
30        }
31    }
32
33    /// Create an option where value equals label.
34    #[must_use]
35    pub fn simple(text: impl Into<String>) -> Self {
36        let text = text.into();
37        Self {
38            value: text.clone(),
39            label: text,
40            disabled: false,
41        }
42    }
43
44    /// Set disabled state.
45    #[must_use]
46    pub const fn disabled(mut self, disabled: bool) -> Self {
47        self.disabled = disabled;
48        self
49    }
50}
51
52/// Message emitted when selection changes.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct SelectionChanged {
55    /// The newly selected value (None if cleared)
56    pub value: Option<String>,
57    /// Index of the selected option
58    pub index: Option<usize>,
59}
60
61/// Select/Dropdown widget.
62#[derive(Serialize, Deserialize)]
63pub struct Select {
64    /// Available options
65    options: Vec<SelectOption>,
66    /// Currently selected index (None for no selection)
67    selected: Option<usize>,
68    /// Placeholder text when nothing selected
69    placeholder: String,
70    /// Whether the dropdown is currently open
71    #[serde(skip)]
72    open: bool,
73    /// Whether the widget is disabled
74    disabled: bool,
75    /// Minimum width
76    min_width: f32,
77    /// Item height
78    item_height: f32,
79    /// Maximum visible items in dropdown
80    max_visible_items: usize,
81    /// Background color
82    background_color: Color,
83    /// Border color
84    border_color: Color,
85    /// Selected item background
86    selected_bg_color: Color,
87    /// Hover item background
88    hover_bg_color: Color,
89    /// Text color
90    text_color: Color,
91    /// Placeholder text color
92    placeholder_color: Color,
93    /// Disabled color
94    disabled_color: Color,
95    /// Test ID
96    test_id_value: Option<String>,
97    /// Accessible name
98    accessible_name_value: Option<String>,
99    /// Cached bounds
100    #[serde(skip)]
101    bounds: Rect,
102    /// Currently hovered item index
103    #[serde(skip)]
104    hovered_item: Option<usize>,
105}
106
107impl Default for Select {
108    fn default() -> Self {
109        Self::new()
110    }
111}
112
113impl Select {
114    /// Create a new select widget.
115    #[must_use]
116    pub fn new() -> Self {
117        Self {
118            options: Vec::new(),
119            selected: None,
120            placeholder: "Select...".to_string(),
121            open: false,
122            disabled: false,
123            min_width: 150.0,
124            item_height: 32.0,
125            max_visible_items: 8,
126            background_color: Color::WHITE,
127            border_color: Color::new(0.8, 0.8, 0.8, 1.0),
128            selected_bg_color: Color::new(0.9, 0.95, 1.0, 1.0),
129            hover_bg_color: Color::new(0.95, 0.95, 0.95, 1.0),
130            text_color: Color::BLACK,
131            placeholder_color: Color::new(0.6, 0.6, 0.6, 1.0),
132            disabled_color: Color::new(0.7, 0.7, 0.7, 1.0),
133            test_id_value: None,
134            accessible_name_value: None,
135            bounds: Rect::default(),
136            hovered_item: None,
137        }
138    }
139
140    /// Add an option.
141    #[must_use]
142    pub fn option(mut self, opt: SelectOption) -> Self {
143        self.options.push(opt);
144        self
145    }
146
147    /// Add multiple options.
148    #[must_use]
149    pub fn options(mut self, opts: impl IntoIterator<Item = SelectOption>) -> Self {
150        self.options.extend(opts);
151        self
152    }
153
154    /// Set options from simple string values.
155    #[must_use]
156    pub fn options_from_strings(
157        mut self,
158        values: impl IntoIterator<Item = impl Into<String>>,
159    ) -> Self {
160        self.options = values.into_iter().map(SelectOption::simple).collect();
161        self
162    }
163
164    /// Set placeholder text.
165    #[must_use]
166    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
167        self.placeholder = text.into();
168        self
169    }
170
171    /// Set selected index.
172    #[must_use]
173    pub fn selected(mut self, index: Option<usize>) -> Self {
174        self.selected = index.filter(|&i| i < self.options.len());
175        self
176    }
177
178    /// Set selected by value.
179    #[must_use]
180    pub fn selected_value(mut self, value: &str) -> Self {
181        self.selected = self.options.iter().position(|o| o.value == value);
182        self
183    }
184
185    /// Set disabled state.
186    #[must_use]
187    pub const fn disabled(mut self, disabled: bool) -> Self {
188        self.disabled = disabled;
189        self
190    }
191
192    /// Set minimum width.
193    #[must_use]
194    pub fn min_width(mut self, width: f32) -> Self {
195        self.min_width = width.max(50.0);
196        self
197    }
198
199    /// Set item height.
200    #[must_use]
201    pub fn item_height(mut self, height: f32) -> Self {
202        self.item_height = height.max(20.0);
203        self
204    }
205
206    /// Set max visible items.
207    #[must_use]
208    pub fn max_visible_items(mut self, count: usize) -> Self {
209        self.max_visible_items = count.max(1);
210        self
211    }
212
213    /// Set background color.
214    #[must_use]
215    pub const fn background_color(mut self, color: Color) -> Self {
216        self.background_color = color;
217        self
218    }
219
220    /// Set border color.
221    #[must_use]
222    pub const fn border_color(mut self, color: Color) -> Self {
223        self.border_color = color;
224        self
225    }
226
227    /// Set test ID.
228    #[must_use]
229    pub fn with_test_id(mut self, id: impl Into<String>) -> Self {
230        self.test_id_value = Some(id.into());
231        self
232    }
233
234    /// Set accessible name.
235    #[must_use]
236    pub fn with_accessible_name(mut self, name: impl Into<String>) -> Self {
237        self.accessible_name_value = Some(name.into());
238        self
239    }
240
241    /// Get selected index.
242    #[must_use]
243    pub const fn get_selected(&self) -> Option<usize> {
244        self.selected
245    }
246
247    /// Get selected value.
248    #[must_use]
249    pub fn get_selected_value(&self) -> Option<&str> {
250        self.selected.map(|i| self.options[i].value.as_str())
251    }
252
253    /// Get selected label.
254    #[must_use]
255    pub fn get_selected_label(&self) -> Option<&str> {
256        self.selected.map(|i| self.options[i].label.as_str())
257    }
258
259    /// Get all options.
260    #[must_use]
261    pub fn get_options(&self) -> &[SelectOption] {
262        &self.options
263    }
264
265    /// Check if dropdown is open.
266    #[must_use]
267    pub const fn is_open(&self) -> bool {
268        self.open
269    }
270
271    /// Check if empty (no options).
272    #[must_use]
273    pub fn is_empty(&self) -> bool {
274        self.options.is_empty()
275    }
276
277    /// Get option count.
278    #[must_use]
279    pub fn option_count(&self) -> usize {
280        self.options.len()
281    }
282
283    /// Calculate dropdown height.
284    fn dropdown_height(&self) -> f32 {
285        let visible = self.options.len().min(self.max_visible_items);
286        visible as f32 * self.item_height
287    }
288
289    /// Get item rect at index.
290    fn item_rect(&self, index: usize) -> Rect {
291        let y = (index as f32).mul_add(self.item_height, self.bounds.y + self.item_height);
292        Rect::new(self.bounds.x, y, self.bounds.width, self.item_height)
293    }
294
295    /// Find item at position.
296    fn item_at_position(&self, y: f32) -> Option<usize> {
297        if !self.open {
298            return None;
299        }
300
301        let dropdown_top = self.bounds.y + self.item_height;
302        if y < dropdown_top {
303            return None;
304        }
305
306        let relative_y = y - dropdown_top;
307        let index = (relative_y / self.item_height) as usize;
308
309        if index < self.options.len() && index < self.max_visible_items {
310            Some(index)
311        } else {
312            None
313        }
314    }
315}
316
317impl Widget for Select {
318    fn type_id(&self) -> TypeId {
319        TypeId::of::<Self>()
320    }
321
322    fn measure(&self, constraints: Constraints) -> Size {
323        let width = self.min_width;
324        let height = self.item_height;
325        constraints.constrain(Size::new(width, height))
326    }
327
328    fn layout(&mut self, bounds: Rect) -> LayoutResult {
329        self.bounds = bounds;
330        LayoutResult {
331            size: bounds.size(),
332        }
333    }
334
335    fn paint(&self, canvas: &mut dyn Canvas) {
336        // Draw main button/header
337        let header_rect = Rect::new(
338            self.bounds.x,
339            self.bounds.y,
340            self.bounds.width,
341            self.item_height,
342        );
343
344        let bg_color = if self.disabled {
345            self.disabled_color
346        } else {
347            self.background_color
348        };
349
350        canvas.fill_rect(header_rect, bg_color);
351        canvas.stroke_rect(header_rect, self.border_color, 1.0);
352
353        // Draw selected text or placeholder
354        let text = self.get_selected_label().unwrap_or(&self.placeholder);
355        let text_color = if self.disabled {
356            self.disabled_color
357        } else if self.selected.is_some() {
358            self.text_color
359        } else {
360            self.placeholder_color
361        };
362
363        let text_style = presentar_core::widget::TextStyle {
364            color: text_color,
365            ..Default::default()
366        };
367        let text_pos = presentar_core::Point::new(
368            self.bounds.x + 8.0,
369            self.bounds.y + (self.item_height - 16.0) / 2.0,
370        );
371        canvas.draw_text(text, text_pos, &text_style);
372
373        // Draw dropdown arrow
374        let arrow_x = self.bounds.x + self.bounds.width - 20.0;
375        let arrow_y = self.bounds.y + self.item_height / 2.0;
376        let arrow_rect = Rect::new(arrow_x, arrow_y - 3.0, 8.0, 6.0);
377        canvas.fill_rect(arrow_rect, self.text_color);
378
379        // Draw dropdown if open
380        if self.open && !self.options.is_empty() {
381            let dropdown_rect = Rect::new(
382                self.bounds.x,
383                self.bounds.y + self.item_height,
384                self.bounds.width,
385                self.dropdown_height(),
386            );
387
388            canvas.fill_rect(dropdown_rect, self.background_color);
389            canvas.stroke_rect(dropdown_rect, self.border_color, 1.0);
390
391            // Draw items
392            for (i, opt) in self.options.iter().take(self.max_visible_items).enumerate() {
393                let item_rect = self.item_rect(i);
394
395                // Background
396                let item_bg = if Some(i) == self.selected {
397                    self.selected_bg_color
398                } else if Some(i) == self.hovered_item {
399                    self.hover_bg_color
400                } else {
401                    self.background_color
402                };
403                canvas.fill_rect(item_rect, item_bg);
404
405                // Text
406                let item_color = if opt.disabled {
407                    self.disabled_color
408                } else {
409                    self.text_color
410                };
411                let item_style = presentar_core::widget::TextStyle {
412                    color: item_color,
413                    ..Default::default()
414                };
415                let item_pos = presentar_core::Point::new(
416                    item_rect.x + 8.0,
417                    item_rect.y + (self.item_height - 16.0) / 2.0,
418                );
419                canvas.draw_text(&opt.label, item_pos, &item_style);
420            }
421        }
422    }
423
424    fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
425        if self.disabled {
426            return None;
427        }
428
429        match event {
430            Event::MouseMove { position } => {
431                if self.open {
432                    self.hovered_item = self.item_at_position(position.y);
433                }
434            }
435            Event::MouseDown {
436                position,
437                button: MouseButton::Left,
438            } => {
439                let header_rect = Rect::new(
440                    self.bounds.x,
441                    self.bounds.y,
442                    self.bounds.width,
443                    self.item_height,
444                );
445
446                if header_rect.contains_point(position) {
447                    // Toggle dropdown
448                    self.open = !self.open;
449                    self.hovered_item = None;
450                } else if self.open {
451                    // Check if clicked on an item
452                    if let Some(index) = self.item_at_position(position.y) {
453                        let opt = &self.options[index];
454                        if !opt.disabled {
455                            self.selected = Some(index);
456                            self.open = false;
457                            return Some(Box::new(SelectionChanged {
458                                value: Some(opt.value.clone()),
459                                index: Some(index),
460                            }));
461                        }
462                    } else {
463                        // Clicked outside - close
464                        self.open = false;
465                    }
466                }
467            }
468            Event::FocusOut => {
469                self.open = false;
470            }
471            _ => {}
472        }
473
474        None
475    }
476
477    fn children(&self) -> &[Box<dyn Widget>] {
478        &[]
479    }
480
481    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
482        &mut []
483    }
484
485    fn is_interactive(&self) -> bool {
486        !self.disabled
487    }
488
489    fn is_focusable(&self) -> bool {
490        !self.disabled
491    }
492
493    fn accessible_name(&self) -> Option<&str> {
494        self.accessible_name_value.as_deref()
495    }
496
497    fn accessible_role(&self) -> AccessibleRole {
498        AccessibleRole::ComboBox
499    }
500
501    fn test_id(&self) -> Option<&str> {
502        self.test_id_value.as_deref()
503    }
504}
505
506// PROBAR-SPEC-009: Brick Architecture - Tests define interface
507impl Brick for Select {
508    fn brick_name(&self) -> &'static str {
509        "Select"
510    }
511
512    fn assertions(&self) -> &[BrickAssertion] {
513        &[BrickAssertion::MaxLatencyMs(16)]
514    }
515
516    fn budget(&self) -> BrickBudget {
517        BrickBudget::uniform(16)
518    }
519
520    fn verify(&self) -> BrickVerification {
521        BrickVerification {
522            passed: self.assertions().to_vec(),
523            failed: vec![],
524            verification_time: Duration::from_micros(10),
525        }
526    }
527
528    fn to_html(&self) -> String {
529        r#"<div class="brick-select"></div>"#.to_string()
530    }
531
532    fn to_css(&self) -> String {
533        ".brick-select { display: block; }".to_string()
534    }
535}
536
537#[cfg(test)]
538#[allow(clippy::unwrap_used, clippy::disallowed_methods)]
539mod tests {
540    use super::*;
541    use presentar_core::Widget;
542
543    // =========================================================================
544    // SelectOption Tests - TESTS FIRST
545    // =========================================================================
546
547    #[test]
548    fn test_select_option_new() {
549        let opt = SelectOption::new("val", "Label");
550        assert_eq!(opt.value, "val");
551        assert_eq!(opt.label, "Label");
552        assert!(!opt.disabled);
553    }
554
555    #[test]
556    fn test_select_option_simple() {
557        let opt = SelectOption::simple("Same");
558        assert_eq!(opt.value, "Same");
559        assert_eq!(opt.label, "Same");
560    }
561
562    #[test]
563    fn test_select_option_disabled() {
564        let opt = SelectOption::new("v", "L").disabled(true);
565        assert!(opt.disabled);
566    }
567
568    // =========================================================================
569    // SelectionChanged Tests - TESTS FIRST
570    // =========================================================================
571
572    #[test]
573    fn test_selection_changed_message() {
574        let msg = SelectionChanged {
575            value: Some("test".to_string()),
576            index: Some(0),
577        };
578        assert_eq!(msg.value, Some("test".to_string()));
579        assert_eq!(msg.index, Some(0));
580    }
581
582    #[test]
583    fn test_selection_changed_none() {
584        let msg = SelectionChanged {
585            value: None,
586            index: None,
587        };
588        assert!(msg.value.is_none());
589        assert!(msg.index.is_none());
590    }
591
592    // =========================================================================
593    // Select Construction Tests - TESTS FIRST
594    // =========================================================================
595
596    #[test]
597    fn test_select_new() {
598        let s = Select::new();
599        assert!(s.is_empty());
600        assert_eq!(s.get_selected(), None);
601        assert!(!s.is_open());
602        assert!(!s.disabled);
603    }
604
605    #[test]
606    fn test_select_default() {
607        let s = Select::default();
608        assert!(s.is_empty());
609    }
610
611    #[test]
612    fn test_select_builder() {
613        let s = Select::new()
614            .option(SelectOption::new("a", "Option A"))
615            .option(SelectOption::new("b", "Option B"))
616            .placeholder("Choose one")
617            .selected(Some(0))
618            .min_width(200.0)
619            .item_height(40.0)
620            .with_test_id("my-select")
621            .with_accessible_name("Country");
622
623        assert_eq!(s.option_count(), 2);
624        assert_eq!(s.get_selected(), Some(0));
625        assert_eq!(Widget::test_id(&s), Some("my-select"));
626        assert_eq!(s.accessible_name(), Some("Country"));
627    }
628
629    #[test]
630    fn test_select_options() {
631        let opts = vec![
632            SelectOption::simple("One"),
633            SelectOption::simple("Two"),
634            SelectOption::simple("Three"),
635        ];
636        let s = Select::new().options(opts);
637        assert_eq!(s.option_count(), 3);
638    }
639
640    #[test]
641    fn test_select_options_from_strings() {
642        let s = Select::new().options_from_strings(["Red", "Green", "Blue"]);
643        assert_eq!(s.option_count(), 3);
644        assert_eq!(s.get_options()[0].value, "Red");
645        assert_eq!(s.get_options()[0].label, "Red");
646    }
647
648    // =========================================================================
649    // Select Selection Tests - TESTS FIRST
650    // =========================================================================
651
652    #[test]
653    fn test_select_selected_index() {
654        let s = Select::new()
655            .options_from_strings(["A", "B", "C"])
656            .selected(Some(1));
657        assert_eq!(s.get_selected(), Some(1));
658        assert_eq!(s.get_selected_value(), Some("B"));
659        assert_eq!(s.get_selected_label(), Some("B"));
660    }
661
662    #[test]
663    fn test_select_selected_value() {
664        let s = Select::new()
665            .option(SelectOption::new("val1", "Label 1"))
666            .option(SelectOption::new("val2", "Label 2"))
667            .selected_value("val2");
668        assert_eq!(s.get_selected(), Some(1));
669    }
670
671    #[test]
672    fn test_select_selected_out_of_bounds() {
673        let s = Select::new()
674            .options_from_strings(["A", "B"])
675            .selected(Some(10));
676        assert_eq!(s.get_selected(), None); // Should clamp
677    }
678
679    #[test]
680    fn test_select_selected_value_not_found() {
681        let s = Select::new()
682            .options_from_strings(["A", "B"])
683            .selected_value("C");
684        assert_eq!(s.get_selected(), None);
685    }
686
687    #[test]
688    fn test_select_no_selection() {
689        let s = Select::new().options_from_strings(["A", "B"]);
690        assert_eq!(s.get_selected(), None);
691        assert_eq!(s.get_selected_value(), None);
692        assert_eq!(s.get_selected_label(), None);
693    }
694
695    // =========================================================================
696    // Select Widget Trait Tests - TESTS FIRST
697    // =========================================================================
698
699    #[test]
700    fn test_select_type_id() {
701        let s = Select::new();
702        assert_eq!(Widget::type_id(&s), TypeId::of::<Select>());
703    }
704
705    #[test]
706    fn test_select_measure() {
707        let s = Select::new().min_width(150.0).item_height(32.0);
708        let size = s.measure(Constraints::loose(Size::new(400.0, 200.0)));
709        assert_eq!(size.width, 150.0);
710        assert_eq!(size.height, 32.0);
711    }
712
713    #[test]
714    fn test_select_is_interactive() {
715        let s = Select::new();
716        assert!(s.is_interactive());
717
718        let s = Select::new().disabled(true);
719        assert!(!s.is_interactive());
720    }
721
722    #[test]
723    fn test_select_is_focusable() {
724        let s = Select::new();
725        assert!(s.is_focusable());
726
727        let s = Select::new().disabled(true);
728        assert!(!s.is_focusable());
729    }
730
731    #[test]
732    fn test_select_accessible_role() {
733        let s = Select::new();
734        assert_eq!(s.accessible_role(), AccessibleRole::ComboBox);
735    }
736
737    #[test]
738    fn test_select_children() {
739        let s = Select::new();
740        assert!(s.children().is_empty());
741    }
742
743    // =========================================================================
744    // Select Layout Tests - TESTS FIRST
745    // =========================================================================
746
747    #[test]
748    fn test_select_layout() {
749        let mut s = Select::new();
750        let bounds = Rect::new(10.0, 20.0, 200.0, 32.0);
751        let result = s.layout(bounds);
752        assert_eq!(result.size, bounds.size());
753        assert_eq!(s.bounds, bounds);
754    }
755
756    // =========================================================================
757    // Select Size Tests - TESTS FIRST
758    // =========================================================================
759
760    #[test]
761    fn test_select_min_width_min() {
762        let s = Select::new().min_width(10.0);
763        assert_eq!(s.min_width, 50.0); // Minimum is 50
764    }
765
766    #[test]
767    fn test_select_item_height_min() {
768        let s = Select::new().item_height(5.0);
769        assert_eq!(s.item_height, 20.0); // Minimum is 20
770    }
771
772    #[test]
773    fn test_select_max_visible_items_min() {
774        let s = Select::new().max_visible_items(0);
775        assert_eq!(s.max_visible_items, 1); // Minimum is 1
776    }
777
778    // =========================================================================
779    // Select Color Tests - TESTS FIRST
780    // =========================================================================
781
782    #[test]
783    fn test_select_colors() {
784        let s = Select::new()
785            .background_color(Color::RED)
786            .border_color(Color::GREEN);
787        assert_eq!(s.background_color, Color::RED);
788        assert_eq!(s.border_color, Color::GREEN);
789    }
790
791    // =========================================================================
792    // Select Dropdown Tests - TESTS FIRST
793    // =========================================================================
794
795    #[test]
796    fn test_select_dropdown_height() {
797        let s = Select::new()
798            .options_from_strings(["A", "B", "C"])
799            .item_height(30.0)
800            .max_visible_items(10);
801        // 3 items * 30px = 90px
802        assert_eq!(s.dropdown_height(), 90.0);
803    }
804
805    #[test]
806    fn test_select_dropdown_height_limited() {
807        let s = Select::new()
808            .options_from_strings(["A", "B", "C", "D", "E"])
809            .item_height(30.0)
810            .max_visible_items(3);
811        // limited to 3 items * 30px = 90px
812        assert_eq!(s.dropdown_height(), 90.0);
813    }
814
815    #[test]
816    fn test_select_is_empty() {
817        let s = Select::new();
818        assert!(s.is_empty());
819
820        let s = Select::new().options_from_strings(["A"]);
821        assert!(!s.is_empty());
822    }
823
824    #[test]
825    fn test_select_option_count() {
826        let s = Select::new().options_from_strings(["A", "B", "C"]);
827        assert_eq!(s.option_count(), 3);
828    }
829
830    // =========================================================================
831    // Event Handling Tests - TESTS FIRST
832    // =========================================================================
833
834    use presentar_core::Point;
835
836    #[test]
837    fn test_select_event_click_header_opens_dropdown() {
838        let mut s = Select::new()
839            .options_from_strings(["A", "B", "C"])
840            .item_height(32.0);
841        s.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
842
843        assert!(!s.open);
844        let result = s.event(&Event::MouseDown {
845            position: Point::new(100.0, 16.0), // In header
846            button: MouseButton::Left,
847        });
848        assert!(s.open);
849        assert!(result.is_none()); // Just opens, no selection
850    }
851
852    #[test]
853    fn test_select_event_click_header_closes_dropdown() {
854        let mut s = Select::new()
855            .options_from_strings(["A", "B", "C"])
856            .item_height(32.0);
857        s.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
858        s.open = true;
859
860        let result = s.event(&Event::MouseDown {
861            position: Point::new(100.0, 16.0), // In header
862            button: MouseButton::Left,
863        });
864        assert!(!s.open);
865        assert!(result.is_none());
866    }
867
868    #[test]
869    fn test_select_event_click_item_selects() {
870        let mut s = Select::new()
871            .options_from_strings(["Apple", "Banana", "Cherry"])
872            .item_height(32.0);
873        s.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
874        s.open = true;
875
876        // Click second item (index 1): y = 32 + 32 + 16 = 80 (middle of item 1)
877        let result = s.event(&Event::MouseDown {
878            position: Point::new(100.0, 80.0),
879            button: MouseButton::Left,
880        });
881
882        assert!(!s.open); // Closes after selection
883        assert_eq!(s.get_selected(), Some(1));
884        assert!(result.is_some());
885
886        let msg = result.unwrap().downcast::<SelectionChanged>().unwrap();
887        assert_eq!(msg.value, Some("Banana".to_string()));
888        assert_eq!(msg.index, Some(1));
889    }
890
891    #[test]
892    fn test_select_event_click_first_item() {
893        let mut s = Select::new()
894            .options_from_strings(["First", "Second", "Third"])
895            .item_height(32.0);
896        s.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
897        s.open = true;
898
899        // Click first item (index 0): y = 32 + 16 = 48
900        let result = s.event(&Event::MouseDown {
901            position: Point::new(100.0, 48.0),
902            button: MouseButton::Left,
903        });
904
905        assert_eq!(s.get_selected(), Some(0));
906        let msg = result.unwrap().downcast::<SelectionChanged>().unwrap();
907        assert_eq!(msg.value, Some("First".to_string()));
908        assert_eq!(msg.index, Some(0));
909    }
910
911    #[test]
912    fn test_select_event_click_disabled_item_no_select() {
913        let mut s = Select::new()
914            .option(SelectOption::simple("Enabled"))
915            .option(SelectOption::simple("Disabled").disabled(true))
916            .item_height(32.0);
917        s.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
918        s.open = true;
919
920        // Click disabled item (index 1)
921        let result = s.event(&Event::MouseDown {
922            position: Point::new(100.0, 80.0),
923            button: MouseButton::Left,
924        });
925
926        assert!(s.open); // Stays open
927        assert!(s.get_selected().is_none());
928        assert!(result.is_none());
929    }
930
931    #[test]
932    fn test_select_event_click_outside_closes() {
933        let mut s = Select::new()
934            .options_from_strings(["A", "B", "C"])
935            .item_height(32.0);
936        s.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
937        s.open = true;
938
939        // Click far below dropdown
940        let result = s.event(&Event::MouseDown {
941            position: Point::new(100.0, 500.0),
942            button: MouseButton::Left,
943        });
944
945        assert!(!s.open);
946        assert!(result.is_none());
947    }
948
949    #[test]
950    fn test_select_event_mouse_move_updates_hover() {
951        let mut s = Select::new()
952            .options_from_strings(["A", "B", "C"])
953            .item_height(32.0);
954        s.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
955        s.open = true;
956
957        assert!(s.hovered_item.is_none());
958
959        // Hover over item 1
960        s.event(&Event::MouseMove {
961            position: Point::new(100.0, 80.0),
962        });
963        assert_eq!(s.hovered_item, Some(1));
964
965        // Hover over item 0
966        s.event(&Event::MouseMove {
967            position: Point::new(100.0, 48.0),
968        });
969        assert_eq!(s.hovered_item, Some(0));
970    }
971
972    #[test]
973    fn test_select_event_mouse_move_when_closed_no_hover() {
974        let mut s = Select::new()
975            .options_from_strings(["A", "B", "C"])
976            .item_height(32.0);
977        s.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
978        // Closed
979
980        s.event(&Event::MouseMove {
981            position: Point::new(100.0, 80.0),
982        });
983        assert!(s.hovered_item.is_none());
984    }
985
986    #[test]
987    fn test_select_event_focus_out_closes() {
988        let mut s = Select::new()
989            .options_from_strings(["A", "B", "C"])
990            .item_height(32.0);
991        s.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
992        s.open = true;
993
994        let result = s.event(&Event::FocusOut);
995        assert!(!s.open);
996        assert!(result.is_none());
997    }
998
999    #[test]
1000    fn test_select_event_disabled_blocks_click() {
1001        let mut s = Select::new()
1002            .options_from_strings(["A", "B", "C"])
1003            .item_height(32.0)
1004            .disabled(true);
1005        s.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1006
1007        let result = s.event(&Event::MouseDown {
1008            position: Point::new(100.0, 16.0),
1009            button: MouseButton::Left,
1010        });
1011
1012        assert!(!s.open);
1013        assert!(result.is_none());
1014    }
1015
1016    #[test]
1017    fn test_select_event_disabled_blocks_mouse_move() {
1018        let mut s = Select::new()
1019            .options_from_strings(["A", "B", "C"])
1020            .item_height(32.0)
1021            .disabled(true);
1022        s.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1023        s.open = true; // Force open
1024
1025        s.event(&Event::MouseMove {
1026            position: Point::new(100.0, 80.0),
1027        });
1028        assert!(s.hovered_item.is_none());
1029    }
1030
1031    #[test]
1032    fn test_select_event_right_click_no_effect() {
1033        let mut s = Select::new()
1034            .options_from_strings(["A", "B", "C"])
1035            .item_height(32.0);
1036        s.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1037
1038        let result = s.event(&Event::MouseDown {
1039            position: Point::new(100.0, 16.0),
1040            button: MouseButton::Right,
1041        });
1042
1043        assert!(!s.open);
1044        assert!(result.is_none());
1045    }
1046
1047    #[test]
1048    fn test_select_event_click_header_clears_hover() {
1049        let mut s = Select::new()
1050            .options_from_strings(["A", "B", "C"])
1051            .item_height(32.0);
1052        s.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1053        s.hovered_item = Some(1);
1054
1055        // Open dropdown
1056        s.event(&Event::MouseDown {
1057            position: Point::new(100.0, 16.0),
1058            button: MouseButton::Left,
1059        });
1060
1061        assert!(s.open);
1062        assert!(s.hovered_item.is_none()); // Cleared
1063    }
1064
1065    #[test]
1066    fn test_select_event_full_interaction_flow() {
1067        let mut s = Select::new()
1068            .options_from_strings(["Red", "Green", "Blue"])
1069            .item_height(32.0);
1070        s.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1071
1072        // 1. Click to open
1073        s.event(&Event::MouseDown {
1074            position: Point::new(100.0, 16.0),
1075            button: MouseButton::Left,
1076        });
1077        assert!(s.open);
1078        assert!(s.selected.is_none());
1079
1080        // 2. Hover over items
1081        s.event(&Event::MouseMove {
1082            position: Point::new(100.0, 48.0), // Item 0
1083        });
1084        assert_eq!(s.hovered_item, Some(0));
1085
1086        s.event(&Event::MouseMove {
1087            position: Point::new(100.0, 112.0), // Item 2
1088        });
1089        assert_eq!(s.hovered_item, Some(2));
1090
1091        // 3. Select item 2
1092        let result = s.event(&Event::MouseDown {
1093            position: Point::new(100.0, 112.0),
1094            button: MouseButton::Left,
1095        });
1096        assert!(!s.open);
1097        assert_eq!(s.get_selected(), Some(2));
1098        assert_eq!(s.get_selected_value(), Some("Blue"));
1099
1100        let msg = result.unwrap().downcast::<SelectionChanged>().unwrap();
1101        assert_eq!(msg.value, Some("Blue".to_string()));
1102
1103        // 4. Reopen and change selection
1104        s.event(&Event::MouseDown {
1105            position: Point::new(100.0, 16.0),
1106            button: MouseButton::Left,
1107        });
1108        assert!(s.open);
1109
1110        // 5. Select item 0
1111        let result = s.event(&Event::MouseDown {
1112            position: Point::new(100.0, 48.0),
1113            button: MouseButton::Left,
1114        });
1115        assert_eq!(s.get_selected_value(), Some("Red"));
1116
1117        let msg = result.unwrap().downcast::<SelectionChanged>().unwrap();
1118        assert_eq!(msg.value, Some("Red".to_string()));
1119        assert_eq!(msg.index, Some(0));
1120    }
1121
1122    #[test]
1123    fn test_select_event_item_at_position_edge_cases() {
1124        let mut s = Select::new()
1125            .options_from_strings(["A", "B"])
1126            .item_height(32.0)
1127            .max_visible_items(2);
1128        s.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1129        s.open = true;
1130
1131        // Just inside first item
1132        assert_eq!(s.item_at_position(32.0), Some(0));
1133        // Just inside second item
1134        assert_eq!(s.item_at_position(64.0), Some(1));
1135        // Past last item
1136        assert_eq!(s.item_at_position(96.0), None);
1137        // In header area
1138        assert_eq!(s.item_at_position(16.0), None);
1139    }
1140
1141    #[test]
1142    fn test_select_event_item_rect_positions() {
1143        let mut s = Select::new()
1144            .options_from_strings(["A", "B", "C"])
1145            .item_height(30.0);
1146        s.layout(Rect::new(10.0, 20.0, 200.0, 30.0));
1147
1148        // Item 0 starts at y = 20 + 30 = 50
1149        let rect0 = s.item_rect(0);
1150        assert_eq!(rect0.x, 10.0);
1151        assert_eq!(rect0.y, 50.0);
1152        assert_eq!(rect0.height, 30.0);
1153
1154        // Item 1 starts at y = 20 + 30 + 30 = 80
1155        let rect1 = s.item_rect(1);
1156        assert_eq!(rect1.y, 80.0);
1157    }
1158
1159    #[test]
1160    fn test_select_event_with_offset_bounds() {
1161        let mut s = Select::new()
1162            .options_from_strings(["X", "Y", "Z"])
1163            .item_height(32.0);
1164        s.layout(Rect::new(100.0, 50.0, 200.0, 32.0));
1165        s.open = true;
1166
1167        // Click item 0: header is at y=50-82, item 0 is at y=82-114
1168        let result = s.event(&Event::MouseDown {
1169            position: Point::new(200.0, 98.0), // Middle of item 0
1170            button: MouseButton::Left,
1171        });
1172
1173        assert_eq!(s.get_selected(), Some(0));
1174        assert!(result.is_some());
1175    }
1176}