1package ui
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "sync"
9
10 "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/application/usecase"
12 "github.com/bnema/dumber/internal/domain/entity"
13 "github.com/bnema/dumber/internal/infrastructure/config"
14 "github.com/bnema/dumber/internal/infrastructure/filtering"
15 "github.com/bnema/dumber/internal/infrastructure/webkit"
16
17 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers"
18 "github.com/bnema/dumber/internal/logging"
19 "github.com/bnema/dumber/internal/ui/adapter"
20 "github.com/bnema/dumber/internal/ui/component"
21 "github.com/bnema/dumber/internal/ui/coordinator"
22 "github.com/bnema/dumber/internal/ui/dispatcher"
23 "github.com/bnema/dumber/internal/ui/focus"
24 "github.com/bnema/dumber/internal/ui/input"
25 "github.com/bnema/dumber/internal/ui/layout"
26 "github.com/bnema/dumber/internal/ui/theme"
27 "github.com/bnema/dumber/internal/ui/window"
28 "github.com/jwijenbergh/puregotk/v4/gdk"
29 "github.com/jwijenbergh/puregotk/v4/gio"
30 "github.com/jwijenbergh/puregotk/v4/glib"
31 "github.com/jwijenbergh/puregotk/v4/gtk"
32)
33
34const (
35 // AppID is the application identifier for GTK.
36 AppID = "com.github.bnema.dumber"
37)
38
39// App wraps the GTK Application and manages the browser lifecycle.
40type App struct {
41 deps *Dependencies
42 gtkApp *gtk.Application
43 mainWindow *window.MainWindow
44
45 // State
46 tabs *entity.TabList
47 tabsUC *usecase.ManageTabsUseCase
48
49 // Coordinators (new architecture)
50 contentCoord *coordinator.ContentCoordinator
51 tabCoord *coordinator.TabCoordinator
52 wsCoord *coordinator.WorkspaceCoordinator
53 navCoord *coordinator.NavigationCoordinator
54 kbDispatcher *dispatcher.KeyboardDispatcher
55 configManager *config.Manager
56
57 // Pane management (used by coordinators)
58 panesUC *usecase.ManagePanesUseCase
59 workspaceViews map[entity.TabID]*component.WorkspaceView
60 widgetFactory layout.WidgetFactory
61 stackedPaneMgr *component.StackedPaneManager
62
63 // Input handling
64 keyboardHandler *input.KeyboardHandler
65 globalShortcutHandler *input.GlobalShortcutHandler
66
67 // Focus and border management
68 focusMgr *focus.Manager
69 borderMgr *focus.BorderManager
70
71 // Omnibox configuration (omnibox is created per workspace view)
72 omniboxCfg component.OmniboxConfig
73 // Find bar configuration (find bar is created per workspace view)
74 findBarCfg component.FindBarConfig
75
76 // Web content (managed by ContentCoordinator)
77 pool *webkit.WebViewPool
78 webViewFactory *webkit.WebViewFactory
79 injector *webkit.ContentInjector
80 router *webkit.MessageRouter
81 settings *webkit.SettingsManager
82 faviconAdapter *adapter.FaviconAdapter
83
84 // App-level toaster for system notifications (filter status, etc.)
85 appToaster *component.Toaster
86
87 // ID generator for tabs/panes
88 idCounter uint64
89 idMu sync.Mutex
90
91 // lifecycle
92 cancel context.CancelCauseFunc
93}
94
95// New creates a new App with the given dependencies.
96func New(deps *Dependencies) (*App, error) {
97 if err := deps.Validate(); err != nil {
98 return nil, err
99 }
100
101 ctx, cancel := context.WithCancelCause(deps.Ctx)
102
103 app := &App{
104 deps: deps,
105 tabs: entity.NewTabList(),
106 tabsUC: deps.TabsUC,
107 panesUC: deps.PanesUC,
108 workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
109 pool: deps.Pool,
110 injector: deps.Injector,
111 router: deps.MessageRouter,
112 settings: deps.Settings,
113 configManager: config.GetManager(),
114 cancel: cancel,
115 }
116 if app.router == nil {
117 app.router = webkit.NewMessageRouter(ctx)
118 }
119
120 // Register message handlers
121 if app.router != nil {
122 if err := handlers.RegisterAll(ctx, app.router, handlers.Config{
123 HistoryUC: deps.HistoryUC,
124 FavoritesUC: deps.FavoritesUC,
125 }); err != nil {
126 return nil, err
127 }
128 }
129
130 return app, nil
131}
132
133// Run starts the GTK application and blocks until it exits.
134// Returns the exit code.
135func (a *App) Run(ctx context.Context, args []string) int {
136 log := logging.FromContext(ctx)
137 log.Debug().Msg("creating GTK application")
138
139 // TODO: Use AppID once puregotk GC bug is fixed (nullable-string-gc-memory-corruption)
140 a.gtkApp = gtk.NewApplication(nil, gio.GApplicationFlagsNoneValue)
141 if a.gtkApp == nil {
142 log.Error().Msg("failed to create GTK application")
143 return 1
144 }
145 defer a.gtkApp.Unref()
146
147 // Connect activate signal
148 activateCb := func(_ gio.Application) {
149 a.onActivate(ctx)
150 }
151 a.gtkApp.ConnectActivate(&activateCb)
152
153 // Connect shutdown signal
154 shutdownCb := func(_ gio.Application) {
155 a.onShutdown(ctx)
156 }
157 a.gtkApp.ConnectShutdown(&shutdownCb)
158
159 log.Info().Msg("starting GTK main loop")
160 code := a.gtkApp.Run(len(args), args)
161
162 return code
163}
164
165// onActivate is called when the GTK application is activated.
166func (a *App) onActivate(ctx context.Context) {
167 log := logging.FromContext(ctx)
168 log.Debug().Msg("GTK application activated")
169
170 a.applyGTKColorSchemePreference(ctx)
171 a.prewarmWebViewPool(ctx)
172
173 if err := a.createMainWindow(ctx); err != nil {
174 log.Error().Err(err).Msg("failed to create main window")
175 return
176 }
177
178 a.initLayoutInfrastructure()
179 a.initAppToasterOverlay()
180 a.initFocusAndBorderOverlay()
181
182 a.initCoordinators(ctx)
183 a.initKeyboardHandler(ctx)
184 a.initOmniboxConfig(ctx)
185 a.initFindBarConfig()
186 a.createInitialTab(ctx)
187 a.finalizeActivation(ctx)
188}
189
190func (a *App) applyGTKColorSchemePreference(ctx context.Context) {
191 log := logging.FromContext(ctx)
192
193 // Propagate app color scheme preference to GTK (and WebKit)
194 // so web content can correctly evaluate prefers-color-scheme.
195 settings := gtk.SettingsGetDefault()
196 if settings == nil {
197 return
198 }
199
200 scheme := "default"
201 if a.deps != nil && a.deps.Config != nil && a.deps.Config.Appearance.ColorScheme != "" {
202 scheme = a.deps.Config.Appearance.ColorScheme
203 }
204 prefersDark := theme.ResolveColorScheme(scheme)
205 settings.SetPropertyGtkApplicationPreferDarkTheme(prefersDark)
206 log.Debug().
207 Str("scheme", scheme).
208 Bool("prefers_dark", prefersDark).
209 Msg("set gtk-application-prefer-dark-theme")
210}
211
212func (a *App) prewarmWebViewPool(ctx context.Context) {
213 // Set theme background color on pool to eliminate white flash.
214 // Must be done before prewarming so WebViews get the correct color.
215 if a.pool != nil && a.deps != nil && a.deps.Theme != nil {
216 r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
217 a.pool.SetBackgroundColor(r, g, b, alpha)
218 }
219
220 // Prewarm WebView pool now that GTK is initialized.
221 if a.pool == nil {
222 return
223 }
224 a.pool.Prewarm(ctx, 0)
225}
226
227func (a *App) createMainWindow(ctx context.Context) error {
228 mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config)
229 if err != nil {
230 return err
231 }
232 a.mainWindow = mainWindow
233
234 // Apply GTK CSS styling from theme manager.
235 if a.deps == nil || a.deps.Theme == nil {
236 return nil
237 }
238 if display := a.mainWindow.Window().GetDisplay(); display != nil {
239 a.deps.Theme.ApplyToDisplay(ctx, display)
240 }
241 return nil
242}
243
244func (a *App) initLayoutInfrastructure() {
245 // Initialize widget factory for pane layout.
246 a.widgetFactory = layout.NewGtkWidgetFactory()
247
248 // Initialize stacked pane manager for incremental widget operations.
249 a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory)
250}
251
252func (a *App) initAppToasterOverlay() {
253 if a.mainWindow == nil {
254 return
255 }
256
257 // Create app-level toaster for system notifications.
258 a.appToaster = component.NewToaster(a.widgetFactory)
259 toasterWidget := a.appToaster.Widget()
260 if toasterWidget == nil {
261 return
262 }
263 gtkWidget := toasterWidget.GtkWidget()
264 if gtkWidget == nil {
265 return
266 }
267 a.mainWindow.AddOverlay(gtkWidget)
268}
269
270func (a *App) initFocusAndBorderOverlay() {
271 // Initialize focus manager for geometric navigation.
272 a.focusMgr = focus.NewManager(a.panesUC)
273
274 // Initialize border manager for mode indicators.
275 a.borderMgr = focus.NewBorderManager(a.widgetFactory)
276
277 // Attach border overlay to main window (visible for all tabs).
278 if a.borderMgr == nil || a.mainWindow == nil {
279 return
280 }
281 borderWidget := a.borderMgr.Widget()
282 if borderWidget == nil {
283 return
284 }
285 gtkWidget := borderWidget.GtkWidget()
286 if gtkWidget == nil {
287 return
288 }
289 a.mainWindow.AddOverlay(gtkWidget)
290}
291
292func (a *App) initKeyboardHandler(ctx context.Context) {
293 if a.mainWindow == nil || a.deps == nil || a.deps.Config == nil {
294 return
295 }
296
297 // Create keyboard handler and wire to dispatcher.
298 a.keyboardHandler = input.NewKeyboardHandler(
299 ctx,
300 &a.deps.Config.Workspace,
301 )
302 a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error {
303 return a.kbDispatcher.Dispatch(ctx, action)
304 })
305 a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
306 a.handleModeChange(ctx, from, to)
307 })
308 a.keyboardHandler.SetShouldBypassInput(func() bool {
309 wsView := a.activeWorkspaceView()
310 if wsView == nil {
311 return false
312 }
313 return wsView.IsOmniboxVisible()
314 })
315 a.keyboardHandler.AttachTo(a.mainWindow.Window())
316
317 // Create global shortcut handler for Alt+1-9 tab switching.
318 // This uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
319 // to intercept shortcuts even when WebView has focus.
320 a.globalShortcutHandler = input.NewGlobalShortcutHandler(
321 ctx,
322 a.mainWindow.Window(),
323 func(ctx context.Context, action input.Action) error {
324 return a.kbDispatcher.Dispatch(ctx, action)
325 },
326 )
327}
328
329func (a *App) initOmniboxConfig(ctx context.Context) {
330 if a.deps == nil || a.deps.Config == nil {
331 return
332 }
333
334 log := logging.FromContext(ctx)
335
336 // Store omnibox config (omnibox is created per-pane via WorkspaceView).
337 a.omniboxCfg = component.OmniboxConfig{
338 HistoryUC: a.deps.HistoryUC,
339 FavoritesUC: a.deps.FavoritesUC,
340 FaviconAdapter: a.faviconAdapter,
341 CopyURLUC: a.deps.CopyURLUC,
342 Shortcuts: a.deps.Config.SearchShortcuts,
343 DefaultSearch: a.deps.Config.DefaultSearchEngine,
344 InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,
345 UIScale: a.deps.Config.DefaultUIScale,
346 OnNavigate: func(url string) {
347 if err := a.navCoord.Navigate(ctx, url); err != nil {
348 log.Error().Err(err).Str("url", url).Msg("navigation failed")
349 }
350 },
351 }
352 a.navCoord.SetOmniboxProvider(a)
353 log.Debug().Msg("omnibox config stored, provider set")
354}
355
356func (a *App) initFindBarConfig() {
357 // Store find bar config (find bar is created per-pane via WorkspaceView).
358 a.findBarCfg = component.FindBarConfig{
359 GetFindController: func(paneID entity.PaneID) port.FindController {
360 if a.contentCoord == nil {
361 return nil
362 }
363 wv := a.contentCoord.GetWebView(paneID)
364 if wv == nil {
365 return nil
366 }
367 return wv.GetFindController()
368 },
369 }
370}
371
372func (a *App) createInitialTab(ctx context.Context) {
373 log := logging.FromContext(ctx)
374
375 // Create an initial tab using coordinator.
376 initialURL := "dumb://home"
377 if a.deps != nil && a.deps.InitialURL != "" {
378 initialURL = a.deps.InitialURL
379 }
380 if _, err := a.tabCoord.Create(ctx, initialURL); err != nil {
381 log.Error().Err(err).Msg("failed to create initial tab")
382 }
383}
384
385func (a *App) finalizeActivation(ctx context.Context) {
386 log := logging.FromContext(ctx)
387
388 // Show the window.
389 if a.mainWindow != nil {
390 a.mainWindow.Show()
391 }
392 log.Info().Msg("main window displayed")
393
394 // Start watching config for appearance changes.
395 a.initConfigWatcher(ctx)
396
397 // Ensure the current appearance config is applied once at startup.
398 // This avoids the WebUI showing default tokens until the first config change event.
399 if a.deps != nil {
400 a.applyAppearanceConfig(ctx, a.deps.Config)
401 }
402
403 // Start async filter loading after window is visible.
404 a.initFilteringAsync(ctx)
405}
406
407// onShutdown is called when the GTK application is shutting down.
408func (a *App) onShutdown(ctx context.Context) {
409 log := logging.FromContext(ctx)
410 log.Debug().Msg("GTK application shutting down")
411
412 // Cancel context to signal all goroutines
413 a.cancel(errors.New("application shutdown"))
414
415 // Cleanup resources
416 if a.faviconAdapter != nil {
417 a.faviconAdapter.Close()
418 }
419 if a.deps.Pool != nil {
420 a.deps.Pool.Close(ctx)
421 }
422 // Close idle inhibitor to release D-Bus connection
423 if a.deps.IdleInhibitor != nil {
424 if err := a.deps.IdleInhibitor.Close(); err != nil {
425 log.Warn().Err(err).Msg("failed to close idle inhibitor")
426 }
427 }
428
429 log.Info().Msg("application shutdown complete")
430}
431
432// initCoordinators initializes all coordinators and wires their callbacks.
433func (a *App) initCoordinators(ctx context.Context) {
434 log := logging.FromContext(ctx)
435 log.Debug().Msg("initializing coordinators")
436
437 // Helper to get active workspace and view
438 getActiveWS := func() (*entity.Workspace, *component.WorkspaceView) {
439 return a.activeWorkspace(), a.activeWorkspaceView()
440 }
441
442 // Create FaviconAdapter with service and WebKit FaviconDatabase
443 var faviconDB *webkit.FaviconDatabase
444 if a.deps.WebContext != nil {
445 faviconDB = a.deps.WebContext.FaviconDatabase()
446 }
447 a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, faviconDB)
448
449 // 1. Content Coordinator (no dependencies on other coordinators)
450 a.contentCoord = coordinator.NewContentCoordinator(
451 ctx,
452 a.pool,
453 a.widgetFactory,
454 a.faviconAdapter,
455 getActiveWS,
456 a.deps.ZoomUC,
457 )
458
459 // Set idle inhibitor for fullscreen video playback
460 if a.deps.IdleInhibitor != nil {
461 a.contentCoord.SetIdleInhibitor(a.deps.IdleInhibitor)
462 }
463
464 // 2. Tab Coordinator
465 a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
466 TabsUC: a.tabsUC,
467 Tabs: a.tabs,
468 MainWindow: a.mainWindow,
469 Config: a.deps.Config,
470 })
471 a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {
472 a.createWorkspaceView(ctx, tab)
473 })
474 a.tabCoord.SetOnTabSwitched(func(ctx context.Context, tab *entity.Tab) {
475 a.switchWorkspaceView(ctx, tab.ID)
476 })
477 a.tabCoord.SetOnQuit(a.Quit)
478 // Wire popup tab WebView attachment
479 a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) {
480 a.attachPopupToTab(ctx, tabID, pane, wv)
481 })
482
483 // Wire tab bar click handling to coordinator
484 if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
485 a.mainWindow.TabBar().SetOnSwitch(func(tabID entity.TabID) {
486 if err := a.tabCoord.Switch(ctx, tabID); err != nil {
487 log.Error().Err(err).Str("tab_id", string(tabID)).Msg("tab switch failed")
488 }
489 })
490 }
491
492 // 3. Workspace Coordinator
493 a.wsCoord = coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{
494 PanesUC: a.panesUC,
495 FocusMgr: a.focusMgr,
496 StackedPaneMgr: a.stackedPaneMgr,
497 WidgetFactory: a.widgetFactory,
498 ContentCoord: a.contentCoord,
499 GetActiveWS: getActiveWS,
500 GenerateID: a.generateID,
501 })
502 a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error {
503 return a.tabCoord.Close(ctx)
504 })
505
506 // Wire popup handling
507 a.webViewFactory = webkit.NewWebViewFactory(
508 a.deps.WebContext,
509 a.settings,
510 a.pool,
511 a.injector,
512 a.router,
513 )
514 // Set theme background color on factory to eliminate white flash
515 if a.deps.Theme != nil {
516 r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
517 a.webViewFactory.SetBackgroundColor(r, g, b, alpha)
518 }
519 a.contentCoord.SetPopupConfig(
520 a.webViewFactory,
521 &a.deps.Config.Workspace.Popups,
522 a.generateID,
523 )
524 a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input coordinator.InsertPopupInput) error {
525 return a.wsCoord.InsertPopup(ctx, input)
526 })
527 a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error {
528 return a.wsCoord.ClosePaneByID(ctx, paneID)
529 })
530 // Wire tabbed popup behavior to create new tabs
531 a.wsCoord.SetOnCreatePopupTab(func(ctx context.Context, input coordinator.InsertPopupInput) error {
532 // Create a new tab with the popup pane
533 tab, err := a.tabCoord.CreateWithPane(ctx, input.PopupPane, input.WebView, input.TargetURI)
534 if err != nil {
535 return err
536 }
537 log.Debug().Str("tab_id", string(tab.ID)).Msg("created tab for popup")
538 return nil
539 })
540
541 // 4. Navigation Coordinator
542 a.navCoord = coordinator.NewNavigationCoordinator(
543 ctx,
544 a.deps.NavigateUC,
545 a.contentCoord,
546 )
547
548 // Wire title updates to history persistence
549 a.contentCoord.SetOnTitleUpdated(func(ctx context.Context, paneID entity.PaneID, url, title string) {
550 a.navCoord.UpdateHistoryTitle(ctx, paneID, url, title)
551 })
552
553 // Wire history recording on LoadCommitted (URI is guaranteed correct at this point)
554 a.contentCoord.SetOnHistoryRecord(func(ctx context.Context, paneID entity.PaneID, url string) {
555 a.navCoord.RecordHistory(ctx, paneID, url)
556 })
557
558 // 5. Keyboard Dispatcher
559 a.kbDispatcher = dispatcher.NewKeyboardDispatcher(
560 ctx,
561 a.tabCoord,
562 a.wsCoord,
563 a.navCoord,
564 a.deps.ZoomUC,
565 a.deps.CopyURLUC,
566 )
567 a.kbDispatcher.SetOnQuit(a.Quit)
568 a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {
569 a.ToggleFindBar(ctx)
570 return nil
571 })
572 a.kbDispatcher.SetOnFindNext(func(ctx context.Context) error {
573 a.FindNext(ctx)
574 return nil
575 })
576 a.kbDispatcher.SetOnFindPrev(func(ctx context.Context) error {
577 a.FindPrevious(ctx)
578 return nil
579 })
580 a.kbDispatcher.SetOnFindClose(func(ctx context.Context) error {
581 a.CloseFindBar(ctx)
582 return nil
583 })
584
585 // Wire gesture handler to dispatcher (for mouse button 8/9 navigation)
586 a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error {
587 return a.kbDispatcher.Dispatch(ctx, action)
588 })
589
590 log.Debug().Msg("coordinators initialized")
591}
592
593// generateID generates a unique ID for tabs and panes.
594// Uses monotonically increasing counter to avoid ID collisions.
595func (a *App) generateID() string {
596 a.idMu.Lock()
597 defer a.idMu.Unlock()
598 a.idCounter++
599 return fmt.Sprintf("p%d", a.idCounter)
600}
601
602// Tabs returns the tab list.
603func (a *App) Tabs() *entity.TabList {
604 return a.tabs
605}
606
607// MainWindow returns the main window.
608func (a *App) MainWindow() *window.MainWindow {
609 return a.mainWindow
610}
611
612// Quit requests the application to quit.
613func (a *App) Quit() {
614 if a.gtkApp != nil {
615 a.gtkApp.Quit()
616 }
617}
618
619// RunWithArgs is a convenience function that creates and runs an App.
620func RunWithArgs(ctx context.Context, deps *Dependencies) int {
621 app, err := New(deps)
622 if err != nil {
623 log := logging.FromContext(ctx)
624 log.Error().Err(err).Msg("failed to create application")
625 return 1
626 }
627 return app.Run(ctx, os.Args)
628}
629
630// handleModeChange is called when the input mode changes.
631func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
632 log := logging.FromContext(ctx)
633 log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")
634
635 // Update border overlay visibility based on mode
636 if a.borderMgr != nil {
637 a.borderMgr.OnModeChange(ctx, from, to)
638 }
639}
640
641// createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.
642func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
643 log := logging.FromContext(ctx)
644
645 if a.widgetFactory == nil {
646 log.Error().Msg("widget factory not initialized")
647 return
648 }
649
650 // Create workspace view
651 wsView := component.NewWorkspaceView(ctx, a.widgetFactory)
652 if wsView == nil {
653 log.Error().Msg("failed to create workspace view")
654 return
655 }
656
657 // Set the workspace
658 if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil {
659 log.Error().Err(err).Msg("failed to set workspace in view")
660 return
661 }
662
663 // Ensure WebViews are attached to panes
664 if a.contentCoord != nil {
665 a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
666 }
667
668 // Note: Border overlay is attached to MainWindow, not per-workspace
669 // This ensures it's visible regardless of which tab is active
670
671 // Set omnibox config for this workspace view
672 wsView.SetOmniboxConfig(a.omniboxCfg)
673 // Set find bar config for this workspace view
674 wsView.SetFindBarConfig(a.findBarCfg)
675
676 // Store in map
677 a.workspaceViews[tab.ID] = wsView
678
679 // Attach to content area
680 if a.mainWindow != nil {
681 widget := wsView.Widget()
682 if widget != nil {
683 gtkWidget := widget.GtkWidget()
684 if gtkWidget != nil {
685 a.mainWindow.SetContent(gtkWidget)
686 }
687 }
688 }
689
690 log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created and attached")
691}
692
693// activeWorkspace returns the workspace of the active tab.
694func (a *App) activeWorkspace() *entity.Workspace {
695 activeTab := a.tabs.ActiveTab()
696 if activeTab == nil {
697 return nil
698 }
699 return activeTab.Workspace
700}
701
702// activeWorkspaceView returns the workspace view for the active tab.
703func (a *App) activeWorkspaceView() *component.WorkspaceView {
704 activeTab := a.tabs.ActiveTab()
705 if activeTab == nil {
706 return nil
707 }
708 return a.workspaceViews[activeTab.ID]
709}
710
711// attachPopupToTab attaches a popup WebView to a newly created tab.
712// This is called when a popup uses tabbed behavior.
713func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) {
714 log := logging.FromContext(ctx)
715
716 wsView := a.workspaceViews[tabID]
717 if wsView == nil {
718 log.Warn().Str("tab_id", string(tabID)).Msg("workspace view not found for popup tab")
719 return
720 }
721
722 // Register WebView with content coordinator
723 if a.contentCoord != nil {
724 // Track the WebView
725 a.contentCoord.RegisterPopupWebView(pane.ID, wv)
726
727 // Wrap and attach widget
728 widget := a.contentCoord.WrapWidget(ctx, wv)
729 if widget != nil {
730 paneView := wsView.GetPaneView(pane.ID)
731 if paneView != nil {
732 paneView.SetWebViewWidget(widget)
733 } else {
734 log.Warn().Str("pane_id", string(pane.ID)).Msg("pane view not found for popup")
735 }
736 }
737
738 // Setup popup handling for nested popups
739 a.contentCoord.SetupPopupHandling(ctx, pane.ID, wv)
740 }
741
742 log.Debug().
743 Str("tab_id", string(tabID)).
744 Str("pane_id", string(pane.ID)).
745 Msg("popup webview attached to tab")
746}
747
748// switchWorkspaceView swaps the displayed workspace view for a tab.
749func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
750 log := logging.FromContext(ctx)
751
752 wsView, exists := a.workspaceViews[tabID]
753 if !exists {
754 log.Warn().Str("tab_id", string(tabID)).Msg("no workspace view for tab")
755 return
756 }
757
758 // Get the workspace view's root widget
759 widget := wsView.Widget()
760 if widget == nil {
761 log.Warn().Str("tab_id", string(tabID)).Msg("workspace view has no widget")
762 return
763 }
764
765 gtkWidget := widget.GtkWidget()
766 if gtkWidget == nil {
767 log.Warn().Str("tab_id", string(tabID)).Msg("workspace view widget has no GTK widget")
768 return
769 }
770
771 // Swap content (MainWindow.SetContent now properly removes old content)
772 if a.mainWindow != nil {
773 a.mainWindow.SetContent(gtkWidget)
774 }
775
776 log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched")
777}
778
779// ToggleOmnibox implements OmniboxProvider.
780// Toggles the omnibox visibility in the active workspace view.
781func (a *App) ToggleOmnibox(ctx context.Context) {
782 log := logging.FromContext(ctx)
783
784 wsView := a.activeWorkspaceView()
785 if wsView == nil {
786 log.Warn().Msg("no active workspace view for omnibox toggle")
787 return
788 }
789
790 if wsView.IsOmniboxVisible() {
791 wsView.HideOmnibox()
792 } else {
793 wsView.ShowOmnibox(ctx, "")
794 }
795}
796
797// ToggleFindBar shows or hides the find bar in the active workspace view.
798func (a *App) ToggleFindBar(ctx context.Context) {
799 log := logging.FromContext(ctx)
800
801 wsView := a.activeWorkspaceView()
802 if wsView == nil {
803 log.Warn().Msg("no active workspace view for find bar toggle")
804 return
805 }
806
807 if wsView.IsFindBarVisible() {
808 wsView.HideFindBar()
809 } else {
810 wsView.ShowFindBar(ctx)
811 }
812}
813
814// FindNext selects the next match in the active find bar.
815func (a *App) FindNext(ctx context.Context) {
816 wsView := a.activeWorkspaceView()
817 if wsView == nil {
818 return
819 }
820
821 if !wsView.IsFindBarVisible() {
822 wsView.ShowFindBar(ctx)
823 }
824 wsView.FindNext()
825}
826
827// FindPrevious selects the previous match in the active find bar.
828func (a *App) FindPrevious(ctx context.Context) {
829 wsView := a.activeWorkspaceView()
830 if wsView == nil {
831 return
832 }
833
834 if !wsView.IsFindBarVisible() {
835 wsView.ShowFindBar(ctx)
836 }
837 wsView.FindPrevious()
838}
839
840// CloseFindBar hides the find bar if visible.
841func (a *App) CloseFindBar(ctx context.Context) {
842 wsView := a.activeWorkspaceView()
843 if wsView == nil {
844 return
845 }
846 wsView.HideFindBar()
847}
848
849// UpdateOmniboxZoom implements OmniboxProvider.
850// Updates the zoom indicator on the current omnibox if visible.
851func (a *App) UpdateOmniboxZoom(factor float64) {
852 wsView := a.activeWorkspaceView()
853 if wsView == nil {
854 return
855 }
856
857 omnibox := wsView.GetOmnibox()
858 if omnibox != nil {
859 omnibox.UpdateZoomIndicator(factor)
860 }
861}
862
863// SetOmniboxOnNavigate implements OmniboxProvider.
864// This is called to set the navigation callback on new omniboxes.
865// Since omniboxes are created per-pane, we store the config with the callback.
866func (a *App) SetOmniboxOnNavigate(fn func(url string)) {
867 // The navigate callback is set when the omnibox is created via WorkspaceView.ShowOmnibox
868 // The WorkspaceView uses the stored omniboxCfg and sets up navigation via the omnibox's SetOnNavigate
869}
870
871// initFilteringAsync starts background filter loading with toast feedback.
872func (a *App) initConfigWatcher(ctx context.Context) {
873 log := logging.FromContext(ctx)
874
875 if a.configManager == nil {
876 log.Debug().Msg("no config manager available, skipping watcher")
877 return
878 }
879
880 // Start viper watcher
881 if err := a.configManager.Watch(); err != nil {
882 log.Warn().Err(err).Msg("failed to start config watcher")
883 return
884 }
885
886 // Only appearance is hot-reloaded for now.
887 a.configManager.OnConfigChange(func(newCfg *config.Config) {
888 cfgCopy := newCfg
889 cb := glib.SourceFunc(func(_ uintptr) bool {
890 a.applyAppearanceConfig(ctx, cfgCopy)
891 return false
892 })
893 glib.IdleAdd(&cb, 0)
894 })
895
896 log.Debug().Msg("config watcher initialized")
897}
898
899func (a *App) applyAppearanceConfig(ctx context.Context, cfg *config.Config) {
900 log := logging.FromContext(ctx)
901 if cfg == nil {
902 return
903 }
904
905 // Update the shared config pointer in-place so existing references see changes.
906 if a.deps != nil && a.deps.Config != nil {
907 *a.deps.Config = *cfg
908 }
909
910 // Update WebKit settings manager
911 if a.settings != nil {
912 a.settings.UpdateFromConfig(ctx, cfg)
913 }
914
915 // Apply settings to existing webviews via coordinator
916 if a.contentCoord != nil {
917 a.contentCoord.ApplySettingsToAll(ctx, a.settings)
918 }
919
920 // Update GTK theme and injected WebUI theme vars
921 if a.deps != nil && a.deps.Theme != nil {
922 var display *gdk.Display
923 if a.mainWindow != nil && a.mainWindow.Window() != nil {
924 display = a.mainWindow.Window().GetDisplay()
925 }
926 a.deps.Theme.UpdateFromConfig(ctx, cfg, display)
927
928 // Keep injector's dark-mode flag in sync for future navigations
929 if a.injector != nil {
930 a.injector.SetPrefersDark(a.deps.Theme.PrefersDark())
931 findCSS := theme.GenerateFindHighlightCSS(a.deps.Theme.GetCurrentPalette())
932 if err := a.injector.InjectFindHighlightCSS(ctx, findCSS); err != nil {
933 log.Warn().Err(err).Msg("failed to update find highlight CSS")
934 }
935 }
936
937 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(a.injector)
938 cssText := a.deps.Theme.GetWebUIThemeCSS()
939 if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: cssText}); err != nil {
940 log.Warn().Err(err).Msg("failed to update WebUI theme CSS")
941 }
942
943 // Refresh injected scripts so future navigations use the latest theme.
944 if a.contentCoord != nil && a.injector != nil {
945 a.contentCoord.RefreshInjectedScriptsToAll(ctx, a.injector)
946 }
947
948 // Apply WebUI theme to already-loaded dumb:// pages
949 if a.contentCoord != nil {
950 a.contentCoord.ApplyWebUIThemeToAll(ctx, a.deps.Theme.PrefersDark(), cssText)
951 }
952 }
953
954 log.Info().Msg("appearance config updated")
955}
956
957func (a *App) initFilteringAsync(ctx context.Context) {
958 if a.deps.FilterManager == nil {
959 return
960 }
961
962 a.deps.FilterManager.SetStatusCallback(func(status filtering.FilterStatus) {
963 statusCopy := status // Capture for closure
964 cb := glib.SourceFunc(func(_ uintptr) bool {
965 a.showFilterStatus(ctx, statusCopy)
966 return false // Don't repeat
967 })
968 glib.IdleAdd(&cb, 0)
969 })
970 a.deps.FilterManager.LoadAsync(ctx)
971}
972
973// showFilterStatus displays toast notification for filter status.
974func (a *App) showFilterStatus(ctx context.Context, status filtering.FilterStatus) {
975 log := logging.FromContext(ctx)
976
977 switch status.State {
978 case filtering.StateLoading:
979 if a.appToaster != nil {
980 a.appToaster.Show(ctx, status.Message, component.ToastInfo)
981 }
982 case filtering.StateActive:
983 // Apply filters to existing webviews that were created before filters loaded
984 if a.contentCoord != nil && a.deps.FilterManager != nil {
985 a.contentCoord.ApplyFiltersToAll(ctx, a.deps.FilterManager)
986 log.Debug().Msg("applied filters to all existing webviews")
987 }
988 if a.appToaster != nil {
989 a.appToaster.Show(ctx, fmt.Sprintf("Ad blocker ready (%s)", status.Version), component.ToastInfo)
990 }
991 case filtering.StateError:
992 if a.appToaster != nil {
993 a.appToaster.Show(ctx, "Filter load failed: "+status.Message, component.ToastError)
994 }
995 }
996}