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

143 lines
5.2 KiB
QML

// One row of the FLATTENED folder tree (LocalLibraryView.slint:444 TreeRow).
//
// 26px, radius 6, depth indent 8 + depth*16, select-mode checkbox (folder =
// tri-state from node.selectState 0/1/2, track = node.selected), an expand
// chevron with its OWN hit target (so toggling never selects), the folder /
// music glyph and the segment name. The selection cue is the 3px accent bar
// on the left edge — ADR-008: never a pill.
// Right-click opens a folder queue menu. DIVERGENCE: LocalLibraryView.slint
// gives the tree rail no per-row menu (only the detail pane's Play button and
// the bulk bar), but the owner asked for working context menus on the local
// rows and every entry here rides a seam that already exists
// (QbzLocal.playFolder / enqueue("folder", path, mode) — local_playback.rs
// `list_folder_tracks_recursive`). TRACK leaves get no menu: their `path` is
// a file path, not a track id, so no track arm accepts it.
import QtQuick
import com.blitzfc.qbz
import "../../controls"
import "../../theme"
Rectangle {
id: root
property var node: ({})
property bool selected: false
property bool selectMode: false
signal toggled()
signal activated()
/// `modifiers` rides straight off the mouse event: Shift is what turns
/// a click into a range (controls/SelectionModel.qml).
signal toggleSelect(int modifiers)
QbzTheme { id: theme }
height: 26
radius: 6
color: selected ? theme.surfaceElevated
: rowArea.containsMouse ? theme.surfaceHover : "transparent"
// Row body — declared FIRST so the chevron / checkbox win their clicks.
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: root.node.isFolder ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton) {
if (root.node.isFolder) folderMenu.openAtCursor(rowArea, mouse.x, mouse.y)
return
}
if (root.node.isFolder) root.activated()
}
}
CardMenu {
id: folderMenu
menuWidth: 200
entries: [
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
]
onPicked: function (a) {
if (a === "play") QbzLocal.playFolder(root.node.path)
else QbzLocal.enqueue("folder", root.node.path, a)
}
}
Rectangle {
visible: root.selected
x: 0
y: 4
width: 3
height: parent.height - 8
radius: 1.5
color: theme.accent
}
Row {
anchors.fill: parent
anchors.leftMargin: 8 + (root.node.depth || 0) * 16
anchors.rightMargin: 10
spacing: 6
// Select-mode checkbox (folder tri-state / track boolean).
Item {
visible: root.selectMode
width: visible ? 13 : 0
height: parent.height
SelectCheck {
anchors.verticalCenter: parent.verticalCenter
on: root.node.isFolder ? root.node.selectState === 2
: root.node.selected === true
partial: root.node.isFolder === true && root.node.selectState === 1
onToggled: function (mods) { root.toggleSelect(mods) }
}
}
// Expand chevron — its own hit target.
Item {
width: 18
height: parent.height
QbzIcon {
visible: root.node.canExpand === true
name: root.node.expanded ? "chevron-down" : "chevron-right"
width: 11
height: 11
anchors.centerIn: parent
tintName: "muted"
}
MouseArea {
anchors.fill: parent
enabled: root.node.canExpand === true
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.toggled()
}
}
QbzIcon {
name: root.node.isFolder
? (root.node.expanded ? "folder-open" : "folder") : "music"
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: root.node.isFolder ? "accent" : "muted"
}
Text {
width: Math.max(0, parent.width - 50 - (root.node.depth || 0) * 16
- (root.selectMode ? 19 : 0))
height: parent.height
text: root.node.segment || ""
color: root.selected ? theme.textPrimary : theme.textSecondary
font.pixelSize: 13
font.weight: root.selected ? theme.weightSemibold : theme.weightRegular
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
}