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
+14 -6
View File
@@ -305,9 +305,15 @@ toolbars deliberately stay on `--background`. Accent tones: one
`@theme inline` maps tokens to Tailwind utilities (`bg-primary`, `@theme inline` maps tokens to Tailwind utilities (`bg-primary`,
`text-icon-folder`, `bg-cursor-tint`). Selected rows are `bg-primary/15` `text-icon-folder`, `bg-cursor-tint`). Selected rows are `bg-primary/15`
(follow the accent); the cursor row uses `--cursor-tint` so graphite stays (follow the accent); the cursor row uses `--cursor-tint` so graphite stays
pixel-identical to the old neutral hover. Text-size levels set `--row-h`, pixel-identical to the old neutral hover. **Interface scale** (`settings.uiScale`,
`--list-font` etc. per `data-text` attribute, consumed via `h-(--row-h)` / 0.5–4 in 0.1 steps): one root font-size lever — `applyUiScale` sets
`text-(length:--list-font)` arbitrary utilities. `html { font-size: 16px * scale }`, and since EVERY size in the app is rem
(panel tokens like `--row-h`, all Tailwind utilities, ng-icon sizes, `--w7-fs`
and the 7.css bridge in 7like.css), both skins scale whole, chrome included.
Driven by the tab-bar stepper, Ctrl/Cmd +/−/0, and preload-baked for a
flash-free first paint. It replaced the 3-tier `data-text` axis; the store
migrates old `textSize` values. TS that measures rem-sized CSS converts through
`@core/metrics` `remPx()` — never an assumed 16.
### Skins (`settings.skin`: `modern | win7`) ### Skins (`settings.skin`: `modern | win7`)
@@ -335,7 +341,7 @@ main window still ran the old one.
**Stylesheet split.** `ui/src/styles.css` holds only what BOTH skins consume — **Stylesheet split.** `ui/src/styles.css` holds only what BOTH skins consume —
the `@layer` statement, the Tailwind import, OKLCH tokens, `@theme inline`, the `@layer` statement, the Tailwind import, OKLCH tokens, `@theme inline`,
`data-text` sizes, animations, the accent axis, and the `html, body` base. Each panel-chrome tokens, animations, the accent axis, and the `html, body` base. Each
skin's own rules live in its folder (`skins/7like/7like.css`, skin's own rules live in its folder (`skins/7like/7like.css`,
`skins/shadlike/shadlike.css`) and are `@import`ed from there. `shadlike.css` is `skins/shadlike/shadlike.css`) and are `@import`ed from there. `shadlike.css` is
near-empty by nature: that skin IS the shared token system plus inline Tailwind. near-empty by nature: that skin IS the shared token system plus inline Tailwind.
@@ -368,8 +374,10 @@ right too. It adds NO new tokens on purpose — a new colour would need another
`:root.win7[data-accent]` feeds it to `--w7-w-bg`, which is authentic (Win7 `:root.win7[data-accent]` feeds it to `--w7-w-bg`, which is authentic (Win7
shipped "Window Color"). Dark Win7 is hand-authored (Microsoft never shipped shipped "Window Color"). Dark Win7 is hand-authored (Microsoft never shipped
one) as ~20 `--w7-*` overrides, duplicated forced + system like every other one) as ~20 `--w7-*` overrides, duplicated forced + system like every other
dark block. `--w7-font` is restated as a px `font` shorthand so `data-text` dark block. `--w7-font` is restated as a rem `font` shorthand so the interface
still scales and 7.css's `pt` shorthand stops resetting weight/line-height. scale reaches it and 7.css's `pt` shorthand stops resetting weight/line-height;
the rest of 7.css's px metrics are re-expressed in rem by the scale bridge in
7like.css's `skinfix`.
**Markup hooks**: `role="progressbar"` (+aria) on the transfer bars, **Markup hooks**: `role="progressbar"` (+aria) on the transfer bars,
`has-scrollbar` on scroll containers (7.css only styles `::-webkit-scrollbar` `has-scrollbar` on scroll containers (7.css only styles `::-webkit-scrollbar`
+32
View File
@@ -54,6 +54,38 @@ touching the adjacent code.
it** — not even in an HTML comment. A backtick in a comment terminates the it** — not even in an HTML comment. A backtick in a comment terminates the
literal and the errors point at the prose, not at the quoting. literal and the errors point at the prose, not at the quoting.
## The interface scale (root font-size lever)
- **electrobun's native page zoom is a silent no-op on Linux — do not reach for
it.** `webviewSetPageZoom` in the shipped `libNativeWrapper.so` (v1.18.1, and
the `_cef.so`, and the copy in our own build output) is a compiled
empty-body stub: the disassembly has no `call` instruction, and
`webkit_web_view_set_zoom_level` is not even in the library's imports.
`webviewGetPageZoom` returns a hardcoded `1.0` from `.rodata`, so the failure
is unobservable through the API — set 2.0, read back "1.0 = success". The
JSDoc's "(WebKit only)" means macOS WKWebView. This is why the scale is CSS.
- **Everything the UI sizes with must be rem.** The scale works by setting the
root font-size, so a px value freezes while everything around it grows. The
three px reservoirs were: the panel-chrome tokens (`--row-h` etc.), 93
`text-[11px]` arbitraries, and 7.css itself — 370 px values, zero rem, with
`--w7-font` reaching only 15 rules' typography. 7like.css's `skinfix` carries
a scale bridge re-expressing 7.css's vars and hardcoded box metrics in rem.
1px hairlines and focus rects stay px deliberately (crisp at any scale; the
`tr` border exists to stop selection shifting rows).
- **Restating a 7.css rule in `skinfix` also outranks 7.css's VARIANTS of that
rule — by layer, regardless of specificity.** The title-bar-controls
restatement silently shrank the close button from 48px to 29px (7.css widens
it via `button[aria-label=Close]`), and the tablist padding flattened the
active tab's 4px raise. Caught only by the harness pixel diff. When bridging
a rule, grep 7.scoped.css for every variant that overrides the same
properties and restate those too.
- **px constants in TS that mirror rem-sized CSS are latent scale bugs.** The
shadlike context menu's `MENU_W = 224` was a hardcoded mirror of `w-56`
(14rem): box grew, math didn't, flip fired at the wrong edge. Convert through
`@core/metrics` `remPx()` at use time — it reads the LIVE root font-size,
and per call, since the scale changes mid-session. The editor's canvas-based
`charWidth` is cached keyed on the computed font string for the same reason.
## The two skin trees (`ui/src/app/skins/`) ## The two skin trees (`ui/src/app/skins/`)
- **`.win7` is not a name we may change.** `7.scoped.css` is precompiled with - **`.win7` is not a name we may change.** `7.scoped.css` is precompiled with
+111
View File
@@ -0,0 +1,111 @@
import { Injectable } from '@angular/core';
/**
* 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
// 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' answers 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';
/**
* The keyboard layer: modifier helpers + one declarative keymap.
*
* 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).
*/
@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;
}
/** 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"]');
}
/**
* 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(event: KeyboardEvent, scope: ShortcutScope): ShortcutMatch | null {
const mod = this.isMod(event);
const key = event.key.toLowerCase();
// ---- 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' };
}
if (scope === 'manager') return null; // any-window shortcuts only
if (scope === 'main') {
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;
}
// 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;
}
}
+37 -18
View File
@@ -16,6 +16,7 @@ import { PanelsService } from '@core/panels.service';
import { blockNativeContextMenu } from '@core/platform'; import { blockNativeContextMenu } from '@core/platform';
import { RpcService } from '@core/rpc.service'; import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service'; import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service';
import { StatusBar } from './status-bar/status-bar'; import { StatusBar } from './status-bar/status-bar';
import { TabBar } from './tab-bar/tab-bar'; import { TabBar } from './tab-bar/tab-bar';
import { TitleBar } from './title-bar/title-bar'; import { TitleBar } from './title-bar/title-bar';
@@ -62,6 +63,7 @@ export class App {
readonly connections = inject(ConnectionsService); readonly connections = inject(ConnectionsService);
private readonly transfers = inject(TransfersService); private readonly transfers = inject(TransfersService);
private readonly settings = inject(SettingsService); private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService);
private readonly updater = inject(UpdaterService); private readonly updater = inject(UpdaterService);
/** The connection-manager window is open — this window is modal-blocked. */ /** The connection-manager window is open — this window is modal-blocked. */
@@ -176,26 +178,43 @@ export class App {
} }
} }
/** Tab (workspace) shortcuts — panel-level keys live on the panels themselves. */ /** Window shortcuts — matching lives in ShortcutsService (one keymap for the
* app); this switch only maps ids onto actions. Panel-level keys stay on
* the panels themselves. */
onWindowKeydown(event: KeyboardEvent): void { onWindowKeydown(event: KeyboardEvent): void {
if (this.managerOpen()) return; // modal-blocked behind the manager if (this.managerOpen()) return; // modal-blocked behind the manager
const mod = event.metaKey || event.ctrlKey; const match = this.shortcuts.resolve(event, 'main');
if (mod && event.key.toLowerCase() === 't') { if (!match) return;
event.preventDefault(); // scale keys are hostile mid-typing ('-' in a rename prompt must type '-');
this.panels.newWorkspace(); // the tab shortcuts keep firing there, as they always did
} else if (mod && event.key.toLowerCase() === 'w') { if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
event.preventDefault(); event.preventDefault();
this.panels.closeWorkspace(this.panels.activeWorkspaceId()); switch (match.id) {
} else if (event.ctrlKey && event.key === 'Tab') { case 'new-tab':
event.preventDefault(); this.panels.newWorkspace();
this.panels.cycleWorkspace(event.shiftKey ? -1 : 1); break;
} else if (mod && !event.shiftKey && !event.altKey) { case 'close-tab':
// ⌘1–⌘8 jump to that tab, ⌘9 to the last (Ctrl+digit elsewhere), like this.panels.closeWorkspace(this.panels.activeWorkspaceId());
// Firefox. Matched on event.code so the digit row works on any layout. break;
const digit = /^(?:Digit|Numpad)([1-9])$/.exec(event.code)?.[1]; case 'cycle-tab':
if (!digit) return; this.panels.cycleWorkspace(1);
event.preventDefault(); break;
this.panels.goToWorkspace(digit === '9' ? -1 : Number(digit) - 1); case 'cycle-tab-back':
this.panels.cycleWorkspace(-1);
break;
case 'go-to-tab':
// ⌘1–⌘8 jump to that tab, ⌘9 to the last, like Firefox
this.panels.goToWorkspace(match.digit === 9 ? -1 : match.digit! - 1);
break;
case 'scale-up':
this.settings.setUiScale(this.settings.uiScale() + 0.1);
break;
case 'scale-down':
this.settings.setUiScale(this.settings.uiScale() - 0.1);
break;
case 'scale-reset':
this.settings.setUiScale(1);
break;
} }
} }
@@ -9,6 +9,7 @@ import { DialogsService } from '@core/dialogs.service';
import { blockNativeContextMenu } from '@core/platform'; import { blockNativeContextMenu } from '@core/platform';
import { RpcService } from '@core/rpc.service'; import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service'; import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service';
import { TitleBar } from '../title-bar/title-bar'; import { TitleBar } from '../title-bar/title-bar';
import { Toasts } from '../toasts/toasts'; import { Toasts } from '../toasts/toasts';
@@ -77,6 +78,7 @@ function fromDraft(d: Draft): SavedConnection {
templateUrl: './connection-manager.html', templateUrl: './connection-manager.html',
host: { host: {
'(window:keydown.escape)': 'onEscape()', '(window:keydown.escape)': 'onEscape()',
'(window:keydown)': 'onWindowKeydown($event)',
'(window:contextmenu)': 'blockContextMenu($event)', '(window:contextmenu)': 'blockContextMenu($event)',
// Aero desaturates an unfocused window's chrome — see .window.active. // Aero desaturates an unfocused window's chrome — see .window.active.
'(window:focus)': 'windowFocused.set(true)', '(window:focus)': 'windowFocused.set(true)',
@@ -94,6 +96,7 @@ export class ConnectionManager {
// injected for its side effects: fetches settings and applies theme/accent/ // injected for its side effects: fetches settings and applies theme/accent/
// text-size data attributes to this window's document root // text-size data attributes to this window's document root
private readonly settings = inject(SettingsService); private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService);
readonly draft = signal<Draft | null>(null); readonly draft = signal<Draft | null>(null);
readonly busy = signal(false); readonly busy = signal(false);
@@ -149,6 +152,16 @@ export class ConnectionManager {
if (!this.dialogs.anyOpen) this.close(); 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 { blockContextMenu(event: Event): void {
blockNativeContextMenu(event); blockNativeContextMenu(event);
} }
@@ -14,6 +14,7 @@ import { I18nService } from '@core/i18n.service';
import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, MOD_LABEL } from '@core/platform'; import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, MOD_LABEL } from '@core/platform';
import { RpcService } from '@core/rpc.service'; import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service'; import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service';
import { ToastService } from '@core/toast.service'; import { ToastService } from '@core/toast.service';
import { TitleBar } from '../title-bar/title-bar'; import { TitleBar } from '../title-bar/title-bar';
import { Toasts } from '../toasts/toasts'; import { Toasts } from '../toasts/toasts';
@@ -108,6 +109,7 @@ export class EditorWindow {
// injected for its side effects: fetches settings and applies theme/accent/ // injected for its side effects: fetches settings and applies theme/accent/
// text-size data attributes to this window's document root // text-size data attributes to this window's document root
private readonly settings = inject(SettingsService); private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService);
private draftTimer: ReturnType<typeof setTimeout> | null = null; private draftTimer: ReturnType<typeof setTimeout> | null = null;
private lastSentDirty = false; private lastSentDirty = false;
@@ -324,22 +326,43 @@ export class EditorWindow {
// ---- keyboard ---- // ---- keyboard ----
onKeydown(event: KeyboardEvent): void { onKeydown(event: KeyboardEvent): void {
const mod = event.metaKey || event.ctrlKey; // Chord shortcuts resolve through the shared keymap (ShortcutsService);
if (mod && event.key.toLowerCase() === 's') { // Escape and Tab stay below because their meaning is THIS window's state
// (close prompt up? find bar open? caret in the textarea?), not a binding.
const match = this.shortcuts.resolve(event, 'editor');
if (match) {
// scale keys must not fire while typing — but the editor's own chords
// (save/find/wrap) are WANTED mid-typing, so the guard is per-shortcut
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
event.preventDefault(); event.preventDefault();
void this.save(); switch (match.id) {
} else if (mod && event.key.toLowerCase() === 'f') { case 'save':
event.preventDefault(); void this.save();
this.openFind(); break;
} else if (mod && event.key.toLowerCase() === 'w') { case 'find':
// reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W and this.openFind();
// bun routes it back as editorCloseRequest break;
event.preventDefault(); case 'close-window':
this.requestClose(); // reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W
} else if (event.altKey && event.code === 'KeyZ') { // and bun routes it back as editorCloseRequest
event.preventDefault(); this.requestClose();
this.toggleWrap(); break;
} else if (event.key === 'Escape' && this.closePrompt()) { case 'toggle-wrap':
this.toggleWrap();
break;
case 'scale-up':
this.settings.setUiScale(this.settings.uiScale() + 0.1);
break;
case 'scale-down':
this.settings.setUiScale(this.settings.uiScale() - 0.1);
break;
case 'scale-reset':
this.settings.setUiScale(1);
break;
}
return;
}
if (event.key === 'Escape' && this.closePrompt()) {
event.preventDefault(); event.preventDefault();
this.cancelClose(); this.cancelClose();
} else if (event.key === 'Escape' && this.findOpen()) { } else if (event.key === 'Escape' && this.findOpen()) {
@@ -21,6 +21,7 @@ import { PanelsService, type Side, type SortKey } from '@core/panels.service';
import { IS_WINDOWS } from '@core/platform'; import { IS_WINDOWS } from '@core/platform';
import { RpcService } from '@core/rpc.service'; import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service'; import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service';
import { TransfersService } from '@core/transfers.service'; import { TransfersService } from '@core/transfers.service';
/** Optional columns in fixed display order, with their track widths. */ /** Optional columns in fixed display order, with their track widths. */
@@ -50,6 +51,7 @@ export class FilePanel {
readonly dialogs = inject(DialogsService); readonly dialogs = inject(DialogsService);
private readonly menu = inject(ContextMenuService); private readonly menu = inject(ContextMenuService);
private readonly settings = inject(SettingsService); private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService);
private readonly rpc = inject(RpcService); private readonly rpc = inject(RpcService);
readonly panel = computed(() => this.panels.get(this.side())); readonly panel = computed(() => this.panels.get(this.side()));
readonly isActive = computed(() => this.panels.activeSide() === this.side()); readonly isActive = computed(() => this.panels.activeSide() === this.side());
@@ -212,7 +214,7 @@ export class FilePanel {
const panel = this.panel(); const panel = this.panel();
if (event.shiftKey) { if (event.shiftKey) {
panel.selectRange(this.lastClickedIndex, index); panel.selectRange(this.lastClickedIndex, index);
} else if (event.metaKey || event.ctrlKey) { } else if (this.shortcuts.isMod(event)) {
const entry = panel.sorted()[index]; const entry = panel.sorted()[index];
if (entry) panel.toggleSelect(entry.name); if (entry) panel.toggleSelect(entry.name);
} else { } else {
@@ -258,7 +260,7 @@ export class FilePanel {
this.panels.setActiveSide(this.panels.otherSide(this.side())); this.panels.setActiveSide(this.panels.otherSide(this.side()));
return; return;
} }
if ((event.metaKey || event.ctrlKey) && key.toLowerCase() === 'a') { if (this.shortcuts.isMod(event) && key.toLowerCase() === 'a') {
event.preventDefault(); event.preventDefault();
panel.selectAll(); panel.selectAll();
return; return;
+37 -18
View File
@@ -16,6 +16,7 @@ import { PanelsService } from '@core/panels.service';
import { blockNativeContextMenu } from '@core/platform'; import { blockNativeContextMenu } from '@core/platform';
import { RpcService } from '@core/rpc.service'; import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service'; import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service';
import { StatusBar } from './status-bar/status-bar'; import { StatusBar } from './status-bar/status-bar';
import { TabBar } from './tab-bar/tab-bar'; import { TabBar } from './tab-bar/tab-bar';
import { ToastService } from '@core/toast.service'; import { ToastService } from '@core/toast.service';
@@ -55,6 +56,7 @@ export class App {
readonly connections = inject(ConnectionsService); readonly connections = inject(ConnectionsService);
private readonly transfers = inject(TransfersService); private readonly transfers = inject(TransfersService);
private readonly settings = inject(SettingsService); private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService);
private readonly updater = inject(UpdaterService); private readonly updater = inject(UpdaterService);
/** The connection-manager window is open — this window is modal-blocked. */ /** The connection-manager window is open — this window is modal-blocked. */
@@ -154,26 +156,43 @@ export class App {
} }
} }
/** Tab (workspace) shortcuts — panel-level keys live on the panels themselves. */ /** Window shortcuts — matching lives in ShortcutsService (one keymap for the
* app); this switch only maps ids onto actions. Panel-level keys stay on
* the panels themselves. */
onWindowKeydown(event: KeyboardEvent): void { onWindowKeydown(event: KeyboardEvent): void {
if (this.managerOpen()) return; // modal-blocked behind the manager if (this.managerOpen()) return; // modal-blocked behind the manager
const mod = event.metaKey || event.ctrlKey; const match = this.shortcuts.resolve(event, 'main');
if (mod && event.key.toLowerCase() === 't') { if (!match) return;
event.preventDefault(); // scale keys are hostile mid-typing ('-' in a rename prompt must type '-');
this.panels.newWorkspace(); // the tab shortcuts keep firing there, as they always did
} else if (mod && event.key.toLowerCase() === 'w') { if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
event.preventDefault(); event.preventDefault();
this.panels.closeWorkspace(this.panels.activeWorkspaceId()); switch (match.id) {
} else if (event.ctrlKey && event.key === 'Tab') { case 'new-tab':
event.preventDefault(); this.panels.newWorkspace();
this.panels.cycleWorkspace(event.shiftKey ? -1 : 1); break;
} else if (mod && !event.shiftKey && !event.altKey) { case 'close-tab':
// ⌘1–⌘8 jump to that tab, ⌘9 to the last (Ctrl+digit elsewhere), like this.panels.closeWorkspace(this.panels.activeWorkspaceId());
// Firefox. Matched on event.code so the digit row works on any layout. break;
const digit = /^(?:Digit|Numpad)([1-9])$/.exec(event.code)?.[1]; case 'cycle-tab':
if (!digit) return; this.panels.cycleWorkspace(1);
event.preventDefault(); break;
this.panels.goToWorkspace(digit === '9' ? -1 : Number(digit) - 1); case 'cycle-tab-back':
this.panels.cycleWorkspace(-1);
break;
case 'go-to-tab':
// ⌘1–⌘8 jump to that tab, ⌘9 to the last, like Firefox
this.panels.goToWorkspace(match.digit === 9 ? -1 : match.digit! - 1);
break;
case 'scale-up':
this.settings.setUiScale(this.settings.uiScale() + 0.1);
break;
case 'scale-down':
this.settings.setUiScale(this.settings.uiScale() - 0.1);
break;
case 'scale-reset':
this.settings.setUiScale(1);
break;
} }
} }
@@ -9,6 +9,7 @@ import { DialogsService } from '@core/dialogs.service';
import { blockNativeContextMenu, IS_MAC_CHROME } from '@core/platform'; import { blockNativeContextMenu, IS_MAC_CHROME } from '@core/platform';
import { RpcService } from '@core/rpc.service'; import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service'; import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service';
import { Toasts } from '../toasts/toasts'; import { Toasts } from '../toasts/toasts';
interface Draft { interface Draft {
@@ -76,6 +77,7 @@ function fromDraft(d: Draft): SavedConnection {
templateUrl: './connection-manager.html', templateUrl: './connection-manager.html',
host: { host: {
'(window:keydown.escape)': 'onEscape()', '(window:keydown.escape)': 'onEscape()',
'(window:keydown)': 'onWindowKeydown($event)',
'(window:contextmenu)': 'blockContextMenu($event)', '(window:contextmenu)': 'blockContextMenu($event)',
}, },
}) })
@@ -88,6 +90,7 @@ export class ConnectionManager {
// injected for its side effects: fetches settings and applies theme/accent/ // injected for its side effects: fetches settings and applies theme/accent/
// text-size data attributes to this window's document root // text-size data attributes to this window's document root
private readonly settings = inject(SettingsService); private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService);
readonly draft = signal<Draft | null>(null); readonly draft = signal<Draft | null>(null);
readonly busy = signal(false); readonly busy = signal(false);
@@ -143,6 +146,16 @@ export class ConnectionManager {
if (!this.dialogs.anyOpen) this.close(); 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 { blockContextMenu(event: Event): void {
blockNativeContextMenu(event); blockNativeContextMenu(event);
} }
@@ -14,6 +14,7 @@ import { I18nService } from '@core/i18n.service';
import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME, MOD_LABEL } from '@core/platform'; import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME, MOD_LABEL } from '@core/platform';
import { RpcService } from '@core/rpc.service'; import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service'; import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service';
import { ToastService } from '@core/toast.service'; import { ToastService } from '@core/toast.service';
import { Toasts } from '../toasts/toasts'; import { Toasts } from '../toasts/toasts';
@@ -101,6 +102,7 @@ export class EditorWindow {
// injected for its side effects: fetches settings and applies theme/accent/ // injected for its side effects: fetches settings and applies theme/accent/
// text-size data attributes to this window's document root // text-size data attributes to this window's document root
private readonly settings = inject(SettingsService); private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService);
private draftTimer: ReturnType<typeof setTimeout> | null = null; private draftTimer: ReturnType<typeof setTimeout> | null = null;
private lastSentDirty = false; private lastSentDirty = false;
@@ -317,22 +319,43 @@ export class EditorWindow {
// ---- keyboard ---- // ---- keyboard ----
onKeydown(event: KeyboardEvent): void { onKeydown(event: KeyboardEvent): void {
const mod = event.metaKey || event.ctrlKey; // Chord shortcuts resolve through the shared keymap (ShortcutsService);
if (mod && event.key.toLowerCase() === 's') { // Escape and Tab stay below because their meaning is THIS window's state
// (close prompt up? find bar open? caret in the textarea?), not a binding.
const match = this.shortcuts.resolve(event, 'editor');
if (match) {
// scale keys must not fire while typing — but the editor's own chords
// (save/find/wrap) are WANTED mid-typing, so the guard is per-shortcut
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
event.preventDefault(); event.preventDefault();
void this.save(); switch (match.id) {
} else if (mod && event.key.toLowerCase() === 'f') { case 'save':
event.preventDefault(); void this.save();
this.openFind(); break;
} else if (mod && event.key.toLowerCase() === 'w') { case 'find':
// reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W and this.openFind();
// bun routes it back as editorCloseRequest break;
event.preventDefault(); case 'close-window':
this.requestClose(); // reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W
} else if (event.altKey && event.code === 'KeyZ') { // and bun routes it back as editorCloseRequest
event.preventDefault(); this.requestClose();
this.toggleWrap(); break;
} else if (event.key === 'Escape' && this.closePrompt()) { case 'toggle-wrap':
this.toggleWrap();
break;
case 'scale-up':
this.settings.setUiScale(this.settings.uiScale() + 0.1);
break;
case 'scale-down':
this.settings.setUiScale(this.settings.uiScale() - 0.1);
break;
case 'scale-reset':
this.settings.setUiScale(1);
break;
}
return;
}
if (event.key === 'Escape' && this.closePrompt()) {
event.preventDefault(); event.preventDefault();
this.cancelClose(); this.cancelClose();
} else if (event.key === 'Escape' && this.findOpen()) { } else if (event.key === 'Escape' && this.findOpen()) {
@@ -21,6 +21,7 @@ import { PanelsService, type Side, type SortKey } from '@core/panels.service';
import { IS_WINDOWS } from '@core/platform'; import { IS_WINDOWS } from '@core/platform';
import { RpcService } from '@core/rpc.service'; import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service'; import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service';
import { TransfersService } from '@core/transfers.service'; import { TransfersService } from '@core/transfers.service';
/** Optional columns in fixed display order, with their track widths. */ /** Optional columns in fixed display order, with their track widths. */
@@ -50,6 +51,7 @@ export class FilePanel {
readonly dialogs = inject(DialogsService); readonly dialogs = inject(DialogsService);
private readonly menu = inject(ContextMenuService); private readonly menu = inject(ContextMenuService);
private readonly settings = inject(SettingsService); private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService);
private readonly rpc = inject(RpcService); private readonly rpc = inject(RpcService);
readonly panel = computed(() => this.panels.get(this.side())); readonly panel = computed(() => this.panels.get(this.side()));
readonly isActive = computed(() => this.panels.activeSide() === this.side()); readonly isActive = computed(() => this.panels.activeSide() === this.side());
@@ -220,7 +222,7 @@ export class FilePanel {
const panel = this.panel(); const panel = this.panel();
if (event.shiftKey) { if (event.shiftKey) {
panel.selectRange(this.lastClickedIndex, index); panel.selectRange(this.lastClickedIndex, index);
} else if (event.metaKey || event.ctrlKey) { } else if (this.shortcuts.isMod(event)) {
const entry = panel.sorted()[index]; const entry = panel.sorted()[index];
if (entry) panel.toggleSelect(entry.name); if (entry) panel.toggleSelect(entry.name);
} else { } else {
@@ -266,7 +268,7 @@ export class FilePanel {
this.panels.setActiveSide(this.panels.otherSide(this.side())); this.panels.setActiveSide(this.panels.otherSide(this.side()));
return; return;
} }
if ((event.metaKey || event.ctrlKey) && key.toLowerCase() === 'a') { if (this.shortcuts.isMod(event) && key.toLowerCase() === 'a') {
event.preventDefault(); event.preventDefault();
panel.selectAll(); panel.selectAll();
return; return;