detached 56bc502

Merge pull request #7 from bnema/feat/non-qwerty-alt-digits

fix(keys): support non-QWERTY Alt digit bindings
commit
56bc5024328ad7be2312d70454a3ad9e79c1cad3
author
bnema · · 2026-07-04 02:07 UTC
committer
GitHub · · 2026-07-04 02:07 UTC
merge of
92167d7 + df056ef · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
2 files · +226 -20
M internal/usecase/keys/router.go +89 -17
@@ -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, len(pendingAlt)); consumed > len(pendingAlt) {
75+ data = data[consumed-len(pendingAlt):]
7176 }
7277 }
7378 r.route(data)
@@ -99,34 +104,57 @@ 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 }
111122 flush()
112123 }
113124
114-func (r *Router) routeAfterPendingESC(data []byte) int {
125+func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int {
115126 next := data[0]
116127 if passThroughPrefix(next) {
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+ key, size := utf8.DecodeRune(data)
140+ if size > 1 {
141+ r.forward(append([]byte{ESC}, data[:size]...))
142+ return size
143+ }
144+ if key == utf8.RuneError && pendingAltLen > 0 {
145+ r.forward(append([]byte{ESC}, data[:pendingAltLen]...))
146+ return pendingAltLen
123147 }
124148 r.forward([]byte{ESC})
125149 return 0
126150 }
127151
128-func (r *Router) retainESC() {
152+func (r *Router) retainESC(altBytes ...[]byte) {
129153 r.pending = true
154+ r.pendingAlt = nil
155+ if len(altBytes) > 0 {
156+ r.pendingAlt = append([]byte(nil), altBytes[0]...)
157+ }
130158 r.timer = r.clock.NewTimer(r.delay)
131159 r.pendingDone = make(chan struct{})
132160 go func(timer ports.Timer, done <-chan struct{}) {
@@ -139,7 +167,9 @@ func (r *Router) retainESC() {
139167 defer r.mu.Unlock()
140168 if r.pending && r.timer == timer {
141169 r.pending = false
142- r.forward([]byte{ESC})
170+ data := append([]byte{ESC}, r.pendingAlt...)
171+ r.pendingAlt = nil
172+ r.forward(data)
143173 }
144174 }(r.timer, r.pendingDone)
145175 }
@@ -155,6 +185,14 @@ func (r *Router) stopTimer() {
155185 }
156186 }
157187
188+func partialUTF8Rune(data []byte) bool {
189+ if len(data) == 0 || utf8.FullRune(data) {
190+ return false
191+ }
192+ key, size := utf8.DecodeRune(data)
193+ return key == utf8.RuneError && size == 1
194+}
195+
158196 func (r *Router) forward(data []byte) {
159197 cp := append([]byte(nil), data...)
160198 r.h.Forward(cp)
@@ -162,15 +200,49 @@ func (r *Router) forward(data []byte) {
162200
163201 func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }
164202
165-func binding(b byte) (Action, bool) {
166- switch b {
203+func binding(data []byte) (Action, int, bool) {
204+ if len(data) == 0 {
205+ return 0, 0, false
206+ }
207+ switch data[0] {
167208 case ' ':
168- return ActionOpenPalette, true
169- case '1', '2', '3', '4', '5', '6', '7', '8', '9':
170- return ActionSwitchTab1 + Action(b-'1'), true
209+ return ActionOpenPalette, 1, true
171210 case 'a':
172- return ActionJumpAttention, true
173- default:
174- return 0, false
211+ return ActionJumpAttention, 1, true
212+ }
213+
214+ key, size := utf8.DecodeRune(data)
215+ if key == utf8.RuneError && size == 1 {
216+ return 0, 0, false
217+ }
218+ if idx, ok := topRowDigitIndex(key); ok {
219+ return ActionSwitchTab1 + Action(idx), size, true
220+ }
221+ return 0, 0, false
222+}
223+
224+var topRowDigitAliases = [][]rune{
225+ {'1', '&'},
226+ {'2', 'é'},
227+ {'3', '"'},
228+ {'4', '\''},
229+ {'5', '('},
230+ {'6', '-', '§'},
231+ {'7', 'è'},
232+ {'8', '_', '!'},
233+ {'9', 'ç'},
234+}
235+
236+// topRowDigitIndex maps symbols emitted by physical top-row digit keys to
237+// zero-based digit positions. It is modifier-agnostic so Alt+digit and future
238+// Ctrl+digit bindings can share the same layout support.
239+func topRowDigitIndex(key rune) (int, bool) {
240+ for idx, aliases := range topRowDigitAliases {
241+ for _, alias := range aliases {
242+ if key == alias {
243+ return idx, true
244+ }
245+ }
175246 }
247+ return 0, false
176248 }
@@ -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, len(pendingAlt)); consumed > len(pendingAlt) {
75+ data = data[consumed-len(pendingAlt):]
71 }76 }
72 }77 }
73 r.route(data)78 r.route(data)
@@ -99,34 +104,57 @@ 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 }
111 flush()122 flush()
112 }123 }
113 124
114-func (r *Router) routeAfterPendingESC(data []byte) int {125+func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int {
115 next := data[0]126 next := data[0]
116 if passThroughPrefix(next) {127 if passThroughPrefix(next) {
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+ key, size := utf8.DecodeRune(data)
140+ if size > 1 {
141+ r.forward(append([]byte{ESC}, data[:size]...))
142+ return size
143+ }
144+ if key == utf8.RuneError && pendingAltLen > 0 {
145+ r.forward(append([]byte{ESC}, data[:pendingAltLen]...))
146+ return pendingAltLen
123 }147 }
124 r.forward([]byte{ESC})148 r.forward([]byte{ESC})
125 return 0149 return 0
126 }150 }
127 151
128-func (r *Router) retainESC() {152+func (r *Router) retainESC(altBytes ...[]byte) {
129 r.pending = true153 r.pending = true
154+ r.pendingAlt = nil
155+ if len(altBytes) > 0 {
156+ r.pendingAlt = append([]byte(nil), altBytes[0]...)
157+ }
130 r.timer = r.clock.NewTimer(r.delay)158 r.timer = r.clock.NewTimer(r.delay)
131 r.pendingDone = make(chan struct{})159 r.pendingDone = make(chan struct{})
132 go func(timer ports.Timer, done <-chan struct{}) {160 go func(timer ports.Timer, done <-chan struct{}) {
@@ -139,7 +167,9 @@ func (r *Router) retainESC() {
139 defer r.mu.Unlock()167 defer r.mu.Unlock()
140 if r.pending && r.timer == timer {168 if r.pending && r.timer == timer {
141 r.pending = false169 r.pending = false
142- r.forward([]byte{ESC})170+ data := append([]byte{ESC}, r.pendingAlt...)
171+ r.pendingAlt = nil
172+ r.forward(data)
143 }173 }
144 }(r.timer, r.pendingDone)174 }(r.timer, r.pendingDone)
145 }175 }
@@ -155,6 +185,14 @@ func (r *Router) stopTimer() {
155 }185 }
156 }186 }
157 187
188+func partialUTF8Rune(data []byte) bool {
189+ if len(data) == 0 || utf8.FullRune(data) {
190+ return false
191+ }
192+ key, size := utf8.DecodeRune(data)
193+ return key == utf8.RuneError && size == 1
194+}
195+
158 func (r *Router) forward(data []byte) {196 func (r *Router) forward(data []byte) {
159 cp := append([]byte(nil), data...)197 cp := append([]byte(nil), data...)
160 r.h.Forward(cp)198 r.h.Forward(cp)
@@ -162,15 +200,49 @@ func (r *Router) forward(data []byte) {
162 200
163 func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }201 func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }
164 202
165-func binding(b byte) (Action, bool) {203+func binding(data []byte) (Action, int, bool) {
166- switch b {204+ if len(data) == 0 {
205+ return 0, 0, false
206+ }
207+ switch data[0] {
167 case ' ':208 case ' ':
168- return ActionOpenPalette, true209+ 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':210 case 'a':
172- return ActionJumpAttention, true211+ return ActionJumpAttention, 1, true
173- default:212+ }
174- return 0, false213+
214+ key, size := utf8.DecodeRune(data)
215+ if key == utf8.RuneError && size == 1 {
216+ return 0, 0, false
217+ }
218+ if idx, ok := topRowDigitIndex(key); ok {
219+ return ActionSwitchTab1 + Action(idx), size, true
220+ }
221+ return 0, 0, false
222+}
223+
224+var topRowDigitAliases = [][]rune{
225+ {'1', '&'},
226+ {'2', 'é'},
227+ {'3', '"'},
228+ {'4', '\''},
229+ {'5', '('},
230+ {'6', '-', '§'},
231+ {'7', 'è'},
232+ {'8', '_', '!'},
233+ {'9', 'ç'},
234+}
235+
236+// topRowDigitIndex maps symbols emitted by physical top-row digit keys to
237+// zero-based digit positions. It is modifier-agnostic so Alt+digit and future
238+// Ctrl+digit bindings can share the same layout support.
239+func topRowDigitIndex(key rune) (int, bool) {
240+ for idx, aliases := range topRowDigitAliases {
241+ for _, alias := range aliases {
242+ if key == alias {
243+ return idx, true
244+ }
245+ }
175 }246 }
247+ return 0, false
176 }248 }
M internal/usecase/keys/router_test.go +137 -3
@@ -61,12 +61,146 @@ 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 TestRouterInterceptsAltLayoutDigitAliases(t *testing.T) {
69+ cases := []struct {
70+ name string
71+ keys []string
72+ }{
73+ {name: "QWERTY", keys: []string{"1", "2", "3", "4", "5", "6", "7", "8", "9"}},
74+ {name: "French AZERTY", keys: []string{"&", "é", "\"", "'", "(", "-", "è", "_", "ç"}},
75+ {name: "Belgian AZERTY", keys: []string{"&", "é", "\"", "'", "(", "§", "è", "!", "ç"}},
76+ }
77+
78+ for _, tc := range cases {
79+ t.Run(tc.name, func(t *testing.T) {
80+ clk := &fakeClock{}
81+ h := &captureHandler{}
82+ r := NewRouter(clk, h)
83+ for _, key := range tc.keys {
84+ r.Route(append([]byte{ESC}, []byte(key)...))
85+ }
86+ require.Equal(t, switchTabActions(), h.actions)
87+ require.Empty(t, h.forwards)
88+ })
89+ }
90+}
91+
92+func TestRouterInterceptsAltAZERTYUTF8SplitAcrossReads(t *testing.T) {
93+ clk := &fakeClock{}
94+ h := &captureHandler{}
95+ r := NewRouter(clk, h)
96+
97+ r.Route([]byte{ESC, 0xc3})
98+ require.Empty(t, h.actions)
99+ require.Empty(t, h.forwards)
100+ require.Len(t, clk.timers, 1)
101+
102+ r.Route([]byte{0xa9})
103+ require.Equal(t, []Action{ActionSwitchTab2}, h.actions)
104+ require.Empty(t, h.forwards)
105+ assertRouterPendingCleared(t, r)
106+
107+ r.Route([]byte{ESC, ' '})
108+ require.Equal(t, []Action{ActionSwitchTab2, ActionOpenPalette}, h.actions)
109+ require.Empty(t, h.forwards)
110+}
111+
112+func TestRouterInterceptsRetainedAltAZERTYUTF8SplitAcrossReads(t *testing.T) {
113+ clk := &fakeClock{}
114+ h := &captureHandler{}
115+ r := NewRouter(clk, h)
116+
117+ r.Route([]byte{ESC})
118+ r.Route([]byte{0xc3})
119+ require.Empty(t, h.actions)
120+ require.Empty(t, h.forwards)
121+ require.Len(t, clk.timers, 2)
122+
123+ r.Route([]byte{0xa9})
124+ require.Equal(t, []Action{ActionSwitchTab2}, h.actions)
125+ require.Empty(t, h.forwards)
126+ assertRouterPendingCleared(t, r)
127+
128+ r.Route([]byte{ESC, ' '})
129+ require.Equal(t, []Action{ActionSwitchTab2, ActionOpenPalette}, h.actions)
130+ require.Empty(t, h.forwards)
131+}
132+
133+func TestRouterForwardsUnboundAltUTF8SplitAcrossReads(t *testing.T) {
134+ clk := &fakeClock{}
135+ h := &captureHandler{}
136+ r := NewRouter(clk, h)
137+
138+ r.Route([]byte{ESC, 0xc3})
139+ r.Route([]byte{0xb1})
140+
141+ require.Empty(t, h.actions)
142+ require.Equal(t, [][]byte{{ESC, 0xc3, 0xb1}}, h.forwards)
143+ assertRouterPendingCleared(t, r)
144+
145+ r.Route([]byte("Z"))
146+ require.Empty(t, h.actions)
147+ require.Equal(t, [][]byte{{ESC, 0xc3, 0xb1}, []byte("Z")}, h.forwards)
148+}
149+
150+func TestRouterForwardsInvalidSplitAltUTF8WithoutDroppingBytes(t *testing.T) {
151+ clk := &fakeClock{}
152+ h := &captureHandler{}
153+ r := NewRouter(clk, h)
154+
155+ r.Route([]byte{ESC, 0xc3})
156+ r.Route([]byte("X"))
157+
158+ require.Empty(t, h.actions)
159+ require.Equal(t, [][]byte{{ESC, 0xc3}, []byte("X")}, h.forwards)
160+ assertRouterPendingCleared(t, r)
161+
162+ r.Route([]byte{ESC, ' '})
163+ require.Equal(t, []Action{ActionOpenPalette}, h.actions)
164+ require.Equal(t, [][]byte{{ESC, 0xc3}, []byte("X")}, h.forwards)
165+}
166+
167+func assertRouterPendingCleared(t *testing.T, r *Router) {
168+ t.Helper()
169+ require.False(t, r.pending)
170+ require.Nil(t, r.pendingAlt)
171+ require.Nil(t, r.timer)
172+ require.Nil(t, r.pendingDone)
173+}
174+
175+func TestTopRowDigitIndexAcceptsLayoutAliases(t *testing.T) {
176+ for want, aliases := range [][]rune{
177+ {'1', '&'},
178+ {'2', 'é'},
179+ {'3', '"'},
180+ {'4', '\''},
181+ {'5', '('},
182+ {'6', '-', '§'},
183+ {'7', 'è'},
184+ {'8', '_', '!'},
185+ {'9', 'ç'},
186+ } {
187+ for _, key := range aliases {
188+ got, ok := topRowDigitIndex(key)
189+ require.True(t, ok, "key %q", key)
190+ require.Equal(t, want, got, "key %q", key)
191+ }
192+ }
193+
194+ _, ok := topRowDigitIndex('0')
195+ require.False(t, ok)
196+}
197+
198+func switchTabActions() []Action {
199+ return []Action{
65200 ActionSwitchTab1, ActionSwitchTab2, ActionSwitchTab3,
66201 ActionSwitchTab4, ActionSwitchTab5, ActionSwitchTab6,
67202 ActionSwitchTab7, ActionSwitchTab8, ActionSwitchTab9,
68- }, h.actions)
69- require.Empty(t, h.forwards)
203+ }
70204 }
71205
72206 func TestRouterInterceptsAltAForJumpAttention(t *testing.T) {
@@ -61,12 +61,146 @@ 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 TestRouterInterceptsAltLayoutDigitAliases(t *testing.T) {
69+ cases := []struct {
70+ name string
71+ keys []string
72+ }{
73+ {name: "QWERTY", keys: []string{"1", "2", "3", "4", "5", "6", "7", "8", "9"}},
74+ {name: "French AZERTY", keys: []string{"&", "é", "\"", "'", "(", "-", "è", "_", "ç"}},
75+ {name: "Belgian AZERTY", keys: []string{"&", "é", "\"", "'", "(", "§", "è", "!", "ç"}},
76+ }
77+
78+ for _, tc := range cases {
79+ t.Run(tc.name, func(t *testing.T) {
80+ clk := &fakeClock{}
81+ h := &captureHandler{}
82+ r := NewRouter(clk, h)
83+ for _, key := range tc.keys {
84+ r.Route(append([]byte{ESC}, []byte(key)...))
85+ }
86+ require.Equal(t, switchTabActions(), h.actions)
87+ require.Empty(t, h.forwards)
88+ })
89+ }
90+}
91+
92+func TestRouterInterceptsAltAZERTYUTF8SplitAcrossReads(t *testing.T) {
93+ clk := &fakeClock{}
94+ h := &captureHandler{}
95+ r := NewRouter(clk, h)
96+
97+ r.Route([]byte{ESC, 0xc3})
98+ require.Empty(t, h.actions)
99+ require.Empty(t, h.forwards)
100+ require.Len(t, clk.timers, 1)
101+
102+ r.Route([]byte{0xa9})
103+ require.Equal(t, []Action{ActionSwitchTab2}, h.actions)
104+ require.Empty(t, h.forwards)
105+ assertRouterPendingCleared(t, r)
106+
107+ r.Route([]byte{ESC, ' '})
108+ require.Equal(t, []Action{ActionSwitchTab2, ActionOpenPalette}, h.actions)
109+ require.Empty(t, h.forwards)
110+}
111+
112+func TestRouterInterceptsRetainedAltAZERTYUTF8SplitAcrossReads(t *testing.T) {
113+ clk := &fakeClock{}
114+ h := &captureHandler{}
115+ r := NewRouter(clk, h)
116+
117+ r.Route([]byte{ESC})
118+ r.Route([]byte{0xc3})
119+ require.Empty(t, h.actions)
120+ require.Empty(t, h.forwards)
121+ require.Len(t, clk.timers, 2)
122+
123+ r.Route([]byte{0xa9})
124+ require.Equal(t, []Action{ActionSwitchTab2}, h.actions)
125+ require.Empty(t, h.forwards)
126+ assertRouterPendingCleared(t, r)
127+
128+ r.Route([]byte{ESC, ' '})
129+ require.Equal(t, []Action{ActionSwitchTab2, ActionOpenPalette}, h.actions)
130+ require.Empty(t, h.forwards)
131+}
132+
133+func TestRouterForwardsUnboundAltUTF8SplitAcrossReads(t *testing.T) {
134+ clk := &fakeClock{}
135+ h := &captureHandler{}
136+ r := NewRouter(clk, h)
137+
138+ r.Route([]byte{ESC, 0xc3})
139+ r.Route([]byte{0xb1})
140+
141+ require.Empty(t, h.actions)
142+ require.Equal(t, [][]byte{{ESC, 0xc3, 0xb1}}, h.forwards)
143+ assertRouterPendingCleared(t, r)
144+
145+ r.Route([]byte("Z"))
146+ require.Empty(t, h.actions)
147+ require.Equal(t, [][]byte{{ESC, 0xc3, 0xb1}, []byte("Z")}, h.forwards)
148+}
149+
150+func TestRouterForwardsInvalidSplitAltUTF8WithoutDroppingBytes(t *testing.T) {
151+ clk := &fakeClock{}
152+ h := &captureHandler{}
153+ r := NewRouter(clk, h)
154+
155+ r.Route([]byte{ESC, 0xc3})
156+ r.Route([]byte("X"))
157+
158+ require.Empty(t, h.actions)
159+ require.Equal(t, [][]byte{{ESC, 0xc3}, []byte("X")}, h.forwards)
160+ assertRouterPendingCleared(t, r)
161+
162+ r.Route([]byte{ESC, ' '})
163+ require.Equal(t, []Action{ActionOpenPalette}, h.actions)
164+ require.Equal(t, [][]byte{{ESC, 0xc3}, []byte("X")}, h.forwards)
165+}
166+
167+func assertRouterPendingCleared(t *testing.T, r *Router) {
168+ t.Helper()
169+ require.False(t, r.pending)
170+ require.Nil(t, r.pendingAlt)
171+ require.Nil(t, r.timer)
172+ require.Nil(t, r.pendingDone)
173+}
174+
175+func TestTopRowDigitIndexAcceptsLayoutAliases(t *testing.T) {
176+ for want, aliases := range [][]rune{
177+ {'1', '&'},
178+ {'2', 'é'},
179+ {'3', '"'},
180+ {'4', '\''},
181+ {'5', '('},
182+ {'6', '-', '§'},
183+ {'7', 'è'},
184+ {'8', '_', '!'},
185+ {'9', 'ç'},
186+ } {
187+ for _, key := range aliases {
188+ got, ok := topRowDigitIndex(key)
189+ require.True(t, ok, "key %q", key)
190+ require.Equal(t, want, got, "key %q", key)
191+ }
192+ }
193+
194+ _, ok := topRowDigitIndex('0')
195+ require.False(t, ok)
196+}
197+
198+func switchTabActions() []Action {
199+ return []Action{
65 ActionSwitchTab1, ActionSwitchTab2, ActionSwitchTab3,200 ActionSwitchTab1, ActionSwitchTab2, ActionSwitchTab3,
66 ActionSwitchTab4, ActionSwitchTab5, ActionSwitchTab6,201 ActionSwitchTab4, ActionSwitchTab5, ActionSwitchTab6,
67 ActionSwitchTab7, ActionSwitchTab8, ActionSwitchTab9,202 ActionSwitchTab7, ActionSwitchTab8, ActionSwitchTab9,
68- }, h.actions)203+ }
69- require.Empty(t, h.forwards)
70 }204 }
71 205
72 func TestRouterInterceptsAltAForJumpAttention(t *testing.T) {206 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