Put every shortcut on one shared keymap and make the app keyboard-reachable
The keymap was scattered over ShortcutsService if-chains, the file panel's key switch, macOS-only menu accelerators and hand-typed hints in 16 tooltips x 7 locales, and large parts of the app had no keyboard path at all. src/shared/keymap.ts is now the one table: scopes, per-platform defaults, exact matching, formatting with localized key names, conflicts, reserved combos and native-menu accelerators, proven by keymap.spec.ts. - ShortcutsService resolves against the effective keymap: claim() takes the key (preventDefault, held keys repeat only movement, never mid-IME), and from a text field only chords and F-keys fire. The file panel, App, editor, manager and Settings windows all dispatch through it. - FileActionsService.run is the one file-command dispatcher for the list keys, the row menu, the macOS menu bar and the toolbar. - New keys: the row menu (Shift+F10 / Menu / Ctrl+Return), the source picker (Mod+K), the path bar (Mod+L), transfers (Mod+J), sorting (Alt+1-7), Shift+movement selection, close other tabs, move and disconnect tabs, refresh and edit on Windows/Linux, permissions, copy path, reveal, trash, open in default app, editor find next/previous. - Right-click menus: soft shortcut hints, icons on the everyday verbs (the toolbar's glyphs), section headers, scrolling, item keys while open, a highlighted first item when opened from the keyboard. The panel source picker now renders through the same menu. - Dialogs: role=dialog, a Tab trap via cycleFocus (WKWebView skips buttons on Tab without Keyboard navigation), initial focus, Esc that stops propagating; the chmod dialog takes focus and applies on Enter. - Fixed: Ctrl+Tab also flipped the active panel; Esc in a manager dialog closed the manager; Ctrl-F/T fired Cmd shortcuts on macOS; shortcuts fired under dialogs; keys died after any button click on macOS; the editor's save prompt and find box never got focus; WebView2 reload keys; a window could save default settings before its first load. - Settings closes on its own close-window key (new settingsClose request). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,114 +1,161 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
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 };
|
||||
|
||||
/**
|
||||
* Every window-level shortcut the app answers to, by name. Components switch
|
||||
* on these ids instead of key-matching inline, so the whole keymap is ONE
|
||||
* table — the precondition for the vim-style bindings planned next, which
|
||||
* will need a mode/leader state machine wrapped around exactly this point.
|
||||
*/
|
||||
export type ShortcutId =
|
||||
// main window (workspace/tab management)
|
||||
| 'new-tab'
|
||||
| 'close-tab'
|
||||
| 'cycle-tab'
|
||||
| 'cycle-tab-back'
|
||||
| 'go-to-tab' // 1-9; the digit rides in ShortcutMatch.digit
|
||||
| 'open-settings'
|
||||
// editor window
|
||||
| 'save'
|
||||
| 'find'
|
||||
| 'close-window'
|
||||
| 'toggle-wrap'
|
||||
// any window
|
||||
| 'scale-up'
|
||||
| 'scale-down'
|
||||
| 'scale-reset';
|
||||
|
||||
export interface ShortcutMatch {
|
||||
id: ShortcutId;
|
||||
/** Only for go-to-tab: the digit pressed, 1–9. */
|
||||
digit?: number;
|
||||
}
|
||||
|
||||
/** Which keymap a window consults. 'manager' and 'settings' answer only to the
|
||||
* any-window shortcuts (scale). Escape/Tab stay component-local: their meaning
|
||||
* is contextual state (find bar open? close prompt up? which pane?), not a
|
||||
* global binding. */
|
||||
export type ShortcutScope = 'main' | 'editor' | 'manager' | 'settings';
|
||||
|
||||
/**
|
||||
* The keyboard layer: modifier helpers + one declarative keymap.
|
||||
* 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.
|
||||
*
|
||||
* House rules encoded here rather than re-derived per component (they were
|
||||
* previously copy-pasted inline in six places, drifting apart):
|
||||
* - `mod` is metaKey OR ctrlKey — ⌘ on macOS, Ctrl elsewhere. The keyboard
|
||||
* does not change with the window chrome (platform.ts says the same about
|
||||
* MOD_LABEL).
|
||||
* - Layout-dependent keys match on `event.code`, not `event.key`: digits
|
||||
* (AZERTY's digit row shifts), +/- (key varies wildly across layouts), and
|
||||
* letter chords with Alt (macOS Option produces Ω for ⌥Z). Plain letter
|
||||
* chords match on `key` so they follow the user's layout.
|
||||
* - isTypingTarget guards shortcuts that would be hostile mid-typing. It is
|
||||
* deliberately NOT applied inside resolve(): the existing bindings rely on
|
||||
* the path bar's stopPropagation and the editor WANTS Mod+S while typing.
|
||||
* Callers opt in per shortcut (the scale keys do).
|
||||
* - 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 {
|
||||
/** The platform command modifier: ⌘ on macOS, Ctrl elsewhere. Mouse events
|
||||
* qualify too — Mod+click toggle-select in the panels uses the same rule. */
|
||||
isMod(event: KeyboardEvent | MouseEvent): boolean {
|
||||
return event.metaKey || event.ctrlKey;
|
||||
}
|
||||
private readonly settings = inject(SettingsService);
|
||||
private readonly i18n = inject(I18nService);
|
||||
|
||||
/** True when the event originates from something the user types into. */
|
||||
isTypingTarget(event: KeyboardEvent): boolean {
|
||||
const target = event.target as HTMLElement | null;
|
||||
return !!target?.closest('input, textarea, select, [contenteditable="true"]');
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an event against the scope's keymap. Returns null for anything the
|
||||
* table does not claim — callers must then leave the event alone.
|
||||
* 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).
|
||||
*/
|
||||
resolve(event: KeyboardEvent, scope: ShortcutScope): ShortcutMatch | null {
|
||||
const mod = this.isMod(event);
|
||||
const key = event.key.toLowerCase();
|
||||
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;
|
||||
}
|
||||
|
||||
// ---- any window: interface scale ----
|
||||
// event.code, not key: '+' needs Shift on many layouts and '-' moves
|
||||
// around; the physical Equal/Minus keys (and the numpad pair) are stable.
|
||||
if (mod && !event.altKey) {
|
||||
if (event.code === 'Equal' || event.code === 'NumpadAdd') return { id: 'scale-up' };
|
||||
if (event.code === 'Minus' || event.code === 'NumpadSubtract') return { id: 'scale-down' };
|
||||
if (event.code === 'Digit0' || event.code === 'Numpad0') return { id: 'scale-reset' };
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** The same for a FIXED key — one no remap moves, such as Enter in the find
|
||||
* box — so it reads in the platform's spelling too (⇧↩ / Shift+Enter). */
|
||||
tipFixed(text: string, combo: string): string {
|
||||
const parsed = parseCombo(combo, IS_MAC);
|
||||
return parsed ? `${text} (${this.format(parsed)})` : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
if (scope === 'manager' || scope === 'settings') return null; // any-window shortcuts only
|
||||
resetAll(): void {
|
||||
this.settings.setKeybindings({});
|
||||
}
|
||||
|
||||
if (scope === 'main') {
|
||||
// on macOS the app menu's ⌘, intercepts first; this is the Windows/Linux path
|
||||
if (mod && key === ',') return { id: 'open-settings' };
|
||||
if (mod && key === 't') return { id: 'new-tab' };
|
||||
if (mod && key === 'w') return { id: 'close-tab' };
|
||||
// ctrlKey only, never meta: ⌘Tab is the macOS app switcher
|
||||
if (event.ctrlKey && event.key === 'Tab') {
|
||||
return { id: event.shiftKey ? 'cycle-tab-back' : 'cycle-tab' };
|
||||
}
|
||||
if (mod && !event.shiftKey && !event.altKey) {
|
||||
const digit = /^(?:Digit|Numpad)([1-9])$/.exec(event.code)?.[1];
|
||||
if (digit) return { id: 'go-to-tab', digit: Number(digit) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/** 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;
|
||||
}
|
||||
|
||||
// scope === 'editor'
|
||||
if (mod && key === 's') return { id: 'save' };
|
||||
if (mod && key === 'f') return { id: 'find' };
|
||||
// on macOS the app menu intercepts ⌘W before this ever fires; this arm is
|
||||
// the Windows/Linux path
|
||||
if (mod && key === 'w') return { id: 'close-window' };
|
||||
if (event.altKey && event.code === 'KeyZ') return { id: 'toggle-wrap' };
|
||||
return null;
|
||||
/** 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])',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user