Skip to content

feat(studio): every editor and player shortcut is remappable - #6966

Merged
miguel-heygen merged 8 commits into
mainfrom
dkeys/studio-key-resolver-combos
Nov 2, 2026
Merged

miguel-heygen merged 8 commits into
mainfrom
dkeys/studio-key-resolver-combos

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

What

Every shortcut Studio's editor and player answer is now an action in the key resolver, so a host can remap each one with setStudioKeys, including the ones with modifiers, Space and the arrow keys. The key a handler answers and the key the shortcut panel, tooltips and clip menu show change together. With no override nothing changes.

Shortcuts can be key combinations ("mod+shift+z", "Space", "ArrowLeft"); mod means Cmd or Ctrl. Ctrl+Alt is refused because Windows sends AltGr as Ctrl+Alt, and Shift with a symbol key is refused because it depends on the layout.

Why

Hosts that embed Studio want users to change any shortcut, not just the single letters. The playback and editing keys were literals in about twenty files.

How

  • Routed through the resolver: play/pause, frame step and 10-frame step, mute, loop, stop, shuttle, in/out points and their clears, seek to in/out, undo, redo (plus the Ctrl+Y alternate), copy, paste, cut, duplicate, group, ungroup, link, detach audio, delete (plus the alternates), linked delete, reset keyframes, dock tab digits, canvas nudges (and 10x), caption select all, keyboard clip pickup/move/drop, and one cancel action for the Escape that cancels a gesture.
  • Handlers read the map at event time. Playback keys dispatch through one table in usePlaybackKeyboard.
  • mod stays Cmd or Ctrl either way, as today.

Not remappable, on purpose

These are the keyboard contract of a focused widget rather than editor shortcuts; changing them would break keyboard and screen-reader use:

  • Arrow/Home/End/PageUp navigation inside a focused timeline row, menu or dock sash.
  • Enter or Space activating a focused button or control.
  • Escape and Enter inside dialogs and menus.
  • Keys typed into inputs (the existing guards stay).
  • Ctrl+Alt combinations (refused: AltGr on Windows).

Test plan

  • usePlaybackKeyboard.test.ts and useAppHotkeys.test.ts cover every default through the real handlers, an override answering with the old key silent, and hints following the override.
  • Mutating a handler back to a literal fails the override tests.
  • Documentation updated: not applicable.
  • Comments follow CONTRIBUTING.md.

What I measured

On a Linux box at the pushed head: oxfmt --check, oxlint, fallow audit --base origin/main and tsc --noEmit clean; the full studio suite passes (722 files, 8268 tests). New tests: studioCombo.test.ts (parse, match with mod as Cmd or Ctrl, symbols, refused combos), every default and an override through the real handlers in usePlaybackKeyboard.test.ts and useAppHotkeys.test.ts. Mutating a handler back to a literal fails the override tests.

What I did NOT exercise

Host adoption (no host feeds the resolver here). Non-Latin and dead-key layouts. Real Windows AltGr input.

Before and after

A Studio dev server on a Linux box, headless Chrome, the repo's timeline-trim-snap fixture as the open
project. Before is main. After is this branch with one host call made from the page:

setStudioKeys({ playPause: "p", undo: "mod+u" })

The same script drove both sides:

main this branch, after the call
Shortcuts panel, Play / Pause Space P
Shortcuts panel, Undo ⌘Z ⌘U
Space plays, 00:00 → 00:01 nothing: still 00:00 and paused
P nothing plays, 00:00 → 00:02
Ctrl+Z after deleting a clip brings the clip back leaves it deleted
Ctrl+U after deleting a clip nothing brings the clip back

The panel prints ⌘ for mod on every platform, as it did before this PR; mod still answers Cmd or Ctrl,
so Ctrl+U is what undoes on Linux.

Before (main)

Shortcuts panel: Space plays, ⌘Z undoes.

before-panel-playback-Space

before-panel-editing-CmdZ

Space plays: the clock reads 00:01 and the transport shows Pause.

before-Space-plays

After (this branch, play/pause on P and undo on mod+u)

The panel follows the override.

after-panel-playback-P

