The Facet contract — guide
Class:
guide· audience: a techie adding a newfacett-<name>component or driving aFacetDeck. The what is infacet-design.md; the why infacet-reasoning.md. If your component is built the Elm way (recommended), the macro writes theFacetimpl for you — seeelm-guide.md.
f0 — a new component, end to end
The recipe every port follows — one component at a time:
- New crate
facett-<name>— add it tomembersin the workspaceCargo.toml. Depend onfacett-core(+facett-graphif it's a graph; +arrow-arrayif it eats Arrow). Inheritversion/edition/licensefrom[workspace.package]. - A state type holding what the component shows, plus a
title: String. - Build the visual with facett primitives where possible — a node/edge graph is a
facett_core::Scene+facett_core::drawwith aLayout; anything else is an egui painter inui(). Keep render-only logic here; no data-fetching / connection code (that stays in the consumer, e.g. knut). impl Facet—title,ui,state_json, pluscaps()and any capability methods you opt into.- Test headless — see
f3.
f1 — the three you owe
impl facett_core::Facet for MyView {
fn title(&self) -> &str { &self.title }
fn ui(&mut self, ui: &mut egui::Ui) { /* draw; theme via theme(ui) */ }
fn state_json(&self) -> serde_json::Value {
// EVERY visible list / status / count / selection goes here.
serde_json::json!({ "rows": self.rows.len(), "selected": self.selected })
}
}
Implement those three and your component drops into a FacetDeck and into korp/nornir's $APP_STATE introspection automatically.
f2 — opting into capabilities
Every other trait method is defaulted, so the three above are the whole obligation — but overriding these is how you get zoom + clipboard for free through the deck:
fn caps(&self) -> FacetCaps {
// table: selectable rows, copyable cells, searchable, scalable, resizable columns.
FacetCaps::NONE.selectable().copyable().searchable().scalable().resizable()
}
fn scale(&self) -> f32 { self.scale }
fn set_scale(&mut self, s: f32) { self.scale = s.clamp(0.25, 4.0); } // deck drives Ctrl-+/-/0
The deck reads caps() to route only the buttons/shortcuts you honor — a non-copyable facet is never asked to copy. Fold "scale": self.scale() into state_json so the harness can assert Ctrl-+ actually changed it.
f3 — clipboard, and testing headless
Implement copy() to return your payload as text (the deck makes the single OS-touching clipboard::put, so your copy/cut stay pure):
- table / grid → TSV via
facett_core::clipboard::rows_to_tsv; - form → the bound model as pretty JSON (round-trips with
paste); - graph / pipeline → newline-joined node labels; map →
"<lon>,<lat>".
Then unit-test without a window:
// drive a selection programmatically, then assert the introspection + clipboard:
let mut v = MyView::new(/* … */);
v.select(id_a);
assert_eq!(v.state_json()["selected"], serde_json::json!(id_a));
assert!(v.copy().unwrap().contains("expected cell"));
// and assert it actually paints, offscreen:
assert!(facett_core::harness::headless_render(&mut v).drew());
When $FACETT_TRAIL is set, every render/state line the harness emits is also appended to that file — externally observable, the robot-test net. See harness-guide.md.
f4 — composing a deck
use facett_core::FacetDeck;
let mut deck = FacetDeck::new(vec![
Box::new(grid) as Box<dyn facett_core::Facet>,
Box::new(map),
]);
deck.push(Box::new(graph)); // build pane-by-pane
// in your egui update: deck.ui(ui); // tab bar + active facet + routed zoom/clipboard
let app_state = deck.state_json(); // every facet's state under its title — free
A host (korp / nornir) is a window around a FacetDeck instead of hand-rolled tabs + a state dump.
f5 — reusing the shared interaction primitives
Before hand-rolling drag or time-scroll logic, reach for the three facett-core reducers (the what is facet-design.md f5). Each is a pure serde Model you own a field of; you drive it with a Msg, apply the returned effect, and fold its state_json into yours — so the robot drives your facet with no display.
use facett_core::{ActionBus, DragDrop, DragMsg, FacetCaps, TimeAxis, TimeMsg};
// A cards→columns board (kanban / gantt lanes): seed, drag, apply the move.
let mut board = DragDrop::new();
board.place("SUPP-1", "todo"); // seed from your data
board.update(DragMsg::Start("SUPP-1".into())); // a gesture = Start → HoverZone → Drop
board.update(DragMsg::HoverZone("doing".into()));
for effect in board.update(DragMsg::Drop) {
let facett_core::DragEffect::Moved(m) = effect; // apply as a real transition (may reject)
bus.dispatch("kanban", "move", serde_json::json!({ "card": m.item, "to": m.to }));
}
// A pannable/zoomable time window (gantt/CFD/calendar): pivot-fixed zoom, then link siblings.
let mut axis = TimeAxis::new(0.0, 100.0);
axis.update(TimeMsg::Zoom { factor: 2.0, pivot: 50.0 }); // narrow, keeping t=50 fixed
other_axis.sync_from(&axis); // one scrubber scrolls several facets
// The typed outbox both gestures hand work to the host through.
let mut bus = ActionBus::new(); // seq is monotonic, never reset — order is asserted
for action in bus.drain() { host.run(action); } // read what the facets emitted this frame
Then advertise the capabilities so the deck/host route the right gestures, and expose every reducer's state:
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.themeable().resizable().draggable().time_scrollable()
}
fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"dragdrop": self.board.state_json(),
"time_axis": self.axis.state_json(),
"action_bus": self.bus.state_json(),
})
}
facett-demo's primitives tab (facett-demo/src/primitives_tab.rs) is the worked example — a board, a scrubber, and the outbox, driven headless through its typed advance_leading_card() / zoom_in() API.
Cross-instance clone (optional)
To let two same-component panes exchange state through the OS clipboard, implement the trio: kind() (a stable id like "table"), portable_state() (the round-trippable subset), and load_state(). A mismatch is rejected with the deck's toast — the caller never has to check.