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
99// Locale hint function removed; no layout-specific remaps.
100
101// RegisterKeyboardShortcut registers a callback under an accelerator string.
102func (w *WebView) RegisterKeyboardShortcut(accel string, callback func()) error {
103 if w == nil || w.destroyed {
104 return ErrNotImplemented
105 }
106 if accel == "" || callback == nil {
107 return fmt.Errorf("invalid shortcut registration")
108 }
109 regMu.Lock()
110 if _, ok := viewShortcuts[w.id]; !ok {
111 viewShortcuts[w.id] = make(shortcutRegistry)
112 }
113 viewShortcuts[w.id][accel] = callback
114 regMu.Unlock()
115 return nil
116}
117
118//export goOnKeyPress
119func goOnKeyPress(id C.ulong, keyval C.guint, state C.GdkModifierType) {
120 // Normalize and dispatch
121 uid := uintptr(id)
122 kv := uint(keyval)
123 st := uint(state)
124 dispatchAccelerator(uid, kv, st)
125}
126
127func dispatchAccelerator(uid uintptr, keyval uint, state uint) {
128 ctrl := (state & uint(C.GDK_CONTROL_MASK)) != 0
129 alt := (state & uint(C.GDK_ALT_MASK)) != 0
130 // Map keyval to string names
131 var keyName string
132 switch keyval {
133 case uint(C.GDK_KEY_minus), uint(C.GDK_KEY_KP_Subtract), uint(C.GDK_KEY_underscore):
134 // Normalize minus; log raw detection for diagnostics
135 log.Printf("[accelerator-raw] minus key detected ctrl=%v alt=%v keyval=0x%x", ctrl, alt, keyval)
136 keyName = "-"
137 case uint(C.GDK_KEY_equal), uint(C.GDK_KEY_KP_Add):
138 keyName = "="
139 case uint(C.GDK_KEY_0), uint(C.GDK_KEY_KP_0):
140 keyName = "0"
141 case uint(C.GDK_KEY_Left):
142 keyName = "ArrowLeft"
143 case uint(C.GDK_KEY_Right):
144 keyName = "ArrowRight"
145 case uint(C.GDK_KEY_F12):
146 keyName = "F12"
147 default:
148 // If Control held, log unknown keyval for diagnostics
149 if ctrl {
150 log.Printf("[accelerator-miss] ctrl keyval=0x%x", keyval)
151 }
152 return
153 }
154
155 // Candidate accelerator strings in order
156 candidates := []string{keyName}
157 if ctrl {
158 // Common style: modifier + '+' + keyName (e.g., cmdorctrl+=)
159 candidates = append([]string{"cmdorctrl+" + keyName, "ctrl+" + keyName}, candidates...)
160 // Also accept style without the extra '+' for punctuation keys (e.g., cmdorctrl-)
161 candidates = append([]string{"cmdorctrl" + keyName, "ctrl" + keyName}, candidates...)
162 }
163 if alt {
164 candidates = append([]string{"alt+" + keyName}, candidates...)
165 }
166
167 regMu.RLock()
168 reg := viewShortcuts[uid]
169 regMu.RUnlock()
170 if reg == nil {
171 return
172 }
173 for _, name := range candidates {
174 if cb, ok := reg[name]; ok {
175 // Debounce duplicates within 120ms for the same view+key
176 regMu.Lock()
177 if _, ok := lastKeyTime[uid]; !ok {
178 lastKeyTime[uid] = make(map[string]time.Time)
179 }
180 last := lastKeyTime[uid][name]
181 now := time.Now()
182 if now.Sub(last) < 120*time.Millisecond {
183 // Skip duplicate
184 regMu.Unlock()
185 return
186 }
187 lastKeyTime[uid][name] = now
188 regMu.Unlock()
189 log.Printf("[accelerator] %s", name)
190 cb()
191 break
192 }
193 }
194}
195
196func nextViewID() uintptr {
197 return uintptr(atomic.AddUint64(&viewIDCounter, 1))
198}
199
200func registerView(id uintptr, w *WebView) {
201 regMu.Lock()
202 viewByID[id] = w
203 regMu.Unlock()
204}
205
206func unregisterView(id uintptr) {
207 regMu.Lock()
208 delete(viewByID, id)
209 delete(viewShortcuts, id)
210 regMu.Unlock()
211}
212
213// AttachKeyboardControllers attaches GTK4 key and mouse controllers to the view widget.
214func AttachKeyboardControllers(w *WebView) {
215 if w == nil || w.native == nil || w.native.view == nil {
216 return
217 }
218 C.attach_key_controller(w.native.view, C.ulong(w.id))
219 if w.native.win != nil {
220 C.attach_key_controller(w.native.win, C.ulong(w.id))
221 }
222 C.attach_mouse_gesture(w.native.view, C.ulong(w.id))
223 // Legacy controller captures raw button presses (8/9) for back/forward reliably
224 C.attach_mouse_legacy(w.native.view, C.ulong(w.id))
225 log.Printf("[input] GTK4 key/mouse controllers attached")
226}
227
228//export goOnScroll
229func goOnScroll(id C.ulong, dx C.double, dy C.double, state C.GdkModifierType) {
230 uid := uintptr(id)
231 regMu.RLock()
232 vw := viewByID[uid]
233 regMu.RUnlock()
234 if vw == nil {
235 return
236 }
237 // Ctrl+scroll zooms
238 if (uint(state) & uint(C.GDK_CONTROL_MASK)) != 0 {
239 if float64(dy) < 0 {
240 // scroll up -> zoom in
241 nz := vw.zoom
242 if nz <= 0 {
243 nz = 1.0
244 }
245 nz *= 1.1
246 if nz < 0.25 {
247 nz = 0.25
248 }
249 if nz > 5.0 {
250 nz = 5.0
251 }
252 _ = vw.SetZoom(nz)
253 } else if float64(dy) > 0 {
254 // scroll down -> zoom out
255 nz := vw.zoom
256 if nz <= 0 {
257 nz = 1.0
258 }
259 nz /= 1.1
260 if nz < 0.25 {
261 nz = 0.25
262 }
263 if nz > 5.0 {
264 nz = 5.0
265 }
266 _ = vw.SetZoom(nz)
267 }
268 }
269}
270
271//export goOnThemeChanged
272func goOnThemeChanged(id C.ulong, preferDark C.int) {
273 uid := uintptr(id)
274 regMu.RLock()
275 vw := viewByID[uid]
276 regMu.RUnlock()
277 if vw == nil {
278 return
279 }
280 // Inject runtime theme update into the page
281 dval := "false"
282 if int(preferDark) != 0 {
283 dval = "true"
284 }
285 js := fmt.Sprintf(`(() => { try { const d=%s; const cs=d?'dark':'light'; console.log('[dumber] theme change:'+cs);
286let m=document.querySelector('meta[name="color-scheme"]');
287if(!m){ m=document.createElement('meta'); m.name='color-scheme'; document.head.appendChild(m); }
288m.setAttribute('content', d ? 'dark light' : 'light dark');
289let s=document.getElementById('__dumber_theme_style');
290if(!s){ s=document.createElement('style'); s.id='__dumber_theme_style'; document.documentElement.appendChild(s); }
291s.textContent=':root{color-scheme:' + (d?'dark':'light') + ';}';
292} catch(e) { console.warn('[dumber] theme runtime update failed', e); } })();`, dval)
293 _ = vw.InjectScript(js)
294}
295
296//export goOnUcmMessage
297func goOnUcmMessage(id C.ulong, json *C.char) {
298 uid := uintptr(id)
299 regMu.RLock()
300 vw := viewByID[uid]
301 regMu.RUnlock()
302 if vw == nil || json == nil {
303 return
304 }
305 goPayload := C.GoString(json)
306 vw.dispatchScriptMessage(goPayload)
307}
308
309//export goOnTitleChanged
310func goOnTitleChanged(id C.ulong, ctitle *C.char) {
311 uid := uintptr(id)
312 regMu.RLock()
313 vw := viewByID[uid]
314 regMu.RUnlock()
315 if vw == nil || ctitle == nil {
316 return
317 }
318 title := C.GoString(ctitle)
319 vw.dispatchTitleChanged(title)
320}
321
322//export goOnURIChanged
323func goOnURIChanged(id C.ulong, curi *C.char) {
324 uid := uintptr(id)
325 regMu.RLock()
326 vw := viewByID[uid]
327 regMu.RUnlock()
328 if vw == nil || curi == nil {
329 return
330 }
331 uri := C.GoString(curi)
332 vw.dispatchURIChanged(uri)
333}
334
335//export goOnButtonPress
336func goOnButtonPress(id C.ulong, button C.guint, state C.GdkModifierType) {
337 uid := uintptr(id)
338 btn := uint(button)
339 log.Printf("[mouse] button=%d state=0x%x", btn, uint(state))
340 regMu.RLock()
341 vw := viewByID[uid]
342 regMu.RUnlock()
343 if vw == nil {
344 return
345 }
346 switch btn {
347 case 8: // Back button on many mice
348 _ = vw.GoBack()
349 case 9: // Forward button on many mice
350 _ = vw.GoForward()
351 }
352}