From 1741bcc2bc3c5b6df88151590f4517d79de5893a Mon Sep 17 00:00:00 2001 From: Jage9 Date: Sun, 8 Mar 2026 19:27:23 -0400 Subject: [PATCH] Add context-aware command palette --- client/public/help.json | 4 + client/public/piano.json | 8 + client/public/version.js | 2 +- client/src/input/commandTypes.ts | 19 + client/src/input/mainCommandRouter.ts | 23 +- client/src/input/mainModeCommands.ts | 327 +++++++ client/src/input/modeDispatcher.ts | 12 +- client/src/items/types/behaviorRegistry.ts | 31 +- client/src/items/types/piano/behavior.ts | 16 +- client/src/items/types/piano/runtime.ts | 355 +++++--- client/src/items/types/runtimeShared.ts | 7 +- client/src/main.ts | 941 ++++++++++++--------- client/src/state/gameState.ts | 1 + docs/controls.md | 13 + 14 files changed, 1241 insertions(+), 518 deletions(-) create mode 100644 client/src/input/commandTypes.ts create mode 100644 client/src/input/mainModeCommands.ts diff --git a/client/public/help.json b/client/public/help.json index a8722f7..5d48edf 100644 --- a/client/public/help.json +++ b/client/public/help.json @@ -7,6 +7,10 @@ "keys": "Arrow Keys", "description": "Move on the grid" }, + { + "keys": "Shift+K / Applications / Shift+F10", + "description": "Open the command palette in supported modes" + }, { "keys": "C", "description": "Speak your coordinates" diff --git a/client/public/piano.json b/client/public/piano.json index 0395361..987e41e 100644 --- a/client/public/piano.json +++ b/client/public/piano.json @@ -15,6 +15,10 @@ "keys": "1 through 0", "description": "Change instrument presets." }, + { + "keys": "Shift+K / Applications / Shift+F10", + "description": "Open the command palette." + }, { "keys": "Minus / Equals", "description": "Shift octave down or up." @@ -38,6 +42,10 @@ { "keys": "Escape", "description": "Exit piano mode." + }, + { + "keys": "Shifted note keys", + "description": "Ignored in piano mode so shifted shortcuts stay distinct." } ] } diff --git a/client/public/version.js b/client/public/version.js index 99d6b09..88425ee 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,5 +1,5 @@ // Maintainer-controlled web client version. // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) -window.CHGRID_WEB_VERSION = "2026.03.01 R334"; +window.CHGRID_WEB_VERSION = "2026.03.08 R335"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/input/commandTypes.ts b/client/src/input/commandTypes.ts new file mode 100644 index 0000000..3134fb6 --- /dev/null +++ b/client/src/input/commandTypes.ts @@ -0,0 +1,19 @@ +export type ModeInput = { + code: string; + key: string; + ctrlKey: boolean; + shiftKey: boolean; +}; + +export type CommandDescriptor = { + id: CommandId; + label: string; + shortcut: string; + tooltip: string; + section: string; +}; + +/** Formats a palette/menu label as `Name: Key`. */ +export function formatCommandMenuLabel(command: Pick): string { + return `${command.label}: ${command.shortcut}`; +} diff --git a/client/src/input/mainCommandRouter.ts b/client/src/input/mainCommandRouter.ts index 93385a1..9345363 100644 --- a/client/src/input/mainCommandRouter.ts +++ b/client/src/input/mainCommandRouter.ts @@ -22,12 +22,15 @@ export type MainModeCommand = | 'secondaryUseItem' | 'speakUsers' | 'addItem' - | 'locateOrListItems' + | 'locateNearestItem' + | 'listItems' | 'pickupDropItem' | 'openItemManagement' - | 'editOrInspectItem' + | 'editItem' + | 'inspectItem' | 'pingServer' - | 'locateOrListUsers' + | 'locateNearestUser' + | 'listUsers' | 'openHelp' | 'openChat' | 'openAdminMenu' @@ -44,9 +47,9 @@ export function resolveMainModeCommand(code: string, shiftKey: boolean): MainMod if (code === 'KeyN') return shiftKey ? null : 'editNickname'; if (code === 'KeyM') return shiftKey ? 'toggleOutputMode' : 'toggleMute'; if (code === 'Digit1') return shiftKey ? 'toggleLoopback' : 'toggleVoiceLayer'; - if (code === 'Digit2') return 'toggleItemLayer'; - if (code === 'Digit3') return 'toggleMediaLayer'; - if (code === 'Digit4') return 'toggleWorldLayer'; + if (code === 'Digit2') return shiftKey ? null : 'toggleItemLayer'; + if (code === 'Digit3') return shiftKey ? null : 'toggleMediaLayer'; + if (code === 'Digit4') return shiftKey ? null : 'toggleWorldLayer'; if (code === 'KeyE') return shiftKey ? null : 'openEffectSelect'; if (code === 'Equal') return shiftKey ? 'effectValueUp' : 'masterVolumeUp'; if (code === 'Minus') return shiftKey ? 'effectValueDown' : 'masterVolumeDown'; @@ -57,15 +60,15 @@ export function resolveMainModeCommand(code: string, shiftKey: boolean): MainMod if (code === 'Enter') return shiftKey ? 'secondaryUseItem' : 'useItem'; if (code === 'KeyU') return shiftKey ? null : 'speakUsers'; if (code === 'KeyA') return shiftKey ? null : 'addItem'; - if (code === 'KeyI') return 'locateOrListItems'; + if (code === 'KeyI') return shiftKey ? 'listItems' : 'locateNearestItem'; if (code === 'KeyD') return shiftKey ? null : 'pickupDropItem'; - if (code === 'KeyO') return 'editOrInspectItem'; + if (code === 'KeyO') return shiftKey ? 'inspectItem' : 'editItem'; if (code === 'KeyP') return shiftKey ? null : 'pingServer'; - if (code === 'KeyL') return 'locateOrListUsers'; + if (code === 'KeyL') return shiftKey ? 'listUsers' : 'locateNearestUser'; if (code === 'Slash') return shiftKey ? 'openHelp' : 'openChat'; if (code === 'KeyZ') return shiftKey ? 'openAdminMenu' : 'openItemManagement'; if (code === 'Comma') return shiftKey ? 'chatFirst' : 'chatPrev'; if (code === 'Period') return shiftKey ? 'chatLast' : 'chatNext'; - if (code === 'Escape') return 'escape'; + if (code === 'Escape') return shiftKey ? null : 'escape'; return null; } diff --git a/client/src/input/mainModeCommands.ts b/client/src/input/mainModeCommands.ts new file mode 100644 index 0000000..56b8059 --- /dev/null +++ b/client/src/input/mainModeCommands.ts @@ -0,0 +1,327 @@ +import type { CommandDescriptor } from './commandTypes'; +import type { MainModeCommand } from './mainCommandRouter'; + +export type MainModeCommandAvailabilityContext = { + voiceSendAllowed: boolean; + mainHelpAvailable: boolean; + hasAdminActions: boolean; + itemTypeCount: number; + visibleItemCount: number; + userCount: number; + chatMessageCount: number; + hasCarriedItem: boolean; + squareItemCount: number; + usableItemCount: number; + manageableItemCount: number; + hasEditableItemTarget: boolean; + hasInspectableItemTarget: boolean; +}; + +type MainModeCommandDescriptor = CommandDescriptor & { + isAvailable: (context: MainModeCommandAvailabilityContext) => boolean; +}; + +const MAIN_MODE_COMMANDS: MainModeCommandDescriptor[] = [ + { + id: 'editNickname', + label: 'Edit nickname', + shortcut: 'N', + tooltip: 'Edit your current nickname.', + section: 'Users', + isAvailable: () => true, + }, + { + id: 'toggleMute', + label: 'Mute or unmute microphone', + shortcut: 'M', + tooltip: 'Toggle local microphone mute.', + section: 'Audio', + isAvailable: () => true, + }, + { + id: 'toggleOutputMode', + label: 'Toggle stereo or mono output', + shortcut: 'Shift+M', + tooltip: 'Switch between stereo and mono output.', + section: 'Audio', + isAvailable: () => true, + }, + { + id: 'toggleLoopback', + label: 'Toggle loopback monitor', + shortcut: 'Shift+1', + tooltip: 'Toggle local microphone loopback monitoring.', + section: 'Audio', + isAvailable: () => true, + }, + { + id: 'toggleVoiceLayer', + label: 'Toggle voice layer', + shortcut: '1', + tooltip: 'Enable or disable voice audio.', + section: 'Audio', + isAvailable: () => true, + }, + { + id: 'toggleItemLayer', + label: 'Toggle item layer', + shortcut: '2', + tooltip: 'Enable or disable item sounds.', + section: 'Audio', + isAvailable: () => true, + }, + { + id: 'toggleMediaLayer', + label: 'Toggle media layer', + shortcut: '3', + tooltip: 'Enable or disable media audio such as radio.', + section: 'Audio', + isAvailable: () => true, + }, + { + id: 'toggleWorldLayer', + label: 'Toggle world layer', + shortcut: '4', + tooltip: 'Enable or disable other world sounds.', + section: 'Audio', + isAvailable: () => true, + }, + { + id: 'masterVolumeUp', + label: 'Raise master volume', + shortcut: '=', + tooltip: 'Increase overall output volume.', + section: 'Audio', + isAvailable: () => true, + }, + { + id: 'masterVolumeDown', + label: 'Lower master volume', + shortcut: '-', + tooltip: 'Decrease overall output volume.', + section: 'Audio', + isAvailable: () => true, + }, + { + id: 'openEffectSelect', + label: 'Open effect select', + shortcut: 'E', + tooltip: 'Open the effects menu.', + section: 'Audio', + isAvailable: () => true, + }, + { + id: 'effectValueUp', + label: 'Raise active effect value', + shortcut: 'Shift+=', + tooltip: 'Increase the selected effect amount.', + section: 'Audio', + isAvailable: () => true, + }, + { + id: 'effectValueDown', + label: 'Lower active effect value', + shortcut: 'Shift+-', + tooltip: 'Decrease the selected effect amount.', + section: 'Audio', + isAvailable: () => true, + }, + { + id: 'speakCoordinates', + label: 'Speak coordinates', + shortcut: 'C', + tooltip: 'Announce your current coordinates.', + section: 'Navigation', + isAvailable: () => true, + }, + { + id: 'openMicGainEdit', + label: 'Set microphone gain', + shortcut: 'V', + tooltip: 'Open microphone gain editing.', + section: 'Audio', + isAvailable: (context) => context.voiceSendAllowed, + }, + { + id: 'calibrateMicrophone', + label: 'Calibrate microphone', + shortcut: 'Shift+V', + tooltip: 'Run microphone calibration.', + section: 'Audio', + isAvailable: (context) => context.voiceSendAllowed, + }, + { + id: 'useItem', + label: 'Use item', + shortcut: 'Enter', + tooltip: 'Use the carried item or a usable item on your current square.', + section: 'Items', + isAvailable: (context) => context.hasCarriedItem || context.usableItemCount > 0, + }, + { + id: 'secondaryUseItem', + label: 'Secondary item action', + shortcut: 'Shift+Enter', + tooltip: 'Run the secondary action for the carried item or a usable item on your current square.', + section: 'Items', + isAvailable: (context) => context.hasCarriedItem || context.usableItemCount > 0, + }, + { + id: 'speakUsers', + label: 'Speak connected users', + shortcut: 'U', + tooltip: 'Announce connected users including yourself.', + section: 'Users', + isAvailable: () => true, + }, + { + id: 'addItem', + label: 'Add item', + shortcut: 'A', + tooltip: 'Open the add-item menu.', + section: 'Items', + isAvailable: (context) => context.itemTypeCount > 0, + }, + { + id: 'locateNearestItem', + label: 'Locate nearest item', + shortcut: 'I', + tooltip: 'Announce the nearest visible item.', + section: 'Items', + isAvailable: (context) => context.visibleItemCount > 0, + }, + { + id: 'listItems', + label: 'List items', + shortcut: 'Shift+I', + tooltip: 'Open the nearby item list and teleport with Enter.', + section: 'Items', + isAvailable: (context) => context.visibleItemCount > 0, + }, + { + id: 'pickupDropItem', + label: 'Pick up or drop item', + shortcut: 'D', + tooltip: 'Pick up an item on your square or drop your carried item.', + section: 'Items', + isAvailable: (context) => context.hasCarriedItem || context.squareItemCount > 0, + }, + { + id: 'openItemManagement', + label: 'Item management', + shortcut: 'Z', + tooltip: 'Open item management actions for items on your square.', + section: 'Items', + isAvailable: (context) => context.manageableItemCount > 0, + }, + { + id: 'editItem', + label: 'Edit item properties', + shortcut: 'O', + tooltip: 'Edit the carried item or an item on your current square.', + section: 'Items', + isAvailable: (context) => context.hasEditableItemTarget, + }, + { + id: 'inspectItem', + label: 'Inspect item properties', + shortcut: 'Shift+O', + tooltip: 'Inspect all properties for the carried item or an item on your current square.', + section: 'Items', + isAvailable: (context) => context.hasInspectableItemTarget, + }, + { + id: 'pingServer', + label: 'Ping server', + shortcut: 'P', + tooltip: 'Measure round-trip latency to the server.', + section: 'Network', + isAvailable: () => true, + }, + { + id: 'locateNearestUser', + label: 'Locate nearest user', + shortcut: 'L', + tooltip: 'Announce the nearest connected user.', + section: 'Users', + isAvailable: (context) => context.userCount > 0, + }, + { + id: 'listUsers', + label: 'List users', + shortcut: 'Shift+L', + tooltip: 'Open the user list; Enter teleports and left or right adjust listen volume.', + section: 'Users', + isAvailable: (context) => context.userCount > 0, + }, + { + id: 'openHelp', + label: 'Open help', + shortcut: '?', + tooltip: 'Open the main help viewer.', + section: 'Help', + isAvailable: (context) => context.mainHelpAvailable, + }, + { + id: 'openChat', + label: 'Open chat', + shortcut: '/', + tooltip: 'Start typing a chat message.', + section: 'Chat', + isAvailable: () => true, + }, + { + id: 'openAdminMenu', + label: 'Open admin menu', + shortcut: 'Shift+Z', + tooltip: 'Open the admin actions menu when permitted.', + section: 'Admin', + isAvailable: (context) => context.hasAdminActions, + }, + { + id: 'chatPrev', + label: 'Previous chat message', + shortcut: ',', + tooltip: 'Read the previous buffered chat message.', + section: 'Chat', + isAvailable: (context) => context.chatMessageCount > 0, + }, + { + id: 'chatNext', + label: 'Next chat message', + shortcut: '.', + tooltip: 'Read the next buffered chat message.', + section: 'Chat', + isAvailable: (context) => context.chatMessageCount > 0, + }, + { + id: 'chatFirst', + label: 'First chat message', + shortcut: 'Shift+,', + tooltip: 'Jump to the first buffered chat message.', + section: 'Chat', + isAvailable: (context) => context.chatMessageCount > 0, + }, + { + id: 'chatLast', + label: 'Last chat message', + shortcut: 'Shift+.', + tooltip: 'Jump to the last buffered chat message.', + section: 'Chat', + isAvailable: (context) => context.chatMessageCount > 0, + }, + { + id: 'escape', + label: 'Disconnect prompt', + shortcut: 'Escape', + tooltip: 'Press once for a disconnect prompt and again to disconnect.', + section: 'System', + isAvailable: () => true, + }, +]; + +export function getAvailableMainModeCommands( + context: MainModeCommandAvailabilityContext, +): MainModeCommandDescriptor[] { + return MAIN_MODE_COMMANDS.filter((command) => command.isAvailable(context)); +} diff --git a/client/src/input/modeDispatcher.ts b/client/src/input/modeDispatcher.ts index 9341258..d395b1c 100644 --- a/client/src/input/modeDispatcher.ts +++ b/client/src/input/modeDispatcher.ts @@ -1,15 +1,13 @@ import type { GameMode } from '../state/gameState'; +import type { ModeInput } from './commandTypes'; -type ModeHandler = (code: string, key: string, ctrlKey: boolean) => void; +type ModeHandler = (input: ModeInput) => void; type ModeHandlers = Partial>; type DispatchOptions = { mode: GameMode; - code: string; - key: string; - ctrlKey: boolean; - shiftKey: boolean; + input: ModeInput; handlers: ModeHandlers; onNormalMode: (code: string, shiftKey: boolean) => void; }; @@ -20,8 +18,8 @@ type DispatchOptions = { export function dispatchModeInput(options: DispatchOptions): void { const modeHandler = options.handlers[options.mode]; if (modeHandler) { - modeHandler(options.code, options.key, options.ctrlKey); + modeHandler(options.input); return; } - options.onNormalMode(options.code, options.shiftKey); + options.onNormalMode(options.input.code, options.input.shiftKey); } diff --git a/client/src/items/types/behaviorRegistry.ts b/client/src/items/types/behaviorRegistry.ts index b52af09..3832315 100644 --- a/client/src/items/types/behaviorRegistry.ts +++ b/client/src/items/types/behaviorRegistry.ts @@ -1,5 +1,6 @@ import { type IncomingMessage } from '../../network/protocol'; import { type GameMode, type WorldItem } from '../../state/gameState'; +import { type CommandDescriptor, type ModeInput } from '../../input/commandTypes'; import { createPianoBehavior } from './piano/behavior'; import { type ItemBehavior, type ItemBehaviorDeps } from './runtimeShared'; @@ -57,9 +58,9 @@ export class ItemBehaviorRegistry { } /** Gives item behaviors first chance to handle mode input. */ - handleModeInput(mode: GameMode, code: string): boolean { + handleModeInput(mode: GameMode, input: ModeInput): boolean { for (const behavior of this.behaviors) { - if (behavior.handleModeInput?.(mode, code)) { + if (behavior.handleModeInput?.(mode, input)) { return true; } } @@ -67,9 +68,31 @@ export class ItemBehaviorRegistry { } /** Gives item behaviors first chance to handle mode key-up events. */ - handleModeKeyUp(mode: GameMode, code: string): boolean { + handleModeKeyUp(mode: GameMode, input: Pick): boolean { for (const behavior of this.behaviors) { - if (behavior.handleModeKeyUp?.(mode, code)) { + if (behavior.handleModeKeyUp?.(mode, input)) { + return true; + } + } + return false; + } + + /** Returns palette-visible commands for the active item-owned mode, if any. */ + getModeCommands(mode: GameMode): CommandDescriptor[] { + const commands: CommandDescriptor[] = []; + for (const behavior of this.behaviors) { + const next = behavior.getModeCommands?.(mode); + if (next && next.length > 0) { + commands.push(...next); + } + } + return commands; + } + + /** Runs an item-owned mode command by id, returning true when handled. */ + runModeCommand(mode: GameMode, commandId: string): boolean { + for (const behavior of this.behaviors) { + if (behavior.runModeCommand?.(mode, commandId)) { return true; } } diff --git a/client/src/items/types/piano/behavior.ts b/client/src/items/types/piano/behavior.ts index cb0c357..d64b531 100644 --- a/client/src/items/types/piano/behavior.ts +++ b/client/src/items/types/piano/behavior.ts @@ -40,16 +40,24 @@ export function createPianoBehavior(deps: ItemBehaviorDeps): ItemBehavior { onWorldUpdate: () => { controller.syncAfterWorldUpdate(); }, - handleModeInput: (mode, code) => { + handleModeInput: (mode, input) => { if (mode !== 'pianoUse') return false; - controller.handleModeInput(code); + controller.handleModeInput(input); return true; }, - handleModeKeyUp: (mode, code) => { + handleModeKeyUp: (mode, input) => { if (mode !== 'pianoUse') return false; - controller.handleModeKeyUp(code); + controller.handleModeKeyUp(input); return true; }, + getModeCommands: (mode) => { + if (mode !== 'pianoUse') return []; + return controller.getModeCommands(); + }, + runModeCommand: (mode, commandId) => { + if (mode !== 'pianoUse') return false; + return controller.runModeCommand(commandId); + }, onRemotePianoNote: (message) => { if (message.on) { controller.playRemoteNote({ diff --git a/client/src/items/types/piano/runtime.ts b/client/src/items/types/piano/runtime.ts index 706dde4..bcbf507 100644 --- a/client/src/items/types/piano/runtime.ts +++ b/client/src/items/types/piano/runtime.ts @@ -4,6 +4,7 @@ import { isPianoInstrumentId, type PianoInstrumentId, } from '../../../audio/pianoSynth'; +import { type CommandDescriptor, type ModeInput } from '../../../input/commandTypes'; import { type OutgoingMessage } from '../../../network/protocol'; import { type GameMode, type WorldItem } from '../../../state/gameState'; import { getItemPropertyOptionValues } from '../../itemRegistry'; @@ -33,6 +34,26 @@ const PIANO_SHARP_KEY_MIDI_BY_CODE: Record = { BracketRight: 78, }; +type PianoModeCommandId = + | 'openHelp' + | 'stopUseMode' + | 'playDemo' + | 'toggleRecord' + | 'playbackRecording' + | 'stopPlaybackAndRecording' + | 'octaveDown' + | 'octaveUp' + | 'instrumentPreset1' + | 'instrumentPreset2' + | 'instrumentPreset3' + | 'instrumentPreset4' + | 'instrumentPreset5' + | 'instrumentPreset6' + | 'instrumentPreset7' + | 'instrumentPreset8' + | 'instrumentPreset9' + | 'instrumentPreset10'; + type PianoDemoEvent = { t: number; keyId: string; @@ -258,14 +279,102 @@ export class PianoController { } } - /** Handles realtime keyboard performance while piano item mode is active. */ - handleModeInput(code: string): void { - if (code === 'Escape') { - this.stopUseMode(true); - return; + /** Returns palette-visible commands while piano item mode is active. */ + getModeCommands(): CommandDescriptor[] { + if (!this.activePianoItemId) { + return []; } - if (code === 'Slash') { - this.deps.openHelpViewer(this.helpViewerLines, 'pianoUse'); + const commands: CommandDescriptor[] = [ + { + id: 'openHelp', + label: 'Open piano help', + shortcut: '?', + tooltip: 'Open piano help.', + section: 'Piano', + }, + { + id: 'stopUseMode', + label: 'Exit piano mode', + shortcut: 'Escape', + tooltip: 'Stop using the current piano.', + section: 'Piano', + }, + { + id: 'playDemo', + label: 'Play demo', + shortcut: 'Enter', + tooltip: 'Play the piano demo melody.', + section: 'Piano', + }, + { + id: 'toggleRecord', + label: 'Toggle recording', + shortcut: 'Z', + tooltip: 'Start, pause, or resume piano recording.', + section: 'Piano', + }, + { + id: 'playbackRecording', + label: 'Play recording', + shortcut: 'X', + tooltip: 'Play the saved piano recording.', + section: 'Piano', + }, + { + id: 'stopPlaybackAndRecording', + label: 'Stop playback or recording', + shortcut: 'C', + tooltip: 'Stop demo playback, recording playback, and active recording.', + section: 'Piano', + }, + { + id: 'octaveDown', + label: 'Lower octave', + shortcut: '-', + tooltip: 'Shift the piano octave down.', + section: 'Piano', + }, + { + id: 'octaveUp', + label: 'Raise octave', + shortcut: '=', + tooltip: 'Shift the piano octave up.', + section: 'Piano', + }, + ]; + const instruments = this.getShortcutInstruments(); + for (let index = 0; index < instruments.length; index += 1) { + const slot = index + 1; + const instrument = instruments[index]; + if (!instrument) continue; + commands.push({ + id: `instrumentPreset${slot}` as PianoModeCommandId, + label: `Switch to ${this.formatInstrumentLabel(instrument)} preset`, + shortcut: slot === 10 ? '0' : String(slot), + tooltip: `Switch to instrument preset ${slot}: ${this.formatInstrumentLabel(instrument)}.`, + section: 'Piano', + }); + } + return commands.filter((command) => this.isCommandAvailable(command.id)); + } + + /** Runs one piano mode command by id. */ + runModeCommand(commandId: string): boolean { + if (!this.activePianoItemId) { + return false; + } + const resolvedId = commandId as PianoModeCommandId; + if (!this.isCommandAvailable(resolvedId)) { + return false; + } + return this.executeCommand(resolvedId); + } + + /** Handles realtime keyboard performance while piano item mode is active. */ + handleModeInput(input: ModeInput): void { + const command = this.resolveCommand(input); + if (command) { + this.executeCommand(command); return; } const itemId = this.activePianoItemId; @@ -278,109 +387,31 @@ export class PianoController { this.stopUseMode(false); return; } - if (code === 'Enter') { - if (this.activePianoRecordingState !== 'idle') { - this.deps.updateStatus('Stop or pause recording first.'); - this.deps.audio.sfxUiCancel(); - return; - } - this.deps.signalingSend({ type: 'item_piano_recording', itemId, action: 'stop_playback' }); - this.startDemo(item, itemId); - this.deps.updateStatus('demo play'); - this.deps.audio.sfxUiBlip(); - return; - } - if (code === 'KeyZ') { - this.deps.signalingSend({ type: 'item_piano_recording', itemId, action: 'toggle_record' }); - return; - } - if (code === 'KeyX') { - if (this.activePianoRecordingState !== 'idle') { - this.deps.updateStatus('Stop or pause recording first.'); - this.deps.audio.sfxUiCancel(); - return; - } - this.stopDemo(true); - this.deps.signalingSend({ type: 'item_piano_recording', itemId, action: 'playback' }); - return; - } - if (code === 'KeyC') { - this.stopDemo(true); - this.deps.signalingSend({ type: 'item_piano_recording', itemId, action: 'stop_playback' }); - this.deps.signalingSend({ type: 'item_piano_recording', itemId, action: 'stop_record' }); - this.activePianoRecordingState = 'idle'; - return; - } - if (code === 'Equal' || code === 'Minus') { - const current = this.getPianoParams(item).octave; - const next = Math.max(-2, Math.min(2, current + (code === 'Equal' ? 1 : -1))); - item.params.octave = next; - this.deps.signalingSend({ type: 'item_update', itemId, params: { octave: next } }); - this.deps.updateStatus(`octave ${next}.`); - return; - } - if (code.startsWith('Digit')) { - const digit = Number(code.slice(5)); - const instrumentIndex = digit === 0 ? 9 : digit - 1; - const shortcutInstruments = this.getShortcutInstruments(); - if (Number.isInteger(instrumentIndex) && instrumentIndex >= 0 && instrumentIndex < shortcutInstruments.length) { - const instrument = shortcutInstruments[instrumentIndex]; - if (instrument) { - const defaults = DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT[instrument]; - const voiceMode = this.defaultsVoiceModeForInstrument(instrument); - const octave = this.defaultsOctaveForInstrument(instrument); - item.params.instrument = instrument; - item.params.voiceMode = voiceMode; - item.params.octave = octave; - item.params.attack = defaults.attack; - item.params.decay = defaults.decay; - item.params.release = defaults.release; - item.params.brightness = defaults.brightness; - this.deps.signalingSend({ - type: 'item_update', - itemId, - params: { - instrument, - }, - }); - void this.previewSettingChange(item, { - instrument, - octave, - attack: defaults.attack, - decay: defaults.decay, - release: defaults.release, - brightness: defaults.brightness, - }); - this.deps.updateStatus(`Instrument ${instrument}.`); - } - return; - } - } - - const midi = this.getPianoMidiForCode(code); + const midi = this.getPianoMidiForCode(input); if (midi === null) return; - if (this.activePianoKeys.has(code)) return; + if (this.activePianoKeys.has(input.code)) return; const config = this.getPianoParams(item); const playedMidi = Math.max(0, Math.min(127, midi + config.octave * 12)); - this.activePianoKeys.add(code); - this.activePianoKeyMidi.set(code, playedMidi); - this.activePianoHeldOrder.push(code); + this.activePianoKeys.add(input.code); + this.activePianoKeyMidi.set(input.code, playedMidi); + this.activePianoHeldOrder.push(input.code); if (config.voiceMode === 'mono') { const previousCode = this.activePianoMonophonicKey; - if (previousCode && previousCode !== code) { + if (previousCode && previousCode !== input.code) { const previousMidi = this.activePianoKeyMidi.get(previousCode); this.pianoSynth.noteOff(previousCode); if (Number.isFinite(previousMidi)) { this.deps.signalingSend({ type: 'item_piano_note', itemId, keyId: previousCode, midi: previousMidi, on: false }); } } - this.activePianoMonophonicKey = code; + this.activePianoMonophonicKey = input.code; } - this.playLocalNote(item, itemId, code, playedMidi, config); + this.playLocalNote(item, itemId, input.code, playedMidi, config); } /** Handles key release while in piano mode, including mono fallback retrigger behavior. */ - handleModeKeyUp(code: string): void { + handleModeKeyUp(input: Pick): void { + const { code } = input; if (!this.activePianoKeys.delete(code)) return; const orderIndex = this.activePianoHeldOrder.lastIndexOf(code); if (orderIndex >= 0) { @@ -677,7 +708,139 @@ export class PianoController { return normalized; } - private getPianoMidiForCode(code: string): number | null { + private formatInstrumentLabel(instrument: PianoInstrumentId): string { + return instrument.replace(/_/g, ' '); + } + + private resolveCommand(input: Pick): PianoModeCommandId | null { + if (input.code === 'Escape' && !input.shiftKey) return 'stopUseMode'; + if (input.code === 'Slash' && input.shiftKey) return 'openHelp'; + if (input.code === 'Enter' && !input.shiftKey) return 'playDemo'; + if (input.code === 'KeyZ' && !input.shiftKey) return 'toggleRecord'; + if (input.code === 'KeyX' && !input.shiftKey) return 'playbackRecording'; + if (input.code === 'KeyC' && !input.shiftKey) return 'stopPlaybackAndRecording'; + if (input.code === 'Minus' && !input.shiftKey) return 'octaveDown'; + if (input.code === 'Equal' && !input.shiftKey) return 'octaveUp'; + if (input.code.startsWith('Digit') && !input.shiftKey) { + const digit = Number(input.code.slice(5)); + const slot = digit === 0 ? 10 : digit; + if (Number.isInteger(slot) && slot >= 1 && slot <= 10) { + return `instrumentPreset${slot}` as PianoModeCommandId; + } + } + return null; + } + + private isCommandAvailable(commandId: PianoModeCommandId): boolean { + if (!this.activePianoItemId) { + return false; + } + if (commandId === 'playDemo' || commandId === 'playbackRecording') { + return this.activePianoRecordingState === 'idle'; + } + if (commandId.startsWith('instrumentPreset')) { + const slot = Number(commandId.slice('instrumentPreset'.length)); + return Number.isInteger(slot) && slot >= 1 && slot <= this.getShortcutInstruments().length; + } + return true; + } + + private executeCommand(commandId: PianoModeCommandId): boolean { + const itemId = this.activePianoItemId; + if (!itemId) { + this.deps.state.mode = 'normal'; + return false; + } + if (commandId === 'openHelp') { + this.deps.openHelpViewer(this.helpViewerLines, 'pianoUse'); + return true; + } + if (commandId === 'stopUseMode') { + this.stopUseMode(true); + return true; + } + const item = this.deps.state.items.get(itemId); + if (!item || item.type !== 'piano') { + this.stopUseMode(false); + return false; + } + if (commandId === 'playDemo') { + this.deps.signalingSend({ type: 'item_piano_recording', itemId, action: 'stop_playback' }); + this.startDemo(item, itemId); + this.deps.updateStatus('demo play'); + this.deps.audio.sfxUiBlip(); + return true; + } + if (commandId === 'toggleRecord') { + this.deps.signalingSend({ type: 'item_piano_recording', itemId, action: 'toggle_record' }); + return true; + } + if (commandId === 'playbackRecording') { + this.stopDemo(true); + this.deps.signalingSend({ type: 'item_piano_recording', itemId, action: 'playback' }); + return true; + } + if (commandId === 'stopPlaybackAndRecording') { + this.stopDemo(true); + this.deps.signalingSend({ type: 'item_piano_recording', itemId, action: 'stop_playback' }); + this.deps.signalingSend({ type: 'item_piano_recording', itemId, action: 'stop_record' }); + this.activePianoRecordingState = 'idle'; + this.deps.updateStatus('Stopped piano playback and recording.'); + this.deps.audio.sfxUiCancel(); + return true; + } + if (commandId === 'octaveDown' || commandId === 'octaveUp') { + const current = this.getPianoParams(item).octave; + const next = Math.max(-2, Math.min(2, current + (commandId === 'octaveUp' ? 1 : -1))); + item.params.octave = next; + this.deps.signalingSend({ type: 'item_update', itemId, params: { octave: next } }); + this.deps.updateStatus(`octave ${next}.`); + this.deps.audio.sfxUiBlip(); + return true; + } + if (commandId.startsWith('instrumentPreset')) { + const slot = Number(commandId.slice('instrumentPreset'.length)); + const instrument = this.getShortcutInstruments()[slot - 1]; + if (!instrument) { + return false; + } + const defaults = DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT[instrument]; + const voiceMode = this.defaultsVoiceModeForInstrument(instrument); + const octave = this.defaultsOctaveForInstrument(instrument); + item.params.instrument = instrument; + item.params.voiceMode = voiceMode; + item.params.octave = octave; + item.params.attack = defaults.attack; + item.params.decay = defaults.decay; + item.params.release = defaults.release; + item.params.brightness = defaults.brightness; + this.deps.signalingSend({ + type: 'item_update', + itemId, + params: { + instrument, + }, + }); + void this.previewSettingChange(item, { + instrument, + octave, + attack: defaults.attack, + decay: defaults.decay, + release: defaults.release, + brightness: defaults.brightness, + }); + this.deps.updateStatus(`Instrument ${instrument}.`); + this.deps.audio.sfxUiBlip(); + return true; + } + return false; + } + + private getPianoMidiForCode(input: Pick): number | null { + if (input.shiftKey) { + return null; + } + const { code } = input; if (code in PIANO_WHITE_KEY_MIDI_BY_CODE) { return PIANO_WHITE_KEY_MIDI_BY_CODE[code]!; } diff --git a/client/src/items/types/runtimeShared.ts b/client/src/items/types/runtimeShared.ts index 2a04c2e..5caa777 100644 --- a/client/src/items/types/runtimeShared.ts +++ b/client/src/items/types/runtimeShared.ts @@ -1,5 +1,6 @@ import { type IncomingMessage, type OutgoingMessage } from '../../network/protocol'; import { type GameMode, type WorldItem } from '../../state/gameState'; +import { type CommandDescriptor, type ModeInput } from '../../input/commandTypes'; /** Shared dependencies made available to all client item behavior modules. */ export type ItemBehaviorDeps = { @@ -29,8 +30,10 @@ export type ItemBehavior = { onActionResultStatus?: (message: Extract) => boolean; onPropertyPreviewChange?: (item: WorldItem, key: string, value: unknown) => void; onWorldUpdate?: () => void; - handleModeInput?: (mode: GameMode, code: string) => boolean; - handleModeKeyUp?: (mode: GameMode, code: string) => boolean; + handleModeInput?: (mode: GameMode, input: ModeInput) => boolean; + handleModeKeyUp?: (mode: GameMode, input: Pick) => boolean; + getModeCommands?: (mode: GameMode) => CommandDescriptor[]; + runModeCommand?: (mode: GameMode, commandId: string) => boolean; onRemotePianoNote?: (message: Extract) => void; onPianoStatus?: (message: Extract) => void; onStopAllRemoteNotesForSender?: (senderId: string) => void; diff --git a/client/src/main.ts b/client/src/main.ts index 279fe6e..1d20688 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -23,7 +23,9 @@ import { moveCursorWordRight, shouldReplaceCurrentText, } from './input/textInput'; -import { resolveMainModeCommand } from './input/mainCommandRouter'; +import { formatCommandMenuLabel, type CommandDescriptor, type ModeInput } from './input/commandTypes'; +import { getAvailableMainModeCommands } from './input/mainModeCommands'; +import { resolveMainModeCommand, type MainModeCommand } from './input/mainCommandRouter'; import { dispatchModeInput } from './input/modeDispatcher'; import { handleListControlKey } from './input/listController'; import { handleYesNoMenuInput, YES_NO_OPTIONS } from './input/yesNoMenu'; @@ -314,6 +316,9 @@ let mainHelpViewerLines: string[] = []; let helpViewerLines: string[] = []; let helpViewerIndex = 0; let helpViewerReturnMode: GameMode = 'normal'; +const commandPaletteCommands: Array void | Promise }> = []; +let commandPaletteIndex = 0; +let commandPaletteReturnMode: GameMode = 'normal'; let heartbeatTimerId: number | null = null; let heartbeatNextPingId = -1; let heartbeatAwaitingPong = false; @@ -2157,6 +2162,457 @@ function toggleMute(): void { updateStatus(state.isMuted ? 'Muted.' : 'Unmuted.'); } +function getCurrentSquareItems(): WorldItem[] { + return getItemsAtPosition(state.player.x, state.player.y); +} + +function getUsableItemsOnCurrentSquare(): WorldItem[] { + return getCurrentSquareItems().filter((item) => item.capabilities.includes('usable')); +} + +function getManageableItemsOnCurrentSquare(): WorldItem[] { + return getCurrentSquareItems().filter((item) => itemManagementOptionsFor(item).length > 0); +} + +function canEditCurrentItem(): boolean { + return getCurrentSquareItems().length > 0 || Boolean(getCarriedItem()); +} + +function canInspectCurrentItem(): boolean { + return canEditCurrentItem(); +} + +function openNicknameEditor(): void { + state.mode = 'nickname'; + state.nicknameInput = state.player.nickname; + state.cursorPos = state.player.nickname.length; + replaceTextOnNextType = true; + updateStatus(`Nickname edit: ${state.nicknameInput}`); + audio.sfxUiBlip(); +} + +function toggleOutputModeCommand(): void { + outputMode = audio.toggleOutputMode(); + mediaSession.saveOutputMode(outputMode); + updateStatus(outputMode === 'mono' ? 'Mono output.' : 'Stereo output.'); + audio.sfxUiBlip(); +} + +function toggleLoopbackCommand(): void { + const enabled = audio.toggleLoopback(); + updateStatus(enabled ? 'Loopback on.' : 'Loopback off.'); + audio.sfxUiBlip(); +} + +function adjustMasterVolumeCommand(step: number): void { + const next = audio.adjustMasterVolume(step); + persistMasterVolume(next); + updateStatus(`Master volume ${next}`); + audio.sfxEffectLevel(next === 50); +} + +function openEffectSelectCommand(): void { + const currentEffect = audio.getCurrentEffect(); + const currentIndex = EFFECT_SEQUENCE.findIndex((effect) => effect.id === currentEffect.id); + state.effectSelectIndex = currentIndex >= 0 ? currentIndex : 0; + state.mode = 'effectSelect'; + announceMenuEntry('Effects', EFFECT_SEQUENCE[state.effectSelectIndex].label); +} + +function adjustEffectValueCommand(step: number): void { + const adjusted = audio.adjustCurrentEffectLevel(step); + if (!adjusted) return; + persistEffectLevels(); + audio.sfxEffectLevel(adjusted.value === adjusted.defaultValue); + updateStatus(`${adjusted.label} ${adjusted.value}`); +} + +function speakCoordinatesCommand(): void { + updateStatus(`${formatCoordinate(state.player.x)}, ${formatCoordinate(state.player.y)}`); + audio.sfxUiBlip(); +} + +function openMicGainEditCommand(): void { + if (!voiceSendAllowed) { + updateStatus('Voice send is disabled for this account.'); + audio.sfxUiCancel(); + return; + } + state.mode = 'micGainEdit'; + state.nicknameInput = formatSteppedNumber(audio.getOutboundInputGain(), MIC_INPUT_GAIN_STEP); + state.cursorPos = state.nicknameInput.length; + replaceTextOnNextType = true; + micGainLoopbackRestoreState = audio.isLoopbackEnabled(); + audio.setLoopbackEnabled(true); + announceMenuEntry('Microphone gain', state.nicknameInput); +} + +function calibrateMicrophoneCommand(): void { + if (!voiceSendAllowed) { + updateStatus('Voice send is disabled for this account.'); + audio.sfxUiCancel(); + return; + } + void calibrateMicInputGain(); +} + +function openAdminMenuCommand(): void { + const actions = getAvailableAdminActions(); + if (actions.length === 0) { + updateStatus('No admin actions available.'); + audio.sfxUiCancel(); + return; + } + adminMenuActions.splice(0, adminMenuActions.length, ...actions); + adminMenuIndex = 0; + state.mode = 'adminMenu'; + announceMenuEntry('Admin', adminMenuActions[0].label); +} + +function useItemCommand(): void { + const carried = getCarriedItem(); + if (carried) { + useItem(carried); + return; + } + const usable = getUsableItemsOnCurrentSquare(); + if (usable.length === 0) { + updateStatus('No usable items here.'); + audio.sfxUiCancel(); + return; + } + if (usable.length === 1) { + useItem(usable[0]); + return; + } + beginItemSelection('use', usable); +} + +function secondaryUseItemCommand(): void { + const carried = getCarriedItem(); + if (carried) { + secondaryUseItem(carried); + return; + } + const usable = getUsableItemsOnCurrentSquare(); + if (usable.length === 0) { + updateStatus('No usable items here.'); + audio.sfxUiCancel(); + return; + } + if (usable.length === 1) { + secondaryUseItem(usable[0]); + return; + } + beginItemSelection('secondaryUse', usable); +} + +function speakUsersCommand(): void { + const allUsers = [state.player.nickname, ...Array.from(state.peers.values()).map((peer) => peer.nickname)]; + const label = allUsers.length === 1 ? 'user' : 'users'; + updateStatus(`${allUsers.length} ${label}: ${allUsers.join(', ')}`); + audio.sfxUiBlip(); +} + +function addItemCommand(): void { + const itemTypeSequence = getItemTypeSequence(); + if (itemTypeSequence.length === 0) { + updateStatus('No item types available.'); + audio.sfxUiCancel(); + return; + } + state.addItemTypeIndex = Math.max(0, Math.min(state.addItemTypeIndex, itemTypeSequence.length - 1)); + state.mode = 'addItem'; + announceMenuEntry('Add item', itemTypeLabel(itemTypeSequence[state.addItemTypeIndex])); +} + +function listItemsCommand(): void { + state.sortedItemIds = Array.from(state.items.entries()) + .filter(([, item]) => !item.carrierId) + .sort( + (a, b) => + Math.hypot(a[1].x - state.player.x, a[1].y - state.player.y) - + Math.hypot(b[1].x - state.player.x, b[1].y - state.player.y), + ) + .map(([id]) => id); + if (state.sortedItemIds.length === 0) { + updateStatus('No items to list.'); + audio.sfxUiCancel(); + return; + } + state.itemListIndex = 0; + state.mode = 'listItems'; + const first = state.items.get(state.sortedItemIds[0]); + if (!first) { + audio.sfxUiCancel(); + return; + } + const itemCount = state.sortedItemIds.length; + const itemLabelText = itemCount === 1 ? 'item' : 'items'; + announceMenuEntry( + `${itemCount} ${itemLabelText}`, + `${itemLabel(first)}, ${distanceDirectionPhrase(state.player.x, state.player.y, first.x, first.y)}, ${first.x}, ${first.y}`, + ); +} + +function locateNearestItemCommand(): void { + const nearest = getNearestItem(state); + if (!nearest.itemId) { + updateStatus('No items to locate.'); + audio.sfxUiCancel(); + return; + } + const item = state.items.get(nearest.itemId); + if (!item) return; + audio.sfxLocate({ x: item.x - state.player.x, y: item.y - state.player.y }); + updateStatus(`${itemLabel(item)}, ${distanceDirectionPhrase(state.player.x, state.player.y, item.x, item.y)}, ${item.x}, ${item.y}`); +} + +function pickupDropItemCommand(): void { + const carried = getCarriedItem(); + if (carried) { + signaling.send({ type: 'item_drop', itemId: carried.id, x: state.player.x, y: state.player.y }); + return; + } + const squareItems = getCurrentSquareItems(); + if (squareItems.length === 0) { + updateStatus('No items to pick up.'); + audio.sfxUiCancel(); + return; + } + if (squareItems.length === 1) { + signaling.send({ type: 'item_pickup', itemId: squareItems[0].id }); + return; + } + beginItemSelection('pickup', squareItems); +} + +function openItemManagementCommand(): void { + const squareItems = getCurrentSquareItems(); + if (squareItems.length === 0) { + updateStatus('No items to manage on this square.'); + audio.sfxUiCancel(); + return; + } + const manageable = squareItems.filter((item) => itemManagementOptionsFor(item).length > 0); + if (manageable.length === 0) { + updateStatus('No permitted item management actions here.'); + audio.sfxUiCancel(); + return; + } + if (manageable.length === 1) { + beginItemManagement(manageable[0]); + return; + } + beginItemSelection('manage', manageable); +} + +function editItemCommand(): void { + const squareItems = getCurrentSquareItems(); + const carried = getCarriedItem(); + if (squareItems.length === 0) { + if (!carried) { + updateStatus('No editable item here.'); + audio.sfxUiCancel(); + return; + } + beginItemProperties(carried); + return; + } + if (squareItems.length === 1) { + beginItemProperties(squareItems[0]); + return; + } + beginItemSelection('edit', squareItems); +} + +function inspectItemCommand(): void { + const squareItems = getCurrentSquareItems(); + const carried = getCarriedItem(); + if (squareItems.length === 0) { + if (!carried) { + updateStatus('No item to inspect.'); + audio.sfxUiCancel(); + return; + } + beginItemProperties(carried, true); + return; + } + if (squareItems.length === 1) { + beginItemProperties(squareItems[0], true); + return; + } + beginItemSelection('inspect', squareItems); +} + +function pingServerCommand(): void { + signaling.send({ type: 'ping', clientSentAt: Date.now() }); +} + +function listUsersCommand(): void { + if (state.peers.size === 0) { + updateStatus('No users to list.'); + audio.sfxUiCancel(); + return; + } + state.sortedPeerIds = Array.from(state.peers.entries()) + .sort((a, b) => a[1].nickname.localeCompare(b[1].nickname, undefined, { sensitivity: 'base' })) + .map(([id]) => id); + state.listIndex = 0; + state.mode = 'listUsers'; + const first = state.peers.get(state.sortedPeerIds[0]); + if (!first) { + audio.sfxUiCancel(); + return; + } + const userCount = state.sortedPeerIds.length; + const userLabelText = userCount === 1 ? 'user' : 'users'; + const gainPhrase = `volume ${formatSteppedNumber(getPeerListenGainForNickname(first.nickname), MIC_INPUT_GAIN_STEP)}`; + announceMenuEntry( + `${userCount} ${userLabelText}`, + `${first.nickname}, ${gainPhrase}, ${distanceDirectionPhrase(state.player.x, state.player.y, first.x, first.y)}, ${first.x}, ${first.y}`, + ); +} + +function locateNearestUserCommand(): void { + const nearest = getNearestPeer(state); + if (!nearest.peerId) { + updateStatus('No users to locate.'); + audio.sfxUiCancel(); + return; + } + const peer = state.peers.get(nearest.peerId); + if (!peer) return; + audio.sfxLocate({ x: peer.x - state.player.x, y: peer.y - state.player.y }); + updateStatus(`${peer.nickname}, ${distanceDirectionPhrase(state.player.x, state.player.y, peer.x, peer.y)}, ${peer.x}, ${peer.y}`); +} + +function openHelpCommand(): void { + openHelpViewer(mainHelpViewerLines); +} + +function openChatCommand(): void { + state.mode = 'chat'; + state.nicknameInput = ''; + state.cursorPos = 0; + replaceTextOnNextType = false; + updateStatus('Chat.'); + audio.sfxUiBlip(); +} + +function escapeCommand(): void { + if (pendingEscapeDisconnect) { + pendingEscapeDisconnect = false; + disconnect(); + return; + } + pendingEscapeDisconnect = true; + updateStatus('Press Escape again to disconnect.'); + audio.sfxUiCancel(); +} + +const mainModeCommandHandlers: Record void> = { + editNickname: openNicknameEditor, + toggleMute, + toggleOutputMode: toggleOutputModeCommand, + toggleLoopback: toggleLoopbackCommand, + toggleVoiceLayer: () => toggleAudioLayer('voice'), + toggleItemLayer: () => toggleAudioLayer('item'), + toggleMediaLayer: () => toggleAudioLayer('media'), + toggleWorldLayer: () => toggleAudioLayer('world'), + masterVolumeUp: () => adjustMasterVolumeCommand(5), + masterVolumeDown: () => adjustMasterVolumeCommand(-5), + openEffectSelect: openEffectSelectCommand, + effectValueUp: () => adjustEffectValueCommand(5), + effectValueDown: () => adjustEffectValueCommand(-5), + speakCoordinates: speakCoordinatesCommand, + openMicGainEdit: openMicGainEditCommand, + calibrateMicrophone: calibrateMicrophoneCommand, + useItem: useItemCommand, + secondaryUseItem: secondaryUseItemCommand, + speakUsers: speakUsersCommand, + addItem: addItemCommand, + locateNearestItem: locateNearestItemCommand, + listItems: listItemsCommand, + pickupDropItem: pickupDropItemCommand, + openItemManagement: openItemManagementCommand, + editItem: editItemCommand, + inspectItem: inspectItemCommand, + pingServer: pingServerCommand, + locateNearestUser: locateNearestUserCommand, + listUsers: listUsersCommand, + openHelp: openHelpCommand, + openChat: openChatCommand, + openAdminMenu: openAdminMenuCommand, + chatPrev: () => navigateChatBuffer('prev'), + chatNext: () => navigateChatBuffer('next'), + chatFirst: () => navigateChatBuffer('first'), + chatLast: () => navigateChatBuffer('last'), + escape: escapeCommand, +}; + +function getAvailableCommandPaletteEntriesForMode(mode: GameMode): Array void | Promise }> { + if (mode === 'normal') { + const descriptors = getAvailableMainModeCommands({ + voiceSendAllowed, + mainHelpAvailable: mainHelpViewerLines.length > 0, + hasAdminActions: getAvailableAdminActions().length > 0, + itemTypeCount: getItemTypeSequence().length, + visibleItemCount: Array.from(state.items.values()).filter((item) => !item.carrierId).length, + userCount: state.peers.size, + chatMessageCount: messageBuffer.length, + hasCarriedItem: Boolean(getCarriedItem()), + squareItemCount: getCurrentSquareItems().length, + usableItemCount: getUsableItemsOnCurrentSquare().length, + manageableItemCount: getManageableItemsOnCurrentSquare().length, + hasEditableItemTarget: canEditCurrentItem(), + hasInspectableItemTarget: canInspectCurrentItem(), + }); + return descriptors.map((descriptor) => ({ + ...descriptor, + run: mainModeCommandHandlers[descriptor.id], + })); + } + if (mode === 'pianoUse') { + return itemBehaviorRegistry.getModeCommands(mode).map((descriptor) => ({ + ...descriptor, + run: () => { + itemBehaviorRegistry.runModeCommand(mode, descriptor.id); + }, + })); + } + return []; +} + +function canOpenCommandPaletteInMode(mode: GameMode): boolean { + return mode === 'normal' || mode === 'pianoUse' || mode === 'commandPalette'; +} + +function openCommandPalette(): void { + const sourceMode = state.mode; + if (sourceMode === 'commandPalette') { + return; + } + const commands = getAvailableCommandPaletteEntriesForMode(sourceMode); + if (commands.length === 0) { + updateStatus('No commands available in this mode.'); + audio.sfxUiCancel(); + return; + } + commandPaletteCommands.splice(0, commandPaletteCommands.length, ...commands); + commandPaletteIndex = 0; + commandPaletteReturnMode = sourceMode; + state.mode = 'commandPalette'; + announceMenuEntry('Commands', formatCommandMenuLabel(commandPaletteCommands[0])); +} + +function executeCommandPaletteSelection(): void { + const selected = commandPaletteCommands[commandPaletteIndex]; + if (!selected) return; + state.mode = commandPaletteReturnMode; + void selected.run(); +} + /** Handles command-mode keybindings while in main gameplay mode. */ function handleNormalModeInput(code: string, shiftKey: boolean): void { if (code !== 'Escape' && pendingEscapeDisconnect) { @@ -2164,369 +2620,7 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void { } const command = resolveMainModeCommand(code, shiftKey); if (!command) return; - - switch (command) { - case 'editNickname': - state.mode = 'nickname'; - state.nicknameInput = state.player.nickname; - state.cursorPos = state.player.nickname.length; - replaceTextOnNextType = true; - updateStatus(`Nickname edit: ${state.nicknameInput}`); - audio.sfxUiBlip(); - return; - case 'toggleMute': - toggleMute(); - return; - case 'toggleOutputMode': - outputMode = audio.toggleOutputMode(); - mediaSession.saveOutputMode(outputMode); - updateStatus(outputMode === 'mono' ? 'Mono output.' : 'Stereo output.'); - audio.sfxUiBlip(); - return; - case 'toggleLoopback': { - const enabled = audio.toggleLoopback(); - updateStatus(enabled ? 'Loopback on.' : 'Loopback off.'); - audio.sfxUiBlip(); - return; - } - case 'toggleVoiceLayer': - toggleAudioLayer('voice'); - return; - case 'toggleItemLayer': - toggleAudioLayer('item'); - return; - case 'toggleMediaLayer': - toggleAudioLayer('media'); - return; - case 'toggleWorldLayer': - toggleAudioLayer('world'); - return; - case 'masterVolumeUp': - case 'masterVolumeDown': { - const step = command === 'masterVolumeUp' ? 5 : -5; - const next = audio.adjustMasterVolume(step); - persistMasterVolume(next); - updateStatus(`Master volume ${next}`); - audio.sfxEffectLevel(next === 50); - return; - } - case 'openEffectSelect': { - const currentEffect = audio.getCurrentEffect(); - const currentIndex = EFFECT_SEQUENCE.findIndex((effect) => effect.id === currentEffect.id); - state.effectSelectIndex = currentIndex >= 0 ? currentIndex : 0; - state.mode = 'effectSelect'; - announceMenuEntry('Effects', EFFECT_SEQUENCE[state.effectSelectIndex].label); - return; - } - case 'effectValueUp': - case 'effectValueDown': { - const step = command === 'effectValueUp' ? 5 : -5; - const adjusted = audio.adjustCurrentEffectLevel(step); - if (!adjusted) return; - persistEffectLevels(); - audio.sfxEffectLevel(adjusted.value === adjusted.defaultValue); - updateStatus(`${adjusted.label} ${adjusted.value}`); - return; - } - case 'speakCoordinates': - updateStatus(`${formatCoordinate(state.player.x)}, ${formatCoordinate(state.player.y)}`); - audio.sfxUiBlip(); - return; - case 'openMicGainEdit': - if (!voiceSendAllowed) { - updateStatus('Voice send is disabled for this account.'); - audio.sfxUiCancel(); - return; - } - state.mode = 'micGainEdit'; - state.nicknameInput = formatSteppedNumber(audio.getOutboundInputGain(), MIC_INPUT_GAIN_STEP); - state.cursorPos = state.nicknameInput.length; - replaceTextOnNextType = true; - micGainLoopbackRestoreState = audio.isLoopbackEnabled(); - audio.setLoopbackEnabled(true); - announceMenuEntry('Microphone gain', state.nicknameInput); - return; - case 'calibrateMicrophone': - if (!voiceSendAllowed) { - updateStatus('Voice send is disabled for this account.'); - audio.sfxUiCancel(); - return; - } - void calibrateMicInputGain(); - return; - case 'openAdminMenu': { - const actions = getAvailableAdminActions(); - if (actions.length === 0) { - return; - } - adminMenuActions.splice(0, adminMenuActions.length, ...actions); - adminMenuIndex = 0; - state.mode = 'adminMenu'; - announceMenuEntry('Admin', adminMenuActions[0].label); - return; - } - case 'useItem': { - const carried = getCarriedItem(); - if (carried) { - useItem(carried); - return; - } - const squareItems = getItemsAtPosition(state.player.x, state.player.y); - const usable = squareItems.filter((item) => item.capabilities.includes('usable')); - if (usable.length === 0) { - updateStatus('No usable items here.'); - audio.sfxUiCancel(); - return; - } - if (usable.length === 1) { - useItem(usable[0]); - return; - } - beginItemSelection('use', usable); - return; - } - case 'secondaryUseItem': { - const carried = getCarriedItem(); - if (carried) { - secondaryUseItem(carried); - return; - } - const squareItems = getItemsAtPosition(state.player.x, state.player.y); - const usable = squareItems.filter((item) => item.capabilities.includes('usable')); - if (usable.length === 0) { - updateStatus('No usable items here.'); - audio.sfxUiCancel(); - return; - } - if (usable.length === 1) { - secondaryUseItem(usable[0]); - return; - } - beginItemSelection('secondaryUse', usable); - return; - } - case 'speakUsers': { - const allUsers = [state.player.nickname, ...Array.from(state.peers.values()).map((p) => p.nickname)]; - const label = allUsers.length === 1 ? 'user' : 'users'; - updateStatus(`${allUsers.length} ${label}: ${allUsers.join(', ')}`); - audio.sfxUiBlip(); - return; - } - case 'addItem': { - const itemTypeSequence = getItemTypeSequence(); - if (itemTypeSequence.length === 0) { - updateStatus('No item types available.'); - audio.sfxUiCancel(); - return; - } - state.addItemTypeIndex = Math.max(0, Math.min(state.addItemTypeIndex, itemTypeSequence.length - 1)); - state.mode = 'addItem'; - announceMenuEntry('Add item', itemTypeLabel(itemTypeSequence[state.addItemTypeIndex])); - return; - } - case 'locateOrListItems': - if (shiftKey) { - if (state.items.size === 0) { - updateStatus('No items to list.'); - audio.sfxUiCancel(); - return; - } - state.sortedItemIds = Array.from(state.items.entries()) - .filter(([, item]) => !item.carrierId) - .sort( - (a, b) => - Math.hypot(a[1].x - state.player.x, a[1].y - state.player.y) - - Math.hypot(b[1].x - state.player.x, b[1].y - state.player.y), - ) - .map(([id]) => id); - if (state.sortedItemIds.length === 0) { - updateStatus('No items to list.'); - audio.sfxUiCancel(); - return; - } - state.itemListIndex = 0; - state.mode = 'listItems'; - const first = state.items.get(state.sortedItemIds[0]); - if (first) { - const itemCount = state.sortedItemIds.length; - const itemLabelText = itemCount === 1 ? 'item' : 'items'; - announceMenuEntry( - `${itemCount} ${itemLabelText}`, - `${itemLabel(first)}, ${distanceDirectionPhrase(state.player.x, state.player.y, first.x, first.y)}, ${first.x}, ${first.y}`, - ); - } else { - audio.sfxUiCancel(); - } - return; - } - { - const nearest = getNearestItem(state); - if (!nearest.itemId) { - updateStatus('No items to locate.'); - audio.sfxUiCancel(); - return; - } - const item = state.items.get(nearest.itemId); - if (!item) return; - audio.sfxLocate({ x: item.x - state.player.x, y: item.y - state.player.y }); - updateStatus( - `${itemLabel(item)}, ${distanceDirectionPhrase(state.player.x, state.player.y, item.x, item.y)}, ${item.x}, ${item.y}`, - ); - return; - } - case 'pickupDropItem': { - const carried = getCarriedItem(); - if (carried) { - signaling.send({ type: 'item_drop', itemId: carried.id, x: state.player.x, y: state.player.y }); - return; - } - const squareItems = getItemsAtPosition(state.player.x, state.player.y); - if (squareItems.length === 0) { - updateStatus('No items to pick up.'); - audio.sfxUiCancel(); - return; - } - if (squareItems.length === 1) { - signaling.send({ type: 'item_pickup', itemId: squareItems[0].id }); - return; - } - beginItemSelection('pickup', squareItems); - return; - } - case 'openItemManagement': { - const squareItems = getItemsAtPosition(state.player.x, state.player.y); - if (squareItems.length === 0) { - updateStatus('No items to manage on this square.'); - audio.sfxUiCancel(); - return; - } - const manageable = squareItems.filter((item) => itemManagementOptionsFor(item).length > 0); - if (manageable.length === 0) { - updateStatus('No permitted item management actions here.'); - audio.sfxUiCancel(); - return; - } - if (manageable.length === 1) { - beginItemManagement(manageable[0]); - return; - } - beginItemSelection('manage', manageable); - return; - } - case 'editOrInspectItem': { - const squareItems = getItemsAtPosition(state.player.x, state.player.y); - const carried = getCarriedItem(); - if (shiftKey) { - if (squareItems.length === 0) { - if (!carried) { - updateStatus('No item to inspect.'); - audio.sfxUiCancel(); - return; - } - beginItemProperties(carried, true); - return; - } - if (squareItems.length === 1) { - beginItemProperties(squareItems[0], true); - return; - } - beginItemSelection('inspect', squareItems); - return; - } - if (squareItems.length === 0) { - if (!carried) { - updateStatus('No editable item here.'); - audio.sfxUiCancel(); - return; - } - beginItemProperties(carried); - return; - } - if (squareItems.length === 1) { - beginItemProperties(squareItems[0]); - return; - } - beginItemSelection('edit', squareItems); - return; - } - case 'pingServer': - signaling.send({ type: 'ping', clientSentAt: Date.now() }); - return; - case 'locateOrListUsers': - if (shiftKey) { - if (state.peers.size === 0) { - updateStatus('No users to list.'); - audio.sfxUiCancel(); - return; - } - state.sortedPeerIds = Array.from(state.peers.entries()) - .sort((a, b) => a[1].nickname.localeCompare(b[1].nickname, undefined, { sensitivity: 'base' })) - .map(([id]) => id); - state.listIndex = 0; - state.mode = 'listUsers'; - const first = state.peers.get(state.sortedPeerIds[0]); - if (first) { - const userCount = state.sortedPeerIds.length; - const userLabelText = userCount === 1 ? 'user' : 'users'; - const gainPhrase = `volume ${formatSteppedNumber(getPeerListenGainForNickname(first.nickname), MIC_INPUT_GAIN_STEP)}`; - announceMenuEntry( - `${userCount} ${userLabelText}`, - `${first.nickname}, ${gainPhrase}, ${distanceDirectionPhrase(state.player.x, state.player.y, first.x, first.y)}, ${first.x}, ${first.y}`, - ); - } else { - audio.sfxUiCancel(); - } - return; - } - { - const nearest = getNearestPeer(state); - if (!nearest.peerId) { - updateStatus('No users to locate.'); - audio.sfxUiCancel(); - return; - } - const peer = state.peers.get(nearest.peerId); - if (!peer) return; - audio.sfxLocate({ x: peer.x - state.player.x, y: peer.y - state.player.y }); - updateStatus( - `${peer.nickname}, ${distanceDirectionPhrase(state.player.x, state.player.y, peer.x, peer.y)}, ${peer.x}, ${peer.y}`, - ); - return; - } - case 'openHelp': - openHelpViewer(mainHelpViewerLines); - return; - case 'openChat': - state.mode = 'chat'; - state.nicknameInput = ''; - state.cursorPos = 0; - replaceTextOnNextType = false; - updateStatus('Chat.'); - audio.sfxUiBlip(); - return; - case 'chatPrev': - navigateChatBuffer('prev'); - return; - case 'chatNext': - navigateChatBuffer('next'); - return; - case 'chatFirst': - navigateChatBuffer('first'); - return; - case 'chatLast': - navigateChatBuffer('last'); - return; - case 'escape': - if (pendingEscapeDisconnect) { - pendingEscapeDisconnect = false; - disconnect(); - return; - } - pendingEscapeDisconnect = true; - updateStatus('Press Escape again to disconnect.'); - audio.sfxUiCancel(); - return; - } + mainModeCommandHandlers[command](); } /** Handles linear help viewer navigation and exit keys. */ @@ -2569,6 +2663,39 @@ function handleHelpViewModeInput(code: string): void { } } +/** Handles command palette list navigation, tooltips, and execution. */ +function handleCommandPaletteModeInput(code: string, key: string): void { + if (commandPaletteCommands.length === 0) { + state.mode = commandPaletteReturnMode; + updateStatus('No commands available.'); + audio.sfxUiCancel(); + return; + } + const control = handleListControlKey(code, key, commandPaletteCommands, commandPaletteIndex, (entry) => formatCommandMenuLabel(entry)); + if (control.type === 'move') { + commandPaletteIndex = control.index; + updateStatus(formatCommandMenuLabel(commandPaletteCommands[commandPaletteIndex])); + audio.sfxUiBlip(); + return; + } + if (code === 'Space') { + const selected = commandPaletteCommands[commandPaletteIndex]; + if (!selected) return; + updateStatus(selected.tooltip || 'No tooltip available.'); + audio.sfxUiBlip(); + return; + } + if (control.type === 'select') { + executeCommandPaletteSelection(); + return; + } + if (control.type === 'cancel') { + state.mode = commandPaletteReturnMode; + updateStatus('Closed commands.'); + audio.sfxUiCancel(); + } +} + /** Handles chat compose mode including submit/cancel and inline editing keys. */ function handleChatModeInput(code: string, key: string, ctrlKey: boolean): void { const editAction = getEditSessionAction(code); @@ -3503,6 +3630,12 @@ function setupInputHandlers(): void { const code = normalizeInputCode(event); if (!code) return; const hasShortcutModifier = event.ctrlKey || event.metaKey; + const input: ModeInput = { + code, + key: event.key, + ctrlKey: hasShortcutModifier, + shiftKey: event.shiftKey, + }; if (!dom.settingsModal.classList.contains('hidden') && code === 'Escape') { closeSettings(); @@ -3548,41 +3681,58 @@ function setupInputHandlers(): void { if (isTypingKey(code) && state.keysPressed[code]) return; + const opensCommandPalette = + canOpenCommandPaletteInMode(state.mode) && + ((code === 'KeyK' && event.shiftKey) || code === 'ContextMenu' || (code === 'F10' && event.shiftKey)); + if (opensCommandPalette) { + if (pendingEscapeDisconnect) { + pendingEscapeDisconnect = false; + } + openCommandPalette(); + state.keysPressed[code] = true; + return; + } + dispatchModeInput({ mode: state.mode, - code, - key: event.key, - ctrlKey: hasShortcutModifier, - shiftKey: event.shiftKey, + input, handlers: { - nickname: handleNicknameModeInput, - chat: handleChatModeInput, - micGainEdit: handleMicGainEditModeInput, - pianoUse: (currentCode) => { - itemBehaviorRegistry.handleModeInput(state.mode, currentCode); + nickname: ({ code: currentCode, key: currentKey, ctrlKey: currentCtrlKey }) => + handleNicknameModeInput(currentCode, currentKey, currentCtrlKey), + chat: ({ code: currentCode, key: currentKey, ctrlKey: currentCtrlKey }) => + handleChatModeInput(currentCode, currentKey, currentCtrlKey), + micGainEdit: ({ code: currentCode, key: currentKey, ctrlKey: currentCtrlKey }) => + handleMicGainEditModeInput(currentCode, currentKey, currentCtrlKey), + pianoUse: (currentInput) => { + itemBehaviorRegistry.handleModeInput(state.mode, currentInput); }, - effectSelect: (currentCode, currentKey) => handleEffectSelectModeInput(currentCode, currentKey), - helpView: (currentCode) => handleHelpViewModeInput(currentCode), - listUsers: (currentCode, currentKey) => handleListModeInput(currentCode, currentKey), - listItems: (currentCode, currentKey) => handleListItemsModeInput(currentCode, currentKey), - addItem: (currentCode, currentKey) => handleAddItemModeInput(currentCode, currentKey), - selectItem: (currentCode, currentKey) => handleSelectItemModeInput(currentCode, currentKey), - itemManageOptions: (currentCode, currentKey) => handleItemManageOptionsModeInput(currentCode, currentKey), - itemManageTransferUser: (currentCode, currentKey) => handleItemManageTransferUserModeInput(currentCode, currentKey), - confirmYesNo: (currentCode, currentKey) => handleConfirmYesNoModeInput(currentCode, currentKey), - adminMenu: (currentCode, currentKey) => handleAdminMenuModeInput(currentCode, currentKey), - adminRoleList: (currentCode, currentKey) => handleAdminRoleListModeInput(currentCode, currentKey), - adminRolePermissionList: (currentCode, currentKey) => handleAdminRolePermissionListModeInput(currentCode, currentKey), - adminRoleDeleteReplacement: (currentCode, currentKey) => handleAdminRoleDeleteReplacementModeInput(currentCode, currentKey), - adminUserList: (currentCode, currentKey) => handleAdminUserListModeInput(currentCode, currentKey), - adminUserRoleSelect: (currentCode, currentKey) => handleAdminUserRoleSelectModeInput(currentCode, currentKey), - adminUserDeleteConfirm: (currentCode, currentKey) => handleAdminUserDeleteConfirmModeInput(currentCode, currentKey), - adminRoleNameEdit: (currentCode, currentKey, currentCtrlKey) => + commandPalette: ({ code: currentCode, key: currentKey }) => handleCommandPaletteModeInput(currentCode, currentKey), + effectSelect: ({ code: currentCode, key: currentKey }) => handleEffectSelectModeInput(currentCode, currentKey), + helpView: ({ code: currentCode }) => handleHelpViewModeInput(currentCode), + listUsers: ({ code: currentCode, key: currentKey }) => handleListModeInput(currentCode, currentKey), + listItems: ({ code: currentCode, key: currentKey }) => handleListItemsModeInput(currentCode, currentKey), + addItem: ({ code: currentCode, key: currentKey }) => handleAddItemModeInput(currentCode, currentKey), + selectItem: ({ code: currentCode, key: currentKey }) => handleSelectItemModeInput(currentCode, currentKey), + itemManageOptions: ({ code: currentCode, key: currentKey }) => handleItemManageOptionsModeInput(currentCode, currentKey), + itemManageTransferUser: ({ code: currentCode, key: currentKey }) => + handleItemManageTransferUserModeInput(currentCode, currentKey), + confirmYesNo: ({ code: currentCode, key: currentKey }) => handleConfirmYesNoModeInput(currentCode, currentKey), + adminMenu: ({ code: currentCode, key: currentKey }) => handleAdminMenuModeInput(currentCode, currentKey), + adminRoleList: ({ code: currentCode, key: currentKey }) => handleAdminRoleListModeInput(currentCode, currentKey), + adminRolePermissionList: ({ code: currentCode, key: currentKey }) => + handleAdminRolePermissionListModeInput(currentCode, currentKey), + adminRoleDeleteReplacement: ({ code: currentCode, key: currentKey }) => + handleAdminRoleDeleteReplacementModeInput(currentCode, currentKey), + adminUserList: ({ code: currentCode, key: currentKey }) => handleAdminUserListModeInput(currentCode, currentKey), + adminUserRoleSelect: ({ code: currentCode, key: currentKey }) => handleAdminUserRoleSelectModeInput(currentCode, currentKey), + adminUserDeleteConfirm: ({ code: currentCode, key: currentKey }) => handleAdminUserDeleteConfirmModeInput(currentCode, currentKey), + adminRoleNameEdit: ({ code: currentCode, key: currentKey, ctrlKey: currentCtrlKey }) => handleAdminRoleNameEditModeInput(currentCode, currentKey, currentCtrlKey), - itemProperties: (currentCode, currentKey) => itemPropertyEditor.handleItemPropertiesModeInput(currentCode, currentKey), - itemPropertyEdit: (currentCode, currentKey, currentCtrlKey) => + itemProperties: ({ code: currentCode, key: currentKey }) => + itemPropertyEditor.handleItemPropertiesModeInput(currentCode, currentKey), + itemPropertyEdit: ({ code: currentCode, key: currentKey, ctrlKey: currentCtrlKey }) => itemPropertyEditor.handleItemPropertyEditModeInput(currentCode, currentKey, currentCtrlKey), - itemPropertyOptionSelect: (currentCode, currentKey) => + itemPropertyOptionSelect: ({ code: currentCode, key: currentKey }) => itemPropertyEditor.handleItemPropertyOptionSelectModeInput(currentCode, currentKey), }, onNormalMode: handleNormalModeInput, @@ -3594,7 +3744,10 @@ function setupInputHandlers(): void { document.addEventListener('keyup', (event) => { const code = normalizeInputCode(event); if (state.mode === 'pianoUse' && code) { - itemBehaviorRegistry.handleModeKeyUp(state.mode, code); + itemBehaviorRegistry.handleModeKeyUp(state.mode, { + code, + shiftKey: event.shiftKey, + }); } if (code) { state.keysPressed[code] = false; diff --git a/client/src/state/gameState.ts b/client/src/state/gameState.ts index c7cd895..e631941 100644 --- a/client/src/state/gameState.ts +++ b/client/src/state/gameState.ts @@ -27,6 +27,7 @@ export type SelectionContext = 'pickup' | 'drop' | 'delete' | 'edit' | 'use' | ' export type GameMode = | 'normal' + | 'commandPalette' | 'helpView' | 'nickname' | 'chat' diff --git a/docs/controls.md b/docs/controls.md index 7a36ff3..5869d7e 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -6,6 +6,7 @@ This document is the authoritative keymap for the client. ### Movement - `Arrow Keys`: Move +- `Shift+K`, `Applications`, or `Shift+F10`: Open the command palette in supported modes - `?`: Open help viewer - `C`: Speak coordinates - `Escape`: Press once for disconnect prompt, press again to disconnect @@ -83,6 +84,17 @@ Applies to effect select, user/item list modes, item selection, item property li - `Space`: Read tooltip/help for current option (where metadata is available) - First-letter navigation: jump to next matching entry +## Command Palette + +- Available in `normal` mode and `pianoUse` mode +- Opens with `Shift+K`, `Applications`, or `Shift+F10` +- Shows only commands available in the current mode/context +- `ArrowUp` / `ArrowDown`: Move selection +- `Enter`: Run selected command +- `Escape`: Close palette and return to prior mode +- `Space`: Read tooltip/help for selected command +- First-letter navigation: jump to next matching command + ## Yes/No Confirmation Menu - `ArrowUp` / `ArrowDown`: Move between `No` and `Yes` @@ -110,6 +122,7 @@ Applies to effect select, user/item list modes, item selection, item property li - `A S D F G H J K L ; '`: Play white keys (C major from C4 upward) - `W E T Y U O P ]`: Play sharps - Multiple keys can be held/played at once +- Shifted note keys are ignored; `Shift+K` opens the command palette instead - `?`: Open piano-mode help viewer - `-` / `=`: Shift octave down/up - `Z`: Start, pause, or resume recording on this piano (max 30s recorded time)