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::automation::hit_keys::HitKeyDoc;
38use crate::form;
39use crate::panels::bom_columns;
40use crate::panels::tree::{self, TreeRow};
41use crate::store::{ModelStore, SETTINGS_KEY};
42use brep_render::engine_state::EngineState;
43use brep_render::style::{settings_form_fields, FormField, RenderSettings};
44use eframe::egui;
45use serde_json::Value;
46use std::collections::{HashMap, HashSet};
47
48/// The amber a BOM-column parse problem is listed in — the status map's
49/// warning amber, the same one the structure tree's outdated badge uses. A
50/// problem is a note about ONE line, not a failure, so it is not error red.
51const PROBLEM_AMBER: egui::Color32 = egui::Color32::from_rgb(0xff, 0x9f, 0x0a);
52
53/// The tabs of the Settings window — one per section. Each draws its own tree;
54/// nothing is shared between them but the window.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum Tab {
57 /// The schema-driven render settings.
58 Display,
59 /// Assembly-wide configuration (today: the BOM's columns).
60 Assemblies,
61}
62
63/// The display-settings panel's own transient UI state. It holds NO model state:
64/// the settings buffer is re-seeded from the live engine each frame (see
65/// [`SettingsPanel::settings_section`]).
66pub struct SettingsPanel {
67 /// Whether the floating window is shown. Toggled by the toolbar gear button
68 /// and by the window's own close (`×`) button; public so the toolbar can bind
69 /// it.
70 pub open: bool,
71 /// Per-frame egui widget screen rects (keyed `tab:<name>` / `field:<key>` /
72 /// `group:<name>` / …), published to JS for the headed verifier. Rebuilt every
73 /// frame, so it holds only the ACTIVE tab's widgets.
74 hits: HashMap<String, egui::Rect>,
75 /// Which tab is shown. Defaults to `Display`, the tab the window has always
76 /// opened on.
77 tab: Tab,
78 /// The `Display settings` root is collapsed (false = open — it defaults open).
79 display_collapsed: bool,
80 /// Setting GROUPS explicitly COLLAPSED, by group name (absent = open — groups
81 /// default open, matching the retired per-group CollapsingHeaders).
82 closed_groups: HashSet<String>,
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}
92
93impl SettingsPanel {
94 /// A fresh panel. The settings buffer is re-seeded from the engine every frame
95 /// (not stored), so construction needs no engine handle.
96 pub fn new() -> Self {
97 Self {
98 open: false,
99 hits: HashMap::new(),
100 tab: Tab::Display,
101 display_collapsed: false,
102 closed_groups: HashSet::new(),
103 assemblies_collapsed: false,
104 bom_columns_buf: None,
105 }
106 }
107
108 /// Draw the floating window (if open) at ctx level — after the panels, like
109 /// the file dialog, so it floats over the shell. The `open` flag is shared with
110 /// the toolbar gear button (which toggles it) and the window's own `×` (which
111 /// closes it). `EngineState` is the single brain, borrowed in.
112 pub fn show(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
113 if self.open {
114 // `egui::Window::open` needs its own `&mut bool`; borrow a copy so the
115 // draw closure can still take `&mut self`, then fold the close back in.
116 let mut open = true;
117 egui::Window::new("Settings")
118 .open(&mut open)
119 .movable(true)
120 .resizable(true)
121 // A bounded default size + a fill ScrollArea (in `body`, under the
122 // tab strip) makes the window FREELY resizable LARGER than its
123 // content: without a filling child egui hugs the window to content
124 // and won't grow.
125 .default_size([320.0, 400.0])
126 // Rest on the right so it floats clear of the left panel; the user
127 // can drag it anywhere.
128 .default_pos([720.0, 56.0])
129 .show(ctx, |ui| self.body(ui, state, store));
130 self.open = open;
131
132 // Publish this frame's widget rects for the headed verifier (parity
133 // with the history + scene panels).
134 if crate::automation::registry::enabled() {
135 crate::automation::registry::publish("__brepSettingsHit", "settings window widget rects (field:*, group:*)", &self.hits_json());
136 }
137 }
138 }
139
140 /// The window body: the tab strip, then the ONE section that tab selects —
141 /// each still built on the shared [`tree`] node helper, so every tab reads as
142 /// the same tree the history + scene panels draw.
143 ///
144 /// The strip sits ABOVE the ScrollArea (rather than inside the one `show` used
145 /// to wrap the whole body in), so the tabs stay put while a long settings tree
146 /// scrolls under them.
147 fn body(&mut self, ui: &mut egui::Ui, state: &mut EngineState, store: &dyn ModelStore) {
148 self.hits.clear();
149
150 // The tab drawn this frame is the one that was active BEFORE the strip.
151 // `selectable_value` switches `self.tab` mid-frame, and a section that
152 // vanishes the same frame it loses the click never gets its focus-loss —
153 // which is how the Assemblies editor COMMITS. Deferring by one frame lets
154 // the editor blur normally (invisible at 60fps, and the difference between
155 // "my BOM columns saved" and "my typing disappeared").
156 let tab = self.tab;
157 ui.horizontal(|ui| {
158 let display = ui.selectable_value(&mut self.tab, Tab::Display, "Display");
159 let assemblies = ui.selectable_value(&mut self.tab, Tab::Assemblies, "Assemblies");
160 self.hits.insert("tab:display".into(), display.rect);
161 self.hits.insert("tab:assemblies".into(), assemblies.rect);
162 });
163 // ...and because of that defer, the switch needs ONE more frame to show
164 // its new section. The shell only requests a repaint while work is
165 // pending (`app.rs`: a run / queries / mesh imports), so on native a
166 // click with no further input would leave the strip highlighting a tab
167 // whose content has not been drawn yet. Ask for that frame here.
168 if self.tab != tab {
169 ui.ctx().request_repaint();
170 }
171 ui.separator();
172
173 egui::ScrollArea::vertical()
174 .auto_shrink([false, false])
175 .show(ui, |ui| {
176 // Tight, tree-like row spacing so connector verticals read
177 // continuously — the same the history + scene trees set (this panel
178 // must match them). Set INSIDE the scroll so the tab strip above
179 // keeps ordinary widget spacing.
180 ui.spacing_mut().item_spacing.y = 2.0;
181 match tab {
182 Tab::Display => self.settings_section(ui, state, store),
183 Tab::Assemblies => self.assemblies_section(ui, state, store),
184 }
185 });
186 }
187
188 /// The `Assemblies` TAB: the BOM's COLUMN CONFIGURATION as one multiline
189 /// textarea, one column per line, a leading `*` for shown.
190 ///
191 /// A hand-written root rather than a schema field: the schema's `FieldKind`s
192 /// are all
193 /// single-widget and render into a tree row's RIGHT-ALIGNED content slot,
194 /// which is exactly the wrong place for a full-width multi-line editor.
195 /// Adding a `TextArea` variant would also force both exhaustive
196 /// `FieldKind` matches open for one consumer.
197 ///
198 /// Commits on focus-LOSS — which includes LEAVING THE TAB, since [`body`]
199 /// draws the pre-click tab for one more frame so this editor is still on
200 /// screen to blur ([`SettingsPanel::body`]) — not per keystroke: this text is
201 /// persisted to the store on commit, and on native that is a rewrite of
202 /// `~/.config/brep-app/settings.json` — per character is a file write per
203 /// character. Parse problems are listed under the editor, naming the line,
204 /// and the text is never rewritten by the panel: a typo costs one column,
205 /// not the configuration.
206 fn assemblies_section(
207 &mut self,
208 ui: &mut egui::Ui,
209 state: &mut EngineState,
210 store: &dyn ModelStore,
211 ) {
212 let open = !self.assemblies_collapsed;
213 let root_resp = tree::node(
214 ui,
215 TreeRow {
216 guides: &[],
217 is_last: true,
218 expandable: true,
219 expanded: open,
220 root: true,
221 glyph: None,
222 label: "Assemblies",
223 selected: false,
224 draggable: false,
225 tint: None,
226 },
227 |_| {},
228 );
229 self.hits.insert("box:__assemblies".into(), root_resp.box_rect);
230 if root_resp.toggled || root_resp.label.clicked() {
231 self.assemblies_collapsed = !self.assemblies_collapsed;
232 }
233 if !open {
234 // Drop the buffer while closed so the next open re-seeds from the
235 // engine (a BOM header drag rewrites this text behind the panel).
236 self.bom_columns_buf = None;
237 return;
238 }
239
240 // Empty stored text means "the shipped default", so the editor shows
241 // the default rather than a blank box the user has to guess at.
242 let stored = bom_columns::effective_text(&state.settings.bom_columns);
243 let buffer = self.bom_columns_buf.get_or_insert_with(|| stored.clone());
244
245 ui.label(egui::RichText::new("BOM columns").strong());
246 ui.label(
247 egui::RichText::new(
248 "One per line, in order. A leading * shows it. \
249 part.<Field> is stored on the part, occurrence.<Field> on one placement. \
250 A line that is just - freezes the columns above it; the rest scroll.",
251 )
252 .weak(),
253 );
254 let editor = ui.add(
255 egui::TextEdit::multiline(buffer)
256 .id_salt("bom-columns-editor")
257 .desired_rows(8)
258 .desired_width(f32::INFINITY)
259 .code_editor(),
260 );
261 self.hits.insert("field:bomColumns".into(), editor.rect);
262
263 if editor.lost_focus() {
264 // Commit: store the text VERBATIM (never the parse's idea of it).
265 let mut settings_json: serde_json::Value =
266 serde_json::from_str(&state.settings_json()).unwrap_or(serde_json::Value::Null);
267 if let Some(object) = settings_json.as_object_mut() {
268 object.insert(
269 "bomColumns".into(),
270 serde_json::Value::String(buffer.clone()),
271 );
272 let json = settings_json.to_string();
273 let _ = state.apply_settings_json(&json);
274 let _ = store.write(SETTINGS_KEY, &json);
275 }
276 } else if !editor.has_focus() && *buffer != stored {
277 // Unfocused and out of step with the engine — the BOM's own header
278 // drag rewrote the configuration. Track it rather than showing a
279 // stale copy the next commit would write back.
280 *buffer = stored;
281 }
282
283 // Parse problems, by line. Listed rather than thrown: the text stands
284 // exactly as typed and every other line still works.
285 let parsed = bom_columns::parse(buffer);
286 if parsed.problems.is_empty() {
287 ui.label(
288 egui::RichText::new(format!(
289 "{} columns, {} shown",
290 parsed.columns.len(),
291 parsed.columns.iter().filter(|column| column.shown).count()
292 ))
293 .weak(),
294 );
295 } else {
296 for problem in &parsed.problems {
297 ui.label(egui::RichText::new(problem).color(PROBLEM_AMBER));
298 }
299 }
300 let reset = ui.button("Reset BOM columns");
301 self.hits.insert("bom-columns:reset".into(), reset.rect);
302 if reset.clicked() {
303 self.bom_columns_buf = None;
304 let mut settings_json: serde_json::Value =
305 serde_json::from_str(&state.settings_json()).unwrap_or(serde_json::Value::Null);
306 if let Some(object) = settings_json.as_object_mut() {
307 // Back to EMPTY, which means "the shipped default" — so a later
308 // change to that default still reaches this user.
309 object.insert("bomColumns".into(), serde_json::Value::String(String::new()));
310 let json = settings_json.to_string();
311 let _ = state.apply_settings_json(&json);
312 let _ = store.write(SETTINGS_KEY, &json);
313 }
314 }
315 ui.add_space(4.0);
316 }
317
318 /// The schema-driven display-settings TREE: a `Display settings` root, one
319 /// collapsible branch per schema group, one leaf per field. Any edit applies to
320 /// `EngineState` (bumps `settings_generation` + `dirty`, so the GPU refreshes)
321 /// and persists through the storage seam.
322 fn settings_section(
323 &mut self,
324 ui: &mut egui::Ui,
325 state: &mut EngineState,
326 store: &dyn ModelStore,
327 ) {
328 // Re-seed a per-frame LOCAL buffer from the LIVE engine settings BEFORE
329 // rendering. The apply below writes the WHOLE buffer, so a buffer kept
330 // across frames would clobber every setting changed elsewhere (the toolbar
331 // wireframe / projection toggles) back to a stale snapshot — the "changing
332 // Render Quality resets my wireframe" bug. A fresh local each frame makes
333 // external changes authoritative and keeps untouched fields a no-op
334 // round-trip (`apply_json`/`to_json` are a documented identity).
335 let mut settings_json: Value =
336 serde_json::from_str(&state.settings_json()).unwrap_or(Value::Null);
337 let fields = settings_form_fields();
338
339 // Group the schema's contiguous same-group runs, preserving order (the
340 // schema lists each group's fields together).
341 let mut groups: Vec<(String, Vec<&FormField>)> = Vec::new();
342 for f in &fields {
343 if let Some(g) = groups.iter_mut().find(|(n, _)| *n == f.group) {
344 g.1.push(f);
345 } else {
346 groups.push((f.group.clone(), vec![f]));
347 }
348 }
349
350 // --- ROOT: `[-] Display settings` (defaults open) ---------------------
351 let root_open = !self.display_collapsed;
352 let root_resp = tree::node(
353 ui,
354 TreeRow {
355 guides: &[],
356 is_last: true,
357 expandable: true,
358 expanded: root_open,
359 root: true,
360 glyph: None,
361 label: "Display settings",
362 selected: false,
363 draggable: false,
364 tint: None,
365 },
366 |_| {},
367 );
368 if root_resp.toggled || root_resp.label.clicked() {
369 self.display_collapsed = !self.display_collapsed;
370 }
371
372 let mut changed = false;
373 if root_open {
374 let n = groups.len();
375 for (gi, (gname, gfields)) in groups.iter().enumerate() {
376 let is_last = gi + 1 == n;
377 let open = !self.closed_groups.contains(gname);
378 let resp = tree::node(ui, TreeRow::branch(&[], is_last, open, gname), |_| {});
379 self.hits.insert(format!("group:{gname}"), resp.box_rect);
380 if resp.toggled || resp.label.clicked() {
381 if open {
382 self.closed_groups.insert(gname.clone());
383 } else {
384 self.closed_groups.remove(gname);
385 }
386 }
387 if !open {
388 continue;
389 }
390 let base = tree::child_guides(&[], is_last);
391 let m = gfields.len();
392 for (fi, &f) in gfields.iter().enumerate() {
393 changed |= self.settings_leaf(ui, f, &mut settings_json, &base, fi + 1 == m);
394 }
395 }
396 }
397
398 // Commit the whole buffer ONCE on any edit (same apply + persist path as
399 // before), so the engine re-runs / the GPU refreshes exactly as it did.
400 if changed {
401 let json = settings_json.to_string();
402 let _ = state.apply_settings_json(&json);
403 let _ = store.write(SETTINGS_KEY, &json);
404 }
405
406 // Reset to defaults — only while the display root is OPEN, matching the
407 // retired CollapsingHeader that hid it when the section was collapsed. It
408 // resets the DISPLAY settings only, which is why it belongs to this tab.
409 if root_open {
410 ui.add_space(2.0);
411 if ui.button("Reset to defaults").clicked() {
412 // Full reset: rebase to defaults, then apply the serialized defaults
413 // (so every key returns, not just the overridden ones) + persist.
414 state.settings = RenderSettings::default();
415 let json = state.settings.to_json();
416 let _ = state.apply_settings_json(&json);
417 let _ = store.write(SETTINGS_KEY, &json);
418 }
419 }
420 }
421
422 /// Render one settings field as a tree LEAF: the field label is the node label;
423 /// its input widget ([`form::field_input`]) fills the row's RIGHT-aligned
424 /// content, exactly like the feature tree's `schema_field`. Settings keys are
425 /// unique across the schema, so no id-stack scoping is needed. Returns whether
426 /// the field changed (the caller commits the whole buffer once).
427 fn settings_leaf(
428 &mut self,
429 ui: &mut egui::Ui,
430 field: &FormField,
431 current: &mut Value,
432 guides: &[bool],
433 is_last: bool,
434 ) -> bool {
435 let mut changed = false;
436 let mut rect = egui::Rect::NOTHING;
437 tree::node(ui, TreeRow::leaf(guides, is_last, &field.label), |ui| {
438 // The tree row's content area is RIGHT-aligned (`right_to_left`), so the
439 // input sits at the panel edge with the label on the left — the feature
440 // tree's exact placement, and the layout `field_input` reads to keep its
441 // inputs COMPACT here. Settings have no reference / button fields, so
442 // `field_input`'s click sink is `None`.
443 let (ch, r) = form::field_input(ui, field, current, None, &mut form::FieldActions::default());
444 changed = ch;
445 rect = r;
446 });
447 self.hits.insert(format!("field:{}", field.key()), rect);
448 changed
449 }
450
451 /// The published widget hit-rects (egui points) for the headed verifier.
452 pub fn hits_json(&self) -> String {
453 crate::automation::hit_rects::hits_json(&self.hits)
454 }
455}
456
457// BREP private tests: fbeefa5a776131c6
458
459/// The hit keys this panel publishes (see `automation::hit_keys`).
460pub static HIT_KEYS: &[HitKeyDoc] = &[
461 HitKeyDoc { panel: "settings", prefix: "field:", meaning: "a settings field by key", command: None },
462 HitKeyDoc { panel: "settings", prefix: "group:", meaning: "a settings group header", command: None },
463 HitKeyDoc { panel: "settings", prefix: "tab:", meaning: "a settings tab (tab:display, tab:assemblies)", command: None },
464 HitKeyDoc { panel: "settings", prefix: "box:", meaning: "expand/collapse a section", command: None },
465 HitKeyDoc { panel: "settings", prefix: "bom-columns:reset", meaning: "reset the BOM columns", command: None },
466];