Skip to main content

fission_core/
hit_test.rs

1use crate::env::ScrollStateMap;
2use crate::input::viewport::ViewportStateMap;
3use crate::ui::custom_render::downcast_render_object;
4use fission_diagnostics::prelude as diag;
5use fission_ir::{CoreIR, LayoutOp, Op, PaintOp, WidgetId};
6use fission_layout::{LayoutPoint, LayoutSnapshot};
7use glam::{Mat4, Vec4};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum FocusDirection {
11    Up,
12    Down,
13    Left,
14    Right,
15}
16
17pub fn hit_test(
18    ir: &CoreIR,
19    layout: &LayoutSnapshot,
20    scroll_map: &ScrollStateMap,
21    point: LayoutPoint,
22) -> Option<WidgetId> {
23    hit_test_internal(ir, layout, Some(scroll_map), None, point)
24}
25
26pub fn hit_test_with_scroll(
27    ir: &CoreIR,
28    layout: &LayoutSnapshot,
29    scroll_map: &ScrollStateMap,
30    point: LayoutPoint,
31) -> Option<WidgetId> {
32    hit_test_internal(ir, layout, Some(scroll_map), None, point)
33}
34
35pub fn hit_test_with_viewports(
36    ir: &CoreIR,
37    layout: &LayoutSnapshot,
38    scroll_map: &ScrollStateMap,
39    viewport_map: &ViewportStateMap,
40    point: LayoutPoint,
41) -> Option<WidgetId> {
42    hit_test_internal(ir, layout, Some(scroll_map), Some(viewport_map), point)
43}
44
45fn hit_test_internal(
46    ir: &CoreIR,
47    layout: &LayoutSnapshot,
48    scroll_map: Option<&ScrollStateMap>,
49    viewport_map: Option<&ViewportStateMap>,
50    point: LayoutPoint,
51) -> Option<WidgetId> {
52    let result = ir
53        .root
54        .and_then(|root| hit_test_recursive(root, ir, layout, scroll_map, viewport_map, point));
55
56    if let Some(id) = result {
57        diag::emit(
58            diag::DiagCategory::Input,
59            diag::DiagLevel::Debug,
60            diag::DiagEventKind::InputEvent {
61                kind: "hit_test_result".into(),
62                target: Some(id.as_u128()),
63                position: Some((point.x, point.y)),
64            },
65        );
66    }
67    result
68}
69
70fn hit_test_recursive(
71    node_id: WidgetId,
72    ir: &CoreIR,
73    layout: &LayoutSnapshot,
74    scroll_map: Option<&ScrollStateMap>,
75    viewport_map: Option<&ViewportStateMap>,
76    point: LayoutPoint,
77) -> Option<WidgetId> {
78    let node = ir.nodes.get(&node_id)?;
79    let geom = layout.get_node_geometry(node_id)?;
80
81    let is_clip_container = match &node.op {
82        Op::Layout(LayoutOp::Clip { .. }) | Op::Layout(LayoutOp::Scroll { .. }) => true,
83        Op::Layout(LayoutOp::InteractiveViewport { clip, .. }) => {
84            !matches!(clip, fission_ir::ViewportClip::None)
85        }
86        _ => false,
87    };
88
89    if is_clip_container && !geom.rect.contains(point) {
90        return None;
91    }
92
93    let mut child_point = point;
94
95    if let (Some(map), Op::Layout(LayoutOp::Scroll { direction, .. })) = (scroll_map, &node.op) {
96        let offset = map.get_offset(node_id);
97        match direction {
98            fission_ir::FlexDirection::Column => {
99                child_point.y += offset;
100            }
101            fission_ir::FlexDirection::Row => {
102                child_point.x += offset;
103            }
104        }
105    }
106
107    if let Op::Layout(LayoutOp::Transform { transform }) = &node.op {
108        let mat = Mat4::from_cols_array(transform);
109        let inv = mat.inverse();
110        let local_x = point.x - geom.rect.origin.x;
111        let local_y = point.y - geom.rect.origin.y;
112        let p = Vec4::new(local_x, local_y, 0.0, 1.0);
113        let transformed = inv * p;
114        child_point = LayoutPoint::new(
115            transformed.x + geom.rect.origin.x,
116            transformed.y + geom.rect.origin.y,
117        );
118    }
119
120    if let (Some(map), Op::Layout(LayoutOp::InteractiveViewport { .. })) = (viewport_map, &node.op)
121    {
122        if let Some(transform) = map.transform(node_id) {
123            let local = [point.x - geom.rect.origin.x, point.y - geom.rect.origin.y];
124            let world = transform.screen_to_world(local);
125            child_point =
126                LayoutPoint::new(world[0] + geom.rect.origin.x, world[1] + geom.rect.origin.y);
127        }
128    }
129
130    for child_id in node.children.iter().rev() {
131        if let Some(hit) =
132            hit_test_recursive(*child_id, ir, layout, scroll_map, viewport_map, child_point)
133        {
134            return Some(hit);
135        }
136    }
137
138    // --- Custom render object hit-test ----------------------------------
139    // If this node has a custom render object, delegate to it before
140    // falling through to the standard semantics-based check.
141    if geom.rect.contains(point) {
142        if let Some(any_ro) = ir.custom_render_objects.get(&node_id) {
143            if let Some(render_obj) = downcast_render_object(any_ro) {
144                let local_point =
145                    LayoutPoint::new(point.x - geom.rect.origin.x, point.y - geom.rect.origin.y);
146                let result = render_obj.hit_test(local_point, geom.rect);
147                if result.hit {
148                    return Some(node_id);
149                }
150            }
151        }
152    }
153
154    if geom.rect.contains(point) && paint_op_blocks_hit_testing(&node.op) {
155        return Some(node_id);
156    }
157
158    let semantic_hit = match &node.op {
159        Op::Semantics(semantics) => match semantics.canvas_target.as_ref() {
160            Some(target) if matches!(target.kind, fission_ir::CanvasTargetKind::Edge { .. }) => {
161                canvas_target_hit(target, point)
162            }
163            _ => geom.rect.contains(point),
164        },
165        _ => geom.rect.contains(point),
166    };
167    let mut current_is_hit = false;
168    if semantic_hit {
169        match &node.op {
170            Op::Layout(LayoutOp::Scroll { .. })
171            | Op::Layout(LayoutOp::Embed { .. })
172            | Op::Layout(LayoutOp::InteractiveViewport { .. }) => {
173                current_is_hit = true;
174            }
175            Op::Semantics(semantics) => {
176                if !semantics.actions.entries.is_empty()
177                    || semantics.focusable
178                    || semantics.draggable
179                    || semantics.scrollable_x
180                    || semantics.scrollable_y
181                {
182                    current_is_hit = true;
183                }
184            }
185            _ => {}
186        }
187    }
188
189    if current_is_hit {
190        Some(node_id)
191    } else {
192        None
193    }
194}
195
196fn canvas_target_hit(target: &fission_ir::CanvasTarget, point: LayoutPoint) -> bool {
197    let fission_ir::CanvasTargetKind::Edge {
198        points,
199        cubic,
200        hit_tolerance,
201        ..
202    } = &target.kind
203    else {
204        return false;
205    };
206    if points.len() < 2 {
207        return false;
208    }
209    let tolerance_squared = hit_tolerance.max(1.0).powi(2);
210    if *cubic && points.len() >= 4 {
211        let mut previous = LayoutPoint::new(points[0][0], points[0][1]);
212        for step in 1..=24 {
213            let t = step as f32 / 24.0;
214            let next = cubic_point(points, t);
215            if point_segment_distance_squared(point, previous, next) <= tolerance_squared {
216                return true;
217            }
218            previous = next;
219        }
220        false
221    } else {
222        let first = LayoutPoint::new(points[0][0], points[0][1]);
223        let second = LayoutPoint::new(points[1][0], points[1][1]);
224        point_segment_distance_squared(point, first, second) <= tolerance_squared
225    }
226}
227
228fn cubic_point(points: &[[f32; 2]], t: f32) -> LayoutPoint {
229    let inverse = 1.0 - t;
230    let weights = [
231        inverse * inverse * inverse,
232        3.0 * inverse * inverse * t,
233        3.0 * inverse * t * t,
234        t * t * t,
235    ];
236    LayoutPoint::new(
237        points
238            .iter()
239            .zip(weights)
240            .map(|(point, weight)| point[0] * weight)
241            .sum(),
242        points
243            .iter()
244            .zip(weights)
245            .map(|(point, weight)| point[1] * weight)
246            .sum(),
247    )
248}
249
250fn point_segment_distance_squared(point: LayoutPoint, start: LayoutPoint, end: LayoutPoint) -> f32 {
251    let dx = end.x - start.x;
252    let dy = end.y - start.y;
253    let length_squared = dx * dx + dy * dy;
254    if length_squared <= f32::EPSILON {
255        return (point.x - start.x).powi(2) + (point.y - start.y).powi(2);
256    }
257    let t =
258        (((point.x - start.x) * dx + (point.y - start.y) * dy) / length_squared).clamp(0.0, 1.0);
259    let nearest = LayoutPoint::new(start.x + dx * t, start.y + dy * t);
260    (point.x - nearest.x).powi(2) + (point.y - nearest.y).powi(2)
261}
262
263fn paint_op_blocks_hit_testing(op: &Op) -> bool {
264    match op {
265        Op::Paint(PaintOp::DrawRect {
266            fill,
267            stroke,
268            shadow,
269            ..
270        }) => fill.is_some() || stroke.is_some() || shadow.is_some(),
271        Op::Paint(PaintOp::DrawText { text, .. }) => !text.is_empty(),
272        Op::Paint(PaintOp::DrawRichText { runs, .. }) => {
273            runs.iter().any(|run| !run.text.is_empty())
274        }
275        Op::Paint(PaintOp::DrawImage { .. }) => true,
276        Op::Paint(PaintOp::DrawPath { fill, stroke, .. })
277        | Op::Paint(PaintOp::DrawSvg { fill, stroke, .. }) => fill.is_some() || stroke.is_some(),
278        _ => false,
279    }
280}
281
282pub fn find_next_focus_node(
283    ir: &CoreIR,
284    current: Option<WidgetId>,
285    reverse: bool,
286) -> Option<WidgetId> {
287    let nodes_in_scope = if let Some(barrier_id) = topmost_focus_barrier(ir) {
288        focusable_nodes_in_scope(ir, barrier_id)
289    } else if let Some(scope_id) = current.and_then(|id| find_containing_focus_scope(id, ir)) {
290        if is_focus_barrier(ir, scope_id) {
291            focusable_nodes_in_scope(ir, scope_id)
292        } else {
293            get_all_focusable_nodes(ir)
294        }
295    } else {
296        get_all_focusable_nodes(ir)
297    };
298
299    if nodes_in_scope.is_empty() {
300        return None;
301    }
302
303    let idx = if let Some(curr_id) = current {
304        nodes_in_scope.iter().position(|id| *id == curr_id)
305    } else {
306        None
307    };
308
309    match idx {
310        Some(i) => {
311            if reverse {
312                if i == 0 {
313                    Some(nodes_in_scope[nodes_in_scope.len() - 1])
314                } else {
315                    Some(nodes_in_scope[i - 1])
316                }
317            } else if i == nodes_in_scope.len() - 1 {
318                Some(nodes_in_scope[0])
319            } else {
320                Some(nodes_in_scope[i + 1])
321            }
322        }
323        None => {
324            if reverse {
325                Some(nodes_in_scope[nodes_in_scope.len() - 1])
326            } else {
327                Some(nodes_in_scope[0])
328            }
329        }
330    }
331}
332
333pub fn get_all_focusable_nodes(ir: &CoreIR) -> Vec<WidgetId> {
334    let mut list = Vec::new();
335    if let Some(root) = ir.root {
336        collect_focusable_nodes(root, ir, &mut list, false, 0);
337    }
338    sort_focusable_nodes(ir, list)
339}
340
341/// Returns focus barriers in tree order. The last barrier is the topmost active
342/// barrier because overlays lower after their underlying content.
343pub fn focus_barriers_in_tree_order(ir: &CoreIR) -> Vec<WidgetId> {
344    let mut barriers = Vec::new();
345    if let Some(root) = ir.root {
346        collect_focus_barriers(root, ir, &mut barriers);
347    }
348    barriers
349}
350
351/// Returns the topmost active focus barrier in the current semantic tree.
352pub fn topmost_focus_barrier(ir: &CoreIR) -> Option<WidgetId> {
353    focus_barriers_in_tree_order(ir).last().copied()
354}
355
356/// Returns enabled focusable nodes inside `scope_id` in traversal order.
357pub fn focusable_nodes_in_scope(ir: &CoreIR, scope_id: WidgetId) -> Vec<WidgetId> {
358    let mut list = Vec::new();
359    if let Some(scope) = ir.nodes.get(&scope_id) {
360        let mut order = 0;
361        for child in &scope.children {
362            collect_focusable_nodes(*child, ir, &mut list, false, order);
363            order = list.last().map(|(_, index)| *index + 1).unwrap_or(order);
364        }
365    }
366    sort_focusable_nodes(ir, list)
367}
368
369/// Returns the preferred entry target for a focus scope.
370pub fn preferred_focus_node_in_scope(ir: &CoreIR, scope_id: WidgetId) -> Option<WidgetId> {
371    let nodes = focusable_nodes_in_scope(ir, scope_id);
372    nodes
373        .iter()
374        .copied()
375        .find(|id| semantics(ir, *id).is_some_and(|value| value.autofocus))
376        .or_else(|| nodes.first().copied())
377}
378
379/// Returns whether `node_id` is an enabled focus target.
380pub fn is_enabled_focus_node(ir: &CoreIR, node_id: WidgetId) -> bool {
381    semantics(ir, node_id).is_some_and(|value| value.focusable && !value.disabled)
382        || ir
383            .custom_render_objects
384            .get(&node_id)
385            .and_then(downcast_render_object)
386            .is_some_and(|render_object| render_object.accepts_text_input())
387}
388
389/// Returns whether `node_id` is `ancestor_id` or belongs to its subtree.
390pub fn is_descendant_or_self(ir: &CoreIR, node_id: WidgetId, ancestor_id: WidgetId) -> bool {
391    let mut current = Some(node_id);
392    while let Some(id) = current {
393        if id == ancestor_id {
394            return true;
395        }
396        current = ir.nodes.get(&id).and_then(|node| node.parent);
397    }
398    false
399}
400
401fn sort_focusable_nodes(ir: &CoreIR, mut list: Vec<(WidgetId, usize)>) -> Vec<WidgetId> {
402    list.sort_by(|(id_a, order_a), (id_b, order_b)| {
403        let idx_a = ir.nodes.get(id_a).and_then(|n| {
404            if let Op::Semantics(s) = &n.op {
405                s.focus_index
406            } else {
407                None
408            }
409        });
410        let idx_b = ir.nodes.get(id_b).and_then(|n| {
411            if let Op::Semantics(s) = &n.op {
412                s.focus_index
413            } else {
414                None
415            }
416        });
417
418        match (idx_a, idx_b) {
419            (Some(a), Some(b)) => a.cmp(&b).then(order_a.cmp(order_b)),
420            (Some(_), None) => std::cmp::Ordering::Less,
421            (None, Some(_)) => std::cmp::Ordering::Greater,
422            (None, None) => order_a.cmp(order_b),
423        }
424    });
425    list.into_iter().map(|(id, _)| id).collect()
426}
427
428fn collect_focusable_nodes(
429    node_id: WidgetId,
430    ir: &CoreIR,
431    list: &mut Vec<(WidgetId, usize)>,
432    stop_at_barriers: bool,
433    mut order: usize,
434) {
435    if let Some(node) = ir.nodes.get(&node_id) {
436        let mut is_barrier = false;
437        if let Op::Semantics(s) = &node.op {
438            if s.focusable && !s.disabled {
439                list.push((node_id, order));
440                order += 1;
441            }
442            is_barrier = s.is_focus_barrier;
443        }
444
445        if stop_at_barriers && is_barrier {
446            return;
447        }
448
449        let mut children = node.children.clone();
450        // Internal sort within branches still useful for tree-order
451        children.sort_by_key(|cid| {
452            ir.nodes
453                .get(cid)
454                .and_then(|n| {
455                    if let Op::Semantics(s) = &n.op {
456                        s.focus_index
457                    } else {
458                        None
459                    }
460                })
461                .unwrap_or(i32::MAX)
462        });
463
464        for child in children {
465            collect_focusable_nodes(child, ir, list, stop_at_barriers, order);
466            order = list.last().map(|(_, o)| *o + 1).unwrap_or(order);
467        }
468    }
469}
470
471fn collect_focus_barriers(node_id: WidgetId, ir: &CoreIR, barriers: &mut Vec<WidgetId>) {
472    let Some(node) = ir.nodes.get(&node_id) else {
473        return;
474    };
475    if matches!(&node.op, Op::Semantics(value) if value.is_focus_scope && value.is_focus_barrier) {
476        barriers.push(node_id);
477    }
478    for child in &node.children {
479        collect_focus_barriers(*child, ir, barriers);
480    }
481}
482
483fn find_containing_focus_scope(node_id: WidgetId, ir: &CoreIR) -> Option<WidgetId> {
484    let mut curr = Some(node_id);
485    while let Some(pid) = curr {
486        if let Some(node) = ir.nodes.get(&pid) {
487            if let Op::Semantics(s) = &node.op {
488                if s.is_focus_scope {
489                    return Some(pid);
490                }
491            }
492            curr = node.parent;
493        } else {
494            break;
495        }
496    }
497    None
498}
499
500fn is_focus_barrier(ir: &CoreIR, node_id: WidgetId) -> bool {
501    semantics(ir, node_id).is_some_and(|value| value.is_focus_barrier)
502}
503
504fn semantics(ir: &CoreIR, node_id: WidgetId) -> Option<&fission_ir::Semantics> {
505    match &ir.nodes.get(&node_id)?.op {
506        Op::Semantics(value) => Some(value),
507        _ => None,
508    }
509}
510
511pub fn find_neighbor_focus_node(
512    ir: &CoreIR,
513    layout: &LayoutSnapshot,
514    current: WidgetId,
515    direction: FocusDirection,
516) -> Option<WidgetId> {
517    let current_rect = layout.get_node_rect(current)?;
518    let focusable_nodes = get_all_focusable_nodes(ir);
519
520    let mut best_candidate = None;
521    let mut best_dist = f32::INFINITY;
522
523    let (cx, cy) = (
524        current_rect.x() + current_rect.width() / 2.0,
525        current_rect.y() + current_rect.height() / 2.0,
526    );
527
528    for node_id in focusable_nodes {
529        if node_id == current {
530            continue;
531        }
532        let rect = match layout.get_node_rect(node_id) {
533            Some(r) => r,
534            None => continue,
535        };
536
537        let (nx, ny) = (
538            rect.x() + rect.width() / 2.0,
539            rect.y() + rect.height() / 2.0,
540        );
541
542        let is_in_dir = match direction {
543            FocusDirection::Up => ny < cy && (nx - cx).abs() < (ny - cy).abs(),
544            FocusDirection::Down => ny > cy && (nx - cx).abs() < (ny - cy).abs(),
545            FocusDirection::Left => nx < cx && (ny - cy).abs() < (nx - cx).abs(),
546            FocusDirection::Right => nx > cx && (ny - cy).abs() < (nx - cx).abs(),
547        };
548
549        if is_in_dir {
550            let dist = (nx - cx).powi(2) + (ny - cy).powi(2);
551            if dist < best_dist {
552                best_dist = dist;
553                best_candidate = Some(node_id);
554            }
555        }
556    }
557
558    best_candidate
559}
560
561#[cfg(test)]
562mod canvas_hit_tests {
563    use super::canvas_target_hit;
564    use fission_ir::{CanvasSelectionPolicy, CanvasTarget, CanvasTargetKind};
565    use fission_layout::LayoutPoint;
566
567    fn edge(points: Vec<[f32; 2]>, cubic: bool) -> CanvasTarget {
568        CanvasTarget {
569            canvas_id: 1,
570            kind: CanvasTargetKind::Edge {
571                edge_id: 2,
572                points,
573                cubic,
574                hit_tolerance: 4.0,
575            },
576            selection_policy: CanvasSelectionPolicy::Single,
577            snap_spacing: None,
578            snap_threshold: 0.0,
579        }
580    }
581
582    #[test]
583    fn edge_hit_testing_uses_stroke_geometry_instead_of_its_bounding_box() {
584        let straight = edge(vec![[10.0, 10.0], [90.0, 90.0]], false);
585        assert!(canvas_target_hit(&straight, LayoutPoint::new(50.0, 52.0)));
586        assert!(!canvas_target_hit(&straight, LayoutPoint::new(10.0, 90.0)));
587
588        let cubic = edge(
589            vec![[0.0, 50.0], [25.0, 0.0], [75.0, 100.0], [100.0, 50.0]],
590            true,
591        );
592        assert!(canvas_target_hit(&cubic, LayoutPoint::new(50.0, 50.0)));
593        assert!(!canvas_target_hit(&cubic, LayoutPoint::new(50.0, 5.0)));
594    }
595}