Skip to main content

fission_core/
hit_test.rs

1use crate::env::ScrollStateMap;
2use crate::ui::custom_render::downcast_render_object;
3use fission_diagnostics::prelude as diag;
4use fission_ir::{CoreIR, LayoutOp, Op, PaintOp, WidgetId};
5use fission_layout::{LayoutPoint, LayoutSnapshot};
6use glam::{Mat4, Vec4};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum FocusDirection {
10    Up,
11    Down,
12    Left,
13    Right,
14}
15
16pub fn hit_test(
17    ir: &CoreIR,
18    layout: &LayoutSnapshot,
19    scroll_map: &ScrollStateMap,
20    point: LayoutPoint,
21) -> Option<WidgetId> {
22    hit_test_internal(ir, layout, Some(scroll_map), point)
23}
24
25pub fn hit_test_with_scroll(
26    ir: &CoreIR,
27    layout: &LayoutSnapshot,
28    scroll_map: &ScrollStateMap,
29    point: LayoutPoint,
30) -> Option<WidgetId> {
31    hit_test_internal(ir, layout, Some(scroll_map), point)
32}
33
34fn hit_test_internal(
35    ir: &CoreIR,
36    layout: &LayoutSnapshot,
37    scroll_map: Option<&ScrollStateMap>,
38    point: LayoutPoint,
39) -> Option<WidgetId> {
40    let result = ir
41        .root
42        .and_then(|root| hit_test_recursive(root, ir, layout, scroll_map, point));
43
44    if let Some(id) = result {
45        diag::emit(
46            diag::DiagCategory::Input,
47            diag::DiagLevel::Debug,
48            diag::DiagEventKind::InputEvent {
49                kind: "hit_test_result".into(),
50                target: Some(id.as_u128()),
51                position: Some((point.x, point.y)),
52            },
53        );
54    }
55    result
56}
57
58fn hit_test_recursive(
59    node_id: WidgetId,
60    ir: &CoreIR,
61    layout: &LayoutSnapshot,
62    scroll_map: Option<&ScrollStateMap>,
63    point: LayoutPoint,
64) -> Option<WidgetId> {
65    let node = ir.nodes.get(&node_id)?;
66    let geom = layout.get_node_geometry(node_id)?;
67
68    let is_clip_container = matches!(
69        node.op,
70        Op::Layout(LayoutOp::Clip { .. }) | Op::Layout(LayoutOp::Scroll { .. })
71    );
72
73    if is_clip_container && !geom.rect.contains(point) {
74        return None;
75    }
76
77    let mut child_point = point;
78
79    if let (Some(map), Op::Layout(LayoutOp::Scroll { direction, .. })) = (scroll_map, &node.op) {
80        let offset = map.get_offset(node_id);
81        match direction {
82            fission_ir::FlexDirection::Column => {
83                child_point.y += offset;
84            }
85            fission_ir::FlexDirection::Row => {
86                child_point.x += offset;
87            }
88        }
89    }
90
91    if let Op::Layout(LayoutOp::Transform { transform }) = &node.op {
92        let mat = Mat4::from_cols_array(transform);
93        let inv = mat.inverse();
94        let local_x = point.x - geom.rect.origin.x;
95        let local_y = point.y - geom.rect.origin.y;
96        let p = Vec4::new(local_x, local_y, 0.0, 1.0);
97        let transformed = inv * p;
98        child_point = LayoutPoint::new(
99            transformed.x + geom.rect.origin.x,
100            transformed.y + geom.rect.origin.y,
101        );
102    }
103
104    for child_id in node.children.iter().rev() {
105        if let Some(hit) = hit_test_recursive(*child_id, ir, layout, scroll_map, child_point) {
106            return Some(hit);
107        }
108    }
109
110    // --- Custom render object hit-test ----------------------------------
111    // If this node has a custom render object, delegate to it before
112    // falling through to the standard semantics-based check.
113    if geom.rect.contains(point) {
114        if let Some(any_ro) = ir.custom_render_objects.get(&node_id) {
115            if let Some(render_obj) = downcast_render_object(any_ro) {
116                let local_point =
117                    LayoutPoint::new(point.x - geom.rect.origin.x, point.y - geom.rect.origin.y);
118                let result = render_obj.hit_test(local_point, geom.rect);
119                if result.hit {
120                    return Some(node_id);
121                }
122            }
123        }
124    }
125
126    if geom.rect.contains(point) && paint_op_blocks_hit_testing(&node.op) {
127        return Some(node_id);
128    }
129
130    let mut current_is_hit = false;
131    if geom.rect.contains(point) {
132        match &node.op {
133            Op::Layout(LayoutOp::Scroll { .. }) | Op::Layout(LayoutOp::Embed { .. }) => {
134                current_is_hit = true;
135            }
136            Op::Semantics(semantics) => {
137                if !semantics.actions.entries.is_empty()
138                    || semantics.focusable
139                    || semantics.draggable
140                    || semantics.scrollable_x
141                    || semantics.scrollable_y
142                {
143                    current_is_hit = true;
144                }
145            }
146            _ => {}
147        }
148    }
149
150    if current_is_hit {
151        Some(node_id)
152    } else {
153        None
154    }
155}
156
157fn paint_op_blocks_hit_testing(op: &Op) -> bool {
158    match op {
159        Op::Paint(PaintOp::DrawRect {
160            fill,
161            stroke,
162            shadow,
163            ..
164        }) => fill.is_some() || stroke.is_some() || shadow.is_some(),
165        Op::Paint(PaintOp::DrawText { text, .. }) => !text.is_empty(),
166        Op::Paint(PaintOp::DrawRichText { runs, .. }) => {
167            runs.iter().any(|run| !run.text.is_empty())
168        }
169        Op::Paint(PaintOp::DrawImage { .. }) => true,
170        Op::Paint(PaintOp::DrawPath { fill, stroke, .. })
171        | Op::Paint(PaintOp::DrawSvg { fill, stroke, .. }) => fill.is_some() || stroke.is_some(),
172        _ => false,
173    }
174}
175
176pub fn find_next_focus_node(
177    ir: &CoreIR,
178    current: Option<WidgetId>,
179    reverse: bool,
180) -> Option<WidgetId> {
181    let nodes_in_scope = if let Some(barrier_id) = topmost_focus_barrier(ir) {
182        focusable_nodes_in_scope(ir, barrier_id)
183    } else if let Some(scope_id) = current.and_then(|id| find_containing_focus_scope(id, ir)) {
184        if is_focus_barrier(ir, scope_id) {
185            focusable_nodes_in_scope(ir, scope_id)
186        } else {
187            get_all_focusable_nodes(ir)
188        }
189    } else {
190        get_all_focusable_nodes(ir)
191    };
192
193    if nodes_in_scope.is_empty() {
194        return None;
195    }
196
197    let idx = if let Some(curr_id) = current {
198        nodes_in_scope.iter().position(|id| *id == curr_id)
199    } else {
200        None
201    };
202
203    match idx {
204        Some(i) => {
205            if reverse {
206                if i == 0 {
207                    Some(nodes_in_scope[nodes_in_scope.len() - 1])
208                } else {
209                    Some(nodes_in_scope[i - 1])
210                }
211            } else if i == nodes_in_scope.len() - 1 {
212                Some(nodes_in_scope[0])
213            } else {
214                Some(nodes_in_scope[i + 1])
215            }
216        }
217        None => {
218            if reverse {
219                Some(nodes_in_scope[nodes_in_scope.len() - 1])
220            } else {
221                Some(nodes_in_scope[0])
222            }
223        }
224    }
225}
226
227pub fn get_all_focusable_nodes(ir: &CoreIR) -> Vec<WidgetId> {
228    let mut list = Vec::new();
229    if let Some(root) = ir.root {
230        collect_focusable_nodes(root, ir, &mut list, false, 0);
231    }
232    sort_focusable_nodes(ir, list)
233}
234
235/// Returns focus barriers in tree order. The last barrier is the topmost active
236/// barrier because overlays lower after their underlying content.
237pub fn focus_barriers_in_tree_order(ir: &CoreIR) -> Vec<WidgetId> {
238    let mut barriers = Vec::new();
239    if let Some(root) = ir.root {
240        collect_focus_barriers(root, ir, &mut barriers);
241    }
242    barriers
243}
244
245/// Returns the topmost active focus barrier in the current semantic tree.
246pub fn topmost_focus_barrier(ir: &CoreIR) -> Option<WidgetId> {
247    focus_barriers_in_tree_order(ir).last().copied()
248}
249
250/// Returns enabled focusable nodes inside `scope_id` in traversal order.
251pub fn focusable_nodes_in_scope(ir: &CoreIR, scope_id: WidgetId) -> Vec<WidgetId> {
252    let mut list = Vec::new();
253    if let Some(scope) = ir.nodes.get(&scope_id) {
254        let mut order = 0;
255        for child in &scope.children {
256            collect_focusable_nodes(*child, ir, &mut list, false, order);
257            order = list.last().map(|(_, index)| *index + 1).unwrap_or(order);
258        }
259    }
260    sort_focusable_nodes(ir, list)
261}
262
263/// Returns the preferred entry target for a focus scope.
264pub fn preferred_focus_node_in_scope(ir: &CoreIR, scope_id: WidgetId) -> Option<WidgetId> {
265    let nodes = focusable_nodes_in_scope(ir, scope_id);
266    nodes
267        .iter()
268        .copied()
269        .find(|id| semantics(ir, *id).is_some_and(|value| value.autofocus))
270        .or_else(|| nodes.first().copied())
271}
272
273/// Returns whether `node_id` is an enabled focus target.
274pub fn is_enabled_focus_node(ir: &CoreIR, node_id: WidgetId) -> bool {
275    semantics(ir, node_id).is_some_and(|value| value.focusable && !value.disabled)
276        || ir
277            .custom_render_objects
278            .get(&node_id)
279            .and_then(downcast_render_object)
280            .is_some_and(|render_object| render_object.accepts_text_input())
281}
282
283/// Returns whether `node_id` is `ancestor_id` or belongs to its subtree.
284pub fn is_descendant_or_self(ir: &CoreIR, node_id: WidgetId, ancestor_id: WidgetId) -> bool {
285    let mut current = Some(node_id);
286    while let Some(id) = current {
287        if id == ancestor_id {
288            return true;
289        }
290        current = ir.nodes.get(&id).and_then(|node| node.parent);
291    }
292    false
293}
294
295fn sort_focusable_nodes(ir: &CoreIR, mut list: Vec<(WidgetId, usize)>) -> Vec<WidgetId> {
296    list.sort_by(|(id_a, order_a), (id_b, order_b)| {
297        let idx_a = ir.nodes.get(id_a).and_then(|n| {
298            if let Op::Semantics(s) = &n.op {
299                s.focus_index
300            } else {
301                None
302            }
303        });
304        let idx_b = ir.nodes.get(id_b).and_then(|n| {
305            if let Op::Semantics(s) = &n.op {
306                s.focus_index
307            } else {
308                None
309            }
310        });
311
312        match (idx_a, idx_b) {
313            (Some(a), Some(b)) => a.cmp(&b).then(order_a.cmp(order_b)),
314            (Some(_), None) => std::cmp::Ordering::Less,
315            (None, Some(_)) => std::cmp::Ordering::Greater,
316            (None, None) => order_a.cmp(order_b),
317        }
318    });
319    list.into_iter().map(|(id, _)| id).collect()
320}
321
322fn collect_focusable_nodes(
323    node_id: WidgetId,
324    ir: &CoreIR,
325    list: &mut Vec<(WidgetId, usize)>,
326    stop_at_barriers: bool,
327    mut order: usize,
328) {
329    if let Some(node) = ir.nodes.get(&node_id) {
330        let mut is_barrier = false;
331        if let Op::Semantics(s) = &node.op {
332            if s.focusable && !s.disabled {
333                list.push((node_id, order));
334                order += 1;
335            }
336            is_barrier = s.is_focus_barrier;
337        }
338
339        if stop_at_barriers && is_barrier {
340            return;
341        }
342
343        let mut children = node.children.clone();
344        // Internal sort within branches still useful for tree-order
345        children.sort_by_key(|cid| {
346            ir.nodes
347                .get(cid)
348                .and_then(|n| {
349                    if let Op::Semantics(s) = &n.op {
350                        s.focus_index
351                    } else {
352                        None
353                    }
354                })
355                .unwrap_or(i32::MAX)
356        });
357
358        for child in children {
359            collect_focusable_nodes(child, ir, list, stop_at_barriers, order);
360            order = list.last().map(|(_, o)| *o + 1).unwrap_or(order);
361        }
362    }
363}
364
365fn collect_focus_barriers(node_id: WidgetId, ir: &CoreIR, barriers: &mut Vec<WidgetId>) {
366    let Some(node) = ir.nodes.get(&node_id) else {
367        return;
368    };
369    if matches!(&node.op, Op::Semantics(value) if value.is_focus_scope && value.is_focus_barrier) {
370        barriers.push(node_id);
371    }
372    for child in &node.children {
373        collect_focus_barriers(*child, ir, barriers);
374    }
375}
376
377fn find_containing_focus_scope(node_id: WidgetId, ir: &CoreIR) -> Option<WidgetId> {
378    let mut curr = Some(node_id);
379    while let Some(pid) = curr {
380        if let Some(node) = ir.nodes.get(&pid) {
381            if let Op::Semantics(s) = &node.op {
382                if s.is_focus_scope {
383                    return Some(pid);
384                }
385            }
386            curr = node.parent;
387        } else {
388            break;
389        }
390    }
391    None
392}
393
394fn is_focus_barrier(ir: &CoreIR, node_id: WidgetId) -> bool {
395    semantics(ir, node_id).is_some_and(|value| value.is_focus_barrier)
396}
397
398fn semantics(ir: &CoreIR, node_id: WidgetId) -> Option<&fission_ir::Semantics> {
399    match &ir.nodes.get(&node_id)?.op {
400        Op::Semantics(value) => Some(value),
401        _ => None,
402    }
403}
404
405pub fn find_neighbor_focus_node(
406    ir: &CoreIR,
407    layout: &LayoutSnapshot,
408    current: WidgetId,
409    direction: FocusDirection,
410) -> Option<WidgetId> {
411    let current_rect = layout.get_node_rect(current)?;
412    let focusable_nodes = get_all_focusable_nodes(ir);
413
414    let mut best_candidate = None;
415    let mut best_dist = f32::INFINITY;
416
417    let (cx, cy) = (
418        current_rect.x() + current_rect.width() / 2.0,
419        current_rect.y() + current_rect.height() / 2.0,
420    );
421
422    for node_id in focusable_nodes {
423        if node_id == current {
424            continue;
425        }
426        let rect = match layout.get_node_rect(node_id) {
427            Some(r) => r,
428            None => continue,
429        };
430
431        let (nx, ny) = (
432            rect.x() + rect.width() / 2.0,
433            rect.y() + rect.height() / 2.0,
434        );
435
436        let is_in_dir = match direction {
437            FocusDirection::Up => ny < cy && (nx - cx).abs() < (ny - cy).abs(),
438            FocusDirection::Down => ny > cy && (nx - cx).abs() < (ny - cy).abs(),
439            FocusDirection::Left => nx < cx && (ny - cy).abs() < (nx - cx).abs(),
440            FocusDirection::Right => nx > cx && (ny - cy).abs() < (nx - cx).abs(),
441        };
442
443        if is_in_dir {
444            let dist = (nx - cx).powi(2) + (ny - cy).powi(2);
445            if dist < best_dist {
446                best_dist = dist;
447                best_candidate = Some(node_id);
448            }
449        }
450    }
451
452    best_candidate
453}