A Keyboard section in the Settings window edits the shared keymap: rows by group with a search field, a chip per key (x removes), + to record, reset per row and Reset all. Recording takes the keydown before every other key in the window (Cmd+W records instead of closing), refuses reserved combos without preventDefault (Cmd+Q still quits), requires a chord or F-key for editor commands, and offers Replace when another command in an overlapping scope owns the key. Rows flag conflicts and plain character keys that would stop starting a type-to-filter query. Fixed keys are listed read-only, in the platform's spelling. Remaps are ordinary settings (only rows that differ from the defaults), so every window, the menu hints and the tooltips follow live — and the macOS menu bar: its accelerators are built from the keymap (only the combos Electrobun's parser expresses faithfully) and rebuilt on a remap. A main-window menu action fired from the editor or Settings window brings the main window forward first. Also: the "Enter / double-click on a file" setting under File list (do nothing / open local files in their default app), arrow keys in the Settings sidebar with focus on open and a hand-walked Tab, translations for all seven languages (the spec now checks every language labels every command), and a README shortcut table generated from the keymap. Co-Authored-By: Claude <noreply@anthropic.com>
456 lines
21 KiB
HTML
456 lines
21 KiB
HTML
<div class="flex h-screen flex-col bg-background text-foreground">
|
|
<!-- header: on macOS this row IS the titlebar (hiddenInset + drag region),
|
|
elsewhere the native titlebar sits above and this is a compact strip -->
|
|
<header
|
|
class="flex shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2"
|
|
[class.h-10]="isMacChrome"
|
|
[class.pl-24]="isMacChrome"
|
|
[class.electrobun-webkit-app-region-drag]="isMacChrome"
|
|
[class.h-9]="!isMacChrome"
|
|
[class.pl-3]="!isMacChrome"
|
|
>
|
|
<ng-icon name="tdesignSetting" size="0.95rem" class="shrink-0 text-muted-foreground" />
|
|
<h1 class="text-sm font-semibold">{{ i18n.t('options.title') }}</h1>
|
|
</header>
|
|
|
|
<div class="flex min-h-0 flex-1">
|
|
<!-- section list -->
|
|
<nav
|
|
#nav
|
|
(keydown)="onNavKeydown($event)"
|
|
class="flex w-44 shrink-0 flex-col gap-0.5 border-r border-border bg-chrome p-2"
|
|
>
|
|
@for (s of sections; track s) {
|
|
<button
|
|
(click)="section.set(s)"
|
|
[attr.data-section]="s"
|
|
[attr.aria-current]="section() === s ? 'page' : null"
|
|
class="px-2.5 py-1.5 text-left text-xs hover:bg-accent hover:text-foreground"
|
|
[class.bg-accent]="section() === s"
|
|
[class.font-medium]="section() === s"
|
|
[class.text-foreground]="section() === s"
|
|
[class.text-muted-foreground]="section() !== s"
|
|
>
|
|
{{ i18n.t('options.sections.' + s) }}
|
|
</button>
|
|
}
|
|
</nav>
|
|
|
|
<!-- the chosen section's rows -->
|
|
<main class="min-w-0 flex-1 overflow-y-auto px-6 py-5">
|
|
<h2 class="text-sm font-semibold">{{ i18n.t('options.sections.' + section()) }}</h2>
|
|
<div class="mt-4 flex max-w-md flex-col gap-3">
|
|
@switch (section()) {
|
|
@case ('appearance') {
|
|
<div class="flex items-center justify-between gap-2">
|
|
<span class="text-xs">{{ i18n.t('options.language') }}</span>
|
|
<select
|
|
(change)="settings.setLocale($any($event.target).value)"
|
|
class="h-7 border border-input bg-background pl-1.5 text-[0.6875rem] outline-none focus:ring-2 focus:ring-ring"
|
|
>
|
|
<option value="system" [selected]="settings.locale() === 'system'">
|
|
{{ i18n.t('options.system') }}
|
|
</option>
|
|
@for (loc of localeOptions; track loc) {
|
|
<option [value]="loc" [selected]="settings.locale() === loc">
|
|
{{ localeLabels[loc] }}
|
|
</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between gap-2">
|
|
<span class="text-xs">{{ i18n.t('options.theme.label') }}</span>
|
|
<div class="flex border border-border p-0.5">
|
|
@for (theme of themes; track theme) {
|
|
<button
|
|
(click)="settings.setTheme(theme)"
|
|
class="px-2 py-0.5 text-[0.6875rem]"
|
|
[class.bg-accent]="settings.theme() === theme"
|
|
[class.text-foreground]="settings.theme() === theme"
|
|
[class.text-muted-foreground]="settings.theme() !== theme"
|
|
>
|
|
{{ i18n.t('options.theme.' + theme) }}
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
@if (settings.effectiveDark()) {
|
|
<div class="flex items-center justify-between gap-2">
|
|
<span class="text-xs">{{ i18n.t('options.tone.label') }}</span>
|
|
<div class="flex border border-border p-0.5">
|
|
@for (tone of darkTones; track tone) {
|
|
<button
|
|
(click)="settings.setDarkTone(tone)"
|
|
class="px-2 py-0.5 text-[0.6875rem]"
|
|
[class.bg-accent]="settings.darkTone() === tone"
|
|
[class.text-foreground]="settings.darkTone() === tone"
|
|
[class.text-muted-foreground]="settings.darkTone() !== tone"
|
|
>
|
|
{{ i18n.t('options.tone.' + tone) }}
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<!-- Interface scale: a continuous stepper, 50%..400% in 10-point
|
|
steps. One lever moves the whole UI (root font-size; everything
|
|
is rem), this window included. -->
|
|
<div class="flex items-center justify-between gap-2">
|
|
<span class="text-xs">{{ i18n.t('options.interface.label') }}</span>
|
|
<div class="flex items-center border border-border p-0.5">
|
|
<button
|
|
(click)="settings.setUiScale(settings.uiScale() - 0.1)"
|
|
[disabled]="settings.uiScale() <= 0.5"
|
|
[title]="i18n.t('options.interface.smaller')"
|
|
class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
|
|
>
|
|
−
|
|
</button>
|
|
<button
|
|
(click)="settings.setUiScale(1)"
|
|
[title]="i18n.t('options.interface.reset')"
|
|
class="w-11 px-1 py-0.5 text-center text-[0.6875rem] tabular-nums hover:bg-accent"
|
|
>
|
|
{{ scalePercent() }}
|
|
</button>
|
|
<button
|
|
(click)="settings.setUiScale(settings.uiScale() + 0.1)"
|
|
[disabled]="settings.uiScale() >= 4"
|
|
[title]="i18n.t('options.interface.larger')"
|
|
class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
|
|
>
|
|
+
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@case ('transfers') {
|
|
<div class="flex items-center justify-between gap-2">
|
|
<span class="text-xs">{{ i18n.t('options.speedUnit.label') }}</span>
|
|
<div class="flex border border-border p-0.5">
|
|
@for (unit of speedUnits; track unit.value) {
|
|
<button
|
|
(click)="settings.setSpeedUnit(unit.value)"
|
|
class="px-2 py-0.5 text-[0.6875rem]"
|
|
[class.bg-accent]="settings.speedUnit() === unit.value"
|
|
[class.text-foreground]="settings.speedUnit() === unit.value"
|
|
[class.text-muted-foreground]="settings.speedUnit() !== unit.value"
|
|
>
|
|
{{ unit.label }}
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between gap-2">
|
|
<span class="text-xs">{{ i18n.t('options.transfers.label') }}</span>
|
|
<div class="flex border border-border p-0.5">
|
|
@for (mode of transferModes; track mode) {
|
|
<button
|
|
(click)="settings.setTransferMode(mode)"
|
|
class="px-2 py-0.5 text-[0.6875rem]"
|
|
[class.bg-accent]="settings.transferMode() === mode"
|
|
[class.text-foreground]="settings.transferMode() === mode"
|
|
[class.text-muted-foreground]="settings.transferMode() !== mode"
|
|
>
|
|
{{ i18n.t('options.transfers.' + mode) }}
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
@if (settings.transferMode() === 'multi') {
|
|
<div class="flex items-center justify-between gap-2 pl-3">
|
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.atOnce') }}</span>
|
|
<div class="flex border border-border p-0.5">
|
|
@for (count of concurrencyChoices; track count) {
|
|
<button
|
|
(click)="settings.setTransferConcurrency(count)"
|
|
class="px-2 py-0.5 text-[0.6875rem] tabular-nums"
|
|
[class.bg-accent]="settings.transferConcurrency() === count"
|
|
[class.text-foreground]="settings.transferConcurrency() === count"
|
|
[class.text-muted-foreground]="settings.transferConcurrency() !== count"
|
|
>
|
|
{{ count }}
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<div class="flex items-center justify-between gap-2">
|
|
<span class="text-xs">{{ i18n.t('options.finishedTransfers.label') }}</span>
|
|
<div class="flex border border-border p-0.5">
|
|
<button
|
|
(click)="settings.setAutoClearCompleted(true)"
|
|
class="px-2 py-0.5 text-[0.6875rem]"
|
|
[class.bg-accent]="settings.autoClearCompleted()"
|
|
[class.text-foreground]="settings.autoClearCompleted()"
|
|
[class.text-muted-foreground]="!settings.autoClearCompleted()"
|
|
>
|
|
{{ i18n.t('options.finishedTransfers.autoClear') }}
|
|
</button>
|
|
<button
|
|
(click)="settings.setAutoClearCompleted(false)"
|
|
class="px-2 py-0.5 text-[0.6875rem]"
|
|
[class.bg-accent]="!settings.autoClearCompleted()"
|
|
[class.text-foreground]="!settings.autoClearCompleted()"
|
|
[class.text-muted-foreground]="settings.autoClearCompleted()"
|
|
>
|
|
{{ i18n.t('options.finishedTransfers.keep') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@case ('editor') {
|
|
<div class="flex items-center justify-between gap-2">
|
|
<span class="text-xs">{{ i18n.t('options.editor.label') }}</span>
|
|
<div class="flex border border-border p-0.5">
|
|
<button
|
|
(click)="settings.setEditor('builtin')"
|
|
class="px-2 py-0.5 text-[0.6875rem]"
|
|
[class.bg-accent]="settings.editor() === 'builtin'"
|
|
[class.text-foreground]="settings.editor() === 'builtin'"
|
|
[class.text-muted-foreground]="settings.editor() !== 'builtin'"
|
|
>
|
|
{{ i18n.t('options.editor.builtin') }}
|
|
</button>
|
|
<button
|
|
(click)="settings.setEditor('custom')"
|
|
class="px-2 py-0.5 text-[0.6875rem]"
|
|
[class.bg-accent]="settings.editor() === 'custom'"
|
|
[class.text-foreground]="settings.editor() === 'custom'"
|
|
[class.text-muted-foreground]="settings.editor() !== 'custom'"
|
|
>
|
|
{{ i18n.t('options.editor.custom') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
@if (settings.editor() === 'custom') {
|
|
<div class="flex items-center justify-between gap-2 pl-3">
|
|
<span class="min-w-0 truncate text-xs text-muted-foreground" [title]="settings.editorApp()">
|
|
{{ editorAppName() }}
|
|
</span>
|
|
<div class="flex shrink-0 items-center gap-1">
|
|
<button
|
|
(click)="pickEditorApp()"
|
|
class="border border-border px-2 py-0.5 text-[0.6875rem] hover:bg-accent"
|
|
>
|
|
{{ i18n.t('common.choose') }}
|
|
</button>
|
|
@if (settings.editorApp()) {
|
|
<button
|
|
(click)="settings.setEditorApp('')"
|
|
[title]="i18n.t('options.resetDefault')"
|
|
class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
<ng-icon name="tdesignClose" size="0.7rem" />
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|
|
|
|
@case ('fileList') {
|
|
<span class="text-xs">{{ i18n.t('options.columns') }}</span>
|
|
<div class="flex flex-col gap-2 pl-3">
|
|
@for (key of columnKeys; track key) {
|
|
<label class="flex items-center gap-2 text-xs">
|
|
<input
|
|
type="checkbox"
|
|
[checked]="settings.columns().includes(key)"
|
|
(change)="settings.toggleColumn(key)"
|
|
/>
|
|
{{ i18n.t('columns.' + key) }}
|
|
</label>
|
|
}
|
|
</div>
|
|
|
|
<!-- Enter / double-click on a file: nothing (folders only) or open it -->
|
|
<div class="mt-2 flex items-center justify-between gap-2">
|
|
<span class="text-xs">{{ i18n.t('options.openFiles.label') }}</span>
|
|
<div class="flex border border-border p-0.5">
|
|
<button
|
|
(click)="settings.setOpenFilesOnEnter(false)"
|
|
class="px-2 py-0.5 text-[0.6875rem]"
|
|
[class.bg-accent]="!settings.openFilesOnEnter()"
|
|
[class.text-foreground]="!settings.openFilesOnEnter()"
|
|
[class.text-muted-foreground]="settings.openFilesOnEnter()"
|
|
>
|
|
{{ i18n.t('options.openFiles.nothing') }}
|
|
</button>
|
|
<button
|
|
(click)="settings.setOpenFilesOnEnter(true)"
|
|
class="px-2 py-0.5 text-[0.6875rem]"
|
|
[class.bg-accent]="settings.openFilesOnEnter()"
|
|
[class.text-foreground]="settings.openFilesOnEnter()"
|
|
[class.text-muted-foreground]="!settings.openFilesOnEnter()"
|
|
>
|
|
{{ i18n.t('options.openFiles.open') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<p class="-mt-1 pl-3 text-[0.6875rem] text-muted-foreground">{{ i18n.t('options.openFiles.hint') }}</p>
|
|
}
|
|
|
|
@case ('keyboard') {
|
|
<!-- search + reset all -->
|
|
<div class="flex items-center gap-2">
|
|
<div class="relative min-w-0 flex-1">
|
|
<ng-icon
|
|
name="tdesignSearch"
|
|
size="0.8rem"
|
|
class="pointer-events-none absolute top-1/2 left-2 -translate-y-1/2 text-muted-foreground"
|
|
/>
|
|
<input
|
|
[value]="keyFilter()"
|
|
(input)="keyFilter.set($any($event.target).value)"
|
|
[placeholder]="i18n.t('keys.ui.filter')"
|
|
spellcheck="false"
|
|
class="h-7 w-full border border-input bg-background pr-2 pl-7 text-xs outline-none focus:ring-2 focus:ring-ring"
|
|
/>
|
|
</div>
|
|
@if (confirmResetAll()) {
|
|
<button
|
|
(click)="resetAll()"
|
|
class="shrink-0 border border-destructive px-2 py-0.5 text-[0.6875rem] text-destructive hover:bg-destructive/10"
|
|
>
|
|
{{ i18n.t('keys.ui.resetAllConfirm') }}
|
|
</button>
|
|
<button
|
|
(click)="confirmResetAll.set(false)"
|
|
class="shrink-0 px-2 py-0.5 text-[0.6875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
{{ i18n.t('common.cancel') }}
|
|
</button>
|
|
} @else {
|
|
<button
|
|
(click)="confirmResetAll.set(true)"
|
|
[disabled]="!anyCustom()"
|
|
class="shrink-0 border border-border px-2 py-0.5 text-[0.6875rem] hover:bg-accent disabled:opacity-40 disabled:hover:bg-transparent"
|
|
>
|
|
{{ i18n.t('keys.ui.resetAll') }}
|
|
</button>
|
|
}
|
|
</div>
|
|
<p class="text-[0.6875rem] text-muted-foreground">{{ i18n.t('keys.ui.intro') }}</p>
|
|
|
|
@for (group of keyGroups(); track group.id) {
|
|
<h3 class="mt-1 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
|
|
{{ i18n.t('keys.groups.' + group.id) }}
|
|
</h3>
|
|
<div class="-mt-2 flex flex-col">
|
|
@for (binding of group.rows; track binding.id) {
|
|
<div class="flex min-h-7 items-center gap-1.5 border-b border-border/50 py-0.5 text-xs">
|
|
<span class="min-w-0 flex-1 truncate" [title]="rowLabel(binding)">{{ rowLabel(binding) }}</span>
|
|
@if (conflictWith(binding); as others) {
|
|
<span
|
|
class="grid size-4 shrink-0 place-items-center bg-destructive/15 text-[0.625rem] font-bold text-destructive"
|
|
[title]="i18n.t('keys.ui.conflict', { action: others })"
|
|
>!</span
|
|
>
|
|
}
|
|
<div class="flex shrink-0 flex-wrap justify-end gap-1">
|
|
@for (combo of binding.combos; track $index) {
|
|
<span class="flex items-center border border-border bg-muted pl-1.5 text-[0.6875rem] tabular-nums">
|
|
{{ shortcuts.format(combo) }}
|
|
<button
|
|
(click)="removeCombo(binding, $index)"
|
|
[attr.aria-label]="i18n.t('keys.ui.remove', { key: shortcuts.format(combo) })"
|
|
[title]="i18n.t('keys.ui.remove', { key: shortcuts.format(combo) })"
|
|
class="grid size-4 place-items-center text-muted-foreground hover:text-foreground"
|
|
>
|
|
<ng-icon name="tdesignClose" size="0.6rem" />
|
|
</button>
|
|
</span>
|
|
}
|
|
@if (capturing() === binding.id) {
|
|
<span class="border border-primary px-1.5 text-[0.6875rem] text-primary">
|
|
{{ i18n.t('keys.ui.press', { esc: fixedKeys().esc }) }}
|
|
</span>
|
|
} @else if (!binding.combos.length) {
|
|
<span class="px-1 text-[0.6875rem] text-muted-foreground">{{ i18n.t('keys.ui.unbound') }}</span>
|
|
}
|
|
</div>
|
|
<button
|
|
data-record
|
|
(click)="startCapture(binding.id)"
|
|
[attr.aria-label]="i18n.t('keys.ui.add')"
|
|
[title]="i18n.t('keys.ui.add')"
|
|
class="grid size-5 shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
[class.text-primary]="capturing() === binding.id"
|
|
>
|
|
<ng-icon name="tdesignAdd" size="0.75rem" />
|
|
</button>
|
|
<button
|
|
(click)="resetRow(binding)"
|
|
[disabled]="!binding.custom"
|
|
[attr.aria-label]="i18n.t('keys.ui.reset')"
|
|
[title]="i18n.t('keys.ui.reset')"
|
|
class="grid size-5 shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground disabled:invisible"
|
|
>
|
|
<ng-icon name="tdesignRefresh" size="0.7rem" />
|
|
</button>
|
|
</div>
|
|
@if (note()?.id === binding.id) {
|
|
<div class="flex items-center gap-2 py-1 pl-3 text-[0.6875rem] text-destructive">
|
|
<span class="min-w-0 flex-1">{{ noteText(note()!) }}</span>
|
|
@if (note()!.kind === 'conflict') {
|
|
<button
|
|
(click)="replaceConflict()"
|
|
class="shrink-0 border border-border px-2 py-0.5 text-foreground hover:bg-accent"
|
|
>
|
|
{{ i18n.t('keys.ui.replace') }}
|
|
</button>
|
|
}
|
|
<button
|
|
(click)="note.set(null)"
|
|
class="shrink-0 px-2 py-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
{{ i18n.t('common.cancel') }}
|
|
</button>
|
|
</div>
|
|
}
|
|
@if (filterKey(binding); as key) {
|
|
<p class="py-0.5 pl-3 text-[0.6875rem] text-muted-foreground">
|
|
{{ i18n.t('keys.ui.plainLetter', { key }) }}
|
|
</p>
|
|
}
|
|
}
|
|
</div>
|
|
} @empty {
|
|
<p class="text-xs text-muted-foreground">{{ i18n.t('keys.ui.noResults') }}</p>
|
|
}
|
|
|
|
<h3 class="mt-1 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
|
|
{{ i18n.t('keys.ui.fixed') }}
|
|
</h3>
|
|
<ul class="-mt-1 flex flex-col gap-1 text-[0.6875rem] text-muted-foreground">
|
|
@for (line of fixedLines; track line) {
|
|
<li>{{ i18n.t('keys.fixed.' + line, fixedKeys()) }}</li>
|
|
}
|
|
</ul>
|
|
}
|
|
|
|
@case ('about') {
|
|
<!-- the old popover footer: opens the native About box -->
|
|
<button
|
|
(click)="showAbout()"
|
|
class="self-start border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
{{ appName }} · {{ buildLabel }}
|
|
</button>
|
|
}
|
|
}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</div>
|