Skip to Content

Keys API

Key event subscription, mapping, and custom tab management.

OBS is read-only. Native Tauri main and overlay windows can write through the revision coordinator and subscribe to the same canonical events.

Key State Events

dmn.keys.onKeyState(callback): ReadyUnsubscribe

Overlay window only.

Subscribes to state changes for registered keys.

interface KeyStateEvent { // Canonical slot identifier, e.g. 'A', 'MOUSE1', 'F|NUMPAD 4' key: string; state: 'UP' | 'DOWN'; mode: string; // current layout mode ID (e.g. '4key') // Elapsed time (ms) between input capture and event emit. // Recover the real input time with `performance.now() - eventAgeMs`. eventAgeMs?: number; // UP events only. Physical hold duration (ms) measured by the input // daemon when the same physical input produced canonical DOWN and UP. // Omitted when those transitions came from different or unknown inputs. holdDurationMs?: number; } const unsub = dmn.keys.onKeyState(({ key, state, mode }) => { console.log(`[${mode}] ${key} → ${state}`); });

holdDurationMs is delivery-latency independent when present. A canonical slot can remain active across overlapping devices or member keys, so consumers that need its full active duration must also track the matching canonical DOWN and UP timestamps when this optional field is absent.

The returned function also exposes a ready promise that resolves once the subscription is actually registered with the backend. Events fired before ready resolves may not be delivered, so await it when you need a guaranteed starting point (e.g. before requesting a snapshot):

const unsub = dmn.keys.onKeyState(handler); await unsub.ready; // subscription is live from this point

dmn.keys.onKeysReset(callback): ReadyUnsubscribe

Fires when the pressed-key state is invalidated as a whole, e.g. when the keyboard hook (re)starts after a global shortcut change. Any state derived from previous onKeyState events (held keys, active visualizations) should be cleared and rebuilt from a fresh snapshot.

interface KeysResetEvent { reason: string; // e.g., 'hook_restart' } dmn.keys.onKeysReset(({ reason }) => { console.log(`key state reset: ${reason}`); });

dmn.keys.onRawInput(callback): Unsubscribe

Overlay window only.

Subscribes to all raw input events (including unregistered keys).

interface RawInputEvent { device: 'keyboard' | 'mouse' | 'gamepad' | 'unknown'; label: string; // e.g., 'A', 'MOUSE1', 'HIDB:1ccf:101c:9:3' labels: string[]; // all candidate labels for this event state: 'DOWN' | 'UP'; } const unsub = dmn.keys.onRawInput(({ device, label, state }) => { console.log(`[${device}] ${label} ${state}`); });

HID device buttons (Windows) are delivered with device: 'gamepad' and labels in the form HIDB:vid:pid:usagePage:usage. See the Knobs API for HID axis (knob) elements.


Counter Events

dmn.keys.getCounters(): Promise<KeyCounters>

Returns the current cumulative key counter snapshot.

// Inner map keys are canonical slot identifiers type KeyCounters = Record<string, Record<string, number>>; const counters = await dmn.keys.getCounters(); console.log(counters['4key']);

dmn.keys.setCounters(counters): Promise<KeyCounters>

Replaces all cumulative key counters and returns the committed snapshot.

await dmn.keys.setCounters({ '4key': { D: 100, F: 200, J: 150, K: 180 }, });

dmn.keys.resetCounters(): Promise<KeyCounters>

Resets cumulative counters for every mode.

await dmn.keys.resetCounters();

dmn.keys.resetCountersMode(mode): Promise<KeyCounters>

Resets cumulative counters for one mode.

await dmn.keys.resetCountersMode('4key');

dmn.keys.resetSingleCounter(mode, key): Promise<KeyCounters>

Resets one canonical slot counter in one mode.

await dmn.keys.resetSingleCounter('4key', 'D'); await dmn.keys.resetSingleCounter('4key', 'F|NUMPAD 4');

dmn.keys.onCounterChanged(callback): Unsubscribe

Overlay window only.

Subscribes to counter changes.

interface CounterEvent { mode: string; key: string; // canonical slot identifier count: number; sessionId: string; revision: number; } const unsub = dmn.keys.onCounterChanged( ({ mode, key, count, sessionId, revision }) => { console.log( `[${mode}] ${key}: ${count} (${sessionId}, revision ${revision})`, ); }, );

revision is the monotonically increasing order of the runtime counter change within sessionId. Compare both fields with keyCountersSessionId/keyCountersRevision from dmn.app.bootstrap() when reconciling a bootstrap snapshot with live events.

dmn.keys.onCountersChanged(callback): Unsubscribe

Subscribes to whole counter snapshot replacements such as reset and bulk set operations.

const unsub = dmn.keys.onCountersChanged((counters) => { console.log('Counter snapshot changed:', counters); });

Mode Events

dmn.keys.onModeChanged(callback): Unsubscribe

Subscribes to layout mode changes (keys:mode-changed).

interface ModeEvent { mode: string; // current layout mode ID (e.g. '4key') } const unsub = dmn.keys.onModeChanged(({ mode }) => { console.log('Mode changed:', mode); });

dmn.keys.setMode(mode): Promise<KeysModeResponse>

Changes the active key mode.

const result = await dmn.keys.setMode('8key'); console.log('Mode changed:', result.success);

dmn.keys.resetMode(mode): Promise<KeysModeResponse>

Resets one key mode, its positions, and plugin display elements placed in that mode to the built-in defaults. Other plugin storage is preserved.

await dmn.keys.resetMode('4key');

dmn.keys.resetAll(): Promise<KeysResetAllResponse>

Resets all keys, positions, counters, custom tabs, and plugin display element placements to their built-in defaults. Installed plugins and other plugin storage are preserved. The reset targets saved defineElement placements. Runtime elements created directly by plugin startup code with displayElement.add() can remain or be created again while the plugin remains installed.

const result = await dmn.keys.resetAll(); console.log('Selected mode:', result.selectedKeyType);

Key Mapping

dmn.keys.get(): Promise<KeyMappings>

Returns the key mappings for all modes.

type SlotMatch = 'all' | 'any'; // A slot is either a single key string, or a multi-key binding: // - match: 'any': the slot activates when any member key is pressed // - match: 'all': the slot activates while all member keys are held together interface MultiKeySlot { keys: string[]; // 2 to 8 member keys match: SlotMatch; } type KeySlot = string | MultiKeySlot; type KeyMappings = Record<string, KeySlot[]>; const mappings = await dmn.keys.get(); console.log(mappings['4key']);

Since the multi-key update, a slot value can be an object (MultiKeySlot), not just a string. Plugins that assume every slot is a string should be updated before handling mappings that contain multi-key slots.

Canonical slot identifiers

Event and counter surfaces identify a slot by its canonical string:

