facett-core/src/dragdrop.rs
//! **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)");
}
}