brep_app/panels/toolbar.rs
1//! Toolbar — a top `Panel` strip of primary actions above the viewport:
2//! Undo / Redo, a Wireframe toggle, and Zoom-to-fit, plus the File-actions
3//! SEAM (owned by the concurrent file panel). Standard views live on the
4//! ViewCube navigation gizmo, not here.
5//!
6//! Follows the panel pattern (a small state struct + a `show(&mut self, ui,
7//! state, store)` the shell calls once), but unlike the left-column sections it
8//! creates its OWN top panel — so the shell just calls `self.toolbar.show(…)`
9//! FIRST in `App::ui` (before the left panel + viewport) to reserve the strip.
10//!
11//! The MODEL is engine-owned: the toolbar only TRIGGERS engine methods
12//! (`state.undo()` / `state.redo()` / `state.zoom_to_fit()`) and drives the
13//! wireframe through the existing
14//! settings-apply path (`apply_settings_json` → bumps generation + dirty). It
15//! owns only the per-frame `hits` map (widget screen rects) the headed verifier
16//! reads to drive real clicks, exactly like the history panel.
17//!
18//! The File buttons don't touch storage here: a click returns a [`FileAction`]
19//! from [`ToolbarPanel::show`] and the shell hands it to the reusable
20//! [`crate::panels::file::FileDialog`].
21//!
22//! Buttons draw their artwork from `assets/glyphs/*.svg`, with the text label in
23//! the hover tooltip. The COLOUR icons are single SVGs from the icon catalog
24//! (see [`crate::icons`]), painted by [`toolbar_button`] whenever a glyph
25//! resolves to colour artwork — they replaced the private-use glyph STACKS this
26//! file used to assemble by hand, one layer per colour. Neither depends on an OS
27//! font or a bitmap asset. All styling/sizing goes through the shared
28//! [`crate::panels::toolbar_button`] helpers (the ONE place toolbar-button style
29//! lives), so a change lands globally. Glyph per button:
30//! New U+E010 (doc) colour
31//! Open U+E011 (folder) colour
32//! Save U+E012 (disk) colour
33//! Save As U+E013 (disk + badge) colour
34//! Import U+E014 (tray + down arrow) colour
35//! Export U+E015 (tray + up arrow) colour
36//! Undo U+E016 (curved arrow) colour
37//! Redo U+E017 (curved arrow) colour
38//! Projctn U+E018 (camera) colour
39//! Submit Bug U+1F41E (bug) colour
40//! Wireframe U+1F578 (single glyph)
41//! Faces U+E028 (shaded cube)
42//! Edges U+E029 (edge cube)
43//! Vertices U+E02A (corner points)
44//! Fit U+26F6 (single glyph)
45//! Settings U+2699 (single glyph)
46//! Help U+2753 (circled question mark)
47//! Info U+2139 (circled i)
48//! Properties U+E066 (part tag)
49//!
50//! The Properties button here is the DOCUMENT's: it opens the open part's own
51//! BOM attribute record (see [`crate::panels::part_properties`]), which is a
52//! property of the whole document and so has nothing to be selected first.
53//! ENTITY inspection is a different thing and is not here: it is opened from the
54//! selection-driven CONTEXT bar (see [`crate::panels::context_bar`]), which spawns
55//! a pinned per-entity window (see [`crate::panels::info_windows`]).
56
57use crate::automation::hit_keys::HitKeyDoc;
58use crate::panels::file::FileAction;
59use crate::panels::toolbar_button;
60use crate::store::{ModelStore, SETTINGS_KEY};
61use crate::workbench;
62use brep_render::engine_state::EngineState;
63use eframe::egui;
64use std::collections::HashMap;
65
66
67/// What one frame of the toolbar produced for the shell to act on. Extends the
68/// old `Option<FileAction>` return so a clicked WORKBENCH button can flow its id
69/// out the SAME way a File button flows a [`FileAction`] — the shell matches on
70/// the id. Phase 1 declares no workbench buttons, so `workbench_button` is always
71/// `None`, but the return path is wired for Phase 2.
72#[derive(Default)]
73pub struct ToolbarOutcome {
74 /// A File button click (New / Open / Save / …), dispatched to the file dialog.
75 pub file: Option<FileAction>,
76 /// A workbench toolbar button click, surfaced by its `WorkbenchButton::id`.
77 pub workbench_button: Option<&'static str>,
78 /// The "Submit Bug" button was clicked this frame — the shell begins the
79 /// screenshot-capture + report flow (see [`crate::panels::bug_report`]).
80 pub bug_report: bool,
81}
82
83/// The toolbar's own state: the per-frame map of egui widget screen rects,
84/// published to JS for the headed verifier to drive real clicks. Rebuilt each
85/// frame (there is no DOM — egui is drawn on the canvas).
86#[derive(Default)]
87pub struct ToolbarPanel {
88 hits: HashMap<String, egui::Rect>,
89}
90
91impl ToolbarPanel {
92 pub fn new() -> Self {
93 Self::default()
94 }
95
96 /// Draw the toolbar as a top panel of primary actions. Called FIRST in the
97 /// shell's `App::ui` so the strip reserves the top before the left panel and
98 /// the central viewport. Rebuilds `hits` each frame as it draws. Returns the
99 /// [`FileAction`] a clicked File button requests (the shell dispatches it to
100 /// the file dialog), or `None`.
101 ///
102 /// `settings_open` is the shell-owned open flag of the floating Settings window
103 /// (see [`crate::panels::settings`]): the gear button reflects it (highlighted
104 /// while open) and toggles it on click. `info_open` is the same arrangement
105 /// for the Info window (see [`crate::panels::info`]).
106 pub fn show(
107 &mut self,
108 ui: &mut egui::Ui,
109 state: &mut EngineState,
110 store: &dyn ModelStore,
111 settings_open: &mut bool,
112 properties_open: &mut bool,
113 info_open: &mut bool,
114 ) -> ToolbarOutcome {
115 self.hits.clear();
116 let mut outcome = ToolbarOutcome::default();
117 egui::containers::panel::Panel::top("brep-toolbar")
118 .resizable(false)
119 .show(ui, |ui| {
120 ui.add_space(3.0);
121 ui.horizontal_wrapped(|ui| {
122 // Workbench selector FIRST (top-left). It is a UI FILTER, not a
123 // mode switch — it only trims the feature-creation palette /
124 // offers. Its extra BUTTONS render LAST (after the normal icons).
125 self.workbench_selector(ui, state, store);
126 ui.separator();
127 outcome.file = self.file_actions(ui);
128 ui.separator();
129 self.edit_actions(ui, state);
130 ui.separator();
131 self.view_actions(ui, state, store);
132 ui.separator();
133 self.properties_action(ui, properties_open);
134 self.settings_action(ui, settings_open);
135 self.help_action(ui);
136 self.info_action(ui, info_open);
137 outcome.bug_report = self.bug_action(ui);
138 // The active workbench's extra buttons go at the END of the
139 // toolbar, after the standard icons (a leading separator draws
140 // only when the active workbench actually declares buttons).
141 let current = state.settings.workbench.clone();
142 outcome.workbench_button = self.workbench_buttons_row(ui, ¤t);
143 });
144 ui.add_space(3.0);
145 });
146 outcome
147 }
148
149 /// The WORKBENCH dropdown: iterates the per-file registry
150 /// ([`workbench::WORKBENCHES`]) so labels/order are never hardcoded, shows the
151 /// current selection (`state.settings.workbench`), and on change writes the id
152 /// through the SAME apply+save path the wireframe/projection toggles use here
153 /// (`apply_settings_json` + `store.write`), so it persists across a reload.
154 /// Publishes the header + per-item hit-rects for the headed verifier.
155 fn workbench_selector(
156 &mut self,
157 ui: &mut egui::Ui,
158 state: &mut EngineState,
159 store: &dyn ModelStore,
160 ) {
161 let current = state.settings.workbench.clone();
162 let options: Vec<(&str, &str, &str)> =
163 workbench::WORKBENCHES.iter().map(|w| (w.label, w.id, w.glyph)).collect();
164 let result =
165 toolbar_button::select(ui, "workbench", workbench::resolve(¤t).id, &options);
166 self.hits.insert("workbench".into(), result.header_rect);
167 for (id, rect) in &result.item_rects {
168 self.hits.insert(format!("workbench:item:{id}"), *rect);
169 }
170 if let Some(next) = result.changed {
171 let json = serde_json::json!({ "workbench": next }).to_string();
172 let _ = state.apply_settings_json(&json);
173 // Persist through the same seam wireframe/projection use so the choice
174 // survives a reload and the settings blob agrees.
175 let _ = store.write(SETTINGS_KEY, &state.settings_json());
176 }
177 }
178
179 /// Render the ACTIVE workbench's extra toolbar buttons generically. Phase 1:
180 /// every workbench declares no buttons, so this draws nothing and returns
181 /// `None`.
182 fn workbench_buttons_row(&mut self, ui: &mut egui::Ui, current: &str) -> Option<&'static str> {
183 let buttons = workbench::workbench_buttons(current);
184 // A leading separator only when there is at least one button, so an
185 // empty-button workbench (e.g. Modeling) leaves no dangling separator at
186 // the toolbar's end.
187 if !buttons.is_empty() {
188 ui.separator();
189 }
190 self.draw_workbench_buttons(ui, &buttons)
191 }
192
193 /// Draw an explicit list of workbench buttons via the shared button helper,
194 /// publishing each hit-rect and surfacing the clicked button's `id` (the
195 /// toolbar RETURN PATH — the shell dispatches on it, exactly like a
196 /// [`FileAction`]). Split out from [`Self::workbench_buttons_row`] so the
197 /// mechanism can be unit-tested with a synthetic button while the real
198 /// registry ships none in Phase 1.
199 fn draw_workbench_buttons(
200 &mut self,
201 ui: &mut egui::Ui,
202 buttons: &[&workbench::WorkbenchButton],
203 ) -> Option<&'static str> {
204 let mut clicked = None;
205 for button in buttons {
206 let resp = toolbar_button::button(ui, button.glyph, button.tooltip);
207 self.hits.insert(format!("workbench:btn:{}", button.id), resp.rect);
208 if resp.clicked() {
209 clicked = Some(button.id);
210 }
211 }
212 clicked
213 }
214
215 /// The Help button: opens the generated help site (`brep-docs` writes it to
216 /// `web/help/` next to the served page) in a new tab. A circled question
217 /// mark (U+2753), drawn in the same line style as the ℹ beside it — this app
218 /// ships NO font fallback, so a glyph is only ever a key into the SVG
219 /// catalog (`assets/glyphs/icon_2753.svg`) and an uncatalogued character
220 /// would paint as tofu on wasm.
221 fn help_action(&mut self, ui: &mut egui::Ui) {
222 let btn = toolbar_button::button(ui, "\u{2753}", "Help");
223 self.hits.insert("help".into(), btn.rect);
224 if btn.clicked() {
225 // ONE literal for the help URL, shared with the `help_open` command.
226 ui.ctx().open_url(egui::OpenUrl::new_tab(crate::automation::HELP_URL));
227 }
228 }
229
230 /// The Info toggle: opens / closes the floating Info window — the licences
231 /// and this session's renderer diagnostics (see [`crate::panels::info`]).
232 /// The ℹ glyph (U+2139) this button now carries is the one the single Docs
233 /// button used to; Help took the question mark, which is what a user looks
234 /// for when they want the manual.
235 fn info_action(&mut self, ui: &mut egui::Ui, open: &mut bool) {
236 let btn = toolbar_button::toggle(ui, *open, "\u{2139}", "Info (licences and diagnostics)");
237 self.hits.insert("info".into(), btn.rect);
238 if btn.clicked() {
239 *open = !*open;
240 }
241 }
242
243 /// The Submit Bug button: opens the in-app problem-report flow, which first
244 /// grabs a screenshot of the app (UI + 3D model) BEFORE its dialog appears,
245 /// then collects a description + optional email and posts the report.
246 /// `bug_report` Material Symbol (base glyph U+1F41E) — `toolbar_button`
247 /// auto-renders it from the icon catalog: U+1F41E is COLOUR artwork, so the
248 /// button paints the SVG rather than the font glyph.
249 /// Returns whether it was clicked.
250 fn bug_action(&mut self, ui: &mut egui::Ui) -> bool {
251 let btn = toolbar_button::button(ui, "\u{1F41E}", "Submit Bug");
252 self.hits.insert("bug".into(), btn.rect);
253 btn.clicked()
254 }
255
256 /// The Part Properties toggle: opens / closes the floating window that edits
257 /// the OPEN document's own BOM attributes (Part Number, Material, Mass, …).
258 /// A tag glyph (U+E066), reflecting the live open state like the gear beside
259 /// it. Document-level, so it needs no selection and is never disabled — every
260 /// document is a part, an assembly included (it is one BOM row in its parent).
261 fn properties_action(&mut self, ui: &mut egui::Ui, open: &mut bool) {
262 let btn = toolbar_button::toggle(ui, *open, "\u{E066}", "Part properties");
263 self.hits.insert("properties".into(), btn.rect);
264 if btn.clicked() {
265 *open = !*open;
266 }
267 }
268
269 /// The Settings toggle: opens / closes the floating Settings window. A
270 /// selectable gear glyph (U+2699, bundled DejaVu font) reflecting the live
271 /// open state; the label lives in the tooltip, matching the other buttons.
272 fn settings_action(&mut self, ui: &mut egui::Ui, open: &mut bool) {
273 // Gear (U+2699) — renders in the bundled DejaVu font (no tofu). A toggle
274 // reflecting the live open state.
275 let btn = toolbar_button::toggle(ui, *open, "\u{2699}", "Settings");
276 self.hits.insert("settings".into(), btn.rect);
277 if btn.clicked() {
278 *open = !*open;
279 }
280 }
281
282 /// The published widget hit-rects (egui points) for the headed verifier.
283 pub fn hits_json(&self) -> String {
284 crate::automation::hit_rects::hits_json(&self.hits)
285 }
286
287 /// File actions (New / Open / Save / Save As) as glyph buttons. Each returns
288 /// the matching [`FileAction`] on click; the shell hands it to the file
289 /// dialog (which owns all storage). Glyph → label in the tooltip.
290 fn file_actions(&mut self, ui: &mut egui::Ui) -> Option<FileAction> {
291 let mut action = None;
292 // New — page (U+1F4C4, the previous-app glyph).
293 let new = toolbar_button::button(ui, "\u{E010}", "New");
294 self.hits.insert("file:new".into(), new.rect);
295 if new.clicked() {
296 action = Some(FileAction::New);
297 }
298 // Open — open folder (U+1F5C1). Kept: the previous app had no Open.
299 let open = toolbar_button::button(ui, "\u{E011}", "Open");
300 self.hits.insert("file:open".into(), open.rect);
301 if open.clicked() {
302 action = Some(FileAction::Open);
303 }
304 // Save — floppy disk (U+1F4BE, the previous-app glyph).
305 let save = toolbar_button::button(ui, "\u{E012}", "Save");
306 self.hits.insert("file:save".into(), save.rect);
307 if save.clicked() {
308 action = Some(FileAction::Save);
309 }
310 // Save As has a dedicated custom-font glyph so its plus badge shares
311 // the disk's weight, alignment, and square advance.
312 let save_as = toolbar_button::button(ui, "\u{E013}", "Save As");
313 self.hits.insert("file:saveas".into(), save_as.rect);
314 if save_as.clicked() {
315 action = Some(FileAction::SaveAs);
316 }
317 ui.separator();
318 // Import — neutral CAD files use their native readers; meshes run through
319 // RANSAC reconstruction before being appended as an IMPORT3D feature.
320 let import = toolbar_button::button(ui, "\u{E014}", "Import STEP / IGES / STL / OBJ\u{2026}");
321 self.hits.insert("file:import".into(), import.rect);
322 if import.clicked() {
323 action = Some(FileAction::Import);
324 }
325 // Export — outbox tray (U+1F4E4): write the model OUT as STEP / IGES / STL.
326 let export = toolbar_button::button(ui, "\u{E015}", "Export\u{2026} (STEP / IGES / STL)");
327 self.hits.insert("file:export".into(), export.rect);
328 if export.clicked() {
329 action = Some(FileAction::Export);
330 }
331 action
332 }
333
334 /// Undo / Redo — trigger the engine-owned undo history. Buttons enable only
335 /// when a step is available so the affordance reflects the real stack. Glyph
336 /// only; the label lives in the tooltip.
337 ///
338 /// While a sketch is open these SAME buttons drive the per-session SKETCH
339 /// history instead of the model-level undo — the sketch has no undo/redo of
340 /// its own; it shares this toolbar pair (matching the Ctrl+Z / Ctrl+Shift+Z
341 /// keyboard router in `app.rs`).
342 fn edit_actions(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
343 let sketch = state.sketch_mode();
344 let can_undo = if sketch { state.sketch_can_undo() } else { state.can_undo() };
345 let undo = toolbar_button::button_enabled(
346 ui,
347 can_undo,
348 "\u{E016}",
349 if sketch { "Undo sketch edit (Ctrl+Z)" } else { "Undo" },
350 );
351 self.hits.insert("undo".into(), undo.rect);
352 if undo.clicked() {
353 if sketch {
354 state.sketch_undo();
355 } else {
356 state.undo();
357 }
358 }
359 let can_redo = if sketch { state.sketch_can_redo() } else { state.can_redo() };
360 let redo = toolbar_button::button_enabled(
361 ui,
362 can_redo,
363 "\u{E017}",
364 if sketch { "Redo sketch edit (Ctrl+Y)" } else { "Redo" },
365 );
366 self.hits.insert("redo".into(), redo.rect);
367 if redo.clicked() {
368 if sketch {
369 state.sketch_redo();
370 } else {
371 state.redo();
372 }
373 }
374 }
375
376 /// View actions: the Wireframe toggle (drives `settings.wireframe` through the
377 /// settings-apply path + persists it like the settings panel), the Projection
378 /// toggle (orthographic ↔ perspective via `set_projection`), Zoom-to-fit, and
379 /// quick standard-view buttons.
380 fn view_actions(&mut self, ui: &mut egui::Ui, state: &mut EngineState, store: &dyn ModelStore) {
381 // Wireframe: reflect the LIVE engine value so the toggle is always honest,
382 // and flip it via the same apply-path the settings panel uses (bumps
383 // settings_generation + dirty so the GPU re-derives styles).
384 let wire = state.settings.wireframe;
385 // Open cube with dashed hidden edges (U+1F578).
386 let wf = toolbar_button::toggle(ui, wire, "\u{1F578}", "Wireframe");
387 self.hits.insert("wireframe".into(), wf.rect);
388 if wf.clicked() {
389 let next = !wire;
390 let _ = state.apply_settings_json(&format!("{{\"wireframe\": {next}}}"));
391 // Persist the full settings through the same seam the settings panel
392 // uses, so the toggle survives a reload and both views agree.
393 let _ = store.write(SETTINGS_KEY, &state.settings_json());
394 }
395
396 // Projection: reflect the LIVE camera mode — the toggle is highlighted while
397 // in perspective. Flip it through the SAME settings-apply path wireframe uses
398 // (`apply_settings_json` reads `orthographic` and drives the camera), then
399 // persist the full settings — so, like wireframe, the projection is now a
400 // real setting that survives a reload and agrees with the settings panel.
401 let is_persp =
402 matches!(state.camera.projection, brep_render::view::Projection::Perspective { .. });
403 // Still-camera icon artwork — the ortho ↔ perspective
404 // toggle. The tooltip names the CURRENT mode.
405 let proj_tip = if is_persp {
406 "Perspective projection"
407 } else {
408 "Orthographic projection"
409 };
410 let proj = toolbar_button::toggle(ui, is_persp, "\u{E018}", proj_tip);
411 self.hits.insert("projection".into(), proj.rect);
412 if proj.clicked() {
413 let want_ortho = is_persp; // currently perspective → switch to orthographic
414 let _ = state.apply_settings_json(&format!("{{\"orthographic\": {want_ortho}}}"));
415 let _ = store.write(SETTINGS_KEY, &state.settings_json());
416 }
417
418 // The three DISPLAY-CLASS toggles, beside wireframe and projection
419 // because they answer the same question — what does the viewport draw.
420 // Each is a real setting (`showFaces` / `showEdges` / `showVertices`),
421 // not a zeroed size: turning edges off and on again must give back the
422 // edge width the user chose, not 0.
423 for (glyph, label, key, on) in [
424 ("\u{E028}", "Show faces", "show:faces", state.settings.show_faces),
425 ("\u{E029}", "Show edges", "show:edges", state.settings.show_edges),
426 ("\u{E02A}", "Show vertices", "show:vertices", state.settings.show_vertices),
427 ] {
428 let btn = toolbar_button::toggle(ui, on, glyph, label);
429 self.hits.insert(key.into(), btn.rect);
430 if btn.clicked() {
431 // The settings JSON key is the hit key's tail, capitalised —
432 // `show:faces` drives `showFaces`, through the same apply+persist
433 // path wireframe and projection use.
434 let field = match key {
435 "show:faces" => "showFaces",
436 "show:edges" => "showEdges",
437 _ => "showVertices",
438 };
439 let next = !on;
440 let _ = state.apply_settings_json(&format!("{{\"{field}\": {next}}}"));
441 let _ = store.write(SETTINGS_KEY, &state.settings_json());
442 }
443 }
444
445 // Square-with-four-corners (U+26F6) — the previous-app zoom-to-fit glyph.
446 let fit = toolbar_button::button(ui, "\u{26F6}", "Zoom to fit");
447 self.hits.insert("fit".into(), fit.rect);
448 if fit.clicked() {
449 state.zoom_to_fit();
450 }
451 }
452}
453
454// BREP private tests: c8c2edf4400ca9a2
455
456/// The hit keys this panel publishes (see `automation::hit_keys`).
457pub static HIT_KEYS: &[HitKeyDoc] = &[
458 HitKeyDoc { panel: "toolbar", prefix: "file:new", meaning: "new document", command: Some("doc_new") },
459 HitKeyDoc { panel: "toolbar", prefix: "file:open", meaning: "open", command: Some("doc_load") },
460 HitKeyDoc { panel: "toolbar", prefix: "file:save", meaning: "save", command: Some("doc_json") },
461 HitKeyDoc { panel: "toolbar", prefix: "file:saveas", meaning: "save as", command: Some("doc_json") },
462 HitKeyDoc { panel: "toolbar", prefix: "file:import", meaning: "import a file", command: Some("doc_import") },
463 HitKeyDoc { panel: "toolbar", prefix: "file:export", meaning: "export", command: Some("doc_export") },
464 HitKeyDoc { panel: "toolbar", prefix: "undo", meaning: "undo", command: Some("undo") },
465 HitKeyDoc { panel: "toolbar", prefix: "redo", meaning: "redo", command: Some("redo") },
466 HitKeyDoc { panel: "toolbar", prefix: "fit", meaning: "zoom to fit", command: Some("zoom_to_fit") },
467 HitKeyDoc { panel: "toolbar", prefix: "projection", meaning: "toggle perspective/orthographic", command: Some("set_projection") },
468 HitKeyDoc { panel: "toolbar", prefix: "wireframe", meaning: "toggle wireframe", command: Some("settings_set") },
469 HitKeyDoc { panel: "toolbar", prefix: "show:faces", meaning: "toggle the shaded faces", command: Some("settings_set") },
470 HitKeyDoc { panel: "toolbar", prefix: "show:edges", meaning: "toggle the edges", command: Some("settings_set") },
471 HitKeyDoc { panel: "toolbar", prefix: "show:vertices", meaning: "toggle the vertex points", command: Some("settings_set") },
472 HitKeyDoc { panel: "toolbar", prefix: "properties", meaning: "open the part properties window", command: Some("part_properties_window") },
473 HitKeyDoc { panel: "toolbar", prefix: "settings", meaning: "open the settings window", command: Some("settings_window") },
474 HitKeyDoc { panel: "toolbar", prefix: "help", meaning: "open the help site", command: Some("help_open") },
475 HitKeyDoc { panel: "toolbar", prefix: "info", meaning: "open the info window (licences + diagnostics)", command: Some("info_window") },
476 HitKeyDoc { panel: "toolbar", prefix: "bug", meaning: "open the bug report", command: Some("bug_report_open") },
477 HitKeyDoc { panel: "toolbar", prefix: "workbench", meaning: "the workbench dropdown", command: Some("settings_set") },
478 HitKeyDoc { panel: "toolbar", prefix: "workbench:btn:", meaning: "the workbench dropdown button", command: Some("workbench_button") },
479 HitKeyDoc { panel: "toolbar", prefix: "workbench:item:", meaning: "pick a workbench from the open dropdown (workbench:item:id)", command: Some("settings_set") },
480];