From 429b7afe2777046d5584035504946d6013a6248b Mon Sep 17 00:00:00 2001 From: numachang Date: Sun, 7 Jun 2026 11:34:35 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(behaviors):=20Step=203a=20=E2=80=94=20?= =?UTF-8?q?keycap=20behavior=20tiles=20in=20the=20Normal=20grid=20(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tile-ify the single-action behaviors and fold them into the Normal-slot key grid's tab strip, building toward the converged tile surface incrementally. - tiles.ts: tileBindingsFor explodes a behavior into one keycap tile per finished (param1,param2) binding; null when a param needs a rich picker. - Mouse + a consolidated System tab (Bluetooth/Output/External Power/System) live in the Normal grid's tab strip between International and Other; picking a behavior tile sets the whole binding (HidUsagePicker extraTabs/onSelectBinding). - Collapse the two basic-tier tabs into one host-adaptive "Basic" tab (ansi->ANSI, iso->ISO+NUHS/NUBS, jis->JIS, ko->KO); ISO keys appear once that layout is selected and stay searchable. - "+ Send with" moves from a side column to an inline row below the grid (Basic / Function+Nav only); drop the "KEY" header and host the search on the NORMAL row; the filter now spans the behavior tiles too. - Slot label shows the devicetree code (formatBinding: &mt LSHFT RET, &mo 1, &reset, ...) in a boxed chip, host-layout-aware (JIS Shift+2 = "). - Keycode-picker DRY cleanup: tab-name constants; promote consumer media keys to Apps/Media/Special; group the Other combobox into labelled sections (other-sections.ts). Tests: tiles / formatBinding / other-sections (121 pass). Snapshot files left out. Co-Authored-By: Claude --- docs/design/key-picker-rethink.md | 168 ++++++- src/behaviors/BehaviorBindingPicker.tsx | 336 +++++++++----- src/behaviors/HidUsagePicker.tsx | 593 ++++++++++++++++-------- src/behaviors/SlotBindingPicker.tsx | 73 ++- src/behaviors/other-sections.test.ts | 73 +++ src/behaviors/other-sections.ts | 47 ++ src/behaviors/parameters.test.ts | 59 +++ src/behaviors/parameters.ts | 35 ++ src/behaviors/tiles.test.ts | 109 +++++ src/behaviors/tiles.ts | 131 ++++++ src/mock/fixtures.ts | 103 ++++ 11 files changed, 1385 insertions(+), 342 deletions(-) create mode 100644 src/behaviors/other-sections.test.ts create mode 100644 src/behaviors/other-sections.ts create mode 100644 src/behaviors/parameters.test.ts create mode 100644 src/behaviors/tiles.test.ts create mode 100644 src/behaviors/tiles.ts diff --git a/docs/design/key-picker-rethink.md b/docs/design/key-picker-rethink.md index 2e45f04b..1be5a024 100644 --- a/docs/design/key-picker-rethink.md +++ b/docs/design/key-picker-rethink.md @@ -6,10 +6,11 @@ > issue is the user-facing summary; this note is the working design. English is > authoritative; a Japanese mirror follows below. > -> Status: **Step 1 shipped (PR #27); Step 2 in progress.** The slot model -> converged on the **Normal / Hold mode** framing (§2) — earlier drafts said -> "Tap / Hold", renamed because "Tap" wrongly implies short-press-only for a plain -> `&kp`. Coverage of every standard ZMK behavior is verified in §4a. +> Status: **Step 1 shipped (PR #27); Step 2 merged to the integration branch; +> Step 3a complete** (the keycode + behavior tile surface — see §5). The slot +> model converged on the **Normal / Hold mode** framing (§2) — earlier drafts +> said "Tap / Hold", renamed because "Tap" wrongly implies short-press-only for a +> plain `&kp`. Coverage of every standard ZMK behavior is verified in §4a. --- @@ -353,10 +354,105 @@ end state. Each step is its own PR. ### Step 3 — tile-ify the single-action behaviors -- **Goal:** turn Mouse / Bluetooth / Output / External Power / System (and the - parameterless `&caps_word` / `&key_repeat` / `&gresc`) into category tabs of - tiles; fold media into the Key Press surface (page 12, §3a); give `&sk` / `&kt` - the parameterised-tile treatment (shape (c), §3); collapse the Basic chip row. +Split into two PRs to keep each diff small (this fork's minimal-diff stance). +Media already folds into the Key Press / Normal grid (page 12), done in Step 2. + +> **Plan note (build the end-state incrementally).** Rather than parking migrated +> behaviors in throwaway scaffolding until Step 5 unifies everything, each step +> moves its behaviors into (or toward) their **final** place, so the converged +> surface is visible — and therefore critiquable — at every step. Step 3a already +> realizes the key part of the Step 5 end-state (behavior tiles living in the +> Normal grid's tab strip), so Step 5 shrinks to retiring whatever residual chip +> area remains. + +**Step 3a — keycap behavior tiles in the Normal grid's tab strip.** + +- **Tile-able behaviors → keycap tiles.** A behavior is *tile-able* when **all** + its parameters are `constant` / `range` / `nil` / absent, so each concrete + `(param1, param2)` combination is a finished binding (shape (a), §3a). An + enum/range parameter is **exploded** into one tile per value (e.g. Bluetooth → + `BT_SEL 0…N`, `BT_CLR`, …). Tiles are styled as **keycaps** so picking a + behavior reads the same as picking a key. A behavior whose parameter needs a + rich picker — a HID key or a layer (`&sk` / `&kt`, the persistent-layer + behaviors, custom hold-taps) — is **not** tile-able and keeps the chip + + parameter-picker flow. +- **Single-action behaviors join the Normal grid's tab strip.** Instead of a + separate behavior-tab area, the tile-able single-action behaviors render as + extra tabs **in the same tab strip as the keycode grid**, inserted between + *International* and *Other*: a **Mouse** tab, and a consolidated **System** tab + that folds Bluetooth / Output / External Power / System (Reset / Bootloader / + Studio Unlock) into one tab of labelled keycap-tile sections. Picking a keycode + tile fills the Normal key (→ `&kp`); picking a behavior tile sets the **whole** + binding (→ `&mkp` / `&bt` / `&reset` / …). The reverse-lookup slot label names + the active behavior (e.g. *"Slots = Reset"*), and its tile/tab is highlighted — + which dissolves most of the §8a "non-core binding shows an empty slot surface" + blocker (the binding is now named and visible, not silently overwritten). +- **Residual "More behaviors" area shrinks** to the not-yet-migrated, non-strip + behaviors: the parameterless Basic singles (`&caps_word` / `&key_repeat` / + `&gresc`, as keycap tiles), the still-chip `&sk` / `&kt` (→ Step 3b), and the + persistent-layer / custom hold-tap chips (→ Step 4). Its tier tabs are gone + (single flat category row). +- **Pure helper** ([`tiles.ts`](../../src/behaviors/tiles.ts), unit-tested): + `tileBindingsFor(behavior) → BehaviorTile[] | null` and the `BindingTileTab` + shape the Normal grid consumes. [`HidUsagePicker`](../../src/behaviors/HidUsagePicker.tsx) + gains optional `extraTabs` / `activeBinding` / `onSelectBinding` so the + behavior tabs live in its tab strip without disturbing its other uses. +- **Tab-strip fit (two follow-on tweaks driven by the wider strip).** Adding + Mouse / System made the keycode tab strip overflow next to the always-on + implicit-modifier column, so: + - **One host-adaptive "Basic" tab** replaces the old fixed *Basic* (ANSI) + + *ISO/JIS* pair. The single tab renders the shape matching the selected host + layout (`layout.physical`): ANSI for `ansi`, ISO for `iso` (adds NUHS / + NUBS), JIS for `jis` (¥ / IME), KO for `ko`. The old design always showed an + ANSI tab plus an adaptive one (which, on ANSI, rendered ISO and thus surfaced + NUHS/NUBS a US board lacks). Now ISO-only keys appear only once an ISO/JIS + layout is selected — matching the physical board — and stay reachable via the + search box regardless. One fewer tab, and correct ANSI for US users. + - **The "+ Send with" (implicit-modifier) picker moves out of the side column.** + On the Normal-slot surface (`inline` mode), it's a compact "Send with" row of + modifier toggles **below the full-width grid**, shown only on the tabs where + composing a modified keycode applies (Basic / Function + Nav) and hidden + elsewhere — so it never steals tab/grid width. Other `HidUsagePicker` uses + (the Sticky Key / Key Toggle key picker) keep the side column. + - **Normal-surface chrome trimmed.** With the modifiers inline, the grid's + redundant "Key" header is dropped and the **search box moves up onto the + "NORMAL" row** (the slot already labels the section), so "Key" / "Send with" + are no longer competing section headers. `HidUsagePicker` gains `inline` + + controlled `search` / `onSearchChange`; the slot surface owns the search box. + The filter now also **spans the behavior tiles** (matching tile label / + section / tab), so a search like `reset` / `bt` / `mouse` surfaces the + Mouse / System tiles too — the search box stops being keycode-only now that + those tiles share the surface. + - **Host-layout-aware reverse label.** The "NORMAL = " label resolves the + glyph through the active host layout (like the grid), so on JIS, Shift+2 reads + `"` not the ANSI `@`. + - **Devicetree code on the slot label.** Next to "Slots = " the + surface shows the binding as the ZMK code an author would write — `&mt LSHFT + RET`, `&kp Q`, `&mo 1`, `&bt BT_SEL 0`, `&reset`. `formatBinding` + ([`parameters.ts`](../../src/behaviors/parameters.ts), unit-tested) reuses the + existing `dtsRefForDisplayName` / `formatBindingParam` serializers and prefers + a parameter's metadata constant name (the ZMK macro) over its raw number. + - **Keycode-picker cleanups (driven by a DRY review).** Tab identifiers are + named constants (no duplicated `"Function + Nav"` / `"Other"` magic strings + across the categorizer, tab-order list, panel renderer, and modifier-row + check). The common consumer media keys (Play / Pause / Stop / Next / Prev / + Vol± / Mute, a curated HID-id set) are promoted out of the "Other" catch-all + onto the **Apps/Media/Special** tab. The remaining "Other" combobox — a long + consumer-page tail the HID spec leaves uncategorised — is grouped into + **labelled sections** (Keyboard/Keypad · Media & Playback · Audio · Display & + Camera · Application Launch · Application Control · Telephony & Contacts · + Other) derived from the usage, so it's navigable. The filter and the section + grouping both read from the derived usage data, not hand-maintained lists. +- **Tile styling:** behavior tiles are keycaps with the same height/font as the + keycode tiles (widening for longer labels, not shrinking the text); section + headings (`BLUETOOTH`, …) are `text-sm` so they read as real dividers. + +**Step 3b — parameterised `&sk` / `&kt` tiles + collapse the Basic chip row.** + +- Give `&sk` (Sticky Key) and `&kt` (Key Toggle) the shape-(c) treatment (§3c): a + tile that reveals a single-key picker. With those off the chip list the Basic + category's chips are gone, so the residual chip row collapses. + - **Risk:** medium — mostly UI; reuses the grid/tile patterns from [`HidUsagePicker.tsx`](../../src/behaviors/HidUsagePicker.tsx). Capability-aware: a tab/tile renders only for behaviors the firmware exposes. @@ -431,15 +527,17 @@ stays here and is not pushed upstream. These are acceptable on the integration branch but **must be resolved before the integration branch merges to `main`**: -- **Non-core bindings still render the slot surface.** Selecting a non-core - behavior (Sticky Key, Caps Word, …) shows the slot surface above with - *"Slots = No matching behavior"* and an empty Normal grid; clicking a key in - that empty grid silently overwrites the binding to `&kp`. The slot surface must - stay reachable (it is the only way to switch *into* a core behavior, since the - core chips are hidden), but for a non-core current binding it should collapse / - disable the grid, label the actual current behavior, or warn before overwriting. - Step 3-4 (tiling the non-core behaviors) largely dissolves this, so it may - resolve naturally — but confirm before `main`. +- **Non-core bindings on the slot surface — largely resolved by Step 3a.** A + non-core current binding no longer reads as a blank *"No matching behavior"*: + the slot label now **names the actual behavior** (e.g. *"Slots = Reset"*) and + shows its **devicetree code** (`&reset`, `&bt BT_SEL 0`, …), and the behavior's + tile is highlighted in its tab (Mouse / System) — so the state is visible, not + silent. The Normal grid is shared with the behavior tabs by design, so picking a + key on the Basic tab is the *deliberate* way to switch a non-core binding back + to a Normal `&kp` (no longer a silent footgun on a stray click). The not-yet- + tiled chip behaviors (Sticky Key / Key Toggle → Step 3b; persistent layers → + Step 4) keep the same labelled-and-coded state. **Confirm the feel before + `main`**, but the silent/ambiguous part of the blocker is gone. - **Minor (acceptable for now):** `deriveBinding → null` makes `writeSlots` a no-op (silent) when the firmware doesn't expose the derived behavior; tie this to the reverse-lookup "no matching behavior" state rather than doing nothing. @@ -712,11 +810,37 @@ behavior は「実際にいくつ枠を持つか」で形が分かれる。見 ホールド枠を埋める)の整合を保つ必要がある。封じ込めとして、スロット面が担う 派生対象 behavior(`&kp`/`&mt`/`<`/`&mo`)の**チップは早めに隠し**、両方を活かして 三重同期しない。 -- **Step 3(タイル化)** — Mouse / Bluetooth / Output / External Power / System - (と param なしの caps_word/key_repeat/gresc)をカテゴリタブのタイルに。メディアは - Key Press 面(page 12, §3a)へ内包。`&sk` / `&kt` はパラメータ付きタイル(shape (c), - §3)に。Basic チップ列を畳む。`HidUsagePicker` のグリッド/タイル型を再利用。 - capability 連動。 +- **Step 3(タイル化)** — 差分を小さく保つため 2 PR に分割(英語版が正)。メディアは + Step 2 で Key Press / Normal グリッド(page 12)に内包済み。 + - **方針(最終形を逐次作る)** — 移行した behavior を Step 5 まで使い捨ての仮置き場に + 留めず、各 Step で**最終的な場所**へ動かす。こうすれば収束後の面が毎 Step 見えて、 + 改善を指摘できる。3a で既に Step 5 終着形の要(behavior タイルを Normal グリッドの + タブ列に置く)を実現するので、Step 5 は残りのチップ領域の撤去だけに縮む。 + - **3a(Normal グリッドのタブ列にキーキャップ behavior タイル)** — パラメータが全て + constant / range / nil / 無しの「タイル可能」behavior を、`(param1,param2)` の全組合せ + 1 タイルずつ・**キーキャップ風**に。enum/range は**各値を個別タイルに展開**(例: + `BT_SEL 0…N`)。これらを別領域ではなく**キーコードグリッドと同じタブ列**に追加し、 + International↔Other の間に **Mouse** タブと、Bluetooth/Output/External Power/System を + 1 つに集約した **System** タブを差し込む。キーコードタイルは Normal キー(→`&kp`)、 + behavior タイルは**binding 全体**(→`&mkp`/`&bt`/`&reset` 等)を設定。逆引きラベルが + 現在の behavior 名を出し(例「Slots = Reset」)タイル/タブをハイライトするので、§8a の + 「非コア binding で空スロット面」ブロッカーの大半が解消(黙って上書きされず、名前が + 見える)。HID キー/レイヤーの param が要る `&sk` / `&kt`・持続レイヤー・カスタム + hold-tap は**タイル不可**でチップ継続。tier タブは廃止(カテゴリ一列)。残置「More + behaviors」は未移行分(caps_word/key_repeat/gresc タイル+ sk/kt チップ+レイヤー等)に + 縮小。`HidUsagePicker` に `extraTabs` / `activeBinding` / `onSelectBinding` を追加。 + - **タブ列の収まり(幅対策2点)** — Mouse/System 追加でキーコードタブ列が修飾列と幅を + 奪い合いあふれたため: ①**Basic を host 連動の1タブに統合**(旧「Basic(ANSI固定)+ISO/JIS」 + を廃し、選択中ホスト配列で ansi→ANSI / iso→ISO(NUHS/NUBS) / jis→JIS / ko→KO を出し分け。 + ANSI ユーザーは ANSI 形状で正しく、ISO/JIS キーは配列選択時のみ出る・検索からは常時可)。 + ②**「+ Send with」をサイド列から外す**: Normal 面(inline)ではグリッド全幅の下に + 「Send with」修飾トグル行として置き、Basic / Function+Nav のみ表示(他タブ非表示)。 + sk/kt のキーピッカー等は従来のサイド列のまま。③Normal 面の chrome 整理: 修飾を行に + したので冗長な「KEY」ラベルを廃し、**検索ボックスを「NORMAL」行へ移設**(節見出しは + NORMAL が兼ねる)。`HidUsagePicker` に `inline` + controlled `search`/`onSearchChange`。 + ④タイルはキーキャップ風に文字サイズ統一、セクション見出しを `text-sm` に。 + - **3b** — `&sk` / `&kt` をパラメータ付きタイル(shape (c))に。Basic チップ列を畳む。 + - `HidUsagePicker` のグリッド/タイル型を再利用。capability 連動。 - **Step 4(逆引きラベル)** — スロット面に「いまの組み合わせ=◯◯」の控えめ表示、 レイヤーサブモード(to/tog/sl)、カスタム hold-tap の identity 選択(§3b)。 - **Step 5 以降(要別設計)** — tier/カテゴリのタブ階層を撤去し「(a)+(b)+(c)」へ。 diff --git a/src/behaviors/BehaviorBindingPicker.tsx b/src/behaviors/BehaviorBindingPicker.tsx index 341de722..3912ee48 100644 --- a/src/behaviors/BehaviorBindingPicker.tsx +++ b/src/behaviors/BehaviorBindingPicker.tsx @@ -4,8 +4,9 @@ import { GetBehaviorDetailsResponse } from "@zmkfirmware/zmk-studio-ts-client/be import { BehaviorBinding } from "@zmkfirmware/zmk-studio-ts-client/keymap"; import { BehaviorParametersPicker } from "./BehaviorParametersPicker"; import { SlotBindingPicker } from "./SlotBindingPicker"; -import { validateBinding } from "./parameters"; +import { formatBinding, validateBinding } from "./parameters"; import { bindingToSlots, buildDerivationRegistry } from "./slots"; +import { BindingTileTab, isTileable, tileBindingsFor } from "./tiles"; export interface BehaviorBindingPickerProps { binding: BehaviorBinding; @@ -17,7 +18,9 @@ export interface BehaviorBindingPickerProps { // ZMK upstream-shipped behaviors, ordered by how often a typical keymap author // reaches for them within each group. The first entry of each group is the // "anchor" behavior (Key Press for typing, Momentary Layer for layers, etc.); -// placeholders (Transparent / None) sit at the end. +// placeholders (Transparent / None) sit at the end. Step 3a flattens the old +// tier hierarchy into a single row of category tabs and splits the former single +// "System" group into Bluetooth / Output / External Power / System. const STANDARD_BEHAVIORS: ReadonlyArray = [ // Basic ["Key Press", "Basic"], @@ -42,10 +45,13 @@ const STANDARD_BEHAVIORS: ReadonlyArray ["Mouse Scroll", "Mouse"], ["mouse_move", "Mouse"], ["mouse_scroll", "Mouse"], + // Bluetooth + ["Bluetooth", "Bluetooth"], + // Output + ["Output Selection", "Output"], + // External Power + ["External Power", "External Power"], // System - ["Bluetooth", "System"], - ["Output Selection", "System"], - ["External Power", "System"], ["Reset", "System"], ["Bootloader", "System"], ["Studio Unlock", "System"], @@ -58,7 +64,31 @@ const STANDARD_BEHAVIOR_ORDER: Record = Object.fromEntries( STANDARD_BEHAVIORS.map(([name], i) => [name, i]) ); -const GROUP_ORDER = ["Basic", "Layer", "Hold-Tap", "Mouse", "System", "Other"]; +const GROUP_ORDER = [ + "Basic", + "Layer", + "Hold-Tap", + "Mouse", + "Bluetooth", + "Output", + "External Power", + "System", + "Other", +]; + +// Single-action behaviors are picked the same way a keycode is — as keycap +// tiles in the Normal key grid's tab strip (issue #16, Step 3). Mouse stays its +// own tab; Bluetooth / Output / External Power / System consolidate under one +// "System" tab, inserted between International and Other. A behavior moves onto +// the strip only when it's tile-able (constant / range / nil / no params); the +// rest stay as chips in the residual "More behaviors" area until later steps. +const STRIP_GROUPS: Record = { + Mouse: "Mouse", + Bluetooth: "System", + Output: "System", + "External Power": "System", + System: "System", +}; // Keycap faces for the in-grid clear tiles: None is left blank (an empty key — // its tooltip/aria-label still says "None"), the conventional ▽ for Transparent. @@ -68,24 +98,22 @@ const CLEAR_TILE_GLYPHS: Record = { Transparent: "▽", }; -interface BehaviorClass { - tier: "standard" | "extension"; - group: string; -} - -function classifyBehavior(b: GetBehaviorDetailsResponse): BehaviorClass { +// The flat category a behavior lands in. Standard behaviors use their table +// group; firmware-extension (keymap-author-defined) behaviors classify by shape +// so they land in the same flat tabs (a mouse extension → Mouse, a hold-tap +// shape → Hold-Tap, the rest → Other). +function classifyBehavior(b: GetBehaviorDetailsResponse): string { const standard = STANDARD_BEHAVIOR_GROUPS[b.displayName]; - if (standard) return { tier: "standard", group: standard }; + if (standard) return standard; - // Firmware-extension (keymap-author-defined) behaviors: classify by shape. if (b.displayName.toLowerCase().includes("mouse")) { - return { tier: "extension", group: "Mouse" }; + return "Mouse"; } const set = b.metadata?.[0]; if (set?.param2?.some((v) => v.hidUsage)) { - return { tier: "extension", group: "Hold-Tap" }; + return "Hold-Tap"; } - return { tier: "extension", group: "Other" }; + return "Other"; } export const BehaviorBindingPicker = ({ @@ -118,8 +146,8 @@ export const BehaviorBindingPicker = ({ [behaviors] ); - // Slot model (Step 2): the four core behaviors are edited through the Tap / - // Hold slot surface and derived from it, so their chips are hidden — the + // Slot model (Step 2): the four core behaviors are edited through the Normal / + // Hold mode slot surface and derived from it, so their chips are hidden — the // surface owns them, avoiding a two-ways-to-edit-one-binding sync hazard. const registry = useMemo(() => buildDerivationRegistry(behaviors), [behaviors]); const derivedChipIds = useMemo( @@ -145,61 +173,77 @@ export const BehaviorBindingPicker = ({ [behaviors] ); - const tieredBehaviors = useMemo(() => { - // None / Transparent are surfaced as keycap tiles in the key grid, and the - // four core behaviors live on the slot surface, so drop all of their - // redundant chips from the behavior groups. - const hiddenIds = new Set([ - ...clearBehaviors.map((b) => b.id), - ...derivedChipIds, - ]); - const out: Record< - "standard" | "extension", - Record - > = { standard: {}, extension: {} }; + // A behavior shown as a keycap tile in the Normal grid's strip (Mouse / + // System), rather than as a chip in the residual area below. + const isStripBehavior = (b: GetBehaviorDetailsResponse) => + isTileable(b) && STRIP_GROUPS[classifyBehavior(b)] !== undefined; + + const hiddenIds = useMemo( + () => + new Set([...clearBehaviors.map((b) => b.id), ...derivedChipIds]), + [clearBehaviors, derivedChipIds] + ); + + // Flat category map for the residual "More behaviors" area. None / Transparent + // surface as keycap tiles in the key grid, the four core behaviors live on the + // slot surface, and the single-action tile behaviors moved into the Normal + // strip — so drop all of those here. + const groupedBehaviors = useMemo(() => { + const out: Record = {}; for (const b of sortedBehaviors) { if (hiddenIds.has(b.id)) continue; - const { tier, group } = classifyBehavior(b); - if (!out[tier][group]) out[tier][group] = []; - out[tier][group].push(b); + if (isStripBehavior(b)) continue; + const group = classifyBehavior(b); + if (!out[group]) out[group] = []; + out[group].push(b); } return out; - }, [sortedBehaviors, clearBehaviors, derivedChipIds]); + }, [sortedBehaviors, hiddenIds]); - const tiers: { key: "standard" | "extension"; title: string }[] = [ - { key: "standard", title: "ZMK Standard" }, - { key: "extension", title: "Firmware Extension" }, - ]; + // The whole-binding behavior tabs for the Normal strip: Mouse, then a + // consolidated System (Bluetooth / Output / External Power / System), each a + // run of labelled keycap-tile sections (one per source behavior). + const behaviorTabs = useMemo(() => { + const sections: Record<"Mouse" | "System", BindingTileTab["sections"]> = { + Mouse: [], + System: [], + }; + for (const b of sortedBehaviors) { + if (hiddenIds.has(b.id) || !isStripBehavior(b)) continue; + const tab = STRIP_GROUPS[classifyBehavior(b)]; + sections[tab].push({ + heading: b.displayName, + tiles: tileBindingsFor(b) ?? [], + }); + } + const tabs: BindingTileTab[] = []; + if (sections.Mouse.length) tabs.push({ name: "Mouse", sections: sections.Mouse }); + if (sections.System.length) + tabs.push({ name: "System", sections: sections.System }); + return tabs; + }, [sortedBehaviors, hiddenIds]); const currentBehavior = behaviors.find((b) => b.id === behaviorId); - const currentClass = currentBehavior + const currentGroup = currentBehavior ? classifyBehavior(currentBehavior) : null; - const [activeTier, setActiveTier] = useState<"standard" | "extension">( - currentClass?.tier ?? "standard" - ); const [activeGroup, setActiveGroup] = useState( - currentClass?.group ?? "Basic" + currentGroup ?? "Basic" ); // When the bound behavior changes (e.g. user clicked a different key on the - // keyboard image), jump the picker tabs to the matching tier/group. + // keyboard image), jump the picker to the matching category. useEffect(() => { - if (!currentClass) return; - setActiveTier(currentClass.tier); - setActiveGroup(currentClass.group); + if (currentGroup) setActiveGroup(currentGroup); }, [behaviorId]); - const availableTiers = tiers.filter((t) => - Object.values(tieredBehaviors[t.key]).some((arr) => arr.length > 0) - ); - const availableGroupsForActiveTier = GROUP_ORDER.filter( - (g) => tieredBehaviors[activeTier][g]?.length + const availableGroups = GROUP_ORDER.filter( + (g) => groupedBehaviors[g]?.length ); - const effectiveGroup = availableGroupsForActiveTier.includes(activeGroup) + const effectiveGroup = availableGroups.includes(activeGroup) ? activeGroup - : availableGroupsForActiveTier[0]; + : availableGroups[0]; const selectBehavior = (id: number) => { if (id !== behaviorId) { @@ -209,6 +253,14 @@ export const BehaviorBindingPicker = ({ } }; + // A tile encodes a finished binding (behaviorId + both params), so picking one + // sets all three at once — no parameter panel needed. + const selectTile = (b: BehaviorBinding) => { + setBehaviorId(b.behaviorId); + setParam1(b.param1); + setParam2(b.param2); + }; + // Shared look for the selectable behavior chips, so a future style tweak // stays consistent across the picker. const chipClass = (selected: boolean) => @@ -218,6 +270,15 @@ export const BehaviorBindingPicker = ({ : "bg-base-200 hover:bg-base-300 border-base-300" }`; + // Keycap-styled so picking a behavior tile reads like picking a key — same + // height + font as the Normal grid's keycode/behavior tiles. + const tileClass = (selected: boolean) => + `min-w-16 h-16 px-2 rounded border whitespace-nowrap leading-tight flex items-center justify-center text-center ${ + selected + ? "bg-primary text-primary-content border-primary" + : "bg-base-200 hover:bg-base-300 border-base-300" + }`; + const renderBehaviorChip = (b: GetBehaviorDetailsResponse) => { const selected = b.id === behaviorId; return ( @@ -309,12 +370,24 @@ export const BehaviorBindingPicker = ({ param2: param2 ?? 0, }; const isCoreBinding = bindingToSlots(currentBinding, registry) !== null; + // A tile behavior (single-action / enum) bakes its params into the tile, so it + // doesn't get the parameter panel either — its active tile shows the state. + const isTileBinding = currentBehavior ? isTileable(currentBehavior) : false; const setBindingFromSlots = (b: BehaviorBinding) => { setBehaviorId(b.behaviorId); setParam1(b.param1); setParam2(b.param2); }; + const groupBehaviors = groupedBehaviors[effectiveGroup] ?? []; + const tileBehaviors = groupBehaviors.filter(isTileable); + const chipBehaviors = groupBehaviors.filter((b) => !isTileable(b)); + + const tileActive = (b: BehaviorBinding) => + b.behaviorId === behaviorId && + b.param1 === (param1 ?? 0) && + b.param2 === (param2 ?? 0); + return (
l.id) + )} /> -
+