  • A single-key slot is its key string, unchanged (e.g. "D").
  • A match: 'all' slot joins its members with + in member order (e.g. "LEFT CTRL+Z").
  • A match: 'any' slot joins its members with | (e.g. "F|NUMPAD 4").

Canonical strings are for generation and equality comparison only. Do not parse them back into members; read the slot objects from get() instead. The display label of an any slot joins members with / (e.g. Z/B), which is a UI convention separate from the canonical |. Switching a slot between all and any changes its canonical identifier, so existing counters do not carry over between the two forms.

Canonical identifiers appear in onKeyState().key, counter map keys (getCounters(), onCounterChanged, onCountersChanged), resetSingleCounter(mode, key), and UI anchor and key menu keyCode values. Mapping surfaces (get(), update(), onChanged, editor documents, preset snapshots) carry the KeySlot union instead.

Hand-crafted legacy data may contain a single-key slot whose string itself includes + or | (for example the one string "A+B"). Such a slot is preserved losslessly but is inert: no daemon input can activate it. It can cosmetically share a canonical identifier, and therefore a counter bucket, with a real multi-key slot; where both exist, note effects resolve to the real multi-key slot.

dmn.keys.update(mappings: KeyMappings, options?): Promise<KeyMappings>

Replaces the key mappings and returns the committed value.

const mappings = await dmn.keys.get(); mappings['4key'][0] = 'S'; const committed = await dmn.keys.update(mappings);

To write mappings while the current configuration contains multi-key slots, declare multi-key support explicitly:

const committed = await dmn.keys.update(mappings, { multiKey: true });

Without the declaration, a write is rejected with the MULTI_KEY_UNSUPPORTED error code (non-retryable) whenever the current mappings contain at least one multi-key slot. This fail-closed gate protects user configurations from being destroyed by plugins that round-trip mappings without preserving multi-key slots. The same rule applies to updateWithPositions() and to dmn.editor.commit() requests that include keys (declare via multiKey: true on the commit request).

dmn.keys.getPositions(): Promise<KeyPositions>

Returns the key positions for all modes.

const positions = await dmn.keys.getPositions(); console.log(positions['4key']);

Style-related fields on each KeyPosition include solid colors and optional gradient siblings:

interface KeyPosition { id?: string; // stable element identity (UUID), assigned and owned by the app // ...position, image, note, and counter fields... rotation: number; // degrees, -180 to 180. Defaults to 0. Content rotation about the box center imageMode?: 'replace' | 'overlay'; // image placement, defaults to replace (image replaces the key) idleImageTransform?: { offsetX: number; offsetY: number; rotation: number; scale: number; }; // identity when absent activeImageTransform?: { offsetX: number; offsetY: number; rotation: number; scale: number; }; noteOpacityTop?: number; noteOpacityBottom?: number; noteGradient?: GradientSpec | null; noteGlowSyncPaint?: boolean; noteGlowOpacityTop?: number; noteGlowOpacityBottom?: number; noteGlowGradient?: GradientSpec | null; noteBorderColor?: string; noteBorderGradient?: GradientSpec | null; backgroundColor?: string; activeBackgroundColor?: string; borderColor?: string; activeBorderColor?: string; borderWidth?: number; // px, defaults to 1 when unset. 0 disables the border backgroundGradient?: GradientSpec | null; activeBackgroundGradient?: GradientSpec | null; borderGradient?: GradientSpec | null; activeBorderGradient?: GradientSpec | null; fontColor?: string; activeFontColor?: string; fontGradient?: GradientSpec | null; activeFontGradient?: GradientSpec | null; shadow?: ElementShadowSpec; activeShadow?: ElementShadowSpec; } interface KeyCounterSettings { // ...placement, typography, and animation fields... fill: { idle: string; active: string }; fillIdleGradient?: GradientSpec | null; fillActiveGradient?: GradientSpec | null; } interface GradientSpec { angle: number; // 0 (inclusive) to 360 (exclusive). 360 normalizes to 0; CSS semantics (0 = up, clockwise) stops: { color: string; pos: number }[]; // 2–8 stops, pos 0–1 ascending } interface ElementShadowSpec { enabled: boolean; color: string; // alpha controls opacity offsetX: number; // px, -100 to 100 offsetY: number; // px, -100 to 100 blur: number; // px, 0 to 100 }

rotation is the element rotation in degrees. Keys, stats, graphs, and knobs share the field. The logical box (dx/dy/width/height) stays axis aligned while the face, the note track, and the outside counter rotate together about the box center. Omitting the field means 0; null, non-finite values, and values outside -180..180 are rejected. Automatic hitline correction (noteAutoYCorrection) aligns unrotated keys to the content top, and aligns rotated keys that flow in the same direction (within 0.5°) to the top edge that is furthest along that direction. A key rotated on its own starts at its own top edge, and note offsets apply after the correction. Sprites also have an element-level placement rotation, independent of their idle and per-pose rotations. See Sprite Position Types.

Every element position (keyPositions, statPositions, graphPositions, knobPositions, spritePositions) carries a stable id. Treat it as opaque: echo back the value you read and never invent one. A write whose id is missing or unknown inherits the id of an existing element whose remaining fields match; with no match, the backend assigns a fresh one. Omitting an id does not guarantee a fresh identity, and editing an element’s values while omitting its id may give it one. When the same id appears more than once, for example when you copy an element you read and append it, the element in its original slot keeps the identity and the remaining copies get fresh ones. Loading a full preset re-issues every id; a tab preset re-issues only the collections that preset supplies positions for. Use id to track an element across reorders instead of its array index.

When a gradient field is present it takes priority over the matching solid field, and the solid field is kept in sync with the first stop color on save. To return to a solid color, set the gradient field to null and update the solid field.

The counter fill gradient fields likewise take priority over counter.fill.idle and counter.fill.active.

For note borders, noteBorderGradient takes priority over noteBorderColor. noteBorderColor always stays a #RRGGBB hex value, so saving a gradient syncs it to the hex form of the first stop. Stop colors accept only hex (#RGB, #RGBA, #RRGGBB, #RRGGBBAA) or rgb()/rgba() notation.

Note bodies and glows support the same format. When noteGradient or noteGlowGradient is present, that surface switches to the new semantics: noteOpacity/noteGlowOpacity acts as a global multiplier (100 when absent), and the legacy fields (the two-color top/bottom object and the per-end opacities) stay synced as approximations for older versions. Stop color restrictions match the border. Writing an element without the gradient field (or with it set to null) returns that surface to legacy semantics. To switch a surface in one write, set the gradient field and its multiplier together in the same updatePositions call; the solid field and the legacy approximations are derived and synced on save.

When noteGlowSyncPaint is true, the glow paint fields (noteGlowColor, noteGlowGradient, noteGlowOpacity, noteGlowOpacityTop/Bottom) are derived from the body paint on save. Any glow value written in that state is overwritten by the body value, so set it to false first to edit the glow independently. Defaults to false.

shadow and activeShadow control the idle and pressed shadows independently. When omitted, the built-in shadow remains unchanged. Store a spec with enabled: false to explicitly disable a shadow.

dmn.keys.updatePositions(positions: KeyPositions): Promise<KeyPositions>

Replaces the key positions and returns the committed value.

const positions = await dmn.keys.getPositions(); positions['4key'][0].dx = 100; const committed = await dmn.keys.updatePositions(positions);

Position writes from plugins are serialized on a dedicated queue and settle against the committed document: the promise resolves only after the commit and the follow-up read complete, and the returned collections carry the stable element id values the app assigned (elements submitted without an id get one issued). On failure the original error is rejected as-is; if a commit might have succeeded but the follow-up read failed, read the current value before retrying instead of resubmitting blindly. The same applies to statItems, graphItems, and knobItems updatePositions.

keys[mode][i] and keyPositions[mode][i] are coupled by index. A standalone update() or updatePositions() may fail with PAIRED_UPDATE_REQUIRED if it changes a mode set or an array length. Use updateWithPositions() for key additions, removals, and reordering.

dmn.keys.updateWithPositions(mappings, positions, options?): Promise<KeysWithPositionsResult>

Updates key mappings and their index-coupled positions in one atomic commit. The two values must have matching mode sets and array lengths. The committed values are returned, and both keys:changed and positions:changed are emitted after the commit succeeds.

interface KeysWithPositionsResult { keys: KeyMappings; positions: KeyPositions; } const result = await dmn.keys.updateWithPositions(mappings, positions, { multiKey: true, });

The third options argument takes the same { multiKey?: boolean } declaration as update(). Without it, the write is rejected with MULTI_KEY_UNSUPPORTED whenever the current mappings contain a multi-key slot.

dmn.keys.onChanged(callback): Unsubscribe

Subscribes to key mapping changes (keys:changed).

const unsubscribe = dmn.keys.onChanged((mappings) => { console.log('key mappings changed', mappings); });

dmn.keys.onPositionsChanged(callback): Unsubscribe

Subscribes to key position changes (positions:changed).

const unsubscribe = dmn.keys.onPositionsChanged((positions) => { console.log('key positions changed', positions); });

These two per-collection events remain available for compatibility, but are deprecated for new editor state synchronization. Use dmn.editor.onCommitted() to observe atomic multi-collection edits.


Custom Tab

Main window only.

dmn.keys.customTabs.list(): Promise<CustomTab[]>

Returns all custom tabs.

const tabs = await dmn.keys.customTabs.list();

dmn.keys.customTabs.create(name): Promise<CustomTabResult>

Creates and selects a new custom tab.

const result = await dmn.keys.customTabs.create('My Keys'); // error: "invalid-name" | "name-too-long" | "reserved-name" // | "duplicate-name" | "max-reached" console.log(result.result?.id, result.error);

The name is trimmed before validation. It is rejected when empty (invalid-name), longer than 10 UTF-16 code units (name-too-long), equal to 4key, 5key, 6key, or 8key (reserved-name), or already in use (duplicate-name). Reaching 30 tabs yields max-reached.

dmn.keys.customTabs.delete(id): Promise<CustomTabDeleteResult>

Deletes a custom tab and its tab-scoped editor data.

const result = await dmn.keys.customTabs.delete('custom-123'); console.log('Selected mode:', result.selected);

dmn.keys.customTabs.select(id): Promise<CustomTabDeleteResult>

Selects an existing built-in mode or custom tab.

await dmn.keys.customTabs.select('custom-123');

dmn.keys.customTabs.restore(customTabs, selectedKeyType): Promise<void>

Restores the names of existing custom tabs and the selected mode atomically. Every tab ID must already have paired keys and keyPositions; use create, delete, and updateWithPositions for topology changes. Invalid or orphaned tab metadata is rejected without changing stored data.

Display order is owned by tabOrder and restore never touches it. The order of the array you pass is ignored; the stored order is kept as is.

await dmn.keys.customTabs.restore( [{ id: 'custom-123', name: 'My Keys' }], 'custom-123', );

dmn.keys.customTabs.onChanged(callback): Unsubscribe

Subscribes to custom tab create, delete, restore, rename, and reorder changes.

Alongside customTabs and selectedKeyType, the payload carries tabOrder, barCount, and selectionAuthoritative. tabOrder is the combined display order of built-in modes and custom tabs, containing every tab ID exactly once; barCount is how many of the leading entries appear in the toolbar bar (1 to 4). selectionAuthoritative is false for rename and reorder events, whose selectedKeyType is only a transaction snapshot, and true when the event owns the selected mode. Since list() returns only an array, read the display order from tabOrder.

const unsub = dmn.keys.customTabs.onChanged( ({ customTabs, tabOrder, barCount, selectedKeyType, selectionAuthoritative, }) => { console.log(customTabs, selectedKeyType); console.log('in the bar:', tabOrder.slice(0, barCount)); console.log('selected mode belongs to event:', selectionAuthoritative); }, );

Stats API

Real-time key input statistics API. Provides KPS (Keys Per Second) and total key count for the current tab.

No performance overhead when there are no subscribers - statistics calculation is only performed when subscribed.

dmn.stats.subscribe(callback): Unsubscribe

Subscribes to real-time key input statistics.

