A Keyboard section in the Settings window edits the shared keymap: rows by group with a search field, a chip per key (x removes), + to record, reset per row and Reset all. Recording takes the keydown before every other key in the window (Cmd+W records instead of closing), refuses reserved combos without preventDefault (Cmd+Q still quits), requires a chord or F-key for editor commands, and offers Replace when another command in an overlapping scope owns the key. Rows flag conflicts and plain character keys that would stop starting a type-to-filter query. Fixed keys are listed read-only, in the platform's spelling. Remaps are ordinary settings (only rows that differ from the defaults), so every window, the menu hints and the tooltips follow live — and the macOS menu bar: its accelerators are built from the keymap (only the combos Electrobun's parser expresses faithfully) and rebuilt on a remap. A main-window menu action fired from the editor or Settings window brings the main window forward first. Also: the "Enter / double-click on a file" setting under File list (do nothing / open local files in their default app), arrow keys in the Settings sidebar with focus on open and a hand-walked Tab, translations for all seven languages (the spec now checks every language labels every command), and a README shortcut table generated from the keymap. Co-Authored-By: Claude <noreply@anthropic.com>
167 lines
6.3 KiB
TypeScript
167 lines
6.3 KiB
TypeScript
import { Injectable, computed, inject } from '@angular/core';
|
|
import {
|
|
type Combo,
|
|
EN_KEY_NAMES,
|
|
effectiveKeymap,
|
|
firesInTextField,
|
|
formatCombo,
|
|
isRepeatable,
|
|
type KeyEventLike,
|
|
type KeyNames,
|
|
matchesEvent,
|
|
parseCombo,
|
|
type ResolvedBinding,
|
|
type Scope,
|
|
sameCombo,
|
|
serializeCombo,
|
|
type ShortcutId,
|
|
} from '@shared/keymap';
|
|
import { I18nService } from './i18n.service';
|
|
import { IS_MAC } from './platform';
|
|
import { SettingsService } from './settings.service';
|
|
|
|
export type { ShortcutId } from '@shared/keymap';
|
|
|
|
/** A keydown as resolve() reads it: the matching fields plus where it came from. */
|
|
type KeyInput = KeyEventLike & { target?: EventTarget | null; isComposing?: boolean; keyCode?: number };
|
|
|
|
/**
|
|
* The keyboard layer over the shared keymap (src/shared/keymap.ts): resolves
|
|
* keydowns against the EFFECTIVE keymap — defaults plus the Settings → Keyboard
|
|
* remaps, live in every window — and formats the hints menus and tooltips show.
|
|
*
|
|
* - Matching is exact: ⌃F is not ⌘F, Ctrl+Tab is not Tab. `Mod` is ⌘ on
|
|
* macOS and Ctrl elsewhere.
|
|
* - On macOS the PAGE sees ⌘-keys first: WKWebView hands key equivalents to the
|
|
* page before the app menu (WebKit's WebViewImpl::performKeyEquivalent), so a
|
|
* key a window claims here (claim() → preventDefault) never reaches the menu
|
|
* bar, whose accelerators bun builds from the same table. Keys nobody claims
|
|
* fall through to it.
|
|
* - From a text field only firesInTextField() combos count (⌘S yes, Delete no).
|
|
* - Escape, the arrows inside menus and dialog Enter stay component-local:
|
|
* they are fixed, so nobody can remap themselves out of a dialog.
|
|
*/
|
|
@Injectable({ providedIn: 'root' })
|
|
export class ShortcutsService {
|
|
private readonly settings = inject(SettingsService);
|
|
private readonly i18n = inject(I18nService);
|
|
|
|
readonly keymap = computed(() => effectiveKeymap(this.settings.keybindings(), IS_MAC));
|
|
private readonly byId = computed(() => new Map(this.keymap().map((b) => [b.id, b])));
|
|
/** Localized key names for the non-mac spelling ("Strg+Entf"); reading t()
|
|
* makes every hint follow a language switch. */
|
|
private readonly names = computed<KeyNames>(() => {
|
|
const names = { ...EN_KEY_NAMES };
|
|
for (const key of Object.keys(names) as (keyof KeyNames)[]) {
|
|
names[key] = this.i18n.t(`keys.names.${key}`);
|
|
}
|
|
return names;
|
|
});
|
|
|
|
/** The command a keydown triggers in `scope`, or null. Window scopes include
|
|
* `any`; `panel` doesn't (the window root runs those). */
|
|
resolve(event: KeyInput, scope: Scope): ShortcutId | null {
|
|
// an IME's Enter commits a candidate, it is never a shortcut
|
|
if (event.isComposing || event.keyCode === 229) return null;
|
|
const typing = this.isTypingTarget(event);
|
|
for (const binding of this.keymap()) {
|
|
if (!binding.scopes.includes(scope) && (scope === 'panel' || !binding.scopes.includes('any'))) {
|
|
continue;
|
|
}
|
|
for (const combo of binding.combos) {
|
|
if (typing && !firesInTextField(combo, IS_MAC)) continue;
|
|
if (matchesEvent(combo, event, IS_MAC)) return binding.id;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* resolve() + take the key: a match is preventDefault-ed, so the macOS menu
|
|
* never runs it a second time. A held key repeats only movement-like
|
|
* commands; any other repeat is swallowed (holding ⌘W closes one tab).
|
|
*/
|
|
claim(event: KeyboardEvent, scope: Scope): ShortcutId | null {
|
|
const id = this.resolve(event, scope);
|
|
if (!id) return null;
|
|
event.preventDefault();
|
|
return event.repeat && !isRepeatable(id) ? null : id;
|
|
}
|
|
|
|
/** Whether a keydown is one of `id`'s combos (an open menu matching its items). */
|
|
matches(id: ShortcutId, event: KeyEventLike): boolean {
|
|
return !!this.byId().get(id)?.combos.some((c) => matchesEvent(c, event, IS_MAC));
|
|
}
|
|
|
|
binding(id: ShortcutId): ResolvedBinding | undefined {
|
|
return this.byId().get(id);
|
|
}
|
|
|
|
format(combo: Combo): string {
|
|
return formatCombo(combo, IS_MAC, this.names());
|
|
}
|
|
|
|
/** The hint menus show: the command's first key, or '' when unbound. */
|
|
label(id: ShortcutId): string {
|
|
const first = this.byId().get(id)?.combos[0];
|
|
return first ? this.format(first) : '';
|
|
}
|
|
|
|
labels(id: ShortcutId): string[] {
|
|
return (this.byId().get(id)?.combos ?? []).map((c) => this.format(c));
|
|
}
|
|
|
|
/** A tooltip with the command's keys: "Delete (F8 / Del)"; bare when unbound. */
|
|
tip(text: string, id: ShortcutId): string {
|
|
const keys = this.labels(id);
|
|
return keys.length ? `${text} (${keys.join(' / ')})` : text;
|
|
}
|
|
|
|
/** A FIXED key — one no remap moves, such as Enter in the find box — in
|
|
* the platform's spelling (⇧↩ / Shift+Enter). */
|
|
fixedKey(combo: string): string {
|
|
const parsed = parseCombo(combo, IS_MAC);
|
|
return parsed ? this.format(parsed) : combo;
|
|
}
|
|
|
|
/** tip() for a fixed key. */
|
|
tipFixed(text: string, combo: string): string {
|
|
return `${text} (${this.fixedKey(combo)})`;
|
|
}
|
|
|
|
/**
|
|
* Store new combos for commands (Settings → Keyboard). Only rows that differ
|
|
* from their defaults are kept, so a reset — or a remap back to the
|
|
* default — leaves nothing behind.
|
|
*/
|
|
rebind(changes: Partial<Record<ShortcutId, Combo[]>>): void {
|
|
const next = { ...this.settings.keybindings() };
|
|
for (const [id, combos] of Object.entries(changes) as [ShortcutId, Combo[]][]) {
|
|
const defaults = this.byId().get(id)?.defaults ?? [];
|
|
const isDefault =
|
|
combos.length === defaults.length && combos.every((c, i) => sameCombo(c, defaults[i]));
|
|
if (isDefault) delete next[id];
|
|
else next[id] = combos.map(serializeCombo);
|
|
}
|
|
this.settings.setKeybindings(next);
|
|
}
|
|
|
|
resetAll(): void {
|
|
this.settings.setKeybindings({});
|
|
}
|
|
|
|
/** The platform command modifier: ⌘ on macOS, Ctrl elsewhere — exactly one.
|
|
* Mouse events too: Mod+click toggle-selects in the panels. */
|
|
isMod(event: KeyboardEvent | MouseEvent): boolean {
|
|
return IS_MAC ? event.metaKey : event.ctrlKey;
|
|
}
|
|
|
|
/** True when the event comes from something the user types into. */
|
|
isTypingTarget(event: { target?: EventTarget | null }): boolean {
|
|
const target = event.target as HTMLElement | null;
|
|
return !!target?.closest?.(
|
|
'textarea, select, [contenteditable="true"], input:not([type=checkbox]):not([type=radio]):not([type=button])',
|
|
);
|
|
}
|
|
}
|