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