diff --git a/docs/architecture.md b/docs/architecture.md index 94dfe8e..e2e2e1a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -305,9 +305,15 @@ toolbars deliberately stay on `--background`. Accent tones: one `@theme inline` maps tokens to Tailwind utilities (`bg-primary`, `text-icon-folder`, `bg-cursor-tint`). Selected rows are `bg-primary/15` (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`, -`--list-font` etc. per `data-text` attribute, consumed via `h-(--row-h)` / -`text-(length:--list-font)` arbitrary utilities. +pixel-identical to the old neutral hover. **Interface scale** (`settings.uiScale`, +0.5–4 in 0.1 steps): one root font-size lever — `applyUiScale` sets +`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`) @@ -335,7 +341,7 @@ main window still ran the old one. **Stylesheet split.** `ui/src/styles.css` holds only what BOTH skins consume — 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`, `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. @@ -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 shipped "Window Color"). Dark Win7 is hand-authored (Microsoft never shipped 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` -still scales and 7.css's `pt` shorthand stops resetting weight/line-height. +dark block. `--w7-font` is restated as a rem `font` shorthand so the interface +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, `has-scrollbar` on scroll containers (7.css only styles `::-webkit-scrollbar` diff --git a/docs/gotchas.md b/docs/gotchas.md index 74a2b20..172848b 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -54,6 +54,38 @@ touching the adjacent code. 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. +## 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/`) - **`.win7` is not a name we may change.** `7.scoped.css` is precompiled with diff --git a/ui/src/app/core/shortcuts.service.ts b/ui/src/app/core/shortcuts.service.ts new file mode 100644 index 0000000..56e95e6 --- /dev/null +++ b/ui/src/app/core/shortcuts.service.ts @@ -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; + } +} diff --git a/ui/src/app/skins/7like/app.ts b/ui/src/app/skins/7like/app.ts index 2445ff7..1152432 100644 --- a/ui/src/app/skins/7like/app.ts +++ b/ui/src/app/skins/7like/app.ts @@ -16,6 +16,7 @@ import { PanelsService } from '@core/panels.service'; import { blockNativeContextMenu } from '@core/platform'; import { RpcService } from '@core/rpc.service'; import { SettingsService } from '@core/settings.service'; +import { ShortcutsService } from '@core/shortcuts.service'; import { StatusBar } from './status-bar/status-bar'; import { TabBar } from './tab-bar/tab-bar'; import { TitleBar } from './title-bar/title-bar'; @@ -62,6 +63,7 @@ export class App { readonly connections = inject(ConnectionsService); private readonly transfers = inject(TransfersService); private readonly settings = inject(SettingsService); + private readonly shortcuts = inject(ShortcutsService); private readonly updater = inject(UpdaterService); /** 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 { if (this.managerOpen()) return; // modal-blocked behind the manager - const mod = event.metaKey || event.ctrlKey; - if (mod && event.key.toLowerCase() === 't') { - event.preventDefault(); - this.panels.newWorkspace(); - } else if (mod && event.key.toLowerCase() === 'w') { - event.preventDefault(); - this.panels.closeWorkspace(this.panels.activeWorkspaceId()); - } else if (event.ctrlKey && event.key === 'Tab') { - event.preventDefault(); - this.panels.cycleWorkspace(event.shiftKey ? -1 : 1); - } else if (mod && !event.shiftKey && !event.altKey) { - // ⌘1–⌘8 jump to that tab, ⌘9 to the last (Ctrl+digit elsewhere), like - // Firefox. Matched on event.code so the digit row works on any layout. - const digit = /^(?:Digit|Numpad)([1-9])$/.exec(event.code)?.[1]; - if (!digit) return; - event.preventDefault(); - this.panels.goToWorkspace(digit === '9' ? -1 : Number(digit) - 1); + const match = this.shortcuts.resolve(event, 'main'); + if (!match) return; + // scale keys are hostile mid-typing ('-' in a rename prompt must type '-'); + // the tab shortcuts keep firing there, as they always did + if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return; + event.preventDefault(); + switch (match.id) { + case 'new-tab': + this.panels.newWorkspace(); + break; + case 'close-tab': + this.panels.closeWorkspace(this.panels.activeWorkspaceId()); + break; + case 'cycle-tab': + this.panels.cycleWorkspace(1); + break; + 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; } } diff --git a/ui/src/app/skins/7like/connection-manager/connection-manager.ts b/ui/src/app/skins/7like/connection-manager/connection-manager.ts index e14eecc..45ad7ab 100644 --- a/ui/src/app/skins/7like/connection-manager/connection-manager.ts +++ b/ui/src/app/skins/7like/connection-manager/connection-manager.ts @@ -9,6 +9,7 @@ import { DialogsService } from '@core/dialogs.service'; import { blockNativeContextMenu } from '@core/platform'; import { RpcService } from '@core/rpc.service'; import { SettingsService } from '@core/settings.service'; +import { ShortcutsService } from '@core/shortcuts.service'; import { TitleBar } from '../title-bar/title-bar'; import { Toasts } from '../toasts/toasts'; @@ -77,6 +78,7 @@ function fromDraft(d: Draft): SavedConnection { templateUrl: './connection-manager.html', host: { '(window:keydown.escape)': 'onEscape()', + '(window:keydown)': 'onWindowKeydown($event)', '(window:contextmenu)': 'blockContextMenu($event)', // Aero desaturates an unfocused window's chrome — see .window.active. '(window:focus)': 'windowFocused.set(true)', @@ -94,6 +96,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(null); readonly busy = signal(false); @@ -149,6 +152,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); } diff --git a/ui/src/app/skins/7like/editor-window/editor-window.ts b/ui/src/app/skins/7like/editor-window/editor-window.ts index 6c654b6..b5bc7bd 100644 --- a/ui/src/app/skins/7like/editor-window/editor-window.ts +++ b/ui/src/app/skins/7like/editor-window/editor-window.ts @@ -14,6 +14,7 @@ import { I18nService } from '@core/i18n.service'; import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, MOD_LABEL } from '@core/platform'; import { RpcService } from '@core/rpc.service'; import { SettingsService } from '@core/settings.service'; +import { ShortcutsService } from '@core/shortcuts.service'; import { ToastService } from '@core/toast.service'; import { TitleBar } from '../title-bar/title-bar'; import { Toasts } from '../toasts/toasts'; @@ -108,6 +109,7 @@ export class EditorWindow { // 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); private draftTimer: ReturnType | null = null; private lastSentDirty = false; @@ -324,22 +326,43 @@ export class EditorWindow { // ---- keyboard ---- onKeydown(event: KeyboardEvent): void { - const mod = event.metaKey || event.ctrlKey; - if (mod && event.key.toLowerCase() === 's') { + // Chord shortcuts resolve through the shared keymap (ShortcutsService); + // 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(); - void this.save(); - } else if (mod && event.key.toLowerCase() === 'f') { - event.preventDefault(); - this.openFind(); - } else if (mod && event.key.toLowerCase() === 'w') { - // reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W and - // bun routes it back as editorCloseRequest - event.preventDefault(); - this.requestClose(); - } else if (event.altKey && event.code === 'KeyZ') { - event.preventDefault(); - this.toggleWrap(); - } else if (event.key === 'Escape' && this.closePrompt()) { + switch (match.id) { + case 'save': + void this.save(); + break; + case 'find': + this.openFind(); + break; + case 'close-window': + // reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W + // and bun routes it back as editorCloseRequest + this.requestClose(); + break; + 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(); this.cancelClose(); } else if (event.key === 'Escape' && this.findOpen()) { diff --git a/ui/src/app/skins/7like/file-panel/file-panel.ts b/ui/src/app/skins/7like/file-panel/file-panel.ts index 8739410..0be44f6 100644 --- a/ui/src/app/skins/7like/file-panel/file-panel.ts +++ b/ui/src/app/skins/7like/file-panel/file-panel.ts @@ -21,6 +21,7 @@ import { PanelsService, type Side, type SortKey } from '@core/panels.service'; import { IS_WINDOWS } from '@core/platform'; import { RpcService } from '@core/rpc.service'; import { SettingsService } from '@core/settings.service'; +import { ShortcutsService } from '@core/shortcuts.service'; import { TransfersService } from '@core/transfers.service'; /** Optional columns in fixed display order, with their track widths. */ @@ -50,6 +51,7 @@ export class FilePanel { readonly dialogs = inject(DialogsService); private readonly menu = inject(ContextMenuService); private readonly settings = inject(SettingsService); + private readonly shortcuts = inject(ShortcutsService); private readonly rpc = inject(RpcService); readonly panel = computed(() => this.panels.get(this.side())); readonly isActive = computed(() => this.panels.activeSide() === this.side()); @@ -212,7 +214,7 @@ export class FilePanel { const panel = this.panel(); if (event.shiftKey) { panel.selectRange(this.lastClickedIndex, index); - } else if (event.metaKey || event.ctrlKey) { + } else if (this.shortcuts.isMod(event)) { const entry = panel.sorted()[index]; if (entry) panel.toggleSelect(entry.name); } else { @@ -258,7 +260,7 @@ export class FilePanel { this.panels.setActiveSide(this.panels.otherSide(this.side())); return; } - if ((event.metaKey || event.ctrlKey) && key.toLowerCase() === 'a') { + if (this.shortcuts.isMod(event) && key.toLowerCase() === 'a') { event.preventDefault(); panel.selectAll(); return; diff --git a/ui/src/app/skins/shadlike/app.ts b/ui/src/app/skins/shadlike/app.ts index da78b6f..ee16af1 100644 --- a/ui/src/app/skins/shadlike/app.ts +++ b/ui/src/app/skins/shadlike/app.ts @@ -16,6 +16,7 @@ import { PanelsService } from '@core/panels.service'; import { blockNativeContextMenu } from '@core/platform'; import { RpcService } from '@core/rpc.service'; import { SettingsService } from '@core/settings.service'; +import { ShortcutsService } from '@core/shortcuts.service'; import { StatusBar } from './status-bar/status-bar'; import { TabBar } from './tab-bar/tab-bar'; import { ToastService } from '@core/toast.service'; @@ -55,6 +56,7 @@ export class App { readonly connections = inject(ConnectionsService); private readonly transfers = inject(TransfersService); private readonly settings = inject(SettingsService); + private readonly shortcuts = inject(ShortcutsService); private readonly updater = inject(UpdaterService); /** 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 { if (this.managerOpen()) return; // modal-blocked behind the manager - const mod = event.metaKey || event.ctrlKey; - if (mod && event.key.toLowerCase() === 't') { - event.preventDefault(); - this.panels.newWorkspace(); - } else if (mod && event.key.toLowerCase() === 'w') { - event.preventDefault(); - this.panels.closeWorkspace(this.panels.activeWorkspaceId()); - } else if (event.ctrlKey && event.key === 'Tab') { - event.preventDefault(); - this.panels.cycleWorkspace(event.shiftKey ? -1 : 1); - } else if (mod && !event.shiftKey && !event.altKey) { - // ⌘1–⌘8 jump to that tab, ⌘9 to the last (Ctrl+digit elsewhere), like - // Firefox. Matched on event.code so the digit row works on any layout. - const digit = /^(?:Digit|Numpad)([1-9])$/.exec(event.code)?.[1]; - if (!digit) return; - event.preventDefault(); - this.panels.goToWorkspace(digit === '9' ? -1 : Number(digit) - 1); + const match = this.shortcuts.resolve(event, 'main'); + if (!match) return; + // scale keys are hostile mid-typing ('-' in a rename prompt must type '-'); + // the tab shortcuts keep firing there, as they always did + if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return; + event.preventDefault(); + switch (match.id) { + case 'new-tab': + this.panels.newWorkspace(); + break; + case 'close-tab': + this.panels.closeWorkspace(this.panels.activeWorkspaceId()); + break; + case 'cycle-tab': + this.panels.cycleWorkspace(1); + break; + 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; } } diff --git a/ui/src/app/skins/shadlike/connection-manager/connection-manager.ts b/ui/src/app/skins/shadlike/connection-manager/connection-manager.ts index c2dd2f8..6685a6e 100644 --- a/ui/src/app/skins/shadlike/connection-manager/connection-manager.ts +++ b/ui/src/app/skins/shadlike/connection-manager/connection-manager.ts @@ -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(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); } diff --git a/ui/src/app/skins/shadlike/editor-window/editor-window.ts b/ui/src/app/skins/shadlike/editor-window/editor-window.ts index cee82f5..144d3cb 100644 --- a/ui/src/app/skins/shadlike/editor-window/editor-window.ts +++ b/ui/src/app/skins/shadlike/editor-window/editor-window.ts @@ -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 { RpcService } from '@core/rpc.service'; import { SettingsService } from '@core/settings.service'; +import { ShortcutsService } from '@core/shortcuts.service'; import { ToastService } from '@core/toast.service'; import { Toasts } from '../toasts/toasts'; @@ -101,6 +102,7 @@ export class EditorWindow { // 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); private draftTimer: ReturnType | null = null; private lastSentDirty = false; @@ -317,22 +319,43 @@ export class EditorWindow { // ---- keyboard ---- onKeydown(event: KeyboardEvent): void { - const mod = event.metaKey || event.ctrlKey; - if (mod && event.key.toLowerCase() === 's') { + // Chord shortcuts resolve through the shared keymap (ShortcutsService); + // 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(); - void this.save(); - } else if (mod && event.key.toLowerCase() === 'f') { - event.preventDefault(); - this.openFind(); - } else if (mod && event.key.toLowerCase() === 'w') { - // reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W and - // bun routes it back as editorCloseRequest - event.preventDefault(); - this.requestClose(); - } else if (event.altKey && event.code === 'KeyZ') { - event.preventDefault(); - this.toggleWrap(); - } else if (event.key === 'Escape' && this.closePrompt()) { + switch (match.id) { + case 'save': + void this.save(); + break; + case 'find': + this.openFind(); + break; + case 'close-window': + // reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W + // and bun routes it back as editorCloseRequest + this.requestClose(); + break; + 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(); this.cancelClose(); } else if (event.key === 'Escape' && this.findOpen()) { diff --git a/ui/src/app/skins/shadlike/file-panel/file-panel.ts b/ui/src/app/skins/shadlike/file-panel/file-panel.ts index bb8391d..422ada1 100644 --- a/ui/src/app/skins/shadlike/file-panel/file-panel.ts +++ b/ui/src/app/skins/shadlike/file-panel/file-panel.ts @@ -21,6 +21,7 @@ import { PanelsService, type Side, type SortKey } from '@core/panels.service'; import { IS_WINDOWS } from '@core/platform'; import { RpcService } from '@core/rpc.service'; import { SettingsService } from '@core/settings.service'; +import { ShortcutsService } from '@core/shortcuts.service'; import { TransfersService } from '@core/transfers.service'; /** Optional columns in fixed display order, with their track widths. */ @@ -50,6 +51,7 @@ export class FilePanel { readonly dialogs = inject(DialogsService); private readonly menu = inject(ContextMenuService); private readonly settings = inject(SettingsService); + private readonly shortcuts = inject(ShortcutsService); private readonly rpc = inject(RpcService); readonly panel = computed(() => this.panels.get(this.side())); readonly isActive = computed(() => this.panels.activeSide() === this.side()); @@ -220,7 +222,7 @@ export class FilePanel { const panel = this.panel(); if (event.shiftKey) { panel.selectRange(this.lastClickedIndex, index); - } else if (event.metaKey || event.ctrlKey) { + } else if (this.shortcuts.isMod(event)) { const entry = panel.sorted()[index]; if (entry) panel.toggleSelect(entry.name); } else { @@ -266,7 +268,7 @@ export class FilePanel { this.panels.setActiveSide(this.panels.otherSide(this.side())); return; } - if ((event.metaKey || event.ctrlKey) && key.toLowerCase() === 'a') { + if (this.shortcuts.isMod(event) && key.toLowerCase() === 'a') { event.preventDefault(); panel.selectAll(); return;