Skip to main content

orinium_browser/engine/ui/components/
select.rs

1//! Dropdown component for the HTML `<select>` element.
2//!
3//! The control renders like a combo box: the currently selected option's
4//! label plus a drop-down arrow. Clicking the box opens a popup (top-layer
5//! overlay) listing every option; clicking a row selects it and reports the
6//! new value through the DOM write-back channel.
7
8use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
9use std::sync::{Arc, Mutex};
10
11use ui_layout::Style;
12
13use crate::engine::bridge::text::{self, TextAttribute, TextMeasureRequest};
14use crate::engine::layouter::types::{Color, FontWeight, TextFlowStyle, TextStyle};
15use crate::engine::renderer_model::{Brush, DrawCommand, FillRule, Paint, Path, Rect, rect_path};
16use crate::engine::ui::custom_node::{ContentSize, CustomNode, PointerEvent, Popup};
17
18/// Row height of the box and of each dropdown option.
19const ROW_HEIGHT: f32 = 28.0;
20/// Height of an `<optgroup>` header row inside the dropdown.
21const GROUP_ROW_HEIGHT: f32 = 20.0;
22/// Minimum select width when nothing can be measured.
23const MIN_WIDTH: f32 = 120.0;
24/// Left/right inset of the box label and option rows.
25const INLINE_PADDING: f32 = 6.0;
26/// Width reserved for the drop-down arrow.
27const ARROW_WIDTH: f32 = 24.0;
28/// Popup background color.
29const POPUP_BG: Color = Color(255, 255, 255, 255);
30/// Background of the option row under the cursor.
31const HIGHLIGHT_BG: Color = Color(209, 231, 255, 255);
32/// Background of a selected option row.
33const SELECTED_BG: Color = Color(210, 210, 210, 255);
34/// Text color of a selected option row.
35const SELECTED_COLOR: Color = Color(40, 40, 40, 255);
36/// Text color for disabled controls and disabled options.
37const DISABLED_COLOR: Color = Color(150, 150, 150, 255);
38/// Text color for `<optgroup>` header rows.
39const GROUP_COLOR: Color = Color(110, 110, 110, 255);
40
41/// One `<option>` inside a `<select>`.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct SelectOption {
44    /// The option's `value` attribute (falls back to its text content).
45    pub value: String,
46    /// The option's visible label.
47    pub label: String,
48    /// Whether the option carries the `selected` attribute.
49    pub selected: bool,
50    /// Whether the option (or its `<optgroup>`) carries the `disabled`
51    /// attribute. Disabled options are shown grayed out and cannot be picked.
52    pub disabled: bool,
53    /// Label of the containing `<optgroup>`, when the option is grouped.
54    pub group: Option<String>,
55}
56
57/// A row rendered inside the dropdown: either an option or an `<optgroup>`
58/// header. Group headers are informational and never selectable.
59#[derive(Debug, Clone, Copy)]
60enum PopupRow<'a> {
61    Option(usize),
62    Group(&'a str),
63}
64
65/// Callback invoked when the select's value changes.
66pub type OnSelectChange = dyn Fn(&str) + Send + Sync;
67
68/// An HTML `<select>` rendered by the engine.
69pub struct SelectComponent {
70    options: Vec<SelectOption>,
71    measurer: Arc<dyn text::TextMeasurer>,
72    selected: Mutex<Vec<usize>>,
73    open: AtomicBool,
74    hovered: AtomicBool,
75    hover_index: AtomicI32,
76    dirty: AtomicBool,
77    last_size: Mutex<ContentSize>,
78    on_change: Option<Arc<OnSelectChange>>,
79    disabled: bool,
80    multiple: bool,
81}
82
83impl std::fmt::Debug for SelectComponent {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        f.debug_struct("SelectComponent")
86            .field("options", &self.options)
87            .field("selected", &self.selected.lock().unwrap())
88            .field("open", &self.open)
89            .finish_non_exhaustive()
90    }
91}
92
93impl SelectComponent {
94    /// Creates a select with the given options. `value` is the element's
95    /// `value` attribute; the matching option (or the first `selected` one,
96    /// falling back to the first option) is initially selected.
97    /// `disabled` mirrors the element's `disabled` attribute: a disabled
98    /// select ignores input and never opens its popup.
99    /// `multiple` mirrors the element's `multiple` attribute: multiple
100    /// options can be selected at once (toggled from the popup) and the
101    /// reported value is a comma-separated list.
102    pub fn new(
103        options: Vec<SelectOption>,
104        value: &str,
105        measurer: Arc<dyn text::TextMeasurer>,
106        disabled: bool,
107        multiple: bool,
108    ) -> Self {
109        Self {
110            selected: Mutex::new(initial_selection(&options, value, multiple)),
111            options,
112            measurer,
113            open: AtomicBool::new(false),
114            hovered: AtomicBool::new(false),
115            hover_index: AtomicI32::new(-1),
116            dirty: AtomicBool::new(true),
117            last_size: Mutex::new(ContentSize::zero()),
118            on_change: None,
119            disabled,
120            multiple,
121        }
122    }
123
124    /// Creates a select with a value-change callback for DOM sync.
125    pub fn with_on_change(
126        options: Vec<SelectOption>,
127        value: &str,
128        measurer: Arc<dyn text::TextMeasurer>,
129        on_change: Arc<OnSelectChange>,
130        disabled: bool,
131        multiple: bool,
132    ) -> Self {
133        let mut select = Self::new(options, value, measurer, disabled, multiple);
134        select.on_change = Some(on_change);
135        select
136    }
137
138    /// Returns the currently selected option's value. For a multiple select
139    /// this is the selected values joined with commas.
140    pub fn selected_value(&self) -> String {
141        let selected = self.selected.lock().unwrap();
142        let values: Vec<&str> = selected
143            .iter()
144            .filter_map(|&index| self.options.get(index))
145            .map(|option| option.value.as_str())
146            .collect();
147        if self.multiple {
148            values.join(",")
149        } else {
150            values.first().copied().unwrap_or("").to_string()
151        }
152    }
153
154    fn toggle(&self) {
155        let opened = !self.open.load(Ordering::Relaxed);
156        self.open.store(opened, Ordering::Relaxed);
157        if opened {
158            self.hover_index.store(-1, Ordering::Relaxed);
159        }
160        self.dirty.store(true, Ordering::Relaxed);
161    }
162
163    fn select(&self, index: usize) {
164        if index >= self.options.len() || self.options[index].disabled {
165            return;
166        }
167        let value = {
168            let mut selected = self.selected.lock().unwrap();
169            if self.multiple {
170                if let Some(pos) = selected.iter().position(|&i| i == index) {
171                    selected.remove(pos);
172                } else {
173                    let pos = selected
174                        .iter()
175                        .position(|&i| i > index)
176                        .unwrap_or(selected.len());
177                    selected.insert(pos, index);
178                }
179                drop(selected);
180                self.dirty.store(true, Ordering::Relaxed);
181            } else {
182                *selected = vec![index];
183                drop(selected);
184                self.open.store(false, Ordering::Relaxed);
185                self.dirty.store(true, Ordering::Relaxed);
186            }
187            self.selected_value()
188        };
189        if let Some(ref on_change) = self.on_change {
190            on_change(&value);
191        }
192    }
193
194    /// Rows to render in the dropdown, in document order. A group header is
195    /// inserted whenever an option's group differs from the previous option's.
196    fn popup_rows(&self) -> Vec<PopupRow<'_>> {
197        let mut rows = Vec::new();
198        let mut last_group: Option<&str> = None;
199        for (i, option) in self.options.iter().enumerate() {
200            let group = option.group.as_deref();
201            if group != last_group {
202                if let Some(label) = group {
203                    rows.push(PopupRow::Group(label));
204                }
205                last_group = group;
206            }
207            rows.push(PopupRow::Option(i));
208        }
209        rows
210    }
211
212    /// The row index whose vertical span contains `y`, or `None` if `y` falls
213    /// outside every row.
214    fn row_at(&self, rows: &[PopupRow<'_>], y: f32) -> Option<usize> {
215        let mut acc = 0.0f32;
216        for (i, row) in rows.iter().enumerate() {
217            let h = Self::row_height(row);
218            if y >= acc && y < acc + h {
219                return Some(i);
220            }
221            acc += h;
222        }
223        None
224    }
225
226    fn row_height(row: &PopupRow<'_>) -> f32 {
227        match row {
228            PopupRow::Group(_) => GROUP_ROW_HEIGHT,
229            PopupRow::Option(_) => ROW_HEIGHT,
230        }
231    }
232
233    fn popup_height(&self, rows: &[PopupRow<'_>]) -> f32 {
234        rows.iter().map(Self::row_height).sum()
235    }
236
237    /// Distinct `<optgroup>` labels in document order.
238    fn group_labels(&self) -> Vec<&str> {
239        let mut labels = Vec::new();
240        let mut last: Option<&str> = None;
241        for option in &self.options {
242            let group = option.group.as_deref();
243            if group != last {
244                if let Some(label) = group {
245                    labels.push(label);
246                }
247                last = group;
248            }
249        }
250        labels
251    }
252
253    fn measure_label(&self, label: &str, style: &TextStyle, flow_style: TextFlowStyle) -> f32 {
254        self.measurer
255            .measure(&TextMeasureRequest {
256                text: label.to_string(),
257                attribute: TextAttribute {
258                    style: style.clone(),
259                    flow_style,
260                },
261            })
262            .map(|fragments| fragments.iter().map(|f| f.width).sum())
263            .unwrap_or(0.0)
264    }
265
266    /// The widest option label plus the box chrome (arrow + padding).
267    fn widest_label(&self, style: &TextStyle) -> f32 {
268        self.options
269            .iter()
270            .map(|option| self.measure_label(&option.label, style, TextFlowStyle::default()))
271            .fold(MIN_WIDTH, f32::max)
272    }
273
274    fn popup_width(&self) -> f32 {
275        let box_width = self.get_last_size().width;
276        let labels = self
277            .options
278            .iter()
279            .map(|option| {
280                self.measure_label(
281                    &option.label,
282                    &TextStyle::default(),
283                    TextFlowStyle::default(),
284                )
285            })
286            .chain(self.group_labels().iter().map(|label| {
287                self.measure_label(label, &TextStyle::default(), TextFlowStyle::default())
288            }))
289            .fold(MIN_WIDTH, f32::max);
290        box_width.max(labels + INLINE_PADDING * 2.0 + ARROW_WIDTH)
291    }
292
293    fn push_fill(cmd_buf: &mut Vec<DrawCommand>, path: Path, color: Color) {
294        cmd_buf.push(DrawCommand::Fill {
295            path,
296            rule: FillRule::NonZero,
297            paint: Paint {
298                brush: Brush::Solid(color),
299                opacity: 1.0,
300            },
301        });
302    }
303
304    fn get_last_size(&self) -> ContentSize {
305        *self.last_size.lock().unwrap()
306    }
307
308    /// Draws the dropdown/list rows starting at `y_offset`, with `width` used
309    /// for the hover highlight. Group headers and disabled options reuse the
310    /// popup styling.
311    fn push_rows(
312        &self,
313        cmd_buf: &mut Vec<DrawCommand>,
314        rows: &[PopupRow<'_>],
315        y_offset: f32,
316        width: f32,
317        text_style: &TextStyle,
318        text_flow_style: &TextFlowStyle,
319    ) {
320        let selected = self.selected.lock().unwrap();
321        let hover = self.hover_index.load(Ordering::Relaxed);
322        let font_size = text_flow_style.font_size;
323
324        let mut y = y_offset;
325        for row in rows {
326            match row {
327                PopupRow::Group(label) => {
328                    let mut style = text_style.clone();
329                    let mut flow_style = *text_flow_style;
330                    flow_style.font_size = (font_size * 0.85).max(10.0);
331                    style.font_weight = FontWeight(700);
332                    style.color = GROUP_COLOR;
333                    push_text(
334                        cmd_buf,
335                        INLINE_PADDING,
336                        y + ((GROUP_ROW_HEIGHT - flow_style.font_size) * 0.5).max(0.0),
337                        label,
338                        &style,
339                        flow_style,
340                    );
341                    y += GROUP_ROW_HEIGHT;
342                }
343                PopupRow::Option(i) => {
344                    let option = &self.options[*i];
345                    let is_selected = selected.contains(i);
346                    if is_selected {
347                        Self::push_fill(cmd_buf, rect_path(0.0, y, width, ROW_HEIGHT), SELECTED_BG);
348                    }
349                    if *i as i32 == hover && !option.disabled {
350                        Self::push_fill(
351                            cmd_buf,
352                            rect_path(0.0, y, width, ROW_HEIGHT),
353                            HIGHLIGHT_BG,
354                        );
355                    }
356                    let mut style = text_style.clone();
357                    if is_selected {
358                        style.font_weight = FontWeight(700);
359                        style.color = SELECTED_COLOR;
360                    }
361                    if option.disabled {
362                        style.color = DISABLED_COLOR;
363                    }
364                    push_text(
365                        cmd_buf,
366                        INLINE_PADDING,
367                        y + ((ROW_HEIGHT - font_size) * 0.5).max(0.0),
368                        &option.label,
369                        &style,
370                        *text_flow_style,
371                    );
372                    y += ROW_HEIGHT;
373                }
374            }
375        }
376    }
377
378    /// Pointer handling for a multiple select rendered as a list box: hover
379    /// highlights the option under the cursor and a click toggles it.
380    fn on_list_pointer_event(&self, event: PointerEvent) -> bool {
381        let rows = self.popup_rows();
382        match event {
383            PointerEvent::Move { y, .. } => {
384                self.set_hovered(true);
385                let hover = self
386                    .row_at(&rows, y)
387                    .and_then(|row| match rows[row] {
388                        PopupRow::Option(i) if !self.options[i].disabled => Some(i as i32),
389                        _ => None,
390                    })
391                    .unwrap_or(-1);
392                if self.hover_index.swap(hover, Ordering::Relaxed) != hover {
393                    self.dirty.store(true, Ordering::Relaxed);
394                }
395                true
396            }
397            PointerEvent::Down { y, .. } => {
398                if let Some(PopupRow::Option(i)) = self.row_at(&rows, y).map(|row| rows[row])
399                    && !self.options[i].disabled
400                {
401                    self.select(i);
402                }
403                true
404            }
405            PointerEvent::Up { .. } => false,
406            PointerEvent::Leave => {
407                self.set_hovered(false);
408                if self.hover_index.swap(-1, Ordering::Relaxed) != -1 {
409                    self.dirty.store(true, Ordering::Relaxed);
410                }
411                false
412            }
413        }
414    }
415}
416
417fn push_text(
418    cmd_buf: &mut Vec<DrawCommand>,
419    x: f32,
420    y: f32,
421    text: &str,
422    style: &TextStyle,
423    flow_style: TextFlowStyle,
424) {
425    cmd_buf.push(DrawCommand::DrawText {
426        x,
427        y,
428        text: text.into(),
429        style: style.clone(),
430        flow_style,
431    });
432}
433
434impl CustomNode for SelectComponent {
435    fn draw_sized(
436        &self,
437        cmd_buf: &mut Vec<DrawCommand>,
438        text_style: &TextStyle,
439        text_flow_style: &TextFlowStyle,
440        _style: &Style,
441        size: ContentSize,
442    ) {
443        {
444            *self.last_size.lock().unwrap() = size;
445        }
446
447        // Phase 1: base background/state comes from UA/CSS and container paint.
448        // Multiple <select> and popup chrome remain component-drawn where needed.
449        if self.multiple {
450            cmd_buf.push(DrawCommand::Fill {
451                path: rect_path(0.0, 0.0, size.width, size.height),
452                rule: FillRule::NonZero,
453                paint: Paint {
454                    brush: Brush::Solid(POPUP_BG),
455                    opacity: 1.0,
456                },
457            });
458        }
459
460        if self.multiple {
461            let rows = self.popup_rows();
462            self.push_rows(cmd_buf, &rows, 0.0, size.width, text_style, text_flow_style);
463            // Multiple list border (content-box coordinates).
464            Self::push_fill(
465                cmd_buf,
466                rect_path(0.0, 0.0, size.width, 1.0),
467                Color(118, 118, 118, 255),
468            );
469            Self::push_fill(
470                cmd_buf,
471                rect_path(0.0, size.height - 1.0, size.width, 1.0),
472                Color(118, 118, 118, 255),
473            );
474            Self::push_fill(
475                cmd_buf,
476                rect_path(0.0, 0.0, 1.0, size.height),
477                Color(118, 118, 118, 255),
478            );
479            Self::push_fill(
480                cmd_buf,
481                rect_path(size.width - 1.0, 0.0, 1.0, size.height),
482                Color(118, 118, 118, 255),
483            );
484            return;
485        }
486
487        let label = {
488            let selected = self.selected.lock().unwrap();
489            selected
490                .iter()
491                .filter_map(|&i| self.options.get(i))
492                .map(|option| option.label.as_str())
493                .collect::<Vec<_>>()
494                .join(", ")
495        };
496
497        let mut box_style = text_style.clone();
498        if self.disabled {
499            box_style.color = DISABLED_COLOR;
500        }
501
502        // Open state is component-internal (not a CSS pseudo-class yet), so draw
503        // a border highlight overlay when open. CSS handles :hover/:active.
504        if self.open.load(Ordering::Relaxed) {
505            let open_border = Color(79, 79, 79, 255);
506            for path in [
507                rect_path(0.0, 0.0, size.width, 1.0),
508                rect_path(0.0, size.height - 1.0, size.width, 1.0),
509                rect_path(0.0, 0.0, 1.0, size.height),
510                rect_path(size.width - 1.0, 0.0, 1.0, size.height),
511            ] {
512                Self::push_fill(cmd_buf, path, open_border);
513            }
514        }
515
516        let font_size = text_flow_style.font_size;
517        let text_y = ((size.height - font_size) * 0.5).max(0.0);
518        let arrow_x = (size.width - ARROW_WIDTH).max(0.0);
519
520        // Drop-down arrow on the right.
521        push_text(cmd_buf, arrow_x, text_y, "▾", &box_style, *text_flow_style);
522
523        // The label is clipped so it never runs under the arrow.
524        let label_width = arrow_x.max(0.0);
525        if label_width > 0.0 {
526            cmd_buf.push(DrawCommand::PushClip {
527                path: rect_path(0.0, 0.0, label_width, size.height),
528                rule: FillRule::NonZero,
529            });
530        }
531        push_text(cmd_buf, 0.0, text_y, &label, &box_style, *text_flow_style);
532        if label_width > 0.0 {
533            cmd_buf.push(DrawCommand::PopClip);
534        }
535    }
536
537    fn intrinsic_size(&self) -> ContentSize {
538        if self.multiple {
539            let rows = self.popup_rows();
540            return ContentSize {
541                width: self.widest_label(&TextStyle::default()) + ARROW_WIDTH + 8.0,
542                height: rows.iter().map(Self::row_height).sum(),
543            };
544        }
545        ContentSize {
546            width: self.widest_label(&TextStyle::default()) + ARROW_WIDTH + 8.0,
547            height: ROW_HEIGHT,
548        }
549    }
550
551    fn on_pointer_event(&self, event: PointerEvent) -> bool {
552        if self.disabled {
553            return false;
554        }
555        if self.multiple {
556            return self.on_list_pointer_event(event);
557        }
558        match event {
559            PointerEvent::Move { .. } => {
560                self.set_hovered(true);
561                true
562            }
563            PointerEvent::Down { .. } => {
564                self.toggle();
565                true
566            }
567            PointerEvent::Up { .. } => false,
568            PointerEvent::Leave => {
569                self.set_hovered(false);
570                false
571            }
572        }
573    }
574
575    fn set_hovered(&self, hovered: bool) {
576        if self.hovered.swap(hovered, Ordering::Relaxed) != hovered {
577            self.dirty.store(true, Ordering::Relaxed);
578        }
579    }
580
581    fn is_hovered(&self) -> bool {
582        self.hovered.load(Ordering::Relaxed)
583    }
584
585    fn on_popup_pointer_event(&self, event: PointerEvent) -> bool {
586        if self.disabled {
587            return false;
588        }
589        let rows = self.popup_rows();
590        let width = self.popup_width();
591        let height = self.popup_height(&rows);
592        let in_popup = |x: f32, y: f32| x >= 0.0 && x <= width && y >= 0.0 && y <= height;
593        match event {
594            PointerEvent::Move { x, y } => {
595                let option_index = if in_popup(x, y) {
596                    self.row_at(&rows, y)
597                        .and_then(|row| match rows[row] {
598                            PopupRow::Option(i) if !self.options[i].disabled => Some(i as i32),
599                            _ => None,
600                        })
601                        .unwrap_or(-1)
602                } else {
603                    -1
604                };
605                if self.hover_index.swap(option_index, Ordering::Relaxed) != option_index {
606                    self.dirty.store(true, Ordering::Relaxed);
607                }
608                true
609            }
610            PointerEvent::Down { x, y } if in_popup(x, y) => {
611                if let Some(PopupRow::Option(i)) = self.row_at(&rows, y).map(|row| rows[row])
612                    && !self.options[i].disabled
613                {
614                    self.select(i);
615                }
616                true
617            }
618            PointerEvent::Up { .. } => false,
619            PointerEvent::Leave => {
620                if self.hover_index.swap(-1, Ordering::Relaxed) != -1 {
621                    self.dirty.store(true, Ordering::Relaxed);
622                }
623                false
624            }
625            PointerEvent::Down { .. } => false,
626        }
627    }
628
629    fn dismiss_popup(&self) {
630        if self.open.swap(false, Ordering::Relaxed) {
631            self.dirty.store(true, Ordering::Relaxed);
632        }
633    }
634
635    fn popup(&self, text_style: &TextStyle, text_flow_style: &TextFlowStyle) -> Option<Popup> {
636        if self.multiple
637            || !self.open.load(Ordering::Relaxed)
638            || self.options.is_empty()
639            || self.disabled
640        {
641            return None;
642        }
643
644        let rows = self.popup_rows();
645        let (box_height, width, height) = {
646            let size = self.get_last_size();
647            let width = self.popup_width();
648            let height = self.popup_height(&rows);
649            (size.height, width, height)
650        };
651
652        let mut commands = Vec::new();
653        Self::push_fill(
654            &mut commands,
655            rect_path(0.0, box_height, width, height),
656            POPUP_BG,
657        );
658        // Popup is drawn as a top-layer overlay; give it a UA-like border.
659        let border = Color(118, 118, 118, 255);
660        for path in [
661            rect_path(0.0, box_height, width, 1.0),
662            rect_path(0.0, box_height + height - 1.0, width, 1.0),
663            rect_path(0.0, box_height, 1.0, height),
664            rect_path(width - 1.0, box_height, 1.0, height),
665        ] {
666            Self::push_fill(&mut commands, path, border);
667        }
668        self.push_rows(
669            &mut commands,
670            &rows,
671            box_height,
672            width,
673            text_style,
674            text_flow_style,
675        );
676
677        Some(Popup {
678            rect: Rect {
679                x: 0.0,
680                y: box_height,
681                width,
682                height,
683            },
684            commands,
685        })
686    }
687
688    fn needs_repaint(&self) -> bool {
689        self.dirty.swap(false, Ordering::Relaxed)
690    }
691
692    fn role(&self) -> Option<&'static str> {
693        Some(if self.multiple { "listbox" } else { "combobox" })
694    }
695
696    fn label(&self) -> Option<String> {
697        let selected = self.selected.lock().unwrap();
698        Some(
699            selected
700                .iter()
701                .filter_map(|&i| self.options.get(i))
702                .map(|option| option.label.as_str())
703                .collect::<Vec<_>>()
704                .join(", "),
705        )
706    }
707
708    fn value(&self) -> Option<String> {
709        Some(self.selected_value())
710    }
711
712    fn is_disabled(&self) -> bool {
713        self.disabled
714    }
715}
716
717/// Resolves the initially selected options.
718///
719/// For a multiple select this is every option whose value appears in the
720/// comma-separated `value` (for DOM write-back round-trips), else every option
721/// carrying the `selected` attribute, which may be empty. For a single select
722/// it is the option matching `value`, else the first `selected` one, else the
723/// first option.
724fn initial_selection(options: &[SelectOption], value: &str, multiple: bool) -> Vec<usize> {
725    if !value.is_empty() {
726        let requested: Vec<&str> = if multiple {
727            value.split(',').map(str::trim).collect()
728        } else {
729            vec![value]
730        };
731        let mut indices = Vec::new();
732        for requested_value in requested {
733            if let Some(index) = options
734                .iter()
735                .position(|option| option.value == requested_value)
736                && !indices.contains(&index)
737            {
738                indices.push(index);
739            }
740        }
741        if !indices.is_empty() {
742            return indices;
743        }
744    }
745    if multiple {
746        options
747            .iter()
748            .enumerate()
749            .filter(|(_, option)| option.selected)
750            .map(|(i, _)| i)
751            .collect()
752    } else {
753        vec![
754            options
755                .iter()
756                .position(|option| option.selected)
757                .unwrap_or(0)
758                .min(options.len().saturating_sub(1)),
759        ]
760    }
761}
762
763#[cfg(test)]
764mod tests {
765    use super::*;
766    use crate::engine::bridge::text::FallbackTextMeasurer;
767
768    fn options() -> Vec<SelectOption> {
769        vec![
770            SelectOption {
771                value: "a".into(),
772                label: "Alpha".into(),
773                selected: true,
774                disabled: false,
775                group: None,
776            },
777            SelectOption {
778                value: "b".into(),
779                label: "Bravo".into(),
780                selected: false,
781                disabled: false,
782                group: None,
783            },
784            SelectOption {
785                value: "c".into(),
786                label: "Charlie".into(),
787                selected: false,
788                disabled: false,
789                group: None,
790            },
791        ]
792    }
793
794    fn measurer() -> Arc<dyn text::TextMeasurer> {
795        Arc::new(FallbackTextMeasurer)
796    }
797
798    fn component() -> SelectComponent {
799        SelectComponent::new(options(), "", measurer(), false, false)
800    }
801
802    #[test]
803    fn starts_closed_with_selected_option() {
804        let select = component();
805        assert!(!select.open.load(Ordering::Relaxed));
806        assert_eq!(select.selected_value(), "a");
807        assert_eq!(select.label(), Some("Alpha".to_string()));
808        assert_eq!(select.role(), Some("combobox"));
809    }
810
811    #[test]
812    fn value_attribute_overrides_selected_attribute() {
813        let select = SelectComponent::new(options(), "c", measurer(), false, false);
814        assert_eq!(select.selected_value(), "c");
815    }
816
817    #[test]
818    fn box_click_toggles_popup() {
819        let select = component();
820        select.on_pointer_event(PointerEvent::Down { x: 5.0, y: 5.0 });
821        assert!(
822            select
823                .popup(&TextStyle::default(), &TextFlowStyle::default())
824                .is_some()
825        );
826        select.on_pointer_event(PointerEvent::Down { x: 5.0, y: 5.0 });
827        assert!(
828            select
829                .popup(&TextStyle::default(), &TextFlowStyle::default())
830                .is_none()
831        );
832    }
833
834    #[test]
835    fn popup_row_click_selects_and_closes() {
836        let select = component();
837        select.on_pointer_event(PointerEvent::Down { x: 5.0, y: 5.0 });
838        assert!(
839            select
840                .popup(&TextStyle::default(), &TextFlowStyle::default())
841                .is_some()
842        );
843        // Popup events are expressed relative to the popup's own top-left.
844        select.on_popup_pointer_event(PointerEvent::Down {
845            x: 10.0,
846            y: ROW_HEIGHT * 2.0,
847        });
848        assert_eq!(select.selected_value(), "c");
849        assert!(
850            select
851                .popup(&TextStyle::default(), &TextFlowStyle::default())
852                .is_none()
853        );
854    }
855
856    #[test]
857    fn popup_hover_tracks_row_and_clears_outside() {
858        let select = component();
859        select.on_pointer_event(PointerEvent::Down { x: 5.0, y: 5.0 });
860        assert!(
861            select
862                .popup(&TextStyle::default(), &TextFlowStyle::default())
863                .is_some()
864        );
865
866        select.on_popup_pointer_event(PointerEvent::Move { x: 10.0, y: 2.0 });
867        assert_eq!(select.hover_index.load(Ordering::Relaxed), 0);
868        select.on_popup_pointer_event(PointerEvent::Move {
869            x: 10.0,
870            y: ROW_HEIGHT + 2.0,
871        });
872        assert_eq!(select.hover_index.load(Ordering::Relaxed), 1);
873        // Outside the popup clears the highlight.
874        select.on_popup_pointer_event(PointerEvent::Move { x: 10.0, y: -5.0 });
875        assert_eq!(select.hover_index.load(Ordering::Relaxed), -1);
876    }
877
878    #[test]
879    fn popup_is_empty_when_closed_or_optionless() {
880        let select = component();
881        assert!(
882            select
883                .popup(&TextStyle::default(), &TextFlowStyle::default())
884                .is_none()
885        );
886        let empty = SelectComponent::new(Vec::new(), "", measurer(), false, false);
887        empty.on_pointer_event(PointerEvent::Down { x: 5.0, y: 5.0 });
888        assert!(
889            empty
890                .popup(&TextStyle::default(), &TextFlowStyle::default())
891                .is_none()
892        );
893    }
894
895    #[test]
896    fn dismiss_popup_closes() {
897        let select = component();
898        select.on_pointer_event(PointerEvent::Down { x: 5.0, y: 5.0 });
899        assert!(select.open.load(Ordering::Relaxed));
900        select.dismiss_popup();
901        assert!(!select.open.load(Ordering::Relaxed));
902        assert!(select.needs_repaint());
903    }
904
905    #[test]
906    fn popup_draws_background_highlight_and_option_text() {
907        let select = component();
908        select.on_pointer_event(PointerEvent::Down { x: 5.0, y: 5.0 });
909        let popup = select
910            .popup(&TextStyle::default(), &TextFlowStyle::default())
911            .unwrap();
912        assert!(!popup.commands.is_empty());
913        assert!(
914            popup
915                .commands
916                .iter()
917                .any(|cmd| matches!(cmd, DrawCommand::DrawText { text, .. } if text == "Alpha"))
918        );
919        assert!(
920            popup
921                .commands
922                .iter()
923                .any(|cmd| matches!(cmd, DrawCommand::Fill { .. }))
924        );
925    }
926
927    #[test]
928    fn on_change_reports_new_value() {
929        use std::sync::Mutex as StdMutex;
930        let received: Arc<StdMutex<Vec<String>>> = Arc::new(StdMutex::new(Vec::new()));
931        let received_clone = Arc::clone(&received);
932        let cb: Arc<OnSelectChange> = Arc::new(move |value: &str| {
933            received_clone.lock().unwrap().push(value.to_string());
934        });
935        let select = SelectComponent::with_on_change(options(), "", measurer(), cb, false, false);
936
937        select.on_pointer_event(PointerEvent::Down { x: 5.0, y: 5.0 });
938        assert!(
939            select
940                .popup(&TextStyle::default(), &TextFlowStyle::default())
941                .is_some()
942        );
943        select.on_popup_pointer_event(PointerEvent::Down {
944            x: 10.0,
945            y: ROW_HEIGHT,
946        });
947        assert_eq!(*received.lock().unwrap(), vec!["b".to_string()]);
948    }
949
950    #[test]
951    fn intrinsic_size_fits_wide_option() {
952        let mut wide = options();
953        wide.push(SelectOption {
954            value: "wide".into(),
955            label: "A very long option label".into(),
956            selected: false,
957            disabled: false,
958            group: None,
959        });
960        let select = SelectComponent::new(wide, "", measurer(), false, false);
961        let size = select.intrinsic_size();
962        assert!(size.width >= MIN_WIDTH);
963        assert!(size.height == ROW_HEIGHT);
964    }
965
966    #[test]
967    fn disabled_select_ignores_input_and_never_opens() {
968        let select = SelectComponent::new(options(), "", measurer(), true, false);
969        assert!(select.is_disabled());
970
971        // Consume the initial dirty flag from construction.
972        assert!(select.needs_repaint());
973
974        select.on_pointer_event(PointerEvent::Move { x: 5.0, y: 5.0 });
975        select.on_pointer_event(PointerEvent::Down { x: 5.0, y: 5.0 });
976        assert!(!select.open.load(Ordering::Relaxed));
977        assert!(
978            select
979                .popup(&TextStyle::default(), &TextFlowStyle::default())
980                .is_none()
981        );
982
983        // Disabled events never mark the component dirty.
984        assert!(!select.needs_repaint());
985        select.on_pointer_event(PointerEvent::Down { x: 5.0, y: 5.0 });
986        assert!(!select.needs_repaint());
987    }
988
989    #[test]
990    fn disabled_option_is_not_selectable() {
991        let mut opts = options();
992        opts[1].disabled = true;
993        let select = SelectComponent::new(opts, "", measurer(), false, false);
994
995        select.on_pointer_event(PointerEvent::Down { x: 5.0, y: 5.0 });
996        assert!(
997            select
998                .popup(&TextStyle::default(), &TextFlowStyle::default())
999                .is_some()
1000        );
1001
1002        // Hovering the disabled row must not highlight it.
1003        select.on_popup_pointer_event(PointerEvent::Move {
1004            x: 10.0,
1005            y: ROW_HEIGHT + 2.0,
1006        });
1007        assert_eq!(select.hover_index.load(Ordering::Relaxed), -1);
1008
1009        // Clicking it must not change the selection or close the popup.
1010        select.on_popup_pointer_event(PointerEvent::Down {
1011            x: 10.0,
1012            y: ROW_HEIGHT + 2.0,
1013        });
1014        assert_eq!(select.selected_value(), "a");
1015        assert!(select.open.load(Ordering::Relaxed));
1016    }
1017
1018    fn grouped_options() -> Vec<SelectOption> {
1019        vec![
1020            SelectOption {
1021                value: "a".into(),
1022                label: "Apple".into(),
1023                selected: true,
1024                disabled: false,
1025                group: Some("Fruits".into()),
1026            },
1027            SelectOption {
1028                value: "b".into(),
1029                label: "Banana".into(),
1030                selected: false,
1031                disabled: false,
1032                group: Some("Fruits".into()),
1033            },
1034            SelectOption {
1035                value: "c".into(),
1036                label: "Carrot".into(),
1037                selected: false,
1038                disabled: false,
1039                group: Some("Veggies".into()),
1040            },
1041        ]
1042    }
1043
1044    #[test]
1045    fn optgroup_renders_headers_and_sizes_rows() {
1046        let select = SelectComponent::new(grouped_options(), "", measurer(), false, false);
1047        select.on_pointer_event(PointerEvent::Down { x: 5.0, y: 5.0 });
1048
1049        let rows = select.popup_rows();
1050        assert!(matches!(rows[0], PopupRow::Group("Fruits")));
1051        assert!(matches!(rows[1], PopupRow::Option(0)));
1052        assert!(matches!(rows[2], PopupRow::Option(1)));
1053        assert!(matches!(rows[3], PopupRow::Group("Veggies")));
1054        assert!(matches!(rows[4], PopupRow::Option(2)));
1055
1056        let popup = select
1057            .popup(&TextStyle::default(), &TextFlowStyle::default())
1058            .unwrap();
1059        let expected_height = 2.0 * GROUP_ROW_HEIGHT + 3.0 * ROW_HEIGHT;
1060        assert!((popup.rect.height - expected_height).abs() < 1e-3);
1061        assert!(
1062            popup
1063                .commands
1064                .iter()
1065                .any(|cmd| matches!(cmd, DrawCommand::DrawText { text, .. } if text == "Fruits"))
1066        );
1067
1068        // Group headers must not be clickable: clicking one keeps the popup
1069        // open and preserves the selection.
1070        select.on_popup_pointer_event(PointerEvent::Move { x: 10.0, y: 2.0 });
1071        assert_eq!(select.hover_index.load(Ordering::Relaxed), -1);
1072        select.on_popup_pointer_event(PointerEvent::Down { x: 10.0, y: 2.0 });
1073        assert_eq!(select.selected_value(), "a");
1074        assert!(select.open.load(Ordering::Relaxed));
1075    }
1076
1077    #[test]
1078    fn optgroup_row_offsets_map_to_the_right_option() {
1079        let select = SelectComponent::new(grouped_options(), "", measurer(), false, false);
1080        select.on_pointer_event(PointerEvent::Down { x: 5.0, y: 5.0 });
1081
1082        // Banana sits below the Fruits header + Apple row.
1083        let banana_y = GROUP_ROW_HEIGHT + ROW_HEIGHT + 2.0;
1084        select.on_popup_pointer_event(PointerEvent::Move {
1085            x: 10.0,
1086            y: banana_y,
1087        });
1088        assert_eq!(select.hover_index.load(Ordering::Relaxed), 1);
1089        select.on_popup_pointer_event(PointerEvent::Down {
1090            x: 10.0,
1091            y: banana_y,
1092        });
1093        assert_eq!(select.selected_value(), "b");
1094    }
1095
1096    fn multiple_options() -> Vec<SelectOption> {
1097        vec![
1098            SelectOption {
1099                value: "a".into(),
1100                label: "Alpha".into(),
1101                selected: true,
1102                disabled: false,
1103                group: None,
1104            },
1105            SelectOption {
1106                value: "b".into(),
1107                label: "Bravo".into(),
1108                selected: false,
1109                disabled: false,
1110                group: None,
1111            },
1112            SelectOption {
1113                value: "c".into(),
1114                label: "Charlie".into(),
1115                selected: true,
1116                disabled: false,
1117                group: None,
1118            },
1119        ]
1120    }
1121
1122    #[test]
1123    fn multiple_initializes_from_selected_attributes() {
1124        let select = SelectComponent::new(multiple_options(), "", measurer(), false, true);
1125        assert_eq!(select.role(), Some("listbox"));
1126        assert_eq!(select.value(), Some("a,c".to_string()));
1127        assert_eq!(select.label(), Some("Alpha, Charlie".to_string()));
1128        // No popup: the list is rendered inline.
1129        assert!(
1130            select
1131                .popup(&TextStyle::default(), &TextFlowStyle::default())
1132                .is_none()
1133        );
1134    }
1135
1136    #[test]
1137    fn multiple_grows_vertically_with_rows() {
1138        let select = SelectComponent::new(multiple_options(), "", measurer(), false, true);
1139        let size = select.intrinsic_size();
1140        assert_eq!(size.height, 3.0 * ROW_HEIGHT);
1141
1142        let grouped = SelectComponent::new(grouped_options(), "", measurer(), false, true);
1143        let grouped_size = grouped.intrinsic_size();
1144        assert_eq!(
1145            grouped_size.height,
1146            2.0 * GROUP_ROW_HEIGHT + 3.0 * ROW_HEIGHT
1147        );
1148    }
1149
1150    #[test]
1151    fn multiple_click_toggles_selection_without_popup() {
1152        let select = SelectComponent::new(multiple_options(), "", measurer(), false, true);
1153
1154        // Click Bravo (row 1). It gets selected and the list stays inline.
1155        select.on_pointer_event(PointerEvent::Down {
1156            x: 5.0,
1157            y: ROW_HEIGHT + 2.0,
1158        });
1159        assert_eq!(select.selected_value(), "a,b,c");
1160        assert!(
1161            select
1162                .popup(&TextStyle::default(), &TextFlowStyle::default())
1163                .is_none()
1164        );
1165
1166        // Click Alpha (row 0) to deselect it.
1167        select.on_pointer_event(PointerEvent::Down { x: 5.0, y: 2.0 });
1168        assert_eq!(select.selected_value(), "b,c");
1169        assert!(
1170            select
1171                .popup(&TextStyle::default(), &TextFlowStyle::default())
1172                .is_none()
1173        );
1174    }
1175
1176    #[test]
1177    fn multiple_reports_changes_through_callback() {
1178        use std::sync::Mutex as StdMutex;
1179        let received: Arc<StdMutex<Vec<String>>> = Arc::new(StdMutex::new(Vec::new()));
1180        let received_clone = Arc::clone(&received);
1181        let cb: Arc<OnSelectChange> = Arc::new(move |value: &str| {
1182            received_clone.lock().unwrap().push(value.to_string());
1183        });
1184        let select =
1185            SelectComponent::with_on_change(multiple_options(), "", measurer(), cb, false, true);
1186
1187        select.on_pointer_event(PointerEvent::Down {
1188            x: 5.0,
1189            y: ROW_HEIGHT + 2.0,
1190        });
1191        assert_eq!(*received.lock().unwrap(), vec!["a,b,c".to_string()]);
1192        select.on_pointer_event(PointerEvent::Down {
1193            x: 5.0,
1194            y: ROW_HEIGHT + 2.0,
1195        });
1196        assert_eq!(
1197            *received.lock().unwrap(),
1198            vec!["a,b,c".to_string(), "a,c".to_string()]
1199        );
1200    }
1201
1202    #[test]
1203    fn multiple_value_attribute_restores_selection() {
1204        let select = SelectComponent::new(multiple_options(), "b,c", measurer(), false, true);
1205        assert_eq!(select.selected_value(), "b,c");
1206
1207        // The `selected` attribute is used when no value is provided.
1208        let defaulted = SelectComponent::new(multiple_options(), "", measurer(), false, true);
1209        assert_eq!(defaulted.selected_value(), "a,c");
1210    }
1211
1212    #[test]
1213    fn multiple_disabled_option_is_not_selectable() {
1214        let mut opts = multiple_options();
1215        opts[1].disabled = true;
1216        let select = SelectComponent::new(opts, "", measurer(), false, true);
1217
1218        select.on_pointer_event(PointerEvent::Move {
1219            x: 5.0,
1220            y: ROW_HEIGHT + 2.0,
1221        });
1222        assert_eq!(select.hover_index.load(Ordering::Relaxed), -1);
1223
1224        select.on_pointer_event(PointerEvent::Down {
1225            x: 5.0,
1226            y: ROW_HEIGHT + 2.0,
1227        });
1228        assert_eq!(select.selected_value(), "a,c");
1229    }
1230
1231    #[test]
1232    fn multiple_draws_list_rows_without_arrow() {
1233        let select = SelectComponent::new(multiple_options(), "", measurer(), false, true);
1234        let mut commands = Vec::new();
1235        select.draw_sized(
1236            &mut commands,
1237            &TextStyle::default(),
1238            &TextFlowStyle::default(),
1239            &Style::default(),
1240            select.intrinsic_size(),
1241        );
1242        assert!(
1243            commands
1244                .iter()
1245                .any(|cmd| matches!(cmd, DrawCommand::DrawText { text, .. } if text == "Alpha"))
1246        );
1247        assert!(
1248            commands
1249                .iter()
1250                .any(|cmd| matches!(cmd, DrawCommand::DrawText { text, .. } if text == "Charlie"))
1251        );
1252        // The drop-down arrow is only drawn for single selects.
1253        assert!(
1254            !commands
1255                .iter()
1256                .any(|cmd| matches!(cmd, DrawCommand::DrawText { text, .. } if text == "▾"))
1257        );
1258    }
1259
1260    #[test]
1261    fn multiple_selected_rows_are_highlighted() {
1262        let select = SelectComponent::new(multiple_options(), "", measurer(), false, true);
1263        let mut commands = Vec::new();
1264        select.draw_sized(
1265            &mut commands,
1266            &TextStyle::default(),
1267            &TextFlowStyle::default(),
1268            &Style::default(),
1269            select.intrinsic_size(),
1270        );
1271
1272        // Selected rows (Alpha, Charlie) get a full-width fill with the row
1273        // height; border strips have other heights and are filtered out.
1274        let fills = commands.iter().filter_map(|cmd| match cmd {
1275            DrawCommand::Fill { path, .. } => path
1276                .bounding_box()
1277                .filter(|rect| rect.height == ROW_HEIGHT)
1278                .map(|rect| rect.y),
1279            _ => None,
1280        });
1281        assert_eq!(fills.collect::<Vec<_>>(), vec![0.0, ROW_HEIGHT * 2.0]);
1282    }
1283}