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}