diff --git a/crates/qbz-qt/qml/cards/AlbumCard.qml b/crates/qbz-qt/qml/cards/AlbumCard.qml index 4fa255551..e9654351f 100644 --- a/crates/qbz-qt/qml/cards/AlbumCard.qml +++ b/crates/qbz-qt/qml/cards/AlbumCard.qml @@ -213,7 +213,10 @@ Rectangle { /// Park the select indicator bottom-right of the art instead of top-right. property bool selectMarkBottom: false property bool selected: false - signal selectToggled() + /// `modifiers` rides straight off the click: a grid host turns Shift into + /// a range (controls/SelectionModel.qml). Every select-mode target on the + /// card — artwork, title, artist line — emits it. + signal selectToggled(int modifiers) // Album blacklist ("Block this album") — LIVE since QbzBlacklist landed // (`blockAlbum(id, title, artist, coverUrl)`). Kept as a property, not @@ -561,7 +564,7 @@ Rectangle { } // .slint:169 — in select mode the card click TOGGLES. if (root.selectMode) { - root.selectToggled() + root.selectToggled(mouse.modifiers) return } if (root.pulledDead) @@ -993,7 +996,7 @@ Rectangle { // .slint:465 — the title carries the SAME target // as the artwork, select mode included. if (root.selectMode) { - root.selectToggled() + root.selectToggled(mouse.modifiers) return } if (root.pulledDead) @@ -1009,7 +1012,8 @@ Rectangle { width: parent.width height: 18 text: root.artist - color: (root.artistId !== "" || root.hostArtistLink) + color: !root.selectMode + && (root.artistId !== "" || root.hostArtistLink) && artistArea.containsMouse ? theme.textPrimary : theme.textMuted font.pixelSize: theme.fontLink - 1 @@ -1019,9 +1023,15 @@ Rectangle { id: artistArea anchors.fill: parent hoverEnabled: true - cursorShape: root.artistId !== "" || root.hostArtistLink + cursorShape: root.selectMode || root.artistId !== "" || root.hostArtistLink ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: { + onClicked: function (mouse) { + // In select mode the artist line is the card too: + // opening the artist mid-selection would drop it. + if (root.selectMode) { + root.selectToggled(mouse.modifiers) + return + } if (root.artistId !== "") QbzArtist.openArtist(root.artistId) else if (root.hostArtistLink) diff --git a/crates/qbz-qt/qml/controls/QbzCheckbox.qml b/crates/qbz-qt/qml/controls/QbzCheckbox.qml index 6bc6306ce..411f3cfef 100644 --- a/crates/qbz-qt/qml/controls/QbzCheckbox.qml +++ b/crates/qbz-qt/qml/controls/QbzCheckbox.qml @@ -1,6 +1,11 @@ // QbzCheckbox (primitives/QbzCheckbox.slint) — 18px square, r4, accent fill -// with an accent-text check when on, muted border when off. Emits toggled(); -// like the Slint original it never self-flips (the owner of the state does). +// with an accent-text check when on, muted border when off. Emits +// toggled(modifiers); like the Slint original it never self-flips (the owner of +// the state does). +// +// `modifiers` is the click's (or the key's), straight through: a multi-select +// host turns Shift into a range (controls/SelectionModel.qml). Hosts that only +// flip a flag keep a plain expression handler and never look at it. // // Used by Settings > Local Library (folder selection) and the Plex library // picker, i.e. every place the Slint uses its own QbzCheckbox. @@ -15,7 +20,7 @@ Rectangle { id: root property bool checked: false - signal toggled() + signal toggled(int modifiers) QbzTheme { id: theme } @@ -30,14 +35,14 @@ Rectangle { activeFocusOnTab: root.enabled Accessible.role: Accessible.CheckBox Accessible.checked: root.checked - Accessible.onToggleAction: if (root.enabled) root.toggled() + Accessible.onToggleAction: if (root.enabled) root.toggled(Qt.NoModifier) Keys.onPressed: function (event) { if (root.enabled && !event.isAutoRepeat && (event.key === Qt.Key_Space || event.key === Qt.Key_Return || event.key === Qt.Key_Enter)) { - root.toggled() + root.toggled(event.modifiers) event.accepted = true } } @@ -66,6 +71,6 @@ Rectangle { anchors.fill: parent cursorShape: Qt.PointingHandCursor onPressed: root.forceActiveFocus() - onClicked: root.toggled() + onClicked: function (mouse) { root.toggled(mouse.modifiers) } } } diff --git a/crates/qbz-qt/qml/rows/TrackRow.qml b/crates/qbz-qt/qml/rows/TrackRow.qml index 4b699b2d0..aef60795c 100644 --- a/crates/qbz-qt/qml/rows/TrackRow.qml +++ b/crates/qbz-qt/qml/rows/TrackRow.qml @@ -749,7 +749,10 @@ Rectangle { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: root.toggleSelect(Qt.NoModifier) + // The disc is where a select-mode click most often lands, + // so it hands over the modifiers exactly like the body: + // a plain `NoModifier` here made Shift-click a toggle. + onClicked: function (mouse) { root.toggleSelect(mouse.modifiers) } } } Text { @@ -969,6 +972,9 @@ Rectangle { MouseArea { anchors.fill: parent hoverEnabled: true + // Hover only: a tooltip must not eat the click the + // row body (play, or a select-mode toggle) owns. + acceptedButtons: Qt.NoButton ToolTip.visible: containsMouse ToolTip.delay: 300 // NEW msgid — the one string this state needs and @@ -1078,7 +1084,12 @@ Rectangle { MouseArea { id: artistLinkArea anchors.fill: parent - enabled: root.artistLink && (artistLine.linkMode || !!root.item.artistId) + // In select mode the whole row is a selection target + // (TrackRow.slint:174): the names stop being links so the + // click — and its Shift / Ctrl — reaches the row body + // instead of opening an artist page mid-selection. + enabled: !root.selectMode + && root.artistLink && (artistLine.linkMode || !!root.item.artistId) hoverEnabled: true cursorShape: (artistLine.linkMode ? (artistLine.hotSpan >= 0 && artistLine.spans[artistLine.hotSpan].href !== "") @@ -1088,6 +1099,9 @@ Rectangle { artistLine.hotSpan = artistLine.spanAt(mouse.x) } onExited: artistLine.hotSpan = -1 + // Entering select mode under the pointer disables the area + // without an `exited`; the hot name must not stay lit. + onEnabledChanged: if (!enabled) artistLine.hotSpan = -1 // Same routing as the menu's "Go to artist" — Slint drives // both through one handler, so gating only the menu would // leave the LINK navigating to the track's own artist. @@ -1116,7 +1130,8 @@ Rectangle { MouseArea { id: albumArea anchors.fill: parent - enabled: !!root.item.albumId && !root.releaseGone + // Not a link in select mode, for the artist line's reason. + enabled: !root.selectMode && !!root.item.albumId && !root.releaseGone hoverEnabled: true cursorShape: Qt.PointingHandCursor // The names ride along so a release the catalog lost can diff --git a/crates/qbz-qt/qml/views/AlbumListRow.qml b/crates/qbz-qt/qml/views/AlbumListRow.qml index a86e72a58..f2772be8a 100644 --- a/crates/qbz-qt/qml/views/AlbumListRow.qml +++ b/crates/qbz-qt/qml/views/AlbumListRow.qml @@ -304,8 +304,11 @@ Rectangle { id: artistArea anchors.fill: parent // Only a real artist id is clickable — otherwise the - // pointer promises a page that cannot open. - enabled: (root.item.artistId || "") !== "" + // pointer promises a page that cannot open. In select + // mode the whole row selects (Shift / Ctrl included), so + // the name stops being a link and the click falls to the + // row body underneath. + enabled: !root.selectMode && (root.item.artistId || "") !== "" hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: QbzArtist.openArtist(root.item.artistId) @@ -331,7 +334,8 @@ Rectangle { MouseArea { id: labelArea anchors.fill: parent - enabled: (root.item.labelId || "") !== "" + // Not a link in select mode, for the artist name's reason. + enabled: !root.selectMode && (root.item.labelId || "") !== "" hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: QbzHome.openLabel(root.item.labelId) diff --git a/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml b/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml index c6248f014..1da27e2de 100644 --- a/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml +++ b/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml @@ -593,11 +593,14 @@ Item { selected: root.nativeActive ? cardCell.slot.selected === true : root.selected[cardCell.slot.id] === true - onSelectToggled: { + // The card's modifiers, straight through: the + // paged grid (299dcde5b) passed NoModifier here, + // so a Shift-click on a card was a toggle. + onSelectToggled: function (mods) { if (root.nativeActive) - root.nativeToggleSelect(cardCell.slot.nativeIndex, Qt.NoModifier) + root.nativeToggleSelect(cardCell.slot.nativeIndex, mods) else - root.toggleSelect(cardCell.slot.id, Qt.NoModifier) + root.toggleSelect(cardCell.slot.id, mods) } // Non-select mode only — the card routes a // select-mode click to `selectToggled` and never diff --git a/crates/qbz-qt/qml/views/local/SelectCheck.qml b/crates/qbz-qt/qml/views/local/SelectCheck.qml index 0a8c8af4f..3bc06d06e 100644 --- a/crates/qbz-qt/qml/views/local/SelectCheck.qml +++ b/crates/qbz-qt/qml/views/local/SelectCheck.qml @@ -5,6 +5,10 @@ // Slint declares this inline inside TreeRow; the Qt port also needs it on // the album cards and the track rows in multi-select, so it is its own file // (three call sites, one shape). +// +// `toggled` carries the click's modifiers: this disc IS a select-mode click +// target, and one that dropped them turned every Shift-click on it into a +// plain toggle (controls/SelectionModel.qml). import QtQuick import com.blitzfc.qbz @@ -16,7 +20,7 @@ Rectangle { property bool on: false property bool partial: false property int diameter: 13 - signal toggled() + signal toggled(int modifiers) QbzTheme { id: theme } @@ -52,6 +56,6 @@ Rectangle { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: root.toggled() + onClicked: function (mouse) { root.toggled(mouse.modifiers) } } } diff --git a/scripts/qml-tests/imports/com/blitzfc/qbz/QbzAlbum.qml b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzAlbum.qml new file mode 100644 index 000000000..8ae344206 --- /dev/null +++ b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzAlbum.qml @@ -0,0 +1,7 @@ +pragma Singleton +import QtQuick +QtObject { + property var opened: [] + function openAlbum(id) { opened = opened.concat([String(id)]) } + function openAlbumFrom(id, title, artist) { opened = opened.concat([String(id)]) } +} diff --git a/scripts/qml-tests/imports/com/blitzfc/qbz/QbzArtist.qml b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzArtist.qml new file mode 100644 index 000000000..03e4a4589 --- /dev/null +++ b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzArtist.qml @@ -0,0 +1,6 @@ +pragma Singleton +import QtQuick +QtObject { + property var opened: [] + function openArtist(id) { opened = opened.concat([String(id)]) } +} diff --git a/scripts/qml-tests/imports/com/blitzfc/qbz/QbzHome.qml b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzHome.qml new file mode 100644 index 000000000..00813dc27 --- /dev/null +++ b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzHome.qml @@ -0,0 +1,6 @@ +pragma Singleton +import QtQuick +QtObject { + property var labels: [] + function openLabel(id) { labels = labels.concat([String(id)]) } +} diff --git a/scripts/qml-tests/imports/com/blitzfc/qbz/QbzSearch.qml b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzSearch.qml new file mode 100644 index 000000000..4cec62812 --- /dev/null +++ b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzSearch.qml @@ -0,0 +1,6 @@ +pragma Singleton +import QtQuick +QtObject { + property var submitted: [] + function searchSubmit(text) { submitted = submitted.concat([String(text)]) } +} diff --git a/scripts/qml-tests/imports/com/blitzfc/qbz/qmldir b/scripts/qml-tests/imports/com/blitzfc/qbz/qmldir index 134e14839..041c163e8 100644 --- a/scripts/qml-tests/imports/com/blitzfc/qbz/qmldir +++ b/scripts/qml-tests/imports/com/blitzfc/qbz/qmldir @@ -9,3 +9,7 @@ singleton QbzPurchases 1.0 QbzPurchases.qml singleton QbzOrbit 1.0 QbzOrbit.qml singleton QbzAbout 1.0 QbzAbout.qml +singleton QbzArtist 1.0 QbzArtist.qml +singleton QbzAlbum 1.0 QbzAlbum.qml +singleton QbzSearch 1.0 QbzSearch.qml +singleton QbzHome 1.0 QbzHome.qml diff --git a/scripts/qml-tests/tst_multi_select.qml b/scripts/qml-tests/tst_multi_select.qml index 9f4948566..4150de265 100644 --- a/scripts/qml-tests/tst_multi_select.qml +++ b/scripts/qml-tests/tst_multi_select.qml @@ -8,11 +8,278 @@ import QtQuick import QtTest import com.blitzfc.qbz import "../../crates/qbz-qt/qml/controls" +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 Item { id: root width: 1200; height: 1400 + function track(i) { + return { "id": "t" + i, "title": "Track " + i, "artist": "Artist " + i, + "artistId": "a" + i, "album": "Album " + i, "albumId": "al" + i, + "duration": "3:00", "qualityTier": "", "qualityDetail": "" } + } + function album(i) { + return { "id": "b" + i, "title": "Record " + i, "artist": "Band " + i, + "artistId": "band" + i, "label": "Label " + i, "labelId": "l" + i, + "year": "2001", "qualityTier": "", "qualityDetail": "" } + } + function range(n, make) { + var out = [] + for (var i = 0; i < n; i++) + out.push(make(i)) + return out + } + + // ---- the track list (AlbumView / PlaylistView wiring) ------------------- + property var tracks: range(8, track) + property var trackSelected: ({}) + SelectionModel { id: trackSel } + function toggleTrack(id, mods) { + root.trackSelected = trackSel.next(root.trackSelected, id, root.tracks, + mods === undefined ? Qt.NoModifier : mods) + } + + Column { + id: trackColumn + width: 1000 + Repeater { + id: trackRepeater + model: root.tracks + delegate: TrackRow { + required property var modelData + required property int index + width: 1000 + item: modelData + number: index + 1 + artistLink: true + showAlbum: true + selectMode: true + checked: root.trackSelected[modelData.id] === true + onToggleSelect: function (mods) { root.toggleTrack(modelData.id, mods) } + } + } + } + + // ---- the album list (Library albums list wiring) ------------------------ + property var albums: range(6, album) + property var albumSelected: ({}) + SelectionModel { id: albumSel } + function toggleAlbum(id, mods) { + root.albumSelected = albumSel.next(root.albumSelected, id, root.albums, + mods === undefined ? Qt.NoModifier : mods) + } + + Column { + id: albumColumn + y: 420 + width: 1000 + Repeater { + id: albumRepeater + model: root.albums + delegate: Views.AlbumListRow { + required property var modelData + required property int index + width: 1000 + item: modelData + rowIndex: index + selectMode: true + checked: root.albumSelected[modelData.id] === true + onToggleSelect: function (mods) { root.toggleAlbum(modelData.id, mods) } + } + } + } + + // ---- one album card and the two checkbox controls ------------------------ + property var cardMods: [] + AlbumCard { + id: card + x: 1000; y: 0 + albumId: "card" + title: "Card title" + artist: "Card artist" + artistId: "card-artist" + selectMode: true + onSelectToggled: function (mods) { root.cardMods = root.cardMods.concat([mods]) } + } + + property var checkboxMods: [] + QbzCheckbox { + id: checkbox + x: 1000; y: 320 + onToggled: function (mods) { root.checkboxMods = root.checkboxMods.concat([mods]) } + } + property var selectCheckMods: [] + Local.SelectCheck { + id: selectCheck + x: 1040; y: 320 + onToggled: function (mods) { root.selectCheckMods = root.selectCheckMods.concat([mods]) } + } + + TestCase { + name: "MultiSelect" + when: windowShown + + function find(item, pred) { + if (!item) + return null + if (pred(item)) + return item + var kids = item.children || [] + for (var i = 0; i < kids.length; i++) { + var hit = find(kids[i], pred) + if (hit) + return hit + } + return null + } + function shown(item) { + for (var it = item; it; it = it.parent) + if (!it.visible) + return false + return true + } + function textItem(host, text) { + var t = find(host, function (it) { + return it.text === text && it.contentWidth !== undefined && shown(it) + }) + verify(t !== null, "text '" + text + "' is drawn") + return t + } + // Clicks go through the window at the item's position, so whichever + // MouseArea really sits on top there receives them — the point of + // using the real components. + function clickOn(item, x, y, mods) { + var p = item.mapToItem(root, x, y) + mouseClick(root, p.x, p.y, Qt.LeftButton, mods === undefined ? Qt.NoModifier : mods) + wait(20) + } + function selectedIds(map) { + return Object.keys(map).sort().join(",") + } + function trackRow(i) { return trackRepeater.itemAt(i) } + function albumRow(i) { return albumRepeater.itemAt(i) } + function disc(row) { + var d = find(row, function (it) { + return it.width === 14 && it.height === 14 && it.hoverEnabled !== undefined + && it.cursorShape !== undefined && shown(it) + }) + verify(d !== null, "the select-mode disc is drawn") + return d + } + + function init() { + root.trackSelected = ({}) + trackSel.anchorId = "" + root.albumSelected = ({}) + albumSel.anchorId = "" + root.cardMods = [] + root.checkboxMods = [] + root.selectCheckMods = [] + QbzArtist.opened = [] + QbzAlbum.opened = [] + QbzHome.labels = [] + } + + // A point of the row with nothing drawn over it but the body: the + // duration cell. + function bodyPoint(row) { + var duration = textItem(row, "3:00") + return duration.mapToItem(row, duration.width / 2, duration.height / 2) + } + function clickBody(row, mods) { + var p = bodyPoint(row) + clickOn(row, p.x, p.y, mods) + } + + function test_row_body_shift_and_ctrl() { + clickBody(trackRow(1)) + clickBody(trackRow(4), Qt.ShiftModifier) + compare(selectedIds(root.trackSelected), "t1,t2,t3,t4") + clickBody(trackRow(6), Qt.ControlModifier) + compare(selectedIds(root.trackSelected), "t1,t2,t3,t4,t6") + } + + function test_checkbox_disc_shift_and_ctrl() { + clickOn(disc(trackRow(0)), 7, 7) + clickOn(disc(trackRow(3)), 7, 7, Qt.ShiftModifier) + compare(selectedIds(root.trackSelected), "t0,t1,t2,t3", + "Shift on the disc selects the range") + clickOn(disc(trackRow(5)), 7, 7, Qt.ControlModifier) + clickOn(disc(trackRow(7)), 7, 7, Qt.ControlModifier | Qt.ShiftModifier) + compare(selectedIds(root.trackSelected), "t0,t1,t2,t3,t5,t6,t7", + "Ctrl adds a row and Ctrl+Shift adds a second group") + } + + function test_artist_link_selects_in_select_mode() { + clickBody(trackRow(2)) + var name = textItem(trackRow(5), "Artist 5") + clickOn(name, 10, name.height / 2, Qt.ShiftModifier) + compare(QbzArtist.opened.length, 0, "no artist page opens in select mode") + compare(selectedIds(root.trackSelected), "t2,t3,t4,t5") + } + + function test_album_link_selects_in_select_mode() { + var name = textItem(trackRow(6), "Album 6") + clickOn(name, 10, name.height / 2) + compare(QbzAlbum.opened.length, 0, "no album page opens in select mode") + compare(selectedIds(root.trackSelected), "t6") + } + + function test_album_row_checkbox_shift_range() { + var first = find(albumRow(0), function (it) { + return it.checked !== undefined && it.width === 18 && it.height === 18 && shown(it) + }) + var fourth = find(albumRow(3), function (it) { + return it.checked !== undefined && it.width === 18 && it.height === 18 && shown(it) + }) + verify(first !== null && fourth !== null, "the album row checkboxes are drawn") + clickOn(first, 9, 9) + clickOn(fourth, 9, 9, Qt.ShiftModifier) + compare(selectedIds(root.albumSelected), "b0,b1,b2,b3") + } + + function test_album_row_links_select_in_select_mode() { + clickOn(albumRow(1), 600, 32) + var band = textItem(albumRow(4), "Band 4") + clickOn(band, 10, band.height / 2, Qt.ShiftModifier) + compare(QbzArtist.opened.length, 0, "no artist page opens in select mode") + var label = textItem(albumRow(5), "Label 5") + clickOn(label, 10, label.height / 2, Qt.ControlModifier) + compare(QbzHome.labels.length, 0, "no label page opens in select mode") + compare(selectedIds(root.albumSelected), "b1,b2,b3,b4,b5") + } + + function test_album_card_forwards_modifiers() { + clickOn(card, 100, 100, Qt.ShiftModifier) + var title = textItem(card, "Card title") + clickOn(title, 10, title.height / 2, Qt.ControlModifier) + var artist = textItem(card, "Card artist") + clickOn(artist, 10, artist.height / 2, Qt.ShiftModifier) + compare(QbzArtist.opened.length, 0, "no artist page opens in select mode") + compare(root.cardMods.length, 3) + verify((root.cardMods[0] & Qt.ShiftModifier) !== 0, "artwork carries Shift") + verify((root.cardMods[1] & Qt.ControlModifier) !== 0, "title carries Ctrl") + verify((root.cardMods[2] & Qt.ShiftModifier) !== 0, "artist line carries Shift") + } + + function test_checkbox_controls_forward_modifiers() { + clickOn(checkbox, 9, 9, Qt.ShiftModifier) + clickOn(selectCheck, 6, 6, Qt.ControlModifier) + compare(root.checkboxMods.length, 1) + verify((root.checkboxMods[0] & Qt.ShiftModifier) !== 0, "QbzCheckbox carries Shift") + compare(root.selectCheckMods.length, 1) + verify((root.selectCheckMods[0] & Qt.ControlModifier) !== 0, "SelectCheck carries Ctrl") + checkbox.forceActiveFocus() + keyClick(Qt.Key_Space) + compare(root.checkboxMods.length, 2) + compare(root.checkboxMods[1] & Qt.ShiftModifier, 0, "the keyboard toggle is a plain toggle") + } + } + // ---- the selection rule itself --------------------------------------------- SelectionModel { id: rule } SelectionModel { id: keyedRule; idKey: "trackId" }