ailloli_ui_runtime/scene/
paint_engine.rs

1//! Retained-tree paint traversal and scene-graph construction.
2
3use std::collections::BTreeMap;
4
5use ailloli_ui_core::geometry::{ClipShape, Offset, Rect};
6use ailloli_ui_core::ids::ElementId;
7
8use crate::app::{Invalidation, RuntimeHandle};
9use crate::component::reactive::{MountGeneration, ReactiveReadScope, ReactiveStage};
10use crate::element::{ElementKind, ElementTree};
11use crate::layout::{LayoutArtifact, LayoutResult};
12use crate::scene::{DrawCmd, DrawText, PaintCtx};
13
14/// Paints a cached retained subtree at an absolute logical-pixel origin.
15///
16/// An unknown element or one without a [`crate::layout::LayoutResult`] is a
17/// no-op. The root bounds are its local `paint_bounds` translated by `origin`.
18/// Widget base paint runs before children; widget overlay paint runs afterward.
19/// An element's clip applies only while traversing its children, while ancestor
20/// clips remain active for all descendant work.
21///
22/// Direct child geometry is paired with retained children by index. Extra tree
23/// children without a layout record are skipped and extra layout records are
24/// ignored. Widget panics propagate and can leave a partially populated paint
25/// context; this traversal provides no rollback.
26///
27/// # Examples
28///
29/// ```
30/// use ailloli_ui_core::{ElementId, Offset};
31/// use ailloli_ui_runtime::element::ElementTree;
32/// use ailloli_ui_runtime::scene::{paint_element, PaintCtx};
33///
34/// let tree = ElementTree::<()>::new();
35/// let mut ctx = PaintCtx::new();
36/// paint_element(&tree, &mut ctx, ElementId(99), Offset::new(10.0, 20.0));
37/// assert_eq!(ctx.layers[0].cmds.len(), 0);
38/// ```
39pub fn paint_element<A: 'static>(
40    tree: &ElementTree<A>,
41    ctx: &mut PaintCtx<'_>,
42    element_id: ElementId,
43    origin: Offset,
44) {
45    let Some(el) = tree.get(element_id) else {
46        return;
47    };
48    let Some(layout) = el.layout.as_ref() else {
49        return;
50    };
51
52    let bounds = layout.paint_bounds.translate(origin);
53    paint_element_with_bounds(tree, None, &mut BTreeMap::new(), ctx, element_id, bounds);
54}
55
56/// Paints one subtree while retaining exact paint-time reactive dependencies.
57pub(crate) fn paint_element_observed<A: 'static>(
58    tree: &ElementTree<A>,
59    runtime: &RuntimeHandle<A>,
60    ctx: &mut PaintCtx<'_>,
61    element_id: ElementId,
62    origin: Offset,
63) {
64    let Some(element) = tree.get(element_id) else {
65        return;
66    };
67    let Some(layout) = element.layout.as_ref() else {
68        return;
69    };
70    let bounds = layout.paint_bounds.translate(origin);
71    let mut stale_feedback = BTreeMap::new();
72    paint_element_with_bounds(
73        tree,
74        Some(runtime),
75        &mut stale_feedback,
76        ctx,
77        element_id,
78        bounds,
79    );
80    runtime.record_stale_paint_feedback(stale_feedback.len());
81    for (_, (element_id, _, invalidation)) in stale_feedback {
82        runtime.invalidate(element_id, invalidation);
83    }
84}
85
86/// Recursive worker using an already resolved absolute bounds rectangle.
87///
88/// Paint diagnostics are incremented before verifying that a recursively
89/// referenced child still exists. The current interaction snapshot is scoped
90/// separately around base and overlay widget callbacks and restored afterward.
91fn paint_element_with_bounds<A: 'static>(
92    tree: &ElementTree<A>,
93    runtime: Option<&RuntimeHandle<A>>,
94    stale_feedback: &mut BTreeMap<u64, (ElementId, MountGeneration, Invalidation)>,
95    ctx: &mut PaintCtx<'_>,
96    element_id: ElementId,
97    bounds: Rect,
98) {
99    tree.record_paint(element_id);
100    let Some(el) = tree.get(element_id) else {
101        return;
102    };
103    let Some(layout) = el.layout.as_ref() else {
104        return;
105    };
106    let pending_invalidation =
107        runtime.and_then(|runtime| runtime.pending_invalidation_for_element(element_id));
108    let generation_is_current = el.committed_layout_generation == Some(el.mount_generation());
109    let reactive_layout_is_current = layout_stamp_is_current(el);
110    if pending_invalidation == Some(Invalidation::Build) {
111        // The queue is populated before `Runtime::prepare_frame` reflects the
112        // work in retained dirty flags. Build invalidates the whole retained
113        // subtree rooted here, so no callback below may observe fresh state
114        // against that still-committed old structure or geometry.
115        return;
116    }
117    if pending_invalidation == Some(Invalidation::Layout) {
118        // Preserve the existing stale-feedback diagnostic for a queued Layout
119        // while leaving its retained request coalesced. A committed text
120        // artifact may be replayed directly, but no widget callback is allowed
121        // to observe the fresh state against these old bounds.
122        record_feedback(
123            stale_feedback,
124            element_id,
125            el.mount_generation(),
126            Invalidation::Layout,
127        );
128        if generation_is_current && !el.dirty.layout && can_replay_committed_text_artifact(el) {
129            replay_committed_text_artifact(ctx, bounds, layout);
130        }
131        return;
132    }
133    if !generation_is_current || !reactive_layout_is_current {
134        record_feedback(
135            stale_feedback,
136            element_id,
137            el.mount_generation(),
138            Invalidation::Layout,
139        );
140        if generation_is_current && !el.dirty.layout && can_replay_committed_text_artifact(el) {
141            replay_committed_text_artifact(ctx, bounds, layout);
142        }
143        return;
144    }
145
146    let checkpoint = ctx.checkpoint();
147
148    let mut paint_children = |tree: &ElementTree<A>, ctx: &mut PaintCtx<'_>| {
149        for (idx, child_id) in el.children.iter().copied().enumerate() {
150            let Some(child_layout) = layout.children.get(idx) else {
151                continue;
152            };
153            let child_bounds = Rect::new(
154                bounds.x + child_layout.offset.x,
155                bounds.y + child_layout.offset.y,
156                child_layout.size.w,
157                child_layout.size.h,
158            );
159            paint_element_with_bounds(tree, runtime, stale_feedback, ctx, child_id, child_bounds);
160        }
161    };
162
163    let observation = match (&el.kind, runtime) {
164        (ElementKind::Widget(_), Some(_)) => Some(ReactiveReadScope::new()),
165        _ => None,
166    };
167
168    match &el.kind {
169        ElementKind::Empty => {}
170        ElementKind::Widget(widget) => {
171            let previous = ctx.set_current_interaction(
172                ctx.input_snapshot()
173                    .interaction_for_widget_paint(tree, element_id),
174            );
175            widget.paint(ctx, bounds, layout);
176            ctx.set_current_interaction(previous);
177        }
178        ElementKind::Component(_) => {}
179    }
180
181    if let Some(invalidation) = paint_blocking_invalidation(runtime, element_id, el) {
182        ctx.rollback_to(checkpoint);
183        record_pending_feedback(
184            stale_feedback,
185            element_id,
186            el.mount_generation(),
187            invalidation,
188        );
189        return;
190    }
191
192    if let Some(clip) = layout.clip {
193        let clip = translate_clip(clip, Offset::new(bounds.x, bounds.y));
194        let is_root = layout.is_window_root_clip;
195        ctx.with_clip_shape(clip, is_root, |ctx| paint_children(tree, ctx));
196    } else {
197        paint_children(tree, ctx);
198    }
199
200    // Child paint is allowed to enqueue work but never to make the parent's
201    // committed geometry stale and then continue into its overlay.
202    if let Some(invalidation) = paint_blocking_invalidation(runtime, element_id, el) {
203        ctx.rollback_to(checkpoint);
204        record_pending_feedback(
205            stale_feedback,
206            element_id,
207            el.mount_generation(),
208            invalidation,
209        );
210        return;
211    }
212
213    match &el.kind {
214        ElementKind::Empty => {}
215        ElementKind::Widget(widget) => {
216            let previous = ctx.set_current_interaction(
217                ctx.input_snapshot()
218                    .interaction_for_widget_paint(tree, element_id),
219            );
220            widget.paint_overlay(ctx, bounds, layout);
221            ctx.set_current_interaction(previous);
222        }
223        ElementKind::Component(_) => {}
224    }
225
226    if let Some(invalidation) = paint_blocking_invalidation(runtime, element_id, el) {
227        ctx.rollback_to(checkpoint);
228        record_pending_feedback(
229            stale_feedback,
230            element_id,
231            el.mount_generation(),
232            invalidation,
233        );
234        return;
235    }
236
237    if let (Some(runtime), Some(observation)) = (runtime, observation) {
238        let dependencies = observation.finish();
239        if !dependencies.is_current() {
240            ctx.rollback_to(checkpoint);
241            record_feedback(
242                stale_feedback,
243                element_id,
244                el.mount_generation(),
245                Invalidation::Paint,
246            );
247            return;
248        }
249        let _ = runtime.replace_reactive_dependencies(
250            element_id,
251            el.mount_generation(),
252            ReactiveStage::Paint,
253            &dependencies,
254        );
255    }
256}
257
258/// Returns whether this exact widget explicitly permits stale text replay.
259fn can_replay_committed_text_artifact<A: 'static>(element: &crate::element::Element<A>) -> bool {
260    matches!(
261        &element.kind,
262        ElementKind::Widget(widget) if widget.can_replay_committed_text_artifact()
263    )
264}
265
266/// Replays immutable shaped text for an explicitly opted-in widget.
267///
268/// The caller has already checked the widget's hidden replay contract. All
269/// text, style, and metrics therefore come from its previously committed
270/// layout, and no potentially fresh widget state is read. Every widget that
271/// does not opt in fails closed until an authoritative relayout succeeds.
272fn replay_committed_text_artifact(ctx: &mut PaintCtx<'_>, bounds: Rect, layout: &LayoutResult) {
273    let Some(LayoutArtifact::Text(prepared)) = layout.artifact.as_ref() else {
274        return;
275    };
276    let style = prepared.style();
277    let baseline = prepared.lines.first().map_or(0.0, |line| line.baseline_y);
278    ctx.push(DrawCmd::Text(DrawText {
279        pos: [bounds.x, bounds.y + baseline],
280        color: style.color,
281        decoration: style.decoration,
282        layout: prepared.clone(),
283    }));
284}
285
286/// Returns whether retained geometry and its contributing reads are current.
287fn layout_stamp_is_current<A>(element: &crate::element::Element<A>) -> bool {
288    !element.dirty.layout
289        && element.committed_layout_generation == Some(element.mount_generation())
290        && element.layout_reactive_dependencies.is_current()
291        && element.layout_commit_reactive_dependencies.is_current()
292}
293
294/// Returns structural work that makes this element's paint unit unsafe.
295///
296/// A historical invalidator can enqueue `Build` or `Layout` without the
297/// mutated source belonging to the element's committed layout read set. The
298/// queue must therefore be rechecked after every user-controlled paint stage,
299/// in addition to validating the retained reactive stamp.
300fn paint_blocking_invalidation<A>(
301    runtime: Option<&RuntimeHandle<A>>,
302    element_id: ElementId,
303    element: &crate::element::Element<A>,
304) -> Option<Invalidation> {
305    match runtime.and_then(|runtime| runtime.pending_invalidation_for_element(element_id)) {
306        Some(Invalidation::Build) => Some(Invalidation::Build),
307        Some(Invalidation::Layout) => Some(Invalidation::Layout),
308        Some(Invalidation::Paint) | None if !layout_stamp_is_current(element) => {
309            Some(Invalidation::Layout)
310        }
311        Some(Invalidation::Paint) | None => None,
312    }
313}
314
315/// Records only feedback that is not already represented by a stronger Build.
316///
317/// A pending Build already guarantees another retained traversal and should
318/// not be duplicated as weaker Layout feedback. Layout keeps the established
319/// coalesced diagnostic/retry behavior used by stale committed stamps.
320fn record_pending_feedback(
321    feedback: &mut BTreeMap<u64, (ElementId, MountGeneration, Invalidation)>,
322    element_id: ElementId,
323    mount_generation: MountGeneration,
324    invalidation: Invalidation,
325) {
326    if invalidation == Invalidation::Layout {
327        record_feedback(feedback, element_id, mount_generation, Invalidation::Layout);
328    }
329}
330
331/// Coalesces one deferred paint feedback request to its strongest level.
332fn record_feedback(
333    feedback: &mut BTreeMap<u64, (ElementId, MountGeneration, Invalidation)>,
334    element_id: ElementId,
335    mount_generation: MountGeneration,
336    invalidation: Invalidation,
337) {
338    feedback
339        .entry(element_id.0)
340        .and_modify(|entry| entry.2 = entry.2.merge(invalidation))
341        .or_insert((element_id, mount_generation, invalidation));
342}
343
344/// Translates a local clip shape to absolute logical-pixel coordinates.
345///
346/// Radius is preserved verbatim for rounded rectangles.
347fn translate_clip(clip: ClipShape, origin: Offset) -> ClipShape {
348    match clip {
349        ClipShape::Rect(r) => ClipShape::Rect(r.translate(origin)),
350        ClipShape::RoundRect { rect, radius } => ClipShape::RoundRect {
351            rect: rect.translate(origin),
352            radius,
353        },
354    }
355}