Skip to main content

fission_core/input/
hover.rs

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