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/// The size the button ARTWORK is drawn at inside that square — shared by the
31/// font glyphs and the SVG icons so the two render identically sized.
32const GLYPH_SIZE: f32 = 17.0;
33
34fn square() -> egui::Vec2 {
35 egui::vec2(TOOLBAR_BTN, TOOLBAR_BTN)
36}
37
38/// The button for one glyph: its catalogued artwork if it has any, else the
39/// characters themselves as text.
40///
41/// Monochrome artwork is white in the catalog, so `image_tint_follows_text_color`
42/// multiplies it by the button's LIVE text colour — which is what keeps hover,
43/// pressed and disabled states looking exactly as they did when the font drew
44/// the glyph. Colour artwork must never be tinted, so it opts out.
45fn glyph_button<'a>(ui: &egui::Ui, glyph: &'a str) -> egui::Button<'a> {
46 match artwork(glyph) {
47 Some(icon) => {
48 // Idempotent, and the only place a toolbar button needs it: a button
49 // can be the first thing on screen to draw an SVG.
50 egui_extras::install_image_loaders(ui.ctx());
51 egui::Button::new(crate::icon_text::image(icon, GLYPH_SIZE))
52 .image_tint_follows_text_color(icon.mono)
53 .min_size(square())
54 }
55 None => egui::Button::new(glyph).min_size(square()),
56 }
57}
58
59/// A plain action toolbar button: `glyph` shown, `tooltip` on hover. Square.
60pub fn button(ui: &mut egui::Ui, glyph: &str, tooltip: &str) -> egui::Response {
61 let button = glyph_button(ui, glyph);
62 ui.add(button).on_hover_text(tooltip)
63}
64
65/// A toolbar button enabled only when `enabled` (undo/redo, selection actions).
66pub fn button_enabled(
67 ui: &mut egui::Ui,
68 enabled: bool,
69 glyph: &str,
70 tooltip: &str,
71) -> egui::Response {
72 let button = glyph_button(ui, glyph);
73 ui.add_enabled(enabled, button).on_hover_text(tooltip)
74}
75
76/// A toolbar TOGGLE button (draw-tool selection, wireframe): pressed when `selected`.
77pub fn toggle(ui: &mut egui::Ui, selected: bool, glyph: &str, tooltip: &str) -> egui::Response {
78 let button = glyph_button(ui, glyph).selected(selected);
79 ui.add(button).on_hover_text(tooltip)
80}
81
82/// The outcome of a toolbar [`select`] combo for one frame.
83pub struct SelectResult {
84 /// The option `id` the user picked THIS frame — `Some` only on a real change
85 /// (differs from `current`); `None` otherwise.
86 pub changed: Option<String>,
87 /// The combo HEADER's screen rect (the verifier clicks this to open the menu).
88 pub header_rect: egui::Rect,
89 /// `(id, rect)` per menu item while the menu is OPEN (empty when closed) — the
90 /// verifier clicks an item rect to drive a SELECTION.
91 pub item_rects: Vec<(String, egui::Rect)>,
92}
93
94/// A toolbar SELECT (combo box) matched to the toolbar-button height — the ONE
95/// place a toolbar dropdown's style lives, so it reads as part of the button row.
96/// `options` are `(label, id)` pairs; `current` is the selected id. Publishes the
97/// header rect and (while open) per-item rects for the headed verifier.
98pub fn select(
99 ui: &mut egui::Ui,
100 id_source: &str,
101 current: &str,
102 options: &[(&str, &str)],
103) -> SelectResult {
104 let current_label = options
105 .iter()
106 .find(|(_, id)| *id == current)
107 .map(|(label, _)| *label)
108 .unwrap_or(current);
109 let mut changed = None;
110 let mut item_rects = Vec::new();
111 let inner = egui::ComboBox::from_id_salt(("toolbar-select", id_source))
112 // Let the popup size to its content (all options visible) — a fixed
113 // TOOLBAR_BTN height capped the menu to ~one row, hiding options past the
114 // first when the list grew (e.g. the added placeholder workbenches).
115 .selected_text(current_label)
116 .show_ui(ui, |ui| {
117 for (label, id) in options {
118 let resp = ui.selectable_label(*id == current, *label);
119 item_rects.push(((*id).to_string(), resp.rect));
120 if resp.clicked() && *id != current {
121 changed = Some((*id).to_string());
122 }
123 }
124 });
125 SelectResult { changed, header_rect: inner.response.rect, item_rects }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 /// The rule that routes a button to artwork. Every toolbar/context/sketch
133 /// button goes through it, and there is no longer a font to fall back to —
134 /// a catalogued glyph that stopped resolving would render as a bare
135 /// character with no icon at all.
136 #[test]
137 fn every_catalogued_glyph_takes_the_artwork_path() {
138 // Colour artwork...
139 for glyph in ["\u{1F41E}", "\u{1F4DA}", "\u{270D}", "\u{E032}"] {
140 let icon = artwork(glyph).unwrap_or_else(|| panic!("{glyph:?} is catalogued"));
141 assert!(!icon.mono, "{glyph:?} is colour artwork");
142 }
143 // ...and MONOCHROME artwork, which now takes the same path and is
144 // tinted to the button's text colour instead of being drawn by a font.
145 for glyph in ["\u{2699}", "\u{26F6}", "\u{2139}"] {
146 let icon = artwork(glyph).unwrap_or_else(|| panic!("{glyph:?} is catalogued"));
147 assert!(icon.mono, "{glyph:?} is monochrome artwork");
148 }
149 // Not catalogued at all — drawn as plain text.
150 assert!(artwork("A").is_none());
151 // A composite label is not an icon, even if it starts with one.
152 assert!(artwork("\u{1F41E} Submit").is_none());
153 assert!(artwork("").is_none());
154 }
155
156 /// Every retired stacked-layer glyph must be gone from the font, not merely
157 /// unreferenced — leaving them would keep dead outlines in every binary.
158 /// U+E010–E027 were the file/undo/redo/projection layers; U+E05A–E05C were
159 /// the bug/book/pencil fills.
160 #[test]
161 fn the_retired_layer_glyphs_are_no_longer_catalogued() {
162 // E010-E018 are now the COMPOSITES (they reclaimed the block their own
163 // layers vacated); E019-E027 and E05A-E05C are the retired layers.
164 let retired = (0xE019u32..=0xE027).chain(0xE05A..=0xE05C);
165 for cp in retired.filter_map(char::from_u32) {
166 assert!(
167 !crate::icons::has(cp),
168 "U+{:04X} was a stacked-layer glyph and should be deleted",
169 cp as u32
170 );
171 }
172 // ...and the composites that replaced them ARE there.
173 for cp in (0xE010u32..=0xE018).filter_map(char::from_u32) {
174 assert!(crate::icons::has(cp), "U+{:04X} composite is missing", cp as u32);
175 let icon = artwork(&cp.to_string()).expect("composite is catalogued");
176 assert!(!icon.mono, "U+{:04X} must be colour", cp as u32);
177 }
178 }
179}