1use 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
48const PROBLEM_AMBER: egui::Color32 = egui::Color32::from_rgb(0xff, 0x9f, 0x0a);
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum Tab {
57 Display,
59 Assemblies,
61 Plm,
65}
66
67pub struct SettingsPanel {
71 pub open: bool,
75 hits: HashMap<String, egui::Rect>,
79 tab: Tab,
82 display_collapsed: bool,
84 closed_groups: HashSet<String>,
87 assemblies_collapsed: bool,
89 bom_columns_buf: Option<String>,
95 plm: Option<crate::plm::connection::PlmConnection>,
98 url_buf: Option<(String, Option<String>)>,
102 url_outcome: Option<crate::plm::connection::Outcome>,
106 pub reconnect_blocker: Option<String>,
109 reconnect_request: Option<std::path::PathBuf>,
111 signed_out: Option<String>,
115}
116
117impl SettingsPanel {
118 pub fn show_plm_tab(&mut self) {
121 self.open = true;
122 self.tab = Tab::Plm;
123 }
124
125 pub fn ask_plm_sign_in(&mut self, why: String) {
128 self.signed_out = Some(why);
129 self.show_plm_tab();
130 }
131
132 pub fn take_fresh_plm_token(&mut self) -> Option<(String, String)> {
136 let fresh = self.plm.as_mut()?.take_fresh_token()?;
137 self.signed_out = None;
138 Some(fresh)
139 }
140
141 pub fn take_plm_signed_out_here(&mut self) -> bool {
144 if !self.plm.as_mut().is_some_and(|plm| plm.take_signed_out_here()) {
145 return false;
146 }
147 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());
148 true
149 }
150
151 pub fn plm_signed_out(&self) -> bool {
153 self.signed_out.is_some()
154 }
155
156 pub fn new() -> Self {
159 Self {
160 open: false,
161 hits: HashMap::new(),
162 tab: Tab::Display,
163 display_collapsed: false,
164 closed_groups: HashSet::new(),
165 assemblies_collapsed: false,
166 bom_columns_buf: None,
167 plm: None,
168 url_buf: None,
169 url_outcome: None,
170 reconnect_blocker: None,
171 reconnect_request: None,
172 signed_out: None,
173 }
174 }
175
176 pub fn show(&mut self, ctx: &egui::Context, state: &mut EngineState, store: &dyn ModelStore) {
181 if self.open {
182 let mut open = true;
185 egui::Window::new("Settings")
186 .open(&mut open)
187 .movable(true)
188 .resizable(true)
189 .default_size([460.0, 480.0])
194 .default_pos(super::below_chrome(ctx, 720.0, 0.0))
197 .show(ctx, |ui| self.body(ui, state, store));
198 self.open = open;
199 } else {
200 self.hits.clear();
208 }
209
210 if crate::automation::registry::enabled() {
213 crate::automation::registry::publish("__brepSettingsHit", "settings window widget rects (field:*, group:*, panel:clip); empty while the window is closed", &self.hits_json());
214 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));
215 }
216 }
217
218 fn body(&mut self, ui: &mut egui::Ui, state: &mut EngineState, store: &dyn ModelStore) {
226 self.hits.clear();
227 self.hits.insert("panel:clip".into(), ui.clip_rect());
238
239 self.sync_plm(store.plm_session());
248 let tab = self.tab;
249 ui.horizontal(|ui| {
250 let display = ui.selectable_value(&mut self.tab, Tab::Display, "Display");
251 let assemblies = ui.selectable_value(&mut self.tab, Tab::Assemblies, "Assemblies");
252 self.hits.insert("tab:display".into(), display.rect);
253 self.hits.insert("tab:assemblies".into(), assemblies.rect);
254 let plm = ui.selectable_value(&mut self.tab, Tab::Plm, "PLM");
255 self.hits.insert("tab:plm".into(), plm.rect);
256 });
257 if self.tab != tab {
263 ui.ctx().request_repaint();
264 }
265 ui.separator();
266
267 egui::ScrollArea::vertical()
268 .auto_shrink([false, false])
269 .show(ui, |ui| {
270 ui.spacing_mut().item_spacing.y = 2.0;
275 match tab {
276 Tab::Display => self.settings_section(ui, state, store),
277 Tab::Assemblies => self.assemblies_section(ui, state, store),
278 Tab::Plm => {
279 self.plm_url_section(ui, store);
280 self.plm_section(ui);
281 }
282 }
283 });
284 }
285
286 fn plm_json(&mut self, store: &dyn ModelStore) -> String {
289 use crate::plm::connection::{Outcome, Status};
290 self.sync_plm(store.plm_session());
291 let setting = store.plm_url_setting();
292 let (url_outcome, url_failed) = match &self.url_outcome {
293 Some(Outcome::Done(text)) => (Some(text.clone()), false),
294 Some(Outcome::Failed(text)) => (Some(text.clone()), true),
295 None => (None, false),
296 };
297 let field = serde_json::json!({
298 "serverUrl": setting.as_ref().and_then(|s| s.url.clone()),
299 "urlSource": setting.as_ref().and_then(|s| s.source).map(crate::plm::config::url_origin),
300 "urlEditable": setting.is_some(),
301 "urlField": self.url_buf.as_ref().map(|(buf, _)| buf.clone()),
302 "urlOutcome": url_outcome, "urlFailed": url_failed,
303 });
304 let Some(plm) = self.plm.as_mut() else {
305 let mut json = serde_json::json!({ "tab": true, "status": "not-configured", "url": null });
306 json.as_object_mut().expect("an object").extend(field.as_object().expect("an object").clone());
307 return json.to_string();
308 };
309 plm.poll();
310 let (status, url, username, reason) = match &plm.status {
311 Status::Connected { url, username } => ("connected", url.clone(), Some(username.clone()), None),
312 Status::NotConnected { url, reason } => ("not-connected", url.clone(), None, reason.clone()),
313 };
314 let (outcome, failed) = match &plm.last {
315 Some(Outcome::Done(text)) => (Some(text.clone()), false),
316 Some(Outcome::Failed(text)) => (Some(text.clone()), true),
317 None => (None, false),
318 };
319 let mut json = serde_json::json!({
320 "tab": true, "status": status, "url": url, "username": username, "reason": reason,
321 "busy": plm.busy(), "outcome": outcome, "failed": failed,
322 "connectable": plm.connectable(), "reconnectBlocker": self.reconnect_blocker,
323 "linking": plm.linking(),
324 "linkCode": plm.link.as_ref().map(|l| l.code.clone()),
325 "linkUrl": plm.link.as_ref().map(|l| l.url.clone()),
326 "linkToken": plm.link.as_ref().and_then(|l| l.url.split_once("token=").map(|(_, t)| t.to_string())),
328 "linkNote": plm.link.as_ref().and_then(|l| l.note.clone()),
329 });
330 json.as_object_mut().expect("an object").extend(field.as_object().expect("an object").clone());
331 json.to_string()
332 }
333
334 pub fn take_reconnect_request(&mut self) -> Option<std::path::PathBuf> {
336 self.reconnect_request.take()
337 }
338
339 pub fn reconnected(&mut self, outcome: Result<String, String>) {
341 if let Some(plm) = self.plm.as_mut() {
342 plm.reconnected(outcome);
343 }
344 }
345
346 fn sync_plm(&mut self, session: Option<crate::plm::connection::Session>) {
348 use crate::plm::connection::PlmConnection;
349 let Some(session) = session else {
350 self.plm = None;
351 return;
352 };
353 let url = match &session.status {
354 crate::plm::connection::Status::Connected { url, .. } | crate::plm::connection::Status::NotConnected { url, .. } => url.clone(),
355 };
356 let same = self.plm.as_ref().is_some_and(|c| c.url() == url && *c.keep() == session.keep);
357 let status = match (&self.signed_out, session.status) {
359 (Some(why), crate::plm::connection::Status::Connected { url, .. }) => {
360 crate::plm::connection::Status::NotConnected { url, reason: Some(why.clone()) }
361 }
362 (_, status) => status,
363 };
364 match self.plm.as_mut().filter(|_| same) {
365 Some(plm) => plm.set_status(status),
367 None => self.plm = Some(PlmConnection::new(status, session.keep)),
368 }
369 }
370
371 fn plm_url_section(&mut self, ui: &mut egui::Ui, store: &dyn ModelStore) {
377 use crate::plm::config::{url_origin, Source};
378 use crate::plm::connection::{Outcome, Status};
379 let setting = store.plm_url_setting();
380 let editable = setting.is_some();
381 let shown = match &setting {
383 Some(setting) => setting.url.clone(),
384 None => self.plm.as_ref().map(|plm| plm.url().to_string()).filter(|url| !url.is_empty()),
385 };
386 let source = setting.as_ref().and_then(|s| s.source);
387 if self.url_buf.as_ref().is_none_or(|(_, from)| *from != shown) {
388 self.url_buf = Some((shown.clone().unwrap_or_default(), shown.clone()));
389 }
390
391 tree::node(
392 ui,
393 TreeRow {
394 guides: &[],
395 is_last: true,
396 expandable: false,
397 expanded: true,
398 root: true,
399 glyph: None,
400 label: "PLM server",
401 selected: false,
402 highlighted: false,
403 draggable: false,
404 tint: None,
405 },
406 |_| {},
407 );
408 let mut committed = false;
409 let mut rect = egui::Rect::NOTHING;
410 let (buf, _) = self.url_buf.as_mut().expect("seeded above");
411 tree::node(ui, TreeRow::leaf(&[], true, "Server URL"), |ui| {
412 ui.add_enabled_ui(editable, |ui| {
413 let width = form::text_input_width(ui);
414 let field = form::text_input(ui, buf, width);
415 committed = field.lost_focus();
416 rect = field.rect;
417 });
418 });
419 self.hits.insert("plm:url".into(), rect);
420
421 if committed && editable {
422 let typed = buf.trim().to_string();
423 if typed != shown.clone().unwrap_or_default() {
424 self.url_outcome = Some(if typed.is_empty() {
425 Outcome::Failed("an empty URL is not saved: Forget this machine's sign-in removes the server from this machine".into())
426 } else {
427 match store.save_plm_url(&typed) {
428 Err(why) => Outcome::Failed(format!("not saved: {why}")),
429 Ok(saved) => {
430 let mut said = format!("saved to plm.json: {}", saved.url);
431 if saved.token_dropped {
432 said += "; this machine's sign-in for the previous server is forgotten (a token is never sent to another server)";
433 }
434 match (source, self.plm.as_ref().map(|plm| &plm.status)) {
435 (Some(Source::Env | Source::Flag), _) => {
436 said += &format!(
437 "; this launch stays on {} ({} overrides plm.json), and a start without it uses {}",
438 shown.clone().unwrap_or_default(),
439 url_origin(source.expect("matched")),
440 saved.url
441 );
442 }
443 (_, Some(Status::Connected { url, .. })) if *url != saved.url => {
444 said += &format!("; this session stays on {url} until the next start");
445 }
446 _ => said += "; sign in below, then Connect now (or the next start) puts this session on it",
447 }
448 let from = store.plm_url_setting().and_then(|s| s.url);
451 self.url_buf = Some((saved.url.clone(), from));
452 Outcome::Done(said)
453 }
454 }
455 });
456 }
457 }
458
459 let note = match (&setting, source) {
460 (Some(_), Some(source @ (Source::Env | Source::Flag))) => format!(
461 "From {}, which overrides plm.json for this launch. A URL saved here is what a launch without it uses.",
462 url_origin(source)
463 ),
464 (Some(_), Some(Source::File)) => {
465 "From plm.json. Changing the server forgets this machine's sign-in: a token is never sent to a different server.".into()
466 }
467 (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(),
468 (None, _) if shown.is_some() => "The web app uses the PLM that serves this page.".into(),
469 (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(),
470 };
471 let said = ui.add(egui::Label::new(note).wrap());
472 self.hits.insert("plm:url-source".into(), said.rect);
473 if let Some(outcome) = &self.url_outcome {
474 let (text, color) = match outcome {
475 Outcome::Done(text) => (text.clone(), ui.visuals().text_color()),
476 Outcome::Failed(text) => (text.clone(), PROBLEM_AMBER),
477 };
478 let said = ui.add(egui::Label::new(egui::RichText::new(text).color(color)).wrap());
479 self.hits.insert("plm:url-outcome".into(), said.rect);
480 }
481 ui.separator();
482 }
483
484 fn plm_section(&mut self, ui: &mut egui::Ui) {
488 use crate::plm::connection::{Keep, Outcome, Status};
489 let Some(plm) = self.plm.as_mut() else { return };
490 if plm.poll() || plm.busy() {
491 ui.ctx().request_repaint();
492 }
493 if plm.take_reload() {
494 reload_page();
495 }
496 let hits = &mut self.hits;
497 let status = match &plm.status {
498 Status::Connected { url, username } => format!("Connected to {url} as {username}."),
499 Status::NotConnected { url, reason: None } => format!("Not connected to {url}."),
500 Status::NotConnected {
501 url,
502 reason: Some(why),
503 } => format!("Not connected to {url}: {why}"),
504 };
505 let shown = ui.add(egui::Label::new(status).wrap());
506 hits.insert("plm:status".into(), shown.rect);
507 ui.separator();
508 if plm.url().is_empty() {
511 return;
512 }
513
514 let native = matches!(plm.keep(), Keep::Files(_));
515 let idle = !plm.busy();
516 if native {
517 browser_sign_in(ui, plm, hits, idle);
518 ui.separator();
519 let here = egui::CollapsingHeader::new("Sign in here instead")
522 .id_salt("plm-sign-in-here")
523 .default_open(false)
524 .show(ui, |ui| {
525 ui.label("Paste an API token (from the PLM's web page):");
526 let token = ui.add(
527 egui::TextEdit::singleline(&mut plm.token_input)
528 .password(true)
529 .hint_text("plm_…"),
530 );
531 hits.insert("plm:token".into(), token.rect);
532 let use_token = ui.add_enabled(
533 idle && !plm.token_input.trim().is_empty(),
534 egui::Button::new("Use token"),
535 );
536 hits.insert("plm:use-token".into(), use_token.rect);
537 if use_token.clicked() {
538 plm.use_token();
539 }
540 ui.separator();
541 ui.label("Or sign in, and this machine keeps a token (not the password):");
542 let username =
543 ui.add(egui::TextEdit::singleline(&mut plm.username).hint_text("username"));
544 hits.insert("plm:username".into(), username.rect);
545 let password = ui.add(
546 egui::TextEdit::singleline(&mut plm.password)
547 .password(true)
548 .hint_text("password"),
549 );
550 hits.insert("plm:password".into(), password.rect);
551 let can_sign_in =
552 idle && !plm.username.trim().is_empty() && !plm.password.is_empty();
553 let sign_in = ui.add_enabled(can_sign_in, egui::Button::new("Sign in"));
554 hits.insert("plm:sign-in".into(), sign_in.rect);
555 if sign_in.clicked() {
556 plm.use_password();
557 }
558 });
559 hits.insert("plm:sign-in-here".into(), here.header_response.rect);
560 ui.separator();
561 } else {
562 ui.label("This browser uses your PLM session. Manage your account and sign in on the PLM page.");
563 let account = ui.hyperlink_to(
564 "Open PLM account",
565 format!("{}/#/account", plm.url().trim_end_matches('/')),
566 );
567 hits.insert("plm:account".into(), account.rect);
568 }
569 if native && matches!(plm.status, Status::NotConnected { .. }) && plm.connectable() {
573 let blocked = self.reconnect_blocker.clone();
574 let connect =
575 ui.add_enabled(idle && blocked.is_none(), egui::Button::new("Connect now"));
576 hits.insert("plm:connect".into(), connect.rect);
577 if let Some(why) = &blocked {
578 ui.label(egui::RichText::new(why).color(PROBLEM_AMBER));
579 }
580 if connect.clicked() {
581 if let Keep::Files(root) = plm.keep() {
582 self.reconnect_request = Some(root.clone());
583 }
584 }
585 }
586 ui.horizontal(|ui| {
587 let check = ui.add_enabled(idle, egui::Button::new("Check connection"));
588 hits.insert("plm:check".into(), check.rect);
589 if check.clicked() {
590 plm.check();
591 }
592 if native {
593 let forget =
594 ui.add_enabled(idle, egui::Button::new("Forget this machine's sign-in"));
595 hits.insert("plm:forget".into(), forget.rect);
596 if forget.clicked() {
597 plm.forget();
598 }
599 }
600 });
601 if !idle {
602 ui.horizontal(|ui| {
603 ui.spinner();
604 ui.label("Asking the server…");
605 });
606 }
607 if let Some(outcome) = &plm.last {
608 let (text, color) = match outcome {
609 Outcome::Done(text) => (text.clone(), ui.visuals().text_color()),
610 Outcome::Failed(text) => (text.clone(), PROBLEM_AMBER),
611 };
612 let said = ui.add(egui::Label::new(egui::RichText::new(text).color(color)).wrap());
613 hits.insert("plm:outcome".into(), said.rect);
614 }
615 }
616
617 fn assemblies_section(
636 &mut self,
637 ui: &mut egui::Ui,
638 state: &mut EngineState,
639 store: &dyn ModelStore,
640 ) {
641 let open = !self.assemblies_collapsed;
642 let root_resp = tree::node(
643 ui,
644 TreeRow {
645 guides: &[],
646 is_last: true,
647 expandable: true,
648 expanded: open,
649 root: true,
650 glyph: None,
651 label: "Assemblies",
652 selected: false,
653 highlighted: false,
654 draggable: false,
655 tint: None,
656 },
657 |_| {},
658 );
659 self.hits.insert("box:__assemblies".into(), root_resp.box_rect);
660 if root_resp.toggled || root_resp.clicked() {
661 self.assemblies_collapsed = !self.assemblies_collapsed;
662 }
663 if !open {
664 self.bom_columns_buf = None;
667 return;
668 }
669
670 let stored = bom_columns::effective_text(&state.settings.bom_columns);
673 let buffer = self.bom_columns_buf.get_or_insert_with(|| stored.clone());
674
675 ui.label(egui::RichText::new("BOM columns").strong());
676 ui.label(
677 egui::RichText::new(
678 "One per line, in order. A leading * shows it. \
679 part.<Field> is stored on the part, occurrence.<Field> on one placement. \
680 A line that is just - freezes the columns above it; the rest scroll.",
681 )
682 .weak(),
683 );
684 let editor = ui.add(
685 egui::TextEdit::multiline(buffer)
686 .id_salt("bom-columns-editor")
687 .desired_rows(8)
688 .desired_width(f32::INFINITY)
689 .code_editor(),
690 );
691 self.hits.insert("field:bomColumns".into(), editor.rect);
692
693 if editor.lost_focus() {
694 let mut settings_json: serde_json::Value =
696 serde_json::from_str(&state.settings_json()).unwrap_or(serde_json::Value::Null);
697 if let Some(object) = settings_json.as_object_mut() {
698 object.insert(
699 "bomColumns".into(),
700 serde_json::Value::String(buffer.clone()),
701 );
702 let json = settings_json.to_string();
703 let _ = state.apply_settings_json(&json);
704 let _ = store.write(SETTINGS_KEY, &json);
705 }
706 } else if !editor.has_focus() && *buffer != stored {
707 *buffer = stored;
711 }
712
713 let parsed = bom_columns::parse(buffer);
716 if parsed.problems.is_empty() {
717 ui.label(
718 egui::RichText::new(format!(
719 "{} columns, {} shown",
720 parsed.columns.len(),
721 parsed.columns.iter().filter(|column| column.shown).count()
722 ))
723 .weak(),
724 );
725 } else {
726 for problem in &parsed.problems {
727 ui.label(egui::RichText::new(problem).color(PROBLEM_AMBER));
728 }
729 }
730 let reset = ui.button("Reset BOM columns");
731 self.hits.insert("bom-columns:reset".into(), reset.rect);
732 if reset.clicked() {
733 self.bom_columns_buf = None;
734 let mut settings_json: serde_json::Value =
735 serde_json::from_str(&state.settings_json()).unwrap_or(serde_json::Value::Null);
736 if let Some(object) = settings_json.as_object_mut() {
737 object.insert("bomColumns".into(), serde_json::Value::String(String::new()));
740 let json = settings_json.to_string();
741 let _ = state.apply_settings_json(&json);
742 let _ = store.write(SETTINGS_KEY, &json);
743 }
744 }
745 ui.add_space(4.0);
746 }
747
748 fn settings_section(
753 &mut self,
754 ui: &mut egui::Ui,
755 state: &mut EngineState,
756 store: &dyn ModelStore,
757 ) {
758 let before = state.settings_json();
766 let mut settings_json: Value = serde_json::from_str(&before).unwrap_or(Value::Null);
767 let fields = settings_form_fields();
768
769 let mut groups: Vec<(String, Vec<&FormField>)> = Vec::new();
772 for f in &fields {
773 if let Some(g) = groups.iter_mut().find(|(n, _)| *n == f.group) {
774 g.1.push(f);
775 } else {
776 groups.push((f.group.clone(), vec![f]));
777 }
778 }
779
780 let root_open = !self.display_collapsed;
782 let root_resp = tree::node(
783 ui,
784 TreeRow {
785 guides: &[],
786 is_last: true,
787 expandable: true,
788 expanded: root_open,
789 root: true,
790 glyph: None,
791 label: "Display settings",
792 selected: false,
793 highlighted: false,
794 draggable: false,
795 tint: None,
796 },
797 |_| {},
798 );
799 if root_resp.toggled || root_resp.clicked() {
800 self.display_collapsed = !self.display_collapsed;
801 }
802
803 let mut changed = false;
804 if root_open {
805 let n = groups.len();
806 for (gi, (gname, gfields)) in groups.iter().enumerate() {
807 let is_last = gi + 1 == n;
808 let open = !self.closed_groups.contains(gname);
809 let resp = tree::node(ui, TreeRow::branch(&[], is_last, open, gname), |_| {});
810 self.hits.insert(format!("group:{gname}"), resp.box_rect);
811 if resp.toggled || resp.clicked() {
812 if open {
813 self.closed_groups.insert(gname.clone());
814 } else {
815 self.closed_groups.remove(gname);
816 }
817 }
818 if !open {
819 continue;
820 }
821 let base = tree::child_guides(&[], is_last);
822 let m = gfields.len();
823 for (fi, &f) in gfields.iter().enumerate() {
824 changed |= self.settings_leaf(ui, f, &mut settings_json, &base, fi + 1 == m);
825 }
826 }
827 }
828
829 if changed {
839 let json = settings_json.to_string();
840 let _ = state.apply_settings_json(&json);
841 if state.settings_json() != before {
842 let _ = store.write(SETTINGS_KEY, &json);
843 }
844 }
845
846 if root_open {
850 ui.add_space(2.0);
851 if ui.button("Reset to defaults").clicked() {
852 state.settings = RenderSettings::default();
855 let json = state.settings.to_json();
856 let _ = state.apply_settings_json(&json);
857 let _ = store.write(SETTINGS_KEY, &json);
858 }
859 }
860 }
861
862 fn settings_leaf(
868 &mut self,
869 ui: &mut egui::Ui,
870 field: &FormField,
871 current: &mut Value,
872 guides: &[bool],
873 is_last: bool,
874 ) -> bool {
875 let mut changed = false;
876 let mut rect = egui::Rect::NOTHING;
877 tree::node(ui, TreeRow::leaf(guides, is_last, &field.label), |ui| {
878 let (ch, r) = form::field_input(ui, field, current, None, &mut form::FieldActions::default());
884 changed = ch;
885 rect = r;
886 });
887 self.hits.insert(format!("field:{}", field.key()), rect);
888 changed
889 }
890
891 pub fn hits_json(&self) -> String {
893 crate::automation::hit_rects::hits_json(&self.hits)
894 }
895}
896
897
898fn browser_sign_in(
904 ui: &mut egui::Ui,
905 plm: &mut crate::plm::connection::PlmConnection,
906 hits: &mut HashMap<String, egui::Rect>,
907 idle: bool,
908) {
909 if let Some(url) = plm.take_open_request() {
910 ui.ctx().open_url(egui::OpenUrl::new_tab(url));
911 }
912 if let Some(wait) = plm.link_wait() {
913 ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(wait.max(0.05)));
914 }
915 let Some(link) = &plm.link else {
916 if plm.linking() {
917 ui.horizontal(|ui| {
918 ui.spinner();
919 ui.label("Starting the sign-in…");
920 });
921 return;
922 }
923 ui.label("Sign in with your PLM account in your web browser. This machine keeps a token, never your password.");
924 let start = ui.add_enabled(idle, egui::Button::new("Sign in with your browser"));
925 hits.insert("plm:browser-sign-in".into(), start.rect);
926 if start.clicked() {
927 plm.sign_in_with_browser();
928 }
929 return;
930 };
931 ui.strong("Continue in your browser");
932 ui.label("Approve the sign-in on the PLM page that opened in your browser. It shows this code:");
933 let code = ui.label(egui::RichText::new(&link.code).monospace().size(22.0).strong());
934 hits.insert("plm:link-code".into(), code.rect);
935 ui.label("No browser page? Copy this link into a browser:");
936 let url = ui.add(egui::Label::new(egui::RichText::new(&link.url).small().weak()).wrap().selectable(true));
937 hits.insert("plm:link-url".into(), url.rect);
938 let (url_text, note) = (link.url.clone(), link.note.clone());
939 ui.horizontal(|ui| {
940 let copy = ui.button("Copy link");
941 hits.insert("plm:copy-link".into(), copy.rect);
942 if copy.clicked() {
943 ui.ctx().copy_text(url_text);
944 }
945 let cancel = ui.button("Cancel");
946 hits.insert("plm:cancel-link".into(), cancel.rect);
947 if cancel.clicked() {
948 plm.cancel_link();
949 }
950 });
951 if let Some(note) = note {
952 ui.label(egui::RichText::new(note).color(PROBLEM_AMBER));
953 }
954 if plm.link.is_some() {
955 ui.horizontal(|ui| {
956 ui.spinner();
957 ui.label("Waiting for the approval…");
958 });
959 }
960}
961
962fn reload_page() {
964 #[cfg(target_arch = "wasm32")]
965 if let Some(window) = web_sys::window() {
966 let _ = window.location().reload();
967 }
968}
969
970pub static HIT_KEYS: &[HitKeyDoc] = &[
972 HitKeyDoc { panel: "settings", prefix: "field:", meaning: "a settings field by key", command: None },
973 HitKeyDoc { panel: "settings", prefix: "group:", meaning: "a settings group header", command: None },
974 HitKeyDoc { panel: "settings", prefix: "tab:", meaning: "a settings tab (tab:display, tab:assemblies, tab:plm — the PLM tab is in every session)", command: None },
975 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 },
976 HitKeyDoc { panel: "settings", prefix: "box:", meaning: "expand/collapse a section", command: None },
977 HitKeyDoc { panel: "settings", prefix: "bom-columns:reset", meaning: "reset the BOM columns", command: None },
978 HitKeyDoc { panel: "settings", prefix: "panel:clip", meaning: "the visible region of the window", command: None },
979];