1use 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
18const ROW_HEIGHT: f32 = 28.0;
20const GROUP_ROW_HEIGHT: f32 = 20.0;
22const MIN_WIDTH: f32 = 120.0;
24const INLINE_PADDING: f32 = 6.0;
26const ARROW_WIDTH: f32 = 24.0;
28const POPUP_BG: Color = Color(255, 255, 255, 255);
30const HIGHLIGHT_BG: Color = Color(209, 231, 255, 255);
32const SELECTED_BG: Color = Color(210, 210, 210, 255);
34const SELECTED_COLOR: Color = Color(40, 40, 40, 255);
36const DISABLED_COLOR: Color = Color(150, 150, 150, 255);
38const GROUP_COLOR: Color = Color(110, 110, 110, 255);
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct SelectOption {
44 pub value: String,
46 pub label: String,
48 pub selected: bool,
50 pub disabled: bool,
53 pub group: Option<String>,
55}
56
57#[derive(Debug, Clone, Copy)]
60enum PopupRow<'a> {
61 Option(usize),
62 Group(&'a str),
63}
64
65pub type OnSelectChange = dyn Fn(&str) + Send + Sync;
67
68pub 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 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 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 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 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 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 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 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 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 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 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 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 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 push_text(cmd_buf, arrow_x, text_y, "▾", &box_style, *text_flow_style);
522
523 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 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
717fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}