BREP_app 0.1.0

The BREP CAD application: an eframe (egui + wgpu) host that draws the brep-render 3D engine into an egui frame — native + wasm from one codebase.
Documentation
//! Toolbar — a top `Panel` strip of primary actions above the viewport:
//! Undo / Redo, a Wireframe toggle, Zoom-to-fit, and quick standard-view
//! buttons, plus the File-actions SEAM (owned by the concurrent file panel).
//!
//! Follows the panel pattern (a small state struct + a `show(&mut self, ui,
//! state, store)` the shell calls once), but unlike the left-column sections it
//! creates its OWN top panel — so the shell just calls `self.toolbar.show(…)`
//! FIRST in `App::ui` (before the left panel + viewport) to reserve the strip.
//!
//! The MODEL is engine-owned: the toolbar only TRIGGERS engine methods
//! (`state.undo()` / `state.redo()` / `state.zoom_to_fit()` /
//! `state.standard_view()`) and drives the wireframe through the existing
//! settings-apply path (`apply_settings_json` → bumps generation + dirty). It
//! owns only the per-frame `hits` map (widget screen rects) the headed verifier
//! reads to drive real clicks, exactly like the history panel.
//!
//! The File buttons don't touch storage here: a click returns a [`FileAction`]
//! from [`ToolbarPanel::show`] and the shell hands it to the reusable
//! [`crate::panels::file::FileDialog`].
//!
//! Buttons show a single UNICODE glyph — the EXACT glyph the retiring
//! UI used — with the text label in the hover tooltip. All styling/sizing goes
//! through the shared [`crate::panels::toolbar_button`] helpers (the ONE place
//! toolbar-button style lives), so a change lands globally. The glyphs that are
//! not in the app's bundled `mono`/`sans_sym`/`sym2` fonts (📄 New, 💾 Save,
//! ⛶ Fit) render via egui's bundled default fallback fonts (NotoEmoji-Regular +
//! emoji-icon-font — see `crate::fonts`). The exact glyph per button:
//!   New       U+1F4C4 📄 page              (previous-app glyph)
//!   Open      U+1F5C1 🗁 open folder        (kept — the previous app had no Open)
//!   Save      U+1F4BE 💾 floppy disk        (previous-app glyph)
//!   Save As   U+1F4BE+"+" 💾+               (previous app's "💾+" — the "+" suffix)
//!   Import    U+1F4E5 📥 inbox tray         (previous-app glyph)
//!   Export    U+1F4E4 📤 outbox tray        (previous-app glyph)
//!   Undo      U+21B6 ↶                      (previous-app glyph)
//!   Redo      U+21B7 ↷                      (previous-app glyph)
//!   Wireframe U+1F578 🕸 spider web         (previous-app glyph, Symbols 2)
//!   Projctn   U+1F3A5 🎥 movie camera       (bundled NotoEmoji fallback, no tofu)
//!   Fit       U+26F6 ⛶ square-four-corners  (previous-app glyph)
//!   Settings  U+2699 ⚙ gear                 (bundled DejaVu font, no tofu)
//!
//! There is NO Info/Properties button here: entity inspection is opened from the
//! selection-driven CONTEXT bar (see [`crate::panels::context_bar`]), which spawns
//! a pinned per-entity window (see [`crate::panels::info_windows`]).

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;

