Skip to main content

agg_gui/widgets/
label.rs

1//! `Label` — static text display widget.
2//!
3//! Labels are non-interactive by design (`hit_test` always returns `false`
4//! and `on_event` always returns `Ignored`).  This makes them safe to use as
5//! transparent overlay children inside interactive parents like `Button` — the
6//! parent retains full hit-test and focus ownership.
7//!
8//! # Backbuffer
9//!
10//! When `buffered` is `true` AND the active `DrawCtx` supports image blitting
11//! (`ctx.has_image_blit()` returns `true`, i.e. the software `GfxCtx` path),
12//! the label pre-renders its glyphs into an offscreen `Framebuffer` on the
13//! first `paint()` call — or whenever `text`, `font_size`, `color`, or `bounds`
14//! change — and blits the cached pixels every subsequent frame via
15//! `ctx.draw_image_rgba()`.  No font shaping or rasterisation occurs on cache
16//! hits.
17//!
18//! On the GL path (`has_image_blit()` → false) the label falls back to the
19//! direct `fill_text()` call; the GL path's `GlyphCache` provides equivalent
20//! glyph-level savings there.
21
22use std::sync::Arc;
23
24use crate::color::Color;
25use crate::draw_ctx::DrawCtx;
26use crate::event::{Event, EventResult};
27use crate::geometry::{Point, Rect, Size};
28use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
29use crate::text::Font;
30use crate::widget::Widget;
31
32/// Break `text` into lines that each fit within `max_width` pixels at the given
33/// font size.  Explicit `\n` characters always produce a new line.  Returns at
34/// least one entry (possibly an empty string for blank text).
35fn wrap_text(font: &Arc<Font>, text: &str, font_size: f64, max_width: f64) -> Vec<String> {
36    use crate::text::measure_text_metrics;
37    let mut result = Vec::new();
38    for paragraph in text.split('\n') {
39        if paragraph.trim().is_empty() {
40            // Preserve explicit blank lines.
41            result.push(String::new());
42            continue;
43        }
44        let mut current: String = String::new();
45        for word in paragraph.split_whitespace() {
46            if current.is_empty() {
47                current.push_str(word);
48            } else {
49                let candidate = format!("{current} {word}");
50                let w = measure_text_metrics(font, &candidate, font_size).width;
51                if w <= max_width {
52                    current = candidate;
53                } else {
54                    result.push(std::mem::replace(&mut current, word.to_string()));
55                }
56            }
57        }
58        if !current.is_empty() {
59            result.push(current);
60        }
61    }
62    if result.is_empty() {
63        result.push(String::new());
64    }
65    result
66}
67
68/// Horizontal alignment for `Label` text.
69#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
70pub enum LabelAlign {
71    #[default]
72    Left,
73    Center,
74    Right,
75}
76
77/// A non-interactive text widget.
78///
79/// Used directly as a standalone label, and as a child of composite widgets
80/// such as [`Button`] and (in the future) `Checkbox`, `RadioGroup`, etc.
81///
82/// When no explicit color is set via [`with_color`](Label::with_color), the
83/// label reads its text color from the active [`Visuals`](crate::theme::Visuals)
84/// at paint time (`ctx.visuals().text_color`), so it automatically adapts to
85/// dark / light mode switches.
86pub struct Label {
87    bounds: Rect,
88    children: Vec<Box<dyn Widget>>, // always empty
89    base: WidgetBase,
90    text: String,
91    font: Arc<Font>,
92    font_size: f64,
93    /// `None` → use a theme colour at paint time (`text_dim` when `dim`,
94    /// else `text_color`).
95    /// `Some(c)` → explicit override (e.g. accent-coloured text).
96    color: Option<Color>,
97    /// When `true` and no explicit `color` is set, follow the theme's
98    /// dimmed/secondary text colour (`visuals().text_dim`) for hints and
99    /// captions.  Resolves at paint time like the `text_color` default, so
100    /// it tracks live dark/light switches.
101    dim: bool,
102    /// When `true` and no explicit `color` is set, render with an
103    /// emphasised "strong" text colour — agg-gui's analog of egui's
104    /// `ui.strong(...)` / `Visuals::strong_text_color()`.  agg-gui has no
105    /// bold font weight, so emphasis is expressed by pushing `text_color`
106    /// toward the theme's extreme (whiter on dark, blacker on light),
107    /// exactly as egui's strong colour brightens body text.  Resolves at
108    /// paint time so it tracks live dark/light switches.
109    strong: bool,
110    align: LabelAlign,
111    /// When `true` (the default), this Label owns a CPU backbuffer
112    /// that's re-rasterised on dirty and blitted every frame.  Set to
113    /// `false` only for text that changes every frame (e.g. live
114    /// counters) where caching adds overhead with no benefit — those
115    /// go through `ctx.fill_text` direct every paint.
116    pub buffered: bool,
117    /// Per-widget CPU bitmap cache.  Populated by `paint_subtree` when
118    /// `buffered = true`; invalidated by Label's setters (text, color,
119    /// align, etc.) so the next paint re-rasterises.
120    cache: crate::widget::BackbufferCache,
121    /// When `true`, long lines are broken at word boundaries to fit
122    /// `available.width`.  The label height expands to fit all lines.
123    /// Disabled by default; enable with `.with_wrap(true)`.
124    wrap: bool,
125    /// When `true`, this Label ignores the system-wide font override
126    /// (`font_settings::current_system_font`) and always renders with
127    /// the specific `self.font` passed to `Label::new`.  Used by font
128    /// preview widgets (ComboBox item labels in the System window's
129    /// font selector) where each entry must render in its OWN face
130    /// regardless of the current global font choice.
131    ignore_system_font: bool,
132    /// Per-instance LCD preference: `Some(true)` always LCD, `Some(false)`
133    /// always grayscale, `None` defers to the global
134    /// `font_settings::lcd_enabled()`.  Exposed on every widget via
135    /// `Widget::lcd_preference`; Label is the only widget that reads it
136    /// today.
137    lcd_pref: Option<bool>,
138
139    // ── Layout measurement cache ──────────────────────────────────────────────
140    /// Cached text advance width from last `measure_advance()` call.
141    /// Avoids calling `rustybuzz::shape()` every frame — only re-measures
142    /// when `text` or `font_size` changes.
143    layout_text: String,
144    layout_font_size: f64,
145    layout_width: f64,
146    /// Pointer identity of the [`Font`] used for the last measurement.  If
147    /// the system-wide font override (see
148    /// [`font_settings::current_system_font`](crate::font_settings::current_system_font))
149    /// is swapped, pointer identity changes and we re-measure to pick up
150    /// the new font's glyph metrics.
151    layout_font_ptr: *const Font,
152    /// Width used for the last word-wrap computation.
153    wrap_at_width: f64,
154    /// Lines produced by the last word-wrap computation.
155    wrapped_lines: Vec<String>,
156}
157
158impl Label {
159    pub fn new(text: impl Into<String>, font: Arc<Font>) -> Self {
160        Self {
161            bounds: Rect::default(),
162            children: Vec::new(),
163            base: WidgetBase::new(),
164            text: text.into(),
165            font,
166            font_size: 14.0,
167            color: None, // resolved from ctx.visuals() at paint time
168            dim: false,
169            strong: false,
170            align: LabelAlign::Left,
171            // Default: backbuffer only when grayscale.  Rationale:
172            //   - Grayscale on GL direct-to-surface goes through
173            //     tessellated glyph outlines, which are visibly thinner
174            //     than AGG's subpixel-accurate scanline coverage.
175            //     Routing grayscale through a software backbuffer gives
176            //     AGG-quality rasterisation blitted as a texture.
177            //   - LCD on GL direct-to-surface uses dual-source blend on
178            //     the cached LCD mask — identical quality to AGG.
179            //     Adding a backbuffer here would force the sub-ctx into
180            //     `Rgba` mode (Label has no opaque bg for `LcdCoverage`)
181            //     and lose the subpixel result.
182            // `buffered` stores the user's opt-out; the actual decision
183            // happens in `backbuffer_cache_mut` based on the global
184            // LCD flag.
185            buffered: true,
186            cache: crate::widget::BackbufferCache::new(),
187            wrap: false,
188            ignore_system_font: false,
189            lcd_pref: None,
190            layout_text: String::new(),
191            layout_font_size: 0.0,
192            layout_width: 0.0,
193            layout_font_ptr: std::ptr::null(),
194            wrap_at_width: -1.0,
195            wrapped_lines: Vec::new(),
196        }
197    }
198
199    // ── builder methods ───────────────────────────────────────────────────────
200
201    pub fn with_font_size(mut self, size: f64) -> Self {
202        self.font_size = size;
203        self
204    }
205    /// Override the label colour.  Pass an explicit `Color` to always use that
206    /// colour regardless of the active theme.  Omit this call to follow the
207    /// theme's `text_color` automatically.
208    pub fn with_color(mut self, color: Color) -> Self {
209        self.color = Some(color);
210        self
211    }
212    /// Follow the theme's *dimmed* text colour (`visuals().text_dim`)
213    /// instead of the primary `text_color` — for secondary / hint /
214    /// caption text.  Has no effect when an explicit
215    /// [`with_color`](Label::with_color) is also set.  Like the default
216    /// `text_color` path this resolves at paint time, so it stays readable
217    /// across live dark/light theme switches (unlike a hard-coded grey).
218    pub fn with_dim(mut self, dim: bool) -> Self {
219        self.dim = dim;
220        self
221    }
222    /// Emphasise this label with a "strong" text colour, agg-gui's analog
223    /// of egui's `ui.strong(...)`.  Has no effect when an explicit
224    /// [`with_color`](Label::with_color) is also set.  Like the default
225    /// colour path this resolves at paint time, so it tracks live
226    /// dark/light theme switches.
227    pub fn with_strong(mut self, strong: bool) -> Self {
228        self.strong = strong;
229        self
230    }
231    pub fn with_align(mut self, align: LabelAlign) -> Self {
232        self.align = align;
233        self
234    }
235    pub fn with_has_backbuffer(mut self, v: bool) -> Self {
236        self.buffered = v;
237        self
238    }
239    /// Enable or disable word-wrapping.  When `true`, long lines are broken at
240    /// word boundaries to fit the available width; the label height expands to
241    /// accommodate all lines.  Newlines in the text are always honoured.
242    pub fn with_wrap(mut self, wrap: bool) -> Self {
243        self.wrap = wrap;
244        self
245    }
246
247    /// Opt OUT of the system-wide font override for this Label.  The
248    /// Label will render with `self.font` (passed to `Label::new`)
249    /// regardless of what `font_settings::set_system_font` is pointing
250    /// at.  Useful for font-preview UI — each entry in a font picker
251    /// dropdown needs its OWN face, not the currently selected one.
252    /// Pin this label's LCD setting: `Some(true)` always LCD, `Some(false)`
253    /// always grayscale, `None` (default) defers to the global toggle.
254    pub fn with_lcd(mut self, pref: Option<bool>) -> Self {
255        self.lcd_pref = pref;
256        self
257    }
258
259    pub fn with_ignore_system_font(mut self, ignore: bool) -> Self {
260        self.ignore_system_font = ignore;
261        self
262    }
263
264    pub fn with_margin(mut self, m: Insets) -> Self {
265        self.base.margin = m;
266        self
267    }
268    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
269        self.base.h_anchor = h;
270        self
271    }
272    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
273        self.base.v_anchor = v;
274        self
275    }
276    pub fn with_min_size(mut self, s: Size) -> Self {
277        self.base.min_size = s;
278        self
279    }
280    pub fn with_max_size(mut self, s: Size) -> Self {
281        self.base.max_size = s;
282        self
283    }
284
285    // ── getter methods ────────────────────────────────────────────────────────
286
287    /// Return the current label text as a `&str`.
288    pub fn text_str(&self) -> &str {
289        &self.text
290    }
291
292    /// Resolve the font used for THIS layout/paint.  Prefers the system-wide
293    /// font override (set by the System window / `font_settings::set_system_font`)
294    /// so swapping the system font live flows through every widget; falls
295    /// back to the per-instance font otherwise.  Scrollbar-style pattern.
296    fn active_font(&self) -> Arc<Font> {
297        if self.ignore_system_font {
298            Arc::clone(&self.font)
299        } else {
300            crate::font_settings::current_system_font().unwrap_or_else(|| Arc::clone(&self.font))
301        }
302    }
303
304    /// Per-instance font size multiplied by the system-wide
305    /// [`font_settings::current_font_size_scale`].  Label's font-preview
306    /// UI (combo-box items flagged `ignore_system_font`) ALSO ignores
307    /// the scale — a font picker must show every entry at the same
308    /// reference size or comparing faces becomes useless.
309    fn active_font_size(&self) -> f64 {
310        if self.ignore_system_font {
311            self.font_size
312        } else {
313            self.font_size * crate::font_settings::current_font_size_scale()
314        }
315    }
316
317    // ── setter methods (for post-construction mutation) ───────────────────────
318
319    pub fn set_font_size(&mut self, size: f64) {
320        if (self.font_size - size).abs() > 1e-9 {
321            self.font_size = size;
322            self.cache.invalidate();
323        }
324    }
325
326    pub fn set_text(&mut self, text: impl Into<String>) {
327        let text = text.into();
328        if text != self.text {
329            self.text = text;
330            self.cache.invalidate();
331        }
332    }
333    pub fn set_color(&mut self, color: Color) {
334        if self.color != Some(color) {
335            self.color = Some(color);
336            self.cache.invalidate();
337        }
338    }
339    pub fn clear_color(&mut self) {
340        if self.color.is_some() {
341            self.color = None;
342            self.cache.invalidate();
343        }
344    }
345    pub fn set_align(&mut self, align: LabelAlign) {
346        if self.align != align {
347            self.align = align;
348            self.cache.invalidate();
349        }
350    }
351}
352
353/// Push `c` toward the theme's extreme to express egui-style "strong" text
354/// emphasis.  Light body text (on a dark theme) brightens toward white;
355/// dark body text (on a light theme) deepens toward black — mirroring how
356/// egui's `strong_text_color()` sits above the ordinary body colour.
357fn strengthen(c: Color) -> Color {
358    const BLEND: f32 = 0.5;
359    let lum = 0.299 * c.r + 0.587 * c.g + 0.114 * c.b;
360    if lum > 0.5 {
361        Color::rgba(
362            c.r + (1.0 - c.r) * BLEND,
363            c.g + (1.0 - c.g) * BLEND,
364            c.b + (1.0 - c.b) * BLEND,
365            c.a,
366        )
367    } else {
368        Color::rgba(
369            c.r * (1.0 - BLEND),
370            c.g * (1.0 - BLEND),
371            c.b * (1.0 - BLEND),
372            c.a,
373        )
374    }
375}
376
377impl Widget for Label {
378    fn type_name(&self) -> &'static str {
379        "Label"
380    }
381    fn bounds(&self) -> Rect {
382        self.bounds
383    }
384    fn set_bounds(&mut self, b: Rect) {
385        // Only invalidate on SIZE change — position doesn't affect
386        // cached bitmap (painted at local origin, blitted at parent's
387        // choice of translation).  Framework also invalidates via
388        // `cache.width != w || cache.height != h` in
389        // `paint_subtree_backbuffered`, so this is defence in depth.
390        if self.bounds.width != b.width || self.bounds.height != b.height {
391            self.cache.invalidate();
392        }
393        self.bounds = b;
394    }
395    fn children(&self) -> &[Box<dyn Widget>] {
396        &self.children
397    }
398    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
399        &mut self.children
400    }
401
402    fn lcd_preference(&self) -> Option<bool> {
403        self.lcd_pref
404    }
405
406    fn set_label_color(&mut self, color: Color) {
407        self.set_color(color);
408    }
409
410    fn set_label_text(&mut self, text: &str) {
411        self.set_text(text);
412    }
413
414    fn backbuffer_cache_mut(&mut self) -> Option<&mut crate::widget::BackbufferCache> {
415        // Cache always when `buffered`.  Mode is chosen by
416        // `backbuffer_mode` below — LCD on → per-channel LcdCoverage
417        // buffer, LCD off → Rgba buffer.  Per-channel alpha means
418        // unpainted pixels stay `alpha = 0` and blit leaves parent
419        // unchanged there, so no scroll-stale cache problem (that
420        // was a dead end from the seed-from-parent approach we ripped
421        // out).
422        if self.buffered {
423            Some(&mut self.cache)
424        } else {
425            None
426        }
427    }
428
429    fn backbuffer_mode(&self) -> crate::widget::BackbufferMode {
430        // Dispatching on the global LCD flag means toggling the
431        // setting automatically rebuilds every cached label in the
432        // right format — `paint_subtree_backbuffered` detects the
433        // mode flip via `cache.lcd_alpha.is_some()` vs the requested
434        // mode and forces a re-raster.
435        if crate::font_settings::lcd_enabled() {
436            crate::widget::BackbufferMode::LcdCoverage
437        } else {
438            crate::widget::BackbufferMode::Rgba
439        }
440    }
441
442    /// Labels are never independently hittable.  This lets their interactive
443    /// parent (e.g., Button) retain full hit-test and focus ownership even
444    /// when the label fills the parent's entire bounds.
445    fn hit_test(&self, _: Point) -> bool {
446        false
447    }
448
449    fn layout(&mut self, available: Size) -> Size {
450        // Resolve the effective font + size ONCE per layout so this call
451        // and the paint that follows agree on glyph metrics even if the
452        // system scale is mid-transition.
453        let font = self.active_font();
454        let size = self.active_font_size();
455        let line_h = size * 1.5;
456
457        // Drop the pre-rasterized bitmap the moment we notice a font or size
458        // swap — unconditionally, before any other branching.  Without this
459        // a buffered Label (the default) keeps blitting glyphs drawn with
460        // the previous typeface / point size until a bounds change or a
461        // text edit happens to invalidate the cache.  DragValue hits this
462        // hardest: its `value_label` often measures the same width for two
463        // different fonts ("14.0" in Arial vs the default is identical
464        // within a pixel), so the size-based invalidation in `set_bounds`
465        // never fires and the stale bitmap lingers until the user hovers
466        // (which triggers some other layout-affecting update).
467        let font_changed = Arc::as_ptr(&font) != self.layout_font_ptr;
468        let size_changed = (self.layout_font_size - size).abs() > 0.01;
469        if font_changed || size_changed {
470            self.cache.invalidate();
471        }
472
473        if self.wrap && available.width > 0.0 {
474            let text_changed = self.layout_text != self.text || size_changed;
475            let width_changed = (self.wrap_at_width - available.width).abs() > 1.0;
476            if text_changed || width_changed || font_changed {
477                self.wrapped_lines = wrap_text(&font, &self.text, size, available.width);
478                self.wrap_at_width = available.width;
479                self.layout_text = self.text.clone();
480                self.layout_font_size = size;
481                self.layout_font_ptr = Arc::as_ptr(&font);
482                // Text changes also need a bitmap rebuild.
483                if text_changed {
484                    self.cache.invalidate();
485                }
486            }
487            let total_h = self.wrapped_lines.len() as f64 * line_h;
488            Size::new(available.width, total_h)
489        } else {
490            // Single-line path: tight bounds matching rendered text width.
491            if self.layout_text != self.text || size_changed || font_changed {
492                let metrics = crate::text::measure_text_metrics(&font, &self.text, size);
493                self.layout_width = metrics.width;
494                self.layout_text = self.text.clone();
495                self.layout_font_size = size;
496                self.layout_font_ptr = Arc::as_ptr(&font);
497            }
498            Size::new(self.layout_width.min(available.width), line_h)
499        }
500    }
501
502    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
503        let w = self.bounds.width;
504        let h = self.bounds.height;
505
506        // Resolve the font to use THIS PAINT: prefer the system-wide override
507        // (set by the System window) so font changes propagate live; fall
508        // back to the per-instance font otherwise.  The same resolution runs
509        // in `layout()` so the two stages agree on metrics.
510        let font = self.active_font();
511        let size = self.active_font_size();
512
513        ctx.set_font(Arc::clone(&font));
514        ctx.set_font_size(size);
515        // If no explicit colour was set, follow the active theme — the
516        // dimmed secondary colour for hint labels, otherwise body text.
517        let color = self.color.unwrap_or_else(|| {
518            let v = ctx.visuals();
519            if self.dim {
520                v.text_dim
521            } else if self.strong {
522                strengthen(v.text_color)
523            } else {
524                v.text_color
525            }
526        });
527
528        let is_wrapped = self.wrap && !self.wrapped_lines.is_empty();
529
530        // Clip text rendering to the label's bounds.  `Label::layout`
531        // clamps its returned width to `available.width`, so a long
532        // label inside a narrow parent gets bounds narrower than the
533        // text's natural width.  The backbuffered path (grayscale cache)
534        // implicitly clips at the bitmap's edges; the direct-paint path
535        // (LCD mode) would otherwise draw glyphs past the bounds.  An
536        // explicit clip makes both modes behave identically — text
537        // never escapes the label's rect.
538        ctx.save();
539        ctx.clip_rect(0.0, 0.0, w, h);
540
541        // Labels always paint through `ctx.fill_text` — the backend
542        // decides LCD vs grayscale AA internally based on
543        // `font_settings::lcd_enabled()` and whether it can composite
544        // per-channel coverage.  No backbuffer, no LCD-specific logic
545        // lives here.  Label is just a widget that draws text.
546        ctx.set_fill_color(color);
547        if is_wrapped {
548            let line_h = size * 1.5;
549            let total_h = self.wrapped_lines.len() as f64 * line_h;
550            for (i, line) in self.wrapped_lines.iter().enumerate() {
551                if line.is_empty() {
552                    continue;
553                }
554                if let Some(m) = ctx.measure_text(line) {
555                    let line_center_y = total_h - (i as f64 + 0.5) * line_h;
556                    let ty = line_center_y - line_h * 0.5 + m.centered_baseline_y(line_h);
557                    let tx = match self.align {
558                        LabelAlign::Left => 0.0,
559                        LabelAlign::Center => (w - m.width) * 0.5,
560                        LabelAlign::Right => w - m.width,
561                    };
562                    ctx.fill_text(line, tx, ty);
563                }
564            }
565        } else if let Some(m) = ctx.measure_text(&self.text) {
566            let ty = m.centered_baseline_y(h);
567            let tx = match self.align {
568                LabelAlign::Left => 0.0,
569                LabelAlign::Center => (w - m.width) * 0.5,
570                LabelAlign::Right => w - m.width,
571            };
572            ctx.fill_text(&self.text, tx, ty);
573        }
574
575        ctx.restore();
576    }
577
578    fn margin(&self) -> Insets {
579        self.base.margin
580    }
581    fn widget_base(&self) -> Option<&WidgetBase> {
582        Some(&self.base)
583    }
584    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
585        Some(&mut self.base)
586    }
587    fn h_anchor(&self) -> HAnchor {
588        self.base.h_anchor
589    }
590    fn v_anchor(&self) -> VAnchor {
591        self.base.v_anchor
592    }
593    fn min_size(&self) -> Size {
594        self.base.min_size
595    }
596    fn max_size(&self) -> Size {
597        self.base.max_size
598    }
599
600    fn measure_min_height(&self, available_w: f64) -> f64 {
601        // Wrapped: count lines at the supplied width.  Non-wrapped:
602        // a single line tall.  Used by ancestor `Window::tight_content_fit`
603        // to compute a content-bound for height.
604        let font = self.active_font();
605        let size = self.active_font_size();
606        let line_h = size * 1.5;
607        if self.wrap && available_w > 0.0 {
608            let lines = wrap_text(&font, &self.text, size, available_w);
609            (lines.len().max(1) as f64) * line_h
610        } else {
611            line_h
612        }
613    }
614
615    fn on_event(&mut self, _: &Event) -> EventResult {
616        EventResult::Ignored
617    }
618
619    fn properties(&self) -> Vec<(&'static str, String)> {
620        vec![
621            ("text", self.text.clone()),
622            ("font_size", format!("{:.1}", self.font_size)),
623            ("align", format!("{:?}", self.align)),
624            (
625                "has_backbuffer",
626                if self.buffered { "true" } else { "false" }.to_string(),
627            ),
628        ]
629    }
630}