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