/// 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>,
}

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.
    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
    }

    /// The Docs button: opens the generated help site (`brep-docs` writes it to
    /// `web/help/` next to the served page) in a new tab. Info glyph (U+2139),
    /// matching the old app's toolbar entry.
    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"));
        }
    }

    /// The Settings toggle: opens / closes the floating Settings window. A
    /// selectable gear glyph (U+2699, bundled DejaVu font) reflecting the live
    /// open state; the label lives in the tooltip, matching the other buttons.
    fn settings_action(&mut self, ui: &mut egui::Ui, open: &mut bool) {
        // Gear (U+2699) — renders in the bundled DejaVu font (no tofu). A toggle
        // reflecting the live open state.
        let btn = toolbar_button::toggle(ui, *open, "\u{2699}", "Settings");
        self.hits.insert("settings".into(), btn.rect);
        if btn.clicked() {
            *open = !*open;
        }
    }

    /// The published widget hit-rects (egui points) for the headed verifier.
    #[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()
    }

    /// File actions (New / Open / Save / Save As) as glyph buttons. Each returns
    /// the matching [`FileAction`] on click; the shell hands it to the file
    /// dialog (which owns all storage). Glyph → label in the tooltip.
    fn file_actions(&mut self, ui: &mut egui::Ui) -> Option<FileAction> {
        let mut action = None;
        // New — page (U+1F4C4, the previous-app glyph).
        let new = toolbar_button::button(ui, "\u{1F4C4}", "New");
        self.hits.insert("file:new".into(), new.rect);
        if new.clicked() {
            action = Some(FileAction::New);
        }
        // Open — open folder (U+1F5C1). Kept: the previous app had no Open.
        let open = toolbar_button::button(ui, "\u{1F5C1}", "Open");
        self.hits.insert("file:open".into(), open.rect);
        if open.clicked() {
            action = Some(FileAction::Open);
        }
        // Save — floppy disk (U+1F4BE, the previous-app glyph).
        let save = toolbar_button::button(ui, "\u{1F4BE}", "Save");
        self.hits.insert("file:save".into(), save.rect);
        if save.clicked() {
            action = Some(FileAction::Save);
        }
        // Save As — floppy + "+" (the previous app's "💾+").
        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();
        // Import — inbox tray (U+1F4E5, the previous-app import glyph): bring a STEP
        // file INTO the model (appended as an IMPORT3D feature).
        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);
        }
        // Export — outbox tray (U+1F4E4): write the model OUT as STEP / STL.
        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
    }

    /// Undo / Redo — trigger the engine-owned undo history. Buttons enable only
    /// when a step is available so the affordance reflects the real stack. Glyph
    /// only; the label lives in the tooltip.
    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();
        }
    }

    /// View actions: the Wireframe toggle (drives `settings.wireframe` through the
    /// settings-apply path + persists it like the settings panel), the Projection
    /// toggle (orthographic ↔ perspective via `set_projection`), Zoom-to-fit, and
    /// quick standard-view buttons.
    fn view_actions(&mut self, ui: &mut egui::Ui, state: &mut EngineState, store: &dyn Store) {
        // Wireframe: reflect the LIVE engine value so the toggle is always honest,
        // and flip it via the same apply-path the settings panel uses (bumps
        // settings_generation + dirty so the GPU re-derives styles).
        let wire = state.settings.wireframe;
        // Spider web (U+1F578, Symbols 2) — the previous-app wireframe glyph.
        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}}}"));
            // Persist the full settings through the same seam the settings panel
            // uses, so the toggle survives a reload and both views agree.
            store.save("settings", &state.settings_json());
        }

        // Projection: reflect the LIVE camera mode — the toggle is highlighted while
        // in perspective. Flip it through the SAME settings-apply path wireframe uses
        // (`apply_settings_json` reads `orthographic` and drives the camera), then
        // persist the full settings — so, like wireframe, the projection is now a
        // real setting that survives a reload and agrees with the settings panel.
        let is_persp =
            matches!(state.camera.projection, brep_render::view::Projection::Perspective { .. });
        // Movie camera (U+1F3A5) — the ortho ↔ perspective toggle. Renders via
        // egui's bundled NotoEmoji fallback (like 📄 New / 💾 Save), so no tofu. The
        // tooltip names the CURRENT mode.
        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; // currently perspective → switch to orthographic
            let _ = state.apply_settings_json(&format!("{{\"orthographic\": {want_ortho}}}"));
            store.save("settings", &state.settings_json());
        }

        // Square-with-four-corners (U+26F6) — the previous-app zoom-to-fit glyph.
        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();
        }

        // Standard views keep their TEXT labels (multi-char names, not glyphs).
        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;

    /// A no-op `Store`: `view_actions` only touches the store on a wireframe
    /// click, and this headless draw injects no pointer input, so nothing is
    /// clicked and the store is never read/written.
    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 { .. })
    }

    /// The new projection toggle must (a) publish a `projection` hit-rect each
    /// frame (the headed verifier drives it by that rect) and (b) flip the camera
    /// mode through the exact `set_projection` strings the button passes — which
    /// marks the view dirty so the viewport re-renders.
    #[test]
    fn projection_toggle_publishes_hit_and_flips_mode() {
        let mut panel = ToolbarPanel::new();
        let mut state = EngineState::new();
        let store = NullStore;

        // The seed camera is orthographic (view.rs `Default`).
        assert!(!is_perspective(&state), "seed camera should be orthographic");

        // Draw one headless egui frame of the view actions; layout records the
        // per-frame hit-rects (no pointer input → nothing is clicked).
        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<_>>()
        );

        // The toggle path round-trips the camera projection and marks it dirty.
        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");
    }
}