From d6ed93280c617ceb809eca491d7200f531f23a30 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:10:28 -0600 Subject: [PATCH] feat(nav): two-finger touchpad swipe for back/forward history (#653) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the AppShell bottom-layer TouchArea (the one already catching the mouse back/forward buttons) with a scroll-event handler that accumulates horizontal touchpad deltas and fires one NavState.request-back/forward per gesture — the same callbacks the header nav buttons drive, so entry swap, scroll restore and nav flags all follow the existing path. Delivery is front-to-back, so a horizontal carousel Flickable consumes horizontal deltas before they reach this layer (carousels keep scrolling and never trigger navigation); a vertical-only page scroller rejects horizontal-only deltas as an orthogonal direction, letting them fall through to the gesture handler. The handler always returns reject, so scroll routing is untouched everywhere. One navigation per gesture via an idle-timeout/direction-flip reset and a fired latch; 70px threshold; fingers-right = back / fingers-left = forward under libinput natural scrolling (commented in code). km --- crates/qbz-ui/ui/shell/AppShell.slint | 66 ++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/crates/qbz-ui/ui/shell/AppShell.slint b/crates/qbz-ui/ui/shell/AppShell.slint index 14df0d8c3..cea700123 100644 --- a/crates/qbz-ui/ui/shell/AppShell.slint +++ b/crates/qbz-ui/ui/shell/AppShell.slint @@ -235,8 +235,16 @@ export component AppShell inherits Rectangle { background: #000000.with-alpha(AppearanceState.app-background-dim); } - // Bottom layer: catches the mouse back / forward buttons for history - // navigation. Declared first so the real UI sits on top of it. + // Bottom layer: catches the mouse back / forward buttons AND two-finger + // horizontal touchpad swipes for history navigation (#653). Declared + // first so the real UI sits on top of it: a scroll event only reaches + // this layer when nothing above consumed it — a horizontal carousel + // Flickable accepts horizontal deltas (carousel keeps scrolling, no + // navigation fires), while a vertical-only page scroller REJECTS + // horizontal-only deltas (orthogonal direction, see Flickable's + // is_allowed_scroll_direction) and they fall through to here. The + // handler always returns EventResult.reject, so no scroll routing + // changes anywhere in the shell. TouchArea { pointer-event(e) => { if (e.kind == PointerEventKind.down) { @@ -248,6 +256,60 @@ export component AppShell inherits Rectangle { } } } + + // Swipe gesture accumulation: one navigation per gesture. Deltas + // arrive as pointer scroll events (Wayland/X11 touchpad). Slint's + // PointerScrollEvent carries no phase, so the gesture boundary is + // approximated: idle timeout (swipe-idle) + direction flips reset + // the accumulator, and `swipe-fired` suppresses re-triggers until + // the gesture goes idle. + property swipe-accum: 0px; + property swipe-fired: false; + swipe-idle := Timer { + interval: 300ms; + triggered => { + swipe-accum = 0px; + swipe-fired = false; + swipe-idle.stop(); + } + } + scroll-event(e) => { + // Only clearly horizontal-dominant deltas count toward the + // gesture; vertical scroll jitter is ignored. + if (e.delta-x != 0 && Math.abs(e.delta-x) > Math.abs(e.delta-y)) { + // A direction flip starts a fresh gesture. + if (swipe-accum != 0 && (swipe-accum > 0) != (e.delta-x > 0)) { + swipe-accum = e.delta-x; + swipe-fired = false; + } else { + swipe-accum += e.delta-x; + } + swipe-idle.restart(); + // 70 logical px total — high enough that resting fingers or + // small trackpad noise never trip it, low enough that a + // deliberate swipe crosses it in one motion. + if (!swipe-fired && Math.abs(swipe-accum) >= 70px) { + // DIRECTION (macOS/GNOME natural-scrolling convention): + // fingers RIGHT = back, fingers LEFT = forward. With + // libinput natural scrolling ON (GNOME default) fingers + // right yields NEGATIVE delta-x, hence the signs below. + // Under non-natural scrolling the mapping feels inverted + // — accepted trade-off (distro scroll-direction setting + // is not visible to the app). + if (swipe-accum < 0 && NavState.can-back) { + NavState.request-back(); + } + if (swipe-accum > 0 && NavState.can-forward) { + NavState.request-forward(); + } + swipe-fired = true; + swipe-accum = 0px; + } + } + // Never consume the event: views above/below keep their exact + // pre-gesture scroll behavior. + return EventResult.reject; + } } VerticalLayout {