feat(qt): hotkeys core, QbzHotkeys bridge, shortcut i18n catalogs (B1)

km
This commit is contained in:
vicrodh committed 2026-08-03 11:33:14 -06:00
1 parent c0f4d2f1b7
commit 0cca9abc58
17 files changed
+1974 -1

No files matched your search

+1 -1
View File
@@ -51,7 +51,7 @@ fn main() {
// (myqbz_qt.rs, blacklist_qt.rs, toast_qt.rs, …) are plain // (myqbz_qt.rs, blacklist_qt.rs, toast_qt.rs, …) are plain
// modules and must NOT be listed — only files that declare a // modules and must NOT be listed — only files that declare a
// #[cxx_qt::bridge] mod belong in this array. // #[cxx_qt::bridge] mod belong in this array.
rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/immersive_bridge.rs", "src/suggestions_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs", "src/playlist_picker_bridge.rs", "src/playlist_manager_bridge.rs", "src/playlist_import_bridge.rs", "src/folder_edit_bridge.rs", "src/playlist_edit_bridge.rs", "src/qconnect_bridge.rs"], rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/immersive_bridge.rs", "src/suggestions_bridge.rs", "src/hotkeys_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs", "src/playlist_picker_bridge.rs", "src/playlist_manager_bridge.rs", "src/playlist_import_bridge.rs", "src/folder_edit_bridge.rs", "src/playlist_edit_bridge.rs", "src/qconnect_bridge.rs"],
qml_files: &[ qml_files: &[
"qml/LoginScreen.qml", "qml/LoginScreen.qml",
"qml/Main.qml", "qml/Main.qml",
+4
View File
@@ -112,6 +112,10 @@ ApplicationWindow {
// badge/devices publishes it makes must find the Qt-thread hop // badge/devices publishes it makes must find the Qt-thread hop
// already registered or they are dropped silently. // already registered or they are dropped silently.
QbzQConnect.boot() QbzQConnect.boot()
// Hotkeys layer (2026-08-03 hotkeys-port contract, block B1): boot
// after QbzSession like every domain singleton. The B2 dispatcher
// (AppShell-root Keys.onPressed) is the only other QML side.
QbzHotkeys.boot()
QbzShell.boot() QbzShell.boot()
QbzPlayer.boot() QbzPlayer.boot()
QbzQueue.boot() QbzQueue.boot()
+448
View File
@@ -0,0 +1,448 @@
//! QbzHotkeys — keyboard/hotkeys domain bridge (viz_bridge.rs exemplar:
//! OnceLock<CxxQtThread>, `ui()` no-op pre-boot, `boot()` invokable, `impl
//! Default` for construction-seeded values).
//!
//! The Rust half of the 2026-08-03 hotkeys-port contract
//! (`qbz-nix-docs/qt-frontend/2026-08-03-hotkeys-port/00-CONTRACT.md` v2 §3.4,
//! block B1): the ONE brain behind the AppShell QML dispatcher (route (b),
//! divergence K1 — a QML-side single dispatcher + a Rust brain, because
//! cxx-qt-lib 0.7.3 ships no QEvent/QKeyEvent/installEventFilter plumbing).
//! The pure model (the 23-action table, grammar, store, capture, groups, the
//! §1.2 Escape stack) lives in `hotkeys_qt.rs`; this file is the singleton
//! surface + the §1.1 ordered pipeline.
//!
//! `keyPressed` runs the pipeline IN ORDER (contract §1.1, trap 7):
//! (A) capture mode — the customize editor is recording a binding
//! (ALWAYS consumes; Escape cancels, conflict keeps recording);
//! (B) the search-dropdown Up/Down steal — immersive wins over the
//! desktop cortinilla when both are open (main.rs:1338-1369);
//! (C) the central text-input gate — a BOOL COMPUTED IN QML
//! (`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);
//! (D) the binding lookup → dispatch to the §2 targets.
//!
//! The synchronous-return invokable is the app's core idiom
//! (session_bridge.rs:86 `tr`): the implementation finishes its OWN state
//! mutations BEFORE or AFTER — never across — the dispatch calls that emit
//! other bridges' property notifies (those ride the cross-singleton `ui()`
//! hops and land on the next event-loop pass regardless).
use std::pin::Pin;
use std::sync::OnceLock;
use cxx_qt::CxxQtThread;
use cxx_qt::Threading as _;
use cxx_qt_lib::QString;
use crate::hotkeys_qt::{
CaptureOutcome, EscapeState, EscapeTarget, QT_KEY_DOWN, QT_KEY_UP,
};
/// Full-shape default for the groups document (trap 15): the QML columns read
/// `.col1.length` in the pre-publish frame. `boot()` refreshes immediately.
const GROUPS_EMPTY: &str = r#"{"col1":[],"col2":[],"col3":[]}"#;
#[cxx_qt::bridge]
pub mod qbz_hotkeys {
extern "C++" {
include!("cxx-qt-lib/qstring.h");
type QString = cxx_qt_lib::QString;
}
#[auto_cxx_name]
extern "RustQt" {
#[qobject]
#[qml_element]
#[qml_singleton]
// The §3.4 groups document: the 5 groups × rows {id,label,shortcut,
// modified,contextual}, split round-robin (cols[i%3]) into col1/2/3
// — the QML renders three Repeater columns from one doc. Labels are
// localized via qbz_i18n::t at build time; recomputed on open AND on
// the trRev language bump (QML calls refresh() there, §3.4/F12).
#[qproperty(QString, groups_json)]
// The customize header's "N modified" accent badge (§4.5).
#[qproperty(i32, modified_count)]
// Capture state (§3.3): the action id being recorded ("" = not
// recording), the live formatted pending combo, and the conflicting
// action's label while a conflict is held.
#[qproperty(QString, recording_id)]
#[qproperty(QString, pending_display)]
#[qproperty(QString, conflict_label)]
// The two modal open flags (the B3 QML modals self-gate on these;
// Escape stack arms 2 and 3 read them).
#[qproperty(bool, cheatsheet_open)]
#[qproperty(bool, customize_open)]
type QbzHotkeys = super::QbzHotkeysRust;
/// Registers this object's Qt-thread hop (Main.qml boots EVERY
/// domain singleton; only QbzSession.boot also fires crate::on_boot).
/// Also computes the first groups document (labels localized for the
/// boot language).
#[qinvokable]
fn boot(self: Pin<&mut QbzHotkeys>);
/// The ONE key entry (§1.1): the AppShell-root dispatcher calls this
/// with the raw QKeyEvent fields + the QML-computed text-input gate.
/// Returns true = consumed (`event.accepted = <return>`, §4.1).
#[qinvokable]
fn key_pressed(
self: Pin<&mut QbzHotkeys>,
key: i32,
modifiers: i32,
text: QString,
text_input_focused: bool,
) -> bool;
/// Customize editor: begin recording a binding for `id`
/// (keybindings.rs:390-397).
#[qinvokable]
fn start_record(self: Pin<&mut QbzHotkeys>, id: QString);
/// Cancel the in-flight recording (the modal's own Escape arm).
#[qinvokable]
fn cancel_record(self: Pin<&mut QbzHotkeys>);
/// Drop one override / every override + refresh (keybindings.rs
/// :313-323).
#[qinvokable]
fn reset_one(self: Pin<&mut QbzHotkeys>, id: QString);
#[qinvokable]
fn reset_all(self: Pin<&mut QbzHotkeys>);
/// ui.showShortcuts target + the HeaderBar menu row: open the
/// cheatsheet (and recompute the groups, §3.4 — recomputed on open).
#[qinvokable]
fn open_cheatsheet(self: Pin<&mut QbzHotkeys>);
/// Cheatsheet footer: open the customize editor.
#[qinvokable]
fn open_customize(self: Pin<&mut QbzHotkeys>);
/// Close both modals + cancel any in-flight recording.
#[qinvokable]
fn close_all(self: Pin<&mut QbzHotkeys>);
/// Recompute groups_json + modified_count — called from the trRev
/// language bump (§3.4/F12) and after every store mutation.
#[qinvokable]
fn refresh(self: Pin<&mut QbzHotkeys>);
}
impl cxx_qt::Threading for QbzHotkeys {}
}
use qbz_hotkeys::QbzHotkeys;
/// Rust side of the hotkeys bridge (plain storage, phase-1 pattern).
pub struct QbzHotkeysRust {
groups_json: QString,
modified_count: i32,
recording_id: QString,
pending_display: QString,
conflict_label: QString,
cheatsheet_open: bool,
customize_open: bool,
}
impl Default for QbzHotkeysRust {
fn default() -> Self {
Self {
groups_json: QString::from(GROUPS_EMPTY),
modified_count: 0,
recording_id: QString::from(""),
pending_display: QString::from(""),
conflict_label: QString::from(""),
cheatsheet_open: false,
customize_open: false,
}
}
}
// ---------------------------------------------------------------------------
// The UI hop (viz_bridge.rs pattern)
// ---------------------------------------------------------------------------
static QT_THREAD: OnceLock<CxxQtThread<QbzHotkeys>> = OnceLock::new();
/// Queue a hotkeys-bridge mutation onto the Qt event loop (no-op before
/// boot registers the thread).
pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzHotkeys>) + Send + 'static) {
if let Some(thread) = QT_THREAD.get() {
let _ = thread.queue(f);
}
}
/// Recompute + publish the groups document and the modified count (the two
/// pieces derived from the store). Called by `refresh`, `boot`, every store
/// mutation, and the open verbs (§3.4 — recompute on open).
fn publish_groups(mut this: Pin<&mut QbzHotkeys>) {
let overrides = crate::hotkeys_qt::load_overrides();
let json = crate::hotkeys_qt::groups_json(&overrides);
let count = crate::hotkeys_qt::modified_count_with(&overrides);
this.as_mut().set_groups_json(QString::from(json.as_str()));
this.as_mut().set_modified_count(count);
}
/// Clear the capture triple (recording id + pending + conflict) — the shared
/// tail of cancel / successful bind / closeAll (keybindings.rs:399-407,
/// :461-464).
fn clear_capture(mut this: Pin<&mut QbzHotkeys>) {
this.as_mut().set_recording_id(QString::from(""));
this.as_mut().set_pending_display(QString::from(""));
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",
/// 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
}
/// §1.3 seek math, verbatim from keybindings.rs:572-581:
/// `clamp(npElapsedSecs ± d, 0, npDurationSecs) / npDurationSecs`, same 1 Hz
/// base (the now_playing model is fed by the poll pump — immersive D14).
fn seek_relative(delta: i32) {
let (pos, duration) = crate::now_playing::position();
if duration <= 0 {
return;
}
let target = (pos + delta).clamp(0, duration);
crate::transport_seek(target as f32 / duration as f32);
}
impl qbz_hotkeys::QbzHotkeys {
pub fn boot(self: Pin<&mut Self>) {
if QT_THREAD.set(self.qt_thread()).is_err() {
log::warn!("[qbz-qt] hotkeys Qt thread already registered");
}
// The first groups document (labels localized for the boot language).
ui(|h| publish_groups(h));
}
/// The §1.1 ordered pipeline (see the file header). true = consumed.
pub fn key_pressed(
mut self: Pin<&mut Self>,
key: i32,
modifiers: i32,
text: QString,
text_input_focused: bool,
) -> bool {
let text = text.to_string();
// (A) Capture mode — the customize editor is recording. ALWAYS
// consumes (keybindings.rs:434: "Always consumes the event").
let recording = self.recording_id().to_string();
if !recording.is_empty() {
let overrides = crate::hotkeys_qt::load_overrides();
match crate::hotkeys_qt::capture_step(&overrides, &recording, key, modifiers, &text) {
CaptureOutcome::Cancelled => {
clear_capture(self.as_mut());
}
CaptureOutcome::Ignored => {}
CaptureOutcome::Conflict { display, label } => {
// Live pending + the conflict label; recording STAYS so
// the user can pick a different combo
// (keybindings.rs:454-459).
self.as_mut().set_pending_display(QString::from(display.as_str()));
self.as_mut().set_conflict_label(QString::from(label.as_str()));
}
CaptureOutcome::Bound { shortcut } => {
crate::hotkeys_qt::set_binding(&recording, &shortcut);
clear_capture(self.as_mut());
publish_groups(self.as_mut());
}
}
return true;
}
// (B) Search-dropdown Up/Down steal — immersive wins when both are
// open (main.rs:1338-1369). The local field handlers fire FIRST via
// the focus chain and accept; this arm only catches keys that reached
// the root (§1.1(B) NOTE — no double-handling).
if key == QT_KEY_UP || key == QT_KEY_DOWN {
let delta = if key == QT_KEY_DOWN { 1 } else { -1 };
if crate::immersive_bridge::is_open() && crate::search_qt::imm_search_open() {
crate::search_qt::imm_move_selection(delta);
return true;
}
if crate::search_qt::cortinilla_open() {
crate::search_qt::move_selection(delta);
return true;
}
}
// (C) The central text-input gate (main.rs:1371-1375) — computed in
// QML per §1.4.4 and passed in. Null activeFocusItem passes (the
// semantically right case).
if text_input_focused {
return false;
}
// (C2) Ctrl+A select-all — separate NON-REBINDABLE branch (§4.6,
// trap 8): the predicate is `ctrl && a` with other modifiers NOT
// excluded, and the arm consumes ONLY when a surface accepted (the
// Slint select_all_active_surface rule — B2 wires the Qt surface).
if crate::hotkeys_qt::is_ctrl_a(key, modifiers, &text) && select_all_active() {
return true;
}
// (D) Binding lookup → dispatch (keybindings.rs:476-500; the context
// gates live in hotkeys_qt::action_for_key).
let overrides = crate::hotkeys_qt::load_overrides();
let Some(action) = crate::hotkeys_qt::action_for_key(
&overrides,
key,
modifiers,
&text,
crate::immersive_bridge::is_open(),
) else {
return false;
};
self.run_action(action.id)
}
/// The §2 dispatch table (keybindings.rs:502-531 `run_action`). true =
/// consumed. The no-op seams are CONTRACTUAL (K3, trap 10): ui.openLink
/// (LinkResolver not ported, HeaderBar.qml:23 — activates when it lands),
/// ui.miniPlayer (no Qt miniplayer, §6 multi-window-ready), and the
/// mini.* rows (no-op on the main window — kept in the table for
/// binding-file compat + the cheatsheet).
fn run_action(mut self: Pin<&mut Self>, id: &str) -> bool {
match id {
"playback.toggle" => crate::transport_toggle_play(),
"playback.next" => crate::transport_next(),
"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.settings" => crate::navigate_to("settings"),
"ui.sidebar" => crate::shell_bridge::ui(|s| s.cycle_sidebar()),
"ui.focusMode" => crate::immersive_bridge::toggle(),
"ui.queue" => crate::shell_bridge::ui(|s| s.toggle_queue()),
"ui.escape" => return self.handle_escape(),
"ui.showShortcuts" => {
// keybindings.rs:521-524: open + refresh (the groups recompute
// on open, §3.4).
self.as_mut().set_cheatsheet_open(true);
publish_groups(self.as_mut());
}
"ui.openLink" => {
log::debug!("[qbz-qt] hotkeys: ui.openLink — no-op seam, LinkResolver not ported (K3)");
}
"ui.miniPlayer" => {
log::debug!("[qbz-qt] hotkeys: ui.miniPlayer — no-op seam, no Qt miniplayer (K3/§6)");
}
"focus.seekForward" => seek_relative(5),
"focus.seekBack" => seek_relative(-5),
"focus.seekForwardLong" => seek_relative(10),
"focus.seekBackLong" => seek_relative(-10),
// mini.* — action_for_key already gates Context::Mini out of the
// main window; this arm is defensive only.
_ => return false,
}
true
}
/// ui.escape — the §1.2 priority stack. The pure ordered check is
/// hotkeys_qt::escape_target; here the state is sampled from the live
/// surfaces and the winning arm executes. The matched ui.escape CONSUMES
/// even when no surface closes (Slint parity — dispatch PreventDefaults on
/// any fired action, keybindings.rs:498-499).
fn handle_escape(mut self: Pin<&mut Self>) -> bool {
let state = EscapeState {
// ABSENT-BY-GAP (§1.2.1): the LinkResolver is not ported
// (HeaderBar.qml:23) — the arm stays in the ORDER, never taken.
link_resolver_open: false,
customize_open: *self.customize_open(),
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,
queue_open: crate::shell_bridge::queue_open(),
};
match crate::hotkeys_qt::escape_target(&state) {
EscapeTarget::Customize => self.as_mut().set_customize_open(false),
EscapeTarget::Cheatsheet => self.as_mut().set_cheatsheet_open(false),
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");
}
EscapeTarget::Queue => crate::shell_bridge::ui(|s| s.toggle_queue()),
EscapeTarget::LinkResolver | EscapeTarget::None => {}
}
true
}
pub fn start_record(mut self: Pin<&mut Self>, id: QString) {
// keybindings.rs:390-397: record + clear pending/conflict.
self.as_mut().set_recording_id(id);
self.as_mut().set_pending_display(QString::from(""));
self.as_mut().set_conflict_label(QString::from(""));
}
pub fn cancel_record(self: Pin<&mut Self>) {
clear_capture(self);
}
pub fn reset_one(self: Pin<&mut Self>, id: QString) {
crate::hotkeys_qt::reset_one(&id.to_string());
publish_groups(self);
}
pub fn reset_all(self: Pin<&mut Self>) {
crate::hotkeys_qt::reset_all();
publish_groups(self);
}
pub fn open_cheatsheet(mut self: Pin<&mut Self>) {
self.as_mut().set_cheatsheet_open(true);
publish_groups(self.as_mut());
}
pub fn open_customize(mut self: Pin<&mut Self>) {
self.as_mut().set_customize_open(true);
publish_groups(self.as_mut());
}
pub fn close_all(mut self: Pin<&mut Self>) {
self.as_mut().set_cheatsheet_open(false);
self.as_mut().set_customize_open(false);
clear_capture(self.as_mut());
}
pub fn refresh(self: Pin<&mut Self>) {
publish_groups(self);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn groups_default_is_full_shape() {
// trap 15: the construction-seeded default parses and carries all
// three columns — QML reads `.colN.length` in the pre-publish frame.
let doc: serde_json::Value = serde_json::from_str(GROUPS_EMPTY).unwrap();
for col in ["col1", "col2", "col3"] {
assert!(doc[col].is_array());
}
}
}
+952
View File
@@ -0,0 +1,952 @@
//! Keyboard / hotkeys layer — the PURE core (2026-08-03 hotkeys-port contract
//! `qbz-nix-docs/qt-frontend/2026-08-03-hotkeys-port/00-CONTRACT.md` v2 §3,
//! block B1). Byte-faithful port of the Slint `crates/qbz/src/keybindings.rs`
//! model: the 23-action DEFAULTS table (:88-117 — NB its :3 "26 actions"
//! comment is STALE, do not "fix" the count), the shortcut-string grammar
//! (:153-249), the bindings store + conflict detection (:256-323), the groups
//! builder + three-column round-robin (:329-380), the capture semantics
//! (:434-467) and the §1.2 Escape priority stack (:585-615).
//!
//! Plain module — NO `#[cxx_qt::bridge]` here (the QbzHotkeys singleton lives
//! in hotkeys_bridge.rs; the core+bridge split is the suggestions_qt /
//! suggestions_bridge precedent). Do NOT list this file in build.rs
//! rust_files.
//!
//! The one intentional DIFFERENCE from Slint is the token source (contract
//! §3.1 HYBRID rule, round-1 F1): Slint reads winit's LOGICAL key, which is
//! already the shifted glyph AND Ctrl-independent; a QKeyEvent's `text()` is
//! NOT (Ctrl+s yields U+0013, so a text-only rule breaks every Ctrl+printable
//! binding). Hence `token_from_qt_key` takes named keys AND letters/digits
//! from `event.key` (Qt key codes, letters uppercased under ShiftModifier —
//! reproducing winit's `Shift+s` -> "S", `Ctrl+s` -> "s") and consults
//! `event.text` ONLY for a single printable NON-control char (the
//! shifted-symbol case, `Shift+/` -> "?" — scoping trap 7). Bare modifiers,
//! multi-char and control texts yield None.
//!
//! Store discipline (contract §3.2): the SAME shared `ui_prefs.json` the
//! Slint app writes, ONE top-level `keybindings` key holding the whole
//! overrides map (action id -> shortcut). Reads go through the settings_qt
//! pref readers, writes ONLY through `settings_qt::save_pref("keybindings",
//! map)` — the additive-patch discipline of settings_qt.rs:135-158 holds
//! because the map is one key. Every pure function takes the overrides map as
//! an argument, so the §3.5 unit tests never touch the real file.
use std::collections::BTreeMap;
use serde::Serialize;
// ============================================================================
// Qt key codes + modifier masks (Qt::Key / Qt::KeyboardModifier, qnamespace.h)
// ============================================================================
// Only the codes the grammar names. QML passes `event.key` / `event.modifiers`
// straight through, so these are the raw values Qt delivers.
pub(crate) const QT_KEY_ESCAPE: i32 = 0x0100_0000;
pub(crate) const QT_KEY_TAB: i32 = 0x0100_0001;
pub(crate) const QT_KEY_BACKSPACE: i32 = 0x0100_0003;
pub(crate) const QT_KEY_RETURN: i32 = 0x0100_0004;
pub(crate) const QT_KEY_ENTER: i32 = 0x0100_0005;
pub(crate) const QT_KEY_DELETE: i32 = 0x0100_0007;
pub(crate) const QT_KEY_LEFT: i32 = 0x0100_0012;
pub(crate) const QT_KEY_UP: i32 = 0x0100_0013;
pub(crate) const QT_KEY_RIGHT: i32 = 0x0100_0014;
pub(crate) const QT_KEY_DOWN: i32 = 0x0100_0015;
pub(crate) const QT_KEY_SPACE: i32 = 0x20;
pub(crate) const QT_KEY_0: i32 = 0x30;
pub(crate) const QT_KEY_9: i32 = 0x39;
pub(crate) const QT_KEY_A: i32 = 0x41;
pub(crate) const QT_KEY_Z: i32 = 0x5a;
pub(crate) const QT_SHIFT: i32 = 0x0200_0000;
pub(crate) const QT_CONTROL: i32 = 0x0400_0000;
pub(crate) const QT_ALT: i32 = 0x0800_0000;
pub(crate) const QT_META: i32 = 0x1000_0000;
/// `(ctrl, alt, shift)` from a QKeyEvent `modifiers` word — contract §1.1(0):
/// modifier state comes WITH each event, and Ctrl folds Meta (the TS
/// `ctrlKey || metaKey`, Slint `main.rs:1266-1274`'s
/// `control_key() || super_key()`).
pub fn mods_from_qt(modifiers: i32) -> (bool, bool, bool) {
(
modifiers & (QT_CONTROL | QT_META) != 0,
modifiers & QT_ALT != 0,
modifiers & QT_SHIFT != 0,
)
}
// ============================================================================
// Action model (keybindings.rs:37-121 verbatim)
// ============================================================================
/// Display/grouping category. Order here is the on-screen order.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Category {
Playback,
Navigation,
Ui,
Immersive,
Mini,
}
impl Category {
const ORDER: [Category; 5] = [
Category::Playback,
Category::Navigation,
Category::Ui,
Category::Immersive,
Category::Mini,
];
/// English source string for the localized category header.
fn label_en(self) -> &'static str {
match self {
Category::Playback => "Playback",
Category::Navigation => "Navigation",
Category::Ui => "Interface",
Category::Immersive => "Immersive",
Category::Mini => "Mini Player",
}
}
}
/// When an action only fires in a specific surface.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Context {
None,
/// Only while the immersive overlay is open (seek shortcuts).
Immersive,
/// Only while the miniplayer window is active (surface 1-5). Dispatched on
/// the mini window's own hook, never on the main window (§6 — multi-window
/// readiness, documented seam, NOT implemented).
Mini,
}
pub struct ActionDef {
pub id: &'static str,
pub label_en: &'static str,
pub category: Category,
pub default: &'static str,
pub context: Context,
}
/// The full action table — a 1:1 port of the Tauri `ACTIONS` array
/// (keybindings.rs:88-117; contract §2 verbatim).
pub const ACTIONS: &[ActionDef] = &[
// Playback
ActionDef { id: "playback.toggle", label_en: "Play / Pause", category: Category::Playback, default: "Space", context: Context::None },
ActionDef { id: "playback.next", label_en: "Next Track", category: Category::Playback, default: "Ctrl+ArrowRight", context: Context::None },
ActionDef { id: "playback.prev", label_en: "Previous Track", category: Category::Playback, default: "Ctrl+ArrowLeft", context: Context::None },
// Navigation
ActionDef { id: "nav.back", label_en: "Go Back", category: Category::Navigation, default: "Alt+ArrowLeft", context: Context::None },
ActionDef { id: "nav.forward", label_en: "Go Forward", category: Category::Navigation, default: "Alt+ArrowRight", context: Context::None },
ActionDef { id: "nav.search", label_en: "Search", category: Category::Navigation, default: "Ctrl+f", context: Context::None },
ActionDef { id: "nav.settings", label_en: "Settings", category: Category::Navigation, default: "Ctrl+,", context: Context::None },
// Interface
ActionDef { id: "ui.sidebar", label_en: "Toggle Sidebar", category: Category::Ui, default: "Shift+S", context: Context::None },
ActionDef { id: "ui.focusMode", label_en: "Immersive Mode", category: Category::Ui, default: "Shift+I", context: Context::None },
ActionDef { id: "ui.queue", label_en: "Queue", category: Category::Ui, default: "q", context: Context::None },
ActionDef { id: "ui.escape", label_en: "Close / Dismiss", category: Category::Ui, default: "Escape", context: Context::None },
ActionDef { id: "ui.showShortcuts", label_en: "Show Shortcuts", category: Category::Ui, default: "?", context: Context::None },
ActionDef { id: "ui.openLink", label_en: "Open Qobuz Link", category: Category::Ui, default: "Ctrl+l", context: Context::None },
ActionDef { id: "ui.miniPlayer", label_en: "Toggle Mini Player", category: Category::Ui, default: "Shift+M", context: Context::None },
// Immersive (contextual)
ActionDef { id: "focus.seekForward", label_en: "Seek Forward (5s)", category: Category::Immersive, default: "ArrowRight", context: Context::Immersive },
ActionDef { id: "focus.seekBack", label_en: "Seek Back (5s)", category: Category::Immersive, default: "ArrowLeft", context: Context::Immersive },
ActionDef { id: "focus.seekForwardLong", label_en: "Seek Forward (10s)", category: Category::Immersive, default: "Shift+ArrowRight", context: Context::Immersive },
ActionDef { id: "focus.seekBackLong", label_en: "Seek Back (10s)", category: Category::Immersive, default: "Shift+ArrowLeft", context: Context::Immersive },
// Mini Player (contextual — dispatched on the mini window)
ActionDef { id: "mini.micro", label_en: "Micro View", category: Category::Mini, default: "1", context: Context::Mini },
ActionDef { id: "mini.compact", label_en: "Compact View", category: Category::Mini, default: "2", context: Context::Mini },
ActionDef { id: "mini.artwork", label_en: "Artwork View", category: Category::Mini, default: "3", context: Context::Mini },
ActionDef { id: "mini.queue", label_en: "Queue View", category: Category::Mini, default: "4", context: Context::Mini },
ActionDef { id: "mini.lyrics", label_en: "Lyrics View", category: Category::Mini, default: "5", context: Context::Mini },
];
pub fn action(id: &str) -> Option<&'static ActionDef> {
ACTIONS.iter().find(|a| a.id == id)
}
// ============================================================================
// Shortcut-string grammar (port of eventToShortcut / formatShortcutDisplay,
// keybindings.rs:153-249, with the §3.1 HYBRID token source)
// ============================================================================
/// Normalize a Qt key event to a canonical key token (the part after the
/// modifiers). Returns `None` for bare modifier presses and unrepresentable
/// keys. HYBRID rule (contract §3.1):
/// - named keys from `event.key` (the Qt key code);
/// - letters/digits from `event.key`, the base char, uppercased when
/// ShiftModifier is held (reproduces winit's logical key: `Shift+s` -> "S",
/// `Ctrl+s` -> "s" — `event.text` would be U+0013 there);
/// - `event.text` ONLY when it is a single printable NON-control char (the
/// shifted-symbol case: `Shift+/` -> "?").
pub fn token_from_qt_key(key: i32, modifiers: i32, text: &str) -> Option<String> {
match key {
QT_KEY_SPACE => return Some("Space".into()),
QT_KEY_LEFT => return Some("ArrowLeft".into()),
QT_KEY_RIGHT => return Some("ArrowRight".into()),
QT_KEY_UP => return Some("ArrowUp".into()),
QT_KEY_DOWN => return Some("ArrowDown".into()),
QT_KEY_ESCAPE => return Some("Escape".into()),
QT_KEY_RETURN | QT_KEY_ENTER => return Some("Enter".into()),
QT_KEY_TAB => return Some("Tab".into()),
QT_KEY_BACKSPACE => return Some("Backspace".into()),
QT_KEY_DELETE => return Some("Delete".into()),
_ => {}
}
// Letters/digits from the key CODE — Ctrl-independent, unlike text().
if (QT_KEY_A..=QT_KEY_Z).contains(&key) {
let base = (b'a' + (key - QT_KEY_A) as u8) as char;
return Some(
if modifiers & QT_SHIFT != 0 {
base.to_ascii_uppercase()
} else {
base
}
.to_string(),
);
}
if (QT_KEY_0..=QT_KEY_9).contains(&key) {
let base = (b'0' + (key - QT_KEY_0) as u8) as char;
return Some(base.to_string());
}
// The text fallback: exactly one char, printable (non-control). This is
// where the shifted symbols come from (`Shift+/` delivers "?").
let mut chars = text.chars();
let c = chars.next()?;
if chars.next().is_some() || c.is_control() {
return None;
}
Some(c.to_string())
}
/// Build the canonical shortcut string from modifiers + a key token
/// (keybindings.rs:177-198 verbatim): `[Ctrl+][Alt+][Shift+]Key`, with Shift
/// emitted ONLY for letters, digits and named (multi-char) keys — for a
/// symbol the Shift is already "consumed" by producing the glyph. Net stored
/// strings: `Shift+s` -> "Shift+S"; `Shift+/` -> "?"; `Ctrl+s` -> "Ctrl+s".
pub fn shortcut_from_parts(ctrl: bool, alt: bool, shift: bool, token: &str) -> Option<String> {
if token.is_empty() {
return None;
}
let mut parts: Vec<String> = Vec::new();
if ctrl {
parts.push("Ctrl".into());
}
if alt {
parts.push("Alt".into());
}
// Shift is emitted only for letters, digits, and named (multi-char) keys.
let is_named = token.chars().count() > 1;
let single = token.chars().next().unwrap();
let is_letter = !is_named && single.is_ascii_alphabetic();
let is_digit = !is_named && single.is_ascii_digit();
if shift && (is_named || is_letter || is_digit) {
parts.push("Shift".into());
}
parts.push(token.to_string());
Some(parts.join("+"))
}
const KEY_DISPLAY: &[(&str, &str)] = &[
// Solid triangles, not the thin Unicode arrows (←→↑↓) whose heads were
// nearly invisible at keycap size — these render a clear, filled arrowhead.
("ArrowLeft", "◀"),
("ArrowRight", "▶"),
("ArrowUp", "▲"),
("ArrowDown", "▼"),
("Space", "Space"),
("Escape", "Esc"),
("Enter", "↵"),
("Backspace", "⌫"),
("Delete", "Del"),
("Tab", "Tab"),
];
/// Format a shortcut string for display (port of `formatShortcutDisplay`,
/// keybindings.rs:217-249 verbatim). macOS uses ⌘⌥⇧ glyphs joined by spaces;
/// elsewhere "Ctrl + …".
pub fn format_display(shortcut: &str) -> String {
if shortcut.is_empty() {
return String::new();
}
let (mut ctrl, mut alt, mut shift) = (false, false, false);
let mut key = "";
for part in shortcut.split('+') {
match part {
"Ctrl" => ctrl = true,
"Alt" => alt = true,
"Shift" => shift = true,
other => key = other,
}
}
let mac = cfg!(target_os = "macos");
let mut out: Vec<String> = Vec::new();
if ctrl {
out.push(if mac { "⌘" } else { "Ctrl" }.into());
}
if alt {
out.push(if mac { "⌥" } else { "Alt" }.into());
}
if shift {
out.push(if mac { "⇧" } else { "Shift" }.into());
}
let disp = KEY_DISPLAY
.iter()
.find(|(k, _)| *k == key)
.map(|(_, v)| (*v).to_string())
.unwrap_or_else(|| key.to_uppercase());
out.push(disp);
out.join(if mac { " " } else { " + " })
}
// ============================================================================
// Bindings (defaults + user overrides) + conflict detection
// (keybindings.rs:256-323; storage contract §3.2)
// ============================================================================
/// The persisted user overrides (action id -> shortcut) from the shared
/// `ui_prefs.json` `keybindings` map — a NESTED JSON object, read via the
/// settings_qt `pref_json` reader (the map is one top-level key, so the
/// additive-patch discipline holds). A missing/unparsable key is an empty
/// map, NOT an error; non-string values are dropped defensively.
pub(crate) fn load_overrides() -> BTreeMap<String, String> {
crate::settings_qt::pref_json("keybindings")
.and_then(|v| v.as_object().cloned())
.unwrap_or_default()
.into_iter()
.filter_map(|(id, sc)| sc.as_str().map(|s| (id, s.to_string())))
.collect()
}
/// The active binding map (defaults overlaid with the user's overrides,
/// keybindings.rs:256-268). Overrides apply to KNOWN ids only; an unknown id
/// is silently ignored here but KEPT in the file (a stale override rides
/// along untouched — contract trap 3, RFB H5).
pub fn active_bindings_with(overrides: &BTreeMap<String, String>) -> BTreeMap<String, String> {
let mut map: BTreeMap<String, String> = BTreeMap::new();
for a in ACTIONS {
map.insert(a.id.to_string(), a.default.to_string());
}
for (id, shortcut) in overrides {
if map.contains_key(id) {
map.insert(id.clone(), shortcut.clone());
}
}
map
}
/// The live active map (defaults + the persisted overrides). Part of the
/// contract §3.2 named API; the bridge resolves keys through
/// `action_for_key` (which overlays internally), so nothing calls this yet.
#[allow(dead_code)]
pub fn active_bindings() -> BTreeMap<String, String> {
active_bindings_with(&load_overrides())
}
fn action_for_shortcut<'a>(
shortcut: &str,
bindings: &'a BTreeMap<String, String>,
) -> Option<&'static ActionDef> {
let id = bindings
.iter()
.find(|(_, v)| v.as_str() == shortcut)
.map(|(k, _)| k.clone())?;
action(&id)
}
/// The action (other than `exclude`) that already owns `shortcut`, if any —
/// conflict detection is EXACT canonical-string equality under a different id
/// (keybindings.rs:282-293, contract §3.3).
fn conflicting_action(
shortcut: &str,
exclude: &str,
bindings: &BTreeMap<String, String>,
) -> Option<&'static ActionDef> {
for (id, sc) in bindings {
if sc == shortcut && id != exclude {
return action(id);
}
}
None
}
/// The pure half of `set_binding` (keybindings.rs:296-311): refuses (false,
/// map untouched) on a conflict; rebinding back to the default DROPS the
/// override (keeps the file minimal — back-to-default pruning, contract
/// trap 3). On true the caller persists the map.
pub fn apply_binding(
overrides: &mut BTreeMap<String, String>,
action_id: &str,
shortcut: &str,
) -> bool {
let bindings = active_bindings_with(overrides);
if conflicting_action(shortcut, action_id, &bindings).is_some() {
return false;
}
let default = action(action_id).map(|a| a.default);
if Some(shortcut) == default {
overrides.remove(action_id);
} else {
overrides.insert(action_id.to_string(), shortcut.to_string());
}
true
}
/// Persist the whole overrides map as the ONE `keybindings` top-level key
/// (the additive single-key patch — every other Slint key survives).
fn save_overrides(overrides: &BTreeMap<String, String>) {
let map: serde_json::Map<String, serde_json::Value> = overrides
.iter()
.map(|(id, sc)| (id.clone(), serde_json::Value::String(sc.clone())))
.collect();
crate::settings_qt::save_pref("keybindings", serde_json::Value::Object(map));
}
/// Persist a new binding. Returns false (and writes nothing) on a conflict.
pub fn set_binding(action_id: &str, shortcut: &str) -> bool {
let mut overrides = load_overrides();
if !apply_binding(&mut overrides, action_id, shortcut) {
return false;
}
save_overrides(&overrides);
true
}
pub fn reset_one(action_id: &str) {
let mut overrides = load_overrides();
overrides.remove(action_id);
save_overrides(&overrides);
}
pub fn reset_all() {
save_overrides(&BTreeMap::new());
}
// ============================================================================
// The groups model (cheatsheet + customize editor share it;
// keybindings.rs:329-380) — published to QML as ONE JSON document.
// ============================================================================
#[derive(Clone, Serialize)]
pub struct GroupRow {
pub id: String,
pub label: String,
/// The FORMATTED display string (glyphs), as the Slint rows carry.
pub shortcut: String,
pub modified: bool,
pub contextual: bool,
}
#[derive(Clone, Serialize)]
pub struct Group {
pub label: String,
pub rows: Vec<GroupRow>,
}
/// The 5 groups in `Category::ORDER`, labels localized via `qbz_i18n::t`
/// (fallback = the English msgid — the inherited gap, contract §5; the 20
/// missing msgids were added to the 7 non-en catalogs in this same block).
pub fn build_groups(overrides: &BTreeMap<String, String>) -> Vec<Group> {
let bindings = active_bindings_with(overrides);
let mut groups: Vec<Group> = Vec::new();
for cat in Category::ORDER {
let mut rows: Vec<GroupRow> = Vec::new();
for a in ACTIONS.iter().filter(|a| a.category == cat) {
let shortcut = bindings.get(a.id).cloned().unwrap_or_default();
let modified = bindings.get(a.id).map(|s| s.as_str()) != Some(a.default);
rows.push(GroupRow {
id: a.id.to_string(),
label: qbz_i18n::t(a.label_en),
shortcut: format_display(&shortcut),
modified,
contextual: a.context != Context::None,
});
}
groups.push(Group {
label: qbz_i18n::t(cat.label_en()),
rows,
});
}
groups
}
pub fn modified_count_with(overrides: &BTreeMap<String, String>) -> i32 {
let bindings = active_bindings_with(overrides);
ACTIONS
.iter()
.filter(|a| bindings.get(a.id).map(|s| s.as_str()) != Some(a.default))
.count() as i32
}
/// The QML-facing document (contract §3.4): the 5 groups split round-robin
/// into THREE columns (`cols[i % 3]`, keybindings.rs:368-374) so the
/// cheatsheet/editor render three Repeater columns from one doc.
/// `{"col1":[Group…],"col2":[…],"col3":[…]}` — full shape ALWAYS (trap 15:
/// never "{}").
pub fn groups_json(overrides: &BTreeMap<String, String>) -> String {
let groups = build_groups(overrides);
let mut cols: [Vec<Group>; 3] = Default::default();
for (i, g) in groups.into_iter().enumerate() {
cols[i % 3].push(g);
}
let [c0, c1, c2] = cols;
serde_json::json!({ "col1": c0, "col2": c1, "col3": c2 }).to_string()
}
// ============================================================================
// Capture (the customize editor's "press a key" widget — keybindings.rs:
// 434-467, contract §3.3). Pure: the bridge applies the outcome to its
// properties + the store.
// ============================================================================
/// What one captured keypress did. Slint's `handle_capture` ALWAYS consumes
/// the event; the enum says what else happened.
#[derive(Debug, PartialEq, Eq)]
pub enum CaptureOutcome {
/// Escape — cancel recording, bind NOTHING (Escape stays the ui.escape
/// default).
Cancelled,
/// Bare modifier / unrepresentable — ignore, KEEP recording.
Ignored,
/// The combo already belongs to another action: live `display` + the
/// conflicting action's localized `label`; recording STAYS so the user
/// can pick a different combo.
Conflict { display: String, label: String },
/// Clean — the caller runs `set_binding` + refresh + clears the capture
/// state.
Bound { shortcut: String },
}
pub fn capture_step(
overrides: &BTreeMap<String, String>,
action_id: &str,
key: i32,
modifiers: i32,
text: &str,
) -> CaptureOutcome {
if key == QT_KEY_ESCAPE {
return CaptureOutcome::Cancelled;
}
let (ctrl, alt, shift) = mods_from_qt(modifiers);
let Some(token) = token_from_qt_key(key, modifiers, text) else {
return CaptureOutcome::Ignored;
};
let Some(shortcut) = shortcut_from_parts(ctrl, alt, shift, &token) else {
return CaptureOutcome::Ignored;
};
let bindings = active_bindings_with(overrides);
if let Some(conflict) = conflicting_action(&shortcut, action_id, &bindings) {
return CaptureOutcome::Conflict {
display: format_display(&shortcut),
label: qbz_i18n::t(conflict.label_en),
};
}
CaptureOutcome::Bound { shortcut }
}
// ============================================================================
// (D) Binding lookup — the pure half of `dispatch` (keybindings.rs:476-500).
// ============================================================================
/// Resolve a key event to an action over the active bindings. `None` = miss
/// (propagate) OR context-gated out: `Context::Immersive` requires the
/// immersive overlay open (the §1.3 gate); `Context::Mini` never fires on the
/// main window (Slint `dispatch` Propagates it — keybindings.rs:494-495; the
/// mini.* actions are kept in the table for binding-file compat + the
/// cheatsheet, contract §2/K3).
pub fn action_for_key(
overrides: &BTreeMap<String, String>,
key: i32,
modifiers: i32,
text: &str,
immersive_open: bool,
) -> Option<&'static ActionDef> {
let (ctrl, alt, shift) = mods_from_qt(modifiers);
let token = token_from_qt_key(key, modifiers, text)?;
let shortcut = shortcut_from_parts(ctrl, alt, shift, &token)?;
let bindings = active_bindings_with(overrides);
let action = action_for_shortcut(&shortcut, &bindings)?;
match action.context {
Context::Immersive if !immersive_open => None,
Context::Mini => None,
_ => Some(action),
}
}
/// The (C2) Ctrl+A predicate, EXACTLY the Slint one (contract §4.6 / trap 8):
/// `ctrl && token.eq_ignore_ascii_case("a")` — other modifiers are NOT
/// excluded, so Ctrl+Shift+A and Ctrl+Alt+A also trigger.
/// `main.rs:1381-1386`.
pub fn is_ctrl_a(key: i32, modifiers: i32, text: &str) -> bool {
let (ctrl, _, _) = mods_from_qt(modifiers);
if !ctrl {
return false;
}
token_from_qt_key(key, modifiers, text)
.is_some_and(|tok| tok.eq_ignore_ascii_case("a"))
}
// ============================================================================
// The §1.2 Escape priority stack, as a PURE ordered check over a state
// struct (keybindings.rs:585-615, contract §1.2). The bridge fills the state
// from the live surfaces and executes the returned target.
// ============================================================================
#[derive(Clone, Copy, Default, Debug)]
pub struct EscapeState {
/// ABSENT-BY-GAP (contract §1.2.1): the LinkResolver is not ported
/// (`HeaderBar.qml:23`) — always false; the arm is kept so the ORDER is
/// the Slint one when the surface lands.
pub link_resolver_open: bool,
pub customize_open: bool,
pub cheatsheet_open: bool,
pub cortinilla_open: bool,
pub immersive_open: bool,
/// §4.6 seam: no Qt multi-select exit invokable exists yet (B2 adds
/// `QbzShell.exitMultiSelect`) — always false in B1.
pub multi_select_active: bool,
pub queue_open: bool,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum EscapeTarget {
LinkResolver,
Customize,
Cheatsheet,
Cortinilla,
Immersive,
MultiSelect,
Queue,
/// Nothing dismissable is open. Slint still CONSUMES the matched
/// ui.escape (dispatch PreventDefaults on any fired action,
/// keybindings.rs:498-499) — the bridge mirrors that.
None,
}
/// First match wins, in the contract §1.2 order.
pub fn escape_target(s: &EscapeState) -> EscapeTarget {
if s.link_resolver_open {
return EscapeTarget::LinkResolver;
}
if s.customize_open {
return EscapeTarget::Customize;
}
if s.cheatsheet_open {
return EscapeTarget::Cheatsheet;
}
if s.cortinilla_open {
return EscapeTarget::Cortinilla;
}
if s.immersive_open {
return EscapeTarget::Immersive;
}
if s.multi_select_active {
return EscapeTarget::MultiSelect;
}
if s.queue_open {
return EscapeTarget::Queue;
}
EscapeTarget::None
}
// ============================================================================
// Unit tests (contract §3.5 — mandatory). All pure: no prefs file, no Qt.
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
fn no_overrides() -> BTreeMap<String, String> {
BTreeMap::new()
}
// --- Grammar round-trips (§3.5) ---------------------------------------
#[test]
fn named_keys_come_from_the_key_code() {
assert_eq!(token_from_qt_key(QT_KEY_SPACE, 0, " "), Some("Space".into()));
assert_eq!(token_from_qt_key(QT_KEY_LEFT, 0, ""), Some("ArrowLeft".into()));
assert_eq!(token_from_qt_key(QT_KEY_RIGHT, 0, ""), Some("ArrowRight".into()));
assert_eq!(token_from_qt_key(QT_KEY_UP, 0, ""), Some("ArrowUp".into()));
assert_eq!(token_from_qt_key(QT_KEY_DOWN, 0, ""), Some("ArrowDown".into()));
assert_eq!(token_from_qt_key(QT_KEY_ESCAPE, 0, ""), Some("Escape".into()));
assert_eq!(token_from_qt_key(QT_KEY_RETURN, 0, "\r"), Some("Enter".into()));
assert_eq!(token_from_qt_key(QT_KEY_ENTER, 0, "\r"), Some("Enter".into()));
assert_eq!(token_from_qt_key(QT_KEY_TAB, 0, "\t"), Some("Tab".into()));
assert_eq!(
token_from_qt_key(QT_KEY_BACKSPACE, 0, "\u{8}"),
Some("Backspace".into())
);
assert_eq!(
token_from_qt_key(QT_KEY_DELETE, 0, "\u{7f}"),
Some("Delete".into())
);
}
#[test]
fn shift_slash_yields_question_mark_not_shift_slash() {
// scoping trap 7: the shifted glyph comes from event.text, and Shift
// is folded into the glyph — the stored string is "?".
let token = token_from_qt_key(0x2f, QT_SHIFT, "?").unwrap();
assert_eq!(token, "?");
assert_eq!(shortcut_from_parts(false, false, true, &token), Some("?".into()));
}
#[test]
fn shift_s_yields_shift_upper_s() {
let token = token_from_qt_key(QT_KEY_A + 18, QT_SHIFT, "S").unwrap(); // Key_S
assert_eq!(token, "S");
assert_eq!(
shortcut_from_parts(false, false, true, &token),
Some("Shift+S".into())
);
}
#[test]
fn ctrl_s_yields_ctrl_s_not_ctrl_control_char() {
// The HYBRID rule's raison d'être (round-1 F1): event.text under Ctrl
// is U+0013; the token MUST come from the key code.
let token = token_from_qt_key(QT_KEY_A + 18, QT_CONTROL, "\u{13}").unwrap();
assert_eq!(token, "s");
assert_eq!(
shortcut_from_parts(true, false, false, &token),
Some("Ctrl+s".into())
);
}
#[test]
fn meta_folds_into_ctrl() {
let (ctrl, alt, shift) = mods_from_qt(QT_META);
assert!(ctrl && !alt && !shift);
let (ctrl, _, _) = mods_from_qt(QT_META | QT_CONTROL);
assert!(ctrl);
}
#[test]
fn bare_modifier_and_unrepresentable_text_yield_none() {
// Qt.Key_Shift — a bare modifier press.
assert_eq!(token_from_qt_key(0x0100_0020, QT_SHIFT, ""), None);
// Multi-char text (compose / IME preview).
assert_eq!(token_from_qt_key(0x0100_0020, 0, "ab"), None);
// A lone control char in text.
assert_eq!(token_from_qt_key(0x0100_0020, 0, "\u{13}"), None);
// Unknown key, empty text.
assert_eq!(token_from_qt_key(0x0100_00ff, 0, ""), None);
}
#[test]
fn letters_and_digits_come_from_the_key_code() {
assert_eq!(token_from_qt_key(QT_KEY_A, 0, "a"), Some("a".into()));
assert_eq!(token_from_qt_key(QT_KEY_Z, QT_SHIFT, "Z"), Some("Z".into()));
assert_eq!(token_from_qt_key(QT_KEY_0 + 5, 0, "5"), Some("5".into()));
// Ctrl+comma: comma has NO control-char mapping in xkb (unlike
// letters), so event.text IS "," — the text arm delivers the token
// and the nav.settings default ("Ctrl+,") binds.
assert_eq!(token_from_qt_key(0x2c, QT_CONTROL, ","), Some(",".into()));
assert_eq!(
shortcut_from_parts(true, false, false, ","),
Some("Ctrl+,".into())
);
}
#[test]
fn format_display_uses_the_glyph_table() {
assert_eq!(format_display("Ctrl+ArrowRight"), "Ctrl + ▶");
assert_eq!(format_display("Shift+S"), "Shift + S");
assert_eq!(format_display("?"), "?");
assert_eq!(format_display("Space"), "Space");
assert_eq!(format_display("Escape"), "Esc");
assert_eq!(format_display("Alt+ArrowLeft"), "Alt + ◀");
assert_eq!(format_display(""), "");
}
// --- Conflict detection (exact canonical-string equality) --------------
#[test]
fn conflict_is_exact_string_under_a_different_id() {
let bindings = active_bindings_with(&no_overrides());
// "q" is ui.queue's default — recording ui.sidebar sees the conflict.
let conflict = conflicting_action("q", "ui.sidebar", &bindings).unwrap();
assert_eq!(conflict.id, "ui.queue");
// The OWNER of the combo is excluded (re-recording its own binding).
assert!(conflicting_action("q", "ui.queue", &bindings).is_none());
// Case matters — exact string equality, no fuzzy match.
assert!(conflicting_action("Q", "ui.queue", &bindings).is_none());
}
// --- active_bindings overlay + stale-id ignore -------------------------
#[test]
fn active_bindings_overlay_applies_known_ids_and_ignores_stale_ones() {
let mut overrides = no_overrides();
overrides.insert("ui.queue".into(), "w".into());
overrides.insert("stale.removed.action".into(), "x".into()); // unknown id
let bindings = active_bindings_with(&overrides);
assert_eq!(bindings.get("ui.queue").unwrap(), "w");
// The known count is exactly the 23 actions; the stale id is absent.
assert_eq!(bindings.len(), 23);
assert!(!bindings.contains_key("stale.removed.action"));
// …but the overrides map itself is untouched (kept in the file).
assert_eq!(overrides.get("stale.removed.action").unwrap(), "x");
}
// --- apply_binding: conflict refusal + back-to-default pruning ---------
#[test]
fn apply_binding_refuses_conflicts_without_touching_the_map() {
let mut overrides = no_overrides();
assert!(!apply_binding(&mut overrides, "ui.sidebar", "q"));
assert!(overrides.is_empty());
}
#[test]
fn apply_binding_back_to_default_prunes_the_override() {
let mut overrides = no_overrides();
overrides.insert("ui.queue".into(), "w".into());
assert!(apply_binding(&mut overrides, "ui.queue", "q")); // "q" = default
assert!(!overrides.contains_key("ui.queue"));
}
#[test]
fn apply_binding_writes_a_clean_rebind() {
let mut overrides = no_overrides();
assert!(apply_binding(&mut overrides, "ui.queue", "w"));
assert_eq!(overrides.get("ui.queue").unwrap(), "w");
assert_eq!(modified_count_with(&overrides), 1);
}
// --- Round-robin split {Playback,Immersive}/{Navigation,Mini}/{Interface}
#[test]
fn groups_split_round_robin_into_three_columns() {
let doc: serde_json::Value =
serde_json::from_str(&groups_json(&no_overrides())).unwrap();
let labels = |col: &str| -> Vec<String> {
doc[col]
.as_array()
.unwrap()
.iter()
.map(|g| g["label"].as_str().unwrap().to_string())
.collect()
};
assert_eq!(labels("col1"), vec!["Playback", "Immersive"]);
assert_eq!(labels("col2"), vec!["Navigation", "Mini Player"]);
assert_eq!(labels("col3"), vec!["Interface"]);
}
#[test]
fn groups_rows_carry_id_label_shortcut_modified_contextual() {
let doc: serde_json::Value =
serde_json::from_str(&groups_json(&no_overrides())).unwrap();
let row = &doc["col1"][0]["rows"][0];
assert_eq!(row["id"], "playback.toggle");
assert_eq!(row["label"], "Play / Pause"); // en fallback (msgid)
assert_eq!(row["shortcut"], "Space");
assert_eq!(row["modified"], false);
assert_eq!(row["contextual"], false);
// The immersive seek rows are contextual.
let seek = &doc["col1"][1]["rows"][0];
assert_eq!(seek["id"], "focus.seekForward");
assert_eq!(seek["contextual"], true);
// Full shape, never "{}" (trap 15).
for col in ["col1", "col2", "col3"] {
assert!(doc[col].is_array());
}
}
// --- Escape stack order (§1.2) -----------------------------------------
#[test]
fn escape_stack_walks_the_seven_surfaces_in_order() {
let s = |link, cust, cheat, cort, imm, multi, queue| EscapeState {
link_resolver_open: link,
customize_open: cust,
cheatsheet_open: cheat,
cortinilla_open: cort,
immersive_open: imm,
multi_select_active: multi,
queue_open: queue,
};
// Everything open → the link-resolver arm wins (order position 1).
assert_eq!(escape_target(&s(true, true, true, true, true, true, true)), EscapeTarget::LinkResolver);
// Then, in order, each surface beats every LATER one.
assert_eq!(escape_target(&s(false, true, true, true, true, true, true)), EscapeTarget::Customize);
assert_eq!(escape_target(&s(false, false, true, true, true, true, true)), EscapeTarget::Cheatsheet);
assert_eq!(escape_target(&s(false, false, false, true, true, true, true)), EscapeTarget::Cortinilla);
assert_eq!(escape_target(&s(false, false, false, false, true, true, true)), EscapeTarget::Immersive);
assert_eq!(escape_target(&s(false, false, false, false, false, true, true)), EscapeTarget::MultiSelect);
assert_eq!(escape_target(&s(false, false, false, false, false, false, true)), EscapeTarget::Queue);
assert_eq!(escape_target(&s(false, false, false, false, false, false, false)), EscapeTarget::None);
// Multi-select beats the queue (order position 6 vs 7).
assert_eq!(escape_target(&s(false, false, false, false, false, true, true)), EscapeTarget::MultiSelect);
}
// --- Capture: Escape-cancels vs conflict-keeps-recording ---------------
#[test]
fn capture_escape_cancels_and_bare_modifiers_are_ignored() {
let overrides = no_overrides();
assert_eq!(
capture_step(&overrides, "ui.queue", QT_KEY_ESCAPE, 0, ""),
CaptureOutcome::Cancelled
);
// Qt.Key_Shift alone → Ignored (keep recording).
assert_eq!(
capture_step(&overrides, "ui.queue", 0x0100_0020, QT_SHIFT, ""),
CaptureOutcome::Ignored
);
}
#[test]
fn capture_conflict_keeps_recording_with_the_live_display() {
let overrides = no_overrides();
// Recording ui.sidebar, the user presses "q" (ui.queue's binding).
let key_q = QT_KEY_A + 16;
match capture_step(&overrides, "ui.sidebar", key_q, 0, "q") {
CaptureOutcome::Conflict { display, label } => {
assert_eq!(display, "Q");
assert_eq!(label, "Queue"); // en fallback msgid
}
other => panic!("expected conflict, got {other:?}"),
}
// A clean combo binds.
let key_w = QT_KEY_A + 22;
assert_eq!(
capture_step(&overrides, "ui.queue", key_w, 0, "w"),
CaptureOutcome::Bound { shortcut: "w".into() }
);
}
// --- (D) lookup: context gates -----------------------------------------
#[test]
fn immersive_actions_are_gated_on_the_overlay_and_mini_never_fires() {
let overrides = no_overrides();
// ArrowRight with immersive CLOSED → miss.
assert!(action_for_key(&overrides, QT_KEY_RIGHT, 0, "", false).is_none());
// …open → focus.seekForward.
let a = action_for_key(&overrides, QT_KEY_RIGHT, 0, "", true).unwrap();
assert_eq!(a.id, "focus.seekForward");
// "1" is mini.micro — never on the main window.
assert!(action_for_key(&overrides, QT_KEY_0 + 1, 0, "1", false).is_none());
// A global action resolves regardless.
let a = action_for_key(&overrides, QT_KEY_SPACE, 0, " ", false).unwrap();
assert_eq!(a.id, "playback.toggle");
}
#[test]
fn ctrl_a_predicate_matches_the_slint_rule() {
let key_a = QT_KEY_A;
assert!(is_ctrl_a(key_a, QT_CONTROL, ""));
// Other modifiers are NOT excluded (§4.6 / trap 8).
assert!(is_ctrl_a(key_a, QT_CONTROL | QT_SHIFT, "A"));
assert!(is_ctrl_a(key_a, QT_CONTROL | QT_ALT, ""));
// Meta folds into Ctrl.
assert!(is_ctrl_a(key_a, QT_META, ""));
// Bare "a" and Ctrl+b are out.
assert!(!is_ctrl_a(key_a, 0, "a"));
assert!(!is_ctrl_a(QT_KEY_A + 1, QT_CONTROL, ""));
}
}
+26
View File
@@ -188,6 +188,31 @@ pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzImmersive>) + Send + 'static) {
} }
} }
/// Rust-side mirror of the `open` property, written inside `apply_open` — the
/// funnel EVERY write passes through (the custom-WRITE `set_open`, contract
/// §3/D3) — so it is exact. Read by the hotkeys pipeline: the (B)
/// dropdown-steal condition and the §1.2 Escape stack / the
/// `Context::Immersive` binding gate (2026-08-03 hotkeys-port contract),
/// which run on ANOTHER singleton and cannot reach this QObject's properties.
static OPEN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub(crate) fn is_open() -> bool {
OPEN.load(std::sync::atomic::Ordering::SeqCst)
}
/// Cross-singleton `toggle()` — the hotkeys `ui.focusMode` dispatch
/// (keybindings.rs:538-545 semantics ride the QbzImmersive invokable itself).
pub(crate) fn toggle() {
ui(|imm| imm.toggle());
}
/// Cross-singleton close — the hotkeys §1.2 Escape-stack arm 5. Rides the
/// SAME `set_open` funnel the QML exit paths use (the false edge parks the
/// viz tap, §3.3a/§4.2).
pub(crate) fn close() {
ui(|imm| imm.set_open(false));
}
/// §3.1 pinned map: a pinned default always lands on `view_mode=0` + the /// §3.1 pinned map: a pinned default always lands on `view_mode=0` + the
/// matching FOCUS `mode` (never WaveBed). `None` = "remember"/unknown — the /// matching FOCUS `mode` (never WaveBed). `None` = "remember"/unknown — the
/// caller restores the persisted triple / does nothing respectively. /// caller restores the persisted triple / does nothing respectively.
@@ -213,6 +238,7 @@ fn apply_open(mut this: Pin<&mut QbzImmersive>, value: bool) {
} }
this.as_mut().rust_mut().open = value; this.as_mut().rust_mut().open = value;
this.as_mut().open_changed(); this.as_mut().open_changed();
OPEN.store(value, std::sync::atomic::Ordering::SeqCst);
if value { if value {
// §3.1 apply-default-view — runs on EVERY false→true open edge, from // §3.1 apply-default-view — runs on EVERY false→true open edge, from
// both entries. Restore writes go DIRECTLY to the properties, NOT // both entries. Restore writes go DIRECTLY to the properties, NOT
+9
View File
@@ -33,6 +33,9 @@ mod immersive_bridge;
// Immersive Suggestions (the same contract, block B4, §4.5): the // Immersive Suggestions (the same contract, block B4, §4.5): the
// QbzSuggestions singleton — the one genuinely absent domain. // QbzSuggestions singleton — the one genuinely absent domain.
mod suggestions_bridge; mod suggestions_bridge;
// Hotkeys layer (2026-08-03 hotkeys-port contract, block B1): the
// QbzHotkeys singleton — the QML dispatcher's Rust brain (§1.1 pipeline).
mod hotkeys_bridge;
mod local_bridge; mod local_bridge;
mod library_bridge; mod library_bridge;
mod album_bridge; mod album_bridge;
@@ -178,6 +181,12 @@ mod search_qt;
// block B4): the loader + action arms behind QbzSuggestions. Plain module — // block B4): the loader + action arms behind QbzSuggestions. Plain module —
// no #[cxx_qt::bridge], so it must NOT appear in build.rs's rust_files. // no #[cxx_qt::bridge], so it must NOT appear in build.rs's rust_files.
mod suggestions_qt; mod suggestions_qt;
// Hotkeys layer PURE core (2026-08-03 hotkeys-port contract §3, block B1):
// the 23-action table, the shortcut grammar, the ui_prefs.json `keybindings`
// store, capture, the groups builder and the §1.2 Escape stack — the byte-
// faithful port of the Slint crates/qbz/src/keybindings.rs. Plain module —
// no #[cxx_qt::bridge], so it must NOT appear in build.rs's rust_files.
mod hotkeys_qt;
mod settings_qt; mod settings_qt;
mod sidebar_qt; mod sidebar_qt;
mod sleep_timer_qt; mod sleep_timer_qt;
+9
View File
@@ -234,6 +234,15 @@ pub fn repeat_mode() -> i32 {
with_model(|m| m.repeat_mode).0 with_model(|m| m.repeat_mode).0
} }
/// (elapsed_secs, duration_secs) of the current track — the read the hotkeys
/// seek math (2026-08-03 hotkeys-port contract §1.3) makes off the Slint
/// `NowPlayingState` (`keybindings.rs:572-581`: `position_secs` /
/// `duration_secs`, then `clamp(pos ± d, 0, dur) / dur`). The 1 Hz poll-pump
/// base is the Slint base (immersive contract D14).
pub(crate) fn position() -> (i32, i32) {
with_model(|m| (m.elapsed_secs, m.duration_secs)).0
}
/// (artist_id, title) of the current track — the read the immersive /// (artist_id, title) of the current track — the read the immersive
/// Suggestions loader makes off the Slint `NowPlayingState` /// Suggestions loader makes off the Slint `NowPlayingState`
/// (`main.rs:16699-16702`: the panel only has the track id, the seed artist /// (`main.rs:16699-16702`: the panel only has the track id, the seed artist
+30
View File
@@ -689,6 +689,7 @@ fn is_current_cort_version(v: u64) -> bool {
} }
fn set_cortinilla_open(open: bool) { fn set_cortinilla_open(open: bool) {
CORT_OPEN.store(open, Ordering::SeqCst);
crate::ui(move |mut b| { crate::ui(move |mut b| {
b.as_mut().set_cortinilla_open(open); b.as_mut().set_cortinilla_open(open);
if !open { if !open {
@@ -697,6 +698,18 @@ fn set_cortinilla_open(open: bool) {
}); });
} }
/// Rust-side mirror of `QbzBridge.cortinillaOpen` (the property itself lives
/// on ANOTHER singleton, unreachable from hotkeys_bridge). Every write already
/// funnels through `set_cortinilla_open` above — QML never writes the property
/// directly (grep `cortinillaOpen =` in qml/: no hits) — so the mirror is
/// exact. Read by the hotkeys pipeline's (B) dropdown-steal and the §1.2
/// Escape stack (2026-08-03 hotkeys-port contract §1.1/§1.2).
static CORT_OPEN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub fn cortinilla_open() -> bool {
CORT_OPEN.load(Ordering::SeqCst)
}
fn set_selected(index: i32, scroll_y: f32) { fn set_selected(index: i32, scroll_y: f32) {
CURRENT_SEL.store(index, Ordering::SeqCst); CURRENT_SEL.store(index, Ordering::SeqCst);
crate::ui(move |mut b| { crate::ui(move |mut b| {
@@ -991,6 +1004,19 @@ static IMM_VERSION: AtomicU64 = AtomicU64::new(0);
/// pattern, :682). /// pattern, :682).
static IMM_SEL: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1); static IMM_SEL: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);
/// Rust-side mirror of `QbzImmersive.immSearchOpen` — same pattern as
/// CORT_OPEN above: every write goes through one of the four
/// `set_imm_search_open` sites below and QML never writes the property
/// directly. Read by the hotkeys pipeline's (B) dropdown-steal
/// (2026-08-03 hotkeys-port contract §1.1(B): `QbzImmersive.open &&
/// QbzImmersive.immSearchOpen` wins over the desktop cortinilla).
static IMM_SEARCH_OPEN: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
pub fn imm_search_open() -> bool {
IMM_SEARCH_OPEN.load(Ordering::SeqCst)
}
fn next_imm_version() -> u64 { fn next_imm_version() -> u64 {
IMM_VERSION.fetch_add(1, Ordering::SeqCst) + 1 IMM_VERSION.fetch_add(1, Ordering::SeqCst) + 1
} }
@@ -1256,6 +1282,7 @@ pub fn imm_live(overlay_open: bool, query: String) {
// the dropdown so a backspaced query leaves no stale one open. // the dropdown so a backspaced query leaves no stale one open.
next_imm_version(); next_imm_version();
IMM_SEL.store(-1, Ordering::SeqCst); IMM_SEL.store(-1, Ordering::SeqCst);
IMM_SEARCH_OPEN.store(false, Ordering::SeqCst);
crate::immersive_bridge::ui(|mut b| { crate::immersive_bridge::ui(|mut b| {
b.as_mut().set_imm_search_open(false); b.as_mut().set_imm_search_open(false);
}); });
@@ -1267,6 +1294,7 @@ pub fn imm_live(overlay_open: bool, query: String) {
// (main.rs:10403-10407). Arrow nav fires no keystroke through here, so it // (main.rs:10403-10407). Arrow nav fires no keystroke through here, so it
// is unaffected. // is unaffected.
IMM_SEL.store(-1, Ordering::SeqCst); IMM_SEL.store(-1, Ordering::SeqCst);
IMM_SEARCH_OPEN.store(true, Ordering::SeqCst);
crate::immersive_bridge::ui(|mut b| { crate::immersive_bridge::ui(|mut b| {
b.as_mut().set_imm_search_open(true); b.as_mut().set_imm_search_open(true);
b.as_mut().set_imm_search_loading(true); b.as_mut().set_imm_search_loading(true);
@@ -1442,6 +1470,7 @@ fn imm_scroll_y(data: &CortinillaData, flat_index: i32) -> f64 {
/// the selection. /// the selection.
pub fn imm_dismiss() { pub fn imm_dismiss() {
IMM_SEL.store(-1, Ordering::SeqCst); IMM_SEL.store(-1, Ordering::SeqCst);
IMM_SEARCH_OPEN.store(false, Ordering::SeqCst);
crate::immersive_bridge::ui(|mut b| { crate::immersive_bridge::ui(|mut b| {
b.as_mut().set_imm_search_open(false); b.as_mut().set_imm_search_open(false);
b.as_mut().set_imm_search_selected_index(-1); b.as_mut().set_imm_search_selected_index(-1);
@@ -1516,6 +1545,7 @@ pub fn imm_row_activated(flat_index: i32) {
// would re-invoke the dropdown when focus returns to the field // would re-invoke the dropdown when focus returns to the field
// (main.rs:10595-10599). // (main.rs:10595-10599).
IMM_SEL.store(-1, Ordering::SeqCst); IMM_SEL.store(-1, Ordering::SeqCst);
IMM_SEARCH_OPEN.store(false, Ordering::SeqCst);
crate::immersive_bridge::ui(|mut b| { crate::immersive_bridge::ui(|mut b| {
b.as_mut().set_imm_search_open(false); b.as_mut().set_imm_search_open(false);
b.as_mut().set_imm_search_selected_index(-1); b.as_mut().set_imm_search_selected_index(-1);
+14
View File
@@ -909,6 +909,20 @@ pub fn pref_i32(key: &str, default: i32) -> i32 {
.unwrap_or(default) .unwrap_or(default)
} }
/// Whole-value reader — same additive single-key discipline as
/// `pref_bool`/`pref_str`/`pref_i32`, for keys whose value is a NESTED
/// document rather than a scalar. Added for the hotkeys layer's `keybindings`
/// map (2026-08-03 hotkeys-port contract §3.2): a JSON object of action id ->
/// shortcut, co-owned with the Slint app, which `hotkeys_qt` reads whole and
/// writes back through the same `save_pref` single-key patch.
pub fn pref_json(key: &str) -> Option<serde_json::Value> {
let path = prefs_path()?;
std::fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
.and_then(|v| v.get(key).cloned())
}
/// Additive single-key patch of ui_prefs.json — THE writer every other pref /// Additive single-key patch of ui_prefs.json — THE writer every other pref
/// setter in this file funnels through, so they all inherit `update_prefs`' /// setter in this file funnels through, so they all inherit `update_prefs`'
/// atomic rename and its refusal to rebuild an unparsable document. /// atomic rename and its refusal to rebuild an unparsable document.
+12
View File
@@ -489,6 +489,17 @@ pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzShell>) + Send + 'static) {
} }
} }
/// Rust-side mirror of the `queue_open` property — `toggle_queue` below is
/// the ONLY writer (the NPB button and the queue panel's close both call it;
/// QML never writes the property directly). Read by the hotkeys §1.2 Escape
/// stack arm 7 (2026-08-03 hotkeys-port contract), which runs on the
/// QbzHotkeys singleton and cannot reach this QObject's properties.
static QUEUE_OPEN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub fn queue_open() -> bool {
QUEUE_OPEN.load(std::sync::atomic::Ordering::SeqCst)
}
impl qbz_shell::QbzShell { impl qbz_shell::QbzShell {
pub fn boot(self: Pin<&mut Self>) { pub fn boot(self: Pin<&mut Self>) {
if QT_THREAD.set(self.qt_thread()).is_err() { if QT_THREAD.set(self.qt_thread()).is_err() {
@@ -513,6 +524,7 @@ impl qbz_shell::QbzShell {
pub fn toggle_queue(mut self: Pin<&mut Self>) { pub fn toggle_queue(mut self: Pin<&mut Self>) {
let next = !self.queue_open(); let next = !self.queue_open();
QUEUE_OPEN.store(next, std::sync::atomic::Ordering::SeqCst);
self.as_mut().set_queue_open(next); self.as_mut().set_queue_open(next);
} }
@@ -4945,3 +4945,70 @@ msgstr "Playlist konnte nicht erstellt werden"
# whose first menu entry is "Open album"; a playlist cell overrides the NOUN. # whose first menu entry is "Open album"; a playlist cell overrides the NOUN.
msgid "Open playlist" msgid "Open playlist"
msgstr "Playlist öffnen" msgstr "Playlist öffnen"
# Hotkeys layer (2026-08-03 hotkeys-port contract §5) — the keyboard-shortcut
# action labels + category names for the cheatsheet and the customize editor.
# Slint's Rust-side labels were never extracted (the port inherits the gap);
# this block fixes it for both frontends. en is EXCLUDED on purpose
# (sparse-override convention, qbz-i18n/src/lib.rs:120-125).
msgid "Play / Pause"
msgstr "Wiedergabe / Pause"
msgid "Go Back"
msgstr "Zurück"
msgid "Go Forward"
msgstr "Vorwärts"
msgid "Toggle Sidebar"
msgstr "Seitenleiste ein-/ausblenden"
msgid "Immersive Mode"
msgstr "Immersiver Modus"
msgid "Close / Dismiss"
msgstr "Schließen / Ausblenden"
msgid "Show Shortcuts"
msgstr "Tastenkürzel anzeigen"
msgid "Toggle Mini Player"
msgstr "Mini-Player ein-/ausblenden"
msgid "Seek Forward (5s)"
msgstr "5 s vorspulen"
msgid "Seek Back (5s)"
msgstr "5 s zurückspulen"
msgid "Seek Forward (10s)"
msgstr "10 s vorspulen"
msgid "Seek Back (10s)"
msgstr "10 s zurückspulen"
msgid "Micro View"
msgstr "Mikro-Ansicht"
msgid "Compact View"
msgstr "Kompaktansicht"
msgid "Artwork View"
msgstr "Cover-Ansicht"
msgid "Queue View"
msgstr "Warteschlangen-Ansicht"
msgid "Lyrics View"
msgstr "Songtext-Ansicht"
msgid "Navigation"
msgstr "Navigation"
msgid "Interface"
msgstr "Oberfläche"
msgid "Mini Player"
msgstr "Mini-Player"
@@ -4953,3 +4953,70 @@ msgstr "No se pudo crear la playlist"
# whose first menu entry is "Open album"; a playlist cell overrides the NOUN. # whose first menu entry is "Open album"; a playlist cell overrides the NOUN.
msgid "Open playlist" msgid "Open playlist"
msgstr "Abrir playlist" msgstr "Abrir playlist"
# Hotkeys layer (2026-08-03 hotkeys-port contract §5) — the keyboard-shortcut
# action labels + category names for the cheatsheet and the customize editor.
# Slint's Rust-side labels were never extracted (the port inherits the gap);
# this block fixes it for both frontends. en is EXCLUDED on purpose
# (sparse-override convention, qbz-i18n/src/lib.rs:120-125).
msgid "Play / Pause"
msgstr "Reproducir / Pausa"
msgid "Go Back"
msgstr "Atrás"
msgid "Go Forward"
msgstr "Adelante"
msgid "Toggle Sidebar"
msgstr "Mostrar/ocultar barra lateral"
msgid "Immersive Mode"
msgstr "Modo inmersivo"
msgid "Close / Dismiss"
msgstr "Cerrar / Descartar"
msgid "Show Shortcuts"
msgstr "Mostrar atajos"
msgid "Toggle Mini Player"
msgstr "Mostrar/ocultar mini reproductor"
msgid "Seek Forward (5s)"
msgstr "Avanzar 5 s"
msgid "Seek Back (5s)"
msgstr "Retroceder 5 s"
msgid "Seek Forward (10s)"
msgstr "Avanzar 10 s"
msgid "Seek Back (10s)"
msgstr "Retroceder 10 s"
msgid "Micro View"
msgstr "Vista micro"
msgid "Compact View"
msgstr "Vista compacta"
msgid "Artwork View"
msgstr "Vista de carátula"
msgid "Queue View"
msgstr "Vista de cola"
msgid "Lyrics View"
msgstr "Vista de letra"
msgid "Navigation"
msgstr "Navegación"
msgid "Interface"
msgstr "Interfaz"
msgid "Mini Player"
msgstr "Mini reproductor"
@@ -4945,3 +4945,70 @@ msgstr "Impossible de créer la playlist"
# whose first menu entry is "Open album"; a playlist cell overrides the NOUN. # whose first menu entry is "Open album"; a playlist cell overrides the NOUN.
msgid "Open playlist" msgid "Open playlist"
msgstr "Ouvrir la playlist" msgstr "Ouvrir la playlist"
# Hotkeys layer (2026-08-03 hotkeys-port contract §5) — the keyboard-shortcut
# action labels + category names for the cheatsheet and the customize editor.
# Slint's Rust-side labels were never extracted (the port inherits the gap);
# this block fixes it for both frontends. en is EXCLUDED on purpose
# (sparse-override convention, qbz-i18n/src/lib.rs:120-125).
msgid "Play / Pause"
msgstr "Lecture / Pause"
msgid "Go Back"
msgstr "Retour"
msgid "Go Forward"
msgstr "Avancer"
msgid "Toggle Sidebar"
msgstr "Afficher/masquer la barre latérale"
msgid "Immersive Mode"
msgstr "Mode immersif"
msgid "Close / Dismiss"
msgstr "Fermer / Ignorer"
msgid "Show Shortcuts"
msgstr "Afficher les raccourcis"
msgid "Toggle Mini Player"
msgstr "Afficher/masquer le mini-lecteur"
msgid "Seek Forward (5s)"
msgstr "Avancer de 5 s"
msgid "Seek Back (5s)"
msgstr "Reculer de 5 s"
msgid "Seek Forward (10s)"
msgstr "Avancer de 10 s"
msgid "Seek Back (10s)"
msgstr "Reculer de 10 s"
msgid "Micro View"
msgstr "Vue micro"
msgid "Compact View"
msgstr "Vue compacte"
msgid "Artwork View"
msgstr "Vue pochette"
msgid "Queue View"
msgstr "Vue file d'attente"
msgid "Lyrics View"
msgstr "Vue paroles"
msgid "Navigation"
msgstr "Navigation"
msgid "Interface"
msgstr "Interface"
msgid "Mini Player"
msgstr "Mini-lecteur"
@@ -4930,3 +4930,70 @@ msgstr "プレイリストを作成できませんでした"
# whose first menu entry is "Open album"; a playlist cell overrides the NOUN. # whose first menu entry is "Open album"; a playlist cell overrides the NOUN.
msgid "Open playlist" msgid "Open playlist"
msgstr "プレイリストを開く" msgstr "プレイリストを開く"
# Hotkeys layer (2026-08-03 hotkeys-port contract §5) — the keyboard-shortcut
# action labels + category names for the cheatsheet and the customize editor.
# Slint's Rust-side labels were never extracted (the port inherits the gap);
# this block fixes it for both frontends. en is EXCLUDED on purpose
# (sparse-override convention, qbz-i18n/src/lib.rs:120-125).
msgid "Play / Pause"
msgstr "再生 / 一時停止"
msgid "Go Back"
msgstr "戻る"
msgid "Go Forward"
msgstr "進む"
msgid "Toggle Sidebar"
msgstr "サイドバーの表示切替"
msgid "Immersive Mode"
msgstr "没入モード"
msgid "Close / Dismiss"
msgstr "閉じる / 破棄"
msgid "Show Shortcuts"
msgstr "ショートカットを表示"
msgid "Toggle Mini Player"
msgstr "ミニプレーヤーの表示切替"
msgid "Seek Forward (5s)"
msgstr "5秒進む"
msgid "Seek Back (5s)"
msgstr "5秒戻る"
msgid "Seek Forward (10s)"
msgstr "10秒進む"
msgid "Seek Back (10s)"
msgstr "10秒戻る"
msgid "Micro View"
msgstr "マイクロビュー"
msgid "Compact View"
msgstr "コンパクトビュー"
msgid "Artwork View"
msgstr "アートワークビュー"
msgid "Queue View"
msgstr "キュービュー"
msgid "Lyrics View"
msgstr "歌詞ビュー"
msgid "Navigation"
msgstr "ナビゲーション"
msgid "Interface"
msgstr "インターフェース"
msgid "Mini Player"
msgstr "ミニプレーヤー"
@@ -4935,3 +4935,70 @@ msgstr "Kon de afspeellijst niet aanmaken"
# whose first menu entry is "Open album"; a playlist cell overrides the NOUN. # whose first menu entry is "Open album"; a playlist cell overrides the NOUN.
msgid "Open playlist" msgid "Open playlist"
msgstr "Afspeellijst openen" msgstr "Afspeellijst openen"
# Hotkeys layer (2026-08-03 hotkeys-port contract §5) — the keyboard-shortcut
# action labels + category names for the cheatsheet and the customize editor.
# Slint's Rust-side labels were never extracted (the port inherits the gap);
# this block fixes it for both frontends. en is EXCLUDED on purpose
# (sparse-override convention, qbz-i18n/src/lib.rs:120-125).
msgid "Play / Pause"
msgstr "Afspelen / Pauzeren"
msgid "Go Back"
msgstr "Terug"
msgid "Go Forward"
msgstr "Vooruit"
msgid "Toggle Sidebar"
msgstr "Zijbalk in-/uitschakelen"
msgid "Immersive Mode"
msgstr "Immersieve modus"
msgid "Close / Dismiss"
msgstr "Sluiten / Verbergen"
msgid "Show Shortcuts"
msgstr "Sneltoetsen tonen"
msgid "Toggle Mini Player"
msgstr "Minispeler in-/uitschakelen"
msgid "Seek Forward (5s)"
msgstr "5 s vooruit spoelen"
msgid "Seek Back (5s)"
msgstr "5 s terugspoelen"
msgid "Seek Forward (10s)"
msgstr "10 s vooruit spoelen"
msgid "Seek Back (10s)"
msgstr "10 s terugspoelen"
msgid "Micro View"
msgstr "Microweergave"
msgid "Compact View"
msgstr "Compacte weergave"
msgid "Artwork View"
msgstr "Hoesweergave"
msgid "Queue View"
msgstr "Wachtrijweergave"
msgid "Lyrics View"
msgstr "Songtekstweergave"
msgid "Navigation"
msgstr "Navigatie"
msgid "Interface"
msgstr "Interface"
msgid "Mini Player"
msgstr "Minispeler"
@@ -4945,3 +4945,70 @@ msgstr "Não foi possível criar a playlist"
# whose first menu entry is "Open album"; a playlist cell overrides the NOUN. # whose first menu entry is "Open album"; a playlist cell overrides the NOUN.
msgid "Open playlist" msgid "Open playlist"
msgstr "Abrir playlist" msgstr "Abrir playlist"
# Hotkeys layer (2026-08-03 hotkeys-port contract §5) — the keyboard-shortcut
# action labels + category names for the cheatsheet and the customize editor.
# Slint's Rust-side labels were never extracted (the port inherits the gap);
# this block fixes it for both frontends. en is EXCLUDED on purpose
# (sparse-override convention, qbz-i18n/src/lib.rs:120-125).
msgid "Play / Pause"
msgstr "Reproduzir / Pausar"
msgid "Go Back"
msgstr "Voltar"
msgid "Go Forward"
msgstr "Avançar"
msgid "Toggle Sidebar"
msgstr "Alternar barra lateral"
msgid "Immersive Mode"
msgstr "Modo imersivo"
msgid "Close / Dismiss"
msgstr "Fechar / Dispensar"
msgid "Show Shortcuts"
msgstr "Mostrar atalhos"
msgid "Toggle Mini Player"
msgstr "Alternar mini player"
msgid "Seek Forward (5s)"
msgstr "Avançar 5 s"
msgid "Seek Back (5s)"
msgstr "Recuar 5 s"
msgid "Seek Forward (10s)"
msgstr "Avançar 10 s"
msgid "Seek Back (10s)"
msgstr "Recuar 10 s"
msgid "Micro View"
msgstr "Vista micro"
msgid "Compact View"
msgstr "Vista compacta"
msgid "Artwork View"
msgstr "Vista de capa"
msgid "Queue View"
msgstr "Vista de fila"
msgid "Lyrics View"
msgstr "Vista de letra"
msgid "Navigation"
msgstr "Navegação"
msgid "Interface"
msgstr "Interface"
msgid "Mini Player"
msgstr "Mini player"
@@ -4962,3 +4962,70 @@ msgstr "Не удалось создать плейлист"
# whose first menu entry is "Open album"; a playlist cell overrides the NOUN. # whose first menu entry is "Open album"; a playlist cell overrides the NOUN.
msgid "Open playlist" msgid "Open playlist"
msgstr "Открыть плейлист" msgstr "Открыть плейлист"
# Hotkeys layer (2026-08-03 hotkeys-port contract §5) — the keyboard-shortcut
# action labels + category names for the cheatsheet and the customize editor.
# Slint's Rust-side labels were never extracted (the port inherits the gap);
# this block fixes it for both frontends. en is EXCLUDED on purpose
# (sparse-override convention, qbz-i18n/src/lib.rs:120-125).
msgid "Play / Pause"
msgstr "Воспроизведение / Пауза"
msgid "Go Back"
msgstr "Назад"
msgid "Go Forward"
msgstr "Вперёд"
msgid "Toggle Sidebar"
msgstr "Переключить боковую панель"
msgid "Immersive Mode"
msgstr "Иммерсивный режим"
msgid "Close / Dismiss"
msgstr "Закрыть / Скрыть"
msgid "Show Shortcuts"
msgstr "Показать сочетания клавиш"
msgid "Toggle Mini Player"
msgstr "Переключить мини-плеер"
msgid "Seek Forward (5s)"
msgstr "Вперёд на 5 с"
msgid "Seek Back (5s)"
msgstr "Назад на 5 с"
msgid "Seek Forward (10s)"
msgstr "Вперёд на 10 с"
msgid "Seek Back (10s)"
msgstr "Назад на 10 с"
msgid "Micro View"
msgstr "Микровид"
msgid "Compact View"
msgstr "Компактный вид"
msgid "Artwork View"
msgstr "Вид обложки"
msgid "Queue View"
msgstr "Вид очереди"
msgid "Lyrics View"
msgstr "Вид текста песни"
msgid "Navigation"
msgstr "Навигация"
msgid "Interface"
msgstr "Интерфейс"
msgid "Mini Player"
msgstr "Мини-плеер"