ltk: subsurface slides over overlays, axis-locked swipes, physical-space layout, touch reset on resume
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

Subsurfaces can now be parented to an overlay surface, not just the main surface. `SubsurfaceSpec` gains `parent: SubsurfaceParent { Main, Overlay(id) }` so a sliding panel can ride above app windows the way an overlay panel does, and an optional `gpu: bool` so content that uses the `surface-panel` backdrop-filter glass (a GLES-only pass) keeps it while sliding instead of dropping to the software rasteriser. `reconcile_subsurfaces` resolves each spec's parent independently — skipping an `Overlay` parent that is absent, unconfigured, or zero-sized — tracks the rastered size on the slot, and commits each touched parent once per frame. The ~14 GLES shader programs are compiled once into a shared `AppData::subsurface_gles_canvas` reused across every subsurface, so a lazily re-created sliding panel never recompiles them (hundreds of ms on a mobile GPU).
Vertical and horizontal swipes are now mutually exclusive: a gesture locks onto its dominant axis within the first 8 px of travel (new `SwipeAxis`) and ignores the perpendicular axis for the rest of the gesture, so a vertical swipe that drifts sideways no longer also drives the pager (and vice-versa). The upward swipe progress is no longer clamped at 1.0 — follow-the-finger panels can keep tracking the finger past the commit threshold — and a release below threshold delivers a final `progress = 0.0` cancellation pulse. A vertical swipe also no longer re-rasters the full-screen main surface on every motion event: only the overlays it drives are refreshed, while the horizontal pager (which does move the main surface) still redraws it. Re-rastering the main on every frame of a vertical drag was wasted work that stalled the loop and made the gesture feel laggy to start on a slow GPU.
Layout-affecting `Length` values (widths, paddings, gaps, widget sizes, including `Vw` / `Vh`) now resolve against the physical viewport via the new `Canvas::viewport_layout()` — the space the layout tree is actually computed in — so a `Vw(100)` fills the surface on a HiDPI (scale 2) output instead of covering half of it. Font sizes are unchanged: they still resolve against the logical viewport and are scaled at raster time. Switched column, row, spacer, wrap_grid, container, image, and vslider over to it; `img_widget` and `vslider` now document `Length::vw` / `vh` sizing and the showcase example demonstrates a viewport-relative image.
Touch gesture state is reset when the touch capability is added or removed — suspend / resume on devices that power the touchscreen down. A yanked capability never delivers the pending `up` / `cancel`, so the shared `reset_touch_state()` (also used by the `wl_touch.cancel` handler) drops the stranded `primary_touch_id` / slot state across the main surface and every overlay, keeping the first post-resume gesture clean. Also drops an accidental duplicate `on_scale_changed` call from the scale-change handler.
This commit is contained in:
2026-06-07 16:45:59 +02:00
parent 68c6a87bf6
commit cfa0faff26
21 changed files with 466 additions and 115 deletions

View File

@@ -1,23 +1,43 @@
//! `cargo run --example showcase` //! `cargo run --example showcase`
//! //!
//! Shows button variants, container with background, a slider, plus the //! Shows button variants, container with background, a slider, a
//! newer additions: a tab strip, a spinner driven by the wall clock, a //! viewport-relative image, plus the newer additions: a tab strip, a spinner
//! multiline text edit, and on-demand toast / tooltip overlays. //! driven by the wall clock, a multiline text edit, and on-demand toast /
//! tooltip overlays.
//! Tab / Shift+Tab cycles keyboard focus. Enter or Space activates the //! Tab / Shift+Tab cycles keyboard focus. Enter or Space activates the
//! focused button. Esc exits. //! focused button. Esc exits.
//! //!
//! NOTE: ltk is a Wayland layer-shell toolkit. This example requires a running //! NOTE: ltk is a Wayland layer-shell toolkit. This example requires a running
//! Wayland compositor (e.g. sway, labwc, or a full desktop session). //! Wayland compositor (e.g. sway, labwc, or a full desktop session).
use std::sync::Arc;
use std::time::{ Duration, Instant }; use std::time::{ Duration, Instant };
use ltk::{ use ltk::{
App, Element, Keysym, ButtonVariant, WidgetId, App, Element, Keysym, ButtonVariant, Length, WidgetId,
OverlaySpec, OverlaySpec,
button, column, row, stack, text, text_edit, spacer, container, slider, scroll, button, column, row, stack, text, text_edit, spacer, container, slider, scroll,
spinner, tabs, toast, tooltip, spinner, tabs, toast, tooltip, img_widget,
}; };
const IMG_W: u32 = 64;
const IMG_H: u32 = 64;
fn gradient_rgba() -> Arc<Vec<u8>>
{
let mut data = Vec::with_capacity( ( IMG_W * IMG_H * 4 ) as usize );
for y in 0..IMG_H
{
for x in 0..IMG_W
{
let r = ( 255 * x / IMG_W ) as u8;
let g = ( 255 * y / IMG_H ) as u8;
data.extend_from_slice( &[ r, g, 180, 255 ] );
}
}
Arc::new( data )
}
// ── Messages ────────────────────────────────────────────────────────────────── // ── Messages ──────────────────────────────────────────────────────────────────
#[ derive( Clone ) ] #[ derive( Clone ) ]
@@ -43,6 +63,7 @@ struct ShowcaseApp
started_at: Instant, started_at: Instant,
toast_until: Option<Instant>, toast_until: Option<Instant>,
tooltip_visible: bool, tooltip_visible: bool,
image: Arc<Vec<u8>>,
} }
impl ShowcaseApp impl ShowcaseApp
@@ -58,6 +79,7 @@ impl ShowcaseApp
started_at: Instant::now(), started_at: Instant::now(),
toast_until: None, toast_until: None,
tooltip_visible: false, tooltip_visible: false,
image: gradient_rgba(),
} }
} }
@@ -127,6 +149,13 @@ impl App for ShowcaseApp
.radius( 12.0 ) .radius( 12.0 )
.padding( 16.0 ); .padding( 16.0 );
// Viewport-relative image: 30 % of the surface width by 10 % of its
// height, so it rescales with the window instead of staying fixed.
let banner = column::<Message>()
.spacing( 4.0 )
.push( text( "img_widget().size( vw 30, vh 10 )" ).size( 12.0 ).color( secondary ) )
.push( img_widget( self.image.clone(), IMG_W, IMG_H ).size( Length::vw( 30.0 ), Length::vh( 10.0 ) ) );
let vol_label = format!( "Volume: {:.0}%", self.slider_value * 100.0 ); let vol_label = format!( "Volume: {:.0}%", self.slider_value * 100.0 );
// Spinner — phase comes straight from the wall clock so the arc // Spinner — phase comes straight from the wall clock so the arc
@@ -163,6 +192,7 @@ impl App for ShowcaseApp
.push( spacer() ) .push( spacer() )
.push( buttons ) .push( buttons )
.push( card ) .push( card )
.push( banner )
.push( text( vol_label ).size( 13.0 ).color( secondary ) ) .push( text( vol_label ).size( 13.0 ).color( secondary ) )
.push( slider( self.slider_value ).on_change( Message::SliderChanged ) ) .push( slider( self.slider_value ).on_change( Message::SliderChanged ) )
.push( spin_row ) .push( spin_row )

View File

