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                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            }
153            DragMsg::Cancel => {
154                self.dragging = None;
155                self.hover = None;
156                Vec::new()
157            }
158        }
159    }
160
161    /// Observable state as JSON (the `state_json` discipline) — placements, the
162    /// in-flight drag, and undrained moves, so a headless test asserts the board
163    /// without a display.
164    pub fn state_json(&self) -> serde_json::Value {
165        serde_json::json!({
166            "placement": self.placement,
167            "dragging": self.dragging,
168            "hover": self.hover,
169            "pending_moves": self.moves,
170        })
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    fn board() -> DragDrop {
179        let mut dd = DragDrop::new();
180        dd.place("SUPP-1", "active");
181        dd.place("SUPP-2", "active");
182        dd.place("SUPP-4", "triage");
183        dd
184    }
185
186    #[test]
187    fn a_full_drag_across_zones_commits_one_move() {
188        let mut dd = board();
189        assert!(dd.update(DragMsg::Start("SUPP-1".into())).is_empty());
190        assert!(dd.is_dragging());
191        assert!(dd.update(DragMsg::HoverZone("done".into())).is_empty());
192        let fx = dd.update(DragMsg::Drop);
193        assert_eq!(
194            fx,
195            vec![DragEffect::Moved(Move { item: "SUPP-1".into(), from: "active".into(), to: "done".into() })]
196        );
197        assert_eq!(dd.zone_of("SUPP-1"), Some("done"), "placement updated");
198        assert!(!dd.is_dragging(), "drag ended");
199        assert_eq!(dd.drain_moves().len(), 1);
200        assert!(dd.drain_moves().is_empty(), "second drain empty");
201    }
202
203    #[test]
204    fn dropping_back_on_the_home_zone_is_a_noop() {
205        let mut dd = board();
206        dd.update(DragMsg::Start("SUPP-1".into()));
207        dd.update(DragMsg::HoverZone("active".into())); // same zone
208        assert!(dd.update(DragMsg::Drop).is_empty(), "no move when zone unchanged");
209        assert_eq!(dd.zone_of("SUPP-1"), Some("active"));
210        assert!(dd.drain_moves().is_empty());
211    }
212
213    #[test]
214    fn cancel_abandons_the_drag_with_no_move() {
215        let mut dd = board();
216        dd.update(DragMsg::Start("SUPP-2".into()));
217        dd.update(DragMsg::HoverZone("done".into()));
218        assert!(dd.update(DragMsg::Cancel).is_empty());
219        assert!(!dd.is_dragging());
220        assert_eq!(dd.zone_of("SUPP-2"), Some("active"), "unchanged after cancel");
221        assert!(dd.drain_moves().is_empty());
222    }
223
224    #[test]
225    fn cannot_drag_an_unknown_item() {
226        let mut dd = board();
227        assert!(dd.update(DragMsg::Start("GHOST".into())).is_empty());
228        assert!(!dd.is_dragging(), "unknown item never starts a drag");
229    }
230
231    #[test]
232    fn hover_only_registers_mid_drag() {
233        let mut dd = board();
234        // No drag started → hover ignored.
235        dd.update(DragMsg::HoverZone("done".into()));
236        assert_eq!(dd.hover(), None);
237        // Drop with nothing dragged → nothing.
238        assert!(dd.update(DragMsg::Drop).is_empty());
239    }
240
241    #[test]
242    fn leave_zone_clears_hover_but_keeps_dragging() {
243        let mut dd = board();
244        dd.update(DragMsg::Start("SUPP-1".into()));
245        dd.update(DragMsg::HoverZone("done".into()));
246        dd.update(DragMsg::LeaveZone);
247        assert_eq!(dd.hover(), None);
248        assert!(dd.is_dragging(), "still dragging after leaving a zone");
249        // Dropping over no zone commits nothing.
250        assert!(dd.update(DragMsg::Drop).is_empty());
251        assert_eq!(dd.zone_of("SUPP-1"), Some("active"));
252    }
253
254    #[test]
255    fn items_in_zone_is_sorted_and_placement_survives_roundtrip() {
256        let dd = board();
257        assert_eq!(dd.items_in("active"), vec!["SUPP-1", "SUPP-2"]);
258        assert_eq!(dd.items_in("triage"), vec!["SUPP-4"]);
259        let json = serde_json::to_string(&dd).unwrap();
260        let back: DragDrop = serde_json::from_str(&json).unwrap();
261        assert_eq!(dd, back, "serde round-trip is identity (FC-3)");
262    }
263}