Skip to main content

frust_widgets/
text.rs

1//! The `Text` widget: a leaf that lays out and paints a string.
2//!
3//! [`text`] is the declarative view-fn; it produces a [`TextView`] descriptor
4//! that materialises into a retained [`TextWidget`]. The widget shapes its
5//! content during the layout pass (via the shared `frust_text::TextContext`
6//! threaded through [`LayoutCtx`]) and emits the resulting glyph runs during
7//! paint.
8
9use frust_core::accesskit::Role;
10use frust_core::{
11    BoxConstraints, BuildCtx, ChangeFlags, LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, View,
12    Widget,
13};
14use frust_text::{
15    FontFamily, FontStyle, FontWeight, LineHeight, TextAlign, TextContext, TextLayout,
16    TextOverflow, TextStyle,
17};
18use frust_theme::{Theme, TypeScale};
19use kurbo::Size;
20use peniko::Color;
21
22/// Which themed color role a text's glyphs default to when no explicit
23/// `.color()`/`.style()` was set and a theme is active.
24///
25/// A plain [`text`] defaults to [`ThemeTextColor::OnSurface`] (body text on the
26/// app surface); a [`crate::Button`]'s label is built with
27/// [`ThemeTextColor::OnPrimary`] so it reads correctly against the primary-filled
28/// button. The role is only consulted when the color was *not* set explicitly —
29/// an app-supplied `.color()` always wins (precedence: explicit > theme >
30/// fallback). With no theme threaded in, the style's own color (black by default)
31/// is the unthemed fallback.
32///
33/// Part of the widget-authoring toolkit — re-exported as
34/// [`crate::authoring::ThemeTextColor`], which is the path a design system
35/// outside this crate names it by.
36// Every variant names an M3 "on_*" color role (`on_surface`/`on_primary`/
37// `on_surface_variant`/`on_primary_container`) — the shared `On` prefix
38// reflects the token vocabulary, not a naming smell.
39#[allow(clippy::enum_variant_names)]
40#[derive(Clone, Copy, PartialEq, Eq, Debug)]
41pub enum ThemeTextColor {
42    /// `colors.on_surface` — the default for standalone/body text.
43    OnSurface,
44    /// `colors.on_primary` — a label painted over a `primary`-filled surface
45    /// (used by [`crate::Button`]).
46    OnPrimary,
47    /// `colors.on_surface_variant` — a de-emphasized label/caption on the app
48    /// surface (a navigation bar's unselected item labels, an app bar's
49    /// trailing action slots).
50    OnSurfaceVariant,
51    /// `colors.on_primary_container` — a label painted over a
52    /// `primary_container`-filled surface (an extended FAB's visible label).
53    OnPrimaryContainer,
54    /// `colors.error` — a label reading as a destructive/error action (used
55    /// by [`crate::Button`]'s `ButtonStyle::Danger`).
56    Error,
57}
58
59/// Which type-scale role a text's font FAMILY resolves from at layout, when
60/// opted into with [`TextView::themed_family`] and a theme is active — one
61/// variant per [`TypeScale`] slot (15 baseline roles plus their 15
62/// `_emphasized` siblings).
63///
64/// Only the family is taken from the role: size, weight, line height and
65/// tracking stay whatever the text view itself set. Resolving it at layout
66/// rather than at build is what lets a text follow a live theme swap — an app
67/// rewriting its type scale's family repaints every opted-in run in the new
68/// face on the next layout, the same way a themed [`ThemeTextColor`] follows a
69/// brightness flip. An explicit [`TextView::family`]/[`TextView::style`] always
70/// wins (precedence: explicit > theme > fallback); with no theme, or no role
71/// set, the style's own family (`FontFamily::SystemUi` by default) is used
72/// unchanged.
73///
74/// Part of the widget-authoring toolkit — re-exported as
75/// [`crate::authoring::ThemeTextType`], which is the path a design system
76/// outside this crate names it by.
77#[derive(Clone, Copy, PartialEq, Eq, Debug)]
78pub enum ThemeTextType {
79    /// `type_scale.display_large`.
80    DisplayLarge,
81    /// `type_scale.display_medium`.
82    DisplayMedium,
83    /// `type_scale.display_small`.
84    DisplaySmall,
85    /// `type_scale.headline_large`.
86    HeadlineLarge,
87    /// `type_scale.headline_medium`.
88    HeadlineMedium,
89    /// `type_scale.headline_small`.
90    HeadlineSmall,
91    /// `type_scale.title_large`.
92    TitleLarge,
93    /// `type_scale.title_medium`.
94    TitleMedium,
95    /// `type_scale.title_small`.
96    TitleSmall,
97    /// `type_scale.body_large`.
98    BodyLarge,
99    /// `type_scale.body_medium`.
100    BodyMedium,
101    /// `type_scale.body_small`.
102    BodySmall,
103    /// `type_scale.label_large`.
104    LabelLarge,
105    /// `type_scale.label_medium`.
106    LabelMedium,
107    /// `type_scale.label_small`.
108    LabelSmall,
109    /// `type_scale.display_large_emphasized`.
110    DisplayLargeEmphasized,
111    /// `type_scale.display_medium_emphasized`.
112    DisplayMediumEmphasized,
113    /// `type_scale.display_small_emphasized`.
114    DisplaySmallEmphasized,
115    /// `type_scale.headline_large_emphasized`.
116    HeadlineLargeEmphasized,
117    /// `type_scale.headline_medium_emphasized`.
118    HeadlineMediumEmphasized,
119    /// `type_scale.headline_small_emphasized`.
120    HeadlineSmallEmphasized,
121    /// `type_scale.title_large_emphasized`.
122    TitleLargeEmphasized,
123    /// `type_scale.title_medium_emphasized`.
124    TitleMediumEmphasized,
125    /// `type_scale.title_small_emphasized`.
126    TitleSmallEmphasized,
127    /// `type_scale.body_large_emphasized`.
128    BodyLargeEmphasized,
129    /// `type_scale.body_medium_emphasized`.
130    BodyMediumEmphasized,
131    /// `type_scale.body_small_emphasized`.
132    BodySmallEmphasized,
133    /// `type_scale.label_large_emphasized`.
134    LabelLargeEmphasized,
135    /// `type_scale.label_medium_emphasized`.
136    LabelMediumEmphasized,
137    /// `type_scale.label_small_emphasized`.
138    LabelSmallEmphasized,
139}
140
141impl ThemeTextType {
142    /// The full [`TextStyle`] this role names in `scale` — what
143    /// [`TextView::themed_family`] reads at layout to resolve an opted-in
144    /// text's family, and the one mapping a design-system crate outside this
145    /// one calls into directly instead of restating its own copy of the
146    /// 30-arm role-to-slot table.
147    pub fn style_in(self, scale: &TypeScale) -> &TextStyle {
148        match self {
149            Self::DisplayLarge => &scale.display_large,
150            Self::DisplayMedium => &scale.display_medium,
151            Self::DisplaySmall => &scale.display_small,
152            Self::HeadlineLarge => &scale.headline_large,
153            Self::HeadlineMedium => &scale.headline_medium,
154            Self::HeadlineSmall => &scale.headline_small,
155            Self::TitleLarge => &scale.title_large,
156            Self::TitleMedium => &scale.title_medium,
157            Self::TitleSmall => &scale.title_small,
158            Self::BodyLarge => &scale.body_large,
159            Self::BodyMedium => &scale.body_medium,
160            Self::BodySmall => &scale.body_small,
161            Self::LabelLarge => &scale.label_large,
162            Self::LabelMedium => &scale.label_medium,
163            Self::LabelSmall => &scale.label_small,
164            Self::DisplayLargeEmphasized => &scale.display_large_emphasized,
165            Self::DisplayMediumEmphasized => &scale.display_medium_emphasized,
166            Self::DisplaySmallEmphasized => &scale.display_small_emphasized,
167            Self::HeadlineLargeEmphasized => &scale.headline_large_emphasized,
168            Self::HeadlineMediumEmphasized => &scale.headline_medium_emphasized,
169            Self::HeadlineSmallEmphasized => &scale.headline_small_emphasized,
170            Self::TitleLargeEmphasized => &scale.title_large_emphasized,
171            Self::TitleMediumEmphasized => &scale.title_medium_emphasized,
172            Self::TitleSmallEmphasized => &scale.title_small_emphasized,
173            Self::BodyLargeEmphasized => &scale.body_large_emphasized,
174            Self::BodyMediumEmphasized => &scale.body_medium_emphasized,
175            Self::BodySmallEmphasized => &scale.body_small_emphasized,
176            Self::LabelLargeEmphasized => &scale.label_large_emphasized,
177            Self::LabelMediumEmphasized => &scale.label_medium_emphasized,
178            Self::LabelSmallEmphasized => &scale.label_small_emphasized,
179        }
180    }
181}
182
183/// A declarative description of a run of text.
184///
185/// Content does not read application state in v0 — the build closure interpolates the
186/// string and hands the finished text in. Styling is applied with the
187/// [`TextView::size`]/[`TextView::color`]/[`TextView::weight`]/
188/// [`TextView::family`]/[`TextView::italic`]/[`TextView::letter_spacing`]/
189/// [`TextView::line_height`]/[`TextView::align`] builder methods, or in bulk
190/// with [`TextView::style`]. [`TextView::themed_role`] and
191/// [`TextView::themed_family`] instead defer the color and the font family to
192/// the active theme, resolved at layout.
193///
194/// [`TextView::align`] positions each wrapped line within the layout's
195/// width (centre/right-aligned paragraphs, not just the block's own
196/// position within its parent). It applies to this static leaf only; a
197/// live-edited `TextInput`'s text is always start-aligned — see
198/// `frust_text::TextEditor`'s docs.
199pub struct TextView {
200    content: String,
201    style: TextStyle,
202    /// Whether the app set the glyph color explicitly (via `.color()`/`.style()`).
203    /// When `false` and a theme is active, the color resolves from `role`; an
204    /// explicit color always wins (explicit > theme > fallback).
205    color_explicit: bool,
206    /// The themed default color role used when `color_explicit` is `false`.
207    role: ThemeTextColor,
208    /// Whether the app set the font family explicitly (via
209    /// `.family()`/`.style()`). When `true`, `family_role` is never consulted.
210    family_explicit: bool,
211    /// The type-scale role the family resolves from at layout, set via
212    /// [`Self::themed_family`]. `None` (default) keeps the style's own family.
213    family_role: Option<ThemeTextType>,
214    /// The maximum number of lines to render, set via [`Self::max_lines`].
215    /// `None` (default) is unbounded — today's behavior.
216    max_lines: Option<usize>,
217    /// How content past `max_lines` is handled, set via [`Self::overflow`].
218    /// Only meaningful alongside `max_lines`; defaults to
219    /// [`TextOverflow::Clip`], a no-op with no `max_lines` set.
220    overflow: TextOverflow,
221}
222
223/// Create a text view rendering `content` with default styling (16px). Its glyph
224/// color defaults to the active theme's `on_surface` role, falling back to black
225/// when no theme is set; an explicit [`TextView::color`] overrides both.
226pub fn text(content: impl Into<String>) -> TextView {
227    TextView {
228        content: content.into(),
229        style: TextStyle::default(),
230        color_explicit: false,
231        role: ThemeTextColor::OnSurface,
232        family_explicit: false,
233        family_role: None,
234        max_lines: None,
235        overflow: TextOverflow::default(),
236    }
237}
238
239impl TextView {
240    /// Set the font size, in logical pixels.
241    pub fn size(mut self, size: f32) -> Self {
242        self.style.size = size;
243        self
244    }
245
246    /// Set the glyph fill color. Marks the color as explicitly set, so it wins
247    /// over any themed default (explicit > theme > fallback).
248    pub fn color(mut self, color: Color) -> Self {
249        self.style.color = color;
250        self.color_explicit = true;
251        self
252    }
253
254    /// Set the font weight.
255    pub fn weight(mut self, weight: FontWeight) -> Self {
256        self.style.weight = weight;
257        self
258    }
259
260    /// Set the font family (or fallback stack). Marks the family as explicitly
261    /// set, so it wins over any [`Self::themed_family`] role.
262    pub fn family(mut self, family: FontFamily) -> Self {
263        self.style.family = family;
264        self.family_explicit = true;
265        self
266    }
267
268    /// Set the font style to italic.
269    pub fn italic(mut self) -> Self {
270        self.style.style = FontStyle::Italic;
271        self
272    }
273
274    /// Set the extra spacing between letters, in logical pixels.
275    pub fn letter_spacing(mut self, letter_spacing: f32) -> Self {
276        self.style.letter_spacing = letter_spacing;
277        self
278    }
279
280    /// Set the line height.
281    pub fn line_height(mut self, line_height: LineHeight) -> Self {
282        self.style.line_height = line_height;
283        self
284    }
285
286    /// Set the paragraph alignment (start/center/end/left/right/justify).
287    ///
288    /// Only visible once the text wraps to more than one line under a
289    /// bounded width — a single-line layout's width already equals the
290    /// line's own content width, so every alignment renders identically to
291    /// the default ([`TextAlign::Start`]).
292    pub fn align(mut self, align: TextAlign) -> Self {
293        self.style.align = align;
294        self
295    }
296
297    /// Replace the whole style in one call. Treated as an explicit color and
298    /// family choice (the supplied style carries its own of both), so it wins
299    /// over the themed color default and any [`Self::themed_family`] role.
300    pub fn style(mut self, style: TextStyle) -> Self {
301        self.style = style;
302        self.color_explicit = true;
303        self.family_explicit = true;
304        self
305    }
306
307    /// Cap the rendered line count. Content past the limit is handled per
308    /// [`Self::overflow`] (default [`TextOverflow::Clip`]: extra lines are
309    /// dropped, nothing else changes). `0` renders nothing.
310    pub fn max_lines(mut self, max_lines: usize) -> Self {
311        self.max_lines = Some(max_lines);
312        self
313    }
314
315    /// Set how content past [`Self::max_lines`] is handled. A no-op without
316    /// `max_lines` set — there is nothing to overflow past.
317    pub fn overflow(mut self, overflow: TextOverflow) -> Self {
318        self.overflow = overflow;
319        self
320    }
321
322    /// Set the themed default color role used when no explicit color was set
323    /// (e.g. [`crate::Button`] labels its text [`ThemeTextColor::OnPrimary`] so it
324    /// reads against the primary-filled button).
325    ///
326    /// Part of the widget-authoring toolkit (see [`crate::authoring`]): it stays
327    /// an inherent method here — an inherent impl cannot be added from another
328    /// crate — while the role enum itself is re-exported from `authoring`.
329    pub fn themed_role(mut self, role: ThemeTextColor) -> Self {
330        self.role = role;
331        self
332    }
333
334    /// Resolve the font family from the active theme's `type_scale` slot
335    /// `role` at layout — the family only; size, weight, line height and
336    /// tracking stay as this view sets them (see [`ThemeTextType`]).
337    ///
338    /// Opt-in: a text without it keeps its style's own family. An explicit
339    /// [`Self::family`]/[`Self::style`] wins regardless of call order, and an
340    /// explicit [`Self::color`] does not affect it — color and family resolve
341    /// independently. With no theme threaded in, the style's own family is
342    /// used unchanged.
343    ///
344    /// Part of the widget-authoring toolkit (see [`crate::authoring`]),
345    /// alongside [`Self::themed_role`].
346    pub fn themed_family(mut self, role: ThemeTextType) -> Self {
347        self.family_role = Some(role);
348        self
349    }
350}
351
352impl<State: 'static> View<State> for TextView {
353    type Element = TextWidget;
354
355    fn build(&self, _ctx: &mut BuildCtx<'_>) -> TextWidget {
356        TextWidget {
357            content: self.content.clone(),
358            style: self.style.clone(),
359            color_explicit: self.color_explicit,
360            role: self.role,
361            family_explicit: self.family_explicit,
362            family_role: self.family_role,
363            max_lines: self.max_lines,
364            overflow: self.overflow,
365            layout: None,
366            laid_out_max_width: None,
367            laid_out_style: None,
368        }
369    }
370
371    fn rebuild(
372        &self,
373        prev: &Self,
374        element: &mut TextWidget,
375        _ctx: &mut BuildCtx<'_>,
376    ) -> ChangeFlags {
377        let mut flags = ChangeFlags::NONE;
378        if prev.content != self.content {
379            element.content = self.content.clone();
380            element.layout = None; // invalidate the cached shaping
381            flags |= ChangeFlags::LAYOUT;
382        }
383        if prev.style != self.style {
384            element.style = self.style.clone();
385            element.layout = None;
386            flags |= ChangeFlags::LAYOUT;
387        }
388        // The themed default color feeds into shaping (the glyph brush is baked at
389        // layout), so a change to the explicit-flag or role invalidates the cache.
390        if prev.color_explicit != self.color_explicit || prev.role != self.role {
391            element.color_explicit = self.color_explicit;
392            element.role = self.role;
393            element.layout = None;
394            flags |= ChangeFlags::LAYOUT;
395        }
396        // The themed family is resolved at layout and shaped with, exactly like
397        // the themed color above — a role change must reshape.
398        if prev.family_explicit != self.family_explicit || prev.family_role != self.family_role {
399            element.family_explicit = self.family_explicit;
400            element.family_role = self.family_role;
401            element.layout = None;
402            flags |= ChangeFlags::LAYOUT;
403        }
404        // Truncation inputs feed into shaping the same way content/style do
405        // (`layout_bounded` reshapes on overflow) — a change here must
406        // invalidate the cache exactly like those.
407        if prev.max_lines != self.max_lines || prev.overflow != self.overflow {
408            element.max_lines = self.max_lines;
409            element.overflow = self.overflow;
410            element.layout = None;
411            flags |= ChangeFlags::LAYOUT;
412        }
413        flags
414    }
415}
416
417/// Retained widget for [`TextView`]: caches the shaped [`TextLayout`] produced
418/// during layout for reuse in paint.
419pub struct TextWidget {
420    content: String,
421    style: TextStyle,
422    /// Whether the app set the glyph color explicitly — see [`TextView`].
423    color_explicit: bool,
424    /// The themed default color role used when `color_explicit` is `false`.
425    role: ThemeTextColor,
426    /// Whether the app set the font family explicitly — see [`TextView`].
427    family_explicit: bool,
428    /// The type-scale role the family resolves from when `family_explicit`
429    /// is `false` — see [`TextView::themed_family`].
430    family_role: Option<ThemeTextType>,
431    /// The maximum rendered line count — see [`TextView::max_lines`].
432    max_lines: Option<usize>,
433    /// How content past `max_lines` is handled — see [`TextView::overflow`].
434    overflow: TextOverflow,
435    /// `None` until the first layout pass, or after a content/style change
436    /// invalidates it.
437    layout: Option<TextLayout>,
438    /// The `max_width` the cached [`layout`](Self::layout) was shaped/broken at.
439    /// A layout pass with the same width, same effective style, and a live
440    /// cache reuses the shaped layout instead of re-shaping — the fix for a
441    /// verified defect where `layout` re-shaped unconditionally
442    /// every pass (see [`Widget::layout`]).
443    laid_out_max_width: Option<f32>,
444    /// The effective (themed-color- and themed-family-resolved) style the
445    /// cached `layout` was shaped with. Compared alongside
446    /// `laid_out_max_width` so a bare theme swap — which re-resolves the baked
447    /// glyph color, and an opted-in family, at layout time without a view
448    /// change — still forces a re-shape (the layout-time-baked contract, see
449    /// `docs/CODE_STANDARDS.md` Theming).
450    laid_out_style: Option<TextStyle>,
451}
452
453impl TextWidget {
454    /// The style to shape with: the app's style, with two independent themed
455    /// substitutions when a theme is active — the color replaced by the
456    /// [`ThemeTextColor`] role unless the color was set explicitly, and the
457    /// family replaced by the [`ThemeTextType`] role's family when one was
458    /// opted into and the family was not set explicitly. With no theme the
459    /// app's style is returned unchanged, which is what keeps the unthemed
460    /// path pixel-identical to a text that never opted in. Resolving here,
461    /// during layout, is what lets both follow a live theme swap.
462    fn effective_style(&self, theme: Option<&Theme>) -> TextStyle {
463        let mut style = self.style.clone();
464        let Some(theme) = theme else {
465            return style;
466        };
467        if !self.color_explicit {
468            let scheme = theme.scheme();
469            style.color = match self.role {
470                ThemeTextColor::OnSurface => scheme.on_surface,
471                ThemeTextColor::OnPrimary => scheme.on_primary,
472                ThemeTextColor::OnSurfaceVariant => scheme.on_surface_variant,
473                ThemeTextColor::OnPrimaryContainer => scheme.on_primary_container,
474                ThemeTextColor::Error => scheme.error,
475            };
476        }
477        if !self.family_explicit
478            && let Some(role) = self.family_role
479        {
480            style.family = role.style_in(&theme.type_scale).family.clone();
481        }
482        style
483    }
484}
485
486impl Widget for TextWidget {
487    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
488        // Wrap to the available width when it is bounded (it is, under the
489        // root's window-sized loose constraints); an unbounded max width means
490        // "lay out on a single line per hard break".
491        let max_width = {
492            let w = bc.max().width;
493            if w.is_finite() { Some(w as f32) } else { None }
494        };
495        // Resolve the themed color and family first so the theme borrow ends
496        // before the (mutable) text-context borrow below.
497        let style = self.effective_style(Theme::from_layout_ctx(ctx));
498
499        // Reuse the cached shaped layout when nothing that affects shaping has
500        // changed since the last pass. `layout` is `Some` only while content /
501        // base style / color and family roles are unchanged (rebuild clears it
502        // otherwise), so the remaining variables are the wrap width and the
503        // effective (themed) style — both compared here. This is the fix for a
504        // verified defect where every layout pass re-shaped unconditionally,
505        // and it preserves the theme-swap contract: a live appearance flip
506        // changes `style.color` (and a type-scale family swap changes an
507        // opted-in `style.family`), which mismatches `laid_out_style` and
508        // forces a re-shape even without a view change.
509        if let Some(cached) = &self.layout
510            && self.laid_out_max_width == max_width
511            && self.laid_out_style.as_ref() == Some(&style)
512        {
513            return bc.constrain(cached.size());
514        }
515
516        let text_ctx = ctx.text_context::<TextContext>();
517        let layout = text_ctx.layout_bounded(
518            &self.content,
519            &style,
520            max_width,
521            self.max_lines,
522            self.overflow,
523        );
524        let size = bc.constrain(layout.size());
525        self.layout = Some(layout);
526        self.laid_out_max_width = max_width;
527        self.laid_out_style = Some(style);
528        size
529    }
530
531    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
532        if let Some(layout) = &self.layout {
533            for run in layout.to_scene_runs(ctx.origin()) {
534                scene.draw_glyph_run(run);
535            }
536        }
537    }
538
539    fn semantics(&self, ctx: &mut SemanticsCtx) {
540        // A static-text leaf: a Label node. accesskit guidance is that a
541        // Role::Label node carries its text in `value` (not `label`), so a
542        // screen reader announces the run's content.
543        ctx.push_node(Role::Label, |node| {
544            node.set_value(self.content.as_str());
545        });
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    #[test]
554    fn builder_methods_compose() {
555        let view = text("x")
556            .size(20.0)
557            .weight(FontWeight::MEDIUM)
558            .italic()
559            .family(FontFamily::named("Inter"))
560            .letter_spacing(2.0)
561            .line_height(LineHeight::Absolute(30.0))
562            .align(TextAlign::Center)
563            .color(Color::from_rgb8(1, 2, 3))
564            .max_lines(2)
565            .overflow(frust_text::TextOverflow::Ellipsis);
566
567        assert_eq!(view.style.size, 20.0);
568        assert_eq!(view.style.weight, FontWeight::MEDIUM);
569        assert_eq!(view.style.style, FontStyle::Italic);
570        assert_eq!(view.style.family, FontFamily::named("Inter"));
571        assert_eq!(view.style.letter_spacing, 2.0);
572        assert_eq!(view.style.line_height, LineHeight::Absolute(30.0));
573        assert_eq!(view.style.align, TextAlign::Center);
574        assert_eq!(view.style.color, Color::from_rgb8(1, 2, 3));
575        assert_eq!(view.max_lines, Some(2));
576        assert_eq!(view.overflow, frust_text::TextOverflow::Ellipsis);
577    }
578
579    #[test]
580    fn default_max_lines_and_overflow_are_unbounded_clip() {
581        let view = text("x");
582        assert_eq!(view.max_lines, None);
583        assert_eq!(view.overflow, frust_text::TextOverflow::Clip);
584    }
585
586    #[test]
587    fn bulk_style_setter_replaces_whole_style() {
588        let custom = TextStyle {
589            size: 40.0,
590            ..TextStyle::default()
591        };
592        let view = text("x").style(custom.clone());
593        assert_eq!(view.style, custom);
594    }
595
596    // --- Role-to-slot mapping ---
597
598    /// Guards [`ThemeTextType::style_in`]'s 30-arm match against a
599    /// copy-paste slip: each role, and no other, reads the slot it names.
600    #[test]
601    fn every_role_reads_its_own_type_scale_slot() {
602        macro_rules! each_role {
603            ($($role:ident => $slot:ident),+ $(,)?) => {$({
604                let mut scale = TypeScale::neutral(&TextStyle::default());
605                let probe = FontFamily::named(stringify!($slot));
606                scale.$slot.family = probe.clone();
607                assert_eq!(
608                    ThemeTextType::$role.style_in(&scale).family,
609                    probe,
610                    "`{}` must read `{}`",
611                    stringify!($role),
612                    stringify!($slot)
613                );
614            })+};
615        }
616        each_role!(
617            DisplayLarge => display_large,
618            DisplayMedium => display_medium,
619            DisplaySmall => display_small,
620            HeadlineLarge => headline_large,
621            HeadlineMedium => headline_medium,
622            HeadlineSmall => headline_small,
623            TitleLarge => title_large,
624            TitleMedium => title_medium,
625            TitleSmall => title_small,
626            BodyLarge => body_large,
627            BodyMedium => body_medium,
628            BodySmall => body_small,
629            LabelLarge => label_large,
630            LabelMedium => label_medium,
631            LabelSmall => label_small,
632            DisplayLargeEmphasized => display_large_emphasized,
633            DisplayMediumEmphasized => display_medium_emphasized,
634            DisplaySmallEmphasized => display_small_emphasized,
635            HeadlineLargeEmphasized => headline_large_emphasized,
636            HeadlineMediumEmphasized => headline_medium_emphasized,
637            HeadlineSmallEmphasized => headline_small_emphasized,
638            TitleLargeEmphasized => title_large_emphasized,
639            TitleMediumEmphasized => title_medium_emphasized,
640            TitleSmallEmphasized => title_small_emphasized,
641            BodyLargeEmphasized => body_large_emphasized,
642            BodyMediumEmphasized => body_medium_emphasized,
643            BodySmallEmphasized => body_small_emphasized,
644            LabelLargeEmphasized => label_large_emphasized,
645            LabelMediumEmphasized => label_medium_emphasized,
646            LabelSmallEmphasized => label_small_emphasized,
647        );
648    }
649
650    // --- Themed color resolution ---
651
652    use frust_core::{BoxConstraints, LayoutCtx, PaintCtx, PaintScene, Widget};
653    use frust_scene::GlyphRun;
654    use frust_text::TextContext;
655    use frust_theme::Theme;
656    use kurbo::{Point, Size};
657    use peniko::Brush;
658    use std::any::Any;
659
660    /// A recording scene that captures each glyph run's solid brush color.
661    #[derive(Default)]
662    struct GlyphRecorder {
663        colors: Vec<Color>,
664    }
665
666    impl PaintScene for GlyphRecorder {
667        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
668        fn draw_text(&mut self, _o: Point, _t: &str) {}
669        fn draw_glyph_run(&mut self, run: GlyphRun) {
670            if let Brush::Solid(color) = run.brush {
671                self.colors.push(color);
672            }
673        }
674    }
675
676    /// Lay out and paint `view`, returning the brush color the single glyph run
677    /// carried. `theme` is threaded into layout (where the glyph brush is baked)
678    /// when `Some`.
679    fn painted_color(view: TextView, theme: Option<&Theme>) -> Color {
680        let mut widget = View::<()>::build(&view, &mut frust_core::BuildCtx::new(&mut 0u64));
681        let mut tcx = TextContext::new();
682        let theme_any = theme.map(|t| t as &dyn Any);
683        let mut lctx = LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), theme_any);
684        widget.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 100.0)));
685        let mut rec = GlyphRecorder::default();
686        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(200.0, 100.0));
687        widget.paint(&mut pctx, &mut rec);
688        *rec.colors.first().expect("one glyph run painted")
689    }
690
691    #[test]
692    fn unthemed_text_keeps_black_default() {
693        // Parity: with no theme threaded in, the glyph color is exactly the
694        // TextStyle default (black) — unchanged from before the retrofit.
695        assert_eq!(painted_color(text("x"), None), Color::BLACK);
696    }
697
698    #[test]
699    fn themed_text_defaults_to_on_surface() {
700        let theme = Theme::neutral();
701        assert_eq!(
702            painted_color(text("x"), Some(&theme)),
703            theme.scheme().on_surface
704        );
705    }
706
707    #[test]
708    fn on_primary_role_resolves_to_on_primary() {
709        let theme = Theme::neutral();
710        let view = text("x").themed_role(ThemeTextColor::OnPrimary);
711        assert_eq!(painted_color(view, Some(&theme)), theme.scheme().on_primary);
712    }
713
714    #[test]
715    fn on_surface_variant_role_resolves_to_on_surface_variant() {
716        let theme = Theme::neutral();
717        let view = text("x").themed_role(ThemeTextColor::OnSurfaceVariant);
718        assert_eq!(
719            painted_color(view, Some(&theme)),
720            theme.scheme().on_surface_variant
721        );
722    }
723
724    #[test]
725    fn on_primary_container_role_resolves_to_on_primary_container() {
726        let theme = Theme::neutral();
727        let view = text("x").themed_role(ThemeTextColor::OnPrimaryContainer);
728        assert_eq!(
729            painted_color(view, Some(&theme)),
730            theme.scheme().on_primary_container
731        );
732    }
733
734    #[test]
735    fn error_role_resolves_to_error() {
736        let theme = Theme::neutral();
737        let view = text("x").themed_role(ThemeTextColor::Error);
738        assert_eq!(painted_color(view, Some(&theme)), theme.scheme().error);
739    }
740
741    #[test]
742    fn explicit_color_wins_over_theme() {
743        // Precedence: an app-set `.color()` beats the themed default.
744        let theme = Theme::neutral();
745        let custom = Color::from_rgb8(1, 2, 3);
746        assert_eq!(painted_color(text("x").color(custom), Some(&theme)), custom);
747    }
748
749    // --- Cached-shape reuse (the verified defect) ---
750
751    #[test]
752    fn unchanged_layout_pass_skips_reshaping_entirely() {
753        // The verified defect: `TextWidget::layout` re-shaped on every pass. Now
754        // an unchanged pass reuses the cached `TextLayout` WITHOUT touching the
755        // text context at all — observable as the shape cache seeing exactly one
756        // shape and, critically, zero further lookups (`hits == 0`). A non-zero
757        // `hits` would mean the widget still called into the context and only
758        // the frust-text cache saved it; `hits == 0` proves the widget-level
759        // skip.
760        let view = text("Hello from Frust");
761        let mut widget = View::<()>::build(&view, &mut frust_core::BuildCtx::new(&mut 0u64));
762        let mut tcx = TextContext::new();
763        let bc = BoxConstraints::loose(Size::new(200.0, 100.0));
764        {
765            let mut lctx = LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), None);
766            widget.layout(&mut lctx, &bc);
767            widget.layout(&mut lctx, &bc);
768            widget.layout(&mut lctx, &bc);
769        }
770        let stats = tcx.shape_cache_stats();
771        assert_eq!(stats.shapes, 1, "shaping must run exactly once");
772        assert_eq!(
773            stats.hits, 0,
774            "an unchanged layout pass must not consult the text context at all"
775        );
776        assert_eq!(stats.line_breaks, 0);
777    }
778
779    #[test]
780    fn width_change_rebreaks_without_reshaping() {
781        // A width change breaks the widget-level skip (the cached width differs),
782        // so it re-lays-out through the context — but the frust-text shape cache
783        // reuses the shaping and re-runs line-breaking only.
784        let view = text("Hello from Frust, the pure Rust mobile UI toolkit");
785        let mut widget = View::<()>::build(&view, &mut frust_core::BuildCtx::new(&mut 0u64));
786        let mut tcx = TextContext::new();
787        {
788            let mut lctx = LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), None);
789            widget.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 100.0)));
790            widget.layout(&mut lctx, &BoxConstraints::loose(Size::new(120.0, 100.0)));
791        }
792        let stats = tcx.shape_cache_stats();
793        assert_eq!(stats.shapes, 1, "the width change must not re-shape");
794        assert_eq!(stats.line_breaks, 1, "the width change re-breaks once");
795    }
796
797    // --- Paragraph alignment, end-to-end through the widget ---
798
799    /// Builds, lays out, and paints `view` at `bc`, returning the painted
800    /// glyph runs (unlike [`painted_color`], which discards everything but
801    /// the brush).
802    fn painted_runs(view: TextView, bc: BoxConstraints) -> Vec<GlyphRun> {
803        let mut widget = View::<()>::build(&view, &mut frust_core::BuildCtx::new(&mut 0u64));
804        let mut tcx = TextContext::new();
805        let mut lctx = LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), None);
806        widget.layout(&mut lctx, &bc);
807        let mut rec = GlyphRunRecorder::default();
808        let mut pctx = PaintCtx::new(Point::ZERO, bc.max());
809        widget.paint(&mut pctx, &mut rec);
810        rec.runs
811    }
812
813    /// A recording scene that captures every painted glyph run in full
814    /// (unlike [`GlyphRecorder`], which keeps only the brush color).
815    #[derive(Default)]
816    struct GlyphRunRecorder {
817        runs: Vec<GlyphRun>,
818    }
819
820    impl PaintScene for GlyphRunRecorder {
821        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
822        fn draw_text(&mut self, _o: Point, _t: &str) {}
823        fn draw_glyph_run(&mut self, run: GlyphRun) {
824            self.runs.push(run);
825        }
826    }
827
828    /// Groups `runs`' glyphs by line (glyphs on the same line share a `y` —
829    /// `frust_text`'s coordinate contract) and returns each line's minimum
830    /// `x` (its rendered left edge), in line order.
831    fn line_min_x(runs: &[GlyphRun]) -> Vec<f32> {
832        let mut by_y: Vec<(f32, f32)> = Vec::new();
833        for run in runs {
834            for g in &run.glyphs {
835                match by_y.iter_mut().find(|(y, _)| (*y - g.y).abs() < 0.01) {
836                    Some((_, min_x)) => *min_x = min_x.min(g.x),
837                    None => by_y.push((g.y, g.x)),
838                }
839            }
840        }
841        by_y.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
842        by_y.into_iter().map(|(_, x)| x).collect()
843    }
844
845    #[test]
846    fn text_view_align_centers_and_right_aligns_wrapped_lines() {
847        // The end-to-end path the finding names: `Align(CENTER, text(..))`
848        // must actually centre every line, not just the block. Asserts on
849        // per-line origins (via the painted glyph runs), not on the style
850        // merely being set.
851        let content = "A\nBBBBBBBBBB";
852        let bc = BoxConstraints::loose(Size::new(400.0, 200.0));
853
854        let start_x = line_min_x(&painted_runs(text(content), bc));
855        let center_x = line_min_x(&painted_runs(text(content).align(TextAlign::Center), bc));
856        let right_x = line_min_x(&painted_runs(text(content).align(TextAlign::Right), bc));
857
858        assert_eq!(start_x.len(), 2, "expected two hard-broken lines");
859        assert_eq!(center_x.len(), 2);
860        assert_eq!(right_x.len(), 2);
861
862        assert!(
863            start_x[0].abs() < 0.5 && start_x[1].abs() < 0.5,
864            "default (start) alignment must hug the left edge: {start_x:?}"
865        );
866        assert!(
867            center_x[0] > center_x[1] + 1.0,
868            "the shorter line must center further right than the longer one: {center_x:?}"
869        );
870        assert!(
871            right_x[0] > right_x[1] + 1.0,
872            "the shorter line's right-aligned left edge must sit further right: {right_x:?}"
873        );
874    }
875
876    // --- max_lines / TextOverflow, end-to-end through the widget ---
877
878    use frust_text::TextOverflow;
879
880    #[test]
881    fn max_lines_truncates_wrapped_content_to_one_line() {
882        let content = "Hello from Frust, the pure Rust mobile UI toolkit";
883        let bc = BoxConstraints::loose(Size::new(80.0, 200.0));
884
885        let unbounded = line_min_x(&painted_runs(text(content), bc));
886        assert!(
887            unbounded.len() > 1,
888            "fixture sanity: expected this phrase to wrap at 80px, got {} line(s)",
889            unbounded.len()
890        );
891
892        let bounded = line_min_x(&painted_runs(
893            text(content).max_lines(1).overflow(TextOverflow::Ellipsis),
894            bc,
895        ));
896        assert_eq!(
897            bounded.len(),
898            1,
899            "max_lines(1) must render exactly one line"
900        );
901    }
902
903    #[test]
904    fn clip_overflow_also_drops_extra_lines() {
905        // Clip is the default overflow — `.max_lines()` alone must already
906        // cap the rendered line count, with no `.overflow()` call needed.
907        let content = "Hello from Frust, the pure Rust mobile UI toolkit";
908        let bc = BoxConstraints::loose(Size::new(80.0, 200.0));
909
910        let clipped = line_min_x(&painted_runs(text(content).max_lines(1), bc));
911        assert_eq!(clipped.len(), 1);
912    }
913
914    #[test]
915    fn changing_max_lines_between_rebuilds_invalidates_the_cached_shape() {
916        // `TextWidget::layout`'s fast path skips reshaping when nothing that
917        // affects shaping changed; `max_lines`/`overflow` must be wired into
918        // that invalidation the same way content/style already are, or a
919        // rebuild that only changes the line cap would keep painting the
920        // stale (differently-truncated) layout.
921        let content = "Hello from Frust, the pure Rust mobile UI toolkit";
922        let bc = BoxConstraints::loose(Size::new(80.0, 200.0));
923
924        let view_a = text(content).max_lines(1).overflow(TextOverflow::Ellipsis);
925        let mut widget = View::<()>::build(&view_a, &mut frust_core::BuildCtx::new(&mut 0u64));
926        {
927            let mut tcx = TextContext::new();
928            let mut lctx = LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), None);
929            widget.layout(&mut lctx, &bc);
930        }
931
932        let view_b = text(content).max_lines(2).overflow(TextOverflow::Ellipsis);
933        View::<()>::rebuild(
934            &view_b,
935            &view_a,
936            &mut widget,
937            &mut frust_core::BuildCtx::new(&mut 0u64),
938        );
939
940        let mut tcx = TextContext::new();
941        let mut lctx = LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), None);
942        widget.layout(&mut lctx, &bc);
943        let mut rec = GlyphRunRecorder::default();
944        let mut pctx = PaintCtx::new(Point::ZERO, bc.max());
945        widget.paint(&mut pctx, &mut rec);
946        assert_eq!(
947            line_min_x(&rec.runs).len(),
948            2,
949            "the rebuild must have re-shaped against the new max_lines(2), \
950             not replayed the max_lines(1) cache"
951        );
952    }
953
954    // --- Theme-swap regression ---
955    //
956    // `effective_style` (above) resolves the themed color at LAYOUT time and
957    // bakes it into the cached `TextLayout`'s glyph brush; `paint` only replays
958    // the cached layout. Every shell calls layout unconditionally every frame
959    // (none gates on `RenderRoot::take_change_flags` today — see
960    // `frust_core::app`'s doc comment on that seam), so a bare `set_theme`
961    // with no view change must still repaint with the new theme's color. This
962    // test drives that exact shell contract end-to-end through a real
963    // `RenderRoot` and is deliberately independent of the `set_theme`
964    // change-flags fix in `frust-core::app` — temporarily reverting that fix
965    // must not break this test, since it never consults `take_change_flags`.
966    #[test]
967    fn theme_swap_with_no_view_change_repaints_new_glyph_color() {
968        use frust_core::{FrameTime, RenderRoot};
969
970        fn logic(_state: &mut ()) -> TextView {
971            text("label").themed_role(ThemeTextColor::OnPrimary)
972        }
973
974        let mut root: RenderRoot<(), TextView> = RenderRoot::new();
975        let mut state = ();
976
977        let mut theme_a = Theme::neutral();
978        theme_a.brightness = frust_theme::Brightness::Light;
979        let color_a = theme_a.scheme().on_primary;
980        root.set_theme(Box::new(theme_a));
981        root.rebuild(&mut logic, &mut state);
982
983        let mut tcx = TextContext::new();
984        root.layout_with_text(Size::new(200.0, 100.0), &mut tcx as &mut dyn Any);
985        let mut rec = GlyphRecorder::default();
986        root.paint(&mut rec, FrameTime::ZERO);
987        assert_eq!(
988            *rec.colors.first().expect("glyph run painted"),
989            color_a,
990            "sanity: first paint reflects theme A's on_primary role"
991        );
992
993        // Swap to a theme whose on_primary genuinely differs (dark scheme), with
994        // NO view change (same `logic`, so `rebuild` diffs identical views) —
995        // mirroring a live appearance flip. Every shell re-lays-out/repaints
996        // unconditionally on the next frame regardless of `rebuild`'s own
997        // ChangeFlags, so drive layout/paint again here without a view change.
998        let mut theme_b = Theme::neutral();
999        theme_b.brightness = frust_theme::Brightness::Dark;
1000        let color_b = theme_b.scheme().on_primary;
1001        assert_ne!(
1002            color_a, color_b,
1003            "fixture sanity: themes must actually differ"
1004        );
1005        root.set_theme(Box::new(theme_b));
1006
1007        root.layout_with_text(Size::new(200.0, 100.0), &mut tcx as &mut dyn Any);
1008        let mut rec2 = GlyphRecorder::default();
1009        root.paint(&mut rec2, FrameTime::ZERO);
1010        assert_eq!(
1011            *rec2.colors.first().expect("glyph run painted"),
1012            color_b,
1013            "a bare theme swap (no view change) must re-resolve the themed glyph \
1014             color at the next layout, since the color is baked into the cached \
1015             TextLayout at layout time, not read fresh at paint time"
1016        );
1017    }
1018
1019    // --- Themed family resolution ---
1020    //
1021    // Asserted on the painted runs' font bytes, not on the style a builder
1022    // stored: a family that is set but never reaches shaping is exactly the
1023    // failure this seam exists to close. One registered test face (Tuffy) and
1024    // one family nothing registers make a run's bytes say which family it
1025    // shaped against.
1026    //
1027    // Only Tuffy is registered here, never its "Helvetica"-renamed twin:
1028    // `register_fonts` also publishes process-wide (`frust_text`'s app-font
1029    // record seeds every later `TextContext`), and `textinput`'s
1030    // late-registration test needs "Helvetica" unregistered when it starts.
1031
1032    /// The public-domain subsetted test face `frust-text`'s own registration
1033    /// tests use (included cross-crate, like `textinput`'s tests do).
1034    const TUFFY: &[u8] = include_bytes!("../../frust-text/tests/fonts/Tuffy-Subset.ttf");
1035
1036    /// A family no test registers: it resolves to a host fallback face, whose
1037    /// bytes cannot be [`TUFFY`]'s (`textinput`'s unregistered-family control).
1038    const UNREGISTERED: &str = "Frust No Such Family";
1039
1040    /// A text context with the Tuffy test face registered.
1041    fn tuffy_context() -> TextContext {
1042        let mut tcx = TextContext::new();
1043        tcx.register_fonts(TUFFY.to_vec())
1044            .expect("the Tuffy test face registers");
1045        tcx
1046    }
1047
1048    /// A neutral theme whose `body_large`/`label_large` slots name the given
1049    /// families — every other slot keeps the neutral scale's generic stack.
1050    fn role_theme(body_large: &str, label_large: &str) -> Theme {
1051        let mut theme = Theme::neutral();
1052        theme.type_scale.body_large.family = FontFamily::named(body_large);
1053        theme.type_scale.label_large.family = FontFamily::named(label_large);
1054        theme
1055    }
1056
1057    /// Builds, lays out (with `theme` threaded in when `Some`) and paints
1058    /// `view` against `tcx`, returning every painted glyph run.
1059    fn painted_runs_in(
1060        view: &TextView,
1061        theme: Option<&Theme>,
1062        tcx: &mut TextContext,
1063    ) -> Vec<GlyphRun> {
1064        let mut widget = View::<()>::build(view, &mut frust_core::BuildCtx::new(&mut 0u64));
1065        let mut lctx =
1066            LayoutCtx::with_resources(Some(tcx as &mut dyn Any), theme.map(|t| t as &dyn Any));
1067        widget.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 100.0)));
1068        let mut rec = GlyphRunRecorder::default();
1069        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(200.0, 100.0));
1070        widget.paint(&mut pctx, &mut rec);
1071        rec.runs
1072    }
1073
1074    /// Whether every run shaped against `face`, with at least one run painted.
1075    fn all_runs_are(runs: &[GlyphRun], face: &[u8]) -> bool {
1076        !runs.is_empty() && runs.iter().all(|r| r.font.font().data.as_ref() == face)
1077    }
1078
1079    /// Whether no run shaped against `face`, with at least one run painted.
1080    fn no_run_is(runs: &[GlyphRun], face: &[u8]) -> bool {
1081        !runs.is_empty() && runs.iter().all(|r| r.font.font().data.as_ref() != face)
1082    }
1083
1084    #[test]
1085    fn a_themed_family_resolves_the_theme_roles_family() {
1086        let mut tcx = tuffy_context();
1087        let body = text("Hello").themed_family(ThemeTextType::BodyLarge);
1088        let label = text("Hello").themed_family(ThemeTextType::LabelLarge);
1089
1090        // Each role reads its own slot — the mapping is per role, not one
1091        // family for every opted-in text — so swapping which slot names Tuffy
1092        // swaps which text shapes in it.
1093        let body_is_tuffy = role_theme("Tuffy", UNREGISTERED);
1094        assert!(
1095            all_runs_are(
1096                &painted_runs_in(&body, Some(&body_is_tuffy), &mut tcx),
1097                TUFFY
1098            ),
1099            "BodyLarge must shape in the theme's body_large family (Tuffy)"
1100        );
1101        assert!(
1102            no_run_is(
1103                &painted_runs_in(&label, Some(&body_is_tuffy), &mut tcx),
1104                TUFFY
1105            ),
1106            "LabelLarge must not read the body_large slot"
1107        );
1108
1109        let label_is_tuffy = role_theme(UNREGISTERED, "Tuffy");
1110        assert!(
1111            all_runs_are(
1112                &painted_runs_in(&label, Some(&label_is_tuffy), &mut tcx),
1113                TUFFY
1114            ),
1115            "LabelLarge must shape in the theme's label_large family (Tuffy)"
1116        );
1117        assert!(
1118            no_run_is(
1119                &painted_runs_in(&body, Some(&label_is_tuffy), &mut tcx),
1120                TUFFY
1121            ),
1122            "BodyLarge must not read the label_large slot"
1123        );
1124
1125        // Fixture sanity: without opting in, the same text under the same
1126        // theme never picks up a type-scale family.
1127        assert!(
1128            no_run_is(
1129                &painted_runs_in(&text("Hello"), Some(&body_is_tuffy), &mut tcx),
1130                TUFFY
1131            ),
1132            "a text that never opted in must not pick up a type-scale family"
1133        );
1134    }
1135
1136    #[test]
1137    fn an_explicit_family_or_style_wins_over_a_themed_family_in_either_order() {
1138        // The role names a family nothing registers; only the explicit
1139        // family can make these runs Tuffy.
1140        let theme = role_theme(UNREGISTERED, UNREGISTERED);
1141        let mut tcx = tuffy_context();
1142        let tuffy = FontFamily::named("Tuffy");
1143        let views = [
1144            text("Hello")
1145                .themed_family(ThemeTextType::BodyLarge)
1146                .family(tuffy.clone()),
1147            text("Hello")
1148                .family(tuffy.clone())
1149                .themed_family(ThemeTextType::BodyLarge),
1150            text("Hello")
1151                .style(TextStyle {
1152                    family: tuffy.clone(),
1153                    ..TextStyle::default()
1154                })
1155                .themed_family(ThemeTextType::BodyLarge),
1156        ];
1157        for (i, view) in views.iter().enumerate() {
1158            let runs = painted_runs_in(view, Some(&theme), &mut tcx);
1159            assert!(
1160                all_runs_are(&runs, TUFFY),
1161                "case {i}: an explicit family must win over the BodyLarge role"
1162            );
1163        }
1164    }
1165
1166    #[test]
1167    fn an_explicit_color_does_not_suppress_the_themed_family() {
1168        // Color and family resolve independently: the explicit color keeps
1169        // its value AND the family still follows the role.
1170        let theme = role_theme("Tuffy", UNREGISTERED);
1171        let mut tcx = tuffy_context();
1172        let custom = Color::from_rgb8(1, 2, 3);
1173        let runs = painted_runs_in(
1174            &text("Hello")
1175                .color(custom)
1176                .themed_family(ThemeTextType::BodyLarge),
1177            Some(&theme),
1178            &mut tcx,
1179        );
1180        assert!(
1181            all_runs_are(&runs, TUFFY),
1182            "an explicit color must not stop the family resolving from BodyLarge"
1183        );
1184        assert!(
1185            runs.iter()
1186                .all(|r| matches!(r.brush, Brush::Solid(c) if c == custom)),
1187            "the explicit color must still win over the themed color role"
1188        );
1189    }
1190
1191    #[test]
1192    fn without_a_theme_or_a_role_the_family_is_exactly_the_style_default() {
1193        // The opt-in property every unthemed app and benchmark relies on.
1194        let view = text("Hello").themed_family(ThemeTextType::BodyLarge);
1195        let widget = View::<()>::build(&view, &mut frust_core::BuildCtx::new(&mut 0u64));
1196        assert_eq!(
1197            widget.effective_style(None),
1198            TextStyle::default(),
1199            "no theme: a themed family must leave the style untouched (SystemUi)"
1200        );
1201        assert_eq!(widget.effective_style(None).family, FontFamily::SystemUi);
1202
1203        let theme = role_theme("Tuffy", "Tuffy");
1204        let plain = View::<()>::build(&text("Hello"), &mut frust_core::BuildCtx::new(&mut 0u64));
1205        assert_eq!(
1206            plain.effective_style(Some(&theme)).family,
1207            FontFamily::SystemUi,
1208            "no role: a theme must not change the family of a text that never opted in"
1209        );
1210
1211        // And what paints matches a text that never opted in at all.
1212        let mut tcx = tuffy_context();
1213        let opted_in = painted_runs_in(&view, None, &mut tcx);
1214        let never = painted_runs_in(&text("Hello"), None, &mut tcx);
1215        let faces = |runs: &[GlyphRun]| -> Vec<Vec<u8>> {
1216            runs.iter()
1217                .map(|r| r.font.font().data.as_ref().to_vec())
1218                .collect()
1219        };
1220        assert_eq!(faces(&opted_in), faces(&never));
1221    }
1222
1223    #[test]
1224    fn changing_the_family_role_on_rebuild_invalidates_the_cached_shape() {
1225        let theme = role_theme(UNREGISTERED, "Tuffy");
1226        let mut tcx = tuffy_context();
1227        let bc = BoxConstraints::loose(Size::new(200.0, 100.0));
1228        let paint = |widget: &mut TextWidget, tcx: &mut TextContext| {
1229            let mut lctx =
1230                LayoutCtx::with_resources(Some(tcx as &mut dyn Any), Some(&theme as &dyn Any));
1231            widget.layout(&mut lctx, &bc);
1232            let mut rec = GlyphRunRecorder::default();
1233            widget.paint(&mut PaintCtx::new(Point::ZERO, bc.max()), &mut rec);
1234            rec.runs
1235        };
1236
1237        let view_a = text("Hello").themed_family(ThemeTextType::BodyLarge);
1238        let mut widget = View::<()>::build(&view_a, &mut frust_core::BuildCtx::new(&mut 0u64));
1239        assert!(no_run_is(&paint(&mut widget, &mut tcx), TUFFY));
1240
1241        let view_b = text("Hello").themed_family(ThemeTextType::LabelLarge);
1242        let flags = View::<()>::rebuild(
1243            &view_b,
1244            &view_a,
1245            &mut widget,
1246            &mut frust_core::BuildCtx::new(&mut 0u64),
1247        );
1248        assert!(flags.needs_layout(), "a role change must request layout");
1249        assert!(
1250            all_runs_are(&paint(&mut widget, &mut tcx), TUFFY),
1251            "the rebuild must re-shape in LabelLarge's family (Tuffy), not \
1252             replay the BodyLarge cache"
1253        );
1254    }
1255
1256    #[test]
1257    fn a_type_scale_family_swap_with_no_view_change_reshapes() {
1258        // The live-theme contract a design system's font picker depends on:
1259        // a bare `set_theme` whose type scale names a new family repaints an
1260        // opted-in text in that family at the next layout. The effective
1261        // style — family included — keys both the widget's own cache and the
1262        // text context's shape cache, so the swap cannot replay stale shaping.
1263        use frust_core::{FrameTime, RenderRoot};
1264
1265        fn logic(_state: &mut ()) -> TextView {
1266            text("label").themed_family(ThemeTextType::BodyLarge)
1267        }
1268        #[derive(Default)]
1269        struct FaceRecorder {
1270            faces: Vec<Vec<u8>>,
1271        }
1272        impl PaintScene for FaceRecorder {
1273            fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1274            fn draw_text(&mut self, _o: Point, _t: &str) {}
1275            fn draw_glyph_run(&mut self, run: GlyphRun) {
1276                self.faces.push(run.font.font().data.as_ref().to_vec());
1277            }
1278        }
1279        let paint_faces = |root: &mut RenderRoot<(), TextView>, tcx: &mut TextContext| {
1280            root.layout_with_text(Size::new(200.0, 100.0), tcx as &mut dyn Any);
1281            let mut rec = FaceRecorder::default();
1282            root.paint(&mut rec, FrameTime::ZERO);
1283            rec.faces
1284        };
1285
1286        let mut root: RenderRoot<(), TextView> = RenderRoot::new();
1287        let mut tcx = tuffy_context();
1288        root.set_theme(Box::new(role_theme(UNREGISTERED, UNREGISTERED)));
1289        root.rebuild(&mut logic, &mut ());
1290        let before = paint_faces(&mut root, &mut tcx);
1291        assert!(!before.is_empty() && before.iter().all(|f| f != TUFFY));
1292
1293        root.set_theme(Box::new(role_theme("Tuffy", UNREGISTERED)));
1294        let after = paint_faces(&mut root, &mut tcx);
1295        assert!(
1296            !after.is_empty() && after.iter().all(|f| f == TUFFY),
1297            "a bare type-scale family swap must re-shape the opted-in text in the \
1298             new family"
1299        );
1300    }
1301}