1use std::collections::BTreeSet;
177use std::rc::Rc;
178
179use frust::authoring::text::{FontWeight, LineHeight};
180use frust::authoring::{Action, Role};
181use frust::authoring::{
182 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, ErasedArgCallback, EventCtx,
183 EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx,
184 ThemeTextColor, ThemeTextType, View, Widget, any,
185};
186use frust::input::TOUCH_SLOP;
187use frust::{FrameTime, Theme, icon, text};
188use kurbo::{Point, Size};
189use peniko::Color;
190
191use crate::icon_button::icon_button;
192use crate::press::presses;
193
194const APP_BAR_HEIGHT: f64 = 64.0;
200const APP_BAR_PAD_X: f64 = 4.0;
203const APP_BAR_GAP: f64 = 4.0;
206const ACTION_SLOT: f64 = 48.0;
212const SELECT_ALL_HEIGHT: f64 = 48.0;
214
215const COUNT_SIZE: f32 = 22.0;
219const COUNT_LINE_HEIGHT: f32 = 28.0;
220const COUNT_WEIGHT: FontWeight = FontWeight::MEDIUM;
221
222const CONTEXTUAL_BACKGROUND: Color = Color::from_rgb8(0xEA, 0xDD, 0xFF);
225
226const CLEAR_SELECTION_LABEL: &str = "Clear selection";
230
231const LONG_PRESS_MS: f64 = 500.0;
237
238pub fn is_selection_mode(selected: &BTreeSet<usize>) -> bool {
243 !selected.is_empty()
244}
245
246pub fn is_selected(selected: &BTreeSet<usize>, index: usize) -> bool {
248 selected.contains(&index)
249}
250
251pub fn all_selected_for(selected: &BTreeSet<usize>, item_count: usize) -> Option<bool> {
256 if item_count == 0 || selected.is_empty() {
257 return Some(false);
258 }
259 if selected.len() >= item_count {
260 return Some(true);
261 }
262 None
263}
264
265pub fn select(selected: &BTreeSet<usize>, index: usize) -> BTreeSet<usize> {
268 let mut next = selected.clone();
269 next.insert(index);
270 next
271}
272
273pub fn deselect(selected: &BTreeSet<usize>, index: usize) -> BTreeSet<usize> {
276 let mut next = selected.clone();
277 next.remove(&index);
278 next
279}
280
281pub fn toggle(selected: &BTreeSet<usize>, index: usize) -> BTreeSet<usize> {
284 let mut next = selected.clone();
285 if !next.remove(&index) {
286 next.insert(index);
287 }
288 next
289}
290
291pub fn select_all(item_count: usize) -> BTreeSet<usize> {
294 (0..item_count).collect()
295}
296
297pub fn cleared() -> BTreeSet<usize> {
299 BTreeSet::new()
300}
301
302type OnIndex<State> = Rc<dyn Fn(&mut State, usize)>;
305
306pub struct SelectionHostView<State: 'static> {
308 items: Vec<AnyView<State>>,
309 selected: BTreeSet<usize>,
310 enabled: bool,
311 on_toggle: OnIndex<State>,
312 on_activate: Option<OnIndex<State>>,
313}
314
315pub fn selection_host<State: 'static, F>(
321 items: impl IntoIterator<Item = AnyView<State>>,
322 selected: &BTreeSet<usize>,
323 on_toggle: F,
324) -> SelectionHostView<State>
325where
326 F: Fn(&mut State, usize) + 'static,
327{
328 SelectionHostView {
329 items: items.into_iter().collect(),
330 selected: selected.clone(),
331 enabled: true,
332 on_toggle: Rc::new(on_toggle),
333 on_activate: None,
334 }
335}
336
337impl<State: 'static> SelectionHostView<State> {
338 pub fn on_activate<F: Fn(&mut State, usize) + 'static>(mut self, on_activate: F) -> Self {
342 self.on_activate = Some(Rc::new(on_activate));
343 self
344 }
345
346 pub fn enabled(mut self, enabled: bool) -> Self {
349 self.enabled = enabled;
350 self
351 }
352}
353
354#[derive(Debug, Default, Clone, Copy)]
357struct LongPressState {
358 press_start: Option<FrameTime>,
361 elapsed: bool,
364 fired: bool,
367}
368
369impl LongPressState {
370 fn clear(&mut self) {
371 *self = Self::default();
372 }
373}
374
375pub struct SelectionHostWidget {
378 items: Vec<ChildPod>,
379 selected: BTreeSet<usize>,
380 enabled: bool,
381 rows: Vec<(f64, f64)>,
384 width: f64,
385 armed: Option<usize>,
388 down_pos: Point,
391 long_press: LongPressState,
392 on_toggle: ErasedArgCallback<usize>,
393 on_activate: Option<ErasedArgCallback<usize>>,
394}
395
396impl<State: 'static> View<State> for SelectionHostView<State> {
397 type Element = SelectionHostWidget;
398
399 fn build(&self, ctx: &mut BuildCtx<'_>) -> SelectionHostWidget {
400 let items = self
401 .items
402 .iter()
403 .map(|view| frust::authoring::build_child(view, ctx))
404 .collect();
405 SelectionHostWidget {
406 items,
407 selected: self.selected.clone(),
408 enabled: self.enabled,
409 rows: Vec::new(),
410 width: 0.0,
411 armed: None,
412 down_pos: Point::ZERO,
413 long_press: LongPressState::default(),
414 on_toggle: frust::authoring::erase_callback_arg(&self.on_toggle),
415 on_activate: self
416 .on_activate
417 .as_ref()
418 .map(frust::authoring::erase_callback_arg),
419 }
420 }
421
422 fn rebuild(
423 &self,
424 prev: &Self,
425 element: &mut SelectionHostWidget,
426 ctx: &mut BuildCtx<'_>,
427 ) -> ChangeFlags {
428 let mut flags = frust::authoring::rebuild_children(
429 &prev.items,
430 &self.items,
431 &mut element.items,
432 ctx,
433 |view| view,
434 |_| None,
435 );
436
437 if prev.items.len() != self.items.len() {
438 element.armed = None;
441 element.long_press.clear();
442 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
443 }
444
445 if element.selected != self.selected {
446 element.selected = self.selected.clone();
447 flags |= ChangeFlags::PAINT;
448 }
449
450 if element.enabled != self.enabled {
451 element.enabled = self.enabled;
452 if !self.enabled {
453 element.armed = None;
454 element.long_press.clear();
455 }
456 flags |= ChangeFlags::PAINT;
457 }
458
459 element.on_toggle = frust::authoring::erase_callback_arg(&self.on_toggle);
460 element.on_activate = self
461 .on_activate
462 .as_ref()
463 .map(frust::authoring::erase_callback_arg);
464 flags
465 }
466
467 fn teardown(&self, element: &mut SelectionHostWidget, ctx: &mut BuildCtx<'_>) {
468 for (view, pod) in self.items.iter().zip(element.items.iter_mut()) {
469 frust::authoring::teardown_child(view, pod, ctx);
470 }
471 }
472}
473
474impl SelectionHostWidget {
475 fn row_at(&self, pos: Point) -> Option<usize> {
478 if pos.x < 0.0 || pos.x >= self.width {
479 return None;
480 }
481 self.rows
482 .iter()
483 .position(|(y, height)| pos.y >= *y && pos.y < y + height)
484 }
485
486 fn clear_press(&mut self) {
489 self.armed = None;
490 self.long_press.clear();
491 }
492
493 fn fire_long_press(&mut self, ctx: &mut EventCtx, index: usize) {
498 if !self.selected.contains(&index) {
499 (self.on_toggle)(ctx, index);
500 }
501 }
502
503 fn fire_tap(&mut self, ctx: &mut EventCtx, index: usize) {
506 if is_selection_mode(&self.selected) {
507 (self.on_toggle)(ctx, index);
508 } else if let Some(on_activate) = self.on_activate.as_mut() {
509 on_activate(ctx, index);
510 }
511 }
512}
513
514impl Widget for SelectionHostWidget {
515 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
516 let width = if bc.max().width.is_finite() {
517 bc.max().width
518 } else {
519 0.0
520 };
521 self.width = width;
522 let max_height = bc.max().height;
523 let child_bc = BoxConstraints::new(Size::new(width, 0.0), Size::new(width, max_height));
524
525 self.rows.clear();
526 let mut y = 0.0;
527 for pod in self.items.iter_mut() {
528 let size = pod.layout_child(ctx, &child_bc);
529 pod.set_origin(Point::new(0.0, y));
530 self.rows.push((y, size.height));
531 y += size.height;
532 }
533
534 bc.constrain(Size::new(width, y))
535 }
536
537 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
538 for pod in &mut self.items {
539 pod.paint_child(ctx, scene);
540 }
541
542 if self.enabled && self.armed.is_some() && !self.long_press.elapsed {
545 let start = *self.long_press.press_start.get_or_insert(ctx.frame_time());
546 let elapsed_ms = ctx.frame_time().saturating_sub(start).as_secs_f64() * 1000.0;
547 if elapsed_ms >= LONG_PRESS_MS {
548 self.long_press.elapsed = true;
549 } else {
550 ctx.request_frame();
551 }
552 }
553 }
554
555 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
556 let InputEvent::Pointer(p) = event else {
557 return frust::authoring::route_event(&mut self.items, ctx, event);
558 };
559 if !self.enabled {
560 return EventResult::Ignored;
564 }
565 match p.phase {
566 PointerPhase::Down => {
567 if !presses(p) {
568 return EventResult::Ignored;
569 }
570 let Some(index) = self.row_at(p.position) else {
571 return EventResult::Ignored;
573 };
574 self.armed = Some(index);
575 self.down_pos = p.position;
576 self.long_press.clear();
577 ctx.capture_pointer();
578 ctx.request_redraw();
579 EventResult::Handled
580 }
581 PointerPhase::Move => {
582 let Some(armed) = self.armed else {
583 return EventResult::Ignored;
587 };
588 if (p.position - self.down_pos).hypot() > TOUCH_SLOP {
591 self.long_press.clear();
592 } else if self.long_press.elapsed && !self.long_press.fired {
593 self.long_press.fired = true;
594 self.fire_long_press(ctx, armed);
595 ctx.request_redraw();
596 }
597 EventResult::Handled
598 }
599 PointerPhase::Up => {
600 let Some(armed) = self.armed else {
601 return EventResult::Ignored;
602 };
603 if self.long_press.elapsed {
607 if !self.long_press.fired {
608 self.long_press.fired = true;
609 self.fire_long_press(ctx, armed);
610 }
611 } else if self.row_at(p.position) == Some(armed) {
612 self.fire_tap(ctx, armed);
613 }
614 self.clear_press();
615 ctx.request_redraw();
616 EventResult::Handled
617 }
618 PointerPhase::Cancel => {
619 if self.armed.is_none() {
620 return EventResult::Ignored;
621 }
622 self.clear_press();
623 ctx.request_redraw();
624 EventResult::Handled
625 }
626 }
627 }
628
629 fn semantics(&self, ctx: &mut SemanticsCtx) {
630 ctx.push_container(
631 Role::List,
632 |_node| {},
633 |ctx| {
634 for pod in &self.items {
635 pod.semantics_child(ctx);
636 }
637 },
638 );
639 }
640
641 frust::authoring::visit_children!(items);
642}
643
644type OnClear<State> = Rc<dyn Fn(&mut State)>;
647type OnAllSelected<State> = Rc<dyn Fn(&mut State, bool)>;
648
649pub struct SelectionAppBarView<State: 'static> {
653 idle: AnyView<State>,
654 selected: BTreeSet<usize>,
655 item_count: usize,
656 actions: Vec<AnyView<State>>,
657 show_select_all: bool,
658 select_all_label: String,
659 on_clear: OnClear<State>,
660 on_all_selected: Option<OnAllSelected<State>>,
661}
662
663pub fn selection_app_bar<State: 'static, F>(
670 idle: AnyView<State>,
671 selected: &BTreeSet<usize>,
672 item_count: usize,
673 on_clear: F,
674) -> SelectionAppBarView<State>
675where
676 F: Fn(&mut State) + 'static,
677{
678 SelectionAppBarView {
679 idle,
680 selected: selected.clone(),
681 item_count,
682 actions: Vec::new(),
683 show_select_all: true,
684 select_all_label: "Select all".to_string(),
685 on_clear: Rc::new(on_clear),
686 on_all_selected: None,
687 }
688}
689
690impl<State: 'static> SelectionAppBarView<State> {
691 pub fn actions(mut self, actions: Vec<AnyView<State>>) -> Self {
694 self.actions = actions;
695 self
696 }
697
698 pub fn show_select_all(mut self, show: bool) -> Self {
701 self.show_select_all = show;
702 self
703 }
704
705 pub fn select_all_label(mut self, label: impl Into<String>) -> Self {
707 self.select_all_label = label.into();
708 self
709 }
710
711 pub fn on_all_selected<F: Fn(&mut State, bool) + 'static>(
716 mut self,
717 on_all_selected: F,
718 ) -> Self {
719 self.on_all_selected = Some(Rc::new(on_all_selected));
720 self
721 }
722}
723
724impl<State: 'static> SelectionAppBarView<State> {
725 fn shape(&self) -> (usize, bool) {
728 (self.actions.len(), self.show_select_all)
729 }
730}
731
732fn close_view<State: 'static>(on_clear: OnClear<State>) -> AnyView<State> {
736 any(
737 icon_button(any(icon(crate::icons::CLOSE)), move |state: &mut State| {
738 (on_clear)(state);
739 })
740 .semantic_label(CLEAR_SELECTION_LABEL),
741 )
742}
743
744fn count_view<State: 'static>(count: usize) -> AnyView<State> {
748 any::<State, _>(
749 text(count.to_string())
750 .size(COUNT_SIZE)
751 .weight(COUNT_WEIGHT)
752 .line_height(LineHeight::Absolute(COUNT_LINE_HEIGHT))
753 .themed_role(ThemeTextColor::OnPrimaryContainer)
754 .themed_family(ThemeTextType::TitleLargeEmphasized),
755 )
756}
757
758fn select_all_checkbox_view<State: 'static>(
763 selected: BTreeSet<usize>,
764 item_count: usize,
765 on_all_selected: Option<OnAllSelected<State>>,
766) -> AnyView<State> {
767 let value = all_selected_for(&selected, item_count);
768 any(crate::checkbox::tristate_checkbox::<State, _>(
769 value,
770 move |state: &mut State, _next: Option<bool>| {
771 if let Some(cb) = &on_all_selected {
772 cb(state, !matches!(value, Some(true)));
773 }
774 },
775 ))
776}
777
778fn select_all_label_view<State: 'static>(label: String) -> AnyView<State> {
781 any::<State, _>(
782 text(label)
783 .themed_role(ThemeTextColor::OnPrimaryContainer)
784 .themed_family(ThemeTextType::BodyLarge),
785 )
786}
787
788fn resolve_contextual_background(theme: Option<&Theme>) -> Color {
791 match theme {
792 Some(theme) => theme.scheme().primary_container,
793 None => CONTEXTUAL_BACKGROUND,
794 }
795}
796
797#[derive(Clone, Copy)]
800struct ContextualSlots {
801 close: usize,
802 count_text: usize,
803 actions_start: usize,
804 actions_len: usize,
805 select_all: Option<(usize, usize)>,
808}
809
810fn build_contextual_children<State: 'static>(
813 view: &SelectionAppBarView<State>,
814 ctx: &mut BuildCtx<'_>,
815) -> (Vec<ChildPod>, ContextualSlots) {
816 let mut children = Vec::new();
817
818 let close_v = close_view::<State>(view.on_clear.clone());
819 children.push(frust::authoring::build_child(&close_v, ctx));
820 let close = 0;
821
822 let count_v = count_view::<State>(view.selected.len());
823 children.push(frust::authoring::build_child(&count_v, ctx));
824 let count_text = 1;
825
826 let actions_start = children.len();
827 for action in &view.actions {
828 children.push(frust::authoring::build_child(action, ctx));
829 }
830 let actions_len = view.actions.len();
831
832 let select_all = view.show_select_all.then(|| {
833 let cb = select_all_checkbox_view::<State>(
834 view.selected.clone(),
835 view.item_count,
836 view.on_all_selected.clone(),
837 );
838 let lbl = select_all_label_view::<State>(view.select_all_label.clone());
839 let cb_idx = children.len();
840 children.push(frust::authoring::build_child(&cb, ctx));
841 let lbl_idx = children.len();
842 children.push(frust::authoring::build_child(&lbl, ctx));
843 (cb_idx, lbl_idx)
844 });
845
846 (
847 children,
848 ContextualSlots {
849 close,
850 count_text,
851 actions_start,
852 actions_len,
853 select_all,
854 },
855 )
856}
857
858fn teardown_contextual_children<State: 'static>(
862 view: &SelectionAppBarView<State>,
863 slots: ContextualSlots,
864 children: &mut [ChildPod],
865 ctx: &mut BuildCtx<'_>,
866) {
867 let close_v = close_view::<State>(view.on_clear.clone());
868 frust::authoring::teardown_child(&close_v, &mut children[slots.close], ctx);
869
870 let count_v = count_view::<State>(view.selected.len());
871 frust::authoring::teardown_child(&count_v, &mut children[slots.count_text], ctx);
872
873 for i in 0..slots.actions_len {
874 frust::authoring::teardown_child(
875 &view.actions[i],
876 &mut children[slots.actions_start + i],
877 ctx,
878 );
879 }
880
881 if let Some((cb_idx, lbl_idx)) = slots.select_all {
882 let cb = select_all_checkbox_view::<State>(
883 view.selected.clone(),
884 view.item_count,
885 view.on_all_selected.clone(),
886 );
887 frust::authoring::teardown_child(&cb, &mut children[cb_idx], ctx);
888 let lbl = select_all_label_view::<State>(view.select_all_label.clone());
889 frust::authoring::teardown_child(&lbl, &mut children[lbl_idx], ctx);
890 }
891}
892
893pub struct SelectionAppBarWidget {
896 idle: ChildPod,
897 contextual: Vec<ChildPod>,
900 contextual_slots: ContextualSlots,
901 selected: BTreeSet<usize>,
902 item_count: usize,
903 show_select_all: bool,
904 select_all_label: String,
905}
906
907impl<State: 'static> View<State> for SelectionAppBarView<State> {
908 type Element = SelectionAppBarWidget;
909
910 fn build(&self, ctx: &mut BuildCtx<'_>) -> SelectionAppBarWidget {
911 let (contextual, contextual_slots) = build_contextual_children(self, ctx);
912 SelectionAppBarWidget {
913 idle: frust::authoring::build_child(&self.idle, ctx),
914 contextual,
915 contextual_slots,
916 selected: self.selected.clone(),
917 item_count: self.item_count,
918 show_select_all: self.show_select_all,
919 select_all_label: self.select_all_label.clone(),
920 }
921 }
922
923 fn rebuild(
924 &self,
925 prev: &Self,
926 element: &mut SelectionAppBarWidget,
927 ctx: &mut BuildCtx<'_>,
928 ) -> ChangeFlags {
929 let mut flags =
930 frust::authoring::rebuild_child(&prev.idle, &self.idle, &mut element.idle, ctx);
931
932 if prev.shape() != self.shape() {
933 teardown_contextual_children(
934 prev,
935 element.contextual_slots,
936 &mut element.contextual,
937 ctx,
938 );
939 let (children, slots) = build_contextual_children(self, ctx);
940 element.contextual = children;
941 element.contextual_slots = slots;
942 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
943 } else {
944 let slots = element.contextual_slots;
945
946 let prev_close = close_view::<State>(prev.on_clear.clone());
947 let next_close = close_view::<State>(self.on_clear.clone());
948 flags |= frust::authoring::rebuild_child(
949 &prev_close,
950 &next_close,
951 &mut element.contextual[slots.close],
952 ctx,
953 );
954
955 let prev_count = count_view::<State>(prev.selected.len());
956 let next_count = count_view::<State>(self.selected.len());
957 flags |= frust::authoring::rebuild_child(
958 &prev_count,
959 &next_count,
960 &mut element.contextual[slots.count_text],
961 ctx,
962 );
963
964 for i in 0..slots.actions_len {
965 flags |= frust::authoring::rebuild_child(
966 &prev.actions[i],
967 &self.actions[i],
968 &mut element.contextual[slots.actions_start + i],
969 ctx,
970 );
971 }
972
973 if let Some((cb_idx, lbl_idx)) = slots.select_all {
974 let prev_cb = select_all_checkbox_view::<State>(
975 prev.selected.clone(),
976 prev.item_count,
977 prev.on_all_selected.clone(),
978 );
979 let next_cb = select_all_checkbox_view::<State>(
980 self.selected.clone(),
981 self.item_count,
982 self.on_all_selected.clone(),
983 );
984 flags |= frust::authoring::rebuild_child(
985 &prev_cb,
986 &next_cb,
987 &mut element.contextual[cb_idx],
988 ctx,
989 );
990
991 let prev_lbl = select_all_label_view::<State>(prev.select_all_label.clone());
992 let next_lbl = select_all_label_view::<State>(self.select_all_label.clone());
993 flags |= frust::authoring::rebuild_child(
994 &prev_lbl,
995 &next_lbl,
996 &mut element.contextual[lbl_idx],
997 ctx,
998 );
999 }
1000 }
1001
1002 if element.selected != self.selected {
1003 element.selected = self.selected.clone();
1004 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1006 }
1007 element.item_count = self.item_count;
1008 element.show_select_all = self.show_select_all;
1009 element.select_all_label = self.select_all_label.clone();
1010 flags
1011 }
1012
1013 fn teardown(&self, element: &mut SelectionAppBarWidget, ctx: &mut BuildCtx<'_>) {
1014 frust::authoring::teardown_child(&self.idle, &mut element.idle, ctx);
1015 teardown_contextual_children(self, element.contextual_slots, &mut element.contextual, ctx);
1016 }
1017}
1018
1019impl Widget for SelectionAppBarWidget {
1020 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1021 let width = if bc.max().width.is_finite() {
1022 bc.max().width
1023 } else {
1024 0.0
1025 };
1026
1027 if !is_selection_mode(&self.selected) {
1028 let idle_size = self
1029 .idle
1030 .layout_child(ctx, &BoxConstraints::loose(Size::new(width, f64::INFINITY)));
1031 self.idle.set_origin(Point::ZERO);
1032 return bc.constrain(Size::new(width, idle_size.height));
1033 }
1034
1035 let slots = self.contextual_slots;
1036 let slot_bc = BoxConstraints::loose(Size::new(f64::INFINITY, APP_BAR_HEIGHT));
1037
1038 let close_size = self.contextual[slots.close].layout_child(ctx, &slot_bc);
1039 self.contextual[slots.close].set_origin(Point::new(
1040 APP_BAR_PAD_X + (ACTION_SLOT - close_size.width).max(0.0) / 2.0,
1041 (APP_BAR_HEIGHT - close_size.height) / 2.0,
1042 ));
1043
1044 let mut right = width - APP_BAR_PAD_X;
1047 for i in (0..slots.actions_len).rev() {
1048 let idx = slots.actions_start + i;
1049 let size = self.contextual[idx].layout_child(ctx, &slot_bc);
1050 right -= size.width;
1051 self.contextual[idx]
1052 .set_origin(Point::new(right, (APP_BAR_HEIGHT - size.height) / 2.0));
1053 right -= APP_BAR_GAP;
1054 }
1055
1056 let count_left = APP_BAR_PAD_X + ACTION_SLOT + APP_BAR_GAP;
1057 let count_max_width = (right - count_left).max(0.0);
1058 let count_size = self.contextual[slots.count_text].layout_child(
1059 ctx,
1060 &BoxConstraints::loose(Size::new(count_max_width, APP_BAR_HEIGHT)),
1061 );
1062 self.contextual[slots.count_text].set_origin(Point::new(
1063 count_left,
1064 (APP_BAR_HEIGHT - count_size.height) / 2.0,
1065 ));
1066
1067 let mut height = APP_BAR_HEIGHT;
1068 if let Some((cb_idx, lbl_idx)) = slots.select_all {
1069 let cb_bc = BoxConstraints::loose(Size::new(f64::INFINITY, SELECT_ALL_HEIGHT));
1070 let cb_size = self.contextual[cb_idx].layout_child(ctx, &cb_bc);
1071 self.contextual[cb_idx].set_origin(Point::new(
1072 APP_BAR_PAD_X + (ACTION_SLOT - cb_size.width).max(0.0) / 2.0,
1073 height + (SELECT_ALL_HEIGHT - cb_size.height) / 2.0,
1074 ));
1075
1076 let lbl_left = APP_BAR_PAD_X + ACTION_SLOT + APP_BAR_GAP;
1077 let lbl_max_width = (width - APP_BAR_PAD_X - lbl_left).max(0.0);
1078 let lbl_size = self.contextual[lbl_idx].layout_child(
1079 ctx,
1080 &BoxConstraints::loose(Size::new(lbl_max_width, SELECT_ALL_HEIGHT)),
1081 );
1082 self.contextual[lbl_idx].set_origin(Point::new(
1083 lbl_left,
1084 height + (SELECT_ALL_HEIGHT - lbl_size.height) / 2.0,
1085 ));
1086 height += SELECT_ALL_HEIGHT;
1087 }
1088
1089 bc.constrain(Size::new(width, height))
1090 }
1091
1092 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1093 if !is_selection_mode(&self.selected) {
1094 self.idle.paint_child(ctx, scene);
1095 return;
1096 }
1097 let theme = Theme::from_paint_ctx(ctx);
1100 let fill = resolve_contextual_background(theme);
1101 scene.fill_rect(ctx.origin(), ctx.size(), fill);
1102 for pod in &mut self.contextual {
1103 pod.paint_child(ctx, scene);
1104 }
1105 }
1106
1107 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1108 if is_selection_mode(&self.selected) {
1109 frust::authoring::route_event(&mut self.contextual, ctx, event)
1110 } else {
1111 frust::authoring::route_event_single(&mut self.idle, ctx, event)
1112 }
1113 }
1114
1115 fn semantics(&self, ctx: &mut SemanticsCtx) {
1116 if !is_selection_mode(&self.selected) {
1117 self.idle.semantics_child(ctx);
1118 return;
1119 }
1120 let count = self.selected.len();
1121 let label = format!("{count} selected");
1122 let contextual = &self.contextual;
1123 ctx.push_container(
1124 Role::TitleBar,
1125 |node| {
1126 node.set_label(label.as_str());
1127 node.add_action(Action::Click);
1128 },
1129 |ctx| {
1130 for pod in contextual {
1131 pod.semantics_child(ctx);
1132 }
1133 },
1134 );
1135 }
1136
1137 frust::authoring::visit_children!(contextual, idle);
1138}
1139
1140#[cfg(test)]
1141mod tests {
1142 use super::*;
1143 use frust::authoring::PointerButton;
1144 use frust::authoring::text::TextContext;
1145 use kurbo::Vec2;
1146 use std::any::Any;
1147
1148 #[test]
1151 fn is_selection_mode_matches_reference_getter() {
1152 assert!(!is_selection_mode(&BTreeSet::new()));
1153 assert!(is_selection_mode(&BTreeSet::from([2])));
1154 }
1155
1156 #[test]
1157 fn is_selected_reads_membership() {
1158 let selected = BTreeSet::from([1, 3]);
1159 assert!(is_selected(&selected, 1));
1160 assert!(!is_selected(&selected, 2));
1161 }
1162
1163 #[test]
1164 fn all_selected_for_matches_reference_tristate_formula() {
1165 assert_eq!(all_selected_for(&BTreeSet::new(), 0), Some(false));
1166 assert_eq!(all_selected_for(&BTreeSet::new(), 3), Some(false));
1167 assert_eq!(all_selected_for(&BTreeSet::from([0, 1, 2]), 3), Some(true));
1168 assert_eq!(
1169 all_selected_for(&BTreeSet::from([0, 1, 2, 5]), 3),
1170 Some(true)
1171 );
1172 assert_eq!(all_selected_for(&BTreeSet::from([0]), 3), None);
1173 }
1174
1175 #[test]
1176 fn select_deselect_toggle_and_select_all_are_pure_next_state_transforms() {
1177 let empty = BTreeSet::new();
1178 assert_eq!(select(&empty, 2), BTreeSet::from([2]));
1179 assert_eq!(empty, BTreeSet::new(), "select must not mutate its input");
1180
1181 let some = BTreeSet::from([1, 2]);
1182 assert_eq!(deselect(&some, 1), BTreeSet::from([2]));
1183 assert_eq!(
1184 deselect(&some, 9),
1185 some,
1186 "deselecting an absent index is a no-op value"
1187 );
1188
1189 assert_eq!(toggle(&some, 1), BTreeSet::from([2]), "toggle off");
1190 assert_eq!(toggle(&some, 5), BTreeSet::from([1, 2, 5]), "toggle on");
1191
1192 assert_eq!(select_all(3), BTreeSet::from([0, 1, 2]));
1193 assert_eq!(select_all(0), BTreeSet::new());
1194 assert_eq!(cleared(), BTreeSet::new());
1195 }
1196
1197 const ROW_HEIGHT: f64 = 40.0;
1200 const WIDTH: f64 = 200.0;
1201
1202 fn build<S: 'static>(view: &SelectionHostView<S>) -> SelectionHostWidget {
1203 let mut counter = 0u64;
1204 View::<S>::build(view, &mut BuildCtx::new(&mut counter))
1205 }
1206
1207 fn layout(w: &mut SelectionHostWidget, height: f64) -> Size {
1208 let mut tcx = TextContext::new();
1209 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1210 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(WIDTH, height)))
1211 }
1212
1213 fn rows<S: 'static>(n: usize) -> Vec<AnyView<S>> {
1214 (0..n)
1215 .map(|_| any::<S, _>(frust::SizedBox(Some(10.0), Some(ROW_HEIGHT))))
1216 .collect()
1217 }
1218
1219 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1220 InputEvent::Pointer(frust::authoring::PointerEvent {
1221 phase,
1222 position: Point::new(x, y),
1223 button: PointerButton::Primary,
1224 })
1225 }
1226
1227 #[derive(Default)]
1228 struct Log {
1229 toggled: Vec<usize>,
1230 activated: Vec<usize>,
1231 }
1232
1233 fn dispatch(w: &mut SelectionHostWidget, state: &mut Log, event: &InputEvent) -> EventResult {
1234 let state_any: &mut dyn Any = state;
1235 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(WIDTH, ROW_HEIGHT * 3.0));
1236 w.event(&mut ctx, event)
1237 }
1238
1239 #[test]
1240 fn layout_stacks_rows_vertically_with_no_gap() {
1241 let selected = BTreeSet::new();
1242 let view: SelectionHostView<Log> =
1243 selection_host(rows(3), &selected, |s: &mut Log, i| s.toggled.push(i));
1244 let mut w = build(&view);
1245 let size = layout(&mut w, 300.0);
1246 assert_eq!(size, Size::new(WIDTH, ROW_HEIGHT * 3.0));
1247 assert_eq!(
1248 w.rows,
1249 vec![
1250 (0.0, ROW_HEIGHT),
1251 (ROW_HEIGHT, ROW_HEIGHT),
1252 (ROW_HEIGHT * 2.0, ROW_HEIGHT)
1253 ]
1254 );
1255 }
1256
1257 #[test]
1258 fn tap_outside_selection_mode_activates_not_toggles() {
1259 let selected = BTreeSet::new();
1260 let view: SelectionHostView<Log> =
1261 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i))
1262 .on_activate(|s: &mut Log, i| s.activated.push(i));
1263 let mut w = build(&view);
1264 layout(&mut w, 300.0);
1265 let mut state = Log::default();
1266 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1267 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1268 assert_eq!(state.activated, vec![0]);
1269 assert!(state.toggled.is_empty());
1270 }
1271
1272 #[test]
1273 fn tap_in_selection_mode_toggles_not_activates() {
1274 let selected = BTreeSet::from([1]);
1275 let view: SelectionHostView<Log> =
1276 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i))
1277 .on_activate(|s: &mut Log, i| s.activated.push(i));
1278 let mut w = build(&view);
1279 layout(&mut w, 300.0);
1280 let mut state = Log::default();
1281 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1283 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1284 assert_eq!(state.toggled, vec![0]);
1285 assert!(state.activated.is_empty());
1286 }
1287
1288 #[test]
1289 fn tap_up_outside_the_armed_row_fires_nothing() {
1290 let selected = BTreeSet::new();
1291 let view: SelectionHostView<Log> =
1292 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i))
1293 .on_activate(|s: &mut Log, i| s.activated.push(i));
1294 let mut w = build(&view);
1295 layout(&mut w, 300.0);
1296 let mut state = Log::default();
1297 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1298 dispatch(
1299 &mut w,
1300 &mut state,
1301 &ev(PointerPhase::Up, 5.0, ROW_HEIGHT + 5.0),
1302 );
1303 assert!(state.activated.is_empty());
1304 assert!(state.toggled.is_empty());
1305 }
1306
1307 #[test]
1308 fn long_press_selects_an_unselected_row_and_suppresses_the_tap() {
1309 let selected = BTreeSet::new();
1310 let view: SelectionHostView<Log> =
1311 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i))
1312 .on_activate(|s: &mut Log, i| s.activated.push(i));
1313 let mut w = build(&view);
1314 layout(&mut w, 300.0);
1315 let mut state = Log::default();
1316
1317 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1318 w.long_press.elapsed = true;
1321 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 6.0, 5.0));
1322 assert_eq!(state.toggled, vec![0], "long-press fired on move-arrival");
1323 assert!(state.activated.is_empty());
1324
1325 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 6.0, 5.0));
1326 assert_eq!(
1327 state.toggled,
1328 vec![0],
1329 "the resolved long-press is not also a tap"
1330 );
1331 }
1332
1333 #[test]
1334 fn long_press_never_deselects_an_already_selected_row() {
1335 let selected = BTreeSet::from([0]);
1336 let view: SelectionHostView<Log> =
1337 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
1338 let mut w = build(&view);
1339 layout(&mut w, 300.0);
1340 let mut state = Log::default();
1341
1342 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1343 w.long_press.elapsed = true;
1344 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 6.0, 5.0));
1345 assert!(
1346 state.toggled.is_empty(),
1347 "a long-press on an already-selected row must not fire on_toggle"
1348 );
1349 }
1350
1351 #[test]
1352 fn long_press_still_fires_at_up_if_no_move_arrives_first() {
1353 let selected = BTreeSet::new();
1354 let view: SelectionHostView<Log> =
1355 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
1356 let mut w = build(&view);
1357 layout(&mut w, 300.0);
1358 let mut state = Log::default();
1359
1360 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1361 w.long_press.elapsed = true;
1362 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1363 assert_eq!(state.toggled, vec![0]);
1364 }
1365
1366 #[test]
1367 fn movement_past_touch_slop_cancels_long_press_candidacy() {
1368 let selected = BTreeSet::new();
1369 let view: SelectionHostView<Log> =
1370 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
1371 let mut w = build(&view);
1372 layout(&mut w, 300.0);
1373 let mut state = Log::default();
1374
1375 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1376 w.long_press.elapsed = true; dispatch(
1378 &mut w,
1379 &mut state,
1380 &ev(PointerPhase::Move, 5.0 + TOUCH_SLOP + 5.0, 5.0),
1381 );
1382 assert!(!w.long_press.elapsed, "a drag cancels the candidacy");
1383 assert!(state.toggled.is_empty());
1384 }
1385
1386 #[test]
1387 fn cancel_clears_state_without_firing() {
1388 let selected = BTreeSet::new();
1389 let view: SelectionHostView<Log> =
1390 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
1391 let mut w = build(&view);
1392 layout(&mut w, 300.0);
1393 let mut state = Log::default();
1394
1395 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1396 assert!(w.armed.is_some());
1397 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 5.0, 5.0));
1398 assert!(w.armed.is_none());
1399 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1400 assert!(state.toggled.is_empty());
1401 }
1402
1403 #[test]
1404 fn disabled_router_arms_nothing() {
1405 let selected = BTreeSet::new();
1406 let view: SelectionHostView<Log> =
1407 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i)).enabled(false);
1408 let mut w = build(&view);
1409 layout(&mut w, 300.0);
1410 let mut state = Log::default();
1411 assert_eq!(
1412 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0)),
1413 EventResult::Ignored
1414 );
1415 assert!(w.armed.is_none());
1416 }
1417
1418 #[test]
1419 fn a_secondary_press_never_arms_a_row() {
1420 let selected = BTreeSet::new();
1421 let view: SelectionHostView<Log> =
1422 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
1423 let mut w = build(&view);
1424 layout(&mut w, 300.0);
1425 let mut state = Log::default();
1426 let secondary = InputEvent::Pointer(frust::authoring::PointerEvent {
1427 phase: PointerPhase::Down,
1428 position: Point::new(5.0, 5.0),
1429 button: PointerButton::Secondary,
1430 });
1431 assert_eq!(
1432 dispatch(&mut w, &mut state, &secondary),
1433 EventResult::Ignored
1434 );
1435 assert!(w.armed.is_none());
1436 }
1437
1438 #[test]
1439 fn external_clear_collapses_mode_on_the_next_dispatch() {
1440 let mut counter = 0u64;
1444 let prev_selected = BTreeSet::from([0]);
1445 let prev: SelectionHostView<Log> =
1446 selection_host(rows(2), &prev_selected, |s: &mut Log, i| s.toggled.push(i))
1447 .on_activate(|s: &mut Log, i| s.activated.push(i));
1448 let mut w = View::<Log>::build(&prev, &mut BuildCtx::new(&mut counter));
1449 layout(&mut w, 300.0);
1450
1451 let next_selected = BTreeSet::new();
1452 let next: SelectionHostView<Log> =
1453 selection_host(rows(2), &next_selected, |s: &mut Log, i| s.toggled.push(i))
1454 .on_activate(|s: &mut Log, i| s.activated.push(i));
1455 View::<Log>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1456 assert!(!is_selection_mode(&w.selected));
1457
1458 let mut state = Log::default();
1459 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1460 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1461 assert_eq!(
1462 state.activated,
1463 vec![0],
1464 "collapsed mode resumes tap-to-activate"
1465 );
1466 assert!(state.toggled.is_empty());
1467 }
1468
1469 fn idle_view<S: 'static>() -> AnyView<S> {
1472 any::<S, _>(frust::SizedBox(Some(0.0), Some(30.0)))
1473 }
1474
1475 fn bar_build<S: 'static>(view: &SelectionAppBarView<S>) -> SelectionAppBarWidget {
1476 let mut counter = 0u64;
1477 View::<S>::build(view, &mut BuildCtx::new(&mut counter))
1478 }
1479
1480 fn bar_layout(w: &mut SelectionAppBarWidget, width: f64) -> Size {
1481 let mut tcx = TextContext::new();
1482 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1483 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(width, 400.0)))
1484 }
1485
1486 #[test]
1487 fn idle_mode_uses_the_idle_contents_own_height() {
1488 let selected = BTreeSet::new();
1489 let view: SelectionAppBarView<()> =
1490 selection_app_bar(idle_view(), &selected, 3, |_: &mut ()| {});
1491 let mut w = bar_build(&view);
1492 let size = bar_layout(&mut w, 300.0);
1493 assert_eq!(size, Size::new(300.0, 30.0));
1494 }
1495
1496 #[test]
1497 fn contextual_mode_height_matches_app_bar_height_metric() {
1498 let selected = BTreeSet::from([0]);
1499 let view: SelectionAppBarView<()> =
1500 selection_app_bar(idle_view(), &selected, 3, |_: &mut ()| {}).show_select_all(false);
1501 let mut w = bar_build(&view);
1502 let size = bar_layout(&mut w, 300.0);
1503 assert_eq!(
1504 size.height, APP_BAR_HEIGHT,
1505 "the toolbar band matches appbar.rs's own HEIGHT metric"
1506 );
1507 }
1508
1509 #[test]
1510 fn contextual_mode_with_select_all_adds_the_select_all_row_height() {
1511 let selected = BTreeSet::from([0]);
1512 let view: SelectionAppBarView<()> =
1513 selection_app_bar(idle_view(), &selected, 3, |_: &mut ()| {});
1514 let mut w = bar_build(&view);
1515 let size = bar_layout(&mut w, 300.0);
1516 assert_eq!(size.height, APP_BAR_HEIGHT + SELECT_ALL_HEIGHT);
1517 }
1518
1519 #[test]
1520 fn unthemed_paint_fills_the_contextual_background_only_in_selection_mode() {
1521 let idle_selected = BTreeSet::new();
1522 let idle: SelectionAppBarView<()> =
1523 selection_app_bar(idle_view(), &idle_selected, 3, |_: &mut ()| {});
1524 let mut idle_w = bar_build(&idle);
1525 bar_layout(&mut idle_w, 300.0);
1526 let mut idle_scene = RectRecorder::default();
1527 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(300.0, 30.0));
1528 idle_w.paint(&mut ctx, &mut idle_scene);
1529 assert!(
1530 idle_scene.rects.is_empty(),
1531 "idle mode paints no background of its own"
1532 );
1533
1534 let contextual_selected = BTreeSet::from([0]);
1535 let contextual: SelectionAppBarView<()> =
1536 selection_app_bar(idle_view(), &contextual_selected, 3, |_: &mut ()| {});
1537 let mut contextual_w = bar_build(&contextual);
1538 let size = bar_layout(&mut contextual_w, 300.0);
1539 let mut contextual_scene = RectRecorder::default();
1540 let mut ctx = PaintCtx::new(Point::ZERO, size);
1541 contextual_w.paint(&mut ctx, &mut contextual_scene);
1542 assert_eq!(contextual_scene.rects[0].1, size);
1546 assert!(!contextual_scene.rects.is_empty());
1547 }
1548
1549 #[derive(Default)]
1550 struct RectRecorder {
1551 rects: Vec<(Point, Size)>,
1552 }
1553 impl PaintScene for RectRecorder {
1554 fn fill_rect(&mut self, o: Point, s: Size, _c: Color) {
1555 self.rects.push((o, s));
1556 }
1557 fn draw_text(&mut self, _o: Point, _t: &str) {}
1558 }
1559
1560 #[test]
1561 fn close_button_press_fires_on_clear() {
1562 #[derive(Default)]
1563 struct S {
1564 cleared: u32,
1565 }
1566 let selected = BTreeSet::from([0]);
1567 let view: SelectionAppBarView<S> =
1568 selection_app_bar(idle_view::<S>(), &selected, 3, |s: &mut S| s.cleared += 1);
1569 let mut w = bar_build(&view);
1570 bar_layout(&mut w, 300.0);
1571
1572 let close_pod = &w.contextual[w.contextual_slots.close];
1573 let center = close_pod.origin()
1574 + Vec2::new(close_pod.size().width / 2.0, close_pod.size().height / 2.0);
1575
1576 let mut state = S::default();
1577 let state_any: &mut dyn Any = &mut state;
1578 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(300.0, APP_BAR_HEIGHT));
1579 w.event(&mut ctx, &ev(PointerPhase::Down, center.x, center.y));
1580 w.event(&mut ctx, &ev(PointerPhase::Up, center.x, center.y));
1581 assert_eq!(state.cleared, 1);
1582 }
1583
1584 #[test]
1585 fn select_all_reports_binary_flip_treating_partial_as_not_all() {
1586 #[derive(Default)]
1587 struct S {
1588 calls: Vec<bool>,
1589 }
1590
1591 fn bar(selected: &BTreeSet<usize>) -> SelectionAppBarView<S> {
1592 selection_app_bar(idle_view::<S>(), selected, 3, |_: &mut S| {})
1593 .on_all_selected(|s: &mut S, all| s.calls.push(all))
1594 }
1595
1596 fn press_checkbox(w: &mut SelectionAppBarWidget, state: &mut S) {
1597 let (cb_idx, _) = w.contextual_slots.select_all.expect("select-all present");
1598 let pod = &w.contextual[cb_idx];
1599 let center = pod.origin() + Vec2::new(pod.size().width / 2.0, pod.size().height / 2.0);
1600 let state_any: &mut dyn Any = state;
1601 let mut ctx = EventCtx::new(
1602 state_any,
1603 Point::ZERO,
1604 Size::new(300.0, APP_BAR_HEIGHT + SELECT_ALL_HEIGHT),
1605 );
1606 w.event(&mut ctx, &ev(PointerPhase::Down, center.x, center.y));
1607 w.event(&mut ctx, &ev(PointerPhase::Up, center.x, center.y));
1608 }
1609
1610 let none = BTreeSet::from([0]); let view = bar(&none);
1613 let mut w = bar_build(&view);
1614 bar_layout(&mut w, 300.0);
1615 let mut state = S::default();
1616 press_checkbox(&mut w, &mut state);
1617 assert_eq!(
1618 state.calls,
1619 vec![true],
1620 "a partial selection reports true (not-all)"
1621 );
1622
1623 let all = BTreeSet::from([0, 1, 2]);
1625 let view = bar(&all);
1626 let mut w = bar_build(&view);
1627 bar_layout(&mut w, 300.0);
1628 let mut state = S::default();
1629 press_checkbox(&mut w, &mut state);
1630 assert_eq!(state.calls, vec![false]);
1631 }
1632
1633 #[test]
1634 fn external_clear_collapses_the_bar_back_to_idle() {
1635 let mut counter = 0u64;
1638 let prev_selected = BTreeSet::from([0]);
1639 let prev: SelectionAppBarView<()> =
1640 selection_app_bar(idle_view(), &prev_selected, 3, |_: &mut ()| {});
1641 let mut w = View::<()>::build(&prev, &mut BuildCtx::new(&mut counter));
1642 bar_layout(&mut w, 300.0);
1643 assert!(is_selection_mode(&w.selected));
1644
1645 let next_selected = BTreeSet::new();
1646 let next: SelectionAppBarView<()> =
1647 selection_app_bar(idle_view(), &next_selected, 3, |_: &mut ()| {});
1648 View::<()>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1649 assert!(!is_selection_mode(&w.selected));
1650
1651 let size = bar_layout(&mut w, 300.0);
1652 assert_eq!(
1653 size,
1654 Size::new(300.0, 30.0),
1655 "layout falls back to idle's own height"
1656 );
1657 }
1658
1659 #[test]
1660 fn semantics_reports_count_selected_label_in_contextual_mode() {
1661 fn logic(_s: &mut ()) -> SelectionAppBarView<()> {
1662 let selected = BTreeSet::from([0, 1]);
1663 selection_app_bar(idle_view(), &selected, 3, |_: &mut ()| {})
1664 }
1665 let mut root: frust_core::RenderRoot<(), SelectionAppBarView<()>> =
1666 frust_core::RenderRoot::new();
1667 let mut state = ();
1668 root.rebuild(&mut logic, &mut state);
1669 let mut tcx = TextContext::new();
1670 root.layout_with_text(Size::new(300.0, 200.0), &mut tcx as &mut dyn Any);
1671 let update = root.semantics();
1672 let (_, node) = update
1673 .nodes
1674 .iter()
1675 .find(|(_, n)| n.role() == Role::TitleBar)
1676 .expect("a TitleBar node is contributed in selection mode");
1677 assert_eq!(node.label(), Some("2 selected"));
1678 }
1679}