1//go:build webkit_cgo
2
3package webkit
4
5/*
6#cgo pkg-config: gtk4
7#include <gtk/gtk.h>
8#include <gdk/gdk.h>
9
10// Declarations for Go callbacks
11extern void goOnKeyPress(unsigned long id, unsigned int keyval, GdkModifierType state);
12extern void goOnButtonPress(unsigned long id, unsigned int button, GdkModifierType state);
13extern void goOnScroll(unsigned long id, double dx, double dy, GdkModifierType state);
14
15// Key controller callback -> forward to Go
16static gboolean on_key_pressed(GtkEventControllerKey* controller, guint keyval, guint keycode, GdkModifierType state, gpointer user_data) {
17 (void)controller; (void)keycode;
18 goOnKeyPress((unsigned long)user_data, keyval, state);
19 return FALSE; // do not stop other handlers
20}
21
22// Mouse click gesture -> forward side buttons to Go
23static void on_click_pressed(GtkGestureClick* gesture, gint n_press, gdouble x, gdouble y, gpointer user_data) {
24 (void)n_press; (void)x; (void)y;
25 guint button = gtk_gesture_single_get_current_button(GTK_GESTURE_SINGLE(gesture));
26 // Pass 0 for modifier state (not provided via gesture API)
27 goOnButtonPress((unsigned long)user_data, button, 0);
28}
29
30// Attach controllers to a widget (GTK4)
31static void attach_key_controller(GtkWidget* widget, unsigned long id) {
32 if (!widget) return;
33 GtkEventController* keyc = gtk_event_controller_key_new();
34 // Capture phase so we see keys even if child consumes them (e.g., WebKit)
35 gtk_event_controller_set_propagation_phase(keyc, GTK_PHASE_CAPTURE);
36 g_signal_connect_data(keyc, "key-pressed", G_CALLBACK(on_key_pressed), (gpointer)id, NULL, 0);
37 gtk_widget_add_controller(widget, keyc);
38}
39
40static void attach_mouse_gesture(GtkWidget* widget, unsigned long id) {
41 if (!widget) return;
42 GtkGesture* click = gtk_gesture_click_new();
43 g_signal_connect_data(click, "pressed", G_CALLBACK(on_click_pressed), (gpointer)id, NULL, 0);
44 gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(click));
45}
46
47// Legacy controller to capture side buttons (8/9) reliably in GTK4
48static gboolean on_legacy_event(GtkEventController* controller, GdkEvent* event, gpointer user_data) {
49 (void)controller;
50 if (!event) return FALSE;
51 GdkEventType type = gdk_event_get_event_type(event);
52 if (type == GDK_BUTTON_PRESS) {
53 guint button = gdk_button_event_get_button(event);
54 GdkModifierType state = gdk_event_get_modifier_state(event);
55 if (button == 8 || button == 9) {
56 goOnButtonPress((unsigned long)user_data, button, state);
57 return TRUE; // consume side buttons
58 }
59 return FALSE; // let WebKit handle normal clicks
60 } else if (type == GDK_SCROLL) {
61 double dx = 0.0, dy = 0.0;
62 gdk_scroll_event_get_deltas(event, &dx, &dy);
63 GdkModifierType state = gdk_event_get_modifier_state(event);
64 goOnScroll((unsigned long)user_data, dx, dy, state);
65 return FALSE; // don't steal regular scrolling
66 }
67 return FALSE;
68}
69
70static void attach_mouse_legacy(GtkWidget* widget, unsigned long id) {
71 if (!widget) return;
72 GtkEventController* legacy = gtk_event_controller_legacy_new();
73 gtk_event_controller_set_propagation_phase(legacy, GTK_PHASE_CAPTURE);
74 g_signal_connect_data(legacy, "event", G_CALLBACK(on_legacy_event), (gpointer)id, NULL, 0);
75 gtk_widget_add_controller(widget, legacy);
76}
77*/
78import "C"
79
80import (
81 "fmt"
82 "log"
83 "sync"
84 "sync/atomic"
85 "time"
86)
87
88// Registry of accelerators per view id.
89type shortcutRegistry map[string]func()
90
91var (
92 regMu sync.RWMutex
93 viewShortcuts = make(map[uintptr]shortcutRegistry)
94 viewIDCounter uint64
95 viewByID = make(map[uintptr]*WebView)
96 lastKeyTime = make(map[uintptr]map[string]time.Time)
97)
98
99const shortcutDebounceWindow = 120 * time.Millisecond
100
101// shouldDebounceShortcut returns true when the given key should be skipped
102// because it was delivered within the debounce window for the same view.
103func shouldDebounceShortcut(uid uintptr, key string, now time.Time) bool {
104 regMu.Lock()
105 defer regMu.Unlock()
106
107 if _, ok := lastKeyTime[uid]; !ok {
108 lastKeyTime[uid] = make(map[string]time.Time)
109 }
110 last := lastKeyTime[uid][key]
111 if now.Sub(last) < shortcutDebounceWindow {
112 return true
113 }
114 lastKeyTime[uid][key] = now
115 return false
116}
117
118// Locale hint function removed; no layout-specific remaps.
119
120// RegisterKeyboardShortcut registers a callback under an accelerator string.
121func (w *WebView) RegisterKeyboardShortcut(accel string, callback func()) error {
122 if w == nil || w.destroyed {
123 return ErrNotImplemented
124 }
125 if accel == "" || callback == nil {
126 return fmt.Errorf("invalid shortcut registration")
127 }
128 regMu.Lock()
129 if _, ok := viewShortcuts[w.id]; !ok {
130 viewShortcuts[w.id] = make(shortcutRegistry)
131 }
132 viewShortcuts[w.id][accel] = callback
133 regMu.Unlock()
134 return nil
135}
136
137//export goOnKeyPress
138func goOnKeyPress(id C.ulong, keyval C.guint, state C.GdkModifierType) {
139 // Normalize and dispatch
140 uid := uintptr(id)
141 kv := uint(keyval)
142 st := uint(state)
143 dispatchAccelerator(uid, kv, st)
144}
145
146func dispatchAccelerator(uid uintptr, keyval uint, state uint) {
147 ctrl := (state & uint(C.GDK_CONTROL_MASK)) != 0
148 alt := (state & uint(C.GDK_ALT_MASK)) != 0
149 shift := (state & uint(C.GDK_SHIFT_MASK)) != 0
150 // Map keyval to string names
151 var keyName string
152 switch keyval {
153 case uint(C.GDK_KEY_minus), uint(C.GDK_KEY_KP_Subtract), uint(C.GDK_KEY_underscore):
154 // Normalize minus; log raw detection for diagnostics
155 log.Printf("[accelerator-raw] minus key detected ctrl=%v alt=%v keyval=0x%x", ctrl, alt, keyval)
156 keyName = "-"
157 case uint(C.GDK_KEY_equal), uint(C.GDK_KEY_KP_Add):
158 keyName = "="
159 case uint(C.GDK_KEY_0), uint(C.GDK_KEY_KP_0):
160 keyName = "0"
161 case uint(C.GDK_KEY_Left):
162 keyName = "ArrowLeft"
163 case uint(C.GDK_KEY_Right):
164 keyName = "ArrowRight"
165 case uint(C.GDK_KEY_F12):
166 keyName = "F12"
167 case uint(C.GDK_KEY_F5):
168 keyName = "F5"
169 case uint(C.GDK_KEY_c), uint(C.GDK_KEY_C):
170 keyName = "c"
171 case uint(C.GDK_KEY_r), uint(C.GDK_KEY_R):
172 keyName = "r"
173 case uint(C.GDK_KEY_l), uint(C.GDK_KEY_L):
174 keyName = "l"
175 case uint(C.GDK_KEY_f), uint(C.GDK_KEY_F):
176 keyName = "f"
177 default:
178 // If Control held, log unknown keyval for diagnostics
179 if ctrl {
180 log.Printf("[accelerator-miss] ctrl keyval=0x%x", keyval)
181 }
182 return
183 }
184
185 // Candidate accelerator strings in order
186 candidates := []string{keyName}
187 if ctrl && shift {
188 // Ctrl+Shift combinations
189 candidates = append([]string{"cmdorctrl+shift+" + keyName, "ctrl+shift+" + keyName}, candidates...)
190 } else if ctrl {
191 // Common style: modifier + '+' + keyName (e.g., cmdorctrl+=)
192 candidates = append([]string{"cmdorctrl+" + keyName, "ctrl+" + keyName}, candidates...)
193 // Also accept style without the extra '+' for punctuation keys (e.g., cmdorctrl-)
194 candidates = append([]string{"cmdorctrl" + keyName, "ctrl" + keyName}, candidates...)
195 }
196 if alt {
197 candidates = append([]string{"alt+" + keyName}, candidates...)
198 }
199
200 // Compute a single normalized shortcut string to forward to GUI (single source of truth)
201 normalized := ""
202 if ctrl && shift {
203 normalized = "cmdorctrl+shift+" + keyName
204 } else if ctrl {
205 normalized = "cmdorctrl+" + keyName
206 } else if alt {
207 normalized = "alt+" + keyName
208 } else if keyName != "" {
209 // For future modes like Vim, we may want to forward non-modified keys
210 // For now, only forward if a modifier is present to avoid noise
211 normalized = ""
212 }
213
214 now := time.Now()
215 // Forward normalized shortcut to GUI KeyboardService via DOM event if present
216 if normalized != "" && !shouldDebounceShortcut(uid, "dom:"+normalized, now) {
217 regMu.RLock()
218 vw := viewByID[uid]
219 regMu.RUnlock()
220 if vw != nil {
221 _ = vw.InjectScript(fmt.Sprintf("document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'%s'}}));", normalized))
222 }
223 }
224
225 regMu.RLock()
226 reg := viewShortcuts[uid]
227 regMu.RUnlock()
228 if reg == nil {
229 return
230 }
231 for _, name := range candidates {
232 if cb, ok := reg[name]; ok {
233 if shouldDebounceShortcut(uid, "shortcut:"+name, now) {
234 return
235 }
236 log.Printf("[accelerator] %s", name)
237 cb()
238 break
239 }
240 }
241}
242
243func nextViewID() uintptr {
244 return uintptr(atomic.AddUint64(&viewIDCounter, 1))
245}
246
247func registerView(id uintptr, w *WebView) {
248 regMu.Lock()
249 viewByID[id] = w
250 regMu.Unlock()
251}
252
253func unregisterView(id uintptr) {
254 regMu.Lock()
255 delete(viewByID, id)
256 delete(viewShortcuts, id)
257 regMu.Unlock()
258}
259
260// AttachKeyboardControllers attaches GTK4 key and mouse controllers to the view widget.
261func AttachKeyboardControllers(w *WebView) {
262 if w == nil || w.native == nil || w.native.view == nil {
263 return
264 }
265 C.attach_key_controller(w.native.view, C.ulong(w.id))
266 if w.native.win != nil {
267 C.attach_key_controller(w.native.win, C.ulong(w.id))
268 }
269 C.attach_mouse_gesture(w.native.view, C.ulong(w.id))
270 // Legacy controller captures raw button presses (8/9) for back/forward reliably
271 C.attach_mouse_legacy(w.native.view, C.ulong(w.id))
272 log.Printf("[input] GTK4 key/mouse controllers attached")
273}
274
275//export goOnScroll
276func goOnScroll(id C.ulong, dx C.double, dy C.double, state C.GdkModifierType) {
277 uid := uintptr(id)
278 regMu.RLock()
279 vw := viewByID[uid]
280 regMu.RUnlock()
281 if vw == nil {
282 return
283 }
284 // Ctrl+scroll zooms
285 if (uint(state) & uint(C.GDK_CONTROL_MASK)) != 0 {
286 if float64(dy) < 0 {
287 // scroll up -> zoom in
288 nz := vw.zoom
289 if nz <= 0 {
290 nz = 1.0
291 }
292 nz *= 1.1
293 if nz < 0.25 {
294 nz = 0.25
295 }
296 if nz > 5.0 {
297 nz = 5.0
298 }
299 _ = vw.SetZoom(nz)
300 } else if float64(dy) > 0 {
301 // scroll down -> zoom out
302 nz := vw.zoom
303 if nz <= 0 {
304 nz = 1.0
305 }
306 nz /= 1.1
307 if nz < 0.25 {
308 nz = 0.25
309 }
310 if nz > 5.0 {
311 nz = 5.0
312 }
313 _ = vw.SetZoom(nz)
314 }
315 }
316}
317
318//export goOnThemeChanged
319func goOnThemeChanged(id C.ulong, preferDark C.int) {
320 uid := uintptr(id)
321 regMu.RLock()
322 vw := viewByID[uid]
323 regMu.RUnlock()
324 if vw == nil {
325 return
326 }
327 // Inject runtime theme update into the page
328 dval := "false"
329 if int(preferDark) != 0 {
330 dval = "true"
331 }
332 js := fmt.Sprintf(`(() => { try { const d=%s; const cs=d?'dark':'light'; console.log('[dumber] theme change:'+cs);
333let m=document.querySelector('meta[name="color-scheme"]');
334if(!m){ m=document.createElement('meta'); m.name='color-scheme'; document.head.appendChild(m); }
335m.setAttribute('content', d ? 'dark light' : 'light dark');
336let s=document.getElementById('__dumber_theme_style');
337if(!s){ s=document.createElement('style'); s.id='__dumber_theme_style'; document.documentElement.appendChild(s); }
338s.textContent=':root{color-scheme:' + (d?'dark':'light') + ';}';
339// Call the unified theme setter for Tailwind dark mode
340if (window.__dumber_setTheme) {
341 window.__dumber_setTheme(cs);
342} else {
343 // Retry after a short delay in case bridge hasn't loaded yet
344 setTimeout(() => {
345 if (window.__dumber_setTheme) {
346 window.__dumber_setTheme(cs);
347 } else {
348 // Fallback: directly apply dark class
349 console.warn('[dumber] Theme setter not available, using fallback');
350 if (d) {
351 document.documentElement.classList.add('dark');
352 } else {
353 document.documentElement.classList.remove('dark');
354 }
355 }
356 }, 100);
357}
358} catch(e) { console.warn('[dumber] theme runtime update failed', e); } })();`, dval)
359 _ = vw.InjectScript(js)
360}
361
362//export goOnUcmMessage
363func goOnUcmMessage(id C.ulong, json *C.char) {
364 uid := uintptr(id)
365 regMu.RLock()
366 vw := viewByID[uid]
367 regMu.RUnlock()
368 if vw == nil || json == nil {
369 return
370 }
371 goPayload := C.GoString(json)
372 vw.dispatchScriptMessage(goPayload)
373}
374
375//export goOnTitleChanged
376func goOnTitleChanged(id C.ulong, ctitle *C.char) {
377 uid := uintptr(id)
378 regMu.RLock()
379 vw := viewByID[uid]
380 regMu.RUnlock()
381 if vw == nil || ctitle == nil {
382 return
383 }
384 title := C.GoString(ctitle)
385 vw.dispatchTitleChanged(title)
386}
387
388//export goOnURIChanged
389func goOnURIChanged(id C.ulong, curi *C.char) {
390 uid := uintptr(id)
391 regMu.RLock()
392 vw := viewByID[uid]
393 regMu.RUnlock()
394 if vw == nil || curi == nil {
395 return
396 }
397 uri := C.GoString(curi)
398 vw.dispatchURIChanged(uri)
399}
400
401//export goOnButtonPress
402func goOnButtonPress(id C.ulong, button C.guint, state C.GdkModifierType) {
403 uid := uintptr(id)
404 btn := uint(button)
405 log.Printf("[mouse] button=%d state=0x%x", btn, uint(state))
406 regMu.RLock()
407 vw := viewByID[uid]
408 regMu.RUnlock()
409 if vw == nil {
410 return
411 }
412 switch btn {
413 case 8: // Back button on many mice
414 _ = vw.GoBack()
415 case 9: // Forward button on many mice
416 _ = vw.GoForward()
417 }
418}
419
420//export goQuitMainLoop
421func goQuitMainLoop() {
422 log.Printf("[webkit] Window close requested - quitting main loop")
423 QuitMainLoop()
424}