1use frust::authoring::text::TextStyle;
64use frust::authoring::{
65 Action, Affine, AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, CursorIcon,
66 ErasedArgCallback, EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, Point,
67 PointerEvent, PointerPhase, Rect, Role, SemanticsCtx, Size, View, Widget, any, build_child,
68 erase_callback_arg, rebuild_children, route_event_single, teardown_child, visit_children,
69};
70use frust::{AnimationController, Theme};
71
72use super::{
73 DROPDOWN_CHIP_H_PADDING, DROPDOWN_CHIP_ICON_SIZE, DROPDOWN_CHIP_LABEL_GAP,
74 DROPDOWN_CHIP_RADIUS, DROPDOWN_CHIP_RUN_SPACING, DROPDOWN_CHIP_SPACING,
75 DROPDOWN_CHIP_V_PADDING, DROPDOWN_FIELD_H_PADDING, DROPDOWN_FIELD_HOVER_ALPHA,
76 DROPDOWN_FIELD_LOADING_SIZE, DROPDOWN_FIELD_LOADING_STROKE, DROPDOWN_FIELD_PRESSED_ALPHA,
77 DROPDOWN_FIELD_V_PADDING, DROPDOWN_FLING_VELOCITY, DROPDOWN_HINT, DROPDOWN_ICON_GAP,
78 DROPDOWN_ICON_SIZE, DROPDOWN_PROGRESS_EPSILON, DROPDOWN_SPRING, DropdownColors, DropdownItem,
79 Glyph, OnOpen, OnSelection, Run, SHAPING_INK, container_radius, ordered_selection,
80};
81use crate::icons;
82use crate::overlay::{OverlayAnchor, with_alpha};
83use crate::press::presses;
84use crate::progress::{ProgressValue, circular_progress};
85
86const UNBOUNDED_WIDTH: f64 = 200.0;
89
90const ARROW_TURN: f64 = std::f64::consts::PI;
93
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
97enum Trailing {
98 Busy,
100 Clear,
103 Arrow,
105}
106
107struct Chip {
110 value: String,
112 label: Run,
113 scale: AnimationController,
115 progress: f64,
117 removing: bool,
120 started: bool,
123 rect: Rect,
125 delete: Rect,
127}
128
129impl Chip {
130 fn new(value: &str, label: &str) -> Self {
132 Chip {
133 value: value.to_string(),
134 label: Run::new(label),
135 scale: AnimationController::new(std::time::Duration::ZERO),
136 progress: 0.0,
137 removing: false,
138 started: false,
139 rect: Rect::ZERO,
140 delete: Rect::ZERO,
141 }
142 }
143
144 fn layout_scale(&self) -> f64 {
147 self.progress.clamp(0.0, 1.0)
148 }
149}
150
151pub struct DropdownFieldView<State: 'static> {
153 items: Vec<DropdownItem>,
154 selected: Vec<String>,
155 hint: String,
156 multi: bool,
157 open: bool,
158 enabled: bool,
159 loading: bool,
160 error: bool,
161 show_clear: bool,
162 max_chips: Option<usize>,
163 anchor: OverlayAnchor,
164 on_open: Option<OnOpen<State>>,
165 on_change: Option<OnSelection<State>>,
166}
167
168pub fn dropdown_field<State: 'static>(items: Vec<DropdownItem>) -> DropdownFieldView<State> {
179 DropdownFieldView {
180 items,
181 selected: Vec::new(),
182 hint: DROPDOWN_HINT.to_string(),
183 multi: false,
184 open: false,
185 enabled: true,
186 loading: false,
187 error: false,
188 show_clear: false,
189 max_chips: None,
190 anchor: OverlayAnchor::new(),
191 on_open: None,
192 on_change: None,
193 }
194}
195
196impl<State: 'static> DropdownFieldView<State> {
197 pub fn anchor(mut self, anchor: &OverlayAnchor) -> Self {
199 self.anchor = anchor.clone();
200 self
201 }
202
203 pub fn selected(mut self, selected: Vec<String>) -> Self {
205 self.selected = selected;
206 self
207 }
208
209 pub fn hint(mut self, hint: impl Into<String>) -> Self {
211 self.hint = hint.into();
212 self
213 }
214
215 pub fn multi(mut self, multi: bool) -> Self {
219 self.multi = multi;
220 self
221 }
222
223 pub fn open(mut self, open: bool) -> Self {
226 self.open = open;
227 self
228 }
229
230 pub fn enabled(mut self, enabled: bool) -> Self {
234 self.enabled = enabled;
235 self
236 }
237
238 pub fn loading(mut self, loading: bool) -> Self {
241 self.loading = loading;
242 self
243 }
244
245 pub fn error(mut self, error: bool) -> Self {
249 self.error = error;
250 self
251 }
252
253 pub fn show_clear(mut self, show_clear: bool) -> Self {
257 self.show_clear = show_clear;
258 self
259 }
260
261 pub fn max_chips(mut self, max: usize) -> Self {
264 self.max_chips = Some(max);
265 self
266 }
267
268 pub fn on_open<F: Fn(&mut State, bool) + 'static>(mut self, on_open: F) -> Self {
271 self.on_open = Some(std::rc::Rc::new(on_open));
272 self
273 }
274
275 pub fn on_change<F: Fn(&mut State, Vec<String>) + 'static>(mut self, on_change: F) -> Self {
278 self.on_change = Some(std::rc::Rc::new(on_change));
279 self
280 }
281
282 fn displayed(&self) -> (Vec<String>, usize) {
285 let ordered = ordered_selection(&self.items, &self.selected);
286 match self.max_chips {
287 Some(max) if ordered.len() > max => {
288 let hidden = ordered.len() - max;
289 (ordered.into_iter().take(max).collect(), hidden)
290 }
291 _ => (ordered, 0),
292 }
293 }
294
295 fn label_of(&self, value: &str) -> String {
297 self.items
298 .iter()
299 .find(|item| item.value() == value)
300 .map_or_else(|| value.to_string(), |item| item.label().to_string())
301 }
302
303 fn busy_views(&self) -> Vec<AnyView<State>> {
306 if !self.loading {
307 return Vec::new();
308 }
309 vec![any(circular_progress(ProgressValue::Indeterminate)
310 .diameter(DROPDOWN_FIELD_LOADING_SIZE)
311 .stroke_width(DROPDOWN_FIELD_LOADING_STROKE))]
312 }
313
314 fn single_label(&self, ordered: &[String]) -> Option<String> {
317 if self.multi || ordered.is_empty() {
318 return None;
319 }
320 Some(self.label_of(&ordered[0]))
321 }
322
323 fn trailing(&self) -> Trailing {
325 if self.loading {
326 Trailing::Busy
327 } else if self.show_clear && self.enabled && !self.selected.is_empty() {
328 Trailing::Clear
329 } else {
330 Trailing::Arrow
331 }
332 }
333}
334
335pub struct DropdownFieldWidget {
337 hint: Run,
338 value_label: Option<Run>,
341 chips: Vec<Chip>,
342 overflow: Option<(Run, usize)>,
344 arrow: Glyph,
345 clear: Glyph,
346 delete: Glyph,
347 trailing: Trailing,
348 busy: Vec<ChildPod>,
350 multi: bool,
351 open: bool,
352 enabled: bool,
353 error: bool,
354 loading: bool,
355 selected: Vec<String>,
357 anchor: OverlayAnchor,
358 on_open: Option<ErasedArgCallback<bool>>,
359 on_change: Option<ErasedArgCallback<Vec<String>>>,
360 arrow_anim: AnimationController,
362 arrow_progress: f64,
363 arrow_started: bool,
364 hovered: bool,
366 pressed: bool,
367 press_inside: bool,
368 captured: bool,
369 armed_delete: Option<usize>,
371 armed_clear: bool,
373 width: f64,
374 height: f64,
375 trailing_rect: Rect,
377 overflow_rect: Rect,
379}
380
381impl DropdownFieldWidget {
382 pub fn width(&self) -> f64 {
384 self.width
385 }
386
387 pub fn height(&self) -> f64 {
390 self.height
391 }
392
393 pub fn arrow_radians(&self) -> f64 {
395 self.arrow_progress * ARROW_TURN
396 }
397
398 pub fn chip_count(&self) -> usize {
401 self.chips.len()
402 }
403
404 fn sync_chips(&mut self, next: &[String], labels: &[String]) -> bool {
413 let mut changed = false;
414 for chip in self.chips.iter_mut() {
415 let present = next.iter().any(|v| v == &chip.value);
416 if present && chip.removing {
417 chip.removing = false;
418 chip.scale.fling(DROPDOWN_FLING_VELOCITY, DROPDOWN_SPRING);
419 changed = true;
420 } else if !present && !chip.removing {
421 chip.removing = true;
422 chip.scale.fling(-DROPDOWN_FLING_VELOCITY, DROPDOWN_SPRING);
423 changed = true;
424 }
425 }
426 let mut fading: Vec<(usize, Chip)> = Vec::new();
427 let mut present: Vec<Chip> = Vec::new();
428 for (i, chip) in std::mem::take(&mut self.chips).into_iter().enumerate() {
429 if chip.removing {
430 fading.push((i, chip));
431 } else {
432 present.push(chip);
433 }
434 }
435 let mut ordered: Vec<Chip> = Vec::with_capacity(next.len() + fading.len());
436 for (value, label) in next.iter().zip(labels.iter()) {
437 match present.iter().position(|chip| &chip.value == value) {
438 Some(i) => ordered.push(present.remove(i)),
439 None => {
440 ordered.push(Chip::new(value, label));
441 changed = true;
442 }
443 }
444 }
445 ordered.append(&mut present);
449 for (index, chip) in fading {
450 let at = index.min(ordered.len());
451 ordered.insert(at, chip);
452 }
453 self.chips = ordered;
454 changed
455 }
456
457 fn advance(&mut self, ctx: &mut PaintCtx, reduce: bool) -> bool {
470 let mut animating = false;
471 let now = ctx.frame_time();
472
473 if !self.arrow_started {
474 self.arrow_started = true;
475 self.arrow_progress = if self.open { 1.0 } else { 0.0 };
476 } else if reduce {
477 if self.arrow_anim.is_animating() {
478 self.arrow_anim.stop();
479 }
480 self.arrow_progress = if self.open { 1.0 } else { 0.0 };
481 } else if self.arrow_anim.is_animating() {
482 if self.arrow_anim.advance(now) {
483 animating = true;
484 }
485 self.arrow_progress = self.arrow_anim.value_clamped();
486 }
487
488 let mut settled: Vec<usize> = Vec::new();
489 let mut chips_moved = false;
490 for (i, chip) in self.chips.iter_mut().enumerate() {
491 let progress_before = chip.progress;
492 if !chip.started {
493 chip.started = true;
494 if reduce {
495 chip.progress = 1.0;
496 } else {
497 chip.scale.fling(DROPDOWN_FLING_VELOCITY, DROPDOWN_SPRING);
498 }
499 }
500 if reduce {
501 if chip.scale.is_animating() {
502 chip.scale.stop();
503 }
504 chip.progress = if chip.removing { 0.0 } else { 1.0 };
505 if chip.progress != progress_before {
512 chips_moved = true;
513 }
514 } else if chip.scale.is_animating() {
515 if chip.scale.advance(now) {
516 animating = true;
517 }
518 chip.progress = chip.scale.value();
519 if chip.progress != progress_before {
528 chips_moved = true;
529 }
530 }
531 if chip.removing && chip.progress <= DROPDOWN_PROGRESS_EPSILON {
532 settled.push(i);
533 }
534 }
535 for i in settled.into_iter().rev() {
536 self.chips.remove(i);
537 chips_moved = true;
538 }
539 if chips_moved {
540 ctx.request_layout();
541 }
542 animating
543 }
544
545 fn has_chip_strip(&self) -> bool {
549 self.multi && (!self.chips.is_empty() || self.overflow.is_some())
550 }
551
552 fn delete_chip(&mut self, ctx: &mut EventCtx, value: &str) {
554 let Some(on_change) = self.on_change.as_mut() else {
555 return;
556 };
557 let next: Vec<String> = self
558 .selected
559 .iter()
560 .filter(|v| v.as_str() != value)
561 .cloned()
562 .collect();
563 on_change(ctx, next);
564 }
565
566 fn delete_at(&self, pos: Point) -> Option<usize> {
568 if !self.enabled {
569 return None;
570 }
571 self.chips
572 .iter()
573 .position(|chip| !chip.removing && chip.delete.contains(pos))
574 }
575
576 fn handle_pointer(&mut self, ctx: &mut EventCtx, p: &PointerEvent) -> EventResult {
578 let inside =
579 |pos: Point| pos.x >= 0.0 && pos.x < self.width && pos.y >= 0.0 && pos.y < self.height;
580 match p.phase {
581 PointerPhase::Move if !self.captured => {
582 let over = inside(p.position);
583 if over {
584 ctx.claim_hover();
585 if self.enabled {
586 ctx.set_cursor(CursorIcon::Pointer);
587 }
588 }
589 if self.hovered != over {
590 self.hovered = over;
591 ctx.request_redraw();
592 }
593 EventResult::Ignored
594 }
595 PointerPhase::Move => {
596 let still = inside(p.position);
597 if self.press_inside != still {
598 self.press_inside = still;
599 ctx.request_redraw();
600 }
601 EventResult::Handled
602 }
603 PointerPhase::Down => {
604 if !presses(p) || !inside(p.position) {
605 return EventResult::Ignored;
606 }
607 self.armed_delete = self.delete_at(p.position);
610 self.armed_clear = self.armed_delete.is_none()
611 && self.trailing == Trailing::Clear
612 && self.trailing_rect.contains(p.position);
613 self.pressed = true;
614 self.press_inside = true;
615 self.captured = true;
616 ctx.capture_pointer();
617 ctx.request_redraw();
618 EventResult::Handled
619 }
620 PointerPhase::Up => {
621 if !self.captured {
622 return EventResult::Ignored;
623 }
624 let released_inside = inside(p.position);
625 let armed_delete = self.armed_delete;
626 let armed_clear = self.armed_clear;
627 self.pressed = false;
628 self.press_inside = false;
629 self.captured = false;
630 self.armed_delete = None;
631 self.armed_clear = false;
632 ctx.request_redraw();
633 if !released_inside {
634 return EventResult::Handled;
635 }
636 if let Some(i) = armed_delete {
637 if self.delete_at(p.position) == Some(i) {
638 let value = self.chips[i].value.clone();
639 self.delete_chip(ctx, &value);
640 }
641 return EventResult::Handled;
642 }
643 if armed_clear {
644 if self.trailing_rect.contains(p.position)
645 && let Some(on_change) = self.on_change.as_mut()
646 {
647 on_change(ctx, Vec::new());
648 }
649 return EventResult::Handled;
650 }
651 if self.enabled
653 && !self.loading
654 && let Some(on_open) = self.on_open.as_mut()
655 {
656 let open = self.open;
657 on_open(ctx, !open);
658 }
659 EventResult::Handled
660 }
661 PointerPhase::Cancel => {
664 if !self.captured {
665 return EventResult::Ignored;
666 }
667 self.pressed = false;
668 self.press_inside = false;
669 self.captured = false;
670 self.armed_delete = None;
671 self.armed_clear = false;
672 ctx.request_redraw();
673 EventResult::Handled
674 }
675 }
676 }
677}
678
679impl<State: 'static> View<State> for DropdownFieldView<State> {
680 type Element = DropdownFieldWidget;
681
682 fn build(&self, ctx: &mut BuildCtx<'_>) -> DropdownFieldWidget {
683 let (displayed, hidden) = self.displayed();
684 let labels: Vec<String> = displayed.iter().map(|v| self.label_of(v)).collect();
685 let chips = if self.multi {
686 displayed
687 .iter()
688 .zip(labels.iter())
689 .map(|(value, label)| Chip::new(value, label))
690 .collect()
691 } else {
692 Vec::new()
693 };
694 let ordered = ordered_selection(&self.items, &self.selected);
695 DropdownFieldWidget {
696 hint: Run::new(self.hint.clone()),
697 value_label: self.single_label(&ordered).map(Run::new),
698 chips,
699 overflow: (self.multi && hidden > 0).then(|| (Run::new(format!("+{hidden}")), hidden)),
700 arrow: Glyph::new(icons::KEYBOARD_ARROW_DOWN, DROPDOWN_ICON_SIZE),
701 clear: Glyph::new(icons::CLOSE, DROPDOWN_ICON_SIZE),
704 delete: Glyph::new(icons::CLOSE, DROPDOWN_CHIP_ICON_SIZE),
705 trailing: self.trailing(),
706 busy: self
707 .busy_views()
708 .iter()
709 .map(|view| build_child(view, ctx))
710 .collect(),
711 multi: self.multi,
712 open: self.open,
713 enabled: self.enabled,
714 error: self.error,
715 loading: self.loading,
716 selected: ordered,
717 anchor: self.anchor.clone(),
718 on_open: self.on_open.as_ref().map(erase_callback_arg),
719 on_change: self.on_change.as_ref().map(erase_callback_arg),
720 arrow_anim: AnimationController::new(std::time::Duration::ZERO),
721 arrow_progress: 0.0,
722 arrow_started: false,
723 hovered: false,
724 pressed: false,
725 press_inside: false,
726 captured: false,
727 armed_delete: None,
728 armed_clear: false,
729 width: 0.0,
730 height: 0.0,
731 trailing_rect: Rect::ZERO,
732 overflow_rect: Rect::ZERO,
733 }
734 }
735
736 fn rebuild(
737 &self,
738 prev: &Self,
739 element: &mut DropdownFieldWidget,
740 ctx: &mut BuildCtx<'_>,
741 ) -> ChangeFlags {
742 let mut flags = rebuild_children(
743 &prev.busy_views(),
744 &self.busy_views(),
745 &mut element.busy,
746 ctx,
747 |view: &AnyView<State>| view,
748 |_| None,
749 );
750 if prev.hint != self.hint {
751 element.hint = Run::new(self.hint.clone());
752 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
753 }
754 let ordered = ordered_selection(&self.items, &self.selected);
755 let (displayed, hidden) = self.displayed();
756 let labels: Vec<String> = displayed.iter().map(|v| self.label_of(v)).collect();
757 if element.selected != ordered || prev.items != self.items || element.multi != self.multi {
758 element.selected = ordered.clone();
759 element.multi = self.multi;
760 element.value_label = self.single_label(&ordered).map(Run::new);
761 if self.multi {
762 if element.sync_chips(&displayed, &labels) {
763 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
764 }
765 } else if !element.chips.is_empty() {
766 element.chips.clear();
767 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
768 }
769 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
770 }
771 let overflow = (self.multi && hidden > 0).then_some(hidden);
772 if element.overflow.as_ref().map(|(_, n)| *n) != overflow {
773 element.overflow = overflow.map(|n| (Run::new(format!("+{n}")), n));
774 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
775 }
776 if element.open != self.open {
777 element.open = self.open;
778 element.arrow_anim.fling(
779 if self.open {
780 DROPDOWN_FLING_VELOCITY
781 } else {
782 -DROPDOWN_FLING_VELOCITY
783 },
784 DROPDOWN_SPRING,
785 );
786 flags |= ChangeFlags::PAINT;
787 }
788 let trailing = self.trailing();
789 if element.trailing != trailing {
790 element.trailing = trailing;
791 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
792 }
793 if element.enabled != self.enabled
794 || element.error != self.error
795 || element.loading != self.loading
796 {
797 element.enabled = self.enabled;
798 element.error = self.error;
799 element.loading = self.loading;
800 flags |= ChangeFlags::PAINT;
801 }
802 element.anchor = self.anchor.clone();
803 element.on_open = self.on_open.as_ref().map(erase_callback_arg);
806 element.on_change = self.on_change.as_ref().map(erase_callback_arg);
807 flags
808 }
809
810 fn teardown(&self, element: &mut DropdownFieldWidget, ctx: &mut BuildCtx<'_>) {
811 for (view, pod) in self.busy_views().iter().zip(element.busy.iter_mut()) {
812 teardown_child(view, pod, ctx);
813 }
814 }
815}
816
817struct Styles {
821 body: TextStyle,
822 chip: TextStyle,
823}
824
825impl Styles {
826 fn resolve(theme: Option<&Theme>) -> Self {
829 let (mut body, mut chip) = match theme {
830 Some(theme) => (
831 theme.type_scale.body_large.clone(),
832 theme.type_scale.label_medium.clone(),
833 ),
834 None => {
835 let body = TextStyle::new(16.0, SHAPING_INK);
836 let mut chip = TextStyle::new(12.0, SHAPING_INK);
837 chip.letter_spacing = 0.5;
838 (body, chip)
839 }
840 };
841 body.color = SHAPING_INK;
842 chip.color = SHAPING_INK;
843 Styles { body, chip }
844 }
845}
846
847impl Widget for DropdownFieldWidget {
848 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
849 let styles = Styles::resolve(Theme::from_layout_ctx(ctx));
850 self.width = if bc.max().width.is_finite() {
851 bc.max().width
852 } else {
853 UNBOUNDED_WIDTH
854 };
855
856 let slot = DROPDOWN_ICON_GAP + DROPDOWN_ICON_SIZE;
859 let content_width = (self.width - 2.0 * DROPDOWN_FIELD_H_PADDING - slot).max(0.0);
860
861 let content_height = if self.has_chip_strip() {
862 self.layout_chips(ctx, &styles, content_width)
863 } else if let Some(label) = self.value_label.as_mut() {
864 label.shape(ctx, &styles.body, content_width).height
865 } else {
866 self.hint.shape(ctx, &styles.body, content_width).height
867 };
868
869 self.height = 2.0 * DROPDOWN_FIELD_V_PADDING + content_height.max(DROPDOWN_ICON_SIZE);
870 self.trailing_rect = Rect::from_origin_size(
871 Point::new(
872 self.width - DROPDOWN_FIELD_H_PADDING - DROPDOWN_ICON_SIZE,
873 (self.height - DROPDOWN_ICON_SIZE) / 2.0,
874 ),
875 Size::new(DROPDOWN_ICON_SIZE, DROPDOWN_ICON_SIZE),
876 );
877
878 if let Some(pod) = self.busy.first_mut() {
879 let extent = DROPDOWN_FIELD_LOADING_SIZE;
880 pod.layout_child(ctx, &BoxConstraints::tight(Size::new(extent, extent)));
881 pod.set_origin(Point::new(
882 self.width - DROPDOWN_FIELD_H_PADDING - extent,
883 (self.height - extent) / 2.0,
884 ));
885 }
886
887 bc.constrain(Size::new(self.width, self.height))
888 }
889
890 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
891 if !ctx.is_hovered() {
893 self.hovered = false;
894 }
895 let theme = Theme::from_paint_ctx(ctx);
896 let colors = DropdownColors::resolve(theme);
897 let radius = container_radius(theme);
898 let reduce = crate::overlay::reduce_motion(theme);
899 if self.advance(ctx, reduce) {
900 ctx.request_frame();
901 }
902
903 let origin = ctx.origin();
907 self.anchor.set(Rect::from_origin_size(origin, ctx.size()));
908
909 let size = Size::new(self.width, self.height);
910 scene.fill_rounded_rect(origin, size, radius, colors.field_container);
911 let ink = if self.error {
912 colors.error
913 } else {
914 colors.field_content
915 };
916 let overlay = if self.pressed && self.press_inside {
920 DROPDOWN_FIELD_PRESSED_ALPHA
921 } else if self.hovered {
922 DROPDOWN_FIELD_HOVER_ALPHA
923 } else {
924 0.0
925 };
926 if overlay > 0.0 && self.enabled {
927 scene.fill_rounded_rect(origin, size, radius, with_alpha(ink, overlay));
928 }
929
930 let content_x = origin.x + DROPDOWN_FIELD_H_PADDING;
931 if self.has_chip_strip() {
932 self.paint_chips(origin, &colors, scene);
933 } else if let Some(label) = &self.value_label {
934 let height = label.size().height;
935 label.paint(
936 Point::new(content_x, origin.y + (self.height - height) / 2.0),
937 ink,
938 scene,
939 );
940 } else {
941 let height = self.hint.size().height;
942 self.hint.paint(
943 Point::new(content_x, origin.y + (self.height - height) / 2.0),
944 colors.hint,
945 scene,
946 );
947 }
948
949 match self.trailing {
950 Trailing::Busy => {
951 if let Some(pod) = self.busy.first_mut() {
952 pod.paint_child(ctx, scene);
953 }
954 }
955 Trailing::Clear => {
956 self.clear
957 .paint(self.trailing_rect.origin() + origin.to_vec2(), ink, scene)
958 }
959 Trailing::Arrow => self.arrow.paint_rotated(
960 self.trailing_rect.origin() + origin.to_vec2(),
961 ink,
962 self.arrow_radians(),
963 scene,
964 ),
965 }
966 }
967
968 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
969 if event.is_broadcast() {
970 for pod in self.busy.iter_mut() {
971 pod.event_child(ctx, event);
972 }
973 return EventResult::Ignored;
974 }
975 if let Some(pod) = self.busy.first_mut()
978 && pod.is_active()
979 {
980 return route_event_single(pod, ctx, event);
981 }
982 match event {
983 InputEvent::Pointer(p) => self.handle_pointer(ctx, p),
984 _ => EventResult::Ignored,
985 }
986 }
987
988 fn semantics(&self, ctx: &mut SemanticsCtx) {
989 let label = if let Some(value) = &self.value_label {
990 value.content.clone()
991 } else {
992 self.hint.content.clone()
993 };
994 let open = self.open;
995 let enabled = self.enabled;
996 ctx.push_node(Role::ComboBox, |node| {
997 node.set_label(label.as_str());
998 node.set_expanded(open);
999 if enabled {
1000 node.add_action(Action::Click);
1001 }
1002 });
1003 }
1004
1005 visit_children!(busy);
1006}
1007
1008impl DropdownFieldWidget {
1009 fn layout_chips(&mut self, ctx: &mut LayoutCtx, styles: &Styles, content_width: f64) -> f64 {
1012 let chip_width = |label: Size, deletable: bool| {
1013 let mut width = 2.0 * DROPDOWN_CHIP_H_PADDING + label.width;
1014 if deletable {
1015 width += DROPDOWN_CHIP_LABEL_GAP + DROPDOWN_CHIP_ICON_SIZE;
1016 }
1017 width
1018 };
1019 let deletable = self.enabled;
1020 let mut sizes: Vec<Size> = Vec::with_capacity(self.chips.len());
1023 for chip in self.chips.iter_mut() {
1024 let natural = chip.label.natural_width(ctx, &styles.chip);
1025 sizes.push(chip.label.shape(ctx, &styles.chip, natural));
1026 }
1027 let overflow_size = self
1028 .overflow
1029 .as_mut()
1030 .map(|(run, _)| {
1031 let natural = run.natural_width(ctx, &styles.chip);
1032 run.shape(ctx, &styles.chip, natural)
1033 })
1034 .unwrap_or(Size::ZERO);
1035
1036 let row_height = sizes
1037 .iter()
1038 .map(|size| size.height)
1039 .fold(overflow_size.height, f64::max)
1040 .max(if deletable {
1041 DROPDOWN_CHIP_ICON_SIZE
1042 } else {
1043 0.0
1044 })
1045 + 2.0 * DROPDOWN_CHIP_V_PADDING;
1046
1047 let mut x = 0.0;
1048 let mut y = 0.0;
1049 for (i, chip) in self.chips.iter_mut().enumerate() {
1050 let width = chip_width(sizes[i], deletable) * chip.layout_scale();
1051 if x > 0.0 && x + width > content_width {
1052 x = 0.0;
1053 y += row_height + DROPDOWN_CHIP_RUN_SPACING;
1054 }
1055 chip.rect = Rect::from_origin_size(
1056 Point::new(DROPDOWN_FIELD_H_PADDING + x, DROPDOWN_FIELD_V_PADDING + y),
1057 Size::new(width, row_height),
1058 );
1059 chip.delete = if deletable && width > 0.0 {
1060 Rect::from_origin_size(
1061 Point::new(
1062 chip.rect.x1 - DROPDOWN_CHIP_H_PADDING - DROPDOWN_CHIP_ICON_SIZE,
1063 chip.rect.y0 + (row_height - DROPDOWN_CHIP_ICON_SIZE) / 2.0,
1064 ),
1065 Size::new(DROPDOWN_CHIP_ICON_SIZE, DROPDOWN_CHIP_ICON_SIZE),
1066 )
1067 } else {
1068 Rect::ZERO
1069 };
1070 x += width + DROPDOWN_CHIP_SPACING;
1071 }
1072 if self.overflow.is_some() {
1073 let width = 2.0 * DROPDOWN_CHIP_H_PADDING + overflow_size.width;
1074 if x > 0.0 && x + width > content_width {
1075 x = 0.0;
1076 y += row_height + DROPDOWN_CHIP_RUN_SPACING;
1077 }
1078 self.overflow_rect = Rect::from_origin_size(
1079 Point::new(DROPDOWN_FIELD_H_PADDING + x, DROPDOWN_FIELD_V_PADDING + y),
1080 Size::new(width, row_height),
1081 );
1082 } else {
1083 self.overflow_rect = Rect::ZERO;
1084 }
1085 y + row_height
1086 }
1087
1088 fn paint_chips(&self, origin: Point, colors: &DropdownColors, scene: &mut dyn PaintScene) {
1091 for chip in self.chips.iter() {
1092 if chip.progress <= DROPDOWN_PROGRESS_EPSILON {
1093 continue;
1094 }
1095 let rect = chip.rect + origin.to_vec2();
1096 scene.fill_rounded_rect(
1097 rect.origin(),
1098 rect.size(),
1099 DROPDOWN_CHIP_RADIUS,
1100 colors.chip_container,
1101 );
1102 let pivot = kurbo::Vec2::new(rect.x0, rect.y0 + rect.height() / 2.0);
1105 let scale = chip.progress.clamp(0.0, 1.0);
1106 let transform = Affine::translate(pivot)
1107 * Affine::scale_non_uniform(scale, 1.0)
1108 * Affine::translate(-pivot);
1109 let label_size = chip.label.size();
1110 chip.label.paint_transformed(
1111 Point::new(
1112 rect.x0 + DROPDOWN_CHIP_H_PADDING,
1113 rect.y0 + (rect.height() - label_size.height) / 2.0,
1114 ),
1115 colors.chip_content,
1116 transform,
1117 scene,
1118 );
1119 if chip.delete.width() > 0.0 {
1120 self.delete.paint(
1121 chip.delete.origin() + origin.to_vec2(),
1122 colors.chip_content,
1123 scene,
1124 );
1125 }
1126 }
1127 if let Some((run, _)) = &self.overflow {
1128 let size = run.size();
1129 let rect = self.overflow_rect + origin.to_vec2();
1130 scene.fill_rounded_rect(
1131 rect.origin(),
1132 rect.size(),
1133 DROPDOWN_CHIP_RADIUS,
1134 colors.chip_container,
1135 );
1136 run.paint(
1137 Point::new(
1138 rect.x0 + DROPDOWN_CHIP_H_PADDING,
1139 rect.y0 + (rect.height() - size.height) / 2.0,
1140 ),
1141 colors.chip_content,
1142 scene,
1143 );
1144 }
1145 }
1146}
1147
1148#[cfg(test)]
1149mod tests {
1150 use super::*;
1151 use crate::dropdown::dropdown_item;
1152 use frust::authoring::scene::GlyphRun;
1153 use frust::authoring::{BezPath, Brush, Color, PointerButton, text::TextContext};
1154 use frust::{Brightness, FrameTime};
1155 use std::any::Any;
1156
1157 const AREA: Size = Size::new(320.0, 200.0);
1158
1159 #[derive(Default)]
1160 struct AppState {
1161 opens: Vec<bool>,
1162 selections: Vec<Vec<String>>,
1163 }
1164
1165 #[derive(Default)]
1166 struct Recorder {
1167 rrects: Vec<(Point, Size, f64, Color)>,
1168 paths: Vec<Point>,
1169 runs: usize,
1170 }
1171
1172 impl PaintScene for Recorder {
1173 fn fill_rect(&mut self, _origin: Point, _size: Size, _color: Color) {}
1174 fn fill_rounded_rect(&mut self, origin: Point, size: Size, radius: f64, color: Color) {
1175 self.rrects.push((origin, size, radius, color));
1176 }
1177 fn fill_path(&mut self, origin: Point, _path: &BezPath, _brush: &Brush) {
1178 self.paths.push(origin);
1179 }
1180 fn draw_text(&mut self, _origin: Point, _text: &str) {}
1181 fn draw_glyph_run(&mut self, _run: GlyphRun) {
1182 self.runs += 1;
1183 }
1184 }
1185
1186 impl Recorder {
1187 fn washes(&self) -> Vec<f32> {
1190 self.rrects
1191 .iter()
1192 .map(|(_, _, _, color)| color.components[3])
1193 .filter(|alpha| *alpha < 1.0)
1194 .collect()
1195 }
1196 }
1197
1198 fn fruit() -> Vec<DropdownItem> {
1199 vec![
1200 dropdown_item("Apple", "apple"),
1201 dropdown_item("Banana", "banana"),
1202 dropdown_item("Cherry", "cherry"),
1203 ]
1204 }
1205
1206 fn view() -> DropdownFieldView<AppState> {
1207 dropdown_field(fruit())
1208 .on_open(|state: &mut AppState, open| state.opens.push(open))
1209 .on_change(|state: &mut AppState, values| state.selections.push(values))
1210 }
1211
1212 fn build(view: &DropdownFieldView<AppState>) -> DropdownFieldWidget {
1213 let mut counter = 0u64;
1214 View::<AppState>::build(view, &mut BuildCtx::new(&mut counter))
1215 }
1216
1217 fn layout(w: &mut DropdownFieldWidget, area: Size) -> Size {
1218 let mut tcx = TextContext::new();
1219 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1220 w.layout(&mut lctx, &BoxConstraints::loose(area))
1221 }
1222
1223 fn ready(view: &DropdownFieldView<AppState>) -> DropdownFieldWidget {
1224 let mut w = build(view);
1225 layout(&mut w, AREA);
1226 w
1227 }
1228
1229 fn rebuild(
1230 w: &mut DropdownFieldWidget,
1231 prev: &DropdownFieldView<AppState>,
1232 next: &DropdownFieldView<AppState>,
1233 ) {
1234 let mut counter = 0u64;
1235 View::<AppState>::rebuild(next, prev, w, &mut BuildCtx::new(&mut counter));
1236 }
1237
1238 fn paint_at(w: &mut DropdownFieldWidget, now: FrameTime) -> Recorder {
1239 let theme = crate::baseline().with_brightness(Brightness::Light);
1240 let mut rec = Recorder::default();
1241 let size = Size::new(w.width, w.height);
1242 let mut ctx = PaintCtx::for_test(Point::ORIGIN, size, now).with_theme(&theme as &dyn Any);
1243 w.paint(&mut ctx, &mut rec);
1244 rec
1245 }
1246
1247 fn paint(w: &mut DropdownFieldWidget) -> Recorder {
1248 paint_at(w, FrameTime::ZERO)
1249 }
1250
1251 fn settle(w: &mut DropdownFieldWidget) {
1253 for step in 1..200 {
1254 paint_at(w, FrameTime::from_nanos(step * 16_000_000));
1255 }
1256 }
1257
1258 fn pointer(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1259 InputEvent::Pointer(PointerEvent {
1260 phase,
1261 position: Point::new(x, y),
1262 button: PointerButton::Primary,
1263 })
1264 }
1265
1266 fn dispatch(
1267 w: &mut DropdownFieldWidget,
1268 state: &mut AppState,
1269 event: &InputEvent,
1270 ) -> EventResult {
1271 let any: &mut dyn Any = state;
1272 let mut ctx = EventCtx::new(any, Point::ZERO, Size::new(w.width, w.height));
1273 w.event(&mut ctx, event)
1274 }
1275
1276 fn click(w: &mut DropdownFieldWidget, state: &mut AppState, at: Point) {
1277 dispatch(w, state, &pointer(PointerPhase::Down, at.x, at.y));
1278 dispatch(w, state, &pointer(PointerPhase::Up, at.x, at.y));
1279 }
1280
1281 #[test]
1284 fn the_field_captures_its_own_rect_into_the_anchor_on_paint() {
1285 let anchor = OverlayAnchor::new();
1286 let mut w = ready(&view().anchor(&anchor));
1287 assert_eq!(anchor.rect(), Rect::ZERO, "nothing captured before paint");
1288 paint(&mut w);
1289 assert_eq!(
1290 anchor.rect(),
1291 Rect::from_origin_size(Point::ORIGIN, Size::new(w.width(), w.height()))
1292 );
1293 }
1294
1295 #[test]
1298 fn an_empty_selection_shows_the_hint() {
1299 let mut w = ready(&view());
1300 assert!(w.value_label.is_none());
1301 let rec = paint(&mut w);
1302 assert!(rec.runs > 0);
1303 }
1304
1305 #[test]
1306 fn a_single_selection_shows_its_label_not_its_value() {
1307 let w = ready(&view().selected(vec!["banana".to_string()]));
1308 assert_eq!(
1309 w.value_label.as_ref().map(|run| run.content.as_str()),
1310 Some("Banana")
1311 );
1312 }
1313
1314 #[test]
1315 fn a_value_naming_no_item_is_dropped_rather_than_shown_raw() {
1316 let w = ready(&view().selected(vec!["durian".to_string()]));
1317 assert!(
1318 w.value_label.is_none(),
1319 "an unknown value normalizes away, leaving the hint"
1320 );
1321 }
1322
1323 #[test]
1326 fn the_trailing_slot_follows_the_reference_s_precedence() {
1327 assert_eq!(ready(&view()).trailing, Trailing::Arrow);
1328 assert_eq!(
1329 ready(&view().show_clear(true).selected(vec!["apple".to_string()])).trailing,
1330 Trailing::Clear
1331 );
1332 assert_eq!(
1333 ready(
1334 &view()
1335 .show_clear(true)
1336 .selected(vec!["apple".to_string()])
1337 .loading(true)
1338 )
1339 .trailing,
1340 Trailing::Busy,
1341 "loading outranks the clear affordance"
1342 );
1343 assert_eq!(
1344 ready(&view().show_clear(true)).trailing,
1345 Trailing::Arrow,
1346 "nothing selected, nothing to clear"
1347 );
1348 assert_eq!(
1349 ready(
1350 &view()
1351 .show_clear(true)
1352 .enabled(false)
1353 .selected(vec!["apple".to_string()])
1354 )
1355 .trailing,
1356 Trailing::Arrow,
1357 "a disabled field offers no clear affordance"
1358 );
1359 }
1360
1361 #[test]
1362 fn a_busy_field_mounts_the_indicator_pod() {
1363 let w = ready(&view().loading(true));
1364 assert_eq!(w.busy.len(), 1);
1365 let plain = ready(&view());
1366 assert_eq!(plain.busy.len(), 0);
1367 }
1368
1369 #[test]
1372 fn the_arrow_rests_at_zero_closed_and_a_half_turn_open() {
1373 let mut closed = ready(&view());
1374 paint(&mut closed);
1375 assert_eq!(closed.arrow_radians(), 0.0);
1376
1377 let mut open = ready(&view().open(true));
1378 paint(&mut open);
1379 assert!((open.arrow_radians() - ARROW_TURN).abs() < 1e-9);
1380 }
1381
1382 #[test]
1383 fn opening_springs_the_arrow_round_and_closing_springs_it_back() {
1384 let prev = view();
1385 let mut w = ready(&prev);
1386 paint(&mut w);
1387 assert_eq!(w.arrow_radians(), 0.0);
1388
1389 let next = view().open(true);
1390 rebuild(&mut w, &prev, &next);
1391 settle(&mut w);
1392 assert!(
1393 (w.arrow_radians() - ARROW_TURN).abs() < 1e-3,
1394 "the arrow settled at a half turn, got {}",
1395 w.arrow_radians()
1396 );
1397
1398 rebuild(&mut w, &next, &prev);
1399 settle(&mut w);
1400 assert!(w.arrow_radians().abs() < 1e-3);
1401 }
1402
1403 #[test]
1406 fn a_press_requests_the_opposite_open_state_and_never_flips_its_own() {
1407 let mut w = ready(&view());
1408 let mut state = AppState::default();
1409 let at = Point::new(40.0, w.height() / 2.0);
1410 click(&mut w, &mut state, at);
1411 assert_eq!(state.opens, vec![true]);
1412 assert!(!w.open, "controlled: the field waits to be told");
1413 }
1414
1415 #[test]
1416 fn an_open_field_requests_a_close() {
1417 let mut w = ready(&view().open(true));
1418 let mut state = AppState::default();
1419 let at = Point::new(40.0, w.height() / 2.0);
1420 click(&mut w, &mut state, at);
1421 assert_eq!(state.opens, vec![false]);
1422 }
1423
1424 #[test]
1425 fn a_disabled_or_loading_field_opens_nothing() {
1426 for v in [view().enabled(false), view().loading(true)] {
1427 let mut w = ready(&v);
1428 let mut state = AppState::default();
1429 let at = Point::new(40.0, w.height() / 2.0);
1430 click(&mut w, &mut state, at);
1431 assert!(state.opens.is_empty());
1432 }
1433 }
1434
1435 #[test]
1436 fn a_release_outside_the_field_reports_nothing() {
1437 let mut w = ready(&view());
1438 let mut state = AppState::default();
1439 let below = w.height() + 40.0;
1440 dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, 40.0, 10.0));
1441 dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, 40.0, below));
1442 assert!(state.opens.is_empty());
1443 }
1444
1445 #[test]
1446 fn only_a_primary_press_arms_the_field() {
1447 let mut w = ready(&view());
1448 let mut state = AppState::default();
1449 let secondary = InputEvent::Pointer(PointerEvent {
1450 phase: PointerPhase::Down,
1451 position: Point::new(40.0, w.height() / 2.0),
1452 button: PointerButton::Secondary,
1453 });
1454 assert_eq!(
1455 dispatch(&mut w, &mut state, &secondary),
1456 EventResult::Ignored
1457 );
1458 }
1459
1460 #[test]
1461 fn a_cancel_clears_the_press_without_reporting() {
1462 let mut w = ready(&view());
1463 let mut state = AppState::default();
1464 let at = Point::new(40.0, w.height() / 2.0);
1465 dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, at.x, at.y));
1466 dispatch(
1467 &mut w,
1468 &mut state,
1469 &pointer(PointerPhase::Cancel, at.x, at.y),
1470 );
1471 assert!(state.opens.is_empty());
1472 assert!(!w.pressed);
1473 }
1474
1475 #[test]
1476 fn the_clear_affordance_requests_an_empty_selection_instead_of_opening() {
1477 let mut w = ready(
1478 &view()
1479 .show_clear(true)
1480 .selected(vec!["apple".to_string(), "banana".to_string()]),
1481 );
1482 let mut state = AppState::default();
1483 let at = w.trailing_rect.center();
1484 click(&mut w, &mut state, at);
1485 assert_eq!(state.selections, vec![Vec::<String>::new()]);
1486 assert!(state.opens.is_empty(), "the clear press never toggles");
1487 }
1488
1489 #[test]
1492 fn a_multi_select_renders_one_chip_per_selected_option() {
1493 let w = ready(
1494 &view()
1495 .multi(true)
1496 .selected(vec!["apple".to_string(), "cherry".to_string()]),
1497 );
1498 assert_eq!(w.chip_count(), 2);
1499 assert_eq!(w.chips[0].label.content, "Apple");
1500 assert_eq!(w.chips[1].label.content, "Cherry");
1501 }
1502
1503 #[test]
1504 fn a_single_select_never_renders_a_chip() {
1505 let w = ready(&view().selected(vec!["apple".to_string()]));
1506 assert_eq!(w.chip_count(), 0);
1507 }
1508
1509 fn long_items() -> Vec<DropdownItem> {
1512 vec![
1513 dropdown_item("Passionfruit sorbet", "a"),
1514 dropdown_item("Blackcurrant cordial", "b"),
1515 dropdown_item("Clementine marmalade", "c"),
1516 ]
1517 }
1518
1519 #[test]
1520 fn chips_grow_the_field_past_the_hint_only_row() {
1521 let plain = ready(&view());
1522 let field = |selected: Vec<String>| {
1523 dropdown_field::<AppState>(long_items())
1524 .multi(true)
1525 .selected(selected)
1526 };
1527 let mut one = ready(&field(vec!["a".to_string()]));
1528 settle(&mut one);
1529 layout(&mut one, AREA);
1530 let mut many = ready(&field(vec![
1531 "a".to_string(),
1532 "b".to_string(),
1533 "c".to_string(),
1534 ]));
1535 settle(&mut many);
1536 layout(&mut many, AREA);
1537 assert!(one.height() >= plain.height() - 1e-6);
1538 assert!(
1539 many.height() > one.height(),
1540 "three long chips wrap past one row in a {}px field ({} vs {})",
1541 AREA.width,
1542 many.height(),
1543 one.height()
1544 );
1545 }
1546
1547 #[test]
1548 fn a_chip_springs_in_from_nothing() {
1549 let mut w = ready(&view().multi(true).selected(vec!["apple".to_string()]));
1550 paint(&mut w);
1551 assert!(w.chips[0].progress < 1.0, "the entrance starts from zero");
1552 settle(&mut w);
1553 assert!((w.chips[0].progress - 1.0).abs() < 1e-2);
1554 }
1555
1556 #[test]
1557 fn a_freshly_added_chip_lands_its_box_exactly_on_the_settle_frame() {
1558 let prev = view().multi(true);
1571 let mut w = ready(&prev);
1572 let next = view().multi(true).selected(vec!["apple".to_string()]);
1573 rebuild(&mut w, &prev, &next);
1574 assert_eq!(w.chip_count(), 1);
1575
1576 let theme = crate::baseline().with_brightness(Brightness::Light);
1577 let mut needs_layout = true;
1586 let mut needs_frame = true;
1587 let mut t = 0u64;
1588 let mut frames = 0u32;
1589 let mut last_width = w.chips[0].rect.width();
1590 loop {
1591 if !needs_frame {
1592 break;
1593 }
1594 if needs_layout {
1595 layout(&mut w, AREA);
1596 last_width = w.chips[0].rect.width();
1597 }
1598 let mut rec = Recorder::default();
1599 let size = Size::new(w.width, w.height);
1600 let mut ctx = PaintCtx::for_test(Point::ORIGIN, size, FrameTime::from_nanos(t))
1601 .with_theme(&theme as &dyn Any);
1602 w.paint(&mut ctx, &mut rec);
1603 needs_layout = ctx.needs_layout();
1604 needs_frame = ctx.needs_frame();
1605 frames += 1;
1606 assert!(
1607 frames < 600,
1608 "the chip entrance should settle well inside 600 frames"
1609 );
1610 t += 16_000_000;
1611 }
1612 assert!(frames > 1, "the chip entrance spans more than one frame");
1613
1614 settle(&mut w);
1617 layout(&mut w, AREA);
1618 let target_width = w.chips[0].rect.width();
1619
1620 assert_eq!(
1621 last_width, target_width,
1622 "the last layout a shell-honest driver runs lands exactly on the chip's settled box width"
1623 );
1624 }
1625
1626 #[test]
1627 fn reduce_motion_gives_a_freshly_added_chip_a_real_box_after_relayout() {
1628 let mut theme = crate::baseline().with_brightness(Brightness::Light);
1636 theme.motion.reduce_motion = true;
1637
1638 let prev = view().multi(true);
1639 let mut w = ready(&prev);
1640 let next = view().multi(true).selected(vec!["apple".to_string()]);
1641 rebuild(&mut w, &prev, &next);
1642 assert_eq!(w.chip_count(), 1);
1643 assert_eq!(w.chips[0].rect, Rect::ZERO, "not yet laid out");
1644
1645 let mut rec = Recorder::default();
1646 let size = Size::new(w.width, w.height);
1647 let mut ctx =
1648 PaintCtx::for_test(Point::ORIGIN, size, FrameTime::ZERO).with_theme(&theme as &dyn Any);
1649 w.paint(&mut ctx, &mut rec);
1650 assert_eq!(w.chips[0].progress, 1.0, "snapped straight to visible");
1651 assert!(
1652 ctx.needs_layout(),
1653 "the snap changed a layout-relevant value, so it must request a relayout"
1654 );
1655
1656 layout(&mut w, AREA);
1658 assert!(
1659 w.chips[0].rect.width() > 0.0,
1660 "the chip's box reflects the snapped-in progress after the requested relayout"
1661 );
1662 assert_ne!(
1663 w.chips[0].delete,
1664 Rect::ZERO,
1665 "the delete glyph gets a real hit box, not an empty one"
1666 );
1667 }
1668
1669 #[test]
1670 fn a_removed_chip_outlives_the_rebuild_that_dropped_it_and_then_settles_away() {
1671 let prev = view()
1672 .multi(true)
1673 .selected(vec!["apple".to_string(), "banana".to_string()]);
1674 let mut w = ready(&prev);
1675 settle(&mut w);
1676 assert_eq!(w.chip_count(), 2);
1677
1678 let next = view().multi(true).selected(vec!["banana".to_string()]);
1679 rebuild(&mut w, &prev, &next);
1680 assert_eq!(
1681 w.chip_count(),
1682 2,
1683 "the removed chip is retained for its exit ramp"
1684 );
1685 assert!(w.chips[0].removing);
1686 settle(&mut w);
1687 assert_eq!(w.chip_count(), 1);
1688 assert_eq!(w.chips[0].value, "banana");
1689 }
1690
1691 #[test]
1692 fn a_chip_re_added_mid_exit_is_revived_rather_than_duplicated() {
1693 let prev = view().multi(true).selected(vec!["apple".to_string()]);
1694 let mut w = ready(&prev);
1695 settle(&mut w);
1696 let gone = view().multi(true);
1697 rebuild(&mut w, &prev, &gone);
1698 assert!(w.chips[0].removing);
1699 rebuild(&mut w, &gone, &prev);
1700 assert_eq!(w.chip_count(), 1);
1701 assert!(!w.chips[0].removing);
1702 }
1703
1704 #[test]
1705 fn a_chip_s_delete_glyph_requests_the_selection_without_that_value() {
1706 let mut w = ready(
1707 &view()
1708 .multi(true)
1709 .selected(vec!["apple".to_string(), "banana".to_string()]),
1710 );
1711 settle(&mut w);
1712 layout(&mut w, AREA);
1713 let mut state = AppState::default();
1714 let at = w.chips[0].delete.center();
1715 click(&mut w, &mut state, at);
1716 assert_eq!(state.selections, vec![vec!["banana".to_string()]]);
1717 assert!(state.opens.is_empty(), "a delete never toggles the panel");
1718 }
1719
1720 #[test]
1721 fn a_press_on_the_chip_body_falls_through_to_the_field_s_own_toggle() {
1722 let mut w = ready(&view().multi(true).selected(vec!["apple".to_string()]));
1723 settle(&mut w);
1724 layout(&mut w, AREA);
1725 let mut state = AppState::default();
1726 let chip = w.chips[0].rect;
1727 let at = Point::new(chip.x0 + 2.0, chip.center().y);
1728 click(&mut w, &mut state, at);
1729 assert!(state.selections.is_empty());
1730 assert_eq!(state.opens, vec![true]);
1731 }
1732
1733 #[test]
1734 fn a_disabled_field_s_chips_offer_no_delete_region() {
1735 let mut w = ready(
1736 &view()
1737 .multi(true)
1738 .enabled(false)
1739 .selected(vec!["apple".to_string()]),
1740 );
1741 settle(&mut w);
1742 layout(&mut w, AREA);
1743 assert_eq!(w.chips[0].delete, Rect::ZERO);
1744 }
1745
1746 #[test]
1747 fn max_chips_hides_the_tail_behind_an_overflow_indicator() {
1748 let mut w = ready(&view().multi(true).max_chips(2).selected(vec![
1749 "apple".to_string(),
1750 "banana".to_string(),
1751 "cherry".to_string(),
1752 ]));
1753 assert_eq!(w.chip_count(), 2);
1754 assert_eq!(w.overflow.as_ref().map(|(_, n)| *n), Some(1));
1755 assert_eq!(
1756 w.overflow.as_ref().map(|(run, _)| run.content.as_str()),
1757 Some("+1")
1758 );
1759 settle(&mut w);
1760 layout(&mut w, AREA);
1761 let rec = paint(&mut w);
1762 assert!(rec.rrects.len() >= 4);
1764 }
1765
1766 #[test]
1769 fn the_container_fills_with_surface_container_highest_at_the_panel_radius() {
1770 let theme = crate::baseline().with_brightness(Brightness::Light);
1771 let mut w = ready(&view());
1772 let rec = paint(&mut w);
1773 let (origin, size, radius, color) = rec.rrects[0];
1774 assert_eq!(origin, Point::ORIGIN);
1775 assert_eq!(size, Size::new(w.width(), w.height()));
1776 assert_eq!(radius, theme.shape.extra_large);
1777 assert_eq!(color, theme.scheme().surface_container_highest);
1778 }
1779
1780 #[test]
1781 fn a_press_washes_the_container_at_the_reference_s_pressed_alpha() {
1782 let mut w = ready(&view());
1783 let mut state = AppState::default();
1784 let at = Point::new(40.0, w.height() / 2.0);
1785 dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, at.x, at.y));
1786 let pressed = paint(&mut w);
1787 assert_eq!(
1788 pressed.rrects[1].3.components[3],
1789 DROPDOWN_FIELD_PRESSED_ALPHA
1790 );
1791 }
1792
1793 #[test]
1798 fn hovering_the_field_washes_it_at_the_reference_s_hover_alpha() {
1799 use frust_core::RenderRoot;
1800 let mut root: RenderRoot<AppState, DropdownFieldView<AppState>> = RenderRoot::new();
1801 let mut state = AppState::default();
1802 let mut tcx = TextContext::new();
1803 let mut logic = move |_s: &mut AppState| view();
1804 root.rebuild(&mut logic, &mut state);
1805 root.layout_with_text(AREA, &mut tcx as &mut dyn Any);
1806
1807 let mut rest = Recorder::default();
1808 root.paint(&mut rest, FrameTime::ZERO);
1809 assert_eq!(rest.washes(), Vec::<f32>::new(), "no wash at rest");
1810
1811 root.event(&mut state, &pointer(PointerPhase::Move, 40.0, 20.0));
1812 let mut hovered = Recorder::default();
1813 root.paint(&mut hovered, FrameTime::ZERO);
1814 assert_eq!(hovered.washes(), vec![DROPDOWN_FIELD_HOVER_ALPHA]);
1815 }
1816
1817 #[test]
1818 fn a_disabled_field_never_washes() {
1819 let mut w = ready(&view().enabled(false));
1820 let mut state = AppState::default();
1821 let at = Point::new(40.0, w.height() / 2.0);
1822 dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, at.x, at.y));
1823 let rec = paint(&mut w);
1824 assert_eq!(rec.rrects.len(), 1, "the container and nothing else");
1825 }
1826
1827 #[test]
1828 fn an_error_field_inks_its_content_in_the_error_role() {
1829 let theme = crate::baseline().with_brightness(Brightness::Light);
1830 let mut w = ready(&view().error(true).selected(vec!["apple".to_string()]));
1831 let mut state = AppState::default();
1832 let at = Point::new(40.0, w.height() / 2.0);
1833 dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, at.x, at.y));
1834 let rec = paint(&mut w);
1835 let wash = rec.rrects[1].3;
1836 let error = theme.scheme().error;
1837 assert_eq!(wash.components[..3], error.components[..3]);
1838 }
1839
1840 #[test]
1841 fn the_arrow_is_the_one_glyph_a_resting_field_paints() {
1842 let mut w = ready(&view());
1843 let rec = paint(&mut w);
1844 assert_eq!(rec.paths.len(), 1);
1845 }
1846
1847 #[test]
1848 fn a_paint_with_no_hover_link_clears_the_latched_flag() {
1849 let mut w = ready(&view());
1850 let mut state = AppState::default();
1851 let at = Point::new(40.0, w.height() / 2.0);
1852 dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, at.x, at.y));
1853 assert!(w.hovered);
1854 paint(&mut w);
1856 assert!(!w.hovered);
1857 }
1858
1859 #[test]
1860 fn the_semantics_node_is_a_combo_box_carrying_the_open_state() {
1861 use frust_core::RenderRoot;
1862 let mut root: RenderRoot<AppState, DropdownFieldView<AppState>> = RenderRoot::new();
1863 let mut state = AppState::default();
1864 let mut tcx = TextContext::new();
1865 let mut logic = move |_s: &mut AppState| view().open(true);
1866 root.rebuild(&mut logic, &mut state);
1867 root.layout_with_text(AREA, &mut tcx as &mut dyn Any);
1868 let tree = root.semantics();
1869 let node = tree
1870 .nodes
1871 .iter()
1872 .find(|(_, node)| node.role() == Role::ComboBox)
1873 .expect("a combo-box node");
1874 assert!(node.1.is_expanded().unwrap_or(false));
1875 }
1876}