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 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
341pub 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
351pub fn topmost_focus_barrier(ir: &CoreIR) -> Option<WidgetId> {
353 focus_barriers_in_tree_order(ir).last().copied()
354}
355
356pub 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
369pub 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
379pub 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
389pub 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 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}