responsive fluid/physical scaling, widget-API stabilization, and perf guardrails
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

Responsive scaling. ltk now offers two first-class ways to size a UI so it adapts across screens, chosen per process via `WidgetScaling { Fluid, Physical }` (`set_widget_scaling` / `widget_scaling`, default `Fluid`). Fluid sizing (`Length::fluid( px )`) makes a design pixel a proportion of the surface's smaller side, calibrated against a reference width (`set_fluid_reference` / `fluid_reference`, 412 px default) and bounded by `FLUID_MIN` / `FLUID_MAX`; physical sizing (`Length::dp( px )`) is a constant-physical-size pixel scaled by display density (`set_density` / `density`). `Length` gains `orient( portrait, landscape )` — resolve one value in portrait, another in landscape — plus `widget( px )`, which picks fluid or dp per the active mode. Canvas exposes `geom_px` (geometry, resolved in physical layout space) and `font_px` (font size, bridging logical / physical per mode) so widgets and apps share one resolution path. Note the rename: `set_design_reference` / `design_reference` became `set_fluid_reference` / `fluid_reference`, and `Length::dp` changed meaning — the old surface-proportional behaviour now lives on `Length::fluid`.
Widgets. Every stock widget resolves its default geometry and font through the widget-scaling mode instead of frozen pixels, so a whole UI scales coherently without per-call units. New size builders where they were missing: `button` gains `font_size` / `height`, `text_edit` gains `height` / `font_size_fluid`, `separator` gains `pad_v`, and assorted widgets accept a `Length` where they previously took only `f32`.
Overlays. `OverlaySpec::size` is now `( Length, Length )` instead of `( u32, u32 )`, resolved against the main surface when the overlay is materialized, so overlays can scale with the display; `Length::px( … )` reproduces the old fixed sizing.
API stabilization (toward 1.0). Widget struct fields are now `pub( crate )` — they are configured through builders, not field access — except the value / state types apps genuinely read or construct (`Time`, `Date`, `ComboState`), which stay public. The internal `test_support` helpers move behind a `test-support` Cargo feature (off by default, so third-party builds never see them; ltk's own `make test` enables it). `Separator` drops its `0.0`-means-mode sentinel for `Option<Length>`, so an explicit `pad_v( 0.0 )` is a real flush divider distinct from the mode-following default.
Performance guardrails. Opt-in diagnostics via `LTK_PERF_WARN=1` warn about stuck animations, sustained software-render animation, and low `poll_interval`; software-rendered animation is capped near 30 Hz to spare CPU on machines that fall back off EGL. Apps can override the cap with `App::cap_software_animation`.
Docs and build. The two scaling modes are documented in README, onboarding and architecture, with the earlier gradient / backdrop doc drift cleaned up. The Makefile now ships the `locales/` directory into the packaged crate (fixing i18n keys rendering raw for downstreams), builds the new `responsive` example, and runs tests with `--features test-support`.
This commit is contained in:
2026-07-07 17:40:33 +02:00
parent d4d7ee742e
commit ce893ac776
83 changed files with 1850 additions and 526 deletions
+173
View File
@@ -0,0 +1,173 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Runtime performance guardrails.
//!
//! The idle/redraw model is efficient only if the app cooperates — a stuck
//! [`App::is_animating`](crate::App::is_animating), an aggressive
//! [`poll_interval`](crate::App::poll_interval), or continuous animation on
//! the software backend all quietly burn CPU/battery. The docs warn about
//! these, but the runtime can also **detect** them (opt-in dev diagnostics,
//! `LTK_PERF_WARN=1`) and **mitigate** one of them (cap animation to ~30 Hz
//! on the software renderer, opt-out via
//! [`App::cap_software_animation`](crate::App::cap_software_animation)).
use std::sync::OnceLock;
use std::time::{ Duration, Instant };
/// A settled animation should return `false` well before this; staying `true`
/// this long is almost always a forgotten `is_animating = false`.
const STUCK_THRESHOLD: Duration = Duration::from_secs( 10 );
/// Continuous software-rendered animation past this is worth flagging on
/// mobile, where it means sustained CPU with no GPU offload.
const SW_ANIM_THRESHOLD: Duration = Duration::from_secs( 3 );
/// Minimum gap between software-backend animation re-rasters (≈ 30 Hz).
const SOFTWARE_ANIM_MIN_INTERVAL: Duration = Duration::from_millis( 33 );
/// A `poll_interval` shorter than this defeats the event-driven idle model.
const POLL_WARN_THRESHOLD: Duration = Duration::from_millis( 100 );
/// `true` when `LTK_PERF_WARN` is set to a non-empty, non-`0` value. Cached.
fn perf_warn_enabled() -> bool
{
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init( ||
std::env::var( "LTK_PERF_WARN" )
.map( |v| !v.is_empty() && v != "0" )
.unwrap_or( false )
)
}
/// Per-runtime performance-guard state. Lives on `AppData`.
pub struct PerfState
{
/// When the current continuous animation began (`None` while idle).
animating_since: Option<Instant>,
/// Last software-backend animation re-raster, for the 30 Hz cap.
last_anim_draw: Instant,
warned_stuck: bool,
warned_software_anim: bool,
warned_poll: bool,
}
impl PerfState
{
pub fn new() -> Self
{
Self
{
animating_since: None,
// Start in the past so the first animated frame is never throttled.
// `checked_sub` guards against underflow shortly after boot.
last_anim_draw: Instant::now().checked_sub( SOFTWARE_ANIM_MIN_INTERVAL ).unwrap_or_else( Instant::now ),
warned_stuck: false,
warned_software_anim: false,
warned_poll: false,
}
}
/// Reset when the app stops animating, so the next animation starts fresh
/// (and can warn again if it, too, gets stuck).
pub fn animation_stopped( &mut self )
{
self.animating_since = None;
self.warned_stuck = false;
self.warned_software_anim = false;
}
/// Called on every animated main-surface frame callback. Runs the opt-in
/// diagnostics and applies the software animation-rate cap. Returns `true`
/// if this frame should actually re-raster, `false` to throttle-skip it
/// (the caller keeps the vsync cadence with a bare frame callback).
pub fn animated_frame( &mut self, software: bool, cap_software: bool ) -> bool
{
let since = *self.animating_since.get_or_insert_with( Instant::now );
if perf_warn_enabled()
{
if !self.warned_stuck && since.elapsed() >= STUCK_THRESHOLD
{
eprintln!(
"[ltk][perf] App::is_animating() has stayed true for {}s — a settled \
animation must return false, or the loop redraws at the display rate \
forever (battery drain).",
STUCK_THRESHOLD.as_secs(),
);
self.warned_stuck = true;
}
if software && !self.warned_software_anim && since.elapsed() >= SW_ANIM_THRESHOLD
{
eprintln!(
"[ltk][perf] continuous animation on the software renderer — sustained \
CPU with no GPU offload (costly on mobile). Prefer event-driven redraws, \
or a GLES-capable compositor.",
);
self.warned_software_anim = true;
}
}
if software && cap_software && self.last_anim_draw.elapsed() < SOFTWARE_ANIM_MIN_INTERVAL
{
return false;
}
self.last_anim_draw = Instant::now();
true
}
/// Warn once (under `LTK_PERF_WARN`) if the app's `poll_interval` is short
/// enough to defeat the idle model.
pub fn warn_poll_interval( &mut self, dur: Duration )
{
if perf_warn_enabled() && !self.warned_poll && dur < POLL_WARN_THRESHOLD
{
eprintln!(
"[ltk][perf] poll_interval() of {}ms defeats the event-driven idle model — \
wake the loop from your worker with set_channel_sender instead of polling.",
dur.as_millis(),
);
self.warned_poll = true;
}
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn gles_animation_is_never_capped()
{
let mut p = PerfState::new();
// Two back-to-back frames on the GPU backend both re-raster.
assert!( p.animated_frame( false, true ) );
assert!( p.animated_frame( false, true ) );
}
#[ test ]
fn software_animation_is_capped_between_rerasters()
{
let mut p = PerfState::new();
// First software frame draws; an immediate second is throttled
// (< 33 ms since the last re-raster).
assert!( p.animated_frame( true, true ) );
assert!( !p.animated_frame( true, true ) );
}
#[ test ]
fn software_cap_opt_out_keeps_full_rate()
{
let mut p = PerfState::new();
assert!( p.animated_frame( true, false ) );
assert!( p.animated_frame( true, false ) );
}
#[ test ]
fn stopping_resets_stuck_warning_state()
{
let mut p = PerfState::new();
let _ = p.animated_frame( false, true );
assert!( p.animating_since.is_some() );
p.animation_stopped();
assert!( p.animating_since.is_none() );
}
}