@@ -124,6 +124,18 @@ pub struct OverlayId( pub u32 );
#[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord )] #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord )]
pub struct SubsurfaceId( pub u32 ); pub struct SubsurfaceId( pub u32 );
/// Which surface a [`SubsurfaceSpec`] is composited as a child of. `Main`
/// (the default position) parents to the app's main surface; `Overlay(id)`
/// parents to one of the [`App::overlays`] surfaces, so a slide can ride
/// above app windows the way an overlay panel does. An `Overlay` parent that
/// is absent or not yet configured is skipped for that frame.
#[derive( Debug, Clone, Copy, PartialEq, Eq )]
pub enum SubsurfaceParent
{
Main,
Overlay( OverlayId ),
}
/// One of the surfaces an [`App`] can target with an invalidation. Used inside /// One of the surfaces an [`App`] can target with an invalidation. Used inside
/// [`InvalidationScope::Only`] to name the affected surfaces. /// [`InvalidationScope::Only`] to name the affected surfaces.
#[derive( Debug, Clone, Copy, PartialEq, Eq, Hash )] #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash )]
@@ -279,6 +291,10 @@ pub struct SubsurfaceSpec<Message: Clone>
/// Stable identifier, used to diff subsurfaces between frames. /// Stable identifier, used to diff subsurfaces between frames.
pub id: SubsurfaceId, pub id: SubsurfaceId,
/// Surface this subsurface is composited as a child of. Sized to that
/// parent; [`x`](Self::x) / [`y`](Self::y) are relative to its top-left.
pub parent: SubsurfaceParent,
/// Widget tree for this subsurface. Should paint its own opaque /// Widget tree for this subsurface. Should paint its own opaque
/// background if it must cover the main surface beneath it. /// background if it must cover the main surface beneath it.
pub view: Element<Message>, pub view: Element<Message>,
@@ -295,6 +311,13 @@ pub struct SubsurfaceSpec<Message: Clone>
/// differently; leave it unchanged for position-only updates so the /// differently; leave it unchanged for position-only updates so the
/// runtime skips the re-raster and only repositions. /// runtime skips the re-raster and only repositions.
pub content_version: u64, pub content_version: u64,
/// Render through GLES (so `backdrop-filter` glass works) rather than the
/// software rasteriser. GLES is only worth it for content that actually
/// uses the blur: it pays a per-surface EGL-surface creation (and a
/// one-time shader compile) the cheap software path avoids, so a panel
/// without glass should leave this `false`.
pub gpu: bool,
} }
/// Trait that application types must implement to integrate with ltk. /// Trait that application types must implement to integrate with ltk.
@@ -381,11 +404,18 @@ pub trait App: 'static
/// `swipe_threshold()` × screen height and then releases. /// `swipe_threshold()` × screen height and then releases.
fn on_swipe_up( &mut self ) -> Option<Self::Message> { None } fn on_swipe_up( &mut self ) -> Option<Self::Message> { None }
/// Called during an upward swipe gesture with progress 0.0..=1.0. /// Called during an upward swipe gesture with progress ≥ 0.0.
/// ///
/// Use this to create follow-the-finger animations. `progress` starts at 0.0 /// Use this to create follow-the-finger animations. `progress` starts at 0.0
/// when the drag begins and increases as the finger moves upward, reaching 1.0 /// when the drag begins and increases as the finger moves upward, reaching 1.0
/// when the drag distance equals `swipe_threshold()` × screen height. /// when the drag distance equals `swipe_threshold()` × screen height. It is
/// **not** clamped at 1.0 — the value keeps growing if the finger drags further
/// so that follow-the-finger panels can continue tracking the finger all the way
/// up the screen. Clamp inside the handler if you need a bounded `[0, 1]` range
/// for a visual indicator.
///
/// When the user releases without completing the gesture, this method is called
/// once more with `progress = 0.0` to signal cancellation.
fn on_swipe_progress( &mut self, _progress: f32 ) {} fn on_swipe_progress( &mut self, _progress: f32 ) {}
/// Called when a sufficient downward swipe gesture is detected. /// Called when a sufficient downward swipe gesture is detected.
@@ -595,8 +625,10 @@ pub trait App: 'static
fn on_resize( &mut self, _width: u32, _height: u32 ) {} fn on_resize( &mut self, _width: u32, _height: u32 ) {}
/// Called when the compositor reports a new integer buffer scale for the /// Called when the compositor reports a new integer buffer scale for the
/// main surface. The default is inert so apps that only care about physical /// main surface. `scale` is the integer buffer scale (1 = standard DPI,
/// pixels can keep using [`on_resize`](Self::on_resize). /// 2 = HiDPI ×2); physical pixels equal logical pixels × scale. The default
/// is inert so apps that only care about physical pixels can keep using
/// [`on_resize`](Self::on_resize).
fn on_scale_changed( &mut self, _scale: u32 ) {} fn on_scale_changed( &mut self, _scale: u32 ) {}
/// Called once at startup with a channel sender that can be used from any /// Called once at startup with a channel sender that can be used from any

View File

@@ -195,7 +195,7 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
{ {
canvas.stroke_rect( rect, color, width, c.corners ); canvas.stroke_rect( rect, color, width, c.corners );
} }
let vp = canvas.viewport_logical(); let vp = canvas.viewport_layout();
let em = crate::types::Length::EM_BASE_DEFAULT; let em = crate::types::Length::EM_BASE_DEFAULT;
let pad_l = c.pad_left.resolve( vp, em ); let pad_l = c.pad_left.resolve( vp, em );
let pad_r = c.pad_right.resolve( vp, em ); let pad_r = c.pad_right.resolve( vp, em );

View File

@@ -221,6 +221,11 @@ pub struct AppData<A: App>
/// Input-transparent child surfaces keyed by their stable /// Input-transparent child surfaces keyed by their stable
/// [`SubsurfaceId`]. Reconciled each frame from [`App::subsurfaces`]. /// [`SubsurfaceId`]. Reconciled each frame from [`App::subsurfaces`].
pub subsurfaces: HashMap<SubsurfaceId, SubsurfaceSlot>, pub subsurfaces: HashMap<SubsurfaceId, SubsurfaceSlot>,
/// Shared GLES canvas reused across every subsurface raster. Held here
/// (not per-slot) so a subsurface that is dropped and re-created — e.g. a
/// lazily-emitted sliding panel — does not recompile the ~14 shader
/// programs (hundreds of ms on a mobile GPU) every time it reappears.
pub subsurface_gles_canvas: Option<crate::render::Canvas>,
/// Surface currently receiving pointer events (updated on each pointer /// Surface currently receiving pointer events (updated on each pointer
/// event via its `surface` field). /// event via its `surface` field).
pub pointer_focus: SurfaceFocus, pub pointer_focus: SurfaceFocus,

View File

