From ee3a186f6d2c0dfb413662d0bd38395acecbc63f Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:31:13 -0600 Subject: [PATCH] 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 --- .../qbz-qt/qml/kiosk/KioskMyQBZDetailLite.qml | 2 +- .../qbz-qt/qml/views/myqbz/MyQbzDetailRow.qml | 15 ++- .../qml/views/myqbz/MyQbzDetailView.qml | 5 +- crates/qbz-qt/src/myqbz_bridge.rs | 10 +- crates/qbz-qt/src/myqbz_detail_qt.rs | 108 +++++++++++++++--- .../imports/com/blitzfc/qbz/QbzMyQbz.qml | 14 +++ .../qml-tests/imports/com/blitzfc/qbz/qmldir | 1 + scripts/qml-tests/tst_multi_select.qml | 27 +++++ 8 files changed, 158 insertions(+), 24 deletions(-) create mode 100644 scripts/qml-tests/imports/com/blitzfc/qbz/QbzMyQbz.qml diff --git a/crates/qbz-qt/qml/kiosk/KioskMyQBZDetailLite.qml b/crates/qbz-qt/qml/kiosk/KioskMyQBZDetailLite.qml index 5fa5ddcb2..d9ec9d5cc 100644 --- a/crates/qbz-qt/qml/kiosk/KioskMyQBZDetailLite.qml +++ b/crates/qbz-qt/qml/kiosk/KioskMyQBZDetailLite.qml @@ -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} } diff --git a/crates/qbz-qt/qml/views/myqbz/MyQbzDetailRow.qml b/crates/qbz-qt/qml/views/myqbz/MyQbzDetailRow.qml index 269ffc668..b71f1fc48 100644 --- a/crates/qbz-qt/qml/views/myqbz/MyQbzDetailRow.qml +++ b/crates/qbz-qt/qml/views/myqbz/MyQbzDetailRow.qml @@ -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: { diff --git a/crates/qbz-qt/qml/views/myqbz/MyQbzDetailView.qml b/crates/qbz-qt/qml/views/myqbz/MyQbzDetailView.qml index 42be96b04..21d7dd123 100644 --- a/crates/qbz-qt/qml/views/myqbz/MyQbzDetailView.qml +++ b/crates/qbz-qt/qml/views/myqbz/MyQbzDetailView.qml @@ -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, diff --git a/crates/qbz-qt/src/myqbz_bridge.rs b/crates/qbz-qt/src/myqbz_bridge.rs index 188fdc52f..f0be2cae9 100644 --- a/crates/qbz-qt/src/myqbz_bridge.rs +++ b/crates/qbz-qt/src/myqbz_bridge.rs @@ -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>) { diff --git a/crates/qbz-qt/src/myqbz_detail_qt.rs b/crates/qbz-qt/src/myqbz_detail_qt.rs index fef26fc17..47ce475ec 100644 --- a/crates/qbz-qt/src/myqbz_detail_qt.rs +++ b/crates/qbz-qt/src/myqbz_detail_qt.rs @@ -463,6 +463,43 @@ static RESOLVE_CACHE: LazyLock>> = /// the bulk actions in agreement with what the user sees (spec 02 ยง12 #13). static SELECTED: LazyLock>> = 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> = 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, + anchor: &mut Option, + 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> = 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 = 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) -> Vec { + let mut out: Vec = 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, diff --git a/scripts/qml-tests/imports/com/blitzfc/qbz/QbzMyQbz.qml b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzMyQbz.qml new file mode 100644 index 000000000..8a14266b1 --- /dev/null +++ b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzMyQbz.qml @@ -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) {} +} diff --git a/scripts/qml-tests/imports/com/blitzfc/qbz/qmldir b/scripts/qml-tests/imports/com/blitzfc/qbz/qmldir index 041c163e8..3107effa1 100644 --- a/scripts/qml-tests/imports/com/blitzfc/qbz/qmldir +++ b/scripts/qml-tests/imports/com/blitzfc/qbz/qmldir @@ -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 diff --git a/scripts/qml-tests/tst_multi_select.qml b/scripts/qml-tests/tst_multi_select.qml index 4150de265..91d4a6027 100644 --- a/scripts/qml-tests/tst_multi_select.qml +++ b/scripts/qml-tests/tst_multi_select.qml @@ -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 ---------------------------------------------