Files
qbz/crates/qbz-qt/qml/shell/NavGestureLayer.qml
T
vicrodh f3a33245b2 feat(qbz-qt): staged loading, skeletons everywhere, lyrics, gestures, richer menus
**Deferred external rows.** The album page awaited the Last.fm row before
publishing anything, so the tracklist waited on an external service plus
MusicBrainz. It publishes in stages now — header and tracks first, then each
Qobuz rail as it lands, then Last.fm — with every patch merged under a
generation counter so a late reply for album A cannot paint onto album B. The
artist page was already staged this way; its views were the part still showing
bare "Loading…" text.

**Skeletons as the standard.** One control, new variants rather than new
components, mounted across the views. Its second job is the one the owner
named: cleanup. A cover that never resolves settles the placeholder once and
reveals the card's own empty state instead of shimmering forever — local albums
with no embedded art produce no artwork entry at all, so a placeholder gated
only on "not arrived yet" would never stop.

**Local artwork streams.** Fifteen seconds to first cover on a 1274-album grid
was one batch emitted after the whole window resolved. Each cover emits as it
resolves now, visible band before prefetch margin.

**Lyrics migrated.** Smooth sync instead of a 1 Hz tick, word-level karaoke
where the document carries it, the surrounding-line dimming, the controls
flyout and the translation layer the v10 backend already supported. Exactly one
row subscribes to the 30 Hz output; the timer drops to 250ms when paused or
hidden.

**Navigation by mouse and touchpad.** Side buttons and a two-finger horizontal
swipe, matching #653 — including the part that matters, which is that a swipe
over a carousel scrolls the carousel. The layer is declared first in the shell
so everything else gets first refusal.

**Context menus.** Brought to the Slint inventory, Play later included, with
right-click opening the same menu as the button everywhere. Entries whose seam
does not exist are hidden rather than shipped inert.

Two controls that rendered and no-opped are now honestly disabled: the My QBZ
nav row, which routes to a view the shell has no case for, and three sidebar
menu entries with no backing call.

The sidebar caret was never a condition bug. The row is a positioner and the
name cell's width ignored the count label, so a folder WITH content pushed the
chevron past the clip — which is why it vanished exactly when it mattered.

cc
2026-07-28 18:12:31 -06:00

176 lines
7.9 KiB
QML

