Skip to main content

jellyflow_runtime/runtime/
gesture.rs

1//! Renderer-neutral pointer and gesture session helpers.
2//!
3//! Adapters still own platform input capture. This module owns the runtime sequencing that should
4//! stay consistent across adapters: pointer arbitration, gesture lifecycle events, and store
5//! commits for common headless sessions.
6
7use serde::{Deserialize, Serialize};
8
9use crate::runtime::connection::{
10    ConnectEdgeError, ConnectEdgeRequest, ConnectionDragActivationInput, ConnectionHandleRef,
11    connection_drag_threshold_met,
12};
13use crate::runtime::drag::{
14    NodeDragRequest, PointerGestureClaim as DragPointerGestureClaim, PointerGestureClaimInput,
15    resolve_pointer_gesture_claim,
16};
17use crate::runtime::events::{
18    ConnectEnd, ConnectEndOutcome, ConnectStart, NodeDragEnd, NodeDragEndOutcome, NodeDragStart,
19    NodeDragUpdate, NodeGraphGestureEvent, ViewportMove, ViewportMoveEnd, ViewportMoveEndOutcome,
20    ViewportMoveKind, ViewportMoveStart,
21};
22use crate::runtime::selection::{
23    SelectionPointerClaim, SelectionPointerClaimInput, resolve_selection_pointer_claim,
24};
25use crate::runtime::store::{DispatchError, DispatchOutcome, NodeGraphStore};
26use crate::runtime::viewport::{
27    ViewportDragPanInput, ViewportGestureContext, ViewportGestureIntent, ViewportGestureRejection,
28    ViewportPointerButton, ViewportTransform, resolve_viewport_drag_pan_gesture,
29};
30use jellyflow_core::core::{CanvasPoint, NodeId};
31
32/// Adapter-normalized pointer target for a possible runtime session.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
35pub enum PointerSessionTarget {
36    Node(NodeId),
37    ConnectionHandle(ConnectionHandleRef),
38    Pane { button: ViewportPointerButton },
39}
40
41/// Input for resolving which high-level runtime session should claim a pointer drag.
42#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
43pub struct PointerSessionClaimInput {
44    pub target: PointerSessionTarget,
45    pub screen_delta: CanvasPoint,
46    pub context: ViewportGestureContext,
47}
48
49impl PointerSessionClaimInput {
50    pub fn new(
51        target: PointerSessionTarget,
52        screen_delta: CanvasPoint,
53        context: ViewportGestureContext,
54    ) -> Self {
55        Self {
56            target,
57            screen_delta,
58            context,
59        }
60    }
61}
62
63/// Runtime session that should own the current pointer drag.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum PointerSessionClaim {
67    None,
68    Selection,
69    Connection,
70    NodeDrag,
71    ViewportPan,
72}
73
74/// Stable reason a normalized pointer session claim was rejected.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
77pub enum PointerSessionClaimRejection {
78    TargetUnavailable,
79    TargetPolicyBlocked,
80    ActivationThresholdNotMet,
81    ViewportGesture(ViewportGestureRejection),
82}
83
84/// Result of resolving pointer ownership for a normalized adapter drag.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86pub struct PointerSessionClaimOutcome {
87    pub claim: PointerSessionClaim,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub rejection: Option<PointerSessionClaimRejection>,
90}
91
92impl PointerSessionClaimOutcome {
93    pub fn claimed(claim: PointerSessionClaim) -> Self {
94        Self {
95            claim,
96            rejection: None,
97        }
98    }
99
100    pub fn rejected(rejection: PointerSessionClaimRejection) -> Self {
101        Self {
102            claim: PointerSessionClaim::None,
103            rejection: Some(rejection),
104        }
105    }
106
107    pub fn is_claimed(self) -> bool {
108        self.claim != PointerSessionClaim::None
109    }
110}
111
112/// One headless node-drag session from pointer start to final pointer update.
113#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
114pub struct NodeDragSession {
115    pub node: NodeId,
116    pub start: CanvasPoint,
117    pub to: CanvasPoint,
118}
119
120impl NodeDragSession {
121    pub fn new(node: NodeId, start: CanvasPoint, to: CanvasPoint) -> Self {
122        Self { node, start, to }
123    }
124}
125
126/// One headless connection session that commits a new edge.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128pub struct ConnectEdgeSession {
129    pub start: ConnectStart,
130    pub request: ConnectEdgeRequest,
131}
132
133impl ConnectEdgeSession {
134    pub fn new(start: ConnectStart, request: ConnectEdgeRequest) -> Self {
135        Self { start, request }
136    }
137}
138
139/// Outcome of applying a connection session.
140#[derive(Debug, Clone)]
141pub struct ConnectSessionOutcome {
142    pub end_outcome: ConnectEndOutcome,
143    pub committed_update: Option<DispatchOutcome>,
144}
145
146impl ConnectSessionOutcome {
147    fn committed(committed_update: DispatchOutcome) -> Self {
148        Self {
149            end_outcome: ConnectEndOutcome::Committed,
150            committed_update: Some(committed_update),
151        }
152    }
153
154    fn without_commit(end_outcome: ConnectEndOutcome) -> Self {
155        Self {
156            end_outcome,
157            committed_update: None,
158        }
159    }
160
161    pub fn committed_update(&self) -> Option<&DispatchOutcome> {
162        self.committed_update.as_ref()
163    }
164}
165
166/// Outcome of applying a node-drag session.
167#[derive(Debug, Clone)]
168pub struct NodeDragSessionOutcome {
169    pub nodes: Vec<NodeId>,
170    pub end_outcome: NodeDragEndOutcome,
171    pub committed_update: Option<DispatchOutcome>,
172}
173
174impl NodeDragSessionOutcome {
175    fn committed(nodes: Vec<NodeId>, committed_update: DispatchOutcome) -> Self {
176        Self {
177            nodes,
178            end_outcome: NodeDragEndOutcome::Committed,
179            committed_update: Some(committed_update),
180        }
181    }
182
183    fn without_commit(nodes: Vec<NodeId>, end_outcome: NodeDragEndOutcome) -> Self {
184        Self {
185            nodes,
186            end_outcome,
187            committed_update: None,
188        }
189    }
190
191    pub fn committed_update(&self) -> Option<&DispatchOutcome> {
192        self.committed_update.as_ref()
193    }
194}
195
196/// One accepted viewport drag-pan session.
197#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
198pub struct ViewportDragPanSession {
199    pub context: ViewportGestureContext,
200    pub input: ViewportDragPanInput,
201}
202
203impl ViewportDragPanSession {
204    pub fn new(context: ViewportGestureContext, input: ViewportDragPanInput) -> Self {
205        Self { context, input }
206    }
207}
208
209/// Outcome of applying a viewport gesture session.
210#[derive(Debug, Clone, Copy, PartialEq)]
211pub struct ViewportGestureSessionOutcome {
212    pub kind: ViewportMoveKind,
213    pub transform: ViewportTransform,
214}
215
216impl NodeGraphStore {
217    /// Resolves which high-level session should own a normalized pointer drag.
218    pub fn resolve_pointer_session_claim(
219        &self,
220        input: PointerSessionClaimInput,
221    ) -> PointerSessionClaimOutcome {
222        if input.context.connection_in_progress {
223            return PointerSessionClaimOutcome::claimed(PointerSessionClaim::Connection);
224        }
225        if input.context.user_selection_active {
226            return PointerSessionClaimOutcome::claimed(PointerSessionClaim::Selection);
227        }
228
229        let interaction = self.resolved_interaction_state();
230        let pan = interaction.pan_interaction();
231        let selection_claim = resolve_selection_pointer_claim(SelectionPointerClaimInput::new(
232            input.screen_delta,
233            pan.pane_click_distance,
234            input.context.selection_key_pressed,
235            input.context.user_selection_active,
236        ));
237        if selection_claim != SelectionPointerClaim::Unclaimed {
238            return PointerSessionClaimOutcome::claimed(PointerSessionClaim::Selection);
239        }
240
241        match input.target {
242            PointerSessionTarget::Node(node) => {
243                if let Err(rejection) = self.pointer_target_can_start_node_drag(node) {
244                    return PointerSessionClaimOutcome::rejected(rejection);
245                }
246                if pointer_claim_reaches_node_drag(PointerGestureClaimInput::new(
247                    input.screen_delta,
248                    false,
249                    false,
250                    false,
251                    pan.pane_click_distance,
252                    interaction.node_drag_interaction().node_drag_threshold,
253                )) {
254                    PointerSessionClaimOutcome::claimed(PointerSessionClaim::NodeDrag)
255                } else {
256                    PointerSessionClaimOutcome::rejected(
257                        PointerSessionClaimRejection::ActivationThresholdNotMet,
258                    )
259                }
260            }
261            PointerSessionTarget::ConnectionHandle(handle) => {
262                if let Err(rejection) = self.pointer_target_can_start_connection(handle) {
263                    return PointerSessionClaimOutcome::rejected(rejection);
264                }
265                if connection_drag_threshold_met(ConnectionDragActivationInput::new(
266                    input.screen_delta,
267                    interaction
268                        .connection_interaction()
269                        .connection_drag_threshold,
270                )) {
271                    PointerSessionClaimOutcome::claimed(PointerSessionClaim::Connection)
272                } else {
273                    PointerSessionClaimOutcome::rejected(
274                        PointerSessionClaimRejection::ActivationThresholdNotMet,
275                    )
276                }
277            }
278            PointerSessionTarget::Pane { button } => {
279                let result = resolve_viewport_drag_pan_gesture(
280                    &pan,
281                    input.context,
282                    ViewportDragPanInput::new(button, input.screen_delta),
283                );
284                match result {
285                    Ok(_) => PointerSessionClaimOutcome::claimed(PointerSessionClaim::ViewportPan),
286                    Err(ViewportGestureRejection::UserSelectionActive) => {
287                        PointerSessionClaimOutcome::claimed(PointerSessionClaim::Selection)
288                    }
289                    Err(ViewportGestureRejection::ConnectionInProgress) => {
290                        PointerSessionClaimOutcome::claimed(PointerSessionClaim::Connection)
291                    }
292                    Err(rejection) => PointerSessionClaimOutcome::rejected(
293                        PointerSessionClaimRejection::ViewportGesture(rejection),
294                    ),
295                }
296            }
297        }
298    }
299
300    fn pointer_target_can_start_node_drag(
301        &self,
302        node: NodeId,
303    ) -> Result<(), PointerSessionClaimRejection> {
304        let Some(node) = self.graph().nodes.get(&node) else {
305            return Err(PointerSessionClaimRejection::TargetUnavailable);
306        };
307        if node.hidden || !node.pos.is_finite() {
308            return Err(PointerSessionClaimRejection::TargetUnavailable);
309        }
310        if node
311            .parent
312            .is_some_and(|parent| self.view_state().selected_groups.contains(&parent))
313        {
314            return Err(PointerSessionClaimRejection::TargetPolicyBlocked);
315        }
316
317        if self
318            .resolved_interaction_state()
319            .node_interaction_policy(node)
320            .draggable
321        {
322            Ok(())
323        } else {
324            Err(PointerSessionClaimRejection::TargetPolicyBlocked)
325        }
326    }
327
328    fn pointer_target_can_start_connection(
329        &self,
330        handle: ConnectionHandleRef,
331    ) -> Result<(), PointerSessionClaimRejection> {
332        let Some(node) = self.graph().nodes.get(&handle.node) else {
333            return Err(PointerSessionClaimRejection::TargetUnavailable);
334        };
335        if node.hidden || !node.ports.contains(&handle.port) {
336            return Err(PointerSessionClaimRejection::TargetUnavailable);
337        }
338
339        let Some(port) = self.graph().ports.get(&handle.port) else {
340            return Err(PointerSessionClaimRejection::TargetUnavailable);
341        };
342        if port.node != handle.node || port.dir != handle.direction {
343            return Err(PointerSessionClaimRejection::TargetUnavailable);
344        }
345
346        if self
347            .resolved_interaction_state()
348            .port_interaction_policy(node, port)
349            .can_start_connection()
350        {
351            Ok(())
352        } else {
353            Err(PointerSessionClaimRejection::TargetPolicyBlocked)
354        }
355    }
356
357    /// Applies a full node-drag gesture session through gesture events and normal store dispatch.
358    pub fn apply_node_drag_session(
359        &mut self,
360        session: NodeDragSession,
361    ) -> Result<NodeDragSessionOutcome, DispatchError> {
362        let plan = self.plan_node_drag(NodeDragRequest {
363            node: session.node,
364            to: session.to,
365        });
366        let nodes = plan
367            .as_ref()
368            .map(|plan| plan.items().iter().map(|item| item.node).collect())
369            .unwrap_or_else(|| vec![session.node]);
370
371        self.emit_gesture(NodeGraphGestureEvent::NodeDragStart(NodeDragStart {
372            primary: session.node,
373            nodes: nodes.clone(),
374            pointer: session.start,
375        }));
376
377        let Some(plan) = plan else {
378            let end_outcome = self.rejected_or_noop_node_drag_outcome(session);
379            self.emit_gesture(NodeGraphGestureEvent::NodeDragEnd(NodeDragEnd {
380                primary: session.node,
381                nodes: nodes.clone(),
382                pointer: session.to,
383                outcome: end_outcome,
384            }));
385            return Ok(NodeDragSessionOutcome::without_commit(nodes, end_outcome));
386        };
387
388        match self.dispatch_transaction(plan.transaction()) {
389            Ok(committed_update) => {
390                self.emit_gesture(NodeGraphGestureEvent::NodeDragUpdate(NodeDragUpdate {
391                    primary: session.node,
392                    nodes: nodes.clone(),
393                    pointer: session.to,
394                }));
395                self.emit_gesture(NodeGraphGestureEvent::NodeDragEnd(NodeDragEnd {
396                    primary: session.node,
397                    nodes: nodes.clone(),
398                    pointer: session.to,
399                    outcome: NodeDragEndOutcome::Committed,
400                }));
401                Ok(NodeDragSessionOutcome::committed(nodes, committed_update))
402            }
403            Err(err) => {
404                self.emit_gesture(NodeGraphGestureEvent::NodeDragEnd(NodeDragEnd {
405                    primary: session.node,
406                    nodes,
407                    pointer: session.to,
408                    outcome: NodeDragEndOutcome::Rejected,
409                }));
410                Err(err)
411            }
412        }
413    }
414
415    /// Applies a full connect gesture session through gesture events and normal store dispatch.
416    pub fn apply_connect_edge_session(
417        &mut self,
418        session: ConnectEdgeSession,
419    ) -> Result<ConnectSessionOutcome, ConnectEdgeError> {
420        self.emit_gesture(NodeGraphGestureEvent::ConnectStart(session.start.clone()));
421
422        match self.apply_connect_edge(session.request) {
423            Ok(Some(committed_update)) => {
424                self.emit_gesture(NodeGraphGestureEvent::ConnectEnd(ConnectEnd {
425                    kind: session.start.kind,
426                    mode: session.start.mode,
427                    target: Some(session.request.to),
428                    outcome: ConnectEndOutcome::Committed,
429                }));
430                Ok(ConnectSessionOutcome::committed(committed_update))
431            }
432            Ok(None) => {
433                self.emit_gesture(NodeGraphGestureEvent::ConnectEnd(ConnectEnd {
434                    kind: session.start.kind,
435                    mode: session.start.mode,
436                    target: Some(session.request.to),
437                    outcome: ConnectEndOutcome::NoOp,
438                }));
439                Ok(ConnectSessionOutcome::without_commit(
440                    ConnectEndOutcome::NoOp,
441                ))
442            }
443            Err(err) => {
444                self.emit_gesture(NodeGraphGestureEvent::ConnectEnd(ConnectEnd {
445                    kind: session.start.kind,
446                    mode: session.start.mode,
447                    target: Some(session.request.to),
448                    outcome: ConnectEndOutcome::Rejected,
449                }));
450                Err(err)
451            }
452        }
453    }
454
455    /// Applies a full viewport drag-pan gesture session through gesture events and view-state.
456    pub fn apply_viewport_drag_pan_session(
457        &mut self,
458        session: ViewportDragPanSession,
459    ) -> Result<ViewportGestureSessionOutcome, ViewportGestureRejection> {
460        let interaction = self.resolved_interaction_state();
461        let intent = resolve_viewport_drag_pan_gesture(
462            &interaction.pan_interaction(),
463            session.context,
464            session.input,
465        )?;
466        self.apply_viewport_gesture_session(intent)
467    }
468
469    fn apply_viewport_gesture_session(
470        &mut self,
471        intent: ViewportGestureIntent,
472    ) -> Result<ViewportGestureSessionOutcome, ViewportGestureRejection> {
473        let kind = intent.move_kind();
474        let start = ViewportTransform::from_view_state(self.view_state())
475            .ok_or(ViewportGestureRejection::InvalidInput)?;
476        self.emit_gesture(NodeGraphGestureEvent::ViewportMoveStart(
477            ViewportMoveStart {
478                kind,
479                pan: start.pan,
480                zoom: start.zoom,
481            },
482        ));
483
484        if !intent.apply_to_store(self) {
485            self.emit_gesture(NodeGraphGestureEvent::ViewportMoveEnd(ViewportMoveEnd {
486                kind,
487                pan: start.pan,
488                zoom: start.zoom,
489                outcome: ViewportMoveEndOutcome::Canceled,
490            }));
491            return Err(ViewportGestureRejection::InvalidInput);
492        }
493
494        let transform = ViewportTransform::from_view_state(self.view_state())
495            .ok_or(ViewportGestureRejection::InvalidInput)?;
496        self.emit_gesture(NodeGraphGestureEvent::ViewportMove(ViewportMove {
497            kind,
498            pan: transform.pan,
499            zoom: transform.zoom,
500        }));
501        self.emit_gesture(NodeGraphGestureEvent::ViewportMoveEnd(ViewportMoveEnd {
502            kind,
503            pan: transform.pan,
504            zoom: transform.zoom,
505            outcome: ViewportMoveEndOutcome::Ended,
506        }));
507
508        Ok(ViewportGestureSessionOutcome { kind, transform })
509    }
510
511    fn rejected_or_noop_node_drag_outcome(&self, session: NodeDragSession) -> NodeDragEndOutcome {
512        let Some(node) = self.graph().nodes.get(&session.node) else {
513            return NodeDragEndOutcome::Rejected;
514        };
515        if node.hidden || !session.to.is_finite() {
516            return NodeDragEndOutcome::Rejected;
517        }
518        if node.pos == session.to {
519            NodeDragEndOutcome::NoOp
520        } else {
521            NodeDragEndOutcome::Rejected
522        }
523    }
524}
525
526fn pointer_claim_reaches_node_drag(input: PointerGestureClaimInput) -> bool {
527    matches!(
528        resolve_pointer_gesture_claim(input),
529        DragPointerGestureClaim::NodeDrag
530    )
531}