v0.6.0 5c50dd3

keyboard_cgo.go RawHistoryFolder

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

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