Mature ltk to host an externally-laid-out Android view tree
Add the primitives rustdroid needs to project an Android view hierarchy onto an ltk surface, all kept general rather than Android-specific. Canvas gains arbitrary vector path fill and stroke: `Canvas::fill_path` / `stroke_path` over a new `PathCmd` command list (MoveTo/LineTo/QuadTo/CubicTo/Close in surface coordinates). The software backend rasterises directly with tiny-skia; the GLES backend rasterises into a tiny-skia pixmap and blits it (CPU fallback, no GPU path shader). This is what renders an Android `Path`, a `VectorDrawable`, or a Lottie frame. `ExternalSource::Cpu` (and the `External::cpu` constructor) adds an immediate-mode CPU drawing closure, invoked once per frame with the canvas and the widget's laid-out rect, working on both the GLES and software backends. It hosts a custom `View.onDraw` straight onto the ltk canvas without a GL texture round-trip, unlike the existing `Texture` source which only renders on GLES. `Stack::push_placed` appends a child at an exact rect, bypassing alignment and intrinsic sizing. This lets a view tree whose geometry is computed elsewhere — Android's measure/layout pass, which yields an absolute rect per view — be projected onto a Stack in paint order. New `RichText` widget: wrapped paragraph text carrying a message per clickable link range, with the layout pass emitting one hit rect per link line so taps land on the link rather than the whole paragraph. It is the ltk side of an Android `Spanned` carrying `URLSpan` / `ClickableSpan`. gesture: only drive the horizontal pager once the axis locks horizontal `on_move` emitted horizontal swipe progress whenever `swipe_axis != Vertical`, which includes the pre-lock window where `swipe_axis` is still `None` (the first ~8 px of travel). But `horizontal_drag_started` only flips once `dx.abs() > 8`. A vertical gesture that opened with a few pixels of lateral drift — a swipe-up to the launcher, a scroll — therefore emitted a tiny horizontal progress sample, which armed the consumer's pager, then locked vertical without `dx` ever passing 8, so `horizontal_drag_started` stayed false. On release the horizontal branch was skipped, no `on_swipe_horizontal_progress(0.0)` fired, and the pager stayed stuck active — on the crustace homescreen that froze the surface (the main stays motion-only behind stale page subsurfaces) until an unrelated gesture reset it. Emit horizontal progress only once `swipe_axis == Some(Horizontal)`. Locking onto the horizontal axis already implies `dx.abs() > 8`, so `horizontal_drag_started` is set in the same step, restoring the invariant that a frame which drives the pager always has a matching release event to settle it. The cost is that the first ~8 px of a horizontal drag no longer move the page, which is the same deadband the axis lock already imposes on the vertical panels. Adds `pre_lock_lateral_drift_does_not_drive_horizontal_pager`.
This commit is contained in:
@@ -44,6 +44,18 @@ impl<A: App> AppData<A>
|
||||
{
|
||||
self.surface_mut( focus ).request_redraw();
|
||||
}
|
||||
MoveOutcome::OverscrollDown { progress } =>
|
||||
{
|
||||
self.app.on_overscroll_down_progress( progress );
|
||||
// Like a vertical swipe, the dismiss rides an overlay panel
|
||||
// (subsurface) over a static main, so refresh the overlays but
|
||||
// leave the main alone — re-rastering it every motion is waste.
|
||||
self.overlays_dirty = true;
|
||||
for ss in self.overlays.values_mut()
|
||||
{
|
||||
ss.request_redraw();
|
||||
}
|
||||
}
|
||||
MoveOutcome::Swipe { up, down, horizontal } =>
|
||||
{
|
||||
if let Some( v ) = up { self.app.on_swipe_progress( v ); }
|
||||
@@ -58,7 +70,12 @@ impl<A: App> AppData<A>
|
||||
// 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()
|
||||
// ...unless the app is sliding the pager on subsurfaces
|
||||
// (`subsurface_motion_only`): then the main is unchanged and the
|
||||
// slide is a per-iteration subsurface reposition, so re-rastering
|
||||
// the full main every motion event is the same waste the vertical
|
||||
// panels already avoid — and, with child subsurfaces, it flickers.
|
||||
if horizontal.is_some() && !self.app.subsurface_motion_only()
|
||||
{
|
||||
self.view_dirty = true;
|
||||
if let SurfaceFocus::Main = focus
|
||||
@@ -172,6 +189,21 @@ impl<A: App> AppData<A>
|
||||
ss.frame_pending = false;
|
||||
}
|
||||
}
|
||||
ReleaseEvent::OverscrollDown { committed } =>
|
||||
{
|
||||
if let Some( msg ) = self.app.on_overscroll_down( committed )
|
||||
{
|
||||
self.pending_msgs.push( msg );
|
||||
}
|
||||
self.dirty_caches();
|
||||
self.main.request_redraw();
|
||||
self.main.frame_pending = false;
|
||||
for ss in self.overlays.values_mut()
|
||||
{
|
||||
ss.request_redraw();
|
||||
ss.frame_pending = false;
|
||||
}
|
||||
}
|
||||
ReleaseEvent::HorizontalFellThrough =>
|
||||
{
|
||||
// Below threshold: pulse horizontal 0 so a
|
||||
|
||||
@@ -128,6 +128,12 @@ pub struct GestureState<Msg: Clone>
|
||||
/// `app.on_drop`. Set by the touch deadline when `drag_start_msg`
|
||||
/// is present, or by the mouse-motion promotion path.
|
||||
pub long_press_fired: bool,
|
||||
/// Accumulated downward overscroll (physical px) while a vertical
|
||||
/// scroll is pinned at its top — built up by [`Self::on_move`] when a
|
||||
/// pull-down can no longer scroll content, unwound by upward drag, and
|
||||
/// drained on release into an `OverscrollDown` event. Drives
|
||||
/// pull-from-top-to-dismiss.
|
||||
pub overscroll_y: f32,
|
||||
/// `true` when the press came from a mouse (`wl_pointer`) rather
|
||||
/// than touch. The 6 px stray-cancel in [`Self::on_move`] is gated
|
||||
/// on this: touch needs the cancel so a hold that turns into a
|
||||
@@ -168,6 +174,7 @@ impl<Msg: Clone> GestureState<Msg>
|
||||
drag_start_msg: None,
|
||||
long_press_text_idx: None,
|
||||
long_press_fired: false,
|
||||
overscroll_y: 0.0,
|
||||
mouse_press: false,
|
||||
}
|
||||
}
|
||||
@@ -204,6 +211,7 @@ impl<Msg: Clone> GestureState<Msg>
|
||||
self.scrolling_widget = self.scroll_candidates.first().copied();
|
||||
self.scroll_locked = false;
|
||||
self.scroll_drag_started = false;
|
||||
self.overscroll_y = 0.0;
|
||||
self.horizontal_drag_started = false;
|
||||
self.vertical_drag_started = false;
|
||||
self.swipe_axis = None;
|
||||
@@ -341,12 +349,43 @@ impl<Msg: Clone> GestureState<Msg>
|
||||
let ( scroll_idx, axis ) = self.scrolling_widget.unwrap();
|
||||
let entry = scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) );
|
||||
if axis.allows_x() { entry.0 = ( entry.0 - dx ).max( 0.0 ); }
|
||||
if axis.allows_y() { entry.1 = ( entry.1 - dy ).max( 0.0 ); }
|
||||
if axis.allows_y()
|
||||
{
|
||||
// Split the vertical delta between content scroll and top
|
||||
// overscroll: a downward pull first scrolls toward the top,
|
||||
// then any excess (the offset can't go below 0) feeds
|
||||
// `overscroll_y`; an upward pull unwinds that overscroll
|
||||
// before it resumes scrolling content.
|
||||
if dy >= 0.0
|
||||
{
|
||||
let consume = dy.min( entry.1 );
|
||||
entry.1 -= consume;
|
||||
self.overscroll_y += dy - consume;
|
||||
}
|
||||
else
|
||||
{
|
||||
let up = -dy;
|
||||
let consume = up.min( self.overscroll_y );
|
||||
self.overscroll_y -= consume;
|
||||
entry.1 += up - consume;
|
||||
}
|
||||
}
|
||||
let moved = if axis.allows_x() && axis.allows_y() { dx.hypot( dy ) }
|
||||
else if axis.allows_x() { dx.abs() }
|
||||
else { dy.abs() };
|
||||
if moved > 8.0 { self.scroll_drag_started = true; }
|
||||
self.start = Some( pos );
|
||||
// Gate the dismiss on the same 8 px arm as a real scroll drag so
|
||||
// tap jitter at the top can't engage a follow-the-finger close.
|
||||
if self.overscroll_y > 0.0 && axis.allows_y() && self.scroll_drag_started
|
||||
{
|
||||
let progress = if swipe.surface_height > 0
|
||||
{
|
||||
self.overscroll_y / swipe.surface_height as f32
|
||||
}
|
||||
else { 0.0 };
|
||||
return MoveOutcome::OverscrollDown { progress };
|
||||
}
|
||||
return MoveOutcome::Scroll;
|
||||
}
|
||||
return MoveOutcome::Idle;
|
||||
@@ -366,8 +405,7 @@ impl<Msg: Clone> GestureState<Msg>
|
||||
{
|
||||
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 vertical_allowed = self.swipe_axis != Some( SwipeAxis::Horizontal );
|
||||
|
||||
let ( up, down ) = if vertical_allowed && swipe.surface_height > 0
|
||||
{
|
||||
@@ -392,11 +430,18 @@ impl<Msg: Clone> GestureState<Msg>
|
||||
}
|
||||
else { ( None, None ) };
|
||||
|
||||
let horizontal = if horizontal_allowed && swipe.surface_width > 0
|
||||
// Only drive the horizontal pager once the gesture has LOCKED onto the
|
||||
// horizontal axis. Emitting progress while `swipe_axis` is still None
|
||||
// would activate the pager from the sub-8 px lateral jitter at the
|
||||
// start of a vertical swipe; `horizontal_drag_started` would then never
|
||||
// trip, so the release emits no horizontal event and the pager stays
|
||||
// stuck active (frozen homescreen). Locking to Horizontal implies
|
||||
// `dx.abs() > 8.0`, so the drag is unambiguously started here.
|
||||
let horizontal = if self.swipe_axis == Some( SwipeAxis::Horizontal ) && 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 };
|
||||
if dx.abs() > 8.0 { self.horizontal_drag_started = true; }
|
||||
self.horizontal_drag_started = true;
|
||||
Some( h_progress )
|
||||
}
|
||||
else { None };
|
||||
@@ -474,6 +519,15 @@ impl<Msg: Clone> GestureState<Msg>
|
||||
if scroll_consumed
|
||||
{
|
||||
self.start = None;
|
||||
// A pull past the top releases into an overscroll-dismiss event;
|
||||
// the app decides whether the accumulated pull committed.
|
||||
let over = self.overscroll_y;
|
||||
self.overscroll_y = 0.0;
|
||||
if over > 0.0
|
||||
{
|
||||
let threshold = swipe.surface_height as f32 * swipe.overscroll_thresh;
|
||||
events.push( ReleaseEvent::OverscrollDown { committed: over >= threshold } );
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,10 @@ pub enum MoveOutcome<Msg>
|
||||
Slider { msg: Option<Msg> },
|
||||
/// Scroll viewport offset updated. Handler just requests a redraw.
|
||||
Scroll,
|
||||
/// Vertical scroll pinned at the top while the finger keeps pulling
|
||||
/// down: `progress` is the accumulated overscroll as a fraction of
|
||||
/// surface height. Handler fires `app.on_overscroll_down_progress`.
|
||||
OverscrollDown { progress: f32 },
|
||||
/// Swipe progress. Each axis is `Some(value)` when it has a valid
|
||||
/// progress reading for this move (not every motion updates all
|
||||
/// axes). Handler fires the matching app callback.
|
||||
@@ -50,6 +54,10 @@ pub enum ReleaseEvent<Msg>
|
||||
SwipeUp,
|
||||
/// Vertical swipe committed down.
|
||||
SwipeDown,
|
||||
/// Released after a top overscroll pull-down — fire
|
||||
/// `app.on_overscroll_down(committed)`. `committed` is set when the
|
||||
/// pull passed `overscroll_thresh` of surface height.
|
||||
OverscrollDown { committed: bool },
|
||||
/// Horizontal swipe did not commit — fire
|
||||
/// `app.on_swipe_horizontal_progress(0.0)` + main redraw.
|
||||
HorizontalFellThrough,
|
||||
@@ -79,6 +87,9 @@ pub struct SwipeConfig
|
||||
pub down_edge: f32,
|
||||
/// Horizontal commit fraction of surface width.
|
||||
pub horizontal_thresh: f32,
|
||||
/// Top-overscroll dismiss commit fraction of surface height — the
|
||||
/// pull-down past a scroll's top must exceed this to commit.
|
||||
pub overscroll_thresh: f32,
|
||||
pub surface_width: u32,
|
||||
pub surface_height: u32,
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ fn cfg_full( w: u32, h: u32 ) -> SwipeConfig
|
||||
down_thresh: 0.30,
|
||||
down_edge: 0.10,
|
||||
horizontal_thresh: 0.30,
|
||||
overscroll_thresh: 0.25,
|
||||
surface_width: w,
|
||||
surface_height: h,
|
||||
}
|
||||
@@ -269,6 +270,76 @@ fn move_below_eight_pixels_does_not_arm_scroll_drag()
|
||||
assert!( !g.scroll_drag_started );
|
||||
}
|
||||
|
||||
// ── on_move / on_release: top overscroll dismiss ──────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn pull_down_at_top_emits_overscroll_progress()
|
||||
{
|
||||
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
|
||||
let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 3, ScrollAxis::Vertical ) ];
|
||||
let mut g = GestureState::<Msg>::new();
|
||||
let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
|
||||
let mut offsets = HashMap::new();
|
||||
// Already at the top; drag down 120 px → all of it is overscroll and the
|
||||
// content offset stays pinned at 0.
|
||||
let out = g.on_move( pt( 100.0, 220.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
|
||||
assert_eq!( offsets.get( &3 ).copied(), Some( ( 0.0, 0.0 ) ) );
|
||||
match out
|
||||
{
|
||||
MoveOutcome::OverscrollDown { progress } =>
|
||||
assert!( ( progress - 120.0 / 1200.0 ).abs() < 1e-4 ),
|
||||
_ => panic!( "expected OverscrollDown" ),
|
||||
}
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn upward_drag_unwinds_overscroll_before_scrolling_content()
|
||||
{
|
||||
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
|
||||
let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 4, ScrollAxis::Vertical ) ];
|
||||
let mut g = GestureState::<Msg>::new();
|
||||
let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
|
||||
let mut offsets = HashMap::new();
|
||||
let _ = g.on_move( pt( 100.0, 200.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
|
||||
assert!( ( g.overscroll_y - 100.0 ).abs() < 1e-4 );
|
||||
// Drag back up 60 px → unwinds 60 px of overscroll, content stays at top.
|
||||
let _ = g.on_move( pt( 100.0, 140.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
|
||||
assert!( ( g.overscroll_y - 40.0 ).abs() < 1e-4 );
|
||||
assert_eq!( offsets.get( &4 ).map( |o| o.1 ), Some( 0.0 ) );
|
||||
// A further 90 px up unwinds the remaining 40 and scrolls the 50 px excess.
|
||||
let _ = g.on_move( pt( 100.0, 50.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
|
||||
assert_eq!( g.overscroll_y, 0.0 );
|
||||
assert_eq!( offsets.get( &4 ).map( |o| o.1 ), Some( 50.0 ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn release_after_overscroll_past_threshold_commits_dismiss()
|
||||
{
|
||||
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
|
||||
let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 5, ScrollAxis::Vertical ) ];
|
||||
let mut g = GestureState::<Msg>::new();
|
||||
let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
|
||||
let mut offsets = HashMap::new();
|
||||
// cfg_full overscroll_thresh is 0.25 → 300 px of 1200 commits; drag 400 px.
|
||||
let _ = g.on_move( pt( 100.0, 500.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
|
||||
let events = g.on_release( pt( 100.0, 500.0 ), &widgets, &cfg_full( 800, 1200 ), false );
|
||||
assert!( events.iter().any( |e| matches!( e, ReleaseEvent::OverscrollDown { committed: true } ) ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn release_after_short_overscroll_does_not_commit()
|
||||
{
|
||||
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
|
||||
let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 6, ScrollAxis::Vertical ) ];
|
||||
let mut g = GestureState::<Msg>::new();
|
||||
let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
|
||||
let mut offsets = HashMap::new();
|
||||
// 100 px is below the 300 px commit threshold.
|
||||
let _ = g.on_move( pt( 100.0, 200.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
|
||||
let events = g.on_release( pt( 100.0, 200.0 ), &widgets, &cfg_full( 800, 1200 ), false );
|
||||
assert!( events.iter().any( |e| matches!( e, ReleaseEvent::OverscrollDown { committed: false } ) ) );
|
||||
}
|
||||
|
||||
// ── on_move: swipe progress ───────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
@@ -338,6 +409,23 @@ fn horizontal_lock_suppresses_later_vertical_drift()
|
||||
assert!( matches!( out, MoveOutcome::Swipe { up: None, down: None, .. } ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn pre_lock_lateral_drift_does_not_drive_horizontal_pager()
|
||||
{
|
||||
// Regression: a vertical swipe that opens with a few px of lateral drift
|
||||
// (axis not yet locked) must not emit horizontal progress. It used to, which
|
||||
// armed the pager without ever tripping `horizontal_drag_started` — so the
|
||||
// release sent no horizontal event and the pager stayed stuck active,
|
||||
// freezing the homescreen.
|
||||
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 out = g.on_move( pt( 103.0, 595.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
|
||||
assert_eq!( g.swipe_axis, None );
|
||||
assert!( matches!( out, MoveOutcome::Swipe { horizontal: None, .. } ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn move_up_progress_unclamped_past_unit_interval()
|
||||
{
|
||||
|
||||
@@ -24,6 +24,7 @@ impl<A: App> AppData<A>
|
||||
down_thresh: self.app.swipe_down_threshold(),
|
||||
down_edge: self.app.swipe_down_edge(),
|
||||
horizontal_thresh: self.app.swipe_horizontal_threshold(),
|
||||
overscroll_thresh: self.app.overscroll_dismiss_threshold(),
|
||||
surface_width: ss.physical_width(),
|
||||
surface_height: ss.physical_height(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user