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 -34
View File
@@ -24,11 +24,9 @@
//! single texture larger than the whole budget still draws — the cache
//! then holds that one entry until something replaces it.
use std::collections::hash_map::DefaultHasher;
use std::hash::{ Hash, Hasher };
use glow::HasContext;
use crate::render::helpers::fingerprint_rgba;
use crate::types::Rect;
use super::helpers::{ ortho_rect, upload_rgba_texture };
@@ -40,37 +38,6 @@ use super::GlesCanvas;
/// the compositor.
pub const IMAGE_CACHE_MAX_BYTES: usize = 32 * 1024 * 1024;
/// Compute a 64-bit fingerprint of an RGBA buffer for the texture
/// cache. Hashes the full byte slice for small buffers (icons,
/// thumbnails — below 16 KB ≈ 64×64 RGBA), and falls back to a
/// strided 8 × 512-byte sample for anything larger so a wallpaper
/// blit does not pay an 8 MB hash on every frame. Both modes
/// distinguish the chevron-icon-style cases that motivated the move
/// to content-keying — the SVG-rasterised buffers differ across
/// most of their interior bytes, not just at the corners.
fn fingerprint_rgba( bytes: &[u8] ) -> u64
{
const FULL_HASH_THRESHOLD: usize = 16 * 1024;
const SAMPLE_CHUNKS: usize = 8;
const SAMPLE_CHUNK_BYTES: usize = 512;
let mut h = DefaultHasher::new();
let n = bytes.len();
n.hash( &mut h );
if n <= FULL_HASH_THRESHOLD
{
bytes.hash( &mut h );
} else {
let stride = n / SAMPLE_CHUNKS;
for i in 0..SAMPLE_CHUNKS
{
let pos = ( i * stride ).min( n - SAMPLE_CHUNK_BYTES );
bytes[ pos..pos + SAMPLE_CHUNK_BYTES ].hash( &mut h );
}
}
h.finish()
}
impl GlesCanvas
{
/// Blit RGBA image data scaled to dest rect with opacity.
+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
+30
View File
@@ -5,10 +5,40 @@
//! rounded-rect construction. System-font resolution lives in
//! `crate::system_fonts`.
use std::collections::hash_map::DefaultHasher;
use std::hash::{ Hash, Hasher };
use tiny_skia::{ Path, PathBuilder };
use crate::types::{ Corners, PathCmd };
/// A 64-bit fingerprint of an RGBA buffer, the key of both backends' image
/// caches. Hashes the full slice below 16 KB (icons, thumbnails) and a
/// strided 8 × 512-byte sample above it, so a wallpaper does not pay a
/// full hash on every frame.
pub( crate ) fn fingerprint_rgba( bytes: &[u8] ) -> u64
{
const FULL_HASH_THRESHOLD: usize = 16 * 1024;
const SAMPLE_CHUNKS: usize = 8;
const SAMPLE_CHUNK_BYTES: usize = 512;
let mut h = DefaultHasher::new();
let n = bytes.len();
n.hash( &mut h );
if n <= FULL_HASH_THRESHOLD
{
bytes.hash( &mut h );
} else {
let stride = n / SAMPLE_CHUNKS;
for i in 0..SAMPLE_CHUNKS
{
let pos = ( i * stride ).min( n - SAMPLE_CHUNK_BYTES );
bytes[ pos..pos + SAMPLE_CHUNK_BYTES ].hash( &mut h );
}
}
h.finish()
}
/// Cubic bezier control-point factor for a quarter-circle approximation
/// (`(4/3) * (sqrt(2) - 1) ≈ 0.5523`).
const KAPPA: f32 = 0.5523_f32;
+122 -27
View File
@@ -13,12 +13,110 @@
//! (Abgr8888 matches tiny-skia's byte order) or swaps R/B in blocks
//! of four pixels (Argb8888 fallback).
use tiny_skia::{ Pixmap, PixmapPaint, Transform };
use std::cell::RefCell;
use std::collections::{ HashMap, VecDeque };
use tiny_skia::{ IntSize, Pixmap, PixmapPaint, Transform };
use crate::types::Rect;
use super::helpers::fingerprint_rgba;
use super::SoftwareCanvas;
/// Byte budget of the premultiplied-image cache, matching the GLES texture
/// cache: a phone wallpaper plus a working set of icons.
const PREMUL_CACHE_MAX_BYTES: usize = 32 * 1024 * 1024;
type PremulKey = ( u32, u32, u64, u32 );
/// A premultiplied source and whether every pixel was fully opaque, which
/// lets a 1:1 draw be a row copy.
struct Premul
{
pixmap: Pixmap,
opaque: bool,
}
/// Premultiplied copies of the images drawn recently, keyed by size,
/// content fingerprint and alpha, so a backdrop or a logo is converted once
/// rather than on every frame. Least-recently-drawn eviction; the entry
/// being drawn is never evicted.
#[ derive( Default ) ]
struct PremulCache
{
entries: HashMap<PremulKey, Premul>,
lru: VecDeque<PremulKey>,
bytes: usize,
}
thread_local! {
static PREMUL_CACHE: RefCell<PremulCache> = RefCell::new( PremulCache::default() );
}
impl PremulCache
{
/// Mark `key` as just used. `false` when it is not cached.
fn touch( &mut self, key: &PremulKey ) -> bool
{
let Some( pos ) = self.lru.iter().position( |k| k == key ) else { return false };
self.lru.remove( pos );
self.lru.push_back( *key );
true
}
fn insert( &mut self, key: PremulKey, premul: Premul )
{
self.bytes += premul.pixmap.data().len();
self.entries.insert( key, premul );
self.lru.push_back( key );
while self.bytes > PREMUL_CACHE_MAX_BYTES && self.lru.len() > 1
{
let Some( old ) = self.lru.pop_front() else { break };
if let Some( p ) = self.entries.remove( &old )
{
self.bytes = self.bytes.saturating_sub( p.pixmap.data().len() );
}
}
}
}
fn premultiply( rgba: &[u8], size: IntSize, alpha: f32 ) -> Option<Premul>
{
let mut out = vec![ 0u8; rgba.len() ];
let mut opaque = alpha >= 1.0;
for ( dst, src ) in out.chunks_exact_mut( 4 ).zip( rgba.chunks_exact( 4 ) )
{
opaque &= src[3] == 255;
let a = ( src[3] as f32 / 255.0 ) * alpha;
dst[0] = ( src[0] as f32 * a ) as u8;
dst[1] = ( src[1] as f32 * a ) as u8;
dst[2] = ( src[2] as f32 * a ) as u8;
dst[3] = ( a * 255.0 ) as u8;
}
Pixmap::from_vec( out, size ).map( |pixmap| Premul { pixmap, opaque } )
}
/// Copy `src` onto `dst` at (`x`, `y`) row by row, clipped to `dst`.
fn copy_rows( dst: &mut Pixmap, src: &Pixmap, x: i32, y: i32 )
{
let ( dw, dh ) = ( dst.width() as i32, dst.height() as i32 );
let ( sw, sh ) = ( src.width() as i32, src.height() as i32 );
let x0 = x.max( 0 );
let x1 = ( x + sw ).min( dw );
let y0 = y.max( 0 );
let y1 = ( y + sh ).min( dh );
if x0 >= x1 || y0 >= y1 { return; }
let row_bytes = ( ( x1 - x0 ) * 4 ) as usize;
let src_data = src.data();
let dst_data = dst.data_mut();
for row in y0..y1
{
let s = ( ( ( row - y ) * sw + ( x0 - x ) ) * 4 ) as usize;
let d = ( ( row * dw + x0 ) * 4 ) as usize;
dst_data[ d..d + row_bytes ].copy_from_slice( &src_data[ s..s + row_bytes ] );
}
}
impl SoftwareCanvas
{
pub fn draw_image_data( &mut self, rgba_data: &[u8], img_w: u32, img_h: u32, dest: Rect, opacity: f32 )
@@ -41,37 +139,34 @@ impl SoftwareCanvas
let Some( int_size ) = tiny_skia::IntSize::from_wh( img_w, img_h ) else { return };
thread_local! {
static PREMUL_BUF: std::cell::RefCell<Vec<u8>> = std::cell::RefCell::new( Vec::new() );
}
PREMUL_BUF.with( |cell|
let alpha = opacity * self.global_alpha;
let key = ( img_w, img_h, fingerprint_rgba( rgba_data ), alpha.to_bits() );
PREMUL_CACHE.with( |cell|
{
let mut premul = cell.borrow_mut();
let needed = rgba_data.len();
premul.resize( needed, 0 );
for ( dst, src ) in premul.chunks_exact_mut( 4 ).zip( rgba_data.chunks_exact( 4 ) )
let mut cache = cell.borrow_mut();
if !cache.touch( &key )
{
let a = (src[3] as f32 / 255.0) * opacity * self.global_alpha;
dst[0] = (src[0] as f32 * a) as u8;
dst[1] = (src[1] as f32 * a) as u8;
dst[2] = (src[2] as f32 * a) as u8;
dst[3] = (a * 255.0) as u8;
let Some( premul ) = premultiply( rgba_data, int_size, alpha ) else { return };
cache.insert( key, premul );
}
let Some( src ) = cache.entries.get( &key ) else { return };
let unscaled = dest.width as u32 == img_w && dest.height as u32 == img_h;
if src.opaque && unscaled && self.clip_mask.is_none()
{
copy_rows( &mut self.pixmap, &src.pixmap, dest.x as i32, dest.y as i32 );
return;
}
if let Some( src_pixmap ) = Pixmap::from_vec( std::mem::take( &mut *premul ), int_size )
let sx = dest.width / img_w as f32;
let sy = dest.height / img_h as f32;
let t = Transform::from_scale( sx, sy ).post_translate( dest.x, dest.y );
let paint = PixmapPaint
{
let sx = dest.width / img_w as f32;
let sy = dest.height / img_h as f32;
let t = Transform::from_scale( sx, sy ).post_translate( dest.x, dest.y );
let paint = PixmapPaint
{
quality: tiny_skia::FilterQuality::Bilinear,
..PixmapPaint::default()
};
self.pixmap.draw_pixmap( 0, 0, src_pixmap.as_ref(), &paint, t, self.clip_mask.as_ref() );
*premul = src_pixmap.take();
}
quality: tiny_skia::FilterQuality::Bilinear,
..PixmapPaint::default()
};
self.pixmap.draw_pixmap( 0, 0, src.pixmap.as_ref(), &paint, t, self.clip_mask.as_ref() );
} );
}
+19 -4
View File
@@ -210,17 +210,32 @@ impl<Msg: Clone> WidgetHandlers<Msg>
matches!( self, WidgetHandlers::Slider { .. } | WidgetHandlers::PatternGrid { .. } )
}
/// The message a pattern grid fires for the node under `pos`, if any.
pub fn pattern_node_msg( &self, rect: Rect, pos: Point ) -> Option<Msg>
/// The pattern-grid node under `pos`, within the snap radius.
pub fn pattern_node_at( &self, rect: Rect, pos: Point ) -> Option<u8>
{
match self
{
WidgetHandlers::PatternGrid { on_node: Some( f ), snap_px, .. } =>
super::pattern_grid::node_at( rect, pos, *snap_px ).map( |node| f( node ) ),
WidgetHandlers::PatternGrid { snap_px, .. } => super::pattern_grid::node_at( rect, pos, *snap_px ),
_ => None,
}
}
/// The message a pattern grid fires for `node`, if it has an `on_node`.
pub fn pattern_msg( &self, node: u8 ) -> Option<Msg>
{
match self
{
WidgetHandlers::PatternGrid { on_node: Some( f ), .. } => Some( f( node ) ),
_ => None,
}
}
/// The message a pattern grid fires for the node under `pos`, if any.
pub fn pattern_node_msg( &self, rect: Rect, pos: Point ) -> Option<Msg>
{
self.pattern_node_at( rect, pos ).and_then( |node| self.pattern_msg( node ) )
}
pub fn pattern_release_msg( &self ) -> Option<Msg>
{
match self