v0.10.0 0f4e9eb

shortcuts_cgo.go RawHistoryFolder

1//go:build webkit_cgo
2
3package webkit
4
5import (
6	"fmt"
7	"log"
8	"strings"
9	"sync"
10	"sync/atomic"
11	"unsafe"
12)
13
14/*
15#cgo pkg-config: gtk4 webkitgtk-6.0
16#include <gtk/gtk.h>
17#include <webkit/webkit.h>
18#include <stdint.h>
19#include <stdlib.h>
20
21extern void goHandleWindowShortcut(uintptr_t handle);
22
23static GtkShortcutController* create_global_shortcut_controller(GtkWindow* window) {
24    if (!window) {
25        return NULL;
26    }
27    GtkEventController* event_controller = gtk_shortcut_controller_new();
28    if (!event_controller) {
29        return NULL;
30    }
31    GtkShortcutController* controller = GTK_SHORTCUT_CONTROLLER(event_controller);
32    gtk_shortcut_controller_set_scope(controller, GTK_SHORTCUT_SCOPE_GLOBAL);
33    gtk_widget_add_controller(GTK_WIDGET(window), event_controller);
34    return controller;
35}
36
37static gboolean shortcut_callback_bridge(GtkWidget* widget, GVariant* args, gpointer user_data) {
38    (void)widget;
39    (void)args;
40    uintptr_t handle = (uintptr_t)user_data;
41    if (handle == 0) {
42        return FALSE;
43    }
44    goHandleWindowShortcut(handle);
45    return TRUE;  // Indicate we handled the shortcut
46}
47
48static void add_shortcut_to_controller(GtkShortcutController* controller,
49                                const char* trigger_str,
50                                uintptr_t handle) {
51    if (!controller || !trigger_str || handle == 0) {
52        return;
53    }
54    GtkShortcutTrigger* trigger = gtk_shortcut_trigger_parse_string(trigger_str);
55    if (!trigger) {
56        return;
57    }
58    GtkShortcutAction* action = gtk_callback_action_new((GtkShortcutFunc)shortcut_callback_bridge,
59                                                        (gpointer)handle,
60                                                        NULL);
61    if (!action) {
62        g_object_unref(trigger);
63        return;
64    }
65    GtkShortcut* shortcut = gtk_shortcut_new(trigger, action);
66    gtk_shortcut_controller_add_shortcut(controller, shortcut);
67}
68*/
69import "C"
70
71var (
72	globalShortcutMu        sync.Mutex
73	globalShortcutCounter   uint64
74	globalShortcutCallbacks = make(map[uintptr]func())
75)
76
77func registerWindowShortcutCallback(cb func()) uintptr {
78	if cb == nil {
79		return 0
80	}
81	id := uintptr(atomic.AddUint64(&globalShortcutCounter, 1))
82	if id == 0 {
83		// Should never happen, but guard against wrapping to 0
84		id = uintptr(atomic.AddUint64(&globalShortcutCounter, 1))
85	}
86	globalShortcutMu.Lock()
87	globalShortcutCallbacks[id] = cb
88	globalShortcutMu.Unlock()
89	return id
90}
91
92func unregisterWindowShortcutCallback(id uintptr) {
93	if id == 0 {
94		return
95	}
96	globalShortcutMu.Lock()
97	delete(globalShortcutCallbacks, id)
98	globalShortcutMu.Unlock()
99}
100
101func invokeWindowShortcutCallback(id uintptr) {
102	if id == 0 {
103		return
104	}
105	globalShortcutMu.Lock()
106	cb := globalShortcutCallbacks[id]
107	globalShortcutMu.Unlock()
108	if cb == nil {
109		log.Printf("[shortcuts] no handler for shortcut id=%d", id)
110		return
111	}
112	cb()
113}
114
115//export goHandleWindowShortcut
116func goHandleWindowShortcut(handle C.uintptr_t) {
117	invokeWindowShortcutCallback(uintptr(handle))
118}
119
120// Go wrapper functions for CGO - these can be called from other Go files
121
122// CreateGlobalShortcutController creates a GTK4 global shortcut controller for a window
123func CreateGlobalShortcutController(window uintptr) uintptr {
124	if window == 0 {
125		return 0
126	}
127	controller := C.create_global_shortcut_controller((*C.GtkWindow)(unsafe.Pointer(window)))
128	return uintptr(unsafe.Pointer(controller))
129}
130
131// AddShortcutToController adds a shortcut to the controller with a callback handle
132func AddShortcutToController(controller uintptr, key string, handle uintptr) error {
133	if controller == 0 || key == "" || handle == 0 {
134		return ErrNotImplemented
135	}
136
137	keyStr := C.CString(key)
138	defer C.free(unsafe.Pointer(keyStr))
139
140	C.add_shortcut_to_controller(
141		(*C.GtkShortcutController)(unsafe.Pointer(controller)),
142		keyStr,
143		C.uintptr_t(handle),
144	)
145
146	return nil
147}
148
149// Global shortcuts registry to track all registered shortcuts for automatic blocking
150var (
151	globalShortcutsRegistry = make(map[string]bool)
152	shortcutsRegistryMu     sync.RWMutex
153	// Map from shortcut key to callback handle for C-level triggering
154	globalShortcutHandles = make(map[string]uintptr)
155)
156
157// WebView tracking for dynamic protection refresh
158var (
159	activeWebViews   = make(map[*WebView]bool)
160	activeWebViewsMu sync.RWMutex
161)
162
163// RegisterGlobalShortcut adds a shortcut to the global registry for automatic webpage blocking
164func RegisterGlobalShortcut(shortcut string) {
165	shortcutsRegistryMu.Lock()
166	// Normalize shortcut format (convert to lowercase, standardize modifiers)
167	normalized := normalizeShortcut(shortcut)
168
169	// Only proceed if this is a new shortcut registration
170	wasNew := !globalShortcutsRegistry[normalized]
171	if wasNew {
172		globalShortcutsRegistry[normalized] = true
173		log.Printf("[shortcuts] Registered global shortcut for blocking: %s -> %s", shortcut, normalized)
174	}
175	shortcutsRegistryMu.Unlock()
176
177	// If we added a new shortcut, refresh protection on all active WebViews
178	if wasNew {
179		RefreshGlobalShortcutsProtection()
180	}
181}
182
183// RegisterGlobalShortcutWithHandle adds a shortcut with its callback handle for C-level triggering
184func RegisterGlobalShortcutWithHandle(shortcut string, handle uintptr) {
185	// First register the shortcut for blocking
186	RegisterGlobalShortcut(shortcut)
187
188	// Then store the callback handle for C-level triggering
189	shortcutsRegistryMu.Lock()
190	normalized := normalizeShortcut(shortcut)
191	globalShortcutHandles[normalized] = handle
192	log.Printf("[shortcuts] Registered callback handle for shortcut: %s", shortcut)
193	shortcutsRegistryMu.Unlock()
194}
195
196// GetRegisteredShortcuts returns a copy of all registered shortcuts
197func GetRegisteredShortcuts() []string {
198	shortcutsRegistryMu.RLock()
199	defer shortcutsRegistryMu.RUnlock()
200
201	shortcuts := make([]string, 0, len(globalShortcutsRegistry))
202	for shortcut := range globalShortcutsRegistry {
203		shortcuts = append(shortcuts, shortcut)
204	}
205	return shortcuts
206}
207
208// RegisterActiveWebView adds a WebView to the active registry for dynamic protection updates
209func RegisterActiveWebView(w *WebView) {
210	if w == nil {
211		return
212	}
213	activeWebViewsMu.Lock()
214	defer activeWebViewsMu.Unlock()
215	activeWebViews[w] = true
216	// WebView registered for protection updates
217}
218
219// UnregisterActiveWebView removes a WebView from the active registry
220func UnregisterActiveWebView(w *WebView) {
221	if w == nil {
222		return
223	}
224	activeWebViewsMu.Lock()
225	defer activeWebViewsMu.Unlock()
226	delete(activeWebViews, w)
227	// WebView unregistered from protection updates
228}
229
230// RefreshGlobalShortcutsProtection updates all active WebViews with current shortcut protection
231func RefreshGlobalShortcutsProtection() {
232	activeWebViewsMu.RLock()
233	webViews := make([]*WebView, 0, len(activeWebViews))
234	for wv := range activeWebViews {
235		webViews = append(webViews, wv)
236	}
237	activeWebViewsMu.RUnlock()
238
239	if len(webViews) == 0 {
240		return
241	}
242
243	for _, wv := range webViews {
244		if wv != nil && !wv.destroyed {
245			// Re-enable protection which will inject updated shortcut list
246			if err := wv.EnableGlobalShortcutsProtection(); err != nil {
247				log.Printf("[shortcuts] Failed to refresh protection for WebView: %v", err)
248			}
249		}
250	}
251}
252
253// normalizeShortcut converts shortcuts to a standardized format for JavaScript blocking
254func normalizeShortcut(shortcut string) string {
255	// Convert to lowercase and replace common variations
256	normalized := strings.ToLower(shortcut)
257
258	// Standardize modifier keys
259	normalized = strings.ReplaceAll(normalized, "cmdorctrl+", "ctrl+")
260	normalized = strings.ReplaceAll(normalized, "cmd+", "ctrl+") // Treat cmd as ctrl for blocking
261	normalized = strings.ReplaceAll(normalized, "control+", "ctrl+")
262
263	// Standardize arrow key names (handle both formats)
264	normalized = strings.ReplaceAll(normalized, "left", "arrowleft")
265	normalized = strings.ReplaceAll(normalized, "right", "arrowright")
266	normalized = strings.ReplaceAll(normalized, "up", "arrowup")
267	normalized = strings.ReplaceAll(normalized, "down", "arrowdown")
268
269	// Fix double arrow prefixes that might occur from above replacements
270	normalized = strings.ReplaceAll(normalized, "arrowarrowleft", "arrowleft")
271	normalized = strings.ReplaceAll(normalized, "arrowarrowright", "arrowright")
272	normalized = strings.ReplaceAll(normalized, "arrowarrowup", "arrowup")
273	normalized = strings.ReplaceAll(normalized, "arrowarrowdown", "arrowdown")
274
275	// Other key normalizations
276	normalized = strings.ReplaceAll(normalized, "equal", "equal") // For Ctrl+=
277	normalized = strings.ReplaceAll(normalized, "plus", "equal")  // Normalize + to =
278
279	return normalized
280}
281
282// Keyboard event blocking functionality for global shortcuts protection
283
284// keyboardBlockerScript holds reference to the main-world keyboard blocking script
285var keyboardBlockerScript *C.WebKitUserScript = nil
286
287// EnableGlobalShortcutsProtection injects a script to block all registered global shortcuts from reaching webpages
288func (w *WebView) EnableGlobalShortcutsProtection() error {
289	if w == nil || w.destroyed || w.native == nil || w.native.ucm == nil {
290		return ErrWebViewNotInitialized
291	}
292
293	// Get all registered shortcuts
294	shortcuts := GetRegisteredShortcuts()
295	if len(shortcuts) == 0 {
296		log.Printf("[shortcuts] No global shortcuts registered, skipping protection")
297		return nil
298	}
299
300	// Generate JavaScript array of shortcuts to block
301	shortcutsJS := "["
302	for i, shortcut := range shortcuts {
303		if i > 0 {
304			shortcutsJS += ","
305		}
306		shortcutsJS += `"` + shortcut + `"`
307	}
308	shortcutsJS += "]"
309
310	// Create a version-based blocking script that can be updated dynamically
311	protectionVersion := len(shortcuts) // Use shortcuts count as version
312	globalProtectionScript := `
313		(() => {
314			const newVersion = ` + fmt.Sprintf("%d", protectionVersion) + `;
315			const currentVersion = window.__dumber_shortcuts_protection_version || 0;
316
317			// Only update if this is a newer version or first time
318			if (newVersion > currentVersion) {
319				// Clean up old protection if it exists
320				if (window.__dumber_cleanup_global_shortcuts) {
321					window.__dumber_cleanup_global_shortcuts();
322				}
323
324				window.__dumber_shortcuts_protection_version = newVersion;
325
326				// List of global shortcuts to block from reaching the webpage
327				const globalShortcuts = ` + shortcutsJS + `;
328				console.log('[dumber] Protecting global shortcuts (v' + newVersion + '):', globalShortcuts);
329
330				// Function to check if a key event matches any global shortcut
331				const isGlobalShortcut = (e) => {
332					const key = e.key.toLowerCase();
333					const code = e.code.toLowerCase();
334
335					// Build shortcut string (similar to GTK format)
336					let shortcut = '';
337					if (e.ctrlKey) shortcut += 'ctrl+';
338					if (e.altKey) shortcut += 'alt+';
339					if (e.shiftKey) shortcut += 'shift+';
340					if (e.metaKey) shortcut += 'meta+';
341
342					// Try both key and code for matching
343					const keyShortcut = shortcut + key;
344					const codeShortcut = shortcut + code;
345
346					// Special case mappings for common shortcuts
347					const specialMappings = {
348						'ctrl+equal': ['ctrl+=', 'ctrl+plus'],
349						'ctrl+minus': ['ctrl+-'],
350						'f12': ['f12'],
351						'ctrl+r': ['ctrl+r'],
352						'ctrl+shift+r': ['ctrl+shift+r'],
353						'f5': ['f5'],
354						// Alt+Arrow key mappings (handle both key and code variations)
355						'alt+arrowleft': ['alt+left', 'alt+arrowleft'],
356						'alt+arrowright': ['alt+right', 'alt+arrowright'],
357						'alt+arrowup': ['alt+up', 'alt+arrowup'],
358						'alt+arrowdown': ['alt+down', 'alt+arrowdown']
359					};
360
361					// Check direct matches
362					if (globalShortcuts.includes(keyShortcut) || globalShortcuts.includes(codeShortcut)) {
363						return true;
364					}
365
366					// Check special mappings
367					for (const [canonical, variants] of Object.entries(specialMappings)) {
368						if (variants.includes(keyShortcut) || variants.includes(codeShortcut)) {
369							if (globalShortcuts.includes(canonical)) {
370								return true;
371							}
372						}
373					}
374
375					return false;
376				};
377
378				// Block only global shortcuts at capture phase
379				const blockGlobalShortcut = (e) => {
380					if (isGlobalShortcut(e)) {
381						console.log('[dumber] Blocked global shortcut from reaching page:', e.key, e.ctrlKey, e.altKey, e.shiftKey);
382						e.stopImmediatePropagation();
383						e.preventDefault();
384						e.stopPropagation();
385						return false;
386					}
387					// Let other key events through normally
388				};
389
390				// Install listeners for keyboard events
391				document.addEventListener('keydown', blockGlobalShortcut, true);
392				document.addEventListener('keyup', blockGlobalShortcut, true);
393				document.addEventListener('keypress', blockGlobalShortcut, true);
394
395				// Store cleanup function
396				window.__dumber_cleanup_global_shortcuts = () => {
397					document.removeEventListener('keydown', blockGlobalShortcut, true);
398					document.removeEventListener('keyup', blockGlobalShortcut, true);
399					document.removeEventListener('keypress', blockGlobalShortcut, true);
400					delete window.__dumber_shortcuts_protection_version;
401					delete window.__dumber_cleanup_global_shortcuts;
402					console.log('[dumber] Global shortcuts protection disabled');
403				};
404
405				console.log('[dumber] Global shortcuts protection enabled for', globalShortcuts.length, 'shortcuts (v' + newVersion + ')');
406			} else {
407				console.log('[dumber] Global shortcuts protection already up to date (v' + currentVersion + ')');
408			}
409		})();
410	`
411
412	// Inject the script into the main world at document start
413	cScript := C.CString(globalProtectionScript)
414	defer C.free(unsafe.Pointer(cScript))
415
416	script := C.webkit_user_script_new(
417		(*C.gchar)(cScript),
418		C.WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
419		C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
420		nil, nil) // nil = main world
421
422	if script == nil {
423		return ErrNotImplemented
424	}
425
426	C.webkit_user_content_manager_add_script(w.native.ucm, script)
427	C.webkit_user_script_unref(script) // Clean up reference
428
429	log.Printf("[shortcuts] Global shortcuts protection enabled for %d shortcuts", len(shortcuts))
430	return nil
431}
432
433// DisableGlobalShortcutsProtection removes the global shortcuts protection
434func (w *WebView) DisableGlobalShortcutsProtection() error {
435	if w == nil || w.destroyed || w.native == nil {
436		return ErrWebViewNotInitialized
437	}
438
439	// Execute cleanup script
440	cleanupScript := `
441		if (window.__dumber_cleanup_global_shortcuts) {
442			window.__dumber_cleanup_global_shortcuts();
443		}
444	`
445
446	return w.InjectScript(cleanupScript)
447}
448
449// EnablePageKeyboardBlocking injects a script into the main world to block all keyboard events
450// This prevents page JavaScript from receiving keyboard events while omnibox is active
451func (w *WebView) EnablePageKeyboardBlocking() error {
452	if w == nil || w.destroyed || w.native == nil || w.native.ucm == nil {
453		return ErrWebViewNotInitialized
454	}
455
456	// Remove any existing blocker script first
457	w.DisablePageKeyboardBlocking()
458
459	// Script to block all keyboard and related events in the main world
460	blockingScript := `
461		(() => {
462			// Store original state to restore later
463			if (!window.__dumber_event_blocker_installed) {
464				window.__dumber_event_blocker_installed = true;
465
466				// List of events to block
467				const eventTypes = [
468					'keydown', 'keyup', 'keypress',
469					'input', 'beforeinput',
470					'compositionstart', 'compositionupdate', 'compositionend'
471				];
472
473				// Block all keyboard-related events at capture phase
474				const blockEvent = (e) => {
475					e.stopImmediatePropagation();
476					e.preventDefault();
477					e.stopPropagation();
478					return false;
479				};
480
481				// Install capture-phase event blockers
482				eventTypes.forEach(type => {
483					document.addEventListener(type, blockEvent, true);
484				});
485
486				// Store cleanup function
487				window.__dumber_cleanup_event_blocker = () => {
488					eventTypes.forEach(type => {
489						document.removeEventListener(type, blockEvent, true);
490					});
491					delete window.__dumber_event_blocker_installed;
492					delete window.__dumber_cleanup_event_blocker;
493				};
494
495				console.log('[dumber] Page keyboard event blocking enabled');
496			}
497		})();
498	`
499
500	cScript := C.CString(blockingScript)
501	defer C.free(unsafe.Pointer(cScript))
502
503	// Inject into main world (nil world parameter = main world)
504	keyboardBlockerScript = C.webkit_user_script_new(
505		(*C.gchar)(cScript),
506		C.WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
507		C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
508		nil, nil) // nil = main world
509
510	if keyboardBlockerScript == nil {
511		return ErrNotImplemented
512	}
513
514	C.webkit_user_content_manager_add_script(w.native.ucm, keyboardBlockerScript)
515	log.Printf("[webkit] Page keyboard event blocking enabled")
516	return nil
517}
518
519// DisablePageKeyboardBlocking removes the keyboard blocking script and restores normal page behavior
520func (w *WebView) DisablePageKeyboardBlocking() error {
521	if w == nil || w.destroyed || w.native == nil || w.native.ucm == nil {
522		return ErrWebViewNotInitialized
523	}
524
525	// Execute cleanup script to remove event listeners
526	cleanupScript := `
527		if (window.__dumber_cleanup_event_blocker) {
528			window.__dumber_cleanup_event_blocker();
529			console.log('[dumber] Page keyboard event blocking disabled');
530		}
531	`
532	// Double-check WebView is still valid before script injection
533	if !w.IsDestroyed() {
534		w.InjectScript(cleanupScript)
535	}
536
537	// Remove the user script if it exists
538	if keyboardBlockerScript != nil {
539		C.webkit_user_content_manager_remove_script(w.native.ucm, keyboardBlockerScript)
540		C.webkit_user_script_unref(keyboardBlockerScript)
541		keyboardBlockerScript = nil
542		log.Printf("[webkit] Keyboard blocker script removed")
543	}
544
545	return nil
546}

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