after-panel-editing-CmdU

Space no longer plays: the clock stays at 00:00 and the transport still shows Play.

after-Space-does-nothing

P plays.

after-P-plays

@miguel-heygen
miguel-heygen changed the base branch from dkeys/studio-key-combos-base to main November 2, 2026 01:12

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST CHANGES · 2 blockers · 2 important · 0 nits · head a634fdba

This now folds in the closed #6965. It fixes the three things I raised there: handlers now go through matchesKey, Shift with a symbol is refused, and the body states that ⌘ prints on every platform.

  • blocker, Comments ratchet fails at this head: captions/keyboard.ts goes from 3/21 to 4/22 comment lines because of the new doc comment on resolveCaptionNudge (↓ inline). CI was still running when I reviewed.
  • important (playback ignore list still uses fixed keys): playbackShortcuts.ts:80 still uses the hard-coded PLAYBACK_FRAME_STEP_CODES (ArrowLeft/ArrowRight) to stop frame-stepping while caption segments are selected. Suppose a host puts frameBack and nudgeLeft both on ,, which findClash allows because that pair is shared by design. In caption edit mode with segments selected, , then frame-steps and nudges the caption. That double handling is exactly what the guard exists to prevent. Make the guard test matchesKey(e, "frameBack"|"frameForward"|…Ten) instead.

The other blocker and important are inline.

Scope

Read end to end: studioCombo.ts, studioShortcuts.ts, appHotkeysDispatch.ts, useAppHotkeys.ts, usePlaybackKeyboard.ts, playbackShortcuts.ts, linkShortcuts.ts, domEditNudge.ts, captions/keyboard.ts, useTimelineKeyboardActor.ts. Not read: the remaining one- to three-line hint call sites and the test files. Not run locally: there are no node_modules on this box, so the dead-path finding comes from reading the code.

togglePlay();
return;
}
if (e.defaultPrevented || ignoresPlaybackKey(e)) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocker: a playback action remapped to any combo with Cmd, Ctrl or Alt can never fire. ignoresPlaybackKey calls shouldIgnorePlaybackShortcutEvent, and that returns true straight away for any metaKey || ctrlKey || altKey (playbackShortcuts.ts:76). So setStudioKeys({ playPause: "mod+p" }) or { mute: "alt+m" } is accepted, the panel shows ⌘P, and the key does nothing; the old key stops working too. That breaks the PR's own claim that every shortcut is remappable, modifier combos included. The same goes for all 16 PLAYBACK_ACTIONS. Either drop the modifier early-out and rely on matchesKey, which is already exact on modifiers, or refuse modifier combos for playback actions in resolve. Add an override test with a modifier through the real handler; the current override test uses bare p, which slips past this check.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Fixed in f6f2ee1

