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}