Skip to content

feat(picker): Visual HID/behavior picker built on upstream PR #159#172

Closed
numachang wants to merge 16 commits into
zmkfirmware:mainfrom
numachang:feature/visual-picker
Closed

feat(picker): Visual HID/behavior picker built on upstream PR #159#172
numachang wants to merge 16 commits into
zmkfirmware:mainfrom
numachang:feature/visual-picker

Conversation

@numachang

Copy link
Copy Markdown

Summary

Personal-fork visual rework of the binding picker, layered on top of upstream zmkfirmware/zmk-studio#159 (Andrew Kannan, @awkannan).

  • Adopts PR Grid Picker for HID Usage #159's category-tabbed HID grid (453980d) with attribution preserved.
  • Adds a cross-tab search bar above the HID grid and replaces the layer-id <select> with a wrap-friendly button row (1d7de0e).
  • Behavior selector: dropdown → flat chip grid grouped by tier (ZMK Standard / Firmware Extension) and category (Basic / Layer / Hold-Tap / Mouse / System / Custom / Other) (931edf0).
  • HID grid: layer ANSI 60% / TKL function+nav cluster / numpad layouts on top of the category tabs, plus locale-neutral labels for Intl1-6 / Lang1-5 (the per-region glyphs were misleading on non-JP keyboards — left for a future locale overlay) (0e226bf).
  • Keymap preview: behavior-name badge, hold-tap stack (hold faded above bold tap), paired short labels (1 ! → stacked), shrink-to-fit for 5+ glyph labels, rotation-aware bounding box (rotated thumb keys no longer clip), and a notify() toast when the firmware rejects setLayerBinding (c3b5cad).
  • Follow-up fixes from self-review: undo path no longer silently swallows firmware rejections; activeParam resets to p1 when the active tab disappears after a behavior swap (50c47a6).

Upstream stance

This is a personal fork, intentionally minimal-diff and Apache-2.0. Apache notices and @awkannan's authorship for PR #159 are preserved. Individual pieces will be proposed upstream as separate PRs (rotation bbox fix, undo-rejection toast, etc. as small standalone bug fixes; the chip grid and physical layouts as opinionated UX proposals).

Test plan

