Skip to main content

fission_core/input/
viewport.rs

1use std::collections::HashMap;
2
3use fission_ir::{
4    ActionEntry, CoreIR, LayoutOp, Op, ViewportBoundary, ViewportPanAxis, ViewportTransform,
5    ViewportZoomPolicy, WidgetId,
6};
7use fission_layout::{LayoutPoint, LayoutRect, LayoutSnapshot};
8use serde::{Deserialize, Serialize};
9
10use super::scoped_action_input;
11use crate::event::{
12    InputEvent, PointerButton, PointerEvent, PointerId, PointerKind, PointerPhase, ScrollDeltaMode,
13};
14use crate::{ActionEnvelope, ActionId, ActionInput, CurrentTime};
15
16const PAN_THRESHOLD_SQUARED: f32 = 25.0;
17const LINE_DELTA_POINTS: f32 = 16.0;
18const WHEEL_ZOOM_SENSITIVITY: f32 = 0.002;
19
20/// Lifecycle stage for a viewport interaction delivered to a reducer.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22pub enum ViewportInteractionPhase {
23    Start,
24    Update,
25    End,
26    Cancel,
27}
28
29/// Physical gesture family that changed an interactive viewport.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31pub enum ViewportInputKind {
32    Mouse,
33    Touch,
34    Stylus,
35    Wheel,
36    Magnify,
37    Unknown,
38}
39
40/// Live event facts accompanying an interactive-viewport action.
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42pub struct ViewportInteraction {
43    pub node_id: WidgetId,
44    pub phase: ViewportInteractionPhase,
45    pub transform: ViewportTransform,
46    pub viewport_focal_point: LayoutPoint,
47    pub world_focal_point: LayoutPoint,
48    pub pan_delta: LayoutPoint,
49    pub scale_factor: f32,
50    pub input_kind: ViewportInputKind,
51    pub modifiers: u8,
52}
53
54#[derive(Debug, Clone)]
55pub struct ViewportRuntimeState {
56    pub transform: ViewportTransform,
57    last_declared_transform: Option<ViewportTransform>,
58    contacts: HashMap<PointerId, Contact>,
59    pending_start: Option<LayoutPoint>,
60    last_centroid: Option<LayoutPoint>,
61    last_distance: Option<f32>,
62    interacting: bool,
63    velocity: LayoutPoint,
64    last_motion_at: Option<CurrentTime>,
65    inertia_tick: Option<CurrentTime>,
66}
67
68impl ViewportRuntimeState {
69    fn new(initial: ViewportTransform, controlled: Option<ViewportTransform>) -> Self {
70        Self {
71            transform: controlled.unwrap_or(initial).normalized(),
72            last_declared_transform: controlled,
73            contacts: HashMap::new(),
74            pending_start: None,
75            last_centroid: None,
76            last_distance: None,
77            interacting: false,
78            velocity: LayoutPoint::ZERO,
79            last_motion_at: None,
80            inertia_tick: None,
81        }
82    }
83}
84
85#[derive(Debug, Clone, Copy)]
86struct Contact {
87    point: LayoutPoint,
88    kind: PointerKind,
89    child_claimed: bool,
90}
91
92/// Runtime-owned camera and active-contact state, keyed by viewer identity.
93#[derive(Debug, Clone, Default)]
94pub struct ViewportStateMap {
95    states: HashMap<WidgetId, ViewportRuntimeState>,
96    captures: HashMap<PointerId, WidgetId>,
97}
98
99impl ViewportStateMap {
100    pub fn transform(&self, id: WidgetId) -> Option<ViewportTransform> {
101        self.states.get(&id).map(|state| state.transform)
102    }
103
104    pub fn set_transform(&mut self, id: WidgetId, transform: ViewportTransform) {
105        if let Some(state) = self.states.get_mut(&id) {
106            state.transform = transform.normalized();
107        } else {
108            self.states
109                .insert(id, ViewportRuntimeState::new(transform, None));
110        }
111    }
112
113    pub fn reconcile(&mut self, ir: &CoreIR) {
114        let mut active = std::collections::HashSet::new();
115        for (id, node) in &ir.nodes {
116            let Op::Layout(LayoutOp::InteractiveViewport {
117                initial_transform,
118                controlled_transform,
119                ..
120            }) = &node.op
121            else {
122                continue;
123            };
124            active.insert(*id);
125            let state = self.states.entry(*id).or_insert_with(|| {
126                ViewportRuntimeState::new(*initial_transform, *controlled_transform)
127            });
128            if let Some(transform) = controlled_transform {
129                // A controlled transform is application authority on every
130                // rebuild, including when its value did not change.
131                state.transform = transform.normalized();
132            }
133            state.last_declared_transform = *controlled_transform;
134        }
135        self.states.retain(|id, _| active.contains(id));
136        self.captures.retain(|_, id| active.contains(id));
137    }
138
139    pub fn advance_inertia(
140        &mut self,
141        ir: &CoreIR,
142        layout: &LayoutSnapshot,
143        now: CurrentTime,
144    ) -> bool {
145        let mut active = false;
146        for (id, state) in &mut self.states {
147            let Some(last_tick) = state.inertia_tick else {
148                continue;
149            };
150            let Some(config) = viewport_config(ir, *id) else {
151                state.inertia_tick = None;
152                continue;
153            };
154            if config.friction <= 0.0 || !state.contacts.is_empty() {
155                state.inertia_tick = None;
156                continue;
157            }
158            let elapsed = (now.saturating_sub(last_tick) as f32 / 1_000.0).min(0.05);
159            state.inertia_tick = Some(now);
160            let decay = (-config.friction * elapsed * 1_000_000.0).exp();
161            state.velocity.x *= decay;
162            state.velocity.y *= decay;
163            if state.velocity.x.hypot(state.velocity.y) < 5.0 {
164                state.inertia_tick = None;
165                state.velocity = LayoutPoint::ZERO;
166                continue;
167            }
168            let delta = constrained_pan(
169                LayoutPoint::new(state.velocity.x * elapsed, state.velocity.y * elapsed),
170                config.pan_axis,
171            );
172            state.transform.translation[0] += delta.x;
173            state.transform.translation[1] += delta.y;
174            state.transform =
175                clamp_boundary(state.transform, config.boundary, viewer_rect(layout, *id));
176            active = true;
177        }
178        active
179    }
180
181    pub fn retain_active(&mut self, active: &std::collections::HashSet<WidgetId>) {
182        self.states.retain(|id, _| active.contains(id));
183        self.captures.retain(|_, id| active.contains(id));
184    }
185}
186
187pub struct ViewportControllerContext<'a> {
188    pub ir: &'a CoreIR,
189    pub layout: &'a LayoutSnapshot,
190    pub scroll: &'a crate::ScrollStateMap,
191    pub viewport: &'a mut ViewportStateMap,
192    pub gesture: &'a mut crate::env::GestureState,
193    pub current_time: CurrentTime,
194    pub dispatched_actions: Vec<(WidgetId, ActionEnvelope, ActionInput)>,
195}
196
197pub struct ViewportController;
198
199impl ViewportController {
200    pub fn handle_event(
201        &mut self,
202        ctx: &mut ViewportControllerContext<'_>,
203        event: &InputEvent,
204    ) -> bool {
205        match event {
206            InputEvent::Pointer(PointerEvent::Down {
207                pointer_id,
208                kind,
209                point,
210                button,
211                modifiers,
212            }) if matches!(button, PointerButton::Primary) => {
213                self.pointer_down(ctx, *pointer_id, *kind, *point, *modifiers)
214            }
215            InputEvent::Pointer(PointerEvent::Move {
216                pointer_id,
217                point,
218                modifiers,
219                ..
220            }) => self.pointer_move(ctx, *pointer_id, *point, *modifiers),
221            InputEvent::Pointer(PointerEvent::Up {
222                pointer_id,
223                point,
224                modifiers,
225                ..
226            }) => self.pointer_end(ctx, *pointer_id, *point, *modifiers, false),
227            InputEvent::Pointer(PointerEvent::Cancel {
228                pointer_id,
229                point,
230                modifiers,
231                ..
232            }) => self.pointer_end(ctx, *pointer_id, *point, *modifiers, true),
233            InputEvent::Pointer(PointerEvent::Scroll {
234                point,
235                delta,
236                delta_mode,
237                modifiers,
238                ..
239            }) => self.scroll(ctx, *point, *delta, *delta_mode, *modifiers),
240            InputEvent::Pointer(PointerEvent::Magnify {
241                point,
242                scale_factor,
243                phase,
244                modifiers,
245            }) => self.magnify(ctx, *point, *scale_factor, *phase, *modifiers),
246            _ => false,
247        }
248    }
249}
250
251impl ViewportController {
252    fn pointer_down(
253        &self,
254        ctx: &mut ViewportControllerContext<'_>,
255        pointer_id: PointerId,
256        kind: PointerKind,
257        point: LayoutPoint,
258        modifiers: u8,
259    ) -> bool {
260        let Some((hit, target)) =
261            hit_and_nearest_viewport(ctx.ir, ctx.layout, ctx.scroll, ctx.viewport, point)
262        else {
263            return false;
264        };
265        let child_claimed = child_drag_claims(ctx.ir, hit, target);
266        ctx.viewport.captures.insert(pointer_id, target);
267        let Some(state) = ctx.viewport.states.get_mut(&target) else {
268            return false;
269        };
270        state.inertia_tick = None;
271        state.velocity = LayoutPoint::ZERO;
272        state.last_motion_at = Some(ctx.current_time);
273        state.contacts.insert(
274            pointer_id,
275            Contact {
276                point,
277                kind,
278                child_claimed,
279            },
280        );
281        if state.contacts.len() == 1 {
282            state.pending_start = Some(point);
283            state.last_centroid = Some(point);
284            state.last_distance = None;
285            // Preserve taps and child drags until the pointer crosses the pan threshold.
286            return false;
287        }
288
289        state.pending_start = None;
290        for contact in state.contacts.values_mut() {
291            contact.child_claimed = false;
292        }
293        let (centroid, distance) = contact_geometry(&state.contacts);
294        state.last_centroid = Some(centroid);
295        state.last_distance = distance;
296        state.interacting = true;
297        let _ = state;
298        crate::input::gesture::cancel_active_drag_for_viewport(
299            ctx.ir,
300            ctx.layout,
301            ctx.viewport,
302            ctx.gesture,
303            centroid,
304            &mut ctx.dispatched_actions,
305        );
306        clear_generic_gesture(ctx);
307        dispatch_viewport_action(
308            ctx,
309            target,
310            InteractionAction::Start,
311            centroid,
312            LayoutPoint::ZERO,
313            1.0,
314            input_kind(kind),
315            modifiers,
316        );
317        true
318    }
319
320    fn pointer_move(
321        &self,
322        ctx: &mut ViewportControllerContext<'_>,
323        pointer_id: PointerId,
324        point: LayoutPoint,
325        modifiers: u8,
326    ) -> bool {
327        let Some(target) = ctx.viewport.captures.get(&pointer_id).copied() else {
328            return false;
329        };
330        let Some(config) = viewport_config(ctx.ir, target) else {
331            return false;
332        };
333        let Some(state) = ctx.viewport.states.get_mut(&target) else {
334            return false;
335        };
336        let (kind, child_claimed) = {
337            let Some(contact) = state.contacts.get_mut(&pointer_id) else {
338                return false;
339            };
340            contact.point = point;
341            (input_kind(contact.kind), contact.child_claimed)
342        };
343
344        if state.contacts.len() == 1 && child_claimed {
345            return false;
346        }
347
348        let (centroid, distance) = contact_geometry(&state.contacts);
349        let mut started = false;
350        if !state.interacting {
351            let start = state.pending_start.unwrap_or(centroid);
352            let dx = centroid.x - start.x;
353            let dy = centroid.y - start.y;
354            if dx * dx + dy * dy <= PAN_THRESHOLD_SQUARED {
355                return false;
356            }
357            state.interacting = true;
358            started = true;
359        }
360
361        let previous_centroid = state.last_centroid.unwrap_or(centroid);
362        let mut pan = LayoutPoint::new(
363            centroid.x - previous_centroid.x,
364            centroid.y - previous_centroid.y,
365        );
366        pan = constrained_pan(pan, config.pan_axis);
367        let scale_factor = match (distance, state.last_distance) {
368            (Some(next), Some(previous)) if previous > 0.0 => next / previous,
369            _ => 1.0,
370        };
371        let mut transform = state.transform;
372        transform.translation[0] += pan.x;
373        transform.translation[1] += pan.y;
374        if scale_factor.is_finite() && scale_factor > 0.0 {
375            let next_scale =
376                (transform.scale * scale_factor).clamp(config.min_scale, config.max_scale);
377            transform =
378                transform.with_scale_around(local_point(ctx.layout, target, centroid), next_scale);
379        }
380        state.transform =
381            clamp_boundary(transform, config.boundary, viewer_rect(ctx.layout, target));
382        state.last_centroid = Some(centroid);
383        state.last_distance = distance;
384        let now = ctx.current_time;
385        if let Some(previous) = state.last_motion_at {
386            let elapsed = now.saturating_sub(previous) as f32 / 1_000.0;
387            if elapsed > 0.0 {
388                state.velocity = LayoutPoint::new(pan.x / elapsed, pan.y / elapsed);
389            }
390        }
391        state.last_motion_at = Some(now);
392        let _ = state;
393        if started {
394            clear_generic_gesture(ctx);
395            dispatch_viewport_action(
396                ctx,
397                target,
398                InteractionAction::Start,
399                centroid,
400                LayoutPoint::ZERO,
401                1.0,
402                kind,
403                modifiers,
404            );
405        }
406        dispatch_viewport_action(
407            ctx,
408            target,
409            InteractionAction::Update,
410            centroid,
411            pan,
412            scale_factor,
413            kind,
414            modifiers,
415        );
416        true
417    }
418
419    fn pointer_end(
420        &self,
421        ctx: &mut ViewportControllerContext<'_>,
422        pointer_id: PointerId,
423        point: LayoutPoint,
424        modifiers: u8,
425        cancelled: bool,
426    ) -> bool {
427        let Some(target) = ctx.viewport.captures.remove(&pointer_id) else {
428            return false;
429        };
430        let friction = viewport_config(ctx.ir, target)
431            .map(|config| config.friction)
432            .unwrap_or(0.0);
433        let Some(state) = ctx.viewport.states.get_mut(&target) else {
434            return false;
435        };
436        let kind = state
437            .contacts
438            .remove(&pointer_id)
439            .map(|contact| input_kind(contact.kind))
440            .unwrap_or(ViewportInputKind::Unknown);
441        let was_interacting = state.interacting;
442        if state.contacts.is_empty() {
443            state.pending_start = None;
444            state.last_centroid = None;
445            state.last_distance = None;
446            state.interacting = false;
447            state.last_motion_at = None;
448            if !cancelled && friction > 0.0 && state.velocity.x.hypot(state.velocity.y) >= 5.0 {
449                state.inertia_tick = Some(ctx.current_time);
450            } else {
451                state.inertia_tick = None;
452                state.velocity = LayoutPoint::ZERO;
453            }
454            if was_interacting {
455                dispatch_viewport_action(
456                    ctx,
457                    target,
458                    if cancelled {
459                        InteractionAction::Cancel
460                    } else {
461                        InteractionAction::End
462                    },
463                    point,
464                    LayoutPoint::ZERO,
465                    1.0,
466                    kind,
467                    modifiers,
468                );
469            }
470            return was_interacting;
471        }
472
473        // Reset the gesture baseline so a 2 -> 1 transition cannot jump.
474        let (centroid, distance) = contact_geometry(&state.contacts);
475        state.last_centroid = Some(centroid);
476        state.last_distance = distance;
477        state.pending_start = None;
478        was_interacting
479    }
480
481    fn scroll(
482        &self,
483        ctx: &mut ViewportControllerContext<'_>,
484        point: LayoutPoint,
485        delta: LayoutPoint,
486        delta_mode: ScrollDeltaMode,
487        modifiers: u8,
488    ) -> bool {
489        let Some(target) = nearest_viewport(ctx.ir, ctx.layout, ctx.scroll, ctx.viewport, point)
490        else {
491            return false;
492        };
493        let Some(config) = viewport_config(ctx.ir, target) else {
494            return false;
495        };
496        let multiplier = if matches!(delta_mode, ScrollDeltaMode::Line) {
497            LINE_DELTA_POINTS
498        } else {
499            1.0
500        };
501        let delta = LayoutPoint::new(delta.x * multiplier, delta.y * multiplier);
502        let modified = modifiers & (4 | 8) != 0;
503        let zoom = matches!(config.zoom_policy, ViewportZoomPolicy::WheelAndTrackpad)
504            || (matches!(config.zoom_policy, ViewportZoomPolicy::WheelWithModifier) && modified);
505        if zoom {
506            let factor = (-delta.y * WHEEL_ZOOM_SENSITIVITY).exp();
507            return self.apply_discrete_transform(
508                ctx,
509                target,
510                point,
511                LayoutPoint::ZERO,
512                factor,
513                ViewportInputKind::Wheel,
514                modifiers,
515            );
516        }
517        if matches!(config.pan_axis, ViewportPanAxis::None) {
518            return false;
519        }
520        self.apply_discrete_transform(
521            ctx,
522            target,
523            point,
524            constrained_pan(LayoutPoint::new(-delta.x, -delta.y), config.pan_axis),
525            1.0,
526            ViewportInputKind::Wheel,
527            modifiers,
528        )
529    }
530
531    fn magnify(
532        &self,
533        ctx: &mut ViewportControllerContext<'_>,
534        point: LayoutPoint,
535        scale_factor: f32,
536        phase: PointerPhase,
537        modifiers: u8,
538    ) -> bool {
539        let Some(target) = nearest_viewport(ctx.ir, ctx.layout, ctx.scroll, ctx.viewport, point)
540        else {
541            return false;
542        };
543        let Some(config) = viewport_config(ctx.ir, target) else {
544            return false;
545        };
546        if matches!(config.zoom_policy, ViewportZoomPolicy::Disabled) {
547            return false;
548        }
549        let action = match phase {
550            PointerPhase::Started => InteractionAction::Start,
551            PointerPhase::Moved => InteractionAction::Update,
552            PointerPhase::Ended => InteractionAction::End,
553            PointerPhase::Cancelled => InteractionAction::Cancel,
554        };
555        if matches!(action, InteractionAction::Update) {
556            self.apply_transform(ctx, target, point, LayoutPoint::ZERO, scale_factor);
557        }
558        dispatch_viewport_action(
559            ctx,
560            target,
561            action,
562            point,
563            LayoutPoint::ZERO,
564            scale_factor,
565            ViewportInputKind::Magnify,
566            modifiers,
567        );
568        true
569    }
570
571    fn apply_discrete_transform(
572        &self,
573        ctx: &mut ViewportControllerContext<'_>,
574        target: WidgetId,
575        focal: LayoutPoint,
576        pan: LayoutPoint,
577        scale_factor: f32,
578        kind: ViewportInputKind,
579        modifiers: u8,
580    ) -> bool {
581        dispatch_viewport_action(
582            ctx,
583            target,
584            InteractionAction::Start,
585            focal,
586            LayoutPoint::ZERO,
587            1.0,
588            kind,
589            modifiers,
590        );
591        self.apply_transform(ctx, target, focal, pan, scale_factor);
592        dispatch_viewport_action(
593            ctx,
594            target,
595            InteractionAction::Update,
596            focal,
597            pan,
598            scale_factor,
599            kind,
600            modifiers,
601        );
602        dispatch_viewport_action(
603            ctx,
604            target,
605            InteractionAction::End,
606            focal,
607            LayoutPoint::ZERO,
608            1.0,
609            kind,
610            modifiers,
611        );
612        true
613    }
614
615    fn apply_transform(
616        &self,
617        ctx: &mut ViewportControllerContext<'_>,
618        target: WidgetId,
619        focal: LayoutPoint,
620        pan: LayoutPoint,
621        scale_factor: f32,
622    ) {
623        let Some(config) = viewport_config(ctx.ir, target) else {
624            return;
625        };
626        let Some(state) = ctx.viewport.states.get_mut(&target) else {
627            return;
628        };
629        let mut transform = state.transform;
630        transform.translation[0] += pan.x;
631        transform.translation[1] += pan.y;
632        if scale_factor.is_finite() && scale_factor > 0.0 {
633            transform = transform.with_scale_around(
634                local_point(ctx.layout, target, focal),
635                (transform.scale * scale_factor).clamp(config.min_scale, config.max_scale),
636            );
637        }
638        state.transform =
639            clamp_boundary(transform, config.boundary, viewer_rect(ctx.layout, target));
640    }
641}
642
643#[derive(Clone, Copy)]
644struct ViewportConfig {
645    pan_axis: ViewportPanAxis,
646    boundary: ViewportBoundary,
647    zoom_policy: ViewportZoomPolicy,
648    min_scale: f32,
649    max_scale: f32,
650    friction: f32,
651}
652
653fn viewport_config(ir: &CoreIR, id: WidgetId) -> Option<ViewportConfig> {
654    let Op::Layout(LayoutOp::InteractiveViewport {
655        pan_axis,
656        boundary,
657        zoom_policy,
658        min_scale,
659        max_scale,
660        friction,
661        ..
662    }) = &ir.nodes.get(&id)?.op
663    else {
664        return None;
665    };
666    Some(ViewportConfig {
667        pan_axis: *pan_axis,
668        boundary: *boundary,
669        zoom_policy: *zoom_policy,
670        min_scale: *min_scale,
671        max_scale: *max_scale,
672        friction: *friction,
673    })
674}
675
676fn nearest_viewport(
677    ir: &CoreIR,
678    layout: &LayoutSnapshot,
679    scroll: &crate::ScrollStateMap,
680    viewport: &ViewportStateMap,
681    point: LayoutPoint,
682) -> Option<WidgetId> {
683    hit_and_nearest_viewport(ir, layout, scroll, viewport, point).map(|(_, viewer)| viewer)
684}
685
686fn hit_and_nearest_viewport(
687    ir: &CoreIR,
688    layout: &LayoutSnapshot,
689    scroll: &crate::ScrollStateMap,
690    viewport: &ViewportStateMap,
691    point: LayoutPoint,
692) -> Option<(WidgetId, WidgetId)> {
693    let hit = crate::hit_test::hit_test_with_viewports(ir, layout, scroll, viewport, point)?;
694    let mut current = Some(hit);
695    while let Some(id) = current {
696        let node = ir.nodes.get(&id)?;
697        if matches!(node.op, Op::Layout(LayoutOp::InteractiveViewport { .. })) {
698            return Some((hit, id));
699        }
700        current = node.parent;
701    }
702    None
703}
704
705fn child_drag_claims(ir: &CoreIR, hit: WidgetId, viewer: WidgetId) -> bool {
706    let mut current = Some(hit);
707    while let Some(id) = current {
708        if id == viewer {
709            return false;
710        }
711        let Some(node) = ir.nodes.get(&id) else {
712            return false;
713        };
714        if let Op::Semantics(semantics) = &node.op {
715            if semantics.actions.entries.iter().any(|entry| {
716                matches!(
717                    entry.trigger,
718                    fission_ir::ActionTrigger::DragStart | fission_ir::ActionTrigger::DragUpdate
719                )
720            }) {
721                return true;
722            }
723        }
724        current = node.parent;
725    }
726    false
727}
728
729fn contact_geometry(contacts: &HashMap<PointerId, Contact>) -> (LayoutPoint, Option<f32>) {
730    let mut points: Vec<_> = contacts
731        .iter()
732        .map(|(id, contact)| (*id, contact.point))
733        .collect();
734    points.sort_by_key(|(id, _)| id.0);
735    let count = points.len().max(1) as f32;
736    let centroid = LayoutPoint::new(
737        points.iter().map(|(_, point)| point.x).sum::<f32>() / count,
738        points.iter().map(|(_, point)| point.y).sum::<f32>() / count,
739    );
740    let distance = (points.len() >= 2).then(|| {
741        let dx = points[1].1.x - points[0].1.x;
742        let dy = points[1].1.y - points[0].1.y;
743        (dx * dx + dy * dy).sqrt()
744    });
745    (centroid, distance)
746}
747
748fn constrained_pan(delta: LayoutPoint, axis: ViewportPanAxis) -> LayoutPoint {
749    match axis {
750        ViewportPanAxis::None => LayoutPoint::ZERO,
751        ViewportPanAxis::Horizontal => LayoutPoint::new(delta.x, 0.0),
752        ViewportPanAxis::Vertical => LayoutPoint::new(0.0, delta.y),
753        ViewportPanAxis::Both => delta,
754    }
755}
756
757fn clamp_boundary(
758    mut transform: ViewportTransform,
759    boundary: ViewportBoundary,
760    viewport: LayoutRect,
761) -> ViewportTransform {
762    let ViewportBoundary::Finite {
763        min_x,
764        min_y,
765        max_x,
766        max_y,
767        margin,
768    } = boundary
769    else {
770        return transform;
771    };
772    let min_tx = viewport.width() - (max_x + margin.right) * transform.scale;
773    let max_tx = -(min_x - margin.left) * transform.scale;
774    let min_ty = viewport.height() - (max_y + margin.bottom) * transform.scale;
775    let max_ty = -(min_y - margin.top) * transform.scale;
776    transform.translation[0] = clamp_or_center(transform.translation[0], min_tx, max_tx);
777    transform.translation[1] = clamp_or_center(transform.translation[1], min_ty, max_ty);
778    transform
779}
780
781fn clamp_or_center(value: f32, min: f32, max: f32) -> f32 {
782    if min <= max {
783        value.clamp(min, max)
784    } else {
785        (min + max) * 0.5
786    }
787}
788
789fn viewer_rect(layout: &LayoutSnapshot, id: WidgetId) -> LayoutRect {
790    layout
791        .get_node_rect(id)
792        .unwrap_or(LayoutRect::new(0.0, 0.0, 0.0, 0.0))
793}
794
795fn local_point(layout: &LayoutSnapshot, id: WidgetId, point: LayoutPoint) -> [f32; 2] {
796    let rect = viewer_rect(layout, id);
797    [point.x - rect.origin.x, point.y - rect.origin.y]
798}
799
800fn input_kind(kind: PointerKind) -> ViewportInputKind {
801    match kind {
802        PointerKind::Mouse => ViewportInputKind::Mouse,
803        PointerKind::Touch => ViewportInputKind::Touch,
804        PointerKind::Stylus => ViewportInputKind::Stylus,
805        PointerKind::Unknown => ViewportInputKind::Unknown,
806    }
807}
808
809#[derive(Clone, Copy)]
810enum InteractionAction {
811    Start,
812    Update,
813    End,
814    Cancel,
815}
816
817fn dispatch_viewport_action(
818    ctx: &mut ViewportControllerContext<'_>,
819    target: WidgetId,
820    action: InteractionAction,
821    focal: LayoutPoint,
822    pan_delta: LayoutPoint,
823    scale_factor: f32,
824    input_kind: ViewportInputKind,
825    modifiers: u8,
826) {
827    let Some(node) = ctx.ir.nodes.get(&target) else {
828        return;
829    };
830    let Op::Layout(LayoutOp::InteractiveViewport {
831        on_interaction_start,
832        on_interaction_update,
833        on_interaction_end,
834        ..
835    }) = &node.op
836    else {
837        return;
838    };
839    let entry: Option<&ActionEntry> = match action {
840        InteractionAction::Start => on_interaction_start.as_ref(),
841        InteractionAction::Update => on_interaction_update.as_ref(),
842        InteractionAction::End | InteractionAction::Cancel => on_interaction_end.as_ref(),
843    };
844    let Some(entry) = entry else { return };
845    let Some(state) = ctx.viewport.states.get(&target) else {
846        return;
847    };
848    let local_focal = local_point(ctx.layout, target, focal);
849    let world = state.transform.screen_to_world(local_focal);
850    let input = ActionInput::ViewportInteraction(ViewportInteraction {
851        node_id: target,
852        phase: match action {
853            InteractionAction::Start => ViewportInteractionPhase::Start,
854            InteractionAction::Update => ViewportInteractionPhase::Update,
855            InteractionAction::End => ViewportInteractionPhase::End,
856            InteractionAction::Cancel => ViewportInteractionPhase::Cancel,
857        },
858        transform: state.transform,
859        viewport_focal_point: LayoutPoint::new(local_focal[0], local_focal[1]),
860        world_focal_point: LayoutPoint::new(world[0], world[1]),
861        pan_delta,
862        scale_factor,
863        input_kind,
864        modifiers,
865    });
866    ctx.dispatched_actions.push((
867        target,
868        ActionEnvelope {
869            id: ActionId::from_u128(entry.action_id),
870            payload: entry.payload_data.clone().unwrap_or_default(),
871        },
872        scoped_action_input(ctx.ir, target, input),
873    ));
874}
875
876fn clear_generic_gesture(ctx: &mut ViewportControllerContext<'_>) {
877    ctx.gesture.start_point = None;
878    ctx.gesture.last_point = None;
879    ctx.gesture.is_panning = false;
880    ctx.gesture.target_node = None;
881    ctx.gesture.dragging_payload = None;
882    ctx.gesture.drag_session = None;
883    ctx.gesture.pressed_button = None;
884}