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}