Skip to main content

sim_lib_intent/
gesture.rs

1//! The gesture algebra: composing raw browser gestures into one Intent value.
2//!
3//! Raw input handling (pointer down/move/up streams, key events, paste) stays
4//! in the browser. This crate owns the *meaning*: a small algebra that folds a
5//! recognized raw gesture plus the thing under the pointer into a single
6//! checked Intent value. The browser feeds pointer events into a
7//! [`GestureRecognizer`], which yields a [`RawGesture`]; [`intent_from_gesture`]
8//! turns that into an Intent (carrying the operator and tick), or returns a
9//! diagnostic when the gesture has no meaning in context.
10
11use sim_kernel::Expr;
12
13use crate::model::{IntentError, Origin, intent};
14
15/// What sits under the pointer at the moment of a gesture.
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub enum HitRole {
18    /// Empty workspace background.
19    Blank,
20    /// A graph node body.
21    Node,
22    /// A typed port on a node.
23    Port,
24    /// An actionable control.
25    Button,
26    /// An editable field.
27    Field,
28    /// A wire between two ports.
29    Edge,
30}
31
32/// A hit-test result: what was under the pointer, the runtime value it stands
33/// for, and any role-specific detail (for example a port's `node`/`port`/`dir`).
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct Hit {
36    /// The kind of element hit.
37    pub role: HitRole,
38    /// The runtime value the element represents, if any.
39    pub target: Option<Expr>,
40    /// Role-specific fields (control name, port descriptor, field path, ...).
41    pub detail: Vec<(String, Expr)>,
42}
43
44impl Hit {
45    /// A blank-background hit.
46    pub fn blank() -> Self {
47        Self {
48            role: HitRole::Blank,
49            target: None,
50            detail: Vec::new(),
51        }
52    }
53
54    /// A hit on `role` standing for `target`.
55    pub fn on(role: HitRole, target: Expr) -> Self {
56        Self {
57            role,
58            target: Some(target),
59            detail: Vec::new(),
60        }
61    }
62
63    /// Attach a role-specific detail field.
64    pub fn with(mut self, key: &str, value: Expr) -> Self {
65        self.detail.push((key.to_owned(), value));
66        self
67    }
68
69    fn detail(&self, key: &str) -> Option<&Expr> {
70        self.detail
71            .iter()
72            .find_map(|(name, value)| (name == key).then_some(value))
73    }
74}
75
76/// One pointer event in a raw input stream.
77#[derive(Clone, Debug, PartialEq)]
78pub struct PointerEvent {
79    /// The phase of this event.
80    pub phase: PointerPhase,
81    /// Pointer x position in workspace coordinates.
82    pub x: f64,
83    /// Pointer y position in workspace coordinates.
84    pub y: f64,
85    /// What is under the pointer for this event.
86    pub hit: Hit,
87}
88
89/// The phase of a pointer event.
90#[derive(Clone, Copy, Debug, PartialEq, Eq)]
91pub enum PointerPhase {
92    /// Pointer pressed.
93    Down,
94    /// Pointer moved while pressed.
95    Move,
96    /// Pointer released.
97    Up,
98}
99
100/// A recognized raw gesture: the largest unit the browser composes before
101/// meaning is assigned.
102#[derive(Clone, Debug, PartialEq)]
103pub enum RawGesture {
104    /// A press and release on the same element with no significant drag.
105    Tap {
106        /// Element the tap landed on.
107        hit: Hit,
108    },
109    /// A press, drag, and release ending on `to`, with the final position.
110    Drag {
111        /// Element the drag started on.
112        from: Hit,
113        /// Element the drag ended on.
114        to: Hit,
115        /// Final pointer position as `(x, y)`.
116        at: (f64, f64),
117    },
118    /// A keyboard command directed at `hit` (for example delete/commit/cancel).
119    Key {
120        /// Command name carried by the key gesture.
121        command: String,
122        /// Element the command is directed at.
123        hit: Hit,
124    },
125}
126
127/// Folds a pointer-event stream into [`RawGesture`]s. The browser pushes each
128/// pointer event; a complete gesture is returned on the release event.
129#[derive(Debug, Default)]
130pub struct GestureRecognizer {
131    down: Option<(Hit, f64, f64)>,
132    moved: bool,
133    last: (f64, f64),
134}
135
136impl GestureRecognizer {
137    /// Create a recognizer with no gesture in progress.
138    pub fn new() -> Self {
139        Self::default()
140    }
141
142    /// Feed one pointer event; returns a [`RawGesture`] when one completes.
143    pub fn pointer(&mut self, event: PointerEvent) -> Option<RawGesture> {
144        match event.phase {
145            PointerPhase::Down => {
146                self.down = Some((event.hit, event.x, event.y));
147                self.moved = false;
148                self.last = (event.x, event.y);
149                None
150            }
151            PointerPhase::Move => {
152                if let Some((_, start_x, start_y)) = &self.down {
153                    if (event.x - start_x).abs() > DRAG_THRESHOLD
154                        || (event.y - start_y).abs() > DRAG_THRESHOLD
155                    {
156                        self.moved = true;
157                    }
158                    self.last = (event.x, event.y);
159                }
160                None
161            }
162            PointerPhase::Up => {
163                let (from, _, _) = self.down.take()?;
164                let at = (event.x, event.y);
165                if self.moved {
166                    Some(RawGesture::Drag {
167                        from,
168                        to: event.hit,
169                        at,
170                    })
171                } else {
172                    Some(RawGesture::Tap { hit: from })
173                }
174            }
175        }
176    }
177
178    /// Compose a keyboard command into a raw gesture directed at `hit`.
179    pub fn key(command: &str, hit: Hit) -> RawGesture {
180        RawGesture::Key {
181            command: command.to_owned(),
182            hit,
183        }
184    }
185}
186
187const DRAG_THRESHOLD: f64 = 3.0;
188
189/// Turn a recognized raw gesture into an Intent value in `pane`, attributed to
190/// `operator` at `tick`. Returns a diagnostic when the gesture is meaningless
191/// in context; nothing mutates on failure.
192pub fn intent_from_gesture(
193    operator: Origin,
194    pane: &str,
195    raw: &RawGesture,
196) -> Result<Expr, IntentError> {
197    match raw {
198        RawGesture::Tap { hit } => tap_intent(operator, hit),
199        RawGesture::Drag { from, to, at } => drag_intent(operator, from, to, *at),
200        RawGesture::Key { command, hit } => key_intent(operator, pane, command, hit),
201    }
202}
203
204fn ungesturable(message: &str) -> IntentError {
205    IntentError {
206        path: vec!["gesture".to_owned()],
207        message: message.to_owned(),
208    }
209}
210
211fn pane_field(pane: &str) -> Expr {
212    sim_value::build::sym(pane)
213}
214
215fn at_field(at: (f64, f64)) -> Expr {
216    sim_value::build::map(vec![
217        ("x", sim_value::build::float(at.0)),
218        ("y", sim_value::build::float(at.1)),
219    ])
220}
221
222fn require_target(hit: &Hit, message: &str) -> Result<Expr, IntentError> {
223    hit.target.clone().ok_or_else(|| ungesturable(message))
224}
225
226fn tap_intent(operator: Origin, hit: &Hit) -> Result<Expr, IntentError> {
227    match hit.role {
228        HitRole::Button => {
229            let target = require_target(hit, "tap on a control with no target")?;
230            let control = hit
231                .detail("control")
232                .cloned()
233                .ok_or_else(|| ungesturable("button hit is missing a 'control'"))?;
234            Ok(intent(
235                "tap",
236                operator,
237                vec![("target", target), ("control", control)],
238            ))
239        }
240        HitRole::Node | HitRole::Port | HitRole::Edge | HitRole::Field => {
241            let target = require_target(hit, "selectable hit with no target")?;
242            Ok(intent(
243                "select",
244                operator,
245                vec![("targets", Expr::List(vec![target]))],
246            ))
247        }
248        HitRole::Blank => Ok(intent(
249            "select",
250            operator,
251            vec![("targets", Expr::List(vec![]))],
252        )),
253    }
254}
255
256fn drag_intent(
257    operator: Origin,
258    from: &Hit,
259    to: &Hit,
260    at: (f64, f64),
261) -> Result<Expr, IntentError> {
262    match (&from.role, &to.role) {
263        (HitRole::Port, HitRole::Port) => {
264            let from_port = port_descriptor(from)?;
265            let to_port = port_descriptor(to)?;
266            Ok(intent(
267                "wire",
268                operator,
269                vec![("from", from_port), ("to", to_port)],
270            ))
271        }
272        (HitRole::Node, _) => {
273            let node = require_target(from, "drag of a node with no target")?;
274            Ok(intent(
275                "move",
276                operator,
277                vec![("node", node), ("at", at_field(at))],
278            ))
279        }
280        _ => Err(ungesturable("drag has no meaning between these elements")),
281    }
282}
283
284fn port_descriptor(hit: &Hit) -> Result<Expr, IntentError> {
285    let node = hit
286        .detail("node")
287        .cloned()
288        .ok_or_else(|| ungesturable("port hit is missing a 'node'"))?;
289    let port = hit
290        .detail("port")
291        .cloned()
292        .ok_or_else(|| ungesturable("port hit is missing a 'port'"))?;
293    Ok(sim_value::build::map(vec![("node", node), ("port", port)]))
294}
295
296fn key_intent(operator: Origin, pane: &str, command: &str, hit: &Hit) -> Result<Expr, IntentError> {
297    match command {
298        "delete" => {
299            let target = require_target(hit, "delete with no target under the pointer")?;
300            Ok(intent(
301                "delete",
302                operator,
303                vec![("targets", Expr::List(vec![target]))],
304            ))
305        }
306        "commit" => Ok(intent("commit", operator, vec![("pane", pane_field(pane))])),
307        "cancel" => Ok(intent("cancel", operator, vec![("pane", pane_field(pane))])),
308        other => Err(ungesturable(&format!(
309            "no Intent bound to command '{other}'"
310        ))),
311    }
312}