Skip to main content

fission_core/input/
gesture.rs

1use super::{ControllerContext, InputController};
2use crate::event::{ExternalDragEvent, InputEvent, PointerEvent};
3use crate::scrollbar::{
4    scrollbar_drag_offset, scrollbar_drag_offset_with_grab, scrollbar_geometry_for_node,
5    scrollbar_hit_test, scrollbar_point_for_node, ScrollbarDragState, ScrollbarHitKind,
6};
7use crate::{Action, ActionEnvelope, ActionId, ActionInput, DragSessionPayload, DragSessionState};
8use fission_ir::op::RichTextAnnotation;
9use fission_ir::{semantics::ActionTrigger, Op, WidgetId};
10use fission_layout::{LayoutPoint, LayoutSnapshot};
11
12pub(crate) fn cancel_active_drag_for_viewport(
13    ir: &fission_ir::CoreIR,
14    layout: &LayoutSnapshot,
15    viewport: &crate::input::viewport::ViewportStateMap,
16    gesture: &crate::env::GestureState,
17    point: LayoutPoint,
18    dispatched_actions: &mut Vec<(WidgetId, ActionEnvelope, ActionInput)>,
19) {
20    let Some(start_node) = gesture.target_node.filter(|_| gesture.is_panning) else {
21        return;
22    };
23    let mut current_id = Some(start_node);
24    while let Some(node_id) = current_id {
25        let Some(node) = ir.nodes.get(&node_id) else {
26            break;
27        };
28        if let Op::Semantics(semantics) = &node.op {
29            if let Some(entry) = semantics
30                .actions
31                .entries
32                .iter()
33                .find(|entry| entry.trigger == ActionTrigger::DragEnd)
34            {
35                let input = if let Some(target) = &semantics.canvas_target {
36                    ActionInput::CanvasInteraction(crate::input::canvas::canvas_interaction(
37                        node_id,
38                        target,
39                        crate::input::canvas::CanvasInteractionPhase::Cancel,
40                        point,
41                        LayoutPoint::ZERO,
42                        gesture.start_point,
43                        layout,
44                        viewport,
45                        gesture.pointer_kind,
46                        gesture.modifiers,
47                    ))
48                } else {
49                    ActionInput::Pointer {
50                        x: point.x,
51                        y: point.y,
52                        delta_x: 0.0,
53                        delta_y: 0.0,
54                    }
55                };
56                dispatched_actions.push((
57                    node_id,
58                    ActionEnvelope {
59                        id: ActionId::from_u128(entry.action_id),
60                        payload: entry.payload_data.clone().unwrap_or_default(),
61                    },
62                    crate::input::scoped_action_input(ir, node_id, input),
63                ));
64                return;
65            }
66        }
67        current_id = node.parent;
68    }
69}
70
71pub struct GestureController;
72
73impl InputController for GestureController {
74    fn handle_event(&mut self, ctx: &mut ControllerContext, event: &InputEvent) -> bool {
75        match event {
76            InputEvent::Pointer(pe) => {
77                match pe {
78                    PointerEvent::Down {
79                        point,
80                        button,
81                        kind,
82                        modifiers,
83                        ..
84                    } => {
85                        // GestureState currently models one active pointer-button
86                        // sequence. Do not let an additional button press replace
87                        // the button that must match the eventual release.
88                        if ctx.gesture.pressed_button.is_some() {
89                            return true;
90                        }
91
92                        ctx.gesture.start_point = Some(*point);
93                        ctx.gesture.last_point = Some(*point);
94                        ctx.gesture.is_panning = false;
95                        ctx.gesture.pressed_button = Some(button.clone());
96                        ctx.gesture.pointer_kind = *kind;
97                        ctx.gesture.modifiers = *modifiers;
98                        ctx.gesture.scrollbar_drag = None;
99
100                        if matches!(button, crate::event::PointerButton::Primary) {
101                            if let Some(hit) =
102                                scrollbar_hit_test(ctx.ir, ctx.layout, ctx.scroll, *point)
103                            {
104                                let pointer_to_thumb_start = match hit.kind {
105                                    ScrollbarHitKind::Thumb => hit.pointer_to_thumb_start,
106                                    ScrollbarHitKind::Rail => hit.geometry.thumb_extent() * 0.5,
107                                };
108                                let new_offset = match hit.kind {
109                                    ScrollbarHitKind::Thumb => hit.geometry.offset,
110                                    ScrollbarHitKind::Rail => {
111                                        scrollbar_drag_offset(hit.geometry, hit.layout_point)
112                                    }
113                                };
114                                ctx.scroll.set_offset(hit.geometry.node_id, new_offset);
115                                ctx.gesture.target_node = Some(hit.geometry.node_id);
116                                ctx.gesture.dragging_payload = None;
117                                ctx.gesture.scrollbar_drag = Some(ScrollbarDragState {
118                                    node_id: hit.geometry.node_id,
119                                    pointer_to_thumb_start,
120                                });
121                                return true;
122                            }
123                        }
124
125                        if let Some(hit) = crate::hit_test::hit_test_with_viewports(
126                            ctx.ir,
127                            ctx.layout,
128                            ctx.scroll,
129                            ctx.viewport,
130                            *point,
131                        ) {
132                            ctx.gesture.target_node = Some(hit);
133                            ctx.gesture.dragging_payload =
134                                matches!(button, crate::event::PointerButton::Primary)
135                                    .then(|| self.find_drag_payload(ctx, hit))
136                                    .flatten();
137                        } else {
138                            ctx.gesture.target_node = None;
139                            ctx.gesture.dragging_payload = None;
140                        }
141                    }
142                    PointerEvent::Move {
143                        point,
144                        kind,
145                        modifiers,
146                        ..
147                    } => {
148                        ctx.gesture.pointer_kind = *kind;
149                        ctx.gesture.modifiers = *modifiers;
150                        if !matches!(
151                            ctx.gesture.pressed_button,
152                            Some(crate::event::PointerButton::Primary)
153                        ) {
154                            return false;
155                        }
156
157                        if let Some(drag) = ctx.gesture.scrollbar_drag {
158                            if let Some(geometry) = scrollbar_geometry_for_node(
159                                ctx.ir,
160                                ctx.layout,
161                                ctx.scroll,
162                                drag.node_id,
163                            ) {
164                                let new_offset = scrollbar_drag_offset_with_grab(
165                                    geometry,
166                                    scrollbar_point_for_node(
167                                        ctx.ir,
168                                        ctx.scroll,
169                                        drag.node_id,
170                                        *point,
171                                    ),
172                                    drag.pointer_to_thumb_start,
173                                );
174                                ctx.scroll.set_offset(drag.node_id, new_offset);
175                            }
176                            ctx.gesture.last_point = Some(*point);
177                            return true;
178                        }
179
180                        if let Some(start) = ctx.gesture.start_point {
181                            let dx = point.x - start.x;
182                            let dy = point.y - start.y;
183                            let dist_sq = dx * dx + dy * dy;
184                            let threshold = 5.0 * 5.0;
185
186                            if !ctx.gesture.is_panning && dist_sq > threshold {
187                                ctx.gesture.is_panning = true;
188                                if let Some(payload) = ctx.gesture.dragging_payload.clone() {
189                                    let target = ctx.gesture.target_node;
190                                    let source_identifier =
191                                        target.and_then(|id| self.semantic_identifier(ctx, id));
192                                    ctx.gesture.drag_session = Some(DragSessionState {
193                                        source_node: target,
194                                        source_identifier,
195                                        payload: DragSessionPayload::Internal(payload),
196                                        point: *point,
197                                        target_node: None,
198                                        target_identifier: None,
199                                    });
200                                    self.update_drag_target(ctx, *point);
201                                }
202                                // Dispatch DragStart now
203                                if let Some(target) = ctx.gesture.target_node {
204                                    self.dispatch_trigger(
205                                        ctx,
206                                        target,
207                                        ActionTrigger::DragStart,
208                                        *point,
209                                        None,
210                                    );
211                                }
212                            }
213
214                            if ctx.gesture.is_panning {
215                                if let Some(session) = ctx.gesture.drag_session.as_mut() {
216                                    session.point = *point;
217                                }
218                                self.update_drag_target(ctx, *point);
219
220                                let last = ctx.gesture.last_point.unwrap_or(start);
221                                let delta = LayoutPoint {
222                                    x: point.x - last.x,
223                                    y: point.y - last.y,
224                                };
225                                ctx.gesture.last_point = Some(*point);
226
227                                // Try dispatching DragUpdate
228                                let dispatched = if let Some(target) = ctx.gesture.target_node {
229                                    self.dispatch_trigger(
230                                        ctx,
231                                        target,
232                                        ActionTrigger::DragUpdate,
233                                        *point,
234                                        Some(delta),
235                                    )
236                                } else {
237                                    false
238                                };
239
240                                if dispatched {
241                                    return true;
242                                }
243
244                                // Fallback to Scroll Panning if DragUpdate not handled
245                                if self.handle_pan_update(ctx, delta) {
246                                    return true;
247                                }
248                            }
249                        }
250                    }
251                    PointerEvent::Up {
252                        point,
253                        button,
254                        kind,
255                        modifiers,
256                        ..
257                    } => {
258                        ctx.gesture.pointer_kind = *kind;
259                        ctx.gesture.modifiers = *modifiers;
260                        let scrollbar_drag = ctx.gesture.scrollbar_drag.take();
261                        let mut handled = false;
262                        let pressed_button = ctx.gesture.pressed_button.clone();
263                        let buttons_match = pressed_button.as_ref() == Some(button);
264                        let was_primary =
265                            matches!(pressed_button, Some(crate::event::PointerButton::Primary));
266                        let was_secondary =
267                            matches!(pressed_button, Some(crate::event::PointerButton::Secondary));
268
269                        if pressed_button.is_some() && !buttons_match {
270                            self.reset_pointer_sequence(ctx, *point);
271                            return true;
272                        }
273
274                        if buttons_match && ctx.gesture.is_panning {
275                            // Internal Drop
276                            if let Some(payload) = ctx.gesture.dragging_payload.take() {
277                                if let Some(up_hit) = crate::hit_test::hit_test_with_viewports(
278                                    ctx.ir,
279                                    ctx.layout,
280                                    ctx.scroll,
281                                    ctx.viewport,
282                                    *point,
283                                ) {
284                                    let _ = self.dispatch_internal_drop(
285                                        ctx, up_hit, payload, *point, *modifiers,
286                                    );
287                                }
288                            }
289
290                            if let Some(target) = ctx.gesture.target_node {
291                                self.dispatch_trigger(
292                                    ctx,
293                                    target,
294                                    ActionTrigger::DragEnd,
295                                    *point,
296                                    None,
297                                );
298                            }
299                            handled = true;
300                        } else if buttons_match && was_secondary {
301                            // Secondary click (right-click)
302                            if let Some(target) = ctx.gesture.target_node {
303                                if let Some(up_hit) = crate::hit_test::hit_test_with_viewports(
304                                    ctx.ir,
305                                    ctx.layout,
306                                    ctx.scroll,
307                                    ctx.viewport,
308                                    *point,
309                                ) {
310                                    if up_hit == target
311                                        || self.is_descendant(ctx, up_hit, target)
312                                        || self.is_descendant(ctx, target, up_hit)
313                                    {
314                                        if let Some(menu_owner) =
315                                            self.find_context_menu_owner(ctx, up_hit)
316                                        {
317                                            ctx.context_menu.open(menu_owner, *point);
318                                            handled = true;
319                                        }
320
321                                        let rich_text_path = self.path_for_node(ctx, up_hit);
322                                        if !handled {
323                                            if let Some((annotation_node_id, annotation)) =
324                                                crate::input::hover::resolve_rich_text_annotation_at_point(
325                                                    ctx,
326                                                    &rich_text_path,
327                                                    *point,
328                                                )
329                                            {
330                                                handled = self.dispatch_annotation_trigger(
331                                                    ctx,
332                                                    annotation_node_id,
333                                                    &annotation,
334                                                    ActionTrigger::SecondaryClick,
335                                                    *point,
336                                                );
337                                            }
338                                        }
339
340                                        if !handled
341                                            && self.dispatch_trigger(
342                                                ctx,
343                                                target,
344                                                ActionTrigger::SecondaryClick,
345                                                *point,
346                                                None,
347                                            )
348                                        {
349                                            handled = true;
350                                        }
351                                    }
352                                }
353                            }
354                        } else if buttons_match && was_primary {
355                            // Tap (primary click)
356                            if let Some(target) = ctx.gesture.target_node {
357                                if let Some(up_hit) = crate::hit_test::hit_test_with_viewports(
358                                    ctx.ir,
359                                    ctx.layout,
360                                    ctx.scroll,
361                                    ctx.viewport,
362                                    *point,
363                                ) {
364                                    if up_hit == target
365                                        || self.is_descendant(ctx, up_hit, target)
366                                        || self.is_descendant(ctx, target, up_hit)
367                                    {
368                                        let rich_text_path = self.path_for_node(ctx, up_hit);
369                                        if let Some((annotation_node_id, annotation)) =
370                                            crate::input::hover::resolve_rich_text_annotation_at_point(
371                                                ctx,
372                                                &rich_text_path,
373                                                *point,
374                                            )
375                                        {
376                                            handled = self.dispatch_annotation_trigger(
377                                                ctx,
378                                                annotation_node_id,
379                                                &annotation,
380                                                ActionTrigger::Default,
381                                                *point,
382                                            );
383                                        }
384
385                                        if !handled
386                                            && self.dispatch_trigger(
387                                                ctx,
388                                                target,
389                                                ActionTrigger::Default,
390                                                *point,
391                                                None,
392                                            )
393                                        {
394                                            handled = true;
395                                        }
396                                    }
397                                }
398                            }
399                        }
400
401                        if !was_secondary {
402                            ctx.context_menu.close();
403                        }
404                        self.reset_pointer_sequence(ctx, *point);
405                        if scrollbar_drag.is_some() {
406                            ctx.gesture.target_node = None;
407                            return true;
408                        }
409                        return handled;
410                    }
411                    PointerEvent::Cancel {
412                        point,
413                        kind,
414                        modifiers,
415                        ..
416                    } => {
417                        let had_sequence = ctx.gesture.pressed_button.is_some()
418                            || ctx.gesture.drag_session.is_some()
419                            || ctx.gesture.scrollbar_drag.is_some();
420                        ctx.gesture.pointer_kind = *kind;
421                        ctx.gesture.modifiers = *modifiers;
422                        if ctx.gesture.is_panning {
423                            if let Some(target) = ctx.gesture.target_node {
424                                self.dispatch_trigger_with_phase(
425                                    ctx,
426                                    target,
427                                    ActionTrigger::DragEnd,
428                                    *point,
429                                    None,
430                                    Some(crate::input::canvas::CanvasInteractionPhase::Cancel),
431                                );
432                            }
433                        }
434                        ctx.gesture.scrollbar_drag = None;
435                        self.reset_pointer_sequence(ctx, *point);
436                        ctx.gesture.target_node = None;
437                        return had_sequence;
438                    }
439                    _ => {}
440                }
441            }
442            InputEvent::ExternalDrag(event) => match event {
443                ExternalDragEvent::Hover { point, paths, .. } => {
444                    ctx.gesture.drag_session = Some(DragSessionState {
445                        source_node: None,
446                        source_identifier: None,
447                        payload: DragSessionPayload::ExternalFiles(paths.clone()),
448                        point: *point,
449                        target_node: ctx
450                            .gesture
451                            .drag_session
452                            .as_ref()
453                            .and_then(|s| s.target_node),
454                        target_identifier: ctx
455                            .gesture
456                            .drag_session
457                            .as_ref()
458                            .and_then(|s| s.target_identifier.clone()),
459                    });
460                    self.update_drag_target(ctx, *point);
461                    return true;
462                }
463                ExternalDragEvent::Cancel => {
464                    let point = ctx
465                        .gesture
466                        .drag_session
467                        .as_ref()
468                        .map(|session| session.point)
469                        .unwrap_or(LayoutPoint::ZERO);
470                    self.clear_drag_target(ctx, point);
471                    ctx.gesture.drag_session = None;
472                    return true;
473                }
474                ExternalDragEvent::Drop {
475                    point,
476                    paths,
477                    modifiers,
478                } => {
479                    ctx.gesture.drag_session = Some(DragSessionState {
480                        source_node: None,
481                        source_identifier: None,
482                        payload: DragSessionPayload::ExternalFiles(paths.clone()),
483                        point: *point,
484                        target_node: ctx
485                            .gesture
486                            .drag_session
487                            .as_ref()
488                            .and_then(|s| s.target_node),
489                        target_identifier: ctx
490                            .gesture
491                            .drag_session
492                            .as_ref()
493                            .and_then(|s| s.target_identifier.clone()),
494                    });
495                    self.update_drag_target(ctx, *point);
496                    if let Some(target) = ctx
497                        .gesture
498                        .drag_session
499                        .as_ref()
500                        .and_then(|s| s.target_node)
501                    {
502                        let _ = self.dispatch_external_drop(
503                            ctx,
504                            target,
505                            paths.clone(),
506                            *point,
507                            *modifiers,
508                        );
509                    }
510                    self.clear_drag_target(ctx, *point);
511                    ctx.gesture.drag_session = None;
512                    return true;
513                }
514            },
515            InputEvent::ContextMenuRequested { point, .. } => {
516                return self.handle_context_menu_request(ctx, *point);
517            }
518            _ => {}
519        }
520        false
521    }
522}
523
524impl GestureController {
525    fn handle_context_menu_request(
526        &mut self,
527        ctx: &mut ControllerContext,
528        point: LayoutPoint,
529    ) -> bool {
530        let Some(hit) = crate::hit_test::hit_test_with_viewports(
531            ctx.ir,
532            ctx.layout,
533            ctx.scroll,
534            ctx.viewport,
535            point,
536        ) else {
537            return false;
538        };
539        if let Some(menu_owner) = self.find_context_menu_owner(ctx, hit) {
540            ctx.context_menu.open(menu_owner, point);
541            return true;
542        }
543        let rich_text_path = self.path_for_node(ctx, hit);
544        if let Some((annotation_node_id, annotation)) =
545            crate::input::hover::resolve_rich_text_annotation_at_point(ctx, &rich_text_path, point)
546        {
547            if self.dispatch_annotation_trigger(
548                ctx,
549                annotation_node_id,
550                &annotation,
551                ActionTrigger::SecondaryClick,
552                point,
553            ) {
554                return true;
555            }
556        }
557        self.dispatch_trigger(ctx, hit, ActionTrigger::SecondaryClick, point, None)
558    }
559
560    fn reset_pointer_sequence(&self, ctx: &mut ControllerContext, point: LayoutPoint) {
561        ctx.gesture.start_point = None;
562        ctx.gesture.is_panning = false;
563        ctx.gesture.dragging_payload = None;
564        self.clear_drag_target(ctx, point);
565        ctx.gesture.drag_session = None;
566        ctx.gesture.pressed_button = None;
567    }
568
569    fn path_for_node(&self, ctx: &ControllerContext, node_id: WidgetId) -> Vec<WidgetId> {
570        let mut path = Vec::new();
571        let mut curr = Some(node_id);
572        while let Some(id) = curr {
573            path.push(id);
574            curr = ctx.ir.nodes.get(&id).and_then(|node| node.parent);
575        }
576        path
577    }
578
579    fn is_descendant(&self, ctx: &ControllerContext, child: WidgetId, ancestor: WidgetId) -> bool {
580        let mut curr = Some(child);
581        while let Some(id) = curr {
582            if id == ancestor {
583                return true;
584            }
585            if let Some(node) = ctx.ir.nodes.get(&id) {
586                curr = node.parent;
587            } else {
588                break;
589            }
590        }
591        false
592    }
593
594    fn find_context_menu_owner(
595        &self,
596        ctx: &ControllerContext,
597        start_node: WidgetId,
598    ) -> Option<WidgetId> {
599        let mut current_id = Some(start_node);
600        while let Some(node_id) = current_id {
601            let Some(node) = ctx.ir.nodes.get(&node_id) else {
602                break;
603            };
604            if let Op::Semantics(semantics) = &node.op {
605                if semantics.context_menu && !semantics.disabled {
606                    return Some(node_id);
607                }
608            }
609            current_id = node.parent;
610        }
611        None
612    }
613
614    fn dispatch_annotation_trigger(
615        &self,
616        ctx: &mut ControllerContext,
617        node_id: WidgetId,
618        annotation: &RichTextAnnotation,
619        trigger: ActionTrigger,
620        point: LayoutPoint,
621    ) -> bool {
622        let Some(action_entry) = annotation
623            .actions
624            .iter()
625            .find(|entry| entry.trigger == trigger)
626        else {
627            return false;
628        };
629        let Some(payload) = &action_entry.payload_data else {
630            return false;
631        };
632
633        let input = crate::input::scoped_action_input(
634            ctx.ir,
635            node_id,
636            ActionInput::Pointer {
637                x: point.x,
638                y: point.y,
639                delta_x: 0.0,
640                delta_y: 0.0,
641            },
642        );
643        ctx.dispatched_actions.push((
644            node_id,
645            ActionEnvelope {
646                id: ActionId::from_u128(action_entry.action_id),
647                payload: payload.clone(),
648            },
649            input,
650        ));
651        true
652    }
653
654    fn find_drag_payload(&self, ctx: &ControllerContext, start_node: WidgetId) -> Option<Vec<u8>> {
655        let mut current_id = Some(start_node);
656        while let Some(node_id) = current_id {
657            if let Some(node) = ctx.ir.nodes.get(&node_id) {
658                if let Op::Semantics(sem) = &node.op {
659                    if let Some(p) = &sem.drag_payload {
660                        return Some(p.clone());
661                    }
662                }
663                current_id = node.parent;
664            } else {
665                break;
666            }
667        }
668        None
669    }
670
671    fn semantic_identifier(&self, ctx: &ControllerContext, start_node: WidgetId) -> Option<String> {
672        let mut current_id = Some(start_node);
673        while let Some(node_id) = current_id {
674            if let Some(node) = ctx.ir.nodes.get(&node_id) {
675                if let Op::Semantics(sem) = &node.op {
676                    if let Some(identifier) = &sem.identifier {
677                        return Some(identifier.clone());
678                    }
679                }
680                current_id = node.parent;
681            } else {
682                break;
683            }
684        }
685        None
686    }
687
688    fn find_drop_target(&self, ctx: &ControllerContext, start_node: WidgetId) -> Option<WidgetId> {
689        let mut current_id = Some(start_node);
690        while let Some(node_id) = current_id {
691            if let Some(node) = ctx.ir.nodes.get(&node_id) {
692                if let Op::Semantics(sem) = &node.op {
693                    if sem
694                        .actions
695                        .entries
696                        .iter()
697                        .any(|entry| entry.trigger == ActionTrigger::Drop)
698                    {
699                        return Some(node_id);
700                    }
701                }
702                current_id = node.parent;
703            } else {
704                break;
705            }
706        }
707        None
708    }
709
710    fn update_drag_target(&self, ctx: &mut ControllerContext, point: LayoutPoint) {
711        let next_target = crate::hit_test::hit_test_with_viewports(
712            ctx.ir,
713            ctx.layout,
714            ctx.scroll,
715            ctx.viewport,
716            point,
717        )
718        .and_then(|hit| self.find_drop_target(ctx, hit));
719
720        let previous_target = ctx
721            .gesture
722            .drag_session
723            .as_ref()
724            .and_then(|s| s.target_node);
725        if previous_target == next_target {
726            return;
727        }
728
729        if let Some(previous) = previous_target {
730            self.dispatch_trigger(ctx, previous, ActionTrigger::DragLeave, point, None);
731        }
732        if let Some(next) = next_target {
733            self.dispatch_trigger(ctx, next, ActionTrigger::DragEnter, point, None);
734        }
735
736        let next_identifier = next_target.and_then(|id| self.semantic_identifier(ctx, id));
737        if let Some(session) = ctx.gesture.drag_session.as_mut() {
738            session.target_node = next_target;
739            session.target_identifier = next_identifier;
740        }
741    }
742
743    fn clear_drag_target(&self, ctx: &mut ControllerContext, point: LayoutPoint) {
744        if let Some(previous) = ctx
745            .gesture
746            .drag_session
747            .as_ref()
748            .and_then(|s| s.target_node)
749        {
750            self.dispatch_trigger(ctx, previous, ActionTrigger::DragLeave, point, None);
751        }
752        if let Some(session) = ctx.gesture.drag_session.as_mut() {
753            session.target_node = None;
754            session.target_identifier = None;
755        }
756    }
757
758    fn dispatch_internal_drop(
759        &self,
760        ctx: &mut ControllerContext,
761        target_node: WidgetId,
762        payload: Vec<u8>,
763        point: LayoutPoint,
764        modifiers: u8,
765    ) -> bool {
766        let mut current_id = Some(target_node);
767        while let Some(node_id) = current_id {
768            if let Some(node) = ctx.ir.nodes.get(&node_id) {
769                if let Op::Semantics(sem) = &node.op {
770                    for entry in &sem.actions.entries {
771                        if entry.trigger == ActionTrigger::Drop {
772                            let envelope = ActionEnvelope {
773                                id: ActionId::from_u128(entry.action_id),
774                                payload: entry.payload_data.clone().unwrap_or_default(),
775                            };
776
777                            let input = crate::input::scoped_action_input(
778                                ctx.ir,
779                                node_id,
780                                ActionInput::InternalDrop {
781                                    payload: payload.clone(),
782                                    x: point.x,
783                                    y: point.y,
784                                    modifiers,
785                                },
786                            );
787
788                            ctx.dispatched_actions.push((node_id, envelope, input));
789                            return true;
790                        }
791                    }
792                }
793                current_id = node.parent;
794            } else {
795                break;
796            }
797        }
798        false
799    }
800
801    fn dispatch_external_drop(
802        &self,
803        ctx: &mut ControllerContext,
804        target_node: WidgetId,
805        paths: Vec<String>,
806        point: LayoutPoint,
807        modifiers: u8,
808    ) -> bool {
809        let mut current_id = Some(target_node);
810        while let Some(node_id) = current_id {
811            if let Some(node) = ctx.ir.nodes.get(&node_id) {
812                if let Op::Semantics(sem) = &node.op {
813                    for entry in &sem.actions.entries {
814                        if entry.trigger == ActionTrigger::Drop {
815                            let envelope = ActionEnvelope {
816                                id: ActionId::from_u128(entry.action_id),
817                                payload: entry.payload_data.clone().unwrap_or_default(),
818                            };
819
820                            let input = crate::input::scoped_action_input(
821                                ctx.ir,
822                                node_id,
823                                ActionInput::Drop {
824                                    paths: paths.clone(),
825                                    x: point.x,
826                                    y: point.y,
827                                    modifiers,
828                                },
829                            );
830
831                            ctx.dispatched_actions.push((node_id, envelope, input));
832                            return true;
833                        }
834                    }
835                }
836                current_id = node.parent;
837            } else {
838                break;
839            }
840        }
841        false
842    }
843
844    fn dispatch_trigger(
845        &self,
846        ctx: &mut ControllerContext,
847        start_node: WidgetId,
848        trigger: ActionTrigger,
849        point: LayoutPoint,
850        delta: Option<LayoutPoint>,
851    ) -> bool {
852        self.dispatch_trigger_with_phase(ctx, start_node, trigger, point, delta, None)
853    }
854
855    fn dispatch_trigger_with_phase(
856        &self,
857        ctx: &mut ControllerContext,
858        start_node: WidgetId,
859        trigger: ActionTrigger,
860        point: LayoutPoint,
861        delta: Option<LayoutPoint>,
862        phase: Option<crate::input::canvas::CanvasInteractionPhase>,
863    ) -> bool {
864        let mut current_id = Some(start_node);
865        while let Some(node_id) = current_id {
866            if let Some(node) = ctx.ir.nodes.get(&node_id) {
867                if let Op::Semantics(sem) = &node.op {
868                    let mut handled = false;
869                    for entry in &sem.actions.entries {
870                        if entry.trigger == trigger {
871                            let envelope = ActionEnvelope {
872                                id: ActionId::from_u128(entry.action_id),
873                                payload: entry.payload_data.clone().unwrap_or_default(),
874                            };
875
876                            let delta = delta.unwrap_or(LayoutPoint::ZERO);
877                            let input = if let Some(target) = &sem.canvas_target {
878                                ActionInput::CanvasInteraction(
879                                    crate::input::canvas::canvas_interaction(
880                                        node_id,
881                                        target,
882                                        phase.unwrap_or_else(|| canvas_phase(trigger)),
883                                        point,
884                                        delta,
885                                        ctx.gesture.start_point,
886                                        ctx.layout,
887                                        ctx.viewport,
888                                        ctx.gesture.pointer_kind,
889                                        ctx.gesture.modifiers,
890                                    ),
891                                )
892                            } else {
893                                ActionInput::Pointer {
894                                    x: point.x,
895                                    y: point.y,
896                                    delta_x: delta.x,
897                                    delta_y: delta.y,
898                                }
899                            };
900                            let input = crate::input::scoped_action_input(ctx.ir, node_id, input);
901
902                            ctx.dispatched_actions.push((node_id, envelope, input));
903                            handled = true;
904                            break;
905                        }
906                    }
907                    if trigger == ActionTrigger::Default {
908                        if let Some(hyperlink) = &sem.hyperlink {
909                            let navigation_already_bound =
910                                sem.actions.entries.iter().any(|entry| {
911                                    entry.trigger == ActionTrigger::Default
912                                        && ActionId::from_u128(entry.action_id)
913                                            == crate::NavigationRequested::static_id()
914                                });
915                            if !navigation_already_bound {
916                                ctx.dispatched_actions.push((
917                                    node_id,
918                                    crate::NavigationRequested::new(
919                                        crate::NavigationCommand::Open(hyperlink.clone()),
920                                    )
921                                    .into(),
922                                    crate::input::scoped_action_input(
923                                        ctx.ir,
924                                        node_id,
925                                        ActionInput::Pointer {
926                                            x: point.x,
927                                            y: point.y,
928                                            delta_x: 0.0,
929                                            delta_y: 0.0,
930                                        },
931                                    ),
932                                ));
933                            }
934                            handled = true;
935                        }
936                    }
937                    if handled {
938                        return true;
939                    }
940                }
941                current_id = node.parent;
942            } else {
943                break;
944            }
945        }
946        false
947    }
948
949    fn handle_pan_update(&self, ctx: &mut ControllerContext, delta: LayoutPoint) -> bool {
950        if let Some(target) = ctx.gesture.target_node {
951            let mut current = Some(target);
952            while let Some(id) = current {
953                if let Some(node) = ctx.ir.nodes.get(&id) {
954                    if let fission_ir::Op::Semantics(sem) = &node.op {
955                        if sem.draggable {
956                            return false;
957                        }
958                    }
959                    if let fission_ir::Op::Layout(fission_ir::op::LayoutOp::Scroll {
960                        direction,
961                        ..
962                    }) = &node.op
963                    {
964                        let current_offset = ctx.scroll.get_offset(id);
965                        let move_val = match direction {
966                            fission_ir::op::FlexDirection::Row => -delta.x,
967                            fission_ir::op::FlexDirection::Column => -delta.y,
968                        };
969
970                        let mut new_offset = current_offset + move_val;
971
972                        if let Some(geom) = ctx.layout.get_node_geometry(id) {
973                            let max_offset =
974                                if matches!(direction, fission_ir::op::FlexDirection::Row) {
975                                    (geom.content_size.width - geom.rect.width()).max(0.0)
976                                } else {
977                                    (geom.content_size.height - geom.rect.height()).max(0.0)
978                                };
979                            new_offset = new_offset.clamp(0.0, max_offset);
980                        }
981
982                        ctx.scroll.set_offset(id, new_offset);
983                        return true;
984                    }
985                    current = node.parent;
986                } else {
987                    break;
988                }
989            }
990        }
991        false
992    }
993}
994
995fn canvas_phase(trigger: ActionTrigger) -> crate::input::canvas::CanvasInteractionPhase {
996    use crate::input::canvas::CanvasInteractionPhase;
997    match trigger {
998        ActionTrigger::DragStart => CanvasInteractionPhase::Start,
999        ActionTrigger::DragUpdate => CanvasInteractionPhase::Update,
1000        ActionTrigger::DragEnd => CanvasInteractionPhase::End,
1001        _ => CanvasInteractionPhase::Activate,
1002    }
1003}