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}