Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-demo/src/primitives_tab.rs

//! **`primitives` demo tab** — the three shared interaction primitives graduated
//! into `facett-core` in the #22 Part-B wave, shown live and headless-observable:
//!
//! - [`DragDrop`](facett_core::DragDrop) — a tiny cards→columns board (the kanban
//!   move reducer, engine-agnostic): each ▶ button drags the leading card one
//!   column forward, committing a [`DragMsg`] sequence.
//! - [`TimeAxis`](facett_core::TimeAxis) — a pan/zoom-in-time window with a
//!   zoom-in/zoom-out/pan scrubber (the gantt/CFD/calendar/timeline axis).
//! - [`ActionBus`](facett_core::ActionBus) — every board move and axis scrub is
//!   dispatched onto the shared outbox, drained into the on-screen "actions" list.
//!
//! It advertises the two new caps ([`FacetCaps::draggable`] +
//! [`FacetCaps::time_scrollable`]) and folds all three primitives' `state_json`
//! into its own, so the robot drives it and asserts the reducer transitions with
//! no display.

use facett_core::{ActionBus, DragDrop, DragMsg, Facet, FacetCaps, TimeAxis, TimeMsg};

/// The board columns, in left-to-right order. A card advances to the next column.
const COLUMNS: &[&str] = &["todo", "doing", "done"];

pub struct PrimitivesDemo {
    title: String,
    dd: DragDrop,
    axis: TimeAxis,
    bus: ActionBus,
}

impl PrimitivesDemo {
    pub fn new() -> Self {
        let mut dd = DragDrop::new();
        // Seed a few cards across the first two columns.
        dd.place("A", "todo");
        dd.place("B", "todo");
        dd.place("C", "doing");
        Self {
            title: "primitives".into(),
            dd,
            // A 0..100 "release day" window, zoomable/pannable.
            axis: TimeAxis::new(0.0, 100.0),
            bus: ActionBus::new(),
        }
    }

    /// An empty board (no cards) — the `DataState::Empty` cell for the matrix.
    pub fn empty() -> Self {
        Self {
            title: "primitives".into(),
            dd: DragDrop::new(),
            axis: TimeAxis::new(0.0, 100.0),
            bus: ActionBus::new(),
        }
    }

    /// How many cards are on the board (across all columns).
    fn card_count(&self) -> usize {
        COLUMNS.iter().map(|&c| self.dd.items_in(c).len()).sum()
    }

    /// The next column to the right of `zone`, if any (the drag target).
    fn next_column(zone: &str) -> Option<&'static str> {
        let i = COLUMNS.iter().position(|c| *c == zone)?;
        COLUMNS.get(i + 1).copied()
    }

    /// **Drag the leading card one column forward** (the typed entry the robot
    /// drives): pick the first item in the leftmost non-terminal column, run the
    /// full [`DragMsg`] Start→Hover→Drop sequence, and dispatch the resulting move
    /// onto the [`ActionBus`]. Returns the moved card id, or `None` if every card
    /// is already in the last column.
    pub fn advance_leading_card(&mut self) -> Option<String> {
        for &col in COLUMNS {
            let Some(next) = Self::next_column(col) else { continue };
            if let Some(&item) = self.dd.items_in(col).first() {
                let item = item.to_string();
                self.dd.update(DragMsg::Start(item.clone()));
                self.dd.update(DragMsg::HoverZone(next.to_string()));
                let effects = self.dd.update(DragMsg::Drop);
                if !effects.is_empty() {
                    self.bus.dispatch(
                        "primitives",
                        "move",
                        serde_json::json!({ "card": item, "from": col, "to": next }),
                    );
                    return Some(item);
                }
            }
        }
        None
    }

    /// **Zoom the time axis in** about the window centre (the typed scrubber entry),
    /// dispatching a `scrub` action. Returns the new visible span.
    pub fn zoom_in(&mut self) -> f64 {
        let pivot = (self.axis.start + self.axis.end) / 2.0;
        let fx = self.axis.update(TimeMsg::Zoom { factor: 2.0, pivot });
        if !fx.is_empty() {
            self.bus.dispatch(
                "primitives",
                "scrub",
                serde_json::json!({ "span": self.axis.span(), "start": self.axis.start }),
            );
        }
        self.axis.span()
    }

    fn columns_json(&self) -> serde_json::Value {
        serde_json::Value::Array(
            COLUMNS
                .iter()
                .map(|&c| {
                    serde_json::json!({ "id": c, "cards": self.dd.items_in(c) })
                })
                .collect(),
        )
    }
}

