Skip to main content

facett_core/
dragdrop.rs

1//! **Shared drag-and-drop primitive** (DND-1) — the *one* reducer behind every
2//! "drag an item onto a zone" facet: cards→columns (kanban), bars→lanes (gantt),
3//! files→folders, chips→buckets. Like [`nav::Navigable`](crate::nav) and
4//! [`scroll_engine::SmoothScroll`](crate::scroll_engine), it is an **engine-agnostic
5//! state machine** — no egui, no rendering — so its transitions are a *tested*
6//! property (FC-7) and any host paints it however it likes.
7//!
8//! It follows the canonical Elm idiom ([`crate::elm`]): all state in one
9//! serializable [`DragDrop`] `Model`, every input a [`DragMsg`], mutation only via
10//! [`update`](DragDrop::update), and side work returned **as data** — a committed
11//! [`Move`] surfaces as a [`DragEffect::Moved`] the host applies (e.g. fires the
12//! real workflow transition and may reject it). It does not itself `impl Facet`:
13//! it is a primitive *used by* facets (kanban et al.), exactly as `Navigable` is
14//! used by map/graph/plot — those facets own the `impl Facet`.
15
16use std::collections::BTreeMap;
17
18use serde::{Deserialize, Serialize};
19
20/// A committed drag-move: item `item` left zone `from` and landed in zone `to`.
21/// The host applies each as a real transition (and may reject it, then re-`place`).
22#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
23pub struct Move {
24    pub item: String,
25    pub from: String,
26    pub to: String,
27}
28
29/// Side work as data (FC-8): a landed drop the host should act on. A pure
30/// hover/cancel produces none.
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub enum DragEffect {
33    /// The item was dropped onto a *different* zone — apply the transition.
34    Moved(Move),
35}
36
37/// Every input the drag session accepts (FC-2) — the **only** legal way to mutate
38/// a [`DragDrop`].
39#[derive(Clone, Debug, PartialEq, Eq)]
40pub enum DragMsg {
41    /// Begin dragging a known item (ignored if the item was never `place`d).
42    Start(String),
43    /// The pointer is over a drop zone (only meaningful mid-drag).
44    HoverZone(String),
45    /// The pointer left every zone (hover cleared; still dragging).
46    LeaveZone,
47    /// Release over the current hover zone — commits a [`Move`] if the zone
48    /// changed, otherwise a no-op. Ends the drag either way.
49    Drop,
50    /// Abort the drag (Escape / released outside any zone). No move.
51    Cancel,
52}
53
54/// The shared drag-and-drop model (DND-1). Tracks where each item currently lives
55/// (`placement`), the in-flight drag (`dragging` + `hover`), and the moves
56/// committed since the host last drained them.
57#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
58pub struct DragDrop {
59    /// item id → the zone it currently sits in.
60    placement: BTreeMap<String, String>,
61    /// The item being dragged this session, if any.
62    dragging: Option<String>,
63    /// The zone under the pointer this session, if any.
64    hover: Option<String>,
65    /// Moves committed since the last [`drain_moves`](Self::drain_moves).
66    moves: Vec<Move>,
67}
68
69impl DragDrop {
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    /// Declare (or move, non-interactively) an item into a zone — how the host
75    /// seeds the board from its data, or re-homes an item after rejecting a move.
76    pub fn place(&mut self, item: impl Into<String>, zone: impl Into<String>) {
77        self.placement.insert(item.into(), zone.into());
78    }
79
80    /// The zone an item currently lives in.
81    pub fn zone_of(&self, item: &str) -> Option<&str> {
82        self.placement.get(item).map(String::as_str)
83    }
84
85    /// The items currently in a zone (sorted, deterministic).
86    pub fn items_in(&self, zone: &str) -> Vec<&str> {
87        self.placement
88            .iter()
89            .filter(|(_, z)| z.as_str() == zone)
90            .map(|(i, _)| i.as_str())
91            .collect()
92    }
93
94    /// The item being dragged this session, if any.
95    pub fn dragging(&self) -> Option<&str> {
96        self.dragging.as_deref()
97    }
98
99    /// The zone under the pointer this session, if any.
100    pub fn hover(&self) -> Option<&str> {
101        self.hover.as_deref()
102    }
103
104    /// Whether a drag is in flight.
105    pub fn is_dragging(&self) -> bool {
106        self.dragging.is_some()
107    }
108
109    /// Drain the moves committed since the last call. The host applies each as a
110    /// transition (mirrors kanban's `take_moves`).
111    pub fn drain_moves(&mut self) -> Vec<Move> {
112        std::mem::take(&mut self.moves)
113    }
114
115    /// **The single mutation path** (FC-2 / FC-8). Apply one [`DragMsg`]; return
116    /// the [`DragEffect`]s the host should run (empty = nothing to do).
117    pub fn update(&mut self, msg: DragMsg) -> Vec<DragEffect> {
118        match msg {
119            DragMsg::Start(item) => {
120                // Only a known (placed) item can be dragged.
121                if self.placement.contains_key(&item) {
122                    self.dragging = Some(item);
123                    self.hover = None;
124                }
125                Vec::new()
126            }
127            DragMsg::HoverZone(zone) => {
128                if self.dragging.is_some() {
129                    self.hover = Some(zone);
130                }
131                Vec::new()
132            }
133            DragMsg::LeaveZone => {
134                self.hover = None;
135                Vec::new()
136            }
137            DragMsg::Drop => {
138                let effects = match (self.dragging.take(), self.hover.take()) {
139                    (Some(item), Some(zone)) => {
140                        let from = self.placement.get(&item).cloned().unwrap_or_default();
141                        if from != zone {
142                            self.placement.insert(item.clone(), zone.clone());
143                            let mv = Move { item, from, to: zone };
144                            self.moves.push(mv.clone());
145                            vec![DragEffect::Moved(mv)]
146                        } else {
147                            Vec::new() // dropped back home — no move
148                        }
149                    }
150                    _ => Vec::new(), // released with nothing dragged / no zone
151                };
152                effects
153            }
154            DragMsg::Cancel => {
155                self.dragging = None;
156                self.hover = None;
157                Vec::new()
158            }
159        }
160    }
161
162    /// Observable state as JSON (the `state_json` discipline) — placements, the
163    /// in-flight drag, and undrained moves, so a headless test asserts the board
164    /// without a display.
165    pub fn state_json(&self) -> serde_json::Value {
166        serde_json::json!({
167            "placement": self.placement,
168            "dragging": self.dragging,
169            "hover": self.hover,
170            "pending_moves": self.moves,
171        })
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    fn board() -> DragDrop {
180        let mut dd = DragDrop::new();
181        dd.place("SUPP-1", "active");
182        dd.place("SUPP-2", "active");
183        dd.place("SUPP-4", "triage");
184        dd
185    }
186
187    #[test]
188    fn a_full_drag_across_zones_commits_one_move() {
189        let mut dd = board();
190        assert!(dd.update(DragMsg::Start("SUPP-1".into())).is_empty());
191        assert!(dd.is_dragging());
192        assert!(dd.update(DragMsg::HoverZone("done".into())).is_empty());
193        let fx = dd.update(DragMsg::Drop);
194        assert_eq!(
195            fx,
196            vec![DragEffect::Moved(Move { item: "SUPP-1".into(), from: "active".into(), to: "done".into() })]
197        );
198        assert_eq!(dd.zone_of("SUPP-1"), Some("done"), "placement updated");
199        assert!(!dd.is_dragging(), "drag ended");
200        assert_eq!(dd.drain_moves().len(), 1);
201        assert!(dd.drain_moves().is_empty(), "second drain empty");
202    }
203
204    #[test]
205    fn dropping_back_on_the_home_zone_is_a_noop() {
206        let mut dd = board();
207        dd.update(DragMsg::Start("SUPP-1".into()));
208        dd.update(DragMsg::HoverZone("active".into())); // same zone
209        assert!(dd.update(DragMsg::Drop).is_empty(), "no move when zone unchanged");
210        assert_eq!(dd.zone_of("SUPP-1"), Some("active"));
211        assert!(dd.drain_moves().is_empty());
212    }
213
214    #[test]
215    fn cancel_abandons_the_drag_with_no_move() {
216        let mut dd = board();
217        dd.update(DragMsg::Start("SUPP-2".into()));
218        dd.update(DragMsg::HoverZone("done".into()));
219        assert!(dd.update(DragMsg::Cancel).is_empty());
220        assert!(!dd.is_dragging());
221        assert_eq!(dd.zone_of("SUPP-2"), Some("active"), "unchanged after cancel");
222        assert!(dd.drain_moves().is_empty());
223    }
224
225    #[test]
226    fn cannot_drag_an_unknown_item() {
227        let mut dd = board();
228        assert!(dd.update(DragMsg::Start("GHOST".into())).is_empty());
229        assert!(!dd.is_dragging(), "unknown item never starts a drag");
230    }
231
232    #[test]
233    fn hover_only_registers_mid_drag() {
234        let mut dd = board();
235        // No drag started → hover ignored.
236        dd.update(DragMsg::HoverZone("done".into()));
237        assert_eq!(dd.hover(), None);
238        // Drop with nothing dragged → nothing.
239        assert!(dd.update(DragMsg::Drop).is_empty());
240    }
241
242    #[test]
243    fn leave_zone_clears_hover_but_keeps_dragging() {
244        let mut dd = board();
245        dd.update(DragMsg::Start("SUPP-1".into()));
246        dd.update(DragMsg::HoverZone("done".into()));
247        dd.update(DragMsg::LeaveZone);
248        assert_eq!(dd.hover(), None);
249        assert!(dd.is_dragging(), "still dragging after leaving a zone");
250        // Dropping over no zone commits nothing.
251        assert!(dd.update(DragMsg::Drop).is_empty());
252        assert_eq!(dd.zone_of("SUPP-1"), Some("active"));
253    }
254
255    #[test]
256    fn items_in_zone_is_sorted_and_placement_survives_roundtrip() {
257        let dd = board();
258        assert_eq!(dd.items_in("active"), vec!["SUPP-1", "SUPP-2"]);
259        assert_eq!(dd.items_in("triage"), vec!["SUPP-4"]);
260        let json = serde_json::to_string(&dd).unwrap();
261        let back: DragDrop = serde_json::from_str(&json).unwrap();
262        assert_eq!(dd, back, "serde round-trip is identity (FC-3)");
263    }
264}