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::{ActionEnvelope, ActionId, ActionInput, DragSessionPayload, DragSessionState};
8use fission_ir::op::RichTextAnnotation;
9use fission_ir::{semantics::ActionTrigger, Op, WidgetId};
10use fission_layout::LayoutPoint;
11
12pub struct GestureController;
13
14impl InputController for GestureController {
15    fn handle_event(&mut self, ctx: &mut ControllerContext, event: &InputEvent) -> bool {
16        match event {
17            InputEvent::Pointer(pe) => {
18                match pe {
19                    PointerEvent::Down { point, button, .. } => {
20                        ctx.gesture.start_point = Some(*point);
21                        ctx.gesture.last_point = Some(*point);
22                        ctx.gesture.is_panning = false;
23                        ctx.gesture.pressed_button = Some(button.clone());
24                        ctx.gesture.scrollbar_drag = None;
25
26                        if matches!(button, crate::event::PointerButton::Primary) {
27                            if let Some(hit) =
28                                scrollbar_hit_test(ctx.ir, ctx.layout, ctx.scroll, *point)
29                            {
30                                let pointer_to_thumb_start = match hit.kind {
31                                    ScrollbarHitKind::Thumb => hit.pointer_to_thumb_start,
32                                    ScrollbarHitKind::Rail => hit.geometry.thumb_extent() * 0.5,
33                                };
34                                let new_offset = match hit.kind {
35                                    ScrollbarHitKind::Thumb => hit.geometry.offset,
36                                    ScrollbarHitKind::Rail => {
37                                        scrollbar_drag_offset(hit.geometry, hit.layout_point)
38                                    }
39                                };
40                                ctx.scroll.set_offset(hit.geometry.node_id, new_offset);
41                                ctx.gesture.target_node = Some(hit.geometry.node_id);
42                                ctx.gesture.dragging_payload = None;
43                                ctx.gesture.scrollbar_drag = Some(ScrollbarDragState {
44                                    node_id: hit.geometry.node_id,
45                                    pointer_to_thumb_start,
46                                });
47                                return true;
48                            }
49                        }
50
51                        if let Some(hit) = crate::hit_test::hit_test_with_scroll(
52                            ctx.ir, ctx.layout, ctx.scroll, *point,
53                        ) {
54                            ctx.gesture.target_node = Some(hit);
55                            ctx.gesture.dragging_payload = self.find_drag_payload(ctx, hit);
56                        } else {
57                            ctx.gesture.target_node = None;
58                            ctx.gesture.dragging_payload = None;
59                        }
60                    }
61                    PointerEvent::Move { point, .. } => {
62                        if let Some(drag) = ctx.gesture.scrollbar_drag {
63                            if let Some(geometry) = scrollbar_geometry_for_node(
64                                ctx.ir,
65                                ctx.layout,
66                                ctx.scroll,
67                                drag.node_id,
68                            ) {
69                                let new_offset = scrollbar_drag_offset_with_grab(
70                                    geometry,
71                                    scrollbar_point_for_node(
72                                        ctx.ir,
73                                        ctx.scroll,
74                                        drag.node_id,
75                                        *point,
76                                    ),
77                                    drag.pointer_to_thumb_start,
78                                );
79                                ctx.scroll.set_offset(drag.node_id, new_offset);
80                            }
81                            ctx.gesture.last_point = Some(*point);
82                            return true;
83                        }
84
85                        if let Some(start) = ctx.gesture.start_point {
86                            let dx = point.x - start.x;
87                            let dy = point.y - start.y;
88                            let dist_sq = dx * dx + dy * dy;
89                            let threshold = 5.0 * 5.0;
90
91                            if !ctx.gesture.is_panning && dist_sq > threshold {
92                                ctx.gesture.is_panning = true;
93                                if let Some(payload) = ctx.gesture.dragging_payload.clone() {
94                                    let target = ctx.gesture.target_node;
95                                    let source_identifier =
96                                        target.and_then(|id| self.semantic_identifier(ctx, id));
97                                    ctx.gesture.drag_session = Some(DragSessionState {
98                                        source_node: target,
99                                        source_identifier,
100                                        payload: DragSessionPayload::Internal(payload),
101                                        point: *point,
102                                        target_node: None,
103                                        target_identifier: None,
104                                    });
105                                    self.update_drag_target(ctx, *point);
106                                }
107                                // Dispatch DragStart now
108                                if let Some(target) = ctx.gesture.target_node {
109                                    self.dispatch_trigger(
110                                        ctx,
111                                        target,
112                                        ActionTrigger::DragStart,
113                                        *point,
114                                        None,
115                                    );
116                                }
117                            }
118
119                            if ctx.gesture.is_panning {
120                                if let Some(session) = ctx.gesture.drag_session.as_mut() {
121                                    session.point = *point;
122                                }
123                                self.update_drag_target(ctx, *point);
124
125                                let last = ctx.gesture.last_point.unwrap_or(start);
126                                let delta = LayoutPoint {
127                                    x: point.x - last.x,
128                                    y: point.y - last.y,
129                                };
130                                ctx.gesture.last_point = Some(*point);
131
132                                // Try dispatching DragUpdate
133                                let dispatched = if let Some(target) = ctx.gesture.target_node {
134                                    self.dispatch_trigger(
135                                        ctx,
136                                        target,
137                                        ActionTrigger::DragUpdate,
138                                        *point,
139                                        Some(delta),
140                                    )
141                                } else {
142                                    false
143                                };
144
145                                if dispatched {
146                                    return true;
147                                }
148
149                                // Fallback to Scroll Panning if DragUpdate not handled
150                                if self.handle_pan_update(ctx, delta) {
151                                    return true;
152                                }
153                            }
154                        }
155                    }
156                    PointerEvent::Up {
157                        point, modifiers, ..
158                    } => {
159                        let scrollbar_drag = ctx.gesture.scrollbar_drag.take();
160                        let mut handled = false;
161                        let was_secondary = matches!(
162                            ctx.gesture.pressed_button,
163                            Some(crate::event::PointerButton::Secondary)
164                        );
165                        if ctx.gesture.is_panning {
166                            // Internal Drop
167                            if let Some(payload) = ctx.gesture.dragging_payload.take() {
168                                if let Some(up_hit) = crate::hit_test::hit_test_with_scroll(
169                                    ctx.ir, ctx.layout, ctx.scroll, *point,
170                                ) {
171                                    let _ = self.dispatch_internal_drop(
172                                        ctx, up_hit, payload, *point, *modifiers,
173                                    );
174                                }
175                            }
176
177                            if let Some(target) = ctx.gesture.target_node {
178                                self.dispatch_trigger(
179                                    ctx,
180                                    target,
181                                    ActionTrigger::DragEnd,
182                                    *point,
183                                    None,
184                                );
185                            }
186                            handled = true;
187                        } else if was_secondary {
188                            // Secondary click (right-click)
189                            if let Some(target) = ctx.gesture.target_node {
190                                if let Some(up_hit) = crate::hit_test::hit_test_with_scroll(
191                                    ctx.ir, ctx.layout, ctx.scroll, *point,
192                                ) {
193                                    if up_hit == target
194                                        || self.is_descendant(ctx, up_hit, target)
195                                        || self.is_descendant(ctx, target, up_hit)
196                                    {
197                                        if let Some(menu_owner) =
198                                            self.find_context_menu_owner(ctx, up_hit)
199                                        {
200                                            ctx.context_menu.open(menu_owner, *point);
201                                            handled = true;
202                                        }
203
204                                        let rich_text_path = self.path_for_node(ctx, up_hit);
205                                        if !handled {
206                                            if let Some((annotation_node_id, annotation)) =
207                                                crate::input::hover::resolve_rich_text_annotation_at_point(
208                                                    ctx,
209                                                    &rich_text_path,
210                                                    *point,
211                                                )
212                                            {
213                                                handled = self.dispatch_annotation_trigger(
214                                                    ctx,
215                                                    annotation_node_id,
216                                                    &annotation,
217                                                    ActionTrigger::SecondaryClick,
218                                                    *point,
219                                                );
220                                            }
221                                        }
222
223                                        if !handled
224                                            && self.dispatch_trigger(
225                                                ctx,
226                                                target,
227                                                ActionTrigger::SecondaryClick,
228                                                *point,
229                                                None,
230                                            )
231                                        {
232                                            handled = true;
233                                        }
234                                    }
235                                }
236                            }
237                        } else {
238                            // Tap (primary click)
239                            if let Some(target) = ctx.gesture.target_node {
240                                if let Some(up_hit) = crate::hit_test::hit_test_with_scroll(
241                                    ctx.ir, ctx.layout, ctx.scroll, *point,
242                                ) {
243                                    if up_hit == target
244                                        || self.is_descendant(ctx, up_hit, target)
245                                        || self.is_descendant(ctx, target, up_hit)
246                                    {
247                                        let rich_text_path = self.path_for_node(ctx, up_hit);
248                                        if let Some((annotation_node_id, annotation)) =
249                                            crate::input::hover::resolve_rich_text_annotation_at_point(
250                                                ctx,
251                                                &rich_text_path,
252                                                *point,
253                                            )
254                                        {
255                                            handled = self.dispatch_annotation_trigger(
256                                                ctx,
257                                                annotation_node_id,
258                                                &annotation,
259                                                ActionTrigger::Default,
260                                                *point,
261                                            );
262                                        }
263
264                                        if !handled
265                                            && self.dispatch_trigger(
266                                                ctx,
267                                                target,
268                                                ActionTrigger::Default,
269                                                *point,
270                                                None,
271                                            )
272                                        {
273                                            handled = true;
274                                        }
275                                    }
276                                }
277                            }
278                        }
279
280                        if !was_secondary {
281                            ctx.context_menu.close();
282                        }
283                        ctx.gesture.start_point = None;
284                        ctx.gesture.is_panning = false;
285                        ctx.gesture.dragging_payload = None;
286                        self.clear_drag_target(ctx, *point);
287                        ctx.gesture.drag_session = None;
288                        ctx.gesture.pressed_button = None;
289                        if scrollbar_drag.is_some() {
290                            ctx.gesture.target_node = None;
291                            return true;
292                        }
293                        return handled;
294                    }
295                    _ => {}
296                }
297            }
298            InputEvent::ExternalDrag(event) => match event {
299                ExternalDragEvent::Hover { point, paths, .. } => {
300                    ctx.gesture.drag_session = Some(DragSessionState {
301                        source_node: None,
302                        source_identifier: None,
303                        payload: DragSessionPayload::ExternalFiles(paths.clone()),
304                        point: *point,
305                        target_node: ctx
306                            .gesture
307                            .drag_session
308                            .as_ref()
309                            .and_then(|s| s.target_node),
310                        target_identifier: ctx
311                            .gesture
312                            .drag_session
313                            .as_ref()
314                            .and_then(|s| s.target_identifier.clone()),
315                    });
316                    self.update_drag_target(ctx, *point);
317                    return true;
318                }
319                ExternalDragEvent::Cancel => {
320                    let point = ctx
321                        .gesture
322                        .drag_session
323                        .as_ref()
324                        .map(|session| session.point)
325                        .unwrap_or(LayoutPoint::ZERO);
326                    self.clear_drag_target(ctx, point);
327                    ctx.gesture.drag_session = None;
328                    return true;
329                }
330                ExternalDragEvent::Drop {
331                    point,
332                    paths,
333                    modifiers,
334                } => {
335                    ctx.gesture.drag_session = Some(DragSessionState {
336                        source_node: None,
337                        source_identifier: None,
338                        payload: DragSessionPayload::ExternalFiles(paths.clone()),
339                        point: *point,
340                        target_node: ctx
341                            .gesture
342                            .drag_session
343                            .as_ref()
344                            .and_then(|s| s.target_node),
345                        target_identifier: ctx
346                            .gesture
347                            .drag_session
348                            .as_ref()
349                            .and_then(|s| s.target_identifier.clone()),
350                    });
351                    self.update_drag_target(ctx, *point);
352                    if let Some(target) = ctx
353                        .gesture
354                        .drag_session
355                        .as_ref()
356                        .and_then(|s| s.target_node)
357                    {
358                        let _ = self.dispatch_external_drop(
359                            ctx,
360                            target,
361                            paths.clone(),
362                            *point,
363                            *modifiers,
364                        );
365                    }
366                    self.clear_drag_target(ctx, *point);
367                    ctx.gesture.drag_session = None;
368                    return true;
369                }
370            },
371            _ => {}
372        }
373        false
374    }
375}
376
377impl GestureController {
378    fn path_for_node(&self, ctx: &ControllerContext, node_id: WidgetId) -> Vec<WidgetId> {
379        let mut path = Vec::new();
380        let mut curr = Some(node_id);
381        while let Some(id) = curr {
382            path.push(id);
383            curr = ctx.ir.nodes.get(&id).and_then(|node| node.parent);
384        }
385        path
386    }
387
388    fn is_descendant(&self, ctx: &ControllerContext, child: WidgetId, ancestor: WidgetId) -> bool {
389        let mut curr = Some(child);
390        while let Some(id) = curr {
391            if id == ancestor {
392                return true;
393            }
394            if let Some(node) = ctx.ir.nodes.get(&id) {
395                curr = node.parent;
396            } else {
397                break;
398            }
399        }
400        false
401    }
402
403    fn find_context_menu_owner(
404        &self,
405        ctx: &ControllerContext,
406        start_node: WidgetId,
407    ) -> Option<WidgetId> {
408        let mut current_id = Some(start_node);
409        while let Some(node_id) = current_id {
410            let Some(node) = ctx.ir.nodes.get(&node_id) else {
411                break;
412            };
413            if let Op::Semantics(semantics) = &node.op {
414                if semantics.context_menu && !semantics.disabled {
415                    return Some(node_id);
416                }
417            }
418            current_id = node.parent;
419        }
420        None
421    }
422
423    fn dispatch_annotation_trigger(
424        &self,
425        ctx: &mut ControllerContext,
426        node_id: WidgetId,
427        annotation: &RichTextAnnotation,
428        trigger: ActionTrigger,
429        point: LayoutPoint,
430    ) -> bool {
431        let Some(action_entry) = annotation
432            .actions
433            .iter()
434            .find(|entry| entry.trigger == trigger)
435        else {
436            return false;
437        };
438        let Some(payload) = &action_entry.payload_data else {
439            return false;
440        };
441
442        let input = crate::input::scoped_action_input(
443            ctx.ir,
444            node_id,
445            ActionInput::Pointer {
446                x: point.x,
447                y: point.y,
448                delta_x: 0.0,
449                delta_y: 0.0,
450            },
451        );
452        ctx.dispatched_actions.push((
453            node_id,
454            ActionEnvelope {
455                id: ActionId::from_u128(action_entry.action_id),
456                payload: payload.clone(),
457            },
458            input,
459        ));
460        true
461    }
462
463    fn find_drag_payload(&self, ctx: &ControllerContext, start_node: WidgetId) -> Option<Vec<u8>> {
464        let mut current_id = Some(start_node);
465        while let Some(node_id) = current_id {
466            if let Some(node) = ctx.ir.nodes.get(&node_id) {
467                if let Op::Semantics(sem) = &node.op {
468                    if let Some(p) = &sem.drag_payload {
469                        return Some(p.clone());
470                    }
471                }
472                current_id = node.parent;
473            } else {
474                break;
475            }
476        }
477        None
478    }
479
480    fn semantic_identifier(&self, ctx: &ControllerContext, start_node: WidgetId) -> Option<String> {
481        let mut current_id = Some(start_node);
482        while let Some(node_id) = current_id {
483            if let Some(node) = ctx.ir.nodes.get(&node_id) {
484                if let Op::Semantics(sem) = &node.op {
485                    if let Some(identifier) = &sem.identifier {
486                        return Some(identifier.clone());
487                    }
488                }
489                current_id = node.parent;
490            } else {
491                break;
492            }
493        }
494        None
495    }
496
497    fn find_drop_target(&self, ctx: &ControllerContext, start_node: WidgetId) -> Option<WidgetId> {
498        let mut current_id = Some(start_node);
499        while let Some(node_id) = current_id {
500            if let Some(node) = ctx.ir.nodes.get(&node_id) {
501                if let Op::Semantics(sem) = &node.op {
502                    if sem
503                        .actions
504                        .entries
505                        .iter()
506                        .any(|entry| entry.trigger == ActionTrigger::Drop)
507                    {
508                        return Some(node_id);
509                    }
510                }
511                current_id = node.parent;
512            } else {
513                break;
514            }
515        }
516        None
517    }
518
519    fn update_drag_target(&self, ctx: &mut ControllerContext, point: LayoutPoint) {
520        let next_target =
521            crate::hit_test::hit_test_with_scroll(ctx.ir, ctx.layout, ctx.scroll, point)
522                .and_then(|hit| self.find_drop_target(ctx, hit));
523
524        let previous_target = ctx
525            .gesture
526            .drag_session
527            .as_ref()
528            .and_then(|s| s.target_node);
529        if previous_target == next_target {
530            return;
531        }
532
533        if let Some(previous) = previous_target {
534            self.dispatch_trigger(ctx, previous, ActionTrigger::DragLeave, point, None);
535        }
536        if let Some(next) = next_target {
537            self.dispatch_trigger(ctx, next, ActionTrigger::DragEnter, point, None);
538        }
539
540        let next_identifier = next_target.and_then(|id| self.semantic_identifier(ctx, id));
541        if let Some(session) = ctx.gesture.drag_session.as_mut() {
542            session.target_node = next_target;
543            session.target_identifier = next_identifier;
544        }
545    }
546
547    fn clear_drag_target(&self, ctx: &mut ControllerContext, point: LayoutPoint) {
548        if let Some(previous) = ctx
549            .gesture
550            .drag_session
551            .as_ref()
552            .and_then(|s| s.target_node)
553        {
554            self.dispatch_trigger(ctx, previous, ActionTrigger::DragLeave, point, None);
555        }
556        if let Some(session) = ctx.gesture.drag_session.as_mut() {
557            session.target_node = None;
558            session.target_identifier = None;
559        }
560    }
561
562    fn dispatch_internal_drop(
563        &self,
564        ctx: &mut ControllerContext,
565        target_node: WidgetId,
566        payload: Vec<u8>,
567        point: LayoutPoint,
568        modifiers: u8,
569    ) -> bool {
570        let mut current_id = Some(target_node);
571        while let Some(node_id) = current_id {
572            if let Some(node) = ctx.ir.nodes.get(&node_id) {
573                if let Op::Semantics(sem) = &node.op {
574                    for entry in &sem.actions.entries {
575                        if entry.trigger == ActionTrigger::Drop {
576                            let envelope = ActionEnvelope {
577                                id: ActionId::from_u128(entry.action_id),
578                                payload: entry.payload_data.clone().unwrap_or_default(),
579                            };
580
581                            let input = crate::input::scoped_action_input(
582                                ctx.ir,
583                                node_id,
584                                ActionInput::InternalDrop {
585                                    payload: payload.clone(),
586                                    x: point.x,
587                                    y: point.y,
588                                    modifiers,
589                                },
590                            );
591
592                            ctx.dispatched_actions.push((node_id, envelope, input));
593                            return true;
594                        }
595                    }
596                }
597                current_id = node.parent;
598            } else {
599                break;
600            }
601        }
602        false
603    }
604
605    fn dispatch_external_drop(
606        &self,
607        ctx: &mut ControllerContext,
608        target_node: WidgetId,
609        paths: Vec<String>,
610        point: LayoutPoint,
611        modifiers: u8,
612    ) -> bool {
613        let mut current_id = Some(target_node);
614        while let Some(node_id) = current_id {
615            if let Some(node) = ctx.ir.nodes.get(&node_id) {
616                if let Op::Semantics(sem) = &node.op {
617                    for entry in &sem.actions.entries {
618                        if entry.trigger == ActionTrigger::Drop {
619                            let envelope = ActionEnvelope {
620                                id: ActionId::from_u128(entry.action_id),
621                                payload: entry.payload_data.clone().unwrap_or_default(),
622                            };
623
624                            let input = crate::input::scoped_action_input(
625                                ctx.ir,
626                                node_id,
627                                ActionInput::Drop {
628                                    paths: paths.clone(),
629                                    x: point.x,
630                                    y: point.y,
631                                    modifiers,
632                                },
633                            );
634
635                            ctx.dispatched_actions.push((node_id, envelope, input));
636                            return true;
637                        }
638                    }
639                }
640                current_id = node.parent;
641            } else {
642                break;
643            }
644        }
645        false
646    }
647
648    fn dispatch_trigger(
649        &self,
650        ctx: &mut ControllerContext,
651        start_node: WidgetId,
652        trigger: ActionTrigger,
653        point: LayoutPoint,
654        delta: Option<LayoutPoint>,
655    ) -> bool {
656        let mut current_id = Some(start_node);
657        while let Some(node_id) = current_id {
658            if let Some(node) = ctx.ir.nodes.get(&node_id) {
659                if let Op::Semantics(sem) = &node.op {
660                    for entry in &sem.actions.entries {
661                        if entry.trigger == trigger {
662                            let envelope = ActionEnvelope {
663                                id: ActionId::from_u128(entry.action_id),
664                                payload: entry.payload_data.clone().unwrap_or_default(),
665                            };
666
667                            let input = crate::input::scoped_action_input(
668                                ctx.ir,
669                                node_id,
670                                ActionInput::Pointer {
671                                    x: point.x,
672                                    y: point.y,
673                                    delta_x: delta.map(|d| d.x).unwrap_or(0.0),
674                                    delta_y: delta.map(|d| d.y).unwrap_or(0.0),
675                                },
676                            );
677
678                            ctx.dispatched_actions.push((node_id, envelope, input));
679                            return true;
680                        }
681                    }
682                }
683                current_id = node.parent;
684            } else {
685                break;
686            }
687        }
688        false
689    }
690
691    fn handle_pan_update(&self, ctx: &mut ControllerContext, delta: LayoutPoint) -> bool {
692        if let Some(target) = ctx.gesture.target_node {
693            let mut current = Some(target);
694            while let Some(id) = current {
695                if let Some(node) = ctx.ir.nodes.get(&id) {
696                    if let fission_ir::Op::Semantics(sem) = &node.op {
697                        if sem.draggable {
698                            return false;
699                        }
700                    }
701                    if let fission_ir::Op::Layout(fission_ir::op::LayoutOp::Scroll {
702                        direction,
703                        ..
704                    }) = &node.op
705                    {
706                        let current_offset = ctx.scroll.get_offset(id);
707                        let move_val = match direction {
708                            fission_ir::op::FlexDirection::Row => -delta.x,
709                            fission_ir::op::FlexDirection::Column => -delta.y,
710                        };
711
712                        let mut new_offset = current_offset + move_val;
713
714                        if let Some(geom) = ctx.layout.get_node_geometry(id) {
715                            let max_offset =
716                                if matches!(direction, fission_ir::op::FlexDirection::Row) {
717                                    (geom.content_size.width - geom.rect.width()).max(0.0)
718                                } else {
719                                    (geom.content_size.height - geom.rect.height()).max(0.0)
720                                };
721                            new_offset = new_offset.clamp(0.0, max_offset);
722                        }
723
724                        ctx.scroll.set_offset(id, new_offset);
725                        return true;
726                    }
727                    current = node.parent;
728                } else {
729                    break;
730                }
731            }
732        }
733        false
734    }
735}