Skip to main content

agg_gui/
font_settings.rs

1//! System-wide font / text rendering settings.
2//!
3//! Mirrors the `theme::current_visuals` / `theme::set_visuals` pattern and
4//! the scrollbar-style globals (`current_scroll_style` / `set_scroll_style`).
5//! Widgets that care about rendering style (`Label`, `Button`, `TextField`,
6//! ...) should consult these at **layout/paint time** so changes made by
7//! the System window propagate without a widget-tree rebuild.
8//!
9//! # Convention
10//!
11//! Each setting has:
12//! - an **override** stored in a thread-local cell (`None` or `false` by default),
13//! - a getter (e.g. [`current_system_font`], [`lcd_enabled`]),
14//! - a setter (e.g. [`set_system_font`], [`set_lcd_enabled`]).
15//!
16//! Widgets pick between the global override and their own per-instance value
17//! — analogous to how a `ScrollView` takes the global scroll style unless
18//! the caller wired an explicit one.
19
20use std::cell::RefCell;
21use std::sync::atomic::{AtomicU64, Ordering};
22use std::sync::Arc;
23
24use crate::text::Font;
25
26// ---------------------------------------------------------------------------
27// Typography epoch
28// ---------------------------------------------------------------------------
29//
30// Bumped every time any typography-style global (font, size scale,
31// LCD, hinting, gamma, width, interval, faux weight/italic, primary
32// weight) changes.  Backbuffered widgets (`Label`, `TextField`, …)
33// compare this epoch against the one they rasterised at and
34// self-invalidate on mismatch — same trick we use for theme epoch.
35// Without this, dragging a slider in the System window would leave
36// pre-existing `Label` caches showing the old style until something
37// else invalidated them.
38
39static TYPOGRAPHY_EPOCH: AtomicU64 = AtomicU64::new(1);
40
41/// Current typography epoch.  Widget render paths read this each frame.
42pub fn current_typography_epoch() -> u64 {
43    TYPOGRAPHY_EPOCH.load(Ordering::Relaxed)
44}
45
46/// Internal helper: called by every setter in this module after it
47/// writes.  Keeps the epoch in lock-step with the globals.
48fn bump_typography_epoch() {
49    TYPOGRAPHY_EPOCH.fetch_add(1, Ordering::Relaxed);
50}
51
52// ---------------------------------------------------------------------------
53// Thread-local storage
54// ---------------------------------------------------------------------------
55
56thread_local! {
57    /// System-wide font override.  `None` means "widgets keep whatever font
58    /// they were constructed with".
59    static SYSTEM_FONT:     RefCell<Option<Arc<Font>>> = RefCell::new(None);
60    /// System-wide font size multiplier — applied to every widget's own
61    /// `font_size` at paint/layout time.  `1.0` = unchanged.  Acts like
62    /// egui's `pixels_per_point` for typography: shrink or enlarge ALL
63    /// text while preserving the relative hierarchy (body stays smaller
64    /// than headings, etc.).
65    static FONT_SIZE_SCALE: RefCell<f64>  = RefCell::new(1.0);
66    /// System-wide LCD-subpixel override.  When `Some(true|false)`, text-
67    /// rendering widgets honour it directly.  When `None` (the default),
68    /// [`lcd_enabled`] derives the effective value from
69    /// [`crate::device_scale`]: LCD is enabled at standard DPI (scale ≤
70    /// 1.25) and disabled at HiDPI, because LCD subpixel rendering only
71    /// pays off when subpixels are roughly the size of a glyph stem; at
72    /// 2× scale the AA halo is already wide enough that grayscale wins on
73    /// chroma fringing while looking identical otherwise.  An explicit
74    /// [`set_lcd_enabled`] overrides the auto-derivation; apps that just
75    /// want the default should never need to call it.
76    static LCD_ENABLED:     RefCell<Option<bool>> = const { RefCell::new(None) };
77    /// System-wide hinting toggle — forwarded to the font engine when the
78    /// engine supports it.  `ttf-parser` does NOT run a hinting interpreter,
79    /// so what we do is **Y-axis-only baseline hinting**: snap the glyph
80    /// origin's Y coordinate to the pixel grid before rasterisation,
81    /// matching the `(y + 0.5).floor()` convention from the AGG C++
82    /// `truetype_test_02_win` demo.  This preserves horizontal subpixel
83    /// positioning (critical for LCD) while giving sharper vertical
84    /// metrics — the pragmatic compromise used by the agg-rust reference.
85    static HINTING_ENABLED: RefCell<bool> = RefCell::new(false);
86
87    // ── Typography-style parameters (driven by the System window's
88    // typography controls / Sample Text tab, and every text paint
89    // globally).  Ranges mirror the agg-rust `truetype_test` demo so
90    // numbers stay comparable against the reference implementation.
91
92    /// Gamma correction applied post-raster.  1.0 = off (linear output).
93    /// Range 0.5..=2.5.
94    static GAMMA:          RefCell<f64> = RefCell::new(1.0);
95    /// Horizontal glyph width scale.  1.0 = native widths.
96    /// Range 0.75..=1.25.
97    static WIDTH:          RefCell<f64> = RefCell::new(1.0);
98    /// Extra letter-spacing as a fraction of em.  0.0 = unchanged.
99    /// Range -0.2..=0.2.
100    static INTERVAL:       RefCell<f64> = RefCell::new(0.0);
101    /// Synthetic boldness via outline contour offset.
102    /// Range -1.0..=1.0; 0.0 = unchanged, positive = heavier, negative = lighter.
103    static FAUX_WEIGHT:    RefCell<f64> = RefCell::new(0.0);
104    /// Synthetic italic slant expressed as a horizontal-shear factor.
105    /// Range -1.0..=1.0; 0.0 = upright.
106    static FAUX_ITALIC:    RefCell<f64> = RefCell::new(0.0);
107    /// LCD primary-weight (the pixel coverage weight of the own-channel
108    /// vs the neighbouring channels in the 3-tap distribution LUT).
109    /// Range 0.0..=1.0; default 1/3 gives a neutral LUT.
110    static PRIMARY_WEIGHT: RefCell<f64> = RefCell::new(1.0 / 3.0);
111}
112
113// ---------------------------------------------------------------------------
114// Font
115// ---------------------------------------------------------------------------
116
117/// Current system font override, if set.  Widgets should prefer this over
118/// their own `self.font` when the override is `Some(_)` so user changes in
119/// the System window propagate live.
120pub fn current_system_font() -> Option<Arc<Font>> {
121    SYSTEM_FONT.with(|c| c.borrow().clone())
122}
123
124/// Replace the system font override.  Pass `None` to clear and fall back
125/// to per-widget fonts.
126pub fn set_system_font(font: Option<Arc<Font>>) {
127    SYSTEM_FONT.with(|c| *c.borrow_mut() = font);
128    bump_typography_epoch();
129}
130
131// ---------------------------------------------------------------------------
132// Font size scale
133// ---------------------------------------------------------------------------
134
135/// Current font size multiplier.  Widgets reading a `self.font_size`
136/// should consult this (via e.g. `Label::active_font_size`) so a single
137/// slider in the System window can grow or shrink all text uniformly.
138pub fn current_font_size_scale() -> f64 {
139    FONT_SIZE_SCALE.with(|c| *c.borrow())
140}
141
142/// Set the system font-size multiplier.  Clamped to a sensible range so
143/// typos or edge-case inputs can't hide every label or fry the layout.
144pub fn set_font_size_scale(scale: f64) {
145    let clamped = scale.clamp(0.5, 3.0);
146    FONT_SIZE_SCALE.with(|c| *c.borrow_mut() = clamped);
147    bump_typography_epoch();
148}
149
150// ---------------------------------------------------------------------------
151// LCD subpixel toggle
152// ---------------------------------------------------------------------------
153
154/// Whether widgets should rasterise text through the LCD subpixel path.
155///
156/// Whether widgets should rasterise text through the LCD subpixel path.
157///
158/// **Hard cap first:** LCD is NEVER used above standard density.  The gate
159/// keys on the *effective* scale ([`crate::ux_scale::effective_scale`] =
160/// device DPR × UX zoom), because LCD subpixel rendering only pays off when
161/// individual physical pixels are large enough to resolve the R/G/B
162/// sub-stripes — and "small pixels" come from a high UX zoom (mobile /
163/// accessibility) just as much as from a HiDPI panel.  Above `1.25×` it is
164/// pure overhead with no visible benefit, so we force the cheaper grayscale
165/// path *regardless of any explicit override*.  This also keeps
166/// CPU-backbuffered widgets (the menu bar) off the LCD blit at high scale,
167/// where they would otherwise shrink.
168///
169/// At standard density the explicit override set via [`set_lcd_enabled`]
170/// wins if present; otherwise LCD defaults on.  So platform shells generally
171/// don't need to call [`set_lcd_enabled`] at all.
172pub fn lcd_enabled() -> bool {
173    // Never LCD at high effective density — overrides included.
174    if crate::ux_scale::effective_scale() > 1.25 {
175        return false;
176    }
177    if let Some(explicit) = LCD_ENABLED.with(|c| *c.borrow()) {
178        return explicit;
179    }
180    true
181}
182
183/// Pin LCD subpixel rendering to a specific value, overriding the
184/// device-scale-derived default.  System-window toggles use this; apps
185/// that just want sensible default behaviour should not call it.
186pub fn set_lcd_enabled(on: bool) {
187    LCD_ENABLED.with(|c| *c.borrow_mut() = Some(on));
188    bump_typography_epoch();
189}
190
191/// Drop any explicit override and return to device-scale-derived auto.
192/// Counterpart to [`set_lcd_enabled`]; used by tests and by System-
193/// window "reset to default" affordances.
194pub fn clear_lcd_enabled_override() {
195    LCD_ENABLED.with(|c| *c.borrow_mut() = None);
196    bump_typography_epoch();
197}
198
199// ---------------------------------------------------------------------------
200// Hinting toggle
201// ---------------------------------------------------------------------------
202
203pub fn hinting_enabled() -> bool {
204    HINTING_ENABLED.with(|c| *c.borrow())
205}
206
207pub fn set_hinting_enabled(on: bool) {
208    HINTING_ENABLED.with(|c| *c.borrow_mut() = on);
209    bump_typography_epoch();
210}
211
212/// Snap a text baseline Y coordinate to the pixel grid **when the global
213/// hinting toggle is on**, otherwise return it unchanged.
214///
215/// This is the *line-level* companion to the per-glyph Y snap that
216/// [`crate::text::shape_text`] applies (`(y + 0.5).floor()`).  Multi-line
217/// editors (`TextArea`, `RichTextEdit`) compute each visual line's baseline
218/// from fractional scroll / alignment / metric math; snapping the line
219/// baseline up front keeps whole-line pitch uniform and lands stems on pixel
220/// rows so the editors render exactly as crisply as `Label` under the same
221/// System settings.  When hinting is off they intentionally degrade the same
222/// way `Label` does (fractional baseline, softer verticals).
223///
224/// Coordinates are **logical**; at device scale 1 that is also the physical
225/// pixel grid.  At higher scales LCD is disabled anyway and the LCD composite
226/// path does its own physical rounding, so gating on the logical grid here is
227/// the right granularity.
228pub fn snap_baseline_y(y: f64) -> f64 {
229    if hinting_enabled() {
230        (y + 0.5).floor()
231    } else {
232        y
233    }
234}
235
236// ---------------------------------------------------------------------------
237// Typography-style parameters
238// ---------------------------------------------------------------------------
239//
240// All six follow the same shape: an immutable thread-local, a getter,
241// and a clamping setter.  The clamp ranges mirror the agg-rust
242// `truetype_test` demo so results stay numerically comparable.  Callers
243// (the System window's typography controls / Sample Text tab) bind to
244// these via `Rc<Cell<f64>>` mirrors owned by `SystemCells`; the global
245// is the source-of-truth for rendering, the cell is the source-of-truth
246// for UI widgets and disk persistence.
247
248pub fn current_gamma() -> f64 {
249    GAMMA.with(|c| *c.borrow())
250}
251pub fn set_gamma(v: f64) {
252    let clamped = v.clamp(0.5, 2.5);
253    GAMMA.with(|c| *c.borrow_mut() = clamped);
254    bump_typography_epoch();
255}
256
257pub fn current_width() -> f64 {
258    WIDTH.with(|c| *c.borrow())
259}
260pub fn set_width(v: f64) {
261    let clamped = v.clamp(0.75, 1.25);
262    WIDTH.with(|c| *c.borrow_mut() = clamped);
263    bump_typography_epoch();
264}
265
266pub fn current_interval() -> f64 {
267    INTERVAL.with(|c| *c.borrow())
268}
269pub fn set_interval(v: f64) {
270    let clamped = v.clamp(-0.2, 0.2);
271    INTERVAL.with(|c| *c.borrow_mut() = clamped);
272    bump_typography_epoch();
273}
274
275pub fn current_faux_weight() -> f64 {
276    FAUX_WEIGHT.with(|c| *c.borrow())
277}
278pub fn set_faux_weight(v: f64) {
279    let clamped = v.clamp(-1.0, 1.0);
280    FAUX_WEIGHT.with(|c| *c.borrow_mut() = clamped);
281    bump_typography_epoch();
282}
283
284pub fn current_faux_italic() -> f64 {
285    FAUX_ITALIC.with(|c| *c.borrow())
286}
287pub fn set_faux_italic(v: f64) {
288    let clamped = v.clamp(-1.0, 1.0);
289    FAUX_ITALIC.with(|c| *c.borrow_mut() = clamped);
290    bump_typography_epoch();
291}
292
293pub fn current_primary_weight() -> f64 {
294    PRIMARY_WEIGHT.with(|c| *c.borrow())
295}
296pub fn set_primary_weight(v: f64) {
297    let clamped = v.clamp(0.0, 1.0);
298    PRIMARY_WEIGHT.with(|c| *c.borrow_mut() = clamped);
299    bump_typography_epoch();
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn test_lcd_flag_explicit_override_round_trips() {
308        // Reset to known state — thread-locals can leak across tests reusing
309        // the same worker thread.  Standard density so the high-scale cap in
310        // `lcd_enabled` doesn't mask the override under test.
311        crate::device_scale::set_device_scale(1.0);
312        crate::ux_scale::set_ux_scale(1.0);
313        set_lcd_enabled(false);
314        assert!(!lcd_enabled());
315        set_lcd_enabled(true);
316        assert!(lcd_enabled());
317        clear_lcd_enabled_override();
318    }
319
320    #[test]
321    fn test_lcd_flag_auto_derives_from_device_scale_when_no_override() {
322        use crate::device_scale::set_device_scale;
323        clear_lcd_enabled_override();
324        set_device_scale(1.0);
325        assert!(lcd_enabled(), "standard DPI should default to LCD on");
326        set_device_scale(2.0);
327        assert!(!lcd_enabled(), "HiDPI should default to LCD off");
328        // Restore to a sane state for sibling tests.
329        set_device_scale(1.0);
330    }
331
332    #[test]
333    fn test_lcd_auto_disabled_at_high_effective_scale_from_ux_zoom() {
334        // LCD subpixel rendering is pointless overhead once the on-screen
335        // pixel density is high — and "high density" can come from the UX
336        // zoom (mobile / accessibility) just as much as from the device DPR.
337        // A device at 1.0 DPR with ux_scale 1.7 renders everything at 1.7×;
338        // LCD must auto-disable there, exactly as it does at 1.7× DPR.
339        // Regression: the auto-derivation keyed on `device_scale` alone, so a
340        // ux-zoomed standard-DPI display kept LCD on, which routed the
341        // CPU-backbuffered menu bar through the LCD blit and rendered it tiny.
342        use crate::device_scale::set_device_scale;
343        use crate::ux_scale::set_ux_scale;
344        clear_lcd_enabled_override();
345        set_device_scale(1.0);
346        set_ux_scale(1.0);
347        assert!(
348            lcd_enabled(),
349            "standard DPI + no zoom should default to LCD on"
350        );
351        set_ux_scale(1.7);
352        assert!(
353            !lcd_enabled(),
354            "high effective scale via ux zoom should default to LCD off"
355        );
356        // The high-scale gate is a HARD CAP: it wins even over an explicit
357        // override, so "force LCD on" can't reintroduce the overhead (and the
358        // tiny-menu blit) at high density.
359        set_lcd_enabled(true);
360        assert!(
361            !lcd_enabled(),
362            "explicit LCD-on override must still be capped off at high effective scale"
363        );
364        // ...but at standard density the explicit override is honoured.
365        set_ux_scale(1.0);
366        assert!(
367            lcd_enabled(),
368            "override LCD-on must apply at standard density"
369        );
370        // Restore sane state for sibling tests.
371        clear_lcd_enabled_override();
372        set_ux_scale(1.0);
373        set_device_scale(1.0);
374    }
375
376    #[test]
377    fn test_hinting_flag_default_off() {
378        set_hinting_enabled(false);
379        assert!(!hinting_enabled());
380        set_hinting_enabled(true);
381        assert!(hinting_enabled());
382        set_hinting_enabled(false);
383    }
384
385    #[test]
386    fn test_snap_baseline_y_lands_on_integer_when_hinting_on() {
387        // With hinting on, a fractional baseline must snap onto the pixel grid
388        // (integer Y at scale 1) — the same treatment `text::shape_text` gives
389        // each glyph, applied at the line level for the multi-line editors.
390        set_hinting_enabled(true);
391        for &raw in &[0.0, 3.2, 3.5, 3.7, 12.49, 12.5, 100.99, -0.4] {
392            let snapped = snap_baseline_y(raw);
393            assert_eq!(
394                snapped.fract(),
395                0.0,
396                "snap_baseline_y({raw}) = {snapped} is not on the pixel grid"
397            );
398            assert_eq!(snapped, (raw + 0.5).floor());
399        }
400
401        // With hinting off, the baseline is passed through unchanged so the
402        // editors degrade exactly like `Label` (softer verticals, no snap).
403        set_hinting_enabled(false);
404        assert_eq!(snap_baseline_y(3.7), 3.7);
405        assert_eq!(snap_baseline_y(12.25), 12.25);
406
407        set_hinting_enabled(false);
408    }
409
410    #[test]
411    fn test_system_font_default_none() {
412        // Reset first — other tests may have set it.
413        set_system_font(None);
414        assert!(current_system_font().is_none());
415    }
416}