responsive fluid/physical scaling, widget-API stabilization, and perf guardrails
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:
@@ -256,6 +256,9 @@ pub struct AppData<A: App>
|
||||
/// Focus request deferred because the target widget was absent from
|
||||
/// `widget_rects` (e.g. read_only, not yet rebuilt). Retried next draw.
|
||||
pub focus_retry: Option<crate::types::WidgetId>,
|
||||
/// Runtime performance guardrails (stuck-animation / poll-interval
|
||||
/// diagnostics and the software animation-rate cap). See [`super::perf`].
|
||||
pub perf: super::perf::PerfState,
|
||||
}
|
||||
|
||||
impl<A: App> AppData<A>
|
||||
|
||||
@@ -662,10 +662,29 @@ impl<A: App> Dispatch<WlCallback, super::SurfaceFocus> for AppData<A>
|
||||
}
|
||||
else
|
||||
{
|
||||
state.view_dirty = true;
|
||||
state.main.request_redraw();
|
||||
// Perf guardrails: run the opt-in diagnostics and apply
|
||||
// the software animation-rate cap (~30 Hz). A throttled
|
||||
// frame keeps the vsync cadence with a bare callback but
|
||||
// skips the re-raster.
|
||||
let software = crate::render::is_software_render();
|
||||
let cap = state.app.cap_software_animation();
|
||||
if state.perf.animated_frame( software, cap )
|
||||
{
|
||||
state.view_dirty = true;
|
||||
state.main.request_redraw();
|
||||
}
|
||||
else if let Some( wl ) = state.main.surface.try_wl_surface().cloned()
|
||||
{
|
||||
let _ = wl.frame( &state.qh, super::SurfaceFocus::Main );
|
||||
wl.commit();
|
||||
state.main.frame_pending = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
state.perf.animation_stopped();
|
||||
}
|
||||
}
|
||||
super::SurfaceFocus::Overlay( id ) =>
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
#[ cfg( feature = "test-support" ) ]
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::app::{ App, InvalidationScope, SurfaceTarget };
|
||||
@@ -62,6 +63,10 @@ pub( super ) fn apply_invalidation<A: App>( data: &mut AppData<A>, scope: Invali
|
||||
///
|
||||
/// `added` preserves the order of `next` (so creation order is deterministic);
|
||||
/// `removed` is unordered (driven by HashMap iteration in the caller).
|
||||
///
|
||||
/// Exposed only for `test_support`; gated behind that feature so it does not
|
||||
/// warn as dead code in ordinary builds.
|
||||
#[ cfg( feature = "test-support" ) ]
|
||||
pub fn diff_overlay_ids(
|
||||
current: impl IntoIterator<Item = crate::app::OverlayId>,
|
||||
next: &[ crate::app::OverlayId ],
|
||||
|
||||
@@ -9,6 +9,7 @@ pub( crate ) mod cursor_shape;
|
||||
pub( crate ) mod drag;
|
||||
pub( crate ) mod focus;
|
||||
mod handlers;
|
||||
pub( crate ) mod perf;
|
||||
pub( crate ) mod repeat;
|
||||
pub( crate ) mod subsurface;
|
||||
pub( crate ) mod surface;
|
||||
@@ -25,4 +26,5 @@ pub( crate ) use app_data::AppData;
|
||||
pub( crate ) use surface::{ FrameState, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
|
||||
pub use error::RunError;
|
||||
pub( crate ) use run::{ run, try_run };
|
||||
#[ cfg( feature = "test-support" ) ]
|
||||
pub use invalidation::diff_overlay_ids;
|
||||
|
||||
@@ -24,7 +24,7 @@ use wayland_protocols::xdg::shell::client::xdg_positioner::
|
||||
};
|
||||
|
||||
use crate::app::App;
|
||||
use crate::types::Rect;
|
||||
use crate::types::{ Length, Rect };
|
||||
use super::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
|
||||
|
||||
/// Sync `data.overlays` with the app's current `App::overlays()` spec list:
|
||||
@@ -106,6 +106,18 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
|
||||
};
|
||||
let parent_scale_i = data.main.scale_factor.max( 1 );
|
||||
let parent_scale = parent_scale_i as f32;
|
||||
// `OverlaySpec::size` carries `Length`s resolved against the main
|
||||
// surface's physical viewport (the app's layout space), so an overlay
|
||||
// sized with `Length::widget( … )` scales with the display exactly like
|
||||
// any other widget. `Length::px( n )` keeps a fixed size.
|
||||
let main_vp = ( data.main.physical_width() as f32, data.main.physical_height() as f32 );
|
||||
let resolve_size = move | s: &( Length, Length ) | -> ( u32, u32 )
|
||||
{
|
||||
(
|
||||
s.0.resolve( main_vp, Length::EM_BASE_DEFAULT ).max( 0.0 ).round() as u32,
|
||||
s.1.resolve( main_vp, Length::EM_BASE_DEFAULT ).max( 0.0 ).round() as u32,
|
||||
)
|
||||
};
|
||||
// `OverlaySpec::size` is physical pixels (the app's layout space); the
|
||||
// layer-shell `set_size` is logical. They only coincide at scale 1, so
|
||||
// convert here — without it a scale-2 overlay requests a surface twice
|
||||
@@ -124,6 +136,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
|
||||
let overlays_m = &mut data.overlays;
|
||||
for spec in &specs
|
||||
{
|
||||
let resolved_size = resolve_size( &spec.size );
|
||||
if let Some( ss ) = overlays_m.get_mut( &spec.id )
|
||||
{
|
||||
// Already-live overlay: propagate size changes to the layer
|
||||
@@ -135,14 +148,14 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
|
||||
// sends a configure; the usual `on_configure` path picks up the
|
||||
// new dimensions and drives the redraw. Popups don't grow /
|
||||
// shrink mid-life — close and reopen instead.
|
||||
if spec.size != ss.last_requested_size
|
||||
if resolved_size != ss.last_requested_size
|
||||
{
|
||||
if let SurfaceKind::Layer( ref layer_surface ) = ss.surface
|
||||
{
|
||||
let ( lw, lh ) = to_logical_size( spec.size );
|
||||
let ( lw, lh ) = to_logical_size( resolved_size );
|
||||
layer_surface.set_size( lw, lh );
|
||||
layer_surface.commit();
|
||||
ss.last_requested_size = spec.size;
|
||||
ss.last_requested_size = resolved_size;
|
||||
}
|
||||
}
|
||||
// Compare the anchor at integer logical-pixel resolution:
|
||||
@@ -173,7 +186,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
|
||||
if let Ok( positioner ) = XdgPositioner::new( xdg_shell )
|
||||
{
|
||||
let ( ax, ay, aw, ah ) = new_q;
|
||||
let ( spec_w, spec_h ) = spec.size;
|
||||
let ( spec_w, spec_h ) = resolved_size;
|
||||
let popup_w = if spec_w == 0 { aw } else { spec_w.max( 1 ) as i32 };
|
||||
let popup_h = spec_h.max( 1 ) as i32;
|
||||
positioner.set_size( popup_w, popup_h );
|
||||
@@ -236,7 +249,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
|
||||
let ay = ( anchor_rect.y / parent_scale ).round() as i32;
|
||||
let aw = ( anchor_rect.width / parent_scale ).round().max( 1.0 ) as i32;
|
||||
let ah = ( anchor_rect.height / parent_scale ).round().max( 1.0 ) as i32;
|
||||
let ( spec_w, spec_h ) = spec.size;
|
||||
let ( spec_w, spec_h ) = resolved_size;
|
||||
let popup_w = if spec_w == 0 { aw } else { spec_w.max( 1 ) as i32 };
|
||||
let popup_h = spec_h.max( 1 ) as i32;
|
||||
positioner.set_size( popup_w, popup_h );
|
||||
@@ -274,7 +287,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
|
||||
}
|
||||
popup.wl_surface().commit();
|
||||
let mut ss = SurfaceState::<A::Message>::new( SurfaceKind::Popup( popup ), 0.0, String::new() );
|
||||
ss.last_requested_size = spec.size;
|
||||
ss.last_requested_size = resolved_size;
|
||||
ss.last_popup_anchor = Some( anchor_rect );
|
||||
overlays_m.insert( spec.id, ss );
|
||||
continue;
|
||||
@@ -290,7 +303,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
|
||||
layer: spec.layer.to_wlr_layer(),
|
||||
exclusive_zone: spec.exclusive_zone,
|
||||
anchor: spec.anchor,
|
||||
size: to_logical_size( spec.size ),
|
||||
size: to_logical_size( resolved_size ),
|
||||
keyboard_exclusive: spec.keyboard_exclusive,
|
||||
namespace: "ltk-overlay",
|
||||
};
|
||||
@@ -305,7 +318,7 @@ pub( super ) fn reconcile_overlays<A: App>( data: &mut AppData<A> )
|
||||
// instead of rendering at scale 1 until `scale_factor_changed`
|
||||
// lands a frame or two later.
|
||||
ss.scale_factor = parent_scale_i;
|
||||
ss.last_requested_size = spec.size;
|
||||
ss.last_requested_size = resolved_size;
|
||||
ss.layer_anchor = Some( spec.anchor );
|
||||
overlays_m.insert( spec.id, ss );
|
||||
}
|
||||
|
||||
@@ -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() );
|
||||
}
|
||||
}
|
||||
@@ -331,6 +331,7 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
||||
overlays_dirty: true,
|
||||
first_frame_committed: false,
|
||||
focus_retry: None,
|
||||
perf: super::perf::PerfState::new(),
|
||||
};
|
||||
|
||||
// Register a calloop channel so the app can send messages from any thread.
|
||||
@@ -360,6 +361,7 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
||||
// The timer fires independently of Wayland events, waking the event loop on schedule.
|
||||
if let Some( dur ) = data.app.poll_interval()
|
||||
{
|
||||
data.perf.warn_poll_interval( dur );
|
||||
event_loop.handle()
|
||||
.insert_source(
|
||||
Timer::from_duration( dur ),
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use super::app_data::AppData;
|
||||
use super::surface::SurfaceFocus;
|
||||
use crate::app::App;
|
||||
use crate::types::Rect;
|
||||
use crate::types::{ Length, Rect };
|
||||
|
||||
pub const TOOLTIP_DELAY: std::time::Duration = std::time::Duration::from_millis( 600 );
|
||||
|
||||
@@ -132,7 +132,7 @@ impl<A: App> AppData<A>
|
||||
id,
|
||||
layer: crate::app::Layer::Overlay,
|
||||
anchor: crate::app::Anchor::ALL,
|
||||
size: ( 0, 0 ),
|
||||
size: ( Length::px( 0.0 ), Length::px( 0.0 ) ),
|
||||
exclusive_zone: -1,
|
||||
keyboard_exclusive: false,
|
||||
input_region: Some( Vec::new() ),
|
||||
|
||||
Reference in New Issue
Block a user