detached f7997f7

refactor(shortcuts): implement window-level global shortcut system

commit
f7997f70858ffbaabee6289bb86ea76391ceb110
author
bamen · · 2025-09-19 03:41 UTC
parent
f223216
signature
unsigned
3 files · +532 -0
A internal/app/browser/window_shortcuts.go +238 -0
@@ -0,0 +1,238 @@
1+package browser
2+
3+import (
4+ "fmt"
5+ "log"
6+ "sync"
7+ "time"
8+
9+ "github.com/bnema/dumber/pkg/webkit"
10+)
11+
12+// WindowShortcutHandler manages global shortcuts at the window level
13+type WindowShortcutHandler struct {
14+ window *webkit.Window
15+ app *BrowserApp
16+ shortcuts *webkit.WindowShortcuts
17+ mu sync.Mutex
18+
19+ // Debounce tracking
20+ lastOmniboxToggle time.Time
21+ lastFindToggle time.Time
22+ lastCopyURL time.Time
23+ lastDevTools time.Time
24+}
25+
26+// NewWindowShortcutHandler creates a new window-level shortcut handler
27+func NewWindowShortcutHandler(window *webkit.Window, app *BrowserApp) *WindowShortcutHandler {
28+ h := &WindowShortcutHandler{
29+ window: window,
30+ app: app,
31+ }
32+
33+ if err := h.initialize(); err != nil {
34+ log.Printf("[window-shortcuts] Failed to initialize: %v", err)
35+ return nil
36+ }
37+
38+ return h
39+}
40+
41+func (h *WindowShortcutHandler) initialize() error {
42+ // Initialize GTK4 global shortcuts
43+ h.shortcuts = h.window.InitializeGlobalShortcuts()
44+ if h.shortcuts == nil {
45+ return ErrFailedToInitialize
46+ }
47+
48+ return h.registerGlobalShortcuts()
49+}
50+
51+func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
52+ shortcuts := []struct {
53+ key string
54+ handler func()
55+ desc string
56+ }{
57+ {"ctrl+l", h.handleOmniboxToggle, "Omnibox toggle"},
58+ {"ctrl+f", h.handleFindToggle, "Find in page"},
59+ {"ctrl+shift+c", h.handleCopyURL, "Copy URL"},
60+ {"F12", h.handleDevTools, "Developer tools"},
61+ }
62+
63+ for _, shortcut := range shortcuts {
64+ if err := h.shortcuts.RegisterGlobalShortcut(shortcut.key, shortcut.handler); err != nil {
65+ log.Printf("[window-shortcuts] Failed to register %s (%s): %v",
66+ shortcut.key, shortcut.desc, err)
67+ return err
68+ }
69+ log.Printf("[window-shortcuts] Registered global shortcut: %s (%s)",
70+ shortcut.key, shortcut.desc)
71+ }
72+
73+ return nil
74+}
75+
76+func (h *WindowShortcutHandler) handleOmniboxToggle() {
77+ h.mu.Lock()
78+ defer h.mu.Unlock()
79+
80+ // Debounce: 50ms minimum between toggles
81+ if time.Since(h.lastOmniboxToggle) < 50*time.Millisecond {
82+ log.Printf("[window-shortcuts] Omnibox toggle debounced")
83+ return
84+ }
85+ h.lastOmniboxToggle = time.Now()
86+
87+ if h.app.activePane == nil || h.app.activePane.webView == nil {
88+ log.Printf("[window-shortcuts] No active pane for omnibox")
89+ return
90+ }
91+
92+ log.Printf("[window-shortcuts] Omnibox toggle -> pane %p", h.app.activePane.webView)
93+
94+ // Ensure GUI is available in active pane
95+ h.ensureGUIInActivePane("omnibox")
96+
97+ // Dispatch to active pane only
98+ if err := h.app.activePane.webView.DispatchCustomEvent("dumber:ui:shortcut", map[string]any{
99+ "action": "omnibox-toggle",
100+ "paneId": h.getPaneId(h.app.activePane),
101+ "timestamp": time.Now().UnixMilli(),
102+ "source": "window-global",
103+ }); err != nil {
104+ log.Printf("[window-shortcuts] Failed to dispatch omnibox toggle: %v", err)
105+ }
106+}
107+
108+func (h *WindowShortcutHandler) handleFindToggle() {
109+ h.mu.Lock()
110+ defer h.mu.Unlock()
111+
112+ if time.Since(h.lastFindToggle) < 50*time.Millisecond {
113+ log.Printf("[window-shortcuts] Find toggle debounced")
114+ return
115+ }
116+ h.lastFindToggle = time.Now()
117+
118+ if h.app.activePane == nil || h.app.activePane.webView == nil {
119+ log.Printf("[window-shortcuts] No active pane for find")
120+ return
121+ }
122+
123+ log.Printf("[window-shortcuts] Find toggle -> pane %p", h.app.activePane.webView)
124+
125+ h.ensureGUIInActivePane("omnibox")
126+
127+ if err := h.app.activePane.webView.DispatchCustomEvent("dumber:ui:shortcut", map[string]any{
128+ "action": "omnibox-find",
129+ "paneId": h.getPaneId(h.app.activePane),
130+ "timestamp": time.Now().UnixMilli(),
131+ "source": "window-global",
132+ }); err != nil {
133+ log.Printf("[window-shortcuts] Failed to dispatch find toggle: %v", err)
134+ }
135+}
136+
137+func (h *WindowShortcutHandler) handleCopyURL() {
138+ h.mu.Lock()
139+ defer h.mu.Unlock()
140+
141+ if time.Since(h.lastCopyURL) < 100*time.Millisecond {
142+ return
143+ }
144+ h.lastCopyURL = time.Now()
145+
146+ if h.app.activePane == nil || h.app.activePane.webView == nil {
147+ log.Printf("[window-shortcuts] No active pane for copy URL")
148+ return
149+ }
150+
151+ log.Printf("[window-shortcuts] Copy URL -> pane %p", h.app.activePane.webView)
152+
153+ // Execute copy URL script directly on active pane
154+ script := `
155+ (async function() {
156+ const toast = (message, type) => {
157+ document.dispatchEvent(new CustomEvent('dumber:showToast', {
158+ detail: { message, duration: 2000, type }
159+ }));
160+ };
161+
162+ try {
163+ const url = window.location.href;
164+ await navigator.clipboard.writeText(url);
165+ console.log('🔗 Window shortcut copied URL:', url);
166+ toast('URL copied to clipboard!', 'success');
167+ } catch (error) {
168+ console.error('❌ Failed to copy URL:', error);
169+ toast('Failed to copy URL', 'error');
170+ }
171+ })();
172+ `
173+
174+ if err := h.app.activePane.webView.InjectScript(script); err != nil {
175+ log.Printf("[window-shortcuts] Failed to inject copy URL script: %v", err)
176+ }
177+}
178+
179+func (h *WindowShortcutHandler) handleDevTools() {
180+ h.mu.Lock()
181+ defer h.mu.Unlock()
182+
183+ if time.Since(h.lastDevTools) < 200*time.Millisecond {
184+ return
185+ }
186+ h.lastDevTools = time.Now()
187+
188+ if h.app.activePane == nil || h.app.activePane.webView == nil {
189+ log.Printf("[window-shortcuts] No active pane for devtools")
190+ return
191+ }
192+
193+ log.Printf("[window-shortcuts] DevTools -> pane %p", h.app.activePane.webView)
194+
195+ if err := h.app.activePane.webView.ShowDevTools(); err != nil {
196+ log.Printf("[window-shortcuts] Failed to show devtools: %v", err)
197+ }
198+}
199+
200+func (h *WindowShortcutHandler) ensureGUIInActivePane(component string) {
201+ if h.app.activePane == nil {
202+ return
203+ }
204+
205+ pane := h.app.activePane
206+ if !pane.HasGUI() {
207+ log.Printf("[window-shortcuts] Injecting GUI into pane %s for %s", pane.ID(), component)
208+ if h.app.workspace != nil {
209+ h.app.workspace.ensureGUIInPane(pane)
210+ }
211+ }
212+
213+ // Ensure specific component is available
214+ if !pane.HasGUIComponent(component) {
215+ log.Printf("[window-shortcuts] Ensuring %s component in pane %s", component, pane.ID())
216+ pane.SetGUIComponent(component, true)
217+ }
218+}
219+
220+func (h *WindowShortcutHandler) getPaneId(pane *BrowserPane) string {
221+ if pane == nil {
222+ return "unknown"
223+ }
224+ return pane.ID()
225+}
226+
227+// Cleanup releases resources
228+func (h *WindowShortcutHandler) Cleanup() {
229+ if h.shortcuts != nil {
230+ h.shortcuts.Cleanup()
231+ h.shortcuts = nil
232+ }
233+}
234+
235+// Error definitions
236+var (
237+ ErrFailedToInitialize = fmt.Errorf("failed to initialize window shortcuts")
238+)
@@ -0,0 +1,238 @@
1+package browser
2+
3+import (
4+ "fmt"
5+ "log"
6+ "sync"
7+ "time"
8+
9+ "github.com/bnema/dumber/pkg/webkit"
10+)
11+
12+// WindowShortcutHandler manages global shortcuts at the window level
13+type WindowShortcutHandler struct {
14+ window *webkit.Window
15+ app *BrowserApp
16+ shortcuts *webkit.WindowShortcuts
17+ mu sync.Mutex
18+
19+ // Debounce tracking
20+ lastOmniboxToggle time.Time
21+ lastFindToggle time.Time
22+ lastCopyURL time.Time
23+ lastDevTools time.Time
24+}
25+
26+// NewWindowShortcutHandler creates a new window-level shortcut handler
27+func NewWindowShortcutHandler(window *webkit.Window, app *BrowserApp) *WindowShortcutHandler {
28+ h := &WindowShortcutHandler{
29+ window: window,
30+ app: app,
31+ }
32+
33+ if err := h.initialize(); err != nil {
34+ log.Printf("[window-shortcuts] Failed to initialize: %v", err)
35+ return nil
36+ }
37+
38+ return h
39+}
40+
41+func (h *WindowShortcutHandler) initialize() error {
42+ // Initialize GTK4 global shortcuts
43+ h.shortcuts = h.window.InitializeGlobalShortcuts()
44+ if h.shortcuts == nil {
45+ return ErrFailedToInitialize
46+ }
47+
48+ return h.registerGlobalShortcuts()
49+}
50+
51+func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
52+ shortcuts := []struct {
53+ key string
54+ handler func()
55+ desc string
56+ }{
57+ {"ctrl+l", h.handleOmniboxToggle, "Omnibox toggle"},
58+ {"ctrl+f", h.handleFindToggle, "Find in page"},
59+ {"ctrl+shift+c", h.handleCopyURL, "Copy URL"},
60+ {"F12", h.handleDevTools, "Developer tools"},
61+ }
62+
63+ for _, shortcut := range shortcuts {
64+ if err := h.shortcuts.RegisterGlobalShortcut(shortcut.key, shortcut.handler); err != nil {
65+ log.Printf("[window-shortcuts] Failed to register %s (%s): %v",
66+ shortcut.key, shortcut.desc, err)
67+ return err
68+ }
69+ log.Printf("[window-shortcuts] Registered global shortcut: %s (%s)",
70+ shortcut.key, shortcut.desc)
71+ }
72+
73+ return nil
74+}
75+
76+func (h *WindowShortcutHandler) handleOmniboxToggle() {
77+ h.mu.Lock()
78+ defer h.mu.Unlock()
79+
80+ // Debounce: 50ms minimum between toggles
81+ if time.Since(h.lastOmniboxToggle) < 50*time.Millisecond {
82+ log.Printf("[window-shortcuts] Omnibox toggle debounced")
83+ return
84+ }
85+ h.lastOmniboxToggle = time.Now()
86+
87+ if h.app.activePane == nil || h.app.activePane.webView == nil {
88+ log.Printf("[window-shortcuts] No active pane for omnibox")
89+ return
90+ }
91+
92+ log.Printf("[window-shortcuts] Omnibox toggle -> pane %p", h.app.activePane.webView)
93+
94+ // Ensure GUI is available in active pane
95+ h.ensureGUIInActivePane("omnibox")
96+
97+ // Dispatch to active pane only
98+ if err := h.app.activePane.webView.DispatchCustomEvent("dumber:ui:shortcut", map[string]any{
99+ "action": "omnibox-toggle",
100+ "paneId": h.getPaneId(h.app.activePane),
101+ "timestamp": time.Now().UnixMilli(),
102+ "source": "window-global",
103+ }); err != nil {
104+ log.Printf("[window-shortcuts] Failed to dispatch omnibox toggle: %v", err)
105+ }
106+}
107+
108+func (h *WindowShortcutHandler) handleFindToggle() {
109+ h.mu.Lock()
110+ defer h.mu.Unlock()
111+
112+ if time.Since(h.lastFindToggle) < 50*time.Millisecond {
113+ log.Printf("[window-shortcuts] Find toggle debounced")
114+ return
115+ }
116+ h.lastFindToggle = time.Now()
117+
118+ if h.app.activePane == nil || h.app.activePane.webView == nil {
119+ log.Printf("[window-shortcuts] No active pane for find")
120+ return
121+ }
122+
123+ log.Printf("[window-shortcuts] Find toggle -> pane %p", h.app.activePane.webView)
124+
125+ h.ensureGUIInActivePane("omnibox")
126+
127+ if err := h.app.activePane.webView.DispatchCustomEvent("dumber:ui:shortcut", map[string]any{
128+ "action": "omnibox-find",
129+ "paneId": h.getPaneId(h.app.activePane),
130+ "timestamp": time.Now().UnixMilli(),
131+ "source": "window-global",
132+ }); err != nil {
133+ log.Printf("[window-shortcuts] Failed to dispatch find toggle: %v", err)
134+ }
135+}
136+
137+func (h *WindowShortcutHandler) handleCopyURL() {
138+ h.mu.Lock()
139+ defer h.mu.Unlock()
140+
141+ if time.Since(h.lastCopyURL) < 100*time.Millisecond {
142+ return
143+ }
144+ h.lastCopyURL = time.Now()
145+
146+ if h.app.activePane == nil || h.app.activePane.webView == nil {
147+ log.Printf("[window-shortcuts] No active pane for copy URL")
148+ return
149+ }
150+
151+ log.Printf("[window-shortcuts] Copy URL -> pane %p", h.app.activePane.webView)
152+
153+ // Execute copy URL script directly on active pane
154+ script := `
155+ (async function() {
156+ const toast = (message, type) => {
157+ document.dispatchEvent(new CustomEvent('dumber:showToast', {
158+ detail: { message, duration: 2000, type }
159+ }));
160+ };
161+
162+ try {
163+ const url = window.location.href;
164+ await navigator.clipboard.writeText(url);
165+ console.log('🔗 Window shortcut copied URL:', url);
166+ toast('URL copied to clipboard!', 'success');
167+ } catch (error) {
168+ console.error('❌ Failed to copy URL:', error);
169+ toast('Failed to copy URL', 'error');
170+ }
171+ })();
172+ `
173+
174+ if err := h.app.activePane.webView.InjectScript(script); err != nil {
175+ log.Printf("[window-shortcuts] Failed to inject copy URL script: %v", err)
176+ }
177+}
178+
179+func (h *WindowShortcutHandler) handleDevTools() {
180+ h.mu.Lock()
181+ defer h.mu.Unlock()
182+
183+ if time.Since(h.lastDevTools) < 200*time.Millisecond {
184+ return
185+ }
186+ h.lastDevTools = time.Now()
187+
188+ if h.app.activePane == nil || h.app.activePane.webView == nil {
189+ log.Printf("[window-shortcuts] No active pane for devtools")
190+ return
191+ }
192+
193+ log.Printf("[window-shortcuts] DevTools -> pane %p", h.app.activePane.webView)
194+
195+ if err := h.app.activePane.webView.ShowDevTools(); err != nil {
196+ log.Printf("[window-shortcuts] Failed to show devtools: %v", err)
197+ }
198+}
199+
200+func (h *WindowShortcutHandler) ensureGUIInActivePane(component string) {
201+ if h.app.activePane == nil {
202+ return
203+ }
204+
205+ pane := h.app.activePane
206+ if !pane.HasGUI() {
207+ log.Printf("[window-shortcuts] Injecting GUI into pane %s for %s", pane.ID(), component)
208+ if h.app.workspace != nil {
209+ h.app.workspace.ensureGUIInPane(pane)
210+ }
211+ }
212+
213+ // Ensure specific component is available
214+ if !pane.HasGUIComponent(component) {
215+ log.Printf("[window-shortcuts] Ensuring %s component in pane %s", component, pane.ID())
216+ pane.SetGUIComponent(component, true)
217+ }
218+}
219+
220+func (h *WindowShortcutHandler) getPaneId(pane *BrowserPane) string {
221+ if pane == nil {
222+ return "unknown"
223+ }
224+ return pane.ID()
225+}
226+
227+// Cleanup releases resources
228+func (h *WindowShortcutHandler) Cleanup() {
229+ if h.shortcuts != nil {
230+ h.shortcuts.Cleanup()
231+ h.shortcuts = nil
232+ }
233+}
234+
235+// Error definitions
236+var (
237+ ErrFailedToInitialize = fmt.Errorf("failed to initialize window shortcuts")
238+)
A pkg/webkit/shortcuts_cgo.go +144 -0
@@ -0,0 +1,144 @@
1+//go:build webkit_cgo
2+
3+package webkit
4+
5+/*
6+#cgo pkg-config: gtk4 webkitgtk-6.0
7+#include <gtk/gtk.h>
8+#include <stdint.h>
9+#include <stdlib.h>
10+
11+extern void goHandleWindowShortcut(uintptr_t handle);
12+
13+static GtkShortcutController* create_global_shortcut_controller(GtkWindow* window) {
14+ if (!window) {
15+ return NULL;
16+ }
17+ GtkEventController* event_controller = gtk_shortcut_controller_new();
18+ if (!event_controller) {
19+ return NULL;
20+ }
21+ GtkShortcutController* controller = GTK_SHORTCUT_CONTROLLER(event_controller);
22+ gtk_shortcut_controller_set_scope(controller, GTK_SHORTCUT_SCOPE_GLOBAL);
23+ gtk_widget_add_controller(GTK_WIDGET(window), event_controller);
24+ return controller;
25+}
26+
27+static gboolean shortcut_callback_bridge(GtkWidget* widget, GVariant* args, gpointer user_data) {
28+ (void)widget;
29+ (void)args;
30+ uintptr_t handle = (uintptr_t)user_data;
31+ if (handle == 0) {
32+ return FALSE;
33+ }
34+ goHandleWindowShortcut(handle);
35+ return TRUE; // Indicate we handled the shortcut
36+}
37+
38+static void add_shortcut_to_controller(GtkShortcutController* controller,
39+ const char* trigger_str,
40+ uintptr_t handle) {
41+ if (!controller || !trigger_str || handle == 0) {
42+ return;
43+ }
44+ GtkShortcutTrigger* trigger = gtk_shortcut_trigger_parse_string(trigger_str);
45+ if (!trigger) {
46+ return;
47+ }
48+ GtkShortcutAction* action = gtk_callback_action_new((GtkShortcutFunc)shortcut_callback_bridge,
49+ (gpointer)handle,
50+ NULL);
51+ if (!action) {
52+ g_object_unref(trigger);
53+ return;
54+ }
55+ GtkShortcut* shortcut = gtk_shortcut_new(trigger, action);
56+ gtk_shortcut_controller_add_shortcut(controller, shortcut);
57+}
58+*/
59+import "C"
60+
61+import (
62+ "log"
63+ "sync"
64+ "sync/atomic"
65+ "unsafe"
66+)
67+
68+var (
69+ globalShortcutMu sync.Mutex
70+ globalShortcutCounter uint64
71+ globalShortcutCallbacks = make(map[uintptr]func())
72+)
73+
74+func registerWindowShortcutCallback(cb func()) uintptr {
75+ if cb == nil {
76+ return 0
77+ }
78+ id := uintptr(atomic.AddUint64(&globalShortcutCounter, 1))
79+ if id == 0 {
80+ // Should never happen, but guard against wrapping to 0
81+ id = uintptr(atomic.AddUint64(&globalShortcutCounter, 1))
82+ }
83+ globalShortcutMu.Lock()
84+ globalShortcutCallbacks[id] = cb
85+ globalShortcutMu.Unlock()
86+ return id
87+}
88+
89+func unregisterWindowShortcutCallback(id uintptr) {
90+ if id == 0 {
91+ return
92+ }
93+ globalShortcutMu.Lock()
94+ delete(globalShortcutCallbacks, id)
95+ globalShortcutMu.Unlock()
96+}
97+
98+func invokeWindowShortcutCallback(id uintptr) {
99+ if id == 0 {
100+ return
101+ }
102+ globalShortcutMu.Lock()
103+ cb := globalShortcutCallbacks[id]
104+ globalShortcutMu.Unlock()
105+ if cb == nil {
106+ log.Printf("[shortcuts] no handler for shortcut id=%d", id)
107+ return
108+ }
109+ cb()
110+}
111+
112+//export goHandleWindowShortcut
113+func goHandleWindowShortcut(handle C.uintptr_t) {
114+ invokeWindowShortcutCallback(uintptr(handle))
115+}
116+
117+// Go wrapper functions for CGO - these can be called from other Go files
118+
119+// CreateGlobalShortcutController creates a GTK4 global shortcut controller for a window
120+func CreateGlobalShortcutController(window uintptr) uintptr {
121+ if window == 0 {
122+ return 0
123+ }
124+ controller := C.create_global_shortcut_controller((*C.GtkWindow)(unsafe.Pointer(window)))
125+ return uintptr(unsafe.Pointer(controller))
126+}
127+
128+// AddShortcutToController adds a shortcut to the controller with a callback handle
129+func AddShortcutToController(controller uintptr, key string, handle uintptr) error {
130+ if controller == 0 || key == "" || handle == 0 {
131+ return ErrNotImplemented
132+ }
133+
134+ keyStr := C.CString(key)
135+ defer C.free(unsafe.Pointer(keyStr))
136+
137+ C.add_shortcut_to_controller(
138+ (*C.GtkShortcutController)(unsafe.Pointer(controller)),
139+ keyStr,
140+ C.uintptr_t(handle),
141+ )
142+
143+ return nil
144+}
@@ -0,0 +1,144 @@
1+//go:build webkit_cgo
2+
3+package webkit
4+
5+/*
6+#cgo pkg-config: gtk4 webkitgtk-6.0
7+#include <gtk/gtk.h>
8+#include <stdint.h>
9+#include <stdlib.h>
10+
11+extern void goHandleWindowShortcut(uintptr_t handle);
12+
13+static GtkShortcutController* create_global_shortcut_controller(GtkWindow* window) {
14+ if (!window) {
15+ return NULL;
16+ }
17+ GtkEventController* event_controller = gtk_shortcut_controller_new();
18+ if (!event_controller) {
19+ return NULL;
20+ }
21+ GtkShortcutController* controller = GTK_SHORTCUT_CONTROLLER(event_controller);
22+ gtk_shortcut_controller_set_scope(controller, GTK_SHORTCUT_SCOPE_GLOBAL);
23+ gtk_widget_add_controller(GTK_WIDGET(window), event_controller);
24+ return controller;
25+}
26+
27+static gboolean shortcut_callback_bridge(GtkWidget* widget, GVariant* args, gpointer user_data) {
28+ (void)widget;
29+ (void)args;
30+ uintptr_t handle = (uintptr_t)user_data;
31+ if (handle == 0) {
32+ return FALSE;
33+ }
34+ goHandleWindowShortcut(handle);
35+ return TRUE; // Indicate we handled the shortcut
36+}
37+
38+static void add_shortcut_to_controller(GtkShortcutController* controller,
39+ const char* trigger_str,
40+ uintptr_t handle) {
41+ if (!controller || !trigger_str || handle == 0) {
42+ return;
43+ }
44+ GtkShortcutTrigger* trigger = gtk_shortcut_trigger_parse_string(trigger_str);
45+ if (!trigger) {
46+ return;
47+ }
48+ GtkShortcutAction* action = gtk_callback_action_new((GtkShortcutFunc)shortcut_callback_bridge,
49+ (gpointer)handle,
50+ NULL);
51+ if (!action) {
52+ g_object_unref(trigger);
53+ return;
54+ }
55+ GtkShortcut* shortcut = gtk_shortcut_new(trigger, action);
56+ gtk_shortcut_controller_add_shortcut(controller, shortcut);
57+}
58+*/
59+import "C"
60+
61+import (
62+ "log"
63+ "sync"
64+ "sync/atomic"
65+ "unsafe"
66+)
67+
68+var (
69+ globalShortcutMu sync.Mutex
70+ globalShortcutCounter uint64
71+ globalShortcutCallbacks = make(map[uintptr]func())
72+)
73+
74+func registerWindowShortcutCallback(cb func()) uintptr {
75+ if cb == nil {
76+ return 0
77+ }
78+ id := uintptr(atomic.AddUint64(&globalShortcutCounter, 1))
79+ if id == 0 {
80+ // Should never happen, but guard against wrapping to 0
81+ id = uintptr(atomic.AddUint64(&globalShortcutCounter, 1))
82+ }
83+ globalShortcutMu.Lock()
84+ globalShortcutCallbacks[id] = cb
85+ globalShortcutMu.Unlock()
86+ return id
87+}
88+
89+func unregisterWindowShortcutCallback(id uintptr) {
90+ if id == 0 {
91+ return
92+ }
93+ globalShortcutMu.Lock()
94+ delete(globalShortcutCallbacks, id)
95+ globalShortcutMu.Unlock()
96+}
97+
98+func invokeWindowShortcutCallback(id uintptr) {
99+ if id == 0 {
100+ return
101+ }
102+ globalShortcutMu.Lock()
103+ cb := globalShortcutCallbacks[id]
104+ globalShortcutMu.Unlock()
105+ if cb == nil {
106+ log.Printf("[shortcuts] no handler for shortcut id=%d", id)
107+ return
108+ }
109+ cb()
110+}
111+
112+//export goHandleWindowShortcut
113+func goHandleWindowShortcut(handle C.uintptr_t) {
114+ invokeWindowShortcutCallback(uintptr(handle))
115+}
116+
117+// Go wrapper functions for CGO - these can be called from other Go files
118+
119+// CreateGlobalShortcutController creates a GTK4 global shortcut controller for a window
120+func CreateGlobalShortcutController(window uintptr) uintptr {
121+ if window == 0 {
122+ return 0
123+ }
124+ controller := C.create_global_shortcut_controller((*C.GtkWindow)(unsafe.Pointer(window)))
125+ return uintptr(unsafe.Pointer(controller))
126+}
127+
128+// AddShortcutToController adds a shortcut to the controller with a callback handle
129+func AddShortcutToController(controller uintptr, key string, handle uintptr) error {
130+ if controller == 0 || key == "" || handle == 0 {
131+ return ErrNotImplemented
132+ }
133+
134+ keyStr := C.CString(key)
135+ defer C.free(unsafe.Pointer(keyStr))
136+
137+ C.add_shortcut_to_controller(
138+ (*C.GtkShortcutController)(unsafe.Pointer(controller)),
139+ keyStr,
140+ C.uintptr_t(handle),
141+ )
142+
143+ return nil
144+}
A pkg/webkit/window_shortcuts.go +150 -0
@@ -0,0 +1,150 @@
1+//go:build webkit_cgo
2+
3+package webkit
4+
5+import (
6+ "log"
7+ "strings"
8+ "unsafe"
9+)
10+
11+// No C imports needed - using Go wrapper functions from shortcuts_cgo.go
12+
13+// WindowShortcuts manages global shortcuts at the window level
14+type WindowShortcuts struct {
15+ window *Window
16+ controller uintptr // *C.GtkShortcutController
17+ callbacks map[string]uintptr
18+}
19+
20+// InitializeGlobalShortcuts sets up the GTK4 global shortcut controller
21+func (w *Window) InitializeGlobalShortcuts() *WindowShortcuts {
22+ if w.win == nil {
23+ log.Printf("[window-shortcuts] Cannot initialize - invalid window")
24+ return nil
25+ }
26+
27+ controller := CreateGlobalShortcutController(uintptr(unsafe.Pointer(w.win)))
28+ if controller == 0 {
29+ log.Printf("[window-shortcuts] Failed to create shortcut controller")
30+ return nil
31+ }
32+
33+ ws := &WindowShortcuts{
34+ window: w,
35+ controller: controller,
36+ callbacks: make(map[string]uintptr),
37+ }
38+
39+ log.Printf("[window-shortcuts] Global shortcut controller initialized")
40+ return ws
41+}
42+
43+// RegisterGlobalShortcut registers a shortcut at the window level with global scope
44+func (ws *WindowShortcuts) RegisterGlobalShortcut(key string, callback func()) error {
45+ if ws == nil || ws.controller == 0 {
46+ return ErrNotImplemented
47+ }
48+
49+ // Convert key format (e.g., "ctrl+l" -> "<Control>l")
50+ gtkKey := convertToGtkFormat(key)
51+ if gtkKey == "" {
52+ log.Printf("[window-shortcuts] Invalid key format: %s", key)
53+ return ErrNotImplemented
54+ }
55+
56+ // Register callback and get handle
57+ handle := registerWindowShortcutCallback(callback)
58+ if handle == 0 {
59+ log.Printf("[window-shortcuts] Failed to register callback for %s", key)
60+ return ErrNotImplemented
61+ }
62+
63+ // Store handle for cleanup
64+ ws.callbacks[key] = handle
65+
66+ // Register with GTK using Go wrapper function
67+ if err := AddShortcutToController(ws.controller, gtkKey, handle); err != nil {
68+ log.Printf("[window-shortcuts] Failed to add shortcut to controller: %v", err)
69+ unregisterWindowShortcutCallback(handle)
70+ delete(ws.callbacks, key)
71+ return err
72+ }
73+
74+ log.Printf("[window-shortcuts] Registered global shortcut: %s -> %s", key, gtkKey)
75+ return nil
76+}
77+
78+// Cleanup unregisters all shortcuts and cleans up resources
79+func (ws *WindowShortcuts) Cleanup() {
80+ if ws == nil {
81+ return
82+ }
83+
84+ // Unregister all callbacks
85+ for key, handle := range ws.callbacks {
86+ unregisterWindowShortcutCallback(handle)
87+ log.Printf("[window-shortcuts] Unregistered shortcut: %s", key)
88+ }
89+
90+ ws.callbacks = nil
91+ ws.controller = 0
92+}
93+
94+// convertToGtkFormat converts common key formats to GTK shortcut format
95+func convertToGtkFormat(key string) string {
96+ // Convert from electron/webkit format to GTK format
97+ key = strings.ToLower(key)
98+
99+ // Handle cmdorctrl -> Control
100+ key = strings.ReplaceAll(key, "cmdorctrl+", "ctrl+")
101+ key = strings.ReplaceAll(key, "cmd+", "ctrl+")
102+
103+ // Build GTK format
104+ var parts []string
105+ components := strings.Split(key, "+")
106+
107+ for i, part := range components {
108+ if i == len(components)-1 {
109+ // Last part is the key
110+ switch part {
111+ case "f12":
112+ parts = append(parts, "F12")
113+ case "f5":
114+ parts = append(parts, "F5")
115+ case "l":
116+ parts = append(parts, "l")
117+ case "f":
118+ parts = append(parts, "f")
119+ case "r":
120+ parts = append(parts, "r")
121+ case "c":
122+ parts = append(parts, "c")
123+ case "left", "arrowleft":
124+ parts = append(parts, "Left")
125+ case "right", "arrowright":
126+ parts = append(parts, "Right")
127+ case "up", "arrowup":
128+ parts = append(parts, "Up")
129+ case "down", "arrowdown":
130+ parts = append(parts, "Down")
131+ default:
132+ parts = append(parts, part)
133+ }
134+ } else {
135+ // Modifier
136+ switch part {
137+ case "ctrl":
138+ parts = append(parts, "<Control>")
139+ case "shift":
140+ parts = append(parts, "<Shift>")
141+ case "alt":
142+ parts = append(parts, "<Alt>")
143+ case "super":
144+ parts = append(parts, "<Super>")
145+ }
146+ }
147+ }
148+
149+ return strings.Join(parts, "")
150+}
@@ -0,0 +1,150 @@
1+//go:build webkit_cgo
2+
3+package webkit
4+
5+import (
6+ "log"
7+ "strings"
8+ "unsafe"
9+)
10+
11+// No C imports needed - using Go wrapper functions from shortcuts_cgo.go
12+
13+// WindowShortcuts manages global shortcuts at the window level
14+type WindowShortcuts struct {
15+ window *Window
16+ controller uintptr // *C.GtkShortcutController
17+ callbacks map[string]uintptr
18+}
19+
20+// InitializeGlobalShortcuts sets up the GTK4 global shortcut controller
21+func (w *Window) InitializeGlobalShortcuts() *WindowShortcuts {
22+ if w.win == nil {
23+ log.Printf("[window-shortcuts] Cannot initialize - invalid window")
24+ return nil
25+ }
26+
27+ controller := CreateGlobalShortcutController(uintptr(unsafe.Pointer(w.win)))
28+ if controller == 0 {
29+ log.Printf("[window-shortcuts] Failed to create shortcut controller")
30+ return nil
31+ }
32+
33+ ws := &WindowShortcuts{
34+ window: w,
35+ controller: controller,
36+ callbacks: make(map[string]uintptr),
37+ }
38+
39+ log.Printf("[window-shortcuts] Global shortcut controller initialized")
40+ return ws
41+}
42+
43+// RegisterGlobalShortcut registers a shortcut at the window level with global scope
44+func (ws *WindowShortcuts) RegisterGlobalShortcut(key string, callback func()) error {
45+ if ws == nil || ws.controller == 0 {
46+ return ErrNotImplemented
47+ }
48+
49+ // Convert key format (e.g., "ctrl+l" -> "<Control>l")
50+ gtkKey := convertToGtkFormat(key)
51+ if gtkKey == "" {
52+ log.Printf("[window-shortcuts] Invalid key format: %s", key)
53+ return ErrNotImplemented
54+ }
55+
56+ // Register callback and get handle
57+ handle := registerWindowShortcutCallback(callback)
58+ if handle == 0 {
59+ log.Printf("[window-shortcuts] Failed to register callback for %s", key)
60+ return ErrNotImplemented
61+ }
62+
63+ // Store handle for cleanup
64+ ws.callbacks[key] = handle
65+
66+ // Register with GTK using Go wrapper function
67+ if err := AddShortcutToController(ws.controller, gtkKey, handle); err != nil {
68+ log.Printf("[window-shortcuts] Failed to add shortcut to controller: %v", err)
69+ unregisterWindowShortcutCallback(handle)
70+ delete(ws.callbacks, key)
71+ return err
72+ }
73+
74+ log.Printf("[window-shortcuts] Registered global shortcut: %s -> %s", key, gtkKey)
75+ return nil
76+}
77+
78+// Cleanup unregisters all shortcuts and cleans up resources
79+func (ws *WindowShortcuts) Cleanup() {
80+ if ws == nil {
81+ return
82+ }
83+
84+ // Unregister all callbacks
85+ for key, handle := range ws.callbacks {
86+ unregisterWindowShortcutCallback(handle)
87+ log.Printf("[window-shortcuts] Unregistered shortcut: %s", key)
88+ }
89+
90+ ws.callbacks = nil
91+ ws.controller = 0
92+}
93+
94+// convertToGtkFormat converts common key formats to GTK shortcut format
95+func convertToGtkFormat(key string) string {
96+ // Convert from electron/webkit format to GTK format
97+ key = strings.ToLower(key)
98+
99+ // Handle cmdorctrl -> Control
100+ key = strings.ReplaceAll(key, "cmdorctrl+", "ctrl+")
101+ key = strings.ReplaceAll(key, "cmd+", "ctrl+")
102+
103+ // Build GTK format
104+ var parts []string
105+ components := strings.Split(key, "+")
106+
107+ for i, part := range components {
108+ if i == len(components)-1 {
109+ // Last part is the key
110+ switch part {
111+ case "f12":
112+ parts = append(parts, "F12")
113+ case "f5":
114+ parts = append(parts, "F5")
115+ case "l":
116+ parts = append(parts, "l")
117+ case "f":
118+ parts = append(parts, "f")
119+ case "r":
120+ parts = append(parts, "r")
121+ case "c":
122+ parts = append(parts, "c")
123+ case "left", "arrowleft":
124+ parts = append(parts, "Left")
125+ case "right", "arrowright":
126+ parts = append(parts, "Right")
127+ case "up", "arrowup":
128+ parts = append(parts, "Up")
129+ case "down", "arrowdown":
130+ parts = append(parts, "Down")
131+ default:
132+ parts = append(parts, part)
133+ }
134+ } else {
135+ // Modifier
136+ switch part {
137+ case "ctrl":
138+ parts = append(parts, "<Control>")
139+ case "shift":
140+ parts = append(parts, "<Shift>")
141+ case "alt":
142+ parts = append(parts, "<Alt>")
143+ case "super":
144+ parts = append(parts, "<Super>")
145+ }
146+ }
147+ }
148+
149+ return strings.Join(parts, "")
150+}

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