Archived
feat: add editable keyboard shortcuts
This commit is contained in:
@@ -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.
|
||||
@@ -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`<project-dialog .machineId=${selectedMachineId(state)} .onSubmit=${(path: string, create: boolean) => this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}></project-dialog>` : null}
|
||||
${state.machineDialogOpen ? html`<machine-dialog .error=${state.error} .onSubmit=${(input: MachineDialogSubmit) => this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}></machine-dialog>` : null}
|
||||
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
|
||||
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .actions=${this.getActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null}
|
||||
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -61,7 +61,18 @@ export class SettingsDialog extends LitElement {
|
||||
|
||||
private renderActiveSection(): TemplateResult {
|
||||
if (this.section === "shortcuts") {
|
||||
return html`<settings-shortcuts-panel .actions=${this.actions} .configResponse=${this.configResponse}></settings-shortcuts-panel>`;
|
||||
return html`
|
||||
<settings-shortcuts-panel
|
||||
.actions=${this.actions}
|
||||
.configResponse=${this.configResponse}
|
||||
.loading=${this.loading}
|
||||
.saving=${this.saving}
|
||||
.error=${this.error}
|
||||
.savedMessage=${this.savedMessage}
|
||||
.onReload=${() => this.loadConfig()}
|
||||
.onSave=${(config: PiWebConfigValues) => this.saveConfig(config)}
|
||||
></settings-shortcuts-panel>
|
||||
`;
|
||||
}
|
||||
if (this.section === "plugins") {
|
||||
return html`
|
||||
|
||||
@@ -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<void>;
|
||||
@property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>;
|
||||
@state() private drafts: Record<string, string> = {};
|
||||
@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<this>): 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`
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Keyboard shortcuts</h2>
|
||||
<p>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 <code>null</code> to disable it.</p>
|
||||
<p>Edit app shortcuts by action. Type a shortcut such as <code>mod+k</code> or <code>mod+g p</code>, 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.</p>
|
||||
</div>
|
||||
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
|
||||
</div>
|
||||
<div class="shortcut-note">Config key: <code>shortcuts</code>. Example: <code>{ "core:view.chat": "mod+1", "core:session.stop": null }</code></div>
|
||||
${groups.length === 0 ? html`<div class="loading-card">No actions registered.</div>` : groups.map((group) => html`
|
||||
<section class="shortcut-group">
|
||||
<h3>${group.name}</h3>
|
||||
<div class="shortcut-list">
|
||||
${group.actions.map((action) => this.renderShortcutRow(action))}
|
||||
</div>
|
||||
</section>
|
||||
`)}
|
||||
${this.renderMessages()}
|
||||
${this.configResponse === undefined && this.loading ? html`<div class="loading-card">Loading shortcuts…</div>` : html`
|
||||
<div class="config-path-card">
|
||||
<span>Config file</span>
|
||||
<code>${this.configResponse?.path ?? "Unknown"}</code>
|
||||
<small>Shortcut overrides are saved under <code>shortcuts</code>. A value of <code>null</code> disables the action shortcut.</small>
|
||||
</div>
|
||||
${groups.length === 0 ? html`<div class="loading-card">No actions registered.</div>` : groups.map((group) => html`
|
||||
<section class="shortcut-group">
|
||||
<h3>${group.name}</h3>
|
||||
<div class="shortcut-list">
|
||||
${group.actions.map((action) => this.renderShortcutRow(action, shortcutResolutions.get(action.id)))}
|
||||
</div>
|
||||
</section>
|
||||
`)}
|
||||
`}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderShortcutRow(action: AppAction): TemplateResult {
|
||||
private renderMessages(): TemplateResult | null {
|
||||
const error = this.localError || this.error;
|
||||
if (error !== "") return html`<div class="message error-message">${error}</div>`;
|
||||
if (this.savedMessage !== "") return html`<div class="message success-message">${this.savedMessage}</div>`;
|
||||
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`
|
||||
<div class="shortcut-row">
|
||||
<article class=${shortcutRowClass(resolution)}>
|
||||
<div class="shortcut-main">
|
||||
<strong>${action.title}</strong>
|
||||
${action.description !== undefined && action.description !== "" ? html`<small>${action.description}</small>` : null}
|
||||
<small class="shortcut-id">${action.id}</small>
|
||||
<small>${action.shortcut !== undefined && action.shortcut !== "" ? html`Default: <kbd>${formatShortcut(action.shortcut)}</kbd>` : "No default shortcut"}</small>
|
||||
</div>
|
||||
<div class="shortcut-value">
|
||||
${shortcut !== undefined && shortcut !== "" ? html`<kbd>${formatShortcut(shortcut)}</kbd>` : html`<span class="unassigned">${state === "disabled" ? "Disabled" : "Unassigned"}</span>`}
|
||||
<small class=${state}>${shortcutStateLabel(state)}</small>
|
||||
<div class="shortcut-editor">
|
||||
<div class="shortcut-status">
|
||||
${previewShortcut !== undefined && previewShortcut !== "" ? html`<kbd>${formatShortcut(previewShortcut)}</kbd>` : html`<span class="unassigned">${state === "disabled" ? "Disabled" : "Unassigned"}</span>`}
|
||||
<small class=${displayState}>${shortcutStateLabel(displayState)}${hasDraft ? " · Unsaved" : ""}</small>
|
||||
${conflictLabel === undefined ? null : html`<small class=${shortcutConflictClass(resolution)}>${conflictLabel}</small>`}
|
||||
</div>
|
||||
<label class="shortcut-input-label">
|
||||
<span>Shortcut</span>
|
||||
<input
|
||||
class="shortcut-input"
|
||||
data-action-id=${action.id}
|
||||
.value=${inputText}
|
||||
placeholder=${action.shortcut ?? "mod+k"}
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
?disabled=${this.saving}
|
||||
@input=${(event: Event) => { this.updateDraft(action.id, inputValue(event)); }}
|
||||
>
|
||||
</label>
|
||||
${recordingHint !== "" ? html`<small class="recording-hint">${recordingHint}</small>` : null}
|
||||
<div class="shortcut-actions">
|
||||
<button class="primary" ?disabled=${this.loading || this.saving || !hasDraft || inputText.trim() === ""} @click=${() => { void this.saveShortcut(action); }}>Save</button>
|
||||
<button ?disabled=${this.loading || this.saving} @click=${() => { void this.toggleRecording(action.id); }}>${this.recording?.actionId === action.id ? "Cancel recording" : "Record"}</button>
|
||||
<button ?disabled=${this.loading || this.saving || configured === null} @click=${() => { void this.setShortcutNone(action.id); }}>None</button>
|
||||
<button ?disabled=${this.loading || this.saving || !hasConfiguredShortcut} @click=${() => { void this.resetShortcut(action.id); }}>Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
this.stopRecording();
|
||||
this.localError = "";
|
||||
await this.saveShortcutPreference(actionId, null);
|
||||
}
|
||||
|
||||
private async resetShortcut(actionId: string): Promise<void> {
|
||||
this.stopRecording();
|
||||
this.localError = "";
|
||||
await this.saveShortcutPreference(actionId, undefined);
|
||||
}
|
||||
|
||||
private async saveShortcutPreference(actionId: string, shortcut: string | null | undefined): Promise<void> {
|
||||
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<string, ShortcutBindingResolution> {
|
||||
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<void> {
|
||||
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<HTMLInputElement>(".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<string, AppAction[]>();
|
||||
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 : "";
|
||||
}
|
||||
|
||||
@@ -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> = {}): ShortcutKeyEvent {
|
||||
return {
|
||||
@@ -16,10 +16,14 @@ function keyEvent(key: string, modifiers: Partial<ShortcutKeyEvent> = {}): 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string | null>;
|
||||
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<typeof setTimeout> | 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<string, ShortcutBinding[]>();
|
||||
for (const binding of bindings) {
|
||||
bindingsByKey.set(binding.key, [...(bindingsByKey.get(binding.key) ?? []), binding]);
|
||||
}
|
||||
|
||||
const exactWinnersByKey = new Map<string, ShortcutBinding>();
|
||||
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<ShortcutBinding, ShortcutBinding[]>();
|
||||
const winnerByBinding = new Map<ShortcutBinding, ShortcutBinding>();
|
||||
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<ShortcutModifier>();
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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>): AppAction {
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user