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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user