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