@@ -324,6 +324,13 @@ impl<A: App> SeatHandler for AppData<A>
} }
Capability::Touch if self.touch.is_none() => Capability::Touch if self.touch.is_none() =>
{ {
// Touch coming back (resume on devices that power the
// touchscreen down at suspend): clear anything the removal
// path didn't, so the first post-resume gesture starts clean.
if self.reset_touch_state()
{
eprintln!( "[ltk] touch capability re-added — cleared touch state stranded across the gap" );
}
self.touch = Some( self.touch = Some(
self.seat_state self.seat_state
.get_touch( qh, &seat ) .get_touch( qh, &seat )
@@ -346,7 +353,17 @@ impl<A: App> SeatHandler for AppData<A>
{ {
Capability::Keyboard => { self.keyboard = None; } Capability::Keyboard => { self.keyboard = None; }
Capability::Pointer => { self.pointer = None; } Capability::Pointer => { self.pointer = None; }
Capability::Touch => { self.touch = None; } Capability::Touch =>
{
self.touch = None;
// The touchscreen is gone before any pending up / cancel for
// the last touch could arrive; drop the stale gesture state
// now rather than stranding it until the next clean release.
if self.reset_touch_state()
{
eprintln!( "[ltk] touch capability removed mid-gesture — reset stale touch state" );
}
}
_ => {} _ => {}
} }
} }

View File

@@ -321,6 +321,7 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
main: SurfaceState::<A::Message>::new( surface_kind, titlebar_height, titlebar_title ), main: SurfaceState::<A::Message>::new( surface_kind, titlebar_height, titlebar_title ),
overlays: std::collections::HashMap::new(), overlays: std::collections::HashMap::new(),
subsurfaces: std::collections::HashMap::new(), subsurfaces: std::collections::HashMap::new(),
subsurface_gles_canvas: None,
pointer_focus: SurfaceFocus::Main, pointer_focus: SurfaceFocus::Main,
keyboard_focus: SurfaceFocus::Main, keyboard_focus: SurfaceFocus::Main,
touch_focus: std::collections::HashMap::new(), touch_focus: std::collections::HashMap::new(),

View File

@@ -20,7 +20,8 @@ use smithay_client_toolkit::reexports::client::protocol::wl_shm;
use smithay_client_toolkit::reexports::client::protocol::wl_subsurface::WlSubsurface; use smithay_client_toolkit::reexports::client::protocol::wl_subsurface::WlSubsurface;
use smithay_client_toolkit::reexports::client::protocol::wl_surface::WlSurface; use smithay_client_toolkit::reexports::client::protocol::wl_surface::WlSurface;
use crate::app::App; use crate::app::{ App, SubsurfaceParent };
use crate::egl_context::{ EglContext, EglSurface };
use crate::draw::DrawCtx; use crate::draw::DrawCtx;
use crate::draw::chrome::apply_input_region; use crate::draw::chrome::apply_input_region;
use crate::draw::layout_and_draw; use crate::draw::layout_and_draw;
@@ -46,46 +47,75 @@ pub( crate ) struct SubsurfaceSlot
{ {
subsurface: WlSubsurface, subsurface: WlSubsurface,
surface: WlSurface, surface: WlSurface,
/// SHM pool for the software raster path. `None` on the GLES path.
pool: Option<SlotPool>, pool: Option<SlotPool>,
/// EGL window surface for the GLES raster path (so the `surface-panel`
/// backdrop blur, a GLES-only pass, renders). `None` on the software path.
egl_surface: Option<EglSurface>,
canvas: Option<Canvas>, canvas: Option<Canvas>,
last_pos: ( i32, i32 ), last_pos: ( i32, i32 ),
last_size: ( u32, u32 ),
last_version: u64, last_version: u64,
rastered: bool, rastered: bool,
parent: SubsurfaceParent,
} }
impl SubsurfaceSlot impl SubsurfaceSlot
{ {
fn destroy( self ) fn destroy( mut self )
{ {
// Drop the EGL surface before the wl_surface its wl_egl_window wraps.
self.egl_surface = None;
self.subsurface.destroy(); self.subsurface.destroy();
self.surface.destroy(); self.surface.destroy();
} }
} }
/// Resolve a subsurface's parent surface to `( wl_surface, phys_w, phys_h,
/// scale )`. Returns `None` when an [`SubsurfaceParent::Overlay`] target is
/// absent, not yet configured, or zero-sized — the caller skips the spec for
/// that frame. The `WlSurface` is cloned (an `Arc`) so the render / reposition
/// loop can run without holding a borrow on `data.overlays` / `data.main`.
fn resolve_parent<A: App>( data: &AppData<A>, parent: SubsurfaceParent ) -> Option<( WlSurface, u32, u32, u32 )>
{
let ss = match parent
{
SubsurfaceParent::Main => &data.main,
SubsurfaceParent::Overlay( id ) => data.overlays.get( &id )?,
};
if !ss.configured { return None; }
if ss.width == 0 || ss.height == 0 { return None; }
let surface = ss.surface.try_wl_surface()?.clone();
let scale = ss.scale_factor.max( 1 ) as u32;
Some( ( surface, ss.width * scale, ss.height * scale, scale ) )
}
/// Record a parent surface that needs a commit this frame, deduped by parent
/// identity so each is committed once.
fn mark_parent_dirty( list: &mut Vec<( SubsurfaceParent, WlSurface )>, parent: SubsurfaceParent, wl: &WlSurface )
{
if !list.iter().any( |( p, _ )| *p == parent )
{
list.push( ( parent, wl.clone() ) );
}
}
/// Reconcile the live subsurfaces against [`App::subsurfaces`], render content /// Reconcile the live subsurfaces against [`App::subsurfaces`], render content
/// where it changed and reposition where it moved. Cheap when nothing but a /// where it changed and reposition where it moved. Cheap when nothing but a
/// position changed. Must run after the parent surface is configured. /// position changed. Each spec is composited under its own parent surface
/// (main or an overlay), so a slide can ride above app windows. Must run after
/// the parent surface is configured.
pub( crate ) fn reconcile_subsurfaces<A: App>( data: &mut AppData<A> ) pub( crate ) fn reconcile_subsurfaces<A: App>( data: &mut AppData<A> )
{ {
if data.subcompositor.is_none() { return; } if data.subcompositor.is_none() { return; }
if !data.main.configured { return; } if !data.main.configured { return; }
let parent = match data.main.surface.try_wl_surface()
{
Some( s ) => s.clone(),
None => return,
};
let scale = data.main.scale_factor.max( 1 ) as u32;
let ( w, h ) = ( data.main.width, data.main.height );
if w == 0 || h == 0 { return; }
let pw = w * scale;
let ph = h * scale;
let ( format, swap_rb ) = pick_shm_format( &data.shm ); let ( format, swap_rb ) = pick_shm_format( &data.shm );
let specs: Vec<crate::app::SubsurfaceSpec<A::Message>> = data.app.subsurfaces(); let specs: Vec<crate::app::SubsurfaceSpec<A::Message>> = data.app.subsurfaces();
let mut parent_dirty = false; // Parents touched this frame; each committed once at the end so the
// placement / mapping actually lands.
let mut dirty_parents: Vec<( SubsurfaceParent, WlSurface )> = Vec::new();
// Drop subsurfaces whose id disappeared from the spec list. // Drop subsurfaces whose id disappeared from the spec list.
let present: std::collections::HashSet<crate::app::SubsurfaceId> = let present: std::collections::HashSet<crate::app::SubsurfaceId> =
@@ -98,19 +128,25 @@ pub( crate ) fn reconcile_subsurfaces<A: App>( data: &mut AppData<A> )
{ {
if let Some( slot ) = data.subsurfaces.remove( &id ) if let Some( slot ) = data.subsurfaces.remove( &id )
{ {
let parent = slot.parent;
slot.destroy(); slot.destroy();
parent_dirty = true; if let Some( ( wl, _, _, _ ) ) = resolve_parent( data, parent )
{
mark_parent_dirty( &mut dirty_parents, parent, &wl );
}
} }
} }
for spec in &specs for spec in &specs
{ {
let Some( ( parent_wl, pw, ph, scale ) ) = resolve_parent( data, spec.parent ) else { continue };
// Create on first sight. // Create on first sight.
if !data.subsurfaces.contains_key( &spec.id ) if !data.subsurfaces.contains_key( &spec.id )
{ {
let ( subsurface, surface ) = { let ( subsurface, surface ) = {
let sc = data.subcompositor.as_ref().unwrap(); let sc = data.subcompositor.as_ref().unwrap();
sc.create_subsurface( parent.clone(), &data.qh ) sc.create_subsurface( parent_wl.clone(), &data.qh )
}; };
// Independent buffer commits; placement still applies on the // Independent buffer commits; placement still applies on the
// parent commit we issue below. // parent commit we issue below.
@@ -121,17 +157,21 @@ pub( crate ) fn reconcile_subsurfaces<A: App>( data: &mut AppData<A> )
subsurface, subsurface,
surface, surface,
pool: None, pool: None,
egl_surface: None,
canvas: None, canvas: None,
last_pos: ( spec.x, spec.y ), last_pos: ( spec.x, spec.y ),
last_size: ( 0, 0 ),
last_version: u64::MAX, last_version: u64::MAX,
rastered: false, rastered: false,
parent: spec.parent,
} ); } );
parent_dirty = true; mark_parent_dirty( &mut dirty_parents, spec.parent, &parent_wl );
} }
// Tracked on the slot (not the canvas) because the GLES canvas lives
// in `data.subsurface_gles_canvas`, shared across all subsurfaces.
let size_changed = data.subsurfaces.get( &spec.id ) let size_changed = data.subsurfaces.get( &spec.id )
.and_then( |s| s.canvas.as_ref() ) .map( |s| s.last_size != ( pw, ph ) )
.map( |c| c.size() != ( pw, ph ) )
.unwrap_or( true ); .unwrap_or( true );
let needs_raster = { let needs_raster = {
let slot = data.subsurfaces.get( &spec.id ).unwrap(); let slot = data.subsurfaces.get( &spec.id ).unwrap();
@@ -140,14 +180,19 @@ pub( crate ) fn reconcile_subsurfaces<A: App>( data: &mut AppData<A> )
if needs_raster if needs_raster
{ {
// GLES only when the spec asks for it (glass content); otherwise
// the cheaper-to-create software rasteriser.
let egl = if spec.gpu { data.egl_context.as_ref() } else { None };
let slot = data.subsurfaces.get_mut( &spec.id ).unwrap(); let slot = data.subsurfaces.get_mut( &spec.id ).unwrap();
render_slot::<A::Message>( render_slot::<A::Message>(
slot, &data.shm, &data.compositor_state, &spec.view, slot, egl, &mut data.subsurface_gles_canvas,
&data.shm, &data.compositor_state, &spec.view,
pw, ph, scale, format, swap_rb, size_changed, pw, ph, scale, format, swap_rb, size_changed,
); );
slot.rastered = true; slot.rastered = true;
slot.last_version = spec.content_version; slot.last_version = spec.content_version;
parent_dirty = true; slot.last_size = ( pw, ph );
mark_parent_dirty( &mut dirty_parents, spec.parent, &parent_wl );
} }
let slot = data.subsurfaces.get_mut( &spec.id ).unwrap(); let slot = data.subsurfaces.get_mut( &spec.id ).unwrap();
@@ -160,20 +205,134 @@ pub( crate ) fn reconcile_subsurfaces<A: App>( data: &mut AppData<A> )
// move actually lands. The parent commit below applies placement. // move actually lands. The parent commit below applies placement.
slot.surface.commit(); slot.surface.commit();
slot.last_pos = ( spec.x, spec.y ); slot.last_pos = ( spec.x, spec.y );
parent_dirty = true; mark_parent_dirty( &mut dirty_parents, spec.parent, &parent_wl );
} }
} }
// Applies pending subsurface placement / mapping without re-attaching the // Applies pending subsurface placement / mapping without re-attaching the
// parent buffer — the cheap per-frame move. // parent buffer — the cheap per-frame move.
if parent_dirty for ( _, wl ) in dirty_parents
{ {
parent.commit(); wl.commit();
}
}
/// Input-transparent subsurfaces never carry focus / hover / scroll state,
/// so they draw with an empty context.
fn empty_draw_ctx<Msg: Clone>() -> DrawCtx<Msg>
{
DrawCtx
{
focused_idx: None,
hovered_idx: None,
pressed_idx: None,
cursor_state: HashMap::new(),
selection_anchor: HashMap::new(),
widget_rects: Vec::new(),
debug_layout: false,
scroll_offsets: HashMap::new(),
scroll_rects: Vec::new(),
scroll_canvases: HashMap::new(),
scroll_navigable_items: HashMap::new(),
previous_widget_rects: Vec::new(),
accessible_extras: Vec::new(),
live_depth: 0,
}
}
/// Raster a subsurface's content. Uses the GLES path when an [`EglContext`]
/// is available — only there does the `surface-panel` backdrop blur render;
/// the software fallback paints the same widgets without the blur.
#[ allow( clippy::too_many_arguments ) ]
fn render_slot<Msg: Clone>(
slot: &mut SubsurfaceSlot,
egl_ctx: Option<&Arc<EglContext>>,
gles_canvas: &mut Option<Canvas>,
shm: &Shm,
compositor: &CompositorState,
view: &Element<Msg>,
pw: u32,
ph: u32,
scale: u32,
shm_format: wl_shm::Format,
swap_rb: bool,
size_changed: bool,
)
{
if let Some( ctx ) = egl_ctx
{
render_slot_gpu::<Msg>( slot, ctx, gles_canvas, compositor, view, pw, ph, scale, size_changed );
}
else
{
render_slot_software::<Msg>( slot, shm, compositor, view, pw, ph, scale, shm_format, swap_rb, size_changed );
} }
} }
#[ allow( clippy::too_many_arguments ) ] #[ allow( clippy::too_many_arguments ) ]
fn render_slot<Msg: Clone>( fn render_slot_gpu<Msg: Clone>(
slot: &mut SubsurfaceSlot,
egl_ctx: &Arc<EglContext>,
gles_canvas: &mut Option<Canvas>,
compositor: &CompositorState,
view: &Element<Msg>,
pw: u32,
ph: u32,
scale: u32,
size_changed: bool,
)
{
// (Re)create the EGL window surface on first sight or a size change.
if slot.egl_surface.is_none() || size_changed
{
match egl_ctx.create_surface( &slot.surface, pw as i32, ph as i32 )
{
Ok( es ) => slot.egl_surface = Some( es ),
Err( e ) =>
{
eprintln!( "ltk: subsurface EGL surface creation failed: {e}" );
return;
}
}
}
let es = slot.egl_surface.as_ref().unwrap();
if egl_ctx.make_current( es ).is_err() { return; }
// The canvas (and its compiled shader programs) is shared across all
// subsurface rasters, so a lazily re-created panel doesn't recompile.
let canvas = gles_canvas.get_or_insert_with( ||
{
let mut c = Canvas::new_gles( Arc::clone( egl_ctx.gl() ), egl_ctx.version, pw, ph );
c.set_dpi_scale( scale as f32 );
if let Some( reg ) = crate::theme::build_font_registry()
{
c.set_font_registry( Arc::new( reg ) );
}
c
} );
if canvas.size() != ( pw, ph )
{
canvas.resize( pw, ph );
canvas.set_dpi_scale( scale as f32 );
}
canvas.clear_clip();
canvas.clear();
let screen_rect = Rect { x: 0.0, y: 0.0, width: pw as f32, height: ph as f32 };
let mut ctx = empty_draw_ctx::<Msg>();
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
canvas.present();
let wl = &slot.surface;
wl.set_buffer_scale( scale as i32 );
// Empty input region: pointer/touch fall through to the parent.
apply_input_region( wl, compositor, Some( &[] ), scale );
// Implicitly commits the wl_surface with the freshly rendered buffer.
let _ = egl_ctx.swap_buffers_with_damage( es, &[ ( 0, 0, pw as i32, ph as i32 ) ] );
}
#[ allow( clippy::too_many_arguments ) ]
fn render_slot_software<Msg: Clone>(
slot: &mut SubsurfaceSlot, slot: &mut SubsurfaceSlot,
shm: &Shm, shm: &Shm,
compositor: &CompositorState, compositor: &CompositorState,
@@ -222,23 +381,7 @@ fn render_slot<Msg: Clone>(
canvas.clear(); canvas.clear();
let screen_rect = Rect { x: 0.0, y: 0.0, width: pw as f32, height: ph as f32 }; let screen_rect = Rect { x: 0.0, y: 0.0, width: pw as f32, height: ph as f32 };
let mut ctx: DrawCtx<Msg> = DrawCtx let mut ctx = empty_draw_ctx::<Msg>();
{
focused_idx: None,
hovered_idx: None,
pressed_idx: None,
cursor_state: HashMap::new(),
selection_anchor: HashMap::new(),
widget_rects: Vec::new(),
debug_layout: false,
scroll_offsets: HashMap::new(),
scroll_rects: Vec::new(),
scroll_canvases: HashMap::new(),
scroll_navigable_items: HashMap::new(),
previous_widget_rects: Vec::new(),
accessible_extras: Vec::new(),
live_depth: 0,
};
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 ); layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
canvas.write_to_wayland_buf( canvas_buf, swap_rb ); canvas.write_to_wayland_buf( canvas_buf, swap_rb );

View File

@@ -49,20 +49,28 @@ impl<A: App> AppData<A>
if let Some( v ) = up { self.app.on_swipe_progress( v ); } if let Some( v ) = up { self.app.on_swipe_progress( v ); }
if let Some( v ) = down { self.app.on_swipe_down_progress( v ); } if let Some( v ) = down { self.app.on_swipe_down_progress( v ); }
if let Some( v ) = horizontal { self.app.on_swipe_horizontal_progress( v ); } if let Some( v ) = horizontal { self.app.on_swipe_horizontal_progress( v ); }
// Swipe-progress callbacks mutate app state outside // Swipe-progress callbacks mutate app state outside `update`,
// `update`, so the cached view tree is stale — unless the // so the cached trees are stale and the affected surfaces must
// motion only slides a subsurface over a static main // redraw. Only the HORIZONTAL pager moves the main surface,
// surface, in which case the per-frame subsurface reconcile // though — vertical swipes drive overlay panels and leave the
// carries the move and a main re-raster is wasted work. // main static. Re-rastering the (full-screen) main on every
if !self.app.subsurface_motion_only() // motion event of a vertical swipe is pure waste and, on a slow
// GPU, stalls the event loop so the gesture itself feels laggy
// to start. So refresh the overlays always, but the main only
// for a horizontal swipe.
if horizontal.is_some()
{ {
self.dirty_caches(); self.view_dirty = true;
self.surface_mut( focus ).request_redraw(); if let SurfaceFocus::Main = focus
for ss in self.overlays.values_mut()
{ {
ss.request_redraw(); self.main.request_redraw();
} }
} }
self.overlays_dirty = true;
for ss in self.overlays.values_mut()
{
ss.request_redraw();
}
} }
} }
} }

