Skip to main content

brep_app/panels/
settings.rs

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