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}