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}