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