From 17b73333faf840107b002c4e05fe2b820c9c94fb Mon Sep 17 00:00:00 2001 From: numachang Date: Sat, 6 Jun 2026 14:20:44 +0900 Subject: [PATCH 01/22] feat(behaviors): slot model deriving kp/mt/lt/mo from Hold/Tap (#16 step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the pure data-model core for the slot-fill binding editor: fill a Tap slot and a Hold slot, derive the behavior instead of naming it first. - src/behaviors/slots.ts: Slots types, a capability-aware DerivationRegistry (resolved by displayName), and deriveBinding / bindingToSlots per the §2 table. isModifierUsage decodes ZMK's implicit-modifier encoding (strips the high-byte mod bits before matching the eight base usages) so multi-modifier holds like LC(LSHFT) survive the binding -> slots -> binding round trip. - Move validateBinding from BehaviorBindingPicker into parameters.ts (its only dependency, validateValue, already lives there) so the tests can assert every derived binding passes the firmware-facing check, and to drop a react-refresh lint warning. - src/behaviors/slots.test.ts: 25 tests covering the §2 table, modifier detection, capability-awareness, binding-lossless round trips, and that every derived binding validates. No firmware/RPC change: the output is still a BehaviorBinding. This is the foundation for the slot UI; the existing chip picker is untouched. Co-Authored-By: Claude --- src/behaviors/BehaviorBindingPicker.tsx | 31 +--- src/behaviors/parameters.ts | 35 +++- src/behaviors/slots.test.ts | 190 +++++++++++++++++++++ src/behaviors/slots.ts | 216 ++++++++++++++++++++++++ 4 files changed, 442 insertions(+), 30 deletions(-) create mode 100644 src/behaviors/slots.test.ts create mode 100644 src/behaviors/slots.ts diff --git a/src/behaviors/BehaviorBindingPicker.tsx b/src/behaviors/BehaviorBindingPicker.tsx index 51cf6e2b..c6341832 100644 --- a/src/behaviors/BehaviorBindingPicker.tsx +++ b/src/behaviors/BehaviorBindingPicker.tsx @@ -1,12 +1,9 @@ import { useEffect, useMemo, useState } from "react"; -import { - GetBehaviorDetailsResponse, - BehaviorBindingParametersSet, -} from "@zmkfirmware/zmk-studio-ts-client/behaviors"; +import { GetBehaviorDetailsResponse } from "@zmkfirmware/zmk-studio-ts-client/behaviors"; import { BehaviorBinding } from "@zmkfirmware/zmk-studio-ts-client/keymap"; import { BehaviorParametersPicker } from "./BehaviorParametersPicker"; -import { validateValue } from "./parameters"; +import { validateBinding } from "./parameters"; export interface BehaviorBindingPickerProps { binding: BehaviorBinding; @@ -89,30 +86,6 @@ function classifyBehavior(b: GetBehaviorDetailsResponse): BehaviorClass { return { tier: "extension", group: "Other" }; } -function validateBinding( - metadata: BehaviorBindingParametersSet[], - layerIds: number[], - param1?: number, - param2?: number -): boolean { - if ( - (param1 === undefined || param1 === 0) && - metadata.every((s) => !s.param1 || s.param1.length === 0) - ) { - return true; - } - - const matchingSet = metadata.find((s) => - validateValue(layerIds, param1, s.param1) - ); - - if (!matchingSet) { - return false; - } - - return validateValue(layerIds, param2, matchingSet.param2); -} - export const BehaviorBindingPicker = ({ binding, layers, diff --git a/src/behaviors/parameters.ts b/src/behaviors/parameters.ts index 96f91527..4f3188e0 100644 --- a/src/behaviors/parameters.ts +++ b/src/behaviors/parameters.ts @@ -1,4 +1,7 @@ -import { BehaviorParameterValueDescription } from "@zmkfirmware/zmk-studio-ts-client/behaviors"; +import { + BehaviorParameterValueDescription, + BehaviorBindingParametersSet, +} from "@zmkfirmware/zmk-studio-ts-client/behaviors"; import { hid_usage_page_and_id_from_usage } from "../hid-usages"; export function validateValue( @@ -30,3 +33,33 @@ export function validateValue( return !!matchingValue || (value === 0 && (!values || values.length === 0)); } + +/** + * Whether a (param1, param2) pair is a valid binding for a behavior with the + * given metadata, gating writes the same way the firmware does. Exported so the + * slot-model unit tests can assert every binding `deriveBinding` produces also + * passes this check (design note §5, Step 2). + */ +export function validateBinding( + metadata: BehaviorBindingParametersSet[], + layerIds: number[], + param1?: number, + param2?: number +): boolean { + if ( + (param1 === undefined || param1 === 0) && + metadata.every((s) => !s.param1 || s.param1.length === 0) + ) { + return true; + } + + const matchingSet = metadata.find((s) => + validateValue(layerIds, param1, s.param1) + ); + + if (!matchingSet) { + return false; + } + + return validateValue(layerIds, param2, matchingSet.param2); +} diff --git a/src/behaviors/slots.test.ts b/src/behaviors/slots.test.ts new file mode 100644 index 00000000..c9215760 --- /dev/null +++ b/src/behaviors/slots.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from "vitest"; + +import type { BehaviorBinding } from "@zmkfirmware/zmk-studio-ts-client/keymap"; + +import { + bindingToSlots, + buildDerivationRegistry, + deriveBinding, + isModifierUsage, + type Slots, +} from "./slots"; +import { validateBinding } from "./parameters"; +import { DEMO_ORTHO_50 } from "../mock/fixtures"; + +// The demo fixture exposes exactly the four core behaviors + None, with the same +// metadata shapes a real keyboard reports, so it's the natural ground truth. +const behaviors = DEMO_ORTHO_50.behaviors; +const reg = buildDerivationRegistry(behaviors); +const layerIds = DEMO_ORTHO_50.layers.map((l) => l.id); + +const idOf = (displayName: string) => + behaviors.find((b) => b.displayName === displayName)!.id; +const KP = idOf("Key Press"); +const MT = idOf("Mod-Tap"); +const LT = idOf("Layer-Tap"); +const MO = idOf("Momentary Layer"); +const NONE = idOf("None"); + +// HID usage encoding: (page << 16) | id, with implicit modifiers in the high +// byte. Page 7 is the Keyboard/Keypad page. +const hid = (id: number) => (7 << 16) | id; +const KEY_A = hid(4); +const KEY_ENTER = hid(40); +const KEY_SPACE = hid(44); +const KEY_N1 = hid(30); +const LSHIFT = hid(225); +const LCTRL = hid(224); +const FN_LAYER = 1; +// Modifier wrappers pack a bit into the high byte (see keymap-parser). +const LC = (usage: number) => usage | (0x01 << 24); +const LS = (usage: number) => usage | (0x02 << 24); + +describe("buildDerivationRegistry", () => { + it("resolves every core behavior id the firmware exposes", () => { + expect(reg.idByCore).toEqual({ + kp: KP, + mt: MT, + lt: LT, + mo: MO, + none: NONE, + }); + expect(reg.coreById.get(MT)).toBe("mt"); + expect(reg.coreById.get(NONE)).toBe("none"); + }); + + it("omits core behaviors the firmware does not expose (capability-aware)", () => { + const partial = buildDerivationRegistry( + behaviors.filter((b) => b.displayName !== "Mod-Tap") + ); + expect(partial.idByCore.mt).toBeUndefined(); + expect(partial.idByCore.kp).toBe(KP); + }); +}); + +describe("isModifierUsage", () => { + it("recognises the eight base HID modifier usages", () => { + for (let id = 0xe0; id <= 0xe7; id++) { + expect(isModifierUsage(hid(id)), `usage 0x${id.toString(16)}`).toBe(true); + } + }); + + it("recognises a multi-modifier hold whose base is a modifier", () => { + // LC(LSHFT) = hold Ctrl+Shift. The LC bit lives in the high byte; matching + // the raw value against the eight usages would miss it (design note §4). + expect(isModifierUsage(LC(LSHIFT))).toBe(true); + expect(isModifierUsage(LS(LCTRL))).toBe(true); + }); + + it("treats a shifted key as a key, not a modifier", () => { + // LS(N1) = "!" — carries a mod bit but its base (N1) is a regular key, so it + // belongs in the Tap slot. + expect(isModifierUsage(LS(KEY_N1))).toBe(false); + expect(isModifierUsage(KEY_A)).toBe(false); + }); + + it("does not treat layer ids / small immediates as modifiers", () => { + expect(isModifierUsage(0)).toBe(false); + expect(isModifierUsage(FN_LAYER)).toBe(false); + }); +}); + +describe("deriveBinding — the §2 table", () => { + const cases: [name: string, slots: Slots, expected: BehaviorBinding][] = [ + [ + "key tap, empty hold → &kp ", + { tap: { kind: "key", usage: KEY_A }, hold: { kind: "empty" } }, + { behaviorId: KP, param1: KEY_A, param2: 0 }, + ], + [ + "key tap, mod hold → &mt ", + { tap: { kind: "key", usage: KEY_ENTER }, hold: { kind: "mod", usage: LSHIFT } }, + { behaviorId: MT, param1: LSHIFT, param2: KEY_ENTER }, + ], + [ + "key tap, layer hold → < ", + { tap: { kind: "key", usage: KEY_SPACE }, hold: { kind: "layer", layerId: FN_LAYER } }, + { behaviorId: LT, param1: FN_LAYER, param2: KEY_SPACE }, + ], + [ + "empty tap, layer hold → &mo ", + { tap: { kind: "empty" }, hold: { kind: "layer", layerId: FN_LAYER } }, + { behaviorId: MO, param1: FN_LAYER, param2: 0 }, + ], + [ + "empty tap, mod hold → &kp ", + { tap: { kind: "empty" }, hold: { kind: "mod", usage: LSHIFT } }, + { behaviorId: KP, param1: LSHIFT, param2: 0 }, + ], + [ + "empty tap, empty hold → &none", + { tap: { kind: "empty" }, hold: { kind: "empty" } }, + { behaviorId: NONE, param1: 0, param2: 0 }, + ], + ]; + + it.each(cases)("%s", (_name, slots, expected) => { + expect(deriveBinding(slots, reg)).toEqual(expected); + }); + + it("every derived binding passes the firmware-facing validateBinding", () => { + for (const [name, slots] of cases) { + const binding = deriveBinding(slots, reg)!; + const metadata = behaviors.find((b) => b.id === binding.behaviorId)! + .metadata!; + expect( + validateBinding(metadata, layerIds, binding.param1, binding.param2), + name + ).toBe(true); + } + }); + + it("returns null when the firmware doesn't expose the derived behavior", () => { + const noMt = buildDerivationRegistry( + behaviors.filter((b) => b.displayName !== "Mod-Tap") + ); + const slots: Slots = { + tap: { kind: "key", usage: KEY_A }, + hold: { kind: "mod", usage: LSHIFT }, + }; + expect(deriveBinding(slots, noMt)).toBeNull(); + }); +}); + +describe("bindingToSlots", () => { + it("reads a lone-modifier &kp into the Hold slot", () => { + expect(bindingToSlots({ behaviorId: KP, param1: LSHIFT, param2: 0 }, reg)).toEqual( + { tap: { kind: "empty" }, hold: { kind: "mod", usage: LSHIFT } } + ); + }); + + it("reads a regular-key &kp into the Tap slot", () => { + expect(bindingToSlots({ behaviorId: KP, param1: KEY_A, param2: 0 }, reg)).toEqual( + { tap: { kind: "key", usage: KEY_A }, hold: { kind: "empty" } } + ); + }); + + it("returns null for behaviors outside the derivable core", () => { + const stickyKey = idOf("Sticky Key"); + expect(bindingToSlots({ behaviorId: stickyKey, param1: LCTRL, param2: 0 }, reg)).toBeNull(); + }); +}); + +describe("round trip — binding-lossless for every core behavior", () => { + const bindings: [name: string, binding: BehaviorBinding][] = [ + ["&kp A", { behaviorId: KP, param1: KEY_A, param2: 0 }], + ["&kp LSHIFT (held modifier)", { behaviorId: KP, param1: LSHIFT, param2: 0 }], + ["&kp LS(N1) (shifted key stays a key)", { behaviorId: KP, param1: LS(KEY_N1), param2: 0 }], + ["&mt LSHIFT ENTER", { behaviorId: MT, param1: LSHIFT, param2: KEY_ENTER }], + ["&mt LC(LSHFT) A (multi-modifier hold)", { behaviorId: MT, param1: LC(LSHIFT), param2: KEY_A }], + ["< FN SPACE", { behaviorId: LT, param1: FN_LAYER, param2: KEY_SPACE }], + ["&mo FN", { behaviorId: MO, param1: FN_LAYER, param2: 0 }], + ["&none", { behaviorId: NONE, param1: 0, param2: 0 }], + ]; + + it.each(bindings)("%s survives binding → slots → binding", (_name, binding) => { + const slots = bindingToSlots(binding, reg); + expect(slots).not.toBeNull(); + expect(deriveBinding(slots!, reg)).toEqual(binding); + }); +}); diff --git a/src/behaviors/slots.ts b/src/behaviors/slots.ts new file mode 100644 index 00000000..bbbc2dd4 --- /dev/null +++ b/src/behaviors/slots.ts @@ -0,0 +1,216 @@ +// Slot model for the binding editor (issue #16, Step 2). +// +// The picker historically asks the user to name a behavior first, then fill its +// parameters. The slot model inverts that for the typing + layer + hold-tap core +// (~90% of real keymaps): the user fills a **Tap** slot and a **Hold** slot, and +// the behavior is *derived* from which slots are filled. See +// `docs/design/key-picker-rethink.md` §2-§4. +// +// | Tap slot | Hold slot | Derived behavior | Firmware | +// | -------------- | --------- | ----------------- | ------------------- | +// | key | (empty) | Key Press | &kp | +// | key | modifier | Mod-Tap | &mt | +// | key | layer | Layer-Tap | < | +// | (empty / None) | layer | Momentary Layer | &mo | +// | (empty) | modifier | a plain held mod | &kp | +// | (empty) | (empty) | None | &none | +// +// The output is still a plain `BehaviorBinding` handed to the existing +// `doUpdateBinding` path — no firmware or RPC change. + +import type { GetBehaviorDetailsResponse } from "@zmkfirmware/zmk-studio-ts-client/behaviors"; +import type { BehaviorBinding } from "@zmkfirmware/zmk-studio-ts-client/keymap"; + +import { hid_usage_page_and_id_from_usage } from "../hid-usages"; + +// ============================================================================= +// Slot types +// ============================================================================= + +/** The Tap slot holds a HID keyboard/consumer usage, or nothing. */ +export type TapSlot = { kind: "empty" } | { kind: "key"; usage: number }; + +/** + * The Hold slot is polymorphic: a held modifier (→ `&mt` / `&kp `), a layer + * (→ `<` / `&mo`), or nothing. A *regular* key can never go on hold, so the + * type itself makes the "key on hold" invalid combination unreachable. + */ +export type HoldSlot = + | { kind: "empty" } + | { kind: "mod"; usage: number } + | { kind: "layer"; layerId: number }; + +export interface Slots { + tap: TapSlot; + hold: HoldSlot; +} + +// ============================================================================= +// Derivation registry (capability-aware, resolved by displayName) +// ============================================================================= + +/** The canonical ZMK behaviors the slot model derives. */ +export type CoreBehavior = "kp" | "mt" | "lt" | "mo" | "none"; + +// displayName the connected firmware reports for each core behavior. Matching by +// displayName mirrors how the rest of the picker classifies behaviors; the +// numeric behaviorId is firmware-assigned at runtime and cannot be hardcoded. +const CORE_DISPLAY_NAMES: Record = { + kp: "Key Press", + mt: "Mod-Tap", + lt: "Layer-Tap", + mo: "Momentary Layer", + none: "None", +}; + +/** + * A bidirectional map between core behaviors and the runtime behaviorIds the + * connected keyboard exposes. Built from the `behaviors` array the picker + * already receives; a core behavior the firmware does not expose is simply + * absent, and every derivation tolerates that (capability-awareness). + */ +export interface DerivationRegistry { + idByCore: Partial>; + coreById: Map; +} + +export function buildDerivationRegistry( + behaviors: GetBehaviorDetailsResponse[] +): DerivationRegistry { + const idByCore: Partial> = {}; + const coreById = new Map(); + for (const core of Object.keys(CORE_DISPLAY_NAMES) as CoreBehavior[]) { + const behavior = behaviors.find( + (b) => b.displayName === CORE_DISPLAY_NAMES[core] + ); + if (behavior) { + idByCore[core] = behavior.id; + coreById.set(behavior.id, core); + } + } + return { idByCore, coreById }; +} + +// ============================================================================= +// Modifier detection +// ============================================================================= + +const HID_KEYBOARD_PAGE = 7; +const FIRST_MODIFIER_USAGE = 0xe0; // 224, Left Control +const LAST_MODIFIER_USAGE = 0xe7; // 231, Right GUI + +/** + * Whether a held HID usage reads as "a modifier" (so it lands in the Hold slot). + * + * ZMK packs implicit modifiers into the high byte of a usage value and keeps the + * base HID usage in the low 24 bits. A value is a modifier when its *base* usage + * — after stripping the implicit-mod bits — is one of the eight base HID + * modifier usages (page 7, ids 0xE0..0xE7). Stripping first is essential: a + * multi-modifier hold like `LC(LSHFT)` carries the LC bit in the high byte, so + * matching the eight usages against the raw value would miss it and break the + * binding → slots → binding round trip (design note §4). A shifted *key* such as + * `LS(N1)` ("!") keeps a non-modifier base (N1) and is therefore a key, not a + * modifier — it belongs in the Tap slot. + */ +export function isModifierUsage(usage: number): boolean { + const base = usage & 0x00ffffff; + const [page, id] = hid_usage_page_and_id_from_usage(base); + return ( + page === HID_KEYBOARD_PAGE && + id >= FIRST_MODIFIER_USAGE && + id <= LAST_MODIFIER_USAGE + ); +} + +// ============================================================================= +// Derivation: slots → binding and binding → slots +// ============================================================================= + +function bindingFor( + reg: DerivationRegistry, + core: CoreBehavior, + param1: number, + param2: number +): BehaviorBinding | null { + const behaviorId = reg.idByCore[core]; + if (behaviorId === undefined) return null; // firmware doesn't expose it + return { behaviorId, param1, param2 }; +} + +/** + * Derive the `BehaviorBinding` from the two filled slots, per the §2 table. + * Returns `null` when the combination has no behavior the firmware exposes — + * the surface should constrain inputs so that's rare, and reflect it in the + * reverse-lookup label rather than silently writing nothing (design note §3b). + */ +export function deriveBinding( + slots: Slots, + reg: DerivationRegistry +): BehaviorBinding | null { + const { tap, hold } = slots; + + if (tap.kind === "key") { + switch (hold.kind) { + case "empty": + return bindingFor(reg, "kp", tap.usage, 0); + case "mod": + return bindingFor(reg, "mt", hold.usage, tap.usage); + case "layer": + return bindingFor(reg, "lt", hold.layerId, tap.usage); + } + } + + // Tap empty. + switch (hold.kind) { + case "empty": + return bindingFor(reg, "none", 0, 0); + case "mod": + return bindingFor(reg, "kp", hold.usage, 0); + case "layer": + return bindingFor(reg, "mo", hold.layerId, 0); + } +} + +/** + * Decompose an existing binding into slots, so opening a key pre-fills the slot + * surface. Returns `null` for any behavior outside the derivable core (Sticky + * Key, Caps Word, Mouse, …), which the caller keeps editing through the legacy + * chip UI. + * + * The **binding** survives binding → slots → binding for every core behavior + * ("binding-lossless"). The slot *decomposition* is heuristic only for `&kp`, + * whose single param is read as a Hold (a lone modifier) or a Tap (any other + * key) from the keycode's type — but both readings derive back to `&kp` with the + * same param, so the binding never changes ("slot-lossless" is *not* claimed). + */ +export function bindingToSlots( + binding: BehaviorBinding, + reg: DerivationRegistry +): Slots | null { + const core = reg.coreById.get(binding.behaviorId); + if (!core) return null; + + switch (core) { + case "kp": + return isModifierUsage(binding.param1) + ? { tap: { kind: "empty" }, hold: { kind: "mod", usage: binding.param1 } } + : { tap: { kind: "key", usage: binding.param1 }, hold: { kind: "empty" } }; + case "mt": + return { + tap: { kind: "key", usage: binding.param2 }, + hold: { kind: "mod", usage: binding.param1 }, + }; + case "lt": + return { + tap: { kind: "key", usage: binding.param2 }, + hold: { kind: "layer", layerId: binding.param1 }, + }; + case "mo": + return { + tap: { kind: "empty" }, + hold: { kind: "layer", layerId: binding.param1 }, + }; + case "none": + return { tap: { kind: "empty" }, hold: { kind: "empty" } }; + } +} From 5f4f5a7484c7a51df371083a9efffee6beff56c0 Mon Sep 17 00:00:00 2001 From: numachang Date: Sat, 6 Jun 2026 16:29:39 +0900 Subject: [PATCH 02/22] feat(behaviors): Normal/Hold-mode slot surface for the binding editor (#16 step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SlotBindingPicker: the user sets a always-shown **Normal** action (the key grid, with the None/Transparent clear tiles and a collapsible right-side "+ Send with" implicit-modifier column) and an optional **Hold mode** checkbox that reveals a Modifier | Layer picker. The behavior is derived, not named: Normal-only = Key Press, Normal+Hold(mod) = Mod-Tap, Normal+Hold(layer) = Layer-Tap, Hold(layer) alone = Momentary Layer, empty = None. - slots.ts: a lone modifier decomposes to Normal (it's a keycode, `&kp `), not Hold — so there's no modifier-vs-key heuristic and the round trip stays binding-lossless. Add modifierSetFromUsage / usageFromModifierSet for the multi-select Hold modifier picker (decodes ZMK's implicit-modifier encoding so multi-mod holds like LC(LSHFT) round-trip); drop the now-unused isModifierUsage. - Only Hold mode carries an on/off toggle; Normal is always present (None is a value, not an off state). Labels are Normal / Hold mode — "Tap" is reserved for the derived names (Mod-Tap, Layer-Tap), since a plain &kp isn't short-press-only. - HidUsagePicker gains a `modifiers` config (side / collapsible / label / hint) so the Tap-key implicit-mod column can sit on the right, collapsed, and not be confused with the Hold-mode modifier picker. Default unchanged for other callers. - BehaviorBindingPicker hides the derivation-target chips (kp/mt/lt/mo) + the clear behaviors; the slot surface and residual "Not yet on the slot surface" chips edit one shared local state (no dual-edit sync). The core behaviors (kp/mt/lt/mo/none/trans) are settable now; layer-in-Normal (to/tog/sl) and the single-action tiles come in Steps 3-4. Coverage of every standard ZMK behavior is verified in the design note. Co-Authored-By: Claude --- src/App.edit.test.tsx | 24 +- src/behaviors/BehaviorBindingPicker.tsx | 65 +++++- src/behaviors/HidUsagePicker.tsx | 138 +++++++++--- src/behaviors/SlotBindingPicker.tsx | 282 ++++++++++++++++++++++++ src/behaviors/slots.test.ts | 47 ++-- src/behaviors/slots.ts | 95 ++++++-- 6 files changed, 550 insertions(+), 101 deletions(-) create mode 100644 src/behaviors/SlotBindingPicker.tsx diff --git a/src/App.edit.test.tsx b/src/App.edit.test.tsx index 0d599b75..daa6d607 100644 --- a/src/App.edit.test.tsx +++ b/src/App.edit.test.tsx @@ -114,28 +114,26 @@ describe("persistent clear tiles (key-picker rethink, step 1)", () => { ).toBeInTheDocument(); }); - it("returns a None'd key to Key Press in one grid click", async () => { + it("clears a key to None and back to Key Press from the Normal grid", async () => { const user = userEvent.setup({ pointerEventsCheck: 0 }); await connectDemo(user); await changeQtoNone(user); - // The None tile now reports itself as active. - expect( - await screen.findByRole("button", { name: "None", pressed: true }) - ).toBeInTheDocument(); + // Q is now None: Hold mode is off and the Normal slot is empty. The Normal + // grid stays shown (it's always present — None is a value, the in-grid tile), + // so promoting back is one grid click. + await waitFor(() => + expect( + screen.getByRole("checkbox", { name: "Hold mode" }) + ).not.toBeChecked() + ); - // With Q set to None its keymap tile no longer reads "Q", so the only - // remaining "Q" button is the live keycode grid's tile. Clicking it must - // promote the binding straight back to Key Press (no tab round trip). + // Clicking a key in the always-shown Normal grid derives Key Press. const gridQ = await screen.findAllByRole("button", { name: /\bQ\b/ }); await user.click(gridQ[gridQ.length - 1]); await waitFor(() => - expect( - screen - .getAllByRole("radio", { name: "Key Press" }) - .some((r) => r.getAttribute("aria-checked") === "true") - ).toBe(true) + expect(screen.getByText("Key Press")).toBeInTheDocument() ); }); }); diff --git a/src/behaviors/BehaviorBindingPicker.tsx b/src/behaviors/BehaviorBindingPicker.tsx index c6341832..341de722 100644 --- a/src/behaviors/BehaviorBindingPicker.tsx +++ b/src/behaviors/BehaviorBindingPicker.tsx @@ -3,7 +3,9 @@ import { useEffect, useMemo, useState } from "react"; import { GetBehaviorDetailsResponse } from "@zmkfirmware/zmk-studio-ts-client/behaviors"; import { BehaviorBinding } from "@zmkfirmware/zmk-studio-ts-client/keymap"; import { BehaviorParametersPicker } from "./BehaviorParametersPicker"; +import { SlotBindingPicker } from "./SlotBindingPicker"; import { validateBinding } from "./parameters"; +import { bindingToSlots, buildDerivationRegistry } from "./slots"; export interface BehaviorBindingPickerProps { binding: BehaviorBinding; @@ -116,6 +118,20 @@ 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 + // surface owns them, avoiding a two-ways-to-edit-one-binding sync hazard. + const registry = useMemo(() => buildDerivationRegistry(behaviors), [behaviors]); + const derivedChipIds = useMemo( + () => + new Set( + (["kp", "mt", "lt", "mo"] as const) + .map((core) => registry.idByCore[core]) + .filter((id): id is number => id !== undefined) + ), + [registry] + ); + const sortedBehaviors = useMemo( () => behaviors.slice().sort((a, b) => { @@ -130,21 +146,25 @@ export const BehaviorBindingPicker = ({ ); const tieredBehaviors = useMemo(() => { - // None / Transparent are surfaced as keycap tiles in the key grid, so drop - // their redundant chips from the behavior groups. - const clearIds = new Set(clearBehaviors.map((b) => b.id)); + // 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: {} }; for (const b of sortedBehaviors) { - if (clearIds.has(b.id)) continue; + if (hiddenIds.has(b.id)) continue; const { tier, group } = classifyBehavior(b); if (!out[tier][group]) out[tier][group] = []; out[tier][group].push(b); } return out; - }, [sortedBehaviors, clearBehaviors]); + }, [sortedBehaviors, clearBehaviors, derivedChipIds]); const tiers: { key: "standard" | "extension"; title: string }[] = [ { key: "standard", title: "ZMK Standard" }, @@ -281,12 +301,37 @@ export const BehaviorBindingPicker = ({ } : undefined; + // The slot surface edits the same local behaviorId/param1/param2 the chips do, + // so there's a single source of truth and no chip<->slot sync to maintain. + const currentBinding: BehaviorBinding = { + behaviorId, + param1: param1 ?? 0, + param2: param2 ?? 0, + }; + const isCoreBinding = bindingToSlots(currentBinding, registry) !== null; + const setBindingFromSlots = (b: BehaviorBinding) => { + setBehaviorId(b.behaviorId); + setParam1(b.param1); + setParam2(b.param2); + }; + return ( -
-
+
+ +
+

+ Temporary home for behaviors the slots don't cover yet — these move + onto tiles and the slot surface in later steps. +

{availableTiers.map((tier) => { @@ -356,7 +401,9 @@ export const BehaviorBindingPicker = ({
- {metadata && ( + {/* Core behaviors are edited on the slot surface above; only non-core + behaviors (Sticky Key, Mouse, System, ...) keep the params picker. */} + {metadata && !isCoreBinding && ( { + const modSide = modifiers?.side ?? "left"; + const modCollapsible = modifiers?.collapsible ?? false; + const modLabel = modifiers?.label ?? "+ Modifier"; + const [modsOpen, setModsOpen] = useState(false); + const mods = useMemo(() => { const flags = value ? value >> 24 : 0; 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 selectionChanged = useCallback( (e: number | undefined) => { let value = typeof e == "number" ? e : undefined; @@ -784,6 +809,74 @@ export const HidUsagePicker = ({ [value], ); + const modifierColumn = ( +
+
+ {modCollapsible ? ( + + ) : ( + + )} +
+ {showModChecks && ( + <> + + {all_mods.map((m) => ( + + {mod_labels[m]} + + ))} + + {modifiers?.hint && ( +

{modifiers.hint}

+ )} + + )} +
+ ); + + const grid = ( +
+ +
+ ); + return (
-
-
- -
- - {all_mods.map((m) => ( - - {mod_labels[m]} - - ))} - -
-
- -
+ {modSide === "right" ? ( + <> + {grid} + {modifierColumn} + + ) : ( + <> + {modifierColumn} + {grid} + + )}
); }; diff --git a/src/behaviors/SlotBindingPicker.tsx b/src/behaviors/SlotBindingPicker.tsx new file mode 100644 index 00000000..3931861f --- /dev/null +++ b/src/behaviors/SlotBindingPicker.tsx @@ -0,0 +1,282 @@ +// The slot-fill binding surface (issue #16, Step 2). The user fills a Tap slot +// and a Hold slot; the behavior (&kp / &mt / < / &mo / &none) is *derived* +// from which slots are filled rather than named first. See +// `docs/design/key-picker-rethink.md` §2-§3 and the pure model in `slots.ts`. + +import { useEffect, useState } from "react"; + +import { BehaviorBinding } from "@zmkfirmware/zmk-studio-ts-client/keymap"; + +import { ClearTilesConfig, HidUsagePicker } from "./HidUsagePicker"; +import { + bindingToSlots, + CORE_LABELS, + deriveBinding, + DerivationRegistry, + HoldSlot, + HOLD_MODIFIERS, + modifierSetFromUsage, + Slots, + usageFromModifierSet, +} from "./slots"; +import { hid_usage_get_label, hid_usage_page_and_id_from_usage } from "../hid-usages"; + +export interface SlotBindingPickerProps { + binding: BehaviorBinding; + registry: DerivationRegistry; + layers: { id: number; name: string }[]; + onBindingChanged: (binding: BehaviorBinding) => void; + /** + * The persistent None / Transparent clear tiles (Step 1), threaded onto the + * Tap grid so one-click clear survives on the slot surface. (Transparent is + * not slot-derivable, so it still needs a tile here.) + */ + clearTiles?: ClearTilesConfig; +} + +const EMPTY_SLOTS: Slots = { tap: { kind: "empty" }, hold: { kind: "empty" } }; + +const TAP_USAGE_PAGES = [ + { id: 7, min: 4 }, + { id: 12 }, +]; + +type HoldMode = "mod" | "layer"; + +const segmentClass = (active: boolean) => + `px-2 py-1 text-sm border first:rounded-l last:rounded-r -ml-px first:ml-0 ${ + active + ? "bg-primary text-primary-content border-primary z-10" + : "bg-base-100 hover:bg-base-200 border-base-300" + }`; + +const chipClass = (active: boolean) => + `px-3 py-1 rounded border text-sm ${ + active + ? "bg-primary text-primary-content border-primary" + : "bg-base-200 hover:bg-base-300 border-base-300" + }`; + +function tapLabel(slot: Slots["tap"]): string { + if (slot.kind === "empty") return "—"; + const [page, id] = hid_usage_page_and_id_from_usage(slot.usage); + return hid_usage_get_label(page, id) ?? `0x${slot.usage.toString(16)}`; +} + +export const SlotBindingPicker = ({ + binding, + registry, + layers, + onBindingChanged, + clearTiles, +}: SlotBindingPickerProps) => { + const slots = bindingToSlots(binding, registry) ?? EMPTY_SLOTS; + + const tapFilled = slots.tap.kind === "key"; + const holdFilled = slots.hold.kind !== "empty"; + + // Normal is always shown — every key has a Normal action, and None is a value + // (the in-grid clear tile), not an "off" state. Only Hold mode is optional: its + // picker shows when a hold value is set or the user ticks "Hold mode" to add + // one; unticking clears it. The flag resets when the bound key changes, so + // opening a plain &kp reads as Normal-only (Hold mode off) at a glance. + const [holdModeOn, setHoldModeOn] = useState(false); + useEffect(() => { + setHoldModeOn(false); + }, [binding.behaviorId, binding.param1, binding.param2]); + const holdOpen = holdFilled || holdModeOn; + + // The Hold sub-type (Modifier vs Layer) is local UI state: ticking Hold mode + // should reveal a picker before a value is chosen. Seed it from the current + // hold and follow the bound key when it changes. + const holdKind = slots.hold.kind; + const [holdMode, setHoldMode] = useState( + holdKind === "layer" ? "layer" : "mod" + ); + useEffect(() => { + if (holdKind !== "empty") setHoldMode(holdKind); + }, [holdKind]); + + const writeSlots = (next: Slots) => { + const derived = deriveBinding(next, registry); + if (derived) onBindingChanged(derived); + }; + + const setNormalKey = (usage?: number) => + writeSlots({ + ...slots, + tap: usage === undefined ? { kind: "empty" } : { kind: "key", usage }, + }); + + const setHold = (hold: HoldSlot) => writeSlots({ ...slots, hold }); + + const toggleHoldMode = () => { + if (holdOpen) { + setHoldModeOn(false); + if (holdFilled) setHold({ kind: "empty" }); + } else { + setHoldModeOn(true); + } + }; + + const modBits = slots.hold.kind === "mod" ? modifierSetFromUsage(slots.hold.usage) : 0; + const toggleMod = (bit: number) => { + const bits = modBits ^ bit; + const usage = usageFromModifierSet(bits); + setHold(usage === 0 ? { kind: "empty" } : { kind: "mod", usage }); + }; + + const derivedName = (() => { + const core = registry.coreById.get(binding.behaviorId); + return core ? CORE_LABELS[core] : "No matching behavior"; + })(); + + const hold = slots.hold; + const holdSummary = + hold.kind === "mod" + ? HOLD_MODIFIERS.filter((m) => modBits & m.bit) + .map((m) => m.label) + .join(" + ") + : hold.kind === "layer" + ? layers.find((l) => l.id === hold.layerId)?.name ?? + `Layer ${hold.layerId}` + : ""; + + const slotLabelClass = + "text-sm uppercase tracking-widest font-bold text-primary"; + const helpClass = "text-xs text-base-content/60"; + + return ( +
+
+ + Slots + + + = {derivedName} + +
+ + {/* Normal (always shown — the key's base action) and an optional Hold mode + (a different action when held). Only Hold mode has an on/off toggle: a + plain &kp reads as Normal-only, a &mo as Hold-mode-only. */} +
+
+ + + {holdOpen ? ( + <> +
+ {(["mod", "layer"] as HoldMode[]).map((mode) => ( + + ))} +
+ + {holdMode === "mod" && ( +
+ {HOLD_MODIFIERS.map((m) => { + const active = (modBits & m.bit) !== 0; + return ( + + ); + })} +
+ )} + + {holdMode === "layer" && ( +
+ {layers.map(({ id, name }, i) => { + const active = + slots.hold.kind === "layer" && slots.hold.layerId === id; + return ( + + ); + })} +
+ )} + + ) : ( +

+ A different action while held — a modifier or layer. +

+ )} +
+ +
+
+ Normal + {tapFilled ? ( + {tapLabel(slots.tap)} + ) : ( + The key sent on a normal press. + )} +
+ + +
+
+
+ ); +}; diff --git a/src/behaviors/slots.test.ts b/src/behaviors/slots.test.ts index c9215760..a5c9f7af 100644 --- a/src/behaviors/slots.test.ts +++ b/src/behaviors/slots.test.ts @@ -6,7 +6,8 @@ import { bindingToSlots, buildDerivationRegistry, deriveBinding, - isModifierUsage, + modifierSetFromUsage, + usageFromModifierSet, type Slots, } from "./slots"; import { validateBinding } from "./parameters"; @@ -62,30 +63,30 @@ describe("buildDerivationRegistry", () => { }); }); -describe("isModifierUsage", () => { - it("recognises the eight base HID modifier usages", () => { - for (let id = 0xe0; id <= 0xe7; id++) { - expect(isModifierUsage(hid(id)), `usage 0x${id.toString(16)}`).toBe(true); - } +describe("modifier set <-> usage (implicit-modifier encoding)", () => { + it("decodes a single base modifier", () => { + expect(modifierSetFromUsage(LCTRL)).toBe(0x01); + expect(modifierSetFromUsage(LSHIFT)).toBe(0x02); }); - it("recognises a multi-modifier hold whose base is a modifier", () => { - // LC(LSHFT) = hold Ctrl+Shift. The LC bit lives in the high byte; matching - // the raw value against the eight usages would miss it (design note §4). - expect(isModifierUsage(LC(LSHIFT))).toBe(true); - expect(isModifierUsage(LS(LCTRL))).toBe(true); + it("decodes a multi-modifier hold (base usage + implicit bits)", () => { + // LC(LSHFT) = Ctrl+Shift: the LC bit is in the high byte, the Shift base in + // the low bits — both must be picked up so the hold round-trips (design §4). + expect(modifierSetFromUsage(LC(LSHIFT))).toBe(0x01 | 0x02); + expect(modifierSetFromUsage(LS(LCTRL))).toBe(0x01 | 0x02); }); - it("treats a shifted key as a key, not a modifier", () => { - // LS(N1) = "!" — carries a mod bit but its base (N1) is a regular key, so it - // belongs in the Tap slot. - expect(isModifierUsage(LS(KEY_N1))).toBe(false); - expect(isModifierUsage(KEY_A)).toBe(false); + it("encodes the empty set as no modifier", () => { + expect(usageFromModifierSet(0)).toBe(0); }); - it("does not treat layer ids / small immediates as modifiers", () => { - expect(isModifierUsage(0)).toBe(false); - expect(isModifierUsage(FN_LAYER)).toBe(false); + it("round-trips every modifier subset through usage and back", () => { + for (let bits = 0; bits <= 0xff; bits++) { + expect( + modifierSetFromUsage(usageFromModifierSet(bits)), + `bits 0x${bits.toString(16)}` + ).toBe(bits); + } }); }); @@ -152,9 +153,11 @@ describe("deriveBinding — the §2 table", () => { }); describe("bindingToSlots", () => { - it("reads a lone-modifier &kp into the Hold slot", () => { + it("reads a lone-modifier &kp into the Tap slot (it's just a key)", () => { + // &kp LSHIFT is a normal Shift key, not the hold half of a Mod-Tap, so it + // lands in Tap — the Hold modifier mode is reserved for Mod-Tap. expect(bindingToSlots({ behaviorId: KP, param1: LSHIFT, param2: 0 }, reg)).toEqual( - { tap: { kind: "empty" }, hold: { kind: "mod", usage: LSHIFT } } + { tap: { kind: "key", usage: LSHIFT }, hold: { kind: "empty" } } ); }); @@ -173,7 +176,7 @@ describe("bindingToSlots", () => { describe("round trip — binding-lossless for every core behavior", () => { const bindings: [name: string, binding: BehaviorBinding][] = [ ["&kp A", { behaviorId: KP, param1: KEY_A, param2: 0 }], - ["&kp LSHIFT (held modifier)", { behaviorId: KP, param1: LSHIFT, param2: 0 }], + ["&kp LSHIFT (lone modifier is a Tap key)", { behaviorId: KP, param1: LSHIFT, param2: 0 }], ["&kp LS(N1) (shifted key stays a key)", { behaviorId: KP, param1: LS(KEY_N1), param2: 0 }], ["&mt LSHIFT ENTER", { behaviorId: MT, param1: LSHIFT, param2: KEY_ENTER }], ["&mt LC(LSHFT) A (multi-modifier hold)", { behaviorId: MT, param1: LC(LSHIFT), param2: KEY_A }], diff --git a/src/behaviors/slots.ts b/src/behaviors/slots.ts index bbbc2dd4..bb9e3ca0 100644 --- a/src/behaviors/slots.ts +++ b/src/behaviors/slots.ts @@ -21,7 +21,10 @@ import type { GetBehaviorDetailsResponse } from "@zmkfirmware/zmk-studio-ts-client/behaviors"; import type { BehaviorBinding } from "@zmkfirmware/zmk-studio-ts-client/keymap"; -import { hid_usage_page_and_id_from_usage } from "../hid-usages"; +import { + hid_usage_from_page_and_id, + hid_usage_page_and_id_from_usage, +} from "../hid-usages"; // ============================================================================= // Slot types @@ -63,6 +66,9 @@ const CORE_DISPLAY_NAMES: Record = { none: "None", }; +/** User-facing names for the reverse-lookup label on the slot surface. */ +export const CORE_LABELS: Record = CORE_DISPLAY_NAMES; + /** * A bidirectional map between core behaviors and the runtime behaviorIds the * connected keyboard exposes. Built from the `behaviors` array the picker @@ -99,26 +105,63 @@ const HID_KEYBOARD_PAGE = 7; const FIRST_MODIFIER_USAGE = 0xe0; // 224, Left Control const LAST_MODIFIER_USAGE = 0xe7; // 231, Right GUI +// The eight base HID modifiers, in implicit-bit order (bit i ↔ usage 0xE0+i), +// matching the LC/LS/.../RG flag order in keymap-parser. The Hold-slot modifier +// picker offers these as a multi-select: a held value combines one base usage +// (the lowest selected modifier) with implicit-modifier bits for the rest, e.g. +// Ctrl+Shift = LS(LCTRL) = base LCTRL + the LS bit. +export interface ModifierInfo { + bit: number; + usage: number; + label: string; +} + +export const HOLD_MODIFIERS: ReadonlyArray = [ + { bit: 0x01, label: "L Ctrl" }, + { bit: 0x02, label: "L Shift" }, + { bit: 0x04, label: "L Alt" }, + { bit: 0x08, label: "L GUI" }, + { bit: 0x10, label: "R Ctrl" }, + { bit: 0x20, label: "R Shift" }, + { bit: 0x40, label: "R Alt" }, + { bit: 0x80, label: "R GUI" }, +].map((m, i) => ({ + ...m, + usage: hid_usage_from_page_and_id(HID_KEYBOARD_PAGE, FIRST_MODIFIER_USAGE + i), +})); + /** - * Whether a held HID usage reads as "a modifier" (so it lands in the Hold slot). - * - * ZMK packs implicit modifiers into the high byte of a usage value and keeps the - * base HID usage in the low 24 bits. A value is a modifier when its *base* usage - * — after stripping the implicit-mod bits — is one of the eight base HID - * modifier usages (page 7, ids 0xE0..0xE7). Stripping first is essential: a - * multi-modifier hold like `LC(LSHFT)` carries the LC bit in the high byte, so - * matching the eight usages against the raw value would miss it and break the - * binding → slots → binding round trip (design note §4). A shifted *key* such as - * `LS(N1)` ("!") keeps a non-modifier base (N1) and is therefore a key, not a - * modifier — it belongs in the Tap slot. + * Decode a held HID usage into the set of active modifiers, as a bitmask over + * {@link HOLD_MODIFIERS} bits. Combines the implicit-modifier bits in the high + * byte with the base usage (when the base is itself a modifier). Returns 0 for a + * value that carries no modifiers. */ -export function isModifierUsage(usage: number): boolean { +export function modifierSetFromUsage(usage: number): number { + let bits = (usage >>> 24) & 0xff; const base = usage & 0x00ffffff; const [page, id] = hid_usage_page_and_id_from_usage(base); + if (page === HID_KEYBOARD_PAGE && id >= FIRST_MODIFIER_USAGE && id <= LAST_MODIFIER_USAGE) { + bits |= 1 << (id - FIRST_MODIFIER_USAGE); + } + return bits; +} + +/** + * Encode a modifier-bit set back into a single held HID usage: the lowest + * selected modifier becomes the base usage, the rest ride along as implicit + * bits (so Ctrl+Shift → `LS(LCTRL)`). Returns 0 for the empty set. Inverse of + * {@link modifierSetFromUsage} up to modifier identity (the exact base/implicit + * split is canonicalized, but the set of modifiers round-trips). + */ +export function usageFromModifierSet(bits: number): number { + const masked = bits & 0xff; + if (masked === 0) return 0; + const lowest = masked & -masked; + const index = Math.log2(lowest); + const implicit = masked & ~lowest; return ( - page === HID_KEYBOARD_PAGE && - id >= FIRST_MODIFIER_USAGE && - id <= LAST_MODIFIER_USAGE + hid_usage_from_page_and_id(HID_KEYBOARD_PAGE, FIRST_MODIFIER_USAGE + index) | + (implicit << 24) ); } @@ -178,10 +221,11 @@ export function deriveBinding( * chip UI. * * The **binding** survives binding → slots → binding for every core behavior - * ("binding-lossless"). The slot *decomposition* is heuristic only for `&kp`, - * whose single param is read as a Hold (a lone modifier) or a Tap (any other - * key) from the keycode's type — but both readings derive back to `&kp` with the - * same param, so the binding never changes ("slot-lossless" is *not* claimed). + * ("binding-lossless"): each core behavior stores its hold/tap explicitly, so + * the decomposition is a direct read, not a heuristic. A lone modifier `&kp` + * (e.g. `&kp LSHIFT`) decomposes to the Tap slot — it's a normal key, not a + * hold — so the Hold slot's modifier mode is only ever the hold half of a + * Mod-Tap. */ export function bindingToSlots( binding: BehaviorBinding, @@ -192,9 +236,14 @@ export function bindingToSlots( switch (core) { case "kp": - return isModifierUsage(binding.param1) - ? { tap: { kind: "empty" }, hold: { kind: "mod", usage: binding.param1 } } - : { tap: { kind: "key", usage: binding.param1 }, hold: { kind: "empty" } }; + // A `&kp` is a single keycode the user pressed — including a lone modifier + // (`&kp LSHIFT` is just a normal Shift key), which is a *key*, not the hold + // half of a Mod-Tap. So its param always lands in the Tap slot; the Hold + // slot's modifier mode is reserved for Mod-Tap, where a tap is also present. + return { + tap: { kind: "key", usage: binding.param1 }, + hold: { kind: "empty" }, + }; case "mt": return { tap: { kind: "key", usage: binding.param2 }, From 22b776d0e8ca483d7cd9102cc5614e44cdd8d7bf Mon Sep 17 00:00:00 2001 From: numachang Date: Sat, 6 Jun 2026 16:29:52 +0900 Subject: [PATCH 03/22] docs(design): converge slot model on Normal / Hold mode (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite §2 around the converged terminology: a always-present **Normal** slot (the key's base action) and an optional **Hold mode** (a different action when held). "Tap" is dropped from the slot labels — it wrongly implies short-press for a plain &kp — and kept only in the derived names (Mod-Tap, Layer-Tap). - §2a: why Normal/Hold mode, and why only Hold mode has an on/off toggle. - §2b: the modifier/layer asymmetry — a lone modifier is a Normal keystroke (`&kp `), but a lone momentary layer is Hold mode (`&mo`), because modifiers are keycodes and layers are not; a layer in Normal is instead the persistent &to/&tog/&sl. Plus the Windows Sticky-Keys aside. - §4a: coverage audit — every standard ZMK behavior stays settable (core via slots, the rest via the existing param UI / tiles); the model is additive. - Update §4 (modifier-set helpers), Step 2/4, §8 (resolve the lone-modifier question), the status line, and the Japanese §2 mirror. Co-Authored-By: Claude --- docs/design/key-picker-rethink.md | 301 +++++++++++++++++++++--------- 1 file changed, 212 insertions(+), 89 deletions(-) diff --git a/docs/design/key-picker-rethink.md b/docs/design/key-picker-rethink.md index a4fa1c71..9360d14d 100644 --- a/docs/design/key-picker-rethink.md +++ b/docs/design/key-picker-rethink.md @@ -6,8 +6,10 @@ > issue is the user-facing summary; this note is the working design. English is > authoritative; a Japanese mirror follows below. > -> Status: **design only — no implementation yet.** Direction is reviewable here -> before any code lands. +> 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. --- @@ -63,29 +65,75 @@ and each parameter value description is one of `constant` / `range` / `hidUsage` ## 2. Core insight: the behavior is an output, not the first input -What a person holds in their head is *"what's on tap, what's on hold"* — not the -name `&mt`. For the typing + layer + hold-tap core (≈90% of real keymaps) the -behavior follows mechanically from which slots are filled: - -| Tap slot | Hold slot | Derived behavior | Firmware | -| -------------- | --------- | ----------------------------- | -------------------------------- | -| key | (empty) | Key Press | `&kp ` | -| key | modifier | Mod-Tap | `&mt ` | -| key | layer | Layer-Tap | `< ` | -| (empty / None) | layer | Momentary Layer | `&mo ` | -| (empty) | modifier | a plain held modifier | `&kp ` (e.g. `&kp LSHIFT`) | -| (empty) | (empty) | None | `&none` | - -In this range the **behavior selector is unnecessary**: fill the two slots and -the name is determined. The parameter panel already exposes Hold / Tap tabs, so -half the groundwork exists. The change is an inversion of order: - -> "pick a behavior, then fill Hold/Tap" → **"fill Hold/Tap, and the behavior is derived."** - -Note the two `&kp` rows: a key on tap with nothing held is `&kp `, and a -modifier held with nothing on tap is also a `&kp` — just with a modifier keycode. -Mod-Tap only appears when **both** slots are filled (a real key on tap *and* a -modifier on hold). The reverse-lookup label (§3b) is what keeps this legible. +What a person holds in their head is *"what does this key do, and does holding it +do something else"* — not the name `&mt`. For the typing + layer + hold-tap core +(≈90% of real keymaps) the behavior follows mechanically from two slots: + +- **Normal** — what the key does on a normal press. A keystroke (including a lone + modifier such as `LSHIFT`), a layer switch, or nothing (None / Transparent). + *Always present* — every key has a Normal action (even if that action is None). +- **Hold mode** — an **optional** *different* action when the key is held long: a + modifier (→ Mod-Tap) or a layer (→ Layer-Tap / Momentary Layer). Off by default. + +| Normal | Hold mode | Derived behavior | Firmware | +| ------------------- | --------- | --------------------- | --------------------- | +| key | (off) | Key Press | `&kp ` | +| modifier (as a key) | (off) | Key Press | `&kp ` | +| key | modifier | Mod-Tap | `&mt ` | +| key | layer | Layer-Tap | `< ` | +| None | layer | Momentary Layer | `&mo ` | +| layer (sub-mode) | (off) | To / Toggle / Sticky | `&to` / `&tog` / `&sl`| +| Transparent | (off) | Transparent | `&trans` | +| None | (off) | None | `&none` | + +The change is an inversion of order: + +> "pick a behavior, then fill its params" → **"set Normal (and optionally Hold mode), and the behavior is derived."** + +### 2a. Why "Normal / Hold mode", not "Tap / Hold" + +"Tap" means *short press*, but a plain `&kp A` is **not** short-press-specific: A +is active for the whole press (held → it auto-repeats). Labelling that slot "Tap" +hides that holding also does something. So the base slot is **Normal** — the key's +action on press, regardless of duration. The tap-vs-hold *split* exists only for +hold-tap behaviors, and surfaces in the **derived name** (Mod-Tap, Layer-Tap), not +in the slot labels. + +Only **Hold mode** carries an on/off toggle. There is no valid "Normal-off + +Hold-on" behavior — `&mt`/`<` always need a Normal keystroke to act as the tap — +so a Normal on/off would be meaningless. Normal is always set (None is a *value*, +not an "off" state). + +### 2b. The modifier / layer asymmetry (real, and principled) + +A lone **modifier** and a lone **layer** are both "hold to use, tap does nothing", +yet they land in *different* slots: + +- A modifier is a **HID keycode**, so a lone modifier is just a key → **Normal** + (`&kp LSHIFT`). There is no separate "momentary modifier" behavior — `&kp ` + *is* the held modifier (active immediately on press, no tapping-term) — so a + modifier alone never goes in Hold mode. Hold mode's Modifier is purely the hold + half of a Mod-Tap (it needs a Normal key). +- A layer is **not a keycode**. Its "while held" form is a distinct behavior + (`&mo`), so a lone momentary layer lives in **Hold mode** (Normal = None). + +So the slot a *layer* sits in changes what it does: + +| Layer placement | Time character | Behavior | +| --------------- | ----------------------------------- | ----------------------------------------- | +| Hold mode | momentary (only while held) | `&mo` (alone) / `<` (with a Normal key) | +| Normal | persistent (tap to switch, latches) | `&to` / `&tog` / `&sl` (a sub-mode pick) | + +Modifiers have no such momentary-vs-persistent split (only `&kp `), so a lone +modifier has exactly one home (Normal). This asymmetry is a direct consequence of +"modifiers are keycodes, layers are not" — not an inconsistency. + +> Aside (Windows Sticky Keys): a `&kp LSHIFT` emits a real Shift press on every +> tap, exactly like a physical Shift, so 5 quick taps trigger Windows Sticky Keys. +> A home-row mod (`&mt LSHIFT A`) emits the *tap* key (A) on tap and Shift only on +> hold, so rapid taps send A, not Shift. There is no standard ZMK behavior that +> sends Shift only on long-hold; that would need a custom hold-tap, which we do not +> surface. --- @@ -186,20 +234,42 @@ are identified **by `displayName` string match** (`STANDARD_BEHAVIOR_GROUPS`, pre-fills the slots correctly and editing slots produces a binding. These are unit-testable in isolation (the repo already runs Vitest), which is the safest place to pin the table in §2. -- **Modifier detection must decode ZMK's implicit-modifier encoding, not just - match eight base usages.** A held value is "a modifier" not only when it is one - of the eight base HID modifier usages (L/R Ctrl/Shift/Alt/GUI), but also when it - is any keycode carrying implicit modifier bits — ZMK packs modifiers into the - high bits of the usage value (`LC(...)`, `LS(LALT)`, `LC(LSHFT)`, …), so users - routinely put **multiple** modifiers on hold. Matching only the eight base - usages would make `bindingToSlots` drop those holds and break the round trip. - Detection (and the hold-slot composite picker, §3b) must understand the - implicit-modifier encoding so a multi-modifier hold survives binding → slots → - binding intact. +- **The Hold-mode modifier picker must decode ZMK's implicit-modifier encoding.** + ZMK packs modifiers into the high byte of a usage value (`LC(...)`, `LS(LALT)`, + `LC(LSHFT)`, …), so a Mod-Tap hold routinely carries **multiple** modifiers. The + multi-select modifier picker converts between a held usage and the active + modifier set with `modifierSetFromUsage` / `usageFromModifierSet` + ([`slots.ts`](../../src/behaviors/slots.ts)), which strip/restore the high-byte + bits so a multi-modifier hold survives binding → slots → binding intact. + (Note: a *lone* modifier is **not** routed to Hold — per §2b it is a Normal + keystroke (`&kp `), so `bindingToSlots` decomposes `&kp` straight to the + Normal slot, no modifier-vs-key heuristic.) No firmware/RPC changes: the output is still a `BehaviorBinding` `{ behaviorId, param1, param2 }` handed to the existing `doUpdateBinding`. +### 4a. Coverage — every standard ZMK behavior stays settable + +The slot model is **additive**: behaviors it does not own remain reachable through +the existing metadata-driven parameter UI (chips today, tiles after Step 3), so no +default key setting becomes un-settable. Verified against the 25 standard behaviors +the repo knows (`behaviorAliases`, [`keymap-parser.ts`](../../src/keymap-parser.ts)): + +- **Slot model (Normal / Hold mode):** `&kp` (incl. a lone modifier, and implicit + mods via the "+ Send with" column), `&mt`, `<`, `&mo`, `&trans`, `&none` + (Step 2); `&to` / `&tog` / `&sl` as Normal-layer sub-modes (Step 4). +- **Tiles / chips (single-action or 1-param), via `ParameterValuePicker`:** `&sk`, + `&kt`, `&caps_word`, `&key_repeat`, `&gresc`, `&mkp` / `&mmv` / `&msc`, `&bt`, + `&out`, `&ext_power`, `&bl`, `&rgb_ug`, `&bootloader`, `&reset`, `&soft_off`, + `&studio_unlock` (Step 3). All their param kinds (`constant` / `range` / + `hidUsage` / `layerId` / `nil`) are already handled. +- **Intentionally not offered:** `&mt ` (a held plain key just + auto-repeats — a footgun) and custom/extension hold-taps (not a ZMK default; + reachable via chips, §3b). + +During the transition (Steps 2–4) the not-yet-migrated behaviors live in the +residual chip area, so coverage is 100% at every step. + --- ## 5. Staged plan (1 PR / 1 feature) @@ -255,31 +325,31 @@ end state. Each step is its own PR. ### Step 2 — derive `&kp` / `&mt` / `<` / `&mo` from the slots (the core) -- **Goal:** implement `deriveBinding` / `bindingToSlots` (§4) and let the user - fill Hold / Tap slots instead of naming a behavior, for the four core - behaviors. The Hold/Tap tabs in - [`BehaviorParametersPicker.tsx`](../../src/behaviors/BehaviorParametersPicker.tsx) - become the *primary* surface; the derived name is shown, not chosen. -- **Risk:** medium — two distinct hazards: - - *Round-trip fidelity.* The **binding** survives binding → slots → binding, but - the **slot decomposition is heuristic**: which slot `&kp LSHIFT` lands in is - inferred from the keycode's type (a lone modifier → hold, a regular key → tap), - not stored anywhere. Describe Step 2 as "binding-lossless," not "slot-lossless." - Vitest should assert both the round trip *and* that `deriveBinding`'s output - passes the existing `validateBinding` (call site - [`BehaviorBindingPicker.tsx`](../../src/behaviors/BehaviorBindingPicker.tsx) - lines 227-234) so derived bindings can never be rejected by the firmware path. - - *Dual-edit state sync (the migration hazard).* Because the old chip row stays - until Step 5, the same binding becomes editable two ways — by chip and by slot. - On top of the existing `binding` prop and local `behaviorId/param1/param2` state - (lines 210-247), slot state is a third source of truth, and the chip↔slot - interaction (clicking the "Mod-Tap" chip vs. filling the hold slot) must stay - consistent. To contain this, **hide the chips for the derivation-target - behaviors** (`&kp`/`&mt`/`<`/`&mo`) as soon as the slot surface owns them, - rather than leaving both live and three-way-syncing. -- **Done when:** filling/clearing the Hold and Tap slots produces the correct one - of `&kp` / `&mt` / `<` / `&mo` / `&none`, opening an existing such key shows the - right slot contents, and the derived binding validates. +- **Goal:** the **Normal / Hold mode** surface (§2) replaces naming-then-params for + the core behaviors, in a new [`SlotBindingPicker.tsx`](../../src/behaviors/SlotBindingPicker.tsx) + rendered above the residual chips in `BehaviorBindingPicker`. **Normal** is an + always-shown key grid (with `clearTiles` for None / Transparent and the + collapsible "+ Send with" implicit-mod column); **Hold mode** is an *on/off* + checkbox that reveals a `Modifier | Layer` picker (multi-select modifiers; layer + radios). The derived behavior name is shown, not chosen. +- **Pure model** ([`slots.ts`](../../src/behaviors/slots.ts), unit-tested in + [`slots.test.ts`](../../src/behaviors/slots.test.ts)): a capability-aware + `DerivationRegistry` (resolved by `displayName`), `deriveBinding(slots) → + BehaviorBinding | null`, `bindingToSlots(binding) → slots | null`, and the + `modifierSetFromUsage` / `usageFromModifierSet` helpers (§4). `validateBinding` + moved to [`parameters.ts`](../../src/behaviors/parameters.ts) so the tests assert + every derived binding also passes the firmware-facing check. +- **Round-trip fidelity:** the binding is **binding-lossless** (each core behavior + stores hold/tap explicitly; `&kp`'s param always decomposes to Normal, so there + is no modifier-vs-key heuristic). Vitest asserts the round trip *and* + `validateBinding`. +- **Dual-edit containment:** the slot surface and the residual chips both edit the + same local `behaviorId/param1/param2` state — one source of truth, no extra slot + state — and the derivation-target chips (`&kp`/`&mt`/`<`/`&mo`, plus + None/Transparent) are hidden so a behavior is never editable two ways at once. +- **Done when:** setting Normal and toggling Hold mode produces the correct one of + `&kp` / `&mt` / `<` / `&mo` / `&none`, opening such a key shows the right slot + state (a `&mo` reads as Hold-only at a glance), and the derived binding validates. ### Step 3 — tile-ify the single-action behaviors @@ -291,12 +361,17 @@ end state. Each step is its own PR. [`HidUsagePicker.tsx`](../../src/behaviors/HidUsagePicker.tsx). Capability-aware: a tab/tile renders only for behaviors the firmware exposes. -### Step 4 — reverse-lookup label + layer sub-mode picker +### Step 4 — layer in the Normal slot (To / Toggle / Sticky) + custom hold-tap -- **Goal:** add the unobtrusive "current combination = X" label to the slot - surface, plus the layer sub-mode picker (`&to` / `&tog` / `&sl`) and the - custom-hold-tap identity choice (§3b). -- **Risk:** low-medium — additive labels and a small selector. +- **Goal:** let the **Normal** slot hold a *layer* (not just a key), which derives + the persistent layer switches — `&to` / `&tog` / `&sl` — with a small sub-mode + pick (they take one layer param and differ only in latch behavior, §2b). Add the + custom-hold-tap identity choice for when more than one hold-tap behavior exists + (§3b, default `&mt`). The reverse-lookup label already ships with Step 2. +- **Note:** this is the *persistent* layer case; the *momentary* layer (`&mo`) and + Layer-Tap (`<`) live in **Hold mode** and already land in Step 2. A layer in + Normal disables Hold mode (a tap-to-switch has no hold half). +- **Risk:** low-medium — additive sub-mode selector on the Normal slot. ### Step 5+ — remove the top-level behavior tabs (later, needs its own design) @@ -342,11 +417,12 @@ stays here and is not pushed upstream. do we accept the `displayName` string match and degrade gracefully when names differ? (Current direction: keep the string match + graceful "not present.") 2. **Multiple hold-tap behaviors.** When `homerow_mods` and `&mt` both exist, what - is the default for a "key + mod" slot fill, and how prominent should the - sub-mode switch be? -3. **The "hold a modifier, empty tap" case.** Deriving `&kp ` from an empty - tap + a held modifier is elegant but slightly surprising; does the reverse- - lookup label make it clear enough, or should that case stay an explicit tile? + is the default for a Normal-key + Hold-modifier fill, and how prominent should + the sub-mode switch be? (Deferred to Step 4.) +3. ~~**The "hold a modifier, empty tap" case.**~~ **Resolved (§2b):** a lone + modifier is a HID keycode, so it is a **Normal** keystroke (`&kp `), not a + Hold. Hold mode's Modifier is only the hold half of a Mod-Tap. There is no + "momentary modifier" behavior to surprise anyone with. 4. **Step 5 scope.** How much of the old tab hierarchy, if any, should survive as an "advanced" escape hatch for behaviors that don't fit either shape? @@ -379,7 +455,10 @@ since they're rarely used day-to-day. > モデル・エッジケース)と段階別の実装計画を足したもの。issue が対外的な要約、 > こちらが作業用の設計。英語が正、本節はそのミラー。 > -> ステータス: **設計のみ・実装はまだ。** コードを書く前にここで方向性をレビュー可能に。 +> ステータス: **Step 1 出荷済み(PR #27)/ Step 2 実装中。** スロットモデルは +> **Normal / Hold mode** 方式に収束(§2)。初期案の「Tap / Hold」は、ただの `&kp` +> に対して「Tap=短押し」が誤解を招くため改名した。全標準 ZMK behavior の設定可否は +> §4a で確認済み。 ## 1. 現状コードに即した問題 @@ -417,24 +496,68 @@ GetBehaviorDetailsResponse>`(36 行)で、呼び出し側 588 行の `Object ## 2. 核心: behavior は最初の入力ではなく結果 -人の頭にあるのは「タップに何、ホールドに何」であって `&mt` という名前ではない。 -タイピング+レイヤー+ホールドタップの中核(実キーマップの約 9 割)では、どの枠を -埋めたかから behavior が機械的に決まる: - -| タップ枠 | ホールド枠 | 派生 behavior | ファーム | -| ---------- | ---------- | ----------------- | ------------------------------- | -| キー | (空) | Key Press | `&kp ` | -| キー | 修飾 | Mod-Tap | `&mt ` | -| キー | レイヤー | Layer-Tap | `< ` | -| (空/None)| レイヤー | Momentary Layer | `&mo ` | -| (空) | 修飾 | ただの修飾キー | `&kp `(例 `&kp LSHIFT`) | -| (空) | (空) | None | `&none` | - -この範囲では **behavior セレクタは不要**——2 枠を埋めれば名前は決まる。やることは -順序の反転:「behavior を選んでから Hold/Tap を埋める」→「**Hold/Tap を埋めれば -behavior が派生する**」。`&kp` が 2 行ある点に注意(タップにキー=`&kp `、 -ホールドに修飾だけ=`&kp `)。Mod-Tap は**両枠**が埋まったときだけ現れる。 -逆引きラベル(§3b)がこの可読性を担保する。 +人の頭にあるのは「このキーは何をする/長押しで別のことをするか」であって `&mt` +という名前ではない。中核(実キーマップの約 9 割)では 2 つのスロットから behavior が +機械的に決まる: + +- **Normal** … 普通に押したときの動作。キーストローク(`LSHIFT` 等の単独修飾も含む)、 + レイヤー切替、または無し(None / Transparent)。**常に存在**(None も“値”)。 +- **Hold mode** … 長押ししたときの**“別の”動作(任意)**。修飾(→ Mod-Tap)か + レイヤー(→ Layer-Tap / Momentary Layer)。既定オフ。 + +| Normal | Hold mode | 派生 behavior | ファーム | +| ----------------- | --------- | -------------------- | ---------------------- | +| キー | (オフ) | Key Press | `&kp ` | +| 修飾(キーとして)| (オフ) | Key Press | `&kp ` | +| キー | 修飾 | Mod-Tap | `&mt ` | +| キー | レイヤー | Layer-Tap | `< ` | +| None | レイヤー | Momentary Layer | `&mo ` | +| レイヤー(サブモード)| (オフ)| To / Toggle / Sticky | `&to` / `&tog` / `&sl` | +| Transparent | (オフ) | Transparent | `&trans` | +| None | (オフ) | None | `&none` | + +順序の反転:「behavior を選んでから param」→「**Normal(と任意で Hold mode)を埋めれば +behavior が派生する**」。 + +### 2a. なぜ「Tap / Hold」でなく「Normal / Hold mode」か + +「Tap」は**短押し**を意味するが、ただの `&kp A` は短押し専用ではない(押している間 +ずっと A、長押しはリピート)。そのスロットを「Tap」と呼ぶと長押し分が見えなくなる。 +だから基本スロットは **Normal**(押したときの動作・押す長さに依らない)。タップ/ +ホールドの**区別**は hold-tap のときだけ生まれ、**派生名**(Mod-Tap, Layer-Tap)に +現れる。on/off があるのは **Hold mode だけ**——「Normal オフ+Hold オン」に当たる +behavior が無い(`&mt`/`<` は必ず Normal のキーをタップ側に要る)ため。 + +### 2b. 修飾/レイヤーの非対称(実在し、筋が通る) + +単独の**修飾**も単独の**レイヤー**も「長押しで使う・タップは無反応」だが、入る +スロットが違う: + +- 修飾は **HID キーコード**なので単独ならただのキー → **Normal**(`&kp LSHIFT`)。 + 「モメンタリ修飾」という別 behavior は無い(`&kp ` がそれ・押した瞬間に即時 + 有効)ので、単独修飾は Hold mode に入らない。Hold mode の修飾は Mod-Tap の長押し側 + 専用(Normal にキーが要る)。 +- レイヤーは**キーコードではない**。「押している間」の形が別 behavior(`&mo`)なので、 + 単独のモメンタリは **Hold mode**(Normal = None)。 + +ゆえにレイヤーは**置くスロットで意味が変わる**: + +| レイヤーの場所 | 時間的性質 | behavior | +| --- | --- | --- | +| Hold mode | モメンタリ(押している間だけ) | `&mo`(単独)/ `<`(Normal キー付き) | +| Normal | 持続(タップで切替・残る) | `&to` / `&tog` / `&sl`(サブモード選択) | + +修飾にはこの「一時 vs 持続」が無い(`&kp ` のみ)ので、単独修飾の居場所は +Normal 一択。この非対称は「**修飾はキーコード、レイヤーはそうでない**」の直接の帰結で、 +矛盾ではない。 + +> 補足(Windows 固定キー): `&kp LSHIFT` はタップごとに本物の Shift 押下を送る(物理 +> Shift と同じ)ので、5 回連打で固定キーが発動する。ホームロウ Mod(`&mt LSHIFT A`) +> はタップで A を送り Shift は長押し時だけなので、連打しても A が出る。長押し時だけ +> Shift を送る標準 behavior は無く、それには自作 hold-tap が要る(出さない)。 + +> 注: 以下 §3〜§8 は旧「Tap / Hold」表記が残る箇所がある(= 新「Normal / Hold mode」)。 +> カバレッジ確認(§4a)と段階計画の最新は**英語版が正**。要点は上の §2/§2a/§2b に集約。 ## 3. 操作形 From cd87751bd38b93f8227cd469957a1495dda0409c Mon Sep 17 00:00:00 2001 From: numachang Date: Sat, 6 Jun 2026 21:20:30 +0900 Subject: [PATCH 04/22] fix(behaviors): keycap send-with badges + slot-surface polish; keep hold when adding send-with (#16 step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI polish for the slot surface and the keymap keycaps, plus one real bug fix: - HidUsageLabel: show a key's implicit "+ Send with" modifiers as small bordered badges to the left of the cap glyph (e.g. `LC(S)` reads "[Ctrl] S"). Strips the high-byte mod bits before resolving the label so the modified value no longer falls back to a raw `0x…`. - SlotBindingPicker: the "+ Send with" implicit-modifier column is always shown (no longer collapsible) and the NORMAL reverse-lookup label spells out its modifiers (e.g. "L Ctrl + Keyboard S"). - Key / Keymap: tighten keycap padding, top-align header'd keys under the pill, give the hold preview the same weight/opacity as the tap (uniform legibility), and tune the hold/tap row spacing. - behavior-short-names.json: concise header names so the behavior pill no longer truncates ("Mod-Tap", "Lay-Tap", "Sticky", "Caps", "Trans", "To Lyr", ...). Bug fix: `modifiersChanged` in HidUsagePicker was memoized on `[value]` only, capturing a stale `onValueChanged`. With the slot surface, adding a "+ Send with" modifier to a Mod-Tap/Layer-Tap reverted to the pre-hold binding and dropped the hold. Adding `onValueChanged` to the deps keeps the hold (and clears the stale react-hooks warning). Co-Authored-By: Claude --- src/behaviors/HidUsagePicker.tsx | 2 +- src/behaviors/SlotBindingPicker.tsx | 14 +++++++-- src/keyboard/HidUsageLabel.tsx | 42 ++++++++++++++++++++++++-- src/keyboard/Key.tsx | 8 ++--- src/keyboard/Keymap.tsx | 6 ++-- src/keyboard/behavior-short-names.json | 30 ++++++++++++------ 6 files changed, 78 insertions(+), 24 deletions(-) diff --git a/src/behaviors/HidUsagePicker.tsx b/src/behaviors/HidUsagePicker.tsx index 0dff44fc..2a11084d 100644 --- a/src/behaviors/HidUsagePicker.tsx +++ b/src/behaviors/HidUsagePicker.tsx @@ -806,7 +806,7 @@ export const HidUsagePicker = ({ const new_value = mask_mods(value) | (mod_flags << 24); onValueChanged(new_value); }, - [value], + [value, onValueChanged], ); const modifierColumn = ( diff --git a/src/behaviors/SlotBindingPicker.tsx b/src/behaviors/SlotBindingPicker.tsx index 3931861f..65def2d1 100644 --- a/src/behaviors/SlotBindingPicker.tsx +++ b/src/behaviors/SlotBindingPicker.tsx @@ -59,8 +59,17 @@ const chipClass = (active: boolean) => function tapLabel(slot: Slots["tap"]): string { if (slot.kind === "empty") return "—"; - const [page, id] = hid_usage_page_and_id_from_usage(slot.usage); - return hid_usage_get_label(page, id) ?? `0x${slot.usage.toString(16)}`; + // 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…`. + 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 mods = HOLD_MODIFIERS.filter( + (m) => ((slot.usage >>> 24) & m.bit) !== 0 + ).map((m) => m.label); + return mods.length ? `${mods.join(" + ")} + ${key}` : key; } export const SlotBindingPicker = ({ @@ -270,7 +279,6 @@ export const SlotBindingPicker = ({ clearTiles={clearTiles} modifiers={{ side: "right", - collapsible: true, label: "+ Send with", hint: "Sent together with the key, e.g. Ctrl+C.", }} diff --git a/src/keyboard/HidUsageLabel.tsx b/src/keyboard/HidUsageLabel.tsx index 7a3d156a..b1067107 100644 --- a/src/keyboard/HidUsageLabel.tsx +++ b/src/keyboard/HidUsageLabel.tsx @@ -38,17 +38,53 @@ export const HidUsageLabel = ({ hid_usage, compact }: HidUsageLabelProps) => { const mods = (pageWithMods >> 8) & 0xff; const page = pageWithMods & 0xff; const shiftActive = (mods & (0x02 | 0x20)) !== 0; + const otherMods = mods & ~(0x02 | 0x20) & 0xff; const labels = hid_usage_get_metadata(page, id, layout); + // Implicit "+ Send with" modifiers shown as small bordered badges to the left + // of the cap glyph (L/R collapsed), stacked vertically when there are several + // — e.g. `LC(S)` reads "[Ctrl] S". Capped at three. + const modBadges = (m: number) => + [ + m & 0x11 ? "Ctrl" : "", + m & 0x22 ? "Shift" : "", + m & 0x44 ? "Alt" : "", + m & 0x88 ? "GUI" : "", + ].filter(Boolean); + // EXPERIMENT: badges at text-xs (same class as the hold preview, 6.4px). + const withMods = (badges: string[], key: string) => + badges.length === 0 ? ( + {key} + ) : ( + + + {badges.slice(0, 3).map((b) => ( + + {b} + + ))} + + {key} + + ); + // 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. + // glyph, so show just that (other modifiers ride beside it). Otherwise render + // the pair 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]}; + return withMods(modBadges(otherMods), pair[2]); + } + // Any other implicit modifier: badges beside the cap glyph (here Shift is not + // a shifted glyph, so it joins the badges). + if (mods) { + return withMods(modBadges(mods), pair ? pair[1] : short); } if (pair && !compact) { return ( diff --git a/src/keyboard/Key.tsx b/src/keyboard/Key.tsx index 0a7a3b6c..99566ac2 100644 --- a/src/keyboard/Key.tsx +++ b/src/keyboard/Key.tsx @@ -59,18 +59,18 @@ export const Key = ({ > {shortHeader && (
{shortHeader}
)}
{children} diff --git a/src/keyboard/Keymap.tsx b/src/keyboard/Keymap.tsx index c67f2ab0..2cc6dd7c 100644 --- a/src/keyboard/Keymap.tsx +++ b/src/keyboard/Keymap.tsx @@ -124,17 +124,17 @@ 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}
diff --git a/src/keyboard/behavior-short-names.json b/src/keyboard/behavior-short-names.json index 2f702ede..c2744688 100644 --- a/src/keyboard/behavior-short-names.json +++ b/src/keyboard/behavior-short-names.json @@ -1,14 +1,24 @@ { "Key Press": {"short": ""}, - "Bootloader": {"short": "Bootloadr"}, - "External Power": {"short": "Ext Pwr"}, - "Grave/Escape": {"short": "Grv/Esc"}, - "Key Repeat": {"short": "Key Rept"}, - "Key Toggle": {"short": "Key Togg"}, + "Mod-Tap": {"short": "Mod-Tap"}, + "Layer-Tap": {"short": "Lay-Tap"}, "Momentary Layer": {"short": "MLayer"}, - "Output Selection": {"short": "OutputSel"}, - "Sticky Key": {"short": "Stcky Key"}, + "To Layer": {"short": "To Lyr"}, + "Toggle Layer": {"short": "Tog Lyr"}, + "Sticky Layer": {"short": "Stk Lyr"}, + "Sticky Key": {"short": "Sticky"}, + "Caps Word": {"short": "Caps"}, + "Key Repeat": {"short": "Repeat"}, + "Key Toggle": {"short": "KeyTogg"}, + "Grave/Escape": {"short": "Grv/Esc"}, + "Bluetooth": {"short": "BT"}, + "Output Selection": {"short": "Output"}, + "External Power": {"short": "ExtPwr"}, + "Backlight": {"short": "Backlt"}, + "RGB Underglow": {"short": "RGB"}, + "Bootloader": {"short": "Boot"}, + "Reset": {"short": "Reset"}, + "Soft Off": {"short": "SoftOff"}, "Studio Unlock": {"short": "Unlock"}, - "Toggle Layer": {"short": "Togg Layr"}, - "Transparent": {"short": "Transprnt"} -} \ No newline at end of file + "Transparent": {"short": "Trans"} +} From b5dad4cbaf60338b5d67e36b90f43c7e8722a792 Mon Sep 17 00:00:00 2001 From: numachang Date: Sat, 6 Jun 2026 21:34:33 +0900 Subject: [PATCH 05/22] =?UTF-8?q?fix(behaviors):=20address=20PR=20#29=20re?= =?UTF-8?q?view=20=E2=80=94=20drop=20EXPERIMENT=20comment,=20show=20all=20?= =?UTF-8?q?send-with=20badges,=20fix=20stale=20doc=20comment=20(#16=20step?= =?UTF-8?q?=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HidUsageLabel: remove the leftover `EXPERIMENT:` comment; show all send-with badges instead of `slice(0, 3)` so a 4-modifier ("hyper") key no longer silently drops its GUI badge. - slots.ts: rewrite the header-comment table to the converged Normal / Hold-mode model (it still described the old Tap/Hold rows incl. the now-wrong "(empty) + modifier → &kp " — a lone modifier is a Normal keystroke, §2b). - docs(design) §8a: record the before-main blockers raised in review — the slot surface rendering for non-core bindings (empty grid silently overwrites to &kp), and the silent `deriveBinding → null` no-op. Co-Authored-By: Claude --- docs/design/key-picker-rethink.md | 18 ++++++++++++++++++ src/behaviors/slots.ts | 24 +++++++++++++----------- src/keyboard/HidUsageLabel.tsx | 10 +++++----- 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/docs/design/key-picker-rethink.md b/docs/design/key-picker-rethink.md index 9360d14d..2e45f04b 100644 --- a/docs/design/key-picker-rethink.md +++ b/docs/design/key-picker-rethink.md @@ -426,6 +426,24 @@ stays here and is not pushed upstream. 4. **Step 5 scope.** How much of the old tab hierarchy, if any, should survive as an "advanced" escape hatch for behaviors that don't fit either shape? +### 8a. Before-main blockers (from the PR #29 review) + +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`. +- **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. + --- ## Appendix — what the "flavor" behaviors actually do diff --git a/src/behaviors/slots.ts b/src/behaviors/slots.ts index bb9e3ca0..2a66db53 100644 --- a/src/behaviors/slots.ts +++ b/src/behaviors/slots.ts @@ -2,18 +2,20 @@ // // The picker historically asks the user to name a behavior first, then fill its // parameters. The slot model inverts that for the typing + layer + hold-tap core -// (~90% of real keymaps): the user fills a **Tap** slot and a **Hold** slot, and -// the behavior is *derived* from which slots are filled. See -// `docs/design/key-picker-rethink.md` §2-§4. +// (~90% of real keymaps): the user sets a **Normal** action (always present) and +// an optional **Hold mode** action, and the behavior is *derived*. The two slot +// fields below are still named `tap` / `hold` (the firmware's tap/hold params); +// the UI labels them Normal / Hold mode. See `docs/design/key-picker-rethink.md` +// §2 (esp. §2b: a lone modifier is a Normal keystroke, not a Hold). // -// | Tap slot | Hold slot | Derived behavior | Firmware | -// | -------------- | --------- | ----------------- | ------------------- | -// | key | (empty) | Key Press | &kp | -// | key | modifier | Mod-Tap | &mt | -// | key | layer | Layer-Tap | < | -// | (empty / None) | layer | Momentary Layer | &mo | -// | (empty) | modifier | a plain held mod | &kp | -// | (empty) | (empty) | None | &none | +// | Normal (tap) | Hold mode (hold) | Derived behavior | Firmware | +// | -------------- | ---------------- | ---------------- | ------------------- | +// | key | (off) | Key Press | &kp | +// | modifier (key) | (off) | Key Press | &kp | +// | key | modifier | Mod-Tap | &mt | +// | key | layer | Layer-Tap | < | +// | (none) | layer | Momentary Layer | &mo | +// | (none) | (off) | None | &none | // // The output is still a plain `BehaviorBinding` handed to the existing // `doUpdateBinding` path — no firmware or RPC change. diff --git a/src/keyboard/HidUsageLabel.tsx b/src/keyboard/HidUsageLabel.tsx index b1067107..6c8c4bd5 100644 --- a/src/keyboard/HidUsageLabel.tsx +++ b/src/keyboard/HidUsageLabel.tsx @@ -42,9 +42,10 @@ export const HidUsageLabel = ({ hid_usage, compact }: HidUsageLabelProps) => { const labels = hid_usage_get_metadata(page, id, layout); - // Implicit "+ Send with" modifiers shown as small bordered badges to the left - // of the cap glyph (L/R collapsed), stacked vertically when there are several - // — e.g. `LC(S)` reads "[Ctrl] S". Capped at three. + // Implicit "+ Send with" modifiers, shown as small bordered badges (text-xs, + // matching the hold preview) to the left of the cap glyph, L/R collapsed and + // stacked vertically — e.g. `LC(S)` reads "[Ctrl] S". There are at most four + // (Ctrl/Shift/Alt/GUI); all are shown so a "hyper"-style key isn't misread. const modBadges = (m: number) => [ m & 0x11 ? "Ctrl" : "", @@ -52,14 +53,13 @@ export const HidUsageLabel = ({ hid_usage, compact }: HidUsageLabelProps) => { m & 0x44 ? "Alt" : "", m & 0x88 ? "GUI" : "", ].filter(Boolean); - // EXPERIMENT: badges at text-xs (same class as the hold preview, 6.4px). const withMods = (badges: string[], key: string) => badges.length === 0 ? ( {key} ) : ( - {badges.slice(0, 3).map((b) => ( + {badges.map((b) => ( Date: Sun, 7 Jun 2026 11:34:35 +0900 Subject: [PATCH 06/22] =?UTF-8?q?feat(behaviors):=20Step=203a=20=E2=80=94?= =?UTF-8?q?=20keycap=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 07/22] =?UTF-8?q?fix(behaviors):=20address=20PR=20#30=20re?= =?UTF-8?q?view=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 From cbbd354fa16904c0ff51e1e871b684ba3bfc1daa Mon Sep 17 00:00:00 2001 From: numachang Date: Sun, 7 Jun 2026 23:51:12 +0900 Subject: [PATCH 08/22] =?UTF-8?q?feat(behaviors):=20Step=203b=20=E2=80=94?= =?UTF-8?q?=20Sticky=20Key=20/=20Key=20Toggle=20latches,=20flavor=20tiles?= =?UTF-8?q?=20(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the Basic "flavor" behaviors off the residual chip area and onto the Normal slot surface, so the residual "More behaviors" Basic group is gone. - Sticky Key / Key Toggle become a "Latch" checkbox pair on the SLOTS row that wraps the Normal key (&kp K -> &sk K / &kt K). The affected key is still picked from the grid; the two are mutually exclusive and disabled while Hold mode is on. While a latch is active the grid is key-only: the Mouse / System tabs and the one-tap flavor tiles are disabled (greyed), but None / Transparent and the keycodes stay live. - Caps Word / Key Repeat / Grave-Escape become one-tap keycap tiles in a 2-column block on the Basic tab, with None / Transparent pinned to the bottom row. Tiles are 1U and wrap at the largest font that still fits. - tiles.ts: isParamTileable detects single-key-param behaviors; drop the now-unused param-tile helper. - keymap-parser: add the missing `kt` alias and correct `gresc` to "Grave/Escape" so &kt / &gresc render in the slot code label. - mock fixtures: expose Key Toggle / Key Repeat / Grave-Escape on the demo. tsc + lint clean; vitest 126 passing. Co-Authored-By: Claude --- src/behaviors/BehaviorBindingPicker.tsx | 78 +++++++++++++-- src/behaviors/HidUsagePicker.tsx | 121 +++++++++++++++++++---- src/behaviors/SlotBindingPicker.tsx | 126 ++++++++++++++++++++++-- src/behaviors/tiles.test.ts | 50 +++++++++- src/behaviors/tiles.ts | 15 +++ src/keymap-parser.ts | 3 +- src/mock/fixtures.ts | 6 ++ 7 files changed, 359 insertions(+), 40 deletions(-) diff --git a/src/behaviors/BehaviorBindingPicker.tsx b/src/behaviors/BehaviorBindingPicker.tsx index 2a09b3e3..39855869 100644 --- a/src/behaviors/BehaviorBindingPicker.tsx +++ b/src/behaviors/BehaviorBindingPicker.tsx @@ -6,7 +6,13 @@ import { BehaviorParametersPicker } from "./BehaviorParametersPicker"; import { SlotBindingPicker } from "./SlotBindingPicker"; import { formatBinding, validateBinding } from "./parameters"; import { bindingToSlots, buildDerivationRegistry } from "./slots"; -import { BindingTileTab, isTileable, tileBindingsFor } from "./tiles"; +import { + BehaviorTile, + BindingTileTab, + isParamTileable, + isTileable, + tileBindingsFor, +} from "./tiles"; export interface BehaviorBindingPickerProps { binding: BehaviorBinding; @@ -22,12 +28,13 @@ export interface BehaviorBindingPickerProps { // 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 + // Basic — Sticky Key / Key Toggle (parameterised tiles) lead the Basic-tab + // column, followed by the parameterless flavor tiles. ["Key Press", "Basic"], ["Sticky Key", "Basic"], + ["Key Toggle", "Basic"], ["Caps Word", "Basic"], ["Key Repeat", "Basic"], - ["Key Toggle", "Basic"], ["Grave/Escape", "Basic"], ["Transparent", "Basic"], ["None", "Basic"], @@ -184,6 +191,20 @@ export const BehaviorBindingPicker = ({ [clearBehaviors, derivedChipIds] ); + // The Basic "flavor" behaviors leave the residual chip area (issue #16, Step + // 3b). They split by shape: the parameterless ones (Caps Word / Key Repeat / + // Grave-Escape) are one-tap finished tiles in the Normal grid's Basic tab; the + // single-key ones (Sticky Key / Key Toggle) are key-state latches on the slot + // surface. (Key Press also has the single-key shape but is a hidden core + // behavior, so hiddenIds already excludes it.) + const isBasicFinishedTile = (b: GetBehaviorDetailsResponse) => + classifyBehavior(b) === "Basic" && !hiddenIds.has(b.id) && isTileable(b); + const isBasicWrapper = (b: GetBehaviorDetailsResponse) => + classifyBehavior(b) === "Basic" && + !hiddenIds.has(b.id) && + !isTileable(b) && + isParamTileable(b); + // 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 @@ -193,6 +214,7 @@ export const BehaviorBindingPicker = ({ for (const b of sortedBehaviors) { if (hiddenIds.has(b.id)) continue; if (isStripBehavior(b)) continue; + if (isBasicFinishedTile(b) || isBasicWrapper(b)) continue; const group = classifyBehavior(b); if (!out[group]) out[group] = []; out[group].push(b); @@ -223,6 +245,26 @@ export const BehaviorBindingPicker = ({ return tabs; }, [sortedBehaviors, hiddenIds]); + // The Basic-tab one-tap flavor tiles (Caps Word / Key Repeat / Grave-Escape), + // one finished binding each. Capability-aware — only firmware-exposed ones. + const basicBehaviorTiles = useMemo(() => { + const out: BehaviorTile[] = []; + for (const b of sortedBehaviors) { + if (isBasicFinishedTile(b)) out.push(...(tileBindingsFor(b) ?? [])); + } + return out; + }, [sortedBehaviors, hiddenIds]); + + // Key-state latch behaviors (Sticky Key / Key Toggle) the firmware exposes, + // offered as toggles on the slot surface that wrap the Normal key. + const keyStateWrappers = useMemo(() => { + const out: { id: number; label: string }[] = []; + for (const b of sortedBehaviors) { + if (isBasicWrapper(b)) out.push({ id: b.id, label: b.displayName }); + } + return out; + }, [sortedBehaviors, hiddenIds]); + const currentBehavior = behaviors.find((b) => b.id === behaviorId); const currentGroup = currentBehavior ? classifyBehavior(currentBehavior) @@ -379,6 +421,14 @@ export const BehaviorBindingPicker = ({ // 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; + // A param-tile behavior (Sticky Key / Key Toggle) picks its key in-panel on the + // slot surface, so it skips the far-bottom params picker too. Key Press shares + // the single-HID-key shape but is a *core* behavior (edited via the slots), so + // gate on !isCoreBinding to keep it out of the param-tile path. + const isParamTileBinding = + currentBehavior && !isCoreBinding + ? isParamTileable(currentBehavior) + : false; const setBindingFromSlots = (b: BehaviorBinding) => { setBehaviorId(b.behaviorId); setParam1(b.param1); @@ -403,6 +453,8 @@ export const BehaviorBindingPicker = ({ onBindingChanged={setBindingFromSlots} clearTiles={clearTiles} behaviorTabs={behaviorTabs} + basicBehaviorTiles={basicBehaviorTiles} + keyStateWrappers={keyStateWrappers} currentBehaviorName={currentBehavior?.displayName} bindingCode={formatBinding( currentBinding, @@ -410,13 +462,17 @@ export const BehaviorBindingPicker = ({ layers.map((l) => l.id) )} /> + {/* Residual area for behaviors not yet migrated onto the slot surface or + the Normal grid's tabs (persistent layers, custom hold-taps, ...). It + disappears entirely once nothing's left to show. */} + {availableGroups.length > 0 && (

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 + parameters. Typing, basic and hold-tap keys live on the slot surface above.

0 && (
- {/* 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 && ( + )} + {/* Core behaviors are edited on the slot surface above, tile behaviors bake + their params into the tile, and param-tile behaviors (Sticky Key / Key + Toggle) pick their key in-panel; only the remaining non-core behaviors + (persistent layers, custom hold-taps, ...) keep the params picker. */} + {metadata && !isCoreBinding && !isTileBinding && !isParamTileBinding && ( void; + /** + * Restrict the grid to picking a *key* only: the whole-binding affordances + * (the Mouse / System behavior tabs, the one-tap flavor tiles, and the None / + * Transparent clear tiles) are disabled. Used while a key-state latch (Sticky + * Key / Key Toggle) is active, where the grid only chooses the latched key and + * selecting another behavior would silently drop the latch (issue #16). + */ + keyOnly?: boolean; /** * 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 @@ -216,8 +232,10 @@ const HidUsageGrid = ({ usagePages, clearTiles, extraTabs, + basicBehaviorTiles, activeBinding, onSelectBinding, + keyOnly, onActiveTabChange, inline, search: controlledSearch, @@ -568,8 +586,16 @@ const HidUsageGrid = ({ // 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) { + if (!activeBinding) return; + // A Basic one-tap flavor behavior (Caps Word / Key Repeat / Grave-Escape, in + // the Basic tab's right column) jumps to the Basic tab. + for (const tile of basicBehaviorTiles ?? []) { + if (sameBinding(tile.binding, activeBinding)) { + setActiveTab(BASIC_TAB_ID); + return; + } + } + for (const t of extraTabs ?? []) { const hit = t.sections.some((s) => s.tiles.some((tile) => sameBinding(tile.binding, activeBinding)) ); @@ -584,6 +610,7 @@ const HidUsageGrid = ({ activeBinding?.param1, activeBinding?.param2, extraTabs, + basicBehaviorTiles, ]); const onTabChange = useCallback((k: string) => setActiveTab(k), []); @@ -621,9 +648,12 @@ const HidUsageGrid = ({ // 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 []; + if (!trimmedSearch) return []; const out: BehaviorTile[] = []; - for (const tab of extraTabs) { + for (const tile of basicBehaviorTiles ?? []) { + if (tile.label.toLowerCase().includes(trimmedSearch)) out.push(tile); + } + 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(); @@ -632,7 +662,7 @@ const HidUsageGrid = ({ } } return out; - }, [trimmedSearch, extraTabs]); + }, [trimmedSearch, extraTabs, basicBehaviorTiles]); const renderUsageButton = (usage: Usage) => { const usageValue = (usage.pageId << 16) | usage.Id; @@ -700,19 +730,40 @@ const HidUsageGrid = ({ // 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 renderBehaviorTile = ( + tile: BehaviorTile, + variant: "tab" | "column" = "tab" + ) => { const active = !!activeBinding && sameBinding(tile.binding, activeBinding); + const onPress = () => onSelectBinding?.(tile.binding); + // A whole-binding tile can't be picked while the grid is key-only (latched). + const disabled = keyOnly; + // The column variant is a 1U-wide cap whose height grows so long labels wrap + // (e.g. "Grave/Escape" → two lines), at the largest font a 6-char word still + // fits the 1U width without breaking mid-word (~13px; text-sm/14px overflows). + const sizing = + variant === "column" + ? "min-h-12 px-0 text-[13px] whitespace-normal break-words" + : "min-w-16 h-16 px-2 whitespace-nowrap"; return ( ); }; @@ -754,13 +805,13 @@ const HidUsageGrid = ({ {/* Matching behavior tiles (Reset, BT_SEL, LCLK, …) so the search reaches the whole surface, not just the keycodes. Grouped as a radiogroup (own row) since the tiles are radios. */} - {behaviorSearchResults.length > 0 && ( + {!keyOnly && behaviorSearchResults.length > 0 && (
- {behaviorSearchResults.map(renderBehaviorTile)} + {behaviorSearchResults.map((t) => renderBehaviorTile(t))}
)} @@ -774,13 +825,14 @@ const HidUsageGrid = ({ className="flex flex-col" selectedKey={activeTab ?? BASIC_TAB_ID} onSelectionChange={(k) => onTabChange(k as string)} + disabledKeys={keyOnly ? extraTabNames : []} > {allTabs.map((category) => ( {category} @@ -804,13 +856,40 @@ const HidUsageGrid = ({ } > {category === BASIC_TAB_ID ? ( -
- {basicRows.map((row, i) => ( -
- {row.map((cell, j) => renderBasicCell(cell, j))} - {i === basicRows.length - 1 && trailingClearKeycapsBasic} +
+
+ {basicRows.map((row, i) => ( +
+ {row.map((cell, j) => renderBasicCell(cell, j))} +
+ ))} +
+ {/* Right block: the one-tap flavor behaviors (Caps Word / Key + Repeat / Grave-Escape) in a 2-wide grid at the top, with the + special None / Transparent caps pinned to the bottom row + (mt-auto), aligned with the board's last row. (Other tabs keep + the clear caps trailing the key list.) */} + {((basicBehaviorTiles && basicBehaviorTiles.length > 0) || + (clearTiles && clearTiles.tiles.length > 0)) && ( +
+ {basicBehaviorTiles && basicBehaviorTiles.length > 0 && ( +
+ {basicBehaviorTiles.map((t) => renderBehaviorTile(t, "column"))} +
+ )} + {clearTiles && clearTiles.tiles.length > 0 && ( +
+ {clearTiles.tiles.map((tile) => + renderClearKeycap(tile, "basic") + )} +
+ )}
- ))} + )}
) : category === NUMPAD_TAB ? (
@@ -923,7 +1002,7 @@ const HidUsageGrid = ({ aria-label={section.heading} className="flex flex-wrap gap-1" > - {section.tiles.map(renderBehaviorTile)} + {section.tiles.map((t) => renderBehaviorTile(t))}
))} @@ -943,8 +1022,10 @@ export const HidUsagePicker = ({ disabled, clearTiles, extraTabs, + basicBehaviorTiles, activeBinding, onSelectBinding, + keyOnly, inline, search, onSearchChange, @@ -1082,8 +1163,10 @@ export const HidUsagePicker = ({ usagePages={usagePages} clearTiles={clearTiles} extraTabs={extraTabs} + basicBehaviorTiles={basicBehaviorTiles} activeBinding={activeBinding} onSelectBinding={onSelectBinding} + keyOnly={keyOnly} onActiveTabChange={setActiveTabName} inline={inline} search={search} diff --git a/src/behaviors/SlotBindingPicker.tsx b/src/behaviors/SlotBindingPicker.tsx index 8c98668c..90b1aed1 100644 --- a/src/behaviors/SlotBindingPicker.tsx +++ b/src/behaviors/SlotBindingPicker.tsx @@ -9,7 +9,7 @@ import { Search } from "lucide-react"; import { BehaviorBinding } from "@zmkfirmware/zmk-studio-ts-client/keymap"; import { ClearTilesConfig, HidUsagePicker } from "./HidUsagePicker"; -import { BindingTileTab } from "./tiles"; +import { BehaviorTile, BindingTileTab } from "./tiles"; import { bindingToSlots, CORE_LABELS, @@ -42,6 +42,19 @@ export interface SlotBindingPickerProps { * (issue #16, Step 3). Selecting one calls {@link onBindingChanged}. */ behaviorTabs?: BindingTileTab[]; + /** + * One-tap "flavor" behaviors (Caps Word / Key Repeat / Grave-Escape) shown as + * a column inside the Normal grid's Basic tab — picked the same way a key is + * (issue #16, Step 3b). Selecting one calls {@link onBindingChanged}. + */ + basicBehaviorTiles?: BehaviorTile[]; + /** + * Key-state latch behaviors (Sticky Key / Key Toggle) the firmware exposes. + * Shown as toggles below Send With: they wrap the Normal key (`&kp K` → `&sk K` + * / `&kt K`), so the affected key is still picked from the normal grid + * (issue #16, Step 3b). Empty/omitted when the firmware exposes neither. + */ + keyStateWrappers?: { id: number; label: string }[]; /** * 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 @@ -101,17 +114,31 @@ export const SlotBindingPicker = ({ onBindingChanged, clearTiles, behaviorTabs, + basicBehaviorTiles, + keyStateWrappers, currentBehaviorName, bindingCode, }: SlotBindingPickerProps) => { const slots = bindingToSlots(binding, registry) ?? EMPTY_SLOTS; const layout = useHostLayout(); + // Sticky Key / Key Toggle "latch" the Normal key: the binding is the wrapper + // behavior with the key as its single param. They sit outside the core slot + // model, so detect them by id and read/write the key directly. + const currentWrapper = + (keyStateWrappers ?? []).find((w) => w.id === binding.behaviorId) ?? null; + // The Normal key being edited, whichever shape currently holds it: the wrapper + // param, or the core slot's tap (kp param1, mt/lt tap key). + const normalKey = currentWrapper + ? binding.param1 || undefined + : slots.tap.kind === "key" + ? slots.tap.usage + : undefined; + // 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"; // Normal is always shown — every key has a Normal action, and None is a value @@ -141,11 +168,23 @@ export const SlotBindingPicker = ({ if (derived) onBindingChanged(derived); }; - const setNormalKey = (usage?: number) => + // Picking a key from the grid sets the Normal key. When a latch (Sticky Key / + // Key Toggle) is active, the key is the wrapper's param so the latch is kept; + // otherwise it flows through the core slot model (→ &kp / &mt / ...). + const setNormalKey = (usage?: number) => { + if (currentWrapper) { + onBindingChanged({ + behaviorId: currentWrapper.id, + param1: usage ?? 0, + param2: 0, + }); + return; + } writeSlots({ ...slots, tap: usage === undefined ? { kind: "empty" } : { kind: "key", usage }, }); + }; const setHold = (hold: HoldSlot) => writeSlots({ ...slots, hold }); @@ -165,6 +204,20 @@ export const SlotBindingPicker = ({ setHold(usage === 0 ? { kind: "empty" } : { kind: "mod", usage }); }; + // Toggling a key-state latch (Sticky Key / Key Toggle) wraps or unwraps the + // current Normal key. Re-toggling the active latch unwraps back to a plain + // &kp (or &none if no key is set yet); the latches are mutually exclusive. + const toggleWrapper = (w: { id: number; label: string }) => { + if (currentWrapper?.id === w.id) { + writeSlots({ + tap: normalKey !== undefined ? { kind: "key", usage: normalKey } : { kind: "empty" }, + hold: { kind: "empty" }, + }); + } else { + onBindingChanged({ behaviorId: w.id, param1: normalKey ?? 0, param2: 0 }); + } + }; + const derivedName = (() => { const core = registry.coreById.get(binding.behaviorId); if (core) return CORE_LABELS[core]; @@ -202,19 +255,64 @@ export const SlotBindingPicker = ({ {bindingCode} )} + + {/* Key-state latch (Sticky Key / Key Toggle) wraps the Normal key (→ &sk + / &kt); it's rare and mutually exclusive with Hold mode, so it lives + here as a compact control rather than in the slot surface. The key is + still picked from the grid below. Disabled while Hold mode is on. */} + {keyStateWrappers && keyStateWrappers.length > 0 && ( +
+ + Latch + + {keyStateWrappers.map((w) => { + const active = currentWrapper?.id === w.id; + const disabled = holdOpen && !active; + return ( + + ); + })} +
+ )}
{/* Normal (always shown — the key's base action) and an optional Hold mode (a different action when held). Only Hold mode has an on/off toggle: a plain &kp reads as Normal-only, a &mo as Hold-mode-only. */}
+ {/* Hold mode adds a different action when held (→ &mt / < / &mo). A + key-state latch (Sticky Key / Key Toggle) has no hold half, so the + toggle is disabled while a latch is active. */}
-
@@ -302,8 +402,14 @@ export const SlotBindingPicker = ({
Normal - {tapFilled ? ( - {tapLabel(slots.tap, layout)} + {normalKey !== undefined ? ( + + {tapLabel({ kind: "key", usage: normalKey }, layout)} + + ) : currentWrapper ? ( + + Pick the key to {currentWrapper.label.toLowerCase()}. + ) : ( The key sent on a normal press. )} @@ -322,13 +428,15 @@ export const SlotBindingPicker = ({ { expect(isTileable(behavior(11, "Reset", []))).toBe(true); }); }); + +describe("isParamTileable (key-state latch behaviors: Sticky Key / Key Toggle)", () => { + const sk = behavior(2, "Sticky Key", [ + { param1: [{ name: "key", hidUsage: HID }], param2: [] }, + ]); + const kt = behavior(5, "Key Toggle", [ + { param1: [{ name: "key", hidUsage: HID }], param2: [] }, + ]); + + it("is true for a single-key-param behavior", () => { + expect(isParamTileable(sk)).toBe(true); + expect(isParamTileable(kt)).toBe(true); + }); + + it("is false for a parameterless behavior (Caps Word / Reset)", () => { + expect(isParamTileable(behavior(3, "Caps Word", [{ param1: [], param2: [] }]))).toBe(false); + expect(isParamTileable(behavior(11, "Reset", []))).toBe(false); + }); + + it("is false for a layer param (To Layer)", () => { + const toLayer = behavior(8, "To Layer", [ + { param1: [{ name: "layer", layerId: {} }], param2: [] }, + ]); + expect(isParamTileable(toLayer)).toBe(false); + }); + + it("is shape-based: Key Press shares the lone-key shape and reports true", () => { + // Key Press has the same single-HID-key shape as Sticky Key, so the shape + // test alone can't tell them apart — callers must gate the slot-model core + // behaviors (Key Press is edited via the slots, not as a latch). + const keyPress = behavior(1, "Key Press", [ + { param1: [{ name: "key", hidUsage: HID }], param2: [] }, + ]); + expect(isParamTileable(keyPress)).toBe(true); + }); + + it("is false for a two-param hold-tap (Mod-Tap): not a lone key param", () => { + const mt = behavior(10, "Mod-Tap", [ + { + param1: [{ name: "mod", hidUsage: HID }], + param2: [{ name: "tap", hidUsage: HID }], + }, + ]); + expect(isParamTileable(mt)).toBe(false); + }); +}); diff --git a/src/behaviors/tiles.ts b/src/behaviors/tiles.ts index e0552d95..cb32e3ce 100644 --- a/src/behaviors/tiles.ts +++ b/src/behaviors/tiles.ts @@ -129,3 +129,18 @@ export function tileBindingsFor( export function isTileable(behavior: GetBehaviorDetailsResponse): boolean { return tileBindingsFor(behavior) !== null; } + +/** + * Whether a behavior takes exactly one HID-key parameter and nothing else + * (Sticky Key, Key Toggle). Such a behavior wraps a single key, so the slot + * surface offers it as a *key-state latch* applied to the Normal key (design + * §3c) rather than a tile. Note Key Press shares this shape; callers that want + * only the latch behaviors must exclude the slot-model core behaviors. + */ +export function isParamTileable(behavior: GetBehaviorDetailsResponse): boolean { + const sets = behavior.metadata; + if (!sets || sets.length !== 1) return false; + const p1 = sets[0].param1 ?? []; + const p2 = sets[0].param2 ?? []; + return p1.length === 1 && !!p1[0].hidUsage && p2.length === 0; +} diff --git a/src/keymap-parser.ts b/src/keymap-parser.ts index 755e70d2..84fdf10b 100644 --- a/src/keymap-parser.ts +++ b/src/keymap-parser.ts @@ -118,9 +118,10 @@ const behaviorAliases: Record = { bootloader: "Bootloader", reset: "Reset", soft_off: "Soft Off", + kt: "Key Toggle", caps_word: "Caps Word", key_repeat: "Key Repeat", - gresc: "Grave Escape", + gresc: "Grave/Escape", studio_unlock: "Studio Unlock", }; diff --git a/src/mock/fixtures.ts b/src/mock/fixtures.ts index a698bbcb..c1734802 100644 --- a/src/mock/fixtures.ts +++ b/src/mock/fixtures.ts @@ -70,12 +70,18 @@ const B = { mouseKeyPress: 17, mouseMove: 18, mouseScroll: 19, + keyToggle: 20, + keyRepeat: 21, + graveEscape: 22, } as const; const BEHAVIORS: GetBehaviorDetailsResponse[] = [ { id: B.keyPress, displayName: "Key Press", metadata: [{ param1: [hidParam("key")], param2: [] }] }, { id: B.stickyKey, displayName: "Sticky Key", metadata: [{ param1: [hidParam("key")], param2: [] }] }, + { id: B.keyToggle, displayName: "Key Toggle", metadata: [{ param1: [hidParam("key")], param2: [] }] }, { id: B.capsWord, displayName: "Caps Word", metadata: [] }, + { id: B.keyRepeat, displayName: "Key Repeat", metadata: [] }, + { id: B.graveEscape, displayName: "Grave/Escape", metadata: [] }, { id: B.transparent, displayName: "Transparent", metadata: [] }, { id: B.none, displayName: "None", metadata: [] }, { id: B.momentaryLayer, displayName: "Momentary Layer", metadata: [{ param1: [layerParam("layer")], param2: [] }] }, From 2e7b10b469b9a9fedd8ba32561e4d4b9f910e31f Mon Sep 17 00:00:00 2001 From: numachang Date: Mon, 8 Jun 2026 00:01:16 +0900 Subject: [PATCH 09/22] fix(behaviors): memoize Basic-tile predicates to clear exhaustive-deps warnings PR #32 review: isBasicFinishedTile / isBasicWrapper were used in the grouping useMemos without being listed as deps, leaving three bare react-hooks/exhaustive-deps warnings (inconsistent with the file's other deliberately-suppressed hook). Wrap both in useCallback([hiddenIds]) and list them in the dependent memos so the rule is satisfied without suppression and the memos still only recompute when hiddenIds changes. Co-Authored-By: Claude --- src/behaviors/BehaviorBindingPicker.tsx | 31 ++++++++++++++++--------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/behaviors/BehaviorBindingPicker.tsx b/src/behaviors/BehaviorBindingPicker.tsx index 39855869..65bdd18a 100644 --- a/src/behaviors/BehaviorBindingPicker.tsx +++ b/src/behaviors/BehaviorBindingPicker.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { GetBehaviorDetailsResponse } from "@zmkfirmware/zmk-studio-ts-client/behaviors"; import { BehaviorBinding } from "@zmkfirmware/zmk-studio-ts-client/keymap"; @@ -197,13 +197,22 @@ export const BehaviorBindingPicker = ({ // single-key ones (Sticky Key / Key Toggle) are key-state latches on the slot // surface. (Key Press also has the single-key shape but is a hidden core // behavior, so hiddenIds already excludes it.) - const isBasicFinishedTile = (b: GetBehaviorDetailsResponse) => - classifyBehavior(b) === "Basic" && !hiddenIds.has(b.id) && isTileable(b); - const isBasicWrapper = (b: GetBehaviorDetailsResponse) => - classifyBehavior(b) === "Basic" && - !hiddenIds.has(b.id) && - !isTileable(b) && - isParamTileable(b); + // Memoized so they're stable deps for the grouping memos below (they only + // change with hiddenIds), keeping react-hooks/exhaustive-deps satisfied + // without re-running the memos every render. + const isBasicFinishedTile = useCallback( + (b: GetBehaviorDetailsResponse) => + classifyBehavior(b) === "Basic" && !hiddenIds.has(b.id) && isTileable(b), + [hiddenIds] + ); + const isBasicWrapper = useCallback( + (b: GetBehaviorDetailsResponse) => + classifyBehavior(b) === "Basic" && + !hiddenIds.has(b.id) && + !isTileable(b) && + isParamTileable(b), + [hiddenIds] + ); // 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 @@ -220,7 +229,7 @@ export const BehaviorBindingPicker = ({ out[group].push(b); } return out; - }, [sortedBehaviors, hiddenIds]); + }, [sortedBehaviors, hiddenIds, isBasicFinishedTile, isBasicWrapper]); // The whole-binding behavior tabs for the Normal strip: Mouse, then a // consolidated System (Bluetooth / Output / External Power / System), each a @@ -253,7 +262,7 @@ export const BehaviorBindingPicker = ({ if (isBasicFinishedTile(b)) out.push(...(tileBindingsFor(b) ?? [])); } return out; - }, [sortedBehaviors, hiddenIds]); + }, [sortedBehaviors, isBasicFinishedTile]); // Key-state latch behaviors (Sticky Key / Key Toggle) the firmware exposes, // offered as toggles on the slot surface that wrap the Normal key. @@ -263,7 +272,7 @@ export const BehaviorBindingPicker = ({ if (isBasicWrapper(b)) out.push({ id: b.id, label: b.displayName }); } return out; - }, [sortedBehaviors, hiddenIds]); + }, [sortedBehaviors, isBasicWrapper]); const currentBehavior = behaviors.find((b) => b.id === behaviorId); const currentGroup = currentBehavior From ed24e8c67fdc892755abde1b11e9280efb9b296e Mon Sep 17 00:00:00 2001 From: numachang Date: Mon, 8 Jun 2026 00:09:09 +0900 Subject: [PATCH 10/22] =?UTF-8?q?docs(design):=20sync=20=C2=A73c/=C2=A75?= =?UTF-8?q?=20Step=203b=20to=20the=20as-built=20key-state=20latch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3b shipped &sk/&kt as a key-state latch (a checkbox pair on the slot label that wraps the Normal key), not the originally-planned parameterised tile. Update §3c, the §5 Step 3b plan, the status block, and the Japanese mirror to match; note the deviation and the keymap-parser kt/gresc fix. Co-Authored-By: Claude --- docs/design/key-picker-rethink.md | 78 +++++++++++++++++++++++-------- 1 file changed, 58 insertions(+), 20 deletions(-) diff --git a/docs/design/key-picker-rethink.md b/docs/design/key-picker-rethink.md index 1be5a024..9f01a3be 100644 --- a/docs/design/key-picker-rethink.md +++ b/docs/design/key-picker-rethink.md @@ -6,11 +6,15 @@ > 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 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. +> Status: **Step 1 shipped (PR #27); Steps 2, 3a, 3b merged to the integration +> branch** (Step 3b via PR #32). 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. **Step 3b deviated from the +> original "parameterised tile" idea (§3c): `&sk`/`&kt` shipped as a *key-state +> latch* — a checkbox pair on the slot label that wraps the Normal key — which +> fits the "wraps a key, not is a key" nature better than a tile.** Step 4 is +> next. --- @@ -171,13 +175,21 @@ category, but the UI is uniformly "open a tab, press a tile." - **Caps Word (`&caps_word`) / Key Repeat (`&key_repeat`) / Grave-Escape (`&gresc`)** — parameterless single tiles. -### (c) Parameterised tiles — one tile, one parameter +### (c) Key-state latches — wrap the Normal key (`&sk` / `&kt`) `&sk` (sticky key) and `&kt` (key toggle) take a single key/mod parameter, so they are **not** "one tile = one binding": pressing the tile then needs a key chosen. -Model them as a tile that reveals a single-key picker (equivalently, a "tap-only" -degenerate of the slot surface). Calling them tile-select would be a category -error. (See the appendix for what these flavor behaviors actually do.) +The original plan modelled them as a tile that reveals a single-key picker; **as +built (Step 3b) they are instead a *key-state latch*** — the cleaner framing is +that they *wrap* a key rather than *being* a key (like the "+ Send with" implicit +modifiers wrap the Normal key). So they ship as a **checkbox pair on the slot +label** (`Latch: ☐ Sticky Key ☐ Key Toggle`); ticking one turns the current +Normal key into `&sk ` / `&kt `, and the key itself is still picked from +the Normal grid. They are mutually exclusive and disabled while Hold mode is on (a +hold-tap can't also be latched); conversely, while a latch is active the grid is +**key-only** (the whole-binding tabs/tiles grey out, but None / Transparent stay +live). Calling them tile-select would be a category error. (See the appendix for +what these flavor behaviors actually do.) ### (b) Slot-fill — two slots, behavior derived per §2 @@ -447,15 +459,31 @@ Media already folds into the Key Press / Normal grid (page 12), done in Step 2. 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. +**Step 3b — `&sk` / `&kt` key-state latches + Basic flavor tiles (shipped, PR #32).** + +The Basic "flavor" behaviors leave the residual chip area entirely, so the +"More behaviors" Basic group is gone. They split by shape: + +- **`&sk` / `&kt` → key-state latch (§3c).** A `Latch: ☐ Sticky Key ☐ Key Toggle` + checkbox pair on the slot label that wraps the Normal key (`&kp K` → `&sk K` / + `&kt K`); the key is picked from the grid. Mutually exclusive with each other + and with Hold mode; while latched the grid is key-only (Mouse / System tabs and + the one-tap tiles disable, None / Transparent stay live). Lives in + [`SlotBindingPicker.tsx`](../../src/behaviors/SlotBindingPicker.tsx) + (`keyStateWrappers`, `toggleWrapper`, wrapper-aware `setNormalKey`); the grid's + `keyOnly` mode is in [`HidUsagePicker.tsx`](../../src/behaviors/HidUsagePicker.tsx). +- **Caps Word / Key Repeat / Grave-Escape → one-tap tiles.** 1U keycap tiles in a + 2-column block on the Basic tab, with None / Transparent pinned to the bottom + row; labels wrap at the largest font that fits a 1U cap. +- **Supporting:** `isParamTileable` ([`tiles.ts`](../../src/behaviors/tiles.ts)) + detects the single-key-param shape (callers gate `!isCoreBinding`, since Key + Press shares it). [`keymap-parser.ts`](../../src/keymap-parser.ts) gained the + missing `kt` alias and a `gresc` → `Grave/Escape` fix so the slot code label + renders `&kt` / `&gresc`. The demo fixtures expose all three new behaviors. - **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. + a tab/tile/latch renders only for behaviors the firmware exposes. ### Step 4 — layer in the Normal slot (To / Toggle / Sticky) + custom hold-tap @@ -702,12 +730,17 @@ behavior は「実際にいくつ枠を持つか」で形が分かれる。見 - **None / Transparent** — 全タブ常設の「空にする/透過」タイル(= Step 1)。 - **Caps Word / Key Repeat / Grave-Escape** — param なし単発タイル。 -### (c) パラメータ付きタイル — 1 タイル+param 1 つ +### (c) キー状態ラッチ — Normal キーを包む(`&sk` / `&kt`) `&sk`(sticky key)と `&kt`(key toggle)はキー/修飾 param を 1 つ取るので「1 タイル -=完成」では**ない**: タイルを押した後にキー選択が要る。タイルを押すと単一キー -ピッカーが現れる形(=スロット面の「タップのみ」退化形)でモデル化する。タイル選択型 -に入れるのはカテゴリ誤り。(小物 behavior の挙動は付録参照。) +=完成」では**ない**: 当初案は「タイルを押すと単一キーピッカー」だったが、**実装 +(Step 3b)はキー状態ラッチに変更**。これらは「キーである」より「キーを包む」もの +("+ Send with" の暗黙修飾が Normal キーを包むのと同類)なので、**スロットラベル上の +チェックボックス対**(`Latch: ☐ Sticky Key ☐ Key Toggle`)として出す。チェックすると +現在の Normal キーが `&sk ` / `&kt ` になり、キー自体は通常どおりグリッドで +選ぶ。両者は排他、Hold mode 中は無効(hold-tap はラッチ不可)。逆にラッチ中はグリッドが +**キー専用**(whole-binding のタブ/タイルは無効、None / Transparent は生存)。(小物 +behavior の挙動は付録参照。) ### (b) スロット型 — 2 枠、behavior は §2 で派生 @@ -839,7 +872,12 @@ behavior は「実際にいくつ枠を持つか」で形が分かれる。見 したので冗長な「KEY」ラベルを廃し、**検索ボックスを「NORMAL」行へ移設**(節見出しは NORMAL が兼ねる)。`HidUsagePicker` に `inline` + controlled `search`/`onSearchChange`。 ④タイルはキーキャップ風に文字サイズ統一、セクション見出しを `text-sm` に。 - - **3b** — `&sk` / `&kt` をパラメータ付きタイル(shape (c))に。Basic チップ列を畳む。 + - **3b(出荷済み・PR #32)** — `&sk` / `&kt` をキー状態ラッチ(§3c)に。スロット + ラベル上の `Latch` チェックボックス対で Normal キーを包む(`&kp K`→`&sk K`/`&kt K`、 + キーはグリッドで選ぶ)。Hold と排他、ラッチ中はグリッドがキー専用。Caps Word / + Key Repeat / Grave-Escape は Basic タブの単発タイル(2 列、最下段に None/Transparent)。 + 付随して keymap-parser に `kt` 別名追加+`gresc`→`Grave/Escape` 修正、fixtures に 3 + behavior 追加。残置 Basic チップ列は消滅。 - `HidUsagePicker` のグリッド/タイル型を再利用。capability 連動。 - **Step 4(逆引きラベル)** — スロット面に「いまの組み合わせ=◯◯」の控えめ表示、 レイヤーサブモード(to/tog/sl)、カスタム hold-tap の identity 選択(§3b)。 From c883767af4983d6ce27a2a8f486d30b3705bfaa1 Mon Sep 17 00:00:00 2001 From: numachang Date: Tue, 9 Jun 2026 22:24:14 +0900 Subject: [PATCH 11/22] =?UTF-8?q?feat(behaviors):=20Step=204=20=E2=80=94?= =?UTF-8?q?=20persistent=20layer=20switches=20as=20a=20"Layer"=20tab=20(#1?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move To / Toggle / Sticky Layer off the residual chip area onto the Normal grid's tab strip, as a "Layer" tab (between International and Mouse) — the same behavior-tab pattern as Step 3a's Mouse / System. Each sub-mode is a labelled section exploded into one tile per layer; picking a tile sets the whole binding (&to / &tog / &sl ). The reverse-lookup label, tab auto-jump and keyOnly disabling are all reused, so slots.ts is unchanged. - tiles.ts: isLayerTileable / layerTileBindingsFor (unit-tested). - BehaviorBindingPicker: build the Layer tab; drop these from the residual area (with only standard behaviors the residual "More behaviors" is now empty and hidden). - mock fixtures: add Sticky Layer so the demo shows all three sub-modes. - docs/design: record Step 4 as built (Layer tab, deviation from the slot-model framing; custom hold-tap identity deferred to a later step, §8 Q2). tsc + lint clean; vitest 131 passing. Co-Authored-By: Claude --- docs/design/key-picker-rethink.md | 45 +++++++++++++------ src/behaviors/BehaviorBindingPicker.tsx | 58 +++++++++++++++++++------ src/behaviors/tiles.test.ts | 57 +++++++++++++++++++++++- src/behaviors/tiles.ts | 32 ++++++++++++++ src/mock/fixtures.ts | 2 + 5 files changed, 166 insertions(+), 28 deletions(-) diff --git a/docs/design/key-picker-rethink.md b/docs/design/key-picker-rethink.md index 9f01a3be..207ed7b2 100644 --- a/docs/design/key-picker-rethink.md +++ b/docs/design/key-picker-rethink.md @@ -13,8 +13,8 @@ > every standard ZMK behavior is verified in §4a. **Step 3b deviated from the > original "parameterised tile" idea (§3c): `&sk`/`&kt` shipped as a *key-state > latch* — a checkbox pair on the slot label that wraps the Normal key — which -> fits the "wraps a key, not is a key" nature better than a tile.** Step 4 is -> next. +> fits the "wraps a key, not is a key" nature better than a tile.** **Step 4 +> (persistent layer switches as a "Layer" tab) is implemented on its branch (§5).** --- @@ -485,17 +485,30 @@ The Basic "flavor" behaviors leave the residual chip area entirely, so the [`HidUsagePicker.tsx`](../../src/behaviors/HidUsagePicker.tsx). Capability-aware: a tab/tile/latch renders only for behaviors the firmware exposes. -### Step 4 — layer in the Normal slot (To / Toggle / Sticky) + custom hold-tap - -- **Goal:** let the **Normal** slot hold a *layer* (not just a key), which derives - the persistent layer switches — `&to` / `&tog` / `&sl` — with a small sub-mode - pick (they take one layer param and differ only in latch behavior, §2b). Add the - custom-hold-tap identity choice for when more than one hold-tap behavior exists - (§3b, default `&mt`). The reverse-lookup label already ships with Step 2. +### Step 4 — persistent layer switches as a "Layer" tab (shipped) + +- **Goal:** make the persistent layer switches — `&to` / `&tog` / `&sl` — + pickable from the Normal surface instead of the residual chips. +- **As built (deviation from the slot-model framing).** The original plan put a + *layer* in the Normal slot and derived `&to`/`&tog`/`&sl` from a sub-mode pick. + As shipped, they're a **"Layer" tab in the Normal grid's tab strip** (between + *International* and *Mouse*), consistent with the Mouse / System behavior tabs + from Step 3a: one labelled section per sub-mode (To Layer / Toggle Layer / + Sticky Layer), each **exploded into one tile per layer**. Picking a tile sets + the whole binding (`&to ` / …); the reverse-lookup label names it and + the tile highlights, and the tab is `keyOnly`-disabled while a latch is active — + all reused from the existing behavior-tab machinery, so `slots.ts` is unchanged. + [`tiles.ts`](../../src/behaviors/tiles.ts) gains `isLayerTileable` / + `layerTileBindingsFor` (unit-tested); the tab is built in + [`BehaviorBindingPicker.tsx`](../../src/behaviors/BehaviorBindingPicker.tsx). - **Note:** this is the *persistent* layer case; the *momentary* layer (`&mo`) and - Layer-Tap (`<`) live in **Hold mode** and already land in Step 2. A layer in - Normal disables Hold mode (a tap-to-switch has no hold half). -- **Risk:** low-medium — additive sub-mode selector on the Normal slot. + Layer-Tap (`<`) live in **Hold mode** and already landed in Step 2. With the + tab approach a layer binding is non-core (like a Mouse/System tile), so Hold + mode simply reads as off rather than being explicitly disabled. +- **Deferred:** the custom-hold-tap identity choice (when more than one hold-tap + behavior exists, pick `&mt` vs e.g. `homerow_mods`, §3b/§8 Q2) is independent of + the layer work and is left to a later step. +- **Risk:** low — additive tab; reuses the Step 3a behavior-tab patterns. ### Step 5+ — remove the top-level behavior tabs (later, needs its own design) @@ -879,8 +892,12 @@ behavior の挙動は付録参照。) 付随して keymap-parser に `kt` 別名追加+`gresc`→`Grave/Escape` 修正、fixtures に 3 behavior 追加。残置 Basic チップ列は消滅。 - `HidUsagePicker` のグリッド/タイル型を再利用。capability 連動。 -- **Step 4(逆引きラベル)** — スロット面に「いまの組み合わせ=◯◯」の控えめ表示、 - レイヤーサブモード(to/tog/sl)、カスタム hold-tap の identity 選択(§3b)。 +- **Step 4(実装済み)** — 持続レイヤー切替 `&to`/`&tog`/`&sl` を、当初の「Normal + スロットにレイヤー」案ではなく **Normal グリッドのタブ列の「Layer」タブ**として実装 + (Mouse/System タブと同型)。To/Toggle/Sticky の各セクションをレイヤーごとのタイルに + 展開し、タイル選択で binding 全体を設定(逆引きラベル・タブ自動ジャンプ・keyOnly + 無効化は既存機構を再利用、`slots.ts` は無変更)。カスタム hold-tap の identity 選択は + 独立課題として後続に延期。逆引きラベルは Step 2 で出荷済み。 - **Step 5 以降(要別設計)** — tier/カテゴリのタブ階層を撤去し「(a)+(b)+(c)」へ。 Step 1-4 で方式が実用検証されてから別途設計。 diff --git a/src/behaviors/BehaviorBindingPicker.tsx b/src/behaviors/BehaviorBindingPicker.tsx index 65bdd18a..881fda5f 100644 --- a/src/behaviors/BehaviorBindingPicker.tsx +++ b/src/behaviors/BehaviorBindingPicker.tsx @@ -8,9 +8,12 @@ import { formatBinding, validateBinding } from "./parameters"; import { bindingToSlots, buildDerivationRegistry } from "./slots"; import { BehaviorTile, + BindingTileSection, BindingTileTab, + isLayerTileable, isParamTileable, isTileable, + layerTileBindingsFor, tileBindingsFor, } from "./tiles"; @@ -214,6 +217,18 @@ export const BehaviorBindingPicker = ({ [hiddenIds] ); + // Persistent layer switches (To / Toggle / Sticky Layer) move from the residual + // chips onto the Normal grid's "Layer" tab as per-layer tiles (issue #16, + // Step 4). Momentary Layer / Layer-Tap share the layer-param shape but are + // slot-model cores, so hiddenIds already excludes them. + const isLayerTabBehavior = useCallback( + (b: GetBehaviorDetailsResponse) => + classifyBehavior(b) === "Layer" && + !hiddenIds.has(b.id) && + isLayerTileable(b), + [hiddenIds] + ); + // 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 @@ -223,36 +238,53 @@ export const BehaviorBindingPicker = ({ for (const b of sortedBehaviors) { if (hiddenIds.has(b.id)) continue; if (isStripBehavior(b)) continue; - if (isBasicFinishedTile(b) || isBasicWrapper(b)) continue; + if (isBasicFinishedTile(b) || isBasicWrapper(b) || isLayerTabBehavior(b)) + continue; const group = classifyBehavior(b); if (!out[group]) out[group] = []; out[group].push(b); } return out; - }, [sortedBehaviors, hiddenIds, isBasicFinishedTile, isBasicWrapper]); - - // 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). + }, [ + sortedBehaviors, + hiddenIds, + isBasicFinishedTile, + isBasicWrapper, + isLayerTabBehavior, + ]); + + // The whole-binding behavior tabs for the Normal strip: a Layer tab (To / + // Toggle / Sticky Layer, one tile per layer), then 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: [], }; + const layerSections: BindingTileSection[] = []; 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) ?? [], - }); + if (hiddenIds.has(b.id)) continue; + if (isStripBehavior(b)) { + const tab = STRIP_GROUPS[classifyBehavior(b)]; + sections[tab].push({ + heading: b.displayName, + tiles: tileBindingsFor(b) ?? [], + }); + } else if (isLayerTabBehavior(b)) { + layerSections.push({ + heading: b.displayName, + tiles: layerTileBindingsFor(b, layers) ?? [], + }); + } } const tabs: BindingTileTab[] = []; + if (layerSections.length) tabs.push({ name: "Layer", sections: layerSections }); 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]); + }, [sortedBehaviors, hiddenIds, isLayerTabBehavior, layers]); // The Basic-tab one-tap flavor tiles (Caps Word / Key Repeat / Grave-Escape), // one finished binding each. Capability-aware — only firmware-exposed ones. diff --git a/src/behaviors/tiles.test.ts b/src/behaviors/tiles.test.ts index ba2c8996..68c14125 100644 --- a/src/behaviors/tiles.test.ts +++ b/src/behaviors/tiles.test.ts @@ -2,7 +2,13 @@ import { describe, expect, it } from "vitest"; import type { GetBehaviorDetailsResponse } from "@zmkfirmware/zmk-studio-ts-client/behaviors"; -import { isParamTileable, isTileable, tileBindingsFor } from "./tiles"; +import { + isLayerTileable, + isParamTileable, + isTileable, + layerTileBindingsFor, + tileBindingsFor, +} from "./tiles"; const HID = { keyboardMax: 0xffff, consumerMax: 0xffff }; @@ -155,3 +161,52 @@ describe("isParamTileable (key-state latch behaviors: Sticky Key / Key Toggle)", expect(isParamTileable(mt)).toBe(false); }); }); + +describe("isLayerTileable / layerTileBindingsFor (persistent layer switches)", () => { + const toLayer = behavior(8, "To Layer", [ + { param1: [{ name: "layer", layerId: {} }], param2: [] }, + ]); + const layers = [ + { id: 0, name: "Base" }, + { id: 1, name: "Fn" }, + ]; + + it("is true for a single-layer-param behavior, false for a key param", () => { + expect(isLayerTileable(toLayer)).toBe(true); + expect( + isLayerTileable( + behavior(2, "Sticky Key", [ + { param1: [{ name: "key", hidUsage: HID }], param2: [] }, + ]) + ) + ).toBe(false); + }); + + it("is false for Layer-Tap (a layer param plus a tap key)", () => { + const lt = behavior(7, "Layer-Tap", [ + { + param1: [{ name: "layer", layerId: {} }], + param2: [{ name: "tap", hidUsage: HID }], + }, + ]); + expect(isLayerTileable(lt)).toBe(false); + }); + + it("explodes into one tile per layer, labelled by layer name", () => { + expect(layerTileBindingsFor(toLayer, layers)).toEqual([ + { label: "Base", binding: { behaviorId: 8, param1: 0, param2: 0 } }, + { label: "Fn", binding: { behaviorId: 8, param1: 1, param2: 0 } }, + ]); + }); + + it("falls back to the layer index when a layer has no name", () => { + const tiles = layerTileBindingsFor(toLayer, [{ id: 5, name: "" }]); + expect(tiles).toEqual([ + { label: "0", binding: { behaviorId: 8, param1: 5, param2: 0 } }, + ]); + }); + + it("returns null for a non-layer behavior", () => { + expect(layerTileBindingsFor(behavior(11, "Reset", []), layers)).toBeNull(); + }); +}); diff --git a/src/behaviors/tiles.ts b/src/behaviors/tiles.ts index cb32e3ce..4b82cc01 100644 --- a/src/behaviors/tiles.ts +++ b/src/behaviors/tiles.ts @@ -144,3 +144,35 @@ export function isParamTileable(behavior: GetBehaviorDetailsResponse): boolean { const p2 = sets[0].param2 ?? []; return p1.length === 1 && !!p1[0].hidUsage && p2.length === 0; } + +/** + * Whether a behavior takes exactly one layer parameter and nothing else: the + * persistent layer switches To / Toggle / Sticky Layer (also Momentary Layer, + * which the caller excludes as a slot-model core). Such a behavior is a finished + * binding once a layer is chosen, so it's exploded into one tile per layer + * (issue #16, Step 4 — the Normal grid's "Layer" tab). + */ +export function isLayerTileable(behavior: GetBehaviorDetailsResponse): boolean { + const sets = behavior.metadata; + if (!sets || sets.length !== 1) return false; + const p1 = sets[0].param1 ?? []; + const p2 = sets[0].param2 ?? []; + return p1.length === 1 && !!p1[0].layerId && p2.length === 0; +} + +/** + * Expand a single-layer-param behavior into one finished tile per layer, or + * `null` when it isn't that shape. The tile label is the layer's name (falling + * back to its index); the section heading names the behavior (To / Toggle / + * Sticky Layer), so a bare layer name reads unambiguously. + */ +export function layerTileBindingsFor( + behavior: GetBehaviorDetailsResponse, + layers: { id: number; name: string }[] +): BehaviorTile[] | null { + if (!isLayerTileable(behavior)) return null; + return layers.map((l, i) => ({ + label: l.name || i.toString(), + binding: { behaviorId: behavior.id, param1: l.id, param2: 0 }, + })); +} diff --git a/src/mock/fixtures.ts b/src/mock/fixtures.ts index c1734802..77506bc3 100644 --- a/src/mock/fixtures.ts +++ b/src/mock/fixtures.ts @@ -73,6 +73,7 @@ const B = { keyToggle: 20, keyRepeat: 21, graveEscape: 22, + stickyLayer: 23, } as const; const BEHAVIORS: GetBehaviorDetailsResponse[] = [ @@ -88,6 +89,7 @@ const BEHAVIORS: GetBehaviorDetailsResponse[] = [ { id: B.layerTap, displayName: "Layer-Tap", metadata: [{ param1: [layerParam("layer")], param2: [hidParam("tap")] }] }, { id: B.toLayer, displayName: "To Layer", metadata: [{ param1: [layerParam("layer")], param2: [] }] }, { id: B.toggleLayer, displayName: "Toggle Layer", metadata: [{ param1: [layerParam("layer")], param2: [] }] }, + { id: B.stickyLayer, displayName: "Sticky Layer", metadata: [{ param1: [layerParam("layer")], param2: [] }] }, { 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: [] }, From 9c151c83237f7a4dcae07d8aa535800ca3565a0b Mon Sep 17 00:00:00 2001 From: numachang Date: Tue, 9 Jun 2026 22:50:21 +0900 Subject: [PATCH 12/22] refactor(behaviors): unify Sticky/Toggle latch across key and layer families (#16) Step 4 follow-up: &sl/&tog are the layer analogue of &sk/&kt, so fold the Layer tab's Toggle/Sticky sections into the existing latch instead of duplicating a per-layer tile grid three times. - The Layer tab is now a single "To Layer" section; Sticky/Toggle are applied via the latch. - The latch checkboxes are a generic "Sticky / Toggle" pair and family-aware: a key binding maps them to &sk/&kt over &kp, a layer binding to &sl/&tog over &to. toggleLatch swaps the behavior id and carries param1; a key-latch unwrap keeps the &kp/&none distinction via the slot model. - BindingTileTab.matchFamily lets &sl/&tog highlight their layer's To-Layer tile and jump to the Layer tab. - keyOnly applies only to a key latch; a layer latch leaves the Layer tab live. tsc + lint clean; vitest 131 passing. Co-Authored-By: Claude --- docs/design/key-picker-rethink.md | 72 ++++++----- src/behaviors/BehaviorBindingPicker.tsx | 90 ++++++++----- src/behaviors/HidUsagePicker.tsx | 25 +++- src/behaviors/SlotBindingPicker.tsx | 160 ++++++++++++++++-------- src/behaviors/tiles.ts | 8 ++ 5 files changed, 231 insertions(+), 124 deletions(-) diff --git a/docs/design/key-picker-rethink.md b/docs/design/key-picker-rethink.md index 207ed7b2..8afb1ea1 100644 --- a/docs/design/key-picker-rethink.md +++ b/docs/design/key-picker-rethink.md @@ -180,16 +180,20 @@ category, but the UI is uniformly "open a tab, press a tile." `&sk` (sticky key) and `&kt` (key toggle) take a single key/mod parameter, so they are **not** "one tile = one binding": pressing the tile then needs a key chosen. The original plan modelled them as a tile that reveals a single-key picker; **as -built (Step 3b) they are instead a *key-state latch*** — the cleaner framing is -that they *wrap* a key rather than *being* a key (like the "+ Send with" implicit -modifiers wrap the Normal key). So they ship as a **checkbox pair on the slot -label** (`Latch: ☐ Sticky Key ☐ Key Toggle`); ticking one turns the current -Normal key into `&sk ` / `&kt `, and the key itself is still picked from -the Normal grid. They are mutually exclusive and disabled while Hold mode is on (a -hold-tap can't also be latched); conversely, while a latch is active the grid is -**key-only** (the whole-binding tabs/tiles grey out, but None / Transparent stay -live). Calling them tile-select would be a category error. (See the appendix for -what these flavor behaviors actually do.) +built they are instead a *latch*** — the cleaner framing is that they *wrap* the +current binding rather than *being* a key (like the "+ Send with" implicit +modifiers wrap the Normal key). So they ship as a **generic `Latch: ☐ Sticky +☐ Toggle` checkbox pair on the slot label**. The same pair is **family-aware** +(Step 4): on a key it maps to `&sk`/`&kt` over a base `&kp`; on a layer it maps to +`&sl`/`&tog` over a base `&to` — `&sk`/`&kt` are to a key exactly what `&sl`/`&tog` +are to a layer. Ticking carries the bound value (the key, or the layer) in +`param1`; the value itself is still picked from the Normal grid (key) or the Layer +tab (layer). The two are mutually exclusive and disabled while Hold mode is on (a +hold-tap can't also be latched). For a *key* latch the grid goes **key-only** +(whole-binding tabs/tiles grey out, but None / Transparent stay live); a *layer* +latch leaves the Layer tab live so the layer can be re-picked. Calling these +tile-select would be a category error. (See the appendix for what these flavor +behaviors actually do.) ### (b) Slot-fill — two slots, behavior derived per §2 @@ -491,24 +495,31 @@ The Basic "flavor" behaviors leave the residual chip area entirely, so the pickable from the Normal surface instead of the residual chips. - **As built (deviation from the slot-model framing).** The original plan put a *layer* in the Normal slot and derived `&to`/`&tog`/`&sl` from a sub-mode pick. - As shipped, they're a **"Layer" tab in the Normal grid's tab strip** (between - *International* and *Mouse*), consistent with the Mouse / System behavior tabs - from Step 3a: one labelled section per sub-mode (To Layer / Toggle Layer / - Sticky Layer), each **exploded into one tile per layer**. Picking a tile sets - the whole binding (`&to ` / …); the reverse-lookup label names it and - the tile highlights, and the tab is `keyOnly`-disabled while a latch is active — - all reused from the existing behavior-tab machinery, so `slots.ts` is unchanged. - [`tiles.ts`](../../src/behaviors/tiles.ts) gains `isLayerTileable` / - `layerTileBindingsFor` (unit-tested); the tab is built in - [`BehaviorBindingPicker.tsx`](../../src/behaviors/BehaviorBindingPicker.tsx). + As shipped: a **"Layer" tab in the Normal grid's tab strip** (between + *International* and *Mouse*), consistent with Step 3a's Mouse / System tabs, with + **a single "To Layer" section exploded into one tile per layer**. The Sticky / + Toggle sub-modes are **not** separate tile sections — they reuse the **latch** + (§3c): `&sl`/`&tog` are the layer family of the same Sticky / Toggle checkboxes + that give keys `&sk`/`&kt`. So you pick a layer (→ `&to `) then optionally + tick Sticky / Toggle (→ `&sl`/`&tog `), exactly mirroring keys. +- **How the pieces fit (all reuse, `slots.ts` unchanged):** + - [`tiles.ts`](../../src/behaviors/tiles.ts): `isLayerTileable` / + `layerTileBindingsFor` build the To-Layer tiles; `BindingTileTab.matchFamily` + lets `&sl`/`&tog` highlight their layer's tile and jump to the Layer tab even + though the tile binding is `&to`. (Unit-tested.) + - [`SlotBindingPicker.tsx`](../../src/behaviors/SlotBindingPicker.tsx): the latch + is family-generic — `keyLatch` / `layerLatch` (base/sticky/toggle ids, + resolved by displayName in + [`BehaviorBindingPicker.tsx`](../../src/behaviors/BehaviorBindingPicker.tsx)), + selected by the current binding's family; toggling swaps the behavior id and + carries `param1`. - **Note:** this is the *persistent* layer case; the *momentary* layer (`&mo`) and - Layer-Tap (`<`) live in **Hold mode** and already landed in Step 2. With the - tab approach a layer binding is non-core (like a Mouse/System tile), so Hold - mode simply reads as off rather than being explicitly disabled. + Layer-Tap (`<`) live in **Hold mode** and already landed in Step 2. Hold mode + is disabled while latched (a latch has no hold). - **Deferred:** the custom-hold-tap identity choice (when more than one hold-tap behavior exists, pick `&mt` vs e.g. `homerow_mods`, §3b/§8 Q2) is independent of the layer work and is left to a later step. -- **Risk:** low — additive tab; reuses the Step 3a behavior-tab patterns. +- **Risk:** low — additive tab + a small generalization of the existing latch. ### Step 5+ — remove the top-level behavior tabs (later, needs its own design) @@ -892,12 +903,13 @@ behavior の挙動は付録参照。) 付随して keymap-parser に `kt` 別名追加+`gresc`→`Grave/Escape` 修正、fixtures に 3 behavior 追加。残置 Basic チップ列は消滅。 - `HidUsagePicker` のグリッド/タイル型を再利用。capability 連動。 -- **Step 4(実装済み)** — 持続レイヤー切替 `&to`/`&tog`/`&sl` を、当初の「Normal - スロットにレイヤー」案ではなく **Normal グリッドのタブ列の「Layer」タブ**として実装 - (Mouse/System タブと同型)。To/Toggle/Sticky の各セクションをレイヤーごとのタイルに - 展開し、タイル選択で binding 全体を設定(逆引きラベル・タブ自動ジャンプ・keyOnly - 無効化は既存機構を再利用、`slots.ts` は無変更)。カスタム hold-tap の identity 選択は - 独立課題として後続に延期。逆引きラベルは Step 2 で出荷済み。 +- **Step 4(実装済み)** — 持続レイヤー切替を **Normal グリッドの「Layer」タブ**として + 実装(Mouse/System と同型)。ただしタブは **「To Layer」1セクション**(レイヤーごとの + タイル)だけで、Sticky/Toggle は**ラッチに統合**:`&sl`/`&tog` はキーの `&sk`/`&kt` の + レイヤー版で、同じ汎用 `Latch: ☐ Sticky ☐ Toggle` が現在の family(key/layer)で + `&sk/&kt` か `&sl/&tog` に対応。レイヤーを選ぶ→`&to`、Sticky/Toggle で `&sl`/`&tog`。 + `matchFamily` で `&sl/&tog` でも該当レイヤータイルがハイライト&タブジャンプ。 + `slots.ts` は無変更。カスタム hold-tap の identity 選択は後続に延期。 - **Step 5 以降(要別設計)** — tier/カテゴリのタブ階層を撤去し「(a)+(b)+(c)」へ。 Step 1-4 で方式が実用検証されてから別途設計。 diff --git a/src/behaviors/BehaviorBindingPicker.tsx b/src/behaviors/BehaviorBindingPicker.tsx index 881fda5f..19c0f046 100644 --- a/src/behaviors/BehaviorBindingPicker.tsx +++ b/src/behaviors/BehaviorBindingPicker.tsx @@ -3,12 +3,11 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { GetBehaviorDetailsResponse } from "@zmkfirmware/zmk-studio-ts-client/behaviors"; import { BehaviorBinding } from "@zmkfirmware/zmk-studio-ts-client/keymap"; import { BehaviorParametersPicker } from "./BehaviorParametersPicker"; -import { SlotBindingPicker } from "./SlotBindingPicker"; +import { LatchConfig, SlotBindingPicker } from "./SlotBindingPicker"; import { formatBinding, validateBinding } from "./parameters"; import { bindingToSlots, buildDerivationRegistry } from "./slots"; import { BehaviorTile, - BindingTileSection, BindingTileTab, isLayerTileable, isParamTileable, @@ -253,38 +252,72 @@ export const BehaviorBindingPicker = ({ isLayerTabBehavior, ]); - // The whole-binding behavior tabs for the Normal strip: a Layer tab (To / - // Toggle / Sticky Layer, one tile per layer), then Mouse, then a consolidated - // System (Bluetooth / Output / External Power / System), each a run of labelled - // keycap-tile sections (one per source behavior). + // The slot surface offers one generic "Sticky / Toggle" latch that maps to the + // key family (&sk / &kt over a base &kp) or the layer family (&sl / &tog over a + // base &to) depending on the current binding. Resolve each behavior id by + // displayName (capability-aware — undefined when the firmware omits it). + const behaviorIdByName = useMemo(() => { + const m = new Map(); + for (const b of behaviors) m.set(b.displayName, b.id); + return m; + }, [behaviors]); + const keyLatch = useMemo( + () => ({ + base: registry.idByCore.kp, + sticky: behaviorIdByName.get("Sticky Key"), + toggle: behaviorIdByName.get("Key Toggle"), + }), + [registry, behaviorIdByName] + ); + const layerLatch = useMemo( + () => ({ + base: behaviorIdByName.get("To Layer"), + sticky: behaviorIdByName.get("Sticky Layer"), + toggle: behaviorIdByName.get("Toggle Layer"), + }), + [behaviorIdByName] + ); + + // The whole-binding behavior tabs for the Normal strip: a Layer tab (To Layer, + // one tile per layer — Sticky / Toggle Layer are applied via the latch, not as + // tiles), then Mouse, then a consolidated System (Bluetooth / Output / External + // Power / System), each a run of labelled keycap-tile sections. const behaviorTabs = useMemo(() => { const sections: Record<"Mouse" | "System", BindingTileTab["sections"]> = { Mouse: [], System: [], }; - const layerSections: BindingTileSection[] = []; for (const b of sortedBehaviors) { - if (hiddenIds.has(b.id)) continue; - if (isStripBehavior(b)) { - const tab = STRIP_GROUPS[classifyBehavior(b)]; - sections[tab].push({ - heading: b.displayName, - tiles: tileBindingsFor(b) ?? [], - }); - } else if (isLayerTabBehavior(b)) { - layerSections.push({ - heading: b.displayName, - tiles: layerTileBindingsFor(b, layers) ?? [], - }); - } + 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 (layerSections.length) tabs.push({ name: "Layer", sections: layerSections }); + // Layer tab: the To Layer base, exploded per layer. matchFamily lets &sl / + // &tog (same layer family) highlight their layer's tile and jump here too. + const toLayer = sortedBehaviors.find( + (b) => !hiddenIds.has(b.id) && b.displayName === "To Layer" + ); + if (toLayer) { + const matchFamily = [layerLatch.base, layerLatch.sticky, layerLatch.toggle].filter( + (x): x is number => x !== undefined + ); + tabs.push({ + name: "Layer", + sections: [ + { heading: "To Layer", tiles: layerTileBindingsFor(toLayer, layers) ?? [] }, + ], + matchFamily, + }); + } 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, isLayerTabBehavior, layers]); + }, [sortedBehaviors, hiddenIds, layers, layerLatch]); // The Basic-tab one-tap flavor tiles (Caps Word / Key Repeat / Grave-Escape), // one finished binding each. Capability-aware — only firmware-exposed ones. @@ -296,16 +329,6 @@ export const BehaviorBindingPicker = ({ return out; }, [sortedBehaviors, isBasicFinishedTile]); - // Key-state latch behaviors (Sticky Key / Key Toggle) the firmware exposes, - // offered as toggles on the slot surface that wrap the Normal key. - const keyStateWrappers = useMemo(() => { - const out: { id: number; label: string }[] = []; - for (const b of sortedBehaviors) { - if (isBasicWrapper(b)) out.push({ id: b.id, label: b.displayName }); - } - return out; - }, [sortedBehaviors, isBasicWrapper]); - const currentBehavior = behaviors.find((b) => b.id === behaviorId); const currentGroup = currentBehavior ? classifyBehavior(currentBehavior) @@ -495,7 +518,8 @@ export const BehaviorBindingPicker = ({ clearTiles={clearTiles} behaviorTabs={behaviorTabs} basicBehaviorTiles={basicBehaviorTiles} - keyStateWrappers={keyStateWrappers} + keyLatch={keyLatch} + layerLatch={layerLatch} currentBehaviorName={currentBehavior?.displayName} bindingCode={formatBinding( currentBinding, diff --git a/src/behaviors/HidUsagePicker.tsx b/src/behaviors/HidUsagePicker.tsx index c96f3f40..897f1cc1 100644 --- a/src/behaviors/HidUsagePicker.tsx +++ b/src/behaviors/HidUsagePicker.tsx @@ -596,9 +596,11 @@ const HidUsageGrid = ({ } } for (const t of extraTabs ?? []) { - const hit = t.sections.some((s) => - s.tiles.some((tile) => sameBinding(tile.binding, activeBinding)) - ); + const hit = + t.matchFamily?.includes(activeBinding.behaviorId) || + t.sections.some((s) => + s.tiles.some((tile) => sameBinding(tile.binding, activeBinding)) + ); if (hit) { setActiveTab(t.name); return; @@ -732,9 +734,18 @@ const HidUsageGrid = ({ // fully visible, so a native `title` replaces the keycode grid's hover tooltip. const renderBehaviorTile = ( tile: BehaviorTile, - variant: "tab" | "column" = "tab" + variant: "tab" | "column" = "tab", + matchFamily?: number[] ) => { - const active = !!activeBinding && sameBinding(tile.binding, activeBinding); + // Highlight on an exact match, or — for a tab with a matchFamily (Layer) — + // when the active binding is in that family and shares this tile's param + // (e.g. &sl Fn / &tog Fn light up the To-Layer "Fn" tile). + const active = + !!activeBinding && + (sameBinding(tile.binding, activeBinding) || + (!!matchFamily && + matchFamily.includes(activeBinding.behaviorId) && + tile.binding.param1 === activeBinding.param1)); const onPress = () => onSelectBinding?.(tile.binding); // A whole-binding tile can't be picked while the grid is key-only (latched). const disabled = keyOnly; @@ -1002,7 +1013,9 @@ const HidUsageGrid = ({ aria-label={section.heading} className="flex flex-wrap gap-1" > - {section.tiles.map((t) => renderBehaviorTile(t))} + {section.tiles.map((t) => + renderBehaviorTile(t, "tab", tab.matchFamily) + )}
))} diff --git a/src/behaviors/SlotBindingPicker.tsx b/src/behaviors/SlotBindingPicker.tsx index 90b1aed1..c43f46f1 100644 --- a/src/behaviors/SlotBindingPicker.tsx +++ b/src/behaviors/SlotBindingPicker.tsx @@ -25,6 +25,17 @@ import { hid_usage_get_label, hid_usage_page_and_id_from_usage } from "../hid-us import { useHostLayout } from "../layouts/LayoutContext"; import type { HostLayout } from "../layouts"; +/** + * The behavior ids for one latch "family": a `base` (plain) the latch wraps into + * a `sticky` (one-shot) or `toggle` (latched) variant — `&kp`/`&sk`/`&kt` for + * keys, `&to`/`&sl`/`&tog` for layers. Each id is optional (capability-aware). + */ +export interface LatchConfig { + base?: number; + sticky?: number; + toggle?: number; +} + export interface SlotBindingPickerProps { binding: BehaviorBinding; registry: DerivationRegistry; @@ -49,12 +60,18 @@ export interface SlotBindingPickerProps { */ basicBehaviorTiles?: BehaviorTile[]; /** - * Key-state latch behaviors (Sticky Key / Key Toggle) the firmware exposes. - * Shown as toggles below Send With: they wrap the Normal key (`&kp K` → `&sk K` - * / `&kt K`), so the affected key is still picked from the normal grid - * (issue #16, Step 3b). Empty/omitted when the firmware exposes neither. + * The key-family latch ids: a base (`&kp`) the latch wraps into Sticky (`&sk`) + * or Toggle (`&kt`). Shown as a generic "Sticky / Toggle" pair on the slot + * label when the current binding is a key (issue #16, Steps 3b/4). */ - keyStateWrappers?: { id: number; label: string }[]; + keyLatch?: LatchConfig; + /** + * The layer-family latch ids: a base (`&to`) wrapped into Sticky (`&sl`) or + * Toggle (`&tog`). The *same* "Sticky / Toggle" pair maps here instead when the + * current binding is a layer switch — `&sk`/`&kt` are to a key what `&sl`/`&tog` + * are to a layer (issue #16, Step 4). + */ + layerLatch?: LatchConfig; /** * 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 @@ -115,25 +132,41 @@ export const SlotBindingPicker = ({ clearTiles, behaviorTabs, basicBehaviorTiles, - keyStateWrappers, + keyLatch, + layerLatch, currentBehaviorName, bindingCode, }: SlotBindingPickerProps) => { const slots = bindingToSlots(binding, registry) ?? EMPTY_SLOTS; const layout = useHostLayout(); - // Sticky Key / Key Toggle "latch" the Normal key: the binding is the wrapper - // behavior with the key as its single param. They sit outside the core slot - // model, so detect them by id and read/write the key directly. - const currentWrapper = - (keyStateWrappers ?? []).find((w) => w.id === binding.behaviorId) ?? null; - // The Normal key being edited, whichever shape currently holds it: the wrapper - // param, or the core slot's tap (kp param1, mt/lt tap key). - const normalKey = currentWrapper - ? binding.param1 || undefined - : slots.tap.kind === "key" - ? slots.tap.usage - : undefined; + // One generic Sticky / Toggle latch maps to whichever family the current + // binding belongs to: layer (&to/&sl/&tog) when the bound id is in the layer + // latch, else key (&kp/&sk/&kt). The wrapped value is always `param1` (the key + // or the layer), so toggling just swaps the behavior id and carries param1. + const inFamily = (latch: LatchConfig | undefined, id: number) => + !!latch && + (id === latch.base || id === latch.sticky || id === latch.toggle); + const isLayerFamily = inFamily(layerLatch, binding.behaviorId); + const activeLatch = isLayerFamily ? layerLatch! : keyLatch ?? {}; + const isSticky = + activeLatch.sticky !== undefined && binding.behaviorId === activeLatch.sticky; + const isToggle = + activeLatch.toggle !== undefined && binding.behaviorId === activeLatch.toggle; + const isLatched = isSticky || isToggle; + // A key latch makes the Normal grid the picker for the latched key; a layer + // latch's value lives in the Layer tab, so the grid stays empty for it. + const keyLatched = !isLayerFamily && isLatched; + + // The Normal key being edited (key family only): the latched key's param, or + // the core slot's tap (kp param1, mt/lt tap key). Layer family has no Normal key. + const normalKey = isLayerFamily + ? undefined + : keyLatched + ? binding.param1 || undefined + : slots.tap.kind === "key" + ? slots.tap.usage + : undefined; // 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. @@ -168,13 +201,15 @@ export const SlotBindingPicker = ({ if (derived) onBindingChanged(derived); }; - // Picking a key from the grid sets the Normal key. When a latch (Sticky Key / - // Key Toggle) is active, the key is the wrapper's param so the latch is kept; - // otherwise it flows through the core slot model (→ &kp / &mt / ...). + // Picking a key from the grid sets the Normal key. When a *key* latch is active + // the key is the latch behavior's param so the latch is kept; otherwise it + // flows through the core slot model (→ &kp / &mt / ...). (A layer latch's value + // is the layer, picked from the Layer tab, so the grid still writes a plain key + // — a deliberate "switch back to a key" escape.) const setNormalKey = (usage?: number) => { - if (currentWrapper) { + if (keyLatched) { onBindingChanged({ - behaviorId: currentWrapper.id, + behaviorId: binding.behaviorId, param1: usage ?? 0, param2: 0, }); @@ -204,19 +239,38 @@ export const SlotBindingPicker = ({ setHold(usage === 0 ? { kind: "empty" } : { kind: "mod", usage }); }; - // Toggling a key-state latch (Sticky Key / Key Toggle) wraps or unwraps the - // current Normal key. Re-toggling the active latch unwraps back to a plain - // &kp (or &none if no key is set yet); the latches are mutually exclusive. - const toggleWrapper = (w: { id: number; label: string }) => { - if (currentWrapper?.id === w.id) { + // The latched value (the key or layer) rides along in param1 across the whole + // family, so toggling just swaps the behavior id and keeps param1. + const latchParam = binding.param1; + // Unwrap to the family base: a layer goes straight to &to ; a key goes + // through the slot model so an empty key collapses to &none rather than &kp 0. + const unwrapLatch = () => { + if (isLayerFamily && activeLatch.base !== undefined) { + onBindingChanged({ behaviorId: activeLatch.base, param1: latchParam, param2: 0 }); + } else { writeSlots({ - tap: normalKey !== undefined ? { kind: "key", usage: normalKey } : { kind: "empty" }, + tap: latchParam ? { kind: "key", usage: latchParam } : { kind: "empty" }, hold: { kind: "empty" }, }); - } else { - onBindingChanged({ behaviorId: w.id, param1: normalKey ?? 0, param2: 0 }); } }; + // Toggling Sticky / Toggle wraps the current binding into the matching family + // variant, or unwraps to the base when it's already active (mutually exclusive). + const toggleLatch = (variant: "sticky" | "toggle") => { + const active = variant === "sticky" ? isSticky : isToggle; + const targetId = variant === "sticky" ? activeLatch.sticky : activeLatch.toggle; + if (active) unwrapLatch(); + else if (targetId !== undefined) + onBindingChanged({ behaviorId: targetId, param1: latchParam, param2: 0 }); + }; + + // The generic Sticky / Toggle checkboxes — only those the current family + // actually exposes (capability-aware). + const latchOptions: { variant: "sticky" | "toggle"; label: string; active: boolean }[] = []; + if (activeLatch.sticky !== undefined) + latchOptions.push({ variant: "sticky", label: "Sticky", active: isSticky }); + if (activeLatch.toggle !== undefined) + latchOptions.push({ variant: "toggle", label: "Toggle", active: isToggle }); const derivedName = (() => { const core = registry.coreById.get(binding.behaviorId); @@ -256,37 +310,35 @@ export const SlotBindingPicker = ({ )} - {/* Key-state latch (Sticky Key / Key Toggle) wraps the Normal key (→ &sk - / &kt); it's rare and mutually exclusive with Hold mode, so it lives - here as a compact control rather than in the slot surface. The key is - still picked from the grid below. Disabled while Hold mode is on. */} - {keyStateWrappers && keyStateWrappers.length > 0 && ( + {/* Generic Sticky / Toggle latch: wraps the current binding into its + family variant (key → &sk/&kt, layer → &sl/&tog) keeping the param. + Mutually exclusive with Hold mode, so disabled while a hold is set. */} + {latchOptions.length > 0 && (
Latch - {keyStateWrappers.map((w) => { - const active = currentWrapper?.id === w.id; - const disabled = holdOpen && !active; + {latchOptions.map((o) => { + const disabled = holdOpen && !o.active; return ( ); })} @@ -299,19 +351,19 @@ export const SlotBindingPicker = ({ plain &kp reads as Normal-only, a &mo as Hold-mode-only. */}
{/* Hold mode adds a different action when held (→ &mt / < / &mo). A - key-state latch (Sticky Key / Key Toggle) has no hold half, so the - toggle is disabled while a latch is active. */} + latched binding (Sticky / Toggle) has no hold half, so the toggle is + disabled while a latch is active. */}
@@ -1041,7 +1020,7 @@ export const HidUsagePicker = ({ disabled, clearTiles, extraTabs, - basicBehaviorTiles, + specialTiles, activeBinding, onSelectBinding, keyOnly, @@ -1182,7 +1161,7 @@ export const HidUsagePicker = ({ usagePages={usagePages} clearTiles={clearTiles} extraTabs={extraTabs} - basicBehaviorTiles={basicBehaviorTiles} + specialTiles={specialTiles} activeBinding={activeBinding} onSelectBinding={onSelectBinding} keyOnly={keyOnly} diff --git a/src/behaviors/SlotBindingPicker.tsx b/src/behaviors/SlotBindingPicker.tsx index 859be203..c679ca3c 100644 --- a/src/behaviors/SlotBindingPicker.tsx +++ b/src/behaviors/SlotBindingPicker.tsx @@ -54,11 +54,11 @@ export interface SlotBindingPickerProps { */ behaviorTabs?: BindingTileTab[]; /** - * One-tap "flavor" behaviors (Caps Word / Key Repeat / Grave-Escape) shown as - * a column inside the Normal grid's Basic tab — picked the same way a key is - * (issue #16, Step 3b). Selecting one calls {@link onBindingChanged}. + * Rarely-used one-tap "flavor" behaviors (Caps Word / Key Repeat / Grave- + * Escape) shown in the Normal grid's Apps/Media/Special tab (issue #16). + * Selecting one calls {@link onBindingChanged}. */ - basicBehaviorTiles?: BehaviorTile[]; + specialTiles?: BehaviorTile[]; /** * The key-family latch ids: a base (`&kp`) the latch wraps into Sticky (`&sk`) * or Toggle (`&kt`). Shown as a generic "Sticky / Toggle" pair on the slot @@ -131,7 +131,7 @@ export const SlotBindingPicker = ({ onBindingChanged, clearTiles, behaviorTabs, - basicBehaviorTiles, + specialTiles, keyLatch, layerLatch, currentBehaviorName, @@ -497,7 +497,7 @@ export const SlotBindingPicker = ({ onValueChanged={setNormalKey} clearTiles={clearTiles} extraTabs={behaviorTabs} - basicBehaviorTiles={basicBehaviorTiles} + specialTiles={specialTiles} activeBinding={binding} onSelectBinding={handleSelectBinding} keyOnly={keyLatched} From e4f882e827f38c3ec046d3a92322bcd7049bcd5a Mon Sep 17 00:00:00 2001 From: numachang Date: Wed, 10 Jun 2026 15:51:37 +0900 Subject: [PATCH 18/22] =?UTF-8?q?feat(behaviors):=20Step=205=20=E2=80=94?= =?UTF-8?q?=20drain=20the=20residual=20area=20into=20shaped=20homes=20(#16?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route every firmware-exposed behavior to a shaped home so the residual "More behaviors" area empties for ordinary keyboards: - Custom hold-taps move to Hold mode with an identity selector: the &mt shape (mod, key) in the Modifier sub-mode, the < shape (layer, key) in Layer. When a family has more than one member the surface offers the choice (default &mt / <); it shows before a modifier/layer is picked and the choice is held locally so it can precede the value (§8 Q2). - Custom tile-able behaviors gather in a new "Custom" tab; recognized standards keep their curated homes (Apps/Media/Special / System / Mouse), unrecognized customs go to Custom. - UI polish on the same surface: None/Transparent share the flavor row (no stray wrap), and the single-section Layer tab drops its heading. slots.ts gains modTapIds/layerTapIds and a shared Slots.holdTapId; the residual block still auto-hides when empty (drain, not delete) so the exotic custom that fits no shape stays settable (§4a). Co-Authored-By: Claude --- src/behaviors/BehaviorBindingPicker.tsx | 116 +++++++++++++----- src/behaviors/HidUsagePicker.tsx | 54 ++++++--- src/behaviors/SlotBindingPicker.tsx | 81 ++++++++++++- src/behaviors/slots.test.ts | 149 ++++++++++++++++++++++++ src/behaviors/slots.ts | 100 +++++++++++++++- src/behaviors/tiles.test.ts | 19 +++ src/behaviors/tiles.ts | 24 +++- 7 files changed, 489 insertions(+), 54 deletions(-) diff --git a/src/behaviors/BehaviorBindingPicker.tsx b/src/behaviors/BehaviorBindingPicker.tsx index 174e6a08..14cacbe9 100644 --- a/src/behaviors/BehaviorBindingPicker.tsx +++ b/src/behaviors/BehaviorBindingPicker.tsx @@ -10,6 +10,7 @@ import { BehaviorTile, BindingTileTab, isLayerTileable, + isParameterless, isParamTileable, isTileable, layerTileBindingsFor, @@ -125,6 +126,13 @@ function classifyBehavior(b: GetBehaviorDetailsResponse): string { return "Other"; } +// 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. Pure (classify + tile shape), +// so it lives at module scope and stays a stable reference for the memos. +function isStripBehavior(b: GetBehaviorDetailsResponse): boolean { + return isTileable(b) && STRIP_GROUPS[classifyBehavior(b)] !== undefined; +} + export const BehaviorBindingPicker = ({ binding, layers, @@ -182,29 +190,40 @@ export const BehaviorBindingPicker = ({ [behaviors] ); - // 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; - + // Behaviors edited on the slot surface are hidden from the residual area: the + // four cores (via derivedChipIds), the clear tiles, and now any custom hold-tap + // — the `&mt` shape (mod-tap family) and the `<` shape (layer-tap family) — + // routed into Hold mode with an identity choice (issue #16 §8 Q2). `&mt`/`<` + // themselves are already cores. const hiddenIds = useMemo( () => - new Set([...clearBehaviors.map((b) => b.id), ...derivedChipIds]), - [clearBehaviors, derivedChipIds] + new Set([ + ...clearBehaviors.map((b) => b.id), + ...derivedChipIds, + ...registry.modTapIds, + ...registry.layerTapIds, + ]), + [clearBehaviors, derivedChipIds, registry] ); - // The Basic "flavor" behaviors leave the residual chip area (issue #16, Step - // 3b). They split by shape: the parameterless ones (Caps Word / Key Repeat / - // Grave-Escape) are one-tap finished tiles in the Normal grid's Basic tab; the - // single-key ones (Sticky Key / Key Toggle) are key-state latches on the slot - // surface. (Key Press also has the single-key shape but is a hidden core - // behavior, so hiddenIds already excludes it.) - // Memoized so they're stable deps for the grouping memos below (they only - // change with hiddenIds), keeping react-hooks/exhaustive-deps satisfied - // without re-running the memos every render. - const isBasicFinishedTile = useCallback( + // The "flavor" behaviors leave the residual chip area (issue #16). They split + // by shape: the single-key ones (Sticky Key / Key Toggle) are key-state latches + // on the slot surface (isBasicWrapper); the parameterless ones are one-tap + // tiles (isSpecialTile). (Key Press also has the single-key shape but is a + // hidden core behavior, so hiddenIds already excludes it.) Both are memoized so + // they're stable deps for the grouping memos below — only changing with + // hiddenIds — keeping react-hooks/exhaustive-deps satisfied. + // + // isSpecialTile: a *recognized standard* parameterless flavor behavior (Caps + // Word / Key Repeat / Grave-Escape — the Basic group). They render in the + // Apps/Media/Special tab. Custom (unrecognized) behaviors don't land here even + // when parameterless — they go to the Custom tab (isCustomTileBehavior), so + // "Special" stays the curated standard set and "Custom" holds your own. + const isSpecialTile = useCallback( (b: GetBehaviorDetailsResponse) => - classifyBehavior(b) === "Basic" && !hiddenIds.has(b.id) && isTileable(b), + classifyBehavior(b) === "Basic" && + !hiddenIds.has(b.id) && + isParameterless(b), [hiddenIds] ); const isBasicWrapper = useCallback( @@ -228,6 +247,21 @@ export const BehaviorBindingPicker = ({ [hiddenIds] ); + // A custom (firmware-extension) tile-able behavior that we don't recognize as a + // standard — classified "Other" (not a standard group, not a mouse/hold-tap + // shape). Covers both a parameterless custom (e.g. Num Word) and a custom enum/ + // range (e.g. an LED-mode selector): all of *your* tile-able behaviors gather in + // one "Custom" tab in the Normal grid's strip, rather than the residual area + // (issue #16 — draining the residual escape hatch). Hold-tap-shaped customs go + // to Hold mode instead (by interaction shape), so they're not here. + const isCustomTileBehavior = useCallback( + (b: GetBehaviorDetailsResponse) => + classifyBehavior(b) === "Other" && + !hiddenIds.has(b.id) && + isTileable(b), + [hiddenIds] + ); + // 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 @@ -237,7 +271,12 @@ export const BehaviorBindingPicker = ({ for (const b of sortedBehaviors) { if (hiddenIds.has(b.id)) continue; if (isStripBehavior(b)) continue; - if (isBasicFinishedTile(b) || isBasicWrapper(b) || isLayerTabBehavior(b)) + if ( + isSpecialTile(b) || + isBasicWrapper(b) || + isLayerTabBehavior(b) || + isCustomTileBehavior(b) + ) continue; const group = classifyBehavior(b); if (!out[group]) out[group] = []; @@ -247,9 +286,10 @@ export const BehaviorBindingPicker = ({ }, [ sortedBehaviors, hiddenIds, - isBasicFinishedTile, + isSpecialTile, isBasicWrapper, isLayerTabBehavior, + isCustomTileBehavior, ]); // The slot surface offers one generic "Sticky / Toggle" latch that maps to the @@ -278,6 +318,22 @@ export const BehaviorBindingPicker = ({ [behaviorIdByName] ); + // The hold-tap identity choices for the Hold-mode surface: the standard `&mt` / + // `<` plus any custom hold-taps of the same shape (issue #16 §8 Q2). The slot + // surface shows a selector only when a family has more than one. + const nameById = useCallback( + (id: number) => behaviors.find((b) => b.id === id)?.displayName ?? "", + [behaviors] + ); + const modTapOptions = useMemo( + () => registry.modTapIds.map((id) => ({ id, name: nameById(id) })), + [registry, nameById] + ); + const layerTapOptions = useMemo( + () => registry.layerTapIds.map((id) => ({ id, name: nameById(id) })), + [registry, nameById] + ); + // The whole-binding behavior tabs for the Normal strip: a Layer tab (To Layer, // one tile per layer — Sticky / Toggle Layer are applied via the latch, not as // tiles), then Mouse, then a consolidated System (Bluetooth / Output / External @@ -307,17 +363,23 @@ export const BehaviorBindingPicker = ({ ); tabs.push({ name: "Layer", - sections: [ - { heading: "To Layer", tiles: layerTileBindingsFor(toLayer, layers) ?? [] }, - ], + // No section heading: the latch (Sticky / Toggle), not a label, decides + // To / Sticky / Toggle, so "To Layer" would misread as the only option. + sections: [{ tiles: layerTileBindingsFor(toLayer, layers) ?? [] }], matchFamily, }); } if (sections.Mouse.length) tabs.push({ name: "Mouse", sections: sections.Mouse }); if (sections.System.length) tabs.push({ name: "System", sections: sections.System }); + // Custom tab: firmware-extension enum/range behaviors that fit no known strip + // group, one labelled section each — so they leave the residual area too. + const custom = sortedBehaviors + .filter(isCustomTileBehavior) + .map((b) => ({ heading: b.displayName, tiles: tileBindingsFor(b) ?? [] })); + if (custom.length) tabs.push({ name: "Custom", sections: custom }); return tabs; - }, [sortedBehaviors, hiddenIds, layers, layerLatch]); + }, [sortedBehaviors, hiddenIds, layers, layerLatch, isCustomTileBehavior]); // The rarely-used one-tap flavor tiles (Caps Word / Key Repeat / Grave-Escape), // one finished binding each, appended into the Apps/Media/Special tab. @@ -325,10 +387,10 @@ export const BehaviorBindingPicker = ({ const specialTiles = useMemo(() => { const out: BehaviorTile[] = []; for (const b of sortedBehaviors) { - if (isBasicFinishedTile(b)) out.push(...(tileBindingsFor(b) ?? [])); + if (isSpecialTile(b)) out.push(...(tileBindingsFor(b) ?? [])); } return out; - }, [sortedBehaviors, isBasicFinishedTile]); + }, [sortedBehaviors, isSpecialTile]); const currentBehavior = behaviors.find((b) => b.id === behaviorId); const currentGroup = currentBehavior @@ -521,6 +583,8 @@ export const BehaviorBindingPicker = ({ specialTiles={specialTiles} keyLatch={keyLatch} layerLatch={layerLatch} + modTapOptions={modTapOptions} + layerTapOptions={layerTapOptions} currentBehaviorName={currentBehavior?.displayName} bindingCode={formatBinding( currentBinding, diff --git a/src/behaviors/HidUsagePicker.tsx b/src/behaviors/HidUsagePicker.tsx index bde102ca..bd124650 100644 --- a/src/behaviors/HidUsagePicker.tsx +++ b/src/behaviors/HidUsagePicker.tsx @@ -657,7 +657,7 @@ const HidUsageGrid = ({ 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(); + const haystack = `${tile.label} ${section.heading ?? ""} ${tab.name}`.toLowerCase(); if (haystack.includes(trimmedSearch)) out.push(tile); } } @@ -963,19 +963,30 @@ const HidUsageGrid = ({ <> {categorizedUsages[category].map(renderUsageButton)} {/* The rarely-used flavor behaviors (Caps Word / Key Repeat / - Grave-Escape) live here, on their own row after the media keys. */} - {category === APPS_MEDIA_TAB && - specialTiles && - specialTiles.length > 0 && ( -
- {specialTiles.map((t) => renderBehaviorTile(t))} + Grave-Escape) get their own row after the media keys, and the + None / Transparent clear caps share that row's trailing space + so they don't wrap onto a line of their own. The flavor tiles + stay in their own radiogroup (they're radios; the clear caps + are toggle buttons). */} + {category === APPS_MEDIA_TAB ? ( + ((specialTiles && specialTiles.length > 0) || + trailingClearKeycapsUsage) && ( +
+ {specialTiles && specialTiles.length > 0 && ( +
+ {specialTiles.map((t) => renderBehaviorTile(t))} +
+ )} + {trailingClearKeycapsUsage}
- )} - {trailingClearKeycapsUsage} + ) + ) : ( + trailingClearKeycapsUsage + )} )} @@ -988,14 +999,19 @@ const HidUsageGrid = ({ id={tab.name} className="min-h-[17rem] max-h-[17rem] overflow-y-auto flex flex-col gap-3 p-1 border border-t-0 rounded-b rac-focus-visible:ring-2 rac-focus-visible:ring-primary" > - {tab.sections.map((section) => ( -
- - {section.heading} - + {tab.sections.map((section, i) => ( +
+ {section.heading && ( + + {section.heading} + + )}
{section.tiles.map((t) => diff --git a/src/behaviors/SlotBindingPicker.tsx b/src/behaviors/SlotBindingPicker.tsx index c679ca3c..c1c3550a 100644 --- a/src/behaviors/SlotBindingPicker.tsx +++ b/src/behaviors/SlotBindingPicker.tsx @@ -72,6 +72,19 @@ export interface SlotBindingPickerProps { * are to a layer (issue #16, Step 4). */ layerLatch?: LatchConfig; + /** + * The Mod-Tap identity choices: `&mt` plus any custom hold-taps of the same + * shape (e.g. `homerow_mods`). When more than one exists, the Hold-mode + * Modifier panel shows a selector so a `key + mod` fill can derive to the + * chosen behavior (issue #16 §8 Q2). First entry is the default (`&mt`). + */ + modTapOptions?: { id: number; name: string }[]; + /** + * The Layer-Tap identity choices: `<` plus any custom layer-taps. Same as + * {@link modTapOptions} but for the Hold-mode Layer sub-mode (a `key + layer` + * fill). First entry is the default (`<`). + */ + layerTapOptions?: { id: number; name: string }[]; /** * 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 @@ -134,6 +147,8 @@ export const SlotBindingPicker = ({ specialTiles, keyLatch, layerLatch, + modTapOptions, + layerTapOptions, currentBehaviorName, bindingCode, }: SlotBindingPickerProps) => { @@ -196,8 +211,19 @@ export const SlotBindingPicker = ({ if (holdKind !== "empty") setHoldMode(holdKind); }, [holdKind]); + // The chosen Mod-Tap identity (`&mt` or a custom hold-tap). Kept as local state + // so it can be picked *before* a modifier exists — a plain `&kp` can't carry the + // identity, so we hold it here and inject it on derive (a key+mod fill becomes + // the chosen behavior). Reseeded from the binding whenever the bound key changes. + const [holdTapId, setHoldTapId] = useState(slots.holdTapId); + useEffect(() => { + setHoldTapId(slots.holdTapId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [binding.behaviorId, binding.param1, binding.param2]); + const writeSlots = (next: Slots) => { - const derived = deriveBinding(next, registry); + // Inject the chosen identity so a key+mod fill derives to it (default `&mt`). + const derived = deriveBinding({ ...next, holdTapId }, registry); if (derived) onBindingChanged(derived); }; @@ -239,6 +265,30 @@ export const SlotBindingPicker = ({ setHold(usage === 0 ? { kind: "empty" } : { kind: "mod", usage }); }; + // The hold-tap identity selector: `&mt` vs a custom mod-tap (Modifier sub-mode), + // or `<` vs a custom layer-tap (Layer sub-mode). Shown whenever the user is + // composing a hold-tap — a Normal key set and a hold sub-mode — even before a + // value is picked, so it's discoverable (the choice applies the moment a + // modifier/layer is added). Only shown when there's a choice to make. + const identityOptions = holdMode === "mod" ? modTapOptions : layerTapOptions; + const showHoldTapIdentity = + slots.tap.kind === "key" && (identityOptions?.length ?? 0) > 1; + // A held identity from the *other* family (a stale id after switching sub-mode) + // isn't in this list, so fall back to the default (first) entry. + const activeHoldTapId = + identityOptions?.some((o) => o.id === holdTapId) + ? holdTapId + : identityOptions?.[0]?.id; + const chooseHoldTap = (id: number) => { + setHoldTapId(id); + // If it's already a real hold-tap (a modifier/layer is set), re-derive now; + // otherwise the choice waits in local state and applies when one is added. + if (slots.hold.kind !== "empty") { + const derived = deriveBinding({ ...slots, holdTapId: id }, registry); + if (derived) onBindingChanged(derived); + } + }; + // The latched value (the key or layer) rides along in param1 across the whole // family, so toggling just swaps the behavior id and keeps param1. const latchParam = binding.param1; @@ -455,6 +505,35 @@ export const SlotBindingPicker = ({ })}
)} + + {/* Hold-tap identity: which behavior a key+hold fill becomes when the + keymap defines custom hold-taps alongside the standard &mt / < + (Mod-Tap in the Modifier sub-mode, Layer-Tap in Layer; §8 Q2). */} + {showHoldTapIdentity && ( +
+ + Hold-tap + +
+ {identityOptions!.map((o) => ( + + ))} +
+
+ )} ) : (

diff --git a/src/behaviors/slots.test.ts b/src/behaviors/slots.test.ts index a5c9f7af..c741d217 100644 --- a/src/behaviors/slots.test.ts +++ b/src/behaviors/slots.test.ts @@ -173,6 +173,155 @@ describe("bindingToSlots", () => { }); }); +describe("custom hold-tap identity (mod-tap family, §8 Q2)", () => { + // A keymap-author-defined hold-tap of the same shape as &mt (mod param, then + // key param), e.g. homerow_mods. It is not a standard behavior. + const HRM_ID = 99; + const withHrm = [ + ...behaviors, + { + id: HRM_ID, + displayName: "Home Row Mods", + metadata: [ + { + param1: [{ name: "mod", hidUsage: { keyboardMax: 0xffff, consumerMax: 0xffff } }], + param2: [{ name: "tap", hidUsage: { keyboardMax: 0xffff, consumerMax: 0xffff } }], + }, + ], + }, + ]; + const regHrm = buildDerivationRegistry(withHrm); + + it("lists the mod-tap family with the standard &mt first", () => { + expect(regHrm.modTapIds).toEqual([MT, HRM_ID]); + }); + + it("a key+mod fill defaults to &mt when no identity is chosen", () => { + const slots: Slots = { + tap: { kind: "key", usage: KEY_A }, + hold: { kind: "mod", usage: LSHIFT }, + }; + expect(deriveBinding(slots, regHrm)).toEqual({ + behaviorId: MT, + param1: LSHIFT, + param2: KEY_A, + }); + }); + + it("derives the custom hold-tap when its identity is chosen", () => { + const slots: Slots = { + tap: { kind: "key", usage: KEY_A }, + hold: { kind: "mod", usage: LSHIFT }, + holdTapId: HRM_ID, + }; + expect(deriveBinding(slots, regHrm)).toEqual({ + behaviorId: HRM_ID, + param1: LSHIFT, + param2: KEY_A, + }); + }); + + it("decomposes a custom hold-tap into slots carrying its identity", () => { + expect( + bindingToSlots({ behaviorId: HRM_ID, param1: LCTRL, param2: KEY_A }, regHrm) + ).toEqual({ + tap: { kind: "key", usage: KEY_A }, + hold: { kind: "mod", usage: LCTRL }, + holdTapId: HRM_ID, + }); + }); + + it("round-trips a custom hold-tap binding → slots → binding", () => { + const binding = { behaviorId: HRM_ID, param1: LS(LCTRL), param2: KEY_ENTER }; + const slots = bindingToSlots(binding, regHrm); + expect(slots).not.toBeNull(); + expect(deriveBinding(slots!, regHrm)).toEqual(binding); + }); + + it("ignores a holdTapId the firmware doesn't expose (falls back to &mt)", () => { + const slots: Slots = { + tap: { kind: "key", usage: KEY_A }, + hold: { kind: "mod", usage: LSHIFT }, + holdTapId: HRM_ID, // not present in the standard-only registry + }; + expect(deriveBinding(slots, reg)).toEqual({ + behaviorId: MT, + param1: LSHIFT, + param2: KEY_A, + }); + }); +}); + +describe("custom layer-tap identity (layer-tap family, §8 Q2)", () => { + // A keymap-author-defined hold-tap of the < shape (layer param, then key). + const BLT_ID = 98; + const withBlt = [ + ...behaviors, + { + id: BLT_ID, + displayName: "Balanced LT", + metadata: [ + { param1: [{ name: "layer", layerId: {} }], param2: [{ name: "tap", hidUsage: { keyboardMax: 0xffff, consumerMax: 0xffff } }] }, + ], + }, + ]; + const regBlt = buildDerivationRegistry(withBlt); + + it("lists the layer-tap family with the standard < first", () => { + expect(regBlt.layerTapIds).toEqual([LT, BLT_ID]); + }); + + it("a key+layer fill defaults to < when no identity is chosen", () => { + const slots: Slots = { + tap: { kind: "key", usage: KEY_SPACE }, + hold: { kind: "layer", layerId: FN_LAYER }, + }; + expect(deriveBinding(slots, regBlt)).toEqual({ + behaviorId: LT, + param1: FN_LAYER, + param2: KEY_SPACE, + }); + }); + + it("derives the custom layer-tap when its identity is chosen", () => { + const slots: Slots = { + tap: { kind: "key", usage: KEY_SPACE }, + hold: { kind: "layer", layerId: FN_LAYER }, + holdTapId: BLT_ID, + }; + expect(deriveBinding(slots, regBlt)).toEqual({ + behaviorId: BLT_ID, + param1: FN_LAYER, + param2: KEY_SPACE, + }); + }); + + it("round-trips a custom layer-tap binding → slots → binding", () => { + const binding = { behaviorId: BLT_ID, param1: FN_LAYER, param2: KEY_ENTER }; + const slots = bindingToSlots(binding, regBlt); + expect(slots).toEqual({ + tap: { kind: "key", usage: KEY_ENTER }, + hold: { kind: "layer", layerId: FN_LAYER }, + holdTapId: BLT_ID, + }); + expect(deriveBinding(slots!, regBlt)).toEqual(binding); + }); + + it("ignores a layer-tap id in the modifier branch (families don't cross)", () => { + // holdTapId from the layer family must not hijack a key+mod fill. + const slots: Slots = { + tap: { kind: "key", usage: KEY_A }, + hold: { kind: "mod", usage: LSHIFT }, + holdTapId: BLT_ID, + }; + expect(deriveBinding(slots, regBlt)).toEqual({ + behaviorId: MT, + param1: LSHIFT, + param2: KEY_A, + }); + }); +}); + describe("round trip — binding-lossless for every core behavior", () => { const bindings: [name: string, binding: BehaviorBinding][] = [ ["&kp A", { behaviorId: KP, param1: KEY_A, param2: 0 }], diff --git a/src/behaviors/slots.ts b/src/behaviors/slots.ts index 2a66db53..73254721 100644 --- a/src/behaviors/slots.ts +++ b/src/behaviors/slots.ts @@ -48,6 +48,16 @@ export type HoldSlot = export interface Slots { tap: TapSlot; hold: HoldSlot; + /** + * Which hold-tap-family behavior a `key + hold` fill derives to. Covers both + * families: on a `key + mod` fill it picks the Mod-Tap behavior (default `&mt`), + * on a `key + layer` fill the Layer-Tap behavior (default `<`). A keymap may + * define custom hold-taps of either shape (e.g. `homerow_mods`, + * `homerow_layers`), so the identity is a sub-mode choice (design §3b / §8 Q2). + * Absent ⇒ the standard core. The id is matched against the family that fits the + * current hold, so a stale id from the other family is simply ignored. + */ + holdTapId?: number; } // ============================================================================= @@ -80,6 +90,33 @@ export const CORE_LABELS: Record = CORE_DISPLAY_NAMES; export interface DerivationRegistry { idByCore: Partial>; coreById: Map; + /** + * Behavior ids of the mod-tap family — a `mod` param then a `key` param, the + * `&mt` shape. The standard `&mt` is first (when present), then any custom + * hold-taps the firmware exposes (e.g. `homerow_mods`). The Hold-mode surface + * offers these as the Mod-Tap identity choice; a `key + mod` fill derives to + * the chosen one (default `&mt`). + */ + modTapIds: number[]; + /** + * Behavior ids of the layer-tap family — a `layer` param then a `key` param, + * the `<` shape. The standard `<` is first, then any custom layer-taps. The + * Hold-mode Layer sub-mode offers these as the Layer-Tap identity choice; a + * `key + layer` fill derives to the chosen one (default `<`). + */ + layerTapIds: number[]; +} + +/** A mod-tap-shaped behavior: a HID `mod` param then a HID `key` param. */ +function isModTapShape(b: GetBehaviorDetailsResponse): boolean { + const set = b.metadata?.[0]; + return !!set?.param1?.[0]?.hidUsage && !!set?.param2?.[0]?.hidUsage; +} + +/** A layer-tap-shaped behavior: a `layer` param then a HID `key` param. */ +function isLayerTapShape(b: GetBehaviorDetailsResponse): boolean { + const set = b.metadata?.[0]; + return !!set?.param1?.[0]?.layerId && !!set?.param2?.[0]?.hidUsage; } export function buildDerivationRegistry( @@ -96,7 +133,21 @@ export function buildDerivationRegistry( coreById.set(behavior.id, core); } } - return { idByCore, coreById }; + // Mod-tap family: the standard `&mt` first, then any custom hold-taps of the + // same shape, in firmware order. (`&mt` itself matches isModTapShape, so skip + // it in the loop to avoid listing it twice.) Layer-tap family is the same, for + // the `<` shape. + const modTapIds: number[] = []; + if (idByCore.mt !== undefined) modTapIds.push(idByCore.mt); + for (const b of behaviors) { + if (b.id !== idByCore.mt && isModTapShape(b)) modTapIds.push(b.id); + } + const layerTapIds: number[] = []; + if (idByCore.lt !== undefined) layerTapIds.push(idByCore.lt); + for (const b of behaviors) { + if (b.id !== idByCore.lt && isLayerTapShape(b)) layerTapIds.push(b.id); + } + return { idByCore, coreById, modTapIds, layerTapIds }; } // ============================================================================= @@ -198,10 +249,26 @@ export function deriveBinding( switch (hold.kind) { case "empty": return bindingFor(reg, "kp", tap.usage, 0); - case "mod": - return bindingFor(reg, "mt", hold.usage, tap.usage); - case "layer": - return bindingFor(reg, "lt", hold.layerId, tap.usage); + case "mod": { + // A real Mod-Tap derives to the chosen mod-tap-family behavior (default + // `&mt`); a custom hold-tap of the same shape uses the same param order. + const id = + slots.holdTapId !== undefined && reg.modTapIds.includes(slots.holdTapId) + ? slots.holdTapId + : reg.idByCore.mt; + if (id === undefined) return null; + return { behaviorId: id, param1: hold.usage, param2: tap.usage }; + } + case "layer": { + // A real Layer-Tap derives to the chosen layer-tap-family behavior + // (default `<`); a custom layer-tap uses the same param order. + const id = + slots.holdTapId !== undefined && reg.layerTapIds.includes(slots.holdTapId) + ? slots.holdTapId + : reg.idByCore.lt; + if (id === undefined) return null; + return { behaviorId: id, param1: hold.layerId, param2: tap.usage }; + } } } @@ -234,7 +301,26 @@ export function bindingToSlots( reg: DerivationRegistry ): Slots | null { const core = reg.coreById.get(binding.behaviorId); - if (!core) return null; + if (!core) { + // A custom hold-tap decomposes like its standard sibling, carrying its + // identity so the Hold-mode surface can re-derive and highlight it: the `&mt` + // shape → Mod-Tap, the `<` shape → Layer-Tap. + if (reg.modTapIds.includes(binding.behaviorId)) { + return { + tap: { kind: "key", usage: binding.param2 }, + hold: { kind: "mod", usage: binding.param1 }, + holdTapId: binding.behaviorId, + }; + } + if (reg.layerTapIds.includes(binding.behaviorId)) { + return { + tap: { kind: "key", usage: binding.param2 }, + hold: { kind: "layer", layerId: binding.param1 }, + holdTapId: binding.behaviorId, + }; + } + return null; + } switch (core) { case "kp": @@ -250,11 +336,13 @@ export function bindingToSlots( return { tap: { kind: "key", usage: binding.param2 }, hold: { kind: "mod", usage: binding.param1 }, + holdTapId: binding.behaviorId, }; case "lt": return { tap: { kind: "key", usage: binding.param2 }, hold: { kind: "layer", layerId: binding.param1 }, + holdTapId: binding.behaviorId, }; case "mo": return { diff --git a/src/behaviors/tiles.test.ts b/src/behaviors/tiles.test.ts index 68c14125..f90c219f 100644 --- a/src/behaviors/tiles.test.ts +++ b/src/behaviors/tiles.test.ts @@ -4,6 +4,7 @@ import type { GetBehaviorDetailsResponse } from "@zmkfirmware/zmk-studio-ts-clie import { isLayerTileable, + isParameterless, isParamTileable, isTileable, layerTileBindingsFor, @@ -162,6 +163,24 @@ describe("isParamTileable (key-state latch behaviors: Sticky Key / Key Toggle)", }); }); +describe("isParameterless (Special one-tap tiles)", () => { + it("is true for empty metadata and for empty param sets", () => { + expect(isParameterless(behavior(11, "Reset", []))).toBe(true); + expect( + isParameterless(behavior(3, "Caps Word", [{ param1: [], param2: [] }])) + ).toBe(true); + // A custom parameterless behavior (e.g. Num Word) qualifies too. + expect(isParameterless(behavior(25, "Num Word", []))).toBe(true); + }); + + it("is false once any parameter slot takes a value", () => { + const sk = behavior(2, "Sticky Key", [ + { param1: [{ name: "key", hidUsage: HID }], param2: [] }, + ]); + expect(isParameterless(sk)).toBe(false); + }); +}); + describe("isLayerTileable / layerTileBindingsFor (persistent layer switches)", () => { const toLayer = behavior(8, "To Layer", [ { param1: [{ name: "layer", layerId: {} }], param2: [] }, diff --git a/src/behaviors/tiles.ts b/src/behaviors/tiles.ts index 01dac0a3..e66eae88 100644 --- a/src/behaviors/tiles.ts +++ b/src/behaviors/tiles.ts @@ -26,9 +26,14 @@ export interface BehaviorTile { binding: BehaviorBinding; } -/** A labelled run of tiles within a tab (one heading per source behavior). */ +/** + * A run of tiles within a tab. `heading` labels the source behavior when a tab + * holds several (Mouse / System); a single-section tab whose tab name already + * says everything (the Layer tab — the latch, not a heading, decides + * To / Sticky / Toggle) omits it. + */ export interface BindingTileSection { - heading: string; + heading?: string; tiles: BehaviorTile[]; } @@ -138,6 +143,21 @@ export function isTileable(behavior: GetBehaviorDetailsResponse): boolean { return tileBindingsFor(behavior) !== null; } +/** + * Whether a behavior takes no parameters at all — a single finished tile with + * nothing to pick (Caps Word / Key Repeat / Grave-Escape, and custom flavor + * behaviors like Num Word). These are the "Special" one-tap tiles in the Normal + * grid's Apps/Media/Special tab (issue #16), so a parameterless custom behavior + * joins them rather than sitting in the residual area. + */ +export function isParameterless(behavior: GetBehaviorDetailsResponse): boolean { + const sets = behavior.metadata; + if (!sets || sets.length === 0) return true; + return sets.every( + (s) => (s.param1?.length ?? 0) === 0 && (s.param2?.length ?? 0) === 0 + ); +} + /** * Whether a behavior takes exactly one HID-key parameter and nothing else * (Sticky Key, Key Toggle). Such a behavior wraps a single key, so the slot From e8b7d55b8e17c4b626fa196890820289143d4062 Mon Sep 17 00:00:00 2001 From: numachang Date: Wed, 10 Jun 2026 15:51:57 +0900 Subject: [PATCH 19/22] test(mock): split Corne demo fixture exercising the custom routes (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 42-key (3x6+3) split board carrying one behavior of each custom class — a custom mod-tap, a custom layer-tap, a parameterless custom, and a custom enum — so the Hold-mode identity selectors, the Custom tab, and an empty residual area are all exercisable in the demo. Co-Authored-By: Claude --- src/mock/fixtures.ts | 162 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 159 insertions(+), 3 deletions(-) diff --git a/src/mock/fixtures.ts b/src/mock/fixtures.ts index 77506bc3..32ab84b1 100644 --- a/src/mock/fixtures.ts +++ b/src/mock/fixtures.ts @@ -22,10 +22,12 @@ const KEY: Record = { K: 14, L: 15, M: 16, N: 17, O: 18, P: 19, Q: 20, R: 21, S: 22, T: 23, U: 24, V: 25, W: 26, X: 27, Y: 28, Z: 29, SEMI: 51, COMMA: 54, DOT: 55, SLASH: 56, - ENTER: 40, SPACE: 44, + ENTER: 40, SPACE: 44, TAB: 43, BSPC: 42, ESC: 41, + QUOTE: 52, GRAVE: 53, MINUS: 45, EQUAL: 46, LBKT: 47, RBKT: 48, BSLH: 49, F1: 58, F2: 59, F3: 60, F4: 61, F5: 62, F6: 63, F7: 64, F8: 65, F9: 66, F10: 67, RIGHT: 79, LEFT: 80, DOWN: 81, UP: 82, - LSHIFT: 225, LCTRL: 224, + LCTRL: 224, LSHIFT: 225, LALT: 226, LGUI: 227, + RCTRL: 228, RSHIFT: 229, RALT: 230, RGUI: 231, }; // --- behavior metadata helpers --------------------------------------------- @@ -74,6 +76,14 @@ const B = { keyRepeat: 21, graveEscape: 22, stickyLayer: 23, + // Firmware-extension (keymap-author-defined) behaviors — not ZMK standards. + // Each exercises a different "drained residual" route (issue #16): a custom + // mod-tap (→ Hold mode), a parameterless one (→ Apps/Media/Special), a custom + // layer-tap (→ Hold mode Layer), and a custom enum (→ the Custom tab). + homeRowMods: 24, + numWord: 25, + layerTap2: 26, + ledMode: 27, } as const; const BEHAVIORS: GetBehaviorDetailsResponse[] = [ @@ -182,6 +192,42 @@ const BEHAVIORS: GetBehaviorDetailsResponse[] = [ }, ]; +// Firmware-extension behaviors a keymap author might define. Appended to the +// standard set for the split fixture so the residual "More behaviors" escape +// hatch has something to show: a custom hold-tap (param2 hidUsage → classifies +// "Hold-Tap", not tileable) and a parameterless custom behavior (→ "Other"). +const CUSTOM_BEHAVIORS: GetBehaviorDetailsResponse[] = [ + // Custom mod-tap (→ Hold mode, Mod-Tap identity). + { + id: B.homeRowMods, + displayName: "Home Row Mods", + metadata: [{ param1: [hidParam("mod")], param2: [hidParam("tap")] }], + }, + // Parameterless custom (→ Apps/Media/Special special tile). + { id: B.numWord, displayName: "Num Word", metadata: [] }, + // Custom layer-tap, same shape as < (→ Hold mode, Layer-Tap identity). + { + id: B.layerTap2, + displayName: "Balanced LT", + metadata: [{ param1: [layerParam("layer")], param2: [hidParam("tap")] }], + }, + // Custom enum behavior with no known strip group (→ the Custom tab). + { + id: B.ledMode, + displayName: "LED Mode", + metadata: [ + { + param1: [ + constParam("OFF", 0), + constParam("DIM", 1), + constParam("BRIGHT", 2), + ], + param2: [], + }, + ], + }, +]; + // --- binding builders ------------------------------------------------------ const bind = (behaviorId: number, param1 = 0, param2 = 0): BehaviorBinding => ({ behaviorId, @@ -260,5 +306,115 @@ export const DEMO_ORTHO_50: MockKeyboardFixture = { maxLayerNameLength: 16, }; +// =========================================================================== +// Split fixture: a Corne-style 3x6+3 (42-key) split, carrying the custom +// behaviors so the residual "More behaviors" escape hatch can be exercised on a +// realistic ergo board. +// =========================================================================== + +// Per-column vertical stagger (key units) for one half, rendered outer→inner so +// the middle finger's column sits highest. The right half mirrors it. +const L_STAGGER = [0.35, 0.35, 0.0, 0.1, 0.25, 0.25]; +const R_STAGGER = [...L_STAGGER].reverse(); +const SPLIT_GAP_X = 8; // left half is x 0..5; the right half starts here. + +const splitKey = (x: number, y: number): KeyPhysicalAttrs => ({ + width: UNIT, + height: UNIT, + x: Math.round(x * UNIT), + y: Math.round(y * UNIT), + r: 0, + rx: 0, + ry: 0, +}); + +// Key order (matches the binding arrays below): for each of the 3 rows, the six +// left keys then the six right keys; then the left thumb trio, then the right. +const CORNE_KEYS: KeyPhysicalAttrs[] = []; +for (let row = 0; row < 3; row++) { + for (let c = 0; c < 6; c++) CORNE_KEYS.push(splitKey(c, row + L_STAGGER[c])); + for (let c = 0; c < 6; c++) + CORNE_KEYS.push(splitKey(SPLIT_GAP_X + c, row + R_STAGGER[c])); +} +const L_THUMBS: Array<[number, number]> = [ + [3.2, 3.4], + [4.2, 3.65], + [5.2, 3.9], +]; +const R_THUMBS: Array<[number, number]> = [ + [SPLIT_GAP_X - 0.2, 3.9], + [SPLIT_GAP_X + 0.8, 3.65], + [SPLIT_GAP_X + 1.8, 3.4], +]; +for (const [x, y] of [...L_THUMBS, ...R_THUMBS]) CORNE_KEYS.push(splitKey(x, y)); + +// --- split layers ---------------------------------------------------------- +const LOWER_ID = 1; +const RAISE_ID = 2; +const mo = (layer: number) => bind(B.momentaryLayer, layer); +// A home-row mod: hold for `mod`, tap for `key` (the custom hold-tap). +const hrm = (mod: string, key: string) => + bind(B.homeRowMods, hid(KEY[mod]), hid(KEY[key])); + +// Base: QWERTY with home-row mods, layer thumbs, and the two custom behaviors +// (Home Row Mods on the home row, Num Word on the right thumb) so they show on +// real keys — and in the residual picker — without hunting. +const corneBase: BehaviorBinding[] = [ + // row 0 + kp("TAB"), kp("Q"), kp("W"), kp("E"), kp("R"), kp("T"), + kp("Y"), kp("U"), kp("I"), kp("O"), kp("P"), kp("BSPC"), + // row 1 (home row: GUI/ALT/CTRL/SHIFT mods under A S D F · J K L ;). G is a + // custom layer-tap (hold → Raise, tap → G) to exercise the Layer-Tap identity. + kp("ESC"), hrm("LGUI", "A"), hrm("LALT", "S"), hrm("LCTRL", "D"), hrm("LSHIFT", "F"), bind(B.layerTap2, RAISE_ID, hid(KEY.G)), + kp("H"), hrm("RSHIFT", "J"), hrm("RCTRL", "K"), hrm("RALT", "L"), hrm("RGUI", "SEMI"), kp("QUOTE"), + // row 2 (B is a custom enum behavior — LED Mode = BRIGHT — to show the Custom tab) + kp("LSHIFT"), kp("Z"), kp("X"), kp("C"), kp("V"), bind(B.ledMode, 2), + kp("N"), kp("M"), kp("COMMA"), kp("DOT"), kp("SLASH"), kp("RSHIFT"), + // thumbs (L: GUI / Lower / Space · R: Enter / Raise / Num Word) + kp("LGUI"), mo(LOWER_ID), kp("SPACE"), + kp("ENTER"), mo(RAISE_ID), bind(B.numWord), +]; + +// Lower: number row + symbols, transparent elsewhere. +const corneLower: BehaviorBinding[] = [ + kp("GRAVE"), kp("N1"), kp("N2"), kp("N3"), kp("N4"), kp("N5"), + kp("N6"), kp("N7"), kp("N8"), kp("N9"), kp("N0"), kp("BSPC"), + trans(), trans(), trans(), trans(), trans(), trans(), + kp("MINUS"), kp("EQUAL"), kp("LBKT"), kp("RBKT"), kp("BSLH"), kp("QUOTE"), + trans(), trans(), trans(), trans(), trans(), trans(), + trans(), trans(), trans(), trans(), trans(), trans(), + trans(), trans(), trans(), trans(), trans(), trans(), +]; + +// Raise: function row + an inverted-T arrow cluster, transparent elsewhere. +const corneRaise: BehaviorBinding[] = [ + kp("TAB"), kp("F1"), kp("F2"), kp("F3"), kp("F4"), kp("F5"), + kp("F6"), kp("F7"), kp("F8"), kp("F9"), kp("F10"), kp("BSPC"), + trans(), trans(), trans(), trans(), trans(), trans(), + kp("LEFT"), kp("DOWN"), kp("UP"), kp("RIGHT"), trans(), trans(), + trans(), trans(), trans(), trans(), trans(), trans(), + trans(), trans(), trans(), trans(), trans(), trans(), + trans(), trans(), trans(), trans(), trans(), trans(), +]; + +const corneLayers: Layer[] = [ + { id: 0, name: "Base", bindings: corneBase }, + { id: LOWER_ID, name: "Lower", bindings: corneLower }, + { id: RAISE_ID, name: "Raise", bindings: corneRaise }, +]; + +export const DEMO_CORNE: MockKeyboardFixture = { + id: "demo-corne-42", + label: "Demo · 42-key split (custom behaviors)", + deviceName: "Mock Corne 42", + serialNumber: "MOCK-CORNE-0001", + behaviors: [...BEHAVIORS, ...CUSTOM_BEHAVIORS], + physicalLayouts: [{ name: "Corne 3x6+3", keys: CORNE_KEYS }], + activeLayoutIndex: 0, + layers: corneLayers, + maxLayers: 8, + maxLayerNameLength: 16, +}; + /** All demo keyboards offered in the connect dialog. */ -export const MOCK_FIXTURES: MockKeyboardFixture[] = [DEMO_ORTHO_50]; +export const MOCK_FIXTURES: MockKeyboardFixture[] = [DEMO_ORTHO_50, DEMO_CORNE]; From 6186cf0297268197620bb81237e09c8dea545bd9 Mon Sep 17 00:00:00 2001 From: numachang Date: Wed, 10 Jun 2026 15:52:04 +0900 Subject: [PATCH 20/22] docs(design): record Step 5 (drain the residual) as built (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the key-picker rethink note (English + Japanese mirror): Step 5 is effectively complete by draining the residual area rather than designing a new hierarchy; §8 Q2/Q4 and the §8a before-main blockers are resolved; the flavor tiles' move to Apps/Media/Special and the editor sizing recorded. Co-Authored-By: Claude --- docs/design/key-picker-rethink.md | 226 ++++++++++++++++++++++-------- 1 file changed, 170 insertions(+), 56 deletions(-) diff --git a/docs/design/key-picker-rethink.md b/docs/design/key-picker-rethink.md index c3592923..e65a1605 100644 --- a/docs/design/key-picker-rethink.md +++ b/docs/design/key-picker-rethink.md @@ -7,14 +7,20 @@ > authoritative; a Japanese mirror follows below. > > Status: **Step 1 shipped (PR #27); Steps 2, 3a, 3b merged to the integration -> branch** (Step 3b via PR #32). 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. **Step 3b deviated from the -> original "parameterised tile" idea (§3c): `&sk`/`&kt` shipped as a *key-state -> latch* — a checkbox pair on the slot label that wraps the Normal key — which -> fits the "wraps a key, not is a key" nature better than a tile.** **Step 4 -> (persistent layer switches as a "Layer" tab) is implemented on its branch (§5).** +> branch** (Step 3b via PR #32). **Steps 4 and 5 are implemented on the +> integration line and ready for the integration → `main` merge.** 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. +> **Step 3b deviated from the original "parameterised tile" idea (§3c): `&sk`/`&kt` +> shipped as a *key-state latch* — a checkbox pair on the slot label that wraps the +> Normal key — which fits the "wraps a key, not is a key" nature better than a +> tile.** **Step 4** put the persistent layer switches on a "Layer" tab. **Step 5 +> is effectively complete by *draining* the residual area** rather than designing a +> new hierarchy: every behavior the firmware exposes now routes to a shaped home +> (slot surface / Hold mode with an identity choice / a tile tab), so the residual +> "More behaviors" area auto-hides for ordinary keyboards and only surfaces for the +> rare exotic custom that fits no shape (§5 Step 5, §8a). --- @@ -173,7 +179,11 @@ category, but the UI is uniformly "open a tab, press a tile." - **None / Transparent** — "clear / make-transparent" tiles, kept available on *every* tab (this is Step 1). - **Caps Word (`&caps_word`) / Key Repeat (`&key_repeat`) / Grave-Escape - (`&gresc`)** — parameterless single tiles. + (`&gresc`)** — parameterless single tiles, in the **Apps/Media/Special** tab + (the "Special" segment). These are *recognized standard* flavor behaviors; an + *unrecognized* custom tile-able behavior (parameterless or enum) instead gathers + in a **Custom** tab, so "Special" stays a curated standard set and "Custom" holds + your keymap's own (§5 Step 5). ### (c) Key-state latches — wrap the Normal key (`&sk` / `&kt`) @@ -477,9 +487,11 @@ The Basic "flavor" behaviors leave the residual chip area entirely, so the [`SlotBindingPicker.tsx`](../../src/behaviors/SlotBindingPicker.tsx) (`keyStateWrappers`, `toggleWrapper`, wrapper-aware `setNormalKey`); the grid's `keyOnly` mode is in [`HidUsagePicker.tsx`](../../src/behaviors/HidUsagePicker.tsx). -- **Caps Word / Key Repeat / Grave-Escape → one-tap tiles.** 1U keycap tiles in a - 2-column block on the Basic tab, with None / Transparent pinned to the bottom - row; labels wrap at the largest font that fits a 1U cap. +- **Caps Word / Key Repeat / Grave-Escape → one-tap tiles.** Keycap tiles that + ship in the **Apps/Media/Special** tab (the "Special" segment), sharing the + flavor row's trailing space with the None / Transparent clear caps (so the clear + caps don't wrap onto a line of their own). (An earlier cut placed these on the + Basic tab; they moved to Apps/Media/Special once the slot surface owned typing.) - **Supporting:** `isParamTileable` ([`tiles.ts`](../../src/behaviors/tiles.ts)) detects the single-key-param shape (callers gate `!isCoreBinding`, since Key Press shares it). [`keymap-parser.ts`](../../src/keymap-parser.ts) gained the @@ -517,16 +529,78 @@ The Basic "flavor" behaviors leave the residual chip area entirely, so the - **Note:** this is the *persistent* layer case; the *momentary* layer (`&mo`) and Layer-Tap (`<`) live in **Hold mode** and already landed in Step 2. Hold mode is disabled while latched (a latch has no hold). -- **Deferred:** the custom-hold-tap identity choice (when more than one hold-tap - behavior exists, pick `&mt` vs e.g. `homerow_mods`, §3b/§8 Q2) is independent of - the layer work and is left to a later step. +- **Layer tab chrome.** The tab has the single To-Layer section and **no section + heading** — the latch (Sticky / Toggle), not a label, decides To / Sticky / + Toggle, so a "To Layer" heading would misread as the only option. +- **Editor sizing (drove by "More behaviors" emptying).** With the residual area + gone for standard firmware, the editor's picker row tightened from `34em` to + `28em` ([`Keyboard.tsx`](../../src/keyboard/Keyboard.tsx)); the picker keeps a + fixed height so selecting a key never resizes the keyboard image. Paramless + behaviors also read as **key legends** on the keymap image (None = blank, + Transparent = ▽, sized 14/12/10px by label length) rather than pills + ([`Key.tsx`](../../src/keyboard/Key.tsx)). - **Risk:** low — additive tab + a small generalization of the existing latch. -### Step 5+ — remove the top-level behavior tabs (later, needs its own design) - -- **Goal:** retire the tier/category tab hierarchy entirely in favor of - "(a) tile tabs + (b) slot surface + (c) parameterised tiles." Deferred; needs its - own design pass once Steps 1-4 have proven the model in use. +### Step 5 — drain the residual area (effectively complete) + +- **Goal:** retire the tier/category tab hierarchy in favor of "(a) tile tabs + + (b) slot surface + (c) parameterised tiles." Step 3a already removed the tier + tabs and realized the key part of the end-state (behavior tiles in the Normal + grid's strip), so Step 5 shrank to **draining whatever still landed in the + residual "More behaviors" area** until it's empty for ordinary keyboards. +- **The draining (route every remaining behavior to a shaped home).** What was + left in the residual area were the *custom* (firmware-extension) behaviors that + Steps 1–4 didn't own. Each now routes by shape: + - **Custom hold-taps → Hold mode, with an identity choice (§8 Q2 resolved).** + A custom behavior of the `&mt` shape (`mod` param, then `key`) joins the + Modifier sub-mode; a custom of the `<` shape (`layer`, then `key`) joins the + Layer sub-mode. When more than one behavior of a family exists, the surface + shows a small **identity selector** (e.g. *Mod-Tap / Home Row Mods*, or + *Layer-Tap / …*); a `key + mod`/`key + layer` fill derives to the chosen one + (default `&mt` / `<`). The selector shows as soon as you're composing a + hold-tap (a Normal key + the sub-mode) — *before* a modifier/layer is picked, + so it's discoverable — and the choice is held in local state and applied the + moment a value is added (order-independent). + - **Custom tile-able behaviors → a "Custom" tab.** A custom (unrecognized) + behavior that's tile-able — parameterless (e.g. `num_word`) *or* an enum/range + (e.g. an LED-mode selector) — gathers in a **Custom** tab in the Normal grid's + strip. The rule is **recognized standards → their curated semantic homes** + (Caps Word / Key Repeat / Grave-Escape → Apps/Media/Special; Bluetooth/… → + System; mouse → Mouse), **unrecognized customs → Custom**, so "Special" stays a + curated set and "Custom" is the predictable home for *your* tile-able behaviors. + (Hold-tap-shaped customs go to Hold mode by interaction shape, not here.) +- **Drain, don't delete.** The residual "More behaviors" block stays in the code + but **auto-hides when empty** (`availableGroups.length > 0`). For standard + firmware — and any keyboard whose customs are hold-taps / tile-able — it never + renders. It still surfaces, as the deliberate escape hatch (§8a / §8 Q4), only + for an **exotic custom that fits no shape**: a single-key custom that isn't + Sticky Key / Key Toggle, a custom single-*layer* behavior, or a variadic + multi-param custom. Deleting it outright would make such a firmware-exposed + behavior un-settable, violating the §4a coverage guarantee — so it is drained, + not removed. +- **How the pieces fit:** + - [`slots.ts`](../../src/behaviors/slots.ts): the registry gains `modTapIds` / + `layerTapIds` (the standard core first, then custom same-shape behaviors); + `Slots.holdTapId` carries the chosen identity for *both* families (the hold + kind selects which list applies, so a stale id from the other family is simply + ignored). `deriveBinding` / `bindingToSlots` emit / read the chosen id. + Unit-tested in [`slots.test.ts`](../../src/behaviors/slots.test.ts). + - [`SlotBindingPicker.tsx`](../../src/behaviors/SlotBindingPicker.tsx): one + identity selector serves both sub-modes (`modTapOptions` / `layerTapOptions`), + holding the choice in local state so it can precede the value. + - [`BehaviorBindingPicker.tsx`](../../src/behaviors/BehaviorBindingPicker.tsx): + `isSpecialTile` (recognized standard flavor, `classify === "Basic"`) vs + `isCustomTileBehavior` (`classify === "Other"` + tile-able) split the tiles + between Apps/Media/Special and the Custom tab; `hiddenIds` adds the mod-tap / + layer-tap families (now slot-edited); `behaviorTabs` builds the Custom tab. + - Demo: the split [`fixtures.ts`](../../src/mock/fixtures.ts) Corne board carries + one of each custom class (a custom mod-tap, a custom layer-tap, a parameterless + custom, and a custom enum) so all four routes — and an empty residual — are + exercisable. +- **Remaining (later, optional).** Realizing the (c) "parameterised tile" for the + *exotic* tail (a single-key custom that reveals a key picker, etc.) would let the + residual block be deleted entirely. It's rare in practice, so it's left as the + auto-hiding escape hatch for now. --- @@ -565,32 +639,39 @@ stays here and is not pushed upstream. (a well-known behavior name constant, a metadata signature) we can match on, or do we accept the `displayName` string match and degrade gracefully when names differ? (Current direction: keep the string match + graceful "not present.") -2. **Multiple hold-tap behaviors.** When `homerow_mods` and `&mt` both exist, what - is the default for a Normal-key + Hold-modifier fill, and how prominent should - the sub-mode switch be? (Deferred to Step 4.) +2. ~~**Multiple hold-tap behaviors.**~~ **Resolved (Step 5).** When a custom + hold-tap (`homerow_mods`) and `&mt` both exist, the Hold-mode surface shows an + **identity selector** (default `&mt`); the same applies to the `<` shape in + the Layer sub-mode. The selector appears only when a family has more than one + member (capability-aware) and is discoverable before a modifier/layer is picked. + See §5 Step 5. 3. ~~**The "hold a modifier, empty tap" case.**~~ **Resolved (§2b):** a lone modifier is a HID keycode, so it is a **Normal** keystroke (`&kp `), not a Hold. Hold mode's Modifier is only the hold half of a Mod-Tap. There is no "momentary modifier" behavior to surprise anyone with. -4. **Step 5 scope.** How much of the old tab hierarchy, if any, should survive as - an "advanced" escape hatch for behaviors that don't fit either shape? +4. ~~**Step 5 scope.**~~ **Resolved (Step 5).** The old hierarchy is drained, not + kept as an advanced surface: every behavior routes to a shaped home, and the + residual "More behaviors" block auto-hides, surfacing only for the exotic custom + that fits no shape. It's a fallback, not a parallel "advanced" mode. ### 8a. Before-main blockers (from the PR #29 review) -These are acceptable on the integration branch but **must be resolved before the -integration branch merges to `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. +These were acceptable on the integration branch and need to be settled before the +integration branch merges to `main`: + +- **Non-core bindings on the slot surface — resolved.** A non-core current binding + no longer reads as a blank *"No matching behavior"*: the slot label **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 / Custom) — 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`. With + Step 5's draining, the formerly-chip behaviors (custom hold-taps → Hold mode, + custom tiles → the Custom tab) now have a named, highlighted home too. +- **Residual escape hatch — resolved (§5 Step 5, §8 Q4).** The "More behaviors" + block is drained to empty for ordinary keyboards and auto-hides; it stays as the + deliberate fallback for the exotic custom that fits no shape (keeping §4a + coverage). Not deleted, so nothing firmware-exposed becomes un-settable. - **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. @@ -624,10 +705,15 @@ since they're rarely used day-to-day. > モデル・エッジケース)と段階別の実装計画を足したもの。issue が対外的な要約、 > こちらが作業用の設計。英語が正、本節はそのミラー。 > -> ステータス: **Step 1 出荷済み(PR #27)/ Step 2 実装中。** スロットモデルは +> ステータス: **Step 1〜3b は統合ブランチにマージ済み(Step 3b は PR #32)。Step 4 +> と Step 5 も統合ライン上に実装済みで、統合 → `main` のマージ待ち。** スロットモデルは > **Normal / Hold mode** 方式に収束(§2)。初期案の「Tap / Hold」は、ただの `&kp` > に対して「Tap=短押し」が誤解を招くため改名した。全標準 ZMK behavior の設定可否は -> §4a で確認済み。 +> §4a で確認済み。**Step 5 は新しい階層を設計するのではなく、残置「More behaviors」を +> *排水*して実質完了**:ファームが公開する behavior はすべて形に応じた置き場(スロット面 +> /identity 付き Hold mode/タイルタブ)へ送られ、残置領域は通常のキーボードでは +> 自動的に隠れ、形に収まらない稀な exotic custom のときだけ顔を出す(§5 Step 5・§8a)。 +> 詳細は英語が正。 ## 1. 現状コードに即した問題 @@ -900,19 +986,44 @@ behavior の挙動は付録参照。) - **3b(出荷済み・PR #32)** — `&sk` / `&kt` をキー状態ラッチ(§3c)に。スロット ラベル上の `Latch` チェックボックス対で Normal キーを包む(`&kp K`→`&sk K`/`&kt K`、 キーはグリッドで選ぶ)。Hold と排他、ラッチ中はグリッドがキー専用。Caps Word / - Key Repeat / Grave-Escape は Basic タブの単発タイル(2 列、最下段に None/Transparent)。 + Key Repeat / Grave-Escape は **Apps/Media/Special** タブの単発タイル(flavor 行の + 末尾を None/Transparent と共有し、クリアキャップが改行で別行に落ちないようにした)。 付随して keymap-parser に `kt` 別名追加+`gresc`→`Grave/Escape` 修正、fixtures に 3 behavior 追加。残置 Basic チップ列は消滅。 - `HidUsagePicker` のグリッド/タイル型を再利用。capability 連動。 - **Step 4(実装済み)** — 持続レイヤー切替を **Normal グリッドの「Layer」タブ**として - 実装(Mouse/System と同型)。ただしタブは **「To Layer」1セクション**(レイヤーごとの - タイル)だけで、Sticky/Toggle は**ラッチに統合**:`&sl`/`&tog` はキーの `&sk`/`&kt` の - レイヤー版で、同じ汎用 `Latch: ☐ Sticky ☐ Toggle` が現在の family(key/layer)で - `&sk/&kt` か `&sl/&tog` に対応。レイヤーを選ぶ→`&to`、Sticky/Toggle で `&sl`/`&tog`。 - `matchFamily` で `&sl/&tog` でも該当レイヤータイルがハイライト&タブジャンプ。 - `slots.ts` は無変更。カスタム hold-tap の identity 選択は後続に延期。 -- **Step 5 以降(要別設計)** — tier/カテゴリのタブ階層を撤去し「(a)+(b)+(c)」へ。 - Step 1-4 で方式が実用検証されてから別途設計。 + 実装(Mouse/System と同型)。ただしタブは **「To Layer」1セクション・見出しなし** + (レイヤーごとのタイル)だけで、Sticky/Toggle は**ラッチに統合**:`&sl`/`&tog` はキーの + `&sk`/`&kt` のレイヤー版で、同じ汎用 `Latch: ☐ Sticky ☐ Toggle` が現在の family + (key/layer)で `&sk/&kt` か `&sl/&tog` に対応。レイヤーを選ぶ→`&to`、Sticky/Toggle で + `&sl`/`&tog`。`matchFamily` で `&sl/&tog` でも該当レイヤータイルがハイライト&タブ + ジャンプ。`slots.ts` は無変更。残置領域が空になったのに合わせ、エディタの picker 行を + `34em`→`28em` に詰め、paramless behavior はキーマップ画像で**キー凡例**化(None=空白/ + Transparent=▽/文字数で 14/12/10px)。 +- **Step 5(実質完了・残置の排水)** — tier/カテゴリ階層は Step 3a で撤去済み。残るは + 残置「More behaviors」を**空にするまで排水**すること。残っていたのは Steps 1-4 が + 扱わない**自作 behavior**で、形ごとに送り先を用意した: + - **自作 hold-tap → Hold mode(identity 選択・§8 Q2 解決)**:`&mt` 形は Modifier、 + `<` 形は Layer サブモードへ。同形が複数あると **identity セレクタ**(例 Mod-Tap / + Home Row Mods、Layer-Tap / …)を表示し、選んだ behavior に派生(既定 `&mt`/`<`)。 + セレクタは hold-tap を組み始めた時点(Normal キー+サブモード)で**修飾/レイヤー選択前** + から表示=発見可能、選択は local state で保持し値を入れた瞬間に反映(順序自由)。 + - **自作 tileable → 「Custom」タブ**:自作(=非標準)の tileable は paramless(例 + `num_word`)も enum/range(例 LED モード)も **Custom タブ**にまとめる。ルールは + **認識できる標準は専用ホーム**(Caps Word 等→Apps/Media/Special、BT 等→System、 + mouse→Mouse)、**認識できない自作は Custom**。hold-tap 形の自作は形が違うので Hold + mode 側(Custom タブではない)。 + - **排水であって削除ではない**:残置ブロックは `availableGroups.length>0` で**空なら + 自動的に隠れる**。標準ファーム+(自作が hold-tap/tileable の)実機では出ない。形に + 収まらない exotic custom(Sticky/Key Toggle 以外の単一キー自作、単一**レイヤー**自作、 + 可変多パラメータ自作)のときだけ安全網として顔を出す(§8a/§8 Q4)。削除すると + ファーム公開 behavior が設定不能になり §4a に反するので残す。 + - 実装: `slots.ts` に `modTapIds`/`layerTapIds`(標準コア先頭+同形自作)、`holdTapId` + を両 family 共用。`SlotBindingPicker` の identity セレクタは両サブモード兼用 + (`modTapOptions`/`layerTapOptions`)。`BehaviorBindingPicker` の `isSpecialTile` + (標準 flavor=`classify Basic`)と `isCustomTileBehavior`(`classify Other`+tileable) + でタイルを Apps/Media/Special と Custom に振り分け、mod/layer-tap family を `hiddenIds` + へ。デモの分割 fixture(Corne)が4種すべてと「空の残置」を再現。 ## 6. capability 連動と非目標 @@ -937,9 +1048,12 @@ upstream([`zmkfirmware/zmk-studio`](https://github.com/zmkfirmware/zmk-studio) キーボードごとに変わるのでハードコード不可。別の安定識別子(既知の behavior 名 定数、metadata シグネチャ)で一致させられるか、`displayName` 文字列一致のまま名前 差異時に穏当に劣化させるか。(現状の方向: 文字列一致+「不在」の穏当劣化。) -2. **複数の hold-tap behavior。** `homerow_mods` と `&mt` が両方在るとき「キー+修飾」 - の既定はどちらか、サブモード切替をどれだけ目立たせるか。 -3. **「修飾ホールド・タップ空」ケース。** 空タップ+修飾ホールドから `&kp ` を - 派生するのは綺麗だがやや意外。逆引きラベルで足りるか、明示タイルにすべきか。 -4. **Step 5 の範囲。** どちらの形にも収まらない behavior 用に、旧タブ階層を - 「上級者向け」退避口としてどれだけ残すか。 +2. ~~**複数の hold-tap behavior。**~~ **解決(Step 5)。** 自作 hold-tap(`homerow_mods`) + と `&mt` が両方在ると Hold mode に **identity セレクタ**(既定 `&mt`)を表示。`<` + 形も Layer サブモードで同様。family に2つ以上あるときだけ出し(capability 連動)、 + 修飾/レイヤー選択前から発見可能。§5 Step 5 参照。 +3. ~~**「修飾ホールド・タップ空」ケース。**~~ **解決(§2b)。** 単独修飾は HID キーコード + なので **Normal**(`&kp `)。Hold mode の修飾は Mod-Tap の長押し側専用。 +4. ~~**Step 5 の範囲。**~~ **解決(Step 5)。** 旧階層は「上級者向け」面として残すのでは + なく**排水**:全 behavior を形に応じた置き場へ送り、残置「More behaviors」は自動的に + 隠れ、形に収まらない exotic custom のときだけ安全網として出る。並行モードではない。 From ffa65ae9a289eacb16cae44b62b0cd64008606c7 Mon Sep 17 00:00:00 2001 From: numachang Date: Wed, 10 Jun 2026 16:07:00 +0900 Subject: [PATCH 21/22] =?UTF-8?q?fix(behaviors):=20address=20PR=20#33=20re?= =?UTF-8?q?view=20=E2=80=94=20preserve=20hold-tap=20identity,=20fix=20fixt?= =?UTF-8?q?ure=20comments=20(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reseed the local hold-tap identity only when the bound *identity* changes (deps on `slots.holdTapId`), not on every binding field. A custom identity picked before adding a modifier now survives changing the Normal key instead of snapping back to the default `&mt`/`<` (review item 2). The Hold panel still collapses on a Normal-key change via the separate, pre-existing `holdModeOn` reset, but the choice is preserved and re-applied. - Correct stale demo-fixture comments: a parameterless custom (Num Word) routes to the Custom tab (unrecognized → classify "Other"), not Apps/Media/Special (review item 1). Co-Authored-By: Claude --- src/behaviors/SlotBindingPicker.tsx | 18 ++++++++++-------- src/mock/fixtures.ts | 14 ++++++++------ 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/behaviors/SlotBindingPicker.tsx b/src/behaviors/SlotBindingPicker.tsx index c1c3550a..8d2e35fa 100644 --- a/src/behaviors/SlotBindingPicker.tsx +++ b/src/behaviors/SlotBindingPicker.tsx @@ -211,15 +211,17 @@ export const SlotBindingPicker = ({ if (holdKind !== "empty") setHoldMode(holdKind); }, [holdKind]); - // The chosen Mod-Tap identity (`&mt` or a custom hold-tap). Kept as local state - // so it can be picked *before* a modifier exists — a plain `&kp` can't carry the - // identity, so we hold it here and inject it on derive (a key+mod fill becomes - // the chosen behavior). Reseeded from the binding whenever the bound key changes. - const [holdTapId, setHoldTapId] = useState(slots.holdTapId); + // The chosen hold-tap identity (`&mt`/`<` or a custom). Kept as local state so + // it can be picked *before* a modifier/layer exists — a plain `&kp` can't carry + // the identity, so we hold it here and inject it on derive (a key+hold fill + // becomes the chosen behavior). Reseeded only when the bound *identity* changes, + // not on a tap-key edit, so a choice made before adding a modifier survives + // changing the Normal key. + const committedHoldTapId = slots.holdTapId; + const [holdTapId, setHoldTapId] = useState(committedHoldTapId); useEffect(() => { - setHoldTapId(slots.holdTapId); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [binding.behaviorId, binding.param1, binding.param2]); + setHoldTapId(committedHoldTapId); + }, [committedHoldTapId]); const writeSlots = (next: Slots) => { // Inject the chosen identity so a key+mod fill derives to it (default `&mt`). diff --git a/src/mock/fixtures.ts b/src/mock/fixtures.ts index 32ab84b1..abf19c50 100644 --- a/src/mock/fixtures.ts +++ b/src/mock/fixtures.ts @@ -78,8 +78,9 @@ const B = { stickyLayer: 23, // Firmware-extension (keymap-author-defined) behaviors — not ZMK standards. // Each exercises a different "drained residual" route (issue #16): a custom - // mod-tap (→ Hold mode), a parameterless one (→ Apps/Media/Special), a custom - // layer-tap (→ Hold mode Layer), and a custom enum (→ the Custom tab). + // mod-tap (→ Hold mode), a parameterless custom (→ the Custom tab — unrecognized, + // so not Apps/Media/Special), a custom layer-tap (→ Hold mode Layer), and a + // custom enum (→ the Custom tab). homeRowMods: 24, numWord: 25, layerTap2: 26, @@ -193,9 +194,10 @@ const BEHAVIORS: GetBehaviorDetailsResponse[] = [ ]; // Firmware-extension behaviors a keymap author might define. Appended to the -// standard set for the split fixture so the residual "More behaviors" escape -// hatch has something to show: a custom hold-tap (param2 hidUsage → classifies -// "Hold-Tap", not tileable) and a parameterless custom behavior (→ "Other"). +// standard set for the split fixture so the four custom routes are exercisable: +// a custom mod-tap and layer-tap (→ Hold mode, with an identity choice), and a +// parameterless custom and a custom enum (both → the Custom tab, since neither is +// a recognized standard). With all four routed, the residual area stays empty. const CUSTOM_BEHAVIORS: GetBehaviorDetailsResponse[] = [ // Custom mod-tap (→ Hold mode, Mod-Tap identity). { @@ -203,7 +205,7 @@ const CUSTOM_BEHAVIORS: GetBehaviorDetailsResponse[] = [ displayName: "Home Row Mods", metadata: [{ param1: [hidParam("mod")], param2: [hidParam("tap")] }], }, - // Parameterless custom (→ Apps/Media/Special special tile). + // Parameterless custom (→ the Custom tab; unrecognized, so classify "Other"). { id: B.numWord, displayName: "Num Word", metadata: [] }, // Custom layer-tap, same shape as < (→ Hold mode, Layer-Tap identity). { From 91792410ccda2c9b627decfe9badaf0f75d9ec16 Mon Sep 17 00:00:00 2001 From: numachang Date: Wed, 10 Jun 2026 16:24:12 +0900 Subject: [PATCH 22/22] docs(design): record the accepted known edges from the PR #34 review (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add §8b noting three edges no standard behavior triggers (raised in the PR #34 review, all non-blocking and in-scope): wide range params explode into many tiles; shape detection reads only metadata[0]; Mouse routing is a displayName heuristic (intentional). Co-Authored-By: Claude --- docs/design/key-picker-rethink.md | 36 +++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/design/key-picker-rethink.md b/docs/design/key-picker-rethink.md index e65a1605..06207c1a 100644 --- a/docs/design/key-picker-rethink.md +++ b/docs/design/key-picker-rethink.md @@ -676,6 +676,28 @@ integration branch merges to `main`: 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. +### 8b. Known edges (accepted — from the PR #34 review) + +Edges that no standard behavior triggers, recorded so they aren't re-discovered. +None block the merge; each is within the model's stated scope. + +- **Wide `range` params explode into many tiles.** `enumerateParam` + ([`tiles.ts`](../../src/behaviors/tiles.ts)) renders one tile per value, which is + right for the small enums in use (BT profiles, output select). A *future* + firmware-extension behavior exposing a wide numeric `range` would render a very + large tile grid; no current behavior does, so it's left until one appears. +- **Shape detection reads only `metadata[0]`.** `isModTapShape` / `isLayerTapShape` + ([`slots.ts`](../../src/behaviors/slots.ts)) — and the tile/latch predicates — + inspect the first parameter set. Standard behaviors report a single set, so this + is exact for them; a custom hold-tap that carries the matching shape *only* in a + later set wouldn't be recognized as a family member (it falls back to the + residual chip, still settable). +- **Mouse classification is a name heuristic.** `classifyBehavior` + ([`BehaviorBindingPicker.tsx`](../../src/behaviors/BehaviorBindingPicker.tsx)) + routes a behavior whose `displayName` contains "mouse" to the Mouse tab + regardless of shape — intentional, so custom mouse behaviors land with the + standard ones. + --- ## Appendix — what the "flavor" behaviors actually do @@ -1057,3 +1079,17 @@ upstream([`zmkfirmware/zmk-studio`](https://github.com/zmkfirmware/zmk-studio) 4. ~~**Step 5 の範囲。**~~ **解決(Step 5)。** 旧階層は「上級者向け」面として残すのでは なく**排水**:全 behavior を形に応じた置き場へ送り、残置「More behaviors」は自動的に 隠れ、形に収まらない exotic custom のときだけ安全網として出る。並行モードではない。 + +### 8b. 既知のエッジ(許容・PR #34 レビューより) + +標準 behavior では発生しないが、再発見を避けるため記録。いずれもマージのブロッカーでは +なく、モデルの想定範囲内(英語が正)。 + +1. **広い `range` は大量タイルに展開。** `enumerateParam`(`tiles.ts`)は値ごとに1タイル。 + 現用の小さな enum(BT profile・output 等)には正しいが、将来広い数値 `range` を持つ + 拡張 behavior は巨大グリッドになりうる。現状該当なしのため、出てきたら対応。 +2. **形判定は `metadata[0]` のみ参照。** `isModTapShape`/`isLayerTapShape`(`slots.ts`)と + タイル/ラッチ判定は最初のパラメータセットを見る。標準は単一セットなので厳密だが、一致形が + 後続セットにしか無いカスタムは family 未認識(残置チップに落ち、設定は可能)。 +3. **Mouse 分類は名前ヒューリスティック。** `classifyBehavior`(`BehaviorBindingPicker.tsx`)は + `displayName` に "mouse" を含む behavior を形に依らず Mouse タブへ。意図的。