diff --git a/src/App.tsx b/src/App.tsx index e09ed0cb..56896fd0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,12 @@ 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 { + dtsRefForDisplayName, + formatBindingParam, + parseKeymapFile, +} from "./keymap-parser"; import { ConnectModal, TransportFactory } from "./ConnectModal"; import type { RpcTransport } from "@zmkfirmware/zmk-studio-ts-client/transport/index"; @@ -29,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 { @@ -70,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; } @@ -126,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) => { @@ -141,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; } @@ -161,6 +168,7 @@ async function connect( } function App() { + const { notify } = useToast(); const [conn, setConn] = useState({ conn: null }); const [connectedDeviceName, setConnectedDeviceName] = useState< string | undefined @@ -189,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 }, }); @@ -208,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() { @@ -223,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() { @@ -243,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) { @@ -271,19 +298,303 @@ function App() { doDisconnect(); }, [conn]); + const exportKeymap = useCallback(() => { + async function doExport() { + if (!conn.conn) { + return; + } + + 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; + } + + const behaviorList = await call_rpc(conn.conn, { behaviors: { listAllBehaviors: true } }); + const behaviorIds = behaviorList?.behaviors?.listAllBehaviors?.behaviors || []; + + const behaviors: Record = {}; + for (const id of behaviorIds) { + const details = await call_rpc(conn.conn, { behaviors: { getBehaviorDetails: { behaviorId: id } } }); + const 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 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(" "); + }); + 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"); + const stamp = new Date() + .toISOString() + .replace(/[:.]/g, "-") + .slice(0, 19); + a.href = url; + 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().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); + + 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) { + notify( + "error", + `No layers found in ${file.name}. Is this a ZMK .keymap file?` + ); + return; + } + + const keymapResp = await call_rpc(conn.conn, { keymap: { getKeymap: true } }); + const keymap = keymapResp?.keymap?.getKeymap; + if (!keymap) { + notify("error", "Failed to fetch current keymap from device"); + return; + } + + 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) { + 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; + } + } + + let updatedCount = 0; + let unchangedCount = 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]; + + for (let ki = 0; ki < parsedLayer.bindings.length && ki < targetLayer.bindings.length; ki++) { + const parsedBinding = parsedLayer.bindings[ki]; + const behaviorName = parsedBinding.behavior.toLowerCase(); + + const behaviorId = behaviorNameToId[behaviorName]; + if (behaviorId === undefined) { + unknownBehaviors.add(parsedBinding.behavior); + skippedCount++; + continue; + } + + 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: { + layerId: targetLayer.id, + keyPosition: ki, + binding: { behaviorId, param1, param2 }, + }, + }, + }); + const code = resp.keymap?.setLayerBinding; + if (code === 0 /* SET_LAYER_BINDING_RESP_OK */) { + updatedCount++; + 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 } + ); + } + } + + console.log( + `[import] updated=${updatedCount} unchanged=${unchangedCount} 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 ( + 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", + `Updated ${updatedCount} (${unchangedCount} already matched), skipped ${skippedCount}, rejected ${failedCount}.${unknownDetail}${readOnlyDetail}${failedDetail}`, + { action: persistReminder } + ); + } else { + notify( + "success", + `Updated ${updatedCount} binding${updatedCount === 1 ? "" : "s"} from ${file.name} (${unchangedCount} already matched).${readOnlyDetail}`, + { action: persistReminder } + ); + } + } + + const file = fileInputRef.current?.files?.[0]; + if (file) { + doImport(file).catch((e) => { + console.error("Import failed", e); + notify("error", `Import failed: ${e instanceof Error ? e.message : String(e)}`); + }); + fileInputRef.current!.value = ""; + } + }, [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 ( + 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,24 @@ export const AppHeader = ({ + {onExportKeymap && ( + + )} + {onImportKeymap && ( + + )} ); 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/keymap-parser.ts b/src/keymap-parser.ts new file mode 100644 index 00000000..3d8fc03b --- /dev/null +++ b/src/keymap-parser.ts @@ -0,0 +1,332 @@ +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[]; +} + +// ============================================================================= +// 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. +// ============================================================================= + +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); +} + +// 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; + 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); + if (pageOverrides.med) hidNameToCode.set(pageOverrides.med.toLowerCase(), code); + if (pageOverrides.long) hidNameToCode.set(pageOverrides.long.toLowerCase(), code); + } + } + } + + // 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 upper = alias.toUpperCase(); + if (!keycodeLookup.has(upper)) keycodeLookup.set(upper, canonicalCode); + } + } + // 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); + } +} + +// ============================================================================= +// Behavior reference name table (RPC displayName <-> DTS reference) +// ============================================================================= + +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", + 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[] = []; + + const keymapBlock = content + .replace(/#include.*/g, "") + .replace(/\/\/.*$/gm, ""); + + // 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 displayName = match[2] ?? layerName; + const bindingsStr = match[3]; + + if (["compatible", "keymap", "behaviors"].includes(layerName)) continue; + + const bindings: ParsedBinding[] = []; + // 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: 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 (const tok of tokens) { + const v = parseBindingParam(tok); + params.push(v ?? 0); + } + + bindings.push({ behavior, params }); + } + + if (bindings.length > 0) { + layers.push({ name: displayName, bindings }); + } + } + + return layers; +} + +export { keycodeLookup, behaviorAliases, codeToZmkName }; 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( - + + + );