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 }
|
||||
|
||||
// ---- 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)]);
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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 ---------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user