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