1package browser
2
3import (
4 "fmt"
5 "log"
6 "sync"
7 "time"
8
9 "github.com/bnema/dumber/internal/app/messaging"
10 "github.com/bnema/dumber/pkg/webkit"
11)
12
13// WindowShortcutHandler manages global shortcuts at the window level
14type WindowShortcutHandler struct {
15 window *webkit.Window
16 app *BrowserApp
17 shortcuts *webkit.WindowShortcuts
18 mu sync.Mutex
19
20 // Debounce tracking
21 lastOmniboxToggle time.Time
22 lastFindToggle time.Time
23 lastCopyURL time.Time
24 lastDevTools time.Time
25 lastPrint time.Time
26}
27
28// NewWindowShortcutHandler creates a new window-level shortcut handler
29func NewWindowShortcutHandler(window *webkit.Window, app *BrowserApp) *WindowShortcutHandler {
30 h := &WindowShortcutHandler{
31 window: window,
32 app: app,
33 }
34
35 if err := h.initialize(); err != nil {
36 log.Printf("[window-shortcuts] Failed to initialize: %v", err)
37 return nil
38 }
39
40 return h
41}
42
43func (h *WindowShortcutHandler) initialize() error {
44 // Initialize GTK4 global shortcuts
45 h.shortcuts = h.window.InitializeGlobalShortcuts()
46 if h.shortcuts == nil {
47 return ErrFailedToInitialize
48 }
49
50 return h.registerGlobalShortcuts()
51}
52
53func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
54 shortcuts := []struct {
55 key string
56 handler func()
57 desc string
58 }{
59 {"ctrl+l", h.handleOmniboxToggle, "Omnibox toggle"},
60 {"ctrl+f", h.handleFindToggle, "Find in page"},
61 {"ctrl+shift+c", h.handleCopyURL, "Copy URL"},
62 {"ctrl+shift+p", h.handlePrint, "Print page"},
63 {"ctrl+w", h.handleClosePane, "Close current pane"},
64 {"F12", h.handleDevTools, "Developer tools"},
65 // Zoom shortcuts - global level for proper active pane targeting
66 {"ctrl+plus", h.handleZoomIn, "Zoom in"},
67 {"ctrl+equal", h.handleZoomIn, "Zoom in (=)"},
68 {"ctrl+minus", h.handleZoomOut, "Zoom out"},
69 {"ctrl+0", h.handleZoomReset, "Zoom reset"},
70 // Workspace navigation shortcuts - global level for proper active pane targeting
71 {"alt+ArrowLeft", func() { h.handleWorkspaceNavigation("left") }, "Navigate left pane"},
72 {"alt+ArrowRight", func() { h.handleWorkspaceNavigation("right") }, "Navigate right pane"},
73 {"alt+ArrowUp", func() { h.handleWorkspaceNavigation("up") }, "Navigate up pane"},
74 {"alt+ArrowDown", func() { h.handleWorkspaceNavigation("down") }, "Navigate down pane"},
75 }
76
77 for _, shortcut := range shortcuts {
78 if err := h.shortcuts.RegisterGlobalShortcut(shortcut.key, shortcut.handler); err != nil {
79 log.Printf("[window-shortcuts] Failed to register %s (%s): %v",
80 shortcut.key, shortcut.desc, err)
81 return err
82 }
83 log.Printf("[window-shortcuts] Registered global shortcut: %s (%s)",
84 shortcut.key, shortcut.desc)
85 }
86
87 return nil
88}
89
90func (h *WindowShortcutHandler) handleOmniboxToggle() {
91 h.handleUIToggle(&h.lastOmniboxToggle, "omnibox", "omnibox-nav-toggle")
92}
93
94func (h *WindowShortcutHandler) handleFindToggle() {
95 h.handleUIToggle(&h.lastFindToggle, "find", "omnibox-find-toggle")
96}
97
98func (h *WindowShortcutHandler) handleCopyURL() {
99 h.mu.Lock()
100 defer h.mu.Unlock()
101
102 if time.Since(h.lastCopyURL) < 100*time.Millisecond {
103 return
104 }
105 h.lastCopyURL = time.Now()
106
107 if h.app.activePane == nil || h.app.activePane.webView == nil {
108 log.Printf("[window-shortcuts] No active pane for copy URL")
109 return
110 }
111
112 log.Printf("[window-shortcuts] Copy URL -> pane %p", h.app.activePane.webView)
113
114 // Execute copy URL script directly on active pane
115 script := `
116 (async function() {
117 const toast = (message, type) => {
118 document.dispatchEvent(new CustomEvent('dumber:showToast', {
119 detail: { message, duration: 2000, type }
120 }));
121 };
122
123 try {
124 const url = window.location.href;
125 await navigator.clipboard.writeText(url);
126 console.log('🔗 Window shortcut copied URL:', url);
127 toast('URL copied to clipboard!', 'success');
128 } catch (error) {
129 console.error('❌ Failed to copy URL:', error);
130 toast('Failed to copy URL', 'error');
131 }
132 })();
133 `
134
135 if err := h.app.activePane.webView.InjectScript(script); err != nil {
136 log.Printf("[window-shortcuts] Failed to inject copy URL script: %v", err)
137 }
138}
139
140func (h *WindowShortcutHandler) handleDevTools() {
141 h.mu.Lock()
142 defer h.mu.Unlock()
143
144 if time.Since(h.lastDevTools) < 200*time.Millisecond {
145 return
146 }
147 h.lastDevTools = time.Now()
148
149 if h.app.activePane == nil || h.app.activePane.webView == nil {
150 log.Printf("[window-shortcuts] No active pane for devtools")
151 return
152 }
153
154 log.Printf("[window-shortcuts] DevTools -> pane %p", h.app.activePane.webView)
155
156 if err := h.app.activePane.webView.ShowDevTools(); err != nil {
157 log.Printf("[window-shortcuts] Failed to show devtools: %v", err)
158 }
159}
160
161func (h *WindowShortcutHandler) handlePrint() {
162 h.mu.Lock()
163 defer h.mu.Unlock()
164
165 if time.Since(h.lastPrint) < 200*time.Millisecond {
166 return
167 }
168 h.lastPrint = time.Now()
169
170 if h.app.activePane == nil || h.app.activePane.webView == nil {
171 log.Printf("[window-shortcuts] No active pane for print")
172 return
173 }
174
175 log.Printf("[window-shortcuts] Print -> pane %p", h.app.activePane.webView)
176
177 if err := h.app.activePane.webView.ShowPrintDialog(); err != nil {
178 log.Printf("[window-shortcuts] Failed to show print dialog: %v", err)
179 }
180}
181
182// handleZoomIn increases zoom level by 10%
183func (h *WindowShortcutHandler) handleZoomIn() {
184 h.handleZoom("in", 1.1)
185}
186
187// handleZoomOut decreases zoom level by 10%
188func (h *WindowShortcutHandler) handleZoomOut() {
189 h.handleZoom("out", 1.0/1.1)
190}
191
192// handleZoomReset resets zoom to 100%
193func (h *WindowShortcutHandler) handleZoomReset() {
194 h.handleZoom("reset", 1.0)
195}
196
197// handleZoom applies zoom changes to the active pane
198func (h *WindowShortcutHandler) handleZoom(action string, multiplier float64) {
199 h.mu.Lock()
200 defer h.mu.Unlock()
201
202 if h.app.activePane == nil || h.app.activePane.webView == nil {
203 log.Printf("[window-shortcuts] No active pane for zoom %s", action)
204 return
205 }
206
207 activeWebView := h.app.activePane.webView
208 log.Printf("[window-shortcuts] Zoom %s -> pane %p", action, activeWebView)
209
210 // Get current zoom level
211 currentZoom, err := activeWebView.GetZoom()
212 if err != nil {
213 log.Printf("[window-shortcuts] Failed to get current zoom: %v", err)
214 return
215 }
216
217 // Calculate new zoom level
218 var newZoom float64
219 if action == "reset" {
220 newZoom = 1.0
221 } else {
222 newZoom = currentZoom * multiplier
223 // Apply zoom limits
224 if newZoom < 0.25 {
225 newZoom = 0.25
226 }
227 if newZoom > 5.0 {
228 newZoom = 5.0
229 }
230 }
231
232 log.Printf("[window-shortcuts] Zoom %s: %.2f -> %.2f", action, currentZoom, newZoom)
233
234 // Apply zoom to active pane
235 if err := activeWebView.SetZoom(newZoom); err != nil {
236 log.Printf("[window-shortcuts] Failed to set zoom: %v", err)
237 }
238}
239
240func (h *WindowShortcutHandler) ensureGUIInActivePane(component string) {
241 if h.app.activePane == nil {
242 return
243 }
244
245 pane := h.app.activePane
246 if !pane.HasGUI() {
247 log.Printf("[window-shortcuts] Injecting GUI into pane %s for %s", pane.ID(), component)
248 if h.app.workspace != nil {
249 h.app.workspace.ensureGUIInPane(pane)
250 }
251 }
252
253 // Ensure specific component is available
254 if !pane.HasGUIComponent(component) {
255 log.Printf("[window-shortcuts] Ensuring %s component in pane %s", component, pane.ID())
256 pane.SetGUIComponent(component, true)
257 }
258}
259
260func (h *WindowShortcutHandler) getWebViewId(pane *BrowserPane) string {
261 if pane == nil || pane.webView == nil {
262 return "unknown"
263 }
264 return pane.webView.ID()
265}
266
267func (h *WindowShortcutHandler) handleUIToggle(lastToggle *time.Time, featureName, action string) {
268 h.mu.Lock()
269 defer h.mu.Unlock()
270
271 if time.Since(*lastToggle) < 50*time.Millisecond {
272 log.Printf("[window-shortcuts] %s toggle debounced", featureName)
273 return
274 }
275 *lastToggle = time.Now()
276
277 if h.app.activePane == nil || h.app.activePane.webView == nil {
278 log.Printf("[window-shortcuts] No active pane for %s", featureName)
279 return
280 }
281
282 log.Printf("[window-shortcuts] %s toggle -> pane %p", featureName, h.app.activePane.webView)
283
284 h.ensureGUIInActivePane("omnibox")
285
286 if err := h.app.activePane.webView.DispatchCustomEvent("dumber:ui:shortcut", map[string]any{
287 "action": action,
288 "webviewId": h.getWebViewId(h.app.activePane),
289 "timestamp": time.Now().UnixMilli(),
290 "source": "window-global",
291 }); err != nil {
292 log.Printf("[window-shortcuts] Failed to dispatch %s toggle: %v", featureName, err)
293 }
294}
295
296func (h *WindowShortcutHandler) handleWorkspaceNavigation(direction string) {
297 if h.app == nil || h.app.workspace == nil {
298 log.Printf("[window-shortcuts] No workspace for navigation")
299 return
300 }
301
302 log.Printf("[window-shortcuts] Workspace navigation: %s", direction)
303
304 if h.app.workspace.FocusNeighbor(direction) {
305 log.Printf("[window-shortcuts] Workspace navigation %s successful", direction)
306 } else {
307 log.Printf("[window-shortcuts] Workspace navigation %s failed", direction)
308 }
309}
310
311func (h *WindowShortcutHandler) handleClosePane() {
312 if h.app == nil || h.app.workspace == nil {
313 log.Printf("[window-shortcuts] No workspace for close pane")
314 return
315 }
316
317 // Find the currently active WebView
318 var activeWebView *webkit.WebView
319 log.Printf("[window-shortcuts] Searching for active WebView among %d WebViews", len(h.app.workspace.viewToNode))
320 for webView := range h.app.workspace.viewToNode {
321 if webView != nil {
322 isActive := webView.IsActive()
323 log.Printf("[window-shortcuts] WebView %s: IsActive=%t", webView.ID(), isActive)
324 if isActive {
325 activeWebView = webView
326 log.Printf("[window-shortcuts] Found active WebView: %s", webView.ID())
327 break
328 }
329 }
330 }
331
332 if activeWebView == nil {
333 log.Printf("[window-shortcuts] No active WebView found, using workspace closeCurrentPane")
334 h.app.workspace.closeCurrentPane()
335 return
336 }
337
338 // Check if the active WebView is a popup
339 node := h.app.workspace.viewToNode[activeWebView]
340 if node != nil && node.isPopup {
341 log.Printf("[window-shortcuts] Closing popup via OnWorkspaceMessage")
342 // Use the proper close-popup message for popups
343 msg := messaging.Message{
344 Event: "close-popup",
345 WebViewID: activeWebView.ID(),
346 Reason: "user-ctrl-w",
347 }
348 h.app.workspace.OnWorkspaceMessage(activeWebView, msg)
349 } else {
350 log.Printf("[window-shortcuts] Closing regular pane via workspace closeCurrentPane")
351 // Use regular close for non-popup panes
352 h.app.workspace.closeCurrentPane()
353 }
354}
355
356// Cleanup releases resources
357func (h *WindowShortcutHandler) Cleanup() {
358 if h.shortcuts != nil {
359 h.shortcuts.Cleanup()
360 h.shortcuts = nil
361 }
362}
363
364// Error definitions
365var (
366 ErrFailedToInitialize = fmt.Errorf("failed to initialize window shortcuts")
367)