use crate::panels::file::FileAction;
use crate::panels::toolbar_button;
use crate::store::Store;
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 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 Store,
settings_open: &mut bool,
) -> Option<FileAction> {
self.hits.clear();
let mut file_action = None;
egui::containers::panel::Panel::top("brep-toolbar")
.resizable(false)
.show(ui, |ui| {
ui.add_space(3.0);
ui.horizontal_wrapped(|ui| {
file_action = 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);
});
ui.add_space(3.0);
});
file_action
}
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 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{1F4C4}", "New");
self.hits.insert("file:new".into(), new.rect);
if new.clicked() {
action = Some(FileAction::New);
}
let open = toolbar_button::button(ui, "\u{1F5C1}", "Open");
self.hits.insert("file:open".into(), open.rect);
if open.clicked() {
action = Some(FileAction::Open);
}
let save = toolbar_button::button(ui, "\u{1F4BE}", "Save");
self.hits.insert("file:save".into(), save.rect);
if save.clicked() {
action = Some(FileAction::Save);
}
let save_as = toolbar_button::button(ui, "\u{1F4BE}+", "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{1F4E5}", "Import STEP\u{2026}");
self.hits.insert("file:import".into(), import.rect);
if import.clicked() {
action = Some(FileAction::Import);
}
let export = toolbar_button::button(ui, "\u{1F4E4}", "Export\u{2026} (STEP / 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 undo = toolbar_button::button_enabled(ui, state.can_undo(), "\u{21b6}", "Undo");
self.hits.insert("undo".into(), undo.rect);
if undo.clicked() {
state.undo();
}
let redo = toolbar_button::button_enabled(ui, state.can_redo(), "\u{21b7}", "Redo");
self.hits.insert("redo".into(), redo.rect);
if redo.clicked() {
state.redo();
}
}
fn view_actions(&mut self, ui: &mut egui::Ui, state: &mut EngineState, store: &dyn Store) {
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}}}"));
store.save("settings", &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{1F3A5}", 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}}}"));
store.save("settings", &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();
}
for name in ["FRONT", "TOP", "RIGHT", "ISO"] {
let b = toolbar_button::button(ui, name, name);
self.hits.insert(format!("view:{name}"), b.rect);
if b.clicked() {
state.standard_view(name);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use brep_render::engine_state::EngineState;
use brep_render::view::Projection;
struct NullStore;
impl Store for NullStore {
fn load(&self, _key: &str) -> Option<String> {
None
}
fn save(&self, _key: &str, _val: &str) {}
}
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");
}
}