View File

@@ -47,6 +47,17 @@ mod tests;
// ─── State ─────────────────────────────────────────────────────────────────── // ─── State ───────────────────────────────────────────────────────────────────
/// Axis a swipe locked onto during its first 8 px of travel. Once set,
/// the perpendicular axis is ignored for the rest of the gesture so a
/// vertical swipe that drifts sideways never also drives the pager (and
/// vice-versa).
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum SwipeAxis
{
Vertical,
Horizontal,
}
/// Per-surface gesture tracking. Lives on `SurfaceState::gesture`. /// Per-surface gesture tracking. Lives on `SurfaceState::gesture`.
/// ///
/// Every field is `pub` because `AppData`'s long-press deadline /// Every field is `pub` because `AppData`'s long-press deadline
@@ -81,6 +92,10 @@ pub struct GestureState<Msg: Clone>
/// Vertical drag exceeded the 8 px threshold. Release runs the /// Vertical drag exceeded the 8 px threshold. Release runs the
/// swipe-up / swipe-down commit check instead of the button path. /// swipe-up / swipe-down commit check instead of the button path.
pub vertical_drag_started: bool, pub vertical_drag_started: bool,
/// Axis this swipe locked onto on its first 8 px of travel. Pins the
/// gesture to one axis so vertical and horizontal swipes stay
/// mutually exclusive. `None` until the lock fires.
pub swipe_axis: Option<SwipeAxis>,
/// Slider widget being dragged for a live-value update. /// Slider widget being dragged for a live-value update.
pub dragging_slider: Option<usize>, pub dragging_slider: Option<usize>,
/// Instant when the long-press window opened. `None` if the hit /// Instant when the long-press window opened. `None` if the hit
@@ -145,6 +160,7 @@ impl<Msg: Clone> GestureState<Msg>
scroll_drag_started: false, scroll_drag_started: false,
horizontal_drag_started: false, horizontal_drag_started: false,
vertical_drag_started: false, vertical_drag_started: false,
swipe_axis: None,
dragging_slider: None, dragging_slider: None,
long_press_start: None, long_press_start: None,
long_press_origin: None, long_press_origin: None,
@@ -190,6 +206,7 @@ impl<Msg: Clone> GestureState<Msg>
self.scroll_drag_started = false; self.scroll_drag_started = false;
self.horizontal_drag_started = false; self.horizontal_drag_started = false;
self.vertical_drag_started = false; self.vertical_drag_started = false;
self.swipe_axis = None;
self.pressed_idx = hit; self.pressed_idx = hit;
// A fresh press resets any stale long-press / source state // A fresh press resets any stale long-press / source state
// from a prior gesture that never released cleanly. The // from a prior gesture that never released cleanly. The
@@ -335,22 +352,33 @@ impl<Msg: Clone> GestureState<Msg>
return MoveOutcome::Idle; return MoveOutcome::Idle;
} }
// Swipe progress — independent on both axes so a vertical- // Swipe progress — locked to a single axis (see below) so a
// dominant gesture that picks up horizontal drift still drives // gesture only ever drives one of the vertical panels or the
// the pager. // horizontal pager, never both.
if let Some( start ) = self.start if let Some( start ) = self.start
{ {
let dy = start.y - pos.y; let dy = start.y - pos.y;
let dx = pos.x - start.x; let dx = pos.x - start.x;
let ( up, down ) = if swipe.surface_height > 0 // Lock onto the dominant axis on the first 8 px of travel so
// vertical and horizontal swipes stay mutually exclusive.
if self.swipe_axis.is_none() && dx.abs().max( dy.abs() ) > 8.0
{
self.swipe_axis = Some( if dy.abs() >= dx.abs() { SwipeAxis::Vertical } else { SwipeAxis::Horizontal } );
}
let vertical_allowed = self.swipe_axis != Some( SwipeAxis::Horizontal );
let horizontal_allowed = self.swipe_axis != Some( SwipeAxis::Vertical );
let ( up, down ) = if vertical_allowed && swipe.surface_height > 0
{ {
let h = swipe.surface_height as f32; let h = swipe.surface_height as f32;
if dy > 0.0 if dy > 0.0
{ {
let threshold = h * swipe.up_thresh; let threshold = h * swipe.up_thresh;
if dy.abs() > 8.0 { self.vertical_drag_started = true; } if dy.abs() > 8.0 { self.vertical_drag_started = true; }
( Some( ( dy / threshold ).clamp( 0.0, 1.0 ) ), None ) // Unclamped on purpose — lets follow-the-finger
// panels track past the commit threshold.
( Some( ( dy / threshold ).max( 0.0 ) ), None )
} }
else if start.y <= h * swipe.down_edge else if start.y <= h * swipe.down_edge
{ {
@@ -364,7 +392,7 @@ impl<Msg: Clone> GestureState<Msg>
} }
else { ( None, None ) }; else { ( None, None ) };
let horizontal = if swipe.surface_width > 0 let horizontal = if horizontal_allowed && swipe.surface_width > 0
{ {
let h_thr = swipe.surface_width as f32 * swipe.horizontal_thresh; let h_thr = swipe.surface_width as f32 * swipe.horizontal_thresh;
let h_progress = if h_thr > 0.0 { dx / h_thr } else { 0.0 }; let h_progress = if h_thr > 0.0 { dx / h_thr } else { 0.0 };

View File

@@ -309,10 +309,41 @@ fn move_horizontal_eleven_pixels_arms_horizontal_drag()
} }
#[ test ] #[ test ]
fn move_up_progress_clamps_to_unit_interval() fn vertical_lock_suppresses_later_horizontal_drift()
{
// A swipe that locks vertical must not arm the horizontal pager even
// if the finger later drifts sideways past the 8 px threshold.
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
g.start = Some( pt( 100.0, 600.0 ) );
let mut offsets = HashMap::new();
let _ = g.on_move( pt( 100.0, 560.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert_eq!( g.swipe_axis, Some( SwipeAxis::Vertical ) );
let out = g.on_move( pt( 140.0, 540.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert!( !g.horizontal_drag_started );
assert!( matches!( out, MoveOutcome::Swipe { horizontal: None, .. } ) );
}
#[ test ]
fn horizontal_lock_suppresses_later_vertical_drift()
{
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new();
g.start = Some( pt( 100.0, 600.0 ) );
let mut offsets = HashMap::new();
let _ = g.on_move( pt( 140.0, 600.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert_eq!( g.swipe_axis, Some( SwipeAxis::Horizontal ) );
let out = g.on_move( pt( 160.0, 560.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert!( !g.vertical_drag_started );
assert!( matches!( out, MoveOutcome::Swipe { up: None, down: None, .. } ) );
}
#[ test ]
fn move_up_progress_unclamped_past_unit_interval()
{ {
// Surface 1000 px tall, threshold 30 % = 300 px. A 600 px upward drag // Surface 1000 px tall, threshold 30 % = 300 px. A 600 px upward drag
// must clamp progress to 1.0 instead of overshooting to 2.0. // overshoots to 2.0 — up progress is unclamped so follow-the-finger
// panels can keep tracking past the commit threshold.
let widgets: Vec<LaidOutWidget<Msg>> = vec![]; let widgets: Vec<LaidOutWidget<Msg>> = vec![];
let mut g = GestureState::<Msg>::new(); let mut g = GestureState::<Msg>::new();
g.start = Some( pt( 100.0, 800.0 ) ); g.start = Some( pt( 100.0, 800.0 ) );
@@ -321,7 +352,7 @@ fn move_up_progress_clamps_to_unit_interval()
match out match out
{ {
MoveOutcome::Swipe { up: Some( v ), .. } => MoveOutcome::Swipe { up: Some( v ), .. } =>
assert!( ( v - 1.0 ).abs() < 1e-6, "expected clamp to 1.0, got {v}" ), assert!( ( v - 2.0 ).abs() < 1e-6, "expected unclamped 2.0, got {v}" ),
_ => panic!( "expected Swipe with up=Some" ), _ => panic!( "expected Swipe with up=Some" ),
} }
} }

View File

@@ -289,9 +289,30 @@ impl<A: App> TouchHandler for AppData<A>
fn cancel( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _touch: &WlTouch ) fn cancel( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _touch: &WlTouch )
{ {
// Snapshot every auxiliary slot's last position so the app self.reset_touch_state();
// can be notified with a meaningful release point, then drop }
// every per-surface slot in one pass. }
impl<A: App> AppData<A>
{
/// Drop every in-flight touch gesture across all surfaces, notifying
/// the app of any auxiliary slots that were still down. Shared by the
/// `wl_touch.cancel` handler and the touch-capability add / remove path
/// (suspend / resume on devices that power the touchscreen down): a
/// yanked capability never delivers the pending `up` / `cancel`, so
/// without this the stale `primary_touch_id` / gesture state strands and
/// the first gesture after resume is misrouted and lost. Returns `true`
/// when it actually had state to clear.
pub( crate ) fn reset_touch_state( &mut self ) -> bool
{
let had_state = self.main.primary_touch_id.is_some()
|| !self.main.touch_slots.is_empty()
|| !self.touch_focus.is_empty()
|| self.overlays.values().any( |ss| ss.primary_touch_id.is_some() || !ss.touch_slots.is_empty() );
// Snapshot every auxiliary slot's last position so the app can be
// notified with a meaningful release point, then drop every
// per-surface slot in one pass.
let mut aux_releases: Vec<( i32, crate::types::Point )> = Vec::new(); let mut aux_releases: Vec<( i32, crate::types::Point )> = Vec::new();
let collect = |ss: &mut SurfaceState<A::Message>, out: &mut Vec<( i32, crate::types::Point )>| let collect = |ss: &mut SurfaceState<A::Message>, out: &mut Vec<( i32, crate::types::Point )>|
{ {
@@ -310,5 +331,6 @@ impl<A: App> TouchHandler for AppData<A>
self.app.on_touch_up( id as i64, pos.x, pos.y ); self.app.on_touch_up( id as i64, pos.x, pos.y );
} }
self.stop_button_repeat(); self.stop_button_repeat();
had_state
} }
} }

View File

@@ -122,19 +122,19 @@ impl<Msg: Clone> Column<Msg>
#[ inline ] #[ inline ]
fn resolved_spacing( &self, canvas: &Canvas ) -> f32 fn resolved_spacing( &self, canvas: &Canvas ) -> f32
{ {
self.spacing.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) self.spacing.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT )
} }
#[ inline ] #[ inline ]
fn resolved_padding( &self, canvas: &Canvas ) -> f32 fn resolved_padding( &self, canvas: &Canvas ) -> f32
{ {
self.padding.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) self.padding.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT )
} }
#[ inline ] #[ inline ]
fn resolved_max_width( &self, canvas: &Canvas ) -> Option<f32> fn resolved_max_width( &self, canvas: &Canvas ) -> Option<f32>
{ {
self.max_width.map( |l| l.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) ) self.max_width.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) )
} }
/// Report the intrinsic content width as preferred width instead of filling /// Report the intrinsic content width as preferred width instead of filling

View File

@@ -88,13 +88,13 @@ impl<Msg: Clone> Row<Msg>
#[ inline ] #[ inline ]
fn resolved_spacing( &self, canvas: &Canvas ) -> f32 fn resolved_spacing( &self, canvas: &Canvas ) -> f32
{ {
self.spacing.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) self.spacing.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT )
} }
#[ inline ] #[ inline ]
fn resolved_padding( &self, canvas: &Canvas ) -> f32 fn resolved_padding( &self, canvas: &Canvas ) -> f32
{ {
self.padding.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) self.padding.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT )
} }
/// Push the content block to the right edge of the available width. /// Push the content block to the right edge of the available width.

View File

@@ -115,7 +115,7 @@ impl Spacer
/// weighted by `weight`. /// weighted by `weight`.
pub fn preferred_size( &self, canvas: &Canvas ) -> ( f32, f32 ) pub fn preferred_size( &self, canvas: &Canvas ) -> ( f32, f32 )
{ {
let vp = canvas.viewport_logical(); let vp = canvas.viewport_layout();
let em = Length::EM_BASE_DEFAULT; let em = Length::EM_BASE_DEFAULT;
( (
self.fixed_width .map( |l| l.resolve( vp, em ) ).unwrap_or( 0.0 ), self.fixed_width .map( |l| l.resolve( vp, em ) ).unwrap_or( 0.0 ),
@@ -128,12 +128,12 @@ impl Spacer
/// layout only needs the main-axis size for one orientation. /// layout only needs the main-axis size for one orientation.
pub fn resolved_height( &self, canvas: &Canvas ) -> Option<f32> pub fn resolved_height( &self, canvas: &Canvas ) -> Option<f32>
{ {
self.fixed_height.map( |l| l.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) ) self.fixed_height.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) )
} }
pub fn resolved_width( &self, canvas: &Canvas ) -> Option<f32> pub fn resolved_width( &self, canvas: &Canvas ) -> Option<f32>
{ {
self.fixed_width.map( |l| l.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) ) self.fixed_width.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) )
} }
/// No-op — spacers are invisible. /// No-op — spacers are invisible.

