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