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:
12 files changed
+428
-66
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
|
||||
|
||||
@@ -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());
|
||||
|
||||
+281
-54
@@ -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());
|
||||
if let Some(shortcut) = overrides.get(a.id) {
|
||||
map.insert(a.id.to_string(), shortcut.clone());
|
||||
continue;
|
||||
}
|
||||
for (id, shortcut) in overrides {
|
||||
if map.contains_key(id) {
|
||||
map.insert(id.clone(), shortcut.clone());
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user