  • KPS values (kps, kpsAvg, kpsMax): Updated approximately every 50ms
  • total: Updated immediately when key counter changes
interface KeyStatsPayload { kps: number; // Current KPS (keys per second) kpsAvg: number; // Average KPS kpsMax: number; // Maximum KPS total: number; // Total key count for current tab } const unsub = dmn.stats.subscribe(({ kps, kpsAvg, kpsMax, total }) => { console.log(`KPS: ${kps}, AVG: ${kpsAvg}, MAX: ${kpsMax}, TOTAL: ${total}`); }); // Unsubscribe unsub();

dmn.stats.get(): KeyStatsPayload

Gets current statistics immediately.

const stats = dmn.stats.get(); console.log(`Current KPS: ${stats.kps}`);

dmn.stats.reset(): void

Resets KPS-related statistics (kps, kpsAvg, kpsMax). total is not reset as it’s based on key counters.

dmn.stats.reset();

To reset total, use dmn.keys.resetCounters() or dmn.keys.resetCountersMode(mode).


Example Usage

// KPS counter using stats API dmn.plugin.defineElement({ name: 'KPS Counter', maxInstances: 1, template: (state, settings, { html }) => html` <div style="font-size: 24px; font-weight: bold;"> KPS: ${state.kps ?? 0} | MAX: ${state.kpsMax ?? 0} </div> `, onMount: ({ setState }) => { const unsub = dmn.stats.subscribe(({ kps, kpsMax }) => { setState({ kps, kpsMax }); }); return () => unsub(); }, });