feat: Reliable keymap import/export (refines upstream PR #171)#1
Merged
Conversation
Adds export (download) and import (upload) buttons to the app header toolbar, allowing users to save their keymap as a .keymap devicetree file and load one back onto the device. Export generates a formatted .keymap file with column-aligned bindings. Import parses .keymap files, resolves ZMK keycode names to HID usage codes, and applies bindings to the connected device via RPC.
Replace icon-only Download and Upload buttons with text labels for better clarity and accessibility in the keymap import/export toolbar. Co-Authored-By: Claude <noreply@anthropic.com>
Pulls in zmkfirmware#171 by @max-hill-4 (max-hill-4) as the starting point for this fork's keymap import/export feature. The PR has been open since 2026-04-16 without maintainer review; merging here to iterate while remaining ready to send refinements back upstream. Original work © max-hill-4, licensed Apache-2.0, retained intact in this merge commit. Co-Authored-By: max-hill-4 <max-hill-4@users.noreply.github.com> Co-Authored-By: Claude <noreply@anthropic.com>
Add src/Toast.tsx exposing a ToastProvider + useToast hook with
kind-aware styling (success / error / warning / info), an optional
`action` field rendered as a bold-red follow-up line for messages
that require the user to take a next step (e.g. press Save), and
auto-dismiss durations tuned per kind.
Wrap App with the provider in main.tsx so all downstream components
can call notify() without prop-drilling. Foundation for replacing
the upstream `window.alert("Failed to connect…")` TODO and for
giving import/export observable outcomes instead of silent failure.
Co-Authored-By: Claude <noreply@anthropic.com>
…er drop PR zmkfirmware#171's keymap parser had three independent issues: 1. The layer-block regex was non-greedy across `[\s\S]*?bindings`, which caused its very first match to start at `keymap { …` and walk through `compatible = "zmk,keymap"; Base { …` before stopping on Base's `bindings = <…>;`. Match #1's layerName came out as "keymap" — correctly skipped — but the Base block had already been consumed, so the next match started at Windows and Base was silently dropped. On a Cornix .keymap the parser produced 7 layers instead of 8 and Base never made it through import. Constrain what's allowed between `{` and `bindings = <` to just an optional `display-name = "…";`. 2. The keycode lookup zmkAliases iteration unconditionally overwrote any keycodeLookup entry, including ones that the `hid-usage-name-overrides.json` short aliases bound to keypad-page usages (KP_N5 etc.). Common ZMK names like N5 / COMMA / FSLH then mapped to the wrong codes and could not be reverse- looked up, so exports for those keys fell back to integers like `&kp 458786`. Replace with a single canonical `ZMK_KEYCODES` table — `[name, page, usage]` tuples covering A-Z, N0-N9, punctuation, F1-F24, system keys, arrows, keypad, modifiers, and the consumer-page media / brightness keycodes. Forward (keycodeLookup) and reverse (codeToZmkName) maps are both derived from it, so the canonical name wins every conflict. 3. Bindings whose keycode parameter carried implicit-modifier flags in the upper byte (e.g. LS(N1) = 0x0207001E for "!") came out as bare integers because the reverse lookup only knew the unshifted forms. Add MODIFIER_FLAGS, fold the bits out of formatBindingParam into LS()/LC()/LA()/LG() (and right-hand variants) wrappers, and teach parseBindingParam to undo the same wrap. Additionally: - Behavior reference name table extended to ZMK's full built-in set (mkp, mmv, msc, bt, out, ext_power, bl, rgb_ug, bootloader, reset, soft_off, caps_word, key_repeat, gresc, studio_unlock, …). - Export `dtsRefForDisplayName(displayName)` so callers can translate RPC `displayName` ("Key Press") into the DTS reference ("kp"), passing user-defined behavior names through unchanged. - Export `formatBindingParam` and `parseBindingParam` as the one-way and two-way primitives for binding serialization. - HID-page names and ZMK punctuation shortcuts (",", "[", "!") are added as parse-only secondary aliases via a `has`-gated insert so they never displace canonical ZMK names. Co-Authored-By: Claude <noreply@anthropic.com>
…eliable PR zmkfirmware#171 shipped a starting point for keymap import/export but it was not usable on a non-trivial keymap: the export emitted invalid DTS (`&Key Press 458795` style), the import silently failed on trans/none entries, both flows were entirely silent on success and failure, and a handful of behaviors got rejected by firmware with no explanation. Rework the App-side wiring on top of the new keymap-parser helpers to make import / export round-trip a real keymap. Export: - Use `dtsRefForDisplayName` and `formatBindingParam` to emit valid ZMK reference syntax: `&kp EQUAL`, `&mo 4`, `&mkp 1`, `&bt 3 1`, `&kp LS(N1)`, etc. instead of the previous `&Key Press 458795` / `&Momentary Layer 4` / `&kp 34013214`. - Stamp the filename with an ISO timestamp so repeated exports don't collide as `Cornix (1).keymap`, `Cornix (2).keymap`, etc. - Notify the user on success and on failure. Import: - Build behaviorNameToId from the device's full behavior list, including DTS reference aliases (`kp`, `mo`, …) alongside the RPC displayName variants. - Drop PR zmkfirmware#171's hardcoded skip for trans/none — they have valid behavior IDs and accept setLayerBinding with (0, 0). The previous skip prevented importing a file that wanted a position to revert to `&trans` if the user had manually changed it. - Check the setLayerBinding response code instead of ignoring it. An OK response counts as applied. A failure with `metadata: []` on the behavior is classified as "preserved" (Studio API can't edit this behavior; the device keeps its saved value). A failure with non-empty metadata is a real parameter mismatch and surfaces to both the toast and the console with the offending behavior's metadata attached, so the next debugging round has data. - Surface counts in the toast: "Imported X / preserved Y / skipped Z / rejected W", with names of read-only behaviors listed for the preserved set (mouse_move, mouse_scroll, ext_power on the tested Cornix build). User-facing copy: - Reword Save / Discard / Import toasts so the model is honest: `setLayerBinding` writes the working keymap in device RAM (which is what the keyboard uses live), `saveChanges` persists it to flash, `discardChanges` reverts the working state to flash. The import success message now reads "Changes are live on the device. Press Save to keep them after restart, or Discard to revert.", Save shows "Committing keymap to device flash…" → "Keymap saved to flash. It will persist across restarts.", Discard shows "Reverted to last saved keymap." - Replace the upstream `window.alert("Failed to connect…")` with a notify("error", …) call routed through the Toast provider. - The action sentence on toasts that demand a follow-up is shown on its own line in bold red so users don't skim past it. Lint: - Sweep through the touched code with `eslint --fix` so PR zmkfirmware#171's prefer-const errors don't carry into the fork's lint baseline. Co-Authored-By: Claude <noreply@anthropic.com>
…eady matches PR zmkfirmware#171's import unconditionally called setLayerBinding for every binding parsed out of the file. Even when the file value was identical to the device's current value (e.g. the user re-imported their own export with no edits), the firmware still flipped its "unsaved changes" flag because *some* setLayerBinding had been called. Save then appeared armed for a no-op flash write, which is both confusing and bad for flash longevity. Diff before calling: fetch keymap.layers once, then per position compare {behaviorId, param1, param2} against the parsed binding. Skip the RPC when they match and count it as `unchanged`. Reword the toast accordingly: - Everything matched: "<file> already matches the device. No changes needed." (info) - Some real updates: "Updated N binding(s) from <file> (M already matched)." with the persist reminder. - Partial / rejected: "Updated N (M already matched), skipped X, rejected Y. …" The `applied` counter is renamed `updated` to match the new semantics. preserved / skipped / failed handling is unchanged. Co-Authored-By: Claude <noreply@anthropic.com>
|
Thanks for the feedback ! |
Owner
Author
|
Hey @max-hill-4, thanks for the kind reply, and even more thanks for the I want to dogfood the changes on my own keyboards for a while before |
numachang
added a commit
that referenced
this pull request
Jun 7, 2026
- formatBinding: when param1 matches no metadata set, emit just the behavior reference rather than borrowing the first set's params (avoids showing a param2 that doesn't apply). [review #1] - Behavior tiles are single-select, so make them `role="radio"` inside a `radiogroup` (consistent with the residual behavior chips) instead of `aria-pressed` toggles; native button + `title` keeps the visible label and drops the now-redundant tooltip. Search-result tiles get their own radiogroup. [review #2] - Annotate the two intentional exhaustive-deps effects with `eslint-disable-next-line` so the warnings clear and the intent is explicit. [review #4] Review #3 (declaration-order of the two tab-select effects) was info-only / no change requested; left as-is. Co-Authored-By: Claude <noreply@anthropic.com>
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.
Summary
Refines upstream zmkfirmware/zmk-studio#171 (by @max-hill-4) into a usable keymap import/export feature on the fork. Brings the round-trip from "barely works on toy keymaps" to "round-trips a real 50-key × 8-layer Cornix keymap with homerow mods, BT layers, and mouse-emulation bindings."
Origin & credit
Upstream PR zmkfirmware#171 has been open and awaiting maintainer review since 2026-04-16. This branch starts from a no-ff merge of PR zmkfirmware#171's two commits and layers refinements on top, keeping @max-hill-4's authorship in history.
What this branch fixes
PR zmkfirmware#171's import/export was a useful starting point but had several independent issues that surfaced when testing against a non-trivial keymap:
Status
Test plan
Generated with Claude Code.