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 server: its URL (editable wherever `plm.json` can be written),
62    /// and for the PLM this session is on or configured for, sign-in, check,
63    /// forget (plan S1 part 3). Present in every session (owner 2026-10-29).
64    Plm,
65}
66
67/// The display-settings panel's own transient UI state. It holds NO model state:
68/// the settings buffer is re-seeded from the live engine each frame (see
69/// `SettingsPanel::settings_section`).
70pub struct SettingsPanel {
71    /// Whether the floating window is shown. Toggled by the toolbar gear button
72    /// and by the window's own close (`×`) button; public so the toolbar can bind
73    /// it.
74    pub open: bool,
75    /// Per-frame egui widget screen rects (keyed `tab:<name>` / `field:<key>` /
76    /// `group:<name>` / …), published to JS for the headed verifier. Rebuilt every
77    /// frame, so it holds only the ACTIVE tab's widgets.
78    hits: HashMap<String, egui::Rect>,
79    /// The Display tree moved the settings while a pointer button was down (a
80    /// slider drag): the engine has them, the store gets them once on release
81    /// rather than on every frame of the drag.
82    settings_unwritten: bool,
83    /// Which tab is shown. Defaults to `Display`, the tab the window has always
84    /// opened on.
85    tab: Tab,
86    /// The `Display settings` root is collapsed (false = open — it defaults open).
87    display_collapsed: bool,
88    /// Setting GROUPS explicitly COLLAPSED, by group name (absent = open — groups
89    /// default open, matching the retired per-group CollapsingHeaders).
90    closed_groups: HashSet<String>,
91    /// The `Assemblies` root is collapsed (false = open — see above).
92    assemblies_collapsed: bool,
93    /// The BOM-columns textarea's live edit buffer. Held here, not re-seeded
94    /// per frame like the settings JSON, because a multi-line editor cannot be
95    /// re-seeded mid-edit without fighting the caret. It tracks the engine
96    /// while UNFOCUSED and commits on focus-loss (the expressions editor's
97    /// rule); `None` = not yet seeded.
98    bom_columns_buf: Option<String>,
99    /// The PLM tab's state, built when the store first reports a PLM and
100    /// rebuilt if its URL changes. `None` in a file-only session.
101    plm: Option<crate::plm::connection::PlmConnection>,
102    /// The Server URL field's edit buffer, and the store URL it was seeded
103    /// from: re-seeded when the store's URL moves, never mid-edit. It commits
104    /// on focus loss (Enter included), the BOM columns editor's rule.
105    url_buf: Option<(String, Option<String>)>,
106    /// What the last Server URL commit did: saved (the sentence says what
107    /// happens next) or refused (the reason). Kept here, not on `plm`, which
108    /// a new URL rebuilds.
109    url_outcome: Option<crate::plm::connection::Outcome>,
110    /// Set by the app every frame: why this session cannot switch to the PLM
111    /// live (open named or dirty documents), or `None` when it can.
112    pub reconnect_blocker: Option<String>,
113    /// Set by the app every frame: a Connect now is on its way (the tab's
114    /// actions wait for it).
115    pub reconnecting: bool,
116    /// Connect now was pressed: the config root the app switches to.
117    reconnect_request: Option<std::path::PathBuf>,
118    /// The live session's sign-in was refused by the server (revoked,
119    /// expired): the tab reads not connected, with this reason, until the
120    /// person signs in again.
121    signed_out: Option<String>,
122}
123
124impl SettingsPanel {
125    /// Open the window on its PLM tab ("Open in CAD" before signing in): the
126    /// tab exists while the store reports a PLM, signed in or not.
127    pub fn show_plm_tab(&mut self) {
128        self.open = true;
129        self.tab = Tab::Plm;
130    }
131
132    /// The server ended this session's sign-in: open the PLM tab on its
133    /// sign-in, saying `why`, instead of a raw error on every request.
134    pub fn ask_plm_sign_in(&mut self, why: String) {
135        self.signed_out = Some(why);
136        self.show_plm_tab();
137    }
138
139    /// A native sign-in in the tab passed: `(username, token)`. The shell
140    /// hands it to the live session when that session's token was refused;
141    /// the tab reads connected again.
142    pub fn take_fresh_plm_token(&mut self) -> Option<(String, String)> {
143        let fresh = self.plm.as_mut()?.take_fresh_token()?;
144        self.signed_out = None;
145        Some(fresh)
146    }
147
148    /// Forget signed this machine out on the server since the last call: the
149    /// tab reads not connected, and the shell expects the server's refusal.
150    pub fn take_plm_signed_out_here(&mut self) -> bool {
151        if !self.plm.as_mut().is_some_and(|plm| plm.take_signed_out_here()) {
152            return false;
153        }
154        self.signed_out = Some("you signed out of the PLM on this machine: the next start is the file app, or sign in again".into());
155        true
156    }
157
158    /// The live session's sign-in was refused (see [`Self::ask_plm_sign_in`]).
159    pub fn plm_signed_out(&self) -> bool {
160        self.signed_out.is_some()
161    }
162
163    /// A fresh panel. The settings buffer is re-seeded from the engine every frame
164    /// (not stored), so construction needs no engine handle.
165    pub fn new() -> Self {
166        Self {
167            open: false,
168            hits: HashMap::new(),
169            settings_unwritten: false,
170            tab: Tab::Display,
171            display_collapsed: false,
172            closed_groups: HashSet::new(),
173            assemblies_collapsed: false,
174            bom_columns_buf: None,
175            plm: None,
176            url_buf: None,
177            url_outcome: None,
178            reconnect_blocker: None,
179            reconnecting: false,
180            reconnect_request: None,
181            signed_out: None,
182        }
183    }
184
185    /// Draw the floating window (if open) at ctx level — after the panels, like
186    /// the file dialog, so it floats over the shell. The `open` flag is shared with
187    /// the toolbar gear button (which toggles it) and the window's own `×` (which
188    /// closes it). `EngineState` is the single brain, borrowed in.
189    pub fn show(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
190        if self.open {
191            // `egui::Window::open` needs its own `&mut bool`; borrow a copy so the
192            // draw closure can still take `&mut self`, then fold the close back in.
193            let mut open = true;
194            egui::Window::new("Settings")
195                .open(&mut open)
196                .movable(true)
197                .resizable(true)
198                // A bounded default size + a fill ScrollArea (in `body`, under the
199                // tab strip) makes the window FREELY resizable LARGER than its
200                // content: without a filling child egui hugs the window to content
201                // and won't grow.
202                .default_size([460.0, 480.0])
203                // Rest on the right so it floats clear of the left panel; the user
204                // can drag it anywhere.
205                .default_pos(super::below_chrome(ctx, 720.0, 0.0))
206                .show(ctx, |ui| self.body(ui, state, store));
207            self.open = open;
208        } else {
209            // A CLOSED window publishes no rects. The publish used to sit inside
210            // the branch above, so the last open frame's map stayed in the
211            // registry for the rest of the session: `hit_rects settings/` kept
212            // answering with fifty fields of a window that was not on screen, and
213            // `click_widget` on one of them aimed a click at empty space. Same
214            // rule as the workbench strip and the sketch overlay, which publish an
215            // empty map while they are hidden.
216            self.hits.clear();
217        }
218
219        // Publish this frame's widget rects for the headed verifier (parity
220        // with the history + scene panels) — empty while the window is closed.
221        if crate::automation::registry::enabled() {
222            crate::automation::registry::publish("__brepSettingsHit", "settings window widget rects (field:*, group:*, panel:clip); empty while the window is closed", &self.hits_json());
223            crate::automation::registry::publish("__brepPlm", "the Settings PLM tab (present in every session, so tab is always true): {tab, status, url, username, reason, busy, outcome, failed, linking, linkCode, linkUrl, linkToken, linkNote} for the session's PLM — status \"not-configured\" (and those null) when there is none — and the Server URL field {serverUrl, urlSource (\"plm.json\", \"BREP_PLM_URL\", \"--plm-url\" or null), urlEditable, urlField, urlOutcome, urlFailed}", &self.plm_json(store));
224        }
225    }
226
227    /// The window body: the tab strip, then the ONE section that tab selects —
228    /// each still built on the shared [`tree`] node helper, so every tab reads as
229    /// the same tree the history + scene panels draw.
230    ///
231    /// The strip sits ABOVE the ScrollArea (rather than inside the one `show` used
232    /// to wrap the whole body in), so the tabs stay put while a long settings tree
233    /// scrolls under them.
234    fn body(&mut self, ui: &mut egui::Ui, state: &mut EngineState, store: &dyn ModelStore) {
235        self.hits.clear();
236        // The window's VISIBLE region, under the same key every scrolling pane
237        // publishes. The Display tree is longer than the window, and egui hands
238        // out a layout rect for a row that is scrolled past the bottom edge — a
239        // click there lands outside the window's clip and does nothing at all,
240        // which reads as "the switch is broken" rather than "the switch is off
241        // screen". With the region published, `scroll_into_view` wheels the row
242        // up first. Taken HERE rather than inside the scroll area so the tab
243        // strip above it counts as visible too: a clip that excluded the strip
244        // would send a click on `tab:assemblies` scrolling forever looking for
245        // a widget that never moves.
246        self.hits.insert("panel:clip".into(), ui.clip_rect());
247
248        // The tab drawn this frame is the one that was active BEFORE the strip.
249        // `selectable_value` switches `self.tab` mid-frame, and a section that
250        // vanishes the same frame it loses the click never gets its focus-loss —
251        // which is how the Assemblies editor COMMITS. Deferring by one frame lets
252        // the editor blur normally (invisible at 60fps, and the difference between
253        // "my BOM columns saved" and "my typing disappeared").
254        // The PLM tab is in every session: a file-only one shows its Server
255        // URL field, which is how such a session connects.
256        self.sync_plm(store.plm_session());
257        let tab = self.tab;
258        ui.horizontal(|ui| {
259            let display = ui.selectable_value(&mut self.tab, Tab::Display, "Display");
260            let assemblies = ui.selectable_value(&mut self.tab, Tab::Assemblies, "Assemblies");
261            self.hits.insert("tab:display".into(), display.rect);
262            self.hits.insert("tab:assemblies".into(), assemblies.rect);
263            let plm = ui.selectable_value(&mut self.tab, Tab::Plm, "PLM");
264            self.hits.insert("tab:plm".into(), plm.rect);
265        });
266        // ...and because of that defer, the switch needs ONE more frame to show
267        // its new section. The shell only requests a repaint while work is
268        // pending (`app.rs`: a run / queries / mesh imports), so on native a
269        // click with no further input would leave the strip highlighting a tab
270        // whose content has not been drawn yet. Ask for that frame here.
271        if self.tab != tab {
272            ui.ctx().request_repaint();
273        }
274        ui.separator();
275
276        egui::ScrollArea::vertical()
277            .auto_shrink([false, false])
278            .show(ui, |ui| {
279                // Tight, tree-like row spacing so connector verticals read
280                // continuously — the same the history + scene trees set (this panel
281                // must match them). Set INSIDE the scroll so the tab strip above
282                // keeps ordinary widget spacing.
283                ui.spacing_mut().item_spacing.y = 2.0;
284                match tab {
285                    Tab::Display => self.settings_section(ui, state, store),
286                    Tab::Assemblies => self.assemblies_section(ui, state, store),
287                    Tab::Plm => {
288                        self.plm_url_section(ui, store);
289                        self.plm_section(ui);
290                    }
291                }
292            });
293    }
294
295    /// The `__brepPlm` state. Read from the store whether or not the window is
296    /// open, so a script can see the session's PLM without opening Settings.
297    fn plm_json(&mut self, store: &dyn ModelStore) -> String {
298        use crate::plm::connection::{Outcome, Status};
299        self.sync_plm(store.plm_session());
300        let setting = store.plm_url_setting();
301        let (url_outcome, url_failed) = match &self.url_outcome {
302            Some(Outcome::Done(text)) => (Some(text.clone()), false),
303            Some(Outcome::Failed(text)) => (Some(text.clone()), true),
304            None => (None, false),
305        };
306        let field = serde_json::json!({
307            "serverUrl": setting.as_ref().and_then(|s| s.url.clone()),
308            "urlSource": setting.as_ref().and_then(|s| s.source).map(crate::plm::config::url_origin),
309            "urlEditable": setting.is_some(),
310            "urlField": self.url_buf.as_ref().map(|(buf, _)| buf.clone()),
311            "urlOutcome": url_outcome, "urlFailed": url_failed,
312        });
313        let Some(plm) = self.plm.as_mut() else {
314            let mut json = serde_json::json!({ "tab": true, "status": "not-configured", "url": null });
315            json.as_object_mut().expect("an object").extend(field.as_object().expect("an object").clone());
316            return json.to_string();
317        };
318        plm.poll();
319        let (status, url, username, reason) = match &plm.status {
320            Status::Connected { url, username } => ("connected", url.clone(), Some(username.clone()), None),
321            Status::NotConnected { url, reason } => ("not-connected", url.clone(), None, reason.clone()),
322        };
323        let (outcome, failed) = match &plm.last {
324            Some(Outcome::Done(text)) => (Some(text.clone()), false),
325            Some(Outcome::Failed(text)) => (Some(text.clone()), true),
326            None => (None, false),
327        };
328        let mut json = serde_json::json!({
329            "tab": true, "status": status, "url": url, "username": username, "reason": reason,
330            "busy": plm.busy(), "outcome": outcome, "failed": failed,
331            "connectable": plm.connectable(), "reconnectBlocker": self.reconnect_blocker,
332            "connecting": self.reconnecting,
333            "linking": plm.linking(),
334            "linkCode": plm.link.as_ref().map(|l| l.code.clone()),
335            "linkUrl": plm.link.as_ref().map(|l| l.url.clone()),
336            // The token alone, for a script deciding the link as the browser would.
337            "linkToken": plm.link.as_ref().and_then(|l| l.url.split_once("token=").map(|(_, t)| t.to_string())),
338            "linkNote": plm.link.as_ref().and_then(|l| l.note.clone()),
339        });
340        json.as_object_mut().expect("an object").extend(field.as_object().expect("an object").clone());
341        json.to_string()
342    }
343
344    /// Connect now's request, for the app to act on (once).
345    pub fn take_reconnect_request(&mut self) -> Option<std::path::PathBuf> {
346        self.reconnect_request.take()
347    }
348
349    /// What the app's switch did: shown in the tab as its outcome.
350    pub fn reconnected(&mut self, outcome: Result<String, String>) {
351        if let Some(plm) = self.plm.as_mut() {
352            plm.reconnected(outcome);
353        }
354    }
355
356    /// Keep the PLM tab's state in step with what the store reports.
357    fn sync_plm(&mut self, session: Option<crate::plm::connection::Session>) {
358        use crate::plm::connection::PlmConnection;
359        let Some(session) = session else {
360            self.plm = None;
361            return;
362        };
363        let url = match &session.status {
364            crate::plm::connection::Status::Connected { url, .. } | crate::plm::connection::Status::NotConnected { url, .. } => url.clone(),
365        };
366        let same = self.plm.as_ref().is_some_and(|c| c.url() == url && *c.keep() == session.keep);
367        // Refused by the server: not connected, whatever the store's label says.
368        let status = match (&self.signed_out, session.status) {
369            (Some(why), crate::plm::connection::Status::Connected { url, .. }) => {
370                crate::plm::connection::Status::NotConnected { url, reason: Some(why.clone()) }
371            }
372            (_, status) => status,
373        };
374        match self.plm.as_mut().filter(|_| same) {
375            // Same server, same keep: a live switch moves only the status.
376            Some(plm) => plm.set_status(status),
377            None => self.plm = Some(PlmConnection::new(status, session.keep)),
378        }
379    }
380
381    /// The PLM tab's Server URL (owner 2026-10-29): the URL this session uses
382    /// or would use and where it came from, and — wherever a `plm.json` can be
383    /// written — the field that writes it, committed on focus loss (Enter
384    /// included) exactly as `--plm-configure` would write it. Hit keys
385    /// `plm:url`, `plm:url-source`, `plm:url-outcome`.
386    fn plm_url_section(&mut self, ui: &mut egui::Ui, store: &dyn ModelStore) {
387        use crate::plm::config::{url_origin, Source};
388        use crate::plm::connection::{Outcome, Status};
389        let setting = store.plm_url_setting();
390        let editable = setting.is_some();
391        // No setting is the browser: its PLM is the server serving the page.
392        let shown = match &setting {
393            Some(setting) => setting.url.clone(),
394            None => self.plm.as_ref().map(|plm| plm.url().to_string()).filter(|url| !url.is_empty()),
395        };
396        let source = setting.as_ref().and_then(|s| s.source);
397        if self.url_buf.as_ref().is_none_or(|(_, from)| *from != shown) {
398            self.url_buf = Some((shown.clone().unwrap_or_default(), shown.clone()));
399        }
400
401        tree::node(
402            ui,
403            TreeRow {
404                guides: &[],
405                is_last: true,
406                expandable: false,
407                expanded: true,
408                root: true,
409                glyph: None,
410                label: "PLM server",
411                selected: false,
412                highlighted: false,
413                draggable: false,
414                tint: None,
415            },
416            |_| {},
417        );
418        let mut committed = false;
419        let mut rect = egui::Rect::NOTHING;
420        let (buf, _) = self.url_buf.as_mut().expect("seeded above");
421        tree::node(ui, TreeRow::leaf(&[], true, "Server URL"), |ui| {
422            ui.add_enabled_ui(editable, |ui| {
423                let width = form::text_input_width(ui);
424                let field = form::text_input(ui, buf, width);
425                committed = field.lost_focus();
426                rect = field.rect;
427            });
428        });
429        self.hits.insert("plm:url".into(), rect);
430
431        if committed && editable {
432            let typed = buf.trim().to_string();
433            if typed != shown.clone().unwrap_or_default() {
434                self.url_outcome = Some(if typed.is_empty() {
435                    Outcome::Failed("an empty URL is not saved: Forget this machine's sign-in removes the server from this machine".into())
436                } else {
437                    match store.save_plm_url(&typed) {
438                        Err(why) => Outcome::Failed(format!("not saved: {why}")),
439                        Ok(saved) => {
440                            let mut said = format!("saved to plm.json: {}", saved.url);
441                            if saved.token_dropped {
442                                said += "; this machine's sign-in for the previous server is forgotten (a token is never sent to another server)";
443                            }
444                            match (source, self.plm.as_ref().map(|plm| &plm.status)) {
445                                (Some(Source::Env | Source::Flag), _) => {
446                                    said += &format!(
447                                        "; this launch stays on {} ({} overrides plm.json), and a start without it uses {}",
448                                        shown.clone().unwrap_or_default(),
449                                        url_origin(source.expect("matched")),
450                                        saved.url
451                                    );
452                                }
453                                (_, Some(Status::Connected { url, .. })) if *url != saved.url => {
454                                    said += &format!("; this session stays on {url} until the next start");
455                                }
456                                _ => said += "; sign in below, then Connect now (or the next start) puts this session on it",
457                            }
458                            // The store's URL moves only for a session on
459                            // plm.json; keep the saved text either way.
460                            let from = store.plm_url_setting().and_then(|s| s.url);
461                            self.url_buf = Some((saved.url.clone(), from));
462                            Outcome::Done(said)
463                        }
464                    }
465                });
466            }
467        }
468
469        let note = match (&setting, source) {
470            (Some(_), Some(source @ (Source::Env | Source::Flag))) => format!(
471                "From {}, which overrides plm.json for this launch. A URL saved here is what a launch without it uses.",
472                url_origin(source)
473            ),
474            (Some(_), Some(Source::File)) => {
475                "From plm.json. Changing the server forgets this machine's sign-in: a token is never sent to a different server.".into()
476            }
477            (Some(_), None) => "No PLM server is set: this session uses the files on this machine. Enter the server's URL (http:// or https://) and press Enter, then sign in.".into(),
478            (None, _) if shown.is_some() => "The web app uses the PLM that serves this page.".into(),
479            (None, _) => "This page is not served by a PLM, so there is no server to point it at: open the CAD app from your PLM's web page to use one.".into(),
480        };
481        let said = ui.add(egui::Label::new(note).wrap());
482        self.hits.insert("plm:url-source".into(), said.rect);
483        if let Some(outcome) = &self.url_outcome {
484            let (text, color) = match outcome {
485                Outcome::Done(text) => (text.clone(), ui.visuals().text_color()),
486                Outcome::Failed(text) => (text.clone(), PROBLEM_AMBER),
487            };
488            let said = ui.add(egui::Label::new(egui::RichText::new(text).color(color)).wrap());
489            self.hits.insert("plm:url-outcome".into(), said.rect);
490        }
491        ui.separator();
492    }
493
494    /// The `PLM` TAB (plan S1 part 3): where this session's PLM is and who it
495    /// is signed in as, or why it is not; a token paste and a password
496    /// sign-in; a check; and Forget. Hit keys `plm:*`.
497    fn plm_section(&mut self, ui: &mut egui::Ui) {
498        use crate::plm::connection::{Keep, Outcome, Status};
499        let Some(plm) = self.plm.as_mut() else { return };
500        if plm.poll() || plm.busy() {
501            ui.ctx().request_repaint();
502        }
503        if plm.take_reload() {
504            reload_page();
505        }
506        let hits = &mut self.hits;
507        let status = match &plm.status {
508            Status::Connected { url, username } => format!("Connected to {url} as {username}."),
509            Status::NotConnected { url, reason: None } => format!("Not connected to {url}."),
510            Status::NotConnected {
511                url,
512                reason: Some(why),
513            } => format!("Not connected to {url}: {why}"),
514        };
515        let shown = ui.add(egui::Label::new(status).wrap());
516        hits.insert("plm:status".into(), shown.rect);
517        ui.separator();
518        // A plm.json that names no usable server: the field above is the fix,
519        // and nothing below has a server to ask.
520        if plm.url().is_empty() {
521            return;
522        }
523
524        let native = matches!(plm.keep(), Keep::Files(_));
525        let idle = !plm.busy() && !self.reconnecting;
526        if native {
527            browser_sign_in(ui, plm, hits, idle);
528            ui.separator();
529            // The fallback, for a machine with no browser (or a person who
530            // would rather type): a pasted token, or a password traded for one.
531            let here = egui::CollapsingHeader::new("Sign in here instead")
532                .id_salt("plm-sign-in-here")
533                .default_open(false)
534                .show(ui, |ui| {
535                    ui.label("Paste an API token (from the PLM's web page):");
536                    let token = ui.add(
537                        egui::TextEdit::singleline(&mut plm.token_input)
538                            .password(true)
539                            .hint_text("plm_…"),
540                    );
541                    hits.insert("plm:token".into(), token.rect);
542                    let use_token = ui.add_enabled(
543                        idle && !plm.token_input.trim().is_empty(),
544                        egui::Button::new("Use token"),
545                    );
546                    hits.insert("plm:use-token".into(), use_token.rect);
547                    if use_token.clicked() {
548                        plm.use_token();
549                    }
550                    ui.separator();
551                    ui.label("Or sign in, and this machine keeps a token (not the password):");
552                    let username =
553                        ui.add(egui::TextEdit::singleline(&mut plm.username).hint_text("username"));
554                    hits.insert("plm:username".into(), username.rect);
555                    let password = ui.add(
556                        egui::TextEdit::singleline(&mut plm.password)
557                            .password(true)
558                            .hint_text("password"),
559                    );
560                    hits.insert("plm:password".into(), password.rect);
561                    let can_sign_in =
562                        idle && !plm.username.trim().is_empty() && !plm.password.is_empty();
563                    let sign_in = ui.add_enabled(can_sign_in, egui::Button::new("Sign in"));
564                    hits.insert("plm:sign-in".into(), sign_in.rect);
565                    if sign_in.clicked() {
566                        plm.use_password();
567                    }
568                });
569            hits.insert("plm:sign-in-here".into(), here.header_response.rect);
570            ui.separator();
571        } else {
572            ui.label("This browser uses your PLM session. Manage your account and sign in on the PLM page.");
573            let account = ui.hyperlink_to(
574                "Open PLM account",
575                format!("{}/#/account", plm.url().trim_end_matches('/')),
576            );
577            hits.insert("plm:account".into(), account.rect);
578        }
579        // Connect now (native): once the server has taken this machine's
580        // sign-in, switch this session to it live instead of at next start
581        // (only in the state a fresh start has: no named or dirty document).
582        if native && matches!(plm.status, Status::NotConnected { .. }) && plm.connectable() {
583            let blocked = self.reconnect_blocker.clone();
584            let connect =
585                ui.add_enabled(idle && blocked.is_none(), egui::Button::new("Connect now"));
586            hits.insert("plm:connect".into(), connect.rect);
587            if self.reconnecting {
588                ui.horizontal(|ui| {
589                    ui.spinner();
590                    ui.label("Connecting to the PLM…");
591                });
592            }
593            if let Some(why) = &blocked {
594                ui.label(egui::RichText::new(why).color(PROBLEM_AMBER));
595            }
596            if connect.clicked() {
597                if let Keep::Files(root) = plm.keep() {
598                    self.reconnect_request = Some(root.clone());
599                }
600            }
601        }
602        ui.horizontal(|ui| {
603            let check = ui.add_enabled(idle, egui::Button::new("Check connection"));
604            hits.insert("plm:check".into(), check.rect);
605            if check.clicked() {
606                plm.check();
607            }
608            if native {
609                let forget =
610                    ui.add_enabled(idle, egui::Button::new("Forget this machine's sign-in"));
611                hits.insert("plm:forget".into(), forget.rect);
612                if forget.clicked() {
613                    plm.forget();
614                }
615            }
616        });
617        if !idle {
618            ui.horizontal(|ui| {
619                ui.spinner();
620                ui.label("Asking the server…");
621            });
622        }
623        if let Some(outcome) = &plm.last {
624            let (text, color) = match outcome {
625                Outcome::Done(text) => (text.clone(), ui.visuals().text_color()),
626                Outcome::Failed(text) => (text.clone(), PROBLEM_AMBER),
627            };
628            let said = ui.add(egui::Label::new(egui::RichText::new(text).color(color)).wrap());
629            hits.insert("plm:outcome".into(), said.rect);
630        }
631    }
632
633    /// The `Assemblies` TAB: the BOM's COLUMN CONFIGURATION as one multiline
634    /// textarea, one column per line, a leading `*` for shown.
635    ///
636    /// A hand-written root rather than a schema field: the schema's `FieldKind`s
637    /// are all
638    /// single-widget and render into a tree row's RIGHT-ALIGNED content slot,
639    /// which is exactly the wrong place for a full-width multi-line editor.
640    /// Adding a `TextArea` variant would also force both exhaustive
641    /// `FieldKind` matches open for one consumer.
642    ///
643    /// Commits on focus-LOSS — which includes LEAVING THE TAB, since [`body`]
644    /// draws the pre-click tab for one more frame so this editor is still on
645    /// screen to blur ([`SettingsPanel::body`]) — not per keystroke: this text is
646    /// persisted to the store on commit, and on native that is a rewrite of
647    /// `~/.config/brep-app/settings.json` — per character is a file write per
648    /// character. Parse problems are listed under the editor, naming the line,
649    /// and the text is never rewritten by the panel: a typo costs one column,
650    /// not the configuration.
651    fn assemblies_section(
652        &mut self,
653        ui: &mut egui::Ui,
654        state: &mut EngineState,
655        store: &dyn ModelStore,
656    ) {
657        let open = !self.assemblies_collapsed;
658        let root_resp = tree::node(
659            ui,
660            TreeRow {
661                guides: &[],
662                is_last: true,
663                expandable: true,
664                expanded: open,
665                root: true,
666                glyph: None,
667                label: "Assemblies",
668                selected: false,
669                highlighted: false,
670                draggable: false,
671                tint: None,
672            },
673            |_| {},
674        );
675        self.hits.insert("box:__assemblies".into(), root_resp.box_rect);
676        if root_resp.toggled || root_resp.clicked() {
677            self.assemblies_collapsed = !self.assemblies_collapsed;
678        }
679        if !open {
680            // Drop the buffer while closed so the next open re-seeds from the
681            // engine (a BOM header drag rewrites this text behind the panel).
682            self.bom_columns_buf = None;
683            return;
684        }
685
686        // Empty stored text means "the shipped default", so the editor shows
687        // the default rather than a blank box the user has to guess at.
688        let stored = bom_columns::effective_text(&state.settings.bom_columns);
689        let buffer = self.bom_columns_buf.get_or_insert_with(|| stored.clone());
690
691        ui.label(egui::RichText::new("BOM columns").strong());
692        ui.label(
693            egui::RichText::new(
694                "One per line, in order. A leading * shows it. \
695                 part.<Field> is stored on the part, occurrence.<Field> on one placement. \
696                 A line that is just - freezes the columns above it; the rest scroll.",
697            )
698            .weak(),
699        );
700        let editor = ui.add(
701            egui::TextEdit::multiline(buffer)
702                .id_salt("bom-columns-editor")
703                .desired_rows(8)
704                .desired_width(f32::INFINITY)
705                .code_editor(),
706        );
707        self.hits.insert("field:bomColumns".into(), editor.rect);
708
709        if editor.lost_focus() {
710            // Commit: store the text VERBATIM (never the parse's idea of it).
711            let mut settings_json: serde_json::Value =
712                serde_json::from_str(&state.settings_json()).unwrap_or(serde_json::Value::Null);
713            if let Some(object) = settings_json.as_object_mut() {
714                object.insert(
715                    "bomColumns".into(),
716                    serde_json::Value::String(buffer.clone()),
717                );
718                let json = settings_json.to_string();
719                let _ = state.apply_settings_json(&json);
720                let _ = store.write(SETTINGS_KEY, &json);
721            }
722        } else if !editor.has_focus() && *buffer != stored {
723            // Unfocused and out of step with the engine — the BOM's own header
724            // drag rewrote the configuration. Track it rather than showing a
725            // stale copy the next commit would write back.
726            *buffer = stored;
727        }
728
729        // Parse problems, by line. Listed rather than thrown: the text stands
730        // exactly as typed and every other line still works.
731        let parsed = bom_columns::parse(buffer);
732        if parsed.problems.is_empty() {
733            ui.label(
734                egui::RichText::new(format!(
735                    "{} columns, {} shown",
736                    parsed.columns.len(),
737                    parsed.columns.iter().filter(|column| column.shown).count()
738                ))
739                .weak(),
740            );
741        } else {
742            for problem in &parsed.problems {
743                ui.label(egui::RichText::new(problem).color(PROBLEM_AMBER));
744            }
745        }
746        let reset = ui.button("Reset BOM columns");
747        self.hits.insert("bom-columns:reset".into(), reset.rect);
748        if reset.clicked() {
749            self.bom_columns_buf = None;
750            let mut settings_json: serde_json::Value =
751                serde_json::from_str(&state.settings_json()).unwrap_or(serde_json::Value::Null);
752            if let Some(object) = settings_json.as_object_mut() {
753                // Back to EMPTY, which means "the shipped default" — so a later
754                // change to that default still reaches this user.
755                object.insert("bomColumns".into(), serde_json::Value::String(String::new()));
756                let json = settings_json.to_string();
757                let _ = state.apply_settings_json(&json);
758                let _ = store.write(SETTINGS_KEY, &json);
759            }
760        }
761        ui.add_space(4.0);
762    }
763
764    /// The schema-driven display-settings TREE: a `Display settings` root, one
765    /// collapsible branch per schema group, one leaf per field. Any edit applies to
766    /// `EngineState` (bumps `settings_generation` + `dirty`, so the GPU refreshes)
767    /// and persists through the storage seam.
768    fn settings_section(
769        &mut self,
770        ui: &mut egui::Ui,
771        state: &mut EngineState,
772        store: &dyn ModelStore,
773    ) {
774        // Re-seed a per-frame LOCAL buffer from the LIVE engine settings BEFORE
775        // rendering. The apply below writes the WHOLE buffer, so a buffer kept
776        // across frames would clobber every setting changed elsewhere (the toolbar
777        // wireframe / projection toggles) back to a stale snapshot — the "changing
778        // Render Quality resets my wireframe" bug. A fresh local each frame makes
779        // external changes authoritative and keeps untouched fields a no-op
780        // round-trip (`apply_json`/`to_json` are a documented identity).
781        let before = state.settings_json();
782        let mut settings_json: Value = serde_json::from_str(&before).unwrap_or(Value::Null);
783        let fields = settings_form_fields();
784
785        // Group the schema's contiguous same-group runs, preserving order (the
786        // schema lists each group's fields together).
787        let mut groups: Vec<(String, Vec<&FormField>)> = Vec::new();
788        for f in &fields {
789            if let Some(g) = groups.iter_mut().find(|(n, _)| *n == f.group) {
790                g.1.push(f);
791            } else {
792                groups.push((f.group.clone(), vec![f]));
793            }
794        }
795
796        // --- ROOT: `[-] Display settings` (defaults open) ---------------------
797        let root_open = !self.display_collapsed;
798        let root_resp = tree::node(
799            ui,
800            TreeRow {
801                guides: &[],
802                is_last: true,
803                expandable: true,
804                expanded: root_open,
805                root: true,
806                glyph: None,
807                label: "Display settings",
808                selected: false,
809                highlighted: false,
810                draggable: false,
811                tint: None,
812            },
813            |_| {},
814        );
815        if root_resp.toggled || root_resp.clicked() {
816            self.display_collapsed = !self.display_collapsed;
817        }
818
819        let mut changed = false;
820        if root_open {
821            let n = groups.len();
822            for (gi, (gname, gfields)) in groups.iter().enumerate() {
823                let is_last = gi + 1 == n;
824                let open = !self.closed_groups.contains(gname);
825                let resp = tree::node(ui, TreeRow::branch(&[], is_last, open, gname), |_| {});
826                self.hits.insert(format!("group:{gname}"), resp.box_rect);
827                if resp.toggled || resp.clicked() {
828                    if open {
829                        self.closed_groups.insert(gname.clone());
830                    } else {
831                        self.closed_groups.remove(gname);
832                    }
833                }
834                if !open {
835                    continue;
836                }
837                let base = tree::child_guides(&[], is_last);
838                let m = gfields.len();
839                for (fi, &f) in gfields.iter().enumerate() {
840                    changed |= self.settings_leaf(ui, f, &mut settings_json, &base, fi + 1 == m);
841                }
842            }
843        }
844
845        // Commit the whole buffer ONCE on any edit (same apply + persist path as
846        // before), so the engine re-runs / the GPU refreshes exactly as it did.
847        //
848        // ...and only when the settings actually MOVED. Some field reports a
849        // change on every frame the tree is drawn, and the write it caused went
850        // unnoticed on a file store (one settings.json rewrite per frame) until
851        // the PLM made each one a request: with the window open the store was
852        // never idle. Comparing through the engine's own serialisation makes a
853        // no-op edit a no-op whichever widget claims otherwise.
854        //
855        // A drag (a slider) moves them on every frame it lasts: the engine
856        // follows each frame, the store is written once, on release.
857        if changed {
858            let json = settings_json.to_string();
859            let _ = state.apply_settings_json(&json);
860            if state.settings_json() != before {
861                self.settings_unwritten = true;
862            }
863        }
864        if self.settings_unwritten && !ui.ctx().input(|i| i.pointer.any_down()) {
865            let _ = store.write(SETTINGS_KEY, &state.settings_json());
866            self.settings_unwritten = false;
867        }
868
869        // Reset to defaults — only while the display root is OPEN, matching the
870        // retired CollapsingHeader that hid it when the section was collapsed. It
871        // resets the DISPLAY settings only, which is why it belongs to this tab.
872        if root_open {
873            ui.add_space(2.0);
874            if ui.button("Reset to defaults").clicked() {
875                // Full reset: rebase to defaults, then apply the serialized defaults
876                // (so every key returns, not just the overridden ones) + persist.
877                state.settings = RenderSettings::default();
878                let json = state.settings.to_json();
879                let _ = state.apply_settings_json(&json);
880                let _ = store.write(SETTINGS_KEY, &json);
881            }
882        }
883    }
884
885    /// Render one settings field as a tree LEAF: the field label is the node label;
886    /// its input widget ([`form::field_input`]) fills the row's RIGHT-aligned
887    /// content, exactly like the feature tree's `schema_field`. Settings keys are
888    /// unique across the schema, so no id-stack scoping is needed. Returns whether
889    /// the field changed (the caller commits the whole buffer once).
890    fn settings_leaf(
891        &mut self,
892        ui: &mut egui::Ui,
893        field: &FormField,
894        current: &mut Value,
895        guides: &[bool],
896        is_last: bool,
897    ) -> bool {
898        let mut changed = false;
899        let mut rect = egui::Rect::NOTHING;
900        tree::node(ui, TreeRow::leaf(guides, is_last, &field.label), |ui| {
901            // The tree row's content area is RIGHT-aligned (`right_to_left`), so the
902            // input sits at the panel edge with the label on the left — the feature
903            // tree's exact placement, and the layout `field_input` reads to keep its
904            // inputs COMPACT here. Settings have no reference / button fields, so
905            // `field_input`'s click sink is `None`.
906            let (ch, r) = form::field_input(ui, field, current, None, &mut form::FieldActions::default());
907            changed = ch;
908            rect = r;
909        });
910        self.hits.insert(format!("field:{}", field.key()), rect);
911        changed
912    }
913
914    /// The published widget hit-rects (egui points) for the headed verifier.
915    pub fn hits_json(&self) -> String {
916        crate::automation::hit_rects::hits_json(&self.hits)
917    }
918}
919
920
921/// The PLM tab's browser sign-in (native): one button, then "Continue in your
922/// browser" with the code the page will show, the link to copy when no browser
923/// opened, and Cancel. The link page opens through `open_url`: eframe hands it
924/// to the default browser, and the headless automation host opens nothing. Hit keys `plm:browser-sign-in`, `plm:link-code`,
925/// `plm:link-url`, `plm:copy-link`, `plm:cancel-link`.
926fn browser_sign_in(
927    ui: &mut egui::Ui,
928    plm: &mut crate::plm::connection::PlmConnection,
929    hits: &mut HashMap<String, egui::Rect>,
930    idle: bool,
931) {
932    if let Some(url) = plm.take_open_request() {
933        ui.ctx().open_url(egui::OpenUrl::new_tab(url));
934    }
935    if let Some(wait) = plm.link_wait() {
936        ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(wait.max(0.05)));
937    }
938    let Some(link) = &plm.link else {
939        if plm.linking() {
940            ui.horizontal(|ui| {
941                ui.spinner();
942                ui.label("Starting the sign-in…");
943            });
944            return;
945        }
946        ui.label("Sign in with your PLM account in your web browser. This machine keeps a token, never your password.");
947        let start = ui.add_enabled(idle, egui::Button::new("Sign in with your browser"));
948        hits.insert("plm:browser-sign-in".into(), start.rect);
949        if start.clicked() {
950            plm.sign_in_with_browser();
951        }
952        return;
953    };
954    ui.strong("Continue in your browser");
955    ui.label("Approve the sign-in on the PLM page that opened in your browser. It shows this code:");
956    let code = ui.label(egui::RichText::new(&link.code).monospace().size(22.0).strong());
957    hits.insert("plm:link-code".into(), code.rect);
958    ui.label("No browser page? Copy this link into a browser:");
959    let url = ui.add(egui::Label::new(egui::RichText::new(&link.url).small().weak()).wrap().selectable(true));
960    hits.insert("plm:link-url".into(), url.rect);
961    let (url_text, note) = (link.url.clone(), link.note.clone());
962    ui.horizontal(|ui| {
963        let copy = ui.button("Copy link");
964        hits.insert("plm:copy-link".into(), copy.rect);
965        if copy.clicked() {
966            ui.ctx().copy_text(url_text);
967        }
968        let cancel = ui.button("Cancel");
969        hits.insert("plm:cancel-link".into(), cancel.rect);
970        if cancel.clicked() {
971            plm.cancel_link();
972        }
973    });
974    if let Some(note) = note {
975        ui.label(egui::RichText::new(note).color(PROBLEM_AMBER));
976    }
977    if plm.link.is_some() {
978        ui.horizontal(|ui| {
979            ui.spinner();
980            ui.label("Waiting for the approval…");
981        });
982    }
983}
984
985/// Reload the page onto the PLM session a web sign-in (or sign-out) made.
986fn reload_page() {
987    #[cfg(target_arch = "wasm32")]
988    if let Some(window) = web_sys::window() {
989        let _ = window.location().reload();
990    }
991}
992
993/// The hit keys this panel publishes (see `automation::hit_keys`).
994pub static HIT_KEYS: &[HitKeyDoc] = &[
995    HitKeyDoc { panel: "settings", prefix: "field:", meaning: "a settings field by key", command: None },
996    HitKeyDoc { panel: "settings", prefix: "group:", meaning: "a settings group header", command: None },
997    HitKeyDoc { panel: "settings", prefix: "tab:", meaning: "a settings tab (tab:display, tab:assemblies, tab:plm — the PLM tab is in every session)", command: None },
998    HitKeyDoc { panel: "settings", prefix: "plm:", meaning: "the PLM tab: url (the Server URL field, committed on Enter or focus loss into plm.json), url-source, url-outcome; status, check, outcome; hosted account; desktop token, use-token, username, password, sign-in, forget", command: None },
999    HitKeyDoc { panel: "settings", prefix: "box:", meaning: "expand/collapse a section", command: None },
1000    HitKeyDoc { panel: "settings", prefix: "bom-columns:reset", meaning: "reset the BOM columns", command: None },
1001    HitKeyDoc { panel: "settings", prefix: "panel:clip", meaning: "the visible region of the window", command: None },
1002];