diff --git a/crates/qbz-qt/qml/views/local/LocalTreeRail.qml b/crates/qbz-qt/qml/views/local/LocalTreeRail.qml index 4e9b32957..621a1d029 100644 --- a/crates/qbz-qt/qml/views/local/LocalTreeRail.qml +++ b/crates/qbz-qt/qml/views/local/LocalTreeRail.qml @@ -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 { diff --git a/crates/qbz-qt/qml/views/local/TreeRow.qml b/crates/qbz-qt/qml/views/local/TreeRow.qml index 18e94645d..e0c7dea77 100644 --- a/crates/qbz-qt/qml/views/local/TreeRow.qml +++ b/crates/qbz-qt/qml/views/local/TreeRow.qml @@ -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() } } diff --git a/crates/qbz-qt/src/local_bridge.rs b/crates/qbz-qt/src/local_bridge.rs index 602f94c72..090073fbf 100644 --- a/crates/qbz-qt/src/local_bridge.rs +++ b/crates/qbz-qt/src/local_bridge.rs @@ -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()); } diff --git a/crates/qbz-qt/src/local_bulk.rs b/crates/qbz-qt/src/local_bulk.rs index b76f09c32..9ab5ed209 100644 --- a/crates/qbz-qt/src/local_bulk.rs +++ b/crates/qbz-qt/src/local_bulk.rs @@ -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> = 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::>(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, 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)]); + } +} diff --git a/crates/qbz-qt/src/local_tree.rs b/crates/qbz-qt/src/local_tree.rs index 94aef190f..f824d9737 100644 --- a/crates/qbz-qt/src/local_tree.rs +++ b/crates/qbz-qt/src/local_tree.rs @@ -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:#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:#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 { + 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); + }); + } + } } } diff --git a/scripts/qml-tests/imports/com/blitzfc/qbz/QbzLocal.qml b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzLocal.qml index 194020216..9fd60f90d 100644 --- a/scripts/qml-tests/imports/com/blitzfc/qbz/QbzLocal.qml +++ b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzLocal.qml @@ -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) {} } diff --git a/scripts/qml-tests/tst_multi_select.qml b/scripts/qml-tests/tst_multi_select.qml index 91d4a6027..d63370c59 100644 --- a/scripts/qml-tests/tst_multi_select.qml +++ b/scripts/qml-tests/tst_multi_select.qml @@ -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 ---------------------------------------------