if (isFrameStep && canvasNudgeKeysClaimed() && resolveCanvasNudgeDelta(e)) return;
if (e.repeat && action !== "playPause" && !isFrameStep) return;
const shuttleOrStep = (direction: -1 | 1) =>
pressedKeysRef.current.has(keyOf("pause"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

important: this compares keyOf("pause") (the combo's key) with pressedKeysRef, which stores e.key.toLowerCase(). With pause on a named key the two never agree. For example, with { pause: "Space", playPause: "p" }, keyOf gives "Space" while the ref holds " ". So holding the pause key plus J or L shuttles instead of stepping one frame. Track held actions with matchesKey on keydown and keyup instead of raw e.key.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Fixed in f6f2ee1

type CaptionNudgeKeyEvent = Parameters<typeof resolveCanvasNudgeDelta>[0];

type CaptionNudgeKeyEvent = Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "key">;
/** The nudge delta for this key, or null when it is not a nudge or the target owns its keys. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocker (Comments ratchet): this added line raises the file's comment share from 3/21 to 4/22, which fails the Comments check at this head. Cut it; the name and the | null return already say this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Fixed in 249c40f

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 271 (base branch 271), smooth 120 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (0)

Unstable (1)

  • seqplainkeys-none-center-r0-root-z100: tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST CHANGES — 1 resolved, 3 open, 1 new · a634fdba → 249c40f4. Still open: the playback modifier early-out (blocker), the caption ignore list's fixed arrow keys, and the held-pause key comparison.

hintKey(keys[action]);
formatCombo(parseCombo(keys[action])!);

/** True when the event is the combo the host (or the default) set for this action. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocker (Comments ratchet, new at this head): with the captions/keyboard.ts comment gone, the ratchet now fails on this file: 3/266 against 2/207 at the base. This doc comment is the added line. Cut it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Fixed in bdcd4c7

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST CHANGES — 1 resolved, 3 open, 0 new · 249c40f4 → bdcd4c73. Still open: the playback modifier early-out (blocker, usePlaybackKeyboard.ts:152), the caption ignore list's fixed arrow keys, and the held-pause key comparison.

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE — 3 resolved, 0 open, 0 new · bdcd4c73 → f6f2ee1f. CI, CodeQL and preview-regression were still running at review time; everything finished is green.

@miguel-heygen
miguel-heygen added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit 9fa02ba Nov 2, 2026
78 checks passed
@miguel-heygen
miguel-heygen deleted the dkeys/studio-key-resolver-combos branch November 2, 2026 02:48

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Post-merge advisory review (this PR is already merged into main). Findings are follow-ups, not gating. Reviewed at f6f2ee1f04acc1a5d6dce68ae17190508778f0db.

Genesis's prior modifier-combo early-out, fixed caption-arrow guard, and named held-pause findings are fixed at this head. The resolver now drives those real playback paths, and #6965's 252d9ef4 is an ancestor of this head.

Important follow-ups:

  • packages/studio/src/components/editor/InspectorHeaderActions.tsx:58 and PropertyPanelFlatHeader.tsx:59: useStudioKeys() runs only when the Ungroup button exists. Rendering either header first without that button and then with it throws Rendered more hooks than during the previous render. The classic inspector header is retained while selecting an ordinary element and then a group (PropertyPanel.tsx:304; its StudioRightPanels.tsx:202 caller is unkeyed). The flat panel is selection-keyed, which mitigates ordinary selection swaps there, but its header has the same conditional-hook defect. Call const keys = useStudioKeys() unconditionally at each component's top level and use keyHint("ungroup", keys) in JSX. Add same-root ordinary/group transitions, rather than separate initial renders.
  • packages/studio/src/hooks/appHotkeysDispatch.ts:244: a remapped resetKeyframes is still reachable only inside the deleteSelected/deleteSelectedAlt branch. setStudioKeys({resetKeyframes:"q"}) is accepted, yet Q never calls onResetKeyframes; Backspace can instead proceed to clip deletion once it no longer matches Reset. Resolve Reset independently while preserving selected-keyframe and automation ownership for shared defaults. Test the independent reset override and ensure its old key cannot reset.

Evidence: exact-head focused Vitest invocation of studioCombo, studioShortcuts, useAppHotkeys, usePlaybackKeyboard, useTimelinePlayer, caption keyboard, domEditNudge, linkShortcuts, and PropertyPanelFlatHeader: 157 passed. Three independent regression controls fail at this head: two header transitions throw, and the reset override calls its callback zero times. Moving the two hooks to component top level and adding an independent reset branch makes the same three controls pass; temporary source changes were restored. bun run build passes. No live host, real Windows AltGr/non-Latin input, or complete interactive editor session was exercised.

Audited: resolver/parser, playback and app dispatch, caption/canvas guards, focused-widget pickup/navigation boundary, header hint lifecycle, and integration callers. Trusting: visual screenshots and reported 8268-test suite; not rerun here.

Need: yes, host-controlled shortcuts · Size: ok, one shared key contract · Reuse: existing resolver and ownership guards · Simpler: keep event-time resolution; call hooks unconditionally and separate Reset dispatch · Structure: fine apart from the two concrete correctness gaps above
Risk: medium — keyboard mapping crosses editor, playback, focused widgets and React lifecycle.

Verdict: COMMENT (post-merge: With fixes)
Reasoning: The prior review blockers are resolved, but two independently reproduced regressions need follow-up fixes before widening host adoption.

— hf-review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants