The Facet contract — design
Class:
design· Function axis:f0the deck (the whole) +f1theFacettrait ·f2FacetCaps·f3clipboard & cross-instance clone ·f4FacetDeck·f5the shared interaction primitives. Strict: this file states only the shape we run. Every why and every rejected shape lives infacet-reasoning.md; the how-to isfacet-guide.md. The internal component loop (Model/Msg/update/pureview) is a separate function — seeelm-design.md.Facetis the host-facing side the macro writes from anElmimpl.
f0 — the deck (the whole)
Every facett component is one value implementing Facet (facett-core/src/lib.rs:228). A host never knows a component's concrete type: it holds Box<dyn Facet> and gets three guarantees — a title, a way to draw, and an observable state_json. A FacetDeck (lib.rs:312) is a Vec<Box<dyn Facet>> + an active index: it paints a tab bar + the active facet, routes zoom/clipboard to it, and composes every facet's state_json under its title — so whole-app introspection is free.
The data path: input → concrete component (builds its Scene / grid / map) → Facet::ui → egui pixels, with a parallel Facet::state_json spur that emits every visible count/status so the same UI is assertable headless ($APP_STATE). The deck is what turns N components into one app.
f1 — the Facet trait
pub trait Facet (facett-core/src/lib.rs:228). Three required members, the rest defaulted:
fn title(&self) -> &str; // :229 tab label / heading
fn ui(&mut self, ui: &mut egui::Ui); // :230 draw into egui
fn state_json(&self) -> serde_json::Value; // :231 every visible count/status
| law | statement | enforced by |
| title | a component names itself for the tab bar / heading | title(&self) -> &str (lib.rs:229) |
| draw | a component paints through the egui::Ui it is handed, and only there |
ui(&mut self, …) (lib.rs:230) |
| observe | every visible list/status/count is in state_json — the headless test surface |
state_json (lib.rs:231) |
The state_json obligation is non-negotiable: it is the headless robot-testing contract korp + nornir live by ($APP_STATE), so a UI is asserted without a display.
f2 — FacetCaps (the defaulted capability surface)
pub struct FacetCaps (facett-core/src/caps.rs:10) is a Copy flag struct of twelve booleans: scalable, copyable, pasteable, cuttable, selectable, themeable, resizable, searchable, editable, navigable, plus draggable and time_scrollable. The last two point a host at the two shared interaction primitives: draggable = the facet's items can be dragged onto zones via crate::dragdrop (cards→columns, bars→lanes); time_scrollable = its content lays on a pannable/zoomable crate::time_axis (gantt/CFD/calendar/timeline), several of which a host can link to one scrubber. It is built from FacetCaps::NONE with chained const fn builders, e.g. FacetCaps::NONE.selectable().draggable(). A component declares its caps by overriding Facet::caps (defaults to NONE).
The deck reads caps() to decide which toolbar buttons and shortcuts to route to the active facet, and folds it into the deck's state_json (caps.to_json, caps.rs:138), so the introspection advertises what each component can do.
| defaulted member | signature | role |
caps |
caps(&self) -> FacetCaps (lib.rs:236) |
declare capabilities |
scale / set_scale |
scale(&self)->f32 / set_scale(&mut self,f32) (lib.rs:241,245) |
uniform zoom; deck drives Ctrl-+/-/0 |
selection_json |
-> serde_json::Value (lib.rs:249) |
current selection (also folded into state_json) |
as_any_mut |
-> Option<&mut dyn Any> (lib.rs:272) |
typed downcast for hosts that need a concrete facet |
f3 — clipboard & cross-instance clone
Text clipboard. copy / cut / paste (lib.rs:255,259,263) are pure, defaulted hooks: copy returns the payload as text, cut delegates to copy then drops the selection, paste consumes text and returns whether it was handled. The deck makes the single OS-touching call; the component's copy/cut stay unit-testable. Row-oriented facets serialize identically via facett_core::clipboard::rows_to_tsv.
Type-tagged state clone. On top of the text layer sits a state layer (lib.rs:287–303): kind(&self) -> &'static str (a stable component-type id, "" = opted out), portable_state (-> Option<Value>, the round-trippable subset a sibling can adopt), and load_state (&Value -> bool). Two instances with the same kind() exchange state through the OS clipboard envelope (clipboard::encode_component / decode_component); a mismatch raises the deck's component-toast (FacetDeck::toast, lib.rs:324).
f4 — FacetDeck
pub struct FacetDeck (facett-core/src/lib.rs:312): facets: Vec<Box<dyn Facet>>, active: usize, opt-in fx: DeckFx (lib.rs:317, OFF by default), an optional raven, and a transient toast. Constructors: FacetDeck::new(Vec<Box<dyn Facet>>) (lib.rs:331) and push (lib.rs:337) to build a deck pane-by-pane. Its ui (lib.rs:560) draws the tab bar + the active facet and routes zoom/clipboard by the active facet's caps(); its state_json (lib.rs:895) composes every facet's state under its title — the whole-app introspection, free.
Reuse map (what the Facet subsystem uses): it depends only on egui, serde_json, and facett-core's own caps/clipboard/deckfx modules. Every component crate (the grid, map, graph, docview, … families) is used by it; the elm macro is what writes a component's Facet impl from its Elm impl, and the harness drives Facet headless.
f5 — the shared interaction primitives
Three interaction reducers live in facett-core beside [nav::Navigable] and [scroll_engine::SmoothScroll]: engine-agnostic state machines — no egui, no rendering — that a facet uses rather than reimplements. Each is a serde Model mutated only through one update(Msg) -> Vec<Effect> path, exposes its own state_json, and returns side work as data (FC-8); none of them impl Facet (the consuming facet owns that). They back the two new f2 caps: draggable ⇒ dragdrop, time_scrollable ⇒ time_axis.
| primitive | type / module | Msg → Effect (the mutation path) |
used by |
| dragdrop (DND-1) | DragDrop (facett-core/src/dragdrop.rs:58) |
DragMsg::{Start,HoverZone,LeaveZone,Drop,Cancel} (dragdrop.rs:40) → DragEffect::Moved(Move) (dragdrop.rs:32) on a committed cross-zone drop |
items-onto-zones facets: cards→columns (kanban), bars→lanes (gantt), files→folders |
| time_axis (TIME-1) | TimeAxis (facett-core/src/time_axis.rs:47) |
TimeMsg::{Pan,Zoom{factor,pivot},Fit,SetBounds} (time_axis.rs:31) → TimeEffect::WindowChanged{start,end} (time_axis.rs:23) iff the window actually moved |
time-laid-out facets: gantt, CFD columns, calendar weeks, timeline events |
| action_bus (BUS-1) | ActionBus (facett-core/src/action_bus.rs:43) |
BusMsg::{Dispatch{source,kind,payload},Clear} (action_bus.rs:33) → the assigned monotonic seq (update returns Option<u64>, action_bus.rs:93) |
the common outbox the other two primitives' effects flow through to the host |
dragdrop. DragDrop (dragdrop.rs:58) holds the item→zone placement, the in-flight dragging/hover, and the moves committed since the host last drained. The host seeds the board with place (dragdrop.rs:76), reads a zone via items_in (dragdrop.rs:86), runs a gesture as a Start → HoverZone → Drop DragMsg sequence through update (dragdrop.rs:117), and applies the returned DragEffect::Moved as a real transition (which it may reject, then re-place). state_json (dragdrop.rs:165) publishes the placement + in-flight drag. Re-exported as dragdrop::{DragDrop, DragEffect, DragMsg, Move as DragMove} (lib.rs:58).
time_axis. TimeAxis (time_axis.rs:47) is {start, end, min, max, min_span} — the temporal sibling of Navigable: where Navigable pans/zooms 2-D space, this pans/zooms a 1-D time window. Time is an f64 in whatever unit the host chooses (epoch s, days, ms); the axis only does affine arithmetic. zoom (time_axis.rs:130) is pivot-fixed — the pivot time stays at the same on-screen fraction (the map/plot zoom feel). Several facets link to one scrubber via sync_from (time_axis.rs:166), which copies another axis's visible window (keeping its own bounds) — one scrubber scrolls them together. Re-exported as time_axis::{TimeAxis, TimeEffect, TimeMsg} (lib.rs:69).
action_bus. ActionBus (action_bus.rs:43) is a FIFO of BusAction (action_bus.rs:23, {seq, source, kind, payload}) plus a monotonic next_seq. dispatch (action_bus.rs:56) stamps each action with a seq that never resets — even across a drain (action_bus.rs:86) — so a headless test asserts the exact order a facet emitted (FC-7). The host drains the queue each frame; the actions themselves are the side work. Re-exported as action_bus::{ActionBus, BusAction, BusMsg} (lib.rs:56).
All three follow the nav/scroll_engine convention — pure, serde, state_json, headless-tested — so the impl Facet a consumer writes stays a thin painter over a robot-drivable reducer. The demo's PrimitivesDemo (facett-demo/src/primitives_tab.rs:22) is the reference host: a cards→columns board, a pan/zoom scrubber, and the outbox both gestures dispatch onto, all folded into one state_json — see facet-guide.md f5.