Skip to main content

pax_runtime/
viewport_proximity.rs

1//! Opt-in global viewport observation over the runtime's shared prepared geometry.
2
3use crate::api::{
4    Event, Layer, LocalProperty, ViewportProximityChange, ViewportProximityEnter,
5    ViewportProximityExit, ViewportProximitySnapshot,
6};
7use crate::constants::*;
8use crate::scene_geometry::SpatialIndex;
9use crate::{ExpandedNode, ExpandedNodeIdentifier, RuntimeContext};
10use kurbo::{Affine, Rect, Shape};
11use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
12use std::rc::Rc;
13
14/// Cumulative observation work, separate from render preparation and application handlers.
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
16pub struct ViewportProximityStats {
17    /// Indexed geometry entries refreshed after dependency changes.
18    pub index_updates: u64,
19    /// Scroll domains queried (cold nested domains are pruned).
20    pub domain_queries: u64,
21    /// Spatial candidates considered, including scroll-owner proxies.
22    pub candidates: u64,
23    /// Detailed target samples, including terminal samples for previously active targets.
24    pub samples: u64,
25    /// Local event deliveries to registered handlers.
26    pub events: u64,
27}
28
29struct Registration {
30    generation: u64,
31    chain: Vec<u32>,
32}
33struct Dependency {
34    users: usize,
35    _effect: LocalProperty<()>,
36    parent: Option<u32>,
37    owner: Option<u32>,
38    scrolls: bool,
39    canvas: bool,
40    escapes: bool,
41    unclipped_scroll: bool,
42}
43
44#[derive(Default)]
45pub(crate) struct ViewportProximityState {
46    registrations: BTreeMap<u32, Registration>,
47    dependencies: HashMap<u32, Dependency>,
48    indices: HashMap<Option<u32>, SpatialIndex>,
49    indexed: HashMap<u32, (Option<u32>, Rect)>,
50    dirty: BTreeSet<u32>,
51    pending_bindings: BTreeSet<u32>,
52    active: BTreeMap<u32, ViewportProximitySnapshot>,
53    escaped: BTreeSet<u32>,
54    pending: bool,
55    rebind: bool,
56    escape_dirty: bool,
57    generation: u64,
58    stats: ViewportProximityStats,
59}
60
61fn has_handlers(node: &ExpandedNode, key: &str) -> bool {
62    node.instance_node
63        .borrow()
64        .base()
65        .get_handler_registry()
66        .is_some_and(|registry| {
67            registry
68                .borrow()
69                .handlers
70                .get(key)
71                .is_some_and(|handlers| !handlers.is_empty())
72        })
73}
74
75impl RuntimeContext {
76    /// Observation counters. With no proximity bindings, no observation records are installed.
77    pub fn viewport_proximity_stats(&self) -> ViewportProximityStats {
78        self.viewport_proximity.borrow().stats
79    }
80
81    pub(crate) fn viewport_dependency(&self, id: u32) -> bool {
82        self.viewport_proximity
83            .borrow()
84            .dependencies
85            .contains_key(&id)
86    }
87
88    pub(crate) fn invalidate_viewport_presentation(&self) {
89        let mut state = self.viewport_proximity.borrow_mut();
90        if !state.registrations.is_empty() {
91            state.pending = true;
92        }
93    }
94
95    pub(crate) fn viewport_structure_changed(&self) {
96        let mut state = self.viewport_proximity.borrow_mut();
97        if !state.registrations.is_empty() {
98            state.rebind = true;
99            state.pending = true;
100        }
101    }
102
103    fn invalidate_viewport_dependency(&self, id: u32) {
104        let mut state = self.viewport_proximity.borrow_mut();
105        if state.dependencies.contains_key(&id) {
106            state.dirty.insert(id);
107            state.pending = true;
108            drop(state);
109            self.invalidate_scene_geometry(id);
110        }
111    }
112
113    pub(crate) fn register_viewport_proximity(self: &Rc<Self>, node: &Rc<ExpandedNode>) {
114        // A detached mask source has a component lifetime but no viewport presence.
115        if node.is_render_source() {
116            return;
117        }
118        if ![
119            VIEWPORT_PROXIMITY_ENTER_HANDLERS,
120            VIEWPORT_PROXIMITY_CHANGE_HANDLERS,
121            VIEWPORT_PROXIMITY_EXIT_HANDLERS,
122        ]
123        .iter()
124        .any(|key| has_handlers(node, key))
125        {
126            return;
127        }
128        let id = node.id.to_u32();
129        if self
130            .viewport_proximity
131            .borrow()
132            .registrations
133            .contains_key(&id)
134        {
135            return;
136        }
137        let chain = Vec::new();
138        let mut state = self.viewport_proximity.borrow_mut();
139        state.generation += 1;
140        let generation = state.generation;
141        state.pending_bindings.insert(id);
142        state.escape_dirty = true;
143        state
144            .registrations
145            .insert(id, Registration { generation, chain });
146        state.dirty.insert(id);
147        state.pending = true;
148    }
149
150    fn acquire_viewport_chain(self: &Rc<Self>, target: &Rc<ExpandedNode>) -> Vec<u32> {
151        let mut nodes = vec![target.clone()];
152        let mut parent = target.render_parent_node();
153        while let Some(node) = parent {
154            parent = node.render_parent_node();
155            nodes.push(node);
156        }
157        for node in nodes.iter().rev() {
158            let id = node.id.to_u32();
159            {
160                let mut state = self.viewport_proximity.borrow_mut();
161                if let Some(dependency) = state.dependencies.get_mut(&id) {
162                    dependency.users += 1;
163                    continue;
164                }
165            }
166            let parent = node.render_parent_node().map(|p| p.id.to_u32());
167            let owner = parent.and_then(|parent| {
168                let state = self.viewport_proximity.borrow();
169                let dependency = &state.dependencies[&parent];
170                if dependency.scrolls {
171                    Some(parent)
172                } else {
173                    dependency.owner
174                }
175            });
176            let instance = node.instance_node.borrow();
177            let scrolls = instance.scrolls_content(node);
178            let canvas = instance.base().flags().layer == Layer::Canvas;
179            let mut dependencies: Vec<_> = node
180                .properties_scope
181                .borrow()
182                .iter()
183                .filter(|(name, _)| instance.property_requires_occlusion_recompute(name))
184                .map(|(_, value)| value.get_untyped_property().clone())
185                .collect();
186            drop(instance);
187            dependencies.extend([
188                node.transform_and_bounds.untyped(),
189                node.parent_frame.untyped(),
190                node.occlusion.untyped(),
191                node.suspended.untyped(),
192                node.get_common_properties().borrow().unclippable.untyped(),
193            ]);
194            let weak_context = Rc::downgrade(self);
195            let effect = self.register_node_effect(node.id, &dependencies, move || {
196                if let Some(context) = weak_context.upgrade() {
197                    context.invalidate_viewport_dependency(id);
198                }
199            });
200            let mut state = self.viewport_proximity.borrow_mut();
201            state.dependencies.insert(
202                id,
203                Dependency {
204                    users: 1,
205                    _effect: effect,
206                    parent,
207                    owner,
208                    scrolls,
209                    canvas,
210                    escapes: false,
211                    unclipped_scroll: false,
212                },
213            );
214            state.dirty.insert(id);
215        }
216        nodes.iter().map(|n| n.id.to_u32()).collect()
217    }
218
219    fn release_viewport_chain(&self, chain: Vec<u32>) {
220        let mut remove_geometry = Vec::new();
221        let mut state = self.viewport_proximity.borrow_mut();
222        for id in chain {
223            let Some(dependency) = state.dependencies.get_mut(&id) else {
224                continue;
225            };
226            dependency.users -= 1;
227            if dependency.users != 0 {
228                continue;
229            }
230            let dependency = state.dependencies.remove(&id).unwrap();
231            if let Some((owner, _)) = state.indexed.remove(&id) {
232                if let Some(index) = state.indices.get_mut(&owner) {
233                    index.remove(id);
234                }
235            }
236            if dependency.scrolls {
237                state.indices.remove(&Some(id));
238            }
239            state.dirty.remove(&id);
240            if !dependency.canvas {
241                remove_geometry.push(id);
242            }
243        }
244        drop(state);
245        for id in remove_geometry {
246            self.remove_observation_geometry(id);
247        }
248    }
249
250    pub(crate) fn unregister_viewport_proximity(&self, id: u32) {
251        let registration = {
252            let mut state = self.viewport_proximity.borrow_mut();
253            state.active.remove(&id);
254            state.pending_bindings.remove(&id);
255            state.escaped.remove(&id);
256            state.registrations.remove(&id)
257        };
258        if let Some(registration) = registration {
259            self.release_viewport_chain(registration.chain);
260            // A still-shared ordinary ancestor no longer needs a target index entry.
261            let mut state = self.viewport_proximity.borrow_mut();
262            if state.dependencies.get(&id).is_some_and(|d| !d.scrolls) {
263                if let Some((owner, _)) = state.indexed.remove(&id) {
264                    if let Some(index) = state.indices.get_mut(&owner) {
265                        index.remove(id);
266                    }
267                }
268            }
269        }
270    }
271
272    fn refresh_viewport_index(self: &Rc<Self>) {
273        let rebind = std::mem::take(&mut self.viewport_proximity.borrow_mut().rebind);
274        if rebind {
275            let ids: Vec<_> = self
276                .viewport_proximity
277                .borrow()
278                .registrations
279                .keys()
280                .copied()
281                .collect();
282            for &id in &ids {
283                let chain = std::mem::take(
284                    &mut self
285                        .viewport_proximity
286                        .borrow_mut()
287                        .registrations
288                        .get_mut(&id)
289                        .unwrap()
290                        .chain,
291                );
292                self.release_viewport_chain(chain);
293            }
294            for id in ids {
295                if let Some(node) = self.get_expanded_node_by_eid(ExpandedNodeIdentifier(id)) {
296                    let chain = self.acquire_viewport_chain(&node);
297                    self.viewport_proximity
298                        .borrow_mut()
299                        .registrations
300                        .get_mut(&id)
301                        .unwrap()
302                        .chain = chain;
303                }
304            }
305            self.viewport_proximity
306                .borrow_mut()
307                .pending_bindings
308                .clear();
309        } else {
310            let pending =
311                std::mem::take(&mut self.viewport_proximity.borrow_mut().pending_bindings);
312            for id in pending {
313                if let Some(node) = self.get_expanded_node_by_eid(ExpandedNodeIdentifier(id)) {
314                    let chain = self.acquire_viewport_chain(&node);
315                    if let Some(registration) = self
316                        .viewport_proximity
317                        .borrow_mut()
318                        .registrations
319                        .get_mut(&id)
320                    {
321                        registration.chain = chain;
322                    }
323                }
324            }
325        }
326        self.drain_node_effects();
327        self.viewport_proximity.borrow_mut().pending = false;
328        let dirty = std::mem::take(&mut self.viewport_proximity.borrow_mut().dirty);
329        let mut escapes_changed =
330            rebind || std::mem::take(&mut self.viewport_proximity.borrow_mut().escape_dirty);
331        for id in dirty {
332            let (owner, indexed) = {
333                let state = self.viewport_proximity.borrow();
334                let Some(dependency) = state.dependencies.get(&id) else {
335                    continue;
336                };
337                (
338                    dependency.owner,
339                    dependency.scrolls || state.registrations.contains_key(&id),
340                )
341            };
342            let Some(node) = self.get_expanded_node_by_eid(ExpandedNodeIdentifier(id)) else {
343                continue;
344            };
345            let escapes = node
346                .get_common_properties()
347                .borrow()
348                .unclippable
349                .get()
350                .unwrap_or(false);
351            let unclipped_scroll = {
352                let instance = node.instance_node.borrow();
353                instance.scrolls_content(&node) && !instance.clips_content(&node)
354            };
355            let geometry = self.canvas_geometry_for_node(&node);
356            let owner_transform = owner
357                .and_then(|owner| self.get_expanded_node_by_eid(ExpandedNodeIdentifier(owner)))
358                .map(|owner| self.canvas_geometry_for_node(&owner).world_transform)
359                .unwrap_or(Affine::IDENTITY);
360            let bounds = (owner_transform.inverse() * geometry.world_transform)
361                .transform_rect_bbox(Rect::new(0.0, 0.0, geometry.bounds.0, geometry.bounds.1));
362            let mut state = self.viewport_proximity.borrow_mut();
363            let dependency = state.dependencies.get_mut(&id).unwrap();
364            escapes_changed |=
365                dependency.escapes != escapes || dependency.unclipped_scroll != unclipped_scroll;
366            dependency.unclipped_scroll = unclipped_scroll;
367            dependency.escapes = escapes;
368            if indexed && state.indexed.get(&id) != Some(&(owner, bounds)) {
369                if let Some((old_owner, _)) = state.indexed.remove(&id) {
370                    if let Some(index) = state.indices.get_mut(&old_owner) {
371                        index.remove(id);
372                    }
373                }
374                state
375                    .indices
376                    .entry(owner)
377                    .or_default()
378                    .insert(id, Some(bounds));
379                state.indexed.insert(id, (owner, bounds));
380                state.stats.index_updates += 1;
381            }
382        }
383        if escapes_changed {
384            let mut state = self.viewport_proximity.borrow_mut();
385            // Unclippable branches may extend outside their scroll-owner proxy. They
386            // remain conservative candidates instead of being incorrectly pruned.
387            state.escaped = state
388                .registrations
389                .iter()
390                .filter_map(|(&id, registration)| {
391                    registration
392                        .chain
393                        .iter()
394                        .any(|id| {
395                            state
396                                .dependencies
397                                .get(id)
398                                .is_some_and(|d| d.escapes || d.unclipped_scroll)
399                        })
400                        .then_some(id)
401                })
402                .collect();
403        }
404    }
405
406    /// Sample once after layout settles, then dispatch a frozen local event batch.
407    pub(crate) fn dispatch_viewport_proximity(self: &Rc<Self>) {
408        {
409            let mut state = self.viewport_proximity.borrow_mut();
410            if !state.pending || state.registrations.is_empty() {
411                return;
412            }
413            state.pending = false;
414        }
415        self.refresh_viewport_index();
416        let viewport = self.viewport_observation_rect();
417        let mut frames = HashMap::new();
418        let mut candidates = BTreeSet::new();
419        self.query_viewport_domain(None, viewport, &mut frames, &mut candidates);
420        {
421            let state = self.viewport_proximity.borrow();
422            candidates.extend(state.active.keys());
423            candidates.extend(&state.escaped);
424        }
425        let mut batch = Vec::new();
426        for id in candidates {
427            if !self
428                .viewport_proximity
429                .borrow()
430                .registrations
431                .contains_key(&id)
432            {
433                continue;
434            }
435            let Some(node) = self.get_expanded_node_by_eid(ExpandedNodeIdentifier(id)) else {
436                continue;
437            };
438            if node.suspended.get() {
439                let mut state = self.viewport_proximity.borrow_mut();
440                state.active.remove(&id);
441                continue;
442            }
443            let Some(frame) = self.viewport_frame(id, viewport, &mut frames) else {
444                continue;
445            };
446            let geometry = self.canvas_geometry_for_node(&node);
447            let transform = frame.inherited_scroll * geometry.world_transform;
448            let bounds = transform.transform_rect_bbox(Rect::new(
449                0.0,
450                0.0,
451                geometry.bounds.0,
452                geometry.bounds.1,
453            ));
454            // A rotated line can have a positive AABB despite having no area.
455            let has_area = geometry.bounds.0 > 0.0
456                && geometry.bounds.1 > 0.0
457                && transform.determinant().is_finite()
458                && transform.determinant() != 0.0
459                && [bounds.x0, bounds.y0, bounds.x1, bounds.y1]
460                    .iter()
461                    .all(|v| v.is_finite());
462            let origin = kurbo::Vec2::new(-viewport.x0, -viewport.y0);
463            let current = ViewportProximitySnapshot {
464                in_proximity: has_area && intersect(Some(bounds), frame.near).is_some(),
465                bounds: bounds + origin,
466                viewport_intersection: has_area
467                    .then(|| intersect(Some(bounds), frame.visible))
468                    .flatten()
469                    .map(|r| r + origin),
470            };
471            let mut state = self.viewport_proximity.borrow_mut();
472            state.stats.samples += 1;
473            let previous = state.active.get(&id).copied();
474            let generation = state.registrations[&id].generation;
475            if current.in_proximity {
476                state.active.insert(id, current);
477            } else {
478                state.active.remove(&id);
479            }
480            if previous == Some(current) || (previous.is_none() && !current.in_proximity) {
481                continue;
482            }
483            if previous.is_none() {
484                batch.push((
485                    id,
486                    generation,
487                    Delivery::Enter(ViewportProximityEnter { current }),
488                ));
489            }
490            batch.push((
491                id,
492                generation,
493                Delivery::Change(ViewportProximityChange { previous, current }),
494            ));
495            if !current.in_proximity {
496                batch.push((
497                    id,
498                    generation,
499                    Delivery::Exit(ViewportProximityExit {
500                        previous: previous.unwrap(),
501                        current,
502                    }),
503                ));
504            }
505        }
506        // No scene/registration borrow survives application code. Recheck generation
507        // before each delivery so teardown/reload cannot receive the remainder of a batch.
508        for (id, generation, delivery) in batch {
509            if !self
510                .viewport_proximity
511                .borrow()
512                .registrations
513                .get(&id)
514                .is_some_and(|registration| registration.generation == generation)
515            {
516                continue;
517            }
518            let Some(node) = self.get_expanded_node_by_eid(ExpandedNodeIdentifier(id)) else {
519                continue;
520            };
521            if node.suspended.get() {
522                continue;
523            }
524            let key = delivery.key();
525            if !has_handlers(&node, key) {
526                continue;
527            }
528            self.viewport_proximity.borrow_mut().stats.events += 1;
529            match delivery {
530                Delivery::Enter(args) => {
531                    node.dispatch_viewport_proximity_enter(Event::new(args), self)
532                }
533                Delivery::Change(args) => {
534                    node.dispatch_viewport_proximity_change(Event::new(args), self)
535                }
536                Delivery::Exit(args) => {
537                    node.dispatch_viewport_proximity_exit(Event::new(args), self)
538                }
539            }
540        }
541    }
542
543    fn viewport_observation_rect(&self) -> Rect {
544        if self.get_root_scroller_id().is_some() {
545            if let Some(visual) = self.get_visual_viewport_state() {
546                let x = visual.page_scroll_x + visual.offset_x;
547                let y = visual.page_scroll_y + visual.offset_y;
548                return Rect::new(x, y, x + visual.width, y + visual.height);
549            }
550        }
551        let (width, height) = self.globals().viewport.get().bounds;
552        Rect::new(0.0, 0.0, width, height)
553    }
554
555    fn query_viewport_domain(
556        &self,
557        owner: Option<u32>,
558        viewport: Rect,
559        frames: &mut HashMap<u32, ViewportFrame>,
560        targets: &mut BTreeSet<u32>,
561    ) {
562        let (region, transform) = if let Some(owner) = owner {
563            let Some(frame) = self.viewport_frame(owner, viewport, frames) else {
564                return;
565            };
566            let Some(node) = self.get_expanded_node_by_eid(ExpandedNodeIdentifier(owner)) else {
567                return;
568            };
569            (
570                frame.child_near,
571                frame.child_scroll * self.canvas_geometry_for_node(&node).world_transform,
572            )
573        } else {
574            (
575                Some(viewport.inflate(viewport.width(), viewport.height())),
576                Affine::IDENTITY,
577            )
578        };
579        let Some(region) = region else {
580            return;
581        };
582        let query = transform.inverse().transform_rect_bbox(region);
583        let mut candidates = {
584            let mut state = self.viewport_proximity.borrow_mut();
585            let Some(index) = state.indices.get(&owner) else {
586                return;
587            };
588            let mut candidates = HashSet::new();
589            index.candidates(query, &mut candidates);
590            state.stats.domain_queries += 1;
591            state.stats.candidates += candidates.len() as u64;
592            candidates
593        };
594        // A page-backed root viewport moves in document coordinates. Its content
595        // remains queryable even when the original root layout box is far behind.
596        if owner.is_none() && self.get_visual_viewport_state().is_some() {
597            if let Some(root) = self.get_root_scroller_id() {
598                if self.viewport_dependency(root) {
599                    candidates.insert(root);
600                }
601            }
602        }
603        for id in candidates {
604            let (target, scrolls) = {
605                let state = self.viewport_proximity.borrow();
606                (
607                    state.registrations.contains_key(&id),
608                    state.dependencies.get(&id).is_some_and(|d| d.scrolls),
609                )
610            };
611            if target {
612                targets.insert(id);
613            }
614            if scrolls {
615                self.query_viewport_domain(Some(id), viewport, frames, targets);
616            }
617        }
618    }
619
620    fn viewport_frame(
621        &self,
622        id: u32,
623        viewport: Rect,
624        frames: &mut HashMap<u32, ViewportFrame>,
625    ) -> Option<ViewportFrame> {
626        if let Some(frame) = frames.get(&id) {
627            return Some(*frame);
628        }
629        let (parent, scrolls, escapes) = {
630            let state = self.viewport_proximity.borrow();
631            let dependency = state.dependencies.get(&id)?;
632            (dependency.parent, dependency.scrolls, dependency.escapes)
633        };
634        let parent = parent.and_then(|parent| self.viewport_frame(parent, viewport, frames));
635        let inherited_scroll = parent.map_or(Affine::IDENTITY, |p| p.child_scroll);
636        let root_near = Some(viewport.inflate(viewport.width(), viewport.height()));
637        let visible = if escapes {
638            Some(viewport)
639        } else {
640            parent.map_or(Some(viewport), |p| p.child_visible)
641        };
642        let near = if escapes {
643            root_near
644        } else {
645            parent.map_or(root_near, |p| p.child_near)
646        };
647        let node = self.get_expanded_node_by_eid(ExpandedNodeIdentifier(id))?;
648        let geometry = self.canvas_geometry_for_node(&node);
649        let mut child_visible = visible;
650        let mut child_near = near;
651        let instance = node.instance_node.borrow();
652        let page_root =
653            self.get_root_scroller_id() == Some(id) && self.get_visual_viewport_state().is_some();
654        let clip = instance
655            .resolve_effect_clip_path(&node)
656            .map(|path| (inherited_scroll * path).bounding_box());
657        if scrolls && instance.clips_content(&node) {
658            let bounds = if page_root {
659                viewport
660            } else {
661                (inherited_scroll * geometry.world_transform).transform_rect_bbox(Rect::new(
662                    0.0,
663                    0.0,
664                    geometry.bounds.0,
665                    geometry.bounds.1,
666                ))
667            };
668            child_visible = intersect(child_visible, Some(bounds));
669            child_near = intersect(
670                child_near,
671                Some(bounds.inflate(viewport.width(), viewport.height())),
672            );
673        }
674        if let Some(clip) = clip.filter(|_| !page_root) {
675            child_visible = intersect(child_visible, Some(clip));
676            child_near = intersect(
677                child_near,
678                Some(if scrolls {
679                    clip.inflate(viewport.width(), viewport.height())
680                } else {
681                    clip
682                }),
683            );
684        }
685        drop(instance);
686        let child_scroll = if scrolls {
687            inherited_scroll * self.scroller_content_presentation_transform(&node)
688        } else {
689            inherited_scroll
690        };
691        let frame = ViewportFrame {
692            inherited_scroll,
693            child_scroll,
694            visible,
695            near,
696            child_visible,
697            child_near,
698        };
699        frames.insert(id, frame);
700        Some(frame)
701    }
702}
703
704#[derive(Clone, Copy)]
705struct ViewportFrame {
706    inherited_scroll: Affine,
707    child_scroll: Affine,
708    visible: Option<Rect>,
709    near: Option<Rect>,
710    child_visible: Option<Rect>,
711    child_near: Option<Rect>,
712}
713
714fn intersect(a: Option<Rect>, b: Option<Rect>) -> Option<Rect> {
715    let rect = a?.intersect(b?);
716    (rect.width() > 0.0
717        && rect.height() > 0.0
718        && [rect.x0, rect.y0, rect.x1, rect.y1]
719            .iter()
720            .all(|x| x.is_finite()))
721    .then_some(rect)
722}
723
724enum Delivery {
725    Enter(ViewportProximityEnter),
726    Change(ViewportProximityChange),
727    Exit(ViewportProximityExit),
728}
729impl Delivery {
730    fn key(&self) -> &'static str {
731        match self {
732            Self::Enter(_) => VIEWPORT_PROXIMITY_ENTER_HANDLERS,
733            Self::Change(_) => VIEWPORT_PROXIMITY_CHANGE_HANDLERS,
734            Self::Exit(_) => VIEWPORT_PROXIMITY_EXIT_HANDLERS,
735        }
736    }
737}