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.
This commit is contained in:
Niklas Herder committed 2026-08-28 07:27:43 +02:00
1 parent 04f9afe83f
commit b781ed283f
12 files changed
+429 -67

No files matched your search

@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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
+29 -3
View File
@@ -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());
+282 -55
View File
@@ -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<String, 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> {
/// 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<String, String>,
) -> BTreeMap<String, String> {
let mut claimed: BTreeSet<&str> = overrides
.iter()
.filter(|(id, _)| action(id).is_some())
.map(|(_, sc)| sc.as_str())
.collect();
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());
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<String, String>,
@@ -562,7 +692,8 @@ fn conflicting_action(
bindings: &BTreeMap<String, String>,
) -> 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<String, String>,
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<String, String>) {
/// 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<String, String>) -> Vec<Group> {
let bindings = active_bindings_with(overrides);
pub fn build_groups(keymap: Keymap, overrides: &BTreeMap<String, String>) -> Vec<Group> {
let bindings = active_bindings_with(keymap, 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);
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<String, String>) -> Vec<Group> {
groups
}
pub fn modified_count_with(overrides: &BTreeMap<String, String>) -> i32 {
let bindings = active_bindings_with(overrides);
pub fn modified_count_with(keymap: Keymap, overrides: &BTreeMap<String, String>) -> 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<String, String>) -> 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, String>) -> String {
let groups = build_groups(overrides);
pub fn groups_json(keymap: Keymap, overrides: &BTreeMap<String, String>) -> String {
let groups = build_groups(keymap, overrides);
let mut cols: [Vec<Group>; 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<String, String>,
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<String, String>,
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<String, String>,
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<String> {
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");
}
}
+7 -1
View File
@@ -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;
};