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
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 pointdmn.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
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
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
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();
},
});