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");
}
}