- Temporary home for behaviors the slots don't cover yet — these move - onto tiles and the slot surface in later steps. + Pick a tile for a finished binding, or a behavior to set its + parameters. Typing, layer and hold-tap keys live on the slot surface + above.

-
-
- {availableTiers.map((tier) => { - const isActive = activeTier === tier.key; - return ( - - ); - })} -
-
- {availableGroupsForActiveTier.map((g) => { - const isActive = effectiveGroup === g; - return ( - - ); - })} -
+
+ {availableGroups.map((g) => { + const isActive = effectiveGroup === g; + return ( + + ); + })} +
+ +
+ {/* Tile-able behaviors: a labelled grid per behavior, one tile per + finished binding. The per-behavior heading disambiguates shared + value names (e.g. Mouse Move "Up" vs Mouse Scroll "Up"). */} + {tileBehaviors.map((b) => { + const tiles = tileBindingsFor(b) ?? []; + return ( +
+ + {b.displayName} + +
+ {tiles.map((tile) => { + const selected = tileActive(tile.binding); + return ( + + ); + })} +
+
+ ); + })} + + {/* Non-tile-able behaviors (Sticky Key, persistent layers, custom + hold-taps): pick a chip, then set its parameters below. */} + {chipBehaviors.length > 0 && (
- {(tieredBehaviors[activeTier][effectiveGroup] || []).map( - renderBehaviorChip - )} + {chipBehaviors.map(renderBehaviorChip)}
+ )}
- {/* Core behaviors are edited on the slot surface above; only non-core - behaviors (Sticky Key, Mouse, System, ...) keep the params picker. */} - {metadata && !isCoreBinding && ( + {/* Core behaviors are edited on the slot surface above, and tile behaviors + bake their params into the tile; only non-core, non-tile behaviors + (Sticky Key, custom hold-taps, ...) keep the params picker. */} + {metadata && !isCoreBinding && !isTileBinding && ( + a.behaviorId === b.behaviorId && a.param1 === b.param1 && a.param2 === b.param2; -// Two basic-tier tabs cover the whole "typing" keycode space: -// - Basic -> ANSI 60% rendering -// - ISO/JIS -> ISO 60% rendering + JIS extras row (¥ / `\_` / IME keys) -// JIS users see ¥ / IME keys in the extras row rather than in their -// physical bottom-row positions; same convention as upstream visual -// pickers, and avoids forcing every user to pick between three shapes -// when in practice they're mutually exclusive. +// One host-adaptive basic-tier tab covers the whole "typing" keycode space, +// rendering the shape that matches the selected host layout: +// - ansi -> ANSI 60% +// - iso -> ISO 60% (adds NUHS / NUBS) +// - jis -> JIS 60% (adds ¥ / `\_` / 変換 / 無変換 / かな) +// - ko -> KO 60% (adds 한자 / 한영) +// A US-ANSI board has no NUHS/NUBS, so they only appear once an ISO/JIS layout +// is selected; the search box still reaches any key regardless of shape. const BASIC_TAB_ID = "Basic"; -const ISO_JIS_TAB_ID = "ISO/JIS"; -const BASIC_TIER_TABS: ReadonlyArray = [BASIC_TAB_ID, ISO_JIS_TAB_ID]; +const BASIC_TIER_TABS: ReadonlyArray = [BASIC_TAB_ID]; + +// Tab identifiers, named once so the magic strings aren't duplicated across the +// categorizer, the tab-order list, the panel renderer, and the modifier-row +// visibility check. The keycode category tabs derive from a usage's metadata +// `category`; FUNCTION_NAV_METADATA_CATEGORY is the spec's longer name we shorten. +const FUNCTION_NAV_TAB = "Function + Nav"; +const NUMPAD_TAB = "Numpad"; +const APPS_MEDIA_TAB = "Apps/Media/Special"; +const INTERNATIONAL_TAB = "International"; +const OTHER_TAB = "Other"; +const FUNCTION_NAV_METADATA_CATEGORY = "Function + Navigation"; // 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 @@ -106,16 +127,43 @@ export interface HidUsagePickerProps { disabled?: boolean; clearTiles?: ClearTilesConfig; /** - * Customise the implicit-modifier column (the one that bakes modifiers into - * the picked keycode, e.g. picking C + L Ctrl → `LC(C)` = Ctrl+C in one - * keypress). Defaults to the always-visible "+ Modifier" column on the left. - * The Tap slot of the slot editor sets `side: "right"` + `collapsible` so it - * doesn't visually compete with the Hold slot's (semantically different) - * modifier picker. + * Whole-binding tile tabs appended to the keycode tab strip (before "Other"), + * e.g. Mouse / System. Picking a tile sets the entire binding via + * {@link onSelectBinding} rather than the Normal key. The Normal-slot surface + * passes these so single-action behaviors are picked the same way a keycode + * is (issue #16, Step 3); other HidUsagePicker uses omit them. + */ + extraTabs?: BindingTileTab[]; + /** Current full binding, used to highlight the active behavior tile / tab. */ + activeBinding?: BehaviorBinding; + /** Called with the whole binding when a behavior tile is pressed. */ + onSelectBinding?: (binding: BehaviorBinding) => void; + /** + * Internal: the grid reports its effective active tab so the outer picker can + * place the implicit-modifier row (only on the keycode tabs where composing a + * modified keycode makes sense) and hide it on the behavior tabs. + */ + onActiveTabChange?: (tab: string) => void; + /** + * Inline layout (the Normal-slot surface): the implicit-modifier picker is a + * compact **row below the grid** (shown only on the Basic / Function + Nav + * tabs) instead of a side column, and the grid's "Key" header + search box are + * suppressed so the parent can host the search on its own header row. Requires + * controlled {@link search} / {@link onSearchChange}. Other uses omit this and + * keep the side column + built-in search. + */ + inline?: boolean; + /** Controlled search text (inline mode); the parent renders the input. */ + search?: string; + /** Controlled search setter (inline mode). */ + onSearchChange?: (search: string) => void; + /** + * Customise the implicit-modifier picker (which bakes modifiers into the + * picked keycode, e.g. C + L Ctrl → `LC(C)` = Ctrl+C in one keypress). Defaults + * to the "+ Modifier" side column; the Normal slot passes `label: "+ Send with"`. */ modifiers?: { side?: "left" | "right"; - collapsible?: boolean; label?: string; hint?: string; }; @@ -167,6 +215,13 @@ const HidUsageGrid = ({ onValueChanged, usagePages, clearTiles, + extraTabs, + activeBinding, + onSelectBinding, + onActiveTabChange, + inline, + search: controlledSearch, + onSearchChange, }: HidUsagePickerProps) => { const layout = useHostLayout(); type Usage = { @@ -228,12 +283,16 @@ const HidUsageGrid = ({ for (const usage of allUsages) { const metadata = hid_usage_get_metadata(usage.pageId, usage.Id, layout); - let category = metadata?.category || "Other"; - // 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"; + let category = metadata?.category || OTHER_TAB; + // The spec's "Function + Navigation" 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_NAV_METADATA_CATEGORY) { + category = FUNCTION_NAV_TAB; + } + // Promote the common consumer media keys out of the Other catch-all. + if (usage.pageId === CONSUMER_PAGE && CONSUMER_MEDIA_IDS.has(usage.Id)) { + category = APPS_MEDIA_TAB; } // Letters / Numbers + Punctuation always land on a basic-tier tab // (rendered explicitly per shape, no flat-list fallback needed) so @@ -242,9 +301,9 @@ const HidUsageGrid = ({ continue; } // Anything on a basic-tier shape (Tab / Caps / mods / Space / etc., - // plus ISO NUHS/NUBS and JIS ¥/IME extras) is handled by the Basic - // or ISO/JIS tab's explicit grid — drop from category tabs so it - // doesn't surface twice and the auto-jump doesn't prefer a stale + // plus ISO NUHS/NUBS and JIS ¥/IME extras) is handled by the single + // host-adaptive Basic tab's explicit grid — drop from category tabs so + // it doesn't surface twice and the auto-jump doesn't prefer a stale // duplicate. if (usage.pageId === 7 && BASIC_TIER_HID_IDS.has(usage.Id)) { continue; @@ -259,6 +318,24 @@ const HidUsageGrid = ({ return categories; }, [allUsages, layout]); + // Group the Other catch-all into labelled sections for the combobox. + const otherSections = useMemo(() => { + const groups = new Map(); + for (const u of categorizedUsages[OTHER_TAB] ?? []) { + const title = otherSectionTitle(u.Name, u.pageId); + const bucket = groups.get(title); + if (bucket) bucket.push(u); + else groups.set(title, [u]); + } + return [...groups.entries()] + .sort( + (a, b) => + OTHER_SECTION_ORDER.indexOf(a[0] as (typeof OTHER_SECTION_ORDER)[number]) - + OTHER_SECTION_ORDER.indexOf(b[0] as (typeof OTHER_SECTION_ORDER)[number]) + ) + .map(([name, items]) => ({ name, items })); + }, [categorizedUsages]); + // null usage = transparent spacer cell (Enter-spans-rows placeholder). type ShapeRowItem = { usage: Usage | null; w?: number }; @@ -284,25 +361,23 @@ const HidUsageGrid = ({ [allUsages] ); - const ansiRows = useMemo(() => buildShapeRows(ANSI_ROWS), [buildShapeRows]); - // ISO/JIS tab shape follows the active host layout's `physical` hint - // so the picker mirrors the user's actual keyboard: - // - JIS -> Japanese 5-row shape with ¥, `\_`, 無変換, 変換, かな - // - KO -> Korean ANSI-shape with 한자 / 한/영 flanking Space - // - ANSI -> ISO shape (fallback that still lets US-ANSI users bind - // ISO compat keys NUHS / NUBS without leaving the tab) - // - ISO -> ISO shape - const altShape = useMemo(() => { + // The single Basic tab's shape follows the active host layout's `physical` + // hint so the picker mirrors the user's actual keyboard: + // - ANSI -> ANSI shape (no NUHS / NUBS — the board doesn't have them) + // - ISO -> ISO shape (adds NUHS / NUBS) + // - JIS -> Japanese shape with ¥, `\_`, 無変換, 変換, かな + // - KO -> Korean shape with 한자 / 한/영 flanking Space + const basicShape = useMemo(() => { switch (layout.physical) { case "jis": return JIS_ROWS; case "ko": return KO_ROWS; case "iso": return ISO_ROWS; - default: return ISO_ROWS; + default: return ANSI_ROWS; } }, [layout.physical]); - const isoOrJisRows = useMemo( - () => buildShapeRows(altShape), - [buildShapeRows, altShape] + const basicRows = useMemo( + () => buildShapeRows(basicShape), + [buildShapeRows, basicShape] ); const UNIT_PX = 48; @@ -334,7 +409,7 @@ const HidUsageGrid = ({ } return out; }); - const extras = (categorizedUsages["Numpad"] || []).filter( + const extras = (categorizedUsages[NUMPAD_TAB] || []).filter( (u) => !placed.has(u) ); if (extras.length) { @@ -425,11 +500,11 @@ const HidUsageGrid = ({ const sortedCategories = useMemo(() => { const categoryOrder = [ - "Function + Nav", - "Numpad", - "Apps/Media/Special", - "International", - "Other", + FUNCTION_NAV_TAB, + NUMPAD_TAB, + APPS_MEDIA_TAB, + INTERNATIONAL_TAB, + OTHER_TAB, ]; return Object.keys(categorizedUsages).sort((a, b) => { const indexA = categoryOrder.indexOf(a); @@ -441,23 +516,28 @@ const HidUsageGrid = ({ }); }, [categorizedUsages]); - // Top-level tab order: ANSI basic, ISO/JIS, then the category tabs. - const allTabs = useMemo( - () => [...BASIC_TIER_TABS, ...sortedCategories], - [sortedCategories] + // Top-level tab order: ANSI basic, ISO/JIS, the keycode category tabs, then + // the whole-binding behavior tabs (Mouse / System), and "Other" stays last. + const extraTabNames = useMemo( + () => (extraTabs ?? []).map((t) => t.name), + [extraTabs] ); + const allTabs = useMemo(() => { + const cats = sortedCategories.filter((c) => c !== OTHER_TAB); + const other = sortedCategories.includes(OTHER_TAB) ? [OTHER_TAB] : []; + return [...BASIC_TIER_TABS, ...cats, ...extraTabNames, ...other]; + }, [sortedCategories, extraTabNames]); - const [search, setSearch] = useState(""); + // Search is controlled in inline mode (the parent hosts the input on its own + // header row); otherwise the grid owns it and renders its own "Key" header. + const [internalSearch, setInternalSearch] = useState(""); + const search = onSearchChange ? controlledSearch ?? "" : internalSearch; + const setSearch = onSearchChange ?? setInternalSearch; const trimmedSearch = search.trim().toLowerCase(); - // Auto-select the tab containing the current value when it changes. - // Keys in the basic-tier (letters / numbers / mods / etc.) prefer the - // user's last-used basic-tier tab (Basic vs ISO/JIS), persisted to - // localStorage so a JIS user doesn't click ISO/JIS every time. - const [preferredBasicTab, setPreferredBasicTab] = useLocalStorageState( - "picker-basic-tab", - BASIC_TAB_ID - ); + // Auto-select the tab containing the current value when it changes. Every + // basic-tier key (letters / numbers / mods / shape-specific NUHS / ¥ / IME) + // lives on the single host-adaptive Basic tab now. const [activeTab, setActiveTab] = useState(null); useEffect(() => { if (value === undefined || value === 0) { @@ -468,18 +548,7 @@ const HidUsageGrid = ({ const page = (masked >> 16) & 0xffff; const usageId = masked & 0xffff; if (page === 7 && BASIC_TIER_HID_IDS.has(usageId)) { - // Shape-specific keys (ISO NUHS/NUBS, JIS ¥/IME, Korean 한자/ - // 한/영) force the ISO/JIS tab; ANSI-shared keys go wherever the - // user last was. - if ( - ISO_ONLY_HID_IDS.has(usageId) || - JIS_ONLY_HID_IDS.has(usageId) || - KO_ONLY_HID_IDS.has(usageId) - ) { - setActiveTab(ISO_JIS_TAB_ID); - } else { - setActiveTab(preferredBasicTab); - } + setActiveTab(BASIC_TAB_ID); return; } for (const cat of sortedCategories) { @@ -491,17 +560,41 @@ const HidUsageGrid = ({ return; } } - }, [value, categorizedUsages, sortedCategories, preferredBasicTab]); + }, [value, categorizedUsages, sortedCategories]); - const onTabChange = useCallback( - (k: string) => { - setActiveTab(k); - if (k === BASIC_TAB_ID || k === ISO_JIS_TAB_ID) { - setPreferredBasicTab(k); + // When the bound behavior is one of the whole-binding tiles (Mouse / System), + // jump to its tab. Keyed on the binding's *values* (not the object identity) + // so it only fires when the binding actually changes — otherwise it would snap + // the tab back to Mouse/System the moment the user clicks a keycode tab to + // pick a Normal key. + useEffect(() => { + if (!extraTabs || !activeBinding) return; + for (const t of extraTabs) { + const hit = t.sections.some((s) => + s.tiles.some((tile) => sameBinding(tile.binding, activeBinding)) + ); + if (hit) { + setActiveTab(t.name); + return; } - }, - [setPreferredBasicTab] - ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + activeBinding?.behaviorId, + activeBinding?.param1, + activeBinding?.param2, + extraTabs, + ]); + + const onTabChange = useCallback((k: string) => setActiveTab(k), []); + + // Report the effective active tab so the outer picker can make the modifier + // column tab-aware. `activeTab` is null until a value/binding forces it, so + // fall back to the tab react-aria actually shows (the Basic tab). + const effectiveActiveTab = activeTab ?? BASIC_TAB_ID; + useEffect(() => { + onActiveTabChange?.(effectiveActiveTab); + }, [effectiveActiveTab, onActiveTabChange]); const searchResults = useMemo(() => { if (!trimmedSearch) { @@ -523,6 +616,24 @@ const HidUsageGrid = ({ }); }, [allUsages, trimmedSearch, layout]); + // The behavior tiles (Mouse / System) live in a separate data shape from the + // HID usages, so they need their own filter — otherwise a search like "reset" + // or "bt" finds nothing even though the tile is right there on a tab. Match the + // tile label, its section heading, and the tab name. + const behaviorSearchResults = useMemo(() => { + if (!trimmedSearch || !extraTabs) return []; + const out: BehaviorTile[] = []; + for (const tab of extraTabs) { + for (const section of tab.sections) { + for (const tile of section.tiles) { + const haystack = `${tile.label} ${section.heading} ${tab.name}`.toLowerCase(); + if (haystack.includes(trimmedSearch)) out.push(tile); + } + } + } + return out; + }, [trimmedSearch, extraTabs]); + const renderUsageButton = (usage: Usage) => { const usageValue = (usage.pageId << 16) | usage.Id; return ( @@ -583,35 +694,66 @@ const HidUsageGrid = ({ const trailingClearKeycapsBasic = makeTrailingClearKeycaps("basic"); const trailingClearKeycapsUsage = makeTrailingClearKeycaps("usage"); + // A whole-binding behavior tile, styled like a keycap (same height + font as + // the keycode tiles) so it reads the same as picking a key. Widens for longer + // labels rather than shrinking the text. Highlighted when it is the current + // binding. + const renderBehaviorTile = (tile: BehaviorTile) => { + const active = !!activeBinding && sameBinding(tile.binding, activeBinding); + return ( + + + + ); + }; + 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 text-sm" - /> + {/* In inline mode the parent hosts the "Key" label + search on its own + header row, so the grid renders none of its own here. */} + {!inline && ( +
+ +
+ + 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 ? (
- {searchResults.length === 0 ? ( + {searchResults.length === 0 && behaviorSearchResults.length === 0 ? (
- No keys match "{search}". + Nothing matches "{search}".
) : ( - searchResults.map(renderUsageButton) + <> + {searchResults.map(renderUsageButton)} + {/* Matching behavior tiles (Reset, BT_SEL, LCLK, …) so the search + reaches the whole surface, not just the keycodes. */} + {behaviorSearchResults.map(renderBehaviorTile)} + )} {/* Keep the clear tiles reachable even when the search matches nothing, so a key can still be cleared mid-search. */} @@ -620,7 +762,7 @@ const HidUsageGrid = ({ ) : ( onTabChange(k as string)} > @@ -634,7 +776,9 @@ const HidUsageGrid = ({ ))} - {allTabs.map((category) => ( + {allTabs + .filter((category) => !extraTabNames.includes(category)) + .map((category) => ( {category === BASIC_TAB_ID ? (
- {ansiRows.map((row, i) => ( -
- {row.map((cell, j) => renderBasicCell(cell, j))} - {i === ansiRows.length - 1 && trailingClearKeycapsBasic} -
- ))} -
- ) : category === ISO_JIS_TAB_ID ? ( -
- {isoOrJisRows.map((row, i) => ( + {basicRows.map((row, i) => (
{row.map((cell, j) => renderBasicCell(cell, j))} - {i === isoOrJisRows.length - 1 && trailingClearKeycapsBasic} + {i === basicRows.length - 1 && trailingClearKeycapsBasic}
))}
- ) : category === "Numpad" ? ( + ) : category === NUMPAD_TAB ? (
{numpadRows.map((row, i) => (
@@ -677,7 +811,7 @@ const HidUsageGrid = ({
))}
- ) : category === "Function + Nav" ? ( + ) : category === FUNCTION_NAV_TAB ? (
{functionRows.map((row, i) => ( @@ -711,10 +845,10 @@ const HidUsageGrid = ({
)}
- ) : category === "Other" ? ( + ) : category === OTHER_TAB ? ( key !== null && onValueChanged(key as number) @@ -729,18 +863,27 @@ const HidUsageGrid = ({
- {(item: Usage) => { - const usageValue = (item.pageId << 16) | item.Id; - return ( - - {item.Name} - - ); - }} + {(section: { name: string; items: Usage[] }) => ( +
+
+ {section.name} +
+ + {(item: Usage) => { + const usageValue = (item.pageId << 16) | item.Id; + return ( + + {item.Name} + + ); + }} + +
+ )}
@@ -752,6 +895,30 @@ const HidUsageGrid = ({ )} ))} + {/* Whole-binding behavior tabs (Mouse / System): keycap-styled tiles, one + labelled section per source behavior, each tile a finished binding. */} + {extraTabs?.map((tab) => ( + + {tab.sections.map((section) => ( +
+ + {section.heading} + +
+ {section.tiles.map(renderBehaviorTile)} +
+
+ ))} +
+ ))} )}
@@ -765,12 +932,32 @@ export const HidUsagePicker = ({ onValueChanged, disabled, clearTiles, + extraTabs, + activeBinding, + onSelectBinding, + inline, + search, + onSearchChange, modifiers, }: HidUsagePickerProps) => { const modSide = modifiers?.side ?? "left"; - const modCollapsible = modifiers?.collapsible ?? false; const modLabel = modifiers?.label ?? "+ Modifier"; - const [modsOpen, setModsOpen] = useState(false); + + // Track the grid's active tab so the implicit-modifier picker can be shown + // only where it makes sense (keycode tabs) and hidden on the behavior tabs. + const [activeTabName, setActiveTabName] = useState(BASIC_TAB_ID); + const behaviorTabNames = useMemo( + () => (extraTabs ?? []).map((t) => t.name), + [extraTabs] + ); + const onBehaviorTab = behaviorTabNames.includes(activeTabName); + // In inline mode the modifier row shows only on the tabs where you actually + // compose a modified keycode — Basic and Function + Nav — and never while a + // search is filtering the grid. + const showModRow = + !onBehaviorTab && + (activeTabName === BASIC_TAB_ID || activeTabName === FUNCTION_NAV_TAB) && + !(search && search.trim().length > 0); const mods = useMemo(() => { const flags = value ? value >> 24 : 0; @@ -778,10 +965,13 @@ export const HidUsagePicker = ({ return all_mods.filter((m) => m & flags).map((m) => m.toLocaleString()); }, [value]); - const activeModSummary = mods - .map((m) => mod_labels[parseInt(m) as Mods]) - .join(" + "); - const showModChecks = !modCollapsible || modsOpen; + const toggleMod = (m: Mods) => { + const next = new Set(mods); + const key = m.toLocaleString(); + if (next.has(key)) next.delete(key); + else next.add(key); + modifiersChanged([...next]); + }; const selectionChanged = useCallback( (e: number | undefined) => { @@ -809,59 +999,67 @@ export const HidUsagePicker = ({ [value, onValueChanged], ); + // Side column (default, non-inline uses such as the Sticky Key / Key Toggle + // key picker): the implicit modifiers as a vertical checkbox stack. const modifierColumn = (
- {modCollapsible ? ( + +
+ + {all_mods.map((m) => ( + + {mod_labels[m]} + + ))} + + {modifiers?.hint && ( +

{modifiers.hint}

+ )} +
+ ); + + // Inline row (the Normal-slot surface): a compact "Send with" row of modifier + // toggles below the full-width grid, so it never steals tab/grid width. + const modifierRow = ( +
+ + {modLabel} + + {all_mods.map((m) => { + const active = mods.includes(m.toLocaleString()); + return ( - ) : ( - - )} -
- {showModChecks && ( - <> - - {all_mods.map((m) => ( - - {mod_labels[m]} - - ))} - - {modifiers?.hint && ( -

{modifiers.hint}

- )} - + ); + })} + {modifiers?.hint && ( + {modifiers.hint} )}
); @@ -873,10 +1071,31 @@ export const HidUsagePicker = ({ onValueChanged={selectionChanged} usagePages={usagePages} clearTiles={clearTiles} + extraTabs={extraTabs} + activeBinding={activeBinding} + onSelectBinding={onSelectBinding} + onActiveTabChange={setActiveTabName} + inline={inline} + search={search} + onSearchChange={onSearchChange} />
); + if (inline) { + return ( +
+ {grid} + {showModRow && modifierRow} +
+ ); + } + return (
+ {/* The implicit-modifier column is irrelevant on the whole-binding tabs + (Mouse / System) — a Reset can't "send with Ctrl" — so hide it there. */} {modSide === "right" ? ( <> {grid} - {modifierColumn} + {!onBehaviorTab && modifierColumn} ) : ( <> - {modifierColumn} + {!onBehaviorTab && modifierColumn} {grid} )} diff --git a/src/behaviors/SlotBindingPicker.tsx b/src/behaviors/SlotBindingPicker.tsx index 65def2d1..8c98668c 100644 --- a/src/behaviors/SlotBindingPicker.tsx +++ b/src/behaviors/SlotBindingPicker.tsx @@ -4,10 +4,12 @@ // `docs/design/key-picker-rethink.md` §2-§3 and the pure model in `slots.ts`. import { useEffect, useState } from "react"; +import { Search } from "lucide-react"; import { BehaviorBinding } from "@zmkfirmware/zmk-studio-ts-client/keymap"; import { ClearTilesConfig, HidUsagePicker } from "./HidUsagePicker"; +import { BindingTileTab } from "./tiles"; import { bindingToSlots, CORE_LABELS, @@ -20,6 +22,8 @@ import { usageFromModifierSet, } from "./slots"; import { hid_usage_get_label, hid_usage_page_and_id_from_usage } from "../hid-usages"; +import { useHostLayout } from "../layouts/LayoutContext"; +import type { HostLayout } from "../layouts"; export interface SlotBindingPickerProps { binding: BehaviorBinding; @@ -32,6 +36,23 @@ export interface SlotBindingPickerProps { * not slot-derivable, so it still needs a tile here.) */ clearTiles?: ClearTilesConfig; + /** + * Whole-binding behavior tabs (Mouse / System) shown in the Normal key grid's + * tab strip — single-action behaviors picked the same way a keycode is + * (issue #16, Step 3). Selecting one calls {@link onBindingChanged}. + */ + behaviorTabs?: BindingTileTab[]; + /** + * Display name of the current behavior when it is *not* a slot-derivable core + * (e.g. a Mouse / System tile). Shown in the reverse-lookup label instead of + * "No matching behavior". + */ + currentBehaviorName?: string; + /** + * The current binding rendered as ZMK devicetree code (e.g. `&mt LSHFT RET`), + * shown alongside the reverse-lookup label so ZMK users see exactly what's set. + */ + bindingCode?: string; } const EMPTY_SLOTS: Slots = { tap: { kind: "empty" }, hold: { kind: "empty" } }; @@ -57,15 +78,16 @@ const chipClass = (active: boolean) => : "bg-base-200 hover:bg-base-300 border-base-300" }`; -function tapLabel(slot: Slots["tap"]): string { +function tapLabel(slot: Slots["tap"], layout: HostLayout): string { if (slot.kind === "empty") return "—"; // Strip the implicit-modifier high byte before resolving the label, then show // those "+ Send with" modifiers as a prefix (e.g. `LC(S)` → "L Ctrl + S"). // Without the strip, the modified value mis-reads its page and falls back to a - // raw `0x…`. + // raw `0x…`. Resolve with the host layout so the glyph matches the grid (e.g. + // on JIS, Shift+2 is `"`, not the ANSI `@`). const base = slot.usage & 0x00ffffff; const [page, id] = hid_usage_page_and_id_from_usage(base); - const key = hid_usage_get_label(page, id) ?? `0x${base.toString(16)}`; + const key = hid_usage_get_label(page, id, layout) ?? `0x${base.toString(16)}`; const mods = HOLD_MODIFIERS.filter( (m) => ((slot.usage >>> 24) & m.bit) !== 0 ).map((m) => m.label); @@ -78,8 +100,16 @@ export const SlotBindingPicker = ({ layers, onBindingChanged, clearTiles, + behaviorTabs, + currentBehaviorName, + bindingCode, }: SlotBindingPickerProps) => { const slots = bindingToSlots(binding, registry) ?? EMPTY_SLOTS; + const layout = useHostLayout(); + + // Search for the Normal key grid is hosted here (on the NORMAL header row) and + // fed to the grid, so the grid drops its own "Key" header. + const [search, setSearch] = useState(""); const tapFilled = slots.tap.kind === "key"; const holdFilled = slots.hold.kind !== "empty"; @@ -137,7 +167,10 @@ export const SlotBindingPicker = ({ const derivedName = (() => { const core = registry.coreById.get(binding.behaviorId); - return core ? CORE_LABELS[core] : "No matching behavior"; + if (core) return CORE_LABELS[core]; + // Non-core: a Mouse / System tile (or other behavior) — name it rather than + // showing "No matching behavior", so the slot label always reflects reality. + return currentBehaviorName ?? "No matching behavior"; })(); const hold = slots.hold; @@ -157,13 +190,18 @@ export const SlotBindingPicker = ({ return (
-
+
Slots = {derivedName} + {bindingCode && ( + + {bindingCode} + + )}
{/* Normal (always shown — the key's base action) and an optional Hold mode @@ -262,13 +300,24 @@ export const SlotBindingPicker = ({
-
+
Normal {tapFilled ? ( - {tapLabel(slots.tap)} + {tapLabel(slots.tap, layout)} ) : ( The key sent on a normal press. )} +
+ + 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" + /> +
diff --git a/src/behaviors/other-sections.test.ts b/src/behaviors/other-sections.test.ts new file mode 100644 index 00000000..0cf4d45d --- /dev/null +++ b/src/behaviors/other-sections.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { + CONSUMER_MEDIA_IDS, + OTHER_SECTION_ORDER, + otherSectionTitle, +} from "./other-sections"; + +describe("otherSectionTitle", () => { + it("groups every page-7 keyboard/keypad usage together", () => { + expect(otherSectionTitle("Keyboard Execute", 7)).toBe("Keyboard / Keypad"); + expect(otherSectionTitle("Keypad Equal Sign", 7)).toBe("Keyboard / Keypad"); + }); + + it("routes AL / AC application usages by prefix", () => { + expect(otherSectionTitle("AL Calculator", 12)).toBe("Application Launch"); + expect(otherSectionTitle("AC Copy", 12)).toBe("Application Control"); + }); + + it("groups telephony and contacts", () => { + expect(otherSectionTitle("Contact Edited", 12)).toBe("Telephony & Contacts"); + expect(otherSectionTitle("Media Select Telephone", 12)).toBe("Telephony & Contacts"); + }); + + it("groups display and camera", () => { + expect(otherSectionTitle("Display Brightness", 12)).toBe("Display & Camera"); + expect(otherSectionTitle("Camera Access Toggle", 12)).toBe("Display & Camera"); + }); + + it("groups audio", () => { + expect(otherSectionTitle("Bass Boost", 12)).toBe("Audio"); + expect(otherSectionTitle("Channel Increment", 12)).toBe("Audio"); + }); + + it("groups media & playback", () => { + expect(otherSectionTitle("Tracking Increment", 12)).toBe("Media & Playback"); + expect(otherSectionTitle("Frame Forward", 12)).toBe("Media & Playback"); + }); + + it("falls back to Other for anything unmatched", () => { + expect(otherSectionTitle("Some Obscure Usage", 12)).toBe("Other"); + }); + + it("only ever returns a title listed in OTHER_SECTION_ORDER", () => { + const samples: [string, number][] = [ + ["Keyboard Help", 7], + ["AL Email Reader", 12], + ["AC Paste", 12], + ["Contact Index", 12], + ["Display Backlight Toggle", 12], + ["Bass Increment", 12], + ["Play/Pause", 12], + ["Totally Unknown", 12], + ]; + for (const [name, page] of samples) { + expect(OTHER_SECTION_ORDER).toContain(otherSectionTitle(name, page)); + } + }); +}); + +describe("CONSUMER_MEDIA_IDS", () => { + it("contains the core transport + volume keys", () => { + expect(CONSUMER_MEDIA_IDS.has(176)).toBe(true); // Play + expect(CONSUMER_MEDIA_IDS.has(183)).toBe(true); // Stop + expect(CONSUMER_MEDIA_IDS.has(226)).toBe(true); // Mute + expect(CONSUMER_MEDIA_IDS.has(233)).toBe(true); // Volume Increment + }); + + it("excludes the obscure long tail", () => { + expect(CONSUMER_MEDIA_IDS.has(189)).toBe(false); // Tracking + expect(CONSUMER_MEDIA_IDS.has(403)).toBe(false); // AL A/V Capture/Playback + }); +}); diff --git a/src/behaviors/other-sections.ts b/src/behaviors/other-sections.ts new file mode 100644 index 00000000..4349233e --- /dev/null +++ b/src/behaviors/other-sections.ts @@ -0,0 +1,47 @@ +// Categorisation helpers for the keycode picker's "Other" catch-all (issue #16, +// Step 3a). Kept in their own module so the pure logic is unit-testable without +// pulling in the React picker. + +// Consumer (HID page 0x0C) media keys worth promoting out of the "Other" +// catch-all onto the Apps/Media/Special tab — the transport + volume controls a +// keymap author actually binds. The long tail of consumer usages (app launch, +// app control, contacts, …) stays in Other, reachable via search. +export const CONSUMER_PAGE = 12; +export const CONSUMER_MEDIA_IDS: ReadonlySet = new Set([ + 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, // Play … Random Play + 204, 205, 206, // Stop/Eject, Play/Pause, Play/Skip + 224, 226, 233, 234, // Volume, Mute, Volume Inc/Dec +]); + +// The "Other" combobox is a long catch-all; group it into labelled sections so +// it's navigable. The grouping is derived from the usage (page + name prefix) — +// the HID spec doesn't give these a finer category, so this is a pragmatic +// heuristic kept in one place. Section order is defined by OTHER_SECTION_ORDER. +export const OTHER_SECTION_ORDER = [ + "Keyboard / Keypad", + "Media & Playback", + "Audio", + "Display & Camera", + "Application Launch", + "Application Control", + "Telephony & Contacts", + "Other", +] as const; + +export type OtherSection = (typeof OTHER_SECTION_ORDER)[number]; + +/** Pick the section a leftover "Other" usage belongs to (page + name heuristic). */ +export function otherSectionTitle(name: string, pageId: number): OtherSection { + if (pageId === 7) return "Keyboard / Keypad"; + if (name.startsWith("AL ")) return "Application Launch"; + if (name.startsWith("AC ")) return "Application Control"; + if (name.startsWith("Contact ") || /Phone|Dial|Telephone/.test(name)) + return "Telephony & Contacts"; + if (/Display|Camera|Brightness|Backlight|Illumination|Privacy Screen/.test(name)) + return "Display & Camera"; + if (/Volume|Mute|Bass|Treble|Balance|Speaker|Channel|Loudness|Surround|Audio/.test(name)) + return "Audio"; + if (/Play|Pause|Record|Rewind|Forward|Track|Stop|Eject|Repeat|Frame|Speed|Mark/.test(name)) + return "Media & Playback"; + return "Other"; +} diff --git a/src/behaviors/parameters.test.ts b/src/behaviors/parameters.test.ts new file mode 100644 index 00000000..0bf5907c --- /dev/null +++ b/src/behaviors/parameters.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; + +import { formatBinding } from "./parameters"; +import { formatBindingParam } from "../keymap-parser"; +import { DEMO_ORTHO_50 } from "../mock/fixtures"; + +const behaviors = DEMO_ORTHO_50.behaviors; +const layerIds = DEMO_ORTHO_50.layers.map((l) => l.id); +const by = (name: string) => behaviors.find((b) => b.displayName === name)!; +const hid = (id: number) => (7 << 16) | id; + +describe("formatBinding", () => { + it("renders a key press with its keycode name", () => { + const kp = by("Key Press"); + expect( + formatBinding({ behaviorId: kp.id, param1: hid(4), param2: 0 }, kp, layerIds) + ).toBe(`&kp ${formatBindingParam(hid(4))}`); + }); + + it("renders mod-tap with both params in order", () => { + const mt = by("Mod-Tap"); + const binding = { behaviorId: mt.id, param1: hid(0xe1), param2: hid(0x28) }; + expect(formatBinding(binding, mt, layerIds)).toBe( + `&mt ${formatBindingParam(hid(0xe1))} ${formatBindingParam(hid(0x28))}` + ); + }); + + it("renders a momentary layer with a layer index", () => { + const mo = by("Momentary Layer"); + expect( + formatBinding({ behaviorId: mo.id, param1: 1, param2: 0 }, mo, layerIds) + ).toBe("&mo 1"); + }); + + it("renders layer-tap with a layer index and a tap key", () => { + const lt = by("Layer-Tap"); + expect( + formatBinding({ behaviorId: lt.id, param1: 1, param2: hid(0x28) }, lt, layerIds) + ).toBe(`< 1 ${formatBindingParam(hid(0x28))}`); + }); + + it("renders a parameterless behavior bare", () => { + const none = by("None"); + const reset = by("Reset"); + expect(formatBinding({ behaviorId: none.id, param1: 0, param2: 0 }, none, layerIds)).toBe("&none"); + expect(formatBinding({ behaviorId: reset.id, param1: 0, param2: 0 }, reset, layerIds)).toBe("&reset"); + }); + + it("uses the metadata constant name and emits param2 only when the set has it", () => { + const bt = by("Bluetooth"); + // BT_SEL (constant 4) takes a profile; BT_CLR (constant 0) does not. + expect(formatBinding({ behaviorId: bt.id, param1: 4, param2: 2 }, bt, layerIds)).toBe("&bt BT_SEL 2"); + expect(formatBinding({ behaviorId: bt.id, param1: 0, param2: 0 }, bt, layerIds)).toBe("&bt BT_CLR"); + }); + + it("returns an empty string for an unknown behavior", () => { + expect(formatBinding({ behaviorId: 999, param1: 0, param2: 0 }, undefined, layerIds)).toBe(""); + }); +}); diff --git a/src/behaviors/parameters.ts b/src/behaviors/parameters.ts index 4f3188e0..a89e29c0 100644 --- a/src/behaviors/parameters.ts +++ b/src/behaviors/parameters.ts @@ -1,8 +1,11 @@ import { BehaviorParameterValueDescription, BehaviorBindingParametersSet, + GetBehaviorDetailsResponse, } from "@zmkfirmware/zmk-studio-ts-client/behaviors"; +import { BehaviorBinding } from "@zmkfirmware/zmk-studio-ts-client/keymap"; import { hid_usage_page_and_id_from_usage } from "../hid-usages"; +import { dtsRefForDisplayName, formatBindingParam } from "../keymap-parser"; export function validateValue( layerIds: number[], @@ -63,3 +66,35 @@ export function validateBinding( return validateValue(layerIds, param2, matchingSet.param2); } + +/** + * Render a binding as the ZMK devicetree code a keymap author would write, e.g. + * `&mt LSHFT RET`, `&kp A`, `&mo 1`, `&bt BT_SEL 0`, `&reset`. The behavior + * reference comes from the displayName ({@link dtsRefForDisplayName}); each + * parameter prefers its metadata constant name (the ZMK macro, e.g. `BT_SEL`) + * and otherwise falls back to a keycode name / layer index + * ({@link formatBindingParam}). Only the parameters the matching metadata set + * actually defines are emitted, so a no-param behavior renders bare. + */ +export function formatBinding( + binding: BehaviorBinding, + behavior: GetBehaviorDetailsResponse | undefined, + layerIds: number[] +): string { + if (!behavior) return ""; + const ref = `&${dtsRefForDisplayName(behavior.displayName)}`; + const sets = behavior.metadata ?? []; + if (sets.length === 0) return ref; + + const set = + sets.find((s) => validateValue(layerIds, binding.param1, s.param1)) ?? sets[0]; + const fmt = ( + value: number, + descs: BehaviorParameterValueDescription[] | undefined + ) => descs?.find((d) => d.constant === value)?.name ?? formatBindingParam(value); + + const parts = [ref]; + if (set.param1 && set.param1.length > 0) parts.push(fmt(binding.param1, set.param1)); + if (set.param2 && set.param2.length > 0) parts.push(fmt(binding.param2, set.param2)); + return parts.join(" "); +} diff --git a/src/behaviors/tiles.test.ts b/src/behaviors/tiles.test.ts new file mode 100644 index 00000000..16f262d4 --- /dev/null +++ b/src/behaviors/tiles.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; + +import type { GetBehaviorDetailsResponse } from "@zmkfirmware/zmk-studio-ts-client/behaviors"; + +import { isTileable, tileBindingsFor } from "./tiles"; + +const behavior = ( + id: number, + displayName: string, + metadata: GetBehaviorDetailsResponse["metadata"] +): GetBehaviorDetailsResponse => ({ id, displayName, metadata }); + +describe("tileBindingsFor", () => { + it("makes a single tile for a parameterless behavior (empty metadata)", () => { + const reset = behavior(11, "Reset", []); + expect(tileBindingsFor(reset)).toEqual([ + { label: "Reset", binding: { behaviorId: 11, param1: 0, param2: 0 } }, + ]); + }); + + it("makes a single tile for a behavior whose set has no params", () => { + const capsWord = behavior(3, "Caps Word", [{ param1: [], param2: [] }]); + expect(tileBindingsFor(capsWord)).toEqual([ + { label: "Caps Word", binding: { behaviorId: 3, param1: 0, param2: 0 } }, + ]); + }); + + it("makes one tile per constant value (Output-style)", () => { + const out = behavior(20, "Output Selection", [ + { + param1: [ + { name: "USB", constant: 0 }, + { name: "BLE", constant: 1 }, + { name: "Toggle", constant: 2 }, + ], + param2: [], + }, + ]); + expect(tileBindingsFor(out)).toEqual([ + { label: "USB", binding: { behaviorId: 20, param1: 0, param2: 0 } }, + { label: "BLE", binding: { behaviorId: 20, param1: 1, param2: 0 } }, + { label: "Toggle", binding: { behaviorId: 20, param1: 2, param2: 0 } }, + ]); + }); + + it("explodes a range param into one tile per value (Bluetooth profiles)", () => { + // BT_SEL takes a profile index range; BT_CLR takes no profile. Two sets. + const bt = behavior(21, "Bluetooth", [ + { + param1: [{ name: "BT_SEL", constant: 0 }], + param2: [{ name: "profile", range: { min: 0, max: 2 } }], + }, + { param1: [{ name: "BT_CLR", constant: 1 }], param2: [] }, + ]); + expect(tileBindingsFor(bt)).toEqual([ + { label: "BT_SEL 0", binding: { behaviorId: 21, param1: 0, param2: 0 } }, + { label: "BT_SEL 1", binding: { behaviorId: 21, param1: 0, param2: 1 } }, + { label: "BT_SEL 2", binding: { behaviorId: 21, param1: 0, param2: 2 } }, + { label: "BT_CLR", binding: { behaviorId: 21, param1: 1, param2: 0 } }, + ]); + }); + + it("treats a nil param as a single zero-valued tile", () => { + const b = behavior(30, "Nilly", [ + { param1: [{ name: "n", nil: {} }], param2: [] }, + ]); + expect(tileBindingsFor(b)).toEqual([ + { label: "Nilly", binding: { behaviorId: 30, param1: 0, param2: 0 } }, + ]); + }); + + it("returns null for a behavior with a HID-key param (Sticky Key)", () => { + const sk = behavior(2, "Sticky Key", [ + { + param1: [ + { name: "key", hidUsage: { keyboardMax: 0xffff, consumerMax: 0xffff } }, + ], + param2: [], + }, + ]); + expect(tileBindingsFor(sk)).toBeNull(); + expect(isTileable(sk)).toBe(false); + }); + + it("returns null for a behavior with a layer param (To Layer)", () => { + const toLayer = behavior(8, "To Layer", [ + { param1: [{ name: "layer", layerId: {} }], param2: [] }, + ]); + expect(tileBindingsFor(toLayer)).toBeNull(); + }); + + it("returns null when only the second param needs a picker (Mod-Tap)", () => { + const mt = behavior(10, "Mod-Tap", [ + { + param1: [ + { name: "mod", hidUsage: { keyboardMax: 0xffff, consumerMax: 0xffff } }, + ], + param2: [ + { name: "tap", hidUsage: { keyboardMax: 0xffff, consumerMax: 0xffff } }, + ], + }, + ]); + expect(tileBindingsFor(mt)).toBeNull(); + }); + + it("reports tile-able behaviors via isTileable", () => { + expect(isTileable(behavior(11, "Reset", []))).toBe(true); + }); +}); diff --git a/src/behaviors/tiles.ts b/src/behaviors/tiles.ts new file mode 100644 index 00000000..e0552d95 --- /dev/null +++ b/src/behaviors/tiles.ts @@ -0,0 +1,131 @@ +// Tile model for the binding editor (issue #16, Step 3a). +// +// Step 2 moved the core typing/layer/hold-tap behaviors onto the slot surface. +// Step 3a turns the remaining *single-action* behaviors — Mouse, Bluetooth, +// Output, External Power, System (Reset / Bootloader / Studio Unlock), and the +// parameterless Caps Word / Key Repeat / Grave-Escape — into grids of tiles, +// one tile per finished binding (design note §3a, shape (a)). +// +// A behavior is **tile-able** only when every parameter it takes is a fixed +// value the UI can pre-bake: `constant`, `range` (exploded into one tile per +// value), `nil`, or absent. A parameter that needs a rich picker — a HID key +// (`hidUsage`: `&sk` / `&kt`) or a layer (`layerId`: the persistent-layer +// behaviors) — makes the behavior *not* tile-able, so it keeps the existing +// chip + parameter-picker flow. `tileBindingsFor` returns `null` for those. + +import type { + BehaviorParameterValueDescription, + GetBehaviorDetailsResponse, +} from "@zmkfirmware/zmk-studio-ts-client/behaviors"; +import type { BehaviorBinding } from "@zmkfirmware/zmk-studio-ts-client/keymap"; + +/** One finished binding the user can pick with a single tile press. */ +export interface BehaviorTile { + /** Label shown on the tile (e.g. "BT_SEL 0", "Reset"). */ + label: string; + binding: BehaviorBinding; +} + +/** A labelled run of tiles within a tab (one heading per source behavior). */ +export interface BindingTileSection { + heading: string; + tiles: BehaviorTile[]; +} + +/** + * A whole-binding tile tab shown alongside the keycode tabs in the Normal-slot + * surface (e.g. "Mouse", "System"). Picking a tile sets the entire binding, + * unlike the keycode tabs which set the Normal key. + */ +export interface BindingTileTab { + name: string; + sections: BindingTileSection[]; +} + +/** One concrete value a parameter slot can take, with its label fragment. */ +interface ParamValue { + value: number; + /** The metadata name for this value, if any (constants / range index). */ + label?: string; +} + +/** + * Enumerate the concrete values a single parameter slot accepts. An absent or + * empty slot yields a single `{ value: 0 }` (the binding still needs a numeric + * param). Returns `null` when the slot needs a rich picker (`hidUsage` / + * `layerId`), marking the whole behavior as not tile-able. + */ +function enumerateParam( + values: BehaviorParameterValueDescription[] | undefined +): ParamValue[] | null { + if (!values || values.length === 0) { + return [{ value: 0 }]; + } + const out: ParamValue[] = []; + for (const v of values) { + if (v.constant !== undefined) { + out.push({ value: v.constant, label: v.name }); + } else if (v.range) { + // Explode the range into one tile per value (e.g. BT profile 0..N). + for (let i = v.range.min; i <= v.range.max; i++) { + out.push({ value: i, label: i.toString() }); + } + } else if (v.nil) { + out.push({ value: 0 }); + } else { + // hidUsage / layerId: needs a picker, so the behavior isn't tile-able. + return null; + } + } + return out; +} + +/** + * Expand a behavior into one tile per concrete `(param1, param2)` combination, + * or `null` when the behavior isn't tile-able (any parameter needs a rich + * picker). A parameterless behavior (empty `metadata`) yields a single tile + * labelled with its display name. + */ +export function tileBindingsFor( + behavior: GetBehaviorDetailsResponse +): BehaviorTile[] | null { + const sets = behavior.metadata; + if (!sets || sets.length === 0) { + return [ + { + label: behavior.displayName, + binding: { behaviorId: behavior.id, param1: 0, param2: 0 }, + }, + ]; + } + + const tiles: BehaviorTile[] = []; + for (const set of sets) { + const p1Values = enumerateParam(set.param1); + if (p1Values === null) return null; + const p2Values = enumerateParam(set.param2); + if (p2Values === null) return null; + + for (const p1 of p1Values) { + for (const p2 of p2Values) { + const parts = [p1.label, p2.label].filter( + (s): s is string => s !== undefined && s.length > 0 + ); + tiles.push({ + label: parts.length > 0 ? parts.join(" ") : behavior.displayName, + binding: { + behaviorId: behavior.id, + param1: p1.value, + param2: p2.value, + }, + }); + } + } + } + return tiles; +} + +/** Whether a behavior renders as tiles (vs. the chip + parameter-picker flow). */ +export function isTileable(behavior: GetBehaviorDetailsResponse): boolean { + return tileBindingsFor(behavior) !== null; +} diff --git a/src/mock/fixtures.ts b/src/mock/fixtures.ts index 33bc3ef4..a698bbcb 100644 --- a/src/mock/fixtures.ts +++ b/src/mock/fixtures.ts @@ -37,6 +37,15 @@ const layerParam = (name: string): BehaviorParameterValueDescription => ({ name, layerId: {}, }); +const constParam = ( + name: string, + constant: number +): BehaviorParameterValueDescription => ({ name, constant }); +const rangeParam = ( + name: string, + min: number, + max: number +): BehaviorParameterValueDescription => ({ name, range: { min, max } }); // Behavior ids are arbitrary stable handles, exactly as a real device reports // them. displayName matches the names the picker classifies on @@ -54,6 +63,13 @@ const B = { modTap: 10, reset: 11, bootloader: 12, + studioUnlock: 13, + bluetooth: 14, + outputSel: 15, + extPower: 16, + mouseKeyPress: 17, + mouseMove: 18, + mouseScroll: 19, } as const; const BEHAVIORS: GetBehaviorDetailsResponse[] = [ @@ -69,6 +85,93 @@ const BEHAVIORS: GetBehaviorDetailsResponse[] = [ { id: B.modTap, displayName: "Mod-Tap", metadata: [{ param1: [hidParam("mod")], param2: [hidParam("tap")] }] }, { id: B.reset, displayName: "Reset", metadata: [] }, { id: B.bootloader, displayName: "Bootloader", metadata: [] }, + { id: B.studioUnlock, displayName: "Studio Unlock", metadata: [] }, + // Single-action tile behaviors (Step 3a). Metadata shapes mirror what a real + // keyboard reports: a list of (param1, param2) sets, each with constant or + // range values the tile grid explodes into one finished binding per value. + { + id: B.bluetooth, + displayName: "Bluetooth", + metadata: [ + { param1: [constParam("BT_CLR", 0)], param2: [] }, + { param1: [constParam("BT_CLR_ALL", 1)], param2: [] }, + { param1: [constParam("BT_NXT", 2)], param2: [] }, + { param1: [constParam("BT_PRV", 3)], param2: [] }, + { param1: [constParam("BT_SEL", 4)], param2: [rangeParam("profile", 0, 4)] }, + ], + }, + { + id: B.outputSel, + displayName: "Output Selection", + metadata: [ + { + param1: [ + constParam("OUT_USB", 0), + constParam("OUT_BLE", 1), + constParam("OUT_TOG", 2), + ], + param2: [], + }, + ], + }, + { + id: B.extPower, + displayName: "External Power", + metadata: [ + { + param1: [ + constParam("EP_OFF", 0), + constParam("EP_ON", 1), + constParam("EP_TOG", 2), + ], + param2: [], + }, + ], + }, + { + id: B.mouseKeyPress, + displayName: "Mouse Key Press", + metadata: [ + { + param1: [ + constParam("LCLK", 1), + constParam("RCLK", 2), + constParam("MCLK", 4), + ], + param2: [], + }, + ], + }, + { + id: B.mouseMove, + displayName: "Mouse Move", + metadata: [ + { + param1: [ + constParam("UP", 0), + constParam("DOWN", 1), + constParam("LEFT", 2), + constParam("RIGHT", 3), + ], + param2: [], + }, + ], + }, + { + id: B.mouseScroll, + displayName: "Mouse Scroll", + metadata: [ + { + param1: [ + constParam("UP", 0), + constParam("DOWN", 1), + constParam("LEFT", 2), + constParam("RIGHT", 3), + ], + param2: [], + }, + ], + }, ]; // --- binding builders ------------------------------------------------------ From 20763570c839f0413b8b362978ae94c811355cee Mon Sep 17 00:00:00 2001 From: numachang Date: Sun, 7 Jun 2026 11:50:40 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(behaviors):=20address=20PR=20#30=20revi?= =?UTF-8?q?ew=20=E2=80=94=20Step=203a=20nits=20(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - formatBinding: when param1 matches no metadata set, emit just the behavior reference rather than borrowing the first set's params (avoids showing a param2 that doesn't apply). [review #1] - Behavior tiles are single-select, so make them `role="radio"` inside a `radiogroup` (consistent with the residual behavior chips) instead of `aria-pressed` toggles; native button + `title` keeps the visible label and drops the now-redundant tooltip. Search-result tiles get their own radiogroup. [review #2] - Annotate the two intentional exhaustive-deps effects with `eslint-disable-next-line` so the warnings clear and the intent is explicit. [review #4] Review #3 (declaration-order of the two tab-select effects) was info-only / no change requested; left as-is. Co-Authored-By: Claude --- src/behaviors/BehaviorBindingPicker.tsx | 8 ++++- src/behaviors/HidUsagePicker.tsx | 40 +++++++++++++++---------- src/behaviors/parameters.ts | 9 ++++-- 3 files changed, 39 insertions(+), 18 deletions(-) diff --git a/src/behaviors/BehaviorBindingPicker.tsx b/src/behaviors/BehaviorBindingPicker.tsx index 3912ee48..2a09b3e3 100644 --- a/src/behaviors/BehaviorBindingPicker.tsx +++ b/src/behaviors/BehaviorBindingPicker.tsx @@ -233,9 +233,12 @@ export const BehaviorBindingPicker = ({ ); // When the bound behavior changes (e.g. user clicked a different key on the - // keyboard image), jump the picker to the matching category. + // keyboard image), jump the picker to the matching category. Intentionally + // keyed on behaviorId only — we don't want to re-jump when currentGroup + // recomputes for the same behavior. useEffect(() => { if (currentGroup) setActiveGroup(currentGroup); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [behaviorId]); const availableGroups = GROUP_ORDER.filter( @@ -326,6 +329,9 @@ export const BehaviorBindingPicker = ({ param2: param2 || 0, }); } + // Fires only when the local edit state changes; metadata/layers/callbacks + // are read fresh but must not re-trigger the write. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [behaviorId, param1, param2]); useEffect(() => { diff --git a/src/behaviors/HidUsagePicker.tsx b/src/behaviors/HidUsagePicker.tsx index befd1654..cac3a454 100644 --- a/src/behaviors/HidUsagePicker.tsx +++ b/src/behaviors/HidUsagePicker.tsx @@ -696,23 +696,24 @@ const HidUsageGrid = ({ // A whole-binding behavior tile, styled like a keycap (same height + font as // the keycode tiles) so it reads the same as picking a key. Widens for longer - // labels rather than shrinking the text. Highlighted when it is the current - // binding. + // labels rather than shrinking the text. It's a single-select "pick the + // current binding" control, so it's a `radio` (consistent with the residual + // behavior chips), highlighted when it is the current binding. The label is + // fully visible, so a native `title` replaces the keycode grid's hover tooltip. const renderBehaviorTile = (tile: BehaviorTile) => { const active = !!activeBinding && sameBinding(tile.binding, activeBinding); return ( - onSelectBinding?.(tile.binding)} + className={`min-w-16 h-16 px-2 rounded border text-center flex items-center justify-center whitespace-nowrap leading-tight shrink-0 ${active ? "bg-primary text-primary-content" : "bg-base-200 hover:bg-base-300"}`} > - - + {tile.label} + ); }; @@ -751,8 +752,17 @@ const HidUsageGrid = ({ <> {searchResults.map(renderUsageButton)} {/* Matching behavior tiles (Reset, BT_SEL, LCLK, …) so the search - reaches the whole surface, not just the keycodes. */} - {behaviorSearchResults.map(renderBehaviorTile)} + reaches the whole surface, not just the keycodes. Grouped as a + radiogroup (own row) since the tiles are radios. */} + {behaviorSearchResults.length > 0 && ( +
+ {behaviorSearchResults.map(renderBehaviorTile)} +
+ )} )} {/* Keep the clear tiles reachable even when the search matches @@ -909,7 +919,7 @@ const HidUsageGrid = ({ {section.heading}
diff --git a/src/behaviors/parameters.ts b/src/behaviors/parameters.ts index a89e29c0..6e9dad34 100644 --- a/src/behaviors/parameters.ts +++ b/src/behaviors/parameters.ts @@ -86,8 +86,13 @@ export function formatBinding( const sets = behavior.metadata ?? []; if (sets.length === 0) return ref; - const set = - sets.find((s) => validateValue(layerIds, binding.param1, s.param1)) ?? sets[0]; + // No matching set → the binding's param1 fits none of the behavior's shapes; + // emit just the reference rather than risk showing a param2 from a set that + // doesn't actually apply. + const set = sets.find((s) => + validateValue(layerIds, binding.param1, s.param1) + ); + if (!set) return ref; const fmt = ( value: number, descs: BehaviorParameterValueDescription[] | undefined