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 | + | } | |||