Skip to content

feat: Reliable keymap import/export (refines upstream PR #171)#1

Merged
numachang merged 7 commits into
mainfrom
feature/keymap-import-export
May 22, 2026
Merged

feat: Reliable keymap import/export (refines upstream PR #171)#1
numachang merged 7 commits into
mainfrom
feature/keymap-import-export

Conversation

@numachang

Copy link
Copy Markdown
Owner

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:

  1. Export emitted invalid ZMK DTS. Used the RPC displayName directly as a behavior reference (and-kp Press 458795) and wrote integer keycodes. Now emits and-kp EQUAL, and-mo 4, and-kp LS(N1), etc. via a canonical ZMK_KEYCODES table + implicit-modifier wrapping.
  2. Parser silently dropped the Base layer because the layer-block regex matched the outer keymap node first. Constrained the regex.
  3. Import had no UI feedback. Added a minimal toast system with a bold-red action line for follow-ups.
  4. Save / Discard were silent. Now show progress / success / error toasts.
  5. Some behaviors silently rejected by firmware. ext_power, mouse_move, mouse_scroll ship metadata=[] on tested Cornix firmware. Try-and-recover: classify as preserved instead of rejected.
  6. trans/none unconditionally skipped on import. Now applied like any other behavior.
  7. No-op imports falsely armed Save. Now diffs against current device state and skips matching bindings entirely.

Status

Test plan

  • Export -> file is valid ZMK DTS
  • Re-import same file -> "already matches" info toast, Save / Discard stay disabled
  • Manual edit + re-import original -> "Updated 1 binding (N already matched)", key restored, Save enabled
  • Implicit modifiers (LS(N1), LS(EQUAL) etc.) round-trip
  • Read-only behaviors reported as preserved
  • npm run build clean
  • Sustained real-world use (the reason this is not yet proposed upstream)

Generated with Claude Code.

max-hill-4 and others added 7 commits April 16, 2026 20:47
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>
@numachang numachang merged commit 826d751 into main May 22, 2026
@numachang numachang deleted the feature/keymap-import-export branch May 22, 2026 18:29
@max-hill-4

Copy link
Copy Markdown

Thanks for the feedback !

@numachang

Copy link
Copy Markdown
Owner Author

Hey @max-hill-4, thanks for the kind reply, and even more thanks for the
original PR — this whole fork is built on top of it.

I want to dogfood the changes on my own keyboards for a while before
suggesting anything back to PR zmkfirmware#171 or upstream. When I'm confident the
design holds up, I'll come back with specific suggestions. Please don't
wait on this fork — push your PR forward however suits you.

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>
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.

2 participants