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/// Painted edge of the workbench icon on one switcher row.
100const SELECT_ICON: f32 = 18.0;
101
102/// Painted edge of a switcher row's trailing marker. Both markers are square
103/// artwork drawn at this size, so the header and every menu item reserve the
104/// SAME right-hand slot and their labels sit at the same x.
105const SELECT_MARKER: f32 = 12.0;
106
107/// The switcher header's "there is a menu here" chevron (U+25BE) and the tick
108/// beside the ACTIVE workbench (U+2713). Both are catalogued artwork, like every
109/// other picture in this module: nothing here renders a character as text, so no
110/// font has to have it. They used to be `right_text("▾")` / `right_text("✓")`,
111/// which drew correctly only where the OS monospace happened to carry them —
112/// on wasm, where `fonts::install` falls back to egui's four bundled faces,
113/// NEITHER codepoint is covered (`Ubuntu-Light`/`NotoEmoji`/`emoji-icon-font`
114/// have no U+25BE, and nothing bundled has U+2713 at all), so both rendered as
115/// a tofu box in the browser build.
116const SELECT_CHEVRON: &str = "\u{25BE}";
117const SELECT_TICK: &str = "\u{2713}";
118
119/// One switcher row: the workbench's own icon, its label, and a right-aligned
120/// SLOT for the trailing marker.
121///
122/// The slot is an empty custom atom, and the marker is painted into the rect it
123/// reports, because [`egui::Button::image_tint_follows_text_color`] is a
124/// per-BUTTON switch: the workbench icons carry their own colours and must not
125/// be tinted, so a monochrome marker sharing the button could not be tinted
126/// either. Painting it separately also gets it the row's LIVE text colour —
127/// including the selected row's, which is the one place a tick ever appears.
128fn switcher_row(label: &str, glyph: &str, marker_slot: egui::Id) -> egui::Button<'static> {
129 let icon = artwork(glyph).expect("workbench icon must be catalogued");
130 let image = egui::Image::new(egui::ImageSource::Bytes {
131 uri: icon.uri.into(),
132 bytes: egui::load::Bytes::Static(icon.svg.as_bytes()),
133 })
134 .fit_to_exact_size(egui::Vec2::splat(SELECT_ICON));
135 egui::Button::new((
136 image,
137 label.to_owned(),
138 egui::Atom::grow(),
139 egui::Atom::custom(marker_slot, egui::Vec2::splat(SELECT_MARKER)),
140 ))
141 .image_tint_follows_text_color(icon.mono)
142 .wrap_mode(egui::TextWrapMode::Extend)
143}
144
145/// Draw one switcher row `width` wide and paint `marker` (if any) into its slot.
146fn switcher_row_ui(
147 ui: &mut egui::Ui,
148 label: &str,
149 glyph: &str,
150 width: f32,
151 selected: bool,
152 marker: Option<&str>,
153) -> egui::Response {
154 let slot = ui.id().with(("switcher-marker", label));
155 let out = switcher_row(label, glyph, slot)
156 .selected(selected)
157 .min_size(egui::vec2(width, TOOLBAR_BTN))
158 .atom_ui(ui);
159 if let (Some(marker), Some(rect)) = (marker, out.rect(slot)) {
160 let icon = artwork(marker).expect("switcher marker must be catalogued");
161 let tint = ui.style().interact_selectable(&out.response, selected).text_color();
162 egui::Image::new(egui::ImageSource::Bytes {
163 uri: icon.uri.into(),
164 bytes: egui::load::Bytes::Static(icon.svg.as_bytes()),
165 })
166 .tint(tint)
167 .paint_at(ui, rect);
168 }
169 out.response
170}
171
172/// The width the switcher is drawn at: the NATURAL width of its widest option,
173/// so the longest label fits exactly and nothing carries dead space.
174///
175/// egui measures it, in a throwaway invisible sizing-pass `Ui`, rather than this
176/// module re-deriving egui's button padding and atom-gap arithmetic — a copy of
177/// that arithmetic would go quietly loose or tight the next time either changes.
178/// Every row is measured with its marker slot present, so the header's chevron
179/// and an item's tick are both accounted for whichever row is widest.
180fn switcher_width(ui: &egui::Ui, options: &[(&str, &str, &str)]) -> f32 {
181 let mut probe = egui::Ui::new(
182 ui.ctx().clone(),
183 ui.id().with("switcher-measure"),
184 egui::UiBuilder::new()
185 .sizing_pass()
186 .invisible()
187 .style(ui.style().clone())
188 .layer_id(ui.layer_id())
189 // Far OFF SCREEN, deliberately. The probe still registers its rows
190 // as widgets in this layer, and at the origin those six rects would
191 // land on the toolbar, the workbench strip and the top of the dock —
192 // over widgets drawn BEFORE the switcher, which the switcher's own
193 // click cannot mask. Layout is arithmetic and `fit_to_exact_size` is
194 // deterministic, so the measurement is the same wherever it happens.
195 .max_rect(egui::Rect::from_min_size(
196 egui::pos2(1.0e5, 1.0e5),
197 egui::Vec2::splat(1.0e4),
198 )),
199 );
200 options.iter().fold(0.0_f32, |widest, (label, _, glyph)| {
201 let slot = probe.id().with(("switcher-marker", *label));
202 let row = switcher_row(label, glyph, slot).min_size(egui::vec2(0.0, TOOLBAR_BTN));
203 widest.max(row.atom_ui(&mut probe).response.rect.width())
204 })
205}
206
207/// An icon-and-label toolbar dropdown, as tall as the surrounding buttons and
208/// exactly as wide as its widest option needs.
209/// Options are `(label, id, glyph)` entries from the workbench registry.
210pub fn select(
211 ui: &mut egui::Ui,
212 id_source: &str,
213 current: &str,
214 options: &[(&str, &str, &str)],
215) -> SelectResult {
216 egui_extras::install_image_loaders(ui.ctx());
217 let width = switcher_width(ui, options);
218 let mut changed = None;
219 let mut item_rects = Vec::new();
220 let header = ui
221 .push_id(("toolbar-select", id_source), |ui| {
222 let (label, _, glyph) = options
223 .iter()
224 .find(|(_, id, _)| *id == current)
225 .expect("selected workbench must be registered");
226 let response =
227 switcher_row_ui(ui, label, glyph, width, false, Some(SELECT_CHEVRON))
228 .on_hover_text("Switch workbench");
229 egui::Popup::menu(&response).width(width).show(|ui| {
230 for (label, id, glyph) in options {
231 let selected = *id == current;
232 let marker = selected.then_some(SELECT_TICK);
233 let resp = switcher_row_ui(ui, label, glyph, width, selected, marker);
234 item_rects.push(((*id).to_string(), resp.rect));
235 if resp.clicked() {
236 if !selected {
237 changed = Some((*id).to_string());
238 }
239 ui.close();
240 }
241 }
242 });
243 response
244 })
245 .inner;
246 SelectResult {
247 changed,
248 header_rect: header.rect,
249 item_rects,
250 }
251}
252
253// BREP private tests: 86f0b0ca48f321fa