Skip to main content

frust_material/appbar/
top.rs

1// Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
2// `lib/components/app_bars/m3e_app_bars.dart`'s `M3EAppBar.top`/`.search`
3// constructors and their `_buildTop`/`_TitleSlot`/`_M3EAppBarSearchTitle`
4// helpers. See `super`'s module docs for the family's porting decisions.
5
6//! The fixed top app bar ([`app_bar`]) and its search-titled sibling
7//! ([`search_app_bar`]) — the family's two flat, single-band bars. Read
8//! [`super`]'s module docs first: the slot contract, metric table, shape
9//! families, and semantics all live there.
10//!
11//! Layout is one band of [`AppBarMetrics::small_height`] (or an explicit
12//! [`AppBarView::toolbar_height`]), below the top window inset and inside the
13//! left/right ones the bar consumes itself (see [`AppBarView::safe_area`]):
14//! the leading slot hugs the leading edge (4dp in), actions hug the trailing
15//! edge in reading order (4dp in), and the title takes the space between
16//! them — 16dp from an edge that
17//! has no slot on it, start-aligned by default,
18//! [`centered`](AppBarView::center_title) *within that remaining space* on
19//! request, which is what upstream's own `_TitleSlot` (an `Align` inside the
20//! `Expanded`) centers within too. Everything is vertically centered in the
21//! band.
22//!
23//! Upstream's `_TitleSlot` additionally branches on whether the title is a
24//! search anchor, capping it at `searchBarTheme.maxWidth` and letting it fill
25//! the slot; that branch has nothing to port here. The cap's own default is
26//! `double.infinity`, and [`crate::search_bar`] already fills whatever finite
27//! width it is offered (its `layout` takes `bc.max().width`), so one uniform
28//! "lay the title out against the remaining width" rule reproduces both arms.
29
30use 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
47/// A declarative M3 fixed top app bar. See the [module docs](self).
48pub 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
64/// Create a top app bar titled `title`, with no leading slot or actions (attach
65/// them with [`AppBarView::leading`]/[`AppBarView::actions`]) — upstream's
66/// `M3EAppBar.top`.
67pub 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/// PascalCase alias for [`app_bar`], matching the container view-fn vocabulary.
86#[allow(non_snake_case)]
87pub fn AppBar<State: 'static>(title: impl Into<String>) -> AppBarView<State> {
88    app_bar(title)
89}
90
91/// Create a top app bar whose title slot is `bar`, a [`crate::search_bar`] —
92/// upstream's `M3EAppBar.search`, which is itself a factory delegating to
93/// `M3EAppBar.top` with a `_M3EAppBarSearchTitle` in the title slot.
94///
95/// The bar is composed, not re-implemented: configure it (hint, leading,
96/// trailing, and the `on_tap` that opens the search view) exactly as a
97/// standalone one, then hand it over.
98///
99/// ```ignore
100/// search_app_bar(
101///     search_bar(state.query.clone(), |s: &mut App, q| s.query = q)
102///         .hint("Search recipes")
103///         .on_tap(|s: &mut App| s.search_open = true),
104/// )
105/// .actions([icon_button(icons::MORE_VERT, |_: &mut App| {})])
106/// ```
107///
108/// Upstream additionally overrides the embedded bar's fill to
109/// `colorScheme.surface`, "contrasting against the app bar's
110/// surfaceContainerHigh". This port's bar container is `colors.surface` itself
111/// (see [`super`]'s metrics divergence), and [`crate::search_bar`] carries the
112/// M3-published `surfaceContainerHigh` pill, so the two already contrast — from
113/// the other side, without an override.
114pub 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/// PascalCase alias for [`search_app_bar`].
122#[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    /// Attach a leading slot (typically a nav/back icon button), erased as an
129    /// [`AnyView`]. Tint is the supplied view's own responsibility — see
130    /// [`super`]'s slot contract.
131    pub fn leading(mut self, leading: impl View<State>) -> Self {
132        self.leading = Some(AnyView::new(leading));
133        self
134    }
135
136    /// Attach trailing action slots, in reading order (the last one sits
137    /// closest to the trailing edge). Tint is each supplied view's own
138    /// responsibility.
139    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    /// Replace the title string with a caller view — upstream's `title` widget
146    /// slot, which takes precedence over its `titleText`. The view is placed
147    /// but never styled, and (unlike a string title) labels itself for
148    /// accessibility.
149    pub fn title_view(mut self, title: impl View<State>) -> Self {
150        self.title = TitleSlot::View(AnyView::new(title));
151        self
152    }
153
154    /// Center the title within the space left between the leading slot and the
155    /// actions (`centerTitle`, default `false`).
156    pub fn center_title(mut self, center: bool) -> Self {
157        self.center_title = center;
158        self
159    }
160
161    /// Shift the row out from under a protruding window-control corner (default
162    /// `true`). Pass `false` for a bar that does not span the window's top edge
163    /// (detail pane, sheet, dialog, below other content): layout cannot see the
164    /// bar's window-space position and the corner value is never consumed, so
165    /// such a bar would otherwise over-shift.
166    #[must_use]
167    pub fn corner_shift(mut self, enabled: bool) -> Self {
168        self.corner_shift = enabled;
169        self
170    }
171
172    /// Set the vertical density — see [`super`]'s metrics table.
173    pub fn density(mut self, density: AppBarDensity) -> Self {
174        self.density = density;
175        self
176    }
177
178    /// Set the container's corner shape family (default
179    /// [`AppBarShapeFamily::Square`], upstream's own `M3EAppBar.top` default).
180    pub fn shape_family(mut self, family: AppBarShapeFamily) -> Self {
181        self.shape_family = family;
182        self
183    }
184
185    /// Override the band height outright (`toolbarHeight`), bypassing the
186    /// density table — the seam that reaches upstream's own 72dp band, among
187    /// anything else.
188    pub fn toolbar_height(mut self, height: f64) -> Self {
189        self.toolbar_height = Some(height);
190        self
191    }
192
193    /// Override the container fill (`backgroundColor`), winning over
194    /// [`Self::container`], the elevation surface role, the theme default and
195    /// its fallback alike.
196    pub fn background(mut self, color: Color) -> Self {
197        self.background = Some(color);
198        self
199    }
200
201    /// Fill the container with a theme color role (default
202    /// [`AppBarContainer::Surface`]), resolved at paint time so it follows a
203    /// brightness flip or theme swap. An explicit role also wins over the
204    /// elevation surface role [`Self::elevation`] would otherwise pick, and a
205    /// string title takes the role's foreground (see [`AppBarContainer`]).
206    /// [`Self::background`] still wins over it.
207    ///
208    /// ```
209    /// use frust_material::{AppBarContainer, app_bar};
210    ///
211    /// // Flutter's counter-template bar: an inverse-primary fill, raised.
212    /// let bar = app_bar::<()>("Counter")
213    ///     .container(AppBarContainer::InversePrimary)
214    ///     .elevation(2);
215    /// # let _ = bar;
216    /// ```
217    pub fn container(mut self, role: AppBarContainer) -> Self {
218        self.container = Some(role);
219        self
220    }
221
222    /// Raise the bar to M3 elevation `level` (`0..=5`, clamped; default 0),
223    /// resolved at paint time against the theme's elevation table: a level
224    /// above 0 paints that level's drop shadow beneath the container and, with
225    /// no [`Self::container`]/[`Self::background`] set, fills with the level's
226    /// surface role (level 2 is `surfaceContainer`). See [`super`]'s module
227    /// docs for the paint order.
228    pub fn elevation(mut self, level: u8) -> Self {
229        self.elevation = level.min(MAX_ELEVATION);
230        self
231    }
232
233    /// Whether the bar consumes the **top, left and right** window insets
234    /// itself (default `true`) — Flutter's `AppBar` wrapping its toolbar in
235    /// `SafeArea(bottom: false)`. It grows by
236    /// `ctx.window_insets().padding().top`, paints its fill (and shadow) edge
237    /// to edge through that band and across the full width, and lays its
238    /// slots out below the top inset and inside the left/right ones (a
239    /// landscape notch or home-indicator edge). A parent `frust::safe_area`
240    /// that already consumed an edge leaves nothing on it to consume, so it
241    /// never double-insets. Pass `false` for a bar that is not docked to the
242    /// window's top edge (a preview, a pane, a sheet); it then consumes no
243    /// edge at all.
244    pub fn safe_area(mut self, enabled: bool) -> Self {
245        self.safe_area = enabled;
246        self
247    }
248
249    /// Label the bar's accessibility container explicitly (`semanticLabel`).
250    /// Without one, a string title labels it and a caller view leaves it
251    /// unlabelled.
252    pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
253        self.semantic_label = Some(label.into());
254        self
255    }
256
257    /// The band this bar occupies, in logical px.
258    fn band(&self) -> f64 {
259        self.toolbar_height
260            .unwrap_or_else(|| AppBarMetrics::for_density(self.density).small_height)
261    }
262
263    /// The themed color role a string title takes over this bar's container.
264    fn title_role(&self) -> ThemeTextColor {
265        self.container.unwrap_or_default().title_role()
266    }
267
268    /// This bar's title slot as a view reference, composing a string title
269    /// into `storage` at the top bar's size and container-driven color role.
270    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    /// The label this bar's semantics container carries.
281    fn label(&self) -> Option<&str> {
282        self.semantic_label
283            .as_deref()
284            .or_else(|| self.title.label())
285    }
286}
287
288/// Collect `view`'s leading (if any), its already-materialized `title`, and its
289/// actions into one ordered slice of [`AnyView`] references — the shared shape
290/// `build`/`teardown` and [`frust::authoring::rebuild_children`] both walk, and
291/// the order [`AppBarWidget`]'s pods, hit-testing, and semantics all keep.
292fn 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
305/// The retained widget for an [`AppBarView`].
306pub struct AppBarWidget {
307    /// The leading slot (if any), then the title, then every action — the one
308    /// list [`frust::authoring::route_event`] hit-tests and
309    /// [`frust::authoring::rebuild_children`] reconciles.
310    ///
311    /// The title rides this list rather than sitting beside it because a title
312    /// can be interactive ([`search_app_bar`]'s embedded bar is), and only a
313    /// routed pod receives events. The list is reconciled positionally like
314    /// every other container in this catalog, so gaining or losing the leading
315    /// slot shifts the pods after it — an in-place type swap where the kinds
316    /// differ, with the framework's own stable-prefix rules applying from there.
317    slots: Vec<ChildPod>,
318    /// Whether `slots[0]` is the leading slot (vs. the title).
319    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    /// Already clamped to `0..=5`.
327    elevation: u8,
328    safe_area: bool,
329    /// The top window inset consumed on the last `layout` pass — `0.0` when
330    /// `safe_area` is off or nothing is left to consume. The slots sit below
331    /// it; the fill and shadow cover it.
332    top_inset: f64,
333    /// The `(left, right)` window insets consumed on the last `layout` pass,
334    /// `0.0` under the same conditions as [`Self::top_inset`]. The slots sit
335    /// inside them; the fill and shadow still span the full width.
336    side_insets: (f64, f64),
337    /// The semantics container's label, retained across rebuilds.
338    label: Option<String>,
339}
340
341impl AppBarWidget {
342    /// Index of the title pod within [`Self::slots`].
343    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        // A label carries no geometry or pixels of its own: assigned outright,
428        // flagged by nothing (the same shape `icon_button`'s own
429        // `semantic_label` rebuild takes).
430        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        // Consume the top, left and right window insets (Flutter's
455        // `SafeArea(bottom: false)`): the bar grows by the top one, and the
456        // slots sit below it and inside the side ones. A parent safe area that
457        // already consumed an edge leaves 0 here.
458        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        // Shift the row out from under a protruding window-control corner (the
472        // iPadOS 26+ traffic lights); see the module docs of `appbar`. A
473        // corner's width is measured beyond the safe-area edge, so it stacks
474        // on the consumed side padding rather than overlapping it.
475        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        // Slots sit `PAD_X` in (their touch targets carry the glyph to the M3
494        // 16dp mark); a bare title edge sits `TITLE_EDGE_INSET` in instead.
495        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        // Lay out actions in reverse so the *last* action lands flush against
506        // the trailing edge, preserving left-to-right reading order.
507        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        // Shadow first, then the fill over it, then the slots — the order an
543        // elevated card paints in, so only the offset/blurred rim shows.
544        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    /// The default band every assertion below is written against.
579    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    /// A layout pass with a real `TextContext` (the title is a real `Text`
591    /// child, which panics without one threaded in) and no theme.
592    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    /// Like [`layout`], but with a theme threaded in too.
599    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    /// Lay out under a window carrying the given window-control corners.
607    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    /// Lay out under a window carrying `top` px of status-bar inset and
620    /// nothing else.
621    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    /// Lay out `view` under `theme` and paint it at its own laid-out size,
635    /// returning what the paint pass recorded.
636    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    /// Records both container shapes — `fill_rect` (square family) and
665    /// `fill_rounded_rect` (round family) — with their colors, plus every
666    /// drop shadow, which [`RecordingScene`] (geometry only, `fill_rect`
667    /// only) cannot observe. `ops` keeps the call order.
668    #[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        // Leading hugs the left edge; slots are [leading, title, a0, a1].
701        assert_eq!(w.slots[0].origin().x, PAD_X);
702        // The two actions sit right-to-left from the trailing edge, in reading
703        // order (slots[2] left of slots[3]).
704        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        // Title sits between leading and the first action.
710        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        // A greedy title view fills whatever width it is offered, so its end
726        // is exactly where the available width stops.
727        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        // The title sits one gap off each slot, not 16dp off either edge.
752        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        // The explicit override wins over the density table — the seam that
928        // reaches upstream's own 72dp band.
929        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    // ---- Container role ---------------------------------------------------
982
983    #[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        // The background replaces the fill only; the shadow still paints.
1047        assert_eq!(scene.shadows.len(), 1);
1048    }
1049
1050    // ---- Elevation --------------------------------------------------------
1051
1052    #[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        // M3 tonal elevation: level 2's surface role.
1070        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    // ---- Self-inset (`safe_area`) -----------------------------------------
1115
1116    #[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        // Every slot is centred in the band below the inset.
1133        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        // The fill and the shadow both cover the consumed band.
1139        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    /// Lay out under an arbitrary `insets` value.
1162    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    /// A landscape phone: a 47px cutout / home-indicator band on each side,
1169    /// no status bar.
1170    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        // The title starts one gap past the leading slot.
1198        assert_eq!(w.slots[1].origin().x, 47.0 + PAD_X + 40.0 + GAP);
1199
1200        // The fill and the shadow still span the whole width.
1201        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        // A corner's width is measured from the safe-area edge (see
1256        // `CornerInsets`), so the slot origin is padding + corner, never the
1257        // larger of the two — and it equals what the same bar gets under a
1258        // parent safe area that consumed the side padding first.
1259        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        // The parent-consumed composition: the bar sees no padding, sits 20px
1278        // in, and still shifts for the (never consumed) corner.
1279        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        // Through a real `frust::safe_area`: the bar is laid out 47px in and
1312        // the whole stack keeps the window width.
1313        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        // And consumes the side insets: the pill sits inside them.
1361        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        // `safe_area(bar).bottom(false)` is how material3-demo and the gallery
1375        // dock the bar: the safe area pads 24 and hands the bar zero, so the
1376        // whole stack is 24 + 64, never 24 + 24 + 64.
1377        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        // Its own 56dp pill, centered in the 64dp band.
1483        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        // And it paints: the bar's own container band, then the embedded bar's
1490        // pill inside it (`search_bar` fills a rounded rect of its own).
1491        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        // Drive through a real `RenderRoot` (see
1506        // `frust-widgets/tests/semantics_tree.rs`'s pattern) since
1507        // `SemanticsCtx::new` is crate-private to `frust-core`.
1508        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    // --- Render-time typeface identity ---
1545
1546    #[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        // The demo gallery's font picker rewrites every type-scale role's
1568        // family and pushes the theme; the title must follow it like the
1569        // buttons do, not stay pinned to the catalog's own Roboto Flex.
1570        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        // Both faces registered, as `install()` does: the title must pick the
1616        // theme's Roboto Mono even with Roboto Flex available to it.
1617        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/// A render-time typeface probe for the Material components that build their
1632/// text from plain `text(..)`: lays a view out and paints it through a real
1633/// `RenderRoot` under a given theme, with only the given faces registered, and
1634/// checks each painted glyph run's font bytes against one exact face.
1635///
1636/// Registering a face proves nothing about what paints — a text run that never
1637/// asks for the theme's family paints the platform's system font right beside a
1638/// successfully registered Roboto Flex — so these tests assert on the runs.
1639///
1640/// Every identity assertion is paired with a control, because "every run is
1641/// the expected face" only means something where a run that does NOT opt in
1642/// would come out differently. The control paints a plain `text(..)` — the
1643/// `SystemUi` request an un-opted Material text makes — through the same
1644/// theme and faces, and requires that none of its runs is the expected face.
1645/// If a host ever resolved `SystemUi` to that face, the control fails, so an
1646/// identity test can never pass there without the behaviour it guards.
1647///
1648/// The control deliberately uses no process-wide fallback decoy
1649/// (`frust_text::register_generic_fallback`): that registry is append-only and
1650/// shared by every `TextContext` this test binary builds, so a decoy would
1651/// change other tests' `SystemUi` resolution depending on scheduling, and on a
1652/// host with system fonts it is appended after them and never reached anyway.
1653/// A host whose `SystemUi` has no face at all shapes zero runs for the control
1654/// (allowed) and for an un-opted component (which then fails the identity
1655/// assertion's at-least-one-run check).
1656#[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    /// Records, per painted glyph run, whether its font bytes are `expected`.
1668    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    /// Rebuilds `logic`'s view under `theme`, lays it out in `window` with a
1683    /// text context holding `faces`, paints it, and returns one entry per
1684    /// glyph run: `true` when that run shaped against `expected`'s bytes.
1685    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    /// The face every run must shape against: its exact bytes and a name for
1710    /// failure messages.
1711    pub(crate) struct Face {
1712        pub(crate) bytes: &'static [u8],
1713        pub(crate) name: &'static str,
1714    }
1715
1716    /// Paints `logic`'s component under `theme` with `faces` registered and
1717    /// asserts it painted at least one glyph run, every one in `expected` —
1718    /// after first asserting the control (see the [module docs](self)): a plain
1719    /// `text(..)` painted the same way has no run in `expected`. Returns the
1720    /// component's run count, for a caller's own fixture checks.
1721    #[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}