Skip to main content

brep_app/panels/
expressions.rs

1//! Expressions / parameters panel — the **variable sheet** that drives feature
2//! params. The engine-owned history document carries an `expressions` source
3//! string (a small `name = expr;` DSL) plus a `configurator` object; the pipeline
4//! evaluates it when running features, so a numeric feature field may be the
5//! expression string `"boxW"` — resolved against the variables defined here.
6//!
7//! Following the panel pattern, this owns NO model state: it edits + reads the
8//! engine (`state.expressions_json()` / `state.set_expressions()` /
9//! `state.expression_variables_json()` / `state.configurator_json()`), which write
10//! into the single-source-of-truth `EngineState.history` and re-run the rolled-to
11//! prefix so var-referencing params update live. The panel holds only a transient
12//! editor buffer (mirrored from the engine when unfocused, committed on
13//! Apply / focus-loss) and the per-frame widget hit-rects the headed verifier reads.
14
15use crate::automation::hit_keys::HitKeyDoc;
16use brep_render::engine_state::EngineState;
17use eframe::egui;
18use serde_json::Value;
19use std::collections::HashMap;
20
21/// The expressions panel's transient UI state (the model lives in the engine).
22pub struct ExpressionsPanel {
23    /// The multiline editor buffer. Mirrored from the engine's `expressions`
24    /// whenever the editor is UNFOCUSED (so an Open / undo / redo that changed the
25    /// document reflects here), and committed BACK to the engine on Apply or when
26    /// the editor loses focus.
27    buf: String,
28    /// Per-frame widget hit-rects, published to JS for the headed verifier (wasm).
29    hits: HashMap<String, egui::Rect>,
30}
31
32impl Default for ExpressionsPanel {
33    fn default() -> Self {
34        Self::new()
35    }
36}
37
38impl ExpressionsPanel {
39    pub fn new() -> Self {
40        Self {
41            buf: String::new(),
42            hits: HashMap::new(),
43        }
44    }
45
46    /// Draw the panel under its own collapsing header (matches the sibling panels).
47    pub fn show(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
48        self.hits.clear();
49
50        egui::CollapsingHeader::new("Expressions / parameters")
51            .id_salt("expressions-panel")
52            .default_open(true)
53            .show(ui, |ui| self.body(ui, state));
54    }
55
56    fn body(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
57        ui.label("Variable sheet — feature params may reference these (e.g. a size field set to `boxW`).");
58
59        // --- the editor, bound to the engine-owned `expressions` source --------
60        // Syntax highlighting via egui's BUILT-IN facility
61        // (`egui_extras::syntax_highlighting`). A `layouter` re-lays every
62        // frame: `highlight(...)` returns a colored `LayoutJob`, and the theme
63        // is read from egui memory so it tracks the light/dark visuals. Results
64        // are memoized inside `highlight`, so this is cheap per-frame.
65        //
66        // The language is `"c"`, not `"js"`. We build egui_extras WITHOUT its
67        // `syntect` feature (16 crates natively, 20 on wasm, for this one
68        // call), so `highlight` runs the crate's own fallback lexer, whose
69        // `Language::new` knows only c/cpp, py, rust and toml — `"js"` returns
70        // None there and falls through to unstyled monospace. The sheet is
71        // `name = expr;` lines, so the C lane colours exactly what matters:
72        // `//` comments, strings, numbers, identifiers and punctuation.
73        let mut layouter = |ui: &egui::Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| {
74            let theme =
75                egui_extras::syntax_highlighting::CodeTheme::from_memory(ui.ctx(), ui.style());
76            let mut job = egui_extras::syntax_highlighting::highlight(
77                ui.ctx(),
78                ui.style(),
79                &theme,
80                buf.as_str(),
81                "c",
82            );
83            job.wrap.max_width = wrap_width;
84            ui.fonts_mut(|f| f.layout_job(job))
85        };
86        let editor = ui.add(
87            egui::TextEdit::multiline(&mut self.buf)
88                .id_salt("expressions-editor")
89                .code_editor()
90                .desired_rows(5)
91                .desired_width(f32::INFINITY)
92                .hint_text("boxW = 30;\nboxH = boxW / 2;")
93                .layouter(&mut layouter),
94        );
95        self.hits.insert("expr:editor".into(), editor.rect);
96
97        // --- commit: Apply button OR focus-loss; re-run only when text changed --
98        let apply = ui.button("Apply (re-run history)");
99        self.hits.insert("expr:apply".into(), apply.rect);
100
101        let commit = editor.lost_focus() || apply.clicked();
102        if commit {
103            // Re-run only on a real change (each commit re-runs the rolled-to
104            // prefix); a no-op commit must not thrash the kernel.
105            if self.buf != state.expressions_json() {
106                let _ = state.set_expressions(&self.buf);
107            }
108        } else if !editor.has_focus() {
109            // Not editing → mirror the engine (an Open / New / undo / redo may have
110            // replaced the document out from under the buffer).
111            self.buf = state.expressions_json();
112        }
113
114        // --- parsed variable list (name = defining expression) -----------------
115        ui.separator();
116        ui.label(egui::RichText::new("Variables").strong());
117        let vars: Vec<Value> =
118            serde_json::from_str(&state.expression_variables_json()).unwrap_or_default();
119        if vars.is_empty() {
120            ui.weak("(no variables defined)");
121        } else {
122            egui::Grid::new("expr-vars")
123                .num_columns(2)
124                .striped(true)
125                .show(ui, |ui| {
126                    for v in &vars {
127                        let name = v.get("name").and_then(Value::as_str).unwrap_or("");
128                        let expr = v.get("expr").and_then(Value::as_str).unwrap_or("");
129                        ui.monospace(name);
130                        ui.monospace(format!("= {expr}"));
131                        ui.end_row();
132                    }
133                });
134        }
135
136        // --- configurator (typed named inputs) — read/display; editing deferred -
137        ui.separator();
138        ui.collapsing("Configurator (read-only)", |ui| {
139            let cfg = state.configurator_json();
140            let pretty = serde_json::from_str::<Value>(&cfg)
141                .and_then(|v| serde_json::to_string_pretty(&v))
142                .unwrap_or(cfg);
143            ui.monospace(pretty);
144            ui.weak("Typed named inputs — a deeper configurator editor is deferred.");
145        });
146    }
147
148    /// The published widget hit-rects (egui points) for the headed verifier.
149    pub fn hits_json(&self) -> String {
150        crate::automation::hit_rects::hits_json(&self.hits)
151    }
152}
153
154// BREP private tests: 218eadaab7b230b7
155
156/// The hit keys this panel publishes (see `automation::hit_keys`).
157pub static HIT_KEYS: &[HitKeyDoc] = &[
158    HitKeyDoc { panel: "expr", prefix: "expr:editor", meaning: "the expressions script editor", command: None },
159    HitKeyDoc { panel: "expr", prefix: "expr:apply", meaning: "apply the script and rerun", command: None },
160];