From d9652dce98bddda99659e1307cd7c096ece512fd Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Sun, 2 Aug 2026 22:50:19 +0200 Subject: [PATCH] =?UTF-8?q?accessibility=20text=20scale:=20global=20font?= =?UTF-8?q?=20multiplier=20synced=20to=20the=20desktop's=20text-scaling-fa?= =?UTF-8?q?ctor=20New=20set=5Ftext=5Fscale=20/=20text=5Fscale=20process=20?= =?UTF-8?q?global=20(clamped=20[0.5,=203.0])=20multiplied=20into=20every?= =?UTF-8?q?=20resolved=20font=20size=20=E2=80=94=20Canvas::resolve=5Ffont?= =?UTF-8?q?=20for=20explicit=20Lengths=20and=20the=20Physical=20branch=20o?= =?UTF-8?q?f=20font=5Fpx=20(the=20Fluid=20branch=20routes=20through=20reso?= =?UTF-8?q?lve=5Ffont)=20=E2=80=94=20so=20the=20whole=20tree's=20text=20fo?= =?UTF-8?q?llows=20the=20accessibility=20"large=20text"=20factor=20while?= =?UTF-8?q?=20geometry=20stays=20untouched,=20mirroring=20GNOME's=20text-s?= =?UTF-8?q?caling-factor=20semantics.=20Unit=20test=20covers=20fonts-scale?= =?UTF-8?q?-geometry-doesn't.=20The=20run=20loop=20keeps=20the=20factor=20?= =?UTF-8?q?synced=20on=20its=20own:=20a=20watcher=20thread=20(event=5Floop?= =?UTF-8?q?/text=5Fscale.rs)=20reads=20org.gnome.desktop.interface=20text-?= =?UTF-8?q?scaling-factor=20via=20gsettings=20get=20at=20startup=20and=20s?= =?UTF-8?q?treams=20external=20changes=20from=20gsettings=20monitor=20into?= =?UTF-8?q?=20a=20calloop=20channel;=20on=20a=20change=20the=20loop=20stor?= =?UTF-8?q?es=20the=20factor,=20invalidates=20the=20view=20caches=20and=20?= =?UTF-8?q?repaints=20main=20and=20overlays.=20Since=20fonts=20resolve=20a?= =?UTF-8?q?t=20paint=20time=20nothing=20else=20needs=20rebuilding.=20Every?= =?UTF-8?q?=20ltk=20app=20tracks=20the=20settings=20slider=20live=20with?= =?UTF-8?q?=20zero=20app-side=20wiring,=20the=20same=20way=20GTK=20apps=20?= =?UTF-8?q?follow=20the=20key;=20missing=20gsettings=20degrades=20silently?= =?UTF-8?q?=20to=20a=20fixed=201.0.=20Embedders=20driving=20core::UiSurfac?= =?UTF-8?q?e=20(forge)=20call=20set=5Ftext=5Fscale=20themselves=20?= =?UTF-8?q?=E2=80=94=20the=20multiplication=20only=20runs=20at=20widget=20?= =?UTF-8?q?font=20resolution,=20so=20raw=20Canvas::draw=5Ftext=20callers?= =?UTF-8?q?=20keep=20hand-computed=20sizes.=20architecture.md=20documents?= =?UTF-8?q?=20the=20multiplier=20in=20the=20font-space=20paragraph=20and?= =?UTF-8?q?=20CHANGELOG=20gains=20the=20entry.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + docs/architecture.md | 2 +- src/event_loop/mod.rs | 1 + src/event_loop/run.rs | 28 ++++++++++++ src/event_loop/text_scale.rs | 86 ++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/render/mod.rs | 22 +++++++-- src/types.rs | 19 ++++++++ 8 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 src/event_loop/text_scale.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e7a2141..393f8e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a - **`Viewport::local_viewport()`** — resolve the child's viewport-relative (`vw` / `vh` / `vmin`) and fluid `Length`s against the viewport's own rect instead of the root layout viewport the sub-canvas inherits. For fixed-size floating mini-UIs (a phone-shaped panel pinned to a corner of a desktop-wide surface) whose content is calibrated against the panel rect; scroll-like clips should keep the default inheritance. - **`ListItem::height( impl Into )` / `ListItem::font_size( impl Into )`** — override the theme row height (floored at the label's rendered height so text never clips) and the primary-label font size, mirroring the `Toggle` / `Radio` `height()` builders, so dense menus can trade the touch-target generosity for row density. +- **Accessibility text scale** — `set_text_scale` / `text_scale` global multiplier (clamped `[0.5, 3.0]`) applied to every resolved font size (`Canvas::resolve_font` and the stock-widget `font_px` path); geometry is untouched. The run loop reads `org.gnome.desktop.interface text-scaling-factor` at startup and follows external changes via a `gsettings monitor` watcher thread, repainting on change — every ltk app tracks the desktop's "large text" setting live with no app-side wiring (silently fixed at 1.0 when `gsettings` is missing). Embedders driving `core::UiSurface` call `set_text_scale` themselves. - **Per-canvas pixel density** — `Canvas::set_density` pins a canvas (and the sub-canvases derived from it) to its own density factor for `Length::dp` resolution, overriding the process `set_density` global; `Canvas::density` reads the effective value. New `Canvas::resolve_geom` / `Canvas::resolve_font` resolve an explicit `Length` in geometry / font space with the canvas' density — widgets now route caller-supplied lengths through them, so a `dp` override follows the canvas it draws on. The hook for surfaces on outputs whose DPI differs from the process-wide one (an overlay on a second monitor, an embedder with several `UiSurface`s). - **`Length::resolve_with_density`** — `Length::resolve` with an explicit density for `LengthBase::Dp`, instead of the process `density()`. diff --git a/docs/architecture.md b/docs/architecture.md index 4eb28f0..8386cd3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -188,7 +188,7 @@ part of the `ltk::runtime` layer. ## Responsive sizing -Every size in a widget tree is a `Length`, resolved to concrete pixels at layout time against the surface. Two coordinate spaces matter. **Geometry** (widths, heights, paddings, gaps, box sizes) is computed in *physical* pixels — the layout root rect is `pw × ph` — so geometry `Length` values resolve against `Canvas::viewport_layout()` (physical). **Font sizes** are the exception: they resolve against `Canvas::viewport_logical()` (physical ÷ `dpi_scale`) and are multiplied by `dpi_scale` again at raster time, so a `vmin` font ends up as a fraction of the *physical* short side regardless of `dpi_scale`. Keep this split in mind when adding a widget: resolve a geometry constant with `Canvas::geom_px(n)` and a font constant with `Canvas::font_px(n)` — the two helpers hide the difference. +Every size in a widget tree is a `Length`, resolved to concrete pixels at layout time against the surface. Two coordinate spaces matter. **Geometry** (widths, heights, paddings, gaps, box sizes) is computed in *physical* pixels — the layout root rect is `pw × ph` — so geometry `Length` values resolve against `Canvas::viewport_layout()` (physical). **Font sizes** are the exception: they resolve against `Canvas::viewport_logical()` (physical ÷ `dpi_scale`) and are multiplied by `dpi_scale` again at raster time, so a `vmin` font ends up as a fraction of the *physical* short side regardless of `dpi_scale`. Keep this split in mind when adding a widget: resolve a geometry constant with `Canvas::geom_px(n)` and a font constant with `Canvas::font_px(n)` — the two helpers hide the difference. Font resolution additionally multiplies by the global accessibility `text_scale()` — the run loop keeps it synced to the desktop's `org.gnome.desktop.interface text-scaling-factor` GSettings key (initial `gsettings get` plus a `gsettings monitor` watcher thread) and repaints on change, so every ltk app follows the "large text" setting live; geometry never scales with it. `ltk` offers two adaptation strategies, and both live in the same `Length` type so an app can mix them per value: diff --git a/src/event_loop/mod.rs b/src/event_loop/mod.rs index 26e8f92..78ee32c 100644 --- a/src/event_loop/mod.rs +++ b/src/event_loop/mod.rs @@ -14,6 +14,7 @@ pub( crate ) mod repeat; pub( crate ) mod subsurface; pub( crate ) mod surface; pub( crate ) mod text_editing; +pub( crate ) mod text_scale; pub( crate ) mod tooltip; pub( crate ) mod error; diff --git a/src/event_loop/run.rs b/src/event_loop/run.rs index 40bb30c..ec9d2c7 100644 --- a/src/event_loop/run.rs +++ b/src/event_loop/run.rs @@ -357,6 +357,34 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> data.app.set_channel_sender( sender ); } + // Follow the desktop's accessibility text scale: a watcher thread reads + // `org.gnome.desktop.interface text-scaling-factor` and streams changes + // here; applying the factor and repainting is all it takes because font + // resolution multiplies by `text_scale()` at paint time. + { + let ( tx, channel ) = calloop::channel::channel::(); + event_loop.handle() + .insert_source( + channel, + |event, _, data: &mut AppData| + { + if let calloop::channel::Event::Msg( factor ) = event + { + if ( factor - crate::types::text_scale() ).abs() < f32::EPSILON { return; } + crate::types::set_text_scale( factor ); + data.dirty_caches(); + data.main.request_redraw(); + for ss in data.overlays.values_mut() + { + ss.request_redraw(); + } + } + }, + ) + .map_err( |e| RunError::EventLoop( format!( "text-scale insert_source: {e:?}" ) ) )?; + super::text_scale::spawn_watcher( tx ); + } + // Register a periodic timer if the app wants one (e.g. clock tick every second). // The timer fires independently of Wayland events, waking the event loop on schedule. if let Some( dur ) = data.app.poll_interval() diff --git a/src/event_loop/text_scale.rs b/src/event_loop/text_scale.rs new file mode 100644 index 0000000..3a1830c --- /dev/null +++ b/src/event_loop/text_scale.rs @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Follow the desktop's accessibility text scale. +//! +//! Reads `org.gnome.desktop.interface text-scaling-factor` once at +//! startup and then follows external changes through `gsettings +//! monitor`, sending each value to the run loop, which applies it via +//! [`crate::set_text_scale`] and repaints. GTK apps track this key on +//! their own; this module gives every ltk app the same behaviour with +//! no app-side wiring. Degrades silently to a fixed 1.0 when +//! `gsettings` is not installed. + +use std::io::{ BufRead, BufReader }; +use std::process::{ Command, Stdio }; + +const SCHEMA: &str = "org.gnome.desktop.interface"; +const KEY: &str = "text-scaling-factor"; + +/// Spawn the watcher thread. `tx` delivers each observed factor to the +/// run loop; the initial `gsettings get` value is sent first. +pub( super ) fn spawn_watcher( tx: calloop::channel::Sender ) +{ + let _ = std::thread::Builder::new() + .name( "ltk-text-scale".into() ) + .spawn( move || + { + if let Some( v ) = read_current() + { + let _ = tx.send( v ); + } + + let Ok( mut child ) = Command::new( "gsettings" ) + .args( [ "monitor", SCHEMA, KEY ] ) + .stdout( Stdio::piped() ) + .stderr( Stdio::null() ) + .spawn() + else { return }; + let Some( stdout ) = child.stdout.take() else { return }; + + // Each line has the shape `text-scaling-factor: 1.25`. + for line in BufReader::new( stdout ).lines().map_while( Result::ok ) + { + if let Some( v ) = parse_factor( &line ) + { + if tx.send( v ).is_err() { break; } + } + } + let _ = child.kill(); + } ); +} + +fn read_current() -> Option +{ + let out = Command::new( "gsettings" ) + .args( [ "get", SCHEMA, KEY ] ) + .stderr( Stdio::null() ) + .output() + .ok()?; + parse_factor( std::str::from_utf8( &out.stdout ).ok()? ) +} + +fn parse_factor( s: &str ) -> Option +{ + s.rsplit( [ ' ', ':' ] ) + .next()? + .trim() + .parse::() + .ok() + .filter( |v| *v > 0.0 ) +} + +#[ cfg( test ) ] +mod tests +{ + use super::parse_factor; + + #[ test ] + fn parses_monitor_line_and_bare_value() + { + assert_eq!( parse_factor( "text-scaling-factor: 1.25" ), Some( 1.25 ) ); + assert_eq!( parse_factor( "1.0\n" ), Some( 1.0 ) ); + assert_eq!( parse_factor( "" ), None ); + assert_eq!( parse_factor( "text-scaling-factor: nope" ), None ); + } +} diff --git a/src/lib.rs b/src/lib.rs index f564197..f1e954c 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -341,6 +341,7 @@ pub use types::{ Color, Corners, CursorShape, Length, LengthBase, PathCmd, Point pub use types::{ WidgetScaling, FLUID_MIN, FLUID_MAX }; pub use types::{ fluid_reference, set_fluid_reference }; pub use types::{ density, set_density }; +pub use types::{ set_text_scale, text_scale }; pub use types::{ widget_scaling, set_widget_scaling }; pub use types::{ Orientation, orientation, viewport_size, set_viewport_size }; pub use widget::{ Element, button, icon_button, text_edit, image as img_widget, text, container }; diff --git a/src/render/mod.rs b/src/render/mod.rs index 1cbdcf4..41ab104 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -322,12 +322,14 @@ impl Canvas } /// Resolve an explicit [`Length`] in **font** space: against - /// [`Self::viewport_logical`], with this canvas' [`Self::density`]. - /// Counterpart of [`Self::resolve_geom`] for font sizes, which are - /// handed to the raster path pre-`dpi_scale`. + /// [`Self::viewport_logical`], with this canvas' [`Self::density`], + /// times the global accessibility [`crate::text_scale`]. Counterpart + /// of [`Self::resolve_geom`] for font sizes, which are handed to the + /// raster path pre-`dpi_scale`. pub fn resolve_font( &self, l: Length ) -> f32 { l.resolve_with_density( self.viewport_logical(), Length::EM_BASE_DEFAULT, self.density() ) + * crate::types::text_scale() } /// Resolve a stock-widget **geometry** design pixel (height, padding, @@ -364,7 +366,7 @@ impl Canvas { let scale = self.dpi_scale(); let scale = if scale > 0.0 { scale } else { 1.0 }; - design_px * self.density() / scale + design_px * self.density() / scale * crate::types::text_scale() } } } @@ -990,6 +992,18 @@ mod viewport_tests assert_eq!( sub.density(), 3.0 ); } + #[ test ] + fn text_scale_multiplies_fonts_not_geometry() + { + let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() ); + + let c = Canvas::new( 400, 400 ); + crate::types::set_text_scale( 1.5 ); + assert_eq!( c.resolve_font( Length::px( 10.0 ) ), 15.0 ); + assert_eq!( c.resolve_geom( Length::px( 10.0 ) ), 10.0 ); + crate::types::set_text_scale( 1.0 ); + } + #[ test ] fn sub_canvas_local_layout_viewport_overrides_inheritance() { diff --git a/src/types.rs b/src/types.rs index b9cef78..03fcb49 100644 --- a/src/types.rs +++ b/src/types.rs @@ -710,6 +710,25 @@ pub fn fluid_reference() -> f32 f32::from_bits( FLUID_REFERENCE_BITS.load( Ordering::Relaxed ) ) } +static TEXT_SCALE_BITS: AtomicU32 = AtomicU32::new( 1.0_f32.to_bits() ); + +/// Set the global text scale multiplier applied to every resolved font +/// size (the accessibility "large text" factor). Clamped to `[0.5, 3.0]`. +/// The run loop keeps it synced to the desktop's +/// `org.gnome.desktop.interface text-scaling-factor` GSettings key and +/// repaints on change, so apps normally never call this themselves; +/// embedders driving [`crate::core::UiSurface`] directly do. +pub fn set_text_scale( s: f32 ) +{ + TEXT_SCALE_BITS.store( s.clamp( 0.5, 3.0 ).to_bits(), Ordering::Relaxed ); +} + +/// Current text scale multiplier. Default `1.0`. +pub fn text_scale() -> f32 +{ + f32::from_bits( TEXT_SCALE_BITS.load( Ordering::Relaxed ) ) +} + static DENSITY_BITS: AtomicU32 = AtomicU32::new( 1.0_f32.to_bits() ); /// Set the process-wide pixel density used by [`Length::dp`] (the