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::event::{Event, EventResult};
26use crate::geometry::{Point, Rect, Size};
27use crate::draw_ctx::DrawCtx;
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 `ctx.visuals().text_color` at paint time.
94    /// `Some(c)` → explicit override (e.g. accent-coloured or dimmed text).
95    color: Option<Color>,
96    align: LabelAlign,
97    /// When `true` (the default), this Label owns a CPU backbuffer
98    /// that's re-rasterised on dirty and blitted every frame.  Set to
99    /// `false` only for text that changes every frame (e.g. live
100    /// counters) where caching adds overhead with no benefit — those
101    /// go through `ctx.fill_text` direct every paint.
102    pub buffered: bool,
103    /// Per-widget CPU bitmap cache.  Populated by `paint_subtree` when
104    /// `buffered = true`; invalidated by Label's setters (text, color,
105    /// align, etc.) so the next paint re-rasterises.
106    cache: crate::widget::BackbufferCache,
107    /// When `true`, long lines are broken at word boundaries to fit
108    /// `available.width`.  The label height expands to fit all lines.
109    /// Disabled by default; enable with `.with_wrap(true)`.
110    wrap: bool,
111    /// When `true`, this Label ignores the system-wide font override
112    /// (`font_settings::current_system_font`) and always renders with
113    /// the specific `self.font` passed to `Label::new`.  Used by font
114    /// preview widgets (ComboBox item labels in the System window's
115    /// font selector) where each entry must render in its OWN face
116    /// regardless of the current global font choice.
117    ignore_system_font: bool,
118    /// Per-instance LCD preference: `Some(true)` always LCD, `Some(false)`
119    /// always grayscale, `None` defers to the global
120    /// `font_settings::lcd_enabled()`.  Exposed on every widget via
121    /// `Widget::lcd_preference`; Label is the only widget that reads it
122    /// today.
123    lcd_pref: Option<bool>,
124
125    // ── Layout measurement cache ──────────────────────────────────────────────
126    /// Cached text advance width from last `measure_advance()` call.
127    /// Avoids calling `rustybuzz::shape()` every frame — only re-measures
128    /// when `text` or `font_size` changes.
129    layout_text: String,
130    layout_font_size: f64,
131    layout_width: f64,
132    /// Pointer identity of the [`Font`] used for the last measurement.  If
133    /// the system-wide font override (see
134    /// [`font_settings::current_system_font`](crate::font_settings::current_system_font))
135    /// is swapped, pointer identity changes and we re-measure to pick up
136    /// the new font's glyph metrics.
137    layout_font_ptr: *const Font,
138    /// Width used for the last word-wrap computation.
139    wrap_at_width: f64,
140    /// Lines produced by the last word-wrap computation.
141    wrapped_lines: Vec<String>,
142
143}
144
145impl Label {
146    pub fn new(text: impl Into<String>, font: Arc<Font>) -> Self {
147        Self {
148            bounds: Rect::default(),
149            children: Vec::new(),
150            base: WidgetBase::new(),
151            text: text.into(),
152            font,
153            font_size: 14.0,
154            color: None, // resolved from ctx.visuals() at paint time
155            align: LabelAlign::Left,
156            // Default: backbuffer only when grayscale.  Rationale:
157            //   - Grayscale on GL direct-to-surface goes through
158            //     tessellated glyph outlines, which are visibly thinner
159            //     than AGG's subpixel-accurate scanline coverage.
160            //     Routing grayscale through a software backbuffer gives
161            //     AGG-quality rasterisation blitted as a texture.
162            //   - LCD on GL direct-to-surface uses dual-source blend on
163            //     the cached LCD mask — identical quality to AGG.
164            //     Adding a backbuffer here would force the sub-ctx into
165            //     `Rgba` mode (Label has no opaque bg for `LcdCoverage`)
166            //     and lose the subpixel result.
167            // `buffered` stores the user's opt-out; the actual decision
168            // happens in `backbuffer_cache_mut` based on the global
169            // LCD flag.
170            buffered: true,
171            cache: crate::widget::BackbufferCache::new(),
172            wrap: false,
173            ignore_system_font: false,
174            lcd_pref: None,
175            layout_text: String::new(),
176            layout_font_size: 0.0,
177            layout_width: 0.0,
178            layout_font_ptr: std::ptr::null(),
179            wrap_at_width: -1.0,
180            wrapped_lines: Vec::new(),
181        }
182    }
183
184    // ── builder methods ───────────────────────────────────────────────────────
185
186    pub fn with_font_size(mut self, size: f64) -> Self { self.font_size = size; self }
187    /// Override the label colour.  Pass an explicit `Color` to always use that
188    /// colour regardless of the active theme.  Omit this call to follow the
189    /// theme's `text_color` automatically.
190    pub fn with_color(mut self, color: Color) -> Self { self.color = Some(color); self }
191    pub fn with_align(mut self, align: LabelAlign) -> Self { self.align = align; self }
192    pub fn with_has_backbuffer(mut self, v: bool) -> Self { self.buffered = v; self }
193    /// Enable or disable word-wrapping.  When `true`, long lines are broken at
194    /// word boundaries to fit the available width; the label height expands to
195    /// accommodate all lines.  Newlines in the text are always honoured.
196    pub fn with_wrap(mut self, wrap: bool) -> Self { self.wrap = wrap; self }
197
198    /// Opt OUT of the system-wide font override for this Label.  The
199    /// Label will render with `self.font` (passed to `Label::new`)
200    /// regardless of what `font_settings::set_system_font` is pointing
201    /// at.  Useful for font-preview UI — each entry in a font picker
202    /// dropdown needs its OWN face, not the currently selected one.
203    /// Pin this label's LCD setting: `Some(true)` always LCD, `Some(false)`
204    /// always grayscale, `None` (default) defers to the global toggle.
205    pub fn with_lcd(mut self, pref: Option<bool>) -> Self {
206        self.lcd_pref = pref; self
207    }
208
209    pub fn with_ignore_system_font(mut self, ignore: bool) -> Self {
210        self.ignore_system_font = ignore;
211        self
212    }
213
214    pub fn with_margin(mut self, m: Insets)    -> Self { self.base.margin   = m; self }
215    pub fn with_h_anchor(mut self, h: HAnchor) -> Self { self.base.h_anchor = h; self }
216    pub fn with_v_anchor(mut self, v: VAnchor) -> Self { self.base.v_anchor = v; self }
217    pub fn with_min_size(mut self, s: Size)    -> Self { self.base.min_size = s; self }
218    pub fn with_max_size(mut self, s: Size)    -> Self { self.base.max_size = s; self }
219
220    // ── getter methods ────────────────────────────────────────────────────────
221
222    /// Return the current label text as a `&str`.
223    pub fn text_str(&self) -> &str { &self.text }
224
225    /// Resolve the font used for THIS layout/paint.  Prefers the system-wide
226    /// font override (set by the System window / `font_settings::set_system_font`)
227    /// so swapping the system font live flows through every widget; falls
228    /// back to the per-instance font otherwise.  Scrollbar-style pattern.
229    fn active_font(&self) -> Arc<Font> {
230        if self.ignore_system_font {
231            Arc::clone(&self.font)
232        } else {
233            crate::font_settings::current_system_font()
234                .unwrap_or_else(|| Arc::clone(&self.font))
235        }
236    }
237
238    /// Per-instance font size multiplied by the system-wide
239    /// [`font_settings::current_font_size_scale`].  Label's font-preview
240    /// UI (combo-box items flagged `ignore_system_font`) ALSO ignores
241    /// the scale — a font picker must show every entry at the same
242    /// reference size or comparing faces becomes useless.
243    fn active_font_size(&self) -> f64 {
244        if self.ignore_system_font {
245            self.font_size
246        } else {
247            self.font_size * crate::font_settings::current_font_size_scale()
248        }
249    }
250
251    // ── setter methods (for post-construction mutation) ───────────────────────
252
253    pub fn set_font_size(&mut self, size: f64) {
254        if (self.font_size - size).abs() > 1e-9 {
255            self.font_size = size;
256            self.cache.invalidate();
257        }
258    }
259
260    pub fn set_text(&mut self, text: impl Into<String>) {
261        let text = text.into();
262        if text != self.text {
263            self.text = text;
264            self.cache.invalidate();
265        }
266    }
267    pub fn set_color(&mut self, color: Color) {
268        if self.color != Some(color) {
269            self.color = Some(color);
270            self.cache.invalidate();
271        }
272    }
273    pub fn clear_color(&mut self) {
274        if self.color.is_some() {
275            self.color = None;
276            self.cache.invalidate();
277        }
278    }
279    pub fn set_align(&mut self, align: LabelAlign) {
280        if self.align != align {
281            self.align = align;
282            self.cache.invalidate();
283        }
284    }
285}
286
287impl Widget for Label {
288    fn type_name(&self) -> &'static str { "Label" }
289    fn bounds(&self) -> Rect { self.bounds }
290    fn set_bounds(&mut self, b: Rect) {
291        // Only invalidate on SIZE change — position doesn't affect
292        // cached bitmap (painted at local origin, blitted at parent's
293        // choice of translation).  Framework also invalidates via
294        // `cache.width != w || cache.height != h` in
295        // `paint_subtree_backbuffered`, so this is defence in depth.
296        if self.bounds.width != b.width || self.bounds.height != b.height {
297            self.cache.invalidate();
298        }
299        self.bounds = b;
300    }
301    fn children(&self) -> &[Box<dyn Widget>] { &self.children }
302    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> { &mut self.children }
303
304    fn lcd_preference(&self) -> Option<bool> { self.lcd_pref }
305
306    fn backbuffer_cache_mut(&mut self) -> Option<&mut crate::widget::BackbufferCache> {
307        // Cache always when `buffered`.  Mode is chosen by
308        // `backbuffer_mode` below — LCD on → per-channel LcdCoverage
309        // buffer, LCD off → Rgba buffer.  Per-channel alpha means
310        // unpainted pixels stay `alpha = 0` and blit leaves parent
311        // unchanged there, so no scroll-stale cache problem (that
312        // was a dead end from the seed-from-parent approach we ripped
313        // out).
314        if self.buffered { Some(&mut self.cache) } else { None }
315    }
316
317    fn backbuffer_mode(&self) -> crate::widget::BackbufferMode {
318        // Dispatching on the global LCD flag means toggling the
319        // setting automatically rebuilds every cached label in the
320        // right format — `paint_subtree_backbuffered` detects the
321        // mode flip via `cache.lcd_alpha.is_some()` vs the requested
322        // mode and forces a re-raster.
323        if crate::font_settings::lcd_enabled() {
324            crate::widget::BackbufferMode::LcdCoverage
325        } else {
326            crate::widget::BackbufferMode::Rgba
327        }
328    }
329
330    /// Labels are never independently hittable.  This lets their interactive
331    /// parent (e.g., Button) retain full hit-test and focus ownership even
332    /// when the label fills the parent's entire bounds.
333    fn hit_test(&self, _: Point) -> bool { false }
334
335    fn layout(&mut self, available: Size) -> Size {
336        // Resolve the effective font + size ONCE per layout so this call
337        // and the paint that follows agree on glyph metrics even if the
338        // system scale is mid-transition.
339        let font   = self.active_font();
340        let size   = self.active_font_size();
341        let line_h = size * 1.5;
342
343        // Drop the pre-rasterized bitmap the moment we notice a font or size
344        // swap — unconditionally, before any other branching.  Without this
345        // a buffered Label (the default) keeps blitting glyphs drawn with
346        // the previous typeface / point size until a bounds change or a
347        // text edit happens to invalidate the cache.  DragValue hits this
348        // hardest: its `value_label` often measures the same width for two
349        // different fonts ("14.0" in Arial vs the default is identical
350        // within a pixel), so the size-based invalidation in `set_bounds`
351        // never fires and the stale bitmap lingers until the user hovers
352        // (which triggers some other layout-affecting update).
353        let font_changed = Arc::as_ptr(&font) != self.layout_font_ptr;
354        let size_changed = (self.layout_font_size - size).abs() > 0.01;
355        if font_changed || size_changed {
356            self.cache.invalidate();
357        }
358
359        if self.wrap && available.width > 0.0 {
360            let text_changed  = self.layout_text != self.text || size_changed;
361            let width_changed = (self.wrap_at_width - available.width).abs() > 1.0;
362            if text_changed || width_changed || font_changed {
363                self.wrapped_lines = wrap_text(&font, &self.text, size, available.width);
364                self.wrap_at_width    = available.width;
365                self.layout_text      = self.text.clone();
366                self.layout_font_size = size;
367                self.layout_font_ptr  = Arc::as_ptr(&font);
368                // Text changes also need a bitmap rebuild.
369                if text_changed { self.cache.invalidate(); }
370            }
371            let total_h = self.wrapped_lines.len() as f64 * line_h;
372            Size::new(available.width, total_h)
373        } else {
374            // Single-line path: tight bounds matching rendered text width.
375            if self.layout_text != self.text || size_changed || font_changed {
376                let metrics =
377                    crate::text::measure_text_metrics(&font, &self.text, size);
378                self.layout_width     = metrics.width;
379                self.layout_text      = self.text.clone();
380                self.layout_font_size = size;
381                self.layout_font_ptr  = Arc::as_ptr(&font);
382            }
383            Size::new(self.layout_width.min(available.width), line_h)
384        }
385    }
386
387    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
388        let w = self.bounds.width;
389        let h = self.bounds.height;
390
391        // Resolve the font to use THIS PAINT: prefer the system-wide override
392        // (set by the System window) so font changes propagate live; fall
393        // back to the per-instance font otherwise.  The same resolution runs
394        // in `layout()` so the two stages agree on metrics.
395        let font = self.active_font();
396        let size = self.active_font_size();
397
398        ctx.set_font(Arc::clone(&font));
399        ctx.set_font_size(size);
400        // If no explicit colour was set, follow the active theme.
401        let color = self.color.unwrap_or_else(|| ctx.visuals().text_color);
402
403        let is_wrapped = self.wrap && !self.wrapped_lines.is_empty();
404
405        // Clip text rendering to the label's bounds.  `Label::layout`
406        // clamps its returned width to `available.width`, so a long
407        // label inside a narrow parent gets bounds narrower than the
408        // text's natural width.  The backbuffered path (grayscale cache)
409        // implicitly clips at the bitmap's edges; the direct-paint path
410        // (LCD mode) would otherwise draw glyphs past the bounds.  An
411        // explicit clip makes both modes behave identically — text
412        // never escapes the label's rect.
413        ctx.save();
414        ctx.clip_rect(0.0, 0.0, w, h);
415
416        // Labels always paint through `ctx.fill_text` — the backend
417        // decides LCD vs grayscale AA internally based on
418        // `font_settings::lcd_enabled()` and whether it can composite
419        // per-channel coverage.  No backbuffer, no LCD-specific logic
420        // lives here.  Label is just a widget that draws text.
421        ctx.set_fill_color(color);
422        if is_wrapped {
423            let line_h  = size * 1.5;
424            let total_h = self.wrapped_lines.len() as f64 * line_h;
425            for (i, line) in self.wrapped_lines.iter().enumerate() {
426                if line.is_empty() { continue; }
427                if let Some(m) = ctx.measure_text(line) {
428                    let line_center_y = total_h - (i as f64 + 0.5) * line_h;
429                    let ty = line_center_y - (m.ascent - m.descent) * 0.5;
430                    let tx = match self.align {
431                        LabelAlign::Left   => 0.0,
432                        LabelAlign::Center => (w - m.width) * 0.5,
433                        LabelAlign::Right  => w - m.width,
434                    };
435                    ctx.fill_text(line, tx, ty);
436                }
437            }
438        } else if let Some(m) = ctx.measure_text(&self.text) {
439            let ty = h * 0.5 - (m.ascent - m.descent) * 0.5;
440            let tx = match self.align {
441                LabelAlign::Left   => 0.0,
442                LabelAlign::Center => (w - m.width) * 0.5,
443                LabelAlign::Right  => w - m.width,
444            };
445            ctx.fill_text(&self.text, tx, ty);
446        }
447
448        ctx.restore();
449    }
450
451    fn margin(&self)   -> Insets  { self.base.margin }
452    fn h_anchor(&self) -> HAnchor { self.base.h_anchor }
453    fn v_anchor(&self) -> VAnchor { self.base.v_anchor }
454    fn min_size(&self) -> Size    { self.base.min_size }
455    fn max_size(&self) -> Size    { self.base.max_size }
456
457    fn on_event(&mut self, _: &Event) -> EventResult { EventResult::Ignored }
458
459    fn properties(&self) -> Vec<(&'static str, String)> {
460        vec![
461            ("text",           self.text.clone()),
462            ("font_size",      format!("{:.1}", self.font_size)),
463            ("align",          format!("{:?}", self.align)),
464            ("has_backbuffer", if self.buffered { "true" } else { "false" }.to_string()),
465        ]
466    }
467}