Add context-aware command palette

This commit is contained in:
Jage9
2026-03-08 19:27:23 -04:00
parent 9e41013fe8
commit 1741bcc2bc
14 changed files with 1241 additions and 518 deletions

View File

@@ -0,0 +1,19 @@
export type ModeInput = {
code: string;
key: string;
ctrlKey: boolean;
shiftKey: boolean;
};
export type CommandDescriptor<CommandId extends string = string> = {
id: CommandId;
label: string;
shortcut: string;
tooltip: string;
section: string;
};
/** Formats a palette/menu label as `Name: Key`. */
export function formatCommandMenuLabel(command: Pick<CommandDescriptor, 'label' | 'shortcut'>): string {
return `${command.label}: ${command.shortcut}`;
}

View File

@@ -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;
}

View File

@@ -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<MainModeCommand> & {
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));
}

View File

@@ -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<Record<GameMode, ModeHandler>>;
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);
}