1use frust::Theme;
63use frust::authoring::text::TextStyle;
64use frust::authoring::{
65 Action, AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, Color, CornerRadii,
66 CursorIcon, ErasedArgCallback, EventCtx, EventResult, InputEvent, Key, LayoutCtx, NamedKey,
67 PaintCtx, PaintScene, Point, PointerEvent, PointerPhase, Rect, Role, ScrollDelta, SemanticsCtx,
68 Size, TypedArgCallback, View, Widget, any, build_child, erase_callback_arg, rebuild_children,
69 route_event_single, teardown_child, visit_children,
70};
71use frust::input::WHEEL_LINE_PX;
72
73use super::{
74 DROPDOWN_CONTENT_PADDING, DROPDOWN_EMPTY_TEXT, DROPDOWN_ICON_GAP, DROPDOWN_ICON_SIZE,
75 DROPDOWN_ITEM_GAP, DROPDOWN_ITEM_H_PADDING, DROPDOWN_ITEM_HOVER_RADIUS,
76 DROPDOWN_ITEM_INNER_RADIUS, DROPDOWN_ITEM_OUTER_RADIUS, DROPDOWN_ITEM_PRESSED_RADIUS,
77 DROPDOWN_ITEM_V_PADDING, DROPDOWN_LOADING_PADDING, DROPDOWN_MESSAGE_PADDING,
78 DROPDOWN_PANEL_MAX_HEIGHT, DROPDOWN_SEARCH_HINT, DropdownColors, DropdownItem, Glyph, OnOpen,
79 OnQuery, Run, SHAPING_INK, container_radius, dropdown_filter, ordered_selection,
80};
81use crate::card_list::{card_position, card_radii};
82use crate::icons;
83use crate::interaction::InteractionState;
84use crate::overlay::{OverlayAnchor, OverlayElevation, shadow, with_alpha};
85use crate::press::presses;
86use crate::progress::{ProgressValue, circular_progress};
87use crate::text_field::text_field;
88
89pub(super) const SEARCH_MARGIN_H: f64 = 12.0;
92pub(super) const SEARCH_MARGIN_TOP: f64 = 8.0;
94pub(super) const SEARCH_MARGIN_BOTTOM: f64 = 4.0;
96pub(super) const SEARCH_FIELD_HEIGHT: f64 = 56.0;
99
100struct Row {
103 value: String,
105 enabled: bool,
106 selected: bool,
108 label: Run,
109 check: Option<Glyph>,
111 top: f64,
113 height: f64,
115}
116
117#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119enum Body {
120 Rows,
122 Loading,
124 Error,
126 Empty,
128}
129
130pub struct DropdownPanelView<State: 'static> {
132 items: Vec<DropdownItem>,
133 selected: Vec<String>,
134 multi: bool,
135 max_selections: usize,
136 query: String,
137 searchable: bool,
138 loading: bool,
139 error: Option<String>,
140 empty_text: String,
141 max_height: f64,
142 open: bool,
143 anchor: OverlayAnchor,
144 on_change: TypedArgCallback<State, Vec<String>>,
145 on_open: Option<OnOpen<State>>,
146 on_query: Option<OnQuery<State>>,
147}
148
149pub fn dropdown_panel<State: 'static, F: Fn(&mut State, Vec<String>) + 'static>(
157 items: Vec<DropdownItem>,
158 on_change: F,
159) -> DropdownPanelView<State> {
160 DropdownPanelView {
161 items,
162 selected: Vec::new(),
163 multi: false,
164 max_selections: 0,
165 query: String::new(),
166 searchable: false,
167 loading: false,
168 error: None,
169 empty_text: DROPDOWN_EMPTY_TEXT.to_string(),
170 max_height: DROPDOWN_PANEL_MAX_HEIGHT,
171 open: true,
172 anchor: OverlayAnchor::new(),
173 on_change: std::rc::Rc::new(on_change),
174 on_open: None,
175 on_query: None,
176 }
177}
178
179impl<State: 'static> DropdownPanelView<State> {
180 pub fn anchor(mut self, anchor: &OverlayAnchor) -> Self {
183 self.anchor = anchor.clone();
184 self
185 }
186
187 pub fn selected(mut self, selected: Vec<String>) -> Self {
189 self.selected = selected;
190 self
191 }
192
193 pub fn multi(mut self, multi: bool) -> Self {
195 self.multi = multi;
196 self
197 }
198
199 pub fn max_selections(mut self, max: usize) -> Self {
201 self.max_selections = max;
202 self
203 }
204
205 pub fn query(mut self, query: impl Into<String>) -> Self {
207 self.query = query.into();
208 self
209 }
210
211 pub fn searchable(mut self, searchable: bool) -> Self {
213 self.searchable = searchable;
214 self
215 }
216
217 pub fn loading(mut self, loading: bool) -> Self {
219 self.loading = loading;
220 self
221 }
222
223 pub fn error(mut self, error: Option<String>) -> Self {
225 self.error = error;
226 self
227 }
228
229 pub fn empty_text(mut self, text: impl Into<String>) -> Self {
231 self.empty_text = text.into();
232 self
233 }
234
235 pub fn max_height(mut self, max_height: f64) -> Self {
237 self.max_height = max_height;
238 self
239 }
240
241 pub fn open(mut self, open: bool) -> Self {
244 self.open = open;
245 self
246 }
247
248 pub fn on_open<F: Fn(&mut State, bool) + 'static>(mut self, on_open: F) -> Self {
250 self.on_open = Some(std::rc::Rc::new(on_open));
251 self
252 }
253
254 pub fn on_query<F: Fn(&mut State, String) + 'static>(mut self, on_query: F) -> Self {
257 self.on_query = Some(std::rc::Rc::new(on_query));
258 self
259 }
260
261 fn build_rows(&self) -> Vec<Row> {
264 dropdown_filter(&self.items, &self.query)
265 .into_iter()
266 .map(|item| {
267 let selected = self.selected.iter().any(|v| v == item.value());
268 Row {
269 value: item.value().to_string(),
270 enabled: !item.is_disabled(),
271 selected,
272 label: Run::new(item.label()),
273 check: selected.then(|| Glyph::new(icons::CHECK_ROUNDED, DROPDOWN_ICON_SIZE)),
276 top: 0.0,
277 height: 0.0,
278 }
279 })
280 .collect()
281 }
282
283 fn body(&self, rows: &[Row]) -> Body {
286 if self.loading {
287 Body::Loading
288 } else if self.error.is_some() {
289 Body::Error
290 } else if rows.is_empty() {
291 Body::Empty
292 } else {
293 Body::Rows
294 }
295 }
296
297 fn search_views(&self) -> Vec<AnyView<State>> {
300 if !self.searchable {
301 return Vec::new();
302 }
303 let on_query = self.on_query.clone();
304 vec![any(text_field(
305 self.query.clone(),
306 move |state: &mut State, text| {
307 if let Some(on_query) = &on_query {
308 on_query(state, text);
309 }
310 },
311 )
312 .label(DROPDOWN_SEARCH_HINT))]
313 }
314
315 fn loading_views(&self) -> Vec<AnyView<State>> {
317 if !self.loading {
318 return Vec::new();
319 }
320 vec![any(circular_progress(ProgressValue::Indeterminate))]
321 }
322
323 fn message(&self) -> String {
326 self.error
327 .clone()
328 .unwrap_or_else(|| self.empty_text.clone())
329 }
330}
331
332pub struct DropdownPanelWidget {
334 items: Vec<DropdownItem>,
337 rows: Vec<Row>,
338 selected: Vec<String>,
339 multi: bool,
340 max_selections: usize,
341 body: Body,
342 message: Run,
344 message_is_error: bool,
346 search: Vec<ChildPod>,
348 busy: Vec<ChildPod>,
350 max_height: f64,
351 open: bool,
352 anchor: OverlayAnchor,
353 on_change: ErasedArgCallback<Vec<String>>,
354 on_open: Option<ErasedArgCallback<bool>>,
355 hovered: Option<usize>,
358 highlighted: Option<usize>,
361 pressed: Option<usize>,
363 press_inside: bool,
365 captured: bool,
366 scroll: f64,
367 body_top: f64,
369 viewport: f64,
371 content_height: f64,
372 width: f64,
373 height: f64,
374}
375
376impl DropdownPanelWidget {
377 pub fn width(&self) -> f64 {
379 self.width
380 }
381
382 pub fn height(&self) -> f64 {
384 self.height
385 }
386
387 pub fn scroll(&self) -> f64 {
389 self.scroll
390 }
391
392 pub fn highlighted(&self) -> Option<usize> {
394 self.highlighted
395 }
396
397 #[cfg(test)]
399 fn band(&self, row: usize) -> (f64, f64) {
400 let row = &self.rows[row];
401 (row.top, row.height)
402 }
403
404 fn reset_interaction(&mut self) {
407 self.hovered = None;
408 self.highlighted = None;
409 self.pressed = None;
410 self.press_inside = false;
411 self.captured = false;
412 }
413
414 fn clamp_scroll(&mut self) {
416 let max = (self.content_height - self.viewport).max(0.0);
417 self.scroll = self.scroll.clamp(0.0, max);
418 }
419
420 pub fn search_rect(&self) -> Option<Rect> {
423 let pod = self.search.first()?;
424 Some(Rect::from_origin_size(pod.origin(), pod.size()))
425 }
426
427 fn row_at(&self, pos: Point) -> Option<usize> {
430 if self.body != Body::Rows
431 || pos.x < 0.0
432 || pos.x >= self.width
433 || pos.y < self.body_top
434 || pos.y >= self.body_top + self.viewport
435 {
436 return None;
437 }
438 let y = pos.y - self.body_top + self.scroll;
439 self.rows
440 .iter()
441 .position(|row| y >= row.top && y < row.top + row.height)
442 }
443
444 fn activatable(&self, i: usize) -> bool {
446 self.rows.get(i).is_some_and(|row| row.enabled)
447 }
448
449 fn requested_selection(&self, i: usize) -> Option<Vec<String>> {
452 let row = self.rows.get(i)?;
453 if !row.enabled {
454 return None;
455 }
456 let already = self.selected.iter().any(|v| v == &row.value);
457 if !self.multi {
458 return Some(if already {
461 Vec::new()
462 } else {
463 vec![row.value.clone()]
464 });
465 }
466 if already {
467 let kept: Vec<String> = self
470 .selected
471 .iter()
472 .filter(|v| *v != &row.value)
473 .cloned()
474 .collect();
475 return Some(ordered_selection(&self.items, &kept));
476 }
477 if self.max_selections > 0 && self.selected.len() >= self.max_selections {
478 return None;
479 }
480 let mut next = self.selected.clone();
481 next.push(row.value.clone());
482 Some(ordered_selection(&self.items, &next))
483 }
484
485 fn activate(&mut self, ctx: &mut EventCtx, i: usize) {
487 let Some(next) = self.requested_selection(i) else {
488 return;
489 };
490 (self.on_change)(ctx, next);
491 if !self.multi
494 && let Some(on_open) = self.on_open.as_mut()
495 {
496 on_open(ctx, false);
497 }
498 }
499
500 fn move_highlight(&mut self, step: isize) -> bool {
503 if self.body != Body::Rows || self.rows.is_empty() {
504 return false;
505 }
506 let len = self.rows.len() as isize;
507 let mut index = match self.highlighted {
508 Some(current) => current as isize,
509 None => {
511 if step > 0 {
512 -1
513 } else {
514 len
515 }
516 }
517 };
518 loop {
519 index += step;
520 if index < 0 || index >= len {
521 return false;
522 }
523 if self.rows[index as usize].enabled {
524 self.highlighted = Some(index as usize);
525 self.reveal(index as usize);
526 return true;
527 }
528 }
529 }
530
531 fn reveal(&mut self, i: usize) {
533 let Some(row) = self.rows.get(i) else {
534 return;
535 };
536 let (top, bottom) = (row.top, row.top + row.height);
537 if top < self.scroll {
538 self.scroll = top;
539 } else if bottom > self.scroll + self.viewport {
540 self.scroll = bottom - self.viewport;
541 }
542 self.clamp_scroll();
543 }
544
545 fn handle_key(&mut self, ctx: &mut EventCtx, key: &Key) -> EventResult {
547 match key {
548 Key::Named(NamedKey::ArrowDown) => {
549 if self.move_highlight(1) {
550 ctx.request_redraw();
551 }
552 EventResult::Handled
553 }
554 Key::Named(NamedKey::ArrowUp) => {
555 if self.move_highlight(-1) {
556 ctx.request_redraw();
557 }
558 EventResult::Handled
559 }
560 Key::Named(NamedKey::Enter) => {
561 let Some(i) = self.highlighted.filter(|i| self.activatable(*i)) else {
562 return EventResult::Ignored;
563 };
564 self.activate(ctx, i);
565 ctx.request_redraw();
566 EventResult::Handled
567 }
568 _ => EventResult::Ignored,
569 }
570 }
571
572 fn handle_pointer(&mut self, ctx: &mut EventCtx, p: &PointerEvent) -> EventResult {
575 match p.phase {
576 PointerPhase::Move if !self.captured => {
577 let row = self.row_at(p.position);
578 if row.is_some() {
581 ctx.claim_hover();
582 }
583 let hovered = row.filter(|i| self.activatable(*i));
584 if hovered.is_some() {
585 ctx.set_cursor(CursorIcon::Pointer);
586 }
587 if self.hovered != hovered {
588 self.hovered = hovered;
589 ctx.request_redraw();
590 }
591 EventResult::Ignored
592 }
593 PointerPhase::Move => {
594 let inside = self.pressed.is_some() && self.row_at(p.position) == self.pressed;
597 if self.press_inside != inside {
598 self.press_inside = inside;
599 ctx.request_redraw();
600 }
601 EventResult::Handled
602 }
603 PointerPhase::Down => {
604 if !presses(p) {
605 return EventResult::Ignored;
606 }
607 ctx.request_focus();
611 let Some(row) = self.row_at(p.position).filter(|i| self.activatable(*i)) else {
612 return EventResult::Ignored;
615 };
616 self.pressed = Some(row);
617 self.press_inside = true;
618 self.captured = true;
619 ctx.capture_pointer();
620 ctx.request_redraw();
621 EventResult::Handled
622 }
623 PointerPhase::Up => {
624 if !self.captured {
625 return EventResult::Ignored;
626 }
627 let released = self.row_at(p.position);
628 let armed = self.pressed;
629 self.pressed = None;
630 self.press_inside = false;
631 self.captured = false;
632 if let Some(row) = armed.filter(|row| released == Some(*row)) {
634 self.activate(ctx, row);
635 }
636 ctx.request_redraw();
637 EventResult::Handled
638 }
639 PointerPhase::Cancel => {
642 if !self.captured {
643 return EventResult::Ignored;
644 }
645 self.pressed = None;
646 self.press_inside = false;
647 self.captured = false;
648 ctx.request_redraw();
649 EventResult::Handled
650 }
651 }
652 }
653
654 fn handle_scroll(
657 &mut self,
658 ctx: &mut EventCtx,
659 position: Point,
660 delta: &ScrollDelta,
661 ) -> EventResult {
662 if position.y < self.body_top || position.y >= self.body_top + self.viewport {
663 return EventResult::Ignored;
664 }
665 let dy = match delta {
666 ScrollDelta::Lines(_, y) => y * WHEEL_LINE_PX,
667 ScrollDelta::Pixels(_, y) => *y,
668 };
669 let before = self.scroll;
670 self.scroll += dy;
671 self.clamp_scroll();
672 if (self.scroll - before).abs() <= f64::EPSILON {
673 return EventResult::Ignored;
674 }
675 ctx.request_redraw();
676 EventResult::Handled
677 }
678}
679
680impl<State: 'static> View<State> for DropdownPanelView<State> {
681 type Element = DropdownPanelWidget;
682
683 fn build(&self, ctx: &mut BuildCtx<'_>) -> DropdownPanelWidget {
684 let rows = self.build_rows();
685 let body = self.body(&rows);
686 DropdownPanelWidget {
687 items: self.items.clone(),
688 rows,
689 selected: self.selected.clone(),
690 multi: self.multi,
691 max_selections: self.max_selections,
692 body,
693 message: Run::new(self.message()),
694 message_is_error: self.error.is_some(),
695 search: self
696 .search_views()
697 .iter()
698 .map(|view| build_child(view, ctx))
699 .collect(),
700 busy: self
701 .loading_views()
702 .iter()
703 .map(|view| build_child(view, ctx))
704 .collect(),
705 max_height: self.max_height,
706 open: self.open,
707 anchor: self.anchor.clone(),
708 on_change: erase_callback_arg(&self.on_change),
709 on_open: self.on_open.as_ref().map(erase_callback_arg),
710 hovered: None,
711 highlighted: None,
712 pressed: None,
713 press_inside: false,
714 captured: false,
715 scroll: 0.0,
716 body_top: 0.0,
717 viewport: 0.0,
718 content_height: 0.0,
719 width: 0.0,
720 height: 0.0,
721 }
722 }
723
724 fn rebuild(
725 &self,
726 prev: &Self,
727 element: &mut DropdownPanelWidget,
728 ctx: &mut BuildCtx<'_>,
729 ) -> ChangeFlags {
730 let mut flags = rebuild_children(
731 &prev.search_views(),
732 &self.search_views(),
733 &mut element.search,
734 ctx,
735 |view: &AnyView<State>| view,
736 |_| None,
737 );
738 flags |= rebuild_children(
739 &prev.loading_views(),
740 &self.loading_views(),
741 &mut element.busy,
742 ctx,
743 |view: &AnyView<State>| view,
744 |_| None,
745 );
746 let rows_changed =
750 prev.items != self.items || prev.selected != self.selected || prev.query != self.query;
751 if rows_changed {
752 element.rows = self.build_rows();
753 element.reset_interaction();
754 element.scroll = 0.0;
755 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
756 }
757 let body = self.body(&element.rows);
758 if element.body != body {
759 element.body = body;
760 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
761 }
762 if prev.error != self.error || prev.empty_text != self.empty_text {
763 element.message = Run::new(self.message());
764 element.message_is_error = self.error.is_some();
765 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
766 }
767 if element.max_height != self.max_height {
768 element.max_height = self.max_height;
769 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
770 }
771 if element.open != self.open {
772 element.open = self.open;
773 if !self.open {
774 element.reset_interaction();
775 flags |= ChangeFlags::PAINT;
776 }
777 }
778 element.items = self.items.clone();
779 element.selected = self.selected.clone();
780 element.multi = self.multi;
781 element.max_selections = self.max_selections;
782 element.anchor = self.anchor.clone();
783 element.on_change = erase_callback_arg(&self.on_change);
786 element.on_open = self.on_open.as_ref().map(erase_callback_arg);
787 flags
788 }
789
790 fn teardown(&self, element: &mut DropdownPanelWidget, ctx: &mut BuildCtx<'_>) {
791 for (view, pod) in self.search_views().iter().zip(element.search.iter_mut()) {
792 teardown_child(view, pod, ctx);
793 }
794 for (view, pod) in self.loading_views().iter().zip(element.busy.iter_mut()) {
795 teardown_child(view, pod, ctx);
796 }
797 }
798}
799
800struct Chrome {
804 colors: DropdownColors,
805 shadow: Option<(f64, f64, Color)>,
807 container_radius: f64,
808}
809
810struct Styles {
814 label: TextStyle,
815 message: TextStyle,
816}
817
818impl Styles {
819 fn resolve(theme: Option<&Theme>) -> Self {
822 let (mut label, mut message) = match theme {
823 Some(theme) => (
824 theme.type_scale.body_large.clone(),
825 theme.type_scale.body_medium.clone(),
826 ),
827 None => (
828 TextStyle::new(16.0, SHAPING_INK),
829 TextStyle::new(14.0, SHAPING_INK),
830 ),
831 };
832 label.color = SHAPING_INK;
833 message.color = SHAPING_INK;
834 Styles { label, message }
835 }
836}
837
838impl Widget for DropdownPanelWidget {
839 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
840 let styles = Styles::resolve(Theme::from_layout_ctx(ctx));
841 let cap = if bc.max().width.is_finite() {
842 bc.max().width
843 } else {
844 f64::INFINITY
845 };
846
847 let anchor_width = self.anchor.rect().width();
851 self.width = if anchor_width > 0.0 {
852 anchor_width
853 } else {
854 let mut natural: f64 = 0.0;
855 for row in self.rows.iter_mut() {
856 let mut width = row.label.natural_width(ctx, &styles.label);
857 width += 2.0 * DROPDOWN_ITEM_H_PADDING;
858 if row.check.is_some() {
859 width += DROPDOWN_ICON_GAP + DROPDOWN_ICON_SIZE;
860 }
861 natural = natural.max(width);
862 }
863 natural + 2.0 * DROPDOWN_CONTENT_PADDING
864 };
865 if cap.is_finite() {
866 self.width = self.width.min(cap);
867 }
868
869 let mut header = 0.0;
872 if let Some(pod) = self.search.first_mut() {
873 let width = (self.width - 2.0 * SEARCH_MARGIN_H).max(0.0);
874 pod.layout_child(
875 ctx,
876 &BoxConstraints::tight(Size::new(width, SEARCH_FIELD_HEIGHT)),
877 );
878 pod.set_origin(Point::new(SEARCH_MARGIN_H, SEARCH_MARGIN_TOP));
879 header = SEARCH_MARGIN_TOP + SEARCH_FIELD_HEIGHT + SEARCH_MARGIN_BOTTOM;
880 }
881 self.body_top = header + DROPDOWN_CONTENT_PADDING;
882
883 let inner = (self.width - 2.0 * DROPDOWN_CONTENT_PADDING).max(0.0);
885 self.content_height = match self.body {
886 Body::Rows => {
887 let text_width = |row: &Row| {
888 let mut width = inner - 2.0 * DROPDOWN_ITEM_H_PADDING;
889 if row.check.is_some() {
890 width -= DROPDOWN_ICON_GAP + DROPDOWN_ICON_SIZE;
891 }
892 width.max(0.0)
893 };
894 let mut y = 0.0;
895 for i in 0..self.rows.len() {
896 if i > 0 {
897 y += DROPDOWN_ITEM_GAP;
898 }
899 let width = text_width(&self.rows[i]);
900 let label = self.rows[i].label.shape(ctx, &styles.label, width).height;
901 let mut height = label + 2.0 * DROPDOWN_ITEM_V_PADDING;
902 if self.rows[i].check.is_some() {
903 height = height.max(DROPDOWN_ICON_SIZE + 2.0 * DROPDOWN_ITEM_V_PADDING);
904 }
905 self.rows[i].top = y;
906 self.rows[i].height = height;
907 y += height;
908 }
909 y
910 }
911 Body::Loading => {
912 let diameter = self.busy.first_mut().map_or(0.0, |pod| {
913 let size =
914 pod.layout_child(ctx, &BoxConstraints::loose(Size::new(inner, inner)));
915 size.height
916 });
917 diameter + 2.0 * DROPDOWN_LOADING_PADDING
918 }
919 Body::Error | Body::Empty => {
920 let width = (inner - 2.0 * DROPDOWN_MESSAGE_PADDING).max(0.0);
921 let height = self.message.shape(ctx, &styles.message, width).height;
922 height + 2.0 * DROPDOWN_MESSAGE_PADDING
923 }
924 };
925
926 let natural = self.body_top + self.content_height + DROPDOWN_CONTENT_PADDING;
927 let mut cap_height = self.max_height;
928 if bc.max().height.is_finite() {
929 cap_height = cap_height.min(bc.max().height);
930 }
931 self.height = natural.min(cap_height);
932 self.viewport = (self.height - self.body_top - DROPDOWN_CONTENT_PADDING).max(0.0);
933 self.clamp_scroll();
934
935 let (width, body_top, viewport) = (self.width, self.body_top, self.viewport);
939 if let Some(pod) = self.busy.first_mut() {
940 let size = pod.size();
941 pod.set_origin(Point::new(
942 (width - size.width) / 2.0,
943 body_top + (viewport - size.height) / 2.0,
944 ));
945 }
946
947 bc.constrain(Size::new(self.width, self.height))
948 }
949
950 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
951 if !ctx.is_hovered() {
954 self.hovered = None;
955 }
956 let theme = Theme::from_paint_ctx(ctx);
957 let chrome = Chrome {
958 colors: DropdownColors::resolve(theme),
959 shadow: shadow(theme, OverlayElevation::Level2),
962 container_radius: container_radius(theme),
963 };
964 let origin = ctx.origin();
965 let size = Size::new(self.width, self.height);
966
967 if let Some((blur, y_offset, color)) = chrome.shadow {
968 scene.draw_shadow(
969 Point::new(origin.x, origin.y + y_offset),
970 size,
971 chrome.container_radius,
972 blur,
973 color,
974 );
975 }
976 scene.fill_rounded_rect(
977 origin,
978 size,
979 chrome.container_radius,
980 chrome.colors.panel_container,
981 );
982
983 if let Some(pod) = self.search.first_mut() {
984 pod.paint_child(ctx, scene);
985 }
986
987 match self.body {
988 Body::Rows => {
989 let clipped = self.content_height > self.viewport + f64::EPSILON;
991 if clipped {
992 scene.push_clip(
993 Point::new(origin.x, origin.y + self.body_top),
994 Size::new(self.width, self.viewport),
995 );
996 }
997 for i in 0..self.rows.len() {
998 self.paint_row(i, origin, &chrome, scene);
999 }
1000 if clipped {
1001 scene.pop_clip();
1002 }
1003 }
1004 Body::Loading => {
1005 if let Some(pod) = self.busy.first_mut() {
1006 pod.paint_child(ctx, scene);
1007 }
1008 }
1009 Body::Error | Body::Empty => {
1010 let ink = if self.message_is_error {
1011 chrome.colors.error
1012 } else {
1013 chrome.colors.empty
1014 };
1015 self.message.paint(
1016 Point::new(
1017 origin.x + DROPDOWN_CONTENT_PADDING + DROPDOWN_MESSAGE_PADDING,
1018 origin.y + self.body_top + DROPDOWN_MESSAGE_PADDING,
1019 ),
1020 ink,
1021 scene,
1022 );
1023 }
1024 }
1025 }
1026
1027 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1028 if event.is_broadcast() {
1029 for pod in self.search.iter_mut().chain(self.busy.iter_mut()) {
1030 pod.event_child(ctx, event);
1031 }
1032 return EventResult::Ignored;
1033 }
1034 if !self.open {
1035 return EventResult::Ignored;
1036 }
1037 if let InputEvent::Key(key) = event {
1038 if self.handle_key(ctx, &key.key) == EventResult::Handled {
1042 return EventResult::Handled;
1043 }
1044 if let Some(pod) = self.search.first_mut() {
1045 return route_event_single(pod, ctx, event);
1046 }
1047 return EventResult::Ignored;
1048 }
1049 if let Some(pod) = self.search.first_mut()
1051 && (pod.is_active() || pod.contains(event.position()))
1052 && route_event_single(pod, ctx, event) == EventResult::Handled
1053 {
1054 return EventResult::Handled;
1055 }
1056 match event {
1057 InputEvent::Scroll { position, delta } => self.handle_scroll(ctx, *position, delta),
1058 InputEvent::Pointer(p) => self.handle_pointer(ctx, p),
1059 _ => EventResult::Ignored,
1060 }
1061 }
1062
1063 fn semantics(&self, ctx: &mut SemanticsCtx) {
1064 let rows = &self.rows;
1065 let search = &self.search;
1066 ctx.push_container(
1067 Role::ListBox,
1068 |_node| {},
1069 |ctx| {
1070 if let Some(pod) = search.first() {
1071 pod.semantics_child(ctx);
1072 }
1073 for row in rows.iter() {
1074 ctx.push_node(Role::ListBoxOption, |node| {
1079 node.set_label(row.label.content.as_str());
1080 node.set_selected(row.selected);
1081 if row.enabled {
1082 node.add_action(Action::Click);
1083 }
1084 });
1085 }
1086 },
1087 );
1088 }
1089
1090 visit_children!(search, busy);
1091}
1092
1093impl DropdownPanelWidget {
1094 fn row_radii(&self, i: usize) -> CornerRadii {
1098 let row = &self.rows[i];
1099 let pressed = self.pressed == Some(i) && self.press_inside;
1100 let hovered = self.hovered == Some(i) || self.highlighted == Some(i);
1101 let current = if pressed {
1102 DROPDOWN_ITEM_PRESSED_RADIUS
1103 } else if hovered {
1104 DROPDOWN_ITEM_HOVER_RADIUS
1105 } else if row.selected {
1106 DROPDOWN_ITEM_OUTER_RADIUS
1107 } else {
1108 DROPDOWN_ITEM_INNER_RADIUS
1109 };
1110 if row.selected {
1111 return CornerRadii::uniform(current);
1114 }
1115 card_radii(
1116 card_position(i, self.rows.len()),
1117 DROPDOWN_ITEM_OUTER_RADIUS,
1118 current,
1119 )
1120 }
1121
1122 fn paint_row(&self, i: usize, origin: Point, chrome: &Chrome, scene: &mut dyn PaintScene) {
1124 let row = &self.rows[i];
1125 let colors = &chrome.colors;
1126 let box_origin = Point::new(
1127 origin.x + DROPDOWN_CONTENT_PADDING,
1128 origin.y + self.body_top + row.top - self.scroll,
1129 );
1130 let box_size = Size::new(
1131 (self.width - 2.0 * DROPDOWN_CONTENT_PADDING).max(0.0),
1132 row.height,
1133 );
1134 let radii = self.row_radii(i);
1135 scene.fill_rounded_rect_radii(
1136 box_origin,
1137 box_size,
1138 radii,
1139 colors.item_background(row.enabled, row.selected),
1140 );
1141
1142 let mut state = InteractionState::new();
1143 state.hovered = self.hovered == Some(i);
1144 state.focused = self.highlighted == Some(i);
1145 state.pressed = self.pressed == Some(i) && self.press_inside;
1146 let opacity = if row.enabled {
1147 state.resolve_opacity()
1148 } else {
1149 0.0
1150 };
1151 if opacity > 0.0 {
1152 scene.fill_rounded_rect_radii(
1153 box_origin,
1154 box_size,
1155 radii,
1156 with_alpha(colors.item_foreground(row.enabled, row.selected), opacity),
1157 );
1158 }
1159
1160 let ink = colors.item_foreground(row.enabled, row.selected);
1161 let middle = box_origin.y + box_size.height / 2.0;
1162 let label_size = row.label.size();
1163 row.label.paint(
1164 Point::new(
1165 box_origin.x + DROPDOWN_ITEM_H_PADDING,
1166 middle - label_size.height / 2.0,
1167 ),
1168 ink,
1169 scene,
1170 );
1171 if let Some(check) = &row.check {
1172 check.paint(
1173 Point::new(
1174 box_origin.x + box_size.width - DROPDOWN_ITEM_H_PADDING - check.extent,
1175 middle - check.extent / 2.0,
1176 ),
1177 ink,
1178 scene,
1179 );
1180 }
1181 }
1182}
1183
1184#[cfg(test)]
1185mod tests {
1186 use super::*;
1187 use crate::dropdown::dropdown_item;
1188 use frust::authoring::scene::GlyphRun;
1189 use frust::authoring::{BezPath, Brush, KeyEvent, Modifiers, PointerButton, text::TextContext};
1190 use frust::{Brightness, FrameTime};
1191 use std::any::Any;
1192
1193 const AREA: Size = Size::new(600.0, 800.0);
1194 const ANCHOR_WIDTH: f64 = 240.0;
1196
1197 #[derive(Default)]
1199 struct AppState {
1200 selections: Vec<Vec<String>>,
1201 opens: Vec<bool>,
1202 queries: Vec<String>,
1203 }
1204
1205 #[derive(Default)]
1206 struct Recorder {
1207 rrects: Vec<(Point, Size, f64, Color)>,
1208 radii: Vec<CornerRadii>,
1209 paths: Vec<Point>,
1210 runs: usize,
1211 clips: usize,
1212 }
1213
1214 impl PaintScene for Recorder {
1215 fn fill_rect(&mut self, _origin: Point, _size: Size, _color: Color) {}
1216 fn fill_rounded_rect(&mut self, origin: Point, size: Size, radius: f64, color: Color) {
1217 self.rrects.push((origin, size, radius, color));
1218 }
1219 fn fill_rounded_rect_radii(
1220 &mut self,
1221 origin: Point,
1222 size: Size,
1223 radii: CornerRadii,
1224 color: Color,
1225 ) {
1226 self.radii.push(radii);
1227 self.rrects.push((origin, size, radii.largest(), color));
1228 }
1229 fn fill_path(&mut self, origin: Point, _path: &BezPath, _brush: &Brush) {
1230 self.paths.push(origin);
1231 }
1232 fn draw_text(&mut self, _origin: Point, _text: &str) {}
1233 fn draw_glyph_run(&mut self, _run: GlyphRun) {
1234 self.runs += 1;
1235 }
1236 fn draw_shadow(&mut self, _o: Point, _s: Size, _r: f64, _std: f64, _c: Color) {}
1237 fn push_clip(&mut self, _origin: Point, _size: Size) {
1238 self.clips += 1;
1239 }
1240 fn pop_clip(&mut self) {}
1241 }
1242
1243 fn fruit() -> Vec<DropdownItem> {
1244 vec![
1245 dropdown_item("Apple", "apple"),
1246 dropdown_item("Banana", "banana"),
1247 dropdown_item("Cherry", "cherry"),
1248 ]
1249 }
1250
1251 fn anchor() -> OverlayAnchor {
1252 let anchor = OverlayAnchor::new();
1253 anchor.set(Rect::new(20.0, 40.0, 20.0 + ANCHOR_WIDTH, 92.0));
1254 anchor
1255 }
1256
1257 fn view(items: Vec<DropdownItem>, anchor: &OverlayAnchor) -> DropdownPanelView<AppState> {
1258 dropdown_panel(items, |state: &mut AppState, values| {
1259 state.selections.push(values)
1260 })
1261 .anchor(anchor)
1262 .on_open(|state: &mut AppState, open| state.opens.push(open))
1263 .on_query(|state: &mut AppState, query| state.queries.push(query))
1264 }
1265
1266 fn build(view: &DropdownPanelView<AppState>) -> DropdownPanelWidget {
1267 let mut counter = 0u64;
1268 View::<AppState>::build(view, &mut BuildCtx::new(&mut counter))
1269 }
1270
1271 fn layout(w: &mut DropdownPanelWidget, area: Size) -> Size {
1272 let mut tcx = TextContext::new();
1273 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1274 w.layout(&mut lctx, &BoxConstraints::loose(area))
1275 }
1276
1277 fn ready(view: &DropdownPanelView<AppState>) -> DropdownPanelWidget {
1279 let mut w = build(view);
1280 layout(&mut w, AREA);
1281 w
1282 }
1283
1284 fn rebuild(
1285 w: &mut DropdownPanelWidget,
1286 prev: &DropdownPanelView<AppState>,
1287 next: &DropdownPanelView<AppState>,
1288 ) {
1289 let mut counter = 0u64;
1290 View::<AppState>::rebuild(next, prev, w, &mut BuildCtx::new(&mut counter));
1291 }
1292
1293 fn paint(w: &mut DropdownPanelWidget) -> Recorder {
1294 let theme = crate::baseline().with_brightness(Brightness::Light);
1295 let mut rec = Recorder::default();
1296 let size = Size::new(w.width, w.height);
1297 let mut ctx =
1298 PaintCtx::for_test(Point::ORIGIN, size, FrameTime::ZERO).with_theme(&theme as &dyn Any);
1299 w.paint(&mut ctx, &mut rec);
1300 rec
1301 }
1302
1303 fn pointer(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1304 InputEvent::Pointer(PointerEvent {
1305 phase,
1306 position: Point::new(x, y),
1307 button: PointerButton::Primary,
1308 })
1309 }
1310
1311 fn key(named: NamedKey) -> InputEvent {
1312 InputEvent::Key(KeyEvent {
1313 key: Key::Named(named),
1314 modifiers: Modifiers::default(),
1315 repeat: false,
1316 })
1317 }
1318
1319 fn dispatch(
1320 w: &mut DropdownPanelWidget,
1321 state: &mut AppState,
1322 event: &InputEvent,
1323 ) -> EventResult {
1324 let any: &mut dyn Any = state;
1325 let mut ctx = EventCtx::new(any, Point::ZERO, Size::new(w.width, w.height));
1326 w.event(&mut ctx, event)
1327 }
1328
1329 fn row_center(w: &DropdownPanelWidget, row: usize) -> Point {
1331 let (top, height) = w.band(row);
1332 Point::new(w.width / 2.0, w.body_top + top + height / 2.0 - w.scroll)
1333 }
1334
1335 fn click(w: &mut DropdownPanelWidget, state: &mut AppState, row: usize) {
1336 let c = row_center(w, row);
1337 dispatch(w, state, &pointer(PointerPhase::Down, c.x, c.y));
1338 dispatch(w, state, &pointer(PointerPhase::Up, c.x, c.y));
1339 }
1340
1341 #[test]
1344 fn the_panel_matches_the_trigger_s_width() {
1345 let a = anchor();
1346 let w = ready(&view(fruit(), &a));
1347 assert_eq!(w.width(), ANCHOR_WIDTH);
1348 }
1349
1350 #[test]
1351 fn a_never_painted_anchor_falls_back_to_the_natural_content_width() {
1352 let bare = OverlayAnchor::new();
1353 let w = ready(&view(fruit(), &bare));
1354 assert!(
1355 w.width() > 2.0 * DROPDOWN_CONTENT_PADDING,
1356 "a degenerate anchor still sizes the panel from its rows"
1357 );
1358 assert!(w.width() <= AREA.width);
1359 }
1360
1361 #[test]
1362 fn every_row_gets_a_band_and_the_gap_sits_between_them() {
1363 let a = anchor();
1364 let w = ready(&view(fruit(), &a));
1365 assert_eq!(w.rows.len(), 3);
1366 for i in 1..w.rows.len() {
1367 let (prev_top, prev_height) = w.band(i - 1);
1368 let (top, _) = w.band(i);
1369 assert!((top - (prev_top + prev_height) - DROPDOWN_ITEM_GAP).abs() < 1e-6);
1370 }
1371 }
1372
1373 #[test]
1374 fn a_tall_list_caps_at_the_max_height_and_clips_while_scrolling() {
1375 let a = anchor();
1376 let many: Vec<DropdownItem> = (0..40)
1377 .map(|i| dropdown_item(format!("Item {i}"), format!("v{i}")))
1378 .collect();
1379 let mut w = ready(&view(many, &a));
1380 assert_eq!(w.height(), DROPDOWN_PANEL_MAX_HEIGHT);
1381 assert!(w.content_height > w.viewport);
1382 let rec = paint(&mut w);
1383 assert_eq!(rec.clips, 1, "a scrolling list clips its own viewport");
1384
1385 let mut state = AppState::default();
1386 let inside = Point::new(w.width / 2.0, w.body_top + 10.0);
1387 dispatch(
1388 &mut w,
1389 &mut state,
1390 &InputEvent::Scroll {
1391 position: inside,
1392 delta: ScrollDelta::Pixels(0.0, 40.0),
1393 },
1394 );
1395 assert_eq!(w.scroll(), 40.0);
1396 }
1397
1398 #[test]
1399 fn a_short_list_never_clips() {
1400 let a = anchor();
1401 let mut w = ready(&view(fruit(), &a));
1402 assert!(w.height() < DROPDOWN_PANEL_MAX_HEIGHT);
1403 let rec = paint(&mut w);
1404 assert_eq!(rec.clips, 0);
1405 }
1406
1407 #[test]
1408 fn the_search_field_is_a_header_above_the_scrolled_list() {
1409 let a = anchor();
1410 let plain = ready(&view(fruit(), &a));
1411 let searchable = ready(&view(fruit(), &a).searchable(true));
1412 assert!(searchable.body_top > plain.body_top);
1413 let rect = searchable.search_rect().expect("a mounted search field");
1414 assert_eq!(rect.x0, SEARCH_MARGIN_H);
1415 assert_eq!(rect.y0, SEARCH_MARGIN_TOP);
1416 assert!(
1417 rect.y1 <= searchable.body_top,
1418 "the header never overlaps the list"
1419 );
1420 }
1421
1422 #[test]
1425 fn a_query_narrows_the_rows_the_panel_renders() {
1426 let a = anchor();
1427 let w = ready(&view(fruit(), &a).query("an"));
1428 assert_eq!(w.rows.len(), 1);
1429 assert_eq!(w.rows[0].value, "banana");
1430 }
1431
1432 #[test]
1433 fn a_query_matching_nothing_shows_the_empty_message() {
1434 let a = anchor();
1435 let mut w = ready(&view(fruit(), &a).query("zzz"));
1436 assert_eq!(w.body, Body::Empty);
1437 assert!(w.rows.is_empty());
1438 let rec = paint(&mut w);
1439 assert!(rec.runs > 0, "the empty line is painted");
1440 assert!(rec.radii.is_empty(), "no row cards at all");
1441 }
1442
1443 #[test]
1444 fn a_rebuilt_query_re_filters_the_rows_and_drops_the_highlight() {
1445 let a = anchor();
1446 let prev = view(fruit(), &a);
1447 let mut w = ready(&prev);
1448 let mut state = AppState::default();
1449 dispatch(&mut w, &mut state, &key(NamedKey::ArrowDown));
1450 assert_eq!(w.highlighted(), Some(0));
1451
1452 let next = view(fruit(), &a).query("cher");
1453 rebuild(&mut w, &prev, &next);
1454 layout(&mut w, AREA);
1455 assert_eq!(w.rows.len(), 1);
1456 assert_eq!(w.rows[0].value, "cherry");
1457 assert_eq!(w.highlighted(), None);
1458 }
1459
1460 #[test]
1463 fn a_single_select_pick_reports_one_value_and_closes() {
1464 let a = anchor();
1465 let mut w = ready(&view(fruit(), &a));
1466 let mut state = AppState::default();
1467 click(&mut w, &mut state, 1);
1468 assert_eq!(state.selections, vec![vec!["banana".to_string()]]);
1469 assert_eq!(state.opens, vec![false]);
1470 }
1471
1472 #[test]
1473 fn a_single_select_never_flips_its_own_state() {
1474 let a = anchor();
1475 let mut w = ready(&view(fruit(), &a));
1476 let mut state = AppState::default();
1477 click(&mut w, &mut state, 1);
1478 assert!(w.rows.iter().all(|row| !row.selected));
1480 }
1481
1482 #[test]
1483 fn re_picking_the_selected_option_clears_it_the_way_toggle_only_does() {
1484 let a = anchor();
1485 let mut w = ready(&view(fruit(), &a).selected(vec!["banana".to_string()]));
1486 let mut state = AppState::default();
1487 click(&mut w, &mut state, 1);
1488 assert_eq!(state.selections, vec![Vec::<String>::new()]);
1489 assert_eq!(state.opens, vec![false]);
1490 }
1491
1492 #[test]
1493 fn a_single_select_pick_replaces_whatever_was_selected() {
1494 let a = anchor();
1495 let mut w = ready(&view(fruit(), &a).selected(vec!["apple".to_string()]));
1496 let mut state = AppState::default();
1497 click(&mut w, &mut state, 2);
1498 assert_eq!(state.selections, vec![vec!["cherry".to_string()]]);
1499 }
1500
1501 #[test]
1504 fn a_multi_select_pick_adds_in_item_order_and_stays_open() {
1505 let a = anchor();
1506 let mut w = ready(
1507 &view(fruit(), &a)
1508 .multi(true)
1509 .selected(vec!["cherry".to_string()]),
1510 );
1511 let mut state = AppState::default();
1512 click(&mut w, &mut state, 0);
1513 assert_eq!(
1514 state.selections,
1515 vec![vec!["apple".to_string(), "cherry".to_string()]],
1516 "item order, not click order"
1517 );
1518 assert!(state.opens.is_empty(), "a multi-select pick never closes");
1519 }
1520
1521 #[test]
1522 fn a_multi_select_pick_on_a_selected_row_removes_it() {
1523 let a = anchor();
1524 let mut w = ready(
1525 &view(fruit(), &a)
1526 .multi(true)
1527 .selected(vec!["apple".to_string(), "cherry".to_string()]),
1528 );
1529 let mut state = AppState::default();
1530 click(&mut w, &mut state, 0);
1531 assert_eq!(state.selections, vec![vec!["cherry".to_string()]]);
1532 }
1533
1534 #[test]
1535 fn max_selections_refuses_a_new_pick_but_never_a_deselect() {
1536 let a = anchor();
1537 let mut w = ready(
1538 &view(fruit(), &a)
1539 .multi(true)
1540 .max_selections(1)
1541 .selected(vec!["apple".to_string()]),
1542 );
1543 let mut state = AppState::default();
1544 click(&mut w, &mut state, 1);
1545 assert!(state.selections.is_empty(), "the cap refuses the addition");
1546 click(&mut w, &mut state, 0);
1547 assert_eq!(
1548 state.selections,
1549 vec![Vec::<String>::new()],
1550 "a deselect is never capped"
1551 );
1552 }
1553
1554 #[test]
1555 fn a_disabled_row_reports_nothing_at_all() {
1556 let a = anchor();
1557 let items = vec![
1558 dropdown_item("Apple", "apple"),
1559 dropdown_item("Banana", "banana").disabled(true),
1560 ];
1561 let mut w = ready(&view(items, &a));
1562 let mut state = AppState::default();
1563 click(&mut w, &mut state, 1);
1564 assert!(state.selections.is_empty());
1565 assert!(state.opens.is_empty(), "not even a close");
1566 }
1567
1568 #[test]
1569 fn a_release_outside_the_armed_row_reports_nothing() {
1570 let a = anchor();
1571 let mut w = ready(&view(fruit(), &a));
1572 let mut state = AppState::default();
1573 let down = row_center(&w, 0);
1574 let up = row_center(&w, 2);
1575 dispatch(
1576 &mut w,
1577 &mut state,
1578 &pointer(PointerPhase::Down, down.x, down.y),
1579 );
1580 dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, up.x, up.y));
1581 assert!(state.selections.is_empty());
1582 }
1583
1584 #[test]
1585 fn only_a_primary_press_arms_a_row() {
1586 let a = anchor();
1587 let mut w = ready(&view(fruit(), &a));
1588 let mut state = AppState::default();
1589 let c = row_center(&w, 0);
1590 let secondary = InputEvent::Pointer(PointerEvent {
1591 phase: PointerPhase::Down,
1592 position: c,
1593 button: PointerButton::Secondary,
1594 });
1595 assert_eq!(
1596 dispatch(&mut w, &mut state, &secondary),
1597 EventResult::Ignored
1598 );
1599 dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, c.x, c.y));
1600 assert!(state.selections.is_empty());
1601 }
1602
1603 #[test]
1604 fn a_cancel_clears_the_press_without_reporting() {
1605 let a = anchor();
1606 let mut w = ready(&view(fruit(), &a));
1607 let mut state = AppState::default();
1608 let c = row_center(&w, 0);
1609 dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, c.x, c.y));
1610 dispatch(&mut w, &mut state, &pointer(PointerPhase::Cancel, c.x, c.y));
1611 assert!(state.selections.is_empty());
1612 assert_eq!(w.pressed, None);
1613 }
1614
1615 #[test]
1616 fn a_press_on_the_panel_s_own_background_is_left_for_the_host() {
1617 let a = anchor();
1618 let mut w = ready(&view(fruit(), &a));
1619 let mut state = AppState::default();
1620 let y = w.height() - 1.0;
1622 assert_eq!(
1623 dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, 5.0, y)),
1624 EventResult::Ignored
1625 );
1626 }
1627
1628 #[test]
1631 fn arrow_down_enters_at_the_top_and_arrow_up_at_the_bottom() {
1632 let a = anchor();
1633 let mut w = ready(&view(fruit(), &a));
1634 let mut state = AppState::default();
1635 dispatch(&mut w, &mut state, &key(NamedKey::ArrowDown));
1636 assert_eq!(w.highlighted(), Some(0));
1637
1638 let mut fresh = ready(&view(fruit(), &a));
1639 dispatch(&mut fresh, &mut state, &key(NamedKey::ArrowUp));
1640 assert_eq!(fresh.highlighted(), Some(2));
1641 }
1642
1643 #[test]
1644 fn the_highlight_skips_disabled_rows_and_stops_at_the_ends() {
1645 let a = anchor();
1646 let items = vec![
1647 dropdown_item("Apple", "apple"),
1648 dropdown_item("Banana", "banana").disabled(true),
1649 dropdown_item("Cherry", "cherry"),
1650 ];
1651 let mut w = ready(&view(items, &a));
1652 let mut state = AppState::default();
1653 dispatch(&mut w, &mut state, &key(NamedKey::ArrowDown));
1654 assert_eq!(w.highlighted(), Some(0));
1655 dispatch(&mut w, &mut state, &key(NamedKey::ArrowDown));
1656 assert_eq!(w.highlighted(), Some(2), "the disabled row is skipped");
1657 dispatch(&mut w, &mut state, &key(NamedKey::ArrowDown));
1658 assert_eq!(w.highlighted(), Some(2), "the end does not wrap");
1659 }
1660
1661 #[test]
1662 fn enter_activates_the_highlighted_row() {
1663 let a = anchor();
1664 let mut w = ready(&view(fruit(), &a));
1665 let mut state = AppState::default();
1666 dispatch(&mut w, &mut state, &key(NamedKey::ArrowDown));
1667 dispatch(&mut w, &mut state, &key(NamedKey::ArrowDown));
1668 assert_eq!(
1669 dispatch(&mut w, &mut state, &key(NamedKey::Enter)),
1670 EventResult::Handled
1671 );
1672 assert_eq!(state.selections, vec![vec!["banana".to_string()]]);
1673 }
1674
1675 #[test]
1676 fn enter_with_no_highlight_reports_nothing() {
1677 let a = anchor();
1678 let mut w = ready(&view(fruit(), &a));
1679 let mut state = AppState::default();
1680 assert_eq!(
1681 dispatch(&mut w, &mut state, &key(NamedKey::Enter)),
1682 EventResult::Ignored
1683 );
1684 assert!(state.selections.is_empty());
1685 }
1686
1687 #[test]
1688 fn escape_is_left_for_the_host_to_dismiss_on() {
1689 let a = anchor();
1690 let mut w = ready(&view(fruit(), &a));
1691 let mut state = AppState::default();
1692 assert_eq!(
1693 dispatch(&mut w, &mut state, &key(NamedKey::Escape)),
1694 EventResult::Ignored
1695 );
1696 }
1697
1698 #[test]
1699 fn the_highlight_scrolls_its_row_into_view() {
1700 let a = anchor();
1701 let many: Vec<DropdownItem> = (0..40)
1702 .map(|i| dropdown_item(format!("Item {i}"), format!("v{i}")))
1703 .collect();
1704 let mut w = ready(&view(many, &a));
1705 let mut state = AppState::default();
1706 for _ in 0..12 {
1707 dispatch(&mut w, &mut state, &key(NamedKey::ArrowDown));
1708 }
1709 assert_eq!(w.highlighted(), Some(11));
1710 assert!(w.scroll() > 0.0, "the highlight pulled the list down");
1711 let (top, height) = w.band(11);
1712 assert!(top >= w.scroll() && top + height <= w.scroll() + w.viewport);
1713 }
1714
1715 #[test]
1716 fn a_closed_panel_ignores_every_interaction() {
1717 let a = anchor();
1718 let mut w = ready(&view(fruit(), &a).open(false));
1719 let mut state = AppState::default();
1720 let c = row_center(&w, 0);
1721 assert_eq!(
1722 dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, c.x, c.y)),
1723 EventResult::Ignored
1724 );
1725 assert_eq!(
1726 dispatch(&mut w, &mut state, &key(NamedKey::ArrowDown)),
1727 EventResult::Ignored
1728 );
1729 assert!(state.selections.is_empty());
1730 }
1731
1732 #[test]
1735 fn loading_replaces_the_rows_with_the_busy_indicator() {
1736 let a = anchor();
1737 let mut w = ready(&view(fruit(), &a).loading(true));
1738 assert_eq!(w.body, Body::Loading);
1739 assert_eq!(w.busy.len(), 1, "the indicator pod is mounted");
1740 let rec = paint(&mut w);
1741 assert!(rec.radii.is_empty(), "no row cards while loading");
1742 }
1743
1744 #[test]
1745 fn a_loading_panel_reports_nothing_on_a_press() {
1746 let a = anchor();
1747 let mut w = ready(&view(fruit(), &a).loading(true));
1748 let mut state = AppState::default();
1749 let middle = Point::new(w.width() / 2.0, w.body_top + w.viewport / 2.0);
1750 dispatch(
1751 &mut w,
1752 &mut state,
1753 &pointer(PointerPhase::Down, middle.x, middle.y),
1754 );
1755 dispatch(
1756 &mut w,
1757 &mut state,
1758 &pointer(PointerPhase::Up, middle.x, middle.y),
1759 );
1760 assert!(state.selections.is_empty());
1761 }
1762
1763 #[test]
1764 fn loading_outranks_an_empty_list_and_an_error_outranks_both() {
1765 let a = anchor();
1766 let loading = ready(&view(Vec::new(), &a).loading(true));
1767 assert_eq!(loading.body, Body::Loading);
1768 let error = ready(&view(Vec::new(), &a).error(Some("boom".to_string())));
1769 assert_eq!(error.body, Body::Error);
1770 let empty = ready(&view(Vec::new(), &a));
1771 assert_eq!(empty.body, Body::Empty);
1772 }
1773
1774 #[test]
1775 fn a_load_that_settles_swaps_the_busy_row_for_the_options() {
1776 let a = anchor();
1777 let prev = view(Vec::new(), &a).loading(true);
1778 let mut w = ready(&prev);
1779 assert_eq!(w.busy.len(), 1);
1780
1781 let next = view(fruit(), &a).loading(false);
1782 rebuild(&mut w, &prev, &next);
1783 layout(&mut w, AREA);
1784 assert_eq!(w.body, Body::Rows);
1785 assert_eq!(w.busy.len(), 0, "the indicator pod is unmounted");
1786 assert_eq!(w.rows.len(), 3);
1787 }
1788
1789 #[test]
1790 fn an_error_paints_its_message_in_the_error_role() {
1791 let a = anchor();
1792 let theme = crate::baseline().with_brightness(Brightness::Light);
1793 let mut w = ready(&view(fruit(), &a).error(Some("network down".to_string())));
1794 assert!(w.message_is_error);
1795 let rec = paint(&mut w);
1796 assert!(rec.runs > 0);
1797 assert_eq!(rec.rrects.len(), 1);
1799 assert_eq!(rec.rrects[0].3, theme.scheme().surface_container_highest);
1800 }
1801
1802 #[test]
1805 fn a_selected_row_takes_the_secondary_container_fill_and_a_trailing_check() {
1806 let a = anchor();
1807 let theme = crate::baseline().with_brightness(Brightness::Light);
1808 let mut w = ready(&view(fruit(), &a).selected(vec!["banana".to_string()]));
1809 let rec = paint(&mut w);
1810 let selected_fill = theme.scheme().secondary_container;
1811 assert!(
1812 rec.rrects
1813 .iter()
1814 .any(|(_, _, _, color)| *color == selected_fill),
1815 "the selected row fills with `secondaryContainer`"
1816 );
1817 assert_eq!(rec.paths.len(), 1, "exactly one check glyph, on row 1");
1818 }
1819
1820 #[test]
1821 fn a_selected_row_loses_its_cap_corners_the_way_the_reference_does() {
1822 let a = anchor();
1823 let w = ready(&view(fruit(), &a).selected(vec!["apple".to_string()]));
1826 assert_eq!(
1827 w.row_radii(0),
1828 CornerRadii::uniform(DROPDOWN_ITEM_OUTER_RADIUS)
1829 );
1830 assert_eq!(
1831 w.row_radii(1),
1832 card_radii(
1833 card_position(1, 3),
1834 DROPDOWN_ITEM_OUTER_RADIUS,
1835 DROPDOWN_ITEM_INNER_RADIUS
1836 )
1837 );
1838 }
1839
1840 #[test]
1841 fn the_card_list_treatment_caps_the_first_and_last_rows() {
1842 let a = anchor();
1843 let w = ready(&view(fruit(), &a));
1844 assert_eq!(
1845 w.row_radii(0),
1846 CornerRadii::new(
1847 DROPDOWN_ITEM_OUTER_RADIUS,
1848 DROPDOWN_ITEM_OUTER_RADIUS,
1849 DROPDOWN_ITEM_INNER_RADIUS,
1850 DROPDOWN_ITEM_INNER_RADIUS
1851 )
1852 );
1853 assert_eq!(
1854 w.row_radii(2),
1855 CornerRadii::new(
1856 DROPDOWN_ITEM_INNER_RADIUS,
1857 DROPDOWN_ITEM_INNER_RADIUS,
1858 DROPDOWN_ITEM_OUTER_RADIUS,
1859 DROPDOWN_ITEM_OUTER_RADIUS
1860 )
1861 );
1862 let single = ready(&view(vec![dropdown_item("Only", "only")], &a));
1864 assert_eq!(
1865 single.row_radii(0),
1866 CornerRadii::uniform(DROPDOWN_ITEM_OUTER_RADIUS)
1867 );
1868 }
1869
1870 #[test]
1871 fn hover_and_press_morph_the_inner_radius() {
1872 let a = anchor();
1873 let mut w = ready(&view(fruit(), &a));
1874 let mut state = AppState::default();
1875 let c = row_center(&w, 1);
1876 dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, c.x, c.y));
1877 assert_eq!(
1878 w.row_radii(1),
1879 CornerRadii::uniform(DROPDOWN_ITEM_HOVER_RADIUS)
1880 );
1881 dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, c.x, c.y));
1882 assert_eq!(
1883 w.row_radii(1),
1884 CornerRadii::uniform(DROPDOWN_ITEM_PRESSED_RADIUS)
1885 );
1886 }
1887
1888 #[test]
1889 fn a_disabled_row_never_washes() {
1890 let a = anchor();
1891 let items = vec![
1892 dropdown_item("Apple", "apple"),
1893 dropdown_item("Banana", "banana").disabled(true),
1894 ];
1895 let mut w = ready(&view(items, &a));
1896 let mut state = AppState::default();
1897 let c = row_center(&w, 1);
1898 dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, c.x, c.y));
1899 assert_eq!(w.hovered, None, "a disabled row is never latched");
1900 }
1901
1902 #[test]
1903 fn a_paint_with_no_hover_link_clears_the_latched_row() {
1904 let a = anchor();
1905 let mut w = ready(&view(fruit(), &a));
1906 let mut state = AppState::default();
1907 let c = row_center(&w, 0);
1908 dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, c.x, c.y));
1909 assert_eq!(w.hovered, Some(0));
1910 paint(&mut w);
1912 assert_eq!(w.hovered, None);
1913 }
1914
1915 #[test]
1916 fn every_painted_rect_stays_inside_the_panel_s_own_box() {
1917 let a = anchor();
1918 let mut w = ready(&view(fruit(), &a).selected(vec!["banana".to_string()]));
1919 let width = w.width();
1920 let height = w.height();
1921 let rec = paint(&mut w);
1922 for (origin, size, _, _) in rec.rrects {
1923 assert!(origin.x >= 0.0 && origin.x + size.width <= width + 1e-6);
1924 assert!(origin.y >= 0.0 && origin.y + size.height <= height + 1e-6);
1925 }
1926 }
1927
1928 #[test]
1929 fn a_rebuild_that_re_supplies_the_same_props_keeps_its_shaped_runs() {
1930 let a = anchor();
1931 let prev = view(fruit(), &a);
1932 let mut w = ready(&prev);
1933 let before: Vec<Size> = w.rows.iter().map(|row| row.label.size()).collect();
1934 let next = view(fruit(), &a);
1935 rebuild(&mut w, &prev, &next);
1936 let after: Vec<Size> = w.rows.iter().map(|row| row.label.size()).collect();
1937 assert_eq!(before, after);
1938 assert!(after.iter().all(|size| size.width > 0.0));
1939 }
1940
1941 #[test]
1942 fn the_semantics_tree_is_a_listbox_of_selectable_options() {
1943 use frust_core::RenderRoot;
1944 let a = anchor();
1945 let mut root: RenderRoot<AppState, DropdownPanelView<AppState>> = RenderRoot::new();
1946 let mut state = AppState::default();
1947 let mut tcx = TextContext::new();
1948 let cell = a.clone();
1949 let mut logic =
1950 move |_s: &mut AppState| view(fruit(), &cell).selected(vec!["banana".to_string()]);
1951 root.rebuild(&mut logic, &mut state);
1952 root.layout_with_text(AREA, &mut tcx as &mut dyn Any);
1953 let tree = root.semantics();
1954 assert!(
1955 tree.nodes
1956 .iter()
1957 .any(|(_, node)| node.role() == Role::ListBox)
1958 );
1959 let options: Vec<_> = tree
1960 .nodes
1961 .iter()
1962 .filter(|(_, node)| node.role() == Role::ListBoxOption)
1963 .collect();
1964 assert_eq!(options.len(), 3);
1965 assert_eq!(
1966 options
1967 .iter()
1968 .filter(|(_, n)| n.is_selected() == Some(true))
1969 .count(),
1970 1
1971 );
1972 }
1973}