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:
7 files changed
+333
-43
No files matched your search
@@ -20,6 +20,52 @@ Item {
|
|||||||
|
|
||||||
QbzTheme { id: theme }
|
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 {
|
Column {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.leftMargin: 12
|
anchors.leftMargin: 12
|
||||||
@@ -149,16 +195,9 @@ Item {
|
|||||||
selectMode: root.view.treeSelectMode
|
selectMode: root.view.treeSelectMode
|
||||||
onToggled: QbzLocal.treeToggle(modelData.path, !modelData.expanded)
|
onToggled: QbzLocal.treeToggle(modelData.path, !modelData.expanded)
|
||||||
onActivated: root.view.selectFolder(modelData.path)
|
onActivated: root.view.selectFolder(modelData.path)
|
||||||
// NO Shift-range here, deliberately: this rail's selection
|
// Shift ranges from the rail's anchor (see
|
||||||
// lives in RUST (QbzLocal.treeToggle*Select owns the set),
|
// `toggleNodeSelect` at the top of this file).
|
||||||
// not in a QML map, so controls/SelectionModel.qml has
|
onToggleSelect: function (mods) { root.toggleNodeSelect(modelData, mods) }
|
||||||
// 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 {
|
QbzScrollBar {
|
||||||
|
|||||||
@@ -50,6 +50,15 @@ Rectangle {
|
|||||||
if (root.node.isFolder) folderMenu.openAtCursor(rowArea, mouse.x, mouse.y)
|
if (root.node.isFolder) folderMenu.openAtCursor(rowArea, mouse.x, mouse.y)
|
||||||
return
|
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()
|
if (root.node.isFolder) root.activated()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -365,6 +365,11 @@ pub mod qbz_local {
|
|||||||
/// Track checkbox: toggle one row by file path.
|
/// Track checkbox: toggle one row by file path.
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn tree_toggle_track_select(self: Pin<&mut QbzLocal>, path: QString);
|
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.
|
/// Tree-rail bulk bar.
|
||||||
#[qinvokable]
|
#[qinvokable]
|
||||||
fn folders_bulk_action(self: Pin<&mut QbzLocal>, action: QString);
|
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());
|
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) {
|
pub fn folders_bulk_action(self: Pin<&mut Self>, action: QString) {
|
||||||
crate::local_bulk::folders_bulk_action(action.to_string());
|
crate::local_bulk::folders_bulk_action(action.to_string());
|
||||||
}
|
}
|
||||||
|
|||||||
+142
-23
@@ -24,7 +24,9 @@
|
|||||||
//! This file is the Qt-facing half of the tree selection; the state and the
|
//! 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.
|
//! 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 cxx_qt_lib::QString;
|
||||||
use qbz_library::LocalTrack;
|
use qbz_library::LocalTrack;
|
||||||
@@ -61,29 +63,124 @@ fn publish_selection() {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Rail header toggle. Leaving select mode drops the selection, so the
|
/// 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) {
|
pub fn set_select_mode(on: bool) {
|
||||||
tree::set_tree_select_mode(on);
|
if on {
|
||||||
publish_selection();
|
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.
|
/// Folder checkbox: recursive, so it is a DB read on a blocking thread.
|
||||||
pub fn toggle_folder_select(path: String) {
|
pub fn toggle_folder_select(path: String) {
|
||||||
crate::spawn(async move {
|
run_tree_select(TreeSelectOp::Folder(path));
|
||||||
let _ =
|
|
||||||
tokio::task::spawn_blocking(move || tree::toggle_folder_select_blocking(&path)).await;
|
|
||||||
publish_selection();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Track checkbox: a deselect is pure state, a select resolves the record
|
/// Track checkbox: a deselect is pure state, a select resolves the record
|
||||||
/// from the parent folder listing — blocking either way.
|
/// from the parent folder listing — blocking either way.
|
||||||
pub fn toggle_track_select(path: String) {
|
pub fn toggle_track_select(path: String) {
|
||||||
crate::spawn(async move {
|
run_tree_select(TreeSelectOp::Track(path));
|
||||||
let _ =
|
}
|
||||||
tokio::task::spawn_blocking(move || tree::toggle_track_select_blocking(&path)).await;
|
|
||||||
publish_selection();
|
/// 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) {
|
pub fn folders_bulk_action(action: String) {
|
||||||
match action.as_str() {
|
match action.as_str() {
|
||||||
"select-all" => {
|
"select-all" => {
|
||||||
// Two-way: "check all" un-checks when everything already is.
|
// Two-way: "check all" un-checks when everything already is. In
|
||||||
crate::spawn(async move {
|
// the same queue as the checkboxes, so it sees them all applied.
|
||||||
let _ = tokio::task::spawn_blocking(tree::tree_select_all_blocking).await;
|
run_tree_select(TreeSelectOp::SelectAll);
|
||||||
publish_selection();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
"clear" => {
|
|
||||||
tree::tree_clear_selection();
|
|
||||||
publish_selection();
|
|
||||||
}
|
}
|
||||||
|
"clear" => run_tree_select(TreeSelectOp::Clear),
|
||||||
_ => {
|
_ => {
|
||||||
let rows = tree::tree_selected_snapshot();
|
let rows = tree::tree_selected_snapshot();
|
||||||
crate::spawn(async move {
|
crate::spawn(async move {
|
||||||
@@ -336,3 +428,30 @@ async fn enqueue_rows(rows: Vec<LocalTrack>, mode: &str) {
|
|||||||
}
|
}
|
||||||
crate::playback_qt::publish_queue(&runtime).await;
|
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)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -364,25 +364,54 @@ pub fn toggle_track_select_blocking(path: &str) {
|
|||||||
if sel(|s| s.remove(path)).is_some() {
|
if sel(|s| s.remove(path)).is_some() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// SACD leaves deliberately carry their authoritative virtual
|
if let Some(track) = resolve_track_record(path) {
|
||||||
// `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() {
|
|
||||||
sel(|s| {
|
sel(|s| {
|
||||||
s.insert(path.to_string(), track);
|
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)
|
let parent = std::path::Path::new(path)
|
||||||
.parent()
|
.parent()
|
||||||
.map(|p| p.to_string_lossy().into_owned())
|
.map(|p| p.to_string_lossy().into_owned())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let tracks = with_db(|db| db.list_folder_tracks(&parent, false)).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) {
|
tracks.into_iter().find(|t| t.file_path == path)
|
||||||
sel(|s| {
|
}
|
||||||
s.insert(path.to_string(), t);
|
|
||||||
});
|
/// 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
|
property int pairingCancels: 0
|
||||||
function mediaCancelQuickConnect() { pairingCancels++ }
|
function mediaCancelQuickConnect() { pairingCancels++ }
|
||||||
function mediaQuickConnect(url) {}
|
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) {}
|
||||||
}
|
}
|
||||||
@@ -131,6 +131,31 @@ Item {
|
|||||||
"subtitleIsLink": true, "artistId": "cb" })
|
"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 {
|
TestCase {
|
||||||
name: "MultiSelect"
|
name: "MultiSelect"
|
||||||
when: windowShown
|
when: windowShown
|
||||||
@@ -197,6 +222,9 @@ Item {
|
|||||||
QbzMyQbz.selectCalls = []
|
QbzMyQbz.selectCalls = []
|
||||||
QbzMyQbz.opened = []
|
QbzMyQbz.opened = []
|
||||||
QbzMyQbz.played = []
|
QbzMyQbz.played = []
|
||||||
|
QbzLocal.treeCalls = []
|
||||||
|
railView.opened = []
|
||||||
|
railView.treeSelectMode = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// A point of the row with nothing drawn over it but the body: the
|
// 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[0].shift, false)
|
||||||
compare(QbzMyQbz.selectCalls[1].shift, true, "Shift reaches the Rust range")
|
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 ---------------------------------------------
|
// ---- the selection rule itself ---------------------------------------------
|
||||||
|
|||||||
Reference in new issue
Block a user