accessibility text scale: global font multiplier synced to the desktop's text-scaling-factor
New set_text_scale / text_scale process global (clamped [0.5, 3.0]) multiplied into every resolved font size — Canvas::resolve_font for explicit Lengths and the Physical branch of font_px (the Fluid branch routes through resolve_font) — so the whole tree's text follows the accessibility "large text" factor while geometry stays untouched, mirroring GNOME's text-scaling-factor semantics. Unit test covers fonts-scale-geometry-doesn't. The run loop keeps the factor synced on its own: a watcher thread (event_loop/text_scale.rs) reads org.gnome.desktop.interface text-scaling-factor via gsettings get at startup and streams external changes from gsettings monitor into a calloop channel; on a change the loop stores the factor, invalidates the view caches and repaints main and overlays. Since fonts resolve at paint time nothing else needs rebuilding. Every ltk app tracks the settings slider live with zero app-side wiring, the same way GTK apps follow the key; missing gsettings degrades silently to a fixed 1.0. Embedders driving core::UiSurface (forge) call set_text_scale themselves — the multiplication only runs at widget font resolution, so raw Canvas::draw_text callers keep hand-computed sizes. architecture.md documents the multiplier in the font-space paragraph and CHANGELOG gains the entry.
This commit is contained in:
@@ -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<Length> )` / `ListItem::font_size( impl Into<Length> )`** — 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()`.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -357,6 +357,34 @@ pub( crate ) fn try_run<A: App>( 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::<f32>();
|
||||
event_loop.handle()
|
||||
.insert_source(
|
||||
channel,
|
||||
|event, _, data: &mut AppData<A>|
|
||||
{
|
||||
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()
|
||||
|
||||
86
src/event_loop/text_scale.rs
Normal file
86
src/event_loop/text_scale.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! 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<f32> )
|
||||
{
|
||||
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<f32>
|
||||
{
|
||||
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<f32>
|
||||
{
|
||||
s.rsplit( [ ' ', ':' ] )
|
||||
.next()?
|
||||
.trim()
|
||||
.parse::<f32>()
|
||||
.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 );
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
19
src/types.rs
19
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
|
||||
|
||||
Reference in New Issue
Block a user