Skip to content
72 changes: 59 additions & 13 deletions docs/design/key-picker-rethink.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,12 +214,41 @@ end state. Each step is its own PR.
so clearing a key never requires a tab round trip. Clicking swaps the binding's
behavior; the active one is highlighted; the grid stays interactive (press any
real key to go back to `&kp`). Leave the existing behavior tabs in place.
- **Where:** a thin strip near the top of
[`BehaviorBindingPicker.tsx`](../../src/behaviors/BehaviorBindingPicker.tsx), or
alongside the `HidUsagePicker`. Resolve the `None`/`Transparent` behaviorIds
from the `behaviors` array by `displayName`; render a tile only if present.
- **Risk:** low — purely additive; reuses the existing `setBehaviorId` path
(lines 192-198) and the existing `doUpdateBinding` write.
- **Where:** *inside the key grid, on every tab* (per the issue's "every
key-grid tab"), rendered as **keycaps** so picking one reads like picking a key:
a **blank cap = None**, a **▽ cap = Transparent** (None is deliberately left
blank — a glyph reads as a specific key, and the tooltip/aria-label already
says "None").
[`HidUsagePicker`](../../src/behaviors/HidUsagePicker.tsx) takes an optional
`clearTiles` config and appends the keycaps among the keys of each tab's layout
(styled identically to a key on that tab — same border, fill and size; the cap
is omitted from the Other tab, which is a combobox not a grid), on Basic /
ISO·JIS / Numpad / … and during search (including the no-match state). The
config is threaded
[`BehaviorBindingPicker`](../../src/behaviors/BehaviorBindingPicker.tsx) →
[`BehaviorParametersPicker`](../../src/behaviors/BehaviorParametersPicker.tsx) →
[`ParameterValuePicker`](../../src/behaviors/ParameterValuePicker.tsx) so the
tiles appear on every keycode grid the binding renders. Resolve the
`None`/`Transparent` behaviorIds from the `behaviors` array by `displayName`;
render a tile only if present. Their now-redundant chips are dropped from the
behavior groups (the keycaps replace them).
- **A11y:** the tiles are **`aria-pressed` toggle buttons**, not radios. They
switch the *whole* binding's behavior and reflect which clear behavior (if any)
is active — so they're not a key (HID-usage) selection, and the same name no
longer lives in two radiogroups. (This resolves the deferred a11y note from the
first cut, which used a `role="radio"` strip.)
- **Risk:** low — additive; reuses the existing `setBehaviorId` path and the
existing `doUpdateBinding` write. When a clear behavior is active the grid stays
live so clicking a key promotes back to `&kp` in one click; other 0-param
behaviors keep the dimmed placeholder.
- **Accepted Step 1 trade-off:** dropping the chips means there's no one-click
clear from a state that renders *no* keycode grid — a layer behavior (layer
radios, no grid) or a non-`&kp` 0-param like Caps Word (dimmed placeholder).
Clearing from there takes two clicks (Key Press → ∅). The common Key Press ↔
None path improves; this rarer gap is left for Step 2, where the slot model
unifies how every state is cleared. (A cheap partial fix — passing `clearTiles`
to the dimmed placeholder — would restore one-click clear from Caps Word but not
from layer behaviors, so it's deferred rather than half-done.)
- **Done when:** from any keycode tab, one click sets `&none`/`&trans`, one click
on a key returns to `&kp`, and the dimmed-placeholder round trip is gone for
this case.
Expand Down Expand Up @@ -501,13 +530,30 @@ behavior は「実際にいくつ枠を持つか」で形が分かれる。見
一気にやると差分が大きく、本フォークの最小主義から外れる。終着点(スロットで派生)
へ後戻りしない順で進める。各 Step は独立 PR。

- **Step 1(最小・低リスク)** — 全タブに常設の **None / Transparent** タイル。
クリックで behavior 差し替え、選択中はハイライト、グリッドは操作可能なまま(文字
キーで `&kp` 復帰)。既存タブは残す。場所は
[`BehaviorBindingPicker.tsx`](../../src/behaviors/BehaviorBindingPicker.tsx) 上部
または `HidUsagePicker` 脇。behaviorId は `behaviors` 配列から解決し、在るときだけ
描画。既存 `setBehaviorId`(192-198 行)と `doUpdateBinding` を再利用。**一番の不満
が消える最小単位。**
- **Step 1(最小・低リスク)** — **キーグリッドの各タブ内**に常設の **None /
Transparent** タイル(issue の「全 key-grid タブ」に忠実)。**キーキャップ**として
描画し、選ぶ動作が「キーを選ぶ」のと同じに見えるようにする(**ブランクのキャップ=
None**、**▽ キャップ=Transparent**。None はあえて空——グリフを入れると特定キーに
見えるし、ツールチップ/aria-label で "None" が出る)。
[`HidUsagePicker`](../../src/behaviors/HidUsagePicker.tsx) に任意の `clearTiles`
設定を渡し、各タブのレイアウトのキーに混ぜて描画(そのタブのキーと同じ枠・塗り・
サイズ。Other タブは combobox でキーではないので出さない)——Basic / ISO·JIS /
Numpad … と検索中(0件時も)表示。設定は
[`BehaviorBindingPicker`](../../src/behaviors/BehaviorBindingPicker.tsx) →
[`BehaviorParametersPicker`](../../src/behaviors/BehaviorParametersPicker.tsx) →
[`ParameterValuePicker`](../../src/behaviors/ParameterValuePicker.tsx) と通し、
binding が出す全キーコードグリッドに出る。クリックで behavior 差し替え、選択中は
ハイライト、クリア中もグリッドは操作可能(キー押下で1クリック `&kp` 復帰)。冗長に
なった None/Transparent のチップは behavior 群から外す(キーキャップで代替)。既存
タブ自体は残す。**割り切り(Step 1)**: チップを消したため、キーコードグリッドを
出さない状態(レイヤー系・Caps Word 等)からのクリアは2クリックになる。よくある
Key Press↔None は改善。この稀なギャップは Step 2 のスロットモデルで統一する。
**A11y:** タイルは radio ではなく **`aria-pressed` トグルボタン**——
binding 全体の behavior を切り替え、どのクリア behavior が有効かを表すだけで、キー
(HID usage)選択ではない。同名が2つの radiogroup に跨る問題も解消(初版の
`role="radio"` ストリップで先送りした a11y 指摘の解決)。behaviorId は `behaviors`
配列から解決し在るときだけ描画。既存 `setBehaviorId` と `doUpdateBinding` を再利用。
**一番の不満が消える最小単位。**
- **Step 2(核)** — `deriveBinding` / `bindingToSlots`(§4)を実装し、4 つの中核
behavior について Hold / Tap 枠を埋めれば `&kp` / `&mt` / `&lt` / `&mo` が派生
するようにする。Hold/Tap タブが主役になり、名前は選ぶのではなく表示される。
Expand Down
53 changes: 51 additions & 2 deletions src/App.edit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,16 @@ async function connectDemo(user: User) {
await screen.findByRole("listbox", { name: /keymap layer/i });
}

// Select the "Q" key on the base layer and reassign it to None.
// Select the "Q" key on the base layer and reassign it to None via the
// persistent in-grid clear tile. The tile is an `aria-pressed` toggle, so the
// `pressed` filter pins it apart from any keymap key that happens to read
// "None".
async function changeQtoNone(user: User) {
const qKey = (await screen.findAllByRole("button", { name: /\bQ\b/ }))[0];
await user.click(qKey);
await user.click(await screen.findByRole("radio", { name: "None" }));
await user.click(
await screen.findByRole("button", { name: "None", pressed: false })
);
}

describe("editing on the demo device drives the save lifecycle", () => {
Expand Down Expand Up @@ -90,3 +95,47 @@ describe("editing on the demo device drives the save lifecycle", () => {
);
});
});

describe("persistent clear tiles (key-picker rethink, step 1)", () => {
it("keeps None and Transparent reachable from the key grid", async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
await connectDemo(user);

const qKey = (await screen.findAllByRole("button", { name: /\bQ\b/ }))[0];
await user.click(qKey);

// Both clear tiles sit in the key grid (aria-pressed toggles), reachable
// without leaving the Key Press grid or switching the behavior tabs.
expect(
await screen.findByRole("button", { name: "None", pressed: false })
).toBeInTheDocument();
expect(
await screen.findByRole("button", { name: "Transparent", pressed: false })
).toBeInTheDocument();
});

it("returns a None'd key to Key Press in one grid click", 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();

// 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).
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)
);
});
});
90 changes: 77 additions & 13 deletions src/behaviors/BehaviorBindingPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ const STANDARD_BEHAVIOR_ORDER: Record<string, number> = Object.fromEntries(

const GROUP_ORDER = ["Basic", "Layer", "Hold-Tap", "Mouse", "System", "Other"];

// 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.
// Falls back to the behavior name on its cap.
const CLEAR_TILE_GLYPHS: Record<string, string> = {
None: "",
Transparent: "▽",
};

interface BehaviorClass {
tier: "standard" | "extension";
group: string;
Expand Down Expand Up @@ -120,6 +128,21 @@ export const BehaviorBindingPicker = ({
[behaviorId, behaviors]
);

// Capability-aware id lookups for the persistent clear tiles. Matching by
// displayName mirrors how the rest of the picker classifies behaviors; a tile
// only renders when the connected firmware actually exposes that behavior.
const keyPressId = useMemo(
() => behaviors.find((b) => b.displayName === "Key Press")?.id,
[behaviors]
);
const clearBehaviors = useMemo(
() =>
["None", "Transparent"]
.map((name) => behaviors.find((b) => b.displayName === name))
.filter((b): b is GetBehaviorDetailsResponse => !!b),
[behaviors]
);

const sortedBehaviors = useMemo(
() =>
behaviors.slice().sort((a, b) => {
Expand All @@ -134,17 +157,21 @@ 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));
const out: Record<
"standard" | "extension",
Record<string, GetBehaviorDetailsResponse[]>
> = { standard: {}, extension: {} };
for (const b of sortedBehaviors) {
if (clearIds.has(b.id)) continue;
const { tier, group } = classifyBehavior(b);
if (!out[tier][group]) out[tier][group] = [];
out[tier][group].push(b);
}
return out;
}, [sortedBehaviors]);
}, [sortedBehaviors, clearBehaviors]);

