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) QbzMyQbz.kioskDetailArtwork(first, last, px)
} }
function openRow(r) { 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) else QbzMyQbz.openItem(r.source, r.itemType, r.sourceItemId)
} }
function entry(label, action) { return {label: root.t(label), action: action} } function entry(label, action) { return {label: root.t(label), action: action} }
@@ -171,7 +171,9 @@ Rectangle {
} }
onClicked: function (mouse) { onClicked: function (mouse) {
if (mouse.button !== Qt.LeftButton) return 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() else root.openThis()
} }
} }
@@ -247,7 +249,10 @@ Rectangle {
visible: root.selectMode visible: root.selectMode
anchors.centerIn: parent anchors.centerIn: parent
on: root.selected 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 { MouseArea {
id: artArea id: artArea
anchors.fill: parent 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 hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: QbzMyQbz.playItem(root.sourceItemId) onClicked: QbzMyQbz.playItem(root.sourceItemId)
@@ -321,6 +330,7 @@ Rectangle {
MouseArea { MouseArea {
id: titleArea id: titleArea
anchors.fill: parent anchors.fill: parent
enabled: !root.selectMode
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: root.openThis() onClicked: root.openThis()
@@ -343,6 +353,7 @@ Rectangle {
MouseArea { MouseArea {
id: subArea id: subArea
anchors.fill: parent anchors.fill: parent
enabled: !root.selectMode
hoverEnabled: true hoverEnabled: true
cursorShape: root.subtitleIsLink ? Qt.PointingHandCursor : Qt.ArrowCursor cursorShape: root.subtitleIsLink ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: { onClicked: {
@@ -1193,7 +1193,10 @@ Rectangle {
// selection checkbox rather than bolt one on. // selection checkbox rather than bolt one on.
selectMode: root.selectMode selectMode: root.selectMode
selected: gcell.cSelected 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 // Card body click, non-select mode only (the card
// routes a select-mode click to `selectToggled`). // routes a select-mode click to `selectToggled`).
onOpenRequested: QbzMyQbz.openItem(gcell.cSource, onOpenRequested: QbzMyQbz.openItem(gcell.cSource,
+6 -4
View File
@@ -162,9 +162,11 @@ pub mod qbz_myqbz_bridge {
/// Leaving select mode clears the selection. /// Leaving select mode clears the selection.
#[qinvokable] #[qinvokable]
fn detail_toggle_select_mode(self: Pin<&mut QbzMyQbz>); 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] #[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 /// Resets type/source/sort; the search text is left INTACT
/// (myqbz_detail.rs:540-541). /// (myqbz_detail.rs:540-541).
#[qinvokable] #[qinvokable]
@@ -472,8 +474,8 @@ impl qbz_myqbz_bridge::QbzMyQbz {
crate::myqbz_detail_qt::toggle_select_mode(); crate::myqbz_detail_qt::toggle_select_mode();
} }
pub fn detail_toggle_item_select(self: Pin<&mut Self>, position: i32) { pub fn detail_toggle_item_select(self: Pin<&mut Self>, position: i32, shift: bool) {
crate::myqbz_detail_qt::toggle_item_select(position); crate::myqbz_detail_qt::toggle_item_select(position, shift);
} }
pub fn detail_reset_filters(self: Pin<&mut Self>) { 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). /// 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())); 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 /// 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. /// whether a rebuild is a RE-DERIVE (clears the selection) or a row patch.
static LAST_FILTER_KEY: Mutex<Option<String>> = Mutex::new(None); 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 // reference, the COUNT below is recomputed from the same set, so the
// bulk bar can never claim a selection the rows do not show. // bulk bar can never claim a selection the rows do not show.
SELECTED.lock().unwrap().clear(); SELECTED.lock().unwrap().clear();
clear_select_anchor();
} }
let view = filtered_sorted(d); let view = filtered_sorted(d);
@@ -1431,6 +1469,7 @@ fn reset() {
// `apply` seeds this one back from it. // `apply` seeds this one back from it.
OPEN_ROWS.lock().unwrap().clear(); OPEN_ROWS.lock().unwrap().clear();
SELECTED.lock().unwrap().clear(); SELECTED.lock().unwrap().clear();
clear_select_anchor();
*LAST_FILTER_KEY.lock().unwrap() = None; *LAST_FILTER_KEY.lock().unwrap() = None;
// Close the persist gate until `apply` restores this collection's prefs. // Close the persist gate until `apply` restores this collection's prefs.
PREFS_HYDRATED.store(false, Ordering::SeqCst); PREFS_HYDRATED.store(false, Ordering::SeqCst);
@@ -2158,6 +2197,7 @@ pub(crate) fn toggle_select_mode() {
let on = !d.select_mode; let on = !d.select_mode;
if !on { if !on {
SELECTED.lock().unwrap().clear(); SELECTED.lock().unwrap().clear();
clear_select_anchor();
for row in d.items.iter_mut() { for row in d.items.iter_mut() {
row.selected = false; row.selected = false;
} }
@@ -2169,24 +2209,19 @@ pub(crate) fn toggle_select_mode() {
publish(&doc); publish(&doc);
} }
/// Toggle one row's selection by its stable POSITION, then recount. A row patch /// One select-mode click on a row, by its stable POSITION: a toggle, or with
/// — never a re-derive. /// `shift` the range from the anchor (`apply_item_click`). Then every drawn
pub(crate) fn toggle_item_select(position: i32) { /// 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 doc = with_doc(|d| {
let now_selected = { let order: Vec<i32> = d.items.iter().map(|r| r.position).collect();
let mut set = SELECTED.lock().unwrap(); let mut set = SELECTED.lock().unwrap();
if set.contains(&position) { let mut anchor = SELECT_ANCHOR.lock().unwrap();
set.remove(&position); apply_item_click(&order, &mut set, &mut anchor, position, shift);
false for row in d.items.iter_mut() {
} else { row.selected = set.contains(&row.position);
set.insert(position);
true
}
};
if let Some(row) = d.items.iter_mut().find(|r| r.position == position) {
row.selected = now_selected;
} }
d.selected_count = SELECTED.lock().unwrap().len() as i32; d.selected_count = set.len() as i32;
d.clone() d.clone()
}); });
publish(&doc); publish(&doc);
@@ -2197,6 +2232,7 @@ pub(crate) fn toggle_item_select(position: i32) {
pub(crate) fn clear_selection() { pub(crate) fn clear_selection() {
let doc = with_doc(|d| { let doc = with_doc(|d| {
SELECTED.lock().unwrap().clear(); SELECTED.lock().unwrap().clear();
clear_select_anchor();
for row in d.items.iter_mut() { for row in d.items.iter_mut() {
row.selected = false; row.selected = false;
} }
@@ -2374,6 +2410,7 @@ pub(crate) fn teardown() {
RESOLVE_CACHE.lock().unwrap().clear(); RESOLVE_CACHE.lock().unwrap().clear();
OPEN_ROWS.lock().unwrap().clear(); OPEN_ROWS.lock().unwrap().clear();
SELECTED.lock().unwrap().clear(); SELECTED.lock().unwrap().clear();
clear_select_anchor();
*LAST_FILTER_KEY.lock().unwrap() = None; *LAST_FILTER_KEY.lock().unwrap() = None;
PREFS_HYDRATED.store(false, Ordering::SeqCst); PREFS_HYDRATED.store(false, Ordering::SeqCst);
let doc = with_doc(|d| { let doc = with_doc(|d| {
@@ -2495,6 +2532,45 @@ pub(crate) fn bulk_action(id: String) {
mod tests { mod tests {
use super::*; 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( fn item(
position: i32, position: i32,
title: &str, 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 QbzAlbum 1.0 QbzAlbum.qml
singleton QbzSearch 1.0 QbzSearch.qml singleton QbzSearch 1.0 QbzSearch.qml
singleton QbzHome 1.0 QbzHome.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/cards"
import "../../crates/qbz-qt/qml/views" as Views import "../../crates/qbz-qt/qml/views" as Views
import "../../crates/qbz-qt/qml/views/local" as Local import "../../crates/qbz-qt/qml/views/local" as Local
import "../../crates/qbz-qt/qml/views/myqbz" as MyQbz
Item { Item {
id: root id: root
@@ -119,6 +120,17 @@ Item {
onToggled: function (mods) { root.selectCheckMods = root.selectCheckMods.concat([mods]) } 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 { TestCase {
name: "MultiSelect" name: "MultiSelect"
when: windowShown when: windowShown
@@ -182,6 +194,9 @@ Item {
QbzArtist.opened = [] QbzArtist.opened = []
QbzAlbum.opened = [] QbzAlbum.opened = []
QbzHome.labels = [] QbzHome.labels = []
QbzMyQbz.selectCalls = []
QbzMyQbz.opened = []
QbzMyQbz.played = []
} }
// 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
@@ -278,6 +293,18 @@ Item {
compare(root.checkboxMods.length, 2) compare(root.checkboxMods.length, 2)
compare(root.checkboxMods[1] & Qt.ShiftModifier, 0, "the keyboard toggle is a plain toggle") 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 --------------------------------------------- // ---- the selection rule itself ---------------------------------------------