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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22pub enum ViewportInteractionPhase {
23 Start,
24 Update,
25 End,
26 Cancel,
27}
28
29#[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#[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#[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 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 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 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}