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 ---------------------------------------------