Skip to content

Remappable Vim keybindings (config + Settings editor)#8

Open
KangaZero wants to merge 7 commits into
mainfrom
feat/remappable-keybindings
Open

Remappable Vim keybindings (config + Settings editor)#8
KangaZero wants to merge 7 commits into
mainfrom
feat/remappable-keybindings

Conversation

@KangaZero

Copy link
Copy Markdown
Owner

Adds user-remappable Vim keybindings via a new [keymaps] config section and a Settings → Keybindings editor, while keeping each binding's canonical US-ASCII Vim identity. Default == identity map, so a settings.toml without [keymaps] behaves byte-for-byte as before.

Mechanism — canonicalize once (no handler logic change)

  • Config.VimAsciiKeymap (neomouseConfig), keyed by canonical Vim char → physical key. Catalog of remappable keys (letters + symbols + 0); strict decode (unknown keys, multi-char values, and digit targets rejected — digits drive the count buffer); canonical(forPhysical:) reverse resolver. Optional keymaps on Config (like theme).
  • Remapping MOVES an action: a key whose action is rebound elsewhere stops firing its default (resolves to nil), rather than duplicating it — except digit keys, which always feed the count buffer (10j). Swaps resolve both ways.
  • KeyDispatch resolves the pressed physical key → canonical char once and feeds it (via new ctx.canonicalAsciiKey/…Base) only to handleNormalMode, whose ~60 case "h": literals are unchanged. Command-line typing / mark+register names / menu search keep reading raw input, so text entry is never remapped.
  • The ⌘E activate/deactivate chord is its own remappable key (keymaps.toggleActivation, default "e"); KeyDispatch + the KeyEventTap swallow flag are key-agnostic.
  • NeoMouseState gains @Published keymaps (init + hot-reload; reload resets any pending op so a mid-sequence rebind can't desync).

Settings editor (UI-only over the char model)

  • New Keybindings section. Each row is a base key + Shift/Option toggle buttons via KeyChord.compose/decompose (neomouseUtils) — so $ shows as 4 + ⇧ and toggling recomposes the stored char (what you compose equals what the tap produces). Conflicts (two actions → one key) flagged inline.
  • KeymapWriter persists [keymaps]: replaces it in place when it precedes the theme block; relocates it before [theme.*] only when absent or sitting after theme (where ThemeWriter would delete it). Reset restores the identity map.

settings.toml ships a documented [keymaps] block; schema validates a keymaps object (single-char values).

Tests (+12 → 132)

Default-identity no-regression proof, override/reverse resolution, freed-default + swap semantics, conflict + digit-target guards, KeyChord round-trip, and [keymaps] decode/validation (unknown key, multi-char, digit target, absent → nil). Build + strict lint + release build clean.

@KangaZero
KangaZero force-pushed the feat/remappable-keybindings branch from 74fec5b to 9f32f62 Compare June 21, 2026 09:32
Let users rebind any letter/symbol Vim key (and the ⌘-activation chord) via a
new [keymaps] config section + a Settings "Keybindings" editor, while keeping
each binding's canonical US-ASCII Vim identity. Default == identity map, so a
settings.toml without [keymaps] behaves byte-for-byte as before.

Mechanism — canonicalize once (no handler logic change):
- neomouseConfig: Config.VimAsciiKeymap (keyed by canonical Vim char →
  physical key) with a catalog of remappable keys, strict decode (unknown keys,
  multi-char values, and digit targets rejected — digits drive the count
  buffer), and canonical(forPhysical:) reverse resolver. Optional `keymaps` on
  Config (like theme).
- Remapping MOVES an action: a key whose action is rebound elsewhere stops
  firing its default (resolves to nil) rather than duplicating it — except digit
  keys, which always feed the count buffer (10j). Swaps resolve both ways.
- KeyDispatch resolves the pressed physical key → canonical char once and feeds
  it (via new ctx.canonicalAsciiKey/…Base) ONLY to handleNormalMode, whose
  `case "h":` literals are unchanged. Command typing / mark+register names /
  menu search keep reading raw input, so text entry is never remapped. A debug
  line logs each keystroke's original → remapped char.
- The ⌘E activate/deactivate chord is its own remappable key
  (keymaps.toggleActivation, default "e"); KeyDispatch + the swallow flag are
  key-agnostic.
- NeoMouseState gains @published keymaps (init + hot-reload; reload resets any
  pending op so a mid-sequence rebind can't desync).

Settings editor:
- New "Keybindings" section — one single-key field per action; conflicts (two
  actions → one key) flagged inline. setBinding logs each bind/reset/ignored
  digit.

Order-robust TOML writers (so users can arrange settings.toml sections freely):
- ThemeWriter now rewrites the [theme.*] run IN PLACE, preserving everything
  before AND after it — instead of truncating from the first [theme.*] to EOF.
  A section placed after the theme block (e.g. [keymaps]) now survives a Save.
- KeymapWriter replaces [keymaps] in place wherever the user put it (introducing
  it before the theme block only when absent); it no longer relocates an
  existing section.

settings.toml ships every default keybind explicitly (grouped, round-trips via
KeymapWriter, like the [theme.*] block); schema validates a `keymaps` object
(single-char values).

Tests (+9 → 129): default-identity no-regression proof, override/reverse
resolution, freed-default + swap semantics, conflict + digit-target guards,
[keymaps] decode/validation (unknown key, multi-char, digit target, absent →
nil), and a shipped-settings.toml decode check.
A bad settings.toml at startup previously only wrote a debug-log line, then fell
back to built-in defaults silently — the user got no visible signal (the
hot-reload path already toasts on failure, but startup didn't).

NeoMouseApp.sharedState now stashes any load/decode failure in
startupConfigError (resolvedURL == nil — i.e. first run with no file — is not an
error and is not stashed). notifyStartupConfigErrorIfNeeded(), called at the end
of launch setup once the overlay UI exists, toasts it ("settings.toml error —
using defaults: …"), deferred one runloop tick so the toast window is ready.

Still non-destructive and whole-file (one bad key → all built-in defaults); this
only closes the notification gap.
A behavior-focused companion to CLAUDE.md (which stays the build/test/release +
conventions doc). DEV.md documents how neomouse runs:
- the keystroke → action hot path (event tap → KeyDispatch → per-mode handler);
- the mode state machine, pending-operation sub-states, and the count buffer;
- permissions + the .defaultTap/.listenOnly tap flavors, swallow flag, re-enable;
- config resolution / strict validation / selective hot-reload + the keymap
  system;
- persistence: the DB (location, non-destructive init, models, sessions) and the
  three order-robust TOML writers;
- overlays + the Theme→SwiftUI bridge, ops/utils (mouse warp, layout-independent
  ASCII), and the launch/terminate/restart lifecycle;
- a "what happens when…" fallback/error table, plus the known gaps (temp-dir DB,
  no migrations, unused session knobs, whole-file config fallback, silent DB
  error swallowing).

Contents verified against the code via a multi-area exploration pass.
@KangaZero
KangaZero force-pushed the feat/remappable-keybindings branch from 9f32f62 to 11b1f09 Compare June 21, 2026 10:01
Saving from the Settings window failed with "no settings.toml found at any
resolved path" whenever no config file existed yet (e.g. a fresh dev run, or
before the bundled default has been deployed) — every writer bails on a nil
Config.resolvedURL.

Now the Save button retries: if the writers fail and no file is resolved, it
creates one at ~/.config/neomouse/settings.toml (SettingsBootstrap), seeded from
the bundled default template when available (a complete, valid config the
writers then overlay live state onto) or an empty file otherwise, then re-runs
the writers. Save logic refactored into a local runWriters() to share between
the first attempt and the retry.
Each UI Save appended another copy of the "# [theme.*] … (regenerated by
SettingsWindow)" comment block — 1, 2, 3 … per save — so the theme section
looked like it was being appended to rather than overwritten. (The actual
[theme.X] data was correctly replaced once; only the generated header grew.)

Cause: serializeTheme re-emits the header every save, and replacingThemeBlock
preserves everything above [theme.grid] — which after the first save includes
the previous header. Fix: droppingGeneratedHeader() truncates the preserved
prefix at the first previously-generated header block (matched on the unique
"(regenerated by SettingsWindow)" marker), so all accumulated copies are removed
and exactly one fresh header is written. Verified stable at 1 across repeated
saves; the shipped hand-written legend (no "(regenerated…)") is untouched.
A bad config surfaced as a raw dump: "Failed to decode TOML at …:
DecodingError.dataCorrupted: Data was corrupted. Debug description: The given
data was not valid TOML.. Underlying error: (Line 148) Ill-formed key." — the
one useful part (the line + reason) was buried.

Config.LoadError.description now unwraps the DecodingError into a single line:
a raw TOML parse failure surfaces its underlyingError (which carries the
line/column, e.g. "(Line 148) Ill-formed key."); our own strict-validation
failures surface their friendly debugDescription (e.g. "keymaps.h cannot be
bound to a digit key …"), plus the key path when present. Result:
"Invalid TOML in <path>: (Line 148) Ill-formed key." This flows through to the
startup + hot-reload failure toasts. +1 test asserting the message reads cleanly
(no DecodingError/"Debug description" noise).
that takes in ToastType<.info, .warning, .error> defaults to .info to
determine the SF symbol to show on the Toast
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant