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