1package input
2
3import (
4 "context"
5 "sync"
6 "time"
7
8 "github.com/bnema/dumber/internal/infrastructure/config"
9 "github.com/bnema/dumber/internal/logging"
10 "github.com/jwijenbergh/puregotk/v4/gdk"
11 "github.com/jwijenbergh/puregotk/v4/gtk"
12 "github.com/rs/zerolog"
13)
14
15// ActionHandler is called when a keyboard action is triggered.
16// It receives the context and the action to perform.
17// Return an error if the action fails.
18type ActionHandler func(ctx context.Context, action Action) error
19
20// KeyboardHandler processes keyboard events and dispatches actions.
21// It manages modal input modes and routes key events to the appropriate handlers.
22type KeyboardHandler struct {
23 shortcuts *ShortcutSet
24 modal *ModalState
25 cfg *config.Config
26
27 // Action handler callback
28 onAction ActionHandler
29 // Optional bypass check (e.g., omnibox visible)
30 shouldBypass func() bool
31
32 // GTK controller (nil until attached)
33 controller *gtk.EventControllerKey
34
35 // Callback retention: must stay reachable by Go GC.
36 keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
37
38 ctx context.Context
39 mu sync.RWMutex
40}
41
42// NewKeyboardHandler creates a new keyboard handler.
43func NewKeyboardHandler(ctx context.Context, cfg *config.Config) *KeyboardHandler {
44 log := logging.FromContext(ctx)
45
46 log.Debug().Msg("creating keyboard handler")
47
48 h := &KeyboardHandler{
49 shortcuts: NewShortcutSet(ctx, cfg),
50 modal: NewModalState(ctx),
51 cfg: cfg,
52 ctx: ctx,
53 }
54
55 return h
56}
57
58// SetOnAction sets the callback for when actions are triggered.
59func (h *KeyboardHandler) SetOnAction(fn ActionHandler) {
60 h.mu.Lock()
61 defer h.mu.Unlock()
62 h.onAction = fn
63}
64
65// SetOnModeChange sets the callback for mode changes (for UI updates).
66func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) {
67 h.modal.SetOnModeChange(fn)
68}
69
70// SetShouldBypassInput sets a hook to bypass keyboard handling entirely.
71// When true, events propagate to focused widgets instead.
72func (h *KeyboardHandler) SetShouldBypassInput(fn func() bool) {
73 h.mu.Lock()
74 defer h.mu.Unlock()
75 h.shouldBypass = fn
76}
77
78// Mode returns the current input mode.
79func (h *KeyboardHandler) Mode() Mode {
80 return h.modal.Mode()
81}
82
83// AttachTo attaches the keyboard handler to a GTK window.
84// The handler will intercept key events in the capture phase.
85func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
86 log := logging.FromContext(h.ctx)
87
88 if window == nil {
89 log.Error().Msg("cannot attach keyboard handler to nil window")
90 return
91 }
92
93 h.controller = gtk.NewEventControllerKey()
94 if h.controller == nil {
95 log.Error().Msg("failed to create event controller key")
96 return
97 }
98
99 // Set capture phase to intercept events before WebView gets them
100 h.controller.SetPropagationPhase(gtk.PhaseCaptureValue)
101
102 // Connect key pressed handler (retain callback to prevent GC).
103 // The callback receives: keyval (translated key), keycode (hardware key position), state (modifiers)
104 h.keyPressedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, state gdk.ModifierType) bool {
105 return h.handleKeyPress(keyval, keycode, state)
106 }
107 h.controller.ConnectKeyPressed(&h.keyPressedCb)
108
109 // Add controller to window
110 window.AddController(&h.controller.EventController)
111
112 log.Debug().Msg("keyboard handler attached to window")
113}
114
115// Detach removes the keyboard handler.
116// Note: GTK handles cleanup when the widget is destroyed,
117// but we clear our reference here.
118func (h *KeyboardHandler) Detach() {
119 h.controller = nil
120 h.keyPressedCb = nil
121}
122
123// handleKeyPress processes a key press event.
124// Returns true if the event was handled and should not propagate further.
125// Parameters:
126// - keyval: the translated key value (depends on keyboard layout)
127// - keycode: the hardware keycode (physical key position, layout-independent)
128// - state: modifier keys state
129func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
130 log := logging.FromContext(h.ctx)
131
132 h.mu.RLock()
133 shouldBypass := h.shouldBypass
134 h.mu.RUnlock()
135 if shouldBypass != nil && shouldBypass() {
136 log.Debug().Uint("keyval", keyval).Uint("keycode", keycode).Uint("state", uint(state)).Msg("keyboard handler bypassed")
137 return false
138 }
139
140 // Build KeyBinding from event
141 modifiers := Modifier(state) & modifierMask
142 binding := KeyBinding{
143 Keyval: keyval,
144 Modifiers: modifiers,
145 }
146
147 mode := h.modal.Mode()
148
149 action, found := h.lookupAction(log, binding, mode, modifiers, keycode)
150
151 if !found {
152 if mode != ModeNormal {
153 // In modal mode, consume unrecognized keys to prevent WebView handling
154 return true
155 }
156 // In normal mode, let the key pass through to WebView
157 return false
158 }
159
160 if h.handleModeAction(action) {
161 return true
162 }
163
164 // Dispatch action to handler
165 h.mu.RLock()
166 handler := h.onAction
167 h.mu.RUnlock()
168
169 if handler != nil {
170 if err := handler(h.ctx, action); err != nil {
171 log := logging.FromContext(h.ctx)
172 log.Error().
173 Err(err).
174 Str("action", string(action)).
175 Msg("action handler error")
176 }
177 }
178
179 // In resize mode, any resize keypress should extend the timeout.
180 if mode == ModeResize && isResizeAction(action) {
181 h.modal.ResetTimeout(h.ctx)
182 }
183
184 // Auto-exit modal mode after certain actions
185 if mode != ModeNormal && ShouldAutoExitMode(action) {
186 h.modal.ExitMode(h.ctx)
187 }
188
189 return true // Consumed the key
190}
191
192func (h *KeyboardHandler) lookupAction(
193 log *zerolog.Logger,
194 binding KeyBinding,
195 mode Mode,
196 modifiers Modifier,
197 keycode uint,
198) (Action, bool) {
199 action, found := h.shortcuts.Lookup(binding, mode)
200
201 // Fallback: check hardware keycode for Alt+number shortcuts on non-QWERTY layouts.
202 if !found && mode == ModeNormal && modifiers == ModAlt {
203 if keycodeAction, ok := KeycodeToTabAction[keycode]; ok {
204 action = keycodeAction
205 found = true
206 if log != nil {
207 log.Debug().
208 Uint("keycode", keycode).
209 Str("action", string(action)).
210 Msg("tab switch via hardware keycode fallback (non-QWERTY layout)")
211 }
212 }
213 }
214
215 if !found && mode == ModeNormal && (modifiers == ModAlt || modifiers == (ModAlt|ModShift)) {
216 if bracketActions, ok := KeycodeToBracketAction[keycode]; ok {
217 if modifiers == (ModAlt | ModShift) {
218 action = bracketActions.WithShift
219 } else {
220 action = bracketActions.NoShift
221 }
222 found = action != ""
223 if found && log != nil {
224 log.Debug().
225 Uint("keycode", keycode).
226 Str("action", string(action)).
227 Msg("consume/expel via hardware keycode fallback (non-QWERTY layout)")
228 }
229 }
230 }
231
232 return action, found
233}
234
235func isResizeAction(action Action) bool {
236 switch action {
237 case ActionResizeIncreaseLeft,
238 ActionResizeIncreaseRight,
239 ActionResizeIncreaseUp,
240 ActionResizeIncreaseDown,
241 ActionResizeDecreaseLeft,
242 ActionResizeDecreaseRight,
243 ActionResizeDecreaseUp,
244 ActionResizeDecreaseDown,
245 ActionResizeIncrease,
246 ActionResizeDecrease:
247 return true
248 default:
249 return false
250 }
251}
252
253func (h *KeyboardHandler) handleModeAction(action Action) bool {
254 switch action {
255 case ActionEnterTabMode:
256 timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
257 h.modal.EnterTabMode(h.ctx, timeout)
258 return true
259 case ActionEnterPaneMode:
260 timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond
261 h.modal.EnterPaneMode(h.ctx, timeout)
262 return true
263 case ActionEnterSessionMode:
264 timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond
265 h.modal.EnterSessionMode(h.ctx, timeout)
266 return true
267 case ActionEnterResizeMode:
268 if h.modal.Mode() == ModeResize {
269 h.modal.ExitMode(h.ctx)
270 return true
271 }
272 timeout := time.Duration(h.cfg.Workspace.ResizeMode.TimeoutMilliseconds) * time.Millisecond
273 h.modal.EnterResizeMode(h.ctx, timeout)
274 return true
275 case ActionExitMode:
276 h.modal.ExitMode(h.ctx)
277 return true
278 default:
279 return false
280 }
281}
282
283// EnterTabMode programmatically enters tab mode.
284// Useful for testing or programmatic mode changes.
285func (h *KeyboardHandler) EnterTabMode() {
286 timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
287 h.modal.EnterTabMode(h.ctx, timeout)
288}
289
290// EnterPaneMode programmatically enters pane mode.
291// Useful for testing or programmatic mode changes.
292func (h *KeyboardHandler) EnterPaneMode() {
293 timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond
294 h.modal.EnterPaneMode(h.ctx, timeout)
295}
296
297// EnterSessionMode programmatically enters session mode.
298// Useful for testing or programmatic mode changes.
299func (h *KeyboardHandler) EnterSessionMode() {
300 timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond
301 h.modal.EnterSessionMode(h.ctx, timeout)
302}
303
304// ExitMode programmatically exits modal mode.
305// Useful for testing or programmatic mode changes.
306func (h *KeyboardHandler) ExitMode() {
307 h.modal.ExitMode(h.ctx)
308}