ailloli_ui_runtime/
popup_mount.rs

1//! Retained fallback-overlay trees for provider-neutral popup content.
2//!
3//! [`PopupOverlayMounts`](crate::popup_mount::PopupOverlayMounts) gives every
4//! mounted popup its own persistent
5//! [`crate::app::Runtime`], element-tree namespace, and input router. The
6//! selected host remains responsible for choosing popup geometry; this module
7//! consumes the bounds recorded in [`crate::popup::PopupPortal`] and produces
8//! overlay scene layers that can be appended after the owner window scene.
9
10use std::collections::{BTreeMap, HashMap, HashSet};
11
12use ailloli_ui_core::event::{
13    Event, FileEvent, Key, KeyState, NamedKey, PointerEvent, PointerId, PointerSample,
14};
15use ailloli_ui_core::geometry::Constraints;
16use ailloli_ui_core::math::Scale;
17use ailloli_ui_core::{ElementId, LogicalWindowId, Offset, Point, Rect};
18use ailloli_ui_text::TextSystem;
19
20use crate::app::{PresentationGeneration, Runtime, RuntimeHandle};
21use crate::input::{
22    hit_test_target, EventEnvelope, EventMeta, HoverCursorRole, InputRole, InputRouter,
23    RouteOutcome,
24};
25use crate::popup::{
26    ElementTreeId, PopupBackendCapabilities, PopupContent, PopupFocusPolicy, PopupId,
27    PopupMountPolicy, PopupOwner,
28};
29use crate::scene::{paint_element_observed, LayerKind, PaintCtx, Scene};
30
31/// Element hit in one popup tree, including its tree namespace.
32///
33/// IDs are copyable snapshots. Element identity is meaningful only together
34/// with `element_tree_id`; the popup may be removed after this value is returned.
35///
36/// # Examples
37///
38/// ```
39/// use ailloli_ui_runtime::popup_mount::PopupMountHit;
40/// fn inspect(hit: PopupMountHit) { let _ = (hit.popup_id(), hit.element_tree_id(), hit.element_id()); }
41/// let _ = inspect;
42/// ```
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct PopupMountHit {
45    /// Popup registration containing the hit.
46    popup_id: PopupId,
47    /// Retained-tree namespace containing the element.
48    element_tree_id: ElementTreeId,
49    /// Tree-local hit element.
50    element_id: ElementId,
51}
52
53/// Identity accessors for a retained popup hit.
54impl PopupMountHit {
55    /// Returns the popup registration ID.
56    ///
57    /// # Examples
58    ///
59    /// ```
60    /// use ailloli_ui_runtime::{popup::PopupId, popup_mount::PopupMountHit};
61    /// fn popup(hit: PopupMountHit) -> PopupId { hit.popup_id() }
62    /// let _ = popup;
63    /// ```
64    pub const fn popup_id(self) -> PopupId {
65        self.popup_id
66    }
67
68    /// Returns the retained-tree namespace.
69    ///
70    /// # Examples
71    ///
72    /// ```
73    /// use ailloli_ui_runtime::{popup::ElementTreeId, popup_mount::PopupMountHit};
74    /// fn tree(hit: PopupMountHit) -> ElementTreeId { hit.element_tree_id() }
75    /// let _ = tree;
76    /// ```
77    pub const fn element_tree_id(self) -> ElementTreeId {
78        self.element_tree_id
79    }
80
81    /// Returns the tree-local element ID.
82    ///
83    /// # Examples
84    ///
85    /// ```
86    /// use ailloli_ui_core::ElementId; use ailloli_ui_runtime::popup_mount::PopupMountHit;
87    /// fn element(hit: PopupMountHit) -> ElementId { hit.element_id() }
88    /// let _ = element;
89    /// ```
90    pub const fn element_id(self) -> ElementId {
91        self.element_id
92    }
93}
94
95/// Keyboard-focus owner inside a mounted popup tree.
96///
97/// The triple is a snapshot and does not keep the mount alive.
98///
99/// # Examples
100///
101/// ```
102/// use ailloli_ui_runtime::popup_mount::PopupMountFocus;
103/// fn inspect(focus: PopupMountFocus) { let _ = (focus.popup_id(), focus.element_tree_id(), focus.element_id()); }
104/// let _ = inspect;
105/// ```
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub struct PopupMountFocus {
108    /// Popup registration containing focus.
109    popup_id: PopupId,
110    /// Retained-tree namespace containing focus.
111    element_tree_id: ElementTreeId,
112    /// Tree-local focused element.
113    element_id: ElementId,
114}
115
116/// Identity accessors for retained popup focus.
117impl PopupMountFocus {
118    /// Returns the focused popup registration ID.
119    ///
120    /// # Examples
121    ///
122    /// ```
123    /// use ailloli_ui_runtime::{popup::PopupId, popup_mount::PopupMountFocus};
124    /// fn popup(focus: PopupMountFocus) -> PopupId { focus.popup_id() }
125    /// let _ = popup;
126    /// ```
127    pub const fn popup_id(self) -> PopupId {
128        self.popup_id
129    }
130
131    /// Returns the focused retained-tree namespace.
132    ///
133    /// # Examples
134    ///
135    /// ```
136    /// use ailloli_ui_runtime::{popup::ElementTreeId, popup_mount::PopupMountFocus};
137    /// fn tree(focus: PopupMountFocus) -> ElementTreeId { focus.element_tree_id() }
138    /// let _ = tree;
139    /// ```
140    pub const fn element_tree_id(self) -> ElementTreeId {
141        self.element_tree_id
142    }
143
144    /// Returns the tree-local focused element ID.
145    ///
146    /// # Examples
147    ///
148    /// ```
149    /// use ailloli_ui_core::ElementId; use ailloli_ui_runtime::popup_mount::PopupMountFocus;
150    /// fn element(focus: PopupMountFocus) -> ElementId { focus.element_id() }
151    /// let _ = element;
152    /// ```
153    pub const fn element_id(self) -> ElementId {
154        self.element_id
155    }
156}
157
158/// Observable changes produced by one portal-to-mount synchronization.
159///
160/// Counts use `usize`: `mounted` is newly cached trees, `removed` is destroyed
161/// cached trees, and `open` is the post-sync visible retained-overlay count.
162/// Reconciliation of existing mounts does not make [`Self::changed`] true.
163///
164/// # Examples
165///
166/// ```
167/// use ailloli_ui_runtime::popup_mount::PopupMountSyncOutcome;
168/// let outcome = PopupMountSyncOutcome::default();
169/// assert_eq!((outcome.mounted(), outcome.removed(), outcome.open()), (0, 0, 0));
170/// ```
171#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
172pub struct PopupMountSyncOutcome {
173    /// Newly created persistent popup trees.
174    mounted: usize,
175    /// Persistent popup trees removed from the cache.
176    removed: usize,
177    /// Open matching retained-overlay popups after synchronization.
178    open: usize,
179}
180
181/// Counter accessors and structural-change interpretation.
182impl PopupMountSyncOutcome {
183    /// Returns how many persistent trees were newly mounted.
184    ///
185    /// # Examples
186    ///
187    /// ```
188    /// use ailloli_ui_runtime::popup_mount::PopupMountSyncOutcome;
189    /// assert_eq!(PopupMountSyncOutcome::default().mounted(), 0);
190    /// ```
191    pub const fn mounted(self) -> usize {
192        self.mounted
193    }
194
195    /// Returns how many cached mount trees were removed.
196    ///
197    /// # Examples
198    ///
199    /// ```
200    /// use ailloli_ui_runtime::popup_mount::PopupMountSyncOutcome;
201    /// assert_eq!(PopupMountSyncOutcome::default().removed(), 0);
202    /// ```
203    pub const fn removed(self) -> usize {
204        self.removed
205    }
206
207    /// Returns the post-sync number of open retained-overlay mounts.
208    ///
209    /// # Examples
210    ///
211    /// ```
212    /// use ailloli_ui_runtime::popup_mount::PopupMountSyncOutcome;
213    /// assert_eq!(PopupMountSyncOutcome::default().open(), 0);
214    /// ```
215    pub const fn open(self) -> usize {
216        self.open
217    }
218
219    /// Reports whether mount-cache membership changed.
220    ///
221    /// Changes to open count, bounds, props, layout, or focus alone are not
222    /// represented by this boolean.
223    ///
224    /// # Examples
225    ///
226    /// ```
227    /// use ailloli_ui_runtime::popup_mount::PopupMountSyncOutcome;
228    /// assert!(!PopupMountSyncOutcome::default().changed());
229    /// ```
230    pub const fn changed(self) -> bool {
231        self.mounted != 0 || self.removed != 0
232    }
233}
234
235/// Result of routing an event through the retained popup overlay.
236///
237/// `popup_id` names the local mount receiving dispatch, if any. `consumed`
238/// tells the host not to route the same event into owner-window content; the
239/// nested [`RouteOutcome`] independently reports interaction/dispatch effects.
240///
241/// # Examples
242///
243/// ```
244/// use ailloli_ui_runtime::popup_mount::PopupMountRouteOutcome;
245/// let outcome = PopupMountRouteOutcome::default();
246/// assert_eq!(outcome.popup_id(), None);
247/// assert!(!outcome.consumed());
248/// ```
249#[derive(Debug, Clone, Default, PartialEq, Eq)]
250pub struct PopupMountRouteOutcome {
251    /// Popup-local dispatch target, or `None`.
252    popup_id: Option<PopupId>,
253    /// Whether owner-tree routing must be suppressed.
254    consumed: bool,
255    /// Aggregated retained input-router effects.
256    route: RouteOutcome,
257}
258
259/// Observation and redraw interpretation for mount routing.
260impl PopupMountRouteOutcome {
261    /// Returns the popup selected for local dispatch, if any.
262    ///
263    /// # Examples
264    ///
265    /// ```
266    /// use ailloli_ui_runtime::popup_mount::PopupMountRouteOutcome;
267    /// assert!(PopupMountRouteOutcome::default().popup_id().is_none());
268    /// ```
269    pub const fn popup_id(&self) -> Option<PopupId> {
270        self.popup_id
271    }
272
273    /// Whether owner-window content must not receive this event.
274    ///
275    /// # Examples
276    ///
277    /// ```
278    /// use ailloli_ui_runtime::popup_mount::PopupMountRouteOutcome;
279    /// assert!(!PopupMountRouteOutcome::default().consumed());
280    /// ```
281    pub const fn consumed(&self) -> bool {
282        self.consumed
283    }
284
285    /// Borrows aggregated popup-local router effects.
286    ///
287    /// # Examples
288    ///
289    /// ```
290    /// use ailloli_ui_runtime::popup_mount::PopupMountRouteOutcome;
291    /// assert!(!PopupMountRouteOutcome::default().route().event_dispatched);
292    /// ```
293    pub const fn route(&self) -> &RouteOutcome {
294        &self.route
295    }
296
297    /// Requests redraw when the overlay consumed input or interaction changed.
298    ///
299    /// Event dispatch alone does not imply redraw unless it was also consumed.
300    ///
301    /// # Examples
302    ///
303    /// ```
304    /// use ailloli_ui_runtime::popup_mount::PopupMountRouteOutcome;
305    /// assert!(!PopupMountRouteOutcome::default().needs_redraw());
306    /// ```
307    pub fn needs_redraw(&self) -> bool {
308        self.consumed || self.route.needs_redraw()
309    }
310
311    /// OR-merges interaction and dispatch flags into the accumulated route.
312    fn merge_route(&mut self, route: RouteOutcome) {
313        self.route.interaction_changed |= route.interaction_changed;
314        self.route.event_dispatched |= route.event_dispatched;
315    }
316}
317
318/// Persistent retained runtime and interaction state for one registration.
319struct PopupMount<A> {
320    /// Last synchronized complete owner identity.
321    owner: PopupOwner,
322    /// Independent retained tree sharing the parent application runtime.
323    runtime: Runtime<A>,
324    /// Popup-local input router.
325    input: InputRouter,
326    /// Window-global logical bounds, or `None` before host resolution.
327    bounds: Option<Rect>,
328    /// Whether this cached registration is currently in portal open order.
329    open: bool,
330    /// Last synchronized focus-on-open behavior.
331    focus_policy: PopupFocusPolicy,
332    /// Deferred first-focus request waiting for non-empty layout.
333    focus_on_next_layout: bool,
334}
335
336/// Borrow-independent portal values consumed during synchronization.
337struct PopupSnapshot<A> {
338    /// Popup registration ID.
339    id: PopupId,
340    /// Complete owner identity.
341    owner: PopupOwner,
342    /// Shared content factory.
343    content: PopupContent<A>,
344    /// Optional window-global logical bounds.
345    bounds: Option<Rect>,
346    /// Focus behavior on first open.
347    focus_policy: PopupFocusPolicy,
348}
349
350/// Persistent retained popup trees for one logical window presentation.
351///
352/// Closed registrations remain cached, so reopening the same registered popup
353/// reconciles into the same tree and preserves component state. A mount is
354/// destroyed only when its registration disappears or its owner no longer
355/// belongs to the synchronized presentation.
356/// Pointer capture/hover and focus are isolated per popup tree, while z-order
357/// follows the shared portal. This value is UI-thread-local through its runtime
358/// handle and component state.
359///
360/// # Examples
361///
362/// ```
363/// use ailloli_ui_runtime::{app::RuntimeHandle, popup_mount::PopupOverlayMounts};
364/// let mounts = PopupOverlayMounts::new(RuntimeHandle::<()>::new());
365/// assert!(mounts.is_empty());
366/// ```
367pub struct PopupOverlayMounts<A> {
368    /// Shared parent runtime used for portal access and tree allocation.
369    runtime: RuntimeHandle<A>,
370    /// Exact logical window/generation last synchronized, or `None` initially.
371    presentation: Option<(LogicalWindowId, PresentationGeneration)>,
372    /// Cached persistent trees by popup ID.
373    mounts: HashMap<PopupId, PopupMount<A>>,
374    /// Open matching retained overlays in bottom-to-top portal order.
375    open_order: Vec<PopupId>,
376    /// Popup-local capture owner per pointer.
377    pointer_capture: BTreeMap<PointerId, PopupId>,
378    /// Popup currently hovered by each pointer.
379    pointer_hover: BTreeMap<PointerId, PopupId>,
380    /// Presses consumed by popup authority until matching release/cancel.
381    consumed_pointer_gestures: HashSet<PointerId>,
382    /// Popup tree currently owning strict keyboard focus.
383    focused_popup: Option<PopupId>,
384}
385
386/// Clears every mounted tree's presentation scope before child runtimes drop.
387impl<A> Drop for PopupOverlayMounts<A> {
388    /// Removes presentation-scoped pending effects for all cached mounts.
389    fn drop(&mut self) {
390        for mount in self.mounts.values() {
391            mount.runtime.runtime.clear_presentation_scope();
392        }
393    }
394}
395
396/// Synchronization, frame stages, hit-testing, and event routing for overlays.
397impl<A: 'static> PopupOverlayMounts<A> {
398    /// Creates an empty mount cache sharing `runtime`'s portal/application state.
399    ///
400    /// No retained tree or presentation scope is allocated until synchronization
401    /// observes a matching retained-overlay registration.
402    ///
403    /// # Examples
404    ///
405    /// ```
406    /// use ailloli_ui_runtime::{app::RuntimeHandle, popup_mount::PopupOverlayMounts};
407    /// let mounts = PopupOverlayMounts::new(RuntimeHandle::<()>::new());
408    /// assert_eq!((mounts.len(), mounts.open_len()), (0, 0));
409    /// ```
410    pub fn new(runtime: RuntimeHandle<A>) -> Self {
411        Self {
412            runtime,
413            presentation: None,
414            mounts: HashMap::new(),
415            open_order: Vec::new(),
416            pointer_capture: BTreeMap::new(),
417            pointer_hover: BTreeMap::new(),
418            consumed_pointer_gestures: HashSet::new(),
419            focused_popup: None,
420        }
421    }
422
423    /// Returns cached persistent popup-tree count, including closed entries.
424    ///
425    /// # Examples
426    ///
427    /// ```
428    /// use ailloli_ui_runtime::{app::RuntimeHandle, popup_mount::PopupOverlayMounts};
429    /// assert_eq!(PopupOverlayMounts::new(RuntimeHandle::<()>::new()).len(), 0);
430    /// ```
431    pub fn len(&self) -> usize {
432        self.mounts.len()
433    }
434
435    /// Reports whether no persistent popup tree is cached.
436    ///
437    /// Closed cached mounts make this false even when [`Self::open_len`] is zero.
438    ///
439    /// # Examples
440    ///
441    /// ```
442    /// use ailloli_ui_runtime::{app::RuntimeHandle, popup_mount::PopupOverlayMounts};
443    /// assert!(PopupOverlayMounts::new(RuntimeHandle::<()>::new()).is_empty());
444    /// ```
445    pub fn is_empty(&self) -> bool {
446        self.mounts.is_empty()
447    }
448
449    /// Returns the number of currently open matching retained overlays.
450    ///
451    /// # Examples
452    ///
453    /// ```
454    /// use ailloli_ui_runtime::{app::RuntimeHandle, popup_mount::PopupOverlayMounts};
455    /// assert_eq!(PopupOverlayMounts::new(RuntimeHandle::<()>::new()).open_len(), 0);
456    /// ```
457    pub fn open_len(&self) -> usize {
458        self.open_order.len()
459    }
460
461    /// Reports whether a persistent tree is cached for `popup_id`.
462    ///
463    /// This is distinct from portal registration and open state.
464    ///
465    /// # Examples
466    ///
467    /// ```
468    /// use ailloli_ui_runtime::{app::RuntimeHandle, popup::PopupId, popup_mount::PopupOverlayMounts};
469    /// assert!(!PopupOverlayMounts::new(RuntimeHandle::<()>::new()).contains(PopupId::new(1)));
470    /// ```
471    pub fn contains(&self, popup_id: PopupId) -> bool {
472        self.mounts.contains_key(&popup_id)
473    }
474
475    /// Returns a cached popup mount's allocated retained-tree namespace.
476    ///
477    /// `None` means the popup has no retained mount, even if a procedural or
478    /// unsynchronized portal registration exists.
479    ///
480    /// # Examples
481    ///
482    /// ```
483    /// use ailloli_ui_runtime::{app::RuntimeHandle, popup::PopupId, popup_mount::PopupOverlayMounts};
484    /// assert_eq!(PopupOverlayMounts::new(RuntimeHandle::<()>::new()).element_tree_id(PopupId::new(1)), None);
485    /// ```
486    pub fn element_tree_id(&self, popup_id: PopupId) -> Option<ElementTreeId> {
487        self.mounts
488            .get(&popup_id)
489            .map(|mount| mount.runtime.runtime.element_tree_id())
490    }
491
492    /// Borrows the last synchronized owner of a cached mount.
493    ///
494    /// `None` means no retained mount is cached. Owner liveness can change in
495    /// the portal until the next synchronization.
496    ///
497    /// # Examples
498    ///
499    /// ```
500    /// use ailloli_ui_runtime::{app::RuntimeHandle, popup::PopupId, popup_mount::PopupOverlayMounts};
501    /// assert!(PopupOverlayMounts::new(RuntimeHandle::<()>::new()).owner(PopupId::new(1)).is_none());
502    /// ```
503    pub fn owner(&self, popup_id: PopupId) -> Option<&PopupOwner> {
504        self.mounts.get(&popup_id).map(|mount| &mount.owner)
505    }
506
507    /// Snapshots strict keyboard focus in the claimed popup tree.
508    ///
509    /// `None` means no mounted popup currently owns a focused element or cached
510    /// focus state became stale.
511    ///
512    /// # Examples
513    ///
514    /// ```
515    /// use ailloli_ui_runtime::{app::RuntimeHandle, popup_mount::PopupOverlayMounts};
516    /// assert!(PopupOverlayMounts::new(RuntimeHandle::<()>::new()).focus_owner().is_none());
517    /// ```
518    pub fn focus_owner(&self) -> Option<PopupMountFocus> {
519        let popup_id = self.focused_popup?;
520        let mount = self.mounts.get(&popup_id)?;
521        let element_id = mount.input.focused()?;
522        Some(PopupMountFocus {
523            popup_id,
524            element_tree_id: mount.runtime.runtime.element_tree_id(),
525            element_id,
526        })
527    }
528
529    /// Whether keyboard focus currently belongs to a retained popup tree.
530    ///
531    /// # Examples
532    ///
533    /// ```
534    /// use ailloli_ui_runtime::{app::RuntimeHandle, popup_mount::PopupOverlayMounts};
535    /// assert!(!PopupOverlayMounts::new(RuntimeHandle::<()>::new()).has_focus());
536    /// ```
537    pub fn has_focus(&self) -> bool {
538        self.focus_owner().is_some()
539    }
540
541    /// Input role of the focused retained-popup element.
542    ///
543    /// Returns [`InputRole::None`] when no popup/element owns focus or the mount
544    /// disappeared.
545    ///
546    /// # Examples
547    ///
548    /// ```
549    /// use ailloli_ui_runtime::{app::RuntimeHandle, input::InputRole, popup_mount::PopupOverlayMounts};
550    /// assert_eq!(PopupOverlayMounts::new(RuntimeHandle::<()>::new()).focused_input_role(), InputRole::None);
551    /// ```
552    pub fn focused_input_role(&self) -> InputRole {
553        let Some(popup_id) = self.focused_popup else {
554            return InputRole::None;
555        };
556        let Some(mount) = self.mounts.get(&popup_id) else {
557            return InputRole::None;
558        };
559        mount.input.focused_input_role(&mount.runtime.tree)
560    }
561
562    /// IME caret rectangle translated from popup-local to window coordinates.
563    ///
564    /// Returns `None` without focused popup input, non-empty bounds, committed
565    /// layout, or a widget-provided caret. Dimensions remain logical pixels.
566    ///
567    /// # Examples
568    ///
569    /// ```
570    /// use ailloli_ui_runtime::{app::RuntimeHandle, popup_mount::PopupOverlayMounts};
571    /// assert!(PopupOverlayMounts::new(RuntimeHandle::<()>::new()).focused_ime_cursor_rect_global().is_none());
572    /// ```
573    pub fn focused_ime_cursor_rect_global(&self) -> Option<Rect> {
574        let popup_id = self.focused_popup?;
575        let mount = self.mounts.get(&popup_id)?;
576        let bounds = mount.bounds.filter(non_empty_rect)?;
577        let cursor = mount.input.focused_ime_cursor_rect(&mount.runtime.tree)?;
578        Some(Rect::new(
579            bounds.x + cursor.x,
580            bounds.y + cursor.y,
581            cursor.w,
582            cursor.h,
583        ))
584    }
585
586    /// Cursor role under a window-global point when a retained popup owns it.
587    ///
588    /// `Some(Default)` deliberately masks the owner-tree cursor while the
589    /// pointer rests on passive popup content. `None` means no retained popup
590    /// occupies the point and lets the host query the owner tree instead.
591    /// `point` and returned hit geometry use window-global logical pixels.
592    ///
593    /// # Examples
594    ///
595    /// ```
596    /// use ailloli_ui_core::Point; use ailloli_ui_runtime::{app::RuntimeHandle, popup_mount::PopupOverlayMounts};
597    /// assert!(PopupOverlayMounts::new(RuntimeHandle::<()>::new()).hovered_cursor_role_at_global(Point::new(1.0, 1.0)).is_none());
598    /// ```
599    pub fn hovered_cursor_role_at_global(&self, point: Point) -> Option<HoverCursorRole> {
600        let popup_id = self.popup_at(point)?;
601        let mount = self.mounts.get(&popup_id)?;
602        let bounds = mount.bounds.filter(non_empty_rect)?;
603        let local = local_point(point, bounds)?;
604        Some(
605            mount
606                .input
607                .hovered_cursor_role_at(&mount.runtime.tree, local),
608        )
609    }
610
611    /// Applies queued popup focus effects to every mounted tree in z-order.
612    ///
613    /// Hosts call this before applying the owner-tree effects so a nested
614    /// popup can restore focus into its retained parent without leaking the
615    /// intent into a sibling tree namespace.
616    /// Returns whether any popup focus changed. Before the first sync there is
617    /// no presentation and the method is a no-op returning `false`.
618    ///
619    /// # Examples
620    ///
621    /// ```
622    /// use ailloli_ui_runtime::{app::RuntimeHandle, popup_mount::PopupOverlayMounts};
623    /// let mut mounts = PopupOverlayMounts::new(RuntimeHandle::<()>::new());
624    /// assert!(!mounts.apply_pending_popup_intents());
625    /// ```
626    pub fn apply_pending_popup_intents(&mut self) -> bool {
627        let Some((logical_window_id, presentation_generation)) = self.presentation.clone() else {
628            return false;
629        };
630
631        let mut changed = false;
632        for popup_id in self.open_order.clone() {
633            let Some(mount) = self.mounts.get_mut(&popup_id) else {
634                continue;
635            };
636            let runtime = mount.runtime.runtime.clone();
637            changed |= mount
638                .input
639                .apply_pending_focus_request(&mount.runtime.tree, runtime.clone());
640            changed |= mount.input.apply_pending_popup_intents_for_presentation(
641                &mount.runtime.tree,
642                runtime,
643                &logical_window_id,
644                presentation_generation,
645            );
646        }
647
648        let focus_candidate = self.open_order.iter().rev().find_map(|popup_id| {
649            self.mounts
650                .get(popup_id)
651                .and_then(|mount| mount.input.focused())
652                .map(|_| *popup_id)
653        });
654        if let Some(popup_id) = focus_candidate {
655            self.claim_focus(popup_id);
656        } else {
657            self.focused_popup = None;
658        }
659        changed
660    }
661
662    /// Reconciles every open portal entry belonging to one exact presentation.
663    ///
664    /// Calling this once per frame is intentional: retained reconciliation
665    /// refreshes declarative props while component slots remain attached to the
666    /// mount's persistent element tree.
667    /// Only `RetainedOverlay` registrations with exact logical-window/generation
668    /// ownership participate. Existing closed matching registrations stay
669    /// cached; removed/mismatched mounts are blurred and destroyed. New mounts
670    /// are created only for open entries. The method also clears stale pointer
671    /// and focus ownership. Portal factories/component builds run synchronously.
672    ///
673    /// # Panics
674    ///
675    /// Propagates content-factory/component reconciliation panics and panics on
676    /// conflicting portal/runtime `RefCell` borrows.
677    ///
678    /// # Examples
679    ///
680    /// ```
681    /// use ailloli_ui_core::LogicalWindowId; use ailloli_ui_runtime::{app::{PresentationGeneration, RuntimeHandle}, popup_mount::PopupOverlayMounts};
682    /// let mut mounts = PopupOverlayMounts::new(RuntimeHandle::<()>::new());
683    /// let outcome = mounts.sync(&LogicalWindowId::new("main"), PresentationGeneration::INITIAL);
684    /// assert_eq!((outcome.mounted(), outcome.removed(), outcome.open()), (0, 0, 0));
685    /// ```
686    pub fn sync(
687        &mut self,
688        logical_window_id: &LogicalWindowId,
689        presentation_generation: PresentationGeneration,
690    ) -> PopupMountSyncOutcome {
691        self.presentation = Some((logical_window_id.clone(), presentation_generation));
692
693        let portal_handle = self.runtime.popup_portal();
694        let portal = portal_handle.borrow();
695        let previous_ids: Vec<PopupId> = self.mounts.keys().copied().collect();
696        let retained_ids: HashSet<PopupId> = previous_ids
697            .iter()
698            .copied()
699            .filter(|popup_id| {
700                portal.request(*popup_id).is_some_and(|request| {
701                    request.mount_policy() == PopupMountPolicy::RetainedOverlay
702                        && owner_matches(
703                            request.owner(),
704                            logical_window_id,
705                            presentation_generation,
706                        )
707                })
708            })
709            .collect();
710        drop(portal);
711
712        let previous_len = self.mounts.len();
713        self.mounts.retain(|popup_id, mount| {
714            let retain = retained_ids.contains(popup_id);
715            if !retain {
716                mount
717                    .input
718                    .blur_subtree(&mount.runtime.tree, mount.runtime.runtime.clone());
719                mount.runtime.runtime.clear_presentation_scope();
720            }
721            retain
722        });
723        let removed = previous_len.saturating_sub(self.mounts.len());
724
725        // Dropping a removed mount releases every popup registration owned by
726        // its tree. Re-read the portal afterwards so a nested registration
727        // removed by that cleanup cannot be recreated from a stale snapshot.
728        let portal_handle = self.runtime.popup_portal();
729        let portal = portal_handle.borrow();
730        let open_order: Vec<PopupId> = portal
731            .open_ids()
732            .filter(|popup_id| {
733                portal.request(*popup_id).is_some_and(|request| {
734                    request.mount_policy() == PopupMountPolicy::RetainedOverlay
735                        && owner_matches(
736                            request.owner(),
737                            logical_window_id,
738                            presentation_generation,
739                        )
740                })
741            })
742            .collect();
743        let snapshots: Vec<PopupSnapshot<A>> = open_order
744            .iter()
745            .filter_map(|popup_id| {
746                let request = portal.request(*popup_id)?;
747                Some(PopupSnapshot {
748                    id: *popup_id,
749                    owner: request.owner().clone(),
750                    content: request.content().clone(),
751                    bounds: portal.bounds(*popup_id),
752                    focus_policy: request.semantics().focus_policy(),
753                })
754            })
755            .collect();
756        drop(portal);
757
758        let open_set: HashSet<PopupId> = open_order.iter().copied().collect();
759        for (popup_id, mount) in &mut self.mounts {
760            if !open_set.contains(popup_id) {
761                mount.open = false;
762                mount.bounds = None;
763                mount.focus_on_next_layout = false;
764                mount.runtime.runtime.clear_presentation_scope();
765                mount
766                    .input
767                    .cancel_pointer_state(&mount.runtime.tree, mount.runtime.runtime.clone());
768                mount
769                    .input
770                    .blur_subtree(&mount.runtime.tree, mount.runtime.runtime.clone());
771            }
772        }
773
774        let mut mounted = 0;
775        for snapshot in snapshots {
776            let mount = self.mounts.entry(snapshot.id).or_insert_with(|| {
777                mounted += 1;
778                PopupMount {
779                    owner: snapshot.owner.clone(),
780                    runtime: Runtime::new(self.runtime.clone()),
781                    input: InputRouter::default(),
782                    bounds: snapshot.bounds,
783                    open: false,
784                    focus_policy: snapshot.focus_policy,
785                    focus_on_next_layout: false,
786                }
787            });
788            let newly_opened = !mount.open;
789            mount.owner = snapshot.owner;
790            mount.bounds = snapshot.bounds;
791            mount.open = true;
792            mount.focus_policy = snapshot.focus_policy;
793            mount
794                .runtime
795                .runtime
796                .set_presentation_scope(logical_window_id.clone(), presentation_generation);
797            mount.focus_on_next_layout |=
798                newly_opened && snapshot.focus_policy != PopupFocusPolicy::None;
799            mount.runtime.reconcile_view(snapshot.content.build());
800        }
801
802        self.open_order = open_order;
803        self.pointer_capture
804            .retain(|_, popup_id| open_set.contains(popup_id));
805        self.pointer_hover
806            .retain(|_, popup_id| open_set.contains(popup_id));
807        if self
808            .focused_popup
809            .is_some_and(|popup_id| !open_set.contains(&popup_id))
810        {
811            self.focused_popup = None;
812        }
813
814        PopupMountSyncOutcome {
815            mounted,
816            removed,
817            open: self.open_order.len(),
818        }
819    }
820
821    /// Resolves retained-overlay geometry for one host viewport, then syncs
822    /// the persistent popup trees.
823    ///
824    /// Requests that already carry backend-published bounds but omit the
825    /// declarative anchor or desired size keep those explicit bounds. This
826    /// preserves low-level hosts while allowing fully provider-neutral popup
827    /// requests to flip and clamp through the shared resolver.
828    /// Placement failures are intentionally ignored here; such entries retain
829    /// previous explicit bounds (if any) and still synchronize. Successfully
830    /// resolved bounds are recorded with `viewport` before [`Self::sync`]. All
831    /// geometry uses global logical pixels.
832    ///
833    /// # Examples
834    ///
835    /// ```
836    /// use ailloli_ui_core::{LogicalWindowId, Rect}; use ailloli_ui_runtime::{app::{PresentationGeneration, RuntimeHandle}, popup::PopupBackendCapabilities, popup_mount::PopupOverlayMounts};
837    /// let mut mounts = PopupOverlayMounts::new(RuntimeHandle::<()>::new());
838    /// let outcome = mounts.resolve_and_sync(&LogicalWindowId::new("main"), PresentationGeneration::INITIAL, Rect::new(0.0, 0.0, 800.0, 600.0), PopupBackendCapabilities::overlay_only());
839    /// assert_eq!(outcome.open(), 0);
840    /// ```
841    pub fn resolve_and_sync(
842        &mut self,
843        logical_window_id: &LogicalWindowId,
844        presentation_generation: PresentationGeneration,
845        viewport: Rect,
846        capabilities: PopupBackendCapabilities,
847    ) -> PopupMountSyncOutcome {
848        let resolved: Vec<(PopupId, Rect)> = {
849            let portal = self.runtime.popup_portal();
850            let portal = portal.borrow();
851            portal
852                .open_ids()
853                .filter_map(|popup_id| {
854                    let request = portal.request(popup_id)?;
855                    if request.mount_policy() != PopupMountPolicy::RetainedOverlay
856                        || !owner_matches(
857                            request.owner(),
858                            logical_window_id,
859                            presentation_generation,
860                        )
861                    {
862                        return None;
863                    }
864                    request
865                        .resolve_placement(viewport, capabilities)
866                        .ok()
867                        .map(|placement| (popup_id, placement.bounds()))
868                })
869                .collect()
870        };
871        if !resolved.is_empty() {
872            let portal = self.runtime.popup_portal();
873            let mut portal = portal.borrow_mut();
874            for (popup_id, bounds) in resolved {
875                // Geometry was validated by the resolver; a failure here can
876                // only mean the registration disappeared between borrows.
877                let _ = portal.set_resolved_bounds(popup_id, viewport, bounds);
878            }
879        }
880        self.sync(logical_window_id, presentation_generation)
881    }
882
883    /// Lays out all open popup trees in their non-empty portal bounds.
884    ///
885    /// Width/height become tight logical-pixel constraints; `scale` is physical
886    /// pixels per logical pixel. Missing/zero-or-negative bounds are skipped.
887    /// Pending focus-on-open is applied after successful layout and focus is
888    /// made exclusive across mounts.
889    ///
890    /// # Panics
891    ///
892    /// Propagates widget layout/focus callback panics.
893    ///
894    /// # Examples
895    ///
896    /// ```
897    /// use ailloli_ui_core::Scale; use ailloli_ui_runtime::{app::RuntimeHandle, popup_mount::PopupOverlayMounts}; use ailloli_ui_text::TextSystem;
898    /// let mut mounts = PopupOverlayMounts::new(RuntimeHandle::<()>::new());
899    /// mounts.layout(Scale::new(1.0), &mut TextSystem::new());
900    /// assert_eq!(mounts.open_len(), 0);
901    /// ```
902    pub fn layout(&mut self, scale: Scale, text_system: &mut TextSystem) {
903        let open_order = self.open_order.clone();
904        let mut focus_candidate = None;
905        for popup_id in open_order {
906            let Some(mount) = self.mounts.get_mut(&popup_id) else {
907                continue;
908            };
909            let Some(bounds) = mount.bounds.filter(non_empty_rect) else {
910                continue;
911            };
912            mount
913                .runtime
914                .layout(Constraints::tight(bounds.w, bounds.h), scale, text_system);
915            if mount.focus_on_next_layout {
916                let focus_changed = mount
917                    .input
918                    .focus_first_descendant(&mount.runtime.tree, mount.runtime.runtime.clone());
919                if focus_changed || mount.input.focused().is_some() {
920                    mount.focus_on_next_layout = false;
921                    focus_candidate = Some(popup_id);
922                }
923            }
924        }
925        if let Some(popup_id) = focus_candidate {
926            self.claim_focus(popup_id);
927        }
928    }
929
930    /// Produces overlay layers in portal z-order, clipped to popup bounds.
931    ///
932    /// Mounts without non-empty bounds/root are skipped. Painting translates
933    /// popup-local geometry by the window-global bounds origin, forces every
934    /// emitted layer to [`LayerKind::Overlay`], and exposes `frame_time_ms`
935    /// verbatim to widget paint callbacks.
936    ///
937    /// # Examples
938    ///
939    /// ```
940    /// use ailloli_ui_runtime::{app::RuntimeHandle, popup_mount::PopupOverlayMounts}; use ailloli_ui_text::TextSystem;
941    /// let mounts = PopupOverlayMounts::new(RuntimeHandle::<()>::new());
942    /// assert!(mounts.paint(&mut TextSystem::new(), 16).layers.is_empty());
943    /// ```
944    pub fn paint(&self, text_system: &mut TextSystem, frame_time_ms: u128) -> Scene {
945        let mut scene = Scene::default();
946        for popup_id in &self.open_order {
947            let Some(mount) = self.mounts.get(popup_id) else {
948                continue;
949            };
950            let Some(bounds) = mount.bounds.filter(non_empty_rect) else {
951                continue;
952            };
953            let Some(root) = mount.runtime.root else {
954                continue;
955            };
956            let mut context = PaintCtx::with_text_system_and_input(
957                text_system,
958                mount.input.snapshot(),
959                frame_time_ms,
960            );
961            context.with_clip(bounds, |context| {
962                paint_element_observed(
963                    &mount.runtime.tree,
964                    &mount.runtime.runtime,
965                    context,
966                    root,
967                    Offset::new(bounds.x, bounds.y),
968                );
969            });
970            let mut popup_scene = context.into_scene();
971            for layer in &mut popup_scene.layers {
972                layer.kind = LayerKind::Overlay;
973            }
974            scene.layers.append(&mut popup_scene.layers);
975        }
976        scene
977    }
978
979    /// Appends retained popup layers after the owner scene.
980    ///
981    /// Existing layers retain their order and popup layers append in portal
982    /// bottom-to-top order. No sorting or deduplication occurs.
983    ///
984    /// # Examples
985    ///
986    /// ```
987    /// use ailloli_ui_runtime::{app::RuntimeHandle, popup_mount::PopupOverlayMounts, scene::Scene}; use ailloli_ui_text::TextSystem;
988    /// let mounts = PopupOverlayMounts::new(RuntimeHandle::<()>::new()); let mut scene = Scene::default();
989    /// mounts.append_to_scene(&mut scene, &mut TextSystem::new(), 0);
990    /// assert!(scene.layers.is_empty());
991    /// ```
992    pub fn append_to_scene(
993        &self,
994        scene: &mut Scene,
995        text_system: &mut TextSystem,
996        frame_time_ms: u128,
997    ) {
998        let mut popup_scene = self.paint(text_system, frame_time_ms);
999        scene.layers.append(&mut popup_scene.layers);
1000    }
1001
1002    /// Hit-tests mounted content from topmost popup to bottommost.
1003    ///
1004    /// `point` is window-global logical pixels. Portal bounds select a retained
1005    /// mount, then the point is translated to popup-local coordinates for tree
1006    /// hit-testing. Missing/non-positive bounds or non-widget hits yield `None`.
1007    ///
1008    /// # Examples
1009    ///
1010    /// ```
1011    /// use ailloli_ui_core::Point; use ailloli_ui_runtime::{app::RuntimeHandle, popup_mount::PopupOverlayMounts};
1012    /// assert!(PopupOverlayMounts::new(RuntimeHandle::<()>::new()).hit_test(Point::new(1.0, 1.0)).is_none());
1013    /// ```
1014    pub fn hit_test(&self, point: Point) -> Option<PopupMountHit> {
1015        let popup_id = self.popup_at(point)?;
1016        let mount = self.mounts.get(&popup_id)?;
1017        let bounds = mount.bounds.filter(non_empty_rect)?;
1018        let local = local_point(point, bounds)?;
1019        let element_id = hit_test_target(&mount.runtime.tree, &mount.input.hit_test, local, None)?;
1020        Some(PopupMountHit {
1021            popup_id,
1022            element_tree_id: mount.runtime.runtime.element_tree_id(),
1023            element_id,
1024        })
1025    }
1026
1027    /// Applies portal authority once, then routes into a popup-local tree.
1028    ///
1029    /// The caller should skip owner-tree dispatch when [`PopupMountRouteOutcome::consumed`]
1030    /// is true. Pointer coordinates are translated to the selected mount while
1031    /// all event correlation metadata is preserved.
1032    /// Envelopes outside the last synchronized logical window/generation are
1033    /// ignored. Pointer gesture consumption persists through matching release;
1034    /// Escape/outside press invoke portal authority before local dispatch;
1035    /// keyboard/IME route to the focused popup; window focus loss cancels every
1036    /// captured popup pointer before clearing interaction state.
1037    ///
1038    /// # Examples
1039    ///
1040    /// ```
1041    /// use std::time::Duration; use ailloli_ui_core::{event::{Event, FocusEvent}, LogicalWindowId};
1042    /// use ailloli_ui_runtime::{app::{PresentationGeneration, RuntimeHandle}, input::{EventEnvelope, EventId, EventMeta, EventTimestamp}, popup_mount::PopupOverlayMounts};
1043    /// let mut mounts = PopupOverlayMounts::new(RuntimeHandle::<()>::new()); let window = LogicalWindowId::new("main");
1044    /// mounts.sync(&window, PresentationGeneration::INITIAL);
1045    /// let envelope = EventEnvelope::new(EventMeta::new(EventId::new(1), EventTimestamp::new(Duration::ZERO), window, PresentationGeneration::INITIAL), Event::Focus(FocusEvent::new(true)));
1046    /// assert!(!mounts.route_envelope(&envelope).consumed());
1047    /// ```
1048    pub fn route_envelope(&mut self, envelope: &EventEnvelope) -> PopupMountRouteOutcome {
1049        let Some((window, generation)) = self.presentation.clone() else {
1050            return PopupMountRouteOutcome::default();
1051        };
1052        if envelope.meta().logical_window_id() != &window
1053            || envelope.meta().presentation_generation() != generation
1054        {
1055            return PopupMountRouteOutcome::default();
1056        }
1057
1058        let mut outcome = PopupMountRouteOutcome::default();
1059        let pointer_id = envelope
1060            .pointer()
1061            .map(|sample| sample.id())
1062            .unwrap_or(PointerId::MOUSE);
1063
1064        if let Event::Pointer(pointer) = envelope.event() {
1065            if let Some((_, true)) = pointer.button_transition() {
1066                if !self.retained_popup_owns_authority() {
1067                    if !self.consumed_pointer_gestures.contains(&pointer_id) {
1068                        return outcome;
1069                    }
1070                } else {
1071                    let point = pointer.position();
1072                    let backend_hit = self.popup_at(point);
1073                    let portal_outcome = self.runtime.route_popup_pointer_press_with_backend_hit(
1074                        &window,
1075                        generation,
1076                        point,
1077                        backend_hit,
1078                    );
1079                    if portal_outcome.handled() {
1080                        self.consumed_pointer_gestures.insert(pointer_id);
1081                    }
1082                    outcome.consumed |= portal_outcome.handled();
1083                    self.sync(&window, generation);
1084                }
1085            }
1086        }
1087
1088        if matches!(envelope.event(), Event::Pointer(_)) {
1089            outcome.consumed |= self.consumed_pointer_gestures.contains(&pointer_id);
1090        }
1091
1092        let escape = matches!(
1093            envelope.event(),
1094            Event::Keyboard(key)
1095                if key.state == KeyState::Pressed
1096                    && key.key == Key::Named(NamedKey::Escape)
1097        );
1098        if escape {
1099            if !self.retained_popup_owns_authority() {
1100                return outcome;
1101            }
1102            let portal_outcome = self.runtime.route_popup_escape(&window, generation);
1103            outcome.consumed |= portal_outcome.handled();
1104            if portal_outcome.handled() {
1105                self.sync(&window, generation);
1106                return outcome;
1107            }
1108        }
1109
1110        let trap_tab = matches!(
1111            envelope.event(),
1112            Event::Keyboard(key)
1113                if key.state == KeyState::Pressed
1114                    && key.key == Key::Named(NamedKey::Tab)
1115        );
1116        if trap_tab {
1117            let Some(popup_id) = self.focused_popup else {
1118                return outcome;
1119            };
1120            let reverse = matches!(
1121                envelope.event(),
1122                Event::Keyboard(key) if key.modifiers.shift
1123            );
1124            let trapped = self
1125                .mounts
1126                .get_mut(&popup_id)
1127                .filter(|mount| mount.focus_policy == PopupFocusPolicy::TrapWithinPopup)
1128                .map(|mount| {
1129                    mount.input.cycle_focus_descendant(
1130                        &mount.runtime.tree,
1131                        mount.runtime.runtime.clone(),
1132                        reverse,
1133                        true,
1134                    )
1135                });
1136            if let Some(changed) = trapped {
1137                outcome.popup_id = Some(popup_id);
1138                outcome.consumed = true;
1139                outcome.route.interaction_changed |= changed;
1140                return outcome;
1141            }
1142        }
1143
1144        match envelope.event() {
1145            Event::Pointer(pointer) => {
1146                let point = pointer.position();
1147                if pointer.is_cancelled() {
1148                    let target = self
1149                        .pointer_capture
1150                        .remove(&pointer_id)
1151                        .or_else(|| self.pointer_hover.remove(&pointer_id));
1152                    if let Some(popup_id) = target {
1153                        outcome.popup_id = Some(popup_id);
1154                        outcome.consumed |= self.popup_consumes_pointer_input(popup_id);
1155                        if let Some(route) = self.route_to_mount(popup_id, envelope) {
1156                            outcome.merge_route(route);
1157                        }
1158                    }
1159                    self.consumed_pointer_gestures.remove(&pointer_id);
1160                    return outcome;
1161                }
1162
1163                let transition = pointer.button_transition();
1164                let captured = self.pointer_capture.get(&pointer_id).copied();
1165                let target = captured.or_else(|| self.popup_at(point));
1166                if transition.is_some_and(|(_, pressed)| pressed) {
1167                    if let Some(popup_id) = target {
1168                        self.pointer_capture.insert(pointer_id, popup_id);
1169                    }
1170                }
1171
1172                if transition.is_none() && captured.is_none() {
1173                    let previous = self.pointer_hover.get(&pointer_id).copied();
1174                    if previous != target {
1175                        if let Some(previous) = previous {
1176                            if let Some(route) = self.route_to_mount(previous, envelope) {
1177                                outcome.merge_route(route);
1178                            }
1179                        }
1180                        match target {
1181                            Some(popup_id) => {
1182                                self.pointer_hover.insert(pointer_id, popup_id);
1183                            }
1184                            None => {
1185                                self.pointer_hover.remove(&pointer_id);
1186                            }
1187                        }
1188                    }
1189                }
1190
1191                if let Some(popup_id) = target {
1192                    outcome.popup_id = Some(popup_id);
1193                    outcome.consumed |= self.popup_consumes_pointer_input(popup_id);
1194                    if let Some(route) = self.route_to_mount(popup_id, envelope) {
1195                        outcome.merge_route(route);
1196                    }
1197                }
1198
1199                if transition.is_some_and(|(_, pressed)| !pressed) {
1200                    self.pointer_capture.remove(&pointer_id);
1201                    self.consumed_pointer_gestures.remove(&pointer_id);
1202                }
1203            }
1204            Event::Keyboard(_) | Event::Ime(_) => {
1205                if let Some(popup_id) = self.focused_popup {
1206                    outcome.popup_id = Some(popup_id);
1207                    outcome.consumed = true;
1208                    if let Some(route) = self.route_to_mount(popup_id, envelope) {
1209                        outcome.merge_route(route);
1210                    }
1211                }
1212            }
1213            Event::File(file) => {
1214                let popup_id = file
1215                    .pos()
1216                    .and_then(|point| self.popup_at(point))
1217                    .or_else(|| self.pointer_hover.get(&PointerId::MOUSE).copied())
1218                    .or(self.focused_popup);
1219                if let Some(popup_id) = popup_id {
1220                    outcome.popup_id = Some(popup_id);
1221                    outcome.consumed = self.popup_consumes_pointer(popup_id);
1222                    if let Some(route) = self.route_to_mount(popup_id, envelope) {
1223                        outcome.merge_route(route);
1224                    }
1225                }
1226                if file.is_left() {
1227                    self.pointer_hover.remove(&PointerId::MOUSE);
1228                }
1229            }
1230            Event::Window(ailloli_ui_core::event::WindowEvent::Focused { focused: false }) => {
1231                self.clear_interaction();
1232            }
1233            Event::Focus(_) | Event::Window(_) => {}
1234            _ => {}
1235        }
1236        outcome
1237    }
1238
1239    /// Finds the topmost synchronized retained-overlay popup at a global point.
1240    fn popup_at(&self, point: Point) -> Option<PopupId> {
1241        let (window, generation) = self.presentation.as_ref()?;
1242        let portal = self.runtime.popup_portal();
1243        let portal = portal.borrow();
1244        let popup_id = portal.hit_test(window, *generation, point)?;
1245        let retained = portal
1246            .request(popup_id)
1247            .is_some_and(|request| request.mount_policy() == PopupMountPolicy::RetainedOverlay);
1248        (retained && self.mounts.contains_key(&popup_id)).then_some(popup_id)
1249    }
1250
1251    /// Reports whether the presentation's effective topmost popup is retained.
1252    fn retained_popup_owns_authority(&self) -> bool {
1253        let Some((window, generation)) = self.presentation.as_ref() else {
1254            return false;
1255        };
1256        let portal = self.runtime.popup_portal();
1257        let portal = portal.borrow();
1258        let retained_owns_authority = portal
1259            .open_ids()
1260            .rev()
1261            .filter_map(|popup_id| portal.request(popup_id))
1262            .find(|request| owner_matches(request.owner(), window, *generation))
1263            .is_some_and(|request| request.mount_policy() == PopupMountPolicy::RetainedOverlay);
1264        retained_owns_authority
1265    }
1266
1267    /// Reads pointer-consumption semantics regardless of mount policy.
1268    fn popup_consumes_pointer(&self, popup_id: PopupId) -> bool {
1269        self.runtime
1270            .popup_portal()
1271            .borrow()
1272            .request(popup_id)
1273            .is_some_and(|request| request.semantics().consumes_pointer_input())
1274    }
1275
1276    /// Reads pointer consumption only for a retained-overlay registration.
1277    fn popup_consumes_pointer_input(&self, popup_id: PopupId) -> bool {
1278        self.runtime
1279            .popup_portal()
1280            .borrow()
1281            .request(popup_id)
1282            .is_some_and(|request| {
1283                request.mount_policy() == PopupMountPolicy::RetainedOverlay
1284                    && request.semantics().consumes_pointer_input()
1285            })
1286    }
1287
1288    /// Translates and routes an envelope into one non-empty popup-local tree.
1289    fn route_to_mount(
1290        &mut self,
1291        popup_id: PopupId,
1292        envelope: &EventEnvelope,
1293    ) -> Option<RouteOutcome> {
1294        let (route, has_focus) = {
1295            let mount = self.mounts.get_mut(&popup_id)?;
1296            let bounds = mount.bounds.filter(non_empty_rect)?;
1297            let translated = translate_envelope(envelope, bounds)?;
1298            let runtime = mount.runtime.runtime.clone();
1299            let mut route = mount.input.route_subtree_envelope(
1300                &mount.runtime.tree,
1301                runtime.clone(),
1302                &translated,
1303            );
1304            route.interaction_changed |= mount.input.apply_pending_popup_intents_for_presentation(
1305                &mount.runtime.tree,
1306                runtime,
1307                translated.meta().logical_window_id(),
1308                translated.meta().presentation_generation(),
1309            );
1310            (route, mount.input.focused().is_some())
1311        };
1312        if has_focus {
1313            self.claim_focus(popup_id);
1314        } else if self.focused_popup == Some(popup_id) {
1315            self.focused_popup = None;
1316        }
1317        Some(route)
1318    }
1319
1320    /// Makes focus exclusive to `popup_id`, synchronously blurring peer mounts.
1321    fn claim_focus(&mut self, popup_id: PopupId) {
1322        for (candidate, mount) in &mut self.mounts {
1323            if *candidate != popup_id && mount.input.focused().is_some() {
1324                mount
1325                    .input
1326                    .blur_subtree(&mount.runtime.tree, mount.runtime.runtime.clone());
1327            }
1328        }
1329        self.focused_popup = self
1330            .mounts
1331            .get(&popup_id)
1332            .and_then(|mount| mount.input.focused())
1333            .map(|_| popup_id);
1334    }
1335
1336    /// Clears pointer state and focus from every mount and ownership map.
1337    fn clear_interaction(&mut self) {
1338        for mount in self.mounts.values_mut() {
1339            mount
1340                .input
1341                .cancel_pointer_state(&mount.runtime.tree, mount.runtime.runtime.clone());
1342            mount
1343                .input
1344                .blur_subtree(&mount.runtime.tree, mount.runtime.runtime.clone());
1345        }
1346        self.pointer_capture.clear();
1347        self.pointer_hover.clear();
1348        self.consumed_pointer_gestures.clear();
1349        self.focused_popup = None;
1350    }
1351}
1352
1353/// Compares only logical-window identity and presentation generation.
1354fn owner_matches(
1355    owner: &PopupOwner,
1356    logical_window_id: &LogicalWindowId,
1357    presentation_generation: PresentationGeneration,
1358) -> bool {
1359    owner.logical_window_id() == logical_window_id
1360        && owner.presentation_generation() == presentation_generation
1361}
1362
1363/// Accepts bounds only when both dimensions are strictly positive.
1364fn non_empty_rect(rect: &Rect) -> bool {
1365    rect.w > 0.0 && rect.h > 0.0
1366}
1367
1368/// Translates a window-global point by bounds origin if results stay finite.
1369fn local_point(point: Point, bounds: Rect) -> Option<Point> {
1370    let point = Point::new(point.x - bounds.x, point.y - bounds.y);
1371    (point.x.is_finite() && point.y.is_finite()).then_some(point)
1372}
1373
1374/// Clones an envelope while translating every positional event/metadata field.
1375///
1376/// IDs, timestamps, window identity, generation, and non-positional event data
1377/// are preserved. Non-finite translated coordinates or invalid pointer samples
1378/// return `None` atomically.
1379fn translate_envelope(envelope: &EventEnvelope, bounds: Rect) -> Option<EventEnvelope> {
1380    let event = match envelope.event() {
1381        Event::Pointer(pointer) => Event::Pointer(translate_pointer_event(pointer, bounds)?),
1382        Event::Keyboard(key) => {
1383            let mut key = key.clone();
1384            key.pointer_pos = match key.pointer_pos {
1385                Some(point) => Some(local_point(point, bounds)?),
1386                None => None,
1387            };
1388            Event::Keyboard(key)
1389        }
1390        Event::File(file) => Event::File(translate_file_event(file, bounds)?),
1391        event => event.clone(),
1392    };
1393
1394    let meta = envelope.meta();
1395    let mut translated_meta = EventMeta::new(
1396        meta.id(),
1397        meta.timestamp(),
1398        meta.logical_window_id().clone(),
1399        meta.presentation_generation(),
1400    );
1401    if let Some(pointer) = meta.pointer() {
1402        translated_meta = translated_meta.with_pointer(translate_pointer_sample(pointer, bounds)?);
1403    }
1404    Some(EventEnvelope::new(translated_meta, event))
1405}
1406
1407/// Translates all current legacy and structured file-event point variants.
1408fn translate_file_event(event: &FileEvent, bounds: Rect) -> Option<FileEvent> {
1409    let translate = |point| local_point(point, bounds);
1410    let translate_optional = |point: Option<Point>| match point {
1411        Some(point) => Some(Some(translate(point)?)),
1412        None => Some(None),
1413    };
1414    Some(match event {
1415        FileEvent::Entered { pos, files } => {
1416            FileEvent::entered(translate_optional(*pos)?, files.clone())
1417        }
1418        FileEvent::Moved { pos, files } => {
1419            FileEvent::moved(translate_optional(*pos)?, files.clone())
1420        }
1421        FileEvent::Left => FileEvent::left(),
1422        FileEvent::Dropped { pos, files } => {
1423            FileEvent::dropped(translate_optional(*pos)?, files.clone())
1424        }
1425        FileEvent::Hover { pos, files } => FileEvent::Hover {
1426            pos: translate(*pos)?,
1427            files: files.clone(),
1428        },
1429        FileEvent::HoverCancelled => FileEvent::HoverCancelled,
1430        FileEvent::Drop { pos, files } => FileEvent::Drop {
1431            pos: translate(*pos)?,
1432            files: files.clone(),
1433        },
1434        _ => event.clone(),
1435    })
1436}
1437
1438/// Rebuilds positional pointer variants in popup-local logical coordinates.
1439fn translate_pointer_event(event: &PointerEvent, bounds: Rect) -> Option<PointerEvent> {
1440    let local = local_point(event.position(), bounds)?;
1441    Some(match event {
1442        PointerEvent::Moved { modifiers, .. } => PointerEvent::moved(local, *modifiers),
1443        PointerEvent::Button {
1444            button,
1445            pressed,
1446            modifiers,
1447            ..
1448        } => PointerEvent::button(local, *button, *pressed, *modifiers),
1449        PointerEvent::Cancelled { modifiers, .. } => PointerEvent::cancelled(local, *modifiers),
1450        PointerEvent::Wheel {
1451            delta,
1452            modifiers,
1453            precise,
1454            ..
1455        } => PointerEvent::wheel(local, *delta, *modifiers, *precise),
1456        _ => event.clone(),
1457    })
1458}
1459
1460/// Rebuilds a metadata pointer sample locally, preserving optional attributes.
1461///
1462/// Any validation failure while restoring pressure, tilt, twist, or contact
1463/// size returns `None` rather than emitting a partially translated sample.
1464fn translate_pointer_sample(sample: &PointerSample, bounds: Rect) -> Option<PointerSample> {
1465    let mut translated = PointerSample::new_with_primary(
1466        sample.id(),
1467        sample.source(),
1468        local_point(sample.position(), bounds)?,
1469        sample.is_primary(),
1470    )
1471    .ok()?
1472    .with_activation(sample.activation());
1473    if let Some(pressure) = sample.pressure() {
1474        translated = translated.with_pressure(pressure).ok()?;
1475    }
1476    if let Some((x, y)) = sample.tilt() {
1477        translated = translated.with_tilt(x, y).ok()?;
1478    }
1479    if let Some(twist) = sample.twist() {
1480        translated = translated.with_twist(twist).ok()?;
1481    }
1482    if let Some(contact_size) = sample.contact_size() {
1483        translated = translated.with_contact_size(contact_size).ok()?;
1484    }
1485    Some(translated)
1486}
1487
1488#[cfg(test)]
1489mod tests {
1490    use super::*;
1491    use std::cell::RefCell;
1492    use std::rc::Rc;
1493
1494    use crate::component::{ComponentNode, Context, Signal, View};
1495    use crate::popup::{PopupDismissReason, PopupRequest};
1496
1497    /// Popup-local component whose build observes one shared source.
1498    #[derive(Clone)]
1499    struct ReactivePopupContent {
1500        /// Source used to prove presentation-scoped wake and cleanup.
1501        value: Signal<u64>,
1502    }
1503
1504    impl ComponentNode<()> for ReactivePopupContent {
1505        fn build(&self, _context: &mut Context<()>) -> View<()> {
1506            let _ = self.value.read();
1507            View::empty()
1508        }
1509    }
1510
1511    #[test]
1512    fn cached_popup_scope_exists_only_while_the_popup_is_open() {
1513        let shared = RuntimeHandle::<()>::new();
1514        let logical_window_id = LogicalWindowId::new("main");
1515        let generation = PresentationGeneration::new(3);
1516        let popup_id = PopupId::new(1);
1517        shared
1518            .register_popup(PopupRequest::new(
1519                popup_id,
1520                PopupOwner::new(
1521                    logical_window_id.clone(),
1522                    generation,
1523                    ElementTreeId::new(40),
1524                    ElementId(2),
1525                ),
1526                PopupContent::new(View::empty),
1527            ))
1528            .unwrap();
1529        shared.open_popup_unpositioned(popup_id).unwrap();
1530
1531        let mut mounts = PopupOverlayMounts::new(shared.clone());
1532        mounts.sync(&logical_window_id, generation);
1533        let mounted = mounts.mounts.get(&popup_id).expect("mounted popup");
1534        assert_eq!(
1535            mounted.runtime.runtime.presentation_scope(),
1536            Some((logical_window_id.clone(), generation))
1537        );
1538
1539        shared.close_popup(popup_id, PopupDismissReason::Programmatic);
1540        mounts.sync(&logical_window_id, generation);
1541        let mounted = mounts.mounts.get(&popup_id).expect("cached popup");
1542        assert!(!mounted.open);
1543        assert!(mounted.runtime.runtime.presentation_scope().is_none());
1544
1545        shared.open_popup_unpositioned(popup_id).unwrap();
1546        mounts.sync(&logical_window_id, generation);
1547        let mounted = mounts.mounts.get(&popup_id).expect("reopened popup");
1548        assert!(mounted.open);
1549        assert_eq!(
1550            mounted.runtime.runtime.presentation_scope(),
1551            Some((logical_window_id, generation))
1552        );
1553    }
1554
1555    #[test]
1556    fn cached_popup_reactive_work_wakes_only_while_presented_and_cleans_up_on_drop() {
1557        let shared = RuntimeHandle::<()>::new();
1558        let logical_window_id = LogicalWindowId::new("main-reactive-popup");
1559        let generation = PresentationGeneration::new(5);
1560        let popup_id = PopupId::new(2);
1561        let value = Signal::new(Rc::new(RefCell::new(0_u64)), Rc::new(|| {}));
1562        let content_value = value.clone();
1563        shared
1564            .register_popup(PopupRequest::new(
1565                popup_id,
1566                PopupOwner::new(
1567                    logical_window_id.clone(),
1568                    generation,
1569                    ElementTreeId::new(41),
1570                    ElementId(3),
1571                ),
1572                PopupContent::new(move || {
1573                    View::component(ReactivePopupContent {
1574                        value: content_value.clone(),
1575                    })
1576                }),
1577            ))
1578            .unwrap();
1579        {
1580            let portal = shared.popup_portal();
1581            portal
1582                .borrow_mut()
1583                .set_bounds(popup_id, Rect::new(10.0, 12.0, 80.0, 40.0))
1584                .unwrap();
1585        }
1586        shared.open_popup_unpositioned(popup_id).unwrap();
1587
1588        let mut mounts = PopupOverlayMounts::new(shared.clone());
1589        mounts.sync(&logical_window_id, generation);
1590        let mut text_system = TextSystem::new();
1591        mounts.layout(Scale::new(1.0), &mut text_system);
1592        assert_eq!(shared.reactive_dependency_consumer_count(), 1);
1593
1594        value.set(1);
1595        let open_work = shared.pending_presentation_work();
1596        assert_eq!(open_work.len(), 1);
1597        assert_eq!(open_work[0].logical_window_id(), &logical_window_id);
1598        assert_eq!(open_work[0].presentation_generation(), generation);
1599        mounts.layout(Scale::new(1.0), &mut text_system);
1600
1601        shared.close_popup(popup_id, PopupDismissReason::Programmatic);
1602        mounts.sync(&logical_window_id, generation);
1603        value.set(2);
1604        assert!(
1605            shared.pending_presentation_work().is_empty(),
1606            "a cached closed popup has no active presentation to wake"
1607        );
1608
1609        shared.open_popup_unpositioned(popup_id).unwrap();
1610        mounts.sync(&logical_window_id, generation);
1611        let reopened_work = shared.pending_presentation_work();
1612        assert_eq!(reopened_work.len(), 1);
1613        assert_eq!(reopened_work[0].logical_window_id(), &logical_window_id);
1614        assert_eq!(reopened_work[0].presentation_generation(), generation);
1615        mounts.layout(Scale::new(1.0), &mut text_system);
1616
1617        drop(mounts);
1618        assert_eq!(
1619            shared.reactive_dependency_consumer_count(),
1620            0,
1621            "dropping popup mounts must remove every popup-tree subscription"
1622        );
1623        value.set(3);
1624        assert!(shared.pending_presentation_work().is_empty());
1625    }
1626}