detached 3562591

refactor(webkit): complete JavaScript bridge and GTK4 keyboard integration

Major fixes for gotk4 migration to restore all keyboard shortcuts:

JavaScript-to-Go Bridge Fixes:
- Fix DispatchCustomEvent to use document.dispatchEvent instead of window
  (critical fix: Document object is shared across JavaScript worlds)
- Fix WebView ID injection at document-start before GUI scripts load
- Update main-world.ts to not overwrite injected webview ID
- Add zoom toast notifications for Ctrl+/-/0 shortcuts

Go-to-JavaScript Bridge Fixes:
- Implement EventControllerKey bridge to forward keyboard events to JavaScript
  (main branch uses CGO EventController, we now use gotk4 equivalent)
- Bridge captures keyboard events at PhaseCapture and forwards via dumber:key events
- Allows JavaScript KeyboardService to receive events for page-level shortcuts
- All keyboard events now properly reach both GTK and JavaScript handlers

GTK4 Shortcut System:
- Fix shortcuts.go to use proper gotk4 API (NewCallbackAction, ShortcutTrigger)
- Convert accelerator format (ctrl+l -> <Control>l) for GTK4 compatibility
- Fix evaluate.go to use javascriptcore/v6 instead of v4 (no more GTK2/3!)
- Update all GTK/GDK constant names for gotk4 (ControlMask, PhaseCapture, etc)

Code Quality:
- Add keyboard_bridge.go as dedicated file for keyboard event forwarding
- Replace magic strings with proper constants (modifierCmdOrCtrl, keyP, etc)
- Use switch statements instead of if-else chains for cleaner code
- Add comprehensive logging for keyboard event flow debugging

Verified Working:
- ✅ Ctrl+L, Ctrl+F (omnibox/find toggles)
- ✅ Ctrl+Shift+C (copy URL)
- ✅ Ctrl++/-/0 (zoom in/out/reset with toast notifications)
- ✅ Ctrl+P (pane mode activation - JavaScript-handled)
- ✅ Alt+Arrow keys (pane navigation)
- ✅ F12 (developer tools)

