Skip to main content

eui_core/
hold.rs

1//! VAPP-162 (docs/round-5-contract.md §9): the Button `hold` gesture as a
2//! pure state machine every painter shares (the core drives it through
3//! `Surface::hold`; gpui may also run it directly). With `hold: true` a
4//! press never fires `press`: pointer down (or Space / Enter key down, key
5//! repeat ignored) starts the hold (`holdStart`, the `pressed` state, the
6//! `holdingLabel`); release within [`HOLD_SLOP`] px of the box (or key up of
7//! the key that started it) ends it (`holdEnd`); the pointer leaving the box
8//! by more than the slop, a pointer cancel, Escape or losing focus cancel it
9//! (`holdCancel`); after a cancel nothing fires until the next down.
10//! `fixtures/interactions-round1.json` `buttonHold` locks it.
11
12use serde::{Deserialize, Serialize};
13
14/// How far (px) the pointer may wander outside the box while held.
15pub const HOLD_SLOP: f32 = 24.0;
16
17/// One input to a hold. Coordinates are px relative to the button's
18/// top-left; `width`/`height` = its laid-out box.
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20#[serde(tag = "do", rename_all = "camelCase")]
21pub enum HoldInput {
22    #[serde(rename = "pointerdown")]
23    PointerDown { x: f32, y: f32 },
24    #[serde(rename = "pointermove")]
25    PointerMove { x: f32, y: f32 },
26    #[serde(rename = "pointerup")]
27    PointerUp { x: f32, y: f32 },
28    #[serde(rename = "pointercancel")]
29    PointerCancel,
30    #[serde(rename = "keydown")]
31    KeyDown {
32        key: String,
33        #[serde(default)]
34        repeat: bool,
35    },
36    #[serde(rename = "keyup")]
37    KeyUp { key: String },
38    #[serde(rename = "blur")]
39    Blur,
40}
41
42/// What a step fired.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "camelCase")]
45pub enum HoldEvent {
46    HoldStart,
47    HoldEnd,
48    HoldCancel,
49}
50
51impl HoldEvent {
52    /// The catalog event name (`holdStart` / `holdEnd` / `holdCancel`).
53    pub fn name(self) -> &'static str {
54        match self {
55            Self::HoldStart => "holdStart",
56            Self::HoldEnd => "holdEnd",
57            Self::HoldCancel => "holdCancel",
58        }
59    }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Default)]
63enum Source {
64    #[default]
65    None,
66    Pointer,
67    Key(String),
68}
69
70/// One button's hold.
71#[derive(Debug, Clone, PartialEq, Eq, Default)]
72pub struct HoldTracker {
73    source: Source,
74}
75
76fn within_slop(x: f32, y: f32, width: f32, height: f32) -> bool {
77    x >= -HOLD_SLOP && y >= -HOLD_SLOP && x <= width + HOLD_SLOP && y <= height + HOLD_SLOP
78}
79
80fn hold_key(key: &str) -> bool {
81    matches!(key, " " | "Space" | "Spacebar" | "Enter")
82}
83
84impl HoldTracker {
85    /// Whether the button is held now (paint `pressed` + `holdingLabel`).
86    pub fn active(&self) -> bool {
87        self.source != Source::None
88    }
89
90    /// Feed one input on a button `width` × `height`; the event it fires.
91    pub fn input(&mut self, input: &HoldInput, width: f32, height: f32) -> Option<HoldEvent> {
92        let active = self.active();
93        let cancel = |s: &mut Self| {
94            s.source = Source::None;
95            Some(HoldEvent::HoldCancel)
96        };
97        match input {
98            HoldInput::PointerDown { .. } if !active => {
99                self.source = Source::Pointer;
100                Some(HoldEvent::HoldStart)
101            }
102            HoldInput::PointerMove { x, y } if self.source == Source::Pointer && !within_slop(*x, *y, width, height) => cancel(self),
103            HoldInput::PointerUp { x, y } if self.source == Source::Pointer => {
104                self.source = Source::None;
105                Some(if within_slop(*x, *y, width, height) { HoldEvent::HoldEnd } else { HoldEvent::HoldCancel })
106            }
107            HoldInput::PointerCancel if self.source == Source::Pointer => cancel(self),
108            HoldInput::KeyDown { key, .. } if active && key == "Escape" => cancel(self),
109            HoldInput::KeyDown { key, repeat } if !active && !repeat && hold_key(key) => {
110                self.source = Source::Key(key.clone());
111                Some(HoldEvent::HoldStart)
112            }
113            HoldInput::KeyUp { key } if self.source == Source::Key(key.clone()) => {
114                self.source = Source::None;
115                Some(HoldEvent::HoldEnd)
116            }
117            HoldInput::Blur if active => cancel(self),
118            _ => None,
119        }
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn a_slide_away_cancels_and_the_release_fires_nothing() {
129        let mut t = HoldTracker::default();
130        assert_eq!(t.input(&HoldInput::PointerDown { x: 60.0, y: 20.0 }, 120.0, 40.0), Some(HoldEvent::HoldStart));
131        assert_eq!(t.input(&HoldInput::PointerMove { x: -24.0, y: 20.0 }, 120.0, 40.0), None, "the slop is inclusive");
132        assert_eq!(t.input(&HoldInput::PointerMove { x: -30.0, y: 20.0 }, 120.0, 40.0), Some(HoldEvent::HoldCancel));
133        assert_eq!(t.input(&HoldInput::PointerUp { x: 60.0, y: 20.0 }, 120.0, 40.0), None);
134        assert!(!t.active());
135    }
136}