lumen_primitives/
press.rs1use 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
23pub const DEFAULT_LONG_PRESS_MS: u64 = 500;
25pub const DEFAULT_DOUBLE_CLICK_MS: u64 = 300;
27pub const DOUBLE_CLICK_RADIUS_PX: f32 = 5.0;
32
33pub struct PressPlugin {
35 pub long_press: Duration,
37 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 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#[derive(Resource, Clone, Copy, Debug)]
77pub struct PressConfig {
78 pub long_press: Duration,
80 pub double_click: Duration,
82}
83
84#[derive(Component, Clone, Copy, Debug)]
89pub struct PressStartedAt {
90 pub at: Instant,
92 pub long_fired: bool,
94}
95
96#[derive(Resource, Clone, Copy, Debug, Default)]
98pub struct LastClick {
99 pub last: Option<(Entity, Instant, glam::Vec2)>,
101}
102
103pub 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
122pub 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 anim.request();
145 }
146 }
147}
148
149pub 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 #[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 #[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 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}