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