Skip to main content

fission_core/input/
hover.rs

1use crate::event::{InputEvent, PointerEvent};
2use crate::hit_test::hit_test_with_scroll;
3use crate::input::{ControllerContext, InputController};
4use crate::{ActionEnvelope, ActionId, ActionInput};
5use fission_ir::op::{PaintOp, RichTextAnnotation};
6use fission_ir::semantics::{ActionTrigger, MouseCursor};
7use fission_ir::{NodeId, Op};
8use fission_layout::{LayoutPoint, LayoutRect};
9
10type ResolvedRichTextAnnotation = (NodeId, RichTextAnnotation);
11
12pub struct HoverController;
13
14impl HoverController {
15    pub fn clear(ctx: &mut ControllerContext, point: Option<LayoutPoint>) -> bool {
16        Self::apply_hover_path(ctx, Vec::new(), point)
17    }
18
19    fn hover_path_at_point(ctx: &ControllerContext, point: LayoutPoint) -> Vec<NodeId> {
20        let Some(hit_node_id) = hit_test_with_scroll(ctx.ir, ctx.layout, ctx.scroll, point) else {
21            return Vec::new();
22        };
23
24        let mut path = Vec::new();
25        let mut current = Some(hit_node_id);
26        while let Some(node_id) = current {
27            path.push(node_id);
28            current = ctx.ir.nodes.get(&node_id).and_then(|node| node.parent);
29        }
30        path
31    }
32
33    fn apply_hover_path(
34        ctx: &mut ControllerContext,
35        next_path: Vec<NodeId>,
36        point: Option<LayoutPoint>,
37    ) -> bool {
38        let previous_path = ctx.interaction.hover_path.clone();
39        let previous_annotation = ctx.interaction.hovered_rich_text_annotation().cloned();
40        let next_annotation =
41            point.and_then(|point| resolve_rich_text_annotation_at_point(ctx, &next_path, point));
42        let common_tail_len = shared_tail_len(&previous_path, &next_path);
43        let exited = &previous_path[..previous_path.len().saturating_sub(common_tail_len)];
44        let entered = &next_path[..next_path.len().saturating_sub(common_tail_len)];
45
46        for node_id in exited {
47            ctx.interaction.set_hovered(*node_id, false);
48        }
49        for node_id in entered {
50            ctx.interaction.set_hovered(*node_id, true);
51        }
52
53        for node_id in exited {
54            dispatch_hover_actions(ctx, *node_id, ActionTrigger::HoverExit, point);
55        }
56        for node_id in entered.iter().rev() {
57            dispatch_hover_actions(ctx, *node_id, ActionTrigger::HoverEnter, point);
58        }
59
60        if previous_annotation
61            .as_ref()
62            .map(|annotation| (&annotation.node_id, &annotation.annotation))
63            != next_annotation
64                .as_ref()
65                .map(|(node_id, annotation)| (node_id, annotation))
66        {
67            if let Some(previous) = &previous_annotation {
68                dispatch_annotation_actions(
69                    ctx,
70                    previous.node_id,
71                    &previous.annotation,
72                    ActionTrigger::HoverExit,
73                    point,
74                );
75            }
76            if let Some((node_id, annotation)) = &next_annotation {
77                dispatch_annotation_actions(
78                    ctx,
79                    *node_id,
80                    annotation,
81                    ActionTrigger::HoverEnter,
82                    point,
83                );
84            }
85        }
86
87        let next_cursor = resolve_cursor(ctx, &next_path, next_annotation.as_ref());
88        let changed = previous_path != next_path
89            || previous_annotation
90                .as_ref()
91                .map(|annotation| (&annotation.node_id, &annotation.annotation))
92                != next_annotation
93                    .as_ref()
94                    .map(|(node_id, annotation)| (node_id, annotation))
95            || ctx.interaction.cursor != next_cursor;
96        ctx.interaction.set_hover_path(next_path);
97        ctx.interaction
98            .set_hovered_rich_text_annotation(next_annotation.map(|(node_id, annotation)| {
99                crate::env::HoveredRichTextAnnotation {
100                    node_id,
101                    annotation,
102                }
103            }));
104        ctx.interaction.set_cursor(next_cursor);
105        changed
106    }
107}
108
109impl InputController for HoverController {
110    fn handle_event(&mut self, ctx: &mut ControllerContext, event: &InputEvent) -> bool {
111        match event {
112            InputEvent::Pointer(PointerEvent::Down { point, .. })
113            | InputEvent::Pointer(PointerEvent::Up { point, .. })
114            | InputEvent::Pointer(PointerEvent::Move { point, .. })
115            | InputEvent::Pointer(PointerEvent::Scroll { point, .. }) => {
116                let next_path = Self::hover_path_at_point(ctx, *point);
117                let _ = Self::apply_hover_path(ctx, next_path, Some(*point));
118            }
119            _ => {}
120        }
121        false
122    }
123}
124
125fn shared_tail_len(previous_path: &[NodeId], next_path: &[NodeId]) -> usize {
126    previous_path
127        .iter()
128        .rev()
129        .zip(next_path.iter().rev())
130        .take_while(|(previous, next)| previous == next)
131        .count()
132}
133
134fn resolve_cursor(
135    ctx: &ControllerContext,
136    hover_path: &[NodeId],
137    rich_text_annotation: Option<&ResolvedRichTextAnnotation>,
138) -> MouseCursor {
139    if let Some((_, annotation)) = rich_text_annotation {
140        if let Some(cursor) = annotation.mouse_cursor.map(map_rich_text_cursor) {
141            return cursor;
142        }
143    }
144
145    for node_id in hover_path {
146        let Some(node) = ctx.ir.nodes.get(node_id) else {
147            continue;
148        };
149        let Op::Semantics(semantics) = &node.op else {
150            continue;
151        };
152        if let Some(cursor) = semantics
153            .actions
154            .entries
155            .iter()
156            .find_map(|entry| entry.as_hover_cursor())
157        {
158            return cursor;
159        }
160    }
161
162    MouseCursor::Default
163}
164
165fn map_rich_text_cursor(cursor: fission_ir::op::MouseCursor) -> MouseCursor {
166    match cursor {
167        fission_ir::op::MouseCursor::Basic => MouseCursor::Default,
168        fission_ir::op::MouseCursor::Pointer => MouseCursor::Pointer,
169        fission_ir::op::MouseCursor::Text => MouseCursor::Text,
170    }
171}
172
173pub(crate) fn resolve_rich_text_annotation_at_point(
174    ctx: &ControllerContext,
175    hover_path: &[NodeId],
176    point: LayoutPoint,
177) -> Option<ResolvedRichTextAnnotation> {
178    let measurer = ctx.measurer?;
179
180    for node_id in hover_path {
181        let Some(any_annotations) = ctx.ir.custom_render_objects.get(node_id) else {
182            continue;
183        };
184        let Some(annotations) = any_annotations.downcast_ref::<Vec<RichTextAnnotation>>() else {
185            continue;
186        };
187        let Some(node) = ctx.ir.nodes.get(node_id) else {
188            continue;
189        };
190        let Op::Paint(PaintOp::DrawRichText {
191            runs,
192            wrap,
193            paragraph_style,
194            ..
195        }) = &node.op
196        else {
197            continue;
198        };
199        let Some(rect) = visual_rect_for_node(ctx, *node_id) else {
200            continue;
201        };
202        let local_x = point.x - rect.origin.x;
203        let local_y = point.y - rect.origin.y;
204        let available_width = if *wrap && rect.width() > 0.0 {
205            Some(rect.width())
206        } else {
207            None
208        };
209
210        if let Some(annotation) = measurer.resolve_rich_text_annotation_at_point(
211            runs,
212            available_width,
213            local_x,
214            local_y,
215            paragraph_style.unwrap_or_default(),
216            annotations,
217        ) {
218            return Some((*node_id, annotation));
219        }
220    }
221
222    None
223}
224
225fn visual_rect_for_node(ctx: &ControllerContext, node_id: NodeId) -> Option<LayoutRect> {
226    let mut rect = ctx.layout.get_node_rect(node_id)?;
227    let mut current = ctx.ir.nodes.get(&node_id).and_then(|node| node.parent);
228    while let Some(parent_id) = current {
229        let Some(parent) = ctx.ir.nodes.get(&parent_id) else {
230            break;
231        };
232        if let Op::Layout(fission_ir::LayoutOp::Scroll { direction, .. }) = &parent.op {
233            let offset = ctx.scroll.get_offset(parent_id);
234            match direction {
235                fission_ir::FlexDirection::Row => rect.origin.x -= offset,
236                fission_ir::FlexDirection::Column => rect.origin.y -= offset,
237            }
238        }
239        current = parent.parent;
240    }
241    Some(rect)
242}
243
244fn dispatch_hover_actions(
245    ctx: &mut ControllerContext,
246    node_id: NodeId,
247    trigger: ActionTrigger,
248    point: Option<LayoutPoint>,
249) {
250    let Some(node) = ctx.ir.nodes.get(&node_id) else {
251        return;
252    };
253    let Op::Semantics(semantics) = &node.op else {
254        return;
255    };
256
257    for entry in semantics
258        .actions
259        .entries
260        .iter()
261        .filter(|entry| entry.trigger == trigger)
262    {
263        let Some(payload) = &entry.payload_data else {
264            continue;
265        };
266        let input = crate::input::scoped_action_input(
267            ctx.ir,
268            node_id,
269            point.map(pointer_input).unwrap_or(ActionInput::None),
270        );
271        ctx.dispatched_actions.push((
272            node_id,
273            ActionEnvelope {
274                id: ActionId::from_u128(entry.action_id),
275                payload: payload.clone(),
276            },
277            input,
278        ));
279    }
280}
281
282fn dispatch_annotation_actions(
283    ctx: &mut ControllerContext,
284    node_id: NodeId,
285    annotation: &RichTextAnnotation,
286    trigger: ActionTrigger,
287    point: Option<LayoutPoint>,
288) {
289    for entry in annotation
290        .actions
291        .iter()
292        .filter(|entry| entry.trigger == trigger)
293    {
294        let Some(payload) = &entry.payload_data else {
295            continue;
296        };
297        let input = crate::input::scoped_action_input(
298            ctx.ir,
299            node_id,
300            point.map(pointer_input).unwrap_or(ActionInput::None),
301        );
302        ctx.dispatched_actions.push((
303            node_id,
304            ActionEnvelope {
305                id: ActionId::from_u128(entry.action_id),
306                payload: payload.clone(),
307            },
308            input,
309        ));
310    }
311}
312
313fn pointer_input(point: LayoutPoint) -> ActionInput {
314    ActionInput::Pointer {
315        x: point.x,
316        y: point.y,
317        delta_x: 0.0,
318        delta_y: 0.0,
319    }
320}