Files
qbz/crates/qbz-qt/qml/views/local/LocalTreeRail.qml
T
vicrodh 750c59034b feat(qt): Excel-style multi-select — Shift ranges across every surface
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
2026-08-22 02:03:10 -06:00

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
}
}
}
}