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}