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