**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
176 lines
7.9 KiB
QML
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
|
|
}
|
|
}
|