From 453980d64a7b88de8882acfe6f82813e0de0c4e3 Mon Sep 17 00:00:00 2001 From: numachang Date: Sat, 23 May 2026 10:15:17 +0900 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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." } + ); } }; });