Skip to main content

brep_app/panels/
toolbar_button.rs

1//! The SINGLE source of truth for toolbar-button styling — shared by the main
2//! toolbar (`toolbar.rs`), the context bar (`context_bar.rs`), and the 2D sketcher
3//! toolbar (`sketch.rs`). Every toolbar button goes through here, so sizing/style
4//! changes land in ONE place. The human label is in the hover TOOLTIP, and every
5//! button is SQUARE (min-width == height).
6//!
7//! Artwork is ALWAYS the catalogued SVG ([`crate::icons`]) — colour icons paint
8//! their own colours, monochrome ones are tinted to the button's live text
9//! colour. There is no font path: this app ships no icon font, and a glyph
10//! character is only ever a KEY into the catalog.
11//!
12//! There used to be two other routes. Several private-use font glyphs painted at
13//! one origin in different colours was the only way to get multi-colour artwork
14//! out of a monochrome TrueType face; that went when each became a single colour
15//! SVG. Then monochrome glyphs were left to `BrepIcons.ttf` on the reasoning that
16//! a rasterised image "would buy nothing" — true in isolation, but it was the
17//! last thing keeping a whole font in the binary, so it went too.
18use eframe::egui;
19
20/// The catalogued artwork for a button glyph — what lets a plain
21/// `button`/`button_enabled`/`toggle` call site render its icon with no per-site
22/// plumbing, so the toolbar, the workbench registry and the sketch tool row all
23/// light up from the glyph alone. Shared with the tree and palette rows so all
24/// three agree on what is an icon.
25use crate::icons::artwork;
26
27/// Square edge length of a single-glyph toolbar button (min-width == height).
28pub const TOOLBAR_BTN: f32 = 28.0;
29
30/// Maximum painted edge length, independent of legacy SVG canvas padding.
31const GLYPH_SIZE: f32 = 17.0;
32
33fn square() -> egui::Vec2 {
34    egui::vec2(TOOLBAR_BTN, TOOLBAR_BTN)
35}
36
37/// The button for one glyph: its catalogued artwork if it has any, else the
38/// characters themselves as text.
39///
40/// Monochrome artwork is white in the catalog, so `image_tint_follows_text_color`
41/// multiplies it by the button's LIVE text colour — which is what keeps hover,
42/// pressed and disabled states looking exactly as they did when the font drew
43/// the glyph. Colour artwork must never be tinted, so it opts out.
44fn glyph_button<'a>(ui: &egui::Ui, glyph: &'a str) -> egui::Button<'a> {
45    match artwork(glyph) {
46        Some(icon) => {
47            // Idempotent, and the only place a toolbar button needs it: a button
48            // can be the first thing on screen to draw an SVG.
49            egui_extras::install_image_loaders(ui.ctx());
50            let height = GLYPH_SIZE / icon.artwork_aspect.max(1.0);
51            let image = egui::Image::new(egui::ImageSource::Bytes {
52                uri: format!("bytes://brep-toolbar/{}.svg", icon.name).into(),
53                bytes: egui::load::Bytes::Static(icon.artwork_svg.as_bytes()),
54            })
55            .fit_to_exact_size(egui::vec2(height * icon.artwork_aspect, height));
56            egui::Button::new(image)
57                .image_tint_follows_text_color(icon.mono)
58                .min_size(square())
59        }
60        None => egui::Button::new(glyph).min_size(square()),
61    }
62}
63
64/// A plain action toolbar button: `glyph` shown, `tooltip` on hover. Square.
65pub fn button(ui: &mut egui::Ui, glyph: &str, tooltip: &str) -> egui::Response {
66    let button = glyph_button(ui, glyph);
67    ui.add(button).on_hover_text(tooltip)
68}
69
70/// A toolbar button enabled only when `enabled` (undo/redo, selection actions).
71pub fn button_enabled(
72    ui: &mut egui::Ui,
73    enabled: bool,
74    glyph: &str,
75    tooltip: &str,
76) -> egui::Response {
77    let button = glyph_button(ui, glyph);
78    ui.add_enabled(enabled, button).on_hover_text(tooltip)
79}
80
81/// A toolbar TOGGLE button (draw-tool selection, wireframe): pressed when `selected`.
82pub fn toggle(ui: &mut egui::Ui, selected: bool, glyph: &str, tooltip: &str) -> egui::Response {
83    let button = glyph_button(ui, glyph).selected(selected);
84    ui.add(button).on_hover_text(tooltip)
85}
86
87/// The outcome of a toolbar [`select`] combo for one frame.
88pub struct SelectResult {
89    /// The option `id` the user picked THIS frame — `Some` only on a real change
90    /// (differs from `current`); `None` otherwise.
91    pub changed: Option<String>,
92    /// The combo HEADER's screen rect (the verifier clicks this to open the menu).
93    pub header_rect: egui::Rect,
94    /// `(id, rect)` per menu item while the menu is OPEN (empty when closed) — the
95    /// verifier clicks an item rect to drive a SELECTION.
96    pub item_rects: Vec<(String, egui::Rect)>,
97}
98
99/// A toolbar SELECT (combo box) matched to the toolbar-button height — the ONE
100/// place a toolbar dropdown's style lives, so it reads as part of the button row.
101/// `options` are `(label, id)` pairs; `current` is the selected id. Publishes the
102/// header rect and (while open) per-item rects for the headed verifier.
103pub fn select(
104    ui: &mut egui::Ui,
105    id_source: &str,
106    current: &str,
107    options: &[(&str, &str)],
108) -> SelectResult {
109    let current_label = options
110        .iter()
111        .find(|(_, id)| *id == current)
112        .map(|(label, _)| *label)
113        .unwrap_or(current);
114    let mut changed = None;
115    let mut item_rects = Vec::new();
116    let inner = egui::ComboBox::from_id_salt(("toolbar-select", id_source))
117        // Let the popup size to its content (all options visible) — a fixed
118        // TOOLBAR_BTN height capped the menu to ~one row, hiding options past the
119        // first when the list grew (e.g. the added placeholder workbenches).
120        .selected_text(current_label)
121        .show_ui(ui, |ui| {
122            for (label, id) in options {
123                let resp = ui.selectable_label(*id == current, *label);
124                item_rects.push(((*id).to_string(), resp.rect));
125                if resp.clicked() && *id != current {
126                    changed = Some((*id).to_string());
127                }
128            }
129        });
130    SelectResult { changed, header_rect: inner.response.rect, item_rects }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    /// The rule that routes a button to artwork. Every toolbar/context/sketch
138    /// button goes through it, and there is no longer a font to fall back to —
139    /// a catalogued glyph that stopped resolving would render as a bare
140    /// character with no icon at all.
141    #[test]
142    fn every_catalogued_glyph_takes_the_artwork_path() {
143        // Colour artwork...
144        for glyph in ["\u{1F41E}", "\u{1F4DA}", "\u{270D}", "\u{E032}"] {
145            let icon = artwork(glyph).unwrap_or_else(|| panic!("{glyph:?} is catalogued"));
146            assert!(!icon.mono, "{glyph:?} is colour artwork");
147        }
148        // ...and MONOCHROME artwork, which now takes the same path and is
149        // tinted to the button's text colour instead of being drawn by a font.
150        for glyph in ["\u{2699}", "\u{26F6}", "\u{2139}"] {
151            let icon = artwork(glyph).unwrap_or_else(|| panic!("{glyph:?} is catalogued"));
152            assert!(icon.mono, "{glyph:?} is monochrome artwork");
153        }
154        // Not catalogued at all — drawn as plain text.
155        assert!(artwork("A").is_none());
156        // A composite label is not an icon, even if it starts with one.
157        assert!(artwork("\u{1F41E} Submit").is_none());
158        assert!(artwork("").is_none());
159    }
160
161    /// Every retired stacked-layer glyph must be gone from the font, not merely
162    /// unreferenced — leaving them would keep dead outlines in every binary.
163    /// U+E010–E027 were the file/undo/redo/projection layers; U+E05A–E05C were
164    /// the bug/book/pencil fills.
165    #[test]
166    fn the_retired_layer_glyphs_are_no_longer_catalogued() {
167        // E010-E018 are now the COMPOSITES (they reclaimed the block their own
168        // layers vacated); E019-E027 and E05A-E05C are the retired layers.
169        let retired = (0xE019u32..=0xE027).chain(0xE05A..=0xE05C);
170        for cp in retired.filter_map(char::from_u32) {
171            assert!(
172                !crate::icons::has(cp),
173                "U+{:04X} was a stacked-layer glyph and should be deleted",
174                cp as u32
175            );
176        }
177        // ...and the composites that replaced them ARE there.
178        for cp in (0xE010u32..=0xE018).filter_map(char::from_u32) {
179            assert!(crate::icons::has(cp), "U+{:04X} composite is missing", cp as u32);
180            let icon = artwork(&cp.to_string()).expect("composite is catalogued");
181            assert!(!icon.mono, "U+{:04X} must be colour", cp as u32);
182        }
183    }
184}