Skip to main content

cranpose_render_common/
graph_scene.rs

1use std::{
2    cell::{Cell, RefCell},
3    cmp::Reverse,
4    rc::Rc,
5};
6
7use cranpose_core::{
8    MemoryApplier, NodeId,
9    collections::map::{HashMap, HashSet},
10};
11use cranpose_foundation::{MINIMUM_TOUCH_TARGET_SIZE, PointerEvent, PointerEventKind};
12use cranpose_ui::{LayoutNode, ModifierNodeSlices, SubcomposeLayoutNode};
13use cranpose_ui_graphics::{Point, PointerIcon, Rect, RoundedCornerShape};
14
15use crate::{
16    HitTestTarget, RenderScene, SceneUpdates,
17    graph::{ProjectiveTransform, RenderGraph},
18};
19
20pub struct RenderDiagnostics {
21    reported_warnings: RefCell<HashSet<&'static str>>,
22    live_modifier_slice_lookup_miss_count: Cell<usize>,
23}
24
25impl RenderDiagnostics {
26    pub fn new() -> Self {
27        Self {
28            reported_warnings: RefCell::new(HashSet::default()),
29            live_modifier_slice_lookup_miss_count: Cell::new(0),
30        }
31    }
32
33    pub fn claim_warning_once(&self, key: &'static str) -> bool {
34        self.reported_warnings.borrow_mut().insert(key)
35    }
36
37    pub fn record_live_modifier_slice_lookup_miss(&self) {
38        self.live_modifier_slice_lookup_miss_count.set(
39            self.live_modifier_slice_lookup_miss_count
40                .get()
41                .saturating_add(1),
42        );
43    }
44
45    pub fn live_modifier_slice_lookup_miss_count(&self) -> usize {
46        self.live_modifier_slice_lookup_miss_count.get()
47    }
48}
49
50impl Default for RenderDiagnostics {
51    fn default() -> Self {
52        Self::new()
53    }
54}
55
56#[derive(Clone, Copy, Debug, PartialEq)]
57pub struct HitClip {
58    pub quad: [[f32; 2]; 4],
59    pub bounds: Rect,
60}
61
62/// Geometry for a hit target, borrowing the clip chain until the sink records it.
63#[derive(Clone, Copy)]
64pub struct HitGeometry<'a> {
65    pub rect: Rect,
66    pub quad: [[f32; 2]; 4],
67    pub local_bounds: Rect,
68    pub world_to_local: ProjectiveTransform,
69    pub hit_clip_bounds: Option<Rect>,
70    pub hit_clips: &'a [HitClip],
71}
72
73/// What a hit target answers with: the shape that narrows its bounds, and the
74/// node's modifier slices, which hold the pointer handlers it dispatches to
75/// and the pointer icon it asks for while hovered.
76pub struct HitTargetSpec<'a> {
77    /// Narrows the target's rectangle to a rounded shape, so a point in a
78    /// corner cutout misses it.
79    pub shape: Option<RoundedCornerShape>,
80    /// The node's slices, shared by the target rather than copied.
81    pub handlers: &'a Rc<ModifierNodeSlices>,
82}
83
84#[derive(Clone)]
85pub struct HitRegion {
86    pub node_id: NodeId,
87    pub capture_path: Vec<NodeId>,
88    pub rect: Rect,
89    pub quad: [[f32; 2]; 4],
90    pub local_bounds: Rect,
91    pub world_to_local: ProjectiveTransform,
92    pub shape: Option<RoundedCornerShape>,
93    /// The node's modifier slices, shared with the render graph: its pointer
94    /// handlers and pointer icon.
95    pub handlers: Rc<ModifierNodeSlices>,
96    pub z_index: usize,
97    pub hit_clip_bounds: Option<Rect>,
98    pub hit_clips: Vec<HitClip>,
99    diagnostics: Rc<RenderDiagnostics>,
100}
101
102struct HitRegionInit<'a> {
103    node_id: NodeId,
104    capture_path: Vec<NodeId>,
105    geometry: HitGeometry<'a>,
106    clip_buffer: Vec<HitClip>,
107    shape: Option<RoundedCornerShape>,
108    handlers: Rc<ModifierNodeSlices>,
109    z_index: usize,
110    diagnostics: Rc<RenderDiagnostics>,
111}
112
113impl Default for HitRegionInit<'_> {
114    fn default() -> Self {
115        Self {
116            node_id: 0,
117            capture_path: Vec::new(),
118            geometry: HitGeometry {
119                rect: Rect {
120                    x: 0.0,
121                    y: 0.0,
122                    width: 0.0,
123                    height: 0.0,
124                },
125                quad: [[0.0, 0.0]; 4],
126                local_bounds: Rect {
127                    x: 0.0,
128                    y: 0.0,
129                    width: 0.0,
130                    height: 0.0,
131                },
132                world_to_local: ProjectiveTransform::identity(),
133                hit_clip_bounds: None,
134                hit_clips: &[],
135            },
136            clip_buffer: Vec::new(),
137            shape: None,
138            handlers: Rc::default(),
139            z_index: 0,
140            diagnostics: Rc::new(RenderDiagnostics::new()),
141        }
142    }
143}
144
145impl HitRegion {
146    fn with_diagnostics(init: HitRegionInit<'_>) -> Self {
147        let HitRegionInit {
148            node_id,
149            capture_path,
150            geometry,
151            clip_buffer: mut hit_clips,
152            shape,
153            handlers,
154            z_index,
155            diagnostics,
156        } = init;
157        let HitGeometry {
158            rect,
159            quad,
160            local_bounds,
161            world_to_local,
162            hit_clip_bounds,
163            hit_clips: clips,
164        } = geometry;
165        hit_clips.extend_from_slice(clips);
166        Self {
167            node_id,
168            capture_path,
169            rect,
170            quad,
171            local_bounds,
172            world_to_local,
173            shape,
174            handlers,
175            z_index,
176            hit_clip_bounds,
177            hit_clips,
178            diagnostics,
179        }
180    }
181
182    fn contains(&self, x: f32, y: f32) -> bool {
183        if !self.rect.contains(x, y) {
184            return false;
185        }
186
187        if let Some(clip_bounds) = self.hit_clip_bounds
188            && !clip_bounds.contains(x, y)
189        {
190            return false;
191        }
192
193        let point = Point { x, y };
194        if !point_in_quad(point, self.quad) {
195            return false;
196        }
197
198        for clip in &self.hit_clips {
199            if !point_in_quad(point, clip.quad) {
200                return false;
201            }
202        }
203
204        let local_point = self.world_to_local.map_point(point);
205        if let Some(shape) = self.shape {
206            point_in_rounded_rect(local_point, self.local_bounds, shape)
207        } else {
208            self.local_bounds.contains(local_point.x, local_point.y)
209        }
210    }
211
212    /// The squared distance from the point to this target when the point lies
213    /// inside the target grown to the minimum touch size and the target takes
214    /// input. None when the target is large enough on its own, when the point
215    /// is outside its reach, or when a clip cuts the point off.
216    fn reach_distance(&self, x: f32, y: f32) -> Option<f32> {
217        if self.handlers.pointer_inputs().is_empty() {
218            return None;
219        }
220        if let Some(clip_bounds) = self.hit_clip_bounds
221            && !clip_bounds.contains(x, y)
222        {
223            return None;
224        }
225        let grow_x = ((MINIMUM_TOUCH_TARGET_SIZE - self.rect.width) / 2.0).max(0.0);
226        let grow_y = ((MINIMUM_TOUCH_TARGET_SIZE - self.rect.height) / 2.0).max(0.0);
227        if grow_x <= 0.0 && grow_y <= 0.0 {
228            return None;
229        }
230        let right = self.rect.x + self.rect.width;
231        let bottom = self.rect.y + self.rect.height;
232        let in_reach = x >= self.rect.x - grow_x
233            && x <= right + grow_x
234            && y >= self.rect.y - grow_y
235            && y <= bottom + grow_y;
236        if !in_reach {
237            return None;
238        }
239        let dx = (self.rect.x - x).max(x - right).max(0.0);
240        let dy = (self.rect.y - y).max(y - bottom).max(0.0);
241        Some(dx * dx + dy * dy)
242    }
243
244    fn localize_event(&self, event: &PointerEvent) -> PointerEvent {
245        let local = self.world_to_local.map_point(event.global_position);
246        event.copy_with_local_position(Point {
247            x: local.x - self.local_bounds.x,
248            y: local.y - self.local_bounds.y,
249        })
250    }
251
252    fn dispatch_modifier_slices(&self, modifier_slices: &ModifierNodeSlices, event: PointerEvent) {
253        if should_skip_consumed_event(&event) {
254            return;
255        }
256
257        modifier_slices.dispatch_pointer_event(self.localize_event(&event));
258    }
259
260    fn live_modifier_slices(&self, applier: &mut MemoryApplier) -> Option<Rc<ModifierNodeSlices>> {
261        if let Ok(modifier_slices) =
262            applier.with_node::<LayoutNode, _>(self.node_id, |node| node.modifier_slices_snapshot())
263        {
264            return Some(modifier_slices);
265        }
266
267        applier
268            .with_node::<SubcomposeLayoutNode, _>(self.node_id, |node| {
269                node.modifier_slices_snapshot()
270            })
271            .ok()
272    }
273}
274
275fn is_terminal_pointer_event(kind: PointerEventKind) -> bool {
276    matches!(kind, PointerEventKind::Up | PointerEventKind::Cancel)
277}
278
279fn should_skip_consumed_event(event: &PointerEvent) -> bool {
280    event.is_consumed() && !is_terminal_pointer_event(event.kind)
281}
282
283impl HitTestTarget for HitRegion {
284    fn node_id(&self) -> NodeId {
285        self.node_id
286    }
287
288    fn pointer_icon(&self) -> Option<PointerIcon> {
289        self.handlers.pointer_icon().cloned()
290    }
291
292    fn capture_path(&self) -> Vec<NodeId> {
293        self.capture_path.clone()
294    }
295
296    fn dispatch(&self, event: PointerEvent) {
297        self.dispatch_modifier_slices(&self.handlers, event);
298    }
299
300    fn dispatch_with_applier(&self, applier: &mut MemoryApplier, event: PointerEvent) {
301        if let Some(modifier_slices) = self.live_modifier_slices(applier) {
302            self.dispatch_modifier_slices(modifier_slices.as_ref(), event);
303            return;
304        }
305
306        self.diagnostics.record_live_modifier_slice_lookup_miss();
307        self.dispatch_modifier_slices(&self.handlers, event);
308    }
309}
310
311#[derive(Default)]
312struct HitBuffers {
313    hit_clips: Vec<HitClip>,
314    capture_path: Vec<NodeId>,
315}
316
317pub struct Scene {
318    pub graph: Option<RenderGraph>,
319    pub hits: Vec<HitRegion>,
320    hit_buffers: Vec<HitBuffers>,
321    pub next_hit_z: usize,
322    pub node_index: HashMap<NodeId, usize>,
323    diagnostics: Rc<RenderDiagnostics>,
324}
325
326impl Scene {
327    pub fn new() -> Self {
328        Self {
329            graph: None,
330            hits: Vec::new(),
331            hit_buffers: Vec::new(),
332            next_hit_z: 0,
333            node_index: HashMap::default(),
334            diagnostics: Rc::new(RenderDiagnostics::new()),
335        }
336    }
337
338    pub fn diagnostics(&self) -> &RenderDiagnostics {
339        self.diagnostics.as_ref()
340    }
341
342    /// Rebuilds the retained graph and hit targets from the applier, reusing
343    /// storage from the previous graph.
344    pub fn rebuild_from_applier(&mut self, applier: &MemoryApplier, root: NodeId) {
345        let previous = self.graph.take();
346        self.clear_hits();
347        let Some(graph) =
348            crate::scene_builder::rebuild_graph_from_applier(applier, root, 1.0, previous)
349        else {
350            return;
351        };
352        self.install_graph_with_hits(graph);
353    }
354
355    fn install_graph_with_hits(&mut self, graph: RenderGraph) {
356        crate::hit_graph::collect_hits_from_graph(
357            &graph.root,
358            ProjectiveTransform::identity(),
359            self,
360            None,
361        );
362        self.replace_graph(graph);
363    }
364
365    /// Applies content and layer-property changes to the retained graph.
366    /// Empty updates or an unavailable scoped update rebuild the scene.
367    /// Hit targets are refreshed when their geometry changes or `refresh_hits`
368    /// is true, as required after layout or pointer-handler changes.
369    pub fn update_from_applier(
370        &mut self,
371        applier: &MemoryApplier,
372        root: NodeId,
373        updates: SceneUpdates<'_>,
374        refresh_hits: bool,
375    ) {
376        let report = if updates.is_empty() {
377            None
378        } else {
379            self.graph.as_mut().map(|graph| {
380                crate::scene_builder::update_graph_from_applier_report(applier, graph, updates, 1.0)
381            })
382        };
383        let Some(report) = report.filter(|report| report.applied()) else {
384            self.rebuild_from_applier(applier, root);
385            return;
386        };
387        if !refresh_hits && !report.hit_graph_dirty {
388            return;
389        }
390        self.clear_hits();
391        if let Some(graph) = self.graph.take() {
392            self.install_graph_with_hits(graph);
393        }
394    }
395
396    /// Adds an interactive target in draw order, ignoring targets that neither
397    /// handle a pointer nor name a pointer icon.
398    pub fn push_hit(
399        &mut self,
400        node_id: NodeId,
401        capture_path: &[NodeId],
402        geometry: HitGeometry<'_>,
403        target: HitTargetSpec<'_>,
404    ) {
405        let HitTargetSpec { shape, handlers } = target;
406        if handlers.pointer_inputs().is_empty() && handlers.pointer_icon().is_none() {
407            return;
408        }
409        let mut buffers = self.hit_buffers.pop().unwrap_or_default();
410        buffers.capture_path.extend_from_slice(capture_path);
411
412        let z_index = self.next_hit_z;
413        self.next_hit_z += 1;
414        let hit_index = self.hits.len();
415        self.hits.push(HitRegion::with_diagnostics(HitRegionInit {
416            node_id,
417            capture_path: buffers.capture_path,
418            geometry,
419            clip_buffer: buffers.hit_clips,
420            shape,
421            handlers: Rc::clone(handlers),
422            z_index,
423            diagnostics: Rc::clone(&self.diagnostics),
424        }));
425        self.node_index.insert(node_id, hit_index);
426    }
427
428    /// Removes all hit targets and releases their handlers while retaining vector capacity.
429    pub fn clear_hits(&mut self) {
430        self.hit_buffers.clear();
431        for hit in self.hits.drain(..) {
432            let mut buffers = HitBuffers {
433                hit_clips: hit.hit_clips,
434                capture_path: hit.capture_path,
435            };
436            buffers.hit_clips.clear();
437            buffers.capture_path.clear();
438            self.hit_buffers.push(buffers);
439        }
440        self.node_index.clear();
441        self.next_hit_z = 0;
442    }
443
444    pub fn replace_graph(&mut self, graph: RenderGraph) {
445        self.graph = Some(graph);
446    }
447}
448
449impl Default for Scene {
450    fn default() -> Self {
451        Self::new()
452    }
453}
454
455impl RenderScene for Scene {
456    type HitTarget = HitRegion;
457
458    fn clear(&mut self) {
459        self.graph = None;
460        self.clear_hits();
461    }
462
463    fn hit_test(&self, x: f32, y: f32) -> Vec<Self::HitTarget> {
464        let mut hit_indices: Vec<usize> = self
465            .hits
466            .iter()
467            .enumerate()
468            .filter_map(|(index, hit)| hit.contains(x, y).then_some(index))
469            .collect();
470
471        hit_indices.sort_by_key(|&index| Reverse(self.hits[index].z_index));
472        hit_indices
473            .into_iter()
474            .map(|index| self.hits[index].clone())
475            .collect()
476    }
477
478    fn hit_test_near(&self, x: f32, y: f32) -> Option<Self::HitTarget> {
479        self.hits
480            .iter()
481            .filter_map(|hit| hit.reach_distance(x, y).map(|distance| (distance, hit)))
482            .min_by(|(near, hit), (other_near, other)| {
483                near.total_cmp(other_near)
484                    .then_with(|| other.z_index.cmp(&hit.z_index))
485            })
486            .map(|(_, hit)| hit.clone())
487    }
488
489    fn find_target(&self, node_id: NodeId) -> Option<Self::HitTarget> {
490        self.node_index
491            .get(&node_id)
492            .and_then(|&index| self.hits.get(index))
493            .cloned()
494    }
495
496    fn collect_retained_visual_observation_nodes(&self, nodes: &mut HashSet<NodeId>) -> bool {
497        if let Some(graph) = &self.graph {
498            graph.collect_retained_visual_observation_nodes(nodes);
499        } else {
500            nodes.clear();
501        }
502        true
503    }
504}
505
506fn point_in_rounded_rect(point: Point, rect: Rect, shape: RoundedCornerShape) -> bool {
507    if !rect.contains(point.x, point.y) {
508        return false;
509    }
510
511    let local_x = point.x - rect.x;
512    let local_y = point.y - rect.y;
513    let radii = shape.resolve(rect.width, rect.height);
514    let tl = radii.top_left;
515    let tr = radii.top_right;
516    let bl = radii.bottom_left;
517    let br = radii.bottom_right;
518
519    if local_x < tl && local_y < tl {
520        let dx = tl - local_x;
521        let dy = tl - local_y;
522        return dx * dx + dy * dy <= tl * tl;
523    }
524
525    if local_x > rect.width - tr && local_y < tr {
526        let dx = local_x - (rect.width - tr);
527        let dy = tr - local_y;
528        return dx * dx + dy * dy <= tr * tr;
529    }
530
531    if local_x < bl && local_y > rect.height - bl {
532        let dx = bl - local_x;
533        let dy = local_y - (rect.height - bl);
534        return dx * dx + dy * dy <= bl * bl;
535    }
536
537    if local_x > rect.width - br && local_y > rect.height - br {
538        let dx = local_x - (rect.width - br);
539        let dy = local_y - (rect.height - br);
540        return dx * dx + dy * dy <= br * br;
541    }
542
543    true
544}
545
546fn point_in_quad(point: Point, quad: [[f32; 2]; 4]) -> bool {
547    point_in_triangle(point, quad[0], quad[1], quad[3])
548        || point_in_triangle(point, quad[0], quad[3], quad[2])
549}
550
551fn point_in_triangle(point: Point, a: [f32; 2], b: [f32; 2], c: [f32; 2]) -> bool {
552    let d1 = triangle_sign(point, a, b);
553    let d2 = triangle_sign(point, b, c);
554    let d3 = triangle_sign(point, c, a);
555    let has_negative = d1 < -f32::EPSILON || d2 < -f32::EPSILON || d3 < -f32::EPSILON;
556    let has_positive = d1 > f32::EPSILON || d2 > f32::EPSILON || d3 > f32::EPSILON;
557    !(has_negative && has_positive)
558}
559
560fn triangle_sign(point: Point, a: [f32; 2], b: [f32; 2]) -> f32 {
561    (point.x - b[0]) * (a[1] - b[1]) - (a[0] - b[0]) * (point.y - b[1])
562}
563
564#[cfg(test)]
565#[path = "tests/graph_scene_tests.rs"]
566mod tests;