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 // Create WindowShortcuts manager
45 h.shortcuts = webkit.NewWindowShortcuts(h.window)
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+t", h.handleNewTab, "New tab (no-op)"},
64 {"ctrl+w", h.handleClosePane, "Close current pane"},
65 {"F12", h.handleDevTools, "Developer tools"},
66 // Zoom shortcuts - global level for proper active pane targeting
67 {"ctrl+plus", h.handleZoomIn, "Zoom in"},
68 {"ctrl+equal", h.handleZoomIn, "Zoom in (=)"},
69 {"ctrl+minus", h.handleZoomOut, "Zoom out"},
70 {"ctrl+0", h.handleZoomReset, "Zoom reset"},
71 // Workspace navigation shortcuts - global level for proper active pane targeting
72 {"alt+ArrowLeft", func() { h.handleWorkspaceNavigation(DirectionLeft) }, "Navigate left pane"},
73 {"alt+ArrowRight", func() { h.handleWorkspaceNavigation(DirectionRight) }, "Navigate right pane"},
74 {"alt+ArrowUp", func() { h.handleWorkspaceNavigation(DirectionUp) }, "Navigate up pane"},
75 {"alt+ArrowDown", func() { h.handleWorkspaceNavigation(DirectionDown) }, "Navigate down pane"},
76 }
77
78 for _, shortcut := range shortcuts {
79 h.shortcuts.RegisterShortcut(shortcut.key, shortcut.handler)
80 log.Printf("[window-shortcuts] Registered global shortcut: %s (%s)",
81 shortcut.key, shortcut.desc)
82 }
83
84 return nil
85}
86
87func (h *WindowShortcutHandler) handleOmniboxToggle() {
88 h.handleUIToggle(&h.lastOmniboxToggle, "omnibox", "omnibox-nav-toggle")
89}
90
91func (h *WindowShortcutHandler) handleFindToggle() {
92 h.handleUIToggle(&h.lastFindToggle, "find", "omnibox-find-toggle")
93}
94
95func (h *WindowShortcutHandler) handleCopyURL() {
96 h.mu.Lock()
97 defer h.mu.Unlock()
98
99 if time.Since(h.lastCopyURL) < 100*time.Millisecond {
100 return
101 }
102 h.lastCopyURL = time.Now()
103
104 if h.app.activePane == nil || h.app.activePane.webView == nil {
105 log.Printf("[window-shortcuts] No active pane for copy URL")
106 return
107 }
108
109 log.Printf("[window-shortcuts] Copy URL -> pane %p", h.app.activePane.webView)
110
111 // Execute copy URL script directly on active pane
112 script := `
113 (async function() {
114 const toast = (message, type) => {
115 document.dispatchEvent(new CustomEvent('dumber:showToast', {
116 detail: { message, duration: 2000, type }
117 }));
118 };
119
120 try {
121 const url = window.location.href;
122 await navigator.clipboard.writeText(url);
123 console.log('🔗 Window shortcut copied URL:', url);
124 toast('URL copied to clipboard!', 'success');
125 } catch (error) {
126 console.error('❌ Failed to copy URL:', error);
127 toast('Failed to copy URL', 'error');
128 }
129 })();
130 `
131
132 if err := h.app.activePane.webView.InjectScript(script); err != nil {
133 log.Printf("[window-shortcuts] Failed to inject copy URL script: %v", err)
134 }
135}
136
137func (h *WindowShortcutHandler) handleDevTools() {
138 h.mu.Lock()
139 defer h.mu.Unlock()
140
141 if time.Since(h.lastDevTools) < 200*time.Millisecond {
142 return
143 }
144 h.lastDevTools = time.Now()
145
146 if h.app.activePane == nil || h.app.activePane.webView == nil {
147 log.Printf("[window-shortcuts] No active pane for devtools")
148 return
149 }
150
151 log.Printf("[window-shortcuts] DevTools -> pane %p", h.app.activePane.webView)
152
153 if err := h.app.activePane.webView.ShowDevTools(); err != nil {
154 log.Printf("[window-shortcuts] Failed to show devtools: %v", err)
155 }
156}
157
158func (h *WindowShortcutHandler) handlePrint() {
159 h.mu.Lock()
160 defer h.mu.Unlock()
161
162 if time.Since(h.lastPrint) < 200*time.Millisecond {
163 return
164 }
165 h.lastPrint = time.Now()
166
167 if h.app.activePane == nil || h.app.activePane.webView == nil {
168 log.Printf("[window-shortcuts] No active pane for print")
169 return
170 }
171
172 log.Printf("[window-shortcuts] Print -> pane %p", h.app.activePane.webView)
173
174 if err := h.app.activePane.webView.ShowPrintDialog(); err != nil {
175 log.Printf("[window-shortcuts] Failed to show print dialog: %v", err)
176 }
177}
178
179// handleZoomIn increases zoom level by 10%
180func (h *WindowShortcutHandler) handleZoomIn() {
181 h.handleZoom("in", 1.1)
182}
183
184// handleZoomOut decreases zoom level by 10%
185func (h *WindowShortcutHandler) handleZoomOut() {
186 h.handleZoom("out", 1.0/1.1)
187}
188
189// handleZoomReset resets zoom to 100%
190func (h *WindowShortcutHandler) handleZoomReset() {
191 h.handleZoom("reset", 1.0)
192}
193
194// handleZoom applies zoom changes to the active pane
195func (h *WindowShortcutHandler) handleZoom(action string, multiplier float64) {
196 h.mu.Lock()
197 defer h.mu.Unlock()
198
199 if h.app.activePane == nil || h.app.activePane.webView == nil {
200 log.Printf("[window-shortcuts] No active pane for zoom %s", action)
201 return
202 }
203
204 activeWebView := h.app.activePane.webView
205 log.Printf("[window-shortcuts] Zoom %s -> pane %p", action, activeWebView)
206
207 // Get current zoom level
208 currentZoom := activeWebView.GetZoom()
209
210 // Calculate new zoom level
211 var newZoom float64
212 if action == "reset" {
213 newZoom = 1.0
214 } else {
215 newZoom = currentZoom * multiplier
216 // Apply zoom limits
217 if newZoom < 0.25 {
218 newZoom = 0.25
219 }
220 if newZoom > 5.0 {
221 newZoom = 5.0
222 }
223 }
224
225 log.Printf("[window-shortcuts] Zoom %s: %.2f -> %.2f", action, currentZoom, newZoom)
226
227 // Apply zoom to active pane
228 if err := activeWebView.SetZoom(newZoom); err != nil {
229 log.Printf("[window-shortcuts] Failed to set zoom: %v", err)
230 return
231 }
232
233 // Ensure GUI/toast component is loaded before showing toast
234 h.ensureGUIInActivePane("toast")
235
236 // Show zoom toast notification
237 toastScript := fmt.Sprintf(`
238 (function() {
239 try {
240 if (typeof window.__dumber_showZoomToast === 'function') {
241 window.__dumber_showZoomToast(%f);
242 }
243 } catch (e) {
244 console.error('[window-shortcuts] Failed to show zoom toast:', e);
245 }
246 })();
247 `, newZoom)
248
249 if err := activeWebView.InjectScript(toastScript); err != nil {
250 log.Printf("[window-shortcuts] Failed to show zoom toast: %v", err)
251 }
252}
253
254func (h *WindowShortcutHandler) ensureGUIInActivePane(component string) {
255 if h.app.activePane == nil {
256 return
257 }
258
259 pane := h.app.activePane
260 if !pane.HasGUI() {
261 log.Printf("[window-shortcuts] Injecting GUI into pane %s for %s", pane.ID(), component)
262 if h.app.workspace != nil {
263 h.app.workspace.ensureGUIInPane(pane)
264 }
265 }
266
267 // Ensure specific component is available
268 if !pane.HasGUIComponent(component) {
269 log.Printf("[window-shortcuts] Ensuring %s component in pane %s", component, pane.ID())
270 pane.SetGUIComponent(component, true)
271 }
272}
273
274func (h *WindowShortcutHandler) getWebViewId(pane *BrowserPane) string {
275 if pane == nil || pane.webView == nil {
276 return "unknown"
277 }
278 return pane.webView.IDString()
279}
280
281func (h *WindowShortcutHandler) handleUIToggle(lastToggle *time.Time, featureName, action string) {
282 h.mu.Lock()
283 defer h.mu.Unlock()
284
285 if time.Since(*lastToggle) < 50*time.Millisecond {
286 log.Printf("[window-shortcuts] %s toggle debounced", featureName)
287 return
288 }
289 *lastToggle = time.Now()
290
291 if h.app.activePane == nil || h.app.activePane.webView == nil {
292 log.Printf("[window-shortcuts] No active pane for %s", featureName)
293 return
294 }
295
296 log.Printf("[window-shortcuts] %s toggle -> pane %p", featureName, h.app.activePane.webView)
297
298 h.ensureGUIInActivePane("omnibox")
299
300 if err := h.app.activePane.webView.DispatchCustomEvent("dumber:ui:shortcut", map[string]any{
301 "action": action,
302 "webviewId": h.getWebViewId(h.app.activePane),
303 "timestamp": time.Now().UnixMilli(),
304 "source": "window-global",
305 }); err != nil {
306 log.Printf("[window-shortcuts] Failed to dispatch %s toggle: %v", featureName, err)
307 }
308}
309
310func (h *WindowShortcutHandler) handleWorkspaceNavigation(direction string) {
311 if h.app == nil || h.app.workspace == nil {
312 log.Printf("[window-shortcuts] No workspace for navigation")
313 return
314 }
315
316 log.Printf("[window-shortcuts] Workspace navigation: %s", direction)
317
318 if h.app.workspace.FocusNeighbor(direction) {
319 log.Printf("[window-shortcuts] Workspace navigation %s successful", direction)
320 } else {
321 log.Printf("[window-shortcuts] Workspace navigation %s failed", direction)
322 }
323}
324
325func (h *WindowShortcutHandler) handleClosePane() {
326 if h.app == nil || h.app.workspace == nil {
327 log.Printf("[window-shortcuts] No workspace for close pane")
328 return
329 }
330
331 // Find the currently active WebView
332 var activeWebView *webkit.WebView
333 log.Printf("[window-shortcuts] Searching for active WebView among %d WebViews", len(h.app.workspace.viewToNode))
334 for webView := range h.app.workspace.viewToNode {
335 if webView != nil {
336 isActive := webView.IsActive()
337 log.Printf("[window-shortcuts] WebView %d: IsActive=%t", webView.ID(), isActive)
338 if isActive {
339 activeWebView = webView
340 log.Printf("[window-shortcuts] Found active WebView: %d", webView.ID())
341 break
342 }
343 }
344 }
345
346 if activeWebView == nil {
347 log.Printf("[window-shortcuts] No active WebView found, using workspace closeCurrentPane")
348 h.app.workspace.closeCurrentPane()
349 return
350 }
351
352 // Check if the active WebView is a popup
353 node := h.app.workspace.viewToNode[activeWebView]
354 if node != nil && node.isPopup {
355 log.Printf("[window-shortcuts] Closing popup via OnWorkspaceMessage")
356 // Use the proper close-popup message for popups
357 msg := messaging.Message{
358 Event: "close-popup",
359 WebViewID: activeWebView.IDString(),
360 Reason: "user-ctrl-w",
361 }
362 h.app.workspace.OnWorkspaceMessage(activeWebView, msg)
363 } else {
364 log.Printf("[window-shortcuts] Closing regular pane via workspace closeCurrentPane")
365 // Use regular close for non-popup panes
366 h.app.workspace.closeCurrentPane()
367 }
368}
369
370// handleNewTab is a no-op handler for Ctrl+T
371// This prevents the default browser behavior and allows for future implementation
372func (h *WindowShortcutHandler) handleNewTab() {
373 log.Printf("[window-shortcuts] Ctrl+T pressed (no-op - tabs not yet implemented)")
374 // TODO: Implement new tab functionality
375}
376
377// Cleanup releases resources
378func (h *WindowShortcutHandler) Cleanup() {
379 // WindowShortcuts doesn't require explicit cleanup
380 h.shortcuts = nil
381}
382
383// Error definitions
384var (
385 ErrFailedToInitialize = fmt.Errorf("failed to initialize window shortcuts")
386)