const tiers: { key: "standard" | "extension"; title: string }[] = [
{ key: "standard", title: "ZMK Standard" },
Expand Down Expand Up @@ -181,6 +208,23 @@ export const BehaviorBindingPicker = ({
? activeGroup
: availableGroupsForActiveTier[0];

const selectBehavior = (id: number) => {
if (id !== behaviorId) {
setBehaviorId(id);
setParam1(0);
setParam2(0);
}
};

// Shared look for the selectable behavior chips, so a future style tweak
// stays consistent across the picker.
const chipClass = (selected: boolean) =>
`px-3 py-1 rounded border text-sm ${
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 (
Expand All @@ -189,18 +233,8 @@ export const BehaviorBindingPicker = ({
type="button"
role="radio"
aria-checked={selected}
onClick={() => {
if (b.id !== behaviorId) {
setBehaviorId(b.id);
setParam1(0);
setParam2(0);
}
}}
className={`px-3 py-1 rounded border text-sm ${
selected
? "bg-primary text-primary-content border-primary"
: "bg-base-200 hover:bg-base-300 border-base-300"
}`}
onClick={() => selectBehavior(b.id)}
className={chipClass(selected)}
>
{b.displayName}
</button>
Expand Down Expand Up @@ -246,6 +280,34 @@ export const BehaviorBindingPicker = ({
setParam2(binding.param2);
}, [binding]);

const isClearBehavior = clearBehaviors.some((b) => b.id === behaviorId);
// Only offer the live keycode grid (instead of the dimmed placeholder) while
// a clear tile is active, and only if Key Press exists to promote to.
const promoteToKeyPress =
keyPressId !== undefined && isClearBehavior
? (usage?: number) => {
if (usage === undefined) return;
setBehaviorId(keyPressId);
setParam1(usage);
setParam2(0);
}
: undefined;

// Persistent None / Transparent tiles rendered inside every key-grid tab (see
// HidUsagePicker). activeId highlights whichever is the current behavior.
const clearTiles =
clearBehaviors.length > 0
? {
tiles: clearBehaviors.map((b) => ({
id: b.id,
label: b.displayName,
glyph: CLEAR_TILE_GLYPHS[b.displayName] ?? b.displayName,
})),
activeId: behaviorId,
onSelect: selectBehavior,
}
: undefined;

return (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1">
Expand Down Expand Up @@ -329,6 +391,8 @@ export const BehaviorBindingPicker = ({
layers={layers}
onParam1Changed={setParam1}
onParam2Changed={setParam2}
onPromoteToKeyPress={promoteToKeyPress}
clearTiles={clearTiles}
/>
)}
</div>
Expand Down
36 changes: 32 additions & 4 deletions src/behaviors/BehaviorParametersPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { BehaviorBindingParametersSet } from "@zmkfirmware/zmk-studio-ts-client/
import { Tab, TabList, TabPanel, Tabs } from "react-aria-components";
import { useEffect, useState } from "react";
import { ParameterValuePicker } from "./ParameterValuePicker";
import { HidUsagePicker } from "./HidUsagePicker";
import { ClearTilesConfig, HidUsagePicker } from "./HidUsagePicker";
import { validateValue } from "./parameters";

export interface BehaviorParametersPickerProps {
Expand All @@ -12,6 +12,19 @@ export interface BehaviorParametersPickerProps {
layers: { id: number; name: string }[];
onParam1Changed: (value?: number) => void;
onParam2Changed: (value?: number) => void;
/**
* When set, a 0-parameter behavior (None / Transparent) renders a *live*
* keycode grid instead of the dimmed placeholder: picking a key promotes the
* binding straight to Key Press in one click, so toggling Key Press <-> None
* never costs a round trip back through the behavior tabs.
*/
onPromoteToKeyPress?: (value?: number) => void;
/**
* Persistent None / Transparent tiles shown in every key-grid tab (see
* {@link ClearTilesConfig}). Threaded onto each keycode grid this picker
* renders so clearing a key is reachable without leaving the grid.
*/
clearTiles?: ClearTilesConfig;
}

export const BehaviorParametersPicker = ({
Expand All @@ -21,6 +34,8 @@ export const BehaviorParametersPicker = ({
layers,
onParam1Changed,
onParam2Changed,
onPromoteToKeyPress,
clearTiles,
}: BehaviorParametersPickerProps) => {
const [activeParam, setActiveParam] = useState<"p1" | "p2">("p1");

Expand Down Expand Up @@ -96,11 +111,23 @@ export const BehaviorParametersPicker = ({
value={param1}
layers={layers}
onValueChanged={onParam1Changed}
clearTiles={clearTiles}
/>
) : onPromoteToKeyPress ? (
// None / Transparent: keep the grid live so clicking a key returns
// the binding to Key Press in a single click (no tab round trip).
<HidUsagePicker
usagePages={[
{ id: 7, min: 4 },
{ id: 12 },
]}
onValueChanged={onPromoteToKeyPress}
clearTiles={clearTiles}
/>
) : (
// 0-param behaviors (None, Transparent, Caps Word, ...) get the
// dimmed HID picker as a placeholder so the panel keeps its size
// when the user toggles to/from a `&kp`-style behavior.
// Other 0-param behaviors (Caps Word, ...) get the dimmed HID picker
// as a placeholder so the panel keeps its size when the user toggles
// to/from a `&kp`-style behavior.
<HidUsagePicker
disabled
usagePages={[
Expand All @@ -118,6 +145,7 @@ export const BehaviorParametersPicker = ({
value={param2}
layers={layers}
onValueChanged={onParam2Changed}
clearTiles={clearTiles}
/>
</TabPanel>
)}
Expand Down
Loading
Loading