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

nordisk/facett

.nornir/facet-guide.md

The Facet contract — guide

Class: guide · audience: a techie adding a new facett-<name> component or driving a FacetDeck. The what is in facet-design.md; the why in facet-reasoning.md. If your component is built the Elm way (recommended), the macro writes the Facet impl for you — see elm-guide.md.

f0 — a new component, end to end

The recipe every port follows — one component at a time:

  1. New crate facett-<name> — add it to members in the workspace Cargo.toml. Depend on facett-core (+ facett-graph if it's a graph; + arrow-array if it eats Arrow). Inherit version / edition / license from [workspace.package].
  2. A state type holding what the component shows, plus a title: String.
  3. Build the visual with facett primitives where possible — a node/edge graph is a facett_core::Scene + facett_core::draw with a Layout; anything else is an egui painter in ui(). Keep render-only logic here; no data-fetching / connection code (that stays in the consumer, e.g. knut).
  4. impl Facet — title, ui, state_json, plus caps() and any capability methods you opt into.
  5. 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):

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.