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