From 23ac5dcaa87baf1891cd0adf7014260678739498 Mon Sep 17 00:00:00 2001 From: max Date: Thu, 16 Apr 2026 20:47:40 +0100 Subject: [PATCH 01/14] feat: Add keymap import and export buttons 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. --- src/App.tsx | 156 +++++++++++++++++++++- src/AppHeader.tsx | 28 +++- src/keymap-parser.ts | 302 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 484 insertions(+), 2 deletions(-) create mode 100644 src/keymap-parser.ts diff --git a/src/App.tsx b/src/App.tsx index e09ed0cb..de4625a5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,8 @@ import { call_rpc } from "./rpc/logging"; import type { Notification } from "@zmkfirmware/zmk-studio-ts-client/studio"; import { ConnectionState, ConnectionContext } from "./rpc/ConnectionContext"; -import { Dispatch, useCallback, useEffect, useState } from "react"; +import { Dispatch, useCallback, useEffect, useRef, useState } from "react"; +import { parseKeymapFile } from "./keymap-parser"; import { ConnectModal, TransportFactory } from "./ConnectModal"; import type { RpcTransport } from "@zmkfirmware/zmk-studio-ts-client/transport/index"; @@ -271,6 +272,150 @@ function App() { doDisconnect(); }, [conn]); + const exportKeymap = useCallback(() => { + async function doExport() { + if (!conn.conn) { + return; + } + + let keymapResp = await call_rpc(conn.conn, { keymap: { getKeymap: true } }); + let keymap = keymapResp?.keymap?.getKeymap; + if (!keymap) { + console.error("Failed to fetch keymap for export", keymapResp); + return; + } + + let behaviorList = await call_rpc(conn.conn, { behaviors: { listAllBehaviors: true } }); + let behaviorIds = behaviorList?.behaviors?.listAllBehaviors?.behaviors || []; + + const behaviors: Record = {}; + for (const id of behaviorIds) { + let details = await call_rpc(conn.conn, { behaviors: { getBehaviorDetails: { behaviorId: id } } }); + let d = details?.behaviors?.getBehaviorDetails; + if (d) { + behaviors[d.id] = d.displayName; + } + } + + let content = `#include \n#include \n\n/ {\n keymap {\n compatible = "zmk,keymap";\n\n`; + + for (const layer of keymap.layers) { + const layerName = layer.name?.replace(/[^a-zA-Z0-9_]/g, "_") || `layer_${layer.id}`; + content += ` ${layerName} {\n bindings = <\n`; + const bindingStrs = layer.bindings.map((b: { behaviorId: number; param1: number; param2: number }) => { + const name = behaviors[b.behaviorId] || `&unknown_${b.behaviorId}`; + const base = name.replace(/^&/, ""); + const parts = [`&${base}`]; + if (b.param1 !== 0) parts.push(String(b.param1)); + if (b.param2 !== 0) parts.push(String(b.param2)); + return parts.join(" "); + }); + // Pad all bindings to the same width for column alignment + const maxLen = Math.max(...bindingStrs.map((s: string) => s.length)); + const padded = bindingStrs.map((s: string) => s.padEnd(maxLen)); + content += padded.map((s: string) => ` ${s}`).join("\n") + "\n"; + content += ` >;\n };\n\n`; + } + + content += ` };\n};\n`; + + const blob = new Blob([content], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${connectedDeviceName || "zmk"}.keymap`; + a.click(); + URL.revokeObjectURL(url); + } + + doExport(); + }, [conn, connectedDeviceName]); + + const fileInputRef = useRef(null); + + const importKeymap = useCallback(() => { + if (!conn.conn) return; + fileInputRef.current?.click(); + }, [conn]); + + const handleFileImport = useCallback(() => { + async function doImport(file: File) { + if (!conn.conn) return; + + const text = await file.text(); + const parsedLayers = parseKeymapFile(text); + if (parsedLayers.length === 0) { + console.error("No layers found in imported keymap"); + return; + } + + // Fetch current keymap to get layer IDs + let keymapResp = await call_rpc(conn.conn, { keymap: { getKeymap: true } }); + let keymap = keymapResp?.keymap?.getKeymap; + if (!keymap) { + console.error("Failed to fetch current keymap", keymapResp); + return; + } + + // Fetch behavior list to map names to IDs + let behaviorList = await call_rpc(conn.conn, { behaviors: { listAllBehaviors: true } }); + let behaviorIds = behaviorList?.behaviors?.listAllBehaviors?.behaviors || []; + + const behaviorNameToId: Record = {}; + for (const id of behaviorIds) { + let details = await call_rpc(conn.conn, { behaviors: { getBehaviorDetails: { behaviorId: id } } }); + let d = details?.behaviors?.getBehaviorDetails; + if (d) { + const name = d.displayName.replace(/^&/, ""); + behaviorNameToId[name.toLowerCase()] = d.id; + behaviorNameToId[d.displayName.toLowerCase()] = d.id; + } + } + + // Apply each parsed layer's bindings + for (let li = 0; li < parsedLayers.length && li < keymap.layers.length; li++) { + const parsedLayer = parsedLayers[li]; + const targetLayer = keymap.layers[li]; + + for (let ki = 0; ki < parsedLayer.bindings.length && ki < targetLayer.bindings.length; ki++) { + const parsedBinding = parsedLayer.bindings[ki]; + const behaviorName = parsedBinding.behavior.toLowerCase(); + + // Handle special behaviors + if (behaviorName === "trans" || behaviorName === "transparent") { + continue; // Skip transparent, keep existing + } + if (behaviorName === "none") { + continue; // Skip none, keep existing + } + + const behaviorId = behaviorNameToId[behaviorName]; + if (behaviorId === undefined) { + console.warn(`Unknown behavior: &${parsedBinding.behavior}, skipping`); + continue; + } + + const param1 = parsedBinding.params[0] || 0; + const param2 = parsedBinding.params[1] || 0; + + await call_rpc(conn.conn, { + keymap: { setLayerBinding: { layerId: targetLayer.id, keyPosition: ki, binding: { behaviorId, param1, param2 } } }, + }); + } + } + + // Refresh the keymap state + setConn({ conn: conn.conn }); + } + + const file = fileInputRef.current?.files?.[0]; + if (file) { + doImport(file); + // Reset input so same file can be re-imported + fileInputRef.current!.value = ""; + } + }, [conn]); + const onConnect = useCallback( (t: RpcTransport) => { const ac = new AbortController(); @@ -284,6 +429,13 @@ function App() { + Promise; onResetSettings?: () => void | Promise; onDisconnect?: () => void | Promise; + onExportKeymap?: () => void | Promise; + onImportKeymap?: () => void | Promise; canUndo?: boolean; canRedo?: boolean; } @@ -38,6 +40,8 @@ export const AppHeader = ({ onDiscard, onDisconnect, onResetSettings, + onExportKeymap, + onImportKeymap, }: AppHeaderProps) => { const [showSettingsReset, setShowSettingsReset] = useState(false); @@ -164,6 +168,28 @@ export const AppHeader = ({ + {onExportKeymap && ( + + + + )} + {onImportKeymap && ( + + + + )} ); diff --git a/src/keymap-parser.ts b/src/keymap-parser.ts new file mode 100644 index 00000000..40c1e109 --- /dev/null +++ b/src/keymap-parser.ts @@ -0,0 +1,302 @@ +import { UsagePages } from "./keyboard-and-consumer-usage-tables.json"; +import HidOverrides from "./hid-usage-name-overrides.json"; + +interface HidLabels { + short?: string; + med?: string; + long?: string; +} + +const overrides: Record> = HidOverrides; + +export interface ParsedBinding { + behavior: string; + params: number[]; +} + +export interface ParsedLayer { + name: string; + bindings: ParsedBinding[]; +} + +function buildZmkKeycodeLookup(): Map { + const lookup = new Map(); + + // ZMK keycode name -> HID usage name mapping + // https://zmk.dev/docs/codes/ + const zmkAliases: Record = { + A: ["a"], + B: ["b"], + C: ["c"], + D: ["d"], + E: ["e"], + F: ["f"], + G: ["g"], + H: ["h"], + I: ["i"], + J: ["j"], + K: ["k"], + L: ["l"], + M: ["m"], + N: ["n"], + O: ["o"], + P: ["p"], + Q: ["q"], + R: ["r"], + S: ["s"], + T: ["t"], + U: ["u"], + V: ["v"], + W: ["w"], + X: ["x"], + Y: ["y"], + Z: ["z"], + N1: ["1", "!", "exclamation mark"], + N2: ["2", "@"], + N3: ["3", "#", "hash"], + N4: ["4", "$", "dollar"], + N5: ["5", "%", "percent"], + N6: ["6", "^", "circumflex"], + N7: ["7", "&", "ampersand"], + N8: ["8", "*", "asterisk"], + N9: ["9", "(", "left parenthesis"], + N0: ["0", ")", "right parenthesis"], + RET: ["return", "enter"], + ESC: ["escape"], + BSPC: ["backspace"], + TAB: ["tab"], + SPACE: ["space"], + MINUS: ["-", "dash", "minus"], + EQUAL: ["=", "equals"], + LBKT: ["[", "left bracket", "open bracket"], + RBKT: ["]", "right bracket", "close bracket"], + BSLH: ["\\", "backslash", "non-us #"], + SEMI: [";", "semicolon"], + SQT: ["'", "quote", "apostrophe"], + GRAVE: ["`", "grave"], + COMMA: [",", "comma"], + DOT: [".", "period"], + FSLH: ["/", "slash"], + CAPS: ["caps lock", "capslock"], + F1: ["f1"], + F2: ["f2"], + F3: ["f3"], + F4: ["f4"], + F5: ["f5"], + F6: ["f6"], + F7: ["f7"], + F8: ["f8"], + F9: ["f9"], + F10: ["f10"], + F11: ["f11"], + F12: ["f12"], + PRSC: ["print screen", "sysrq", "printscr"], + SLCK: ["scroll lock", "scrolllock"], + PAUSE: ["pause", "break"], + INS: ["insert"], + HOME: ["home"], + PGUP: ["page up", "pageup"], + DEL: ["delete"], + END: ["end"], + PGDN: ["page down", "pagedown"], + RGUI: ["right gui", "r gui"], + LGUI: ["left gui", "l gui"], + RALT: ["right alt", "altgr", "alt gr"], + LALT: ["left alt", "l alt"], + RSHFT: ["r shift", "right shift", "r shft"], + LSHFT: ["l shift", "left shift", "l shft"], + RCTL: ["r ctrl", "right ctrl", "r ctrl"], + LCTL: ["l ctrl", "left ctrl", "l ctrl"], + UP: ["up arrow", "up"], + DOWN: ["down arrow", "down"], + RIGHT: ["right arrow", "right"], + LEFT: ["left arrow", "left"], + KP_NUM: ["num lock", "numlock"], + C_PP: ["play/pause"], + C_NEXT: ["scan next track"], + C_PREV: ["scan previous track"], + C_STOP: ["stop"], + C_VOL_UP: ["volume increment"], + C_VOL_DN: ["volume decrement"], + C_MUTE: ["mute"], + C_BRI_UP: ["brightness increment"], + C_BRI_DN: ["brightness decrement"], + }; + + // Build HID usage name -> code lookup from the tables + const hidNameToCode = new Map(); + for (const page of UsagePages) { + for (const usage of page.UsageIds) { + const code = (page.Id << 16) + usage.Id; + const nameLower = usage.Name.toLowerCase(); + hidNameToCode.set(nameLower, code); + + // Also add the override short/med/long names + const pageOverrides = overrides[page.Id.toString()]?.[usage.Id.toString()]; + if (pageOverrides) { + if (pageOverrides.short) hidNameToCode.set(pageOverrides.short.toLowerCase(), code); + if (pageOverrides.med) hidNameToCode.set(pageOverrides.med.toLowerCase(), code); + if (pageOverrides.long) hidNameToCode.set(pageOverrides.long.toLowerCase(), code); + } + } + } + + for (const [zmkName, aliases] of Object.entries(zmkAliases)) { + for (const alias of aliases) { + const code = hidNameToCode.get(alias.toLowerCase()); + if (code !== undefined) { + lookup.set(zmkName, code); + break; + } + } + } + + // Also register by ZMK name directly for simple single-letter keycodes + // (HID keyboard page 0x07 usage IDs: A=4, B=5, ..., Z=29) + for (let i = 0; i < 26; i++) { + const letter = String.fromCharCode(65 + i); // A-Z + if (!lookup.has(letter)) { + lookup.set(letter, (0x07 << 16) + (4 + i)); + } + } + + // Number keys: HID usage IDs 30-39 for 1-0 + const numKeycodes: [string, number][] = [ + ["N1", 30], ["N2", 31], ["N3", 32], ["N4", 33], ["N5", 34], + ["N6", 35], ["N7", 36], ["N8", 37], ["N9", 38], ["N0", 39], + ]; + for (const [name, usageId] of numKeycodes) { + if (!lookup.has(name)) { + lookup.set(name, (0x07 << 16) + usageId); + } + } + + // Function keys: F1-F12 = usage IDs 58-69 + for (let i = 1; i <= 12; i++) { + const name = `F${i}`; + if (!lookup.has(name)) { + lookup.set(name, (0x07 << 16) + (58 + i - 1)); + } + } + + // Modifier keys + const modKeycodes: [string, number][] = [ + ["LCTL", 224], ["LSHFT", 225], ["LALT", 226], ["LGUI", 227], + ["RCTL", 228], ["RSHFT", 229], ["RALT", 230], ["RGUI", 231], + ]; + for (const [name, usageId] of modKeycodes) { + if (!lookup.has(name)) { + lookup.set(name, (0x07 << 16) + usageId); + } + } + + // Other common keys + const otherKeycodes: [string, number][] = [ + ["RET", 40], ["ESC", 41], ["BSPC", 42], ["TAB", 43], ["SPACE", 44], + ["MINUS", 45], ["EQUAL", 46], ["LBKT", 47], ["RBKT", 48], + ["BSLH", 49], ["SEMI", 51], ["SQT", 52], ["GRAVE", 53], + ["COMMA", 54], ["DOT", 55], ["FSLH", 56], ["CAPS", 57], + ["PRSC", 70], ["SLCK", 71], ["PAUSE", 72], ["INS", 73], + ["HOME", 74], ["PGUP", 75], ["DEL", 76], ["END", 77], ["PGDN", 78], + ["RIGHT", 79], ["LEFT", 80], ["DOWN", 81], ["UP", 82], + ]; + for (const [name, usageId] of otherKeycodes) { + if (!lookup.has(name)) { + lookup.set(name, (0x07 << 16) + usageId); + } + } + + // Consumer page (0x0C) keycodes + const consumerKeycodes: [string, number][] = [ + ["C_MUTE", 226], ["C_VOL_UP", 233], ["C_VOL_DN", 234], + ["C_PP", 205], ["C_NEXT", 181], ["C_PREV", 182], + ["C_STOP", 183], ["C_BRI_UP", 111], ["C_BRI_DN", 112], + ]; + for (const [name, usageId] of consumerKeycodes) { + if (!lookup.has(name)) { + lookup.set(name, (0x0c << 16) + usageId); + } + } + + return lookup; +} + +const keycodeLookup = buildZmkKeycodeLookup(); + +// Common ZMK behavior reference names -> standardized names +const behaviorAliases: Record = { + kp: "Key Press", + mo: "Momentary Layer", + mt: "Mod-Tap", + lt: "Layer-Tap", + to: "To Layer", + tog: "Toggle Layer", + trans: "Transparent", + none: "None", + sk: "Sticky Key", + sl: "Sticky Layer", + hl: "Hold-Tap Layer", +}; + +export function parseKeymapFile(content: string): ParsedLayer[] { + const layers: ParsedLayer[] = []; + + // Remove C preprocessor lines and comments + const keymapBlock = content.replace(/#include.*/g, "").replace(/\/\/.*$/gm, ""); + + let match; + // We need a simpler approach: find each layer definition + const layerBlockRegex = /(\w+)\s*\{[\s\S]*?bindings\s*=\s*<([^>]*)>\s*;/g; + + while ((match = layerBlockRegex.exec(keymapBlock)) !== null) { + const layerName = match[1]; + const bindingsStr = match[2]; + + // Skip non-layer nodes (compatible, keymap, etc.) + if (["compatible", "keymap", "behaviors"].includes(layerName)) continue; + + // Parse display-name if present + const displayNameMatch = keymapBlock.substring( + match.index, + match.index + match[0].length + ).match(/display-name\s*=\s*"([^"]*)"/); + const displayName = displayNameMatch ? displayNameMatch[1] : layerName; + + // Parse individual bindings + const bindings: ParsedBinding[] = []; + const bindingRegex = /&(\w+)(?:\s+(\S+))?(?:\s+(\S+))?(?:\s+(\S+))?/g; + + let bMatch; + while ((bMatch = bindingRegex.exec(bindingsStr)) !== null) { + const behavior = bMatch[1]; + const params: number[] = []; + + for (let i = 2; i < bMatch.length && bMatch[i]; i++) { + const val = bMatch[i]; + const num = parseInt(val, 10); + if (!isNaN(num)) { + params.push(num); + } else { + // Try to resolve as a ZMK keycode + const code = keycodeLookup.get(val.toUpperCase()); + if (code !== undefined) { + params.push(code); + } else { + // Try as-is (might be a layer name or other reference) + params.push(num || 0); + } + } + } + + bindings.push({ behavior, params }); + } + + if (bindings.length > 0) { + layers.push({ name: displayName, bindings }); + } + } + + return layers; +} + +export { keycodeLookup, behaviorAliases }; \ No newline at end of file From 8ffa859a68746a11b6f12d64e8d58b37d9553b9c Mon Sep 17 00:00:00 2001 From: max Date: Sat, 9 May 2026 17:01:38 +0100 Subject: [PATCH 02/14] style: Replace Download/Upload icons with text buttons in AppHeader 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 --- src/AppHeader.tsx | 34 +++++++++++++++------------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/src/AppHeader.tsx b/src/AppHeader.tsx index 3c0e8a4c..0f0c69f6 100644 --- a/src/AppHeader.tsx +++ b/src/AppHeader.tsx @@ -12,7 +12,7 @@ import { useModalRef } from "./misc/useModalRef"; import { LockStateContext } from "./rpc/LockStateContext"; import { LockState } from "@zmkfirmware/zmk-studio-ts-client/core"; import { ConnectionContext } from "./rpc/ConnectionContext"; -import { ChevronDown, Undo2, Redo2, Save, Trash2, Download, Upload } from "lucide-react"; +import { ChevronDown, Undo2, Redo2, Save, Trash2 } from "lucide-react"; import { Tooltip } from "./misc/Tooltip"; import { GenericModal } from "./GenericModal"; @@ -169,26 +169,22 @@ export const AppHeader = ({ {onExportKeymap && ( - - - + )} {onImportKeymap && ( - - - + )} From d5bd51e7f09a43e57a31600028ffd0eff16d969f Mon Sep 17 00:00:00 2001 From: numachang Date: Sat, 23 May 2026 01:09:27 +0900 Subject: [PATCH 03/14] docs: Reframe README for the tweaks fork and add CLAUDE.md 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 --- CLAUDE.md | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 74 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..5141ccaa --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,99 @@ +# CLAUDE.md + +このファイルは、Claude Code がこのリポジトリで作業するときの方針を定めるものです。人間の読者(≒将来の自分)も読む前提で書いています。 + +--- + +## このプロジェクトの性格 + +- **個人用フォーク (personal fork)** です。商業化はしません。 +- サポート・SLA・長期メンテの保証はありません。 +- 上流 [zmkfirmware/zmk-studio](https://github.com/zmkfirmware/zmk-studio) の代替ではなく、上流に「足りないと感じた小さな機能」を私的に試す場です。 + +## コミュニティへの敬意(最重要) + +このフォークは、ZMK プロジェクトの善意の上に成り立っています。次のことを必ず守ってください。 + +- **クレジットを残す**: README / コミットメッセージ / PR 説明で、上流 (`zmkfirmware/zmk-studio`, Apache-2.0) への謝意とリンクを明示する。 +- **上流をネガティブに書かない**: 「公式が遅いから」「メンテナが対応しないから」といったニュアンスは避ける。フォークの動機は「個人的な使い勝手の追求」として記述する。 +- **upstream に還元する姿勢を保つ**: 機能ができたら upstream にも PR を投げる。取り込まれなくても、投げたという事実が大事。 +- **関連プロジェクトのライセンスと規約を尊重**: [`zmk-studio-ts-client`](https://github.com/zmkfirmware/zmk-studio-ts-client) と [`zmk-studio-messages`](https://github.com/zmkfirmware/zmk-studio-messages) も同様。 +- **囲い込みにしない**: フォークでの改造は常に公開する。private にして抱え込まない。 + +## 開発スタンス + +### 最小限主義 + +不満を解消する**最小限**の改修にとどめます。 + +- 大規模リファクタや独自路線の追求はしない。 +- 既存の `src/` ディレクトリ構造(`keyboard/`, `behaviors/`, `rpc/`, `tauri/` ほか)を尊重する。 +- 「ついでにこれも直す」と差分を膨らませない。1 PR / 1 機能。 + +### upstream 追従 + +- `upstream` remote: `https://github.com/zmkfirmware/zmk-studio.git` +- 定期的に `git fetch upstream` して、`main` に rebase で取り込む(merge ではなく rebase 推奨。履歴を直線に保つ)。 +- 上流の `main` が動いた直後は、こちらの作業ブランチも早めに rebase する。 + +### 上流 PR の取り込み積極性 + +特に下記の open PR / issue は、Planned tweaks と直結しているので状況を観察すること。 + +- [PR #171](https://github.com/zmkfirmware/zmk-studio/pull/171) — keymap import/export(Planned ① と直結) +- [PR #159](https://github.com/zmkfirmware/zmk-studio/pull/159) — Grid Picker for HID Usage(Planned ② と関連) +- [issue #166](https://github.com/zmkfirmware/zmk-studio/issues/166) — Import keymap file +- [issue #168](https://github.com/zmkfirmware/zmk-studio/issues/168) — Mouse Emulation Support +- [issue #169](https://github.com/zmkfirmware/zmk-studio/issues/169) — Tog-Tap Mo-Hold Support + +近い実装が upstream にあれば、ゼロから書かずに参考にする / 取り込む。 + +## 作業時のルール + +### 不可逆な操作の前は必ず確認 + +- `git push`, deploy, リポジトリ設定変更, force push, ブランチ削除 などは、実行前にユーザに一声かける。 +- 「許可済み」と勝手に拡大解釈しない。スコープ外の操作は毎回確認。 + +### ブランチ運用 + +- `main` への直 push は避ける。機能ブランチを切る: `feature/` または `chore/`。 +- PR ベースで `main` に入れる(self-merge でも PR を経由)。 + +### コミット + +- メッセージは upstream のスタイル(Conventional Commits 風: `feat:`, `fix:`, `ci:`, `chore:`, `refactor:`, `docs:` 等)に合わせる。 +- すべての Claude による commit には `Co-Authored-By: Claude ` を含める。 +- `.env` や認証情報、巨大なバイナリを誤って add しない(`git add -A` / `git add .` は避け、ファイル指定で add する)。 + +### ライセンス遵守 + +- 上流は **Apache License 2.0**。MIT ではない。 +- 改変ファイルには Apache 2.0 §4(b) に従い、変更があった旨を残す(ファイル先頭の copyright 注記など)。 +- `NOTICE` ファイルは派生物にも保持する義務がある。削除・改竄しない。 +- 新規ファイル冒頭に独自の copyright を追加するのは OK だが、Apache 2.0 のままにする。 + +## スタック(参考) + +- **フロント**: React 18 + TypeScript 5 + Vite 5 + Tailwind CSS 3 + react-aria-components +- **デスクトップ版**: Tauri 2 (Rust) — `src-tauri/`、`bluest` で BLE GATT、`tokio-serial` でシリアル +- **RPC クライアント**: [`@zmkfirmware/zmk-studio-ts-client`](https://github.com/zmkfirmware/zmk-studio-ts-client) +- **メッセージ定義**: [`zmkfirmware/zmk-studio-messages`](https://github.com/zmkfirmware/zmk-studio-messages)(protobuf v3) +- **HID テーブル**: `src/HidUsageTables-1.5.json` 等の静的 JSON +- **firmware 側 RPC サーバ**: [`zmkfirmware/zmk`](https://github.com/zmkfirmware/zmk) の `app/src/studio/`(C/Zephyr、USB UART と BLE GATT の 2 トランスポート) + +## デプロイ + +- 静的 SPA を **Cloudflare Pages** に公開予定(`npm run build` → `dist/`)。 +- GitHub `main` への push でビルド & デプロイ自動化(未設定)。 +- Web Serial API のため、配布先のブラウザは Chromium 系限定であることを明示する。 + +## 起動コマンド早見 + +```bash +npm install +npm run dev # Web 版(要 Chromium 系) +npm run tauri dev # デスクトップ版 +npm run build # 静的ビルド → dist/ +npm run lint +``` diff --git a/README.md b/README.md index 26c36836..bbcca017 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,73 @@ -# ZMK Studio +# zmk-studio-tweaks -Initial work on the ZMK Studio UI. +A community fork of [zmk-studio](https://github.com/zmkfirmware/zmk-studio). +Planned tweaks: keymap import/export, type-to-search key picker, and host-layout localization. + +> ⚠️ **Personal fork / Work in progress** — none of the tweaks below are implemented yet. +> This repo exists to track ideas, experiment, and (where possible) feed changes back upstream. +> It is **not** a replacement for upstream ZMK Studio. + +## Based on + +This project is a fork of **[zmkfirmware/zmk-studio](https://github.com/zmkfirmware/zmk-studio)**, +the official keymap editor for [ZMK Firmware](https://zmk.dev/). All credit for the underlying +application, design, and protocol work belongs to the upstream maintainers and contributors. + +This fork is maintained independently and is **not affiliated with or endorsed by** the ZMK project. + +## Why this fork exists + +A handful of features would make day-to-day use a little smoother for me personally. +Some of these are already being discussed in upstream issues/PRs; this fork is a place to +prototype them without blocking on upstream review cycles. Where a feature lands cleanly, +the intent is to send it back upstream as a PR. + +## Planned tweaks + +| # | Feature | Status | Related upstream | +| - | ------- | ------ | ---------------- | +| 1 | **Keymap import/export** — round-trip a keymap as a JSON file | Planned | [PR #171](https://github.com/zmkfirmware/zmk-studio/pull/171), [issue #166](https://github.com/zmkfirmware/zmk-studio/issues/166) | +| 2 | **Type-to-search key picker** — replace dropdown navigation with incremental search | Planned | [PR #159](https://github.com/zmkfirmware/zmk-studio/pull/159) | +| 3 | **Host-layout localization** — choose how the host OS interprets keys (e.g. JIS / Japanese) | Planned | — | + +Roadmap only — nothing is shipped yet. See `CLAUDE.md` for the working stance behind these choices. + +## Getting started (development) + +The build setup is the same as upstream — see [zmk-studio's own README](https://github.com/zmkfirmware/zmk-studio#readme) for the canonical instructions. + +```bash +npm install +npm run dev +``` + +The web build talks to keyboards over the **Web Serial API**, which is only available in +Chromium-based browsers (Chrome, Edge, Brave, …). A native build is also available via Tauri: + +```bash +npm run tauri dev +``` + +## Related upstream projects + +- [`zmkfirmware/zmk-studio-ts-client`](https://github.com/zmkfirmware/zmk-studio-ts-client) — the TypeScript RPC client this app depends on. +- [`zmkfirmware/zmk-studio-messages`](https://github.com/zmkfirmware/zmk-studio-messages) — the protobuf message definitions shared with ZMK firmware. +- [`zmkfirmware/zmk`](https://github.com/zmkfirmware/zmk) — the ZMK firmware itself, which hosts the Studio RPC server. + +## License + +Distributed under the **Apache License 2.0**, the same license as upstream. See [`LICENSE`](LICENSE) +and [`NOTICE`](NOTICE) for the full text and required attribution. Modifications in this fork are +also released under Apache 2.0. + +## Acknowledgments + +Huge thanks to the ZMK project maintainers and the wider ZMK community for building and +freely sharing the firmware, the Studio app, and the surrounding ecosystem. This fork would +not exist without their work. + +## Disclaimer + +This is a personal fork with no warranty, no support guarantee, and no roadmap commitments. +If you need a stable, supported Studio, use the official one at +[zmkfirmware/zmk-studio](https://github.com/zmkfirmware/zmk-studio). From fe61ce3548e2f3113674f3701ea4da1cb4c450c6 Mon Sep 17 00:00:00 2001 From: numachang Date: Sat, 23 May 2026 01:13:34 +0900 Subject: [PATCH 04/14] ci: Disable auto-triggers for fork to avoid CI noise 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 --- .github/workflows/release-please.yml | 31 ---------------------------- .github/workflows/tauri-build.yml | 6 +++--- 2 files changed, 3 insertions(+), 34 deletions(-) delete mode 100644 .github/workflows/release-please.yml diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml deleted file mode 100644 index 02df13dc..00000000 --- a/.github/workflows/release-please.yml +++ /dev/null @@ -1,31 +0,0 @@ -on: - push: - branches: - - main - -permissions: {} - -name: release-please - -jobs: - release-please: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - uses: googleapis/release-please-action@v4 - id: release - with: - token: ${{ secrets.ZMK_STUDIO_RELEASE_TOKEN }} - - uses: actions/checkout@v4 - if: ${{ steps.release.outputs.release_created }} - with: - ref: prod - - name: publish to prod branch - if: ${{ steps.release.outputs.release_created }} - run: | - git fetch origin main - git pull --ff-only origin main - git remote set-url origin "https://x-access-token:${{ secrets.ZMK_STUDIO_RELEASE_TOKEN }}@github.com/${{ github.repository }}.git" - git push diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 9c54f8d3..87aad05b 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -1,10 +1,10 @@ name: "tauri-build" +# Fork note: auto-build on push / release disabled to avoid burning CI on every +# commit while signing secrets are not configured for this fork. Trigger +# manually from the Actions tab when a desktop build is needed. on: workflow_dispatch: - push: - release: - types: [published] jobs: publish-tauri: From a9db400bac761e8f7ca5f1fe098222cca74e9943 Mon Sep 17 00:00:00 2001 From: numachang Date: Sat, 23 May 2026 03:11:12 +0900 Subject: [PATCH 05/14] feat(toast): Add minimal Toast system for user feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/Toast.tsx | 121 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.tsx | 5 ++- 2 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 src/Toast.tsx diff --git a/src/Toast.tsx b/src/Toast.tsx new file mode 100644 index 00000000..8d4908b7 --- /dev/null +++ b/src/Toast.tsx @@ -0,0 +1,121 @@ +import { + createContext, + ReactNode, + useCallback, + useContext, + useRef, + useState, +} from "react"; + +export type ToastKind = "success" | "error" | "warning" | "info"; + +export interface NotifyOptions { + /** + * Supplemental call-to-action shown on its own line, emphasized so the user + * doesn't miss it. Use for things like "Press Save to persist". + */ + action?: string; +} + +export interface ToastMessage { + id: number; + kind: ToastKind; + message: string; + action?: string; +} + +interface ToastContextValue { + notify: (kind: ToastKind, message: string, options?: NotifyOptions) => void; +} + +const ToastContext = createContext(null); + +export function useToast(): ToastContextValue { + const ctx = useContext(ToastContext); + if (!ctx) { + throw new Error("useToast must be used inside "); + } + return ctx; +} + +const DURATION_MS: Record = { + success: 6000, + error: 8000, + warning: 8000, + info: 5000, +}; + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + const idRef = useRef(0); + + const dismiss = useCallback((id: number) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + const notify = useCallback( + (kind: ToastKind, message: string, options?: NotifyOptions) => { + const id = ++idRef.current; + setToasts((prev) => [ + ...prev, + { id, kind, message, action: options?.action }, + ]); + window.setTimeout(() => dismiss(id), DURATION_MS[kind]); + }, + [dismiss] + ); + + return ( + + {children} + + + ); +} + +const KIND_CLASSES: Record = { + success: "bg-emerald-700 text-emerald-50 border-emerald-500", + error: "bg-rose-700 text-rose-50 border-rose-500", + warning: "bg-amber-700 text-amber-50 border-amber-500", + info: "bg-sky-700 text-sky-50 border-sky-500", +}; + +function ToastViewport({ + toasts, + onDismiss, +}: { + toasts: ToastMessage[]; + onDismiss: (id: number) => void; +}) { + if (toasts.length === 0) return null; + return ( +
+ {toasts.map((t) => ( +
+
+
+ {t.message} + {t.action && ( +
+ {t.action} +
+ )} +
+ +
+
+ ))} +
+ ); +} diff --git a/src/main.tsx b/src/main.tsx index 966f17a4..b5f92b87 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,13 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App.tsx"; +import { ToastProvider } from "./Toast.tsx"; import "./index.css"; ReactDOM.createRoot(document.getElementById("root")!).render( - + + + ); From 1d95558def399b4b416c7681a90dfe86ba20b3f4 Mon Sep 17 00:00:00 2001 From: numachang Date: Sat, 23 May 2026 03:11:38 +0900 Subject: [PATCH 06/14] fix(keymap): Rewrite parser, add ZMK round-trip helpers, fix Base-layer drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #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 --- src/keymap-parser.ts | 462 +++++++++++++++++++++++-------------------- 1 file changed, 246 insertions(+), 216 deletions(-) diff --git a/src/keymap-parser.ts b/src/keymap-parser.ts index 40c1e109..3d8fc03b 100644 --- a/src/keymap-parser.ts +++ b/src/keymap-parser.ts @@ -19,119 +19,124 @@ export interface ParsedLayer { bindings: ParsedBinding[]; } -function buildZmkKeycodeLookup(): Map { - const lookup = new Map(); +// ============================================================================= +// Canonical ZMK keycode table +// ============================================================================= +// `[zmkName, hidPage, hidUsageId]`. Single source of truth used to build both +// the forward (name -> code) and reverse (code -> name) lookups, so we never +// give a code two different canonical names. Order matters only inside groups +// of synonyms; the first entry for a given code wins on the reverse side. +// ============================================================================= - // ZMK keycode name -> HID usage name mapping - // https://zmk.dev/docs/codes/ - const zmkAliases: Record = { - A: ["a"], - B: ["b"], - C: ["c"], - D: ["d"], - E: ["e"], - F: ["f"], - G: ["g"], - H: ["h"], - I: ["i"], - J: ["j"], - K: ["k"], - L: ["l"], - M: ["m"], - N: ["n"], - O: ["o"], - P: ["p"], - Q: ["q"], - R: ["r"], - S: ["s"], - T: ["t"], - U: ["u"], - V: ["v"], - W: ["w"], - X: ["x"], - Y: ["y"], - Z: ["z"], - N1: ["1", "!", "exclamation mark"], - N2: ["2", "@"], - N3: ["3", "#", "hash"], - N4: ["4", "$", "dollar"], - N5: ["5", "%", "percent"], - N6: ["6", "^", "circumflex"], - N7: ["7", "&", "ampersand"], - N8: ["8", "*", "asterisk"], - N9: ["9", "(", "left parenthesis"], - N0: ["0", ")", "right parenthesis"], - RET: ["return", "enter"], - ESC: ["escape"], - BSPC: ["backspace"], - TAB: ["tab"], - SPACE: ["space"], - MINUS: ["-", "dash", "minus"], - EQUAL: ["=", "equals"], - LBKT: ["[", "left bracket", "open bracket"], - RBKT: ["]", "right bracket", "close bracket"], - BSLH: ["\\", "backslash", "non-us #"], - SEMI: [";", "semicolon"], - SQT: ["'", "quote", "apostrophe"], - GRAVE: ["`", "grave"], - COMMA: [",", "comma"], - DOT: [".", "period"], - FSLH: ["/", "slash"], - CAPS: ["caps lock", "capslock"], - F1: ["f1"], - F2: ["f2"], - F3: ["f3"], - F4: ["f4"], - F5: ["f5"], - F6: ["f6"], - F7: ["f7"], - F8: ["f8"], - F9: ["f9"], - F10: ["f10"], - F11: ["f11"], - F12: ["f12"], - PRSC: ["print screen", "sysrq", "printscr"], - SLCK: ["scroll lock", "scrolllock"], - PAUSE: ["pause", "break"], - INS: ["insert"], - HOME: ["home"], - PGUP: ["page up", "pageup"], - DEL: ["delete"], - END: ["end"], - PGDN: ["page down", "pagedown"], - RGUI: ["right gui", "r gui"], - LGUI: ["left gui", "l gui"], - RALT: ["right alt", "altgr", "alt gr"], - LALT: ["left alt", "l alt"], - RSHFT: ["r shift", "right shift", "r shft"], - LSHFT: ["l shift", "left shift", "l shft"], - RCTL: ["r ctrl", "right ctrl", "r ctrl"], - LCTL: ["l ctrl", "left ctrl", "l ctrl"], - UP: ["up arrow", "up"], - DOWN: ["down arrow", "down"], - RIGHT: ["right arrow", "right"], - LEFT: ["left arrow", "left"], - KP_NUM: ["num lock", "numlock"], - C_PP: ["play/pause"], - C_NEXT: ["scan next track"], - C_PREV: ["scan previous track"], - C_STOP: ["stop"], - C_VOL_UP: ["volume increment"], - C_VOL_DN: ["volume decrement"], - C_MUTE: ["mute"], - C_BRI_UP: ["brightness increment"], - C_BRI_DN: ["brightness decrement"], - }; +type Keycode = readonly [name: string, page: number, usage: number]; + +const LETTERS: Keycode[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + .split("") + .map((l, i) => [l, 0x07, 4 + i] as const); + +const KEYBOARD_KEYCODES: Keycode[] = [ + // Number row + ["N1", 0x07, 30], ["N2", 0x07, 31], ["N3", 0x07, 32], ["N4", 0x07, 33], + ["N5", 0x07, 34], ["N6", 0x07, 35], ["N7", 0x07, 36], ["N8", 0x07, 37], + ["N9", 0x07, 38], ["N0", 0x07, 39], + // Control / whitespace + ["RET", 0x07, 40], ["ESC", 0x07, 41], ["BSPC", 0x07, 42], + ["TAB", 0x07, 43], ["SPACE", 0x07, 44], + // Punctuation + ["MINUS", 0x07, 45], ["EQUAL", 0x07, 46], ["LBKT", 0x07, 47], + ["RBKT", 0x07, 48], ["BSLH", 0x07, 49], ["NUHS", 0x07, 50], + ["SEMI", 0x07, 51], ["SQT", 0x07, 52], ["GRAVE", 0x07, 53], + ["COMMA", 0x07, 54], ["DOT", 0x07, 55], ["FSLH", 0x07, 56], + ["CAPS", 0x07, 57], + // F-row + ["F1", 0x07, 58], ["F2", 0x07, 59], ["F3", 0x07, 60], ["F4", 0x07, 61], + ["F5", 0x07, 62], ["F6", 0x07, 63], ["F7", 0x07, 64], ["F8", 0x07, 65], + ["F9", 0x07, 66], ["F10", 0x07, 67], ["F11", 0x07, 68], ["F12", 0x07, 69], + // System + ["PRSC", 0x07, 70], ["SLCK", 0x07, 71], ["PAUSE", 0x07, 72], + ["INS", 0x07, 73], ["HOME", 0x07, 74], ["PGUP", 0x07, 75], + ["DEL", 0x07, 76], ["END", 0x07, 77], ["PGDN", 0x07, 78], + // Arrows + ["RIGHT", 0x07, 79], ["LEFT", 0x07, 80], ["DOWN", 0x07, 81], ["UP", 0x07, 82], + // Keypad + ["KP_NUM", 0x07, 83], ["KP_SLASH", 0x07, 84], ["KP_ASTERISK", 0x07, 85], + ["KP_MINUS", 0x07, 86], ["KP_PLUS", 0x07, 87], ["KP_ENTER", 0x07, 88], + ["KP_N1", 0x07, 89], ["KP_N2", 0x07, 90], ["KP_N3", 0x07, 91], + ["KP_N4", 0x07, 92], ["KP_N5", 0x07, 93], ["KP_N6", 0x07, 94], + ["KP_N7", 0x07, 95], ["KP_N8", 0x07, 96], ["KP_N9", 0x07, 97], + ["KP_N0", 0x07, 98], ["KP_DOT", 0x07, 99], + // Misc + ["NUBS", 0x07, 100], ["K_APP", 0x07, 101], ["KP_EQUAL", 0x07, 103], + ["F13", 0x07, 104], ["F14", 0x07, 105], ["F15", 0x07, 106], + ["F16", 0x07, 107], ["F17", 0x07, 108], ["F18", 0x07, 109], + ["F19", 0x07, 110], ["F20", 0x07, 111], ["F21", 0x07, 112], + ["F22", 0x07, 113], ["F23", 0x07, 114], ["F24", 0x07, 115], + // Modifiers + ["LCTL", 0x07, 224], ["LSHFT", 0x07, 225], ["LALT", 0x07, 226], ["LGUI", 0x07, 227], + ["RCTL", 0x07, 228], ["RSHFT", 0x07, 229], ["RALT", 0x07, 230], ["RGUI", 0x07, 231], +]; + +const CONSUMER_KEYCODES: Keycode[] = [ + ["C_PWR", 0x0c, 0x30], ["C_SLEEP", 0x0c, 0x32], + ["C_BRI_UP", 0x0c, 0x6f], ["C_BRI_DN", 0x0c, 0x70], + ["C_NEXT", 0x0c, 0xb5], ["C_PREV", 0x0c, 0xb6], ["C_STOP", 0x0c, 0xb7], + ["C_EJECT", 0x0c, 0xb8], ["C_PP", 0x0c, 0xcd], + ["C_MUTE", 0x0c, 0xe2], ["C_VOL_UP", 0x0c, 0xe9], ["C_VOL_DN", 0x0c, 0xea], +]; + +const ZMK_KEYCODES: Keycode[] = [...LETTERS, ...KEYBOARD_KEYCODES, ...CONSUMER_KEYCODES]; + +// ============================================================================= +// ZMK implicit-modifier flags +// ============================================================================= +// ZMK encodes "shifted" or otherwise pre-modified keys in the upper byte of the +// 32-bit binding parameter. e.g. LS(N1) = 0x02 << 24 | (0x07 << 16) | 30 = the +// HID 'shift+1' usage. We emit and parse `LS(N1)` / `LC(MINUS)` etc. +// ============================================================================= + +const MODIFIER_FLAGS: ReadonlyArray = [ + [0x01, "LC"], + [0x02, "LS"], + [0x04, "LA"], + [0x08, "LG"], + [0x10, "RC"], + [0x20, "RS"], + [0x40, "RA"], + [0x80, "RG"], +]; + +const MOD_NAME_TO_BIT: Map = new Map( + MODIFIER_FLAGS.map(([bit, name]) => [name, bit]) +); + +// ============================================================================= +// Lookups built from the canonical table +// ============================================================================= + +function keycodeFromTuple(t: Keycode): number { + return (t[1] << 16) + t[2]; +} + +const keycodeLookup: Map = new Map(); +const codeToZmkName: Map = new Map(); + +for (const t of ZMK_KEYCODES) { + const code = keycodeFromTuple(t); + const name = t[0]; + if (!keycodeLookup.has(name)) keycodeLookup.set(name, code); + if (!codeToZmkName.has(code)) codeToZmkName.set(code, name); +} - // Build HID usage name -> code lookup from the tables +// Parse-only HID-name fallback. The canonical table is authoritative; these +// entries only widen what the parser will accept (e.g. plain "Comma" or "1" +// strings that some hand-written files might use), and they never overwrite +// canonical ZMK names. +{ const hidNameToCode = new Map(); for (const page of UsagePages) { for (const usage of page.UsageIds) { const code = (page.Id << 16) + usage.Id; - const nameLower = usage.Name.toLowerCase(); - hidNameToCode.set(nameLower, code); - - // Also add the override short/med/long names + hidNameToCode.set(usage.Name.toLowerCase(), code); const pageOverrides = overrides[page.Id.toString()]?.[usage.Id.toString()]; if (pageOverrides) { if (pageOverrides.short) hidNameToCode.set(pageOverrides.short.toLowerCase(), code); @@ -141,89 +146,33 @@ function buildZmkKeycodeLookup(): Map { } } + // Permissive aliases that point at the keyboard-page (not the keypad-page) + // code so e.g. "1" resolves to N1 (kbd) rather than KP_N1 (keypad). + const zmkAliases: Record = { + N1: ["1", "!"], N2: ["2", "@"], N3: ["3", "#"], N4: ["4", "$"], N5: ["5", "%"], + N6: ["6", "^"], N7: ["7", "&"], N8: ["8", "*"], N9: ["9", "("], N0: ["0", ")"], + MINUS: ["-"], EQUAL: ["="], LBKT: ["["], RBKT: ["]"], BSLH: ["\\"], + SEMI: [";"], SQT: ["'"], GRAVE: ["`"], COMMA: [","], DOT: ["."], FSLH: ["/"], + }; for (const [zmkName, aliases] of Object.entries(zmkAliases)) { + if (!keycodeLookup.has(zmkName)) continue; // skip if not in canonical table + const canonicalCode = keycodeLookup.get(zmkName)!; for (const alias of aliases) { - const code = hidNameToCode.get(alias.toLowerCase()); - if (code !== undefined) { - lookup.set(zmkName, code); - break; - } - } - } - - // Also register by ZMK name directly for simple single-letter keycodes - // (HID keyboard page 0x07 usage IDs: A=4, B=5, ..., Z=29) - for (let i = 0; i < 26; i++) { - const letter = String.fromCharCode(65 + i); // A-Z - if (!lookup.has(letter)) { - lookup.set(letter, (0x07 << 16) + (4 + i)); - } - } - - // Number keys: HID usage IDs 30-39 for 1-0 - const numKeycodes: [string, number][] = [ - ["N1", 30], ["N2", 31], ["N3", 32], ["N4", 33], ["N5", 34], - ["N6", 35], ["N7", 36], ["N8", 37], ["N9", 38], ["N0", 39], - ]; - for (const [name, usageId] of numKeycodes) { - if (!lookup.has(name)) { - lookup.set(name, (0x07 << 16) + usageId); - } - } - - // Function keys: F1-F12 = usage IDs 58-69 - for (let i = 1; i <= 12; i++) { - const name = `F${i}`; - if (!lookup.has(name)) { - lookup.set(name, (0x07 << 16) + (58 + i - 1)); - } - } - - // Modifier keys - const modKeycodes: [string, number][] = [ - ["LCTL", 224], ["LSHFT", 225], ["LALT", 226], ["LGUI", 227], - ["RCTL", 228], ["RSHFT", 229], ["RALT", 230], ["RGUI", 231], - ]; - for (const [name, usageId] of modKeycodes) { - if (!lookup.has(name)) { - lookup.set(name, (0x07 << 16) + usageId); - } - } - - // Other common keys - const otherKeycodes: [string, number][] = [ - ["RET", 40], ["ESC", 41], ["BSPC", 42], ["TAB", 43], ["SPACE", 44], - ["MINUS", 45], ["EQUAL", 46], ["LBKT", 47], ["RBKT", 48], - ["BSLH", 49], ["SEMI", 51], ["SQT", 52], ["GRAVE", 53], - ["COMMA", 54], ["DOT", 55], ["FSLH", 56], ["CAPS", 57], - ["PRSC", 70], ["SLCK", 71], ["PAUSE", 72], ["INS", 73], - ["HOME", 74], ["PGUP", 75], ["DEL", 76], ["END", 77], ["PGDN", 78], - ["RIGHT", 79], ["LEFT", 80], ["DOWN", 81], ["UP", 82], - ]; - for (const [name, usageId] of otherKeycodes) { - if (!lookup.has(name)) { - lookup.set(name, (0x07 << 16) + usageId); + const upper = alias.toUpperCase(); + if (!keycodeLookup.has(upper)) keycodeLookup.set(upper, canonicalCode); } } - - // Consumer page (0x0C) keycodes - const consumerKeycodes: [string, number][] = [ - ["C_MUTE", 226], ["C_VOL_UP", 233], ["C_VOL_DN", 234], - ["C_PP", 205], ["C_NEXT", 181], ["C_PREV", 182], - ["C_STOP", 183], ["C_BRI_UP", 111], ["C_BRI_DN", 112], - ]; - for (const [name, usageId] of consumerKeycodes) { - if (!lookup.has(name)) { - lookup.set(name, (0x0c << 16) + usageId); - } + // Wire the HID Name field as a last-resort alias for parsing (e.g. "Return") + for (const [hidName, code] of hidNameToCode) { + const upper = hidName.toUpperCase(); + if (!keycodeLookup.has(upper)) keycodeLookup.set(upper, code); } - - return lookup; } -const keycodeLookup = buildZmkKeycodeLookup(); +// ============================================================================= +// Behavior reference name table (RPC displayName <-> DTS reference) +// ============================================================================= -// Common ZMK behavior reference names -> standardized names const behaviorAliases: Record = { kp: "Key Press", mo: "Momentary Layer", @@ -235,57 +184,138 @@ const behaviorAliases: Record = { none: "None", sk: "Sticky Key", sl: "Sticky Layer", - hl: "Hold-Tap Layer", + mkp: "Mouse Key Press", + mmv: "Mouse Move", + msc: "Mouse Scroll", + bt: "Bluetooth", + out: "Output Selection", + ext_power: "External Power", + bl: "Backlight", + rgb_ug: "RGB Underglow", + bootloader: "Bootloader", + reset: "Reset", + soft_off: "Soft Off", + caps_word: "Caps Word", + key_repeat: "Key Repeat", + gresc: "Grave Escape", + studio_unlock: "Studio Unlock", }; +const displayNameToDtsRefMap: Map = new Map( + Object.entries(behaviorAliases).map(([ref, dn]) => [dn.toLowerCase(), ref]) +); + +/** + * Translate the RPC behavior displayName (e.g. "Key Press") to its DTS + * reference name (e.g. "kp"). User-defined behaviors whose displayName + * already is the DTS node name (e.g. "homerow_mods_left") pass through + * after stripping a leading "&". + */ +export function dtsRefForDisplayName(displayName: string): string { + const stripped = displayName.replace(/^&/, ""); + const known = displayNameToDtsRefMap.get(stripped.toLowerCase()); + return known ?? stripped; +} + +// ============================================================================= +// Binding-parameter serialization +// ============================================================================= + +/** + * Format a single binding parameter for inclusion in a .keymap file. + * - Values with HID page bits become ZMK keycode names, with implicit + * modifier bits unfolded into `LS(...)`, `LC(...)` etc. + * - Values without HID page bits (layer indices, BT profile numbers, + * small immediate integers) stay as decimal. + */ +export function formatBindingParam(code: number): string { + const modBits = (code >>> 24) & 0xff; + const base = code & 0x00ffffff; + + if (base < 0x010000) { + return String(code); + } + + const baseName = codeToZmkName.get(base); + if (!baseName) { + return String(code); + } + + if (modBits === 0) return baseName; + + let result = baseName; + for (const [bit, name] of MODIFIER_FLAGS) { + if (modBits & bit) { + result = `${name}(${result})`; + } + } + return result; +} + +/** + * Parse a single ZMK binding parameter token (e.g. "EQUAL", "LS(N1)", "42") + * into its 32-bit binding value. Returns `undefined` if the token can't be + * resolved to a known keycode (and is not a bare integer). + */ +export function parseBindingParam(token: string): number | undefined { + const numeric = parseInt(token, 10); + if (!isNaN(numeric) && /^-?\d+$/.test(token)) return numeric; + + const wrap = token.match(/^([LR][CSAG])\((.+)\)$/); + if (wrap) { + const bit = MOD_NAME_TO_BIT.get(wrap[1]); + if (bit === undefined) return undefined; + const inner = parseBindingParam(wrap[2]); + if (inner === undefined) return undefined; + return inner | (bit << 24); + } + + const code = keycodeLookup.get(token.toUpperCase()); + return code; +} + +// ============================================================================= +// .keymap file parser +// ============================================================================= + export function parseKeymapFile(content: string): ParsedLayer[] { const layers: ParsedLayer[] = []; - // Remove C preprocessor lines and comments - const keymapBlock = content.replace(/#include.*/g, "").replace(/\/\/.*$/gm, ""); + const keymapBlock = content + .replace(/#include.*/g, "") + .replace(/\/\/.*$/gm, ""); - let match; - // We need a simpler approach: find each layer definition - const layerBlockRegex = /(\w+)\s*\{[\s\S]*?bindings\s*=\s*<([^>]*)>\s*;/g; + // Layer node: `Name { [display-name = "..."; ] bindings = <...>; }`. + // Constrain what's allowed between the opening brace and `bindings = <` so + // we don't accidentally pair an outer node (e.g. `keymap { compatible = + // "..."; Base { bindings = <...>; }; }`) with an inner layer's bindings + // block — that bug caused the Base layer to be silently dropped on import. + const layerBlockRegex = + /(\w+)\s*\{\s*(?:display-name\s*=\s*"([^"]*)"\s*;\s*)?bindings\s*=\s*<([^>]*)>\s*;/g; + let match: RegExpExecArray | null; while ((match = layerBlockRegex.exec(keymapBlock)) !== null) { const layerName = match[1]; - const bindingsStr = match[2]; + const displayName = match[2] ?? layerName; + const bindingsStr = match[3]; - // Skip non-layer nodes (compatible, keymap, etc.) if (["compatible", "keymap", "behaviors"].includes(layerName)) continue; - // Parse display-name if present - const displayNameMatch = keymapBlock.substring( - match.index, - match.index + match[0].length - ).match(/display-name\s*=\s*"([^"]*)"/); - const displayName = displayNameMatch ? displayNameMatch[1] : layerName; - - // Parse individual bindings const bindings: ParsedBinding[] = []; - const bindingRegex = /&(\w+)(?:\s+(\S+))?(?:\s+(\S+))?(?:\s+(\S+))?/g; + // Behavior reference followed by zero-or-more whitespace-separated tokens. + // Tokens may include `LS(N1)` etc., so we match them as `[^\s&]+`. + const bindingRegex = /&(\w+)((?:\s+[^\s&>]+)*)/g; - let bMatch; + let bMatch: RegExpExecArray | null; while ((bMatch = bindingRegex.exec(bindingsStr)) !== null) { const behavior = bMatch[1]; + const paramStr = bMatch[2].trim(); + const tokens = paramStr.length === 0 ? [] : paramStr.split(/\s+/); const params: number[] = []; - for (let i = 2; i < bMatch.length && bMatch[i]; i++) { - const val = bMatch[i]; - const num = parseInt(val, 10); - if (!isNaN(num)) { - params.push(num); - } else { - // Try to resolve as a ZMK keycode - const code = keycodeLookup.get(val.toUpperCase()); - if (code !== undefined) { - params.push(code); - } else { - // Try as-is (might be a layer name or other reference) - params.push(num || 0); - } - } + for (const tok of tokens) { + const v = parseBindingParam(tok); + params.push(v ?? 0); } bindings.push({ behavior, params }); @@ -299,4 +329,4 @@ export function parseKeymapFile(content: string): ParsedLayer[] { return layers; } -export { keycodeLookup, behaviorAliases }; \ No newline at end of file +export { keycodeLookup, behaviorAliases, codeToZmkName }; From 34aa60df65a67614ebf99a044caf4f407488cd5d Mon Sep 17 00:00:00 2001 From: numachang Date: Sat, 23 May 2026 03:12:06 +0900 Subject: [PATCH 07/14] feat(import-export): Make PR #171's keymap import/export reliable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #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 #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 #171's prefer-const errors don't carry into the fork's lint baseline. Co-Authored-By: Claude --- src/App.tsx | 254 +++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 194 insertions(+), 60 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index de4625a5..92a058c6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,7 +6,11 @@ import { call_rpc } from "./rpc/logging"; import type { Notification } from "@zmkfirmware/zmk-studio-ts-client/studio"; import { ConnectionState, ConnectionContext } from "./rpc/ConnectionContext"; import { Dispatch, useCallback, useEffect, useRef, useState } from "react"; -import { parseKeymapFile } from "./keymap-parser"; +import { + dtsRefForDisplayName, + formatBindingParam, + parseKeymapFile, +} from "./keymap-parser"; import { ConnectModal, TransportFactory } from "./ConnectModal"; import type { RpcTransport } from "@zmkfirmware/zmk-studio-ts-client/transport/index"; @@ -30,6 +34,8 @@ import { valueAfter } from "./misc/async"; import { AppFooter } from "./AppFooter"; import { AboutModal } from "./AboutModal"; import { LicenseNoticeModal } from "./misc/LicenseNoticeModal"; +import { ToastKind, useToast } from "./Toast"; +import type { GetBehaviorDetailsResponse } from "@zmkfirmware/zmk-studio-ts-client/behaviors"; declare global { interface Window { @@ -71,17 +77,17 @@ async function listen_for_notifications( notification_stream: ReadableStream, signal: AbortSignal ): Promise { - let reader = notification_stream.getReader(); + const reader = notification_stream.getReader(); const onAbort = () => { reader.cancel(); reader.releaseLock(); }; signal.addEventListener("abort", onAbort, { once: true }); do { - let pub = usePub(); + const pub = usePub(); try { - let { done, value } = await reader.read(); + const { done, value } = await reader.read(); if (done) { break; } @@ -127,11 +133,12 @@ async function connect( transport: RpcTransport, setConn: Dispatch, setConnectedDeviceName: Dispatch, - signal: AbortSignal + signal: AbortSignal, + notify: (kind: ToastKind, message: string) => void ) { - let conn = await create_rpc_connection(transport, { signal }); + const conn = await create_rpc_connection(transport, { signal }); - let details = await Promise.race([ + const details = await Promise.race([ call_rpc(conn, { core: { getDeviceInfo: true } }) .then((r) => r?.core?.getDeviceInfo) .catch((e) => { @@ -142,8 +149,7 @@ async function connect( ]); if (!details) { - // TODO: Show a proper toast/alert not using `window.alert` - window.alert("Failed to connect to the chosen device"); + notify("error", "Failed to connect to the chosen device"); return; } @@ -162,6 +168,7 @@ async function connect( } function App() { + const { notify } = useToast(); const [conn, setConn] = useState({ conn: null }); const [connectedDeviceName, setConnectedDeviceName] = useState< string | undefined @@ -190,7 +197,7 @@ function App() { return; } - let locked_resp = await call_rpc(conn.conn, { + const locked_resp = await call_rpc(conn.conn, { core: { getLockState: true }, }); @@ -209,14 +216,24 @@ function App() { return; } - let resp = await call_rpc(conn.conn, { keymap: { saveChanges: true } }); + notify("info", "Committing keymap to device flash…"); + const resp = await call_rpc(conn.conn, { keymap: { saveChanges: true } }); if (!resp.keymap?.saveChanges || resp.keymap?.saveChanges.err) { console.error("Failed to save changes", resp.keymap?.saveChanges); + notify( + "error", + `Save failed${resp.keymap?.saveChanges?.err ? `: ${resp.keymap.saveChanges.err}` : ""}` + ); + return; } + notify("success", "Keymap saved to flash. It will persist across restarts."); } - doSave(); - }, [conn]); + doSave().catch((e) => { + console.error("Save failed", e); + notify("error", `Save failed: ${e instanceof Error ? e.message : String(e)}`); + }); + }, [conn, notify]); const discard = useCallback(() => { async function doDiscard() { @@ -224,19 +241,28 @@ function App() { return; } - let resp = await call_rpc(conn.conn, { + const resp = await call_rpc(conn.conn, { keymap: { discardChanges: true }, }); if (!resp.keymap?.discardChanges) { console.error("Failed to discard changes", resp); + notify("error", "Failed to discard changes"); + return; } reset(); setConn({ conn: conn.conn }); + notify("info", "Reverted to last saved keymap."); } - doDiscard(); - }, [conn]); + doDiscard().catch((e) => { + console.error("Discard failed", e); + notify( + "error", + `Discard failed: ${e instanceof Error ? e.message : String(e)}` + ); + }); + }, [conn, notify]); const resetSettings = useCallback(() => { async function doReset() { @@ -244,7 +270,7 @@ function App() { return; } - let resp = await call_rpc(conn.conn, { + const resp = await call_rpc(conn.conn, { core: { resetSettings: true }, }); if (!resp.core?.resetSettings) { @@ -278,20 +304,21 @@ function App() { return; } - let keymapResp = await call_rpc(conn.conn, { keymap: { getKeymap: true } }); - let keymap = keymapResp?.keymap?.getKeymap; + const keymapResp = await call_rpc(conn.conn, { keymap: { getKeymap: true } }); + const keymap = keymapResp?.keymap?.getKeymap; if (!keymap) { console.error("Failed to fetch keymap for export", keymapResp); + notify("error", "Failed to fetch keymap from device"); return; } - let behaviorList = await call_rpc(conn.conn, { behaviors: { listAllBehaviors: true } }); - let behaviorIds = behaviorList?.behaviors?.listAllBehaviors?.behaviors || []; + const behaviorList = await call_rpc(conn.conn, { behaviors: { listAllBehaviors: true } }); + const behaviorIds = behaviorList?.behaviors?.listAllBehaviors?.behaviors || []; const behaviors: Record = {}; for (const id of behaviorIds) { - let details = await call_rpc(conn.conn, { behaviors: { getBehaviorDetails: { behaviorId: id } } }); - let d = details?.behaviors?.getBehaviorDetails; + const details = await call_rpc(conn.conn, { behaviors: { getBehaviorDetails: { behaviorId: id } } }); + const d = details?.behaviors?.getBehaviorDetails; if (d) { behaviors[d.id] = d.displayName; } @@ -303,14 +330,13 @@ function App() { const layerName = layer.name?.replace(/[^a-zA-Z0-9_]/g, "_") || `layer_${layer.id}`; content += ` ${layerName} {\n bindings = <\n`; const bindingStrs = layer.bindings.map((b: { behaviorId: number; param1: number; param2: number }) => { - const name = behaviors[b.behaviorId] || `&unknown_${b.behaviorId}`; - const base = name.replace(/^&/, ""); - const parts = [`&${base}`]; - if (b.param1 !== 0) parts.push(String(b.param1)); - if (b.param2 !== 0) parts.push(String(b.param2)); + const displayName = behaviors[b.behaviorId] ?? `unknown_${b.behaviorId}`; + const ref = dtsRefForDisplayName(displayName); + const parts = [`&${ref}`]; + if (b.param1 !== 0) parts.push(formatBindingParam(b.param1)); + if (b.param2 !== 0) parts.push(formatBindingParam(b.param2)); return parts.join(" "); }); - // Pad all bindings to the same width for column alignment const maxLen = Math.max(...bindingStrs.map((s: string) => s.length)); const padded = bindingStrs.map((s: string) => s.padEnd(maxLen)); content += padded.map((s: string) => ` ${s}`).join("\n") + "\n"; @@ -322,14 +348,27 @@ function App() { const blob = new Blob([content], { type: "text/plain" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); + const stamp = new Date() + .toISOString() + .replace(/[:.]/g, "-") + .slice(0, 19); a.href = url; - a.download = `${connectedDeviceName || "zmk"}.keymap`; + a.download = `${connectedDeviceName || "zmk"}-${stamp}.keymap`; a.click(); URL.revokeObjectURL(url); + + const layerCount = keymap.layers.length; + notify( + "success", + `Exported ${layerCount} layer${layerCount === 1 ? "" : "s"} to ${a.download}` + ); } - doExport(); - }, [conn, connectedDeviceName]); + doExport().catch((e) => { + console.error("Export failed", e); + notify("error", `Export failed: ${e instanceof Error ? e.message : String(e)}`); + }); + }, [conn, connectedDeviceName, notify]); const fileInputRef = useRef(null); @@ -345,34 +384,52 @@ function App() { const text = await file.text(); const parsedLayers = parseKeymapFile(text); if (parsedLayers.length === 0) { - console.error("No layers found in imported keymap"); + notify( + "error", + `No layers found in ${file.name}. Is this a ZMK .keymap file?` + ); return; } - // Fetch current keymap to get layer IDs - let keymapResp = await call_rpc(conn.conn, { keymap: { getKeymap: true } }); - let keymap = keymapResp?.keymap?.getKeymap; + const keymapResp = await call_rpc(conn.conn, { keymap: { getKeymap: true } }); + const keymap = keymapResp?.keymap?.getKeymap; if (!keymap) { - console.error("Failed to fetch current keymap", keymapResp); + notify("error", "Failed to fetch current keymap from device"); return; } - // Fetch behavior list to map names to IDs - let behaviorList = await call_rpc(conn.conn, { behaviors: { listAllBehaviors: true } }); - let behaviorIds = behaviorList?.behaviors?.listAllBehaviors?.behaviors || []; + const behaviorList = await call_rpc(conn.conn, { behaviors: { listAllBehaviors: true } }); + const behaviorIds = behaviorList?.behaviors?.listAllBehaviors?.behaviors || []; const behaviorNameToId: Record = {}; + const behaviorDetailsById: Record = {}; for (const id of behaviorIds) { - let details = await call_rpc(conn.conn, { behaviors: { getBehaviorDetails: { behaviorId: id } } }); - let d = details?.behaviors?.getBehaviorDetails; + const details = await call_rpc(conn.conn, { behaviors: { getBehaviorDetails: { behaviorId: id } } }); + const d = details?.behaviors?.getBehaviorDetails; if (d) { const name = d.displayName.replace(/^&/, ""); behaviorNameToId[name.toLowerCase()] = d.id; behaviorNameToId[d.displayName.toLowerCase()] = d.id; + // Also accept the DTS reference form (e.g. "kp" for "Key Press") so + // that .keymap files can be imported using ZMK's standard syntax. + behaviorNameToId[dtsRefForDisplayName(d.displayName).toLowerCase()] = d.id; + behaviorDetailsById[d.id] = d; } } - // Apply each parsed layer's bindings + let appliedCount = 0; + let skippedCount = 0; + let preservedCount = 0; + let failedCount = 0; + const unknownBehaviors = new Set(); + const readOnlyBehaviors = new Set(); + const failedBindings: Array<{ + layer: number; + position: number; + behavior: string; + resp: unknown; + }> = []; + for (let li = 0; li < parsedLayers.length && li < keymap.layers.length; li++) { const parsedLayer = parsedLayers[li]; const targetLayer = keymap.layers[li]; @@ -381,48 +438,125 @@ function App() { const parsedBinding = parsedLayer.bindings[ki]; const behaviorName = parsedBinding.behavior.toLowerCase(); - // Handle special behaviors - if (behaviorName === "trans" || behaviorName === "transparent") { - continue; // Skip transparent, keep existing - } - if (behaviorName === "none") { - continue; // Skip none, keep existing - } - const behaviorId = behaviorNameToId[behaviorName]; if (behaviorId === undefined) { - console.warn(`Unknown behavior: &${parsedBinding.behavior}, skipping`); + unknownBehaviors.add(parsedBinding.behavior); + skippedCount++; continue; } const param1 = parsedBinding.params[0] || 0; const param2 = parsedBinding.params[1] || 0; - await call_rpc(conn.conn, { - keymap: { setLayerBinding: { layerId: targetLayer.id, keyPosition: ki, binding: { behaviorId, param1, param2 } } }, + const resp = await call_rpc(conn.conn, { + keymap: { + setLayerBinding: { + layerId: targetLayer.id, + keyPosition: ki, + binding: { behaviorId, param1, param2 }, + }, + }, + }); + const code = resp.keymap?.setLayerBinding; + if (code === 0 /* SET_LAYER_BINDING_RESP_OK */) { + appliedCount++; + continue; + } + + // Firmware rejected the call. ZMK ships some behaviors with + // `metadata: []` (e.g. ext_power, mouse_move, mouse_scroll on the + // tested Cornix build) which means "no setLayerBinding parameter + // shape is exposed via Studio". For those, treat the rejection as + // an expected "preserved" outcome: the saved value matches the + // file already, and there is no Studio API path to overwrite it. + // Other rejections are real param-shape mismatches and should + // surface to the user. + const det = behaviorDetailsById[behaviorId]; + const isStudioReadOnly = + !det?.metadata || det.metadata.length === 0; + if (isStudioReadOnly) { + readOnlyBehaviors.add(parsedBinding.behavior); + preservedCount++; + continue; + } + + failedCount++; + failedBindings.push({ + layer: li, + position: ki, + behavior: parsedBinding.behavior, + resp: code, }); + console.warn( + `[import] setLayerBinding FAILED layer=${li} pos=${ki} behavior='${parsedBinding.behavior}' (id=${behaviorId}) params=[${param1}, ${param2}] resp=${code}`, + { metadata: det } + ); } } - // Refresh the keymap state + console.log( + `[import] applied=${appliedCount} preserved=${preservedCount} skipped=${skippedCount} failed=${failedCount}` + ); + if (failedBindings.length > 0) { + console.warn("[import] failed bindings:", failedBindings); + } + setConn({ conn: conn.conn }); + + // setLayerBinding has already updated the device's live working keymap; + // it just hasn't been committed to flash yet. Be explicit so the user + // doesn't think the changes are unwritten. + const persistReminder = + "Changes are live on the device. Press Save to keep them after restart, or Discard to revert."; + + const unknownDetail = + unknownBehaviors.size > 0 + ? ` Unknown behaviors: ${[...unknownBehaviors].slice(0, 5).join(", ")}${unknownBehaviors.size > 5 ? ", …" : ""}.` + : ""; + const readOnlyDetail = + readOnlyBehaviors.size > 0 + ? ` ${preservedCount} preserved (ZMK Studio can't edit ${[...readOnlyBehaviors].join(", ")}; original values kept).` + : ""; + const failedDetail = + failedCount > 0 ? ` Firmware rejected ${failedCount} (see console).` : ""; + + if (appliedCount === 0 && preservedCount === 0) { + notify( + "error", + `Import applied no bindings.${unknownDetail}${failedDetail}` + ); + } else if (skippedCount > 0 || failedCount > 0) { + notify( + "warning", + `Imported ${appliedCount}, skipped ${skippedCount}, rejected ${failedCount}.${unknownDetail}${readOnlyDetail}${failedDetail}`, + { action: persistReminder } + ); + } else { + notify( + "success", + `Imported ${appliedCount} bindings from ${file.name}.${readOnlyDetail}`, + { action: persistReminder } + ); + } } const file = fileInputRef.current?.files?.[0]; if (file) { - doImport(file); - // Reset input so same file can be re-imported + doImport(file).catch((e) => { + console.error("Import failed", e); + notify("error", `Import failed: ${e instanceof Error ? e.message : String(e)}`); + }); fileInputRef.current!.value = ""; } - }, [conn]); + }, [conn, notify]); const onConnect = useCallback( (t: RpcTransport) => { const ac = new AbortController(); setConnectionAbort(ac); - connect(t, setConn, setConnectedDeviceName, ac.signal); + connect(t, setConn, setConnectedDeviceName, ac.signal, notify); }, - [setConn, setConnectedDeviceName, setConnectedDeviceName] + [setConn, setConnectedDeviceName, notify] ); return ( From 048b044d75f27251b2a4c16507d71d2ea58ab8b6 Mon Sep 17 00:00:00 2001 From: numachang Date: Sat, 23 May 2026 03:15:40 +0900 Subject: [PATCH 08/14] fix(import): Don't fire setLayerBinding for positions whose value already matches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #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: " already matches the device. No changes needed." (info) - Some real updates: "Updated N binding(s) from (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 --- src/App.tsx | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 92a058c6..56896fd0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -417,7 +417,8 @@ function App() { } } - let appliedCount = 0; + let updatedCount = 0; + let unchangedCount = 0; let skippedCount = 0; let preservedCount = 0; let failedCount = 0; @@ -448,6 +449,20 @@ function App() { const param1 = parsedBinding.params[0] || 0; const param2 = parsedBinding.params[1] || 0; + // Skip the RPC entirely when the file's value matches the device's + // current value. Sending setLayerBinding for a no-op still flips + // the firmware's "unsaved changes" flag, which would leave Save + // glowing as if there was something to persist when there isn't. + const current = targetLayer.bindings[ki]; + if ( + current?.behaviorId === behaviorId && + current?.param1 === param1 && + current?.param2 === param2 + ) { + unchangedCount++; + continue; + } + const resp = await call_rpc(conn.conn, { keymap: { setLayerBinding: { @@ -459,7 +474,7 @@ function App() { }); const code = resp.keymap?.setLayerBinding; if (code === 0 /* SET_LAYER_BINDING_RESP_OK */) { - appliedCount++; + updatedCount++; continue; } @@ -495,7 +510,7 @@ function App() { } console.log( - `[import] applied=${appliedCount} preserved=${preservedCount} skipped=${skippedCount} failed=${failedCount}` + `[import] updated=${updatedCount} unchanged=${unchangedCount} preserved=${preservedCount} skipped=${skippedCount} failed=${failedCount}` ); if (failedBindings.length > 0) { console.warn("[import] failed bindings:", failedBindings); @@ -520,21 +535,31 @@ function App() { const failedDetail = failedCount > 0 ? ` Firmware rejected ${failedCount} (see console).` : ""; - if (appliedCount === 0 && preservedCount === 0) { + if ( + updatedCount === 0 && + unchangedCount === 0 && + preservedCount === 0 + ) { notify( "error", `Import applied no bindings.${unknownDetail}${failedDetail}` ); + } else if (updatedCount === 0 && failedCount === 0 && skippedCount === 0) { + // Everything in the file already matched the device — nothing to save. + notify( + "info", + `${file.name} already matches the device. No changes needed.${readOnlyDetail}` + ); } else if (skippedCount > 0 || failedCount > 0) { notify( "warning", - `Imported ${appliedCount}, skipped ${skippedCount}, rejected ${failedCount}.${unknownDetail}${readOnlyDetail}${failedDetail}`, + `Updated ${updatedCount} (${unchangedCount} already matched), skipped ${skippedCount}, rejected ${failedCount}.${unknownDetail}${readOnlyDetail}${failedDetail}`, { action: persistReminder } ); } else { notify( "success", - `Imported ${appliedCount} bindings from ${file.name}.${readOnlyDetail}`, + `Updated ${updatedCount} binding${updatedCount === 1 ? "" : "s"} from ${file.name} (${unchangedCount} already matched).${readOnlyDetail}`, { action: persistReminder } ); } From 453980d64a7b88de8882acfe6f82813e0de0c4e3 Mon Sep 17 00:00:00 2001 From: numachang Date: Sat, 23 May 2026 10:15:17 +0900 Subject: [PATCH 09/14] feat(picker): Adopt upstream PR #159 grid-style HID usage picker Squashes upstream zmkfirmware/zmk-studio#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/zmk-studio#159 (Andrew Kannan, @awkannan). Apache-2.0 license preserved. Co-Authored-By: Andrew Kannan Co-Authored-By: Claude --- src/behaviors/HidUsagePicker.tsx | 280 ++++++++++++++++++-------- src/hid-usage-metadata.json | 314 ++++++++++++++++++++++++++++++ src/hid-usage-name-overrides.json | 85 -------- src/hid-usages.ts | 30 ++- src/keyboard/HidUsageLabel.tsx | 8 +- 5 files changed, 533 insertions(+), 184 deletions(-) create mode 100644 src/hid-usage-metadata.json delete mode 100644 src/hid-usage-name-overrides.json diff --git a/src/behaviors/HidUsagePicker.tsx b/src/behaviors/HidUsagePicker.tsx index 4cbf8566..d8dedf5c 100644 --- a/src/behaviors/HidUsagePicker.tsx +++ b/src/behaviors/HidUsagePicker.tsx @@ -2,21 +2,18 @@ import { Button, Checkbox, CheckboxGroup, - Collection, ComboBox, - Header, Input, - Key, Label, ListBox, ListBoxItem, Popover, - Section, + Tab, + TabList, + TabPanel, + Tabs, } from "react-aria-components"; -import { - hid_usage_from_page_and_id, - hid_usage_page_get_ids, -} from "../hid-usages"; +import { hid_usage_page_get_ids, hid_usage_get_metadata } from "../hid-usages"; import { useCallback, useMemo } from "react"; import { ChevronDown } from "lucide-react"; @@ -33,41 +30,6 @@ export interface HidUsagePickerProps { onValueChanged: (value?: number) => void; } -type UsageSectionProps = HidUsagePage; - -const UsageSection = ({ id, min, max }: UsageSectionProps) => { - const info = useMemo(() => hid_usage_page_get_ids(id), [id]); - - let usages = useMemo(() => { - let usages = info?.UsageIds || []; - if (max || min) { - usages = usages.filter( - (i) => - (i.Id <= (max || Number.MAX_SAFE_INTEGER) && i.Id >= (min || 0)) || - (id === 7 && i.Id >= 0xe0 && i.Id <= 0xe7) - ); - } - - return usages; - }, [id, min, max, info]); - - return ( -
-
{info?.Name}
- - {(i) => ( - - {i.Name} - - )} - -
- ); -}; - enum Mods { LeftControl = 0x01, LeftShift = 0x02, @@ -109,6 +71,168 @@ function mask_mods(value: number) { return value & ~(mods_to_flags(all_mods) << 24); } +const HidUsageGrid = ({ + value, + onValueChanged, + usagePages, +}: HidUsagePickerProps) => { + type Usage = { + Name: string; + Id: number; + pageName: string; + pageId: number; + }; + const allUsages = useMemo(() => { + return usagePages.flatMap((page) => { + const pageInfo = hid_usage_page_get_ids(page.id); + if (!pageInfo) { + return []; + } + + let usages = pageInfo.UsageIds || []; + if (page.max || page.min) { + usages = usages.filter( + (i) => + (i.Id <= (page.max || Number.MAX_SAFE_INTEGER) && + i.Id >= (page.min || 0)) || + (page.id === 7 && i.Id >= 0xe0 && i.Id <= 0xe7), + ); + } + + return usages.map((usage) => ({ + ...usage, + pageId: page.id, + pageName: pageInfo.Name, + })); + }); + }, [usagePages]); + + const selectedKey = value !== undefined ? mask_mods(value) : null; + + const getButtonLabel = (usage: Usage) => { + const metadata = hid_usage_get_metadata(usage.pageId, usage.Id); + if (metadata?.med) { + return metadata.med; + } + if (metadata?.short) { + return metadata.short; + } + + if (usage.pageName === "Keyboard/Keypad") { + const match = usage.Name.match(/^(Keyboard|Keypad) (\S+)/); + if (match && match[2]) { + return match[2]; + } + } + return usage.Name; + }; + + const categorizedUsages = useMemo(() => { + const categories: Record = {}; + + for (const usage of allUsages) { + const metadata = hid_usage_get_metadata(usage.pageId, usage.Id); + const category = metadata?.category || "Other"; + + if (!categories[category]) { + categories[category] = []; + } + categories[category].push(usage); + } + + return categories; + }, [allUsages]); + + const categoryOrder = [ + "Letters", + "Numbers + Punctuation", + "Function + Navigation", + "Numpad", + "Apps/Media/Special", + "International", + "Other", + ]; + const sortedCategories = Object.keys(categorizedUsages).sort((a, b) => { + const indexA = categoryOrder.indexOf(a); + const indexB = categoryOrder.indexOf(b); + if (indexA !== -1 && indexB !== -1) return indexA - indexB; + if (indexA !== -1) return -1; + if (indexB !== -1) return 1; + return a.localeCompare(b); + }); + + return ( + + + {sortedCategories.map((category) => ( + + {category} + + ))} + + {sortedCategories.map((category) => ( + + {category === "Other" ? ( + + key !== null && onValueChanged(key as number) + } + > + +
+ + +
+ + + {(item: Usage) => { + const usageValue = (item.pageId << 16) | item.Id; + return ( + + {item.Name} + + ); + }} + + +
+ ) : ( + categorizedUsages[category].map((usage) => { + const usageValue = (usage.pageId << 16) | usage.Id; + return ( + + ); + }) + )} +
+ ))} +
+ ); +}; + export const HidUsagePicker = ({ label, value, @@ -122,7 +246,7 @@ export const HidUsagePicker = ({ }, [value]); const selectionChanged = useCallback( - (e: Key | null) => { + (e: number | undefined) => { let value = typeof e == "number" ? e : undefined; if (value !== undefined) { let mod_flags = mods_to_flags(mods.map((m) => parseInt(m))); @@ -131,7 +255,7 @@ export const HidUsagePicker = ({ onValueChanged(value); }, - [onValueChanged, mods] + [onValueChanged, mods], ); const modifiersChanged = useCallback( @@ -144,49 +268,35 @@ export const HidUsagePicker = ({ let new_value = mask_mods(value) | (mod_flags << 24); onValueChanged(new_value); }, - [value] + [value], ); return ( -
- {label && } - -
- - -
- - - {({ id, min, max }) => } - - -
- - {all_mods.map((m) => ( - - {mod_labels[m]} - - ))} - +
+
+ {label && } + + {all_mods.map((m) => ( + + {mod_labels[m]} + + ))} + +
+
); }; diff --git a/src/hid-usage-metadata.json b/src/hid-usage-metadata.json new file mode 100644 index 00000000..96e71035 --- /dev/null +++ b/src/hid-usage-metadata.json @@ -0,0 +1,314 @@ +{ + "7": { + "4": { "category": "Letters" }, + "5": { "category": "Letters" }, + "6": { "category": "Letters" }, + "7": { "category": "Letters" }, + "8": { "category": "Letters" }, + "9": { "category": "Letters" }, + "10": { "category": "Letters" }, + "11": { "category": "Letters" }, + "12": { "category": "Letters" }, + "13": { "category": "Letters" }, + "14": { "category": "Letters" }, + "15": { "category": "Letters" }, + "16": { "category": "Letters" }, + "17": { "category": "Letters" }, + "18": { "category": "Letters" }, + "19": { "category": "Letters" }, + "20": { "category": "Letters" }, + "21": { "category": "Letters" }, + "22": { "category": "Letters" }, + "23": { "category": "Letters" }, + "24": { "category": "Letters" }, + "25": { "category": "Letters" }, + "26": { "category": "Letters" }, + "27": { "category": "Letters" }, + "28": { "category": "Letters" }, + "29": { "category": "Letters" }, + "30": { "short": "1 !", "category": "Numbers + Punctuation" }, + "31": { "short": "2 @", "category": "Numbers + Punctuation" }, + "32": { "short": "3 #", "category": "Numbers + Punctuation" }, + "33": { "short": "4 $", "category": "Numbers + Punctuation" }, + "34": { "short": "5 %", "category": "Numbers + Punctuation" }, + "35": { "short": "6 ^", "category": "Numbers + Punctuation" }, + "36": { "short": "7 &", "category": "Numbers + Punctuation" }, + "37": { "short": "8 *", "category": "Numbers + Punctuation" }, + "38": { "short": "9 (", "category": "Numbers + Punctuation" }, + "39": { "short": "0 )", "category": "Numbers + Punctuation" }, + "40": { + "short": "Ret", + "med": "Return", + "category": "Function + Navigation" + }, + "41": { + "short": "Esc", + "long": "Escape", + "category": "Function + Navigation" + }, + "42": { + "short": "BkSp", + "med": "BkSpc", + "long": "Backspace", + "category": "Function + Navigation" + }, + "43": { "category": "Function + Navigation" }, + "44": { "short": "␣", "med": "Space", "category": "Function + Navigation" }, + "45": { + "short": "- _", + "med": "Dash", + "category": "Numbers + Punctuation" + }, + "46": { + "short": "= +", + "med": "Equals", + "category": "Numbers + Punctuation" + }, + "47": { "short": "[ {", "category": "Numbers + Punctuation" }, + "48": { "short": "] }", "category": "Numbers + Punctuation" }, + "49": { "short": "\\ |", "category": "Numbers + Punctuation" }, + "50": { + "short": "NUHS", + "long": "NonUS Hash", + "category": "International" + }, + "51": { "short": "; :", "category": "Numbers + Punctuation" }, + "52": { "short": "' \"", "category": "Numbers + Punctuation" }, + "53": { "short": "` ~", "category": "Numbers + Punctuation" }, + "54": { "short": ", <", "category": "Numbers + Punctuation" }, + "55": { "short": ". >", "category": "Numbers + Punctuation" }, + "56": { "short": "/ ?", "category": "Numbers + Punctuation" }, + "57": { + "short": "Cap", + "med": "CapsLk", + "long": "Caps Lock", + "category": "Function + Navigation" + }, + "58": { "category": "Function + Navigation" }, + "59": { "category": "Function + Navigation" }, + "60": { "category": "Function + Navigation" }, + "61": { "category": "Function + Navigation" }, + "62": { "category": "Function + Navigation" }, + "63": { "category": "Function + Navigation" }, + "64": { "category": "Function + Navigation" }, + "65": { "category": "Function + Navigation" }, + "66": { "category": "Function + Navigation" }, + "67": { "category": "Function + Navigation" }, + "68": { "category": "Function + Navigation" }, + "69": { "category": "Function + Navigation" }, + "70": { + "short": "PrSc", + "long": "Print Scr", + "category": "Function + Navigation" + }, + "71": { + "short": "ScLk", + "long": "ScrollLock", + "category": "Function + Navigation" + }, + "72": { + "short": "Paus", + "med": "Pause", + "category": "Function + Navigation" + }, + "73": { + "short": "Ins", + "med": "Insert", + "category": "Function + Navigation" + }, + "74": { "category": "Function + Navigation" }, + "75": { + "short": "PgUp", + "med": "PageUp", + "long": "Page Up", + "category": "Function + Navigation" + }, + "76": { + "short": "Del", + "med": "Delete", + "category": "Function + Navigation" + }, + "77": { "category": "Function + Navigation" }, + "78": { + "short": "PgDn", + "med": "PageDn", + "long": "Page Down", + "category": "Function + Navigation" + }, + "79": { "short": "→", "category": "Function + Navigation" }, + "80": { "short": "←", "category": "Function + Navigation" }, + "81": { "short": "↓", "category": "Function + Navigation" }, + "82": { "short": "↑", "category": "Function + Navigation" }, + "83": { + "short": "Num", + "med": "NumLck", + "long": "Num Lock", + "category": "Numpad" + }, + "84": { "short": "/", "category": "Numpad" }, + "85": { "short": "*", "category": "Numpad" }, + "86": { "short": "-", "category": "Numpad" }, + "87": { "short": "+", "category": "Numpad" }, + "88": { + "short": "Ent", + "med": "KP Ent", + "long": "KP Enter", + "category": "Numpad" + }, + "89": { "short": "1 En", "med": "1 End", "category": "Numpad" }, + "90": { "short": "2 ↓", "category": "Numpad" }, + "91": { "short": "3 PD", "med": "3 PgDn", "category": "Numpad" }, + "92": { "short": "4 ←", "category": "Numpad" }, + "93": { "short": "5", "category": "Numpad" }, + "94": { "short": "6 →", "category": "Numpad" }, + "95": { "short": "7 Hm", "med": "7 Home", "category": "Numpad" }, + "96": { "short": "8 ↑", "category": "Numpad" }, + "97": { "short": "9 PU", "med": "9 PgUp", "category": "Numpad" }, + "98": { + "short": "0 In", + "med": "0 Ins", + "long": "0 Insert", + "category": "Numpad" + }, + "99": { + "short": ". Dl", + "med": ". Del", + "long": ". Delete", + "category": "Numpad" + }, + "101": { + "short": "Menu", + "med": "Menu", + "long": "Applicat'n (Menu)", + "category": "Function + Navigation" + }, + "102": { + "short": "Power", + "med": "Power", + "category": "Apps/Media/Special" + }, + "100": { "short": "NUBS", "category": "International" }, + "103": { "short": "=", "category": "Numpad" }, + "104": { "category": "Apps/Media/Special" }, + "105": { "category": "Apps/Media/Special" }, + "106": { "category": "Apps/Media/Special" }, + "107": { "category": "Apps/Media/Special" }, + "108": { "category": "Apps/Media/Special" }, + "109": { "category": "Apps/Media/Special" }, + "110": { "category": "Apps/Media/Special" }, + "111": { "category": "Apps/Media/Special" }, + "112": { "category": "Apps/Media/Special" }, + "113": { "category": "Apps/Media/Special" }, + "114": { "category": "Apps/Media/Special" }, + "115": { "category": "Apps/Media/Special" }, + "118": { "category": "Apps/Media/Special" }, + "133": { "short": ",", "category": "Numpad" }, + "135": { "short": "Intl1", "med": "Int1 ろ", "category": "International" }, + "136": { + "short": "Intl2", + "med": "Int2 かな", + "category": "International" + }, + "137": { "short": "Intl3", "med": "Int3 ¥", "category": "International" }, + "138": { + "short": "Intl4", + "med": "Int4 変換", + "category": "International" + }, + "139": { + "short": "Intl5", + "med": "Int5 無変換", + "category": "International" + }, + "140": { "short": "Intl6", "med": "Int6 ,", "category": "International" }, + "141": { "short": "Intl7", "category": "International" }, + "142": { "short": "Intl8", "category": "International" }, + "143": { "short": "Intl9", "category": "International" }, + "144": { + "short": "Lang1", + "med": "Lang1 한/영", + "category": "International" + }, + "145": { + "short": "Lang2", + "med": "Lang2 한자", + "category": "International" + }, + "146": { + "short": "Lang3", + "med": "Lang3 カタカナ", + "category": "International" + }, + "147": { + "short": "Lang4", + "med": "Lang4 ひらがな", + "category": "International" + }, + "148": { + "short": "Lang5", + "med": "Lang5 半角/全角", + "category": "International" + }, + "149": { "short": "Lang6", "category": "International" }, + "150": { "short": "Lang7", "category": "International" }, + "151": { "short": "Lang8", "category": "International" }, + "152": { "short": "Lang9", "category": "International" }, + "176": { "short": "00", "category": "Numpad" }, + "177": { "short": "000" }, + "224": { + "short": "Ctrl", + "med": "L Ctrl", + "category": "Function + Navigation" + }, + "225": { + "short": "Shft", + "med": "L Shft", + "long": "L Shift", + "category": "Function + Navigation" + }, + "226": { + "short": "Alt", + "med": "L Alt", + "long": "Left Alt", + "category": "Function + Navigation" + }, + "227": { + "short": "GUI", + "med": "L GUI", + "long": "Left GUI", + "category": "Function + Navigation" + }, + "228": { + "short": "Ctrl", + "med": "R Ctrl", + "category": "Function + Navigation" + }, + "229": { + "short": "Shft", + "med": "R Shft", + "long": "R Shift", + "category": "Function + Navigation" + }, + "230": { + "short": "AltG", + "med": "AltGr", + "category": "Function + Navigation" + }, + "231": { + "short": "GUI", + "med": "R GUI", + "long": "Right GUI", + "category": "Function + Navigation" + } + }, + "12": { + "111": { "short": "🔆", "category": "Apps/Media/Special" }, + "112": { "short": "🔅", "category": "Apps/Media/Special" }, + "181": { "short": "⇥", "category": "Apps/Media/Special" }, + "182": { "short": "⇤", "category": "Apps/Media/Special" }, + "205": { "short": "⏯️", "category": "Apps/Media/Special" }, + "226": { "short": "🔇", "category": "Apps/Media/Special" }, + "233": { "short": "🔊", "category": "Apps/Media/Special" }, + "234": { "short": "🔉", "category": "Apps/Media/Special" } + } +} diff --git a/src/hid-usage-name-overrides.json b/src/hid-usage-name-overrides.json deleted file mode 100644 index f2773ff5..00000000 --- a/src/hid-usage-name-overrides.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "7": { - "30": { "short": "1" }, - "31": { "short": "2" }, - "32": { "short": "3" }, - "33": { "short": "4" }, - "34": { "short": "5" }, - "35": { "short": "6" }, - "36": { "short": "7" }, - "37": { "short": "8" }, - "38": { "short": "9" }, - "39": { "short": "0" }, - "40": { "short": "Ret", "med": "Return" }, - "41": { "short": "Esc", "long": "Escape" }, - "42": { "short": "BkSp", "med": "BkSpc", "long": "Backspace" }, - "44": { "short": "␣", "med": "Space" }, - "45": { "short": "-", "med": "Dash" }, - "46": { "short": "=", "med": "Equals" }, - "47": { "short": "{" }, - "48": { "short": "}" }, - "49": { "short": "\\" }, - "50": { "short": "NUHS", "long": "NonUS Hash" }, - "51": { "short": ";" }, - "52": { "short": "'" }, - "53": { "short": "`" }, - "54": { "short": "," }, - "55": { "short": "." }, - "56": { "short": "/" }, - "57": { "short": "Cap", "long": "Caps Lock" }, - "70": { "short": "PrSc", "long": "Print Scr" }, - "71": { "short": "ScLk", "long": "ScrollLock" }, - "72": { "short": "Paus", "med": "Pause" }, - "73": { "short": "Ins", "med": "Insert" }, - "75": { "short": "PgUp", "med": "PageUp", "long": "Page Up" }, - "76": { "short": "Del", "med": "Delete" }, - "78": { "short": "PgDn", "med": "PageDn", "long": "Page Down" }, - "79": { "short": "→" }, - "80": { "short": "←" }, - "81": { "short": "↓" }, - "82": { "short": "↑" }, - "83": { "short": "Num", "med": "NumLck", "long": "Num Lock" }, - "84": { "short": "/" }, - "85": { "short": "*" }, - "86": { "short": "-" }, - "87": { "short": "+" }, - "88": { "short": "Ent", "med": "KP Ent", "long": "KP Enter" }, - "89": { "short": "1 En", "med": "1 End" }, - "90": { "short": "2 ↓" }, - "91": { "short": "3 PD", "med": "3 PgDn" }, - "92": { "short": "4 ←" }, - "93": { "short": "5" }, - "94": { "short": "6 →" }, - "95": { "short": "7 Hm", "med": "7 Home" }, - "96": { "short": "8 ↑" }, - "97": { "short": "9 PU", "med": "9 PgUp" }, - "98": { "short": "0 In", "med": "0 Ins", "long": "0 Insert" }, - "99": { "short": ". Dl", "med": ". Del", "long": ". Delete" }, - "101": { "short": "Appl", "med": "Appl", "long": "Applicat'n" }, - "102": { "short": "Power", "med": "Power" }, - "100": { "short": "NUBS" }, - "103": { "short": "=" }, - "133": { "short": "," }, - "134": { "short": "=" }, - "176": { "short": "00" }, - "177": { "short": "000" }, - "224": { "short": "Ctrl", "med": "L Ctrl" }, - "225": { "short": "Shft", "med": "L Shft", "long": "L Shift" }, - "226": { "short": "Alt", "med": "L Alt", "long": "Left Alt" }, - "227": { "short": "GUI", "med": "L GUI", "long": "Left GUI" }, - "228": { "short": "Ctrl", "med": "R Ctrl" }, - "229": { "short": "Shft", "med": "R Shft", "long": "R Shift" }, - "230": { "short": "AltG", "med": "AltGr" }, - "231": { "short": "GUI", "med": "R GUI", "long": "Right GUI" } - }, - "12": { - "111": { "short": "🔆" }, - "112": { "short": "🔅" }, - "181": { "short": "⇥" }, - "182": { "short": "⇤" }, - "205": { "short": "⏯️" }, - "226": { "short": "🔇" }, - "233": { "short": "🔊" }, - "234": { "short": "🔉" } - } -} diff --git a/src/hid-usages.ts b/src/hid-usages.ts index a3e3dd84..d99ceefc 100644 --- a/src/hid-usages.ts +++ b/src/hid-usages.ts @@ -1,15 +1,16 @@ // import { UsagePages } from "./HidUsageTables-1.5.json"; // Filtered with `cat src/HidUsageTables-1.5.json | jq '{ UsagePages: [.UsagePages[] | select([.Id] |inside([7, 12]))] }' > src/keyboard-and-consumer-usage-tables.json` import { UsagePages } from "./keyboard-and-consumer-usage-tables.json"; -import HidOverrides from "./hid-usage-name-overrides.json"; +import HidSupplementaryMetadata from "./hid-usage-metadata.json"; -interface HidLabels { +interface HidMetadata { short?: string; med?: string; long?: string; + category?: string; } -const overrides: Record> = HidOverrides; +const overrides: Record> = HidSupplementaryMetadata; export interface UsageId { Id: number; @@ -41,12 +42,21 @@ export const hid_usage_get_label = ( (u) => u.Id === usage_id )?.Name; -export const hid_usage_get_labels = ( +export const hid_usage_get_metadata = ( usage_page: number, usage_id: number -): { short?: string; med?: string; long?: string } => - overrides[usage_page.toString()]?.[usage_id.toString()] || { - short: UsagePages.find((p) => p.Id === usage_page)?.UsageIds?.find( - (u) => u.Id === usage_id - )?.Name, - }; +): { short?: string; med?: string; long?: string, category?: string } => { + if(overrides[usage_page.toString()]?.[usage_id.toString()]?.short) { + return overrides[usage_page.toString()]?.[usage_id.toString()]; + } else { + const fullName = UsagePages.find((p) => p.Id === usage_page)?.UsageIds?.find( + (u) => u.Id === usage_id + )?.Name + return { + short: fullName?.replace(/^Keyboard /, ""), + med: fullName?.replace(/^Keyboard /, ""), + long: fullName, + category: overrides[usage_page.toString()]?.[usage_id.toString()]?.category || "Other" + } + } +} diff --git a/src/keyboard/HidUsageLabel.tsx b/src/keyboard/HidUsageLabel.tsx index aa15195c..2ad05f52 100644 --- a/src/keyboard/HidUsageLabel.tsx +++ b/src/keyboard/HidUsageLabel.tsx @@ -1,5 +1,5 @@ import { - hid_usage_get_labels, + hid_usage_get_metadata, hid_usage_page_and_id_from_usage, } from "../hid-usages"; @@ -17,13 +17,13 @@ export const HidUsageLabel = ({ hid_usage }: HidUsageLabelProps) => { // TODO: Do something with implicit mods! page &= 0xff; - let labels = hid_usage_get_labels(page, id); + let labels = hid_usage_get_metadata(page, id); return ( Date: Sat, 23 May 2026 10:21:02 +0900 Subject: [PATCH 10/14] feat(picker): Add cross-tab search bar and visual layer picker Layered on top of upstream PR #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 setSearch(e.target.value)} + className="w-full pl-8 pr-2 py-1 rounded border border-base-300 bg-base-100 focus:outline-none focus:border-primary" + /> +
+ {searchResults !== null ? ( +
+ {searchResults.length === 0 ? ( +
No keys match "{search}".
+ ) : ( + searchResults.map(renderUsageButton) + )} +
+ ) : ( {sortedCategories.map((category) => ( @@ -214,22 +274,13 @@ const HidUsageGrid = ({ ) : ( - categorizedUsages[category].map((usage) => { - const usageValue = (usage.pageId << 16) | usage.Id; - return ( - - ); - }) + categorizedUsages[category].map(renderUsageButton) )} ))} + )} + ); }; diff --git a/src/behaviors/ParameterValuePicker.tsx b/src/behaviors/ParameterValuePicker.tsx index ca0aaae4..fdf2d1ac 100644 --- a/src/behaviors/ParameterValuePicker.tsx +++ b/src/behaviors/ParameterValuePicker.tsx @@ -58,17 +58,33 @@ export const ParameterValuePicker = ({ ); } else if (values[0].layerId) { return ( -
- - + {layers.map(({ name, id }, i) => { + const selected = value === id; + return ( + + ); + })} +
); } diff --git a/src/keymap-parser.ts b/src/keymap-parser.ts index 3d8fc03b..2cba6e2a 100644 --- a/src/keymap-parser.ts +++ b/src/keymap-parser.ts @@ -1,10 +1,11 @@ import { UsagePages } from "./keyboard-and-consumer-usage-tables.json"; -import HidOverrides from "./hid-usage-name-overrides.json"; +import HidOverrides from "./hid-usage-metadata.json"; interface HidLabels { short?: string; med?: string; long?: string; + category?: string; } const overrides: Record> = HidOverrides; From 931edf036ec35e9feb463b6530e7bd09f1be12be Mon Sep 17 00:00:00 2001 From: numachang Date: Sat, 23 May 2026 16:12:44 +0900 Subject: [PATCH 11/14] feat(picker): Rework behavior selector into tier+category chip grid 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 { - setBehaviorId(parseInt(e.target.value)); - setParam1(0); - setParam2(0); - }} - > - {sortedBehaviors.map((b) => ( - - ))} - +
+ +
+
+ {availableTiers.map((tier) => { + const isActive = activeTier === tier.key; + return ( + + ); + })} +
+
+ {availableGroupsForActiveTier.map((g) => { + const isActive = effectiveGroup === g; + return ( + + ); + })} +
+
+ {(tieredBehaviors[activeTier][effectiveGroup] || []).map( + renderBehaviorChip + )} +
+
{metadata && ( { - if (param1 === undefined) { - return ( -
- m.param1)} - onValueChanged={onParam1Changed} - layers={layers} - /> -
- ); - } else { - const set = metadata.find((s) => + const [activeParam, setActiveParam] = useState<"p1" | "p2">("p1"); + + const param1Values = metadata.flatMap((m) => m.param1); + // Fall back to the first set when the current param1 doesn't validate + // (typical when a fresh behavior is picked and param1 hasn't been set + // yet) so we can still inspect param2's shape and render the right tabs. + const set = + metadata.find((s) => validateValue( layers.map((l) => l.id), param1, s.param1 ) - ); - return ( - <> - m.param1)} - value={param1} - layers={layers} - onValueChanged={onParam1Changed} - /> - {(set?.param2?.length || 0) > 0 && ( + ) ?? metadata[0]; + const param2Values = set?.param2 ?? []; + + const hasParam1 = param1Values.length > 0; + const hasParam2 = param2Values.length > 0; + + const param1Name = param1Values[0]?.name || "Param 1"; + const param2Name = param2Values[0]?.name || "Param 2"; + const sameName = hasParam2 && param1Name === param2Name; + // ZMK hold-tap class behaviors (Mod-Tap, Layer-Tap, custom homerow_mods, + // ...) share the shape: param1 = whatever you hold, param2 = a HID key + // that fires on tap. Label them with the user-facing Hold / Tap concept + // rather than the raw metadata names. + const isHoldTapLike = hasParam1 && hasParam2 && !!param2Values[0]?.hidUsage; + const tab1Label = isHoldTapLike + ? "Hold" + : sameName + ? `${param1Name} 1` + : hasParam1 + ? param1Name + : "—"; + const tab2Label = isHoldTapLike + ? "Tap" + : sameName + ? `${param2Name} 2` + : param2Name; + + const tabClass = + "px-4 py-1 cursor-default outline-none rac-selected:border-b-2 rac-selected:border-primary rac-focus-visible:ring-2 rac-focus-visible:ring-primary rac-disabled:opacity-40 rac-disabled:cursor-not-allowed rounded-t-md"; + + return ( + setActiveParam(k as "p1" | "p2")} + > + + + {tab1Label} + + {hasParam2 && ( + + {tab2Label} + + )} + + + {hasParam1 ? ( + ) : ( + // 0-param behaviors (None, Transparent, Caps Word, ...) get the + // dimmed HID picker as a placeholder so the panel keeps its size + // when the user toggles to/from a `&kp`-style behavior. + {}} + /> + )} + + {hasParam2 && ( + + - )} - - ); - } + + )} + + ); }; diff --git a/src/behaviors/ParameterValuePicker.tsx b/src/behaviors/ParameterValuePicker.tsx index fdf2d1ac..430fc4a8 100644 --- a/src/behaviors/ParameterValuePicker.tsx +++ b/src/behaviors/ParameterValuePicker.tsx @@ -25,7 +25,9 @@ export const ParameterValuePicker = ({ onChange={(e) => onValueChanged(parseInt(e.target.value))} > {values.map((v) => ( - + ))} From 0e226bf1f3f6d9f1cf2a35d62e3b63e3970c8368 Mon Sep 17 00:00:00 2001 From: numachang Date: Sat, 23 May 2026 16:13:04 +0900 Subject: [PATCH 12/14] feat(picker): Add physical-keyboard layouts to HID grid picker Layer ANSI 60% / TKL function+nav cluster / numpad layouts on top of upstream PR #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 --- src/behaviors/HidUsagePicker.tsx | 426 +++++++++++++++++++++++++++---- src/hid-usage-metadata.json | 54 +--- 2 files changed, 391 insertions(+), 89 deletions(-) diff --git a/src/behaviors/HidUsagePicker.tsx b/src/behaviors/HidUsagePicker.tsx index db32afc5..b0fed5e8 100644 --- a/src/behaviors/HidUsagePicker.tsx +++ b/src/behaviors/HidUsagePicker.tsx @@ -14,9 +14,90 @@ import { Tabs, } from "react-aria-components"; import { hid_usage_page_get_ids, hid_usage_get_metadata } from "../hid-usages"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { ChevronDown, Search } from "lucide-react"; +type BasicCell = { id: number; w?: number }; + +// ANSI 60% layout in HID page 7 IDs with per-key width multipliers (1U = +// standard square). Mirrors a real keyboard so the user can grab Caps / +// Shift / Space / Mods without leaving the tab and so the layout reads as a +// keyboard rather than a grid. +const BASIC_LAYOUT: BasicCell[][] = [ + // ` 1234567890 - = BkSp(2U) + [ + { id: 53 }, + { id: 30 }, { id: 31 }, { id: 32 }, { id: 33 }, { id: 34 }, + { id: 35 }, { id: 36 }, { id: 37 }, { id: 38 }, { id: 39 }, + { id: 45 }, { id: 46 }, { id: 42, w: 2 }, + ], + // Tab(1.5U) Q-P [ ] \(1.5U) + [ + { id: 43, w: 1.5 }, + { id: 20 }, { id: 26 }, { id: 8 }, { id: 21 }, { id: 23 }, + { id: 28 }, { id: 24 }, { id: 12 }, { id: 18 }, { id: 19 }, + { id: 47 }, { id: 48 }, { id: 49, w: 1.5 }, + ], + // Caps(1.75U) A-L ; ' Ret(2.25U) + [ + { id: 57, w: 1.75 }, + { id: 4 }, { id: 22 }, { id: 7 }, { id: 9 }, { id: 10 }, + { id: 11 }, { id: 13 }, { id: 14 }, { id: 15 }, + { id: 51 }, { id: 52 }, + { id: 40, w: 2.25 }, + ], + // LShft(2.25U) Z-M , . / RShft(2.75U) + [ + { id: 225, w: 2.25 }, + { id: 29 }, { id: 27 }, { id: 6 }, { id: 25 }, { id: 5 }, + { id: 17 }, { id: 16 }, { id: 54 }, { id: 55 }, { id: 56 }, + { id: 229, w: 2.75 }, + ], + // LCtrl LGUI LAlt(1.25U each) Space(6.25U) RAlt RGUI Menu RCtrl(1.25U each) + [ + { id: 224, w: 1.25 }, { id: 227, w: 1.25 }, { id: 226, w: 1.25 }, + { id: 44, w: 6.25 }, + { id: 230, w: 1.25 }, { id: 231, w: 1.25 }, + { id: 101, w: 1.25 }, { id: 228, w: 1.25 }, + ], +]; + +// Set of HID page 7 IDs that appear on BASIC_LAYOUT, used to keep them +// out of Navigation/etc. so the Basic tab is their only home. +const BASIC_LAYOUT_HID_IDS = new Set( + BASIC_LAYOUT.flatMap((row) => row.map((cell) => cell.id)) +); + +// Standard numeric keypad layout. + and KP Enter are normally 1U×2U and 0 +// is 2U×1U on a real numpad; we keep heights uniform and only widen 0 to +// 2U so the bottom row still looks like a numpad. +const NUMPAD_LAYOUT: BasicCell[][] = [ + [{ id: 83 }, { id: 84 }, { id: 85 }, { id: 86 }], + [{ id: 95 }, { id: 96 }, { id: 97 }, { id: 87 }], + [{ id: 92 }, { id: 93 }, { id: 94 }, { id: 88 }], + [{ id: 89 }, { id: 90 }, { id: 91 }], + [{ id: 98, w: 2 }, { id: 99 }], +]; + +// Function row laid out in 3 stacks so the tab isn't a single super-wide +// row. Esc sits at top-left where it lives on a real keyboard. +const FUNCTION_LAYOUT: (BasicCell | null)[][] = [ + [{ id: 41 }, { id: 58 }, { id: 59 }, { id: 60 }, { id: 61 }], // Esc F1 F2 F3 F4 + [null, { id: 62 }, { id: 63 }, { id: 64 }, { id: 65 }], // F5-F8 + [null, { id: 66 }, { id: 67 }, { id: 68 }, { id: 69 }], // F9-F12 +]; + +// TKL navigation cluster: PrSc/ScLk/Pause, the 2x3 edit block, then the +// inverted-T arrow cluster. `null` cells render as transparent spacers so +// the up arrow centers above left/down/right. +const NAVIGATION_LAYOUT: (BasicCell | null)[][] = [ + [{ id: 70 }, { id: 71 }, { id: 72 }], // PrSc ScLk Pause + [{ id: 73 }, { id: 74 }, { id: 75 }], // Ins Home PgUp + [{ id: 76 }, { id: 77 }, { id: 78 }], // Del End PgDn + [null, { id: 82 }, null ], // ↑ + [{ id: 80 }, { id: 81 }, { id: 79 }], // ← ↓ → +]; + export interface HidUsagePage { id: number; min?: number; @@ -28,6 +109,12 @@ export interface HidUsagePickerProps { value?: number; usagePages: HidUsagePage[]; onValueChanged: (value?: number) => void; + /** + * When true the picker stays visible but is fully non-interactive and + * dimmed. Used as a stable placeholder when the current behavior takes no + * parameters, so switching to/from `&kp` doesn't cause a layout jump. + */ + disabled?: boolean; } enum Mods { @@ -109,8 +196,11 @@ const HidUsageGrid = ({ const selectedKey = value !== undefined ? mask_mods(value) : null; - const getButtonLabel = (usage: Usage) => { + const getButtonLabel = (usage: Usage, opts?: { preferShort?: boolean }) => { const metadata = hid_usage_get_metadata(usage.pageId, usage.Id); + if (opts?.preferShort && metadata?.short) { + return metadata.short; + } if (metadata?.med) { return metadata.med; } @@ -132,7 +222,27 @@ const HidUsageGrid = ({ for (const usage of allUsages) { const metadata = hid_usage_get_metadata(usage.pageId, usage.Id); - const category = metadata?.category || "Other"; + let category = metadata?.category || "Other"; + // Collapse the typing keys (alphabet + number row + adjacent + // punctuation) into a single Basic tab. Two separate tabs for + // "Letters" vs "Numbers" forces an extra click for the most common + // edits. + if (category === "Letters" || category === "Numbers + Punctuation") { + category = "Basic"; + } + // The old "Function + Navigation" metadata category covers the TKL + // function row + navigation cluster + arrows. We keep them in one + // tab — they share screen real estate cleanly without scrolling. + if (category === "Function + Navigation") { + category = "Function + Nav"; + } + // Anything explicitly placed on the Basic layout (modifiers, Tab, + // Caps, Ret, BkSp, Space, Menu, ...) lives in the Basic tab alone — + // otherwise it'd appear in Navigation too and the auto-jump would + // prefer that stale duplicate. + if (usage.pageId === 7 && BASIC_LAYOUT_HID_IDS.has(usage.Id)) { + category = "Basic"; + } if (!categories[category]) { categories[category] = []; @@ -143,27 +253,173 @@ const HidUsageGrid = ({ return categories; }, [allUsages]); - const categoryOrder = [ - "Letters", - "Numbers + Punctuation", - "Function + Navigation", - "Numpad", - "Apps/Media/Special", - "International", - "Other", - ]; - const sortedCategories = Object.keys(categorizedUsages).sort((a, b) => { - const indexA = categoryOrder.indexOf(a); - const indexB = categoryOrder.indexOf(b); - if (indexA !== -1 && indexB !== -1) return indexA - indexB; - if (indexA !== -1) return -1; - if (indexB !== -1) return 1; - return a.localeCompare(b); - }); + const basicRows = useMemo(() => { + // Pull from allUsages so the edge keys (Tab/BkSp/Ret/modifiers/...) + // appear here regardless of which category their metadata assigns them. + const byHidId = new Map(); + for (const u of allUsages) { + if (u.pageId === 7) { + byHidId.set(u.Id, u); + } + } + const placed = new Set(); + const rows = BASIC_LAYOUT.map((row) => { + const out: { usage: Usage; w?: number }[] = []; + for (const cell of row) { + const u = byHidId.get(cell.id); + if (u) { + out.push({ usage: u, w: cell.w }); + placed.add(u); + } + } + return out; + }); + const extras = (categorizedUsages["Basic"] || []).filter( + (u) => !placed.has(u) + ); + if (extras.length) { + rows.push(extras.map((u) => ({ usage: u }))); + } + return rows; + }, [allUsages, categorizedUsages]); + + const UNIT_PX = 48; + // HID IDs whose "short" label (e.g. "Shft", "Ctrl") loses the L/R + // distinction. Use the metadata "med" label instead so the keycap reads + // as "L Shft" / "R Shft" / "CapsLk" etc. + const BASIC_PREFER_MED = new Set([ + 57, // Caps Lock + 224, 225, 226, 227, // Left mods (Ctrl/Shft/Alt/GUI) + 228, 229, 230, 231, // Right mods (Ctrl/Shft/Alt/GUI) + ]); + + const numpadRows = useMemo(() => { + const byHidId = new Map(); + for (const u of allUsages) { + if (u.pageId === 7) { + byHidId.set(u.Id, u); + } + } + const placed = new Set(); + const rows = NUMPAD_LAYOUT.map((row) => { + const out: { usage: Usage; w?: number }[] = []; + for (const cell of row) { + const u = byHidId.get(cell.id); + if (u) { + out.push({ usage: u, w: cell.w }); + placed.add(u); + } + } + return out; + }); + const extras = (categorizedUsages["Numpad"] || []).filter( + (u) => !placed.has(u) + ); + if (extras.length) { + rows.push(extras.map((u) => ({ usage: u }))); + } + return rows; + }, [allUsages, categorizedUsages]); + + // For the merged Function tab we render two layouts side-by-side. The + // layouts together cover every Function-category HID we care about, so + // we don't bother surfacing leftovers. + const { functionRows, navigationRows } = useMemo(() => { + const byHidId = new Map(); + for (const u of allUsages) { + if (u.pageId === 7) { + byHidId.set(u.Id, u); + } + } + const renderRow = (row: (BasicCell | null)[]) => { + const out: ({ usage: Usage; w?: number } | null)[] = []; + for (const cell of row) { + if (cell === null) { + out.push(null); + continue; + } + const u = byHidId.get(cell.id); + if (u) { + out.push({ usage: u, w: cell.w }); + } + } + return out; + }; + return { + functionRows: FUNCTION_LAYOUT.map(renderRow), + navigationRows: NAVIGATION_LAYOUT.map(renderRow), + }; + }, [allUsages]); + const renderBasicButton = ({ usage, w }: { usage: Usage; w?: number }) => { + const usageValue = (usage.pageId << 16) | usage.Id; + const preferShort = + !(usage.pageId === 7 && BASIC_PREFER_MED.has(usage.Id)); + const label = getButtonLabel(usage, { preferShort }); + const pair = label.match(/^(\S{1,2}) (\S{1,2})$/); + const width = (w ?? 1) * UNIT_PX + ((w ?? 1) - 1) * 4; // include the gap absorbed + return ( + + ); + }; + + const sortedCategories = useMemo(() => { + const categoryOrder = [ + "Basic", + "Function + Nav", + "Numpad", + "Apps/Media/Special", + "International", + "Other", + ]; + return Object.keys(categorizedUsages).sort((a, b) => { + const indexA = categoryOrder.indexOf(a); + const indexB = categoryOrder.indexOf(b); + if (indexA !== -1 && indexB !== -1) return indexA - indexB; + if (indexA !== -1) return -1; + if (indexB !== -1) return 1; + return a.localeCompare(b); + }); + }, [categorizedUsages]); const [search, setSearch] = useState(""); const trimmedSearch = search.trim().toLowerCase(); + // Auto-select the tab containing the current value when it changes. When + // the value resets (no selection / 0 from a behavior swap), fall back to + // the first tab instead of leaving the user on the previous behavior's + // stale category. + const [activeTab, setActiveTab] = useState(null); + useEffect(() => { + if (value === undefined || value === 0) { + setActiveTab(null); + return; + } + const masked = mask_mods(value); + for (const cat of sortedCategories) { + const hit = categorizedUsages[cat]?.some( + (u) => ((u.pageId << 16) | u.Id) === masked + ); + if (hit) { + setActiveTab(cat); + return; + } + } + }, [value, categorizedUsages, sortedCategories]); + const searchResults = useMemo(() => { if (!trimmedSearch) { return null; @@ -198,17 +454,22 @@ const HidUsageGrid = ({ }; return ( -
-
- - setSearch(e.target.value)} - className="w-full pl-8 pr-2 py-1 rounded border border-base-300 bg-base-100 focus:outline-none focus:border-primary" - /> +
+
+ +
+ + setSearch(e.target.value)} + className="w-full pl-8 pr-2 py-1 rounded border border-base-300 bg-base-100 focus:outline-none focus:border-primary text-sm" + /> +
{searchResults !== null ? (
) : ( - + setActiveTab(k as string)} + > {sortedCategories.map((category) => ( {category} @@ -238,9 +503,63 @@ const HidUsageGrid = ({ - {category === "Other" ? ( + {category === "Basic" ? ( +
+ {basicRows.map((row, i) => ( +
+ {row.map(renderBasicButton)} +
+ ))} +
+ ) : category === "Numpad" ? ( +
+ {numpadRows.map((row, i) => ( +
+ {row.map(renderBasicButton)} +
+ ))} +
+ ) : category === "Function + Nav" ? ( +
+
+ {functionRows.map((row, i) => ( +
+ {row.map((cell, j) => + cell ? ( + renderBasicButton(cell) + ) : ( +
+ ) + )} +
+ ))} +
+
+ {navigationRows.map((row, i) => ( +
+ {row.map((cell, j) => + cell ? ( + renderBasicButton(cell) + ) : ( +
+ ) + )} +
+ ))} +
+
+ ) : category === "Other" ? ( { const mods = useMemo(() => { let flags = value ? value >> 24 : 0; @@ -323,12 +643,24 @@ export const HidUsagePicker = ({ ); return ( -
-
- {label && } +
+
+
+ +
@@ -336,18 +668,20 @@ export const HidUsagePicker = ({ {mod_labels[m]} ))}
- +
+ +
); }; diff --git a/src/hid-usage-metadata.json b/src/hid-usage-metadata.json index 96e71035..555eb148 100644 --- a/src/hid-usage-metadata.json +++ b/src/hid-usage-metadata.json @@ -203,52 +203,20 @@ "115": { "category": "Apps/Media/Special" }, "118": { "category": "Apps/Media/Special" }, "133": { "short": ",", "category": "Numpad" }, - "135": { "short": "Intl1", "med": "Int1 ろ", "category": "International" }, - "136": { - "short": "Intl2", - "med": "Int2 かな", - "category": "International" - }, - "137": { "short": "Intl3", "med": "Int3 ¥", "category": "International" }, - "138": { - "short": "Intl4", - "med": "Int4 変換", - "category": "International" - }, - "139": { - "short": "Intl5", - "med": "Int5 無変換", - "category": "International" - }, - "140": { "short": "Intl6", "med": "Int6 ,", "category": "International" }, + "135": { "short": "Intl1", "category": "International" }, + "136": { "short": "Intl2", "category": "International" }, + "137": { "short": "Intl3", "category": "International" }, + "138": { "short": "Intl4", "category": "International" }, + "139": { "short": "Intl5", "category": "International" }, + "140": { "short": "Intl6", "category": "International" }, "141": { "short": "Intl7", "category": "International" }, "142": { "short": "Intl8", "category": "International" }, "143": { "short": "Intl9", "category": "International" }, - "144": { - "short": "Lang1", - "med": "Lang1 한/영", - "category": "International" - }, - "145": { - "short": "Lang2", - "med": "Lang2 한자", - "category": "International" - }, - "146": { - "short": "Lang3", - "med": "Lang3 カタカナ", - "category": "International" - }, - "147": { - "short": "Lang4", - "med": "Lang4 ひらがな", - "category": "International" - }, - "148": { - "short": "Lang5", - "med": "Lang5 半角/全角", - "category": "International" - }, + "144": { "short": "Lang1", "category": "International" }, + "145": { "short": "Lang2", "category": "International" }, + "146": { "short": "Lang3", "category": "International" }, + "147": { "short": "Lang4", "category": "International" }, + "148": { "short": "Lang5", "category": "International" }, "149": { "short": "Lang6", "category": "International" }, "150": { "short": "Lang7", "category": "International" }, "151": { "short": "Lang8", "category": "International" }, From c3b5cad5640025eb00df932475ad0d0fbe7faba4 Mon Sep 17 00:00:00 2001 From: numachang Date: Sat, 23 May 2026 16:13:28 +0900 Subject: [PATCH 13/14] feat(keymap): Polish key preview, fit long labels, surface RPC rejects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/keyboard/HidUsageLabel.tsx | 49 +++++++++++++-- src/keyboard/Key.tsx | 23 ++++++- src/keyboard/Keyboard.tsx | 14 ++++- src/keyboard/Keymap.tsx | 99 ++++++++++++++++++++++++++++--- src/keyboard/PhysicalLayout.tsx | 102 +++++++++++++++++++++++++------- 5 files changed, 249 insertions(+), 38 deletions(-) diff --git a/src/keyboard/HidUsageLabel.tsx b/src/keyboard/HidUsageLabel.tsx index 2ad05f52..ae6caf85 100644 --- a/src/keyboard/HidUsageLabel.tsx +++ b/src/keyboard/HidUsageLabel.tsx @@ -5,23 +5,62 @@ import { export interface HidUsageLabelProps { hid_usage: number; + /** + * When true, render "1 !" as a single token instead of stacking the + * shifted variant on top. Used by hold-tap previews where the badge and + * hold label already eat most of the vertical budget. + */ + compact?: boolean; } function remove_prefix(s?: string) { return s?.replace(/^Keyboard /, ""); } -export const HidUsageLabel = ({ hid_usage }: HidUsageLabelProps) => { - let [page, id] = hid_usage_page_and_id_from_usage(hid_usage); +// 1U keys are ~48px wide. text-base (~16px) bold fits about 4 glyphs before +// the label overflows and the parent's `truncate` clips it. Shrink relative +// to the parent so longer labels still fit without ellipsis. +function shrink_class_for(len: number): string { + if (len <= 4) return ""; + if (len === 5) return "text-[0.82em]"; + if (len === 6) return "text-[0.7em]"; + if (len === 7) return "text-[0.6em]"; + return "text-[0.5em]"; +} + +export const HidUsageLabel = ({ hid_usage, compact }: HidUsageLabelProps) => { + let [pageWithMods, id] = hid_usage_page_and_id_from_usage(hid_usage); - // TODO: Do something with implicit mods! - page &= 0xff; + // The encoded value packs implicit-modifier bits into the high byte of + // the page word: bit 1 / bit 5 = L Shift / R Shift, etc. + const mods = (pageWithMods >> 8) & 0xff; + const page = pageWithMods & 0xff; + const shiftActive = (mods & (0x02 | 0x20)) !== 0; let labels = hid_usage_get_metadata(page, id); + // Short labels like "1 !" / "- _" / "[ {" are unshifted+shifted pairs. + // When an implicit Shift is applied the binding always types the shifted + // glyph, so show just that. Otherwise render them stacked like a + // physical keycap — unless compact is requested. + const short = labels.short || ""; + const pair = short.match(/^(\S{1,2}) (\S{1,2})$/); + if (pair && shiftActive) { + return {pair[2]}; + } + if (pair && !compact) { + return ( + + {pair[2]} + {pair[1]} + + ); + } + + const shrink = shrink_class_for((labels.short || "").length); return ( -
{shortenHeader(header)}
- {children} + {shortHeader && ( +
+ {shortHeader} +
+ )} +
+ {children} +
); }; diff --git a/src/keyboard/Keyboard.tsx b/src/keyboard/Keyboard.tsx index e4a9e4bf..cf74e505 100644 --- a/src/keyboard/Keyboard.tsx +++ b/src/keyboard/Keyboard.tsx @@ -31,6 +31,7 @@ import { LockStateContext } from "../rpc/LockStateContext"; import { LockState } from "@zmkfirmware/zmk-studio-ts-client/core"; import { deserializeLayoutZoom, LayoutZoom } from "./PhysicalLayout"; import { useLocalStorageState } from "../misc/useLocalStorageState"; +import { useToast } from "../Toast"; type BehaviorMap = Record; @@ -186,6 +187,7 @@ export default function Keyboard() { const conn = useContext(ConnectionContext); const undoRedo = useContext(UndoRedoContext); + const { notify } = useToast(); useEffect(() => { setSelectedLayerIndex(0); @@ -262,7 +264,15 @@ export default function Keyboard() { }) ); } else { + const name = + behaviors[binding.behaviorId]?.displayName ?? + `behavior id ${binding.behaviorId}`; console.error("Failed to set binding", resp.keymap?.setLayerBinding); + notify( + "error", + `Couldn't assign "${name}" to this key — the firmware rejected the change.`, + { action: "This behavior may not be writable from Studio in this firmware build." } + ); } return async () => { @@ -501,7 +511,7 @@ export default function Keyboard() { }, [keymap, selectedLayerIndex]); return ( -
+
{layouts && (
@@ -560,7 +570,7 @@ export default function Keyboard() {
)} {keymap && selectedBinding && ( -
+
; +type ParamRender = { + node: JSX.Element; + // Drives the displayed font size — HID labels are short, layer names tend + // to be longer so they need a smaller default. + kind: "hid" | "layer" | "constant" | "raw"; +}; + +function renderBindingParam( + value: number, + spec: BehaviorParameterValueDescription[], + layers: Layer[], + options?: { compact?: boolean } +): ParamRender | null { + if (!spec || spec.length === 0) { + return null; + } + if (spec.some((s) => s.hidUsage)) { + return { + node: , + kind: "hid", + }; + } + if (spec.some((s) => s.layerId)) { + const idx = layers.findIndex((l) => l.id === value); + const layer = idx >= 0 ? layers[idx] : undefined; + return { + node: ( + {layer?.name || (idx >= 0 ? idx.toString() : `L${value}`)} + ), + kind: "layer", + }; + } + const constMatch = spec.find((s) => s.constant === value); + if (constMatch?.name) { + return { node: {constMatch.name}, kind: "constant" }; + } + return { node: {value}, kind: "raw" }; +} + +function sizeClassFor(kind: ParamRender["kind"]): string { + // Tailwind needs full class strings in source for purge to keep them. + if (kind === "hid") return "text-base"; + if (kind === "layer") return "text-xs"; + return "text-sm"; // constant / raw +} + export interface KeymapProps { layout: PhysicalLayout; keymap: KeymapMsg; @@ -48,11 +98,24 @@ export const Keymap = ({ }; } + const binding = keymap.layers[selectedLayerIndex].bindings[i]; + const behavior = behaviors[binding.behaviorId]; + const set = behavior?.metadata?.[0]; + const isHoldTap = !!set?.param1?.length && !!set?.param2?.length; + const p1 = set?.param1?.length + ? renderBindingParam(binding.param1, set.param1, keymap.layers, { + compact: isHoldTap, + }) + : null; + const p2 = set?.param2?.length + ? renderBindingParam(binding.param2, set.param2, keymap.layers, { + compact: isHoldTap, + }) + : null; + return { id: `${keymap.layers[selectedLayerIndex].id}-${i}`, - header: - behaviors[keymap.layers[selectedLayerIndex].bindings[i].behaviorId] - ?.displayName || "Unknown", + header: behavior?.displayName || "Unknown", x: k.x / 100.0, y: k.y / 100.0, width: k.width / 100, @@ -61,9 +124,31 @@ export const Keymap = ({ rx: (k.rx || 0) / 100.0, ry: (k.ry || 0) / 100.0, children: ( - +
+ {p1 && p2 ? ( + // Hold-tap style: hold above (faded, always small), tap below + // (bold, sized per type) — the tap fires on a normal press so + // it gets the prominent slot. + <> +
+ {p1.node} +
+
+ {p2.node} +
+ + ) : ( + p1 && ( +
+ {p1.node} +
+ ) + )} +
), }; }); diff --git a/src/keyboard/PhysicalLayout.tsx b/src/keyboard/PhysicalLayout.tsx index db8719c7..756366a0 100644 --- a/src/keyboard/PhysicalLayout.tsx +++ b/src/keyboard/PhysicalLayout.tsx @@ -81,6 +81,50 @@ export const PhysicalLayout = ({ const ref = useRef(null); const [scale, setScale] = useState(1); + // Bounding box that *includes* rotated keys. The naive max(k.x+k.width) / + // max(k.y+k.height) ignores rotation, so rotated thumb keys extend past the + // wrapper, the parent's centering uses the wrong size, and the visible + // keyboard ends up top-aligned with content clipping at the bottom. + let minX = 0; + let minY = 0; + let maxX = 0; + let maxY = 0; + for (const p of positions) { + const r = p.r || 0; + if (!r) { + if (p.x < minX) minX = p.x; + if (p.y < minY) minY = p.y; + if (p.x + p.width > maxX) maxX = p.x + p.width; + if (p.y + p.height > maxY) maxY = p.y + p.height; + continue; + } + const rx = p.rx ?? p.x; + const ry = p.ry ?? p.y; + const rad = (r * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const corners = [ + [p.x, p.y], + [p.x + p.width, p.y], + [p.x + p.width, p.y + p.height], + [p.x, p.y + p.height], + ]; + for (const [cx, cy] of corners) { + const dx = cx - rx; + const dy = cy - ry; + const rotX = dx * cos - dy * sin + rx; + const rotY = dx * sin + dy * cos + ry; + if (rotX < minX) minX = rotX; + if (rotY < minY) minY = rotY; + if (rotX > maxX) maxX = rotX; + if (rotY > maxY) maxY = rotY; + } + } + const origWidth = (maxX - minX) * oneU; + const origHeight = (maxY - minY) * oneU; + const offsetX = -minX * oneU; + const offsetY = -minY * oneU; + useLayoutEffect(() => { const element = ref.current; if (!element) return; @@ -90,10 +134,16 @@ export const PhysicalLayout = ({ const calculateScale = () => { if (props.zoom === "auto") { - const padding = Math.min(window.innerWidth, window.innerHeight) * 0.05; // Padding when in auto mode + // Trimmer margin (~2% of the smaller viewport dim) than the original + // 5%, so the keyboard fills more of the available area without quite + // touching the edges. + const padding = Math.min(window.innerWidth, window.innerHeight) * 0.02; + // Compute from the constant original keyboard size, not the live + // element size — the element's clientHeight tracks the scaled wrapper + // and observing it creates a feedback loop where scale shrinks to 0. const newScale = Math.min( - parent.clientWidth / (element.clientWidth + 2 * padding), - parent.clientHeight / (element.clientHeight + 2 * padding), + parent.clientWidth / (origWidth + 2 * padding), + parent.clientHeight / (origHeight + 2 * padding), ); setScale(newScale); } else { @@ -101,34 +151,26 @@ export const PhysicalLayout = ({ } }; - calculateScale(); // Initial calculation + calculateScale(); const resizeObserver = new ResizeObserver(() => { calculateScale(); }); - resizeObserver.observe(element); + // Only observe the parent — observing the (now scale-driven) element + // re-fires the loop above. resizeObserver.observe(parent); return () => { resizeObserver.disconnect(); }; - }, [props.zoom]); - - // TODO: Add a bit of padding for rotation when supported - let rightMost = positions - .map((k) => k.x + k.width) - .reduce((a, b) => Math.max(a, b), 0); - let bottomMost = positions - .map((k) => k.y + k.height) - .reduce((a, b) => Math.max(a, b), 0); + }, [props.zoom, origWidth, origHeight]); const positionItems = positions.map((p, idx) => (
onPositionClicked?.(idx)} - className="hover:[transform:translateZ(100px)] transition-transform duration-200" > )); + // Anchor the scale at top-left and size the DOM box to the visually scaled + // dimensions, so the parent's `items-center` actually centers what the user + // sees instead of the unscaled box (which made the keyboard hug the top and + // leave dead space at the bottom). return (
- {positionItems} +
+
+ {positionItems} +
+
); }; From 50c47a6bca99588e9c5d96f9f80fd30b898c7474 Mon Sep 17 00:00:00 2001 From: numachang Date: Sat, 23 May 2026 16:25:12 +0900 Subject: [PATCH 14/14] fix(keymap): Surface undo rejections and reset stale param tab 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 a key for a tab that no longer exists. Snap it back to "p1" via useEffect when hasParam2 flips false. Co-Authored-By: Claude --- src/behaviors/BehaviorParametersPicker.tsx | 11 ++++++++++- src/keyboard/Keyboard.tsx | 12 ++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/behaviors/BehaviorParametersPicker.tsx b/src/behaviors/BehaviorParametersPicker.tsx index 83f558a4..c52e979e 100644 --- a/src/behaviors/BehaviorParametersPicker.tsx +++ b/src/behaviors/BehaviorParametersPicker.tsx @@ -1,6 +1,6 @@ import { BehaviorBindingParametersSet } from "@zmkfirmware/zmk-studio-ts-client/behaviors"; import { Tab, TabList, TabPanel, Tabs } from "react-aria-components"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { ParameterValuePicker } from "./ParameterValuePicker"; import { HidUsagePicker } from "./HidUsagePicker"; import { validateValue } from "./parameters"; @@ -41,6 +41,15 @@ export const BehaviorParametersPicker = ({ const hasParam1 = param1Values.length > 0; const hasParam2 = param2Values.length > 0; + // Switching from a 2-param behavior (Mod-Tap, ...) to a 1-param one + // (Key Press, ...) hides the p2 tab. Snap back to p1 so we don't pass a + // dangling selectedKey to . + useEffect(() => { + if (!hasParam2 && activeParam === "p2") { + setActiveParam("p1"); + } + }, [hasParam2, activeParam]); + const param1Name = param1Values[0]?.name || "Param 1"; const param2Name = param2Values[0]?.name || "Param 2"; const sameName = hasParam2 && param1Name === param2Name; diff --git a/src/keyboard/Keyboard.tsx b/src/keyboard/Keyboard.tsx index cf74e505..dbce995b 100644 --- a/src/keyboard/Keyboard.tsx +++ b/src/keyboard/Keyboard.tsx @@ -295,6 +295,18 @@ export default function Keyboard() { }) ); } else { + const name = + behaviors[oldBinding.behaviorId]?.displayName ?? + `behavior id ${oldBinding.behaviorId}`; + console.error( + "Failed to undo binding", + resp.keymap?.setLayerBinding + ); + notify( + "error", + `Couldn't restore "${name}" — undo was rejected by the firmware.`, + { action: "The previous binding may no longer be writable from Studio." } + ); } }; });