lumen_primitives/drag.rs
1//! Drag primitive: threshold + state machine.
2//!
3//! State per entity tracked via the [`DragState`] component:
4//!
5//! * `Pending { start }` - pointer pressed on entity, hasn't moved past
6//! threshold yet.
7//! * `Active { start, last }` - threshold crossed, drag is in progress.
8//!
9//! Transitions:
10//!
11//! ```text
12//! (no state)
13//! --(PointerPressed on Hovered)--> Pending
14//! Pending
15//! --(PointerMoved, dist > threshold)--> Active + DragStartEvent
16//! --(PointerReleased)--> (no state)
17//! Active
18//! --(PointerMoved)--> Active + DragMoveEvent
19//! --(PointerReleased)--> (no state) + DragEndEvent
20//! ```
21//!
22//! Threshold is configured via [`DragConfig`] (default 4 px).
23//!
24//! Reads `PointerPressed` from `lumen-input::dispatch_clicks` indirectly:
25//! we look at entities that gain [`Pressed`] (so the existing dispatch
26//! pipeline already filters to the hovered entity).
27
28use bevy_ecs::message::{MessageReader, MessageWriter};
29use bevy_ecs::prelude::*;
30use glam::Vec2;
31use lumen_core::prelude::*;
32
33/// Opt-in: entities with this marker have their [`Transform::absolute`]
34/// translated by each [`DragMoveEvent`] delta. Without this, drag events
35/// still fire but the entity stays put - apps that want different drag
36/// semantics (scrollbars, marquee selection) read DragMove themselves.
37#[derive(Component, Clone, Copy, Debug, Default)]
38pub struct Draggable;
39
40/// Default pixel threshold before a press graduates to a drag.
41pub const DEFAULT_DRAG_THRESHOLD_PX: f32 = 4.0;
42
43/// Tunables for the drag recognizer.
44#[derive(Resource, Clone, Copy, Debug)]
45pub struct DragConfig {
46 /// Pointer distance from press point that triggers `DragStartEvent`.
47 pub threshold_px: f32,
48}
49
50impl Default for DragConfig {
51 fn default() -> Self {
52 Self {
53 threshold_px: DEFAULT_DRAG_THRESHOLD_PX,
54 }
55 }
56}
57
58/// Per-entity drag state. Attached on Pressed insert, removed on release.
59#[derive(Component, Clone, Copy, Debug)]
60pub enum DragState {
61 /// Press observed; drag not yet started.
62 Pending {
63 /// Pointer position when the press began.
64 start: Vec2,
65 },
66 /// Drag in progress.
67 Active {
68 /// Pointer position at press start.
69 start: Vec2,
70 /// Last reported pointer position; subtracted from next position
71 /// to compute `DragMoveEvent.delta`.
72 last: Vec2,
73 },
74}
75
76/// Plugin: installs [`DragConfig`] and registers the three drag systems.
77#[derive(Default)]
78pub struct DragPlugin {
79 /// Initial config; users can mutate the resource at runtime.
80 pub config: DragConfig,
81}
82
83impl Plugin for DragPlugin {
84 fn build(self, app: &mut App) {
85 app.world.insert_resource(self.config);
86 // attach_drag_pending must run AFTER lumen-input::dispatch_clicks
87 // because that's where Pressed is inserted (via Commands; deferred).
88 // The .after() pulls in an ApplyDeferred sync point so attach can
89 // see the freshly-inserted Pressed component in the same tick.
90 app.add_systems(
91 TickStage::Systems,
92 attach_drag_pending.after(lumen_input::dispatch_clicks),
93 );
94 app.add_systems(
95 TickStage::Systems,
96 update_drag_on_move.after(attach_drag_pending),
97 );
98 app.add_systems(
99 TickStage::Systems,
100 translate_draggable.after(update_drag_on_move),
101 );
102 app.add_systems(
103 TickStage::Systems,
104 release_drag_on_unpress.after(translate_draggable),
105 );
106 }
107}
108
109/// Generic system: every [`DragMoveEvent`] for an entity carrying the
110/// [`Draggable`] marker adds the delta to its [`Transform::absolute`].
111pub fn translate_draggable(
112 mut moves: MessageReader<DragMoveEvent>,
113 mut q: Query<&mut Transform, With<Draggable>>,
114) {
115 for ev in moves.read() {
116 if let Ok(mut t) = q.get_mut(ev.entity) {
117 t.absolute += ev.delta;
118 }
119 }
120}
121
122/// On Pressed-without-DragState: attach `DragState::Pending` with the
123/// current pointer position as the press anchor.
124pub fn attach_drag_pending(
125 mut commands: Commands,
126 pointer: Res<PointerState>,
127 just_pressed: Query<Entity, (With<Pressed>, Without<DragState>)>,
128) {
129 let Some(p) = pointer.position else {
130 return;
131 };
132 for e in &just_pressed {
133 commands.entity(e).insert(DragState::Pending { start: p });
134 }
135}
136
137/// Walk `PointerMoved` events: promote Pending -> Active when distance
138/// exceeds threshold; emit `DragMoveEvent` while Active.
139pub fn update_drag_on_move(
140 cfg: Res<DragConfig>,
141 mut moves: MessageReader<PointerMoved>,
142 mut q: Query<(Entity, &mut DragState)>,
143 mut starts: MessageWriter<DragStartEvent>,
144 mut deltas: MessageWriter<DragMoveEvent>,
145) {
146 let pending: Vec<Vec2> = moves.read().map(|m| m.position).collect();
147 if pending.is_empty() {
148 return;
149 }
150 // Use only the latest movement per frame for state transitions;
151 // emit one DragMoveEvent per individual move so apps can replay paths.
152 let latest = *pending.last().unwrap();
153
154 for (entity, mut state) in &mut q {
155 match *state {
156 DragState::Pending { start } => {
157 if (latest - start).length() >= cfg.threshold_px {
158 *state = DragState::Active {
159 start,
160 last: latest,
161 };
162 starts.write(DragStartEvent {
163 entity,
164 start,
165 position: latest,
166 });
167 }
168 }
169 DragState::Active { start, mut last } => {
170 for pos in &pending {
171 let delta = *pos - last;
172 if delta == Vec2::ZERO {
173 continue;
174 }
175 deltas.write(DragMoveEvent {
176 entity,
177 position: *pos,
178 delta,
179 });
180 last = *pos;
181 }
182 *state = DragState::Active { start, last };
183 }
184 }
185 }
186}
187
188/// On Pressed -> not-Pressed: remove `DragState`; if it was `Active`, emit
189/// `DragEndEvent` with the final pointer position.
190pub fn release_drag_on_unpress(
191 mut commands: Commands,
192 pointer: Res<PointerState>,
193 released: Query<(Entity, &DragState), Without<Pressed>>,
194 mut ends: MessageWriter<DragEndEvent>,
195) {
196 let p = pointer.position.unwrap_or(Vec2::ZERO);
197 for (entity, state) in &released {
198 if let DragState::Active { .. } = state {
199 ends.write(DragEndEvent {
200 entity,
201 position: p,
202 });
203 }
204 commands.entity(entity).remove::<DragState>();
205 }
206}