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}