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>) -> AnyView<State> {
764 any(
765 icon_button(icon(crate::icons::CLOSE), move |state: &mut State| {
766 (on_clear)(state);
767 })
768 .semantic_label(CLEAR_SELECTION_LABEL),
769 )
770}
771
772fn count_view<State: 'static>(count: usize) -> AnyView<State> {
776 any::<State, _>(
777 text(count.to_string())
778 .size(COUNT_SIZE)
779 .weight(COUNT_WEIGHT)
780 .line_height(LineHeight::Absolute(COUNT_LINE_HEIGHT))
781 .themed_role(ThemeTextColor::OnPrimaryContainer)
782 .themed_family(ThemeTextType::TitleLargeEmphasized),
783 )
784}
785
786fn select_all_checkbox_view<State: 'static>(
792 selected: BTreeSet<usize>,
793 item_count: usize,
794 on_all_selected: Option<OnAllSelected<State>>,
795) -> AnyView<State> {
796 let value = all_selected_for(&selected, item_count);
797 any(crate::checkbox::tristate_checkbox::<State, _>(
798 value,
799 move |state: &mut State, _next: Option<bool>| {
800 if let Some(cb) = &on_all_selected {
801 cb(state, !matches!(value, Some(true)));
802 }
803 },
804 ))
805}
806
807fn select_all_label_view<State: 'static>(label: String) -> AnyView<State> {
810 any::<State, _>(
811 text(label)
812 .themed_role(ThemeTextColor::OnPrimaryContainer)
813 .themed_family(ThemeTextType::BodyLarge),
814 )
815}
816
817fn resolve_contextual_background(theme: Option<&Theme>) -> Color {
820 match theme {
821 Some(theme) => theme.scheme().primary_container,
822 None => CONTEXTUAL_BACKGROUND,
823 }
824}
825
826#[derive(Clone, Copy)]
829struct ContextualSlots {
830 close: usize,
831 count_text: usize,
832 actions_start: usize,
833 actions_len: usize,
834 select_all: Option<(usize, usize)>,
837}
838
839fn build_contextual_children<State: 'static>(
842 view: &SelectionAppBarView<State>,
843 ctx: &mut BuildCtx<'_>,
844) -> (Vec<ChildPod>, ContextualSlots) {
845 let mut children = Vec::new();
846
847 let close_v = close_view::<State>(view.on_clear.clone());
848 children.push(frust::authoring::build_child(&close_v, ctx));
849 let close = 0;
850
851 let count_v = count_view::<State>(view.selected.len());
852 children.push(frust::authoring::build_child(&count_v, ctx));
853 let count_text = 1;
854
855 let actions_start = children.len();
856 for action in &view.actions {
857 children.push(frust::authoring::build_child(action, ctx));
858 }
859 let actions_len = view.actions.len();
860
861 let select_all = view.show_select_all.then(|| {
862 let cb = select_all_checkbox_view::<State>(
863 view.selected.clone(),
864 view.item_count,
865 view.on_all_selected.clone(),
866 );
867 let lbl = select_all_label_view::<State>(view.select_all_label.clone());
868 let cb_idx = children.len();
869 children.push(frust::authoring::build_child(&cb, ctx));
870 let lbl_idx = children.len();
871 children.push(frust::authoring::build_child(&lbl, ctx));
872 (cb_idx, lbl_idx)
873 });
874
875 (
876 children,
877 ContextualSlots {
878 close,
879 count_text,
880 actions_start,
881 actions_len,
882 select_all,
883 },
884 )
885}
886
887fn teardown_contextual_children<State: 'static>(
891 view: &SelectionAppBarView<State>,
892 slots: ContextualSlots,
893 children: &mut [ChildPod],
894 ctx: &mut BuildCtx<'_>,
895) {
896 let close_v = close_view::<State>(view.on_clear.clone());
897 frust::authoring::teardown_child(&close_v, &mut children[slots.close], ctx);
898
899 let count_v = count_view::<State>(view.selected.len());
900 frust::authoring::teardown_child(&count_v, &mut children[slots.count_text], ctx);
901
902 for i in 0..slots.actions_len {
903 frust::authoring::teardown_child(
904 &view.actions[i],
905 &mut children[slots.actions_start + i],
906 ctx,
907 );
908 }
909
910 if let Some((cb_idx, lbl_idx)) = slots.select_all {
911 let cb = select_all_checkbox_view::<State>(
912 view.selected.clone(),
913 view.item_count,
914 view.on_all_selected.clone(),
915 );
916 frust::authoring::teardown_child(&cb, &mut children[cb_idx], ctx);
917 let lbl = select_all_label_view::<State>(view.select_all_label.clone());
918 frust::authoring::teardown_child(&lbl, &mut children[lbl_idx], ctx);
919 }
920}
921
922pub struct SelectionAppBarWidget {
925 idle: ChildPod,
926 contextual: Vec<ChildPod>,
929 contextual_slots: ContextualSlots,
930 selected: BTreeSet<usize>,
931 item_count: usize,
932 show_select_all: bool,
933 select_all_label: String,
934 safe_area: bool,
937}
938
939impl<State: 'static> View<State> for SelectionAppBarView<State> {
940 type Element = SelectionAppBarWidget;
941
942 fn build(&self, ctx: &mut BuildCtx<'_>) -> SelectionAppBarWidget {
943 let (contextual, contextual_slots) = build_contextual_children(self, ctx);
944 SelectionAppBarWidget {
945 idle: frust::authoring::build_child(&self.idle, ctx),
946 contextual,
947 contextual_slots,
948 selected: self.selected.clone(),
949 item_count: self.item_count,
950 show_select_all: self.show_select_all,
951 select_all_label: self.select_all_label.clone(),
952 safe_area: self.safe_area,
953 }
954 }
955
956 fn rebuild(
957 &self,
958 prev: &Self,
959 element: &mut SelectionAppBarWidget,
960 ctx: &mut BuildCtx<'_>,
961 ) -> ChangeFlags {
962 let mut flags =
963 frust::authoring::rebuild_child(&prev.idle, &self.idle, &mut element.idle, ctx);
964
965 if prev.shape() != self.shape() {
966 teardown_contextual_children(
967 prev,
968 element.contextual_slots,
969 &mut element.contextual,
970 ctx,
971 );
972 let (children, slots) = build_contextual_children(self, ctx);
973 element.contextual = children;
974 element.contextual_slots = slots;
975 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
976 } else {
977 let slots = element.contextual_slots;
978
979 let prev_close = close_view::<State>(prev.on_clear.clone());
980 let next_close = close_view::<State>(self.on_clear.clone());
981 flags |= frust::authoring::rebuild_child(
982 &prev_close,
983 &next_close,
984 &mut element.contextual[slots.close],
985 ctx,
986 );
987
988 let prev_count = count_view::<State>(prev.selected.len());
989 let next_count = count_view::<State>(self.selected.len());
990 flags |= frust::authoring::rebuild_child(
991 &prev_count,
992 &next_count,
993 &mut element.contextual[slots.count_text],
994 ctx,
995 );
996
997 for i in 0..slots.actions_len {
998 flags |= frust::authoring::rebuild_child(
999 &prev.actions[i],
1000 &self.actions[i],
1001 &mut element.contextual[slots.actions_start + i],
1002 ctx,
1003 );
1004 }
1005
1006 if let Some((cb_idx, lbl_idx)) = slots.select_all {
1007 let prev_cb = select_all_checkbox_view::<State>(
1008 prev.selected.clone(),
1009 prev.item_count,
1010 prev.on_all_selected.clone(),
1011 );
1012 let next_cb = select_all_checkbox_view::<State>(
1013 self.selected.clone(),
1014 self.item_count,
1015 self.on_all_selected.clone(),
1016 );
1017 flags |= frust::authoring::rebuild_child(
1018 &prev_cb,
1019 &next_cb,
1020 &mut element.contextual[cb_idx],
1021 ctx,
1022 );
1023
1024 let prev_lbl = select_all_label_view::<State>(prev.select_all_label.clone());
1025 let next_lbl = select_all_label_view::<State>(self.select_all_label.clone());
1026 flags |= frust::authoring::rebuild_child(
1027 &prev_lbl,
1028 &next_lbl,
1029 &mut element.contextual[lbl_idx],
1030 ctx,
1031 );
1032 }
1033 }
1034
1035 if element.selected != self.selected {
1036 element.selected = self.selected.clone();
1037 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
1039 }
1040 if element.safe_area != self.safe_area {
1041 element.safe_area = self.safe_area;
1042 flags |= ChangeFlags::LAYOUT;
1043 }
1044 element.item_count = self.item_count;
1045 element.show_select_all = self.show_select_all;
1046 element.select_all_label = self.select_all_label.clone();
1047 flags
1048 }
1049
1050 fn teardown(&self, element: &mut SelectionAppBarWidget, ctx: &mut BuildCtx<'_>) {
1051 frust::authoring::teardown_child(&self.idle, &mut element.idle, ctx);
1052 teardown_contextual_children(self, element.contextual_slots, &mut element.contextual, ctx);
1053 }
1054}
1055
1056impl Widget for SelectionAppBarWidget {
1057 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1058 let width = if bc.max().width.is_finite() {
1059 bc.max().width
1060 } else {
1061 0.0
1062 };
1063
1064 if !is_selection_mode(&self.selected) {
1065 let idle_size = self
1066 .idle
1067 .layout_child(ctx, &BoxConstraints::loose(Size::new(width, f64::INFINITY)));
1068 self.idle.set_origin(Point::ZERO);
1069 return bc.constrain(Size::new(width, idle_size.height));
1070 }
1071
1072 let (pad_l, top, pad_r) = if self.safe_area {
1077 let padding = ctx.window_insets().padding();
1078 (
1079 padding.left.max(0.0),
1080 padding.top.max(0.0),
1081 padding.right.max(0.0),
1082 )
1083 } else {
1084 (0.0, 0.0, 0.0)
1085 };
1086 let leading_edge = pad_l + APP_BAR_PAD_X;
1087 let trailing_edge = width - pad_r - APP_BAR_PAD_X;
1088
1089 let slots = self.contextual_slots;
1090 let slot_bc = BoxConstraints::loose(Size::new(f64::INFINITY, APP_BAR_HEIGHT));
1091
1092 let close_size = self.contextual[slots.close].layout_child(ctx, &slot_bc);
1093 self.contextual[slots.close].set_origin(Point::new(
1094 leading_edge + (ACTION_SLOT - close_size.width).max(0.0) / 2.0,
1095 top + (APP_BAR_HEIGHT - close_size.height) / 2.0,
1096 ));
1097
1098 let mut right = trailing_edge;
1101 for i in (0..slots.actions_len).rev() {
1102 let idx = slots.actions_start + i;
1103 let size = self.contextual[idx].layout_child(ctx, &slot_bc);
1104 right -= size.width;
1105 self.contextual[idx].set_origin(Point::new(
1106 right,
1107 top + (APP_BAR_HEIGHT - size.height) / 2.0,
1108 ));
1109 right -= APP_BAR_GAP;
1110 }
1111
1112 let count_left = leading_edge + ACTION_SLOT + APP_BAR_GAP;
1113 let count_max_width = (right - count_left).max(0.0);
1114 let count_size = self.contextual[slots.count_text].layout_child(
1115 ctx,
1116 &BoxConstraints::loose(Size::new(count_max_width, APP_BAR_HEIGHT)),
1117 );
1118 self.contextual[slots.count_text].set_origin(Point::new(
1119 count_left,
1120 top + (APP_BAR_HEIGHT - count_size.height) / 2.0,
1121 ));
1122
1123 let mut height = top + APP_BAR_HEIGHT;
1124 if let Some((cb_idx, lbl_idx)) = slots.select_all {
1125 let cb_bc = BoxConstraints::loose(Size::new(f64::INFINITY, SELECT_ALL_HEIGHT));
1126 let cb_size = self.contextual[cb_idx].layout_child(ctx, &cb_bc);
1127 self.contextual[cb_idx].set_origin(Point::new(
1128 leading_edge + (ACTION_SLOT - cb_size.width).max(0.0) / 2.0,
1129 height + (SELECT_ALL_HEIGHT - cb_size.height) / 2.0,
1130 ));
1131
1132 let lbl_left = leading_edge + ACTION_SLOT + APP_BAR_GAP;
1133 let lbl_max_width = (trailing_edge - lbl_left).max(0.0);
1134 let lbl_size = self.contextual[lbl_idx].layout_child(
1135 ctx,
1136 &BoxConstraints::loose(Size::new(lbl_max_width, SELECT_ALL_HEIGHT)),
1137 );
1138 self.contextual[lbl_idx].set_origin(Point::new(
1139 lbl_left,
1140 height + (SELECT_ALL_HEIGHT - lbl_size.height) / 2.0,
1141 ));
1142 height += SELECT_ALL_HEIGHT;
1143 }
1144
1145 bc.constrain(Size::new(width, height))
1146 }
1147
1148 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1149 if !is_selection_mode(&self.selected) {
1150 self.idle.paint_child(ctx, scene);
1151 return;
1152 }
1153 let theme = Theme::from_paint_ctx(ctx);
1156 let fill = resolve_contextual_background(theme);
1157 scene.fill_rect(ctx.origin(), ctx.size(), fill);
1158 for pod in &mut self.contextual {
1159 pod.paint_child(ctx, scene);
1160 }
1161 }
1162
1163 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1164 if is_selection_mode(&self.selected) {
1165 frust::authoring::route_event(&mut self.contextual, ctx, event)
1166 } else {
1167 frust::authoring::route_event_single(&mut self.idle, ctx, event)
1168 }
1169 }
1170
1171 fn semantics(&self, ctx: &mut SemanticsCtx) {
1172 if !is_selection_mode(&self.selected) {
1173 self.idle.semantics_child(ctx);
1174 return;
1175 }
1176 let count = self.selected.len();
1177 let label = format!("{count} selected");
1178 let contextual = &self.contextual;
1179 ctx.push_container(
1180 Role::TitleBar,
1181 |node| {
1182 node.set_label(label.as_str());
1183 node.add_action(Action::Click);
1184 },
1185 |ctx| {
1186 for pod in contextual {
1187 pod.semantics_child(ctx);
1188 }
1189 },
1190 );
1191 }
1192
1193 frust::authoring::visit_children!(contextual, idle);
1194}
1195
1196#[cfg(test)]
1197mod tests {
1198 use super::*;
1199 use frust::authoring::PointerButton;
1200 use frust::authoring::text::TextContext;
1201 use kurbo::Vec2;
1202 use std::any::Any;
1203
1204 #[test]
1207 fn is_selection_mode_matches_reference_getter() {
1208 assert!(!is_selection_mode(&BTreeSet::new()));
1209 assert!(is_selection_mode(&BTreeSet::from([2])));
1210 }
1211
1212 #[test]
1213 fn is_selected_reads_membership() {
1214 let selected = BTreeSet::from([1, 3]);
1215 assert!(is_selected(&selected, 1));
1216 assert!(!is_selected(&selected, 2));
1217 }
1218
1219 #[test]
1220 fn all_selected_for_matches_reference_tristate_formula() {
1221 assert_eq!(all_selected_for(&BTreeSet::new(), 0), Some(false));
1222 assert_eq!(all_selected_for(&BTreeSet::new(), 3), Some(false));
1223 assert_eq!(all_selected_for(&BTreeSet::from([0, 1, 2]), 3), Some(true));
1224 assert_eq!(
1225 all_selected_for(&BTreeSet::from([0, 1, 2, 5]), 3),
1226 Some(true)
1227 );
1228 assert_eq!(all_selected_for(&BTreeSet::from([0]), 3), None);
1229 }
1230
1231 #[test]
1232 fn select_deselect_toggle_and_select_all_are_pure_next_state_transforms() {
1233 let empty = BTreeSet::new();
1234 assert_eq!(select(&empty, 2), BTreeSet::from([2]));
1235 assert_eq!(empty, BTreeSet::new(), "select must not mutate its input");
1236
1237 let some = BTreeSet::from([1, 2]);
1238 assert_eq!(deselect(&some, 1), BTreeSet::from([2]));
1239 assert_eq!(
1240 deselect(&some, 9),
1241 some,
1242 "deselecting an absent index is a no-op value"
1243 );
1244
1245 assert_eq!(toggle(&some, 1), BTreeSet::from([2]), "toggle off");
1246 assert_eq!(toggle(&some, 5), BTreeSet::from([1, 2, 5]), "toggle on");
1247
1248 assert_eq!(select_all(3), BTreeSet::from([0, 1, 2]));
1249 assert_eq!(select_all(0), BTreeSet::new());
1250 assert_eq!(cleared(), BTreeSet::new());
1251 }
1252
1253 const ROW_HEIGHT: f64 = 40.0;
1256 const WIDTH: f64 = 200.0;
1257
1258 fn build<S: 'static>(view: &SelectionHostView<S>) -> SelectionHostWidget {
1259 let mut counter = 0u64;
1260 View::<S>::build(view, &mut BuildCtx::new(&mut counter))
1261 }
1262
1263 fn layout(w: &mut SelectionHostWidget, height: f64) -> Size {
1264 let mut tcx = TextContext::new();
1265 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1266 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(WIDTH, height)))
1267 }
1268
1269 fn rows<S: 'static>(n: usize) -> Vec<AnyView<S>> {
1270 (0..n)
1271 .map(|_| any::<S, _>(frust::SizedBox(Some(10.0), Some(ROW_HEIGHT))))
1272 .collect()
1273 }
1274
1275 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1276 InputEvent::Pointer(frust::authoring::PointerEvent {
1277 phase,
1278 position: Point::new(x, y),
1279 button: PointerButton::Primary,
1280 })
1281 }
1282
1283 #[derive(Default)]
1284 struct Log {
1285 toggled: Vec<usize>,
1286 activated: Vec<usize>,
1287 }
1288
1289 fn dispatch(w: &mut SelectionHostWidget, state: &mut Log, event: &InputEvent) -> EventResult {
1290 let state_any: &mut dyn Any = state;
1291 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(WIDTH, ROW_HEIGHT * 3.0));
1292 w.event(&mut ctx, event)
1293 }
1294
1295 #[test]
1296 fn layout_stacks_rows_vertically_with_no_gap() {
1297 let selected = BTreeSet::new();
1298 let view: SelectionHostView<Log> =
1299 selection_host(rows(3), &selected, |s: &mut Log, i| s.toggled.push(i));
1300 let mut w = build(&view);
1301 let size = layout(&mut w, 300.0);
1302 assert_eq!(size, Size::new(WIDTH, ROW_HEIGHT * 3.0));
1303 assert_eq!(
1304 w.rows,
1305 vec![
1306 (0.0, ROW_HEIGHT),
1307 (ROW_HEIGHT, ROW_HEIGHT),
1308 (ROW_HEIGHT * 2.0, ROW_HEIGHT)
1309 ]
1310 );
1311 }
1312
1313 #[test]
1314 fn tap_outside_selection_mode_activates_not_toggles() {
1315 let selected = BTreeSet::new();
1316 let view: SelectionHostView<Log> =
1317 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i))
1318 .on_activate(|s: &mut Log, i| s.activated.push(i));
1319 let mut w = build(&view);
1320 layout(&mut w, 300.0);
1321 let mut state = Log::default();
1322 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1323 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1324 assert_eq!(state.activated, vec![0]);
1325 assert!(state.toggled.is_empty());
1326 }
1327
1328 #[test]
1329 fn tap_in_selection_mode_toggles_not_activates() {
1330 let selected = BTreeSet::from([1]);
1331 let view: SelectionHostView<Log> =
1332 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i))
1333 .on_activate(|s: &mut Log, i| s.activated.push(i));
1334 let mut w = build(&view);
1335 layout(&mut w, 300.0);
1336 let mut state = Log::default();
1337 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1339 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1340 assert_eq!(state.toggled, vec![0]);
1341 assert!(state.activated.is_empty());
1342 }
1343
1344 #[test]
1345 fn tap_up_outside_the_armed_row_fires_nothing() {
1346 let selected = BTreeSet::new();
1347 let view: SelectionHostView<Log> =
1348 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i))
1349 .on_activate(|s: &mut Log, i| s.activated.push(i));
1350 let mut w = build(&view);
1351 layout(&mut w, 300.0);
1352 let mut state = Log::default();
1353 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1354 dispatch(
1355 &mut w,
1356 &mut state,
1357 &ev(PointerPhase::Up, 5.0, ROW_HEIGHT + 5.0),
1358 );
1359 assert!(state.activated.is_empty());
1360 assert!(state.toggled.is_empty());
1361 }
1362
1363 #[test]
1364 fn long_press_selects_an_unselected_row_and_suppresses_the_tap() {
1365 let selected = BTreeSet::new();
1366 let view: SelectionHostView<Log> =
1367 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i))
1368 .on_activate(|s: &mut Log, i| s.activated.push(i));
1369 let mut w = build(&view);
1370 layout(&mut w, 300.0);
1371 let mut state = Log::default();
1372
1373 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1374 w.long_press.elapsed = true;
1377 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 6.0, 5.0));
1378 assert_eq!(state.toggled, vec![0], "long-press fired on move-arrival");
1379 assert!(state.activated.is_empty());
1380
1381 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 6.0, 5.0));
1382 assert_eq!(
1383 state.toggled,
1384 vec![0],
1385 "the resolved long-press is not also a tap"
1386 );
1387 }
1388
1389 #[test]
1390 fn long_press_never_deselects_an_already_selected_row() {
1391 let selected = BTreeSet::from([0]);
1392 let view: SelectionHostView<Log> =
1393 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
1394 let mut w = build(&view);
1395 layout(&mut w, 300.0);
1396 let mut state = Log::default();
1397
1398 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1399 w.long_press.elapsed = true;
1400 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 6.0, 5.0));
1401 assert!(
1402 state.toggled.is_empty(),
1403 "a long-press on an already-selected row must not fire on_toggle"
1404 );
1405 }
1406
1407 #[test]
1408 fn long_press_still_fires_at_up_if_no_move_arrives_first() {
1409 let selected = BTreeSet::new();
1410 let view: SelectionHostView<Log> =
1411 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
1412 let mut w = build(&view);
1413 layout(&mut w, 300.0);
1414 let mut state = Log::default();
1415
1416 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1417 w.long_press.elapsed = true;
1418 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1419 assert_eq!(state.toggled, vec![0]);
1420 }
1421
1422 #[test]
1423 fn movement_past_touch_slop_cancels_long_press_candidacy() {
1424 let selected = BTreeSet::new();
1425 let view: SelectionHostView<Log> =
1426 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
1427 let mut w = build(&view);
1428 layout(&mut w, 300.0);
1429 let mut state = Log::default();
1430
1431 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1432 w.long_press.elapsed = true; dispatch(
1434 &mut w,
1435 &mut state,
1436 &ev(PointerPhase::Move, 5.0 + TOUCH_SLOP + 5.0, 5.0),
1437 );
1438 assert!(!w.long_press.elapsed, "a drag cancels the candidacy");
1439 assert!(state.toggled.is_empty());
1440 }
1441
1442 #[test]
1443 fn cancel_clears_state_without_firing() {
1444 let selected = BTreeSet::new();
1445 let view: SelectionHostView<Log> =
1446 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
1447 let mut w = build(&view);
1448 layout(&mut w, 300.0);
1449 let mut state = Log::default();
1450
1451 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1452 assert!(w.armed.is_some());
1453 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 5.0, 5.0));
1454 assert!(w.armed.is_none());
1455 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1456 assert!(state.toggled.is_empty());
1457 }
1458
1459 #[test]
1460 fn disabled_router_arms_nothing() {
1461 let selected = BTreeSet::new();
1462 let view: SelectionHostView<Log> =
1463 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i)).enabled(false);
1464 let mut w = build(&view);
1465 layout(&mut w, 300.0);
1466 let mut state = Log::default();
1467 assert_eq!(
1468 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0)),
1469 EventResult::Ignored
1470 );
1471 assert!(w.armed.is_none());
1472 }
1473
1474 #[test]
1475 fn a_secondary_press_never_arms_a_row() {
1476 let selected = BTreeSet::new();
1477 let view: SelectionHostView<Log> =
1478 selection_host(rows(2), &selected, |s: &mut Log, i| s.toggled.push(i));
1479 let mut w = build(&view);
1480 layout(&mut w, 300.0);
1481 let mut state = Log::default();
1482 let secondary = InputEvent::Pointer(frust::authoring::PointerEvent {
1483 phase: PointerPhase::Down,
1484 position: Point::new(5.0, 5.0),
1485 button: PointerButton::Secondary,
1486 });
1487 assert_eq!(
1488 dispatch(&mut w, &mut state, &secondary),
1489 EventResult::Ignored
1490 );
1491 assert!(w.armed.is_none());
1492 }
1493
1494 #[test]
1495 fn external_clear_collapses_mode_on_the_next_dispatch() {
1496 let mut counter = 0u64;
1500 let prev_selected = BTreeSet::from([0]);
1501 let prev: SelectionHostView<Log> =
1502 selection_host(rows(2), &prev_selected, |s: &mut Log, i| s.toggled.push(i))
1503 .on_activate(|s: &mut Log, i| s.activated.push(i));
1504 let mut w = View::<Log>::build(&prev, &mut BuildCtx::new(&mut counter));
1505 layout(&mut w, 300.0);
1506
1507 let next_selected = BTreeSet::new();
1508 let next: SelectionHostView<Log> =
1509 selection_host(rows(2), &next_selected, |s: &mut Log, i| s.toggled.push(i))
1510 .on_activate(|s: &mut Log, i| s.activated.push(i));
1511 View::<Log>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1512 assert!(!is_selection_mode(&w.selected));
1513
1514 let mut state = Log::default();
1515 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1516 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1517 assert_eq!(
1518 state.activated,
1519 vec![0],
1520 "collapsed mode resumes tap-to-activate"
1521 );
1522 assert!(state.toggled.is_empty());
1523 }
1524
1525 fn idle_view<S: 'static>() -> AnyView<S> {
1528 any::<S, _>(frust::SizedBox(Some(0.0), Some(30.0)))
1529 }
1530
1531 fn bar_build<S: 'static>(view: &SelectionAppBarView<S>) -> SelectionAppBarWidget {
1532 let mut counter = 0u64;
1533 View::<S>::build(view, &mut BuildCtx::new(&mut counter))
1534 }
1535
1536 fn bar_layout(w: &mut SelectionAppBarWidget, width: f64) -> Size {
1537 let mut tcx = TextContext::new();
1538 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1539 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(width, 400.0)))
1540 }
1541
1542 #[test]
1543 fn idle_mode_uses_the_idle_contents_own_height() {
1544 let selected = BTreeSet::new();
1545 let view: SelectionAppBarView<()> =
1546 selection_app_bar(idle_view(), &selected, 3, |_: &mut ()| {});
1547 let mut w = bar_build(&view);
1548 let size = bar_layout(&mut w, 300.0);
1549 assert_eq!(size, Size::new(300.0, 30.0));
1550 }
1551
1552 #[test]
1553 fn contextual_mode_height_matches_app_bar_height_metric() {
1554 let selected = BTreeSet::from([0]);
1555 let view: SelectionAppBarView<()> =
1556 selection_app_bar(idle_view(), &selected, 3, |_: &mut ()| {}).show_select_all(false);
1557 let mut w = bar_build(&view);
1558 let size = bar_layout(&mut w, 300.0);
1559 assert_eq!(
1560 size.height, APP_BAR_HEIGHT,
1561 "the toolbar band matches appbar.rs's own HEIGHT metric"
1562 );
1563 }
1564
1565 fn notched() -> frust::authoring::WindowInsets {
1569 frust::authoring::WindowInsets::new(
1570 frust::authoring::WindowEdgeInsets::new(47.0, 24.0, 47.0, 0.0),
1571 frust::authoring::WindowEdgeInsets::ZERO,
1572 )
1573 }
1574
1575 fn bar_layout_with(
1576 w: &mut SelectionAppBarWidget,
1577 width: f64,
1578 insets: frust::authoring::WindowInsets,
1579 ) -> Size {
1580 let mut tcx = TextContext::new();
1581 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1582 lctx.with_window_insets(insets, |ctx| {
1583 w.layout(ctx, &BoxConstraints::loose(Size::new(width, 400.0)))
1584 })
1585 }
1586
1587 fn real_bar(selected: &BTreeSet<usize>, safe_area: bool) -> SelectionAppBarView<()> {
1590 use frust_widgets::test_support::leaf_any;
1591 let idle = crate::app_bar::<()>("Inbox")
1592 .leading(leaf_any(48.0, 48.0))
1593 .actions(vec![leaf_any(48.0, 48.0)])
1594 .safe_area(safe_area);
1595 selection_app_bar(idle, selected, 3, |_: &mut ()| {})
1596 .show_select_all(false)
1597 .actions(vec![leaf_any(48.0, 48.0)])
1598 .safe_area(safe_area)
1599 }
1600
1601 #[test]
1602 fn idle_to_selection_keeps_height_and_slot_offsets_under_window_insets() {
1603 let idle_set = BTreeSet::new();
1604 let selected = BTreeSet::from([0]);
1605
1606 let mut idle = bar_build(&real_bar(&idle_set, true));
1607 let idle_size = bar_layout_with(&mut idle, 800.0, notched());
1608 let mut ctx_w = bar_build(&real_bar(&selected, true));
1609 let ctx_size = bar_layout_with(&mut ctx_w, 800.0, notched());
1610
1611 assert_eq!(idle_size, Size::new(800.0, 24.0 + APP_BAR_HEIGHT));
1612 assert_eq!(ctx_size, idle_size, "the swap keeps the bar's height");
1613
1614 let close = &ctx_w.contextual[ctx_w.contextual_slots.close];
1617 assert_eq!(
1618 close.origin().x,
1619 47.0 + APP_BAR_PAD_X + (ACTION_SLOT - close.size().width).max(0.0) / 2.0
1620 );
1621 assert_eq!(
1622 close.origin().y,
1623 24.0 + (APP_BAR_HEIGHT - close.size().height) / 2.0
1624 );
1625 let action = &ctx_w.contextual[ctx_w.contextual_slots.actions_start];
1626 assert_eq!(
1627 action.origin().x + action.size().width,
1628 800.0 - 47.0 - APP_BAR_PAD_X
1629 );
1630 assert_eq!(action.origin().y, 24.0 + (APP_BAR_HEIGHT - 48.0) / 2.0);
1631 let count = &ctx_w.contextual[ctx_w.contextual_slots.count_text];
1632 assert_eq!(
1633 count.origin().x,
1634 47.0 + APP_BAR_PAD_X + ACTION_SLOT + APP_BAR_GAP
1635 );
1636
1637 let mut scene = RectRecorder::default();
1639 let mut pctx = PaintCtx::new(Point::ZERO, ctx_size);
1640 ctx_w.paint(&mut pctx, &mut scene);
1641 assert_eq!(scene.rects[0], (Point::ZERO, ctx_size));
1642 }
1643
1644 #[test]
1645 fn idle_to_selection_stays_continuous_under_a_consuming_safe_area() {
1646 let idle_set = BTreeSet::new();
1647 let selected = BTreeSet::from([0]);
1648 let consumed = notched().consuming(true, true, true, false);
1649
1650 let mut idle = bar_build(&real_bar(&idle_set, true));
1652 let idle_size = bar_layout_with(&mut idle, 706.0, consumed);
1653 let mut ctx_w = bar_build(&real_bar(&selected, true));
1654 let ctx_size = bar_layout_with(&mut ctx_w, 706.0, consumed);
1655 assert_eq!(idle_size.height, APP_BAR_HEIGHT);
1656 assert_eq!(ctx_size, idle_size);
1657 let close = &ctx_w.contextual[ctx_w.contextual_slots.close];
1658 assert_eq!(
1659 close.origin().x,
1660 APP_BAR_PAD_X + (ACTION_SLOT - close.size().width).max(0.0) / 2.0
1661 );
1662 let action = &ctx_w.contextual[ctx_w.contextual_slots.actions_start];
1663 assert_eq!(
1664 action.origin().x + action.size().width,
1665 706.0 - APP_BAR_PAD_X
1666 );
1667
1668 let heights: Vec<f64> = [&idle_set, &selected]
1670 .into_iter()
1671 .map(|set| {
1672 let wrapped = frust::safe_area(real_bar(set, true)).bottom(false);
1673 let mut counter = 0u64;
1674 let mut w = View::<()>::build(&wrapped, &mut BuildCtx::new(&mut counter));
1675 let mut tcx = TextContext::new();
1676 let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1677 lctx.with_window_insets(notched(), |ctx| {
1678 w.layout(ctx, &BoxConstraints::loose(Size::new(800.0, 400.0)))
1679 })
1680 .height
1681 })
1682 .collect();
1683 assert_eq!(heights, vec![24.0 + APP_BAR_HEIGHT; 2]);
1684 }
1685
1686 #[test]
1687 fn safe_area_false_opts_the_contextual_branch_out_like_the_idle_bar() {
1688 let idle_set = BTreeSet::new();
1689 let selected = BTreeSet::from([0]);
1690 let mut idle = bar_build(&real_bar(&idle_set, false));
1691 let idle_size = bar_layout_with(&mut idle, 800.0, notched());
1692 let mut ctx_w = bar_build(&real_bar(&selected, false));
1693 let ctx_size = bar_layout_with(&mut ctx_w, 800.0, notched());
1694 assert_eq!(idle_size.height, APP_BAR_HEIGHT);
1695 assert_eq!(ctx_size, idle_size);
1696 let action = &ctx_w.contextual[ctx_w.contextual_slots.actions_start];
1697 assert_eq!(
1698 action.origin().x + action.size().width,
1699 800.0 - APP_BAR_PAD_X
1700 );
1701
1702 let mut counter = 0u64;
1704 let prev = real_bar(&selected, false);
1705 let next = real_bar(&selected, true);
1706 let flags = View::<()>::rebuild(&next, &prev, &mut ctx_w, &mut BuildCtx::new(&mut counter));
1707 assert!(flags.needs_layout());
1708 assert!(ctx_w.safe_area);
1709 }
1710
1711 #[test]
1712 fn contextual_mode_with_select_all_adds_the_select_all_row_height() {
1713 let selected = BTreeSet::from([0]);
1714 let view: SelectionAppBarView<()> =
1715 selection_app_bar(idle_view(), &selected, 3, |_: &mut ()| {});
1716 let mut w = bar_build(&view);
1717 let size = bar_layout(&mut w, 300.0);
1718 assert_eq!(size.height, APP_BAR_HEIGHT + SELECT_ALL_HEIGHT);
1719 }
1720
1721 #[test]
1722 fn unthemed_paint_fills_the_contextual_background_only_in_selection_mode() {
1723 let idle_selected = BTreeSet::new();
1724 let idle: SelectionAppBarView<()> =
1725 selection_app_bar(idle_view(), &idle_selected, 3, |_: &mut ()| {});
1726 let mut idle_w = bar_build(&idle);
1727 bar_layout(&mut idle_w, 300.0);
1728 let mut idle_scene = RectRecorder::default();
1729 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(300.0, 30.0));
1730 idle_w.paint(&mut ctx, &mut idle_scene);
1731 assert!(
1732 idle_scene.rects.is_empty(),
1733 "idle mode paints no background of its own"
1734 );
1735
1736 let contextual_selected = BTreeSet::from([0]);
1737 let contextual: SelectionAppBarView<()> =
1738 selection_app_bar(idle_view(), &contextual_selected, 3, |_: &mut ()| {});
1739 let mut contextual_w = bar_build(&contextual);
1740 let size = bar_layout(&mut contextual_w, 300.0);
1741 let mut contextual_scene = RectRecorder::default();
1742 let mut ctx = PaintCtx::new(Point::ZERO, size);
1743 contextual_w.paint(&mut ctx, &mut contextual_scene);
1744 assert_eq!(contextual_scene.rects[0].1, size);
1748 assert!(!contextual_scene.rects.is_empty());
1749 }
1750
1751 #[derive(Default)]
1752 struct RectRecorder {
1753 rects: Vec<(Point, Size)>,
1754 }
1755 impl PaintScene for RectRecorder {
1756 fn fill_rect(&mut self, o: Point, s: Size, _c: Color) {
1757 self.rects.push((o, s));
1758 }
1759 fn draw_text(&mut self, _o: Point, _t: &str) {}
1760 }
1761
1762 #[test]
1763 fn close_button_press_fires_on_clear() {
1764 #[derive(Default)]
1765 struct S {
1766 cleared: u32,
1767 }
1768 let selected = BTreeSet::from([0]);
1769 let view: SelectionAppBarView<S> =
1770 selection_app_bar(idle_view::<S>(), &selected, 3, |s: &mut S| s.cleared += 1);
1771 let mut w = bar_build(&view);
1772 bar_layout(&mut w, 300.0);
1773
1774 let close_pod = &w.contextual[w.contextual_slots.close];
1775 let center = close_pod.origin()
1776 + Vec2::new(close_pod.size().width / 2.0, close_pod.size().height / 2.0);
1777
1778 let mut state = S::default();
1779 let state_any: &mut dyn Any = &mut state;
1780 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(300.0, APP_BAR_HEIGHT));
1781 w.event(&mut ctx, &ev(PointerPhase::Down, center.x, center.y));
1782 w.event(&mut ctx, &ev(PointerPhase::Up, center.x, center.y));
1783 assert_eq!(state.cleared, 1);
1784 }
1785
1786 #[test]
1787 fn select_all_reports_binary_flip_treating_partial_as_not_all() {
1788 #[derive(Default)]
1789 struct S {
1790 calls: Vec<bool>,
1791 }
1792
1793 fn bar(selected: &BTreeSet<usize>) -> SelectionAppBarView<S> {
1794 selection_app_bar(idle_view::<S>(), selected, 3, |_: &mut S| {})
1795 .on_all_selected(|s: &mut S, all| s.calls.push(all))
1796 }
1797
1798 fn press_checkbox(w: &mut SelectionAppBarWidget, state: &mut S) {
1799 let (cb_idx, _) = w.contextual_slots.select_all.expect("select-all present");
1800 let pod = &w.contextual[cb_idx];
1801 let center = pod.origin() + Vec2::new(pod.size().width / 2.0, pod.size().height / 2.0);
1802 let state_any: &mut dyn Any = state;
1803 let mut ctx = EventCtx::new(
1804 state_any,
1805 Point::ZERO,
1806 Size::new(300.0, APP_BAR_HEIGHT + SELECT_ALL_HEIGHT),
1807 );
1808 w.event(&mut ctx, &ev(PointerPhase::Down, center.x, center.y));
1809 w.event(&mut ctx, &ev(PointerPhase::Up, center.x, center.y));
1810 }
1811
1812 let none = BTreeSet::from([0]); let view = bar(&none);
1815 let mut w = bar_build(&view);
1816 bar_layout(&mut w, 300.0);
1817 let mut state = S::default();
1818 press_checkbox(&mut w, &mut state);
1819 assert_eq!(
1820 state.calls,
1821 vec![true],
1822 "a partial selection reports true (not-all)"
1823 );
1824
1825 let all = BTreeSet::from([0, 1, 2]);
1827 let view = bar(&all);
1828 let mut w = bar_build(&view);
1829 bar_layout(&mut w, 300.0);
1830 let mut state = S::default();
1831 press_checkbox(&mut w, &mut state);
1832 assert_eq!(state.calls, vec![false]);
1833 }
1834
1835 #[test]
1836 fn external_clear_collapses_the_bar_back_to_idle() {
1837 let mut counter = 0u64;
1840 let prev_selected = BTreeSet::from([0]);
1841 let prev: SelectionAppBarView<()> =
1842 selection_app_bar(idle_view(), &prev_selected, 3, |_: &mut ()| {});
1843 let mut w = View::<()>::build(&prev, &mut BuildCtx::new(&mut counter));
1844 bar_layout(&mut w, 300.0);
1845 assert!(is_selection_mode(&w.selected));
1846
1847 let next_selected = BTreeSet::new();
1848 let next: SelectionAppBarView<()> =
1849 selection_app_bar(idle_view(), &next_selected, 3, |_: &mut ()| {});
1850 View::<()>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1851 assert!(!is_selection_mode(&w.selected));
1852
1853 let size = bar_layout(&mut w, 300.0);
1854 assert_eq!(
1855 size,
1856 Size::new(300.0, 30.0),
1857 "layout falls back to idle's own height"
1858 );
1859 }
1860
1861 #[test]
1862 fn semantics_reports_count_selected_label_in_contextual_mode() {
1863 fn logic(_s: &mut ()) -> SelectionAppBarView<()> {
1864 let selected = BTreeSet::from([0, 1]);
1865 selection_app_bar(idle_view(), &selected, 3, |_: &mut ()| {})
1866 }
1867 let mut root: frust_core::RenderRoot<(), SelectionAppBarView<()>> =
1868 frust_core::RenderRoot::new();
1869 let mut state = ();
1870 root.rebuild(&mut logic, &mut state);
1871 let mut tcx = TextContext::new();
1872 root.layout_with_text(Size::new(300.0, 200.0), &mut tcx as &mut dyn Any);
1873 let update = root.semantics();
1874 let (_, node) = update
1875 .nodes
1876 .iter()
1877 .find(|(_, n)| n.role() == Role::TitleBar)
1878 .expect("a TitleBar node is contributed in selection mode");
1879 assert_eq!(node.label(), Some("2 selected"));
1880 }
1881}