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