feat(qt): Shift ranges in the Local Library folder rail

The rail's selection lives in Rust as a set of file paths, so it had no
anchor and stayed toggle-only (750c59034 said as much). The rail now keeps
the anchor path and, on Shift, sends every drawn row from it to the clicked
row to a new treeSelectRange, which SELECTS: a folder with its whole
subtree, a track with its own record, so a range only ever adds. In select
mode a Shift or Ctrl click on a row body selects it instead of opening the
folder; a plain click still opens it.

The rail's selection mutations used to be spawned side by side. With ranges
that became a race: a range that finished before the click that set its
anchor would be undone by that late toggle. They now run one at a time in
click order, with select-all, clear and leaving select mode in the same
queue.

cc
This commit is contained in:
vicrodh committed 2026-09-14 10:31:22 -06:00
1 parent ee3a186f6d
commit b43771cc3e
7 files changed
+333 -43

No files matched your search

+49 -10
View File
@@ -20,6 +20,52 @@ Item {
QbzTheme { id: theme }
// ---- Shift / Ctrl on the rail's checkboxes ----
// The selection lives in RUST (QbzLocal.tree*Select owns the set of
// paths); what the rail holds is the one piece the rule needs on top of
// it, the ANCHOR: the path of the last plain or Ctrl click. A Shift-click
// looks it up among the rows drawn right now and SELECTS every row from
// there to the clicked one (QbzLocal.treeSelectRange — additive, like
// controls/SelectionModel.qml); an anchor that is not drawn any more
// (collapsed, filtered away) makes it a plain toggle again.
property string selectAnchor: ""
Connections {
target: root.view
function onTreeSelectModeChanged() {
if (!root.view.treeSelectMode)
root.selectAnchor = ""
}
}
/// The rows from `anchorPath` to `path`, inclusive, in rail order, as the
/// `{path, isFolder}` pairs treeSelectRange takes. [] when either is not
/// drawn.
function rangeNodes(rows, anchorPath, path) {
var from = -1
var to = -1
for (var i = 0; i < rows.length; i++) {
if (rows[i].path === anchorPath) from = i
if (rows[i].path === path) to = i
}
if (from < 0 || to < 0)
return []
var out = []
for (var j = Math.min(from, to); j <= Math.max(from, to); j++)
out.push({ "path": rows[j].path, "isFolder": rows[j].isFolder === true })
return out
}
function toggleNodeSelect(node, mods) {
if (((mods || 0) & Qt.ShiftModifier) !== 0 && root.selectAnchor !== "") {
var nodes = root.rangeNodes(root.view.tree || [], root.selectAnchor, node.path)
if (nodes.length > 0) {
QbzLocal.treeSelectRange(JSON.stringify(nodes))
return
}
}
if (node.isFolder) QbzLocal.treeToggleFolderSelect(node.path)
else QbzLocal.treeToggleTrackSelect(node.path)
root.selectAnchor = node.path
}
Column {
anchors.fill: parent
anchors.leftMargin: 12
@@ -149,16 +195,9 @@ Item {
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)
}
// Shift ranges from the rail's anchor (see
// `toggleNodeSelect` at the top of this file).
onToggleSelect: function (mods) { root.toggleNodeSelect(modelData, mods) }
}
}
QbzScrollBar {
@@ -50,6 +50,15 @@ Rectangle {
if (root.node.isFolder) folderMenu.openAtCursor(rowArea, mouse.x, mouse.y)
return
}
// In select mode a Shift or Ctrl click on the body is a selection
// gesture, as in any file list: it ticks (or ranges) the row
// instead of opening the folder. A plain click still opens it.
if (root.selectMode
&& (mouse.modifiers & (Qt.ShiftModifier | Qt.ControlModifier
| Qt.MetaModifier)) !== 0) {
root.toggleSelect(mouse.modifiers)
return
}
if (root.node.isFolder) root.activated()
}
}
+9
View File
@@ -365,6 +365,11 @@ pub mod qbz_local {
/// Track checkbox: toggle one row by file path.
#[qinvokable]
fn tree_toggle_track_select(self: Pin<&mut QbzLocal>, path: QString);
/// Shift-click on a rail checkbox: SELECT every visible row from the
/// anchor to the clicked one. `nodes_json` =
/// `[{"path": …, "isFolder": …}]` in rail order.
#[qinvokable]
fn tree_select_range(self: Pin<&mut QbzLocal>, nodes_json: QString);
/// Tree-rail bulk bar.
#[qinvokable]
fn folders_bulk_action(self: Pin<&mut QbzLocal>, action: QString);
@@ -1757,6 +1762,10 @@ impl qbz_local::QbzLocal {
crate::local_bulk::toggle_track_select(path.to_string());
}
pub fn tree_select_range(self: Pin<&mut Self>, nodes_json: QString) {
crate::local_bulk::select_tree_range(nodes_json.to_string());
}
pub fn folders_bulk_action(self: Pin<&mut Self>, action: QString) {
crate::local_bulk::folders_bulk_action(action.to_string());
}
+142 -23
View File
@@ -24,7 +24,9 @@
//! This file is the Qt-facing half of the tree selection; the state and the
//! blocking mutators live in `local_tree.rs`, next to the tree they annotate.
use std::collections::HashMap;
use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use cxx_qt_lib::QString;
use qbz_library::LocalTrack;
@@ -61,29 +63,124 @@ fn publish_selection() {
// ---------------------------------------------------------------------------
/// Rail header toggle. Leaving select mode drops the selection, so the
/// republish is what hollows every checkbox again.
/// republish is what hollows every checkbox again. The drop waits behind any
/// checkbox click still queued, or that click would tick a row again after it.
pub fn set_select_mode(on: bool) {
tree::set_tree_select_mode(on);
publish_selection();
if on {
publish_selection();
} else {
run_tree_select(TreeSelectOp::LeaveSelectMode);
}
}
/// One tree-selection mutation, as the user issued it.
enum TreeSelectOp {
Folder(String),
Track(String),
Range(Vec<(String, bool)>),
SelectAll,
Clear,
LeaveSelectMode,
}
/// The rail's selection mutations run ONE AT A TIME, in the order they were
/// clicked. Each is a blocking DB read, and they used to be spawned side by
/// side: harmless while every click was a toggle of its own row, but a
/// Shift-range that finished before the plain click that set its anchor would
/// see that folder already selected by the range — and the late toggle would
/// then UNselect it. Clicks push here on the Qt thread, so the queue order is
/// the click order; a single drainer works through it.
static TREE_SELECT_OPS: Mutex<VecDeque<TreeSelectOp>> = Mutex::new(VecDeque::new());
static TREE_SELECT_DRAINING: AtomicBool = AtomicBool::new(false);
fn run_tree_select(op: TreeSelectOp) {
TREE_SELECT_OPS
.lock()
.unwrap_or_else(|e| e.into_inner())
.push_back(op);
if TREE_SELECT_DRAINING.swap(true, Ordering::SeqCst) {
return;
}
crate::spawn(async move {
// A drainer that dies mid-queue must not leave the flag up, or no
// later click would ever be applied.
struct Unwedge;
impl Drop for Unwedge {
fn drop(&mut self) {
if std::thread::panicking() {
TREE_SELECT_DRAINING.store(false, Ordering::SeqCst);
}
}
}
let _unwedge = Unwedge;
loop {
let next = TREE_SELECT_OPS
.lock()
.unwrap_or_else(|e| e.into_inner())
.pop_front();
let Some(op) = next else {
TREE_SELECT_DRAINING.store(false, Ordering::SeqCst);
// A click that queued between the empty pop and the store saw
// a drainer still running and spawned none: take it over.
let pending = !TREE_SELECT_OPS
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_empty();
if pending && !TREE_SELECT_DRAINING.swap(true, Ordering::SeqCst) {
continue;
}
break;
};
let _ = tokio::task::spawn_blocking(move || match op {
TreeSelectOp::Folder(path) => tree::toggle_folder_select_blocking(&path),
TreeSelectOp::Track(path) => tree::toggle_track_select_blocking(&path),
TreeSelectOp::Range(nodes) => tree::select_nodes_blocking(&nodes),
TreeSelectOp::SelectAll => tree::tree_select_all_blocking(),
TreeSelectOp::Clear => tree::tree_clear_selection(),
TreeSelectOp::LeaveSelectMode => tree::set_tree_select_mode(false),
})
.await;
publish_selection();
}
});
}
/// Folder checkbox: recursive, so it is a DB read on a blocking thread.
pub fn toggle_folder_select(path: String) {
crate::spawn(async move {
let _ =
tokio::task::spawn_blocking(move || tree::toggle_folder_select_blocking(&path)).await;
publish_selection();
});
run_tree_select(TreeSelectOp::Folder(path));
}
/// Track checkbox: a deselect is pure state, a select resolves the record
/// from the parent folder listing — blocking either way.
pub fn toggle_track_select(path: String) {
crate::spawn(async move {
let _ =
tokio::task::spawn_blocking(move || tree::toggle_track_select_blocking(&path)).await;
publish_selection();
});
run_tree_select(TreeSelectOp::Track(path));
}
/// Shift-click on a rail checkbox: `nodes_json` is the visible rows from the
/// anchor to the clicked one, `[{"path": …, "isFolder": …}]`, all SELECTED
/// (`local_tree::select_nodes_blocking`).
pub fn select_tree_range(nodes_json: String) {
let nodes = parse_tree_range(&nodes_json);
if nodes.is_empty() {
return;
}
run_tree_select(TreeSelectOp::Range(nodes));
}
fn parse_tree_range(json: &str) -> Vec<(String, bool)> {
#[derive(serde::Deserialize)]
struct Node {
#[serde(default)]
path: String,
#[serde(rename = "isFolder", default)]
is_folder: bool,
}
serde_json::from_str::<Vec<Node>>(json)
.unwrap_or_default()
.into_iter()
.filter(|node| !node.path.is_empty())
.map(|node| (node.path, node.is_folder))
.collect()
}
// ---------------------------------------------------------------------------
@@ -99,16 +196,11 @@ pub fn toggle_track_select(path: String) {
pub fn folders_bulk_action(action: String) {
match action.as_str() {
"select-all" => {
// Two-way: "check all" un-checks when everything already is.
crate::spawn(async move {
let _ = tokio::task::spawn_blocking(tree::tree_select_all_blocking).await;
publish_selection();
});
}
"clear" => {
tree::tree_clear_selection();
publish_selection();
// Two-way: "check all" un-checks when everything already is. In
// the same queue as the checkboxes, so it sees them all applied.
run_tree_select(TreeSelectOp::SelectAll);
}
"clear" => run_tree_select(TreeSelectOp::Clear),
_ => {
let rows = tree::tree_selected_snapshot();
crate::spawn(async move {
@@ -336,3 +428,30 @@ async fn enqueue_rows(rows: Vec<LocalTrack>, mode: &str) {
}
crate::playback_qt::publish_queue(&runtime).await;
}
#[cfg(test)]
mod tree_range_tests {
use super::parse_tree_range;
#[test]
fn range_nodes_keep_their_order_and_kind() {
let nodes = parse_tree_range(
r#"[{"path":"/m/a","isFolder":true},{"path":"/m/a/1.flac","isFolder":false},{"path":"/m/b","isFolder":true}]"#,
);
assert_eq!(
nodes,
vec![
("/m/a".to_string(), true),
("/m/a/1.flac".to_string(), false),
("/m/b".to_string(), true),
]
);
}
#[test]
fn range_nodes_without_a_path_or_bad_json_are_dropped() {
assert!(parse_tree_range("not json").is_empty());
let nodes = parse_tree_range(r#"[{"isFolder":true},{"path":"/m/c"}]"#);
assert_eq!(nodes, vec![("/m/c".to_string(), false)]);
}
}
+39 -10
View File
@@ -364,25 +364,54 @@ pub fn toggle_track_select_blocking(path: &str) {
if sel(|s| s.remove(path)).is_some() {
return;
}
// SACD leaves deliberately carry their authoritative virtual
// `sacd:<image>#N` path, whose filesystem parent is not the synthetic ISO
// folder shown in the tree. Resolve the exact key first; ordinary files
// take this same cheap indexed path and no longer need a parent listing.
if let Some(track) = with_db(|db| db.get_track_by_path(path)).flatten() {
if let Some(track) = resolve_track_record(path) {
sel(|s| {
s.insert(path.to_string(), track);
});
return;
}
}
/// The library record behind a track leaf's path.
///
/// SACD leaves deliberately carry their authoritative virtual
/// `sacd:<image>#N` path, whose filesystem parent is not the synthetic ISO
/// folder shown in the tree. Resolve the exact key first; ordinary files take
/// this same cheap indexed path and no longer need a parent listing.
fn resolve_track_record(path: &str) -> Option<LocalTrack> {
if let Some(track) = with_db(|db| db.get_track_by_path(path)).flatten() {
return Some(track);
}
let parent = std::path::Path::new(path)
.parent()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let tracks = with_db(|db| db.list_folder_tracks(&parent, false)).unwrap_or_default();
if let Some(t) = tracks.into_iter().find(|t| t.file_path == path) {
sel(|s| {
s.insert(path.to_string(), t);
});
tracks.into_iter().find(|t| t.file_path == path)
}
/// Shift-click in the rail: SELECT — never toggle — every node the range
/// covers (`nodes` = `(path, is_folder)` of the visible rows from the anchor
/// to the clicked row). A folder brings its whole subtree and a track its own
/// record. Inserting is idempotent, so a folder and its expanded children
/// sharing the range, or rows that were already ticked, simply stay ticked:
/// a range only ever adds, like every other select-mode surface.
pub fn select_nodes_blocking(nodes: &[(String, bool)]) {
for (path, is_folder) in nodes {
if *is_folder {
let tracks =
with_db(|db| db.list_folder_tracks_recursive(path, false)).unwrap_or_default();
sel(|s| {
for t in tracks {
s.insert(t.file_path.clone(), t);
}
});
} else if !sel(|s| s.contains_key(path.as_str())) {
if let Some(track) = resolve_track_record(path) {
sel(|s| {
s.insert(path.clone(), track);
});
}
}
}
}
@@ -6,4 +6,18 @@ QtObject {
property int pairingCancels: 0
function mediaCancelQuickConnect() { pairingCancels++ }
function mediaQuickConnect(url) {}
// Folder tree rail (LocalTreeRail / TreeRow).
property bool localTreeLoading: false
property int localTreeSelectedCount: 0
property var treeCalls: []
function treeToggleFolderSelect(path) { treeCalls = treeCalls.concat(["folder:" + path]) }
function treeToggleTrackSelect(path) { treeCalls = treeCalls.concat(["track:" + path]) }
function treeSelectRange(json) { treeCalls = treeCalls.concat(["range:" + json]) }
function treeToggle(path, open) {}
function treeCollapseAll() {}
function treeSearch(query) {}
function foldersBulkAction(action) {}
function playFolder(path) {}
function enqueue(kind, id, mode) {}
}
+71
View File
@@ -131,6 +131,31 @@ Item {
"subtitleIsLink": true, "artistId": "cb" })
}
// ---- the Local Library folder rail (selection lives in Rust) ----------------
QtObject {
id: railView
property bool treeSelectMode: true
property string selectedFolder: ""
property string treeSearch: ""
property bool skelPhase: false
property var opened: []
property var tree: [
{ "path": "/m/a", "segment": "a", "depth": 0, "isFolder": true, "canExpand": true },
{ "path": "/m/a/1.flac", "segment": "1.flac", "depth": 1, "isFolder": false },
{ "path": "/m/b", "segment": "b", "depth": 0, "isFolder": true, "canExpand": true },
{ "path": "/m/c", "segment": "c", "depth": 0, "isFolder": true, "canExpand": true },
{ "path": "/m/d", "segment": "d", "depth": 0, "isFolder": true, "canExpand": true }
]
function toggleTreeSelectMode() { treeSelectMode = !treeSelectMode }
function selectFolder(path) { opened = opened.concat([path]) }
}
Local.LocalTreeRail {
id: rail
x: 1000; y: 400
width: 200; height: 400
view: railView
}
TestCase {
name: "MultiSelect"
when: windowShown
@@ -197,6 +222,9 @@ Item {
QbzMyQbz.selectCalls = []
QbzMyQbz.opened = []
QbzMyQbz.played = []
QbzLocal.treeCalls = []
railView.opened = []
railView.treeSelectMode = true
}
// A point of the row with nothing drawn over it but the body: the
@@ -305,6 +333,49 @@ Item {
compare(QbzMyQbz.selectCalls[0].shift, false)
compare(QbzMyQbz.selectCalls[1].shift, true, "Shift reaches the Rust range")
}
function railRow(path) {
var list = find(rail, function (it) { return it.model !== undefined && it.itemAtIndex !== undefined })
verify(list !== null, "the rail list exists")
for (var i = 0; i < railView.tree.length; i++)
if (railView.tree[i].path === path)
return list.itemAtIndex(i)
return null
}
function railCheck(path) {
var row = railRow(path)
verify(row !== null, "rail row " + path + " is drawn")
var check = find(row, function (it) {
return it.partial !== undefined && it.on !== undefined && shown(it)
})
verify(check !== null, "rail checkbox " + path + " is drawn")
return check
}
function test_folder_rail_shift_range_and_modifier_body_click() {
tryVerify(function () { return railRow("/m/d") !== null }, 2000)
clickOn(railCheck("/m/a"), 6, 6)
clickOn(railCheck("/m/c"), 6, 6, Qt.ShiftModifier)
compare(QbzLocal.treeCalls.length, 2)
compare(QbzLocal.treeCalls[0], "folder:/m/a")
var range = JSON.parse(QbzLocal.treeCalls[1].slice("range:".length))
compare(range.map(function (n) { return n.path }).join(","), "/m/a,/m/a/1.flac,/m/b,/m/c")
compare(range[1].isFolder, false)
// A Ctrl click on a row body selects instead of opening the folder.
var d = railRow("/m/d")
clickOn(d, d.width - 20, d.height / 2, Qt.ControlModifier)
compare(QbzLocal.treeCalls[2], "folder:/m/d")
compare(railView.opened.length, 0)
// A plain click on the body still opens it.
clickOn(d, d.width - 20, d.height / 2)
compare(railView.opened.length, 1)
// Leaving select mode forgets the anchor: Shift is a plain toggle.
railView.treeSelectMode = false
railView.treeSelectMode = true
wait(0)
clickOn(railCheck("/m/b"), 6, 6, Qt.ShiftModifier)
compare(QbzLocal.treeCalls[3], "folder:/m/b")
}
}
// ---- the selection rule itself ---------------------------------------------