[ltk — src/input/gesture/mod.rs, src/input/gesture/tests.rs, src/widget/handlers.rs, src/input/keyboard/dispatch.rs, src/render/image.rs, docs/architecture.md, CHANGELOG.md] Keep a tap on a button while the finger rolls inside it, treat XF86Back like Escape, and convert ARGB8888 by whole pixels
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

Any travel past the slop turned a press into a vertical or horizontal drag and its release skipped the button, so a thumb that rolled on a large key lost the tap. `GestureState::on_move` now stays idle while the pressed widget fires on press (the new `WidgetHandlers::has_press`) and the finger is still inside its rect; the swipe machinery takes over from the original press point once the finger leaves. Presses inside a scroll viewport are unaffected, since a list of full-width rows is scrolled by dragging on them. Two tests cover the tap that survives motion inside the button and the swipe that arms once the finger leaves it; the architecture notes and the changelog describe the rule.
The compositor now sends XF86Back for the Android back gesture, an inward swipe from either side edge. With a text input focused that keysym fell into `handle_key_default` and was swallowed, so an app never saw it. `XF86Back` now dispatches through `handle_key_escape`, so every ltk app gets the back gesture with Escape's layering: open popups, then the context menu, then a selection, then a dialog's cancel, and finally the focus drop and `on_key_with_modifiers` with the keysym, where an app can act on it.
`SoftwareCanvas::write_to_wayland_buf` swapped red and blue with a byte-by-byte loop, unrolled by hand over sixteen bytes. It now reads each pixel as a `u32`, exchanges the two channels with masks and shifts, and leaves the vectorisation to the compiler. Compositors that offer `ABGR8888` never took this path.
This commit is contained in:
2026-09-14 10:57:10 +02:00
parent 75be800390
commit 5b8ae35830
7 changed files with 52 additions and 31 deletions
+2
View File
@@ -38,6 +38,7 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a
### Changed
- **The software backend's ARGB8888 conversion works on whole pixels.** `SoftwareCanvas::write_to_wayland_buf` swapped red and blue with a byte-by-byte loop over the frame; it now reads each pixel as a `u32`, exchanges the two channels with masks and shifts, and lets the compiler vectorise it. Compositors that offer `ABGR8888` were never affected.
- **Breaking: `App::app_id`, `App::save_state` and `App::restore_state` are new mandatory trait methods** — every implementor must add them (shell components: constant id, `None`, no-op). `xdg_toplevel.set_app_id` and the AccessKit application name now come from `app_id()` (previously `"ltk"` / `"ltk-app"`); the `app_id` element of the deprecated `window_config` tuple is ignored. Crate version bumped to 0.3.0.
- **`Button::icon_size` takes `impl Into<Length>`** and resolves through `Canvas::resolve_geom`, joining `font_size` / `height` / `width`. A bare `f32` still means `Length::px` — every existing call keeps its exact size — but a caller can now pass `Length::widget( n )` to have an icon button follow the widget-scaling mode the way stock icons do. Without it an explicit `icon_size` was the one geometry setter that ignored the mode, so a 21 px back arrow sat next to a `list_item` chevron that fluid sizing had grown well past 21 and looked visibly smaller.
- **`Length::dp` now applies the density at resolution time, not at construction.** The value carries its design pixels in a new `LengthBase::Dp` variant and `resolve` multiplies by the density in effect when it runs, so a `set_density` change takes effect on the next paint without rebuilding the view's lengths — previously a `dp` value was frozen to the density read when it was constructed. Behaviour is unchanged for code that sets density once at startup.
@@ -52,6 +53,7 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a
- **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.
- **A tap on a button survives finger motion inside the button.** Any travel past the slop turned the press into a vertical or horizontal drag and its release skipped the button, so a thumb that rolled on a large key lost the tap. While the pressed widget fires on press (`WidgetHandlers::has_press`) and the finger is still inside its rect, motion is ignored; the swipe machinery takes over from the original press point once the finger leaves, and presses inside a scroll viewport are unaffected.
- **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).
+1 -1
View File
@@ -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. 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.
**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`. A press on a widget that fires on tap also stays a tap for as long as the finger is inside that widget, however far it rolls; the swipe machinery only engages, from the original press point, once the finger leaves it. Presses inside a scroll viewport keep the 8 logical px slop, since a list of full-width rows is scrolled by dragging on them. 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.
+10
View File
@@ -420,6 +420,16 @@ impl<Msg: Clone> GestureState<Msg>
return MoveOutcome::Idle;
}
// A press on a button stays a tap while the finger is inside it;
// the swipe only starts once it leaves.
if let Some( w ) = self.pressed_idx.and_then( |idx| find_widget( widget_rects, idx ) )
{
if w.handlers.has_press() && w.rect.contains( pos )
{
return MoveOutcome::Idle;
}
}
// 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.
+27
View File
@@ -487,6 +487,33 @@ fn release_on_button_emits_press_msg()
assert!( matches!( events[ 0 ], ReleaseEvent::PushMsg( Msg::Pressed ) ) );
}
#[ test ]
fn motion_inside_pressed_button_keeps_the_tap()
{
let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), Some( Msg::Pressed ), None ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 50.0, 80.0 ), &widgets, &[] );
let mut offsets = HashMap::new();
let out = g.on_move( pt( 55.0, 50.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert!( matches!( out, MoveOutcome::Idle ) );
assert!( !g.vertical_drag_started );
let events = g.on_release( pt( 55.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false );
assert!( matches!( events[ 0 ], ReleaseEvent::PushMsg( Msg::Pressed ) ) );
}
#[ test ]
fn motion_leaving_pressed_button_arms_the_swipe()
{
let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), Some( Msg::Pressed ), None ) ];
let mut g = GestureState::<Msg>::new();
let _ = g.on_press( pt( 50.0, 50.0 ), &widgets, &[] );
let mut offsets = HashMap::new();
let _ = g.on_move( pt( 50.0, 120.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
assert!( g.vertical_drag_started );
let events = g.on_release( pt( 50.0, 120.0 ), &widgets, &cfg_full( 800, 1200 ), false );
assert!( events.iter().all( |e| !matches!( e, ReleaseEvent::PushMsg( _ ) ) ) );
}
#[ test ]
fn release_on_different_widget_does_not_emit_press_msg()
{
+1 -1
View File
@@ -32,7 +32,7 @@ impl<A: App> AppData<A>
Keysym::x | Keysym::X if self.ctrl_pressed => self.handle_key_ctrl_x( focus ),
Keysym::v | Keysym::V if self.ctrl_pressed => self.handle_key_ctrl_v( focus ),
Keysym::Tab | Keysym::ISO_Left_Tab => self.handle_key_tab( focus, &event, qh ),
Keysym::Escape => self.handle_key_escape( focus, &event, qh ),
Keysym::Escape | Keysym::XF86_Back => self.handle_key_escape( focus, &event, qh ),
Keysym::space => self.handle_key_space( focus, &event ),
_ => self.handle_key_default( focus, &event ),
}
+5 -29
View File
@@ -181,36 +181,12 @@ impl SoftwareCanvas
return;
}
let chunks = len / 16;
let remainder = len % 16;
let mut i = 0;
for _ in 0..chunks
let whole = len - len % 4;
for ( dst, px ) in buf[..whole].chunks_exact_mut( 4 ).zip( src[..whole].chunks_exact( 4 ) )
{
buf[i] = src[i + 2];
buf[i + 1] = src[i + 1];
buf[i + 2] = src[i];
buf[i + 3] = src[i + 3];
buf[i + 4] = src[i + 6];
buf[i + 5] = src[i + 5];
buf[i + 6] = src[i + 4];
buf[i + 7] = src[i + 7];
buf[i + 8] = src[i + 10];
buf[i + 9] = src[i + 9];
buf[i + 10] = src[i + 8];
buf[i + 11] = src[i + 11];
buf[i + 12] = src[i + 14];
buf[i + 13] = src[i + 13];
buf[i + 14] = src[i + 12];
buf[i + 15] = src[i + 15];
i += 16;
}
for _ in 0..(remainder / 4)
{
buf[i] = src[i + 2];
buf[i + 1] = src[i + 1];
buf[i + 2] = src[i];
buf[i + 3] = src[i + 3];
i += 4;
let v = u32::from_le_bytes( [ px[0], px[1], px[2], px[3] ] );
let swapped = ( v & 0xff00_ff00 ) | ( ( v >> 16 ) & 0xff ) | ( ( v & 0xff ) << 16 );
dst.copy_from_slice( &swapped.to_le_bytes() );
}
}
}
+6
View File
@@ -295,6 +295,12 @@ impl<Msg: Clone> WidgetHandlers<Msg>
}
}
/// Whether a tap on this widget fires something.
pub fn has_press( &self ) -> bool
{
self.press_msg().is_some()
}
/// Submit message (Enter on a focused TextEdit). `None` for every other
/// variant.
pub fn submit_msg( &self ) -> Option<Msg>