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