From 78db363d43b4e3c52f111c87f6e1cbb820ba5b3e Mon Sep 17 00:00:00 2001 From: snekxs <26660858+snekxs@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:30:27 -0600 Subject: [PATCH 1/7] feat(keychron): add a Hall-effect keyboard adapter behind a flag Wraps KeychronHidClient for the keyboard workspace: profiles, staged actuation and rapid-trigger writes with an explicit save, the response curve with Launcher's ranges, live key travel, calibration, the switch axis and key remapping through setKeycode. Keychron HID filters and the keyboard factory only include these boards when the openmouse.keychronHallEffect flag is set in localStorage. KeyboardClient gains an optional recoveredWorkspace() so a failed write can still show the keymap the board actually holds. Not verified on hardware. Needs @openmouse/keyboard-protocol with the Keychron driver (keyboard-protocol#6). --- src/device/controller.ts | 2 + src/device/device-clients.ts | 6 +- src/device/hid-filters.ts | 16 +- src/device/keyboard-client.ts | 4 +- src/device/keyboard-workspace.ts | 5 +- src/device/keychron-curve.test.ts | 53 ++++++ src/device/keychron-curve.ts | 67 +++++++ src/device/keychron-flag.ts | 16 ++ src/device/keychron-keyboard.test.ts | 264 +++++++++++++++++++++++++++ src/device/keychron-keyboard.ts | 226 +++++++++++++++++++++++ src/device/keychron-layout-data.ts | 2 + src/device/keychron-layout.ts | 27 +++ src/device/keychron-legend.ts | 13 ++ src/device/keychron-picker.test.ts | 51 ++++++ src/device/keychron-picker.ts | 30 +++ src/device/keychron-remap.ts | 130 +++++++++++++ src/device/keychron-travel.test.ts | 40 ++++ src/device/keychron-travel.ts | 72 ++++++++ src/device/types.ts | 2 +- 19 files changed, 1021 insertions(+), 5 deletions(-) create mode 100644 src/device/keychron-curve.test.ts create mode 100644 src/device/keychron-curve.ts create mode 100644 src/device/keychron-flag.ts create mode 100644 src/device/keychron-keyboard.test.ts create mode 100644 src/device/keychron-keyboard.ts create mode 100644 src/device/keychron-layout-data.ts create mode 100644 src/device/keychron-layout.ts create mode 100644 src/device/keychron-legend.ts create mode 100644 src/device/keychron-picker.test.ts create mode 100644 src/device/keychron-picker.ts create mode 100644 src/device/keychron-remap.ts create mode 100644 src/device/keychron-travel.test.ts create mode 100644 src/device/keychron-travel.ts diff --git a/src/device/controller.ts b/src/device/controller.ts index 01bbdb21..c428c007 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -3288,6 +3288,8 @@ export async function applyKeyboardCommand(command: KeyboardCommand): Promise ({ + vendorId: KEYCHRON_VENDOR_ID, + productId: Number(productId), + usagePage: KEYCHRON_CONFIG_USAGE_PAGE, + usage: KEYCHRON_CONFIG_USAGE, +})); + +/** Keychron filters are offered only behind the experimental flag, read when the app loads. */ +export const HID_FILTERS: HIDDeviceFilter[] = [ + ...SUPPORTED_HID_FILTERS, ...KEYBOARD_HID_FILTERS, ...(keychronHallEffectEnabled() ? KEYCHRON_HID_FILTERS : []), + BASILISK_V3_PRO_BLUETOOTH, +]; diff --git a/src/device/keyboard-client.ts b/src/device/keyboard-client.ts index c08e1269..246d8801 100644 --- a/src/device/keyboard-client.ts +++ b/src/device/keyboard-client.ts @@ -3,13 +3,15 @@ import type { KeyboardIdentityView, KeyboardPollingView, KeyboardTriggerKey, Key export interface KeyboardClient { readonly device: HIDDevice; - readonly family: "wuque" | "kochi" | "wooting" | "razer" | "logitech" | "steelseries"; + readonly family: "wuque" | "kochi" | "wooting" | "razer" | "logitech" | "steelseries" | "keychron"; readonly brand: string; readonly name: string; open(): Promise; close(): Promise; readIdentity(): Promise; command(command: KeyboardCommand): Promise>; + /** State to merge after `command` threw, when the failure also changed what the board holds. */ + recoveredWorkspace?(): Partial | null; readPolling?(): Promise; setPollingRate?(hz: number): Promise; readTrigger?(): Promise<{ matrix: readonly (readonly number[])[]; keys: KeyboardTriggerKey[] }>; diff --git a/src/device/keyboard-workspace.ts b/src/device/keyboard-workspace.ts index f9dd3c74..35f4fa02 100644 --- a/src/device/keyboard-workspace.ts +++ b/src/device/keyboard-workspace.ts @@ -6,6 +6,7 @@ import type { WootingAkcPayloadWrite, WootingRapidTriggerTarget, WootingRgbColor import type { RazerKeyboardCommand } from './razer-keyboard.ts'; import type { LogitechKeyboardCommand } from './logitech-keyboard.ts'; import type { SteelSeriesKeyboardCommand } from './steelseries-keyboard.ts'; +import type { KeychronKeyboardCommand, KeychronWorkspace } from './keychron-keyboard.ts'; export interface WootingRgbView { core: WootingRgbCore | null; @@ -20,6 +21,8 @@ export interface KeyboardWorkspace { razerGameModeLed?: boolean | null; /** Typed RGB core and color pages; raw `wooting.rgb` stays for diagnostics. */ wootingRgb?: WootingRgbView | null; + /** Keychron Hall-effect state; set only while the experimental flag is on. */ + keychron?: KeychronWorkspace | null; /** HB68 mode; independent of the Wuque global-settings structure. */ systemMode?: "win" | "mac"; global: WuqueGlobalState | null; @@ -35,7 +38,7 @@ export interface KeyboardWorkspace { } export const emptyKeyboardWorkspace = (): KeyboardWorkspace => ({ global: null, lighting: null, custom: [], shell: null, layer: null, styles: [], defaults: [], macro: null, higher: null, adc: null }); export type KeyboardCommand = - | RazerKeyboardCommand | LogitechKeyboardCommand | SteelSeriesKeyboardCommand + | RazerKeyboardCommand | LogitechKeyboardCommand | SteelSeriesKeyboardCommand | KeychronKeyboardCommand | { kind: 'refresh' } | { kind: 'wootingKeyActuation'; keys: number | readonly number[]; actuationMm: number | null } | { kind: 'wootingKeyRapidTrigger'; keys: number | readonly number[]; target: WootingRapidTriggerTarget | null } diff --git a/src/device/keychron-curve.test.ts b/src/device/keychron-curve.test.ts new file mode 100644 index 00000000..9acfbe97 --- /dev/null +++ b/src/device/keychron-curve.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { KeychronState } from "@openmouse/keyboard-protocol/drivers"; +import type { KeychronCurve } from "@openmouse/keyboard-protocol/keychron"; +import { CURVE_MAX_Y, clampCurve, curveInRange, curveScale, linearCurve, setCurvePoint } from "./keychron-curve.ts"; + +const base: KeychronState = { + productId: 0x040c, heVersion: 4, features: { switchAxis: true, resetSingleKey: false, dkr: false, turbo: false, gamepadDisable: false, supportMechanical: true }, + unit: 0.1, profileSize: 0, dksCount: null, snapTapCount: null, rows: 6, cols: 19, travel: { min: 0.2, max: 3.3, deadzone: 0.2 }, +}; + +test("x runs to floor(10 * (max + dead zone)) and y to 127", () => { + assert.deepEqual(curveScale(base, null), { maxX: 35, fromBoard: true, unverifiedStep: false }); + assert.equal(curveScale({ ...base, travel: { min: 0.1, max: 3.35 } }, null).maxX, 33); + assert.equal(curveScale({ ...base, travel: null }, 3.3).maxX, 33); + assert.deepEqual(curveScale({ ...base, travel: null }, null), { maxX: 255, fromBoard: false, unverifiedStep: false }); + assert.equal(curveScale({ ...base, heVersion: 5 }, null).unverifiedStep, true); +}); + +test("the linear preset is Launcher's: thirds along, ending at (max, 127)", () => { + assert.deepEqual(linearCurve(35), [[0, 0], [12, 42], [23, 85], [35, 127]]); + assert.ok(curveInRange(linearCurve(35), 35)); + assert.ok(curveInRange(linearCurve(255), 255)); +}); + +test("a point stays between its neighbours and inside the ranges", () => { + const curve = linearCurve(35); + assert.deepEqual(setCurvePoint(curve, 1, 99, 500, 35)[1], [23, 127]); + assert.deepEqual(setCurvePoint(curve, 2, -4, -9, 35)[2], [12, 0]); + assert.deepEqual(setCurvePoint(curve, 0, 8, 3, 35)[0], [8, 3]); // the first point may move right, up to the second point + assert.deepEqual(setCurvePoint(curve, 0, 40, 3, 35)[0], [12, 3]); + assert.deepEqual(setCurvePoint(curve, 3, 200, 10, 35)[3], [35, 10]); + assert.deepEqual(setCurvePoint(curve, 1, Number.NaN, Number.NaN, 35)[1], [0, 0]); +}); + +test("no sequence of edits can leave a byte out of range", () => { + let seed = 7; + const random = (): number => { seed = (seed * 48271) % 2147483647; return seed / 2147483647; }; + for (const maxX of [1, 33, 35, 255]) { + let curve: KeychronCurve = linearCurve(maxX); + for (let step = 0; step < 500; step++) { + curve = setCurvePoint(curve, Math.floor(random() * 4), (random() - 0.3) * 600, (random() - 0.3) * 400, maxX); + assert.ok(curveInRange(curve, maxX), JSON.stringify(curve)); + } + } +}); + +test("clampCurve repairs a curve outside the ranges", () => { + const repaired = clampCurve([[40, 200], [30, -3], [220, 50], [10, 127]], 35); + assert.deepEqual(repaired, [[35, CURVE_MAX_Y], [35, 0], [35, 50], [35, 127]]); + assert.ok(curveInRange(repaired, 35)); + assert.equal(curveInRange([[0, 0], [36, 0], [36, 0], [36, 0]], 35), false); +}); diff --git a/src/device/keychron-curve.ts b/src/device/keychron-curve.ts new file mode 100644 index 00000000..b6d535d0 --- /dev/null +++ b/src/device/keychron-curve.ts @@ -0,0 +1,67 @@ +import { KEYCHRON_ADVANCE_MODE, type KeychronCurve, type KeychronProfileBuffer } from "@openmouse/keyboard-protocol/keychron"; +import type { KeychronState } from "@openmouse/keyboard-protocol/drivers"; + +/** + * Ranges for the response curve, as Keychron Launcher's curve editor uses them + * (read from its code, not verified on hardware): x is key travel in 0.1 mm + * steps up to the board's maximum travel plus dead zone, y is output strength + * 0 to 127, and the four points keep x non-decreasing left to right. + */ +export const CURVE_MAX_Y = 127; +/** Millimetres per x step, as Launcher derives it (mm * 10). */ +export const CURVE_X_STEP_MM = 0.1; + +export interface CurveScale { + /** Largest x byte. */ + maxX: number; + /** False when neither the board definition nor the axis type gave a travel range, so the byte limit is used. */ + fromBoard: boolean; + /** Command v5 uses another travel step; Launcher still scales x by 10, so that scale is unverified there. */ + unverifiedStep: boolean; +} + +export function curveScale(state: KeychronState, axisMaxMm: number | null): CurveScale { + const travel = state.travel; + const mm = travel?.max !== undefined ? travel.max + (travel.deadzone ?? 0) : axisMaxMm; + const maxX = mm === null || mm <= 0 ? 255 : Math.min(255, Math.floor(mm / CURVE_X_STEP_MM + 1e-6)); + return { maxX, fromBoard: mm !== null && mm > 0, unverifiedStep: state.heVersion >= 5 }; +} + +const clamp = (value: number, low: number, high: number): number => Math.min(high, Math.max(low, Math.round(Number.isFinite(value) ? value : low))); + +/** Move one point, keeping every byte in range and x between its neighbours. */ +export function setCurvePoint(curve: KeychronCurve, index: number, x: number, y: number, maxX: number): KeychronCurve { + const low = index === 0 ? 0 : curve[index - 1]![0]; + const high = index === curve.length - 1 ? maxX : curve[index + 1]![0]; + return curve.map((point, i) => (i === index ? [clamp(x, low, Math.max(low, high)), clamp(y, 0, CURVE_MAX_Y)] : point)) as unknown as KeychronCurve; +} + +/** Bring any curve into range, left to right. */ +export function clampCurve(curve: KeychronCurve, maxX: number): KeychronCurve { + let previous = 0; + return curve.map(([x, y]) => { + previous = clamp(x, previous, maxX); + return [previous, clamp(y, 0, CURVE_MAX_Y)]; + }) as unknown as KeychronCurve; +} + +export function curveInRange(curve: KeychronCurve, maxX: number): boolean { + let previous = 0; + return curve.every(([x, y]) => { + const ok = Number.isInteger(x) && Number.isInteger(y) && x >= previous && x <= maxX && y >= 0 && y <= CURVE_MAX_Y; + previous = x; + return ok; + }); +} + +/** Launcher's linear preset: from (0, 0) to (max, 127) with the middle points a third and two thirds along. */ +export function linearCurve(maxX: number): KeychronCurve { + const at = (fraction: number): [number, number] => [Math.round(maxX * fraction), Math.round(CURVE_MAX_Y * fraction)]; + return [[0, 0], at(1 / 3), at(2 / 3), [maxX, CURVE_MAX_Y]]; +} + +/** Keys set to the analog advanced mode; the curve shapes their output. */ +export function analogKeyCount(profile: KeychronProfileBuffer | null): number { + if (!profile) return 0; + return profile.keys.reduce((sum, row) => sum + row.filter((key) => key.advanceMode === KEYCHRON_ADVANCE_MODE.analog).length, 0); +} diff --git a/src/device/keychron-flag.ts b/src/device/keychron-flag.ts new file mode 100644 index 00000000..d229fb36 --- /dev/null +++ b/src/device/keychron-flag.ts @@ -0,0 +1,16 @@ +/** + * Keychron Hall-effect support is experimental: the protocol was derived from + * static analysis of Keychron Launcher and has not run on a physical board. + * It stays off until the flag is set, e.g. in the browser console: + * + * localStorage.setItem("openmouse.keychronHallEffect", "1"); location.reload(); + */ +export const KEYCHRON_HALL_EFFECT_FLAG = "openmouse.keychronHallEffect"; + +export function keychronHallEffectEnabled(): boolean { + try { + return globalThis.localStorage?.getItem(KEYCHRON_HALL_EFFECT_FLAG) === "1"; + } catch { + return false; + } +} diff --git a/src/device/keychron-keyboard.test.ts b/src/device/keychron-keyboard.test.ts new file mode 100644 index 00000000..bfc235cc --- /dev/null +++ b/src/device/keychron-keyboard.test.ts @@ -0,0 +1,264 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { KEYCHRON_HE_COMMAND, KEYCHRON_HE_SUBCOMMAND as SUB } from "@openmouse/keyboard-protocol/keychron"; +import { KeychronKeyboardClient } from "./keychron-keyboard.ts"; +import { KEYCHRON_HID_FILTERS } from "./hid-filters.ts"; +import { createKeyboardClient } from "./device-clients.ts"; +import { KEYCHRON_HALL_EFFECT_FLAG } from "./keychron-flag.ts"; + +/** Keychron J3 HE ANSI: 6 x 19 keys, travel 0.2 to 3.3 mm, dead zone 0.2 mm (range 0.2 to 3.5 mm). */ +const PRODUCT_ID = 0x040c; +const ROWS = 6; +const COLS = 19; +const SIZE = 4 * (ROWS * COLS + 1) + 19 * 20 + 3 * 20 + 32; + +/** A v4 board with three profiles; the active one is slot 1. Calibration state and curve are mutable. */ +class FakeBoard { + vendorId = 0x3434; + productId = PRODUCT_ID; + productName = "Keychron J3 HE"; + opened = false; + collections = [{ usagePage: 0xff60, usage: 0x61, inputReports: [], outputReports: [], featureReports: [], children: [] }]; + listeners = new Set<(event: HIDInputReportEvent) => void>(); + sent: Uint8Array[] = []; + buffer = new Uint8Array(SIZE); + curve = [0, 0, 12, 42, 24, 85, 35, 127]; + /** Report this curve instead of the one written, to model a board that ignores the write. */ + ignoreCurveWrite = false; + calibrationState = 0; + viaSent: Uint8Array[] = []; + /** Leave VIA reads unanswered, as a board without the dynamic keymap would. */ + noKeymap = false; + get hid(): HIDDevice { return this as unknown as HIDDevice; } + async open() { this.opened = true; } + async close() { this.opened = false; } + addEventListener(_type: string, listener: (event: HIDInputReportEvent) => void) { this.listeners.add(listener); } + removeEventListener(_type: string, listener: (event: HIDInputReportEvent) => void) { this.listeners.delete(listener); } + commands(subcommand: number): Uint8Array[] { return this.sent.filter((frame) => frame[1] === subcommand); } + /** Standard VIA reads: protocol version, layer count, and a keymap whose code is its own flat index + 4. */ + private answerVia(request: Uint8Array) { + this.viaSent.push(request.slice()); + if (this.noKeymap) return; + const reply = new Uint8Array(32); + reply[0] = request[0]!; + if (request[0] === 0x01) reply[2] = 12; + else if (request[0] === 0x11) reply[1] = 2; + else { + const offset = (request[1]! << 8) | request[2]!; + reply.set(request.slice(1, 4), 1); + for (let i = 0; i < request[3]!; i += 2) { const code = offset / 2 + i / 2 + 4; reply[4 + i] = code >> 8; reply[5 + i] = code & 0xff; } + } + const event = { device: this.hid, reportId: 0, data: new DataView(reply.buffer, 0, 32) } as HIDInputReportEvent; + this.listeners.forEach((listener) => listener(event)); + } + async sendReport(_reportId: number, data: BufferSource) { + const request = data as Uint8Array; + if ([0x01, 0x11, 0x12].includes(request[0]!)) return this.answerVia(request); + assert.equal(request[0], KEYCHRON_HE_COMMAND); + this.sent.push(request.slice()); + if (request[1] === SUB.calibrate && request[2] === 4) return; // stop is never answered + const reply = new Uint8Array(32); + reply.set(request.slice(0, 2)); + switch (request[1]) { + case SUB.getVersion: reply.set([4, 0, 0b100001], 2); break; + case SUB.getProfilesInfo: reply.set([1, 3, SIZE & 0xff, SIZE >> 8, 20, 20], 2); break; + case SUB.getProfileBuffer: { + reply.set(request.slice(2, 6), 2); + const offset = request[3]! | (request[4]! << 8); + const nameStart = SIZE - 32; + const name = new Uint8Array(32); + name.set([0, 0x50, 0, 0x30 + request[2]!]); + for (let i = 0; i < request[5]!; i++) reply[6 + i] = offset + i >= nameStart ? name[offset + i - nameStart]! : this.buffer[offset + i]!; + break; + } + case SUB.setTravel: case SUB.saveProfileBuffer: case SUB.clearProfileBuffer: case SUB.selectProfile: reply[2] = 0; break; + case SUB.getCurve: reply.set(this.curve, 2); break; + case SUB.setCurve: if (!this.ignoreCurveWrite) this.curve = [...request.slice(2, 10)]; break; + case SUB.getRealtimeTravel: reply.set([request[2]!, request[3]!, 33], 3); break; + case SUB.getAxisType: reply.set([2, 0x21, 0x00], 2); break; + case SUB.calibrate: this.calibrationState = 2; break; + case SUB.getCalibrateState: reply[3] = this.calibrationState; reply[4] = 0b101; break; + default: break; + } + const event = { device: this.hid, reportId: 0, data: new DataView(reply.buffer, 0, 32) } as HIDInputReportEvent; + this.listeners.forEach((listener) => listener(event)); + } +} + +async function ready(board = new FakeBoard()) { + const client = new KeychronKeyboardClient(board.hid); + const workspace = await client.command({ kind: "refresh" }); + return { board, client, workspace }; +} + +test("refresh reads status, profile, curve and axis type", async () => { + const { workspace } = await ready(); + const keychron = workspace.keychron!; + assert.equal(keychron.status.activeProfile, 1); + assert.deepEqual(keychron.status.profileNames, ["P0", "P1", "P2"]); + assert.equal(keychron.status.keychron?.unit, 0.1); + assert.equal(keychron.profile?.keys.length, ROWS); + assert.equal(keychron.profile?.keys[0]?.length, COLS); + assert.deepEqual(keychron.curve, [[0, 0], [12, 42], [24, 85], [35, 127]]); + assert.equal(keychron.axis?.type, 2); + assert.equal(keychron.unsaved, null); + assert.equal(keychron.layout.physical, true); + assert.equal(keychron.layout.keys.length, 100); + assert.equal(keychron.keymap?.length, ROWS); + assert.equal(keychron.keymap?.[0]?.[0], 4); + assert.equal(keychron.keymap?.[1]?.[0], 4 + COLS); + assert.equal(keychron.calibrationPhase, "idle"); +}); + +test("a board that does not answer the keymap read still returns everything else and keeps working", async () => { + const board = new FakeBoard(); + board.noKeymap = true; + const client = new KeychronKeyboardClient(board.hid); + const keychron = (await client.command({ kind: "refresh" })).keychron!; + assert.equal(keychron.keymap, null); + assert.ok(keychron.profile && keychron.curve && keychron.axis); + assert.ok(keychron.layout.physical); + assert.equal(board.viaSent.length, 1); + const travel = await client.command({ kind: "keychronKeyTravel", row: 1, col: 1 }); + assert.equal(travel.keychron?.keyTravel?.steps, 33); +}); + +test("setTravel stages the write and does not save", async () => { + const { board, client } = await ready(); + const result = await client.command({ kind: "keychronSetTravel", rapidTrigger: true, pressMm: 1.5, sensitivePressMm: 0.3, sensitiveReleaseMm: 0.4 }); + const writes = board.commands(SUB.setTravel); + assert.equal(writes.length, 1); + // slot, work mode (quick trigger), press, rapid-trigger press and release, key mode (all keys) + assert.deepEqual([...writes[0]!.slice(2, 8)], [1, 2, 15, 3, 4, 1]); + assert.equal(board.commands(SUB.saveProfileBuffer).length, 0); + assert.deepEqual(result.keychron?.unsaved, { press: 15, sensitivePress: 3, sensitiveRelease: 4 }); +}); + +test("plain actuation uses the static work mode and sends no rapid-trigger values", async () => { + const { board, client } = await ready(); + await client.command({ kind: "keychronSetTravel", rapidTrigger: false, pressMm: 1.5, sensitivePressMm: 0.3, sensitiveReleaseMm: 0.4 }); + assert.deepEqual([...board.commands(SUB.setTravel)[0]!.slice(2, 8)], [1, 1, 15, 0, 0, 1]); +}); + +test("a selected key is written through the per-key bitmap", async () => { + const { board, client } = await ready(); + await client.command({ kind: "keychronSetTravel", rapidTrigger: false, keys: [{ row: 1, col: 9 }], pressMm: 1, sensitivePressMm: 0.3, sensitiveReleaseMm: 0.3 }); + const write = board.commands(SUB.setTravel)[0]!; + assert.equal(write[7], 0); // per-key + assert.equal(write[8 + 3 * 1 + 1], 0b10); // byte 3 * row + floor(col / 8), bit col % 8 + assert.equal(write.slice(8, 32).reduce((sum, byte) => sum + byte, 0), 0b10); + await assert.rejects(() => client.command({ kind: "keychronSetTravel", rapidTrigger: false, keys: [], pressMm: 1, sensitivePressMm: 0.3, sensitiveReleaseMm: 0.3 }), RangeError); +}); + +test("renaming writes the name, and is blocked while a travel change is unsaved", async () => { + const { board, client } = await ready(); + const result = await client.command({ kind: "keychronRename", slot: 2, name: " Work " }); + const [write] = board.commands(SUB.setProfileName); + assert.equal(write![2], 2); + assert.equal(write![3], 8); // "Work" in UTF-16BE + assert.equal(board.commands(SUB.saveProfileBuffer).length, 1); + assert.ok(result.keychron); + await assert.rejects(() => client.command({ kind: "keychronRename", slot: 2, name: "" }), RangeError); + await client.command({ kind: "keychronSetTravel", rapidTrigger: false, pressMm: 1, sensitivePressMm: 0.3, sensitiveReleaseMm: 0.3 }); + await assert.rejects(() => client.command({ kind: "keychronRename", slot: 2, name: "Other" }), /unsaved/); + assert.equal(board.commands(SUB.setProfileName).length, 1); +}); + +test("save needs a staged change, persists it, then re-reads the profile", async () => { + const { board, client } = await ready(); + await assert.rejects(() => client.command({ kind: "keychronSave" }), /no travel change/); + await client.command({ kind: "keychronSetTravel", rapidTrigger: true, pressMm: 1.5, sensitivePressMm: 0.3, sensitiveReleaseMm: 0.3 }); + const reads = board.commands(SUB.getProfileBuffer).length; + const result = await client.command({ kind: "keychronSave" }); + assert.equal(board.commands(SUB.saveProfileBuffer).length, 1); + assert.ok(board.commands(SUB.getProfileBuffer).length > reads); + assert.equal(result.keychron?.unsaved, null); +}); + +test("travel outside the board's declared range is rejected before anything is sent", async () => { + const { board, client } = await ready(); + for (const pressMm of [0.1, 3.6, Number.NaN]) { + await assert.rejects(() => client.command({ kind: "keychronSetTravel", rapidTrigger: true, pressMm, sensitivePressMm: 0.3, sensitiveReleaseMm: 0.3 }), RangeError); + } + assert.equal(board.commands(SUB.setTravel).length, 0); +}); + +test("a staged change blocks switching profile", async () => { + const { board, client } = await ready(); + await client.command({ kind: "keychronSetTravel", rapidTrigger: true, pressMm: 1, sensitivePressMm: 0.3, sensitiveReleaseMm: 0.3 }); + await assert.rejects(() => client.command({ kind: "keychronSelectProfile", slot: 2 }), /unsaved/); + assert.equal(board.commands(SUB.selectProfile).length, 0); +}); + +test("selecting a profile writes the slot and re-reads the board", async () => { + const { board, client } = await ready(); + await client.command({ kind: "keychronSelectProfile", slot: 2 }); + assert.deepEqual([...board.commands(SUB.selectProfile)[0]!.slice(2, 3)], [2]); + await assert.rejects(() => client.command({ kind: "keychronSelectProfile", slot: 3 }), RangeError); +}); + +test("clearing a profile needs the confirmation flag", async () => { + const { board, client } = await ready(); + await assert.rejects(() => client.command({ kind: "keychronClear" } as never), /confirmation/); + assert.equal(board.commands(SUB.clearProfileBuffer).length, 0); + await client.command({ kind: "keychronClear", confirmed: true }); + assert.deepEqual([...board.commands(SUB.clearProfileBuffer)[0]!.slice(2, 3)], [1]); +}); + +test("starting calibration needs the confirmation flag; stop is sent without a reply", async () => { + const { board, client } = await ready(); + await assert.rejects(() => client.command({ kind: "keychronCalibration", action: "start" } as never), /confirmation/); + assert.equal(board.commands(SUB.calibrate).length, 0); + const started = await client.command({ kind: "keychronCalibration", action: "start", confirmed: true }); + assert.equal(started.keychron?.calibration?.state, 2); + assert.equal(started.keychron?.calibration?.keys.filter(Boolean).length, 2); + const stopped = await client.command({ kind: "keychronCalibration", action: "stop" }); + assert.deepEqual(board.commands(SUB.calibrate).map((frame) => frame[2]), [2, 4]); + assert.equal(stopped.keychron?.calibration, null); +}); + +test("a curve write is confirmed by reading the board back", async () => { + const { board, client } = await ready(); + const curve = [[1, 2], [3, 4], [5, 6], [7, 8]] as const; + const result = await client.command({ kind: "keychronWriteCurve", curve }); + assert.deepEqual(result.keychron?.curve, curve); + board.ignoreCurveWrite = true; + await assert.rejects(() => client.command({ kind: "keychronWriteCurve", curve: [[9, 9], [9, 9], [9, 9], [9, 9]] }), /different response curve/); + const writes = board.commands(SUB.setCurve).length; + for (const bad of [[[36, 0], [36, 0], [36, 0], [36, 0]], [[0, 128], [1, 0], [2, 0], [3, 0]], [[5, 0], [4, 0], [6, 0], [7, 0]]] as const) { + await assert.rejects(() => client.command({ kind: "keychronWriteCurve", curve: bad }), RangeError); + } + assert.equal(board.commands(SUB.setCurve).length, writes); +}); + +test("live key travel reads one key inside the matrix", async () => { + const { client } = await ready(); + const result = await client.command({ kind: "keychronKeyTravel", row: 2, col: 5 }); + assert.deepEqual(result.keychron?.keyTravel, { row: 2, col: 5, steps: 33 }); + await assert.rejects(() => client.command({ kind: "keychronKeyTravel", row: ROWS, col: 0 }), RangeError); +}); + +test("commands for other brands are refused", async () => { + const { client } = await ready(); + await assert.rejects(() => client.command({ kind: "system", value: 0 }), /different keyboard brand/); +}); + +test("Keychron filters cover every catalogued board's raw-HID collection", () => { + assert.ok(KEYCHRON_HID_FILTERS.length > 100); + assert.ok(KEYCHRON_HID_FILTERS.every((filter) => filter.vendorId === 0x3434 && filter.usagePage === 0xff60 && filter.usage === 0x61)); + assert.ok(KEYCHRON_HID_FILTERS.some((filter) => filter.productId === PRODUCT_ID)); +}); + +test("the keyboard factory ignores Keychron boards until the flag is set", () => { + const store = new Map(); + const storage = { getItem: (key: string) => store.get(key) ?? null }; + Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true }); + try { + const board = new FakeBoard(); + assert.equal(createKeyboardClient(board.hid), null); + store.set(KEYCHRON_HALL_EFFECT_FLAG, "1"); + assert.ok(createKeyboardClient(board.hid) instanceof KeychronKeyboardClient); + } finally { + Reflect.deleteProperty(globalThis, "localStorage"); + } +}); diff --git a/src/device/keychron-keyboard.ts b/src/device/keychron-keyboard.ts new file mode 100644 index 00000000..383531ce --- /dev/null +++ b/src/device/keychron-keyboard.ts @@ -0,0 +1,226 @@ +import { KeychronHidClient } from "@openmouse/keyboard-protocol/drivers/keychron/hid"; +import type { KeyboardStatus, KeychronState } from "@openmouse/keyboard-protocol/drivers"; +import { KEYCHRON_WORK_MODE, keychronKeyTarget } from "@openmouse/keyboard-protocol/keychron"; +import type { + KeychronAxisType, KeychronCalibrationState, KeychronCurve, KeychronKeyPosition, KeychronProfileBuffer, +} from "@openmouse/keyboard-protocol/keychron"; +import type { KeyboardClient } from "./keyboard-client.ts"; +import type { KeyboardCommand, KeyboardWorkspace } from "./keyboard-workspace.ts"; +import type { KeyboardIdentityView } from "./types.ts"; +import { travelToSteps } from "./keychron-travel.ts"; +import { curveInRange, curveScale } from "./keychron-curve.ts"; +import { loadKeychronLayout, type KeychronLayout } from "./keychron-layout.ts"; +import { loadRemap, remapKey, selectRemapLayer, type KeychronRemapState } from "./keychron-remap.ts"; + +export interface KeychronWorkspace { + status: KeyboardStatus; + /** The active profile as the board last reported it. */ + profile: KeychronProfileBuffer | null; + curve: KeychronCurve | null; + axis: KeychronAxisType | null; + calibration: KeychronCalibrationState | null; + /** + * What this app last asked calibration to do. The board's own state byte has + * no documented meaning, so the panel words the state from this, not from it. + */ + calibrationPhase: "idle" | "started" | "stopped"; + /** Physical key boxes, or a plain grid of the matrix when the board's layout is unknown. */ + layout: KeychronLayout; + /** Layer 0 keycodes by [row][col], when the board's keymap could be read; they label the keys. */ + keymap: readonly (readonly number[])[] | null; + /** Key remapping: the selected layer's keymap, the keycode context and the last result. */ + remap: KeychronRemapState; + /** Last live reading of one key, in profile steps. */ + keyTravel: { row: number; col: number; steps: number } | null; + /** + * Values written with setTravel that have not been saved yet. The board is + * not read back after setTravel, so these are what was sent, not confirmed. + */ + unsaved: { press: number; sensitivePress: number; sensitiveRelease: number } | null; +} + +export type KeychronKeyboardCommand = + | { kind: "keychronSelectProfile"; slot: number } + | { kind: "keychronRename"; slot: number; name: string } + | { + kind: "keychronSetTravel"; + /** Rapid trigger sends Launcher's quick-trigger work mode; otherwise the static one. */ + rapidTrigger: boolean; + /** Omit to apply to every key; otherwise only these keys. */ + keys?: readonly KeychronKeyPosition[]; + pressMm: number; + sensitivePressMm: number; + sensitiveReleaseMm: number; + } + | { kind: "keychronSave" } + | { kind: "keychronClear"; confirmed: true } + | { kind: "keychronWriteCurve"; curve: KeychronCurve } + | { kind: "keychronKeyTravel"; row: number; col: number } + | { kind: "keychronSelectLayer"; layer: number } + | { kind: "keychronRemap"; layer: number; row: number; col: number; code: number; confirmed?: boolean; restore?: boolean } + | { kind: "keychronDismissRemap" } + | { kind: "keychronCalibration"; action: "start"; confirmed: true } + | { kind: "keychronCalibration"; action: "stop" | "read" }; + +const isKeychronCommand = (command: KeyboardCommand): command is KeychronKeyboardCommand => + command.kind.startsWith("keychron"); + +const sameCurve = (a: KeychronCurve, b: KeychronCurve): boolean => + a.every((point, index) => point[0] === b[index]![0] && point[1] === b[index]![1]); + +/** + * Panel-side wrapper over the Keychron Hall-effect driver. The protocol is + * derived from Keychron Launcher and has not been run on a physical board, so + * nothing here reports a write as hardware-verified. Travel is staged with + * `setTravel` and only persisted by an explicit save. + */ +export class KeychronKeyboardClient implements KeyboardClient { + readonly family = "keychron" as const; + readonly brand = "Keychron"; + get name(): string { return this.device.productName || "Keychron keyboard"; } + readonly device: HIDDevice; + private readonly client: KeychronHidClient; + private state: KeychronWorkspace | null = null; + private recovered: Partial | null = null; + + constructor(device: HIDDevice) { this.device = device; this.client = new KeychronHidClient(device); } + static isSupported(device: HIDDevice): boolean { return KeychronHidClient.isSupported(device); } + open(): Promise { return this.client.open(); } + close(): Promise { return this.client.close(); } + + async readIdentity(): Promise { + const status = await this.client.readStatus(); + return { + brand: status.brand, name: status.name, boardId: null, capabilities: null, + firmwareVersion: status.firmwareVersion, appVersion: null, pcbVersion: null, serial: status.serial, + }; + } + + private current(): KeychronWorkspace { + if (!this.state) throw new Error("Read the keyboard before changing it."); + return this.state; + } + + private publish(next: KeychronWorkspace): Partial { + this.state = next; + return { keychron: next }; + } + + private keychronState(): KeychronState { + const state = this.current().status.keychron; + if (!state) throw new Error("The keyboard did not report its Hall-effect layout."); + return state; + } + + /** State to show after a command failed: the controller asks once, right after the error. */ + recoveredWorkspace(): Partial | null { + const recovered = this.recovered; + this.recovered = null; + return recovered; + } + + /** Publish a new remap state; layer 0 also labels the keys on the actuation map. */ + private publishRemap(remap: KeychronRemapState): Partial { + const current = this.current(); + return this.publish({ ...current, remap, keymap: remap.layer === 0 && remap.keymap ? remap.keymap : current.keymap }); + } + + /** Optional reads must not block the rest of the panel when a board lacks them. */ + private async optional(read: () => Promise): Promise { + try { return await read(); } catch { return null; } + } + + private async refresh(): Promise> { + const status = await this.client.readStatus(); + const profile = await this.client.readProfile(); + const features = status.keychron?.features; + const curve = await this.optional(() => this.client.readCurve()); + const axis = features?.switchAxis ? await this.optional(() => this.client.readAxisType()) : null; + const layout = await loadKeychronLayout(this.device.productId, status.keychron?.rows ?? 0, status.keychron?.cols ?? 0); + // A board that ignores VIA leaves remapping unavailable and the keymap null; + // keys are then labelled by row and column. + const remap = await loadRemap(this.client, 0, this.state?.remap ?? null); + return this.publish({ + status, profile, curve, axis, layout, keymap: remap.keymap, remap, calibration: this.state?.calibration ?? null, + calibrationPhase: this.state?.calibrationPhase ?? "idle", keyTravel: this.state?.keyTravel ?? null, unsaved: null, + }); + } + + async command(command: KeyboardCommand): Promise> { + if (command.kind === "refresh") return this.refresh(); + if (!isKeychronCommand(command)) throw new Error("This command is only for a different keyboard brand."); + switch (command.kind) { + case "keychronSelectProfile": + if (this.current().unsaved) throw new Error("Save or discard the unsaved travel change before switching profile."); + await this.client.selectProfile(command.slot); + return this.refresh(); + case "keychronRename": + // renameProfile saves the active profile, which would also persist a staged travel change. + if (this.current().unsaved) throw new Error("Save or discard the unsaved travel change before renaming."); + await this.client.renameProfile(command.slot, command.name); + return this.refresh(); + case "keychronSetTravel": { + const steps = travelToSteps(this.keychronState(), command); + const target = keychronKeyTarget(command.keys); + await this.client.setTravel({ + workMode: command.rapidTrigger ? KEYCHRON_WORK_MODE.quickTrigger : KEYCHRON_WORK_MODE.static, + press: steps.press, + ...(command.rapidTrigger ? { sensitivePress: steps.sensitivePress, sensitiveRelease: steps.sensitiveRelease } : {}), + ...target, + }); + return this.publish({ ...this.current(), unsaved: { press: steps.press, sensitivePress: steps.sensitivePress, sensitiveRelease: steps.sensitiveRelease } }); + } + case "keychronSave": { + if (!this.current().unsaved) throw new Error("There is no travel change to save."); + await this.client.saveProfile(); + return this.refresh(); + } + case "keychronClear": + if (command.confirmed !== true) throw new Error("Clearing a profile needs confirmation."); + await this.client.clearProfile(); + return this.refresh(); + case "keychronWriteCurve": { + const { maxX } = curveScale(this.keychronState(), this.current().axis?.maxMm ?? null); + if (!curveInRange(command.curve, maxX)) throw new RangeError(`Curve points need x from 0 to ${maxX} in rising order and y from 0 to 127.`); + await this.client.writeCurve(command.curve); + const confirmed = await this.client.readCurve(); + if (!sameCurve(confirmed, command.curve)) throw new Error("The keyboard reported a different response curve after the write."); + return this.publish({ ...this.current(), curve: confirmed }); + } + case "keychronSelectLayer": + return this.publishRemap(await selectRemapLayer(this.client, this.current().remap, command.layer)); + case "keychronDismissRemap": + return this.publishRemap({ ...this.current().remap, warning: null }); + case "keychronRemap": { + const outcome = await remapKey(this.client, this.current().remap, command); + const published = this.publishRemap(outcome.state); + if (outcome.error) { + // The controller drops the update when a command throws, so hand it the real state separately. + this.recovered = published; + throw outcome.error; + } + return published; + } + case "keychronKeyTravel": { + const { rows, cols } = this.keychronState(); + if (!Number.isInteger(command.row) || command.row < 0 || command.row >= rows + || !Number.isInteger(command.col) || command.col < 0 || command.col >= cols) { + throw new RangeError("That key is outside the keyboard's matrix."); + } + const steps = await this.client.readKeyTravel(command.row, command.col); + return this.publish({ ...this.current(), keyTravel: { row: command.row, col: command.col, steps } }); + } + case "keychronCalibration": + if (command.action === "start") { + if (command.confirmed !== true) throw new Error("Starting calibration needs confirmation."); + await this.client.startCalibration(); + this.state = { ...this.current(), calibrationPhase: "started" }; + } else if (command.action === "stop") { + await this.client.stopCalibration(); + // The board sends no reply to stop, so the last known state is not refreshed. + return this.publish({ ...this.current(), calibration: null, calibrationPhase: "stopped" }); + } + return this.publish({ ...this.current(), calibration: await this.client.readCalibration() }); + } + } +} diff --git a/src/device/keychron-layout-data.ts b/src/device/keychron-layout-data.ts new file mode 100644 index 00000000..63785beb --- /dev/null +++ b/src/device/keychron-layout-data.ts @@ -0,0 +1,2 @@ +// Lazy boundary for the protocol's physical layout tables (about 250 KB of source). +export { keychronLayoutFor } from "@openmouse/keyboard-protocol/keychron"; diff --git a/src/device/keychron-layout.ts b/src/device/keychron-layout.ts new file mode 100644 index 00000000..adca43ed --- /dev/null +++ b/src/device/keychron-layout.ts @@ -0,0 +1,27 @@ +import type { KeychronKeyShape } from "@openmouse/keyboard-protocol/keychron"; + +export type KeychronLayoutKey = KeychronKeyShape; + +export interface KeychronLayout { + keys: readonly KeychronLayoutKey[]; + width: number; + height: number; + /** False when no physical layout is known and the keys are the matrix drawn as a plain grid. */ + physical: boolean; +} + +/** + * The board's physical layout, or its matrix as a uniform grid when none is + * known. The layout tables are large, so they load on demand; the shared + * `import()` keeps them out of the main bundle. + */ +export async function loadKeychronLayout(productId: number, rows: number, cols: number): Promise { + const { keychronLayoutFor } = await import("./keychron-layout-data.ts"); + const known = keychronLayoutFor(productId); + if (known && known.keys.length > 0) return { keys: known.keys, width: known.width, height: known.height, physical: true }; + const keys: KeychronLayoutKey[] = []; + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) keys.push({ row, col, x: col, y: row, w: 1, h: 1, rotation: 0, rx: 0, ry: 0, encoder: null }); + } + return { keys, width: cols, height: rows, physical: false }; +} diff --git a/src/device/keychron-legend.ts b/src/device/keychron-legend.ts new file mode 100644 index 00000000..ea338ce3 --- /dev/null +++ b/src/device/keychron-legend.ts @@ -0,0 +1,13 @@ +import { keychronKeycodeLegend } from "@openmouse/keyboard-protocol/keychron"; +import type { KeychronKeycodeContext } from "@openmouse/keyboard-protocol/keychron"; + +/** + * Short legend and tooltip for a keycode read from the board; empty for an + * unassigned key. Pass the board's keycode context, or layer and custom keys + * are shown wrongly. `unknown` keys carry their hex value as the label. + */ +export function legendFor(code: number | null | undefined, context?: KeychronKeycodeContext): { label: string; title: string; unknown: boolean } { + if (code === null || code === undefined) return { label: "", title: "", unknown: false }; + const { label, title, unknown } = keychronKeycodeLegend(code, context); + return { label, title, unknown }; +} diff --git a/src/device/keychron-picker.test.ts b/src/device/keychron-picker.test.ts new file mode 100644 index 00000000..ee4610fc --- /dev/null +++ b/src/device/keychron-picker.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { keychronKeycodeContext, keychronKeycodeIsChoosable, keychronKeycodeLegend } from "@openmouse/keyboard-protocol/keychron"; +import { entryMatches, pickerCatalog, pickerGroups } from "./keychron-picker.ts"; + +const context = keychronKeycodeContext(0x040c, 12); +const catalog = pickerCatalog(context, 3); + +test("the catalog groups keys, limits layer keys to the board and includes its custom keys", () => { + assert.deepEqual(catalog.map((category) => category.id), [ + "letters", "numbers", "punctuation", "editing", "navigation", "function", "modifiers", "keypad", "system", "media", "layers", "special", "keychron", + ]); + const layerLabels = catalog.find((category) => category.id === "layers")!.entries.map((entry) => entry.label); + assert.ok(layerLabels.includes("MO2")); + assert.ok(!layerLabels.includes("MO3")); + assert.ok(catalog.find((category) => category.id === "keychron")!.entries.length > 0); +}); + +test("search matches the label or the title, any case, every word", () => { + const entry = { code: 0x2a, label: "Bksp", title: "Backspace" }; + assert.ok(entryMatches(entry, "bksp")); + assert.ok(entryMatches(entry, "BACK")); + assert.ok(entryMatches(entry, "back sp")); + assert.ok(!entryMatches(entry, "enter")); + assert.ok(entryMatches(entry, " ")); +}); + +test("without a search the chosen category shows; with one every category is searched", () => { + assert.deepEqual(pickerGroups(catalog, "numbers", "").map((group) => group.id), ["numbers"]); + assert.deepEqual(pickerGroups(catalog, "nonsense", "").map((group) => group.id), ["letters"]); + const hits = pickerGroups(catalog, "numbers", "escape"); + assert.ok(hits.length > 0); + assert.ok(hits.every((group) => group.entries.every((entry) => entryMatches(entry, "escape")))); + assert.deepEqual(pickerGroups(catalog, "letters", "zzzzzz"), []); +}); + +test("an unrecognised keycode is shown as hex and is never offered", () => { + const unknown = keychronKeycodeLegend(0xbeef, context); + assert.equal(unknown.unknown, true); + assert.equal(unknown.label, "0xBEEF"); + assert.equal(keychronKeycodeIsChoosable(0xbeef, context), false); + assert.ok(!catalog.some((category) => category.entries.some((entry) => entry.code === 0xbeef))); + assert.ok(catalog.every((category) => category.entries.every((entry) => keychronKeycodeIsChoosable(entry.code, context)))); +}); + +test("every key the legacy catalog offers is valid on legacy firmware", () => { + const legacy = keychronKeycodeContext(0x040c, 10); + for (const category of pickerCatalog(legacy, 3)) { + for (const entry of category.entries) assert.ok(keychronKeycodeIsChoosable(entry.code, legacy), `${category.id} ${entry.label}`); + } +}); diff --git a/src/device/keychron-picker.ts b/src/device/keychron-picker.ts new file mode 100644 index 00000000..2225d7e3 --- /dev/null +++ b/src/device/keychron-picker.ts @@ -0,0 +1,30 @@ +import { keychronKeycodeCatalog } from "@openmouse/keyboard-protocol/keychron"; +import type { KeychronCatalogCategory, KeychronCatalogEntry, KeychronKeycodeContext } from "@openmouse/keyboard-protocol/keychron"; + +export type PickerGroup = Pick & { entries: readonly KeychronCatalogEntry[] }; + +/** The keycodes this board's firmware can be set to, grouped for the picker. */ +export function pickerCatalog(context: Required, layerCount: number): KeychronCatalogCategory[] { + return keychronKeycodeCatalog({ ...context, layerCount }); +} + +/** Every search word must appear in the entry's label or title, ignoring case. */ +export function entryMatches(entry: KeychronCatalogEntry, query: string): boolean { + const words = query.toLowerCase().split(/\s+/).filter(Boolean); + const text = `${entry.label} ${entry.title}`.toLowerCase(); + return words.every((word) => text.includes(word)); +} + +/** + * What the picker lists. With a search, every category is searched and only + * those with hits are shown; without one, the chosen category is shown. + */ +export function pickerGroups(catalog: readonly KeychronCatalogCategory[], categoryId: string, query: string): PickerGroup[] { + if (query.trim() === "") { + const category = catalog.find((entry) => entry.id === categoryId) ?? catalog[0]; + return category ? [category] : []; + } + return catalog + .map((category) => ({ id: category.id, label: category.label, entries: category.entries.filter((entry) => entryMatches(entry, query)) })) + .filter((group) => group.entries.length > 0); +} diff --git a/src/device/keychron-remap.ts b/src/device/keychron-remap.ts new file mode 100644 index 00000000..16cef421 --- /dev/null +++ b/src/device/keychron-remap.ts @@ -0,0 +1,130 @@ +import type { KeychronHidClient } from "@openmouse/keyboard-protocol/drivers/keychron/hid"; +import type { KeychronKeycodeContext } from "@openmouse/keyboard-protocol/keychron"; + +export type KeychronKeymap = readonly (readonly number[])[]; + +export interface KeychronRemapWarning { + layer: number; + row: number; + col: number; + code: number; + warnings: readonly string[]; +} + +export interface KeychronRemapResult { + kind: "ok" | "failed"; + layer: number; + row: number; + col: number; + /** The keycode that was requested. */ + code: number; + /** The rejection message when it failed. */ + message: string | null; +} + +export interface KeychronRemapState { + /** Why remapping is off (the board ignores the VIA reads), or null when it works. */ + unavailable: string | null; + protocol: number | null; + /** Protocol version and the board's own custom keys, for legends and the picker. */ + context: Required; + layerCount: number; + layer: number; + /** Keycodes of the selected layer as the board last reported them. */ + keymap: KeychronKeymap | null; + /** The keycode a key had before this session changed it, by "layer:row:col". */ + originals: Readonly>; + /** Set when a write was held back until the user confirms its warnings. */ + warning: KeychronRemapWarning | null; + result: KeychronRemapResult | null; +} + +export interface KeychronRemapRequest { + layer: number; + row: number; + col: number; + code: number; + /** The user confirmed the warnings shown for this exact change. */ + confirmed?: boolean; + /** This change puts back the original code of the key. */ + restore?: boolean; +} + +export const remapKeyId = (layer: number, row: number, col: number): string => `${layer}:${row}:${col}`; + +export const emptyRemap = (unavailable: string | null): KeychronRemapState => ({ + unavailable, protocol: null, context: { protocol: 12, custom: [] }, layerCount: 0, layer: 0, keymap: null, + originals: {}, warning: null, result: null, +}); + +const messageOf = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +async function attempt(read: () => Promise): Promise { + try { return await read(); } catch { return null; } +} + +/** + * Read what remapping needs: the VIA protocol version, the board's keycode + * context, the layer count and one layer's keymap. A board that ignores VIA + * leaves remapping unavailable with the reason, instead of throwing. + */ +export async function loadRemap(client: KeychronHidClient, layer: number, previous: KeychronRemapState | null): Promise { + try { + await client.readViaProtocol(); + const context = await client.keycodeContext(); + const layerCount = await client.readLayerCount(); + const target = Math.min(Math.max(0, layer), Math.max(0, layerCount - 1)); + const keymap = await client.readKeymap(target); + return { + unavailable: null, protocol: context.protocol, context, layerCount, layer: target, keymap, + originals: previous?.originals ?? {}, warning: null, result: null, + }; + } catch (error) { + return emptyRemap(messageOf(error)); + } +} + +export async function selectRemapLayer(client: KeychronHidClient, state: KeychronRemapState, layer: number): Promise { + if (state.unavailable) throw new Error(state.unavailable); + if (!Number.isInteger(layer) || layer < 0 || layer >= state.layerCount) throw new RangeError("That layer does not exist on this keyboard."); + const keymap = await client.readKeymap(layer); + return { ...state, layer, keymap, warning: null, result: null }; +} + +/** + * Set one key. Warnings (for example replacing the only layer key on a layer) + * hold the write until the same request comes back confirmed. A write counts as + * done only when `setKeycode` resolved, which reads the key back; the keymap is + * then re-read. When it rejects the keymap is re-read too, so the map shows the + * board's real state, and the error is returned for the caller to raise. + */ +export async function remapKey( + client: KeychronHidClient, state: KeychronRemapState, request: KeychronRemapRequest, +): Promise<{ state: KeychronRemapState; error: Error | null }> { + if (state.unavailable) throw new Error(state.unavailable); + const { layer, row, col, code } = request; + if (layer !== state.layer) throw new RangeError("Select that layer before changing its keys."); + const previous = state.keymap?.[row]?.[col]; + if (previous === undefined) throw new RangeError("That key is not in the keymap."); + const warnings = await client.remapWarnings(layer, row, col, code); + if (warnings.length > 0 && !request.confirmed) { + return { state: { ...state, warning: { layer, row, col, code, warnings }, result: null }, error: null }; + } + try { + const confirmed = await client.setKeycode(layer, row, col, code); + const reread = await attempt(() => client.readKeymap(layer)); + // The read-back inside setKeycode proved this key even if the full re-read failed. + const keymap = reread ?? state.keymap!.map((line, r) => (r === row ? line.map((value, c) => (c === col ? confirmed : value)) : line)); + const originals = { ...state.originals }; + const id = remapKeyId(layer, row, col); + if (request.restore || originals[id] === confirmed) delete originals[id]; + else if (!(id in originals)) originals[id] = previous; + return { state: { ...state, keymap, originals, warning: null, result: { kind: "ok", layer, row, col, code: confirmed, message: null } }, error: null }; + } catch (error) { + const reread = await attempt(() => client.readKeymap(layer)); + return { + state: { ...state, keymap: reread ?? state.keymap, warning: null, result: { kind: "failed", layer, row, col, code, message: messageOf(error) } }, + error: error instanceof Error ? error : new Error(String(error)), + }; + } +} diff --git a/src/device/keychron-travel.test.ts b/src/device/keychron-travel.test.ts new file mode 100644 index 00000000..d74f72d1 --- /dev/null +++ b/src/device/keychron-travel.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { KeychronState } from "@openmouse/keyboard-protocol/drivers"; +import { mmToSteps, stepsToMm, travelBounds, travelToSteps } from "./keychron-travel.ts"; + +const base: KeychronState = { + productId: 0x040c, heVersion: 4, features: { switchAxis: true, resetSingleKey: false, dkr: false, turbo: false, gamepadDisable: false, supportMechanical: true }, + unit: 0.1, profileSize: 0, dksCount: null, snapTapCount: null, rows: 6, cols: 19, travel: { min: 0.2, max: 3.3, deadzone: 0.2 }, +}; + +test("steps and millimetres round trip without float drift", () => { + assert.equal(stepsToMm(33, 0.1), 3.3); + assert.equal(mmToSteps(3.3, 0.1), 33); + assert.equal(stepsToMm(7, 0.01), 0.07); + assert.equal(mmToSteps(0.07, 0.01), 7); +}); + +test("the dead zone extends the top of the range, not the bottom", () => { + assert.deepEqual(travelBounds(base), { stepMm: 0.1, minMm: 0.2, maxMm: 3.5, deadzoneMm: 0.2, fromBoard: true }); + const deep = travelBounds({ ...base, travel: { min: 0.1, max: 3.1, deadzone: 0.4 } }); + assert.equal(deep.minMm, 0.1); + assert.equal(deep.maxMm, 3.5); + assert.equal(travelBounds({ ...base, travel: { min: 0.1, max: 3.35 } }).maxMm, 3.35); +}); + +test("a board without a declared range is limited to what a slot can store", () => { + const older = travelBounds({ ...base, travel: null }); + assert.equal(older.fromBoard, false); + assert.equal(older.maxMm, 6.3); + const v5 = travelBounds({ ...base, travel: null, heVersion: 5, unit: 0.01 }); + assert.equal(v5.maxMm, 163.83); + assert.equal(travelBounds({ ...base, travel: { min: 0.1, max: 9 } }).maxMm, 6.3); +}); + +test("requests convert to steps and out-of-range values throw", () => { + assert.deepEqual(travelToSteps(base, { pressMm: 1.5, sensitivePressMm: 0.3, sensitiveReleaseMm: 0.4 }), { press: 15, sensitivePress: 3, sensitiveRelease: 4 }); + assert.throws(() => travelToSteps(base, { pressMm: 0.1, sensitivePressMm: 0.3, sensitiveReleaseMm: 0.4 }), RangeError); + assert.throws(() => travelToSteps(base, { pressMm: 1, sensitivePressMm: 3.6, sensitiveReleaseMm: 0.4 }), RangeError); + assert.equal(travelToSteps(base, { pressMm: 3.5, sensitivePressMm: 0.2, sensitiveReleaseMm: 0.4 }).press, 35); +}); diff --git a/src/device/keychron-travel.ts b/src/device/keychron-travel.ts new file mode 100644 index 00000000..cce3e32b --- /dev/null +++ b/src/device/keychron-travel.ts @@ -0,0 +1,72 @@ +import type { KeychronState } from "@openmouse/keyboard-protocol/drivers"; + +export interface KeychronTravelBounds { + /** Millimetres per profile step. */ + stepMm: number; + minMm: number; + maxMm: number; + /** The board's dead zone from Launcher's definition; it extends the top of the range. */ + deadzoneMm: number | null; + /** False when the board's definition declares no travel range. */ + fromBoard: boolean; +} + +/** Widest value a key slot can hold: 6 bits up to command v4, 14 bits in v5. */ +const maxSteps = (heVersion: number): number => (heVersion >= 5 ? 0x3fff : 0x3f); + +const round = (value: number): number => Number(value.toFixed(4)); + +export const stepsToMm = (steps: number, unit: number): number => round(steps * unit); + +export const mmToSteps = (mm: number, unit: number): number => Math.round(mm / unit + 1e-9); + +/** + * Limits for actuation and rapid-trigger values, as Launcher shows them: from + * the board's minimum to its maximum plus its dead zone. Launcher also keeps a + * separate minimum for the rapid-trigger sliders that boards do not declare; + * it is not invented here, so the board minimum applies to all three. Without + * a declared range the only limit is what the slot can encode. + */ +export function travelBounds(state: KeychronState): KeychronTravelBounds { + const stepMm = state.unit; + const encodable = round(maxSteps(state.heVersion) * stepMm); + const travel = state.travel; + const deadzone = travel?.deadzone ?? null; + const declaredMax = travel?.max; + const declaredMin = travel?.min; + return { + stepMm, + minMm: round(Math.max(declaredMin ?? stepMm, stepMm)), + maxMm: declaredMax === undefined ? encodable : Math.min(round(declaredMax + (deadzone ?? 0)), encodable), + deadzoneMm: deadzone, + fromBoard: declaredMax !== undefined || declaredMin !== undefined, + }; +} + +export interface KeychronTravelRequest { + pressMm: number; + sensitivePressMm: number; + sensitiveReleaseMm: number; +} + +export interface KeychronTravelSteps { + press: number; + sensitivePress: number; + sensitiveRelease: number; +} + +/** Convert a request to profile steps; throws if any value leaves the board's range. */ +export function travelToSteps(state: KeychronState, request: KeychronTravelRequest): KeychronTravelSteps { + const bounds = travelBounds(state); + const convert = (mm: number, label: string): number => { + if (!Number.isFinite(mm) || mm < bounds.minMm - 1e-9 || mm > bounds.maxMm + 1e-9) { + throw new RangeError(`${label} must be between ${bounds.minMm} and ${bounds.maxMm} mm.`); + } + return mmToSteps(mm, bounds.stepMm); + }; + return { + press: convert(request.pressMm, "Actuation"), + sensitivePress: convert(request.sensitivePressMm, "Rapid trigger press"), + sensitiveRelease: convert(request.sensitiveReleaseMm, "Rapid trigger release"), + }; +} diff --git a/src/device/types.ts b/src/device/types.ts index 564ca77a..43f53893 100644 --- a/src/device/types.ts +++ b/src/device/types.ts @@ -224,7 +224,7 @@ export interface KeyboardTriggerView { } export interface KeyboardView { - family?: "wuque" | "kochi" | "wooting" | "razer" | "logitech" | "steelseries"; + family?: "wuque" | "kochi" | "wooting" | "razer" | "logitech" | "steelseries" | "keychron"; name: string; vendorId: number; productId: number; From e29eaf971357415160b47fe338f7341ba54256cc Mon Sep 17 00:00:00 2001 From: snekxs <26660858+snekxs@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:30:28 -0600 Subject: [PATCH 2/7] feat(keychron): add the Hall-effect keyboard panel Overview, Performance, Buttons, Profiles and Advanced tabs on the shared workspace: a keyboard-shaped key map with legends and actuation values, actuation and rapid-trigger sliders with multi-key writes, a response curve graph, calibration, the switch axis, and key remapping with a layer switcher, a searchable picker, confirmations for risky changes and a restore action. Keychron keyboards get a neutral keyboard outline in place of the mouse placeholder. Raises the CSS budget from 165,000 to 168,000 bytes for keychron.css. --- build/check-bundle-size.ts | 5 +- public/devices/keychron-keyboard.svg | 65 ++++ src/features/devices/cards/KeyboardPanel.tsx | 2 + .../devices/cards/KeychronCurveGraph.tsx | 76 ++++ src/features/devices/cards/KeychronKeyMap.tsx | 75 ++++ .../devices/cards/KeychronKeyboardPanel.tsx | 334 ++++++++++++++++++ .../devices/cards/KeychronRemapCard.tsx | 104 ++++++ src/features/devices/workspace-tabs.test.ts | 3 + src/features/devices/workspace-tabs.ts | 1 + src/i18n/index.ts | 116 ++++++ src/i18n/locales/vendor-keyboard-ui.ts | 116 ++++++ src/shared/device-images.test.ts | 7 + src/shared/device-images.ts | 4 + src/styles/areas/keychron.css | 56 +++ src/styles/index.css | 1 + src/styles/keychron-layout.test.ts | 42 +++ 16 files changed, 1006 insertions(+), 1 deletion(-) create mode 100644 public/devices/keychron-keyboard.svg create mode 100644 src/features/devices/cards/KeychronCurveGraph.tsx create mode 100644 src/features/devices/cards/KeychronKeyMap.tsx create mode 100644 src/features/devices/cards/KeychronKeyboardPanel.tsx create mode 100644 src/features/devices/cards/KeychronRemapCard.tsx create mode 100644 src/styles/areas/keychron.css create mode 100644 src/styles/keychron-layout.test.ts diff --git a/build/check-bundle-size.ts b/build/check-bundle-size.ts index c79ab753..26add559 100644 --- a/build/check-bundle-size.ts +++ b/build/check-bundle-size.ts @@ -19,8 +19,11 @@ import { join } from "node:path"; // 2026-10-29: Razer, Logitech and SteelSeries keyboard HID clients and panels // add three real device protocols. Measured: 3,558,003 bytes across all // chunks, with the main chunk at 1,149,146 bytes (below its existing cap). +// 2026-10-30: Keychron Hall-effect panel (key map, response curve graph) adds +// styles/areas/keychron.css, about 3 kB. Measured: 164,771 bytes of CSS before the +// bump; the lazy layout tables add a 251 kB chunk of JS (main grew 6.7 kB). const BUDGET_BYTES: Record = { - ".css": 165_000, + ".css": 168_000, ".js": 3_600_000, }; diff --git a/public/devices/keychron-keyboard.svg b/public/devices/keychron-keyboard.svg new file mode 100644 index 00000000..4d149bea --- /dev/null +++ b/public/devices/keychron-keyboard.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/features/devices/cards/KeyboardPanel.tsx b/src/features/devices/cards/KeyboardPanel.tsx index 6069449e..09cac344 100644 --- a/src/features/devices/cards/KeyboardPanel.tsx +++ b/src/features/devices/cards/KeyboardPanel.tsx @@ -7,6 +7,7 @@ import * as control from "../../../device/controller"; import type { ControlSnapshot, KeyboardTriggerKey } from "../../../device/types"; import { t, tp } from "../../../i18n/index"; import { RateSlider, StepperSlider } from "../../../shared/ui"; +const KeychronKeyboardPanel = lazy(() => import("./KeychronKeyboardPanel").then(({ KeychronKeyboardPanel }) => ({ default: KeychronKeyboardPanel }))); const RazerKeyboardPanel = lazy(() => import("./RazerKeyboardPanel").then(({ RazerKeyboardPanel }) => ({ default: RazerKeyboardPanel }))); const LogitechKeyboardPanel = lazy(() => import("./LogitechKeyboardPanel").then(({ LogitechKeyboardPanel }) => ({ default: LogitechKeyboardPanel }))); const SteelSeriesKeyboardPanel = lazy(() => import("./SteelSeriesKeyboardPanel").then(({ SteelSeriesKeyboardPanel }) => ({ default: SteelSeriesKeyboardPanel }))); @@ -28,6 +29,7 @@ export function KeyboardPanel({ snapshot }: { snapshot: ControlSnapshot }): Reac const keyboard = snapshot.keyboard; if (!keyboard) return null; if (keyboard.family === "kochi") return ; + if (keyboard.family === "keychron") return ; if (keyboard.family === "wooting") return ; if (keyboard.family === "razer") return ; if (keyboard.family === "logitech") return ; diff --git a/src/features/devices/cards/KeychronCurveGraph.tsx b/src/features/devices/cards/KeychronCurveGraph.tsx new file mode 100644 index 00000000..7c073c24 --- /dev/null +++ b/src/features/devices/cards/KeychronCurveGraph.tsx @@ -0,0 +1,76 @@ +import { useRef, type KeyboardEvent, type PointerEvent, type ReactNode } from "react"; +import type { KeychronCurve } from "@openmouse/keyboard-protocol/keychron"; +import { CURVE_MAX_Y, CURVE_X_STEP_MM, setCurvePoint } from "../../../device/keychron-curve"; +import { tp } from "../../../i18n/index"; +import type { InterfaceLocale } from "../../settings/interface-preferences"; + +const LEFT = 34; +const RIGHT = 12; +const TOP = 12; +const BOTTOM = 26; +const PLOT_W = 280; +const PLOT_H = 200; +const WIDTH = LEFT + PLOT_W + RIGHT; +const HEIGHT = TOP + PLOT_H + BOTTOM; +const Y_TICKS = [0, 32, 64, 96, CURVE_MAX_Y]; + +const fixed = (value: number): string => String(Number(value.toFixed(2))); + +/** + * The response curve: key travel in millimetres across, output 0 to 127 up. + * Points drag with a pointer and move with the arrow keys (Shift for 10 steps). + * Every move goes through `setCurvePoint`, so a byte can never leave its range. + */ +export function KeychronCurveGraph({ curve, maxX, locale, disabled, active, onActive, onChange }: { + curve: KeychronCurve; + maxX: number; + locale: InterfaceLocale; + disabled: boolean; + active: number | null; + onActive: (index: number | null) => void; + onChange: (next: KeychronCurve) => void; +}): ReactNode { + const svg = useRef(null); + const px = (x: number): number => LEFT + (x / maxX) * PLOT_W; + const py = (y: number): number => TOP + PLOT_H - (y / CURVE_MAX_Y) * PLOT_H; + const drag = (index: number, event: PointerEvent): void => { + const box = svg.current?.getBoundingClientRect(); + if (!box || disabled || !event.currentTarget.hasPointerCapture(event.pointerId)) return; + const scale = WIDTH / box.width; + const x = (((event.clientX - box.left) * scale - LEFT) / PLOT_W) * maxX; + const y = (1 - ((event.clientY - box.top) * scale - TOP) / PLOT_H) * CURVE_MAX_Y; + onChange(setCurvePoint(curve, index, x, y, maxX)); + }; + const nudge = (index: number, event: KeyboardEvent): void => { + const step = event.shiftKey ? 10 : 1; + const [x, y] = curve[index]!; + const move: Record = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, step], ArrowDown: [0, -step] }; + const delta = move[event.key]; + if (!delta || disabled) return; + event.preventDefault(); + onChange(setCurvePoint(curve, index, x + delta[0], y + delta[1], maxX)); + }; + const mmTicks = Array.from({ length: Math.floor(maxX * CURVE_X_STEP_MM) + 1 }, (_, mm) => mm / CURVE_X_STEP_MM); + if (mmTicks[mmTicks.length - 1] !== maxX) mmTicks.push(maxX); + return ( + + {mmTicks.map((x) => )} + {Y_TICKS.map((y) => )} + {mmTicks.map((x) => {fixed(x * CURVE_X_STEP_MM)})} + {Y_TICKS.map((y) => {y})} + `${px(x)},${py(y)}`).join(" ")} /> + {curve.map(([x, y], index) => ( + { if (!disabled) event.currentTarget.setPointerCapture(event.pointerId); onActive(index); }} + onPointerMove={(event) => drag(index, event)} + onPointerEnter={() => onActive(index)} onPointerLeave={() => onActive(null)} + onFocus={() => onActive(index)} onBlur={() => onActive(null)} + onKeyDown={(event) => nudge(index, event)} /> + ))} + + ); +} diff --git a/src/features/devices/cards/KeychronKeyMap.tsx b/src/features/devices/cards/KeychronKeyMap.tsx new file mode 100644 index 00000000..c0eb0551 --- /dev/null +++ b/src/features/devices/cards/KeychronKeyMap.tsx @@ -0,0 +1,75 @@ +import type { CSSProperties, ReactNode } from "react"; +import { KEYCHRON_ADVANCE_MODE, type KeychronKeySlot, type KeychronKeycodeContext } from "@openmouse/keyboard-protocol/keychron"; +import type { KeychronWorkspace } from "../../../device/keychron-keyboard"; +import { legendFor } from "../../../device/keychron-legend"; +import { stepsToMm } from "../../../device/keychron-travel"; +import { t, tp } from "../../../i18n/index"; +import type { InterfaceLocale } from "../../settings/interface-preferences"; + +export type KeychronKeyKind = "static" | "rapid" | "advanced"; + +/** An advanced mode wins; otherwise rapid-trigger values make a key rapid. */ +export function keyKind(slot: KeychronKeySlot | undefined): KeychronKeyKind { + if (!slot) return "static"; + if (slot.advanceMode !== KEYCHRON_ADVANCE_MODE.none) return "advanced"; + return slot.advancePress > 0 || slot.advanceRelease > 0 ? "rapid" : "static"; +} + +export const keyId = (row: number, col: number): string => `${row}:${col}`; + +const fixed = (value: number): string => String(Number(value.toFixed(3))); + +/** + * The keyboard drawn at its real key positions. Each key shows its legend + * from the board's current keymap (row and column when that could not be + * read) and its actuation in millimetres. + */ +export function KeychronKeyMap({ kc, locale, keymap, context, showTravel = true, selected, disabled, onSelect }: { + kc: KeychronWorkspace; + locale: InterfaceLocale; + /** Keycodes that label the keys: a layer of the board's keymap. */ + keymap: readonly (readonly number[])[] | null; + context?: KeychronKeycodeContext; + /** Show each key's actuation in mm under its legend. */ + showTravel?: boolean; + selected: ReadonlySet; + disabled: boolean; + /** `additive` is true for Shift, Ctrl or Command clicks. */ + onSelect: (row: number, col: number, additive: boolean) => void; +}): ReactNode { + const unit = kc.status.keychron?.unit ?? 0.1; + const { layout, profile } = kc; + return ( +
+
+ {layout.keys.map((key) => { + const slot = profile?.keys[key.row]?.[key.col]; + const kind = keyKind(slot); + const mm = slot ? fixed(stepsToMm(slot.distance, unit)) : null; + const legend = keymap ? legendFor(keymap[key.row]?.[key.col], context) : null; + const place = tp(locale, "kb.keychronKeyLabel", { row: key.row, col: key.col }); + const label = legend?.label || place; + const id = keyId(key.row, key.col); + const style: CSSProperties = { + left: `${(key.x / layout.width) * 100}%`, top: `${(key.y / layout.height) * 100}%`, + width: `${(key.w / layout.width) * 100}%`, height: `${(key.h / layout.height) * 100}%`, + ...(key.rotation ? { transform: `rotate(${key.rotation}deg)`, transformOrigin: `${((key.rx - key.x) / key.w) * 100}% ${((key.ry - key.y) / key.h) * 100}%` } : {}), + }; + const className = `keychron-key${showTravel ? ` is-${kind}` : ""}${legend?.unknown ? " is-unknown" : ""}${selected.has(id) ? " is-selected" : ""}${key.encoder !== null ? " is-knob" : ""}`; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/features/devices/cards/KeychronKeyboardPanel.tsx b/src/features/devices/cards/KeychronKeyboardPanel.tsx new file mode 100644 index 00000000..d6287b7a --- /dev/null +++ b/src/features/devices/cards/KeychronKeyboardPanel.tsx @@ -0,0 +1,334 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { KEYCHRON_ADVANCE_MODE } from "@openmouse/keyboard-protocol/keychron"; +import type { KeychronCurve } from "@openmouse/keyboard-protocol/keychron"; +import type { ControlSnapshot } from "../../../device/types"; +import type { KeychronWorkspace } from "../../../device/keychron-keyboard"; +import { legendFor } from "../../../device/keychron-legend"; +import { stepsToMm, travelBounds } from "../../../device/keychron-travel"; +import { CURVE_MAX_Y, CURVE_X_STEP_MM, analogKeyCount, clampCurve, curveInRange, curveScale, linearCurve, setCurvePoint } from "../../../device/keychron-curve"; +import * as control from "../../../device/controller"; +import { displayText, t, tp, type I18nKey } from "../../../i18n/index"; +import type { InterfaceLocale } from "../../settings/interface-preferences"; +import { Segmented, StepperSlider, SwitchRow } from "../../../shared/ui"; +import { KeychronCurveGraph } from "./KeychronCurveGraph"; +import { KeychronKeyMap, keyId } from "./KeychronKeyMap"; +import { KeychronRemapCard } from "./KeychronRemapCard"; + +const send = control.applyKeyboardCommand; +const millimetres = (value: number): string => `${value.toFixed(3)} mm`; +const fixed = (value: number): string => String(Number(value.toFixed(4))); + +type Locale = InterfaceLocale; +type Vars = Record; +const l = (locale: Locale, key: string, vars?: Vars): string => + vars ? tp(locale, `kb.keychron${key}` as I18nKey, vars) : t(locale, `kb.keychron${key}` as I18nKey); + +interface KeyRef { row: number; col: number } + +const ADVANCE_LABELS: ReadonlyArray = [ + [KEYCHRON_ADVANCE_MODE.none, "AdvanceNone"], + [KEYCHRON_ADVANCE_MODE.dks, "AdvanceDks"], + [KEYCHRON_ADVANCE_MODE.analog, "AdvanceAnalog"], + [KEYCHRON_ADVANCE_MODE.toggle, "AdvanceToggle"], + [KEYCHRON_ADVANCE_MODE.dkr, "AdvanceDkr"], + [KEYCHRON_ADVANCE_MODE.turbo, "AdvanceTurbo"], +]; + +/** Named only for the modes the protocol documents; anything else stays a number. */ +function advanceLabel(locale: Locale, mode: number): string { + const known = ADVANCE_LABELS.find(([value]) => value === mode); + return known ? l(locale, known[1]) : l(locale, "AdvanceRaw", { n: mode }); +} + +function Card({ title, hidden, children }: { title: string; hidden: boolean; children: ReactNode }): ReactNode { + return ; +} + +/** Two-step button, the same inline pattern the SteelSeries panel uses for overwrites. */ +function Confirmed({ label, question, yes, locale, disabled, onConfirm }: { + label: string; question: string; yes: string; locale: Locale; disabled: boolean; onConfirm: () => void; +}): ReactNode { + const [asking, setAsking] = useState(false); + if (!asking) return ; + return
+

{question}

+
+ + +
+
; +} + +function IdentityCard({ kc, locale, busy, hidden, name }: { kc: KeychronWorkspace | null; locale: Locale; busy: boolean; hidden: boolean; name: string }): ReactNode { + const state = kc?.status.keychron; + return ; +} + +function KeysCard({ kc, locale, busy, hidden, selection, primary, onSelect, onSelectAll, onClear }: { + kc: KeychronWorkspace; locale: Locale; busy: boolean; hidden: boolean; selection: ReadonlySet; primary: KeyRef | null; + onSelect: (row: number, col: number, additive: boolean) => void; onSelectAll: () => void; onClear: () => void; +}): ReactNode { + const state = kc.status.keychron; + const slot = primary ? kc.profile?.keys[primary.row]?.[primary.col] : undefined; + const legend = primary && kc.keymap ? legendFor(kc.keymap[primary.row]?.[primary.col], kc.remap.context) : null; + const unit = state?.unit ?? 0.1; + return