Selection existed but had no keyboard: every click was a single toggle,
so picking 40 tracks meant 40 clicks. Slint and Tauri have had ranges
since 2026-06; this is that port.
NINE surfaces, not the five the track views suggested: Album, Artist,
Playlist, Label, Local Album, plus Library's tracks and albums and Local
Library's tracks and albums. Each carried its own hand-copied `selected`
map and its own `toggleSelected`, so the rule went into
controls/SelectionModel.qml and the maps stayed where they were — the
smallest change that stops it being copied a tenth time.
The rules are Tauri's `applyShiftRange`, unchanged: a plain click
toggles one row without clearing the rest, Shift fills the range from
the anchor ADDITIVELY (so re-dragging a range cannot eat the set) and
leaves the anchor where it is, and Ctrl/Cmd is the same as a plain click
— not a shortcut, but the behaviour both references already have,
because an accumulating click is what "add without disturbing" means.
Qt makes this CHEAPER than Slint, and the difference is worth recording:
Slint had no modifiers at click time and had to read a thread-local set
from a ModifiersChanged handler, with a documented stale-read edge case
when the window lost focus mid-chord. A QML MouseArea delivers
`mouse.modifiers` on the event itself, so the modifier and the click can
never disagree and no such seam exists here.
Three judgement calls, stated because they are not forced:
- Library gets TWO models, one per kind. Its rows are mixed, and one
anchor would let a Shift-click on a track sweep up the albums drawn
between it and the anchor.
- Ranges run over the VISIBLE rows. Local Library's search box is a
view filter, and a range over rows the user cannot see is not the
range they asked for — the same call `select-all` already made.
- The Local Library tree rail keeps plain toggles. Its selection lives
in Rust (QbzLocal.treeToggle*Select), so there is no QML map to hang
an anchor off; teaching the Rust side about an anchor is its own
change, and the file says so where a reader would expect otherwise.
The anchor is an ID, not an index, because every one of these views can
re-sort or filter under a live selection — an index anchor silently
points at a different track the moment the order changes. Slint's
selection.rs made the same call for the same reason.
Verified by driving the app: in Abbey Road, click row 1 then Shift-click
row 5 selects exactly rows 1-5 and leaves 6-8 untouched, with the bar
reading "5 selected"; the same in a playlist, additively over a row that
was already selected.
cc
174 lines
7.1 KiB
QML
174 lines
7.1 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()
|
|
}
|
|
// Filler: the search collapses to a 30px magnifier, so the slot
|
|
// stays right-aligned and the field opens LEFT over this gap.
|
|
Item {
|
|
// TWO buttons now, not three: "open folder" moved to the
|
|
// header's `Open` menu, which serves every medium.
|
|
width: Math.max(0, parent.width - 2 * 30 - 30 - 3 * 4)
|
|
height: 1
|
|
}
|
|
LocalSearchBox {
|
|
boxWidth: parent.width - 2 * 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)
|
|
// NO Shift-range here, deliberately: this rail's selection
|
|
// lives in RUST (QbzLocal.treeToggle*Select owns the set),
|
|
// not in a QML map, so controls/SelectionModel.qml has
|
|
// nothing to hold an anchor against. Giving the tree ranges
|
|
// means teaching the Rust side about an anchor, which is a
|
|
// change of its own and not part of this port.
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|