feat(qt): hotkeys dispatcher, focus lifecycle, multi-select seam (B2)

km
This commit is contained in:
vicrodh committed 2026-08-03 13:32:35 -06:00
1 parent 0cca9abc58
commit 5130e872e7
22 files changed
+475 -79

No files matched your search

+6 -11
View File
@@ -430,17 +430,12 @@ ApplicationWindow {
// Immersive toggle (2026-08-02 immersive-port contract §5.4 exit path 4 /
// §7): lives on the ApplicationWindow so it works open AND closed. The
// text-input gate (port of main.rs:1371-1375) makes it INERT while any
// text input has focus — with the immersive search field focused,
// Shift+I types "I". The FIRST `Shortcut {}` in the tree (BlacklistManagerView.qml:29
// confirms none existed), so no conflicts.
Shortcut {
sequence: "Shift+I"
enabled: !(window.activeFocusItem instanceof TextInput)
onActivated: QbzImmersive.toggle()
}
// The immersive toggle Shortcut is GONE (2026-08-03 hotkeys-port §1.3):
// Shift+I now fires as the REBINDABLE ui.focusMode action through the
// AppShell dispatcher (QbzHotkeys.keyPressed), whose central text-input
// gate replaces the `enabled:` expression this block carried.
// NavGestureLayer.qml owns mouse Back/Forward; no other Shortcut item
// exists in the tree (trap 11).
Loader {
id: screenLoader
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 8h.01" />
<path d="M12 12h.01" />
<path d="M14 8h.01" />
<path d="M16 12h.01" />
<path d="M18 8h.01" />
<path d="M6 8h.01" />
<path d="M7 16h10" />
<path d="M8 12h.01" />
<rect width="20" height="16" x="2" y="4" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 437 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 8h.01" />
<path d="M12 12h.01" />
<path d="M14 8h.01" />
<path d="M16 12h.01" />
<path d="M18 8h.01" />
<path d="M6 8h.01" />
<path d="M7 16h10" />
<path d="M8 12h.01" />
<rect width="20" height="16" x="2" y="4" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 437 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 8h.01" />
<path d="M12 12h.01" />
<path d="M14 8h.01" />
<path d="M16 12h.01" />
<path d="M18 8h.01" />
<path d="M6 8h.01" />
<path d="M7 16h10" />
<path d="M8 12h.01" />
<rect width="20" height="16" x="2" y="4" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 437 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 8h.01" />
<path d="M12 12h.01" />
<path d="M14 8h.01" />
<path d="M16 12h.01" />
<path d="M18 8h.01" />
<path d="M6 8h.01" />
<path d="M7 16h10" />
<path d="M8 12h.01" />
<rect width="20" height="16" x="2" y="4" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 437 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 8h.01" />
<path d="M12 12h.01" />
<path d="M14 8h.01" />
<path d="M16 12h.01" />
<path d="M18 8h.01" />
<path d="M6 8h.01" />
<path d="M7 16h10" />
<path d="M8 12h.01" />
<rect width="20" height="16" x="2" y="4" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 437 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 8h.01" />
<path d="M12 12h.01" />
<path d="M14 8h.01" />
<path d="M16 12h.01" />
<path d="M18 8h.01" />
<path d="M6 8h.01" />
<path d="M7 16h10" />
<path d="M8 12h.01" />
<rect width="20" height="16" x="2" y="4" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 437 B

@@ -78,6 +78,21 @@ Item {
visible: root.createOpen || root.editOpen
enabled: root.visible
// §1.4.3 (2026-08-03 hotkeys-port contract): both FocusScopes grab focus
// on open and Qt strands it on the now-invisible scope at close, which
// kills the AppShell key dispatcher until the next click. Restore the
// shell root — the dispatcher's fallback focus item — on the close edge.
function _restoreShellFocus() {
var p = root
while (p.parent) {
if (p.parent.isQbzShellRoot === true) {
p.parent.forceActiveFocus()
return
}
p = p.parent
}
}
// ==================================================================
// PANEL 1 — Create folder (CreateFolderModal.slint)
// ==================================================================
@@ -90,6 +105,8 @@ Item {
root.draftCreateName = ""
createScope.forceActiveFocus()
createField.focusField()
} else {
root._restoreShellFocus()
}
}
@@ -237,6 +254,8 @@ Item {
// A sub-modal must never outlive its host panel.
if (!root.editOpen)
deleteConfirm.close()
if (!root.editOpen)
root._restoreShellFocus()
}
FocusScope {
@@ -120,6 +120,23 @@ Item {
}
}
// §1.4.3 (2026-08-03 hotkeys-port contract): the FocusScope grabs focus
// on open and Qt strands it on the now-invisible scope at close, which
// kills the AppShell key dispatcher until the next click. Restore the
// shell root — the dispatcher's fallback focus item — on the close edge.
onEditOpenChanged: if (!root.editOpen) root._restoreShellFocus()
function _restoreShellFocus() {
var p = root
while (p.parent) {
if (p.parent.isQbzShellRoot === true) {
p.parent.forceActiveFocus()
return
}
p = p.parent
}
}
function submit() {
if (root.canSave)
QbzPlaylistEdit.save(root.draftName, root.draftDescription,
@@ -74,6 +74,23 @@ Item {
}
function close() {
root._open = false
root._restoreShellFocus()
}
// §1.4.3 (2026-08-03 hotkeys-port contract): keyScope GRABS focus on open
// and Qt strands activeFocus on the now-invisible item at close, which
// kills the AppShell key dispatcher until the next click. Restore the
// shell root — the dispatcher's fallback focus item — on every close
// path. Duck-walk, because this modal mounts under several parents (the
// Blacklist manager, FolderModals, MyQBZ delete).
function _restoreShellFocus() {
var p = root
while (p.parent) {
if (p.parent.isQbzShellRoot === true) {
p.parent.forceActiveFocus()
return
}
p = p.parent
}
}
function _cancel() {
root.close()
+6 -4
View File
@@ -97,10 +97,12 @@ Rectangle {
}
// Immersive-port contract D16 (2026-08-02, additive one-liner): the ONE
// read-only seam into the inner TextInput's focus. The immersive header
// gates Shift+I and the seek arrows on this ("inert while any text input
// has focus", §5.4) — the input itself stays deliberately unexposed
// (:186). No behavior change for existing consumers.
// read-only seam into the inner TextInput's focus. The immersive root
// reads it as `textInputActive` (ImmersiveView.qml); the Shift+I /
// seek-arrow gates it used to feed are GONE, replaced by the hotkeys
// dispatcher's central text-input gate (2026-08-03 hotkeys-port §1.4.4)
// — the input itself stays deliberately unexposed (:186). No behavior
// change for existing consumers.
readonly property bool fieldActive: input.activeFocus
/// Focus the field from OUTSIDE the control — what a modal needs on open.
+32 -38
View File
@@ -4,7 +4,8 @@
// declaration-order z convention).
//
// BLOCK B2+B3+B4+B5 SCOPE: the overlay shell (root + click-blocker + chrome +
// auto-hide + exit paths 2-4 + the fullscreen guard + seek arrows) PLUS the
// auto-hide + exit paths 2-4 + the fullscreen guard; the seek arrows later
// moved to the hotkeys dispatcher — 2026-08-03 hotkeys-port §1.3) PLUS the
// B3 panels (the ImmersiveAtmosphere underlay, the five FOCUS panels Album
// Reactive / Static / Coverflow / Spectrum / Wave Bed, the layer-4
// ImmersiveSongCard) PLUS the B4 panels: FOCUS Lyrics (mode 4,
@@ -46,14 +47,16 @@ Item {
// through QbzImmersive's open setter (§3/D3) — this item only reacts.
visible: QbzImmersive.open
clip: true
// focus: true while open so the root's Keys handlers (Escape, seek
// arrows) are live; the 30ms grab timer below re-asserts it after mount
// (:1259-1267).
// focus: true while open so the root's Keys handler (Escape) is live;
// the 30ms grab timer below re-asserts it after mount (:1259-1267).
focus: QbzImmersive.open
// §5.4 text-input gate: Shift+I + the seek arrows are inert while ANY
// text input has focus. The immersive surface's own input is the header
// search field (D16 seam).
// §5.4 text-input gate (kept per 2026-08-03 hotkeys-port §1.3 — the
// seek arrows and the Shift+I Shortcut it used to feed are GONE,
// absorbed into the dispatcher's rebindable focus.seek*/ui.focusMode
// actions; the central gate in QbzHotkeys.keyPressed now does their
// job. Retained as the immersive surface's own "a text input is
// focused" flag).
readonly property bool textInputActive: header.searchActive
// --- §5.4 fullscreen / shell guard ------------------------------------
@@ -80,13 +83,21 @@ Item {
w.visibility = Window.Windowed
// Focus hygiene (measured 2026-08-02 over RFB): when the
// overlay hides while its search field holds activeFocus, Qt
// leaves activeFocus STRANDED on the now-invisible TextInput —
// Main.qml's Shift+I gate (activeFocusItem instanceof
// TextInput) and the seek-arrow textInputActive gate then stay
// inert forever. Hand focus to the window content root so the
// gates see a non-text item again.
if (w !== null)
w.contentItem.forceActiveFocus()
// leaves activeFocus STRANDED on the now-invisible TextInput
// — the AppShell dispatcher's text-input gate would read
// that dead field as focused and swallow every hotkey.
// Hand focus to the SHELL ROOT (K8, 2026-08-03 hotkeys-port
// §1.4.2 — NOT contentItem): the shell root is the
// dispatcher's fallback focus item, so the pipeline keeps
// receiving keys with no click in between.
var p = root
while (p.parent) {
if (p.parent.isQbzShellRoot === true) {
p.parent.forceActiveFocus()
break
}
p = p.parent
}
}
}
}
@@ -485,7 +496,7 @@ Item {
readonly property bool _noShaders: GraphicsInfo.api === GraphicsInfo.Software
|| GraphicsInfo.api === GraphicsInfo.Null
// --- Exit paths 3 + seek arrows (§5.4, §7) -------------------------------
// --- Exit paths 3 (§5.4, §7) -------------------------------------------
// Escape on the root: dismisses the search cortinilla FIRST when open
// (§3.4), exits immersive otherwise. The search field's own Escape
// declines and propagates here (ImmersiveHeader.qml comment).
@@ -496,29 +507,12 @@ Item {
QbzImmersive.open = false
event.accepted = true
}
// Seek arrows (§7, keybindings.rs:107-110,572-581): ArrowRight/Left ±5s,
// Shift+ArrowRight/Left ±10s. Target clamps to DURATION (not
// npSeekableMax — that clamp belongs to the B5 TinyBar only, trap 9).
// Position base is the 1Hz npElapsedSecs (D14, resolved). Inert while a
// text input has focus (§5.4) — the gate is load-bearing because key
// events PROPAGATE from the focused field up to this root.
Keys.onPressed: function (event) {
if (root.textInputActive)
return
var d = 0
if (event.key === Qt.Key_Right)
d = (event.modifiers & Qt.ShiftModifier) ? 10 : 5
else if (event.key === Qt.Key_Left)
d = -((event.modifiers & Qt.ShiftModifier) ? 10 : 5)
else
return
if (QbzPlayer.npDurationSecs > 0) {
var target = Math.max(0, Math.min(QbzPlayer.npDurationSecs,
QbzPlayer.npElapsedSecs + d))
QbzPlayer.seek(target / QbzPlayer.npDurationSecs)
}
event.accepted = true
}
// The seek arrows are GONE (2026-08-03 hotkeys-port §1.3): ±5s/±10s now
// fire as the REBINDABLE focus.seekForward/Back/Long actions through the
// AppShell dispatcher, with the Context::Immersive gate and the central
// text-input gate (QbzHotkeys.keyPressed, same §1.3 math). Kept here per
// the same section: the Escape handler above, `textInputActive`, and the
// cortinilla arrows (ImmersiveHeader).
// --- Layer 5: the player bar (§5.7, :1607-1616) -------------------------
// Centered, y = height-90-24; fades with the auto-hide chrome on the
+82
View File
@@ -58,6 +58,88 @@ Rectangle {
// The host ApplicationWindow (custom chrome: drag / maximize / resize).
property var hostWindow: null
// §1.4.1 + §4.1 (2026-08-03 hotkeys-port contract): the shell root is
// BOTH the dispatcher host and the fallback focus item — Qt delivers key
// events only to activeFocusItem and propagates up its parent chain, and
// the shell tree had ZERO focus items, so without this a fresh launch
// has activeFocusItem == null and the pipeline receives NOTHING until
// the first click (the round-1 BLOCKER; H0 is the RFB proof). The
// marker lets a modal anywhere in the tree walk up and re-focus this
// root on close (§1.4.2/§1.4.3).
focus: true
readonly property bool isQbzShellRoot: true
// The assert half of §1.4.1: declarative `focus: true` alone did NOT
// survive the splash -> shell Loader swap in a WM-less (VNC) session —
// activeFocusItem stayed null and the dispatcher received nothing
// until the first click (measured 2026-08-03, RFB H0 first pass). Grab
// once at mount; every other lifecycle arm (immersive close, modal
// closes) hands focus BACK here.
Component.onCompleted: root.forceActiveFocus()
// THE ONE key entry (§1.1 route (b), divergence K1): NOTHING is handled
// locally. The ordered pipeline (capture steal, search-dropdown Up/Down
// steal, central text-input gate, Ctrl+A, binding dispatch, the §1.2
// Escape stack) lives in Rust behind QbzHotkeys.keyPressed; the QML side
// only computes the gate (§1.4.4). Null activeFocusItem passes the gate
// (the semantically right case). Every existing Keys. handler accepts
// only what it owns, and all buttons are MouseArea-based and can never
// take keyboard focus — verified non-conflicts (§4.1).
Keys.onPressed: function (event) {
var w = root.Window.window
var afi = w !== null ? w.activeFocusItem : null
var textInputFocused = (afi instanceof TextInput) || (afi instanceof TextEdit)
event.accepted = QbzHotkeys.keyPressed(event.key, event.modifiers,
event.text, textInputFocused)
}
// --- Hotkeys routers (§4.3 / §4.6) -------------------------------------
// nav.search (Ctrl+f, divergence K6 — EXCEEDS Slint, whose focus_search
// only flipped cortinilla_open with no visible effect on an empty
// query): the Rust dispatch emits this signal and the field lands
// focused and ready to type.
Connections {
target: QbzHotkeys
function onFocusSearchRequested() { header.focusSearch() }
}
// The §4.6 Ctrl+A + multi-select Escape-exit seam. Selection state is
// in-view QML JS (library_bulk.rs:8: "select-all / clear never reach
// Rust"), so the QbzShell signals route to the mounted view's
// duck-typed interface: selectAll() / exitMultiSelectMode() /
// multiSelectOn. Implemented by LibraryView, LocalLibraryView and
// LocalAlbumView (MyQbzDetailView is EXIT-ONLY — its selection lives in
// Rust with no select-all arm); views without multi-select (Artist,
// Playlist, Mix, Label, Offline) match nothing — PARITY-DEBT (K4).
Connections {
target: QbzShell
function onSelectAllRequested() {
if (viewLoader.item !== null && typeof viewLoader.item.selectAll === "function")
viewLoader.item.selectAll()
}
function onExitMultiSelectRequested() {
if (viewLoader.item !== null && typeof viewLoader.item.exitMultiSelectMode === "function")
viewLoader.item.exitMultiSelectMode()
}
}
// The reporters: their QbzShell mirrors feed the Rust Ctrl+A
// consumption predicate (capable) and the §1.2 Escape stack arm 6
// (active). Duck-typed, so a view without the interface reports
// capable=false and Ctrl+A falls through to the binding lookup (the
// Slint select_all_active_surface false case).
Binding {
target: QbzShell
property: "multiSelectCapable"
value: viewLoader.item !== null && typeof viewLoader.item.selectAll === "function"
restoreMode: Binding.RestoreNone
}
Binding {
target: QbzShell
property: "multiSelectActive"
value: viewLoader.item !== null && viewLoader.item.multiSelectOn === true
restoreMode: Binding.RestoreNone
}
QbzTheme { id: theme }
HeaderBar {
+25 -2
View File
@@ -19,8 +19,8 @@
// replica — the cortinilla/live-search is out of scope; POC-NOTE).
// Right: the tri-state offline status badge with its flyout (recovery
// "Sign in" wired to QbzSession.recoveryLogin) and the app menu (user block
// + Documentation + Log Out + Close. Still missing vs Slint, each needing a
// surface the port does not have yet: Open Music Link, Keyboard Shortcuts,
// + Keyboard Shortcuts + Documentation + Log Out + Close. Still missing vs
// Slint, each needing a surface the port does not have yet: Open Music Link,
// Report an Issue, What's New, About QBZ.
//
// POC-NOTE: the custom window-chrome parts (drag surface, drawn
@@ -616,6 +616,18 @@ Rectangle {
QbzBridge.cortinillaDismiss()
}
// nav.search / Ctrl+f (2026-08-03 hotkeys-port contract §4.3, divergence
// K6): QbzHotkeys emits focusSearchRequested, the AppShell Connections
// calls this. EXCEEDS Slint, whose focus_search only flipped
// cortinilla_open — with an empty query the only visible effect was the
// "↵ Enter" hint (the panel is gated on ≥2 chars; the "the field grabs
// focus on open" comment at keybindings.rs:533-536 is stale). Here the
// field lands focused and ready to type, which is what Ctrl+f means in
// every other app.
function focusSearch() {
searchInput.forceActiveFocus()
}
Rectangle {
id: searchBox
x: (root.width - width) / 2
@@ -1099,6 +1111,17 @@ Rectangle {
QbzShell.navigateTo("settings")
}
}
// Slint order (HeaderBar.slint:1175-1212): Settings, Open Music
// Link, Keyboard Shortcuts, Documentation… The Open Music Link
// row stays ABSENT (the LinkResolver is not ported, K3).
AppMenuItem {
name: "keyboard"
label: QbzSession.tr("Keyboard Shortcuts", QbzSession.trRev)
onClicked: {
appMenu.close()
QbzHotkeys.openCheatsheet()
}
}
AppMenuItem {
name: "book-open"
label: QbzSession.tr("Documentation", QbzSession.trRev)
+22
View File
@@ -602,6 +602,28 @@ Rectangle {
root.albumsSelected = ({})
}
// --- Ctrl+A / Escape hotkeys interface (2026-08-03 hotkeys-port §4.6) --
// The duck-typed seam the AppShell router calls on QbzShell's
// selectAllRequested / exitMultiSelectRequested. Only the tracks and
// albums tabs have multi-select here; on any other tab selectAll() is a
// deliberate no-op (the capability reporter still counts this view, so
// Ctrl+A consumes without visible effect on the All/Artists/Playlists
// tabs — those have no selection model to fill).
readonly property bool multiSelectOn: root.tracksMultiSelect || root.albumsMultiSelect
function selectAll() {
if (root.activeTab === "tracks") {
if (!root.tracksMultiSelect) root.setTracksMultiSelect(true)
root.tracksBulkAction("select-all")
} else if (root.activeTab === "albums") {
if (!root.albumsMultiSelect) root.setAlbumsMultiSelect(true)
root.albumsBulkAction("select-all")
}
}
function exitMultiSelectMode() {
if (root.tracksMultiSelect) root.setTracksMultiSelect(false)
if (root.albumsMultiSelect) root.setAlbumsMultiSelect(false)
}
// --------------------- windowed artwork -----------------------------
// Report the mounted window as artKeys; prune far-away covers.
function reportWindow(visibleArray, first, last) {
@@ -131,6 +131,19 @@ Rectangle {
QbzLocal.bulkAction("track", JSON.stringify(selectedIdsInOrder()), action)
}
// --- Ctrl+A / Escape hotkeys interface (2026-08-03 hotkeys-port §4.6) --
// The duck-typed seam the AppShell router calls.
readonly property bool multiSelectOn: root.multiSelect
function selectAll() {
if (!root.multiSelect) root.multiSelect = true
root.bulkAction("select-all")
}
function exitMultiSelectMode() {
// Same "leaving drops the selection" contract the toolbar toggle
// carries (:371-373).
if (root.multiSelect) { root.multiSelect = false; root.selected = ({}) }
}
// Disc divider before the first row of each disc on a multi-disc album
// (0 = flat list, as the Slint's disc-header-number).
function discHeader(i) {
@@ -720,6 +720,28 @@ Rectangle {
QbzLocal.bulkAction("track", JSON.stringify(Object.keys(tracksSelected)), action)
}
// --- Ctrl+A / Escape hotkeys interface (2026-08-03 hotkeys-port §4.6) --
// The duck-typed seam the AppShell router calls. Only the albums and
// tracks tabs have multi-select (the folder tree rail's select mode is
// Rust-side with no select-all arm — not covered); on the artists /
// folders tabs selectAll() is a deliberate no-op.
readonly property bool multiSelectOn: root.albumsMultiSelect || root.tracksMultiSelect
function selectAll() {
if (root.activeTab === "albums") {
if (!root.albumsMultiSelect) root.albumsMultiSelect = true
root.albumsBulkAction("select-all")
} else if (root.activeTab === "tracks") {
if (!root.tracksMultiSelect) root.tracksMultiSelect = true
root.tracksBulkAction("select-all")
}
}
function exitMultiSelectMode() {
// Same "leaving drops the selection" contract the toggle buttons
// carry (toggleAlbumsMultiSelect / toggleTracksMultiSelect).
if (root.albumsMultiSelect) { root.albumsMultiSelect = false; root.albumsSelected = ({}) }
if (root.tracksMultiSelect) { root.tracksMultiSelect = false; root.tracksSelected = ({}) }
}
function toggleTreeSelectMode() {
treeSelectMode = !treeSelectMode
QbzLocal.treeSetSelectMode(treeSelectMode)
@@ -197,6 +197,19 @@ Rectangle {
readonly property string viewMode: doc.viewMode || "list"
readonly property bool selectMode: doc.selectMode === true
readonly property int selectedCount: doc.selectedCount || 0
// --- Escape hotkeys interface (2026-08-03 hotkeys-port §4.6), EXIT-ONLY.
// The selection lives in RUST (myqbz_detail_qt.rs positions) and its bulk
// bar carries no select-all arm, so this view deliberately exposes NO
// selectAll() — the AppShell capability reporter reads that as
// "Ctrl+A does not apply here". Escape-exit is available:
// detailToggleSelectMode off ALSO clears the selection
// (myqbz_detail_qt.rs:1902-1917).
readonly property bool multiSelectOn: root.selectMode
function exitMultiSelectMode() {
if (root.selectMode) QbzMyQbz.detailToggleSelectMode()
}
readonly property int filterCount: doc.filterCount || 0
readonly property bool hasAnyFilter: doc.hasAnyFilter === true
+31 -24
View File
@@ -20,9 +20,10 @@
//! (`activeFocusItem instanceof TextInput || TextEdit`, §1.4.4) and
//! passed in; zero per-field probes (scoping's stated goal);
//! (C2) Ctrl+A select-all — separate non-rebindable branch (§4.6, trap 8);
//! the Qt select-all seam is B2, so the stub reports "no surface
//! accepted" and the arm falls through to (D), exactly the Slint
//! `select_all_active_surface` false case (main.rs:1381-1386);
//! consumes ONLY when a multi-select-capable view is mounted (the
//! Slint `select_all_active_surface` false case falls through to (D),
//! main.rs:1381-1386); the QbzShell.selectAllActive() seam routes the
//! select-all through QML (the selection state is in-view QML JS);
//! (D) the binding lookup → dispatch to the §2 targets.
//!
//! The synchronous-return invokable is the app's core idiom
@@ -125,6 +126,16 @@ pub mod qbz_hotkeys {
/// language bump (§3.4/F12) and after every store mutation.
#[qinvokable]
fn refresh(self: Pin<&mut QbzHotkeys>);
/// nav.search (Ctrl+f) — divergence K6 / contract §4.3: the AppShell
/// Connections listens and calls HeaderBar.focusSearch()
/// (`searchInput.forceActiveFocus()`, HeaderBar.qml:644). EXCEEDS
/// Slint, whose `focus_search` only flipped `cortinilla_open` — with
/// an empty query the only visible effect was the Enter hint and the
/// "the field grabs focus on open" comment at keybindings.rs:533-536
/// is stale.
#[qsignal]
fn focus_search_requested(self: Pin<&mut QbzHotkeys>);
}
impl cxx_qt::Threading for QbzHotkeys {}
@@ -191,25 +202,20 @@ fn clear_capture(mut this: Pin<&mut QbzHotkeys>) {
this.as_mut().set_conflict_label(QString::from(""));
}
/// nav.search (Ctrl+f) — B2 SEAM (divergence K6, contract §4.3): Slint's
/// `focus_search` only flips `cortinilla_open` (keybindings.rs:533-536 — the
/// "the field grabs focus on open" comment is stale), with no visible effect
/// on an empty query. The Qt decision EXCEEDS Slint: a QML `focusSearch()`
/// hook on HeaderBar (`searchInput.forceActiveFocus()`), which lands with the
/// B2 dispatcher. B1 named stub.
fn focus_search() {
log::debug!("[qbz-qt] hotkeys: nav.search — the HeaderBar focusSearch() hook lands in B2 (K6)");
}
/// (C2) Ctrl+A select-all — B2 SEAM (contract §4.6, trap 8). Round-1 finding:
/// NO select-all or multi-select-exit invokable exists in Qt (selection state
/// is in-view QML JS; `library_bulk.rs:8`); B2 adds `QbzShell.selectAllActive()`
/// + the duck-typed view interface. The stub reports "no surface accepted",
/// (C2) Ctrl+A select-all — the §4.6 seam (contract, trap 8). Round-1
/// finding: NO select-all invokable existed in Qt (selection state is
/// in-view QML JS; `library_bulk.rs:8`), so the bridge bounces to QML:
/// `QbzShell.selectAllActive()` emits a signal the AppShell router forwards
/// to the mounted view's duck-typed `selectAll()`. Consumes ONLY when a
/// capable view is mounted — the QML-reported `multiSelectCapable` mirror —
/// so the arm falls through to (D) exactly like the Slint
/// `select_all_active_surface` false case.
fn select_all_active() -> bool {
log::debug!("[qbz-qt] hotkeys: Ctrl+A — the QbzShell.selectAllActive() seam lands in B2 (§4.6)");
false
if !crate::shell_bridge::multi_select_capable() {
return false;
}
crate::shell_bridge::ui(|s| s.select_all_active());
true
}
/// §1.3 seek math, verbatim from keybindings.rs:572-581:
@@ -328,7 +334,7 @@ impl qbz_hotkeys::QbzHotkeys {
"playback.prev" => crate::transport_previous(),
"nav.back" => crate::nav_qt::back(),
"nav.forward" => crate::nav_qt::forward(),
"nav.search" => focus_search(), // B2 QML seam (K6) — named stub
"nav.search" => self.as_mut().focus_search_requested(), // K6 QML seam
"nav.settings" => crate::navigate_to("settings"),
"ui.sidebar" => crate::shell_bridge::ui(|s| s.cycle_sidebar()),
"ui.focusMode" => crate::immersive_bridge::toggle(),
@@ -371,9 +377,10 @@ impl qbz_hotkeys::QbzHotkeys {
cheatsheet_open: *self.cheatsheet_open(),
cortinilla_open: crate::search_qt::cortinilla_open(),
immersive_open: crate::immersive_bridge::is_open(),
// §4.6 seam: no Qt multi-select exit invokable exists yet (B2
// adds QbzShell.exitMultiSelect + the view reporters).
multi_select_active: false,
// §4.6 seam: the QML-reported mirror (a view's multiSelectOn
// flag is up). The exit rides QbzShell.exitMultiSelect(), which
// bounces to the mounted view's exitMultiSelectMode().
multi_select_active: crate::shell_bridge::multi_select_active(),
queue_open: crate::shell_bridge::queue_open(),
};
match crate::hotkeys_qt::escape_target(&state) {
@@ -382,7 +389,7 @@ impl qbz_hotkeys::QbzHotkeys {
EscapeTarget::Cortinilla => crate::search_qt::dismiss(),
EscapeTarget::Immersive => crate::immersive_bridge::close(),
EscapeTarget::MultiSelect => {
log::debug!("[qbz-qt] hotkeys: multi-select exit ABSENT — the §4.6 seam lands in B2");
crate::shell_bridge::ui(|s| s.exit_multi_select());
}
EscapeTarget::Queue => crate::shell_bridge::ui(|s| s.toggle_queue()),
EscapeTarget::LinkResolver | EscapeTarget::None => {}
+1
View File
@@ -199,6 +199,7 @@ const MASTERS: &[(&str, &str)] = &[
("import", include_str!("../qml/assets/icons/primary/import.svg")),
("infinity", include_str!("../qml/assets/icons/primary/infinity.svg")),
("info", include_str!("../qml/assets/icons/primary/info.svg")),
("keyboard", include_str!("../qml/assets/icons/primary/keyboard.svg")),
("layers", include_str!("../qml/assets/icons/primary/layers.svg")),
("layout-grid", include_str!("../qml/assets/icons/primary/layout-grid.svg")),
("library", include_str!("../qml/assets/icons/primary/library.svg")),
+4
View File
@@ -1708,6 +1708,10 @@ pub(crate) fn apply_language(code: String) {
let next = b.as_ref().tr_rev() + 1;
b.as_mut().set_tr_rev(next);
});
// §3.4/F12 (2026-08-03 hotkeys-port): the hotkeys groups document's
// action labels are Rust-translated (`qbz_i18n::t`), so they cannot
// re-translate through trRev — recompute on the same bump.
hotkeys_bridge::ui(|h| h.refresh());
reload_home();
reload_library();
publish_settings();
+99
View File
@@ -191,6 +191,20 @@ pub mod qbz_shell {
#[qproperty(f32, art_preview_x)]
#[qproperty(f32, art_preview_y)]
// --- Multi-select seam (2026-08-03 hotkeys-port contract §4.6) ----
// QML-REPORTED — selection state is in-view QML JS
// (`library_bulk.rs:8`: "select-all / clear never reach Rust"), so the
// AppShell reporter Bindings write these: a view implementing the
// duck-typed `selectAll()` is mounted (capable) / a view's
// `multiSelectOn` flag is up (active). CUSTOM WRITE (the
// `QbzImmersive.open` funnel precedent) so the Rust-side AtomicBool
// mirrors stay exact for the cross-singleton hotkeys reads — the
// Ctrl+A consumption predicate and the §1.2 Escape stack arm 6 run
// on the QbzHotkeys singleton and cannot reach this QObject's
// properties.
#[qproperty(bool, multi_select_active, READ, WRITE = set_multi_select_active, NOTIFY)]
#[qproperty(bool, multi_select_capable, READ, WRITE = set_multi_select_capable, NOTIFY)]
type QbzShell = super::QbzShellRust;
/// Registers this object's Qt-thread hop (Main.qml boots EVERY
@@ -322,6 +336,31 @@ pub mod qbz_shell {
/// Release: add the dragged track(s) to the over-playlist target.
#[qinvokable]
fn drag_end(self: Pin<&mut QbzShell>);
// --- Multi-select seam (§4.6) --------------------------------------
/// The hotkeys (C2) Ctrl+A arm / the §1.2 Escape-stack arm 6. Rust
/// cannot reach the content-view Loader (AppShell.qml), so both
/// bounce to QML as a signal; the AppShell router forwards to the
/// mounted view's duck-typed `selectAll()` /
/// `exitMultiSelectMode()`.
#[qinvokable]
fn select_all_active(self: Pin<&mut QbzShell>);
#[qinvokable]
fn exit_multi_select(self: Pin<&mut QbzShell>);
#[qsignal]
fn select_all_requested(self: Pin<&mut QbzShell>);
#[qsignal]
fn exit_multi_select_requested(self: Pin<&mut QbzShell>);
}
// The custom WRITE targets of the §4.6 seam properties. NOT qinvokables —
// QML reaches them by writing `QbzShell.multiSelectActive` /
// `multiSelectCapable`, never by name (the `QbzImmersive.open` cxx-qt
// custom-setter pattern: the property's auto setter is replaced by this
// method, which owns the store + notify + mirror).
unsafe extern "RustQt" {
fn set_multi_select_active(self: Pin<&mut QbzShell>, value: bool);
fn set_multi_select_capable(self: Pin<&mut QbzShell>, value: bool);
}
impl cxx_qt::Threading for QbzShell {}
@@ -379,6 +418,8 @@ pub struct QbzShellRust {
art_preview_show: bool,
art_preview_x: f32,
art_preview_y: f32,
multi_select_active: bool,
multi_select_capable: bool,
}
impl Default for QbzShellRust {
@@ -444,6 +485,8 @@ impl Default for QbzShellRust {
art_preview_show: false,
art_preview_x: 0.0,
art_preview_y: 0.0,
multi_select_active: false,
multi_select_capable: false,
}
}
}
@@ -500,6 +543,26 @@ pub fn queue_open() -> bool {
QUEUE_OPEN.load(std::sync::atomic::Ordering::SeqCst)
}
/// Rust-side mirrors of the §4.6 multi-select properties, written inside the
/// custom-WRITE setters below — the funnel EVERY write passes through (QML
/// reporter Bindings included), so they are exact (the QUEUE_OPEN
/// precedent). Read by the hotkeys pipeline: the (C2) Ctrl+A consumption
/// predicate and the §1.2 Escape stack arm 6 (2026-08-03 hotkeys-port
/// contract), which run on ANOTHER singleton and cannot reach this QObject's
/// properties.
static MULTI_SELECT_ACTIVE: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
static MULTI_SELECT_CAPABLE: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
pub fn multi_select_active() -> bool {
MULTI_SELECT_ACTIVE.load(std::sync::atomic::Ordering::SeqCst)
}
pub fn multi_select_capable() -> bool {
MULTI_SELECT_CAPABLE.load(std::sync::atomic::Ordering::SeqCst)
}
impl qbz_shell::QbzShell {
pub fn boot(self: Pin<&mut Self>) {
if QT_THREAD.set(self.qt_thread()).is_err() {
@@ -626,6 +689,42 @@ impl qbz_shell::QbzShell {
pub fn drag_end(self: Pin<&mut Self>) {
crate::drag_end();
}
/// The custom WRITE targets of the §4.6 seam properties — the funnel
/// every QML reporter-Binding write passes through (the
/// `QbzImmersive.open` precedent), keeping the cross-singleton mirrors
/// exact.
pub fn set_multi_select_active(mut self: Pin<&mut Self>, value: bool) {
use cxx_qt::CxxQtType as _;
if self.multi_select_active == value {
return;
}
self.as_mut().rust_mut().multi_select_active = value;
self.as_mut().multi_select_active_changed();
MULTI_SELECT_ACTIVE.store(value, std::sync::atomic::Ordering::SeqCst);
}
pub fn set_multi_select_capable(mut self: Pin<&mut Self>, value: bool) {
use cxx_qt::CxxQtType as _;
if self.multi_select_capable == value {
return;
}
self.as_mut().rust_mut().multi_select_capable = value;
self.as_mut().multi_select_capable_changed();
MULTI_SELECT_CAPABLE.store(value, std::sync::atomic::Ordering::SeqCst);
}
/// §4.6: Ctrl+A select-all — bounce to the AppShell router, which
/// forwards to the mounted view's duck-typed `selectAll()`.
pub fn select_all_active(mut self: Pin<&mut Self>) {
self.as_mut().select_all_requested();
}
/// §4.6 / §1.2 Escape stack arm 6: exit the mounted view's multi-select
/// mode via its duck-typed `exitMultiSelectMode()`.
pub fn exit_multi_select(mut self: Pin<&mut Self>) {
self.as_mut().exit_multi_select_requested();
}
pub fn open_external_url(self: Pin<&mut Self>, url: QString) {
let url = url.to_string();
if let Err(e) = open::that(&url) {