Open npm run dev in real Chrome/Edge (Web Serial / BLE will not work in VSCode's embedded browser):

  • Pick &kp → HID grid auto-jumps to the Basic tab and renders the ANSI 60% layout
  • Toggle a modifier (e.g. Shft) → paired keys collapse to the shifted glyph (1 !!)
  • Pick &mt LSHFT A → keymap preview shows hold faded above bold tap, Hold/Tap tabs appear in the param picker
  • Pick a 2-param behavior, switch to the Tap tab, then swap to a 1-param behavior → no stale selectedKey warning
  • Type caps in the search bar → flat results grid filters across all tabs; clearing restores per-tab view
  • Open a layout with rotated thumb keys (Corne, Lily58) → auto zoom no longer clips the rotated keys
  • Assign a behavior the firmware can't write (e.g. mouse_move on a build without it) → error toast appears with hint text
  • Trigger the same rejection via Ctrl+Z on a successful binding restored to a now-rejected one → undo error toast appears (previously silent)

max-hill-4 and others added 16 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>
Rewrite the upstream-derived README to describe this as a personal,
work-in-progress fork of zmkfirmware/zmk-studio, with explicit credit
to the upstream project and a list of planned tweaks (keymap
import/export, type-to-search key picker, host-layout localization).

Add CLAUDE.md documenting the working stance for this fork:
upstream-respectful, minimal-diff, Apache-2.0 obligations preserved.

Co-Authored-By: Claude <noreply@anthropic.com>
This fork does not have the signing / release secrets configured in
the upstream repo (Apple, Azure Trusted Signing, NETLIFY_DEPLOY_HOOK,
ZMK_STUDIO_RELEASE_TOKEN), so the inherited workflows would fail on
every push and clutter the Actions tab without producing useful
artifacts.

- Remove release-please.yml entirely: depends on a custom release
  token and pushes to a 'prod' branch that does not exist here.
- Restrict tauri-build.yml to workflow_dispatch only, so desktop
  builds can still be triggered manually when needed.

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>
feat: Reliable keymap import/export (refines upstream PR zmkfirmware#171)
…picker

Squashes upstream zmkfirmware#159 by @awkannan into a single
commit. Replaces the HID usage dropdown with a categorized tab grid
(Letters / Numbers + Punctuation / Function + Navigation / Numpad /
Apps/Media/Special / International / Other) using react-aria-components
Tabs, and keeps the implicit modifier checkboxes above the grid.

- Renames src/hid-usage-name-overrides.json to src/hid-usage-metadata.json
  and extends entries with a "category" field plus richer labels (incl.
  Japanese/Korean IME-related keys).
- Adds hid_usage_get_metadata() that falls back to the canonical name
  stripped of the "Keyboard " prefix when no override is present.
- Updates HidUsageLabel to consume the new metadata accessor.

Credit: zmkfirmware#159 (Andrew Kannan, @awkannan).
Apache-2.0 license preserved.

Co-Authored-By: Andrew Kannan <andrew.kannan@gmail.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Layered on top of upstream PR zmkfirmware#159's category-tabbed grid:

- HidUsagePicker: a global filter input above the tabs. While the field
  has content, the tab list is replaced by a flat grid of all matching
  keys (matched against canonical HID name, short/med/long override
  labels, and category). The per-tab "Other" ComboBox is kept untouched
  so the upstream layout still works when the filter is empty.

- ParameterValuePicker: the layer-id <select> is replaced with a
  wrap-friendly row of buttons styled like the keycode grid, so picking
  a target layer for `mo` / `lt` / `to` matches the visual feel of the
  rest of the picker.

- keymap-parser: re-point the override JSON import at the renamed
  hid-usage-metadata.json (PR zmkfirmware#159 deleted the old name).

Co-Authored-By: Claude <noreply@anthropic.com>
Replace the single behavior dropdown with a flat chip grid grouped by
tier ("ZMK Standard" / "Firmware Extension") and category
("Basic / Layer / Hold-Tap / Mouse / System / Custom / Other"). Chips
within a category are reordered so the most common behaviors land first
("Key Press" first, "Transparent / None" last). The block label uses a
shared uppercase-tracked style so it lines up with the keycode picker.

- BehaviorParametersPicker: show Hold/Tap tabs only for 2-param bindings
  with a HID-typed param2, fall through to the picker directly for
  1-param, and disable the field for 0-param behaviors. Defaults to the
  first metadata set so param2 is visible even when param1 = 0.
- ParameterValuePicker: drop the native <select> for layer ids in favor
  of a wrap-friendly button row styled to match the keycode grid.

Co-Authored-By: Claude <noreply@anthropic.com>
Layer ANSI 60% / TKL function+nav cluster / numpad layouts on top of
upstream PR zmkfirmware#159's tab grid, so picking a keycode reads like a real
keyboard. Modifiers and CapsLk fall back to short labels ("L Shft",
"L Ctrl") and paired short labels ("1 !", "[ {") are stacked top-small /
bottom-large to match the physical legend.

- HidUsagePicker: split into "+ MODIFIER" (eight modifier column) and
  "KEY" (search + tabs + visual grid). Tabs: Basic (ANSI 60%),
  Function + Nav (TKL cluster), Numpad, Apps/Media/Special,
  International, Other. All tabs share a fixed min/max height so the
  panel doesn't jump when switching. Auto-jumps to the tab that holds
  the current value (or Basic when value is 0).
- Search bar above the tabs filters across every category and renders a
  flat result grid while populated; clears restore the per-tab view.
- hid-usage-metadata.json: drop JIS-only sub-labels from Intl1-6 and
  Lang1-5. Locale-specific glyphs were misleading on non-JP keyboards
  (e.g. Lang1/Lang2 type Hangul per spec but trigger Henkan/Muhenkan on
  JIS layouts), so labels stay neutral until a proper i18n overlay is
  introduced.

Co-Authored-By: Claude <noreply@anthropic.com>
Make the keymap preview faithful to the bound behavior at a glance and
keep the rendering stable across zoom, rotation, and key width.

- Key + Keymap: render the behavior name in a rounded badge at the top
  of each cap (clears with pt-3.5; no pad when no badge). Hold-tap
  bindings stack the hold value small/faded above the bold tap value;
  layer-type params render the layer name directly; constants and raw
  values get type-aware text sizes. Drop the hover scale/3D translate
  so popped keys don't overlap their neighbors.
- HidUsageLabel: render paired short labels ("1 !" → top-small "!" /
  base "1") and collapse to the shifted glyph alone when an implicit
  Shift modifier is set ("LS(N2)" → "@"). Auto-shrink labels longer
  than 4 glyphs so values like "Lang1" fit in 1U keys without ellipsis.
  Add a `compact` mode used by hold-tap previews where vertical room is
  scarce.
- PhysicalLayout: bbox the layout with rotation taken into account so
  rotated thumb keys no longer clip, and switch the scale wrapper to
  observe only the parent (element observation caused a feedback loop
  on zoom-to-fit).
- Keyboard: pin the bottom panel's grid row to a fixed width with
  overflow auto so picker tab changes don't jump the layout. Surface
  setLayerBinding rejections via a toast that names the behavior and
  hints that it may not be writable from Studio in this firmware build.

Co-Authored-By: Claude <noreply@anthropic.com>
Two follow-ups from a self-review of the visual-picker branch.

- Keyboard: the undo path of setLayerBinding had an empty else, so a
  firmware-rejected Ctrl+Z would silently leave the UI out of sync with
  the device. Mirror the do-path notify() with an undo-specific message.
- BehaviorParametersPicker: when the user switches from a 2-param
  behavior to a 1-param one while the Tap tab is active, activeParam
  stays at "p2" and feeds <Tabs> a key for a tab that no longer exists.
  Snap it back to "p1" via useEffect when hasParam2 flips false.

Co-Authored-By: Claude <noreply@anthropic.com>
@netlify

netlify Bot commented May 23, 2026

Copy link
Copy Markdown

Deploy Preview for zmk-studio ready!

Name Link
🔨 Latest commit 50c47a6
🔍 Latest deploy log https://app.netlify.com/projects/zmk-studio/deploys/6a1156c6373f950008e26c05
😎 Deploy Preview https://deploy-preview-172.preview.zmk.studio
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@numachang

Copy link
Copy Markdown
Author

Closing immediately — this PR was opened against the wrong base. It was intended as an internal PR on my personal fork (numachang/zmk-studio-tweaks), not against upstream. Apologies for the noise. The individual changes will be proposed upstream later as separate, smaller PRs.

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