detached faafca5

feat(ui): add keyboard input handling with modal modes

- Implement KeyboardHandler with GTK EventControllerKey
- Add ModalState for tab/pane mode management with timeout
- Add ShortcutSet with table-driven key binding lookup
- Support global, tab mode, and pane mode shortcuts
- Auto-exit modal mode after action execution
commit
faafca519a0742f43d7993eaebac0011cf937e6c
author
brice · · 2025-12-09 18:08 UTC
committer
brice · · 2025-12-09 19:41 UTC
parent
ba9aeae
signature
unsigned
5 files · +1460 -0
A internal/ui/input/keyboard.go +205 -0
@@ -0,0 +1,205 @@
1+package input
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/jwijenbergh/puregotk/v4/gdk"
10+ "github.com/jwijenbergh/puregotk/v4/gtk"
11+ "github.com/rs/zerolog"
12+)
13+
14+// ActionHandler is called when a keyboard action is triggered.
15+// It receives the context and the action to perform.
16+// Return an error if the action fails.
17+type ActionHandler func(ctx context.Context, action Action) error
18+
19+// KeyboardHandler processes keyboard events and dispatches actions.
20+// It manages modal input modes and routes key events to the appropriate handlers.
21+type KeyboardHandler struct {
22+ shortcuts *ShortcutSet
23+ modal *ModalState
24+ cfg *config.WorkspaceConfig
25+
26+ // Action handler callback
27+ onAction ActionHandler
28+
29+ // GTK controller (nil until attached)
30+ controller *gtk.EventControllerKey
31+
32+ ctx context.Context
33+ logger *zerolog.Logger
34+ mu sync.RWMutex
35+}
36+
37+// NewKeyboardHandler creates a new keyboard handler.
38+func NewKeyboardHandler(
39+ ctx context.Context,
40+ cfg *config.WorkspaceConfig,
41+ logger *zerolog.Logger,
42+) *KeyboardHandler {
43+ h := &KeyboardHandler{
44+ shortcuts: NewShortcutSet(cfg, logger),
45+ modal: NewModalState(logger),
46+ cfg: cfg,
47+ ctx: ctx,
48+ logger: logger,
49+ }
50+
51+ return h
52+}
53+
54+// SetOnAction sets the callback for when actions are triggered.
55+func (h *KeyboardHandler) SetOnAction(fn ActionHandler) {
56+ h.mu.Lock()
57+ defer h.mu.Unlock()
58+ h.onAction = fn
59+}
60+
61+// SetOnModeChange sets the callback for mode changes (for UI updates).
62+func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) {
63+ h.modal.SetOnModeChange(fn)
64+}
65+
66+// Mode returns the current input mode.
67+func (h *KeyboardHandler) Mode() Mode {
68+ return h.modal.Mode()
69+}
70+
71+// AttachTo attaches the keyboard handler to a GTK window.
72+// The handler will intercept key events in the capture phase.
73+func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
74+ if window == nil {
75+ h.logger.Error().Msg("cannot attach keyboard handler to nil window")
76+ return
77+ }
78+
79+ h.controller = gtk.NewEventControllerKey()
80+ if h.controller == nil {
81+ h.logger.Error().Msg("failed to create event controller key")
82+ return
83+ }
84+
85+ // Set capture phase to intercept events before WebView gets them
86+ h.controller.SetPropagationPhase(gtk.PhaseCaptureValue)
87+
88+ // Connect key pressed handler
89+ keyPressedCb := func(ctrl gtk.EventControllerKey, keyval uint, keycode uint, state gdk.ModifierType) bool {
90+ return h.handleKeyPress(keyval, keycode, state)
91+ }
92+ h.controller.ConnectKeyPressed(&keyPressedCb)
93+
94+ // Add controller to window
95+ window.AddController(&h.controller.EventController)
96+
97+ h.logger.Debug().Msg("keyboard handler attached to window")
98+}
99+
100+// Detach removes the keyboard handler.
101+// Note: GTK handles cleanup when the widget is destroyed,
102+// but we clear our reference here.
103+func (h *KeyboardHandler) Detach() {
104+ h.controller = nil
105+}
106+
107+// handleKeyPress processes a key press event.
108+// Returns true if the event was handled and should not propagate further.
109+func (h *KeyboardHandler) handleKeyPress(keyval uint, keycode uint, state gdk.ModifierType) bool {
110+ // Build KeyBinding from event
111+ modifiers := Modifier(state) & modifierMask
112+ binding := KeyBinding{
113+ Keyval: keyval,
114+ Modifiers: modifiers,
115+ }
116+
117+ mode := h.modal.Mode()
118+
119+ h.logger.Debug().
120+ Uint("keyval", keyval).
121+ Uint("keycode", keycode).
122+ Uint("modifiers", uint(modifiers)).
123+ Str("mode", mode.String()).
124+ Msg("key pressed")
125+
126+ // Look up the action for this key binding
127+ action, found := h.shortcuts.Lookup(binding, mode)
128+
129+ if !found {
130+ if mode != ModeNormal {
131+ // In modal mode, consume unrecognized keys to prevent WebView handling
132+ h.logger.Debug().
133+ Uint("keyval", keyval).
134+ Uint("modifiers", uint(modifiers)).
135+ Str("mode", mode.String()).
136+ Msg("unrecognized key in modal mode, consumed")
137+ return true
138+ }
139+ // In normal mode, let the key pass through to WebView
140+ return false
141+ }
142+
143+ // Handle special mode actions first
144+ switch action {
145+ case ActionEnterTabMode:
146+ timeout := time.Duration(h.cfg.TabMode.TimeoutMilliseconds) * time.Millisecond
147+ h.modal.EnterTabMode(timeout)
148+ return true
149+
150+ case ActionEnterPaneMode:
151+ timeout := time.Duration(h.cfg.PaneMode.TimeoutMilliseconds) * time.Millisecond
152+ h.modal.EnterPaneMode(timeout)
153+ return true
154+
155+ case ActionExitMode:
156+ h.modal.ExitMode()
157+ return true
158+ }
159+
160+ // Dispatch action to handler
161+ h.mu.RLock()
162+ handler := h.onAction
163+ h.mu.RUnlock()
164+
165+ if handler != nil {
166+ h.logger.Debug().
167+ Str("action", string(action)).
168+ Str("mode", mode.String()).
169+ Msg("dispatching action")
170+
171+ if err := handler(h.ctx, action); err != nil {
172+ h.logger.Error().
173+ Err(err).
174+ Str("action", string(action)).
175+ Msg("action handler error")
176+ }
177+ }
178+
179+ // Auto-exit modal mode after certain actions
180+ if mode != ModeNormal && ShouldAutoExitMode(action) {
181+ h.modal.ExitMode()
182+ }
183+
184+ return true // Consumed the key
185+}
186+
187+// EnterTabMode programmatically enters tab mode.
188+// Useful for testing or programmatic mode changes.
189+func (h *KeyboardHandler) EnterTabMode() {
190+ timeout := time.Duration(h.cfg.TabMode.TimeoutMilliseconds) * time.Millisecond
191+ h.modal.EnterTabMode(timeout)
192+}
193+
194+// EnterPaneMode programmatically enters pane mode.
195+// Useful for testing or programmatic mode changes.
196+func (h *KeyboardHandler) EnterPaneMode() {
197+ timeout := time.Duration(h.cfg.PaneMode.TimeoutMilliseconds) * time.Millisecond
198+ h.modal.EnterPaneMode(timeout)
199+}
200+
201+// ExitMode programmatically exits modal mode.
202+// Useful for testing or programmatic mode changes.
203+func (h *KeyboardHandler) ExitMode() {
204+ h.modal.ExitMode()
205+}
@@ -0,0 +1,205 @@
1+package input
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/jwijenbergh/puregotk/v4/gdk"
10+ "github.com/jwijenbergh/puregotk/v4/gtk"
11+ "github.com/rs/zerolog"
12+)
13+
14+// ActionHandler is called when a keyboard action is triggered.
15+// It receives the context and the action to perform.
16+// Return an error if the action fails.
17+type ActionHandler func(ctx context.Context, action Action) error
18+
19+// KeyboardHandler processes keyboard events and dispatches actions.
20+// It manages modal input modes and routes key events to the appropriate handlers.
21+type KeyboardHandler struct {
22+ shortcuts *ShortcutSet
23+ modal *ModalState
24+ cfg *config.WorkspaceConfig
25+
26+ // Action handler callback
27+ onAction ActionHandler
28+
29+ // GTK controller (nil until attached)
30+ controller *gtk.EventControllerKey
31+
32+ ctx context.Context
33+ logger *zerolog.Logger
34+ mu sync.RWMutex
35+}
36+
37+// NewKeyboardHandler creates a new keyboard handler.
38+func NewKeyboardHandler(
39+ ctx context.Context,
40+ cfg *config.WorkspaceConfig,
41+ logger *zerolog.Logger,
42+) *KeyboardHandler {
43+ h := &KeyboardHandler{
44+ shortcuts: NewShortcutSet(cfg, logger),
45+ modal: NewModalState(logger),
46+ cfg: cfg,
47+ ctx: ctx,
48+ logger: logger,
49+ }
50+
51+ return h
52+}
53+
54+// SetOnAction sets the callback for when actions are triggered.
55+func (h *KeyboardHandler) SetOnAction(fn ActionHandler) {
56+ h.mu.Lock()
57+ defer h.mu.Unlock()
58+ h.onAction = fn
59+}
60+
61+// SetOnModeChange sets the callback for mode changes (for UI updates).
62+func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) {
63+ h.modal.SetOnModeChange(fn)
64+}
65+
66+// Mode returns the current input mode.
67+func (h *KeyboardHandler) Mode() Mode {
68+ return h.modal.Mode()
69+}
70+
71+// AttachTo attaches the keyboard handler to a GTK window.
72+// The handler will intercept key events in the capture phase.
73+func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
74+ if window == nil {
75+ h.logger.Error().Msg("cannot attach keyboard handler to nil window")
76+ return
77+ }
78+
79+ h.controller = gtk.NewEventControllerKey()
80+ if h.controller == nil {
81+ h.logger.Error().Msg("failed to create event controller key")
82+ return
83+ }
84+
85+ // Set capture phase to intercept events before WebView gets them
86+ h.controller.SetPropagationPhase(gtk.PhaseCaptureValue)
87+
88+ // Connect key pressed handler
89+ keyPressedCb := func(ctrl gtk.EventControllerKey, keyval uint, keycode uint, state gdk.ModifierType) bool {
90+ return h.handleKeyPress(keyval, keycode, state)
91+ }
92+ h.controller.ConnectKeyPressed(&keyPressedCb)
93+
94+ // Add controller to window
95+ window.AddController(&h.controller.EventController)
96+
97+ h.logger.Debug().Msg("keyboard handler attached to window")
98+}
99+
100+// Detach removes the keyboard handler.
101+// Note: GTK handles cleanup when the widget is destroyed,
102+// but we clear our reference here.
103+func (h *KeyboardHandler) Detach() {
104+ h.controller = nil
105+}
106+
107+// handleKeyPress processes a key press event.
108+// Returns true if the event was handled and should not propagate further.
109+func (h *KeyboardHandler) handleKeyPress(keyval uint, keycode uint, state gdk.ModifierType) bool {
110+ // Build KeyBinding from event
111+ modifiers := Modifier(state) & modifierMask
112+ binding := KeyBinding{
113+ Keyval: keyval,
114+ Modifiers: modifiers,
115+ }
116+
117+ mode := h.modal.Mode()
118+
119+ h.logger.Debug().
120+ Uint("keyval", keyval).
121+ Uint("keycode", keycode).
122+ Uint("modifiers", uint(modifiers)).
123+ Str("mode", mode.String()).
124+ Msg("key pressed")
125+
126+ // Look up the action for this key binding
127+ action, found := h.shortcuts.Lookup(binding, mode)
128+
129+ if !found {
130+ if mode != ModeNormal {
131+ // In modal mode, consume unrecognized keys to prevent WebView handling
132+ h.logger.Debug().
133+ Uint("keyval", keyval).
134+ Uint("modifiers", uint(modifiers)).
135+ Str("mode", mode.String()).
136+ Msg("unrecognized key in modal mode, consumed")
137+ return true
138+ }
139+ // In normal mode, let the key pass through to WebView
140+ return false
141+ }
142+
143+ // Handle special mode actions first
144+ switch action {
145+ case ActionEnterTabMode:
146+ timeout := time.Duration(h.cfg.TabMode.TimeoutMilliseconds) * time.Millisecond
147+ h.modal.EnterTabMode(timeout)
148+ return true
149+
150+ case ActionEnterPaneMode:
151+ timeout := time.Duration(h.cfg.PaneMode.TimeoutMilliseconds) * time.Millisecond
152+ h.modal.EnterPaneMode(timeout)
153+ return true
154+
155+ case ActionExitMode:
156+ h.modal.ExitMode()
157+ return true
158+ }
159+
160+ // Dispatch action to handler
161+ h.mu.RLock()
162+ handler := h.onAction
163+ h.mu.RUnlock()
164+
165+ if handler != nil {
166+ h.logger.Debug().
167+ Str("action", string(action)).
168+ Str("mode", mode.String()).
169+ Msg("dispatching action")
170+
171+ if err := handler(h.ctx, action); err != nil {
172+ h.logger.Error().
173+ Err(err).
174+ Str("action", string(action)).
175+ Msg("action handler error")
176+ }
177+ }
178+
179+ // Auto-exit modal mode after certain actions
180+ if mode != ModeNormal && ShouldAutoExitMode(action) {
181+ h.modal.ExitMode()
182+ }
183+
184+ return true // Consumed the key
185+}
186+
187+// EnterTabMode programmatically enters tab mode.
188+// Useful for testing or programmatic mode changes.
189+func (h *KeyboardHandler) EnterTabMode() {
190+ timeout := time.Duration(h.cfg.TabMode.TimeoutMilliseconds) * time.Millisecond
191+ h.modal.EnterTabMode(timeout)
192+}
193+
194+// EnterPaneMode programmatically enters pane mode.
195+// Useful for testing or programmatic mode changes.
196+func (h *KeyboardHandler) EnterPaneMode() {
197+ timeout := time.Duration(h.cfg.PaneMode.TimeoutMilliseconds) * time.Millisecond
198+ h.modal.EnterPaneMode(timeout)
199+}
200+
201+// ExitMode programmatically exits modal mode.
202+// Useful for testing or programmatic mode changes.
203+func (h *KeyboardHandler) ExitMode() {
204+ h.modal.ExitMode()
205+}
A internal/ui/input/modal.go +202 -0
@@ -0,0 +1,202 @@
1+package input
2+
3+import (
4+ "sync"
5+ "time"
6+
7+ "github.com/rs/zerolog"
8+)
9+
10+// Mode represents the current input mode.
11+type Mode int
12+
13+const (
14+ // ModeNormal is the default mode where keys pass through to WebView.
15+ ModeNormal Mode = iota
16+ // ModeTab is the modal tab management mode.
17+ ModeTab
18+ // ModePane is the modal pane management mode.
19+ ModePane
20+)
21+
22+// String returns a human-readable mode name.
23+func (m Mode) String() string {
24+ switch m {
25+ case ModeNormal:
26+ return "normal"
27+ case ModeTab:
28+ return "tab"
29+ case ModePane:
30+ return "pane"
31+ default:
32+ return "unknown"
33+ }
34+}
35+
36+// ModalState manages the current input mode with optional timeout.
37+type ModalState struct {
38+ mode Mode
39+ timeout time.Duration
40+ timer *time.Timer
41+
42+ // Callback for mode changes (called synchronously under lock).
43+ onModeChange func(from, to Mode)
44+
45+ logger *zerolog.Logger
46+ mu sync.RWMutex
47+}
48+
49+// NewModalState creates a new modal state manager.
50+func NewModalState(logger *zerolog.Logger) *ModalState {
51+ return &ModalState{
52+ mode: ModeNormal,
53+ logger: logger,
54+ }
55+}
56+
57+// Mode returns the current mode (thread-safe).
58+func (m *ModalState) Mode() Mode {
59+ m.mu.RLock()
60+ defer m.mu.RUnlock()
61+ return m.mode
62+}
63+
64+// EnterTabMode switches to tab mode with an optional timeout.
65+// If timeout is 0, the mode stays until explicitly exited.
66+func (m *ModalState) EnterTabMode(timeout time.Duration) {
67+ m.mu.Lock()
68+ defer m.mu.Unlock()
69+
70+ if m.mode == ModeTab {
71+ // Already in tab mode, just reset timeout
72+ m.resetTimeoutLocked(timeout)
73+ return
74+ }
75+
76+ m.cancelTimerLocked()
77+ oldMode := m.mode
78+ m.mode = ModeTab
79+ m.timeout = timeout
80+
81+ if timeout > 0 {
82+ m.timer = time.AfterFunc(timeout, func() {
83+ m.ExitMode()
84+ })
85+ }
86+
87+ if m.logger != nil {
88+ m.logger.Debug().
89+ Str("from", oldMode.String()).
90+ Str("to", m.mode.String()).
91+ Dur("timeout", timeout).
92+ Msg("entered tab mode")
93+ }
94+
95+ if m.onModeChange != nil {
96+ m.onModeChange(oldMode, m.mode)
97+ }
98+}
99+
100+// EnterPaneMode switches to pane mode with an optional timeout.
101+// If timeout is 0, the mode stays until explicitly exited.
102+func (m *ModalState) EnterPaneMode(timeout time.Duration) {
103+ m.mu.Lock()
104+ defer m.mu.Unlock()
105+
106+ if m.mode == ModePane {
107+ // Already in pane mode, just reset timeout
108+ m.resetTimeoutLocked(timeout)
109+ return
110+ }
111+
112+ m.cancelTimerLocked()
113+ oldMode := m.mode
114+ m.mode = ModePane
115+ m.timeout = timeout
116+
117+ if timeout > 0 {
118+ m.timer = time.AfterFunc(timeout, func() {
119+ m.ExitMode()
120+ })
121+ }
122+
123+ if m.logger != nil {
124+ m.logger.Debug().
125+ Str("from", oldMode.String()).
126+ Str("to", m.mode.String()).
127+ Dur("timeout", timeout).
128+ Msg("entered pane mode")
129+ }
130+
131+ if m.onModeChange != nil {
132+ m.onModeChange(oldMode, m.mode)
133+ }
134+}
135+
136+// ExitMode returns to normal mode.
137+func (m *ModalState) ExitMode() {
138+ m.mu.Lock()
139+ defer m.mu.Unlock()
140+
141+ if m.mode == ModeNormal {
142+ return
143+ }
144+
145+ m.cancelTimerLocked()
146+ oldMode := m.mode
147+ m.mode = ModeNormal
148+ m.timeout = 0
149+
150+ if m.logger != nil {
151+ m.logger.Debug().
152+ Str("from", oldMode.String()).
153+ Str("to", "normal").
154+ Msg("exited modal mode")
155+ }
156+
157+ if m.onModeChange != nil {
158+ m.onModeChange(oldMode, ModeNormal)
159+ }
160+}
161+
162+// ResetTimeout restarts the mode timeout (e.g., after a valid keystroke).
163+// Does nothing if not in a modal mode or if no timeout was set.
164+func (m *ModalState) ResetTimeout() {
165+ m.mu.Lock()
166+ defer m.mu.Unlock()
167+
168+ if m.mode == ModeNormal || m.timeout == 0 {
169+ return
170+ }
171+
172+ m.resetTimeoutLocked(m.timeout)
173+}
174+
175+// SetOnModeChange sets the callback for mode changes.
176+// The callback is invoked synchronously under the lock.
177+func (m *ModalState) SetOnModeChange(fn func(from, to Mode)) {
178+ m.mu.Lock()
179+ defer m.mu.Unlock()
180+ m.onModeChange = fn
181+}
182+
183+// cancelTimerLocked stops any active timeout timer.
184+// Must be called with m.mu held.
185+func (m *ModalState) cancelTimerLocked() {
186+ if m.timer != nil {
187+ m.timer.Stop()
188+ m.timer = nil
189+ }
190+}
191+
192+// resetTimeoutLocked resets the timeout timer.
193+// Must be called with m.mu held.
194+func (m *ModalState) resetTimeoutLocked(timeout time.Duration) {
195+ m.cancelTimerLocked()
196+ m.timeout = timeout
197+ if timeout > 0 {
198+ m.timer = time.AfterFunc(timeout, func() {
199+ m.ExitMode()
200+ })
201+ }
202+}
@@ -0,0 +1,202 @@
1+package input
2+
3+import (
4+ "sync"
5+ "time"
6+
7+ "github.com/rs/zerolog"
8+)
9+
10+// Mode represents the current input mode.
11+type Mode int
12+
13+const (
14+ // ModeNormal is the default mode where keys pass through to WebView.
15+ ModeNormal Mode = iota
16+ // ModeTab is the modal tab management mode.
17+ ModeTab
18+ // ModePane is the modal pane management mode.
19+ ModePane
20+)
21+
22+// String returns a human-readable mode name.
23+func (m Mode) String() string {
24+ switch m {
25+ case ModeNormal:
26+ return "normal"
27+ case ModeTab:
28+ return "tab"
29+ case ModePane:
30+ return "pane"
31+ default:
32+ return "unknown"
33+ }
34+}
35+
36+// ModalState manages the current input mode with optional timeout.
37+type ModalState struct {
38+ mode Mode
39+ timeout time.Duration
40+ timer *time.Timer
41+
42+ // Callback for mode changes (called synchronously under lock).
43+ onModeChange func(from, to Mode)
44+
45+ logger *zerolog.Logger
46+ mu sync.RWMutex
47+}
48+
49+// NewModalState creates a new modal state manager.
50+func NewModalState(logger *zerolog.Logger) *ModalState {
51+ return &ModalState{
52+ mode: ModeNormal,
53+ logger: logger,
54+ }
55+}
56+
57+// Mode returns the current mode (thread-safe).
58+func (m *ModalState) Mode() Mode {
59+ m.mu.RLock()
60+ defer m.mu.RUnlock()
61+ return m.mode
62+}
63+
64+// EnterTabMode switches to tab mode with an optional timeout.
65+// If timeout is 0, the mode stays until explicitly exited.
66+func (m *ModalState) EnterTabMode(timeout time.Duration) {
67+ m.mu.Lock()
68+ defer m.mu.Unlock()
69+
70+ if m.mode == ModeTab {
71+ // Already in tab mode, just reset timeout
72+ m.resetTimeoutLocked(timeout)
73+ return
74+ }
75+
76+ m.cancelTimerLocked()
77+ oldMode := m.mode
78+ m.mode = ModeTab
79+ m.timeout = timeout
80+
81+ if timeout > 0 {
82+ m.timer = time.AfterFunc(timeout, func() {
83+ m.ExitMode()
84+ })
85+ }
86+
87+ if m.logger != nil {
88+ m.logger.Debug().
89+ Str("from", oldMode.String()).
90+ Str("to", m.mode.String()).
91+ Dur("timeout", timeout).
92+ Msg("entered tab mode")
93+ }
94+
95+ if m.onModeChange != nil {
96+ m.onModeChange(oldMode, m.mode)
97+ }
98+}
99+
100+// EnterPaneMode switches to pane mode with an optional timeout.
101+// If timeout is 0, the mode stays until explicitly exited.
102+func (m *ModalState) EnterPaneMode(timeout time.Duration) {
103+ m.mu.Lock()
104+ defer m.mu.Unlock()
105+
106+ if m.mode == ModePane {
107+ // Already in pane mode, just reset timeout
108+ m.resetTimeoutLocked(timeout)
109+ return
110+ }
111+
112+ m.cancelTimerLocked()
113+ oldMode := m.mode
114+ m.mode = ModePane
115+ m.timeout = timeout
116+
117+ if timeout > 0 {
118+ m.timer = time.AfterFunc(timeout, func() {
119+ m.ExitMode()
120+ })
121+ }
122+
123+ if m.logger != nil {
124+ m.logger.Debug().
125+ Str("from", oldMode.String()).
126+ Str("to", m.mode.String()).
127+ Dur("timeout", timeout).
128+ Msg("entered pane mode")
129+ }
130+
131+ if m.onModeChange != nil {
132+ m.onModeChange(oldMode, m.mode)
133+ }
134+}
135+
136+// ExitMode returns to normal mode.
137+func (m *ModalState) ExitMode() {
138+ m.mu.Lock()
139+ defer m.mu.Unlock()
140+
141+ if m.mode == ModeNormal {
142+ return
143+ }
144+
145+ m.cancelTimerLocked()
146+ oldMode := m.mode
147+ m.mode = ModeNormal
148+ m.timeout = 0
149+
150+ if m.logger != nil {
151+ m.logger.Debug().
152+ Str("from", oldMode.String()).
153+ Str("to", "normal").
154+ Msg("exited modal mode")
155+ }
156+
157+ if m.onModeChange != nil {
158+ m.onModeChange(oldMode, ModeNormal)
159+ }
160+}
161+
162+// ResetTimeout restarts the mode timeout (e.g., after a valid keystroke).
163+// Does nothing if not in a modal mode or if no timeout was set.
164+func (m *ModalState) ResetTimeout() {
165+ m.mu.Lock()
166+ defer m.mu.Unlock()
167+
168+ if m.mode == ModeNormal || m.timeout == 0 {
169+ return
170+ }
171+
172+ m.resetTimeoutLocked(m.timeout)
173+}
174+
175+// SetOnModeChange sets the callback for mode changes.
176+// The callback is invoked synchronously under the lock.
177+func (m *ModalState) SetOnModeChange(fn func(from, to Mode)) {
178+ m.mu.Lock()
179+ defer m.mu.Unlock()
180+ m.onModeChange = fn
181+}
182+
183+// cancelTimerLocked stops any active timeout timer.
184+// Must be called with m.mu held.
185+func (m *ModalState) cancelTimerLocked() {
186+ if m.timer != nil {
187+ m.timer.Stop()
188+ m.timer = nil
189+ }
190+}
191+
192+// resetTimeoutLocked resets the timeout timer.
193+// Must be called with m.mu held.
194+func (m *ModalState) resetTimeoutLocked(timeout time.Duration) {
195+ m.cancelTimerLocked()
196+ m.timeout = timeout
197+ if timeout > 0 {
198+ m.timer = time.AfterFunc(timeout, func() {
199+ m.ExitMode()
200+ })
201+ }
202+}
A internal/ui/input/modal_test.go +268 -0
@@ -0,0 +1,268 @@
1+package input
2+
3+import (
4+ "sync"
5+ "testing"
6+ "time"
7+)
8+
9+func TestModalState_InitialState(t *testing.T) {
10+ ms := NewModalState(nil)
11+
12+ if ms.Mode() != ModeNormal {
13+ t.Errorf("initial mode = %v, want ModeNormal", ms.Mode())
14+ }
15+}
16+
17+func TestModalState_EnterTabMode(t *testing.T) {
18+ ms := NewModalState(nil)
19+
20+ ms.EnterTabMode(0)
21+
22+ if ms.Mode() != ModeTab {
23+ t.Errorf("mode after EnterTabMode = %v, want ModeTab", ms.Mode())
24+ }
25+}
26+
27+func TestModalState_EnterPaneMode(t *testing.T) {
28+ ms := NewModalState(nil)
29+
30+ ms.EnterPaneMode(0)
31+
32+ if ms.Mode() != ModePane {
33+ t.Errorf("mode after EnterPaneMode = %v, want ModePane", ms.Mode())
34+ }
35+}
36+
37+func TestModalState_ExitMode(t *testing.T) {
38+ ms := NewModalState(nil)
39+
40+ // Enter tab mode
41+ ms.EnterTabMode(0)
42+ if ms.Mode() != ModeTab {
43+ t.Fatalf("mode should be ModeTab")
44+ }
45+
46+ // Exit mode
47+ ms.ExitMode()
48+ if ms.Mode() != ModeNormal {
49+ t.Errorf("mode after ExitMode = %v, want ModeNormal", ms.Mode())
50+ }
51+}
52+
53+func TestModalState_ExitMode_FromNormal(t *testing.T) {
54+ ms := NewModalState(nil)
55+
56+ // ExitMode from normal should be no-op
57+ ms.ExitMode()
58+
59+ if ms.Mode() != ModeNormal {
60+ t.Errorf("mode = %v, want ModeNormal", ms.Mode())
61+ }
62+}
63+
64+func TestModalState_SwitchModes(t *testing.T) {
65+ ms := NewModalState(nil)
66+
67+ // Enter tab mode
68+ ms.EnterTabMode(0)
69+ if ms.Mode() != ModeTab {
70+ t.Fatalf("expected ModeTab, got %v", ms.Mode())
71+ }
72+
73+ // Switch to pane mode directly
74+ ms.EnterPaneMode(0)
75+ if ms.Mode() != ModePane {
76+ t.Errorf("mode after switch to pane = %v, want ModePane", ms.Mode())
77+ }
78+
79+ // Switch back to tab mode
80+ ms.EnterTabMode(0)
81+ if ms.Mode() != ModeTab {
82+ t.Errorf("mode after switch to tab = %v, want ModeTab", ms.Mode())
83+ }
84+}
85+
86+func TestModalState_ModeChangeCallback(t *testing.T) {
87+ ms := NewModalState(nil)
88+
89+ var callCount int
90+ var lastFrom, lastTo Mode
91+ var mu sync.Mutex
92+
93+ ms.SetOnModeChange(func(from, to Mode) {
94+ mu.Lock()
95+ defer mu.Unlock()
96+ callCount++
97+ lastFrom = from
98+ lastTo = to
99+ })
100+
101+ // Enter tab mode
102+ ms.EnterTabMode(0)
103+
104+ mu.Lock()
105+ if callCount != 1 {
106+ t.Errorf("callback count = %d, want 1", callCount)
107+ }
108+ if lastFrom != ModeNormal || lastTo != ModeTab {
109+ t.Errorf("callback args = (%v, %v), want (ModeNormal, ModeTab)", lastFrom, lastTo)
110+ }
111+ mu.Unlock()
112+
113+ // Exit mode
114+ ms.ExitMode()
115+
116+ mu.Lock()
117+ if callCount != 2 {
118+ t.Errorf("callback count = %d, want 2", callCount)
119+ }
120+ if lastFrom != ModeTab || lastTo != ModeNormal {
121+ t.Errorf("callback args = (%v, %v), want (ModeTab, ModeNormal)", lastFrom, lastTo)
122+ }
123+ mu.Unlock()
124+}
125+
126+func TestModalState_NoCallbackWhenAlreadyInMode(t *testing.T) {
127+ ms := NewModalState(nil)
128+
129+ var callCount int
130+ ms.SetOnModeChange(func(from, to Mode) {
131+ callCount++
132+ })
133+
134+ // Enter tab mode
135+ ms.EnterTabMode(0)
136+ if callCount != 1 {
137+ t.Fatalf("expected 1 callback, got %d", callCount)
138+ }
139+
140+ // Enter tab mode again (should not trigger callback)
141+ ms.EnterTabMode(0)
142+ if callCount != 1 {
143+ t.Errorf("callback count = %d, want 1 (no new call)", callCount)
144+ }
145+}
146+
147+func TestModalState_Timeout(t *testing.T) {
148+ ms := NewModalState(nil)
149+
150+ // Enter with short timeout
151+ ms.EnterTabMode(50 * time.Millisecond)
152+
153+ if ms.Mode() != ModeTab {
154+ t.Fatalf("mode should be ModeTab")
155+ }
156+
157+ // Wait for timeout
158+ time.Sleep(100 * time.Millisecond)
159+
160+ if ms.Mode() != ModeNormal {
161+ t.Errorf("mode after timeout = %v, want ModeNormal", ms.Mode())
162+ }
163+}
164+
165+func TestModalState_TimeoutReset(t *testing.T) {
166+ ms := NewModalState(nil)
167+
168+ // Enter with timeout
169+ ms.EnterTabMode(100 * time.Millisecond)
170+
171+ // Wait a bit, then reset
172+ time.Sleep(50 * time.Millisecond)
173+ ms.ResetTimeout()
174+
175+ // Wait a bit more (original timeout would have expired)
176+ time.Sleep(80 * time.Millisecond)
177+
178+ // Should still be in tab mode because timeout was reset
179+ if ms.Mode() != ModeTab {
180+ t.Errorf("mode = %v, want ModeTab (timeout should have been reset)", ms.Mode())
181+ }
182+
183+ // Wait for the reset timeout to expire
184+ time.Sleep(50 * time.Millisecond)
185+
186+ if ms.Mode() != ModeNormal {
187+ t.Errorf("mode after reset timeout = %v, want ModeNormal", ms.Mode())
188+ }
189+}
190+
191+func TestModalState_ExitCancelsTimeout(t *testing.T) {
192+ ms := NewModalState(nil)
193+
194+ var callCount int
195+ ms.SetOnModeChange(func(from, to Mode) {
196+ callCount++
197+ })
198+
199+ // Enter with timeout
200+ ms.EnterTabMode(100 * time.Millisecond)
201+ if callCount != 1 {
202+ t.Fatalf("expected 1 callback")
203+ }
204+
205+ // Exit immediately
206+ ms.ExitMode()
207+ if callCount != 2 {
208+ t.Fatalf("expected 2 callbacks")
209+ }
210+
211+ // Wait past the timeout
212+ time.Sleep(150 * time.Millisecond)
213+
214+ // Callback should not have been called again
215+ if callCount != 2 {
216+ t.Errorf("callback count = %d, want 2 (timeout should have been cancelled)", callCount)
217+ }
218+}
219+
220+func TestModalState_ConcurrentAccess(t *testing.T) {
221+ ms := NewModalState(nil)
222+
223+ var wg sync.WaitGroup
224+ iterations := 100
225+
226+ // Multiple goroutines entering/exiting modes
227+ for i := 0; i < 10; i++ {
228+ wg.Add(1)
229+ go func() {
230+ defer wg.Done()
231+ for j := 0; j < iterations; j++ {
232+ ms.EnterTabMode(0)
233+ _ = ms.Mode()
234+ ms.ExitMode()
235+ ms.EnterPaneMode(0)
236+ _ = ms.Mode()
237+ ms.ExitMode()
238+ }
239+ }()
240+ }
241+
242+ wg.Wait()
243+
244+ // Should end up in normal mode
245+ if ms.Mode() != ModeNormal {
246+ t.Errorf("final mode = %v, want ModeNormal", ms.Mode())
247+ }
248+}
249+
250+func TestMode_String(t *testing.T) {
251+ tests := []struct {
252+ mode Mode
253+ want string
254+ }{
255+ {ModeNormal, "normal"},
256+ {ModeTab, "tab"},
257+ {ModePane, "pane"},
258+ {Mode(99), "unknown"},
259+ }
260+
261+ for _, tt := range tests {
262+ t.Run(tt.want, func(t *testing.T) {
263+ if got := tt.mode.String(); got != tt.want {
264+ t.Errorf("Mode.String() = %v, want %v", got, tt.want)
265+ }
266+ })
267+ }
268+}
@@ -0,0 +1,268 @@
1+package input
2+
3+import (
4+ "sync"
5+ "testing"
6+ "time"
7+)
8+
9+func TestModalState_InitialState(t *testing.T) {
10+ ms := NewModalState(nil)
11+
12+ if ms.Mode() != ModeNormal {
13+ t.Errorf("initial mode = %v, want ModeNormal", ms.Mode())
14+ }
15+}
16+
17+func TestModalState_EnterTabMode(t *testing.T) {
18+ ms := NewModalState(nil)
19+
20+ ms.EnterTabMode(0)
21+
22+ if ms.Mode() != ModeTab {
23+ t.Errorf("mode after EnterTabMode = %v, want ModeTab", ms.Mode())
24+ }
25+}
26+
27+func TestModalState_EnterPaneMode(t *testing.T) {
28+ ms := NewModalState(nil)
29+
30+ ms.EnterPaneMode(0)
31+
32+ if ms.Mode() != ModePane {
33+ t.Errorf("mode after EnterPaneMode = %v, want ModePane", ms.Mode())
34+ }
35+}
36+
37+func TestModalState_ExitMode(t *testing.T) {
38+ ms := NewModalState(nil)
39+
40+ // Enter tab mode
41+ ms.EnterTabMode(0)
42+ if ms.Mode() != ModeTab {
43+ t.Fatalf("mode should be ModeTab")
44+ }
45+
46+ // Exit mode
47+ ms.ExitMode()
48+ if ms.Mode() != ModeNormal {
49+ t.Errorf("mode after ExitMode = %v, want ModeNormal", ms.Mode())
50+ }
51+}
52+
53+func TestModalState_ExitMode_FromNormal(t *testing.T) {
54+ ms := NewModalState(nil)
55+
56+ // ExitMode from normal should be no-op
57+ ms.ExitMode()
58+
59+ if ms.Mode() != ModeNormal {
60+ t.Errorf("mode = %v, want ModeNormal", ms.Mode())
61+ }
62+}
63+
64+func TestModalState_SwitchModes(t *testing.T) {
65+ ms := NewModalState(nil)
66+
67+ // Enter tab mode
68+ ms.EnterTabMode(0)
69+ if ms.Mode() != ModeTab {
70+ t.Fatalf("expected ModeTab, got %v", ms.Mode())
71+ }
72+
73+ // Switch to pane mode directly
74+ ms.EnterPaneMode(0)
75+ if ms.Mode() != ModePane {
76+ t.Errorf("mode after switch to pane = %v, want ModePane", ms.Mode())
77+ }
78+
79+ // Switch back to tab mode
80+ ms.EnterTabMode(0)
81+ if ms.Mode() != ModeTab {
82+ t.Errorf("mode after switch to tab = %v, want ModeTab", ms.Mode())
83+ }
84+}
85+
86+func TestModalState_ModeChangeCallback(t *testing.T) {
87+ ms := NewModalState(nil)
88+
89+ var callCount int
90+ var lastFrom, lastTo Mode
91+ var mu sync.Mutex
92+
93+ ms.SetOnModeChange(func(from, to Mode) {
94+ mu.Lock()
95+ defer mu.Unlock()
96+ callCount++
97+ lastFrom = from
98+ lastTo = to
99+ })
100+
101+ // Enter tab mode
102+ ms.EnterTabMode(0)
103+
104+ mu.Lock()
105+ if callCount != 1 {
106+ t.Errorf("callback count = %d, want 1", callCount)
107+ }
108+ if lastFrom != ModeNormal || lastTo != ModeTab {
109+ t.Errorf("callback args = (%v, %v), want (ModeNormal, ModeTab)", lastFrom, lastTo)
110+ }
111+ mu.Unlock()
112+
113+ // Exit mode
114+ ms.ExitMode()
115+
116+ mu.Lock()
117+ if callCount != 2 {
118+ t.Errorf("callback count = %d, want 2", callCount)
119+ }
120+ if lastFrom != ModeTab || lastTo != ModeNormal {
121+ t.Errorf("callback args = (%v, %v), want (ModeTab, ModeNormal)", lastFrom, lastTo)
122+ }
123+ mu.Unlock()
124+}
125+
126+func TestModalState_NoCallbackWhenAlreadyInMode(t *testing.T) {
127+ ms := NewModalState(nil)
128+
129+ var callCount int
130+ ms.SetOnModeChange(func(from, to Mode) {
131+ callCount++
132+ })
133+
134+ // Enter tab mode
135+ ms.EnterTabMode(0)
136+ if callCount != 1 {
137+ t.Fatalf("expected 1 callback, got %d", callCount)
138+ }
139+
140+ // Enter tab mode again (should not trigger callback)
141+ ms.EnterTabMode(0)
142+ if callCount != 1 {
143+ t.Errorf("callback count = %d, want 1 (no new call)", callCount)
144+ }
145+}
146+
147+func TestModalState_Timeout(t *testing.T) {
148+ ms := NewModalState(nil)
149+
150+ // Enter with short timeout
151+ ms.EnterTabMode(50 * time.Millisecond)
152+
153+ if ms.Mode() != ModeTab {
154+ t.Fatalf("mode should be ModeTab")
155+ }
156+
157+ // Wait for timeout
158+ time.Sleep(100 * time.Millisecond)
159+
160+ if ms.Mode() != ModeNormal {
161+ t.Errorf("mode after timeout = %v, want ModeNormal", ms.Mode())
162+ }
163+}
164+
165+func TestModalState_TimeoutReset(t *testing.T) {
166+ ms := NewModalState(nil)
167+
168+ // Enter with timeout
169+ ms.EnterTabMode(100 * time.Millisecond)
170+
171+ // Wait a bit, then reset
172+ time.Sleep(50 * time.Millisecond)
173+ ms.ResetTimeout()
174+
175+ // Wait a bit more (original timeout would have expired)
176+ time.Sleep(80 * time.Millisecond)
177+
178+ // Should still be in tab mode because timeout was reset
179+ if ms.Mode() != ModeTab {
180+ t.Errorf("mode = %v, want ModeTab (timeout should have been reset)", ms.Mode())
181+ }
182+
183+ // Wait for the reset timeout to expire
184+ time.Sleep(50 * time.Millisecond)
185+
186+ if ms.Mode() != ModeNormal {
187+ t.Errorf("mode after reset timeout = %v, want ModeNormal", ms.Mode())
188+ }
189+}
190+
191+func TestModalState_ExitCancelsTimeout(t *testing.T) {
192+ ms := NewModalState(nil)
193+
194+ var callCount int
195+ ms.SetOnModeChange(func(from, to Mode) {
196+ callCount++
197+ })
198+
199+ // Enter with timeout
200+ ms.EnterTabMode(100 * time.Millisecond)
201+ if callCount != 1 {
202+ t.Fatalf("expected 1 callback")
203+ }
204+
205+ // Exit immediately
206+ ms.ExitMode()
207+ if callCount != 2 {
208+ t.Fatalf("expected 2 callbacks")
209+ }
210+
211+ // Wait past the timeout
212+ time.Sleep(150 * time.Millisecond)
213+
214+ // Callback should not have been called again
215+ if callCount != 2 {
216+ t.Errorf("callback count = %d, want 2 (timeout should have been cancelled)", callCount)
217+ }
218+}
219+
220+func TestModalState_ConcurrentAccess(t *testing.T) {
221+ ms := NewModalState(nil)
222+
223+ var wg sync.WaitGroup
224+ iterations := 100
225+
226+ // Multiple goroutines entering/exiting modes
227+ for i := 0; i < 10; i++ {
228+ wg.Add(1)
229+ go func() {
230+ defer wg.Done()
231+ for j := 0; j < iterations; j++ {
232+ ms.EnterTabMode(0)
233+ _ = ms.Mode()
234+ ms.ExitMode()
235+ ms.EnterPaneMode(0)
236+ _ = ms.Mode()
237+ ms.ExitMode()
238+ }
239+ }()
240+ }
241+
242+ wg.Wait()
243+
244+ // Should end up in normal mode
245+ if ms.Mode() != ModeNormal {
246+ t.Errorf("final mode = %v, want ModeNormal", ms.Mode())
247+ }
248+}
249+
250+func TestMode_String(t *testing.T) {
251+ tests := []struct {
252+ mode Mode
253+ want string
254+ }{
255+ {ModeNormal, "normal"},
256+ {ModeTab, "tab"},
257+ {ModePane, "pane"},
258+ {Mode(99), "unknown"},
259+ }
260+
261+ for _, tt := range tests {
262+ t.Run(tt.want, func(t *testing.T) {
263+ if got := tt.mode.String(); got != tt.want {
264+ t.Errorf("Mode.String() = %v, want %v", got, tt.want)
265+ }
266+ })
267+ }
268+}
A internal/ui/input/shortcuts.go +435 -0
@@ -0,0 +1,435 @@
1+// Package input provides keyboard event handling and modal input mode management.
2+package input
3+
4+import (
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/jwijenbergh/puregotk/v4/gdk"
9+ "github.com/rs/zerolog"
10+)
11+
12+// Modifier represents keyboard modifier flags.
13+type Modifier uint
14+
15+const (
16+ // ModNone indicates no modifier is pressed.
17+ ModNone Modifier = 0
18+ // ModShift indicates the Shift key is pressed.
19+ ModShift Modifier = Modifier(gdk.ShiftMaskValue)
20+ // ModCtrl indicates the Control key is pressed.
21+ ModCtrl Modifier = Modifier(gdk.ControlMaskValue)
22+ // ModAlt indicates the Alt key is pressed.
23+ ModAlt Modifier = Modifier(gdk.AltMaskValue)
24+)
25+
26+// modifierMask filters out non-standard modifiers from GDK state.
27+const modifierMask = ModCtrl | ModShift | ModAlt
28+
29+// KeyBinding represents a single key combination.
30+type KeyBinding struct {
31+ Keyval uint // GDK key value (e.g., gdk.KEY_t)
32+ Modifiers Modifier // Combined modifiers
33+}
34+
35+// Action represents what happens when a shortcut is triggered.
36+type Action string
37+
38+// Predefined actions for the keyboard system.
39+const (
40+ // Mode management
41+ ActionEnterTabMode Action = "enter_tab_mode"
42+ ActionEnterPaneMode Action = "enter_pane_mode"
43+ ActionExitMode Action = "exit_mode"
44+
45+ // Tab actions (global and modal)
46+ ActionNewTab Action = "new_tab"
47+ ActionCloseTab Action = "close_tab"
48+ ActionNextTab Action = "next_tab"
49+ ActionPreviousTab Action = "previous_tab"
50+ ActionRenameTab Action = "rename_tab"
51+
52+ // Pane actions (modal)
53+ ActionSplitRight Action = "split_right"
54+ ActionSplitLeft Action = "split_left"
55+ ActionSplitUp Action = "split_up"
56+ ActionSplitDown Action = "split_down"
57+ ActionClosePane Action = "close_pane"
58+ ActionStackPane Action = "stack_pane"
59+
60+ // Pane focus navigation
61+ ActionFocusRight Action = "focus_right"
62+ ActionFocusLeft Action = "focus_left"
63+ ActionFocusUp Action = "focus_up"
64+ ActionFocusDown Action = "focus_down"
65+
66+ // Page navigation
67+ ActionGoBack Action = "go_back"
68+ ActionGoForward Action = "go_forward"
69+ ActionReload Action = "reload"
70+ ActionHardReload Action = "hard_reload"
71+ ActionStop Action = "stop"
72+
73+ // Zoom
74+ ActionZoomIn Action = "zoom_in"
75+ ActionZoomOut Action = "zoom_out"
76+ ActionZoomReset Action = "zoom_reset"
77+
78+ // UI
79+ ActionOpenOmnibox Action = "open_omnibox"
80+ ActionOpenDevTools Action = "open_devtools"
81+ ActionToggleFullscreen Action = "toggle_fullscreen"
82+
83+ // Application
84+ ActionQuit Action = "quit"
85+)
86+
87+// ShortcutTable maps KeyBinding to Action.
88+type ShortcutTable map[KeyBinding]Action
89+
90+// ShortcutSet holds all shortcut tables organized by context.
91+type ShortcutSet struct {
92+ // Global shortcuts are always active regardless of mode.
93+ Global ShortcutTable
94+ // TabMode shortcuts are only active in tab mode.
95+ TabMode ShortcutTable
96+ // PaneMode shortcuts are only active in pane mode.
97+ PaneMode ShortcutTable
98+
99+ logger *zerolog.Logger
100+}
101+
102+// NewShortcutSet creates a ShortcutSet from the workspace configuration.
103+func NewShortcutSet(cfg *config.WorkspaceConfig, logger *zerolog.Logger) *ShortcutSet {
104+ set := &ShortcutSet{
105+ Global: make(ShortcutTable),
106+ TabMode: make(ShortcutTable),
107+ PaneMode: make(ShortcutTable),
108+ logger: logger,
109+ }
110+
111+ set.buildGlobalShortcuts(cfg)
112+ set.buildTabModeShortcuts(cfg)
113+ set.buildPaneModeShortcuts(cfg)
114+
115+ logger.Debug().Int("global", len(set.Global)).Int("tab", len(set.TabMode)).Int("pane", len(set.PaneMode)).Msg("shortcuts registered")
116+
117+ return set
118+}
119+
120+// buildGlobalShortcuts populates global shortcuts from config.
121+func (s *ShortcutSet) buildGlobalShortcuts(cfg *config.WorkspaceConfig) {
122+ // Mode entry from activation shortcuts
123+ if binding, ok := ParseKeyString(cfg.TabMode.ActivationShortcut); ok {
124+ s.Global[binding] = ActionEnterTabMode
125+ s.logger.Debug().Str("shortcut", cfg.TabMode.ActivationShortcut).Uint("keyval", binding.Keyval).Uint("mod", uint(binding.Modifiers)).Msg("tab mode activation registered")
126+ } else {
127+ s.logger.Warn().Str("shortcut", cfg.TabMode.ActivationShortcut).Msg("failed to parse tab mode activation shortcut")
128+ }
129+ if binding, ok := ParseKeyString(cfg.PaneMode.ActivationShortcut); ok {
130+ s.Global[binding] = ActionEnterPaneMode
131+ s.logger.Debug().Str("shortcut", cfg.PaneMode.ActivationShortcut).Uint("keyval", binding.Keyval).Uint("mod", uint(binding.Modifiers)).Msg("pane mode activation registered")
132+ } else {
133+ s.logger.Warn().Str("shortcut", cfg.PaneMode.ActivationShortcut).Msg("failed to parse pane mode activation shortcut")
134+ }
135+
136+ // Tab shortcuts from TabKeyConfig
137+ if binding, ok := ParseKeyString(cfg.Tabs.NewTab); ok {
138+ s.Global[binding] = ActionNewTab
139+ }
140+ if binding, ok := ParseKeyString(cfg.Tabs.CloseTab); ok {
141+ s.Global[binding] = ActionCloseTab
142+ }
143+ if binding, ok := ParseKeyString(cfg.Tabs.NextTab); ok {
144+ s.Global[binding] = ActionNextTab
145+ }
146+ if binding, ok := ParseKeyString(cfg.Tabs.PreviousTab); ok {
147+ s.Global[binding] = ActionPreviousTab
148+ }
149+
150+ // Standard browser shortcuts (hardcoded defaults)
151+ s.Global[KeyBinding{uint(gdk.KEY_l), ModCtrl}] = ActionOpenOmnibox
152+ s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl}] = ActionReload
153+ s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl | ModShift}] = ActionHardReload
154+ s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload
155+ s.Global[KeyBinding{uint(gdk.KEY_F5), ModCtrl}] = ActionHardReload
156+ s.Global[KeyBinding{uint(gdk.KEY_F12), ModNone}] = ActionOpenDevTools
157+ s.Global[KeyBinding{uint(gdk.KEY_Left), ModAlt}] = ActionGoBack
158+ s.Global[KeyBinding{uint(gdk.KEY_Right), ModAlt}] = ActionGoForward
159+ s.Global[KeyBinding{uint(gdk.KEY_plus), ModCtrl}] = ActionZoomIn
160+ s.Global[KeyBinding{uint(gdk.KEY_equal), ModCtrl}] = ActionZoomIn // Ctrl+= (no shift needed)
161+ s.Global[KeyBinding{uint(gdk.KEY_minus), ModCtrl}] = ActionZoomOut
162+ s.Global[KeyBinding{uint(gdk.KEY_0), ModCtrl}] = ActionZoomReset
163+ s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit
164+ s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen
165+}
166+
167+// buildTabModeShortcuts populates tab mode shortcuts from config.
168+func (s *ShortcutSet) buildTabModeShortcuts(cfg *config.WorkspaceConfig) {
169+ bindings := cfg.TabMode.GetKeyBindings()
170+ s.logger.Debug().Int("count", len(bindings)).Msg("building tab mode shortcuts")
171+ for key, configAction := range bindings {
172+ if binding, ok := ParseKeyString(key); ok {
173+ if action := mapConfigAction(configAction); action != "" {
174+ s.TabMode[binding] = action
175+ s.logger.Debug().Str("key", key).Str("configAction", configAction).Str("action", string(action)).Uint("keyval", binding.Keyval).Msg("tab mode shortcut registered")
176+ } else {
177+ s.logger.Warn().Str("key", key).Str("configAction", configAction).Msg("unknown config action in tab mode")
178+ }
179+ } else {
180+ s.logger.Warn().Str("key", key).Msg("failed to parse tab mode key")
181+ }
182+ }
183+}
184+
185+// buildPaneModeShortcuts populates pane mode shortcuts from config.
186+func (s *ShortcutSet) buildPaneModeShortcuts(cfg *config.WorkspaceConfig) {
187+ bindings := cfg.PaneMode.GetKeyBindings()
188+ s.logger.Debug().Int("count", len(bindings)).Msg("building pane mode shortcuts")
189+ for key, configAction := range bindings {
190+ if binding, ok := ParseKeyString(key); ok {
191+ if action := mapConfigAction(configAction); action != "" {
192+ s.PaneMode[binding] = action
193+ s.logger.Debug().Str("key", key).Str("configAction", configAction).Str("action", string(action)).Uint("keyval", binding.Keyval).Msg("pane mode shortcut registered")
194+ } else {
195+ s.logger.Warn().Str("key", key).Str("configAction", configAction).Msg("unknown config action in pane mode")
196+ }
197+ } else {
198+ s.logger.Warn().Str("key", key).Msg("failed to parse pane mode key")
199+ }
200+ }
201+}
202+
203+// mapConfigAction maps config action names to Action constants.
204+func mapConfigAction(configAction string) Action {
205+ switch configAction {
206+ // Mode management
207+ case "cancel":
208+ return ActionExitMode
209+ case "confirm":
210+ return "" // No action for confirm yet
211+
212+ // Tab actions
213+ case "new-tab":
214+ return ActionNewTab
215+ case "close-tab":
216+ return ActionCloseTab
217+ case "next-tab":
218+ return ActionNextTab
219+ case "previous-tab":
220+ return ActionPreviousTab
221+ case "rename-tab":
222+ return ActionRenameTab
223+
224+ // Pane actions
225+ case "split-right":
226+ return ActionSplitRight
227+ case "split-left":
228+ return ActionSplitLeft
229+ case "split-up":
230+ return ActionSplitUp
231+ case "split-down":
232+ return ActionSplitDown
233+ case "close-pane":
234+ return ActionClosePane
235+ case "stack-pane":
236+ return ActionStackPane
237+
238+ // Focus navigation
239+ case "focus-right":
240+ return ActionFocusRight
241+ case "focus-left":
242+ return ActionFocusLeft
243+ case "focus-up":
244+ return ActionFocusUp
245+ case "focus-down":
246+ return ActionFocusDown
247+
248+ default:
249+ return ""
250+ }
251+}
252+
253+// ParseKeyString converts a config key string like "ctrl+t" to a KeyBinding.
254+// Returns false if the string cannot be parsed.
255+func ParseKeyString(s string) (KeyBinding, bool) {
256+ if s == "" {
257+ return KeyBinding{}, false
258+ }
259+
260+ s = strings.ToLower(strings.TrimSpace(s))
261+ parts := strings.Split(s, "+")
262+
263+ var modifiers Modifier
264+ var keyPart string
265+
266+ for _, part := range parts {
267+ part = strings.TrimSpace(part)
268+ switch part {
269+ case "ctrl", "control":
270+ modifiers |= ModCtrl
271+ case "shift":
272+ modifiers |= ModShift
273+ case "alt":
274+ modifiers |= ModAlt
275+ default:
276+ keyPart = part
277+ }
278+ }
279+
280+ if keyPart == "" {
281+ return KeyBinding{}, false
282+ }
283+
284+ keyval, ok := stringToKeyval(keyPart)
285+ if !ok {
286+ return KeyBinding{}, false
287+ }
288+
289+ return KeyBinding{
290+ Keyval: keyval,
291+ Modifiers: modifiers,
292+ }, true
293+}
294+
295+// stringToKeyval converts a key name to its GDK keyval.
296+func stringToKeyval(s string) (uint, bool) {
297+ // Special keys
298+ switch s {
299+ case "escape", "esc":
300+ return uint(gdk.KEY_Escape), true
301+ case "return", "enter":
302+ return uint(gdk.KEY_Return), true
303+ case "tab":
304+ return uint(gdk.KEY_Tab), true
305+ case "space":
306+ return uint(gdk.KEY_space), true
307+ case "backspace":
308+ return uint(gdk.KEY_BackSpace), true
309+ case "delete", "del":
310+ return uint(gdk.KEY_Delete), true
311+ case "home":
312+ return uint(gdk.KEY_Home), true
313+ case "end":
314+ return uint(gdk.KEY_End), true
315+ case "pageup", "page_up":
316+ return uint(gdk.KEY_Page_Up), true
317+ case "pagedown", "page_down":
318+ return uint(gdk.KEY_Page_Down), true
319+
320+ // Arrow keys
321+ case "left", "arrowleft":
322+ return uint(gdk.KEY_Left), true
323+ case "right", "arrowright":
324+ return uint(gdk.KEY_Right), true
325+ case "up", "arrowup":
326+ return uint(gdk.KEY_Up), true
327+ case "down", "arrowdown":
328+ return uint(gdk.KEY_Down), true
329+
330+ // Function keys
331+ case "f1":
332+ return uint(gdk.KEY_F1), true
333+ case "f2":
334+ return uint(gdk.KEY_F2), true
335+ case "f3":
336+ return uint(gdk.KEY_F3), true
337+ case "f4":
338+ return uint(gdk.KEY_F4), true
339+ case "f5":
340+ return uint(gdk.KEY_F5), true
341+ case "f6":
342+ return uint(gdk.KEY_F6), true
343+ case "f7":
344+ return uint(gdk.KEY_F7), true
345+ case "f8":
346+ return uint(gdk.KEY_F8), true
347+ case "f9":
348+ return uint(gdk.KEY_F9), true
349+ case "f10":
350+ return uint(gdk.KEY_F10), true
351+ case "f11":
352+ return uint(gdk.KEY_F11), true
353+ case "f12":
354+ return uint(gdk.KEY_F12), true
355+
356+ // Symbols
357+ case "plus", "+":
358+ return uint(gdk.KEY_plus), true
359+ case "minus", "-":
360+ return uint(gdk.KEY_minus), true
361+ case "equal", "=":
362+ return uint(gdk.KEY_equal), true
363+
364+ // Numbers
365+ case "0":
366+ return uint(gdk.KEY_0), true
367+ case "1":
368+ return uint(gdk.KEY_1), true
369+ case "2":
370+ return uint(gdk.KEY_2), true
371+ case "3":
372+ return uint(gdk.KEY_3), true
373+ case "4":
374+ return uint(gdk.KEY_4), true
375+ case "5":
376+ return uint(gdk.KEY_5), true
377+ case "6":
378+ return uint(gdk.KEY_6), true
379+ case "7":
380+ return uint(gdk.KEY_7), true
381+ case "8":
382+ return uint(gdk.KEY_8), true
383+ case "9":
384+ return uint(gdk.KEY_9), true
385+ }
386+
387+ // Single letter keys (a-z)
388+ if len(s) == 1 && s[0] >= 'a' && s[0] <= 'z' {
389+ // ASCII lowercase a=97, which matches gdk.KEY_a
390+ return uint(s[0]), true
391+ }
392+
393+ return 0, false
394+}
395+
396+// Lookup finds an action for the given key binding in the appropriate table.
397+// It first checks the mode-specific table, then falls back to global.
398+func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) {
399+ // Normalize the binding modifiers
400+ binding.Modifiers &= modifierMask
401+
402+ // Check mode-specific table first
403+ var modeTable ShortcutTable
404+ switch mode {
405+ case ModeTab:
406+ modeTable = s.TabMode
407+ case ModePane:
408+ modeTable = s.PaneMode
409+ }
410+
411+ if modeTable != nil {
412+ if action, ok := modeTable[binding]; ok {
413+ return action, true
414+ }
415+ }
416+
417+ // Fall back to global
418+ if action, ok := s.Global[binding]; ok {
419+ return action, true
420+ }
421+
422+ return "", false
423+}
424+
425+// ShouldAutoExitMode returns true if the action should cause modal mode to exit.
426+func ShouldAutoExitMode(action Action) bool {
427+ switch action {
428+ case ActionNewTab, ActionCloseTab, ActionRenameTab,
429+ ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,
430+ ActionClosePane, ActionStackPane:
431+ return true
432+ default:
433+ return false
434+ }
435+}
@@ -0,0 +1,435 @@
1+// Package input provides keyboard event handling and modal input mode management.
2+package input
3+
4+import (
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/jwijenbergh/puregotk/v4/gdk"
9+ "github.com/rs/zerolog"
10+)
11+
12+// Modifier represents keyboard modifier flags.
13+type Modifier uint
14+
15+const (
16+ // ModNone indicates no modifier is pressed.
17+ ModNone Modifier = 0
18+ // ModShift indicates the Shift key is pressed.
19+ ModShift Modifier = Modifier(gdk.ShiftMaskValue)
20+ // ModCtrl indicates the Control key is pressed.
21+ ModCtrl Modifier = Modifier(gdk.ControlMaskValue)
22+ // ModAlt indicates the Alt key is pressed.
23+ ModAlt Modifier = Modifier(gdk.AltMaskValue)
24+)
25+
26+// modifierMask filters out non-standard modifiers from GDK state.
27+const modifierMask = ModCtrl | ModShift | ModAlt
28+
29+// KeyBinding represents a single key combination.
30+type KeyBinding struct {
31+ Keyval uint // GDK key value (e.g., gdk.KEY_t)
32+ Modifiers Modifier // Combined modifiers
33+}
34+
35+// Action represents what happens when a shortcut is triggered.
36+type Action string
37+
38+// Predefined actions for the keyboard system.
39+const (
40+ // Mode management
41+ ActionEnterTabMode Action = "enter_tab_mode"
42+ ActionEnterPaneMode Action = "enter_pane_mode"
43+ ActionExitMode Action = "exit_mode"
44+
45+ // Tab actions (global and modal)
46+ ActionNewTab Action = "new_tab"
47+ ActionCloseTab Action = "close_tab"
48+ ActionNextTab Action = "next_tab"
49+ ActionPreviousTab Action = "previous_tab"
50+ ActionRenameTab Action = "rename_tab"
51+
52+ // Pane actions (modal)
53+ ActionSplitRight Action = "split_right"
54+ ActionSplitLeft Action = "split_left"
55+ ActionSplitUp Action = "split_up"
56+ ActionSplitDown Action = "split_down"
57+ ActionClosePane Action = "close_pane"
58+ ActionStackPane Action = "stack_pane"
59+
60+ // Pane focus navigation
61+ ActionFocusRight Action = "focus_right"
62+ ActionFocusLeft Action = "focus_left"
63+ ActionFocusUp Action = "focus_up"
64+ ActionFocusDown Action = "focus_down"
65+
66+ // Page navigation
67+ ActionGoBack Action = "go_back"
68+ ActionGoForward Action = "go_forward"
69+ ActionReload Action = "reload"
70+ ActionHardReload Action = "hard_reload"
71+ ActionStop Action = "stop"
72+
73+ // Zoom
74+ ActionZoomIn Action = "zoom_in"
75+ ActionZoomOut Action = "zoom_out"
76+ ActionZoomReset Action = "zoom_reset"
77+
78+ // UI
79+ ActionOpenOmnibox Action = "open_omnibox"
80+ ActionOpenDevTools Action = "open_devtools"
81+ ActionToggleFullscreen Action = "toggle_fullscreen"
82+
83+ // Application
84+ ActionQuit Action = "quit"
85+)
86+
87+// ShortcutTable maps KeyBinding to Action.
88+type ShortcutTable map[KeyBinding]Action
89+
90+// ShortcutSet holds all shortcut tables organized by context.
91+type ShortcutSet struct {
92+ // Global shortcuts are always active regardless of mode.
93+ Global ShortcutTable
94+ // TabMode shortcuts are only active in tab mode.
95+ TabMode ShortcutTable
96+ // PaneMode shortcuts are only active in pane mode.
97+ PaneMode ShortcutTable
98+
99+ logger *zerolog.Logger
100+}
101+
102+// NewShortcutSet creates a ShortcutSet from the workspace configuration.
103+func NewShortcutSet(cfg *config.WorkspaceConfig, logger *zerolog.Logger) *ShortcutSet {
104+ set := &ShortcutSet{
105+ Global: make(ShortcutTable),
106+ TabMode: make(ShortcutTable),
107+ PaneMode: make(ShortcutTable),
108+ logger: logger,
109+ }
110+
111+ set.buildGlobalShortcuts(cfg)
112+ set.buildTabModeShortcuts(cfg)
113+ set.buildPaneModeShortcuts(cfg)
114+
115+ logger.Debug().Int("global", len(set.Global)).Int("tab", len(set.TabMode)).Int("pane", len(set.PaneMode)).Msg("shortcuts registered")
116+
117+ return set
118+}
119+
120+// buildGlobalShortcuts populates global shortcuts from config.
121+func (s *ShortcutSet) buildGlobalShortcuts(cfg *config.WorkspaceConfig) {
122+ // Mode entry from activation shortcuts
123+ if binding, ok := ParseKeyString(cfg.TabMode.ActivationShortcut); ok {
124+ s.Global[binding] = ActionEnterTabMode
125+ s.logger.Debug().Str("shortcut", cfg.TabMode.ActivationShortcut).Uint("keyval", binding.Keyval).Uint("mod", uint(binding.Modifiers)).Msg("tab mode activation registered")
126+ } else {
127+ s.logger.Warn().Str("shortcut", cfg.TabMode.ActivationShortcut).Msg("failed to parse tab mode activation shortcut")
128+ }
129+ if binding, ok := ParseKeyString(cfg.PaneMode.ActivationShortcut); ok {
130+ s.Global[binding] = ActionEnterPaneMode
131+ s.logger.Debug().Str("shortcut", cfg.PaneMode.ActivationShortcut).Uint("keyval", binding.Keyval).Uint("mod", uint(binding.Modifiers)).Msg("pane mode activation registered")
132+ } else {
133+ s.logger.Warn().Str("shortcut", cfg.PaneMode.ActivationShortcut).Msg("failed to parse pane mode activation shortcut")
134+ }
135+
136+ // Tab shortcuts from TabKeyConfig
137+ if binding, ok := ParseKeyString(cfg.Tabs.NewTab); ok {
138+ s.Global[binding] = ActionNewTab
139+ }
140+ if binding, ok := ParseKeyString(cfg.Tabs.CloseTab); ok {
141+ s.Global[binding] = ActionCloseTab
142+ }
143+ if binding, ok := ParseKeyString(cfg.Tabs.NextTab); ok {
144+ s.Global[binding] = ActionNextTab
145+ }
146+ if binding, ok := ParseKeyString(cfg.Tabs.PreviousTab); ok {
147+ s.Global[binding] = ActionPreviousTab
148+ }
149+
150+ // Standard browser shortcuts (hardcoded defaults)
151+ s.Global[KeyBinding{uint(gdk.KEY_l), ModCtrl}] = ActionOpenOmnibox
152+ s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl}] = ActionReload
153+ s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl | ModShift}] = ActionHardReload
154+ s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload
155+ s.Global[KeyBinding{uint(gdk.KEY_F5), ModCtrl}] = ActionHardReload
156+ s.Global[KeyBinding{uint(gdk.KEY_F12), ModNone}] = ActionOpenDevTools
157+ s.Global[KeyBinding{uint(gdk.KEY_Left), ModAlt}] = ActionGoBack
158+ s.Global[KeyBinding{uint(gdk.KEY_Right), ModAlt}] = ActionGoForward
159+ s.Global[KeyBinding{uint(gdk.KEY_plus), ModCtrl}] = ActionZoomIn
160+ s.Global[KeyBinding{uint(gdk.KEY_equal), ModCtrl}] = ActionZoomIn // Ctrl+= (no shift needed)
161+ s.Global[KeyBinding{uint(gdk.KEY_minus), ModCtrl}] = ActionZoomOut
162+ s.Global[KeyBinding{uint(gdk.KEY_0), ModCtrl}] = ActionZoomReset
163+ s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit
164+ s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen
165+}
166+
167+// buildTabModeShortcuts populates tab mode shortcuts from config.
168+func (s *ShortcutSet) buildTabModeShortcuts(cfg *config.WorkspaceConfig) {
169+ bindings := cfg.TabMode.GetKeyBindings()
170+ s.logger.Debug().Int("count", len(bindings)).Msg("building tab mode shortcuts")
171+ for key, configAction := range bindings {
172+ if binding, ok := ParseKeyString(key); ok {
173+ if action := mapConfigAction(configAction); action != "" {
174+ s.TabMode[binding] = action
175+ s.logger.Debug().Str("key", key).Str("configAction", configAction).Str("action", string(action)).Uint("keyval", binding.Keyval).Msg("tab mode shortcut registered")
176+ } else {
177+ s.logger.Warn().Str("key", key).Str("configAction", configAction).Msg("unknown config action in tab mode")
178+ }
179+ } else {
180+ s.logger.Warn().Str("key", key).Msg("failed to parse tab mode key")
181+ }
182+ }
183+}
184+
185+// buildPaneModeShortcuts populates pane mode shortcuts from config.
186+func (s *ShortcutSet) buildPaneModeShortcuts(cfg *config.WorkspaceConfig) {
187+ bindings := cfg.PaneMode.GetKeyBindings()
188+ s.logger.Debug().Int("count", len(bindings)).Msg("building pane mode shortcuts")
189+ for key, configAction := range bindings {
190+ if binding, ok := ParseKeyString(key); ok {
191+ if action := mapConfigAction(configAction); action != "" {
192+ s.PaneMode[binding] = action
193+ s.logger.Debug().Str("key", key).Str("configAction", configAction).Str("action", string(action)).Uint("keyval", binding.Keyval).Msg("pane mode shortcut registered")
194+ } else {
195+ s.logger.Warn().Str("key", key).Str("configAction", configAction).Msg("unknown config action in pane mode")
196+ }
197+ } else {
198+ s.logger.Warn().Str("key", key).Msg("failed to parse pane mode key")
199+ }
200+ }
201+}
202+
203+// mapConfigAction maps config action names to Action constants.
204+func mapConfigAction(configAction string) Action {
205+ switch configAction {
206+ // Mode management
207+ case "cancel":
208+ return ActionExitMode
209+ case "confirm":
210+ return "" // No action for confirm yet
211+
212+ // Tab actions
213+ case "new-tab":
214+ return ActionNewTab
215+ case "close-tab":
216+ return ActionCloseTab
217+ case "next-tab":
218+ return ActionNextTab
219+ case "previous-tab":
220+ return ActionPreviousTab
221+ case "rename-tab":
222+ return ActionRenameTab
223+
224+ // Pane actions
225+ case "split-right":
226+ return ActionSplitRight
227+ case "split-left":
228+ return ActionSplitLeft
229+ case "split-up":
230+ return ActionSplitUp
231+ case "split-down":
232+ return ActionSplitDown
233+ case "close-pane":
234+ return ActionClosePane
235+ case "stack-pane":
236+ return ActionStackPane
237+
238+ // Focus navigation
239+ case "focus-right":
240+ return ActionFocusRight
241+ case "focus-left":
242+ return ActionFocusLeft
243+ case "focus-up":
244+ return ActionFocusUp
245+ case "focus-down":
246+ return ActionFocusDown
247+
248+ default:
249+ return ""
250+ }
251+}
252+
253+// ParseKeyString converts a config key string like "ctrl+t" to a KeyBinding.
254+// Returns false if the string cannot be parsed.
255+func ParseKeyString(s string) (KeyBinding, bool) {
256+ if s == "" {
257+ return KeyBinding{}, false
258+ }
259+
260+ s = strings.ToLower(strings.TrimSpace(s))
261+ parts := strings.Split(s, "+")
262+
263+ var modifiers Modifier
264+ var keyPart string
265+
266+ for _, part := range parts {
267+ part = strings.TrimSpace(part)
268+ switch part {
269+ case "ctrl", "control":
270+ modifiers |= ModCtrl
271+ case "shift":
272+ modifiers |= ModShift
273+ case "alt":
274+ modifiers |= ModAlt
275+ default:
276+ keyPart = part
277+ }
278+ }
279+
280+ if keyPart == "" {
281+ return KeyBinding{}, false
282+ }
283+
284+ keyval, ok := stringToKeyval(keyPart)
285+ if !ok {
286+ return KeyBinding{}, false
287+ }
288+
289+ return KeyBinding{
290+ Keyval: keyval,
291+ Modifiers: modifiers,
292+ }, true
293+}
294+
295+// stringToKeyval converts a key name to its GDK keyval.
296+func stringToKeyval(s string) (uint, bool) {
297+ // Special keys
298+ switch s {
299+ case "escape", "esc":
300+ return uint(gdk.KEY_Escape), true
301+ case "return", "enter":
302+ return uint(gdk.KEY_Return), true
303+ case "tab":
304+ return uint(gdk.KEY_Tab), true
305+ case "space":
306+ return uint(gdk.KEY_space), true
307+ case "backspace":
308+ return uint(gdk.KEY_BackSpace), true
309+ case "delete", "del":
310+ return uint(gdk.KEY_Delete), true
311+ case "home":
312+ return uint(gdk.KEY_Home), true
313+ case "end":
314+ return uint(gdk.KEY_End), true
315+ case "pageup", "page_up":
316+ return uint(gdk.KEY_Page_Up), true
317+ case "pagedown", "page_down":
318+ return uint(gdk.KEY_Page_Down), true
319+
320+ // Arrow keys
321+ case "left", "arrowleft":
322+ return uint(gdk.KEY_Left), true
323+ case "right", "arrowright":
324+ return uint(gdk.KEY_Right), true
325+ case "up", "arrowup":
326+ return uint(gdk.KEY_Up), true
327+ case "down", "arrowdown":
328+ return uint(gdk.KEY_Down), true
329+
330+ // Function keys
331+ case "f1":
332+ return uint(gdk.KEY_F1), true
333+ case "f2":
334+ return uint(gdk.KEY_F2), true
335+ case "f3":
336+ return uint(gdk.KEY_F3), true
337+ case "f4":
338+ return uint(gdk.KEY_F4), true
339+ case "f5":
340+ return uint(gdk.KEY_F5), true
341+ case "f6":
342+ return uint(gdk.KEY_F6), true
343+ case "f7":
344+ return uint(gdk.KEY_F7), true
345+ case "f8":
346+ return uint(gdk.KEY_F8), true
347+ case "f9":
348+ return uint(gdk.KEY_F9), true
349+ case "f10":
350+ return uint(gdk.KEY_F10), true
351+ case "f11":
352+ return uint(gdk.KEY_F11), true
353+ case "f12":
354+ return uint(gdk.KEY_F12), true
355+
356+ // Symbols
357+ case "plus", "+":
358+ return uint(gdk.KEY_plus), true
359+ case "minus", "-":
360+ return uint(gdk.KEY_minus), true
361+ case "equal", "=":
362+ return uint(gdk.KEY_equal), true
363+
364+ // Numbers
365+ case "0":
366+ return uint(gdk.KEY_0), true
367+ case "1":
368+ return uint(gdk.KEY_1), true
369+ case "2":
370+ return uint(gdk.KEY_2), true
371+ case "3":
372+ return uint(gdk.KEY_3), true
373+ case "4":
374+ return uint(gdk.KEY_4), true
375+ case "5":
376+ return uint(gdk.KEY_5), true
377+ case "6":
378+ return uint(gdk.KEY_6), true
379+ case "7":
380+ return uint(gdk.KEY_7), true
381+ case "8":
382+ return uint(gdk.KEY_8), true
383+ case "9":
384+ return uint(gdk.KEY_9), true
385+ }
386+
387+ // Single letter keys (a-z)
388+ if len(s) == 1 && s[0] >= 'a' && s[0] <= 'z' {
389+ // ASCII lowercase a=97, which matches gdk.KEY_a
390+ return uint(s[0]), true
391+ }
392+
393+ return 0, false
394+}
395+
396+// Lookup finds an action for the given key binding in the appropriate table.
397+// It first checks the mode-specific table, then falls back to global.
398+func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) {
399+ // Normalize the binding modifiers
400+ binding.Modifiers &= modifierMask
401+
402+ // Check mode-specific table first
403+ var modeTable ShortcutTable
404+ switch mode {
405+ case ModeTab:
406+ modeTable = s.TabMode
407+ case ModePane:
408+ modeTable = s.PaneMode
409+ }
410+
411+ if modeTable != nil {
412+ if action, ok := modeTable[binding]; ok {
413+ return action, true
414+ }
415+ }
416+
417+ // Fall back to global
418+ if action, ok := s.Global[binding]; ok {
419+ return action, true
420+ }
421+
422+ return "", false
423+}
424+
425+// ShouldAutoExitMode returns true if the action should cause modal mode to exit.
426+func ShouldAutoExitMode(action Action) bool {
427+ switch action {
428+ case ActionNewTab, ActionCloseTab, ActionRenameTab,
429+ ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,
430+ ActionClosePane, ActionStackPane:
431+ return true
432+ default:
433+ return false
434+ }
435+}
A internal/ui/input/shortcuts_test.go +350 -0
@@ -0,0 +1,350 @@
1+package input
2+
3+import (
4+ "testing"
5+
6+ "github.com/jwijenbergh/puregotk/v4/gdk"
7+)
8+
9+func TestParseKeyString_SingleKeys(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ input string
13+ want KeyBinding
14+ wantOk bool
15+ }{
16+ {
17+ name: "escape",
18+ input: "escape",
19+ want: KeyBinding{Keyval: uint(gdk.KEY_Escape), Modifiers: ModNone},
20+ wantOk: true,
21+ },
22+ {
23+ name: "esc alias",
24+ input: "esc",
25+ want: KeyBinding{Keyval: uint(gdk.KEY_Escape), Modifiers: ModNone},
26+ wantOk: true,
27+ },
28+ {
29+ name: "return",
30+ input: "return",
31+ want: KeyBinding{Keyval: uint(gdk.KEY_Return), Modifiers: ModNone},
32+ wantOk: true,
33+ },
34+ {
35+ name: "enter alias",
36+ input: "enter",
37+ want: KeyBinding{Keyval: uint(gdk.KEY_Return), Modifiers: ModNone},
38+ wantOk: true,
39+ },
40+ {
41+ name: "tab",
42+ input: "tab",
43+ want: KeyBinding{Keyval: uint(gdk.KEY_Tab), Modifiers: ModNone},
44+ wantOk: true,
45+ },
46+ {
47+ name: "space",
48+ input: "space",
49+ want: KeyBinding{Keyval: uint(gdk.KEY_space), Modifiers: ModNone},
50+ wantOk: true,
51+ },
52+ {
53+ name: "f5",
54+ input: "f5",
55+ want: KeyBinding{Keyval: uint(gdk.KEY_F5), Modifiers: ModNone},
56+ wantOk: true,
57+ },
58+ {
59+ name: "f12",
60+ input: "f12",
61+ want: KeyBinding{Keyval: uint(gdk.KEY_F12), Modifiers: ModNone},
62+ wantOk: true,
63+ },
64+ {
65+ name: "single letter",
66+ input: "t",
67+ want: KeyBinding{Keyval: uint('t'), Modifiers: ModNone},
68+ wantOk: true,
69+ },
70+ {
71+ name: "single number",
72+ input: "0",
73+ want: KeyBinding{Keyval: uint(gdk.KEY_0), Modifiers: ModNone},
74+ wantOk: true,
75+ },
76+ {
77+ name: "arrow left",
78+ input: "left",
79+ want: KeyBinding{Keyval: uint(gdk.KEY_Left), Modifiers: ModNone},
80+ wantOk: true,
81+ },
82+ {
83+ name: "arrow right",
84+ input: "right",
85+ want: KeyBinding{Keyval: uint(gdk.KEY_Right), Modifiers: ModNone},
86+ wantOk: true,
87+ },
88+ }
89+
90+ for _, tt := range tests {
91+ t.Run(tt.name, func(t *testing.T) {
92+ got, ok := ParseKeyString(tt.input)
93+ if ok != tt.wantOk {
94+ t.Errorf("ParseKeyString(%q) ok = %v, want %v", tt.input, ok, tt.wantOk)
95+ return
96+ }
97+ if !tt.wantOk {
98+ return
99+ }
100+ if got.Keyval != tt.want.Keyval {
101+ t.Errorf("ParseKeyString(%q) keyval = %d, want %d", tt.input, got.Keyval, tt.want.Keyval)
102+ }
103+ if got.Modifiers != tt.want.Modifiers {
104+ t.Errorf("ParseKeyString(%q) modifiers = %d, want %d", tt.input, got.Modifiers, tt.want.Modifiers)
105+ }
106+ })
107+ }
108+}
109+
110+func TestParseKeyString_WithModifiers(t *testing.T) {
111+ tests := []struct {
112+ name string
113+ input string
114+ want KeyBinding
115+ wantOk bool
116+ }{
117+ {
118+ name: "ctrl+t",
119+ input: "ctrl+t",
120+ want: KeyBinding{Keyval: uint('t'), Modifiers: ModCtrl},
121+ wantOk: true,
122+ },
123+ {
124+ name: "control+t",
125+ input: "control+t",
126+ want: KeyBinding{Keyval: uint('t'), Modifiers: ModCtrl},
127+ wantOk: true,
128+ },
129+ {
130+ name: "shift+tab",
131+ input: "shift+tab",
132+ want: KeyBinding{Keyval: uint(gdk.KEY_Tab), Modifiers: ModShift},
133+ wantOk: true,
134+ },
135+ {
136+ name: "alt+left",
137+ input: "alt+left",
138+ want: KeyBinding{Keyval: uint(gdk.KEY_Left), Modifiers: ModAlt},
139+ wantOk: true,
140+ },
141+ {
142+ name: "ctrl+shift+t",
143+ input: "ctrl+shift+t",
144+ want: KeyBinding{Keyval: uint('t'), Modifiers: ModCtrl | ModShift},
145+ wantOk: true,
146+ },
147+ {
148+ name: "ctrl+r",
149+ input: "ctrl+r",
150+ want: KeyBinding{Keyval: uint('r'), Modifiers: ModCtrl},
151+ wantOk: true,
152+ },
153+ }
154+
155+ for _, tt := range tests {
156+ t.Run(tt.name, func(t *testing.T) {
157+ got, ok := ParseKeyString(tt.input)
158+ if ok != tt.wantOk {
159+ t.Errorf("ParseKeyString(%q) ok = %v, want %v", tt.input, ok, tt.wantOk)
160+ return
161+ }
162+ if !tt.wantOk {
163+ return
164+ }
165+ if got.Keyval != tt.want.Keyval {
166+ t.Errorf("ParseKeyString(%q) keyval = %d, want %d", tt.input, got.Keyval, tt.want.Keyval)
167+ }
168+ if got.Modifiers != tt.want.Modifiers {
169+ t.Errorf("ParseKeyString(%q) modifiers = %d, want %d", tt.input, got.Modifiers, tt.want.Modifiers)
170+ }
171+ })
172+ }
173+}
174+
175+func TestParseKeyString_Invalid(t *testing.T) {
176+ tests := []string{
177+ "",
178+ "ctrl+",
179+ "unknownkey",
180+ "ctrl+unknownkey",
181+ }
182+
183+ for _, input := range tests {
184+ t.Run(input, func(t *testing.T) {
185+ _, ok := ParseKeyString(input)
186+ if ok {
187+ t.Errorf("ParseKeyString(%q) should have failed", input)
188+ }
189+ })
190+ }
191+}
192+
193+func TestParseKeyString_CaseInsensitive(t *testing.T) {
194+ tests := []struct {
195+ input1 string
196+ input2 string
197+ }{
198+ {"ctrl+t", "CTRL+T"},
199+ {"Escape", "escape"},
200+ {"Tab", "TAB"},
201+ {"Ctrl+Shift+T", "ctrl+shift+t"},
202+ }
203+
204+ for _, tt := range tests {
205+ t.Run(tt.input1+" vs "+tt.input2, func(t *testing.T) {
206+ got1, ok1 := ParseKeyString(tt.input1)
207+ got2, ok2 := ParseKeyString(tt.input2)
208+
209+ if ok1 != ok2 {
210+ t.Errorf("ParseKeyString case sensitivity: %q=%v, %q=%v", tt.input1, ok1, tt.input2, ok2)
211+ return
212+ }
213+
214+ if got1 != got2 {
215+ t.Errorf("ParseKeyString case sensitivity: %q=%v, %q=%v", tt.input1, got1, tt.input2, got2)
216+ }
217+ })
218+ }
219+}
220+
221+func TestShortcutSet_Lookup(t *testing.T) {
222+ set := &ShortcutSet{
223+ Global: ShortcutTable{
224+ {uint(gdk.KEY_q), ModCtrl}: ActionQuit,
225+ {uint(gdk.KEY_l), ModCtrl}: ActionOpenOmnibox,
226+ },
227+ TabMode: ShortcutTable{
228+ {uint('n'), ModNone}: ActionNewTab,
229+ {uint(gdk.KEY_Escape), ModNone}: ActionExitMode,
230+ },
231+ PaneMode: ShortcutTable{
232+ {uint('x'), ModNone}: ActionClosePane,
233+ {uint(gdk.KEY_Escape), ModNone}: ActionExitMode,
234+ },
235+ }
236+
237+ tests := []struct {
238+ name string
239+ binding KeyBinding
240+ mode Mode
241+ want Action
242+ wantOk bool
243+ }{
244+ {
245+ name: "global in normal mode",
246+ binding: KeyBinding{uint(gdk.KEY_q), ModCtrl},
247+ mode: ModeNormal,
248+ want: ActionQuit,
249+ wantOk: true,
250+ },
251+ {
252+ name: "global in tab mode",
253+ binding: KeyBinding{uint(gdk.KEY_l), ModCtrl},
254+ mode: ModeTab,
255+ want: ActionOpenOmnibox,
256+ wantOk: true,
257+ },
258+ {
259+ name: "tab mode action",
260+ binding: KeyBinding{uint('n'), ModNone},
261+ mode: ModeTab,
262+ want: ActionNewTab,
263+ wantOk: true,
264+ },
265+ {
266+ name: "pane mode action",
267+ binding: KeyBinding{uint('x'), ModNone},
268+ mode: ModePane,
269+ want: ActionClosePane,
270+ wantOk: true,
271+ },
272+ {
273+ name: "escape exits tab mode",
274+ binding: KeyBinding{uint(gdk.KEY_Escape), ModNone},
275+ mode: ModeTab,
276+ want: ActionExitMode,
277+ wantOk: true,
278+ },
279+ {
280+ name: "escape exits pane mode",
281+ binding: KeyBinding{uint(gdk.KEY_Escape), ModNone},
282+ mode: ModePane,
283+ want: ActionExitMode,
284+ wantOk: true,
285+ },
286+ {
287+ name: "unknown key in normal mode",
288+ binding: KeyBinding{uint('z'), ModNone},
289+ mode: ModeNormal,
290+ want: "",
291+ wantOk: false,
292+ },
293+ {
294+ name: "tab action not in normal mode",
295+ binding: KeyBinding{uint('n'), ModNone},
296+ mode: ModeNormal,
297+ want: "",
298+ wantOk: false,
299+ },
300+ }
301+
302+ for _, tt := range tests {
303+ t.Run(tt.name, func(t *testing.T) {
304+ got, ok := set.Lookup(tt.binding, tt.mode)
305+ if ok != tt.wantOk {
306+ t.Errorf("Lookup() ok = %v, want %v", ok, tt.wantOk)
307+ return
308+ }
309+ if got != tt.want {
310+ t.Errorf("Lookup() = %v, want %v", got, tt.want)
311+ }
312+ })
313+ }
314+}
315+
316+func TestShouldAutoExitMode(t *testing.T) {
317+ exitActions := []Action{
318+ ActionNewTab,
319+ ActionCloseTab,
320+ ActionRenameTab,
321+ ActionSplitRight,
322+ ActionSplitLeft,
323+ ActionSplitUp,
324+ ActionSplitDown,
325+ ActionClosePane,
326+ ActionStackPane,
327+ }
328+
329+ stayActions := []Action{
330+ ActionNextTab,
331+ ActionPreviousTab,
332+ ActionFocusRight,
333+ ActionFocusLeft,
334+ ActionGoBack,
335+ ActionZoomIn,
336+ ActionOpenOmnibox,
337+ }
338+
339+ for _, action := range exitActions {
340+ if !ShouldAutoExitMode(action) {
341+ t.Errorf("ShouldAutoExitMode(%s) = false, want true", action)
342+ }
343+ }
344+
345+ for _, action := range stayActions {
346+ if ShouldAutoExitMode(action) {
347+ t.Errorf("ShouldAutoExitMode(%s) = true, want false", action)
348+ }
349+ }
350+}
@@ -0,0 +1,350 @@
1+package input
2+
3+import (
4+ "testing"
5+
6+ "github.com/jwijenbergh/puregotk/v4/gdk"
7+)
8+
9+func TestParseKeyString_SingleKeys(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ input string
13+ want KeyBinding
14+ wantOk bool
15+ }{
16+ {
17+ name: "escape",
18+ input: "escape",
19+ want: KeyBinding{Keyval: uint(gdk.KEY_Escape), Modifiers: ModNone},
20+ wantOk: true,
21+ },
22+ {
23+ name: "esc alias",
24+ input: "esc",
25+ want: KeyBinding{Keyval: uint(gdk.KEY_Escape), Modifiers: ModNone},
26+ wantOk: true,
27+ },
28+ {
29+ name: "return",
30+ input: "return",
31+ want: KeyBinding{Keyval: uint(gdk.KEY_Return), Modifiers: ModNone},
32+ wantOk: true,
33+ },
34+ {
35+ name: "enter alias",
36+ input: "enter",
37+ want: KeyBinding{Keyval: uint(gdk.KEY_Return), Modifiers: ModNone},
38+ wantOk: true,
39+ },
40+ {
41+ name: "tab",
42+ input: "tab",
43+ want: KeyBinding{Keyval: uint(gdk.KEY_Tab), Modifiers: ModNone},
44+ wantOk: true,
45+ },
46+ {
47+ name: "space",
48+ input: "space",
49+ want: KeyBinding{Keyval: uint(gdk.KEY_space), Modifiers: ModNone},
50+ wantOk: true,
51+ },
52+ {
53+ name: "f5",
54+ input: "f5",
55+ want: KeyBinding{Keyval: uint(gdk.KEY_F5), Modifiers: ModNone},
56+ wantOk: true,
57+ },
58+ {
59+ name: "f12",
60+ input: "f12",
61+ want: KeyBinding{Keyval: uint(gdk.KEY_F12), Modifiers: ModNone},
62+ wantOk: true,
63+ },
64+ {
65+ name: "single letter",
66+ input: "t",
67+ want: KeyBinding{Keyval: uint('t'), Modifiers: ModNone},
68+ wantOk: true,
69+ },
70+ {
71+ name: "single number",
72+ input: "0",
73+ want: KeyBinding{Keyval: uint(gdk.KEY_0), Modifiers: ModNone},
74+ wantOk: true,
75+ },
76+ {
77+ name: "arrow left",
78+ input: "left",
79+ want: KeyBinding{Keyval: uint(gdk.KEY_Left), Modifiers: ModNone},
80+ wantOk: true,
81+ },
82+ {
83+ name: "arrow right",
84+ input: "right",
85+ want: KeyBinding{Keyval: uint(gdk.KEY_Right), Modifiers: ModNone},
86+ wantOk: true,
87+ },
88+ }
89+
90+ for _, tt := range tests {
91+ t.Run(tt.name, func(t *testing.T) {
92+ got, ok := ParseKeyString(tt.input)
93+ if ok != tt.wantOk {
94+ t.Errorf("ParseKeyString(%q) ok = %v, want %v", tt.input, ok, tt.wantOk)
95+ return
96+ }
97+ if !tt.wantOk {
98+ return
99+ }
100+ if got.Keyval != tt.want.Keyval {
101+ t.Errorf("ParseKeyString(%q) keyval = %d, want %d", tt.input, got.Keyval, tt.want.Keyval)
102+ }
103+ if got.Modifiers != tt.want.Modifiers {
104+ t.Errorf("ParseKeyString(%q) modifiers = %d, want %d", tt.input, got.Modifiers, tt.want.Modifiers)
105+ }
106+ })
107+ }
108+}
109+
110+func TestParseKeyString_WithModifiers(t *testing.T) {
111+ tests := []struct {
112+ name string
113+ input string
114+ want KeyBinding
115+ wantOk bool
116+ }{
117+ {
118+ name: "ctrl+t",
119+ input: "ctrl+t",
120+ want: KeyBinding{Keyval: uint('t'), Modifiers: ModCtrl},
121+ wantOk: true,
122+ },
123+ {
124+ name: "control+t",
125+ input: "control+t",
126+ want: KeyBinding{Keyval: uint('t'), Modifiers: ModCtrl},
127+ wantOk: true,
128+ },
129+ {
130+ name: "shift+tab",
131+ input: "shift+tab",
132+ want: KeyBinding{Keyval: uint(gdk.KEY_Tab), Modifiers: ModShift},
133+ wantOk: true,
134+ },
135+ {
136+ name: "alt+left",
137+ input: "alt+left",
138+ want: KeyBinding{Keyval: uint(gdk.KEY_Left), Modifiers: ModAlt},
139+ wantOk: true,
140+ },
141+ {
142+ name: "ctrl+shift+t",
143+ input: "ctrl+shift+t",
144+ want: KeyBinding{Keyval: uint('t'), Modifiers: ModCtrl | ModShift},
145+ wantOk: true,
146+ },
147+ {
148+ name: "ctrl+r",
149+ input: "ctrl+r",
150+ want: KeyBinding{Keyval: uint('r'), Modifiers: ModCtrl},
151+ wantOk: true,
152+ },
153+ }
154+
155+ for _, tt := range tests {
156+ t.Run(tt.name, func(t *testing.T) {
157+ got, ok := ParseKeyString(tt.input)
158+ if ok != tt.wantOk {
159+ t.Errorf("ParseKeyString(%q) ok = %v, want %v", tt.input, ok, tt.wantOk)
160+ return
161+ }
162+ if !tt.wantOk {
163+ return
164+ }
165+ if got.Keyval != tt.want.Keyval {
166+ t.Errorf("ParseKeyString(%q) keyval = %d, want %d", tt.input, got.Keyval, tt.want.Keyval)
167+ }
168+ if got.Modifiers != tt.want.Modifiers {
169+ t.Errorf("ParseKeyString(%q) modifiers = %d, want %d", tt.input, got.Modifiers, tt.want.Modifiers)
170+ }
171+ })
172+ }
173+}
174+
175+func TestParseKeyString_Invalid(t *testing.T) {
176+ tests := []string{
177+ "",
178+ "ctrl+",
179+ "unknownkey",
180+ "ctrl+unknownkey",
181+ }
182+
183+ for _, input := range tests {
184+ t.Run(input, func(t *testing.T) {
185+ _, ok := ParseKeyString(input)
186+ if ok {
187+ t.Errorf("ParseKeyString(%q) should have failed", input)
188+ }
189+ })
190+ }
191+}
192+
193+func TestParseKeyString_CaseInsensitive(t *testing.T) {
194+ tests := []struct {
195+ input1 string
196+ input2 string
197+ }{
198+ {"ctrl+t", "CTRL+T"},
199+ {"Escape", "escape"},
200+ {"Tab", "TAB"},
201+ {"Ctrl+Shift+T", "ctrl+shift+t"},
202+ }
203+
204+ for _, tt := range tests {
205+ t.Run(tt.input1+" vs "+tt.input2, func(t *testing.T) {
206+ got1, ok1 := ParseKeyString(tt.input1)
207+ got2, ok2 := ParseKeyString(tt.input2)
208+
209+ if ok1 != ok2 {
210+ t.Errorf("ParseKeyString case sensitivity: %q=%v, %q=%v", tt.input1, ok1, tt.input2, ok2)
211+ return
212+ }
213+
214+ if got1 != got2 {
215+ t.Errorf("ParseKeyString case sensitivity: %q=%v, %q=%v", tt.input1, got1, tt.input2, got2)
216+ }
217+ })
218+ }
219+}
220+
221+func TestShortcutSet_Lookup(t *testing.T) {
222+ set := &ShortcutSet{
223+ Global: ShortcutTable{
224+ {uint(gdk.KEY_q), ModCtrl}: ActionQuit,
225+ {uint(gdk.KEY_l), ModCtrl}: ActionOpenOmnibox,
226+ },
227+ TabMode: ShortcutTable{
228+ {uint('n'), ModNone}: ActionNewTab,
229+ {uint(gdk.KEY_Escape), ModNone}: ActionExitMode,
230+ },
231+ PaneMode: ShortcutTable{
232+ {uint('x'), ModNone}: ActionClosePane,
233+ {uint(gdk.KEY_Escape), ModNone}: ActionExitMode,
234+ },
235+ }
236+
237+ tests := []struct {
238+ name string
239+ binding KeyBinding
240+ mode Mode
241+ want Action
242+ wantOk bool
243+ }{
244+ {
245+ name: "global in normal mode",
246+ binding: KeyBinding{uint(gdk.KEY_q), ModCtrl},
247+ mode: ModeNormal,
248+ want: ActionQuit,
249+ wantOk: true,
250+ },
251+ {
252+ name: "global in tab mode",
253+ binding: KeyBinding{uint(gdk.KEY_l), ModCtrl},
254+ mode: ModeTab,
255+ want: ActionOpenOmnibox,
256+ wantOk: true,
257+ },
258+ {
259+ name: "tab mode action",
260+ binding: KeyBinding{uint('n'), ModNone},
261+ mode: ModeTab,
262+ want: ActionNewTab,
263+ wantOk: true,
264+ },
265+ {
266+ name: "pane mode action",
267+ binding: KeyBinding{uint('x'), ModNone},
268+ mode: ModePane,
269+ want: ActionClosePane,
270+ wantOk: true,
271+ },
272+ {
273+ name: "escape exits tab mode",
274+ binding: KeyBinding{uint(gdk.KEY_Escape), ModNone},
275+ mode: ModeTab,
276+ want: ActionExitMode,
277+ wantOk: true,
278+ },
279+ {
280+ name: "escape exits pane mode",
281+ binding: KeyBinding{uint(gdk.KEY_Escape), ModNone},
282+ mode: ModePane,
283+ want: ActionExitMode,
284+ wantOk: true,
285+ },
286+ {
287+ name: "unknown key in normal mode",
288+ binding: KeyBinding{uint('z'), ModNone},
289+ mode: ModeNormal,
290+ want: "",
291+ wantOk: false,
292+ },
293+ {
294+ name: "tab action not in normal mode",
295+ binding: KeyBinding{uint('n'), ModNone},
296+ mode: ModeNormal,
297+ want: "",
298+ wantOk: false,
299+ },
300+ }
301+
302+ for _, tt := range tests {
303+ t.Run(tt.name, func(t *testing.T) {
304+ got, ok := set.Lookup(tt.binding, tt.mode)
305+ if ok != tt.wantOk {
306+ t.Errorf("Lookup() ok = %v, want %v", ok, tt.wantOk)
307+ return
308+ }
309+ if got != tt.want {
310+ t.Errorf("Lookup() = %v, want %v", got, tt.want)
311+ }
312+ })
313+ }
314+}
315+
316+func TestShouldAutoExitMode(t *testing.T) {
317+ exitActions := []Action{
318+ ActionNewTab,
319+ ActionCloseTab,
320+ ActionRenameTab,
321+ ActionSplitRight,
322+ ActionSplitLeft,
323+ ActionSplitUp,
324+ ActionSplitDown,
325+ ActionClosePane,
326+ ActionStackPane,
327+ }
328+
329+ stayActions := []Action{
330+ ActionNextTab,
331+ ActionPreviousTab,
332+ ActionFocusRight,
333+ ActionFocusLeft,
334+ ActionGoBack,
335+ ActionZoomIn,
336+ ActionOpenOmnibox,
337+ }
338+
339+ for _, action := range exitActions {
340+ if !ShouldAutoExitMode(action) {
341+ t.Errorf("ShouldAutoExitMode(%s) = false, want true", action)
342+ }
343+ }
344+
345+ for _, action := range stayActions {
346+ if ShouldAutoExitMode(action) {
347+ t.Errorf("ShouldAutoExitMode(%s) = true, want false", action)
348+ }
349+ }
350+}

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 8

Tags 42

↑↓ moveenter openesc close