From 75be8003900c6ee912501d2364867eed9a89c73e Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Sat, 12 Sep 2026 10:43:51 +0200 Subject: [PATCH] input, render: keep fast touch typing whole, fire pattern nodes once, and make the software image path cheap 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. --- CHANGELOG.md | 5 ++ docs/architecture.md | 2 +- docs/widgets.md | 8 +- src/gles_render/image.rs | 35 +------- src/input/dispatch/outcomes.rs | 2 +- src/input/gesture/mod.rs | 40 +++++++-- src/input/gesture/outcomes.rs | 18 ++++ src/input/gesture/tests.rs | 1 + src/input/pointer/helpers.rs | 1 + src/input/touch/mod.rs | 50 +++++++---- src/render/helpers.rs | 30 +++++++ src/render/image.rs | 149 +++++++++++++++++++++++++++------ src/widget/handlers.rs | 23 ++++- 13 files changed, 270 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8030f68..1d18071 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,11 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a ### Fixed +- **Fast two-thumb typing no longer drops taps.** A finger landing while another was still down went to the auxiliary slot and never reached the gesture machine, so on a touch keypad every overlapping tap was lost. 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 `App::on_touch_up`. Presses that have become a drag keep the old behaviour, so a second finger during a pattern stroke or a slider drag still stays out of the gesture machine. +- **`PatternGrid::on_node` fires once per node, not once per motion event.** Every touch motion within a node's snap radius re-emitted the node's message and requested a repaint, so drawing a pattern rebuilt and re-rastered the surface at the touchscreen's motion rate. The gesture machine now remembers the node the finger is over (`last_pattern_node`) and emits only on change; a `Slider` move outcome without a message no longer requests a redraw. +- **Gesture thresholds are logical pixels.** 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 on a scale-2 panel they were 4 and 3 logical px and an ordinary thumb tap became a vertical drag whose release skipped the button. `SwipeConfig` now carries the surface's buffer `scale` and the machine reads the thresholds through `SwipeConfig::slop` / `stray`. +- **A 1:1 opaque image is a row copy in the software renderer.** With the premultiplied copy cached, drawing it unscaled with no clip and full alpha now copies rows into the canvas instead of running tiny-skia's bilinear pipeline over every pixel, which is what a full-surface backdrop cost on every frame. +- **The software renderer premultiplies an image once, not on every draw.** `SoftwareCanvas::draw_image_data` converted the whole source to premultiplied alpha in floating point on each call, so a full-surface backdrop cost a pass over every pixel per frame on top of the blit. Premultiplied copies are now kept in a 32 MiB least-recently-drawn cache keyed by size, content fingerprint and alpha, the same key the GLES texture cache uses (`fingerprint_rgba` moves to `render::helpers` to be shared). - **Rows no longer spill their children out of the layout.** `Row` distributed leftover width but had no notion of a shortfall: `leftover` floored at zero, so a cluster wider than its rect kept every preferred width and overflowed — symmetrically, since the no-spacer branch centres it, which is why both end labels of a segmented control were clipped at once. The shortfall now comes off the widest children first (water filling), so a long title gives up its width while the icon beside it keeps its size, and equal siblings — a segmented control — share it evenly; pinned spacers keep their width, and `Row::no_shrink()` opts out for strips deliberately wider than their viewport. `Button` elides its label into the rect it is granted, so shrinking degrades to a truncated label rather than clipped glyphs; the truncation rule moves to one crate-internal helper shared with `ListItem`. - **`ListItem` labels no longer run under the trailing slot.** The label and subtitle were painted with no width limit, so a title longer than the row overlapped the trailing text or the disclosure icon instead of truncating. The trailing slots are now laid out first and both lines are elided with an ellipsis against the space they leave, matching what `Text` already did. - **Text inputs no longer insert mid-string when the value grows after focus.** Focusing a text input pinned the cursor to a snapshot of `value.len()`; if the value kept growing without the widget seeing keystrokes (a field fed over IPC), the first normally-delivered key inserted at the stale position. The focus-time cursor is now an end-of-value sentinel that every consumer clamps to the current value length, collapsing to a concrete position on the first real keystroke or click. diff --git a/docs/architecture.md b/docs/architecture.md index 54b9682..f0c6123 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -320,7 +320,7 @@ Downstream consumers shipping into regulated environments (EN 301 549, WCAG 2.1 **Cross-application drag-and-drop — deferred.** The clipboard now bridges to the Wayland selection via `wl_data_device_manager` (see `event_loop/data_device.rs`), so Ctrl+C / Ctrl+V crosses application boundaries when the compositor advertises the global. Middle-click primary selection (`zwp_primary_selection_v1`) and inter-app drag-and-drop targets (drop-zone widgets that accept text / URI lists from outside the process) are still pending — they share most of the offer / source plumbing but need widget-level drop-target wiring on top. -**Multi-touch — primary slot plus raw auxiliary fingers.** The first finger to land on a surface becomes its primary slot and drives the built-in gesture machine (swipe, scroll, long-press, drag). Additional fingers bypass the gesture machine and surface directly through `App::on_touch_down` / `on_touch_move` / `on_touch_up`, so apps can implement pinch-zoom or two-finger pan without losing the built-in gestures. Apps that want the whole stream raw — an embedded web view, a drawing canvas — return `true` from `App::claims_raw_touch`: every finger, primary included, then reports through `on_touch_*` and widget presses, taps and swipes never fire. What ltk itself does not provide is *recognition* of multi-finger gestures — pinch and rotate detection is the app's job on top of the raw stream. +**Multi-touch — primary slot plus raw auxiliary fingers.** The first finger to land on a surface becomes its primary slot and drives the built-in gesture machine (swipe, scroll, long-press, drag). Additional fingers bypass the gesture machine and surface directly through `App::on_touch_down` / `on_touch_move` / `on_touch_up`, so apps can implement pinch-zoom or two-finger pan without losing the built-in gestures. One exception keeps fast two-thumb typing whole: a finger landing while the primary is still a plain press (no drag, scroll, swipe or slider in flight) commits that press as a tap at its last position and takes the primary slot itself, so alternating taps whose touches overlap all register; the earlier finger's release then reports through `on_touch_up`. Apps that want the whole stream raw — an embedded web view, a drawing canvas — return `true` from `App::claims_raw_touch`: every finger, primary included, then reports through `on_touch_*` and widget presses, taps and swipes never fire. What ltk itself does not provide is *recognition* of multi-finger gestures — pinch and rotate detection is the app's job on top of the raw stream. **HarfBuzz shaping — wired in.** `src/text_shaping.rs::shape_line` now drives both renderers: the line is BiDi-reordered, split into per-font sub-runs and shaped through rustybuzz. The glyph cache is keyed on `(glyph_id, size_bits, font_id)` and each glyph is rasterised by index via `fontdue::Font::rasterize_indexed`, so Arabic connected forms, Devanagari clusters and CJK shaped glyphs render correctly. diff --git a/docs/widgets.md b/docs/widgets.md index b2feb7b..4fb1e4b 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -362,10 +362,10 @@ pattern_grid( self.path.clone() ) # }} ``` -`on_node( f )` fires with a node index whenever a press or a drag reaches -a node, including nodes already in the path — appending, skipping a -repeat, or taking in the node a straight stroke crosses is the app's -rule, not the widget's. `on_release( msg )` fires when the finger lifts. +`on_node( f )` fires with a node index when a press or a drag enters a +node, once per entry rather than once per motion event, including nodes +already in the path — appending, skipping a repeat, or taking in the node +a straight stroke crosses is the app's rule, not the widget's. `on_release( msg )` fires when the finger lifts. `hide_stroke( true )` draws only the dots, against shoulder surfing; `error( true )` paints the current path in the palette's `danger` colour. `size( len )` sets the side of the square grid (any `Length`); without it the diff --git a/src/gles_render/image.rs b/src/gles_render/image.rs index 1164fec..7b151e1 100644 --- a/src/gles_render/image.rs +++ b/src/gles_render/image.rs @@ -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. diff --git a/src/input/dispatch/outcomes.rs b/src/input/dispatch/outcomes.rs index 2ecb903..d35dfae 100644 --- a/src/input/dispatch/outcomes.rs +++ b/src/input/dispatch/outcomes.rs @@ -37,8 +37,8 @@ impl AppData if let Some( m ) = msg { self.pending_msgs.push( m ); + self.surface_mut( focus ).request_redraw(); } - self.surface_mut( focus ).request_redraw(); } MoveOutcome::Scroll => { diff --git a/src/input/gesture/mod.rs b/src/input/gesture/mod.rs index 752abff..75e99a0 100644 --- a/src/input/gesture/mod.rs +++ b/src/input/gesture/mod.rs @@ -144,6 +144,9 @@ pub struct GestureState /// 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, } impl Default for GestureState @@ -176,9 +179,22 @@ impl GestureState 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 GestureState 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 GestureState 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 GestureState 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 GestureState 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 GestureState 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 GestureState // 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 GestureState 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 GestureState 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 GestureState self.mouse_press = false; self.horizontal_drag_started = false; self.vertical_drag_started = false; + self.last_pattern_node = None; let mut events = Vec::new(); diff --git a/src/input/gesture/outcomes.rs b/src/input/gesture/outcomes.rs index 60e61ad..a749d9a 100644 --- a/src/input/gesture/outcomes.rs +++ b/src/input/gesture/outcomes.rs @@ -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 ) + } } diff --git a/src/input/gesture/tests.rs b/src/input/gesture/tests.rs index 290332b..c5f83fc 100644 --- a/src/input/gesture/tests.rs +++ b/src/input/gesture/tests.rs @@ -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, } } diff --git a/src/input/pointer/helpers.rs b/src/input/pointer/helpers.rs index e8e2bcb..db83a7f 100644 --- a/src/input/pointer/helpers.rs +++ b/src/input/pointer/helpers.rs @@ -27,6 +27,7 @@ impl AppData 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, } } } diff --git a/src/input/touch/mod.rs b/src/input/touch/mod.rs index 1890082..8f6aa6d 100644 --- a/src/input/touch/mod.rs +++ b/src/input/touch/mod.rs @@ -63,6 +63,23 @@ impl TouchHandler for AppData 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 TouchHandler for AppData 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 TouchHandler for AppData impl AppData { + /// 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 diff --git a/src/render/helpers.rs b/src/render/helpers.rs index 7faf170..058662e 100644 --- a/src/render/helpers.rs +++ b/src/render/helpers.rs @@ -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; diff --git a/src/render/image.rs b/src/render/image.rs index 9f1d037..8289d74 100644 --- a/src/render/image.rs +++ b/src/render/image.rs @@ -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, + lru: VecDeque, + bytes: usize, +} + +thread_local! { + static PREMUL_CACHE: RefCell = 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 +{ + 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> = 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() ); } ); } diff --git a/src/widget/handlers.rs b/src/widget/handlers.rs index 2edf60d..26410de 100644 --- a/src/widget/handlers.rs +++ b/src/widget/handlers.rs @@ -210,17 +210,32 @@ impl WidgetHandlers 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 + /// The pattern-grid node under `pos`, within the snap radius. + pub fn pattern_node_at( &self, rect: Rect, pos: Point ) -> Option { 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 + { + 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 + { + self.pattern_node_at( rect, pos ).and_then( |node| self.pattern_msg( node ) ) + } + pub fn pattern_release_msg( &self ) -> Option { match self