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        self.arena.fill_active_ids(&mut self.active_ids_scratch);
221        let ids = std::mem::take(&mut self.active_ids_scratch);
222        for &id in &ids {
223            if let Some(node) = self.arena.get_mut(id) {
224                node.dirty.needs_paint = false;
225            }
226        }
227        self.active_ids_scratch = ids;
228
229        if has_animations {
230            frame
231                .anim_params
232                .extend_from_slice(self.animated_quads.scratch_slice());
233        }
234        frame.debug_validate_stacks();
235        let rc = std::rc::Rc::new(frame);
236        self.cached_frame = Some(std::rc::Rc::clone(&rc));
237        // Full-render path: subscribers whose owners were just painted
238        // have `last_painted_epoch == paint_epoch`. Re-arm the
239        // frame-tick chain so per-frame effects keep ticking. When all
240        // subscribers are off-screen this returns false and the chain
241        // dies — exactly the bug fix that motivated this scheduler.
242        self.arm_frame_tick_for_visible_subscribers();
243        rc
244    }
245
246    /// Walk the frame-tick subscriber set and arm
247    /// `frame_tick_armed_by_subscribers` if any subscriber's owner is
248    /// currently visible. Called from `render_with_ops` (both cache-hit and
249    /// full-render paths).
250    ///
251    /// Not `frame_tick_requested`: that flag is paced at 60 Hz, this one at
252    /// the subscribers' own interval, and `frame_tick_deadline` has to tell
253    /// them apart or a throttled subscriber slows every requested frame.
254    pub(crate) fn arm_frame_tick_for_visible_subscribers(&mut self) {
255        if self
256            .frame_tick_scheduler
257            .should_arm_frame_tick(&self.arena, self.paint_epoch)
258        {
259            self.frame_tick_armed_by_subscribers = true;
260        }
261    }
262}
263
264/// Recursive paint pass with per-widget caching.
265/// Only re-runs `paint()` for widgets with `needs_paint` set; clean widgets
266/// reuse their `cached_paint` output. The tree walk still runs for clip/child
267/// ordering, but skips the expensive `paint()` call for clean widgets.
268///
269/// `parent_effective_enabled` is the AND of every ancestor's `enabled_state`
270/// resolved value (start `true` at root). The walker ANDs this with the
271/// current node's `enabled_state` to produce `this_effective_enabled`, which
272/// it both injects into the node's `PaintContext` and forwards as the parent
273/// value to children. This is the single mechanism by which leaf widgets see
274/// the arena's enabled-state at paint time without needing to walk ancestors
275/// themselves (`PaintContext` carries no `WidgetId` or arena reference).
276///
277/// `accumulated_raster_scale` is the quantized product of every ancestor
278/// transform scope's scale (start `1.0` at root). The walker multiplies in
279/// this node's own transform scale, sets the result as the text backend's
280/// ambient raster scale around the node's paint (so text drawn here
281/// rasterizes densely enough for the GPU transform that will stretch it),
282/// stamps it on the node's paint cache, and forwards it to children.
283#[allow(clippy::too_many_arguments)]
284fn paint_widget_cached(
285    arena: &mut WidgetArena,
286    id: WidgetId,
287    frame: &mut RenderFrame,
288    base_theme: &crate::styles::Theme,
289    text_backend: &Option<Rc<RefCell<dyn teksilo_canvas::TextBackend>>>,
290    clip_bounds: Option<Rect>,
291    a11y_prefs: &A11yPaintPrefs,
292    paint_epoch: u64,
293    overlay_skip: &std::collections::HashSet<WidgetId>,
294    overlay_rects: &[Rect],
295    layout_direction: crate::environment::LayoutDirection,
296    parent_effective_enabled: bool,
297    accumulated_raster_scale: f32,
298) {
299    if !arena.is_active(id) {
300        return;
301    }
302
303    // Compute this node's effective enabled-state once: AND the
304    // ancestor-derived value with our own `enabled_state` if set.
305    // Used both for our own paint context and for the recursion into
306    // children below.
307    let this_effective_enabled = parent_effective_enabled
308        && arena
309            .get(id)
310            .and_then(|n| n.enabled_state.as_ref())
311            .is_none_or(|p| p.get());
312
313    let node = arena.get(id).expect("node id is active (guarded above)");
314    let bounds = node.bounds;
315    if let Some(clip) = clip_bounds {
316        let x0 = bounds.x.max(clip.x);
317        let y0 = bounds.y.max(clip.y);
318        let x1 = bounds.right().min(clip.right());
319        let y1 = bounds.bottom().min(clip.bottom());
320        if x1 <= x0 || y1 <= y0 {
321            // Clipped to nothing — widget is offscreen. Skip its paint
322            // AND skip stamping `last_painted_epoch`, so the animation
323            // scheduler will notice it is no longer visible and pause
324            // its looping animations.
325            return;
326        }
327    }
328
329    // Mark this widget as "painted in epoch N" regardless of whether
330    // we hit the cache-path or ran `paint()` — both outcomes mean the
331    // widget's bounds landed inside the viewport this frame, which is
332    // all the animation scheduler cares about.
333    if let Some(node_mut) = arena.get_mut(id) {
334        node_mut.last_painted_epoch = paint_epoch;
335    }
336
337    // Read the optional opacity scope before borrowing the node again
338    // for the paint/cache path. The scope wraps both this widget's own
339    // paint and its children's paint, composing with any ancestor
340    // opacity via the canvas's stacked-opacity model.
341    let node = arena.get(id).expect("node id is active (guarded above)");
342    let opacity = node.opacity_prop.as_ref().map(|p| p.get().clamp(0.0, 1.0));
343    if let Some(o) = opacity
344        && o < 1.0 / 512.0
345    {
346        // Sub-perceptual: skip the subtree entirely. Saves a draw
347        // pass when a `Fade` is fully transparent (e.g. just-dismissed
348        // tooltip waiting for cleanup) and prevents 0.0 from emitting
349        // a spurious blend pass on the GPU. We do this before any blur
350        // scope emit so the Begin/End pair stays balanced.
351        return;
352    }
353
354    // Optional blur scope is the OUTERMOST per-node scope: it captures
355    // the entire rendered subtree (including any opacity/transform scopes
356    // we're about to push) into an intermediate texture, blurs it via
357    // the renderer's dual-Kawase chain, and composites the blurred
358    // result back at the widget's bounds. Sub-perceptual radii skip the
359    // pair entirely so animated 0→target_radius patterns have zero cost
360    // when fully off.
361    let node = arena.get(id).expect("node id is active (guarded above)");
362    let blur_radius = node
363        .blur_prop
364        .as_ref()
365        .map(|p| p.get())
366        .filter(|r| *r >= 0.5);
367    let blur_bounds = arena.bounds(id);
368    if let Some(r) = blur_radius {
369        frame
370            .draw_order
371            .push(teksilo_canvas::DrawCommand::BeginBlurredSubtree {
372                bounds: blur_bounds,
373                radius: r,
374            });
375    }
376
377    if let Some(o) = opacity {
378        frame
379            .draw_order
380            .push(teksilo_canvas::DrawCommand::SetOpacity(o));
381    }
382
383    // Optional transform scope — wraps both this widget's own paint
384    // and its children's paint. The renderer composes the pushed
385    // transform onto its stack so widget-internal canvas transforms
386    // (canvas.translate / scale / rotate) compose with this wrapper
387    // transform instead of clobbering it. Skip the push entirely when
388    // the transform is identity — saves a flush on every wrapper that
389    // happens to be at its rest pose.
390    let node = arena.get(id).expect("node id is active (guarded above)");
391    let transform = node.transform_prop.as_ref().map(|p| p.get());
392    let push_transform = transform.filter(|t| *t != teksilo_canvas::Transform2D::IDENTITY);
393    let clips = node.clips_children;
394    let content_transform = node.content_transform;
395    let bounds = node.bounds;
396
397    // Accumulated raster scale for this node and its subtree: multiply
398    // the ancestors' scale by this node's own transform scale (a
399    // SceneView zoom or a `Scale` wrapper) and re-quantize. Text painted
400    // inside the scope rasterizes at this density so the GPU transform
401    // lands a ~1:1 texel-to-pixel mapping instead of stretching a 1×
402    // bitmap. Pure translations/rotations have `geometric_scale() == 1`
403    // and inherit the parent value bit-identically.
404    let this_raster_scale = match push_transform {
405        Some(t) => quantize_raster_scale(accumulated_raster_scale * t.geometric_scale()),
406        None => accumulated_raster_scale,
407    };
408    // The backend currently holds the parent's ambient scale (root
409    // callers start at 1.0; every recursion level restores on exit), so
410    // it only needs touching when this node's scale differs.
411    let raster_scale_changed = this_raster_scale != accumulated_raster_scale;
412    if raster_scale_changed && let Some(tb) = text_backend {
413        tb.borrow_mut().set_raster_scale(this_raster_scale);
414    }
415    // A *content* transform (a SceneView's pan/zoom) leaves the node's bounds a
416    // fixed parent-space viewport and only moves the content. Emit its clip
417    // BEFORE the transform so the renderer scissors to that fixed viewport
418    // (transformed by ancestors only — correct for nested scenes too) instead
419    // of the pan/zoom-shifted rect, and so the node's own paint (background
420    // grid / lightweight items) is clipped to the viewport as well. A *self*
421    // transform (Scale/Rotate) keeps its clip INSIDE the transform — there the
422    // clip is meant to be the scaled visual region.
423    let clip_outside_transform = clips && content_transform;
424    if clip_outside_transform {
425        frame
426            .draw_order
427            .push(teksilo_canvas::DrawCommand::SetClip(bounds));
428    }
429    if let Some(t) = push_transform {
430        frame
431            .draw_order
432            .push(teksilo_canvas::DrawCommand::PushTransform(t));
433    }
434
435    let node = arena.get(id).expect("node id is active (guarded above)");
436    // A clean widget's cached frames bake glyph quads at the raster
437    // scale current when they were recorded; when the ambient scale
438    // moved (a scene zoom crossed a quantization bucket), those quads
439    // sample wrong-density bitmaps — treat the node as needing paint.
440    let needs_paint = node.dirty.needs_paint || node.paint_raster_scale != this_raster_scale;
441
442    if needs_paint || node.cached_paint.is_none() {
443        let resolved_theme = arena.resolve_theme(id, base_theme);
444        let ctx = PaintContext {
445            theme: &resolved_theme,
446            scale_factor: this_raster_scale,
447            text_scale: a11y_prefs.text_scale,
448            layout_direction,
449            effective_enabled: this_effective_enabled,
450            prefers_high_contrast: a11y_prefs.high_contrast,
451            prefers_reduced_motion: a11y_prefs.reduced_motion,
452            prefers_large_text: a11y_prefs.large_text,
453            window_active: a11y_prefs.window_active,
454            clip_bounds,
455        };
456
457        let bounds = arena.bounds(id);
458        let node = arena.get(id).expect("node id is active (guarded above)");
459
460        let mut canvas = match text_backend {
461            Some(tb) => Canvas::with_text_backend(tb.clone()),
462            None => Canvas::new(),
463        };
464        node.widget.paint(bounds, &mut canvas, &ctx);
465        let widget_frame = canvas.into_render_frame();
466
467        frame.merge(&widget_frame);
468        if let Some(node) = arena.get_mut(id) {
469            node.cached_paint = Some(widget_frame);
470            node.paint_raster_scale = this_raster_scale;
471        }
472    } else {
473        let node = arena.get(id).expect("node id is active (guarded above)");
474        if let Some(cached) = &node.cached_paint {
475            // Refresh the text backend's glyph timestamps for every
476            // layout baked into this cached paint. Without this,
477            // widgets that stay clean for ~180 frames (e.g. static
478            // labels next to an animation) can have their atlas slots
479            // evicted and reused, and the cached UVs then sample the
480            // wrong glyph. `TextBackend::touch_layout` is a no-op for
481            // backends without a glyph cache (the mock).
482            if !cached.layout_keys.is_empty()
483                && let Some(tb) = text_backend
484            {
485                let mut tb = tb.borrow_mut();
486                for key in &cached.layout_keys {
487                    tb.touch_layout(*key);
488                }
489                #[cfg(debug_assertions)]
490                debug_validate_layout_keys(&*tb, &cached.layout_keys, Some(id));
491            }
492            frame.merge(cached);
493        }
494    }
495
496    let node = arena.get(id).expect("node id is active (guarded above)");
497    let children: Vec<WidgetId> = node.children.clone();
498    let next_clip = if clips {
499        Some(match clip_bounds {
500            Some(clip) => {
501                let x0 = bounds.x.max(clip.x);
502                let y0 = bounds.y.max(clip.y);
503                let x1 = bounds.right().min(clip.right());
504                let y1 = bounds.bottom().min(clip.bottom());
505                Rect::new(x0, y0, (x1 - x0).max(0.0), (y1 - y0).max(0.0))
506            }
507            None => bounds,
508        })
509    } else {
510        clip_bounds
511    };
512
513    // A *content*-transform node (a `SceneView`'s pan/zoom) places its children
514    // in the transformed (content) coordinate space — `place_children` writes
515    // each child's `node.bounds` in scene coords, and the pan/zoom is applied
516    // only at draw time via the `PushTransform` above. The cull clip we hand the
517    // children must therefore be in that same content space; otherwise the
518    // per-child offscreen check at the top of this fn compares scene-space child
519    // bounds against a screen-space clip and drops content that is panned into
520    // view (a card far down in scene coords reads as "outside the viewport"
521    // regardless of pan — the lightweight tier, painted in the node's own
522    // `paint()`, is unaffected, hence "connectors render but cards don't").
523    // Inverse-transform the screen-space clip into content space. The GPU
524    // `SetClip` (emitted in parent/screen space) is untouched — it stays the
525    // real viewport scissor.
526    let next_clip = match (
527        content_transform,
528        next_clip,
529        transform.and_then(|t| t.inverse()),
530    ) {
531        (true, Some(screen_clip), Some(inv)) => Some(inv.apply_rect(screen_clip)),
532        _ => next_clip,
533    };
534
535    // Self-transform / plain clipping nodes emit their clip here — after the
536    // node's own paint, inside any transform scope. Content-transform nodes
537    // already emitted theirs above (in parent space).
538    if clips && !clip_outside_transform {
539        frame
540            .draw_order
541            .push(teksilo_canvas::DrawCommand::SetClip(bounds));
542    }
543
544    for child_id in children {
545        // Skip overlay-managed content here — it is painted via the
546        // dedicated overlay loop in render_with_ops. Without this, an
547        // overlay (e.g. a tooltip) attached as a child of its anchor
548        // would paint twice per frame: once via the parent walk and
549        // once via the overlay loop.
550        if overlay_skip.contains(&child_id) {
551            continue;
552        }
553        paint_widget_cached(
554            arena,
555            child_id,
556            frame,
557            base_theme,
558            text_backend,
559            next_clip,
560            a11y_prefs,
561            paint_epoch,
562            overlay_skip,
563            overlay_rects,
564            layout_direction,
565            this_effective_enabled,
566            this_raster_scale,
567        );
568    }
569
570    // Post-order `after_paint` hook. Fires after every descendant has
571    // painted and committed its bounds, so a parent can read those
572    // bounds via `WidgetTreeView::bounds(child_id)`. Gated on
573    // `wants_after_paint()` to avoid a virtual call per widget per
574    // frame for the 99% of widgets that don't aggregate. The arena
575    // mutable borrow from the recursive child loop has dropped by
576    // this point, so an immutable reborrow is safe.
577    {
578        let arena_ref: &WidgetArena = &*arena;
579        if let Some(node) = arena_ref.get(id)
580            && node.widget.wants_after_paint()
581        {
582            let view = crate::widget::WidgetTreeView::new(arena_ref, overlay_rects);
583            let resolved_theme = arena_ref.resolve_theme(id, base_theme);
584            let ctx = PaintContext {
585                theme: &resolved_theme,
586                scale_factor: this_raster_scale,
587                text_scale: a11y_prefs.text_scale,
588                layout_direction,
589                effective_enabled: this_effective_enabled,
590                prefers_high_contrast: a11y_prefs.high_contrast,
591                prefers_reduced_motion: a11y_prefs.reduced_motion,
592                prefers_large_text: a11y_prefs.large_text,
593                window_active: a11y_prefs.window_active,
594                clip_bounds,
595            };
596            node.widget.after_paint(&view, &ctx);
597        }
598    }
599
600    // Foreground pass — `post_paint` emits *after* the whole child
601    // subtree, so its draws land on top of this node's descendants. Still
602    // inside this node's clip / transform / opacity / blur scopes (their
603    // closers come below), so a foreground decoration pans, scales and
604    // clips consistently with the subtree it covers. Same `needs_paint`
605    // cache gate as the main paint above, with its own `cached_post_paint`
606    // frame. Gated on `wants_post_paint` so non-foreground widgets pay
607    // nothing.
608    let wants_post_paint = arena
609        .get(id)
610        .map(|n| n.widget.wants_post_paint())
611        .unwrap_or(false);
612    if wants_post_paint {
613        let has_post_cache = arena
614            .get(id)
615            .map(|n| n.cached_post_paint.is_some())
616            .unwrap_or(false);
617        if needs_paint || !has_post_cache {
618            // Children restored the backend to this node's ambient scale
619            // on their way out; re-assert defensively so post_paint text
620            // (e.g. a SceneView's foreground lightweight items) can't
621            // bake at a child-leaked scale.
622            if raster_scale_changed && let Some(tb) = text_backend {
623                tb.borrow_mut().set_raster_scale(this_raster_scale);
624            }
625            let resolved_theme = arena.resolve_theme(id, base_theme);
626            let ctx = PaintContext {
627                theme: &resolved_theme,
628                scale_factor: this_raster_scale,
629                text_scale: a11y_prefs.text_scale,
630                layout_direction,
631                effective_enabled: this_effective_enabled,
632                prefers_high_contrast: a11y_prefs.high_contrast,
633                prefers_reduced_motion: a11y_prefs.reduced_motion,
634                prefers_large_text: a11y_prefs.large_text,
635                window_active: a11y_prefs.window_active,
636                clip_bounds,
637            };
638            let bounds = arena.bounds(id);
639            let node = arena.get(id).expect("node id is active (guarded above)");
640            let mut canvas = match text_backend {
641                Some(tb) => Canvas::with_text_backend(tb.clone()),
642                None => Canvas::new(),
643            };
644            node.widget.post_paint(bounds, &mut canvas, &ctx);
645            let post_frame = canvas.into_render_frame();
646            frame.merge(&post_frame);
647            if let Some(node) = arena.get_mut(id) {
648                node.cached_post_paint = Some(post_frame);
649            }
650        } else if let Some(cached) = arena.get(id).and_then(|n| n.cached_post_paint.as_ref()) {
651            if !cached.layout_keys.is_empty()
652                && let Some(tb) = text_backend
653            {
654                let mut tb = tb.borrow_mut();
655                for key in &cached.layout_keys {
656                    tb.touch_layout(*key);
657                }
658                #[cfg(debug_assertions)]
659                debug_validate_layout_keys(&*tb, &cached.layout_keys, Some(id));
660            }
661            frame.merge(cached);
662        }
663    }
664
665    if clips && !clip_outside_transform {
666        frame
667            .draw_order
668            .push(teksilo_canvas::DrawCommand::ClearClip);
669    }
670
671    if push_transform.is_some() {
672        frame
673            .draw_order
674            .push(teksilo_canvas::DrawCommand::PopTransform);
675    }
676
677    // A content-transform clip opened before the transform, so it closes after
678    // the transform pops (clip { transform { … } } nesting).
679    if clip_outside_transform {
680        frame
681            .draw_order
682            .push(teksilo_canvas::DrawCommand::ClearClip);
683    }
684
685    if opacity.is_some() {
686        frame
687            .draw_order
688            .push(teksilo_canvas::DrawCommand::RestoreOpacity);
689    }
690
691    if blur_radius.is_some() {
692        frame
693            .draw_order
694            .push(teksilo_canvas::DrawCommand::EndBlurredSubtree);
695    }
696
697    // Unwind the ambient raster scale to the parent's value so siblings
698    // painted after this subtree see their own ancestor scale.
699    if raster_scale_changed && let Some(tb) = text_backend {
700        tb.borrow_mut().set_raster_scale(accumulated_raster_scale);
701    }
702}
703
704/// Debug-build corruption catcher: before a retained paint frame is
705/// replayed, verify that every layout baked into it still matches the
706/// live glyph atlas.
707///
708/// A `RectMismatch` means the frame's glyph quads reference atlas pixels
709/// that were evicted and reused — replaying them draws the wrong
710/// characters (the historical "random text corruption fixed by a
711/// repaint" bug). That is always a missing cache-invalidation path, so
712/// abort with a diagnostic. A `StaleKey` (backend forgot the layout
713/// entirely — its caches were cleared after this frame was baked) is the
714/// same bug class but can transiently occur around legitimate wholesale
715/// clears, so it logs loudly (once per key) instead of aborting.
716#[cfg(debug_assertions)]
717fn debug_validate_layout_keys(
718    tb: &dyn teksilo_canvas::TextBackend,
719    layout_keys: &[u64],
720    widget: Option<WidgetId>,
721) {
722    use teksilo_canvas::GlyphValidation;
723    std::thread_local! {
724        static REPORTED_STALE_KEYS: std::cell::RefCell<std::collections::HashSet<u64>> =
725            std::cell::RefCell::new(std::collections::HashSet::new());
726    }
727    for key in layout_keys {
728        match tb.debug_validate_layout(*key) {
729            GlyphValidation::Valid => {}
730            GlyphValidation::StaleKey => {
731                let first_report = REPORTED_STALE_KEYS.with(|set| set.borrow_mut().insert(*key));
732                if first_report {
733                    eprintln!(
734                        "[teksilo] WARNING: retained paint cache replays layout_key={key} \
735                         (widget={widget:?}) that the text backend no longer knows — the \
736                         frame survived a backend cache clear, so a paint-cache \
737                         invalidation path is likely missing."
738                    );
739                }
740            }
741            GlyphValidation::RectMismatch => {
742                panic!(
743                    "stale glyph UVs in retained paint cache (layout_key={key}, \
744                     widget={widget:?}): cached quads no longer match the live glyph \
745                     atlas — a cache-invalidation path is missing"
746                );
747            }
748        }
749    }
750}
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755    use crate::test_widgets::{FillWidget, StackWidget};
756    use teksilo_tokens::{Color, CornerRadius};
757
758    /// Headless text backend that records every `touch_layout` call and
759    /// hands out layouts with a fixed non-zero `layout_key`, so tests
760    /// can assert that paint-cache reuse keeps glyph timestamps fresh.
761    /// Also tracks the ambient raster scale (`set_raster_scale`) and the
762    /// scale every `layout_single_line` call ran under, so walker tests
763    /// can assert the set/restore discipline around transform scopes.
764    #[derive(Default)]
765    struct RecordingTextBackend {
766        touched: std::rc::Rc<std::cell::RefCell<Vec<u64>>>,
767        raster_scale: f32,
768        /// Ambient raster scale observed by each `layout_single_line`
769        /// call, in call order.
770        layout_scales: std::rc::Rc<std::cell::RefCell<Vec<f32>>>,
771    }
772
773    impl RecordingTextBackend {
774        fn new(touched: std::rc::Rc<std::cell::RefCell<Vec<u64>>>) -> Self {
775            Self {
776                touched,
777                raster_scale: 1.0,
778                layout_scales: Default::default(),
779            }
780        }
781    }
782
783    impl teksilo_canvas::TextBackend for RecordingTextBackend {
784        fn set_raster_scale(&mut self, raster_scale: f32) {
785            self.raster_scale = raster_scale;
786        }
787
788        fn raster_scale(&self) -> f32 {
789            self.raster_scale
790        }
791
792        fn layout_single_line(
793            &mut self,
794            text: &str,
795            _style: &teksilo_tokens::TextStyle,
796            _max_width: Option<f32>,
797        ) -> teksilo_canvas::TextLayout {
798            self.layout_scales.borrow_mut().push(self.raster_scale);
799            teksilo_canvas::TextLayout {
800                width: text.len() as f32 * 8.0,
801                height: 16.0,
802                ascent: 12.0,
803                descent: 4.0,
804                underline_offset: 1.0,
805                underline_thickness: 1.0,
806                layout_key: 42,
807                line_count: 1,
808                spans: Vec::new(),
809                raster_scale: self.raster_scale,
810                geometry: None,
811            }
812        }
813
814        fn ensure_glyphs(
815            &mut self,
816            _layout: &teksilo_canvas::TextLayout,
817        ) -> Vec<teksilo_canvas::GlyphQuad> {
818            Vec::new()
819        }
820
821        fn touch_layout(&mut self, layout_key: u64) {
822            self.touched.borrow_mut().push(layout_key);
823        }
824    }
825
826    /// Leaf widget that draws one line of text so its emitted frame
827    /// carries a `layout_key`.
828    #[derive(Debug)]
829    struct TextPaintWidget;
830
831    impl Widget for TextPaintWidget {
832        fn layout_response(
833            &self,
834            proposal: SizeProposal,
835            _ctx: &LayoutContext,
836        ) -> crate::widget::LayoutResponse {
837            proposal.resolve(100.0, 20.0).into()
838        }
839
840        fn paint(
841            &self,
842            bounds: teksilo_canvas::Rect,
843            canvas: &mut teksilo_canvas::Canvas,
844            _ctx: &PaintContext,
845        ) {
846            let style = teksilo_tokens::TextStyle::default();
847            let _ = canvas.draw_text("hello", bounds, &style, Color::BLACK);
848        }
849    }
850
851    #[test]
852    fn full_frame_cache_hit_touches_layout_keys() {
853        let touched = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
854        let backend = RecordingTextBackend::new(touched.clone());
855        let mut tree = WidgetTree::new()
856            .with_theme(crate::presets::intui::light())
857            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(backend)));
858        tree.add(TextPaintWidget);
859        tree.layout(SizeProposal::exact(200.0, 50.0));
860
861        // First render paints for real; no cache reuse yet.
862        let _ = tree.render();
863        touched.borrow_mut().clear();
864
865        // Nothing is dirty: this render takes the full-frame early-out.
866        // The reused frame's layout keys must still be touched, or the
867        // backend's LRU ages out glyphs that are on screen.
868        let _ = tree.render();
869        assert!(
870            touched.borrow().contains(&42),
871            "full-frame cache hit must touch_layout every baked layout key; \
872             touched = {:?}",
873            touched.borrow()
874        );
875    }
876
877    /// Shared fixture: a root stack with a text leaf before, inside, and
878    /// after a transform-scoped wrapper:
879    ///
880    /// ```text
881    /// root Stack
882    /// ├── TextPaintWidget          (A, screen scale)
883    /// ├── Stack [transform]        (wrapper)
884    /// │   └── TextPaintWidget      (B, scaled)
885    /// └── TextPaintWidget          (C, screen scale)
886    /// ```
887    fn scaled_subtree_tree(
888        transform: impl Into<crate::signal::Prop<teksilo_canvas::Transform2D>>,
889    ) -> (
890        WidgetTree,
891        std::rc::Rc<std::cell::RefCell<RecordingTextBackend>>,
892        std::rc::Rc<std::cell::RefCell<Vec<f32>>>,
893    ) {
894        let backend = RecordingTextBackend::new(Default::default());
895        let layout_scales = backend.layout_scales.clone();
896        let backend_rc = std::rc::Rc::new(std::cell::RefCell::new(backend));
897        let mut tree = WidgetTree::new()
898            .with_theme(crate::presets::intui::light())
899            .with_text_backend(backend_rc.clone()
900                as std::rc::Rc<std::cell::RefCell<dyn teksilo_canvas::TextBackend>>);
901
902        let root = tree.add(StackWidget::new());
903        tree.add_child(root, TextPaintWidget); // A
904        let wrapper = tree.add_child(root, StackWidget::new());
905        tree.add_child(wrapper, TextPaintWidget); // B
906        tree.add_child(root, TextPaintWidget); // C
907        tree.set_transform(wrapper, transform);
908
909        tree.layout(SizeProposal::exact(400.0, 300.0));
910        (tree, backend_rc, layout_scales)
911    }
912
913    #[test]
914    fn walker_sets_raster_scale_for_scaled_subtree_and_restores() {
915        let (mut tree, backend_rc, layout_scales) =
916            scaled_subtree_tree(teksilo_canvas::Transform2D::scale(2.0, 2.0));
917        let _ = tree.render();
918
919        // Paint order is child order: A (screen), B (inside the 2x
920        // scope, quantized onto the 1.25^n ladder), C (screen again —
921        // the wrapper restored the ambient scale on exit).
922        let expected_b = 1.25_f32.powi(3); // quantize(2.0)
923        assert_eq!(
924            layout_scales.borrow().as_slice(),
925            &[1.0, expected_b, 1.0],
926            "text inside the transform scope must lay out at the quantized \
927             scale; siblings before/after at screen scale"
928        );
929        // The walk unwound to the root ambient scale.
930        assert_eq!(backend_rc.borrow().raster_scale, 1.0);
931    }
932
933    #[test]
934    fn raster_scale_change_repaints_clean_descendants() {
935        let transform = crate::signal::Signal::new(teksilo_canvas::Transform2D::scale(2.0, 2.0));
936        let (mut tree, _backend_rc, layout_scales) = scaled_subtree_tree(transform.clone());
937        let _ = tree.render();
938        layout_scales.borrow_mut().clear();
939
940        // Zoom changes: only the wrapper node is dirtied (RepaintOnly
941        // binding); its text child B stays clean — the raster-scale
942        // stamp on B's paint cache must force the repaint anyway.
943        transform.set(teksilo_canvas::Transform2D::scale(3.0, 3.0));
944        let _ = tree.render();
945        assert_eq!(
946            layout_scales.borrow().as_slice(),
947            &[1.25_f32.powi(5)],
948            "the clean text leaf inside the scope must re-rasterize at the \
949             new quantized scale (A and C stay cached: no layout calls)"
950        );
951
952        // And back down to identity: B re-bakes once more at 1.0.
953        layout_scales.borrow_mut().clear();
954        transform.set(teksilo_canvas::Transform2D::IDENTITY);
955        let _ = tree.render();
956        assert_eq!(layout_scales.borrow().as_slice(), &[1.0]);
957
958        // Steady state: nothing dirty, no scale movement → full-frame
959        // cache hit, zero layout calls.
960        layout_scales.borrow_mut().clear();
961        let _ = tree.render();
962        assert!(layout_scales.borrow().is_empty());
963    }
964
965    #[test]
966    fn nested_scale_transforms_multiply() {
967        let backend = RecordingTextBackend::new(Default::default());
968        let layout_scales = backend.layout_scales.clone();
969        let mut tree = WidgetTree::new()
970            .with_theme(crate::presets::intui::light())
971            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(backend)));
972
973        // outer [2x] → inner [1.5x] → text. Accumulation:
974        // quantize(2.0) = 1.25^3, then quantize(1.25^3 × 1.5) = 1.25^5.
975        let outer = tree.add(StackWidget::new());
976        let inner = tree.add_child(outer, StackWidget::new());
977        tree.add_child(inner, TextPaintWidget);
978        tree.set_transform(outer, teksilo_canvas::Transform2D::scale(2.0, 2.0));
979        tree.set_content_transform(inner, teksilo_canvas::Transform2D::scale(1.5, 1.5));
980
981        tree.layout(SizeProposal::exact(400.0, 300.0));
982        let _ = tree.render();
983        assert_eq!(layout_scales.borrow().as_slice(), &[1.25_f32.powi(5)]);
984    }
985
986    #[test]
987    fn invalidate_all_paints_clears_post_paint_cache() {
988        /// Widget that draws chrome in the foreground pass so the walker
989        /// populates `cached_post_paint`.
990        #[derive(Debug)]
991        struct PostPaintWidget;
992
993        impl Widget for PostPaintWidget {
994            fn layout_response(
995                &self,
996                proposal: SizeProposal,
997                _ctx: &LayoutContext,
998            ) -> crate::widget::LayoutResponse {
999                proposal.resolve(50.0, 50.0).into()
1000            }
1001
1002            fn wants_post_paint(&self) -> bool {
1003                true
1004            }
1005
1006            fn post_paint(
1007                &self,
1008                bounds: teksilo_canvas::Rect,
1009                canvas: &mut teksilo_canvas::Canvas,
1010                _ctx: &PaintContext,
1011            ) {
1012                canvas.fill_rect(bounds, Color::RED);
1013            }
1014        }
1015
1016        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1017        let id = tree.add(PostPaintWidget);
1018        tree.layout(SizeProposal::exact(100.0, 100.0));
1019        let _ = tree.render();
1020        assert!(
1021            tree.arena
1022                .get(id)
1023                .and_then(|n| n.cached_post_paint.as_ref())
1024                .is_some(),
1025            "render must populate cached_post_paint for a post-painting widget"
1026        );
1027
1028        tree.invalidate_all_paints();
1029        assert!(
1030            tree.arena
1031                .get(id)
1032                .and_then(|n| n.cached_post_paint.as_ref())
1033                .is_none(),
1034            "invalidate_all_paints must clear cached_post_paint too — a retained \
1035             post-paint frame can hold stale glyph UVs after atlas eviction"
1036        );
1037    }
1038
1039    #[derive(Debug)]
1040    struct ThemeAwareWidget;
1041
1042    impl Widget for ThemeAwareWidget {
1043        fn layout_response(
1044            &self,
1045            proposal: SizeProposal,
1046            _ctx: &LayoutContext,
1047        ) -> crate::widget::LayoutResponse {
1048            proposal.resolve(0.0, 0.0).into()
1049        }
1050
1051        fn paint(
1052            &self,
1053            bounds: teksilo_canvas::Rect,
1054            canvas: &mut teksilo_canvas::Canvas,
1055            ctx: &PaintContext,
1056        ) {
1057            canvas.fill_rounded_rect(
1058                bounds,
1059                teksilo_tokens::CornerRadius::uniform(4.0),
1060                ctx.theme.colors.accent,
1061            );
1062        }
1063    }
1064
1065    #[test]
1066    fn fill_widget_produces_shape_in_frame() {
1067        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1068        tree.add(
1069            FillWidget::new()
1070                .background(Color::RED)
1071                .corner_radius(CornerRadius::uniform(6.0)),
1072        );
1073        tree.layout(SizeProposal::exact(100.0, 40.0));
1074        let frame = tree.render();
1075        assert_eq!(frame.shapes.len(), 1);
1076        assert_eq!(
1077            frame.shapes[0].shape,
1078            teksilo_canvas::ShapeKind::RoundedRect
1079        );
1080    }
1081
1082    #[test]
1083    fn empty_tree_renders_empty_frame() {
1084        let mut tree = WidgetTree::new();
1085        let frame = tree.render();
1086        assert!(frame.is_empty());
1087    }
1088
1089    #[test]
1090    fn render_clears_paint_dirty() {
1091        let mut tree = WidgetTree::new();
1092        tree.add(FillWidget::new().background(Color::RED));
1093        tree.layout(SizeProposal::exact(100.0, 50.0));
1094        assert!(tree.needs_paint());
1095        tree.render();
1096        assert!(!tree.needs_paint());
1097    }
1098
1099    #[test]
1100    fn dormant_widget_not_rendered() {
1101        let mut tree = WidgetTree::new();
1102        let widget = tree.add(
1103            FillWidget::new()
1104                .background(Color::RED)
1105                .corner_radius(CornerRadius::uniform(4.0)),
1106        );
1107        tree.layout(SizeProposal::exact(100.0, 50.0));
1108        let frame = tree.render();
1109        assert!(!frame.shapes.is_empty());
1110
1111        tree.set_dormant(widget);
1112        tree.layout(SizeProposal::exact(100.0, 50.0));
1113        let frame = tree.render();
1114        assert!(frame.shapes.is_empty());
1115    }
1116
1117    #[test]
1118    fn dormancy_is_recursive() {
1119        let mut tree = WidgetTree::new();
1120        let child = tree.add(
1121            FillWidget::new()
1122                .background(Color::RED)
1123                .corner_radius(CornerRadius::uniform(4.0)),
1124        );
1125        let parent = tree.add(StackWidget::new().child(child));
1126        tree.layout(SizeProposal::exact(100.0, 50.0));
1127
1128        let frame = tree.render();
1129        assert_eq!(frame.shapes.len(), 1);
1130
1131        tree.set_dormant(parent);
1132        tree.layout(SizeProposal::exact(100.0, 50.0));
1133        let frame = tree.render();
1134        assert!(frame.shapes.is_empty());
1135
1136        tree.activate(parent);
1137        tree.layout(SizeProposal::exact(100.0, 50.0));
1138        let frame = tree.render();
1139        assert_eq!(frame.shapes.len(), 1);
1140    }
1141
1142    #[test]
1143    fn set_theme_marks_all_dirty() {
1144        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1145        tree.add(FillWidget::new().background(Color::RED));
1146        tree.layout(SizeProposal::exact(100.0, 50.0));
1147        tree.render();
1148
1149        assert!(!tree.needs_layout());
1150        assert!(!tree.needs_paint());
1151
1152        tree.set_theme(crate::presets::intui::dark());
1153        assert!(tree.needs_layout());
1154        assert!(tree.needs_paint());
1155    }
1156
1157    #[test]
1158    fn set_theme_changes_rendered_colors() {
1159        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1160        tree.add(ThemeAwareWidget);
1161        tree.layout(SizeProposal::exact(100.0, 50.0));
1162        let light_frame = tree.render();
1163        let light_color = light_frame.shapes[0].color;
1164
1165        tree.set_theme(crate::presets::intui::dark());
1166        tree.layout(SizeProposal::exact(100.0, 50.0));
1167        let dark_frame = tree.render();
1168        let dark_color = dark_frame.shapes[0].color;
1169
1170        assert_ne!(light_color, dark_color);
1171    }
1172
1173    #[test]
1174    fn subtree_theme_override() {
1175        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1176        let parent = tree.add(ThemeAwareWidget);
1177        let _child = tree.add_child(parent, ThemeAwareWidget);
1178
1179        tree.set_theme_override(parent, |theme| {
1180            theme.colors = teksilo_tokens::ColorTokens::dark_default();
1181        });
1182
1183        tree.layout(SizeProposal::exact(100.0, 50.0));
1184        let frame = tree.render();
1185
1186        let dark_accent = teksilo_tokens::ColorTokens::dark_default()
1187            .accent
1188            .to_array();
1189        assert_eq!(frame.shapes[0].color, dark_accent);
1190        assert_eq!(frame.shapes[1].color, dark_accent);
1191    }
1192
1193    #[test]
1194    fn theme_override_only_affects_subtree() {
1195        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1196
1197        let _unaffected = tree.add(ThemeAwareWidget);
1198        let overridden = tree.add(ThemeAwareWidget);
1199
1200        tree.set_theme_override(overridden, |theme| {
1201            theme.colors = teksilo_tokens::ColorTokens::dark_default();
1202        });
1203
1204        tree.layout(SizeProposal::exact(100.0, 50.0));
1205        let frame = tree.render();
1206
1207        let light_accent = teksilo_tokens::ColorTokens::light_default()
1208            .accent
1209            .to_array();
1210        let dark_accent = teksilo_tokens::ColorTokens::dark_default()
1211            .accent
1212            .to_array();
1213
1214        assert_eq!(frame.shapes[0].color, light_accent);
1215        assert_eq!(frame.shapes[1].color, dark_accent);
1216    }
1217
1218    #[test]
1219    fn resolved_theme_reflects_overrides() {
1220        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1221
1222        let parent = tree.add(FillWidget::new());
1223        let child = tree.add_child(parent, FillWidget::new());
1224
1225        tree.set_theme_override(parent, |theme| {
1226            theme.colors.accent = Color::RED;
1227        });
1228
1229        tree.layout(SizeProposal::exact(100.0, 50.0));
1230
1231        let parent_theme = tree.resolved_theme(parent);
1232        assert_eq!(parent_theme.colors.accent, Color::RED);
1233
1234        let child_theme = tree.resolved_theme(child);
1235        assert_eq!(child_theme.colors.accent, Color::RED);
1236    }
1237
1238    #[test]
1239    fn opacity_prop_emits_set_and_restore_around_subtree() {
1240        // A widget with opacity_prop = Some(Static(0.5)) wraps its
1241        // own paint AND its children's paint inside a SetOpacity /
1242        // RestoreOpacity pair, so the canvas's stacked-opacity model
1243        // multiplies through.
1244        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1245        let parent = tree.add(StackWidget::new());
1246        let _child = tree.add_child(parent, FillWidget::new().background(Color::RED));
1247        tree.set_opacity(parent, 0.5_f32);
1248        tree.layout(SizeProposal::exact(100.0, 50.0));
1249        let frame = tree.render();
1250
1251        let mut set_count = 0;
1252        let mut restore_count = 0;
1253        for cmd in &frame.draw_order {
1254            match cmd {
1255                teksilo_canvas::DrawCommand::SetOpacity(v) => {
1256                    assert!((v - 0.5).abs() < 1e-6, "expected 0.5, got {}", v);
1257                    set_count += 1;
1258                }
1259                teksilo_canvas::DrawCommand::RestoreOpacity => restore_count += 1,
1260                _ => {}
1261            }
1262        }
1263        assert_eq!(set_count, 1, "draw_order = {:?}", frame.draw_order);
1264        assert_eq!(restore_count, 1);
1265    }
1266
1267    #[test]
1268    fn opacity_prop_zero_skips_subtree_entirely() {
1269        // Sub-perceptual opacity (< 1/512) returns early — no
1270        // SetOpacity, no children draw commands. Saves a blend pass.
1271        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1272        let parent = tree.add(StackWidget::new());
1273        tree.add_child(parent, FillWidget::new().background(Color::RED));
1274        tree.set_opacity(parent, 0.0_f32);
1275        tree.layout(SizeProposal::exact(100.0, 50.0));
1276        let frame = tree.render();
1277
1278        for cmd in &frame.draw_order {
1279            assert!(
1280                !matches!(cmd, teksilo_canvas::DrawCommand::SetOpacity(_)),
1281                "fully-transparent subtree should not emit SetOpacity"
1282            );
1283        }
1284        // The red FillWidget child must not appear either.
1285        assert!(
1286            !frame
1287                .shapes
1288                .iter()
1289                .any(|s| s.color == Color::RED.to_array()),
1290            "fully-transparent subtree should not paint its descendants"
1291        );
1292    }
1293
1294    #[test]
1295    fn transform_prop_emits_push_and_pop_around_subtree() {
1296        // A widget with transform_prop = Some(Static(scale(2))) wraps
1297        // both its own paint AND its children's paint inside a
1298        // PushTransform / PopTransform pair, mirroring the opacity
1299        // pattern.
1300        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1301        let parent = tree.add(StackWidget::new());
1302        let _child = tree.add_child(parent, FillWidget::new().background(Color::RED));
1303        let scale_2x = teksilo_canvas::Transform2D::scale(2.0, 2.0);
1304        tree.set_transform(parent, scale_2x);
1305        tree.layout(SizeProposal::exact(100.0, 50.0));
1306        let frame = tree.render();
1307
1308        let mut push_count = 0;
1309        let mut pop_count = 0;
1310        let mut push_value = None;
1311        for cmd in &frame.draw_order {
1312            match cmd {
1313                teksilo_canvas::DrawCommand::PushTransform(t) => {
1314                    push_count += 1;
1315                    push_value = Some(*t);
1316                }
1317                teksilo_canvas::DrawCommand::PopTransform => pop_count += 1,
1318                _ => {}
1319            }
1320        }
1321        assert_eq!(push_count, 1, "draw_order = {:?}", frame.draw_order);
1322        assert_eq!(pop_count, 1);
1323        assert_eq!(push_value, Some(scale_2x));
1324    }
1325
1326    #[test]
1327    fn content_transform_clip_wraps_outside_the_transform() {
1328        // A *content* transform (the SceneView pattern: clips_children + a
1329        // content transform set via set_content_transform) must emit its clip
1330        // OUTSIDE the transform — SetClip before PushTransform, ClearClip after
1331        // PopTransform — so the renderer scissors to the fixed parent-space
1332        // viewport instead of the pan/zoom-shifted rect. (A *self* transform
1333        // like Scale keeps the clip inside the transform; see
1334        // transform_prop_emits_push_and_pop_around_subtree.)
1335        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1336        let parent = tree.add(StackWidget::new());
1337        tree.add_child(parent, FillWidget::new().background(Color::RED));
1338        tree.layout(SizeProposal::exact(100.0, 50.0));
1339        // Set after layout so no rebuild can clear the flags before render.
1340        tree.set_clips_children(parent, true);
1341        tree.set_content_transform(parent, teksilo_canvas::Transform2D::translate(50.0, 30.0));
1342        let frame = tree.render();
1343
1344        let mut set_clip = None;
1345        let mut push = None;
1346        let mut pop = None;
1347        let mut clear = None;
1348        for (i, cmd) in frame.draw_order.iter().enumerate() {
1349            match cmd {
1350                teksilo_canvas::DrawCommand::SetClip(_) => set_clip = set_clip.or(Some(i)),
1351                teksilo_canvas::DrawCommand::PushTransform(_) => push = push.or(Some(i)),
1352                teksilo_canvas::DrawCommand::PopTransform => pop = Some(i),
1353                teksilo_canvas::DrawCommand::ClearClip => clear = Some(i),
1354                _ => {}
1355            }
1356        }
1357        let (sc, pt, pop, cc) = (
1358            set_clip.expect("SetClip emitted"),
1359            push.expect("PushTransform emitted"),
1360            pop.expect("PopTransform emitted"),
1361            clear.expect("ClearClip emitted"),
1362        );
1363        assert!(
1364            sc < pt,
1365            "clip must open before the transform: SetClip@{sc}, PushTransform@{pt}; order={:?}",
1366            frame.draw_order
1367        );
1368        assert!(
1369            pop < cc,
1370            "clip must close after the transform: PopTransform@{pop}, ClearClip@{cc}"
1371        );
1372    }
1373
1374    #[test]
1375    fn identity_transform_prop_skipped() {
1376        // transform_prop = Some(Static(IDENTITY)) is a no-op — the
1377        // walker should NOT emit a PushTransform / PopTransform pair
1378        // for the rest pose. Saves a flush per identity wrapper per
1379        // frame (Scale at full visibility, Rotate at angle=0).
1380        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1381        let parent = tree.add(StackWidget::new());
1382        tree.add_child(parent, FillWidget::new().background(Color::RED));
1383        tree.set_transform(parent, teksilo_canvas::Transform2D::IDENTITY);
1384        tree.layout(SizeProposal::exact(100.0, 50.0));
1385        let frame = tree.render();
1386
1387        for cmd in &frame.draw_order {
1388            assert!(
1389                !matches!(
1390                    cmd,
1391                    teksilo_canvas::DrawCommand::PushTransform(_)
1392                        | teksilo_canvas::DrawCommand::PopTransform
1393                ),
1394                "identity transform must not emit a push/pop scope"
1395            );
1396        }
1397    }
1398
1399    #[test]
1400    fn transform_scope_paint_order_opacity_outer_transform_inner() {
1401        // When both opacity_prop AND transform_prop are set on the
1402        // same node, the framework's contract is opacity OUTER and
1403        // transform INNER. This pins down the order so future
1404        // refactors don't silently flip composability.
1405        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1406        let parent = tree.add(StackWidget::new());
1407        tree.add_child(parent, FillWidget::new().background(Color::RED));
1408        tree.set_opacity(parent, 0.7_f32);
1409        tree.set_transform(parent, teksilo_canvas::Transform2D::scale(2.0, 2.0));
1410        tree.layout(SizeProposal::exact(100.0, 50.0));
1411        let frame = tree.render();
1412
1413        // Find the indices of each command kind.
1414        let mut set_opacity_idx = None;
1415        let mut push_transform_idx = None;
1416        let mut pop_transform_idx = None;
1417        let mut restore_opacity_idx = None;
1418        for (i, cmd) in frame.draw_order.iter().enumerate() {
1419            match cmd {
1420                teksilo_canvas::DrawCommand::SetOpacity(_) => set_opacity_idx = Some(i),
1421                teksilo_canvas::DrawCommand::PushTransform(_) => push_transform_idx = Some(i),
1422                teksilo_canvas::DrawCommand::PopTransform => pop_transform_idx = Some(i),
1423                teksilo_canvas::DrawCommand::RestoreOpacity => restore_opacity_idx = Some(i),
1424                _ => {}
1425            }
1426        }
1427        let so = set_opacity_idx.expect("SetOpacity emitted");
1428        let pt = push_transform_idx.expect("PushTransform emitted");
1429        let popt = pop_transform_idx.expect("PopTransform emitted");
1430        let ro = restore_opacity_idx.expect("RestoreOpacity emitted");
1431        assert!(so < pt, "opacity must open before transform: {so} < {pt}");
1432        assert!(pt < popt, "transform push must precede its pop");
1433        assert!(
1434            popt < ro,
1435            "transform must close before opacity: {popt} < {ro}"
1436        );
1437    }
1438
1439    /// A composing widget that paints a RED backdrop in `paint()`, hosts a
1440    /// GREEN child, and paints a BLUE foreground in `post_paint()`. Pins the
1441    /// P-C-AP draw order: backdrop (P) → child (C) → foreground (AP).
1442    #[derive(Debug)]
1443    struct Sandwich {
1444        child: Option<crate::WidgetId>,
1445    }
1446
1447    impl Widget for Sandwich {
1448        fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<crate::WidgetId> {
1449            let c = ctx.add(FillWidget::new().background(Color::GREEN));
1450            self.child = Some(c);
1451            vec![c]
1452        }
1453
1454        fn layout_response(
1455            &self,
1456            proposal: SizeProposal,
1457            _ctx: &LayoutContext,
1458        ) -> crate::widget::LayoutResponse {
1459            proposal.resolve(0.0, 0.0).into()
1460        }
1461
1462        fn place_children(
1463            &self,
1464            bounds: teksilo_canvas::Rect,
1465            _proposal: SizeProposal,
1466            children: &mut [crate::widget::WidgetPlacement],
1467            _ctx: &LayoutContext,
1468        ) {
1469            for child in children.iter_mut() {
1470                child.origin = bounds.origin();
1471                child.size = bounds.size();
1472            }
1473        }
1474
1475        fn children(&self) -> Vec<crate::WidgetId> {
1476            self.child.iter().copied().collect()
1477        }
1478
1479        fn paint(
1480            &self,
1481            bounds: teksilo_canvas::Rect,
1482            canvas: &mut teksilo_canvas::Canvas,
1483            _ctx: &PaintContext,
1484        ) {
1485            canvas.fill_rounded_rect(bounds, CornerRadius::uniform(0.0), Color::RED);
1486        }
1487
1488        fn wants_post_paint(&self) -> bool {
1489            true
1490        }
1491
1492        fn post_paint(
1493            &self,
1494            bounds: teksilo_canvas::Rect,
1495            canvas: &mut teksilo_canvas::Canvas,
1496            _ctx: &PaintContext,
1497        ) {
1498            canvas.fill_rounded_rect(bounds, CornerRadius::uniform(0.0), Color::BLUE);
1499        }
1500    }
1501
1502    #[test]
1503    fn post_paint_emits_after_children() {
1504        // P-C-AP ordering: a composing widget's own paint() is a backdrop
1505        // (before children) and post_paint() is a foreground (after the whole
1506        // child subtree). Backdrop=RED, child=GREEN, foreground=BLUE; assert
1507        // RED < GREEN < BLUE in draw_order.
1508        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1509        tree.add(Sandwich { child: None });
1510        tree.layout(SizeProposal::exact(100.0, 50.0));
1511        let frame = tree.render();
1512
1513        let color_of = |cmd: &teksilo_canvas::DrawCommand| -> Option<[f32; 4]> {
1514            match cmd {
1515                teksilo_canvas::DrawCommand::Shape(i) => frame.shapes.get(*i).map(|s| s.color),
1516                teksilo_canvas::DrawCommand::Decoration(i) => {
1517                    frame.decorations.get(*i).map(|d| d.color)
1518                }
1519                _ => None,
1520            }
1521        };
1522        // Find the first draw whose color is dominated by `dominant` channel.
1523        let find = |dominant: usize| -> Option<usize> {
1524            frame.draw_order.iter().position(|cmd| {
1525                color_of(cmd).is_some_and(|c| {
1526                    c[dominant] > 0.5 && (0..3).all(|ch| ch == dominant || c[ch] < 0.5)
1527                })
1528            })
1529        };
1530        let red = find(0).expect("backdrop (RED) painted");
1531        let green = find(1).expect("child (GREEN) painted");
1532        let blue = find(2).expect("foreground (BLUE) painted");
1533        assert!(
1534            red < green && green < blue,
1535            "expected backdrop < child < foreground; RED@{red}, GREEN@{green}, BLUE@{blue}; order={:?}",
1536            frame.draw_order
1537        );
1538    }
1539
1540    #[test]
1541    fn blur_prop_emits_begin_end_pair_around_subtree() {
1542        // A widget with blur_prop = Some(Static(8.0)) wraps both its
1543        // own paint AND its children's paint inside a BeginBlurredSubtree
1544        // / EndBlurredSubtree pair, mirroring the opacity and transform
1545        // patterns. The Begin command carries the widget's bounds and
1546        // the requested radius.
1547        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1548        let parent = tree.add(StackWidget::new());
1549        let _child = tree.add_child(parent, FillWidget::new().background(Color::RED));
1550        tree.set_blur(parent, 8.0_f32);
1551        tree.layout(SizeProposal::exact(100.0, 50.0));
1552        let frame = tree.render();
1553
1554        let mut begin_count = 0;
1555        let mut end_count = 0;
1556        let mut begin_radius = None;
1557        for cmd in &frame.draw_order {
1558            match cmd {
1559                teksilo_canvas::DrawCommand::BeginBlurredSubtree { radius, .. } => {
1560                    begin_count += 1;
1561                    begin_radius = Some(*radius);
1562                }
1563                teksilo_canvas::DrawCommand::EndBlurredSubtree => end_count += 1,
1564                _ => {}
1565            }
1566        }
1567        assert_eq!(begin_count, 1, "draw_order = {:?}", frame.draw_order);
1568        assert_eq!(end_count, 1);
1569        assert_eq!(begin_radius, Some(8.0));
1570    }
1571
1572    #[test]
1573    fn blur_prop_subperceptual_radius_skipped() {
1574        // blur_prop = Some(Static(0.2)) is below the 0.5 threshold —
1575        // the walker emits no Begin/End pair so animated 0→target
1576        // patterns have zero per-frame cost when fully off.
1577        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1578        let parent = tree.add(StackWidget::new());
1579        tree.add_child(parent, FillWidget::new().background(Color::RED));
1580        tree.set_blur(parent, 0.2_f32);
1581        tree.layout(SizeProposal::exact(100.0, 50.0));
1582        let frame = tree.render();
1583
1584        for cmd in &frame.draw_order {
1585            assert!(
1586                !matches!(
1587                    cmd,
1588                    teksilo_canvas::DrawCommand::BeginBlurredSubtree { .. }
1589                        | teksilo_canvas::DrawCommand::EndBlurredSubtree
1590                ),
1591                "sub-perceptual blur must not emit Begin/End"
1592            );
1593        }
1594    }
1595
1596    #[test]
1597    fn blur_scope_is_outermost_when_combined_with_opacity_and_transform() {
1598        // Architectural pin: blur is the OUTERMOST scope so it captures
1599        // the already-faded, already-transformed subtree into the
1600        // intermediate texture.  Order on enter:
1601        //   Begin → SetOpacity → PushTransform → ...paint...
1602        // Order on exit (LIFO):
1603        //   PopTransform → RestoreOpacity → End
1604        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1605        let parent = tree.add(StackWidget::new());
1606        tree.add_child(parent, FillWidget::new().background(Color::RED));
1607        tree.set_blur(parent, 8.0_f32);
1608        tree.set_opacity(parent, 0.7_f32);
1609        tree.set_transform(parent, teksilo_canvas::Transform2D::scale(2.0, 2.0));
1610        tree.layout(SizeProposal::exact(100.0, 50.0));
1611        let frame = tree.render();
1612
1613        let mut begin_idx = None;
1614        let mut set_opacity_idx = None;
1615        let mut push_transform_idx = None;
1616        let mut pop_transform_idx = None;
1617        let mut restore_opacity_idx = None;
1618        let mut end_idx = None;
1619        for (i, cmd) in frame.draw_order.iter().enumerate() {
1620            match cmd {
1621                teksilo_canvas::DrawCommand::BeginBlurredSubtree { .. } => begin_idx = Some(i),
1622                teksilo_canvas::DrawCommand::SetOpacity(_) => set_opacity_idx = Some(i),
1623                teksilo_canvas::DrawCommand::PushTransform(_) => push_transform_idx = Some(i),
1624                teksilo_canvas::DrawCommand::PopTransform => pop_transform_idx = Some(i),
1625                teksilo_canvas::DrawCommand::RestoreOpacity => restore_opacity_idx = Some(i),
1626                teksilo_canvas::DrawCommand::EndBlurredSubtree => end_idx = Some(i),
1627                _ => {}
1628            }
1629        }
1630        let bg = begin_idx.expect("Begin emitted");
1631        let so = set_opacity_idx.expect("SetOpacity emitted");
1632        let pt = push_transform_idx.expect("PushTransform emitted");
1633        let popt = pop_transform_idx.expect("PopTransform emitted");
1634        let ro = restore_opacity_idx.expect("RestoreOpacity emitted");
1635        let en = end_idx.expect("End emitted");
1636        assert!(bg < so, "blur opens before opacity");
1637        assert!(so < pt, "opacity opens before transform");
1638        assert!(pt < popt);
1639        assert!(popt < ro, "transform closes before opacity");
1640        assert!(ro < en, "opacity closes before blur");
1641    }
1642
1643    #[test]
1644    fn nested_theme_overrides_compose() {
1645        let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1646
1647        let grandparent = tree.add(FillWidget::new());
1648        let parent = tree.add_child(grandparent, FillWidget::new());
1649        let child = tree.add_child(parent, FillWidget::new());
1650
1651        tree.set_theme_override(grandparent, |theme| {
1652            theme.colors.accent = Color::RED;
1653        });
1654        tree.set_theme_override(parent, |theme| {
1655            theme.colors.text_secondary = Color::GREEN;
1656        });
1657
1658        tree.layout(SizeProposal::exact(100.0, 50.0));
1659
1660        let child_theme = tree.resolved_theme(child);
1661        assert_eq!(child_theme.colors.accent, Color::RED);
1662        assert_eq!(child_theme.colors.text_secondary, Color::GREEN);
1663    }
1664}