Skip to main content

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}