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;
#[cfg(target_arch = "wasm32")]
use serde_json::Value;
use std::collections::HashMap;
#[derive(Default)]
pub struct ToolbarOutcome {
pub file: Option<FileAction>,
pub workbench_button: Option<&'static str>,
pub bug_report: bool,
}
#[derive(Default)]
pub struct ToolbarPanel {
hits: HashMap<String, egui::Rect>,
}
impl ToolbarPanel {
pub fn new() -> Self {
Self::default()
}
pub fn show(
&mut self,
ui: &mut egui::Ui,
state: &mut EngineState,
store: &dyn ModelStore,
settings_open: &mut bool,
) -> ToolbarOutcome {
self.hits.clear();
let mut outcome = ToolbarOutcome::default();
egui::containers::panel::Panel::top("brep-toolbar")
.resizable(false)
.show(ui, |ui| {
ui.add_space(3.0);
ui.horizontal_wrapped(|ui| {
self.workbench_selector(ui, state, store);
ui.separator();
outcome.file = self.file_actions(ui);
ui.separator();
self.edit_actions(ui, state);
ui.separator();
self.view_actions(ui, state, store);
ui.separator();
self.settings_action(ui, settings_open);
self.docs_action(ui);
outcome.bug_report = self.bug_action(ui);
let current = state.settings.workbench.clone();
outcome.workbench_button = self.workbench_buttons_row(ui, ¤t);
});
ui.add_space(3.0);
});
outcome
}
fn workbench_selector(
&mut self,
ui: &mut egui::Ui,
state: &mut EngineState,
store: &dyn ModelStore,
) {
let current = state.settings.workbench.clone();
let options: Vec<(&str, &str)> =
workbench::WORKBENCHES.iter().map(|w| (w.label, w.id)).collect();
let result = toolbar_button::select(ui, "workbench", ¤t, &options);
self.hits.insert("workbench".into(), result.header_rect);
for (id, rect) in &result.item_rects {
self.hits.insert(format!("workbench:item:{id}"), *rect);
}
if let Some(next) = result.changed {
let json = serde_json::json!({ "workbench": next }).to_string();
let _ = state.apply_settings_json(&json);
let _ = store.write(SETTINGS_KEY, &state.settings_json());
}
}
fn workbench_buttons_row(&mut self, ui: &mut egui::Ui, current: &str) -> Option<&'static str> {
let buttons = workbench::workbench_buttons(current);
if !buttons.is_empty() {
ui.separator();
}
self.draw_workbench_buttons(ui, &buttons)
}
fn draw_workbench_buttons(
&mut self,
ui: &mut egui::Ui,
buttons: &[&workbench::WorkbenchButton],
) -> Option<&'static str> {
let mut clicked = None;
for button in buttons {
let resp = toolbar_button::button(ui, button.glyph, button.tooltip);
self.hits.insert(format!("workbench:btn:{}", button.id), resp.rect);
if resp.clicked() {
clicked = Some(button.id);
}
}
clicked
}
fn docs_action(&mut self, ui: &mut egui::Ui) {
let btn = toolbar_button::button(ui, "\u{2139}", "Docs");
self.hits.insert("docs".into(), btn.rect);
if btn.clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab("help/index.html"));
}
}
fn bug_action(&mut self, ui: &mut egui::Ui) -> bool {
let btn = toolbar_button::button(ui, "\u{1F41E}", "Submit Bug");
self.hits.insert("bug".into(), btn.rect);
btn.clicked()
}
fn settings_action(&mut self, ui: &mut egui::Ui, open: &mut bool) {
let btn = toolbar_button::toggle(ui, *open, "\u{2699}", "Settings");
self.hits.insert("settings".into(), btn.rect);
if btn.clicked() {
*open = !*open;
}
}
#[cfg(target_arch = "wasm32")]
pub fn hits_json(&self) -> String {
let map: serde_json::Map<String, Value> = self
.hits
.iter()
.map(|(k, r)| {
(
k.clone(),
serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]),
)
})
.collect();
Value::Object(map).to_string()
}
fn file_actions(&mut self, ui: &mut egui::Ui) -> Option<FileAction> {
let mut action = None;
let new = toolbar_button::button(ui, "\u{E010}", "New");
self.hits.insert("file:new".into(), new.rect);
if new.clicked() {
action = Some(FileAction::New);
}
let open = toolbar_button::button(ui, "\u{E011}", "Open");
self.hits.insert("file:open".into(), open.rect);
if open.clicked() {
action = Some(FileAction::Open);
}
let save = toolbar_button::button(ui, "\u{E012}", "Save");
self.hits.insert("file:save".into(), save.rect);
if save.clicked() {
action = Some(FileAction::Save);
}
let save_as = toolbar_button::button(ui, "\u{E013}", "Save As");
self.hits.insert("file:saveas".into(), save_as.rect);
if save_as.clicked() {
action = Some(FileAction::SaveAs);
}
ui.separator();
let import = toolbar_button::button(ui, "\u{E014}", "Import STEP / IGES / STL / OBJ\u{2026}");
self.hits.insert("file:import".into(), import.rect);
if import.clicked() {
action = Some(FileAction::Import);
}
let export = toolbar_button::button(ui, "\u{E015}", "Export\u{2026} (STEP / IGES / STL)");
self.hits.insert("file:export".into(), export.rect);
if export.clicked() {
action = Some(FileAction::Export);
}
action
}
fn edit_actions(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
let sketch = state.sketch_mode();
let can_undo = if sketch { state.sketch_can_undo() } else { state.can_undo() };
let undo = toolbar_button::button_enabled(
ui,
can_undo,
"\u{E016}",
if sketch { "Undo sketch edit (Ctrl+Z)" } else { "Undo" },
);
self.hits.insert("undo".into(), undo.rect);
if undo.clicked() {
if sketch {
state.sketch_undo();
} else {
state.undo();
}
}
let can_redo = if sketch { state.sketch_can_redo() } else { state.can_redo() };
let redo = toolbar_button::button_enabled(
ui,
can_redo,
"\u{E017}",
if sketch { "Redo sketch edit (Ctrl+Y)" } else { "Redo" },
);
self.hits.insert("redo".into(), redo.rect);
if redo.clicked() {
if sketch {
state.sketch_redo();
} else {
state.redo();
}
}
}
fn view_actions(&mut self, ui: &mut egui::Ui, state: &mut EngineState, store: &dyn ModelStore) {
let wire = state.settings.wireframe;
let wf = toolbar_button::toggle(ui, wire, "\u{1F578}", "Wireframe");
self.hits.insert("wireframe".into(), wf.rect);
if wf.clicked() {
let next = !wire;
let _ = state.apply_settings_json(&format!("{{\"wireframe\": {next}}}"));
let _ = store.write(SETTINGS_KEY, &state.settings_json());
}
let is_persp =
matches!(state.camera.projection, brep_render::view::Projection::Perspective { .. });
let proj_tip = if is_persp {
"Perspective projection"
} else {
"Orthographic projection"
};
let proj = toolbar_button::toggle(ui, is_persp, "\u{E018}", proj_tip);
self.hits.insert("projection".into(), proj.rect);
if proj.clicked() {
let want_ortho = is_persp; let _ = state.apply_settings_json(&format!("{{\"orthographic\": {want_ortho}}}"));
let _ = store.write(SETTINGS_KEY, &state.settings_json());
}
let fit = toolbar_button::button(ui, "\u{26F6}", "Zoom to fit");
self.hits.insert("fit".into(), fit.rect);
if fit.clicked() {
state.zoom_to_fit();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use brep_render::engine_state::EngineState;
use brep_render::view::Projection;
struct NullStore;
impl ModelStore for NullStore {
fn read(&self, _key: &str) -> Option<String> {
None
}
fn write(&self, _key: &str, _val: &str) -> Result<(), String> {
Ok(())
}
}
fn is_perspective(state: &EngineState) -> bool {
matches!(state.camera.projection, Projection::Perspective { .. })
}
#[test]
fn projection_toggle_publishes_hit_and_flips_mode() {
let mut panel = ToolbarPanel::new();
let mut state = EngineState::new();
let store = NullStore;
assert!(!is_perspective(&state), "seed camera should be orthographic");
let ctx = egui::Context::default();
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
panel.view_actions(ui, &mut state, &store);
});
assert!(
panel.hits.contains_key("projection"),
"toolbar must publish a `projection` hit-rect, got {:?}",
panel.hits.keys().collect::<Vec<_>>()
);
state.dirty = false;
state.set_projection("perspective");
assert!(is_perspective(&state), "set_projection(perspective) → perspective");
assert!(state.dirty, "a projection flip must mark the view dirty for a redraw");
state.set_projection("orthographic");
assert!(!is_perspective(&state), "set_projection(orthographic) → orthographic");
}
#[test]
fn workbench_button_click_surfaces_its_id() {
let button = workbench::WorkbenchButton {
id: "test:flat",
glyph: "\u{2699}",
tooltip: "Flat pattern (test)",
};
let buttons: Vec<&workbench::WorkbenchButton> = vec![&button];
let ctx = egui::Context::default();
let draw = |panel: &mut ToolbarPanel, events: Vec<egui::Event>| -> Option<&'static str> {
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(400.0, 120.0),
)),
events,
..Default::default()
};
let mut out = None;
let _ = ctx.run_ui(raw, |ui| {
out = panel.draw_workbench_buttons(ui, &buttons);
});
out
};
let mut panel = ToolbarPanel::new();
assert!(draw(&mut panel, vec![]).is_none(), "a passive render is not a click");
let rect = *panel
.hits
.get("workbench:btn:test:flat")
.expect("the button publishes a hit-rect");
let pos = rect.center();
draw(
&mut panel,
vec![
egui::Event::PointerMoved(pos),
egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::default(),
},
],
);
let clicked = draw(
&mut panel,
vec![egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::default(),
}],
);
assert_eq!(clicked, Some("test:flat"), "the clicked button's id surfaces via the return path");
}
#[test]
fn real_workbench_button_passive_render_no_click() {
let mut panel = ToolbarPanel::new();
let ctx = egui::Context::default();
let mut clicked = Some("sentinel");
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
clicked = panel.workbench_buttons_row(ui, "sheetMetal");
});
assert_eq!(clicked, None, "a passive render is not a click");
assert!(panel.hits.contains_key("workbench:btn:sheetmetal.flat_pattern"));
}
}