Skip to content

Commit 2cbe3ce

Browse files
authored
fix(hotkeys): bind Delete to the backspace key on Linux, as on macOS and Windows (#1624)
1 parent ee1260c commit 2cbe3ce

8 files changed

Lines changed: 363 additions & 23 deletions

File tree

docs/CONFIGURATION.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,9 @@ right either way.
359359
| Navigation | `Up`, `Down`, `Left`, `Right`, `Home`, `End`, `PageUp`, `PageDown`, `Insert` (Linux and Windows only) |
360360
| Function | `F1``F24` (`F21``F24` on Linux and Windows only) |
361361

362+
`Delete` and `Backspace` both name the backspace key, the one that erases to the
363+
left, on every platform. The forward-delete key has no hotkey name.
364+
362365
See [CLI.md](CLI.md#neru-action-feed) for a full key reference with key codes and platform behavior.
363366

364367
Multi-key sequences (e.g. `gg`, `ab`) are supported for per-mode hotkeys with a 500ms timeout.

internal/adapter/eventtap/linux/global_hotkey_cgo.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ func NewGlobalHotkeyListener(logger *zap.Logger) *GlobalHotkeyListener {
4242
// press when the chord's key goes down, release (nil for none) when it comes
4343
// up. Safe to call before or after Start.
4444
func (l *GlobalHotkeyListener) SetBinding(chord string, press, release func()) {
45-
signature := canonicalChordSignature(chord)
45+
signature := canonicalBindingSignature(chord)
4646
if signature == "" || press == nil {
4747
return
4848
}

internal/adapter/eventtap/linux/global_hotkey_keys.go

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,36 @@ const (
1919
canonicalKeyTab = "tab"
2020
canonicalKeyEscape = "escape"
2121
canonicalKeyBackspace = "backspace"
22+
23+
// canonicalKeyDelete is the spelling a configured "Delete" arrives as. It
24+
// is a binding-side name only: canonicalBindingBaseKey folds it to
25+
// canonicalKeyBackspace, so no stored chord ever carries it.
26+
canonicalKeyDelete = "delete"
2227
)
2328

29+
// canonicalBindingSignature is canonicalChordSignature for a chord read from
30+
// the config, the side that can spell "Delete". In [hotkeys] that name means
31+
// the backspace key on every platform — kVK_Delete on macOS, VK_BACK on
32+
// Windows, XK_BackSpace on X11 — so it is folded to the signature a press of
33+
// that key produces. A live press never takes this fold: the forward-delete
34+
// key keeps the "delete" signature, which no configured chord can carry, and
35+
// so matches nothing, as it does on the other platforms.
36+
func canonicalBindingSignature(chord string) string {
37+
return chordSignature(chord, canonicalBindingBaseKey)
38+
}
39+
2440
// canonicalChordSignature normalizes a chord such as "Ctrl+Shift+G" or the
2541
// evdev-decoded "Shift+Ctrl+g" into a stable signature like "ctrl+shift+g":
2642
// modifiers lowercased, de-duplicated and sorted, base key lowercased/normalized.
2743
// This lets the config side and the live keyboard side match regardless of the
2844
// order or casing each produced.
2945
func canonicalChordSignature(chord string) string {
46+
return chordSignature(chord, canonicalBaseKey)
47+
}
48+
49+
// chordSignature is the normalization both signatures share; baseKey is how
50+
// the non-modifier key is spelled.
51+
func chordSignature(chord string, baseKey func(string) string) string {
3052
chord = strings.TrimSpace(chord)
3153
if chord == "" {
3254
return ""
@@ -37,7 +59,7 @@ func canonicalChordSignature(chord string) string {
3759
return ""
3860
}
3961

40-
base := canonicalBaseKey(parts[len(parts)-1])
62+
base := baseKey(parts[len(parts)-1])
4163
if base == "" {
4264
return ""
4365
}
@@ -82,6 +104,17 @@ func canonicalModifierToken(token string) string {
82104
}
83105
}
84106

107+
// canonicalBindingBaseKey is canonicalBaseKey plus the fold
108+
// canonicalBindingSignature describes: "Delete" names the backspace key.
109+
func canonicalBindingBaseKey(base string) string {
110+
switch strings.ToLower(strings.TrimSpace(base)) {
111+
case canonicalKeyDelete:
112+
return canonicalKeyBackspace
113+
default:
114+
return canonicalBaseKey(base)
115+
}
116+
}
117+
85118
// canonicalBaseKey normalizes the non-modifier key. Single characters are
86119
// lowercased; common named keys are folded to one spelling.
87120
func canonicalBaseKey(base string) string {

internal/adapter/eventtap/linux/global_hotkey_keys_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,42 @@ func TestCanonicalChordSignatureMatchesAcrossSides(t *testing.T) {
4242
t.Fatal("config and live spellings of Ctrl+Shift+G do not match")
4343
}
4444
}
45+
46+
// TestCanonicalBindingSignature_DeleteNamesTheBackspaceKey pins what a
47+
// configured "Delete" binds on the Wayland listener: the backspace key, as on
48+
// macOS and Windows and the X11 grab. The config side folds the name, and the
49+
// live side does not, so a press of the forward-delete key — which the
50+
// decoder also spells "Delete" — keeps a signature no configured chord carries.
51+
func TestCanonicalBindingSignature_DeleteNamesTheBackspaceKey(t *testing.T) {
52+
const wantModifiedDelete = "ctrl+shift+" + canonicalKeyBackspace
53+
54+
cases := []struct {
55+
name string
56+
in string
57+
want string
58+
}{
59+
{"config delete", evdevKeyNameDelete, canonicalKeyBackspace},
60+
{"config delete with modifiers", "Ctrl+Shift+Delete", wantModifiedDelete},
61+
{"config backspace", evdevKeyNameBackspace, canonicalKeyBackspace},
62+
}
63+
64+
for _, tc := range cases {
65+
t.Run(tc.name, func(t *testing.T) {
66+
if got := canonicalBindingSignature(tc.in); got != tc.want {
67+
t.Fatalf("canonicalBindingSignature(%q) = %q, want %q", tc.in, got, tc.want)
68+
}
69+
})
70+
}
71+
72+
live := canonicalChordSignature(evdevKeyNameDelete)
73+
if live == canonicalBindingSignature(evdevKeyNameDelete) {
74+
t.Fatalf(
75+
"a live forward-delete press canonicalizes to %q, the signature a configured Delete binds",
76+
live,
77+
)
78+
}
79+
80+
if got := canonicalChordSignature(evdevKeyNameBackspace); got != canonicalKeyBackspace {
81+
t.Fatalf("live backspace press canonicalizes to %q, want %q", got, canonicalKeyBackspace)
82+
}
83+
}

internal/adapter/hotkeys/linux/x11_cgo.go

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -398,11 +398,14 @@ func parseX11Hotkey(display *C.Display, keyString string) (C.uint, C.uint, error
398398
// coincidence of spelling, not a contract.
399399
//
400400
// "Backspace" is the same story with a sharper edge: X11 spells it "BackSpace",
401-
// so it resolved through neither path, and the repair that suggests itself —
402-
// folding the name through the vocabulary's aliases — grabs the wrong physical
403-
// key, for the reason x11CanonicalKeyName below sets out. Delete and Insert are
404-
// mapped alongside it rather than left to their matching spellings, so a reader
405-
// sees the three editing keys and their distinct keysyms in one place.
401+
// so it resolved through neither path. "Delete" reaches the same keysym on
402+
// purpose: in [hotkeys] the name means the backspace key on every platform —
403+
// kVK_Delete on macOS, VK_BACK on Windows — and X11's own "Delete", the
404+
// forward-delete key, is what a config file would have grabbed here alone.
405+
// The forward-delete key has no hotkey name; one that all three platforms can
406+
// bind is the way to add it, not a Linux-only meaning for this one. Insert is
407+
// mapped alongside the pair rather than left to its matching spelling, so a
408+
// reader sees the editing keys in one place.
406409
//
407410
// The rest fall through to XStringToKeysym: punctuation, and F1-F24, where
408411
// X11's own name for the key is the name Neru writes. Those are pinned by test
@@ -429,10 +432,8 @@ func x11KeysymFor(key string) C.KeySym {
429432
return C.XK_Tab
430433
case keyvocab.KeyEscape:
431434
return C.XK_Escape
432-
case keyvocab.KeyBackspace:
435+
case keyvocab.KeyBackspace, keyvocab.KeyDelete:
433436
return C.XK_BackSpace
434-
case keyvocab.KeyDelete:
435-
return C.XK_Delete
436437
case keyvocab.KeyInsert:
437438
return C.XK_Insert
438439
case keyvocab.KeyUp:
@@ -468,12 +469,13 @@ func x11KeysymFor(key string) C.KeySym {
468469
// strings reaching this adapter were already canonicalized — config's
469470
// CanonicalHotkeyForPlatform display-cases the base key without folding
470471
// aliases — and it is what a grab needs: a grab names a physical key, and the
471-
// vocabulary's aliases cross keys that X11 keeps apart. Folding "Backspace" to
472-
// "Delete" the way the taps do would resolve XK_Delete and grab the
473-
// forward-delete key for a binding written "Backspace". So a named key keeps
474-
// its own spelling here — "Enter" does not become "Return", though both are
475-
// mapped above — and only "esc", which the vocabulary deliberately keeps out of
476-
// the named-key set, resolves through its alias.
472+
// switch above is where each name is given one. Folding "Backspace" to
473+
// "Delete" the way the taps do and then handing "Delete" to XStringToKeysym
474+
// is how a binding written "Backspace" once grabbed the forward-delete key.
475+
// So a named key keeps its own spelling here — "Enter" does not become
476+
// "Return", though both are mapped above — and only "esc", which the
477+
// vocabulary deliberately keeps out of the named-key set, resolves through
478+
// its alias.
477479
func x11CanonicalKeyName(key string) string {
478480
if display, isNamed := keyvocab.NamedKeyDisplay(key); isNamed {
479481
return display

internal/adapter/hotkeys/linux/x11_keysym_test.go

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ const (
3333
x11KeysymInsert = 0xFF63 // XK_Insert
3434
x11KeysymF1 = 0xFFBE // XK_F1
3535
x11KeysymF24 = 0xFFD5 // XK_F24
36-
x11KeysymDelete = 0xFFFF // XK_Delete
3736
x11KeysymJ = 0x006A // XK_j
3837
)
3938

@@ -73,10 +72,10 @@ func TestX11KeysymFor_NavigationKeys(t *testing.T) {
7372
// TestX11KeysymFor_EditingKeys pins Backspace, Delete and Insert, and pins them
7473
// together because the first two are the pair a hotkey can get wrong without
7574
// noticing. X11 spells the erase-left key "BackSpace", so Neru's "Backspace"
76-
// resolves only when the lookup maps it; and the vocabulary makes "Backspace"
77-
// an alias of "Delete", so a lookup that folded aliases would grab X11's
78-
// forward-delete key instead. Both spellings must reach their own keysym, and
79-
// neither may reach the other's.
75+
// resolves only when the lookup maps it; and X11 spells the forward-delete key
76+
// "Delete", so Neru's "Delete" — the backspace key, as on macOS and Windows —
77+
// resolves to the right key only when the lookup maps it too. Both spellings
78+
// must reach XK_BackSpace, and neither may reach XK_Delete.
8079
func TestX11KeysymFor_EditingKeys(t *testing.T) {
8180
t.Parallel()
8281

@@ -87,8 +86,8 @@ func TestX11KeysymFor_EditingKeys(t *testing.T) {
8786
}{
8887
{name: "backspace", key: keyvocab.KeyBackspace, want: x11KeysymBackSpace},
8988
{name: "lowercased backspace", key: "backspace", want: x11KeysymBackSpace},
90-
{name: "delete", key: keyvocab.KeyDelete, want: x11KeysymDelete},
91-
{name: "lowercased delete", key: "delete", want: x11KeysymDelete},
89+
{name: "delete", key: keyvocab.KeyDelete, want: x11KeysymBackSpace},
90+
{name: "lowercased delete", key: "delete", want: x11KeysymBackSpace},
9291
{name: "insert", key: keyvocab.KeyInsert, want: x11KeysymInsert},
9392
{name: "lowercased insert", key: "insert", want: x11KeysymInsert},
9493
}

internal/architecture/doc.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@
4141
// by naming it, and every test name or test file it names resolves.
4242
// - hint_placement_vocabulary_test.go — the hint placement vocabulary is the
4343
// same on both sides of the Go/Objective-C boundary.
44+
// - hotkey_delete_key_test.go — every platform's [hotkeys] table resolves
45+
// "Delete" and "Backspace" to one physical key, the backspace key, so a
46+
// config file binds the same key on all three.
4447
// - justfile_doc_test.go — every recipe just --list shows declares its own
4548
// one-line summary, rather than inheriting whichever line of the comment
4649
// block above it just happens to read.

0 commit comments

Comments
 (0)