ailloli_ui_runtime/popup.rs
1//! Provider-neutral popup ownership and lifecycle.
2//!
3//! [`PopupPortal`](crate::popup::PopupPortal) is the runtime authority for
4//! popup identity, ordering, and dismissal. It intentionally does not choose
5//! an overlay or native-window backend: hosts consume
6//! [`PopupIntent`](crate::popup::PopupIntent) values and obtain popup content
7//! from the portal. This keeps widget APIs independent from a particular
8//! windowing provider.
9
10use std::collections::{HashMap, HashSet};
11use std::fmt;
12use std::rc::Rc;
13
14use ailloli_ui_core::{ElementId, LogicalWindowId, Point, Rect, Size};
15
16use crate::app::PresentationGeneration;
17use crate::component::View;
18
19/// Logical presentation used by direct/headless event routing.
20///
21/// Native adapters replace this owner metadata as soon as they dispatch an
22/// [`crate::input::EventEnvelope`]. Keeping the fallback in runtime (rather
23/// than in a widget crate) lets the input router enforce the same portal
24/// semantics in deterministic tests.
25///
26/// # Examples
27///
28/// ```
29/// use ailloli_ui_runtime::popup::HEADLESS_POPUP_WINDOW_ID;
30/// assert_eq!(HEADLESS_POPUP_WINDOW_ID, "__ailloli_headless__");
31/// ```
32pub const HEADLESS_POPUP_WINDOW_ID: &str = "__ailloli_headless__";
33
34/// Preferred vertical side of an anchored popup.
35///
36/// The selected side can differ from this preference when placement
37/// resolution is allowed to flip the popup to keep more of it in the
38/// viewport.
39///
40/// # Examples
41///
42/// ```
43/// use ailloli_ui_runtime::popup::PopupPlacement;
44/// assert_eq!(PopupPlacement::default(), PopupPlacement::Bottom);
45/// ```
46#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
47pub enum PopupPlacement {
48 /// Place the popup above its anchor before viewport correction.
49 Top,
50 /// Place the popup below its anchor before viewport correction.
51 #[default]
52 Bottom,
53}
54
55/// Private side-flipping helper used by placement resolution.
56impl PopupPlacement {
57 /// Returns the other vertical side.
58 const fn opposite(self) -> Self {
59 match self {
60 Self::Top => Self::Bottom,
61 Self::Bottom => Self::Top,
62 }
63 }
64}
65
66/// Cross-axis alignment of a popup relative to its anchor.
67///
68/// `Start` and `End` are logical leading and trailing edges. The current
69/// left-to-right geometry resolver maps them to left and right; a future
70/// direction-aware host can preserve this public contract while changing that
71/// mapping at the provider boundary.
72///
73/// # Examples
74///
75/// ```
76/// use ailloli_ui_runtime::popup::PopupAlignment;
77/// assert_eq!(PopupAlignment::default(), PopupAlignment::Center);
78/// assert_ne!(PopupAlignment::Start, PopupAlignment::End);
79/// ```
80#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
81pub enum PopupAlignment {
82 /// Align logical leading edges (currently left edges).
83 Start,
84 /// Center the popup on the anchor's cross axis.
85 #[default]
86 Center,
87 /// Align logical trailing edges (currently right edges).
88 End,
89}
90
91/// Provider-neutral placement requested before a host viewport is known.
92///
93/// The active host combines this semantic geometry with its viewport and
94/// backend capabilities through [`resolve_popup_placement`]. Keeping viewport
95/// data out of this value prevents widgets from substituting their own bounds
96/// for the complete presentation area.
97/// All geometry is in logical pixels and is stored without validation; portal
98/// publication or placement resolution performs validation.
99///
100/// # Examples
101///
102/// ```
103/// use ailloli_ui_core::{Rect, Size};
104/// use ailloli_ui_runtime::popup::PopupPlacementSpec;
105/// let spec = PopupPlacementSpec::new(Rect::new(1.0, 2.0, 3.0, 4.0), Size::new(20.0, 10.0));
106/// assert_eq!(spec.gap(), 0.0);
107/// assert!(spec.allows_flip());
108/// ```
109#[derive(Debug, Clone, Copy, PartialEq)]
110pub struct PopupPlacementSpec {
111 /// Anchor rectangle in global logical pixels.
112 anchor: Rect,
113 /// Requested popup size in logical pixels.
114 desired_size: Size,
115 /// Preferred vertical side.
116 placement: PopupPlacement,
117 /// Cross-axis alignment against the anchor.
118 alignment: PopupAlignment,
119 /// Non-negative logical-pixel separation from the anchor.
120 gap: f32,
121 /// Whether viewport resolution may choose the opposite vertical side.
122 allow_flip: bool,
123}
124
125/// Builder and accessor operations for semantic placement input.
126impl PopupPlacementSpec {
127 /// Creates bottom-centered placement with zero gap and flipping enabled.
128 ///
129 /// Values are stored verbatim and validated only when published/resolved.
130 ///
131 /// # Examples
132 ///
133 /// ```
134 /// use ailloli_ui_core::{Rect, Size};
135 /// use ailloli_ui_runtime::popup::{PopupAlignment, PopupPlacement, PopupPlacementSpec};
136 /// let spec = PopupPlacementSpec::new(Rect::new(0.0, 0.0, 10.0, 5.0), Size::new(30.0, 20.0));
137 /// assert_eq!((spec.placement(), spec.alignment(), spec.gap(), spec.allows_flip()), (PopupPlacement::Bottom, PopupAlignment::Center, 0.0, true));
138 /// ```
139 pub const fn new(anchor: Rect, desired_size: Size) -> Self {
140 Self {
141 anchor,
142 desired_size,
143 placement: PopupPlacement::Bottom,
144 alignment: PopupAlignment::Center,
145 gap: 0.0,
146 allow_flip: true,
147 }
148 }
149
150 /// Returns the stored global logical-pixel anchor rectangle.
151 ///
152 /// # Examples
153 ///
154 /// ```
155 /// use ailloli_ui_core::{Rect, Size};
156 /// use ailloli_ui_runtime::popup::PopupPlacementSpec;
157 /// let anchor = Rect::new(1.0, 2.0, 3.0, 4.0);
158 /// assert_eq!(PopupPlacementSpec::new(anchor, Size::new(5.0, 6.0)).anchor(), anchor);
159 /// ```
160 pub const fn anchor(self) -> Rect {
161 self.anchor
162 }
163
164 /// Returns the requested logical-pixel popup size.
165 ///
166 /// # Examples
167 ///
168 /// ```
169 /// use ailloli_ui_core::{Rect, Size};
170 /// use ailloli_ui_runtime::popup::PopupPlacementSpec;
171 /// let size = Size::new(20.0, 10.0);
172 /// assert_eq!(PopupPlacementSpec::new(Rect::new(0.0, 0.0, 0.0, 0.0), size).desired_size(), size);
173 /// ```
174 pub const fn desired_size(self) -> Size {
175 self.desired_size
176 }
177
178 /// Returns the preferred vertical side.
179 ///
180 /// # Examples
181 ///
182 /// ```
183 /// use ailloli_ui_core::{Rect, Size};
184 /// use ailloli_ui_runtime::popup::{PopupPlacement, PopupPlacementSpec};
185 /// assert_eq!(PopupPlacementSpec::new(Rect::new(0.0, 0.0, 0.0, 0.0), Size::default()).placement(), PopupPlacement::Bottom);
186 /// ```
187 pub const fn placement(self) -> PopupPlacement {
188 self.placement
189 }
190
191 /// Returns the requested cross-axis alignment.
192 ///
193 /// # Examples
194 ///
195 /// ```
196 /// use ailloli_ui_core::{Rect, Size};
197 /// use ailloli_ui_runtime::popup::{PopupAlignment, PopupPlacementSpec};
198 /// assert_eq!(PopupPlacementSpec::new(Rect::new(0.0, 0.0, 0.0, 0.0), Size::default()).alignment(), PopupAlignment::Center);
199 /// ```
200 pub const fn alignment(self) -> PopupAlignment {
201 self.alignment
202 }
203
204 /// Returns the requested anchor gap in logical pixels.
205 ///
206 /// # Examples
207 ///
208 /// ```
209 /// use ailloli_ui_core::{Rect, Size};
210 /// use ailloli_ui_runtime::popup::PopupPlacementSpec;
211 /// assert_eq!(PopupPlacementSpec::new(Rect::new(0.0, 0.0, 0.0, 0.0), Size::default()).with_gap(4.0).gap(), 4.0);
212 /// ```
213 pub const fn gap(self) -> f32 {
214 self.gap
215 }
216
217 /// Returns whether the host may select the opposite vertical side.
218 ///
219 /// # Examples
220 ///
221 /// ```
222 /// use ailloli_ui_core::{Rect, Size};
223 /// use ailloli_ui_runtime::popup::PopupPlacementSpec;
224 /// assert!(!PopupPlacementSpec::new(Rect::new(0.0, 0.0, 0.0, 0.0), Size::default()).with_flip(false).allows_flip());
225 /// ```
226 pub const fn allows_flip(self) -> bool {
227 self.allow_flip
228 }
229
230 /// Replaces the preferred vertical side.
231 ///
232 /// # Examples
233 ///
234 /// ```
235 /// use ailloli_ui_core::{Rect, Size};
236 /// use ailloli_ui_runtime::popup::{PopupPlacement, PopupPlacementSpec};
237 /// let spec = PopupPlacementSpec::new(Rect::new(0.0, 0.0, 0.0, 0.0), Size::default()).with_placement(PopupPlacement::Top);
238 /// assert_eq!(spec.placement(), PopupPlacement::Top);
239 /// ```
240 pub const fn with_placement(mut self, placement: PopupPlacement) -> Self {
241 self.placement = placement;
242 self
243 }
244
245 /// Replaces cross-axis alignment.
246 ///
247 /// # Examples
248 ///
249 /// ```
250 /// use ailloli_ui_core::{Rect, Size};
251 /// use ailloli_ui_runtime::popup::{PopupAlignment, PopupPlacementSpec};
252 /// let spec = PopupPlacementSpec::new(Rect::new(0.0, 0.0, 0.0, 0.0), Size::default()).with_alignment(PopupAlignment::End);
253 /// assert_eq!(spec.alignment(), PopupAlignment::End);
254 /// ```
255 pub const fn with_alignment(mut self, alignment: PopupAlignment) -> Self {
256 self.alignment = alignment;
257 self
258 }
259
260 /// Replaces the logical-pixel gap without validating it.
261 ///
262 /// Negative and non-finite values are rejected when the spec is published.
263 ///
264 /// # Examples
265 ///
266 /// ```
267 /// use ailloli_ui_core::{Rect, Size};
268 /// use ailloli_ui_runtime::popup::PopupPlacementSpec;
269 /// assert_eq!(PopupPlacementSpec::new(Rect::new(0.0, 0.0, 0.0, 0.0), Size::default()).with_gap(2.5).gap(), 2.5);
270 /// ```
271 pub const fn with_gap(mut self, gap: f32) -> Self {
272 self.gap = gap;
273 self
274 }
275
276 /// Enables or disables automatic vertical-side flipping.
277 ///
278 /// # Examples
279 ///
280 /// ```
281 /// use ailloli_ui_core::{Rect, Size};
282 /// use ailloli_ui_runtime::popup::PopupPlacementSpec;
283 /// assert!(!PopupPlacementSpec::new(Rect::new(0.0, 0.0, 0.0, 0.0), Size::default()).with_flip(false).allows_flip());
284 /// ```
285 pub const fn with_flip(mut self, allow_flip: bool) -> Self {
286 self.allow_flip = allow_flip;
287 self
288 }
289}
290
291/// Presentation backend requested or selected for a popup.
292///
293/// Overlay support is universal. `Native` is only selected when the active
294/// host explicitly reports that capability; requesting it never disables the
295/// deterministic overlay fallback.
296///
297/// # Examples
298///
299/// ```
300/// use ailloli_ui_runtime::popup::PopupBackend;
301/// assert_eq!(PopupBackend::default(), PopupBackend::Overlay);
302/// ```
303#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
304pub enum PopupBackend {
305 /// Mount content in the retained in-window overlay.
306 #[default]
307 Overlay,
308 /// Ask a capable host for a separate native popup presentation.
309 Native,
310}
311
312/// Popup presentation capabilities reported by a host adapter.
313///
314/// The safe default is overlay-only. In particular, the winit 0.30 adapter
315/// does not advertise native popup support.
316///
317/// # Examples
318///
319/// ```
320/// use ailloli_ui_runtime::popup::{PopupBackend, PopupBackendCapabilities};
321/// assert!(!PopupBackendCapabilities::default().supports(PopupBackend::Native));
322/// ```
323#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
324pub struct PopupBackendCapabilities {
325 /// Whether native popup presentation has been validated by this host.
326 native: bool,
327}
328
329/// Backend support tests and deterministic fallback resolution.
330impl PopupBackendCapabilities {
331 /// Capabilities for headless hosts and the universal fallback path.
332 ///
333 /// # Examples
334 ///
335 /// ```
336 /// use ailloli_ui_runtime::popup::{PopupBackend, PopupBackendCapabilities};
337 /// let capabilities = PopupBackendCapabilities::overlay_only();
338 /// assert!(capabilities.supports(PopupBackend::Overlay));
339 /// assert!(!capabilities.supports(PopupBackend::Native));
340 /// ```
341 pub const fn overlay_only() -> Self {
342 Self { native: false }
343 }
344
345 /// Capabilities for a host that has independently validated native popup
346 /// presentation while retaining overlay fallback support.
347 ///
348 /// # Examples
349 ///
350 /// ```
351 /// use ailloli_ui_runtime::popup::{PopupBackend, PopupBackendCapabilities};
352 /// let capabilities = PopupBackendCapabilities::native_and_overlay();
353 /// assert!(capabilities.supports(PopupBackend::Native));
354 /// ```
355 pub const fn native_and_overlay() -> Self {
356 Self { native: true }
357 }
358
359 /// Reports direct support for a backend before fallback.
360 ///
361 /// Overlay is always supported; native support equals the advertised flag.
362 ///
363 /// # Examples
364 ///
365 /// ```
366 /// use ailloli_ui_runtime::popup::{PopupBackend, PopupBackendCapabilities};
367 /// assert!(PopupBackendCapabilities::overlay_only().supports(PopupBackend::Overlay));
368 /// ```
369 pub const fn supports(self, backend: PopupBackend) -> bool {
370 match backend {
371 PopupBackend::Overlay => true,
372 PopupBackend::Native => self.native,
373 }
374 }
375
376 /// Selects the request when supported, otherwise the overlay fallback.
377 ///
378 /// # Examples
379 ///
380 /// ```
381 /// use ailloli_ui_runtime::popup::{PopupBackend, PopupBackendCapabilities};
382 /// let resolution = PopupBackendCapabilities::overlay_only().resolve(PopupBackend::Native);
383 /// assert_eq!(resolution.selected(), PopupBackend::Overlay);
384 /// assert!(resolution.fell_back());
385 /// ```
386 pub const fn resolve(self, requested: PopupBackend) -> PopupBackendResolution {
387 let selected = if self.supports(requested) {
388 requested
389 } else {
390 PopupBackend::Overlay
391 };
392 PopupBackendResolution {
393 requested,
394 selected,
395 }
396 }
397}
398
399/// Observable backend selection, including a native-to-overlay fallback.
400///
401/// # Examples
402///
403/// ```
404/// use ailloli_ui_runtime::popup::{PopupBackend, PopupBackendCapabilities};
405/// let value = PopupBackendCapabilities::overlay_only().resolve(PopupBackend::Native);
406/// assert_eq!((value.requested(), value.selected()), (PopupBackend::Native, PopupBackend::Overlay));
407/// ```
408#[derive(Debug, Clone, Copy, PartialEq, Eq)]
409pub struct PopupBackendResolution {
410 /// Original semantic request.
411 requested: PopupBackend,
412 /// Actually selected supported backend.
413 selected: PopupBackend,
414}
415
416/// Accessors for requested and selected backend identity.
417impl PopupBackendResolution {
418 /// Returns the original requested backend.
419 ///
420 /// # Examples
421 ///
422 /// ```
423 /// use ailloli_ui_runtime::popup::{PopupBackend, PopupBackendCapabilities};
424 /// assert_eq!(PopupBackendCapabilities::overlay_only().resolve(PopupBackend::Native).requested(), PopupBackend::Native);
425 /// ```
426 pub const fn requested(self) -> PopupBackend {
427 self.requested
428 }
429
430 /// Returns the supported backend chosen for presentation.
431 ///
432 /// # Examples
433 ///
434 /// ```
435 /// use ailloli_ui_runtime::popup::{PopupBackend, PopupBackendCapabilities};
436 /// assert_eq!(PopupBackendCapabilities::overlay_only().resolve(PopupBackend::Native).selected(), PopupBackend::Overlay);
437 /// ```
438 pub const fn selected(self) -> PopupBackend {
439 self.selected
440 }
441
442 /// Reports whether selection differs from the request.
443 ///
444 /// # Examples
445 ///
446 /// ```
447 /// use ailloli_ui_runtime::popup::{PopupBackend, PopupBackendCapabilities};
448 /// assert!(!PopupBackendCapabilities::native_and_overlay().resolve(PopupBackend::Native).fell_back());
449 /// ```
450 pub fn fell_back(self) -> bool {
451 self.requested != self.selected
452 }
453}
454
455/// Complete provider-neutral input to popup placement resolution.
456///
457/// All rectangles/sizes/gaps are logical pixels. Construction and builder
458/// calls store values verbatim; [`resolve_popup_placement`] validates finite,
459/// non-negative geometry and a non-empty viewport.
460///
461/// # Examples
462///
463/// ```
464/// use ailloli_ui_core::{Rect, Size};
465/// use ailloli_ui_runtime::popup::PopupPlacementInput;
466/// let input = PopupPlacementInput::new(Rect::new(0.0, 0.0, 10.0, 5.0), Size::new(20.0, 10.0), Rect::new(0.0, 0.0, 100.0, 80.0));
467/// assert!(input.allows_flip());
468/// ```
469#[derive(Debug, Clone, Copy, PartialEq)]
470pub struct PopupPlacementInput {
471 /// Global anchor rectangle.
472 anchor: Rect,
473 /// Requested popup size.
474 desired_size: Size,
475 /// Complete global host viewport.
476 viewport: Rect,
477 /// Preferred vertical side.
478 placement: PopupPlacement,
479 /// Cross-axis anchor alignment.
480 alignment: PopupAlignment,
481 /// Requested anchor separation.
482 gap: f32,
483 /// Whether vertical flipping is permitted.
484 allow_flip: bool,
485 /// Preferred presentation backend.
486 backend: PopupBackend,
487}
488
489/// Builder and accessor operations for complete placement resolution input.
490impl PopupPlacementInput {
491 /// Creates bottom-centered overlay input with zero gap and flipping enabled.
492 ///
493 /// # Examples
494 ///
495 /// ```
496 /// use ailloli_ui_core::{Rect, Size};
497 /// use ailloli_ui_runtime::popup::{PopupBackend, PopupPlacementInput};
498 /// let input = PopupPlacementInput::new(Rect::new(0.0, 0.0, 0.0, 0.0), Size::new(10.0, 5.0), Rect::new(0.0, 0.0, 100.0, 100.0));
499 /// assert_eq!(input.backend(), PopupBackend::Overlay);
500 /// assert_eq!(input.gap(), 0.0);
501 /// ```
502 pub fn new(anchor: Rect, desired_size: Size, viewport: Rect) -> Self {
503 Self {
504 anchor,
505 desired_size,
506 viewport,
507 placement: PopupPlacement::Bottom,
508 alignment: PopupAlignment::Center,
509 gap: 0.0,
510 allow_flip: true,
511 backend: PopupBackend::Overlay,
512 }
513 }
514
515 /// Returns the stored global logical-pixel anchor.
516 ///
517 /// # Examples
518 ///
519 /// ```
520 /// use ailloli_ui_core::{Rect, Size}; use ailloli_ui_runtime::popup::PopupPlacementInput;
521 /// let anchor = Rect::new(1.0, 2.0, 3.0, 4.0);
522 /// assert_eq!(PopupPlacementInput::new(anchor, Size::default(), Rect::new(0.0, 0.0, 1.0, 1.0)).anchor(), anchor);
523 /// ```
524 pub const fn anchor(self) -> Rect {
525 self.anchor
526 }
527
528 /// Returns the requested logical-pixel popup size.
529 ///
530 /// # Examples
531 ///
532 /// ```
533 /// use ailloli_ui_core::{Rect, Size}; use ailloli_ui_runtime::popup::PopupPlacementInput;
534 /// let size = Size::new(3.0, 4.0);
535 /// assert_eq!(PopupPlacementInput::new(Rect::new(0.0, 0.0, 0.0, 0.0), size, Rect::new(0.0, 0.0, 1.0, 1.0)).desired_size(), size);
536 /// ```
537 pub const fn desired_size(self) -> Size {
538 self.desired_size
539 }
540
541 /// Returns the complete global logical-pixel viewport.
542 ///
543 /// # Examples
544 ///
545 /// ```
546 /// use ailloli_ui_core::{Rect, Size}; use ailloli_ui_runtime::popup::PopupPlacementInput;
547 /// let viewport = Rect::new(5.0, 6.0, 100.0, 80.0);
548 /// assert_eq!(PopupPlacementInput::new(Rect::new(0.0, 0.0, 0.0, 0.0), Size::default(), viewport).viewport(), viewport);
549 /// ```
550 pub const fn viewport(self) -> Rect {
551 self.viewport
552 }
553
554 /// Returns the preferred vertical side.
555 ///
556 /// # Examples
557 ///
558 /// ```
559 /// use ailloli_ui_core::{Rect, Size}; use ailloli_ui_runtime::popup::{PopupPlacement, PopupPlacementInput};
560 /// let input = PopupPlacementInput::new(Rect::new(0.0, 0.0, 0.0, 0.0), Size::default(), Rect::new(0.0, 0.0, 1.0, 1.0));
561 /// assert_eq!(input.placement(), PopupPlacement::Bottom);
562 /// ```
563 pub const fn placement(self) -> PopupPlacement {
564 self.placement
565 }
566
567 /// Returns the cross-axis anchor alignment.
568 ///
569 /// # Examples
570 ///
571 /// ```
572 /// use ailloli_ui_core::{Rect, Size}; use ailloli_ui_runtime::popup::{PopupAlignment, PopupPlacementInput};
573 /// let input = PopupPlacementInput::new(Rect::new(0.0, 0.0, 0.0, 0.0), Size::default(), Rect::new(0.0, 0.0, 1.0, 1.0));
574 /// assert_eq!(input.alignment(), PopupAlignment::Center);
575 /// ```
576 pub const fn alignment(self) -> PopupAlignment {
577 self.alignment
578 }
579
580 /// Returns the requested logical-pixel gap.
581 ///
582 /// # Examples
583 ///
584 /// ```
585 /// use ailloli_ui_core::{Rect, Size}; use ailloli_ui_runtime::popup::PopupPlacementInput;
586 /// let input = PopupPlacementInput::new(Rect::new(0.0, 0.0, 0.0, 0.0), Size::default(), Rect::new(0.0, 0.0, 1.0, 1.0)).with_gap(3.0);
587 /// assert_eq!(input.gap(), 3.0);
588 /// ```
589 pub const fn gap(self) -> f32 {
590 self.gap
591 }
592
593 /// Returns whether vertical side flipping is enabled.
594 ///
595 /// # Examples
596 ///
597 /// ```
598 /// use ailloli_ui_core::{Rect, Size}; use ailloli_ui_runtime::popup::PopupPlacementInput;
599 /// let input = PopupPlacementInput::new(Rect::new(0.0, 0.0, 0.0, 0.0), Size::default(), Rect::new(0.0, 0.0, 1.0, 1.0)).with_flip(false);
600 /// assert!(!input.allows_flip());
601 /// ```
602 pub const fn allows_flip(self) -> bool {
603 self.allow_flip
604 }
605
606 /// Returns the requested presentation backend.
607 ///
608 /// # Examples
609 ///
610 /// ```
611 /// use ailloli_ui_core::{Rect, Size}; use ailloli_ui_runtime::popup::{PopupBackend, PopupPlacementInput};
612 /// let input = PopupPlacementInput::new(Rect::new(0.0, 0.0, 0.0, 0.0), Size::default(), Rect::new(0.0, 0.0, 1.0, 1.0)).with_backend(PopupBackend::Native);
613 /// assert_eq!(input.backend(), PopupBackend::Native);
614 /// ```
615 pub const fn backend(self) -> PopupBackend {
616 self.backend
617 }
618
619 /// Replaces the preferred vertical side.
620 ///
621 /// # Examples
622 ///
623 /// ```
624 /// use ailloli_ui_core::{Rect, Size}; use ailloli_ui_runtime::popup::{PopupPlacement, PopupPlacementInput};
625 /// let input = PopupPlacementInput::new(Rect::new(0.0, 0.0, 0.0, 0.0), Size::default(), Rect::new(0.0, 0.0, 1.0, 1.0)).with_placement(PopupPlacement::Top);
626 /// assert_eq!(input.placement(), PopupPlacement::Top);
627 /// ```
628 pub const fn with_placement(mut self, placement: PopupPlacement) -> Self {
629 self.placement = placement;
630 self
631 }
632
633 /// Replaces the cross-axis alignment.
634 ///
635 /// # Examples
636 ///
637 /// ```
638 /// use ailloli_ui_core::{Rect, Size}; use ailloli_ui_runtime::popup::{PopupAlignment, PopupPlacementInput};
639 /// let input = PopupPlacementInput::new(Rect::new(0.0, 0.0, 0.0, 0.0), Size::default(), Rect::new(0.0, 0.0, 1.0, 1.0)).with_alignment(PopupAlignment::Start);
640 /// assert_eq!(input.alignment(), PopupAlignment::Start);
641 /// ```
642 pub const fn with_alignment(mut self, alignment: PopupAlignment) -> Self {
643 self.alignment = alignment;
644 self
645 }
646
647 /// Replaces the logical-pixel gap without validating it.
648 ///
649 /// # Examples
650 ///
651 /// ```
652 /// use ailloli_ui_core::{Rect, Size}; use ailloli_ui_runtime::popup::PopupPlacementInput;
653 /// let input = PopupPlacementInput::new(Rect::new(0.0, 0.0, 0.0, 0.0), Size::default(), Rect::new(0.0, 0.0, 1.0, 1.0)).with_gap(2.0);
654 /// assert_eq!(input.gap(), 2.0);
655 /// ```
656 pub const fn with_gap(mut self, gap: f32) -> Self {
657 self.gap = gap;
658 self
659 }
660
661 /// Enables or disables automatic vertical flipping.
662 ///
663 /// # Examples
664 ///
665 /// ```
666 /// use ailloli_ui_core::{Rect, Size}; use ailloli_ui_runtime::popup::PopupPlacementInput;
667 /// let input = PopupPlacementInput::new(Rect::new(0.0, 0.0, 0.0, 0.0), Size::default(), Rect::new(0.0, 0.0, 1.0, 1.0)).with_flip(false);
668 /// assert!(!input.allows_flip());
669 /// ```
670 pub const fn with_flip(mut self, allow_flip: bool) -> Self {
671 self.allow_flip = allow_flip;
672 self
673 }
674
675 /// Replaces the preferred backend; capability fallback occurs at resolve.
676 ///
677 /// # Examples
678 ///
679 /// ```
680 /// use ailloli_ui_core::{Rect, Size}; use ailloli_ui_runtime::popup::{PopupBackend, PopupPlacementInput};
681 /// let input = PopupPlacementInput::new(Rect::new(0.0, 0.0, 0.0, 0.0), Size::default(), Rect::new(0.0, 0.0, 1.0, 1.0)).with_backend(PopupBackend::Native);
682 /// assert_eq!(input.backend(), PopupBackend::Native);
683 /// ```
684 pub const fn with_backend(mut self, backend: PopupBackend) -> Self {
685 self.backend = backend;
686 self
687 }
688}
689
690/// Deterministic result of backend and popup geometry resolution.
691///
692/// `bounds` is a finite rectangle clamped inside the input viewport in global
693/// logical pixels. `flipped` compares selected/requested side; `clamped` covers
694/// both size reduction and position correction.
695///
696/// # Examples
697///
698/// ```
699/// use ailloli_ui_core::{Rect, Size};
700/// use ailloli_ui_runtime::popup::{resolve_popup_placement, PopupBackendCapabilities, PopupPlacementInput};
701/// let result = resolve_popup_placement(PopupPlacementInput::new(Rect::new(10.0, 10.0, 10.0, 5.0), Size::new(20.0, 10.0), Rect::new(0.0, 0.0, 100.0, 100.0)), PopupBackendCapabilities::overlay_only())?;
702/// assert_eq!(result.bounds().w, 20.0);
703/// # Ok::<(), ailloli_ui_runtime::popup::PopupPlacementError>(())
704/// ```
705#[derive(Debug, Clone, Copy, PartialEq)]
706pub struct ResolvedPopupPlacement {
707 /// Requested and selected backend.
708 backend: PopupBackendResolution,
709 /// Final viewport-contained global bounds.
710 bounds: Rect,
711 /// Selected vertical side.
712 placement: PopupPlacement,
713 /// Whether the selected side differs from the request.
714 flipped: bool,
715 /// Whether size or position required viewport correction.
716 clamped: bool,
717}
718
719/// Accessors for one immutable placement result.
720impl ResolvedPopupPlacement {
721 /// Returns requested/selected backend resolution.
722 ///
723 /// # Examples
724 ///
725 /// ```
726 /// use ailloli_ui_core::{Rect, Size}; use ailloli_ui_runtime::popup::*;
727 /// let input = PopupPlacementInput::new(Rect::new(0.0, 0.0, 1.0, 1.0), Size::new(1.0, 1.0), Rect::new(0.0, 0.0, 10.0, 10.0)).with_backend(PopupBackend::Native);
728 /// assert!(resolve_popup_placement(input, PopupBackendCapabilities::overlay_only())?.backend().fell_back());
729 /// # Ok::<(), PopupPlacementError>(())
730 /// ```
731 pub const fn backend(self) -> PopupBackendResolution {
732 self.backend
733 }
734
735 /// Returns final global logical-pixel bounds.
736 ///
737 /// # Examples
738 ///
739 /// ```
740 /// use ailloli_ui_core::{Rect, Size}; use ailloli_ui_runtime::popup::*;
741 /// let result = resolve_popup_placement(PopupPlacementInput::new(Rect::new(0.0, 0.0, 2.0, 2.0), Size::new(4.0, 3.0), Rect::new(0.0, 0.0, 10.0, 10.0)), PopupBackendCapabilities::overlay_only())?;
742 /// assert_eq!(result.bounds().w, 4.0);
743 /// # Ok::<(), PopupPlacementError>(())
744 /// ```
745 pub const fn bounds(self) -> Rect {
746 self.bounds
747 }
748
749 /// Returns the selected side after optional flipping.
750 ///
751 /// # Examples
752 ///
753 /// ```
754 /// use ailloli_ui_core::{Rect, Size}; use ailloli_ui_runtime::popup::*;
755 /// let input = PopupPlacementInput::new(Rect::new(10.0, 90.0, 5.0, 5.0), Size::new(10.0, 20.0), Rect::new(0.0, 0.0, 100.0, 100.0));
756 /// assert_eq!(resolve_popup_placement(input, PopupBackendCapabilities::overlay_only())?.placement(), PopupPlacement::Top);
757 /// # Ok::<(), PopupPlacementError>(())
758 /// ```
759 pub const fn placement(self) -> PopupPlacement {
760 self.placement
761 }
762
763 /// Reports whether resolution selected the opposite vertical side.
764 ///
765 /// # Examples
766 ///
767 /// ```
768 /// use ailloli_ui_core::{Rect, Size}; use ailloli_ui_runtime::popup::*;
769 /// let input = PopupPlacementInput::new(Rect::new(10.0, 90.0, 5.0, 5.0), Size::new(10.0, 20.0), Rect::new(0.0, 0.0, 100.0, 100.0));
770 /// assert!(resolve_popup_placement(input, PopupBackendCapabilities::overlay_only())?.flipped());
771 /// # Ok::<(), PopupPlacementError>(())
772 /// ```
773 pub const fn flipped(self) -> bool {
774 self.flipped
775 }
776
777 /// Reports whether desired size or positioned bounds were viewport-corrected.
778 ///
779 /// # Examples
780 ///
781 /// ```
782 /// use ailloli_ui_core::{Rect, Size}; use ailloli_ui_runtime::popup::*;
783 /// let input = PopupPlacementInput::new(Rect::new(0.0, 0.0, 0.0, 0.0), Size::new(50.0, 50.0), Rect::new(0.0, 0.0, 10.0, 10.0));
784 /// assert!(resolve_popup_placement(input, PopupBackendCapabilities::overlay_only())?.clamped());
785 /// # Ok::<(), PopupPlacementError>(())
786 /// ```
787 pub const fn clamped(self) -> bool {
788 self.clamped
789 }
790}
791
792/// Invalid geometry supplied to popup placement resolution.
793///
794/// Validation is deterministic and performs no platform calls. The enum is
795/// non-exhaustive so downstream matches require a wildcard arm.
796///
797/// # Examples
798///
799/// ```
800/// use ailloli_ui_core::{Rect, Size};
801/// use ailloli_ui_runtime::popup::{position_popup, PopupAlignment, PopupPlacement, PopupPlacementError};
802/// assert_eq!(position_popup(Rect::new(0.0, 0.0, 0.0, 0.0), Size::new(-1.0, 2.0), PopupPlacement::Bottom, PopupAlignment::Center, 0.0), Err(PopupPlacementError::InvalidDesiredSize));
803/// ```
804#[non_exhaustive]
805#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
806pub enum PopupPlacementError {
807 /// Anchor coordinate/size is non-finite or a dimension is negative.
808 #[error("popup anchor must be finite and non-negative in size")]
809 InvalidAnchor,
810 /// Desired width/height is non-finite or negative.
811 #[error("popup desired size must be finite and non-negative")]
812 InvalidDesiredSize,
813 /// Viewport coordinate/size is non-finite or a dimension is negative.
814 #[error("popup viewport must be finite and non-negative in size")]
815 InvalidViewport,
816 /// Viewport width or height is exactly zero after basic validation.
817 #[error("popup viewport must have a positive width and height")]
818 EmptyViewport,
819 /// Gap is negative or non-finite.
820 #[error("popup gap must be finite and non-negative")]
821 InvalidGap,
822 /// A retained request was resolved before receiving an anchor.
823 #[error("popup request has no anchor")]
824 MissingAnchor,
825 /// A retained request was resolved before receiving desired size.
826 #[error("popup request has no desired size")]
827 MissingDesiredSize,
828 /// Finite inputs overflowed to non-finite positioned/clamped edges.
829 #[error("popup geometry cannot be represented with finite coordinates")]
830 UnrepresentableGeometry,
831}
832
833/// Resolves popup side, alignment, flip, viewport clamp, and backend fallback.
834///
835/// Flipping occurs only when the preferred side cannot contain the resolved
836/// height and the opposite side either can contain it or offers strictly more
837/// space. The final rectangle is always clamped to the viewport, including a
838/// deterministic size reduction when the desired popup is larger than it.
839/// All input/output geometry is in global logical pixels. Validation order is
840/// anchor, desired size, viewport, then gap; backend fallback is not an error.
841///
842/// # Errors
843///
844/// Returns [`PopupPlacementError`] for invalid/missing representable geometry.
845///
846/// # Examples
847///
848/// ```
849/// use ailloli_ui_core::{Rect, Size}; use ailloli_ui_runtime::popup::*;
850/// let input = PopupPlacementInput::new(Rect::new(40.0, 90.0, 20.0, 5.0), Size::new(30.0, 20.0), Rect::new(0.0, 0.0, 100.0, 100.0));
851/// let result = resolve_popup_placement(input, PopupBackendCapabilities::overlay_only())?;
852/// assert_eq!(result.placement(), PopupPlacement::Top);
853/// assert_eq!(result.bounds(), Rect::new(35.0, 70.0, 30.0, 20.0));
854/// # Ok::<(), PopupPlacementError>(())
855/// ```
856pub fn resolve_popup_placement(
857 input: PopupPlacementInput,
858 capabilities: PopupBackendCapabilities,
859) -> Result<ResolvedPopupPlacement, PopupPlacementError> {
860 validate_anchor(input.anchor)?;
861 validate_desired_size(input.desired_size)?;
862 validate_viewport(input.viewport)?;
863 validate_gap(input.gap)?;
864
865 let resolved_size = Size::new(
866 input.desired_size.w.min(input.viewport.w),
867 input.desired_size.h.min(input.viewport.h),
868 );
869 let preferred_space =
870 available_vertical_space(input.anchor, input.viewport, input.placement, input.gap);
871 let opposite = input.placement.opposite();
872 let opposite_space =
873 available_vertical_space(input.anchor, input.viewport, opposite, input.gap);
874 let preferred_fits = resolved_size.h <= preferred_space;
875 let opposite_fits = resolved_size.h <= opposite_space;
876 let placement = if input.allow_flip
877 && !preferred_fits
878 && (opposite_fits || opposite_space > preferred_space)
879 {
880 opposite
881 } else {
882 input.placement
883 };
884
885 let positioned = position_popup_unchecked(
886 input.anchor,
887 resolved_size,
888 placement,
889 input.alignment,
890 input.gap,
891 )?;
892 let bounds = clamp_popup_to_viewport(positioned, input.viewport)?;
893 let clamped = resolved_size != input.desired_size || bounds != positioned;
894
895 Ok(ResolvedPopupPlacement {
896 backend: capabilities.resolve(input.backend),
897 bounds,
898 placement,
899 flipped: placement != input.placement,
900 clamped,
901 })
902}
903
904/// Positions a popup relative to an anchor without viewport flip or clamp.
905///
906/// Procedural overlays use this primitive before their window viewport is
907/// available. Once a viewport is known, prefer [`resolve_popup_placement`].
908/// All geometry is global logical pixels. Center alignment may produce negative
909/// coordinates; no clipping occurs here.
910///
911/// # Errors
912///
913/// Returns typed validation errors or `UnrepresentableGeometry` on arithmetic
914/// overflow to non-finite edges.
915///
916/// # Examples
917///
918/// ```
919/// use ailloli_ui_core::{Rect, Size}; use ailloli_ui_runtime::popup::*;
920/// let bounds = position_popup(Rect::new(10.0, 20.0, 20.0, 5.0), Size::new(10.0, 4.0), PopupPlacement::Bottom, PopupAlignment::Center, 2.0)?;
921/// assert_eq!(bounds, Rect::new(15.0, 27.0, 10.0, 4.0));
922/// # Ok::<(), PopupPlacementError>(())
923/// ```
924pub fn position_popup(
925 anchor: Rect,
926 desired_size: Size,
927 placement: PopupPlacement,
928 alignment: PopupAlignment,
929 gap: f32,
930) -> Result<Rect, PopupPlacementError> {
931 validate_anchor(anchor)?;
932 validate_desired_size(desired_size)?;
933 validate_gap(gap)?;
934 position_popup_unchecked(anchor, desired_size, placement, alignment, gap)
935}
936
937/// Clamps popup bounds to a viewport without tracking a requested side.
938///
939/// Both size and origin are corrected: oversize dimensions shrink to viewport
940/// dimensions, then origin clamps so all edges remain inside. Inputs/output are
941/// global logical pixels.
942///
943/// # Errors
944///
945/// Invalid bounds map to `UnrepresentableGeometry`; invalid/empty viewport uses
946/// its specific validation error.
947///
948/// # Examples
949///
950/// ```
951/// use ailloli_ui_core::Rect; use ailloli_ui_runtime::popup::{clamp_popup_to_viewport, PopupPlacementError};
952/// assert_eq!(clamp_popup_to_viewport(Rect::new(-5.0, 8.0, 30.0, 20.0), Rect::new(0.0, 0.0, 10.0, 10.0))?, Rect::new(0.0, 0.0, 10.0, 10.0));
953/// # Ok::<(), PopupPlacementError>(())
954/// ```
955pub fn clamp_popup_to_viewport(bounds: Rect, viewport: Rect) -> Result<Rect, PopupPlacementError> {
956 validate_anchor(bounds).map_err(|_| PopupPlacementError::UnrepresentableGeometry)?;
957 validate_viewport(viewport)?;
958
959 let width = bounds.w.min(viewport.w);
960 let height = bounds.h.min(viewport.h);
961 let max_x = viewport.right() - width;
962 let max_y = viewport.bottom() - height;
963 let x = bounds.x.clamp(viewport.x, max_x);
964 let y = bounds.y.clamp(viewport.y, max_y);
965 let clamped = Rect::new(x, y, width, height);
966 if rect_has_finite_edges(clamped) {
967 Ok(clamped)
968 } else {
969 Err(PopupPlacementError::UnrepresentableGeometry)
970 }
971}
972
973/// Stable identity of one retained element tree.
974///
975/// `ElementId` values are only unique within a tree, so popup ownership always
976/// carries both identifiers.
977/// Zero is a valid compatibility namespace and no value is reserved by the
978/// type itself.
979///
980/// # Examples
981///
982/// ```
983/// use ailloli_ui_runtime::popup::ElementTreeId;
984/// assert_eq!(ElementTreeId::new(7).get(), 7);
985/// ```
986#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
987pub struct ElementTreeId(u64);
988
989/// Construction and extraction for a retained-tree namespace.
990impl ElementTreeId {
991 /// Wraps an arbitrary `u64` without validation or allocation.
992 ///
993 /// # Examples
994 ///
995 /// ```
996 /// use ailloli_ui_runtime::popup::ElementTreeId;
997 /// assert_eq!(ElementTreeId::new(0).get(), 0);
998 /// ```
999 pub const fn new(value: u64) -> Self {
1000 Self(value)
1001 }
1002
1003 /// Returns the exact stored integer.
1004 ///
1005 /// # Examples
1006 ///
1007 /// ```
1008 /// use ailloli_ui_runtime::popup::ElementTreeId;
1009 /// assert_eq!(ElementTreeId::new(u64::MAX).get(), u64::MAX);
1010 /// ```
1011 pub const fn get(self) -> u64 {
1012 self.0
1013 }
1014}
1015
1016/// Stable identity of one popup registration.
1017///
1018/// The value type does not allocate or reserve identifiers. [`PopupPortal`]
1019/// allocates one-based checked IDs, while callers may explicitly construct zero.
1020///
1021/// # Examples
1022///
1023/// ```
1024/// use ailloli_ui_runtime::popup::PopupId;
1025/// assert_eq!(PopupId::new(1).get(), 1);
1026/// ```
1027#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1028pub struct PopupId(u64);
1029
1030/// Construction and extraction for popup identity.
1031impl PopupId {
1032 /// Wraps an arbitrary `u64` without registration or validation.
1033 ///
1034 /// # Examples
1035 ///
1036 /// ```
1037 /// use ailloli_ui_runtime::popup::PopupId;
1038 /// assert_eq!(PopupId::new(0).get(), 0);
1039 /// ```
1040 pub const fn new(value: u64) -> Self {
1041 Self(value)
1042 }
1043
1044 /// Returns the exact stored integer.
1045 ///
1046 /// # Examples
1047 ///
1048 /// ```
1049 /// use ailloli_ui_runtime::popup::PopupId;
1050 /// assert_eq!(PopupId::new(42).get(), 42);
1051 /// ```
1052 pub const fn get(self) -> u64 {
1053 self.0
1054 }
1055}
1056
1057/// Complete identity of the element that owns a popup.
1058///
1059/// Logical window, native presentation generation, retained-tree namespace,
1060/// and tree-local element ID are all required to avoid stale/cross-window
1061/// aliasing. Values are stored without liveness validation.
1062///
1063/// # Examples
1064///
1065/// ```
1066/// use ailloli_ui_core::ElementId; use ailloli_ui_runtime::{app::PresentationGeneration, popup::{ElementTreeId, PopupOwner}};
1067/// let owner = PopupOwner::new("main", PresentationGeneration::new(2), ElementTreeId::new(3), ElementId(4));
1068/// assert_eq!(owner.logical_window_id().as_str(), "main");
1069/// ```
1070#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1071pub struct PopupOwner {
1072 /// Stable application-level logical window.
1073 logical_window_id: LogicalWindowId,
1074 /// Native presentation generation preventing stale surface reuse.
1075 presentation_generation: PresentationGeneration,
1076 /// Retained-tree namespace.
1077 element_tree_id: ElementTreeId,
1078 /// Tree-local owner element.
1079 element_id: ElementId,
1080}
1081
1082/// Construction, field access, and exact presentation matching for owners.
1083impl PopupOwner {
1084 /// Creates a complete owner identity without checking current liveness.
1085 ///
1086 /// Empty logical-window IDs and zero numeric IDs are stored verbatim.
1087 ///
1088 /// # Examples
1089 ///
1090 /// ```
1091 /// use ailloli_ui_core::ElementId; use ailloli_ui_runtime::{app::PresentationGeneration, popup::{ElementTreeId, PopupOwner}};
1092 /// let owner = PopupOwner::new("editor", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1));
1093 /// assert_eq!(owner.element_id(), ElementId(1));
1094 /// ```
1095 pub fn new(
1096 logical_window_id: impl Into<LogicalWindowId>,
1097 presentation_generation: PresentationGeneration,
1098 element_tree_id: ElementTreeId,
1099 element_id: ElementId,
1100 ) -> Self {
1101 Self {
1102 logical_window_id: logical_window_id.into(),
1103 presentation_generation,
1104 element_tree_id,
1105 element_id,
1106 }
1107 }
1108
1109 /// Borrows the stable logical-window ID.
1110 ///
1111 /// # Examples
1112 ///
1113 /// ```
1114 /// use ailloli_ui_core::ElementId; use ailloli_ui_runtime::{app::PresentationGeneration, popup::{ElementTreeId, PopupOwner}};
1115 /// let owner = PopupOwner::new("main", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1));
1116 /// assert_eq!(owner.logical_window_id().as_str(), "main");
1117 /// ```
1118 pub fn logical_window_id(&self) -> &LogicalWindowId {
1119 &self.logical_window_id
1120 }
1121
1122 /// Returns the exact native presentation generation.
1123 ///
1124 /// # Examples
1125 ///
1126 /// ```
1127 /// use ailloli_ui_core::ElementId; use ailloli_ui_runtime::{app::PresentationGeneration, popup::{ElementTreeId, PopupOwner}};
1128 /// let owner = PopupOwner::new("main", PresentationGeneration::new(9), ElementTreeId::new(0), ElementId(1));
1129 /// assert_eq!(owner.presentation_generation(), PresentationGeneration::new(9));
1130 /// ```
1131 pub const fn presentation_generation(&self) -> PresentationGeneration {
1132 self.presentation_generation
1133 }
1134
1135 /// Returns the retained-tree namespace.
1136 ///
1137 /// # Examples
1138 ///
1139 /// ```
1140 /// use ailloli_ui_core::ElementId; use ailloli_ui_runtime::{app::PresentationGeneration, popup::{ElementTreeId, PopupOwner}};
1141 /// let owner = PopupOwner::new("main", PresentationGeneration::INITIAL, ElementTreeId::new(4), ElementId(1));
1142 /// assert_eq!(owner.element_tree_id(), ElementTreeId::new(4));
1143 /// ```
1144 pub const fn element_tree_id(&self) -> ElementTreeId {
1145 self.element_tree_id
1146 }
1147
1148 /// Returns the tree-local owner element ID.
1149 ///
1150 /// # Examples
1151 ///
1152 /// ```
1153 /// use ailloli_ui_core::ElementId; use ailloli_ui_runtime::{app::PresentationGeneration, popup::{ElementTreeId, PopupOwner}};
1154 /// let owner = PopupOwner::new("main", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(6));
1155 /// assert_eq!(owner.element_id(), ElementId(6));
1156 /// ```
1157 pub const fn element_id(&self) -> ElementId {
1158 self.element_id
1159 }
1160
1161 /// Returns whether this owner belongs to the active presentation of a
1162 /// logical window.
1163 /// Tree and element IDs are deliberately irrelevant to this comparison.
1164 ///
1165 /// # Examples
1166 ///
1167 /// ```
1168 /// use ailloli_ui_core::{ElementId, LogicalWindowId}; use ailloli_ui_runtime::{app::PresentationGeneration, popup::{ElementTreeId, PopupOwner}};
1169 /// let owner = PopupOwner::new("main", PresentationGeneration::new(2), ElementTreeId::new(3), ElementId(4));
1170 /// assert!(owner.belongs_to(&LogicalWindowId::new("main"), PresentationGeneration::new(2)));
1171 /// assert!(!owner.belongs_to(&LogicalWindowId::new("main"), PresentationGeneration::new(3)));
1172 /// ```
1173 pub fn belongs_to(
1174 &self,
1175 logical_window_id: &LogicalWindowId,
1176 presentation_generation: PresentationGeneration,
1177 ) -> bool {
1178 self.logical_window_id == *logical_window_id
1179 && self.presentation_generation == presentation_generation
1180 }
1181}
1182
1183/// Retained factory used to remount popup content in either an overlay tree or
1184/// a native presentation.
1185///
1186/// The UI-thread-local `Rc` makes clones share one factory without requiring
1187/// `Send` or `Sync`. Each [`Self::build`] invocation calls the factory again and
1188/// returns a fresh declarative view.
1189///
1190/// # Examples
1191///
1192/// ```
1193/// use ailloli_ui_runtime::{component::View, popup::PopupContent};
1194/// let content = PopupContent::<()>::new(|| View::empty().key("menu"));
1195/// assert_eq!(content.build().key_ref(), Some("menu"));
1196/// ```
1197pub struct PopupContent<A> {
1198 /// Shared remount factory.
1199 factory: Rc<dyn Fn() -> View<A>>,
1200}
1201
1202/// Factory construction and invocation.
1203impl<A> PopupContent<A> {
1204 /// Stores a UI-thread-local `'static` view factory without invoking it.
1205 ///
1206 /// # Examples
1207 ///
1208 /// ```
1209 /// use std::{cell::Cell, rc::Rc}; use ailloli_ui_runtime::{component::View, popup::PopupContent};
1210 /// let calls = Rc::new(Cell::new(0)); let seen = calls.clone();
1211 /// let content = PopupContent::<()>::new(move || { seen.set(seen.get() + 1); View::empty() });
1212 /// assert_eq!(calls.get(), 0); content.build(); assert_eq!(calls.get(), 1);
1213 /// ```
1214 pub fn new(factory: impl Fn() -> View<A> + 'static) -> Self {
1215 Self {
1216 factory: Rc::new(factory),
1217 }
1218 }
1219
1220 /// Invokes the retained factory once and returns its new view.
1221 ///
1222 /// # Panics
1223 ///
1224 /// Propagates a factory panic.
1225 ///
1226 /// # Examples
1227 ///
1228 /// ```
1229 /// use ailloli_ui_runtime::{component::View, popup::PopupContent};
1230 /// let content = PopupContent::<()>::new(|| View::empty().key("popup"));
1231 /// assert_eq!(content.build().key_ref(), Some("popup"));
1232 /// ```
1233 pub fn build(&self) -> View<A> {
1234 (self.factory)()
1235 }
1236}
1237
1238/// Clones the shared factory rather than invoking or duplicating it.
1239impl<A> Clone for PopupContent<A> {
1240 /// Increments the factory's strong count.
1241 fn clone(&self) -> Self {
1242 Self {
1243 factory: Rc::clone(&self.factory),
1244 }
1245 }
1246}
1247
1248/// Redacts the closure internals from debug output.
1249impl<A> fmt::Debug for PopupContent<A> {
1250 /// Writes the stable placeholder `PopupContent(<factory>)`.
1251 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1252 formatter.write_str("PopupContent(<factory>)")
1253 }
1254}
1255
1256/// Rendering ownership selected for one popup registration.
1257///
1258/// New popup content is mounted into the provider-neutral retained overlay by
1259/// default. Widgets that still draw their own overlay must opt into
1260/// [`Self::ProceduralFallback`] until their shell, placement, and interaction
1261/// have migrated to the retained subtree.
1262///
1263/// The portal keeps two fixed z-order strata: procedural fallbacks are always
1264/// below retained overlays. Opening a popup raises it only within its own
1265/// stratum, so paint, hit-testing, outside dismissal, and Escape all agree on
1266/// the same topmost popup during the migration.
1267///
1268/// # Examples
1269///
1270/// ```
1271/// use ailloli_ui_runtime::popup::PopupMountPolicy;
1272/// assert_eq!(PopupMountPolicy::default(), PopupMountPolicy::RetainedOverlay);
1273/// ```
1274#[non_exhaustive]
1275#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1276pub enum PopupMountPolicy {
1277 /// Mount the popup factory into the runtime-owned retained overlay tree.
1278 #[default]
1279 RetainedOverlay,
1280 /// Keep widget-owned procedural drawing in the lower migration stratum.
1281 ProceduralFallback,
1282}
1283
1284/// Semantic role exposed by a popup independently from its presentation.
1285///
1286/// # Examples
1287///
1288/// ```
1289/// use ailloli_ui_runtime::popup::PopupRole;
1290/// assert_eq!(PopupRole::default(), PopupRole::Generic);
1291/// ```
1292#[non_exhaustive]
1293#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1294pub enum PopupRole {
1295 /// Popup with no more specific semantic category.
1296 #[default]
1297 Generic,
1298 /// Selectable list of options.
1299 Listbox,
1300 /// Command/action menu.
1301 Menu,
1302 /// Non-interactive explanatory tooltip.
1303 Tooltip,
1304 /// Modal dialog whose retained descendants own focus until dismissal.
1305 Dialog,
1306}
1307
1308/// Focus behavior requested when the popup becomes visible.
1309///
1310/// # Examples
1311///
1312/// ```
1313/// use ailloli_ui_runtime::popup::PopupFocusPolicy;
1314/// assert_eq!(PopupFocusPolicy::default(), PopupFocusPolicy::None);
1315/// ```
1316#[non_exhaustive]
1317#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1318pub enum PopupFocusPolicy {
1319 /// Preserve current focus.
1320 #[default]
1321 None,
1322 /// Move focus to the first focusable popup descendant.
1323 MoveIntoPopup,
1324 /// Move focus into the popup and request focus trapping within it.
1325 TrapWithinPopup,
1326}
1327
1328/// Provider-neutral interaction and accessibility contract for a popup.
1329///
1330/// Defaults describe an interactive generic popup: outside press/Escape close
1331/// it, pointer input is consumed, and focus is restored on close, but opening
1332/// does not move focus. Builder booleans mean exactly whether each behavior is
1333/// requested; the portal does not infer a role-specific override.
1334///
1335/// # Examples
1336///
1337/// ```
1338/// use ailloli_ui_runtime::popup::PopupSemantics;
1339/// let semantics = PopupSemantics::new();
1340/// assert!(semantics.dismisses_on_escape() && semantics.consumes_pointer_input());
1341/// ```
1342#[non_exhaustive]
1343#[derive(Debug, Clone, PartialEq, Eq)]
1344pub struct PopupSemantics {
1345 /// Accessibility/interaction category.
1346 role: PopupRole,
1347 /// Focus action emitted on first open.
1348 focus_policy: PopupFocusPolicy,
1349 /// Whether a press outside this popup subtree dismisses it.
1350 dismiss_on_outside_press: bool,
1351 /// Whether Escape dismisses this popup.
1352 dismiss_on_escape: bool,
1353 /// Whether pointer input inside/opening dismissal is considered handled.
1354 consume_pointer_input: bool,
1355 /// Whether closing emits restoration to the complete owner identity.
1356 restore_focus_on_close: bool,
1357}
1358
1359/// Uses the interactive defaults from [`PopupSemantics::new`].
1360impl Default for PopupSemantics {
1361 /// Returns [`PopupSemantics::new`].
1362 fn default() -> Self {
1363 Self::new()
1364 }
1365}
1366
1367/// Constructors, accessors, and immutable-style semantic builders.
1368impl PopupSemantics {
1369 /// Creates the interactive generic defaults.
1370 ///
1371 /// # Examples
1372 ///
1373 /// ```
1374 /// use ailloli_ui_runtime::popup::{PopupFocusPolicy, PopupRole, PopupSemantics};
1375 /// let value = PopupSemantics::new();
1376 /// assert_eq!((value.role(), value.focus_policy()), (PopupRole::Generic, PopupFocusPolicy::None));
1377 /// assert!(value.dismisses_on_outside_press() && value.dismisses_on_escape() && value.consumes_pointer_input() && value.restores_focus_on_close());
1378 /// ```
1379 pub const fn new() -> Self {
1380 Self {
1381 role: PopupRole::Generic,
1382 focus_policy: PopupFocusPolicy::None,
1383 dismiss_on_outside_press: true,
1384 dismiss_on_escape: true,
1385 consume_pointer_input: true,
1386 restore_focus_on_close: true,
1387 }
1388 }
1389
1390 /// Returns the semantic category.
1391 ///
1392 /// # Examples
1393 ///
1394 /// ```
1395 /// use ailloli_ui_runtime::popup::{PopupRole, PopupSemantics};
1396 /// assert_eq!(PopupSemantics::new().with_role(PopupRole::Menu).role(), PopupRole::Menu);
1397 /// ```
1398 pub const fn role(&self) -> PopupRole {
1399 self.role
1400 }
1401
1402 /// Returns requested focus behavior on open.
1403 ///
1404 /// # Examples
1405 ///
1406 /// ```
1407 /// use ailloli_ui_runtime::popup::{PopupFocusPolicy, PopupSemantics};
1408 /// assert_eq!(PopupSemantics::new().with_focus_policy(PopupFocusPolicy::MoveIntoPopup).focus_policy(), PopupFocusPolicy::MoveIntoPopup);
1409 /// ```
1410 pub const fn focus_policy(&self) -> PopupFocusPolicy {
1411 self.focus_policy
1412 }
1413
1414 /// Reports whether an outside press requests dismissal.
1415 ///
1416 /// # Examples
1417 ///
1418 /// ```
1419 /// use ailloli_ui_runtime::popup::PopupSemantics;
1420 /// assert!(!PopupSemantics::new().dismiss_on_outside_press(false).dismisses_on_outside_press());
1421 /// ```
1422 pub const fn dismisses_on_outside_press(&self) -> bool {
1423 self.dismiss_on_outside_press
1424 }
1425
1426 /// Reports whether Escape requests dismissal.
1427 ///
1428 /// # Examples
1429 ///
1430 /// ```
1431 /// use ailloli_ui_runtime::popup::PopupSemantics;
1432 /// assert!(!PopupSemantics::new().dismiss_on_escape(false).dismisses_on_escape());
1433 /// ```
1434 pub const fn dismisses_on_escape(&self) -> bool {
1435 self.dismiss_on_escape
1436 }
1437
1438 /// Reports whether routed pointer input is considered handled.
1439 ///
1440 /// # Examples
1441 ///
1442 /// ```
1443 /// use ailloli_ui_runtime::popup::PopupSemantics;
1444 /// assert!(!PopupSemantics::new().consume_pointer_input(false).consumes_pointer_input());
1445 /// ```
1446 pub const fn consumes_pointer_input(&self) -> bool {
1447 self.consume_pointer_input
1448 }
1449
1450 /// Reports whether close emits owner focus restoration.
1451 ///
1452 /// # Examples
1453 ///
1454 /// ```
1455 /// use ailloli_ui_runtime::popup::PopupSemantics;
1456 /// assert!(!PopupSemantics::new().restore_focus_on_close(false).restores_focus_on_close());
1457 /// ```
1458 pub const fn restores_focus_on_close(&self) -> bool {
1459 self.restore_focus_on_close
1460 }
1461
1462 /// Replaces the semantic category without changing other behavior.
1463 ///
1464 /// # Examples
1465 ///
1466 /// ```
1467 /// use ailloli_ui_runtime::popup::{PopupRole, PopupSemantics};
1468 /// assert_eq!(PopupSemantics::new().with_role(PopupRole::Listbox).role(), PopupRole::Listbox);
1469 /// ```
1470 pub const fn with_role(mut self, role: PopupRole) -> Self {
1471 self.role = role;
1472 self
1473 }
1474
1475 /// Replaces focus-on-open behavior.
1476 ///
1477 /// # Examples
1478 ///
1479 /// ```
1480 /// use ailloli_ui_runtime::popup::{PopupFocusPolicy, PopupSemantics};
1481 /// assert_eq!(PopupSemantics::new().with_focus_policy(PopupFocusPolicy::TrapWithinPopup).focus_policy(), PopupFocusPolicy::TrapWithinPopup);
1482 /// ```
1483 pub const fn with_focus_policy(mut self, focus_policy: PopupFocusPolicy) -> Self {
1484 self.focus_policy = focus_policy;
1485 self
1486 }
1487
1488 /// Sets outside-press dismissal exactly to `dismiss`.
1489 ///
1490 /// # Examples
1491 ///
1492 /// ```
1493 /// use ailloli_ui_runtime::popup::PopupSemantics;
1494 /// assert!(!PopupSemantics::new().dismiss_on_outside_press(false).dismisses_on_outside_press());
1495 /// ```
1496 pub const fn dismiss_on_outside_press(mut self, dismiss: bool) -> Self {
1497 self.dismiss_on_outside_press = dismiss;
1498 self
1499 }
1500
1501 /// Sets Escape dismissal exactly to `dismiss`.
1502 ///
1503 /// # Examples
1504 ///
1505 /// ```
1506 /// use ailloli_ui_runtime::popup::PopupSemantics;
1507 /// assert!(!PopupSemantics::new().dismiss_on_escape(false).dismisses_on_escape());
1508 /// ```
1509 pub const fn dismiss_on_escape(mut self, dismiss: bool) -> Self {
1510 self.dismiss_on_escape = dismiss;
1511 self
1512 }
1513
1514 /// Sets pointer-input consumption exactly to `consume`.
1515 ///
1516 /// # Examples
1517 ///
1518 /// ```
1519 /// use ailloli_ui_runtime::popup::PopupSemantics;
1520 /// assert!(!PopupSemantics::new().consume_pointer_input(false).consumes_pointer_input());
1521 /// ```
1522 pub const fn consume_pointer_input(mut self, consume: bool) -> Self {
1523 self.consume_pointer_input = consume;
1524 self
1525 }
1526
1527 /// Sets owner focus restoration exactly to `restore`.
1528 ///
1529 /// # Examples
1530 ///
1531 /// ```
1532 /// use ailloli_ui_runtime::popup::PopupSemantics;
1533 /// assert!(!PopupSemantics::new().restore_focus_on_close(false).restores_focus_on_close());
1534 /// ```
1535 pub const fn restore_focus_on_close(mut self, restore: bool) -> Self {
1536 self.restore_focus_on_close = restore;
1537 self
1538 }
1539
1540 /// Non-interactive semantics suitable for a tooltip.
1541 ///
1542 /// Role is tooltip; Escape dismissal stays enabled, while outside press,
1543 /// pointer consumption, and focus restoration are disabled.
1544 ///
1545 /// # Examples
1546 ///
1547 /// ```
1548 /// use ailloli_ui_runtime::popup::{PopupRole, PopupSemantics};
1549 /// let value = PopupSemantics::tooltip();
1550 /// assert_eq!(value.role(), PopupRole::Tooltip);
1551 /// assert!(value.dismisses_on_escape());
1552 /// assert!(!value.dismisses_on_outside_press() && !value.consumes_pointer_input());
1553 /// assert!(!value.restores_focus_on_close());
1554 /// ```
1555 pub const fn tooltip() -> Self {
1556 Self::new()
1557 .with_role(PopupRole::Tooltip)
1558 .dismiss_on_escape(true)
1559 .dismiss_on_outside_press(false)
1560 .consume_pointer_input(false)
1561 .restore_focus_on_close(false)
1562 }
1563}
1564
1565/// Registration contract for one popup.
1566///
1567/// A request combines immutable identity/ownership, optional parent and
1568/// geometry, backend/placement preferences, a remountable content factory, and
1569/// interaction semantics. Defaults are closed-registration friendly: no parent,
1570/// anchor, or desired size; bottom-center, zero gap, flip enabled; overlay
1571/// backend and retained-overlay mounting. Builders store geometry verbatim;
1572/// portal publication/resolution validates it.
1573///
1574/// # Examples
1575///
1576/// ```
1577/// use ailloli_ui_core::ElementId; use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
1578/// let request = PopupRequest::<()>::new(PopupId::new(1), PopupOwner::new("main", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty));
1579/// assert_eq!(request.parent(), None);
1580/// assert_eq!(request.mount_policy(), PopupMountPolicy::RetainedOverlay);
1581/// ```
1582pub struct PopupRequest<A> {
1583 /// Stable registry identity.
1584 id: PopupId,
1585 /// Complete retained owner identity.
1586 owner: PopupOwner,
1587 /// Optional parent popup in the same presentation.
1588 parent: Option<PopupId>,
1589 /// Optional global logical-pixel anchor.
1590 anchor: Option<Rect>,
1591 /// Optional requested logical-pixel size.
1592 desired_size: Option<Size>,
1593 /// Preferred vertical side.
1594 placement: PopupPlacement,
1595 /// Cross-axis alignment.
1596 alignment: PopupAlignment,
1597 /// Logical-pixel anchor separation.
1598 gap: f32,
1599 /// Whether viewport resolution may flip sides.
1600 allow_flip: bool,
1601 /// Preferred presentation backend.
1602 backend: PopupBackend,
1603 /// Remountable declarative content.
1604 content: PopupContent<A>,
1605 /// Interaction/accessibility behavior.
1606 semantics: PopupSemantics,
1607 /// Retained-versus-procedural migration stratum.
1608 mount_policy: PopupMountPolicy,
1609}
1610
1611/// Construction, immutable access, builders, and placement resolution.
1612impl<A> PopupRequest<A> {
1613 /// Creates a closed-registration request with semantic defaults.
1614 ///
1615 /// IDs/owner liveness are not checked and content is not built.
1616 ///
1617 /// # Examples
1618 ///
1619 /// ```
1620 /// use ailloli_ui_core::ElementId; use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
1621 /// let request = PopupRequest::<()>::new(PopupId::new(2), PopupOwner::new("main", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(3)), PopupContent::new(View::empty));
1622 /// assert_eq!(request.id(), PopupId::new(2));
1623 /// assert_eq!((request.anchor(), request.desired_size()), (None, None));
1624 /// ```
1625 pub fn new(id: PopupId, owner: PopupOwner, content: PopupContent<A>) -> Self {
1626 Self {
1627 id,
1628 owner,
1629 parent: None,
1630 anchor: None,
1631 desired_size: None,
1632 placement: PopupPlacement::Bottom,
1633 alignment: PopupAlignment::Center,
1634 gap: 0.0,
1635 allow_flip: true,
1636 backend: PopupBackend::Overlay,
1637 content,
1638 semantics: PopupSemantics::default(),
1639 mount_policy: PopupMountPolicy::default(),
1640 }
1641 }
1642
1643 /// Returns the stable popup registration ID.
1644 ///
1645 /// # Examples
1646 ///
1647 /// ```
1648 /// # use ailloli_ui_core::ElementId;
1649 /// # use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
1650 /// # let request = PopupRequest::<()>::new(PopupId::new(7), PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty));
1651 /// assert_eq!(request.id(), PopupId::new(7));
1652 /// ```
1653 pub const fn id(&self) -> PopupId {
1654 self.id
1655 }
1656
1657 /// Borrows the complete popup owner identity.
1658 ///
1659 /// # Examples
1660 ///
1661 /// ```
1662 /// # use ailloli_ui_core::ElementId;
1663 /// # use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
1664 /// # let request = PopupRequest::<()>::new(PopupId::new(1), PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(9)), PopupContent::new(View::empty));
1665 /// assert_eq!(request.owner().element_id(), ElementId(9));
1666 /// ```
1667 pub const fn owner(&self) -> &PopupOwner {
1668 &self.owner
1669 }
1670
1671 /// Returns the optional parent popup ID.
1672 ///
1673 /// `None` denotes a root popup, not an unknown parent.
1674 ///
1675 /// # Examples
1676 ///
1677 /// ```
1678 /// # use ailloli_ui_core::ElementId;
1679 /// # use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
1680 /// # let request = PopupRequest::<()>::new(PopupId::new(1), PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty));
1681 /// assert_eq!(request.parent(), None);
1682 /// ```
1683 pub const fn parent(&self) -> Option<PopupId> {
1684 self.parent
1685 }
1686
1687 /// Returns optional global logical-pixel anchor geometry.
1688 ///
1689 /// # Examples
1690 ///
1691 /// ```
1692 /// # use ailloli_ui_core::{ElementId, Rect};
1693 /// # use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
1694 /// # let request = PopupRequest::<()>::new(PopupId::new(1), PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty));
1695 /// assert_eq!(request.with_anchor(Rect::new(1.0, 2.0, 3.0, 4.0)).anchor(), Some(Rect::new(1.0, 2.0, 3.0, 4.0)));
1696 /// ```
1697 pub const fn anchor(&self) -> Option<Rect> {
1698 self.anchor
1699 }
1700
1701 /// Returns optional requested logical-pixel popup size.
1702 ///
1703 /// # Examples
1704 ///
1705 /// ```
1706 /// # use ailloli_ui_core::{ElementId, Size};
1707 /// # use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
1708 /// # let request = PopupRequest::<()>::new(PopupId::new(1), PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty));
1709 /// assert_eq!(request.with_desired_size(Size::new(20.0, 10.0)).desired_size(), Some(Size::new(20.0, 10.0)));
1710 /// ```
1711 pub const fn desired_size(&self) -> Option<Size> {
1712 self.desired_size
1713 }
1714
1715 /// Returns the preferred vertical side.
1716 ///
1717 /// # Examples
1718 ///
1719 /// ```
1720 /// # use ailloli_ui_core::ElementId;
1721 /// # use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
1722 /// # let request = PopupRequest::<()>::new(PopupId::new(1), PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty));
1723 /// assert_eq!(request.placement(), PopupPlacement::Bottom);
1724 /// ```
1725 pub const fn placement(&self) -> PopupPlacement {
1726 self.placement
1727 }
1728
1729 /// Returns cross-axis alignment.
1730 ///
1731 /// # Examples
1732 ///
1733 /// ```
1734 /// # use ailloli_ui_core::ElementId;
1735 /// # use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
1736 /// # let request = PopupRequest::<()>::new(PopupId::new(1), PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty));
1737 /// assert_eq!(request.alignment(), PopupAlignment::Center);
1738 /// ```
1739 pub const fn alignment(&self) -> PopupAlignment {
1740 self.alignment
1741 }
1742
1743 /// Returns requested anchor gap in logical pixels.
1744 ///
1745 /// # Examples
1746 ///
1747 /// ```
1748 /// # use ailloli_ui_core::ElementId;
1749 /// # use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
1750 /// # let request = PopupRequest::<()>::new(PopupId::new(1), PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty));
1751 /// assert_eq!(request.gap(), 0.0);
1752 /// ```
1753 pub const fn gap(&self) -> f32 {
1754 self.gap
1755 }
1756
1757 /// Reports whether viewport resolution may flip vertical sides.
1758 ///
1759 /// # Examples
1760 ///
1761 /// ```
1762 /// # use ailloli_ui_core::ElementId;
1763 /// # use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
1764 /// # let request = PopupRequest::<()>::new(PopupId::new(1), PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty));
1765 /// assert!(request.allows_flip());
1766 /// ```
1767 pub const fn allows_flip(&self) -> bool {
1768 self.allow_flip
1769 }
1770
1771 /// Returns the preferred presentation backend.
1772 ///
1773 /// # Examples
1774 ///
1775 /// ```
1776 /// # use ailloli_ui_core::ElementId;
1777 /// # use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
1778 /// # let request = PopupRequest::<()>::new(PopupId::new(1), PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty));
1779 /// assert_eq!(request.backend(), PopupBackend::Overlay);
1780 /// ```
1781 pub const fn backend(&self) -> PopupBackend {
1782 self.backend
1783 }
1784
1785 /// Borrows the remountable content factory without invoking it.
1786 ///
1787 /// # Examples
1788 ///
1789 /// ```
1790 /// # use ailloli_ui_core::ElementId;
1791 /// # use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
1792 /// # let request = PopupRequest::<()>::new(PopupId::new(1), PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(|| View::empty().key("content")));
1793 /// assert_eq!(request.content().build().key_ref(), Some("content"));
1794 /// ```
1795 pub const fn content(&self) -> &PopupContent<A> {
1796 &self.content
1797 }
1798
1799 /// Borrows interaction/accessibility semantics.
1800 ///
1801 /// # Examples
1802 ///
1803 /// ```
1804 /// # use ailloli_ui_core::ElementId;
1805 /// # use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
1806 /// # let request = PopupRequest::<()>::new(PopupId::new(1), PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty));
1807 /// assert_eq!(request.semantics().role(), PopupRole::Generic);
1808 /// ```
1809 pub const fn semantics(&self) -> &PopupSemantics {
1810 &self.semantics
1811 }
1812
1813 /// Returns retained/procedural mounting policy.
1814 ///
1815 /// # Examples
1816 ///
1817 /// ```
1818 /// # use ailloli_ui_core::ElementId;
1819 /// # use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
1820 /// # let request = PopupRequest::<()>::new(PopupId::new(1), PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty));
1821 /// assert_eq!(request.mount_policy(), PopupMountPolicy::RetainedOverlay);
1822 /// ```
1823 pub const fn mount_policy(&self) -> PopupMountPolicy {
1824 self.mount_policy
1825 }
1826
1827 /// Assigns a parent popup ID.
1828 ///
1829 /// Registration later requires that parent to exist in the same presentation;
1830 /// opening later requires it to be open.
1831 ///
1832 /// # Examples
1833 ///
1834 /// ```
1835 /// # use ailloli_ui_core::ElementId;
1836 /// # use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
1837 /// # let request = PopupRequest::<()>::new(PopupId::new(2), PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty));
1838 /// assert_eq!(request.with_parent(PopupId::new(1)).parent(), Some(PopupId::new(1)));
1839 /// ```
1840 pub const fn with_parent(mut self, parent: PopupId) -> Self {
1841 self.parent = Some(parent);
1842 self
1843 }
1844
1845 /// Sets global logical-pixel anchor geometry without validation.
1846 ///
1847 /// # Examples
1848 ///
1849 /// ```
1850 /// # use ailloli_ui_core::{ElementId, Rect};
1851 /// # use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
1852 /// # let request = PopupRequest::<()>::new(PopupId::new(1), PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty));
1853 /// let anchor = Rect::new(1.0, 2.0, 3.0, 4.0);
1854 /// assert_eq!(request.with_anchor(anchor).anchor(), Some(anchor));
1855 /// ```
1856 pub const fn with_anchor(mut self, anchor: Rect) -> Self {
1857 self.anchor = Some(anchor);
1858 self
1859 }
1860
1861 /// Sets requested logical-pixel size without validation.
1862 ///
1863 /// # Examples
1864 ///
1865 /// ```
1866 /// # use ailloli_ui_core::{ElementId, Size};
1867 /// # use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
1868 /// # let request = PopupRequest::<()>::new(PopupId::new(1), PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty));
1869 /// let size = Size::new(20.0, 10.0);
1870 /// assert_eq!(request.with_desired_size(size).desired_size(), Some(size));
1871 /// ```
1872 pub const fn with_desired_size(mut self, desired_size: Size) -> Self {
1873 self.desired_size = Some(desired_size);
1874 self
1875 }
1876
1877 /// Replaces preferred vertical side.
1878 ///
1879 /// # Examples
1880 ///
1881 /// ```
1882 /// # use ailloli_ui_core::ElementId;
1883 /// # use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
1884 /// # let request = PopupRequest::<()>::new(PopupId::new(1), PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty));
1885 /// assert_eq!(request.with_placement(PopupPlacement::Top).placement(), PopupPlacement::Top);
1886 /// ```
1887 pub const fn with_placement(mut self, placement: PopupPlacement) -> Self {
1888 self.placement = placement;
1889 self
1890 }
1891
1892 /// Replaces cross-axis alignment.
1893 ///
1894 /// # Examples
1895 ///
1896 /// ```
1897 /// # use ailloli_ui_core::ElementId;
1898 /// # use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
1899 /// # let request = PopupRequest::<()>::new(PopupId::new(1), PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty));
1900 /// assert_eq!(request.with_alignment(PopupAlignment::End).alignment(), PopupAlignment::End);
1901 /// ```
1902 pub const fn with_alignment(mut self, alignment: PopupAlignment) -> Self {
1903 self.alignment = alignment;
1904 self
1905 }
1906
1907 /// Replaces logical-pixel gap without validation.
1908 ///
1909 /// # Examples
1910 ///
1911 /// ```
1912 /// # use ailloli_ui_core::ElementId;
1913 /// # use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
1914 /// # let request = PopupRequest::<()>::new(PopupId::new(1), PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty));
1915 /// assert_eq!(request.with_gap(4.0).gap(), 4.0);
1916 /// ```
1917 pub const fn with_gap(mut self, gap: f32) -> Self {
1918 self.gap = gap;
1919 self
1920 }
1921
1922 /// Enables or disables vertical-side flipping.
1923 ///
1924 /// # Examples
1925 ///
1926 /// ```
1927 /// # use ailloli_ui_core::ElementId;
1928 /// # use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
1929 /// # let request = PopupRequest::<()>::new(PopupId::new(1), PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty));
1930 /// assert!(!request.with_flip(false).allows_flip());
1931 /// ```
1932 pub const fn with_flip(mut self, allow_flip: bool) -> Self {
1933 self.allow_flip = allow_flip;
1934 self
1935 }
1936
1937 /// Replaces preferred backend; capability fallback occurs during resolution.
1938 ///
1939 /// # Examples
1940 ///
1941 /// ```
1942 /// # use ailloli_ui_core::ElementId;
1943 /// # use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
1944 /// # let request = PopupRequest::<()>::new(PopupId::new(1), PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty));
1945 /// assert_eq!(request.with_backend(PopupBackend::Native).backend(), PopupBackend::Native);
1946 /// ```
1947 pub const fn with_backend(mut self, backend: PopupBackend) -> Self {
1948 self.backend = backend;
1949 self
1950 }
1951
1952 /// Replaces the complete semantic contract.
1953 ///
1954 /// # Examples
1955 ///
1956 /// ```
1957 /// # use ailloli_ui_core::ElementId;
1958 /// # use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
1959 /// # let request = PopupRequest::<()>::new(PopupId::new(1), PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty));
1960 /// assert_eq!(request.with_semantics(PopupSemantics::tooltip()).semantics().role(), PopupRole::Tooltip);
1961 /// ```
1962 pub fn with_semantics(mut self, semantics: PopupSemantics) -> Self {
1963 self.semantics = semantics;
1964 self
1965 }
1966
1967 /// Replaces retained/procedural mounting policy.
1968 ///
1969 /// # Examples
1970 ///
1971 /// ```
1972 /// # use ailloli_ui_core::ElementId;
1973 /// # use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
1974 /// # let request = PopupRequest::<()>::new(PopupId::new(1), PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty));
1975 /// assert_eq!(request.with_mount_policy(PopupMountPolicy::ProceduralFallback).mount_policy(), PopupMountPolicy::ProceduralFallback);
1976 /// ```
1977 pub const fn with_mount_policy(mut self, mount_policy: PopupMountPolicy) -> Self {
1978 self.mount_policy = mount_policy;
1979 self
1980 }
1981
1982 /// Resolves this request against a host viewport and its advertised popup
1983 /// capabilities.
1984 ///
1985 /// Registration remains valid without geometry because declaratively open
1986 /// popups can be mounted before their first layout. This method reports a
1987 /// typed missing-field error until both anchor and desired size are known.
1988 /// All geometry is logical pixels. Backend selection uses `capabilities`;
1989 /// the request itself is not mutated.
1990 ///
1991 /// # Errors
1992 ///
1993 /// Returns `MissingAnchor`, then `MissingDesiredSize`, or a validation/
1994 /// representation error from [`resolve_popup_placement`].
1995 ///
1996 /// # Examples
1997 ///
1998 /// ```
1999 /// # use ailloli_ui_core::{ElementId, Rect, Size};
2000 /// # use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
2001 /// # let request = PopupRequest::<()>::new(PopupId::new(1), PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty));
2002 /// let request = request.with_anchor(Rect::new(10.0, 10.0, 5.0, 5.0)).with_desired_size(Size::new(20.0, 10.0));
2003 /// let resolved = request.resolve_placement(Rect::new(0.0, 0.0, 100.0, 100.0), PopupBackendCapabilities::overlay_only())?;
2004 /// assert_eq!(resolved.bounds().w, 20.0);
2005 /// # Ok::<(), PopupPlacementError>(())
2006 /// ```
2007 pub fn resolve_placement(
2008 &self,
2009 viewport: Rect,
2010 capabilities: PopupBackendCapabilities,
2011 ) -> Result<ResolvedPopupPlacement, PopupPlacementError> {
2012 let anchor = self.anchor.ok_or(PopupPlacementError::MissingAnchor)?;
2013 let desired_size = self
2014 .desired_size
2015 .ok_or(PopupPlacementError::MissingDesiredSize)?;
2016 resolve_popup_placement(
2017 PopupPlacementInput::new(anchor, desired_size, viewport)
2018 .with_placement(self.placement)
2019 .with_alignment(self.alignment)
2020 .with_gap(self.gap)
2021 .with_flip(self.allow_flip)
2022 .with_backend(self.backend),
2023 capabilities,
2024 )
2025 }
2026}
2027
2028/// Clones every value and shares the content factory's `Rc`.
2029impl<A> Clone for PopupRequest<A> {
2030 /// Produces an equivalent independent request value.
2031 fn clone(&self) -> Self {
2032 Self {
2033 id: self.id,
2034 owner: self.owner.clone(),
2035 parent: self.parent,
2036 anchor: self.anchor,
2037 desired_size: self.desired_size,
2038 placement: self.placement,
2039 alignment: self.alignment,
2040 gap: self.gap,
2041 allow_flip: self.allow_flip,
2042 backend: self.backend,
2043 content: self.content.clone(),
2044 semantics: self.semantics.clone(),
2045 mount_policy: self.mount_policy,
2046 }
2047 }
2048}
2049
2050/// Formats registration metadata while redacting the content closure.
2051impl<A> fmt::Debug for PopupRequest<A> {
2052 /// Writes all request fields through their debug representations.
2053 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2054 formatter
2055 .debug_struct("PopupRequest")
2056 .field("id", &self.id)
2057 .field("owner", &self.owner)
2058 .field("parent", &self.parent)
2059 .field("anchor", &self.anchor)
2060 .field("desired_size", &self.desired_size)
2061 .field("placement", &self.placement)
2062 .field("alignment", &self.alignment)
2063 .field("gap", &self.gap)
2064 .field("allow_flip", &self.allow_flip)
2065 .field("backend", &self.backend)
2066 .field("content", &self.content)
2067 .field("semantics", &self.semantics)
2068 .field("mount_policy", &self.mount_policy)
2069 .finish()
2070 }
2071}
2072
2073/// Reason recorded when the portal asks a backend to hide a popup.
2074///
2075/// This value is diagnostic/behavioral metadata carried by a dismissal intent;
2076/// it does not itself close a popup. The enum is non-exhaustive.
2077///
2078/// # Examples
2079///
2080/// ```
2081/// use ailloli_ui_runtime::popup::PopupDismissReason;
2082/// assert_ne!(PopupDismissReason::Escape, PopupDismissReason::OutsidePress);
2083/// ```
2084#[non_exhaustive]
2085#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2086pub enum PopupDismissReason {
2087 /// Explicit application/widget request.
2088 Programmatic,
2089 /// Pointer press occurred outside the active popup subtree.
2090 OutsidePress,
2091 /// Escape dismissed the topmost eligible popup.
2092 Escape,
2093 /// Retained owner element no longer exists.
2094 OwnerRemoved,
2095 /// Native presentation generation is obsolete.
2096 PresentationStale,
2097 /// An ancestor popup closed.
2098 ParentClosed,
2099 /// Registration was explicitly removed.
2100 Unregistered,
2101}
2102
2103/// Host-independent side effect emitted by [`PopupPortal`].
2104///
2105/// Intents are ordered: presentation precedes focus movement on open; subtree
2106/// dismissals precede optional owner focus restoration on close. The enum is
2107/// non-exhaustive.
2108///
2109/// # Examples
2110///
2111/// ```
2112/// use ailloli_ui_runtime::popup::{PopupId, PopupIntent};
2113/// assert!(matches!(PopupIntent::Present { popup_id: PopupId::new(1) }, PopupIntent::Present { .. }));
2114/// ```
2115#[non_exhaustive]
2116#[derive(Debug, Clone, PartialEq, Eq)]
2117pub enum PopupIntent {
2118 /// Ask the selected backend to present or raise a popup.
2119 Present {
2120 /// Stable registration ID.
2121 popup_id: PopupId,
2122 },
2123 /// Ask input routing to focus the first retained popup descendant.
2124 MoveFocusInto {
2125 /// Popup whose mounted subtree should receive focus.
2126 popup_id: PopupId,
2127 /// Whether subsequent focus cycling should remain inside the subtree.
2128 trap: bool,
2129 },
2130 /// Ask a backend to hide a popup for the recorded reason.
2131 Dismiss {
2132 /// Stable registration ID.
2133 popup_id: PopupId,
2134 /// Lifecycle/input reason for dismissal.
2135 reason: PopupDismissReason,
2136 },
2137 /// Restore focus only if the host can still resolve this complete owner.
2138 RestoreFocus {
2139 /// Exact owner that should regain focus if it is still live/current.
2140 owner: PopupOwner,
2141 },
2142}
2143
2144/// Result of routing one popup lifecycle or input operation.
2145///
2146/// `handled` answers whether the popup authority consumed/recognized the
2147/// operation; it is independent from whether any intents were emitted. An
2148/// already-open raise, for example, may be handled with no new intent.
2149///
2150/// # Examples
2151///
2152/// ```
2153/// use ailloli_ui_runtime::popup::PopupPortalOutcome;
2154/// let outcome = PopupPortalOutcome::default();
2155/// assert!(!outcome.handled() && outcome.intents().is_empty());
2156/// ```
2157#[derive(Debug, Clone, Default, PartialEq, Eq)]
2158pub struct PopupPortalOutcome {
2159 /// Whether the authority recognized/consumed the operation.
2160 handled: bool,
2161 /// Ordered provider/input side effects.
2162 intents: Vec<PopupIntent>,
2163}
2164
2165/// Observation and ownership conversion for popup outcomes.
2166impl PopupPortalOutcome {
2167 /// Reports whether the popup authority handled the operation.
2168 ///
2169 /// # Examples
2170 ///
2171 /// ```
2172 /// use ailloli_ui_runtime::popup::{PopupId, PopupPortal};
2173 /// assert!(!PopupPortal::<()>::new().close(PopupId::new(1)).handled());
2174 /// ```
2175 pub const fn handled(&self) -> bool {
2176 self.handled
2177 }
2178
2179 /// Borrows ordered intents without consuming the outcome.
2180 ///
2181 /// An empty slice does not imply `handled == false`.
2182 ///
2183 /// # Examples
2184 ///
2185 /// ```
2186 /// use ailloli_ui_runtime::popup::PopupPortalOutcome;
2187 /// assert_eq!(PopupPortalOutcome::default().intents(), &[]);
2188 /// ```
2189 pub fn intents(&self) -> &[PopupIntent] {
2190 &self.intents
2191 }
2192
2193 /// Consumes the outcome and returns its ordered intent vector.
2194 ///
2195 /// The `handled` flag is discarded.
2196 ///
2197 /// # Examples
2198 ///
2199 /// ```
2200 /// use ailloli_ui_runtime::popup::PopupPortalOutcome;
2201 /// assert!(PopupPortalOutcome::default().into_intents().is_empty());
2202 /// ```
2203 pub fn into_intents(self) -> Vec<PopupIntent> {
2204 self.intents
2205 }
2206
2207 /// Creates a handled outcome with no side effects.
2208 fn handled_empty() -> Self {
2209 Self {
2210 handled: true,
2211 intents: Vec::new(),
2212 }
2213 }
2214
2215 /// ORs `handled` and appends another outcome's intents in order.
2216 fn append(&mut self, mut other: Self) {
2217 self.handled |= other.handled;
2218 self.intents.append(&mut other.intents);
2219 }
2220}
2221
2222/// Invalid popup registry operation.
2223///
2224/// Runtime-handle helpers return these errors and also queue a cloned copy for
2225/// non-fatal host diagnostics. The enum is non-exhaustive.
2226///
2227/// # Examples
2228///
2229/// ```
2230/// use ailloli_ui_runtime::popup::PopupPortalError;
2231/// assert_eq!(PopupPortalError::UnknownPopup.to_string(), "popup identifier is not registered");
2232/// ```
2233#[non_exhaustive]
2234#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2235pub enum PopupPortalError {
2236 /// Requested registration ID already exists.
2237 #[error("popup identifier is already registered")]
2238 DuplicateId,
2239 /// Operation addressed an unregistered popup ID.
2240 #[error("popup identifier is not registered")]
2241 UnknownPopup,
2242 /// A requested parent ID is not registered.
2243 #[error("popup parent is not registered")]
2244 UnknownParent,
2245 /// Parent/child owners use different logical window or generation.
2246 #[error("popup parent belongs to another presentation")]
2247 ParentPresentationMismatch,
2248 /// A child was opened while its registered parent was closed.
2249 #[error("popup parent must be open before its child")]
2250 ParentNotOpen,
2251 /// Checked one-based `u64` ID allocation cannot advance.
2252 #[error("popup identifier space is exhausted")]
2253 IdExhausted,
2254 /// Bounds/anchor contain non-finite edges or negative dimensions.
2255 #[error("popup geometry must be finite and non-negative")]
2256 InvalidBounds,
2257}
2258
2259/// Internal mutable record for one registered popup.
2260struct PopupEntry<A> {
2261 /// Semantic registration contract.
2262 request: PopupRequest<A>,
2263 /// Backend-resolved global bounds, or `None` before positioning.
2264 bounds: Option<Rect>,
2265 /// Viewport paired with host-resolved bounds, or `None` for explicit bounds.
2266 resolved_viewport: Option<Rect>,
2267 /// Current semantic visibility.
2268 open: bool,
2269}
2270
2271/// UI-local popup registry and z-order authority.
2272///
2273/// The portal is deliberately `Rc`-backed through [`PopupContent`], matching
2274/// the runtime's retained UI ownership. It must be driven on the UI thread.
2275/// Registration storage is unbounded. Open z-order is bottom-to-top with a
2276/// fixed procedural-fallback stratum below retained overlays; opening raises
2277/// only within the popup's stratum.
2278///
2279/// # Examples
2280///
2281/// ```
2282/// use ailloli_ui_runtime::popup::PopupPortal;
2283/// let portal = PopupPortal::<()>::new();
2284/// assert!(portal.topmost().is_none());
2285/// ```
2286pub struct PopupPortal<A> {
2287 /// Next checked one-based automatically allocated ID.
2288 next_id: u64,
2289 /// All closed/open registrations by stable ID.
2290 entries: HashMap<PopupId, PopupEntry<A>>,
2291 /// Open IDs in effective bottom-to-top mount-stratum order.
2292 z_order: Vec<PopupId>,
2293}
2294
2295/// Creates an empty portal whose first allocated ID is one.
2296impl<A> Default for PopupPortal<A> {
2297 /// Returns empty registration and z-order storage.
2298 fn default() -> Self {
2299 Self {
2300 next_id: 1,
2301 entries: HashMap::new(),
2302 z_order: Vec::new(),
2303 }
2304 }
2305}
2306
2307/// Registration, geometry, lifecycle, z-order, and input authority operations.
2308impl<A> PopupPortal<A> {
2309 /// Creates an empty portal equivalent to [`Default::default`].
2310 ///
2311 /// # Examples
2312 ///
2313 /// ```
2314 /// use ailloli_ui_runtime::popup::PopupPortal;
2315 /// assert_eq!(PopupPortal::<()>::new().open_ids().count(), 0);
2316 /// ```
2317 pub fn new() -> Self {
2318 Self::default()
2319 }
2320
2321 /// Allocates a portal-local popup ID suitable for registration.
2322 ///
2323 /// IDs start at one, skip currently registered collisions, and are not
2324 /// reused merely because an entry is unregistered. Allocation reserves no
2325 /// entry, so callers must still register the returned ID.
2326 ///
2327 /// # Errors
2328 ///
2329 /// Returns `IdExhausted` when checked increment cannot advance beyond
2330 /// `u64::MAX`; that terminal candidate is not returned.
2331 ///
2332 /// # Examples
2333 ///
2334 /// ```
2335 /// use ailloli_ui_runtime::popup::{PopupId, PopupPortal};
2336 /// let mut portal = PopupPortal::<()>::new();
2337 /// assert_eq!(portal.allocate_id()?, PopupId::new(1));
2338 /// assert_eq!(portal.allocate_id()?, PopupId::new(2));
2339 /// # Ok::<(), ailloli_ui_runtime::popup::PopupPortalError>(())
2340 /// ```
2341 pub fn allocate_id(&mut self) -> Result<PopupId, PopupPortalError> {
2342 while self.entries.contains_key(&PopupId::new(self.next_id)) {
2343 self.next_id = self
2344 .next_id
2345 .checked_add(1)
2346 .ok_or(PopupPortalError::IdExhausted)?;
2347 }
2348 let id = PopupId::new(self.next_id);
2349 self.next_id = self
2350 .next_id
2351 .checked_add(1)
2352 .ok_or(PopupPortalError::IdExhausted)?;
2353 Ok(id)
2354 }
2355
2356 /// Registers a closed popup without presenting or building it.
2357 ///
2358 /// Explicit IDs advance subsequent automatic allocation. A parent must
2359 /// already be registered and share its logical window/presentation
2360 /// generation; tree namespace may differ. Geometry is not validated here.
2361 ///
2362 /// # Errors
2363 ///
2364 /// Returns `DuplicateId`, `UnknownParent`, `ParentPresentationMismatch`, or
2365 /// `IdExhausted`. Errors leave registration storage unchanged, though a
2366 /// terminal explicit `u64::MAX` cannot advance the allocator.
2367 ///
2368 /// # Examples
2369 ///
2370 /// ```
2371 /// use ailloli_ui_core::ElementId; use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
2372 /// let mut portal = PopupPortal::<()>::new(); let id = PopupId::new(4);
2373 /// portal.register(PopupRequest::new(id, PopupOwner::new("main", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty)))?;
2374 /// assert!(portal.contains(id) && !portal.is_open(id));
2375 /// # Ok::<(), PopupPortalError>(())
2376 /// ```
2377 pub fn register(&mut self, request: PopupRequest<A>) -> Result<(), PopupPortalError> {
2378 let id = request.id();
2379 if self.entries.contains_key(&id) {
2380 return Err(PopupPortalError::DuplicateId);
2381 }
2382
2383 if let Some(parent_id) = request.parent() {
2384 let parent = self
2385 .entries
2386 .get(&parent_id)
2387 .ok_or(PopupPortalError::UnknownParent)?;
2388 if !same_presentation(parent.request.owner(), request.owner()) {
2389 return Err(PopupPortalError::ParentPresentationMismatch);
2390 }
2391 }
2392
2393 if id.get() >= self.next_id {
2394 self.next_id = id
2395 .get()
2396 .checked_add(1)
2397 .ok_or(PopupPortalError::IdExhausted)?;
2398 }
2399 self.entries.insert(
2400 id,
2401 PopupEntry {
2402 request,
2403 bounds: None,
2404 resolved_viewport: None,
2405 open: false,
2406 },
2407 );
2408 Ok(())
2409 }
2410
2411 /// Reports whether an ID is registered, regardless of visibility.
2412 ///
2413 /// # Examples
2414 ///
2415 /// ```
2416 /// use ailloli_ui_runtime::popup::{PopupId, PopupPortal};
2417 /// assert!(!PopupPortal::<()>::new().contains(PopupId::new(1)));
2418 /// ```
2419 pub fn contains(&self, popup_id: PopupId) -> bool {
2420 self.entries.contains_key(&popup_id)
2421 }
2422
2423 /// Reports whether a registered popup is semantically open.
2424 ///
2425 /// Unknown and closed IDs both return `false`.
2426 ///
2427 /// # Examples
2428 ///
2429 /// ```
2430 /// use ailloli_ui_runtime::popup::{PopupId, PopupPortal};
2431 /// assert!(!PopupPortal::<()>::new().is_open(PopupId::new(1)));
2432 /// ```
2433 pub fn is_open(&self, popup_id: PopupId) -> bool {
2434 self.entries.get(&popup_id).is_some_and(|entry| entry.open)
2435 }
2436
2437 /// Borrows a registered semantic request.
2438 ///
2439 /// `None` means unknown ID. The returned request may describe either a
2440 /// closed or open popup; resolved bounds live separately in the portal.
2441 ///
2442 /// # Examples
2443 ///
2444 /// ```
2445 /// use ailloli_ui_core::ElementId; use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
2446 /// let mut portal = PopupPortal::<()>::new(); let id = PopupId::new(1);
2447 /// portal.register(PopupRequest::new(id, PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(2)), PopupContent::new(View::empty)))?;
2448 /// assert_eq!(portal.request(id).unwrap().owner().element_id(), ElementId(2));
2449 /// # Ok::<(), PopupPortalError>(())
2450 /// ```
2451 pub fn request(&self, popup_id: PopupId) -> Option<&PopupRequest<A>> {
2452 self.entries.get(&popup_id).map(|entry| &entry.request)
2453 }
2454
2455 /// Builds a fresh view from a registered popup's retained factory.
2456 ///
2457 /// Closed registrations can be built. `None` means unknown ID; a factory
2458 /// panic propagates.
2459 ///
2460 /// # Examples
2461 ///
2462 /// ```
2463 /// use ailloli_ui_core::ElementId; use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
2464 /// let mut portal = PopupPortal::<()>::new(); let id = PopupId::new(1);
2465 /// portal.register(PopupRequest::new(id, PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(|| View::empty().key("built"))))?;
2466 /// assert_eq!(portal.build_content(id).unwrap().key_ref(), Some("built"));
2467 /// # Ok::<(), PopupPortalError>(())
2468 /// ```
2469 pub fn build_content(&self, popup_id: PopupId) -> Option<View<A>> {
2470 self.request(popup_id)
2471 .map(|request| request.content.build())
2472 }
2473
2474 /// Replaces the declarative content factory without changing visibility,
2475 /// z-order, ownership, or backend-resolved geometry.
2476 ///
2477 /// Component reconciliation uses this when a stable popup owner rebuilds
2478 /// with new options, bindings, callbacks, or disabled state.
2479 /// The factory is moved but not invoked.
2480 ///
2481 /// # Errors
2482 ///
2483 /// Returns `UnknownPopup` without retaining `content` when the ID is absent.
2484 ///
2485 /// # Examples
2486 ///
2487 /// ```
2488 /// use ailloli_ui_core::ElementId; use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
2489 /// let mut portal = PopupPortal::<()>::new(); let id = PopupId::new(1);
2490 /// portal.register(PopupRequest::new(id, PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty)))?;
2491 /// portal.set_content(id, PopupContent::new(|| View::empty().key("new")))?;
2492 /// assert_eq!(portal.build_content(id).unwrap().key_ref(), Some("new"));
2493 /// # Ok::<(), PopupPortalError>(())
2494 /// ```
2495 pub fn set_content(
2496 &mut self,
2497 popup_id: PopupId,
2498 content: PopupContent<A>,
2499 ) -> Result<(), PopupPortalError> {
2500 let entry = self
2501 .entries
2502 .get_mut(&popup_id)
2503 .ok_or(PopupPortalError::UnknownPopup)?;
2504 entry.request.content = content;
2505 Ok(())
2506 }
2507
2508 /// Returns backend-resolved global logical-pixel bounds.
2509 ///
2510 /// `None` covers unknown IDs and registered popups with no current bounds;
2511 /// closing or changed semantic placement clears bounds.
2512 ///
2513 /// # Examples
2514 ///
2515 /// ```
2516 /// use ailloli_ui_runtime::popup::{PopupId, PopupPortal};
2517 /// assert_eq!(PopupPortal::<()>::new().bounds(PopupId::new(1)), None);
2518 /// ```
2519 pub fn bounds(&self, popup_id: PopupId) -> Option<Rect> {
2520 self.entries.get(&popup_id).and_then(|entry| entry.bounds)
2521 }
2522
2523 /// Returns the host viewport that produced the current resolved bounds.
2524 ///
2525 /// Explicit backend bounds installed through [`Self::set_bounds`] do not
2526 /// imply a viewport and therefore return `None` here.
2527 /// Unknown IDs and cleared/closed geometry also return `None`.
2528 ///
2529 /// # Examples
2530 ///
2531 /// ```
2532 /// use ailloli_ui_runtime::popup::{PopupId, PopupPortal};
2533 /// assert_eq!(PopupPortal::<()>::new().resolved_viewport(PopupId::new(1)), None);
2534 /// ```
2535 pub fn resolved_viewport(&self, popup_id: PopupId) -> Option<Rect> {
2536 self.entries
2537 .get(&popup_id)
2538 .and_then(|entry| entry.resolved_viewport)
2539 }
2540
2541 /// Updates the retained anchor used by the selected presentation backend.
2542 ///
2543 /// This does not change popup bounds: the anchor belongs to the semantic
2544 /// request while [`Self::set_bounds`] records the rectangle produced by the
2545 /// active backend.
2546 /// `None` explicitly clears the semantic anchor. Validation occurs before
2547 /// ID lookup, so an invalid anchor returns `InvalidBounds` even for an
2548 /// unknown ID.
2549 ///
2550 /// # Errors
2551 ///
2552 /// Returns `InvalidBounds` for non-finite edges/negative dimensions, or
2553 /// `UnknownPopup` after successful validation.
2554 ///
2555 /// # Examples
2556 ///
2557 /// ```
2558 /// use ailloli_ui_core::{ElementId, Rect}; use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
2559 /// let mut portal = PopupPortal::<()>::new(); let id = PopupId::new(1);
2560 /// portal.register(PopupRequest::new(id, PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty)))?;
2561 /// let anchor = Rect::new(1.0, 2.0, 3.0, 4.0); portal.set_anchor(id, Some(anchor))?;
2562 /// assert_eq!(portal.request(id).unwrap().anchor(), Some(anchor));
2563 /// # Ok::<(), PopupPortalError>(())
2564 /// ```
2565 pub fn set_anchor(
2566 &mut self,
2567 popup_id: PopupId,
2568 anchor: Option<Rect>,
2569 ) -> Result<(), PopupPortalError> {
2570 if anchor.is_some_and(|anchor| !rect_is_valid(anchor)) {
2571 return Err(PopupPortalError::InvalidBounds);
2572 }
2573 let entry = self
2574 .entries
2575 .get_mut(&popup_id)
2576 .ok_or(PopupPortalError::UnknownPopup)?;
2577 entry.request.anchor = anchor;
2578 Ok(())
2579 }
2580
2581 /// Publishes provider-neutral placement inputs for a registered popup.
2582 ///
2583 /// The update is atomic: every geometry value is validated before the
2584 /// retained request is changed. Previously resolved backend bounds are
2585 /// cleared only when a placement field changes, so an idempotent repaint
2586 /// cannot discard geometry that the host already resolved.
2587 /// Backend and mount policy are not part of [`PopupPlacementSpec`] and stay
2588 /// unchanged.
2589 ///
2590 /// # Errors
2591 ///
2592 /// All geometry validation failures map to `InvalidBounds`; a valid update
2593 /// for an absent ID returns `UnknownPopup`.
2594 ///
2595 /// # Examples
2596 ///
2597 /// ```
2598 /// use ailloli_ui_core::{ElementId, Rect, Size}; use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
2599 /// let mut portal = PopupPortal::<()>::new(); let id = PopupId::new(1);
2600 /// portal.register(PopupRequest::new(id, PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty)))?;
2601 /// let spec = PopupPlacementSpec::new(Rect::new(1.0, 2.0, 3.0, 4.0), Size::new(20.0, 10.0)).with_gap(2.0);
2602 /// portal.set_placement_request(id, spec)?; assert_eq!(portal.request(id).unwrap().gap(), 2.0);
2603 /// # Ok::<(), PopupPortalError>(())
2604 /// ```
2605 pub fn set_placement_request(
2606 &mut self,
2607 popup_id: PopupId,
2608 placement: PopupPlacementSpec,
2609 ) -> Result<(), PopupPortalError> {
2610 validate_anchor(placement.anchor).map_err(|_| PopupPortalError::InvalidBounds)?;
2611 validate_desired_size(placement.desired_size)
2612 .map_err(|_| PopupPortalError::InvalidBounds)?;
2613 validate_gap(placement.gap).map_err(|_| PopupPortalError::InvalidBounds)?;
2614
2615 let entry = self
2616 .entries
2617 .get_mut(&popup_id)
2618 .ok_or(PopupPortalError::UnknownPopup)?;
2619 let geometry_changed = entry.request.anchor != Some(placement.anchor)
2620 || entry.request.desired_size != Some(placement.desired_size)
2621 || entry.request.placement != placement.placement
2622 || entry.request.alignment != placement.alignment
2623 || entry.request.gap != placement.gap
2624 || entry.request.allow_flip != placement.allow_flip;
2625 entry.request.anchor = Some(placement.anchor);
2626 entry.request.desired_size = Some(placement.desired_size);
2627 entry.request.placement = placement.placement;
2628 entry.request.alignment = placement.alignment;
2629 entry.request.gap = placement.gap;
2630 entry.request.allow_flip = placement.allow_flip;
2631 if geometry_changed {
2632 entry.bounds = None;
2633 entry.resolved_viewport = None;
2634 }
2635 Ok(())
2636 }
2637
2638 /// Records bounds produced by the chosen popup backend for hit-testing.
2639 ///
2640 /// Bounds are global logical pixels and must have finite edges/non-negative
2641 /// dimensions. Any previously associated resolved viewport is cleared.
2642 /// Validation precedes ID lookup.
2643 ///
2644 /// # Errors
2645 ///
2646 /// Returns `InvalidBounds` or `UnknownPopup`.
2647 ///
2648 /// # Examples
2649 ///
2650 /// ```
2651 /// use ailloli_ui_core::{ElementId, Rect}; use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
2652 /// let mut portal = PopupPortal::<()>::new(); let id = PopupId::new(1);
2653 /// portal.register(PopupRequest::new(id, PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty)))?;
2654 /// let bounds = Rect::new(5.0, 6.0, 20.0, 10.0); portal.set_bounds(id, bounds)?;
2655 /// assert_eq!(portal.bounds(id), Some(bounds));
2656 /// # Ok::<(), PopupPortalError>(())
2657 /// ```
2658 pub fn set_bounds(&mut self, popup_id: PopupId, bounds: Rect) -> Result<(), PopupPortalError> {
2659 if !rect_is_valid(bounds) {
2660 return Err(PopupPortalError::InvalidBounds);
2661 }
2662 let entry = self
2663 .entries
2664 .get_mut(&popup_id)
2665 .ok_or(PopupPortalError::UnknownPopup)?;
2666 entry.bounds = Some(bounds);
2667 entry.resolved_viewport = None;
2668 Ok(())
2669 }
2670
2671 /// Records bounds resolved by a host together with its complete viewport.
2672 ///
2673 /// Both rectangles are validated before either value is committed, so a
2674 /// rejected update cannot separate bounds from the viewport that produced
2675 /// them.
2676 /// Geometry is global logical pixels. Viewport must have strictly positive
2677 /// dimensions, but bounds may be zero-sized and need not lie inside it.
2678 ///
2679 /// # Errors
2680 ///
2681 /// Any invalid/empty viewport or invalid bounds maps to `InvalidBounds`;
2682 /// valid geometry for an absent ID returns `UnknownPopup`.
2683 ///
2684 /// # Examples
2685 ///
2686 /// ```
2687 /// use ailloli_ui_core::{ElementId, Rect}; use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
2688 /// let mut portal = PopupPortal::<()>::new(); let id = PopupId::new(1);
2689 /// portal.register(PopupRequest::new(id, PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty)))?;
2690 /// let viewport = Rect::new(0.0, 0.0, 100.0, 80.0); let bounds = Rect::new(5.0, 6.0, 20.0, 10.0);
2691 /// portal.set_resolved_bounds(id, viewport, bounds)?; assert_eq!(portal.resolved_viewport(id), Some(viewport));
2692 /// # Ok::<(), PopupPortalError>(())
2693 /// ```
2694 pub fn set_resolved_bounds(
2695 &mut self,
2696 popup_id: PopupId,
2697 viewport: Rect,
2698 bounds: Rect,
2699 ) -> Result<(), PopupPortalError> {
2700 validate_viewport(viewport).map_err(|_| PopupPortalError::InvalidBounds)?;
2701 if !rect_is_valid(bounds) {
2702 return Err(PopupPortalError::InvalidBounds);
2703 }
2704 let entry = self
2705 .entries
2706 .get_mut(&popup_id)
2707 .ok_or(PopupPortalError::UnknownPopup)?;
2708 entry.bounds = Some(bounds);
2709 entry.resolved_viewport = Some(viewport);
2710 Ok(())
2711 }
2712
2713 /// Clears both backend bounds and their optional resolved viewport.
2714 ///
2715 /// Visibility, semantic anchor/request, and z-order remain unchanged.
2716 ///
2717 /// # Errors
2718 ///
2719 /// Returns `UnknownPopup` for an absent ID.
2720 ///
2721 /// # Examples
2722 ///
2723 /// ```
2724 /// use ailloli_ui_core::{ElementId, Rect}; use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
2725 /// let mut portal = PopupPortal::<()>::new(); let id = PopupId::new(1);
2726 /// portal.register(PopupRequest::new(id, PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty)))?;
2727 /// portal.set_bounds(id, Rect::new(0.0, 0.0, 1.0, 1.0))?; portal.clear_bounds(id)?;
2728 /// assert_eq!(portal.bounds(id), None);
2729 /// # Ok::<(), PopupPortalError>(())
2730 /// ```
2731 pub fn clear_bounds(&mut self, popup_id: PopupId) -> Result<(), PopupPortalError> {
2732 let entry = self
2733 .entries
2734 .get_mut(&popup_id)
2735 .ok_or(PopupPortalError::UnknownPopup)?;
2736 entry.bounds = None;
2737 entry.resolved_viewport = None;
2738 Ok(())
2739 }
2740
2741 /// Opens or raises a popup and emits presentation/focus intents.
2742 ///
2743 /// A closed popup emits `Present`, followed by `MoveFocusInto` when
2744 /// requested. Reopening an open popup raises it within its mount stratum but
2745 /// emits no duplicate intents. Geometry is optional. A child requires its
2746 /// parent to be open.
2747 ///
2748 /// # Errors
2749 ///
2750 /// Returns `UnknownPopup` or `ParentNotOpen` without changing visibility.
2751 ///
2752 /// # Panics
2753 ///
2754 /// Panics only if the internal popup map loses the entry between the initial
2755 /// existence check and its mutation.
2756 ///
2757 /// # Examples
2758 ///
2759 /// ```
2760 /// use ailloli_ui_core::ElementId; use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
2761 /// let mut portal = PopupPortal::<()>::new(); let id = PopupId::new(1);
2762 /// portal.register(PopupRequest::new(id, PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty)))?;
2763 /// let outcome = portal.open(id)?; assert!(outcome.handled());
2764 /// assert!(matches!(outcome.intents(), [PopupIntent::Present { popup_id }] if *popup_id == id));
2765 /// # Ok::<(), PopupPortalError>(())
2766 /// ```
2767 pub fn open(&mut self, popup_id: PopupId) -> Result<PopupPortalOutcome, PopupPortalError> {
2768 let parent = self
2769 .entries
2770 .get(&popup_id)
2771 .ok_or(PopupPortalError::UnknownPopup)?
2772 .request
2773 .parent();
2774 if parent.is_some_and(|id| !self.is_open(id)) {
2775 return Err(PopupPortalError::ParentNotOpen);
2776 }
2777
2778 let entry = self
2779 .entries
2780 .get_mut(&popup_id)
2781 .expect("popup existence checked above");
2782 let was_open = entry.open;
2783 entry.open = true;
2784 let focus_policy = entry.request.semantics.focus_policy();
2785 let mount_policy = entry.request.mount_policy();
2786
2787 self.raise_in_effective_z_order(popup_id, mount_policy);
2788
2789 let mut outcome = PopupPortalOutcome::handled_empty();
2790 if !was_open {
2791 outcome.intents.push(PopupIntent::Present { popup_id });
2792 match focus_policy {
2793 PopupFocusPolicy::None => {}
2794 PopupFocusPolicy::MoveIntoPopup => {
2795 outcome.intents.push(PopupIntent::MoveFocusInto {
2796 popup_id,
2797 trap: false,
2798 });
2799 }
2800 PopupFocusPolicy::TrapWithinPopup => {
2801 outcome.intents.push(PopupIntent::MoveFocusInto {
2802 popup_id,
2803 trap: true,
2804 });
2805 }
2806 }
2807 }
2808 Ok(outcome)
2809 }
2810
2811 /// Programmatically closes an open popup subtree.
2812 ///
2813 /// This is shorthand for `close_with_reason(id, Programmatic)`. Unknown IDs
2814 /// return an unhandled empty outcome; a registered but already-closed ID is
2815 /// handled with no intents.
2816 ///
2817 /// # Examples
2818 ///
2819 /// ```
2820 /// use ailloli_ui_core::ElementId; use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
2821 /// let mut portal = PopupPortal::<()>::new(); let id = PopupId::new(1);
2822 /// portal.register(PopupRequest::new(id, PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty)))?; portal.open(id)?;
2823 /// assert!(portal.close(id).handled()); assert!(!portal.is_open(id));
2824 /// # Ok::<(), PopupPortalError>(())
2825 /// ```
2826 pub fn close(&mut self, popup_id: PopupId) -> PopupPortalOutcome {
2827 self.close_with_reason(popup_id, PopupDismissReason::Programmatic)
2828 }
2829
2830 /// Closes a popup and every registered descendant with explicit root reason.
2831 ///
2832 /// Open descendants dismiss topmost-first with `ParentClosed`; the root uses
2833 /// `reason`. Bounds are cleared, registrations remain, and optional owner
2834 /// focus restoration is emitted last. Unknown IDs are unhandled.
2835 ///
2836 /// # Examples
2837 ///
2838 /// ```
2839 /// use ailloli_ui_core::ElementId; use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
2840 /// let mut portal = PopupPortal::<()>::new(); let id = PopupId::new(1);
2841 /// portal.register(PopupRequest::new(id, PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty)))?; portal.open(id)?;
2842 /// let outcome = portal.close_with_reason(id, PopupDismissReason::Escape);
2843 /// assert!(matches!(outcome.intents()[0], PopupIntent::Dismiss { reason: PopupDismissReason::Escape, .. }));
2844 /// # Ok::<(), PopupPortalError>(())
2845 /// ```
2846 pub fn close_with_reason(
2847 &mut self,
2848 popup_id: PopupId,
2849 reason: PopupDismissReason,
2850 ) -> PopupPortalOutcome {
2851 self.close_tree(popup_id, reason, true)
2852 }
2853
2854 /// Removes a registration and all registered descendants.
2855 ///
2856 /// Open entries first emit `Unregistered`/`ParentClosed` dismissals without
2857 /// focus restoration, then all subtree entries/z-order records are removed.
2858 /// A registered closed entry still yields `handled == true`; unknown IDs do
2859 /// not.
2860 ///
2861 /// # Examples
2862 ///
2863 /// ```
2864 /// use ailloli_ui_core::ElementId; use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
2865 /// let mut portal = PopupPortal::<()>::new(); let id = PopupId::new(1);
2866 /// portal.register(PopupRequest::new(id, PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty)))?;
2867 /// assert!(portal.unregister(id).handled()); assert!(!portal.contains(id));
2868 /// # Ok::<(), PopupPortalError>(())
2869 /// ```
2870 pub fn unregister(&mut self, popup_id: PopupId) -> PopupPortalOutcome {
2871 let existed = self.entries.contains_key(&popup_id);
2872 let mut outcome = self.close_tree(popup_id, PopupDismissReason::Unregistered, false);
2873 let descendants = self.descendants_including(popup_id);
2874 for id in descendants {
2875 self.entries.remove(&id);
2876 self.z_order.retain(|candidate| *candidate != id);
2877 }
2878 outcome.handled |= existed;
2879 outcome
2880 }
2881
2882 /// Iterates open popups from bottom to top across the fixed mount strata.
2883 ///
2884 /// The iterator borrows the portal, is double-ended, and yields IDs by
2885 /// value. Procedural fallback entries always precede retained overlays.
2886 ///
2887 /// # Examples
2888 ///
2889 /// ```
2890 /// use ailloli_ui_runtime::popup::PopupPortal;
2891 /// let portal = PopupPortal::<()> ::new();
2892 /// assert_eq!(portal.open_ids().next(), None);
2893 /// ```
2894 pub fn open_ids(&self) -> impl DoubleEndedIterator<Item = PopupId> + '_ {
2895 self.z_order.iter().copied()
2896 }
2897
2898 /// Returns the effective topmost open popup across mount strata.
2899 ///
2900 /// `None` means no popup is open.
2901 ///
2902 /// # Examples
2903 ///
2904 /// ```
2905 /// use ailloli_ui_runtime::popup::PopupPortal;
2906 /// assert_eq!(PopupPortal::<()>::new().topmost(), None);
2907 /// ```
2908 pub fn topmost(&self) -> Option<PopupId> {
2909 self.z_order.last().copied()
2910 }
2911
2912 /// Returns the topmost open popup owned by an exact retained element in
2913 /// one presentation.
2914 ///
2915 /// Procedural overlay backends use this to translate their authoritative
2916 /// tree hit-test into a portal popup id before first paint has committed
2917 /// global popup bounds.
2918 /// Exact matching includes logical window, presentation generation,
2919 /// retained-tree namespace, and element ID. Geometry is irrelevant.
2920 ///
2921 /// # Examples
2922 ///
2923 /// ```
2924 /// use ailloli_ui_core::{ElementId, LogicalWindowId}; use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
2925 /// let mut portal = PopupPortal::<()>::new(); let id = PopupId::new(1); let window = LogicalWindowId::new("w");
2926 /// portal.register(PopupRequest::new(id, PopupOwner::new(window.clone(), PresentationGeneration::INITIAL, ElementTreeId::new(3), ElementId(4)), PopupContent::new(View::empty)))?; portal.open(id)?;
2927 /// assert_eq!(portal.topmost_for_owner(&window, PresentationGeneration::INITIAL, ElementTreeId::new(3), ElementId(4)), Some(id));
2928 /// # Ok::<(), PopupPortalError>(())
2929 /// ```
2930 pub fn topmost_for_owner(
2931 &self,
2932 logical_window_id: &LogicalWindowId,
2933 presentation_generation: PresentationGeneration,
2934 element_tree_id: ElementTreeId,
2935 element_id: ElementId,
2936 ) -> Option<PopupId> {
2937 self.z_order.iter().rev().copied().find(|popup_id| {
2938 self.entries.get(popup_id).is_some_and(|entry| {
2939 let owner = entry.request.owner();
2940 entry.open
2941 && owner.belongs_to(logical_window_id, presentation_generation)
2942 && owner.element_tree_id() == element_tree_id
2943 && owner.element_id() == element_id
2944 })
2945 })
2946 }
2947
2948 /// Returns the topmost bounded popup containing `point` for one presentation.
2949 ///
2950 /// Point/bounds use global logical pixels and [`Rect::contains`] edge
2951 /// semantics. Open unpositioned popups cannot hit. Exact logical-window and
2952 /// generation matching prevents stale surfaces from participating.
2953 ///
2954 /// # Examples
2955 ///
2956 /// ```
2957 /// use ailloli_ui_core::{ElementId, LogicalWindowId, Point, Rect}; use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
2958 /// let mut portal = PopupPortal::<()>::new(); let id = PopupId::new(1); let window = LogicalWindowId::new("w");
2959 /// portal.register(PopupRequest::new(id, PopupOwner::new(window.clone(), PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty)))?; portal.set_bounds(id, Rect::new(0.0, 0.0, 10.0, 10.0))?; portal.open(id)?;
2960 /// assert_eq!(portal.hit_test(&window, PresentationGeneration::INITIAL, Point::new(5.0, 5.0)), Some(id));
2961 /// # Ok::<(), PopupPortalError>(())
2962 /// ```
2963 pub fn hit_test(
2964 &self,
2965 logical_window_id: &LogicalWindowId,
2966 presentation_generation: PresentationGeneration,
2967 point: Point,
2968 ) -> Option<PopupId> {
2969 self.z_order.iter().rev().copied().find(|popup_id| {
2970 let Some(entry) = self.entries.get(popup_id) else {
2971 return false;
2972 };
2973 entry.open
2974 && entry
2975 .request
2976 .owner()
2977 .belongs_to(logical_window_id, presentation_generation)
2978 && entry
2979 .bounds
2980 .is_some_and(|bounds| bounds.contains(point.x, point.y))
2981 })
2982 }
2983
2984 /// Resolves a backend-confirmed candidate and a committed-bounds hit using
2985 /// the same effective z-order as every other portal operation.
2986 /// Invalid/closed/stale backend candidates are ignored; when both evidence
2987 /// sources hit different entries, effective topmost order wins.
2988 ///
2989 /// # Examples
2990 ///
2991 /// ```
2992 /// use ailloli_ui_runtime::popup::PopupPortal;
2993 /// // Public pointer routing invokes this arbitration before dismissal.
2994 /// assert!(PopupPortal::<()>::new().topmost().is_none());
2995 /// ```
2996 pub(crate) fn resolve_pointer_hit(
2997 &self,
2998 logical_window_id: &LogicalWindowId,
2999 presentation_generation: PresentationGeneration,
3000 point: Point,
3001 backend_hit: Option<PopupId>,
3002 ) -> Option<PopupId> {
3003 let backend_hit = backend_hit.filter(|popup_id| {
3004 self.entries.get(popup_id).is_some_and(|entry| {
3005 entry.open
3006 && entry
3007 .request
3008 .owner()
3009 .belongs_to(logical_window_id, presentation_generation)
3010 })
3011 });
3012 let bounds_hit = self.hit_test(logical_window_id, presentation_generation, point);
3013 self.z_order
3014 .iter()
3015 .rev()
3016 .copied()
3017 .find(|candidate| Some(*candidate) == backend_hit || Some(*candidate) == bounds_hit)
3018 }
3019
3020 /// Routes a pointer press through popup z-order.
3021 ///
3022 /// Popups above the hit popup see an outside press and may close. A hit on
3023 /// an interactive popup is consumed so underlying content is not activated.
3024 /// Routing is restricted to the exact logical window/generation and uses
3025 /// global logical-pixel geometry. `handled` can be true with no dismissal
3026 /// when an inside popup consumes pointer input.
3027 ///
3028 /// # Examples
3029 ///
3030 /// ```
3031 /// use ailloli_ui_core::{ElementId, LogicalWindowId, Point, Rect}; use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
3032 /// let mut portal = PopupPortal::<()>::new(); let id = PopupId::new(1); let window = LogicalWindowId::new("w");
3033 /// portal.register(PopupRequest::new(id, PopupOwner::new(window.clone(), PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty)))?; portal.set_bounds(id, Rect::new(0.0, 0.0, 10.0, 10.0))?; portal.open(id)?;
3034 /// assert!(portal.handle_pointer_press(&window, PresentationGeneration::INITIAL, Point::new(20.0, 20.0)).handled());
3035 /// assert!(!portal.is_open(id));
3036 /// # Ok::<(), PopupPortalError>(())
3037 /// ```
3038 pub fn handle_pointer_press(
3039 &mut self,
3040 logical_window_id: &LogicalWindowId,
3041 presentation_generation: PresentationGeneration,
3042 point: Point,
3043 ) -> PopupPortalOutcome {
3044 self.handle_pointer_press_with_backend_hit(
3045 logical_window_id,
3046 presentation_generation,
3047 point,
3048 None,
3049 )
3050 }
3051
3052 /// Routes a pointer press using an optional hit confirmed by the selected
3053 /// presentation backend.
3054 ///
3055 /// The explicit hit is useful before a procedural overlay's first paint,
3056 /// when retained overlay hit regions exist but global portal bounds have
3057 /// not yet been committed. It is accepted only when the popup is open and
3058 /// belongs to the routed presentation, then arbitrated with any bounds hit
3059 /// according to the portal's effective z-order.
3060 /// `point` remains relevant for competing committed-bounds hits. A backend
3061 /// hit does not require portal bounds.
3062 ///
3063 /// # Examples
3064 ///
3065 /// ```
3066 /// use ailloli_ui_core::{ElementId, LogicalWindowId, Point}; use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
3067 /// let mut portal = PopupPortal::<()>::new(); let id = PopupId::new(1); let window = LogicalWindowId::new("w");
3068 /// portal.register(PopupRequest::new(id, PopupOwner::new(window.clone(), PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty)))?; portal.open(id)?;
3069 /// let outcome = portal.handle_pointer_press_with_backend_hit(&window, PresentationGeneration::INITIAL, Point::new(500.0, 500.0), Some(id));
3070 /// assert!(outcome.handled() && portal.is_open(id));
3071 /// # Ok::<(), PopupPortalError>(())
3072 /// ```
3073 pub fn handle_pointer_press_with_backend_hit(
3074 &mut self,
3075 logical_window_id: &LogicalWindowId,
3076 presentation_generation: PresentationGeneration,
3077 point: Point,
3078 backend_hit: Option<PopupId>,
3079 ) -> PopupPortalOutcome {
3080 let hit = self.resolve_pointer_hit(
3081 logical_window_id,
3082 presentation_generation,
3083 point,
3084 backend_hit,
3085 );
3086 let snapshot: Vec<PopupId> = self.z_order.iter().rev().copied().collect();
3087 let mut outcome = PopupPortalOutcome::default();
3088
3089 for popup_id in snapshot {
3090 if Some(popup_id) == hit {
3091 if self
3092 .entries
3093 .get(&popup_id)
3094 .is_some_and(|entry| entry.request.semantics.consumes_pointer_input())
3095 {
3096 outcome.handled = true;
3097 }
3098 break;
3099 }
3100
3101 let Some(entry) = self.entries.get(&popup_id) else {
3102 continue;
3103 };
3104 if !entry.open {
3105 continue;
3106 }
3107 if !entry
3108 .request
3109 .owner()
3110 .belongs_to(logical_window_id, presentation_generation)
3111 {
3112 continue;
3113 }
3114 if !entry.request.semantics.dismisses_on_outside_press() {
3115 if entry.request.semantics.consumes_pointer_input() {
3116 outcome.handled = true;
3117 }
3118 break;
3119 }
3120
3121 let consumes = entry.request.semantics.consumes_pointer_input();
3122 let dismissed = self.close_tree(popup_id, PopupDismissReason::OutsidePress, true);
3123 outcome.append(dismissed);
3124 outcome.handled |= consumes;
3125 }
3126 outcome
3127 }
3128
3129 /// Dismisses the topmost popup in a presentation when it allows Escape.
3130 ///
3131 /// A topmost popup that disables Escape blocks lower popups and returns an
3132 /// unhandled outcome. Successful close uses reason `Escape` and may restore
3133 /// owner focus.
3134 ///
3135 /// # Examples
3136 ///
3137 /// ```
3138 /// use ailloli_ui_core::{ElementId, LogicalWindowId}; use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
3139 /// let mut portal = PopupPortal::<()>::new(); let id = PopupId::new(1); let window = LogicalWindowId::new("w");
3140 /// portal.register(PopupRequest::new(id, PopupOwner::new(window.clone(), PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty)))?; portal.open(id)?;
3141 /// assert!(portal.handle_escape(&window, PresentationGeneration::INITIAL).handled());
3142 /// assert!(!portal.is_open(id));
3143 /// # Ok::<(), PopupPortalError>(())
3144 /// ```
3145 pub fn handle_escape(
3146 &mut self,
3147 logical_window_id: &LogicalWindowId,
3148 presentation_generation: PresentationGeneration,
3149 ) -> PopupPortalOutcome {
3150 let candidate = self.z_order.iter().rev().copied().find(|popup_id| {
3151 self.entries.get(popup_id).is_some_and(|entry| {
3152 entry
3153 .request
3154 .owner()
3155 .belongs_to(logical_window_id, presentation_generation)
3156 })
3157 });
3158 let Some(popup_id) = candidate else {
3159 return PopupPortalOutcome::default();
3160 };
3161 let dismisses = self
3162 .entries
3163 .get(&popup_id)
3164 .is_some_and(|entry| entry.request.semantics.dismisses_on_escape());
3165 if !dismisses {
3166 return PopupPortalOutcome::default();
3167 }
3168 self.close_tree(popup_id, PopupDismissReason::Escape, true)
3169 }
3170
3171 /// Removes every registration whose owner callback reports missing.
3172 ///
3173 /// The callback sees all registrations in unspecified hash-map order. Stale
3174 /// roots are closed/removed with descendants; duplicate descendant matches
3175 /// are collapsed. Open roots can emit dismissal intents; focus is not
3176 /// restored during stale-owner pruning.
3177 ///
3178 /// # Examples
3179 ///
3180 /// ```
3181 /// use ailloli_ui_core::ElementId; use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
3182 /// let mut portal = PopupPortal::<()>::new(); let id = PopupId::new(1);
3183 /// portal.register(PopupRequest::new(id, PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(0), ElementId(9)), PopupContent::new(View::empty)))?;
3184 /// assert!(portal.prune_stale_owners(|owner| owner.element_id() != ElementId(9)).handled());
3185 /// assert!(!portal.contains(id));
3186 /// # Ok::<(), PopupPortalError>(())
3187 /// ```
3188 pub fn prune_stale_owners(
3189 &mut self,
3190 mut owner_is_alive: impl FnMut(&PopupOwner) -> bool,
3191 ) -> PopupPortalOutcome {
3192 let stale: Vec<PopupId> = self
3193 .entries
3194 .iter()
3195 .filter_map(|(id, entry)| (!owner_is_alive(entry.request.owner())).then_some(*id))
3196 .collect();
3197 self.remove_stale_roots(stale, PopupDismissReason::OwnerRemoved)
3198 }
3199
3200 /// Removes registrations owned by one retained tree whose element no
3201 /// longer exists, without inspecting registrations from sibling trees.
3202 ///
3203 /// A [`crate::app::RuntimeHandle`] can be shared by multiple windows. A
3204 /// tree-local reconcile must therefore never decide that owners belonging
3205 /// to another tree are stale.
3206 /// Callback order is unspecified. Removal semantics match
3207 /// [`Self::prune_stale_owners`] and do not restore focus.
3208 ///
3209 /// # Examples
3210 ///
3211 /// ```
3212 /// use ailloli_ui_core::ElementId; use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
3213 /// let mut portal = PopupPortal::<()>::new(); let id = PopupId::new(1);
3214 /// portal.register(PopupRequest::new(id, PopupOwner::new("w", PresentationGeneration::INITIAL, ElementTreeId::new(3), ElementId(9)), PopupContent::new(View::empty)))?;
3215 /// portal.prune_stale_owners_in_tree(ElementTreeId::new(3), |_| false);
3216 /// assert!(!portal.contains(id));
3217 /// # Ok::<(), PopupPortalError>(())
3218 /// ```
3219 pub fn prune_stale_owners_in_tree(
3220 &mut self,
3221 element_tree_id: ElementTreeId,
3222 mut element_is_alive: impl FnMut(ElementId) -> bool,
3223 ) -> PopupPortalOutcome {
3224 let stale: Vec<PopupId> = self
3225 .entries
3226 .iter()
3227 .filter_map(|(id, entry)| {
3228 let owner = entry.request.owner();
3229 (owner.element_tree_id() == element_tree_id
3230 && !element_is_alive(owner.element_id()))
3231 .then_some(*id)
3232 })
3233 .collect();
3234 self.remove_stale_roots(stale, PopupDismissReason::OwnerRemoved)
3235 }
3236
3237 /// Removes every registration owned by a released retained-tree
3238 /// namespace, including registered descendants.
3239 ///
3240 /// No backend intent is returned because the presentation tree itself is
3241 /// being destroyed. The caller uses the returned identities to discard
3242 /// queued effects that can no longer be applied.
3243 /// IDs are returned in ascending numeric order. Registrations belonging to
3244 /// other tree namespaces remain intact.
3245 ///
3246 /// # Examples
3247 ///
3248 /// ```
3249 /// use ailloli_ui_core::ElementId; use ailloli_ui_runtime::{app::{PresentationGeneration, Runtime, RuntimeHandle}, component::View, popup::*};
3250 /// let shared = RuntimeHandle::<()>::new(); let runtime = Runtime::new(shared.clone()); let id = PopupId::new(1);
3251 /// runtime.runtime.register_popup(PopupRequest::new(id, PopupOwner::new("w", PresentationGeneration::INITIAL, runtime.runtime.element_tree_id(), ElementId(1)), PopupContent::new(View::empty)))?;
3252 /// drop(runtime); assert!(!shared.popup_portal().borrow().contains(id));
3253 /// # Ok::<(), PopupPortalError>(())
3254 /// ```
3255 pub(crate) fn release_element_tree(&mut self, element_tree_id: ElementTreeId) -> Vec<PopupId> {
3256 let before: HashSet<PopupId> = self.entries.keys().copied().collect();
3257 let stale: Vec<PopupId> = self
3258 .entries
3259 .iter()
3260 .filter_map(|(id, entry)| {
3261 (entry.request.owner().element_tree_id() == element_tree_id).then_some(*id)
3262 })
3263 .collect();
3264 let _ = self.remove_stale_roots(stale, PopupDismissReason::OwnerRemoved);
3265
3266 let mut removed: Vec<PopupId> = before
3267 .into_iter()
3268 .filter(|popup_id| !self.entries.contains_key(popup_id))
3269 .collect();
3270 removed.sort_by_key(|popup_id| popup_id.get());
3271 removed
3272 }
3273
3274 /// Removes popups attached to obsolete generations of a logical window.
3275 ///
3276 /// Registrations for other logical windows and for `current_generation`
3277 /// remain. Stale roots and descendants are closed/removed with
3278 /// `PresentationStale`/`ParentClosed`; focus restoration is suppressed.
3279 ///
3280 /// # Examples
3281 ///
3282 /// ```
3283 /// use ailloli_ui_core::{ElementId, LogicalWindowId}; use ailloli_ui_runtime::{app::PresentationGeneration, component::View, popup::*};
3284 /// let mut portal = PopupPortal::<()>::new(); let id = PopupId::new(1); let window = LogicalWindowId::new("w");
3285 /// portal.register(PopupRequest::new(id, PopupOwner::new(window.clone(), PresentationGeneration::new(1), ElementTreeId::new(0), ElementId(1)), PopupContent::new(View::empty)))?; portal.open(id)?;
3286 /// assert!(portal.close_stale_presentations(&window, PresentationGeneration::new(2)).handled());
3287 /// assert!(!portal.contains(id));
3288 /// # Ok::<(), PopupPortalError>(())
3289 /// ```
3290 pub fn close_stale_presentations(
3291 &mut self,
3292 logical_window_id: &LogicalWindowId,
3293 current_generation: PresentationGeneration,
3294 ) -> PopupPortalOutcome {
3295 let stale: Vec<PopupId> = self
3296 .entries
3297 .iter()
3298 .filter_map(|(id, entry)| {
3299 let owner = entry.request.owner();
3300 (owner.logical_window_id() == logical_window_id
3301 && owner.presentation_generation() != current_generation)
3302 .then_some(*id)
3303 })
3304 .collect();
3305 self.remove_stale_roots(stale, PopupDismissReason::PresentationStale)
3306 }
3307
3308 /// Collapses stale descendants to roots, closes/removes them deterministically.
3309 fn remove_stale_roots(
3310 &mut self,
3311 stale: Vec<PopupId>,
3312 reason: PopupDismissReason,
3313 ) -> PopupPortalOutcome {
3314 let stale_set: HashSet<PopupId> = stale.iter().copied().collect();
3315 let mut roots: Vec<PopupId> = stale
3316 .into_iter()
3317 .filter(|id| {
3318 self.entries
3319 .get(id)
3320 .and_then(|entry| entry.request.parent())
3321 .is_none_or(|parent| !stale_set.contains(&parent))
3322 })
3323 .collect();
3324 roots.sort_by_key(|id| {
3325 self.z_order
3326 .iter()
3327 .position(|candidate| candidate == id)
3328 .map(|position| (0_u8, position as u64))
3329 .unwrap_or((1_u8, id.get()))
3330 });
3331 let mut outcome = PopupPortalOutcome::default();
3332 for root in roots {
3333 outcome.append(self.close_tree(root, reason, false));
3334 for id in self.descendants_including(root) {
3335 self.entries.remove(&id);
3336 self.z_order.retain(|candidate| *candidate != id);
3337 }
3338 }
3339 outcome
3340 }
3341
3342 /// Raises a popup within its fixed procedural/retained mount stratum.
3343 fn raise_in_effective_z_order(&mut self, popup_id: PopupId, mount_policy: PopupMountPolicy) {
3344 self.z_order.retain(|candidate| *candidate != popup_id);
3345 match mount_policy {
3346 PopupMountPolicy::ProceduralFallback => {
3347 let retained_start = self
3348 .z_order
3349 .iter()
3350 .position(|candidate| {
3351 self.entries.get(candidate).is_some_and(|entry| {
3352 entry.request.mount_policy() == PopupMountPolicy::RetainedOverlay
3353 })
3354 })
3355 .unwrap_or(self.z_order.len());
3356 self.z_order.insert(retained_start, popup_id);
3357 }
3358 PopupMountPolicy::RetainedOverlay => self.z_order.push(popup_id),
3359 }
3360 }
3361
3362 /// Closes an open subtree topmost-first and optionally restores root focus.
3363 fn close_tree(
3364 &mut self,
3365 popup_id: PopupId,
3366 reason: PopupDismissReason,
3367 restore_focus: bool,
3368 ) -> PopupPortalOutcome {
3369 if !self.entries.contains_key(&popup_id) {
3370 return PopupPortalOutcome::default();
3371 }
3372
3373 let mut ids = self.descendants_including(popup_id);
3374 ids.sort_by_key(|id| {
3375 self.z_order
3376 .iter()
3377 .position(|candidate| candidate == id)
3378 .unwrap_or(0)
3379 });
3380 ids.reverse();
3381
3382 let mut outcome = PopupPortalOutcome::handled_empty();
3383 let root_owner = self
3384 .entries
3385 .get(&popup_id)
3386 .map(|entry| entry.request.owner().clone());
3387 let root_restores_focus = self
3388 .entries
3389 .get(&popup_id)
3390 .is_some_and(|entry| entry.request.semantics.restores_focus_on_close());
3391
3392 for id in ids {
3393 let Some(entry) = self.entries.get_mut(&id) else {
3394 continue;
3395 };
3396 if !entry.open {
3397 continue;
3398 }
3399 entry.open = false;
3400 entry.bounds = None;
3401 self.z_order.retain(|candidate| *candidate != id);
3402 outcome.intents.push(PopupIntent::Dismiss {
3403 popup_id: id,
3404 reason: if id == popup_id {
3405 reason
3406 } else {
3407 PopupDismissReason::ParentClosed
3408 },
3409 });
3410 }
3411
3412 if restore_focus && root_restores_focus && !outcome.intents.is_empty() {
3413 if let Some(owner) = root_owner {
3414 outcome.intents.push(PopupIntent::RestoreFocus { owner });
3415 }
3416 }
3417 outcome
3418 }
3419
3420 /// Collects a registration and all parent-linked descendants without cycles.
3421 fn descendants_including(&self, popup_id: PopupId) -> Vec<PopupId> {
3422 let mut result = Vec::new();
3423 let mut pending = vec![popup_id];
3424 while let Some(id) = pending.pop() {
3425 if result.contains(&id) {
3426 continue;
3427 }
3428 result.push(id);
3429 pending.extend(self.entries.iter().filter_map(|(candidate, entry)| {
3430 (entry.request.parent() == Some(id)).then_some(*candidate)
3431 }));
3432 }
3433 result
3434 }
3435}
3436
3437/// Compares only logical-window identity and presentation generation.
3438fn same_presentation(left: &PopupOwner, right: &PopupOwner) -> bool {
3439 left.logical_window_id() == right.logical_window_id()
3440 && left.presentation_generation() == right.presentation_generation()
3441}
3442
3443/// Accepts finite rectangles with finite edges and non-negative dimensions.
3444fn rect_is_valid(rect: Rect) -> bool {
3445 rect_has_finite_edges(rect) && rect.w >= 0.0 && rect.h >= 0.0
3446}
3447
3448/// Checks stored components plus computed right/bottom edges for finiteness.
3449fn rect_has_finite_edges(rect: Rect) -> bool {
3450 rect.x.is_finite()
3451 && rect.y.is_finite()
3452 && rect.w.is_finite()
3453 && rect.h.is_finite()
3454 && rect.right().is_finite()
3455 && rect.bottom().is_finite()
3456}
3457
3458/// Maps invalid anchor geometry to [`PopupPlacementError::InvalidAnchor`].
3459///
3460/// # Errors
3461///
3462/// Returns [`PopupPlacementError::InvalidAnchor`] for a negative dimension or
3463/// any non-finite component or computed edge.
3464fn validate_anchor(anchor: Rect) -> Result<(), PopupPlacementError> {
3465 if rect_is_valid(anchor) {
3466 Ok(())
3467 } else {
3468 Err(PopupPlacementError::InvalidAnchor)
3469 }
3470}
3471
3472/// Accepts finite non-negative desired width and height, including zero.
3473///
3474/// # Errors
3475///
3476/// Returns [`PopupPlacementError::InvalidDesiredSize`] for a negative or
3477/// non-finite width or height.
3478fn validate_desired_size(desired_size: Size) -> Result<(), PopupPlacementError> {
3479 if desired_size.w.is_finite()
3480 && desired_size.h.is_finite()
3481 && desired_size.w >= 0.0
3482 && desired_size.h >= 0.0
3483 {
3484 Ok(())
3485 } else {
3486 Err(PopupPlacementError::InvalidDesiredSize)
3487 }
3488}
3489
3490/// Requires valid finite edges and strictly positive viewport dimensions.
3491///
3492/// # Errors
3493///
3494/// Returns [`PopupPlacementError::InvalidViewport`] for invalid geometry, or
3495/// [`PopupPlacementError::EmptyViewport`] when either dimension is zero.
3496fn validate_viewport(viewport: Rect) -> Result<(), PopupPlacementError> {
3497 if !rect_is_valid(viewport) {
3498 return Err(PopupPlacementError::InvalidViewport);
3499 }
3500 if viewport.w == 0.0 || viewport.h == 0.0 {
3501 return Err(PopupPlacementError::EmptyViewport);
3502 }
3503 Ok(())
3504}
3505
3506/// Accepts a finite non-negative logical-pixel anchor gap.
3507///
3508/// # Errors
3509///
3510/// Returns [`PopupPlacementError::InvalidGap`] for a negative or non-finite gap.
3511fn validate_gap(gap: f32) -> Result<(), PopupPlacementError> {
3512 if gap.is_finite() && gap >= 0.0 {
3513 Ok(())
3514 } else {
3515 Err(PopupPlacementError::InvalidGap)
3516 }
3517}
3518
3519/// Computes non-negative viewport space on one anchor side after the gap.
3520fn available_vertical_space(
3521 anchor: Rect,
3522 viewport: Rect,
3523 placement: PopupPlacement,
3524 gap: f32,
3525) -> f32 {
3526 match placement {
3527 PopupPlacement::Top => (anchor.y - gap - viewport.y).max(0.0),
3528 PopupPlacement::Bottom => (viewport.bottom() - anchor.bottom() - gap).max(0.0),
3529 }
3530}
3531
3532/// Computes aligned bounds after callers validate finite input geometry.
3533///
3534/// Arithmetic can still overflow, which maps to `UnrepresentableGeometry`.
3535///
3536/// # Errors
3537///
3538/// Returns [`PopupPlacementError::UnrepresentableGeometry`] when aligned-edge
3539/// arithmetic produces a non-finite coordinate.
3540fn position_popup_unchecked(
3541 anchor: Rect,
3542 desired_size: Size,
3543 placement: PopupPlacement,
3544 alignment: PopupAlignment,
3545 gap: f32,
3546) -> Result<Rect, PopupPlacementError> {
3547 let x = match alignment {
3548 PopupAlignment::Start => anchor.x,
3549 PopupAlignment::Center => anchor.x + (anchor.w - desired_size.w) * 0.5,
3550 PopupAlignment::End => anchor.right() - desired_size.w,
3551 };
3552 let y = match placement {
3553 PopupPlacement::Top => anchor.y - gap - desired_size.h,
3554 PopupPlacement::Bottom => anchor.bottom() + gap,
3555 };
3556 let bounds = Rect::new(x, y, desired_size.w, desired_size.h);
3557 if rect_has_finite_edges(bounds) {
3558 Ok(bounds)
3559 } else {
3560 Err(PopupPlacementError::UnrepresentableGeometry)
3561 }
3562}