v0.15.0 9006957

keyboard_bridge.go RawHistoryFolder

1package webkit
2
3import (
4	"encoding/json"
5	"fmt"
6
7	"github.com/bnema/dumber/internal/logging"
8	"github.com/diamondburned/gotk4/pkg/gdk/v4"
9	"github.com/diamondburned/gotk4/pkg/gtk/v4"
10)
11
12// Keyboard shortcut modifier string constants
13const (
14	modifierCtrl  = "ctrl"
15	modifierShift = "shift"
16	modifierAlt   = "alt"
17)
18
19// GDK key constants for special keys
20const (
21	gdkKeyLeft   = 0xff51
22	gdkKeyRight  = 0xff53
23	gdkKeyUp     = 0xff52
24	gdkKeyDown   = 0xff54
25	gdkKeyEscape = 0xff1b
26	gdkKeyF12    = 0xffbe
27	gdkKeyPlus   = 0x2b
28	gdkKeyEqual  = 0x3d
29	gdkKeyMinus  = 0x2d
30)
31
32// Key name constants for shortcut mapping
33const (
34	keyP          = "p"
35	keyL          = "l"
36	keyR          = "r"
37	keyD          = "d"
38	keyU          = "u"
39	keyF          = "f"
40	keyC          = "c"
41	keyW          = "w"
42	keyX          = "x"
43	keyS          = "s"
44	keyH          = "h"
45	keyJ          = "j"
46	keyK          = "k"
47	keyEscape     = "escape"
48	keyArrowLeft  = "arrowleft"
49	keyArrowRight = "arrowright"
50	keyArrowUp    = "arrowup"
51	keyArrowDown  = "arrowdown"
52	keyF12        = "F12"
53	keyPlus       = "plus"
54	keyMinus      = "minus"
55	keyZero       = "0"
56	altmod        = "alt"
57)
58
59// Pane mode action constants
60const (
61	actionClose      = "close"
62	actionSplitLeft  = "split-left"
63	actionSplitRight = "split-right"
64	actionSplitDown  = "split-down"
65	actionSplitUp    = "split-up"
66	actionStack      = "stack"
67	actionExit       = "exit"
68	actionEnter      = "enter"
69)
70
71// Navigation direction constants
72const (
73	directionLeft  = "left"
74	directionRight = "right"
75	directionUp    = "up"
76	directionDown  = "down"
77)
78
79// Map shortcuts to actions (unified for all keyboard handling)
80var shortcutActions = map[string]string{
81	// Pane mode actions (no modifier)
82	keyX:          actionClose,
83	keyS:          actionStack,
84	keyL:          actionSplitLeft,
85	keyR:          actionSplitRight,
86	keyD:          actionSplitDown,
87	keyU:          actionSplitUp,
88	keyArrowLeft:  actionSplitLeft,
89	keyArrowRight: actionSplitRight,
90	keyArrowDown:  actionSplitDown,
91	keyArrowUp:    actionSplitUp,
92
93	// Navigation (alt modifier)
94	altmod + "+" + keyArrowLeft:  directionLeft,
95	altmod + "+" + keyArrowRight: directionRight,
96	altmod + "+" + keyArrowDown:  directionDown,
97	altmod + "+" + keyArrowUp:    directionUp,
98
99	// Vim-style navigation (alt modifier)
100	altmod + "+" + keyH: directionLeft,
101	altmod + "+" + keyL: directionRight,
102	altmod + "+" + keyK: directionUp,
103	altmod + "+" + keyJ: directionDown,
104}
105
106// TODO(#8): Refactor this function to reduce cyclomatic complexity (52 -> <30)
107//
108// AttachKeyboardBridge attaches an EventControllerKey to bridge keyboard events to JavaScript
109// This is critical for allowing JavaScript KeyboardService to receive keyboard events
110// that would otherwise be consumed by GTK shortcuts
111func (w *WebView) AttachKeyboardBridge() {
112	if w == nil || w.view == nil {
113		return
114	}
115
116	// Create event controller for keyboard
117	keyController := gtk.NewEventControllerKey()
118
119	// Set to capture phase so we see events before WebView processes them
120	keyController.SetPropagationPhase(gtk.PhaseCapture)
121
122	// Connect to key-pressed signal
123	keyController.ConnectKeyPressed(func(keyval, keycode uint, state gdk.ModifierType) bool {
124		// Check if pane mode is active - block ALL keys except pane mode actions
125		w.mu.RLock()
126		paneModeChecker := w.isPaneModeActive
127		w.mu.RUnlock()
128
129		isPaneModeActive := paneModeChecker != nil && paneModeChecker()
130
131		// Build normalized shortcut string
132		var parts []string
133
134		ctrl := state.Has(gdk.ControlMask)
135		alt := state.Has(gdk.AltMask)
136		shift := state.Has(gdk.ShiftMask)
137
138		// Build modifier prefix using switch on modifier combination
139		switch {
140		case ctrl && shift:
141			parts = append(parts, modifierCtrl, modifierShift)
142		case ctrl && !shift:
143			parts = append(parts, modifierCtrl)
144		case alt:
145			parts = append(parts, modifierAlt)
146		case shift:
147			parts = append(parts, modifierShift)
148		default:
149			// No modifiers - only forward if we match a key below
150		}
151
152		// Map keyval to key name
153		var keyName string
154		switch keyval {
155		case 'p', 'P':
156			keyName = keyP
157		case 'l', 'L':
158			keyName = keyL
159		case 'r', 'R':
160			keyName = keyR
161		case 'd', 'D':
162			keyName = keyD
163		case 'u', 'U':
164			keyName = keyU
165		case 'f', 'F':
166			keyName = keyF
167		case 'c', 'C':
168			keyName = keyC
169		case 'w', 'W':
170			keyName = keyW
171		case 'x', 'X':
172			keyName = keyX
173		case 'h', 'H':
174			keyName = keyH
175		case 'j', 'J':
176			keyName = keyJ
177		case 'k', 'K':
178			keyName = keyK
179		case 's', 'S':
180			keyName = keyS
181		case gdkKeyLeft:
182			keyName = keyArrowLeft
183		case gdkKeyRight:
184			keyName = keyArrowRight
185		case gdkKeyUp:
186			keyName = keyArrowUp
187		case gdkKeyDown:
188			keyName = keyArrowDown
189		case gdkKeyEscape:
190			keyName = keyEscape
191		case gdkKeyF12:
192			keyName = keyF12
193		case gdkKeyPlus, gdkKeyEqual:
194			keyName = keyPlus
195		case gdkKeyMinus:
196			keyName = keyMinus
197		case '0':
198			keyName = keyZero
199		default:
200			// For unknown keys, let WebView handle them
201			// This allows web apps to receive Ctrl+C, Ctrl+V, Ctrl+A, etc.
202			return false
203		}
204
205		if keyName != "" {
206			parts = append(parts, keyName)
207		}
208
209		shortcut := ""
210		if len(parts) > 0 {
211			shortcut = parts[0]
212			for i := 1; i < len(parts); i++ {
213				shortcut += "+" + parts[i]
214			}
215		}
216
217		if shortcut != "" {
218			// Special case: Ctrl+P enters pane mode
219			if shortcut == modifierCtrl+"+"+keyP {
220				w.mu.RLock()
221				handler := w.onPaneModeShortcut
222				w.mu.RUnlock()
223				if handler != nil && handler(actionEnter) {
224					logging.Debug(fmt.Sprintf("[keyboard-bridge] Pane mode: enter"))
225					return true
226				}
227				return true // Block even if no handler
228			}
229
230			// Special case: Escape exits active mode
231			if shortcut == keyEscape {
232				w.mu.RLock()
233				handler := w.onPaneModeShortcut
234				w.mu.RUnlock()
235				if handler != nil && handler(actionExit) {
236					logging.Debug(fmt.Sprintf("[keyboard-bridge] Escape: exited mode"))
237					return true
238				}
239				return false // Pass through to DOM if no active mode
240			}
241
242			// Check if shortcut maps to an action
243			if action, exists := shortcutActions[shortcut]; exists {
244				// Navigation actions (alt+arrow)
245				if action == directionLeft || action == directionRight || action == directionUp || action == directionDown {
246					w.mu.RLock()
247					handler := w.onWorkspaceNavigation
248					w.mu.RUnlock()
249					if handler != nil && handler(action) {
250						logging.Debug(fmt.Sprintf("[keyboard-bridge] Navigation: %s", action))
251						return true
252					}
253					return true // Block anyway to prevent scrolling
254				}
255
256				// Pane mode actions (x, l, r, d, u, arrows)
257				w.mu.RLock()
258				handler := w.onPaneModeShortcut
259				w.mu.RUnlock()
260				if handler != nil && handler(action) {
261					logging.Debug(fmt.Sprintf("[keyboard-bridge] Pane mode action: %s", action))
262					return true
263				}
264				return false // Not in pane mode, pass through
265			}
266
267			// Pane mode active: block all other shortcuts
268			if isPaneModeActive {
269				logging.Debug(fmt.Sprintf("[keyboard-bridge] Blocking '%s' during pane mode", shortcut))
270				return true
271			}
272
273			// Forward to JavaScript using proper JSON marshaling to prevent any potential injection
274			shortcutJSON, err := json.Marshal(shortcut)
275			if err != nil {
276				logging.Error(fmt.Sprintf("[keyboard-bridge] Failed to marshal shortcut: %v", err))
277				return false
278			}
279			script := fmt.Sprintf(`document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:%s}}));`, shortcutJSON)
280			if err := w.InjectScript(script); err != nil {
281				logging.Error(fmt.Sprintf("[keyboard-bridge] Failed to dispatch: %v", err))
282			}
283			return false // Allow WebKit to handle
284		}
285
286		// Pane mode active: block ALL non-shortcut keys
287		if isPaneModeActive {
288			logging.Debug(fmt.Sprintf("[keyboard-bridge] Blocking key during pane mode: keyval=%d", keyval))
289			return true // Block all other keys
290		}
291
292		// No shortcut matched and not in pane mode - allow WebKit to handle normally
293		return false
294	})
295
296	// Attach controller to WebView
297	w.view.AddController(keyController)
298	logging.Debug(fmt.Sprintf("[keyboard-bridge] EventControllerKey attached to WebView ID %d", w.id))
299}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close