Skip to main content

brep_app/panels/
settings.rs

1//! Display-settings panel — the schema-driven settings form + the per-solid
2//! metadata color overrides (Phase 1). Drawn as a FLOATING window (movable +
3//! resizable [`egui::Window`], toggled from the toolbar gear ⚙ button), mirroring
4//! the Properties window: a `pub open` flag the toolbar binds + a ctx-level
5//! `show(&mut self, ctx, state, store)` the shell calls after the panels. The
6//! panel OWNS only its transient UI state (which nodes are open, the per-solid
7//! color-picker working values); `EngineState` stays the single brain, borrowed
8//! in.
9//!
10//! # One TAB per section, each drawn as a tree
11//!
12//! The window opens on a tab strip ([`Tab`], the Info window's `selectable_value`
13//! strip) — `Display` / `Assemblies` / `Per-Solid Colors` — and draws exactly ONE
14//! section below it. Each section is still the SAME connector-line `[+]/[-]` tree
15//! the feature history and Scene panels use (the shared [`tree`] node helper), so
16//! the whole app reads as one system:
17//!   * `Display` → `[-] Display settings` (root) → one collapsible BRANCH per
18//!     schema group (`Scene`, `Faces`, `Edges`, …) → one LEAF per field, whose
19//!     node label is the field label and whose right-aligned content is the field
20//!     input ([`form::field_input`], EXACTLY like the feature tree's
21//!     `schema_field`).
22//!   * `Assemblies` → `[-] Assemblies` (root) → the BOM column configuration.
23//!   * `Per-Solid Colors` → `[-] Per-solid colors` (root) → one LEAF per scene
24//!     solid (enable checkbox + color picker in the right slot).
25//! Group open-state is tracked on the panel (default open). Every ROOT defaults
26//! OPEN too: the roots that used to default collapsed did so only because all
27//! three shared one scroll — a tab whose entire content is one `[+]` row is not
28//! worth the click.
29//!
30//! Only the ACTIVE tab's widget rects are published to `__brepSettingsHit`, since
31//! `hits` is rebuilt each frame from what was actually drawn.
32
33use crate::form;
34use crate::panels::bom_columns;
35use crate::panels::tree::{self, TreeRow};
36use crate::store::{ModelStore, SETTINGS_KEY};
37use brep_render::engine_state::EngineState;
38use brep_render::style::{settings_form_fields, FormField, RenderSettings};
39use eframe::egui;
40use serde_json::Value;
41use std::collections::{HashMap, HashSet};
42
43/// The amber a BOM-column parse problem is listed in — the status map's
44/// warning amber, the same one the structure tree's outdated badge uses. A
45/// problem is a note about ONE line, not a failure, so it is not error red.
46const PROBLEM_AMBER: egui::Color32 = egui::Color32::from_rgb(0xff, 0x9f, 0x0a);
47
48/// The three tabs of the Settings window — one per section. Each draws its own
49/// tree; nothing is shared between them but the window.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Tab {
52    /// The schema-driven render settings.
53    Display,
54    /// Assembly-wide configuration (today: the BOM's columns).
55    Assemblies,
56    /// The per-solid metadata color overrides.
57    PerSolid,
58}
59
60/// The display-settings panel's own transient UI state. It holds NO model state:
61/// the settings buffer is re-seeded from the live engine each frame (see
62/// [`SettingsPanel::settings_section`]).
63pub struct SettingsPanel {
64    /// Whether the floating window is shown. Toggled by the toolbar gear button
65    /// and by the window's own close (`×`) button; public so the toolbar can bind
66    /// it.
67    pub open: bool,
68    /// Per-frame egui widget screen rects (keyed `tab:<name>` / `field:<key>` /
69    /// `solid:<name>` / …), published to JS for the headed verifier. Rebuilt every
70    /// frame, so it holds only the ACTIVE tab's widgets.
71    hits: HashMap<String, egui::Rect>,
72    /// Which tab is shown. Defaults to `Display`, the tab the window has always
73    /// opened on.
74    tab: Tab,
75    /// The `Display settings` root is collapsed (false = open — it defaults open).
76    display_collapsed: bool,
77    /// Setting GROUPS explicitly COLLAPSED, by group name (absent = open — groups
78    /// default open, matching the retired per-group CollapsingHeaders).
79    closed_groups: HashSet<String>,
80    /// The `Per-solid colors` root is collapsed (false = open — it defaults open,
81    /// like every other root: it has its own tab, so nothing else is pushed off).
82    per_solid_collapsed: bool,
83    /// The `Assemblies` root is collapsed (false = open — see above).
84    assemblies_collapsed: bool,
85    /// The BOM-columns textarea's live edit buffer. Held here, not re-seeded
86    /// per frame like the settings JSON, because a multi-line editor cannot be
87    /// re-seeded mid-edit without fighting the caret. It tracks the engine
88    /// while UNFOCUSED and commits on focus-loss (the expressions editor's
89    /// rule); `None` = not yet seeded.
90    bom_columns_buf: Option<String>,
91    /// Per-solid color-picker working state (so a live drag keeps its value even
92    /// before it is committed to the scene override).
93    solid_override_edit: HashMap<String, [u8; 3]>,
94}
95
96impl SettingsPanel {
97    /// A fresh panel. The settings buffer is re-seeded from the engine every frame
98    /// (not stored), so construction needs no engine handle.
99    pub fn new() -> Self {
100        Self {
101            open: false,
102            hits: HashMap::new(),
103            tab: Tab::Display,
104            display_collapsed: false,
105            closed_groups: HashSet::new(),
106            per_solid_collapsed: false,
107            solid_override_edit: HashMap::new(),
108            assemblies_collapsed: false,
109            bom_columns_buf: None,
110        }
111    }
112
113    /// Draw the floating window (if open) at ctx level — after the panels, like
114    /// the file dialog, so it floats over the shell. The `open` flag is shared with
115    /// the toolbar gear button (which toggles it) and the window's own `×` (which
116    /// closes it). `EngineState` is the single brain, borrowed in.
117    pub fn show(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
118        if self.open {
119            // `egui::Window::open` needs its own `&mut bool`; borrow a copy so the
120            // draw closure can still take `&mut self`, then fold the close back in.
121            let mut open = true;
122            egui::Window::new("Settings")
123                .open(&mut open)
124                .movable(true)
125                .resizable(true)
126                // A bounded default size + a fill ScrollArea (in `body`, under the
127                // tab strip) makes the window FREELY resizable LARGER than its
128                // content: without a filling child egui hugs the window to content
129                // and won't grow.
130                .default_size([320.0, 400.0])
131                // Rest on the right so it floats clear of the left panel; the user
132                // can drag it anywhere.
133                .default_pos([720.0, 56.0])
134                .show(ctx, |ui| self.body(ui, state, store));
135            self.open = open;
136
137            // Publish this frame's widget rects for the headed verifier (parity
138            // with the history + scene panels).
139            #[cfg(target_arch = "wasm32")]
140            publish("__brepSettingsHit", &self.hits_json());
141        }
142    }
143
144    /// The window body: the tab strip, then the ONE section that tab selects —
145    /// each still built on the shared [`tree`] node helper, so every tab reads as
146    /// the same tree the history + scene panels draw.
147    ///
148    /// The strip sits ABOVE the ScrollArea (rather than inside the one `show` used
149    /// to wrap the whole body in), so the tabs stay put while a long settings tree
150    /// scrolls under them.
151    fn body(&mut self, ui: &mut egui::Ui, state: &mut EngineState, store: &dyn ModelStore) {
152        self.hits.clear();
153
154        // The tab drawn this frame is the one that was active BEFORE the strip.
155        // `selectable_value` switches `self.tab` mid-frame, and a section that
156        // vanishes the same frame it loses the click never gets its focus-loss —
157        // which is how the Assemblies editor COMMITS. Deferring by one frame lets
158        // the editor blur normally (invisible at 60fps, and the difference between
159        // "my BOM columns saved" and "my typing disappeared").
160        let tab = self.tab;
161        ui.horizontal(|ui| {
162            let display = ui.selectable_value(&mut self.tab, Tab::Display, "Display");
163            let assemblies = ui.selectable_value(&mut self.tab, Tab::Assemblies, "Assemblies");
164            let per_solid = ui.selectable_value(&mut self.tab, Tab::PerSolid, "Per-Solid Colors");
165            self.hits.insert("tab:display".into(), display.rect);
166            self.hits.insert("tab:assemblies".into(), assemblies.rect);
167            self.hits.insert("tab:per-solid".into(), per_solid.rect);
168        });
169        // ...and because of that defer, the switch needs ONE more frame to show
170        // its new section. The shell only requests a repaint while work is
171        // pending (`app.rs`: a run / queries / mesh imports), so on native a
172        // click with no further input would leave the strip highlighting a tab
173        // whose content has not been drawn yet. Ask for that frame here.
174        if self.tab != tab {
175            ui.ctx().request_repaint();
176        }
177        ui.separator();
178
179        egui::ScrollArea::vertical()
180            .auto_shrink([false, false])
181            .show(ui, |ui| {
182                // Tight, tree-like row spacing so connector verticals read
183                // continuously — the same the history + scene trees set (this panel
184                // must match them). Set INSIDE the scroll so the tab strip above
185                // keeps ordinary widget spacing.
186                ui.spacing_mut().item_spacing.y = 2.0;
187                match tab {
188                    Tab::Display => self.settings_section(ui, state, store),
189                    Tab::Assemblies => self.assemblies_section(ui, state, store),
190                    Tab::PerSolid => self.per_solid_color_section(ui, state),
191                }
192            });
193    }
194
195    /// The `Assemblies` TAB: the BOM's COLUMN CONFIGURATION as one multiline
196    /// textarea, one column per line, a leading `*` for shown.
197    ///
198    /// A hand-written root rather than a schema field, following the
199    /// `Per-solid colors` precedent: the schema's `FieldKind`s are all
200    /// single-widget and render into a tree row's RIGHT-ALIGNED content slot,
201    /// which is exactly the wrong place for a full-width multi-line editor.
202    /// Adding a `TextArea` variant would also force both exhaustive
203    /// `FieldKind` matches open for one consumer.
204    ///
205    /// Commits on focus-LOSS — which includes LEAVING THE TAB, since [`body`]
206    /// draws the pre-click tab for one more frame so this editor is still on
207    /// screen to blur ([`SettingsPanel::body`]) — not per keystroke: this text is
208    /// persisted to the store on commit, and on native that is a rewrite of
209    /// `~/.config/brep-app/settings.json` — per character is a file write per
210    /// character. Parse problems are listed under the editor, naming the line,
211    /// and the text is never rewritten by the panel: a typo costs one column,
212    /// not the configuration.
213    fn assemblies_section(
214        &mut self,
215        ui: &mut egui::Ui,
216        state: &mut EngineState,
217        store: &dyn ModelStore,
218    ) {
219        let open = !self.assemblies_collapsed;
220        let root_resp = tree::node(
221            ui,
222            TreeRow {
223                guides: &[],
224                is_last: true,
225                expandable: true,
226                expanded: open,
227                root: true,
228                glyph: None,
229                label: "Assemblies",
230                selected: false,
231                draggable: false,
232            },
233            |_| {},
234        );
235        self.hits.insert("box:__assemblies".into(), root_resp.box_rect);
236        if root_resp.toggled || root_resp.label.clicked() {
237            self.assemblies_collapsed = !self.assemblies_collapsed;
238        }
239        if !open {
240            // Drop the buffer while closed so the next open re-seeds from the
241            // engine (a BOM header drag rewrites this text behind the panel).
242            self.bom_columns_buf = None;
243            return;
244        }
245
246        // Empty stored text means "the shipped default", so the editor shows
247        // the default rather than a blank box the user has to guess at.
248        let stored = bom_columns::effective_text(&state.settings.bom_columns);
249        let buffer = self.bom_columns_buf.get_or_insert_with(|| stored.clone());
250
251        ui.label(egui::RichText::new("BOM columns").strong());
252        ui.label(
253            egui::RichText::new(
254                "One per line, in order. A leading * shows it. \
255                 part.<Field> is stored on the part, occurrence.<Field> on one placement. \
256                 A line that is just - freezes the columns above it; the rest scroll.",
257            )
258            .weak(),
259        );
260        let editor = ui.add(
261            egui::TextEdit::multiline(buffer)
262                .id_salt("bom-columns-editor")
263                .desired_rows(8)
264                .desired_width(f32::INFINITY)
265                .code_editor(),
266        );
267        self.hits.insert("field:bomColumns".into(), editor.rect);
268
269        if editor.lost_focus() {
270            // Commit: store the text VERBATIM (never the parse's idea of it).
271            let mut settings_json: serde_json::Value =
272                serde_json::from_str(&state.settings_json()).unwrap_or(serde_json::Value::Null);
273            if let Some(object) = settings_json.as_object_mut() {
274                object.insert(
275                    "bomColumns".into(),
276                    serde_json::Value::String(buffer.clone()),
277                );
278                let json = settings_json.to_string();
279                let _ = state.apply_settings_json(&json);
280                let _ = store.write(SETTINGS_KEY, &json);
281            }
282        } else if !editor.has_focus() && *buffer != stored {
283            // Unfocused and out of step with the engine — the BOM's own header
284            // drag rewrote the configuration. Track it rather than showing a
285            // stale copy the next commit would write back.
286            *buffer = stored;
287        }
288
289        // Parse problems, by line. Listed rather than thrown: the text stands
290        // exactly as typed and every other line still works.
291        let parsed = bom_columns::parse(buffer);
292        if parsed.problems.is_empty() {
293            ui.label(
294                egui::RichText::new(format!(
295                    "{} columns, {} shown",
296                    parsed.columns.len(),
297                    parsed.columns.iter().filter(|column| column.shown).count()
298                ))
299                .weak(),
300            );
301        } else {
302            for problem in &parsed.problems {
303                ui.label(egui::RichText::new(problem).color(PROBLEM_AMBER));
304            }
305        }
306        let reset = ui.button("Reset BOM columns");
307        self.hits.insert("bom-columns:reset".into(), reset.rect);
308        if reset.clicked() {
309            self.bom_columns_buf = None;
310            let mut settings_json: serde_json::Value =
311                serde_json::from_str(&state.settings_json()).unwrap_or(serde_json::Value::Null);
312            if let Some(object) = settings_json.as_object_mut() {
313                // Back to EMPTY, which means "the shipped default" — so a later
314                // change to that default still reaches this user.
315                object.insert("bomColumns".into(), serde_json::Value::String(String::new()));
316                let json = settings_json.to_string();
317                let _ = state.apply_settings_json(&json);
318                let _ = store.write(SETTINGS_KEY, &json);
319            }
320        }
321        ui.add_space(4.0);
322    }
323
324    /// The schema-driven display-settings TREE: a `Display settings` root, one
325    /// collapsible branch per schema group, one leaf per field. Any edit applies to
326    /// `EngineState` (bumps `settings_generation` + `dirty`, so the GPU refreshes)
327    /// and persists through the storage seam.
328    fn settings_section(
329        &mut self,
330        ui: &mut egui::Ui,
331        state: &mut EngineState,
332        store: &dyn ModelStore,
333    ) {
334        // Re-seed a per-frame LOCAL buffer from the LIVE engine settings BEFORE
335        // rendering. The apply below writes the WHOLE buffer, so a buffer kept
336        // across frames would clobber every setting changed elsewhere (the toolbar
337        // wireframe / projection toggles) back to a stale snapshot — the "changing
338        // Render Quality resets my wireframe" bug. A fresh local each frame makes
339        // external changes authoritative and keeps untouched fields a no-op
340        // round-trip (`apply_json`/`to_json` are a documented identity).
341        let mut settings_json: Value =
342            serde_json::from_str(&state.settings_json()).unwrap_or(Value::Null);
343        let fields = settings_form_fields();
344
345        // Group the schema's contiguous same-group runs, preserving order (the
346        // schema lists each group's fields together).
347        let mut groups: Vec<(String, Vec<&FormField>)> = Vec::new();
348        for f in &fields {
349            if let Some(g) = groups.iter_mut().find(|(n, _)| *n == f.group) {
350                g.1.push(f);
351            } else {
352                groups.push((f.group.clone(), vec![f]));
353            }
354        }
355
356        // --- ROOT: `[-] Display settings` (defaults open) ---------------------
357        let root_open = !self.display_collapsed;
358        let root_resp = tree::node(
359            ui,
360            TreeRow {
361                guides: &[],
362                is_last: true,
363                expandable: true,
364                expanded: root_open,
365                root: true,
366                glyph: None,
367                label: "Display settings",
368                selected: false,
369                draggable: false,
370            },
371            |_| {},
372        );
373        if root_resp.toggled || root_resp.label.clicked() {
374            self.display_collapsed = !self.display_collapsed;
375        }
376
377        let mut changed = false;
378        if root_open {
379            let n = groups.len();
380            for (gi, (gname, gfields)) in groups.iter().enumerate() {
381                let is_last = gi + 1 == n;
382                let open = !self.closed_groups.contains(gname);
383                let resp = tree::node(ui, TreeRow::branch(&[], is_last, open, gname), |_| {});
384                self.hits.insert(format!("group:{gname}"), resp.box_rect);
385                if resp.toggled || resp.label.clicked() {
386                    if open {
387                        self.closed_groups.insert(gname.clone());
388                    } else {
389                        self.closed_groups.remove(gname);
390                    }
391                }
392                if !open {
393                    continue;
394                }
395                let base = tree::child_guides(&[], is_last);
396                let m = gfields.len();
397                for (fi, &f) in gfields.iter().enumerate() {
398                    changed |= self.settings_leaf(ui, f, &mut settings_json, &base, fi + 1 == m);
399                }
400            }
401        }
402
403        // Commit the whole buffer ONCE on any edit (same apply + persist path as
404        // before), so the engine re-runs / the GPU refreshes exactly as it did.
405        if changed {
406            let json = settings_json.to_string();
407            let _ = state.apply_settings_json(&json);
408            let _ = store.write(SETTINGS_KEY, &json);
409        }
410
411        // Reset to defaults — only while the display root is OPEN, matching the
412        // retired CollapsingHeader that hid it when the section was collapsed. It
413        // resets the DISPLAY settings only, which is why it belongs to this tab.
414        if root_open {
415            ui.add_space(2.0);
416            if ui.button("Reset to defaults").clicked() {
417                // Full reset: rebase to defaults, then apply the serialized defaults
418                // (so every key returns, not just the overridden ones) + persist.
419                state.settings = RenderSettings::default();
420                let json = state.settings.to_json();
421                let _ = state.apply_settings_json(&json);
422                let _ = store.write(SETTINGS_KEY, &json);
423            }
424        }
425    }
426
427    /// Render one settings field as a tree LEAF: the field label is the node label;
428    /// its input widget ([`form::field_input`]) fills the row's RIGHT-aligned
429    /// content, exactly like the feature tree's `schema_field`. Settings keys are
430    /// unique across the schema, so no id-stack scoping is needed. Returns whether
431    /// the field changed (the caller commits the whole buffer once).
432    fn settings_leaf(
433        &mut self,
434        ui: &mut egui::Ui,
435        field: &FormField,
436        current: &mut Value,
437        guides: &[bool],
438        is_last: bool,
439    ) -> bool {
440        let mut changed = false;
441        let mut rect = egui::Rect::NOTHING;
442        tree::node(ui, TreeRow::leaf(guides, is_last, &field.label), |ui| {
443            // The tree row's content area is RIGHT-aligned (`right_to_left`), so the
444            // input sits at the panel edge with the label on the left — the feature
445            // tree's exact placement, and the layout `field_input` reads to keep its
446            // inputs COMPACT here. Settings have no reference / button fields, so
447            // `field_input`'s click sink is `None`.
448            let (ch, r) = form::field_input(ui, field, current, None, &mut None);
449            changed = ch;
450            rect = r;
451        });
452        self.hits.insert(format!("field:{}", field.key()), rect);
453        changed
454    }
455
456    /// Per-solid metadata color overrides — the "settings ↔ metadata" control, now
457    /// the `Per-Solid Colors` TAB: a `Per-solid colors` root + one LEAF per scene
458    /// solid (the enable checkbox + color picker in the row's right slot).
459    ///
460    /// COLOR PRECEDENCE (final pixel color of a face), highest wins:
461    ///   1. selection / hover emphasis  (Emphasis::face_state → selected/hover)
462    ///   2. per-solid metadata override (this control → SolidDisplay.color_override)
463    ///   3. faceColorMode global        (Uniform faceColor | HashedBySolid)
464    /// (1) is applied in the draw pass; (2)/(3) are resolved in
465    /// `RenderCore::face_base_color`. So a solid recolored here overrides the
466    /// global face color, but a selection still highlights it.
467    fn per_solid_color_section(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
468        let names: Vec<String> = state
469            .scene
470            .solids()
471            .iter()
472            .map(|solid| solid.name.clone())
473            .collect();
474
475        // --- ROOT: `[-] Per-solid colors  <count>` (defaults OPEN) ------------
476        let open = !self.per_solid_collapsed;
477        let root_resp = tree::node(
478            ui,
479            TreeRow {
480                guides: &[],
481                is_last: true,
482                expandable: true,
483                expanded: open,
484                root: true,
485                glyph: None,
486                label: "Per-solid colors",
487                selected: false,
488                draggable: false,
489            },
490            |ui| {
491                ui.add_space(6.0);
492                ui.label(egui::RichText::new(format!("{}", names.len())).weak());
493            },
494        );
495        self.hits.insert("box:__per_solid".into(), root_resp.box_rect);
496        if root_resp.toggled || root_resp.label.clicked() {
497            self.per_solid_collapsed = !self.per_solid_collapsed;
498        }
499        if !open {
500            return;
501        }
502
503        let base = tree::child_guides(&[], true);
504        if names.is_empty() {
505            tree::node(ui, TreeRow::leaf(&base, true, "(no solids)"), |_| {});
506            return;
507        }
508
509        // One deferred mutation per frame — the scene panel's pattern — so no
510        // `&mut state` is held across the draw.
511        let mut color_action: Option<(String, Option<String>)> = None;
512        let m = names.len();
513        for (i, name) in names.iter().enumerate() {
514            let last = i + 1 == m;
515            let current_override = state.scene.solid(name).and_then(|s| s.color_override);
516            let cached = self.solid_override_edit.get(name).copied();
517            let mut enabled = current_override.is_some();
518            let mut rgb = current_override
519                .map(|c| {
520                    [
521                        (c[0] * 255.0).round() as u8,
522                        (c[1] * 255.0).round() as u8,
523                        (c[2] * 255.0).round() as u8,
524                    ]
525                })
526                .or(cached)
527                // A clear, obvious demo red so enabling an override is visible at a
528                // glance overriding the global face color.
529                .unwrap_or([255, 51, 51]);
530            let mut enable_rect = egui::Rect::NOTHING;
531            let mut toggled = false;
532            let mut picker_changed = false;
533            let resp = tree::node(ui, TreeRow::leaf(&base, last, name), |ui| {
534                // right-to-left: the enable checkbox (rightmost, the scene tree's
535                // right-slot convention), then the color picker to its left when
536                // enabled.
537                let cb = ui.add(egui::Checkbox::new(&mut enabled, ""));
538                enable_rect = cb.rect;
539                toggled = cb.changed();
540                if enabled {
541                    picker_changed = ui.color_edit_button_srgb(&mut rgb).changed();
542                }
543            });
544            self.hits.insert(format!("solid:{name}"), resp.label.rect);
545            self.hits.insert(format!("solid-enable:{name}"), enable_rect);
546            if enabled {
547                self.solid_override_edit.insert(name.clone(), rgb);
548                if toggled || picker_changed {
549                    let hex = format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]);
550                    color_action = Some((name.clone(), Some(hex)));
551                }
552            } else if toggled {
553                color_action = Some((name.clone(), None));
554            }
555        }
556
557        if let Some((name, hex)) = color_action {
558            state.set_color_override(&name, hex.as_deref());
559        }
560    }
561
562    /// The published widget hit-rects (egui points) for the headed verifier.
563    #[cfg(target_arch = "wasm32")]
564    pub fn hits_json(&self) -> String {
565        let map: serde_json::Map<String, Value> = self
566            .hits
567            .iter()
568            .map(|(k, r)| {
569                (
570                    k.clone(),
571                    serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]),
572                )
573            })
574            .collect();
575        Value::Object(map).to_string()
576    }
577}
578
579/// Mirror a JSON string to `window.<name>` (wasm/verification only).
580#[cfg(target_arch = "wasm32")]
581fn publish(name: &str, json: &str) {
582    if let Some(win) = web_sys::window() {
583        let _ = js_sys::Reflect::set(
584            &win,
585            &wasm_bindgen::JsValue::from_str(name),
586            &wasm_bindgen::JsValue::from_str(json),
587        );
588    }
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594    use std::cell::RefCell;
595    use std::collections::HashMap as Map;
596
597    /// An in-memory settings store so a round-trip test never touches the config
598    /// dir. `save` is `&self` (the trait's contract), so `RefCell` suffices.
599    #[derive(Default)]
600    struct MemStore {
601        map: RefCell<Map<String, String>>,
602    }
603    impl ModelStore for MemStore {
604        fn read(&self, key: &str) -> Option<String> {
605            self.map.borrow().get(key).cloned()
606        }
607        fn write(&self, key: &str, val: &str) -> Result<(), String> {
608            self.map.borrow_mut().insert(key.to_string(), val.to_string());
609            Ok(())
610        }
611    }
612
613    /// Run ONE headless frame of the settings BODY on a plain Ui (skipping the
614    /// floating Window geometry, which would clip on a small test screen), feeding
615    /// `events` as this frame's input. Layout is deterministic, so the `hits` rects
616    /// are stable frame-to-frame and can be read back to drive a real click.
617    fn run_frame(
618        ctx: &egui::Context,
619        panel: &mut SettingsPanel,
620        state: &mut EngineState,
621        store: &dyn ModelStore,
622        events: Vec<egui::Event>,
623    ) {
624        let raw = egui::RawInput {
625            screen_rect: Some(egui::Rect::from_min_size(
626                egui::pos2(0.0, 0.0),
627                egui::vec2(400.0, 800.0),
628            )),
629            events,
630            ..Default::default()
631        };
632        let _ = ctx.run_ui(raw, |ui| panel.body(ui, state, store));
633    }
634
635    /// Left-click at `pos` split across a press frame and a release frame (egui
636    /// fires `clicked()` on release), re-running the body each frame so the deferred
637    /// apply/persist happens.
638    fn click_at(
639        ctx: &egui::Context,
640        panel: &mut SettingsPanel,
641        state: &mut EngineState,
642        store: &dyn ModelStore,
643        pos: egui::Pos2,
644    ) {
645        run_frame(
646            ctx,
647            panel,
648            state,
649            store,
650            vec![
651                egui::Event::PointerMoved(pos),
652                egui::Event::PointerButton {
653                    pos,
654                    button: egui::PointerButton::Primary,
655                    pressed: true,
656                    modifiers: egui::Modifiers::default(),
657                },
658            ],
659        );
660        run_frame(
661            ctx,
662            panel,
663            state,
664            store,
665            vec![egui::Event::PointerButton {
666                pos,
667                button: egui::PointerButton::Primary,
668                pressed: false,
669                modifiers: egui::Modifiers::default(),
670            }],
671        );
672    }
673
674    /// STRUCTURAL: every schema field renders as a tree LEAF (a `field:<key>` hit
675    /// rect). Because a collapsed group would omit its leaves, this also proves
676    /// every GROUP defaults OPEN — i.e. the whole settings form is drawn as tree
677    /// nodes (the gate's headless "groups render as tree nodes" assertion).
678    #[test]
679    fn settings_tree_renders_every_field_as_a_leaf() {
680        let ctx = egui::Context::default();
681        let mut state = EngineState::new();
682        let mut panel = SettingsPanel::new();
683        let store = MemStore::default();
684
685        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
686
687        for field in settings_form_fields() {
688            let key = format!("field:{}", field.key());
689            assert!(
690                panel.hits.contains_key(&key),
691                "missing tree leaf for settings field {key}; have {:?}",
692                panel.hits.keys().collect::<Vec<_>>()
693            );
694        }
695    }
696
697    /// BEHAVIORAL: clicking a Bool field's checkbox in the tree reaches the SAME
698    /// apply path as before — the engine setting flips AND the whole settings JSON
699    /// is persisted through the store.
700    #[test]
701    fn settings_tree_field_edit_applies_and_persists() {
702        let ctx = egui::Context::default();
703        let mut state = EngineState::new();
704        let mut panel = SettingsPanel::new();
705        let store = MemStore::default();
706
707        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
708        assert!(!state.settings.wireframe, "wireframe starts off");
709        let rect = *panel
710            .hits
711            .get("field:wireframe")
712            .expect("wireframe leaf checkbox rect");
713
714        click_at(&ctx, &mut panel, &mut state, &store, rect.center());
715
716        assert!(
717            state.settings.wireframe,
718            "clicking the checkbox flips the engine setting through apply_settings_json"
719        );
720        let saved: Value = serde_json::from_str(
721            &store.read(SETTINGS_KEY).expect("edit persisted through the store"),
722        )
723        .unwrap();
724        assert_eq!(
725            saved["wireframe"],
726            Value::Bool(true),
727            "the whole settings JSON is persisted with the edit"
728        );
729    }
730
731    /// BEHAVIORAL, the model-overlay LABEL SCALE end to end through the panel:
732    /// dragging the "Label scale" slider reaches the engine (so the labels resize
733    /// LIVE — `viewport::labels` reads `settings.label_scale` every frame) AND the
734    /// whole settings JSON is written to the store under `@settings`, which is what
735    /// carries the setting across a document load and an app restart.
736    ///
737    /// This is the panel-side half of the coverage; `viewport::labels`' own tests
738    /// pin that the font, the measured edit-box width and the chip padding all
739    /// follow the value once it arrives.
740    #[test]
741    fn label_scale_slider_applies_to_the_engine_and_persists() {
742        let ctx = egui::Context::default();
743        let mut state = EngineState::new();
744        let mut panel = SettingsPanel::new();
745        let store = MemStore::default();
746
747        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
748        assert_eq!(
749            state.settings.label_scale, 1.0,
750            "labels start at their native size"
751        );
752        let rect = *panel
753            .hits
754            .get("field:labelScale")
755            .expect("the schema-driven panel renders a `Label scale` leaf");
756
757        // The published rect spans the whole `egui::Slider`, whose RIGHT portion is
758        // the value box — press on the rail in its left third and drag along it, so
759        // the value lands somewhere other than the 1.0 default regardless of which
760        // exact pixel the rail starts at.
761        let y = rect.center().y;
762        let from = egui::pos2(rect.left() + rect.width() * 0.15, y);
763        let to = egui::pos2(rect.left() + rect.width() * 0.45, y);
764        run_frame(
765            &ctx,
766            &mut panel,
767            &mut state,
768            &store,
769            vec![
770                egui::Event::PointerMoved(from),
771                egui::Event::PointerButton {
772                    pos: from,
773                    button: egui::PointerButton::Primary,
774                    pressed: true,
775                    modifiers: egui::Modifiers::default(),
776                },
777            ],
778        );
779        run_frame(
780            &ctx,
781            &mut panel,
782            &mut state,
783            &store,
784            vec![egui::Event::PointerMoved(to)],
785        );
786        run_frame(
787            &ctx,
788            &mut panel,
789            &mut state,
790            &store,
791            vec![egui::Event::PointerButton {
792                pos: to,
793                button: egui::PointerButton::Primary,
794                pressed: false,
795                modifiers: egui::Modifiers::default(),
796            }],
797        );
798
799        let applied = state.settings.label_scale;
800        assert_ne!(
801            applied, 1.0,
802            "dragging the slider must reach the engine through apply_settings_json"
803        );
804        // The slider domain is the engine's clamp, so a dragged value is always usable.
805        assert!(
806            (0.25..=3.0).contains(&applied),
807            "label scale stays inside the clamped domain, got {applied}"
808        );
809
810        // ...and the edit is persisted, so it survives a reload / restart.
811        let saved: Value = serde_json::from_str(
812            &store.read(SETTINGS_KEY).expect("the edit persisted through the store"),
813        )
814        .unwrap();
815        let stored = saved["labelScale"].as_f64().expect("labelScale is persisted") as f32;
816        assert_eq!(stored, applied, "the persisted value matches the live one");
817
818        // A fresh engine restored from that JSON comes back with the same scale —
819        // the actual restart path (`app.rs` reads `@settings` and applies it).
820        let mut restored = EngineState::new();
821        restored
822            .apply_settings_json(&saved.to_string())
823            .expect("the persisted settings re-apply");
824        assert_eq!(
825            restored.settings.label_scale, applied,
826            "the label scale survives a reload of the persisted settings"
827        );
828    }
829
830    /// RE-SEED GUARD: the per-frame re-seed keeps EXTERNAL setting changes
831    /// authoritative — a value changed outside the panel (a toolbar toggle) must
832    /// survive an UNRELATED edit made through the tree. This pins the "changing
833    /// Render Quality resets my wireframe" fix.
834    #[test]
835    fn settings_tree_edit_preserves_externally_changed_setting() {
836        let ctx = egui::Context::default();
837        let mut state = EngineState::new();
838        let mut panel = SettingsPanel::new();
839        let store = MemStore::default();
840
841        // Lay out once, then externally flip wireframe on (as the toolbar would —
842        // straight onto the engine, bypassing this panel's buffer).
843        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
844        state.settings.wireframe = true;
845        assert!(state.settings.wireframe);
846
847        // Edit a DIFFERENT field through the tree (toggle flatShading).
848        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
849        let before = state.settings.flat_shading;
850        let rect = *panel
851            .hits
852            .get("field:flatShading")
853            .expect("flatShading leaf checkbox rect");
854
855        click_at(&ctx, &mut panel, &mut state, &store, rect.center());
856
857        assert_ne!(
858            state.settings.flat_shading, before,
859            "the tree edit took effect"
860        );
861        assert!(
862            state.settings.wireframe,
863            "the externally-set wireframe survived the unrelated tree edit (re-seed intact)"
864        );
865    }
866
867    /// The Assemblies TAB, reached by CLICKING the published tab-strip rect (so
868    /// the strip itself is pinned, not just the field it sets): the window opens
869    /// on `Display`, the tab switch swaps which section is drawn, and the
870    /// BOM-columns textarea is seeded with the SHIPPED default (never a blank
871    /// box). It commits on focus-loss — not per keystroke, since each commit is a
872    /// settings file write — and persists the text VERBATIM so a malformed line
873    /// survives.
874    #[test]
875    fn assemblies_tab_edits_bom_columns_and_persists_verbatim() {
876        let ctx = egui::Context::default();
877        let mut panel = SettingsPanel::new();
878        let mut state = EngineState::new();
879        let store = MemStore::default();
880
881        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
882        assert!(
883            panel.hits.contains_key("field:wireframe"),
884            "the window opens on the Display tab"
885        );
886        assert!(
887            !panel.hits.contains_key("field:bomColumns"),
888            "...which does not draw the BOM editor — it lives on its own tab now"
889        );
890
891        let tab = *panel.hits.get("tab:assemblies").expect("tab strip rect");
892        click_at(&ctx, &mut panel, &mut state, &store, tab.center());
893        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
894        assert!(
895            !panel.hits.contains_key("field:wireframe"),
896            "one tab at a time — the display tree is gone"
897        );
898        assert!(
899            panel.hits.contains_key("box:__assemblies"),
900            "and the Assemblies root draws, OPEN (a tab holding one [+] row is \
901             not worth the click)"
902        );
903        let editor = *panel
904            .hits
905            .get("field:bomColumns")
906            .expect("the textarea draws");
907        assert_eq!(
908            panel.bom_columns_buf.as_deref(),
909            Some(bom_columns::default_text().as_str()),
910            "seeded with the shipped default, not a blank box"
911        );
912
913        // Type into it: focus, replace the text, and confirm NOTHING is
914        // persisted until the focus leaves.
915        click_at(&ctx, &mut panel, &mut state, &store, editor.center());
916        panel.bom_columns_buf = Some("*part.Part_Number\nnonsense\n".into());
917        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
918        assert_eq!(
919            state.settings.bom_columns, "",
920            "typing alone writes neither the engine nor the store"
921        );
922        // The parse problem is surfaced while typing, though — it names the line.
923        let parsed = bom_columns::parse(panel.bom_columns_buf.as_deref().unwrap());
924        assert_eq!(parsed.problems.len(), 1);
925        assert!(parsed.problems[0].starts_with("line 2:"));
926
927        // Click away → focus lost → commit, VERBATIM (the bad line included).
928        let root = *panel.hits.get("box:__assemblies").expect("root box rect");
929        click_at(&ctx, &mut panel, &mut state, &store, root.center());
930        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
931        assert_eq!(
932            state.settings.bom_columns, "*part.Part_Number\nnonsense\n",
933            "committed exactly as typed"
934        );
935        let persisted: Value =
936            serde_json::from_str(&store.read(SETTINGS_KEY).expect("settings persisted")).unwrap();
937        assert_eq!(persisted["bomColumns"], "*part.Part_Number\nnonsense\n");
938    }
939
940    /// Reset puts the setting back to EMPTY — which MEANS "the shipped
941    /// default" — rather than pasting today's default in as literal text, so a
942    /// later change to that default still reaches the user.
943    #[test]
944    fn resetting_bom_columns_stores_empty_not_a_copy_of_the_default() {
945        let ctx = egui::Context::default();
946        let mut panel = SettingsPanel::new();
947        let mut state = EngineState::new();
948        let store = MemStore::default();
949        state
950            .apply_settings_json(r##"{"bomColumns": "*part.Mass\n"}"##)
951            .unwrap();
952
953        panel.tab = Tab::Assemblies;
954        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
955        assert_eq!(panel.bom_columns_buf.as_deref(), Some("*part.Mass\n"));
956        let reset = *panel.hits.get("bom-columns:reset").expect("reset button");
957        click_at(&ctx, &mut panel, &mut state, &store, reset.center());
958
959        assert_eq!(state.settings.bom_columns, "", "back to the shipped default");
960        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
961        assert_eq!(
962            panel.bom_columns_buf.as_deref(),
963            Some(bom_columns::default_text().as_str()),
964            "and the editor re-seeds from it"
965        );
966    }
967
968    /// Switching tabs MID-EDIT still commits the BOM text. The editor commits on
969    /// `lost_focus()`, which can only fire on a frame the editor is DRAWN — so
970    /// `body` draws the tab that was active BEFORE the strip. Drop that one-frame
971    /// defer and the section disappears on the very frame the click steals focus:
972    /// egui drops the focus silently, no commit runs, and the next visit
973    /// re-seeds the buffer from the engine — the user's typing is simply gone.
974    #[test]
975    fn switching_tabs_mid_edit_commits_the_bom_text() {
976        let ctx = egui::Context::default();
977        let mut panel = SettingsPanel::new();
978        let mut state = EngineState::new();
979        let store = MemStore::default();
980        panel.tab = Tab::Assemblies;
981
982        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
983        let editor = *panel.hits.get("field:bomColumns").expect("the editor draws");
984        click_at(&ctx, &mut panel, &mut state, &store, editor.center());
985        panel.bom_columns_buf = Some("*part.Mass\n".into());
986        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
987        assert_eq!(
988            state.settings.bom_columns, "",
989            "typing alone commits nothing (a commit is a settings file write)"
990        );
991
992        let display_tab = *panel.hits.get("tab:display").expect("tab strip rect");
993        click_at(&ctx, &mut panel, &mut state, &store, display_tab.center());
994        assert_eq!(
995            state.settings.bom_columns, "*part.Mass\n",
996            "leaving the tab blurred the editor, which committed the edit"
997        );
998        assert_eq!(
999            store.read(SETTINGS_KEY).map(|json| {
1000                serde_json::from_str::<Value>(&json).unwrap()["bomColumns"].clone()
1001            }),
1002            Some(Value::String("*part.Mass\n".into())),
1003            "...and persisted it"
1004        );
1005
1006        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
1007        assert!(
1008            panel.hits.contains_key("field:wireframe"),
1009            "and the Display tab is what is drawn now"
1010        );
1011    }
1012
1013    /// The third tab: `Per-Solid Colors` draws its root, OPEN, with nothing else
1014    /// beside it. (`EngineState::new()` has no solids, so the tree is its empty
1015    /// leaf — the root is what this pins.)
1016    #[test]
1017    fn per_solid_tab_draws_its_tree_alone_and_open() {
1018        let ctx = egui::Context::default();
1019        let mut panel = SettingsPanel::new();
1020        let mut state = EngineState::new();
1021        let store = MemStore::default();
1022
1023        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
1024        let tab = *panel.hits.get("tab:per-solid").expect("tab strip rect");
1025        click_at(&ctx, &mut panel, &mut state, &store, tab.center());
1026        run_frame(&ctx, &mut panel, &mut state, &store, vec![]);
1027
1028        assert!(
1029            panel.hits.contains_key("box:__per_solid"),
1030            "the per-solid root draws, open; have {:?}",
1031            panel.hits.keys().collect::<Vec<_>>()
1032        );
1033        assert!(
1034            !panel.hits.contains_key("field:wireframe")
1035                && !panel.hits.contains_key("field:bomColumns"),
1036            "and neither other section is drawn beside it"
1037        );
1038    }
1039}