v0.11.0 dc50343

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

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