detached 3a2ff56

fix(keys): support non-qwerty alt digit bindings

commit
3a2ff56225123a3242aa9782be5d0736d8266526
author
brice · · 2026-07-03 21:23 UTC
parent
7cfdd26
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +160 -19
M internal/usecase/keys/router.go +74 -16
@@ -5,6 +5,7 @@ package keys
55 import (
66 "sync"
77 "time"
8+ "unicode/utf8"
89
910 "github.com/bnema/vev/internal/ports"
1011 )
@@ -48,6 +49,7 @@ type Router struct {
4849
4950 mu sync.Mutex
5051 pending bool
52+ pendingAlt []byte
5153 timer ports.Timer
5254 pendingDone chan struct{}
5355 }
@@ -64,10 +66,13 @@ func (r *Router) Route(data []byte) {
6466 r.mu.Lock()
6567 defer r.mu.Unlock()
6668 if r.pending {
69+ pendingAlt := r.pendingAlt
6770 r.stopTimer()
6871 r.pending = false
69- if consumed := r.routeAfterPendingESC(data); consumed > 0 {
70- data = data[consumed:]
72+ r.pendingAlt = nil
73+ combined := append(append([]byte(nil), pendingAlt...), data...)
74+ if consumed := r.routeAfterPendingESC(combined); consumed > len(pendingAlt) {
75+ data = data[consumed-len(pendingAlt):]
7176 }
7277 }
7378 r.route(data)
@@ -99,12 +104,18 @@ func (r *Router) route(data []byte) {
99104 i += 2
100105 continue
101106 }
102- if action, ok := binding(next); ok {
107+ remaining := data[i+1:]
108+ if action, size, ok := binding(remaining); ok {
103109 flush()
104110 r.h.Action(action)
105- i += 2
111+ i += 1 + size
106112 continue
107113 }
114+ if partialUTF8Rune(remaining) {
115+ flush()
116+ r.retainESC(remaining)
117+ return
118+ }
108119 buf = append(buf, ESC)
109120 i++
110121 }
@@ -117,16 +128,28 @@ func (r *Router) routeAfterPendingESC(data []byte) int {
117128 r.forward([]byte{ESC, next})
118129 return 1
119130 }
120- if action, ok := binding(next); ok {
131+ if action, size, ok := binding(data); ok {
121132 r.h.Action(action)
122- return 1
133+ return size
134+ }
135+ if partialUTF8Rune(data) {
136+ r.retainESC(data)
137+ return len(data)
138+ }
139+ if _, size := utf8.DecodeRune(data); size > 1 {
140+ r.forward(append([]byte{ESC}, data[:size]...))
141+ return size
123142 }
124143 r.forward([]byte{ESC})
125144 return 0
126145 }
127146
128-func (r *Router) retainESC() {
147+func (r *Router) retainESC(altBytes ...[]byte) {
129148 r.pending = true
149+ r.pendingAlt = nil
150+ if len(altBytes) > 0 {
151+ r.pendingAlt = append([]byte(nil), altBytes[0]...)
152+ }
130153 r.timer = r.clock.NewTimer(r.delay)
131154 r.pendingDone = make(chan struct{})
132155 go func(timer ports.Timer, done <-chan struct{}) {
@@ -139,7 +162,9 @@ func (r *Router) retainESC() {
139162 defer r.mu.Unlock()
140163 if r.pending && r.timer == timer {
141164 r.pending = false
142- r.forward([]byte{ESC})
165+ data := append([]byte{ESC}, r.pendingAlt...)
166+ r.pendingAlt = nil
167+ r.forward(data)
143168 }
144169 }(r.timer, r.pendingDone)
145170 }
@@ -155,6 +180,14 @@ func (r *Router) stopTimer() {
155180 }
156181 }
157182
183+func partialUTF8Rune(data []byte) bool {
184+ if len(data) == 0 || utf8.FullRune(data) {
185+ return false
186+ }
187+ key, size := utf8.DecodeRune(data)
188+ return key == utf8.RuneError && size == 1
189+}
190+
158191 func (r *Router) forward(data []byte) {
159192 cp := append([]byte(nil), data...)
160193 r.h.Forward(cp)
@@ -162,15 +195,40 @@ func (r *Router) forward(data []byte) {
162195
163196 func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }
164197
165-func binding(b byte) (Action, bool) {
166- switch b {
198+func binding(data []byte) (Action, int, bool) {
199+ if len(data) == 0 {
200+ return 0, 0, false
201+ }
202+ switch data[0] {
167203 case ' ':
168- return ActionOpenPalette, true
169- case '1', '2', '3', '4', '5', '6', '7', '8', '9':
170- return ActionSwitchTab1 + Action(b-'1'), true
204+ return ActionOpenPalette, 1, true
171205 case 'a':
172- return ActionJumpAttention, true
173- default:
174- return 0, false
206+ return ActionJumpAttention, 1, true
207+ }
208+
209+ key, size := utf8.DecodeRune(data)
210+ if key == utf8.RuneError && size == 1 {
211+ return 0, 0, false
212+ }
213+ if idx, ok := topRowDigitIndex(key); ok {
214+ return ActionSwitchTab1 + Action(idx), size, true
215+ }
216+ return 0, 0, false
217+}
218+
219+var azertyTopRowDigitVariants = []rune{'&', 'é', '"', '\'', '(', '-', 'è', '_', 'ç'}
220+
221+// topRowDigitIndex maps the symbols emitted by physical top-row digit keys to
222+// zero-based digit positions. It is modifier-agnostic so Alt+digit and future
223+// Ctrl+digit bindings can share the same layout support.
224+func topRowDigitIndex(key rune) (int, bool) {
225+ if key >= '1' && key <= '9' {
226+ return int(key - '1'), true
227+ }
228+ for idx, variant := range azertyTopRowDigitVariants {
229+ if key == variant {
230+ return idx, true
231+ }
175232 }
233+ return 0, false
176234 }
@@ -5,6 +5,7 @@ package keys
5 import (5 import (
6 "sync"6 "sync"
7 "time"7 "time"
8+ "unicode/utf8"
8 9
9 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
10 )11 )
@@ -48,6 +49,7 @@ type Router struct {
48 49
49 mu sync.Mutex50 mu sync.Mutex
50 pending bool51 pending bool
52+ pendingAlt []byte
51 timer ports.Timer53 timer ports.Timer
52 pendingDone chan struct{}54 pendingDone chan struct{}
53 }55 }
@@ -64,10 +66,13 @@ func (r *Router) Route(data []byte) {
64 r.mu.Lock()66 r.mu.Lock()
65 defer r.mu.Unlock()67 defer r.mu.Unlock()
66 if r.pending {68 if r.pending {
69+ pendingAlt := r.pendingAlt
67 r.stopTimer()70 r.stopTimer()
68 r.pending = false71 r.pending = false
69- if consumed := r.routeAfterPendingESC(data); consumed > 0 {72+ r.pendingAlt = nil
70- data = data[consumed:]73+ combined := append(append([]byte(nil), pendingAlt...), data...)
74+ if consumed := r.routeAfterPendingESC(combined); consumed > len(pendingAlt) {
75+ data = data[consumed-len(pendingAlt):]
71 }76 }
72 }77 }
73 r.route(data)78 r.route(data)
@@ -99,12 +104,18 @@ func (r *Router) route(data []byte) {
99 i += 2104 i += 2
100 continue105 continue
101 }106 }
102- if action, ok := binding(next); ok {107+ remaining := data[i+1:]
108+ if action, size, ok := binding(remaining); ok {
103 flush()109 flush()
104 r.h.Action(action)110 r.h.Action(action)
105- i += 2111+ i += 1 + size
106 continue112 continue
107 }113 }
114+ if partialUTF8Rune(remaining) {
115+ flush()
116+ r.retainESC(remaining)
117+ return
118+ }
108 buf = append(buf, ESC)119 buf = append(buf, ESC)
109 i++120 i++
110 }121 }
@@ -117,16 +128,28 @@ func (r *Router) routeAfterPendingESC(data []byte) int {
117 r.forward([]byte{ESC, next})128 r.forward([]byte{ESC, next})
118 return 1129 return 1
119 }130 }
120- if action, ok := binding(next); ok {131+ if action, size, ok := binding(data); ok {
121 r.h.Action(action)132 r.h.Action(action)
122- return 1133+ return size
134+ }
135+ if partialUTF8Rune(data) {
136+ r.retainESC(data)
137+ return len(data)
138+ }
139+ if _, size := utf8.DecodeRune(data); size > 1 {
140+ r.forward(append([]byte{ESC}, data[:size]...))
141+ return size
123 }142 }
124 r.forward([]byte{ESC})143 r.forward([]byte{ESC})
125 return 0144 return 0
126 }145 }
127 146
128-func (r *Router) retainESC() {147+func (r *Router) retainESC(altBytes ...[]byte) {
129 r.pending = true148 r.pending = true
149+ r.pendingAlt = nil
150+ if len(altBytes) > 0 {
151+ r.pendingAlt = append([]byte(nil), altBytes[0]...)
152+ }
130 r.timer = r.clock.NewTimer(r.delay)153 r.timer = r.clock.NewTimer(r.delay)
131 r.pendingDone = make(chan struct{})154 r.pendingDone = make(chan struct{})
132 go func(timer ports.Timer, done <-chan struct{}) {155 go func(timer ports.Timer, done <-chan struct{}) {
@@ -139,7 +162,9 @@ func (r *Router) retainESC() {
139 defer r.mu.Unlock()162 defer r.mu.Unlock()
140 if r.pending && r.timer == timer {163 if r.pending && r.timer == timer {
141 r.pending = false164 r.pending = false
142- r.forward([]byte{ESC})165+ data := append([]byte{ESC}, r.pendingAlt...)
166+ r.pendingAlt = nil
167+ r.forward(data)
143 }168 }
144 }(r.timer, r.pendingDone)169 }(r.timer, r.pendingDone)
145 }170 }
@@ -155,6 +180,14 @@ func (r *Router) stopTimer() {
155 }180 }
156 }181 }
157 182
183+func partialUTF8Rune(data []byte) bool {
184+ if len(data) == 0 || utf8.FullRune(data) {
185+ return false
186+ }
187+ key, size := utf8.DecodeRune(data)
188+ return key == utf8.RuneError && size == 1
189+}
190+
158 func (r *Router) forward(data []byte) {191 func (r *Router) forward(data []byte) {
159 cp := append([]byte(nil), data...)192 cp := append([]byte(nil), data...)
160 r.h.Forward(cp)193 r.h.Forward(cp)
@@ -162,15 +195,40 @@ func (r *Router) forward(data []byte) {
162 195
163 func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }196 func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }
164 197
165-func binding(b byte) (Action, bool) {198+func binding(data []byte) (Action, int, bool) {
166- switch b {199+ if len(data) == 0 {
200+ return 0, 0, false
201+ }
202+ switch data[0] {
167 case ' ':203 case ' ':
168- return ActionOpenPalette, true204+ return ActionOpenPalette, 1, true
169- case '1', '2', '3', '4', '5', '6', '7', '8', '9':
170- return ActionSwitchTab1 + Action(b-'1'), true
171 case 'a':205 case 'a':
172- return ActionJumpAttention, true206+ return ActionJumpAttention, 1, true
173- default:207+ }
174- return 0, false208+
209+ key, size := utf8.DecodeRune(data)
210+ if key == utf8.RuneError && size == 1 {
211+ return 0, 0, false
212+ }
213+ if idx, ok := topRowDigitIndex(key); ok {
214+ return ActionSwitchTab1 + Action(idx), size, true
215+ }
216+ return 0, 0, false
217+}
218+
219+var azertyTopRowDigitVariants = []rune{'&', 'é', '"', '\'', '(', '-', 'è', '_', 'ç'}
220+
221+// topRowDigitIndex maps the symbols emitted by physical top-row digit keys to
222+// zero-based digit positions. It is modifier-agnostic so Alt+digit and future
223+// Ctrl+digit bindings can share the same layout support.
224+func topRowDigitIndex(key rune) (int, bool) {
225+ if key >= '1' && key <= '9' {
226+ return int(key - '1'), true
227+ }
228+ for idx, variant := range azertyTopRowDigitVariants {
229+ if key == variant {
230+ return idx, true
231+ }
175 }232 }
233+ return 0, false
176 }234 }
M internal/usecase/keys/router_test.go +86 -3
@@ -61,12 +61,95 @@ func TestRouterInterceptsAltDigits(t *testing.T) {
6161 for b := byte('1'); b <= '9'; b++ {
6262 r.Route([]byte{ESC, b})
6363 }
64- require.Equal(t, []Action{
64+ require.Equal(t, switchTabActions(), h.actions)
65+ require.Empty(t, h.forwards)
66+}
67+
68+func TestRouterInterceptsAltAZERTYTopRowAsDigits(t *testing.T) {
69+ clk := &fakeClock{}
70+ h := &captureHandler{}
71+ r := NewRouter(clk, h)
72+ for _, key := range []string{"&", "é", "\"", "'", "(", "-", "è", "_", "ç"} {
73+ r.Route(append([]byte{ESC}, []byte(key)...))
74+ }
75+ require.Equal(t, switchTabActions(), h.actions)
76+ require.Empty(t, h.forwards)
77+}
78+
79+func TestRouterInterceptsAltAZERTYUTF8SplitAcrossReads(t *testing.T) {
80+ clk := &fakeClock{}
81+ h := &captureHandler{}
82+ r := NewRouter(clk, h)
83+
84+ r.Route([]byte{ESC, 0xc3})
85+ require.Empty(t, h.actions)
86+ require.Empty(t, h.forwards)
87+ require.Len(t, clk.timers, 1)
88+
89+ r.Route([]byte{0xa9})
90+ require.Equal(t, []Action{ActionSwitchTab2}, h.actions)
91+ require.Empty(t, h.forwards)
92+}
93+
94+func TestRouterInterceptsRetainedAltAZERTYUTF8SplitAcrossReads(t *testing.T) {
95+ clk := &fakeClock{}
96+ h := &captureHandler{}
97+ r := NewRouter(clk, h)
98+
99+ r.Route([]byte{ESC})
100+ r.Route([]byte{0xc3})
101+ require.Empty(t, h.actions)
102+ require.Empty(t, h.forwards)
103+ require.Len(t, clk.timers, 2)
104+
105+ r.Route([]byte{0xa9})
106+ require.Equal(t, []Action{ActionSwitchTab2}, h.actions)
107+ require.Empty(t, h.forwards)
108+}
109+
110+func TestRouterForwardsUnboundAltUTF8SplitAcrossReads(t *testing.T) {
111+ clk := &fakeClock{}
112+ h := &captureHandler{}
113+ r := NewRouter(clk, h)
114+
115+ r.Route([]byte{ESC, 0xc3})
116+ r.Route([]byte{0xb1})
117+
118+ require.Empty(t, h.actions)
119+ require.Equal(t, [][]byte{{ESC, 0xc3, 0xb1}}, h.forwards)
120+}
121+
122+func TestTopRowDigitIndexAcceptsQWERTYAndAZERTYVariants(t *testing.T) {
123+ cases := []struct {
124+ key rune
125+ want int
126+ }{
127+ {key: '1', want: 0}, {key: '&', want: 0},
128+ {key: '2', want: 1}, {key: 'é', want: 1},
129+ {key: '3', want: 2}, {key: '"', want: 2},
130+ {key: '4', want: 3}, {key: '\'', want: 3},
131+ {key: '5', want: 4}, {key: '(', want: 4},
132+ {key: '6', want: 5}, {key: '-', want: 5},
133+ {key: '7', want: 6}, {key: 'è', want: 6},
134+ {key: '8', want: 7}, {key: '_', want: 7},
135+ {key: '9', want: 8}, {key: 'ç', want: 8},
136+ }
137+ for _, tc := range cases {
138+ got, ok := topRowDigitIndex(tc.key)
139+ require.True(t, ok, "key %q", tc.key)
140+ require.Equal(t, tc.want, got, "key %q", tc.key)
141+ }
142+
143+ _, ok := topRowDigitIndex('0')
144+ require.False(t, ok)
145+}
146+
147+func switchTabActions() []Action {
148+ return []Action{
65149 ActionSwitchTab1, ActionSwitchTab2, ActionSwitchTab3,
66150 ActionSwitchTab4, ActionSwitchTab5, ActionSwitchTab6,
67151 ActionSwitchTab7, ActionSwitchTab8, ActionSwitchTab9,
68- }, h.actions)
69- require.Empty(t, h.forwards)
152+ }
70153 }
71154
72155 func TestRouterInterceptsAltAForJumpAttention(t *testing.T) {
@@ -61,12 +61,95 @@ func TestRouterInterceptsAltDigits(t *testing.T) {
61 for b := byte('1'); b <= '9'; b++ {61 for b := byte('1'); b <= '9'; b++ {
62 r.Route([]byte{ESC, b})62 r.Route([]byte{ESC, b})
63 }63 }
64- require.Equal(t, []Action{64+ require.Equal(t, switchTabActions(), h.actions)
65+ require.Empty(t, h.forwards)
66+}
67+
68+func TestRouterInterceptsAltAZERTYTopRowAsDigits(t *testing.T) {
69+ clk := &fakeClock{}
70+ h := &captureHandler{}
71+ r := NewRouter(clk, h)
72+ for _, key := range []string{"&", "é", "\"", "'", "(", "-", "è", "_", "ç"} {
73+ r.Route(append([]byte{ESC}, []byte(key)...))
74+ }
75+ require.Equal(t, switchTabActions(), h.actions)
76+ require.Empty(t, h.forwards)
77+}
78+
79+func TestRouterInterceptsAltAZERTYUTF8SplitAcrossReads(t *testing.T) {
80+ clk := &fakeClock{}
81+ h := &captureHandler{}
82+ r := NewRouter(clk, h)
83+
84+ r.Route([]byte{ESC, 0xc3})
85+ require.Empty(t, h.actions)
86+ require.Empty(t, h.forwards)
87+ require.Len(t, clk.timers, 1)
88+
89+ r.Route([]byte{0xa9})
90+ require.Equal(t, []Action{ActionSwitchTab2}, h.actions)
91+ require.Empty(t, h.forwards)
92+}
93+
94+func TestRouterInterceptsRetainedAltAZERTYUTF8SplitAcrossReads(t *testing.T) {
95+ clk := &fakeClock{}
96+ h := &captureHandler{}
97+ r := NewRouter(clk, h)
98+
99+ r.Route([]byte{ESC})
100+ r.Route([]byte{0xc3})
101+ require.Empty(t, h.actions)
102+ require.Empty(t, h.forwards)
103+ require.Len(t, clk.timers, 2)
104+
105+ r.Route([]byte{0xa9})
106+ require.Equal(t, []Action{ActionSwitchTab2}, h.actions)
107+ require.Empty(t, h.forwards)
108+}
109+
110+func TestRouterForwardsUnboundAltUTF8SplitAcrossReads(t *testing.T) {
111+ clk := &fakeClock{}
112+ h := &captureHandler{}
113+ r := NewRouter(clk, h)
114+
115+ r.Route([]byte{ESC, 0xc3})
116+ r.Route([]byte{0xb1})
117+
118+ require.Empty(t, h.actions)
119+ require.Equal(t, [][]byte{{ESC, 0xc3, 0xb1}}, h.forwards)
120+}
121+
122+func TestTopRowDigitIndexAcceptsQWERTYAndAZERTYVariants(t *testing.T) {
123+ cases := []struct {
124+ key rune
125+ want int
126+ }{
127+ {key: '1', want: 0}, {key: '&', want: 0},
128+ {key: '2', want: 1}, {key: 'é', want: 1},
129+ {key: '3', want: 2}, {key: '"', want: 2},
130+ {key: '4', want: 3}, {key: '\'', want: 3},
131+ {key: '5', want: 4}, {key: '(', want: 4},
132+ {key: '6', want: 5}, {key: '-', want: 5},
133+ {key: '7', want: 6}, {key: 'è', want: 6},
134+ {key: '8', want: 7}, {key: '_', want: 7},
135+ {key: '9', want: 8}, {key: 'ç', want: 8},
136+ }
137+ for _, tc := range cases {
138+ got, ok := topRowDigitIndex(tc.key)
139+ require.True(t, ok, "key %q", tc.key)
140+ require.Equal(t, tc.want, got, "key %q", tc.key)
141+ }
142+
143+ _, ok := topRowDigitIndex('0')
144+ require.False(t, ok)
145+}
146+
147+func switchTabActions() []Action {
148+ return []Action{
65 ActionSwitchTab1, ActionSwitchTab2, ActionSwitchTab3,149 ActionSwitchTab1, ActionSwitchTab2, ActionSwitchTab3,
66 ActionSwitchTab4, ActionSwitchTab5, ActionSwitchTab6,150 ActionSwitchTab4, ActionSwitchTab5, ActionSwitchTab6,
67 ActionSwitchTab7, ActionSwitchTab8, ActionSwitchTab9,151 ActionSwitchTab7, ActionSwitchTab8, ActionSwitchTab9,
68- }, h.actions)152+ }
69- require.Empty(t, h.forwards)
70 }153 }
71 154
72 func TestRouterInterceptsAltAForJumpAttention(t *testing.T) {155 func TestRouterInterceptsAltAForJumpAttention(t *testing.T) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close