1package browser
2
3import (
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
13type 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
27func 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
41func (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
51func (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 // Zoom shortcuts - global level for proper active pane targeting
62 {"ctrl+plus", h.handleZoomIn, "Zoom in"},
63 {"ctrl+equal", h.handleZoomIn, "Zoom in (=)"},
64 {"ctrl+minus", h.handleZoomOut, "Zoom out"},
65 {"ctrl+0", h.handleZoomReset, "Zoom reset"},
66 }
67
68 for _, shortcut := range shortcuts {
69 if err := h.shortcuts.RegisterGlobalShortcut(shortcut.key, shortcut.handler); err != nil {
70 log.Printf("[window-shortcuts] Failed to register %s (%s): %v",
71 shortcut.key, shortcut.desc, err)
72 return err
73 }
74 log.Printf("[window-shortcuts] Registered global shortcut: %s (%s)",
75 shortcut.key, shortcut.desc)
76 }
77
78 return nil
79}
80
81func (h *WindowShortcutHandler) handleOmniboxToggle() {
82 h.handleUIToggle(&h.lastOmniboxToggle, "omnibox", "omnibox-nav-toggle")
83}
84
85func (h *WindowShortcutHandler) handleFindToggle() {
86 h.handleUIToggle(&h.lastFindToggle, "find", "omnibox-find-toggle")
87}
88
89func (h *WindowShortcutHandler) handleCopyURL() {
90 h.mu.Lock()
91 defer h.mu.Unlock()
92
93 if time.Since(h.lastCopyURL) < 100*time.Millisecond {
94 return
95 }
96 h.lastCopyURL = time.Now()
97
98 if h.app.activePane == nil || h.app.activePane.webView == nil {
99 log.Printf("[window-shortcuts] No active pane for copy URL")
100 return
101 }
102
103 log.Printf("[window-shortcuts] Copy URL -> pane %p", h.app.activePane.webView)
104
105 // Execute copy URL script directly on active pane
106 script := `
107 (async function() {
108 const toast = (message, type) => {
109 document.dispatchEvent(new CustomEvent('dumber:showToast', {
110 detail: { message, duration: 2000, type }
111 }));
112 };
113
114 try {
115 const url = window.location.href;
116 await navigator.clipboard.writeText(url);
117 console.log('🔗 Window shortcut copied URL:', url);
118 toast('URL copied to clipboard!', 'success');
119 } catch (error) {
120 console.error('❌ Failed to copy URL:', error);
121 toast('Failed to copy URL', 'error');
122 }
123 })();
124 `
125
126 if err := h.app.activePane.webView.InjectScript(script); err != nil {
127 log.Printf("[window-shortcuts] Failed to inject copy URL script: %v", err)
128 }
129}
130
131func (h *WindowShortcutHandler) handleDevTools() {
132 h.mu.Lock()
133 defer h.mu.Unlock()
134
135 if time.Since(h.lastDevTools) < 200*time.Millisecond {
136 return
137 }
138 h.lastDevTools = time.Now()
139
140 if h.app.activePane == nil || h.app.activePane.webView == nil {
141 log.Printf("[window-shortcuts] No active pane for devtools")
142 return
143 }
144
145 log.Printf("[window-shortcuts] DevTools -> pane %p", h.app.activePane.webView)
146
147 if err := h.app.activePane.webView.ShowDevTools(); err != nil {
148 log.Printf("[window-shortcuts] Failed to show devtools: %v", err)
149 }
150}
151
152// handleZoomIn increases zoom level by 10%
153func (h *WindowShortcutHandler) handleZoomIn() {
154 h.handleZoom("in", 1.1)
155}
156
157// handleZoomOut decreases zoom level by 10%
158func (h *WindowShortcutHandler) handleZoomOut() {
159 h.handleZoom("out", 1.0/1.1)
160}
161
162// handleZoomReset resets zoom to 100%
163func (h *WindowShortcutHandler) handleZoomReset() {
164 h.handleZoom("reset", 1.0)
165}
166
167// handleZoom applies zoom changes to the active pane
168func (h *WindowShortcutHandler) handleZoom(action string, multiplier float64) {
169 h.mu.Lock()
170 defer h.mu.Unlock()
171
172 if h.app.activePane == nil || h.app.activePane.webView == nil {
173 log.Printf("[window-shortcuts] No active pane for zoom %s", action)
174 return
175 }
176
177 activeWebView := h.app.activePane.webView
178 log.Printf("[window-shortcuts] Zoom %s -> pane %p", action, activeWebView)
179
180 // Get current zoom level
181 currentZoom, err := activeWebView.GetZoom()
182 if err != nil {
183 log.Printf("[window-shortcuts] Failed to get current zoom: %v", err)
184 return
185 }
186
187 // Calculate new zoom level
188 var newZoom float64
189 if action == "reset" {
190 newZoom = 1.0
191 } else {
192 newZoom = currentZoom * multiplier
193 // Apply zoom limits
194 if newZoom < 0.25 {
195 newZoom = 0.25
196 }
197 if newZoom > 5.0 {
198 newZoom = 5.0
199 }
200 }
201
202 log.Printf("[window-shortcuts] Zoom %s: %.2f -> %.2f", action, currentZoom, newZoom)
203
204 // Apply zoom to active pane
205 if err := activeWebView.SetZoom(newZoom); err != nil {
206 log.Printf("[window-shortcuts] Failed to set zoom: %v", err)
207 }
208}
209
210func (h *WindowShortcutHandler) ensureGUIInActivePane(component string) {
211 if h.app.activePane == nil {
212 return
213 }
214
215 pane := h.app.activePane
216 if !pane.HasGUI() {
217 log.Printf("[window-shortcuts] Injecting GUI into pane %s for %s", pane.ID(), component)
218 if h.app.workspace != nil {
219 h.app.workspace.ensureGUIInPane(pane)
220 }
221 }
222
223 // Ensure specific component is available
224 if !pane.HasGUIComponent(component) {
225 log.Printf("[window-shortcuts] Ensuring %s component in pane %s", component, pane.ID())
226 pane.SetGUIComponent(component, true)
227 }
228}
229
230func (h *WindowShortcutHandler) getPaneId(pane *BrowserPane) string {
231 if pane == nil {
232 return "unknown"
233 }
234 return pane.ID()
235}
236
237func (h *WindowShortcutHandler) handleUIToggle(lastToggle *time.Time, featureName, action string) {
238 h.mu.Lock()
239 defer h.mu.Unlock()
240
241 if time.Since(*lastToggle) < 50*time.Millisecond {
242 log.Printf("[window-shortcuts] %s toggle debounced", featureName)
243 return
244 }
245 *lastToggle = time.Now()
246
247 if h.app.activePane == nil || h.app.activePane.webView == nil {
248 log.Printf("[window-shortcuts] No active pane for %s", featureName)
249 return
250 }
251
252 log.Printf("[window-shortcuts] %s toggle -> pane %p", featureName, h.app.activePane.webView)
253
254 h.ensureGUIInActivePane("omnibox")
255
256 if err := h.app.activePane.webView.DispatchCustomEvent("dumber:ui:shortcut", map[string]any{
257 "action": action,
258 "paneId": h.getPaneId(h.app.activePane),
259 "timestamp": time.Now().UnixMilli(),
260 "source": "window-global",
261 }); err != nil {
262 log.Printf("[window-shortcuts] Failed to dispatch %s toggle: %v", featureName, err)
263 }
264}
265
266// Cleanup releases resources
267func (h *WindowShortcutHandler) Cleanup() {
268 if h.shortcuts != nil {
269 h.shortcuts.Cleanup()
270 h.shortcuts = nil
271 }
272}
273
274// Error definitions
275var (
276 ErrFailedToInitialize = fmt.Errorf("failed to initialize window shortcuts")
277)