ailloli_ui_runtime/scene/
clip_stack.rs

1//! Ordered clip-stack state and snapshots used during scene construction.
2
3use ailloli_ui_core::{ClipShape, Rect};
4
5/// One clip pushed by layout or paint, preserving metadata that must not be fused away.
6///
7/// Shapes use window-space logical pixels once captured by a paint layer.
8/// `is_window_root` remains attached even when rectangular bounds could be
9/// intersected, allowing renderers to preserve surface-specific semantics.
10///
11/// # Examples
12///
13/// ```
14/// use ailloli_ui_core::{ClipShape, Rect};
15/// use ailloli_ui_runtime::scene::ClipEntry;
16///
17/// let entry = ClipEntry::new(ClipShape::Rect(Rect::new(0.0, 0.0, 40.0, 20.0)), true);
18/// assert!(entry.is_window_root);
19/// ```
20#[derive(Debug, Clone, Copy, PartialEq)]
21pub struct ClipEntry {
22    /// Geometric clip shape in logical pixels.
23    pub shape: ClipShape,
24    /// Whether this clip represents the root surface/window boundary.
25    pub is_window_root: bool,
26}
27
28/// Provides the operations defined for ClipEntry.
29impl ClipEntry {
30    /// Creates an entry without validating or normalizing the shape.
31    ///
32    /// # Examples
33    ///
34    /// ```
35    /// use ailloli_ui_core::{ClipShape, Rect};
36    /// use ailloli_ui_runtime::scene::ClipEntry;
37    ///
38    /// let entry = ClipEntry::new(ClipShape::Rect(Rect::new(1.0, 2.0, 3.0, 4.0)), false);
39    /// assert!(!entry.is_window_root);
40    /// ```
41    pub fn new(shape: ClipShape, is_window_root: bool) -> Self {
42        Self {
43            shape,
44            is_window_root,
45        }
46    }
47}
48
49/// Immutable view of the active clip stack for one paint layer.
50///
51/// Entry order is outermost to innermost. Snapshots own a cloned vector and do
52/// not change if the originating [`ClipStack`] is later mutated.
53///
54/// # Examples
55///
56/// ```
57/// use ailloli_ui_runtime::scene::ClipStackSnapshot;
58/// let snapshot = ClipStackSnapshot::empty();
59/// assert!(snapshot.entries().is_empty());
60/// ```
61#[derive(Debug, Default, Clone, PartialEq)]
62pub struct ClipStackSnapshot {
63    /// Owned outermost-to-innermost clip entries captured from a stack.
64    entries: Vec<ClipEntry>,
65}
66
67/// Provides the operations defined for ClipStackSnapshot.
68impl ClipStackSnapshot {
69    /// Returns a snapshot containing no clipping constraints.
70    ///
71    /// # Examples
72    ///
73    /// ```
74    /// use ailloli_ui_runtime::scene::ClipStackSnapshot;
75    /// assert!(ClipStackSnapshot::empty().is_empty());
76    /// ```
77    pub fn empty() -> Self {
78        Self {
79            entries: Vec::new(),
80        }
81    }
82
83    /// Takes ownership of entries in their existing outer-to-inner order.
84    ///
85    /// Empty, duplicate, and mutually disjoint shapes are accepted.
86    ///
87    /// # Examples
88    ///
89    /// ```
90    /// use ailloli_ui_core::{ClipShape, Rect};
91    /// use ailloli_ui_runtime::scene::{ClipEntry, ClipStackSnapshot};
92    /// let entry = ClipEntry::new(ClipShape::Rect(Rect::new(0.0, 0.0, 10.0, 10.0)), false);
93    /// assert_eq!(ClipStackSnapshot::from_entries(vec![entry]).entries(), &[entry]);
94    /// ```
95    pub fn from_entries(entries: Vec<ClipEntry>) -> Self {
96        Self { entries }
97    }
98
99    /// Builds an empty or single-entry snapshot from an optional shape.
100    ///
101    /// `is_window_root` is ignored when `clip` is `None`.
102    ///
103    /// # Examples
104    ///
105    /// ```
106    /// use ailloli_ui_runtime::scene::ClipStackSnapshot;
107    /// assert!(ClipStackSnapshot::from_clip(None, true).is_empty());
108    /// ```
109    pub fn from_clip(clip: Option<ClipShape>, is_window_root: bool) -> Self {
110        match clip {
111            Some(shape) => Self::from_entries(vec![ClipEntry::new(shape, is_window_root)]),
112            None => Self::empty(),
113        }
114    }
115
116    /// Borrows entries in outer-to-inner order without allocating.
117    ///
118    /// # Examples
119    ///
120    /// ```
121    /// use ailloli_ui_core::{ClipShape, Rect};
122    /// use ailloli_ui_runtime::scene::ClipStackSnapshot;
123    /// let snapshot = ClipStackSnapshot::from_clip(
124    ///     Some(ClipShape::Rect(Rect::new(0.0, 0.0, 5.0, 6.0))), false,
125    /// );
126    /// assert_eq!(snapshot.entries().len(), 1);
127    /// ```
128    pub fn entries(&self) -> &[ClipEntry] {
129        &self.entries
130    }
131
132    /// Returns `true` exactly when there are no clip entries.
133    ///
134    /// # Examples
135    ///
136    /// ```
137    /// use ailloli_ui_runtime::scene::ClipStackSnapshot;
138    /// assert!(ClipStackSnapshot::default().is_empty());
139    /// ```
140    pub fn is_empty(&self) -> bool {
141        self.entries.is_empty()
142    }
143
144    /// Returns the axis-aligned intersection of all clip bounding rectangles.
145    ///
146    /// The result is used as a coarse GPU scissor; it does not preserve rounded
147    /// corners. `None` means either no clip or an empty intersection, so callers
148    /// needing to distinguish those cases must also inspect [`Self::is_empty`].
149    ///
150    /// # Examples
151    ///
152    /// ```
153    /// use ailloli_ui_core::{ClipShape, Rect};
154    /// use ailloli_ui_runtime::scene::{ClipEntry, ClipStackSnapshot};
155    /// let snapshot = ClipStackSnapshot::from_entries(vec![
156    ///     ClipEntry::new(ClipShape::Rect(Rect::new(0.0, 0.0, 10.0, 10.0)), false),
157    ///     ClipEntry::new(ClipShape::Rect(Rect::new(5.0, 2.0, 10.0, 4.0)), false),
158    /// ]);
159    /// assert_eq!(snapshot.scissor_rect(), Some(Rect::new(5.0, 2.0, 5.0, 4.0)));
160    /// ```
161    pub fn scissor_rect(&self) -> Option<Rect> {
162        let mut it = self.entries.iter().map(|entry| entry.shape.bounding_rect());
163        let mut acc = it.next()?;
164        for rect in it {
165            acc = acc.intersection(rect)?;
166        }
167        Some(acc)
168    }
169
170    /// Returns one representable clip shape for legacy callers.
171    ///
172    /// Empty snapshots return `None`, one entry preserves its exact shape, and
173    /// two or more rectangles collapse to their intersection. Any multi-entry
174    /// stack containing a rounded rectangle returns `None` because flattening
175    /// it would lose shape or root metadata.
176    ///
177    /// # Examples
178    ///
179    /// ```
180    /// use ailloli_ui_core::{ClipShape, Rect};
181    /// use ailloli_ui_runtime::scene::ClipStackSnapshot;
182    /// let shape = ClipShape::RoundRect { rect: Rect::new(0.0, 0.0, 20.0, 10.0), radius: 3.0 };
183    /// assert_eq!(ClipStackSnapshot::from_clip(Some(shape), true).single_shape(), Some(shape));
184    /// ```
185    pub fn single_shape(&self) -> Option<ClipShape> {
186        match self.entries.as_slice() {
187            [] => None,
188            [entry] => Some(entry.shape),
189            entries
190                if entries
191                    .iter()
192                    .all(|entry| matches!(entry.shape, ClipShape::Rect(_))) =>
193            {
194                self.scissor_rect().map(ClipShape::Rect)
195            }
196            _ => None,
197        }
198    }
199
200    /// Tests whether every clip shape contains a logical-pixel point.
201    ///
202    /// An empty snapshot contains every point (vacuous intersection). Shape
203    /// boundary and non-finite-coordinate behavior comes from [`ClipShape`].
204    ///
205    /// # Examples
206    ///
207    /// ```
208    /// use ailloli_ui_core::{ClipShape, Rect};
209    /// use ailloli_ui_runtime::scene::ClipStackSnapshot;
210    /// let snapshot = ClipStackSnapshot::from_clip(
211    ///     Some(ClipShape::Rect(Rect::new(0.0, 0.0, 10.0, 10.0))), false,
212    /// );
213    /// assert!(snapshot.contains_point(4.0, 5.0));
214    /// assert!(!snapshot.contains_point(14.0, 5.0));
215    /// ```
216    pub fn contains_point(&self, px: f32, py: f32) -> bool {
217        self.entries
218            .iter()
219            .all(|entry| entry.shape.contains_point(px, py))
220    }
221}
222
223/// Mutable hierarchical clip stack for paint passes.
224///
225/// Entries are last-in, first-out; this type performs no automatic scope
226/// restoration. Use [`crate::scene::PaintCtx`] helpers when closure-based
227/// restoration is required.
228///
229/// # Examples
230///
231/// ```
232/// use ailloli_ui_core::{ClipShape, Rect};
233/// use ailloli_ui_runtime::scene::ClipStack;
234/// let mut stack = ClipStack::new();
235/// stack.push(ClipShape::Rect(Rect::new(0.0, 0.0, 10.0, 10.0)), false);
236/// assert_eq!(stack.snapshot().entries().len(), 1);
237/// ```
238#[derive(Debug, Default, Clone)]
239pub struct ClipStack {
240    /// Mutable outermost-to-innermost clip entries active during painting.
241    clips: Vec<ClipEntry>,
242}
243
244/// Provides the operations defined for ClipStack.
245impl ClipStack {
246    /// Creates an empty stack.
247    ///
248    /// # Examples
249    ///
250    /// ```
251    /// use ailloli_ui_runtime::scene::ClipStack;
252    /// assert!(ClipStack::new().is_empty());
253    /// ```
254    pub fn new() -> Self {
255        Self { clips: Vec::new() }
256    }
257
258    /// Returns `true` exactly when the stack contains no entries.
259    ///
260    /// # Examples
261    ///
262    /// ```
263    /// use ailloli_ui_runtime::scene::ClipStack;
264    /// assert!(ClipStack::default().is_empty());
265    /// ```
266    pub fn is_empty(&self) -> bool {
267        self.clips.is_empty()
268    }
269
270    /// Pushes an unvalidated clip as the new innermost entry.
271    ///
272    /// # Examples
273    ///
274    /// ```
275    /// use ailloli_ui_core::{ClipShape, Rect};
276    /// use ailloli_ui_runtime::scene::ClipStack;
277    /// let mut stack = ClipStack::new();
278    /// stack.push(ClipShape::Rect(Rect::new(0.0, 0.0, 8.0, 8.0)), true);
279    /// assert!(stack.snapshot().entries()[0].is_window_root);
280    /// ```
281    pub fn push(&mut self, clip: ClipShape, is_window_root: bool) {
282        self.clips.push(ClipEntry::new(clip, is_window_root));
283    }
284
285    /// Removes and returns the innermost entry, or `None` when empty.
286    ///
287    /// # Examples
288    ///
289    /// ```
290    /// use ailloli_ui_runtime::scene::ClipStack;
291    /// let mut stack = ClipStack::new();
292    /// assert_eq!(stack.pop(), None);
293    /// ```
294    pub fn pop(&mut self) -> Option<ClipEntry> {
295        self.clips.pop()
296    }
297
298    /// Clones the current entries into an immutable snapshot.
299    ///
300    /// # Examples
301    ///
302    /// ```
303    /// use ailloli_ui_core::{ClipShape, Rect};
304    /// use ailloli_ui_runtime::scene::ClipStack;
305    /// let mut stack = ClipStack::new();
306    /// stack.push(ClipShape::Rect(Rect::new(0.0, 0.0, 2.0, 2.0)), false);
307    /// let snapshot = stack.snapshot();
308    /// stack.pop();
309    /// assert_eq!(snapshot.entries().len(), 1);
310    /// ```
311    pub fn snapshot(&self) -> ClipStackSnapshot {
312        ClipStackSnapshot::from_entries(self.clips.clone())
313    }
314
315    /// Returns a legacy single-shape representation of the current stack.
316    ///
317    /// See [`ClipStackSnapshot::single_shape`] for lossy-stack rules.
318    ///
319    /// # Examples
320    ///
321    /// ```
322    /// use ailloli_ui_runtime::scene::ClipStack;
323    /// assert_eq!(ClipStack::new().current(), None);
324    /// ```
325    pub fn current(&self) -> Option<ClipShape> {
326        self.snapshot().single_shape()
327    }
328
329    /// Returns axis-aligned intersection bounds for the current clip stack.
330    ///
331    /// `None` represents both an empty stack and disjoint clip bounds; use
332    /// [`Self::is_empty`] when that distinction matters.
333    ///
334    /// # Examples
335    ///
336    /// ```
337    /// use ailloli_ui_core::{ClipShape, Rect};
338    /// use ailloli_ui_runtime::scene::ClipStack;
339    /// let mut stack = ClipStack::new();
340    /// stack.push(ClipShape::Rect(Rect::new(1.0, 2.0, 3.0, 4.0)), false);
341    /// assert_eq!(stack.current_bbox(), Some(Rect::new(1.0, 2.0, 3.0, 4.0)));
342    /// ```
343    pub fn current_bbox(&self) -> Option<Rect> {
344        self.snapshot().scissor_rect()
345    }
346}
347
348#[cfg(test)]
349/// Tests implementation details.
350mod tests {
351    use super::*;
352
353    #[test]
354    /// Verifies that current is none when empty.
355    fn current_is_none_when_empty() {
356        let stack = ClipStack::new();
357        assert_eq!(stack.current(), None);
358    }
359
360    #[test]
361    /// Verifies that current is intersection rect.
362    fn current_is_intersection_rect() {
363        let mut stack = ClipStack::new();
364        stack.push(ClipShape::Rect(Rect::new(0.0, 0.0, 10.0, 10.0)), false);
365        stack.push(ClipShape::Rect(Rect::new(5.0, 2.0, 10.0, 4.0)), false);
366        assert_eq!(
367            stack.current(),
368            Some(ClipShape::Rect(Rect::new(5.0, 2.0, 5.0, 4.0)))
369        );
370    }
371
372    #[test]
373    /// Implements the round_rect_and_rect_remain_separate_in_snapshot helper used by this module.
374    fn round_rect_and_rect_remain_separate_in_snapshot() {
375        let mut stack = ClipStack::new();
376        stack.push(
377            ClipShape::RoundRect {
378                rect: Rect::new(0.0, 0.0, 100.0, 80.0),
379                radius: 12.0,
380            },
381            true,
382        );
383        stack.push(ClipShape::Rect(Rect::new(10.0, 10.0, 40.0, 20.0)), false);
384
385        let snapshot = stack.snapshot();
386
387        assert_eq!(snapshot.entries().len(), 2);
388        assert!(snapshot.entries()[0].is_window_root);
389        assert_eq!(
390            snapshot.scissor_rect(),
391            Some(Rect::new(10.0, 10.0, 40.0, 20.0))
392        );
393        assert_eq!(snapshot.single_shape(), None);
394    }
395}