focusSearch() force-focuses the search input and nothing in HeaderBar ever
released it: the Escape arm only fired while the cortinilla was open, and
even then it dismissed the dropdown and left focus in the field. Since the
central dispatcher refuses every binding while a text input holds focus
(AppShell.qml:133-139), the field was a keyboard dead end whose only way out
was a mouse click.
Escape now always dismisses the dropdown if open, accepts, and hands focus
to the shell root via the same isQbzShellRoot duck-walk the modals use.
Clearing focus alone would not do: a null activeFocusItem passes the gate
but leaves AppShell's Keys handler receiving nothing at all.
Reachable from Ctrl+f since the port; the Vim keymap's `/` puts it one
keystroke from the reflex that expects Escape to work, which is how it
surfaced.
The preset swaps the BASE shortcut table underneath the user's overrides
instead of rewriting them, so Default <-> Vim is lossless. Vim binds single
modifier-free keys: n/p, h/l, Shift+J/K, /, and Ctrl+o / Ctrl+i for the jump
list; rows with no vim idiom keep their defaults.
j and k are deliberately left UNBOUND. They belong to a per-list cursor,
which QBZ has no notion of yet, and a global action bound there now would
have to be taken back the day one lands. A test holds them free.
Where an override has already claimed a shortcut the new base wants, that
action goes unbound rather than double-binding a combo that
action_for_shortcut would otherwise resolve by BTreeMap id order.
Seven new tests, including a gate that fails if any preset ever binds one
shortcut to two actions.
Eight Playback actions the table never had, on Spotify's desktop defaults:
volume up/down (Ctrl+Up/Down), mute (M), shuffle (Ctrl+S), repeat (Ctrl+R),
like (Alt+Shift+B), and a surface-independent seek (Ctrl+Shift+Left/Right).
Each reuses an existing transport seam, and the immersive bare-arrow seek
rows are untouched. Volume honours the player bars' volLocked predicate, so
the hotkey cannot move the slider on an ALSA-Direct hw path where the engine
documents the write as a no-op. Like rides a QML seam, as nav.search does,
because the track id is a QbzPlayer property rather than part of the Rust
now-playing model.
Two new tests; labels added to all eight catalogs.