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