use crate::env::ScrollStateMap;
use crate::input::viewport::ViewportStateMap;
use crate::ui::custom_render::downcast_render_object;
use fission_diagnostics::prelude as diag;
use fission_ir::{CoreIR, LayoutOp, Op, PaintOp, WidgetId};
use fission_layout::{LayoutPoint, LayoutSnapshot};
use glam::{Mat4, Vec4};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FocusDirection {
Up,
Down,
Left,
Right,
}
pub fn hit_test(
ir: &CoreIR,
layout: &LayoutSnapshot,
scroll_map: &ScrollStateMap,
point: LayoutPoint,
) -> Option<WidgetId> {
hit_test_internal(ir, layout, Some(scroll_map), None, point)
}
pub fn hit_test_with_scroll(
ir: &CoreIR,
layout: &LayoutSnapshot,
scroll_map: &ScrollStateMap,
point: LayoutPoint,
) -> Option<WidgetId> {
hit_test_internal(ir, layout, Some(scroll_map), None, point)
}
pub fn hit_test_with_viewports(
ir: &CoreIR,
layout: &LayoutSnapshot,
scroll_map: &ScrollStateMap,
viewport_map: &ViewportStateMap,
point: LayoutPoint,
) -> Option<WidgetId> {
hit_test_internal(ir, layout, Some(scroll_map), Some(viewport_map), point)
}
fn hit_test_internal(
ir: &CoreIR,
layout: &LayoutSnapshot,
scroll_map: Option<&ScrollStateMap>,
viewport_map: Option<&ViewportStateMap>,
point: LayoutPoint,
) -> Option<WidgetId> {
let result = ir
.root
.and_then(|root| hit_test_recursive(root, ir, layout, scroll_map, viewport_map, point));
if let Some(id) = result {
diag::emit(
diag::DiagCategory::Input,
diag::DiagLevel::Debug,
diag::DiagEventKind::InputEvent {
kind: "hit_test_result".into(),
target: Some(id.as_u128()),
position: Some((point.x, point.y)),
},
);
}
result
}
fn hit_test_recursive(
node_id: WidgetId,
ir: &CoreIR,
layout: &LayoutSnapshot,
scroll_map: Option<&ScrollStateMap>,
viewport_map: Option<&ViewportStateMap>,
point: LayoutPoint,
) -> Option<WidgetId> {
let node = ir.nodes.get(&node_id)?;
let geom = layout.get_node_geometry(node_id)?;
let is_clip_container = match &node.op {
Op::Layout(LayoutOp::Clip { .. }) | Op::Layout(LayoutOp::Scroll { .. }) => true,
Op::Layout(LayoutOp::InteractiveViewport { clip, .. }) => {
!matches!(clip, fission_ir::ViewportClip::None)
}
_ => false,
};
if is_clip_container && !geom.rect.contains(point) {
return None;
}
let mut child_point = point;
if let (Some(map), Op::Layout(LayoutOp::Scroll { direction, .. })) = (scroll_map, &node.op) {
let offset = map.get_offset(node_id);
match direction {
fission_ir::FlexDirection::Column => {
child_point.y += offset;
}
fission_ir::FlexDirection::Row => {
child_point.x += offset;
}
}
}
if let Op::Layout(LayoutOp::Transform { transform }) = &node.op {
let mat = Mat4::from_cols_array(transform);
let inv = mat.inverse();
let local_x = point.x - geom.rect.origin.x;
let local_y = point.y - geom.rect.origin.y;
let p = Vec4::new(local_x, local_y, 0.0, 1.0);
let transformed = inv * p;
child_point = LayoutPoint::new(
transformed.x + geom.rect.origin.x,
transformed.y + geom.rect.origin.y,
);
}
if let (Some(map), Op::Layout(LayoutOp::InteractiveViewport { .. })) = (viewport_map, &node.op)
{
if let Some(transform) = map.transform(node_id) {
let local = [point.x - geom.rect.origin.x, point.y - geom.rect.origin.y];
let world = transform.screen_to_world(local);
child_point =
LayoutPoint::new(world[0] + geom.rect.origin.x, world[1] + geom.rect.origin.y);
}
}
for child_id in node.children.iter().rev() {
if let Some(hit) =
hit_test_recursive(*child_id, ir, layout, scroll_map, viewport_map, child_point)
{
return Some(hit);
}
}
if geom.rect.contains(point) {
if let Some(any_ro) = ir.custom_render_objects.get(&node_id) {
if let Some(render_obj) = downcast_render_object(any_ro) {
let local_point =
LayoutPoint::new(point.x - geom.rect.origin.x, point.y - geom.rect.origin.y);
let result = render_obj.hit_test(local_point, geom.rect);
if result.hit {
return Some(node_id);
}
}
}
}
if geom.rect.contains(point) && paint_op_blocks_hit_testing(&node.op) {
return Some(node_id);
}
let semantic_hit = match &node.op {
Op::Semantics(semantics) => match semantics.canvas_target.as_ref() {
Some(target) if matches!(target.kind, fission_ir::CanvasTargetKind::Edge { .. }) => {
canvas_target_hit(target, point)
}
_ => geom.rect.contains(point),
},
_ => geom.rect.contains(point),
};
let mut current_is_hit = false;
if semantic_hit {
match &node.op {
Op::Layout(LayoutOp::Scroll { .. })
| Op::Layout(LayoutOp::Embed { .. })
| Op::Layout(LayoutOp::InteractiveViewport { .. }) => {
current_is_hit = true;
}
Op::Semantics(semantics) => {
if !semantics.actions.entries.is_empty()
|| semantics.focusable
|| semantics.draggable
|| semantics.scrollable_x
|| semantics.scrollable_y
{
current_is_hit = true;
}
}
_ => {}
}
}
if current_is_hit {
Some(node_id)
} else {
None
}
}
fn canvas_target_hit(target: &fission_ir::CanvasTarget, point: LayoutPoint) -> bool {
let fission_ir::CanvasTargetKind::Edge {
points,
cubic,
hit_tolerance,
..
} = &target.kind
else {
return false;
};
if points.len() < 2 {
return false;
}
let tolerance_squared = hit_tolerance.max(1.0).powi(2);
if *cubic && points.len() >= 4 {
let mut previous = LayoutPoint::new(points[0][0], points[0][1]);
for step in 1..=24 {
let t = step as f32 / 24.0;
let next = cubic_point(points, t);
if point_segment_distance_squared(point, previous, next) <= tolerance_squared {
return true;
}
previous = next;
}
false
} else {
let first = LayoutPoint::new(points[0][0], points[0][1]);
let second = LayoutPoint::new(points[1][0], points[1][1]);
point_segment_distance_squared(point, first, second) <= tolerance_squared
}
}
fn cubic_point(points: &[[f32; 2]], t: f32) -> LayoutPoint {
let inverse = 1.0 - t;
let weights = [
inverse * inverse * inverse,
3.0 * inverse * inverse * t,
3.0 * inverse * t * t,
t * t * t,
];
LayoutPoint::new(
points
.iter()
.zip(weights)
.map(|(point, weight)| point[0] * weight)
.sum(),
points
.iter()
.zip(weights)
.map(|(point, weight)| point[1] * weight)
.sum(),
)
}
fn point_segment_distance_squared(point: LayoutPoint, start: LayoutPoint, end: LayoutPoint) -> f32 {
let dx = end.x - start.x;
let dy = end.y - start.y;
let length_squared = dx * dx + dy * dy;
if length_squared <= f32::EPSILON {
return (point.x - start.x).powi(2) + (point.y - start.y).powi(2);
}
let t =
(((point.x - start.x) * dx + (point.y - start.y) * dy) / length_squared).clamp(0.0, 1.0);
let nearest = LayoutPoint::new(start.x + dx * t, start.y + dy * t);
(point.x - nearest.x).powi(2) + (point.y - nearest.y).powi(2)
}
fn paint_op_blocks_hit_testing(op: &Op) -> bool {
match op {
Op::Paint(PaintOp::DrawRect {
fill,
stroke,
shadow,
..
}) => fill.is_some() || stroke.is_some() || shadow.is_some(),
Op::Paint(PaintOp::DrawText { text, .. }) => !text.is_empty(),
Op::Paint(PaintOp::DrawRichText { runs, .. }) => {
runs.iter().any(|run| !run.text.is_empty())
}
Op::Paint(PaintOp::DrawImage { .. }) => true,
Op::Paint(PaintOp::DrawPath { fill, stroke, .. })
| Op::Paint(PaintOp::DrawSvg { fill, stroke, .. }) => fill.is_some() || stroke.is_some(),
_ => false,
}
}
pub fn find_next_focus_node(
ir: &CoreIR,
current: Option<WidgetId>,
reverse: bool,
) -> Option<WidgetId> {
let nodes_in_scope = if let Some(barrier_id) = topmost_focus_barrier(ir) {
focusable_nodes_in_scope(ir, barrier_id)
} else if let Some(scope_id) = current.and_then(|id| find_containing_focus_scope(id, ir)) {
if is_focus_barrier(ir, scope_id) {
focusable_nodes_in_scope(ir, scope_id)
} else {
get_all_focusable_nodes(ir)
}
} else {
get_all_focusable_nodes(ir)
};
if nodes_in_scope.is_empty() {
return None;
}
let idx = if let Some(curr_id) = current {
nodes_in_scope.iter().position(|id| *id == curr_id)
} else {
None
};
match idx {
Some(i) => {
if reverse {
if i == 0 {
Some(nodes_in_scope[nodes_in_scope.len() - 1])
} else {
Some(nodes_in_scope[i - 1])
}
} else if i == nodes_in_scope.len() - 1 {
Some(nodes_in_scope[0])
} else {
Some(nodes_in_scope[i + 1])
}
}
None => {
if reverse {
Some(nodes_in_scope[nodes_in_scope.len() - 1])
} else {
Some(nodes_in_scope[0])
}
}
}
}
pub fn get_all_focusable_nodes(ir: &CoreIR) -> Vec<WidgetId> {
let mut list = Vec::new();
if let Some(root) = ir.root {
collect_focusable_nodes(root, ir, &mut list, false, 0);
}
sort_focusable_nodes(ir, list)
}
pub fn focus_barriers_in_tree_order(ir: &CoreIR) -> Vec<WidgetId> {
let mut barriers = Vec::new();
if let Some(root) = ir.root {
collect_focus_barriers(root, ir, &mut barriers);
}
barriers
}
pub fn topmost_focus_barrier(ir: &CoreIR) -> Option<WidgetId> {
focus_barriers_in_tree_order(ir).last().copied()
}
pub fn focusable_nodes_in_scope(ir: &CoreIR, scope_id: WidgetId) -> Vec<WidgetId> {
let mut list = Vec::new();
if let Some(scope) = ir.nodes.get(&scope_id) {
let mut order = 0;
for child in &scope.children {
collect_focusable_nodes(*child, ir, &mut list, false, order);
order = list.last().map(|(_, index)| *index + 1).unwrap_or(order);
}
}
sort_focusable_nodes(ir, list)
}
pub fn preferred_focus_node_in_scope(ir: &CoreIR, scope_id: WidgetId) -> Option<WidgetId> {
let nodes = focusable_nodes_in_scope(ir, scope_id);
nodes
.iter()
.copied()
.find(|id| semantics(ir, *id).is_some_and(|value| value.autofocus))
.or_else(|| nodes.first().copied())
}
pub fn is_enabled_focus_node(ir: &CoreIR, node_id: WidgetId) -> bool {
semantics(ir, node_id).is_some_and(|value| value.focusable && !value.disabled)
|| ir
.custom_render_objects
.get(&node_id)
.and_then(downcast_render_object)
.is_some_and(|render_object| render_object.accepts_text_input())
}
pub fn is_descendant_or_self(ir: &CoreIR, node_id: WidgetId, ancestor_id: WidgetId) -> bool {
let mut current = Some(node_id);
while let Some(id) = current {
if id == ancestor_id {
return true;
}
current = ir.nodes.get(&id).and_then(|node| node.parent);
}
false
}
fn sort_focusable_nodes(ir: &CoreIR, mut list: Vec<(WidgetId, usize)>) -> Vec<WidgetId> {
list.sort_by(|(id_a, order_a), (id_b, order_b)| {
let idx_a = ir.nodes.get(id_a).and_then(|n| {
if let Op::Semantics(s) = &n.op {
s.focus_index
} else {
None
}
});
let idx_b = ir.nodes.get(id_b).and_then(|n| {
if let Op::Semantics(s) = &n.op {
s.focus_index
} else {
None
}
});
match (idx_a, idx_b) {
(Some(a), Some(b)) => a.cmp(&b).then(order_a.cmp(order_b)),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => order_a.cmp(order_b),
}
});
list.into_iter().map(|(id, _)| id).collect()
}
fn collect_focusable_nodes(
node_id: WidgetId,
ir: &CoreIR,
list: &mut Vec<(WidgetId, usize)>,
stop_at_barriers: bool,
mut order: usize,
) {
if let Some(node) = ir.nodes.get(&node_id) {
let mut is_barrier = false;
if let Op::Semantics(s) = &node.op {
if s.focusable && !s.disabled {
list.push((node_id, order));
order += 1;
}
is_barrier = s.is_focus_barrier;
}
if stop_at_barriers && is_barrier {
return;
}
let mut children = node.children.clone();
children.sort_by_key(|cid| {
ir.nodes
.get(cid)
.and_then(|n| {
if let Op::Semantics(s) = &n.op {
s.focus_index
} else {
None
}
})
.unwrap_or(i32::MAX)
});
for child in children {
collect_focusable_nodes(child, ir, list, stop_at_barriers, order);
order = list.last().map(|(_, o)| *o + 1).unwrap_or(order);
}
}
}
fn collect_focus_barriers(node_id: WidgetId, ir: &CoreIR, barriers: &mut Vec<WidgetId>) {
let Some(node) = ir.nodes.get(&node_id) else {
return;
};
if matches!(&node.op, Op::Semantics(value) if value.is_focus_scope && value.is_focus_barrier) {
barriers.push(node_id);
}
for child in &node.children {
collect_focus_barriers(*child, ir, barriers);
}
}
fn find_containing_focus_scope(node_id: WidgetId, ir: &CoreIR) -> Option<WidgetId> {
let mut curr = Some(node_id);
while let Some(pid) = curr {
if let Some(node) = ir.nodes.get(&pid) {
if let Op::Semantics(s) = &node.op {
if s.is_focus_scope {
return Some(pid);
}
}
curr = node.parent;
} else {
break;
}
}
None
}
fn is_focus_barrier(ir: &CoreIR, node_id: WidgetId) -> bool {
semantics(ir, node_id).is_some_and(|value| value.is_focus_barrier)
}
fn semantics(ir: &CoreIR, node_id: WidgetId) -> Option<&fission_ir::Semantics> {
match &ir.nodes.get(&node_id)?.op {
Op::Semantics(value) => Some(value),
_ => None,
}
}
pub fn find_neighbor_focus_node(
ir: &CoreIR,
layout: &LayoutSnapshot,
current: WidgetId,
direction: FocusDirection,
) -> Option<WidgetId> {
let current_rect = layout.get_node_rect(current)?;
let focusable_nodes = get_all_focusable_nodes(ir);
let mut best_candidate = None;
let mut best_dist = f32::INFINITY;
let (cx, cy) = (
current_rect.x() + current_rect.width() / 2.0,
current_rect.y() + current_rect.height() / 2.0,
);
for node_id in focusable_nodes {
if node_id == current {
continue;
}
let rect = match layout.get_node_rect(node_id) {
Some(r) => r,
None => continue,
};
let (nx, ny) = (
rect.x() + rect.width() / 2.0,
rect.y() + rect.height() / 2.0,
);
let is_in_dir = match direction {
FocusDirection::Up => ny < cy && (nx - cx).abs() < (ny - cy).abs(),
FocusDirection::Down => ny > cy && (nx - cx).abs() < (ny - cy).abs(),
FocusDirection::Left => nx < cx && (ny - cy).abs() < (nx - cx).abs(),
FocusDirection::Right => nx > cx && (ny - cy).abs() < (nx - cx).abs(),
};
if is_in_dir {
let dist = (nx - cx).powi(2) + (ny - cy).powi(2);
if dist < best_dist {
best_dist = dist;
best_candidate = Some(node_id);
}
}
}
best_candidate
}
#[cfg(test)]
mod canvas_hit_tests {
use super::canvas_target_hit;
use fission_ir::{CanvasSelectionPolicy, CanvasTarget, CanvasTargetKind};
use fission_layout::LayoutPoint;
fn edge(points: Vec<[f32; 2]>, cubic: bool) -> CanvasTarget {
CanvasTarget {
canvas_id: 1,
kind: CanvasTargetKind::Edge {
edge_id: 2,
points,
cubic,
hit_tolerance: 4.0,
},
selection_policy: CanvasSelectionPolicy::Single,
snap_spacing: None,
snap_threshold: 0.0,
}
}
#[test]
fn edge_hit_testing_uses_stroke_geometry_instead_of_its_bounding_box() {
let straight = edge(vec![[10.0, 10.0], [90.0, 90.0]], false);
assert!(canvas_target_hit(&straight, LayoutPoint::new(50.0, 52.0)));
assert!(!canvas_target_hit(&straight, LayoutPoint::new(10.0, 90.0)));
let cubic = edge(
vec![[0.0, 50.0], [25.0, 0.0], [75.0, 100.0], [100.0, 50.0]],
true,
);
assert!(canvas_target_hit(&cubic, LayoutPoint::new(50.0, 50.0)));
assert!(!canvas_target_hit(&cubic, LayoutPoint::new(50.0, 5.0)));
}
}