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