From b781ed283f10eaaa831f00578060c42c32afeb65 Mon Sep 17 00:00:00 2001 From: Niklas Herder Date: Fri, 28 Aug 2026 07:26:17 +0200 Subject: [PATCH] feat(hotkeys): selectable Vim keymap preset The preset swaps the BASE shortcut table underneath the user's overrides instead of rewriting them, so Default <-> Vim is lossless. Vim binds single modifier-free keys: n/p, h/l, Shift+J/K, /, and Ctrl+o / Ctrl+i for the jump list; rows with no vim idiom keep their defaults. j and k are deliberately left UNBOUND. They belong to a per-list cursor, which QBZ has no notion of yet, and a global action bound there now would have to be taken back the day one lands. A test holds them free. Where an override has already claimed a shortcut the new base wants, that action goes unbound rather than double-binding a combo that action_for_shortcut would otherwise resolve by BTreeMap id order. Seven new tests, including a gate that fails if any preset ever binds one shortcut to two actions. --- .../translations/de/LC_MESSAGES/qbz-ui.po | 8 +- .../translations/en/LC_MESSAGES/qbz-ui.po | 8 +- .../translations/es/LC_MESSAGES/qbz-ui.po | 8 +- .../translations/fr/LC_MESSAGES/qbz-ui.po | 8 +- .../translations/ja/LC_MESSAGES/qbz-ui.po | 8 +- .../translations/nl/LC_MESSAGES/qbz-ui.po | 8 +- .../translations/pt/LC_MESSAGES/qbz-ui.po | 8 +- .../translations/ru/LC_MESSAGES/qbz-ui.po | 8 +- .../qml/shell/CustomizeShortcutsModal.qml | 55 +++ crates/qbz-qt/src/hotkeys_bridge.rs | 32 +- crates/qbz-qt/src/hotkeys_qt.rs | 337 +++++++++++++++--- crates/qbz-qt/src/mini_bridge.rs | 8 +- 12 files changed, 429 insertions(+), 67 deletions(-) diff --git a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po index f5b9b1f7c..9cdbdf50e 100644 --- a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po @@ -6037,7 +6037,7 @@ msgstr "Dieses Album enthält keine Titel." msgid "Reactive Rings" msgstr "Reaktive Ringe" -# Hotkeys: the Spotify-parity playback actions +# Hotkeys: the Spotify-parity playback actions + the keymap preset picker msgid "Volume Up" msgstr "Lauter" @@ -6062,3 +6062,9 @@ msgstr "Vorspulen" msgid "Seek Back" msgstr "Zurückspulen" + +msgid "Keymap" +msgstr "Tastenschema" + +msgid "Vim" +msgstr "Vim" diff --git a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po index 24e9bb391..c11409b80 100644 --- a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po @@ -1642,7 +1642,7 @@ msgstr "This album has no tracks." msgid "Reactive Rings" msgstr "Reactive Rings" -# Hotkeys: the Spotify-parity playback actions +# Hotkeys: the Spotify-parity playback actions + the keymap preset picker msgid "Volume Up" msgstr "Volume Up" @@ -1667,3 +1667,9 @@ msgstr "Seek Forward" msgid "Seek Back" msgstr "Seek Back" + +msgid "Keymap" +msgstr "Keymap" + +msgid "Vim" +msgstr "Vim" diff --git a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po index a52c348f5..e18485f9d 100644 --- a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po @@ -6043,7 +6043,7 @@ msgstr "Este álbum no tiene pistas." msgid "Reactive Rings" msgstr "Anillos reactivos" -# Hotkeys: the Spotify-parity playback actions +# Hotkeys: the Spotify-parity playback actions + the keymap preset picker msgid "Volume Up" msgstr "Subir Volumen" @@ -6068,3 +6068,9 @@ msgstr "Avanzar" msgid "Seek Back" msgstr "Retroceder" + +msgid "Keymap" +msgstr "Mapa de Teclas" + +msgid "Vim" +msgstr "Vim" diff --git a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po index 2b3664222..08d9fd5d2 100644 --- a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po @@ -6035,7 +6035,7 @@ msgstr "Cet album ne contient aucun titre." msgid "Reactive Rings" msgstr "Anneaux réactifs" -# Hotkeys: the Spotify-parity playback actions +# Hotkeys: the Spotify-parity playback actions + the keymap preset picker msgid "Volume Up" msgstr "Augmenter le volume" @@ -6060,3 +6060,9 @@ msgstr "Avancer" msgid "Seek Back" msgstr "Reculer" + +msgid "Keymap" +msgstr "Schéma de touches" + +msgid "Vim" +msgstr "Vim" diff --git a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po index 42ec7afa6..a55261b2f 100644 --- a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po @@ -6019,7 +6019,7 @@ msgstr "このアルバムには曲がありません。" msgid "Reactive Rings" msgstr "リアクティブリング" -# Hotkeys: the Spotify-parity playback actions +# Hotkeys: the Spotify-parity playback actions + the keymap preset picker msgid "Volume Up" msgstr "音量を上げる" @@ -6044,3 +6044,9 @@ msgstr "早送り" msgid "Seek Back" msgstr "巻き戻し" + +msgid "Keymap" +msgstr "キーマップ" + +msgid "Vim" +msgstr "Vim" diff --git a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po index 3ea892000..e0407e731 100644 --- a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po @@ -6025,7 +6025,7 @@ msgstr "Dit album bevat geen nummers." msgid "Reactive Rings" msgstr "Reactieve ringen" -# Hotkeys: the Spotify-parity playback actions +# Hotkeys: the Spotify-parity playback actions + the keymap preset picker msgid "Volume Up" msgstr "Volume omhoog" @@ -6050,3 +6050,9 @@ msgstr "Vooruit spoelen" msgid "Seek Back" msgstr "Terugspoelen" + +msgid "Keymap" +msgstr "Toetsenschema" + +msgid "Vim" +msgstr "Vim" diff --git a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po index cbd2e2250..e795d60f4 100644 --- a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po @@ -6035,7 +6035,7 @@ msgstr "Este álbum não tem faixas." msgid "Reactive Rings" msgstr "Anéis reativos" -# Hotkeys: the Spotify-parity playback actions +# Hotkeys: the Spotify-parity playback actions + the keymap preset picker msgid "Volume Up" msgstr "Aumentar Volume" @@ -6060,3 +6060,9 @@ msgstr "Avançar" msgid "Seek Back" msgstr "Recuar" + +msgid "Keymap" +msgstr "Mapa de Teclas" + +msgid "Vim" +msgstr "Vim" diff --git a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po index 58320c81d..41b674616 100644 --- a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po @@ -6055,7 +6055,7 @@ msgstr "В этом альбоме нет треков." msgid "Reactive Rings" msgstr "Реактивные кольца" -# Hotkeys: the Spotify-parity playback actions +# Hotkeys: the Spotify-parity playback actions + the keymap preset picker msgid "Volume Up" msgstr "Громче" @@ -6080,3 +6080,9 @@ msgstr "Перемотка вперёд" msgid "Seek Back" msgstr "Перемотка назад" + +msgid "Keymap" +msgstr "Раскладка клавиш" + +msgid "Vim" +msgstr "Vim" diff --git a/crates/qbz-qt/qml/shell/CustomizeShortcutsModal.qml b/crates/qbz-qt/qml/shell/CustomizeShortcutsModal.qml index df82ce059..e7174e1ba 100644 --- a/crates/qbz-qt/qml/shell/CustomizeShortcutsModal.qml +++ b/crates/qbz-qt/qml/shell/CustomizeShortcutsModal.qml @@ -197,6 +197,61 @@ Item { font.weight: theme.weightMedium } } + // Keymap preset. Switching swaps the BASE shortcut table + // only — user overrides are kept and keep winning over it + // (hotkeys_qt::active_bindings_with), so Default <-> Vim is + // lossless. `keymapIndex` is republished with the groups, so + // the selection and the keycaps cannot disagree. + Row { + id: keymapPicker + anchors.right: closeX.left + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + Text { + anchors.verticalCenter: parent.verticalCenter + rightPadding: 6 + text: root.t("Keymap") + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + Repeater { + model: [{ "label": root.t("Default"), "index": 0 }, + { "label": root.t("Vim"), "index": 1 }] + delegate: Rectangle { + id: seg + required property var modelData + readonly property bool current: + QbzHotkeys.keymapIndex === seg.modelData.index + width: segLabel.implicitWidth + 20 + height: 26 + radius: theme.radiusSm + color: seg.current + ? theme.accent + : (segArea.containsMouse ? theme.surfaceHover + : "transparent") + border.width: 1 + border.color: seg.current ? theme.accent + : theme.borderSubtle + Text { + id: segLabel + anchors.centerIn: parent + text: seg.modelData.label + color: seg.current ? theme.accentText + : theme.textPrimary + font.pixelSize: theme.fontLegal + font.weight: theme.weightMedium + } + MouseArea { + id: segArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzHotkeys.setKeymap(seg.modelData.index) + } + } + } + } Item { id: closeX anchors.right: parent.right diff --git a/crates/qbz-qt/src/hotkeys_bridge.rs b/crates/qbz-qt/src/hotkeys_bridge.rs index 2e2478110..504f906c0 100644 --- a/crates/qbz-qt/src/hotkeys_bridge.rs +++ b/crates/qbz-qt/src/hotkeys_bridge.rs @@ -65,6 +65,10 @@ pub mod qbz_hotkeys { #[qproperty(QString, groups_json)] // The customize header's "N modified" accent badge (§4.5). #[qproperty(i32, modified_count)] + // Active keymap preset as its selector index (0 = Default, 1 = Vim). + // Published alongside the groups so the editor's segmented control and + // the keycaps can never disagree about which preset is showing. + #[qproperty(i32, keymap_index)] // 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. @@ -109,6 +113,10 @@ pub mod qbz_hotkeys { fn reset_one(self: Pin<&mut QbzHotkeys>, id: QString); #[qinvokable] fn reset_all(self: Pin<&mut QbzHotkeys>); + /// Switch the keymap preset (0 = Default, 1 = Vim) + refresh. User + /// overrides are KEPT and keep winning over the new base. + #[qinvokable] + fn set_keymap(self: Pin<&mut QbzHotkeys>, index: i32); /// ui.showShortcuts target + the HeaderBar menu row: open the /// cheatsheet (and recompute the groups, §3.4 — recomputed on open). #[qinvokable] @@ -152,6 +160,7 @@ use qbz_hotkeys::QbzHotkeys; pub struct QbzHotkeysRust { groups_json: QString, modified_count: i32, + keymap_index: i32, recording_id: QString, pending_display: QString, conflict_label: QString, @@ -164,6 +173,7 @@ impl Default for QbzHotkeysRust { Self { groups_json: QString::from(GROUPS_EMPTY), modified_count: 0, + keymap_index: 0, recording_id: QString::from(""), pending_display: QString::from(""), conflict_label: QString::from(""), @@ -191,11 +201,13 @@ pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzHotkeys>) + Send + 'static) { /// 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 keymap = crate::hotkeys_qt::active_keymap(); let overrides = crate::hotkeys_qt::load_overrides(); - let json = crate::hotkeys_qt::groups_json(&overrides); - let count = crate::hotkeys_qt::modified_count_with(&overrides); + let json = crate::hotkeys_qt::groups_json(keymap, &overrides); + let count = crate::hotkeys_qt::modified_count_with(keymap, &overrides); this.as_mut().set_groups_json(QString::from(json.as_str())); this.as_mut().set_modified_count(count); + this.as_mut().set_keymap_index(keymap.index()); } /// Clear the capture triple (recording id + pending + conflict) — the shared @@ -281,7 +293,15 @@ impl qbz_hotkeys::QbzHotkeys { 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) { + let keymap = crate::hotkeys_qt::active_keymap(); + match crate::hotkeys_qt::capture_step( + keymap, + &overrides, + &recording, + key, + modifiers, + &text, + ) { CaptureOutcome::Cancelled => { clear_capture(self.as_mut()); } @@ -339,6 +359,7 @@ impl qbz_hotkeys::QbzHotkeys { // gates live in hotkeys_qt::action_for_key). let overrides = crate::hotkeys_qt::load_overrides(); let Some(action) = crate::hotkeys_qt::action_for_key( + crate::hotkeys_qt::active_keymap(), &overrides, key, modifiers, @@ -459,6 +480,11 @@ impl qbz_hotkeys::QbzHotkeys { publish_groups(self); } + pub fn set_keymap(self: Pin<&mut Self>, index: i32) { + crate::hotkeys_qt::set_keymap(crate::hotkeys_qt::Keymap::from_index(index)); + publish_groups(self); + } + pub fn open_cheatsheet(mut self: Pin<&mut Self>) { self.as_mut().set_cheatsheet_open(true); publish_groups(self.as_mut()); diff --git a/crates/qbz-qt/src/hotkeys_qt.rs b/crates/qbz-qt/src/hotkeys_qt.rs index 982ac01a6..963793f26 100644 --- a/crates/qbz-qt/src/hotkeys_qt.rs +++ b/crates/qbz-qt/src/hotkeys_qt.rs @@ -13,11 +13,12 @@ //! suggestions_bridge precedent). Do NOT list this file in build.rs //! rust_files. //! -//! ONE deliberate addition POST-port, additive to the ported model: eight -//! Playback actions the Slint table never had (volume, mute, shuffle, repeat, -//! like, and a surface-independent seek — Spotify's desktop defaults). -//! Everything below still resolves through one action table and one canonical -//! shortcut string. +//! TWO deliberate additions POST-port, both additive to the ported model: +//! eight Playback actions the Slint table never had (volume, mute, shuffle, +//! repeat, like, and a surface-independent seek — Spotify's desktop defaults), +//! and the `Keymap` preset, which swaps the BASE shortcut table under the user +//! overrides instead of rewriting them. Everything below still resolves +//! through one action table and one canonical shortcut string. //! //! 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 @@ -38,7 +39,7 @@ //! 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 std::collections::{BTreeMap, BTreeSet}; use serde::Serialize; @@ -130,14 +131,72 @@ pub enum Context { Mini, } +/// Which preset supplies the base shortcuts. User overrides sit on top of +/// whichever base is active, so switching preset never discards them. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum Keymap { + #[default] + Default, + /// Modifier-free home-row bindings. `j`/`k` are deliberately left UNBOUND: + /// they belong to a per-list cursor, which QBZ has no notion of yet, and a + /// global action bound there today would have to be taken back the day one + /// lands. + Vim, +} + +impl Keymap { + /// Persisted `ui_prefs.json` value. An unknown string reads as `Default` — + /// a typo must not unbind the app. + pub fn from_key(key: &str) -> Keymap { + match key { + "vim" => Keymap::Vim, + _ => Keymap::Default, + } + } + + pub fn key(self) -> &'static str { + match self { + Keymap::Default => "default", + Keymap::Vim => "vim", + } + } + + /// QML passes the selector's index; same unknown-reads-as-Default rule. + pub fn from_index(index: i32) -> Keymap { + match index { + 1 => Keymap::Vim, + _ => Keymap::Default, + } + } + + pub fn index(self) -> i32 { + match self { + Keymap::Default => 0, + Keymap::Vim => 1, + } + } +} + pub struct ActionDef { pub id: &'static str, pub label_en: &'static str, pub category: Category, pub default: &'static str, + /// Shortcut under `Keymap::Vim`; empty = the preset keeps `default`. + pub vim: &'static str, pub context: Context, } +impl ActionDef { + /// This action's shortcut under `keymap`, BEFORE user overrides. + pub fn base(&self, keymap: Keymap) -> &'static str { + match keymap { + Keymap::Vim if !self.vim.is_empty() => self.vim, + _ => self.default, + } + } +} + /// The full action table — a 1:1 port of the Tauri `ACTIONS` array /// (keybindings.rs:88-117; contract §2 verbatim). pub const ACTIONS: &[ActionDef] = &[ @@ -147,6 +206,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Play / Pause", category: Category::Playback, default: "Space", + vim: "", context: Context::None, }, ActionDef { @@ -154,6 +214,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Next Track", category: Category::Playback, default: "Ctrl+ArrowRight", + vim: "n", context: Context::None, }, ActionDef { @@ -161,6 +222,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Previous Track", category: Category::Playback, default: "Ctrl+ArrowLeft", + vim: "p", context: Context::None, }, ActionDef { @@ -168,6 +230,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Volume Up", category: Category::Playback, default: "Ctrl+ArrowUp", + vim: "Shift+K", context: Context::None, }, ActionDef { @@ -175,6 +238,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Volume Down", category: Category::Playback, default: "Ctrl+ArrowDown", + vim: "Shift+J", context: Context::None, }, ActionDef { @@ -182,6 +246,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Mute / Unmute", category: Category::Playback, default: "m", + vim: "", context: Context::None, }, ActionDef { @@ -189,6 +254,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Toggle Shuffle", category: Category::Playback, default: "Ctrl+s", + vim: "s", context: Context::None, }, ActionDef { @@ -196,6 +262,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Cycle Repeat", category: Category::Playback, default: "Ctrl+r", + vim: "r", context: Context::None, }, ActionDef { @@ -203,6 +270,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Like Track", category: Category::Playback, default: "Alt+Shift+B", + vim: "f", context: Context::None, }, // Seek from ANY surface. The `focus.seek*` rows below stay bare arrows and @@ -213,6 +281,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Seek Forward", category: Category::Playback, default: "Ctrl+Shift+ArrowRight", + vim: "l", context: Context::None, }, ActionDef { @@ -220,6 +289,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Seek Back", category: Category::Playback, default: "Ctrl+Shift+ArrowLeft", + vim: "h", context: Context::None, }, // Navigation @@ -228,6 +298,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Go Back", category: Category::Navigation, default: "Alt+ArrowLeft", + vim: "Ctrl+o", context: Context::None, }, ActionDef { @@ -235,6 +306,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Go Forward", category: Category::Navigation, default: "Alt+ArrowRight", + vim: "Ctrl+i", context: Context::None, }, ActionDef { @@ -242,6 +314,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Search", category: Category::Navigation, default: "Ctrl+f", + vim: "/", context: Context::None, }, ActionDef { @@ -249,6 +322,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Settings", category: Category::Navigation, default: "Ctrl+,", + vim: "", context: Context::None, }, // Interface @@ -257,6 +331,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Toggle Sidebar", category: Category::Ui, default: "Shift+S", + vim: "", context: Context::None, }, ActionDef { @@ -264,6 +339,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Immersive Mode", category: Category::Ui, default: "Shift+I", + vim: "z", context: Context::None, }, ActionDef { @@ -271,6 +347,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Queue", category: Category::Ui, default: "q", + vim: "", context: Context::None, }, ActionDef { @@ -278,6 +355,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Close / Dismiss", category: Category::Ui, default: "Escape", + vim: "", context: Context::None, }, ActionDef { @@ -285,6 +363,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Show Shortcuts", category: Category::Ui, default: "?", + vim: "", context: Context::None, }, ActionDef { @@ -292,6 +371,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Open Music Link", category: Category::Ui, default: "Ctrl+l", + vim: "", context: Context::None, }, ActionDef { @@ -299,6 +379,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Toggle Mini Player", category: Category::Ui, default: "Shift+M", + vim: "", context: Context::None, }, // Immersive (contextual) @@ -307,6 +388,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Seek Forward (5s)", category: Category::Immersive, default: "ArrowRight", + vim: "", context: Context::Immersive, }, ActionDef { @@ -314,6 +396,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Seek Back (5s)", category: Category::Immersive, default: "ArrowLeft", + vim: "", context: Context::Immersive, }, ActionDef { @@ -321,6 +404,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Seek Forward (10s)", category: Category::Immersive, default: "Shift+ArrowRight", + vim: "", context: Context::Immersive, }, ActionDef { @@ -328,6 +412,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Seek Back (10s)", category: Category::Immersive, default: "Shift+ArrowLeft", + vim: "", context: Context::Immersive, }, // Mini Player (contextual — dispatched on the mini window) @@ -336,6 +421,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Micro View", category: Category::Mini, default: "1", + vim: "", context: Context::Mini, }, ActionDef { @@ -343,6 +429,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Compact View", category: Category::Mini, default: "2", + vim: "", context: Context::Mini, }, ActionDef { @@ -350,6 +437,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Artwork View", category: Category::Mini, default: "3", + vim: "", context: Context::Mini, }, ActionDef { @@ -357,6 +445,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Listen list", category: Category::Mini, default: "4", + vim: "", context: Context::Mini, }, ActionDef { @@ -364,6 +453,7 @@ pub const ACTIONS: &[ActionDef] = &[ label_en: "Lyrics View", category: Category::Mini, default: "5", + vim: "", context: Context::Mini, }, ]; @@ -525,23 +615,63 @@ pub(crate) fn load_overrides() -> BTreeMap { .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) -> BTreeMap { +/// The active binding map: the `keymap` preset's bases 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). +/// +/// Overrides win over a base. A base shortcut some override has already taken +/// leaves ITS action UNBOUND ("") instead of double-binding the combo: +/// `action_for_shortcut` breaks a tie by BTreeMap id order, so a duplicate +/// would silently hand the key to whichever action sorts first. Only reachable +/// by switching preset with overrides in place — `apply_binding` refuses a +/// conflict outright — and the empty binding is visible as a blank keycap in +/// the editor rather than a wrong one. +pub fn active_bindings_with( + keymap: Keymap, + overrides: &BTreeMap, +) -> BTreeMap { + let mut claimed: BTreeSet<&str> = overrides + .iter() + .filter(|(id, _)| action(id).is_some()) + .map(|(_, sc)| sc.as_str()) + .collect(); let mut map: BTreeMap = 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()); + if let Some(shortcut) = overrides.get(a.id) { + map.insert(a.id.to_string(), shortcut.clone()); + continue; + } + let base = a.base(keymap); + if claimed.contains(base) { + map.insert(a.id.to_string(), String::new()); + } else { + claimed.insert(base); + map.insert(a.id.to_string(), base.to_string()); } } map } +/// The persisted preset (`ui_prefs.json` -> `keymap`), shared with the Slint +/// build the same way the `keybindings` map is. +pub fn active_keymap() -> Keymap { + let key = crate::settings_qt::pref_json("keymap") + .and_then(|v| v.as_str().map(str::to_string)) + .unwrap_or_default(); + Keymap::from_key(&key) +} + +/// Persist the preset. User overrides are deliberately KEPT — they ride on top +/// of the new base (see `active_bindings_with`), so switching to Vim and back +/// is lossless. +pub fn set_keymap(keymap: Keymap) { + crate::settings_qt::save_pref( + "keymap", + serde_json::Value::String(keymap.key().to_string()), + ); +} + fn action_for_shortcut<'a>( shortcut: &str, bindings: &'a BTreeMap, @@ -562,7 +692,8 @@ fn conflicting_action( bindings: &BTreeMap, ) -> Option<&'static ActionDef> { for (id, sc) in bindings { - if sc == shortcut && id != exclude { + // "" is the unbound marker from `active_bindings_with`, never a combo. + if !sc.is_empty() && sc == shortcut && id != exclude { return action(id); } } @@ -574,16 +705,20 @@ fn conflicting_action( /// override (keeps the file minimal — back-to-default pruning, contract /// trap 3). On true the caller persists the map. pub fn apply_binding( + keymap: Keymap, overrides: &mut BTreeMap, action_id: &str, shortcut: &str, ) -> bool { - let bindings = active_bindings_with(overrides); + let bindings = active_bindings_with(keymap, 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 { + // Back-to-BASE (not back-to-`default`) pruning: under Vim the preset's own + // shortcut is what "unmodified" means, so rebinding to it drops the + // override exactly as it does under Default. + let base = action(action_id).map(|a| a.base(keymap)); + if Some(shortcut) == base { overrides.remove(action_id); } else { overrides.insert(action_id.to_string(), shortcut.to_string()); @@ -604,7 +739,7 @@ fn save_overrides(overrides: &BTreeMap) { /// 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) { + if !apply_binding(active_keymap(), &mut overrides, action_id, shortcut) { return false; } save_overrides(&overrides); @@ -645,14 +780,14 @@ pub struct Group { /// 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) -> Vec { - let bindings = active_bindings_with(overrides); +pub fn build_groups(keymap: Keymap, overrides: &BTreeMap) -> Vec { + let bindings = active_bindings_with(keymap, overrides); let mut groups: Vec = Vec::new(); for cat in Category::ORDER { let mut rows: Vec = 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); + let modified = bindings.get(a.id).map(|s| s.as_str()) != Some(a.base(keymap)); rows.push(GroupRow { id: a.id.to_string(), label: qbz_i18n::t(a.label_en), @@ -669,11 +804,11 @@ pub fn build_groups(overrides: &BTreeMap) -> Vec { groups } -pub fn modified_count_with(overrides: &BTreeMap) -> i32 { - let bindings = active_bindings_with(overrides); +pub fn modified_count_with(keymap: Keymap, overrides: &BTreeMap) -> i32 { + let bindings = active_bindings_with(keymap, overrides); ACTIONS .iter() - .filter(|a| bindings.get(a.id).map(|s| s.as_str()) != Some(a.default)) + .filter(|a| bindings.get(a.id).map(|s| s.as_str()) != Some(a.base(keymap))) .count() as i32 } @@ -682,8 +817,8 @@ pub fn modified_count_with(overrides: &BTreeMap) -> i32 { /// 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 { - let groups = build_groups(overrides); +pub fn groups_json(keymap: Keymap, overrides: &BTreeMap) -> String { + let groups = build_groups(keymap, overrides); let mut cols: [Vec; 3] = Default::default(); for (i, g) in groups.into_iter().enumerate() { cols[i % 3].push(g); @@ -717,6 +852,7 @@ pub enum CaptureOutcome { } pub fn capture_step( + keymap: Keymap, overrides: &BTreeMap, action_id: &str, key: i32, @@ -733,7 +869,7 @@ pub fn capture_step( let Some(shortcut) = shortcut_from_parts(ctrl, alt, shift, &token) else { return CaptureOutcome::Ignored; }; - let bindings = active_bindings_with(overrides); + let bindings = active_bindings_with(keymap, overrides); if let Some(conflict) = conflicting_action(&shortcut, action_id, &bindings) { return CaptureOutcome::Conflict { display: format_display(&shortcut), @@ -754,6 +890,7 @@ pub fn capture_step( /// mini.* actions are kept in the table for binding-file compat + the /// cheatsheet, contract §2/K3). pub fn action_for_key( + keymap: Keymap, overrides: &BTreeMap, key: i32, modifiers: i32, @@ -763,7 +900,7 @@ pub fn action_for_key( 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 bindings = active_bindings_with(keymap, overrides); let action = action_for_shortcut(&shortcut, &bindings)?; match action.context { Context::Immersive if !immersive_open => None, @@ -787,6 +924,7 @@ pub fn action_for_key( /// The main window must keep rejecting mini actions and the test at /// `immersive_actions_are_gated_on_the_overlay_and_mini_never_fires` asserts it. pub fn action_for_mini_key( + keymap: Keymap, overrides: &BTreeMap, key: i32, modifiers: i32, @@ -795,7 +933,7 @@ pub fn action_for_mini_key( 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 bindings = active_bindings_with(keymap, overrides); let action = action_for_shortcut(&shortcut, &bindings)?; match action.context { Context::Immersive => None, @@ -1043,7 +1181,7 @@ mod tests { #[test] fn conflict_is_exact_string_under_a_different_id() { - let bindings = active_bindings_with(&no_overrides()); + let bindings = active_bindings_with(Keymap::Default, &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"); @@ -1060,7 +1198,7 @@ mod tests { 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); + let bindings = active_bindings_with(Keymap::Default, &overrides); assert_eq!(bindings.get("ui.queue").unwrap(), "w"); // The known count is exactly the action table; the stale id is absent. assert_eq!(bindings.len(), ACTIONS.len()); @@ -1074,7 +1212,7 @@ mod tests { #[test] fn apply_binding_refuses_conflicts_without_touching_the_map() { let mut overrides = no_overrides(); - assert!(!apply_binding(&mut overrides, "ui.sidebar", "q")); + assert!(!apply_binding(Keymap::Default, &mut overrides, "ui.sidebar", "q")); assert!(overrides.is_empty()); } @@ -1082,23 +1220,23 @@ mod tests { 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!(apply_binding(Keymap::Default, &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!(apply_binding(Keymap::Default, &mut overrides, "ui.queue", "w")); assert_eq!(overrides.get("ui.queue").unwrap(), "w"); - assert_eq!(modified_count_with(&overrides), 1); + assert_eq!(modified_count_with(Keymap::Default, &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 doc: serde_json::Value = serde_json::from_str(&groups_json(Keymap::Default, &no_overrides())).unwrap(); let labels = |col: &str| -> Vec { doc[col] .as_array() @@ -1114,7 +1252,7 @@ mod tests { #[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 doc: serde_json::Value = serde_json::from_str(&groups_json(Keymap::Default, &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) @@ -1223,12 +1361,12 @@ mod tests { fn capture_escape_cancels_and_bare_modifiers_are_ignored() { let overrides = no_overrides(); assert_eq!( - capture_step(&overrides, "ui.queue", QT_KEY_ESCAPE, 0, ""), + capture_step(Keymap::Default, &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, ""), + capture_step(Keymap::Default, &overrides, "ui.queue", 0x0100_0020, QT_SHIFT, ""), CaptureOutcome::Ignored ); } @@ -1238,7 +1376,7 @@ mod tests { 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") { + match capture_step(Keymap::Default, &overrides, "ui.sidebar", key_q, 0, "q") { CaptureOutcome::Conflict { display, label } => { assert_eq!(display, "Q"); assert_eq!(label, "Queue"); // en fallback msgid @@ -1248,7 +1386,7 @@ mod tests { // A clean combo binds. let key_w = QT_KEY_A + 22; assert_eq!( - capture_step(&overrides, "ui.queue", key_w, 0, "w"), + capture_step(Keymap::Default, &overrides, "ui.queue", key_w, 0, "w"), CaptureOutcome::Bound { shortcut: "w".into() } @@ -1261,14 +1399,14 @@ mod tests { 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()); + assert!(action_for_key(Keymap::Default, &overrides, QT_KEY_RIGHT, 0, "", false).is_none()); // …open → focus.seekForward. - let a = action_for_key(&overrides, QT_KEY_RIGHT, 0, "", true).unwrap(); + let a = action_for_key(Keymap::Default, &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()); + assert!(action_for_key(Keymap::Default, &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(); + let a = action_for_key(Keymap::Default, &overrides, QT_KEY_SPACE, 0, " ", false).unwrap(); assert_eq!(a.id, "playback.toggle"); } @@ -1279,16 +1417,16 @@ mod tests { fn the_mini_gate_is_the_mirror_of_the_main_one() { let overrides = no_overrides(); // "1" is mini.micro — it fires HERE and nowhere else. - let a = action_for_mini_key(&overrides, QT_KEY_0 + 1, 0, "1").unwrap(); + let a = action_for_mini_key(Keymap::Default, &overrides, QT_KEY_0 + 1, 0, "1").unwrap(); assert_eq!(a.id, "mini.micro"); // …and it is still refused on the main window (the arm was not widened). - assert!(action_for_key(&overrides, QT_KEY_0 + 1, 0, "1", false).is_none()); + assert!(action_for_key(Keymap::Default, &overrides, QT_KEY_0 + 1, 0, "1", false).is_none()); // A global action resolves on both. - let a = action_for_mini_key(&overrides, QT_KEY_SPACE, 0, " ").unwrap(); + let a = action_for_mini_key(Keymap::Default, &overrides, QT_KEY_SPACE, 0, " ").unwrap(); assert_eq!(a.id, "playback.toggle"); // Immersive-context actions never fire on the mini: the overlay lives // on the main window, which is hidden while the mini is up. - assert!(action_for_mini_key(&overrides, QT_KEY_RIGHT, 0, "").is_none()); + assert!(action_for_mini_key(Keymap::Default, &overrides, QT_KEY_RIGHT, 0, "").is_none()); } #[test] @@ -1305,6 +1443,94 @@ mod tests { assert!(!is_ctrl_a(QT_KEY_A + 1, QT_CONTROL, "")); } + // --- Keymap presets ---------------------------------------------------- + + /// Every preset must be internally conflict-free: two actions sharing a + /// combo would be resolved by BTreeMap id order, i.e. arbitrarily. This is + /// the gate on adding a row to ACTIONS with a colliding `vim` (or + /// `default`) shortcut — it fails at `cargo test`, not in someone's hands. + #[test] + fn every_preset_binds_each_shortcut_at_most_once() { + for keymap in [Keymap::Default, Keymap::Vim] { + let mut seen: BTreeMap<&str, &str> = BTreeMap::new(); + for a in ACTIONS { + let base = a.base(keymap); + assert!(!base.is_empty(), "{} has no {keymap:?} shortcut", a.id); + if let Some(prev) = seen.insert(base, a.id) { + panic!("{keymap:?}: {base:?} bound to both {prev} and {}", a.id); + } + } + } + } + + /// `j`/`k` stay free for a future per-list cursor — the reason the volume + /// rows are Shift+J / Shift+K rather than the bare keys. + #[test] + fn vim_leaves_j_and_k_unbound() { + let bound: Vec<&str> = ACTIONS.iter().map(|a| a.base(Keymap::Vim)).collect(); + assert!(!bound.contains(&"j")); + assert!(!bound.contains(&"k")); + } + + #[test] + fn vim_swaps_the_bases_it_defines_and_inherits_the_rest() { + let vim = active_bindings_with(Keymap::Vim, &no_overrides()); + assert_eq!(vim["nav.search"], "/"); + assert_eq!(vim["playback.seekForward"], "l"); + assert_eq!(vim["playback.volumeUp"], "Shift+K"); + // No `vim` column -> the default rides through unchanged. + assert_eq!(vim["ui.queue"], "q"); + assert_eq!(vim["nav.settings"], "Ctrl+,"); + // Nothing counts as modified just because the preset changed. + assert_eq!(modified_count_with(Keymap::Vim, &no_overrides()), 0); + } + + #[test] + fn vim_dispatches_its_own_bases() { + let ov = no_overrides(); + let key_slash = 0x2f; + assert!(action_for_key(Keymap::Default, &ov, key_slash, 0, "/", false).is_none()); + let a = action_for_key(Keymap::Vim, &ov, key_slash, 0, "/", false).unwrap(); + assert_eq!(a.id, "nav.search"); + } + + /// An override wins over the preset base, and the action whose base it took + /// goes UNBOUND rather than sharing the combo. Reachable only by switching + /// preset with overrides already in place. + #[test] + fn an_override_beats_a_base_and_unbinds_the_action_it_displaced() { + let mut ov = no_overrides(); + ov.insert("ui.queue".into(), "/".into()); + let vim = active_bindings_with(Keymap::Vim, &ov); + assert_eq!(vim["ui.queue"], "/"); + assert_eq!(vim["nav.search"], "", "displaced base must not double-bind"); + // The unbound row resolves to nothing rather than to an arbitrary + // action, and "" never matches a real keypress. + let a = action_for_key(Keymap::Vim, &ov, 0x2f, 0, "/", false).unwrap(); + assert_eq!(a.id, "ui.queue"); + } + + /// Rebinding to the ACTIVE preset's own shortcut prunes the override, the + /// same way it does under Default. + #[test] + fn back_to_the_vim_base_prunes_the_override() { + let mut ov = no_overrides(); + assert!(apply_binding(Keymap::Vim, &mut ov, "nav.search", "w")); + assert_eq!(ov.get("nav.search").map(String::as_str), Some("w")); + assert!(apply_binding(Keymap::Vim, &mut ov, "nav.search", "/")); + assert!(!ov.contains_key("nav.search"), "back-to-base must prune"); + } + + #[test] + fn unknown_keymap_keys_and_indexes_read_as_default() { + assert_eq!(Keymap::from_key("emacs"), Keymap::Default); + assert_eq!(Keymap::from_key(""), Keymap::Default); + assert_eq!(Keymap::from_key("vim"), Keymap::Vim); + assert_eq!(Keymap::from_index(7), Keymap::Default); + assert_eq!(Keymap::from_index(Keymap::Vim.index()), Keymap::Vim); + assert_eq!(Keymap::from_key(Keymap::Vim.key()), Keymap::Vim); + } + // --- The new Spotify-parity actions ------------------------------------ #[test] @@ -1320,7 +1546,7 @@ mod tests { (QT_KEY_RIGHT, QT_CONTROL | QT_SHIFT, "", "playback.seekForward"), (QT_KEY_LEFT, QT_CONTROL | QT_SHIFT, "", "playback.seekBack"), ] { - let a = action_for_key(&ov, key, modifiers, text, false) + let a = action_for_key(Keymap::Default, &ov, key, modifiers, text, false) .unwrap_or_else(|| panic!("{id} did not resolve")); assert_eq!(a.id, id); } @@ -1332,6 +1558,7 @@ mod tests { fn global_seek_is_context_free_and_immersive_seek_still_is_not() { let ov = no_overrides(); let global = action_for_key( + Keymap::Default, &ov, QT_KEY_RIGHT, QT_CONTROL | QT_SHIFT, @@ -1341,8 +1568,8 @@ mod tests { .unwrap(); assert_eq!(global.id, "playback.seekForward"); assert_eq!(global.context, Context::None); - assert!(action_for_key(&ov, QT_KEY_RIGHT, 0, "", false).is_none()); - let imm = action_for_key(&ov, QT_KEY_RIGHT, 0, "", true).unwrap(); + assert!(action_for_key(Keymap::Default, &ov, QT_KEY_RIGHT, 0, "", false).is_none()); + let imm = action_for_key(Keymap::Default, &ov, QT_KEY_RIGHT, 0, "", true).unwrap(); assert_eq!(imm.id, "focus.seekForward"); } } diff --git a/crates/qbz-qt/src/mini_bridge.rs b/crates/qbz-qt/src/mini_bridge.rs index 46f886751..70f8e08d1 100644 --- a/crates/qbz-qt/src/mini_bridge.rs +++ b/crates/qbz-qt/src/mini_bridge.rs @@ -525,7 +525,13 @@ impl qbz_mini::QbzMini { let text = text.to_string(); let overrides = crate::hotkeys_qt::load_overrides(); let Some(action) = - crate::hotkeys_qt::action_for_mini_key(&overrides, key, modifiers, &text) + crate::hotkeys_qt::action_for_mini_key( + crate::hotkeys_qt::active_keymap(), + &overrides, + key, + modifiers, + &text, + ) else { return false; };