facett_core/panel.rs
1//! **The `Panel` trait — the constellation-wide UI-pane seam (Phase 0 foundation).**
2//!
3//! Every UI pane in every egui app in the nordisk constellation becomes one
4//! `Panel` value; the app holds `Box<dyn Panel>` per tab and never cares which
5//! *backend* is behind it (see `.nornir/wasm-ui-panels-design.md` §1). The trait
6//! is the PRIMARY seam and it has four backends, in priority order:
7//!
8//! 1. **native in-process** (fastest, default) — a facett [`Facet`](crate::Facet)
9//! painting straight into `egui::Ui`. This module provides it as the **blanket
10//! [`impl<T: Facet> Panel for T`]**: because [`Facet`](crate::Facet) now carries
11//! a defaulted `update_json`, its four methods line up 1:1 with `Panel`, so
12//! **every existing `Facet` is a `Panel` for free** — no per-facet code.
13//! 2. **native cross-host adapter** (app↔app embedding, no wasm) — a `<guest>-embed`
14//! bridge crate wraps a guest component crate as `Box<dyn Panel>` (design §3).
15//! 3. **wasm Component** — only when the target is the browser (design §2).
16//! 4. **headless** — for tests: `ui()` is a no-op, `update_json` applies messages,
17//! `state_json` snapshots. [`drive`] is that driver (reuses the same seam the
18//! facett-core [`harness`](crate::harness) drives for `Facet`/`Elm`).
19//!
20//! The four methods are exactly the facett Elm contract projected onto a
21//! host-agnostic, object-safe trait — the SAME `state_json` the nornir test-matrix
22//! and robot-UI already consume, so "headless drive falls out for free".
23
24/// One pane, host-/backend-agnostic. The single seam every UI pane implements.
25///
26/// It is **object-safe** (`Box<dyn Panel>` is the currency the deck/host holds),
27/// and it mirrors [`Facet`](crate::Facet)'s four core methods so the blanket impl
28/// below makes every `Facet` a `Panel`.
29pub trait Panel {
30 /// Tab label / panel heading.
31 fn title(&self) -> &str;
32 /// Paint into the live egui frame. Backends: native paints directly; the
33 /// native cross-host adapter forwards to the guest pane's own `ui`; the wasm
34 /// backend paints a decoded display-list; headless paints nothing.
35 fn ui(&mut self, ui: &mut egui::Ui);
36 /// FC-3 observable state — the common currency for tests + the web state-hook.
37 fn state_json(&self) -> serde_json::Value;
38 /// The Elm mutation path — apply one message (JSON). Used by the headless
39 /// drive + robotui, and by the wasm/adapter backends to route host-side input.
40 fn update_json(&mut self, msg_json: &str);
41}
42
43/// **Backend (1): native in-process.** The blanket impl that makes every facett
44/// [`Facet`](crate::Facet) a [`Panel`] with zero per-facet code. It forwards each
45/// of the four `Panel` methods to the identically-shaped `Facet` method (the
46/// `update_json` default landed on `Facet` for exactly this convergence). There is
47/// no boundary, no serialize, no wasm toolchain — this is what a pane runs in its
48/// own app's desktop client, the common case for every inventoried pane.
49impl<T: crate::Facet + ?Sized> Panel for T {
50 fn title(&self) -> &str {
51 crate::Facet::title(self)
52 }
53 fn ui(&mut self, ui: &mut egui::Ui) {
54 crate::Facet::ui(self, ui)
55 }
56 fn state_json(&self) -> serde_json::Value {
57 crate::Facet::state_json(self)
58 }
59 fn update_json(&mut self, msg_json: &str) {
60 crate::Facet::update_json(self, msg_json)
61 }
62}
63
64/// **Backend (4): headless.** Apply `msgs` to `panel` in order via
65/// [`Panel::update_json`], snapshotting [`Panel::state_json`] **after each
66/// message**. Returns one JSON snapshot per applied message (same length + order as
67/// `msgs`), so a test can assert the whole state *transition sequence*, not just
68/// the final state. No egui, no GPU — `ui()` is never called, so this runs
69/// anywhere, deterministically, with zero device. This is the `Panel` analogue of
70/// the [`harness`](crate::harness) `Elm` driver.
71pub fn drive<'a>(panel: &mut dyn Panel, msgs: impl IntoIterator<Item = &'a str>) -> Vec<serde_json::Value> {
72 let mut snapshots = Vec::new();
73 for m in msgs {
74 panel.update_json(m);
75 snapshots.push(panel.state_json());
76 }
77 snapshots
78}
79
80/// [`drive`] `panel` through `msgs`, then return **only** the final
81/// [`Panel::state_json`] snapshot — the terminal FC-3 state after the sequence.
82pub fn snapshot<'a>(panel: &mut dyn Panel, msgs: impl IntoIterator<Item = &'a str>) -> serde_json::Value {
83 drive(panel, msgs);
84 panel.state_json()
85}
86
87#[cfg(test)]
88mod tests {
89 use super::*;
90 use crate::Facet;
91 use egui::Ui;
92
93 /// A trivial `Facet` that does NOT override `update_json` — it exercises the
94 /// defaulted no-op path. Proves the blanket impl compiles + a plain `Facet` is
95 /// usable as `Box<dyn Panel>`.
96 struct Static {
97 title: String,
98 items: usize,
99 }
100 impl Facet for Static {
101 fn title(&self) -> &str {
102 &self.title
103 }
104 fn ui(&mut self, _ui: &mut Ui) {}
105 fn state_json(&self) -> serde_json::Value {
106 serde_json::json!({ "items": self.items })
107 }
108 }
109
110 /// A `Facet` that DOES override `update_json` (the Elm mutation path). Proves an
111 /// existing hand-written `update_json` override still works — the blanket
112 /// `Panel::update_json` routes into the override, not the `Facet` default no-op.
113 struct Counter {
114 count: i64,
115 last: Option<String>,
116 }
117 impl Facet for Counter {
118 fn title(&self) -> &str {
119 "counter"
120 }
121 fn ui(&mut self, _ui: &mut Ui) {}
122 fn state_json(&self) -> serde_json::Value {
123 serde_json::json!({ "count": self.count, "last": self.last })
124 }
125 fn update_json(&mut self, msg_json: &str) {
126 let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else {
127 return;
128 };
129 if let Some(n) = v.get("add").and_then(|n| n.as_i64()) {
130 self.count += n;
131 }
132 if let Some(s) = v.get("mark").and_then(|s| s.as_str()) {
133 self.last = Some(s.to_string());
134 }
135 }
136 }
137
138 /// Backend (1): the blanket impl compiles and a sample `Facet` is usable as
139 /// `Box<dyn Panel>` — the whole point of Phase 0.
140 #[test]
141 fn blanket_impl_makes_a_facet_usable_as_box_dyn_panel() {
142 let mut p: Box<dyn Panel> = Box::new(Static { title: "s".into(), items: 3 });
143 assert_eq!(Panel::title(&*p), "s");
144 assert_eq!(p.state_json()["items"], 3);
145 // Default `update_json` is a no-op on a facet that doesn't override it.
146 p.update_json(r#"{"anything":true}"#);
147 assert_eq!(p.state_json()["items"], 3, "defaulted update_json is a no-op");
148 }
149
150 /// A hand-written `update_json` override on a `Facet` is what the `Panel`
151 /// mutation path calls — verifying the ADDITIVE default did not shadow overrides.
152 #[test]
153 fn override_update_json_is_routed_through_panel() {
154 let mut c = Counter { count: 0, last: None };
155 // Call through the `Panel` seam specifically (not the inherent/Facet path).
156 Panel::update_json(&mut c, r#"{"add": 5}"#);
157 assert_eq!(Panel::state_json(&c)["count"], 5, "Panel routed into the Facet override");
158 }
159
160 /// Backend (4): the headless driver applies a `Vec` of JSON messages and the
161 /// `state_json` snapshots show the transition sequence.
162 #[test]
163 fn headless_driver_applies_msgs_and_state_json_transitions() {
164 let mut c = Counter { count: 0, last: None };
165 let snaps = drive(
166 &mut c as &mut dyn Panel,
167 [r#"{"add": 1}"#, r#"{"add": 2}"#, r#"{"mark": "done"}"#, r#"{"add": -3}"#],
168 );
169 // One snapshot per message, in order — the whole transition, not just the end.
170 assert_eq!(snaps.len(), 4);
171 assert_eq!(snaps[0]["count"], 1);
172 assert_eq!(snaps[1]["count"], 3);
173 assert_eq!(snaps[2]["count"], 3, "the mark message doesn't change count");
174 assert_eq!(snaps[2]["last"], "done");
175 assert_eq!(snaps[3]["count"], 0, "1+2-3 = 0");
176 }
177
178 /// `snapshot` returns just the terminal state after the whole sequence.
179 #[test]
180 fn snapshot_returns_terminal_state() {
181 let mut c = Counter { count: 10, last: None };
182 let final_state = snapshot(&mut c as &mut dyn Panel, [r#"{"add": 4}"#, r#"{"add": 6}"#]);
183 assert_eq!(final_state["count"], 20);
184 }
185
186 /// A heterogeneous deck of `Box<dyn Panel>` from DIFFERENT concrete `Facet`
187 /// types — the compose-uniformly property the whole factoring rests on.
188 #[test]
189 fn box_dyn_panel_deck_is_heterogeneous() {
190 let mut deck: Vec<Box<dyn Panel>> = vec![
191 Box::new(Static { title: "a".into(), items: 1 }),
192 Box::new(Counter { count: 7, last: None }),
193 ];
194 let titles: Vec<String> = deck.iter().map(|p| p.title().to_string()).collect();
195 assert_eq!(titles, vec!["a".to_string(), "counter".to_string()]);
196 // Driving one pane doesn't touch the other.
197 deck[1].update_json(r#"{"add": 3}"#);
198 assert_eq!(deck[1].state_json()["count"], 10);
199 assert_eq!(deck[0].state_json()["items"], 1);
200 }
201}