View File

@@ -95,7 +95,7 @@ impl<Msg: Clone> WrapGrid<Msg>
fn resolved( &self, canvas: &Canvas ) -> ( f32, f32, f32 ) fn resolved( &self, canvas: &Canvas ) -> ( f32, f32, f32 )
{ {
let vp = canvas.viewport_logical(); let vp = canvas.viewport_layout();
let em = Length::EM_BASE_DEFAULT; let em = Length::EM_BASE_DEFAULT;
( (
self.spacing_x.resolve( vp, em ), self.spacing_x.resolve( vp, em ),

View File

@@ -207,7 +207,7 @@ pub mod core;
pub use app:: pub use app::
{ {
Anchor, App, ChannelSender, ShellMode, Layer, OverlayId, OverlaySpec, Anchor, App, ChannelSender, ShellMode, Layer, OverlayId, OverlaySpec,
SubsurfaceId, SubsurfaceSpec, SubsurfaceId, SubsurfaceSpec, SubsurfaceParent,
InvalidationScope, SurfaceTarget, ToplevelEvent, InvalidationScope, SurfaceTarget, ToplevelEvent,
}; };
pub use theme:: pub use theme::

View File

@@ -224,6 +224,19 @@ impl Canvas
} }
} }
/// `(width, height)` of the surface in **physical** pixels — the same
/// space the layout tree is computed in (the root rect is `pw × ph`).
/// This is the viewport that layout-affecting [`crate::Length`] values
/// (widths, paddings, gaps, widget sizes) must resolve against so a
/// `Vw(100)` fills the surface. Text font sizes are the exception:
/// they resolve against [`Self::viewport_logical`] and are then scaled
/// by `dpi_scale` at raster time, so they must NOT use this.
pub fn viewport_layout( &self ) -> ( f32, f32 )
{
let ( pw, ph ) = self.size();
( pw as f32, ph as f32 )
}
/// Borrow the GLES texture backing this canvas, when the canvas /// Borrow the GLES texture backing this canvas, when the canvas
/// is GPU-backed. /// is GPU-backed.
pub fn borrowed_gles_texture( &self ) -> Option<BorrowedGlesTexture> pub fn borrowed_gles_texture( &self ) -> Option<BorrowedGlesTexture>
@@ -697,4 +710,16 @@ mod viewport_tests
c.set_dpi_scale( 0.0 ); c.set_dpi_scale( 0.0 );
assert_eq!( c.viewport_logical(), ( 800.0, 600.0 ) ); assert_eq!( c.viewport_logical(), ( 800.0, 600.0 ) );
} }
#[ test ]
fn viewport_layout_is_physical_and_ignores_dpi_scale()
{
// Layout-affecting `Length` values resolve against this, so it must
// stay in physical space (where the layout tree is computed) even on
// HiDPI — unlike `viewport_logical`, it does not divide by the scale.
let mut c = Canvas::new( 720, 1440 );
assert_eq!( c.viewport_layout(), ( 720.0, 1440.0 ) );
c.set_dpi_scale( 2.0 );
assert_eq!( c.viewport_layout(), ( 720.0, 1440.0 ) );
}
} }

