1use frust::Theme;
31use frust::authoring::{
32 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
33 LayoutCtx, PaintCtx, PaintScene, Role, SemanticsCtx, ThemeTextColor, View, ViewSeq, Widget,
34 build_child, rebuild_children, route_event, teardown_child, visit_children,
35};
36use kurbo::{Point, Size};
37use peniko::Color;
38
39use super::{
40 AppBarContainer, AppBarDensity, AppBarMetrics, AppBarShapeFamily, GAP, MAX_ELEVATION, PAD_X,
41 TITLE_EDGE_INSET, TITLE_LINE_HEIGHT, TITLE_SIZE, TitleSlot, fill_container, finite_or_zero,
42 push_bar_semantics, resolve_elevation_shadow, resolve_radius, resolve_top_container,
43 title_ref_in,
44};
45use crate::search::SearchBarView;
46
47pub struct AppBarView<State: 'static> {
49 title: TitleSlot<State>,
50 leading: Option<AnyView<State>>,
51 actions: Vec<AnyView<State>>,
52 center_title: bool,
53 corner_shift: bool,
54 density: AppBarDensity,
55 shape_family: AppBarShapeFamily,
56 toolbar_height: Option<f64>,
57 background: Option<Color>,
58 container: Option<AppBarContainer>,
59 elevation: u8,
60 safe_area: bool,
61 semantic_label: Option<String>,
62}
63
64pub fn app_bar<State: 'static>(title: impl Into<String>) -> AppBarView<State> {
68 AppBarView {
69 title: TitleSlot::Text(title.into()),
70 leading: None,
71 actions: Vec::new(),
72 center_title: false,
73 corner_shift: true,
74 density: AppBarDensity::Regular,
75 shape_family: AppBarShapeFamily::Square,
76 toolbar_height: None,
77 background: None,
78 container: None,
79 elevation: 0,
80 safe_area: true,
81 semantic_label: None,
82 }
83}
84
85#[allow(non_snake_case)]
87pub fn AppBar<State: 'static>(title: impl Into<String>) -> AppBarView<State> {
88 app_bar(title)
89}
90
91pub fn search_app_bar<State: 'static>(bar: SearchBarView<State>) -> AppBarView<State> {
115 AppBarView {
116 title: TitleSlot::View(frust::authoring::any::<State, _>(bar)),
117 ..app_bar(String::new())
118 }
119}
120
121#[allow(non_snake_case)]
123pub fn SearchAppBar<State: 'static>(bar: SearchBarView<State>) -> AppBarView<State> {
124 search_app_bar(bar)
125}
126
127impl<State: 'static> AppBarView<State> {
128 pub fn leading(mut self, leading: impl View<State>) -> Self {
132 self.leading = Some(AnyView::new(leading));
133 self
134 }
135
136 pub fn actions<M>(mut self, actions: impl ViewSeq<State, M>) -> Self {
140 self.actions.clear();
141 actions.extend_views(&mut self.actions);
142 self
143 }
144
145 pub fn title_view(mut self, title: impl View<State>) -> Self {
150 self.title = TitleSlot::View(AnyView::new(title));
151 self
152 }
153
154 pub fn center_title(mut self, center: bool) -> Self {
157 self.center_title = center;
158 self
159 }
160
161 #[must_use]
167 pub fn corner_shift(mut self, enabled: bool) -> Self {
168 self.corner_shift = enabled;
169 self
170 }
171
172 pub fn density(mut self, density: AppBarDensity) -> Self {
174 self.density = density;
175 self
176 }
177
178 pub fn shape_family(mut self, family: AppBarShapeFamily) -> Self {
181 self.shape_family = family;
182 self
183 }
184
185 pub fn toolbar_height(mut self, height: f64) -> Self {
189 self.toolbar_height = Some(height);
190 self
191 }
192
193 pub fn background(mut self, color: Color) -> Self {
197 self.background = Some(color);
198 self
199 }
200
201 pub fn container(mut self, role: AppBarContainer) -> Self {
218 self.container = Some(role);
219 self
220 }
221
222 pub fn elevation(mut self, level: u8) -> Self {
229 self.elevation = level.min(MAX_ELEVATION);
230 self
231 }
232
233 pub fn safe_area(mut self, enabled: bool) -> Self {
245 self.safe_area = enabled;
246 self
247 }
248
249 pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
253 self.semantic_label = Some(label.into());
254 self
255 }
256
257 fn band(&self) -> f64 {
259 self.toolbar_height
260 .unwrap_or_else(|| AppBarMetrics::for_density(self.density).small_height)
261 }
262
263 fn title_role(&self) -> ThemeTextColor {
265 self.container.unwrap_or_default().title_role()
266 }
267
268 fn title_ref<'a>(&'a self, storage: &'a mut Option<AnyView<State>>) -> &'a AnyView<State> {
271 title_ref_in(
272 &self.title,
273 storage,
274 TITLE_SIZE,
275 TITLE_LINE_HEIGHT,
276 self.title_role(),
277 )
278 }
279
280 fn label(&self) -> Option<&str> {
282 self.semantic_label
283 .as_deref()
284 .or_else(|| self.title.label())
285 }
286}
287
288fn slot_views<'a, State: 'static>(
293 view: &'a AppBarView<State>,
294 title: &'a AnyView<State>,
295) -> Vec<&'a AnyView<State>> {
296 let mut views = Vec::with_capacity(2 + view.actions.len());
297 if let Some(leading) = &view.leading {
298 views.push(leading);
299 }
300 views.push(title);
301 views.extend(view.actions.iter());
302 views
303}
304
305pub struct AppBarWidget {
307 slots: Vec<ChildPod>,
318 has_leading: bool,
320 center_title: bool,
321 corner_shift: bool,
322 band: f64,
323 shape_family: AppBarShapeFamily,
324 background: Option<Color>,
325 container: Option<AppBarContainer>,
326 elevation: u8,
328 safe_area: bool,
329 top_inset: f64,
333 side_insets: (f64, f64),
337 label: Option<String>,
339}
340
341impl AppBarWidget {
342 fn title_index(&self) -> usize {
344 usize::from(self.has_leading)
345 }
346}
347
348impl<State: 'static> View<State> for AppBarView<State> {
349 type Element = AppBarWidget;
350
351 fn build(&self, ctx: &mut BuildCtx<'_>) -> AppBarWidget {
352 let mut storage = None;
353 let title = self.title_ref(&mut storage);
354 let slots = slot_views(self, title)
355 .into_iter()
356 .map(|view| build_child(view, ctx))
357 .collect();
358 AppBarWidget {
359 slots,
360 has_leading: self.leading.is_some(),
361 center_title: self.center_title,
362 corner_shift: self.corner_shift,
363 band: self.band(),
364 shape_family: self.shape_family,
365 background: self.background,
366 container: self.container,
367 elevation: self.elevation,
368 safe_area: self.safe_area,
369 top_inset: 0.0,
370 side_insets: (0.0, 0.0),
371 label: self.label().map(str::to_string),
372 }
373 }
374
375 fn rebuild(
376 &self,
377 prev: &Self,
378 element: &mut AppBarWidget,
379 ctx: &mut BuildCtx<'_>,
380 ) -> ChangeFlags {
381 let mut prev_storage = None;
382 let mut next_storage = None;
383 let prev_title = prev.title_ref(&mut prev_storage);
384 let next_title = self.title_ref(&mut next_storage);
385 let mut flags = rebuild_children(
386 &slot_views(prev, prev_title),
387 &slot_views(self, next_title),
388 &mut element.slots,
389 ctx,
390 |view: &&AnyView<State>| *view,
391 |_| None,
392 );
393
394 let has_leading = self.leading.is_some();
395 if element.has_leading != has_leading {
396 element.has_leading = has_leading;
397 flags |= ChangeFlags::LAYOUT;
398 }
399 if element.center_title != self.center_title {
400 element.center_title = self.center_title;
401 flags |= ChangeFlags::LAYOUT;
402 }
403 if element.corner_shift != self.corner_shift {
404 element.corner_shift = self.corner_shift;
405 flags |= ChangeFlags::LAYOUT;
406 }
407 let band = self.band();
408 if element.band != band {
409 element.band = band;
410 flags |= ChangeFlags::LAYOUT;
411 }
412 if element.safe_area != self.safe_area {
413 element.safe_area = self.safe_area;
414 flags |= ChangeFlags::LAYOUT;
415 }
416 if element.shape_family != self.shape_family
417 || element.background != self.background
418 || element.container != self.container
419 || element.elevation != self.elevation
420 {
421 element.shape_family = self.shape_family;
422 element.background = self.background;
423 element.container = self.container;
424 element.elevation = self.elevation;
425 flags |= ChangeFlags::PAINT;
426 }
427 element.label = self.label().map(str::to_string);
431 flags
432 }
433
434 fn teardown(&self, element: &mut AppBarWidget, ctx: &mut BuildCtx<'_>) {
435 let mut storage = None;
436 let title = self.title_ref(&mut storage);
437 for (view, pod) in slot_views(self, title)
438 .into_iter()
439 .zip(element.slots.iter_mut())
440 {
441 teardown_child(view, pod, ctx);
442 }
443 }
444}
445
446impl Widget for AppBarWidget {
447 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
448 let width = finite_or_zero(bc.max().width);
449 let band = self.band;
450 let slot_bc = BoxConstraints::loose(Size::new(f64::INFINITY, band));
451 let title_index = self.title_index();
452 let has_actions = self.slots.len() > title_index + 1;
453
454 let (pad_l, top, pad_r) = if self.safe_area {
459 let padding = ctx.window_insets().padding();
460 (
461 padding.left.max(0.0),
462 padding.top.max(0.0),
463 padding.right.max(0.0),
464 )
465 } else {
466 (0.0, 0.0, 0.0)
467 };
468 self.top_inset = top;
469 self.side_insets = (pad_l, pad_r);
470
471 let (shift_l, shift_r) = if self.corner_shift {
476 let corners = ctx.window_insets().corner_insets;
477 (
478 if corners.top_left.height > 0.0 {
479 corners.top_left.width
480 } else {
481 0.0
482 },
483 if corners.top_right.height > 0.0 {
484 corners.top_right.width
485 } else {
486 0.0
487 },
488 )
489 } else {
490 (0.0, 0.0)
491 };
492
493 let mut left = pad_l + shift_l;
496 if self.has_leading {
497 left += PAD_X;
498 let size = self.slots[0].layout_child(ctx, &slot_bc);
499 self.slots[0].set_origin(Point::new(left, top + (band - size.height) / 2.0));
500 left += size.width + GAP;
501 } else {
502 left += TITLE_EDGE_INSET;
503 }
504
505 let mut right = width - pad_r - shift_r;
508 if has_actions {
509 right -= PAD_X;
510 for pod in self.slots[title_index + 1..].iter_mut().rev() {
511 let size = pod.layout_child(ctx, &slot_bc);
512 right -= size.width;
513 pod.set_origin(Point::new(right, top + (band - size.height) / 2.0));
514 right -= GAP;
515 }
516 } else {
517 right -= TITLE_EDGE_INSET;
518 }
519
520 let available = (right - left).max(0.0);
521 let title_pod = &mut self.slots[title_index];
522 let title_size =
523 title_pod.layout_child(ctx, &BoxConstraints::loose(Size::new(available, band)));
524 let title_x = if self.center_title {
525 left + (available - title_size.width).max(0.0) / 2.0
526 } else {
527 left
528 };
529 title_pod.set_origin(Point::new(title_x, top + (band - title_size.height) / 2.0));
530
531 bc.constrain(Size::new(width, band + top))
532 }
533
534 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
535 let theme = Theme::from_paint_ctx(ctx);
536 let origin = ctx.origin();
537 let size = ctx.size();
538 let fill = self
539 .background
540 .unwrap_or_else(|| resolve_top_container(theme, self.container, self.elevation));
541 let radius = resolve_radius(theme, self.shape_family, size.width, size.height);
542 if let Some((y_offset, blur, color)) = resolve_elevation_shadow(theme, self.elevation) {
545 scene.draw_shadow(
546 Point::new(origin.x, origin.y + y_offset),
547 size,
548 radius,
549 blur,
550 color,
551 );
552 }
553 fill_container(scene, origin, size, radius, fill);
554 for pod in &mut self.slots {
555 pod.paint_child(ctx, scene);
556 }
557 }
558
559 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
560 route_event(&mut self.slots, ctx, event)
561 }
562
563 fn semantics(&self, ctx: &mut SemanticsCtx) {
564 push_bar_semantics(ctx, Role::TitleBar, self.label.as_deref(), &self.slots);
565 }
566
567 visit_children!(slots);
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573 use frust::authoring::text::TextContext;
574 use frust::authoring::{CornerInset, CornerInsets, WindowEdgeInsets, WindowInsets};
575 use frust_widgets::test_support::{RecordingScene, leaf_any};
576 use std::any::Any;
577
578 const BAND: f64 = 64.0;
580
581 fn ctx(counter: &mut u64) -> BuildCtx<'_> {
582 BuildCtx::new(counter)
583 }
584
585 fn build(view: &AppBarView<()>) -> AppBarWidget {
586 let mut counter = 0u64;
587 View::<()>::build(view, &mut ctx(&mut counter))
588 }
589
590 fn layout(w: &mut AppBarWidget, bc: &BoxConstraints) -> Size {
593 let mut tcx = TextContext::new();
594 let mut lctx = LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), None);
595 w.layout(&mut lctx, bc)
596 }
597
598 fn layout_themed(w: &mut AppBarWidget, bc: &BoxConstraints, theme: &Theme) -> Size {
600 let mut tcx = TextContext::new();
601 let mut lctx =
602 LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), Some(theme as &dyn Any));
603 w.layout(&mut lctx, bc)
604 }
605
606 fn layout_with_corners(
608 w: &mut AppBarWidget,
609 bc: &BoxConstraints,
610 corners: CornerInsets,
611 ) -> Size {
612 let mut tcx = TextContext::new();
613 let mut lctx = LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), None);
614 lctx.with_window_insets(WindowInsets::default().with_corner_insets(corners), |ctx| {
615 w.layout(ctx, bc)
616 })
617 }
618
619 fn layout_with_top_inset(w: &mut AppBarWidget, bc: &BoxConstraints, top: f64) -> Size {
622 let mut tcx = TextContext::new();
623 let mut lctx = LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), None);
624 lctx.with_window_insets(status_bar(top), |ctx| w.layout(ctx, bc))
625 }
626
627 fn status_bar(top: f64) -> WindowInsets {
628 WindowInsets::new(
629 WindowEdgeInsets::new(0.0, top, 0.0, 0.0),
630 WindowEdgeInsets::ZERO,
631 )
632 }
633
634 fn paint_themed(view: &AppBarView<()>, theme: &Theme) -> FillRecorder {
637 let mut w = build(view);
638 let size = layout_themed(
639 &mut w,
640 &BoxConstraints::loose(Size::new(300.0, 100.0)),
641 theme,
642 );
643 let mut scene = FillRecorder::default();
644 let mut pctx = PaintCtx::new(Point::ZERO, size).with_theme(theme);
645 w.paint(&mut pctx, &mut scene);
646 scene
647 }
648
649 fn dark_baseline() -> Theme {
650 let mut theme = crate::baseline();
651 theme.brightness = frust::Brightness::Dark;
652 theme
653 }
654
655 fn top_corners() -> CornerInsets {
656 CornerInsets::new(
657 CornerInset::new(44.0, 30.0),
658 CornerInset::new(52.0, 30.0),
659 CornerInset::ZERO,
660 CornerInset::ZERO,
661 )
662 }
663
664 #[derive(Default)]
669 struct FillRecorder {
670 rects: Vec<(Point, Size, Color)>,
671 rrects: Vec<(Point, Size, f64, Color)>,
672 shadows: Vec<(Point, Size, f64, f64, Color)>,
673 ops: Vec<&'static str>,
674 }
675 impl PaintScene for FillRecorder {
676 fn fill_rect(&mut self, o: Point, s: Size, c: Color) {
677 self.rects.push((o, s, c));
678 self.ops.push("fill");
679 }
680 fn draw_text(&mut self, _o: Point, _t: &str) {}
681 fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
682 self.rrects.push((o, s, radius, color));
683 self.ops.push("rounded");
684 }
685 fn draw_shadow(&mut self, o: Point, s: Size, radius: f64, std_dev: f64, color: Color) {
686 self.shadows.push((o, s, radius, std_dev, color));
687 self.ops.push("shadow");
688 }
689 }
690
691 #[test]
692 fn layout_places_leading_title_and_actions() {
693 let view: AppBarView<()> = app_bar("Home")
694 .leading(leaf_any(40.0, 40.0))
695 .actions(vec![leaf_any(24.0, 24.0), leaf_any(24.0, 24.0)]);
696 let mut w = build(&view);
697 let size = layout(&mut w, &BoxConstraints::loose(Size::new(400.0, 200.0)));
698 assert_eq!(size, Size::new(400.0, BAND));
699
700 assert_eq!(w.slots[0].origin().x, PAD_X);
702 assert!(w.slots[2].origin().x < w.slots[3].origin().x);
705 assert_eq!(
706 w.slots[3].origin().x + w.slots[3].size().width,
707 400.0 - PAD_X
708 );
709 assert!(w.slots[1].origin().x > w.slots[0].origin().x);
711 assert!(w.slots[1].origin().x < w.slots[2].origin().x);
712 }
713
714 #[test]
715 fn layout_without_leading_starts_title_at_the_m3_title_inset() {
716 let view: AppBarView<()> = app_bar("Home");
717 let mut w = build(&view);
718 layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 100.0)));
719 assert_eq!(TITLE_EDGE_INSET, 16.0);
720 assert_eq!(w.slots[0].origin().x, TITLE_EDGE_INSET);
721 }
722
723 #[test]
724 fn layout_without_actions_ends_the_title_width_at_the_m3_title_inset() {
725 let view: AppBarView<()> = app_bar("").title_view(leaf_any(1_000.0, 40.0));
728 let mut w = build(&view);
729 layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 100.0)));
730 let title = &w.slots[0];
731 assert_eq!(title.origin().x, TITLE_EDGE_INSET);
732 assert_eq!(
733 title.origin().x + title.size().width,
734 300.0 - TITLE_EDGE_INSET
735 );
736 }
737
738 #[test]
739 fn leading_and_actions_keep_the_4dp_slot_edge_inset() {
740 let view: AppBarView<()> = app_bar("")
741 .title_view(leaf_any(1_000.0, 40.0))
742 .leading(leaf_any(48.0, 48.0))
743 .actions(vec![leaf_any(48.0, 48.0)]);
744 let mut w = build(&view);
745 layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 100.0)));
746 assert_eq!(w.slots[0].origin().x, PAD_X);
747 assert_eq!(
748 w.slots[2].origin().x + w.slots[2].size().width,
749 300.0 - PAD_X
750 );
751 assert_eq!(w.slots[1].origin().x, PAD_X + 48.0 + GAP);
753 assert_eq!(
754 w.slots[1].origin().x + w.slots[1].size().width,
755 300.0 - PAD_X - 48.0 - GAP
756 );
757 }
758
759 #[test]
760 fn the_title_inset_applies_after_the_corner_shift() {
761 let view: AppBarView<()> = app_bar("").title_view(leaf_any(1_000.0, 40.0));
762 let mut w = build(&view);
763 layout_with_corners(
764 &mut w,
765 &BoxConstraints::loose(Size::new(400.0, 200.0)),
766 top_corners(),
767 );
768 let title = &w.slots[0];
769 assert_eq!(title.origin().x, 44.0 + TITLE_EDGE_INSET);
770 assert_eq!(
771 title.origin().x + title.size().width,
772 400.0 - 52.0 - TITLE_EDGE_INSET
773 );
774 }
775
776 #[test]
777 fn center_title_centers_within_the_space_left_between_the_slots() {
778 let view: AppBarView<()> = app_bar("Home")
779 .leading(leaf_any(40.0, 40.0))
780 .actions(vec![leaf_any(24.0, 24.0)])
781 .center_title(true);
782 let mut w = build(&view);
783 layout(&mut w, &BoxConstraints::loose(Size::new(400.0, 200.0)));
784
785 let leading_end = w.slots[0].origin().x + w.slots[0].size().width;
786 let action_start = w.slots[2].origin().x;
787 let title_start = w.slots[1].origin().x;
788 let title_end = title_start + w.slots[1].size().width;
789 let left_gap = title_start - leading_end;
790 let right_gap = action_start - title_end;
791 assert!(
792 (left_gap - right_gap).abs() < 0.01,
793 "the title is centered in the remaining space \
794 (left_gap={left_gap}, right_gap={right_gap})"
795 );
796 }
797
798 #[test]
799 fn corner_shift_moves_leading_and_actions() {
800 let view: AppBarView<()> = app_bar("Home")
801 .leading(leaf_any(40.0, 40.0))
802 .actions(vec![leaf_any(24.0, 24.0), leaf_any(24.0, 24.0)]);
803 let mut w = build(&view);
804 let size = layout_with_corners(
805 &mut w,
806 &BoxConstraints::loose(Size::new(400.0, 200.0)),
807 top_corners(),
808 );
809 assert_eq!(size.height, BAND);
810 assert_eq!(w.slots[0].origin().x, PAD_X + 44.0);
811 assert_eq!(
812 w.slots[3].origin().x + w.slots[3].size().width,
813 400.0 - PAD_X - 52.0
814 );
815 }
816
817 #[test]
818 fn corner_shift_opt_out_is_byte_identical_with_nonzero_corners() {
819 let bc = BoxConstraints::loose(Size::new(400.0, 200.0));
820 let mk = |shift: Option<bool>| {
821 let mut view: AppBarView<()> = app_bar("Home")
822 .leading(leaf_any(40.0, 40.0))
823 .actions(vec![leaf_any(24.0, 24.0), leaf_any(24.0, 24.0)]);
824 if let Some(b) = shift {
825 view = view.corner_shift(b);
826 }
827 build(&view)
828 };
829 let mut plain = mk(None);
830 let plain_size = layout(&mut plain, &bc);
831 let mut opted = mk(Some(false));
832 let opted_size = layout_with_corners(&mut opted, &bc, top_corners());
833 assert_eq!(plain_size, opted_size);
834 assert_eq!(plain.slots.len(), opted.slots.len());
835 for (a, b) in plain.slots.iter().zip(opted.slots.iter()) {
836 assert_eq!(a.origin(), b.origin());
837 assert_eq!(a.size(), b.size());
838 }
839 }
840
841 #[test]
842 fn corner_shift_defaults_on() {
843 let view: AppBarView<()> = app_bar("Home")
844 .leading(leaf_any(40.0, 40.0))
845 .actions(vec![leaf_any(24.0, 24.0), leaf_any(24.0, 24.0)]);
846 let mut w = build(&view);
847 layout_with_corners(
848 &mut w,
849 &BoxConstraints::loose(Size::new(400.0, 200.0)),
850 top_corners(),
851 );
852 assert_eq!(w.slots[0].origin().x, PAD_X + 44.0);
853 }
854
855 #[test]
856 fn corner_with_zero_height_shifts_nothing() {
857 let view: AppBarView<()> = app_bar("Home")
858 .leading(leaf_any(40.0, 40.0))
859 .actions(vec![leaf_any(24.0, 24.0)]);
860 let mut w = build(&view);
861 let corners = CornerInsets::new(
862 CornerInset::new(44.0, 0.0),
863 CornerInset::new(52.0, 0.0),
864 CornerInset::ZERO,
865 CornerInset::ZERO,
866 );
867 layout_with_corners(
868 &mut w,
869 &BoxConstraints::loose(Size::new(400.0, 200.0)),
870 corners,
871 );
872 assert_eq!(w.slots[0].origin().x, PAD_X);
873 assert_eq!(
874 w.slots[2].origin().x + w.slots[2].size().width,
875 400.0 - PAD_X
876 );
877 }
878
879 #[test]
880 fn center_title_still_centers_in_the_remaining_space_with_shifted_slots() {
881 let view: AppBarView<()> = app_bar("Home")
882 .leading(leaf_any(40.0, 40.0))
883 .actions(vec![leaf_any(24.0, 24.0)])
884 .center_title(true);
885 let mut w = build(&view);
886 layout_with_corners(
887 &mut w,
888 &BoxConstraints::loose(Size::new(400.0, 200.0)),
889 top_corners(),
890 );
891 let leading_end = w.slots[0].origin().x + w.slots[0].size().width;
892 let action_start = w.slots[2].origin().x;
893 let title_start = w.slots[1].origin().x;
894 let title_end = title_start + w.slots[1].size().width;
895 let left_gap = title_start - leading_end;
896 let right_gap = action_start - title_end;
897 assert!(
898 (left_gap - right_gap).abs() < 0.01,
899 "left_gap={left_gap}, right_gap={right_gap}"
900 );
901 }
902
903 #[test]
904 fn zero_corners_are_byte_identical() {
905 let view: AppBarView<()> = app_bar("Home")
906 .leading(leaf_any(40.0, 40.0))
907 .actions(vec![leaf_any(24.0, 24.0), leaf_any(24.0, 24.0)]);
908 let bc = BoxConstraints::loose(Size::new(400.0, 200.0));
909 let mut plain = build(&view);
910 layout(&mut plain, &bc);
911 let mut zeroed = build(&view);
912 layout_with_corners(&mut zeroed, &bc, CornerInsets::ZERO);
913 assert_eq!(plain.slots.len(), zeroed.slots.len());
914 for (a, b) in plain.slots.iter().zip(zeroed.slots.iter()) {
915 assert_eq!(a.origin(), b.origin());
916 assert_eq!(a.size(), b.size());
917 }
918 }
919
920 #[test]
921 fn compact_density_and_an_explicit_height_both_move_the_band() {
922 let compact: AppBarView<()> = app_bar("Home").density(AppBarDensity::Compact);
923 let mut w = build(&compact);
924 let size = layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
925 assert_eq!(size.height, BAND - super::super::COMPACT_REDUCTION);
926
927 let tall: AppBarView<()> = app_bar("Home")
930 .density(AppBarDensity::Compact)
931 .toolbar_height(72.0);
932 let mut w = build(&tall);
933 let size = layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 200.0)));
934 assert_eq!(size.height, 72.0);
935 }
936
937 #[test]
938 fn unthemed_paint_uses_fallback_container() {
939 let view: AppBarView<()> = app_bar("Home");
940 let mut w = build(&view);
941 layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 100.0)));
942 let mut scene = RecordingScene::default();
943 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(300.0, BAND));
944 w.paint(&mut pctx, &mut scene);
945 assert_eq!(scene.rects[0].1, Size::new(300.0, BAND));
946 }
947
948 #[test]
949 fn themed_paint_resolves_surface() {
950 let theme = crate::baseline();
951 let view: AppBarView<()> = app_bar("Home");
952 let mut w = build(&view);
953 layout_themed(
954 &mut w,
955 &BoxConstraints::loose(Size::new(300.0, 100.0)),
956 &theme,
957 );
958 let mut scene = FillRecorder::default();
959 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(300.0, BAND)).with_theme(&theme);
960 w.paint(&mut pctx, &mut scene);
961 assert_eq!(scene.rects[0].2, theme.scheme().surface);
962 }
963
964 #[test]
965 fn an_explicit_background_wins_over_the_theme_role() {
966 let theme = crate::baseline();
967 let custom = Color::from_rgb8(0x12, 0x34, 0x56);
968 let view: AppBarView<()> = app_bar("Home").background(custom);
969 let mut w = build(&view);
970 layout_themed(
971 &mut w,
972 &BoxConstraints::loose(Size::new(300.0, 100.0)),
973 &theme,
974 );
975 let mut scene = FillRecorder::default();
976 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(300.0, BAND)).with_theme(&theme);
977 w.paint(&mut pctx, &mut scene);
978 assert_eq!(scene.rects[0].2, custom);
979 }
980
981 #[test]
984 fn the_inverse_primary_container_follows_the_theme_brightness() {
985 let view: AppBarView<()> = app_bar("Home").container(AppBarContainer::InversePrimary);
986 let light = crate::baseline();
987 let dark = dark_baseline();
988 assert_eq!(
989 paint_themed(&view, &light).rects[0].2,
990 light.scheme().inverse_primary
991 );
992 assert_eq!(
993 paint_themed(&view, &dark).rects[0].2,
994 dark.scheme().inverse_primary
995 );
996 assert_ne!(
997 light.scheme().inverse_primary,
998 dark.scheme().inverse_primary
999 );
1000 }
1001
1002 #[test]
1003 fn every_container_role_resolves_its_own_scheme_color() {
1004 let theme = crate::baseline();
1005 let scheme = theme.scheme();
1006 for (role, expected) in [
1007 (AppBarContainer::Surface, scheme.surface),
1008 (AppBarContainer::SurfaceContainer, scheme.surface_container),
1009 (AppBarContainer::InversePrimary, scheme.inverse_primary),
1010 (AppBarContainer::PrimaryContainer, scheme.primary_container),
1011 ] {
1012 let view: AppBarView<()> = app_bar("Home").container(role);
1013 assert_eq!(paint_themed(&view, &theme).rects[0].2, expected, "{role:?}");
1014 }
1015 }
1016
1017 #[test]
1018 fn only_the_primary_container_changes_the_title_role() {
1019 assert_eq!(
1020 app_bar::<()>("Home").title_role(),
1021 ThemeTextColor::OnSurface
1022 );
1023 for (role, expected) in [
1024 (AppBarContainer::Surface, ThemeTextColor::OnSurface),
1025 (AppBarContainer::SurfaceContainer, ThemeTextColor::OnSurface),
1026 (AppBarContainer::InversePrimary, ThemeTextColor::OnSurface),
1027 (
1028 AppBarContainer::PrimaryContainer,
1029 ThemeTextColor::OnPrimaryContainer,
1030 ),
1031 ] {
1032 assert_eq!(app_bar::<()>("Home").container(role).title_role(), expected);
1033 }
1034 }
1035
1036 #[test]
1037 fn an_explicit_background_wins_over_container_and_elevation() {
1038 let theme = crate::baseline();
1039 let custom = Color::from_rgb8(0x12, 0x34, 0x56);
1040 let view: AppBarView<()> = app_bar("Home")
1041 .container(AppBarContainer::InversePrimary)
1042 .elevation(2)
1043 .background(custom);
1044 let scene = paint_themed(&view, &theme);
1045 assert_eq!(scene.rects[0].2, custom);
1046 assert_eq!(scene.shadows.len(), 1);
1048 }
1049
1050 #[test]
1053 fn elevation_two_paints_a_shadow_then_a_surface_container_fill() {
1054 let theme = crate::baseline();
1055 let view: AppBarView<()> = app_bar("Home").elevation(2);
1056 let scene = paint_themed(&view, &theme);
1057
1058 assert_eq!(scene.ops[..2], ["shadow", "fill"], "shadow before the fill");
1059 let level = theme.elevation.level2;
1060 let spec = level.shadow(theme.brightness);
1061 let (origin, size, radius, blur, color) = scene.shadows[0];
1062 assert_eq!(origin, Point::new(0.0, spec.y_offset));
1063 assert_eq!(size, Size::new(300.0, BAND));
1064 assert_eq!(radius, 0.0);
1065 assert_eq!(blur, spec.blur_std_dev);
1066 assert_eq!(color.components[3], spec.color_alpha);
1067 assert_eq!((spec.blur_std_dev, spec.y_offset), (3.0, 2.5));
1068
1069 assert_eq!(level.surface_role, frust::SurfaceRole::SurfaceContainer);
1071 assert_eq!(scene.rects[0].2, theme.scheme().surface_container);
1072 }
1073
1074 #[test]
1075 fn an_explicit_container_wins_over_the_elevation_surface_role() {
1076 let theme = crate::baseline();
1077 let view: AppBarView<()> = app_bar("Home")
1078 .elevation(2)
1079 .container(AppBarContainer::Surface);
1080 let scene = paint_themed(&view, &theme);
1081 assert_eq!(scene.rects[0].2, theme.scheme().surface);
1082 assert_eq!(scene.shadows.len(), 1);
1083 }
1084
1085 #[test]
1086 fn elevation_zero_paints_no_shadow_and_large_levels_clamp() {
1087 let theme = crate::baseline();
1088 assert!(paint_themed(&app_bar("Home"), &theme).shadows.is_empty());
1089
1090 let clamped = paint_themed(&app_bar("Home").elevation(9), &theme);
1091 let top = paint_themed(&app_bar("Home").elevation(5), &theme);
1092 assert_eq!(clamped.shadows, top.shadows);
1093 assert_eq!(clamped.rects[0].2, top.rects[0].2);
1094 assert_eq!(clamped.rects[0].2, theme.scheme().surface_container_highest);
1095 }
1096
1097 #[test]
1098 fn an_unthemed_elevated_bar_uses_the_catalog_table_and_fallback_shadow() {
1099 let view: AppBarView<()> = app_bar("Home").elevation(2);
1100 let mut w = build(&view);
1101 layout(&mut w, &BoxConstraints::loose(Size::new(300.0, 100.0)));
1102 let mut scene = FillRecorder::default();
1103 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(300.0, BAND));
1104 w.paint(&mut pctx, &mut scene);
1105 let (origin, _, _, blur, color) = scene.shadows[0];
1106 assert_eq!((origin.y, blur), (2.5, 3.0));
1107 assert_eq!(color, super::super::FALLBACK_SHADOW_COLOR);
1108 assert_eq!(
1109 scene.rects[0].2,
1110 crate::tokens::color_scheme_light().surface_container
1111 );
1112 }
1113
1114 #[test]
1117 fn safe_area_grows_the_band_by_the_top_inset_and_fills_it() {
1118 let theme = crate::baseline();
1119 let view: AppBarView<()> = app_bar("Home")
1120 .leading(leaf_any(40.0, 40.0))
1121 .actions(vec![leaf_any(24.0, 24.0)])
1122 .container(AppBarContainer::InversePrimary)
1123 .elevation(2);
1124 let mut w = build(&view);
1125 let size = layout_with_top_inset(
1126 &mut w,
1127 &BoxConstraints::loose(Size::new(300.0, 200.0)),
1128 24.0,
1129 );
1130 assert_eq!(size, Size::new(300.0, BAND + 24.0));
1131 assert_eq!(w.top_inset, 24.0);
1132 assert_eq!(w.slots[0].origin().y, 24.0 + (BAND - 40.0) / 2.0);
1134 assert_eq!(w.slots[2].origin().y, 24.0 + (BAND - 24.0) / 2.0);
1135 let title = &w.slots[1];
1136 assert_eq!(title.origin().y, 24.0 + (BAND - title.size().height) / 2.0);
1137
1138 let mut scene = FillRecorder::default();
1140 let mut pctx = PaintCtx::new(Point::ZERO, size).with_theme(&theme);
1141 w.paint(&mut pctx, &mut scene);
1142 assert_eq!(scene.rects[0].0, Point::ZERO);
1143 assert_eq!(scene.rects[0].1, Size::new(300.0, BAND + 24.0));
1144 assert_eq!(scene.rects[0].2, theme.scheme().inverse_primary);
1145 assert_eq!(scene.shadows[0].1, Size::new(300.0, BAND + 24.0));
1146 }
1147
1148 #[test]
1149 fn safe_area_false_ignores_the_top_inset() {
1150 let view: AppBarView<()> = app_bar("Home").safe_area(false);
1151 let mut w = build(&view);
1152 let size = layout_with_top_inset(
1153 &mut w,
1154 &BoxConstraints::loose(Size::new(300.0, 200.0)),
1155 24.0,
1156 );
1157 assert_eq!(size.height, BAND);
1158 assert_eq!(w.top_inset, 0.0);
1159 }
1160
1161 fn layout_with_insets(w: &mut AppBarWidget, bc: &BoxConstraints, insets: WindowInsets) -> Size {
1163 let mut tcx = TextContext::new();
1164 let mut lctx = LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), None);
1165 lctx.with_window_insets(insets, |ctx| w.layout(ctx, bc))
1166 }
1167
1168 fn landscape() -> WindowInsets {
1171 WindowInsets::new(
1172 WindowEdgeInsets::new(47.0, 0.0, 47.0, 0.0),
1173 WindowEdgeInsets::ZERO,
1174 )
1175 }
1176
1177 #[test]
1178 fn safe_area_pads_the_slots_inside_the_side_insets_and_fills_full_width() {
1179 let theme = crate::baseline();
1180 let view: AppBarView<()> = app_bar("Home")
1181 .leading(leaf_any(40.0, 40.0))
1182 .actions(vec![leaf_any(24.0, 24.0), leaf_any(24.0, 24.0)])
1183 .elevation(2);
1184 let mut w = build(&view);
1185 let size = layout_with_insets(
1186 &mut w,
1187 &BoxConstraints::loose(Size::new(800.0, 200.0)),
1188 landscape(),
1189 );
1190 assert_eq!(size, Size::new(800.0, BAND));
1191 assert_eq!(w.side_insets, (47.0, 47.0));
1192 assert_eq!(w.slots[0].origin().x, 47.0 + PAD_X);
1193 assert_eq!(
1194 w.slots[3].origin().x + w.slots[3].size().width,
1195 800.0 - 47.0 - PAD_X
1196 );
1197 assert_eq!(w.slots[1].origin().x, 47.0 + PAD_X + 40.0 + GAP);
1199
1200 let mut scene = FillRecorder::default();
1202 let mut pctx = PaintCtx::new(Point::ZERO, size).with_theme(&theme);
1203 w.paint(&mut pctx, &mut scene);
1204 assert_eq!(scene.rects[0].0, Point::ZERO);
1205 assert_eq!(scene.rects[0].1, Size::new(800.0, BAND));
1206 assert_eq!(scene.shadows[0].0.x, 0.0);
1207 assert_eq!(scene.shadows[0].1, Size::new(800.0, BAND));
1208 }
1209
1210 #[test]
1211 fn a_bare_title_keeps_the_m3_title_inset_inside_the_side_insets() {
1212 let view: AppBarView<()> = app_bar("").title_view(leaf_any(1_000.0, 40.0));
1213 let mut w = build(&view);
1214 layout_with_insets(
1215 &mut w,
1216 &BoxConstraints::loose(Size::new(800.0, 200.0)),
1217 landscape(),
1218 );
1219 let title = &w.slots[0];
1220 assert_eq!(title.origin().x, 47.0 + TITLE_EDGE_INSET);
1221 assert_eq!(
1222 title.origin().x + title.size().width,
1223 800.0 - 47.0 - TITLE_EDGE_INSET
1224 );
1225 }
1226
1227 #[test]
1228 fn center_title_centers_between_the_slots_inside_the_side_insets() {
1229 let view: AppBarView<()> = app_bar("Home")
1230 .leading(leaf_any(40.0, 40.0))
1231 .actions(vec![leaf_any(24.0, 24.0)])
1232 .center_title(true);
1233 let mut w = build(&view);
1234 layout_with_insets(
1235 &mut w,
1236 &BoxConstraints::loose(Size::new(800.0, 200.0)),
1237 WindowInsets::new(
1238 WindowEdgeInsets::new(47.0, 0.0, 10.0, 0.0),
1239 WindowEdgeInsets::ZERO,
1240 ),
1241 );
1242 let leading_end = w.slots[0].origin().x + w.slots[0].size().width;
1243 let title_start = w.slots[1].origin().x;
1244 let title_end = title_start + w.slots[1].size().width;
1245 let left_gap = title_start - leading_end;
1246 let right_gap = w.slots[2].origin().x - title_end;
1247 assert!(
1248 (left_gap - right_gap).abs() < 0.01,
1249 "left_gap={left_gap}, right_gap={right_gap}"
1250 );
1251 }
1252
1253 #[test]
1254 fn the_corner_shift_stacks_beyond_the_consumed_side_padding() {
1255 let view: AppBarView<()> = app_bar("Home")
1260 .leading(leaf_any(40.0, 40.0))
1261 .actions(vec![leaf_any(24.0, 24.0)]);
1262 let bc = BoxConstraints::loose(Size::new(800.0, 200.0));
1263 let insets = WindowInsets::new(
1264 WindowEdgeInsets::new(20.0, 24.0, 30.0, 0.0),
1265 WindowEdgeInsets::ZERO,
1266 )
1267 .with_corner_insets(top_corners());
1268
1269 let mut w = build(&view);
1270 layout_with_insets(&mut w, &bc, insets);
1271 assert_eq!(w.slots[0].origin().x, 20.0 + 44.0 + PAD_X);
1272 assert_eq!(
1273 w.slots[2].origin().x + w.slots[2].size().width,
1274 800.0 - 30.0 - 52.0 - PAD_X
1275 );
1276
1277 let mut nested = build(&view);
1280 layout_with_insets(
1281 &mut nested,
1282 &BoxConstraints::loose(Size::new(800.0 - 20.0 - 30.0, 200.0)),
1283 insets.consuming(true, true, true, false),
1284 );
1285 assert_eq!(nested.side_insets, (0.0, 0.0));
1286 assert_eq!(20.0 + nested.slots[0].origin().x, w.slots[0].origin().x);
1287 assert_eq!(
1288 20.0 + nested.slots[2].origin().x + nested.slots[2].size().width,
1289 w.slots[2].origin().x + w.slots[2].size().width
1290 );
1291 }
1292
1293 #[test]
1294 fn a_side_consuming_safe_area_leaves_no_side_padding_to_consume_again() {
1295 let view: AppBarView<()> = app_bar("Home")
1296 .leading(leaf_any(40.0, 40.0))
1297 .actions(vec![leaf_any(24.0, 24.0)]);
1298 let mut w = build(&view);
1299 layout_with_insets(
1300 &mut w,
1301 &BoxConstraints::loose(Size::new(706.0, 200.0)),
1302 landscape().consuming(true, true, true, false),
1303 );
1304 assert_eq!(w.side_insets, (0.0, 0.0));
1305 assert_eq!(w.slots[0].origin().x, PAD_X);
1306 assert_eq!(
1307 w.slots[2].origin().x + w.slots[2].size().width,
1308 706.0 - PAD_X
1309 );
1310
1311 let wrapped = frust::safe_area(app_bar::<()>("Home")).bottom(false);
1314 let mut counter = 0u64;
1315 let mut sw = View::<()>::build(&wrapped, &mut ctx(&mut counter));
1316 let mut tcx = TextContext::new();
1317 let mut lctx = LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), None);
1318 let size = lctx.with_window_insets(landscape(), |ctx| {
1319 sw.layout(ctx, &BoxConstraints::loose(Size::new(800.0, 400.0)))
1320 });
1321 assert_eq!(size.height, BAND);
1322 }
1323
1324 #[test]
1325 fn safe_area_false_ignores_the_side_insets_too() {
1326 let view: AppBarView<()> = app_bar("Home")
1327 .leading(leaf_any(40.0, 40.0))
1328 .actions(vec![leaf_any(24.0, 24.0)])
1329 .safe_area(false);
1330 let mut w = build(&view);
1331 let insets = WindowInsets::new(
1332 WindowEdgeInsets::new(47.0, 24.0, 47.0, 0.0),
1333 WindowEdgeInsets::ZERO,
1334 );
1335 let size = layout_with_insets(
1336 &mut w,
1337 &BoxConstraints::loose(Size::new(800.0, 200.0)),
1338 insets,
1339 );
1340 assert_eq!(size.height, BAND);
1341 assert_eq!(w.side_insets, (0.0, 0.0));
1342 assert_eq!(w.slots[0].origin().x, PAD_X);
1343 assert_eq!(
1344 w.slots[2].origin().x + w.slots[2].size().width,
1345 800.0 - PAD_X
1346 );
1347 }
1348
1349 #[test]
1350 fn a_search_app_bar_self_insets_too() {
1351 let view: AppBarView<()> = search_app_bar(crate::search_bar("", |_: &mut (), _| {}));
1352 let mut w = build(&view);
1353 let size = layout_with_top_inset(
1354 &mut w,
1355 &BoxConstraints::loose(Size::new(300.0, 200.0)),
1356 24.0,
1357 );
1358 assert_eq!(size.height, BAND + 24.0);
1359
1360 let mut w = build(&view);
1362 layout_with_insets(
1363 &mut w,
1364 &BoxConstraints::loose(Size::new(800.0, 200.0)),
1365 landscape(),
1366 );
1367 let title = &w.slots[0];
1368 assert_eq!(title.origin().x, 47.0 + TITLE_EDGE_INSET);
1369 assert!(title.origin().x + title.size().width <= 800.0 - 47.0 - TITLE_EDGE_INSET);
1370 }
1371
1372 #[test]
1373 fn a_top_consuming_safe_area_leaves_no_inset_to_consume_again() {
1374 let wrapped = frust::safe_area(app_bar::<()>("Home")).bottom(false);
1378 let mut counter = 0u64;
1379 let mut w = View::<()>::build(&wrapped, &mut ctx(&mut counter));
1380 let mut tcx = TextContext::new();
1381 let mut lctx = LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), None);
1382 let size = lctx.with_window_insets(status_bar(24.0), |ctx| {
1383 w.layout(ctx, &BoxConstraints::loose(Size::new(300.0, 400.0)))
1384 });
1385 assert_eq!(size.height, 24.0 + BAND);
1386 }
1387
1388 #[test]
1389 fn rebuild_flags_container_and_elevation_as_paint_and_safe_area_as_layout() {
1390 let mut counter = 0u64;
1391 let prev: AppBarView<()> = app_bar("Home");
1392 let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
1393
1394 let next: AppBarView<()> = app_bar("Home").elevation(2);
1395 let flags = View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
1396 assert!(flags.contains(ChangeFlags::PAINT));
1397 assert!(!flags.needs_layout());
1398 assert_eq!(w.elevation, 2);
1399
1400 let prev = next;
1401 let next: AppBarView<()> = app_bar("Home")
1402 .elevation(2)
1403 .container(AppBarContainer::InversePrimary);
1404 let flags = View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
1405 assert!(flags.contains(ChangeFlags::PAINT));
1406 assert_eq!(w.container, Some(AppBarContainer::InversePrimary));
1407
1408 let prev = next;
1409 let next: AppBarView<()> = app_bar("Home")
1410 .elevation(2)
1411 .container(AppBarContainer::InversePrimary)
1412 .safe_area(false);
1413 let flags = View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
1414 assert!(flags.needs_layout());
1415 assert!(!w.safe_area);
1416 }
1417
1418 #[test]
1419 fn the_round_shape_family_paints_a_rounded_container() {
1420 let theme = crate::baseline();
1421 let view: AppBarView<()> = app_bar("Home").shape_family(AppBarShapeFamily::Round);
1422 let mut w = build(&view);
1423 layout_themed(
1424 &mut w,
1425 &BoxConstraints::loose(Size::new(300.0, 100.0)),
1426 &theme,
1427 );
1428 let mut scene = FillRecorder::default();
1429 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(300.0, BAND)).with_theme(&theme);
1430 w.paint(&mut pctx, &mut scene);
1431 assert!(
1432 scene.rects.is_empty(),
1433 "a round-family bar paints no plain rect"
1434 );
1435 assert_eq!(scene.rrects[0].2, theme.shape.small);
1436 }
1437
1438 #[test]
1439 fn rebuild_adopts_new_title() {
1440 let mut counter = 0u64;
1441 let prev: AppBarView<()> = app_bar("Home");
1442 let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
1443 let next: AppBarView<()> = app_bar("Settings");
1444 let flags = View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
1445 assert!(flags.needs_layout());
1446 assert_eq!(w.label.as_deref(), Some("Settings"));
1447 }
1448
1449 #[test]
1450 fn rebuild_adopts_a_new_leading_slot_and_shifts_the_title_index() {
1451 let mut counter = 0u64;
1452 let prev: AppBarView<()> = app_bar("Home").actions(vec![leaf_any(24.0, 24.0)]);
1453 let mut w = View::<()>::build(&prev, &mut ctx(&mut counter));
1454 assert_eq!(w.slots.len(), 2);
1455 assert_eq!(w.title_index(), 0);
1456
1457 let next: AppBarView<()> = app_bar("Home")
1458 .leading(leaf_any(40.0, 40.0))
1459 .actions(vec![leaf_any(24.0, 24.0)]);
1460 let flags = View::<()>::rebuild(&next, &prev, &mut w, &mut ctx(&mut counter));
1461 assert!(flags.needs_layout());
1462 assert_eq!(w.slots.len(), 3);
1463 assert_eq!(w.title_index(), 1);
1464 }
1465
1466 #[test]
1467 fn a_search_titled_bar_fills_the_slot_between_leading_and_actions() {
1468 let view: AppBarView<()> = search_app_bar(crate::search_bar("", |_: &mut (), _| {}))
1469 .leading(leaf_any(40.0, 40.0))
1470 .actions(vec![leaf_any(24.0, 24.0)]);
1471 let mut w = build(&view);
1472 layout(&mut w, &BoxConstraints::loose(Size::new(400.0, 200.0)));
1473
1474 let leading_end = w.slots[0].origin().x + w.slots[0].size().width;
1475 let action_start = w.slots[2].origin().x;
1476 let title = &w.slots[1];
1477 assert_eq!(title.origin().x, leading_end + GAP);
1478 assert!(
1479 (title.origin().x + title.size().width - (action_start - GAP)).abs() < 0.01,
1480 "the search title fills the slot up to the first action's gap"
1481 );
1482 assert_eq!(title.size().height, crate::SEARCH_BAR_MIN_HEIGHT);
1484 assert_eq!(
1485 title.origin().y,
1486 (BAND - crate::SEARCH_BAR_MIN_HEIGHT) / 2.0
1487 );
1488
1489 let theme = crate::baseline();
1492 let mut scene = FillRecorder::default();
1493 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(400.0, BAND)).with_theme(&theme);
1494 w.paint(&mut pctx, &mut scene);
1495 assert_eq!(scene.rects[0].1, Size::new(400.0, BAND));
1496 assert_eq!(scene.rects[0].2, theme.scheme().surface);
1497 assert!(
1498 !scene.rrects.is_empty(),
1499 "the embedded search bar paints its own pill"
1500 );
1501 }
1502
1503 #[test]
1504 fn semantics_node_is_titlebar_labelled_with_the_title() {
1505 fn logic(_state: &mut ()) -> AppBarView<()> {
1509 app_bar("Inbox")
1510 }
1511 let mut root: frust_core::RenderRoot<(), AppBarView<()>> = frust_core::RenderRoot::new();
1512 let mut state = ();
1513 root.rebuild(&mut logic, &mut state);
1514 let mut tcx = TextContext::new();
1515 root.layout_with_text(Size::new(300.0, BAND), &mut tcx as &mut dyn Any);
1516 let update = root.semantics();
1517 let (_, node) = update
1518 .nodes
1519 .iter()
1520 .find(|(_, n)| n.role() == Role::TitleBar)
1521 .expect("a TitleBar node is contributed");
1522 assert_eq!(node.label(), Some("Inbox"));
1523 }
1524
1525 #[test]
1526 fn an_explicit_semantic_label_wins_over_the_title() {
1527 fn logic(_state: &mut ()) -> AppBarView<()> {
1528 app_bar("Inbox").semantic_label("Mail, inbox folder")
1529 }
1530 let mut root: frust_core::RenderRoot<(), AppBarView<()>> = frust_core::RenderRoot::new();
1531 let mut state = ();
1532 root.rebuild(&mut logic, &mut state);
1533 let mut tcx = TextContext::new();
1534 root.layout_with_text(Size::new(300.0, BAND), &mut tcx as &mut dyn Any);
1535 let update = root.semantics();
1536 let (_, node) = update
1537 .nodes
1538 .iter()
1539 .find(|(_, n)| n.role() == Role::TitleBar)
1540 .expect("a TitleBar node is contributed");
1541 assert_eq!(node.label(), Some("Mail, inbox folder"));
1542 }
1543
1544 #[cfg(feature = "bundled-fonts")]
1547 #[test]
1548 fn the_title_paints_in_roboto_flex_under_the_material_theme() {
1549 use super::typeface_probe::{Face, assert_paints_only_in};
1550 let flex = crate::tokens::font_data()[0];
1551 assert_paints_only_in(
1552 "the app bar title",
1553 |_: &mut ()| app_bar::<()>("Hello"),
1554 crate::baseline(),
1555 &[flex],
1556 Size::new(300.0, BAND),
1557 Face {
1558 bytes: flex,
1559 name: "Roboto Flex",
1560 },
1561 );
1562 }
1563
1564 #[cfg(feature = "bundled-fonts")]
1565 #[test]
1566 fn the_title_follows_a_live_theme_family_change() {
1567 use super::typeface_probe::{Face, assert_paints_only_in};
1571 use frust::authoring::text::{FontFamily, GenericSlot};
1572 let (flex, mono) = (crate::tokens::font_data()[0], crate::tokens::font_data()[1]);
1573 let mono_family = FontFamily::stack_with_generic(
1574 [crate::tokens::ROBOTO_MONO_FAMILY],
1575 GenericSlot::Monospace,
1576 );
1577 let mut theme = crate::baseline();
1578 macro_rules! every_role {
1579 ($($role:ident),+ $(,)?) => {
1580 $( theme.type_scale.$role.family = mono_family.clone(); )+
1581 };
1582 }
1583 every_role!(
1584 display_large,
1585 display_medium,
1586 display_small,
1587 headline_large,
1588 headline_medium,
1589 headline_small,
1590 title_large,
1591 title_medium,
1592 title_small,
1593 body_large,
1594 body_medium,
1595 body_small,
1596 label_large,
1597 label_medium,
1598 label_small,
1599 display_large_emphasized,
1600 display_medium_emphasized,
1601 display_small_emphasized,
1602 headline_large_emphasized,
1603 headline_medium_emphasized,
1604 headline_small_emphasized,
1605 title_large_emphasized,
1606 title_medium_emphasized,
1607 title_small_emphasized,
1608 body_large_emphasized,
1609 body_medium_emphasized,
1610 body_small_emphasized,
1611 label_large_emphasized,
1612 label_medium_emphasized,
1613 label_small_emphasized,
1614 );
1615 assert_paints_only_in(
1618 "the app bar title",
1619 |_: &mut ()| app_bar::<()>("Hello"),
1620 theme,
1621 &[flex, mono],
1622 Size::new(300.0, BAND),
1623 Face {
1624 bytes: mono,
1625 name: "Roboto Mono",
1626 },
1627 );
1628 }
1629}
1630
1631#[cfg(all(test, feature = "bundled-fonts"))]
1657pub(crate) mod typeface_probe {
1658 use frust::Theme;
1659 use frust::authoring::scene::GlyphRun;
1660 use frust::authoring::text::TextContext;
1661 use frust::authoring::{PaintScene, View};
1662 use frust_core::{FrameTime, RenderRoot};
1663 use kurbo::{Point, Size};
1664 use peniko::Color;
1665 use std::any::Any;
1666
1667 struct FaceRecorder {
1669 expected: &'static [u8],
1670 matches: Vec<bool>,
1671 }
1672
1673 impl PaintScene for FaceRecorder {
1674 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1675 fn draw_text(&mut self, _o: Point, _t: &str) {}
1676 fn draw_glyph_run(&mut self, run: GlyphRun) {
1677 self.matches
1678 .push(run.font.font().data.as_ref() == self.expected);
1679 }
1680 }
1681
1682 fn painted_run_faces<V: View<()>>(
1686 mut logic: impl FnMut(&mut ()) -> V,
1687 theme: Theme,
1688 faces: &[&'static [u8]],
1689 window: Size,
1690 expected: &'static [u8],
1691 ) -> Vec<bool> {
1692 let mut tcx = TextContext::new();
1693 for face in faces {
1694 tcx.register_fonts(face.to_vec())
1695 .expect("a bundled Material face registers");
1696 }
1697 let mut root: RenderRoot<(), V> = RenderRoot::new();
1698 root.set_theme(Box::new(theme));
1699 root.rebuild(&mut logic, &mut ());
1700 root.layout_with_text(window, &mut tcx as &mut dyn Any);
1701 let mut recorder = FaceRecorder {
1702 expected,
1703 matches: Vec::new(),
1704 };
1705 root.paint(&mut recorder, FrameTime::ZERO);
1706 recorder.matches
1707 }
1708
1709 pub(crate) struct Face {
1712 pub(crate) bytes: &'static [u8],
1713 pub(crate) name: &'static str,
1714 }
1715
1716 #[track_caller]
1722 pub(crate) fn assert_paints_only_in<V: View<()>>(
1723 what: &str,
1724 logic: impl FnMut(&mut ()) -> V,
1725 theme: Theme,
1726 faces: &[&'static [u8]],
1727 window: Size,
1728 expected: Face,
1729 ) -> usize {
1730 let control = painted_run_faces(
1731 |_: &mut ()| frust::text("Hello"),
1732 theme.clone(),
1733 faces,
1734 window,
1735 expected.bytes,
1736 );
1737 let leaked = control.iter().filter(|matched| **matched).count();
1738 assert_eq!(
1739 leaked,
1740 0,
1741 "control: {leaked} of {} run(s) of a plain text() that never opts in shaped \
1742 in {} — on this host an un-opted run is indistinguishable from the \
1743 expected face, so the identity assertion for {what} would prove nothing",
1744 control.len(),
1745 expected.name
1746 );
1747
1748 let runs = painted_run_faces(logic, theme, faces, window, expected.bytes);
1749 assert!(!runs.is_empty(), "{what} painted no glyph run at all");
1750 let foreign = runs.iter().filter(|matched| !**matched).count();
1751 assert_eq!(
1752 foreign,
1753 0,
1754 "{foreign} of {} glyph run(s) in {what} shaped against a face other than \
1755 {} — the text is not asking for the theme's type-scale family",
1756 runs.len(),
1757 expected.name
1758 );
1759 runs.len()
1760 }
1761}