1use std::collections::BTreeSet;
186use std::rc::Rc;
187
188use frust::authoring::text::{FontWeight, LineHeight};
189use frust::authoring::{Action, Role};
190use frust::authoring::{
191 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, ErasedArgCallback, EventCtx,
192 EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx,
193 ThemeTextColor, ThemeTextType, View, ViewSeq, Widget, any,
194};
195use frust::input::TOUCH_SLOP;
196use frust::{FrameTime, Theme, icon, text};
197use kurbo::{Point, Size};
198use peniko::Color;
199
200use crate::icon_button::icon_button;
201use crate::press::presses;
202
203const APP_BAR_HEIGHT: f64 = 64.0;
209const APP_BAR_PAD_X: f64 = 4.0;
212const APP_BAR_GAP: f64 = 4.0;
215const ACTION_SLOT: f64 = 48.0;
221const SELECT_ALL_HEIGHT: f64 = 48.0;
223
224const COUNT_SIZE: f32 = 22.0;
228const COUNT_LINE_HEIGHT: f32 = 28.0;
229const COUNT_WEIGHT: FontWeight = FontWeight::MEDIUM;
230
231const CONTEXTUAL_BACKGROUND: Color = Color::from_rgb8(0xEA, 0xDD, 0xFF);
234
235const CLEAR_SELECTION_LABEL: &str = "Clear selection";
239
240const LONG_PRESS_MS: f64 = 500.0;
246
247pub fn is_selection_mode(selected: &BTreeSet<usize>) -> bool {
252 !selected.is_empty()
253}
254
255pub fn is_selected(selected: &BTreeSet<usize>, index: usize) -> bool {
257 selected.contains(&index)
258}
259
260pub fn all_selected_for(selected: &BTreeSet<usize>, item_count: usize) -> Option<bool> {
265 if item_count == 0 || selected.is_empty() {
266 return Some(false);
267 }
268 if selected.len() >= item_count {
269 return Some(true);
270 }
271 None
272}
273
274pub fn select(selected: &BTreeSet<usize>, index: usize) -> BTreeSet<usize> {
277 let mut next = selected.clone();
278 next.insert(index);
279 next
280}
281
282pub fn deselect(selected: &BTreeSet<usize>, index: usize) -> BTreeSet<usize> {
285 let mut next = selected.clone();
286 next.remove(&index);
287 next
288}
289
290pub fn toggle(selected: &BTreeSet<usize>, index: usize) -> BTreeSet<usize> {
293 let mut next = selected.clone();
294 if !next.remove(&index) {
295 next.insert(index);
296 }
297 next
298}
299
300pub fn select_all(item_count: usize) -> BTreeSet<usize> {
303 (0..item_count).collect()
304}
305
306pub fn cleared() -> BTreeSet<usize> {
308 BTreeSet::new()
309}
310
311type OnIndex<State> = Rc<dyn Fn(&mut State, usize)>;
314
315pub struct SelectionHostView<State: 'static> {
317 items: Vec<AnyView<State>>,
318 selected: BTreeSet<usize>,
319 enabled: bool,
320 on_toggle: OnIndex<State>,
321 on_activate: Option<OnIndex<State>>,
322}
323
324pub fn selection_host<State: 'static, F, M>(
330 items: impl ViewSeq<State, M>,
331 selected: &BTreeSet<usize>,
332 on_toggle: F,
333) -> SelectionHostView<State>
334where
335 F: Fn(&mut State, usize) + 'static,
336{
337 let mut erased = Vec::new();
338 items.extend_views(&mut erased);
339 SelectionHostView {
340 items: erased,
341 selected: selected.clone(),
342 enabled: true,
343 on_toggle: Rc::new(on_toggle),
344 on_activate: None,
345 }
346}
347
348impl<State: 'static> SelectionHostView<State> {
349 pub fn on_activate<F: Fn(&mut State, usize) + 'static>(mut self, on_activate: F) -> Self {
353 self.on_activate = Some(Rc::new(on_activate));
354 self
355 }
356
357 pub fn enabled(mut self, enabled: bool) -> Self {
360 self.enabled = enabled;
361 self
362 }
363}
364
365#[derive(Debug, Default, Clone, Copy)]
368struct LongPressState {
369 press_start: Option<FrameTime>,
372 elapsed: bool,
375 fired: bool,
378}
379
380impl LongPressState {
381 fn clear(&mut self) {
382 *self = Self::default();
383 }
384}
385
386pub struct SelectionHostWidget {
389 items: Vec<ChildPod>,
390 selected: BTreeSet<usize>,
391 enabled: bool,
392 rows: Vec<(f64, f64)>,
395 width: f64,
396 armed: Option<usize>,
399 down_pos: Point,
402 long_press: LongPressState,
403 on_toggle: ErasedArgCallback<usize>,
404 on_activate: Option<ErasedArgCallback<usize>>,
405}
406
407impl<State: 'static> View<State> for SelectionHostView<State> {
408 type Element = SelectionHostWidget;
409
410 fn build(&self, ctx: &mut BuildCtx<'_>) -> SelectionHostWidget {
411 let items = self
412 .items
413 .iter()
414 .map(|view| frust::authoring::build_child(view, ctx))
415 .collect();
416 SelectionHostWidget {
417 items,
418 selected: self.selected.clone(),
419 enabled: self.enabled,
420 rows: Vec::new(),
421 width: 0.0,
422 armed: None,
423 down_pos: Point::ZERO,
424 long_press: LongPressState::default(),
425 on_toggle: frust::authoring::erase_callback_arg(&self.on_toggle),
426 on_activate: self
427 .on_activate
428 .as_ref()
429 .map(frust::authoring::erase_callback_arg),
430 }
431 }
432
433 fn rebuild(
434 &self,
435 prev: &Self,
436 element: &mut SelectionHostWidget,
437 ctx: &mut BuildCtx<'_>,
438 ) -> ChangeFlags {
439 let mut flags = frust::authoring::rebuild_children(
440 &prev.items,
441 &self.items,
442 &mut element.items,
443 ctx,
444 |view| view,
445 |_| None,
446 );
447
448 if prev.items.len() != self.items.len() {
449 element.armed = None;
452 element.long_press.clear();
453 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
454 }
455
456 if element.selected != self.selected {
457 element.selected = self.selected.clone();
458 flags |= ChangeFlags::PAINT;
459 }
460
461 if element.enabled != self.enabled {
462 element.enabled = self.enabled;
463 if !self.enabled {
464 element.armed = None;
465 element.long_press.clear();
466 }
467 flags |= ChangeFlags::PAINT;
468 }
469
470 element.on_toggle = frust::authoring::erase_callback_arg(&self.on_toggle);
471 element.on_activate = self
472 .on_activate
473 .as_ref()
474 .map(frust::authoring::erase_callback_arg);
475 flags
476 }
477
478 fn teardown(&self, element: &mut SelectionHostWidget, ctx: &mut BuildCtx<'_>) {
479 for (view, pod) in self.items.iter().zip(element.items.iter_mut()) {
480 frust::authoring::teardown_child(view, pod, ctx);
481 }
482 }
483}
484
485impl SelectionHostWidget {
486 fn row_at(&self, pos: Point) -> Option<usize> {
489 if pos.x < 0.0 || pos.x >= self.width {
490 return None;
491 }
492 self.rows
493 .iter()
494 .position(|(y, height)| pos.y >= *y && pos.y < y + height)
495 }
496
497 fn clear_press(&mut self) {
500 self.armed = None;
501 self.long_press.clear();
502 }
503
504 fn fire_long_press(&mut self, ctx: &mut EventCtx, index: usize) {
509 if !self.selected.contains(&index) {
510 (self.on_toggle)(ctx, index);
511 }
512 }
513
514 fn fire_tap(&mut self, ctx: &mut EventCtx, index: usize) {
517 if is_selection_mode(&self.selected) {
518 (self.on_toggle)(ctx, index);
519 } else if let Some(on_activate) = self.on_activate.as_mut() {
520 on_activate(ctx, index);
521 }
522 }
523}
524
525impl Widget for SelectionHostWidget {
526 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
527 let width = if bc.max().width.is_finite() {
528 bc.max().width
529 } else {
530 0.0
531 };
532 self.width = width;
533 let max_height = bc.max().height;
534 let child_bc = BoxConstraints::new(Size::new(width, 0.0), Size::new(width, max_height));
535
536 self.rows.clear();
537 let mut y = 0.0;
538 for pod in self.items.iter_mut() {
539 let size = pod.layout_child(ctx, &child_bc);
540 pod.set_origin(Point::new(0.0, y));
541 self.rows.push((y, size.height));
542 y += size.height;
543 }
544
545 bc.constrain(Size::new(width, y))
546 }
547
548 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
549 for pod in &mut self.items {
550 pod.paint_child(ctx, scene);
551 }
552
553 if self.enabled && self.armed.is_some() && !self.long_press.elapsed {
556 let start = *self.long_press.press_start.get_or_insert(ctx.frame_time());
557 let elapsed_ms = ctx.frame_time().saturating_sub(start).as_secs_f64() * 1000.0;
558 if elapsed_ms >= LONG_PRESS_MS {
559 self.long_press.elapsed = true;
560 } else {
561 ctx.request_frame();
562 }
563 }
564 }
565
566 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
567 let InputEvent::Pointer(p) = event else {
568 return frust::authoring::route_event(&mut self.items, ctx, event);
569 };
570 if !self.enabled {
571 return EventResult::Ignored;
575 }
576 match p.phase {
577 PointerPhase::Down => {
578 if !presses(p) {
579 return EventResult::Ignored;
580 }
581 let Some(index) = self.row_at(p.position) else {
582 return EventResult::Ignored;
584 };
585 self.armed = Some(index);
586 self.down_pos = p.position;
587 self.long_press.clear();
588 ctx.capture_pointer();
589 ctx.request_redraw();
590 EventResult::Handled
591 }
592 PointerPhase::Move => {
593 let Some(armed) = self.armed else {
594 return EventResult::Ignored;
598 };
599 if (p.position - self.down_pos).hypot() > TOUCH_SLOP {
602 self.long_press.clear();
603 } else if self.long_press.elapsed && !self.long_press.fired {
604 self.long_press.fired = true;
605 self.fire_long_press(ctx, armed);
606 ctx.request_redraw();
607 }
608 EventResult::Handled
609 }
610 PointerPhase::Up => {
611 let Some(armed) = self.armed else {
612 return EventResult::Ignored;
613 };
614 if self.long_press.elapsed {
618 if !self.long_press.fired {
619 self.long_press.fired = true;
620 self.fire_long_press(ctx, armed);
621 }
622 } else if self.row_at(p.position) == Some(armed) {
623 self.fire_tap(ctx, armed);
624 }
625 self.clear_press();
626 ctx.request_redraw();
627 EventResult::Handled
628 }
629 PointerPhase::Cancel => {
630 if self.armed.is_none() {
631 return EventResult::Ignored;
632 }
633 self.clear_press();
634 ctx.request_redraw();
635 EventResult::Handled
636 }
637 }
638 }
639
640 fn semantics(&self, ctx: &mut SemanticsCtx) {
641 ctx.push_container(
642 Role::List,
643 |_node| {},
644 |ctx| {
645 for pod in &self.items {
646 pod.semantics_child(ctx);
647 }
648 },
649 );
650 }
651
652 frust::authoring::visit_children!(items);
653}
654
655type OnClear<State> = Rc<dyn Fn(&mut State)>;
658type OnAllSelected<State> = Rc<dyn Fn(&mut State, bool)>;
659
660pub struct SelectionAppBarView<State: 'static> {
664 idle: AnyView<State>,
665 selected: BTreeSet<usize>,
666 item_count: usize,
667 actions: Vec<AnyView<State>>,
668 show_select_all: bool,
669 select_all_label: String,
670 on_clear: OnClear<State>,
671 on_all_selected: Option<OnAllSelected<State>>,
672 safe_area: bool,
673}
674
675pub fn selection_app_bar<State: 'static, F>(
682 idle: impl View<State>,
683 selected: &BTreeSet<usize>,
684 item_count: usize,
685 on_clear: F,
686) -> SelectionAppBarView<State>
687where
688 F: Fn(&mut State) + 'static,
689{
690 SelectionAppBarView {
691 idle: AnyView::new(idle),
692 selected: selected.clone(),
693 item_count,
694 actions: Vec::new(),
695 show_select_all: true,
696 select_all_label: "Select all".to_string(),
697 on_clear: Rc::new(on_clear),
698 on_all_selected: None,
699 safe_area: true,
700 }
701}
702
703impl<State: 'static> SelectionAppBarView<State> {
704 pub fn actions<M>(mut self, actions: impl ViewSeq<State, M>) -> Self {
707 self.actions.clear();
708 actions.extend_views(&mut self.actions);
709 self
710 }
711
712 pub fn show_select_all(mut self, show: bool) -> Self {
715 self.show_select_all = show;
716 self
717 }
718
719 pub fn select_all_label(mut self, label: impl Into<String>) -> Self {
721 self.select_all_label = label.into();
722 self
723 }
724
725 pub fn on_all_selected<F: Fn(&mut State, bool) + 'static>(
730 mut self,
731 on_all_selected: F,
732 ) -> Self {
733 self.on_all_selected = Some(Rc::new(on_all_selected));
734 self
735 }
736
737 pub fn safe_area(mut self, enabled: bool) -> Self {
746 self.safe_area = enabled;
747 self
748 }
749}
750
751impl<State: 'static> SelectionAppBarView<State> {
752 fn shape(&self) -> (usize, bool) {
755 (self.actions.len(), self.show_select_all)
756 }
757}
758
759fn close_view<State: 'static>(on_clear: OnClear<State>) -> impl View<State> {
763 icon_button(icon(crate::icons::CLOSE), move |state: &mut State| {
764 (on_clear)(state);
765 })
766 .semantic_label(CLEAR_SELECTION_LABEL)
767}
768
769fn count_view<State: 'static>(count: usize) -> AnyView<State> {
773 any::<State, _>(
774 text(count.to_string())
775 .size(COUNT_SIZE)
776 .weight(COUNT_WEIGHT)
777 .line_height(LineHeight::Absolute(COUNT_LINE_HEIGHT))
778 .themed_role(ThemeTextColor::OnPrimaryContainer)
779 .themed_family(ThemeTextType::TitleLargeEmphasized),
780 )
781}
782
783fn select_all_checkbox_view<State: 'static>(
788 selected: BTreeSet<usize>,
789 item_count: usize,
790 on_all_selected: Option<OnAllSelected<State>>,
791) -> impl View<State> {
792 let value = all_selected_for(&selected, item_count);
793 crate::checkbox::tristate_checkbox::<State, _>(
794 value,
795 move |state: &mut State, _next: Option<bool>| {
796 if let Some(cb) = &on_all_selected {
797 cb(state, !matches!(value, Some(true)));
798 }
799 },
800 )
801}
802
803fn select_all_label_view<State: 'static>(label: String) -> AnyView<State> {
806 any::<State, _>(
807 text(label)
808 .themed_role(ThemeTextColor::OnPrimaryContainer)
809 .themed_family(ThemeTextType::BodyLarge),
810 )
811}
812
813fn resolve_contextual_background(theme: Option<&Theme>) -> Color {
816 match theme {
817 Some(theme) => theme.scheme().primary_container,
818 None => CONTEXTUAL_BACKGROUND,
819 }
820}
821
822#[derive(Clone, Copy)]
825struct ContextualSlots {
826 close: usize,
827 count_text: usize,
828 actions_start: usize,
829 actions_len: usize,
830 select_all: Option<(usize, usize)>,
833}
834
835fn build_contextual_children<State: 'static>(
838 view: &SelectionAppBarView<State>,
839 ctx: &mut BuildCtx<'_>,
840) -> (Vec<ChildPod>, ContextualSlots) {
841 let mut children = Vec::new();
842
843 let close_v = close_view::<State>(view.on_clear.clone());
844 children.push(frust::authoring::build_child(&close_v, ctx));
845 let close = 0;
846
847 let count_v = count_view::<State>(view.selected.len());
848 children.push(frust::authoring::build_child(&count_v, ctx));
849 let count_text = 1;
850
851 let actions_start = children.len();
852 for action in &view.actions {
853 children.push(frust::authoring::build_child(action, ctx));
854 }
855 let actions_len = view.actions.len();
856
857 let select_all = view.show_select_all.then(|| {
858 let cb = select_all_checkbox_view::<State>(
859 view.selected.clone(),
860 view.item_count,
861 view.on_all_selected.clone(),
862 );
863 let lbl = select_all_label_view::<State>(view.select_all_label.clone());
864 let cb_idx = children.len();
865 children.push(frust::authoring::build_child(&cb, ctx));
866 let lbl_idx = children.len();
867 children.push(frust::authoring::build_child(&lbl, ctx));
868 (cb_idx, lbl_idx)
869 });
870
871 (
872 children,
873 ContextualSlots {
874 close,
875 count_text,
876 actions_start,
877 actions_len,
878 select_all,
879 },
880 )
881}
882
883fn teardown_contextual_children<State: 'static>(
887 view: &SelectionAppBarView<State>,
888 slots: ContextualSlots,
889 children: &mut [ChildPod],
890 ctx: &mut BuildCtx<'_>,
891) {
892 let close_v = close_view::<State>(view.on_clear.clone());
893 frust::authoring::teardown_child(&close_v, &mut children[slots.close], ctx);
894
895 let count_v = count_view::<State>(view.selected.len());
896 frust::authoring::teardown_child(&count_v, &mut children[slots.count_text], ctx);
897
898 for i in 0..slots.actions_len {
899 frust::authoring::teardown_child(
900 &view.actions[i],
901 &mut children[slots.actions_start + i],
902 ctx,
903 );
904 }
905
906 if let Some((cb_idx, lbl_idx)) = slots.select_all {
907 let cb = select_all_checkbox_view::<State>(
908 view.selected.clone(),
909 view.item_count,
910 view.on_all_selected.clone(),
911 );
912 frust::authoring::teardown_child(&cb, &mut children[cb_idx], ctx);
913 let lbl = select_all_label_view::<State>(view.select_all_label.clone());
914 frust::authoring::teardown_child(&lbl, &mut children[lbl_idx], ctx);
915 }
916}
917
918pub struct SelectionAppBarWidget {
921 idle: ChildPod,
922 contextual: Vec<ChildPod>,
925 contextual_slots: ContextualSlots,
926 selected: BTreeSet<usize>,
927 item_count: usize,
928 show_select_all: bool,
929 select_all_label: String,
930 safe_area: bool,
933}
934
935impl<State: 'static> View<State> for SelectionAppBarView<State> {
936 type Element = SelectionAppBarWidget;
937
938 fn build(&self, ctx: &mut BuildCtx<'_>) -> SelectionAppBarWidget {
939 let (contextual, contextual_slots) = build_contextual_children(self, ctx);
940 SelectionAppBarWidget {
941 idle: frust::authoring::build_child(&self.idle, ctx),
942 contextual,
943 contextual_slots,
944 selected: self.selected.clone(),
945 item_count: self.item_count,
946 show_select_all: self.show_select_all,
947 select_all_label: self.select_all_label.clone(),
948 safe_area: self.safe_area,
949 }
950 }
951
952 fn rebuild(
953 &self,
954 prev: &Self,
955 element: &mut SelectionAppBarWidget,
956 ctx: &mut BuildCtx<'_>,
957 ) -> ChangeFlags {
958 let mut flags =
959 frust::authoring::rebuild_child(&prev.idle, &self.idle, &mut element.idle, ctx);
960
961 if prev.shape() != self.shape() {
962 teardown_contextual_children(
963 prev,
964 element.contextual_slots,
965 &mut element.contextual,
966 ctx,
967 );
968 let (children, slots) = build_contextual_children(self, ctx);
969 element.contextual = children;
970 element.contextual_slots = slots;
971 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
972 } else {
973 let slots = element.contextual_slots;
974
975 let prev_close = close_view::<State>(prev.on_clear.clone());
976 let next_close = close_view::<State>(self.on_clear.clone());
977 flags |= frust::authoring::rebuild_child(
978 &prev_close,
979 &next_close,
980 &mut element.contextual[slots.close],
981 ctx,
982 );
983
984 let prev_count = count_view::<State>(prev.selected.len());
985 let next_count = count_view::<State>(self.selected.len());
986 flags |= frust::authoring::rebuild_child(
987 &prev_count,
988 &next_count,
989 &mut element.contextual[slots.count_text],
990 ctx,
991 );
992
993 for i in 0..slots.actions_len {
994 flags |= frust::authoring::rebuild_child(
995 &prev.actions[i],
996 &self.actions[i],
997 &mut element.contextual[slots.actions_start + i],
998 ctx,
999 );
1000 }
1001
1002 if let Some((cb_idx, lbl_idx)) = slots.select_all {
1003 let prev_cb = select_all_checkbox_view::<State>(
1004 prev.selected.clone(),
1005 prev.item_count,
1006 prev.on_all_selected.clone(),
1007 );
1008 let next_cb = select_all_checkbox_view::<State>(
1009 self.selected.clone(),
1010 self.item_count,
1011 self.on_all_selected.clone(),
1012 );
1013 flags |= frust::authoring::rebuild_child(
1014 &prev_cb,
1015 &next_cb,
1016 &mut element.contextual[cb_idx],
1017 ctx,
1018 );
1019
1020 let prev_lbl = select_all_label_view::<State>(prev.select_all_label.clone());
1021 let next_lbl = select_all_label_view::<State>(self.select_all_label.clone());
1022 flags |= frust::authoring::rebuild_child(
1023 &prev_lbl,
1024 &next_lbl,
1025 &mut element.contextual[lbl_idx],
1026 ctx,
1027 );
1028 }
1029 }
1030
1031 if element.selected != self.selected {
1032 element.selected = self.selected.clone();
1033 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1035 }
1036 if element.safe_area != self.safe_area {
1037 element.safe_area = self.safe_area;
1038 flags |= ChangeFlags::LAYOUT;
1039 }
1040 element.item_count = self.item_count;
1041 element.show_select_all = self.show_select_all;
1042 element.select_all_label = self.select_all_label.clone();
1043 flags
1044 }
1045
1046 fn teardown(&self, element: &mut SelectionAppBarWidget, ctx: &mut BuildCtx<'_>) {
1047 frust::authoring::teardown_child(&self.idle, &mut element.idle, ctx);
1048 teardown_contextual_children(self, element.contextual_slots, &mut element.contextual, ctx);
1049 }
1050}
1051
1052impl Widget for SelectionAppBarWidget {
1053 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1054 let width = if bc.max().width.is_finite() {
1055 bc.max().width
1056 } else {
1057 0.0
1058 };
1059
1060 if !is_selection_mode(&self.selected) {
1061 let idle_size = self
1062 .idle
1063 .layout_child(ctx, &BoxConstraints::loose(Size::new(width, f64::INFINITY)));
1064 self.idle.set_origin(Point::ZERO);
1065 return bc.constrain(Size::new(width, idle_size.height));
1066 }
1067
1068 let (pad_l, top, pad_r) = if self.safe_area {
1073 let padding = ctx.window_insets().padding();
1074 (
1075 padding.left.max(0.0),
1076 padding.top.max(0.0),
1077 padding.right.max(0.0),
1078 )
1079 } else {
1080 (0.0, 0.0, 0.0)
1081 };
1082 let leading_edge = pad_l + APP_BAR_PAD_X;
1083 let trailing_edge = width - pad_r - APP_BAR_PAD_X;
1084
1085 let slots = self.contextual_slots;
1086 let slot_bc = BoxConstraints::loose(Size::new(f64::INFINITY, APP_BAR_HEIGHT));
1087
1088 let close_size = self.contextual[slots.close].layout_child(ctx, &slot_bc);
1089 self.contextual[slots.close].set_origin(Point::new(
1090 leading_edge + (ACTION_SLOT - close_size.width).max(0.0) / 2.0,
1091 top + (APP_BAR_HEIGHT - close_size.height) / 2.0,
1092 ));
1093
1094 let mut right = trailing_edge;
1097 for i in (0..slots.actions_len).rev() {
1098 let idx = slots.actions_start + i;
1099 let size = self.contextual[idx].layout_child(ctx, &slot_bc);
1100 right -= size.width;
1101 self.contextual[idx].set_origin(Point::new(
1102 right,
1103 top + (APP_BAR_HEIGHT - size.height) / 2.0,
1104 ));
1105 right -= APP_BAR_GAP;
1106 }
1107
1108 let count_left = leading_edge + ACTION_SLOT + APP_BAR_GAP;
1109 let count_max_width = (right - count_left).max(0.0);
1110 let count_size = self.contextual[slots.count_text].layout_child(
1111 ctx,
1112 &BoxConstraints::loose(Size::new(count_max_width, APP_BAR_HEIGHT)),
1113 );
1114 self.contextual[slots.count_text].set_origin(Point::new(
1115 count_left,
1116 top + (APP_BAR_HEIGHT - count_size.height) / 2.0,
1117 ));
1118
1119 let mut height = top + APP_BAR_HEIGHT;
1120 if let Some((cb_idx, lbl_idx)) = slots.select_all {
1121 let cb_bc = BoxConstraints::loose(Size::new(f64::INFINITY, SELECT_ALL_HEIGHT));
1122 let cb_size = self.contextual[cb_idx].layout_child(ctx, &cb_bc);
1123 self.contextual[cb_idx].set_origin(Point::new(
1124 leading_edge + (ACTION_SLOT - cb_size.width).max(0.0) / 2.0,
1125 height + (SELECT_ALL_HEIGHT - cb_size.height) / 2.0,
1126 ));
1127
1128 let lbl_left = leading_edge + ACTION_SLOT + APP_BAR_GAP;
1129 let lbl_max_width = (trailing_edge - lbl_left).max(0.0);
1130 let lbl_size = self.contextual[lbl_idx].layout_child(
1131 ctx,
1132 &BoxConstraints::loose(Size::new(lbl_max_width, SELECT_ALL_HEIGHT)),
1133 );
1134 self.contextual[lbl_idx].set_origin(Point::new(
1135 lbl_left,
1136 height + (SELECT_ALL_HEIGHT - lbl_size.height) / 2.0,
1137 ));
1138 height += SELECT_ALL_HEIGHT;
1139 }
1140
1141 bc.constrain(Size::new(width, height))
1142 }
1143
1144 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1145 if !is_selection_mode(&self.selected) {
1146 self.idle.paint_child(ctx, scene);
1147 return;
1148 }
1149 let theme = Theme::from_paint_ctx(ctx);
1152 let fill = resolve_contextual_background(theme);
1153 scene.fill_rect(ctx.origin(), ctx.size(), fill);
1154 for pod in &mut self.contextual {
1155 pod.paint_child(ctx, scene);
1156 }
1157 }
1158
1159 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1160 if is_selection_mode(&self.selected) {
1161 frust::authoring::route_event(&mut self.contextual, ctx, event)
1162 } else {
1163 frust::authoring::route_event_single(&mut self.idle, ctx, event)
1164 }
1165 }
1166
1167 fn semantics(&self, ctx: &mut SemanticsCtx) {
1168 if !is_selection_mode(&self.selected) {
1169 self.idle.semantics_child(ctx);
1170 return;
1171 }
1172 let count = self.selected.len();
1173 let label = format!("{count} selected");
1174 let contextual = &self.contextual;
1175 ctx.push_container(
1176 Role::TitleBar,
1177 |node| {
1178 node.set_label(label.as_str());
1179 node.add_action(Action::Click);
1180 },
1181 |ctx| {
1182 for pod in contextual {
1183 pod.semantics_child(ctx);
1184 }
1185 },
1186 );
1187 }
1188
1189 frust::authoring::visit_children!(contextual, idle);
1190}
1191
1192#[cfg(test)]
1193mod tests {
1194 use super::*;
1195 use frust::authoring::PointerButton;
1196 use frust::authoring::text::TextContext;
1197 use kurbo::Vec2;
1198 use std::any::Any;
1199
1200 #[test]
1203 fn is_selection_mode_matches_reference_getter() {
1204 assert!(!is_selection_mode(&BTreeSet::new()));
1205 assert!(is_selection_mode(&BTreeSet::from([2])));
1206 }
1207
1208 #[test]
1209 fn is_selected_reads_membership() {
1210 let selected = BTreeSet::from([1, 3]);
1211 assert!(is_selected(&selected, 1));
1212 assert!(!is_selected(&selected, 2));
1213 }
1214
1215 #[test]
1216 fn all_selected_for_matches_reference_tristate_formula() {
1217 assert_eq!(all_selected_for(&BTreeSet::new(), 0), Some(false));
1218 assert_eq!(all_selected_for(&BTreeSet::new(), 3), Some(false));
1219 assert_eq!(all_selected_for(&BTreeSet::from([0, 1, 2]), 3), Some(true));
1220 assert_eq!(
1221 all_selected_for(&BTreeSet::from([0, 1, 2, 5]), 3),
1222 Some(true)
1223 );
1224 assert_eq!(all_selected_for(&BTreeSet::from([0]), 3), None);
1225 }
1226
1227 #[test]
1228 fn select_deselect_toggle_and_select_all_are_pure_next_state_transforms() {
1229 let empty = BTreeSet::new();
1230 assert_eq!(select(&empty, 2), BTreeSet::from([2]));
1231 assert_eq!(empty, BTreeSet::new(), "select must not mutate its input");
1232
1233 let some = BTreeSet::from([1, 2]);
1234 assert_eq!(deselect(&some, 1), BTreeSet::from([2]));
1235 assert_eq!(
1236 deselect(&some, 9),
1237 some,
1238 "deselecting an absent index is a no-op value"
1239 );
1240
1241 assert_eq!(toggle(&some, 1), BTreeSet::from([2]), "toggle off");
1242 assert_eq!(toggle(&some, 5), BTreeSet::from([1, 2, 5]), "toggle on");
1243
1244 assert_eq!(select_all(3), BTreeSet::from([0, 1, 2]));
1245 assert_eq!(select_all(0), BTreeSet::new());
1246 assert_eq!(cleared(), BTreeSet::new());
1247 }
1248
1249 const ROW_HEIGHT: f64 = 40.0;
1252 const WIDTH: f64 = 200.0;
1253
1254 fn build<S: 'static>(view: &SelectionHostView<S>) -> SelectionHostWidget {
1255 let mut counter = 0u64;
1256 View::<S>::build(view, &mut BuildCtx::new(&mut counter))
1257 }
1258
1259 fn layout(w: &mut SelectionHostWidget, height: f64) -> Size {
1260 let mut tcx = TextContext::new();
1261 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1262 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(WIDTH, height)))
1263 }
1264
1265 fn rows<S: 'static>(n: usize) -> Vec<AnyView<S>> {
1266 (0..n)
1267 .map(|_| any::<S, _>(frust::SizedBox(Some(10.0), Some(ROW_HEIGHT))))
1268 .collect()
1269 }
1270
1271 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1272 InputEvent::Pointer(frust::authoring::PointerEvent {
1273 phase,
1274 position: Point::new(x, y),
1275 button: PointerButton::Primary,
1276 })
1277 }
1278
1279 #[derive(Default)]
1280 struct Log {
1281 toggled: Vec<usize>,
1282 activated: Vec<usize>,
1283 }
1284
1285 fn dispatch(w: &mut SelectionHostWidget, state: &mut Log, event: &InputEvent) -> EventResult {
1286 let state_any: &mut dyn Any = state;
1287 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(WIDTH, ROW_HEIGHT * 3.0));
1288 w.event(&mut ctx, event)
1289 }
1290
1291 #[test]
1292 fn layout_stacks_rows_vertically_with_no_gap() {
1293 let selected = BTreeSet::new();
1294 let view: SelectionHostView<Log> =
1295 selection_host(rows(3), &selected, |s: &mut Log, i| s.toggled.push(i));
1296 let mut w = build(&view);
1297 let size = layout(&mut w, 300.0);
1298 assert_eq!(size, Size::new(WIDTH, ROW_HEIGHT * 3.0));
1299 assert_eq!(
1300 w.rows,
1301 vec![
1302 (0.0, ROW_HEIGHT),
1303 (ROW_HEIGHT, ROW_HEIGHT),
1304 (ROW_HEIGHT * 2.0, ROW_HEIGHT)
1305 ]
1306 );
1307 }
1308
1309 #[test]
1310 fn tap_outside_selection_mode_activates_not_toggles() {
1311 let selected = BTreeSet::new();
1312 let view: SelectionHostView<Log> =
1313 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i))
1314 .on_activate(|s: &mut Log, i| s.activated.push(i));
1315 let mut w = build(&view);
1316 layout(&mut w, 300.0);
1317 let mut state = Log::default();
1318 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1319 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1320 assert_eq!(state.activated, vec![0]);
1321 assert!(state.toggled.is_empty());
1322 }
1323
1324 #[test]
1325 fn tap_in_selection_mode_toggles_not_activates() {
1326 let selected = BTreeSet::from([1]);
1327 let view: SelectionHostView<Log> =
1328 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i))
1329 .on_activate(|s: &mut Log, i| s.activated.push(i));
1330 let mut w = build(&view);
1331 layout(&mut w, 300.0);
1332 let mut state = Log::default();
1333 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1335 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1336 assert_eq!(state.toggled, vec![0]);
1337 assert!(state.activated.is_empty());
1338 }
1339
1340 #[test]
1341 fn tap_up_outside_the_armed_row_fires_nothing() {
1342 let selected = BTreeSet::new();
1343 let view: SelectionHostView<Log> =
1344 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i))
1345 .on_activate(|s: &mut Log, i| s.activated.push(i));
1346 let mut w = build(&view);
1347 layout(&mut w, 300.0);
1348 let mut state = Log::default();
1349 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1350 dispatch(
1351 &mut w,
1352 &mut state,
1353 &ev(PointerPhase::Up, 5.0, ROW_HEIGHT + 5.0),
1354 );
1355 assert!(state.activated.is_empty());
1356 assert!(state.toggled.is_empty());
1357 }
1358
1359 #[test]
1360 fn long_press_selects_an_unselected_row_and_suppresses_the_tap() {
1361 let selected = BTreeSet::new();
1362 let view: SelectionHostView<Log> =
1363 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i))
1364 .on_activate(|s: &mut Log, i| s.activated.push(i));
1365 let mut w = build(&view);
1366 layout(&mut w, 300.0);
1367 let mut state = Log::default();
1368
1369 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1370 w.long_press.elapsed = true;
1373 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 6.0, 5.0));
1374 assert_eq!(state.toggled, vec![0], "long-press fired on move-arrival");
1375 assert!(state.activated.is_empty());
1376
1377 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 6.0, 5.0));
1378 assert_eq!(
1379 state.toggled,
1380 vec![0],
1381 "the resolved long-press is not also a tap"
1382 );
1383 }
1384
1385 #[test]
1386 fn long_press_never_deselects_an_already_selected_row() {
1387 let selected = BTreeSet::from([0]);
1388 let view: SelectionHostView<Log> =
1389 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
1390 let mut w = build(&view);
1391 layout(&mut w, 300.0);
1392 let mut state = Log::default();
1393
1394 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1395 w.long_press.elapsed = true;
1396 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 6.0, 5.0));
1397 assert!(
1398 state.toggled.is_empty(),
1399 "a long-press on an already-selected row must not fire on_toggle"
1400 );
1401 }
1402
1403 #[test]
1404 fn long_press_still_fires_at_up_if_no_move_arrives_first() {
1405 let selected = BTreeSet::new();
1406 let view: SelectionHostView<Log> =
1407 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
1408 let mut w = build(&view);
1409 layout(&mut w, 300.0);
1410 let mut state = Log::default();
1411
1412 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1413 w.long_press.elapsed = true;
1414 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1415 assert_eq!(state.toggled, vec![0]);
1416 }
1417
1418 #[test]
1419 fn movement_past_touch_slop_cancels_long_press_candidacy() {
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
1427 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1428 w.long_press.elapsed = true; dispatch(
1430 &mut w,
1431 &mut state,
1432 &ev(PointerPhase::Move, 5.0 + TOUCH_SLOP + 5.0, 5.0),
1433 );
1434 assert!(!w.long_press.elapsed, "a drag cancels the candidacy");
1435 assert!(state.toggled.is_empty());
1436 }
1437
1438 #[test]
1439 fn cancel_clears_state_without_firing() {
1440 let selected = BTreeSet::new();
1441 let view: SelectionHostView<Log> =
1442 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
1443 let mut w = build(&view);
1444 layout(&mut w, 300.0);
1445 let mut state = Log::default();
1446
1447 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1448 assert!(w.armed.is_some());
1449 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 5.0, 5.0));
1450 assert!(w.armed.is_none());
1451 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1452 assert!(state.toggled.is_empty());
1453 }
1454
1455 #[test]
1456 fn disabled_router_arms_nothing() {
1457 let selected = BTreeSet::new();
1458 let view: SelectionHostView<Log> =
1459 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i)).enabled(false);
1460 let mut w = build(&view);
1461 layout(&mut w, 300.0);
1462 let mut state = Log::default();
1463 assert_eq!(
1464 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0)),
1465 EventResult::Ignored
1466 );
1467 assert!(w.armed.is_none());
1468 }
1469
1470 #[test]
1471 fn a_secondary_press_never_arms_a_row() {
1472 let selected = BTreeSet::new();
1473 let view: SelectionHostView<Log> =
1474 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
1475 let mut w = build(&view);
1476 layout(&mut w, 300.0);
1477 let mut state = Log::default();
1478 let secondary = InputEvent::Pointer(frust::authoring::PointerEvent {
1479 phase: PointerPhase::Down,
1480 position: Point::new(5.0, 5.0),
1481 button: PointerButton::Secondary,
1482 });
1483 assert_eq!(
1484 dispatch(&mut w, &mut state, &secondary),
1485 EventResult::Ignored
1486 );
1487 assert!(w.armed.is_none());
1488 }
1489
1490 #[test]
1491 fn external_clear_collapses_mode_on_the_next_dispatch() {
1492 let mut counter = 0u64;
1496 let prev_selected = BTreeSet::from([0]);
1497 let prev: SelectionHostView<Log> =
1498 selection_host(rows(2), &prev_selected, |s: &mut Log, i| s.toggled.push(i))
1499 .on_activate(|s: &mut Log, i| s.activated.push(i));
1500 let mut w = View::<Log>::build(&prev, &mut BuildCtx::new(&mut counter));
1501 layout(&mut w, 300.0);
1502
1503 let next_selected = BTreeSet::new();
1504 let next: SelectionHostView<Log> =
1505 selection_host(rows(2), &next_selected, |s: &mut Log, i| s.toggled.push(i))
1506 .on_activate(|s: &mut Log, i| s.activated.push(i));
1507 View::<Log>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1508 assert!(!is_selection_mode(&w.selected));
1509
1510 let mut state = Log::default();
1511 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1512 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1513 assert_eq!(
1514 state.activated,
1515 vec![0],
1516 "collapsed mode resumes tap-to-activate"
1517 );
1518 assert!(state.toggled.is_empty());
1519 }
1520
1521 fn idle_view<S: 'static>() -> AnyView<S> {
1524 any::<S, _>(frust::SizedBox(Some(0.0), Some(30.0)))
1525 }
1526
1527 fn bar_build<S: 'static>(view: &SelectionAppBarView<S>) -> SelectionAppBarWidget {
1528 let mut counter = 0u64;
1529 View::<S>::build(view, &mut BuildCtx::new(&mut counter))
1530 }
1531
1532 fn bar_layout(w: &mut SelectionAppBarWidget, width: f64) -> Size {
1533 let mut tcx = TextContext::new();
1534 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1535 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(width, 400.0)))
1536 }
1537
1538 #[test]
1539 fn idle_mode_uses_the_idle_contents_own_height() {
1540 let selected = BTreeSet::new();
1541 let view: SelectionAppBarView<()> =
1542 selection_app_bar(idle_view(), &selected, 3, |_: &mut ()| {});
1543 let mut w = bar_build(&view);
1544 let size = bar_layout(&mut w, 300.0);
1545 assert_eq!(size, Size::new(300.0, 30.0));
1546 }
1547
1548 #[test]
1549 fn contextual_mode_height_matches_app_bar_height_metric() {
1550 let selected = BTreeSet::from([0]);
1551 let view: SelectionAppBarView<()> =
1552 selection_app_bar(idle_view(), &selected, 3, |_: &mut ()| {}).show_select_all(false);
1553 let mut w = bar_build(&view);
1554 let size = bar_layout(&mut w, 300.0);
1555 assert_eq!(
1556 size.height, APP_BAR_HEIGHT,
1557 "the toolbar band matches appbar.rs's own HEIGHT metric"
1558 );
1559 }
1560
1561 fn notched() -> frust::authoring::WindowInsets {
1565 frust::authoring::WindowInsets::new(
1566 frust::authoring::WindowEdgeInsets::new(47.0, 24.0, 47.0, 0.0),
1567 frust::authoring::WindowEdgeInsets::ZERO,
1568 )
1569 }
1570
1571 fn bar_layout_with(
1572 w: &mut SelectionAppBarWidget,
1573 width: f64,
1574 insets: frust::authoring::WindowInsets,
1575 ) -> Size {
1576 let mut tcx = TextContext::new();
1577 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1578 lctx.with_window_insets(insets, |ctx| {
1579 w.layout(ctx, &BoxConstraints::loose(Size::new(width, 400.0)))
1580 })
1581 }
1582
1583 fn real_bar(selected: &BTreeSet<usize>, safe_area: bool) -> SelectionAppBarView<()> {
1586 use frust_widgets::test_support::leaf_any;
1587 let idle = crate::app_bar::<()>("Inbox")
1588 .leading(leaf_any(48.0, 48.0))
1589 .actions(vec![leaf_any(48.0, 48.0)])
1590 .safe_area(safe_area);
1591 selection_app_bar(idle, selected, 3, |_: &mut ()| {})
1592 .show_select_all(false)
1593 .actions(vec![leaf_any(48.0, 48.0)])
1594 .safe_area(safe_area)
1595 }
1596
1597 #[test]
1598 fn idle_to_selection_keeps_height_and_slot_offsets_under_window_insets() {
1599 let idle_set = BTreeSet::new();
1600 let selected = BTreeSet::from([0]);
1601
1602 let mut idle = bar_build(&real_bar(&idle_set, true));
1603 let idle_size = bar_layout_with(&mut idle, 800.0, notched());
1604 let mut ctx_w = bar_build(&real_bar(&selected, true));
1605 let ctx_size = bar_layout_with(&mut ctx_w, 800.0, notched());
1606
1607 assert_eq!(idle_size, Size::new(800.0, 24.0 + APP_BAR_HEIGHT));
1608 assert_eq!(ctx_size, idle_size, "the swap keeps the bar's height");
1609
1610 let close = &ctx_w.contextual[ctx_w.contextual_slots.close];
1613 assert_eq!(
1614 close.origin().x,
1615 47.0 + APP_BAR_PAD_X + (ACTION_SLOT - close.size().width).max(0.0) / 2.0
1616 );
1617 assert_eq!(
1618 close.origin().y,
1619 24.0 + (APP_BAR_HEIGHT - close.size().height) / 2.0
1620 );
1621 let action = &ctx_w.contextual[ctx_w.contextual_slots.actions_start];
1622 assert_eq!(
1623 action.origin().x + action.size().width,
1624 800.0 - 47.0 - APP_BAR_PAD_X
1625 );
1626 assert_eq!(action.origin().y, 24.0 + (APP_BAR_HEIGHT - 48.0) / 2.0);
1627 let count = &ctx_w.contextual[ctx_w.contextual_slots.count_text];
1628 assert_eq!(
1629 count.origin().x,
1630 47.0 + APP_BAR_PAD_X + ACTION_SLOT + APP_BAR_GAP
1631 );
1632
1633 let mut scene = RectRecorder::default();
1635 let mut pctx = PaintCtx::new(Point::ZERO, ctx_size);
1636 ctx_w.paint(&mut pctx, &mut scene);
1637 assert_eq!(scene.rects[0], (Point::ZERO, ctx_size));
1638 }
1639
1640 #[test]
1641 fn idle_to_selection_stays_continuous_under_a_consuming_safe_area() {
1642 let idle_set = BTreeSet::new();
1643 let selected = BTreeSet::from([0]);
1644 let consumed = notched().consuming(true, true, true, false);
1645
1646 let mut idle = bar_build(&real_bar(&idle_set, true));
1648 let idle_size = bar_layout_with(&mut idle, 706.0, consumed);
1649 let mut ctx_w = bar_build(&real_bar(&selected, true));
1650 let ctx_size = bar_layout_with(&mut ctx_w, 706.0, consumed);
1651 assert_eq!(idle_size.height, APP_BAR_HEIGHT);
1652 assert_eq!(ctx_size, idle_size);
1653 let close = &ctx_w.contextual[ctx_w.contextual_slots.close];
1654 assert_eq!(
1655 close.origin().x,
1656 APP_BAR_PAD_X + (ACTION_SLOT - close.size().width).max(0.0) / 2.0
1657 );
1658 let action = &ctx_w.contextual[ctx_w.contextual_slots.actions_start];
1659 assert_eq!(
1660 action.origin().x + action.size().width,
1661 706.0 - APP_BAR_PAD_X
1662 );
1663
1664 let heights: Vec<f64> = [&idle_set, &selected]
1666 .into_iter()
1667 .map(|set| {
1668 let wrapped = frust::safe_area(real_bar(set, true)).bottom(false);
1669 let mut counter = 0u64;
1670 let mut w = View::<()>::build(&wrapped, &mut BuildCtx::new(&mut counter));
1671 let mut tcx = TextContext::new();
1672 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1673 lctx.with_window_insets(notched(), |ctx| {
1674 w.layout(ctx, &BoxConstraints::loose(Size::new(800.0, 400.0)))
1675 })
1676 .height
1677 })
1678 .collect();
1679 assert_eq!(heights, vec![24.0 + APP_BAR_HEIGHT; 2]);
1680 }
1681
1682 #[test]
1683 fn safe_area_false_opts_the_contextual_branch_out_like_the_idle_bar() {
1684 let idle_set = BTreeSet::new();
1685 let selected = BTreeSet::from([0]);
1686 let mut idle = bar_build(&real_bar(&idle_set, false));
1687 let idle_size = bar_layout_with(&mut idle, 800.0, notched());
1688 let mut ctx_w = bar_build(&real_bar(&selected, false));
1689 let ctx_size = bar_layout_with(&mut ctx_w, 800.0, notched());
1690 assert_eq!(idle_size.height, APP_BAR_HEIGHT);
1691 assert_eq!(ctx_size, idle_size);
1692 let action = &ctx_w.contextual[ctx_w.contextual_slots.actions_start];
1693 assert_eq!(
1694 action.origin().x + action.size().width,
1695 800.0 - APP_BAR_PAD_X
1696 );
1697
1698 let mut counter = 0u64;
1700 let prev = real_bar(&selected, false);
1701 let next = real_bar(&selected, true);
1702 let flags = View::<()>::rebuild(&next, &prev, &mut ctx_w, &mut BuildCtx::new(&mut counter));
1703 assert!(flags.needs_layout());
1704 assert!(ctx_w.safe_area);
1705 }
1706
1707 #[test]
1708 fn contextual_mode_with_select_all_adds_the_select_all_row_height() {
1709 let selected = BTreeSet::from([0]);
1710 let view: SelectionAppBarView<()> =
1711 selection_app_bar(idle_view(), &selected, 3, |_: &mut ()| {});
1712 let mut w = bar_build(&view);
1713 let size = bar_layout(&mut w, 300.0);
1714 assert_eq!(size.height, APP_BAR_HEIGHT + SELECT_ALL_HEIGHT);
1715 }
1716
1717 #[test]
1718 fn unthemed_paint_fills_the_contextual_background_only_in_selection_mode() {
1719 let idle_selected = BTreeSet::new();
1720 let idle: SelectionAppBarView<()> =
1721 selection_app_bar(idle_view(), &idle_selected, 3, |_: &mut ()| {});
1722 let mut idle_w = bar_build(&idle);
1723 bar_layout(&mut idle_w, 300.0);
1724 let mut idle_scene = RectRecorder::default();
1725 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(300.0, 30.0));
1726 idle_w.paint(&mut ctx, &mut idle_scene);
1727 assert!(
1728 idle_scene.rects.is_empty(),
1729 "idle mode paints no background of its own"
1730 );
1731
1732 let contextual_selected = BTreeSet::from([0]);
1733 let contextual: SelectionAppBarView<()> =
1734 selection_app_bar(idle_view(), &contextual_selected, 3, |_: &mut ()| {});
1735 let mut contextual_w = bar_build(&contextual);
1736 let size = bar_layout(&mut contextual_w, 300.0);
1737 let mut contextual_scene = RectRecorder::default();
1738 let mut ctx = PaintCtx::new(Point::ZERO, size);
1739 contextual_w.paint(&mut ctx, &mut contextual_scene);
1740 assert_eq!(contextual_scene.rects[0].1, size);
1744 assert!(!contextual_scene.rects.is_empty());
1745 }
1746
1747 #[derive(Default)]
1748 struct RectRecorder {
1749 rects: Vec<(Point, Size)>,
1750 }
1751 impl PaintScene for RectRecorder {
1752 fn fill_rect(&mut self, o: Point, s: Size, _c: Color) {
1753 self.rects.push((o, s));
1754 }
1755 fn draw_text(&mut self, _o: Point, _t: &str) {}
1756 }
1757
1758 #[test]
1759 fn close_button_press_fires_on_clear() {
1760 #[derive(Default)]
1761 struct S {
1762 cleared: u32,
1763 }
1764 let selected = BTreeSet::from([0]);
1765 let view: SelectionAppBarView<S> =
1766 selection_app_bar(idle_view::<S>(), &selected, 3, |s: &mut S| s.cleared += 1);
1767 let mut w = bar_build(&view);
1768 bar_layout(&mut w, 300.0);
1769
1770 let close_pod = &w.contextual[w.contextual_slots.close];
1771 let center = close_pod.origin()
1772 + Vec2::new(close_pod.size().width / 2.0, close_pod.size().height / 2.0);
1773
1774 let mut state = S::default();
1775 let state_any: &mut dyn Any = &mut state;
1776 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(300.0, APP_BAR_HEIGHT));
1777 w.event(&mut ctx, &ev(PointerPhase::Down, center.x, center.y));
1778 w.event(&mut ctx, &ev(PointerPhase::Up, center.x, center.y));
1779 assert_eq!(state.cleared, 1);
1780 }
1781
1782 #[test]
1783 fn select_all_reports_binary_flip_treating_partial_as_not_all() {
1784 #[derive(Default)]
1785 struct S {
1786 calls: Vec<bool>,
1787 }
1788
1789 fn bar(selected: &BTreeSet<usize>) -> SelectionAppBarView<S> {
1790 selection_app_bar(idle_view::<S>(), selected, 3, |_: &mut S| {})
1791 .on_all_selected(|s: &mut S, all| s.calls.push(all))
1792 }
1793
1794 fn press_checkbox(w: &mut SelectionAppBarWidget, state: &mut S) {
1795 let (cb_idx, _) = w.contextual_slots.select_all.expect("select-all present");
1796 let pod = &w.contextual[cb_idx];
1797 let center = pod.origin() + Vec2::new(pod.size().width / 2.0, pod.size().height / 2.0);
1798 let state_any: &mut dyn Any = state;
1799 let mut ctx = EventCtx::new(
1800 state_any,
1801 Point::ZERO,
1802 Size::new(300.0, APP_BAR_HEIGHT + SELECT_ALL_HEIGHT),
1803 );
1804 w.event(&mut ctx, &ev(PointerPhase::Down, center.x, center.y));
1805 w.event(&mut ctx, &ev(PointerPhase::Up, center.x, center.y));
1806 }
1807
1808 let none = BTreeSet::from([0]); let view = bar(&none);
1811 let mut w = bar_build(&view);
1812 bar_layout(&mut w, 300.0);
1813 let mut state = S::default();
1814 press_checkbox(&mut w, &mut state);
1815 assert_eq!(
1816 state.calls,
1817 vec![true],
1818 "a partial selection reports true (not-all)"
1819 );
1820
1821 let all = BTreeSet::from([0, 1, 2]);
1823 let view = bar(&all);
1824 let mut w = bar_build(&view);
1825 bar_layout(&mut w, 300.0);
1826 let mut state = S::default();
1827 press_checkbox(&mut w, &mut state);
1828 assert_eq!(state.calls, vec![false]);
1829 }
1830
1831 #[test]
1832 fn external_clear_collapses_the_bar_back_to_idle() {
1833 let mut counter = 0u64;
1836 let prev_selected = BTreeSet::from([0]);
1837 let prev: SelectionAppBarView<()> =
1838 selection_app_bar(idle_view(), &prev_selected, 3, |_: &mut ()| {});
1839 let mut w = View::<()>::build(&prev, &mut BuildCtx::new(&mut counter));
1840 bar_layout(&mut w, 300.0);
1841 assert!(is_selection_mode(&w.selected));
1842
1843 let next_selected = BTreeSet::new();
1844 let next: SelectionAppBarView<()> =
1845 selection_app_bar(idle_view(), &next_selected, 3, |_: &mut ()| {});
1846 View::<()>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1847 assert!(!is_selection_mode(&w.selected));
1848
1849 let size = bar_layout(&mut w, 300.0);
1850 assert_eq!(
1851 size,
1852 Size::new(300.0, 30.0),
1853 "layout falls back to idle's own height"
1854 );
1855 }
1856
1857 #[test]
1858 fn semantics_reports_count_selected_label_in_contextual_mode() {
1859 fn logic(_s: &mut ()) -> SelectionAppBarView<()> {
1860 let selected = BTreeSet::from([0, 1]);
1861 selection_app_bar(idle_view(), &selected, 3, |_: &mut ()| {})
1862 }
1863 let mut root: frust_core::RenderRoot<(), SelectionAppBarView<()>> =
1864 frust_core::RenderRoot::new();
1865 let mut state = ();
1866 root.rebuild(&mut logic, &mut state);
1867 let mut tcx = TextContext::new();
1868 root.layout_with_text(Size::new(300.0, 200.0), &mut tcx as &mut dyn Any);
1869 let update = root.semantics();
1870 let (_, node) = update
1871 .nodes
1872 .iter()
1873 .find(|(_, n)| n.role() == Role::TitleBar)
1874 .expect("a TitleBar node is contributed in selection mode");
1875 assert_eq!(node.label(), Some("2 selected"));
1876 }
1877}