feat(qt): hotkeys dispatcher, focus lifecycle, multi-select seam (B2)
km
This commit is contained in:
22 files changed
+475
-79
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 => {}
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user