Skip to main content

brep_app/
icon_text.rs

1//! [`IconText`] — a label that draws catalogued characters as real SVG images,
2//! inline with the surrounding text.
3//!
4//! # Why
5//!
6//! The app used to draw its icons as characters, from an icon font. A TrueType
7//! glyph can only be ONE colour, so multi-colour artwork meant stacking several
8//! private-use glyphs at one origin and painting each a different colour. This
9//! widget removed that ceiling — a character is drawn from its SVG source
10//! ([`crate::icons`]), so an icon carries as many colours as its artwork does —
11//! and with every icon site drawing this way, the font itself is gone. An icon
12//! character is now only ever a KEY into the catalog.
13//!
14//! # Drop-in
15//!
16//! [`IconTextUi::icon_label`] takes exactly what [`egui::Ui::label`] takes and
17//! returns exactly what it returns, so converting a call site is a one-token
18//! edit — `ui.label(x)` becomes `ui.icon_label(x)`. Text with no catalogued
19//! character takes a fast path that IS a plain `Label`, so converting a site
20//! that never shows an icon changes nothing about how it lays out.
21//!
22//! Styling survives the split. The text is resolved to one [`egui::text::LayoutJob`]
23//! first, then sliced at icon characters, so every run keeps the exact
24//! [`egui::TextFormat`] it was given — `.strong()`, `.weak()`, `.small()`,
25//! `.color(…)`, an explicit `.font(…)`, all of it. Each icon is sized from the
26//! font of the run it sits in and tinted with that run's colour, so an icon in a
27//! `.small()` label is small and an icon in a red label is red.
28//!
29//! # Layout
30//!
31//! Segments are emitted into a wrapping horizontal layout, so a line of text
32//! with icons in it wraps like ordinary text rather than overflowing. egui's
33//! `Label` cooperates: in a wrapped horizontal layout it starts on the current
34//! row after the previous widget and continues below.
35//!
36//! # Image loader
37//!
38//! Drawing an SVG needs `egui_extras`' image loader on the context. This widget
39//! installs it itself, the first time it actually has an icon to draw, rather
40//! than making every shell remember to — a host that forgot would show blank
41//! gaps where icons should be, with no error. `install_image_loaders` skips
42//! loaders already present, so the call is idempotent, and the no-icon fast
43//! path never reaches it.
44
45use crate::icons;
46use eframe::egui::{
47    self,
48    text::{ByteIndex, LayoutJob, LayoutSection},
49    Align, FontSelection, Response, Sense, TextWrapMode, Widget, WidgetText,
50};
51
52/// The `Image` for a catalogued icon, sized to `height` points and its own
53/// aspect. Shared by every site that draws artwork instead of a font glyph —
54/// this widget, toolbar buttons, tree rows and palette rows — so one icon is the
55/// same size and comes from the same texture wherever it appears.
56///
57/// The image is NOT tinted: monochrome artwork is white in the catalog and needs
58/// the caller's text colour multiplied in, which only the caller knows. Colour
59/// artwork must never be tinted at all.
60pub fn image(icon: &'static icons::Icon, height: f32) -> egui::Image<'static> {
61    let size = egui::vec2(height * icon.aspect, height);
62    egui::Image::new(egui::ImageSource::Bytes {
63        uri: icon.uri.into(),
64        bytes: egui::load::Bytes::Static(icon.svg.as_bytes()),
65    })
66    .fit_to_exact_size(size)
67}
68
69/// A [`egui::Button`] whose label's leading catalogued glyph is drawn as
70/// artwork instead of as a character — the button counterpart of
71/// [`IconTextUi::icon_label`], for the small `✎` / `✕` / `▶` buttons scattered
72/// through the panels.
73///
74/// Monochrome artwork is tinted to the button's LIVE text colour, so hover,
75/// pressed and disabled states look exactly as they did when a font drew the
76/// glyph. Colour artwork is never tinted. A label with no leading catalogued
77/// glyph comes back as a plain text button, so converting a call site that
78/// turns out to have no icon changes nothing.
79///
80/// Build it before calling `ui.add`, since it borrows the `Ui` to install the
81/// image loader:
82///
83/// ```ignore
84/// let button = icon_text::icon_button(ui, "✎").small();
85/// if ui.add(button).clicked() { … }
86/// ```
87pub fn icon_button<'a>(ui: &egui::Ui, label: &'a str) -> egui::Button<'a> {
88    icon_button_colored(ui, label, None)
89}
90
91/// The same, in an explicit colour: `color` paints BOTH the artwork and the
92/// text, for a destructive action that has to read red. `None` leaves the
93/// button its ambient colours, which is what lets monochrome artwork follow
94/// hover and disabled state.
95pub fn icon_button_colored<'a>(
96    ui: &egui::Ui,
97    label: &'a str,
98    color: Option<egui::Color32>,
99) -> egui::Button<'a> {
100    let Some((icon, rest)) = split_leading(label) else {
101        return match color {
102            Some(c) => egui::Button::new(egui::RichText::new(label).color(c)),
103            None => egui::Button::new(label),
104        };
105    };
106    egui_extras::install_image_loaders(ui.ctx());
107    let mut art = image(icon, ui.text_style_height(&egui::TextStyle::Body));
108    // Colour artwork carries its own colours and is never recoloured; only
109    // monochrome artwork takes the caller's.
110    if let (Some(c), true) = (color, icon.mono) {
111        art = art.tint(c);
112    }
113    let button = match (rest.is_empty(), color) {
114        (true, _) => egui::Button::new(art),
115        (false, Some(c)) => egui::Button::new((art, egui::RichText::new(rest).color(c))),
116        (false, None) => egui::Button::new((art, rest)),
117    };
118    // An explicit colour is already applied above; otherwise let the tint follow
119    // the widget's live text colour so hover/pressed/disabled still read.
120    button.image_tint_follows_text_color(icon.mono && color.is_none())
121}
122
123/// A label's leading catalogued icon and the text after it, or `None` when it
124/// does not start with one.
125fn split_leading(label: &str) -> Option<(&'static icons::Icon, &str)> {
126    let mut chars = label.chars();
127    let icon = icons::lookup(chars.next()?)?;
128    Some((icon, chars.as_str().trim_start()))
129}
130
131/// A `selectable_label` whose leading catalogued glyph is drawn as artwork —
132/// for the list rows that lead with an icon (the add-feature palette, the file
133/// browser). Falls back to a plain `selectable_label` when there is no leading
134/// icon, so converting a row that turns out to have none changes nothing.
135pub fn selectable_icon_label(ui: &mut egui::Ui, selected: bool, label: &str) -> Response {
136    let Some((icon, rest)) = split_leading(label) else {
137        return ui.selectable_label(selected, label);
138    };
139    egui_extras::install_image_loaders(ui.ctx());
140    let art = image(icon, ui.text_style_height(&egui::TextStyle::Body));
141    ui.add(
142        egui::Button::selectable(selected, (art, rest))
143            .image_tint_follows_text_color(icon.mono),
144    )
145}
146
147/// ONE catalogued glyph drawn on its own, in `color` — for a status badge or
148/// any other place a bare glyph was previously a coloured `RichText`.
149/// `None` when the string is not a single catalogued character.
150pub fn glyph(ui: &egui::Ui, glyph: &str, color: egui::Color32) -> Option<egui::Image<'static>> {
151    let icon = icons::artwork(glyph)?;
152    egui_extras::install_image_loaders(ui.ctx());
153    let art = image(icon, ui.text_style_height(&egui::TextStyle::Body));
154    // Colour artwork carries its own colours and must not be tinted; monochrome
155    // artwork is white in the catalog, so the multiply lands it on `color`.
156    Some(if icon.mono { art.tint(color) } else { art })
157}
158
159/// The same split, for a caller that must MEASURE a caption the way
160/// [`icon_button`] will draw it: the leading icon (if any) and the remaining
161/// text. A caption with no leading icon comes back whole.
162pub fn split_caption(label: &str) -> (Option<&'static icons::Icon>, &str) {
163    match split_leading(label) {
164        Some((icon, rest)) => (Some(icon), rest),
165        None => (None, label),
166    }
167}
168
169/// One piece of a split label: either a run of text or a catalogued icon.
170enum Segment {
171    /// A text run, already carrying its own formatting.
172    Text(LayoutJob),
173    /// A catalogued icon, plus the format of the run it appeared in — that is
174    /// what sizes and colours it.
175    Icon(&'static icons::Icon, egui::TextFormat),
176}
177
178/// The settings shared by every `Label` a split produces. Held apart from the
179/// text so [`IconText::show`] can consume the text into a job and still pass
180/// these down.
181#[derive(Clone, Copy, Default)]
182struct Opts {
183    wrap_mode: Option<TextWrapMode>,
184    sense: Option<Sense>,
185    selectable: Option<bool>,
186}
187
188/// A label whose catalogued characters are drawn as SVG images inline with the
189/// text. See the module docs; construct with [`IconText::new`] or, at an
190/// existing `ui.label` call site, [`IconTextUi::icon_label`].
191#[must_use = "widgets do nothing unless you add them to a Ui"]
192pub struct IconText {
193    text: WidgetText,
194    opts: Opts,
195}
196
197impl IconText {
198    /// Take anything [`egui::Ui::label`] takes.
199    pub fn new(text: impl Into<WidgetText>) -> Self {
200        Self { text: text.into(), opts: Opts::default() }
201    }
202
203    /// Override the wrap mode (default: the `Ui`'s).
204    #[inline]
205    pub fn wrap_mode(mut self, wrap_mode: TextWrapMode) -> Self {
206        self.opts.wrap_mode = Some(wrap_mode);
207        self
208    }
209
210    /// Wrap long text onto the next line.
211    #[inline]
212    pub fn wrap(self) -> Self {
213        self.wrap_mode(TextWrapMode::Wrap)
214    }
215
216    /// Truncate long text with an ellipsis instead of wrapping.
217    #[inline]
218    pub fn truncate(self) -> Self {
219        self.wrap_mode(TextWrapMode::Truncate)
220    }
221
222    /// Let the text extend past the available width, growing the parent `Ui`.
223    #[inline]
224    pub fn extend(self) -> Self {
225        self.wrap_mode(TextWrapMode::Extend)
226    }
227
228    /// Make the label respond to clicks and/or drags, as [`egui::Label::sense`].
229    #[inline]
230    pub fn sense(mut self, sense: Sense) -> Self {
231        self.opts.sense = Some(sense);
232        self
233    }
234
235    /// Whether the text can be selected with the mouse, as [`egui::Label::selectable`].
236    #[inline]
237    pub fn selectable(mut self, selectable: bool) -> Self {
238        self.opts.selectable = Some(selectable);
239        self
240    }
241
242    /// Add to `ui`, returning the union of every segment's response.
243    pub fn show(self, ui: &mut egui::Ui) -> Response {
244        let Self { text, opts } = self;
245        // Resolve styling ONCE, into the same job `Label` would have built.
246        // Slicing this (rather than the original string) is what preserves
247        // per-run formatting through the split.
248        let job = std::sync::Arc::unwrap_or_clone(text.into_layout_job(
249            ui.style(),
250            FontSelection::Default,
251            ui.text_valign(),
252        ));
253
254        let segments = split(&job);
255
256        // Fast path: nothing to draw as an image, so BE a plain `Label` —
257        // same layout, same response, no nested `Ui`. Most converted call
258        // sites take this path, which is what makes the conversion safe to
259        // apply wholesale.
260        if segments.len() == 1 {
261            if let Some(Segment::Text(_)) = segments.first() {
262                return opts.label(job).ui(ui);
263            }
264        }
265
266        // Icons present, so we are about to draw images: make sure the loader
267        // that decodes them is on this context. Idempotent — see the module docs.
268        egui_extras::install_image_loaders(ui.ctx());
269
270        // Icons present: lay the pieces out as one wrapping line of text. If we
271        // are already inside a wrapping horizontal layout, join it rather than
272        // nesting — nesting would restart wrapping at the full width and
273        // overflow the row we were handed.
274        if ui.layout().is_horizontal() && ui.layout().main_wrap() {
275            opts.emit(ui, segments)
276        } else {
277            ui.horizontal_wrapped(|ui| opts.emit(ui, segments)).inner
278        }
279    }
280}
281
282impl Opts {
283    /// Draw the segments into `ui`, which is a wrapping horizontal layout.
284    fn emit(&self, ui: &mut egui::Ui, segments: Vec<Segment>) -> Response {
285        // Text runs must butt up against their icons with no gap, or every icon
286        // would sit in a word-space of its own. Restore the caller's spacing
287        // afterwards so our next sibling is still separated normally.
288        let spacing = ui.spacing().item_spacing;
289        ui.spacing_mut().item_spacing.x = 0.0;
290
291        let mut response: Option<Response> = None;
292        let mut union = |acc: &mut Option<Response>, r: Response| {
293            *acc = Some(match acc.take() {
294                Some(prev) => prev | r,
295                None => r,
296            });
297        };
298
299        for segment in segments {
300            match segment {
301                Segment::Text(job) => union(&mut response, self.label(job).ui(ui)),
302                Segment::Icon(icon, format) => {
303                    union(&mut response, self.icon(ui, icon, &format));
304                }
305            }
306        }
307
308        ui.spacing_mut().item_spacing = spacing;
309        // An empty string yields no segments; hand back an inert zero-size
310        // allocation so callers always get a Response, as `ui.label("")` does.
311        response.unwrap_or_else(|| ui.allocate_response(egui::Vec2::ZERO, Sense::hover()))
312    }
313
314    /// Draw one icon, sized and coloured from the text run it belongs to.
315    fn icon(&self, ui: &mut egui::Ui, icon: &'static icons::Icon, format: &egui::TextFormat) -> Response {
316        // Match the line box of the run's own font, so the image occupies the
317        // space a glyph of that font would have, so the icon lands on the text
318        // baseline at text size.
319        let height = ui.fonts_mut(|f| f.row_height(&format.font_id));
320        let size = egui::vec2(height * icon.aspect, height);
321
322        let mut image = image(icon, height);
323
324        // Monochrome artwork is white in the catalog, so multiplying by the text
325        // colour lands it exactly on that colour. Artwork with authored colours
326        // is never tinted — that would wash it out.
327        if icon.mono {
328            image = image.tint(resolve(ui, format.color));
329        }
330
331        let response = ui.add_sized(size, image);
332
333        // Put the character itself over the image, invisibly: the icon then
334        // still selects, copies and reads out as text, exactly as it does when
335        // the font draws it.
336        ui.put(
337            response.rect,
338            egui::Label::new(
339                egui::RichText::new(icon.ch)
340                    .font(format.font_id.clone())
341                    .color(egui::Color32::TRANSPARENT),
342            )
343            .selectable(self.selectable.unwrap_or(false)),
344        ) | response
345    }
346
347    /// A `Label` for one text run, carrying the shared settings.
348    fn label(&self, job: LayoutJob) -> egui::Label {
349        let mut label = egui::Label::new(job);
350        if let Some(wrap_mode) = self.wrap_mode {
351            label = label.wrap_mode(wrap_mode);
352        }
353        if let Some(sense) = self.sense {
354            label = label.sense(sense);
355        }
356        if let Some(selectable) = self.selectable {
357            label = label.selectable(selectable);
358        }
359        label
360    }
361}
362
363impl Widget for IconText {
364    fn ui(self, ui: &mut egui::Ui) -> Response {
365        self.show(ui)
366    }
367}
368
369/// [`egui::Color32::PLACEHOLDER`] means "whatever the style says" — `Label`
370/// substitutes the real colour when it paints. We tint before that happens, so
371/// we have to do the same substitution ourselves; tinting with the placeholder
372/// would paint the sentinel colour.
373fn resolve(ui: &egui::Ui, color: egui::Color32) -> egui::Color32 {
374    if color == egui::Color32::PLACEHOLDER {
375        ui.visuals().text_color()
376    } else {
377        color
378    }
379}
380
381/// Split a laid-out job at every catalogued character.
382///
383/// Returns a single `Text` segment when nothing is catalogued, which is the
384/// signal for the plain-`Label` fast path.
385fn split(job: &LayoutJob) -> Vec<Segment> {
386    let mut segments = Vec::new();
387    let mut run_start = 0;
388
389    for (at, ch) in job.text.char_indices() {
390        let Some(icon) = icons::lookup(ch) else { continue };
391        if run_start < at {
392            segments.push(Segment::Text(slice(job, run_start, at)));
393        }
394        segments.push(Segment::Icon(icon, format_at(job, at)));
395        run_start = at + ch.len_utf8();
396    }
397
398    if segments.is_empty() {
399        // No catalogued character anywhere: hand back the job exactly as it
400        // came, so the fast path in `show` is a byte-for-byte plain `Label`.
401        return vec![Segment::Text(job.clone())];
402    }
403    if run_start < job.text.len() {
404        segments.push(Segment::Text(slice(job, run_start, job.text.len())));
405    }
406    segments
407}
408
409/// The format covering byte `at` — the styling of the run an icon sits in.
410fn format_at(job: &LayoutJob, at: usize) -> egui::TextFormat {
411    // `format_at_byte` panics on a section-less job; one with text always has
412    // sections, but an icon-only job built by hand might not.
413    if job.sections.is_empty() {
414        return egui::TextFormat::default();
415    }
416    job.format_at_byte(ByteIndex(at)).clone()
417}
418
419/// A sub-job over `job[start..end]`, keeping each overlapping section's format.
420fn slice(job: &LayoutJob, start: usize, end: usize) -> LayoutJob {
421    // Clone so every scalar setting (halign, justify, break_on_newline, …)
422    // carries over, then replace the parts that are per-slice.
423    let mut out = job.clone();
424    out.text = job.text[start..end].to_owned();
425    out.sections = job
426        .sections
427        .iter()
428        .filter_map(|s| {
429            let lo = s.byte_range.start.0.max(start);
430            let hi = s.byte_range.end.0.min(end);
431            (lo < hi).then(|| LayoutSection {
432                // Leading space belongs to the section's true start; a section
433                // we cut into mid-way must not re-apply it.
434                leading_space: if s.byte_range.start.0 >= start { s.leading_space } else { 0.0 },
435                byte_range: ByteIndex(lo - start)..ByteIndex(hi - start),
436                format: s.format.clone(),
437            })
438        })
439        .collect();
440    // `Label` rebuilds these per row it lays out.
441    out.first_row_min_height = 0.0;
442    out.halign = Align::LEFT;
443    out
444}
445
446/// `ui.label(…)` → `ui.icon_label(…)`: the drop-in seam. Implemented for
447/// [`egui::Ui`], so it is in scope wherever this trait is imported.
448pub trait IconTextUi {
449    /// As [`egui::Ui::label`], but catalogued characters are drawn as SVG icons.
450    fn icon_label(&mut self, text: impl Into<WidgetText>) -> Response;
451}
452
453impl IconTextUi for egui::Ui {
454    fn icon_label(&mut self, text: impl Into<WidgetText>) -> Response {
455        IconText::new(text).show(self)
456    }
457}
458
459// BREP private tests: 261625828601e384
460