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