Skip to main content

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    /// The pane's **STRUCTURAL severity** — the RESOLVED Robot-UI error signal that
43    /// the headless robot reads through this seam (decision (a)). Mirrors
44    /// [`Facet::severity`](crate::Facet::severity); the blanket impl below forwards
45    /// to it. **Defaulted to [`Severity::Info`]**(crate::Severity::Info) so it is
46    /// purely ADDITIVE — no existing `Panel` backend has to change. The Robot-UI
47    /// HARD GATE fails on any driven pane that returns
48    /// [`Severity::Error`](crate::Severity::Error).
49    fn severity(&self) -> crate::Severity {
50        crate::Severity::Info
51    }
52
53    /// The pane's stable DEV-ID — mirrors [`Facet::component`](crate::Facet::component);
54    /// the blanket impl below forwards to it. Defaulted to `""` so it is purely
55    /// ADDITIVE — no existing `Panel` backend has to change.
56    fn component(&self) -> &'static str {
57        ""
58    }
59}
60
61/// **Backend (1): native in-process.** The blanket impl that makes every facett
62/// [`Facet`](crate::Facet) a [`Panel`] with zero per-facet code. It forwards each
63/// of the four `Panel` methods to the identically-shaped `Facet` method (the
64/// `update_json` default landed on `Facet` for exactly this convergence). There is
65/// no boundary, no serialize, no wasm toolchain — this is what a pane runs in its
66/// own app's desktop client, the common case for every inventoried pane.
67impl<T: crate::Facet + ?Sized> Panel for T {
68    fn title(&self) -> &str {
69        crate::Facet::title(self)
70    }
71    fn ui(&mut self, ui: &mut egui::Ui) {
72        crate::Facet::ui(self, ui)
73    }
74    fn state_json(&self) -> serde_json::Value {
75        crate::Facet::state_json(self)
76    }
77    fn update_json(&mut self, msg_json: &str) {
78        crate::Facet::update_json(self, msg_json)
79    }
80    fn severity(&self) -> crate::Severity {
81        crate::Facet::severity(self)
82    }
83    fn component(&self) -> &'static str {
84        crate::Facet::component(self)
85    }
86}
87
88/// **Backend (4): headless.** Apply `msgs` to `panel` in order via
89/// [`Panel::update_json`], snapshotting [`Panel::state_json`] **after each
90/// message**. Returns one JSON snapshot per applied message (same length + order as
91/// `msgs`), so a test can assert the whole state *transition sequence*, not just
92/// the final state. No egui, no GPU — `ui()` is never called, so this runs
93/// anywhere, deterministically, with zero device. This is the `Panel` analogue of
94/// the [`harness`](crate::harness) `Elm` driver.
95pub fn drive<'a>(panel: &mut dyn Panel, msgs: impl IntoIterator<Item = &'a str>) -> Vec<serde_json::Value> {
96    let mut snapshots = Vec::new();
97    for m in msgs {
98        panel.update_json(m);
99        snapshots.push(panel.state_json());
100    }
101    snapshots
102}
103
104/// [`drive`] `panel` through `msgs`, then return **only** the final
105/// [`Panel::state_json`] snapshot — the terminal FC-3 state after the sequence.
106pub fn snapshot<'a>(panel: &mut dyn Panel, msgs: impl IntoIterator<Item = &'a str>) -> serde_json::Value {
107    drive(panel, msgs);
108    panel.state_json()
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::Facet;
115    use egui::Ui;
116
117    /// A trivial `Facet` that does NOT override `update_json` — it exercises the
118    /// defaulted no-op path. Proves the blanket impl compiles + a plain `Facet` is
119    /// usable as `Box<dyn Panel>`.
120    struct Static {
121        title: String,
122        items: usize,
123    }
124    impl Facet for Static {
125        fn title(&self) -> &str {
126            &self.title
127        }
128        fn ui(&mut self, _ui: &mut Ui) {}
129        fn state_json(&self) -> serde_json::Value {
130            serde_json::json!({ "items": self.items })
131        }
132    }
133
134    /// A `Facet` that DOES override `update_json` (the Elm mutation path). Proves an
135    /// existing hand-written `update_json` override still works — the blanket
136    /// `Panel::update_json` routes into the override, not the `Facet` default no-op.
137    struct Counter {
138        count: i64,
139        last: Option<String>,
140    }
141    impl Facet for Counter {
142        fn title(&self) -> &str {
143            "counter"
144        }
145        fn ui(&mut self, _ui: &mut Ui) {}
146        fn state_json(&self) -> serde_json::Value {
147            serde_json::json!({ "count": self.count, "last": self.last })
148        }
149        fn update_json(&mut self, msg_json: &str) {
150            let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else {
151                return;
152            };
153            if let Some(n) = v.get("add").and_then(|n| n.as_i64()) {
154                self.count += n;
155            }
156            if let Some(s) = v.get("mark").and_then(|s| s.as_str()) {
157                self.last = Some(s.to_string());
158            }
159        }
160    }
161
162    /// Backend (1): the blanket impl compiles and a sample `Facet` is usable as
163    /// `Box<dyn Panel>` — the whole point of Phase 0.
164    #[test]
165    fn blanket_impl_makes_a_facet_usable_as_box_dyn_panel() {
166        let mut p: Box<dyn Panel> = Box::new(Static { title: "s".into(), items: 3 });
167        assert_eq!(Panel::title(&*p), "s");
168        assert_eq!(p.state_json()["items"], 3);
169        // Default `update_json` is a no-op on a facet that doesn't override it.
170        p.update_json(r#"{"anything":true}"#);
171        assert_eq!(p.state_json()["items"], 3, "defaulted update_json is a no-op");
172    }
173
174    /// A hand-written `update_json` override on a `Facet` is what the `Panel`
175    /// mutation path calls — verifying the ADDITIVE default did not shadow overrides.
176    #[test]
177    fn override_update_json_is_routed_through_panel() {
178        let mut c = Counter { count: 0, last: None };
179        // Call through the `Panel` seam specifically (not the inherent/Facet path).
180        Panel::update_json(&mut c, r#"{"add": 5}"#);
181        assert_eq!(Panel::state_json(&c)["count"], 5, "Panel routed into the Facet override");
182    }
183
184    /// Backend (4): the headless driver applies a `Vec` of JSON messages and the
185    /// `state_json` snapshots show the transition sequence.
186    #[test]
187    fn headless_driver_applies_msgs_and_state_json_transitions() {
188        let mut c = Counter { count: 0, last: None };
189        let snaps = drive(
190            &mut c as &mut dyn Panel,
191            [r#"{"add": 1}"#, r#"{"add": 2}"#, r#"{"mark": "done"}"#, r#"{"add": -3}"#],
192        );
193        // One snapshot per message, in order — the whole transition, not just the end.
194        assert_eq!(snaps.len(), 4);
195        assert_eq!(snaps[0]["count"], 1);
196        assert_eq!(snaps[1]["count"], 3);
197        assert_eq!(snaps[2]["count"], 3, "the mark message doesn't change count");
198        assert_eq!(snaps[2]["last"], "done");
199        assert_eq!(snaps[3]["count"], 0, "1+2-3 = 0");
200    }
201
202    /// The STRUCTURAL severity (decision (a)) rides the `Facet` → blanket `Panel`
203    /// seam: a facet reporting `Severity::Error` is RED *through the `Panel`
204    /// contract* the headless robot drives — no text scan involved. Default facets
205    /// are the `Info` (green) floor.
206    #[test]
207    fn structural_severity_rides_the_facet_panel_seam() {
208        use crate::Severity;
209
210        /// A facet whose severity depends on its state: it goes RED (Error) when a
211        /// load flag is set — the shape a real pane uses (fold over its atoms).
212        struct Loader {
213            failed: bool,
214        }
215        impl Facet for Loader {
216            fn title(&self) -> &str {
217                "loader"
218            }
219            fn ui(&mut self, _ui: &mut Ui) {}
220            fn state_json(&self) -> serde_json::Value {
221                serde_json::json!({ "failed": self.failed })
222            }
223            fn update_json(&mut self, msg_json: &str) {
224                if let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) {
225                    if let Some(b) = v.get("failed").and_then(|b| b.as_bool()) {
226                        self.failed = b;
227                    }
228                }
229            }
230            fn severity(&self) -> Severity {
231                // Fold: the "load failed" atom is the only non-Info atom.
232                crate::worst_severity([if self.failed {
233                    Severity::Error
234                } else {
235                    Severity::Info
236                }])
237            }
238        }
239
240        // Green pane: Info through both the Facet and the Panel seam.
241        let mut p: Box<dyn Panel> = Box::new(Loader { failed: false });
242        assert_eq!(Panel::severity(&*p), Severity::Info, "clean pane is green");
243        assert!(!Panel::severity(&*p).is_error());
244
245        // Drive it into a failure — the Panel seam now reports Error (RED).
246        p.update_json(r#"{"failed": true}"#);
247        assert_eq!(
248            Panel::severity(&*p),
249            Severity::Error,
250            "a driven load failure is RED through the Panel seam (the gate signal)"
251        );
252        assert!(Panel::severity(&*p).is_error(), "this is what FAILS the Robot-UI gate");
253
254        // A default facet that never overrides severity() stays green (additive).
255        let d: Box<dyn Panel> = Box::new(Static { title: "s".into(), items: 1 });
256        assert_eq!(Panel::severity(&*d), Severity::Info, "defaulted facet is green");
257    }
258
259    /// `snapshot` returns just the terminal state after the whole sequence.
260    #[test]
261    fn snapshot_returns_terminal_state() {
262        let mut c = Counter { count: 10, last: None };
263        let final_state = snapshot(&mut c as &mut dyn Panel, [r#"{"add": 4}"#, r#"{"add": 6}"#]);
264        assert_eq!(final_state["count"], 20);
265    }
266
267    /// A heterogeneous deck of `Box<dyn Panel>` from DIFFERENT concrete `Facet`
268    /// types — the compose-uniformly property the whole factoring rests on.
269    #[test]
270    fn box_dyn_panel_deck_is_heterogeneous() {
271        let mut deck: Vec<Box<dyn Panel>> = vec![
272            Box::new(Static { title: "a".into(), items: 1 }),
273            Box::new(Counter { count: 7, last: None }),
274        ];
275        let titles: Vec<String> = deck.iter().map(|p| p.title().to_string()).collect();
276        assert_eq!(titles, vec!["a".to_string(), "counter".to_string()]);
277        // Driving one pane doesn't touch the other.
278        deck[1].update_json(r#"{"add": 3}"#);
279        assert_eq!(deck[1].state_json()["count"], 10);
280        assert_eq!(deck[0].state_json()["items"], 1);
281    }
282}