This completes the core JavaScript<->GTK bridge for the gotk4 migration.
Pane splitting functionality works but has separate layout issues to address.
commit
356259143f7aea22efa6096c1c9a4f4b1721184e
author
bamen · · 2025-10-09 17:29 UTC
parent
4be3d7e
signature
unsigned
12 files · +486 -50
M gui/src/components/toast/ToastContainer.svelte +7 -7
@@ -110,15 +110,15 @@
110110 $effect(() => {
111111 console.log('🔄 ToastContainer initialized, functions exposed');
112112
113- // Monitor container periodically for debugging
114- const monitorInterval = setInterval(() => {
115- const container = document.querySelector('.toast-container');
116- const browserRoot = document.querySelector('.browser-component-root');
117- console.log('[dumber] Periodic check - container:', !!container, 'browser-root:', !!browserRoot, 'toasts:', toasts.length);
118- }, 5000);
113+ // Monitor container periodically for debugging (disabled)
114+ // const monitorInterval = setInterval(() => {
115+ // const container = document.querySelector('.toast-container');
116+ // const browserRoot = document.querySelector('.browser-component-root');
117+ // console.log('[dumber] Periodic check - container:', !!container, 'browser-root:', !!browserRoot, 'toasts:', toasts.length);
118+ // }, 5000);
119119
120120 return () => {
121- clearInterval(monitorInterval);
121+ // clearInterval(monitorInterval);
122122 console.log('🗑️ ToastContainer cleanup');
123123 };
124124 });
@@ -110,15 +110,15 @@
110 $effect(() => {110 $effect(() => {
111 console.log('🔄 ToastContainer initialized, functions exposed');111 console.log('🔄 ToastContainer initialized, functions exposed');
112 112
113- // Monitor container periodically for debugging113+ // Monitor container periodically for debugging (disabled)
114- const monitorInterval = setInterval(() => {114+ // const monitorInterval = setInterval(() => {
115- const container = document.querySelector('.toast-container');115+ // const container = document.querySelector('.toast-container');
116- const browserRoot = document.querySelector('.browser-component-root');116+ // const browserRoot = document.querySelector('.browser-component-root');
117- console.log('[dumber] Periodic check - container:', !!container, 'browser-root:', !!browserRoot, 'toasts:', toasts.length);117+ // console.log('[dumber] Periodic check - container:', !!container, 'browser-root:', !!browserRoot, 'toasts:', toasts.length);
118- }, 5000);118+ // }, 5000);
119 119
120 return () => {120 return () => {
121- clearInterval(monitorInterval);121+ // clearInterval(monitorInterval);
122 console.log('🗑️ ToastContainer cleanup');122 console.log('🗑️ ToastContainer cleanup');
123 };123 };
124 });124 });
M gui/src/injected/gui.ts +8 -0
@@ -283,7 +283,15 @@ if (!window.__dumber_gui_ready) {
283283 const eventWebViewId = detail?.webviewId;
284284 const source = detail?.source || "unknown";
285285
286+ console.log("[dumber shortcuts] Event received", {
287+ action,
288+ eventWebViewId,
289+ currentWebViewId,
290+ detail,
291+ });
292+
286293 if (typeof action !== "string") {
294+ console.log("[dumber shortcuts] No action in event, ignoring");
287295 return;
288296 }
289297
@@ -283,7 +283,15 @@ if (!window.__dumber_gui_ready) {
283 const eventWebViewId = detail?.webviewId;283 const eventWebViewId = detail?.webviewId;
284 const source = detail?.source || "unknown";284 const source = detail?.source || "unknown";
285 285
286+ console.log("[dumber shortcuts] Event received", {
287+ action,
288+ eventWebViewId,
289+ currentWebViewId,
290+ detail,
291+ });
292+
286 if (typeof action !== "string") {293 if (typeof action !== "string") {
294+ console.log("[dumber shortcuts] No action in event, ignoring");
287 return;295 return;
288 }296 }
289 297
M gui/src/injected/main-world.ts +8 -3
@@ -120,9 +120,14 @@ function detectWindowType(features?: string | null): string {
120120 const initialZoom = 1.0; // __DOM_ZOOM_DEFAULT__ will be replaced by Go
121121 window.__dumber_dom_zoom_seed = initialZoom;
122122
123- // Initialize WebView ID and active state (will be replaced by Go)
124- window.__dumber_webview_id = "__WEBVIEW_ID__";
125- window.__dumber_is_active = "__WEBVIEW_ACTIVE__" as unknown as boolean;
123+ // Initialize WebView ID and active state (injected by Go at document-start)
124+ // Only set fallback values if not already injected
125+ if (typeof window.__dumber_webview_id === 'undefined') {
126+ window.__dumber_webview_id = "__WEBVIEW_ID__";
127+ }
128+ if (typeof window.__dumber_is_active === 'undefined') {
129+ window.__dumber_is_active = "__WEBVIEW_ACTIVE__" as unknown as boolean;
130+ }
126131
127132 // Request color palettes from Go backend
128133 try {
@@ -120,9 +120,14 @@ function detectWindowType(features?: string | null): string {
120 const initialZoom = 1.0; // __DOM_ZOOM_DEFAULT__ will be replaced by Go120 const initialZoom = 1.0; // __DOM_ZOOM_DEFAULT__ will be replaced by Go
121 window.__dumber_dom_zoom_seed = initialZoom;121 window.__dumber_dom_zoom_seed = initialZoom;
122 122
123- // Initialize WebView ID and active state (will be replaced by Go)123+ // Initialize WebView ID and active state (injected by Go at document-start)
124- window.__dumber_webview_id = "__WEBVIEW_ID__";124+ // Only set fallback values if not already injected
125- window.__dumber_is_active = "__WEBVIEW_ACTIVE__" as unknown as boolean;125+ if (typeof window.__dumber_webview_id === 'undefined') {
126+ window.__dumber_webview_id = "__WEBVIEW_ID__";
127+ }
128+ if (typeof window.__dumber_is_active === 'undefined') {
129+ window.__dumber_is_active = "__WEBVIEW_ACTIVE__" as unknown as boolean;
130+ }
126 131
127 // Request color palettes from Go backend132 // Request color palettes from Go backend
128 try {133 try {
M internal/app/browser/browser.go +7 -2
@@ -153,8 +153,8 @@ func (app *BrowserApp) Run() {
153153 // Set config on scheme handler
154154 app.schemeHandler.SetConfig(app.config)
155155
156- // Register custom scheme resolver for "dumb://" URIs
157- webkit.SetURISchemeResolver("dumb", app.schemeHandler.Handle)
156+ // Register custom scheme resolver for "dumb://" URIs (will be applied after WebView creation)
157+ webkit.RegisterURIScheme("dumb", app.schemeHandler.Handle)
158158
159159 // Create and setup WebView
160160 if err := app.createWebView(); err != nil {
@@ -162,6 +162,11 @@ func (app *BrowserApp) Run() {
162162 return
163163 }
164164
165+ // Apply URI scheme handlers after WebView creation
166+ if err := webkit.ApplyURISchemeHandlers(app.webView.GetWebView()); err != nil {
167+ log.Printf("Warning: failed to register URI scheme handlers: %v", err)
168+ }
169+
165170 // Initialize content blocking
166171 if err := app.setupContentBlocking(); err != nil {
167172 log.Printf("Warning: failed to setup content blocking: %v", err)
@@ -153,8 +153,8 @@ func (app *BrowserApp) Run() {
153 // Set config on scheme handler153 // Set config on scheme handler
154 app.schemeHandler.SetConfig(app.config)154 app.schemeHandler.SetConfig(app.config)
155 155
156- // Register custom scheme resolver for "dumb://" URIs156+ // Register custom scheme resolver for "dumb://" URIs (will be applied after WebView creation)
157- webkit.SetURISchemeResolver("dumb", app.schemeHandler.Handle)157+ webkit.RegisterURIScheme("dumb", app.schemeHandler.Handle)
158 158
159 // Create and setup WebView159 // Create and setup WebView
160 if err := app.createWebView(); err != nil {160 if err := app.createWebView(); err != nil {
@@ -162,6 +162,11 @@ func (app *BrowserApp) Run() {
162 return162 return
163 }163 }
164 164
165+ // Apply URI scheme handlers after WebView creation
166+ if err := webkit.ApplyURISchemeHandlers(app.webView.GetWebView()); err != nil {
167+ log.Printf("Warning: failed to register URI scheme handlers: %v", err)
168+ }
169+
165 // Initialize content blocking170 // Initialize content blocking
166 if err := app.setupContentBlocking(); err != nil {171 if err := app.setupContentBlocking(); err != nil {
167 log.Printf("Warning: failed to setup content blocking: %v", err)172 log.Printf("Warning: failed to setup content blocking: %v", err)
M internal/app/browser/window_shortcuts.go +24 -0
@@ -226,6 +226,30 @@ func (h *WindowShortcutHandler) handleZoom(action string, multiplier float64) {
226226 // Apply zoom to active pane
227227 if err := activeWebView.SetZoom(newZoom); err != nil {
228228 log.Printf("[window-shortcuts] Failed to set zoom: %v", err)
229+ return
230+ }
231+
232+ // Show zoom toast notification
233+ zoomPercent := int(newZoom * 100)
234+ toastScript := fmt.Sprintf(`
235+ (function() {
236+ try {
237+ if (typeof window.__dumber_showZoomToast === 'function') {
238+ window.__dumber_showZoomToast(%f);
239+ } else {
240+ // Fallback: dispatch toast event directly
241+ document.dispatchEvent(new CustomEvent('dumber:showToast', {
242+ detail: { message: 'Zoom: %d%%', duration: 1500, type: 'info' }
243+ }));
244+ }
245+ } catch (e) {
246+ console.error('[zoom] Failed to show toast:', e);
247+ }
248+ })();
249+ `, newZoom, zoomPercent)
250+
251+ if err := activeWebView.InjectScript(toastScript); err != nil {
252+ log.Printf("[window-shortcuts] Failed to show zoom toast: %v", err)
229253 }
230254 }
231255
@@ -226,6 +226,30 @@ func (h *WindowShortcutHandler) handleZoom(action string, multiplier float64) {
226 // Apply zoom to active pane226 // Apply zoom to active pane
227 if err := activeWebView.SetZoom(newZoom); err != nil {227 if err := activeWebView.SetZoom(newZoom); err != nil {
228 log.Printf("[window-shortcuts] Failed to set zoom: %v", err)228 log.Printf("[window-shortcuts] Failed to set zoom: %v", err)
229+ return
230+ }
231+
232+ // Show zoom toast notification
233+ zoomPercent := int(newZoom * 100)
234+ toastScript := fmt.Sprintf(`
235+ (function() {
236+ try {
237+ if (typeof window.__dumber_showZoomToast === 'function') {
238+ window.__dumber_showZoomToast(%f);
239+ } else {
240+ // Fallback: dispatch toast event directly
241+ document.dispatchEvent(new CustomEvent('dumber:showToast', {
242+ detail: { message: 'Zoom: %d%%', duration: 1500, type: 'info' }
243+ }));
244+ }
245+ } catch (e) {
246+ console.error('[zoom] Failed to show toast:', e);
247+ }
248+ })();
249+ `, newZoom, zoomPercent)
250+
251+ if err := activeWebView.InjectScript(toastScript); err != nil {
252+ log.Printf("[window-shortcuts] Failed to show zoom toast: %v", err)
229 }253 }
230 }254 }
231 255
M pkg/webkit/evaluate.go +42 -8
@@ -3,42 +3,76 @@ package webkit
33 /*
44 #cgo pkg-config: webkitgtk-6.0
55 #include <webkit/webkit.h>
6+#include <stdlib.h>
67 */
78 import "C"
89 import (
910 "unsafe"
1011
12+ javascriptcore "github.com/diamondburned/gotk4-webkitgtk/pkg/javascriptcore/v6"
1113 webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
12- "github.com/diamondburned/gotk4/pkg/core/gbox"
14+ "github.com/diamondburned/gotk4/pkg/core/glib"
1315 )
1416
15-// EvaluateJavascript executes JavaScript code in the WebView
17+// EvaluateJavascript executes JavaScript code in the WebView in the default world
1618 // This is a wrapper around webkit_web_view_evaluate_javascript that wasn't generated by gotk4
1719 func EvaluateJavascript(view *webkit.WebView, script string) {
20+ EvaluateJavascriptInWorld(view, script, "")
21+}
22+
23+// EvaluateJavascriptInWorld executes JavaScript code in a specific world
24+// Pass empty string for worldName to use the main/page world
25+func EvaluateJavascriptInWorld(view *webkit.WebView, script string, worldName string) {
1826 if view == nil {
1927 return
2028 }
2129
22- // Get the native C pointer from the gotk4 WebView using gbox.Get
23- // gbox.Get expects a uintptr (the address of the Go object wrapper)
24- ptr := gbox.Get(uintptr(unsafe.Pointer(view)))
25- if ptr == nil {
30+ // Get the underlying GObject pointer from the gotk4 WebView
31+ obj := glib.BaseObject(view)
32+ if obj == nil {
33+ return
34+ }
35+
36+ nativePtr := obj.Native()
37+ if nativePtr == 0 {
2638 return
2739 }
2840
2941 cScript := C.CString(script)
3042 defer C.free(unsafe.Pointer(cScript))
3143
44+ var cWorldName *C.char
45+ if worldName != "" {
46+ cWorldName = C.CString(worldName)
47+ defer C.free(unsafe.Pointer(cWorldName))
48+ }
49+
3250 // Call webkit_web_view_evaluate_javascript
3351 // Parameters: web_view, script, length, world_name, source_uri, cancellable, callback, user_data
3452 C.webkit_web_view_evaluate_javascript(
35- (*C.WebKitWebView)(ptr.(unsafe.Pointer)),
53+ (*C.WebKitWebView)(unsafe.Pointer(nativePtr)),
3654 cScript,
3755 C.gssize(-1), // -1 means null-terminated string
38- nil, // world_name (NULL for default world)
56+ cWorldName, // world_name (NULL for main world, or specific world name)
3957 nil, // source_uri (NULL)
4058 nil, // cancellable (NULL)
4159 nil, // callback (NULL for fire-and-forget)
4260 nil, // user_data (NULL)
4361 )
4462 }
63+
64+// JSCValueToString converts a JSCValue from a GTK signal to a Go string
65+func JSCValueToString(value interface{}) string {
66+ if value == nil {
67+ return ""
68+ }
69+
70+ // Type assert to *javascriptcore.Value from gotk4 bindings
71+ jscValue, ok := value.(*javascriptcore.Value)
72+ if !ok {
73+ return ""
74+ }
75+
76+ // Use the gotk4 String() method to convert JSCValue to string
77+ return jscValue.String()
78+}
@@ -3,42 +3,76 @@ package webkit
3 /*3 /*
4 #cgo pkg-config: webkitgtk-6.04 #cgo pkg-config: webkitgtk-6.0
5 #include <webkit/webkit.h>5 #include <webkit/webkit.h>
6+#include <stdlib.h>
6 */7 */
7 import "C"8 import "C"
8 import (9 import (
9 "unsafe"10 "unsafe"
10 11
12+ javascriptcore "github.com/diamondburned/gotk4-webkitgtk/pkg/javascriptcore/v6"
11 webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"13 webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
12- "github.com/diamondburned/gotk4/pkg/core/gbox"14+ "github.com/diamondburned/gotk4/pkg/core/glib"
13 )15 )
14 16
15-// EvaluateJavascript executes JavaScript code in the WebView17+// EvaluateJavascript executes JavaScript code in the WebView in the default world
16 // This is a wrapper around webkit_web_view_evaluate_javascript that wasn't generated by gotk418 // This is a wrapper around webkit_web_view_evaluate_javascript that wasn't generated by gotk4
17 func EvaluateJavascript(view *webkit.WebView, script string) {19 func EvaluateJavascript(view *webkit.WebView, script string) {
20+ EvaluateJavascriptInWorld(view, script, "")
21+}
22+
23+// EvaluateJavascriptInWorld executes JavaScript code in a specific world
24+// Pass empty string for worldName to use the main/page world
25+func EvaluateJavascriptInWorld(view *webkit.WebView, script string, worldName string) {
18 if view == nil {26 if view == nil {
19 return27 return
20 }28 }
21 29
22- // Get the native C pointer from the gotk4 WebView using gbox.Get30+ // Get the underlying GObject pointer from the gotk4 WebView
23- // gbox.Get expects a uintptr (the address of the Go object wrapper)31+ obj := glib.BaseObject(view)
24- ptr := gbox.Get(uintptr(unsafe.Pointer(view)))32+ if obj == nil {
25- if ptr == nil {33+ return
34+ }
35+
36+ nativePtr := obj.Native()
37+ if nativePtr == 0 {
26 return38 return
27 }39 }
28 40
29 cScript := C.CString(script)41 cScript := C.CString(script)
30 defer C.free(unsafe.Pointer(cScript))42 defer C.free(unsafe.Pointer(cScript))
31 43
44+ var cWorldName *C.char
45+ if worldName != "" {
46+ cWorldName = C.CString(worldName)
47+ defer C.free(unsafe.Pointer(cWorldName))
48+ }
49+
32 // Call webkit_web_view_evaluate_javascript50 // Call webkit_web_view_evaluate_javascript
33 // Parameters: web_view, script, length, world_name, source_uri, cancellable, callback, user_data51 // Parameters: web_view, script, length, world_name, source_uri, cancellable, callback, user_data
34 C.webkit_web_view_evaluate_javascript(52 C.webkit_web_view_evaluate_javascript(
35- (*C.WebKitWebView)(ptr.(unsafe.Pointer)),53+ (*C.WebKitWebView)(unsafe.Pointer(nativePtr)),
36 cScript,54 cScript,
37 C.gssize(-1), // -1 means null-terminated string55 C.gssize(-1), // -1 means null-terminated string
38- nil, // world_name (NULL for default world)56+ cWorldName, // world_name (NULL for main world, or specific world name)
39 nil, // source_uri (NULL)57 nil, // source_uri (NULL)
40 nil, // cancellable (NULL)58 nil, // cancellable (NULL)
41 nil, // callback (NULL for fire-and-forget)59 nil, // callback (NULL for fire-and-forget)
42 nil, // user_data (NULL)60 nil, // user_data (NULL)
43 )61 )
44 }62 }
63+
64+// JSCValueToString converts a JSCValue from a GTK signal to a Go string
65+func JSCValueToString(value interface{}) string {
66+ if value == nil {
67+ return ""
68+ }
69+
70+ // Type assert to *javascriptcore.Value from gotk4 bindings
71+ jscValue, ok := value.(*javascriptcore.Value)
72+ if !ok {
73+ return ""
74+ }
75+
76+ // Use the gotk4 String() method to convert JSCValue to string
77+ return jscValue.String()
78+}
A pkg/webkit/keyboard_bridge.go +152 -0
@@ -0,0 +1,152 @@
1+package webkit
2+
3+import (
4+ "fmt"
5+ "log"
6+
7+ "github.com/diamondburned/gotk4/pkg/gdk/v4"
8+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
9+)
10+
11+// Keyboard shortcut modifier string constants
12+const (
13+ modifierCmdOrCtrl = "cmdorctrl"
14+ modifierShift = "shift"
15+ modifierAlt = "alt"
16+)
17+
18+// GDK key constants for special keys
19+const (
20+ gdkKeyLeft = 0xff51
21+ gdkKeyRight = 0xff53
22+ gdkKeyUp = 0xff52
23+ gdkKeyDown = 0xff54
24+ gdkKeyF12 = 0xffbe
25+ gdkKeyPlus = 0x2b
26+ gdkKeyEqual = 0x3d
27+ gdkKeyMinus = 0x2d
28+)
29+
30+// Key name constants for shortcut mapping
31+const (
32+ keyP = "p"
33+ keyL = "l"
34+ keyF = "f"
35+ keyC = "c"
36+ keyW = "w"
37+ keyR = "r"
38+ keyArrowLeft = "arrowleft"
39+ keyArrowRight = "arrowright"
40+ keyArrowUp = "arrowup"
41+ keyArrowDown = "arrowdown"
42+ keyF12 = "F12"
43+ keyPlus = "plus"
44+ keyMinus = "minus"
45+ keyZero = "0"
46+)
47+
48+// AttachKeyboardBridge attaches an EventControllerKey to bridge keyboard events to JavaScript
49+// This is critical for allowing JavaScript KeyboardService to receive keyboard events
50+// that would otherwise be consumed by GTK shortcuts
51+func (w *WebView) AttachKeyboardBridge() {
52+ if w == nil || w.view == nil {
53+ return
54+ }
55+
56+ // Create event controller for keyboard
57+ keyController := gtk.NewEventControllerKey()
58+
59+ // Set to capture phase so we see events before WebView processes them
60+ keyController.SetPropagationPhase(gtk.PhaseCapture)
61+
62+ // Connect to key-pressed signal
63+ keyController.ConnectKeyPressed(func(keyval, keycode uint, state gdk.ModifierType) bool {
64+ // Build normalized shortcut string
65+ var parts []string
66+
67+ ctrl := state.Has(gdk.ControlMask)
68+ alt := state.Has(gdk.AltMask)
69+ shift := state.Has(gdk.ShiftMask)
70+
71+ // Build modifier prefix using switch on modifier combination
72+ switch {
73+ case ctrl && shift:
74+ parts = append(parts, modifierCmdOrCtrl, modifierShift)
75+ case ctrl && !shift:
76+ parts = append(parts, modifierCmdOrCtrl)
77+ case alt:
78+ parts = append(parts, modifierAlt)
79+ case shift:
80+ parts = append(parts, modifierShift)
81+ default:
82+ // No modifiers - only forward if we match a key below
83+ }
84+
85+ // Map keyval to key name
86+ var keyName string
87+ switch keyval {
88+ case 'p', 'P':
89+ keyName = keyP
90+ case 'l', 'L':
91+ keyName = keyL
92+ case 'f', 'F':
93+ keyName = keyF
94+ case 'c', 'C':
95+ keyName = keyC
96+ case 'w', 'W':
97+ keyName = keyW
98+ case 'r', 'R':
99+ keyName = keyR
100+ case gdkKeyLeft:
101+ keyName = keyArrowLeft
102+ case gdkKeyRight:
103+ keyName = keyArrowRight
104+ case gdkKeyUp:
105+ keyName = keyArrowUp
106+ case gdkKeyDown:
107+ keyName = keyArrowDown
108+ case gdkKeyF12:
109+ keyName = keyF12
110+ case gdkKeyPlus, gdkKeyEqual:
111+ keyName = keyPlus
112+ case gdkKeyMinus:
113+ keyName = keyMinus
114+ case '0':
115+ keyName = keyZero
116+ default:
117+ // Only forward shortcuts with modifiers
118+ if !ctrl && !alt && !shift {
119+ return false
120+ }
121+ return false
122+ }
123+
124+ if keyName != "" {
125+ parts = append(parts, keyName)
126+ }
127+
128+ shortcut := ""
129+ if len(parts) > 0 {
130+ shortcut = parts[0]
131+ for i := 1; i < len(parts); i++ {
132+ shortcut += "+" + parts[i]
133+ }
134+ }
135+
136+ if shortcut != "" {
137+ // Dispatch custom dumber:key event to JavaScript
138+ script := fmt.Sprintf(`document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'%s'}}));`, shortcut)
139+ if err := w.InjectScript(script); err != nil {
140+ log.Printf("[keyboard-bridge] Failed to dispatch key event: %v", err)
141+ }
142+ log.Printf("[keyboard-bridge] Forwarded shortcut to JS: %s", shortcut)
143+ }
144+
145+ // Return false to allow GTK shortcuts to also handle this
146+ return false
147+ })
148+
149+ // Attach controller to WebView
150+ w.view.AddController(keyController)
151+ log.Printf("[keyboard-bridge] EventControllerKey attached to WebView ID %d", w.id)
152+}
@@ -0,0 +1,152 @@
1+package webkit
2+
3+import (
4+ "fmt"
5+ "log"
6+
7+ "github.com/diamondburned/gotk4/pkg/gdk/v4"
8+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
9+)
10+
11+// Keyboard shortcut modifier string constants
12+const (
13+ modifierCmdOrCtrl = "cmdorctrl"
14+ modifierShift = "shift"
15+ modifierAlt = "alt"
16+)
17+
18+// GDK key constants for special keys
19+const (
20+ gdkKeyLeft = 0xff51
21+ gdkKeyRight = 0xff53
22+ gdkKeyUp = 0xff52
23+ gdkKeyDown = 0xff54
24+ gdkKeyF12 = 0xffbe
25+ gdkKeyPlus = 0x2b
26+ gdkKeyEqual = 0x3d
27+ gdkKeyMinus = 0x2d
28+)
29+
30+// Key name constants for shortcut mapping
31+const (
32+ keyP = "p"
33+ keyL = "l"
34+ keyF = "f"
35+ keyC = "c"
36+ keyW = "w"
37+ keyR = "r"
38+ keyArrowLeft = "arrowleft"
39+ keyArrowRight = "arrowright"
40+ keyArrowUp = "arrowup"
41+ keyArrowDown = "arrowdown"
42+ keyF12 = "F12"
43+ keyPlus = "plus"
44+ keyMinus = "minus"
45+ keyZero = "0"
46+)
47+
48+// AttachKeyboardBridge attaches an EventControllerKey to bridge keyboard events to JavaScript
49+// This is critical for allowing JavaScript KeyboardService to receive keyboard events
50+// that would otherwise be consumed by GTK shortcuts
51+func (w *WebView) AttachKeyboardBridge() {
52+ if w == nil || w.view == nil {
53+ return
54+ }
55+
56+ // Create event controller for keyboard
57+ keyController := gtk.NewEventControllerKey()
58+
59+ // Set to capture phase so we see events before WebView processes them
60+ keyController.SetPropagationPhase(gtk.PhaseCapture)
61+
62+ // Connect to key-pressed signal
63+ keyController.ConnectKeyPressed(func(keyval, keycode uint, state gdk.ModifierType) bool {
64+ // Build normalized shortcut string
65+ var parts []string
66+
67+ ctrl := state.Has(gdk.ControlMask)
68+ alt := state.Has(gdk.AltMask)
69+ shift := state.Has(gdk.ShiftMask)
70+
71+ // Build modifier prefix using switch on modifier combination
72+ switch {
73+ case ctrl && shift:
74+ parts = append(parts, modifierCmdOrCtrl, modifierShift)
75+ case ctrl && !shift:
76+ parts = append(parts, modifierCmdOrCtrl)
77+ case alt:
78+ parts = append(parts, modifierAlt)
79+ case shift:
80+ parts = append(parts, modifierShift)
81+ default:
82+ // No modifiers - only forward if we match a key below
83+ }
84+
85+ // Map keyval to key name
86+ var keyName string
87+ switch keyval {
88+ case 'p', 'P':
89+ keyName = keyP
90+ case 'l', 'L':
91+ keyName = keyL
92+ case 'f', 'F':
93+ keyName = keyF
94+ case 'c', 'C':
95+ keyName = keyC
96+ case 'w', 'W':
97+ keyName = keyW
98+ case 'r', 'R':
99+ keyName = keyR
100+ case gdkKeyLeft:
101+ keyName = keyArrowLeft
102+ case gdkKeyRight:
103+ keyName = keyArrowRight
104+ case gdkKeyUp:
105+ keyName = keyArrowUp
106+ case gdkKeyDown:
107+ keyName = keyArrowDown
108+ case gdkKeyF12:
109+ keyName = keyF12
110+ case gdkKeyPlus, gdkKeyEqual:
111+ keyName = keyPlus
112+ case gdkKeyMinus:
113+ keyName = keyMinus
114+ case '0':
115+ keyName = keyZero
116+ default:
117+ // Only forward shortcuts with modifiers
118+ if !ctrl && !alt && !shift {
119+ return false
120+ }
121+ return false
122+ }
123+
124+ if keyName != "" {
125+ parts = append(parts, keyName)
126+ }
127+
128+ shortcut := ""
129+ if len(parts) > 0 {
130+ shortcut = parts[0]
131+ for i := 1; i < len(parts); i++ {
132+ shortcut += "+" + parts[i]
133+ }
134+ }
135+
136+ if shortcut != "" {
137+ // Dispatch custom dumber:key event to JavaScript
138+ script := fmt.Sprintf(`document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'%s'}}));`, shortcut)
139+ if err := w.InjectScript(script); err != nil {
140+ log.Printf("[keyboard-bridge] Failed to dispatch key event: %v", err)
141+ }
142+ log.Printf("[keyboard-bridge] Forwarded shortcut to JS: %s", shortcut)
143+ }
144+
145+ // Return false to allow GTK shortcuts to also handle this
146+ return false
147+ })
148+
149+ // Attach controller to WebView
150+ w.view.AddController(keyController)
151+ log.Printf("[keyboard-bridge] EventControllerKey attached to WebView ID %d", w.id)
152+}
M pkg/webkit/mainloop.go +7 -2
@@ -4,6 +4,7 @@ import (
44 "runtime"
55
66 "github.com/diamondburned/gotk4/pkg/glib/v2"
7+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
78 )
89
910 var (
@@ -12,11 +13,15 @@ var (
1213 isInitialized bool
1314 )
1415
15-// InitMainThread locks the current goroutine to the OS thread for GTK operations.
16-// This must be called before any GTK operations.
16+// InitMainThread locks the current goroutine to the OS thread for GTK operations
17+// and initializes GTK. This must be called before any GTK operations.
1718 func InitMainThread() {
1819 if !isInitialized {
1920 runtime.LockOSThread()
21+
22+ // Initialize GTK - this is required before creating any GTK widgets
23+ gtk.Init()
24+
2025 isInitialized = true
2126 }
2227 }
@@ -4,6 +4,7 @@ import (
4 "runtime"4 "runtime"
5 5
6 "github.com/diamondburned/gotk4/pkg/glib/v2"6 "github.com/diamondburned/gotk4/pkg/glib/v2"
7+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
7 )8 )
8 9
9 var (10 var (
@@ -12,11 +13,15 @@ var (
12 isInitialized bool13 isInitialized bool
13 )14 )
14 15
15-// InitMainThread locks the current goroutine to the OS thread for GTK operations.16+// InitMainThread locks the current goroutine to the OS thread for GTK operations
16-// This must be called before any GTK operations.17+// and initializes GTK. This must be called before any GTK operations.
17 func InitMainThread() {18 func InitMainThread() {
18 if !isInitialized {19 if !isInitialized {
19 runtime.LockOSThread()20 runtime.LockOSThread()
21+
22+ // Initialize GTK - this is required before creating any GTK widgets
23+ gtk.Init()
24+
20 isInitialized = true25 isInitialized = true
21 }26 }
22 }27 }
M pkg/webkit/shortcuts.go +120 -7
@@ -1,21 +1,37 @@
11 package webkit
22
33 import (
4+ "log"
5+ "strings"
6+
47 "github.com/diamondburned/gotk4/pkg/gdk/v4"
8+ "github.com/diamondburned/gotk4/pkg/glib/v2"
59 "github.com/diamondburned/gotk4/pkg/gtk/v4"
610 )
711
812 // WindowShortcuts manages keyboard shortcuts for a window
913 type WindowShortcuts struct {
10- window *Window
11- shortcuts map[string]*gtk.ShortcutAction
14+ window *Window
15+ shortcuts map[string]*gtk.Shortcut
16+ controller *gtk.ShortcutController
1217 }
1318
1419 // NewWindowShortcuts creates a new WindowShortcuts manager
1520 func NewWindowShortcuts(win *Window) *WindowShortcuts {
21+ controller := gtk.NewShortcutController()
22+
23+ // Set scope to GLOBAL so shortcuts work at the window level
24+ // and aren't captured by child widgets (like the WebView)
25+ controller.SetScope(gtk.ShortcutScopeGlobal)
26+
27+ if win != nil && win.win != nil {
28+ win.win.AddController(controller)
29+ }
30+
1631 return &WindowShortcuts{
17- window: win,
18- shortcuts: make(map[string]*gtk.ShortcutAction),
32+ window: win,
33+ shortcuts: make(map[string]*gtk.Shortcut),
34+ controller: controller,
1935 }
2036 }
2137
@@ -39,10 +55,107 @@ func (w *Window) InitializeGlobalShortcuts(shortcuts map[string]func()) {
3955
4056 // RegisterShortcut registers a keyboard shortcut
4157 func (ws *WindowShortcuts) RegisterShortcut(accelerator string, handler func()) {
42- if ws == nil {
58+ if ws == nil || ws.controller == nil {
4359 return
4460 }
4561
46- // TODO: Implement proper shortcut registration using GTK4's shortcut system
47- // For now, this is a placeholder
62+ // Convert to GTK format (e.g., "ctrl+plus" -> "<Control>plus")
63+ gtkFormat := convertToGtkFormat(accelerator)
64+ if gtkFormat == "" {
65+ log.Printf("[shortcuts] Invalid key format: %s", accelerator)
66+ return
67+ }
68+
69+ // Create a callback action that will trigger our handler
70+ // ShortcutFunc signature: func(widget Widgetter, args *glib.Variant) (ok bool)
71+ action := gtk.NewCallbackAction(func(widget gtk.Widgetter, args *glib.Variant) bool {
72+ if handler != nil {
73+ log.Printf("[shortcuts] Shortcut triggered: %s", accelerator)
74+ handler()
75+ }
76+ // Return false to allow event propagation to WebView
77+ // This lets JavaScript KeyboardService also handle events if needed
78+ return false
79+ })
80+
81+ // Parse the accelerator string into a GDK key trigger
82+ // GTK4 uses gtk.ShortcutTrigger for key combinations
83+ trigger := gtk.NewShortcutTriggerParseString(gtkFormat)
84+ if trigger == nil {
85+ log.Printf("[shortcuts] Failed to parse trigger: %s", gtkFormat)
86+ return
87+ }
88+
89+ // Create the shortcut with trigger and action
90+ shortcut := gtk.NewShortcut(trigger, action)
91+
92+ // Add shortcut to the shared controller
93+ ws.controller.AddShortcut(shortcut)
94+
95+ // Store the shortcut for potential cleanup
96+ ws.shortcuts[accelerator] = shortcut
97+
98+ log.Printf("[shortcuts] Registered: %s -> %s", accelerator, gtkFormat)
99+}
100+
101+// convertToGtkFormat converts common key formats to GTK shortcut format
102+// Examples: "ctrl+plus" -> "<Control>plus", "ctrl+l" -> "<Control>l"
103+func convertToGtkFormat(key string) string {
104+ key = strings.ToLower(key)
105+
106+ // Handle cmdorctrl -> Control
107+ key = strings.ReplaceAll(key, "cmdorctrl+", "ctrl+")
108+ key = strings.ReplaceAll(key, "cmd+", "ctrl+")
109+
110+ // Split by + to get components
111+ components := strings.Split(key, "+")
112+ if len(components) == 0 {
113+ return ""
114+ }
115+
116+ var parts []string
117+ for i, part := range components {
118+ if i == len(components)-1 {
119+ // Last part is the actual key
120+ switch part {
121+ case "f12":
122+ parts = append(parts, "F12")
123+ case "f5":
124+ parts = append(parts, "F5")
125+ case "plus":
126+ parts = append(parts, "plus")
127+ case "equal":
128+ parts = append(parts, "equal")
129+ case "minus":
130+ parts = append(parts, "minus")
131+ case "0":
132+ parts = append(parts, "0")
133+ case "left", "arrowleft":
134+ parts = append(parts, "Left")
135+ case "right", "arrowright":
136+ parts = append(parts, "Right")
137+ case "up", "arrowup":
138+ parts = append(parts, "Up")
139+ case "down", "arrowdown":
140+ parts = append(parts, "Down")
141+ default:
142+ // Single letter keys like l, f, c, w, etc.
143+ parts = append(parts, part)
144+ }
145+ } else {
146+ // Modifier keys
147+ switch part {
148+ case "ctrl":
149+ parts = append(parts, "<Control>")
150+ case "shift":
151+ parts = append(parts, "<Shift>")
152+ case "alt":
153+ parts = append(parts, "<Alt>")
154+ case "super", "meta":
155+ parts = append(parts, "<Super>")
156+ }
157+ }
158+ }
159+
160+ return strings.Join(parts, "")
48161 }
@@ -1,21 +1,37 @@
1 package webkit1 package webkit
2 2
3 import (3 import (
4+ "log"
5+ "strings"
6+
4 "github.com/diamondburned/gotk4/pkg/gdk/v4"7 "github.com/diamondburned/gotk4/pkg/gdk/v4"
8+ "github.com/diamondburned/gotk4/pkg/glib/v2"
5 "github.com/diamondburned/gotk4/pkg/gtk/v4"9 "github.com/diamondburned/gotk4/pkg/gtk/v4"
6 )10 )
7 11
8 // WindowShortcuts manages keyboard shortcuts for a window12 // WindowShortcuts manages keyboard shortcuts for a window
9 type WindowShortcuts struct {13 type WindowShortcuts struct {
10- window *Window14+ window *Window
11- shortcuts map[string]*gtk.ShortcutAction15+ shortcuts map[string]*gtk.Shortcut
16+ controller *gtk.ShortcutController
12 }17 }
13 18
14 // NewWindowShortcuts creates a new WindowShortcuts manager19 // NewWindowShortcuts creates a new WindowShortcuts manager
15 func NewWindowShortcuts(win *Window) *WindowShortcuts {20 func NewWindowShortcuts(win *Window) *WindowShortcuts {
21+ controller := gtk.NewShortcutController()
22+
23+ // Set scope to GLOBAL so shortcuts work at the window level
24+ // and aren't captured by child widgets (like the WebView)
25+ controller.SetScope(gtk.ShortcutScopeGlobal)
26+
27+ if win != nil && win.win != nil {
28+ win.win.AddController(controller)
29+ }
30+
16 return &WindowShortcuts{31 return &WindowShortcuts{
17- window: win,32+ window: win,
18- shortcuts: make(map[string]*gtk.ShortcutAction),33+ shortcuts: make(map[string]*gtk.Shortcut),
34+ controller: controller,
19 }35 }
20 }36 }
21 37
@@ -39,10 +55,107 @@ func (w *Window) InitializeGlobalShortcuts(shortcuts map[string]func()) {
39 55
40 // RegisterShortcut registers a keyboard shortcut56 // RegisterShortcut registers a keyboard shortcut
41 func (ws *WindowShortcuts) RegisterShortcut(accelerator string, handler func()) {57 func (ws *WindowShortcuts) RegisterShortcut(accelerator string, handler func()) {
42- if ws == nil {58+ if ws == nil || ws.controller == nil {
43 return59 return
44 }60 }
45 61
46- // TODO: Implement proper shortcut registration using GTK4's shortcut system62+ // Convert to GTK format (e.g., "ctrl+plus" -> "<Control>plus")
47- // For now, this is a placeholder63+ gtkFormat := convertToGtkFormat(accelerator)
64+ if gtkFormat == "" {
65+ log.Printf("[shortcuts] Invalid key format: %s", accelerator)
66+ return
67+ }
68+
69+ // Create a callback action that will trigger our handler
70+ // ShortcutFunc signature: func(widget Widgetter, args *glib.Variant) (ok bool)
71+ action := gtk.NewCallbackAction(func(widget gtk.Widgetter, args *glib.Variant) bool {
72+ if handler != nil {
73+ log.Printf("[shortcuts] Shortcut triggered: %s", accelerator)
74+ handler()
75+ }
76+ // Return false to allow event propagation to WebView
77+ // This lets JavaScript KeyboardService also handle events if needed
78+ return false
79+ })
80+
81+ // Parse the accelerator string into a GDK key trigger
82+ // GTK4 uses gtk.ShortcutTrigger for key combinations
83+ trigger := gtk.NewShortcutTriggerParseString(gtkFormat)
84+ if trigger == nil {
85+ log.Printf("[shortcuts] Failed to parse trigger: %s", gtkFormat)
86+ return
87+ }
88+
89+ // Create the shortcut with trigger and action
90+ shortcut := gtk.NewShortcut(trigger, action)
91+
92+ // Add shortcut to the shared controller
93+ ws.controller.AddShortcut(shortcut)
94+
95+ // Store the shortcut for potential cleanup
96+ ws.shortcuts[accelerator] = shortcut
97+
98+ log.Printf("[shortcuts] Registered: %s -> %s", accelerator, gtkFormat)
99+}
100+
101+// convertToGtkFormat converts common key formats to GTK shortcut format
102+// Examples: "ctrl+plus" -> "<Control>plus", "ctrl+l" -> "<Control>l"
103+func convertToGtkFormat(key string) string {
104+ key = strings.ToLower(key)
105+
106+ // Handle cmdorctrl -> Control
107+ key = strings.ReplaceAll(key, "cmdorctrl+", "ctrl+")
108+ key = strings.ReplaceAll(key, "cmd+", "ctrl+")
109+
110+ // Split by + to get components
111+ components := strings.Split(key, "+")
112+ if len(components) == 0 {
113+ return ""
114+ }
115+
116+ var parts []string
117+ for i, part := range components {
118+ if i == len(components)-1 {
119+ // Last part is the actual key
120+ switch part {
121+ case "f12":
122+ parts = append(parts, "F12")
123+ case "f5":
124+ parts = append(parts, "F5")
125+ case "plus":
126+ parts = append(parts, "plus")
127+ case "equal":
128+ parts = append(parts, "equal")
129+ case "minus":
130+ parts = append(parts, "minus")
131+ case "0":
132+ parts = append(parts, "0")
133+ case "left", "arrowleft":
134+ parts = append(parts, "Left")
135+ case "right", "arrowright":
136+ parts = append(parts, "Right")
137+ case "up", "arrowup":
138+ parts = append(parts, "Up")
139+ case "down", "arrowdown":
140+ parts = append(parts, "Down")
141+ default:
142+ // Single letter keys like l, f, c, w, etc.
143+ parts = append(parts, part)
144+ }
145+ } else {
146+ // Modifier keys
147+ switch part {
148+ case "ctrl":
149+ parts = append(parts, "<Control>")
150+ case "shift":
151+ parts = append(parts, "<Shift>")
152+ case "alt":
153+ parts = append(parts, "<Alt>")
154+ case "super", "meta":
155+ parts = append(parts, "<Super>")
156+ }
157+ }
158+ }
159+
160+ return strings.Join(parts, "")
48 }161 }
M pkg/webkit/uri_scheme.go +47 -11
@@ -1,26 +1,62 @@
11 package webkit
22
33 import (
4+ "fmt"
5+ "log"
6+
47 webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
58 )
69
710 // URISchemeRequestCallback is called when a custom URI scheme is requested
811 type URISchemeRequestCallback func(request *webkit.URISchemeRequest)
912
10-// SetURISchemeResolver registers a custom URI scheme handler
11-func SetURISchemeResolver(scheme string, callback URISchemeRequestCallback) {
12- // Get the default web context
13- ctx := webkit.WebContextGetDefault()
13+// URISchemeHandler stores registered URI scheme handlers
14+type URISchemeHandler struct {
15+ scheme string
16+ callback URISchemeRequestCallback
17+}
18+
19+var (
20+ // pendingURISchemeHandlers stores handlers to be registered before WebView creation
21+ pendingURISchemeHandlers []*URISchemeHandler
22+)
23+
24+// RegisterURIScheme registers a custom URI scheme handler
25+// In WebKitGTK 6.0, this must be called BEFORE creating any WebViews
26+// The actual registration happens in InitPersistentSession
27+func RegisterURIScheme(scheme string, callback URISchemeRequestCallback) {
28+ log.Printf("[webkit] Registering URI scheme handler for: %s", scheme)
29+ pendingURISchemeHandlers = append(pendingURISchemeHandlers, &URISchemeHandler{
30+ scheme: scheme,
31+ callback: callback,
32+ })
33+}
34+
35+// ApplyURISchemeHandlers registers all pending URI schemes on the WebContext
36+// This should be called after creating the first WebView
37+func ApplyURISchemeHandlers(view *webkit.WebView) error {
38+ if view == nil {
39+ return fmt.Errorf("webview is nil")
40+ }
41+
42+ ctx := view.Context()
1443 if ctx == nil {
15- return
44+ return fmt.Errorf("webcontext is nil")
1645 }
1746
18- // Register the URI scheme
19- ctx.RegisterURIScheme(scheme, func(req *webkit.URISchemeRequest) {
20- if callback != nil {
21- callback(req)
22- }
23- })
47+ for _, handler := range pendingURISchemeHandlers {
48+ log.Printf("[webkit] Registering URI scheme on WebContext: %s", handler.scheme)
49+
50+ // Capture handler in closure
51+ callback := handler.callback
52+ ctx.RegisterURIScheme(handler.scheme, func(req *webkit.URISchemeRequest) {
53+ if callback != nil {
54+ callback(req)
55+ }
56+ })
57+ }
58+
59+ return nil
2460 }
2561
2662 // FinishURISchemeRequest finishes a URI scheme request with data
@@ -1,26 +1,62 @@
1 package webkit1 package webkit
2 2
3 import (3 import (
4+ "fmt"
5+ "log"
6+
4 webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"7 webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
5 )8 )
6 9
7 // URISchemeRequestCallback is called when a custom URI scheme is requested10 // URISchemeRequestCallback is called when a custom URI scheme is requested
8 type URISchemeRequestCallback func(request *webkit.URISchemeRequest)11 type URISchemeRequestCallback func(request *webkit.URISchemeRequest)
9 12
10-// SetURISchemeResolver registers a custom URI scheme handler13+// URISchemeHandler stores registered URI scheme handlers
11-func SetURISchemeResolver(scheme string, callback URISchemeRequestCallback) {14+type URISchemeHandler struct {
12- // Get the default web context15+ scheme string
13- ctx := webkit.WebContextGetDefault()16+ callback URISchemeRequestCallback
17+}
18+
19+var (
20+ // pendingURISchemeHandlers stores handlers to be registered before WebView creation
21+ pendingURISchemeHandlers []*URISchemeHandler
22+)
23+
24+// RegisterURIScheme registers a custom URI scheme handler
25+// In WebKitGTK 6.0, this must be called BEFORE creating any WebViews
26+// The actual registration happens in InitPersistentSession
27+func RegisterURIScheme(scheme string, callback URISchemeRequestCallback) {
28+ log.Printf("[webkit] Registering URI scheme handler for: %s", scheme)
29+ pendingURISchemeHandlers = append(pendingURISchemeHandlers, &URISchemeHandler{
30+ scheme: scheme,
31+ callback: callback,
32+ })
33+}
34+
35+// ApplyURISchemeHandlers registers all pending URI schemes on the WebContext
36+// This should be called after creating the first WebView
37+func ApplyURISchemeHandlers(view *webkit.WebView) error {
38+ if view == nil {
39+ return fmt.Errorf("webview is nil")
40+ }
41+
42+ ctx := view.Context()
14 if ctx == nil {43 if ctx == nil {
15- return44+ return fmt.Errorf("webcontext is nil")
16 }45 }
17 46
18- // Register the URI scheme47+ for _, handler := range pendingURISchemeHandlers {
19- ctx.RegisterURIScheme(scheme, func(req *webkit.URISchemeRequest) {48+ log.Printf("[webkit] Registering URI scheme on WebContext: %s", handler.scheme)
20- if callback != nil {49+
21- callback(req)50+ // Capture handler in closure
22- }51+ callback := handler.callback
23- })52+ ctx.RegisterURIScheme(handler.scheme, func(req *webkit.URISchemeRequest) {
53+ if callback != nil {
54+ callback(req)
55+ }
56+ })
57+ }
58+
59+ return nil
24 }60 }
25 61
26 // FinishURISchemeRequest finishes a URI scheme request with data62 // FinishURISchemeRequest finishes a URI scheme request with data
M pkg/webkit/user_content.go +16 -2
@@ -10,7 +10,7 @@ import (
1010
1111 // SetupUserContentManager configures UserContentManager for the WebView
1212 // This injects GUI scripts at document-start and registers message handlers
13-func SetupUserContentManager(view *webkit.WebView, appearanceConfigJSON string) error {
13+func SetupUserContentManager(view *webkit.WebView, appearanceConfigJSON string, webviewID uint64) error {
1414 if view == nil {
1515 return nil
1616 }
@@ -22,7 +22,21 @@ func SetupUserContentManager(view *webkit.WebView, appearanceConfigJSON string)
2222 return nil
2323 }
2424
25- // Inject GTK theme detection FIRST, before color-scheme script
25+ // Inject webview ID FIRST, so GUI scripts can access it immediately
26+ webviewIDScript := fmt.Sprintf(`
27+ window.__dumber_webview_id = "%d";
28+ console.log('[webkit] WebView ID set in JavaScript:', window.__dumber_webview_id);
29+ `, webviewID)
30+ ucm.AddScript(webkit.NewUserScript(
31+ webviewIDScript,
32+ webkit.UserContentInjectAllFrames,
33+ webkit.UserScriptInjectAtDocumentStart,
34+ nil,
35+ nil,
36+ ))
37+ log.Printf("[webkit] Injected webview ID script for ID: %d", webviewID)
38+
39+ // Inject GTK theme detection SECOND, before color-scheme script
2640 // The color-scheme.ts expects window.__dumber_gtk_prefers_dark to be set
2741 prefersDark := PrefersDarkTheme()
2842 gtkThemeScript := fmt.Sprintf(`window.__dumber_gtk_prefers_dark = %t;`, prefersDark)
@@ -10,7 +10,7 @@ import (
10 10
11 // SetupUserContentManager configures UserContentManager for the WebView11 // SetupUserContentManager configures UserContentManager for the WebView
12 // This injects GUI scripts at document-start and registers message handlers12 // This injects GUI scripts at document-start and registers message handlers
13-func SetupUserContentManager(view *webkit.WebView, appearanceConfigJSON string) error {13+func SetupUserContentManager(view *webkit.WebView, appearanceConfigJSON string, webviewID uint64) error {
14 if view == nil {14 if view == nil {
15 return nil15 return nil
16 }16 }
@@ -22,7 +22,21 @@ func SetupUserContentManager(view *webkit.WebView, appearanceConfigJSON string)
22 return nil22 return nil
23 }23 }
24 24
25- // Inject GTK theme detection FIRST, before color-scheme script25+ // Inject webview ID FIRST, so GUI scripts can access it immediately
26+ webviewIDScript := fmt.Sprintf(`
27+ window.__dumber_webview_id = "%d";
28+ console.log('[webkit] WebView ID set in JavaScript:', window.__dumber_webview_id);
29+ `, webviewID)
30+ ucm.AddScript(webkit.NewUserScript(
31+ webviewIDScript,
32+ webkit.UserContentInjectAllFrames,
33+ webkit.UserScriptInjectAtDocumentStart,
34+ nil,
35+ nil,
36+ ))
37+ log.Printf("[webkit] Injected webview ID script for ID: %d", webviewID)
38+
39+ // Inject GTK theme detection SECOND, before color-scheme script
26 // The color-scheme.ts expects window.__dumber_gtk_prefers_dark to be set40 // The color-scheme.ts expects window.__dumber_gtk_prefers_dark to be set
27 prefersDark := PrefersDarkTheme()41 prefersDark := PrefersDarkTheme()
28 gtkThemeScript := fmt.Sprintf(`window.__dumber_gtk_prefers_dark = %t;`, prefersDark)42 gtkThemeScript := fmt.Sprintf(`window.__dumber_gtk_prefers_dark = %t;`, prefersDark)
M pkg/webkit/webview.go +48 -8
@@ -1,7 +1,9 @@
11 package webkit
22
33 import (
4+ "encoding/json"
45 "fmt"
6+ "log"
57 "strconv"
68 "sync"
79 "sync/atomic"
@@ -46,6 +48,10 @@ func NewWebView(cfg *Config) (*WebView, error) {
4648 cfg = GetDefaultConfig()
4749 }
4850
51+ // Generate unique ID immediately
52+ id := atomic.AddUint64(&viewIDCounter, 1)
53+ log.Printf("[webkit] Generated WebView ID: %d", id)
54+
4955 InitMainThread()
5056
5157 // Create GTK Window
@@ -91,15 +97,12 @@ func NewWebView(cfg *Config) (*WebView, error) {
9197 cookieManager.SetPersistentStorage(cookiePath, webkit.CookiePersistentStorageSqlite)
9298 cookieManager.SetAcceptPolicy(webkit.CookiePolicyAcceptNoThirdParty)
9399
94- // Setup UserContentManager and inject GUI scripts
100+ // Setup UserContentManager and inject GUI scripts (including webview ID)
95101 // This must be done BEFORE any pages are loaded
96- if err := SetupUserContentManager(wkView, cfg.AppearanceConfigJSON); err != nil {
102+ if err := SetupUserContentManager(wkView, cfg.AppearanceConfigJSON, id); err != nil {
97103 return nil, fmt.Errorf("failed to setup user content manager: %w", err)
98104 }
99105
100- // Generate unique ID
101- id := atomic.AddUint64(&viewIDCounter, 1)
102-
103106 wv := &WebView{
104107 view: wkView,
105108 window: window,
@@ -115,6 +118,9 @@ func NewWebView(cfg *Config) (*WebView, error) {
115118 // Setup event handlers
116119 wv.setupEventHandlers()
117120
121+ // Attach keyboard bridge to forward keyboard events to JavaScript
122+ wv.AttachKeyboardBridge()
123+
118124 // Add WebView as child of window
119125 window.SetChild(wkView)
120126
@@ -193,6 +199,23 @@ func (w *WebView) setupEventHandlers() {
193199 w.onClose()
194200 }
195201 })
202+
203+ // Script message received - connect to UserContentManager's script-message-received signal
204+ // This receives messages from JavaScript via webkit.messageHandlers.dumber.postMessage()
205+ // The signal name includes the handler name as a detail: "script-message-received::dumber"
206+ ucm := w.view.UserContentManager()
207+ if ucm != nil {
208+ // GTK signals pass the sender as the first parameter, the actual signal data as subsequent parameters
209+ ucm.Connect("script-message-received::dumber", func(sender interface{}, jscValue interface{}) {
210+ if w.onScriptMessage != nil && jscValue != nil {
211+ // Convert JSCValue to string using gotk4 javascriptcore bindings
212+ valueStr := JSCValueToString(jscValue)
213+ if valueStr != "" {
214+ w.onScriptMessage(valueStr)
215+ }
216+ }
217+ })
218+ }
196219 }
197220
198221 // LoadURL loads the given URL in the WebView
@@ -524,10 +547,27 @@ func (w *WebView) InjectScript(script string) error {
524547
525548 // DispatchCustomEvent dispatches a custom event via JavaScript
526549 func (w *WebView) DispatchCustomEvent(eventName string, data interface{}) error {
527- // TODO: Implement proper event dispatching with data serialization
550+ // Serialize data to JSON
551+ jsonData, err := json.Marshal(data)
552+ if err != nil {
553+ return fmt.Errorf("failed to marshal event data: %w", err)
554+ }
555+
556+ log.Printf("[webkit] Dispatching event '%s' to WebView ID %d with data: %s", eventName, w.id, string(jsonData))
557+
558+ // Use document.dispatchEvent (not window) so events cross JavaScript world boundaries
559+ // The GUI scripts run in an isolated world but share the same Document object
528560 script := fmt.Sprintf(`
529- window.dispatchEvent(new CustomEvent('%s', { detail: %v }));
530- `, eventName, data)
561+ (function() {
562+ try {
563+ var detail = %s;
564+ console.log('[dumber] Dispatching event: %s', detail);
565+ document.dispatchEvent(new CustomEvent('%s', { detail: detail }));
566+ } catch (e) {
567+ console.error('[dumber] Failed to dispatch %s', e);
568+ }
569+ })();
570+ `, string(jsonData), eventName, eventName, eventName)
531571 return w.InjectScript(script)
532572 }
533573
@@ -1,7 +1,9 @@
1 package webkit1 package webkit
2 2
3 import (3 import (
4+ "encoding/json"
4 "fmt"5 "fmt"
6+ "log"
5 "strconv"7 "strconv"
6 "sync"8 "sync"
7 "sync/atomic"9 "sync/atomic"
@@ -46,6 +48,10 @@ func NewWebView(cfg *Config) (*WebView, error) {
46 cfg = GetDefaultConfig()48 cfg = GetDefaultConfig()
47 }49 }
48 50
51+ // Generate unique ID immediately
52+ id := atomic.AddUint64(&viewIDCounter, 1)
53+ log.Printf("[webkit] Generated WebView ID: %d", id)
54+
49 InitMainThread()55 InitMainThread()
50 56
51 // Create GTK Window57 // Create GTK Window
@@ -91,15 +97,12 @@ func NewWebView(cfg *Config) (*WebView, error) {
91 cookieManager.SetPersistentStorage(cookiePath, webkit.CookiePersistentStorageSqlite)97 cookieManager.SetPersistentStorage(cookiePath, webkit.CookiePersistentStorageSqlite)
92 cookieManager.SetAcceptPolicy(webkit.CookiePolicyAcceptNoThirdParty)98 cookieManager.SetAcceptPolicy(webkit.CookiePolicyAcceptNoThirdParty)
93 99
94- // Setup UserContentManager and inject GUI scripts100+ // Setup UserContentManager and inject GUI scripts (including webview ID)
95 // This must be done BEFORE any pages are loaded101 // This must be done BEFORE any pages are loaded
96- if err := SetupUserContentManager(wkView, cfg.AppearanceConfigJSON); err != nil {102+ if err := SetupUserContentManager(wkView, cfg.AppearanceConfigJSON, id); err != nil {
97 return nil, fmt.Errorf("failed to setup user content manager: %w", err)103 return nil, fmt.Errorf("failed to setup user content manager: %w", err)
98 }104 }
99 105
100- // Generate unique ID
101- id := atomic.AddUint64(&viewIDCounter, 1)
102-
103 wv := &WebView{106 wv := &WebView{
104 view: wkView,107 view: wkView,
105 window: window,108 window: window,
@@ -115,6 +118,9 @@ func NewWebView(cfg *Config) (*WebView, error) {
115 // Setup event handlers118 // Setup event handlers
116 wv.setupEventHandlers()119 wv.setupEventHandlers()
117 120
121+ // Attach keyboard bridge to forward keyboard events to JavaScript
122+ wv.AttachKeyboardBridge()
123+
118 // Add WebView as child of window124 // Add WebView as child of window
119 window.SetChild(wkView)125 window.SetChild(wkView)
120 126
@@ -193,6 +199,23 @@ func (w *WebView) setupEventHandlers() {
193 w.onClose()199 w.onClose()
194 }200 }
195 })201 })
202+
203+ // Script message received - connect to UserContentManager's script-message-received signal
204+ // This receives messages from JavaScript via webkit.messageHandlers.dumber.postMessage()
205+ // The signal name includes the handler name as a detail: "script-message-received::dumber"
206+ ucm := w.view.UserContentManager()
207+ if ucm != nil {
208+ // GTK signals pass the sender as the first parameter, the actual signal data as subsequent parameters
209+ ucm.Connect("script-message-received::dumber", func(sender interface{}, jscValue interface{}) {
210+ if w.onScriptMessage != nil && jscValue != nil {
211+ // Convert JSCValue to string using gotk4 javascriptcore bindings
212+ valueStr := JSCValueToString(jscValue)
213+ if valueStr != "" {
214+ w.onScriptMessage(valueStr)
215+ }
216+ }
217+ })
218+ }
196 }219 }
197 220
198 // LoadURL loads the given URL in the WebView221 // LoadURL loads the given URL in the WebView
@@ -524,10 +547,27 @@ func (w *WebView) InjectScript(script string) error {
524 547
525 // DispatchCustomEvent dispatches a custom event via JavaScript548 // DispatchCustomEvent dispatches a custom event via JavaScript
526 func (w *WebView) DispatchCustomEvent(eventName string, data interface{}) error {549 func (w *WebView) DispatchCustomEvent(eventName string, data interface{}) error {
527- // TODO: Implement proper event dispatching with data serialization550+ // Serialize data to JSON
551+ jsonData, err := json.Marshal(data)
552+ if err != nil {
553+ return fmt.Errorf("failed to marshal event data: %w", err)
554+ }
555+
556+ log.Printf("[webkit] Dispatching event '%s' to WebView ID %d with data: %s", eventName, w.id, string(jsonData))
557+
558+ // Use document.dispatchEvent (not window) so events cross JavaScript world boundaries
559+ // The GUI scripts run in an isolated world but share the same Document object
528 script := fmt.Sprintf(`560 script := fmt.Sprintf(`
529- window.dispatchEvent(new CustomEvent('%s', { detail: %v }));561+ (function() {
530- `, eventName, data)562+ try {
563+ var detail = %s;
564+ console.log('[dumber] Dispatching event: %s', detail);
565+ document.dispatchEvent(new CustomEvent('%s', { detail: detail }));
566+ } catch (e) {
567+ console.error('[dumber] Failed to dispatch %s', e);
568+ }
569+ })();
570+ `, string(jsonData), eventName, eventName, eventName)
531 return w.InjectScript(script)571 return w.InjectScript(script)
532 }572 }
533 573

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