From 351ed03bf8624ef5b19af67a92019ebc1fbd57ec Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 8 Jun 2026 14:57:24 +0200 Subject: [PATCH] feat: add editable keyboard shortcuts --- .changeset/editable-keyboard-shortcuts.md | 5 + src/client/src/components/PiWebApp.ts | 13 +- src/client/src/components/SettingsDialog.ts | 13 +- .../settings/SettingsShortcutsPanel.ts | 360 ++++++++++++++++-- src/client/src/keyboardShortcuts.test.ts | 113 +++++- src/client/src/keyboardShortcuts.ts | 347 +++++++++++++++-- src/client/src/shortcutPreferences.test.ts | 22 +- src/client/src/shortcutPreferences.ts | 8 + 8 files changed, 800 insertions(+), 81 deletions(-) create mode 100644 .changeset/editable-keyboard-shortcuts.md diff --git a/.changeset/editable-keyboard-shortcuts.md b/.changeset/editable-keyboard-shortcuts.md new file mode 100644 index 0000000..c499af1 --- /dev/null +++ b/.changeset/editable-keyboard-shortcuts.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add a keyboard shortcuts settings editor with manual entry, recording, disabling, reset-to-default controls, and conflict/shadowing indicators. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index c619965..e842917 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -31,7 +31,7 @@ import { NavigationSectionsController, type NavigationSection } from "../appShel import { PanelCollapseController, mainViewClass } from "../appShell/panelCollapseController"; import { readRoute, writeRoute, type AppRoute } from "../route"; import { readSettingsSection, writeSettingsSection, type SettingsSection } from "../settingsRoute"; -import { applyShortcutPreferences } from "../shortcutPreferences"; +import { applyActiveShortcutPreferences } from "../shortcutPreferences"; import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime"; import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionRunFilter } from "../workspaceDeletion"; import "./MachineList"; @@ -184,7 +184,8 @@ export class PiWebApp extends LitElement { } private readonly onKeyDown = (event: KeyboardEvent) => { - if (this.keyboard.handle(event, this.getActions())) { + if (this.settingsSection !== undefined) return; + if (this.keyboard.handle(event, this.getDefaultActions(), { shortcuts: this.shortcutConfig })) { event.preventDefault(); event.stopPropagation(); } @@ -994,7 +995,11 @@ export class PiWebApp extends LitElement { } private getActions(): AppAction[] { - return applyShortcutPreferences([...this.plugins.getActions(this.createPluginRuntimeContext()), ...this.navigationFocusActions()], this.shortcutConfig); + return applyActiveShortcutPreferences(this.getDefaultActions(), this.shortcutConfig); + } + + private getDefaultActions(): AppAction[] { + return [...this.plugins.getActions(this.createPluginRuntimeContext()), ...this.navigationFocusActions()]; } private navigationFocusActions(): AppAction[] { @@ -1457,7 +1462,7 @@ export class PiWebApp extends LitElement { ${state.projectDialogOpen ? html` this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}>` : null} ${state.machineDialogOpen ? html` this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}>` : null} ${state.themeDialog !== undefined ? html` { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}>` : null} - ${this.settingsSection !== undefined ? html` { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}>` : null} + ${this.settingsSection !== undefined ? html` { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}>` : null} `; } diff --git a/src/client/src/components/SettingsDialog.ts b/src/client/src/components/SettingsDialog.ts index 3f601f7..7db4667 100644 --- a/src/client/src/components/SettingsDialog.ts +++ b/src/client/src/components/SettingsDialog.ts @@ -61,7 +61,18 @@ export class SettingsDialog extends LitElement { private renderActiveSection(): TemplateResult { if (this.section === "shortcuts") { - return html``; + return html` + this.loadConfig()} + .onSave=${(config: PiWebConfigValues) => this.saveConfig(config)} + > + `; } if (this.section === "plugins") { return html` diff --git a/src/client/src/components/settings/SettingsShortcutsPanel.ts b/src/client/src/components/settings/SettingsShortcutsPanel.ts index 528a9fc..34db3f0 100644 --- a/src/client/src/components/settings/SettingsShortcutsPanel.ts +++ b/src/client/src/components/settings/SettingsShortcutsPanel.ts @@ -1,55 +1,283 @@ -import { css, html, LitElement, type TemplateResult } from "lit"; -import { customElement, property } from "lit/decorators.js"; +import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; import type { AppAction } from "../../actions"; -import type { PiWebConfigResponse, PiWebShortcutConfig } from "../../api"; -import { formatShortcut } from "../../keyboardShortcuts"; +import type { PiWebConfigResponse, PiWebConfigValues, PiWebShortcutConfig } from "../../api"; +import { formatShortcut, isShortcutSequenceStarter, parseShortcutInput, resolveShortcutBindings, shortcutSequenceTimeoutMs, shortcutTokenFromEvent, type ShortcutBindingResolution } from "../../keyboardShortcuts"; + +const RECORD_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const; @customElement("settings-shortcuts-panel") export class SettingsShortcutsPanel extends LitElement { @property({ attribute: false }) actions: AppAction[] = []; @property({ attribute: false }) configResponse: PiWebConfigResponse | undefined; + @property({ type: Boolean }) loading = false; + @property({ type: Boolean }) saving = false; + @property() error = ""; + @property() savedMessage = ""; + @property({ attribute: false }) onReload?: () => void | Promise; + @property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise; + @state() private drafts: Record = {}; + @state() private localError = ""; + @state() private recording: RecordingState | undefined; + private recordingTimer: number | undefined; + private recordingListenerActive = false; + + private readonly onRecordKeyDown = (event: KeyboardEvent): void => { + const recording = this.recording; + if (recording === undefined) return; + event.preventDefault(); + event.stopPropagation(); + + if (event.key === "Escape") { + this.stopRecording(); + return; + } + + const token = shortcutTokenFromEvent(event); + if (token === undefined) { + this.localError = "Press a letter, number, punctuation, function, or navigation key. Press Esc to cancel recording."; + return; + } + if (recording.tokens.length === 0 && !isShortcutSequenceStarter(token)) { + this.localError = "Start shortcuts with Ctrl/⌘ or Alt so normal typing is not captured."; + return; + } + + const tokens = [...recording.tokens, token]; + this.localError = ""; + this.drafts = { [recording.actionId]: tokens.join(" ") }; + this.recording = { actionId: recording.actionId, tokens }; + this.armRecordingTimer(); + }; + + protected override willUpdate(changed: PropertyValues): void { + if (changed.has("configResponse") && this.configResponse !== undefined) { + this.drafts = {}; + this.localError = ""; + this.stopRecording(); + } + } + + override disconnectedCallback(): void { + this.stopRecording(); + super.disconnectedCallback(); + } override render(): TemplateResult { const groups = shortcutGroups(this.actions); + const shortcutResolutions = this.shortcutResolutions(); return html`

Keyboard shortcuts

-

Review registered app actions and the shortcut config that will become editable here. Manual config entries use action ids and can override a default shortcut or set it to null to disable it.

+

Edit app shortcuts by action. Type a shortcut such as mod+k or mod+g p, record one from the keyboard, disable it with None, or reset it to the default. When shortcuts conflict, custom shortcuts win before defaults; ties are resolved by action id, and shorter shortcuts shadow longer sequences with the same prefix.

+
-
Config key: shortcuts. Example: { "core:view.chat": "mod+1", "core:session.stop": null }
- ${groups.length === 0 ? html`
No actions registered.
` : groups.map((group) => html` -
-

${group.name}

-
- ${group.actions.map((action) => this.renderShortcutRow(action))} -
-
- `)} + ${this.renderMessages()} + ${this.configResponse === undefined && this.loading ? html`
Loading shortcuts…
` : html` +
+ Config file + ${this.configResponse?.path ?? "Unknown"} + Shortcut overrides are saved under shortcuts. A value of null disables the action shortcut. +
+ ${groups.length === 0 ? html`
No actions registered.
` : groups.map((group) => html` +
+

${group.name}

+
+ ${group.actions.map((action) => this.renderShortcutRow(action, shortcutResolutions.get(action.id)))} +
+
+ `)} + `} `; } - private renderShortcutRow(action: AppAction): TemplateResult { + private renderMessages(): TemplateResult | null { + const error = this.localError || this.error; + if (error !== "") return html`
${error}
`; + if (this.savedMessage !== "") return html`
${this.savedMessage}
`; + return null; + } + + private renderShortcutRow(action: AppAction, resolution: ShortcutBindingResolution | undefined): TemplateResult { const shortcuts = this.configResponse?.config.shortcuts; const configured = shortcutPreference(action.id, shortcuts); - const shortcut = configured === null ? undefined : configured ?? action.shortcut; const state = shortcutState(action, shortcuts); + const inputText = this.shortcutInputText(action); + const parsedInput = inputText.trim() === "" ? undefined : parseShortcutInput(inputText); + const previewShortcut = parsedInput?.ok === true ? parsedInput.shortcut : effectiveShortcut(action, shortcuts); + const hasConfiguredShortcut = configured !== undefined; + const hasDraft = this.drafts[action.id] !== undefined; + const displayState = hasDraft && inputText.trim() !== "" ? "custom" : state; + const recordingHint = this.recordingHint(action.id); + const conflictLabel = shortcutConflictLabel(resolution); return html` -
+
${action.title} ${action.description !== undefined && action.description !== "" ? html`${action.description}` : null} ${action.id} + ${action.shortcut !== undefined && action.shortcut !== "" ? html`Default: ${formatShortcut(action.shortcut)}` : "No default shortcut"}
-
- ${shortcut !== undefined && shortcut !== "" ? html`${formatShortcut(shortcut)}` : html`${state === "disabled" ? "Disabled" : "Unassigned"}`} - ${shortcutStateLabel(state)} +
+
+ ${previewShortcut !== undefined && previewShortcut !== "" ? html`${formatShortcut(previewShortcut)}` : html`${state === "disabled" ? "Disabled" : "Unassigned"}`} + ${shortcutStateLabel(displayState)}${hasDraft ? " · Unsaved" : ""} + ${conflictLabel === undefined ? null : html`${conflictLabel}`} +
+ + ${recordingHint !== "" ? html`${recordingHint}` : null} +
+ + + + +
-
+
`; } + private shortcutInputText(action: AppAction): string { + const draft = this.drafts[action.id]; + if (draft !== undefined) return draft; + const configured = shortcutPreference(action.id, this.configResponse?.config.shortcuts); + if (configured === null) return ""; + return configured ?? action.shortcut ?? ""; + } + + private recordingHint(actionId: string): string { + const recording = this.recording; + if (recording?.actionId !== actionId) return ""; + if (recording.tokens.length === 0) return "Recording: press Ctrl/⌘ or Alt with a key. Press Esc to cancel."; + return `Recording: ${formatShortcut(recording.tokens.join(" "))}. Press another key to add a sequence, or wait to finish.`; + } + + private updateDraft(actionId: string, value: string): void { + this.drafts = { [actionId]: value }; + this.localError = ""; + } + + private async saveShortcut(action: AppAction): Promise { + this.stopRecording(); + const input = this.shortcutInputText(action).trim(); + const parsed = parseShortcutInput(input); + if (!parsed.ok) { + this.localError = parsed.message; + return; + } + this.localError = ""; + await this.saveShortcutPreference(action.id, parsed.shortcut); + } + + private async setShortcutNone(actionId: string): Promise { + this.stopRecording(); + this.localError = ""; + await this.saveShortcutPreference(actionId, null); + } + + private async resetShortcut(actionId: string): Promise { + this.stopRecording(); + this.localError = ""; + await this.saveShortcutPreference(actionId, undefined); + } + + private async saveShortcutPreference(actionId: string, shortcut: string | null | undefined): Promise { + const config: PiWebConfigValues = { ...(this.configResponse?.config ?? {}) }; + const currentShortcuts = config.shortcuts ?? {}; + const shortcuts = shortcut === undefined ? withoutShortcutPreference(currentShortcuts, actionId) : { ...currentShortcuts, [actionId]: shortcut }; + if (Object.keys(shortcuts).length === 0) { + delete config.shortcuts; + } else { + config.shortcuts = shortcuts; + } + await this.onSave?.(config); + } + + private shortcutResolutions(): Map { + return new Map(resolveShortcutBindings(this.actions, this.previewShortcutConfig(), { enabledOnly: true }).map((resolution) => [resolution.action.id, resolution])); + } + + private previewShortcutConfig(): PiWebShortcutConfig | undefined { + const shortcuts = { ...(this.configResponse?.config.shortcuts ?? {}) }; + for (const [actionId, draft] of Object.entries(this.drafts)) { + const trimmedDraft = draft.trim(); + if (trimmedDraft === "") continue; + const parsed = parseShortcutInput(trimmedDraft); + if (parsed.ok) shortcuts[actionId] = parsed.shortcut; + } + return Object.keys(shortcuts).length === 0 ? undefined : shortcuts; + } + + private async toggleRecording(actionId: string): Promise { + if (this.recording?.actionId === actionId) { + this.stopRecording(); + return; + } + this.stopRecording(); + this.localError = ""; + this.recording = { actionId, tokens: [] }; + this.ensureRecordingListener(); + await this.updateComplete; + this.focusShortcutInput(actionId); + } + + private focusShortcutInput(actionId: string): void { + for (const input of this.renderRoot.querySelectorAll(".shortcut-input")) { + if (input.dataset["actionId"] === actionId) { + input.focus(); + input.select(); + return; + } + } + } + + private armRecordingTimer(): void { + this.clearRecordingTimer(); + this.recordingTimer = window.setTimeout(() => { + this.recordingTimer = undefined; + this.stopRecording(); + }, shortcutSequenceTimeoutMs); + } + + private stopRecording(): void { + this.clearRecordingTimer(); + this.removeRecordingListener(); + this.recording = undefined; + } + + private clearRecordingTimer(): void { + if (this.recordingTimer === undefined) return; + window.clearTimeout(this.recordingTimer); + this.recordingTimer = undefined; + } + + private ensureRecordingListener(): void { + if (this.recordingListenerActive) return; + window.addEventListener("keydown", this.onRecordKeyDown, RECORD_SHORTCUT_LISTENER_OPTIONS); + this.recordingListenerActive = true; + } + + private removeRecordingListener(): void { + if (!this.recordingListenerActive) return; + window.removeEventListener("keydown", this.onRecordKeyDown, RECORD_SHORTCUT_LISTENER_OPTIONS); + this.recordingListenerActive = false; + } + static override styles = css` :host { display: block; } .section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; } @@ -58,37 +286,83 @@ export class SettingsShortcutsPanel extends LitElement { h2 { font-size: 17px; line-height: 1.25; } h3 { font-size: 13px; line-height: 1.3; } p { color: var(--pi-muted); line-height: 1.45; } + button, input { font: inherit; } + button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; } + button:disabled, input:disabled { opacity: .55; cursor: not-allowed; } + .primary { border-color: var(--pi-accent); background: var(--pi-selection-bg); color: var(--pi-text-bright); } + .secondary { flex: 0 0 auto; } + .message, .loading-card, .config-path-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; } + .message { margin-bottom: 12px; } + .error-message { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); } + .success-message { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); } + .loading-card, .config-path-card { color: var(--pi-muted); } + .config-path-card { display: grid; gap: 5px; margin-bottom: 14px; } + .config-path-card span { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; } code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; } - .loading-card, .shortcut-note { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; } - .loading-card, .shortcut-note { color: var(--pi-muted); } - .shortcut-note { margin-bottom: 14px; } .shortcut-group { margin: 0 0 16px; } .shortcut-group h3 { margin: 0 0 8px; color: var(--pi-muted); font-size: 12px; text-transform: uppercase; } .shortcut-list { border: 1px solid var(--pi-border); border-radius: 10px; overflow: hidden; } - .shortcut-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-surface); } + .shortcut-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(360px, 48%); gap: 14px; align-items: start; padding: 12px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-surface); } + .shortcut-row.shadowed { background: color-mix(in srgb, var(--pi-warning) 5%, var(--pi-surface)); } + .shortcut-row.shadowing { background: color-mix(in srgb, var(--pi-accent) 5%, var(--pi-surface)); } .shortcut-row:last-child { border-bottom: 0; } - .shortcut-main { min-width: 0; display: grid; gap: 3px; } + .shortcut-main { min-width: 0; display: grid; gap: 4px; } .shortcut-main strong, .shortcut-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .shortcut-main small { color: var(--pi-muted); } .shortcut-id { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } - .shortcut-value { justify-self: end; display: grid; justify-items: end; gap: 3px; } - kbd { justify-self: end; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-bg); color: var(--pi-text-secondary); padding: 3px 7px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; } - .unassigned { justify-self: end; color: var(--pi-muted); font-size: 12px; } - .shortcut-value small { color: var(--pi-muted); font-size: 11px; } - .shortcut-value small.custom { color: var(--pi-accent); } - .shortcut-value small.disabled { color: var(--pi-warning); } + .shortcut-editor { min-width: 0; display: grid; gap: 8px; } + .shortcut-status { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; } + .shortcut-status small { color: var(--pi-muted); font-size: 11px; } + .shortcut-status small.custom { color: var(--pi-accent); } + .shortcut-status small.disabled { color: var(--pi-warning); } + .shortcut-status small.conflict { border: 1px solid currentColor; border-radius: 999px; padding: 2px 7px; } + .shortcut-status small.conflict.shadowing { color: var(--pi-accent); } + .shortcut-status small.conflict.shadowed { color: var(--pi-warning); } + .shortcut-input-label { min-width: 0; display: grid; gap: 5px; } + .shortcut-input-label span { color: var(--pi-muted); font-size: 11px; font-weight: 700; text-transform: uppercase; } + input { box-sizing: border-box; width: 100%; min-width: 0; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 8px 9px; outline: none; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + input:focus { border-color: var(--pi-accent); box-shadow: 0 0 0 1px var(--pi-accent-border); } + .shortcut-actions { display: flex; justify-content: flex-end; gap: 7px; flex-wrap: wrap; } + kbd { border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-bg); color: var(--pi-text-secondary); padding: 3px 7px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; } + .unassigned { color: var(--pi-muted); font-size: 12px; } + .recording-hint { color: var(--pi-accent); font-size: 12px; } @media (max-width: 760px) { .section-heading { display: grid; gap: 12px; } + .section-heading .secondary { justify-self: start; } .shortcut-row { grid-template-columns: minmax(0, 1fr); align-items: start; } - .shortcut-value { justify-self: start; justify-items: start; } - kbd, .unassigned { justify-self: start; } + .shortcut-status, .shortcut-actions { justify-content: flex-start; } } `; } +interface RecordingState { + actionId: string; + tokens: string[]; +} + type ShortcutState = "default" | "custom" | "disabled" | "unassigned"; +function shortcutRowClass(resolution: ShortcutBindingResolution | undefined): string { + if (resolution?.active === false) return "shortcut-row shadowed"; + if (resolution?.active === true && resolution.shadows.length > 0) return "shortcut-row shadowing"; + return "shortcut-row"; +} + +function shortcutConflictClass(resolution: ShortcutBindingResolution | undefined): string { + return resolution?.active === false ? "conflict shadowed" : "conflict shadowing"; +} + +function shortcutConflictLabel(resolution: ShortcutBindingResolution | undefined): string | undefined { + if (resolution === undefined) return undefined; + if (!resolution.active) return `Shadowed by ${resolution.shadowedBy?.action.title ?? "another action"}`; + const shadowedCount = resolution.shadows.length; + if (shadowedCount === 0) return undefined; + const shadowedNames = resolution.shadows.slice(0, 2).map((binding) => binding.action.title).join(", "); + const suffix = shadowedCount > 2 ? `, +${String(shadowedCount - 2)} more` : ""; + return `Shadows ${String(shadowedCount)} ${shadowedCount === 1 ? "action" : "actions"}: ${shadowedNames}${suffix}`; +} + function shortcutGroups(actions: AppAction[]): { name: string; actions: AppAction[] }[] { const grouped = new Map(); for (const action of [...actions].sort(compareActions)) { @@ -107,6 +381,16 @@ function shortcutPreference(actionId: string, shortcuts: PiWebShortcutConfig | u return shortcuts[actionId]; } +function withoutShortcutPreference(shortcuts: PiWebShortcutConfig, actionId: string): PiWebShortcutConfig { + return Object.fromEntries(Object.entries(shortcuts).filter(([shortcutActionId]) => shortcutActionId !== actionId)); +} + +function effectiveShortcut(action: AppAction, shortcuts: PiWebShortcutConfig | undefined): string | undefined { + const configured = shortcutPreference(action.id, shortcuts); + if (configured === null) return undefined; + return configured ?? action.shortcut; +} + function shortcutState(action: AppAction, shortcuts: PiWebShortcutConfig | undefined): ShortcutState { const configured = shortcutPreference(action.id, shortcuts); if (configured === null) return "disabled"; @@ -117,8 +401,12 @@ function shortcutState(action: AppAction, shortcuts: PiWebShortcutConfig | undef function shortcutStateLabel(state: ShortcutState): string { switch (state) { case "default": return "Default"; - case "custom": return "Config override"; - case "disabled": return "Config disabled"; + case "custom": return "Custom"; + case "disabled": return "Disabled"; case "unassigned": return "No default"; } } + +function inputValue(event: Event): string { + return event.target instanceof HTMLInputElement ? event.target.value : ""; +} diff --git a/src/client/src/keyboardShortcuts.test.ts b/src/client/src/keyboardShortcuts.test.ts index 348b543..804da85 100644 --- a/src/client/src/keyboardShortcuts.test.ts +++ b/src/client/src/keyboardShortcuts.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { AppAction } from "./actions"; -import { KeyboardShortcutDispatcher, type ShortcutKeyEvent } from "./keyboardShortcuts"; +import { KeyboardShortcutDispatcher, parseShortcutInput, resolveShortcutBindings, shortcutTokenFromEvent, type ShortcutKeyEvent } from "./keyboardShortcuts"; function keyEvent(key: string, modifiers: Partial = {}): ShortcutKeyEvent { return { @@ -16,10 +16,14 @@ function keyEvent(key: string, modifiers: Partial = {}): Short } function action(shortcut: string, enabled = true) { + return actionWithId(shortcut, shortcut, enabled); +} + +function actionWithId(id: string, shortcut: string, enabled = true) { const run = vi.fn(); const value: AppAction = { - id: shortcut, - title: shortcut, + id, + title: id, shortcut, enabled, run, @@ -48,6 +52,26 @@ describe("KeyboardShortcutDispatcher", () => { expect(run).not.toHaveBeenCalled(); }); + it("matches manually typed Ctrl shortcuts as the cross-platform Mod modifier", () => { + const dispatcher = new KeyboardShortcutDispatcher(); + const { value, run } = action("ctrl+k"); + + const handled = dispatcher.handle(keyEvent("k", { ctrlKey: true }), [value]); + + expect(handled).toBe(true); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("ignores shift-only shortcuts so capitalized typing is never captured", () => { + const dispatcher = new KeyboardShortcutDispatcher(); + const { value, run } = action("shift+r"); + + const handled = dispatcher.handle(keyEvent("r", { shiftKey: true }), [value]); + + expect(handled).toBe(false); + expect(run).not.toHaveBeenCalled(); + }); + it("ignores disabled matching shortcuts", () => { const dispatcher = new KeyboardShortcutDispatcher(); const { value, run } = action("mod+enter", false); @@ -77,6 +101,42 @@ describe("KeyboardShortcutDispatcher", () => { expect(run).toHaveBeenCalledTimes(1); }); + it("deterministically runs the lowest action id when default shortcuts conflict", () => { + const dispatcher = new KeyboardShortcutDispatcher(); + const later = actionWithId("plugin:z", "mod+k"); + const earlier = actionWithId("plugin:a", "mod+k"); + + const handled = dispatcher.handle(keyEvent("k", { ctrlKey: true }), [later.value, earlier.value]); + + expect(handled).toBe(true); + expect(earlier.run).toHaveBeenCalledTimes(1); + expect(later.run).not.toHaveBeenCalled(); + }); + + it("runs custom shortcut winners before default shortcut conflicts", () => { + const dispatcher = new KeyboardShortcutDispatcher(); + const defaultAction = actionWithId("plugin:a", "mod+j"); + const customAction = actionWithId("plugin:z", "mod+k"); + + const handled = dispatcher.handle(keyEvent("j", { ctrlKey: true }), [defaultAction.value, customAction.value], { shortcuts: { "plugin:z": "mod+j" } }); + + expect(handled).toBe(true); + expect(customAction.run).toHaveBeenCalledTimes(1); + expect(defaultAction.run).not.toHaveBeenCalled(); + }); + + it("uses the same shadowing rules when a standalone shortcut is a sequence prefix", () => { + const dispatcher = new KeyboardShortcutDispatcher(); + const standalone = actionWithId("plugin:standalone", "mod+g"); + const sequence = actionWithId("plugin:sequence", "mod+g p"); + + const handled = dispatcher.handle(keyEvent("g", { ctrlKey: true }), [standalone.value, sequence.value]); + + expect(handled).toBe(true); + expect(standalone.run).toHaveBeenCalledTimes(1); + expect(sequence.run).not.toHaveBeenCalled(); + }); + it("falls back to a standalone modified shortcut when a pending sequence misses", () => { const dispatcher = new KeyboardShortcutDispatcher(); const sequence = action("mod+g p"); @@ -88,3 +148,50 @@ describe("KeyboardShortcutDispatcher", () => { expect(standalone.run).toHaveBeenCalledTimes(1); }); }); + +describe("shortcut conflict resolution", () => { + it("reports which duplicate bindings shadow and which are shadowed", () => { + const defaultAction = actionWithId("plugin:a", "mod+k"); + const customAction = actionWithId("plugin:z", "mod+j"); + + const resolutions = resolveShortcutBindings([defaultAction.value, customAction.value], { "plugin:z": "mod+k" }); + const defaultResolution = resolutions.find((resolution) => resolution.action.id === "plugin:a"); + const customResolution = resolutions.find((resolution) => resolution.action.id === "plugin:z"); + + expect(customResolution?.active).toBe(true); + expect(customResolution?.shadows.map((binding) => binding.action.id)).toEqual(["plugin:a"]); + expect(defaultResolution?.active).toBe(false); + expect(defaultResolution?.shadowedBy?.action.id).toBe("plugin:z"); + }); + + it("reports sequence bindings shadowed by shorter shortcut prefixes", () => { + const standalone = actionWithId("plugin:standalone", "mod+g"); + const sequence = actionWithId("plugin:sequence", "mod+g p"); + + const resolutions = resolveShortcutBindings([sequence.value, standalone.value]); + const standaloneResolution = resolutions.find((resolution) => resolution.action.id === "plugin:standalone"); + const sequenceResolution = resolutions.find((resolution) => resolution.action.id === "plugin:sequence"); + + expect(standaloneResolution?.active).toBe(true); + expect(standaloneResolution?.shadows.map((binding) => binding.action.id)).toEqual(["plugin:sequence"]); + expect(sequenceResolution?.active).toBe(false); + expect(sequenceResolution?.shadowedBy?.action.id).toBe("plugin:standalone"); + }); +}); + +describe("shortcut input parsing", () => { + it("normalizes manually typed shortcuts", () => { + expect(parseShortcutInput("Ctrl + Shift + K")).toEqual({ ok: true, shortcut: "mod+shift+k", tokens: ["mod+shift+k"] }); + expect(parseShortcutInput("cmd+g p")).toEqual({ ok: true, shortcut: "mod+g p", tokens: ["mod+g", "p"] }); + }); + + it("rejects shortcuts that would capture normal typing", () => { + expect(parseShortcutInput("r")).toEqual({ ok: false, message: "Shortcuts must start with Ctrl/⌘ or Alt so normal typing is not captured." }); + expect(parseShortcutInput("shift+r")).toEqual({ ok: false, message: "Shortcuts must start with Ctrl/⌘ or Alt so normal typing is not captured." }); + }); + + it("builds canonical tokens from recorded key events", () => { + expect(shortcutTokenFromEvent(keyEvent("K", { metaKey: true, shiftKey: true }))).toBe("mod+shift+k"); + expect(shortcutTokenFromEvent(keyEvent("ArrowDown", { altKey: true }))).toBe("alt+arrowdown"); + }); +}); diff --git a/src/client/src/keyboardShortcuts.ts b/src/client/src/keyboardShortcuts.ts index 1c28045..ac207c3 100644 --- a/src/client/src/keyboardShortcuts.ts +++ b/src/client/src/keyboardShortcuts.ts @@ -1,6 +1,12 @@ import type { AppAction } from "./actions"; -const sequenceTimeoutMs = 1200; +export const shortcutSequenceTimeoutMs = 1200; + +const modifierOrder = ["mod", "alt", "shift"] as const; +type ShortcutModifier = typeof modifierOrder[number]; + +export type ShortcutPreferenceConfig = Record; +export type ShortcutBindingSource = "default" | "custom"; export interface ShortcutKeyEvent { key: string; @@ -12,25 +18,49 @@ export interface ShortcutKeyEvent { target: EventTarget | null; } +export type ShortcutParseResult = + | { ok: true; shortcut: string; tokens: string[] } + | { ok: false; message: string }; + +export interface ShortcutBindingSummary { + action: AppAction; + shortcut: string; + source: ShortcutBindingSource; +} + +export interface ShortcutBindingResolution extends ShortcutBindingSummary { + tokens: string[]; + key: string; + order: number; + active: boolean; + shadows: ShortcutBindingSummary[]; + shadowedBy?: ShortcutBindingSummary; +} + +interface ShortcutBinding extends ShortcutBindingSummary { + tokens: string[]; + key: string; + order: number; +} + export class KeyboardShortcutDispatcher { private pendingTokens: string[] = []; private pendingTimer: ReturnType | undefined; - handle(event: ShortcutKeyEvent, actions: AppAction[]): boolean { - const token = eventToken(event); + handle(event: ShortcutKeyEvent, actions: AppAction[], options: { shortcuts?: ShortcutPreferenceConfig } = {}): boolean { + const token = shortcutTokenFromEvent(event); if (token === undefined) return false; - const shortcuts = actions - .filter((action) => action.shortcut !== undefined && action.enabled !== false) - .map((action) => ({ action, tokens: normalizeShortcut(action.shortcut ?? "") })) - .filter((entry) => entry.tokens.length > 0); + const shortcuts = resolveShortcutBindings(actions, options.shortcuts, { enabledOnly: true }) + .filter((binding) => binding.active) + .map((binding) => ({ action: binding.action, tokens: binding.tokens })); if (this.pendingTokens.length > 0) { const handledPending = this.handleSequence([...this.pendingTokens, token], shortcuts); if (handledPending) return true; this.clearPending(); - if (!isModifiedShortcut(token)) return false; - } else if (!isModifiedShortcut(token)) return false; + if (!isShortcutSequenceStarter(token)) return false; + } else if (!isShortcutSequenceStarter(token)) return false; return this.handleSequence([token], shortcuts); } @@ -62,7 +92,7 @@ export class KeyboardShortcutDispatcher { this.pendingTimer = globalThis.setTimeout(() => { this.pendingTokens = []; this.pendingTimer = undefined; - }, sequenceTimeoutMs); + }, shortcutSequenceTimeoutMs); } private clearPending(): void { @@ -74,25 +104,67 @@ export class KeyboardShortcutDispatcher { } } +export function resolveShortcutBindings(actions: AppAction[], shortcuts?: ShortcutPreferenceConfig, options: { enabledOnly?: boolean } = {}): ShortcutBindingResolution[] { + const bindings = actions.flatMap((action, order) => { + if (options.enabledOnly === true && action.enabled === false) return []; + const binding = shortcutBindingForAction(action, shortcuts, order); + return binding === undefined ? [] : [binding]; + }); + const bindingsByKey = new Map(); + for (const binding of bindings) { + bindingsByKey.set(binding.key, [...(bindingsByKey.get(binding.key) ?? []), binding]); + } + + const exactWinnersByKey = new Map(); + for (const conflictSet of bindingsByKey.values()) { + const winner = [...conflictSet].sort(compareShortcutBindings)[0]; + if (winner !== undefined) exactWinnersByKey.set(winner.key, winner); + } + + const exactWinners = [...exactWinnersByKey.values()].sort(compareShortcutPrefixCandidates); + const shadowsByWinner = new Map(); + const winnerByBinding = new Map(); + for (const binding of bindings) { + const exactWinner = exactWinnersByKey.get(binding.key); + if (exactWinner === undefined) continue; + const winner = prefixWinnerFor(exactWinner, exactWinners) ?? exactWinner; + winnerByBinding.set(binding, winner); + if (binding !== winner) shadowsByWinner.set(winner, [...(shadowsByWinner.get(winner) ?? []), binding]); + } + + return bindings.map((binding) => { + const winner = winnerByBinding.get(binding); + const active = winner === binding; + const shadowedBy = winner === undefined || active ? undefined : shortcutBindingSummary(winner); + const shadows = active ? [...(shadowsByWinner.get(binding) ?? [])].sort(compareShortcutBindings).map(shortcutBindingSummary) : []; + return { + ...binding, + active, + shadows, + ...(shadowedBy === undefined ? {} : { shadowedBy }), + }; + }).sort((left, right) => left.order - right.order); +} + +export function parseShortcutInput(shortcut: string): ShortcutParseResult { + return parseShortcut(shortcut, { requireFirstChordActivator: true }); +} + +export function normalizeShortcut(shortcut: string): string[] { + const parsed = parseShortcut(shortcut, { requireFirstChordActivator: false }); + return parsed.ok ? parsed.tokens : []; +} + export function formatShortcut(shortcut: string): string { return normalizeShortcut(shortcut) .map((token) => token .split("+") - .map((part) => { - if (part === "mod") return isMac() ? "⌘" : "Ctrl"; - if (part === "shift") return "Shift"; - if (part === "alt") return isMac() ? "⌥" : "Alt"; - if (part === "ctrl") return "Ctrl"; - if (part === "enter") return "Enter"; - if (part === "escape") return "Esc"; - if (part === ".") return "."; - return part.length === 1 ? part.toUpperCase() : `${part.charAt(0).toUpperCase()}${part.slice(1)}`; - }) + .map(formatShortcutPart) .join("+")) .join(" "); } -function eventToken(event: ShortcutKeyEvent): string | undefined { +export function shortcutTokenFromEvent(event: ShortcutKeyEvent): string | undefined { if (event.isComposing) return undefined; const key = normalizeKey(event.key); if (key === undefined) return undefined; @@ -104,21 +176,228 @@ function eventToken(event: ShortcutKeyEvent): string | undefined { return modifiers.join("+"); } -function normalizeShortcut(shortcut: string): string[] { - return shortcut - .trim() - .toLowerCase() - .split(/\s+/u) - .filter((token) => token !== "") - .map((token) => token.split("+").filter((part) => part !== "").join("+")); +export function isShortcutSequenceStarter(token: string): boolean { + return token.split("+").includes("mod") || token.split("+").includes("alt"); +} + +function shortcutBindingForAction(action: AppAction, shortcuts: ShortcutPreferenceConfig | undefined, order: number): ShortcutBinding | undefined { + const configured = shortcutPreference(action.id, shortcuts); + if (configured === null) return undefined; + const shortcut = configured ?? action.shortcut; + if (shortcut === undefined || shortcut === "") return undefined; + const tokens = normalizeShortcut(shortcut); + const firstToken = tokens[0]; + if (firstToken === undefined || !isShortcutSequenceStarter(firstToken)) return undefined; + return { + action, + shortcut: tokens.join(" "), + source: configured === undefined ? "default" : "custom", + tokens, + key: shortcutBindingKey(tokens), + order, + }; +} + +function shortcutPreference(actionId: string, shortcuts: ShortcutPreferenceConfig | undefined): string | null | undefined { + if (shortcuts === undefined || !Object.hasOwn(shortcuts, actionId)) return undefined; + return shortcuts[actionId]; +} + +function shortcutBindingKey(tokens: string[]): string { + return tokens.join("\u0000"); +} + +function shortcutBindingSummary(binding: ShortcutBinding): ShortcutBindingSummary { + return { action: binding.action, shortcut: binding.shortcut, source: binding.source }; +} + +function compareShortcutBindings(left: ShortcutBinding, right: ShortcutBinding): number { + return shortcutSourceRank(left.source) - shortcutSourceRank(right.source) + || compareStrings(left.action.id, right.action.id) + || compareStrings(left.action.title, right.action.title) + || left.order - right.order; +} + +function compareShortcutPrefixCandidates(left: ShortcutBinding, right: ShortcutBinding): number { + return left.tokens.length - right.tokens.length || compareShortcutBindings(left, right); +} + +function prefixWinnerFor(binding: ShortcutBinding, exactWinners: ShortcutBinding[]): ShortcutBinding | undefined { + return exactWinners.find((candidate) => candidate !== binding && candidate.tokens.length < binding.tokens.length && startsWithTokens(binding.tokens, candidate.tokens)); +} + +function shortcutSourceRank(source: ShortcutBindingSource): number { + switch (source) { + case "custom": return 0; + case "default": return 1; + } +} + +function compareStrings(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +function parseShortcut(shortcut: string, options: { requireFirstChordActivator: boolean }): ShortcutParseResult { + const cleaned = shortcut.trim().toLowerCase().replace(/\s*\+\s*/gu, "+"); + if (cleaned === "") return { ok: false, message: "Enter a shortcut, choose None, or reset to the default." }; + + const tokens: string[] = []; + const chordInputs = cleaned.split(/\s+/u).filter((token) => token !== ""); + for (const [index, chordInput] of chordInputs.entries()) { + const parsed = parseShortcutChord(chordInput); + if (!parsed.ok) return parsed; + if (index === 0 && options.requireFirstChordActivator && !isShortcutSequenceStarter(parsed.token)) { + return { ok: false, message: "Shortcuts must start with Ctrl/⌘ or Alt so normal typing is not captured." }; + } + tokens.push(parsed.token); + } + + return { ok: true, shortcut: tokens.join(" "), tokens }; +} + +type ShortcutChordParseResult = + | { ok: true; token: string } + | { ok: false; message: string }; + +function parseShortcutChord(chord: string): ShortcutChordParseResult { + const parts = chord.split("+").filter((part) => part !== ""); + if (parts.length === 0) return { ok: false, message: "Shortcut chords must include a key." }; + + const modifiers = new Set(); + let key: string | undefined; + for (const part of parts) { + const modifier = modifierAlias(part); + if (modifier !== undefined) { + if (modifiers.has(modifier)) return { ok: false, message: `Shortcut has duplicate ${formatShortcutPart(modifier)} modifiers.` }; + modifiers.add(modifier); + continue; + } + + const normalizedKey = normalizeShortcutKeyName(part); + if (normalizedKey === undefined) return { ok: false, message: `Unsupported shortcut key: ${part}` }; + if (key !== undefined) return { ok: false, message: "Each shortcut chord can include only one non-modifier key." }; + key = normalizedKey; + } + + if (key === undefined) return { ok: false, message: "Shortcut chords must include a key." }; + + const orderedModifiers = modifierOrder.filter((modifier) => modifiers.has(modifier)); + return { ok: true, token: [...orderedModifiers, key].join("+") }; +} + +function modifierAlias(part: string): ShortcutModifier | undefined { + switch (part) { + case "mod": + case "meta": + case "cmd": + case "command": + case "ctrl": + case "control": + case "primary": + return "mod"; + case "alt": + case "option": + case "opt": + return "alt"; + case "shift": + return "shift"; + default: + return undefined; + } +} + +function normalizeShortcutKeyName(key: string): string | undefined { + const alias = keyAlias(key); + if (alias !== undefined) return alias; + if (/^f(?:[1-9]|1[0-9]|2[0-4])$/u.test(key)) return key; + if (key.length === 1) return key; + return undefined; } function normalizeKey(key: string): string | undefined { if (key === " ") return "space"; - if (key.length === 1) return key.toLowerCase(); const normalized = key.toLowerCase(); - if (["enter", "escape", "tab", "arrowup", "arrowdown", "arrowleft", "arrowright", "backspace", "delete"].includes(normalized)) return normalized; - return undefined; + return normalizeShortcutKeyName(normalized); +} + +function keyAlias(key: string): string | undefined { + switch (key) { + case " ": + case "spacebar": + case "space": + return "space"; + case "esc": + case "escape": + return "escape"; + case "return": + case "enter": + return "enter"; + case "del": + case "delete": + return "delete"; + case "backspace": + return "backspace"; + case "tab": + return "tab"; + case "up": + case "arrowup": + return "arrowup"; + case "down": + case "arrowdown": + return "arrowdown"; + case "left": + case "arrowleft": + return "arrowleft"; + case "right": + case "arrowright": + return "arrowright"; + case "pageup": + case "pagedown": + case "home": + case "end": + return key; + case "+": + case "plus": + return "plus"; + case "period": + case "dot": + return "."; + case "comma": + return ","; + case "slash": + return "/"; + case "backslash": + return "\\"; + case "minus": + return "-"; + default: + return undefined; + } +} + +function formatShortcutPart(part: string): string { + if (part === "mod") return isMac() ? "⌘" : "Ctrl"; + if (part === "shift") return "Shift"; + if (part === "alt") return isMac() ? "⌥" : "Alt"; + if (part === "enter") return "Enter"; + if (part === "escape") return "Esc"; + if (part === "space") return "Space"; + if (part === "tab") return "Tab"; + if (part === "backspace") return "Backspace"; + if (part === "delete") return "Delete"; + if (part === "arrowup") return "↑"; + if (part === "arrowdown") return "↓"; + if (part === "arrowleft") return "←"; + if (part === "arrowright") return "→"; + if (part === "pageup") return "PageUp"; + if (part === "pagedown") return "PageDown"; + if (part === "home") return "Home"; + if (part === "end") return "End"; + if (part === "plus") return "+"; + if (/^f(?:[1-9]|1[0-9]|2[0-4])$/u.test(part)) return part.toUpperCase(); + return part.length === 1 ? part.toUpperCase() : `${part.charAt(0).toUpperCase()}${part.slice(1)}`; } function sameTokens(left: string[], right: string[]): boolean { @@ -129,10 +408,6 @@ function startsWithTokens(tokens: string[], prefix: string[]): boolean { return prefix.every((token, index) => tokens[index] === token); } -function isModifiedShortcut(token: string): boolean { - return token.includes("+"); -} - function isMac(): boolean { - return navigator.userAgent.toLowerCase().includes("mac"); + return typeof navigator !== "undefined" && navigator.userAgent.toLowerCase().includes("mac"); } diff --git a/src/client/src/shortcutPreferences.test.ts b/src/client/src/shortcutPreferences.test.ts index 51128e9..7bb453e 100644 --- a/src/client/src/shortcutPreferences.test.ts +++ b/src/client/src/shortcutPreferences.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { AppAction } from "./actions"; -import { applyShortcutPreferences } from "./shortcutPreferences"; +import { applyActiveShortcutPreferences, applyShortcutPreferences } from "./shortcutPreferences"; const noop = () => undefined; @@ -26,6 +26,26 @@ describe("shortcut preferences", () => { action({ id: "core:view.chat" }), ]); }); + + it("keeps only active shortcuts when applying preferences for display", () => { + expect(applyActiveShortcutPreferences([ + action({ id: "core:z", title: "Later", shortcut: "mod+k" }), + action({ id: "core:a", title: "Earlier", shortcut: "mod+k" }), + ], undefined)).toEqual([ + action({ id: "core:z", title: "Later" }), + action({ id: "core:a", title: "Earlier", shortcut: "mod+k" }), + ]); + }); + + it("hides default shortcut labels shadowed by user-defined shortcuts", () => { + expect(applyActiveShortcutPreferences([ + action({ id: "core:a", title: "Default", shortcut: "mod+1" }), + action({ id: "core:z", title: "Custom", shortcut: "mod+2" }), + ], { "core:z": "mod+1" })).toEqual([ + action({ id: "core:a", title: "Default" }), + action({ id: "core:z", title: "Custom", shortcut: "mod+1" }), + ]); + }); }); function action(patch: Partial): AppAction { diff --git a/src/client/src/shortcutPreferences.ts b/src/client/src/shortcutPreferences.ts index ff9902e..baed861 100644 --- a/src/client/src/shortcutPreferences.ts +++ b/src/client/src/shortcutPreferences.ts @@ -1,11 +1,19 @@ import type { AppAction } from "./actions"; import type { PiWebShortcutConfig } from "./api"; +import { resolveShortcutBindings } from "./keyboardShortcuts"; export function applyShortcutPreferences(actions: AppAction[], shortcuts: PiWebShortcutConfig | undefined): AppAction[] { if (shortcuts === undefined) return actions; return actions.map((action) => applyShortcutPreference(action, shortcuts)); } +export function applyActiveShortcutPreferences(actions: AppAction[], shortcuts: PiWebShortcutConfig | undefined): AppAction[] { + const activeShortcutActionIds = new Set(resolveShortcutBindings(actions, shortcuts, { enabledOnly: true }) + .filter((binding) => binding.active) + .map((binding) => binding.action.id)); + return applyShortcutPreferences(actions, shortcuts).map((action) => action.shortcut !== undefined && !activeShortcutActionIds.has(action.id) ? withoutShortcut(action) : action); +} + export function applyShortcutPreference(action: AppAction, shortcuts: PiWebShortcutConfig): AppAction { if (!Object.hasOwn(shortcuts, action.id)) return action; const shortcut = shortcuts[action.id];