ailloli_ui_runtime/scene/
paint_ctx.rs

1//! Mutable paint context for draw commands, clips, and isolated effects.
2
3use crate::input::{InputInteraction, InputSnapshot};
4use crate::scene::clip_stack::{ClipStack, ClipStackSnapshot};
5use crate::scene::draw_cmd::DrawCmd;
6use crate::scene::isolated_effects::IsolatedEffects;
7use crate::scene::scene_graph::{Layer, LayerKind, Scene};
8use ailloli_ui_core::{ClipShape, Offset, Rect};
9use ailloli_ui_text::{ParleyEngine, TextSystem};
10
11/// Accumulates draw commands, clips, overlays, effects, and paint-time services.
12///
13/// Coordinates are window-space logical pixels unless a widget contract says
14/// otherwise. A fresh context contains one empty base layer, an empty clip
15/// stack, zero origin and frame time, default interaction state, and no text
16/// system. It is single-threaded because it exclusively borrows `TextSystem`.
17///
18/// # Examples
19///
20/// ```
21/// use ailloli_ui_runtime::scene::PaintCtx;
22/// let ctx = PaintCtx::new();
23/// assert_eq!(ctx.layers.len(), 1);
24/// assert!(ctx.overlay_layers.is_empty() && ctx.text_system.is_none());
25/// ```
26pub struct PaintContext<'a> {
27    /// Draw commands grouped by clip layer (scissor / shader / stencil downstream).
28    pub layers: Vec<Layer>,
29    /// Top-level overlay layers appended after all base content.
30    pub overlay_layers: Vec<Layer>,
31    /// Mutable active clip entries, ordered outermost to innermost.
32    pub clip_stack: ClipStack,
33    /// Caller-managed paint translation in logical pixels.
34    pub origin: Offset,
35    /// Snapshot corresponding to the active clip stack.
36    pub clip: ClipStackSnapshot,
37    /// Optional shared shaping engine and prepared-layout cache.
38    pub text_system: Option<&'a mut TextSystem>,
39    /// Immutable input state observed by widgets during this paint pass.
40    input: InputSnapshot,
41    /// Interaction role of the widget currently being painted.
42    current_interaction: InputInteraction,
43    /// Monotonic frame timestamp in milliseconds supplied by the host.
44    frame_time_ms: u128,
45    /// Indices into `layers` for active isolated scopes (outermost first).
46    isolated_scope_stack: Vec<usize>,
47    /// Overlay-layer indices temporarily selected by nested overlay scopes.
48    overlay_target_stack: Vec<usize>,
49    /// Lazily allocated fallback overlay layer used outside explicit scopes.
50    default_overlay_layer: Option<usize>,
51}
52
53/// Short alias for [`PaintCtx`].
54///
55/// # Examples
56///
57/// ```
58/// use ailloli_ui_runtime::scene::PaintCtx;
59/// let ctx = PaintCtx::new();
60/// assert_eq!(ctx.origin.x, 0.0);
61/// ```
62pub type PaintCtx<'a> = PaintContext<'a>;
63
64/// Allocation-free marker used to discard one stale element paint unit.
65pub(crate) struct PaintCheckpoint {
66    /// Base-layer count before the unit started.
67    layers_len: usize,
68    /// Existing base target and its command count, when one was active.
69    base_target: Option<(usize, usize)>,
70    /// Overlay-layer count before the unit started.
71    overlay_layers_len: usize,
72    /// Existing overlay target and its command count, when one was active.
73    overlay_target: Option<(usize, usize)>,
74    /// Lazily selected unscoped overlay target before the unit started.
75    default_overlay_layer: Option<usize>,
76}
77
78/// Implements the `Default` contract for `PaintContext<'a>`.
79impl<'a> Default for PaintContext<'a> {
80    /// Constructs the documented default value.
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86/// Provides the operations defined for `PaintContext<'a>`.
87impl<'a> PaintContext<'a> {
88    /// Creates an empty paint accumulator with one base layer.
89    ///
90    /// # Examples
91    ///
92    /// ```
93    /// use ailloli_ui_runtime::scene::{LayerKind, PaintCtx};
94    /// let ctx = PaintCtx::new();
95    /// assert_eq!(ctx.layers[0].kind, LayerKind::Base);
96    /// assert!(ctx.clip.is_empty());
97    /// ```
98    pub fn new() -> Self {
99        let mut s = Self {
100            layers: Vec::new(),
101            overlay_layers: Vec::new(),
102            clip_stack: ClipStack::new(),
103            origin: Offset::default(),
104            clip: ClipStackSnapshot::empty(),
105            text_system: None,
106            input: InputSnapshot::default(),
107            current_interaction: InputInteraction::default(),
108            frame_time_ms: 0,
109            isolated_scope_stack: Vec::new(),
110            overlay_target_stack: Vec::new(),
111            default_overlay_layer: None,
112        };
113        s.layers.push(Layer::base(ClipStackSnapshot::empty()));
114        s
115    }
116
117    /// Creates a fresh context borrowing a text system.
118    ///
119    /// Input interaction stays at its default and frame time stays zero.
120    ///
121    /// # Examples
122    ///
123    /// ```
124    /// use ailloli_ui_runtime::scene::PaintCtx;
125    /// use ailloli_ui_text::TextSystem;
126    /// let mut text = TextSystem::new();
127    /// let ctx = PaintCtx::with_text_system(&mut text);
128    /// assert!(ctx.text_system.is_some());
129    /// ```
130    pub fn with_text_system(text_system: &'a mut TextSystem) -> Self {
131        let mut s = Self::new();
132        s.text_system = Some(text_system);
133        s
134    }
135
136    /// Creates a text-enabled context with an input snapshot and frame timestamp.
137    ///
138    /// `frame_time_ms` is an opaque monotonic host timestamp in milliseconds;
139    /// it is stored verbatim and is not compared with wall-clock time.
140    ///
141    /// # Examples
142    ///
143    /// ```
144    /// use ailloli_ui_runtime::input::InputSnapshot;
145    /// use ailloli_ui_runtime::scene::PaintCtx;
146    /// use ailloli_ui_text::TextSystem;
147    /// let mut text = TextSystem::new();
148    /// let ctx = PaintCtx::with_text_system_and_input(
149    ///     &mut text, InputSnapshot::default(), 250,
150    /// );
151    /// assert_eq!(ctx.frame_time_ms(), 250);
152    /// ```
153    pub fn with_text_system_and_input(
154        text_system: &'a mut TextSystem,
155        input: InputSnapshot,
156        frame_time_ms: u128,
157    ) -> Self {
158        let mut s = Self::with_text_system(text_system);
159        s.input = input;
160        s.frame_time_ms = frame_time_ms;
161        s
162    }
163
164    /// Captures append-only layer positions without allocating or cloning commands.
165    pub(crate) fn checkpoint(&self) -> PaintCheckpoint {
166        let base_target = self
167            .isolated_scope_stack
168            .last()
169            .copied()
170            .or_else(|| self.layers.len().checked_sub(1))
171            .and_then(|index| {
172                self.layers
173                    .get(index)
174                    .map(|layer| (index, layer.cmds.len()))
175            });
176        let overlay_target = self
177            .overlay_target_stack
178            .last()
179            .copied()
180            .or(self.default_overlay_layer)
181            .and_then(|index| {
182                self.overlay_layers
183                    .get(index)
184                    .map(|layer| (index, layer.cmds.len()))
185            });
186        PaintCheckpoint {
187            layers_len: self.layers.len(),
188            base_target,
189            overlay_layers_len: self.overlay_layers.len(),
190            overlay_target,
191            default_overlay_layer: self.default_overlay_layer,
192        }
193    }
194
195    /// Removes commands and layers appended after `checkpoint`.
196    pub(crate) fn rollback_to(&mut self, checkpoint: PaintCheckpoint) {
197        if let Some((index, command_count)) = checkpoint.base_target {
198            if let Some(layer) = self.layers.get_mut(index) {
199                layer.cmds.truncate(command_count);
200            }
201        }
202        self.layers.truncate(checkpoint.layers_len);
203
204        if let Some((index, command_count)) = checkpoint.overlay_target {
205            if let Some(layer) = self.overlay_layers.get_mut(index) {
206                layer.cmds.truncate(command_count);
207            }
208        }
209        self.overlay_layers.truncate(checkpoint.overlay_layers_len);
210        self.default_overlay_layer = checkpoint.default_overlay_layer;
211    }
212
213    /// Borrows the underlying Parley engine, or returns `None` without text services.
214    ///
215    /// Mutations through the engine bypass `TextSystem` cache accounting; prefer
216    /// the higher-level text-system APIs unless direct shaping is required.
217    ///
218    /// # Examples
219    ///
220    /// ```
221    /// use ailloli_ui_runtime::scene::PaintCtx;
222    /// let mut ctx = PaintCtx::new();
223    /// assert!(ctx.text_engine_mut().is_none());
224    /// ```
225    pub fn text_engine_mut(&mut self) -> Option<&mut ParleyEngine> {
226        self.text_system.as_mut().map(|ts| ts.parley_engine_mut())
227    }
228
229    /// Appends a command to the current base or innermost isolated layer.
230    ///
231    /// If public mutation removed all base layers, this method first recreates
232    /// one with the current clip snapshot. Commands preserve call order.
233    ///
234    /// # Panics
235    ///
236    /// May panic if caller mutation leaves an active isolated-layer index that
237    /// no longer exists in `layers`.
238    ///
239    /// # Examples
240    ///
241    /// ```
242    /// use ailloli_ui_core::{Color, Rect};
243    /// use ailloli_ui_runtime::scene::{DrawCmd, DrawRect, PaintCtx};
244    /// let mut ctx = PaintCtx::new();
245    /// ctx.push(DrawCmd::Rect(DrawRect { rect: Rect::new(0.0, 0.0, 4.0, 4.0), color: Color::WHITE }));
246    /// assert_eq!(ctx.layers[0].cmds.len(), 1);
247    /// ```
248    pub fn push(&mut self, cmd: DrawCmd) {
249        if self.layers.is_empty() {
250            self.layers.push(Layer::base(self.clip.clone()));
251        }
252        let idx = self.push_target_layer_index();
253        self.layers[idx].cmds.push(cmd);
254    }
255
256    /// Pushes a command into the current top-level overlay target.
257    ///
258    /// Outside an explicit overlay clip, consecutive calls share one unclipped
259    /// overlay layer. [`Self::into_scene`] places all overlay layers after base
260    /// layers regardless of when this method was called.
261    ///
262    /// # Examples
263    ///
264    /// ```
265    /// use ailloli_ui_core::{Color, Rect};
266    /// use ailloli_ui_runtime::scene::{DrawCmd, DrawRect, PaintCtx};
267    /// let mut ctx = PaintCtx::new();
268    /// ctx.push_overlay(DrawCmd::Rect(DrawRect { rect: Rect::new(0.0, 0.0, 4.0, 4.0), color: Color::WHITE }));
269    /// assert_eq!(ctx.overlay_layers[0].cmds.len(), 1);
270    /// ```
271    pub fn push_overlay(&mut self, cmd: DrawCmd) {
272        let idx = self.overlay_target_layer_index();
273        self.overlay_layers[idx].cmds.push(cmd);
274    }
275
276    /// Pushes overlay commands into a layer clipped by a window-space rect.
277    ///
278    /// Overlay clips are top-level and intentionally do not inherit parent widget clips.
279    /// Nested overlay scopes target the innermost layer. On normal return, an
280    /// unscoped overlay command starts a fresh layer after the clipped scope,
281    /// preserving procedural order.
282    ///
283    /// # Panics
284    ///
285    /// A panic in `f` propagates without popping the overlay target, so callers
286    /// that catch unwinding must discard the context rather than continue it.
287    ///
288    /// # Examples
289    ///
290    /// ```
291    /// use ailloli_ui_core::{Color, Rect};
292    /// use ailloli_ui_runtime::scene::{DrawCmd, DrawRect, PaintCtx};
293    /// let mut ctx = PaintCtx::new();
294    /// let clip = Rect::new(10.0, 10.0, 20.0, 20.0);
295    /// ctx.with_overlay_clip(clip, |ctx| ctx.push_overlay(DrawCmd::Rect(DrawRect {
296    ///     rect: clip, color: Color::WHITE,
297    /// })));
298    /// assert_eq!(ctx.overlay_layers[0].clip.scissor_rect(), Some(clip));
299    /// ```
300    pub fn with_overlay_clip(&mut self, clip: Rect, f: impl FnOnce(&mut Self)) {
301        let entered_from_default_target = self.overlay_target_stack.is_empty();
302        let idx = self.overlay_layers.len();
303        self.overlay_layers
304            .push(Layer::overlay_with_clip(Some(ClipShape::Rect(clip)), false));
305        self.overlay_target_stack.push(idx);
306        f(self);
307        self.overlay_target_stack.pop();
308        if entered_from_default_target {
309            // Preserve procedural paint order. The next unscoped overlay must
310            // be appended after this clipped scope, never into its clip layer.
311            self.default_overlay_layer = None;
312        }
313    }
314
315    /// Resolves the destination base-layer index for a new command.
316    fn push_target_layer_index(&self) -> usize {
317        self.isolated_scope_stack
318            .last()
319            .copied()
320            .unwrap_or_else(|| self.layers.len() - 1)
321    }
322
323    /// Resolves or lazily creates the destination overlay-layer index.
324    fn overlay_target_layer_index(&mut self) -> usize {
325        if let Some(idx) = self.overlay_target_stack.last().copied() {
326            return idx;
327        }
328        if let Some(idx) = self.default_overlay_layer {
329            return idx;
330        }
331        let idx = self.overlay_layers.len();
332        self.overlay_layers
333            .push(Layer::overlay(ClipStackSnapshot::empty()));
334        self.default_overlay_layer = Some(idx);
335        idx
336    }
337
338    /// Runs `f` with an additional nested rectangular clip and base layer.
339    ///
340    /// The full ancestor stack is retained. A new base pass is started both on
341    /// entry and normal exit; empty passes are removed by [`Self::into_scene`].
342    ///
343    /// # Panics
344    ///
345    /// A panic in `f` propagates before the clip is popped, so a caught unwind
346    /// leaves this context unbalanced.
347    ///
348    /// # Examples
349    ///
350    /// ```
351    /// use ailloli_ui_core::{Color, Rect};
352    /// use ailloli_ui_runtime::scene::{DrawCmd, DrawRect, PaintCtx};
353    /// let mut ctx = PaintCtx::new();
354    /// let clip = Rect::new(0.0, 0.0, 10.0, 10.0);
355    /// ctx.with_clip(clip, |ctx| ctx.push(DrawCmd::Rect(DrawRect { rect: clip, color: Color::WHITE })));
356    /// let scene = ctx.into_scene();
357    /// assert_eq!(scene.layers[0].clip.scissor_rect(), Some(clip));
358    /// ```
359    pub fn with_clip(&mut self, clip: Rect, f: impl FnOnce(&mut Self)) {
360        self.with_clip_shape(ClipShape::Rect(clip), false, f);
361    }
362
363    /// Pushes a **single** rectangular clip for a new layer without inheriting the stack.
364    ///
365    /// Intersects `clip` with the current parent scissor when present. Useful for
366    /// widget-local viewports that need a "fresh" clip stack (e.g. embedded
367    /// surfaces, off-screen viewports) so they don't re-apply expensive ancestor
368    /// masks (round mask, stencil) on every contained draw. Most widgets should
369    /// prefer [`Self::with_clip`] which keeps the full ancestor stack.
370    ///
371    /// This is implemented as an isolated offscreen layer with no effects. If
372    /// the requested rectangle does not intersect a parent scissor, the current
373    /// compatibility behavior falls back to the requested rectangle rather
374    /// than representing an empty clip.
375    ///
376    /// # Panics
377    ///
378    /// A panic in `f` propagates before the saved stack and snapshot are
379    /// restored; a caught unwind leaves the context unsuitable for reuse.
380    ///
381    /// # Examples
382    ///
383    /// ```
384    /// use ailloli_ui_core::{Color, Rect};
385    /// use ailloli_ui_runtime::scene::{DrawCmd, DrawRect, PaintCtx};
386    /// let mut ctx = PaintCtx::new();
387    /// let clip = Rect::new(2.0, 3.0, 8.0, 9.0);
388    /// ctx.with_clip_isolated(clip, |ctx| ctx.push(DrawCmd::Rect(DrawRect { rect: clip, color: Color::WHITE })));
389    /// assert!(ctx.into_scene().layers[0].isolated);
390    /// ```
391    pub fn with_clip_isolated(&mut self, clip: Rect, f: impl FnOnce(&mut Self)) {
392        self.with_isolated_effects(clip, IsolatedEffects::default(), f);
393    }
394
395    /// Runs `f` in a fresh clipped offscreen layer with post-effects.
396    ///
397    /// The requested logical-pixel clip is intersected with the parent's coarse
398    /// scissor when they overlap; disjoint rectangles currently fall back to
399    /// the requested clip. Nested depth starts at zero and is cast to `u8`, so
400    /// more than 256 nested scopes wrap the stored diagnostic depth.
401    ///
402    /// # Panics
403    ///
404    /// A panic in `f` prevents stack restoration. Public mutation of `layers`
405    /// inside `f` can also invalidate the recorded isolated index.
406    ///
407    /// # Examples
408    ///
409    /// ```
410    /// use ailloli_ui_core::{Color, Rect};
411    /// use ailloli_ui_runtime::scene::{DrawCmd, DrawRect, IsolatedEffects, PaintCtx};
412    /// let mut ctx = PaintCtx::new();
413    /// let clip = Rect::new(0.0, 0.0, 20.0, 20.0);
414    /// ctx.with_isolated_effects(clip, IsolatedEffects { opacity: 0.5, ..Default::default() },
415    ///     |ctx| ctx.push(DrawCmd::Rect(DrawRect { rect: clip, color: Color::WHITE })));
416    /// assert_eq!(ctx.into_scene().layers[0].effects.opacity, 0.5);
417    /// ```
418    pub fn with_isolated_effects(
419        &mut self,
420        clip: Rect,
421        effects: IsolatedEffects,
422        f: impl FnOnce(&mut Self),
423    ) {
424        let clip = self
425            .clip
426            .scissor_rect()
427            .and_then(|parent| clip.intersection(parent))
428            .unwrap_or(clip);
429        let saved_stack = std::mem::replace(&mut self.clip_stack, ClipStack::new());
430        let saved_clip = self.clip.clone();
431        self.clip_stack.push(ClipShape::Rect(clip), false);
432        self.clip = self.clip_stack.snapshot();
433        self.start_isolated_pass(effects);
434        f(self);
435        self.clip_stack = saved_stack;
436        self.clip = saved_clip;
437        self.end_isolated_scope();
438    }
439
440    /// Runs `f` with one additional clip shape and base-layer pass.
441    ///
442    /// `is_window_root` is preserved on the clip entry for downstream surface
443    /// handling. The active clip snapshot and stack are restored on normal
444    /// return, and a new pass is opened after the scope.
445    ///
446    /// # Panics
447    ///
448    /// A panic in `f` propagates without restoring the clip stack.
449    ///
450    /// # Examples
451    ///
452    /// ```
453    /// use ailloli_ui_core::{ClipShape, Color, Rect};
454    /// use ailloli_ui_runtime::scene::{DrawCmd, DrawRect, PaintCtx};
455    /// let mut ctx = PaintCtx::new();
456    /// let rect = Rect::new(0.0, 0.0, 20.0, 10.0);
457    /// ctx.with_clip_shape(ClipShape::Rect(rect), true, |ctx| {
458    ///     ctx.push(DrawCmd::Rect(DrawRect { rect, color: Color::WHITE }));
459    /// });
460    /// assert!(ctx.into_scene().layers[0].clip.entries()[0].is_window_root);
461    /// ```
462    pub fn with_clip_shape(
463        &mut self,
464        clip: ClipShape,
465        is_window_root: bool,
466        f: impl FnOnce(&mut Self),
467    ) {
468        self.clip_stack.push(clip, is_window_root);
469        self.clip = self.clip_stack.snapshot();
470        self.start_new_pass();
471        f(self);
472        self.clip_stack.pop();
473        self.clip = self.clip_stack.snapshot();
474        self.start_new_pass();
475    }
476
477    /// Clones the active clip snapshot.
478    ///
479    /// The returned snapshot remains unchanged after later scope transitions.
480    ///
481    /// # Examples
482    ///
483    /// ```
484    /// use ailloli_ui_runtime::scene::PaintCtx;
485    /// assert!(PaintCtx::new().current_clip().is_empty());
486    /// ```
487    pub fn current_clip(&self) -> ClipStackSnapshot {
488        self.clip.clone()
489    }
490
491    /// Returns the coarse axis-aligned intersection of active clip bounds.
492    ///
493    /// `None` means no active clip or a disjoint stack.
494    ///
495    /// # Examples
496    ///
497    /// ```
498    /// use ailloli_ui_runtime::scene::PaintCtx;
499    /// assert_eq!(PaintCtx::new().current_clip_bbox(), None);
500    /// ```
501    pub fn current_clip_bbox(&self) -> Option<Rect> {
502        self.clip.scissor_rect()
503    }
504
505    /// Replaces paint-time interaction flags and returns the previous value.
506    ///
507    /// This does not invalidate any element; the paint traversal uses it to
508    /// scope a widget's focused, hovered, and pressed state.
509    ///
510    /// # Examples
511    ///
512    /// ```
513    /// use ailloli_ui_runtime::input::InputInteraction;
514    /// use ailloli_ui_runtime::scene::PaintCtx;
515    /// let mut ctx = PaintCtx::new();
516    /// let previous = ctx.set_current_interaction(InputInteraction { hovered: true, ..Default::default() });
517    /// assert!(!previous.hovered && ctx.is_hovered());
518    /// ```
519    pub fn set_current_interaction(&mut self, interaction: InputInteraction) -> InputInteraction {
520        let previous = self.current_interaction;
521        self.current_interaction = interaction;
522        previous
523    }
524
525    /// Returns all current paint-time interaction flags by value.
526    ///
527    /// # Examples
528    ///
529    /// ```
530    /// use ailloli_ui_runtime::scene::PaintCtx;
531    /// assert_eq!(PaintCtx::new().interaction(), Default::default());
532    /// ```
533    pub fn interaction(&self) -> InputInteraction {
534        self.current_interaction
535    }
536
537    /// Returns whether the currently painted widget owns keyboard focus.
538    ///
539    /// # Examples
540    ///
541    /// ```
542    /// use ailloli_ui_runtime::scene::PaintCtx;
543    /// assert!(!PaintCtx::new().is_focused());
544    /// ```
545    pub fn is_focused(&self) -> bool {
546        self.current_interaction.focused
547    }
548
549    /// Whether this element or a descendant owns keyboard focus.
550    ///
551    /// # Examples
552    ///
553    /// ```
554    /// use ailloli_ui_runtime::scene::PaintCtx;
555    /// assert!(!PaintCtx::new().has_focus_within());
556    /// ```
557    pub fn has_focus_within(&self) -> bool {
558        self.current_interaction.focus_within
559    }
560
561    /// Returns whether the pointer currently hovers the painted widget.
562    ///
563    /// # Examples
564    ///
565    /// ```
566    /// use ailloli_ui_runtime::scene::PaintCtx;
567    /// assert!(!PaintCtx::new().is_hovered());
568    /// ```
569    pub fn is_hovered(&self) -> bool {
570        self.current_interaction.hovered
571    }
572
573    /// Returns whether the painted widget owns an active pointer press.
574    ///
575    /// # Examples
576    ///
577    /// ```
578    /// use ailloli_ui_runtime::scene::PaintCtx;
579    /// assert!(!PaintCtx::new().is_pressed());
580    /// ```
581    pub fn is_pressed(&self) -> bool {
582        self.current_interaction.pressed
583    }
584
585    /// Returns the opaque host frame timestamp in milliseconds.
586    ///
587    /// A context created without explicit input uses zero. This is not elapsed
588    /// time and arithmetic is the caller's responsibility.
589    ///
590    /// # Examples
591    ///
592    /// ```
593    /// use ailloli_ui_runtime::scene::PaintCtx;
594    /// assert_eq!(PaintCtx::new().frame_time_ms(), 0);
595    /// ```
596    pub fn frame_time_ms(&self) -> u128 {
597        self.frame_time_ms
598    }
599
600    /// Returns the copyable input snapshot used to derive widget interaction.
601    ///
602    /// This is crate-visible because widgets should use the focused/hovered/
603    /// pressed accessors rather than depend on router internals.
604    ///
605    /// # Examples
606    ///
607    /// ```
608    /// use ailloli_ui_runtime::scene::PaintCtx;
609    /// let ctx = PaintCtx::new();
610    /// assert!(!ctx.is_focused() && !ctx.is_hovered());
611    /// ```
612    pub(crate) fn input_snapshot(&self) -> InputSnapshot {
613        self.input
614    }
615
616    /// Opens an empty base layer carrying the active clip snapshot.
617    fn start_new_pass(&mut self) {
618        self.layers.push(Layer::base(self.clip.clone()));
619    }
620
621    /// Opens an isolated base layer and records it as the current push target.
622    fn start_isolated_pass(&mut self, effects: IsolatedEffects) {
623        let depth = self.isolated_scope_stack.len() as u8;
624        self.layers
625            .push(Layer::isolated_base(self.clip.clone(), effects, depth));
626        let layer_index = self.layers.len() - 1;
627        self.isolated_scope_stack.push(layer_index);
628    }
629
630    /// Closes one isolated target and starts a base pass after the outermost scope.
631    fn end_isolated_scope(&mut self) {
632        self.isolated_scope_stack.pop();
633        if self.isolated_scope_stack.is_empty() {
634            self.start_new_pass();
635        }
636    }
637
638    /// Consumes the context and returns non-empty layers in final paint order.
639    ///
640    /// Empty base and overlay layers are removed. Remaining base layers come
641    /// first with `kind` normalized to [`LayerKind::Base`], followed by every
642    /// overlay layer normalized to [`LayerKind::Overlay`]. Command order within
643    /// each layer is unchanged.
644    ///
645    /// # Examples
646    ///
647    /// ```
648    /// use ailloli_ui_core::{Color, Rect};
649    /// use ailloli_ui_runtime::scene::{DrawCmd, DrawRect, LayerKind, PaintCtx};
650    /// let mut ctx = PaintCtx::new();
651    /// ctx.push_overlay(DrawCmd::Rect(DrawRect { rect: Rect::new(0.0, 0.0, 1.0, 1.0), color: Color::WHITE }));
652    /// let scene = ctx.into_scene();
653    /// assert_eq!(scene.layers.len(), 1);
654    /// assert_eq!(scene.layers[0].kind, LayerKind::Overlay);
655    /// ```
656    pub fn into_scene(mut self) -> Scene {
657        self.layers.retain(|l| !l.cmds.is_empty());
658        self.overlay_layers.retain(|l| !l.cmds.is_empty());
659        let mut scene = Scene::default();
660        for mut l in self.layers {
661            l.kind = LayerKind::Base;
662            scene.push_layer(l);
663        }
664        for mut l in self.overlay_layers {
665            l.kind = LayerKind::Overlay;
666            scene.push_layer(l);
667        }
668        scene
669    }
670}
671
672/// Minimal command sink accepted by legacy paint APIs.
673///
674/// Implementations decide how optional clips are represented. The built-in
675/// [`PaintCtx`] implementation opens a nested rectangular clip layer.
676///
677/// # Examples
678///
679/// ```
680/// use ailloli_ui_core::{Color, Rect};
681/// use ailloli_ui_runtime::scene::{DrawCmd, DrawRect, PaintCtx, Painter};
682/// let mut painter = PaintCtx::new();
683/// Painter::push(&mut painter, DrawCmd::Rect(DrawRect {
684///     rect: Rect::new(0.0, 0.0, 2.0, 2.0), color: Color::WHITE,
685/// }));
686/// assert_eq!(painter.layers[0].cmds.len(), 1);
687/// ```
688pub trait Painter {
689    /// Submits one command without adding a clip.
690    ///
691    /// # Examples
692    ///
693    /// ```
694    /// use ailloli_ui_core::{Color, Rect};
695    /// use ailloli_ui_runtime::scene::{DrawCmd, DrawRect, PaintCtx, Painter};
696    /// let mut painter = PaintCtx::new();
697    /// painter.push(DrawCmd::Rect(DrawRect { rect: Rect::new(0.0, 0.0, 1.0, 1.0), color: Color::WHITE }));
698    /// assert_eq!(painter.layers[0].cmds.len(), 1);
699    /// ```
700    fn push(&mut self, cmd: DrawCmd);
701
702    /// Submits one command under `clip`, or unclipped when it is `None`.
703    ///
704    /// # Examples
705    ///
706    /// ```
707    /// use ailloli_ui_core::{Color, Rect};
708    /// use ailloli_ui_runtime::scene::{DrawCmd, DrawRect, PaintCtx, Painter};
709    /// let mut painter = PaintCtx::new();
710    /// let clip = Rect::new(0.0, 0.0, 3.0, 3.0);
711    /// painter.push_clipped(DrawCmd::Rect(DrawRect { rect: clip, color: Color::WHITE }), Some(clip));
712    /// assert_eq!(painter.into_scene().layers[0].clip.scissor_rect(), Some(clip));
713    /// ```
714    fn push_clipped(&mut self, cmd: DrawCmd, clip: Option<Rect>);
715}
716
717/// Implements the `Painter` contract for `PaintContext<'a>`.
718impl<'a> Painter for PaintContext<'a> {
719    /// Implements the push helper used by this module.
720    fn push(&mut self, cmd: DrawCmd) {
721        PaintContext::push(self, cmd);
722    }
723
724    /// Implements the push_clipped helper used by this module.
725    fn push_clipped(&mut self, cmd: DrawCmd, clip: Option<Rect>) {
726        if let Some(c) = clip {
727            self.with_clip(c, |ctx| ctx.push(cmd));
728        } else {
729            self.push(cmd);
730        }
731    }
732}
733
734#[cfg(test)]
735/// Tests implementation details.
736mod tests {
737    use super::*;
738    use ailloli_ui_core::{ClipShape, Color, Rect};
739
740    /// Implements the rect_cmd helper used by this module.
741    fn rect_cmd(rect: Rect) -> DrawCmd {
742        DrawCmd::Rect(crate::DrawRect {
743            rect,
744            color: Color::WHITE,
745        })
746    }
747
748    #[test]
749    /// Implements the isolated_clip_does_not_inherit_parent_stack helper used by this module.
750    fn isolated_clip_does_not_inherit_parent_stack() {
751        let root = ClipShape::RoundRect {
752            rect: Rect::new(0.0, 0.0, 100.0, 80.0),
753            radius: 12.0,
754        };
755        let inner = Rect::new(10.0, 10.0, 40.0, 20.0);
756        let mut ctx = PaintCtx::new();
757
758        ctx.with_clip_shape(root, true, |ctx| {
759            ctx.with_clip_isolated(inner, |ctx| {
760                ctx.push(rect_cmd(Rect::new(12.0, 12.0, 8.0, 8.0)));
761            });
762        });
763
764        let scene = ctx.into_scene();
765        assert_eq!(scene.layers.len(), 1);
766        assert_eq!(
767            scene.layers[0].clip.entries(),
768            &[crate::scene::ClipEntry::new(ClipShape::Rect(inner), false)]
769        );
770    }
771
772    #[test]
773    /// Verifies that nested isolated parent cmds stay on parent layer.
774    fn nested_isolated_parent_cmds_stay_on_parent_layer() {
775        let outer = Rect::new(0.0, 0.0, 100.0, 100.0);
776        let inner = Rect::new(10.0, 10.0, 30.0, 30.0);
777        let mut ctx = PaintCtx::new();
778
779        ctx.with_isolated_effects(outer, IsolatedEffects::default(), |ctx| {
780            ctx.with_isolated_effects(inner, IsolatedEffects::default(), |ctx| {
781                ctx.push(rect_cmd(Rect::new(12.0, 12.0, 8.0, 8.0)));
782            });
783            ctx.push(rect_cmd(Rect::new(50.0, 50.0, 8.0, 8.0)));
784        });
785
786        let scene = ctx.into_scene();
787        assert_eq!(scene.layers.len(), 2);
788        assert!(scene.layers[0].isolated);
789        assert_eq!(scene.layers[0].isolated_depth, 0);
790        assert_eq!(scene.layers[0].cmds.len(), 1);
791        assert!(scene.layers[1].isolated);
792        assert_eq!(scene.layers[1].isolated_depth, 1);
793        assert_eq!(scene.layers[1].cmds.len(), 1);
794    }
795
796    #[test]
797    /// Verifies that nested clip restores window root snapshot.
798    fn nested_clip_restores_window_root_snapshot() {
799        let root = ClipShape::RoundRect {
800            rect: Rect::new(0.0, 0.0, 100.0, 80.0),
801            radius: 12.0,
802        };
803        let inner = Rect::new(10.0, 10.0, 40.0, 20.0);
804        let mut ctx = PaintCtx::new();
805
806        ctx.with_clip_shape(root, true, |ctx| {
807            ctx.push(rect_cmd(Rect::new(0.0, 0.0, 100.0, 80.0)));
808            ctx.with_clip(inner, |ctx| {
809                ctx.push(rect_cmd(Rect::new(0.0, 0.0, 100.0, 80.0)));
810            });
811            ctx.push(rect_cmd(Rect::new(0.0, 60.0, 20.0, 10.0)));
812        });
813
814        let scene = ctx.into_scene();
815
816        assert_eq!(scene.layers.len(), 3);
817        assert_eq!(
818            scene.layers[0].clip.entries(),
819            &[crate::scene::ClipEntry::new(root, true)]
820        );
821        assert_eq!(
822            scene.layers[1].clip.entries(),
823            &[
824                crate::scene::ClipEntry::new(root, true),
825                crate::scene::ClipEntry::new(ClipShape::Rect(inner), false)
826            ]
827        );
828        assert_eq!(
829            scene.layers[2].clip.entries(),
830            &[crate::scene::ClipEntry::new(root, true)]
831        );
832    }
833
834    #[test]
835    /// Verifies that overlay layers are appended after base layers.
836    fn overlay_layers_are_appended_after_base_layers() {
837        let mut ctx = PaintCtx::new();
838
839        ctx.push(rect_cmd(Rect::new(0.0, 0.0, 10.0, 10.0)));
840        ctx.push_overlay(rect_cmd(Rect::new(1.0, 1.0, 8.0, 8.0)));
841
842        let scene = ctx.into_scene();
843
844        assert_eq!(scene.layers.len(), 2);
845        assert_eq!(scene.layers[0].kind, LayerKind::Base);
846        assert_eq!(scene.layers[1].kind, LayerKind::Overlay);
847    }
848
849    #[test]
850    /// Verifies that overlay clip creates top level overlay clip.
851    fn overlay_clip_creates_top_level_overlay_clip() {
852        let clip = Rect::new(10.0, 12.0, 100.0, 80.0);
853        let mut ctx = PaintCtx::new();
854
855        ctx.with_overlay_clip(clip, |ctx| {
856            ctx.push_overlay(rect_cmd(Rect::new(20.0, 24.0, 10.0, 10.0)));
857        });
858
859        let scene = ctx.into_scene();
860
861        assert_eq!(scene.layers.len(), 1);
862        assert_eq!(scene.layers[0].kind, LayerKind::Overlay);
863        assert_eq!(
864            scene.layers[0].clip.entries(),
865            &[crate::scene::ClipEntry::new(ClipShape::Rect(clip), false)]
866        );
867    }
868
869    #[test]
870    /// Verifies that unscoped overlay after clipped scope uses a fresh unclipped layer.
871    fn unscoped_overlay_after_clipped_scope_uses_a_fresh_unclipped_layer() {
872        let clip = Rect::new(10.0, 12.0, 100.0, 80.0);
873        let mut ctx = PaintCtx::new();
874
875        ctx.push_overlay(rect_cmd(Rect::new(0.0, 0.0, 8.0, 8.0)));
876        ctx.with_overlay_clip(clip, |ctx| {
877            ctx.push_overlay(rect_cmd(Rect::new(20.0, 24.0, 10.0, 10.0)));
878        });
879        ctx.push_overlay(rect_cmd(Rect::new(150.0, 140.0, 12.0, 12.0)));
880
881        let scene = ctx.into_scene();
882
883        assert_eq!(scene.layers.len(), 3);
884        assert!(scene.layers[0].clip.is_empty());
885        assert_eq!(
886            scene.layers[1].clip.entries(),
887            &[crate::scene::ClipEntry::new(ClipShape::Rect(clip), false)]
888        );
889        assert!(scene.layers[2].clip.is_empty());
890        assert!(scene.layers.iter().all(|layer| layer.cmds.len() == 1));
891    }
892}