Skip to main content

frus_widgets/
relayout.rs

1//! The **relayout boundary** cache: holds, between frames, the computed rectangles
2//! of each layout root, indexed by identity.
3//!
4//! Frus rebuilds the widget tree on every frame and, until now, re-ran taffy *from
5//! scratch* at every layout root (`build_ui`, every scrollable, every screen, every
6//! overlay…). Yet the geometry depends **only** on the tree's *style* and
7//! *structure* and on the parent's *constraints* — not on colors or text, which
8//! only affect painting. A hover, a blinking caret, an animating color → the same
9//! layout.
10//!
11//! This cache records, per root (`WidgetId`), `(fingerprint, constraints,
12//! rectangles)`. If the layout fingerprint and the constraints are unchanged, the
13//! **rectangles are reused** and taffy is not called again. The output is
14//! **bit-for-bit identical** to the full computation — only the performance
15//! changes. It is also the foundation of the future phase system: "fingerprint
16//! changed" *is* a root's "layout dirty" bit.
17
18use std::collections::{HashMap, HashSet};
19use std::hash::{Hash, Hasher};
20
21use frus_core::{Rect, Size};
22use frus_layout::Overflowing;
23
24use crate::interaction::WidgetId;
25use crate::runtime::Runtime;
26use crate::ui::{build_layout, child_id, effective_style};
27use crate::widget::Widget;
28
29/// The constraints passed to taffy for a layout root. `free_x`/`free_y` leave an
30/// axis **free** (the content takes its natural size) — the case of scrollable
31/// content.
32#[derive(Clone, Copy, Debug, PartialEq)]
33pub(crate) struct Constraints {
34    pub w: f32,
35    pub h: f32,
36    pub free_x: bool,
37    pub free_y: bool,
38    /// **Fill** the box rather than hug the content: an unset (`Auto`) dimension of
39    /// the root becomes the available size. For content that is *given* its box —
40    /// a page of a [`crate::PageView`] — rather than asked how big it wants to be.
41    pub fill: bool,
42    /// **Force** the constrained axes rather than merely fill them: a size the content
43    /// chose for itself is overruled. What a stack layer pinned to two opposite edges
44    /// asks for — see [`frus_layout::Layout::compute_tight`].
45    pub tight: bool,
46    /// Whether each bounded axis is filled, said per axis — for a box that hands its child
47    /// the room on one axis and only allows it on the other (milestone 598). `None` leaves
48    /// it to `fill`, and to the lone-axis rule of a scrollable.
49    pub fill_each: Option<[bool; 2]>,
50}
51
52impl Constraints {
53    /// Both axes constrained to `size` (ordinary layout).
54    pub fn definite(size: Size) -> Self {
55        Self {
56            w: size.width,
57            h: size.height,
58            free_x: false,
59            free_y: false,
60            fill: false,
61            tight: false,
62            fill_each: None,
63        }
64    }
65
66    /// A box the content is **handed**: constrained on both axes, and filled.
67    pub fn filled(size: Size) -> Self {
68        Self {
69            fill: true,
70            ..Self::definite(size)
71        }
72    }
73
74    /// Scrollable content: each axis either constrained or free.
75    pub fn scroll(w: f32, h: f32, free_x: bool, free_y: bool) -> Self {
76        Self {
77            w,
78            h,
79            free_x,
80            free_y,
81            fill: false,
82            tight: false,
83            fill_each: None,
84        }
85    }
86
87    /// A box the content is **forced** into on the axes that were given, with the others
88    /// left free so the content's own size comes back on them.
89    pub fn pinned(w: Option<f32>, h: Option<f32>, available: Size) -> Self {
90        Self {
91            w: w.unwrap_or(available.width),
92            h: h.unwrap_or(available.height),
93            free_x: w.is_none(),
94            free_y: h.is_none(),
95            fill: false,
96            tight: true,
97            fill_each: None,
98        }
99    }
100
101    /// Each axis free or bounded, and each bounded axis filled or only allowed.
102    pub fn axes(w: f32, h: f32, free: [bool; 2], fill: [bool; 2]) -> Self {
103        Self {
104            fill_each: Some(fill),
105            ..Self::scroll(w, h, free[0], free[1])
106        }
107    }
108}
109
110/// One cache entry: the fingerprint of the last root computed under this identity,
111/// its constraints, and the rectangles produced (in prefix order).
112struct Entry {
113    signature: u64,
114    constraints: Constraints,
115    rects: Vec<Rect>,
116    /// Boxes in this root whose children did not fit. Cached alongside the rectangles
117    /// because it is computed from the same taffy pass and would otherwise need a
118    /// second one on every cache hit.
119    overflows: Vec<Overflowing>,
120}
121
122/// The relayout cache, retained in the [`crate::Runtime`] from one frame to the next.
123#[derive(Default)]
124pub struct LayoutCache {
125    entries: HashMap<WidgetId, Entry>,
126    /// Roots touched during the current frame (to evict the vanished ones).
127    touched: HashSet<WidgetId>,
128    /// Diagnostic: reuses and recomputations of the **last** frame.
129    last_hits: u32,
130    last_misses: u32,
131    hits: u32,
132    misses: u32,
133}
134
135impl LayoutCache {
136    /// The rectangles (prefix order) of root `key` under constraints `c`. Reuses
137    /// the cache if the layout fingerprint **and** the constraints are unchanged;
138    /// otherwise re-runs taffy and records the result.
139    pub(crate) fn rects<Msg>(
140        &mut self,
141        key: WidgetId,
142        root: &dyn Widget<Msg>,
143        runtime: &Runtime,
144        theme: &crate::theme::Theme,
145        c: Constraints,
146    ) -> (Vec<Rect>, Vec<Overflowing>) {
147        self.touched.insert(key);
148        let (signature, volatile) = signature_of(root, key, runtime, theme);
149        if !volatile {
150            if let Some(entry) = self.entries.get(&key) {
151                if entry.signature == signature && entry.constraints == c {
152                    self.hits += 1;
153                    return (entry.rects.clone(), entry.overflows.clone());
154                }
155            }
156        }
157        self.misses += 1;
158        let (rects, overflows) = compute_rects(root, key, runtime, theme, c);
159        if volatile {
160            // Nothing is stored either: an entry that can never be trusted would only
161            // sit there being evicted and re-made.
162            self.entries.remove(&key);
163            return (rects, overflows);
164        }
165        self.entries.insert(
166            key,
167            Entry {
168                signature,
169                constraints: c,
170                rects: rects.clone(),
171                overflows: overflows.clone(),
172            },
173        );
174        (rects, overflows)
175    }
176
177    /// To be called at the end of a frame: forgets untouched roots (vanished
178    /// widgets) and freezes the frame's diagnostic counters.
179    pub(crate) fn end_frame(&mut self) {
180        let touched = std::mem::take(&mut self.touched);
181        self.entries.retain(|id, _| touched.contains(id));
182        self.last_hits = self.hits;
183        self.last_misses = self.misses;
184        self.hits = 0;
185        self.misses = 0;
186    }
187
188    /// Reuses and recomputations of the last completed frame (diagnostic).
189    pub fn last_frame_stats(&self) -> (u32, u32) {
190        (self.last_hits, self.last_misses)
191    }
192}
193
194/// Computes a root's absolute rectangles — the "full" path (build + taffy +
195/// collect), taken on every cache *miss*.
196fn compute_rects<Msg>(
197    root: &dyn Widget<Msg>,
198    key: WidgetId,
199    runtime: &Runtime,
200    theme: &crate::theme::Theme,
201    c: Constraints,
202) -> (Vec<Rect>, Vec<Overflowing>) {
203    let mut layout = frus_layout::Layout::new();
204    let node = build_layout(root, key, runtime, theme, &mut layout);
205    // `compute_scroll(_, _, false, false)` is equivalent to `compute` (both axes
206    // `Definite`): a single path covers both cases.
207    if c.tight {
208        layout.compute_tight(node, c.w, c.h, c.free_x, c.free_y);
209    } else if let Some(fill) = c.fill_each {
210        layout.compute_axes(node, c.w, c.h, [c.free_x, c.free_y], fill);
211    } else if c.fill {
212        layout.compute_filled(node, c.w, c.h);
213    } else {
214        layout.compute_scroll(node, c.w, c.h, c.free_x, c.free_y);
215    }
216    let rects = layout
217        .absolute_rects(node)
218        .into_iter()
219        .map(|(rect, _)| rect)
220        .collect();
221    (rects, layout.overflows(node))
222}
223
224/// A 64-bit fingerprint of a subtree's **layout** — styles + structure, following
225/// **exactly** the branching of [`build_layout`] (scrollable/navigator/list/stack = a
226/// leaf; portal = the anchor alone), with colours, texts and messages excluded because
227/// they only affect painting — and whether that fingerprint can be **trusted between
228/// frames**.
229///
230/// A `LayoutBuilder`'s box is the size of what its closure built, and a closure cannot be
231/// fingerprinted: two frames whose styles and structure are identical can still want
232/// different geometry, because the application changed what it builds. So a root holding
233/// one is marked *volatile* and recomputed every frame — the cache's contract is that a
234/// hit is **bit-for-bit** what the full computation would have produced, and the only
235/// honest way to keep that here is to take the miss.
236fn signature_of<Msg>(
237    root: &dyn Widget<Msg>,
238    id: WidgetId,
239    runtime: &Runtime,
240    theme: &crate::theme::Theme,
241) -> (u64, bool) {
242    let mut hasher = std::collections::hash_map::DefaultHasher::new();
243    // The **reader's font size**, once, at the root. Every measured box in the tree is
244    // resolved against it, so a reader who changes the system setting changes every
245    // geometry below — and a fingerprint blind to it would answer the new frame with the
246    // old layout and nothing would move.
247    frus_core::text_scale().to_bits().hash(&mut hasher);
248    let mut volatile = false;
249    hash_node(root, id, runtime, theme, &mut hasher, &mut volatile);
250    (hasher.finish(), volatile)
251}
252
253fn hash_node<Msg, H: Hasher>(
254    widget: &dyn Widget<Msg>,
255    id: WidgetId,
256    runtime: &Runtime,
257    theme: &crate::theme::Theme,
258    hasher: &mut H,
259    volatile: &mut bool,
260) {
261    // A themed subtree, exactly as `build_layout` sees it: its styles are resolved
262    // against its own theme, so the fingerprint is taken against that one too. A
263    // fingerprint that skipped the swap would hash one geometry and the cache would
264    // store another.
265    let scoped = crate::ui::scoped_theme(widget, id, runtime, theme);
266    let theme = scoped.as_deref().unwrap_or(theme);
267    // The same for the **surface**: a scoped description changes what the subtree measures
268    // with, so it has to be in force here as well — and hashed, or two different surfaces
269    // would share one fingerprint and the cache would keep the first one's geometry.
270    let surface = widget.media_override(crate::MediaQuery::of());
271    let _surface = surface.map(crate::MediaQuery::install);
272    if let Some(mq) = surface {
273        mq.measure_hash(hasher);
274    }
275    // And the **shell** (milestone 422), hashed for the same reason: whether there is a
276    // drawer decides whether a bar composes a menu button, and a button is a different row.
277    let shell = widget.scaffold_override();
278    if let Some(info) = &shell {
279        info.shape_hash(hasher);
280    }
281    let _shell = shell.map(crate::ScaffoldInfo::install);
282    // And the scroll behaviour (milestone 616): it decides whether a scrollable lays out
283    // a scrollbar.
284    let scrolling = widget.scroll_behavior_override();
285    if let Some(behavior) = &scrolling {
286        behavior.shape_hash(hasher);
287    }
288    let _scrolling = scrolling.map(crate::ScrollConfiguration::install);
289    // The cache exists to **skip** `build_layout`, so this walk can be the first one down
290    // the tree: a deferred subtree has to be composed here too, or the fingerprint would
291    // be taken of a node with no children and the cache would agree with itself forever.
292    widget.build_in(id, runtime, theme);
293    // These branches must stay aligned with `build_layout`: the shape of the taffy
294    // tree (and therefore the number and order of the rectangles) depends on it.
295    // The **effective** style is hashed (animated size injected) — the same source
296    // as `build_layout`, so the fingerprint changes while the size moves.
297    // `RotatedBox`: its box depends on the child's **natural** size (swapped for an
298    // odd quarter), so the fingerprint must include the child; otherwise a modified
299    // child would leave a stale box in the cache.
300    if let Some(q) = widget.rotated_quarter_turns() {
301        4u8.hash(hasher);
302        q.hash(hasher);
303        effective_style(widget, id, runtime, theme).layout_hash(hasher);
304        if let Some(child) = widget.children().first() {
305            hash_node(
306                child.as_ref(),
307                child_id(id, 0, child.as_ref()),
308                runtime,
309                theme,
310                hasher,
311                volatile,
312            );
313        }
314        return;
315    }
316    if widget.scroll_content().is_some()
317        || widget.interactive().is_some()
318        || widget.fitted().is_some()
319        || widget.navigator().is_some()
320        // Only *whether*, never *what*: this asks if the widget windows its
321        // children at all, and a size it will not read is the honest argument.
322        || widget.virtual_list(frus_core::Size::ZERO).is_some()
323        || widget.page_view().is_some()
324        || widget.overflow_box().is_some()
325        || widget.stack()
326        || widget.custom_layout().is_some()
327    {
328        1u8.hash(hasher);
329        effective_style(widget, id, runtime, theme).layout_hash(hasher);
330        return;
331    }
332    // A box that transforms its constraints is sized from its child, laid out in a tree of
333    // its own, under a rule that is **data**: the rule and the child are hashed, and the
334    // entry can be trusted (milestone 593). It used to poison the entry as a
335    // `LayoutBuilder` does, and every root holding one was laid out again every frame.
336    if let Some(transform) = widget.constraints_transform() {
337        5u8.hash(hasher);
338        transform.layout_hash(hasher);
339        let fills = widget.fill_axes(theme);
340        (fills.horizontal, fills.vertical).hash(hasher);
341        // A box that is a fraction of its child is a different box at every fraction
342        // (milestone 596).
343        if let Some((across, down)) = widget.size_factor() {
344            across.to_bits().hash(hasher);
345            down.to_bits().hash(hasher);
346        }
347        // A box following its child's size is a different geometry at every step of the
348        // move, and the same one once it has arrived (milestone 594).
349        if widget.animated_size().is_some() {
350            if let Some((now, to)) = runtime.size_anims.borrow_mut().fingerprint(id) {
351                for v in [now.width, now.height, to.width, to.height] {
352                    v.to_bits().hash(hasher);
353                }
354            }
355        }
356        effective_style(widget, id, runtime, theme).layout_hash(hasher);
357        if let Some(child) = widget.children().first() {
358            hash_node(
359                child.as_ref(),
360                child_id(id, 0, child.as_ref()),
361                runtime,
362                theme,
363                hasher,
364                volatile,
365            );
366        }
367        return;
368    }
369    // A `LayoutBuilder` hashes like a leaf and poisons the entry: its box comes from a
370    // closure, and a closure cannot be hashed. Two frames whose trees fingerprint alike
371    // could build two different children.
372    if widget.layout_builder().is_some() {
373        1u8.hash(hasher);
374        effective_style(widget, id, runtime, theme).layout_hash(hasher);
375        *volatile = true;
376        return;
377    }
378    if widget.overlay().is_some() {
379        2u8.hash(hasher);
380        effective_style(widget, id, runtime, theme).layout_hash(hasher);
381        let anchor = widget.children()[0].as_ref();
382        hash_node(
383            anchor,
384            child_id(id, 0, anchor),
385            runtime,
386            theme,
387            hasher,
388            volatile,
389        );
390        return;
391    }
392    let children = widget.children();
393    3u8.hash(hasher);
394    effective_style(widget, id, runtime, theme).layout_hash(hasher);
395    // A measured leaf: its **content** (text…) affects the geometry without going
396    // through the style — without this fingerprint, two different contents would
397    // be conflated and the cache would keep an old layout.
398    widget.measure_key(theme).hash(hasher);
399    // Filling the parent is resolved during the walk rather than written into the style,
400    // so the style hash alone would conflate a run that fills with one that shrink-wraps.
401    let fills = widget.fill_axes(theme);
402    (fills.horizontal, fills.vertical).hash(hasher);
403    widget.main_axis_floor(theme).map(f32::to_bits).hash(hasher);
404    widget.tile_shape().map(f32::to_bits).hash(hasher);
405    children.len().hash(hasher);
406    for (i, child) in children.iter().enumerate() {
407        hash_node(
408            child.as_ref(),
409            child_id(id, i, child.as_ref()),
410            runtime,
411            theme,
412            hasher,
413            volatile,
414        );
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use crate::{Container, Flex};
422
423    fn sig<Msg>(w: &dyn Widget<Msg>) -> u64 {
424        signature_of(
425            w,
426            WidgetId::ROOT,
427            &Runtime::default(),
428            &crate::theme::Theme::default(),
429        )
430        .0
431    }
432
433    #[test]
434    fn identical_trees_share_a_signature() {
435        let a: Container<()> = Container::new().width(100.0).height(40.0);
436        let b: Container<()> = Container::new().width(100.0).height(40.0);
437        assert_eq!(sig(&a), sig(&b));
438    }
439
440    #[test]
441    fn a_size_change_changes_the_signature() {
442        let a: Container<()> = Container::new().width(100.0).height(40.0);
443        let b: Container<()> = Container::new().width(101.0).height(40.0);
444        assert_ne!(sig(&a), sig(&b));
445    }
446
447    #[test]
448    fn child_count_changes_the_signature() {
449        let one: Flex<()> = Flex::row().child(Container::new());
450        let two: Flex<()> = Flex::row().child(Container::new()).child(Container::new());
451        assert_ne!(sig(&one), sig(&two));
452    }
453
454    /// Filling the parent is resolved during the walk and never reaches the style, so
455    /// the fingerprint has to ask for it separately — otherwise a row that fills and one
456    /// that shrink-wraps are the same tree as far as the cache is concerned, and the
457    /// second one silently gets the first one's layout.
458    #[test]
459    fn a_change_of_main_axis_size_changes_the_signature() {
460        let fills: crate::Row<()> = crate::Row::new().child(Container::new());
461        let hugs: crate::Row<()> = crate::Row::new().shrink_wrap().child(Container::new());
462        assert_ne!(sig(&fills), sig(&hugs));
463    }
464
465    /// **A root holding an unconstrained box is reused** when nothing in it changed
466    /// (milestone 593), and laid out again when its child or its rule did.
467    #[test]
468    fn a_measured_box_can_be_reused_and_still_notices_a_change() {
469        let rt = Runtime::default();
470        let theme = crate::theme::Theme::default();
471        let c = Constraints::definite(Size::new(300.0, 200.0));
472        let page = |label: &'static str, rule: crate::constraints::AxisConstraint| -> Flex<()> {
473            Flex::column()
474                // A spacer with a width of its own: an empty container takes the width on
475                // offer (milestone 633), and this test is about the label's.
476                .child(Container::new().width(10.0).height(20.0))
477                .child(crate::ConstraintsTransformBox::new(crate::Text::new(label)).width(rule))
478        };
479        let unbounded = crate::constraints::AxisConstraint::Unbounded;
480        let mut cache = LayoutCache::default();
481        let first = cache.rects(WidgetId::ROOT, &page("one", unbounded), &rt, &theme, c);
482        let again = cache.rects(WidgetId::ROOT, &page("one", unbounded), &rt, &theme, c);
483        assert_eq!(first, again);
484        assert_eq!(
485            (cache.hits, cache.misses),
486            (1, 1),
487            "the second frame is reused"
488        );
489
490        // One thing at a time, each against the entry just before it.
491        let longer = page("a much longer label", unbounded);
492        let changed = cache.rects(WidgetId::ROOT, &longer, &rt, &theme, c);
493        assert_eq!(cache.misses, 2, "a different child is a different box");
494        assert!(changed.0.last().unwrap().width > first.0.last().unwrap().width);
495
496        let fixed = page(
497            "a much longer label",
498            crate::constraints::AxisConstraint::Fixed(250.0),
499        );
500        let ruled = cache.rects(WidgetId::ROOT, &fixed, &rt, &theme, c);
501        assert_eq!(cache.misses, 3, "a different rule is a different box");
502        assert_eq!(ruled.0.last().unwrap().width, 250.0);
503
504        let wider = page(
505            "a much longer label",
506            crate::constraints::AxisConstraint::Fixed(280.0),
507        );
508        let rewidth = cache.rects(WidgetId::ROOT, &wider, &rt, &theme, c);
509        assert_eq!(cache.misses, 4, "a different number is a different rule");
510        assert_eq!(rewidth.0.last().unwrap().width, 280.0);
511    }
512
513    /// A `LayoutBuilder` still cannot be trusted between frames.
514    #[test]
515    fn a_layout_builder_still_poisons_its_root() {
516        let rt = Runtime::default();
517        let theme = crate::theme::Theme::default();
518        let c = Constraints::definite(Size::new(300.0, 200.0));
519        let tree: crate::LayoutBuilder<()> = crate::LayoutBuilder::new(|_| Container::new());
520        let mut cache = LayoutCache::default();
521        cache.rects(WidgetId::ROOT, &tree, &rt, &theme, c);
522        cache.rects(WidgetId::ROOT, &tree, &rt, &theme, c);
523        assert_eq!((cache.hits, cache.misses), (0, 2));
524    }
525
526    #[test]
527    fn cache_hits_on_repeated_identical_root() {
528        let mut cache = LayoutCache::default();
529        let rt = Runtime::default();
530        let key = WidgetId::ROOT;
531        let c = Constraints::definite(Size::new(200.0, 100.0));
532
533        let tree: Container<()> = Container::new().width(200.0).height(100.0);
534        let first = cache.rects(key, &tree, &rt, &crate::theme::Theme::default(), c);
535        let second = cache.rects(key, &tree, &rt, &crate::theme::Theme::default(), c);
536        assert_eq!(first, second, "the same rectangles");
537        // 1 miss (computed), then 1 hit (reused).
538        assert_eq!((cache.hits, cache.misses), (1, 1));
539    }
540
541    #[test]
542    fn changed_constraints_miss() {
543        let mut cache = LayoutCache::default();
544        let rt = Runtime::default();
545        let key = WidgetId::ROOT;
546        let tree: Container<()> = Container::new();
547        let theme = crate::theme::Theme::default();
548        cache.rects(
549            key,
550            &tree,
551            &rt,
552            &theme,
553            Constraints::definite(Size::new(200.0, 100.0)),
554        );
555        cache.rects(
556            key,
557            &tree,
558            &rt,
559            &theme,
560            Constraints::definite(Size::new(300.0, 100.0)),
561        );
562        assert_eq!(
563            (cache.hits, cache.misses),
564            (0, 2),
565            "size changed → recomputed"
566        );
567    }
568
569    #[test]
570    fn end_frame_evicts_untouched_roots() {
571        let mut cache = LayoutCache::default();
572        let rt = Runtime::default();
573        let c = Constraints::definite(Size::new(10.0, 10.0));
574        let tree: Container<()> = Container::new();
575        cache.rects(
576            WidgetId::ROOT,
577            &tree,
578            &rt,
579            &crate::theme::Theme::default(),
580            c,
581        );
582        cache.rects(
583            WidgetId::ROOT.child(0),
584            &tree,
585            &rt,
586            &crate::theme::Theme::default(),
587            c,
588        );
589        cache.end_frame();
590        assert_eq!(cache.entries.len(), 2);
591
592        // Next frame: only one root touched → the other is evicted.
593        cache.rects(
594            WidgetId::ROOT,
595            &tree,
596            &rt,
597            &crate::theme::Theme::default(),
598            c,
599        );
600        cache.end_frame();
601        assert_eq!(cache.entries.len(), 1);
602        assert!(cache.entries.contains_key(&WidgetId::ROOT));
603    }
604
605    /// **A style in flight moves the fingerprint.** The same tree twice, at two points of
606    /// one movement: nothing differs but the runtime.
607    ///
608    /// This is the cache's half of milestone 498, and it is not a formality. A font size
609    /// reaches *layout* — the box a run of words is measured into — so a fingerprint
610    /// blind to the style the theme swap put in force would answer every frame of the
611    /// movement with the first frame's geometry. The words would grow inside a box that
612    /// did not, and the layout would snap into place at the end. That failure is exactly
613    /// what this cache's contract forbids: a hit must be bit-for-bit what the full
614    /// computation would have produced.
615    #[test]
616    fn a_text_style_in_flight_moves_the_signature() {
617        let tree = |size: f32| {
618            crate::AnimatedDefaultTextStyle::<()>::new(
619                0.10,
620                frus_core::Curve::Linear,
621                frus_core::TextStyle::NONE.size(size),
622                crate::Text::new("Ag"),
623            )
624        };
625        let theme = crate::theme::Theme::default();
626        let mut rt = Runtime::default();
627        rt.advance_text_styles(&tree(24.0), 1.0);
628        rt.advance_text_styles(&tree(12.0), 0.02);
629        let early = signature_of(&tree(12.0), WidgetId::ROOT, &rt, &theme).0;
630        rt.advance_text_styles(&tree(12.0), 0.04);
631        let later = signature_of(&tree(12.0), WidgetId::ROOT, &rt, &theme).0;
632        assert_ne!(
633            early, later,
634            "the same tree at two points of one movement must not share a fingerprint"
635        );
636    }
637}