impl Default for PrimitivesDemo {
    fn default() -> Self {
        Self::new()
    }
}

impl Facet for PrimitivesDemo {
    fn title(&self) -> &str {
        &self.title
    }

    fn ui(&mut self, ui: &mut egui::Ui) {
        ui.heading("Shared interaction primitives (#22 Part B)");
        ui.weak("facett-core: dragdrop · time_axis · action_bus — all engine-agnostic reducers");
        ui.separator();

        // ── dragdrop board ───────────────────────────────────────────────────
        ui.label(egui::RichText::new("dragdrop — cards → columns").strong());
        if ui.button("▶ advance leading card").clicked() {
            self.advance_leading_card();
        }
        ui.horizontal_top(|ui| {
            for &col in COLUMNS {
                ui.vertical(|ui| {
                    ui.label(egui::RichText::new(col).strong());
                    for item in self.dd.items_in(col) {
                        ui.label(format!("• {item}"));
                    }
                });
                ui.separator();
            }
        });

        ui.add_space(8.0);
        ui.separator();

        // ── time axis scrubber ───────────────────────────────────────────────
        ui.label(egui::RichText::new("time_axis — pan / zoom in time").strong());
        ui.horizontal(|ui| {
            if ui.button("+ zoom in").clicked() {
                self.zoom_in();
            }
            if ui.button("- zoom out").clicked() {
                let pivot = (self.axis.start + self.axis.end) / 2.0;
                let _ = self.axis.update(TimeMsg::Zoom { factor: 0.5, pivot });
            }
            if ui.button("▶ pan").clicked() {
                let _ = self.axis.update(TimeMsg::Pan(self.axis.span() * 0.1));
            }
        });
        ui.weak(format!(
            "window [{:.1}, {:.1}]  span {:.1}",
            self.axis.start,
            self.axis.end,
            self.axis.span()
        ));

        ui.add_space(8.0);
        ui.separator();

        // ── action bus outbox ────────────────────────────────────────────────
        ui.label(egui::RichText::new(format!("action_bus — {} pending", self.bus.len())).strong());
        if let Some(next) = self.bus.peek() {
            ui.weak(format!("next: [{}] {}·{}", next.seq, next.source, next.kind));
        }
    }

    fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "caps": { "draggable": true, "time_scrollable": true },
            "card_count": self.card_count(),
            "columns": self.columns_json(),
            "dragdrop": self.dd.state_json(),
            "time_axis": self.axis.state_json(),
            "action_bus": self.bus.state_json(),
        })
    }

    fn caps(&self) -> FacetCaps {
        FacetCaps::NONE.themeable().resizable().draggable().time_scrollable()
    }

    /// Opt into the typed downcast so the robot driver can reach the primitives'
    /// gesture API (advance a card / zoom the axis) directly.
    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
        Some(self)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn advancing_moves_a_card_and_records_a_bus_action() {
        let mut p = PrimitivesDemo::new();
        // A starts in "todo"; advancing moves the leading card forward.
        let moved = p.advance_leading_card();
        assert_eq!(moved.as_deref(), Some("A"));
        assert_eq!(p.dd.zone_of("A"), Some("doing"));
        assert_eq!(p.bus.len(), 1, "the move was dispatched onto the bus");
        let s = p.state_json();
        assert_eq!(s["caps"]["draggable"], true);
        assert_eq!(s["caps"]["time_scrollable"], true);
    }

    #[test]
    fn zoom_in_narrows_the_window_and_records_a_scrub() {
        let mut p = PrimitivesDemo::new();
        let before = p.axis.span();
        let after = p.zoom_in();
        assert!(after < before, "zoom in narrows the span ({after} < {before})");
        assert_eq!(p.bus.len(), 1, "the scrub was dispatched");
    }

    #[test]
    fn title_is_the_legend_key() {
        assert_eq!(PrimitivesDemo::new().title(), "primitives");
    }
}