input, render: keep fast touch typing whole, fire pattern nodes once, and make the software image path cheap
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

Two ways a tap was lost on a phone. The gesture machine serves one finger: a second finger landing while the first was still down went to the auxiliary slot and never reached it, so with two thumbs every overlapping tap on a keypad vanished. A finger landing on a primary that is still a plain press (GestureState::is_plain_press: no drag, scroll, swipe or slider in flight) now commits that press as a tap at its last position and takes the primary slot; the earlier finger's release reports through on_touch_up, and a second finger during a pattern stroke or a slider drag stays out as before. The touch-up body moves to release_primary so both paths share it. The other loss needed one finger: the 8 px travel that turns a press into a scroll, swipe or drag, and the 6 px stray that cancels a touch long-press, were compared against physical coordinates, so at buffer scale 2 they were 4 and 3 logical px and an ordinary thumb tap became a vertical drag whose release skipped the button. SwipeConfig carries the surface's scale and the machine reads the thresholds through slop() and stray(), which are logical.
Pattern grids fired on_node on every motion event within a node's snap radius, and the Slider move outcome requested a repaint each time, so drawing a pattern rebuilt and re-rastered the surface at the touchscreen's motion rate. The machine remembers the node under the finger (last_pattern_node) and emits only on change; the handlers split pattern_node_at from pattern_msg, with pattern_node_msg kept on top of them; a move outcome without a message no longer requests a redraw.
SoftwareCanvas::draw_image_data premultiplied the whole source in floating point on every call, so a full-surface backdrop paid a pass over every pixel per frame before the blit. Premultiplied copies now live in a 32 MiB least-recently-drawn cache keyed by size, content fingerprint and alpha, the key the GLES texture cache already used; fingerprint_rgba moves to render::helpers and both backends share it. The entry records whether the source was fully opaque, and an opaque image drawn 1:1 with no clip and full alpha is copied row by row into the canvas instead of going through tiny-skia's bilinear pipeline.
docs/architecture.md describes the primary-slot hand-over, docs/widgets.md the once-per-node contract, and the CHANGELOG lists the five fixes.
This commit is contained in:
2026-09-12 10:43:51 +02:00
parent 0a4afd8f81
commit 75be800390
13 changed files with 270 additions and 94 deletions
+1 -1
View File
@@ -37,8 +37,8 @@ impl<A: App> AppData<A>
if let Some( m ) = msg
{
self.pending_msgs.push( m );
self.surface_mut( focus ).request_redraw();
}
self.surface_mut( focus ).request_redraw();
}
MoveOutcome::Scroll =>
{
+31 -9
View File
@@ -144,6 +144,9 @@ pub struct GestureState<Msg: Clone>
/// the pointer Press handler right after [`Self::on_press`]; left
/// `false` for touch presses.
pub mouse_press: bool,
/// Pattern-grid node the finger was last over, so `on_node` fires
/// once per node rather than once per motion event.
pub last_pattern_node: Option<u8>,
}
impl<Msg: Clone> Default for GestureState<Msg>
@@ -176,9 +179,22 @@ impl<Msg: Clone> GestureState<Msg>
long_press_fired: false,
overscroll_y: 0.0,
mouse_press: false,
last_pattern_node: None,
}
}
/// A press that has not become anything else yet: no drag, scroll,
/// swipe or slider in flight. Its release would be a tap.
pub fn is_plain_press( &self ) -> bool
{
self.start.is_some()
&& self.dragging_slider.is_none()
&& !self.scroll_drag_started
&& !self.horizontal_drag_started
&& !self.vertical_drag_started
&& !self.long_press_fired
}
/// Press / touch-down: record the gesture origin, capture long-press
/// handler if the hit widget has one, identify slider / scroll
/// targets. Returns the hit index (for keyboard focus update) and
@@ -256,8 +272,10 @@ impl<Msg: Clone> GestureState<Msg>
let value = w.handlers.slider_value_from_pos( w.rect, pos );
initial_slider_msg = w.handlers.slider_change_msg( value );
} else if w.handlers.is_drag_target() {
self.dragging_slider = Some( idx );
initial_slider_msg = w.handlers.pattern_node_msg( w.rect, pos );
self.dragging_slider = Some( idx );
let node = w.handlers.pattern_node_at( w.rect, pos );
self.last_pattern_node = node;
initial_slider_msg = node.and_then( |n| w.handlers.pattern_msg( n ) );
}
}
}
@@ -303,7 +321,7 @@ impl<Msg: Clone> GestureState<Msg>
if let Some( origin ) = self.long_press_origin
{
let d = ( pos.x - origin.x ).hypot( pos.y - origin.y );
if d > 6.0
if d > swipe.stray()
{
self.long_press_start = None;
self.long_press_origin = None;
@@ -324,7 +342,10 @@ impl<Msg: Clone> GestureState<Msg>
let value = w.handlers.slider_value_from_pos( w.rect, pos );
w.handlers.slider_change_msg( value )
} else {
w.handlers.pattern_node_msg( w.rect, pos )
let node = w.handlers.pattern_node_at( w.rect, pos );
if node == self.last_pattern_node { return MoveOutcome::Idle; }
self.last_pattern_node = node;
node.and_then( |n| w.handlers.pattern_msg( n ) )
};
return MoveOutcome::Slider { msg };
}
@@ -340,7 +361,7 @@ impl<Msg: Clone> GestureState<Msg>
if !self.scroll_locked
{
if dx.abs() <= 8.0 && dy.abs() <= 8.0
if dx.abs() <= swipe.slop() && dy.abs() <= swipe.slop()
{
return MoveOutcome::Idle;
}
@@ -381,7 +402,7 @@ impl<Msg: Clone> GestureState<Msg>
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; }
if moved > swipe.slop() { 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.
@@ -409,7 +430,7 @@ impl<Msg: Clone> GestureState<Msg>
// 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
if self.swipe_axis.is_none() && dx.abs().max( dy.abs() ) > swipe.slop()
{
self.swipe_axis = Some( if dy.abs() >= dx.abs() { SwipeAxis::Vertical } else { SwipeAxis::Horizontal } );
}
@@ -421,7 +442,7 @@ impl<Msg: Clone> GestureState<Msg>
if dy > 0.0
{
let threshold = h * swipe.up_thresh;
if dy.abs() > 8.0 { self.vertical_drag_started = true; }
if dy.abs() > swipe.slop() { self.vertical_drag_started = true; }
// Unclamped on purpose — lets follow-the-finger
// panels track past the commit threshold.
( Some( ( dy / threshold ).max( 0.0 ) ), None )
@@ -429,7 +450,7 @@ impl<Msg: Clone> GestureState<Msg>
else if start.y <= h * swipe.down_edge
{
let threshold = h * swipe.down_thresh;
if dy.abs() > 8.0 { self.vertical_drag_started = true; }
if dy.abs() > swipe.slop() { self.vertical_drag_started = true; }
// Unclamped on purpose — lets follow-the-finger
// panels track past the commit threshold.
( None, Some( ( -dy / threshold ).max( 0.0 ) ) )
@@ -501,6 +522,7 @@ impl<Msg: Clone> GestureState<Msg>
self.mouse_press = false;
self.horizontal_drag_started = false;
self.vertical_drag_started = false;
self.last_pattern_node = None;
let mut events = Vec::new();
+18
View File
@@ -92,4 +92,22 @@ pub struct SwipeConfig
pub overscroll_thresh: f32,
pub surface_width: u32,
pub surface_height: u32,
/// Buffer scale of the surface (physical px per logical px), so the
/// motion thresholds below stay a logical size on a HiDPI panel.
pub scale: f32,
}
impl SwipeConfig
{
/// Travel past which a press becomes a scroll, swipe or drag: 8 logical px.
pub fn slop( &self ) -> f32
{
8.0 * self.scale.max( 1.0 )
}
/// Travel past which a touch hold stops being a long-press candidate: 6 logical px.
pub fn stray( &self ) -> f32
{
6.0 * self.scale.max( 1.0 )
}
}
+1
View File
@@ -87,6 +87,7 @@ fn cfg_full( w: u32, h: u32 ) -> SwipeConfig
overscroll_thresh: 0.25,
surface_width: w,
surface_height: h,
scale: 1.0,
}
}
+1
View File
@@ -27,6 +27,7 @@ impl<A: App> AppData<A>
overscroll_thresh: self.app.overscroll_dismiss_threshold(),
surface_width: ss.physical_width(),
surface_height: ss.physical_height(),
scale: ss.scale_factor.max( 1 ) as f32,
}
}
}
+36 -14
View File
@@ -63,6 +63,23 @@ impl<A: App> TouchHandler for AppData<A>
if self.is_secondary_surface( &surface ) { return; }
let focus = self.focus_for_surface( &surface ).unwrap_or( SurfaceFocus::Main );
self.touch_focus.insert( id, focus );
// A second finger landing on a plain press means the first was a
// tap: commit it at its last position and hand the slot over, so
// alternating thumbs never lose a key.
if !self.app.claims_raw_touch()
{
let takeover =
{
let ss = self.surface( focus );
ss.primary_touch_id.is_some_and( |primary| primary != id ) && ss.gesture.is_plain_press()
};
if takeover
{
self.release_primary( focus );
}
}
let pos = self.surface( focus ).to_physical( position.0, position.1 );
self.pointer_pos = pos;
@@ -226,20 +243,7 @@ impl<A: App> TouchHandler for AppData<A>
return;
}
// Touch-up does not carry a position in wl_touch — the last
// motion's position is the release point.
let pos = self.pointer_pos;
let global_drag = self.has_active_long_press_drag();
let swipe = self.swipe_config( focus );
let events_out =
{
let ss = self.surface_mut( focus );
ss.needs_redraw = true;
ss.primary_touch_id = None;
ss.gesture.on_release( pos, &ss.frame.widget_rects, &swipe, global_drag )
};
self.apply_release_events( focus, events_out );
self.stop_button_repeat();
self.release_primary( focus );
}
fn motion(
@@ -334,6 +338,24 @@ impl<A: App> TouchHandler for AppData<A>
impl<A: App> AppData<A>
{
/// Release the primary slot's gesture. Touch-up carries no position in
/// wl_touch, so the last motion's position is the release point.
fn release_primary( &mut self, focus: SurfaceFocus )
{
let pos = self.pointer_pos;
let global_drag = self.has_active_long_press_drag();
let swipe = self.swipe_config( focus );
let events_out =
{
let ss = self.surface_mut( focus );
ss.needs_redraw = true;
ss.primary_touch_id = None;
ss.gesture.on_release( pos, &ss.frame.widget_rects, &swipe, global_drag )
};
self.apply_release_events( focus, events_out );
self.stop_button_repeat();
}
/// Drop every in-flight touch gesture across all surfaces, notifying
/// the app of any slot still down that it was tracking raw (auxiliary
/// fingers, or every finger under `claims_raw_touch`). Shared by the