package dispatcher import ( "context" "fmt" "github.com/bnema/dumber/internal/application/usecase" "github.com/bnema/dumber/internal/domain/entity" domainurl "github.com/bnema/dumber/internal/domain/url" "github.com/bnema/dumber/internal/infrastructure/config" "github.com/bnema/dumber/internal/logging" "github.com/bnema/dumber/internal/ui/component" "github.com/bnema/dumber/internal/ui/coordinator" "github.com/bnema/dumber/internal/ui/input" "github.com/jwijenbergh/puregotk/v4/glib" ) // KeyboardDispatcher routes keyboard actions to appropriate coordinators. type KeyboardDispatcher struct { tabCoord *coordinator.TabCoordinator wsCoord *coordinator.WorkspaceCoordinator navCoord *coordinator.NavigationCoordinator zoomUC *usecase.ManageZoomUseCase copyURLUC *usecase.CopyURLUseCase actionHandlers map[input.Action]func(ctx context.Context) error onQuit func() onFindOpen func(ctx context.Context) error onFindNext func(ctx context.Context) error onFindPrev func(ctx context.Context) error onFindClose func(ctx context.Context) error onSessionOpen func(ctx context.Context) error onMovePaneToTab func(ctx context.Context) error onMovePaneToNext func(ctx context.Context) error } // NewKeyboardDispatcher creates a new KeyboardDispatcher. func NewKeyboardDispatcher( ctx context.Context, tabCoord *coordinator.TabCoordinator, wsCoord *coordinator.WorkspaceCoordinator, navCoord *coordinator.NavigationCoordinator, zoomUC *usecase.ManageZoomUseCase, copyURLUC *usecase.CopyURLUseCase, ) *KeyboardDispatcher { log := logging.FromContext(ctx) log.Debug().Msg("creating keyboard dispatcher") dispatcher := &KeyboardDispatcher{ tabCoord: tabCoord, wsCoord: wsCoord, navCoord: navCoord, zoomUC: zoomUC, copyURLUC: copyURLUC, } dispatcher.initActionHandlers() return dispatcher } // SetOnQuit sets the callback for quit action. func (d *KeyboardDispatcher) SetOnQuit(fn func()) { d.onQuit = fn } // SetOnFindOpen sets the callback for opening the find bar. func (d *KeyboardDispatcher) SetOnFindOpen(fn func(ctx context.Context) error) { d.onFindOpen = fn } // SetOnFindNext sets the callback for the next match action. func (d *KeyboardDispatcher) SetOnFindNext(fn func(ctx context.Context) error) { d.onFindNext = fn } // SetOnFindPrev sets the callback for the previous match action. func (d *KeyboardDispatcher) SetOnFindPrev(fn func(ctx context.Context) error) { d.onFindPrev = fn } // SetOnFindClose sets the callback for closing the find bar. func (d *KeyboardDispatcher) SetOnFindClose(fn func(ctx context.Context) error) { d.onFindClose = fn } // SetOnSessionOpen sets the callback for opening the session manager. func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context) error) { d.onSessionOpen = fn } func (d *KeyboardDispatcher) SetOnMovePaneToTab(fn func(ctx context.Context) error) { d.onMovePaneToTab = fn } func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context) error) { d.onMovePaneToNext = fn } func (d *KeyboardDispatcher) initActionHandlers() { const ( firstTabIndex = 0 secondTabIndex = 1 thirdTabIndex = 2 fourthTabIndex = 3 fifthTabIndex = 4 sixthTabIndex = 5 seventhTabIndex = 6 eighthTabIndex = 7 ninthTabIndex = 8 tenthTabIndex = 9 ) d.actionHandlers = map[input.Action]func(ctx context.Context) error{ // Tab actions input.ActionNewTab: func(ctx context.Context) error { cfg := config.Get() _, err := d.tabCoord.Create(ctx, domainurl.Normalize(cfg.Workspace.NewPaneURL)) return err }, input.ActionCloseTab: d.tabCoord.Close, input.ActionNextTab: d.tabCoord.SwitchNext, input.ActionPreviousTab: d.tabCoord.SwitchPrev, input.ActionSwitchLastTab: d.tabCoord.SwitchToLastActive, input.ActionSwitchTabIndex1: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, firstTabIndex) }, input.ActionSwitchTabIndex2: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, secondTabIndex) }, input.ActionSwitchTabIndex3: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, thirdTabIndex) }, input.ActionSwitchTabIndex4: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, fourthTabIndex) }, input.ActionSwitchTabIndex5: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, fifthTabIndex) }, input.ActionSwitchTabIndex6: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, sixthTabIndex) }, input.ActionSwitchTabIndex7: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, seventhTabIndex) }, input.ActionSwitchTabIndex8: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, eighthTabIndex) }, input.ActionSwitchTabIndex9: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, ninthTabIndex) }, input.ActionSwitchTabIndex10: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, tenthTabIndex) }, input.ActionRenameTab: func(ctx context.Context) error { return d.logNoop(ctx, "rename tab action (not yet implemented)") }, // Pane actions input.ActionSplitRight: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitRight) }, input.ActionSplitLeft: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitLeft) }, input.ActionSplitUp: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitUp) }, input.ActionSplitDown: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitDown) }, input.ActionClosePane: d.wsCoord.ClosePane, input.ActionStackPane: d.wsCoord.StackPane, input.ActionMovePaneToTab: func(ctx context.Context) error { return d.handleMovePaneToTab(ctx) }, input.ActionMovePaneToNextTab: func(ctx context.Context) error { return d.handleMovePaneToNextTab(ctx) }, input.ActionConsumeOrExpelLeft: func(ctx context.Context) error { return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft) }, input.ActionConsumeOrExpelRight: func(ctx context.Context) error { return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelRight) }, input.ActionConsumeOrExpelUp: func(ctx context.Context) error { return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelUp) }, input.ActionConsumeOrExpelDown: func(ctx context.Context) error { return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelDown) }, input.ActionFocusRight: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavRight) }, input.ActionFocusLeft: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavLeft) }, input.ActionFocusUp: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavUp) }, input.ActionFocusDown: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavDown) }, // Resize actions input.ActionResizeIncreaseLeft: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseLeft) }, input.ActionResizeIncreaseRight: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseRight) }, input.ActionResizeIncreaseUp: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseUp) }, input.ActionResizeIncreaseDown: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseDown) }, input.ActionResizeDecreaseLeft: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseLeft) }, input.ActionResizeDecreaseRight: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseRight) }, input.ActionResizeDecreaseUp: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseUp) }, input.ActionResizeDecreaseDown: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseDown) }, input.ActionResizeIncrease: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncrease) }, input.ActionResizeDecrease: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecrease) }, // Stack navigation input.ActionStackNavUp: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "up") }, input.ActionStackNavDown: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "down") }, // Navigation input.ActionGoBack: d.navCoord.GoBack, input.ActionGoForward: d.navCoord.GoForward, input.ActionReload: d.navCoord.Reload, input.ActionHardReload: d.navCoord.HardReload, // Zoom actions input.ActionZoomIn: func(ctx context.Context) error { return d.handleZoom(ctx, "in") }, input.ActionZoomOut: func(ctx context.Context) error { return d.handleZoom(ctx, "out") }, input.ActionZoomReset: func(ctx context.Context) error { return d.handleZoom(ctx, "reset") }, // UI input.ActionOpenOmnibox: d.navCoord.OpenOmnibox, input.ActionOpenFind: d.handleFindOpen, input.ActionFindNext: d.handleFindNext, input.ActionFindPrev: d.handleFindPrev, input.ActionCloseFind: d.handleFindClose, input.ActionOpenDevTools: d.navCoord.OpenDevTools, input.ActionToggleFullscreen: func(ctx context.Context) error { return d.logNoop(ctx, "toggle fullscreen action (not yet implemented)") }, // Clipboard input.ActionCopyURL: d.handleCopyURL, // Session management input.ActionOpenSessionManager: d.handleSessionOpen, // Application input.ActionQuit: d.handleQuit, } } // Dispatch routes a keyboard action to the appropriate coordinator. func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action) error { log := logging.FromContext(ctx) log.Debug().Str("action", string(action)).Msg("dispatching keyboard action") if handler, ok := d.actionHandlers[action]; ok { return handler(ctx) } log.Warn().Str("action", string(action)).Msg("unhandled keyboard action") return nil } func (d *KeyboardDispatcher) handleFindOpen(ctx context.Context) error { if d.onFindOpen != nil { return d.onFindOpen(ctx) } logging.FromContext(ctx).Debug().Msg("find open action (no handler)") return nil } func (d *KeyboardDispatcher) handleFindNext(ctx context.Context) error { if d.onFindNext != nil { return d.onFindNext(ctx) } logging.FromContext(ctx).Debug().Msg("find next action (no handler)") return nil } func (d *KeyboardDispatcher) handleFindPrev(ctx context.Context) error { if d.onFindPrev != nil { return d.onFindPrev(ctx) } logging.FromContext(ctx).Debug().Msg("find prev action (no handler)") return nil } func (d *KeyboardDispatcher) handleFindClose(ctx context.Context) error { if d.onFindClose != nil { return d.onFindClose(ctx) } logging.FromContext(ctx).Debug().Msg("find close action (no handler)") return nil } func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error { if d.onSessionOpen != nil { return d.onSessionOpen(ctx) } logging.FromContext(ctx).Debug().Msg("session open action (no handler)") return nil } func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error { if d.onMovePaneToTab != nil { return d.onMovePaneToTab(ctx) } logging.FromContext(ctx).Debug().Msg("move pane to tab action (no handler)") return nil } func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error { if d.onMovePaneToNext != nil { return d.onMovePaneToNext(ctx) } logging.FromContext(ctx).Debug().Msg("move pane to next tab action (no handler)") return nil } func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error { if ctx == nil { return fmt.Errorf("missing context") } logging.FromContext(ctx).Debug().Msg(message) return nil } func (d *KeyboardDispatcher) handleQuit(ctx context.Context) error { if d.onQuit != nil { d.onQuit() } if ctx == nil { return fmt.Errorf("missing context") } return nil } // handleZoom processes zoom in/out/reset actions for the active WebView. func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) error { log := logging.FromContext(ctx) if d.zoomUC == nil { log.Warn().Msg("zoom use case not available") return nil } wv := d.navCoord.ActiveWebView(ctx) if wv == nil { log.Debug().Msg("no active webview for zoom") return nil } domain, err := usecase.ExtractDomain(wv.URI()) if err != nil { log.Debug().Str("uri", wv.URI()).Msg("cannot extract domain for zoom") return nil } current := wv.GetZoomLevel() var newZoom *entity.ZoomLevel switch action { case "in": newZoom, err = d.zoomUC.ZoomIn(ctx, domain, current) case "out": newZoom, err = d.zoomUC.ZoomOut(ctx, domain, current) case "reset": err = d.zoomUC.ResetZoom(ctx, domain) if err == nil { newZoom = entity.NewZoomLevel(domain, d.zoomUC.DefaultZoom()) } } if err != nil { log.Error().Err(err).Str("action", action).Msg("zoom action failed") return err } if newZoom != nil { if setErr := wv.SetZoomLevel(ctx, newZoom.ZoomFactor); setErr != nil { log.Error().Err(setErr).Float64("zoom", newZoom.ZoomFactor).Msg("failed to apply zoom") return setErr } // Notify omnibox to update zoom indicator d.navCoord.NotifyZoomChanged(ctx, newZoom.ZoomFactor) // Show zoom toast on the active pane zoomPercent := int(newZoom.ZoomFactor * 100) d.wsCoord.ShowZoomToast(ctx, zoomPercent) log.Debug(). Str("domain", domain). Str("action", action). Float64("zoom", newZoom.ZoomFactor). Msg("zoom applied") } return nil } // handleCopyURL copies the active pane's URL to clipboard. func (d *KeyboardDispatcher) handleCopyURL(ctx context.Context) error { log := logging.FromContext(ctx) if d.copyURLUC == nil { log.Warn().Msg("copy URL use case not available") return nil } wv := d.navCoord.ActiveWebView(ctx) if wv == nil { log.Debug().Msg("no active webview for copy URL") return nil } uri := wv.URI() if uri == "" { log.Debug().Msg("active webview has empty URI") return nil } // Copy URL in background goroutine go func() { if err := d.copyURLUC.Copy(ctx, uri); err != nil { log.Error().Err(err).Str("uri", uri).Msg("copy URL failed") return } // Show toast on GTK main thread cb := glib.SourceFunc(func(_ uintptr) bool { d.wsCoord.ShowToastOnActivePane(ctx, "URL copied", component.ToastSuccess) return false }) glib.IdleAdd(&cb, 0) }() return nil }