1use std::rc::Rc;
25
26use frus_core::{BorderRadius, Color, Insets, Rect, Scene, ShapeBorder, TextStyle};
27use frus_layout::{Dimension, FlexDirection, Style};
28
29use crate::dropdown::{label_style, option_row, DropdownOption};
30use crate::flex::Flex;
31use crate::icons::Icons;
32use crate::interaction::Status;
33use crate::menu::{Panel, PanelKind, PanelStyle};
34use crate::portal::Placement;
35use crate::scroll::SingleChildScrollView;
36use crate::textinput::TextField;
37use crate::theme::Theme;
38use crate::widget::Widget;
39
40const DEFAULT_WIDTH: f32 = 240.0;
42const ROW_H: f32 = 40.0;
44
45fn matches(label: &str, query: &str) -> bool {
54 if query.trim().is_empty() {
55 return true;
56 }
57 label.to_lowercase().contains(&query.trim().to_lowercase())
58}
59
60pub struct DropdownMenu<Msg = crate::callback::Callback> {
77 query: String,
78 open: bool,
79 enabled: bool,
80 filter: bool,
81 width: f32,
82 selected: Option<usize>,
83 label: Option<String>,
84 placeholder: Option<String>,
85 max_visible: Option<usize>,
86 text_style: Option<TextStyle>,
87 look: PanelStyle,
89 on_input: Rc<dyn Fn(String) -> Msg>,
90 on_toggle: Msg,
91 options: Vec<DropdownOption<Msg>>,
92 on_select: Option<Rc<dyn Fn(usize) -> Msg>>,
93 children: Vec<Box<dyn Widget<Msg>>>,
95}
96
97impl<Msg: Clone + 'static> DropdownMenu<Msg> {
98 pub fn new(
106 query: impl Into<String>,
107 open: bool,
108 on_input: impl Fn(String) -> Msg + 'static,
109 on_toggle: Msg,
110 ) -> Self {
111 let mut menu = Self {
112 query: query.into(),
113 open,
114 enabled: true,
115 filter: true,
116 width: DEFAULT_WIDTH,
117 selected: None,
118 label: None,
119 placeholder: None,
120 max_visible: None,
121 text_style: None,
122 look: PanelStyle::default(),
123 on_input: Rc::new(on_input),
124 on_toggle,
125 options: Vec::new(),
126 on_select: None,
127 children: Vec::new(),
128 };
129 menu.rebuild();
130 menu
131 }
132
133 #[must_use]
136 pub fn label(mut self, label: impl Into<String>) -> Self {
137 self.label = Some(label.into());
138 self.rebuild();
139 self
140 }
141
142 #[must_use]
144 pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
145 self.placeholder = Some(placeholder.into());
146 self.rebuild();
147 self
148 }
149
150 #[must_use]
152 pub fn width(mut self, width: f32) -> Self {
153 self.width = width;
154 self.rebuild();
155 self
156 }
157
158 #[must_use]
161 pub fn selected(mut self, index: Option<usize>) -> Self {
162 self.selected = index;
163 self.rebuild();
164 self
165 }
166
167 #[must_use]
172 pub fn enabled(mut self, enabled: bool) -> Self {
173 self.enabled = enabled;
174 self.rebuild();
175 self
176 }
177
178 #[must_use]
188 pub fn filter(mut self, filter: bool) -> Self {
189 self.filter = filter;
190 self.rebuild();
191 self
192 }
193
194 #[must_use]
197 pub fn max_visible(mut self, rows: usize) -> Self {
198 self.max_visible = Some(rows.max(1));
199 self.rebuild();
200 self
201 }
202
203 #[must_use]
205 pub fn text_style(mut self, style: TextStyle) -> Self {
206 self.text_style = Some(style);
207 self.rebuild();
208 self
209 }
210
211 #[must_use]
220 pub fn menu_background(mut self, color: Color) -> Self {
221 self.look.background = Some(color);
222 self.rebuild();
223 self
224 }
225
226 #[must_use]
228 pub fn menu_shape(mut self, shape: ShapeBorder) -> Self {
229 self.look.shape = Some(shape);
230 self.rebuild();
231 self
232 }
233
234 #[must_use]
236 pub fn menu_radius(self, radius: impl Into<BorderRadius>) -> Self {
237 self.menu_shape(ShapeBorder::rounded(radius.into()))
238 }
239
240 #[must_use]
242 pub fn menu_elevation(mut self, elevation: f32) -> Self {
243 self.look.elevation = Some(elevation);
244 self.rebuild();
245 self
246 }
247
248 #[must_use]
251 pub fn menu_shadow_color(mut self, color: Color) -> Self {
252 self.look.shadow_color = Some(color);
253 self.rebuild();
254 self
255 }
256
257 #[must_use]
259 pub fn menu_padding(mut self, padding: Insets) -> Self {
260 self.look.padding = Some(padding);
261 self.rebuild();
262 self
263 }
264
265 #[must_use]
267 pub fn options(self, labels: &[&str], on_select: impl Fn(usize) -> Msg + 'static) -> Self {
268 let options = labels.iter().map(|s| DropdownOption::new(*s)).collect();
269 self.options_widgets(options, on_select)
270 }
271
272 #[must_use]
278 pub fn options_widgets(
279 mut self,
280 options: Vec<DropdownOption<Msg>>,
281 on_select: impl Fn(usize) -> Msg + 'static,
282 ) -> Self {
283 self.options = options;
284 self.on_select = Some(Rc::new(on_select));
285 self.rebuild();
286 self
287 }
288
289 fn display(&self) -> String {
298 if self.open && self.enabled && self.filter {
299 return self.query.clone();
300 }
301 self.selected
302 .and_then(|i| self.options.get(i))
303 .and_then(DropdownOption::label)
304 .unwrap_or_default()
305 .to_string()
306 }
307
308 fn showing(&self) -> Vec<usize> {
310 (0..self.options.len())
311 .filter(|&i| {
312 if !(self.filter && self.open) {
313 return true;
314 }
315 match self.options[i].label() {
316 Some(label) => matches(label, &self.query),
317 None => true,
322 }
323 })
324 .collect()
325 }
326
327 fn rebuild(&mut self) {
328 let on_input = self.on_input.clone();
329 let mut field = TextField::new(self.display())
330 .width(self.width)
331 .enabled(self.enabled)
332 .suffix_icon(if self.open && self.enabled {
335 Icons::ARROW_DROP_UP
336 } else {
337 Icons::ARROW_DROP_DOWN
338 })
339 .on_suffix(self.on_toggle.clone())
340 .on_input(move |text| on_input(text));
341 if let Some(label) = &self.label {
342 field = field.label(label.clone());
343 }
344 if let Some(placeholder) = &self.placeholder {
345 field = field.placeholder(placeholder.clone());
346 }
347 if let Some(size) = self.text_style.and_then(|style| style.size) {
348 field = field.size(size);
349 }
350 if !self.filter {
351 field = field.read_only();
354 }
355 self.children = vec![Box::new(field)];
356
357 if !self.open || !self.enabled {
358 return;
359 }
360 let showing = self.showing();
361 if showing.is_empty() {
362 return;
365 }
366 let mut list = Flex::column();
369 for &index in &showing {
370 let option = &self.options[index];
371 let on_click = self
372 .on_select
373 .as_ref()
374 .filter(|_| option.is_enabled())
375 .map(|f| f(index));
376 list = list.child(option_row(
377 option,
378 self.width,
379 self.selected == Some(index),
380 self.enabled,
381 self.text_style,
382 (PanelKind::Menu, self.look.background),
383 on_click,
384 ));
385 }
386 let rows = showing.len();
387 let content: Box<dyn Widget<Msg>> = match self.max_visible {
392 Some(n) if rows > n => {
393 let row = frus_text::line_box(ROW_H, &label_style(self.text_style, None), 0.0);
399 Box::new(
400 SingleChildScrollView::new()
401 .width(self.width)
402 .height(n as f32 * row)
403 .child(list),
404 )
405 }
406 _ => Box::new(list),
407 };
408 self.children.push(Box::new(Panel::new(
409 PanelKind::Menu,
410 self.look,
411 vec![content],
412 )));
413 }
414}
415
416impl<Msg: Clone> Widget<Msg> for DropdownMenu<Msg> {
417 fn style(&self) -> Style {
418 Style {
419 flex_direction: FlexDirection::Column,
420 width: Dimension::Length(self.width),
421 padding: Insets::ZERO,
422 ..Default::default()
423 }
424 }
425
426 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
427 &self.children
428 }
429
430 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
431
432 fn on_click(&self) -> Option<Msg> {
433 None
434 }
435
436 fn overlay(&self) -> Option<(&dyn Widget<Msg>, Placement)> {
437 self.children
438 .get(1)
439 .map(|list| (list.as_ref(), Placement::Below))
440 }
441
442 fn overlay_dismiss(&self) -> Option<Msg> {
443 (self.open && self.enabled).then(|| self.on_toggle.clone())
446 }
447
448 fn overlay_traps_focus(&self) -> bool {
449 self.open
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456 use crate::{build_ui, Container, Runtime, Size};
457 use frus_core::{Color, Point, Primitive};
458
459 #[derive(Clone, Debug, PartialEq)]
460 enum Msg {
461 Query(String),
462 Toggle,
463 Pick(usize),
464 }
465
466 fn texts(scene: &frus_core::Scene) -> Vec<String> {
468 fn walk(primitives: &[Primitive], out: &mut Vec<String>) {
469 for p in primitives {
470 match p {
471 Primitive::Text { text, .. } => out.push(text.clone()),
472 Primitive::Layer { primitives, .. } => walk(primitives, out),
473 _ => {}
474 }
475 }
476 }
477 let mut out = Vec::new();
478 walk(scene.primitives(), &mut out);
479 out
480 }
481
482 fn frame(menu: &DropdownMenu<Msg>) -> crate::Ui<Msg> {
483 build_ui(
484 menu,
485 Size::new(400.0, 500.0),
486 &Runtime::default(),
487 &Theme::default(),
488 )
489 }
490
491 fn colours() -> [&'static str; 4] {
492 ["Red", "Dark green", "Light green", "Blue"]
493 }
494
495 fn menu(query: &str, open: bool) -> DropdownMenu<Msg> {
496 DropdownMenu::new(query, open, Msg::Query, Msg::Toggle)
497 .selected(Some(1))
498 .options(&colours(), Msg::Pick)
499 }
500
501 #[test]
510 fn a_closed_field_shows_the_choice_and_not_the_query() {
511 let shut = frame(&menu("gre", false));
512 let drawn = texts(shut.scene());
513 assert!(
514 drawn.iter().any(|t| t == "Dark green"),
515 "the selected choice: {drawn:?}"
516 );
517 assert!(
518 !drawn.iter().any(|t| t == "gre"),
519 "and not the half-typed query: {drawn:?}"
520 );
521
522 let open = frame(&menu("gre", true));
523 assert!(
524 texts(open.scene()).iter().any(|t| t == "gre"),
525 "which is the only thing an open one shows"
526 );
527 }
528
529 #[test]
534 fn an_open_menu_shows_its_options_wherever_it_sits() {
535 let options = |root: &dyn Widget<Msg>| {
536 let ui = build_ui(
537 root,
538 Size::new(400.0, 500.0),
539 &Runtime::default(),
540 &Theme::default(),
541 );
542 texts(ui.scene())
543 .into_iter()
544 .filter(|t| colours().contains(&t.as_str()))
545 .count()
546 };
547 let at_root = options(&menu("", true));
548 assert_eq!(at_root, 4);
549 let measured = crate::UnconstrainedBox::new(menu("", true));
550 assert_eq!(options(&measured), at_root, "in an unconstrained box");
551 let built = crate::LayoutBuilder::new(|_| menu("", true));
552 assert_eq!(options(&built), at_root, "in a layout builder");
553 let listed = crate::ListView::new(3, 80.0, |_| menu("", true)).height(400.0);
554 assert!(options(&listed) >= at_root, "in a list's row");
555 }
556
557 #[test]
559 fn nothing_selected_shows_the_placeholder() {
560 let none = DropdownMenu::new("", false, Msg::Query, Msg::Toggle)
561 .placeholder("Choose a colour")
562 .options(&colours(), Msg::Pick);
563 assert!(texts(frame(&none).scene())
564 .iter()
565 .any(|t| t == "Choose a colour"));
566 }
567
568 #[test]
573 fn the_list_filters_on_a_substring_and_not_a_prefix() {
574 let shown = |query: &str| {
575 let ui = frame(&menu(query, true));
576 texts(ui.scene())
577 .into_iter()
578 .filter(|t| colours().contains(&t.as_str()))
579 .count()
580 };
581 assert_eq!(shown(""), 4, "an empty query is not a filter yet");
582 assert_eq!(shown("GREEN"), 2, "case-insensitive, and matching inside");
583 assert_eq!(shown("blu"), 1);
584 }
585
586 #[test]
593 fn the_message_carries_the_index_in_the_callers_own_list() {
594 let ui = frame(&menu("green", true));
595 let row = |n: f32| {
597 ui.hit(Point::new(120.0, 56.0 + 8.0 + ROW_H * (n + 0.5)))
598 .and_then(|id| ui.msg_for(id))
599 };
600 assert_eq!(
604 row(0.0),
605 Some(Msg::Pick(1)),
606 "`Dark green` is the second colour"
607 );
608 assert_eq!(
609 row(1.0),
610 Some(Msg::Pick(2)),
611 "and the second row of the filtered list is the third"
612 );
613 }
614
615 #[test]
618 fn a_query_that_matches_nothing_floats_no_panel() {
619 let empty = menu("zzz", true);
620 assert!(Widget::<Msg>::overlay(&empty).is_none());
621 assert!(Widget::<Msg>::overlay(&menu("green", true)).is_some());
622 }
623
624 #[test]
628 fn filtering_off_leaves_the_choice_in_the_field() {
629 let unfiltered = menu("gre", true).filter(false);
630 let drawn = texts(frame(&unfiltered).scene());
631 assert!(drawn.iter().any(|t| t == "Dark green"));
632 assert!(!drawn.iter().any(|t| t == "gre"));
633 assert_eq!(
634 texts(frame(&unfiltered).scene())
635 .into_iter()
636 .filter(|t| colours().contains(&t.as_str()))
637 .count(),
638 5,
639 "all four choices, plus the one in the field"
640 );
641 }
642
643 #[test]
645 fn disabled_is_never_open() {
646 let off = menu("", true).enabled(false);
647 assert!(Widget::<Msg>::overlay(&off).is_none());
648 assert_eq!(Widget::<Msg>::overlay_dismiss(&off), None);
649 }
650
651 #[test]
654 fn a_press_outside_shuts_it() {
655 assert_eq!(
656 Widget::<Msg>::overlay_dismiss(&menu("", true)),
657 Some(Msg::Toggle)
658 );
659 }
660
661 #[test]
665 fn a_choice_with_no_words_survives_the_filter() {
666 let swatch = |c: Color| Container::<Msg>::new().width(12.0).height(12.0).color(c);
667 let menu = DropdownMenu::new("blu", true, Msg::Query, Msg::Toggle).options_widgets(
668 vec![
669 DropdownOption::new("Red"),
670 DropdownOption::widget(swatch(Color::rgb(0.0, 0.0, 1.0))),
671 DropdownOption::new("Blue"),
672 ],
673 Msg::Pick,
674 );
675 let ui = frame(&menu);
676 let painted = texts(ui.scene());
677 assert!(!painted.iter().any(|t| t == "Red"), "filtered out");
678 assert!(painted.iter().any(|t| t == "Blue"), "kept");
679 let swatches = count_colour(ui.scene(), Color::rgb(0.0, 0.0, 1.0));
680 assert_eq!(swatches, 1, "and the one with no words is still there");
681 }
682
683 use crate::menu::probe::{blurred, colour_of, crisp, height_of, Painted};
684
685 fn row_height() -> f32 {
687 frus_text::line_box(ROW_H, &label_style(None, Some(&Theme::default())), 0.0)
688 }
689
690 fn below_field(scene: &frus_core::Scene) -> Vec<Painted> {
693 crisp(scene)
694 .into_iter()
695 .filter(|r| r.rect.y >= 56.0)
696 .collect()
697 }
698
699 fn themed_frame(menu: &DropdownMenu<Msg>, theme: &Theme) -> crate::Ui<Msg> {
700 build_ui(menu, Size::new(400.0, 500.0), &Runtime::default(), theme)
701 }
702
703 #[test]
710 fn the_choices_float_on_one_panel_and_draw_no_box_of_their_own() {
711 let theme = Theme::default();
712 let ui = frame(&menu("", true));
713 let painted = below_field(ui.scene());
714 assert_eq!(
715 painted.len(),
716 2,
717 "the panel and the selection: {painted:#?}"
718 );
719 let (panel, selection) = (painted[0], painted[1]);
720 assert_eq!(panel.color, theme.scheme.surface_container);
721 assert!(panel.radius != frus_core::BorderRadius::ZERO);
722 assert_eq!(panel.rect.width, DEFAULT_WIDTH);
723 assert_eq!(panel.rect.height, 4.0 * row_height() + 16.0);
724 assert!(
725 painted.iter().all(|r| r.border_width == 0.0),
726 "no choice is outlined: {painted:#?}"
727 );
728 assert_eq!(
729 selection.color,
730 theme.scheme.surface_container.lerp(theme.primary, 0.14)
731 );
732 assert_eq!(
733 selection.rect.y,
734 panel.rect.y + 8.0 + row_height(),
735 "the second choice, on the panel"
736 );
737 assert_eq!(
738 selection.radius,
739 frus_core::BorderRadius::ZERO,
740 "a strip, not a box"
741 );
742 }
743
744 #[test]
747 fn the_panel_casts_a_shadow_three_high() {
748 let theme = Theme::default();
749 let ui = frame(&menu("", true));
750 let panel = below_field(ui.scene())[0];
751 let shadows = blurred(ui.scene());
752 assert_eq!(shadows.len(), 3, "{shadows:#?}");
753 assert_eq!(height_of(ui.scene()), 3.0);
754 assert_eq!(
755 shadows[1].rect.y + shadows[1].rect.height * 0.5,
756 panel.rect.y + panel.rect.height * 0.5 + 3.0,
757 "the second shadow drops by the height"
758 );
759 assert_eq!(colour_of(ui.scene()), Some(theme.scheme.shadow));
760 assert!(
761 blurred(frame(&menu("", false)).scene()).is_empty(),
762 "shut, none"
763 );
764 }
765
766 #[test]
769 fn the_panel_answers_to_the_menu_theme_and_to_its_caller() {
770 let mut theme = Theme::default();
771 let (surface, shade) = (Color::rgb(0.2, 0.4, 0.6), Color::rgba(0.0, 0.0, 0.5, 0.5));
772 theme.widgets.menu.background = Some(surface);
773 theme.widgets.menu.radius = Some(3.0);
774 theme.widgets.menu.elevation = Some(1.0);
775 theme.widgets.menu.shadow_color = Some(shade);
776 theme.widgets.menu.padding = Some(Insets::new(20.0, 0.0, 20.0, 0.0));
777 let ui = themed_frame(&menu("", true), &theme);
778 let painted = below_field(ui.scene());
779 let panel = *painted
780 .iter()
781 .find(|r| r.color == surface)
782 .expect("the theme's");
783 assert_eq!(panel.radius, frus_core::BorderRadius::uniform(3.0));
784 assert_eq!(panel.rect.height, 4.0 * row_height() + 40.0);
785 assert!(painted
786 .iter()
787 .any(|r| r.color == surface.lerp(theme.primary, 0.14)));
788 assert_eq!(
789 (height_of(ui.scene()), colour_of(ui.scene())),
790 (1.0, Some(shade))
791 );
792
793 let (own, own_shade) = (Color::rgb(0.9, 0.9, 0.1), Color::rgba(0.5, 0.0, 0.0, 0.4));
794 let told = menu("", true)
795 .menu_background(own)
796 .menu_radius(9.0)
797 .menu_elevation(2.0)
798 .menu_shadow_color(own_shade)
799 .menu_padding(Insets::ZERO);
800 let ui = themed_frame(&told, &theme);
801 let panel = *below_field(ui.scene())
802 .iter()
803 .find(|r| r.color == own)
804 .expect("the caller's");
805 assert_eq!(panel.radius, frus_core::BorderRadius::uniform(9.0));
806 assert_eq!(panel.rect.height, 4.0 * row_height());
807 assert_eq!(
808 (height_of(ui.scene()), colour_of(ui.scene())),
809 (2.0, Some(own_shade))
810 );
811 }
812
813 #[test]
816 fn a_transparent_shadow_casts_nothing() {
817 let clear = menu("", true).menu_shadow_color(Color::TRANSPARENT);
818 assert!(blurred(frame(&clear).scene()).is_empty());
819 let mut theme = Theme::default();
820 theme.widgets.menu.shadow_color = Some(Color::TRANSPARENT);
821 assert!(blurred(themed_frame(&menu("", true), &theme).scene()).is_empty());
822 }
823
824 #[test]
830 fn a_long_list_scrolls_and_clips_inside_the_panel() {
831 let theme = Theme::default();
832 let ui = frame(&menu("", true).max_visible(2));
833 let painted = below_field(ui.scene());
834 let panel = *painted
835 .iter()
836 .find(|r| r.color == theme.scheme.surface_container)
837 .expect("a panel");
838 assert_eq!(panel.rect.height, 2.0 * row_height() + 16.0);
839 let window = Rect::new(0.0, 0.0, 400.0, 500.0);
840 assert_eq!(
841 panel.clip, window,
842 "the panel is clipped by nothing but the window"
843 );
844 assert_eq!(
845 blurred(ui.scene())[0].clip,
846 window,
847 "and neither is its shadow"
848 );
849
850 let selected = *painted
852 .iter()
853 .find(|r| r.color == theme.scheme.surface_container.lerp(theme.primary, 0.14))
854 .expect("the selected choice is drawn");
855 let clip = selected.clip;
856 assert_eq!(
857 clip.y,
858 panel.rect.y + 8.0,
859 "the viewport starts inside the room"
860 );
861 assert_eq!(clip.height, 2.0 * row_height(), "and shows two rows");
862 assert!(clip.x >= panel.rect.x && clip.x + clip.width <= panel.rect.x + panel.rect.width);
863
864 fn text_at(primitives: &[Primitive], words: &str) -> Option<Point> {
865 primitives.iter().find_map(|p| match p {
866 Primitive::Text { position, text, .. } if text == words => Some(*position),
867 Primitive::Layer { primitives, .. } => text_at(primitives, words),
868 _ => None,
869 })
870 }
871 let blue = text_at(ui.scene().primitives(), "Blue").expect("the last choice is built");
872 assert!(
873 blue.y >= clip.y + clip.height,
874 "the last choice is past the fold, so there is something to scroll to: {blue:?}"
875 );
876 }
877
878 fn count_colour(scene: &frus_core::Scene, wanted: Color) -> usize {
879 fn walk(primitives: &[Primitive], wanted: Color) -> usize {
880 primitives
881 .iter()
882 .map(|p| match p {
883 Primitive::Rect { color, .. } if *color == wanted => 1,
884 Primitive::Layer { primitives, .. } => walk(primitives, wanted),
885 _ => 0,
886 })
887 .sum()
888 }
889 walk(scene.primitives(), wanted)
890 }
891}