// Window-level history-navigation gestures — the QML port of the #653
// handler in crates/qbz-ui/ui/shell/AppShell.slint:244-321.
//
// Two input paths, ONE nav. Both end in QbzShell.navigateBack /
// navigateForward — the same calls the three sacred header buttons make —
// and both honour QbzShell.canBack / canForward, so a back gesture with no
// history does nothing, silently (no beep, no flash, no half-state).
//
// 1. Mouse side buttons (Qt.BackButton / Qt.ForwardButton). They must
// work anywhere in the window, which is why this is a window-level
// layer and not something inside HeaderBar.
// 2. Two-finger horizontal touchpad swipe.
//
// STACKING IS LOAD-BEARING — mount this as the FIRST child of the shell
// root so the whole UI paints and hit-tests on top of it (see GLUE NEEDED
// in the round report). Qt delivers a wheel event front-to-back through
// every item under the cursor until one accepts it
// (QQuickDeliveryAgentPrivate::deliverSinglePointEventUntilAccepted), so a
// scroll only reaches this bottom layer when nothing above consumed it:
//
// * a HORIZONTAL carousel Flickable/ListView CAN flick on x, so it
// accepts horizontal deltas -> the rail scrolls and no navigation
// fires. This is the whole point of the bottom placement;
// * a vertical-only page scroller cannot flick on x, and Qt's Flickable
// only accepts an axis it can actually move with a non-zero delta on
// that axis, so a pure-horizontal delta is left unaccepted and falls
// through to here. Slint reaches the same outcome by the same rule
// (Flickable::is_allowed_scroll_direction rejects when delta.y == 0
// and the viewport does not overflow horizontally).
//
// The wheel handler ALWAYS leaves the event unaccepted, on every path out,
// so no scroll routing anywhere in the shell changes because of this file.
import QtQuick
import com.blitzfc.qbz
MouseArea {
id: gestures
// Side buttons ONLY. Every other button is not in acceptedButtons, so
// Qt never even targets this layer for them and the UI above keeps its
// clicks untouched.
acceptedButtons: Qt.BackButton | Qt.ForwardButton
// No hover grab and no cursorShape assignment: this layer must stay
// invisible to hover states and to the cursor stack above it.
hoverEnabled: false
// Explicit even though it is the default — without it MouseArea drops
// phased (touchpad) wheel events straight to QQuickItem and onWheel
// never runs.
scrollGestureEnabled: true
// --- Tunables (Slint parity) -----------------------------------------
// 70 logical px accumulated in one gesture — AppShell.slint:297. High
// enough that resting fingers or small trackpad noise never trip it,
// low enough that a deliberate swipe crosses it in one motion.
readonly property real swipeThreshold: 70
// Gesture-boundary fallback for platforms that report no scroll phase
// (xcb/XI2 smooth scrolling sends Qt.NoScrollPhase) — AppShell.slint:275
// uses the same 300 ms idle timer, because Slint's PointerScrollEvent
// carries no phase at all.
readonly property int swipeIdleMs: 300
// Notched-wheel fallback: logical px per wheel notch. Slint's winit
// backend converts a line delta with `LineDelta(lx, ly) => (lx * 60.,
// ly * 60.)` (i-slint-backend-winit event_loop.rs:397), and one Qt
// notch is 120 angle units == one line, so 60 px keeps a horizontal
// tilt-wheel behaving exactly like it does in the Slint build (two
// notches to cross the 70 px threshold).
readonly property real pxPerNotch: 60
readonly property int anglePerNotch: 120
// --- Gesture accumulator ---------------------------------------------
// One navigation per gesture. Deltas arrive as wheel events; the
// gesture boundary is the scroll phase when the platform provides one,
// and otherwise the idle timeout below. A direction flip also starts a
// fresh gesture, and `swipeFired` suppresses re-triggers until the
// gesture ends.
property real swipeAccum: 0
property bool swipeFired: false
Timer {
id: swipeIdle
interval: gestures.swipeIdleMs
repeat: false
onTriggered: gestures.resetSwipe()
}
function resetSwipe() {
gestures.swipeAccum = 0
gestures.swipeFired = false
swipeIdle.stop()
}
// --- Mouse side buttons ----------------------------------------------
onPressed: function (mouse) {
if (mouse.button === Qt.BackButton) {
if (QbzShell.canBack)
QbzShell.navigateBack()
} else if (mouse.button === Qt.ForwardButton) {
if (QbzShell.canForward)
QbzShell.navigateForward()
}
}
// --- Two-finger horizontal swipe --------------------------------------
onWheel: function (wheel) {
// MouseArea sets the event ACCEPTED the moment an onWheel handler is
// connected (QQuickMouseArea::wheelEvent -> we.setAccepted(
// isWheelConnected())), so clearing it is mandatory and has to
// happen before any early return — otherwise this layer would
// swallow every scroll the views above it declined.
wheel.accepted = false
// Qt.ScrollBegin / ScrollEnd / ScrollMomentum are Qt::ScrollPhase,
// registered on the QML Qt object via Q_ENUM_NS(ScrollPhase)
// (qnamespace.h:1855 in the Qt 6.11 headers on this box).
if (wheel.phase === Qt.ScrollBegin) {
gestures.resetSwipe()
} else if (wheel.phase === Qt.ScrollEnd) {
gestures.resetSwipe()
return
} else if (wheel.phase === Qt.ScrollMomentum) {
// Kinetic tail after the fingers lift is not a new gesture, and
// it must never fire a second navigation.
return
}
// Touchpads report pixel deltas in logical px (the same unit as
// Slint's delta-x). A notched wheel reports angle deltas only.
var dx = wheel.pixelDelta.x
var dy = wheel.pixelDelta.y
if (dx === 0 && dy === 0) {
dx = wheel.angleDelta.x / gestures.anglePerNotch * gestures.pxPerNotch
dy = wheel.angleDelta.y / gestures.anglePerNotch * gestures.pxPerNotch
}
// Only clearly horizontal-dominant deltas count toward the gesture;
// vertical scroll jitter is ignored (AppShell.slint:285).
if (dx === 0 || Math.abs(dx) <= Math.abs(dy))
return
// A direction flip starts a fresh gesture.
if (gestures.swipeAccum !== 0 && (gestures.swipeAccum > 0) !== (dx > 0)) {
gestures.swipeAccum = dx
gestures.swipeFired = false
} else {
gestures.swipeAccum += dx
}
swipeIdle.restart()
if (gestures.swipeFired || Math.abs(gestures.swipeAccum) < gestures.swipeThreshold)
return
// DIRECTION (macOS/GNOME natural-scrolling convention, identical to
// AppShell.slint:298-312): fingers RIGHT = back, fingers LEFT =
// forward. With libinput natural scrolling ON (the GNOME default)
// fingers right yield a NEGATIVE horizontal delta, hence the signs.
// Qt and Slint share this sign convention — both add the delta to
// the content/viewport offset — so the mapping ports 1:1.
//
// The Slint build lets users who run without natural scrolling flip
// this with AppearanceState.invert-swipe-navigation. That pref has
// no counterpart on QbzShell yet, so this port hard-codes the
// pref's DEFAULT (false / not inverted). No settings row is drawn
// for it: a toggle with nothing behind it would be a no-op control.
if (gestures.swipeAccum < 0) {
if (QbzShell.canBack)
QbzShell.navigateBack()
} else {
if (QbzShell.canForward)
QbzShell.navigateForward()
}
gestures.swipeFired = true
gestures.swipeAccum = 0
}
}