1package ui
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "sync"
10
11 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/application/usecase"
13 "github.com/bnema/dumber/internal/domain/entity"
14 "github.com/bnema/dumber/internal/infrastructure/config"
15 "github.com/bnema/dumber/internal/infrastructure/desktop"
16 "github.com/bnema/dumber/internal/infrastructure/filesystem"
17 "github.com/bnema/dumber/internal/infrastructure/filtering"
18 "github.com/bnema/dumber/internal/infrastructure/snapshot"
19 "github.com/bnema/dumber/internal/infrastructure/textinput"
20 "github.com/bnema/dumber/internal/infrastructure/webkit"
21
22 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers"
23 "github.com/bnema/dumber/internal/logging"
24 "github.com/bnema/dumber/internal/ui/adapter"
25 "github.com/bnema/dumber/internal/ui/component"
26 "github.com/bnema/dumber/internal/ui/coordinator"
27 "github.com/bnema/dumber/internal/ui/dispatcher"
28 "github.com/bnema/dumber/internal/ui/focus"
29 "github.com/bnema/dumber/internal/ui/input"
30 "github.com/bnema/dumber/internal/ui/layout"
31 "github.com/bnema/dumber/internal/ui/theme"
32 "github.com/bnema/dumber/internal/ui/window"
33 "github.com/jwijenbergh/puregotk/v4/adw"
34 "github.com/jwijenbergh/puregotk/v4/gdk"
35 "github.com/jwijenbergh/puregotk/v4/gio"
36 "github.com/jwijenbergh/puregotk/v4/glib"
37 "github.com/jwijenbergh/puregotk/v4/gtk"
38)
39
40const (
41 // AppID is the application identifier for GTK.
42 AppID = "com.github.bnema.dumber"
43)
44
45// App wraps the GTK Application and manages the browser lifecycle.
46type App struct {
47 deps *Dependencies
48 gtkApp *gtk.Application
49 mainWindow *window.MainWindow
50
51 // State
52 tabs *entity.TabList
53 tabsUC *usecase.ManageTabsUseCase
54
55 // Coordinators (new architecture)
56 contentCoord *coordinator.ContentCoordinator
57 tabCoord *coordinator.TabCoordinator
58 wsCoord *coordinator.WorkspaceCoordinator
59 navCoord *coordinator.NavigationCoordinator
60 kbDispatcher *dispatcher.KeyboardDispatcher
61 configManager *config.Manager
62
63 // Pane management (used by coordinators)
64 panesUC *usecase.ManagePanesUseCase
65 workspaceViews map[entity.TabID]*component.WorkspaceView
66 widgetFactory layout.WidgetFactory
67 stackedPaneMgr *component.StackedPaneManager
68
69 // Input handling
70 keyboardHandler *input.KeyboardHandler
71 globalShortcutHandler *input.GlobalShortcutHandler
72
73 // Focus and border management
74 focusMgr *focus.Manager
75 borderMgr *focus.BorderManager
76
77 resizeModeBorderTarget layout.Widget
78
79 // Omnibox configuration (omnibox is created per workspace view)
80 omniboxCfg component.OmniboxConfig
81 // Find bar configuration (find bar is created per workspace view)
82 findBarCfg component.FindBarConfig
83
84 // Web content (managed by ContentCoordinator)
85 pool *webkit.WebViewPool
86 webViewFactory *webkit.WebViewFactory
87 injector *webkit.ContentInjector
88 router *webkit.MessageRouter
89 settings *webkit.SettingsManager
90 faviconAdapter *adapter.FaviconAdapter
91
92 // App-level toaster for system notifications (filter status, etc.)
93 appToaster *component.Toaster
94 // Mode indicator toaster for modal mode notifications (pane, tab, session, resize)
95 modeToaster *component.Toaster
96
97 // Session management
98 sessionManager *component.SessionManager
99 tabPicker *component.TabPicker
100 tabPickerWidget layout.Widget
101 tabPickerPaneID entity.PaneID
102 snapshotService *snapshot.Service
103
104 // Update management
105 updateCoord *coordinator.UpdateCoordinator
106
107 // ID generator for tabs/panes
108 idCounter uint64
109 idMu sync.Mutex
110 firstWebViewShownOnce sync.Once
111
112 movePaneToTabUC *usecase.MovePaneToTabUseCase
113
114 // Accent picker for dead keys support
115 accentPicker *component.AccentPicker
116 insertAccentUC *usecase.InsertAccentUseCase
117 accentFocusProvider *textinput.FocusProvider
118
119 // Deferred initialization - runs after first load_started to avoid blocking initial navigation
120 deferredInitOnce sync.Once
121 deferredInitFn func()
122
123 // lifecycle
124 cancel context.CancelCauseFunc
125}
126
127// New creates a new App with the given dependencies.
128func New(deps *Dependencies) (*App, error) {
129 if err := deps.Validate(); err != nil {
130 return nil, err
131 }
132 ctx, cancel := context.WithCancelCause(deps.Ctx)
133
134 app := &App{
135 deps: deps,
136 tabs: entity.NewTabList(),
137 tabsUC: deps.TabsUC,
138 panesUC: deps.PanesUC,
139 workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
140 pool: deps.Pool,
141 injector: deps.Injector,
142 router: deps.MessageRouter,
143 settings: deps.Settings,
144 configManager: config.GetManager(),
145 cancel: cancel,
146 }
147 if app.router == nil {
148 app.router = webkit.NewMessageRouter(ctx)
149 }
150
151 // Register message handlers
152 if app.router != nil {
153 if err := handlers.RegisterAll(ctx, app.router, handlers.Config{
154 HistoryUC: deps.HistoryUC,
155 FavoritesUC: deps.FavoritesUC,
156 Clipboard: deps.Clipboard,
157 ConfigGetter: config.Get,
158 OnClipboardCopied: func(textLen int) {
159 // Show brief toast on auto-copy (similar to zellij footer notification)
160 // Must schedule on GTK main thread since this is called from WebKit handler
161 cb := glib.SourceFunc(func(_ uintptr) bool {
162 if app.appToaster != nil {
163 app.appToaster.Show(ctx, "Copied to clipboard", component.ToastInfo,
164 component.WithDuration(component.ToastBriefDurationMs),
165 component.WithPosition(component.ToastPositionBottomRight),
166 )
167 }
168 return false
169 })
170 glib.IdleAdd(&cb, 0)
171 },
172 }); err != nil {
173 return nil, err
174 }
175 }
176
177 return app, nil
178}
179
180// Run starts the GTK application and blocks until it exits.
181// Returns the exit code.
182func (a *App) Run(ctx context.Context, args []string) int {
183 log := logging.FromContext(ctx)
184 log.Debug().Msg("creating GTK application")
185
186 // Initialize libadwaita once (required before using StyleManager).
187 // This also initializes GTK implicitly.
188 adw.Init()
189 logging.Trace().Mark("gtk_init")
190
191 // Mark adwaita detector as available now that adw.Init() is complete.
192 // This enables the highest-priority color scheme detector.
193 if a.deps != nil && a.deps.AdwaitaDetector != nil {
194 a.deps.AdwaitaDetector.MarkAvailable()
195 log.Debug().Msg("adwaita detector marked available")
196
197 // Refresh scripts in pooled WebViews that were prewarmed before adw.Init().
198 // They have the wrong dark mode preference injected; re-inject with correct value.
199 if a.pool != nil {
200 a.pool.RefreshScripts(ctx)
201 }
202 }
203
204 // TODO: Use AppID once puregotk GC bug is fixed (nullable-string-gc-memory-corruption)
205 a.gtkApp = gtk.NewApplication(nil, gio.GApplicationFlagsNoneValue)
206 if a.gtkApp == nil {
207 log.Error().Msg("failed to create GTK application")
208 return 1
209 }
210 defer a.gtkApp.Unref()
211 logging.Trace().Mark("gtk_app_created")
212
213 // Connect activate signal
214 activateCb := func(_ gio.Application) {
215 a.onActivate(ctx)
216 }
217 a.gtkApp.ConnectActivate(&activateCb)
218
219 // Connect shutdown signal
220 shutdownCb := func(_ gio.Application) {
221 a.onShutdown(ctx)
222 }
223 a.gtkApp.ConnectShutdown(&shutdownCb)
224
225 log.Info().Msg("starting GTK main loop")
226 code := a.gtkApp.Run(len(args), args)
227
228 return code
229}
230
231// onActivate is called when the GTK application is activated.
232func (a *App) onActivate(ctx context.Context) {
233 log := logging.FromContext(ctx)
234 log.Debug().Msg("GTK application activated")
235
236 a.applyGTKColorSchemePreference(ctx)
237 // Configure pool background color early (prevents white flash), but avoid
238 // synchronous prewarming that delays the first navigation on cold start.
239 a.setupPoolBackgroundColor(ctx)
240
241 if err := a.createMainWindow(ctx); err != nil {
242 log.Error().Err(err).Msg("failed to create main window")
243 return
244 }
245 logging.Trace().Mark("window_created")
246
247 a.initLayoutInfrastructure()
248 a.initAppToasterOverlay()
249 a.initFocusAndBorderOverlay()
250 a.initAccentPicker(ctx)
251 a.initDownloadHandler(ctx)
252
253 a.initCoordinators(ctx)
254 logging.Trace().Mark("coordinators_init")
255 a.initKeyboardHandler(ctx)
256 a.initOmniboxConfig(ctx)
257 a.initFindBarConfig()
258 a.initSessionManager(ctx)
259 a.initTabPicker(ctx)
260 a.initSnapshotService(ctx)
261 a.initUpdateCoordinator(ctx)
262 a.createInitialTab(ctx)
263 a.finalizeActivation(ctx)
264}
265
266func (a *App) applyGTKColorSchemePreference(ctx context.Context) {
267 log := logging.FromContext(ctx)
268
269 // Get the config color scheme preference (default follows system theme)
270 scheme := "default"
271 if a.deps != nil && a.deps.Config != nil && a.deps.Config.Appearance.ColorScheme != "" {
272 scheme = a.deps.Config.Appearance.ColorScheme
273 }
274
275 // Apply color scheme via libadwaita's StyleManager.
276 // This properly communicates the preference to GTK and WebKit,
277 // so web content can correctly evaluate prefers-color-scheme media queries.
278 styleMgr := adw.StyleManagerGetDefault()
279 if styleMgr == nil {
280 log.Warn().Msg("failed to get adw.StyleManager")
281 return
282 }
283
284 var adwScheme adw.ColorScheme
285 switch scheme {
286 case "dark", "prefer-dark":
287 adwScheme = adw.ColorSchemeForceDarkValue
288 case "light", "prefer-light":
289 adwScheme = adw.ColorSchemeForceLightValue
290 default:
291 // "default" or empty - follow system preference
292 adwScheme = adw.ColorSchemeDefaultValue
293 }
294
295 styleMgr.SetColorScheme(adwScheme)
296
297 // Refresh the color resolver now that adwaita detector is available.
298 // This ensures all components get the correct preference.
299 var pref string
300 var prefersDark bool
301 if a.deps != nil && a.deps.ColorResolver != nil {
302 result := a.deps.ColorResolver.Refresh()
303 prefersDark = result.PrefersDark
304 pref = result.Source
305 } else {
306 prefersDark = styleMgr.GetDark()
307 pref = "adw.StyleManager"
308 }
309
310 log.Debug().
311 Str("scheme", scheme).
312 Bool("prefers_dark", prefersDark).
313 Str("source", pref).
314 Msg("applied color scheme via adw.StyleManager")
315}
316
317func (a *App) setupPoolBackgroundColor(ctx context.Context) {
318 log := logging.FromContext(ctx)
319
320 // Set theme background color on pool to eliminate white flash.
321 // Must be done before any WebView creation so WebViews get the correct color.
322 if a.pool == nil {
323 log.Debug().Msg("webview pool not available; skipping background color setup")
324 return
325 }
326 if a.deps == nil || a.deps.Theme == nil {
327 log.Debug().Msg("theme not available; skipping webview pool background color setup")
328 return
329 }
330
331 r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
332 a.pool.SetBackgroundColor(r, g, b, alpha)
333 log.Debug().Msg("configured webview pool background color")
334}
335
336func (a *App) prewarmWebViewPoolAsync(ctx context.Context) {
337 // Prewarm WebView pool after startup so cold-start navigation is not blocked.
338 if a.pool == nil {
339 return
340 }
341 a.pool.PrewarmAsync(ctx, 0)
342}
343
344func (a *App) createMainWindow(ctx context.Context) error {
345 mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config)
346 if err != nil {
347 return err
348 }
349 a.mainWindow = mainWindow
350
351 // Apply GTK CSS styling from theme manager.
352 if a.deps == nil || a.deps.Theme == nil {
353 return nil
354 }
355 if display := a.mainWindow.Window().GetDisplay(); display != nil {
356 a.deps.Theme.ApplyToDisplay(ctx, display)
357 }
358 return nil
359}
360
361func (a *App) initLayoutInfrastructure() {
362 // Initialize widget factory for pane layout.
363 a.widgetFactory = layout.NewGtkWidgetFactory()
364
365 // Initialize stacked pane manager for incremental widget operations.
366 a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory)
367}
368
369func (a *App) initAppToasterOverlay() {
370 if a.mainWindow == nil {
371 return
372 }
373
374 // Create app-level toaster for system notifications.
375 a.appToaster = component.NewToaster(a.widgetFactory)
376 toasterWidget := a.appToaster.Widget()
377 if toasterWidget == nil {
378 return
379 }
380 gtkWidget := toasterWidget.GtkWidget()
381 if gtkWidget == nil {
382 return
383 }
384 a.mainWindow.AddOverlay(gtkWidget)
385
386 // Create mode indicator toaster for modal mode notifications.
387 a.modeToaster = component.NewToaster(a.widgetFactory)
388 modeToasterWidget := a.modeToaster.Widget()
389 if modeToasterWidget == nil {
390 return
391 }
392 modeGtkWidget := modeToasterWidget.GtkWidget()
393 if modeGtkWidget == nil {
394 return
395 }
396 a.mainWindow.AddOverlay(modeGtkWidget)
397}
398
399func (a *App) initFocusAndBorderOverlay() {
400 // Initialize focus manager for geometric navigation.
401 a.focusMgr = focus.NewManager(a.panesUC)
402
403 // Initialize border manager for mode indicators.
404 a.borderMgr = focus.NewBorderManager(a.widgetFactory)
405
406 // Attach border overlay to main window (visible for all tabs).
407 if a.borderMgr == nil || a.mainWindow == nil {
408 return
409 }
410 borderWidget := a.borderMgr.Widget()
411 if borderWidget == nil {
412 return
413 }
414 gtkWidget := borderWidget.GtkWidget()
415 if gtkWidget == nil {
416 return
417 }
418 a.mainWindow.AddOverlay(gtkWidget)
419}
420
421func (a *App) initAccentPicker(ctx context.Context) {
422 log := logging.FromContext(ctx)
423
424 if a.widgetFactory == nil || a.mainWindow == nil {
425 log.Debug().Msg("widget factory or main window not available, skipping accent picker")
426 return
427 }
428
429 // Create accent picker component
430 a.accentPicker = component.NewAccentPicker(a.widgetFactory)
431 if a.accentPicker == nil {
432 log.Warn().Msg("failed to create accent picker")
433 return
434 }
435
436 // Add to main window overlay
437 pickerWidget := a.accentPicker.Widget()
438 if pickerWidget != nil {
439 gtkWidget := pickerWidget.GtkWidget()
440 if gtkWidget != nil {
441 a.mainWindow.AddOverlay(gtkWidget)
442 }
443 }
444
445 // Create focus provider (tracks which input has focus)
446 a.accentFocusProvider = textinput.NewFocusProvider()
447
448 // Create the use case with glib.IdleAdd for thread-safe GTK calls
449 a.insertAccentUC = usecase.NewInsertAccentUseCase(
450 a.accentFocusProvider,
451 a.accentPicker,
452 func(fn func()) {
453 cb := glib.SourceFunc(func(_ uintptr) bool {
454 fn()
455 return false // Don't repeat
456 })
457 glib.IdleAdd(&cb, 0)
458 },
459 )
460
461 log.Debug().Msg("accent picker initialized")
462}
463
464func (a *App) initDownloadHandler(ctx context.Context) {
465 log := logging.FromContext(ctx)
466
467 if a.deps == nil || a.deps.WebContext == nil {
468 log.Debug().Msg("WebContext not available, skipping download handler")
469 return
470 }
471
472 // Determine download path from config, fallback to XDG.
473 downloadPath := ""
474 if a.deps.Config != nil {
475 downloadPath = a.deps.Config.Downloads.Path
476 }
477 if downloadPath == "" && a.deps.XDG != nil {
478 var err error
479 downloadPath, err = a.deps.XDG.DownloadDir()
480 if err != nil {
481 log.Warn().Err(err).Msg("failed to get XDG download dir")
482 }
483 }
484 if downloadPath == "" {
485 // Final fallback to ~/Downloads, with /tmp as last resort.
486 home, err := os.UserHomeDir()
487 if err != nil {
488 log.Warn().Err(err).Msg("failed to get home dir, using /tmp for downloads")
489 downloadPath = "/tmp"
490 } else {
491 downloadPath = filepath.Join(home, "Downloads")
492 }
493 }
494
495 // Create download event adapter to show toasts.
496 eventAdapter := &downloadEventAdapter{app: a}
497
498 // Create use case for preparing download destinations with file deduplication.
499 prepareDownloadUC := usecase.NewPrepareDownloadUseCase(filesystem.New())
500
501 // Create and wire the download handler.
502 handler := webkit.NewDownloadHandler(downloadPath, eventAdapter, prepareDownloadUC)
503 a.deps.WebContext.SetDownloadHandler(ctx, handler)
504
505 log.Info().Str("path", downloadPath).Msg("download handler initialized")
506}
507
508// downloadEventAdapter implements port.DownloadEventHandler and shows toasts.
509type downloadEventAdapter struct {
510 app *App
511}
512
513func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) {
514 // Must schedule on GTK main thread.
515 cb := glib.SourceFunc(func(_ uintptr) bool {
516 if d.app.appToaster == nil {
517 return false
518 }
519
520 switch event.Type {
521 case port.DownloadEventStarted:
522 d.app.appToaster.Show(ctx, "Download started: "+event.Filename, component.ToastInfo)
523 case port.DownloadEventFinished:
524 d.app.appToaster.Show(ctx, "Download complete: "+event.Filename, component.ToastSuccess)
525 case port.DownloadEventFailed:
526 d.app.appToaster.Show(ctx, "Download failed: "+event.Filename, component.ToastError)
527 }
528 return false
529 })
530 glib.IdleAdd(&cb, 0)
531}
532
533func (a *App) initKeyboardHandler(ctx context.Context) {
534 if a.mainWindow == nil || a.deps == nil || a.deps.Config == nil {
535 return
536 }
537
538 // Create keyboard handler and wire to dispatcher.
539 a.keyboardHandler = input.NewKeyboardHandler(ctx, a.deps.Config)
540 a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error {
541 return a.kbDispatcher.Dispatch(ctx, action)
542 })
543 a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
544 a.handleModeChange(ctx, from, to)
545 })
546 a.keyboardHandler.SetShouldBypassInput(func() bool {
547 // Bypass keyboard handler when modals are visible
548 if a.sessionManager != nil && a.sessionManager.IsVisible() {
549 return true
550 }
551 if a.tabPicker != nil && a.tabPicker.IsVisible() {
552 return true
553 }
554 wsView := a.activeWorkspaceView()
555 if wsView == nil {
556 return false
557 }
558 return wsView.IsOmniboxVisible()
559 })
560 // Wire accent handler for dead keys support
561 if a.insertAccentUC != nil {
562 a.keyboardHandler.SetAccentHandler(a.insertAccentUC)
563 }
564 a.keyboardHandler.AttachTo(a.mainWindow.Window())
565
566 // Create global shortcut handler for Alt+1-9 tab switching.
567 // This uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
568 // to intercept shortcuts even when WebView has focus.
569 a.globalShortcutHandler = input.NewGlobalShortcutHandler(
570 ctx,
571 a.mainWindow.Window(),
572 a.deps.Config,
573 func(ctx context.Context, action input.Action) error {
574 return a.kbDispatcher.Dispatch(ctx, action)
575 },
576 )
577}
578
579func (a *App) initOmniboxConfig(ctx context.Context) {
580 if a.deps == nil || a.deps.Config == nil {
581 return
582 }
583
584 log := logging.FromContext(ctx)
585
586 // Convert config shortcuts to use case type
587 shortcuts := make(map[string]usecase.SearchShortcut, len(a.deps.Config.SearchShortcuts))
588 for key, shortcut := range a.deps.Config.SearchShortcuts {
589 shortcuts[key] = usecase.SearchShortcut{
590 URL: shortcut.URL,
591 Description: shortcut.Description,
592 }
593 }
594 shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts)
595
596 // Create autocomplete use case for inline suggestions
597 autocompleteUC := usecase.NewAutocompleteUseCase(a.deps.HistoryUC, a.deps.FavoritesUC, shortcutsUC)
598
599 // Store omnibox config (omnibox is created per-pane via WorkspaceView).
600 a.omniboxCfg = component.OmniboxConfig{
601 HistoryUC: a.deps.HistoryUC,
602 FavoritesUC: a.deps.FavoritesUC,
603 FaviconAdapter: a.faviconAdapter,
604 CopyURLUC: a.deps.CopyURLUC,
605 ShortcutsUC: shortcutsUC,
606 AutocompleteUC: autocompleteUC,
607 DefaultSearch: a.deps.Config.DefaultSearchEngine,
608 InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,
609 UIScale: a.deps.Config.DefaultUIScale,
610 OnNavigate: func(url string) {
611 if err := a.navCoord.Navigate(ctx, url); err != nil {
612 log.Error().Err(err).Str("url", url).Msg("navigation failed")
613 }
614 },
615 OnFocusIn: func(entry *gtk.SearchEntry) {
616 // Set omnibox entry as the focused input for accent picker
617 if a.accentFocusProvider != nil && entry != nil {
618 a.accentFocusProvider.SetFocusedInput(textinput.NewGTKEntryTarget(entry))
619 }
620 },
621 OnFocusOut: func() {
622 // When omnibox loses focus, set WebView as the focused input
623 if a.accentFocusProvider != nil {
624 a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
625 }
626 },
627 }
628 a.navCoord.SetOmniboxProvider(a)
629 log.Debug().Msg("omnibox config stored, provider set")
630}
631
632func (a *App) initFindBarConfig() {
633 // Store find bar config (find bar is created per-pane via WorkspaceView).
634 a.findBarCfg = component.FindBarConfig{
635 GetFindController: func(paneID entity.PaneID) port.FindController {
636 if a.contentCoord == nil {
637 return nil
638 }
639 wv := a.contentCoord.GetWebView(paneID)
640 if wv == nil {
641 return nil
642 }
643 return wv.GetFindController()
644 },
645 OnFocusIn: func(entry *gtk.SearchEntry) {
646 // Set find bar entry as the focused input for accent picker
647 if a.accentFocusProvider != nil && entry != nil {
648 a.accentFocusProvider.SetFocusedInput(textinput.NewGTKEntryTarget(entry))
649 }
650 },
651 OnFocusOut: func() {
652 // When find bar loses focus, set WebView as the focused input
653 if a.accentFocusProvider != nil {
654 a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
655 }
656 },
657 }
658}
659
660func (a *App) initSessionManager(ctx context.Context) {
661 log := logging.FromContext(ctx)
662
663 if a.deps == nil {
664 log.Debug().Msg("deps not available, skipping session manager")
665 return
666 }
667
668 // Session manager can work without a repo - it just shows an empty list
669 var listSessionsUC *usecase.ListSessionsUseCase
670 var deleteSessionUC *usecase.DeleteSessionUseCase
671 if a.deps.SessionRepo != nil && a.deps.SessionStateRepo != nil {
672 listSessionsUC = usecase.NewListSessionsUseCase(
673 a.deps.SessionRepo,
674 a.deps.SessionStateRepo,
675 a.deps.Config.Logging.LogDir,
676 )
677 deleteSessionUC = usecase.NewDeleteSessionUseCase(
678 a.deps.SessionStateRepo,
679 a.deps.SessionRepo,
680 a.deps.Config.Logging.LogDir,
681 )
682 }
683
684 // Create session spawner for restoration
685 spawner := desktop.NewSessionSpawner(ctx)
686
687 // Create session manager component
688 a.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{
689 ListSessionsUC: listSessionsUC,
690 DeleteSessionUC: deleteSessionUC,
691 CurrentSession: a.deps.CurrentSessionID,
692 UIScale: a.deps.Config.DefaultUIScale,
693 OnClose: func() {
694 log.Debug().Msg("session manager closed")
695 },
696 OnOpen: func(sessionID entity.SessionID) {
697 log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested")
698 if err := spawner.SpawnWithSession(sessionID); err != nil {
699 log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session")
700 }
701 },
702 OnToast: func(ctx context.Context, message string, level component.ToastLevel) {
703 if a.appToaster != nil {
704 a.appToaster.Show(ctx, message, level)
705 }
706 },
707 })
708
709 if a.sessionManager == nil {
710 log.Warn().Msg("failed to create session manager")
711 return
712 }
713
714 // Add session manager to main window overlay
715 if a.mainWindow != nil {
716 widget := a.sessionManager.Widget()
717 if widget != nil {
718 a.mainWindow.AddOverlay(widget)
719 }
720 }
721
722 // Wire session manager action to keyboard dispatcher
723 a.kbDispatcher.SetOnSessionOpen(func(ctx context.Context) error {
724 a.ToggleSessionManager(ctx)
725 return nil
726 })
727
728 log.Debug().Msg("session manager initialized")
729}
730
731// ToggleSessionManager shows or hides the session manager.
732func (a *App) ToggleSessionManager(ctx context.Context) {
733 if a.sessionManager == nil {
734 return
735 }
736 a.sessionManager.Toggle(ctx)
737}
738
739func (a *App) attachTabPickerToActivePane() {
740 if a.tabPicker == nil || a.widgetFactory == nil {
741 return
742 }
743 wsView := a.activeWorkspaceView()
744 if wsView == nil {
745 return
746 }
747
748 activeTab := a.tabs.ActiveTab()
749 if activeTab == nil || activeTab.Workspace == nil {
750 return
751 }
752 activePaneID := activeTab.Workspace.ActivePaneID
753 if activePaneID == "" {
754 return
755 }
756 pv := wsView.GetPaneView(activePaneID)
757 if pv == nil {
758 return
759 }
760
761 if a.tabPickerWidget == nil {
762 a.tabPickerWidget = a.tabPicker.WidgetAsLayout(a.widgetFactory)
763 if a.tabPickerWidget == nil {
764 return
765 }
766 }
767
768 // If currently attached to a different pane overlay, detach.
769 if a.tabPickerPaneID != "" && a.tabPickerPaneID != activePaneID {
770 for _, view := range a.workspaceViews {
771 if view == nil {
772 continue
773 }
774 if oldPV := view.GetPaneView(a.tabPickerPaneID); oldPV != nil {
775 if parent := a.tabPickerWidget.GetParent(); parent == oldPV.Overlay() {
776 oldPV.RemoveOverlayWidget(a.tabPickerWidget)
777 } else if parent != nil {
778 a.tabPickerWidget.Unparent()
779 }
780 break
781 }
782 }
783 }
784
785 // Ensure the widget can be reparented.
786 if parent := a.tabPickerWidget.GetParent(); parent != nil {
787 a.tabPickerWidget.Unparent()
788 }
789
790 a.tabPicker.SetParentOverlay(pv.Overlay())
791 pv.AddOverlayWidget(a.tabPickerWidget)
792 a.tabPickerPaneID = activePaneID
793}
794
795func (a *App) HandleMovePaneToTab(ctx context.Context) error {
796 if a.movePaneToTabUC == nil {
797 return nil
798 }
799 if a.tabPicker == nil {
800 return nil
801 }
802
803 sourceTab := a.tabs.ActiveTab()
804 if sourceTab == nil || sourceTab.Workspace == nil {
805 return nil
806 }
807
808 // If there is only 1 tab, auto-create a new tab and move there.
809 if a.tabs.Count() <= 1 {
810 return a.MoveActivePaneToTab(ctx, "")
811 }
812
813 items := make([]component.TabPickerItem, 0, a.tabs.Count())
814 for _, tab := range a.tabs.Tabs {
815 if tab == nil {
816 continue
817 }
818 if tab.ID == sourceTab.ID {
819 continue
820 }
821 items = append(items, component.TabPickerItem{
822 TabID: tab.ID,
823 Title: tab.Title(),
824 IsNew: false,
825 Index: tab.Position,
826 })
827 }
828 items = append(items, component.TabPickerItem{IsNew: true, Index: -1})
829
830 a.attachTabPickerToActivePane()
831 a.tabPicker.Show(ctx, items)
832 return nil
833}
834
835func (a *App) HandleMovePaneToNextTab(ctx context.Context) error {
836 if a.movePaneToTabUC == nil {
837 return nil
838 }
839 active := a.tabs.ActiveTab()
840 if active == nil {
841 return nil
842 }
843
844 nextPos := active.Position + 1
845 if nextPos >= a.tabs.Count() {
846 // Create a new tab on the right.
847 return a.MoveActivePaneToTab(ctx, "")
848 }
849 next := a.tabs.TabAt(nextPos)
850 if next == nil {
851 return nil
852 }
853 return a.MoveActivePaneToTab(ctx, next.ID)
854}
855
856func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID) error {
857 in, sourceTab := a.buildMovePaneToTabInput(targetTabID)
858 if in == nil {
859 return nil
860 }
861
862 out, err := a.movePaneToTabUC.Execute(*in)
863 if err != nil {
864 return err
865 }
866 if out == nil || out.TargetTab == nil {
867 return nil
868 }
869
870 a.applyMovePaneToTabUI(ctx, out, sourceTab)
871 a.switchToTargetTabIfConfigured(ctx, out)
872 a.updateTabBarVisibility(ctx)
873 a.MarkDirty()
874 return nil
875}
876
877func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePaneToTabInput, *entity.Tab) {
878 if a.movePaneToTabUC == nil {
879 return nil, nil
880 }
881 activeTab := a.tabs.ActiveTab()
882 if activeTab == nil || activeTab.Workspace == nil {
883 return nil, nil
884 }
885 sourcePaneID := activeTab.Workspace.ActivePaneID
886 if sourcePaneID == "" {
887 return nil, nil
888 }
889
890 in := &usecase.MovePaneToTabInput{
891 TabList: a.tabs,
892 SourceTabID: activeTab.ID,
893 SourcePaneID: sourcePaneID,
894 TargetTabID: targetTabID,
895 }
896 return in, activeTab
897}
898
899func (a *App) applyMovePaneToTabUI(ctx context.Context, out *usecase.MovePaneToTabOutput, sourceTab *entity.Tab) {
900 if out == nil || out.TargetTab == nil {
901 return
902 }
903 sourceTabID := entity.TabID("")
904 if sourceTab != nil {
905 sourceTabID = sourceTab.ID
906 }
907
908 if out.SourceTabClosed {
909 a.removeSourceTabUI(sourceTabID)
910 }
911 if out.NewTabCreated {
912 a.ensureTargetTabUI(ctx, out.TargetTab)
913 }
914
915 if !out.SourceTabClosed {
916 a.rebuildAndAttachWorkspace(ctx, sourceTabID, sourceTab)
917 }
918 a.rebuildAndAttachWorkspace(ctx, out.TargetTab.ID, out.TargetTab)
919}
920
921func (a *App) removeSourceTabUI(sourceTabID entity.TabID) {
922 delete(a.workspaceViews, sourceTabID)
923 if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
924 a.mainWindow.TabBar().RemoveTab(sourceTabID)
925 }
926}
927
928func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab) {
929 if tab == nil {
930 return
931 }
932 if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
933 a.mainWindow.TabBar().AddTab(tab)
934 }
935 a.createWorkspaceViewWithoutAttach(ctx, tab)
936}
937
938func (a *App) rebuildAndAttachWorkspace(ctx context.Context, tabID entity.TabID, tab *entity.Tab) {
939 wsView := a.workspaceViews[tabID]
940 if wsView == nil {
941 return
942 }
943 _ = wsView.Rebuild(ctx)
944
945 if tab == nil || tab.Workspace == nil || a.contentCoord == nil {
946 return
947 }
948 a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
949 if a.wsCoord != nil {
950 a.wsCoord.SetupStackedPaneCallbacks(ctx, tab.Workspace, wsView)
951 }
952}
953
954func (a *App) switchToTargetTabIfConfigured(ctx context.Context, out *usecase.MovePaneToTabOutput) {
955 if out == nil || out.TargetTab == nil {
956 return
957 }
958
959 switchToTarget := config.Get().Workspace.SwitchToTabOnMove
960 if out.SourceTabClosed {
961 switchToTarget = true
962 }
963 if !switchToTarget {
964 return
965 }
966
967 a.tabs.SetActive(out.TargetTab.ID)
968 if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
969 a.mainWindow.TabBar().SetActive(out.TargetTab.ID)
970 }
971 a.switchWorkspaceView(ctx, out.TargetTab.ID)
972}
973
974func (a *App) updateTabBarVisibility(ctx context.Context) {
975 if a.tabCoord != nil {
976 a.tabCoord.UpdateBarVisibility(ctx)
977 }
978}
979
980func (a *App) initTabPicker(ctx context.Context) {
981 log := logging.FromContext(ctx)
982
983 if a.deps == nil || a.deps.Config == nil {
984 log.Debug().Msg("deps/config not available, skipping tab picker")
985 return
986 }
987
988 a.tabPicker = component.NewTabPicker(ctx, component.TabPickerConfig{
989 UIScale: a.deps.Config.DefaultUIScale,
990 OnClose: func() {
991 log.Debug().Msg("tab picker closed")
992 },
993 OnSelect: func(item component.TabPickerItem) {
994 cb := glib.SourceFunc(func(_ uintptr) bool {
995 var targetID entity.TabID
996 if !item.IsNew {
997 targetID = item.TabID
998 }
999 if err := a.MoveActivePaneToTab(ctx, targetID); err != nil {
1000 log.Warn().Err(err).Msg("move pane to tab failed")
1001 }
1002 return false
1003 })
1004 glib.IdleAdd(&cb, 0)
1005 },
1006 })
1007
1008 if a.tabPicker == nil {
1009 log.Warn().Msg("failed to create tab picker")
1010 return
1011 }
1012
1013 log.Debug().Msg("tab picker initialized")
1014}
1015
1016func (a *App) initSnapshotService(ctx context.Context) {
1017 log := logging.FromContext(ctx)
1018
1019 if a.deps == nil || a.deps.SnapshotUC == nil {
1020 log.Debug().Msg("snapshot use case not available, skipping snapshot service")
1021 return
1022 }
1023
1024 intervalMs := 5000 // default
1025 if a.deps.Config != nil && a.deps.Config.Session.SnapshotIntervalMs > 0 {
1026 intervalMs = a.deps.Config.Session.SnapshotIntervalMs
1027 }
1028
1029 a.snapshotService = snapshot.NewService(a.deps.SnapshotUC, a, intervalMs)
1030 a.snapshotService.Start(ctx)
1031
1032 // Set up callback for main.go to notify when session is persisted
1033 a.deps.OnSessionPersisted = func() {
1034 a.snapshotService.SetReady()
1035 }
1036
1037 log.Debug().Int("interval_ms", intervalMs).Msg("snapshot service started")
1038}
1039
1040func (a *App) initUpdateCoordinator(ctx context.Context) {
1041 log := logging.FromContext(ctx)
1042
1043 if a.deps == nil || a.deps.CheckUpdateUC == nil {
1044 log.Debug().Msg("update use cases not available, skipping update coordinator")
1045 return
1046 }
1047
1048 a.updateCoord = coordinator.NewUpdateCoordinator(
1049 a.deps.CheckUpdateUC,
1050 a.deps.ApplyUpdateUC,
1051 a.appToaster,
1052 a.deps.Config,
1053 )
1054
1055 // Start async update check
1056 a.updateCoord.CheckOnStartup(ctx)
1057
1058 log.Debug().Msg("update coordinator initialized")
1059}
1060
1061// GetTabList implements port.TabListProvider.
1062func (a *App) GetTabList() *entity.TabList {
1063 return a.tabs
1064}
1065
1066// GetSessionID implements port.TabListProvider.
1067func (a *App) GetSessionID() entity.SessionID {
1068 if a.deps == nil {
1069 return ""
1070 }
1071 return a.deps.CurrentSessionID
1072}
1073
1074// MarkDirty signals that session state has changed and should be saved.
1075func (a *App) MarkDirty() {
1076 if a.snapshotService != nil {
1077 a.snapshotService.MarkDirty()
1078 }
1079}
1080
1081func (a *App) createInitialTab(ctx context.Context) {
1082 log := logging.FromContext(ctx)
1083
1084 // Check if we should restore a session
1085 if a.deps != nil && a.deps.RestoreSessionID != "" {
1086 if err := a.restoreSession(ctx, entity.SessionID(a.deps.RestoreSessionID)); err != nil {
1087 log.Error().Err(err).Str("session_id", a.deps.RestoreSessionID).Msg("failed to restore session, creating default tab")
1088 // Show error toast and fall through to create default tab
1089 if a.appToaster != nil {
1090 a.appToaster.Show(ctx, "Session restore failed", component.ToastWarning)
1091 }
1092 } else {
1093 log.Info().Str("session_id", a.deps.RestoreSessionID).Msg("session restored")
1094 // Show success toast for session restoration
1095 if a.appToaster != nil {
1096 a.appToaster.Show(ctx, "Session restored", component.ToastSuccess)
1097 }
1098 return
1099 }
1100 }
1101
1102 // Create an initial tab using coordinator.
1103 initialURL := "dumb://home"
1104 if a.deps != nil && a.deps.InitialURL != "" {
1105 initialURL = a.deps.InitialURL
1106 }
1107 if _, err := a.tabCoord.Create(ctx, initialURL); err != nil {
1108 log.Error().Err(err).Msg("failed to create initial tab")
1109 }
1110}
1111
1112func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) error {
1113 log := logging.FromContext(ctx)
1114
1115 if a.deps == nil || a.deps.SessionStateRepo == nil {
1116 return fmt.Errorf("session state repo not available")
1117 }
1118
1119 // Load session state
1120 state, err := a.deps.SessionStateRepo.GetSnapshot(ctx, sessionID)
1121 if err != nil {
1122 return fmt.Errorf("load session state: %w", err)
1123 }
1124 if state == nil {
1125 return fmt.Errorf("session state not found")
1126 }
1127
1128 log.Info().Int("tabs", len(state.Tabs)).Int("panes", state.CountPanes()).Msg("restoring session state")
1129
1130 // Build complete TabList from snapshot (state-first approach)
1131 // This reconstructs the full pane tree with new IDs
1132 restoredTabs := entity.TabListFromSnapshot(state, a.generateID)
1133 if restoredTabs == nil || len(restoredTabs.Tabs) == 0 {
1134 return fmt.Errorf("failed to build tab list from snapshot")
1135 }
1136
1137 // Replace tabs in-place to preserve references held by TabCoordinator
1138 a.tabs.ReplaceFrom(restoredTabs)
1139
1140 // Create UI for each restored tab and add to tab bar
1141 // Don't attach to content area yet - only the active tab should be attached
1142 tabBar := a.mainWindow.TabBar()
1143 for _, tab := range a.tabs.Tabs {
1144 a.createWorkspaceViewWithoutAttach(ctx, tab)
1145 if tabBar != nil {
1146 tabBar.AddTab(tab)
1147 }
1148 log.Debug().
1149 Str("tab_id", string(tab.ID)).
1150 Str("name", tab.Name).
1151 Int("panes", tab.Workspace.PaneCount()).
1152 Msg("restored tab with workspace")
1153 }
1154
1155 // Switch to active tab - this attaches the active workspace view to content area
1156 activeTab := a.tabs.ActiveTab()
1157 if activeTab != nil {
1158 a.switchWorkspaceView(ctx, activeTab.ID)
1159 if tabBar != nil {
1160 tabBar.SetActive(activeTab.ID)
1161 }
1162 }
1163
1164 return nil
1165}
1166
1167func (a *App) finalizeActivation(ctx context.Context) {
1168 log := logging.FromContext(ctx)
1169
1170 // Show the window
1171 if a.mainWindow != nil {
1172 a.mainWindow.Show()
1173 }
1174 log.Info().Msg("main window displayed")
1175
1176 // Defer non-critical initialization until after first navigation starts.
1177 // This keeps pool prewarm, config watcher, and filter loading from
1178 // competing with the initial page load.
1179 a.runAfterFirstLoadStarted(func() {
1180 a.prewarmWebViewPoolAsync(ctx)
1181 a.initConfigWatcher(ctx)
1182 a.initFilteringAsync(ctx)
1183 a.checkConfigMigration(ctx)
1184 })
1185}
1186
1187// runAfterFirstLoadStarted schedules work to run after the first navigation starts.
1188// This keeps the GTK main loop free to process the initial load_uri() quickly,
1189// reducing the gap between webview_attached and load_started.
1190func (a *App) runAfterFirstLoadStarted(fn func()) {
1191 a.deferredInitFn = fn
1192}
1193
1194// triggerDeferredInit is called from ContentCoordinator on first load_started.
1195// It runs deferred initialization at LOW priority so navigation continues unblocked.
1196func (a *App) triggerDeferredInit(ctx context.Context) {
1197 a.deferredInitOnce.Do(func() {
1198 if a.deferredInitFn == nil {
1199 return
1200 }
1201 log := logging.FromContext(ctx)
1202 log.Debug().Msg("triggering deferred initialization")
1203
1204 fn := a.deferredInitFn
1205 cb := glib.SourceFunc(func(_ uintptr) bool {
1206 fn()
1207 return false
1208 })
1209 // Use LOW priority so navigation processing continues first
1210 glib.IdleAddFull(glib.PRIORITY_LOW, &cb, 0, nil)
1211 })
1212}
1213
1214// onShutdown is called when the GTK application is shutting down.
1215func (a *App) onShutdown(ctx context.Context) {
1216 log := logging.FromContext(ctx)
1217 log.Debug().Msg("GTK application shutting down")
1218
1219 // Save final session state before shutdown
1220 if a.snapshotService != nil {
1221 if err := a.snapshotService.Stop(ctx); err != nil {
1222 log.Warn().Err(err).Msg("failed to save final session state")
1223 }
1224 }
1225
1226 // Apply staged update if available (before cleanup)
1227 if a.updateCoord != nil {
1228 if err := a.updateCoord.FinalizeOnExit(ctx); err != nil {
1229 log.Warn().Err(err).Msg("failed to apply staged update")
1230 }
1231 }
1232
1233 // Cancel context to signal all goroutines
1234 a.cancel(errors.New("application shutdown"))
1235
1236 // Cleanup resources
1237 if a.faviconAdapter != nil {
1238 a.faviconAdapter.Close()
1239 }
1240 if a.deps.Pool != nil {
1241 a.deps.Pool.Close(ctx)
1242 }
1243 // Close idle inhibitor to release D-Bus connection
1244 if a.deps.IdleInhibitor != nil {
1245 if err := a.deps.IdleInhibitor.Close(); err != nil {
1246 log.Warn().Err(err).Msg("failed to close idle inhibitor")
1247 }
1248 }
1249
1250 log.Info().Msg("application shutdown complete")
1251}
1252
1253// initCoordinators initializes all coordinators and wires their callbacks.
1254func (a *App) initCoordinators(ctx context.Context) {
1255 log := logging.FromContext(ctx)
1256 log.Debug().Msg("initializing coordinators")
1257
1258 // Helper to get active workspace and view
1259 getActiveWS := func() (*entity.Workspace, *component.WorkspaceView) {
1260 return a.activeWorkspace(), a.activeWorkspaceView()
1261 }
1262
1263 // Create FaviconAdapter with service and WebKit FaviconDatabase
1264 var faviconDB *webkit.FaviconDatabase
1265 if a.deps.WebContext != nil {
1266 faviconDB = a.deps.WebContext.FaviconDatabase()
1267 }
1268 a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, faviconDB)
1269
1270 // 1. Content Coordinator (no dependencies on other coordinators)
1271 a.contentCoord = coordinator.NewContentCoordinator(
1272 ctx,
1273 a.pool,
1274 a.widgetFactory,
1275 a.faviconAdapter,
1276 getActiveWS,
1277 a.deps.ZoomUC,
1278 )
1279
1280 // Set idle inhibitor for fullscreen video playback
1281 if a.deps.IdleInhibitor != nil {
1282 a.contentCoord.SetIdleInhibitor(a.deps.IdleInhibitor)
1283 }
1284
1285 // Wire deferred init trigger - runs after first navigation starts
1286 a.contentCoord.SetOnFirstLoadStarted(func() {
1287 a.triggerDeferredInit(ctx)
1288 })
1289
1290 // 2. Tab Coordinator
1291 a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
1292 TabsUC: a.tabsUC,
1293 Tabs: a.tabs,
1294 MainWindow: a.mainWindow,
1295 Config: a.deps.Config,
1296 })
1297 a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {
1298 a.createWorkspaceView(ctx, tab)
1299 })
1300 a.tabCoord.SetOnTabSwitched(func(ctx context.Context, tab *entity.Tab) {
1301 a.switchWorkspaceView(ctx, tab.ID)
1302 })
1303 a.tabCoord.SetOnQuit(a.Quit)
1304 a.tabCoord.SetOnStateChanged(a.MarkDirty)
1305 // Wire popup tab WebView attachment
1306 a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) {
1307 a.attachPopupToTab(ctx, tabID, pane, wv)
1308 })
1309
1310 // Wire tab bar click handling to coordinator
1311 if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
1312 a.mainWindow.TabBar().SetOnSwitch(func(tabID entity.TabID) {
1313 if err := a.tabCoord.Switch(ctx, tabID); err != nil {
1314 log.Error().Err(err).Str("tab_id", string(tabID)).Msg("tab switch failed")
1315 }
1316 })
1317 }
1318
1319 // Set fullscreen callback to hide/show tab bar (after tabCoord is initialized)
1320 a.contentCoord.SetOnFullscreenChanged(func(entering bool) {
1321 if a.mainWindow == nil || a.mainWindow.TabBar() == nil {
1322 return
1323 }
1324 if entering {
1325 // Hide tab bar when entering fullscreen video
1326 a.mainWindow.TabBar().SetVisible(false)
1327 } else {
1328 // Restore tab bar visibility based on normal logic
1329 a.tabCoord.UpdateBarVisibility(ctx)
1330 }
1331 })
1332
1333 // 3. Workspace Coordinator
1334 a.wsCoord = coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{
1335 PanesUC: a.panesUC,
1336 FocusMgr: a.focusMgr,
1337 StackedPaneMgr: a.stackedPaneMgr,
1338 WidgetFactory: a.widgetFactory,
1339 ContentCoord: a.contentCoord,
1340 GetActiveWS: getActiveWS,
1341 GenerateID: a.generateID,
1342 })
1343 a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error {
1344 return a.tabCoord.Close(ctx)
1345 })
1346 a.wsCoord.SetOnStateChanged(a.MarkDirty)
1347
1348 // Wire popup handling
1349 a.webViewFactory = webkit.NewWebViewFactory(
1350 a.deps.WebContext,
1351 a.settings,
1352 a.pool,
1353 a.injector,
1354 a.router,
1355 )
1356 // Set theme background color on factory to eliminate white flash
1357 if a.deps.Theme != nil {
1358 r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
1359 a.webViewFactory.SetBackgroundColor(r, g, b, alpha)
1360 }
1361 a.contentCoord.SetPopupConfig(
1362 a.webViewFactory,
1363 &a.deps.Config.Workspace.Popups,
1364 a.generateID,
1365 )
1366 a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input coordinator.InsertPopupInput) error {
1367 return a.wsCoord.InsertPopup(ctx, input)
1368 })
1369 a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error {
1370 return a.wsCoord.ClosePaneByID(ctx, paneID)
1371 })
1372 // Wire tabbed popup behavior to create new tabs
1373 a.wsCoord.SetOnCreatePopupTab(func(ctx context.Context, input coordinator.InsertPopupInput) error {
1374 // Create a new tab with the popup pane
1375 tab, err := a.tabCoord.CreateWithPane(ctx, input.PopupPane, input.WebView, input.TargetURI)
1376 if err != nil {
1377 return err
1378 }
1379 log.Debug().Str("tab_id", string(tab.ID)).Msg("created tab for popup")
1380 return nil
1381 })
1382
1383 // Move pane use case (cross-tab)
1384 a.movePaneToTabUC = usecase.NewMovePaneToTabUseCase(a.generateID)
1385
1386 // 4. Navigation Coordinator
1387 a.navCoord = coordinator.NewNavigationCoordinator(
1388 ctx,
1389 a.deps.NavigateUC,
1390 a.contentCoord,
1391 )
1392
1393 // Wire title updates to history persistence
1394 a.contentCoord.SetOnTitleUpdated(func(ctx context.Context, paneID entity.PaneID, url, title string) {
1395 a.navCoord.UpdateHistoryTitle(ctx, paneID, url, title)
1396 })
1397
1398 // Wire history recording on LoadCommitted (URI is guaranteed correct at this point)
1399 a.contentCoord.SetOnHistoryRecord(func(ctx context.Context, paneID entity.PaneID, url string) {
1400 a.navCoord.RecordHistory(ctx, paneID, url)
1401 })
1402
1403 // Wire window title updates when active pane's title changes
1404 a.contentCoord.SetOnWindowTitleChanged(func(title string) {
1405 a.updateWindowTitle(title)
1406 })
1407
1408 // Wire pane URI updates for session snapshots (searches all tabs)
1409 a.contentCoord.SetOnPaneURIUpdated(func(paneID entity.PaneID, url string) {
1410 a.updatePaneURIInAllTabs(paneID, url)
1411 // Mark dirty so snapshot captures the new URI
1412 a.MarkDirty()
1413 })
1414
1415 // Hide loading skeleton once the WebView paints
1416 a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {
1417 // The WebView can be shown while its pane is in a background tab, so scan
1418 // all WorkspaceViews rather than only the active one.
1419 for _, wsView := range a.workspaceViews {
1420 if wsView == nil {
1421 continue
1422 }
1423 if pv := wsView.GetPaneView(paneID); pv != nil {
1424 pv.HideLoadingSkeleton()
1425 }
1426 }
1427 if a.deps != nil && a.deps.OnFirstWebViewShown != nil {
1428 a.firstWebViewShownOnce.Do(func() {
1429 a.deps.OnFirstWebViewShown(ctx)
1430 })
1431 }
1432
1433 // Set the WebView as the focused input for accent picker (if this is the active pane)
1434 ws := a.activeWorkspace()
1435 if ws != nil && ws.ActivePaneID == paneID && a.accentFocusProvider != nil {
1436 a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
1437 }
1438 })
1439
1440 // 5. Keyboard Dispatcher
1441 a.kbDispatcher = dispatcher.NewKeyboardDispatcher(
1442 ctx,
1443 a.tabCoord,
1444 a.wsCoord,
1445 a.navCoord,
1446 a.deps.ZoomUC,
1447 a.deps.CopyURLUC,
1448 )
1449 a.kbDispatcher.SetOnQuit(a.Quit)
1450 a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {
1451 a.ToggleFindBar(ctx)
1452 return nil
1453 })
1454 a.kbDispatcher.SetOnFindNext(func(ctx context.Context) error {
1455 a.FindNext(ctx)
1456 return nil
1457 })
1458 a.kbDispatcher.SetOnFindPrev(func(ctx context.Context) error {
1459 a.FindPrevious(ctx)
1460 return nil
1461 })
1462 a.kbDispatcher.SetOnFindClose(func(ctx context.Context) error {
1463 a.CloseFindBar(ctx)
1464 return nil
1465 })
1466 a.kbDispatcher.SetOnMovePaneToTab(func(ctx context.Context) error {
1467 return a.HandleMovePaneToTab(ctx)
1468 })
1469 a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context) error {
1470 return a.HandleMovePaneToNextTab(ctx)
1471 })
1472
1473 // Wire gesture handler to dispatcher (for mouse button 8/9 navigation)
1474 a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error {
1475 return a.kbDispatcher.Dispatch(ctx, action)
1476 })
1477
1478 log.Debug().Msg("coordinators initialized")
1479}
1480
1481// generateID generates a unique ID for tabs and panes.
1482// Uses monotonically increasing counter to avoid ID collisions.
1483func (a *App) generateID() string {
1484 a.idMu.Lock()
1485 defer a.idMu.Unlock()
1486 a.idCounter++
1487 return fmt.Sprintf("p%d", a.idCounter)
1488}
1489
1490// Tabs returns the tab list.
1491func (a *App) Tabs() *entity.TabList {
1492 return a.tabs
1493}
1494
1495// MainWindow returns the main window.
1496func (a *App) MainWindow() *window.MainWindow {
1497 return a.mainWindow
1498}
1499
1500// Quit requests the application to quit.
1501func (a *App) Quit() {
1502 if a.gtkApp != nil {
1503 a.gtkApp.Quit()
1504 }
1505}
1506
1507// RunWithArgs is a convenience function that creates and runs an App.
1508func RunWithArgs(ctx context.Context, deps *Dependencies) int {
1509 app, err := New(deps)
1510 if err != nil {
1511 log := logging.FromContext(ctx)
1512 log.Error().Err(err).Msg("failed to create application")
1513 return 1
1514 }
1515 return app.Run(ctx, os.Args)
1516}
1517
1518// updateWindowTitle updates the window title with the given page title.
1519// Format: "<Page Title> - Dumber" or just "Dumber" if title is empty.
1520func (a *App) updateWindowTitle(pageTitle string) {
1521 if a.mainWindow == nil {
1522 return
1523 }
1524
1525 title := "Dumber"
1526 if pageTitle != "" {
1527 title = pageTitle + " - Dumber"
1528 }
1529 a.mainWindow.SetTitle(title)
1530}
1531
1532// updateWindowTitleFromActivePane updates the window title based on the current active pane.
1533func (a *App) updateWindowTitleFromActivePane() {
1534 ws := a.activeWorkspace()
1535 if ws == nil || a.contentCoord == nil {
1536 a.updateWindowTitle("")
1537 return
1538 }
1539 title := a.contentCoord.GetTitle(ws.ActivePaneID)
1540 a.updateWindowTitle(title)
1541}
1542
1543// handleModeChange is called when the input mode changes.
1544func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
1545 log := logging.FromContext(ctx)
1546 log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")
1547
1548 if from == input.ModeResize && to != input.ModeResize {
1549 a.clearResizeModeBorder()
1550 }
1551 if to == input.ModeResize {
1552 a.applyResizeModeBorder(ctx, a.activeWorkspace())
1553 }
1554
1555 // Update global border overlay visibility based on mode.
1556 // Note: resize mode border is handled per-pane (stack container), not via global overlay.
1557 if a.borderMgr != nil {
1558 a.borderMgr.OnModeChange(ctx, from, to)
1559 }
1560
1561 // Show/hide mode indicator toaster based on config.
1562 a.updateModeIndicatorToaster(ctx, to)
1563}
1564
1565// updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config.
1566func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) {
1567 if a.modeToaster == nil {
1568 return
1569 }
1570
1571 // Check if mode indicator toaster is enabled in config.
1572 if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.Styling.ModeIndicatorToasterEnabled {
1573 a.modeToaster.Hide()
1574 return
1575 }
1576
1577 if mode == input.ModeNormal {
1578 a.modeToaster.Hide()
1579 return
1580 }
1581
1582 // Show persistent toaster at bottom-left with mode display name.
1583 // Mode class is applied atomically with Show() to avoid visual flicker.
1584 modeClass := getModeToastClass(mode)
1585 a.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
1586 component.WithDuration(0), // Persistent until mode exits.
1587 component.WithPosition(component.ToastPositionBottomLeft),
1588 component.WithModeClass(modeClass),
1589 )
1590}
1591
1592// getModeToastClass returns the CSS class for the given mode's toast styling.
1593func getModeToastClass(mode input.Mode) string {
1594 switch mode {
1595 case input.ModePane:
1596 return "toast-pane-mode"
1597 case input.ModeTab:
1598 return "toast-tab-mode"
1599 case input.ModeSession:
1600 return "toast-session-mode"
1601 case input.ModeResize:
1602 return "toast-resize-mode"
1603 default:
1604 return ""
1605 }
1606}
1607
1608func (a *App) clearResizeModeBorder() {
1609 if a.resizeModeBorderTarget != nil {
1610 a.resizeModeBorderTarget.RemoveCssClass("resize-mode-active")
1611 a.resizeModeBorderTarget = nil
1612 }
1613}
1614
1615func (a *App) applyResizeModeBorder(ctx context.Context, ws *entity.Workspace) {
1616 wsView := a.activeWorkspaceView()
1617 if wsView == nil || ws == nil {
1618 a.clearResizeModeBorder()
1619 return
1620 }
1621
1622 paneID := ws.ActivePaneID
1623 if paneID == "" {
1624 a.clearResizeModeBorder()
1625 return
1626 }
1627
1628 // Important: wrap the whole stack container when the active pane is in a stack.
1629 target := wsView.GetStackContainerWidget(paneID)
1630 if target == nil {
1631 a.clearResizeModeBorder()
1632 return
1633 }
1634
1635 if !target.HasCssClass("resize-mode-active") {
1636 target.AddCssClass("resize-mode-active")
1637 }
1638
1639 if a.resizeModeBorderTarget != target {
1640 if a.resizeModeBorderTarget != nil {
1641 a.resizeModeBorderTarget.RemoveCssClass("resize-mode-active")
1642 } else {
1643 logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("resize mode border attached")
1644 }
1645 a.resizeModeBorderTarget = target
1646 }
1647}
1648
1649// createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.
1650func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
1651 a.createWorkspaceViewWithoutAttach(ctx, tab)
1652
1653 // Attach to content area
1654 wsView := a.workspaceViews[tab.ID]
1655 if wsView != nil && a.mainWindow != nil {
1656 widget := wsView.Widget()
1657 if widget != nil {
1658 gtkWidget := widget.GtkWidget()
1659 if gtkWidget != nil {
1660 a.mainWindow.SetContent(gtkWidget)
1661 }
1662 }
1663 }
1664}
1665
1666// createWorkspaceViewWithoutAttach creates a WorkspaceView for a tab without attaching to content area.
1667// Used during session restoration where we create all views first, then attach only the active one.
1668func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.Tab) {
1669 log := logging.FromContext(ctx)
1670
1671 if a.widgetFactory == nil {
1672 log.Error().Msg("widget factory not initialized")
1673 return
1674 }
1675
1676 // Create workspace view
1677 wsView := component.NewWorkspaceView(ctx, a.widgetFactory)
1678 if wsView == nil {
1679 log.Error().Msg("failed to create workspace view")
1680 return
1681 }
1682
1683 // Set the workspace
1684 if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil {
1685 log.Error().Err(err).Msg("failed to set workspace in view")
1686 return
1687 }
1688
1689 // Ensure WebViews are attached to panes
1690 if a.contentCoord != nil {
1691 a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
1692 }
1693
1694 // Note: Mode borders for tab/pane/session are attached to MainWindow.
1695 // Resize mode border is attached to the active pane's stack container.
1696
1697 // Set omnibox config for this workspace view
1698 wsView.SetOmniboxConfig(a.omniboxCfg)
1699 // Set find bar config for this workspace view
1700 wsView.SetFindBarConfig(a.findBarCfg)
1701 // Set auto-open omnibox on new pane
1702 wsView.SetAutoOpenOnNewPane(config.Get().Omnibox.AutoOpenOnNewPane)
1703
1704 wsView.SetOnPaneFocused(func(paneID entity.PaneID) {
1705 if a.keyboardHandler != nil && a.keyboardHandler.Mode() == input.ModeResize {
1706 ws := a.activeWorkspace()
1707 if ws != nil {
1708 ws.ActivePaneID = paneID
1709 }
1710 a.applyResizeModeBorder(ctx, ws)
1711 }
1712 })
1713
1714 wsView.SetOnSplitRatioDragged(func(nodeID string, ratio float64) {
1715 if a.wsCoord != nil {
1716 _ = a.wsCoord.SetSplitRatio(ctx, nodeID, ratio)
1717 }
1718 })
1719
1720 // Store in map
1721 a.workspaceViews[tab.ID] = wsView
1722
1723 log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created")
1724}
1725
1726// activeWorkspace returns the workspace of the active tab.
1727func (a *App) activeWorkspace() *entity.Workspace {
1728 activeTab := a.tabs.ActiveTab()
1729 if activeTab == nil {
1730 return nil
1731 }
1732 return activeTab.Workspace
1733}
1734
1735// updatePaneURIInAllTabs finds a pane by ID across all tabs and updates its URI.
1736// This is necessary because panes in inactive tabs also need URI updates for session snapshots.
1737func (a *App) updatePaneURIInAllTabs(paneID entity.PaneID, url string) {
1738 for _, tab := range a.tabs.Tabs {
1739 if tab.Workspace == nil {
1740 continue
1741 }
1742 paneNode := tab.Workspace.FindPane(paneID)
1743 if paneNode != nil && paneNode.Pane != nil {
1744 paneNode.Pane.URI = url
1745 return // Pane IDs are unique, no need to continue
1746 }
1747 }
1748}
1749
1750// activeWorkspaceView returns the workspace view for the active tab.
1751func (a *App) activeWorkspaceView() *component.WorkspaceView {
1752 activeTab := a.tabs.ActiveTab()
1753 if activeTab == nil {
1754 return nil
1755 }
1756 return a.workspaceViews[activeTab.ID]
1757}
1758
1759// getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView.
1760// Used by the accent picker to insert accented characters into web content.
1761func (a *App) getActiveWebViewTarget() port.TextInputTarget {
1762 ws := a.activeWorkspace()
1763 if ws == nil || a.contentCoord == nil || a.deps == nil || a.deps.Clipboard == nil {
1764 return nil
1765 }
1766
1767 wv := a.contentCoord.GetWebView(ws.ActivePaneID)
1768 if wv == nil {
1769 return nil
1770 }
1771
1772 // Get the underlying webkit.WebView for the text input target
1773 return textinput.NewWebViewTarget(wv.Widget(), a.deps.Clipboard)
1774}
1775
1776// attachPopupToTab attaches a popup WebView to a newly created tab.
1777// This is called when a popup uses tabbed behavior.
1778func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) {
1779 log := logging.FromContext(ctx)
1780
1781 wsView := a.workspaceViews[tabID]
1782 if wsView == nil {
1783 log.Warn().Str("tab_id", string(tabID)).Msg("workspace view not found for popup tab")
1784 return
1785 }
1786
1787 // Register WebView with content coordinator
1788 if a.contentCoord != nil {
1789 // Track the WebView
1790 a.contentCoord.RegisterPopupWebView(pane.ID, wv)
1791
1792 // Wrap and attach widget
1793 widget := a.contentCoord.WrapWidget(ctx, wv)
1794 if widget != nil {
1795 paneView := wsView.GetPaneView(pane.ID)
1796 if paneView != nil {
1797 paneView.SetWebViewWidget(widget)
1798 } else {
1799 log.Warn().Str("pane_id", string(pane.ID)).Msg("pane view not found for popup")
1800 }
1801 }
1802 }
1803 log.Debug().
1804 Str("tab_id", string(tabID)).
1805 Str("pane_id", string(pane.ID)).
1806 Msg("popup webview attached to tab")
1807}
1808
1809// switchWorkspaceView swaps the displayed workspace view for a tab.
1810func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
1811 log := logging.FromContext(ctx)
1812
1813 wsView, exists := a.workspaceViews[tabID]
1814 if !exists {
1815 log.Warn().Str("tab_id", string(tabID)).Msg("no workspace view for tab")
1816 return
1817 }
1818
1819 // Get the workspace view's root widget
1820 widget := wsView.Widget()
1821 if widget == nil {
1822 log.Warn().Str("tab_id", string(tabID)).Msg("workspace view has no widget")
1823 return
1824 }
1825
1826 gtkWidget := widget.GtkWidget()
1827 if gtkWidget == nil {
1828 log.Warn().Str("tab_id", string(tabID)).Msg("workspace view widget has no GTK widget")
1829 return
1830 }
1831
1832 // Swap content (MainWindow.SetContent now properly removes old content)
1833 if a.mainWindow != nil {
1834 a.mainWindow.SetContent(gtkWidget)
1835 }
1836
1837 // Update window title with the new active pane's title
1838 a.updateWindowTitleFromActivePane()
1839
1840 log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched")
1841}
1842
1843// ToggleOmnibox implements OmniboxProvider.
1844// Toggles the omnibox visibility in the active workspace view.
1845func (a *App) ToggleOmnibox(ctx context.Context) {
1846 log := logging.FromContext(ctx)
1847
1848 wsView := a.activeWorkspaceView()
1849 if wsView == nil {
1850 log.Warn().Msg("no active workspace view for omnibox toggle")
1851 return
1852 }
1853
1854 if wsView.IsOmniboxVisible() {
1855 wsView.HideOmnibox()
1856 } else {
1857 wsView.ShowOmnibox(ctx, "")
1858 }
1859}
1860
1861// ToggleFindBar shows or hides the find bar in the active workspace view.
1862func (a *App) ToggleFindBar(ctx context.Context) {
1863 log := logging.FromContext(ctx)
1864
1865 wsView := a.activeWorkspaceView()
1866 if wsView == nil {
1867 log.Warn().Msg("no active workspace view for find bar toggle")
1868 return
1869 }
1870
1871 if wsView.IsFindBarVisible() {
1872 wsView.HideFindBar()
1873 } else {
1874 wsView.ShowFindBar(ctx)
1875 }
1876}
1877
1878// FindNext selects the next match in the active find bar.
1879func (a *App) FindNext(ctx context.Context) {
1880 wsView := a.activeWorkspaceView()
1881 if wsView == nil {
1882 return
1883 }
1884
1885 if !wsView.IsFindBarVisible() {
1886 wsView.ShowFindBar(ctx)
1887 }
1888 wsView.FindNext()
1889}
1890
1891// FindPrevious selects the previous match in the active find bar.
1892func (a *App) FindPrevious(ctx context.Context) {
1893 wsView := a.activeWorkspaceView()
1894 if wsView == nil {
1895 return
1896 }
1897
1898 if !wsView.IsFindBarVisible() {
1899 wsView.ShowFindBar(ctx)
1900 }
1901 wsView.FindPrevious()
1902}
1903
1904// CloseFindBar hides the find bar if visible.
1905func (a *App) CloseFindBar(ctx context.Context) {
1906 wsView := a.activeWorkspaceView()
1907 if wsView == nil {
1908 return
1909 }
1910 wsView.HideFindBar()
1911}
1912
1913// UpdateOmniboxZoom implements OmniboxProvider.
1914// Updates the zoom indicator on the current omnibox if visible.
1915func (a *App) UpdateOmniboxZoom(factor float64) {
1916 wsView := a.activeWorkspaceView()
1917 if wsView == nil {
1918 return
1919 }
1920
1921 omnibox := wsView.GetOmnibox()
1922 if omnibox != nil {
1923 omnibox.UpdateZoomIndicator(factor)
1924 }
1925}
1926
1927// SetOmniboxOnNavigate implements OmniboxProvider.
1928// This is called to set the navigation callback on new omniboxes.
1929// Since omniboxes are created per-pane, we store the config with the callback.
1930func (a *App) SetOmniboxOnNavigate(fn func(url string)) {
1931 // The navigate callback is set when the omnibox is created via WorkspaceView.ShowOmnibox
1932 // The WorkspaceView uses the stored omniboxCfg and sets up navigation via the omnibox's SetOnNavigate
1933}
1934
1935// initFilteringAsync starts background filter loading with toast feedback.
1936func (a *App) initConfigWatcher(ctx context.Context) {
1937 log := logging.FromContext(ctx)
1938
1939 if a.configManager == nil {
1940 log.Debug().Msg("no config manager available, skipping watcher")
1941 return
1942 }
1943
1944 // Start viper watcher
1945 if err := a.configManager.Watch(); err != nil {
1946 log.Warn().Err(err).Msg("failed to start config watcher")
1947 return
1948 }
1949
1950 // Hot-reload appearance and keybindings on config change.
1951 a.configManager.OnConfigChange(func(newCfg *config.Config) {
1952 cfgCopy := newCfg
1953 cb := glib.SourceFunc(func(_ uintptr) bool {
1954 a.applyAppearanceConfig(ctx, cfgCopy)
1955 // Reload keyboard shortcuts
1956 if a.keyboardHandler != nil {
1957 a.keyboardHandler.ReloadShortcuts(ctx, cfgCopy)
1958 }
1959 return false
1960 })
1961 glib.IdleAdd(&cb, 0)
1962 })
1963
1964 log.Debug().Msg("config watcher initialized")
1965}
1966
1967func (a *App) applyAppearanceConfig(ctx context.Context, cfg *config.Config) {
1968 log := logging.FromContext(ctx)
1969 if cfg == nil {
1970 return
1971 }
1972
1973 // Update the shared config pointer in-place so existing references see changes.
1974 if a.deps != nil && a.deps.Config != nil {
1975 *a.deps.Config = *cfg
1976 }
1977
1978 // Update WebKit settings manager
1979 if a.settings != nil {
1980 a.settings.UpdateFromConfig(ctx, cfg)
1981 }
1982
1983 // Apply settings to existing webviews via coordinator
1984 if a.contentCoord != nil {
1985 a.contentCoord.ApplySettingsToAll(ctx, a.settings)
1986 }
1987
1988 // Update GTK theme and injected WebUI theme vars
1989 if a.deps != nil && a.deps.Theme != nil {
1990 var display *gdk.Display
1991 if a.mainWindow != nil && a.mainWindow.Window() != nil {
1992 display = a.mainWindow.Window().GetDisplay()
1993 }
1994 a.deps.Theme.UpdateFromConfig(ctx, cfg, display)
1995
1996 // Update find highlight CSS for future navigations
1997 if a.injector != nil {
1998 findCSS := theme.GenerateFindHighlightCSS(a.deps.Theme.GetCurrentPalette())
1999 if err := a.injector.InjectFindHighlightCSS(ctx, findCSS); err != nil {
2000 log.Warn().Err(err).Msg("failed to update find highlight CSS")
2001 }
2002 }
2003
2004 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(a.injector)
2005 cssText := a.deps.Theme.GetWebUIThemeCSS()
2006 if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: cssText}); err != nil {
2007 log.Warn().Err(err).Msg("failed to update WebUI theme CSS")
2008 }
2009
2010 // Refresh injected scripts so future navigations use the latest theme.
2011 if a.contentCoord != nil && a.injector != nil {
2012 a.contentCoord.RefreshInjectedScriptsToAll(ctx, a.injector)
2013 }
2014
2015 // Apply WebUI theme to already-loaded dumb:// pages
2016 if a.contentCoord != nil {
2017 a.contentCoord.ApplyWebUIThemeToAll(ctx, a.deps.Theme.PrefersDark(), cssText)
2018 }
2019 }
2020
2021 log.Info().Msg("appearance config updated")
2022}
2023
2024func (a *App) initFilteringAsync(ctx context.Context) {
2025 if a.deps.FilterManager == nil {
2026 return
2027 }
2028
2029 a.deps.FilterManager.SetStatusCallback(func(status filtering.FilterStatus) {
2030 statusCopy := status // Capture for closure
2031 cb := glib.SourceFunc(func(_ uintptr) bool {
2032 a.showFilterStatus(ctx, statusCopy)
2033 return false // Don't repeat
2034 })
2035 glib.IdleAdd(&cb, 0)
2036 })
2037 a.deps.FilterManager.LoadAsync(ctx)
2038}
2039
2040// showFilterStatus displays toast notification for filter status.
2041func (a *App) showFilterStatus(ctx context.Context, status filtering.FilterStatus) {
2042 log := logging.FromContext(ctx)
2043
2044 switch status.State {
2045 case filtering.StateLoading:
2046 if a.appToaster != nil {
2047 a.appToaster.Show(ctx, status.Message, component.ToastInfo)
2048 }
2049 case filtering.StateActive:
2050 // Apply filters to existing webviews that were created before filters loaded
2051 if a.contentCoord != nil && a.deps.FilterManager != nil {
2052 a.contentCoord.ApplyFiltersToAll(ctx, a.deps.FilterManager)
2053 log.Debug().Msg("applied filters to all existing webviews")
2054 }
2055 if a.appToaster != nil {
2056 a.appToaster.Show(ctx, fmt.Sprintf("Ad blocker ready (%s)", status.Version), component.ToastInfo)
2057 }
2058 case filtering.StateError:
2059 if a.appToaster != nil {
2060 a.appToaster.Show(ctx, "Filter load failed: "+status.Message, component.ToastError)
2061 }
2062 }
2063}