1package input
2
3import (
4 "context"
5 "testing"
6
7 "github.com/bnema/dumber/internal/domain/entity"
8 "github.com/bnema/puregotk/v4/gdk"
9 "github.com/stretchr/testify/assert"
10)
11
12// newTestWorkspace creates a minimal WorkspaceConfig with activation shortcuts set.
13// Used by tests that construct a KeyboardHandler.
14func newTestWorkspace() *entity.WorkspaceConfig {
15 return &entity.WorkspaceConfig{
16 TabMode: entity.TabModeConfig{ActivationShortcut: "ctrl+t"},
17 PaneMode: entity.PaneModeConfig{ActivationShortcut: "ctrl+p"},
18 ResizeMode: entity.ResizeModeConfig{ActivationShortcut: "ctrl+alt+r"},
19 }
20}
21
22// newTestSession creates a minimal SessionConfig with activation shortcuts set.
23func newTestSession() *entity.SessionConfig {
24 return &entity.SessionConfig{
25 SessionMode: entity.SessionModeConfig{ActivationShortcut: "ctrl+s"},
26 }
27}
28
29// stubAccentHandler is a test stub for the AccentHandler interface.
30type stubAccentHandler struct {
31 onKeyPressedResult bool
32}
33
34func (s *stubAccentHandler) OnKeyPressed(_ context.Context, _ rune, _ bool) bool {
35 return s.onKeyPressedResult
36}
37func (s *stubAccentHandler) OnKeyReleased(_ context.Context, _ rune) {}
38func (s *stubAccentHandler) IsPickerVisible() bool { return false }
39func (s *stubAccentHandler) Cancel(_ context.Context) {}
40
41func TestIsShortcutModified(t *testing.T) {
42 tests := []struct {
43 name string
44 mods Modifier
45 expected bool
46 }{
47 {"no modifier", ModNone, false},
48 {"shift only", ModShift, false},
49 {"ctrl", ModCtrl, true},
50 {"alt", ModAlt, true},
51 {"ctrl+shift", ModCtrl | ModShift, true},
52 {"alt+shift", ModAlt | ModShift, true},
53 {"ctrl+alt", ModCtrl | ModAlt, true},
54 }
55
56 for _, tt := range tests {
57 t.Run(tt.name, func(t *testing.T) {
58 assert.Equal(t, tt.expected, IsShortcutModified(tt.mods))
59 })
60 }
61}
62
63func TestIsTextInputKey(t *testing.T) {
64 tests := []struct {
65 name string
66 keyval uint
67 expected bool
68 }{
69 // Printable characters
70 {"lowercase a", uint('a'), true},
71 {"lowercase z", uint('z'), true},
72 {"uppercase A", uint('A'), true},
73 {"digit 1", uint('1'), true},
74 {"space", 0x020, true},
75 {"period", uint('.'), true},
76 {"slash", uint('/'), true},
77 {"apostrophe", 0x027, true},
78 {"grave accent", 0x060, true},
79 {"tilde", 0x07e, true},
80 {"quotedbl", 0x022, true},
81 {"caret", 0x05e, true},
82
83 // Dead keys (used by US International layout)
84 {"dead acute", 0xfe51, true},
85 {"dead grave", 0xfe50, true},
86 {"dead circumflex", 0xfe52, true},
87 {"dead tilde", 0xfe53, true},
88 {"dead diaeresis", 0xfe57, true},
89
90 // Dead key upper-bound boundary cases
91 {"dead_greek (0xfe8c) - highest dead key", 0xfe8c, true},
92 {"0xfe8d - one past highest dead key", 0xfe8d, false},
93
94 // Non-text keys (should NOT be treated as text)
95 {"escape", 0xff1b, false},
96 {"return", 0xff0d, false},
97 {"tab", 0xff09, false},
98 {"F1", 0xffbe, false},
99 {"F12", 0xffc9, false},
100 {"left arrow", 0xff51, false},
101 {"right arrow", 0xff53, false},
102 {"backspace", 0xff08, false},
103 {"delete", 0xffff, false},
104 {"home", 0xff50, false},
105 {"end", 0xff57, false},
106 }
107
108 for _, tt := range tests {
109 t.Run(tt.name, func(t *testing.T) {
110 assert.Equal(t, tt.expected, IsTextInputKey(tt.keyval), "keyval 0x%x", tt.keyval)
111 })
112 }
113}
114
115func TestHandleKeyPress_RoutePassToWidget(t *testing.T) {
116 ctx := context.Background()
117
118 h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
119
120 // Route all non-shortcut keys to widget (simulates WebView text input)
121 h.SetRouteKey(func(kc KeyContext) KeyRoute {
122 return RoutePassToWidget
123 })
124
125 tests := []struct {
126 name string
127 keyval uint
128 state gdk.ModifierType
129 }{
130 {"plain 'e'", uint('e'), 0},
131 {"plain 'a'", uint('a'), 0},
132 {"shift 'E'", uint('E'), gdk.ShiftMaskValue},
133 {"space", 0x020, 0},
134 {"digit 1", uint('1'), 0},
135 }
136
137 for _, tt := range tests {
138 t.Run(tt.name, func(t *testing.T) {
139 result := h.handleKeyPress(tt.keyval, 0, tt.state)
140 assert.False(t, result, "RoutePassToWidget should let key propagate (return false)")
141 })
142 }
143}
144
145func TestHandleKeyPress_RouteAccentDetection_NoAccent(t *testing.T) {
146 ctx := context.Background()
147
148 h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
149
150 // Set a stub accent handler that always returns false (simulates "no accents for this key")
151 h.SetAccentHandler(&stubAccentHandler{onKeyPressedResult: false})
152
153 // Route to accent detection
154 h.SetRouteKey(func(kc KeyContext) KeyRoute {
155 return RouteAccentDetection
156 })
157
158 // 'x' has no accents -> accent handler returns false -> key falls through
159 result := h.handleKeyPress(uint('x'), 0, 0)
160 assert.False(t, result, "'x' has no accents, should pass through")
161}
162
163func TestHandleKeyPress_ShortcutsWorkWithRouting(t *testing.T) {
164 ctx := context.Background()
165
166 h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
167
168 h.SetOnAction(func(ctx context.Context, action Action) error {
169 return nil
170 })
171
172 // Route text keys to widget, shortcuts to handler
173 h.SetRouteKey(func(kc KeyContext) KeyRoute {
174 if IsShortcutModified(kc.Modifiers) {
175 return RouteHandleShortcuts
176 }
177 return RoutePassToWidget
178 })
179
180 // Ctrl+L = open omnibox (registered global shortcut)
181 result := h.handleKeyPress(uint('l'), 0, gdk.ControlMaskValue)
182 assert.True(t, result, "Ctrl+L should be consumed by shortcut handler")
183}
184
185func TestHandleKeyPress_ModalModeIgnoresRoute(t *testing.T) {
186 ctx := context.Background()
187
188 h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
189 h.EnterTabMode()
190
191 // Even if routeKey says pass to widget, modal mode ignores it
192 h.SetRouteKey(func(kc KeyContext) KeyRoute {
193 return RoutePassToWidget
194 })
195
196 // In modal mode, unknown keys are consumed (return true)
197 result := h.handleKeyPress(uint('z'), 0, 0)
198 assert.True(t, result, "modal mode should consume unknown keys regardless of route")
199}
200
201func TestKeyboardHandler_SetRouteKey(t *testing.T) {
202 ctx := context.Background()
203 h := NewKeyboardHandler(ctx, nil, nil)
204
205 // Default: no callback set
206 h.mu.RLock()
207 fn := h.routeKey
208 h.mu.RUnlock()
209 assert.Nil(t, fn, "routeKey should be nil by default")
210
211 // Set callback
212 h.SetRouteKey(func(kc KeyContext) KeyRoute {
213 return RoutePassToWidget
214 })
215
216 h.mu.RLock()
217 fn = h.routeKey
218 h.mu.RUnlock()
219 assert.NotNil(t, fn, "routeKey should be set")
220 assert.Equal(t, RoutePassToWidget, fn(KeyContext{}))
221}
222
223func TestHandleKeyPress_NoRouteCallback_DefaultsToShortcuts(t *testing.T) {
224 ctx := context.Background()
225
226 h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
227
228 // No routeKey set -- should default to shortcut handling
229 // 'z' is not a registered shortcut in ModeNormal, so returns false
230 result := h.handleKeyPress(uint('z'), 0, 0)
231 assert.False(t, result, "unregistered key in ModeNormal returns false")
232}