Remappable Vim keybindings (config + Settings editor)#8
Open
KangaZero wants to merge 7 commits into
Open
Conversation
KangaZero
force-pushed
the
feat/remappable-keybindings
branch
from
June 21, 2026 09:32
74fec5b to
9f32f62
Compare
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
force-pushed
the
feat/remappable-keybindings
branch
from
June 21, 2026 10:01
9f32f62 to
11b1f09
Compare
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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. OptionalkeymapsonConfig(liketheme).nil), rather than duplicating it — except digit keys, which always feed the count buffer (10j). Swaps resolve both ways.KeyDispatchresolves the pressed physical key → canonical char once and feeds it (via newctx.canonicalAsciiKey/…Base) only tohandleNormalMode, whose ~60case "h":literals are unchanged. Command-line typing / mark+register names / menu search keep reading raw input, so text entry is never remapped.keymaps.toggleActivation, default"e");KeyDispatch+ theKeyEventTapswallow flag are key-agnostic.NeoMouseStategains@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)
KeyChord.compose/decompose(neomouseUtils) — so$shows as4+ ⇧ and toggling recomposes the stored char (what you compose equals what the tap produces). Conflicts (two actions → one key) flagged inline.KeymapWriterpersists[keymaps]: replaces it in place when it precedes the theme block; relocates it before[theme.*]only when absent or sitting after theme (whereThemeWriterwould delete it). Reset restores the identity map.settings.tomlships a documented[keymaps]block; schema validates akeymapsobject (single-char values).Tests (+12 → 132)
Default-identity no-regression proof, override/reverse resolution, freed-default + swap semantics, conflict + digit-target guards,
KeyChordround-trip, and[keymaps]decode/validation (unknown key, multi-char, digit target, absent → nil). Build + strict lint + release build clean.