From cfa0faff26bd3e083ad499b3e0f41f2e11dd39e1 Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Sun, 7 Jun 2026 16:45:59 +0200 Subject: [PATCH] =?UTF-8?q?ltk:=20subsurface=20slides=20over=20overlays,?= =?UTF-8?q?=20axis-locked=20swipes,=20physical-space=20layout,=20touch=20r?= =?UTF-8?q?eset=20on=20resume=20Subsurfaces=20can=20now=20be=20parented=20?= =?UTF-8?q?to=20an=20overlay=20surface,=20not=20just=20the=20main=20surfac?= =?UTF-8?q?e.=20`SubsurfaceSpec`=20gains=20`parent:=20SubsurfaceParent=20{?= =?UTF-8?q?=20Main,=20Overlay(id)=20}`=20so=20a=20sliding=20panel=20can=20?= =?UTF-8?q?ride=20above=20app=20windows=20the=20way=20an=20overlay=20panel?= =?UTF-8?q?=20does,=20and=20an=20optional=20`gpu:=20bool`=20so=20content?= =?UTF-8?q?=20that=20uses=20the=20`surface-panel`=20backdrop-filter=20glas?= =?UTF-8?q?s=20(a=20GLES-only=20pass)=20keeps=20it=20while=20sliding=20ins?= =?UTF-8?q?tead=20of=20dropping=20to=20the=20software=20rasteriser.=20`rec?= =?UTF-8?q?oncile=5Fsubsurfaces`=20resolves=20each=20spec's=20parent=20ind?= =?UTF-8?q?ependently=20=E2=80=94=20skipping=20an=20`Overlay`=20parent=20t?= =?UTF-8?q?hat=20is=20absent,=20unconfigured,=20or=20zero-sized=20?= =?UTF-8?q?=E2=80=94=20tracks=20the=20rastered=20size=20on=20the=20slot,?= =?UTF-8?q?=20and=20commits=20each=20touched=20parent=20once=20per=20frame?= =?UTF-8?q?.=20The=20~14=20GLES=20shader=20programs=20are=20compiled=20onc?= =?UTF-8?q?e=20into=20a=20shared=20`AppData::subsurface=5Fgles=5Fcanvas`?= =?UTF-8?q?=20reused=20across=20every=20subsurface,=20so=20a=20lazily=20re?= =?UTF-8?q?-created=20sliding=20panel=20never=20recompiles=20them=20(hundr?= =?UTF-8?q?eds=20of=20ms=20on=20a=20mobile=20GPU).=20Vertical=20and=20hori?= =?UTF-8?q?zontal=20swipes=20are=20now=20mutually=20exclusive:=20a=20gestu?= =?UTF-8?q?re=20locks=20onto=20its=20dominant=20axis=20within=20the=20firs?= =?UTF-8?q?t=208=20px=20of=20travel=20(new=20`SwipeAxis`)=20and=20ignores?= =?UTF-8?q?=20the=20perpendicular=20axis=20for=20the=20rest=20of=20the=20g?= =?UTF-8?q?esture,=20so=20a=20vertical=20swipe=20that=20drifts=20sideways?= =?UTF-8?q?=20no=20longer=20also=20drives=20the=20pager=20(and=20vice-vers?= =?UTF-8?q?a).=20The=20upward=20swipe=20progress=20is=20no=20longer=20clam?= =?UTF-8?q?ped=20at=201.0=20=E2=80=94=20follow-the-finger=20panels=20can?= =?UTF-8?q?=20keep=20tracking=20the=20finger=20past=20the=20commit=20thres?= =?UTF-8?q?hold=20=E2=80=94=20and=20a=20release=20below=20threshold=20deli?= =?UTF-8?q?vers=20a=20final=20`progress=20=3D=200.0`=20cancellation=20puls?= =?UTF-8?q?e.=20A=20vertical=20swipe=20also=20no=20longer=20re-rasters=20t?= =?UTF-8?q?he=20full-screen=20main=20surface=20on=20every=20motion=20event?= =?UTF-8?q?:=20only=20the=20overlays=20it=20drives=20are=20refreshed,=20wh?= =?UTF-8?q?ile=20the=20horizontal=20pager=20(which=20does=20move=20the=20m?= =?UTF-8?q?ain=20surface)=20still=20redraws=20it.=20Re-rastering=20the=20m?= =?UTF-8?q?ain=20on=20every=20frame=20of=20a=20vertical=20drag=20was=20was?= =?UTF-8?q?ted=20work=20that=20stalled=20the=20loop=20and=20made=20the=20g?= =?UTF-8?q?esture=20feel=20laggy=20to=20start=20on=20a=20slow=20GPU.=20Lay?= =?UTF-8?q?out-affecting=20`Length`=20values=20(widths,=20paddings,=20gaps?= =?UTF-8?q?,=20widget=20sizes,=20including=20`Vw`=20/=20`Vh`)=20now=20reso?= =?UTF-8?q?lve=20against=20the=20physical=20viewport=20via=20the=20new=20`?= =?UTF-8?q?Canvas::viewport=5Flayout()`=20=E2=80=94=20the=20space=20the=20?= =?UTF-8?q?layout=20tree=20is=20actually=20computed=20in=20=E2=80=94=20so?= =?UTF-8?q?=20a=20`Vw(100)`=20fills=20the=20surface=20on=20a=20HiDPI=20(sc?= =?UTF-8?q?ale=202)=20output=20instead=20of=20covering=20half=20of=20it.?= =?UTF-8?q?=20Font=20sizes=20are=20unchanged:=20they=20still=20resolve=20a?= =?UTF-8?q?gainst=20the=20logical=20viewport=20and=20are=20scaled=20at=20r?= =?UTF-8?q?aster=20time.=20Switched=20column,=20row,=20spacer,=20wrap=5Fgr?= =?UTF-8?q?id,=20container,=20image,=20and=20vslider=20over=20to=20it;=20`?= =?UTF-8?q?img=5Fwidget`=20and=20`vslider`=20now=20document=20`Length::vw`?= =?UTF-8?q?=20/=20`vh`=20sizing=20and=20the=20showcase=20example=20demonst?= =?UTF-8?q?rates=20a=20viewport-relative=20image.=20Touch=20gesture=20stat?= =?UTF-8?q?e=20is=20reset=20when=20the=20touch=20capability=20is=20added?= =?UTF-8?q?=20or=20removed=20=E2=80=94=20suspend=20/=20resume=20on=20devic?= =?UTF-8?q?es=20that=20power=20the=20touchscreen=20down.=20A=20yanked=20ca?= =?UTF-8?q?pability=20never=20delivers=20the=20pending=20`up`=20/=20`cance?= =?UTF-8?q?l`,=20so=20the=20shared=20`reset=5Ftouch=5Fstate()`=20(also=20u?= =?UTF-8?q?sed=20by=20the=20`wl=5Ftouch.cancel`=20handler)=20drops=20the?= =?UTF-8?q?=20stranded=20`primary=5Ftouch=5Fid`=20/=20slot=20state=20acros?= =?UTF-8?q?s=20the=20main=20surface=20and=20every=20overlay,=20keeping=20t?= =?UTF-8?q?he=20first=20post-resume=20gesture=20clean.=20Also=20drops=20an?= =?UTF-8?q?=20accidental=20duplicate=20`on=5Fscale=5Fchanged`=20call=20fro?= =?UTF-8?q?m=20the=20scale-change=20handler.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/showcase.rs | 40 +++++- src/app.rs | 40 +++++- src/draw/layout.rs | 2 +- src/event_loop/app_data.rs | 5 + src/event_loop/handlers.rs | 19 ++- src/event_loop/run.rs | 1 + src/event_loop/subsurface.rs | 231 ++++++++++++++++++++++++++------- src/input/dispatch/outcomes.rs | 28 ++-- src/input/gesture/mod.rs | 40 +++++- src/input/gesture/tests.rs | 37 +++++- src/input/touch/mod.rs | 28 +++- src/layout/column.rs | 6 +- src/layout/row.rs | 4 +- src/layout/spacer.rs | 6 +- src/layout/wrap_grid.rs | 2 +- src/lib.rs | 2 +- src/render/mod.rs | 25 ++++ src/widget/container/mod.rs | 2 +- src/widget/image/mod.rs | 23 ++-- src/widget/image/tests.rs | 19 +-- src/widget/vslider/mod.rs | 21 +-- 21 files changed, 466 insertions(+), 115 deletions(-) diff --git a/examples/showcase.rs b/examples/showcase.rs index ad01a1c..8e2d398 100644 --- a/examples/showcase.rs +++ b/examples/showcase.rs @@ -1,23 +1,43 @@ //! `cargo run --example showcase` //! -//! Shows button variants, container with background, a slider, plus the -//! newer additions: a tab strip, a spinner driven by the wall clock, a -//! multiline text edit, and on-demand toast / tooltip overlays. +//! Shows button variants, container with background, a slider, a +//! viewport-relative image, plus the newer additions: a tab strip, a spinner +//! 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 //! focused button. Esc exits. //! //! NOTE: ltk is a Wayland layer-shell toolkit. This example requires a running //! Wayland compositor (e.g. sway, labwc, or a full desktop session). +use std::sync::Arc; use std::time::{ Duration, Instant }; use ltk::{ - App, Element, Keysym, ButtonVariant, WidgetId, + App, Element, Keysym, ButtonVariant, Length, WidgetId, OverlaySpec, 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> +{ + 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 ────────────────────────────────────────────────────────────────── #[ derive( Clone ) ] @@ -43,6 +63,7 @@ struct ShowcaseApp started_at: Instant, toast_until: Option, tooltip_visible: bool, + image: Arc>, } impl ShowcaseApp @@ -58,6 +79,7 @@ impl ShowcaseApp started_at: Instant::now(), toast_until: None, tooltip_visible: false, + image: gradient_rgba(), } } @@ -127,6 +149,13 @@ impl App for ShowcaseApp .radius( 12.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::() + .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 ); // Spinner — phase comes straight from the wall clock so the arc @@ -163,6 +192,7 @@ impl App for ShowcaseApp .push( spacer() ) .push( buttons ) .push( card ) + .push( banner ) .push( text( vol_label ).size( 13.0 ).color( secondary ) ) .push( slider( self.slider_value ).on_change( Message::SliderChanged ) ) .push( spin_row ) diff --git a/src/app.rs b/src/app.rs index 9c28e3e..0a1db11 100644 --- a/src/app.rs +++ b/src/app.rs @@ -124,6 +124,18 @@ pub struct OverlayId( pub u32 ); #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord )] 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 /// [`InvalidationScope::Only`] to name the affected surfaces. #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash )] @@ -279,6 +291,10 @@ pub struct SubsurfaceSpec /// Stable identifier, used to diff subsurfaces between frames. 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 /// background if it must cover the main surface beneath it. pub view: Element, @@ -295,6 +311,13 @@ pub struct SubsurfaceSpec /// differently; leave it unchanged for position-only updates so the /// runtime skips the re-raster and only repositions. 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. @@ -381,11 +404,18 @@ pub trait App: 'static /// `swipe_threshold()` × screen height and then releases. fn on_swipe_up( &mut self ) -> Option { 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 /// 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 ) {} /// 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 ) {} /// 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 - /// pixels can keep using [`on_resize`](Self::on_resize). + /// main surface. `scale` is the integer buffer scale (1 = standard DPI, + /// 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 ) {} /// Called once at startup with a channel sender that can be used from any diff --git a/src/draw/layout.rs b/src/draw/layout.rs index bd7deaa..6b51db6 100644 --- a/src/draw/layout.rs +++ b/src/draw/layout.rs @@ -195,7 +195,7 @@ pub( crate ) fn layout_and_draw( { 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 pad_l = c.pad_left.resolve( vp, em ); let pad_r = c.pad_right.resolve( vp, em ); diff --git a/src/event_loop/app_data.rs b/src/event_loop/app_data.rs index b59e643..ea16a19 100644 --- a/src/event_loop/app_data.rs +++ b/src/event_loop/app_data.rs @@ -221,6 +221,11 @@ pub struct AppData /// Input-transparent child surfaces keyed by their stable /// [`SubsurfaceId`]. Reconciled each frame from [`App::subsurfaces`]. pub subsurfaces: HashMap, + /// 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, /// Surface currently receiving pointer events (updated on each pointer /// event via its `surface` field). pub pointer_focus: SurfaceFocus, diff --git a/src/event_loop/handlers.rs b/src/event_loop/handlers.rs index 6a998e7..8c491ce 100644 --- a/src/event_loop/handlers.rs +++ b/src/event_loop/handlers.rs @@ -324,6 +324,13 @@ impl SeatHandler for AppData } 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.seat_state .get_touch( qh, &seat ) @@ -346,7 +353,17 @@ impl SeatHandler for AppData { Capability::Keyboard => { self.keyboard = 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" ); + } + } _ => {} } } diff --git a/src/event_loop/run.rs b/src/event_loop/run.rs index 0a6f77f..52cdefb 100644 --- a/src/event_loop/run.rs +++ b/src/event_loop/run.rs @@ -321,6 +321,7 @@ pub( crate ) fn try_run( app: A ) -> Result<(), RunError> main: SurfaceState::::new( surface_kind, titlebar_height, titlebar_title ), overlays: std::collections::HashMap::new(), subsurfaces: std::collections::HashMap::new(), + subsurface_gles_canvas: None, pointer_focus: SurfaceFocus::Main, keyboard_focus: SurfaceFocus::Main, touch_focus: std::collections::HashMap::new(), diff --git a/src/event_loop/subsurface.rs b/src/event_loop/subsurface.rs index e6d88b4..2432445 100644 --- a/src/event_loop/subsurface.rs +++ b/src/event_loop/subsurface.rs @@ -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_surface::WlSurface; -use crate::app::App; +use crate::app::{ App, SubsurfaceParent }; +use crate::egl_context::{ EglContext, EglSurface }; use crate::draw::DrawCtx; use crate::draw::chrome::apply_input_region; use crate::draw::layout_and_draw; @@ -46,46 +47,75 @@ pub( crate ) struct SubsurfaceSlot { subsurface: WlSubsurface, surface: WlSurface, + /// SHM pool for the software raster path. `None` on the GLES path. pool: Option, + /// 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, canvas: Option, last_pos: ( i32, i32 ), + last_size: ( u32, u32 ), last_version: u64, rastered: bool, + parent: SubsurfaceParent, } 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.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( data: &AppData, 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 /// 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( data: &mut AppData ) { if data.subcompositor.is_none() { 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 specs: Vec> = 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. let present: std::collections::HashSet = @@ -98,19 +128,25 @@ pub( crate ) fn reconcile_subsurfaces( data: &mut AppData ) { if let Some( slot ) = data.subsurfaces.remove( &id ) { + let parent = slot.parent; 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 { + let Some( ( parent_wl, pw, ph, scale ) ) = resolve_parent( data, spec.parent ) else { continue }; + // Create on first sight. if !data.subsurfaces.contains_key( &spec.id ) { let ( subsurface, surface ) = { 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 // parent commit we issue below. @@ -121,17 +157,21 @@ pub( crate ) fn reconcile_subsurfaces( data: &mut AppData ) subsurface, surface, pool: None, + egl_surface: None, canvas: None, last_pos: ( spec.x, spec.y ), + last_size: ( 0, 0 ), last_version: u64::MAX, 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 ) - .and_then( |s| s.canvas.as_ref() ) - .map( |c| c.size() != ( pw, ph ) ) + .map( |s| s.last_size != ( pw, ph ) ) .unwrap_or( true ); let needs_raster = { let slot = data.subsurfaces.get( &spec.id ).unwrap(); @@ -140,14 +180,19 @@ pub( crate ) fn reconcile_subsurfaces( data: &mut AppData ) 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(); render_slot::( - 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, ); slot.rastered = true; 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(); @@ -160,20 +205,134 @@ pub( crate ) fn reconcile_subsurfaces( data: &mut AppData ) // move actually lands. The parent commit below applies placement. slot.surface.commit(); 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 // 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() -> DrawCtx +{ + 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( + slot: &mut SubsurfaceSlot, + egl_ctx: Option<&Arc>, + gles_canvas: &mut Option, + shm: &Shm, + compositor: &CompositorState, + view: &Element, + 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::( slot, ctx, gles_canvas, compositor, view, pw, ph, scale, size_changed ); + } + else + { + render_slot_software::( slot, shm, compositor, view, pw, ph, scale, shm_format, swap_rb, size_changed ); } } #[ allow( clippy::too_many_arguments ) ] -fn render_slot( +fn render_slot_gpu( + slot: &mut SubsurfaceSlot, + egl_ctx: &Arc, + gles_canvas: &mut Option, + compositor: &CompositorState, + view: &Element, + 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::(); + layout_and_draw::( 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( slot: &mut SubsurfaceSlot, shm: &Shm, compositor: &CompositorState, @@ -222,23 +381,7 @@ fn render_slot( canvas.clear(); let screen_rect = Rect { x: 0.0, y: 0.0, width: pw as f32, height: ph as f32 }; - let mut ctx: DrawCtx = 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, - }; + let mut ctx = empty_draw_ctx::(); layout_and_draw::( view, canvas, screen_rect, &mut ctx, 0 ); canvas.write_to_wayland_buf( canvas_buf, swap_rb ); diff --git a/src/input/dispatch/outcomes.rs b/src/input/dispatch/outcomes.rs index e274c26..c618e65 100644 --- a/src/input/dispatch/outcomes.rs +++ b/src/input/dispatch/outcomes.rs @@ -49,20 +49,28 @@ impl AppData 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 ) = horizontal { self.app.on_swipe_horizontal_progress( v ); } - // Swipe-progress callbacks mutate app state outside - // `update`, so the cached view tree is stale — unless the - // motion only slides a subsurface over a static main - // surface, in which case the per-frame subsurface reconcile - // carries the move and a main re-raster is wasted work. - if !self.app.subsurface_motion_only() + // Swipe-progress callbacks mutate app state outside `update`, + // so the cached trees are stale and the affected surfaces must + // redraw. Only the HORIZONTAL pager moves the main surface, + // though — vertical swipes drive overlay panels and leave the + // main static. Re-rastering the (full-screen) main on every + // 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.surface_mut( focus ).request_redraw(); - for ss in self.overlays.values_mut() + self.view_dirty = true; + if let SurfaceFocus::Main = focus { - ss.request_redraw(); + self.main.request_redraw(); } } + self.overlays_dirty = true; + for ss in self.overlays.values_mut() + { + ss.request_redraw(); + } } } } diff --git a/src/input/gesture/mod.rs b/src/input/gesture/mod.rs index eda48c6..9024d9c 100644 --- a/src/input/gesture/mod.rs +++ b/src/input/gesture/mod.rs @@ -47,6 +47,17 @@ mod tests; // ─── 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`. /// /// Every field is `pub` because `AppData`'s long-press deadline @@ -81,6 +92,10 @@ pub struct GestureState /// Vertical drag exceeded the 8 px threshold. Release runs the /// swipe-up / swipe-down commit check instead of the button path. 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, /// Slider widget being dragged for a live-value update. pub dragging_slider: Option, /// Instant when the long-press window opened. `None` if the hit @@ -145,6 +160,7 @@ impl GestureState scroll_drag_started: false, horizontal_drag_started: false, vertical_drag_started: false, + swipe_axis: None, dragging_slider: None, long_press_start: None, long_press_origin: None, @@ -190,6 +206,7 @@ impl GestureState self.scroll_drag_started = false; self.horizontal_drag_started = false; self.vertical_drag_started = false; + self.swipe_axis = None; self.pressed_idx = hit; // A fresh press resets any stale long-press / source state // from a prior gesture that never released cleanly. The @@ -335,22 +352,33 @@ impl GestureState return MoveOutcome::Idle; } - // Swipe progress — independent on both axes so a vertical- - // dominant gesture that picks up horizontal drift still drives - // the pager. + // Swipe progress — locked to a single axis (see below) so a + // gesture only ever drives one of the vertical panels or the + // horizontal pager, never both. if let Some( start ) = self.start { let dy = start.y - pos.y; 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; if dy > 0.0 { let threshold = h * swipe.up_thresh; 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 { @@ -364,7 +392,7 @@ impl GestureState } 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_progress = if h_thr > 0.0 { dx / h_thr } else { 0.0 }; diff --git a/src/input/gesture/tests.rs b/src/input/gesture/tests.rs index 24b395f..9fd26a6 100644 --- a/src/input/gesture/tests.rs +++ b/src/input/gesture/tests.rs @@ -309,10 +309,41 @@ fn move_horizontal_eleven_pixels_arms_horizontal_drag() } #[ 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> = vec![]; + let mut g = GestureState::::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> = vec![]; + let mut g = GestureState::::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 - // 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> = vec![]; let mut g = GestureState::::new(); g.start = Some( pt( 100.0, 800.0 ) ); @@ -321,7 +352,7 @@ fn move_up_progress_clamps_to_unit_interval() match out { 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" ), } } diff --git a/src/input/touch/mod.rs b/src/input/touch/mod.rs index f64781f..b4091ca 100644 --- a/src/input/touch/mod.rs +++ b/src/input/touch/mod.rs @@ -289,9 +289,30 @@ impl TouchHandler for AppData fn cancel( &mut self, _conn: &Connection, _qh: &QueueHandle, _touch: &WlTouch ) { - // 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. + self.reset_touch_state(); + } +} + +impl AppData +{ + /// 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 collect = |ss: &mut SurfaceState, out: &mut Vec<( i32, crate::types::Point )>| { @@ -310,5 +331,6 @@ impl TouchHandler for AppData self.app.on_touch_up( id as i64, pos.x, pos.y ); } self.stop_button_repeat(); + had_state } } diff --git a/src/layout/column.rs b/src/layout/column.rs index 7cb29dd..a051ec2 100644 --- a/src/layout/column.rs +++ b/src/layout/column.rs @@ -122,19 +122,19 @@ impl Column #[ inline ] 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 ] 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 ] fn resolved_max_width( &self, canvas: &Canvas ) -> Option { - 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 diff --git a/src/layout/row.rs b/src/layout/row.rs index ee77327..993e16b 100644 --- a/src/layout/row.rs +++ b/src/layout/row.rs @@ -88,13 +88,13 @@ impl Row #[ inline ] 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 ] 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. diff --git a/src/layout/spacer.rs b/src/layout/spacer.rs index ad7b825..2b64f66 100644 --- a/src/layout/spacer.rs +++ b/src/layout/spacer.rs @@ -115,7 +115,7 @@ impl Spacer /// weighted by `weight`. 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; ( 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. pub fn resolved_height( &self, canvas: &Canvas ) -> Option { - 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 { - 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. diff --git a/src/layout/wrap_grid.rs b/src/layout/wrap_grid.rs index 242b0db..9eb2c70 100644 --- a/src/layout/wrap_grid.rs +++ b/src/layout/wrap_grid.rs @@ -95,7 +95,7 @@ impl WrapGrid fn resolved( &self, canvas: &Canvas ) -> ( f32, f32, f32 ) { - let vp = canvas.viewport_logical(); + let vp = canvas.viewport_layout(); let em = Length::EM_BASE_DEFAULT; ( self.spacing_x.resolve( vp, em ), diff --git a/src/lib.rs b/src/lib.rs index edf1760..c8f136f 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -207,7 +207,7 @@ pub mod core; pub use app:: { Anchor, App, ChannelSender, ShellMode, Layer, OverlayId, OverlaySpec, - SubsurfaceId, SubsurfaceSpec, + SubsurfaceId, SubsurfaceSpec, SubsurfaceParent, InvalidationScope, SurfaceTarget, ToplevelEvent, }; pub use theme:: diff --git a/src/render/mod.rs b/src/render/mod.rs index 77e57b4..aa1c62d 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -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 /// is GPU-backed. pub fn borrowed_gles_texture( &self ) -> Option @@ -697,4 +710,16 @@ mod viewport_tests c.set_dpi_scale( 0.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 ) ); + } } diff --git a/src/widget/container/mod.rs b/src/widget/container/mod.rs index cd1d840..8f456cd 100644 --- a/src/widget/container/mod.rs +++ b/src/widget/container/mod.rs @@ -275,7 +275,7 @@ impl Container /// Return the preferred `(width, height)` accounting for padding. 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 pad_l = self.pad_left.resolve( vp, em ); let pad_r = self.pad_right.resolve( vp, em ); diff --git a/src/widget/image/mod.rs b/src/widget/image/mod.rs index 1f1042b..e557d12 100644 --- a/src/widget/image/mod.rs +++ b/src/widget/image/mod.rs @@ -16,10 +16,13 @@ use crate::render::Canvas; /// /// ```rust,no_run /// # use std::sync::Arc; -/// # use ltk::{ img_widget, Element }; +/// # use ltk::{ img_widget, Element, Length }; /// # #[ derive( Clone ) ] enum Msg {} /// # fn _ex( rgba_bytes: Arc>, width: u32, height: u32 ) -> Element { +/// // 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 ) +/// .size( Length::vw( 40.0 ), Length::vh( 20.0 ) ) /// .opacity( 0.8 ) /// .into() /// # } @@ -34,7 +37,7 @@ pub struct Image pub height: u32, /// When `true` the image scales to fill the available width (cover mode). 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 )>, /// Opacity multiplier in `[0.0, 1.0]`. Default: `1.0`. pub opacity: f32, @@ -66,7 +69,8 @@ impl Image 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, height: impl Into ) -> Self { self.display_size = Some( ( width.into(), height.into() ) ); @@ -81,15 +85,16 @@ impl Image } /// 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) { - if let Some( ( width, height ) ) = self.display_size + if let Some( ( w, h ) ) = &self.display_size { - let viewport = canvas.viewport_logical(); - return ( - width.resolve( viewport, Length::EM_BASE_DEFAULT ).max( 0.0 ), - height.resolve( viewport, Length::EM_BASE_DEFAULT ).max( 0.0 ), - ); + let vp = canvas.viewport_layout(); + let em = Length::EM_BASE_DEFAULT; + let rw = w.resolve( vp, em ).max( 0.0 ); + let rh = h.resolve( vp, em ).max( 0.0 ); + return ( rw, rh ); } if self.cover { diff --git a/src/widget/image/tests.rs b/src/widget/image/tests.rs index 066f1d6..5b07c20 100644 --- a/src/widget/image/tests.rs +++ b/src/widget/image/tests.rs @@ -2,12 +2,18 @@ // Copyright (C) 2026 Liberux Labs, S. L. use super::*; +use crate::render::Canvas; fn solid_rgba( w: u32, h: u32 ) -> Arc> { Arc::new( vec![ 255u8; ( w * h * 4 ) as usize ] ) } +fn dummy_canvas() -> Canvas +{ + Canvas::new( 800, 600 ) +} + // ── builders / defaults ─────────────────────────────────────────────────── #[ 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. let img = Image::new( solid_rgba( 200, 100 ), 200, 100 ); - let canvas = Canvas::new( 480, 900 ); - let ( w, h ) = img.preferred_size( 400.0, &canvas ); + let ( w, h ) = img.preferred_size( 400.0, &dummy_canvas() ); assert_eq!( w, 400.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() { 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, &canvas ), ( 50.0, 25.0 ) ); + assert_eq!( img.preferred_size( 1000.0, &dummy_canvas() ), ( 50.0, 25.0 ) ); } #[ 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. 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, &canvas ); + let ( w, h ) = img.preferred_size( 300.0, &dummy_canvas() ); assert_eq!( w, 300.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. let normal = Image::new( solid_rgba( 100, 50 ), 100, 50 ); let covered = Image::new( solid_rgba( 100, 50 ), 100, 50 ).cover(); - let canvas = Canvas::new( 480, 900 ); - assert_eq!( normal.preferred_size( 200.0, &canvas ), covered.preferred_size( 200.0, &canvas ) ); + let c = dummy_canvas(); + assert_eq!( normal.preferred_size( 200.0, &c ), covered.preferred_size( 200.0, &c ) ); } // ── Arc sharing ─────────────────────────────────────────────────────────── diff --git a/src/widget/vslider/mod.rs b/src/widget/vslider/mod.rs index 274506f..c652355 100644 --- a/src/widget/vslider/mod.rs +++ b/src/widget/vslider/mod.rs @@ -65,9 +65,9 @@ pub struct VSlider /// Current value in `[0.0, 1.0]`. `0.0` paints no fill; `1.0` fills the /// whole pill. 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, - /// 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, /// 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 @@ -117,9 +117,10 @@ impl VSlider self } - /// Override the fixed pill `(width, height)` in logical units. Both are - /// clamped to a minimum of `2.0` after viewport-relative values resolve, - /// so a rounded pill can always be drawn. + /// Override the pill size. Accepts logical `f32` pixels or any [`Length`] + /// variant (e.g. `Length::vw(16.0)` for 16 % of the viewport width). Both + /// 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, height: impl Into ) -> Self { self.width = width.into(); @@ -138,11 +139,11 @@ impl VSlider /// the type-level docs on intrinsic sizing. pub fn preferred_size( &self, _max_width: f32, canvas: &Canvas ) -> (f32, f32) { - let viewport = canvas.viewport_logical(); - ( - self.width.resolve( viewport, Length::EM_BASE_DEFAULT ).max( 2.0 ), - self.height.resolve( viewport, Length::EM_BASE_DEFAULT ).max( 2.0 ), - ) + let vp = canvas.viewport_layout(); + let em = Length::EM_BASE_DEFAULT; + let w = self.width.resolve( vp, em ).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`.