feat(qt): Shift ranges in My QBZ collections

A collection keeps its selection in Rust by persisted position, and
detailToggleItemSelect only toggled. It now takes `shift`: a plain or Ctrl
click toggles and becomes the anchor, a Shift click adds every row between
the anchor and the clicked one in the order the rows are DRAWN (filtered and
sorted, not the position order), and the anchor is dropped wherever the
selection is cleared. The list rows, their checkbox and the grid cards pass
Shift through; the kiosk list passes false.

In select mode a row's artwork still started playback and its title and
subtitle still opened the item or the artist. They stand down now, so the
click selects.

cc
This commit is contained in:
vicrodh committed 2026-09-14 10:31:13 -06:00
1 parent 0fc19ac7fb
commit ee3a186f6d
8 files changed
+158 -24

No files matched your search

@@ -63,7 +63,7 @@ Rectangle {
QbzMyQbz.kioskDetailArtwork(first, last, px)
}
function openRow(r) {
if (root.doc.selectMode) QbzMyQbz.detailToggleItemSelect(r.position)
if (root.doc.selectMode) QbzMyQbz.detailToggleItemSelect(r.position, false)
else QbzMyQbz.openItem(r.source, r.itemType, r.sourceItemId)
}
function entry(label, action) { return {label: root.t(label), action: action} }
@@ -171,7 +171,9 @@ Rectangle {
}
onClicked: function (mouse) {
if (mouse.button !== Qt.LeftButton) return
if (root.selectMode) QbzMyQbz.detailToggleItemSelect(root.itemPosition)
if (root.selectMode)
QbzMyQbz.detailToggleItemSelect(root.itemPosition,
(mouse.modifiers & Qt.ShiftModifier) !== 0)
else root.openThis()
}
}
@@ -247,7 +249,10 @@ Rectangle {
visible: root.selectMode
anchors.centerIn: parent
on: root.selected
onToggled: QbzMyQbz.detailToggleItemSelect(root.itemPosition)
onToggled: function (mods) {
QbzMyQbz.detailToggleItemSelect(root.itemPosition,
(mods & Qt.ShiftModifier) !== 0)
}
}
}
@@ -295,6 +300,10 @@ Rectangle {
MouseArea {
id: artArea
anchors.fill: parent
// In select mode the whole row selects (Shift / Ctrl
// included): the artwork, title and artist stop playing
// or navigating so the click reaches the row body.
enabled: !root.selectMode
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzMyQbz.playItem(root.sourceItemId)
@@ -321,6 +330,7 @@ Rectangle {
MouseArea {
id: titleArea
anchors.fill: parent
enabled: !root.selectMode
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.openThis()
@@ -343,6 +353,7 @@ Rectangle {
MouseArea {
id: subArea
anchors.fill: parent
enabled: !root.selectMode
hoverEnabled: true
cursorShape: root.subtitleIsLink ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
@@ -1193,7 +1193,10 @@ Rectangle {
// selection checkbox rather than bolt one on.
selectMode: root.selectMode
selected: gcell.cSelected
onSelectToggled: QbzMyQbz.detailToggleItemSelect(gcell.cPosition)
onSelectToggled: function (mods) {
QbzMyQbz.detailToggleItemSelect(gcell.cPosition,
(mods & Qt.ShiftModifier) !== 0)
}
// Card body click, non-select mode only (the card
// routes a select-mode click to `selectToggled`).
onOpenRequested: QbzMyQbz.openItem(gcell.cSource,
+6 -4
View File
@@ -162,9 +162,11 @@ pub mod qbz_myqbz_bridge {
/// Leaving select mode clears the selection.
#[qinvokable]
fn detail_toggle_select_mode(self: Pin<&mut QbzMyQbz>);
/// `position` is the 0-based persisted position (the stable key).
/// `position` is the 0-based persisted position (the stable key);
/// `shift` = the click held Shift, i.e. add the range from the last
/// plain click (myqbz_detail_qt::toggle_item_select).
#[qinvokable]
fn detail_toggle_item_select(self: Pin<&mut QbzMyQbz>, position: i32);
fn detail_toggle_item_select(self: Pin<&mut QbzMyQbz>, position: i32, shift: bool);
/// Resets type/source/sort; the search text is left INTACT
/// (myqbz_detail.rs:540-541).
#[qinvokable]
@@ -472,8 +474,8 @@ impl qbz_myqbz_bridge::QbzMyQbz {
crate::myqbz_detail_qt::toggle_select_mode();
}
pub fn detail_toggle_item_select(self: Pin<&mut Self>, position: i32) {
crate::myqbz_detail_qt::toggle_item_select(position);
pub fn detail_toggle_item_select(self: Pin<&mut Self>, position: i32, shift: bool) {
crate::myqbz_detail_qt::toggle_item_select(position, shift);
}
pub fn detail_reset_filters(self: Pin<&mut Self>) {
+92 -16
View File
@@ -463,6 +463,43 @@ static RESOLVE_CACHE: LazyLock<Mutex<HashMap<String, ResolvedItem>>> =
/// the bulk actions in agreement with what the user sees (spec 02 §12 #13).
static SELECTED: LazyLock<Mutex<HashSet<i32>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
/// The POSITION a Shift-click ranges from (the last plain or Ctrl click).
/// Dropped wherever `SELECTED` is cleared, so a range never measures from a
/// row of a previous derive. Lock order: DOC, then SELECTED, then this.
static SELECT_ANCHOR: Mutex<Option<i32>> = Mutex::new(None);
fn clear_select_anchor() {
*SELECT_ANCHOR.lock().unwrap() = None;
}
/// One select-mode click over the rows as DRAWN (`order` = the rendered
/// positions, top to bottom). The rule every select-mode surface shares
/// (qml/controls/SelectionModel.qml): a plain or Ctrl click toggles `position`
/// and becomes the anchor; Shift ADDS every row from the anchor to `position`
/// and leaves the anchor where it is, so the range can be adjusted. Without a
/// drawn anchor a Shift-click is a plain toggle.
fn apply_item_click(
order: &[i32],
selected: &mut HashSet<i32>,
anchor: &mut Option<i32>,
position: i32,
shift: bool,
) {
if shift {
let from = anchor.and_then(|a| order.iter().position(|p| *p == a));
let to = order.iter().position(|p| *p == position);
if let (Some(from), Some(to)) = (from, to) {
let (lo, hi) = if from <= to { (from, to) } else { (to, from) };
selected.extend(order[lo..=hi].iter().copied());
return;
}
}
if !selected.remove(&position) {
selected.insert(position);
}
*anchor = Some(position);
}
/// Fingerprint of the last derive's filter/sort/search inputs — what decides
/// whether a rebuild is a RE-DERIVE (clears the selection) or a row patch.
static LAST_FILTER_KEY: Mutex<Option<String>> = Mutex::new(None);
@@ -1080,6 +1117,7 @@ fn rebuild(d: &mut DetailDoc) {
// reference, the COUNT below is recomputed from the same set, so the
// bulk bar can never claim a selection the rows do not show.
SELECTED.lock().unwrap().clear();
clear_select_anchor();
}
let view = filtered_sorted(d);
@@ -1431,6 +1469,7 @@ fn reset() {
// `apply` seeds this one back from it.
OPEN_ROWS.lock().unwrap().clear();
SELECTED.lock().unwrap().clear();
clear_select_anchor();
*LAST_FILTER_KEY.lock().unwrap() = None;
// Close the persist gate until `apply` restores this collection's prefs.
PREFS_HYDRATED.store(false, Ordering::SeqCst);
@@ -2158,6 +2197,7 @@ pub(crate) fn toggle_select_mode() {
let on = !d.select_mode;
if !on {
SELECTED.lock().unwrap().clear();
clear_select_anchor();
for row in d.items.iter_mut() {
row.selected = false;
}
@@ -2169,24 +2209,19 @@ pub(crate) fn toggle_select_mode() {
publish(&doc);
}
/// Toggle one row's selection by its stable POSITION, then recount. A row patch
/// — never a re-derive.
pub(crate) fn toggle_item_select(position: i32) {
/// One select-mode click on a row, by its stable POSITION: a toggle, or with
/// `shift` the range from the anchor (`apply_item_click`). Then every drawn
/// row's tick and the count follow the set. A row patch — never a re-derive.
pub(crate) fn toggle_item_select(position: i32, shift: bool) {
let doc = with_doc(|d| {
let now_selected = {
let mut set = SELECTED.lock().unwrap();
if set.contains(&position) {
set.remove(&position);
false
} else {
set.insert(position);
true
}
};
if let Some(row) = d.items.iter_mut().find(|r| r.position == position) {
row.selected = now_selected;
let order: Vec<i32> = d.items.iter().map(|r| r.position).collect();
let mut set = SELECTED.lock().unwrap();
let mut anchor = SELECT_ANCHOR.lock().unwrap();
apply_item_click(&order, &mut set, &mut anchor, position, shift);
for row in d.items.iter_mut() {
row.selected = set.contains(&row.position);
}
d.selected_count = SELECTED.lock().unwrap().len() as i32;
d.selected_count = set.len() as i32;
d.clone()
});
publish(&doc);
@@ -2197,6 +2232,7 @@ pub(crate) fn toggle_item_select(position: i32) {
pub(crate) fn clear_selection() {
let doc = with_doc(|d| {
SELECTED.lock().unwrap().clear();
clear_select_anchor();
for row in d.items.iter_mut() {
row.selected = false;
}
@@ -2374,6 +2410,7 @@ pub(crate) fn teardown() {
RESOLVE_CACHE.lock().unwrap().clear();
OPEN_ROWS.lock().unwrap().clear();
SELECTED.lock().unwrap().clear();
clear_select_anchor();
*LAST_FILTER_KEY.lock().unwrap() = None;
PREFS_HYDRATED.store(false, Ordering::SeqCst);
let doc = with_doc(|d| {
@@ -2495,6 +2532,45 @@ pub(crate) fn bulk_action(id: String) {
mod tests {
use super::*;
fn picked(set: &HashSet<i32>) -> Vec<i32> {
let mut out: Vec<i32> = set.iter().copied().collect();
out.sort_unstable();
out
}
#[test]
fn select_click_ranges_over_the_drawn_order() {
// Rows drawn in a sorted order that is NOT the position order.
let order = [4, 0, 7, 2, 9, 5];
let mut set = HashSet::new();
let mut anchor = None;
apply_item_click(&order, &mut set, &mut anchor, 0, false);
assert_eq!(anchor, Some(0));
apply_item_click(&order, &mut set, &mut anchor, 9, true);
// 0, 7, 2, 9 are the drawn rows between them; 4 and 5 are outside.
assert_eq!(picked(&set), vec![0, 2, 7, 9]);
assert_eq!(anchor, Some(0), "a Shift-click keeps the anchor");
// Re-dragging the range upward only ever adds.
apply_item_click(&order, &mut set, &mut anchor, 4, true);
assert_eq!(picked(&set), vec![0, 2, 4, 7, 9]);
}
#[test]
fn select_click_toggles_and_moves_the_anchor() {
let order = [1, 2, 3, 4];
let mut set = HashSet::new();
let mut anchor = None;
apply_item_click(&order, &mut set, &mut anchor, 3, false);
apply_item_click(&order, &mut set, &mut anchor, 3, false);
assert!(set.is_empty(), "a second plain click unticks the row");
assert_eq!(anchor, Some(3));
// Shift without a drawn anchor is a plain toggle that anchors there.
let mut gone = Some(42);
apply_item_click(&order, &mut set, &mut gone, 2, true);
assert_eq!(picked(&set), vec![2]);
assert_eq!(gone, Some(2));
}
fn item(
position: i32,
title: &str,
@@ -0,0 +1,14 @@
pragma Singleton
import QtQuick
QtObject {
property var selectCalls: []
property var opened: []
property var played: []
function detailToggleItemSelect(position, shift) {
selectCalls = selectCalls.concat([{ "position": position, "shift": shift }])
}
function openItem(source, itemType, id) { opened = opened.concat([String(id)]) }
function openArtist(source, name, id) { opened = opened.concat(["artist:" + name]) }
function playItem(id) { played = played.concat([String(id)]) }
function detailToggleRowExpand(id) {}
}
@@ -13,3 +13,4 @@ singleton QbzArtist 1.0 QbzArtist.qml
singleton QbzAlbum 1.0 QbzAlbum.qml
singleton QbzSearch 1.0 QbzSearch.qml
singleton QbzHome 1.0 QbzHome.qml
singleton QbzMyQbz 1.0 QbzMyQbz.qml
+27
View File
@@ -12,6 +12,7 @@ import "../../crates/qbz-qt/qml/rows"
import "../../crates/qbz-qt/qml/cards"
import "../../crates/qbz-qt/qml/views" as Views
import "../../crates/qbz-qt/qml/views/local" as Local
import "../../crates/qbz-qt/qml/views/myqbz" as MyQbz
Item {
id: root
@@ -119,6 +120,17 @@ Item {
onToggled: function (mods) { root.selectCheckMods = root.selectCheckMods.concat([mods]) }
}
// ---- a My QBZ collection row (selection lives in Rust) ----------------------
MyQbz.MyQbzDetailRow {
id: myqbzRow
x: 0; y: 820
width: 1000
selectMode: true
item: ({ "position": 3, "itemType": "album", "source": "qobuz",
"sourceItemId": "alb3", "title": "Collected record", "subtitle": "Collected band",
"subtitleIsLink": true, "artistId": "cb" })
}
TestCase {
name: "MultiSelect"
when: windowShown
@@ -182,6 +194,9 @@ Item {
QbzArtist.opened = []
QbzAlbum.opened = []
QbzHome.labels = []
QbzMyQbz.selectCalls = []
QbzMyQbz.opened = []
QbzMyQbz.played = []
}
// A point of the row with nothing drawn over it but the body: the
@@ -278,6 +293,18 @@ Item {
compare(root.checkboxMods.length, 2)
compare(root.checkboxMods[1] & Qt.ShiftModifier, 0, "the keyboard toggle is a plain toggle")
}
function test_myqbz_row_targets_select_with_shift() {
var title = textItem(myqbzRow, "Collected record")
clickOn(title, 10, title.height / 2)
var subtitle = textItem(myqbzRow, "Collected band")
clickOn(subtitle, 10, subtitle.height / 2, Qt.ShiftModifier)
compare(QbzMyQbz.opened.length, 0, "nothing opens in select mode")
compare(QbzMyQbz.selectCalls.length, 2)
compare(QbzMyQbz.selectCalls[0].position, 3)
compare(QbzMyQbz.selectCalls[0].shift, false)
compare(QbzMyQbz.selectCalls[1].shift, true, "Shift reaches the Rust range")
}
}
// ---- the selection rule itself ---------------------------------------------