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    /// The PLM this session is on or configured for: sign-in, check, forget
62    /// (plan S1 part 3). Present only when the store reports a PLM.
63    Plm,
64}
65
66/// The display-settings panel's own transient UI state. It holds NO model state:
67/// the settings buffer is re-seeded from the live engine each frame (see
68/// [`SettingsPanel::settings_section`]).
69pub struct SettingsPanel {
70    /// Whether the floating window is shown. Toggled by the toolbar gear button
71    /// and by the window's own close (`×`) button; public so the toolbar can bind
72    /// it.
73    pub open: bool,
74    /// Per-frame egui widget screen rects (keyed `tab:<name>` / `field:<key>` /
75    /// `group:<name>` / …), published to JS for the headed verifier. Rebuilt every
76    /// frame, so it holds only the ACTIVE tab's widgets.
77    hits: HashMap<String, egui::Rect>,
78    /// Which tab is shown. Defaults to `Display`, the tab the window has always
79    /// opened on.
80    tab: Tab,
81    /// The `Display settings` root is collapsed (false = open — it defaults open).
82    display_collapsed: bool,
83    /// Setting GROUPS explicitly COLLAPSED, by group name (absent = open — groups
84    /// default open, matching the retired per-group CollapsingHeaders).
85    closed_groups: HashSet<String>,
86    /// The `Assemblies` root is collapsed (false = open — see above).
87    assemblies_collapsed: bool,
88    /// The BOM-columns textarea's live edit buffer. Held here, not re-seeded
89    /// per frame like the settings JSON, because a multi-line editor cannot be
90    /// re-seeded mid-edit without fighting the caret. It tracks the engine
91    /// while UNFOCUSED and commits on focus-loss (the expressions editor's
92    /// rule); `None` = not yet seeded.
93    bom_columns_buf: Option<String>,
94    /// The PLM tab's state, built when the store first reports a PLM and
95    /// rebuilt if its URL changes. `None` in a file-only session.
96    plm: Option<crate::plm::connection::PlmConnection>,
97    /// Set by the app every frame: why this session cannot switch to the PLM
98    /// live (open named or dirty documents), or `None` when it can.
99    pub reconnect_blocker: Option<String>,
100    /// Connect now was pressed: the config root the app switches to.
101    reconnect_request: Option<std::path::PathBuf>,
102}
103
104impl SettingsPanel {
105    /// Open the window on its PLM tab ("Open in CAD" before signing in): the
106    /// tab exists while the store reports a PLM, signed in or not.
107    pub fn show_plm_tab(&mut self) {
108        self.open = true;
109        self.tab = Tab::Plm;
110    }
111
112    /// A fresh panel. The settings buffer is re-seeded from the engine every frame
113    /// (not stored), so construction needs no engine handle.
114    pub fn new() -> Self {
115        Self {
116            open: false,
117            hits: HashMap::new(),
118            tab: Tab::Display,
119            display_collapsed: false,
120            closed_groups: HashSet::new(),
121            assemblies_collapsed: false,
122            bom_columns_buf: None,
123            plm: None,
124            reconnect_blocker: None,
125            reconnect_request: None,
126        }
127    }
128
129    /// Draw the floating window (if open) at ctx level — after the panels, like
130    /// the file dialog, so it floats over the shell. The `open` flag is shared with
131    /// the toolbar gear button (which toggles it) and the window's own `×` (which
132    /// closes it). `EngineState` is the single brain, borrowed in.
133    pub fn show(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
134        if self.open {
135            // `egui::Window::open` needs its own `&mut bool`; borrow a copy so the
136            // draw closure can still take `&mut self`, then fold the close back in.
137            let mut open = true;
138            egui::Window::new("Settings")
139                .open(&mut open)
140                .movable(true)
141                .resizable(true)
142                // A bounded default size + a fill ScrollArea (in `body`, under the
143                // tab strip) makes the window FREELY resizable LARGER than its
144                // content: without a filling child egui hugs the window to content
145                // and won't grow.
146                .default_size([460.0, 480.0])
147                // Rest on the right so it floats clear of the left panel; the user
148                // can drag it anywhere.
149                .default_pos(super::below_chrome(ctx, 720.0, 0.0))
150                .show(ctx, |ui| self.body(ui, state, store));
151            self.open = open;
152        } else {
153            // A CLOSED window publishes no rects. The publish used to sit inside
154            // the branch above, so the last open frame's map stayed in the
155            // registry for the rest of the session: `hit_rects settings/` kept
156            // answering with fifty fields of a window that was not on screen, and
157            // `click_widget` on one of them aimed a click at empty space. Same
158            // rule as the workbench strip and the sketch overlay, which publish an
159            // empty map while they are hidden.
160            self.hits.clear();
161        }
162
163        // Publish this frame's widget rects for the headed verifier (parity
164        // with the history + scene panels) — empty while the window is closed.
165        if crate::automation::registry::enabled() {
166            crate::automation::registry::publish("__brepSettingsHit", "settings window widget rects (field:*, group:*, panel:clip); empty while the window is closed", &self.hits_json());
167            crate::automation::registry::publish("__brepPlm", "the Settings PLM tab: {tab, status, url, username, reason, busy, outcome, failed}; tab false (and the rest null) in a file-only session", &self.plm_json(store));
168        }
169    }
170
171    /// The window body: the tab strip, then the ONE section that tab selects —
172    /// each still built on the shared [`tree`] node helper, so every tab reads as
173    /// the same tree the history + scene panels draw.
174    ///
175    /// The strip sits ABOVE the ScrollArea (rather than inside the one `show` used
176    /// to wrap the whole body in), so the tabs stay put while a long settings tree
177    /// scrolls under them.
178    fn body(&mut self, ui: &mut egui::Ui, state: &mut EngineState, store: &dyn ModelStore) {
179        self.hits.clear();
180        // The window's VISIBLE region, under the same key every scrolling pane
181        // publishes. The Display tree is longer than the window, and egui hands
182        // out a layout rect for a row that is scrolled past the bottom edge — a
183        // click there lands outside the window's clip and does nothing at all,
184        // which reads as "the switch is broken" rather than "the switch is off
185        // screen". With the region published, `scroll_into_view` wheels the row
186        // up first. Taken HERE rather than inside the scroll area so the tab
187        // strip above it counts as visible too: a clip that excluded the strip
188        // would send a click on `tab:assemblies` scrolling forever looking for
189        // a widget that never moves.
190        self.hits.insert("panel:clip".into(), ui.clip_rect());
191
192        // The tab drawn this frame is the one that was active BEFORE the strip.
193        // `selectable_value` switches `self.tab` mid-frame, and a section that
194        // vanishes the same frame it loses the click never gets its focus-loss —
195        // which is how the Assemblies editor COMMITS. Deferring by one frame lets
196        // the editor blur normally (invisible at 60fps, and the difference between
197        // "my BOM columns saved" and "my typing disappeared").
198        // The PLM tab exists only while the store reports a PLM: a file-only
199        // session has no such tab at all.
200        self.sync_plm(store.plm_session());
201        if self.plm.is_none() && self.tab == Tab::Plm {
202            self.tab = Tab::Display;
203        }
204        let tab = self.tab;
205        ui.horizontal(|ui| {
206            let display = ui.selectable_value(&mut self.tab, Tab::Display, "Display");
207            let assemblies = ui.selectable_value(&mut self.tab, Tab::Assemblies, "Assemblies");
208            self.hits.insert("tab:display".into(), display.rect);
209            self.hits.insert("tab:assemblies".into(), assemblies.rect);
210            if self.plm.is_some() {
211                let plm = ui.selectable_value(&mut self.tab, Tab::Plm, "PLM");
212                self.hits.insert("tab:plm".into(), plm.rect);
213            }
214        });
215        // ...and because of that defer, the switch needs ONE more frame to show
216        // its new section. The shell only requests a repaint while work is
217        // pending (`app.rs`: a run / queries / mesh imports), so on native a
218        // click with no further input would leave the strip highlighting a tab
219        // whose content has not been drawn yet. Ask for that frame here.
220        if self.tab != tab {
221            ui.ctx().request_repaint();
222        }
223        ui.separator();
224
225        egui::ScrollArea::vertical()
226            .auto_shrink([false, false])
227            .show(ui, |ui| {
228                // Tight, tree-like row spacing so connector verticals read
229                // continuously — the same the history + scene trees set (this panel
230                // must match them). Set INSIDE the scroll so the tab strip above
231                // keeps ordinary widget spacing.
232                ui.spacing_mut().item_spacing.y = 2.0;
233                match tab {
234                    Tab::Display => self.settings_section(ui, state, store),
235                    Tab::Assemblies => self.assemblies_section(ui, state, store),
236                    Tab::Plm => self.plm_section(ui),
237                }
238            });
239    }
240
241    /// The `__brepPlm` state. Read from the store whether or not the window is
242    /// open, so a script can see the session's PLM without opening Settings.
243    fn plm_json(&mut self, store: &dyn ModelStore) -> String {
244        use crate::plm::connection::{Outcome, Status};
245        self.sync_plm(store.plm_session());
246        let Some(plm) = self.plm.as_mut() else {
247            return serde_json::json!({ "tab": false, "status": null }).to_string();
248        };
249        plm.poll();
250        let (status, url, username, reason) = match &plm.status {
251            Status::Connected { url, username } => ("connected", url.clone(), Some(username.clone()), None),
252            Status::NotConnected { url, reason } => ("not-connected", url.clone(), None, reason.clone()),
253        };
254        let (outcome, failed) = match &plm.last {
255            Some(Outcome::Done(text)) => (Some(text.clone()), false),
256            Some(Outcome::Failed(text)) => (Some(text.clone()), true),
257            None => (None, false),
258        };
259        serde_json::json!({
260            "tab": true, "status": status, "url": url, "username": username, "reason": reason,
261            "busy": plm.busy(), "outcome": outcome, "failed": failed,
262            "connectable": plm.connectable(), "reconnectBlocker": self.reconnect_blocker,
263        })
264        .to_string()
265    }
266
267    /// Connect now's request, for the app to act on (once).
268    pub fn take_reconnect_request(&mut self) -> Option<std::path::PathBuf> {
269        self.reconnect_request.take()
270    }
271
272    /// What the app's switch did: shown in the tab as its outcome.
273    pub fn reconnected(&mut self, outcome: Result<String, String>) {
274        if let Some(plm) = self.plm.as_mut() {
275            plm.reconnected(outcome);
276        }
277    }
278
279    /// Keep the PLM tab's state in step with what the store reports.
280    fn sync_plm(&mut self, session: Option<crate::plm::connection::Session>) {
281        use crate::plm::connection::PlmConnection;
282        let Some(session) = session else {
283            self.plm = None;
284            return;
285        };
286        let url = match &session.status {
287            crate::plm::connection::Status::Connected { url, .. } | crate::plm::connection::Status::NotConnected { url, .. } => url.clone(),
288        };
289        let same = self.plm.as_ref().is_some_and(|c| c.url() == url && *c.keep() == session.keep);
290        match self.plm.as_mut().filter(|_| same) {
291            // Same server, same keep: a live switch moves only the status.
292            Some(plm) => plm.set_status(session.status),
293            None => self.plm = Some(PlmConnection::new(session.status, session.keep)),
294        }
295    }
296
297    /// The `PLM` TAB (plan S1 part 3): where this session's PLM is and who it
298    /// is signed in as, or why it is not; a token paste and a password
299    /// sign-in; a check; and Forget. Hit keys `plm:*`.
300    fn plm_section(&mut self, ui: &mut egui::Ui) {
301        use crate::plm::connection::{Keep, Outcome, Status};
302        let Some(plm) = self.plm.as_mut() else { return };
303        if plm.poll() || plm.busy() {
304            ui.ctx().request_repaint();
305        }
306        if plm.take_reload() {
307            reload_page();
308        }
309        let hits = &mut self.hits;
310        let status = match &plm.status {
311            Status::Connected { url, username } => format!("Connected to {url} as {username}."),
312            Status::NotConnected { url, reason: None } => format!("Not connected to {url}."),
313            Status::NotConnected {
314                url,
315                reason: Some(why),
316            } => format!("Not connected to {url}: {why}"),
317        };
318        let shown = ui.add(egui::Label::new(status).wrap());
319        hits.insert("plm:status".into(), shown.rect);
320        ui.separator();
321
322        let native = matches!(plm.keep(), Keep::Files(_));
323        let idle = !plm.busy();
324        if native {
325            ui.label("Paste an API token (from the PLM's web page):");
326            let token = ui.add(
327                egui::TextEdit::singleline(&mut plm.token_input)
328                    .password(true)
329                    .hint_text("plm_…"),
330            );
331            hits.insert("plm:token".into(), token.rect);
332            let use_token = ui.add_enabled(
333                idle && !plm.token_input.trim().is_empty(),
334                egui::Button::new("Use token"),
335            );
336            hits.insert("plm:use-token".into(), use_token.rect);
337            if use_token.clicked() {
338                plm.use_token();
339            }
340            ui.separator();
341        }
342        if native {
343            ui.label("Or sign in, and this machine keeps a token (not the password):");
344            let username =
345                ui.add(egui::TextEdit::singleline(&mut plm.username).hint_text("username"));
346            hits.insert("plm:username".into(), username.rect);
347            let password = ui.add(
348                egui::TextEdit::singleline(&mut plm.password)
349                    .password(true)
350                    .hint_text("password"),
351            );
352            hits.insert("plm:password".into(), password.rect);
353            let can_sign_in = idle && !plm.username.trim().is_empty() && !plm.password.is_empty();
354            let sign_in = ui.add_enabled(can_sign_in, egui::Button::new("Sign in"));
355            hits.insert("plm:sign-in".into(), sign_in.rect);
356            if sign_in.clicked() {
357                plm.use_password();
358            }
359            ui.separator();
360        } else {
361            ui.label("This browser uses your PLM session. Manage your account and sign in on the PLM page.");
362            let account = ui.hyperlink_to(
363                "Open PLM account",
364                format!("{}/#/account", plm.url().trim_end_matches('/')),
365            );
366            hits.insert("plm:account".into(), account.rect);
367        }
368        // Connect now (native): once the server has taken this machine's
369        // sign-in, switch this session to it live instead of at next start
370        // (only in the state a fresh start has: no named or dirty document).
371        if native && matches!(plm.status, Status::NotConnected { .. }) && plm.connectable() {
372            let blocked = self.reconnect_blocker.clone();
373            let connect =
374                ui.add_enabled(idle && blocked.is_none(), egui::Button::new("Connect now"));
375            hits.insert("plm:connect".into(), connect.rect);
376            if let Some(why) = &blocked {
377                ui.label(egui::RichText::new(why).color(PROBLEM_AMBER));
378            }
379            if connect.clicked() {
380                if let Keep::Files(root) = plm.keep() {
381                    self.reconnect_request = Some(root.clone());
382                }
383            }
384        }
385        ui.horizontal(|ui| {
386            let check = ui.add_enabled(idle, egui::Button::new("Check connection"));
387            hits.insert("plm:check".into(), check.rect);
388            if check.clicked() {
389                plm.check();
390            }
391            if native {
392                let forget =
393                    ui.add_enabled(idle, egui::Button::new("Forget this machine's sign-in"));
394                hits.insert("plm:forget".into(), forget.rect);
395                if forget.clicked() {
396                    plm.forget();
397                }
398            }
399        });
400        if !idle {
401            ui.horizontal(|ui| {
402                ui.spinner();
403                ui.label("Asking the server…");
404            });
405        }
406        if let Some(outcome) = &plm.last {
407            let (text, color) = match outcome {
408                Outcome::Done(text) => (text.clone(), ui.visuals().text_color()),
409                Outcome::Failed(text) => (text.clone(), PROBLEM_AMBER),
410            };
411            let said = ui.add(egui::Label::new(egui::RichText::new(text).color(color)).wrap());
412            hits.insert("plm:outcome".into(), said.rect);
413        }
414    }
415
416    /// The `Assemblies` TAB: the BOM's COLUMN CONFIGURATION as one multiline
417    /// textarea, one column per line, a leading `*` for shown.
418    ///
419    /// A hand-written root rather than a schema field: the schema's `FieldKind`s
420    /// are all
421    /// single-widget and render into a tree row's RIGHT-ALIGNED content slot,
422    /// which is exactly the wrong place for a full-width multi-line editor.
423    /// Adding a `TextArea` variant would also force both exhaustive
424    /// `FieldKind` matches open for one consumer.
425    ///
426    /// Commits on focus-LOSS — which includes LEAVING THE TAB, since [`body`]
427    /// draws the pre-click tab for one more frame so this editor is still on
428    /// screen to blur ([`SettingsPanel::body`]) — not per keystroke: this text is
429    /// persisted to the store on commit, and on native that is a rewrite of
430    /// `~/.config/brep-app/settings.json` — per character is a file write per
431    /// character. Parse problems are listed under the editor, naming the line,
432    /// and the text is never rewritten by the panel: a typo costs one column,
433    /// not the configuration.
434    fn assemblies_section(
435        &mut self,
436        ui: &mut egui::Ui,
437        state: &mut EngineState,
438        store: &dyn ModelStore,
439    ) {
440        let open = !self.assemblies_collapsed;
441        let root_resp = tree::node(
442            ui,
443            TreeRow {
444                guides: &[],
445                is_last: true,
446                expandable: true,
447                expanded: open,
448                root: true,
449                glyph: None,
450                label: "Assemblies",
451                selected: false,
452                highlighted: false,
453                draggable: false,
454                tint: None,
455            },
456            |_| {},
457        );
458        self.hits.insert("box:__assemblies".into(), root_resp.box_rect);
459        if root_resp.toggled || root_resp.clicked() {
460            self.assemblies_collapsed = !self.assemblies_collapsed;
461        }
462        if !open {
463            // Drop the buffer while closed so the next open re-seeds from the
464            // engine (a BOM header drag rewrites this text behind the panel).
465            self.bom_columns_buf = None;
466            return;
467        }
468
469        // Empty stored text means "the shipped default", so the editor shows
470        // the default rather than a blank box the user has to guess at.
471        let stored = bom_columns::effective_text(&state.settings.bom_columns);
472        let buffer = self.bom_columns_buf.get_or_insert_with(|| stored.clone());
473
474        ui.label(egui::RichText::new("BOM columns").strong());
475        ui.label(
476            egui::RichText::new(
477                "One per line, in order. A leading * shows it. \
478                 part.<Field> is stored on the part, occurrence.<Field> on one placement. \
479                 A line that is just - freezes the columns above it; the rest scroll.",
480            )
481            .weak(),
482        );
483        let editor = ui.add(
484            egui::TextEdit::multiline(buffer)
485                .id_salt("bom-columns-editor")
486                .desired_rows(8)
487                .desired_width(f32::INFINITY)
488                .code_editor(),
489        );
490        self.hits.insert("field:bomColumns".into(), editor.rect);
491
492        if editor.lost_focus() {
493            // Commit: store the text VERBATIM (never the parse's idea of it).
494            let mut settings_json: serde_json::Value =
495                serde_json::from_str(&state.settings_json()).unwrap_or(serde_json::Value::Null);
496            if let Some(object) = settings_json.as_object_mut() {
497                object.insert(
498                    "bomColumns".into(),
499                    serde_json::Value::String(buffer.clone()),
500                );
501                let json = settings_json.to_string();
502                let _ = state.apply_settings_json(&json);
503                let _ = store.write(SETTINGS_KEY, &json);
504            }
505        } else if !editor.has_focus() && *buffer != stored {
506            // Unfocused and out of step with the engine — the BOM's own header
507            // drag rewrote the configuration. Track it rather than showing a
508            // stale copy the next commit would write back.
509            *buffer = stored;
510        }
511
512        // Parse problems, by line. Listed rather than thrown: the text stands
513        // exactly as typed and every other line still works.
514        let parsed = bom_columns::parse(buffer);
515        if parsed.problems.is_empty() {
516            ui.label(
517                egui::RichText::new(format!(
518                    "{} columns, {} shown",
519                    parsed.columns.len(),
520                    parsed.columns.iter().filter(|column| column.shown).count()
521                ))
522                .weak(),
523            );
524        } else {
525            for problem in &parsed.problems {
526                ui.label(egui::RichText::new(problem).color(PROBLEM_AMBER));
527            }
528        }
529        let reset = ui.button("Reset BOM columns");
530        self.hits.insert("bom-columns:reset".into(), reset.rect);
531        if reset.clicked() {
532            self.bom_columns_buf = None;
533            let mut settings_json: serde_json::Value =
534                serde_json::from_str(&state.settings_json()).unwrap_or(serde_json::Value::Null);
535            if let Some(object) = settings_json.as_object_mut() {
536                // Back to EMPTY, which means "the shipped default" — so a later
537                // change to that default still reaches this user.
538                object.insert("bomColumns".into(), serde_json::Value::String(String::new()));
539                let json = settings_json.to_string();
540                let _ = state.apply_settings_json(&json);
541                let _ = store.write(SETTINGS_KEY, &json);
542            }
543        }
544        ui.add_space(4.0);
545    }
546
547    /// The schema-driven display-settings TREE: a `Display settings` root, one
548    /// collapsible branch per schema group, one leaf per field. Any edit applies to
549    /// `EngineState` (bumps `settings_generation` + `dirty`, so the GPU refreshes)
550    /// and persists through the storage seam.
551    fn settings_section(
552        &mut self,
553        ui: &mut egui::Ui,
554        state: &mut EngineState,
555        store: &dyn ModelStore,
556    ) {
557        // Re-seed a per-frame LOCAL buffer from the LIVE engine settings BEFORE
558        // rendering. The apply below writes the WHOLE buffer, so a buffer kept
559        // across frames would clobber every setting changed elsewhere (the toolbar
560        // wireframe / projection toggles) back to a stale snapshot — the "changing
561        // Render Quality resets my wireframe" bug. A fresh local each frame makes
562        // external changes authoritative and keeps untouched fields a no-op
563        // round-trip (`apply_json`/`to_json` are a documented identity).
564        let before = state.settings_json();
565        let mut settings_json: Value = serde_json::from_str(&before).unwrap_or(Value::Null);
566        let fields = settings_form_fields();
567
568        // Group the schema's contiguous same-group runs, preserving order (the
569        // schema lists each group's fields together).
570        let mut groups: Vec<(String, Vec<&FormField>)> = Vec::new();
571        for f in &fields {
572            if let Some(g) = groups.iter_mut().find(|(n, _)| *n == f.group) {
573                g.1.push(f);
574            } else {
575                groups.push((f.group.clone(), vec![f]));
576            }
577        }
578
579        // --- ROOT: `[-] Display settings` (defaults open) ---------------------
580        let root_open = !self.display_collapsed;
581        let root_resp = tree::node(
582            ui,
583            TreeRow {
584                guides: &[],
585                is_last: true,
586                expandable: true,
587                expanded: root_open,
588                root: true,
589                glyph: None,
590                label: "Display settings",
591                selected: false,
592                highlighted: false,
593                draggable: false,
594                tint: None,
595            },
596            |_| {},
597        );
598        if root_resp.toggled || root_resp.clicked() {
599            self.display_collapsed = !self.display_collapsed;
600        }
601
602        let mut changed = false;
603        if root_open {
604            let n = groups.len();
605            for (gi, (gname, gfields)) in groups.iter().enumerate() {
606                let is_last = gi + 1 == n;
607                let open = !self.closed_groups.contains(gname);
608                let resp = tree::node(ui, TreeRow::branch(&[], is_last, open, gname), |_| {});
609                self.hits.insert(format!("group:{gname}"), resp.box_rect);
610                if resp.toggled || resp.clicked() {
611                    if open {
612                        self.closed_groups.insert(gname.clone());
613                    } else {
614                        self.closed_groups.remove(gname);
615                    }
616                }
617                if !open {
618                    continue;
619                }
620                let base = tree::child_guides(&[], is_last);
621                let m = gfields.len();
622                for (fi, &f) in gfields.iter().enumerate() {
623                    changed |= self.settings_leaf(ui, f, &mut settings_json, &base, fi + 1 == m);
624                }
625            }
626        }
627
628        // Commit the whole buffer ONCE on any edit (same apply + persist path as
629        // before), so the engine re-runs / the GPU refreshes exactly as it did.
630        //
631        // ...and only when the settings actually MOVED. Some field reports a
632        // change on every frame the tree is drawn, and the write it caused went
633        // unnoticed on a file store (one settings.json rewrite per frame) until
634        // the PLM made each one a request: with the window open the store was
635        // never idle. Comparing through the engine's own serialisation makes a
636        // no-op edit a no-op whichever widget claims otherwise.
637        if changed {
638            let json = settings_json.to_string();
639            let _ = state.apply_settings_json(&json);
640            if state.settings_json() != before {
641                let _ = store.write(SETTINGS_KEY, &json);
642            }
643        }
644
645        // Reset to defaults — only while the display root is OPEN, matching the
646        // retired CollapsingHeader that hid it when the section was collapsed. It
647        // resets the DISPLAY settings only, which is why it belongs to this tab.
648        if root_open {
649            ui.add_space(2.0);
650            if ui.button("Reset to defaults").clicked() {
651                // Full reset: rebase to defaults, then apply the serialized defaults
652                // (so every key returns, not just the overridden ones) + persist.
653                state.settings = RenderSettings::default();
654                let json = state.settings.to_json();
655                let _ = state.apply_settings_json(&json);
656                let _ = store.write(SETTINGS_KEY, &json);
657            }
658        }
659    }
660
661    /// Render one settings field as a tree LEAF: the field label is the node label;
662    /// its input widget ([`form::field_input`]) fills the row's RIGHT-aligned
663    /// content, exactly like the feature tree's `schema_field`. Settings keys are
664    /// unique across the schema, so no id-stack scoping is needed. Returns whether
665    /// the field changed (the caller commits the whole buffer once).
666    fn settings_leaf(
667        &mut self,
668        ui: &mut egui::Ui,
669        field: &FormField,
670        current: &mut Value,
671        guides: &[bool],
672        is_last: bool,
673    ) -> bool {
674        let mut changed = false;
675        let mut rect = egui::Rect::NOTHING;
676        tree::node(ui, TreeRow::leaf(guides, is_last, &field.label), |ui| {
677            // The tree row's content area is RIGHT-aligned (`right_to_left`), so the
678            // input sits at the panel edge with the label on the left — the feature
679            // tree's exact placement, and the layout `field_input` reads to keep its
680            // inputs COMPACT here. Settings have no reference / button fields, so
681            // `field_input`'s click sink is `None`.
682            let (ch, r) = form::field_input(ui, field, current, None, &mut form::FieldActions::default());
683            changed = ch;
684            rect = r;
685        });
686        self.hits.insert(format!("field:{}", field.key()), rect);
687        changed
688    }
689
690    /// The published widget hit-rects (egui points) for the headed verifier.
691    pub fn hits_json(&self) -> String {
692        crate::automation::hit_rects::hits_json(&self.hits)
693    }
694}
695
696
697/// Reload the page onto the PLM session a web sign-in (or sign-out) made.
698fn reload_page() {
699    #[cfg(target_arch = "wasm32")]
700    if let Some(window) = web_sys::window() {
701        let _ = window.location().reload();
702    }
703}
704
705/// The hit keys this panel publishes (see `automation::hit_keys`).
706pub static HIT_KEYS: &[HitKeyDoc] = &[
707    HitKeyDoc { panel: "settings", prefix: "field:", meaning: "a settings field by key", command: None },
708    HitKeyDoc { panel: "settings", prefix: "group:", meaning: "a settings group header", command: None },
709    HitKeyDoc { panel: "settings", prefix: "tab:", meaning: "a settings tab (tab:display, tab:assemblies, and tab:plm while a PLM is configured)", command: None },
710    HitKeyDoc { panel: "settings", prefix: "plm:", meaning: "the PLM tab: status, check, outcome; hosted account; desktop token, use-token, username, password, sign-in, forget", command: None },
711    HitKeyDoc { panel: "settings", prefix: "box:", meaning: "expand/collapse a section", command: None },
712    HitKeyDoc { panel: "settings", prefix: "bom-columns:reset", meaning: "reset the BOM columns", command: None },
713    HitKeyDoc { panel: "settings", prefix: "panel:clip", meaning: "the visible region of the window", command: None },
714];