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