View File

@@ -275,7 +275,7 @@ impl<Msg: Clone> Container<Msg>
/// Return the preferred `(width, height)` accounting for padding. /// Return the preferred `(width, height)` accounting for padding.
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 ) pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
{ {
let vp = canvas.viewport_logical(); let vp = canvas.viewport_layout();
let em = Length::EM_BASE_DEFAULT; let em = Length::EM_BASE_DEFAULT;
let pad_l = self.pad_left.resolve( vp, em ); let pad_l = self.pad_left.resolve( vp, em );
let pad_r = self.pad_right.resolve( vp, em ); let pad_r = self.pad_right.resolve( vp, em );

View File

@@ -16,10 +16,13 @@ use crate::render::Canvas;
/// ///
/// ```rust,no_run /// ```rust,no_run
/// # use std::sync::Arc; /// # use std::sync::Arc;
/// # use ltk::{ img_widget, Element }; /// # use ltk::{ img_widget, Element, Length };
/// # #[ derive( Clone ) ] enum Msg {} /// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex( rgba_bytes: Arc<Vec<u8>>, width: u32, height: u32 ) -> Element<Msg> { /// # fn _ex( rgba_bytes: Arc<Vec<u8>>, width: u32, height: u32 ) -> Element<Msg> {
/// // Display at 40 % of the viewport width by 20 % of its height instead of
/// // the source's intrinsic pixel size — scales across screens with no tweaks.
/// img_widget( rgba_bytes, width, height ) /// img_widget( rgba_bytes, width, height )
/// .size( Length::vw( 40.0 ), Length::vh( 20.0 ) )
/// .opacity( 0.8 ) /// .opacity( 0.8 )
/// .into() /// .into()
/// # } /// # }
@@ -34,7 +37,7 @@ pub struct Image
pub height: u32, pub height: u32,
/// When `true` the image scales to fill the available width (cover mode). /// When `true` the image scales to fill the available width (cover mode).
pub cover: bool, pub cover: bool,
/// Optional explicit display size in logical units. /// Optional explicit display size (Length values, resolved at layout time).
pub display_size: Option<( Length, Length )>, pub display_size: Option<( Length, Length )>,
/// Opacity multiplier in `[0.0, 1.0]`. Default: `1.0`. /// Opacity multiplier in `[0.0, 1.0]`. Default: `1.0`.
pub opacity: f32, pub opacity: f32,
@@ -66,7 +69,8 @@ impl Image
self self
} }
/// Set an explicit display size in logical units. /// Set an explicit display size. Accepts logical `f32` pixels or any
/// [`Length`] variant (e.g. `Length::vw(13.0)` for 13 % of viewport width).
pub fn size( mut self, width: impl Into<Length>, height: impl Into<Length> ) -> Self pub fn size( mut self, width: impl Into<Length>, height: impl Into<Length> ) -> Self
{ {
self.display_size = Some( ( width.into(), height.into() ) ); self.display_size = Some( ( width.into(), height.into() ) );
@@ -81,15 +85,16 @@ impl Image
} }
/// Return the preferred `(width, height)` given available `max_width`. /// Return the preferred `(width, height)` given available `max_width`.
/// `canvas` is used to resolve viewport-relative [`Length`] values.
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32) pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{ {
if let Some( ( width, height ) ) = self.display_size if let Some( ( w, h ) ) = &self.display_size
{ {
let viewport = canvas.viewport_logical(); let vp = canvas.viewport_layout();
return ( let em = Length::EM_BASE_DEFAULT;
width.resolve( viewport, Length::EM_BASE_DEFAULT ).max( 0.0 ), let rw = w.resolve( vp, em ).max( 0.0 );
height.resolve( viewport, Length::EM_BASE_DEFAULT ).max( 0.0 ), let rh = h.resolve( vp, em ).max( 0.0 );
); return ( rw, rh );
} }
if self.cover if self.cover
{ {

View File

@@ -2,12 +2,18 @@
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net> // Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use super::*; use super::*;
use crate::render::Canvas;
fn solid_rgba( w: u32, h: u32 ) -> Arc<Vec<u8>> fn solid_rgba( w: u32, h: u32 ) -> Arc<Vec<u8>>
{ {
Arc::new( vec![ 255u8; ( w * h * 4 ) as usize ] ) Arc::new( vec![ 255u8; ( w * h * 4 ) as usize ] )
} }
fn dummy_canvas() -> Canvas
{
Canvas::new( 800, 600 )
}
// ── builders / defaults ─────────────────────────────────────────────────── // ── builders / defaults ───────────────────────────────────────────────────
#[ test ] #[ test ]
@@ -57,8 +63,7 @@ fn preferred_size_default_scales_height_to_match_width_aspect()
{ {
// 200×100 source image at max_width = 400 → scale = 2 → height 200. // 200×100 source image at max_width = 400 → scale = 2 → height 200.
let img = Image::new( solid_rgba( 200, 100 ), 200, 100 ); let img = Image::new( solid_rgba( 200, 100 ), 200, 100 );
let canvas = Canvas::new( 480, 900 ); let ( w, h ) = img.preferred_size( 400.0, &dummy_canvas() );
let ( w, h ) = img.preferred_size( 400.0, &canvas );
assert_eq!( w, 400.0 ); assert_eq!( w, 400.0 );
assert_eq!( h, 200.0 ); assert_eq!( h, 200.0 );
} }
@@ -67,8 +72,7 @@ fn preferred_size_default_scales_height_to_match_width_aspect()
fn preferred_size_with_explicit_size_wins_over_aspect_logic() fn preferred_size_with_explicit_size_wins_over_aspect_logic()
{ {
let img = Image::new( solid_rgba( 200, 100 ), 200, 100 ).size( 50.0, 25.0 ); let img = Image::new( solid_rgba( 200, 100 ), 200, 100 ).size( 50.0, 25.0 );
let canvas = Canvas::new( 480, 900 ); assert_eq!( img.preferred_size( 1000.0, &dummy_canvas() ), ( 50.0, 25.0 ) );
assert_eq!( img.preferred_size( 1000.0, &canvas ), ( 50.0, 25.0 ) );
} }
#[ test ] #[ test ]
@@ -84,8 +88,7 @@ fn preferred_size_cover_mode_keeps_aspect_ratio()
{ {
// 100×50 source in cover mode at max_width = 300 → height = 300 * (50/100) = 150. // 100×50 source in cover mode at max_width = 300 → height = 300 * (50/100) = 150.
let img = Image::new( solid_rgba( 100, 50 ), 100, 50 ).cover(); let img = Image::new( solid_rgba( 100, 50 ), 100, 50 ).cover();
let canvas = Canvas::new( 480, 900 ); let ( w, h ) = img.preferred_size( 300.0, &dummy_canvas() );
let ( w, h ) = img.preferred_size( 300.0, &canvas );
assert_eq!( w, 300.0 ); assert_eq!( w, 300.0 );
assert_eq!( h, 150.0 ); assert_eq!( h, 150.0 );
} }
@@ -98,8 +101,8 @@ fn preferred_size_cover_and_default_match_for_uniform_scaling()
// when the source is taller than wide. // when the source is taller than wide.
let normal = Image::new( solid_rgba( 100, 50 ), 100, 50 ); let normal = Image::new( solid_rgba( 100, 50 ), 100, 50 );
let covered = Image::new( solid_rgba( 100, 50 ), 100, 50 ).cover(); let covered = Image::new( solid_rgba( 100, 50 ), 100, 50 ).cover();
let canvas = Canvas::new( 480, 900 ); let c = dummy_canvas();
assert_eq!( normal.preferred_size( 200.0, &canvas ), covered.preferred_size( 200.0, &canvas ) ); assert_eq!( normal.preferred_size( 200.0, &c ), covered.preferred_size( 200.0, &c ) );
} }
// ── Arc sharing ─────────────────────────────────────────────────────────── // ── Arc sharing ───────────────────────────────────────────────────────────

View File

@@ -65,9 +65,9 @@ pub struct VSlider<Msg: Clone>
/// Current value in `[0.0, 1.0]`. `0.0` paints no fill; `1.0` fills the /// Current value in `[0.0, 1.0]`. `0.0` paints no fill; `1.0` fills the
/// whole pill. /// whole pill.
pub value: f32, pub value: f32,
/// Fixed width of the pill in logical units. Defaults to 56 px. /// Width of the pill. Defaults to 56 px; accepts any [`Length`].
pub width: Length, pub width: Length,
/// Fixed height of the pill in logical units. Defaults to 160 px. /// Height of the pill. Defaults to 160 px; accepts any [`Length`].
pub height: Length, pub height: Length,
/// Callback invoked with the new value when the slider is tapped or /// Callback invoked with the new value when the slider is tapped or
/// dragged. `Arc` (not `Box`) so the layout pass can clone it into the /// dragged. `Arc` (not `Box`) so the layout pass can clone it into the
@@ -117,9 +117,10 @@ impl<Msg: Clone> VSlider<Msg>
self self
} }
/// Override the fixed pill `(width, height)` in logical units. Both are /// Override the pill size. Accepts logical `f32` pixels or any [`Length`]
/// clamped to a minimum of `2.0` after viewport-relative values resolve, /// variant (e.g. `Length::vw(16.0)` for 16 % of the viewport width). Both
/// so a rounded pill can always be drawn. /// are clamped to a minimum of `2.0` after viewport-relative values
/// resolve, so a rounded pill can always be drawn.
pub fn size( mut self, width: impl Into<Length>, height: impl Into<Length> ) -> Self pub fn size( mut self, width: impl Into<Length>, height: impl Into<Length> ) -> Self
{ {
self.width = width.into(); self.width = width.into();
@@ -138,11 +139,11 @@ impl<Msg: Clone> VSlider<Msg>
/// the type-level docs on intrinsic sizing. /// the type-level docs on intrinsic sizing.
pub fn preferred_size( &self, _max_width: f32, canvas: &Canvas ) -> (f32, f32) pub fn preferred_size( &self, _max_width: f32, canvas: &Canvas ) -> (f32, f32)
{ {
let viewport = canvas.viewport_logical(); let vp = canvas.viewport_layout();
( let em = Length::EM_BASE_DEFAULT;
self.width.resolve( viewport, Length::EM_BASE_DEFAULT ).max( 2.0 ), let w = self.width.resolve( vp, em ).max( 2.0 );
self.height.resolve( viewport, Length::EM_BASE_DEFAULT ).max( 2.0 ), let h = self.height.resolve( vp, em ).max( 2.0 );
) ( w, h )
} }
/// Compute the value `[0.0, 1.0]` from a tap/drag y position within `rect`. /// Compute the value `[0.0, 1.0]` from a tap/drag y position within `rect`.