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 laid_anchor_width: f64,
358 on_change: ErasedArgCallback<Vec<String>>,
359 on_open: Option<ErasedArgCallback<bool>>,
360 hovered: Option<usize>,
363 highlighted: Option<usize>,
366 pressed: Option<usize>,
368 press_inside: bool,
370 captured: bool,
371 scroll: f64,
372 body_top: f64,
374 viewport: f64,
376 content_height: f64,
377 width: f64,
378 height: f64,
379}
380
381impl DropdownPanelWidget {
382 pub fn width(&self) -> f64 {
384 self.width
385 }
386
387 pub fn height(&self) -> f64 {
389 self.height
390 }
391
392 pub fn scroll(&self) -> f64 {
394 self.scroll
395 }
396
397 pub fn highlighted(&self) -> Option<usize> {
399 self.highlighted
400 }
401
402 #[cfg(test)]
404 fn band(&self, row: usize) -> (f64, f64) {
405 let row = &self.rows[row];
406 (row.top, row.height)
407 }
408
409 fn reset_interaction(&mut self) {
412 self.hovered = None;
413 self.highlighted = None;
414 self.pressed = None;
415 self.press_inside = false;
416 self.captured = false;
417 }
418
419 fn clamp_scroll(&mut self) {
421 let max = (self.content_height - self.viewport).max(0.0);
422 self.scroll = self.scroll.clamp(0.0, max);
423 }
424
425 pub fn search_rect(&self) -> Option<Rect> {
428 let pod = self.search.first()?;
429 Some(Rect::from_origin_size(pod.origin(), pod.size()))
430 }
431
432 fn row_at(&self, pos: Point) -> Option<usize> {
435 if self.body != Body::Rows
436 || pos.x < 0.0
437 || pos.x >= self.width
438 || pos.y < self.body_top
439 || pos.y >= self.body_top + self.viewport
440 {
441 return None;
442 }
443 let y = pos.y - self.body_top + self.scroll;
444 self.rows
445 .iter()
446 .position(|row| y >= row.top && y < row.top + row.height)
447 }
448
449 fn activatable(&self, i: usize) -> bool {
451 self.rows.get(i).is_some_and(|row| row.enabled)
452 }
453
454 fn requested_selection(&self, i: usize) -> Option<Vec<String>> {
457 let row = self.rows.get(i)?;
458 if !row.enabled {
459 return None;
460 }
461 let already = self.selected.iter().any(|v| v == &row.value);
462 if !self.multi {
463 return Some(if already {
466 Vec::new()
467 } else {
468 vec![row.value.clone()]
469 });
470 }
471 if already {
472 let kept: Vec<String> = self
475 .selected
476 .iter()
477 .filter(|v| *v != &row.value)
478 .cloned()
479 .collect();
480 return Some(ordered_selection(&self.items, &kept));
481 }
482 if self.max_selections > 0 && self.selected.len() >= self.max_selections {
483 return None;
484 }
485 let mut next = self.selected.clone();
486 next.push(row.value.clone());
487 Some(ordered_selection(&self.items, &next))
488 }
489
490 fn activate(&mut self, ctx: &mut EventCtx, i: usize) {
492 let Some(next) = self.requested_selection(i) else {
493 return;
494 };
495 (self.on_change)(ctx, next);
496 if !self.multi
499 && let Some(on_open) = self.on_open.as_mut()
500 {
501 on_open(ctx, false);
502 }
503 }
504
505 fn move_highlight(&mut self, step: isize) -> bool {
508 if self.body != Body::Rows || self.rows.is_empty() {
509 return false;
510 }
511 let len = self.rows.len() as isize;
512 let mut index = match self.highlighted {
513 Some(current) => current as isize,
514 None => {
516 if step > 0 {
517 -1
518 } else {
519 len
520 }
521 }
522 };
523 loop {
524 index += step;
525 if index < 0 || index >= len {
526 return false;
527 }
528 if self.rows[index as usize].enabled {
529 self.highlighted = Some(index as usize);
530 self.reveal(index as usize);
531 return true;
532 }
533 }
534 }
535
536 fn reveal(&mut self, i: usize) {
538 let Some(row) = self.rows.get(i) else {
539 return;
540 };
541 let (top, bottom) = (row.top, row.top + row.height);
542 if top < self.scroll {
543 self.scroll = top;
544 } else if bottom > self.scroll + self.viewport {
545 self.scroll = bottom - self.viewport;
546 }
547 self.clamp_scroll();
548 }
549
550 fn handle_key(&mut self, ctx: &mut EventCtx, key: &Key) -> EventResult {
552 match key {
553 Key::Named(NamedKey::ArrowDown) => {
554 if self.move_highlight(1) {
555 ctx.request_redraw();
556 }
557 EventResult::Handled
558 }
559 Key::Named(NamedKey::ArrowUp) => {
560 if self.move_highlight(-1) {
561 ctx.request_redraw();
562 }
563 EventResult::Handled
564 }
565 Key::Named(NamedKey::Enter) => {
566 let Some(i) = self.highlighted.filter(|i| self.activatable(*i)) else {
567 return EventResult::Ignored;
568 };
569 self.activate(ctx, i);
570 ctx.request_redraw();
571 EventResult::Handled
572 }
573 _ => EventResult::Ignored,
574 }
575 }
576
577 fn handle_pointer(&mut self, ctx: &mut EventCtx, p: &PointerEvent) -> EventResult {
580 match p.phase {
581 PointerPhase::Move if !self.captured => {
582 let row = self.row_at(p.position);
583 if row.is_some() {
586 ctx.claim_hover();
587 }
588 let hovered = row.filter(|i| self.activatable(*i));
589 if hovered.is_some() {
590 ctx.set_cursor(CursorIcon::Pointer);
591 }
592 if self.hovered != hovered {
593 self.hovered = hovered;
594 ctx.request_redraw();
595 }
596 EventResult::Ignored
597 }
598 PointerPhase::Move => {
599 let inside = self.pressed.is_some() && self.row_at(p.position) == self.pressed;
602 if self.press_inside != inside {
603 self.press_inside = inside;
604 ctx.request_redraw();
605 }
606 EventResult::Handled
607 }
608 PointerPhase::Down => {
609 if !presses(p) {
610 return EventResult::Ignored;
611 }
612 ctx.request_focus();
616 let Some(row) = self.row_at(p.position).filter(|i| self.activatable(*i)) else {
617 return EventResult::Ignored;
620 };
621 self.pressed = Some(row);
622 self.press_inside = true;
623 self.captured = true;
624 ctx.capture_pointer();
625 ctx.request_redraw();
626 EventResult::Handled
627 }
628 PointerPhase::Up => {
629 if !self.captured {
630 return EventResult::Ignored;
631 }
632 let released = self.row_at(p.position);
633 let armed = self.pressed;
634 self.pressed = None;
635 self.press_inside = false;
636 self.captured = false;
637 if let Some(row) = armed.filter(|row| released == Some(*row)) {
639 self.activate(ctx, row);
640 }
641 ctx.request_redraw();
642 EventResult::Handled
643 }
644 PointerPhase::Cancel => {
647 if !self.captured {
648 return EventResult::Ignored;
649 }
650 self.pressed = None;
651 self.press_inside = false;
652 self.captured = false;
653 ctx.request_redraw();
654 EventResult::Handled
655 }
656 }
657 }
658
659 fn handle_scroll(
662 &mut self,
663 ctx: &mut EventCtx,
664 position: Point,
665 delta: &ScrollDelta,
666 ) -> EventResult {
667 if position.y < self.body_top || position.y >= self.body_top + self.viewport {
668 return EventResult::Ignored;
669 }
670 let dy = match delta {
671 ScrollDelta::Lines(_, y) => y * WHEEL_LINE_PX,
672 ScrollDelta::Pixels(_, y) => *y,
673 };
674 let before = self.scroll;
675 self.scroll += dy;
676 self.clamp_scroll();
677 if (self.scroll - before).abs() <= f64::EPSILON {
678 return EventResult::Ignored;
679 }
680 ctx.request_redraw();
681 EventResult::Handled
682 }
683}
684
685impl<State: 'static> View<State> for DropdownPanelView<State> {
686 type Element = DropdownPanelWidget;
687
688 fn build(&self, ctx: &mut BuildCtx<'_>) -> DropdownPanelWidget {
689 let rows = self.build_rows();
690 let body = self.body(&rows);
691 DropdownPanelWidget {
692 items: self.items.clone(),
693 rows,
694 selected: self.selected.clone(),
695 multi: self.multi,
696 max_selections: self.max_selections,
697 body,
698 message: Run::new(self.message()),
699 message_is_error: self.error.is_some(),
700 search: self
701 .search_views()
702 .iter()
703 .map(|view| build_child(view, ctx))
704 .collect(),
705 busy: self
706 .loading_views()
707 .iter()
708 .map(|view| build_child(view, ctx))
709 .collect(),
710 max_height: self.max_height,
711 open: self.open,
712 anchor: self.anchor.clone(),
713 laid_anchor_width: 0.0,
714 on_change: erase_callback_arg(&self.on_change),
715 on_open: self.on_open.as_ref().map(erase_callback_arg),
716 hovered: None,
717 highlighted: None,
718 pressed: None,
719 press_inside: false,
720 captured: false,
721 scroll: 0.0,
722 body_top: 0.0,
723 viewport: 0.0,
724 content_height: 0.0,
725 width: 0.0,
726 height: 0.0,
727 }
728 }
729
730 fn rebuild(
731 &self,
732 prev: &Self,
733 element: &mut DropdownPanelWidget,
734 ctx: &mut BuildCtx<'_>,
735 ) -> ChangeFlags {
736 let mut flags = rebuild_children(
737 &prev.search_views(),
738 &self.search_views(),
739 &mut element.search,
740 ctx,
741 |view: &AnyView<State>| view,
742 |_| None,
743 );
744 flags |= rebuild_children(
745 &prev.loading_views(),
746 &self.loading_views(),
747 &mut element.busy,
748 ctx,
749 |view: &AnyView<State>| view,
750 |_| None,
751 );
752 let rows_changed =
756 prev.items != self.items || prev.selected != self.selected || prev.query != self.query;
757 if rows_changed {
758 element.rows = self.build_rows();
759 element.reset_interaction();
760 element.scroll = 0.0;
761 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
762 }
763 let body = self.body(&element.rows);
764 if element.body != body {
765 element.body = body;
766 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
767 }
768 if prev.error != self.error || prev.empty_text != self.empty_text {
769 element.message = Run::new(self.message());
770 element.message_is_error = self.error.is_some();
771 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
772 }
773 if element.max_height != self.max_height {
774 element.max_height = self.max_height;
775 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
776 }
777 if element.open != self.open {
778 element.open = self.open;
779 if !self.open {
780 element.reset_interaction();
781 flags |= ChangeFlags::PAINT;
782 }
783 }
784 element.items = self.items.clone();
785 element.selected = self.selected.clone();
786 element.multi = self.multi;
787 element.max_selections = self.max_selections;
788 element.anchor = self.anchor.clone();
789 if element.anchor.rect().width() != element.laid_anchor_width {
792 flags |= ChangeFlags::LAYOUT;
793 }
794 element.on_change = erase_callback_arg(&self.on_change);
797 element.on_open = self.on_open.as_ref().map(erase_callback_arg);
798 flags
799 }
800
801 fn teardown(&self, element: &mut DropdownPanelWidget, ctx: &mut BuildCtx<'_>) {
802 for (view, pod) in self.search_views().iter().zip(element.search.iter_mut()) {
803 teardown_child(view, pod, ctx);
804 }
805 for (view, pod) in self.loading_views().iter().zip(element.busy.iter_mut()) {
806 teardown_child(view, pod, ctx);
807 }
808 }
809}
810
811struct Chrome {
815 colors: DropdownColors,
816 shadow: Option<(f64, f64, Color)>,
818 container_radius: f64,
819}
820
821struct Styles {
825 label: TextStyle,
826 message: TextStyle,
827}
828
829impl Styles {
830 fn resolve(theme: Option<&Theme>) -> Self {
833 let (mut label, mut message) = match theme {
834 Some(theme) => (
835 theme.type_scale.body_large.clone(),
836 theme.type_scale.body_medium.clone(),
837 ),
838 None => (
839 TextStyle::new(16.0, SHAPING_INK),
840 TextStyle::new(14.0, SHAPING_INK),
841 ),
842 };
843 label.color = SHAPING_INK;
844 message.color = SHAPING_INK;
845 Styles { label, message }
846 }
847}
848
849impl Widget for DropdownPanelWidget {
850 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
851 let styles = Styles::resolve(Theme::from_layout_ctx(ctx));
852 let cap = if bc.max().width.is_finite() {
853 bc.max().width
854 } else {
855 f64::INFINITY
856 };
857
858 let anchor_width = self.anchor.rect().width();
862 self.laid_anchor_width = anchor_width;
863 self.width = if anchor_width > 0.0 {
864 anchor_width
865 } else {
866 let mut natural: f64 = 0.0;
867 for row in self.rows.iter_mut() {
868 let mut width = row.label.natural_width(ctx, &styles.label);
869 width += 2.0 * DROPDOWN_ITEM_H_PADDING;
870 if row.check.is_some() {
871 width += DROPDOWN_ICON_GAP + DROPDOWN_ICON_SIZE;
872 }
873 natural = natural.max(width);
874 }
875 natural + 2.0 * DROPDOWN_CONTENT_PADDING
876 };
877 if cap.is_finite() {
878 self.width = self.width.min(cap);
879 }
880
881 let mut header = 0.0;
884 if let Some(pod) = self.search.first_mut() {
885 let width = (self.width - 2.0 * SEARCH_MARGIN_H).max(0.0);
886 pod.layout_child(
887 ctx,
888 &BoxConstraints::tight(Size::new(width, SEARCH_FIELD_HEIGHT)),
889 );
890 pod.set_origin(Point::new(SEARCH_MARGIN_H, SEARCH_MARGIN_TOP));
891 header = SEARCH_MARGIN_TOP + SEARCH_FIELD_HEIGHT + SEARCH_MARGIN_BOTTOM;
892 }
893 self.body_top = header + DROPDOWN_CONTENT_PADDING;
894
895 let inner = (self.width - 2.0 * DROPDOWN_CONTENT_PADDING).max(0.0);
897 self.content_height = match self.body {
898 Body::Rows => {
899 let text_width = |row: &Row| {
900 let mut width = inner - 2.0 * DROPDOWN_ITEM_H_PADDING;
901 if row.check.is_some() {
902 width -= DROPDOWN_ICON_GAP + DROPDOWN_ICON_SIZE;
903 }
904 width.max(0.0)
905 };
906 let mut y = 0.0;
907 for i in 0..self.rows.len() {
908 if i > 0 {
909 y += DROPDOWN_ITEM_GAP;
910 }
911 let width = text_width(&self.rows[i]);
912 let label = self.rows[i].label.shape(ctx, &styles.label, width).height;
913 let mut height = label + 2.0 * DROPDOWN_ITEM_V_PADDING;
914 if self.rows[i].check.is_some() {
915 height = height.max(DROPDOWN_ICON_SIZE + 2.0 * DROPDOWN_ITEM_V_PADDING);
916 }
917 self.rows[i].top = y;
918 self.rows[i].height = height;
919 y += height;
920 }
921 y
922 }
923 Body::Loading => {
924 let diameter = self.busy.first_mut().map_or(0.0, |pod| {
925 let size =
926 pod.layout_child(ctx, &BoxConstraints::loose(Size::new(inner, inner)));
927 size.height
928 });
929 diameter + 2.0 * DROPDOWN_LOADING_PADDING
930 }
931 Body::Error | Body::Empty => {
932 let width = (inner - 2.0 * DROPDOWN_MESSAGE_PADDING).max(0.0);
933 let height = self.message.shape(ctx, &styles.message, width).height;
934 height + 2.0 * DROPDOWN_MESSAGE_PADDING
935 }
936 };
937
938 let natural = self.body_top + self.content_height + DROPDOWN_CONTENT_PADDING;
939 let mut cap_height = self.max_height;
940 if bc.max().height.is_finite() {
941 cap_height = cap_height.min(bc.max().height);
942 }
943 self.height = natural.min(cap_height);
944 self.viewport = (self.height - self.body_top - DROPDOWN_CONTENT_PADDING).max(0.0);
945 self.clamp_scroll();
946
947 let (width, body_top, viewport) = (self.width, self.body_top, self.viewport);
951 if let Some(pod) = self.busy.first_mut() {
952 let size = pod.size();
953 pod.set_origin(Point::new(
954 (width - size.width) / 2.0,
955 body_top + (viewport - size.height) / 2.0,
956 ));
957 }
958
959 bc.constrain(Size::new(self.width, self.height))
960 }
961
962 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
963 if !ctx.is_hovered() {
966 self.hovered = None;
967 }
968 if self.anchor.rect().width() != self.laid_anchor_width {
971 ctx.request_layout();
972 }
973 let theme = Theme::from_paint_ctx(ctx);
974 let chrome = Chrome {
975 colors: DropdownColors::resolve(theme),
976 shadow: shadow(theme, OverlayElevation::Level2),
979 container_radius: container_radius(theme),
980 };
981 let origin = ctx.origin();
982 let size = Size::new(self.width, self.height);
983
984 if let Some((blur, y_offset, color)) = chrome.shadow {
985 scene.draw_shadow(
986 Point::new(origin.x, origin.y + y_offset),
987 size,
988 chrome.container_radius,
989 blur,
990 color,
991 );
992 }
993 scene.fill_rounded_rect(
994 origin,
995 size,
996 chrome.container_radius,
997 chrome.colors.panel_container,
998 );
999
1000 if let Some(pod) = self.search.first_mut() {
1001 pod.paint_child(ctx, scene);
1002 }
1003
1004 match self.body {
1005 Body::Rows => {
1006 let clipped = self.content_height > self.viewport + f64::EPSILON;
1008 if clipped {
1009 scene.push_clip(
1010 Point::new(origin.x, origin.y + self.body_top),
1011 Size::new(self.width, self.viewport),
1012 );
1013 }
1014 for i in 0..self.rows.len() {
1015 self.paint_row(i, origin, &chrome, scene);
1016 }
1017 if clipped {
1018 scene.pop_clip();
1019 }
1020 }
1021 Body::Loading => {
1022 if let Some(pod) = self.busy.first_mut() {
1023 pod.paint_child(ctx, scene);
1024 }
1025 }
1026 Body::Error | Body::Empty => {
1027 let ink = if self.message_is_error {
1028 chrome.colors.error
1029 } else {
1030 chrome.colors.empty
1031 };
1032 self.message.paint(
1033 Point::new(
1034 origin.x + DROPDOWN_CONTENT_PADDING + DROPDOWN_MESSAGE_PADDING,
1035 origin.y + self.body_top + DROPDOWN_MESSAGE_PADDING,
1036 ),
1037 ink,
1038 scene,
1039 );
1040 }
1041 }
1042 }
1043
1044 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1045 if event.is_broadcast() {
1046 for pod in self.search.iter_mut().chain(self.busy.iter_mut()) {
1047 pod.event_child(ctx, event);
1048 }
1049 return EventResult::Ignored;
1050 }
1051 if !self.open {
1052 return EventResult::Ignored;
1053 }
1054 if let InputEvent::Key(key) = event {
1055 if self.handle_key(ctx, &key.key) == EventResult::Handled {
1059 return EventResult::Handled;
1060 }
1061 if let Some(pod) = self.search.first_mut() {
1062 return route_event_single(pod, ctx, event);
1063 }
1064 return EventResult::Ignored;
1065 }
1066 if let Some(pod) = self.search.first_mut()
1068 && (pod.is_active() || pod.contains(event.position()))
1069 && route_event_single(pod, ctx, event) == EventResult::Handled
1070 {
1071 return EventResult::Handled;
1072 }
1073 match event {
1074 InputEvent::Scroll { position, delta } => self.handle_scroll(ctx, *position, delta),
1075 InputEvent::Pointer(p) => self.handle_pointer(ctx, p),
1076 _ => EventResult::Ignored,
1077 }
1078 }
1079
1080 fn semantics(&self, ctx: &mut SemanticsCtx) {
1081 let rows = &self.rows;
1082 let search = &self.search;
1083 ctx.push_container(
1084 Role::ListBox,
1085 |_node| {},
1086 |ctx| {
1087 if let Some(pod) = search.first() {
1088 pod.semantics_child(ctx);
1089 }
1090 for row in rows.iter() {
1091 ctx.push_node(Role::ListBoxOption, |node| {
1096 node.set_label(row.label.content.as_str());
1097 node.set_selected(row.selected);
1098 if row.enabled {
1099 node.add_action(Action::Click);
1100 }
1101 });
1102 }
1103 },
1104 );
1105 }
1106
1107 visit_children!(search, busy);
1108}
1109
1110impl DropdownPanelWidget {
1111 fn row_radii(&self, i: usize) -> CornerRadii {
1115 let row = &self.rows[i];
1116 let pressed = self.pressed == Some(i) && self.press_inside;
1117 let hovered = self.hovered == Some(i) || self.highlighted == Some(i);
1118 let current = if pressed {
1119 DROPDOWN_ITEM_PRESSED_RADIUS
1120 } else if hovered {
1121 DROPDOWN_ITEM_HOVER_RADIUS
1122 } else if row.selected {
1123 DROPDOWN_ITEM_OUTER_RADIUS
1124 } else {
1125 DROPDOWN_ITEM_INNER_RADIUS
1126 };
1127 if row.selected {
1128 return CornerRadii::uniform(current);
1131 }
1132 card_radii(
1133 card_position(i, self.rows.len()),
1134 DROPDOWN_ITEM_OUTER_RADIUS,
1135 current,
1136 )
1137 }
1138
1139 fn paint_row(&self, i: usize, origin: Point, chrome: &Chrome, scene: &mut dyn PaintScene) {
1141 let row = &self.rows[i];
1142 let colors = &chrome.colors;
1143 let box_origin = Point::new(
1144 origin.x + DROPDOWN_CONTENT_PADDING,
1145 origin.y + self.body_top + row.top - self.scroll,
1146 );
1147 let box_size = Size::new(
1148 (self.width - 2.0 * DROPDOWN_CONTENT_PADDING).max(0.0),
1149 row.height,
1150 );
1151 let radii = self.row_radii(i);
1152 scene.fill_rounded_rect_radii(
1153 box_origin,
1154 box_size,
1155 radii,
1156 colors.item_background(row.enabled, row.selected),
1157 );
1158
1159 let mut state = InteractionState::new();
1160 state.hovered = self.hovered == Some(i);
1161 state.focused = self.highlighted == Some(i);
1162 state.pressed = self.pressed == Some(i) && self.press_inside;
1163 let opacity = if row.enabled {
1164 state.resolve_opacity()
1165 } else {
1166 0.0
1167 };
1168 if opacity > 0.0 {
1169 scene.fill_rounded_rect_radii(
1170 box_origin,
1171 box_size,
1172 radii,
1173 with_alpha(colors.item_foreground(row.enabled, row.selected), opacity),
1174 );
1175 }
1176
1177 let ink = colors.item_foreground(row.enabled, row.selected);
1178 let middle = box_origin.y + box_size.height / 2.0;
1179 let label_size = row.label.size();
1180 row.label.paint(
1181 Point::new(
1182 box_origin.x + DROPDOWN_ITEM_H_PADDING,
1183 middle - label_size.height / 2.0,
1184 ),
1185 ink,
1186 scene,
1187 );
1188 if let Some(check) = &row.check {
1189 check.paint(
1190 Point::new(
1191 box_origin.x + box_size.width - DROPDOWN_ITEM_H_PADDING - check.extent,
1192 middle - check.extent / 2.0,
1193 ),
1194 ink,
1195 scene,
1196 );
1197 }
1198 }
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203 use super::*;
1204 use crate::dropdown::dropdown_item;
1205 use frust::authoring::scene::GlyphRun;
1206 use frust::authoring::{BezPath, Brush, KeyEvent, Modifiers, PointerButton, text::TextContext};
1207 use frust::{Brightness, FrameTime};
1208 use std::any::Any;
1209
1210 const AREA: Size = Size::new(600.0, 800.0);
1211 const ANCHOR_WIDTH: f64 = 240.0;
1213
1214 #[derive(Default)]
1216 struct AppState {
1217 selections: Vec<Vec<String>>,
1218 opens: Vec<bool>,
1219 queries: Vec<String>,
1220 }
1221
1222 #[derive(Default)]
1223 struct Recorder {
1224 rrects: Vec<(Point, Size, f64, Color)>,
1225 radii: Vec<CornerRadii>,
1226 paths: Vec<Point>,
1227 runs: usize,
1228 clips: usize,
1229 }
1230
1231 impl PaintScene for Recorder {
1232 fn fill_rect(&mut self, _origin: Point, _size: Size, _color: Color) {}
1233 fn fill_rounded_rect(&mut self, origin: Point, size: Size, radius: f64, color: Color) {
1234 self.rrects.push((origin, size, radius, color));
1235 }
1236 fn fill_rounded_rect_radii(
1237 &mut self,
1238 origin: Point,
1239 size: Size,
1240 radii: CornerRadii,
1241 color: Color,
1242 ) {
1243 self.radii.push(radii);
1244 self.rrects.push((origin, size, radii.largest(), color));
1245 }
1246 fn fill_path(&mut self, origin: Point, _path: &BezPath, _brush: &Brush) {
1247 self.paths.push(origin);
1248 }
1249 fn draw_text(&mut self, _origin: Point, _text: &str) {}
1250 fn draw_glyph_run(&mut self, _run: GlyphRun) {
1251 self.runs += 1;
1252 }
1253 fn draw_shadow(&mut self, _o: Point, _s: Size, _r: f64, _std: f64, _c: Color) {}
1254 fn push_clip(&mut self, _origin: Point, _size: Size) {
1255 self.clips += 1;
1256 }
1257 fn pop_clip(&mut self) {}
1258 }
1259
1260 fn fruit() -> Vec<DropdownItem> {
1261 vec![
1262 dropdown_item("Apple", "apple"),
1263 dropdown_item("Banana", "banana"),
1264 dropdown_item("Cherry", "cherry"),
1265 ]
1266 }
1267
1268 fn anchor() -> OverlayAnchor {
1269 let anchor = OverlayAnchor::new();
1270 anchor.set(Rect::new(20.0, 40.0, 20.0 + ANCHOR_WIDTH, 92.0));
1271 anchor
1272 }
1273
1274 fn view(items: Vec<DropdownItem>, anchor: &OverlayAnchor) -> DropdownPanelView<AppState> {
1275 dropdown_panel(items, |state: &mut AppState, values| {
1276 state.selections.push(values)
1277 })
1278 .anchor(anchor)
1279 .on_open(|state: &mut AppState, open| state.opens.push(open))
1280 .on_query(|state: &mut AppState, query| state.queries.push(query))
1281 }
1282
1283 fn build(view: &DropdownPanelView<AppState>) -> DropdownPanelWidget {
1284 let mut counter = 0u64;
1285 View::<AppState>::build(view, &mut BuildCtx::new(&mut counter))
1286 }
1287
1288 fn layout(w: &mut DropdownPanelWidget, area: Size) -> Size {
1289 let mut tcx = TextContext::new();
1290 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1291 w.layout(&mut lctx, &BoxConstraints::loose(area))
1292 }
1293
1294 fn ready(view: &DropdownPanelView<AppState>) -> DropdownPanelWidget {
1296 let mut w = build(view);
1297 layout(&mut w, AREA);
1298 w
1299 }
1300
1301 fn rebuild(
1302 w: &mut DropdownPanelWidget,
1303 prev: &DropdownPanelView<AppState>,
1304 next: &DropdownPanelView<AppState>,
1305 ) {
1306 let mut counter = 0u64;
1307 View::<AppState>::rebuild(next, prev, w, &mut BuildCtx::new(&mut counter));
1308 }
1309
1310 fn paint(w: &mut DropdownPanelWidget) -> Recorder {
1311 let theme = crate::baseline().with_brightness(Brightness::Light);
1312 let mut rec = Recorder::default();
1313 let size = Size::new(w.width, w.height);
1314 let mut ctx =
1315 PaintCtx::for_test(Point::ORIGIN, size, FrameTime::ZERO).with_theme(&theme as &dyn Any);
1316 w.paint(&mut ctx, &mut rec);
1317 rec
1318 }
1319
1320 fn pointer(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1321 InputEvent::Pointer(PointerEvent {
1322 phase,
1323 position: Point::new(x, y),
1324 button: PointerButton::Primary,
1325 })
1326 }
1327
1328 fn key(named: NamedKey) -> InputEvent {
1329 InputEvent::Key(KeyEvent {
1330 key: Key::Named(named),
1331 modifiers: Modifiers::default(),
1332 repeat: false,
1333 })
1334 }
1335
1336 fn dispatch(
1337 w: &mut DropdownPanelWidget,
1338 state: &mut AppState,
1339 event: &InputEvent,
1340 ) -> EventResult {
1341 let any: &mut dyn Any = state;
1342 let mut ctx = EventCtx::new(any, Point::ZERO, Size::new(w.width, w.height));
1343 w.event(&mut ctx, event)
1344 }
1345
1346 fn row_center(w: &DropdownPanelWidget, row: usize) -> Point {
1348 let (top, height) = w.band(row);
1349 Point::new(w.width / 2.0, w.body_top + top + height / 2.0 - w.scroll)
1350 }
1351
1352 fn click(w: &mut DropdownPanelWidget, state: &mut AppState, row: usize) {
1353 let c = row_center(w, row);
1354 dispatch(w, state, &pointer(PointerPhase::Down, c.x, c.y));
1355 dispatch(w, state, &pointer(PointerPhase::Up, c.x, c.y));
1356 }
1357
1358 #[test]
1361 fn the_panel_matches_the_trigger_s_width() {
1362 let a = anchor();
1363 let w = ready(&view(fruit(), &a));
1364 assert_eq!(w.width(), ANCHOR_WIDTH);
1365 }
1366
1367 #[test]
1368 fn a_never_painted_anchor_falls_back_to_the_natural_content_width() {
1369 let bare = OverlayAnchor::new();
1370 let w = ready(&view(fruit(), &bare));
1371 assert!(
1372 w.width() > 2.0 * DROPDOWN_CONTENT_PADDING,
1373 "a degenerate anchor still sizes the panel from its rows"
1374 );
1375 assert!(w.width() <= AREA.width);
1376 }
1377
1378 #[test]
1379 fn every_row_gets_a_band_and_the_gap_sits_between_them() {
1380 let a = anchor();
1381 let w = ready(&view(fruit(), &a));
1382 assert_eq!(w.rows.len(), 3);
1383 for i in 1..w.rows.len() {
1384 let (prev_top, prev_height) = w.band(i - 1);
1385 let (top, _) = w.band(i);
1386 assert!((top - (prev_top + prev_height) - DROPDOWN_ITEM_GAP).abs() < 1e-6);
1387 }
1388 }
1389
1390 #[test]
1391 fn a_tall_list_caps_at_the_max_height_and_clips_while_scrolling() {
1392 let a = anchor();
1393 let many: Vec<DropdownItem> = (0..40)
1394 .map(|i| dropdown_item(format!("Item {i}"), format!("v{i}")))
1395 .collect();
1396 let mut w = ready(&view(many, &a));
1397 assert_eq!(w.height(), DROPDOWN_PANEL_MAX_HEIGHT);
1398 assert!(w.content_height > w.viewport);
1399 let rec = paint(&mut w);
1400 assert_eq!(rec.clips, 1, "a scrolling list clips its own viewport");
1401
1402 let mut state = AppState::default();
1403 let inside = Point::new(w.width / 2.0, w.body_top + 10.0);
1404 dispatch(
1405 &mut w,
1406 &mut state,
1407 &InputEvent::Scroll {
1408 position: inside,
1409 delta: ScrollDelta::Pixels(0.0, 40.0),
1410 },
1411 );
1412 assert_eq!(w.scroll(), 40.0);
1413 }
1414
1415 #[test]
1416 fn a_short_list_never_clips() {
1417 let a = anchor();
1418 let mut w = ready(&view(fruit(), &a));
1419 assert!(w.height() < DROPDOWN_PANEL_MAX_HEIGHT);
1420 let rec = paint(&mut w);
1421 assert_eq!(rec.clips, 0);
1422 }
1423
1424 #[test]
1425 fn the_search_field_is_a_header_above_the_scrolled_list() {
1426 let a = anchor();
1427 let plain = ready(&view(fruit(), &a));
1428 let searchable = ready(&view(fruit(), &a).searchable(true));
1429 assert!(searchable.body_top > plain.body_top);
1430 let rect = searchable.search_rect().expect("a mounted search field");
1431 assert_eq!(rect.x0, SEARCH_MARGIN_H);
1432 assert_eq!(rect.y0, SEARCH_MARGIN_TOP);
1433 assert!(
1434 rect.y1 <= searchable.body_top,
1435 "the header never overlaps the list"
1436 );
1437 }
1438
1439 #[test]
1442 fn a_query_narrows_the_rows_the_panel_renders() {
1443 let a = anchor();
1444 let w = ready(&view(fruit(), &a).query("an"));
1445 assert_eq!(w.rows.len(), 1);
1446 assert_eq!(w.rows[0].value, "banana");
1447 }
1448
1449 #[test]
1450 fn a_query_matching_nothing_shows_the_empty_message() {
1451 let a = anchor();
1452 let mut w = ready(&view(fruit(), &a).query("zzz"));
1453 assert_eq!(w.body, Body::Empty);
1454 assert!(w.rows.is_empty());
1455 let rec = paint(&mut w);
1456 assert!(rec.runs > 0, "the empty line is painted");
1457 assert!(rec.radii.is_empty(), "no row cards at all");
1458 }
1459
1460 #[test]
1461 fn a_rebuilt_query_re_filters_the_rows_and_drops_the_highlight() {
1462 let a = anchor();
1463 let prev = view(fruit(), &a);
1464 let mut w = ready(&prev);
1465 let mut state = AppState::default();
1466 dispatch(&mut w, &mut state, &key(NamedKey::ArrowDown));
1467 assert_eq!(w.highlighted(), Some(0));
1468
1469 let next = view(fruit(), &a).query("cher");
1470 rebuild(&mut w, &prev, &next);
1471 layout(&mut w, AREA);
1472 assert_eq!(w.rows.len(), 1);
1473 assert_eq!(w.rows[0].value, "cherry");
1474 assert_eq!(w.highlighted(), None);
1475 }
1476
1477 #[test]
1480 fn a_single_select_pick_reports_one_value_and_closes() {
1481 let a = anchor();
1482 let mut w = ready(&view(fruit(), &a));
1483 let mut state = AppState::default();
1484 click(&mut w, &mut state, 1);
1485 assert_eq!(state.selections, vec![vec!["banana".to_string()]]);
1486 assert_eq!(state.opens, vec![false]);
1487 }
1488
1489 #[test]
1490 fn a_single_select_never_flips_its_own_state() {
1491 let a = anchor();
1492 let mut w = ready(&view(fruit(), &a));
1493 let mut state = AppState::default();
1494 click(&mut w, &mut state, 1);
1495 assert!(w.rows.iter().all(|row| !row.selected));
1497 }
1498
1499 #[test]
1500 fn re_picking_the_selected_option_clears_it_the_way_toggle_only_does() {
1501 let a = anchor();
1502 let mut w = ready(&view(fruit(), &a).selected(vec!["banana".to_string()]));
1503 let mut state = AppState::default();
1504 click(&mut w, &mut state, 1);
1505 assert_eq!(state.selections, vec![Vec::<String>::new()]);
1506 assert_eq!(state.opens, vec![false]);
1507 }
1508
1509 #[test]
1510 fn a_single_select_pick_replaces_whatever_was_selected() {
1511 let a = anchor();
1512 let mut w = ready(&view(fruit(), &a).selected(vec!["apple".to_string()]));
1513 let mut state = AppState::default();
1514 click(&mut w, &mut state, 2);
1515 assert_eq!(state.selections, vec![vec!["cherry".to_string()]]);
1516 }
1517
1518 #[test]
1521 fn a_multi_select_pick_adds_in_item_order_and_stays_open() {
1522 let a = anchor();
1523 let mut w = ready(
1524 &view(fruit(), &a)
1525 .multi(true)
1526 .selected(vec!["cherry".to_string()]),
1527 );
1528 let mut state = AppState::default();
1529 click(&mut w, &mut state, 0);
1530 assert_eq!(
1531 state.selections,
1532 vec![vec!["apple".to_string(), "cherry".to_string()]],
1533 "item order, not click order"
1534 );
1535 assert!(state.opens.is_empty(), "a multi-select pick never closes");
1536 }
1537
1538 #[test]
1539 fn a_multi_select_pick_on_a_selected_row_removes_it() {
1540 let a = anchor();
1541 let mut w = ready(
1542 &view(fruit(), &a)
1543 .multi(true)
1544 .selected(vec!["apple".to_string(), "cherry".to_string()]),
1545 );
1546 let mut state = AppState::default();
1547 click(&mut w, &mut state, 0);
1548 assert_eq!(state.selections, vec![vec!["cherry".to_string()]]);
1549 }
1550
1551 #[test]
1552 fn max_selections_refuses_a_new_pick_but_never_a_deselect() {
1553 let a = anchor();
1554 let mut w = ready(
1555 &view(fruit(), &a)
1556 .multi(true)
1557 .max_selections(1)
1558 .selected(vec!["apple".to_string()]),
1559 );
1560 let mut state = AppState::default();
1561 click(&mut w, &mut state, 1);
1562 assert!(state.selections.is_empty(), "the cap refuses the addition");
1563 click(&mut w, &mut state, 0);
1564 assert_eq!(
1565 state.selections,
1566 vec![Vec::<String>::new()],
1567 "a deselect is never capped"
1568 );
1569 }
1570
1571 #[test]
1572 fn a_disabled_row_reports_nothing_at_all() {
1573 let a = anchor();
1574 let items = vec![
1575 dropdown_item("Apple", "apple"),
1576 dropdown_item("Banana", "banana").disabled(true),
1577 ];
1578 let mut w = ready(&view(items, &a));
1579 let mut state = AppState::default();
1580 click(&mut w, &mut state, 1);
1581 assert!(state.selections.is_empty());
1582 assert!(state.opens.is_empty(), "not even a close");
1583 }
1584
1585 #[test]
1586 fn a_release_outside_the_armed_row_reports_nothing() {
1587 let a = anchor();
1588 let mut w = ready(&view(fruit(), &a));
1589 let mut state = AppState::default();
1590 let down = row_center(&w, 0);
1591 let up = row_center(&w, 2);
1592 dispatch(
1593 &mut w,
1594 &mut state,
1595 &pointer(PointerPhase::Down, down.x, down.y),
1596 );
1597 dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, up.x, up.y));
1598 assert!(state.selections.is_empty());
1599 }
1600
1601 #[test]
1602 fn only_a_primary_press_arms_a_row() {
1603 let a = anchor();
1604 let mut w = ready(&view(fruit(), &a));
1605 let mut state = AppState::default();
1606 let c = row_center(&w, 0);
1607 let secondary = InputEvent::Pointer(PointerEvent {
1608 phase: PointerPhase::Down,
1609 position: c,
1610 button: PointerButton::Secondary,
1611 });
1612 assert_eq!(
1613 dispatch(&mut w, &mut state, &secondary),
1614 EventResult::Ignored
1615 );
1616 dispatch(&mut w, &mut state, &pointer(PointerPhase::Up, c.x, c.y));
1617 assert!(state.selections.is_empty());
1618 }
1619
1620 #[test]
1621 fn a_cancel_clears_the_press_without_reporting() {
1622 let a = anchor();
1623 let mut w = ready(&view(fruit(), &a));
1624 let mut state = AppState::default();
1625 let c = row_center(&w, 0);
1626 dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, c.x, c.y));
1627 dispatch(&mut w, &mut state, &pointer(PointerPhase::Cancel, c.x, c.y));
1628 assert!(state.selections.is_empty());
1629 assert_eq!(w.pressed, None);
1630 }
1631
1632 #[test]
1633 fn a_press_on_the_panel_s_own_background_is_left_for_the_host() {
1634 let a = anchor();
1635 let mut w = ready(&view(fruit(), &a));
1636 let mut state = AppState::default();
1637 let y = w.height() - 1.0;
1639 assert_eq!(
1640 dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, 5.0, y)),
1641 EventResult::Ignored
1642 );
1643 }
1644
1645 #[test]
1648 fn arrow_down_enters_at_the_top_and_arrow_up_at_the_bottom() {
1649 let a = anchor();
1650 let mut w = ready(&view(fruit(), &a));
1651 let mut state = AppState::default();
1652 dispatch(&mut w, &mut state, &key(NamedKey::ArrowDown));
1653 assert_eq!(w.highlighted(), Some(0));
1654
1655 let mut fresh = ready(&view(fruit(), &a));
1656 dispatch(&mut fresh, &mut state, &key(NamedKey::ArrowUp));
1657 assert_eq!(fresh.highlighted(), Some(2));
1658 }
1659
1660 #[test]
1661 fn the_highlight_skips_disabled_rows_and_stops_at_the_ends() {
1662 let a = anchor();
1663 let items = vec![
1664 dropdown_item("Apple", "apple"),
1665 dropdown_item("Banana", "banana").disabled(true),
1666 dropdown_item("Cherry", "cherry"),
1667 ];
1668 let mut w = ready(&view(items, &a));
1669 let mut state = AppState::default();
1670 dispatch(&mut w, &mut state, &key(NamedKey::ArrowDown));
1671 assert_eq!(w.highlighted(), Some(0));
1672 dispatch(&mut w, &mut state, &key(NamedKey::ArrowDown));
1673 assert_eq!(w.highlighted(), Some(2), "the disabled row is skipped");
1674 dispatch(&mut w, &mut state, &key(NamedKey::ArrowDown));
1675 assert_eq!(w.highlighted(), Some(2), "the end does not wrap");
1676 }
1677
1678 #[test]
1679 fn enter_activates_the_highlighted_row() {
1680 let a = anchor();
1681 let mut w = ready(&view(fruit(), &a));
1682 let mut state = AppState::default();
1683 dispatch(&mut w, &mut state, &key(NamedKey::ArrowDown));
1684 dispatch(&mut w, &mut state, &key(NamedKey::ArrowDown));
1685 assert_eq!(
1686 dispatch(&mut w, &mut state, &key(NamedKey::Enter)),
1687 EventResult::Handled
1688 );
1689 assert_eq!(state.selections, vec![vec!["banana".to_string()]]);
1690 }
1691
1692 #[test]
1693 fn enter_with_no_highlight_reports_nothing() {
1694 let a = anchor();
1695 let mut w = ready(&view(fruit(), &a));
1696 let mut state = AppState::default();
1697 assert_eq!(
1698 dispatch(&mut w, &mut state, &key(NamedKey::Enter)),
1699 EventResult::Ignored
1700 );
1701 assert!(state.selections.is_empty());
1702 }
1703
1704 #[test]
1705 fn escape_is_left_for_the_host_to_dismiss_on() {
1706 let a = anchor();
1707 let mut w = ready(&view(fruit(), &a));
1708 let mut state = AppState::default();
1709 assert_eq!(
1710 dispatch(&mut w, &mut state, &key(NamedKey::Escape)),
1711 EventResult::Ignored
1712 );
1713 }
1714
1715 #[test]
1716 fn the_highlight_scrolls_its_row_into_view() {
1717 let a = anchor();
1718 let many: Vec<DropdownItem> = (0..40)
1719 .map(|i| dropdown_item(format!("Item {i}"), format!("v{i}")))
1720 .collect();
1721 let mut w = ready(&view(many, &a));
1722 let mut state = AppState::default();
1723 for _ in 0..12 {
1724 dispatch(&mut w, &mut state, &key(NamedKey::ArrowDown));
1725 }
1726 assert_eq!(w.highlighted(), Some(11));
1727 assert!(w.scroll() > 0.0, "the highlight pulled the list down");
1728 let (top, height) = w.band(11);
1729 assert!(top >= w.scroll() && top + height <= w.scroll() + w.viewport);
1730 }
1731
1732 #[test]
1733 fn a_closed_panel_ignores_every_interaction() {
1734 let a = anchor();
1735 let mut w = ready(&view(fruit(), &a).open(false));
1736 let mut state = AppState::default();
1737 let c = row_center(&w, 0);
1738 assert_eq!(
1739 dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, c.x, c.y)),
1740 EventResult::Ignored
1741 );
1742 assert_eq!(
1743 dispatch(&mut w, &mut state, &key(NamedKey::ArrowDown)),
1744 EventResult::Ignored
1745 );
1746 assert!(state.selections.is_empty());
1747 }
1748
1749 #[test]
1752 fn loading_replaces_the_rows_with_the_busy_indicator() {
1753 let a = anchor();
1754 let mut w = ready(&view(fruit(), &a).loading(true));
1755 assert_eq!(w.body, Body::Loading);
1756 assert_eq!(w.busy.len(), 1, "the indicator pod is mounted");
1757 let rec = paint(&mut w);
1758 assert!(rec.radii.is_empty(), "no row cards while loading");
1759 }
1760
1761 #[test]
1762 fn a_loading_panel_reports_nothing_on_a_press() {
1763 let a = anchor();
1764 let mut w = ready(&view(fruit(), &a).loading(true));
1765 let mut state = AppState::default();
1766 let middle = Point::new(w.width() / 2.0, w.body_top + w.viewport / 2.0);
1767 dispatch(
1768 &mut w,
1769 &mut state,
1770 &pointer(PointerPhase::Down, middle.x, middle.y),
1771 );
1772 dispatch(
1773 &mut w,
1774 &mut state,
1775 &pointer(PointerPhase::Up, middle.x, middle.y),
1776 );
1777 assert!(state.selections.is_empty());
1778 }
1779
1780 #[test]
1781 fn loading_outranks_an_empty_list_and_an_error_outranks_both() {
1782 let a = anchor();
1783 let loading = ready(&view(Vec::new(), &a).loading(true));
1784 assert_eq!(loading.body, Body::Loading);
1785 let error = ready(&view(Vec::new(), &a).error(Some("boom".to_string())));
1786 assert_eq!(error.body, Body::Error);
1787 let empty = ready(&view(Vec::new(), &a));
1788 assert_eq!(empty.body, Body::Empty);
1789 }
1790
1791 #[test]
1792 fn a_load_that_settles_swaps_the_busy_row_for_the_options() {
1793 let a = anchor();
1794 let prev = view(Vec::new(), &a).loading(true);
1795 let mut w = ready(&prev);
1796 assert_eq!(w.busy.len(), 1);
1797
1798 let next = view(fruit(), &a).loading(false);
1799 rebuild(&mut w, &prev, &next);
1800 layout(&mut w, AREA);
1801 assert_eq!(w.body, Body::Rows);
1802 assert_eq!(w.busy.len(), 0, "the indicator pod is unmounted");
1803 assert_eq!(w.rows.len(), 3);
1804 }
1805
1806 #[test]
1807 fn an_error_paints_its_message_in_the_error_role() {
1808 let a = anchor();
1809 let theme = crate::baseline().with_brightness(Brightness::Light);
1810 let mut w = ready(&view(fruit(), &a).error(Some("network down".to_string())));
1811 assert!(w.message_is_error);
1812 let rec = paint(&mut w);
1813 assert!(rec.runs > 0);
1814 assert_eq!(rec.rrects.len(), 1);
1816 assert_eq!(rec.rrects[0].3, theme.scheme().surface_container_highest);
1817 }
1818
1819 #[test]
1822 fn a_selected_row_takes_the_secondary_container_fill_and_a_trailing_check() {
1823 let a = anchor();
1824 let theme = crate::baseline().with_brightness(Brightness::Light);
1825 let mut w = ready(&view(fruit(), &a).selected(vec!["banana".to_string()]));
1826 let rec = paint(&mut w);
1827 let selected_fill = theme.scheme().secondary_container;
1828 assert!(
1829 rec.rrects
1830 .iter()
1831 .any(|(_, _, _, color)| *color == selected_fill),
1832 "the selected row fills with `secondaryContainer`"
1833 );
1834 assert_eq!(rec.paths.len(), 1, "exactly one check glyph, on row 1");
1835 }
1836
1837 #[test]
1838 fn a_selected_row_loses_its_cap_corners_the_way_the_reference_does() {
1839 let a = anchor();
1840 let w = ready(&view(fruit(), &a).selected(vec!["apple".to_string()]));
1843 assert_eq!(
1844 w.row_radii(0),
1845 CornerRadii::uniform(DROPDOWN_ITEM_OUTER_RADIUS)
1846 );
1847 assert_eq!(
1848 w.row_radii(1),
1849 card_radii(
1850 card_position(1, 3),
1851 DROPDOWN_ITEM_OUTER_RADIUS,
1852 DROPDOWN_ITEM_INNER_RADIUS
1853 )
1854 );
1855 }
1856
1857 #[test]
1858 fn the_card_list_treatment_caps_the_first_and_last_rows() {
1859 let a = anchor();
1860 let w = ready(&view(fruit(), &a));
1861 assert_eq!(
1862 w.row_radii(0),
1863 CornerRadii::new(
1864 DROPDOWN_ITEM_OUTER_RADIUS,
1865 DROPDOWN_ITEM_OUTER_RADIUS,
1866 DROPDOWN_ITEM_INNER_RADIUS,
1867 DROPDOWN_ITEM_INNER_RADIUS
1868 )
1869 );
1870 assert_eq!(
1871 w.row_radii(2),
1872 CornerRadii::new(
1873 DROPDOWN_ITEM_INNER_RADIUS,
1874 DROPDOWN_ITEM_INNER_RADIUS,
1875 DROPDOWN_ITEM_OUTER_RADIUS,
1876 DROPDOWN_ITEM_OUTER_RADIUS
1877 )
1878 );
1879 let single = ready(&view(vec![dropdown_item("Only", "only")], &a));
1881 assert_eq!(
1882 single.row_radii(0),
1883 CornerRadii::uniform(DROPDOWN_ITEM_OUTER_RADIUS)
1884 );
1885 }
1886
1887 #[test]
1888 fn hover_and_press_morph_the_inner_radius() {
1889 let a = anchor();
1890 let mut w = ready(&view(fruit(), &a));
1891 let mut state = AppState::default();
1892 let c = row_center(&w, 1);
1893 dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, c.x, c.y));
1894 assert_eq!(
1895 w.row_radii(1),
1896 CornerRadii::uniform(DROPDOWN_ITEM_HOVER_RADIUS)
1897 );
1898 dispatch(&mut w, &mut state, &pointer(PointerPhase::Down, c.x, c.y));
1899 assert_eq!(
1900 w.row_radii(1),
1901 CornerRadii::uniform(DROPDOWN_ITEM_PRESSED_RADIUS)
1902 );
1903 }
1904
1905 #[test]
1906 fn a_disabled_row_never_washes() {
1907 let a = anchor();
1908 let items = vec![
1909 dropdown_item("Apple", "apple"),
1910 dropdown_item("Banana", "banana").disabled(true),
1911 ];
1912 let mut w = ready(&view(items, &a));
1913 let mut state = AppState::default();
1914 let c = row_center(&w, 1);
1915 dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, c.x, c.y));
1916 assert_eq!(w.hovered, None, "a disabled row is never latched");
1917 }
1918
1919 #[test]
1920 fn a_paint_with_no_hover_link_clears_the_latched_row() {
1921 let a = anchor();
1922 let mut w = ready(&view(fruit(), &a));
1923 let mut state = AppState::default();
1924 let c = row_center(&w, 0);
1925 dispatch(&mut w, &mut state, &pointer(PointerPhase::Move, c.x, c.y));
1926 assert_eq!(w.hovered, Some(0));
1927 paint(&mut w);
1929 assert_eq!(w.hovered, None);
1930 }
1931
1932 #[test]
1933 fn every_painted_rect_stays_inside_the_panel_s_own_box() {
1934 let a = anchor();
1935 let mut w = ready(&view(fruit(), &a).selected(vec!["banana".to_string()]));
1936 let width = w.width();
1937 let height = w.height();
1938 let rec = paint(&mut w);
1939 for (origin, size, _, _) in rec.rrects {
1940 assert!(origin.x >= 0.0 && origin.x + size.width <= width + 1e-6);
1941 assert!(origin.y >= 0.0 && origin.y + size.height <= height + 1e-6);
1942 }
1943 }
1944
1945 #[test]
1946 fn a_rebuild_that_re_supplies_the_same_props_keeps_its_shaped_runs() {
1947 let a = anchor();
1948 let prev = view(fruit(), &a);
1949 let mut w = ready(&prev);
1950 let before: Vec<Size> = w.rows.iter().map(|row| row.label.size()).collect();
1951 let next = view(fruit(), &a);
1952 rebuild(&mut w, &prev, &next);
1953 let after: Vec<Size> = w.rows.iter().map(|row| row.label.size()).collect();
1954 assert_eq!(before, after);
1955 assert!(after.iter().all(|size| size.width > 0.0));
1956 }
1957
1958 #[test]
1959 fn the_semantics_tree_is_a_listbox_of_selectable_options() {
1960 use frust_core::RenderRoot;
1961 let a = anchor();
1962 let mut root: RenderRoot<AppState, DropdownPanelView<AppState>> = RenderRoot::new();
1963 let mut state = AppState::default();
1964 let mut tcx = TextContext::new();
1965 let cell = a.clone();
1966 let mut logic =
1967 move |_s: &mut AppState| view(fruit(), &cell).selected(vec!["banana".to_string()]);
1968 root.rebuild(&mut logic, &mut state);
1969 root.layout_with_text(AREA, &mut tcx as &mut dyn Any);
1970 let tree = root.semantics();
1971 assert!(
1972 tree.nodes
1973 .iter()
1974 .any(|(_, node)| node.role() == Role::ListBox)
1975 );
1976 let options: Vec<_> = tree
1977 .nodes
1978 .iter()
1979 .filter(|(_, node)| node.role() == Role::ListBoxOption)
1980 .collect();
1981 assert_eq!(options.len(), 3);
1982 assert_eq!(
1983 options
1984 .iter()
1985 .filter(|(_, n)| n.is_selected() == Some(true))
1986 .count(),
1987 1
1988 );
1989 }
1990}