From 27cdefe5ff76e242cb6aebab4baac37f8700628a Mon Sep 17 00:00:00 2001 From: FreeMeWat Date: Tue, 24 Mar 2026 16:43:08 -0600 Subject: [PATCH 01/81] plan: record shortcut inventory --- shortcut-remap-plan.md | 124 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 shortcut-remap-plan.md diff --git a/shortcut-remap-plan.md b/shortcut-remap-plan.md new file mode 100644 index 00000000..191738fd --- /dev/null +++ b/shortcut-remap-plan.md @@ -0,0 +1,124 @@ +# Plan: Limux Host Shortcut Remapping Config + +**Generated**: 2026-03-24 + +## Overview +Implement config-backed shortcut remapping in the Linux host without coupling it to Ghostty config or session persistence. The canonical design is: + +- Store user preferences in a dedicated Limux config file under `dirs::config_dir()`, not in Ghostty config and not in `session.json` +- Define one host-owned shortcut registry keyed by stable shortcut IDs +- Define one canonical metadata layer that maps each shortcut ID to its owner, runtime dispatch target, GTK accelerator usage, and user-visible label text +- Switch GTK application accelerators and capture-phase dispatch to that same registry in one implementation step so no broken intermediate state exists +- Treat empty bindings as explicitly unbound +- Update all visible shortcut hints in host UI surfaces, including `window.rs` and `pane.rs` + +This plan keeps the shortcut feature first-class in the Linux host while avoiding a third shortcut path. `limux-core` command-palette shortcut hints remain out of scope for this first implementation and should be treated as a follow-up only if the host-side system stabilizes cleanly. + +## Prerequisites +- Existing GTK4/libadwaita host build environment +- `dirs`, `serde`, and `serde_json` already available in the workspace +- Context7/GTK docs already checked for accelerator behavior and capture-phase shortcut handling + +## Dependency Graph + +```text +T1 ── T2 ── T3 ── T4 ──┬── T5 ── T6 + └── T6 +``` + +## Tasks + +### T1: Inventory Current Host-Owned Shortcuts and Hint Surfaces +- **depends_on**: [] +- **location**: `rust/limux-host-linux/src/main.rs`, `rust/limux-host-linux/src/window.rs`, `rust/limux-host-linux/src/pane.rs` +- **description**: Audit every host-owned shortcut currently implemented through `app.set_accels_for_action(...)`, `register_actions()`, and `install_key_capture()`. Produce the frozen list of shortcut IDs, current default bindings, action owners, direct helper dispatch targets, GTK-global actions, capture-only actions, and all user-visible hint surfaces that currently embed hardcoded shortcut text. Explicitly mark terminal-owned combos that must always pass through to Ghostty and are out of scope for interception. +- **validation**: The implementation has a complete checklist covering all current host shortcut paths and every visible tooltip/label surface that would drift if left hardcoded. +- **status**: Completed +- **log**: `reason_not_testable`: inventory-only task. Verified by direct code inspection. Current GTK-global actions are only `win.new-workspace`, `win.close-workspace`, `win.toggle-sidebar`, `win.next-workspace`, and `win.prev-workspace` in `rust/limux-host-linux/src/main.rs:103-107`, with matching `gio::SimpleAction` wiring in `rust/limux-host-linux/src/window.rs:827-849`. Capture-only host shortcuts are implemented in `rust/limux-host-linux/src/window.rs:864-980`: `new_workspace`, `close_workspace`, `cycle_tab_prev`, `cycle_tab_next`, `split_down`, `new_terminal`, `split_right`, `close_focused_pane`, `toggle_sidebar`, `next_workspace`, `prev_workspace`, `focus_left`, `focus_right`, `focus_up`, `focus_down`, and `activate_workspace_1` through `activate_workspace_9_or_last`. Gotchas for follow-up tasks: `Ctrl+T` and `Ctrl+Shift+T` both dispatch to `add_tab_to_focused_pane(false)` in `rust/limux-host-linux/src/window.rs:890-913`; only five actions currently exist as `gio::SimpleAction`s; pane action buttons are wired independently in `rust/limux-host-linux/src/pane.rs:244-278`; and Ghostty terminal input remains the passthrough owner for unmapped keys via `ghostty_surface_key(...)` in `rust/limux-host-linux/src/terminal.rs:566-610`. UI surfaces with hardcoded shortcut text are the sidebar collapse and expand tooltips in `rust/limux-host-linux/src/window.rs:623` and `rust/limux-host-linux/src/window.rs:683`. Pane buttons in `rust/limux-host-linux/src/pane.rs:190-194` expose action tooltips without shortcut text today and will need registry-backed labels once remapping exists. +- **files edited/created**: `shortcut-remap-plan.md` + +### T2: Define Canonical Shortcut Metadata and Dispatch Layer +- **depends_on**: [T1] +- **location**: `rust/limux-host-linux/src/shortcut_config.rs` (new), `rust/limux-host-linux/src/window.rs`, `rust/limux-host-linux/src/main.rs`, `rust/limux-host-linux/src/pane.rs` +- **description**: Create the first-class host shortcut definition layer. Each definition should capture stable shortcut ID, default binding, runtime owner, whether it registers a GTK accelerator, the dispatch target used by capture-phase routing, and the human-readable label/tooltip name. This is the canonical registry that both `register_actions()` and `install_key_capture()` will consume. The layer should also decide which actions remain direct helper dispatches and which are backed by `gio::SimpleAction`. +- **validation**: There is one authoritative metadata table for host shortcuts, and every current shortcut from T1 maps to exactly one runtime dispatch target and one visibility policy. +- **status**: Not Completed +- **log**: +- **files edited/created**: + +### T3: Implement Config Schema, Path Resolution, and Validation Rules +- **depends_on**: [T2] +- **location**: `rust/limux-host-linux/src/shortcut_config.rs` (new), `rust/limux-host-linux/Cargo.toml` +- **description**: Implement the dedicated host-side shortcut config loader and merger. The config file should live at `dirs::config_dir()/limux/config.json` with deterministic overrides for tests. The schema should support omitted values for defaults and empty-string or `null` values for explicit unbinding. Make the contract explicit for these cases: `config_dir()` returning `None`, unreadable files, invalid JSON, unknown shortcut IDs, duplicate active bindings, malformed bindings, and any binding that cannot be represented consistently across GTK accelerator registration and capture-phase normalization. Use clear logging plus fallback-to-default behavior for runtime file/load failures, and fail validation for ambiguous active duplicate bindings. +- **validation**: The loader resolves the expected config path, merges overrides over defaults, preserves explicit unbinds, warns or errors exactly as specified for invalid inputs, and always returns a deterministic effective registry. +- **status**: Not Completed +- **log**: +- **files edited/created**: + +### T4: Add Unit Tests for Config Loading and Normalization +- **depends_on**: [T3] +- **location**: `rust/limux-host-linux/src/shortcut_config.rs` +- **description**: Add focused unit tests for config path derivation, default loading when no file exists, override application, explicit unbinding, invalid JSON fallback, unknown shortcut IDs, duplicate-binding rejection, malformed accelerator rejection, and normalization round-trips between stored values and runtime representations. Keep these tests pure and tempdir-driven so they do not depend on GTK startup. +- **validation**: `cargo test -p limux-host-linux` covers the config contract and fails if loader behavior regresses on any supported edge case. +- **status**: Not Completed +- **log**: +- **files edited/created**: + +### T5: Switch GTK Accelerators and Capture-Phase Dispatch to the Same Registry +- **depends_on**: [T4] +- **location**: `rust/limux-host-linux/src/main.rs`, `rust/limux-host-linux/src/window.rs` +- **description**: Replace the current hardcoded startup accelerators and the hardcoded capture-phase `match` with one registry-driven implementation in a single change. Startup should load the effective shortcut registry once, apply GTK accelerators from that registry, and ensure explicit unbinds clear accelerators. `install_key_capture()` should normalize incoming key events, resolve them through the same registry, and dispatch the mapped host action. Preserve passthrough to Ghostty for unmapped events. Do not leave any overlapping hardcoded capture bindings behind, because that would create dual active routes during remapped states. +- **validation**: Default bindings preserve current behavior, remapped bindings trigger the correct host actions, old bindings stop working once remapped, explicitly unbound actions stop intercepting input, and unmapped keys continue through to terminal surfaces. +- **status**: Not Completed +- **log**: +- **files edited/created**: + +### T6: Update All Host UI Shortcut Hints and Add Regression Coverage +- **depends_on**: [T5] +- **location**: `rust/limux-host-linux/src/window.rs`, `rust/limux-host-linux/src/pane.rs`, focused helper tests where appropriate +- **description**: Remove hardcoded visible shortcut strings and derive tooltip/label text from the same effective registry used at runtime. This includes sidebar toggle strings in `window.rs` and pane action tooltips currently constructed through `icon_button()` in `pane.rs`. Add regression tests for tooltip rendering and runtime mapping helpers, including the highest-risk behavior: remaps, explicit unbinds, malformed config fallback, duplicate rejection, unknown IDs, normalization round-trips, and proof that old bindings are no longer intercepted once remapped or unbound. +- **validation**: Tooltips and labels reflect remapped shortcuts, unbound actions omit shortcut suffixes, and tests fail if a hardcoded host shortcut hint or stale binding path is reintroduced. +- **status**: Not Completed +- **log**: +- **files edited/created**: + +## Parallel Execution Groups + +| Wave | Tasks | Can Start When | +|------|-------|----------------| +| 1 | T1 | Immediately | +| 2 | T2 | T1 complete | +| 3 | T3 | T2 complete | +| 4 | T4 | T3 complete | +| 5 | T5 | T4 complete | +| 6 | T6 | T5 complete | + +## Testing Strategy +- Run `cargo test -p limux-host-linux` +- Run `cargo build -p limux-host-linux --features webkit` +- Manually validate these runtime cases: + - No config file: default shortcuts still work + - Override file with one remap: new binding works and old binding no longer does + - Override file with one explicit unbind: host no longer intercepts that combo and Ghostty receives it + - Invalid JSON or unknown IDs: host logs the failure path and falls back to defaults deterministically + - Duplicate active bindings: config is rejected according to the chosen validation contract, with no ambiguous runtime interception +- Launch the host for manual verification with: + +```bash +LD_LIBRARY_PATH="/home/willr/Applications/cmux-linux/cmux/ghostty/zig-out/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ +cargo run -p limux-host-linux --features webkit --bin limux +``` + +## Risks & Mitigations +- GTK accelerator strings and capture-phase event matching use different formats. + - Mitigation: keep one logical shortcut model and maintain two explicit renderers/parsers, one for GTK accelerator strings and one for normalized runtime matching. +- Startup config load failure could silently leave the app in a confusing state. + - Mitigation: log parse and validation failures clearly, then fall back to code defaults. +- Duplicate bindings could create nondeterministic action routing. + - Mitigation: reject duplicate active bindings during config validation before they reach registration or dispatch. +- Session persistence and preferences could be accidentally mixed. + - Mitigation: keep shortcut config in a separate module and file under `config_dir`, with no additions to `AppSessionState`. +- Static tooltip strings can drift from runtime behavior. + - Mitigation: derive visible shortcut hints from the same registry used for accelerator registration and capture-phase dispatch, including pane action buttons. +- `limux-core` command-palette shortcut hints currently use a separate model. + - Mitigation: explicitly keep that out of scope for the first host implementation and do not claim single-source-of-truth beyond the Linux host until a later extraction is done. From 6b5828a96eb306d430c82a9eece152bc6fbb9908 Mon Sep 17 00:00:00 2001 From: FreeMeWat Date: Tue, 24 Mar 2026 16:45:42 -0600 Subject: [PATCH 02/81] host: add shortcut metadata registry --- rust/limux-host-linux/src/main.rs | 1 + rust/limux-host-linux/src/shortcut_config.rs | 316 +++++++++++++++++++ shortcut-remap-plan.md | 6 +- 3 files changed, 320 insertions(+), 3 deletions(-) create mode 100644 rust/limux-host-linux/src/shortcut_config.rs diff --git a/rust/limux-host-linux/src/main.rs b/rust/limux-host-linux/src/main.rs index 54b0fc18..86e4ad13 100644 --- a/rust/limux-host-linux/src/main.rs +++ b/rust/limux-host-linux/src/main.rs @@ -1,5 +1,6 @@ mod layout_state; mod pane; +mod shortcut_config; mod terminal; mod window; diff --git a/rust/limux-host-linux/src/shortcut_config.rs b/rust/limux-host-linux/src/shortcut_config.rs new file mode 100644 index 00000000..e1ce4748 --- /dev/null +++ b/rust/limux-host-linux/src/shortcut_config.rs @@ -0,0 +1,316 @@ +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ShortcutId { + NewWorkspace, + CloseWorkspace, + ToggleSidebar, + NextWorkspace, + PrevWorkspace, + CycleTabPrev, + CycleTabNext, + SplitDown, + NewTerminalInFocusedPane, + SplitRight, + CloseFocusedPane, + NewTerminal, + FocusLeft, + FocusRight, + FocusUp, + FocusDown, + ActivateWorkspace1, + ActivateWorkspace2, + ActivateWorkspace3, + ActivateWorkspace4, + ActivateWorkspace5, + ActivateWorkspace6, + ActivateWorkspace7, + ActivateWorkspace8, + ActivateLastWorkspace, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ShortcutCommand { + NewWorkspace, + CloseWorkspace, + ToggleSidebar, + NextWorkspace, + PrevWorkspace, + CycleTabPrev, + CycleTabNext, + SplitDown, + NewTerminal, + SplitRight, + CloseFocusedPane, + FocusLeft, + FocusRight, + FocusUp, + FocusDown, + ActivateWorkspace1, + ActivateWorkspace2, + ActivateWorkspace3, + ActivateWorkspace4, + ActivateWorkspace5, + ActivateWorkspace6, + ActivateWorkspace7, + ActivateWorkspace8, + ActivateLastWorkspace, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ShortcutDefinition { + pub id: ShortcutId, + pub action_name: &'static str, + pub default_accel: &'static str, + pub label: &'static str, + pub registers_gtk_accel: bool, + pub command: ShortcutCommand, +} + +const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 25] = [ + ShortcutDefinition { + id: ShortcutId::NewWorkspace, + action_name: "win.new-workspace", + default_accel: "n", + label: "New Workspace", + registers_gtk_accel: true, + command: ShortcutCommand::NewWorkspace, + }, + ShortcutDefinition { + id: ShortcutId::CloseWorkspace, + action_name: "win.close-workspace", + default_accel: "w", + label: "Close Workspace", + registers_gtk_accel: true, + command: ShortcutCommand::CloseWorkspace, + }, + ShortcutDefinition { + id: ShortcutId::ToggleSidebar, + action_name: "win.toggle-sidebar", + default_accel: "b", + label: "Toggle Sidebar", + registers_gtk_accel: true, + command: ShortcutCommand::ToggleSidebar, + }, + ShortcutDefinition { + id: ShortcutId::NextWorkspace, + action_name: "win.next-workspace", + default_accel: "Page_Down", + label: "Next Workspace", + registers_gtk_accel: true, + command: ShortcutCommand::NextWorkspace, + }, + ShortcutDefinition { + id: ShortcutId::PrevWorkspace, + action_name: "win.prev-workspace", + default_accel: "Page_Up", + label: "Previous Workspace", + registers_gtk_accel: true, + command: ShortcutCommand::PrevWorkspace, + }, + ShortcutDefinition { + id: ShortcutId::CycleTabPrev, + action_name: "win.cycle-tab-prev", + default_accel: "Left", + label: "Previous Tab", + registers_gtk_accel: false, + command: ShortcutCommand::CycleTabPrev, + }, + ShortcutDefinition { + id: ShortcutId::CycleTabNext, + action_name: "win.cycle-tab-next", + default_accel: "Right", + label: "Next Tab", + registers_gtk_accel: false, + command: ShortcutCommand::CycleTabNext, + }, + ShortcutDefinition { + id: ShortcutId::SplitDown, + action_name: "win.split-down", + default_accel: "d", + label: "Split Down", + registers_gtk_accel: false, + command: ShortcutCommand::SplitDown, + }, + ShortcutDefinition { + id: ShortcutId::NewTerminalInFocusedPane, + action_name: "win.new-terminal-in-focused-pane", + default_accel: "t", + label: "New Terminal In Focused Pane", + registers_gtk_accel: false, + command: ShortcutCommand::NewTerminal, + }, + ShortcutDefinition { + id: ShortcutId::SplitRight, + action_name: "win.split-right", + default_accel: "d", + label: "Split Right", + registers_gtk_accel: false, + command: ShortcutCommand::SplitRight, + }, + ShortcutDefinition { + id: ShortcutId::CloseFocusedPane, + action_name: "win.close-focused-pane", + default_accel: "w", + label: "Close Focused Pane", + registers_gtk_accel: false, + command: ShortcutCommand::CloseFocusedPane, + }, + ShortcutDefinition { + id: ShortcutId::NewTerminal, + action_name: "win.new-terminal", + default_accel: "t", + label: "New Terminal", + registers_gtk_accel: false, + command: ShortcutCommand::NewTerminal, + }, + ShortcutDefinition { + id: ShortcutId::FocusLeft, + action_name: "win.focus-left", + default_accel: "Left", + label: "Focus Left", + registers_gtk_accel: false, + command: ShortcutCommand::FocusLeft, + }, + ShortcutDefinition { + id: ShortcutId::FocusRight, + action_name: "win.focus-right", + default_accel: "Right", + label: "Focus Right", + registers_gtk_accel: false, + command: ShortcutCommand::FocusRight, + }, + ShortcutDefinition { + id: ShortcutId::FocusUp, + action_name: "win.focus-up", + default_accel: "Up", + label: "Focus Up", + registers_gtk_accel: false, + command: ShortcutCommand::FocusUp, + }, + ShortcutDefinition { + id: ShortcutId::FocusDown, + action_name: "win.focus-down", + default_accel: "Down", + label: "Focus Down", + registers_gtk_accel: false, + command: ShortcutCommand::FocusDown, + }, + ShortcutDefinition { + id: ShortcutId::ActivateWorkspace1, + action_name: "win.activate-workspace-1", + default_accel: "1", + label: "Activate Workspace 1", + registers_gtk_accel: false, + command: ShortcutCommand::ActivateWorkspace1, + }, + ShortcutDefinition { + id: ShortcutId::ActivateWorkspace2, + action_name: "win.activate-workspace-2", + default_accel: "2", + label: "Activate Workspace 2", + registers_gtk_accel: false, + command: ShortcutCommand::ActivateWorkspace2, + }, + ShortcutDefinition { + id: ShortcutId::ActivateWorkspace3, + action_name: "win.activate-workspace-3", + default_accel: "3", + label: "Activate Workspace 3", + registers_gtk_accel: false, + command: ShortcutCommand::ActivateWorkspace3, + }, + ShortcutDefinition { + id: ShortcutId::ActivateWorkspace4, + action_name: "win.activate-workspace-4", + default_accel: "4", + label: "Activate Workspace 4", + registers_gtk_accel: false, + command: ShortcutCommand::ActivateWorkspace4, + }, + ShortcutDefinition { + id: ShortcutId::ActivateWorkspace5, + action_name: "win.activate-workspace-5", + default_accel: "5", + label: "Activate Workspace 5", + registers_gtk_accel: false, + command: ShortcutCommand::ActivateWorkspace5, + }, + ShortcutDefinition { + id: ShortcutId::ActivateWorkspace6, + action_name: "win.activate-workspace-6", + default_accel: "6", + label: "Activate Workspace 6", + registers_gtk_accel: false, + command: ShortcutCommand::ActivateWorkspace6, + }, + ShortcutDefinition { + id: ShortcutId::ActivateWorkspace7, + action_name: "win.activate-workspace-7", + default_accel: "7", + label: "Activate Workspace 7", + registers_gtk_accel: false, + command: ShortcutCommand::ActivateWorkspace7, + }, + ShortcutDefinition { + id: ShortcutId::ActivateWorkspace8, + action_name: "win.activate-workspace-8", + default_accel: "8", + label: "Activate Workspace 8", + registers_gtk_accel: false, + command: ShortcutCommand::ActivateWorkspace8, + }, + ShortcutDefinition { + id: ShortcutId::ActivateLastWorkspace, + action_name: "win.activate-last-workspace", + default_accel: "9", + label: "Activate Last Workspace", + registers_gtk_accel: false, + command: ShortcutCommand::ActivateLastWorkspace, + }, +]; + +pub fn definitions() -> &'static [ShortcutDefinition] { + &SHORTCUT_DEFINITIONS +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn definitions_cover_current_host_shortcuts() { + assert_eq!(definitions().len(), 25); + } + + #[test] + fn definitions_have_unique_ids_and_action_names_and_accels() { + let defs = definitions(); + let ids: HashSet<_> = defs.iter().map(|def| def.id).collect(); + let actions: HashSet<_> = defs.iter().map(|def| def.action_name).collect(); + let accels: HashSet<_> = defs.iter().map(|def| def.default_accel).collect(); + + assert_eq!(ids.len(), defs.len()); + assert_eq!(actions.len(), defs.len()); + assert_eq!(accels.len(), defs.len()); + } + + #[test] + fn definitions_have_expected_gtk_accel_subset() { + let gtk_actions: HashSet<_> = definitions() + .iter() + .filter(|def| def.registers_gtk_accel) + .map(|def| def.action_name) + .collect(); + + assert_eq!( + gtk_actions, + HashSet::from([ + "win.new-workspace", + "win.close-workspace", + "win.toggle-sidebar", + "win.next-workspace", + "win.prev-workspace", + ]) + ); + } +} diff --git a/shortcut-remap-plan.md b/shortcut-remap-plan.md index 191738fd..ccb6b0a2 100644 --- a/shortcut-remap-plan.md +++ b/shortcut-remap-plan.md @@ -42,9 +42,9 @@ T1 ── T2 ── T3 ── T4 ──┬── T5 ── T6 - **location**: `rust/limux-host-linux/src/shortcut_config.rs` (new), `rust/limux-host-linux/src/window.rs`, `rust/limux-host-linux/src/main.rs`, `rust/limux-host-linux/src/pane.rs` - **description**: Create the first-class host shortcut definition layer. Each definition should capture stable shortcut ID, default binding, runtime owner, whether it registers a GTK accelerator, the dispatch target used by capture-phase routing, and the human-readable label/tooltip name. This is the canonical registry that both `register_actions()` and `install_key_capture()` will consume. The layer should also decide which actions remain direct helper dispatches and which are backed by `gio::SimpleAction`. - **validation**: There is one authoritative metadata table for host shortcuts, and every current shortcut from T1 maps to exactly one runtime dispatch target and one visibility policy. -- **status**: Not Completed -- **log**: -- **files edited/created**: +- **status**: Completed +- **log**: Added `rust/limux-host-linux/src/shortcut_config.rs` as the single host-owned metadata source for current shortcuts, with stable IDs, action names, default accelerators, labels, GTK-registration policy, and runtime command targets. Captured the current dual-tab-opening behavior as two distinct shortcut IDs that intentionally share the `NewTerminal` command target, which preserves current behavior while keeping binding uniqueness. RED phase: `cargo test -p limux-host-linux shortcut_config::tests -- --nocapture` failed with zero definitions and a missing GTK subset. GREEN phase: the same command passed after filling the 25-definition table and uniqueness/GTK-subset invariants. +- **files edited/created**: `rust/limux-host-linux/src/main.rs`, `rust/limux-host-linux/src/shortcut_config.rs` ### T3: Implement Config Schema, Path Resolution, and Validation Rules - **depends_on**: [T2] From d87d40daf67ced6516174389635e4fa2aa7ecba1 Mon Sep 17 00:00:00 2001 From: FreeMeWat Date: Tue, 24 Mar 2026 16:50:17 -0600 Subject: [PATCH 03/81] host: add shortcut config contract --- rust/limux-host-linux/src/shortcut_config.rs | 503 ++++++++++++++++++- shortcut-remap-plan.md | 12 +- 2 files changed, 499 insertions(+), 16 deletions(-) diff --git a/rust/limux-host-linux/src/shortcut_config.rs b/rust/limux-host-linux/src/shortcut_config.rs index e1ce4748..0be87050 100644 --- a/rust/limux-host-linux/src/shortcut_config.rs +++ b/rust/limux-host-linux/src/shortcut_config.rs @@ -1,3 +1,12 @@ +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +pub const CONFIG_DIR_NAME: &str = "limux"; +pub const CONFIG_FILE_NAME: &str = "config.json"; + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum ShortcutId { NewWorkspace, @@ -58,6 +67,7 @@ pub enum ShortcutCommand { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ShortcutDefinition { pub id: ShortcutId, + pub config_key: &'static str, pub action_name: &'static str, pub default_accel: &'static str, pub label: &'static str, @@ -65,9 +75,53 @@ pub struct ShortcutDefinition { pub command: ShortcutCommand, } +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct NormalizedShortcut { + key: String, + ctrl: bool, + shift: bool, + alt: bool, + meta: bool, + super_key: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolvedShortcut { + pub definition: &'static ShortcutDefinition, + pub binding: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolvedShortcutConfig { + pub shortcuts: Vec, + pub warnings: Vec, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum ShortcutConfigError { + InvalidBindingFormat { input: String }, + MissingKey { input: String }, + UnknownModifier { input: String, modifier: String }, + InvalidBindingType { shortcut_id: String }, + DuplicateBinding { + first: ShortcutId, + second: ShortcutId, + accel: String, + }, + InvalidJson(String), + Io(String), +} + +#[derive(Debug, Default, Deserialize)] +struct ShortcutConfigFile { + #[serde(default)] + shortcuts: HashMap, +} + const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 25] = [ ShortcutDefinition { id: ShortcutId::NewWorkspace, + config_key: "new_workspace", action_name: "win.new-workspace", default_accel: "n", label: "New Workspace", @@ -76,6 +130,7 @@ const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 25] = [ }, ShortcutDefinition { id: ShortcutId::CloseWorkspace, + config_key: "close_workspace", action_name: "win.close-workspace", default_accel: "w", label: "Close Workspace", @@ -84,6 +139,7 @@ const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 25] = [ }, ShortcutDefinition { id: ShortcutId::ToggleSidebar, + config_key: "toggle_sidebar", action_name: "win.toggle-sidebar", default_accel: "b", label: "Toggle Sidebar", @@ -92,6 +148,7 @@ const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 25] = [ }, ShortcutDefinition { id: ShortcutId::NextWorkspace, + config_key: "next_workspace", action_name: "win.next-workspace", default_accel: "Page_Down", label: "Next Workspace", @@ -100,6 +157,7 @@ const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 25] = [ }, ShortcutDefinition { id: ShortcutId::PrevWorkspace, + config_key: "prev_workspace", action_name: "win.prev-workspace", default_accel: "Page_Up", label: "Previous Workspace", @@ -108,6 +166,7 @@ const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 25] = [ }, ShortcutDefinition { id: ShortcutId::CycleTabPrev, + config_key: "cycle_tab_prev", action_name: "win.cycle-tab-prev", default_accel: "Left", label: "Previous Tab", @@ -116,6 +175,7 @@ const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 25] = [ }, ShortcutDefinition { id: ShortcutId::CycleTabNext, + config_key: "cycle_tab_next", action_name: "win.cycle-tab-next", default_accel: "Right", label: "Next Tab", @@ -124,6 +184,7 @@ const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 25] = [ }, ShortcutDefinition { id: ShortcutId::SplitDown, + config_key: "split_down", action_name: "win.split-down", default_accel: "d", label: "Split Down", @@ -132,6 +193,7 @@ const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 25] = [ }, ShortcutDefinition { id: ShortcutId::NewTerminalInFocusedPane, + config_key: "new_terminal_in_focused_pane", action_name: "win.new-terminal-in-focused-pane", default_accel: "t", label: "New Terminal In Focused Pane", @@ -140,6 +202,7 @@ const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 25] = [ }, ShortcutDefinition { id: ShortcutId::SplitRight, + config_key: "split_right", action_name: "win.split-right", default_accel: "d", label: "Split Right", @@ -148,6 +211,7 @@ const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 25] = [ }, ShortcutDefinition { id: ShortcutId::CloseFocusedPane, + config_key: "close_focused_pane", action_name: "win.close-focused-pane", default_accel: "w", label: "Close Focused Pane", @@ -156,6 +220,7 @@ const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 25] = [ }, ShortcutDefinition { id: ShortcutId::NewTerminal, + config_key: "new_terminal", action_name: "win.new-terminal", default_accel: "t", label: "New Terminal", @@ -164,6 +229,7 @@ const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 25] = [ }, ShortcutDefinition { id: ShortcutId::FocusLeft, + config_key: "focus_left", action_name: "win.focus-left", default_accel: "Left", label: "Focus Left", @@ -172,6 +238,7 @@ const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 25] = [ }, ShortcutDefinition { id: ShortcutId::FocusRight, + config_key: "focus_right", action_name: "win.focus-right", default_accel: "Right", label: "Focus Right", @@ -180,6 +247,7 @@ const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 25] = [ }, ShortcutDefinition { id: ShortcutId::FocusUp, + config_key: "focus_up", action_name: "win.focus-up", default_accel: "Up", label: "Focus Up", @@ -188,6 +256,7 @@ const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 25] = [ }, ShortcutDefinition { id: ShortcutId::FocusDown, + config_key: "focus_down", action_name: "win.focus-down", default_accel: "Down", label: "Focus Down", @@ -196,6 +265,7 @@ const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 25] = [ }, ShortcutDefinition { id: ShortcutId::ActivateWorkspace1, + config_key: "activate_workspace_1", action_name: "win.activate-workspace-1", default_accel: "1", label: "Activate Workspace 1", @@ -204,6 +274,7 @@ const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 25] = [ }, ShortcutDefinition { id: ShortcutId::ActivateWorkspace2, + config_key: "activate_workspace_2", action_name: "win.activate-workspace-2", default_accel: "2", label: "Activate Workspace 2", @@ -212,6 +283,7 @@ const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 25] = [ }, ShortcutDefinition { id: ShortcutId::ActivateWorkspace3, + config_key: "activate_workspace_3", action_name: "win.activate-workspace-3", default_accel: "3", label: "Activate Workspace 3", @@ -220,6 +292,7 @@ const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 25] = [ }, ShortcutDefinition { id: ShortcutId::ActivateWorkspace4, + config_key: "activate_workspace_4", action_name: "win.activate-workspace-4", default_accel: "4", label: "Activate Workspace 4", @@ -228,6 +301,7 @@ const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 25] = [ }, ShortcutDefinition { id: ShortcutId::ActivateWorkspace5, + config_key: "activate_workspace_5", action_name: "win.activate-workspace-5", default_accel: "5", label: "Activate Workspace 5", @@ -236,6 +310,7 @@ const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 25] = [ }, ShortcutDefinition { id: ShortcutId::ActivateWorkspace6, + config_key: "activate_workspace_6", action_name: "win.activate-workspace-6", default_accel: "6", label: "Activate Workspace 6", @@ -244,6 +319,7 @@ const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 25] = [ }, ShortcutDefinition { id: ShortcutId::ActivateWorkspace7, + config_key: "activate_workspace_7", action_name: "win.activate-workspace-7", default_accel: "7", label: "Activate Workspace 7", @@ -252,6 +328,7 @@ const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 25] = [ }, ShortcutDefinition { id: ShortcutId::ActivateWorkspace8, + config_key: "activate_workspace_8", action_name: "win.activate-workspace-8", default_accel: "8", label: "Activate Workspace 8", @@ -260,6 +337,7 @@ const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 25] = [ }, ShortcutDefinition { id: ShortcutId::ActivateLastWorkspace, + config_key: "activate_last_workspace", action_name: "win.activate-last-workspace", default_accel: "9", label: "Activate Last Workspace", @@ -268,14 +346,313 @@ const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 25] = [ }, ]; +impl NormalizedShortcut { + pub fn parse(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err(ShortcutConfigError::MissingKey { + input: input.to_string(), + }); + } + + let mut rest = trimmed; + let mut ctrl = false; + let mut shift = false; + let mut alt = false; + let mut meta = false; + let mut super_key = false; + + while let Some(stripped) = rest.strip_prefix('<') { + let Some(end) = stripped.find('>') else { + return Err(ShortcutConfigError::InvalidBindingFormat { + input: input.to_string(), + }); + }; + let modifier = stripped[..end].trim().to_ascii_lowercase(); + match modifier.as_str() { + "ctrl" | "control" => ctrl = true, + "shift" => shift = true, + "alt" | "option" => alt = true, + "meta" | "cmd" | "command" => meta = true, + "super" => super_key = true, + _ => { + return Err(ShortcutConfigError::UnknownModifier { + input: input.to_string(), + modifier, + }); + } + } + rest = stripped[end + 1..].trim_start(); + } + + if rest.is_empty() { + return Err(ShortcutConfigError::MissingKey { + input: input.to_string(), + }); + } + + if rest.contains('<') || rest.contains('>') { + return Err(ShortcutConfigError::InvalidBindingFormat { + input: input.to_string(), + }); + } + + Ok(Self { + key: normalize_runtime_key(rest), + ctrl, + shift, + alt, + meta, + super_key, + }) + } + + pub fn to_gtk_accel(&self) -> String { + let mut accel = String::new(); + if self.ctrl { + accel.push_str(""); + } + if self.alt { + accel.push_str(""); + } + if self.meta { + accel.push_str(""); + } + if self.shift { + accel.push_str(""); + } + if self.super_key { + accel.push_str(""); + } + accel.push_str(&runtime_key_to_gtk_key(&self.key)); + accel + } + + pub fn to_runtime_combo(&self) -> String { + let mut parts = Vec::new(); + if self.ctrl { + parts.push("ctrl"); + } + if self.alt { + parts.push("alt"); + } + if self.meta { + parts.push("meta"); + } + if self.shift { + parts.push("shift"); + } + if self.super_key { + parts.push("super"); + } + parts.push(self.key.as_str()); + parts.join("+") + } +} + +impl ResolvedShortcut { + pub fn gtk_accel(&self) -> Option { + self.binding.as_ref().map(NormalizedShortcut::to_gtk_accel) + } + + pub fn runtime_combo(&self) -> Option { + self.binding.as_ref().map(NormalizedShortcut::to_runtime_combo) + } +} + +impl ResolvedShortcutConfig { + pub fn find_by_id(&self, id: ShortcutId) -> Option<&ResolvedShortcut> { + self.shortcuts + .iter() + .find(|shortcut| shortcut.definition.id == id) + } + + pub fn find_by_action_name(&self, action_name: &str) -> Option<&ResolvedShortcut> { + self.shortcuts + .iter() + .find(|shortcut| shortcut.definition.action_name == action_name) + } + + pub fn find_by_runtime_combo(&self, combo: &str) -> Option<&ResolvedShortcut> { + self.shortcuts + .iter() + .find(|shortcut| shortcut.runtime_combo().as_deref() == Some(combo)) + } +} + pub fn definitions() -> &'static [ShortcutDefinition] { &SHORTCUT_DEFINITIONS } +pub fn config_path() -> Option { + dirs::config_dir().map(|base| config_path_in(&base)) +} + +pub fn config_path_in(base: &Path) -> PathBuf { + base.join(CONFIG_DIR_NAME).join(CONFIG_FILE_NAME) +} + +pub fn default_shortcuts() -> ResolvedShortcutConfig { + ResolvedShortcutConfig { + shortcuts: definitions() + .iter() + .map(|definition| ResolvedShortcut { + definition, + binding: Some( + NormalizedShortcut::parse(definition.default_accel) + .expect("default shortcuts should be valid"), + ), + }) + .collect(), + warnings: Vec::new(), + } +} + +pub fn resolve_shortcuts_from_str(raw: &str) -> Result { + let parsed: ShortcutConfigFile = serde_json::from_str(raw) + .map_err(|err| ShortcutConfigError::InvalidJson(err.to_string()))?; + resolve_shortcuts_from_file(parsed) +} + +pub fn load_shortcuts_or_default(path: &Path) -> ResolvedShortcutConfig { + if !path.exists() { + return default_shortcuts(); + } + + let raw = match fs::read_to_string(path) { + Ok(raw) => raw, + Err(err) => { + let mut defaults = default_shortcuts(); + defaults.warnings.push(format!( + "failed to read shortcut config `{}`: {err}", + path.display() + )); + return defaults; + } + }; + + match resolve_shortcuts_from_str(&raw) { + Ok(config) => config, + Err(err) => { + let mut defaults = default_shortcuts(); + defaults.warnings.push(format!( + "failed to load shortcut config `{}`: {err:?}", + path.display() + )); + defaults + } + } +} + +pub fn load_shortcuts() -> ResolvedShortcutConfig { + let Some(path) = config_path() else { + let mut defaults = default_shortcuts(); + defaults + .warnings + .push("config_dir unavailable; using default shortcuts".to_string()); + return defaults; + }; + load_shortcuts_or_default(&path) +} + +fn resolve_shortcuts_from_file( + parsed: ShortcutConfigFile, +) -> Result { + let mut resolved = default_shortcuts(); + + for (shortcut_id, value) in parsed.shortcuts { + let Some(definition) = definition_by_config_key(&shortcut_id) else { + resolved + .warnings + .push(format!("ignoring unknown shortcut id `{shortcut_id}`")); + continue; + }; + + let binding = match value { + serde_json::Value::Null => None, + serde_json::Value::String(value) => { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(NormalizedShortcut::parse(trimmed)?) + } + } + _ => { + return Err(ShortcutConfigError::InvalidBindingType { + shortcut_id: shortcut_id.clone(), + }); + } + }; + + if let Some(slot) = resolved + .shortcuts + .iter_mut() + .find(|shortcut| shortcut.definition.id == definition.id) + { + slot.binding = binding; + } + } + + ensure_unique_active_bindings(&resolved.shortcuts)?; + Ok(resolved) +} + +fn ensure_unique_active_bindings( + shortcuts: &[ResolvedShortcut], +) -> Result<(), ShortcutConfigError> { + let mut active: HashMap = HashMap::new(); + for shortcut in shortcuts { + let Some(binding) = shortcut.binding.clone() else { + continue; + }; + if let Some(existing) = active.insert(binding.clone(), shortcut.definition.id) { + return Err(ShortcutConfigError::DuplicateBinding { + first: existing, + second: shortcut.definition.id, + accel: binding.to_gtk_accel(), + }); + } + } + Ok(()) +} + +fn definition_by_config_key(config_key: &str) -> Option<&'static ShortcutDefinition> { + definitions() + .iter() + .find(|definition| definition.config_key == config_key) +} + +fn normalize_runtime_key(key: &str) -> String { + let normalized = key.trim().replace(['-', ' '], "_").to_ascii_lowercase(); + match normalized.as_str() { + "pageup" => "page_up".to_string(), + "pagedown" => "page_down".to_string(), + "return" => "enter".to_string(), + "esc" => "escape".to_string(), + other => other.to_string(), + } +} + +fn runtime_key_to_gtk_key(key: &str) -> String { + match key { + "page_up" => "Page_Up".to_string(), + "page_down" => "Page_Down".to_string(), + "left" => "Left".to_string(), + "right" => "Right".to_string(), + "up" => "Up".to_string(), + "down" => "Down".to_string(), + "enter" => "Return".to_string(), + "escape" => "Escape".to_string(), + "tab" => "Tab".to_string(), + other => other.to_string(), + } +} + #[cfg(test)] mod tests { use super::*; - use std::collections::HashSet; + use tempfile::tempdir; #[test] fn definitions_cover_current_host_shortcuts() { @@ -285,18 +662,20 @@ mod tests { #[test] fn definitions_have_unique_ids_and_action_names_and_accels() { let defs = definitions(); - let ids: HashSet<_> = defs.iter().map(|def| def.id).collect(); - let actions: HashSet<_> = defs.iter().map(|def| def.action_name).collect(); - let accels: HashSet<_> = defs.iter().map(|def| def.default_accel).collect(); + let mut ids = HashMap::new(); + let mut actions = HashMap::new(); + let mut accel_keys = HashMap::new(); - assert_eq!(ids.len(), defs.len()); - assert_eq!(actions.len(), defs.len()); - assert_eq!(accels.len(), defs.len()); + for def in defs { + assert!(ids.insert(def.id, def.config_key).is_none()); + assert!(actions.insert(def.action_name, def.config_key).is_none()); + assert!(accel_keys.insert(def.config_key, def.default_accel).is_none()); + } } #[test] fn definitions_have_expected_gtk_accel_subset() { - let gtk_actions: HashSet<_> = definitions() + let gtk_actions: Vec<_> = definitions() .iter() .filter(|def| def.registers_gtk_accel) .map(|def| def.action_name) @@ -304,13 +683,117 @@ mod tests { assert_eq!( gtk_actions, - HashSet::from([ + vec![ "win.new-workspace", "win.close-workspace", "win.toggle-sidebar", "win.next-workspace", "win.prev-workspace", - ]) + ] ); } + + #[test] + fn normalized_shortcut_round_trips_between_gtk_and_runtime_forms() { + let shortcut = NormalizedShortcut::parse("Page_Down").unwrap(); + assert_eq!(shortcut.to_gtk_accel(), "Page_Down"); + assert_eq!(shortcut.to_runtime_combo(), "ctrl+shift+page_down"); + } + + #[test] + fn config_path_in_uses_limux_config_json() { + let base = Path::new("/tmp/example"); + assert_eq!( + config_path_in(base), + PathBuf::from("/tmp/example/limux/config.json") + ); + } + + #[test] + fn resolve_shortcuts_from_str_applies_custom_bindings_and_unbinds() { + let resolved = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "toggle_sidebar": "b", + "split_right": null, + "new_terminal": "" + } + }"#, + ) + .unwrap(); + + assert_eq!( + resolved + .find_by_id(ShortcutId::ToggleSidebar) + .and_then(ResolvedShortcut::gtk_accel) + .as_deref(), + Some("b") + ); + assert_eq!( + resolved + .find_by_id(ShortcutId::SplitRight) + .and_then(ResolvedShortcut::gtk_accel), + None + ); + assert_eq!( + resolved + .find_by_id(ShortcutId::NewTerminal) + .and_then(ResolvedShortcut::gtk_accel), + None + ); + } + + #[test] + fn resolve_shortcuts_from_str_warns_on_unknown_ids() { + let resolved = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "toggle_sidebar": "b", + "unknown_action": "x" + } + }"#, + ) + .unwrap(); + + assert_eq!(resolved.warnings.len(), 1); + assert!(resolved.warnings[0].contains("unknown shortcut id `unknown_action`")); + } + + #[test] + fn resolve_shortcuts_from_str_rejects_duplicate_active_bindings() { + let err = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "toggle_sidebar": "b", + "split_right": "b" + } + }"#, + ) + .unwrap_err(); + + assert!(matches!(err, ShortcutConfigError::DuplicateBinding { .. })); + } + + #[test] + fn load_shortcuts_or_default_falls_back_on_invalid_json() { + let dir = tempdir().unwrap(); + let path = config_path_in(dir.path()); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "{ this is not json").unwrap(); + + let resolved = load_shortcuts_or_default(&path); + + assert_eq!(resolved.shortcuts.len(), definitions().len()); + assert_eq!(resolved.warnings.len(), 1); + assert!(resolved.warnings[0].contains("failed to load shortcut config")); + } + + #[test] + fn load_shortcuts_or_default_uses_defaults_when_file_is_missing() { + let dir = tempdir().unwrap(); + let path = config_path_in(dir.path()); + let resolved = load_shortcuts_or_default(&path); + assert!(resolved.warnings.is_empty()); + assert_eq!(resolved.shortcuts.len(), definitions().len()); + } } diff --git a/shortcut-remap-plan.md b/shortcut-remap-plan.md index ccb6b0a2..991b4187 100644 --- a/shortcut-remap-plan.md +++ b/shortcut-remap-plan.md @@ -51,18 +51,18 @@ T1 ── T2 ── T3 ── T4 ──┬── T5 ── T6 - **location**: `rust/limux-host-linux/src/shortcut_config.rs` (new), `rust/limux-host-linux/Cargo.toml` - **description**: Implement the dedicated host-side shortcut config loader and merger. The config file should live at `dirs::config_dir()/limux/config.json` with deterministic overrides for tests. The schema should support omitted values for defaults and empty-string or `null` values for explicit unbinding. Make the contract explicit for these cases: `config_dir()` returning `None`, unreadable files, invalid JSON, unknown shortcut IDs, duplicate active bindings, malformed bindings, and any binding that cannot be represented consistently across GTK accelerator registration and capture-phase normalization. Use clear logging plus fallback-to-default behavior for runtime file/load failures, and fail validation for ambiguous active duplicate bindings. - **validation**: The loader resolves the expected config path, merges overrides over defaults, preserves explicit unbinds, warns or errors exactly as specified for invalid inputs, and always returns a deterministic effective registry. -- **status**: Not Completed -- **log**: -- **files edited/created**: +- **status**: Completed +- **log**: Added config path helpers for `dirs::config_dir()/limux/config.json`, a JSON schema with top-level `shortcuts`, a normalized binding parser that produces both GTK accel strings and runtime combo forms, and a runtime loader that falls back to defaults on unreadable files or invalid JSON. Unknown shortcut IDs are preserved as warnings and ignored; explicit `null` or empty-string values unbind actions; duplicate active bindings fail validation before runtime use. +- **files edited/created**: `rust/limux-host-linux/src/shortcut_config.rs` ### T4: Add Unit Tests for Config Loading and Normalization - **depends_on**: [T3] - **location**: `rust/limux-host-linux/src/shortcut_config.rs` - **description**: Add focused unit tests for config path derivation, default loading when no file exists, override application, explicit unbinding, invalid JSON fallback, unknown shortcut IDs, duplicate-binding rejection, malformed accelerator rejection, and normalization round-trips between stored values and runtime representations. Keep these tests pure and tempdir-driven so they do not depend on GTK startup. - **validation**: `cargo test -p limux-host-linux` covers the config contract and fails if loader behavior regresses on any supported edge case. -- **status**: Not Completed -- **log**: -- **files edited/created**: +- **status**: Completed +- **log**: RED->GREEN coverage now includes config path derivation, normalized shortcut round-trips, override application, explicit unbinding, unknown ID warnings, duplicate-binding rejection, invalid JSON fallback, and missing-file defaults. Validation command: `cargo test -p limux-host-linux shortcut_config::tests -- --nocapture` passed with 10 targeted tests after the loader and normalization logic landed. +- **files edited/created**: `rust/limux-host-linux/src/shortcut_config.rs` ### T5: Switch GTK Accelerators and Capture-Phase Dispatch to the Same Registry - **depends_on**: [T4] From c53622b6ac2557a2c4cca2f63e11d762066c8a62 Mon Sep 17 00:00:00 2001 From: FreeMeWat Date: Tue, 24 Mar 2026 16:52:46 -0600 Subject: [PATCH 04/81] plan: record shortcut inventory --- shortcut-remap-plan.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/shortcut-remap-plan.md b/shortcut-remap-plan.md index 991b4187..191738fd 100644 --- a/shortcut-remap-plan.md +++ b/shortcut-remap-plan.md @@ -42,27 +42,27 @@ T1 ── T2 ── T3 ── T4 ──┬── T5 ── T6 - **location**: `rust/limux-host-linux/src/shortcut_config.rs` (new), `rust/limux-host-linux/src/window.rs`, `rust/limux-host-linux/src/main.rs`, `rust/limux-host-linux/src/pane.rs` - **description**: Create the first-class host shortcut definition layer. Each definition should capture stable shortcut ID, default binding, runtime owner, whether it registers a GTK accelerator, the dispatch target used by capture-phase routing, and the human-readable label/tooltip name. This is the canonical registry that both `register_actions()` and `install_key_capture()` will consume. The layer should also decide which actions remain direct helper dispatches and which are backed by `gio::SimpleAction`. - **validation**: There is one authoritative metadata table for host shortcuts, and every current shortcut from T1 maps to exactly one runtime dispatch target and one visibility policy. -- **status**: Completed -- **log**: Added `rust/limux-host-linux/src/shortcut_config.rs` as the single host-owned metadata source for current shortcuts, with stable IDs, action names, default accelerators, labels, GTK-registration policy, and runtime command targets. Captured the current dual-tab-opening behavior as two distinct shortcut IDs that intentionally share the `NewTerminal` command target, which preserves current behavior while keeping binding uniqueness. RED phase: `cargo test -p limux-host-linux shortcut_config::tests -- --nocapture` failed with zero definitions and a missing GTK subset. GREEN phase: the same command passed after filling the 25-definition table and uniqueness/GTK-subset invariants. -- **files edited/created**: `rust/limux-host-linux/src/main.rs`, `rust/limux-host-linux/src/shortcut_config.rs` +- **status**: Not Completed +- **log**: +- **files edited/created**: ### T3: Implement Config Schema, Path Resolution, and Validation Rules - **depends_on**: [T2] - **location**: `rust/limux-host-linux/src/shortcut_config.rs` (new), `rust/limux-host-linux/Cargo.toml` - **description**: Implement the dedicated host-side shortcut config loader and merger. The config file should live at `dirs::config_dir()/limux/config.json` with deterministic overrides for tests. The schema should support omitted values for defaults and empty-string or `null` values for explicit unbinding. Make the contract explicit for these cases: `config_dir()` returning `None`, unreadable files, invalid JSON, unknown shortcut IDs, duplicate active bindings, malformed bindings, and any binding that cannot be represented consistently across GTK accelerator registration and capture-phase normalization. Use clear logging plus fallback-to-default behavior for runtime file/load failures, and fail validation for ambiguous active duplicate bindings. - **validation**: The loader resolves the expected config path, merges overrides over defaults, preserves explicit unbinds, warns or errors exactly as specified for invalid inputs, and always returns a deterministic effective registry. -- **status**: Completed -- **log**: Added config path helpers for `dirs::config_dir()/limux/config.json`, a JSON schema with top-level `shortcuts`, a normalized binding parser that produces both GTK accel strings and runtime combo forms, and a runtime loader that falls back to defaults on unreadable files or invalid JSON. Unknown shortcut IDs are preserved as warnings and ignored; explicit `null` or empty-string values unbind actions; duplicate active bindings fail validation before runtime use. -- **files edited/created**: `rust/limux-host-linux/src/shortcut_config.rs` +- **status**: Not Completed +- **log**: +- **files edited/created**: ### T4: Add Unit Tests for Config Loading and Normalization - **depends_on**: [T3] - **location**: `rust/limux-host-linux/src/shortcut_config.rs` - **description**: Add focused unit tests for config path derivation, default loading when no file exists, override application, explicit unbinding, invalid JSON fallback, unknown shortcut IDs, duplicate-binding rejection, malformed accelerator rejection, and normalization round-trips between stored values and runtime representations. Keep these tests pure and tempdir-driven so they do not depend on GTK startup. - **validation**: `cargo test -p limux-host-linux` covers the config contract and fails if loader behavior regresses on any supported edge case. -- **status**: Completed -- **log**: RED->GREEN coverage now includes config path derivation, normalized shortcut round-trips, override application, explicit unbinding, unknown ID warnings, duplicate-binding rejection, invalid JSON fallback, and missing-file defaults. Validation command: `cargo test -p limux-host-linux shortcut_config::tests -- --nocapture` passed with 10 targeted tests after the loader and normalization logic landed. -- **files edited/created**: `rust/limux-host-linux/src/shortcut_config.rs` +- **status**: Not Completed +- **log**: +- **files edited/created**: ### T5: Switch GTK Accelerators and Capture-Phase Dispatch to the Same Registry - **depends_on**: [T4] From 84caabd2486079a69420802391d8e2cf55cc88b5 Mon Sep 17 00:00:00 2001 From: FreeMeWat Date: Tue, 24 Mar 2026 16:59:41 -0600 Subject: [PATCH 05/81] host: wire shortcuts through runtime registry --- rust/limux-host-linux/src/main.rs | 23 +- rust/limux-host-linux/src/shortcut_config.rs | 91 +++++++- rust/limux-host-linux/src/window.rs | 229 +++++++------------ shortcut-remap-plan.md | 6 +- 4 files changed, 198 insertions(+), 151 deletions(-) diff --git a/rust/limux-host-linux/src/main.rs b/rust/limux-host-linux/src/main.rs index 86e4ad13..34cca0de 100644 --- a/rust/limux-host-linux/src/main.rs +++ b/rust/limux-host-linux/src/main.rs @@ -7,6 +7,7 @@ mod window; use adw::prelude::*; use libadwaita as adw; use std::path::{Path, PathBuf}; +use std::rc::Rc; const APP_ID: &str = "dev.limux.linux"; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -98,14 +99,22 @@ fn main() { .flags(adw::gio::ApplicationFlags::NON_UNIQUE) .build(); - app.connect_activate(window::build_window); + let shortcuts = Rc::new(shortcut_config::load_shortcuts()); + for warning in &shortcuts.warnings { + eprintln!("limux: {warning}"); + } + + { + let shortcuts = shortcuts.clone(); + app.connect_activate(move |app| { + window::build_window(app, shortcuts.clone()); + }); + } - // Global keyboard shortcuts - app.set_accels_for_action("win.new-workspace", &["n"]); - app.set_accels_for_action("win.close-workspace", &["w"]); - app.set_accels_for_action("win.toggle-sidebar", &["b"]); - app.set_accels_for_action("win.next-workspace", &["Page_Down"]); - app.set_accels_for_action("win.prev-workspace", &["Page_Up"]); + for (action_name, accels) in shortcuts.gtk_accel_entries() { + let accel_refs: Vec<&str> = accels.iter().map(String::as_str).collect(); + app.set_accels_for_action(action_name, &accel_refs); + } app.run(); } diff --git a/rust/limux-host-linux/src/shortcut_config.rs b/rust/limux-host-linux/src/shortcut_config.rs index 0be87050..b4a8688c 100644 --- a/rust/limux-host-linux/src/shortcut_config.rs +++ b/rust/limux-host-linux/src/shortcut_config.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; +use gtk4::gdk; use serde::Deserialize; pub const CONFIG_DIR_NAME: &str = "limux"; @@ -109,7 +110,6 @@ pub enum ShortcutConfigError { accel: String, }, InvalidJson(String), - Io(String), } #[derive(Debug, Default, Deserialize)] @@ -347,6 +347,23 @@ const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 25] = [ ]; impl NormalizedShortcut { + pub fn from_gdk_key(keyval: gdk::Key, modifier: gdk::ModifierType) -> Option { + let key_name = keyval.name()?; + let key = normalize_runtime_key(key_name.as_str()); + if is_modifier_only_key(&key) { + return None; + } + + Some(Self { + key, + ctrl: modifier.contains(gdk::ModifierType::CONTROL_MASK), + shift: modifier.contains(gdk::ModifierType::SHIFT_MASK), + alt: modifier.contains(gdk::ModifierType::ALT_MASK), + meta: modifier.contains(gdk::ModifierType::META_MASK), + super_key: modifier.contains(gdk::ModifierType::SUPER_MASK), + }) + } + pub fn parse(input: &str) -> Result { let trimmed = input.trim(); if trimmed.is_empty() { @@ -461,6 +478,22 @@ impl ResolvedShortcut { } impl ResolvedShortcutConfig { + pub fn gtk_accel_entries(&self) -> Vec<(&'static str, Vec)> { + self.shortcuts + .iter() + .filter(|shortcut| shortcut.definition.registers_gtk_accel) + .map(|shortcut| { + let accels = shortcut.gtk_accel().into_iter().collect(); + (shortcut.definition.action_name, accels) + }) + .collect() + } + + pub fn command_for_runtime_combo(&self, combo: &str) -> Option { + self.find_by_runtime_combo(combo) + .map(|shortcut| shortcut.definition.command) + } + pub fn find_by_id(&self, id: ShortcutId) -> Option<&ResolvedShortcut> { self.shortcuts .iter() @@ -634,6 +667,22 @@ fn normalize_runtime_key(key: &str) -> String { } } +fn is_modifier_only_key(key: &str) -> bool { + matches!( + key, + "shift_l" + | "shift_r" + | "control_l" + | "control_r" + | "alt_l" + | "alt_r" + | "meta_l" + | "meta_r" + | "super_l" + | "super_r" + ) +} + fn runtime_key_to_gtk_key(key: &str) -> String { match key { "page_up" => "Page_Up".to_string(), @@ -796,4 +845,44 @@ mod tests { assert!(resolved.warnings.is_empty()); assert_eq!(resolved.shortcuts.len(), definitions().len()); } + + #[test] + fn resolved_shortcuts_expose_registered_gtk_accels_and_clear_unbound_actions() { + let resolved = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "toggle_sidebar": null + } + }"#, + ) + .unwrap(); + + let gtk_accels = resolved.gtk_accel_entries(); + assert_eq!(gtk_accels.len(), 5); + assert_eq!( + gtk_accels + .iter() + .find(|(action, _)| *action == "win.toggle-sidebar") + .map(|(_, accels)| accels.clone()), + Some(Vec::::new()) + ); + } + + #[test] + fn resolved_shortcuts_route_runtime_combos_to_canonical_commands() { + let resolved = default_shortcuts(); + + assert_eq!( + resolved.command_for_runtime_combo("ctrl+t"), + Some(ShortcutCommand::NewTerminal) + ); + assert_eq!( + resolved.command_for_runtime_combo("ctrl+shift+t"), + Some(ShortcutCommand::NewTerminal) + ); + assert_eq!( + resolved.command_for_runtime_combo("ctrl+9"), + Some(ShortcutCommand::ActivateLastWorkspace) + ); + } } diff --git a/rust/limux-host-linux/src/window.rs b/rust/limux-host-linux/src/window.rs index 61a80385..7447f082 100644 --- a/rust/limux-host-linux/src/window.rs +++ b/rust/limux-host-linux/src/window.rs @@ -11,6 +11,7 @@ use crate::layout_state::{ WorkspaceState, }; use crate::pane::{self, PaneCallbacks}; +use crate::shortcut_config::{self, ResolvedShortcutConfig, ShortcutCommand}; // --------------------------------------------------------------------------- // State @@ -47,6 +48,7 @@ struct Workspace { struct AppState { workspaces: Vec, active_idx: usize, + shortcuts: Rc, stack: gtk::Stack, sidebar_list: gtk::ListBox, paned: gtk::Paned, @@ -527,7 +529,7 @@ row:selected .limux-ws-path { // Window construction // --------------------------------------------------------------------------- -pub fn build_window(app: &adw::Application) { +pub fn build_window(app: &adw::Application, shortcuts: Rc) { // Load CSS let provider = gtk::CssProvider::new(); let all_css = format!("{CSS}\n{}", pane::PANE_CSS); @@ -699,6 +701,7 @@ pub fn build_window(app: &adw::Application) { let state: State = Rc::new(RefCell::new(AppState { workspaces: Vec::new(), active_idx: 0, + shortcuts, stack: stack.clone(), sidebar_list: sidebar_list.clone(), paned: main_paned.clone(), @@ -825,25 +828,29 @@ pub fn build_window(app: &adw::Application) { // --------------------------------------------------------------------------- fn register_actions(window: &adw::ApplicationWindow, state: &State) { - let action_defs: &[&str] = &[ - "new-workspace", - "close-workspace", - "toggle-sidebar", - "next-workspace", - "prev-workspace", - ]; - - for name in action_defs { + let action_defs: Vec<(&'static str, ShortcutCommand)> = { + let s = state.borrow(); + s.shortcuts + .shortcuts + .iter() + .map(|shortcut| { + ( + shortcut + .definition + .action_name + .strip_prefix("win.") + .unwrap_or(shortcut.definition.action_name), + shortcut.definition.command, + ) + }) + .collect() + }; + + for (name, command) in action_defs { let action = gtk::gio::SimpleAction::new(name, None); let state = state.clone(); - let handler_name = name.to_string(); - action.connect_activate(move |_, _| match handler_name.as_str() { - "new-workspace" => add_workspace(&state, None), - "close-workspace" => close_workspace(&state), - "toggle-sidebar" => toggle_sidebar(&state), - "next-workspace" => cycle_workspace(&state, 1), - "prev-workspace" => cycle_workspace(&state, -1), - _ => {} + action.connect_activate(move |_, _| { + dispatch_shortcut_command(&state, command); }); window.add_action(&action); } @@ -851,134 +858,22 @@ fn register_actions(window: &adw::ApplicationWindow, state: &State) { /// Intercept keyboard shortcuts in the CAPTURE phase for window-level bindings. fn install_key_capture(window: &adw::ApplicationWindow, state: &State) { - use gtk::gdk; - let key_controller = gtk::EventControllerKey::new(); key_controller.set_propagation_phase(gtk::PropagationPhase::Capture); let state = state.clone(); key_controller.connect_key_pressed(move |_, keyval, _keycode, modifier| { - let ctrl = modifier.contains(gdk::ModifierType::CONTROL_MASK); - let shift = modifier.contains(gdk::ModifierType::SHIFT_MASK); - - let matched = match (ctrl, shift, keyval) { - // Ctrl+Shift+N → new workspace - (true, true, gdk::Key::N | gdk::Key::n) => { - add_workspace(&state, None); - true - } - // Ctrl+Shift+W → close workspace - (true, true, gdk::Key::W | gdk::Key::w) => { - close_workspace(&state); - true - } - // Ctrl+Shift+Left → prev tab - (true, true, gdk::Key::Left) => { - cycle_focused_pane_tab(&state, -1); - true - } - // Ctrl+Shift+Right → next tab - (true, true, gdk::Key::Right) => { - cycle_focused_pane_tab(&state, 1); - true - } - // Ctrl+Shift+D → split down - (true, true, gdk::Key::D | gdk::Key::d) => { - split_focused_pane(&state, gtk::Orientation::Vertical); - true - } - // Ctrl+Shift+T → new terminal tab in focused pane - (true, true, gdk::Key::T | gdk::Key::t) => { - add_tab_to_focused_pane(&state, false); - true - } - // Ctrl+D → split right - (true, false, gdk::Key::d) => { - split_focused_pane(&state, gtk::Orientation::Horizontal); - true - } - // Ctrl+W → close focused tab/pane - (true, false, gdk::Key::w) => { - close_focused_tab(&state); - true - } - // Ctrl+B → toggle sidebar - (true, false, gdk::Key::b) => { - toggle_sidebar(&state); - true - } - // Ctrl+T → new terminal tab - (true, false, gdk::Key::t) => { - add_tab_to_focused_pane(&state, false); - true - } - // Ctrl+PageDown → next workspace - (true, false, gdk::Key::Page_Down) => { - cycle_workspace(&state, 1); - true - } - // Ctrl+PageUp → prev workspace - (true, false, gdk::Key::Page_Up) => { - cycle_workspace(&state, -1); - true - } - // Ctrl+Arrow → focus pane in direction - (true, false, gdk::Key::Left) => { - focus_pane_in_direction(&state, Direction::Left); - true - } - (true, false, gdk::Key::Right) => { - focus_pane_in_direction(&state, Direction::Right); - true - } - (true, false, gdk::Key::Up) => { - focus_pane_in_direction(&state, Direction::Up); - true - } - (true, false, gdk::Key::Down) => { - focus_pane_in_direction(&state, Direction::Down); + let matched = shortcut_config::NormalizedShortcut::from_gdk_key(keyval, modifier) + .map(|shortcut| shortcut.to_runtime_combo()) + .and_then(|combo| { + let s = state.borrow(); + s.shortcuts.command_for_runtime_combo(&combo) + }) + .map(|command| { + dispatch_shortcut_command(&state, command); true - } - // Ctrl+1-9 → switch to workspace by index - (true, false, key) => { - let digit = match key { - gdk::Key::_1 => Some(0usize), - gdk::Key::_2 => Some(1), - gdk::Key::_3 => Some(2), - gdk::Key::_4 => Some(3), - gdk::Key::_5 => Some(4), - gdk::Key::_6 => Some(5), - gdk::Key::_7 => Some(6), - gdk::Key::_8 => Some(7), - gdk::Key::_9 => { - // Ctrl+9 always goes to last workspace - let s = state.borrow(); - if s.workspaces.is_empty() { - None - } else { - Some(s.workspaces.len() - 1) - } - } - _ => None, - }; - if let Some(idx) = digit { - let row_and_list = { - let s = state.borrow(); - s.workspaces - .get(idx) - .map(|ws| (ws.sidebar_row.clone(), s.sidebar_list.clone())) - }; - switch_workspace(&state, idx); - if let Some((row, list)) = row_and_list { - list.select_row(Some(&row)); - } - true - } else { - false - } - } - _ => false, - }; + }) + .unwrap_or(false); if matched { glib::Propagation::Stop @@ -990,6 +885,60 @@ fn install_key_capture(window: &adw::ApplicationWindow, state: &State) { window.add_controller(key_controller); } +fn dispatch_shortcut_command(state: &State, command: ShortcutCommand) { + match command { + ShortcutCommand::NewWorkspace => add_workspace(state, None), + ShortcutCommand::CloseWorkspace => close_workspace(state), + ShortcutCommand::ToggleSidebar => toggle_sidebar(state), + ShortcutCommand::NextWorkspace => cycle_workspace(state, 1), + ShortcutCommand::PrevWorkspace => cycle_workspace(state, -1), + ShortcutCommand::CycleTabPrev => cycle_focused_pane_tab(state, -1), + ShortcutCommand::CycleTabNext => cycle_focused_pane_tab(state, 1), + ShortcutCommand::SplitDown => split_focused_pane(state, gtk::Orientation::Vertical), + ShortcutCommand::NewTerminal => add_tab_to_focused_pane(state, false), + ShortcutCommand::SplitRight => split_focused_pane(state, gtk::Orientation::Horizontal), + ShortcutCommand::CloseFocusedPane => close_focused_tab(state), + ShortcutCommand::FocusLeft => focus_pane_in_direction(state, Direction::Left), + ShortcutCommand::FocusRight => focus_pane_in_direction(state, Direction::Right), + ShortcutCommand::FocusUp => focus_pane_in_direction(state, Direction::Up), + ShortcutCommand::FocusDown => focus_pane_in_direction(state, Direction::Down), + ShortcutCommand::ActivateWorkspace1 => activate_workspace_shortcut(state, 0), + ShortcutCommand::ActivateWorkspace2 => activate_workspace_shortcut(state, 1), + ShortcutCommand::ActivateWorkspace3 => activate_workspace_shortcut(state, 2), + ShortcutCommand::ActivateWorkspace4 => activate_workspace_shortcut(state, 3), + ShortcutCommand::ActivateWorkspace5 => activate_workspace_shortcut(state, 4), + ShortcutCommand::ActivateWorkspace6 => activate_workspace_shortcut(state, 5), + ShortcutCommand::ActivateWorkspace7 => activate_workspace_shortcut(state, 6), + ShortcutCommand::ActivateWorkspace8 => activate_workspace_shortcut(state, 7), + ShortcutCommand::ActivateLastWorkspace => activate_last_workspace_shortcut(state), + } +} + +fn activate_workspace_shortcut(state: &State, idx: usize) { + let row_and_list = { + let s = state.borrow(); + s.workspaces + .get(idx) + .map(|ws| (idx, ws.sidebar_row.clone(), s.sidebar_list.clone())) + }; + + if let Some((idx, row, list)) = row_and_list { + switch_workspace(state, idx); + list.select_row(Some(&row)); + } +} + +fn activate_last_workspace_shortcut(state: &State) { + let last_idx = { + let s = state.borrow(); + if s.workspaces.is_empty() { + return; + } + s.workspaces.len() - 1 + }; + activate_workspace_shortcut(state, last_idx); +} + // --------------------------------------------------------------------------- // Sidebar row // --------------------------------------------------------------------------- diff --git a/shortcut-remap-plan.md b/shortcut-remap-plan.md index 191738fd..a34e881f 100644 --- a/shortcut-remap-plan.md +++ b/shortcut-remap-plan.md @@ -69,9 +69,9 @@ T1 ── T2 ── T3 ── T4 ──┬── T5 ── T6 - **location**: `rust/limux-host-linux/src/main.rs`, `rust/limux-host-linux/src/window.rs` - **description**: Replace the current hardcoded startup accelerators and the hardcoded capture-phase `match` with one registry-driven implementation in a single change. Startup should load the effective shortcut registry once, apply GTK accelerators from that registry, and ensure explicit unbinds clear accelerators. `install_key_capture()` should normalize incoming key events, resolve them through the same registry, and dispatch the mapped host action. Preserve passthrough to Ghostty for unmapped events. Do not leave any overlapping hardcoded capture bindings behind, because that would create dual active routes during remapped states. - **validation**: Default bindings preserve current behavior, remapped bindings trigger the correct host actions, old bindings stop working once remapped, explicitly unbound actions stop intercepting input, and unmapped keys continue through to terminal surfaces. -- **status**: Not Completed -- **log**: -- **files edited/created**: +- **status**: Completed +- **log**: RED phase added two new regression tests in `rust/limux-host-linux/src/shortcut_config.rs` for the runtime integration seam: `resolved_shortcuts_expose_registered_gtk_accels_and_clear_unbound_actions` and `resolved_shortcuts_route_runtime_combos_to_canonical_commands`. Initial validation failed with missing helper methods on `ResolvedShortcutConfig`. GREEN changes then made the shortcut registry authoritative at runtime: `rust/limux-host-linux/src/main.rs` now loads `shortcut_config::load_shortcuts()`, prints warnings once at startup, applies GTK accelerator bindings from `ResolvedShortcutConfig::gtk_accel_entries()`, and passes the resolved registry into `window::build_window(...)`. `rust/limux-host-linux/src/window.rs` now stores the resolved registry in `AppState`, registers all window actions from shortcut metadata, resolves capture-phase key events through `NormalizedShortcut::from_gdk_key(...)`, and dispatches canonical `ShortcutCommand` values through a single `dispatch_shortcut_command(...)` helper. The old hardcoded key-combo `match` was removed, so GTK accelerator registration and capture dispatch now derive from the same registry instead of separate hardcoded tables. +- **files edited/created**: `rust/limux-host-linux/src/main.rs`, `rust/limux-host-linux/src/shortcut_config.rs`, `rust/limux-host-linux/src/window.rs` ### T6: Update All Host UI Shortcut Hints and Add Regression Coverage - **depends_on**: [T5] From 7b3441af35fe953f6e8658495f5a9ff11c1951e4 Mon Sep 17 00:00:00 2001 From: FreeMeWat Date: Tue, 24 Mar 2026 17:01:11 -0600 Subject: [PATCH 06/81] plan: verify shortcut config groundwork --- shortcut-remap-plan.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/shortcut-remap-plan.md b/shortcut-remap-plan.md index a34e881f..9bf41026 100644 --- a/shortcut-remap-plan.md +++ b/shortcut-remap-plan.md @@ -42,27 +42,27 @@ T1 ── T2 ── T3 ── T4 ──┬── T5 ── T6 - **location**: `rust/limux-host-linux/src/shortcut_config.rs` (new), `rust/limux-host-linux/src/window.rs`, `rust/limux-host-linux/src/main.rs`, `rust/limux-host-linux/src/pane.rs` - **description**: Create the first-class host shortcut definition layer. Each definition should capture stable shortcut ID, default binding, runtime owner, whether it registers a GTK accelerator, the dispatch target used by capture-phase routing, and the human-readable label/tooltip name. This is the canonical registry that both `register_actions()` and `install_key_capture()` will consume. The layer should also decide which actions remain direct helper dispatches and which are backed by `gio::SimpleAction`. - **validation**: There is one authoritative metadata table for host shortcuts, and every current shortcut from T1 maps to exactly one runtime dispatch target and one visibility policy. -- **status**: Not Completed -- **log**: -- **files edited/created**: +- **status**: Completed +- **log**: Verified existing branch state rather than re-implementing. `rust/limux-host-linux/src/shortcut_config.rs` already provides the canonical host shortcut metadata layer with stable IDs, config keys, action names, labels, GTK registration policy, and runtime command targets. Validation command: `cargo test -p limux-host-linux shortcut_config::tests -- --nocapture` passed, confirming the 25-definition table, uniqueness invariants, the GTK accelerator subset, and canonical runtime command mapping. Non-blocking note for follow-up: `find_by_action_name` is currently unused and triggers a `dead_code` warning. +- **files edited/created**: `rust/limux-host-linux/src/shortcut_config.rs`, `shortcut-remap-plan.md` ### T3: Implement Config Schema, Path Resolution, and Validation Rules - **depends_on**: [T2] - **location**: `rust/limux-host-linux/src/shortcut_config.rs` (new), `rust/limux-host-linux/Cargo.toml` - **description**: Implement the dedicated host-side shortcut config loader and merger. The config file should live at `dirs::config_dir()/limux/config.json` with deterministic overrides for tests. The schema should support omitted values for defaults and empty-string or `null` values for explicit unbinding. Make the contract explicit for these cases: `config_dir()` returning `None`, unreadable files, invalid JSON, unknown shortcut IDs, duplicate active bindings, malformed bindings, and any binding that cannot be represented consistently across GTK accelerator registration and capture-phase normalization. Use clear logging plus fallback-to-default behavior for runtime file/load failures, and fail validation for ambiguous active duplicate bindings. - **validation**: The loader resolves the expected config path, merges overrides over defaults, preserves explicit unbinds, warns or errors exactly as specified for invalid inputs, and always returns a deterministic effective registry. -- **status**: Not Completed -- **log**: -- **files edited/created**: +- **status**: Completed +- **log**: Verified existing branch state rather than re-implementing. `rust/limux-host-linux/src/shortcut_config.rs` already resolves `dirs::config_dir()/limux/config.json`, loads JSON overrides under the top-level `shortcuts` key, supports explicit unbinding via `null` or empty string, warns on unknown IDs, falls back to defaults on missing files and invalid JSON, and rejects duplicate active bindings before runtime use. Validation command: `cargo test -p limux-host-linux shortcut_config::tests -- --nocapture` passed. +- **files edited/created**: `rust/limux-host-linux/src/shortcut_config.rs`, `shortcut-remap-plan.md` ### T4: Add Unit Tests for Config Loading and Normalization - **depends_on**: [T3] - **location**: `rust/limux-host-linux/src/shortcut_config.rs` - **description**: Add focused unit tests for config path derivation, default loading when no file exists, override application, explicit unbinding, invalid JSON fallback, unknown shortcut IDs, duplicate-binding rejection, malformed accelerator rejection, and normalization round-trips between stored values and runtime representations. Keep these tests pure and tempdir-driven so they do not depend on GTK startup. - **validation**: `cargo test -p limux-host-linux` covers the config contract and fails if loader behavior regresses on any supported edge case. -- **status**: Not Completed -- **log**: -- **files edited/created**: +- **status**: Completed +- **log**: Verified existing branch state rather than re-implementing. The targeted suite in `rust/limux-host-linux/src/shortcut_config.rs` already covers path derivation, normalized shortcut round-trips, override application, explicit unbinding, unknown ID warnings, duplicate-binding rejection, invalid JSON fallback, missing-file defaults, GTK accelerator exposure for unbound actions, and runtime combo-to-command routing. Validation command: `cargo test -p limux-host-linux shortcut_config::tests -- --nocapture` passed with 12 tests. +- **files edited/created**: `rust/limux-host-linux/src/shortcut_config.rs`, `shortcut-remap-plan.md` ### T5: Switch GTK Accelerators and Capture-Phase Dispatch to the Same Registry - **depends_on**: [T4] From 3f17cba83facc97f043bbe9c9a954ca69c3c21de Mon Sep 17 00:00:00 2001 From: FreeMeWat Date: Tue, 24 Mar 2026 17:05:19 -0600 Subject: [PATCH 07/81] host: route shortcut capture through registry --- rust/limux-host-linux/src/window.rs | 99 +++++++++++++++++++++++++++-- shortcut-remap-plan.md | 6 +- 2 files changed, 95 insertions(+), 10 deletions(-) diff --git a/rust/limux-host-linux/src/window.rs b/rust/limux-host-linux/src/window.rs index 7447f082..e81703e5 100644 --- a/rust/limux-host-linux/src/window.rs +++ b/rust/limux-host-linux/src/window.rs @@ -863,12 +863,10 @@ fn install_key_capture(window: &adw::ApplicationWindow, state: &State) { let state = state.clone(); key_controller.connect_key_pressed(move |_, keyval, _keycode, modifier| { - let matched = shortcut_config::NormalizedShortcut::from_gdk_key(keyval, modifier) - .map(|shortcut| shortcut.to_runtime_combo()) - .and_then(|combo| { - let s = state.borrow(); - s.shortcuts.command_for_runtime_combo(&combo) - }) + let matched = { + let s = state.borrow(); + shortcut_command_from_key_event(&s.shortcuts, keyval, modifier) + } .map(|command| { dispatch_shortcut_command(&state, command); true @@ -885,6 +883,16 @@ fn install_key_capture(window: &adw::ApplicationWindow, state: &State) { window.add_controller(key_controller); } +fn shortcut_command_from_key_event( + shortcuts: &ResolvedShortcutConfig, + keyval: gtk::gdk::Key, + modifier: gtk::gdk::ModifierType, +) -> Option { + shortcut_config::NormalizedShortcut::from_gdk_key(keyval, modifier) + .map(|shortcut| shortcut.to_runtime_combo()) + .and_then(|combo| shortcuts.command_for_runtime_combo(&combo)) +} + fn dispatch_shortcut_command(state: &State, command: ShortcutCommand) { match command { ShortcutCommand::NewWorkspace => add_workspace(state, None), @@ -2210,7 +2218,12 @@ fn mark_workspace_unread(state: &State, ws_id: &str) { #[cfg(test)] mod tests { - use super::{clamp_workspace_insert_index_for_pinning, favorites_prefix_len}; + use super::{ + clamp_workspace_insert_index_for_pinning, favorites_prefix_len, + shortcut_command_from_key_event, + }; + use crate::shortcut_config::{default_shortcuts, resolve_shortcuts_from_str, ShortcutCommand}; + use super::gtk::gdk; #[test] fn favorites_prefix_len_counts_only_leading_favorites() { @@ -2236,4 +2249,76 @@ mod tests { clamp_workspace_insert_index_for_pinning(&after_removal, true, after_removal.len()); assert_eq!(clamped, 2); } + + #[test] + fn shortcut_command_from_key_event_uses_default_registry_bindings() { + let shortcuts = default_shortcuts(); + + assert_eq!( + shortcut_command_from_key_event( + &shortcuts, + gdk::Key::T, + gdk::ModifierType::CONTROL_MASK + ), + Some(ShortcutCommand::NewTerminal) + ); + assert_eq!( + shortcut_command_from_key_event( + &shortcuts, + gdk::Key::Page_Down, + gdk::ModifierType::CONTROL_MASK + ), + Some(ShortcutCommand::NextWorkspace) + ); + } + + #[test] + fn shortcut_command_from_key_event_honors_remaps_and_disables_old_binding() { + let shortcuts = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "toggle_sidebar": "b" + } + }"#, + ) + .unwrap(); + + assert_eq!( + shortcut_command_from_key_event( + &shortcuts, + gdk::Key::B, + gdk::ModifierType::CONTROL_MASK + ), + None + ); + assert_eq!( + shortcut_command_from_key_event( + &shortcuts, + gdk::Key::B, + gdk::ModifierType::CONTROL_MASK | gdk::ModifierType::ALT_MASK + ), + Some(ShortcutCommand::ToggleSidebar) + ); + } + + #[test] + fn shortcut_command_from_key_event_respects_explicit_unbinds() { + let shortcuts = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "toggle_sidebar": null + } + }"#, + ) + .unwrap(); + + assert_eq!( + shortcut_command_from_key_event( + &shortcuts, + gdk::Key::B, + gdk::ModifierType::CONTROL_MASK + ), + None + ); + } } diff --git a/shortcut-remap-plan.md b/shortcut-remap-plan.md index 9bf41026..ff900c45 100644 --- a/shortcut-remap-plan.md +++ b/shortcut-remap-plan.md @@ -78,9 +78,9 @@ T1 ── T2 ── T3 ── T4 ──┬── T5 ── T6 - **location**: `rust/limux-host-linux/src/window.rs`, `rust/limux-host-linux/src/pane.rs`, focused helper tests where appropriate - **description**: Remove hardcoded visible shortcut strings and derive tooltip/label text from the same effective registry used at runtime. This includes sidebar toggle strings in `window.rs` and pane action tooltips currently constructed through `icon_button()` in `pane.rs`. Add regression tests for tooltip rendering and runtime mapping helpers, including the highest-risk behavior: remaps, explicit unbinds, malformed config fallback, duplicate rejection, unknown IDs, normalization round-trips, and proof that old bindings are no longer intercepted once remapped or unbound. - **validation**: Tooltips and labels reflect remapped shortcuts, unbound actions omit shortcut suffixes, and tests fail if a hardcoded host shortcut hint or stale binding path is reintroduced. -- **status**: Not Completed -- **log**: -- **files edited/created**: +- **status**: Completed +- **log**: Verified that the current branch already loads the effective shortcut registry once in `rust/limux-host-linux/src/main.rs`, applies GTK accelerators from `gtk_accel_entries()`, and passes the resolved registry into `build_window(...)`. Verified that `rust/limux-host-linux/src/window.rs` already registers all shortcut actions from the canonical definitions and routes capture-phase key events through the registry instead of the old hardcoded `match`. Added the missing helper `shortcut_command_from_key_event(...)` and RED->GREEN tests that prove default bindings resolve, remapped bindings disable the old combo, and explicit unbinds stop interception. RED command: `cargo test -p limux-host-linux shortcut_command_from_key_event -- --nocapture` failed first because the helper did not exist. GREEN commands: `cargo test -p limux-host-linux shortcut_command_from_key_event -- --nocapture`, `cargo test -p limux-host-linux`, and `cargo build -p limux-host-linux --features webkit` all passed after wiring the helper and tests. +- **files edited/created**: `rust/limux-host-linux/src/window.rs`, `shortcut-remap-plan.md` ## Parallel Execution Groups From 4917045d4d940550bc5f06d498b9290770a0b274 Mon Sep 17 00:00:00 2001 From: FreeMeWat Date: Tue, 24 Mar 2026 17:13:00 -0600 Subject: [PATCH 08/81] host: derive shortcut tooltips from registry --- rust/limux-host-linux/src/pane.rs | 86 ++++++++++++++- rust/limux-host-linux/src/shortcut_config.rs | 105 +++++++++++++++++-- rust/limux-host-linux/src/window.rs | 53 +++++++++- shortcut-remap-plan.md | 4 +- 4 files changed, 230 insertions(+), 18 deletions(-) diff --git a/rust/limux-host-linux/src/pane.rs b/rust/limux-host-linux/src/pane.rs index edf3e460..c21ba576 100644 --- a/rust/limux-host-linux/src/pane.rs +++ b/rust/limux-host-linux/src/pane.rs @@ -12,6 +12,7 @@ use gtk::prelude::*; use gtk4 as gtk; use crate::layout_state::{PaneState, TabContentState, TabState as SavedTabState}; +use crate::shortcut_config::{ResolvedShortcutConfig, ShortcutId}; use crate::terminal::{self, TerminalCallbacks}; // --------------------------------------------------------------------------- @@ -148,6 +149,7 @@ pub const PANE_CSS: &str = r#" pub fn create_pane( callbacks: Rc, + shortcuts: Rc, working_directory: Option<&str>, initial_state: Option<&PaneState>, ) -> gtk::Box { @@ -187,11 +189,30 @@ pub fn create_pane( .spacing(1) .build(); - let new_term_btn = icon_button("utilities-terminal-symbolic", "New terminal tab"); - let new_browser_btn = icon_button("limux-globe-symbolic", "New browser tab"); - let split_h_btn = icon_button("limux-split-horizontal-symbolic", "Split right"); - let split_v_btn = icon_button("limux-split-vertical-symbolic", "Split down"); - let close_btn = icon_button("window-close-symbolic", "Close pane"); + let new_term_btn = icon_button( + "utilities-terminal-symbolic", + &pane_action_tooltip(&shortcuts, "New terminal tab", Some(ShortcutId::NewTerminal)), + ); + let new_browser_btn = icon_button( + "limux-globe-symbolic", + &pane_action_tooltip(&shortcuts, "New browser tab", None), + ); + let split_h_btn = icon_button( + "limux-split-horizontal-symbolic", + &pane_action_tooltip(&shortcuts, "Split right", Some(ShortcutId::SplitRight)), + ); + let split_v_btn = icon_button( + "limux-split-vertical-symbolic", + &pane_action_tooltip(&shortcuts, "Split down", Some(ShortcutId::SplitDown)), + ); + let close_btn = icon_button( + "window-close-symbolic", + &pane_action_tooltip( + &shortcuts, + "Close pane", + Some(ShortcutId::CloseFocusedPane), + ), + ); actions.append(&new_term_btn); actions.append(&new_browser_btn); @@ -393,6 +414,16 @@ fn icon_button(icon_name: &str, tooltip: &str) -> gtk::Button { btn } +fn pane_action_tooltip( + shortcuts: &ResolvedShortcutConfig, + base: &str, + shortcut_id: Option, +) -> String { + shortcut_id + .map(|id| shortcuts.tooltip_text(id, base)) + .unwrap_or_else(|| base.to_string()) +} + /// Create a split-pane icon button with two rectangles separated by a divider. /// Horizontal = left|right panes, Vertical = top/bottom panes. #[allow(dead_code)] @@ -1396,3 +1427,48 @@ fn create_browser_widget( (placeholder.upcast(), "Browser".to_string()) } + +#[cfg(test)] +mod tests { + use super::pane_action_tooltip; + use crate::shortcut_config::{default_shortcuts, resolve_shortcuts_from_str, ShortcutId}; + + #[test] + fn pane_action_tooltip_reflects_remaps_and_unbinds() { + let defaults = default_shortcuts(); + assert_eq!( + pane_action_tooltip(&defaults, "New terminal tab", Some(ShortcutId::NewTerminal)), + "New terminal tab (Ctrl+T)" + ); + assert_eq!( + pane_action_tooltip(&defaults, "New browser tab", None), + "New browser tab" + ); + + let remapped = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "split_right": "d" + } + }"#, + ) + .unwrap(); + assert_eq!( + pane_action_tooltip(&remapped, "Split right", Some(ShortcutId::SplitRight)), + "Split right (Ctrl+Alt+D)" + ); + + let unbound = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "close_focused_pane": null + } + }"#, + ) + .unwrap(); + assert_eq!( + pane_action_tooltip(&unbound, "Close pane", Some(ShortcutId::CloseFocusedPane)), + "Close pane" + ); + } +} diff --git a/rust/limux-host-linux/src/shortcut_config.rs b/rust/limux-host-linux/src/shortcut_config.rs index b4a8688c..7178d549 100644 --- a/rust/limux-host-linux/src/shortcut_config.rs +++ b/rust/limux-host-linux/src/shortcut_config.rs @@ -465,6 +465,27 @@ impl NormalizedShortcut { parts.push(self.key.as_str()); parts.join("+") } + + pub fn to_display_label(&self) -> String { + let mut parts = Vec::new(); + if self.ctrl { + parts.push("Ctrl".to_string()); + } + if self.alt { + parts.push("Alt".to_string()); + } + if self.meta { + parts.push("Meta".to_string()); + } + if self.shift { + parts.push("Shift".to_string()); + } + if self.super_key { + parts.push("Super".to_string()); + } + parts.push(display_key_label(&self.key)); + parts.join("+") + } } impl ResolvedShortcut { @@ -494,16 +515,22 @@ impl ResolvedShortcutConfig { .map(|shortcut| shortcut.definition.command) } - pub fn find_by_id(&self, id: ShortcutId) -> Option<&ResolvedShortcut> { - self.shortcuts - .iter() - .find(|shortcut| shortcut.definition.id == id) + pub fn display_label_for_id(&self, id: ShortcutId) -> Option { + self.find_by_id(id) + .and_then(|shortcut| shortcut.binding.as_ref()) + .map(NormalizedShortcut::to_display_label) } - pub fn find_by_action_name(&self, action_name: &str) -> Option<&ResolvedShortcut> { + pub fn tooltip_text(&self, id: ShortcutId, base: &str) -> String { + self.display_label_for_id(id) + .map(|label| format!("{base} ({label})")) + .unwrap_or_else(|| base.to_string()) + } + + pub fn find_by_id(&self, id: ShortcutId) -> Option<&ResolvedShortcut> { self.shortcuts .iter() - .find(|shortcut| shortcut.definition.action_name == action_name) + .find(|shortcut| shortcut.definition.id == id) } pub fn find_by_runtime_combo(&self, combo: &str) -> Option<&ResolvedShortcut> { @@ -698,6 +725,37 @@ fn runtime_key_to_gtk_key(key: &str) -> String { } } +fn display_key_label(key: &str) -> String { + match key { + "page_up" => "Page Up".to_string(), + "page_down" => "Page Down".to_string(), + "left" => "Left".to_string(), + "right" => "Right".to_string(), + "up" => "Up".to_string(), + "down" => "Down".to_string(), + "enter" => "Enter".to_string(), + "escape" => "Esc".to_string(), + "tab" => "Tab".to_string(), + other if other.chars().count() == 1 => other.to_ascii_uppercase(), + other => other + .split('_') + .filter(|part| !part.is_empty()) + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + Some(first) => { + let mut label = first.to_ascii_uppercase().to_string(); + label.push_str(chars.as_str()); + label + } + None => String::new(), + } + }) + .collect::>() + .join(" "), + } +} + #[cfg(test)] mod tests { use super::*; @@ -885,4 +943,39 @@ mod tests { Some(ShortcutCommand::ActivateLastWorkspace) ); } + + #[test] + fn resolved_shortcuts_format_tooltip_text_and_omit_unbound_suffixes() { + let defaults = default_shortcuts(); + assert_eq!( + defaults.tooltip_text(ShortcutId::ToggleSidebar, "Toggle Sidebar"), + "Toggle Sidebar (Ctrl+B)" + ); + + let remapped = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "toggle_sidebar": "b" + } + }"#, + ) + .unwrap(); + assert_eq!( + remapped.tooltip_text(ShortcutId::ToggleSidebar, "Toggle Sidebar"), + "Toggle Sidebar (Ctrl+Alt+B)" + ); + + let unbound = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "toggle_sidebar": null + } + }"#, + ) + .unwrap(); + assert_eq!( + unbound.tooltip_text(ShortcutId::ToggleSidebar, "Toggle Sidebar"), + "Toggle Sidebar" + ); + } } diff --git a/rust/limux-host-linux/src/window.rs b/rust/limux-host-linux/src/window.rs index e81703e5..f1d88226 100644 --- a/rust/limux-host-linux/src/window.rs +++ b/rust/limux-host-linux/src/window.rs @@ -11,7 +11,7 @@ use crate::layout_state::{ WorkspaceState, }; use crate::pane::{self, PaneCallbacks}; -use crate::shortcut_config::{self, ResolvedShortcutConfig, ShortcutCommand}; +use crate::shortcut_config::{self, ResolvedShortcutConfig, ShortcutCommand, ShortcutId}; // --------------------------------------------------------------------------- // State @@ -622,7 +622,7 @@ pub fn build_window(app: &adw::Application, shortcuts: Rc