//! Global toolbar shell: File menu and either Ribbon or Classic presentation.
//!
//! Both renderers consume the same offered-command snapshots and button helper.
//! Command targets retain the existing engine, workbench, plugin and creation
//! dispatch paths. This panel owns presentation and automation hit rectangles;
//! the shell owns File dialogs and creation outcomes.
use crate::automation::hit_keys::HitKeyDoc;
use crate::panels::file::FileAction;
use crate::panels::toolbar_button;
use crate::store::{ModelStore, SETTINGS_KEY};
use crate::workbench;
use brep_render::engine_state::EngineState;
use eframe::egui;
use std::collections::HashMap;
/// Toolbar actions returned to the shell for dispatch.
#[derive(Default)]
pub struct ToolbarOutcome {
pub toggle_mobile_panels: bool,
pub documents: super::document_tabs::TabsOutcome,
/// A File button click (New / Open / Save / …), dispatched to the file dialog.
pub file: Option<FileAction>,
pub recent_document: Option<String>,
pub creation: super::workbench_toolbar::WorkbenchToolbarOutcome,
/// A workbench toolbar button click, surfaced by its `WorkbenchButton::id`.
pub workbench_button: Option<&'static str>,
pub plugin_action: Option<String>,
pub plugins: bool,
pub javascript: bool,
/// The "Submit Bug" button was clicked this frame — the shell begins the
/// screenshot-capture + report flow (see [`crate::panels::bug_report`]).
pub bug_report: bool,
/// Zoom-to-fit was clicked. It is SURFACED rather than run here because what
/// "fit" means depends on what the viewport is drawing — the 3D scene, or
/// the OPEN SHEET's paper — and the sheet's transform lives in the viewport,
/// not the engine. `BrepApp::zoom_to_fit` owns that branch, and the
/// `zoom_to_fit` command calls the very same method.
pub zoom_to_fit: bool,
/// A row of the PLM review inbox was opened: the review it
/// names (`panels::plm_review`, S4). The shell hands it to the PLM pane.
pub plm_review: Vec<crate::panels::plm_review::PlmReviewEvent>,
}
/// The toolbar's own state: the per-frame map of egui widget screen rects,
/// published to JS for the headed verifier to drive real clicks. Rebuilt each
/// frame (there is no DOM — egui is drawn on the canvas).
#[derive(Default)]
pub struct ToolbarPanel {
hits: HashMap<String, egui::Rect>,
ribbon: super::ribbon::RibbonPanel,
pub read_only: bool,
pub(crate) documents: super::document_tabs::MenuState,
/// The PLM review inbox badge beside the connection indicator (S4).
inbox: crate::panels::plm_review::InboxBadge,
}
impl ToolbarPanel {
pub fn new() -> Self {
Self::default()
}
/// Draw the toolbar as a top panel of primary actions. Called FIRST in the
/// shell's `App::ui` so the strip reserves the top before the left panel and
/// the central viewport. Rebuilds `hits` each frame as it draws. Returns the
/// [`FileAction`] a clicked File button requests (the shell dispatches it to
/// the file dialog), or `None`.
///
/// `settings_open` is the shell-owned open flag of the floating Settings window
/// (see [`crate::panels::settings`]): the gear button reflects it (highlighted
/// while open) and toggles it on click. `info_open` is the same arrangement
/// for the Info window (see [`crate::panels::info`]).
pub fn show(
&mut self,
ui: &mut egui::Ui,
state: &mut EngineState,
ecad: Option<&crate::workbench::ecad::Editors>,
store: &dyn ModelStore,
settings_open: &mut bool,
properties_open: &mut bool,
info_open: &mut bool,
) -> ToolbarOutcome {
self.hits.clear();
if crate::viewport::touch::compact(ui.ctx()) {
return self.show_compact(ui, state, ecad, store, settings_open, properties_open, info_open);
}
let mut outcome = ToolbarOutcome::default();
egui::containers::panel::Panel::top("brep-toolbar")
.resizable(false)
.show(ui, |ui| {
ui.add_space(3.0);
let style = state.settings.toolbar_style;
let mut target = None;
toolbar_button::wrapped(ui, |ui| {
let file = super::file::file_menu(
ui,
state,
store,
settings_open,
properties_open,
&mut self.hits,
);
outcome.file = file.action;
outcome.recent_document = file.recent;
outcome.plugins |= file.plugins;
outcome.javascript |= file.javascript;
if style == brep_render::style::ToolbarStyle::Ribbon {
self.ribbon.tabs(ui, &mut self.hits);
} else {
let commands = self.commands(state, ecad, *info_open);
let mut last_group = None;
for command in commands.iter().filter(|c| !c.is_creation()) {
let segments = command.segments();
let group = (segments[0], segments[1]);
if last_group != Some(group) {
ui.separator();
last_group = Some(group);
}
if let Some(t) = super::ribbon::draw_command(
ui,
command,
true,
false,
&mut self.hits,
) {
target = Some(t);
}
}
}
outcome.plm_review = self.plm_indicator(ui, store, settings_open);
});
if style == brep_render::style::ToolbarStyle::Ribbon {
let commands = self.commands(state, ecad, *info_open);
target = self.ribbon.show(ui, &commands, &mut self.hits).or(target);
}
if let Some(target) = target {
self.dispatch_target(ui.ctx(), target, state, store, info_open, &mut outcome);
}
ui.add_space(3.0);
});
outcome
}
fn commands(
&self,
state: &EngineState,
ecad: Option<&workbench::ecad::Editors>,
info_open: bool,
) -> Vec<workbench::OfferedCommand> {
let mut commands = workbench::offered_commands(
&workbench::ButtonState {
engine: state,
ecad,
},
info_open,
);
if self.read_only {
for c in commands.iter_mut().filter(|c| c.is_creation()) {
c.disabled_reason = Some("Document is read-only".into());
}
}
commands
}
fn show_compact(
&mut self, ui: &mut egui::Ui, state: &mut EngineState,
ecad: Option<&crate::workbench::ecad::Editors>, store: &dyn ModelStore,
settings_open: &mut bool, properties_open: &mut bool, info_open: &mut bool,
) -> ToolbarOutcome {
let mut outcome = ToolbarOutcome::default();
let mut target = None;
egui::containers::panel::Panel::top("brep-mobile-toolbar").resizable(false).show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
let file = super::file::file_menu(ui, state, store, settings_open, properties_open, &mut self.hits);
outcome.file = file.action;
outcome.recent_document = file.recent;
outcome.plugins = file.plugins;
outcome.javascript = file.javascript;
let mut commands = self.commands(state, ecad, *info_open);
if state.any_sketch_mode() {
commands.sort_by_key(|c| !matches!(&c.target, workbench::CommandTarget::Workbench(_)));
}
let menu = ui.menu_button("Tools", |ui| {
ui.set_max_width((ui.ctx().content_rect().width() - 32.0).max(100.0));
egui::ScrollArea::vertical().max_height((ui.ctx().content_rect().height() - 80.0).max(80.0)).show(ui, |ui| {
let mut group = String::new();
for command in &commands {
let segments = command.segments();
let heading = format!("{} / {}", segments[0], segments[1]);
if heading != group { ui.separator(); ui.weak(&heading); group = heading; }
if let Some(chosen) = super::ribbon::draw_command(ui, command, true, true, &mut self.hits) {
target = Some(chosen);
}
}
});
});
self.hits.insert("ribbon:overflow".into(), menu.response.rect);
let (documents, button) = super::document_tabs::menu(ui, &self.documents);
self.hits.insert("mobile:documents".into(), button.rect);
outcome.documents = documents;
for command in commands.iter().filter(|c| matches!(&c.target, workbench::CommandTarget::Shell("undo" | "redo"))) {
if let Some(chosen) = super::ribbon::draw_command(ui, command, true, false, &mut self.hits) { target = Some(chosen); }
}
// A DOM entry button shares this toolbar slot so the browser
// receives the trusted click required by the Fullscreen API.
let (slot, _) = ui.allocate_exact_size(egui::vec2(44.0, 44.0), egui::Sense::hover());
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("brep-mobile-fullscreen-slot"), slot));
self.hits.insert("mobile:fullscreen".into(), slot);
outcome.plm_review = self.plm_indicator(ui, store, settings_open);
});
if let Some(target) = target {
self.dispatch_target(ui.ctx(), target, state, store, info_open, &mut outcome);
}
});
outcome
}
fn dispatch_target(
&mut self,
ctx: &egui::Context,
target: workbench::CommandTarget,
state: &mut EngineState,
store: &dyn ModelStore,
info_open: &mut bool,
outcome: &mut ToolbarOutcome,
) {
use workbench::CommandTarget;
let key = match target {
CommandTarget::Workbench(id) => {
outcome.workbench_button = Some(id);
return;
}
CommandTarget::Plugin(id) => {
outcome.plugin_action = Some(id);
return;
}
CommandTarget::Feature(id) => {
outcome.creation.feature = Some(id);
return;
}
CommandTarget::Constraint(id) => {
outcome.creation.constraint = Some(id);
return;
}
CommandTarget::Annotation(id) => {
outcome.creation.annotation = Some(id);
return;
}
CommandTarget::Shell(key) => key,
};
match key {
"file:new" => outcome.file = Some(FileAction::New),
"file:open" => outcome.file = Some(FileAction::Open),
"file:save" => outcome.file = Some(FileAction::Save),
"file:saveas" => outcome.file = Some(FileAction::SaveAs),
"file:import" => outcome.file = Some(FileAction::Import),
"file:export" => outcome.file = Some(FileAction::Export),
"undo" => {
if state.sketch_mode() {
state.sketch_undo();
} else if state.sketch3d_mode() {
state.sketch3d_undo();
} else {
state.undo();
}
}
"redo" => {
if state.sketch_mode() {
state.sketch_redo();
} else if state.sketch3d_mode() {
state.sketch3d_redo();
} else {
state.redo();
}
}
"fit" => outcome.zoom_to_fit = true,
"info" => *info_open = !*info_open,
"bug" => outcome.bug_report = true,
"workbench:manage:plugins" => outcome.plugins = true,
"workbench:manage:javascript" => outcome.javascript = true,
"help" => ctx.open_url(egui::OpenUrl::new_tab(crate::automation::HELP_URL)),
key => {
let (field, value) = match key {
"wireframe" => ("wireframe", !state.settings.wireframe),
"projection" => (
"orthographic",
matches!(
state.camera.projection,
brep_render::view::Projection::Perspective { .. }
),
),
"show:faces" => ("showFaces", !state.settings.show_faces),
"show:edges" => ("showEdges", !state.settings.show_edges),
"show:vertices" => ("showVertices", !state.settings.show_vertices),
_ => return,
};
let _ = state.apply_settings_json(&serde_json::json!({field: value}).to_string());
let _ = store.write(SETTINGS_KEY, &state.settings_json());
}
}
}
pub fn home_hits(&self) -> HashMap<String, egui::Rect> {
self.hits
.iter()
.filter(|(key, _)| key.starts_with("wbtb:"))
.map(|(k, r)| (k.clone(), *r))
.collect()
}
/// The PLM CONNECTION INDICATOR (plan S1 part 3): present only while the
/// store reports a PLM, so a file-only session has none. `PLM: <user>`
/// when this session is on the server, `PLM: sign in` when it is
/// configured and not on it (plain text: no glyph a font could lack);
/// the tooltip says where and as whom, or why not, and a click opens
/// Settings, whose PLM tab signs in. S4's inbox badge sits beside it
/// while the session is on the server; the rows it opens are returned.
fn plm_indicator(
&mut self,
ui: &mut egui::Ui,
store: &dyn ModelStore,
settings_open: &mut bool,
) -> Vec<crate::panels::plm_review::PlmReviewEvent> {
use crate::plm::connection::Status;
let Some(session) = store.plm_session() else {
return Vec::new();
};
ui.separator();
let (label, tip) = match &session.status {
Status::Connected { url, username } => (
format!("PLM: {username}"),
format!("On the PLM at {url} as {username}"),
),
Status::NotConnected { url, reason } => (
"PLM: sign in".to_string(),
match reason {
Some(why) => format!(
"Not connected to the PLM at {url}: {why}. Settings → PLM signs in."
),
None => format!("Not connected to the PLM at {url}. Settings → PLM signs in."),
},
),
};
let btn = ui.button(label).on_hover_text(tip);
self.hits.insert("plm:indicator".into(), btn.rect);
if btn.clicked() {
*settings_open = true;
}
if let Some(url) = crate::panels::plm_host::web_url(store, "#/reviews") {
let link = crate::panels::plm_host::web_link(ui, "PLM inbox", url);
self.hits.insert("plm:inbox".into(), link.rect);
}
Vec::new()
}
/// Ask the review inbox again on the next frame: the user just decided,
/// submitted or released something (`panels::plm_review`).
pub fn refresh_inbox(&mut self) {
self.inbox.refresh();
}
/// The review inbox badge, for tests (never drawn: the toolbar links to the
/// web inbox instead, and publishes no blob for it).
pub fn inbox(&self) -> &crate::panels::plm_review::InboxBadge {
&self.inbox
}
/// The published widget hit-rects (egui points) for the headed verifier.
pub fn hits_json(&self) -> String {
crate::automation::hit_rects::hits_json(&self.hits)
}
}
/// The hit keys this panel publishes (see `automation::hit_keys`).
pub static HIT_KEYS: &[HitKeyDoc] = &[
HitKeyDoc { panel: "toolbar", prefix: "workbench:manage:plugins", meaning: "open the installed plugin manager: the Plugins row under Settings in the File menu (both styles)", command: Some("plugins_window") },
HitKeyDoc { panel: "toolbar", prefix: "workbench:manage:javascript", meaning: "open the JavaScript editor: the JavaScript row under Settings in the File menu (both styles)", command: Some("javascript_window") },
HitKeyDoc { panel: "toolbar", prefix: "plugin:action:", meaning: "open the form for a named plugin action", command: Some("plugin_action") },
HitKeyDoc { panel: "toolbar", prefix: "file:menu", meaning: "open the shared File menu", command: None },
HitKeyDoc { panel: "toolbar", prefix: "file:recent", meaning: "recent documents section and document rows, present only when nonempty", command: Some("doc_load") },
HitKeyDoc { panel: "toolbar", prefix: "file:recent:thumb:", meaning: "the thumbnail square at the left of a recent-document row (file:recent:thumb:name): the document's saved picture, or the neutral placeholder before it has one", command: Some("doc_load") },
HitKeyDoc { panel: "toolbar", prefix: "ribbon:", meaning: "ribbon tabs (ribbon:tab:<Tab>), group borders (ribbon:group:<Group>) and the overflow menu (ribbon:overflow)", command: None },
#[cfg(target_arch = "wasm32")]
HitKeyDoc { panel: "toolbar", prefix: "mobile:", meaning: "compact browser drawer controls", command: None },
HitKeyDoc { panel: "toolbar", prefix: "wbtb:", meaning: "Home creation commands; same keys as the Classic feature strip", command: Some("feature_add") },
HitKeyDoc { panel: "toolbar", prefix: "file:new", meaning: "new document", command: Some("doc_new") },
HitKeyDoc { panel: "toolbar", prefix: "file:newmenu", meaning: "legacy key for the flat New part row in File", command: Some("doc_new_of_class") },
HitKeyDoc { panel: "toolbar", prefix: "file:newclass:", meaning: "a New menu row: file:newclass:normal, file:newclass:family, file:newclass:template (visible directly in File)", command: Some("doc_new_of_class") },
HitKeyDoc { panel: "toolbar", prefix: "file:open", meaning: "open", command: Some("doc_load") },
HitKeyDoc { panel: "toolbar", prefix: "file:save", meaning: "save", command: Some("doc_json") },
HitKeyDoc { panel: "toolbar", prefix: "file:saveas", meaning: "save as", command: Some("doc_json") },
HitKeyDoc { panel: "toolbar", prefix: "file:import", meaning: "import a file", command: Some("doc_import") },
HitKeyDoc { panel: "toolbar", prefix: "file:export", meaning: "export", command: Some("doc_export") },
HitKeyDoc { panel: "toolbar", prefix: "undo", meaning: "undo", command: Some("undo") },
HitKeyDoc { panel: "toolbar", prefix: "redo", meaning: "redo", command: Some("redo") },
HitKeyDoc { panel: "toolbar", prefix: "fit", meaning: "zoom to fit \u{2014} the whole model, or the OPEN SHEET's whole paper while a sheet is open", command: Some("zoom_to_fit") },
HitKeyDoc { panel: "toolbar", prefix: "projection", meaning: "toggle perspective/orthographic", command: Some("set_projection") },
HitKeyDoc { panel: "toolbar", prefix: "wireframe", meaning: "toggle wireframe", command: Some("settings_set") },
HitKeyDoc { panel: "toolbar", prefix: "show:faces", meaning: "toggle the shaded faces", command: Some("settings_set") },
HitKeyDoc { panel: "toolbar", prefix: "show:edges", meaning: "toggle the edges", command: Some("settings_set") },
HitKeyDoc { panel: "toolbar", prefix: "show:vertices", meaning: "toggle the vertex points", command: Some("settings_set") },
HitKeyDoc { panel: "toolbar", prefix: "properties", meaning: "open the part properties window", command: Some("part_properties_window") },
HitKeyDoc { panel: "toolbar", prefix: "settings", meaning: "open the settings window", command: Some("settings_window") },
HitKeyDoc { panel: "toolbar", prefix: "plm:", meaning: "plm:indicator opens connection settings; plm:inbox opens the PLM web inbox in another tab", command: Some("settings_window") },
HitKeyDoc { panel: "toolbar", prefix: "help", meaning: "open the help site", command: Some("help_open") },
HitKeyDoc { panel: "toolbar", prefix: "info", meaning: "open the info window (licences + diagnostics)", command: Some("info_window") },
HitKeyDoc { panel: "toolbar", prefix: "bug", meaning: "open the bug report", command: Some("bug_report_open") },
HitKeyDoc { panel: "toolbar", prefix: "workbench", meaning: "legacy opener key for the shared File menu", command: Some("settings_set") },
HitKeyDoc { panel: "toolbar", prefix: "workbench:btn:", meaning: "one button of the ACTIVE workbench's action row (workbench:btn:<button id>) \u{2014} published only while that button is offered, so Drawing's `drawing.sheet_close`, the six `drawing.dim.*` constructions, the two `drawing.ord.*` sets, `drawing.section` and `drawing.detail` are here exactly while a sheet is open, and the ten `sketch.tool.*` draw tools and `sketch.autoconstrain` (which EVERY workbench carries) exactly while a sketch is being edited; the armed construction and the armed draw tool draw pressed. A button that is offered but DISABLED (an eCAD action its editor does not allow yet) draws greyed and `workbench_button` refuses it with the reason. A MENU button (PCB's copper-layer picker) opens a menu whose entries publish this key while it is OPEN. Classic wraps available buttons; Ribbon places them in their declared tabs and groups, compacting Large buttons before whole-group overflow. Menu entries publish only while their menu is open", command: Some("workbench_button") },
HitKeyDoc { panel: "toolbar", prefix: "workbench:item:", meaning: "pick a radio row from File / Workbench (workbench:item:id)", command: Some("settings_set") },
];