feat(qt): remember where each page was scrolled, across back and forward
The port of the block Slint repeats in 27 views (NavState.restore-scope /
scroll-restore / report-scroll). A .slint file cannot hand a component a
reference to a Flickable, which is why the reference inlines it; QML can, so
controls/ScrollMemory.qml exists once and 31 containers mount it.
Two divergences from the reference, both forced by the same fact — tabs are not
history entries in this port:
1. THE SCOPE IS NAMED BY THE VIEW, not derived from the entry by a scope_for
table. Slint can derive it because its entries carry the tab
(Favorites { tab }); here an entry is a bare route id, so a derived scope
would read "library" for all five Library tabs and pour the Albums grid's
offset into whatever tab mounted. Letting the reporter name its own scope
buys tab granularity for free, and the mismatch case degrades the right way:
an unmatched scope restores NOTHING instead of restoring the wrong thing.
2. THE RESTORE RE-ASSERTS instead of firing once. A Qt item view resets
contentY to 0 whenever its model is replaced, and several pages assign their
model from a binding whose inputs arrive over the network after the mount —
the album page rebuilds listCells from three deferred rails, so it hands its
ListView a new array up to four times in its first seconds and snaps back to
the top over a restore that already ran correctly. The offset is held and
re-applied until a 2.5 s deadline, or until the user takes over (a wheel, a
drag or a flick raises `moving`, and from then the position is theirs).
Diagnostics are off by default: QT_LOGGING_RULES="qbz.nav.scroll.info=true" for
the QML half, RUST_LOG=qbz=debug for the stamp/arm half.
cc
This commit is contained in:
36 files changed
+581
-22
No files matched your search
@@ -433,6 +433,9 @@ fn main() {
|
||||
"qml/controls/QbzPrimaryButton.qml",
|
||||
"qml/controls/QbzRadioOption.qml",
|
||||
"qml/controls/QbzSectionHeader.qml",
|
||||
// Per-page scroll memory for the back/forward stack; mounted
|
||||
// beside each view's scroll container.
|
||||
"qml/controls/ScrollMemory.qml",
|
||||
"qml/controls/QbzSegToggle.qml",
|
||||
"qml/controls/QbzSelect.qml",
|
||||
"qml/controls/QbzSlider.qml",
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
// ScrollMemory — per-page scroll-position memory for the back/forward stack.
|
||||
//
|
||||
// THE PORT of the block Slint repeats in 27 views (`property <string>
|
||||
// sr-armed: NavState.restore-scope; changed viewport-height => { if
|
||||
// (NavState.restore-scope == "<scope>" && ...) { viewport-y =
|
||||
// NavState.scroll-restore; NavState.restore-scope = ""; } }` plus `changed
|
||||
// viewport-y => NavState.report-scroll(...)`). Slint had to inline it because
|
||||
// a `.slint` file cannot hand a component a reference to a Flickable; QML can,
|
||||
// so this exists ONCE and every view mounts it beside its scroll container:
|
||||
//
|
||||
// ScrollMemory { target: pageFlick; scope: "album" }
|
||||
//
|
||||
// Two halves, and they are independent:
|
||||
//
|
||||
// REPORT — every `contentY` change is pushed to `QbzShell.reportScroll`,
|
||||
// which stores (scope, y) in nav_qt::LIVE. Nothing reads it until a
|
||||
// navigation stamps the page it is leaving, which is the whole point of the
|
||||
// design: the ~40 `record` call sites never learn that scroll memory exists.
|
||||
// This is a bare store per scroll frame — no notify, no binding, no
|
||||
// allocation once the page has reported once — and it is what Slint does.
|
||||
//
|
||||
// RESTORE — `nav_qt::step` (back/forward) arms `QbzShell.restoreScope` with
|
||||
// the SCOPE the destination stored and `QbzShell.scrollRestore` with its
|
||||
// saved offset BEFORE it publishes `currentView`, so by the time the
|
||||
// router's Loader has built this view the arming is already on the bridge.
|
||||
//
|
||||
// ============================================================================
|
||||
// WHY THE RESTORE RE-ASSERTS INSTEAD OF FIRING ONCE
|
||||
// ============================================================================
|
||||
//
|
||||
// The first cut set `contentY` the moment the content was tall enough and then
|
||||
// dropped the arming. That is not enough, and the reason is not specific to
|
||||
// one view:
|
||||
//
|
||||
// A Qt Quick item view RESETS contentY TO 0 WHENEVER ITS MODEL IS REPLACED
|
||||
// (`QQuickItemView::setModel`), and several of this app's pages assign their
|
||||
// model from a binding whose inputs arrive over the network AFTER the mount.
|
||||
//
|
||||
// The album page is the clean example. `AlbumView.qml:208` builds `listCells`
|
||||
// from the track rows PLUS three deferred rails ("more from this artist",
|
||||
// "suggestions", "similar albums"), each of which flips a loading flag and
|
||||
// then lands its own document. So a freshly-mounted album page hands its
|
||||
// ListView a NEW array one to four times in its first couple of seconds, and
|
||||
// every one of those snaps the list back to the top — over a restore that had
|
||||
// already run correctly.
|
||||
//
|
||||
// (The same reset also throws a plain forward visit to the top if a rail lands
|
||||
// while you are reading. The re-assert below only stops it from eating the
|
||||
// restore; fixing that properly means giving those rails a model of their own,
|
||||
// which is an AlbumView change and not this file's business.)
|
||||
//
|
||||
// So the offset is held in `_wanted` and re-applied on every contentHeight
|
||||
// change, every count change and every contentY move we did not make, until
|
||||
// one of:
|
||||
//
|
||||
// - `settleMs` elapses — the page never grew back that far (the album lost
|
||||
// tracks, a filter changed), so it lands on whatever is reachable and lets
|
||||
// go; or
|
||||
// - THE USER TAKES OVER. A wheel, a drag or a flick all raise `moving`, and
|
||||
// from that instant the position is theirs. This is the guard that keeps a
|
||||
// re-asserting restore from fighting the person using it.
|
||||
//
|
||||
// The shared scope is still consumed IMMEDIATELY on the first match, so two
|
||||
// containers in one view can never both claim it; only this instance's private
|
||||
// `_armed` latch outlives that.
|
||||
//
|
||||
// GATED ON `visible`. Tabbed views (Library, Local Library, Discover) mount
|
||||
// more than one scroll container and only one of them holds the active tab's
|
||||
// content; the others sit at contentY 0 with an empty model. Without the gate
|
||||
// an inactive container's relayout would report 0 over the live page's real
|
||||
// offset.
|
||||
//
|
||||
// GPU doctrine: nothing here animates. The restore is a handful of property
|
||||
// assignments on one navigation, and at rest this component writes nothing.
|
||||
//
|
||||
// DIAGNOSTICS. Off by default (a LoggingCategory at Warning emits nothing at
|
||||
// Info). One run says which link broke — the report, the arming or the apply:
|
||||
//
|
||||
// QT_LOGGING_RULES="qbz.nav.scroll.info=true" ./target/release/qbz
|
||||
//
|
||||
// The Rust half of the same story is `log::debug!` under `[qbz-qt] scroll:` in
|
||||
// nav_qt.rs (what got stamped, what got armed), i.e. `RUST_LOG=qbz=debug`.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
/// The page's scroll container — a Flickable, ListView or GridView.
|
||||
property Flickable target: null
|
||||
/// This container's identity, chosen by the view and opaque to Rust: the
|
||||
/// route id for a single-container page ("album", "artist"), the route id
|
||||
/// plus the tab for a tabbed one ("library:albums", "local:tracks"). It is
|
||||
/// both what gets STORED with the offset and what `restoreScope` is
|
||||
/// matched against, so the two halves can never disagree — the only
|
||||
/// failure a typo can cause is a page that does not remember its position,
|
||||
/// never a page that jumps to somebody else's.
|
||||
///
|
||||
/// TABBED VIEWS MUST INCLUDE THE TAB. Without it, leaving Library on
|
||||
/// Albums and coming back to a view that mounted on All would pour the
|
||||
/// Albums offset into the All feed. With it, a tab that does not match
|
||||
/// simply does not restore.
|
||||
///
|
||||
/// An empty scope disables both halves — the opt-out for a container that
|
||||
/// is not the page (a sidebar rail, a popup list).
|
||||
property string scope: ""
|
||||
/// How long to keep re-asserting the offset against late model swaps
|
||||
/// before landing on what is reachable and letting go. 2.5 s covers the
|
||||
/// album page's three deferred rails on a slow connection; past that the
|
||||
/// page is not coming back to that height.
|
||||
property int settleMs: 2500
|
||||
|
||||
// Zero-footprint: this is plumbing, never a visual.
|
||||
width: 0
|
||||
height: 0
|
||||
visible: false
|
||||
|
||||
LoggingCategory {
|
||||
id: srLog
|
||||
name: "qbz.nav.scroll"
|
||||
defaultLogLevel: LoggingCategory.Warning
|
||||
}
|
||||
|
||||
readonly property bool _live: root.target !== null && root.target.visible
|
||||
/// Set while this instance owns a restore in flight.
|
||||
property bool _armed: false
|
||||
/// The offset that restore is heading for.
|
||||
property real _wanted: 0
|
||||
|
||||
/// Claim the arming if it is ours. Consumes the shared scope immediately
|
||||
/// (so a sibling container cannot also act on it) and moves the state into
|
||||
/// this instance's private latch.
|
||||
function _begin() {
|
||||
if (root.scope === "" || root.target === null)
|
||||
return
|
||||
if (QbzShell.restoreScope !== root.scope)
|
||||
return
|
||||
if (!root.target.visible)
|
||||
return
|
||||
var y = QbzShell.scrollRestore
|
||||
QbzShell.restoreScope = ""
|
||||
if (y <= 0)
|
||||
return
|
||||
root._wanted = y
|
||||
root._armed = true
|
||||
settle.restart()
|
||||
console.info(srLog, "[scroll] " + root.scope + " armed y=" + y
|
||||
+ " contentHeight=" + root.target.contentHeight
|
||||
+ " height=" + root.target.height)
|
||||
root._apply()
|
||||
}
|
||||
|
||||
/// Re-assert the wanted offset if the content can hold it and something
|
||||
/// has moved it away. A no-op in the common case, which is what lets it be
|
||||
/// called from five signals.
|
||||
function _apply() {
|
||||
if (!root._armed || root.target === null || !root.target.visible)
|
||||
return
|
||||
if (root.target.contentHeight - root.target.height < root._wanted)
|
||||
return // not tall enough yet — wait for the next growth
|
||||
if (Math.abs(root.target.contentY - root._wanted) < 0.5)
|
||||
return // already there
|
||||
console.info(srLog, "[scroll] " + root.scope + " apply y=" + root._wanted
|
||||
+ " was=" + root.target.contentY)
|
||||
root.target.contentY = root._wanted
|
||||
}
|
||||
|
||||
function _disarm(why) {
|
||||
if (!root._armed)
|
||||
return
|
||||
root._armed = false
|
||||
settle.stop()
|
||||
console.info(srLog, "[scroll] " + root.scope + " disarm (" + why + ")")
|
||||
}
|
||||
|
||||
/// The deadline: land on what is reachable and let go, so a page that
|
||||
/// never grows back to its old height cannot hold the latch forever.
|
||||
Timer {
|
||||
id: settle
|
||||
interval: root.settleMs
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
if (root._armed && root.target !== null) {
|
||||
var reach = Math.max(0, root.target.contentHeight - root.target.height)
|
||||
var land = Math.min(root._wanted, reach)
|
||||
if (Math.abs(root.target.contentY - land) > 0.5)
|
||||
root.target.contentY = land
|
||||
}
|
||||
root._disarm("deadline")
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: root.target
|
||||
// A GridView/ListView carries every Flickable signal plus `count`; a
|
||||
// plain Flickable has no `countChanged`. The flag is what lets one
|
||||
// component serve both.
|
||||
ignoreUnknownSignals: true
|
||||
|
||||
function onContentYChanged() {
|
||||
if (!root._live || root.scope === "")
|
||||
return
|
||||
// While a restore is in flight, report where the page is MEANT to
|
||||
// be. A model swap knocks contentY to 0 mid-window, and reporting
|
||||
// that would stamp 0 onto this entry if the user navigated away
|
||||
// before the re-assert landed.
|
||||
QbzShell.reportScroll(root.scope,
|
||||
root._armed ? root._wanted : root.target.contentY)
|
||||
// The model-reset case, caught directly: contentY moved and it was
|
||||
// not us. `_apply` is idempotent — its equality guard stops the
|
||||
// write below from recursing.
|
||||
root._apply()
|
||||
}
|
||||
function onContentHeightChanged() { root._apply() }
|
||||
function onCountChanged() { root._apply() }
|
||||
function onVisibleChanged() { root._apply() }
|
||||
|
||||
// USER TAKEOVER. A wheel, a drag or a flick all raise `moving`, and
|
||||
// from that moment the position belongs to the person, not to the
|
||||
// history. Without this the re-assert would yank the page back under
|
||||
// someone who started scrolling inside the settle window.
|
||||
function onMovingChanged() {
|
||||
if (root.target.moving)
|
||||
root._disarm("user took over")
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: QbzShell
|
||||
function onRestoreScopeChanged() { root._begin() }
|
||||
}
|
||||
|
||||
// The view was built AFTER the arming (the normal back/forward path):
|
||||
// there is no property change left to react to, so the first attempt has
|
||||
// to be made here.
|
||||
Component.onCompleted: root._begin()
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../controls"
|
||||
import "../theme"
|
||||
|
||||
Item {
|
||||
@@ -310,6 +311,10 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: flick; scope: "settings" }
|
||||
QbzScrollBar {
|
||||
target: flick
|
||||
anchors.right: parent.right
|
||||
|
||||
@@ -1485,6 +1485,10 @@ Rectangle {
|
||||
}
|
||||
|
||||
// Thin auto-hiding scrollbar (ListScrollbar).
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: pageFlick; scope: "album" }
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
|
||||
@@ -340,6 +340,10 @@ Rectangle {
|
||||
|
||||
// Tracks only the scrolling grid — the header is a sibling above,
|
||||
// which is the .slint's `y: 56px` / `height: parent.height - 56px`.
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: flick; scope: "artistreleases" }
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
|
||||
@@ -1254,6 +1254,10 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: list; scope: "scene" }
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
|
||||
@@ -2859,6 +2859,10 @@ Rectangle {
|
||||
// with the page. Hidden while the network panel is open — that panel pins
|
||||
// to the same right edge and carries its own scroll
|
||||
// (ArtistPageView.slint:1157).
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: flick; scope: "artist" }
|
||||
QbzScrollBar {
|
||||
visible: !root.networkOpen
|
||||
anchors.right: parent.right
|
||||
|
||||
@@ -203,6 +203,10 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: page; scope: "awardalbums" }
|
||||
QbzScrollBar {
|
||||
target: page
|
||||
anchors.right: parent.right
|
||||
|
||||
@@ -426,6 +426,10 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: page; scope: "award" }
|
||||
QbzScrollBar {
|
||||
target: page
|
||||
anchors.right: parent.right
|
||||
|
||||
@@ -497,6 +497,10 @@ Rectangle {
|
||||
}
|
||||
// The ListScrollbar replica — 14px gutter, inset 4 from the pane's
|
||||
// right edge, which is exactly the 18px the list gave back.
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: rowList; scope: "blacklist" }
|
||||
QbzScrollBar {
|
||||
// The control's own rule is `visible: maxScroll > 0` (its auto-hide,
|
||||
// = the Slint ListScrollbar's `auto-hide: true`). Assigning `visible`
|
||||
|
||||
@@ -211,6 +211,10 @@ Rectangle {
|
||||
}
|
||||
|
||||
// Tracks only the scrolling list (the header is a sibling above).
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: flick; scope: "discoverbrowse" }
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
|
||||
@@ -1783,6 +1783,10 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
// Thin auto-hiding scrollbar in the right gutter (ListScrollbar).
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: homeFlick; scope: "home:" + root.activeTab }
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
|
||||
@@ -332,6 +332,10 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: flick; scope: "labelreleases" }
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
|
||||
@@ -773,6 +773,10 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: flick; scope: "label" }
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
|
||||
@@ -1323,6 +1323,10 @@ Rectangle {
|
||||
}
|
||||
|
||||
// Thin auto-hiding scrollbars (ListScrollbar replica).
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: grid; scope: "library:" + root.activeTab }
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: root.alphaVisible ? 34 : 4
|
||||
@@ -1331,6 +1335,10 @@ Rectangle {
|
||||
target: grid
|
||||
visible: grid.visible && grid.contentHeight > grid.height
|
||||
}
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: list; scope: "library:" + root.activeTab }
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: root.alphaVisible ? 34 : 4
|
||||
@@ -1339,6 +1347,10 @@ Rectangle {
|
||||
target: list
|
||||
visible: list.visible && list.contentHeight > list.height
|
||||
}
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: playlistsList; scope: "library:" + root.activeTab }
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
|
||||
@@ -689,6 +689,10 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: flick; scope: "localalbum" }
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
|
||||
@@ -312,6 +312,10 @@ Rectangle {
|
||||
}
|
||||
|
||||
// Thin auto-hiding scrollbar in the right gutter (ListScrollbar).
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: flick; scope: "mix" }
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
|
||||
@@ -738,6 +738,10 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: flick; scope: "musician" }
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
|
||||
@@ -594,6 +594,10 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: rowList; scope: "offlinemanager" }
|
||||
QbzScrollBar {
|
||||
target: rowList
|
||||
anchors.right: parent.right
|
||||
|
||||
@@ -214,6 +214,10 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: flick; scope: QbzShell.currentView }
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
|
||||
@@ -458,6 +458,10 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: flick; scope: "playlistbrowse" }
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
|
||||
@@ -227,6 +227,10 @@ Rectangle {
|
||||
// the Slint ListScrollbar's `auto-hide: true`). NEVER assign `visible`
|
||||
// at this call site — that replaces the binding and a short page gets a
|
||||
// full-height thumb on gutter hover.
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: pmList; scope: "playlistmanager" }
|
||||
QbzScrollBar {
|
||||
target: pmList
|
||||
anchors.right: parent.right
|
||||
|
||||
@@ -1015,6 +1015,10 @@ Rectangle {
|
||||
color: theme.accent
|
||||
}
|
||||
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: trackList; scope: "playlist" }
|
||||
QbzScrollBar {
|
||||
target: trackList
|
||||
anchors.right: parent.right
|
||||
|
||||
@@ -987,6 +987,10 @@ Rectangle {
|
||||
}
|
||||
|
||||
// Thin auto-hiding scrollbar (ListScrollbar).
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: pageFlick; scope: "purchase-album" }
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
|
||||
@@ -538,6 +538,10 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: flick; scope: "purchases" }
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
|
||||
@@ -1144,6 +1144,10 @@ Rectangle {
|
||||
font.pixelSize: 14
|
||||
}
|
||||
}
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: bodyFlick; scope: "search" }
|
||||
QbzScrollBar {
|
||||
target: bodyFlick
|
||||
anchors.right: parent.right
|
||||
|
||||
@@ -31,6 +31,9 @@ Item {
|
||||
/// instances of this component can be alive at once (Albums, Folders-flat,
|
||||
/// the Artists detail grid), and they must not share a slot.
|
||||
property string surface: "collection"
|
||||
/// Scroll-memory scope for this list ("local:albums" / "local:folders"),
|
||||
/// empty when the host is not the page (the artist detail pane).
|
||||
property string scrollScope: ""
|
||||
/// Flat album rows (already searched/sorted/filtered by the host).
|
||||
property var rows: []
|
||||
/// [{ letter, items: [...] }] — only read when `grouped`.
|
||||
@@ -485,6 +488,12 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// Back/forward scroll memory — OPT-IN, because this component is mounted
|
||||
// by three different hosts (the Albums tab, the Folders tab and the artist
|
||||
// detail pane) and only the two that ARE the page should own a scroll
|
||||
// scope. The default "" leaves the third inert.
|
||||
ScrollMemory { target: list; scope: root.scrollScope }
|
||||
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
// Negative when the host insets its content — the bar overhangs to
|
||||
|
||||
@@ -118,6 +118,7 @@ Item {
|
||||
scrollBarInset: root.view.albumsGroup === "alpha" ? 34 : 4
|
||||
view: root.view
|
||||
surface: "albums"
|
||||
scrollScope: "local:albums"
|
||||
rows: root.view.albumsVisible
|
||||
groups: root.view.albumsGrouped
|
||||
grouped: root.view.albumsGroup !== "off"
|
||||
|
||||
@@ -150,6 +150,8 @@ Item {
|
||||
width: parent.width
|
||||
height: parent.height - 30
|
||||
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml).
|
||||
ScrollMemory { target: rail; scope: "local:artists" }
|
||||
ListView {
|
||||
id: rail
|
||||
anchors.fill: parent
|
||||
|
||||
@@ -89,6 +89,7 @@ Item {
|
||||
text: QbzSession.tr("No folders match your search.", QbzSession.trRev)
|
||||
}
|
||||
LocalAlbumCollection {
|
||||
scrollScope: "local:folders"
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 32
|
||||
anchors.rightMargin: 32
|
||||
|
||||
@@ -288,6 +288,8 @@ Item {
|
||||
list.positionViewAtIndex(index, ListView.Beginning)
|
||||
}
|
||||
}
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml).
|
||||
ScrollMemory { target: list; scope: "local:tracks" }
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
// The bar belongs on the VIEW edge, not on the content edge.
|
||||
|
||||
@@ -476,6 +476,10 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: rowList; scope: "discobuilder" }
|
||||
QbzScrollBar {
|
||||
target: rowList
|
||||
anchors.right: parent.right
|
||||
|
||||
@@ -1154,6 +1154,10 @@ Rectangle {
|
||||
// The .slint floats its ListScrollbar at `parent.width - 14 - 4`
|
||||
// (:1226-1235), i.e. INSIDE the page's 32px right padding. Mounted
|
||||
// here, outside `body`, because the grid host clips.
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: itemList; scope: "mixtapedetail" }
|
||||
QbzScrollBar {
|
||||
target: itemList
|
||||
visible: root.itemCount > 0 && root.viewMode !== "grid"
|
||||
@@ -1163,6 +1167,10 @@ Rectangle {
|
||||
anchors.top: body.top
|
||||
anchors.bottom: body.bottom
|
||||
}
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: itemGrid; scope: "mixtapedetail" }
|
||||
QbzScrollBar {
|
||||
target: itemGrid
|
||||
visible: root.itemCount > 0 && root.viewMode === "grid"
|
||||
|
||||
@@ -490,6 +490,10 @@ Rectangle {
|
||||
// and mapped the track's top 118px — visually the header/toolbar band — to
|
||||
// content that is not there, so a click in the gutter beside the TOOLBAR
|
||||
// scrolled the grid.
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: grid; scope: QbzShell.currentView }
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
@@ -498,6 +502,10 @@ Rectangle {
|
||||
target: grid
|
||||
visible: grid.visible && grid.contentHeight > grid.height
|
||||
}
|
||||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||||
// this container's offset while it is the live page, and restores it
|
||||
// when a back/forward step arms this route.
|
||||
ScrollMemory { target: list; scope: QbzShell.currentView }
|
||||
QbzScrollBar {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
|
||||
+164
-22
@@ -54,21 +54,83 @@ use std::sync::Mutex;
|
||||
|
||||
use cxx_qt_lib::QString;
|
||||
|
||||
/// One slot in the history stack: where we went, and how far it was scrolled
|
||||
/// when we last left it (the Slint `nav::Entry`, `crates/qbz/src/nav.rs:178`).
|
||||
struct Entry {
|
||||
view: String,
|
||||
/// The scroll container the offset belongs to, as the VIEW named it —
|
||||
/// "album", "library:albums", "local:tracks". Opaque here: this module
|
||||
/// never builds one and never parses one, it only carries it back.
|
||||
///
|
||||
/// THIS IS WHY THERE IS NO `scope_for(entry)` TABLE like the Slint build's
|
||||
/// (`crates/qbz/src/main.rs:3068`). Slint can derive the scope from the
|
||||
/// entry because ITS history entries carry the tab — `Favorites { tab }`
|
||||
/// and `LocalLibrary { tab }` are separate entries there. This port's
|
||||
/// entry is a bare route id, so a derived scope would read `"library"` for
|
||||
/// all five Library tabs, and coming back would drop the Albums grid's
|
||||
/// offset into whatever tab the view happened to mount on. Letting the
|
||||
/// reporter name its own scope buys tab granularity without turning tabs
|
||||
/// into history entries, and the mismatch case degrades the right way: an
|
||||
/// unmatched scope restores NOTHING instead of restoring the wrong thing.
|
||||
scope: String,
|
||||
/// Saved `contentY` of that container, in Qt's convention: 0 at the top,
|
||||
/// POSITIVE downward. (Slint stores the same distance as a negative
|
||||
/// `viewport-y`; nothing crosses between the two, so this side keeps the
|
||||
/// sign its own Flickables use.)
|
||||
scroll: f32,
|
||||
}
|
||||
|
||||
struct NavHistory {
|
||||
entries: Vec<String>,
|
||||
entries: Vec<Entry>,
|
||||
// Index of the CURRENT entry in `entries` (0 when only "home" exists).
|
||||
index: usize,
|
||||
}
|
||||
|
||||
static HISTORY: Mutex<Option<NavHistory>> = Mutex::new(None);
|
||||
|
||||
/// The on-screen page's live (scope, contentY), kept fresh by the mounted view
|
||||
/// (`QbzShell.reportScroll`, ScrollMemory.qml). Read only when LEAVING a page,
|
||||
/// so its entry can be stamped without touching the ~40 `record` call sites —
|
||||
/// the reason Slint chose the same shape.
|
||||
///
|
||||
/// A second Mutex, and the lock order is ALWAYS HISTORY then LIVE (never the
|
||||
/// reverse — `set_live_scroll` takes this one alone). The scope String is
|
||||
/// reused in place rather than reassigned, so a scroll frame allocates nothing
|
||||
/// once the page has reported once.
|
||||
static LIVE: Mutex<(String, f32)> = Mutex::new((String::new(), 0.0));
|
||||
|
||||
/// Record the on-screen scroll container's current `contentY` under its scope.
|
||||
pub fn set_live_scroll(scope: &str, y: f32) {
|
||||
let mut live = LIVE.lock().unwrap();
|
||||
if live.0 != scope {
|
||||
live.0.clear();
|
||||
live.0.push_str(scope);
|
||||
}
|
||||
live.1 = y;
|
||||
}
|
||||
|
||||
fn live_scroll() -> (String, f32) {
|
||||
let live = LIVE.lock().unwrap();
|
||||
(live.0.clone(), live.1)
|
||||
}
|
||||
|
||||
fn reset_live_scroll() {
|
||||
let mut live = LIVE.lock().unwrap();
|
||||
live.0.clear();
|
||||
live.1 = 0.0;
|
||||
}
|
||||
|
||||
fn with_history<R>(f: impl FnOnce(&mut NavHistory) -> R) -> R {
|
||||
let mut guard = HISTORY.lock().unwrap();
|
||||
let history = guard.get_or_insert_with(|| NavHistory {
|
||||
// Seeded with the STARTUP view, not a literal "home": booting on a
|
||||
// restored view with a history rooted at Home would offer a Back to a
|
||||
// page the user never opened this session.
|
||||
entries: vec![startup_view()],
|
||||
entries: vec![Entry {
|
||||
view: startup_view(),
|
||||
scope: String::new(),
|
||||
scroll: 0.0,
|
||||
}],
|
||||
index: 0,
|
||||
});
|
||||
f(history)
|
||||
@@ -231,15 +293,39 @@ pub fn record(view: &str) {
|
||||
if let Some(pref) = pref_for_view(view) {
|
||||
crate::settings_qt::save_pref("last_view", serde_json::json!(pref));
|
||||
}
|
||||
let (can_back, can_forward, current) = with_history(|h| {
|
||||
if h.entries[h.index] != view {
|
||||
let (can_back, can_forward, current, pushed) = with_history(|h| {
|
||||
let mut pushed = false;
|
||||
if h.entries[h.index].view != view {
|
||||
// Stamp the page we are leaving with its live scroll position.
|
||||
let (scope, y) = live_scroll();
|
||||
log::debug!(
|
||||
"[qbz-qt] scroll: stamping {:?} with {scope:?} y={y} on the way to {view:?}",
|
||||
h.entries[h.index].view
|
||||
);
|
||||
h.entries[h.index].scope = scope;
|
||||
h.entries[h.index].scroll = y;
|
||||
h.entries.truncate(h.index + 1);
|
||||
h.entries.push(view.to_string());
|
||||
h.entries.push(Entry {
|
||||
view: view.to_string(),
|
||||
scope: String::new(),
|
||||
scroll: 0.0,
|
||||
});
|
||||
h.index += 1;
|
||||
pushed = true;
|
||||
}
|
||||
snapshot(h)
|
||||
let (b, f, c) = snapshot(h);
|
||||
(b, f, c, pushed)
|
||||
});
|
||||
publish(can_back, can_forward, current);
|
||||
// A fresh page starts at the top; the new view reports its own scroll as
|
||||
// the user moves it.
|
||||
if pushed {
|
||||
reset_live_scroll();
|
||||
}
|
||||
// A FORWARD navigation never restores — and it must actively DISARM, or a
|
||||
// scope armed by a back() that its destination never consumed (a page
|
||||
// whose list stayed shorter than the viewport, so the guard never passed)
|
||||
// would fire on whatever mounts next.
|
||||
publish(can_back, can_forward, current, String::new(), 0.0);
|
||||
}
|
||||
|
||||
/// The id of the entry the user is standing on.
|
||||
@@ -252,43 +338,78 @@ pub fn record(view: &str) {
|
||||
/// property back off the Qt thread — hence the accessor rather than a QML
|
||||
/// round trip.
|
||||
pub fn current_view() -> String {
|
||||
with_history(|h| h.entries[h.index].clone())
|
||||
with_history(|h| h.entries[h.index].view.clone())
|
||||
}
|
||||
|
||||
/// Move one entry back, if possible.
|
||||
pub fn back() {
|
||||
let (can_back, can_forward, current) = with_history(|h| {
|
||||
if h.index > 0 {
|
||||
h.index -= 1;
|
||||
}
|
||||
snapshot(h)
|
||||
});
|
||||
publish(can_back, can_forward, current);
|
||||
step(-1);
|
||||
}
|
||||
|
||||
/// Move one entry forward, if possible.
|
||||
pub fn forward() {
|
||||
let (can_back, can_forward, current) = with_history(|h| {
|
||||
if h.index + 1 < h.entries.len() {
|
||||
h.index += 1;
|
||||
step(1);
|
||||
}
|
||||
|
||||
/// The shared body of back/forward: stamp the outgoing page's live scroll,
|
||||
/// move the cursor, and hand the destination's saved position to the shell so
|
||||
/// its scroll container can pick it up when it lays out.
|
||||
fn step(delta: isize) {
|
||||
let (can_back, can_forward, current, scope, scroll) = with_history(|h| {
|
||||
let next = h.index as isize + delta;
|
||||
if next >= 0 && (next as usize) < h.entries.len() {
|
||||
// Stamp the page we are leaving before stepping away.
|
||||
let (scope, y) = live_scroll();
|
||||
h.entries[h.index].scope = scope;
|
||||
h.entries[h.index].scroll = y;
|
||||
h.index = next as usize;
|
||||
}
|
||||
snapshot(h)
|
||||
let (b, f, c) = snapshot(h);
|
||||
let e = &h.entries[h.index];
|
||||
(b, f, c, e.scope.clone(), e.scroll)
|
||||
});
|
||||
publish(can_back, can_forward, current);
|
||||
// The destination is now the live page: seed the live pair with what it is
|
||||
// about to restore to, so leaving it again before it has reported anything
|
||||
// does not stamp the OUTGOING page's offset onto it.
|
||||
set_live_scroll(&scope, scroll);
|
||||
// Arm only for a position worth restoring. A page that was at the top
|
||||
// needs no restore, and arming for 0 would leave a scope standing until
|
||||
// some container happened to clear it.
|
||||
let armed = if scroll > 0.5 && !scope.is_empty() {
|
||||
scope
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
log::debug!("[qbz-qt] scroll: {current:?} arms scope {armed:?} y={scroll}");
|
||||
publish(can_back, can_forward, current, armed, scroll);
|
||||
}
|
||||
|
||||
fn snapshot(h: &NavHistory) -> (bool, bool, String) {
|
||||
(
|
||||
h.index > 0,
|
||||
h.index + 1 < h.entries.len(),
|
||||
h.entries[h.index].clone(),
|
||||
h.entries[h.index].view.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn publish(can_back: bool, can_forward: bool, current: String) {
|
||||
/// ORDER IS LOAD-BEARING. `set_current_view` notifies synchronously, and that
|
||||
/// notify is what re-evaluates ContentRouter's `Loader.source` and BUILDS the
|
||||
/// destination view — synchronously, inside this closure. The scroll arming
|
||||
/// must therefore already be on the bridge when it happens, or the new view's
|
||||
/// `Component.onCompleted` reads an empty scope and nothing restores.
|
||||
fn publish(
|
||||
can_back: bool,
|
||||
can_forward: bool,
|
||||
current: String,
|
||||
restore_scope: String,
|
||||
scroll_restore: f32,
|
||||
) {
|
||||
crate::shell_bridge::ui(move |mut b| {
|
||||
b.as_mut().set_can_back(can_back);
|
||||
b.as_mut().set_can_forward(can_forward);
|
||||
b.as_mut()
|
||||
.set_restore_scope(QString::from(restore_scope.as_str()));
|
||||
b.as_mut().set_scroll_restore(scroll_restore);
|
||||
b.as_mut().set_current_view(QString::from(current.as_str()));
|
||||
});
|
||||
}
|
||||
@@ -316,4 +437,25 @@ mod tests {
|
||||
// Forward -> playlist again. No panics = the invariants hold.
|
||||
super::forward();
|
||||
}
|
||||
|
||||
/// The scroll memory: a page stamped on the way out comes back with the
|
||||
/// same offset, and a fresh record resets the live value so the new page
|
||||
/// does not inherit it.
|
||||
#[test]
|
||||
fn scroll_is_stamped_on_leave_and_returned_on_back() {
|
||||
super::record("home");
|
||||
super::set_live_scroll("home:home", 1234.0);
|
||||
super::record("album");
|
||||
// The new page starts at the top, with no scope of its own yet.
|
||||
assert_eq!(super::live_scroll(), (String::new(), 0.0));
|
||||
super::set_live_scroll("album", 88.0);
|
||||
super::back();
|
||||
// Back to the stamped page: scope AND offset come back together.
|
||||
assert_eq!(super::current_view(), "home");
|
||||
assert_eq!(super::live_scroll(), ("home:home".to_string(), 1234.0));
|
||||
// ...and forward returns the album's own pair.
|
||||
super::forward();
|
||||
assert_eq!(super::current_view(), "album");
|
||||
assert_eq!(super::live_scroll(), ("album".to_string(), 88.0));
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,22 @@ pub mod qbz_shell {
|
||||
// Nav history (src/nav_qt.rs):
|
||||
#[qproperty(bool, can_back)]
|
||||
#[qproperty(bool, can_forward)]
|
||||
// --- Scroll-position memory (the Slint NavState.restore-scope /
|
||||
// scroll-restore / report-scroll trio, crates/qbz/src/nav.rs:7-14) ---
|
||||
//
|
||||
// `restore_scope` is the ROUTE ID a back/forward step wants restored,
|
||||
// "" when nothing is armed; `scroll_restore` is the `contentY` it
|
||||
// should land on. `nav_qt::step` writes BOTH before it writes
|
||||
// `current_view`, so the destination view is already armed when the
|
||||
// Loader builds it (see the order note on `nav_qt::publish`).
|
||||
//
|
||||
// WRITABLE FROM QML on purpose: the consuming scroll container clears
|
||||
// the scope the moment it applies the offset, which is what stops a
|
||||
// second container in the same view — or the same container after a
|
||||
// later relayout — from yanking the page back. Same one-shot handshake
|
||||
// Slint uses (`NavState.restore-scope = ""` at the apply site).
|
||||
#[qproperty(QString, restore_scope)]
|
||||
#[qproperty(f32, scroll_restore)]
|
||||
// --- Flyout landing tab (NavFlyout rows carry view + tab) ----------
|
||||
// The tab a nav-flyout click must land on: Discover > For You is
|
||||
// nav_tab_view="home" + nav_tab="forYou". Written by
|
||||
@@ -457,6 +473,18 @@ pub mod qbz_shell {
|
||||
#[qinvokable]
|
||||
fn toggle_lyrics(self: Pin<&mut QbzShell>);
|
||||
|
||||
/// The mounted page's live scroll offset and the SCOPE it belongs to
|
||||
/// ("album", "library:albums", ...), reported on every `contentY`
|
||||
/// change by controls/ScrollMemory.qml. Read only when a navigation
|
||||
/// stamps the page it is leaving, so this is a store and nothing else
|
||||
/// — no notify, no binding re-evaluation, and no allocation once the
|
||||
/// page has reported once. (Slint wires the same callback,
|
||||
/// `NavState.report-scroll`; the scope is this port's addition — see
|
||||
/// the note on `nav_qt::Entry::scope` for why it cannot be derived
|
||||
/// from the route here the way Slint derives it from the entry.)
|
||||
#[qinvokable]
|
||||
fn report_scroll(self: Pin<&mut QbzShell>, scope: QString, y: f32);
|
||||
|
||||
/// Sidebar navigation: record a content view ("home" | "library")
|
||||
/// and lazy-load its data on first visit.
|
||||
#[qinvokable]
|
||||
@@ -712,6 +740,8 @@ pub struct QbzShellRust {
|
||||
current_view: QString,
|
||||
can_back: bool,
|
||||
can_forward: bool,
|
||||
restore_scope: QString,
|
||||
scroll_restore: f32,
|
||||
nav_tab: QString,
|
||||
nav_tab_view: QString,
|
||||
nav_tab_seq: i32,
|
||||
@@ -801,6 +831,8 @@ impl Default for QbzShellRust {
|
||||
// AND the stored view is in the safe set.
|
||||
current_view: QString::from(crate::nav_qt::startup_view().as_str()),
|
||||
can_back: false,
|
||||
restore_scope: QString::default(),
|
||||
scroll_restore: 0.0,
|
||||
nav_tab: QString::default(),
|
||||
nav_tab_view: QString::default(),
|
||||
nav_tab_seq: 0,
|
||||
@@ -1106,6 +1138,10 @@ impl qbz_shell::QbzShell {
|
||||
crate::toggle_lyrics();
|
||||
}
|
||||
|
||||
pub fn report_scroll(self: Pin<&mut Self>, scope: QString, y: f32) {
|
||||
crate::nav_qt::set_live_scroll(&scope.to_string(), y);
|
||||
}
|
||||
|
||||
pub fn navigate_to(self: Pin<&mut Self>, view: QString) {
|
||||
crate::navigate_to(&view.to_string());
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user