//go:build webkit_cgo package webkit /* #cgo pkg-config: gtk4 #include #include // Declarations for Go callbacks extern void goOnKeyPress(unsigned long id, unsigned int keyval, GdkModifierType state); extern void goOnButtonPress(unsigned long id, unsigned int button, GdkModifierType state); extern void goOnScroll(unsigned long id, double dx, double dy, GdkModifierType state); // Key controller callback -> forward to Go static gboolean on_key_pressed(GtkEventControllerKey* controller, guint keyval, guint keycode, GdkModifierType state, gpointer user_data) { (void)controller; (void)keycode; goOnKeyPress((unsigned long)user_data, keyval, state); return FALSE; // do not stop other handlers } // Mouse click gesture -> forward side buttons to Go static void on_click_pressed(GtkGestureClick* gesture, gint n_press, gdouble x, gdouble y, gpointer user_data) { (void)n_press; (void)x; (void)y; guint button = gtk_gesture_single_get_current_button(GTK_GESTURE_SINGLE(gesture)); // Pass 0 for modifier state (not provided via gesture API) goOnButtonPress((unsigned long)user_data, button, 0); } // Attach controllers to a widget (GTK4) static void attach_key_controller(GtkWidget* widget, unsigned long id) { if (!widget) return; GtkEventController* keyc = gtk_event_controller_key_new(); // Capture phase so we see keys even if child consumes them (e.g., WebKit) gtk_event_controller_set_propagation_phase(keyc, GTK_PHASE_CAPTURE); g_signal_connect_data(keyc, "key-pressed", G_CALLBACK(on_key_pressed), (gpointer)id, NULL, 0); gtk_widget_add_controller(widget, keyc); } static void attach_mouse_gesture(GtkWidget* widget, unsigned long id) { if (!widget) return; GtkGesture* click = gtk_gesture_click_new(); g_signal_connect_data(click, "pressed", G_CALLBACK(on_click_pressed), (gpointer)id, NULL, 0); gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(click)); } // Legacy controller to capture side buttons (8/9) reliably in GTK4 static gboolean on_legacy_event(GtkEventController* controller, GdkEvent* event, gpointer user_data) { (void)controller; if (!event) return FALSE; GdkEventType type = gdk_event_get_event_type(event); if (type == GDK_BUTTON_PRESS) { guint button = gdk_button_event_get_button(event); GdkModifierType state = gdk_event_get_modifier_state(event); if (button == 8 || button == 9) { goOnButtonPress((unsigned long)user_data, button, state); return TRUE; // consume side buttons } return FALSE; // let WebKit handle normal clicks } else if (type == GDK_SCROLL) { double dx = 0.0, dy = 0.0; gdk_scroll_event_get_deltas(event, &dx, &dy); GdkModifierType state = gdk_event_get_modifier_state(event); goOnScroll((unsigned long)user_data, dx, dy, state); return FALSE; // don't steal regular scrolling } return FALSE; } static void attach_mouse_legacy(GtkWidget* widget, unsigned long id) { if (!widget) return; GtkEventController* legacy = gtk_event_controller_legacy_new(); gtk_event_controller_set_propagation_phase(legacy, GTK_PHASE_CAPTURE); g_signal_connect_data(legacy, "event", G_CALLBACK(on_legacy_event), (gpointer)id, NULL, 0); gtk_widget_add_controller(widget, legacy); } */ import "C" import ( "fmt" "log" "sync" "sync/atomic" "time" ) // Registry of accelerators per view id. type shortcutRegistry map[string]func() var ( regMu sync.RWMutex viewShortcuts = make(map[uintptr]shortcutRegistry) viewIDCounter uint64 viewByID = make(map[uintptr]*WebView) lastKeyTime = make(map[uintptr]map[string]time.Time) ) // Locale hint function removed; no layout-specific remaps. // RegisterKeyboardShortcut registers a callback under an accelerator string. func (w *WebView) RegisterKeyboardShortcut(accel string, callback func()) error { if w == nil || w.destroyed { return ErrNotImplemented } if accel == "" || callback == nil { return fmt.Errorf("invalid shortcut registration") } regMu.Lock() if _, ok := viewShortcuts[w.id]; !ok { viewShortcuts[w.id] = make(shortcutRegistry) } viewShortcuts[w.id][accel] = callback regMu.Unlock() return nil } //export goOnKeyPress func goOnKeyPress(id C.ulong, keyval C.guint, state C.GdkModifierType) { // Normalize and dispatch uid := uintptr(id) kv := uint(keyval) st := uint(state) dispatchAccelerator(uid, kv, st) } func dispatchAccelerator(uid uintptr, keyval uint, state uint) { ctrl := (state & uint(C.GDK_CONTROL_MASK)) != 0 alt := (state & uint(C.GDK_ALT_MASK)) != 0 // Map keyval to string names var keyName string switch keyval { case uint(C.GDK_KEY_minus), uint(C.GDK_KEY_KP_Subtract), uint(C.GDK_KEY_underscore): // Normalize minus; log raw detection for diagnostics log.Printf("[accelerator-raw] minus key detected ctrl=%v alt=%v keyval=0x%x", ctrl, alt, keyval) keyName = "-" case uint(C.GDK_KEY_equal), uint(C.GDK_KEY_KP_Add): keyName = "=" case uint(C.GDK_KEY_0), uint(C.GDK_KEY_KP_0): keyName = "0" case uint(C.GDK_KEY_Left): keyName = "ArrowLeft" case uint(C.GDK_KEY_Right): keyName = "ArrowRight" case uint(C.GDK_KEY_F12): keyName = "F12" default: // If Control held, log unknown keyval for diagnostics if ctrl { log.Printf("[accelerator-miss] ctrl keyval=0x%x", keyval) } return } // Candidate accelerator strings in order candidates := []string{keyName} if ctrl { // Common style: modifier + '+' + keyName (e.g., cmdorctrl+=) candidates = append([]string{"cmdorctrl+" + keyName, "ctrl+" + keyName}, candidates...) // Also accept style without the extra '+' for punctuation keys (e.g., cmdorctrl-) candidates = append([]string{"cmdorctrl" + keyName, "ctrl" + keyName}, candidates...) } if alt { candidates = append([]string{"alt+" + keyName}, candidates...) } regMu.RLock() reg := viewShortcuts[uid] regMu.RUnlock() if reg == nil { return } for _, name := range candidates { if cb, ok := reg[name]; ok { // Debounce duplicates within 120ms for the same view+key regMu.Lock() if _, ok := lastKeyTime[uid]; !ok { lastKeyTime[uid] = make(map[string]time.Time) } last := lastKeyTime[uid][name] now := time.Now() if now.Sub(last) < 120*time.Millisecond { // Skip duplicate regMu.Unlock() return } lastKeyTime[uid][name] = now regMu.Unlock() log.Printf("[accelerator] %s", name) cb() break } } } func nextViewID() uintptr { return uintptr(atomic.AddUint64(&viewIDCounter, 1)) } func registerView(id uintptr, w *WebView) { regMu.Lock() viewByID[id] = w regMu.Unlock() } func unregisterView(id uintptr) { regMu.Lock() delete(viewByID, id) delete(viewShortcuts, id) regMu.Unlock() } // AttachKeyboardControllers attaches GTK4 key and mouse controllers to the view widget. func AttachKeyboardControllers(w *WebView) { if w == nil || w.native == nil || w.native.view == nil { return } C.attach_key_controller(w.native.view, C.ulong(w.id)) if w.native.win != nil { C.attach_key_controller(w.native.win, C.ulong(w.id)) } C.attach_mouse_gesture(w.native.view, C.ulong(w.id)) // Legacy controller captures raw button presses (8/9) for back/forward reliably C.attach_mouse_legacy(w.native.view, C.ulong(w.id)) log.Printf("[input] GTK4 key/mouse controllers attached") } //export goOnScroll func goOnScroll(id C.ulong, dx C.double, dy C.double, state C.GdkModifierType) { uid := uintptr(id) regMu.RLock() vw := viewByID[uid] regMu.RUnlock() if vw == nil { return } // Ctrl+scroll zooms if (uint(state) & uint(C.GDK_CONTROL_MASK)) != 0 { if float64(dy) < 0 { // scroll up -> zoom in nz := vw.zoom if nz <= 0 { nz = 1.0 } nz *= 1.1 if nz < 0.25 { nz = 0.25 } if nz > 5.0 { nz = 5.0 } _ = vw.SetZoom(nz) } else if float64(dy) > 0 { // scroll down -> zoom out nz := vw.zoom if nz <= 0 { nz = 1.0 } nz /= 1.1 if nz < 0.25 { nz = 0.25 } if nz > 5.0 { nz = 5.0 } _ = vw.SetZoom(nz) } } } //export goOnThemeChanged func goOnThemeChanged(id C.ulong, preferDark C.int) { uid := uintptr(id) regMu.RLock() vw := viewByID[uid] regMu.RUnlock() if vw == nil { return } // Inject runtime theme update into the page dval := "false" if int(preferDark) != 0 { dval = "true" } js := fmt.Sprintf(`(() => { try { const d=%s; const cs=d?'dark':'light'; console.log('[dumber] theme change:'+cs); let m=document.querySelector('meta[name="color-scheme"]'); if(!m){ m=document.createElement('meta'); m.name='color-scheme'; document.head.appendChild(m); } m.setAttribute('content', d ? 'dark light' : 'light dark'); let s=document.getElementById('__dumber_theme_style'); if(!s){ s=document.createElement('style'); s.id='__dumber_theme_style'; document.documentElement.appendChild(s); } s.textContent=':root{color-scheme:' + (d?'dark':'light') + ';}'; } catch(e) { console.warn('[dumber] theme runtime update failed', e); } })();`, dval) _ = vw.InjectScript(js) } //export goOnUcmMessage func goOnUcmMessage(id C.ulong, json *C.char) { uid := uintptr(id) regMu.RLock() vw := viewByID[uid] regMu.RUnlock() if vw == nil || json == nil { return } goPayload := C.GoString(json) vw.dispatchScriptMessage(goPayload) } //export goOnTitleChanged func goOnTitleChanged(id C.ulong, ctitle *C.char) { uid := uintptr(id) regMu.RLock() vw := viewByID[uid] regMu.RUnlock() if vw == nil || ctitle == nil { return } title := C.GoString(ctitle) vw.dispatchTitleChanged(title) } //export goOnURIChanged func goOnURIChanged(id C.ulong, curi *C.char) { uid := uintptr(id) regMu.RLock() vw := viewByID[uid] regMu.RUnlock() if vw == nil || curi == nil { return } uri := C.GoString(curi) vw.dispatchURIChanged(uri) } //export goOnButtonPress func goOnButtonPress(id C.ulong, button C.guint, state C.GdkModifierType) { uid := uintptr(id) btn := uint(button) log.Printf("[mouse] button=%d state=0x%x", btn, uint(state)) regMu.RLock() vw := viewByID[uid] regMu.RUnlock() if vw == nil { return } switch btn { case 8: // Back button on many mice _ = vw.GoBack() case 9: // Forward button on many mice _ = vw.GoForward() } }