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}