Skip to main content

brep_app/panels/
settings.rs

1//! Display-settings panel — the schema-driven settings form. Drawn as a
2//! FLOATING window (movable + resizable [`egui::Window`], toggled from the
3//! toolbar gear ⚙ button), mirroring the Properties window: a `pub open` flag the
4//! toolbar binds + a ctx-level `show(&mut self, ctx, state, store)` the shell
5//! calls after the panels. The panel OWNS only its transient UI state (which
6//! nodes are open); `EngineState` stays the single brain, borrowed in.
7//!
8//! # Model colours are NOT set here
9//!
10//! A body's or face's colour is a durable `color` METADATA attribute, set in the
11//! Info window and saved with the document. This panel carries only the display
12//! switch over it — `Faces ▸ Override model colors`, an ordinary schema `Bool`
13//! that makes the viewport ignore those colours without touching them. The old
14//! `Per-Solid Colors` tab wrote a transient override that no document ever
15//! stored and any feature edit threw away; it is gone.
16//!
17//! # One TAB per section, each drawn as a tree
18//!
19//! The window opens on a tab strip ([`Tab`], the Info window's `selectable_value`
20//! strip) — `Display` / `Assemblies` — and draws exactly ONE section below it. Each section is still the SAME connector-line `[+]/[-]` tree
21//! the feature history and Scene panels use (the shared [`tree`] node helper), so
22//! the whole app reads as one system:
23//!   * `Display` → `[-] Display settings` (root) → one collapsible BRANCH per
24//!     schema group (`Scene`, `Faces`, `Edges`, …) → one LEAF per field, whose
25//!     node label is the field label and whose right-aligned content is the field
26//!     input ([`form::field_input`], EXACTLY like the feature tree's
27//!     `schema_field`).
28//!   * `Assemblies` → `[-] Assemblies` (root) → the BOM column configuration.
29//! Group open-state is tracked on the panel (default open). Every ROOT defaults
30//! OPEN too: the roots that used to default collapsed did so only because they
31//! shared one scroll — a tab whose entire content is one `[+]` row is not
32//! worth the click.
33//!
34//! Only the ACTIVE tab's widget rects are published to `__brepSettingsHit`, since
35//! `hits` is rebuilt each frame from what was actually drawn.
36
37use crate::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];