Skip to main content

teksilo_core/widget_tree/
rendering_impl.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use super::*;
5
6use teksilo_canvas::quantize_raster_scale;
7
8/// Accessibility preferences passed through the paint recursion.
9struct A11yPaintPrefs {
10    high_contrast: bool,
11    reduced_motion: bool,
12    large_text: bool,
13    /// Combined user×OS text-scale factor (the logical accessibility
14    /// magnification). Surfaced to widgets via `PaintContext::text_scale`.
15    text_scale: f32,
16    /// Window-active state (`focused AND not occluded`). Surfaced to widgets
17    /// via `PaintContext::window_active`. (Carried alongside the a11y prefs as
18    /// a per-paint ambient flag; not itself an accessibility preference.)
19    window_active: bool,
20}
21
22impl WidgetTree {
23    /// Paint all active widgets and produce a RenderFrame.
24    /// Uses per-widget paint caching: only widgets with `needs_paint` are
25    /// re-painted; clean widgets reuse their cached paint output.
26    /// Also caches the full assembled frame — if no widget needs painting,
27    /// the previous frame is returned immediately.
28    pub fn render(&mut self) -> std::rc::Rc<RenderFrame> {
29        let mut noop = crate::window::NoopWindowOps;
30        self.render_with_ops(&mut noop)
31    }
32
33    /// Render a frame, threading the app's
34    /// [`WindowOps`](crate::window::WindowOps) sink through any
35    /// state-change-triggered handlers (data-driven rebuild, binding
36    /// flush). Called by `teksilo-app` during its paint pipeline.
37    pub fn render_with_ops(
38        &mut self,
39        ops: &mut dyn crate::window::WindowOps,
40    ) -> std::rc::Rc<RenderFrame> {
41        self.process_state_changes(&mut *ops);
42
43        // Always tick the animated-quad registry — even on the cache-hit
44        // early-out we still need fresh phase in the frame's
45        // anim_params, because every looping slot advances. Widgets
46        // whose paint() wasn't re-run (all of them on cache hit) keep
47        // their last DrawCommand::AnimatedQuad in the cached frame;
48        // the renderer reads the live params from `frame.anim_params`
49        // at the slot index stored in the draw command.
50        //
51        // The registry's internal `scratch` buffer owns the params; we
52        // only copy when actually writing them into the frame (below).
53        // Taking a borrow here lets us skip the copy entirely in the
54        // non-cache-hit branch where we allocate a fresh frame anyway.
55        let now = std::time::Instant::now();
56        let has_animations = self.animated_quads.has_running();
57        if has_animations {
58            self.animated_quads
59                .tick(now, &self.arena, self.paint_epoch, &self.theme);
60        }
61
62        // Cache-hit short-circuit: nothing in the tree was marked
63        // needs_paint, so the pixels are identical to the previous
64        // frame apart from the animated-quad uniforms. We deliberately
65        // do NOT bump `paint_epoch` here — if we did, every widget's
66        // `last_painted_epoch` would silently age out and the animation
67        // scheduler would treat them as "off-screen" on the next tick.
68        // Holding the epoch steady preserves the visibility gate
69        // through arbitrarily many idle cache-hit frames. The fresh
70        // `anim_params` we just computed are attached so shader-driven
71        // animations keep advancing even when paint() doesn't run.
72        //
73        // `Rc::make_mut` short-circuits to a mutable borrow when the
74        // tree is the sole owner of the cached frame — which is the
75        // common case, since the app-side caller typically drops the
76        // previous frame before calling `render()` again. If the
77        // caller holds a second Rc clone (e.g. two back-to-back
78        // renders without letting the first drop) we fall back to a
79        // single deep clone for that frame.
80        if !self.arena.any_needs_paint() && self.cached_frame.is_some() {
81            // Cache-hit path. paint_epoch is frozen, so visible
82            // subscribers' `last_painted_epoch == paint_epoch` still
83            // holds. Re-arm the frame-tick chain BEFORE refreshing the
84            // cached frame so we don't tangle borrows.
85            self.arm_frame_tick_for_visible_subscribers();
86
87            if let Some(cached) = self.cached_frame.as_mut() {
88                let frame = std::rc::Rc::make_mut(cached);
89                if has_animations {
90                    let src = self.animated_quads.scratch_slice();
91                    frame.anim_params.clear();
92                    frame.anim_params.extend_from_slice(src);
93                }
94                // Refresh the text backend's glyph timestamps for every
95                // layout baked into the reused frame — same contract as
96                // the per-widget cached_paint path below. Without this,
97                // a window that idles on the full-frame cache while
98                // text work elsewhere (another window, or measure-only
99                // layout passes) advances the atlas generation would
100                // have its still-visible glyphs evicted and their atlas
101                // slots reused, garbling the cached quads.
102                if !frame.layout_keys.is_empty()
103                    && let Some(tb) = &self.text_backend
104                {
105                    let mut tb = tb.borrow_mut();
106                    for key in &frame.layout_keys {
107                        tb.touch_layout(*key);
108                    }
109                    #[cfg(debug_assertions)]
110                    debug_validate_layout_keys(&*tb, &frame.layout_keys, None);
111                }
112                return std::rc::Rc::clone(cached);
113            }
114        }
115
116        self.paint_epoch = self.paint_epoch.saturating_add(1);
117        let paint_epoch = self.paint_epoch;
118
119        let mut frame = RenderFrame::new();
120        // `effective_theme` carries the user/OS text-scale multiplier baked into
121        // its typography, so painted glyphs match the scaled layout sizes. The
122        // palette is then projected for this window's paint state: an inactive
123        // window greys out every accent-coloured control (default button,
124        // Toggle, checked Checkbox/Radio, selected Tab/Segment, Slider fill,
125        // ProgressBar, focus rings), the Qt `QPalette::Inactive` model resolved
126        // once theme-side instead of per widget, and the OS "increase
127        // contrast" preference swaps in the high-contrast variant (WCAG 1.4.6
128        // Enhanced / EN 301 549 11.7). A theme's own `PaintProjection` decides
129        // both when it registers one; see `Theme::for_paint_state`. Colours
130        // only; typography is unchanged, so this is repaint-only (geometry
131        // stable).
132        let base_theme = self.effective_theme.for_paint_state(self.paint_state());
133        let text_backend = self.text_backend.clone();
134        let a11y_prefs = A11yPaintPrefs {
135            high_contrast: self.prefers_high_contrast(),
136            reduced_motion: self.prefers_reduced_motion,
137            large_text: self.text_scale_factor > 1.0,
138            text_scale: self.effective_text_scale,
139            window_active: self.is_window_active(),
140        };
141
142        let overlay_skip: std::collections::HashSet<WidgetId> = self
143            .overlay_manager
144            .active_content_ids()
145            .into_iter()
146            .collect();
147
148        // Interactive overlay rects for this frame, handed to
149        // `after_paint` via `WidgetTreeView`. Chrome aggregators
150        // (`TitleBar`) subtract them from the geometry they publish to
151        // the OS — an overlay floats above the caption, so its pixels
152        // must not be reported as draggable chrome.
153        let overlay_rects: Vec<Rect> = self.overlay_manager.interactive_rects();
154
155        for root_id in self.arena.roots() {
156            // Don't descend into overlay content via its anchor parent — it
157            // is painted via the dedicated overlay loop below. Without this,
158            // the overlay content paints twice per frame.
159            if overlay_skip.contains(&root_id) {
160                continue;
161            }
162            paint_widget_cached(
163                &mut self.arena,
164                root_id,
165                &mut frame,
166                &base_theme,
167                &text_backend,
168                None,
169                &a11y_prefs,
170                paint_epoch,
171                &overlay_skip,
172                &overlay_rects,
173                self.layout_direction,
174                // Root starts enabled; the walker ANDs in each node's
175                // own `enabled_state` as it descends.
176                true,
177                // Root is at screen scale; transform scopes below
178                // multiply in their own scale.
179                1.0,
180            );
181        }
182
183        for content_id in self.overlay_manager.active_content_ids() {
184            paint_widget_cached(
185                &mut self.arena,
186                content_id,
187                &mut frame,
188                &base_theme,
189                &text_backend,
190                None,
191                &a11y_prefs,
192                paint_epoch,
193                &overlay_skip,
194                &overlay_rects,
195                self.layout_direction,
196                // Overlays detach from their anchor's enabled-state.
197                // A tooltip / popover stays enabled even if its
198                // anchor was disabled — overlays receive their own
199                // explicit enabled-state if they need one.
200                true,
201                // Overlays render at screen scale regardless of their
202                // anchor's transform scopes.
203                1.0,
204            );
205        }
206
207        // Clear `needs_paint` on every active node. Mutation during
208        // iter — fill the reusable scratch first (zero-alloc once
209        // warm), then drive the loop from the snapshot.
210        //
211        // A node still dirty whose `paint()` did not run this render was
212        // skipped by the walker: clipped out, or under a sub-perceptual
213        // opacity. Its caches predate the change that dirtied it, so drop
214        // them. Clearing the flag alone would let the node replay that
215        // stale paint once it is visible again without having moved, since
216        // nothing else would mark it.
217        self.arena.fill_active_ids(&mut self.active_ids_scratch);
218        let ids = std::mem::take(&mut self.active_ids_scratch);
219        for &id in &ids {
220            if let Some(node) = self.arena.get_mut(id) {
221                if node.dirty.needs_paint && node.paint_consumed_epoch != paint_epoch {
222                    node.cached_paint = None;
223                    node.cached_post_paint = None;
224                }
225                node.dirty.needs_paint = false;
226            }
227        }
228        self.active_ids_scratch = ids;
229
230        if has_animations {
231            frame
232                .anim_params
233                .extend_from_slice(self.animated_quads.scratch_slice());
234        }
235        frame.debug_validate_stacks();
236        let rc = std::rc::Rc::new(frame);
237        self.cached_frame = Some(std::rc::Rc::clone(&rc));
238        // Full-render path: subscribers whose owners were just painted
239        // have `last_painted_epoch == paint_epoch`. Re-arm the
240        // frame-tick chain so per-frame effects keep ticking. When all
241        // subscribers are off-screen this returns false and the chain
242        // dies — exactly the bug fix that motivated this scheduler.
243        self.arm_frame_tick_for_visible_subscribers();
244        rc
245    }
246
247    /// Walk the frame-tick subscriber set and arm
248    /// `frame_tick_armed_by_subscribers` if any subscriber's owner is
249    /// currently visible. Called from `render_with_ops` (both cache-hit and
250    /// full-render paths).
251    ///
252    /// Not `frame_tick_requested`: that flag is paced at 60 Hz, this one at
253    /// the subscribers' own interval, and `frame_tick_deadline` has to tell
254    /// them apart or a throttled subscriber slows every requested frame.
255    pub(crate) fn arm_frame_tick_for_visible_subscribers(&mut self) {
256        if self
257            .frame_tick_scheduler
258            .should_arm_frame_tick(&self.arena, self.paint_epoch)
259        {
260            self.frame_tick_armed_by_subscribers = true;
261        }
262    }
263}
264
265/// Recursive paint pass with per-widget caching.
266/// Only re-runs `paint()` for widgets with `needs_paint` set; clean widgets
267/// reuse their `cached_paint` output. The tree walk still runs for clip/child
268/// ordering, but skips the expensive `paint()` call for clean widgets.
269///
270/// `parent_effective_enabled` is the AND of every ancestor's `enabled_state`
271/// resolved value (start `true` at root). The walker ANDs this with the
272/// current node's `enabled_state` to produce `this_effective_enabled`, which
273/// it both injects into the node's `PaintContext` and forwards as the parent
274/// value to children. This is the single mechanism by which leaf widgets see
275/// the arena's enabled-state at paint time without needing to walk ancestors
276/// themselves (`PaintContext` carries no `WidgetId` or arena reference).
277///
278/// `accumulated_raster_scale` is the quantized product of every ancestor
279/// transform scope's scale (start `1.0` at root). The walker multiplies in
280/// this node's own transform scale, sets the result as the text backend's
281/// ambient raster scale around the node's paint (so text drawn here
282/// rasterizes densely enough for the GPU transform that will stretch it),
283/// stamps it on the node's paint cache, and forwards it to children.
284#[allow(clippy::too_many_arguments)]
285fn paint_widget_cached(
286    arena: &mut WidgetArena,
287    id: WidgetId,
288    frame: &mut RenderFrame,
289    base_theme: &crate::styles::Theme,
290    text_backend: &Option<Rc<RefCell<dyn teksilo_canvas::TextBackend>>>,
291    clip_bounds: Option<Rect>,
292    a11y_prefs: &A11yPaintPrefs,
293    paint_epoch: u64,
294    overlay_skip: &std::collections::HashSet<WidgetId>,
295    overlay_rects: &[Rect],
296    layout_direction: crate::environment::LayoutDirection,
297    parent_effective_enabled: bool,
298    accumulated_raster_scale: f32,
299) {
300    if !arena.is_active(id) {
301        return;
302    }
303
304    // Compute this node's effective enabled-state once: AND the
305    // ancestor-derived value with our own `enabled_state` if set.
306    // Used both for our own paint context and for the recursion into
307    // children below.
308    let this_effective_enabled = parent_effective_enabled
309        && arena
310            .get(id)
311            .and_then(|n| n.enabled_state.as_ref())
312            .is_none_or(|p| p.get());
313
314    let node = arena.get(id).expect("node id is active (guarded above)");
315    let bounds = node.bounds;
316    if let Some(clip) = clip_bounds {
317        let x0 = bounds.x.max(clip.x);
318        let y0 = bounds.y.max(clip.y);
319        let x1 = bounds.right().min(clip.right());
320        let y1 = bounds.bottom().min(clip.bottom());
321        if x1 <= x0 || y1 <= y0 {
322            // Clipped to nothing — widget is offscreen. Skip its paint
323            // AND skip stamping `last_painted_epoch`, so the animation
324            // scheduler will notice it is no longer visible and pause
325            // its looping animations.
326            return;
327        }
328    }
329
330    // Mark this widget as "painted in epoch N" regardless of whether
331    // we hit the cache-path or ran `paint()` — both outcomes mean the
332    // widget's bounds landed inside the viewport this frame, which is
333    // all the animation scheduler cares about.
334    if let Some(node_mut) = arena.get_mut(id) {
335        node_mut.last_painted_epoch = paint_epoch;
336    }
337
338    // Read the optional opacity scope before borrowing the node again
339    // for the paint/cache path. The scope wraps both this widget's own
340    // paint and its children's paint, composing with any ancestor
341    // opacity via the canvas's stacked-opacity model.
342    let node = arena.get(id).expect("node id is active (guarded above)");
343    let opacity = node.opacity_prop.as_ref().map(|p| p.get().clamp(0.0, 1.0));
344    if let Some(o) = opacity
345        && o < 1.0 / 512.0
346    {
347        // Sub-perceptual: skip the subtree entirely. Saves a draw
348        // pass when a `Fade` is fully transparent (e.g. just-dismissed
349        // tooltip waiting for cleanup) and prevents 0.0 from emitting
350        // a spurious blend pass on the GPU. We do this before any blur
351        // scope emit so the Begin/End pair stays balanced.
352        return;
353    }
354
355    // Optional blur scope is the OUTERMOST per-node scope: it captures
356    // the entire rendered subtree (including any opacity/transform scopes
357    // we're about to push) into an intermediate texture, blurs it via
358    // the renderer's dual-Kawase chain, and composites the blurred
359    // result back at the widget's bounds. Sub-perceptual radii skip the
360    // pair entirely so animated 0→target_radius patterns have zero cost
361    // when fully off.
362    let node = arena.get(id).expect("node id is active (guarded above)");
363    let blur_radius = node
364        .blur_prop
365        .as_ref()
366        .map(|p| p.get())
367        .filter(|r| *r >= 0.5);
368    let blur_bounds = arena.bounds(id);
369    if let Some(r) = blur_radius {
370        frame
371            .draw_order
372            .push(teksilo_canvas::DrawCommand::BeginBlurredSubtree {
373                bounds: blur_bounds,
374                radius: r,
375            });
376    }
377
378    if let Some(o) = opacity {
379        frame
380            .draw_order
381            .push(teksilo_canvas::DrawCommand::SetOpacity(o));
382    }
383
384    // Optional transform scope — wraps both this widget's own paint
385    // and its children's paint. The renderer composes the pushed
386    // transform onto its stack so widget-internal canvas transforms
387    // (canvas.translate / scale / rotate) compose with this wrapper
388    // transform instead of clobbering it. Skip the push entirely when
389    // the transform is identity — saves a flush on every wrapper that
390    // happens to be at its rest pose.
391    let node = arena.get(id).expect("node id is active (guarded above)");
392    let transform = node.transform_prop.as_ref().map(|p| p.get());
393    let push_transform = transform.filter(|t| *t != teksilo_canvas::Transform2D::IDENTITY);
394    let clips = node.clips_children;
395    let content_transform = node.content_transform;
396    let bounds = node.bounds;
397
398    // Accumulated raster scale for this node and its subtree: multiply
399    // the ancestors' scale by this node's own transform scale (a
400    // SceneView zoom or a `Scale` wrapper) and re-quantize. Text painted
401    // inside the scope rasterizes at this density so the GPU transform
402    // lands a ~1:1 texel-to-pixel mapping instead of stretching a 1×
403    // bitmap. Pure translations/rotations have `geometric_scale() == 1`
404    // and inherit the parent value bit-identically.
405    let this_raster_scale = match push_transform {
406        Some(t) => quantize_raster_scale(accumulated_raster_scale * t.geometric_scale()),
407        None => accumulated_raster_scale,
408    };
409    // The backend currently holds the parent's ambient scale (root
410    // callers start at 1.0; every recursion level restores on exit), so
411    // it only needs touching when this node's scale differs.
412    let raster_scale_changed = this_raster_scale != accumulated_raster_scale;
413    if raster_scale_changed && let Some(tb) = text_backend {
414        tb.borrow_mut().set_raster_scale(this_raster_scale);
415    }
416    // A *content* transform (a SceneView's pan/zoom) leaves the node's bounds a
417    // fixed parent-space viewport and only moves the content. Emit its clip
418    // BEFORE the transform so the renderer scissors to that fixed viewport
419    // (transformed by ancestors only — correct for nested scenes too) instead
420    // of the pan/zoom-shifted rect, and so the node's own paint (background
421    // grid / lightweight items) is clipped to the viewport as well. A *self*
422    // transform (Scale/Rotate) keeps its clip INSIDE the transform — there the
423    // clip is meant to be the scaled visual region.
424    let clip_outside_transform = clips && content_transform;
425    if clip_outside_transform {
426        frame
427            .draw_order
428            .push(teksilo_canvas::DrawCommand::SetClip(bounds));
429    }
430    if let Some(t) = push_transform {
431        frame
432            .draw_order
433            .push(teksilo_canvas::DrawCommand::PushTransform(t));
434    }
435
436    let node = arena.get(id).expect("node id is active (guarded above)");
437    // A clean widget's cached frames bake glyph quads at the raster
438    // scale current when they were recorded; when the ambient scale
439    // moved (a scene zoom crossed a quantization bucket), those quads
440    // sample wrong-density bitmaps — treat the node as needing paint.
441    let needs_paint = node.dirty.needs_paint || node.paint_raster_scale != this_raster_scale;
442
443    if needs_paint || node.cached_paint.is_none() {
444        let resolved_theme = arena.resolve_theme(id, base_theme);
445        let ctx = PaintContext {
446            theme: &resolved_theme,
447            scale_factor: this_raster_scale,
448            text_scale: a11y_prefs.text_scale,
449            layout_direction,
450            effective_enabled: this_effective_enabled,
451            prefers_high_contrast: a11y_prefs.high_contrast,
452            prefers_reduced_motion: a11y_prefs.reduced_motion,
453            prefers_large_text: a11y_prefs.large_text,
454            window_active: a11y_prefs.window_active,
455            clip_bounds,
456        };
457
458        let bounds = arena.bounds(id);
459        let node = arena.get(id).expect("node id is active (guarded above)");
460
461        let mut canvas = match text_backend {
462            Some(tb) => Canvas::with_text_backend(tb.clone()),
463            None => Canvas::new(),
464        };
465        // The paint about to run serves every repaint requested off the UI
466        // thread so far: take the request before reading the state.
467        if let Some(wake) = &node.repaint_wake {
468            wake.consume_repaint();
469        }
470        node.widget.paint(bounds, &mut canvas, &ctx);
471        let widget_frame = canvas.into_render_frame();
472
473        frame.merge(&widget_frame);
474        if let Some(node) = arena.get_mut(id) {
475            node.cached_paint = Some(widget_frame);
476            node.paint_raster_scale = this_raster_scale;
477            node.paint_consumed_epoch = paint_epoch;
478        }
479    } else {
480        let node = arena.get(id).expect("node id is active (guarded above)");
481        if let Some(cached) = &node.cached_paint {
482            // Refresh the text backend's glyph timestamps for every
483            // layout baked into this cached paint. Without this,
484            // widgets that stay clean for ~180 frames (e.g. static
485            // labels next to an animation) can have their atlas slots
486            // evicted and reused, and the cached UVs then sample the
487            // wrong glyph. `TextBackend::touch_layout` is a no-op for
488            // backends without a glyph cache (the mock).
489            if !cached.layout_keys.is_empty()
490                && let Some(tb) = text_backend
491            {
492                let mut tb = tb.borrow_mut();
493                for key in &cached.layout_keys {
494                    tb.touch_layout(*key);
495                }
496                #[cfg(debug_assertions)]
497                debug_validate_layout_keys(&*tb, &cached.layout_keys, Some(id));
498            }
499            frame.merge(cached);
500        }
501    }
502
503    let node = arena.get(id).expect("node id is active (guarded above)");
504    let children: Vec<WidgetId> = node.children.clone();
505    let next_clip = if clips {
506        Some(match clip_bounds {
507            Some(clip) => {
508                let x0 = bounds.x.max(clip.x);
509                let y0 = bounds.y.max(clip.y);
510                let x1 = bounds.right().min(clip.right());
511                let y1 = bounds.bottom().min(clip.bottom());
512                Rect::new(x0, y0, (x1 - x0).max(0.0), (y1 - y0).max(0.0))
513            }
514            None => bounds,
515        })
516    } else {
517        clip_bounds
518    };
519
520    // A *content*-transform node (a `SceneView`'s pan/zoom) places its children
521    // in the transformed (content) coordinate space — `place_children` writes
522    // each child's `node.bounds` in scene coords, and the pan/zoom is applied
523    // only at draw time via the `PushTransform` above. The cull clip we hand the
524    // children must therefore be in that same content space; otherwise the
525    // per-child offscreen check at the top of this fn compares scene-space child
526    // bounds against a screen-space clip and drops content that is panned into
527    // view (a card far down in scene coords reads as "outside the viewport"
528    // regardless of pan — the lightweight tier, painted in the node's own
529    // `paint()`, is unaffected, hence "connectors render but cards don't").
530    // Inverse-transform the screen-space clip into content space. The GPU
531    // `SetClip` (emitted in parent/screen space) is untouched — it stays the
532    // real viewport scissor.
533    let next_clip = match (
534        content_transform,
535        next_clip,
536        transform.and_then(|t| t.inverse()),
537    ) {
538        (true, Some(screen_clip), Some(inv)) => Some(inv.apply_rect(screen_clip)),
539        _ => next_clip,
540    };
541
542    // Self-transform / plain clipping nodes emit their clip here — after the
543    // node's own paint, inside any transform scope. Content-transform nodes
544    // already emitted theirs above (in parent space).
545    if clips && !clip_outside_transform {
546        frame
547            .draw_order
548            .push(teksilo_canvas::DrawCommand::SetClip(bounds));
549    }
550
551    for child_id in children {
552        // Skip overlay-managed content here — it is painted via the
553        // dedicated overlay loop in render_with_ops. Without this, an
554        // overlay (e.g. a tooltip) attached as a child of its anchor
555        // would paint twice per frame: once via the parent walk and
556        // once via the overlay loop.
557        if overlay_skip.contains(&child_id) {
558            continue;
559        }
560        paint_widget_cached(
561            arena,
562            child_id,
563            frame,
564            base_theme,
565            text_backend,
566            next_clip,
567            a11y_prefs,
568            paint_epoch,
569            overlay_skip,
570            overlay_rects,
571            layout_direction,
572            this_effective_enabled,
573            this_raster_scale,
574        );
575    }
576
577    // Post-order `after_paint` hook. Fires after every descendant has
578    // painted and committed its bounds, so a parent can read those
579    // bounds via `WidgetTreeView::bounds(child_id)`. Gated on
580    // `wants_after_paint()` to avoid a virtual call per widget per
581    // frame for the 99% of widgets that don't aggregate. The arena
582    // mutable borrow from the recursive child loop has dropped by
583    // this point, so an immutable reborrow is safe.
584    {
585        let arena_ref: &WidgetArena = &*arena;
586        if let Some(node) = arena_ref.get(id)
587            && node.widget.wants_after_paint()
588        {
589            let view = crate::widget::WidgetTreeView::new(arena_ref, overlay_rects);
590            let resolved_theme = arena_ref.resolve_theme(id, base_theme);
591            let ctx = PaintContext {
592                theme: &resolved_theme,
593                scale_factor: this_raster_scale,
594                text_scale: a11y_prefs.text_scale,
595                layout_direction,
596                effective_enabled: this_effective_enabled,
597                prefers_high_contrast: a11y_prefs.high_contrast,
598                prefers_reduced_motion: a11y_prefs.reduced_motion,
599                prefers_large_text: a11y_prefs.large_text,
600                window_active: a11y_prefs.window_active,
601                clip_bounds,
602            };
603            node.widget.after_paint(&view, &ctx);
604        }
605    }
606
607    // Foreground pass — `post_paint` emits *after* the whole child
608    // subtree, so its draws land on top of this node's descendants. Still
609    // inside this node's clip / transform / opacity / blur scopes (their
610    // closers come below), so a foreground decoration pans, scales and
611    // clips consistently with the subtree it covers. Same `needs_paint`
612    // cache gate as the main paint above, with its own `cached_post_paint`
613    // frame. Gated on `wants_post_paint` so non-foreground widgets pay
614    // nothing.
615    let wants_post_paint = arena
616        .get(id)
617        .map(|n| n.widget.wants_post_paint())
618        .unwrap_or(false);
619    if wants_post_paint {
620        let has_post_cache = arena
621            .get(id)
622            .map(|n| n.cached_post_paint.is_some())
623            .unwrap_or(false);
624        if needs_paint || !has_post_cache {
625            // Children restored the backend to this node's ambient scale
626            // on their way out; re-assert defensively so post_paint text
627            // (e.g. a SceneView's foreground lightweight items) can't
628            // bake at a child-leaked scale.
629            if raster_scale_changed && let Some(tb) = text_backend {
630                tb.borrow_mut().set_raster_scale(this_raster_scale);
631            }
632            let resolved_theme = arena.resolve_theme(id, base_theme);
633            let ctx = PaintContext {
634                theme: &resolved_theme,
635                scale_factor: this_raster_scale,
636                text_scale: a11y_prefs.text_scale,
637                layout_direction,
638                effective_enabled: this_effective_enabled,
639                prefers_high_contrast: a11y_prefs.high_contrast,
640                prefers_reduced_motion: a11y_prefs.reduced_motion,
641                prefers_large_text: a11y_prefs.large_text,
642                window_active: a11y_prefs.window_active,
643                clip_bounds,
644            };
645            let bounds = arena.bounds(id);
646            let node = arena.get(id).expect("node id is active (guarded above)");
647            let mut canvas = match text_backend {
648                Some(tb) => Canvas::with_text_backend(tb.clone()),
649                None => Canvas::new(),
650            };
651            node.widget.post_paint(bounds, &mut canvas, &ctx);
652            let post_frame = canvas.into_render_frame();
653            frame.merge(&post_frame);
654            if let Some(node) = arena.get_mut(id) {
655                node.cached_post_paint = Some(post_frame);
656            }
657        } else if let Some(cached) = arena.get(id).and_then(|n| n.cached_post_paint.as_ref()) {
658            if !cached.layout_keys.is_empty()
659                && let Some(tb) = text_backend
660            {
661                let mut tb = tb.borrow_mut();
662                for key in &cached.layout_keys {
663                    tb.touch_layout(*key);
664                }
665                #[cfg(debug_assertions)]
666                debug_validate_layout_keys(&*tb, &cached.layout_keys, Some(id));
667            }
668            frame.merge(cached);
669        }
670    }
671
672    if clips && !clip_outside_transform {
673        frame
674            .draw_order
675            .push(teksilo_canvas::DrawCommand::ClearClip);
676    }
677
678    if push_transform.is_some() {
679        frame
680            .draw_order
681            .push(teksilo_canvas::DrawCommand::PopTransform);
682    }
683
684    // A content-transform clip opened before the transform, so it closes after
685    // the transform pops (clip { transform { … } } nesting).
686    if clip_outside_transform {
687        frame
688            .draw_order
689            .push(teksilo_canvas::DrawCommand::ClearClip);
690    }
691
692    if opacity.is_some() {
693        frame
694            .draw_order
695            .push(teksilo_canvas::DrawCommand::RestoreOpacity);
696    }
697
698    if blur_radius.is_some() {
699        frame
700            .draw_order
701            .push(teksilo_canvas::DrawCommand::EndBlurredSubtree);
702    }
703
704    // Unwind the ambient raster scale to the parent's value so siblings
705    // painted after this subtree see their own ancestor scale.
706    if raster_scale_changed && let Some(tb) = text_backend {
707        tb.borrow_mut().set_raster_scale(accumulated_raster_scale);
708    }
709}
710
711/// Debug-build corruption catcher: before a retained paint frame is
712/// replayed, verify that every layout baked into it still matches the
713/// live glyph atlas.
714///
715/// A `RectMismatch` means the frame's glyph quads reference atlas pixels
716/// that were evicted and reused — replaying them draws the wrong
717/// characters (the historical "random text corruption fixed by a
718/// repaint" bug). That is always a missing cache-invalidation path, so
719/// abort with a diagnostic. A `StaleKey` (backend forgot the layout
720/// entirely — its caches were cleared after this frame was baked) is the
721/// same bug class but can transiently occur around legitimate wholesale
722/// clears, so it logs loudly (once per key) instead of aborting.
723#[cfg(debug_assertions)]
724fn debug_validate_layout_keys(
725    tb: &dyn teksilo_canvas::TextBackend,
726    layout_keys: &[u64],
727    widget: Option<WidgetId>,
728) {
729    use teksilo_canvas::GlyphValidation;
730    std::thread_local! {
731        static REPORTED_STALE_KEYS: std::cell::RefCell<std::collections::HashSet<u64>> =
732            std::cell::RefCell::new(std::collections::HashSet::new());
733    }
734    for key in layout_keys {
735        match tb.debug_validate_layout(*key) {
736            GlyphValidation::Valid => {}
737            GlyphValidation::StaleKey => {
738                let first_report = REPORTED_STALE_KEYS.with(|set| set.borrow_mut().insert(*key));
739                if first_report {
740                    eprintln!(
741                        "[teksilo] WARNING: retained paint cache replays layout_key={key} \
742                         (widget={widget:?}) that the text backend no longer knows — the \
743                         frame survived a backend cache clear, so a paint-cache \
744                         invalidation path is likely missing."
745                    );
746                }
747            }
748            GlyphValidation::RectMismatch => {
749                panic!(
750                    "stale glyph UVs in retained paint cache (layout_key={key}, \
751                     widget={widget:?}): cached quads no longer match the live glyph \
752                     atlas — a cache-invalidation path is missing"
753                );
754            }
755        }
756    }
757}
758
759#[cfg(test)]
760mod tests {
761    use super::*;
762    use crate::test_widgets::{FillWidget, StackWidget};
763    use teksilo_tokens::{Color, CornerRadius};
764
765    /// Headless text backend that records every `touch_layout` call and
766    /// hands out layouts with a fixed non-zero `layout_key`, so tests
767    /// can assert that paint-cache reuse keeps glyph timestamps fresh.
768    /// Also tracks the ambient raster scale (`set_raster_scale`) and the
769    /// scale every `layout_single_line` call ran under, so walker tests
770    /// can assert the set/restore discipline around transform scopes.
771    #[derive(Default)]
772    struct RecordingTextBackend {
773        touched: std::rc::Rc<std::cell::RefCell<Vec<u64>>>,
774        raster_scale: f32,
775        /// Ambient raster scale observed by each `layout_single_line`
776        /// call, in call order.
777        layout_scales: std::rc::Rc<std::cell::RefCell<Vec<f32>>>,
778    }
779
780    impl RecordingTextBackend {
781        fn new(touched: std::rc::Rc<std::cell::RefCell<Vec<u64>>>) -> Self {
782            Self {
783                touched,
784                raster_scale: 1.0,
785                layout_scales: Default::default(),
786            }
787        }
788    }
789
790    impl teksilo_canvas::TextBackend for RecordingTextBackend {
791        fn set_raster_scale(&mut self, raster_scale: f32) {
792            self.raster_scale = raster_scale;
793        }
794
795        fn raster_scale(&self) -> f32 {
796            self.raster_scale
797        }
798
799        fn layout_single_line(
800            &mut self,
801            text: &str,
802            _style: &teksilo_tokens::TextStyle,
803            _max_width: Option<f32>,
804        ) -> teksilo_canvas::TextLayout {
805            self.layout_scales.borrow_mut().push(self.raster_scale);
806            teksilo_canvas::TextLayout {
807                width: text.len() as f32 * 8.0,
808                height: 16.0,
809                ascent: 12.0,
810                descent: 4.0,
811                underline_offset: 1.0,
812                underline_thickness: 1.0,
813                layout_key: 42,
814                line_count: 1,
815                spans: Vec::new(),
816                raster_scale: self.raster_scale,
817                geometry: None,
818            }
819        }
820
821        fn ensure_glyphs(
822            &mut self,
823            _layout: &teksilo_canvas::TextLayout,
824        ) -> Vec<teksilo_canvas::GlyphQuad> {
825            Vec::new()
826        }
827
828        fn touch_layout(&mut self, layout_key: u64) {
829            self.touched.borrow_mut().push(layout_key);
830        }
831    }
832
833    /// Leaf widget that draws one line of text so its emitted frame
834    /// carries a `layout_key`.
835    #[derive(Debug)]
836    struct TextPaintWidget;
837
838    impl Widget for TextPaintWidget {
839        fn layout_response(
840            &self,
841            proposal: SizeProposal,
842            _ctx: &LayoutContext,
843        ) -> crate::widget::LayoutResponse {
844            proposal.resolve(100.0, 20.0).into()
845        }
846
847        fn paint(
848            &self,
849            bounds: teksilo_canvas::Rect,
850            canvas: &mut teksilo_canvas::Canvas,
851            _ctx: &PaintContext,
852        ) {
853            let style = teksilo_tokens::TextStyle::default();
854            let _ = canvas.draw_text("hello", bounds, &style, Color::BLACK);
855        }
856    }
857
858    #[test]
859    fn full_frame_cache_hit_touches_layout_keys() {
860        let touched = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
861        let backend = RecordingTextBackend::new(touched.clone());
862        let mut tree = WidgetTree::new()
863            .with_theme(crate::presets::intui::light())
864            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(backend)));
865        tree.add(TextPaintWidget);
866        tree.layout(SizeProposal::exact(200.0, 50.0));
867
868        // First render paints for real; no cache reuse yet.
869        let _ = tree.render();
870        touched.borrow_mut().clear();
871
872        // Nothing is dirty: this render takes the full-frame early-out.
873        // The reused frame's layout keys must still be touched, or the
874        // backend's LRU ages out glyphs that are on screen.
875        let _ = tree.render();
876        assert!(
877            touched.borrow().contains(&42),
878            "full-frame cache hit must touch_layout every baked layout key; \
879             touched = {:?}",
880            touched.borrow()
881        );
882    }
883
884    /// Shared fixture: a root stack with a text leaf before, inside, and
885    /// after a transform-scoped wrapper:
886    ///
887    /// ```text
888    /// root Stack
889    /// ├── TextPaintWidget          (A, screen scale)
890    /// ├── Stack [transform]        (wrapper)
891    /// │   └── TextPaintWidget      (B, scaled)
892    /// └── TextPaintWidget          (C, screen scale)
893    /// ```
894    fn scaled_subtree_tree(
895        transform: impl Into<crate::signal::Prop<teksilo_canvas::Transform2D>>,
896    ) -> (
897        WidgetTree,
898        std::rc::Rc<std::cell::RefCell<RecordingTextBackend>>,
899        std::rc::Rc<std::cell::RefCell<Vec<f32>>>,
900    ) {
901        let backend = RecordingTextBackend::new(Default::default());
902        let layout_scales = backend.layout_scales.clone();
903        let backend_rc = std::rc::Rc::new(std::cell::RefCell::new(backend));
904        let mut tree = WidgetTree::new()
905            .with_theme(crate::presets::intui::light())
906            .with_text_backend(backend_rc.clone()
907                as std::rc::Rc<std::cell::RefCell<dyn teksilo_canvas::TextBackend>>);
908
909        let root = tree.add(StackWidget::new());
910        tree.add_child(root, TextPaintWidget); // A
911        let wrapper = tree.add_child(root, StackWidget::new());
912        tree.add_child(wrapper, TextPaintWidget); // B
913        tree.add_child(root, TextPaintWidget); // C
914        tree.set_transform(wrapper, transform);
915
916        tree.layout(SizeProposal::exact(400.0, 300.0));
917        (tree, backend_rc, layout_scales)
918    }
919
920    #[test]
921    fn walker_sets_raster_scale_for_scaled_subtree_and_restores() {
922        let (mut tree, backend_rc, layout_scales) =
923            scaled_subtree_tree(teksilo_canvas::Transform2D::scale(2.0, 2.0));
924        let _ = tree.render();
925
926        // Paint order is child order: A (screen), B (inside the 2x
927        // scope, quantized onto the 1.25^n ladder), C (screen again —
928        // the wrapper restored the ambient scale on exit).
929        let expected_b = 1.25_f32.powi(3); // quantize(2.0)
930        assert_eq!(
931            layout_scales.borrow().as_slice(),
932            &[1.0, expected_b, 1.0],
933            "text inside the transform scope must lay out at the quantized \
934             scale; siblings before/after at screen scale"
935        );
936        // The walk unwound to the root ambient scale.
937        assert_eq!(backend_rc.borrow().raster_scale, 1.0);
938    }
939
940    #[test]
941    fn raster_scale_change_repaints_clean_descendants() {
942        let transform = crate::signal::Signal::new(teksilo_canvas::Transform2D::scale(2.0, 2.0));
943        let (mut tree, _backend_rc, layout_scales) = scaled_subtree_tree(transform.clone());
944        let _ = tree.render();
945        layout_scales.borrow_mut().clear();
946
947        // Zoom changes: only the wrapper node is dirtied (RepaintOnly
948        // binding); its text child B stays clean — the raster-scale
949        // stamp on B's paint cache must force the repaint anyway.
950        transform.set(teksilo_canvas::Transform2D::scale(3.0, 3.0));
951        let _ = tree.render();
952        assert_eq!(
953            layout_scales.borrow().as_slice(),
954            &[1.25_f32.powi(5)],
955            "the clean text leaf inside the scope must re-rasterize at the \
956             new quantized scale (A and C stay cached: no layout calls)"
957        );
958
959        // And back down to identity: B re-bakes once more at 1.0.
960        layout_scales.borrow_mut().clear();
961        transform.set(teksilo_canvas::Transform2D::IDENTITY);
962        let _ = tree.render();
963        assert_eq!(layout_scales.borrow().as_slice(), &[1.0]);
964
965        // Steady state: nothing dirty, no scale movement → full-frame
966        // cache hit, zero layout calls.
967        layout_scales.borrow_mut().clear();
968        let _ = tree.render();
969        assert!(layout_scales.borrow().is_empty());
970    }
971
972    #[test]
973    fn nested_scale_transforms_multiply() {
974        let backend = RecordingTextBackend::new(Default::default());
975        let layout_scales = backend.layout_scales.clone();
976        let mut tree = WidgetTree::new()
977            .with_theme(crate::presets::intui::light())
978            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(backend)));
979
980        // outer [2x] → inner [1.5x] → text. Accumulation:
981        // quantize(2.0) = 1.25^3, then quantize(1.25^3 × 1.5) = 1.25^5.
982        let outer = tree.add(StackWidget::new());
983        let inner = tree.add_child(outer, StackWidget::new());
984        tree.add_child(inner, TextPaintWidget);
985        tree.set_transform(outer, teksilo_canvas::Transform2D::scale(2.0, 2.0));
986        tree.set_content_transform(inner, teksilo_canvas::Transform2D::scale(1.5, 1.5));
987
988        tree.layout(SizeProposal::exact(400.0, 300.0));
989        let _ = tree.render();
990        assert_eq!(layout_scales.borrow().as_slice(), &[1.25_f32.powi(5)]);
991    }
992
993    #[test]
994    fn invalidate_all_paints_clears_post_paint_cache() {
995        /// Widget that draws chrome in the foreground pass so the walker
996        /// populates `cached_post_paint`.
997        #[derive(Debug)]
998        struct PostPaintWidget;
999
1000        impl Widget for PostPaintWidget {
1001            fn layout_response(
1002                &self,
1003                proposal: SizeProposal,
1004                _ctx: &LayoutContext,
1005            ) -> crate::widget::LayoutResponse {
1006                proposal.resolve(50.0, 50.0).into()
1007            }
1008
1009            fn wants_post_paint(&self) -> bool {
1010                true
1011            }
1012
1013            fn post_paint(
1014                &self,
1015                bounds: teksilo_canvas::Rect,
1016                canvas: &mut teksilo_canvas::Canvas,
1017                _ctx: &PaintContext,
1018            ) {
1019                canvas.fill_rect(bounds, Color::RED);
1020            }
1021        }
1022
1023        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1024        let id = tree.add(PostPaintWidget);
1025        tree.layout(SizeProposal::exact(100.0, 100.0));
1026        let _ = tree.render();
1027        assert!(
1028            tree.arena
1029                .get(id)
1030                .and_then(|n| n.cached_post_paint.as_ref())
1031                .is_some(),
1032            "render must populate cached_post_paint for a post-painting widget"
1033        );
1034
1035        tree.invalidate_all_paints();
1036        assert!(
1037            tree.arena
1038                .get(id)
1039                .and_then(|n| n.cached_post_paint.as_ref())
1040                .is_none(),
1041            "invalidate_all_paints must clear cached_post_paint too — a retained \
1042             post-paint frame can hold stale glyph UVs after atlas eviction"
1043        );
1044    }
1045
1046    #[derive(Debug)]
1047    struct ThemeAwareWidget;
1048
1049    impl Widget for ThemeAwareWidget {
1050        fn layout_response(
1051            &self,
1052            proposal: SizeProposal,
1053            _ctx: &LayoutContext,
1054        ) -> crate::widget::LayoutResponse {
1055            proposal.resolve(0.0, 0.0).into()
1056        }
1057
1058        fn paint(
1059            &self,
1060            bounds: teksilo_canvas::Rect,
1061            canvas: &mut teksilo_canvas::Canvas,
1062            ctx: &PaintContext,
1063        ) {
1064            canvas.fill_rounded_rect(
1065                bounds,
1066                teksilo_tokens::CornerRadius::uniform(4.0),
1067                ctx.theme.colors.accent,
1068            );
1069        }
1070    }
1071
1072    #[test]
1073    fn fill_widget_produces_shape_in_frame() {
1074        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1075        tree.add(
1076            FillWidget::new()
1077                .background(Color::RED)
1078                .corner_radius(CornerRadius::uniform(6.0)),
1079        );
1080        tree.layout(SizeProposal::exact(100.0, 40.0));
1081        let frame = tree.render();
1082        assert_eq!(frame.shapes.len(), 1);
1083        assert_eq!(
1084            frame.shapes[0].shape,
1085            teksilo_canvas::ShapeKind::RoundedRect
1086        );
1087    }
1088
1089    #[test]
1090    fn empty_tree_renders_empty_frame() {
1091        let mut tree = WidgetTree::new();
1092        let frame = tree.render();
1093        assert!(frame.is_empty());
1094    }
1095
1096    #[test]
1097    fn render_clears_paint_dirty() {
1098        let mut tree = WidgetTree::new();
1099        tree.add(FillWidget::new().background(Color::RED));
1100        tree.layout(SizeProposal::exact(100.0, 50.0));
1101        assert!(tree.needs_paint());
1102        tree.render();
1103        assert!(!tree.needs_paint());
1104    }
1105
1106    #[test]
1107    fn dormant_widget_not_rendered() {
1108        let mut tree = WidgetTree::new();
1109        let widget = tree.add(
1110            FillWidget::new()
1111                .background(Color::RED)
1112                .corner_radius(CornerRadius::uniform(4.0)),
1113        );
1114        tree.layout(SizeProposal::exact(100.0, 50.0));
1115        let frame = tree.render();
1116        assert!(!frame.shapes.is_empty());
1117
1118        tree.set_dormant(widget);
1119        tree.layout(SizeProposal::exact(100.0, 50.0));
1120        let frame = tree.render();
1121        assert!(frame.shapes.is_empty());
1122    }
1123
1124    #[test]
1125    fn dormancy_is_recursive() {
1126        let mut tree = WidgetTree::new();
1127        let child = tree.add(
1128            FillWidget::new()
1129                .background(Color::RED)
1130                .corner_radius(CornerRadius::uniform(4.0)),
1131        );
1132        let parent = tree.add(StackWidget::new().child(child));
1133        tree.layout(SizeProposal::exact(100.0, 50.0));
1134
1135        let frame = tree.render();
1136        assert_eq!(frame.shapes.len(), 1);
1137
1138        tree.set_dormant(parent);
1139        tree.layout(SizeProposal::exact(100.0, 50.0));
1140        let frame = tree.render();
1141        assert!(frame.shapes.is_empty());
1142
1143        tree.activate(parent);
1144        tree.layout(SizeProposal::exact(100.0, 50.0));
1145        let frame = tree.render();
1146        assert_eq!(frame.shapes.len(), 1);
1147    }
1148
1149    #[test]
1150    fn set_theme_marks_all_dirty() {
1151        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1152        tree.add(FillWidget::new().background(Color::RED));
1153        tree.layout(SizeProposal::exact(100.0, 50.0));
1154        tree.render();
1155
1156        assert!(!tree.needs_layout());
1157        assert!(!tree.needs_paint());
1158
1159        tree.set_theme(crate::presets::intui::dark());
1160        assert!(tree.needs_layout());
1161        assert!(tree.needs_paint());
1162    }
1163
1164    #[test]
1165    fn set_theme_changes_rendered_colors() {
1166        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1167        tree.add(ThemeAwareWidget);
1168        tree.layout(SizeProposal::exact(100.0, 50.0));
1169        let light_frame = tree.render();
1170        let light_color = light_frame.shapes[0].color;
1171
1172        tree.set_theme(crate::presets::intui::dark());
1173        tree.layout(SizeProposal::exact(100.0, 50.0));
1174        let dark_frame = tree.render();
1175        let dark_color = dark_frame.shapes[0].color;
1176
1177        assert_ne!(light_color, dark_color);
1178    }
1179
1180    #[test]
1181    fn subtree_theme_override() {
1182        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1183        let parent = tree.add(ThemeAwareWidget);
1184        let _child = tree.add_child(parent, ThemeAwareWidget);
1185
1186        tree.set_theme_override(parent, |theme| {
1187            theme.colors = teksilo_tokens::ColorTokens::dark_default();
1188        });
1189
1190        tree.layout(SizeProposal::exact(100.0, 50.0));
1191        let frame = tree.render();
1192
1193        let dark_accent = teksilo_tokens::ColorTokens::dark_default()
1194            .accent
1195            .to_array();
1196        assert_eq!(frame.shapes[0].color, dark_accent);
1197        assert_eq!(frame.shapes[1].color, dark_accent);
1198    }
1199
1200    #[test]
1201    fn theme_override_only_affects_subtree() {
1202        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1203
1204        let _unaffected = tree.add(ThemeAwareWidget);
1205        let overridden = tree.add(ThemeAwareWidget);
1206
1207        tree.set_theme_override(overridden, |theme| {
1208            theme.colors = teksilo_tokens::ColorTokens::dark_default();
1209        });
1210
1211        tree.layout(SizeProposal::exact(100.0, 50.0));
1212        let frame = tree.render();
1213
1214        let light_accent = teksilo_tokens::ColorTokens::light_default()
1215            .accent
1216            .to_array();
1217        let dark_accent = teksilo_tokens::ColorTokens::dark_default()
1218            .accent
1219            .to_array();
1220
1221        assert_eq!(frame.shapes[0].color, light_accent);
1222        assert_eq!(frame.shapes[1].color, dark_accent);
1223    }
1224
1225    #[test]
1226    fn resolved_theme_reflects_overrides() {
1227        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1228
1229        let parent = tree.add(FillWidget::new());
1230        let child = tree.add_child(parent, FillWidget::new());
1231
1232        tree.set_theme_override(parent, |theme| {
1233            theme.colors.accent = Color::RED;
1234        });
1235
1236        tree.layout(SizeProposal::exact(100.0, 50.0));
1237
1238        let parent_theme = tree.resolved_theme(parent);
1239        assert_eq!(parent_theme.colors.accent, Color::RED);
1240
1241        let child_theme = tree.resolved_theme(child);
1242        assert_eq!(child_theme.colors.accent, Color::RED);
1243    }
1244
1245    #[test]
1246    fn opacity_prop_emits_set_and_restore_around_subtree() {
1247        // A widget with opacity_prop = Some(Static(0.5)) wraps its
1248        // own paint AND its children's paint inside a SetOpacity /
1249        // RestoreOpacity pair, so the canvas's stacked-opacity model
1250        // multiplies through.
1251        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1252        let parent = tree.add(StackWidget::new());
1253        let _child = tree.add_child(parent, FillWidget::new().background(Color::RED));
1254        tree.set_opacity(parent, 0.5_f32);
1255        tree.layout(SizeProposal::exact(100.0, 50.0));
1256        let frame = tree.render();
1257
1258        let mut set_count = 0;
1259        let mut restore_count = 0;
1260        for cmd in &frame.draw_order {
1261            match cmd {
1262                teksilo_canvas::DrawCommand::SetOpacity(v) => {
1263                    assert!((v - 0.5).abs() < 1e-6, "expected 0.5, got {}", v);
1264                    set_count += 1;
1265                }
1266                teksilo_canvas::DrawCommand::RestoreOpacity => restore_count += 1,
1267                _ => {}
1268            }
1269        }
1270        assert_eq!(set_count, 1, "draw_order = {:?}", frame.draw_order);
1271        assert_eq!(restore_count, 1);
1272    }
1273
1274    #[test]
1275    fn opacity_prop_zero_skips_subtree_entirely() {
1276        // Sub-perceptual opacity (< 1/512) returns early — no
1277        // SetOpacity, no children draw commands. Saves a blend pass.
1278        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1279        let parent = tree.add(StackWidget::new());
1280        tree.add_child(parent, FillWidget::new().background(Color::RED));
1281        tree.set_opacity(parent, 0.0_f32);
1282        tree.layout(SizeProposal::exact(100.0, 50.0));
1283        let frame = tree.render();
1284
1285        for cmd in &frame.draw_order {
1286            assert!(
1287                !matches!(cmd, teksilo_canvas::DrawCommand::SetOpacity(_)),
1288                "fully-transparent subtree should not emit SetOpacity"
1289            );
1290        }
1291        // The red FillWidget child must not appear either.
1292        assert!(
1293            !frame
1294                .shapes
1295                .iter()
1296                .any(|s| s.color == Color::RED.to_array()),
1297            "fully-transparent subtree should not paint its descendants"
1298        );
1299    }
1300
1301    #[test]
1302    fn transform_prop_emits_push_and_pop_around_subtree() {
1303        // A widget with transform_prop = Some(Static(scale(2))) wraps
1304        // both its own paint AND its children's paint inside a
1305        // PushTransform / PopTransform pair, mirroring the opacity
1306        // pattern.
1307        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1308        let parent = tree.add(StackWidget::new());
1309        let _child = tree.add_child(parent, FillWidget::new().background(Color::RED));
1310        let scale_2x = teksilo_canvas::Transform2D::scale(2.0, 2.0);
1311        tree.set_transform(parent, scale_2x);
1312        tree.layout(SizeProposal::exact(100.0, 50.0));
1313        let frame = tree.render();
1314
1315        let mut push_count = 0;
1316        let mut pop_count = 0;
1317        let mut push_value = None;
1318        for cmd in &frame.draw_order {
1319            match cmd {
1320                teksilo_canvas::DrawCommand::PushTransform(t) => {
1321                    push_count += 1;
1322                    push_value = Some(*t);
1323                }
1324                teksilo_canvas::DrawCommand::PopTransform => pop_count += 1,
1325                _ => {}
1326            }
1327        }
1328        assert_eq!(push_count, 1, "draw_order = {:?}", frame.draw_order);
1329        assert_eq!(pop_count, 1);
1330        assert_eq!(push_value, Some(scale_2x));
1331    }
1332
1333    #[test]
1334    fn content_transform_clip_wraps_outside_the_transform() {
1335        // A *content* transform (the SceneView pattern: clips_children + a
1336        // content transform set via set_content_transform) must emit its clip
1337        // OUTSIDE the transform — SetClip before PushTransform, ClearClip after
1338        // PopTransform — so the renderer scissors to the fixed parent-space
1339        // viewport instead of the pan/zoom-shifted rect. (A *self* transform
1340        // like Scale keeps the clip inside the transform; see
1341        // transform_prop_emits_push_and_pop_around_subtree.)
1342        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1343        let parent = tree.add(StackWidget::new());
1344        tree.add_child(parent, FillWidget::new().background(Color::RED));
1345        tree.layout(SizeProposal::exact(100.0, 50.0));
1346        // Set after layout so no rebuild can clear the flags before render.
1347        tree.set_clips_children(parent, true);
1348        tree.set_content_transform(parent, teksilo_canvas::Transform2D::translate(50.0, 30.0));
1349        let frame = tree.render();
1350
1351        let mut set_clip = None;
1352        let mut push = None;
1353        let mut pop = None;
1354        let mut clear = None;
1355        for (i, cmd) in frame.draw_order.iter().enumerate() {
1356            match cmd {
1357                teksilo_canvas::DrawCommand::SetClip(_) => set_clip = set_clip.or(Some(i)),
1358                teksilo_canvas::DrawCommand::PushTransform(_) => push = push.or(Some(i)),
1359                teksilo_canvas::DrawCommand::PopTransform => pop = Some(i),
1360                teksilo_canvas::DrawCommand::ClearClip => clear = Some(i),
1361                _ => {}
1362            }
1363        }
1364        let (sc, pt, pop, cc) = (
1365            set_clip.expect("SetClip emitted"),
1366            push.expect("PushTransform emitted"),
1367            pop.expect("PopTransform emitted"),
1368            clear.expect("ClearClip emitted"),
1369        );
1370        assert!(
1371            sc < pt,
1372            "clip must open before the transform: SetClip@{sc}, PushTransform@{pt}; order={:?}",
1373            frame.draw_order
1374        );
1375        assert!(
1376            pop < cc,
1377            "clip must close after the transform: PopTransform@{pop}, ClearClip@{cc}"
1378        );
1379    }
1380
1381    #[test]
1382    fn identity_transform_prop_skipped() {
1383        // transform_prop = Some(Static(IDENTITY)) is a no-op — the
1384        // walker should NOT emit a PushTransform / PopTransform pair
1385        // for the rest pose. Saves a flush per identity wrapper per
1386        // frame (Scale at full visibility, Rotate at angle=0).
1387        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1388        let parent = tree.add(StackWidget::new());
1389        tree.add_child(parent, FillWidget::new().background(Color::RED));
1390        tree.set_transform(parent, teksilo_canvas::Transform2D::IDENTITY);
1391        tree.layout(SizeProposal::exact(100.0, 50.0));
1392        let frame = tree.render();
1393
1394        for cmd in &frame.draw_order {
1395            assert!(
1396                !matches!(
1397                    cmd,
1398                    teksilo_canvas::DrawCommand::PushTransform(_)
1399                        | teksilo_canvas::DrawCommand::PopTransform
1400                ),
1401                "identity transform must not emit a push/pop scope"
1402            );
1403        }
1404    }
1405
1406    #[test]
1407    fn transform_scope_paint_order_opacity_outer_transform_inner() {
1408        // When both opacity_prop AND transform_prop are set on the
1409        // same node, the framework's contract is opacity OUTER and
1410        // transform INNER. This pins down the order so future
1411        // refactors don't silently flip composability.
1412        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1413        let parent = tree.add(StackWidget::new());
1414        tree.add_child(parent, FillWidget::new().background(Color::RED));
1415        tree.set_opacity(parent, 0.7_f32);
1416        tree.set_transform(parent, teksilo_canvas::Transform2D::scale(2.0, 2.0));
1417        tree.layout(SizeProposal::exact(100.0, 50.0));
1418        let frame = tree.render();
1419
1420        // Find the indices of each command kind.
1421        let mut set_opacity_idx = None;
1422        let mut push_transform_idx = None;
1423        let mut pop_transform_idx = None;
1424        let mut restore_opacity_idx = None;
1425        for (i, cmd) in frame.draw_order.iter().enumerate() {
1426            match cmd {
1427                teksilo_canvas::DrawCommand::SetOpacity(_) => set_opacity_idx = Some(i),
1428                teksilo_canvas::DrawCommand::PushTransform(_) => push_transform_idx = Some(i),
1429                teksilo_canvas::DrawCommand::PopTransform => pop_transform_idx = Some(i),
1430                teksilo_canvas::DrawCommand::RestoreOpacity => restore_opacity_idx = Some(i),
1431                _ => {}
1432            }
1433        }
1434        let so = set_opacity_idx.expect("SetOpacity emitted");
1435        let pt = push_transform_idx.expect("PushTransform emitted");
1436        let popt = pop_transform_idx.expect("PopTransform emitted");
1437        let ro = restore_opacity_idx.expect("RestoreOpacity emitted");
1438        assert!(so < pt, "opacity must open before transform: {so} < {pt}");
1439        assert!(pt < popt, "transform push must precede its pop");
1440        assert!(
1441            popt < ro,
1442            "transform must close before opacity: {popt} < {ro}"
1443        );
1444    }
1445
1446    /// A composing widget that paints a RED backdrop in `paint()`, hosts a
1447    /// GREEN child, and paints a BLUE foreground in `post_paint()`. Pins the
1448    /// P-C-AP draw order: backdrop (P) → child (C) → foreground (AP).
1449    #[derive(Debug)]
1450    struct Sandwich {
1451        child: Option<crate::WidgetId>,
1452    }
1453
1454    impl Widget for Sandwich {
1455        fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<crate::WidgetId> {
1456            let c = ctx.add(FillWidget::new().background(Color::GREEN));
1457            self.child = Some(c);
1458            vec![c]
1459        }
1460
1461        fn layout_response(
1462            &self,
1463            proposal: SizeProposal,
1464            _ctx: &LayoutContext,
1465        ) -> crate::widget::LayoutResponse {
1466            proposal.resolve(0.0, 0.0).into()
1467        }
1468
1469        fn place_children(
1470            &self,
1471            bounds: teksilo_canvas::Rect,
1472            _proposal: SizeProposal,
1473            children: &mut [crate::widget::WidgetPlacement],
1474            _ctx: &LayoutContext,
1475        ) {
1476            for child in children.iter_mut() {
1477                child.origin = bounds.origin();
1478                child.size = bounds.size();
1479            }
1480        }
1481
1482        fn children(&self) -> Vec<crate::WidgetId> {
1483            self.child.iter().copied().collect()
1484        }
1485
1486        fn paint(
1487            &self,
1488            bounds: teksilo_canvas::Rect,
1489            canvas: &mut teksilo_canvas::Canvas,
1490            _ctx: &PaintContext,
1491        ) {
1492            canvas.fill_rounded_rect(bounds, CornerRadius::uniform(0.0), Color::RED);
1493        }
1494
1495        fn wants_post_paint(&self) -> bool {
1496            true
1497        }
1498
1499        fn post_paint(
1500            &self,
1501            bounds: teksilo_canvas::Rect,
1502            canvas: &mut teksilo_canvas::Canvas,
1503            _ctx: &PaintContext,
1504        ) {
1505            canvas.fill_rounded_rect(bounds, CornerRadius::uniform(0.0), Color::BLUE);
1506        }
1507    }
1508
1509    #[test]
1510    fn post_paint_emits_after_children() {
1511        // P-C-AP ordering: a composing widget's own paint() is a backdrop
1512        // (before children) and post_paint() is a foreground (after the whole
1513        // child subtree). Backdrop=RED, child=GREEN, foreground=BLUE; assert
1514        // RED < GREEN < BLUE in draw_order.
1515        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1516        tree.add(Sandwich { child: None });
1517        tree.layout(SizeProposal::exact(100.0, 50.0));
1518        let frame = tree.render();
1519
1520        let color_of = |cmd: &teksilo_canvas::DrawCommand| -> Option<[f32; 4]> {
1521            match cmd {
1522                teksilo_canvas::DrawCommand::Shape(i) => frame.shapes.get(*i).map(|s| s.color),
1523                teksilo_canvas::DrawCommand::Decoration(i) => {
1524                    frame.decorations.get(*i).map(|d| d.color)
1525                }
1526                _ => None,
1527            }
1528        };
1529        // Find the first draw whose color is dominated by `dominant` channel.
1530        let find = |dominant: usize| -> Option<usize> {
1531            frame.draw_order.iter().position(|cmd| {
1532                color_of(cmd).is_some_and(|c| {
1533                    c[dominant] > 0.5 && (0..3).all(|ch| ch == dominant || c[ch] < 0.5)
1534                })
1535            })
1536        };
1537        let red = find(0).expect("backdrop (RED) painted");
1538        let green = find(1).expect("child (GREEN) painted");
1539        let blue = find(2).expect("foreground (BLUE) painted");
1540        assert!(
1541            red < green && green < blue,
1542            "expected backdrop < child < foreground; RED@{red}, GREEN@{green}, BLUE@{blue}; order={:?}",
1543            frame.draw_order
1544        );
1545    }
1546
1547    #[test]
1548    fn blur_prop_emits_begin_end_pair_around_subtree() {
1549        // A widget with blur_prop = Some(Static(8.0)) wraps both its
1550        // own paint AND its children's paint inside a BeginBlurredSubtree
1551        // / EndBlurredSubtree pair, mirroring the opacity and transform
1552        // patterns. The Begin command carries the widget's bounds and
1553        // the requested radius.
1554        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1555        let parent = tree.add(StackWidget::new());
1556        let _child = tree.add_child(parent, FillWidget::new().background(Color::RED));
1557        tree.set_blur(parent, 8.0_f32);
1558        tree.layout(SizeProposal::exact(100.0, 50.0));
1559        let frame = tree.render();
1560
1561        let mut begin_count = 0;
1562        let mut end_count = 0;
1563        let mut begin_radius = None;
1564        for cmd in &frame.draw_order {
1565            match cmd {
1566                teksilo_canvas::DrawCommand::BeginBlurredSubtree { radius, .. } => {
1567                    begin_count += 1;
1568                    begin_radius = Some(*radius);
1569                }
1570                teksilo_canvas::DrawCommand::EndBlurredSubtree => end_count += 1,
1571                _ => {}
1572            }
1573        }
1574        assert_eq!(begin_count, 1, "draw_order = {:?}", frame.draw_order);
1575        assert_eq!(end_count, 1);
1576        assert_eq!(begin_radius, Some(8.0));
1577    }
1578
1579    #[test]
1580    fn blur_prop_subperceptual_radius_skipped() {
1581        // blur_prop = Some(Static(0.2)) is below the 0.5 threshold —
1582        // the walker emits no Begin/End pair so animated 0→target
1583        // patterns have zero per-frame cost when fully off.
1584        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1585        let parent = tree.add(StackWidget::new());
1586        tree.add_child(parent, FillWidget::new().background(Color::RED));
1587        tree.set_blur(parent, 0.2_f32);
1588        tree.layout(SizeProposal::exact(100.0, 50.0));
1589        let frame = tree.render();
1590
1591        for cmd in &frame.draw_order {
1592            assert!(
1593                !matches!(
1594                    cmd,
1595                    teksilo_canvas::DrawCommand::BeginBlurredSubtree { .. }
1596                        | teksilo_canvas::DrawCommand::EndBlurredSubtree
1597                ),
1598                "sub-perceptual blur must not emit Begin/End"
1599            );
1600        }
1601    }
1602
1603    #[test]
1604    fn blur_scope_is_outermost_when_combined_with_opacity_and_transform() {
1605        // Architectural pin: blur is the OUTERMOST scope so it captures
1606        // the already-faded, already-transformed subtree into the
1607        // intermediate texture.  Order on enter:
1608        //   Begin → SetOpacity → PushTransform → ...paint...
1609        // Order on exit (LIFO):
1610        //   PopTransform → RestoreOpacity → End
1611        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1612        let parent = tree.add(StackWidget::new());
1613        tree.add_child(parent, FillWidget::new().background(Color::RED));
1614        tree.set_blur(parent, 8.0_f32);
1615        tree.set_opacity(parent, 0.7_f32);
1616        tree.set_transform(parent, teksilo_canvas::Transform2D::scale(2.0, 2.0));
1617        tree.layout(SizeProposal::exact(100.0, 50.0));
1618        let frame = tree.render();
1619
1620        let mut begin_idx = None;
1621        let mut set_opacity_idx = None;
1622        let mut push_transform_idx = None;
1623        let mut pop_transform_idx = None;
1624        let mut restore_opacity_idx = None;
1625        let mut end_idx = None;
1626        for (i, cmd) in frame.draw_order.iter().enumerate() {
1627            match cmd {
1628                teksilo_canvas::DrawCommand::BeginBlurredSubtree { .. } => begin_idx = Some(i),
1629                teksilo_canvas::DrawCommand::SetOpacity(_) => set_opacity_idx = Some(i),
1630                teksilo_canvas::DrawCommand::PushTransform(_) => push_transform_idx = Some(i),
1631                teksilo_canvas::DrawCommand::PopTransform => pop_transform_idx = Some(i),
1632                teksilo_canvas::DrawCommand::RestoreOpacity => restore_opacity_idx = Some(i),
1633                teksilo_canvas::DrawCommand::EndBlurredSubtree => end_idx = Some(i),
1634                _ => {}
1635            }
1636        }
1637        let bg = begin_idx.expect("Begin emitted");
1638        let so = set_opacity_idx.expect("SetOpacity emitted");
1639        let pt = push_transform_idx.expect("PushTransform emitted");
1640        let popt = pop_transform_idx.expect("PopTransform emitted");
1641        let ro = restore_opacity_idx.expect("RestoreOpacity emitted");
1642        let en = end_idx.expect("End emitted");
1643        assert!(bg < so, "blur opens before opacity");
1644        assert!(so < pt, "opacity opens before transform");
1645        assert!(pt < popt);
1646        assert!(popt < ro, "transform closes before opacity");
1647        assert!(ro < en, "opacity closes before blur");
1648    }
1649
1650    #[test]
1651    fn nested_theme_overrides_compose() {
1652        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1653
1654        let grandparent = tree.add(FillWidget::new());
1655        let parent = tree.add_child(grandparent, FillWidget::new());
1656        let child = tree.add_child(parent, FillWidget::new());
1657
1658        tree.set_theme_override(grandparent, |theme| {
1659            theme.colors.accent = Color::RED;
1660        });
1661        tree.set_theme_override(parent, |theme| {
1662            theme.colors.text_secondary = Color::GREEN;
1663        });
1664
1665        tree.layout(SizeProposal::exact(100.0, 50.0));
1666
1667        let child_theme = tree.resolved_theme(child);
1668        assert_eq!(child_theme.colors.accent, Color::RED);
1669        assert_eq!(child_theme.colors.text_secondary, Color::GREEN);
1670    }
1671
1672    /// Leaf that paints one rect in a colour read at paint time, in its own
1673    /// pass and optionally in its foreground pass, and counts its paints.
1674    #[derive(Debug)]
1675    struct ColorProbe {
1676        color: Rc<std::cell::Cell<Color>>,
1677        paints: Rc<std::cell::Cell<u32>>,
1678        post_paints: Rc<std::cell::Cell<u32>>,
1679        foreground: bool,
1680    }
1681
1682    impl ColorProbe {
1683        fn new(color: Color) -> Self {
1684            Self {
1685                color: Rc::new(std::cell::Cell::new(color)),
1686                paints: Rc::new(std::cell::Cell::new(0)),
1687                post_paints: Rc::new(std::cell::Cell::new(0)),
1688                foreground: false,
1689            }
1690        }
1691    }
1692
1693    impl Widget for ColorProbe {
1694        fn layout_response(
1695            &self,
1696            proposal: SizeProposal,
1697            _ctx: &LayoutContext,
1698        ) -> crate::widget::LayoutResponse {
1699            proposal.resolve(40.0, 40.0).into()
1700        }
1701
1702        fn paint(&self, bounds: Rect, canvas: &mut Canvas, _ctx: &PaintContext) {
1703            self.paints.set(self.paints.get() + 1);
1704            canvas.fill_rect(bounds, self.color.get());
1705        }
1706
1707        fn wants_post_paint(&self) -> bool {
1708            self.foreground
1709        }
1710
1711        fn post_paint(&self, bounds: Rect, canvas: &mut Canvas, _ctx: &PaintContext) {
1712            self.post_paints.set(self.post_paints.get() + 1);
1713            canvas.fill_rect(bounds, self.color.get());
1714        }
1715    }
1716
1717    /// Container that places each child 40×40 at a fixed offset from its own
1718    /// origin, whatever its own size, so a child can leave and re-enter the
1719    /// container's clip without moving.
1720    #[derive(Debug)]
1721    struct OffsetContainer {
1722        offset: f32,
1723    }
1724
1725    impl Widget for OffsetContainer {
1726        fn layout_response(
1727            &self,
1728            proposal: SizeProposal,
1729            _ctx: &LayoutContext,
1730        ) -> crate::widget::LayoutResponse {
1731            proposal.resolve(0.0, 0.0).into()
1732        }
1733
1734        fn place_children(
1735            &self,
1736            bounds: Rect,
1737            _proposal: SizeProposal,
1738            children: &mut [WidgetPlacement],
1739            _ctx: &LayoutContext,
1740        ) {
1741            for child in children.iter_mut() {
1742                child.origin = teksilo_canvas::Point::new(bounds.x + self.offset, bounds.y);
1743                child.size = teksilo_canvas::Size::new(40.0, 40.0);
1744            }
1745        }
1746    }
1747
1748    fn painted_colors(frame: &RenderFrame) -> Vec<[f32; 4]> {
1749        frame.decorations.iter().map(|d| d.color).collect()
1750    }
1751
1752    /// A clipping root 200 px wide with a probe at x = 150. Narrowing the
1753    /// root to 100 px clips the probe out without moving it.
1754    fn clipped_probe_tree(probe: ColorProbe) -> (WidgetTree, WidgetId) {
1755        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1756        let root = tree.add(OffsetContainer { offset: 150.0 });
1757        tree.set_clips_children(root, true);
1758        let child = tree.add_child(root, probe);
1759        tree.layout(SizeProposal::exact(200.0, 50.0));
1760        (tree, child)
1761    }
1762
1763    #[test]
1764    fn clipped_out_dirty_node_repaints_when_it_comes_back_into_view() {
1765        let probe = ColorProbe::new(Color::RED);
1766        let (color, paints) = (probe.color.clone(), probe.paints.clone());
1767        let (mut tree, child) = clipped_probe_tree(probe);
1768        assert!(painted_colors(&tree.render()).contains(&Color::RED.to_array()));
1769
1770        tree.layout(SizeProposal::exact(100.0, 50.0));
1771        assert_eq!(tree.bounds(child).x, 150.0, "the probe must not move");
1772        color.set(Color::BLUE);
1773        tree.arena.mark_needs_paint(child);
1774        let frame = tree.render();
1775        assert!(
1776            painted_colors(&frame).is_empty(),
1777            "the probe is clipped out"
1778        );
1779        assert_eq!(paints.get(), 1, "a clipped-out node does not paint");
1780
1781        tree.layout(SizeProposal::exact(200.0, 50.0));
1782        let colors = painted_colors(&tree.render());
1783        assert_eq!(
1784            colors,
1785            vec![Color::BLUE.to_array()],
1786            "a node dirtied while clipped out must not replay its old paint"
1787        );
1788        assert_eq!(paints.get(), 2);
1789    }
1790
1791    #[test]
1792    fn clipped_out_clean_node_keeps_its_cache() {
1793        let probe = ColorProbe::new(Color::RED);
1794        let paints = probe.paints.clone();
1795        let (mut tree, child) = clipped_probe_tree(probe);
1796        let _ = tree.render();
1797
1798        tree.layout(SizeProposal::exact(100.0, 50.0));
1799        let _ = tree.render();
1800        assert!(
1801            tree.arena.get(child).unwrap().cached_paint.is_some(),
1802            "a clean node keeps its cache while clipped out"
1803        );
1804
1805        tree.layout(SizeProposal::exact(200.0, 50.0));
1806        let colors = painted_colors(&tree.render());
1807        assert_eq!(colors, vec![Color::RED.to_array()]);
1808        assert_eq!(paints.get(), 1, "the clean node replays its cache");
1809    }
1810
1811    #[test]
1812    fn node_dirtied_under_a_transparent_ancestor_repaints_when_it_fades_in() {
1813        let probe = ColorProbe::new(Color::RED);
1814        let (color, paints) = (probe.color.clone(), probe.paints.clone());
1815        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1816        let parent = tree.add(StackWidget::new());
1817        let child = tree.add_child(parent, probe);
1818        let opacity = crate::signal::Signal::new(1.0_f32);
1819        tree.set_opacity(parent, opacity.clone());
1820        tree.layout(SizeProposal::exact(100.0, 50.0));
1821        let _ = tree.render();
1822
1823        opacity.set(0.0);
1824        tree.layout(SizeProposal::exact(100.0, 50.0));
1825        color.set(Color::BLUE);
1826        tree.arena.mark_needs_paint(child);
1827        let _ = tree.render();
1828        assert_eq!(paints.get(), 1, "a transparent subtree does not paint");
1829
1830        opacity.set(1.0);
1831        tree.layout(SizeProposal::exact(100.0, 50.0));
1832        let colors = painted_colors(&tree.render());
1833        assert_eq!(colors, vec![Color::BLUE.to_array()]);
1834    }
1835
1836    #[test]
1837    fn transparent_dirty_node_repaints_when_it_fades_in() {
1838        let probe = ColorProbe::new(Color::RED);
1839        let color = probe.color.clone();
1840        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1841        let node = tree.add(probe);
1842        let opacity = crate::signal::Signal::new(1.0_f32);
1843        tree.set_opacity(node, opacity.clone());
1844        tree.layout(SizeProposal::exact(100.0, 50.0));
1845        let _ = tree.render();
1846
1847        // The node's own opacity scope stamps it as visited before the
1848        // sub-perceptual early return, so only the paint stamp tells the
1849        // clear loop that `paint()` never ran. Nothing visible depends on it
1850        // here: the opacity binding re-marks the node on fade-in, so it
1851        // repaints either way. This pins the cache itself, which must not
1852        // outlive the change that dirtied it.
1853        opacity.set(0.0);
1854        tree.layout(SizeProposal::exact(100.0, 50.0));
1855        color.set(Color::BLUE);
1856        tree.arena.mark_needs_paint(node);
1857        let _ = tree.render();
1858        assert!(
1859            tree.arena.get(node).unwrap().cached_paint.is_none(),
1860            "a node dirtied while transparent drops its stale cache"
1861        );
1862
1863        opacity.set(1.0);
1864        tree.layout(SizeProposal::exact(100.0, 50.0));
1865        let colors = painted_colors(&tree.render());
1866        assert_eq!(colors, vec![Color::BLUE.to_array()]);
1867    }
1868
1869    /// A guard, not a test of the stale-cache fix: dormant nodes never reach
1870    /// the end-of-render clear, and activation marks a node for repaint on
1871    /// its own. It keeps that path covered beside the cases the fix does
1872    /// change.
1873    #[test]
1874    fn a_node_changed_while_dormant_repaints_when_activated() {
1875        let probe = ColorProbe::new(Color::RED);
1876        let color = probe.color.clone();
1877        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1878        let node = tree.add(probe);
1879        tree.layout(SizeProposal::exact(100.0, 50.0));
1880        let _ = tree.render();
1881
1882        tree.set_dormant(node);
1883        color.set(Color::BLUE);
1884        tree.arena.mark_needs_paint(node);
1885        tree.layout(SizeProposal::exact(100.0, 50.0));
1886        let _ = tree.render();
1887
1888        tree.activate(node);
1889        tree.layout(SizeProposal::exact(100.0, 50.0));
1890        let colors = painted_colors(&tree.render());
1891        assert_eq!(colors, vec![Color::BLUE.to_array()]);
1892    }
1893
1894    /// Stacks its children at its origin, as `StackWidget` does, and counts
1895    /// its layouts.
1896    #[derive(Debug)]
1897    struct CountingStack {
1898        children: Vec<WidgetId>,
1899        layouts: Rc<std::cell::Cell<u32>>,
1900    }
1901
1902    impl Widget for CountingStack {
1903        fn layout_response(
1904            &self,
1905            proposal: SizeProposal,
1906            _ctx: &LayoutContext,
1907        ) -> crate::widget::LayoutResponse {
1908            self.layouts.set(self.layouts.get() + 1);
1909            proposal.resolve(0.0, 0.0).into()
1910        }
1911
1912        fn place_children(
1913            &self,
1914            bounds: Rect,
1915            _proposal: SizeProposal,
1916            children: &mut [WidgetPlacement],
1917            _ctx: &LayoutContext,
1918        ) {
1919            for child in children.iter_mut() {
1920                child.origin = bounds.origin();
1921                child.size = bounds.size();
1922            }
1923        }
1924
1925        fn children(&self) -> Vec<WidgetId> {
1926            self.children.clone()
1927        }
1928    }
1929
1930    /// A destroyed subtree is not in the next frame, though nothing else
1931    /// changed and the frame would otherwise be replayed whole, and its
1932    /// parent lays out again without it.
1933    #[test]
1934    fn a_destroyed_subtree_leaves_the_next_frame() {
1935        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1936        let red = tree.add(ColorProbe::new(Color::RED));
1937        let blue = tree.add(ColorProbe::new(Color::BLUE));
1938        let layouts = Rc::new(std::cell::Cell::new(0));
1939        let root = tree.add(CountingStack {
1940            children: vec![red, blue],
1941            layouts: layouts.clone(),
1942        });
1943        tree.layout(SizeProposal::exact(100.0, 50.0));
1944        assert_eq!(painted_colors(&tree.render()).len(), 2);
1945        tree.layout(SizeProposal::exact(100.0, 50.0));
1946        let settled = layouts.get();
1947
1948        tree.destroy_subtree_for_testing(red);
1949        tree.layout(SizeProposal::exact(100.0, 50.0));
1950        assert!(layouts.get() > settled, "the parent laid out again");
1951        assert_eq!(painted_colors(&tree.render()), vec![Color::BLUE.to_array()]);
1952
1953        // A root has no parent to mark: the composed frame goes all the same.
1954        tree.destroy_subtree_for_testing(root);
1955        tree.layout(SizeProposal::exact(100.0, 50.0));
1956        assert!(painted_colors(&tree.render()).is_empty());
1957    }
1958
1959    /// The same through a handler's `EventContext::destroy`, the door an
1960    /// application takes at run time.
1961    #[test]
1962    fn a_subtree_a_handler_destroys_leaves_the_next_frame() {
1963        use crate::widget_builder::WidgetBuilder;
1964        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1965        let red = tree.add(ColorProbe::new(Color::RED));
1966        let button =
1967            tree.add(ColorProbe::new(Color::BLUE).on_tap(move |_tap, ctx| ctx.destroy(red)));
1968        tree.add(StackWidget::new().child(red).child(button));
1969        tree.layout(SizeProposal::exact(100.0, 50.0));
1970        assert_eq!(painted_colors(&tree.render()).len(), 2);
1971
1972        tree.synthesise_tap(button);
1973        assert!(tree.arena.get(red).is_none(), "the tap destroyed it");
1974        tree.layout(SizeProposal::exact(100.0, 50.0));
1975        assert_eq!(painted_colors(&tree.render()), vec![Color::BLUE.to_array()]);
1976    }
1977
1978    #[test]
1979    fn clipped_out_dirty_node_reruns_its_foreground_pass() {
1980        let mut probe = ColorProbe::new(Color::RED);
1981        probe.foreground = true;
1982        let (color, post_paints) = (probe.color.clone(), probe.post_paints.clone());
1983        let (mut tree, child) = clipped_probe_tree(probe);
1984        let _ = tree.render();
1985        assert_eq!(post_paints.get(), 1);
1986
1987        tree.layout(SizeProposal::exact(100.0, 50.0));
1988        color.set(Color::BLUE);
1989        tree.arena.mark_needs_paint(child);
1990        let _ = tree.render();
1991        assert!(
1992            tree.arena.get(child).unwrap().cached_post_paint.is_none(),
1993            "the stale foreground cache is dropped with the main one"
1994        );
1995
1996        tree.layout(SizeProposal::exact(200.0, 50.0));
1997        let colors = painted_colors(&tree.render());
1998        assert_eq!(
1999            colors,
2000            vec![Color::BLUE.to_array(), Color::BLUE.to_array()],
2001            "both passes repaint in the new colour"
2002        );
2003        assert_eq!(post_paints.get(), 2);
2004    }
2005}