Scale stage 3: shortcuts service + Ctrl/Cmd +/-/0 for the scale

Groundwork for the vim-style bindings planned next: the app previously had NO
keyboard abstraction — `event.metaKey || event.ctrlKey` copy-pasted inline in
six places, no typing-target guard anywhere, every window root matching keys
by hand.

New @core/shortcuts.service.ts owns:
- isMod() (also accepts MouseEvent — Mod+click toggle-select uses the same
  rule) and isTypingTarget();
- ONE declarative keymap: resolve(event, scope) -> ShortcutId | null, scoped
  main / editor / manager. Layout rules are encoded once: digits, +/- and
  Alt-letter chords match on event.code (AZERTY digit rows, layouts where '+'
  needs Shift, macOS Option-Z producing Ω); plain letter chords match on
  event.key so they follow the user's layout.

Both app.ts, both editor-window.ts and both connection-manager.ts now resolve
through the service and switch on ids — behaviour-preserving for every
existing shortcut, including the deliberate quirks (Ctrl+Tab never meta
because Cmd-Tab is the macOS app switcher; Cmd+9 = last tab; editor Mod+W is
the Windows/Linux arm, macOS routes via the app menu). Escape and Tab stay
component-local on purpose: their meaning is per-window state (find bar open?
close prompt up? caret in the textarea?), not a global binding. The file
panels keep their contextual key state machine but delegate modifier tests.

On top of the table: Ctrl/Cmd +/-/0 steps/resets the interface scale in every
window kind, all three previously unclaimed. isTypingTarget is applied
per-shortcut, not globally in resolve(): the scale keys must not fire while
typing ('-' in a rename prompt must type '-'), but the editor WANTS Mod+S
mid-typing and the tab shortcuts keep their existing reach.

Docs: architecture.md's density prose now describes the uiScale lever;
gotchas.md gains the scale section — the disassembly evidence that native
page zoom is an empty stub on Linux (so nobody tries it again), the
everything-must-be-rem rule, the skinfix-outranks-variants trap the harness
caught (48px close button silently 29px), and the px-mirrors-in-TS class of
bug with the remPx() remedy.

Verified: tsc + build clean; live boot with the user's real legacy settings
file migrates textSize:large -> uiScale 1.2 and renders scaled. Not verified
here (needs interaction): the stepper clicks and the shortcut keys themselves.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-16 18:15:14 +02:00
co-authored by Claude
parent f101257f20
commit 9c54a441a9
11 changed files with 341 additions and 76 deletions
@@ -9,6 +9,7 @@ import { DialogsService } from '@core/dialogs.service';
import { blockNativeContextMenu, IS_MAC_CHROME } from '@core/platform';
import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service';
import { Toasts } from '../toasts/toasts';
interface Draft {
@@ -76,6 +77,7 @@ function fromDraft(d: Draft): SavedConnection {
templateUrl: './connection-manager.html',
host: {
'(window:keydown.escape)': 'onEscape()',
'(window:keydown)': 'onWindowKeydown($event)',
'(window:contextmenu)': 'blockContextMenu($event)',
},
})
@@ -88,6 +90,7 @@ export class ConnectionManager {
// injected for its side effects: fetches settings and applies theme/accent/
// text-size data attributes to this window's document root
private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService);
readonly draft = signal<Draft | null>(null);
readonly busy = signal(false);
@@ -143,6 +146,16 @@ export class ConnectionManager {
if (!this.dialogs.anyOpen) this.close();
}
/** Any-window shortcuts (interface scale). Escape has its own binding above. */
onWindowKeydown(event: KeyboardEvent): void {
const match = this.shortcuts.resolve(event, 'manager');
if (!match || this.shortcuts.isTypingTarget(event)) return;
event.preventDefault();
if (match.id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1);
else if (match.id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1);
else if (match.id === 'scale-reset') this.settings.setUiScale(1);
}
blockContextMenu(event: Event): void {
blockNativeContextMenu(event);
}