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 // Zoom actions
183 input.ActionZoomIn: func(ctx context.Context) error { return d.handleZoom(ctx, "in") },
184 input.ActionZoomOut: func(ctx context.Context) error { return d.handleZoom(ctx, "out") },
185 input.ActionZoomReset: func(ctx context.Context) error { return d.handleZoom(ctx, "reset") },
186 // UI
187 input.ActionOpenOmnibox: d.navCoord.OpenOmnibox,
188 input.ActionOpenFind: d.handleFindOpen,
189 input.ActionFindNext: d.handleFindNext,
190 input.ActionFindPrev: d.handleFindPrev,
191 input.ActionCloseFind: d.handleFindClose,
192 input.ActionOpenDevTools: d.navCoord.OpenDevTools,
193 input.ActionToggleFullscreen: func(ctx context.Context) error {
194 return d.logNoop(ctx, "toggle fullscreen action (not yet implemented)")
195 },
196 // Clipboard
197 input.ActionCopyURL: d.handleCopyURL,
198 // Session management
199 input.ActionOpenSessionManager: d.handleSessionOpen,
200 // Application
201 input.ActionQuit: d.handleQuit,
202 }
203}
204
205// Dispatch routes a keyboard action to the appropriate coordinator.
206func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action) error {
207 log := logging.FromContext(ctx)
208 log.Debug().Str("action", string(action)).Msg("dispatching keyboard action")
209
210 if handler, ok := d.actionHandlers[action]; ok {
211 return handler(ctx)
212 }
213
214 log.Warn().Str("action", string(action)).Msg("unhandled keyboard action")
215 return nil
216}
217
218func (d *KeyboardDispatcher) handleFindOpen(ctx context.Context) error {
219 if d.onFindOpen != nil {
220 return d.onFindOpen(ctx)
221 }
222 logging.FromContext(ctx).Debug().Msg("find open action (no handler)")
223 return nil
224}
225
226func (d *KeyboardDispatcher) handleFindNext(ctx context.Context) error {
227 if d.onFindNext != nil {
228 return d.onFindNext(ctx)
229 }
230 logging.FromContext(ctx).Debug().Msg("find next action (no handler)")
231 return nil
232}
233
234func (d *KeyboardDispatcher) handleFindPrev(ctx context.Context) error {
235 if d.onFindPrev != nil {
236 return d.onFindPrev(ctx)
237 }
238 logging.FromContext(ctx).Debug().Msg("find prev action (no handler)")
239 return nil
240}
241
242func (d *KeyboardDispatcher) handleFindClose(ctx context.Context) error {
243 if d.onFindClose != nil {
244 return d.onFindClose(ctx)
245 }
246 logging.FromContext(ctx).Debug().Msg("find close action (no handler)")
247 return nil
248}
249
250func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error {
251 if d.onSessionOpen != nil {
252 return d.onSessionOpen(ctx)
253 }
254 logging.FromContext(ctx).Debug().Msg("session open action (no handler)")
255 return nil
256}
257
258func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error {
259 if d.onMovePaneToTab != nil {
260 return d.onMovePaneToTab(ctx)
261 }
262 logging.FromContext(ctx).Debug().Msg("move pane to tab action (no handler)")
263 return nil
264}
265
266func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error {
267 if d.onMovePaneToNext != nil {
268 return d.onMovePaneToNext(ctx)
269 }
270 logging.FromContext(ctx).Debug().Msg("move pane to next tab action (no handler)")
271 return nil
272}
273
274func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {
275 if ctx == nil {
276 return fmt.Errorf("missing context")
277 }
278 logging.FromContext(ctx).Debug().Msg(message)
279 return nil
280}
281
282func (d *KeyboardDispatcher) handleQuit(ctx context.Context) error {
283 if d.onQuit != nil {
284 d.onQuit()
285 }
286 if ctx == nil {
287 return fmt.Errorf("missing context")
288 }
289 return nil
290}
291
292// handleZoom processes zoom in/out/reset actions for the active WebView.
293func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) error {
294 log := logging.FromContext(ctx)
295
296 if d.zoomUC == nil {
297 log.Warn().Msg("zoom use case not available")
298 return nil
299 }
300
301 wv := d.navCoord.ActiveWebView(ctx)
302 if wv == nil {
303 log.Debug().Msg("no active webview for zoom")
304 return nil
305 }
306
307 domain, err := usecase.ExtractDomain(wv.URI())
308 if err != nil {
309 log.Debug().Str("uri", wv.URI()).Msg("cannot extract domain for zoom")
310 return nil
311 }
312
313 current := wv.GetZoomLevel()
314 var newZoom *entity.ZoomLevel
315
316 switch action {
317 case "in":
318 newZoom, err = d.zoomUC.ZoomIn(ctx, domain, current)
319 case "out":
320 newZoom, err = d.zoomUC.ZoomOut(ctx, domain, current)
321 case "reset":
322 err = d.zoomUC.ResetZoom(ctx, domain)
323 if err == nil {
324 newZoom = entity.NewZoomLevel(domain, d.zoomUC.DefaultZoom())
325 }
326 }
327
328 if err != nil {
329 log.Error().Err(err).Str("action", action).Msg("zoom action failed")
330 return err
331 }
332
333 if newZoom != nil {
334 if setErr := wv.SetZoomLevel(ctx, newZoom.ZoomFactor); setErr != nil {
335 log.Error().Err(setErr).Float64("zoom", newZoom.ZoomFactor).Msg("failed to apply zoom")
336 return setErr
337 }
338
339 // Notify omnibox to update zoom indicator
340 d.navCoord.NotifyZoomChanged(ctx, newZoom.ZoomFactor)
341
342 // Show zoom toast on the active pane
343 zoomPercent := int(newZoom.ZoomFactor * 100)
344 d.wsCoord.ShowZoomToast(ctx, zoomPercent)
345
346 log.Debug().
347 Str("domain", domain).
348 Str("action", action).
349 Float64("zoom", newZoom.ZoomFactor).
350 Msg("zoom applied")
351 }
352
353 return nil
354}
355
356// handleCopyURL copies the active pane's URL to clipboard.
357func (d *KeyboardDispatcher) handleCopyURL(ctx context.Context) error {
358 log := logging.FromContext(ctx)
359
360 if d.copyURLUC == nil {
361 log.Warn().Msg("copy URL use case not available")
362 return nil
363 }
364
365 wv := d.navCoord.ActiveWebView(ctx)
366 if wv == nil {
367 log.Debug().Msg("no active webview for copy URL")
368 return nil
369 }
370
371 uri := wv.URI()
372 if uri == "" {
373 log.Debug().Msg("active webview has empty URI")
374 return nil
375 }
376
377 // Copy URL in background goroutine
378 go func() {
379 if err := d.copyURLUC.Copy(ctx, uri); err != nil {
380 log.Error().Err(err).Str("uri", uri).Msg("copy URL failed")
381 return
382 }
383
384 // Show toast on GTK main thread
385 cb := glib.SourceFunc(func(_ uintptr) bool {
386 d.wsCoord.ShowToastOnActivePane(ctx, "URL copied", component.ToastSuccess)
387 return false
388 })
389 glib.IdleAdd(&cb, 0)
390 }()
391
392 return nil
393}