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 BORDER_COLOR: Color = Color(150, 150, 150, 255);
30const POPUP_BG: Color = Color(255, 255, 255, 255);
32const HIGHLIGHT_BG: Color = Color(209, 231, 255, 255);
34const SELECTED_BG: Color = Color(210, 210, 210, 255);
36const SELECTED_COLOR: Color = Color(40, 40, 40, 255);
38const DISABLED_COLOR: Color = Color(150, 150, 150, 255);
40const GROUP_COLOR: Color = Color(110, 110, 110, 255);
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct SelectOption {
46 pub value: String,
48 pub label: String,
50 pub selected: bool,
52 pub disabled: bool,
55 pub group: Option<String>,
57}
58
59#[derive(Debug, Clone, Copy)]
62enum PopupRow<'a> {
63 Option(usize),
64 Group(&'a str),
65}
66
67pub type OnSelectChange = dyn Fn(&str) + Send + Sync;
69
70pub 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 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 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 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 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 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 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 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 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 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 push_text(cmd_buf, arrow_x, text_y, "▾", &box_style, *text_flow_style);
520
521 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
715fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}