feat(qt): hotkeys core, QbzHotkeys bridge, shortcut i18n catalogs (B1)
km
This commit is contained in:
17 files changed
+1974
-1
No files matched your search
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 "Мини-плеер"
|
||||||
|
|
||||||
Reference in new issue
Block a user