Remap any shortcut in Settings → Keyboard; the macOS menu bar follows

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>
This commit is contained in:
2026-10-05 14:57:27 +02:00
co-authored by Claude
parent d361297690
commit 8f8f230711
18 changed files with 1256 additions and 50 deletions
@@ -15,10 +15,16 @@
<div class="flex min-h-0 flex-1">
<!-- section list -->
<nav class="flex w-44 shrink-0 flex-col gap-0.5 border-r border-border bg-chrome p-2">
<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"
@@ -266,6 +272,171 @@
</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') {