Files
qbz/crates/qbz-qt/qml/views/local/LocalTreeRail.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

172 lines
6.7 KiB
QML

// Folders tree-mode LEFT RAIL (LocalLibraryView.slint:1641).
//
// Header toolbar: select-mode, collapse-all, open-ephemeral-folder, then the
// search box (stretches). Below it the compact bulk bar (only in select mode
// with a selection — icon buttons, because the rail is narrow), then the
// tree itself.
//
// The tree is a FLAT array windowed by a ListView — the recursive component
// is deliberately not reproduced, and levels are fetched lazily on expand.
import QtQuick
import com.blitzfc.qbz
import "../../controls"
import "../../theme"
Item {
id: root
property var view: null
QbzTheme { id: theme }
Column {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 8
anchors.topMargin: 12
anchors.bottomMargin: 8
spacing: 8
// ---- Header toolbar ----
Row {
width: parent.width
height: 30
spacing: 4
QbzIconButton {
name: "square-check-big"
btnSize: 30
iconSize: 15
active: root.view.treeSelectMode
activeBackground: true
onClicked: root.view.toggleTreeSelectMode()
}
QbzIconButton {
name: "chevron-up"
btnSize: 30
iconSize: 15
onClicked: QbzLocal.treeCollapseAll()
}
QbzIconButton {
name: "folder-open"
btnSize: 30
iconSize: 15
onClicked: QbzLocal.ephemeralOpen()
}
// Filler: the search collapses to a 30px magnifier, so the slot
// stays right-aligned and the field opens LEFT over this gap.
Item {
width: Math.max(0, parent.width - 3 * 30 - 30 - 4 * 4)
height: 1
}
LocalSearchBox {
boxWidth: parent.width - 3 * 34
placeholder: QbzSession.tr("Search folders", QbzSession.trRev)
onEdited: function (v) {
root.view.treeSearch = v
treeSearchDebounce.restart()
}
Timer {
id: treeSearchDebounce
interval: 180
onTriggered: QbzLocal.treeSearch(root.view.treeSearch)
}
}
}
// ---- Compact bulk bar (select mode + a selection) ----
Rectangle {
visible: root.view.treeSelectMode && QbzLocal.localTreeSelectedCount > 0
width: parent.width
height: visible ? 36 : 0
radius: 8
color: theme.surfaceElevated
Row {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 6
spacing: 2
Text {
width: parent.width - 7 * 30
height: parent.height
text: QbzLocal.localTreeSelectedCount + " "
+ QbzSession.tr("sel", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
Repeater {
// ASSET GAP: Slint's "Check all" uses folder-check, which
// is not baked in the Qt icon set (see GLUE).
model: [
{ "icon": "square-check-big", "action": "select-all", "tip": QbzSession.tr("Check all", QbzSession.trRev) },
{ "icon": "list-start", "action": "play-next", "tip": QbzSession.tr("Play next", QbzSession.trRev) },
{ "icon": "list-plus", "action": "play-later", "tip": QbzSession.tr("Play later", QbzSession.trRev) },
{ "icon": "list-end", "action": "queue", "tip": QbzSession.tr("Add to queue", QbzSession.trRev) },
{ "icon": "list-music", "action": "add-to-playlist", "tip": QbzSession.tr("Add to playlist", QbzSession.trRev) },
{ "icon": "cassette-tape", "action": "add-to-mixtape", "tip": QbzSession.tr("Add to Mixtape/Collection", QbzSession.trRev) },
{ "icon": "x", "action": "clear", "tip": QbzSession.tr("Clear", QbzSession.trRev) },
]
delegate: QbzIconButton {
required property var modelData
anchors.verticalCenter: parent.verticalCenter
name: modelData.icon
btnSize: 28
iconSize: 15
onClicked: QbzLocal.foldersBulkAction(modelData.action)
}
}
}
}
// ---- The tree ----
Item {
width: parent.width
height: parent.height - 38
- (root.view.treeSelectMode && QbzLocal.localTreeSelectedCount > 0 ? 44 : 0)
// Lazy level fetch: the rail shows the shape of the tree rows
// (26px, small leading glyph) instead of a 28px spinner.
QbzSkeleton {
variant: "rowList"
anchors.fill: parent
visible: QbzLocal.localTreeLoading
rowH: 26
rowGap: 0
rowArtSize: 14
phase: root.view ? root.view.skelPhase : false
}
ListView {
id: treeList
anchors.fill: parent
visible: !QbzLocal.localTreeLoading
clip: true
cacheBuffer: 26 * 20
boundsBehavior: Flickable.StopAtBounds
model: root.view.tree
delegate: TreeRow {
required property var modelData
width: treeList.width
node: modelData
selected: modelData.path === root.view.selectedFolder
selectMode: root.view.treeSelectMode
onToggled: QbzLocal.treeToggle(modelData.path, !modelData.expanded)
onActivated: root.view.selectFolder(modelData.path)
onToggleSelect: {
if (modelData.isFolder) QbzLocal.treeToggleFolderSelect(modelData.path)
else QbzLocal.treeToggleTrackSelect(modelData.path)
}
}
}
QbzScrollBar {
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
target: treeList
visible: treeList.contentHeight > treeList.height
}
}
}
}