1package dispatcher
2
3import (
4 "context"
5 "fmt"
6
7 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/application/usecase"
9 "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/bnema/dumber/internal/logging"
11 "github.com/bnema/dumber/internal/ui/component"
12 "github.com/bnema/dumber/internal/ui/coordinator"
13 "github.com/bnema/dumber/internal/ui/input"
14 "github.com/bnema/puregotk/v4/glib"
15)
16
17const (
18 // historySystemViewURL is the full-page/systemview history surface.
19 // It remains reachable by direct navigation (for example typing
20 // dumb://history in the omnibox), but Ctrl+H no longer falls back to it.
21 // Ctrl+H is reserved for the native GTK history sidebar only.
22 historySystemViewURL = "dumb://history"
23 favoritesSystemViewURL = "dumb://favorites"
24 configSystemViewURL = "dumb://config"
25)
26
27type KeyboardActions struct {
28 NewTab func(context.Context) error
29 CloseTab func(context.Context) error
30 NextTab func(context.Context) error
31 PreviousTab func(context.Context) error
32 SwitchLastTab func(context.Context) error
33 SwitchTabIndex func(context.Context, int) error
34 ActiveWebView func(context.Context) port.WebView
35}
36
37// KeyboardDispatcher routes keyboard actions to appropriate coordinators.
38type KeyboardDispatcher struct {
39 actions KeyboardActions
40 wsCoord *coordinator.WorkspaceCoordinator
41 navCoord *coordinator.NavigationCoordinator
42 zoomUC *usecase.ManageZoomUseCase
43 copyURLUC *usecase.CopyURLUseCase
44 actionHandlers map[input.Action]func(ctx context.Context) error
45 onQuit func()
46 onFindOpen func(ctx context.Context) error
47 onFindNext func(ctx context.Context) error
48 onFindPrev func(ctx context.Context) error
49 onFindClose func(ctx context.Context) error
50 activePaneID func(ctx context.Context) entity.PaneID
51 onSessionOpen func(ctx context.Context, paneID entity.PaneID) error
52 onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error
53 onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error
54 onEjectPaneToWindow func(ctx context.Context, paneID entity.PaneID) error
55 onVimModePulse func(ctx context.Context, fast bool)
56 onToggleHistorySidebar func(ctx context.Context) error
57 onToggleFavoritesSidebar func(ctx context.Context) error
58 onToggleCurrentFavorite func(ctx context.Context) error
59 onToggleFloating func(ctx context.Context) error
60 onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error
61}
62
63// NewKeyboardDispatcher creates a new KeyboardDispatcher.
64func NewKeyboardDispatcher(
65 ctx context.Context,
66 wsCoord *coordinator.WorkspaceCoordinator,
67 navCoord *coordinator.NavigationCoordinator,
68 zoomUC *usecase.ManageZoomUseCase,
69 copyURLUC *usecase.CopyURLUseCase,
70 actions KeyboardActions,
71 activePaneID func(context.Context) entity.PaneID,
72) *KeyboardDispatcher {
73 log := logging.FromContext(ctx)
74 log.Debug().Msg("creating keyboard dispatcher")
75
76 dispatcher := &KeyboardDispatcher{
77 actions: actions,
78 wsCoord: wsCoord,
79 navCoord: navCoord,
80 zoomUC: zoomUC,
81 copyURLUC: copyURLUC,
82 activePaneID: activePaneID,
83 }
84 dispatcher.initActionHandlers()
85 return dispatcher
86}
87
88// SetOnQuit sets the callback for quit action.
89func (d *KeyboardDispatcher) SetOnQuit(fn func()) {
90 d.onQuit = fn
91}
92
93// SetOnFindOpen sets the callback for opening the find bar.
94func (d *KeyboardDispatcher) SetOnFindOpen(fn func(ctx context.Context) error) {
95 d.onFindOpen = fn
96}
97
98// SetOnFindNext sets the callback for the next match action.
99func (d *KeyboardDispatcher) SetOnFindNext(fn func(ctx context.Context) error) {
100 d.onFindNext = fn
101}
102
103// SetOnFindPrev sets the callback for the previous match action.
104func (d *KeyboardDispatcher) SetOnFindPrev(fn func(ctx context.Context) error) {
105 d.onFindPrev = fn
106}
107
108// SetOnFindClose sets the callback for closing the find bar.
109func (d *KeyboardDispatcher) SetOnFindClose(fn func(ctx context.Context) error) {
110 d.onFindClose = fn
111}
112
113// SetOnSessionOpen sets the callback for opening the session manager.
114func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context, paneID entity.PaneID) error) {
115 d.onSessionOpen = fn
116}
117
118func (d *KeyboardDispatcher) SetOnMovePaneToTab(fn func(ctx context.Context, paneID entity.PaneID) error) {
119 d.onMovePaneToTab = fn
120}
121
122func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context, paneID entity.PaneID) error) {
123 d.onMovePaneToNext = fn
124}
125
126func (d *KeyboardDispatcher) SetOnEjectPaneToWindow(fn func(ctx context.Context, paneID entity.PaneID) error) {
127 d.onEjectPaneToWindow = fn
128}
129
130func (d *KeyboardDispatcher) SetOnToggleHistorySidebar(fn func(ctx context.Context) error) {
131 d.onToggleHistorySidebar = fn
132}
133
134func (d *KeyboardDispatcher) SetOnToggleFavoritesSidebar(fn func(ctx context.Context) error) {
135 d.onToggleFavoritesSidebar = fn
136}
137
138func (d *KeyboardDispatcher) SetOnToggleCurrentPageFavorite(fn func(ctx context.Context) error) {
139 d.onToggleCurrentFavorite = fn
140}
141
142func (d *KeyboardDispatcher) SetOnToggleFloatingPane(fn func(ctx context.Context) error) {
143 d.onToggleFloating = fn
144}
145
146func (d *KeyboardDispatcher) SetOnOpenFloatingURL(fn func(ctx context.Context, url string) error) {
147 if fn == nil {
148 d.onOpenFloating = nil
149 return
150 }
151 d.onOpenFloating = func(ctx context.Context, target input.FloatingProfileTarget) error {
152 return fn(ctx, target.URL)
153 }
154}
155
156func (d *KeyboardDispatcher) SetOnOpenFloatingTarget(fn func(ctx context.Context, target input.FloatingProfileTarget) error) {
157 d.onOpenFloating = fn
158}
159
160// SetOnVimModePulse registers a callback for pane-local Vim mode pulse
161// visual feedback triggered after a page scroll action.
162// The cb receives a bool indicating whether the pulse should be "fast"
163// (stronger/longer for up-fast/down-fast).
164// If nil, pulse callbacks are no-ops.
165func (d *KeyboardDispatcher) SetOnVimModePulse(cb func(ctx context.Context, fast bool)) {
166 d.onVimModePulse = cb
167}
168
169// triggerPulse calls the registered pulse callback if set, or no-ops otherwise.
170func (d *KeyboardDispatcher) triggerPulse(ctx context.Context, fast bool) {
171 if d.onVimModePulse != nil {
172 d.onVimModePulse(ctx, fast)
173 }
174}
175
176func (d *KeyboardDispatcher) initActionHandlers() {
177 const (
178 firstTabIndex = 0
179 secondTabIndex = 1
180 thirdTabIndex = 2
181 fourthTabIndex = 3
182 fifthTabIndex = 4
183 sixthTabIndex = 5
184 seventhTabIndex = 6
185 eighthTabIndex = 7
186 ninthTabIndex = 8
187 tenthTabIndex = 9
188 )
189 d.actionHandlers = map[input.Action]func(ctx context.Context) error{
190 // Tab actions
191 input.ActionNewTab: func(ctx context.Context) error { return d.handleKeyboardAction(ctx, "new tab", d.actions.NewTab) },
192 input.ActionCloseTab: func(ctx context.Context) error { return d.handleKeyboardAction(ctx, "close tab", d.actions.CloseTab) },
193 input.ActionNextTab: func(ctx context.Context) error { return d.handleKeyboardAction(ctx, "next tab", d.actions.NextTab) },
194 input.ActionPreviousTab: func(ctx context.Context) error {
195 return d.handleKeyboardAction(ctx, "previous tab", d.actions.PreviousTab)
196 },
197 input.ActionSwitchLastTab: func(ctx context.Context) error {
198 return d.handleKeyboardAction(ctx, "switch last tab", d.actions.SwitchLastTab)
199 },
200 input.ActionSwitchTabIndex1: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, firstTabIndex) },
201 input.ActionSwitchTabIndex2: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, secondTabIndex) },
202 input.ActionSwitchTabIndex3: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, thirdTabIndex) },
203 input.ActionSwitchTabIndex4: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, fourthTabIndex) },
204 input.ActionSwitchTabIndex5: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, fifthTabIndex) },
205 input.ActionSwitchTabIndex6: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, sixthTabIndex) },
206 input.ActionSwitchTabIndex7: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, seventhTabIndex) },
207 input.ActionSwitchTabIndex8: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, eighthTabIndex) },
208 input.ActionSwitchTabIndex9: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, ninthTabIndex) },
209 input.ActionSwitchTabIndex10: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, tenthTabIndex) },
210 input.ActionRenameTab: func(ctx context.Context) error {
211 return d.logNoop(ctx, "rename tab action (not yet implemented)")
212 },
213 // Pane actions
214 input.ActionSplitRight: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitRight) },
215 input.ActionSplitLeft: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitLeft) },
216 input.ActionSplitUp: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitUp) },
217 input.ActionSplitDown: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitDown) },
218 input.ActionClosePane: d.wsCoord.ClosePane,
219 input.ActionStackPane: d.wsCoord.StackPane,
220 input.ActionMovePaneToTab: func(ctx context.Context) error {
221 return d.handleMovePaneToTab(ctx)
222 },
223 input.ActionMovePaneToNextTab: func(ctx context.Context) error {
224 return d.handleMovePaneToNextTab(ctx)
225 },
226 input.ActionEjectPaneToWindow: func(ctx context.Context) error {
227 return d.handleEjectPaneToWindow(ctx)
228 },
229 input.ActionConsumeOrExpelLeft: func(ctx context.Context) error {
230 return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft)
231 },
232 input.ActionConsumeOrExpelRight: func(ctx context.Context) error {
233 return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelRight)
234 },
235 input.ActionConsumeOrExpelUp: func(ctx context.Context) error {
236 return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelUp)
237 },
238 input.ActionConsumeOrExpelDown: func(ctx context.Context) error {
239 return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelDown)
240 },
241 input.ActionFocusRight: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavRight) },
242 input.ActionFocusLeft: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavLeft) },
243 input.ActionFocusUp: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavUp) },
244 input.ActionFocusDown: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavDown) },
245 // Resize actions
246 input.ActionResizeIncreaseLeft: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseLeft) },
247 input.ActionResizeIncreaseRight: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseRight) },
248 input.ActionResizeIncreaseUp: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseUp) },
249 input.ActionResizeIncreaseDown: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseDown) },
250 input.ActionResizeDecreaseLeft: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseLeft) },
251 input.ActionResizeDecreaseRight: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseRight) },
252 input.ActionResizeDecreaseUp: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseUp) },
253 input.ActionResizeDecreaseDown: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseDown) },
254 input.ActionResizeIncrease: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncrease) },
255 input.ActionResizeDecrease: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecrease) },
256 // Stack navigation
257 input.ActionStackNavUp: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "up") },
258 input.ActionStackNavDown: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "down") },
259 // Navigation
260 input.ActionGoBack: d.handleGoBack,
261 input.ActionGoForward: d.handleGoForward,
262 input.ActionReload: d.handleReload,
263 input.ActionHardReload: d.handleHardReload,
264 input.ActionPrintPage: d.handlePrintPage,
265 // Zoom actions
266 input.ActionZoomIn: func(ctx context.Context) error { return d.handleZoom(ctx, "in") },
267 input.ActionZoomOut: func(ctx context.Context) error { return d.handleZoom(ctx, "out") },
268 input.ActionZoomReset: func(ctx context.Context) error { return d.handleZoom(ctx, "reset") },
269 // Vim mode scroll actions — single mapping drives tap pulse + command.
270 input.ActionVimScrollLeft: d.vimScrollTapHandler(input.ActionVimScrollLeft),
271 input.ActionVimScrollRight: d.vimScrollTapHandler(input.ActionVimScrollRight),
272 input.ActionVimScrollUp: d.vimScrollTapHandler(input.ActionVimScrollUp),
273 input.ActionVimScrollDown: d.vimScrollTapHandler(input.ActionVimScrollDown),
274 input.ActionVimScrollUpFast: d.vimScrollTapHandler(input.ActionVimScrollUpFast),
275 input.ActionVimScrollDownFast: d.vimScrollTapHandler(input.ActionVimScrollDownFast),
276 // UI
277 input.ActionOpenOmnibox: d.navCoord.OpenOmnibox,
278 input.ActionOpenFind: d.handleFindOpen,
279 input.ActionFindNext: d.handleFindNext,
280 input.ActionFindPrev: d.handleFindPrev,
281 input.ActionCloseFind: d.handleFindClose,
282 input.ActionOpenDevTools: d.handleOpenDevTools,
283 input.ActionToggleFloatingPane: func(ctx context.Context) error {
284 if d.onToggleFloating != nil {
285 return d.onToggleFloating(ctx)
286 }
287 return d.logNoop(ctx, "toggle floating pane action (no handler)")
288 },
289 // ActionToggleHistorySystemView (default Ctrl+H) is intentionally bound
290 // to the native GTK history sidebar only. The dumb://history systemview
291 // remains available through direct navigation, not shortcut fallback.
292 input.ActionToggleHistorySystemView: func(ctx context.Context) error {
293 if d.onToggleHistorySidebar == nil {
294 return fmt.Errorf("history sidebar unavailable: toggle handler not wired")
295 }
296 return d.onToggleHistorySidebar(ctx)
297 },
298 input.ActionToggleFavoritesSidebar: func(ctx context.Context) error {
299 if d.onToggleFavoritesSidebar == nil {
300 return fmt.Errorf("favorites sidebar unavailable: toggle handler not wired")
301 }
302 return d.onToggleFavoritesSidebar(ctx)
303 },
304 input.ActionToggleCurrentPageFavorite: func(ctx context.Context) error {
305 if d.onToggleCurrentFavorite == nil {
306 return fmt.Errorf("favorites unavailable: toggle current page handler not wired")
307 }
308 return d.onToggleCurrentFavorite(ctx)
309 },
310 input.ActionToggleConfigSystemView: func(ctx context.Context) error {
311 return d.wsCoord.ToggleSystemViewRight(ctx, configSystemViewURL)
312 },
313 input.ActionToggleFullscreen: func(ctx context.Context) error {
314 return d.logNoop(ctx, "toggle fullscreen action (not yet implemented)")
315 },
316 // Clipboard
317 input.ActionCopyURL: d.handleCopyURL,
318 // Session management
319 input.ActionOpenSessionManager: d.handleSessionOpen,
320 // Application
321 input.ActionQuit: d.handleQuit,
322 }
323}
324
325// DispatchVimScrollLifecycle routes autonomous held-key page scroll events.
326// Stop deliberately avoids the normal action map so it emits neither another
327// scroll tap nor visual pulse.
328func (d *KeyboardDispatcher) DispatchVimScrollLifecycle(ctx context.Context, action input.Action, phase input.VimScrollPhase) error {
329 spec, ok := vimScrollSpec(action)
330 if !ok {
331 return nil
332 }
333 return d.withActiveWebView(ctx, "page scroll lifecycle", func(wv port.WebView) error {
334 if phase == input.VimScrollStop {
335 return d.navCoord.StopPageScroll(ctx, wv)
336 }
337 return d.navCoord.ScrollWebViewContinuous(ctx, wv, spec.cmd)
338 })
339}
340
341type vimScrollActionSpec struct {
342 cmd usecase.PageScrollCommand
343 fast bool
344}
345
346// vimScrollSpec is the single source of truth for Vim Mode action→command
347// and fast/slow pulse used by tap handlers and held lifecycle routing.
348func vimScrollSpec(action input.Action) (vimScrollActionSpec, bool) {
349 switch action {
350 case input.ActionVimScrollLeft:
351 return vimScrollActionSpec{cmd: usecase.PageScrollLeft, fast: false}, true
352 case input.ActionVimScrollRight:
353 return vimScrollActionSpec{cmd: usecase.PageScrollRight, fast: false}, true
354 case input.ActionVimScrollUp:
355 return vimScrollActionSpec{cmd: usecase.PageScrollUp, fast: false}, true
356 case input.ActionVimScrollDown:
357 return vimScrollActionSpec{cmd: usecase.PageScrollDown, fast: false}, true
358 case input.ActionVimScrollUpFast:
359 return vimScrollActionSpec{cmd: usecase.PageScrollUpFast, fast: true}, true
360 case input.ActionVimScrollDownFast:
361 return vimScrollActionSpec{cmd: usecase.PageScrollDownFast, fast: true}, true
362 default:
363 return vimScrollActionSpec{}, false
364 }
365}
366
367func (d *KeyboardDispatcher) vimScrollTapHandler(action input.Action) func(context.Context) error {
368 spec, ok := vimScrollSpec(action)
369 if !ok {
370 return func(context.Context) error { return nil }
371 }
372 label := "page scroll " + string(action)
373 return func(ctx context.Context) error {
374 return d.withActiveWebView(ctx, label, func(wv port.WebView) error {
375 if err := d.navCoord.ScrollWebView(ctx, wv, spec.cmd); err != nil {
376 return err
377 }
378 d.triggerPulse(ctx, spec.fast)
379 return nil
380 })
381 }
382}
383
384func vimScrollCommand(action input.Action) (usecase.PageScrollCommand, bool) {
385 spec, ok := vimScrollSpec(action)
386 if !ok {
387 return 0, false
388 }
389 return spec.cmd, true
390}
391
392// Dispatch routes a keyboard action to the appropriate coordinator.
393func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action) error {
394 log := logging.FromContext(ctx)
395 log.Debug().Str("action", string(action)).Msg("dispatching keyboard action")
396
397 if target, ok := input.ParseFloatingProfileTarget(action); ok {
398 if d.onOpenFloating != nil {
399 return d.onOpenFloating(ctx, target)
400 }
401 log.Debug().Str("url", target.URL).Msg("floating profile action ignored (no handler)")
402 return nil
403 }
404
405 if handler, ok := d.actionHandlers[action]; ok {
406 return handler(ctx)
407 }
408
409 log.Warn().Str("action", string(action)).Msg("unhandled keyboard action")
410 return nil
411}
412
413func (d *KeyboardDispatcher) handleFindOpen(ctx context.Context) error {
414 if d.onFindOpen != nil {
415 return d.onFindOpen(ctx)
416 }
417 logging.FromContext(ctx).Debug().Msg("find open action (no handler)")
418 return nil
419}
420
421func (d *KeyboardDispatcher) handleFindNext(ctx context.Context) error {
422 if d.onFindNext != nil {
423 return d.onFindNext(ctx)
424 }
425 logging.FromContext(ctx).Debug().Msg("find next action (no handler)")
426 return nil
427}
428
429func (d *KeyboardDispatcher) handleFindPrev(ctx context.Context) error {
430 if d.onFindPrev != nil {
431 return d.onFindPrev(ctx)
432 }
433 logging.FromContext(ctx).Debug().Msg("find prev action (no handler)")
434 return nil
435}
436
437func (d *KeyboardDispatcher) handleFindClose(ctx context.Context) error {
438 if d.onFindClose != nil {
439 return d.onFindClose(ctx)
440 }
441 logging.FromContext(ctx).Debug().Msg("find close action (no handler)")
442 return nil
443}
444
445func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error {
446 if d.onSessionOpen != nil {
447 return d.onSessionOpen(ctx, d.activePaneID(ctx))
448 }
449 logging.FromContext(ctx).Debug().Msg("session open action (no handler)")
450 return nil
451}
452
453func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error {
454 if d.onMovePaneToTab != nil {
455 return d.onMovePaneToTab(ctx, d.activePaneID(ctx))
456 }
457 logging.FromContext(ctx).Debug().Msg("move pane to tab action (no handler)")
458 return nil
459}
460
461func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error {
462 if d.onMovePaneToNext != nil {
463 return d.onMovePaneToNext(ctx, d.activePaneID(ctx))
464 }
465 logging.FromContext(ctx).Debug().Msg("move pane to next tab action (no handler)")
466 return nil
467}
468
469func (d *KeyboardDispatcher) handleEjectPaneToWindow(ctx context.Context) error {
470 if d.onEjectPaneToWindow != nil {
471 return d.onEjectPaneToWindow(ctx, d.activePaneID(ctx))
472 }
473 logging.FromContext(ctx).Debug().Msg("eject pane to window action (no handler)")
474 return nil
475}
476
477func (d *KeyboardDispatcher) handleSwitchTabIndex(ctx context.Context, index int) error {
478 if d.actions.SwitchTabIndex == nil {
479 logging.FromContext(ctx).Debug().Int("index", index).Msg("switch tab index action ignored (no handler)")
480 return nil
481 }
482 return d.actions.SwitchTabIndex(ctx, index)
483}
484
485func (d *KeyboardDispatcher) handleKeyboardAction(ctx context.Context, name string, fn func(context.Context) error) error {
486 if fn == nil {
487 logging.FromContext(ctx).Debug().Str("action", name).Msg("keyboard action ignored (no handler)")
488 return nil
489 }
490 return fn(ctx)
491}
492
493func (d *KeyboardDispatcher) activeWebView(ctx context.Context) port.WebView {
494 if d.actions.ActiveWebView == nil {
495 logging.FromContext(ctx).Debug().Msg("active webview unavailable (no handler)")
496 return nil
497 }
498 return d.actions.ActiveWebView(ctx)
499}
500
501func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {
502 if ctx == nil {
503 return fmt.Errorf("missing context")
504 }
505 logging.FromContext(ctx).Debug().Msg(message)
506 return nil
507}
508
509func (d *KeyboardDispatcher) handleQuit(ctx context.Context) error {
510 if d.onQuit != nil {
511 d.onQuit()
512 }
513 if ctx == nil {
514 return fmt.Errorf("missing context")
515 }
516 return nil
517}
518
519func (d *KeyboardDispatcher) withActiveWebView(ctx context.Context, action string, fn func(port.WebView) error) error {
520 wv := d.activeWebView(ctx)
521 if wv == nil {
522 logging.FromContext(ctx).Debug().Str("action", action).Msg("no active webview for keyboard action")
523 return nil
524 }
525 return fn(wv)
526}
527
528func (d *KeyboardDispatcher) handleReload(ctx context.Context) error {
529 return d.withActiveWebView(ctx, "reload", func(wv port.WebView) error {
530 return d.navCoord.ReloadWebView(ctx, wv, false)
531 })
532}
533
534func (d *KeyboardDispatcher) handleHardReload(ctx context.Context) error {
535 return d.withActiveWebView(ctx, "hard reload", func(wv port.WebView) error {
536 return d.navCoord.ReloadWebView(ctx, wv, true)
537 })
538}
539
540func (d *KeyboardDispatcher) handleGoBack(ctx context.Context) error {
541 return d.withActiveWebView(ctx, "go back", func(wv port.WebView) error {
542 return d.navCoord.GoBackWebView(ctx, wv)
543 })
544}
545
546func (d *KeyboardDispatcher) handleGoForward(ctx context.Context) error {
547 return d.withActiveWebView(ctx, "go forward", func(wv port.WebView) error {
548 return d.navCoord.GoForwardWebView(ctx, wv)
549 })
550}
551
552func (d *KeyboardDispatcher) handlePrintPage(ctx context.Context) error {
553 return d.withActiveWebView(ctx, "print page", func(wv port.WebView) error {
554 return d.navCoord.PrintWebView(ctx, wv)
555 })
556}
557
558func (d *KeyboardDispatcher) handleOpenDevTools(ctx context.Context) error {
559 return d.withActiveWebView(ctx, "open devtools", func(wv port.WebView) error {
560 return d.navCoord.OpenDevToolsWebView(ctx, wv)
561 })
562}
563
564// handleZoom processes zoom in/out/reset actions for the active WebView.
565func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) error {
566 log := logging.FromContext(ctx)
567
568 if d.zoomUC == nil {
569 log.Warn().Msg("zoom use case not available")
570 return nil
571 }
572
573 wv := d.activeWebView(ctx)
574 if wv == nil {
575 log.Debug().Msg("no active webview for zoom")
576 return nil
577 }
578
579 zoomKey, err := usecase.ExtractZoomKey(wv.URI())
580 if err != nil {
581 log.Debug().Str("uri", wv.URI()).Msg("cannot extract zoom key")
582 return nil
583 }
584
585 current := wv.GetZoomLevel()
586 var newZoom *entity.ZoomLevel
587
588 switch action {
589 case "in":
590 newZoom, err = d.zoomUC.ZoomIn(ctx, zoomKey, current)
591 case "out":
592 newZoom, err = d.zoomUC.ZoomOut(ctx, zoomKey, current)
593 case "reset":
594 err = d.zoomUC.ResetZoom(ctx, zoomKey)
595 if err == nil {
596 newZoom = entity.NewZoomLevel(zoomKey, d.zoomUC.DefaultZoom())
597 }
598 }
599
600 if err != nil {
601 log.Error().Err(err).Str("action", action).Msg("zoom action failed")
602 return err
603 }
604
605 if newZoom != nil {
606 if setErr := wv.SetZoomLevel(ctx, newZoom.ZoomFactor); setErr != nil {
607 log.Error().Err(setErr).Float64("zoom", newZoom.ZoomFactor).Msg("failed to apply zoom")
608 return setErr
609 }
610
611 // Notify omnibox to update zoom indicator
612 d.navCoord.NotifyZoomChanged(ctx, newZoom.ZoomFactor)
613
614 // Show zoom toast on the active pane
615 zoomPercent := entity.ZoomPercentage(newZoom.ZoomFactor)
616 d.wsCoord.ShowZoomToast(ctx, zoomPercent)
617
618 log.Debug().
619 Str("zoom_key", zoomKey).
620 Str("action", action).
621 Float64("zoom", newZoom.ZoomFactor).
622 Msg("zoom applied")
623 }
624
625 return nil
626}
627
628// handleCopyURL copies the active pane's URL to clipboard.
629func (d *KeyboardDispatcher) handleCopyURL(ctx context.Context) error {
630 log := logging.FromContext(ctx)
631
632 if d.copyURLUC == nil {
633 log.Warn().Msg("copy URL use case not available")
634 return nil
635 }
636
637 wv := d.activeWebView(ctx)
638 if wv == nil {
639 log.Debug().Msg("no active webview for copy URL")
640 return nil
641 }
642
643 uri := wv.URI()
644 if uri == "" {
645 log.Debug().Msg("active webview has empty URI")
646 return nil
647 }
648
649 // Copy URL in background goroutine
650 go func() {
651 if err := d.copyURLUC.Copy(ctx, uri); err != nil {
652 log.Error().Err(err).Str("uri", uri).Msg("copy URL failed")
653 return
654 }
655
656 // Show toast on GTK main thread
657 cb := glib.SourceFunc(func(_ uintptr) bool {
658 d.wsCoord.ShowToastOnActivePane(ctx, "URL copied", component.ToastSuccess)
659 return false
660 })
661 glib.IdleAdd(&cb, 0)
662 }()
663
664 return nil
665}