facett-core 0.1.11

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
//! **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 => {
                let effects = 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
                };
                effects
            }
            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)");
    }
}