ltk: subsurface slides over overlays, axis-locked swipes, physical-space layout, touch reset on resume
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:
40
src/app.rs
40
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<Message: Clone>
|
||||
/// 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<Message>,
|
||||
@@ -295,6 +311,13 @@ pub struct SubsurfaceSpec<Message: Clone>
|
||||
/// 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<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
|
||||
/// 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
|
||||
|
||||
@@ -195,7 +195,7 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
|
||||
{
|
||||
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 );
|
||||
|
||||
@@ -221,6 +221,11 @@ pub struct AppData<A: App>
|
||||
/// Input-transparent child surfaces keyed by their stable
|
||||
/// [`SubsurfaceId`]. Reconciled each frame from [`App::subsurfaces`].
|
||||
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
|
||||
/// event via its `surface` field).
|
||||
pub pointer_focus: SurfaceFocus,
|
||||
|
||||
@@ -324,6 +324,13 @@ impl<A: App> SeatHandler for AppData<A>
|
||||
}
|
||||
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<A: App> SeatHandler for AppData<A>
|
||||
{
|
||||
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" );
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ),
|
||||
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(),
|
||||
|
||||
@@ -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<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>,
|
||||
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<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
|
||||
/// 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> )
|
||||
{
|
||||
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<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.
|
||||
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 )
|
||||
{
|
||||
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<A: App>( data: &mut AppData<A> )
|
||||
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<A: App>( data: &mut AppData<A> )
|
||||
|
||||
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::<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,
|
||||
);
|
||||
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<A: App>( data: &mut AppData<A> )
|
||||
// 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<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 ) ]
|
||||
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,
|
||||
shm: &Shm,
|
||||
compositor: &CompositorState,
|
||||
@@ -222,23 +381,7 @@ fn render_slot<Msg: Clone>(
|
||||
canvas.clear();
|
||||
|
||||
let screen_rect = Rect { x: 0.0, y: 0.0, width: pw as f32, height: ph as f32 };
|
||||
let mut ctx: 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,
|
||||
};
|
||||
let mut ctx = empty_draw_ctx::<Msg>();
|
||||
layout_and_draw::<Msg>( view, canvas, screen_rect, &mut ctx, 0 );
|
||||
|
||||
canvas.write_to_wayland_buf( canvas_buf, swap_rb );
|
||||
|
||||
@@ -49,20 +49,28 @@ impl<A: App> AppData<A>
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Msg: Clone>
|
||||
/// 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<SwipeAxis>,
|
||||
/// Slider widget being dragged for a live-value update.
|
||||
pub dragging_slider: Option<usize>,
|
||||
/// Instant when the long-press window opened. `None` if the hit
|
||||
@@ -145,6 +160,7 @@ impl<Msg: Clone> GestureState<Msg>
|
||||
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<Msg: Clone> GestureState<Msg>
|
||||
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<Msg: Clone> GestureState<Msg>
|
||||
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<Msg: Clone> GestureState<Msg>
|
||||
}
|
||||
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 };
|
||||
|
||||
@@ -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<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
|
||||
// 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 mut g = GestureState::<Msg>::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" ),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,9 +289,30 @@ impl<A: App> TouchHandler for AppData<A>
|
||||
|
||||
fn cancel( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _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<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 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.stop_button_repeat();
|
||||
had_state
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,19 +122,19 @@ impl<Msg: Clone> Column<Msg>
|
||||
#[ 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<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
|
||||
|
||||
@@ -88,13 +88,13 @@ impl<Msg: Clone> Row<Msg>
|
||||
#[ 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.
|
||||
|
||||
@@ -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<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>
|
||||
{
|
||||
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.
|
||||
|
||||
@@ -95,7 +95,7 @@ impl<Msg: Clone> WrapGrid<Msg>
|
||||
|
||||
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 ),
|
||||
|
||||
@@ -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::
|
||||
|
||||
@@ -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<BorrowedGlesTexture>
|
||||
@@ -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 ) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ impl<Msg: Clone> Container<Msg>
|
||||
/// 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 );
|
||||
|
||||
@@ -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<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 )
|
||||
/// .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<Length>, height: impl Into<Length> ) -> 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
|
||||
{
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
use super::*;
|
||||
use crate::render::Canvas;
|
||||
|
||||
fn solid_rgba( w: u32, h: u32 ) -> Arc<Vec<u8>>
|
||||
{
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
/// 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<Msg: Clone> VSlider<Msg>
|
||||
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<Length>, height: impl Into<Length> ) -> Self
|
||||
{
|
||||
self.width = width.into();
|
||||
@@ -138,11 +139,11 @@ impl<Msg: Clone> VSlider<Msg>
|
||||
/// 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`.
|
||||
|
||||
Reference in New Issue
Block a user