import { Component, ElementRef, afterNextRender, computed, inject, signal, viewChild } from '@angular/core'; import { NgIcon } from '@ng-icons/core'; import { BRANDING } from '@shared/branding'; import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n'; import { type Combo, comboFromEvent, conflicts, firesInTextField, GROUPS, isReserved, ownerOf, type ResolvedBinding, sameCombo, type ShortcutId, typesCharacter, } from '@shared/keymap'; import { COLUMN_KEYS } from '@core/context-menu.service'; import { cycleFocus } from '@core/focus-cycle'; import { I18nService } from '@core/i18n.service'; import { blockNativeContextMenu, blockReloadKeys, IS_MAC, IS_MAC_CHROME } from '@core/platform'; import { RpcService } from '@core/rpc.service'; import { SettingsService } from '@core/settings.service'; import { ShortcutsService } from '@core/shortcuts.service'; const SECTIONS = ['appearance', 'transfers', 'editor', 'fileList', 'keyboard', 'about'] as const; type Section = (typeof SECTIONS)[number]; const THEMES = ['system', 'light', 'dark'] as const; const DARK_TONES = ['dark', 'darker'] as const; const SPEED_UNITS = [ { value: 'MBps', label: 'MB/s' }, { value: 'Mbps', label: 'Mbps' }, ] as const; const TRANSFER_MODES = ['single', 'multi'] as const; const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const; /** The keys no remap moves, listed read-only under the editable ones. */ const FIXED_LINES = ['escape', 'escList', 'dialogs', 'menus', 'filter', 'shift', 'find', 'manager', 'sidebar'] as const; /** A recording that could not simply be added: explained under its row. */ interface CaptureNote { id: ShortcutId; kind: 'reserved' | 'needsModifier' | 'conflict'; combo: Combo; /** conflict: the command that already owns the combo. */ owner?: ShortcutId; } /** * Root of the Settings window (APP_MODE 'settings'). Every control is a plain * SettingsService setter: the save goes to bun, which rebroadcasts it, so the * other windows follow live while this one stays open — keyboard remaps * included (menu hints, tooltips and the macOS menu bar follow at once). */ @Component({ selector: 'app-root', imports: [NgIcon], templateUrl: './settings-window.html', host: { '(window:keydown)': 'onWindowKeydown($event)', '(window:contextmenu)': 'blockContextMenu($event)', '(window:mousedown)': 'onWindowMousedown($event)', '(window:blur)': 'capturing.set(null)', }, }) export class SettingsWindow { readonly settings = inject(SettingsService); readonly i18n = inject(I18nService); readonly shortcuts = inject(ShortcutsService); private readonly rpc = inject(RpcService); readonly isMacChrome = IS_MAC_CHROME; readonly sections = SECTIONS; readonly section = signal
('appearance'); readonly localeOptions = SUPPORTED; readonly localeLabels = LOCALE_LABELS; readonly themes = THEMES; readonly darkTones = DARK_TONES; readonly speedUnits = SPEED_UNITS; readonly transferModes = TRANSFER_MODES; readonly concurrencyChoices = CONCURRENCY_CHOICES; readonly columnKeys = COLUMN_KEYS; readonly fixedLines = FIXED_LINES; readonly appName = BRANDING.appName; readonly buildLabel = BRANDING.buildLabel; /** "100%" for the stepper readout; the value itself lives in settings. */ readonly scalePercent = computed(() => `${Math.round(this.settings.uiScale() * 100)}%`); private readonly nav = viewChild.required>('nav'); // ---- Settings → Keyboard ---- readonly keyFilter = signal(''); /** The command being recorded, if any. */ readonly capturing = signal(null); readonly note = signal(null); readonly confirmResetAll = signal(false); readonly anyCustom = computed(() => this.shortcuts.keymap().some((b) => b.custom)); private readonly conflictMap = computed(() => conflicts(this.shortcuts.keymap())); /** The editable rows by group, narrowed by the search field (label or keys). */ readonly keyGroups = computed(() => { const query = this.keyFilter().trim().toLowerCase(); const keymap = this.shortcuts.keymap(); return GROUPS.map((group) => ({ id: group, rows: keymap.filter((b) => b.group === group && (!query || this.rowText(b).includes(query))), })).filter((group) => group.rows.length); }); /** The fixed keys, in this platform's spelling, for the read-only lines. */ readonly fixedKeys = computed(() => ({ esc: this.shortcuts.fixedKey('Escape'), enter: this.shortcuts.fixedKey('Enter'), shiftEnter: this.shortcuts.fixedKey('Shift+Enter'), tab: this.shortcuts.fixedKey('Tab'), shiftTab: this.shortcuts.fixedKey('Shift+Tab'), backspace: this.shortcuts.fixedKey('Backspace'), home: this.shortcuts.fixedKey('Home'), end: this.shortcuts.fixedKey('End'), })); constructor() { // the keyboard starts on the current section afterNextRender(() => this.sectionButton(this.section())?.focus()); } /** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */ editorAppName(): string { const path = this.settings.editorApp(); if (!path) return this.i18n.t('options.systemDefault'); const base = path.split('/').filter(Boolean).pop() ?? path; return base.replace(/\.app$/i, ''); } async pickEditorApp(): Promise { const res = await this.rpc.request.pickEditorApp({}); if (res.ok && res.path) this.settings.setEditorApp(res.path); } showAbout(): void { void this.rpc.request.showAbout({}); } // ---- sections ---- /** ↑ / ↓ in the sidebar move between sections (and focus follows). */ onNavKeydown(event: KeyboardEvent): void { const step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; if (!step) return; event.preventDefault(); const next = SECTIONS[(SECTIONS.indexOf(this.section()) + step + SECTIONS.length) % SECTIONS.length]; this.section.set(next); this.sectionButton(next)?.focus(); } private sectionButton(section: Section): HTMLElement | null { return this.nav().nativeElement.querySelector(`[data-section="${section}"]`); } // ---- keyboard remaps ---- rowLabel(binding: ResolvedBinding): string { return binding.n ? this.i18n.t('keys.go-to-tab', { n: binding.n }) : this.i18n.t(`keys.${binding.id}`); } private labelOf(id: ShortcutId): string { const binding = this.shortcuts.binding(id); return binding ? this.rowLabel(binding) : id; } private rowText(binding: ResolvedBinding): string { return [this.rowLabel(binding), ...binding.combos.map((c) => this.shortcuts.format(c))] .join(' ') .toLowerCase(); } /** "Rename, Edit" when this row shares a key with others that can collide. */ conflictWith(binding: ResolvedBinding): string { return (this.conflictMap().get(binding.id) ?? []).map((id) => this.labelOf(id)).join(', '); } /** A plain character key on a file-list row: typing it no longer filters. */ filterKey(binding: ResolvedBinding): string { if (!binding.scopes.some((s) => s === 'panel' || s === 'main')) return ''; const plain = binding.combos.find(typesCharacter); return plain ? this.shortcuts.format(plain) : ''; } noteText(note: CaptureNote): string { const key = this.shortcuts.format(note.combo); if (note.kind === 'reserved') return this.i18n.t('keys.ui.reserved', { key }); if (note.kind === 'needsModifier') return this.i18n.t('keys.ui.needsModifier', { key }); return this.i18n.t('keys.ui.usedBy', { key, action: note.owner ? this.labelOf(note.owner) : '' }); } startCapture(id: ShortcutId): void { this.note.set(null); this.capturing.set(this.capturing() === id ? null : id); } /** * One keydown while recording, taken before every other key in this window * (⌘W records instead of closing). A bare modifier keeps waiting; Esc on its * own cancels. Reserved combos are refused WITHOUT preventDefault, so ⌘Q * still quits and ⌘C still copies. */ private record(event: KeyboardEvent, id: ShortcutId): void { const combo = comboFromEvent(event, IS_MAC); if (combo && isReserved(combo, IS_MAC)) { this.capturing.set(null); this.note.set({ id, kind: 'reserved', combo }); return; } event.preventDefault(); event.stopPropagation(); if (event.repeat || !combo) return; const bare = !combo.mod && !combo.ctrl && !combo.alt && !combo.shift; this.capturing.set(null); if (bare && combo.key === 'Escape') return; const binding = this.shortcuts.binding(id); if (!binding || binding.combos.some((c) => sameCombo(c, combo))) return; // the editor's text area always has focus: a plain key could never fire there if (binding.scopes.includes('editor') && !firesInTextField(combo, IS_MAC)) { this.note.set({ id, kind: 'needsModifier', combo }); return; } const owner = ownerOf(this.shortcuts.keymap(), combo, binding.scopes, id); if (owner) { this.note.set({ id, kind: 'conflict', combo, owner: owner.id }); return; } this.shortcuts.rebind({ [id]: [...binding.combos, combo] }); } /** "Replace": the key moves from the command that had it to this one. */ replaceConflict(): void { const note = this.note(); const binding = note && this.shortcuts.binding(note.id); const owner = note?.owner && this.shortcuts.binding(note.owner); if (!note || !binding || !owner) return; this.shortcuts.rebind({ [note.id]: [...binding.combos, note.combo], [owner.id]: owner.combos.filter((c) => !sameCombo(c, note.combo)), }); this.note.set(null); } removeCombo(binding: ResolvedBinding, index: number): void { this.shortcuts.rebind({ [binding.id]: binding.combos.filter((_, i) => i !== index) }); } resetRow(binding: ResolvedBinding): void { this.shortcuts.rebind({ [binding.id]: binding.defaults }); if (this.note()?.id === binding.id) this.note.set(null); } resetAll(): void { this.shortcuts.resetAll(); this.confirmResetAll.set(false); this.note.set(null); } // ---- window ---- /** Recording first; then this window's keys (close-window, the scale) and * Tab, walked by hand — WKWebView skips buttons on Tab otherwise. */ onWindowKeydown(event: KeyboardEvent): void { const recording = this.capturing(); if (recording) { this.record(event, recording); return; } const id = this.shortcuts.claim(event, 'settings'); if (id === 'close-window') void this.rpc.request.settingsClose({}); else if (id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1); else if (id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1); else if (id === 'scale-reset') this.settings.setUiScale(1); else if (!id) cycleFocus(document.body, event); blockReloadKeys(event); } /** A click anywhere but a record button ends a recording. */ onWindowMousedown(event: MouseEvent): void { if (this.capturing() && !(event.target as Element).closest('[data-record]')) { this.capturing.set(null); } } blockContextMenu(event: Event): void { blockNativeContextMenu(event); } }