1package dispatcher
2
3import (
4 "context"
5 "fmt"
6
7 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/application/usecase"
9 "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/bnema/dumber/internal/logging"
11 "github.com/bnema/dumber/internal/ui/component"
12 "github.com/bnema/dumber/internal/ui/coordinator"
13 "github.com/bnema/dumber/internal/ui/input"
14 "github.com/bnema/puregotk/v4/glib"
15)
16
17const (
18 historySystemViewURL = "dumb://history"
19 favoritesSystemViewURL = "dumb://favorites"
20 configSystemViewURL = "dumb://config"
21)
22
23type KeyboardActions struct {
24 NewTab func(context.Context) error
25 CloseTab func(context.Context) error
26 NextTab func(context.Context) error
27 PreviousTab func(context.Context) error
28 SwitchLastTab func(context.Context) error
29 SwitchTabIndex func(context.Context, int) error
30 ActiveWebView func(context.Context) port.WebView
31}
32
33// KeyboardDispatcher routes keyboard actions to appropriate coordinators.
34type KeyboardDispatcher struct {
35 actions KeyboardActions
36 wsCoord *coordinator.WorkspaceCoordinator
37 navCoord *coordinator.NavigationCoordinator
38 zoomUC *usecase.ManageZoomUseCase
39 copyURLUC *usecase.CopyURLUseCase
40 actionHandlers map[input.Action]func(ctx context.Context) error
41 onQuit func()
42 onFindOpen func(ctx context.Context) error
43 onFindNext func(ctx context.Context) error
44 onFindPrev func(ctx context.Context) error
45 onFindClose func(ctx context.Context) error
46 activePaneID func(ctx context.Context) entity.PaneID
47 onSessionOpen func(ctx context.Context, paneID entity.PaneID) error
48 onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error
49 onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error
50 onToggleFloating func(ctx context.Context) error
51 onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error
52}
53
54// NewKeyboardDispatcher creates a new KeyboardDispatcher.
55func NewKeyboardDispatcher(
56 ctx context.Context,
57 wsCoord *coordinator.WorkspaceCoordinator,
58 navCoord *coordinator.NavigationCoordinator,
59 zoomUC *usecase.ManageZoomUseCase,
60 copyURLUC *usecase.CopyURLUseCase,
61 actions KeyboardActions,
62 activePaneID func(context.Context) entity.PaneID,
63) *KeyboardDispatcher {
64 log := logging.FromContext(ctx)
65 log.Debug().Msg("creating keyboard dispatcher")
66
67 dispatcher := &KeyboardDispatcher{
68 actions: actions,
69 wsCoord: wsCoord,
70 navCoord: navCoord,
71 zoomUC: zoomUC,
72 copyURLUC: copyURLUC,
73 activePaneID: activePaneID,
74 }
75 dispatcher.initActionHandlers()
76 return dispatcher
77}
78
79// SetOnQuit sets the callback for quit action.
80func (d *KeyboardDispatcher) SetOnQuit(fn func()) {
81 d.onQuit = fn
82}
83
84// SetOnFindOpen sets the callback for opening the find bar.
85func (d *KeyboardDispatcher) SetOnFindOpen(fn func(ctx context.Context) error) {
86 d.onFindOpen = fn
87}
88
89// SetOnFindNext sets the callback for the next match action.
90func (d *KeyboardDispatcher) SetOnFindNext(fn func(ctx context.Context) error) {
91 d.onFindNext = fn
92}
93
94// SetOnFindPrev sets the callback for the previous match action.
95func (d *KeyboardDispatcher) SetOnFindPrev(fn func(ctx context.Context) error) {
96 d.onFindPrev = fn
97}
98
99// SetOnFindClose sets the callback for closing the find bar.
100func (d *KeyboardDispatcher) SetOnFindClose(fn func(ctx context.Context) error) {
101 d.onFindClose = fn
102}
103
104// SetOnSessionOpen sets the callback for opening the session manager.
105func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context, paneID entity.PaneID) error) {
106 d.onSessionOpen = fn
107}
108
109func (d *KeyboardDispatcher) SetOnMovePaneToTab(fn func(ctx context.Context, paneID entity.PaneID) error) {
110 d.onMovePaneToTab = fn
111}
112
113func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context, paneID entity.PaneID) error) {
114 d.onMovePaneToNext = fn
115}
116
117func (d *KeyboardDispatcher) SetOnToggleFloatingPane(fn func(ctx context.Context) error) {
118 d.onToggleFloating = fn
119}
120
121func (d *KeyboardDispatcher) SetOnOpenFloatingURL(fn func(ctx context.Context, url string) error) {
122 if fn == nil {
123 d.onOpenFloating = nil
124 return
125 }
126 d.onOpenFloating = func(ctx context.Context, target input.FloatingProfileTarget) error {
127 return fn(ctx, target.URL)
128 }
129}
130
131func (d *KeyboardDispatcher) SetOnOpenFloatingTarget(fn func(ctx context.Context, target input.FloatingProfileTarget) error) {
132 d.onOpenFloating = fn
133}
134
135func (d *KeyboardDispatcher) initActionHandlers() {
136 const (
137 firstTabIndex = 0
138 secondTabIndex = 1
139 thirdTabIndex = 2
140 fourthTabIndex = 3
141 fifthTabIndex = 4
142 sixthTabIndex = 5
143 seventhTabIndex = 6
144 eighthTabIndex = 7
145 ninthTabIndex = 8
146 tenthTabIndex = 9
147 )
148 d.actionHandlers = map[input.Action]func(ctx context.Context) error{
149 // Tab actions
150 input.ActionNewTab: func(ctx context.Context) error { return d.handleKeyboardAction(ctx, "new tab", d.actions.NewTab) },
151 input.ActionCloseTab: func(ctx context.Context) error { return d.handleKeyboardAction(ctx, "close tab", d.actions.CloseTab) },
152 input.ActionNextTab: func(ctx context.Context) error { return d.handleKeyboardAction(ctx, "next tab", d.actions.NextTab) },
153 input.ActionPreviousTab: func(ctx context.Context) error {
154 return d.handleKeyboardAction(ctx, "previous tab", d.actions.PreviousTab)
155 },
156 input.ActionSwitchLastTab: func(ctx context.Context) error {
157 return d.handleKeyboardAction(ctx, "switch last tab", d.actions.SwitchLastTab)
158 },
159 input.ActionSwitchTabIndex1: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, firstTabIndex) },
160 input.ActionSwitchTabIndex2: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, secondTabIndex) },
161 input.ActionSwitchTabIndex3: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, thirdTabIndex) },
162 input.ActionSwitchTabIndex4: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, fourthTabIndex) },
163 input.ActionSwitchTabIndex5: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, fifthTabIndex) },
164 input.ActionSwitchTabIndex6: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, sixthTabIndex) },
165 input.ActionSwitchTabIndex7: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, seventhTabIndex) },
166 input.ActionSwitchTabIndex8: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, eighthTabIndex) },
167 input.ActionSwitchTabIndex9: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, ninthTabIndex) },
168 input.ActionSwitchTabIndex10: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, tenthTabIndex) },
169 input.ActionRenameTab: func(ctx context.Context) error {
170 return d.logNoop(ctx, "rename tab action (not yet implemented)")
171 },
172 // Pane actions
173 input.ActionSplitRight: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitRight) },
174 input.ActionSplitLeft: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitLeft) },
175 input.ActionSplitUp: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitUp) },
176 input.ActionSplitDown: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitDown) },
177 input.ActionClosePane: d.wsCoord.ClosePane,
178 input.ActionStackPane: d.wsCoord.StackPane,
179 input.ActionMovePaneToTab: func(ctx context.Context) error {
180 return d.handleMovePaneToTab(ctx)
181 },
182 input.ActionMovePaneToNextTab: func(ctx context.Context) error {
183 return d.handleMovePaneToNextTab(ctx)
184 },
185 input.ActionConsumeOrExpelLeft: func(ctx context.Context) error {
186 return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft)
187 },
188 input.ActionConsumeOrExpelRight: func(ctx context.Context) error {
189 return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelRight)
190 },
191 input.ActionConsumeOrExpelUp: func(ctx context.Context) error {
192 return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelUp)
193 },
194 input.ActionConsumeOrExpelDown: func(ctx context.Context) error {
195 return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelDown)
196 },
197 input.ActionFocusRight: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavRight) },
198 input.ActionFocusLeft: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavLeft) },
199 input.ActionFocusUp: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavUp) },
200 input.ActionFocusDown: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavDown) },
201 // Resize actions
202 input.ActionResizeIncreaseLeft: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseLeft) },
203 input.ActionResizeIncreaseRight: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseRight) },
204 input.ActionResizeIncreaseUp: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseUp) },
205 input.ActionResizeIncreaseDown: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseDown) },
206 input.ActionResizeDecreaseLeft: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseLeft) },
207 input.ActionResizeDecreaseRight: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseRight) },
208 input.ActionResizeDecreaseUp: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseUp) },
209 input.ActionResizeDecreaseDown: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseDown) },
210 input.ActionResizeIncrease: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncrease) },
211 input.ActionResizeDecrease: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecrease) },
212 // Stack navigation
213 input.ActionStackNavUp: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "up") },
214 input.ActionStackNavDown: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "down") },
215 // Navigation
216 input.ActionGoBack: d.handleGoBack,
217 input.ActionGoForward: d.handleGoForward,
218 input.ActionReload: d.handleReload,
219 input.ActionHardReload: d.handleHardReload,
220 input.ActionPrintPage: d.handlePrintPage,
221 // Zoom actions
222 input.ActionZoomIn: func(ctx context.Context) error { return d.handleZoom(ctx, "in") },
223 input.ActionZoomOut: func(ctx context.Context) error { return d.handleZoom(ctx, "out") },
224 input.ActionZoomReset: func(ctx context.Context) error { return d.handleZoom(ctx, "reset") },
225 // UI
226 input.ActionOpenOmnibox: d.navCoord.OpenOmnibox,
227 input.ActionOpenFind: d.handleFindOpen,
228 input.ActionFindNext: d.handleFindNext,
229 input.ActionFindPrev: d.handleFindPrev,
230 input.ActionCloseFind: d.handleFindClose,
231 input.ActionOpenDevTools: d.handleOpenDevTools,
232 input.ActionToggleFloatingPane: func(ctx context.Context) error {
233 if d.onToggleFloating != nil {
234 return d.onToggleFloating(ctx)
235 }
236 return d.logNoop(ctx, "toggle floating pane action (no handler)")
237 },
238 input.ActionToggleHistorySystemView: func(ctx context.Context) error {
239 return d.wsCoord.ToggleSystemViewRight(ctx, historySystemViewURL)
240 },
241 input.ActionToggleFavoritesSystemView: func(ctx context.Context) error {
242 return d.wsCoord.ToggleSystemViewRight(ctx, favoritesSystemViewURL)
243 },
244 input.ActionToggleConfigSystemView: func(ctx context.Context) error {
245 return d.wsCoord.ToggleSystemViewRight(ctx, configSystemViewURL)
246 },
247 input.ActionToggleFullscreen: func(ctx context.Context) error {
248 return d.logNoop(ctx, "toggle fullscreen action (not yet implemented)")
249 },
250 // Clipboard
251 input.ActionCopyURL: d.handleCopyURL,
252 // Session management
253 input.ActionOpenSessionManager: d.handleSessionOpen,
254 // Application
255 input.ActionQuit: d.handleQuit,
256 }
257}
258
259// Dispatch routes a keyboard action to the appropriate coordinator.
260func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action) error {
261 log := logging.FromContext(ctx)
262 log.Debug().Str("action", string(action)).Msg("dispatching keyboard action")
263
264 if target, ok := input.ParseFloatingProfileTarget(action); ok {
265 if d.onOpenFloating != nil {
266 return d.onOpenFloating(ctx, target)
267 }
268 log.Debug().Str("url", target.URL).Msg("floating profile action ignored (no handler)")
269 return nil
270 }
271
272 if handler, ok := d.actionHandlers[action]; ok {
273 return handler(ctx)
274 }
275
276 log.Warn().Str("action", string(action)).Msg("unhandled keyboard action")
277 return nil
278}
279
280func (d *KeyboardDispatcher) handleFindOpen(ctx context.Context) error {
281 if d.onFindOpen != nil {
282 return d.onFindOpen(ctx)
283 }
284 logging.FromContext(ctx).Debug().Msg("find open action (no handler)")
285 return nil
286}
287
288func (d *KeyboardDispatcher) handleFindNext(ctx context.Context) error {
289 if d.onFindNext != nil {
290 return d.onFindNext(ctx)
291 }
292 logging.FromContext(ctx).Debug().Msg("find next action (no handler)")
293 return nil
294}
295
296func (d *KeyboardDispatcher) handleFindPrev(ctx context.Context) error {
297 if d.onFindPrev != nil {
298 return d.onFindPrev(ctx)
299 }
300 logging.FromContext(ctx).Debug().Msg("find prev action (no handler)")
301 return nil
302}
303
304func (d *KeyboardDispatcher) handleFindClose(ctx context.Context) error {
305 if d.onFindClose != nil {
306 return d.onFindClose(ctx)
307 }
308 logging.FromContext(ctx).Debug().Msg("find close action (no handler)")
309 return nil
310}
311
312func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error {
313 if d.onSessionOpen != nil {
314 return d.onSessionOpen(ctx, d.activePaneID(ctx))
315 }
316 logging.FromContext(ctx).Debug().Msg("session open action (no handler)")
317 return nil
318}
319
320func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error {
321 if d.onMovePaneToTab != nil {
322 return d.onMovePaneToTab(ctx, d.activePaneID(ctx))
323 }
324 logging.FromContext(ctx).Debug().Msg("move pane to tab action (no handler)")
325 return nil
326}
327
328func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error {
329 if d.onMovePaneToNext != nil {
330 return d.onMovePaneToNext(ctx, d.activePaneID(ctx))
331 }
332 logging.FromContext(ctx).Debug().Msg("move pane to next tab action (no handler)")
333 return nil
334}
335
336func (d *KeyboardDispatcher) handleSwitchTabIndex(ctx context.Context, index int) error {
337 if d.actions.SwitchTabIndex == nil {
338 logging.FromContext(ctx).Debug().Int("index", index).Msg("switch tab index action ignored (no handler)")
339 return nil
340 }
341 return d.actions.SwitchTabIndex(ctx, index)
342}
343
344func (d *KeyboardDispatcher) handleKeyboardAction(ctx context.Context, name string, fn func(context.Context) error) error {
345 if fn == nil {
346 logging.FromContext(ctx).Debug().Str("action", name).Msg("keyboard action ignored (no handler)")
347 return nil
348 }
349 return fn(ctx)
350}
351
352func (d *KeyboardDispatcher) activeWebView(ctx context.Context) port.WebView {
353 if d.actions.ActiveWebView == nil {
354 logging.FromContext(ctx).Debug().Msg("active webview unavailable (no handler)")
355 return nil
356 }
357 return d.actions.ActiveWebView(ctx)
358}
359
360func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {
361 if ctx == nil {
362 return fmt.Errorf("missing context")
363 }
364 logging.FromContext(ctx).Debug().Msg(message)
365 return nil
366}
367
368func (d *KeyboardDispatcher) handleQuit(ctx context.Context) error {
369 if d.onQuit != nil {
370 d.onQuit()
371 }
372 if ctx == nil {
373 return fmt.Errorf("missing context")
374 }
375 return nil
376}
377
378func (d *KeyboardDispatcher) withActiveWebView(ctx context.Context, action string, fn func(port.WebView) error) error {
379 wv := d.activeWebView(ctx)
380 if wv == nil {
381 logging.FromContext(ctx).Debug().Str("action", action).Msg("no active webview for keyboard action")
382 return nil
383 }
384 return fn(wv)
385}
386
387func (d *KeyboardDispatcher) handleReload(ctx context.Context) error {
388 return d.withActiveWebView(ctx, "reload", func(wv port.WebView) error {
389 return d.navCoord.ReloadWebView(ctx, wv, false)
390 })
391}
392
393func (d *KeyboardDispatcher) handleHardReload(ctx context.Context) error {
394 return d.withActiveWebView(ctx, "hard reload", func(wv port.WebView) error {
395 return d.navCoord.ReloadWebView(ctx, wv, true)
396 })
397}
398
399func (d *KeyboardDispatcher) handleGoBack(ctx context.Context) error {
400 return d.withActiveWebView(ctx, "go back", func(wv port.WebView) error {
401 return d.navCoord.GoBackWebView(ctx, wv)
402 })
403}
404
405func (d *KeyboardDispatcher) handleGoForward(ctx context.Context) error {
406 return d.withActiveWebView(ctx, "go forward", func(wv port.WebView) error {
407 return d.navCoord.GoForwardWebView(ctx, wv)
408 })
409}
410
411func (d *KeyboardDispatcher) handlePrintPage(ctx context.Context) error {
412 return d.withActiveWebView(ctx, "print page", func(wv port.WebView) error {
413 return d.navCoord.PrintWebView(ctx, wv)
414 })
415}
416
417func (d *KeyboardDispatcher) handleOpenDevTools(ctx context.Context) error {
418 return d.withActiveWebView(ctx, "open devtools", func(wv port.WebView) error {
419 return d.navCoord.OpenDevToolsWebView(ctx, wv)
420 })
421}
422
423// handleZoom processes zoom in/out/reset actions for the active WebView.
424func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) error {
425 log := logging.FromContext(ctx)
426
427 if d.zoomUC == nil {
428 log.Warn().Msg("zoom use case not available")
429 return nil
430 }
431
432 wv := d.activeWebView(ctx)
433 if wv == nil {
434 log.Debug().Msg("no active webview for zoom")
435 return nil
436 }
437
438 zoomKey, err := usecase.ExtractZoomKey(wv.URI())
439 if err != nil {
440 log.Debug().Str("uri", wv.URI()).Msg("cannot extract zoom key")
441 return nil
442 }
443
444 current := wv.GetZoomLevel()
445 var newZoom *entity.ZoomLevel
446
447 switch action {
448 case "in":
449 newZoom, err = d.zoomUC.ZoomIn(ctx, zoomKey, current)
450 case "out":
451 newZoom, err = d.zoomUC.ZoomOut(ctx, zoomKey, current)
452 case "reset":
453 err = d.zoomUC.ResetZoom(ctx, zoomKey)
454 if err == nil {
455 newZoom = entity.NewZoomLevel(zoomKey, d.zoomUC.DefaultZoom())
456 }
457 }
458
459 if err != nil {
460 log.Error().Err(err).Str("action", action).Msg("zoom action failed")
461 return err
462 }
463
464 if newZoom != nil {
465 if setErr := wv.SetZoomLevel(ctx, newZoom.ZoomFactor); setErr != nil {
466 log.Error().Err(setErr).Float64("zoom", newZoom.ZoomFactor).Msg("failed to apply zoom")
467 return setErr
468 }
469
470 // Notify omnibox to update zoom indicator
471 d.navCoord.NotifyZoomChanged(ctx, newZoom.ZoomFactor)
472
473 // Show zoom toast on the active pane
474 zoomPercent := int(newZoom.ZoomFactor * 100)
475 d.wsCoord.ShowZoomToast(ctx, zoomPercent)
476
477 log.Debug().
478 Str("zoom_key", zoomKey).
479 Str("action", action).
480 Float64("zoom", newZoom.ZoomFactor).
481 Msg("zoom applied")
482 }
483
484 return nil
485}
486
487// handleCopyURL copies the active pane's URL to clipboard.
488func (d *KeyboardDispatcher) handleCopyURL(ctx context.Context) error {
489 log := logging.FromContext(ctx)
490
491 if d.copyURLUC == nil {
492 log.Warn().Msg("copy URL use case not available")
493 return nil
494 }
495
496 wv := d.activeWebView(ctx)
497 if wv == nil {
498 log.Debug().Msg("no active webview for copy URL")
499 return nil
500 }
501
502 uri := wv.URI()
503 if uri == "" {
504 log.Debug().Msg("active webview has empty URI")
505 return nil
506 }
507
508 // Copy URL in background goroutine
509 go func() {
510 if err := d.copyURLUC.Copy(ctx, uri); err != nil {
511 log.Error().Err(err).Str("uri", uri).Msg("copy URL failed")
512 return
513 }
514
515 // Show toast on GTK main thread
516 cb := glib.SourceFunc(func(_ uintptr) bool {
517 d.wsCoord.ShowToastOnActivePane(ctx, "URL copied", component.ToastSuccess)
518 return false
519 })
520 glib.IdleAdd(&cb, 0)
521 }()
522
523 return nil
524}