Skip to main content

lumen_primitives/
press.rs

1//! Press primitive: long-press detection + double-click recognizer.
2//!
3//! Both signals are derived from existing low-level state:
4//!
5//! * Long-press = entity carrying [`Pressed`] continuously for more than
6//!   `long_press_threshold` (default 500 ms). Fires [`LongPressEvent`] once
7//!   per press cycle. The marker [`PressStartedAt`] is auto-attached on
8//!   [`Pressed`] insertion and removed on [`Pressed`] removal so a fresh
9//!   press always starts a fresh timer.
10//! * Double-click = two consecutive [`ClickEvent`]s on the same entity
11//!   within `double_click_threshold` (default 300 ms). Tracked in the
12//!   [`LastClick`] resource.
13//!
14//! Apps consume [`LongPressEvent`] / [`DoubleClickEvent`] just like any
15//! other Message - `MessageReader<LongPressEvent>` / `MessageReader<DoubleClickEvent>`.
16
17use bevy_ecs::message::{MessageReader, MessageWriter};
18use bevy_ecs::prelude::*;
19use lumen_core::prelude::*;
20use lumen_core::render_world::AnimationsActive;
21use lumen_core::time::{Duration, Instant};
22
23/// Default long-press threshold. Matches the W3C touch UI guideline.
24pub const DEFAULT_LONG_PRESS_MS: u64 = 500;
25/// Default double-click window. Matches GTK / Windows defaults (~300ms).
26pub const DEFAULT_DOUBLE_CLICK_MS: u64 = 300;
27/// Maximum pointer travel between two clicks for them to still count as a
28/// double-click. Matches the GTK / Qt double-click slop (~5px): two
29/// clicks at opposite corners of one large entity are two singles, not a
30/// double.
31pub const DOUBLE_CLICK_RADIUS_PX: f32 = 5.0;
32
33/// Plugin: registers press-recognizer systems.
34pub struct PressPlugin {
35    /// Threshold above which a continuous press fires [`LongPressEvent`].
36    pub long_press: Duration,
37    /// Maximum gap between two clicks on the same entity to count as a
38    /// [`DoubleClickEvent`].
39    pub double_click: Duration,
40}
41
42impl Default for PressPlugin {
43    fn default() -> Self {
44        Self {
45            long_press: Duration::from_millis(DEFAULT_LONG_PRESS_MS),
46            double_click: Duration::from_millis(DEFAULT_DOUBLE_CLICK_MS),
47        }
48    }
49}
50
51impl Plugin for PressPlugin {
52    fn build(self, app: &mut App) {
53        app.world.insert_resource(PressConfig {
54            long_press: self.long_press,
55            double_click: self.double_click,
56        });
57        app.world.insert_resource(LastClick::default());
58        // attach_press_timer must run AFTER lumen-input::dispatch_clicks
59        // for the same Commands-deferred reason as the drag primitive.
60        app.add_systems(
61            TickStage::Systems,
62            attach_press_timer.after(lumen_input::dispatch_clicks),
63        );
64        app.add_systems(
65            TickStage::Systems,
66            detect_long_press.after(attach_press_timer),
67        );
68        app.add_systems(
69            TickStage::Systems,
70            detect_double_click.after(attach_press_timer),
71        );
72    }
73}
74
75/// Tunables. Re-read every frame so apps can mutate at runtime.
76#[derive(Resource, Clone, Copy, Debug)]
77pub struct PressConfig {
78    /// See [`PressPlugin::long_press`].
79    pub long_press: Duration,
80    /// See [`PressPlugin::double_click`].
81    pub double_click: Duration,
82}
83
84/// Auto-attached to every entity the moment it acquires [`Pressed`].
85/// Carries the [`Instant`] the press began. Removed once the entity
86/// releases. Also marks whether a `LongPressEvent` has already fired for
87/// this press cycle so we don't spam.
88#[derive(Component, Clone, Copy, Debug)]
89pub struct PressStartedAt {
90    /// When the [`Pressed`] marker was first observed on this entity.
91    pub at: Instant,
92    /// Set once a [`LongPressEvent`] fires for this press cycle.
93    pub long_fired: bool,
94}
95
96/// Last accepted click - drives the double-click detector.
97#[derive(Resource, Clone, Copy, Debug, Default)]
98pub struct LastClick {
99    /// Most recent click target + timestamp + position. `None` until first click.
100    pub last: Option<(Entity, Instant, glam::Vec2)>,
101}
102
103/// Maintain [`PressStartedAt`]: attach on first frame an entity carries
104/// [`Pressed`] without a timer, remove when [`Pressed`] disappears.
105pub fn attach_press_timer(
106    mut commands: Commands,
107    just_pressed: Query<Entity, (With<Pressed>, Without<PressStartedAt>)>,
108    just_released: Query<Entity, (With<PressStartedAt>, Without<Pressed>)>,
109) {
110    let now = Instant::now();
111    for e in &just_pressed {
112        commands.entity(e).insert(PressStartedAt {
113            at: now,
114            long_fired: false,
115        });
116    }
117    for e in &just_released {
118        commands.entity(e).remove::<PressStartedAt>();
119    }
120}
121
122/// Emit [`LongPressEvent`] for entities pressed past the threshold. Once
123/// per press cycle (gated by `PressStartedAt::long_fired`).
124pub fn detect_long_press(
125    cfg: Res<PressConfig>,
126    anim: Option<Res<AnimationsActive>>,
127    mut q: Query<(Entity, &mut PressStartedAt), With<Pressed>>,
128    mut events: MessageWriter<LongPressEvent>,
129) {
130    let now = Instant::now();
131    for (entity, mut state) in &mut q {
132        if state.long_fired {
133            continue;
134        }
135        if now.duration_since(state.at) >= cfg.long_press {
136            state.long_fired = true;
137            events.write(LongPressEvent { entity });
138        } else if let Some(anim) = &anim {
139            // Threshold not yet reached: keep the frame loop awake so the
140            // timer is re-evaluated on the next tick. A held press on a
141            // static screen produces no OS events; without this ping the
142            // loop parks once the press-tint tween settles (~120ms) and
143            // the 500ms long-press fires late or never.
144            anim.request();
145        }
146    }
147}
148
149/// Detect double-click by tracking the last-seen [`ClickEvent`] target +
150/// time. Two clicks on the same entity within `cfg.double_click` fire a
151/// [`DoubleClickEvent`]; the cache resets so a third click within the
152/// window is a *new* first click.
153pub fn detect_double_click(
154    cfg: Res<PressConfig>,
155    mut last: ResMut<LastClick>,
156    mut clicks: MessageReader<ClickEvent>,
157    mut doubles: MessageWriter<DoubleClickEvent>,
158) {
159    let now = Instant::now();
160    for click in clicks.read() {
161        if let Some((prev_e, prev_t, prev_pos)) = last.last
162            && prev_e == click.entity
163            && now.duration_since(prev_t) <= cfg.double_click
164            && (click.position - prev_pos).length() <= DOUBLE_CLICK_RADIUS_PX
165        {
166            doubles.write(DoubleClickEvent {
167                entity: click.entity,
168                position: click.position,
169            });
170            last.last = None;
171            continue;
172        }
173        last.last = Some((click.entity, now, click.position));
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use bevy_ecs::message::Messages;
181    use bevy_ecs::system::RunSystemOnce;
182
183    fn world_with_press(at: Instant, long_fired: bool) -> (World, Entity) {
184        let mut world = World::new();
185        world.insert_resource(PressConfig {
186            long_press: Duration::from_millis(500),
187            double_click: Duration::from_millis(300),
188        });
189        world.insert_resource(AnimationsActive::default());
190        world.init_resource::<Messages<LongPressEvent>>();
191        let e = world
192            .spawn((Pressed, PressStartedAt { at, long_fired }))
193            .id();
194        world.resource::<AnimationsActive>().clear();
195        (world, e)
196    }
197
198    /// A held press below the threshold must ping [`AnimationsActive`] so
199    /// the frame loop keeps ticking and actually re-evaluates the timer on
200    /// an otherwise static screen - without it the long-press fires late
201    /// or never.
202    #[test]
203    fn pending_long_press_keeps_loop_awake() {
204        let (mut world, _e) = world_with_press(Instant::now(), false);
205        world.run_system_once(detect_long_press).unwrap();
206        assert!(
207            world.resource::<AnimationsActive>().get(),
208            "a pending long-press must request an animation frame"
209        );
210        assert!(
211            world.resource::<Messages<LongPressEvent>>().is_empty(),
212            "the threshold has not elapsed yet, so no event fires"
213        );
214    }
215
216    /// Once the threshold elapses the event fires and the press is marked
217    /// fired; it must stop requesting frames from then on.
218    #[test]
219    fn elapsed_long_press_fires_then_stops_requesting() {
220        let past = Instant::now() - Duration::from_millis(600);
221        let (mut world, e) = world_with_press(past, false);
222        world.run_system_once(detect_long_press).unwrap();
223        assert!(
224            !world.resource::<Messages<LongPressEvent>>().is_empty(),
225            "past-threshold press fires LongPressEvent"
226        );
227        assert!(
228            world.get::<PressStartedAt>(e).unwrap().long_fired,
229            "the press is marked fired so it won't spam"
230        );
231
232        // Second pass: already fired -> must not keep the loop awake.
233        world.resource::<AnimationsActive>().clear();
234        world.run_system_once(detect_long_press).unwrap();
235        assert!(
236            !world.resource::<AnimationsActive>().get(),
237            "an already-fired long-press must stop requesting frames"
238        );
239    }
240}