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
143 lines
5.2 KiB
QML
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
|
|
}
|
|
}
|
|
}
|