1package ui
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "sync"
11 "unsafe"
12
13 "github.com/bnema/dumber/internal/application/port"
14 "github.com/bnema/dumber/internal/application/usecase"
15 "github.com/bnema/dumber/internal/domain/entity"
16 urlutil "github.com/bnema/dumber/internal/domain/url"
17
18 "github.com/bnema/dumber/internal/logging"
19 "github.com/bnema/dumber/internal/ui/adapter"
20 "github.com/bnema/dumber/internal/ui/component"
21 "github.com/bnema/dumber/internal/ui/coordinator"
22 "github.com/bnema/dumber/internal/ui/coordinator/content"
23 "github.com/bnema/dumber/internal/ui/dialog"
24 "github.com/bnema/dumber/internal/ui/dispatcher"
25 "github.com/bnema/dumber/internal/ui/focus"
26 "github.com/bnema/dumber/internal/ui/input"
27 "github.com/bnema/dumber/internal/ui/layout"
28 "github.com/bnema/dumber/internal/ui/theme"
29 "github.com/bnema/dumber/internal/ui/window"
30 "github.com/bnema/puregotk/v4/adw"
31 "github.com/bnema/puregotk/v4/gdk"
32 "github.com/bnema/puregotk/v4/gio"
33 "github.com/bnema/puregotk/v4/glib"
34 "github.com/bnema/puregotk/v4/gtk"
35)
36
37const (
38 // AppID is the application identifier for GTK.
39 AppID = "com.github.bnema.dumber"
40 // crashReportToastDurationMs keeps startup crash-report toast visible longer.
41 crashReportToastDurationMs = 5000
42 floatingPaneFallbackWidth = 1200
43 floatingPaneFallbackHeight = 800
44 floatingPaneIDPrefix = "floating-pane:"
45 floatingSessionIDDefault = "default"
46 floatingPaneVisibleClass = "floating-pane-visible"
47)
48
49func gtkApplicationFlags() gio.ApplicationFlags {
50 return gio.GApplicationNonUniqueValue
51}
52
53// App wraps the GTK Application and manages the browser lifecycle.
54type App struct {
55 deps *Dependencies
56 gtkApp *gtk.Application
57 mainWindow *window.MainWindow
58
59 // State
60 tabs *entity.TabList
61 tabsUC *usecase.ManageTabsUseCase
62
63 // Coordinators (new architecture)
64 contentCoord *content.Coordinator
65 tabCoord *coordinator.TabCoordinator
66 wsCoord *coordinator.WorkspaceCoordinator
67 navCoord *coordinator.NavigationCoordinator
68 kbDispatcher *dispatcher.KeyboardDispatcher
69
70 // Pane management (used by coordinators)
71 panesUC *usecase.ManagePanesUseCase
72 workspaceViews map[entity.TabID]*component.WorkspaceView
73 widgetFactory layout.WidgetFactory
74 stackedPaneMgr *component.StackedPaneManager
75
76 // Input handling
77 keyboardHandler *input.KeyboardHandler
78 globalShortcutHandler *input.GlobalShortcutHandler
79
80 // Focus and border management
81 focusMgr *focus.Manager
82 borderMgr *focus.BorderManager
83
84 resizeModeBorderTarget layout.Widget
85
86 // Omnibox configuration (omnibox is created per workspace view)
87 omniboxCfg component.OmniboxConfig
88 omniboxNavigateFn func(url string)
89 // Find bar configuration (find bar is created per workspace view)
90 findBarCfg component.FindBarConfig
91 // Floating pane sessions keyed by tab and profile session ID.
92 floatingSessions map[floatingSessionKey]*floatingWorkspaceSession
93
94 // Engine
95 engine port.Engine
96
97 // Web content (managed by content.Coordinator)
98 faviconAdapter *adapter.FaviconAdapter
99
100 // App-level toaster for system notifications (filter status, etc.)
101 appToaster *component.Toaster
102 // Mode indicator toaster for modal mode notifications (pane, tab, session, resize)
103 modeToaster *component.Toaster
104 // Top-right WebRTC permission activity indicator
105 webrtcIndicator *component.WebRTCPermissionIndicator
106
107 // Session management
108 sessionManager *component.SessionManager
109 tabPicker *component.TabPicker
110 tabPickerWidget layout.Widget
111 tabPickerPaneID entity.PaneID
112 snapshotService port.SnapshotService
113
114 // Update management
115 updateCoord *coordinator.UpdateCoordinator
116
117 // ID generator for tabs/panes
118 idCounter uint64
119 idMu sync.Mutex
120 firstWebViewShownOnce sync.Once
121
122 movePaneToTabUC *usecase.MovePaneToTabUseCase
123
124 // Accent picker for dead keys support
125 accentPicker *component.AccentPicker
126 insertAccentUC *usecase.InsertAccentUseCase
127 accentFocusProvider port.FocusedInputProvider
128
129 // Deferred initialization - runs after first load_started to avoid blocking initial navigation
130 deferredInitOnce sync.Once
131 deferredInitFn func()
132
133 // lifecycle
134 cancel context.CancelCauseFunc
135}
136
137type floatingWorkspaceSession struct {
138 paneID entity.PaneID
139 pane *component.FloatingPane
140 paneView *component.PaneView
141 webView port.WebView
142 overlay layout.OverlayWidget
143 widget layout.Widget
144 focusWidget layout.Widget
145 omnibox *component.Omnibox
146 omniboxWidget layout.Widget
147 resizeWatcherActive bool
148 resizeTickID uint
149 appliedWidth int
150 appliedHeight int
151}
152
153type floatingSessionKey struct {
154 tabID entity.TabID
155 sessionID string
156}
157
158// New creates a new App with the given dependencies.
159func New(deps *Dependencies) (*App, error) {
160 if err := deps.Validate(); err != nil {
161 return nil, err
162 }
163 ctx, cancel := context.WithCancelCause(deps.Ctx)
164
165 app := &App{
166 deps: deps,
167 tabs: entity.NewTabList(),
168 tabsUC: deps.TabsUC,
169 panesUC: deps.PanesUC,
170 workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
171 floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession),
172 engine: deps.Engine,
173 cancel: cancel,
174 }
175 // Register message handlers through the engine.
176 if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{
177 HistoryUC: deps.HistoryUC,
178 FavoritesUC: deps.FavoritesUC,
179 Clipboard: deps.Clipboard,
180 AutoCopyConfig: &autoCopyConfigFn{fn: func() bool {
181 if deps.Config == nil {
182 return false
183 }
184 return deps.Config.Clipboard.AutoCopyOnSelection
185 }},
186 OnClipboardCopied: func(textLen int) {
187 cb := glib.SourceFunc(func(_ uintptr) bool {
188 if app.appToaster != nil {
189 app.appToaster.Show(ctx, "Copied to clipboard", component.ToastInfo,
190 component.WithDuration(component.ToastBriefDurationMs),
191 component.WithPosition(component.ToastPositionBottomRight),
192 )
193 }
194 return false
195 })
196 glib.IdleAdd(&cb, 0)
197 },
198 }); err != nil {
199 return nil, err
200 }
201
202 return app, nil
203}
204
205// Run starts the GTK application and blocks until it exits.
206// Returns the exit code.
207func (a *App) Run(ctx context.Context, args []string) int {
208 log := logging.FromContext(ctx)
209 log.Debug().Msg("creating GTK application")
210
211 // Initialize libadwaita once (required before using StyleManager).
212 // This also initializes GTK implicitly.
213 adw.Init()
214 logging.Trace().Mark("gtk_init")
215
216 // Mark adwaita detector as available now that adw.Init() is complete.
217 // This enables the highest-priority color scheme detector.
218 if a.deps != nil && a.deps.AdwaitaDetector != nil {
219 a.deps.AdwaitaDetector.MarkAvailable()
220 log.Debug().Msg("adwaita detector marked available")
221
222 // Refresh scripts in pooled WebViews that were prewarmed before adw.Init().
223 // They have the wrong dark mode preference injected; re-inject with correct value.
224 if a.engine != nil {
225 _ = a.engine.OnToolkitReady(ctx)
226 }
227 }
228
229 appID := AppID
230 a.gtkApp = gtk.NewApplication(&appID, gtkApplicationFlags())
231 if a.gtkApp == nil {
232 log.Error().Msg("failed to create GTK application")
233 return 1
234 }
235 defer a.gtkApp.Unref()
236 logging.Trace().Mark("gtk_app_created")
237
238 // Connect activate signal
239 activateCb := func(_ gio.Application) {
240 a.onActivate(ctx)
241 }
242 a.gtkApp.ConnectActivate(&activateCb)
243
244 // Connect shutdown signal
245 shutdownCb := func(_ gio.Application) {
246 a.onShutdown(ctx)
247 }
248 a.gtkApp.ConnectShutdown(&shutdownCb)
249
250 log.Info().Msg("starting GTK main loop")
251 code := a.gtkApp.Run(len(args), args)
252
253 return code
254}
255
256// onActivate is called when the GTK application is activated.
257func (a *App) onActivate(ctx context.Context) {
258 log := logging.FromContext(ctx)
259 log.Debug().Msg("GTK application activated")
260
261 a.applyGTKColorSchemePreference(ctx)
262 // Configure pool background color early (prevents white flash), but avoid
263 // synchronous prewarming that delays the first navigation on cold start.
264 a.setupPoolBackgroundColor(ctx)
265
266 if err := a.createMainWindow(ctx); err != nil {
267 log.Error().Err(err).Msg("failed to create main window")
268 return
269 }
270 logging.Trace().Mark("window_created")
271
272 a.initLayoutInfrastructure()
273 a.initAppToasterOverlay()
274 a.installCrashReportNotifier(ctx)
275 a.initFocusAndBorderOverlay()
276 a.initAccentPicker(ctx)
277 a.registerAccentHandlers(ctx)
278 a.initDownloadHandler(ctx)
279
280 a.initCoordinators(ctx)
281 a.wireWebRTCPermissionIndicator()
282 logging.Trace().Mark("coordinators_init")
283 a.initKeyboardHandler(ctx)
284 a.initOmniboxConfig(ctx)
285 a.initFindBarConfig(ctx)
286 a.initSessionManager(ctx)
287 a.initTabPicker(ctx)
288 a.initSnapshotService(ctx)
289 a.initUpdateCoordinator(ctx)
290 a.createInitialTab(ctx)
291 a.finalizeActivation(ctx)
292}
293
294func (a *App) applyGTKColorSchemePreference(ctx context.Context) {
295 log := logging.FromContext(ctx)
296
297 // Get the config color scheme preference (default follows system theme)
298 scheme := "default"
299 if a.deps != nil && a.deps.Config != nil && a.deps.Config.Appearance.ColorScheme != "" {
300 scheme = a.deps.Config.Appearance.ColorScheme
301 }
302
303 // Apply color scheme via libadwaita's StyleManager.
304 // This properly communicates the preference to GTK and WebKit,
305 // so web content can correctly evaluate prefers-color-scheme media queries.
306 styleMgr := adw.StyleManagerGetDefault()
307 if styleMgr == nil {
308 log.Warn().Msg("failed to get adw.StyleManager")
309 return
310 }
311
312 var adwScheme adw.ColorScheme
313 switch scheme {
314 case "dark", "prefer-dark":
315 adwScheme = adw.ColorSchemeForceDarkValue
316 case "light", "prefer-light":
317 adwScheme = adw.ColorSchemeForceLightValue
318 default:
319 // "default" or empty - follow system preference
320 adwScheme = adw.ColorSchemeDefaultValue
321 }
322
323 styleMgr.SetColorScheme(adwScheme)
324
325 // Refresh the color resolver now that adwaita detector is available.
326 // This ensures all components get the correct preference.
327 var pref string
328 var prefersDark bool
329 if a.deps != nil && a.deps.ColorResolver != nil {
330 result := a.deps.ColorResolver.Refresh()
331 prefersDark = result.PrefersDark
332 pref = result.Source
333 } else {
334 prefersDark = styleMgr.GetDark()
335 pref = "adw.StyleManager"
336 }
337
338 log.Debug().
339 Str("scheme", scheme).
340 Bool("prefers_dark", prefersDark).
341 Str("source", pref).
342 Msg("applied color scheme via adw.StyleManager")
343}
344
345func (a *App) setupPoolBackgroundColor(ctx context.Context) {
346 log := logging.FromContext(ctx)
347 if a.engine == nil {
348 log.Debug().Msg("engine not available; skipping background color setup")
349 return
350 }
351 if a.deps == nil || a.deps.Theme == nil {
352 log.Debug().Msg("theme not available; skipping webview pool background color setup")
353 return
354 }
355 r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
356 if err := a.engine.UpdateAppearance(ctx, float64(r), float64(g), float64(b), float64(alpha)); err != nil {
357 log.Warn().Err(err).Msg("failed to update engine appearance")
358 }
359 log.Debug().Msg("configured webview pool background color")
360}
361
362func (a *App) prewarmWebViewPoolAsync(ctx context.Context) {
363 if a.engine == nil {
364 return
365 }
366 pool := a.engine.Pool()
367 if pool == nil {
368 return
369 }
370 pool.PrewarmAsync(ctx, 0)
371}
372
373func (a *App) createMainWindow(ctx context.Context) error {
374 mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config.Workspace.TabBarPosition)
375 if err != nil {
376 return err
377 }
378 a.mainWindow = mainWindow
379
380 // Create permission popup and dialog presenter
381 if a.deps != nil && a.deps.PermissionUC != nil {
382 uiScale := 1.0
383 if a.deps.Config != nil {
384 uiScale = a.deps.Config.DefaultUIScale
385 }
386 permPopup := component.NewPermissionPopup(nil, uiScale)
387 if permPopup != nil {
388 // Add popup to the main window's content overlay
389 if w := permPopup.Widget(); w != nil {
390 a.mainWindow.AddOverlay(w)
391 }
392 permDialog := dialog.NewPermissionDialog(permPopup)
393 a.deps.PermissionUC.SetDialogPresenter(permDialog)
394 }
395 }
396
397 // Create top-right WebRTC permission activity indicator.
398 indicator := component.NewWebRTCPermissionIndicator()
399 if indicator != nil {
400 if w := indicator.Widget(); w != nil {
401 a.mainWindow.AddOverlay(w)
402 a.webrtcIndicator = indicator
403 }
404 }
405
406 // Apply GTK CSS styling from theme manager.
407 if a.deps == nil || a.deps.Theme == nil {
408 return nil
409 }
410 if display := a.mainWindow.Window().GetDisplay(); display != nil {
411 a.deps.Theme.ApplyToDisplay(ctx, display)
412 }
413 return nil
414}
415
416func (a *App) initLayoutInfrastructure() {
417 // Initialize widget factory for pane layout.
418 a.widgetFactory = layout.NewGtkWidgetFactory()
419
420 // Initialize stacked pane manager for incremental widget operations.
421 a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory)
422}
423
424func (a *App) initAppToasterOverlay() {
425 if a.mainWindow == nil {
426 return
427 }
428
429 // Create app-level toaster for system notifications.
430 a.appToaster = component.NewToaster(a.widgetFactory)
431 toasterWidget := a.appToaster.Widget()
432 if toasterWidget == nil {
433 return
434 }
435 gtkWidget := toasterWidget.GtkWidget()
436 if gtkWidget == nil {
437 return
438 }
439 a.mainWindow.AddOverlay(gtkWidget)
440
441 // Create mode indicator toaster for modal mode notifications.
442 a.modeToaster = component.NewToaster(a.widgetFactory)
443 modeToasterWidget := a.modeToaster.Widget()
444 if modeToasterWidget == nil {
445 return
446 }
447 modeGtkWidget := modeToasterWidget.GtkWidget()
448 if modeGtkWidget == nil {
449 return
450 }
451 a.mainWindow.AddOverlay(modeGtkWidget)
452}
453
454func (a *App) installCrashReportNotifier(ctx context.Context) {
455 if a.deps == nil {
456 return
457 }
458
459 a.deps.OnCrashReportsDetected = func(paths []string) {
460 a.showCrashReportToast(ctx, paths)
461 }
462}
463
464func (a *App) initFocusAndBorderOverlay() {
465 // Initialize focus manager for geometric navigation.
466 a.focusMgr = focus.NewManager(a.panesUC)
467
468 // Initialize border manager for mode indicators.
469 a.borderMgr = focus.NewBorderManager(a.widgetFactory)
470
471 // Attach border overlay to main window (visible for all tabs).
472 if a.borderMgr == nil || a.mainWindow == nil {
473 return
474 }
475 borderWidget := a.borderMgr.Widget()
476 if borderWidget == nil {
477 return
478 }
479 gtkWidget := borderWidget.GtkWidget()
480 if gtkWidget == nil {
481 return
482 }
483 a.mainWindow.AddOverlay(gtkWidget)
484}
485
486func (a *App) initAccentPicker(ctx context.Context) {
487 log := logging.FromContext(ctx)
488
489 if a.widgetFactory == nil || a.mainWindow == nil {
490 log.Debug().Msg("widget factory or main window not available, skipping accent picker")
491 return
492 }
493
494 // Create accent picker component
495 a.accentPicker = component.NewAccentPicker(a.widgetFactory)
496 if a.accentPicker == nil {
497 log.Warn().Msg("failed to create accent picker")
498 return
499 }
500
501 // Add to main window overlay
502 pickerWidget := a.accentPicker.Widget()
503 if pickerWidget != nil {
504 gtkWidget := pickerWidget.GtkWidget()
505 if gtkWidget != nil {
506 a.mainWindow.AddOverlay(gtkWidget)
507 }
508 }
509
510 // Use focus provider from deps (tracks which input has focus)
511 a.accentFocusProvider = a.deps.AccentFocusProvider
512
513 // Create the use case with glib.IdleAdd for thread-safe GTK calls
514 a.insertAccentUC = usecase.NewInsertAccentUseCase(
515 a.accentFocusProvider,
516 a.accentPicker,
517 func(fn func()) {
518 cb := glib.SourceFunc(func(_ uintptr) bool {
519 fn()
520 return false // Don't repeat
521 })
522 glib.IdleAdd(&cb, 0)
523 },
524 )
525
526 log.Debug().Msg("accent picker initialized")
527}
528
529// registerAccentHandlers registers the accent key press/release message handlers
530// with the router. Must be called after initAccentPicker so insertAccentUC is non-nil.
531func (a *App) registerAccentHandlers(ctx context.Context) {
532 log := logging.FromContext(ctx)
533
534 if a.insertAccentUC == nil {
535 log.Debug().Msg("insertAccentUC not available, skipping accent handler registration")
536 return
537 }
538 if a.engine == nil {
539 log.Debug().Msg("engine not available, skipping accent handler registration")
540 return
541 }
542
543 if err := a.engine.RegisterAccentHandlers(ctx, a.insertAccentUC); err != nil {
544 log.Error().Err(err).Msg("failed to register accent handlers")
545 } else {
546 log.Info().Msg("registered accent key handlers")
547 }
548}
549
550func (a *App) initDownloadHandler(ctx context.Context) {
551 log := logging.FromContext(ctx)
552
553 if a.deps == nil || a.engine == nil {
554 log.Debug().Msg("WebContext not available, skipping download handler")
555 return
556 }
557
558 // Determine download path from config, fallback to XDG.
559 downloadPath := ""
560 if a.deps.Config != nil {
561 downloadPath = a.deps.Config.Downloads.Path
562 }
563 if downloadPath == "" && a.deps.XDG != nil {
564 var err error
565 downloadPath, err = a.deps.XDG.DownloadDir()
566 if err != nil {
567 log.Warn().Err(err).Msg("failed to get XDG download dir")
568 }
569 }
570 if downloadPath == "" {
571 // Final fallback to ~/Downloads, with /tmp as last resort.
572 home, err := os.UserHomeDir()
573 if err != nil {
574 log.Warn().Err(err).Msg("failed to get home dir, using /tmp for downloads")
575 downloadPath = "/tmp"
576 } else {
577 downloadPath = filepath.Join(home, "Downloads")
578 }
579 }
580
581 // Create download event adapter to show toasts.
582 eventAdapter := &downloadEventAdapter{app: a}
583
584 // Create use case for preparing download destinations with file deduplication.
585 prepareDownloadUC := usecase.NewPrepareDownloadUseCase(a.deps.FileSystem)
586
587 if err := a.engine.ConfigureDownloads(ctx, downloadPath, eventAdapter, prepareDownloadUC); err != nil {
588 log.Error().Err(err).Msg("failed to configure downloads")
589 return
590 }
591
592 log.Info().Str("path", downloadPath).Msg("download handler initialized")
593}
594
595// downloadEventAdapter implements port.DownloadEventHandler and shows toasts.
596type downloadEventAdapter struct {
597 app *App
598}
599
600func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) {
601 // Must schedule on GTK main thread.
602 cb := glib.SourceFunc(func(_ uintptr) bool {
603 if d.app.appToaster == nil {
604 return false
605 }
606
607 switch event.Type {
608 case port.DownloadEventStarted:
609 d.app.appToaster.Show(ctx, "Download started: "+event.Filename, component.ToastInfo)
610 case port.DownloadEventFinished:
611 d.app.appToaster.Show(ctx, "Download complete: "+event.Filename, component.ToastSuccess)
612 case port.DownloadEventFailed:
613 d.app.appToaster.Show(ctx, "Download failed: "+event.Filename, component.ToastError)
614 }
615 return false
616 })
617 glib.IdleAdd(&cb, 0)
618}
619
620// autoCopyConfigFn implements port.AutoCopyConfig using a function closure.
621type autoCopyConfigFn struct {
622 fn func() bool
623}
624
625func (a *autoCopyConfigFn) IsAutoCopyEnabled() bool {
626 if a.fn == nil {
627 return false
628 }
629 return a.fn()
630}
631
632func (a *App) initKeyboardHandler(ctx context.Context) {
633 log := logging.FromContext(ctx)
634
635 if a.mainWindow == nil || a.deps == nil || a.deps.Config == nil {
636 return
637 }
638
639 // Create keyboard handler and wire to dispatcher.
640 a.keyboardHandler = input.NewKeyboardHandler(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session)
641 a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error {
642 if action == input.ActionClosePane {
643 if a.closeAndReleaseActiveFloatingPane(ctx) {
644 return nil
645 }
646 }
647 return a.kbDispatcher.Dispatch(ctx, action)
648 })
649 a.keyboardHandler.SetOnEscape(func(ctx context.Context) bool {
650 return a.handleGlobalEscape(ctx)
651 })
652 a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
653 a.handleModeChange(ctx, from, to)
654 })
655 // Wire key routing policy.
656 // Determines how each key event should be handled based on UI state:
657 // - Overlays visible (session manager, tab picker): pass all keys to widget
658 // - Omnibox visible: accent detection for text keys, shortcuts for modified keys
659 // - WebView focused: pass text/dead keys to WebKit IM for native compose,
660 // intercept shortcut-modified keys (Ctrl+, Alt+)
661 // - Default: handle through shortcut system
662 a.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute {
663 // Overlays take full keyboard control
664 if a.sessionManager != nil && a.sessionManager.IsVisible() {
665 return input.RoutePassToWidget
666 }
667 if a.tabPicker != nil && a.tabPicker.IsVisible() {
668 return input.RoutePassToWidget
669 }
670
671 wsView := a.activeWorkspaceView()
672 if wsView == nil {
673 return input.RouteHandleShortcuts
674 }
675
676 // Check actual focus rather than visibility: find bar can remain
677 // visible after focus returns to the WebView, so visibility alone
678 // would misroute keys.
679 if a.accentFocusProvider != nil {
680 if _, ok := a.accentFocusProvider.GetFocusedInput().(port.EntryInputTarget); ok {
681 // GTK Entry focused (omnibox or find bar): Alt-modified keys go to
682 // shortcuts (pane navigation), other keys pass through to the widget's
683 // own capture-phase key controller (which handles accent detection)
684 if kc.Modifiers&input.ModAlt != 0 {
685 return input.RouteHandleShortcuts
686 }
687 return input.RoutePassToWidget
688 }
689 }
690
691 // WebView focused: text/dead keys pass through for native IM compose,
692 // shortcut-modified keys (Ctrl+, Alt+) are handled by shortcut system
693 if input.IsShortcutModified(kc.Modifiers) {
694 return input.RouteHandleShortcuts
695 }
696 if input.IsTextInputKey(kc.Keyval) {
697 return input.RoutePassToWidget
698 }
699
700 // Non-text, non-shortcut keys (F-keys, arrows, etc.): shortcut system
701 return input.RouteHandleShortcuts
702 })
703 // Wire accent handler for dead keys support
704 if a.insertAccentUC != nil {
705 a.keyboardHandler.SetAccentHandler(a.insertAccentUC)
706 }
707 a.keyboardHandler.AttachTo(a.mainWindow.Window())
708
709 // Create global shortcut handler for Alt+1-9 tab switching.
710 // This uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
711 // to intercept shortcuts even when WebView has focus.
712 a.globalShortcutHandler = input.NewGlobalShortcutHandler(
713 ctx,
714 a.mainWindow.Window(),
715 &a.deps.Config.Workspace,
716 &a.deps.Config.Session,
717 a.keyboardHandler,
718 func(ctx context.Context, action input.Action) error {
719 return a.kbDispatcher.Dispatch(ctx, action)
720 },
721 )
722 if a.globalShortcutHandler == nil {
723 log.Warn().Msg("global shortcut handler creation failed, shortcuts may not work when WebView has focus")
724 }
725}
726
727// handleAccentKeyPress handles accent key press events for GTK entry widgets
728// (omnibox and find bar). Returns true if the key was consumed.
729func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.ModifierType) bool {
730 if a.insertAccentUC == nil {
731 return false
732 }
733 if state&(gdk.ControlMaskValue|gdk.AltMaskValue) != 0 {
734 return false
735 }
736 shiftHeld := state&gdk.ShiftMaskValue != 0
737 if char := input.KeyvalToRune(keyval); char != 0 {
738 return a.insertAccentUC.OnKeyPressed(ctx, char, shiftHeld)
739 }
740 return false
741}
742
743// handleAccentKeyRelease handles accent key release events for GTK entry widgets
744// (omnibox and find bar).
745func (a *App) handleAccentKeyRelease(ctx context.Context, keyval uint) {
746 if a.insertAccentUC == nil {
747 return
748 }
749 if char := input.KeyvalToRune(keyval); char != 0 {
750 a.insertAccentUC.OnKeyReleased(ctx, char)
751 }
752}
753
754func (a *App) initOmniboxConfig(ctx context.Context) {
755 if a.deps == nil || a.deps.Config == nil {
756 return
757 }
758
759 log := logging.FromContext(ctx)
760
761 // Convert config shortcuts to use case type
762 shortcuts := make(map[string]usecase.SearchShortcut, len(a.deps.Config.SearchShortcuts))
763 for key, shortcut := range a.deps.Config.SearchShortcuts {
764 shortcuts[key] = usecase.SearchShortcut{
765 URL: shortcut.URL,
766 Description: shortcut.Description,
767 }
768 }
769 shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts)
770
771 // Store omnibox config (omnibox is created per-pane via WorkspaceView).
772 a.omniboxCfg = component.OmniboxConfig{
773 HistoryUC: a.deps.HistoryUC,
774 FavoritesUC: a.deps.FavoritesUC,
775 FaviconAdapter: a.faviconAdapter,
776 CopyURLUC: a.deps.CopyURLUC,
777 ShortcutsUC: shortcutsUC,
778 DefaultSearch: a.deps.Config.DefaultSearchEngine,
779 InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,
780 UIScale: a.deps.Config.DefaultUIScale,
781 OnNavigate: func(url string) {
782 if err := a.navigateFromOmnibox(ctx, url); err != nil {
783 log.Error().Err(err).Str("url", url).Msg("navigation failed")
784 }
785 },
786 OnFocusIn: func(entry *gtk.SearchEntry) {
787 // Set omnibox entry as the focused input for accent picker
788 if a.accentFocusProvider != nil && entry != nil {
789 if a.deps.NewGTKEntryTarget != nil {
790 a.accentFocusProvider.SetFocusedInput(a.deps.NewGTKEntryTarget(entry))
791 }
792 }
793 },
794 OnFocusOut: func() {
795 // When omnibox loses focus, set WebView as the focused input
796 if a.accentFocusProvider != nil {
797 a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
798 }
799 },
800 OnAccentKeyPress: func(keyval uint, state gdk.ModifierType) bool {
801 return a.handleAccentKeyPress(ctx, keyval, state)
802 },
803 OnAccentKeyRelease: func(keyval uint) {
804 a.handleAccentKeyRelease(ctx, keyval)
805 },
806 }
807 a.navCoord.SetOmniboxProvider(a)
808 log.Debug().Msg("omnibox config stored, provider set")
809}
810
811func (a *App) initFindBarConfig(ctx context.Context) {
812 // Store find bar config (find bar is created per-pane via WorkspaceView).
813 a.findBarCfg = component.FindBarConfig{
814 GetFindController: func(paneID entity.PaneID) port.FindController {
815 if a.contentCoord == nil {
816 return nil
817 }
818 wv := a.contentCoord.GetWebView(paneID)
819 if wv == nil {
820 return nil
821 }
822 return wv.GetFindController()
823 },
824 OnFocusIn: func(entry *gtk.SearchEntry) {
825 // Set find bar entry as the focused input for accent picker
826 if a.accentFocusProvider != nil && entry != nil {
827 if a.deps.NewGTKEntryTarget != nil {
828 a.accentFocusProvider.SetFocusedInput(a.deps.NewGTKEntryTarget(entry))
829 }
830 }
831 },
832 OnFocusOut: func() {
833 // When find bar loses focus, set WebView as the focused input
834 if a.accentFocusProvider != nil {
835 a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
836 }
837 },
838 OnAccentKeyPress: func(keyval uint, state gdk.ModifierType) bool {
839 return a.handleAccentKeyPress(ctx, keyval, state)
840 },
841 OnAccentKeyRelease: func(keyval uint) {
842 a.handleAccentKeyRelease(ctx, keyval)
843 },
844 }
845}
846
847func (a *App) initSessionManager(ctx context.Context) {
848 log := logging.FromContext(ctx)
849
850 if a.deps == nil {
851 log.Debug().Msg("deps not available, skipping session manager")
852 return
853 }
854
855 // Session manager can work without a repo - it just shows an empty list
856 var listSessionsUC *usecase.ListSessionsUseCase
857 var deleteSessionUC *usecase.DeleteSessionUseCase
858 if a.deps.SessionRepo != nil && a.deps.SessionStateRepo != nil {
859 listSessionsUC = usecase.NewListSessionsUseCase(
860 a.deps.SessionRepo,
861 a.deps.SessionStateRepo,
862 a.deps.Config.Logging.LogDir,
863 )
864 deleteSessionUC = usecase.NewDeleteSessionUseCase(
865 a.deps.SessionStateRepo,
866 a.deps.SessionRepo,
867 a.deps.Config.Logging.LogDir,
868 )
869 }
870
871 // Use injected session spawner for restoration
872 spawner := a.deps.SessionSpawner
873
874 // Create session manager component
875 a.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{
876 ListSessionsUC: listSessionsUC,
877 DeleteSessionUC: deleteSessionUC,
878 CurrentSession: a.deps.CurrentSessionID,
879 UIScale: a.deps.Config.DefaultUIScale,
880 OnClose: func() {
881 log.Debug().Msg("session manager closed")
882 },
883 OnOpen: func(sessionID entity.SessionID) {
884 log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested")
885 if spawner == nil {
886 log.Warn().Str("session_id", string(sessionID)).Msg("session spawner not available")
887 return
888 }
889 if err := spawner.SpawnWithSession(sessionID); err != nil {
890 log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session")
891 }
892 },
893 OnToast: func(ctx context.Context, message string, level component.ToastLevel) {
894 if a.appToaster != nil {
895 a.appToaster.Show(ctx, message, level)
896 }
897 },
898 })
899
900 if a.sessionManager == nil {
901 log.Warn().Msg("failed to create session manager")
902 return
903 }
904
905 // Add session manager to main window overlay
906 if a.mainWindow != nil {
907 widget := a.sessionManager.Widget()
908 if widget != nil {
909 a.mainWindow.AddOverlay(widget)
910 }
911 }
912
913 // Wire session manager action to keyboard dispatcher
914 a.kbDispatcher.SetOnSessionOpen(func(ctx context.Context) error {
915 a.ToggleSessionManager(ctx)
916 return nil
917 })
918
919 log.Debug().Msg("session manager initialized")
920}
921
922// ToggleSessionManager shows or hides the session manager.
923func (a *App) ToggleSessionManager(ctx context.Context) {
924 if a.sessionManager == nil {
925 return
926 }
927 a.sessionManager.Toggle(ctx)
928}
929
930func (a *App) attachTabPickerToActivePane() {
931 if a.tabPicker == nil || a.widgetFactory == nil {
932 return
933 }
934 wsView := a.activeWorkspaceView()
935 if wsView == nil {
936 return
937 }
938
939 activeTab := a.tabs.ActiveTab()
940 if activeTab == nil || activeTab.Workspace == nil {
941 return
942 }
943 activePaneID := activeTab.Workspace.ActivePaneID
944 if activePaneID == "" {
945 return
946 }
947 pv := wsView.GetPaneView(activePaneID)
948 if pv == nil {
949 return
950 }
951
952 if a.tabPickerWidget == nil {
953 a.tabPickerWidget = a.tabPicker.WidgetAsLayout(a.widgetFactory)
954 if a.tabPickerWidget == nil {
955 return
956 }
957 }
958
959 // If currently attached to a different pane overlay, detach.
960 if a.tabPickerPaneID != "" && a.tabPickerPaneID != activePaneID {
961 for _, view := range a.workspaceViews {
962 if view == nil {
963 continue
964 }
965 if oldPV := view.GetPaneView(a.tabPickerPaneID); oldPV != nil {
966 if parent := a.tabPickerWidget.GetParent(); parent == oldPV.Overlay() {
967 oldPV.RemoveOverlayWidget(a.tabPickerWidget)
968 } else if parent != nil {
969 a.tabPickerWidget.Unparent()
970 }
971 break
972 }
973 }
974 }
975
976 // Ensure the widget can be reparented.
977 if parent := a.tabPickerWidget.GetParent(); parent != nil {
978 a.tabPickerWidget.Unparent()
979 }
980
981 a.tabPicker.SetParentOverlay(pv.Overlay())
982 pv.AddOverlayWidget(a.tabPickerWidget)
983 a.tabPickerPaneID = activePaneID
984}
985
986func (a *App) HandleMovePaneToTab(ctx context.Context) error {
987 if a.movePaneToTabUC == nil {
988 return nil
989 }
990 if a.tabPicker == nil {
991 return nil
992 }
993
994 sourceTab := a.tabs.ActiveTab()
995 if sourceTab == nil || sourceTab.Workspace == nil {
996 return nil
997 }
998
999 // If there is only 1 tab, auto-create a new tab and move there.
1000 if a.tabs.Count() <= 1 {
1001 return a.MoveActivePaneToTab(ctx, "")
1002 }
1003
1004 items := make([]component.TabPickerItem, 0, a.tabs.Count())
1005 for _, tab := range a.tabs.Tabs {
1006 if tab == nil {
1007 continue
1008 }
1009 if tab.ID == sourceTab.ID {
1010 continue
1011 }
1012 items = append(items, component.TabPickerItem{
1013 TabID: tab.ID,
1014 Title: tab.Title(),
1015 IsNew: false,
1016 Index: tab.Position,
1017 })
1018 }
1019 items = append(items, component.TabPickerItem{IsNew: true, Index: -1})
1020
1021 a.attachTabPickerToActivePane()
1022 a.tabPicker.Show(ctx, items)
1023 return nil
1024}
1025
1026func (a *App) HandleMovePaneToNextTab(ctx context.Context) error {
1027 if a.movePaneToTabUC == nil {
1028 return nil
1029 }
1030 active := a.tabs.ActiveTab()
1031 if active == nil {
1032 return nil
1033 }
1034
1035 nextPos := active.Position + 1
1036 if nextPos >= a.tabs.Count() {
1037 // Create a new tab on the right.
1038 return a.MoveActivePaneToTab(ctx, "")
1039 }
1040 next := a.tabs.TabAt(nextPos)
1041 if next == nil {
1042 return nil
1043 }
1044 return a.MoveActivePaneToTab(ctx, next.ID)
1045}
1046
1047func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID) error {
1048 in, sourceTab := a.buildMovePaneToTabInput(targetTabID)
1049 if in == nil {
1050 return nil
1051 }
1052
1053 out, err := a.movePaneToTabUC.Execute(*in)
1054 if err != nil {
1055 return err
1056 }
1057 if out == nil || out.TargetTab == nil {
1058 return nil
1059 }
1060
1061 a.applyMovePaneToTabUI(ctx, out, sourceTab)
1062 a.switchToTargetTabIfConfigured(ctx, out)
1063 a.updateTabBarVisibility(ctx)
1064 a.MarkDirty()
1065 return nil
1066}
1067
1068func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePaneToTabInput, *entity.Tab) {
1069 if a.movePaneToTabUC == nil {
1070 return nil, nil
1071 }
1072 activeTab := a.tabs.ActiveTab()
1073 if activeTab == nil || activeTab.Workspace == nil {
1074 return nil, nil
1075 }
1076 sourcePaneID := activeTab.Workspace.ActivePaneID
1077 if sourcePaneID == "" {
1078 return nil, nil
1079 }
1080
1081 in := &usecase.MovePaneToTabInput{
1082 TabList: a.tabs,
1083 SourceTabID: activeTab.ID,
1084 SourcePaneID: sourcePaneID,
1085 TargetTabID: targetTabID,
1086 }
1087 return in, activeTab
1088}
1089
1090func (a *App) applyMovePaneToTabUI(ctx context.Context, out *usecase.MovePaneToTabOutput, sourceTab *entity.Tab) {
1091 if out == nil || out.TargetTab == nil {
1092 return
1093 }
1094 sourceTabID := entity.TabID("")
1095 if sourceTab != nil {
1096 sourceTabID = sourceTab.ID
1097 }
1098
1099 if out.SourceTabClosed {
1100 a.removeSourceTabUI(sourceTabID)
1101 }
1102 if out.NewTabCreated {
1103 a.ensureTargetTabUI(ctx, out.TargetTab)
1104 }
1105
1106 if !out.SourceTabClosed {
1107 a.rebuildAndAttachWorkspace(ctx, sourceTabID, sourceTab)
1108 }
1109 a.rebuildAndAttachWorkspace(ctx, out.TargetTab.ID, out.TargetTab)
1110}
1111
1112func (a *App) removeSourceTabUI(sourceTabID entity.TabID) {
1113 a.releaseFloatingSessionsForTab(context.Background(), sourceTabID)
1114 delete(a.workspaceViews, sourceTabID)
1115 if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
1116 a.mainWindow.TabBar().RemoveTab(sourceTabID)
1117 }
1118}
1119
1120func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab) {
1121 if tab == nil {
1122 return
1123 }
1124 if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
1125 a.mainWindow.TabBar().AddTab(tab)
1126 }
1127 a.createWorkspaceViewWithoutAttach(ctx, tab)
1128}
1129
1130func (a *App) rebuildAndAttachWorkspace(ctx context.Context, tabID entity.TabID, tab *entity.Tab) {
1131 wsView := a.workspaceViews[tabID]
1132 if wsView == nil {
1133 return
1134 }
1135 _ = wsView.Rebuild(ctx)
1136 a.reattachFloatingSessions(tabID, wsView)
1137 a.syncFloatingFocus()
1138
1139 if tab == nil || tab.Workspace == nil || a.contentCoord == nil {
1140 return
1141 }
1142 a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
1143 if a.wsCoord != nil {
1144 a.wsCoord.SetupStackedPaneCallbacks(ctx, tab.Workspace, wsView)
1145 }
1146}
1147
1148func (a *App) reattachFloatingSessions(tabID entity.TabID, wsView *component.WorkspaceView) {
1149 if wsView == nil {
1150 return
1151 }
1152
1153 for key, session := range a.floatingSessions {
1154 if key.tabID != tabID || session == nil || session.pane == nil {
1155 continue
1156 }
1157
1158 session.pane.SetParentOverlay(wsView.WorkspaceOverlayWidget())
1159 if session.widget != nil {
1160 wsView.AddWorkspaceOverlayWidget(session.widget)
1161 configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), session.widget)
1162 setFloatingWidgetShown(session.widget, session.pane.IsVisible())
1163 }
1164 if session.paneView != nil {
1165 wsView.RegisterPaneView(session.paneID, session.paneView)
1166 if session.focusWidget == nil {
1167 session.focusWidget = session.paneView.WebViewWidget()
1168 }
1169 }
1170 }
1171}
1172
1173func (a *App) switchToTargetTabIfConfigured(ctx context.Context, out *usecase.MovePaneToTabOutput) {
1174 if out == nil || out.TargetTab == nil {
1175 return
1176 }
1177
1178 switchToTarget := a.deps.Config.Workspace.SwitchToTabOnMove
1179 if out.SourceTabClosed {
1180 switchToTarget = true
1181 }
1182 if !switchToTarget {
1183 return
1184 }
1185
1186 a.tabs.SetActive(out.TargetTab.ID)
1187 if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
1188 a.mainWindow.TabBar().SetActive(out.TargetTab.ID)
1189 }
1190 a.switchWorkspaceView(ctx, out.TargetTab.ID)
1191}
1192
1193func (a *App) updateTabBarVisibility(ctx context.Context) {
1194 if a.tabCoord != nil {
1195 a.tabCoord.UpdateBarVisibility(ctx)
1196 }
1197}
1198
1199func (a *App) initTabPicker(ctx context.Context) {
1200 log := logging.FromContext(ctx)
1201
1202 if a.deps == nil || a.deps.Config == nil {
1203 log.Debug().Msg("deps/config not available, skipping tab picker")
1204 return
1205 }
1206
1207 a.tabPicker = component.NewTabPicker(ctx, component.TabPickerConfig{
1208 UIScale: a.deps.Config.DefaultUIScale,
1209 OnClose: func() {
1210 log.Debug().Msg("tab picker closed")
1211 },
1212 OnSelect: func(item component.TabPickerItem) {
1213 cb := glib.SourceFunc(func(_ uintptr) bool {
1214 var targetID entity.TabID
1215 if !item.IsNew {
1216 targetID = item.TabID
1217 }
1218 if err := a.MoveActivePaneToTab(ctx, targetID); err != nil {
1219 log.Warn().Err(err).Msg("move pane to tab failed")
1220 }
1221 return false
1222 })
1223 glib.IdleAdd(&cb, 0)
1224 },
1225 })
1226
1227 if a.tabPicker == nil {
1228 log.Warn().Msg("failed to create tab picker")
1229 return
1230 }
1231
1232 log.Debug().Msg("tab picker initialized")
1233}
1234
1235func (a *App) initSnapshotService(ctx context.Context) {
1236 log := logging.FromContext(ctx)
1237
1238 if a.deps == nil || a.deps.SnapshotServiceFactory == nil {
1239 log.Debug().Msg("snapshot service factory not available, skipping")
1240 return
1241 }
1242
1243 intervalMs := 5000 // default
1244 if a.deps.Config != nil && a.deps.Config.Session.SnapshotIntervalMs > 0 {
1245 intervalMs = a.deps.Config.Session.SnapshotIntervalMs
1246 }
1247
1248 a.snapshotService = a.deps.SnapshotServiceFactory(a, intervalMs)
1249 if a.snapshotService == nil {
1250 log.Warn().Msg("snapshot service factory returned nil")
1251 return
1252 }
1253 a.snapshotService.Start(ctx)
1254
1255 // Set up callback for main.go to notify when session is persisted
1256 a.deps.OnSessionPersisted = func() {
1257 a.snapshotService.SetReady()
1258 }
1259
1260 log.Debug().Int("interval_ms", intervalMs).Msg("snapshot service started")
1261}
1262
1263func (a *App) initUpdateCoordinator(ctx context.Context) {
1264 log := logging.FromContext(ctx)
1265
1266 if a.deps == nil || a.deps.CheckUpdateUC == nil {
1267 log.Debug().Msg("update use cases not available, skipping update coordinator")
1268 return
1269 }
1270
1271 a.updateCoord = coordinator.NewUpdateCoordinator(
1272 a.deps.CheckUpdateUC,
1273 a.deps.ApplyUpdateUC,
1274 a.appToaster,
1275 a.deps.Config.Update.EnableOnStartup,
1276 a.deps.Config.Update.AutoDownload,
1277 )
1278
1279 // Start async update check
1280 a.updateCoord.CheckOnStartup(ctx)
1281
1282 log.Debug().Msg("update coordinator initialized")
1283}
1284
1285// GetTabList implements port.TabListProvider.
1286func (a *App) GetTabList() *entity.TabList {
1287 return a.tabs
1288}
1289
1290// GetSessionID implements port.TabListProvider.
1291func (a *App) GetSessionID() entity.SessionID {
1292 if a.deps == nil {
1293 return ""
1294 }
1295 return a.deps.CurrentSessionID
1296}
1297
1298// MarkDirty signals that session state has changed and should be saved.
1299func (a *App) MarkDirty() {
1300 if a.snapshotService != nil {
1301 a.snapshotService.MarkDirty()
1302 }
1303}
1304
1305func (a *App) createInitialTab(ctx context.Context) {
1306 log := logging.FromContext(ctx)
1307
1308 // Check if we should restore a session
1309 if a.deps != nil && a.deps.RestoreSessionID != "" {
1310 if err := a.restoreSession(ctx, entity.SessionID(a.deps.RestoreSessionID)); err != nil {
1311 log.Error().Err(err).Str("session_id", a.deps.RestoreSessionID).Msg("failed to restore session, creating default tab")
1312 // Show error toast and fall through to create default tab
1313 if a.appToaster != nil {
1314 a.appToaster.Show(ctx, "Session restore failed", component.ToastWarning)
1315 }
1316 } else {
1317 log.Info().Str("session_id", a.deps.RestoreSessionID).Msg("session restored")
1318 // Show success toast for session restoration
1319 if a.appToaster != nil {
1320 a.appToaster.Show(ctx, "Session restored", component.ToastSuccess)
1321 }
1322 return
1323 }
1324 }
1325
1326 // Create an initial tab using coordinator.
1327 initialURL := "dumb://home"
1328 if a.deps != nil && a.deps.InitialURL != "" {
1329 initialURL = a.deps.InitialURL
1330 }
1331 if _, err := a.tabCoord.Create(ctx, initialURL); err != nil {
1332 log.Error().Err(err).Msg("failed to create initial tab")
1333 }
1334}
1335
1336func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) error {
1337 log := logging.FromContext(ctx)
1338
1339 if a.deps == nil || a.deps.SessionStateRepo == nil {
1340 return fmt.Errorf("session state repo not available")
1341 }
1342
1343 // Load session state
1344 state, err := a.deps.SessionStateRepo.GetSnapshot(ctx, sessionID)
1345 if err != nil {
1346 return fmt.Errorf("load session state: %w", err)
1347 }
1348 if state == nil {
1349 return fmt.Errorf("session state not found")
1350 }
1351
1352 log.Info().Int("tabs", len(state.Tabs)).Int("panes", state.CountPanes()).Msg("restoring session state")
1353
1354 // Build complete TabList from snapshot (state-first approach)
1355 // This reconstructs the full pane tree with new IDs
1356 restoredTabs := entity.TabListFromSnapshot(state, a.generateID)
1357 if restoredTabs == nil || len(restoredTabs.Tabs) == 0 {
1358 return fmt.Errorf("failed to build tab list from snapshot")
1359 }
1360
1361 // Replace tabs in-place to preserve references held by TabCoordinator
1362 a.tabs.ReplaceFrom(restoredTabs)
1363
1364 // Create UI for each restored tab and add to tab bar
1365 // Don't attach to content area yet - only the active tab should be attached
1366 tabBar := a.mainWindow.TabBar()
1367 for _, tab := range a.tabs.Tabs {
1368 a.createWorkspaceViewWithoutAttach(ctx, tab)
1369 if tabBar != nil {
1370 tabBar.AddTab(tab)
1371 }
1372 log.Debug().
1373 Str("tab_id", string(tab.ID)).
1374 Str("name", tab.Name).
1375 Int("panes", tab.Workspace.PaneCount()).
1376 Msg("restored tab with workspace")
1377 }
1378
1379 // Switch to active tab - this attaches the active workspace view to content area
1380 activeTab := a.tabs.ActiveTab()
1381 if activeTab != nil {
1382 a.switchWorkspaceView(ctx, activeTab.ID)
1383 if tabBar != nil {
1384 tabBar.SetActive(activeTab.ID)
1385 }
1386 }
1387
1388 return nil
1389}
1390
1391func (a *App) finalizeActivation(ctx context.Context) {
1392 log := logging.FromContext(ctx)
1393
1394 // Show the window
1395 if a.mainWindow != nil {
1396 a.mainWindow.Show()
1397 }
1398 log.Info().Msg("main window displayed")
1399
1400 if a.deps != nil && len(a.deps.StartupCrashReports) > 0 {
1401 a.showCrashReportToast(ctx, a.deps.StartupCrashReports)
1402 }
1403
1404 // Defer non-critical initialization until after first navigation starts.
1405 // This keeps pool prewarm, config watcher, and filter loading from
1406 // competing with the initial page load.
1407 a.runAfterFirstLoadStarted(func() {
1408 a.prewarmWebViewPoolAsync(ctx)
1409 a.initConfigWatcher(ctx)
1410 a.initFilteringAsync(ctx)
1411 a.checkConfigMigration(ctx)
1412 })
1413}
1414
1415func (a *App) showCrashReportToast(ctx context.Context, paths []string) {
1416 if len(paths) == 0 {
1417 return
1418 }
1419 log := logging.FromContext(ctx)
1420 log.Warn().Int("count", len(paths)).Msg("unexpected-close reports available")
1421
1422 cb := glib.SourceFunc(func(_ uintptr) bool {
1423 if a.appToaster != nil {
1424 msg := fmt.Sprintf("Detected %d unexpected close report(s). Run: dumber crashes issue latest", len(paths))
1425 a.appToaster.Show(ctx, msg, component.ToastWarning,
1426 component.WithDuration(crashReportToastDurationMs),
1427 component.WithPosition(component.ToastPositionBottomRight),
1428 )
1429 }
1430 return false
1431 })
1432 glib.IdleAdd(&cb, 0)
1433}
1434
1435// runAfterFirstLoadStarted schedules work to run after the first navigation starts.
1436// This keeps the GTK main loop free to process the initial load_uri() quickly,
1437// reducing the gap between webview_attached and load_started.
1438func (a *App) runAfterFirstLoadStarted(fn func()) {
1439 a.deferredInitFn = fn
1440}
1441
1442// triggerDeferredInit is called from content.Coordinator on first load_started.
1443// It runs deferred initialization at LOW priority so navigation continues unblocked.
1444func (a *App) triggerDeferredInit(ctx context.Context) {
1445 a.deferredInitOnce.Do(func() {
1446 if a.deferredInitFn == nil {
1447 return
1448 }
1449 log := logging.FromContext(ctx)
1450 log.Debug().Msg("triggering deferred initialization")
1451
1452 fn := a.deferredInitFn
1453 cb := glib.SourceFunc(func(_ uintptr) bool {
1454 fn()
1455 return false
1456 })
1457 // Use LOW priority so navigation processing continues first
1458 glib.IdleAddFull(glib.PRIORITY_LOW, &cb, 0, nil)
1459 })
1460}
1461
1462// onShutdown is called when the GTK application is shutting down.
1463func (a *App) onShutdown(ctx context.Context) {
1464 log := logging.FromContext(ctx)
1465 log.Debug().Msg("GTK application shutting down")
1466
1467 // Save final session state before shutdown
1468 if a.snapshotService != nil {
1469 if err := a.snapshotService.Stop(ctx); err != nil {
1470 log.Warn().Err(err).Msg("failed to save final session state")
1471 }
1472 }
1473
1474 // Apply staged update if available (before cleanup)
1475 if a.updateCoord != nil {
1476 if err := a.updateCoord.FinalizeOnExit(ctx); err != nil {
1477 log.Warn().Err(err).Msg("failed to apply staged update")
1478 }
1479 }
1480
1481 // Cancel context to signal all goroutines
1482 a.cancel(errors.New("application shutdown"))
1483
1484 // Cleanup resources
1485 if a.faviconAdapter != nil {
1486 a.faviconAdapter.Close()
1487 }
1488 tabIDSet := make(map[entity.TabID]struct{}, len(a.floatingSessions))
1489 for key := range a.floatingSessions {
1490 tabIDSet[key.tabID] = struct{}{}
1491 }
1492 tabIDs := make([]entity.TabID, 0, len(tabIDSet))
1493 for tabID := range tabIDSet {
1494 tabIDs = append(tabIDs, tabID)
1495 }
1496 for _, tabID := range tabIDs {
1497 a.releaseFloatingSessionsForTab(ctx, tabID)
1498 }
1499 if a.engine != nil {
1500 if err := a.engine.Close(); err != nil {
1501 log.Warn().Err(err).Msg("failed to close engine")
1502 }
1503 }
1504 // Close idle inhibitor to release D-Bus connection
1505 if a.deps.IdleInhibitor != nil {
1506 if err := a.deps.IdleInhibitor.Close(); err != nil {
1507 log.Warn().Err(err).Msg("failed to close idle inhibitor")
1508 }
1509 }
1510
1511 log.Info().Msg("application shutdown complete")
1512}
1513
1514// initContentCoordinator creates the content coordinator and wires its optional dependencies.
1515func (a *App) initContentCoordinator(
1516 ctx context.Context,
1517 getActiveWS func() (*entity.Workspace, *component.WorkspaceView),
1518) {
1519 a.contentCoord = content.NewCoordinator(
1520 ctx,
1521 a.engine.Pool(),
1522 a.engine.ContentInjector(),
1523 a.widgetFactory,
1524 a.faviconAdapter,
1525 getActiveWS,
1526 a.deps.ZoomUC,
1527 a.deps.PermissionUC,
1528 )
1529
1530 // Set idle inhibitor for fullscreen video playback
1531 if a.deps.IdleInhibitor != nil {
1532 a.contentCoord.SetIdleInhibitor(a.deps.IdleInhibitor)
1533 }
1534
1535 // Wire engine settings and filter appliers for hot-reload and late-binding filters
1536 if sa := a.deps.Engine.SettingsApplier(); sa != nil {
1537 a.contentCoord.SetSettingsApplier(sa)
1538 }
1539 if fa := a.deps.Engine.FilterApplier(); fa != nil {
1540 a.contentCoord.SetFilterApplier(fa)
1541 }
1542
1543 // Wire external URL launcher (e.g. xdg-open for vscode://, spotify://)
1544 if a.deps.LaunchExternalURL != nil {
1545 a.contentCoord.SetOnLaunchExternalURL(a.deps.LaunchExternalURL)
1546 }
1547
1548 // Wire deferred init trigger - runs after first navigation starts
1549 a.contentCoord.SetOnFirstLoadStarted(func() {
1550 a.triggerDeferredInit(ctx)
1551 })
1552}
1553
1554// initCoordinators initializes all coordinators and wires their callbacks.
1555func (a *App) initCoordinators(ctx context.Context) {
1556 log := logging.FromContext(ctx)
1557 log.Debug().Msg("initializing coordinators")
1558
1559 // Helper to get active workspace and view
1560 getActiveWS := func() (*entity.Workspace, *component.WorkspaceView) {
1561 return a.activeWorkspace(), a.activeWorkspaceView()
1562 }
1563
1564 // Create FaviconAdapter with service and engine FaviconDatabase.
1565 // Skip if FaviconService is nil (e.g. in tests or when favicon support is disabled).
1566 if a.deps.FaviconService != nil {
1567 a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, a.engine.FaviconDatabase(), a.deps.FaviconAdapterConfig)
1568 }
1569
1570 // 1. Content Coordinator (no dependencies on other coordinators)
1571 a.initContentCoordinator(ctx, getActiveWS)
1572
1573 // 2. Tab Coordinator
1574 a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
1575 TabsUC: a.tabsUC,
1576 Tabs: a.tabs,
1577 MainWindow: a.mainWindow,
1578 HideTabBarWhenSingleTab: a.deps.Config.Workspace.HideTabBarWhenSingleTab,
1579 })
1580 a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {
1581 a.createWorkspaceView(ctx, tab)
1582 })
1583 a.tabCoord.SetOnTabSwitched(func(ctx context.Context, tab *entity.Tab) {
1584 a.switchWorkspaceView(ctx, tab.ID)
1585 })
1586 a.tabCoord.SetOnQuit(a.Quit)
1587 a.tabCoord.SetOnStateChanged(a.MarkDirty)
1588 // Wire popup tab WebView attachment
1589 a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) {
1590 a.attachPopupToTab(ctx, tabID, pane, wv)
1591 })
1592
1593 // Wire tab bar click handling to coordinator
1594 if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
1595 a.mainWindow.TabBar().SetOnSwitch(func(tabID entity.TabID) {
1596 if err := a.tabCoord.Switch(ctx, tabID); err != nil {
1597 log.Error().Err(err).Str("tab_id", string(tabID)).Msg("tab switch failed")
1598 }
1599 })
1600 }
1601
1602 // Set fullscreen callback to hide/show tab bar (after tabCoord is initialized)
1603 a.contentCoord.SetOnFullscreenChanged(func(entering bool) {
1604 if a.mainWindow == nil || a.mainWindow.TabBar() == nil {
1605 return
1606 }
1607 if entering {
1608 // Hide tab bar when entering fullscreen video
1609 a.mainWindow.TabBar().SetVisible(false)
1610 } else {
1611 // Restore tab bar visibility based on normal logic
1612 a.tabCoord.UpdateBarVisibility(ctx)
1613 }
1614 })
1615
1616 // 3. Workspace Coordinator
1617 a.wsCoord = coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{
1618 PanesUC: a.panesUC,
1619 FocusMgr: a.focusMgr,
1620 StackedPaneMgr: a.stackedPaneMgr,
1621 WidgetFactory: a.widgetFactory,
1622 ContentCoord: a.contentCoord,
1623 GetActiveWS: getActiveWS,
1624 GenerateID: a.generateID,
1625 NewPaneURL: a.deps.Config.Workspace.NewPaneURL,
1626 ResizeStepPercent: a.deps.Config.Workspace.ResizeMode.StepPercent,
1627 ResizeMinPanePercent: a.deps.Config.Workspace.ResizeMode.MinPanePercent,
1628 })
1629 a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error {
1630 return a.tabCoord.Close(ctx)
1631 })
1632 a.wsCoord.SetOnStateChanged(a.MarkDirty)
1633
1634 // Wire popup handling
1635 // Set theme background color on the engine's popup factory to eliminate white flash.
1636 if a.engine != nil && a.deps.Theme != nil {
1637 r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
1638 _ = a.engine.UpdateAppearance(ctx, float64(r), float64(g), float64(b), float64(alpha))
1639 }
1640 a.contentCoord.SetPopupConfig(
1641 a.engine.Factory(),
1642 &a.deps.Config.Workspace.Popups,
1643 a.generateID,
1644 )
1645 a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input content.InsertPopupInput) error {
1646 return a.wsCoord.InsertPopup(ctx, input)
1647 })
1648 a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error {
1649 return a.wsCoord.ClosePaneByID(ctx, paneID)
1650 })
1651 // Wire tabbed popup behavior to create new tabs
1652 a.wsCoord.SetOnCreatePopupTab(func(ctx context.Context, input content.InsertPopupInput) error {
1653 // Create a new tab with the popup pane
1654 tab, err := a.tabCoord.CreateWithPane(ctx, input.PopupPane, input.WebView, input.TargetURI)
1655 if err != nil {
1656 return err
1657 }
1658 log.Debug().Str("tab_id", string(tab.ID)).Msg("created tab for popup")
1659 return nil
1660 })
1661
1662 // Move pane use case (cross-tab)
1663 a.movePaneToTabUC = usecase.NewMovePaneToTabUseCase(a.generateID)
1664
1665 // 4. Navigation Coordinator
1666 a.navCoord = coordinator.NewNavigationCoordinator(
1667 ctx,
1668 a.deps.NavigateUC,
1669 a.contentCoord,
1670 )
1671 a.wsCoord.SetOnPaneClosed(func(paneID entity.PaneID) {
1672 a.navCoord.ClearPaneHistory(paneID)
1673 })
1674
1675 // Wire title updates to history persistence
1676 a.contentCoord.SetOnTitleUpdated(func(ctx context.Context, paneID entity.PaneID, url, title string) {
1677 a.navCoord.UpdateHistoryTitle(ctx, paneID, url, title)
1678 })
1679
1680 // Wire history recording on LoadCommitted (URI is guaranteed correct at this point)
1681 a.contentCoord.SetOnHistoryRecord(func(ctx context.Context, paneID entity.PaneID, url string) {
1682 a.navCoord.RecordHistory(ctx, paneID, url)
1683 })
1684
1685 // Wire window title updates when active pane's title changes
1686 a.contentCoord.SetOnWindowTitleChanged(func(title string) {
1687 a.updateWindowTitle(title)
1688 })
1689
1690 // Wire pane URI updates for session snapshots (searches all tabs)
1691 a.contentCoord.SetOnPaneURIUpdated(func(paneID entity.PaneID, url string) {
1692 a.updatePaneURIInAllTabs(paneID, url)
1693 a.updateFloatingSessionURI(paneID, url)
1694 // Mark dirty so snapshot captures the new URI
1695 a.MarkDirty()
1696 })
1697
1698 // Hide loading skeleton once the WebView paints
1699 a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {
1700 // The WebView can be shown while its pane is in a background tab, so scan
1701 // all WorkspaceViews rather than only the active one.
1702 for _, wsView := range a.workspaceViews {
1703 if wsView == nil {
1704 continue
1705 }
1706 if pv := wsView.GetPaneView(paneID); pv != nil {
1707 pv.HideLoadingSkeleton()
1708 }
1709 }
1710 if a.deps != nil && a.deps.OnFirstWebViewShown != nil {
1711 a.firstWebViewShownOnce.Do(func() {
1712 a.deps.OnFirstWebViewShown(ctx)
1713 })
1714 }
1715
1716 // Set the WebView as the focused input for accent picker (if this is the active pane)
1717 ws := a.activeWorkspace()
1718 if ws != nil && ws.ActivePaneID == paneID && a.accentFocusProvider != nil {
1719 a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
1720 }
1721 })
1722
1723 // 5. Keyboard Dispatcher
1724 a.kbDispatcher = dispatcher.NewKeyboardDispatcher(
1725 ctx,
1726 a.tabCoord,
1727 a.wsCoord,
1728 a.navCoord,
1729 a.deps.ZoomUC,
1730 a.deps.CopyURLUC,
1731 a.deps.Config.Workspace.NewPaneURL,
1732 )
1733 a.wireKeyboardActions()
1734
1735 log.Debug().Msg("coordinators initialized")
1736}
1737
1738func (a *App) wireKeyboardActions() {
1739 a.kbDispatcher.SetOnQuit(a.Quit)
1740 a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {
1741 a.ToggleFindBar(ctx)
1742 return nil
1743 })
1744 a.kbDispatcher.SetOnFindNext(func(ctx context.Context) error {
1745 a.FindNext(ctx)
1746 return nil
1747 })
1748 a.kbDispatcher.SetOnFindPrev(func(ctx context.Context) error {
1749 a.FindPrevious(ctx)
1750 return nil
1751 })
1752 a.kbDispatcher.SetOnFindClose(func(ctx context.Context) error {
1753 a.CloseFindBar(ctx)
1754 return nil
1755 })
1756 a.kbDispatcher.SetOnMovePaneToTab(func(ctx context.Context) error {
1757 return a.HandleMovePaneToTab(ctx)
1758 })
1759 a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context) error {
1760 return a.HandleMovePaneToNextTab(ctx)
1761 })
1762 a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error {
1763 return a.ToggleFloatingPane(ctx)
1764 })
1765 a.kbDispatcher.SetOnOpenFloatingTarget(func(ctx context.Context, target input.FloatingProfileTarget) error {
1766 return a.OpenFloatingPaneProfileURL(ctx, target.SessionID, target.URL)
1767 })
1768
1769 // Wire gesture handler to dispatcher (for mouse button 8/9 navigation)
1770 a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error {
1771 return a.kbDispatcher.Dispatch(ctx, action)
1772 })
1773}
1774
1775func (a *App) wireWebRTCPermissionIndicator() {
1776 if a.webrtcIndicator == nil || a.contentCoord == nil {
1777 return
1778 }
1779 ctx := context.Background()
1780 if a.deps != nil && a.deps.Ctx != nil {
1781 ctx = a.deps.Ctx
1782 }
1783 log := logging.FromContext(ctx)
1784
1785 a.contentCoord.SetOnPermissionActivity(func(origin string, permTypes []entity.PermissionType, state content.PermissionActivityState) {
1786 a.webrtcIndicator.SetOrigin(origin)
1787
1788 switch state {
1789 case content.PermissionActivityRequesting:
1790 a.webrtcIndicator.MarkRequesting(permTypes)
1791 case content.PermissionActivityAllowed:
1792 a.webrtcIndicator.MarkAllowed(permTypes)
1793 case content.PermissionActivityBlocked:
1794 a.webrtcIndicator.MarkBlocked(permTypes)
1795 }
1796
1797 for _, permType := range permTypes {
1798 a.syncWebRTCPermissionLockState(ctx, origin, permType)
1799 }
1800 })
1801
1802 a.webrtcIndicator.SetOnToggleLock(func(origin string, permType entity.PermissionType, state string, hasStored bool) {
1803 if a.deps == nil || a.deps.PermissionUC == nil || origin == "" {
1804 return
1805 }
1806
1807 if hasStored {
1808 // Already locked: reset to prompt (will re-ask on next request).
1809 if err := a.deps.PermissionUC.ResetManualPermissionDecision(ctx, origin, permType); err != nil {
1810 log.Warn().Err(err).Str("origin", origin).Str("type", string(permType)).Msg("failed to reset manual permission decision")
1811 return
1812 }
1813 } else {
1814 // Not locked: flip the state.
1815 // Allowed/requesting → deny future requests.
1816 // Blocked → allow future requests.
1817 decision := entity.PermissionDenied
1818 if state == string(content.PermissionActivityBlocked) {
1819 decision = entity.PermissionGranted
1820 }
1821 if err := a.deps.PermissionUC.SetManualPermissionDecision(ctx, origin, permType, decision); err != nil {
1822 log.Warn().Err(err).Str("origin", origin).Str("type", string(permType)).Msg("failed to set manual permission decision")
1823 return
1824 }
1825 }
1826
1827 a.syncWebRTCPermissionLockState(ctx, origin, permType)
1828 })
1829
1830 // Reset indicator when the active pane navigates away from the current origin.
1831 a.contentCoord.SetOnActiveNavigationCommitted(func(uri string) {
1832 newOrigin, err := urlutil.ExtractOrigin(uri)
1833 if err != nil {
1834 // Internal pages (dumb://, about:) — clear the indicator.
1835 a.webrtcIndicator.Reset()
1836 return
1837 }
1838
1839 currentOrigin := a.webrtcIndicator.Origin()
1840 if currentOrigin != "" && currentOrigin != newOrigin {
1841 a.webrtcIndicator.Reset()
1842 }
1843 })
1844}
1845
1846func (a *App) syncWebRTCPermissionLockState(ctx context.Context, origin string, permType entity.PermissionType) {
1847 if a.webrtcIndicator == nil || a.deps == nil || a.deps.PermissionUC == nil || origin == "" {
1848 return
1849 }
1850
1851 record, err := a.deps.PermissionUC.GetManualPermissionDecision(ctx, origin, permType)
1852 if err != nil {
1853 logging.FromContext(ctx).Warn().
1854 Err(err).
1855 Str("origin", origin).
1856 Str("type", string(permType)).
1857 Msg("failed to read manual permission decision")
1858 return
1859 }
1860
1861 if record == nil {
1862 a.webrtcIndicator.SetStoredDecision(permType, entity.PermissionPrompt, false)
1863 return
1864 }
1865
1866 a.webrtcIndicator.SetStoredDecision(permType, record.Decision, true)
1867}
1868
1869// generateID generates a unique ID for tabs and panes.
1870// Uses monotonically increasing counter to avoid ID collisions.
1871func (a *App) generateID() string {
1872 a.idMu.Lock()
1873 defer a.idMu.Unlock()
1874 a.idCounter++
1875 return fmt.Sprintf("p%d", a.idCounter)
1876}
1877
1878// Tabs returns the tab list.
1879func (a *App) Tabs() *entity.TabList {
1880 return a.tabs
1881}
1882
1883// MainWindow returns the main window.
1884func (a *App) MainWindow() *window.MainWindow {
1885 return a.mainWindow
1886}
1887
1888// Quit requests the application to quit.
1889func (a *App) Quit() {
1890 if a.gtkApp != nil {
1891 a.gtkApp.Quit()
1892 }
1893}
1894
1895// RunWithArgs is a convenience function that creates and runs an App.
1896func RunWithArgs(ctx context.Context, deps *Dependencies) int {
1897 app, err := New(deps)
1898 if err != nil {
1899 log := logging.FromContext(ctx)
1900 log.Error().Err(err).Msg("failed to create application")
1901 return 1
1902 }
1903 return app.Run(ctx, os.Args)
1904}
1905
1906// updateWindowTitle updates the window title with the given page title.
1907// Format: "<Page Title> - Dumber" or just "Dumber" if title is empty.
1908func (a *App) updateWindowTitle(pageTitle string) {
1909 if a.mainWindow == nil {
1910 return
1911 }
1912
1913 title := "Dumber"
1914 if pageTitle != "" {
1915 title = pageTitle + " - Dumber"
1916 }
1917 a.mainWindow.SetTitle(title)
1918}
1919
1920// updateWindowTitleFromActivePane updates the window title based on the current active pane.
1921func (a *App) updateWindowTitleFromActivePane() {
1922 ws := a.activeWorkspace()
1923 if ws == nil || a.contentCoord == nil {
1924 a.updateWindowTitle("")
1925 return
1926 }
1927 title := a.contentCoord.GetTitle(ws.ActivePaneID)
1928 a.updateWindowTitle(title)
1929}
1930
1931// handleModeChange is called when the input mode changes.
1932func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
1933 log := logging.FromContext(ctx)
1934 log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")
1935
1936 if from == input.ModeResize && to != input.ModeResize {
1937 a.clearResizeModeBorder()
1938 }
1939 if to == input.ModeResize {
1940 a.applyResizeModeBorder(ctx, a.activeWorkspace())
1941 }
1942
1943 // Update global border overlay visibility based on mode.
1944 // Note: resize mode border is handled per-pane (stack container), not via global overlay.
1945 if a.borderMgr != nil {
1946 a.borderMgr.OnModeChange(ctx, from, to)
1947 }
1948
1949 // Show/hide mode indicator toaster based on config.
1950 a.updateModeIndicatorToaster(ctx, to)
1951}
1952
1953// updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config.
1954func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) {
1955 if a.modeToaster == nil {
1956 return
1957 }
1958
1959 // Check if mode indicator toaster is enabled in config.
1960 if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.Styling.ModeIndicatorToasterEnabled {
1961 a.modeToaster.Hide()
1962 return
1963 }
1964
1965 if mode == input.ModeNormal {
1966 a.modeToaster.Hide()
1967 return
1968 }
1969
1970 // Show persistent toaster at bottom-left with mode display name.
1971 // Mode class is applied atomically with Show() to avoid visual flicker.
1972 modeClass := getModeToastClass(mode)
1973 a.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
1974 component.WithDuration(0), // Persistent until mode exits.
1975 component.WithPosition(component.ToastPositionBottomLeft),
1976 component.WithModeClass(modeClass),
1977 )
1978}
1979
1980// getModeToastClass returns the CSS class for the given mode's toast styling.
1981func getModeToastClass(mode input.Mode) string {
1982 switch mode {
1983 case input.ModePane:
1984 return "toast-pane-mode"
1985 case input.ModeTab:
1986 return "toast-tab-mode"
1987 case input.ModeSession:
1988 return "toast-session-mode"
1989 case input.ModeResize:
1990 return "toast-resize-mode"
1991 default:
1992 return ""
1993 }
1994}
1995
1996func (a *App) clearResizeModeBorder() {
1997 if a.resizeModeBorderTarget != nil {
1998 a.resizeModeBorderTarget.RemoveCssClass("resize-mode-active")
1999 a.resizeModeBorderTarget = nil
2000 }
2001}
2002
2003func (a *App) applyResizeModeBorder(ctx context.Context, ws *entity.Workspace) {
2004 wsView := a.activeWorkspaceView()
2005 if wsView == nil || ws == nil {
2006 a.clearResizeModeBorder()
2007 return
2008 }
2009
2010 paneID := ws.ActivePaneID
2011 if paneID == "" {
2012 a.clearResizeModeBorder()
2013 return
2014 }
2015
2016 // Important: wrap the whole stack container when the active pane is in a stack.
2017 target := wsView.GetStackContainerWidget(paneID)
2018 if target == nil {
2019 a.clearResizeModeBorder()
2020 return
2021 }
2022
2023 if !target.HasCssClass("resize-mode-active") {
2024 target.AddCssClass("resize-mode-active")
2025 }
2026
2027 if a.resizeModeBorderTarget != target {
2028 if a.resizeModeBorderTarget != nil {
2029 a.resizeModeBorderTarget.RemoveCssClass("resize-mode-active")
2030 } else {
2031 logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("resize mode border attached")
2032 }
2033 a.resizeModeBorderTarget = target
2034 }
2035}
2036
2037// createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.
2038func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
2039 a.createWorkspaceViewWithoutAttach(ctx, tab)
2040
2041 // Attach to content area
2042 wsView := a.workspaceViews[tab.ID]
2043 if wsView != nil && a.mainWindow != nil {
2044 widget := wsView.Widget()
2045 if widget != nil {
2046 gtkWidget := widget.GtkWidget()
2047 if gtkWidget != nil {
2048 a.mainWindow.SetContent(gtkWidget)
2049 }
2050 }
2051 }
2052}
2053
2054// createWorkspaceViewWithoutAttach creates a WorkspaceView for a tab without attaching to content area.
2055// Used during session restoration where we create all views first, then attach only the active one.
2056func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.Tab) {
2057 log := logging.FromContext(ctx)
2058
2059 if a.widgetFactory == nil {
2060 log.Error().Msg("widget factory not initialized")
2061 return
2062 }
2063
2064 // Create workspace view
2065 wsView := component.NewWorkspaceView(ctx, a.widgetFactory)
2066 if wsView == nil {
2067 log.Error().Msg("failed to create workspace view")
2068 return
2069 }
2070 a.installFloatingOverlayPositioning(tab.ID, wsView.WorkspaceOverlayWidget())
2071
2072 // Set the workspace
2073 if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil {
2074 log.Error().Err(err).Msg("failed to set workspace in view")
2075 return
2076 }
2077
2078 // Ensure WebViews are attached to panes
2079 if a.contentCoord != nil {
2080 a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
2081 }
2082
2083 // Note: Mode borders for tab/pane/session are attached to MainWindow.
2084 // Resize mode border is attached to the active pane's stack container.
2085
2086 // Set omnibox config for this workspace view
2087 wsView.SetOmniboxConfig(a.omniboxCfg)
2088 // Set find bar config for this workspace view
2089 wsView.SetFindBarConfig(a.findBarCfg)
2090 // Set auto-open omnibox on new pane
2091 if a.deps.Config != nil {
2092 wsView.SetAutoOpenOnNewPane(a.deps.Config.Omnibox.AutoOpenOnNewPane)
2093 }
2094
2095 wsView.SetOnPaneFocused(func(paneID entity.PaneID) {
2096 if a.keyboardHandler != nil && a.keyboardHandler.Mode() == input.ModeResize {
2097 ws := a.activeWorkspace()
2098 if ws != nil {
2099 ws.ActivePaneID = paneID
2100 }
2101 a.applyResizeModeBorder(ctx, ws)
2102 }
2103 })
2104
2105 wsView.SetOnSplitRatioDragged(func(nodeID string, ratio float64) {
2106 if a.wsCoord != nil {
2107 _ = a.wsCoord.SetSplitRatio(ctx, nodeID, ratio)
2108 }
2109 })
2110
2111 // Store in map
2112 a.workspaceViews[tab.ID] = wsView
2113 a.reattachFloatingSessions(tab.ID, wsView)
2114 a.syncFloatingFocus()
2115
2116 log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created")
2117}
2118
2119// activeWorkspace returns the workspace of the active tab.
2120func (a *App) activeWorkspace() *entity.Workspace {
2121 activeTab := a.tabs.ActiveTab()
2122 if activeTab == nil {
2123 return nil
2124 }
2125 return activeTab.Workspace
2126}
2127
2128// updatePaneURIInAllTabs finds a pane by ID across all tabs and updates its URI.
2129// This is necessary because panes in inactive tabs also need URI updates for session snapshots.
2130func (a *App) updatePaneURIInAllTabs(paneID entity.PaneID, url string) {
2131 for _, tab := range a.tabs.Tabs {
2132 if tab.Workspace == nil {
2133 continue
2134 }
2135 paneNode := tab.Workspace.FindPane(paneID)
2136 if paneNode != nil && paneNode.Pane != nil {
2137 paneNode.Pane.URI = url
2138 return // Pane IDs are unique, no need to continue
2139 }
2140 }
2141}
2142
2143// activeWorkspaceView returns the workspace view for the active tab.
2144func (a *App) activeWorkspaceView() *component.WorkspaceView {
2145 activeTab := a.tabs.ActiveTab()
2146 if activeTab == nil {
2147 return nil
2148 }
2149 return a.workspaceViews[activeTab.ID]
2150}
2151
2152// getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView.
2153// Used by the accent picker to insert accented characters into web content.
2154func (a *App) getActiveWebViewTarget() port.TextInputTarget {
2155 if a.contentCoord == nil {
2156 return nil
2157 }
2158
2159 wv := a.contentCoord.ActiveWebView(context.Background())
2160 if wv == nil {
2161 return nil
2162 }
2163
2164 if provider, ok := wv.(port.TextInputTargetProvider); ok {
2165 return provider.TextInputTarget()
2166 }
2167 return nil
2168}
2169
2170// attachPopupToTab attaches a popup WebView to a newly created tab.
2171// This is called when a popup uses tabbed behavior.
2172func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) {
2173 log := logging.FromContext(ctx)
2174
2175 wsView := a.workspaceViews[tabID]
2176 if wsView == nil {
2177 log.Warn().Str("tab_id", string(tabID)).Msg("workspace view not found for popup tab")
2178 return
2179 }
2180
2181 // Register WebView with content coordinator
2182 if a.contentCoord != nil {
2183 // Track the WebView
2184 a.contentCoord.RegisterPopupWebView(pane.ID, wv)
2185
2186 // Wrap and attach widget
2187 widget := a.contentCoord.WrapWidget(ctx, wv)
2188 if widget != nil {
2189 paneView := wsView.GetPaneView(pane.ID)
2190 if paneView != nil {
2191 paneView.SetWebViewWidget(widget)
2192 } else {
2193 log.Warn().Str("pane_id", string(pane.ID)).Msg("pane view not found for popup")
2194 }
2195 }
2196 }
2197 log.Debug().
2198 Str("tab_id", string(tabID)).
2199 Str("pane_id", string(pane.ID)).
2200 Msg("popup webview attached to tab")
2201}
2202
2203// switchWorkspaceView swaps the displayed workspace view for a tab.
2204func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
2205 log := logging.FromContext(ctx)
2206
2207 wsView, exists := a.workspaceViews[tabID]
2208 if !exists {
2209 log.Warn().Str("tab_id", string(tabID)).Msg("no workspace view for tab")
2210 return
2211 }
2212
2213 // Get the workspace view's root widget
2214 widget := wsView.Widget()
2215 if widget == nil {
2216 log.Warn().Str("tab_id", string(tabID)).Msg("workspace view has no widget")
2217 return
2218 }
2219
2220 gtkWidget := widget.GtkWidget()
2221 if gtkWidget == nil {
2222 log.Warn().Str("tab_id", string(tabID)).Msg("workspace view widget has no GTK widget")
2223 return
2224 }
2225
2226 // Swap content (MainWindow.SetContent now properly removes old content)
2227 if a.mainWindow != nil {
2228 a.mainWindow.SetContent(gtkWidget)
2229 }
2230
2231 // Update window title with the new active pane's title
2232 a.updateWindowTitleFromActivePane()
2233 a.syncFloatingFocus()
2234
2235 log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched")
2236}
2237
2238func normalizeFloatingSessionID(sessionID string) string {
2239 sessionID = strings.TrimSpace(sessionID)
2240 if sessionID == "" {
2241 return floatingSessionIDDefault
2242 }
2243 return sessionID
2244}
2245
2246func floatingPaneIDForSession(tabID entity.TabID, sessionID string) entity.PaneID {
2247 return entity.PaneID(floatingPaneIDPrefix + string(tabID) + ":" + normalizeFloatingSessionID(sessionID))
2248}
2249
2250func floatingSessionMapKey(tabID entity.TabID, sessionID string) floatingSessionKey {
2251 return floatingSessionKey{tabID: tabID, sessionID: normalizeFloatingSessionID(sessionID)}
2252}
2253
2254func decorateFloatingOverlay(overlay layout.OverlayWidget) {
2255 if overlay == nil {
2256 return
2257 }
2258
2259 overlay.SetHexpand(false)
2260 overlay.SetVexpand(false)
2261 overlay.AddCssClass("floating-pane-container")
2262 overlay.AddCssClass("pane-border")
2263 overlay.AddCssClass("pane-active")
2264}
2265
2266// showFloatingWidget makes a floating pane widget interactive and visible.
2267// Visibility is controlled by CSS class to animate opacity while keeping the
2268// widget mapped so WebKit can redraw without a hard pop.
2269func showFloatingWidget(w layout.Widget) {
2270 if w == nil {
2271 return
2272 }
2273 w.AddCssClass(floatingPaneVisibleClass)
2274 w.SetCanTarget(true)
2275 w.SetCanFocus(true)
2276}
2277
2278// hideFloatingWidget makes a floating pane widget invisible and non-interactive.
2279// Removes the visible CSS class instead of SetVisible(false) so GTK keeps the
2280// widget mapped and WebKit retains its rendered surface in GPU memory.
2281func hideFloatingWidget(w layout.Widget) {
2282 if w == nil {
2283 return
2284 }
2285 w.RemoveCssClass(floatingPaneVisibleClass)
2286 w.SetCanTarget(false)
2287 w.SetCanFocus(false)
2288}
2289
2290// setFloatingWidgetShown applies show or hide based on the visible flag.
2291func setFloatingWidgetShown(w layout.Widget, visible bool) {
2292 if visible {
2293 showFloatingWidget(w)
2294 } else {
2295 hideFloatingWidget(w)
2296 }
2297}
2298
2299func configureFloatingOverlayMeasurement(workspaceOverlay layout.OverlayWidget, floatingOverlay layout.Widget) {
2300 if workspaceOverlay == nil || floatingOverlay == nil {
2301 return
2302 }
2303
2304 workspaceOverlay.SetMeasureOverlay(floatingOverlay, false)
2305 workspaceOverlay.SetClipOverlay(floatingOverlay, false)
2306}
2307
2308func floatingAllocationRect(overlayWidth, overlayHeight, desiredWidth, desiredHeight int) (x, y, width, height int, ok bool) {
2309 if overlayWidth <= 0 || overlayHeight <= 0 || desiredWidth <= 0 || desiredHeight <= 0 {
2310 return 0, 0, 0, 0, false
2311 }
2312
2313 width = desiredWidth
2314 height = desiredHeight
2315 if width > overlayWidth {
2316 width = overlayWidth
2317 }
2318 if height > overlayHeight {
2319 height = overlayHeight
2320 }
2321
2322 x = (overlayWidth - width) / 2
2323 y = (overlayHeight - height) / 2
2324 if x < 0 {
2325 x = 0
2326 }
2327 if y < 0 {
2328 y = 0
2329 }
2330
2331 return x, y, width, height, true
2332}
2333
2334type gtkAllocationLayout [4]int32
2335
2336const gtkAllocationLayoutSize = 16
2337
2338var (
2339 _ [gtkAllocationLayoutSize - int(unsafe.Sizeof(gtkAllocationLayout{}))]struct{}
2340 _ [int(unsafe.Sizeof(gtkAllocationLayout{})) - gtkAllocationLayoutSize]struct{}
2341)
2342
2343// writeOverlayAllocation writes GTK-owned GtkAllocation fields in place.
2344// Assumes GTK4 C layout for GdkRectangle/GtkAllocation: four contiguous
2345// 32-bit signed integers {x, y, width, height} in native endianness.
2346// If GTK struct layout changes, revisit offsets and writes below.
2347//
2348//nolint:gosec // Intentional unsafe pointer math against GTK-owned allocation memory.
2349func writeOverlayAllocation(allocationPtr *uintptr, x, y, width, height int) bool {
2350 if allocationPtr == nil {
2351 return false
2352 }
2353
2354 const (
2355 gtkAllocationOffsetX = uintptr(0)
2356 gtkAllocationOffsetY = uintptr(4)
2357 gtkAllocationOffsetWidth = uintptr(8)
2358 gtkAllocationOffsetHeight = uintptr(12)
2359 )
2360
2361 // GtkAllocation/GdkRectangle is four 32-bit signed integers in C.
2362 base := unsafe.Pointer(allocationPtr)
2363 *(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetX)) = int32(x)
2364 *(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetY)) = int32(y)
2365 *(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetWidth)) = int32(width)
2366 *(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetHeight)) = int32(height)
2367 return true
2368}
2369
2370func (a *App) floatingAllocationForWidget(
2371 tabID entity.TabID,
2372 widgetPtr uintptr,
2373 overlayWidth, overlayHeight int,
2374) (x, y, width, height int, ok bool) {
2375 for key, session := range a.floatingSessions {
2376 if key.tabID != tabID || session == nil || session.widget == nil {
2377 continue
2378 }
2379
2380 gtkWidget := session.widget.GtkWidget()
2381 if gtkWidget == nil || gtkWidget.GoPointer() != widgetPtr {
2382 continue
2383 }
2384
2385 desiredWidth := session.appliedWidth
2386 desiredHeight := session.appliedHeight
2387 if (desiredWidth <= 0 || desiredHeight <= 0) && session.pane != nil {
2388 session.pane.Resize()
2389 desiredWidth, desiredHeight = session.pane.Dimensions()
2390 }
2391
2392 return floatingAllocationRect(overlayWidth, overlayHeight, desiredWidth, desiredHeight)
2393 }
2394
2395 return 0, 0, 0, 0, false
2396}
2397
2398func (a *App) installFloatingOverlayPositioning(tabID entity.TabID, workspaceOverlay layout.OverlayWidget) {
2399 if workspaceOverlay == nil {
2400 return
2401 }
2402
2403 workspaceWidget := workspaceOverlay.GtkWidget()
2404 if workspaceWidget == nil {
2405 return
2406 }
2407
2408 gtkOverlay := gtk.OverlayNewFromInternalPtr(workspaceWidget.GoPointer())
2409 if gtkOverlay == nil {
2410 return
2411 }
2412
2413 cb := func(_ gtk.Overlay, widgetPtr uintptr, allocationPtr *uintptr) bool {
2414 overlayWidth := workspaceOverlay.GetAllocatedWidth()
2415 overlayHeight := workspaceOverlay.GetAllocatedHeight()
2416 x, y, width, height, ok := a.floatingAllocationForWidget(tabID, widgetPtr, overlayWidth, overlayHeight)
2417 if !ok {
2418 return false
2419 }
2420 return writeOverlayAllocation(allocationPtr, x, y, width, height)
2421 }
2422
2423 gtkOverlay.ConnectGetChildPosition(&cb)
2424}
2425
2426// currentFloatingWidthPct returns the configured floating pane width percentage.
2427func (a *App) currentFloatingWidthPct() float64 {
2428 if a.deps != nil && a.deps.Config != nil {
2429 return a.deps.Config.Workspace.FloatingPane.WidthPct
2430 }
2431 return 0
2432}
2433
2434// currentFloatingHeightPct returns the configured floating pane height percentage.
2435func (a *App) currentFloatingHeightPct() float64 {
2436 if a.deps != nil && a.deps.Config != nil {
2437 return a.deps.Config.Workspace.FloatingPane.HeightPct
2438 }
2439 return 0
2440}
2441
2442func (a *App) ensureFloatingSession(
2443 ctx context.Context,
2444 tabID entity.TabID,
2445 sessionID string,
2446 wsView *component.WorkspaceView,
2447) (*floatingWorkspaceSession, error) {
2448 key := floatingSessionMapKey(tabID, sessionID)
2449 if session, ok := a.floatingSessions[key]; ok && session != nil {
2450 if wsView == nil {
2451 return session, nil
2452 }
2453 session.pane.SetParentOverlay(wsView.WorkspaceOverlayWidget())
2454 if session.widget != nil {
2455 wsView.AddWorkspaceOverlayWidget(session.widget)
2456 configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), session.widget)
2457 }
2458 if session.paneView != nil {
2459 wsView.RegisterPaneView(session.paneID, session.paneView)
2460 if session.focusWidget == nil {
2461 session.focusWidget = session.paneView.WebViewWidget()
2462 }
2463 }
2464 return session, nil
2465 }
2466
2467 if wsView == nil {
2468 return nil, fmt.Errorf("workspace view not found for tab %s", tabID)
2469 }
2470 if a.contentCoord == nil {
2471 return nil, fmt.Errorf("content coordinator not initialized")
2472 }
2473 if a.widgetFactory == nil {
2474 return nil, fmt.Errorf("widget factory not initialized")
2475 }
2476
2477 paneID := floatingPaneIDForSession(tabID, sessionID)
2478 wv, err := a.contentCoord.EnsureWebView(ctx, paneID)
2479 if err != nil {
2480 return nil, fmt.Errorf("ensure floating webview: %w", err)
2481 }
2482
2483 webViewWidget := a.contentCoord.WrapWidget(ctx, wv)
2484 if webViewWidget == nil {
2485 return nil, fmt.Errorf("wrap floating webview widget")
2486 }
2487 webViewWidget.SetHexpand(true)
2488 webViewWidget.SetVexpand(true)
2489
2490 // Wrap the WebView in a PaneView so it gets progress bar, loading
2491 // skeleton, and link status overlays — the same widget hierarchy as
2492 // regular workspace panes. This allows the content coordinator's
2493 // existing callbacks (onLoadStarted, onProgressChanged, etc.) to find
2494 // and update the floating pane automatically.
2495 pv := component.NewPaneView(a.widgetFactory, paneID, webViewWidget)
2496 pv.SetActive(true)
2497 // Hide loading skeleton — floating panes have their own themed container.
2498 pv.HideLoadingSkeleton()
2499 wsView.RegisterPaneView(paneID, pv)
2500
2501 // Use the PaneView's overlay directly as the floating overlay.
2502 // This avoids nesting two overlays which causes GTK4 allocation
2503 // issues (inner overlay expanding to fill the parent's allocation).
2504 pvOverlay := pv.Overlay()
2505 decorateFloatingOverlay(pvOverlay)
2506 pvOverlay.SetHalign(gtk.AlignCenterValue)
2507 pvOverlay.SetValign(gtk.AlignCenterValue)
2508 hideFloatingWidget(pvOverlay)
2509 wsView.AddWorkspaceOverlayWidget(pvOverlay)
2510 configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), pvOverlay)
2511
2512 floatingPane := component.NewFloatingPane(wsView.WorkspaceOverlayWidget(), component.FloatingPaneOptions{
2513 WidthPct: a.currentFloatingWidthPct(),
2514 HeightPct: a.currentFloatingHeightPct(),
2515 FallbackWidth: floatingPaneFallbackWidth,
2516 FallbackHeight: floatingPaneFallbackHeight,
2517 OnNavigate: func(navCtx context.Context, url string) error {
2518 return wv.LoadURI(navCtx, url)
2519 },
2520 })
2521
2522 session := &floatingWorkspaceSession{
2523 paneID: paneID,
2524 pane: floatingPane,
2525 paneView: pv,
2526 webView: wv,
2527 overlay: pvOverlay,
2528 widget: pvOverlay,
2529 focusWidget: webViewWidget,
2530 }
2531 a.floatingSessions[key] = session
2532 return session, nil
2533}
2534
2535func (a *App) floatingSessionsForTab(tabID entity.TabID) map[floatingSessionKey]*floatingWorkspaceSession {
2536 sessions := make(map[floatingSessionKey]*floatingWorkspaceSession)
2537 for key, session := range a.floatingSessions {
2538 if key.tabID != tabID || session == nil {
2539 continue
2540 }
2541 sessions[key] = session
2542 }
2543 return sessions
2544}
2545
2546func (a *App) releaseFloatingSessionsForTab(ctx context.Context, tabID entity.TabID) {
2547 sessions := a.floatingSessionsForTab(tabID)
2548 if len(sessions) == 0 {
2549 return
2550 }
2551
2552 for key, session := range sessions {
2553 a.releaseFloatingSession(ctx, key, session)
2554 }
2555 a.syncFloatingFocus()
2556}
2557
2558func (a *App) activeFloatingSessionEntry() (floatingSessionKey, *floatingWorkspaceSession, bool) {
2559 activeTab := a.tabs.ActiveTab()
2560 if activeTab == nil {
2561 return floatingSessionKey{}, nil, false
2562 }
2563 for key, session := range a.floatingSessions {
2564 if key.tabID != activeTab.ID || session == nil || session.pane == nil {
2565 continue
2566 }
2567 if session.pane.IsVisible() {
2568 return key, session, true
2569 }
2570 }
2571 return floatingSessionKey{tabID: activeTab.ID}, nil, false
2572}
2573
2574func (a *App) activeFloatingSession() (*floatingWorkspaceSession, entity.TabID) {
2575 key, session, ok := a.activeFloatingSessionEntry()
2576 if !ok {
2577 return nil, key.tabID
2578 }
2579 return session, key.tabID
2580}
2581
2582func (a *App) releaseFloatingSession(ctx context.Context, key floatingSessionKey, session *floatingWorkspaceSession) {
2583 if session == nil {
2584 return
2585 }
2586
2587 a.stopFloatingResizeWatcher(session)
2588 if session.pane != nil {
2589 session.pane.Hide(ctx)
2590 }
2591 a.hideFloatingOmnibox(ctx, session)
2592 if wsView := a.workspaceViews[key.tabID]; wsView != nil {
2593 if session.widget != nil {
2594 wsView.RemoveWorkspaceOverlayWidget(session.widget)
2595 }
2596 if session.paneID != "" {
2597 wsView.UnregisterPaneView(session.paneID)
2598 }
2599 }
2600 if a.contentCoord != nil && session.paneID != "" {
2601 a.contentCoord.ReleaseWebView(ctx, session.paneID)
2602 }
2603 delete(a.floatingSessions, key)
2604
2605 session.appliedWidth = 0
2606 session.appliedHeight = 0
2607 session.focusWidget = nil
2608 session.paneView = nil
2609 session.widget = nil
2610 session.overlay = nil
2611 session.webView = nil
2612 session.pane = nil
2613 // Session is fully released/zeroed; caller must drop this reference and not reuse it.
2614}
2615
2616func (a *App) updateFloatingSessionURI(paneID entity.PaneID, url string) {
2617 if paneID == "" {
2618 return
2619 }
2620
2621 for _, session := range a.floatingSessions {
2622 if session == nil || session.pane == nil {
2623 continue
2624 }
2625 if session.paneID != paneID {
2626 continue
2627 }
2628 session.pane.RecordLoadedURL(url)
2629 return
2630 }
2631}
2632
2633func (a *App) syncFloatingFocus() {
2634 for _, wsView := range a.workspaceViews {
2635 if wsView != nil {
2636 wsView.SetHoverFocusLocked(false)
2637 }
2638 }
2639
2640 if a.contentCoord == nil {
2641 return
2642 }
2643
2644 activeTab := a.tabs.ActiveTab()
2645 if activeTab == nil {
2646 a.contentCoord.ClearActivePaneOverride()
2647 return
2648 }
2649
2650 if session, _ := a.activeFloatingSession(); session != nil {
2651 if wsView := a.workspaceViews[activeTab.ID]; wsView != nil {
2652 wsView.SetHoverFocusLocked(true)
2653 }
2654 a.startFloatingResizeWatcher(session)
2655 a.contentCoord.SetActivePaneOverride(session.paneID)
2656 return
2657 }
2658
2659 a.contentCoord.ClearActivePaneOverride()
2660}
2661
2662func (a *App) showFloatingOmnibox(ctx context.Context, session *floatingWorkspaceSession) {
2663 if session == nil || session.overlay == nil || a.widgetFactory == nil {
2664 return
2665 }
2666
2667 if session.omnibox == nil {
2668 cfg := a.omniboxCfg
2669 cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) {
2670 if a.appToaster != nil {
2671 a.appToaster.Show(toastCtx, message, level)
2672 }
2673 }
2674
2675 omnibox := component.NewOmnibox(ctx, cfg)
2676 if omnibox == nil {
2677 return
2678 }
2679
2680 omnibox.SetParentOverlay(session.overlay)
2681 omniboxWidget := omnibox.WidgetAsLayout(a.widgetFactory)
2682 if omniboxWidget == nil {
2683 return
2684 }
2685
2686 session.overlay.AddOverlay(omniboxWidget)
2687 session.overlay.SetClipOverlay(omniboxWidget, false)
2688 session.overlay.SetMeasureOverlay(omniboxWidget, false)
2689
2690 session.omnibox = omnibox
2691 session.omniboxWidget = omniboxWidget
2692 }
2693
2694 session.pane.SetOmniboxVisible(true)
2695 session.omnibox.Show(ctx, "")
2696}
2697
2698func (a *App) hideFloatingOmnibox(ctx context.Context, session *floatingWorkspaceSession) {
2699 if session == nil || session.pane == nil {
2700 return
2701 }
2702
2703 session.pane.SetOmniboxVisible(false)
2704 if session.omnibox == nil {
2705 return
2706 }
2707
2708 session.omnibox.Hide(ctx)
2709 if session.overlay != nil && session.omniboxWidget != nil {
2710 if parent := session.omniboxWidget.GetParent(); parent == session.overlay {
2711 session.overlay.RemoveOverlay(session.omniboxWidget)
2712 } else if parent != nil {
2713 session.omniboxWidget.Unparent()
2714 }
2715 }
2716 session.omnibox = nil
2717 session.omniboxWidget = nil
2718}
2719
2720func floatingFocusWidget(session *floatingWorkspaceSession) layout.Widget {
2721 if session == nil {
2722 return nil
2723 }
2724 if session.focusWidget != nil {
2725 return session.focusWidget
2726 }
2727 if session.paneView != nil {
2728 return session.paneView.WebViewWidget()
2729 }
2730 return nil
2731}
2732
2733func focusFloatingSessionContent(session *floatingWorkspaceSession) {
2734 if session == nil || session.pane == nil {
2735 return
2736 }
2737 if !session.pane.IsVisible() || session.pane.IsOmniboxVisible() {
2738 return
2739 }
2740 w := floatingFocusWidget(session)
2741 if w == nil {
2742 return
2743 }
2744 w.GrabFocus()
2745}
2746
2747func (a *App) resizeFloatingWidget(session *floatingWorkspaceSession) {
2748 if session == nil || session.pane == nil {
2749 return
2750 }
2751 session.pane.Resize()
2752 if session.widget == nil {
2753 return
2754 }
2755 width, height := session.pane.Dimensions()
2756 if session.appliedWidth == width && session.appliedHeight == height {
2757 return
2758 }
2759 session.widget.SetSizeRequest(width, height)
2760 session.appliedWidth = width
2761 session.appliedHeight = height
2762}
2763
2764func (a *App) handleFloatingViewportTick(session *floatingWorkspaceSession) bool {
2765 if session == nil || session.pane == nil {
2766 return false
2767 }
2768 if !session.pane.IsVisible() {
2769 session.resizeWatcherActive = false
2770 session.resizeTickID = 0
2771 return false
2772 }
2773
2774 a.resizeFloatingWidget(session)
2775 return true
2776}
2777
2778func (a *App) floatingSessionByPaneID(paneID entity.PaneID) *floatingWorkspaceSession {
2779 if paneID == "" {
2780 return nil
2781 }
2782 for _, session := range a.floatingSessions {
2783 if session == nil || session.paneID != paneID {
2784 continue
2785 }
2786 return session
2787 }
2788 return nil
2789}
2790
2791func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) {
2792 if session == nil || session.overlay == nil || session.resizeWatcherActive {
2793 return
2794 }
2795
2796 overlayWidget := session.overlay.GtkWidget()
2797 if overlayWidget == nil {
2798 return
2799 }
2800
2801 session.resizeWatcherActive = true
2802 paneID := session.paneID
2803 tickCallback := gtk.TickCallback(func(_ uintptr, _ uintptr, _ uintptr) bool {
2804 liveSession := a.floatingSessionByPaneID(paneID)
2805 if liveSession == nil {
2806 session.resizeWatcherActive = false
2807 session.resizeTickID = 0
2808 return false
2809 }
2810
2811 keepRunning := a.handleFloatingViewportTick(liveSession)
2812 if !keepRunning {
2813 liveSession.resizeWatcherActive = false
2814 liveSession.resizeTickID = 0
2815 }
2816 return keepRunning
2817 })
2818 session.resizeTickID = overlayWidget.AddTickCallback(&tickCallback, 0, nil)
2819}
2820
2821func (a *App) stopFloatingResizeWatcher(session *floatingWorkspaceSession) {
2822 if session == nil {
2823 return
2824 }
2825
2826 if session.resizeTickID != 0 && session.overlay != nil {
2827 if overlayWidget := session.overlay.GtkWidget(); overlayWidget != nil {
2828 overlayWidget.RemoveTickCallback(session.resizeTickID)
2829 }
2830 }
2831 session.resizeTickID = 0
2832 session.resizeWatcherActive = false
2833}
2834
2835func (a *App) hideFloatingSession(ctx context.Context, session *floatingWorkspaceSession) {
2836 if session == nil || session.pane == nil {
2837 return
2838 }
2839
2840 a.stopFloatingResizeWatcher(session)
2841 session.pane.Hide(ctx)
2842 a.hideFloatingOmnibox(ctx, session)
2843 hideFloatingWidget(session.widget)
2844 // Force a fresh size request on next show so WebKit gets a new
2845 // allocation cycle and repaints content after rapid toggles.
2846 session.appliedWidth = 0
2847 session.appliedHeight = 0
2848}
2849
2850func (a *App) hideVisibleFloatingSessions(ctx context.Context, tabID entity.TabID, except *floatingWorkspaceSession) {
2851 for key, session := range a.floatingSessions {
2852 if key.tabID != tabID || session == nil || session == except || session.pane == nil {
2853 continue
2854 }
2855 if !session.pane.IsVisible() {
2856 continue
2857 }
2858 a.hideFloatingSession(ctx, session)
2859 }
2860}
2861
2862func (a *App) closeActiveFloatingPane(ctx context.Context) bool {
2863 session, _ := a.activeFloatingSession()
2864 if session == nil {
2865 return false
2866 }
2867 a.hideFloatingSession(ctx, session)
2868 a.syncFloatingFocus()
2869 return true
2870}
2871
2872func (a *App) handleGlobalEscape(ctx context.Context) bool {
2873 return a.closeActiveFloatingPane(ctx)
2874}
2875
2876func (a *App) closeAndReleaseActiveFloatingPane(ctx context.Context) bool {
2877 key, session, ok := a.activeFloatingSessionEntry()
2878 if !ok {
2879 return false
2880 }
2881 a.releaseFloatingSession(ctx, key, session)
2882 a.syncFloatingFocus()
2883 return true
2884}
2885
2886// ToggleFloatingPane toggles the active workspace floating pane visibility.
2887func (a *App) ToggleFloatingPane(ctx context.Context) error {
2888 activeTab := a.tabs.ActiveTab()
2889 if activeTab == nil {
2890 return nil
2891 }
2892 if a.closeActiveFloatingPane(ctx) {
2893 return nil
2894 }
2895 return a.openFloatingPaneSession(ctx, floatingSessionIDDefault)
2896}
2897
2898// OpenFloatingPaneURL opens the active workspace floating pane directly to a URL.
2899func (a *App) OpenFloatingPaneURL(ctx context.Context, url string) error {
2900 return a.openFloatingPaneSession(ctx, floatingSessionIDDefault, url)
2901}
2902
2903// OpenFloatingPaneProfileURL opens a named floating pane profile session directly to a URL.
2904func (a *App) OpenFloatingPaneProfileURL(ctx context.Context, sessionID, url string) error {
2905 return a.openFloatingPaneSession(ctx, sessionID, url)
2906}
2907
2908func (a *App) openFloatingPaneSession(ctx context.Context, sessionID string, url ...string) error {
2909 activeTab := a.tabs.ActiveTab()
2910 if activeTab == nil {
2911 return nil
2912 }
2913
2914 wsView := a.workspaceViews[activeTab.ID]
2915 session, err := a.ensureFloatingSession(ctx, activeTab.ID, sessionID, wsView)
2916 if err != nil {
2917 return err
2918 }
2919 hasURL := len(url) > 0 && strings.TrimSpace(url[0]) != ""
2920 isProfileSession := normalizeFloatingSessionID(sessionID) != floatingSessionIDDefault
2921
2922 if hasURL && isProfileSession && session.pane.IsVisible() {
2923 a.hideFloatingSession(ctx, session)
2924 a.syncFloatingFocus()
2925 return nil
2926 }
2927
2928 a.hideVisibleFloatingSessions(ctx, activeTab.ID, session)
2929
2930 if hasURL {
2931 if isProfileSession && session.pane.SessionStarted() {
2932 session.pane.Show()
2933 } else {
2934 if err := session.pane.ShowURL(ctx, url[0]); err != nil {
2935 return err
2936 }
2937 }
2938 } else {
2939 if err := session.pane.ShowToggle(ctx); err != nil {
2940 return err
2941 }
2942 }
2943
2944 a.resizeFloatingWidget(session)
2945 setFloatingWidgetShown(session.widget, session.pane.IsVisible())
2946 if session.pane.IsOmniboxVisible() {
2947 a.showFloatingOmnibox(ctx, session)
2948 } else {
2949 a.hideFloatingOmnibox(ctx, session)
2950 focusFloatingSessionContent(session)
2951 }
2952 a.startFloatingResizeWatcher(session)
2953 a.syncFloatingFocus()
2954
2955 return nil
2956}
2957
2958func (a *App) navigateFromOmnibox(ctx context.Context, url string) error {
2959 session, _ := a.activeFloatingSession()
2960 if session != nil && session.pane != nil && session.pane.IsVisible() && session.pane.IsOmniboxVisible() {
2961 return session.pane.Navigate(ctx, url)
2962 }
2963 if a.omniboxNavigateFn != nil {
2964 a.omniboxNavigateFn(url)
2965 return nil
2966 }
2967 if a.navCoord == nil {
2968 return fmt.Errorf("navigation coordinator not initialized")
2969 }
2970 return a.navCoord.Navigate(ctx, url)
2971}
2972
2973// ToggleOmnibox implements OmniboxProvider.
2974// Toggles the omnibox visibility in the active workspace view.
2975func (a *App) ToggleOmnibox(ctx context.Context) {
2976 log := logging.FromContext(ctx)
2977 if session, _ := a.activeFloatingSession(); session != nil && session.pane != nil && session.pane.IsVisible() {
2978 wsView := a.activeWorkspaceView()
2979 if wsView != nil && wsView.IsOmniboxVisible() {
2980 wsView.HideOmnibox()
2981 }
2982
2983 if session.omnibox != nil && session.pane.IsOmniboxVisible() {
2984 a.hideFloatingOmnibox(ctx, session)
2985 } else {
2986 a.showFloatingOmnibox(ctx, session)
2987 }
2988 a.syncFloatingFocus()
2989 return
2990 }
2991
2992 wsView := a.activeWorkspaceView()
2993 if wsView == nil {
2994 log.Warn().Msg("no active workspace view for omnibox toggle")
2995 return
2996 }
2997
2998 if wsView.IsOmniboxVisible() {
2999 wsView.HideOmnibox()
3000 } else {
3001 wsView.ShowOmnibox(ctx, "")
3002 }
3003}
3004
3005// ToggleFindBar shows or hides the find bar in the active workspace view.
3006func (a *App) ToggleFindBar(ctx context.Context) {
3007 log := logging.FromContext(ctx)
3008
3009 wsView := a.activeWorkspaceView()
3010 if wsView == nil {
3011 log.Warn().Msg("no active workspace view for find bar toggle")
3012 return
3013 }
3014
3015 if wsView.IsFindBarVisible() {
3016 wsView.HideFindBar()
3017 } else {
3018 wsView.ShowFindBar(ctx)
3019 }
3020}
3021
3022// FindNext selects the next match in the active find bar.
3023func (a *App) FindNext(ctx context.Context) {
3024 wsView := a.activeWorkspaceView()
3025 if wsView == nil {
3026 return
3027 }
3028
3029 if !wsView.IsFindBarVisible() {
3030 wsView.ShowFindBar(ctx)
3031 }
3032 wsView.FindNext()
3033}
3034
3035// FindPrevious selects the previous match in the active find bar.
3036func (a *App) FindPrevious(ctx context.Context) {
3037 wsView := a.activeWorkspaceView()
3038 if wsView == nil {
3039 return
3040 }
3041
3042 if !wsView.IsFindBarVisible() {
3043 wsView.ShowFindBar(ctx)
3044 }
3045 wsView.FindPrevious()
3046}
3047
3048// CloseFindBar hides the find bar if visible.
3049func (a *App) CloseFindBar(ctx context.Context) {
3050 wsView := a.activeWorkspaceView()
3051 if wsView == nil {
3052 return
3053 }
3054 wsView.HideFindBar()
3055}
3056
3057// UpdateOmniboxZoom implements OmniboxProvider.
3058// Updates the zoom indicator on the current omnibox if visible.
3059func (a *App) UpdateOmniboxZoom(factor float64) {
3060 wsView := a.activeWorkspaceView()
3061 if wsView == nil {
3062 return
3063 }
3064
3065 omnibox := wsView.GetOmnibox()
3066 if omnibox != nil {
3067 omnibox.UpdateZoomIndicator(factor)
3068 }
3069}
3070
3071// SetOmniboxOnNavigate implements OmniboxProvider.
3072// This is called to set the navigation callback on new omniboxes.
3073// Since omniboxes are created per-pane, we store the config with the callback.
3074func (a *App) SetOmniboxOnNavigate(fn func(url string)) {
3075 a.omniboxNavigateFn = fn
3076}
3077
3078// initFilteringAsync starts background filter loading with toast feedback.
3079func (a *App) initConfigWatcher(ctx context.Context) {
3080 log := logging.FromContext(ctx)
3081
3082 if a.deps.WatchConfig == nil || a.deps.OnConfigChange == nil {
3083 log.Debug().Msg("no config watcher available, skipping")
3084 return
3085 }
3086
3087 // Start viper watcher
3088 if err := a.deps.WatchConfig(); err != nil {
3089 log.Warn().Err(err).Msg("failed to start config watcher")
3090 return
3091 }
3092
3093 // Hot-reload appearance and keybindings on config change.
3094 // deps.Config is updated in-place before this callback fires.
3095 a.deps.OnConfigChange(func() {
3096 cb := glib.SourceFunc(func(_ uintptr) bool {
3097 a.applyAppearanceConfig(ctx)
3098 // Reload keyboard shortcuts
3099 if a.keyboardHandler != nil {
3100 a.keyboardHandler.ReloadShortcuts(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session)
3101 }
3102 // Reload global shortcuts (removes stale, re-registers from config)
3103 if a.globalShortcutHandler != nil {
3104 a.globalShortcutHandler.ReloadShortcuts(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session)
3105 }
3106 return false
3107 })
3108 glib.IdleAdd(&cb, 0)
3109 })
3110
3111 log.Debug().Msg("config watcher initialized")
3112}
3113
3114func (a *App) applyAppearanceConfig(ctx context.Context) {
3115 log := logging.FromContext(ctx)
3116 if a.deps == nil || a.deps.Config == nil {
3117 return
3118 }
3119 cfg := a.deps.Config
3120
3121 if a.engine != nil {
3122 if err := a.engine.UpdateSettings(ctx, port.EngineSettingsUpdate{Raw: cfg}); err != nil {
3123 log.Warn().Err(err).Msg("failed to apply engine settings update")
3124 }
3125 }
3126
3127 // Apply settings to existing webviews via coordinator
3128 if a.contentCoord != nil {
3129 a.contentCoord.ApplySettingsToAll(ctx)
3130 }
3131
3132 // Update GTK theme and injected WebUI theme vars
3133 if a.deps.Theme != nil {
3134 var display *gdk.Display
3135 if a.mainWindow != nil && a.mainWindow.Window() != nil {
3136 display = a.mainWindow.Window().GetDisplay()
3137 }
3138 a.deps.Theme.UpdateFromConfig(ctx, &cfg.Appearance, cfg.DefaultUIScale, &cfg.Workspace.Styling, display)
3139
3140 var inj port.ContentInjector
3141 if a.engine != nil {
3142 inj = a.engine.ContentInjector()
3143 }
3144
3145 if inj != nil {
3146 findCSS := theme.GenerateFindHighlightCSS(a.deps.Theme.GetCurrentPalette())
3147 if err := inj.InjectFindHighlightCSS(ctx, findCSS); err != nil {
3148 log.Warn().Err(err).Msg("failed to update find highlight CSS")
3149 }
3150 }
3151
3152 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(inj)
3153 cssText := a.deps.Theme.GetWebUIThemeCSS()
3154 if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: cssText}); err != nil {
3155 log.Warn().Err(err).Msg("failed to update WebUI theme CSS")
3156 }
3157
3158 if a.contentCoord != nil && inj != nil {
3159 a.contentCoord.RefreshInjectedScriptsToAll(ctx)
3160 }
3161
3162 // Apply WebUI theme to already-loaded dumb:// pages
3163 if a.contentCoord != nil {
3164 a.contentCoord.ApplyWebUIThemeToAll(ctx, a.deps.Theme.PrefersDark(), cssText)
3165 }
3166 }
3167
3168 log.Info().Msg("appearance config updated")
3169}
3170
3171func (a *App) initFilteringAsync(ctx context.Context) {
3172 if a.deps.FilterManager == nil {
3173 return
3174 }
3175
3176 a.deps.FilterManager.SetStatusCallback(func(status port.FilterStatus) {
3177 statusCopy := status // Capture for closure
3178 cb := glib.SourceFunc(func(_ uintptr) bool {
3179 a.showFilterStatus(ctx, statusCopy)
3180 return false // Don't repeat
3181 })
3182 glib.IdleAdd(&cb, 0)
3183 })
3184 a.deps.FilterManager.LoadAsync(ctx)
3185}
3186
3187// showFilterStatus displays toast notification for filter status.
3188func (a *App) showFilterStatus(ctx context.Context, status port.FilterStatus) {
3189 log := logging.FromContext(ctx)
3190
3191 switch status.State {
3192 case port.FilterStateLoading:
3193 if a.appToaster != nil {
3194 a.appToaster.Show(ctx, status.Message, component.ToastInfo)
3195 }
3196 case port.FilterStateActive:
3197 // Apply filters to existing webviews that were created before filters loaded
3198 if a.contentCoord != nil && a.deps.FilterManager != nil {
3199 a.contentCoord.ApplyFiltersToAll(ctx)
3200 log.Debug().Msg("applied filters to all existing webviews")
3201 }
3202 if a.appToaster != nil {
3203 a.appToaster.Show(ctx, fmt.Sprintf("Ad blocker ready (%s)", status.Version), component.ToastInfo)
3204 }
3205 case port.FilterStateError:
3206 if a.appToaster != nil {
3207 a.appToaster.Show(ctx, "Filter load failed: "+status.Message, component.ToastError)
3208 }
3209 }
3210}