refactor: localize multi-window shell chrome and routing
- commit
- af0abd1612f40f3996cf1865d4d0652305e2bb5a
- author
- brice · · 2026-04-15 05:02 UTC
- parent
- 81d2160
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
13 files · +1279 -379
M internal/ui/app.go +247 -314
| @@ -67,6 +67,7 @@ type App struct { | |||
| 67 | 67 | mainWindow *window.MainWindow | |
| 68 | 68 | ||
| 69 | 69 | browserWindows map[string]*browserWindow | |
| 70 | + | lastFocusedWindowID string | |
| 70 | 71 | browserWindowFactory func(context.Context, string) (*browserWindow, error) | |
| 71 | 72 | tabCreationWindow *browserWindow | |
| 72 | 73 | dispatchOnMainThread func(func()) | |
| @@ -93,9 +94,8 @@ type App struct { | |||
| 93 | 94 | keyboardHandler *input.KeyboardHandler | |
| 94 | 95 | globalShortcutHandler *input.GlobalShortcutHandler | |
| 95 | 96 | ||
| 96 | - | // Focus and border management | |
| 97 | - | focusMgr *focus.Manager | |
| 98 | - | borderMgr *focus.BorderManager | |
| 97 | + | // Focus tracking stays app-global. | |
| 98 | + | focusMgr *focus.Manager | |
| 99 | 99 | ||
| 100 | 100 | resizeModeBorderTarget layout.Widget | |
| 101 | 101 | ||
| @@ -113,16 +113,6 @@ type App struct { | |||
| 113 | 113 | // Web content (managed by content.Coordinator) | |
| 114 | 114 | faviconAdapter *adapter.FaviconAdapter | |
| 115 | 115 | ||
| 116 | - | // App-level toaster for system notifications (filter status, etc.) | |
| 117 | - | appToaster *component.Toaster | |
| 118 | - | // Mode indicator toaster for modal mode notifications (pane, tab, session, resize) | |
| 119 | - | modeToaster *component.Toaster | |
| 120 | - | ||
| 121 | - | // Session management | |
| 122 | - | sessionManager *component.SessionManager | |
| 123 | - | tabPicker *component.TabPicker | |
| 124 | - | tabPickerWidget layout.Widget | |
| 125 | - | tabPickerPaneID entity.PaneID | |
| 126 | 116 | snapshotService port.SnapshotService | |
| 127 | 117 | ||
| 128 | 118 | // Update management | |
| @@ -138,8 +128,6 @@ type App struct { | |||
| 138 | 128 | movePaneToTabUC *usecase.MovePaneToTabUseCase | |
| 139 | 129 | ||
| 140 | 130 | // Accent picker for dead keys support | |
| 141 | - | accentPicker *component.AccentPicker | |
| 142 | - | insertAccentUC *usecase.InsertAccentUseCase | |
| 143 | 131 | accentFocusProvider port.FocusedInputProvider | |
| 144 | 132 | ||
| 145 | 133 | // Deferred initialization - runs after first load_started to avoid blocking initial navigation | |
| @@ -206,12 +194,10 @@ func New(deps *Dependencies) (*App, error) { | |||
| 206 | 194 | }}, | |
| 207 | 195 | OnClipboardCopied: func(textLen int) { | |
| 208 | 196 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 209 | - | if app.appToaster != nil { | |
| 210 | - | app.appToaster.Show(ctx, "Copied to clipboard", component.ToastInfo, | |
| 211 | - | component.WithDuration(component.ToastBriefDurationMs), | |
| 212 | - | component.WithPosition(component.ToastPositionBottomRight), | |
| 213 | - | ) | |
| 214 | - | } | |
| 197 | + | app.showToastOnLastFocusedBrowserWindow(ctx, "Copied to clipboard", component.ToastInfo, | |
| 198 | + | component.WithDuration(component.ToastBriefDurationMs), | |
| 199 | + | component.WithPosition(component.ToastPositionBottomRight), | |
| 200 | + | ) | |
| 215 | 201 | return false | |
| 216 | 202 | }) | |
| 217 | 203 | glib.IdleAdd(&cb, 0) | |
| @@ -285,6 +271,7 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 285 | 271 | // Configure pool background color early (prevents white flash), but avoid | |
| 286 | 272 | // synchronous prewarming that delays the first navigation on cold start. | |
| 287 | 273 | a.setupPoolBackgroundColor(ctx) | |
| 274 | + | a.initLayoutInfrastructure() | |
| 288 | 275 | ||
| 289 | 276 | if err := a.OpenFreshWindow(ctx, a.initialWindowURL()); err != nil { | |
| 290 | 277 | log.Error().Err(err).Msg("failed to create main window") | |
| @@ -292,11 +279,8 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 292 | 279 | } | |
| 293 | 280 | logging.Trace().Mark("window_created") | |
| 294 | 281 | ||
| 295 | - | a.initLayoutInfrastructure() | |
| 296 | - | a.initAppToasterOverlay() | |
| 297 | 282 | a.installCrashReportNotifier(ctx) | |
| 298 | - | a.initFocusAndBorderOverlay() | |
| 299 | - | a.initAccentPicker(ctx) | |
| 283 | + | a.initFocusManager() | |
| 300 | 284 | a.registerAccentHandlers(ctx) | |
| 301 | 285 | a.initDownloadHandler(ctx) | |
| 302 | 286 | ||
| @@ -306,8 +290,7 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 306 | 290 | a.initKeyboardHandler(ctx) | |
| 307 | 291 | a.initOmniboxConfig(ctx) | |
| 308 | 292 | a.initFindBarConfig(ctx) | |
| 309 | - | a.initSessionManager(ctx) | |
| 310 | - | a.initTabPicker(ctx) | |
| 293 | + | a.wireSessionManagerShortcut() | |
| 311 | 294 | a.initSnapshotService(ctx) | |
| 312 | 295 | a.initUpdateCoordinator(ctx) | |
| 313 | 296 | a.createInitialTab(ctx) | |
| @@ -395,7 +378,13 @@ func (a *App) prewarmWebViewPoolAsync(ctx context.Context) { | |||
| 395 | 378 | ||
| 396 | 379 | func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*browserWindow, error) { | |
| 397 | 380 | if a.browserWindowFactory != nil { | |
| 398 | - | return a.browserWindowFactory(ctx, initialURL) | |
| 381 | + | created, err := a.browserWindowFactory(ctx, initialURL) | |
| 382 | + | if err != nil { | |
| 383 | + | return nil, err | |
| 384 | + | } | |
| 385 | + | a.wireBrowserWindowActivationTracking(created) | |
| 386 | + | created.initChrome(ctx, a) | |
| 387 | + | return created, nil | |
| 399 | 388 | } | |
| 400 | 389 | ||
| 401 | 390 | log := logging.FromContext(ctx) | |
| @@ -408,6 +397,7 @@ func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*brow | |||
| 408 | 397 | initialURL: initialURL, | |
| 409 | 398 | mainWindow: mainWindow, | |
| 410 | 399 | } | |
| 400 | + | a.wireBrowserWindowActivationTracking(browserWindow) | |
| 411 | 401 | if a.mainWindow == nil { | |
| 412 | 402 | a.mainWindow = mainWindow | |
| 413 | 403 | } | |
| @@ -450,6 +440,7 @@ func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*brow | |||
| 450 | 440 | if a.tabCoord != nil { | |
| 451 | 441 | a.wireBrowserWindowTabBar(ctx, browserWindow) | |
| 452 | 442 | } | |
| 443 | + | browserWindow.initChrome(ctx, a) | |
| 453 | 444 | ||
| 454 | 445 | // Apply GTK CSS styling from theme manager. | |
| 455 | 446 | if a.deps == nil || a.deps.Theme == nil { | |
| @@ -506,10 +497,82 @@ func (a *App) browserWindowForPane(paneID entity.PaneID) *browserWindow { | |||
| 506 | 497 | return nil | |
| 507 | 498 | } | |
| 508 | 499 | ||
| 500 | + | func (a *App) lastFocusedBrowserWindow() *browserWindow { | |
| 501 | + | if a.lastFocusedWindowID == "" || a.browserWindows == nil { | |
| 502 | + | return nil | |
| 503 | + | } | |
| 504 | + | return a.browserWindows[a.lastFocusedWindowID] | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | func (a *App) showToastOnBrowserWindow( | |
| 508 | + | ctx context.Context, | |
| 509 | + | bw *browserWindow, | |
| 510 | + | message string, | |
| 511 | + | level component.ToastLevel, | |
| 512 | + | opts ...component.ToastOption, | |
| 513 | + | ) { | |
| 514 | + | if bw == nil || bw.appToaster == nil { | |
| 515 | + | return | |
| 516 | + | } | |
| 517 | + | bw.appToaster.Show(ctx, message, level, opts...) | |
| 518 | + | } | |
| 519 | + | ||
| 520 | + | func (a *App) showToastOnLastFocusedBrowserWindow( | |
| 521 | + | ctx context.Context, | |
| 522 | + | message string, | |
| 523 | + | level component.ToastLevel, | |
| 524 | + | opts ...component.ToastOption, | |
| 525 | + | ) { | |
| 526 | + | a.showToastOnBrowserWindow(ctx, a.lastFocusedBrowserWindow(), message, level, opts...) | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | func (a *App) ownerOrLastFocusedBrowserWindow(tabID entity.TabID, paneID entity.PaneID) *browserWindow { | |
| 530 | + | if paneID != "" { | |
| 531 | + | if bw := a.browserWindowForPane(paneID); bw != nil { | |
| 532 | + | return bw | |
| 533 | + | } | |
| 534 | + | } | |
| 535 | + | if tabID != "" { | |
| 536 | + | if bw := a.browserWindowForTab(tabID); bw != nil { | |
| 537 | + | return bw | |
| 538 | + | } | |
| 539 | + | } | |
| 540 | + | return a.lastFocusedBrowserWindow() | |
| 541 | + | } | |
| 542 | + | ||
| 543 | + | func (a *App) handlePaneWindowTitleChanged(paneID entity.PaneID, title string) { | |
| 544 | + | a.updateWindowTitle(title, a.browserWindowForPane(paneID)) | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | func (a *App) handlePaneFullscreenChanged(paneID entity.PaneID, entering bool) { | |
| 548 | + | bw := a.browserWindowForPane(paneID) | |
| 549 | + | if bw == nil || bw.mainWindow == nil || bw.mainWindow.TabBar() == nil { | |
| 550 | + | return | |
| 551 | + | } | |
| 552 | + | if entering { | |
| 553 | + | bw.mainWindow.TabBar().SetVisible(false) | |
| 554 | + | return | |
| 555 | + | } | |
| 556 | + | a.updateBrowserWindowTabBarVisibility(bw) | |
| 557 | + | } | |
| 558 | + | ||
| 559 | + | func (a *App) updateBrowserWindowTabBarVisibility(bw *browserWindow) { | |
| 560 | + | if bw == nil || bw.mainWindow == nil || bw.mainWindow.TabBar() == nil { | |
| 561 | + | return | |
| 562 | + | } | |
| 563 | + | if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.HideTabBarWhenSingleTab { | |
| 564 | + | bw.mainWindow.TabBar().SetVisible(true) | |
| 565 | + | return | |
| 566 | + | } | |
| 567 | + | shouldShow := bw.mainWindow.TabBar().Count() > 1 | |
| 568 | + | bw.mainWindow.TabBar().SetVisible(shouldShow) | |
| 569 | + | } | |
| 570 | + | ||
| 509 | 571 | func (a *App) activateBrowserWindow(bw *browserWindow) { | |
| 510 | 572 | if bw == nil { | |
| 511 | 573 | return | |
| 512 | 574 | } | |
| 575 | + | a.lastFocusedWindowID = bw.id | |
| 513 | 576 | if bw.mainWindow != nil { | |
| 514 | 577 | a.mainWindow = bw.mainWindow | |
| 515 | 578 | } | |
| @@ -521,6 +584,23 @@ func (a *App) activateBrowserWindow(bw *browserWindow) { | |||
| 521 | 584 | if a.tabs != nil && bw.activeTabID != "" && a.tabs.Find(bw.activeTabID) != nil { | |
| 522 | 585 | a.tabs.SetActive(bw.activeTabID) | |
| 523 | 586 | } | |
| 587 | + | a.updateWindowTitleFromActivePane(bw.activeTabID) | |
| 588 | + | } | |
| 589 | + | ||
| 590 | + | func (a *App) handleBrowserWindowActivationChanged(bw *browserWindow, active bool) { | |
| 591 | + | if !active || bw == nil { | |
| 592 | + | return | |
| 593 | + | } | |
| 594 | + | a.activateBrowserWindow(bw) | |
| 595 | + | } | |
| 596 | + | ||
| 597 | + | func (a *App) wireBrowserWindowActivationTracking(bw *browserWindow) { | |
| 598 | + | if bw == nil || bw.mainWindow == nil { | |
| 599 | + | return | |
| 600 | + | } | |
| 601 | + | bw.mainWindow.ConnectActiveNotify(func(active bool) { | |
| 602 | + | a.handleBrowserWindowActivationChanged(bw, active) | |
| 603 | + | }) | |
| 524 | 604 | } | |
| 525 | 605 | ||
| 526 | 606 | func (a *App) setBrowserWindowForTab(tabID entity.TabID, bw *browserWindow) { | |
| @@ -674,36 +754,6 @@ func (a *App) initLayoutInfrastructure() { | |||
| 674 | 754 | a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory) | |
| 675 | 755 | } | |
| 676 | 756 | ||
| 677 | - | func (a *App) initAppToasterOverlay() { | |
| 678 | - | if a.mainWindow == nil { | |
| 679 | - | return | |
| 680 | - | } | |
| 681 | - | ||
| 682 | - | // Create app-level toaster for system notifications. | |
| 683 | - | a.appToaster = component.NewToaster(a.widgetFactory) | |
| 684 | - | toasterWidget := a.appToaster.Widget() | |
| 685 | - | if toasterWidget == nil { | |
| 686 | - | return | |
| 687 | - | } | |
| 688 | - | gtkWidget := toasterWidget.GtkWidget() | |
| 689 | - | if gtkWidget == nil { | |
| 690 | - | return | |
| 691 | - | } | |
| 692 | - | a.mainWindow.AddOverlay(gtkWidget) | |
| 693 | - | ||
| 694 | - | // Create mode indicator toaster for modal mode notifications. | |
| 695 | - | a.modeToaster = component.NewToaster(a.widgetFactory) | |
| 696 | - | modeToasterWidget := a.modeToaster.Widget() | |
| 697 | - | if modeToasterWidget == nil { | |
| 698 | - | return | |
| 699 | - | } | |
| 700 | - | modeGtkWidget := modeToasterWidget.GtkWidget() | |
| 701 | - | if modeGtkWidget == nil { | |
| 702 | - | return | |
| 703 | - | } | |
| 704 | - | a.mainWindow.AddOverlay(modeGtkWidget) | |
| 705 | - | } | |
| 706 | - | ||
| 707 | 757 | func (a *App) installCrashReportNotifier(ctx context.Context) { | |
| 708 | 758 | if a.deps == nil { | |
| 709 | 759 | return | |
| @@ -714,92 +764,49 @@ func (a *App) installCrashReportNotifier(ctx context.Context) { | |||
| 714 | 764 | } | |
| 715 | 765 | } | |
| 716 | 766 | ||
| 717 | - | func (a *App) initFocusAndBorderOverlay() { | |
| 718 | - | // Initialize focus manager for geometric navigation. | |
| 767 | + | func (a *App) initFocusManager() { | |
| 719 | 768 | a.focusMgr = focus.NewManager(a.panesUC) | |
| 720 | - | ||
| 721 | - | // Initialize border manager for mode indicators. | |
| 722 | - | a.borderMgr = focus.NewBorderManager(a.widgetFactory) | |
| 723 | - | ||
| 724 | - | // Attach border overlay to main window (visible for all tabs). | |
| 725 | - | if a.borderMgr == nil || a.mainWindow == nil { | |
| 726 | - | return | |
| 727 | - | } | |
| 728 | - | borderWidget := a.borderMgr.Widget() | |
| 729 | - | if borderWidget == nil { | |
| 730 | - | return | |
| 731 | - | } | |
| 732 | - | gtkWidget := borderWidget.GtkWidget() | |
| 733 | - | if gtkWidget == nil { | |
| 734 | - | return | |
| 735 | - | } | |
| 736 | - | a.mainWindow.AddOverlay(gtkWidget) | |
| 737 | 769 | } | |
| 738 | 770 | ||
| 739 | - | func (a *App) initAccentPicker(ctx context.Context) { | |
| 740 | - | log := logging.FromContext(ctx) | |
| 741 | - | ||
| 742 | - | if a.widgetFactory == nil || a.mainWindow == nil { | |
| 743 | - | log.Debug().Msg("widget factory or main window not available, skipping accent picker") | |
| 771 | + | func (a *App) wireSessionManagerShortcut() { | |
| 772 | + | if a.kbDispatcher == nil { | |
| 744 | 773 | return | |
| 745 | 774 | } | |
| 746 | - | ||
| 747 | - | // Create accent picker component | |
| 748 | - | a.accentPicker = component.NewAccentPicker(a.widgetFactory) | |
| 749 | - | if a.accentPicker == nil { | |
| 750 | - | log.Warn().Msg("failed to create accent picker") | |
| 751 | - | return | |
| 752 | - | } | |
| 753 | - | ||
| 754 | - | // Add to main window overlay | |
| 755 | - | pickerWidget := a.accentPicker.Widget() | |
| 756 | - | if pickerWidget != nil { | |
| 757 | - | gtkWidget := pickerWidget.GtkWidget() | |
| 758 | - | if gtkWidget != nil { | |
| 759 | - | a.mainWindow.AddOverlay(gtkWidget) | |
| 775 | + | a.kbDispatcher.SetOnSessionOpen(func(ctx context.Context, paneID entity.PaneID) error { | |
| 776 | + | bw := a.ownerOrLastFocusedBrowserWindow("", paneID) | |
| 777 | + | if bw == nil || bw.sessionManager == nil { | |
| 778 | + | return nil | |
| 760 | 779 | } | |
| 761 | - | } | |
| 762 | - | ||
| 763 | - | // Use focus provider from deps (tracks which input has focus) | |
| 764 | - | a.accentFocusProvider = a.deps.AccentFocusProvider | |
| 765 | - | ||
| 766 | - | // Create the use case with glib.IdleAdd for thread-safe GTK calls | |
| 767 | - | a.insertAccentUC = usecase.NewInsertAccentUseCase( | |
| 768 | - | a.accentFocusProvider, | |
| 769 | - | a.accentPicker, | |
| 770 | - | func(fn func()) { | |
| 771 | - | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 772 | - | fn() | |
| 773 | - | return false // Don't repeat | |
| 774 | - | }) | |
| 775 | - | glib.IdleAdd(&cb, 0) | |
| 776 | - | }, | |
| 777 | - | ) | |
| 778 | - | ||
| 779 | - | log.Debug().Msg("accent picker initialized") | |
| 780 | + | bw.sessionManager.Toggle(ctx) | |
| 781 | + | return nil | |
| 782 | + | }) | |
| 780 | 783 | } | |
| 781 | 784 | ||
| 782 | 785 | // registerAccentHandlers registers the accent key press/release message handlers | |
| 783 | - | // with the router. Must be called after initAccentPicker so insertAccentUC is non-nil. | |
| 786 | + | // with the router. | |
| 784 | 787 | func (a *App) registerAccentHandlers(ctx context.Context) { | |
| 785 | 788 | log := logging.FromContext(ctx) | |
| 786 | 789 | ||
| 787 | - | if a.insertAccentUC == nil { | |
| 788 | - | log.Debug().Msg("insertAccentUC not available, skipping accent handler registration") | |
| 789 | - | return | |
| 790 | - | } | |
| 791 | 790 | if a.engine == nil { | |
| 792 | 791 | log.Debug().Msg("engine not available, skipping accent handler registration") | |
| 793 | 792 | return | |
| 794 | 793 | } | |
| 795 | 794 | ||
| 796 | - | if err := a.engine.RegisterAccentHandlers(ctx, a.insertAccentUC); err != nil { | |
| 795 | + | if err := a.engine.RegisterAccentHandlers(ctx, a); err != nil { | |
| 797 | 796 | log.Error().Err(err).Msg("failed to register accent handlers") | |
| 798 | 797 | } else { | |
| 799 | 798 | log.Info().Msg("registered accent key handlers") | |
| 800 | 799 | } | |
| 801 | 800 | } | |
| 802 | 801 | ||
| 802 | + | func (a *App) activeAccentHandler() input.AccentHandler { | |
| 803 | + | bw := a.lastFocusedBrowserWindow() | |
| 804 | + | if bw == nil { | |
| 805 | + | return nil | |
| 806 | + | } | |
| 807 | + | return bw.insertAccentUC | |
| 808 | + | } | |
| 809 | + | ||
| 803 | 810 | func (a *App) initDownloadHandler(ctx context.Context) { | |
| 804 | 811 | log := logging.FromContext(ctx) | |
| 805 | 812 | ||
| @@ -853,17 +860,13 @@ type downloadEventAdapter struct { | |||
| 853 | 860 | func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) { | |
| 854 | 861 | // Must schedule on GTK main thread. | |
| 855 | 862 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 856 | - | if d.app.appToaster == nil { | |
| 857 | - | return false | |
| 858 | - | } | |
| 859 | - | ||
| 860 | 863 | switch event.Type { | |
| 861 | 864 | case port.DownloadEventStarted: | |
| 862 | - | d.app.appToaster.Show(ctx, "Download started: "+event.Filename, component.ToastInfo) | |
| 865 | + | d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download started: "+event.Filename, component.ToastInfo) | |
| 863 | 866 | case port.DownloadEventFinished: | |
| 864 | - | d.app.appToaster.Show(ctx, "Download complete: "+event.Filename, component.ToastSuccess) | |
| 867 | + | d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download complete: "+event.Filename, component.ToastSuccess) | |
| 865 | 868 | case port.DownloadEventFailed: | |
| 866 | - | d.app.appToaster.Show(ctx, "Download failed: "+event.Filename, component.ToastError) | |
| 869 | + | d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download failed: "+event.Filename, component.ToastError) | |
| 867 | 870 | } | |
| 868 | 871 | return false | |
| 869 | 872 | }) | |
| @@ -896,6 +899,9 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) { | |||
| 896 | 899 | if bw == nil || bw.mainWindow == nil || a.deps == nil || a.deps.Config == nil || a.kbDispatcher == nil { | |
| 897 | 900 | return | |
| 898 | 901 | } | |
| 902 | + | if bw.keyboardHandler != nil || bw.globalShortcutHandler != nil { | |
| 903 | + | return | |
| 904 | + | } | |
| 899 | 905 | ||
| 900 | 906 | bw.keyboardHandler = input.NewKeyboardHandler(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session) | |
| 901 | 907 | bw.keyboardHandler.SetOnAction(func(actionCtx context.Context, action input.Action) error { | |
| @@ -916,10 +922,10 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) { | |||
| 916 | 922 | a.handleModeChange(ctx, from, to) | |
| 917 | 923 | }) | |
| 918 | 924 | bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute { | |
| 919 | - | if a.sessionManager != nil && a.sessionManager.IsVisible() { | |
| 925 | + | if bw.sessionManager != nil && bw.sessionManager.IsVisible() { | |
| 920 | 926 | return input.RoutePassToWidget | |
| 921 | 927 | } | |
| 922 | - | if a.tabPicker != nil && a.tabPicker.IsVisible() { | |
| 928 | + | if bw.tabPicker != nil && bw.tabPicker.IsVisible() { | |
| 923 | 929 | return input.RoutePassToWidget | |
| 924 | 930 | } | |
| 925 | 931 | ||
| @@ -941,9 +947,7 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) { | |||
| 941 | 947 | ||
| 942 | 948 | return input.RouteHandleShortcuts | |
| 943 | 949 | }) | |
| 944 | - | if a.insertAccentUC != nil { | |
| 945 | - | bw.keyboardHandler.SetAccentHandler(a.insertAccentUC) | |
| 946 | - | } | |
| 950 | + | bw.keyboardHandler.SetAccentHandler(a) | |
| 947 | 951 | bw.keyboardHandler.AttachTo(bw.mainWindow.Window()) | |
| 948 | 952 | ||
| 949 | 953 | bw.globalShortcutHandler = input.NewGlobalShortcutHandler( | |
| @@ -981,7 +985,8 @@ func (a *App) wireBrowserWindowTabBar(ctx context.Context, bw *browserWindow) { | |||
| 981 | 985 | // handleAccentKeyPress handles accent key press events for GTK entry widgets | |
| 982 | 986 | // (omnibox and find bar). Returns true if the key was consumed. | |
| 983 | 987 | func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.ModifierType) bool { | |
| 984 | - | if a.insertAccentUC == nil { | |
| 988 | + | handler := a.activeAccentHandler() | |
| 989 | + | if handler == nil { | |
| 985 | 990 | return false | |
| 986 | 991 | } | |
| 987 | 992 | if state&(gdk.ControlMaskValue|gdk.AltMaskValue) != 0 { | |
| @@ -989,7 +994,7 @@ func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.M | |||
| 989 | 994 | } | |
| 990 | 995 | shiftHeld := state&gdk.ShiftMaskValue != 0 | |
| 991 | 996 | if char := input.KeyvalToRune(keyval); char != 0 { | |
| 992 | - | return a.insertAccentUC.OnKeyPressed(ctx, char, shiftHeld) | |
| 997 | + | return handler.OnKeyPressed(ctx, char, shiftHeld) | |
| 993 | 998 | } | |
| 994 | 999 | return false | |
| 995 | 1000 | } | |
| @@ -997,12 +1002,45 @@ func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.M | |||
| 997 | 1002 | // handleAccentKeyRelease handles accent key release events for GTK entry widgets | |
| 998 | 1003 | // (omnibox and find bar). | |
| 999 | 1004 | func (a *App) handleAccentKeyRelease(ctx context.Context, keyval uint) { | |
| 1000 | - | if a.insertAccentUC == nil { | |
| 1005 | + | handler := a.activeAccentHandler() | |
| 1006 | + | if handler == nil { | |
| 1001 | 1007 | return | |
| 1002 | 1008 | } | |
| 1003 | 1009 | if char := input.KeyvalToRune(keyval); char != 0 { | |
| 1004 | - | a.insertAccentUC.OnKeyReleased(ctx, char) | |
| 1010 | + | handler.OnKeyReleased(ctx, char) | |
| 1011 | + | } | |
| 1012 | + | } | |
| 1013 | + | ||
| 1014 | + | func (a *App) OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool { | |
| 1015 | + | handler := a.activeAccentHandler() | |
| 1016 | + | if handler == nil { | |
| 1017 | + | return false | |
| 1018 | + | } | |
| 1019 | + | return handler.OnKeyPressed(ctx, char, shiftHeld) | |
| 1020 | + | } | |
| 1021 | + | ||
| 1022 | + | func (a *App) OnKeyReleased(ctx context.Context, char rune) { | |
| 1023 | + | handler := a.activeAccentHandler() | |
| 1024 | + | if handler == nil { | |
| 1025 | + | return | |
| 1005 | 1026 | } | |
| 1027 | + | handler.OnKeyReleased(ctx, char) | |
| 1028 | + | } | |
| 1029 | + | ||
| 1030 | + | func (a *App) IsPickerVisible() bool { | |
| 1031 | + | handler := a.activeAccentHandler() | |
| 1032 | + | if handler == nil { | |
| 1033 | + | return false | |
| 1034 | + | } | |
| 1035 | + | return handler.IsPickerVisible() | |
| 1036 | + | } | |
| 1037 | + | ||
| 1038 | + | func (a *App) Cancel(ctx context.Context) { | |
| 1039 | + | handler := a.activeAccentHandler() | |
| 1040 | + | if handler == nil { | |
| 1041 | + | return | |
| 1042 | + | } | |
| 1043 | + | handler.Cancel(ctx) | |
| 1006 | 1044 | } | |
| 1007 | 1045 | ||
| 1008 | 1046 | type omniboxCallbacks struct { | |
| @@ -1168,89 +1206,18 @@ func (a *App) initFindBarConfig(ctx context.Context) { | |||
| 1168 | 1206 | } | |
| 1169 | 1207 | } | |
| 1170 | 1208 | ||
| 1171 | - | func (a *App) initSessionManager(ctx context.Context) { | |
| 1172 | - | log := logging.FromContext(ctx) | |
| 1173 | - | ||
| 1174 | - | if a.deps == nil { | |
| 1175 | - | log.Debug().Msg("deps not available, skipping session manager") | |
| 1176 | - | return | |
| 1177 | - | } | |
| 1178 | - | ||
| 1179 | - | // Session manager can work without a repo - it just shows an empty list | |
| 1180 | - | var listSessionsUC *usecase.ListSessionsUseCase | |
| 1181 | - | var deleteSessionUC *usecase.DeleteSessionUseCase | |
| 1182 | - | if a.deps.SessionRepo != nil && a.deps.SessionStateRepo != nil { | |
| 1183 | - | listSessionsUC = usecase.NewListSessionsUseCase( | |
| 1184 | - | a.deps.SessionRepo, | |
| 1185 | - | a.deps.SessionStateRepo, | |
| 1186 | - | ) | |
| 1187 | - | deleteSessionUC = usecase.NewDeleteSessionUseCase( | |
| 1188 | - | a.deps.SessionStateRepo, | |
| 1189 | - | a.deps.SessionRepo, | |
| 1190 | - | ) | |
| 1191 | - | } | |
| 1192 | - | ||
| 1193 | - | // Use injected session spawner for restoration | |
| 1194 | - | spawner := a.deps.SessionSpawner | |
| 1195 | - | ||
| 1196 | - | // Create session manager component | |
| 1197 | - | a.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{ | |
| 1198 | - | ListSessionsUC: listSessionsUC, | |
| 1199 | - | DeleteSessionUC: deleteSessionUC, | |
| 1200 | - | CurrentSession: a.deps.CurrentSessionID, | |
| 1201 | - | UIScale: a.deps.Config.DefaultUIScale, | |
| 1202 | - | OnClose: func() { | |
| 1203 | - | log.Debug().Msg("session manager closed") | |
| 1204 | - | }, | |
| 1205 | - | OnOpen: func(sessionID entity.SessionID) { | |
| 1206 | - | log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested") | |
| 1207 | - | if spawner == nil { | |
| 1208 | - | log.Warn().Str("session_id", string(sessionID)).Msg("session spawner not available") | |
| 1209 | - | return | |
| 1210 | - | } | |
| 1211 | - | if err := spawner.SpawnWithSession(sessionID); err != nil { | |
| 1212 | - | log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session") | |
| 1213 | - | } | |
| 1214 | - | }, | |
| 1215 | - | OnToast: func(ctx context.Context, message string, level component.ToastLevel) { | |
| 1216 | - | if a.appToaster != nil { | |
| 1217 | - | a.appToaster.Show(ctx, message, level) | |
| 1218 | - | } | |
| 1219 | - | }, | |
| 1220 | - | }) | |
| 1221 | - | ||
| 1222 | - | if a.sessionManager == nil { | |
| 1223 | - | log.Warn().Msg("failed to create session manager") | |
| 1224 | - | return | |
| 1225 | - | } | |
| 1226 | - | ||
| 1227 | - | // Add session manager to main window overlay | |
| 1228 | - | if a.mainWindow != nil { | |
| 1229 | - | widget := a.sessionManager.Widget() | |
| 1230 | - | if widget != nil { | |
| 1231 | - | a.mainWindow.AddOverlay(widget) | |
| 1232 | - | } | |
| 1233 | - | } | |
| 1234 | - | ||
| 1235 | - | // Wire session manager action to keyboard dispatcher | |
| 1236 | - | a.kbDispatcher.SetOnSessionOpen(func(ctx context.Context) error { | |
| 1237 | - | a.ToggleSessionManager(ctx) | |
| 1238 | - | return nil | |
| 1239 | - | }) | |
| 1240 | - | ||
| 1241 | - | log.Debug().Msg("session manager initialized") | |
| 1242 | - | } | |
| 1243 | - | ||
| 1244 | 1209 | // ToggleSessionManager shows or hides the session manager. | |
| 1245 | 1210 | func (a *App) ToggleSessionManager(ctx context.Context) { | |
| 1246 | - | if a.sessionManager == nil { | |
| 1211 | + | bw := a.lastFocusedBrowserWindow() | |
| 1212 | + | if bw == nil || bw.sessionManager == nil { | |
| 1247 | 1213 | return | |
| 1248 | 1214 | } | |
| 1249 | - | a.sessionManager.Toggle(ctx) | |
| 1215 | + | bw.sessionManager.Toggle(ctx) | |
| 1250 | 1216 | } | |
| 1251 | 1217 | ||
| 1252 | 1218 | func (a *App) attachTabPickerToActivePane() { | |
| 1253 | - | if a.tabPicker == nil || a.widgetFactory == nil { | |
| 1219 | + | bw := a.lastFocusedBrowserWindow() | |
| 1220 | + | if bw == nil || bw.tabPicker == nil || a.widgetFactory == nil { | |
| 1254 | 1221 | return | |
| 1255 | 1222 | } | |
| 1256 | 1223 | wsView := a.activeWorkspaceView() | |
| @@ -1271,24 +1238,24 @@ func (a *App) attachTabPickerToActivePane() { | |||
| 1271 | 1238 | return | |
| 1272 | 1239 | } | |
| 1273 | 1240 | ||
| 1274 | - | if a.tabPickerWidget == nil { | |
| 1275 | - | a.tabPickerWidget = a.tabPicker.WidgetAsLayout(a.widgetFactory) | |
| 1276 | - | if a.tabPickerWidget == nil { | |
| 1241 | + | if bw.tabPickerWidget == nil { | |
| 1242 | + | bw.tabPickerWidget = bw.tabPicker.WidgetAsLayout(a.widgetFactory) | |
| 1243 | + | if bw.tabPickerWidget == nil { | |
| 1277 | 1244 | return | |
| 1278 | 1245 | } | |
| 1279 | 1246 | } | |
| 1280 | 1247 | ||
| 1281 | 1248 | // If currently attached to a different pane overlay, detach. | |
| 1282 | - | if a.tabPickerPaneID != "" && a.tabPickerPaneID != activePaneID { | |
| 1249 | + | if bw.tabPickerPaneID != "" && bw.tabPickerPaneID != activePaneID { | |
| 1283 | 1250 | for _, view := range a.workspaceViews { | |
| 1284 | 1251 | if view == nil { | |
| 1285 | 1252 | continue | |
| 1286 | 1253 | } | |
| 1287 | - | if oldPV := view.GetPaneView(a.tabPickerPaneID); oldPV != nil { | |
| 1288 | - | if parent := a.tabPickerWidget.GetParent(); parent == oldPV.Overlay() { | |
| 1289 | - | oldPV.RemoveOverlayWidget(a.tabPickerWidget) | |
| 1254 | + | if oldPV := view.GetPaneView(bw.tabPickerPaneID); oldPV != nil { | |
| 1255 | + | if parent := bw.tabPickerWidget.GetParent(); parent == oldPV.Overlay() { | |
| 1256 | + | oldPV.RemoveOverlayWidget(bw.tabPickerWidget) | |
| 1290 | 1257 | } else if parent != nil { | |
| 1291 | - | a.tabPickerWidget.Unparent() | |
| 1258 | + | bw.tabPickerWidget.Unparent() | |
| 1292 | 1259 | } | |
| 1293 | 1260 | break | |
| 1294 | 1261 | } | |
| @@ -1296,20 +1263,21 @@ func (a *App) attachTabPickerToActivePane() { | |||
| 1296 | 1263 | } | |
| 1297 | 1264 | ||
| 1298 | 1265 | // Ensure the widget can be reparented. | |
| 1299 | - | if parent := a.tabPickerWidget.GetParent(); parent != nil { | |
| 1300 | - | a.tabPickerWidget.Unparent() | |
| 1266 | + | if parent := bw.tabPickerWidget.GetParent(); parent != nil { | |
| 1267 | + | bw.tabPickerWidget.Unparent() | |
| 1301 | 1268 | } | |
| 1302 | 1269 | ||
| 1303 | - | a.tabPicker.SetParentOverlay(pv.Overlay()) | |
| 1304 | - | pv.AddOverlayWidget(a.tabPickerWidget) | |
| 1305 | - | a.tabPickerPaneID = activePaneID | |
| 1270 | + | bw.tabPicker.SetParentOverlay(pv.Overlay()) | |
| 1271 | + | pv.AddOverlayWidget(bw.tabPickerWidget) | |
| 1272 | + | bw.tabPickerPaneID = activePaneID | |
| 1306 | 1273 | } | |
| 1307 | 1274 | ||
| 1308 | 1275 | func (a *App) HandleMovePaneToTab(ctx context.Context) error { | |
| 1309 | 1276 | if a.movePaneToTabUC == nil { | |
| 1310 | 1277 | return nil | |
| 1311 | 1278 | } | |
| 1312 | - | if a.tabPicker == nil { | |
| 1279 | + | bw := a.lastFocusedBrowserWindow() | |
| 1280 | + | if bw == nil || bw.tabPicker == nil { | |
| 1313 | 1281 | return nil | |
| 1314 | 1282 | } | |
| 1315 | 1283 | ||
| @@ -1341,7 +1309,7 @@ func (a *App) HandleMovePaneToTab(ctx context.Context) error { | |||
| 1341 | 1309 | items = append(items, component.TabPickerItem{IsNew: true, Index: -1}) | |
| 1342 | 1310 | ||
| 1343 | 1311 | a.attachTabPickerToActivePane() | |
| 1344 | - | a.tabPicker.Show(ctx, items) | |
| 1312 | + | bw.tabPicker.Show(ctx, items) | |
| 1345 | 1313 | return nil | |
| 1346 | 1314 | } | |
| 1347 | 1315 | ||
| @@ -1387,6 +1355,13 @@ func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID) | |||
| 1387 | 1355 | return nil | |
| 1388 | 1356 | } | |
| 1389 | 1357 | ||
| 1358 | + | func (a *App) moveActivePaneToTabFromBrowserWindow(ctx context.Context, bw *browserWindow, targetTabID entity.TabID) error { | |
| 1359 | + | if bw != nil { | |
| 1360 | + | a.activateBrowserWindow(bw) | |
| 1361 | + | } | |
| 1362 | + | return a.MoveActivePaneToTab(ctx, targetTabID) | |
| 1363 | + | } | |
| 1364 | + | ||
| 1390 | 1365 | func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePaneToTabInput, *entity.Tab) { | |
| 1391 | 1366 | if a.movePaneToTabUC == nil { | |
| 1392 | 1367 | return nil, nil | |
| @@ -1518,42 +1493,6 @@ func (a *App) updateTabBarVisibility(ctx context.Context) { | |||
| 1518 | 1493 | } | |
| 1519 | 1494 | } | |
| 1520 | 1495 | ||
| 1521 | - | func (a *App) initTabPicker(ctx context.Context) { | |
| 1522 | - | log := logging.FromContext(ctx) | |
| 1523 | - | ||
| 1524 | - | if a.deps == nil || a.deps.Config == nil { | |
| 1525 | - | log.Debug().Msg("deps/config not available, skipping tab picker") | |
| 1526 | - | return | |
| 1527 | - | } | |
| 1528 | - | ||
| 1529 | - | a.tabPicker = component.NewTabPicker(ctx, component.TabPickerConfig{ | |
| 1530 | - | UIScale: a.deps.Config.DefaultUIScale, | |
| 1531 | - | OnClose: func() { | |
| 1532 | - | log.Debug().Msg("tab picker closed") | |
| 1533 | - | }, | |
| 1534 | - | OnSelect: func(item component.TabPickerItem) { | |
| 1535 | - | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 1536 | - | var targetID entity.TabID | |
| 1537 | - | if !item.IsNew { | |
| 1538 | - | targetID = item.TabID | |
| 1539 | - | } | |
| 1540 | - | if err := a.MoveActivePaneToTab(ctx, targetID); err != nil { | |
| 1541 | - | log.Warn().Err(err).Msg("move pane to tab failed") | |
| 1542 | - | } | |
| 1543 | - | return false | |
| 1544 | - | }) | |
| 1545 | - | glib.IdleAdd(&cb, 0) | |
| 1546 | - | }, | |
| 1547 | - | }) | |
| 1548 | - | ||
| 1549 | - | if a.tabPicker == nil { | |
| 1550 | - | log.Warn().Msg("failed to create tab picker") | |
| 1551 | - | return | |
| 1552 | - | } | |
| 1553 | - | ||
| 1554 | - | log.Debug().Msg("tab picker initialized") | |
| 1555 | - | } | |
| 1556 | - | ||
| 1557 | 1496 | func (a *App) initSnapshotService(ctx context.Context) { | |
| 1558 | 1497 | log := logging.FromContext(ctx) | |
| 1559 | 1498 | ||
| @@ -1593,7 +1532,9 @@ func (a *App) initUpdateCoordinator(ctx context.Context) { | |||
| 1593 | 1532 | a.updateCoord = coordinator.NewUpdateCoordinator( | |
| 1594 | 1533 | a.deps.CheckUpdateUC, | |
| 1595 | 1534 | a.deps.ApplyUpdateUC, | |
| 1596 | - | a.appToaster, | |
| 1535 | + | func(ctx context.Context, msg string, level component.ToastLevel) { | |
| 1536 | + | a.showToastOnLastFocusedBrowserWindow(ctx, msg, level) | |
| 1537 | + | }, | |
| 1597 | 1538 | a.deps.Config.Update.EnableOnStartup, | |
| 1598 | 1539 | a.deps.Config.Update.AutoDownload, | |
| 1599 | 1540 | ) | |
| @@ -1632,15 +1573,11 @@ func (a *App) createInitialTab(ctx context.Context) { | |||
| 1632 | 1573 | if err := a.restoreSession(ctx, entity.SessionID(a.deps.RestoreSessionID)); err != nil { | |
| 1633 | 1574 | log.Error().Err(err).Str("session_id", a.deps.RestoreSessionID).Msg("failed to restore session, creating default tab") | |
| 1634 | 1575 | // Show error toast and fall through to create default tab | |
| 1635 | - | if a.appToaster != nil { | |
| 1636 | - | a.appToaster.Show(ctx, "Session restore failed", component.ToastWarning) | |
| 1637 | - | } | |
| 1576 | + | a.showToastOnLastFocusedBrowserWindow(ctx, "Session restore failed", component.ToastWarning) | |
| 1638 | 1577 | } else { | |
| 1639 | 1578 | log.Info().Str("session_id", a.deps.RestoreSessionID).Msg("session restored") | |
| 1640 | 1579 | // Show success toast for session restoration | |
| 1641 | - | if a.appToaster != nil { | |
| 1642 | - | a.appToaster.Show(ctx, "Session restored", component.ToastSuccess) | |
| 1643 | - | } | |
| 1580 | + | a.showToastOnLastFocusedBrowserWindow(ctx, "Session restored", component.ToastSuccess) | |
| 1644 | 1581 | return | |
| 1645 | 1582 | } | |
| 1646 | 1583 | } | |
| @@ -1739,13 +1676,11 @@ func (a *App) showCrashReportToast(ctx context.Context, paths []string) { | |||
| 1739 | 1676 | log.Warn().Int("count", len(paths)).Msg("unexpected-close reports available") | |
| 1740 | 1677 | ||
| 1741 | 1678 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 1742 | - | if a.appToaster != nil { | |
| 1743 | - | msg := fmt.Sprintf("Detected %d unexpected close report(s). Run: dumber crashes issue latest", len(paths)) | |
| 1744 | - | a.appToaster.Show(ctx, msg, component.ToastWarning, | |
| 1745 | - | component.WithDuration(crashReportToastDurationMs), | |
| 1746 | - | component.WithPosition(component.ToastPositionBottomRight), | |
| 1747 | - | ) | |
| 1748 | - | } | |
| 1679 | + | msg := fmt.Sprintf("Detected %d unexpected close report(s). Run: dumber crashes issue latest", len(paths)) | |
| 1680 | + | a.showToastOnLastFocusedBrowserWindow(ctx, msg, component.ToastWarning, | |
| 1681 | + | component.WithDuration(crashReportToastDurationMs), | |
| 1682 | + | component.WithPosition(component.ToastPositionBottomRight), | |
| 1683 | + | ) | |
| 1749 | 1684 | return false | |
| 1750 | 1685 | }) | |
| 1751 | 1686 | glib.IdleAdd(&cb, 0) | |
| @@ -1919,17 +1854,8 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 1919 | 1854 | } | |
| 1920 | 1855 | ||
| 1921 | 1856 | // Set fullscreen callback to hide/show tab bar (after tabCoord is initialized) | |
| 1922 | - | a.contentCoord.SetOnFullscreenChanged(func(entering bool) { | |
| 1923 | - | if a.mainWindow == nil || a.mainWindow.TabBar() == nil { | |
| 1924 | - | return | |
| 1925 | - | } | |
| 1926 | - | if entering { | |
| 1927 | - | // Hide tab bar when entering fullscreen video | |
| 1928 | - | a.mainWindow.TabBar().SetVisible(false) | |
| 1929 | - | } else { | |
| 1930 | - | // Restore tab bar visibility based on normal logic | |
| 1931 | - | a.tabCoord.UpdateBarVisibility(ctx) | |
| 1932 | - | } | |
| 1857 | + | a.contentCoord.SetOnFullscreenChanged(func(paneID entity.PaneID, entering bool) { | |
| 1858 | + | a.handlePaneFullscreenChanged(paneID, entering) | |
| 1933 | 1859 | }) | |
| 1934 | 1860 | ||
| 1935 | 1861 | // 3. Workspace Coordinator | |
| @@ -2002,8 +1928,8 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 2002 | 1928 | }) | |
| 2003 | 1929 | ||
| 2004 | 1930 | // Wire window title updates when active pane's title changes | |
| 2005 | - | a.contentCoord.SetOnWindowTitleChanged(func(title string) { | |
| 2006 | - | a.updateWindowTitle(title, a.browserWindowForTab(a.tabs.ActiveTabID)) | |
| 1931 | + | a.contentCoord.SetOnWindowTitleChanged(func(paneID entity.PaneID, title string) { | |
| 1932 | + | a.handlePaneWindowTitleChanged(paneID, title) | |
| 2007 | 1933 | }) | |
| 2008 | 1934 | ||
| 2009 | 1935 | // Wire pane URI updates for session snapshots (searches all tabs) | |
| @@ -2048,6 +1974,7 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 2048 | 1974 | a.deps.ZoomUC, | |
| 2049 | 1975 | a.deps.CopyURLUC, | |
| 2050 | 1976 | a.deps.Config.Workspace.NewPaneURL, | |
| 1977 | + | a.contentCoord.ActivePaneID, | |
| 2051 | 1978 | ) | |
| 2052 | 1979 | a.wireKeyboardActions() | |
| 2053 | 1980 | for _, bw := range a.browserWindows { | |
| @@ -2075,10 +2002,16 @@ func (a *App) wireKeyboardActions() { | |||
| 2075 | 2002 | a.CloseFindBar(ctx) | |
| 2076 | 2003 | return nil | |
| 2077 | 2004 | }) | |
| 2078 | - | a.kbDispatcher.SetOnMovePaneToTab(func(ctx context.Context) error { | |
| 2005 | + | a.kbDispatcher.SetOnMovePaneToTab(func(ctx context.Context, paneID entity.PaneID) error { | |
| 2006 | + | if bw := a.ownerOrLastFocusedBrowserWindow("", paneID); bw != nil { | |
| 2007 | + | a.activateBrowserWindow(bw) | |
| 2008 | + | } | |
| 2079 | 2009 | return a.HandleMovePaneToTab(ctx) | |
| 2080 | 2010 | }) | |
| 2081 | - | a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context) error { | |
| 2011 | + | a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context, paneID entity.PaneID) error { | |
| 2012 | + | if bw := a.ownerOrLastFocusedBrowserWindow("", paneID); bw != nil { | |
| 2013 | + | a.activateBrowserWindow(bw) | |
| 2014 | + | } | |
| 2082 | 2015 | return a.HandleMovePaneToNextTab(ctx) | |
| 2083 | 2016 | }) | |
| 2084 | 2017 | a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error { | |
| @@ -2304,8 +2237,12 @@ func (a *App) updateWindowTitle(pageTitle string, target *browserWindow) { | |||
| 2304 | 2237 | // updateWindowTitleFromActivePane updates the window title based on the current active pane. | |
| 2305 | 2238 | func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) { | |
| 2306 | 2239 | var ws *entity.Workspace | |
| 2307 | - | if tab := a.tabs.Find(tabID); tab != nil { | |
| 2308 | - | ws = tab.Workspace | |
| 2240 | + | if a.tabs != nil { | |
| 2241 | + | if tab := a.tabs.Find(tabID); tab != nil { | |
| 2242 | + | ws = tab.Workspace | |
| 2243 | + | } else { | |
| 2244 | + | ws = a.activeWorkspace() | |
| 2245 | + | } | |
| 2309 | 2246 | } else { | |
| 2310 | 2247 | ws = a.activeWorkspace() | |
| 2311 | 2248 | } | |
| @@ -2314,7 +2251,7 @@ func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) { | |||
| 2314 | 2251 | return | |
| 2315 | 2252 | } | |
| 2316 | 2253 | title := a.contentCoord.GetTitle(ws.ActivePaneID) | |
| 2317 | - | a.updateWindowTitle(title, a.browserWindowForTab(tabID)) | |
| 2254 | + | a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, "")) | |
| 2318 | 2255 | } | |
| 2319 | 2256 | ||
| 2320 | 2257 | // handleModeChange is called when the input mode changes. | |
| @@ -2331,8 +2268,8 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) { | |||
| 2331 | 2268 | ||
| 2332 | 2269 | // Update global border overlay visibility based on mode. | |
| 2333 | 2270 | // Note: resize mode border is handled per-pane (stack container), not via global overlay. | |
| 2334 | - | if a.borderMgr != nil { | |
| 2335 | - | a.borderMgr.OnModeChange(ctx, from, to) | |
| 2271 | + | if bw := a.lastFocusedBrowserWindow(); bw != nil && bw.borderMgr != nil { | |
| 2272 | + | bw.borderMgr.OnModeChange(ctx, from, to) | |
| 2336 | 2273 | } | |
| 2337 | 2274 | ||
| 2338 | 2275 | // Show/hide mode indicator toaster based on config. | |
| @@ -2341,25 +2278,26 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) { | |||
| 2341 | 2278 | ||
| 2342 | 2279 | // updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config. | |
| 2343 | 2280 | func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) { | |
| 2344 | - | if a.modeToaster == nil { | |
| 2281 | + | bw := a.lastFocusedBrowserWindow() | |
| 2282 | + | if bw == nil || bw.modeToaster == nil { | |
| 2345 | 2283 | return | |
| 2346 | 2284 | } | |
| 2347 | 2285 | ||
| 2348 | 2286 | // Check if mode indicator toaster is enabled in config. | |
| 2349 | 2287 | if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.Styling.ModeIndicatorToasterEnabled { | |
| 2350 | - | a.modeToaster.Hide() | |
| 2288 | + | bw.modeToaster.Hide() | |
| 2351 | 2289 | return | |
| 2352 | 2290 | } | |
| 2353 | 2291 | ||
| 2354 | 2292 | if mode == input.ModeNormal { | |
| 2355 | - | a.modeToaster.Hide() | |
| 2293 | + | bw.modeToaster.Hide() | |
| 2356 | 2294 | return | |
| 2357 | 2295 | } | |
| 2358 | 2296 | ||
| 2359 | 2297 | // Show persistent toaster at bottom-left with mode display name. | |
| 2360 | 2298 | // Mode class is applied atomically with Show() to avoid visual flicker. | |
| 2361 | 2299 | modeClass := getModeToastClass(mode) | |
| 2362 | - | a.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo, | |
| 2300 | + | bw.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo, | |
| 2363 | 2301 | component.WithDuration(0), // Persistent until mode exits. | |
| 2364 | 2302 | component.WithPosition(component.ToastPositionBottomLeft), | |
| 2365 | 2303 | component.WithModeClass(modeClass), | |
| @@ -2512,6 +2450,9 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity. | |||
| 2512 | 2450 | ||
| 2513 | 2451 | // activeWorkspace returns the workspace of the active tab. | |
| 2514 | 2452 | func (a *App) activeWorkspace() *entity.Workspace { | |
| 2453 | + | if a.tabs == nil { | |
| 2454 | + | return nil | |
| 2455 | + | } | |
| 2515 | 2456 | activeTab := a.tabs.ActiveTab() | |
| 2516 | 2457 | if activeTab == nil { | |
| 2517 | 2458 | return nil | |
| @@ -3068,9 +3009,7 @@ func (a *App) showFloatingOmnibox(ctx context.Context, session *floatingWorkspac | |||
| 3068 | 3009 | if session.omnibox == nil { | |
| 3069 | 3010 | cfg := a.omniboxCfg | |
| 3070 | 3011 | cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) { | |
| 3071 | - | if a.appToaster != nil { | |
| 3072 | - | a.appToaster.Show(toastCtx, message, level) | |
| 3073 | - | } | |
| 3012 | + | a.showToastOnLastFocusedBrowserWindow(toastCtx, message, level) | |
| 3074 | 3013 | } | |
| 3075 | 3014 | ||
| 3076 | 3015 | omnibox := component.NewOmnibox(ctx, cfg) | |
| @@ -3594,21 +3533,15 @@ func (a *App) showFilterStatus(ctx context.Context, status port.FilterStatus) { | |||
| 3594 | 3533 | ||
| 3595 | 3534 | switch status.State { | |
| 3596 | 3535 | case port.FilterStateLoading: | |
| 3597 | - | if a.appToaster != nil { | |
| 3598 | - | a.appToaster.Show(ctx, status.Message, component.ToastInfo) | |
| 3599 | - | } | |
| 3536 | + | a.showToastOnLastFocusedBrowserWindow(ctx, status.Message, component.ToastInfo) | |
| 3600 | 3537 | case port.FilterStateActive: | |
| 3601 | 3538 | // Apply filters to existing webviews that were created before filters loaded | |
| 3602 | 3539 | if a.contentCoord != nil && a.deps.FilterManager != nil { | |
| 3603 | 3540 | a.contentCoord.ApplyFiltersToAll(ctx) | |
| 3604 | 3541 | log.Debug().Msg("applied filters to all existing webviews") | |
| 3605 | 3542 | } | |
| 3606 | - | if a.appToaster != nil { | |
| 3607 | - | a.appToaster.Show(ctx, fmt.Sprintf("Ad blocker ready (%s)", status.Version), component.ToastInfo) | |
| 3608 | - | } | |
| 3543 | + | a.showToastOnLastFocusedBrowserWindow(ctx, fmt.Sprintf("Ad blocker ready (%s)", status.Version), component.ToastInfo) | |
| 3609 | 3544 | case port.FilterStateError: | |
| 3610 | - | if a.appToaster != nil { | |
| 3611 | - | a.appToaster.Show(ctx, "Filter load failed: "+status.Message, component.ToastError) | |
| 3612 | - | } | |
| 3545 | + | a.showToastOnLastFocusedBrowserWindow(ctx, "Filter load failed: "+status.Message, component.ToastError) | |
| 3613 | 3546 | } | |
| 3614 | 3547 | } | |
| @@ -67,6 +67,7 @@ type App struct { | |||||
| 67 | mainWindow *window.MainWindow | 67 | mainWindow *window.MainWindow | ||
| 68 | 68 | ||||
| 69 | browserWindows map[string]*browserWindow | 69 | browserWindows map[string]*browserWindow | ||
| 70 | + | lastFocusedWindowID string | |||
| 70 | browserWindowFactory func(context.Context, string) (*browserWindow, error) | 71 | browserWindowFactory func(context.Context, string) (*browserWindow, error) | ||
| 71 | tabCreationWindow *browserWindow | 72 | tabCreationWindow *browserWindow | ||
| 72 | dispatchOnMainThread func(func()) | 73 | dispatchOnMainThread func(func()) | ||
| @@ -93,9 +94,8 @@ type App struct { | |||||
| 93 | keyboardHandler *input.KeyboardHandler | 94 | keyboardHandler *input.KeyboardHandler | ||
| 94 | globalShortcutHandler *input.GlobalShortcutHandler | 95 | globalShortcutHandler *input.GlobalShortcutHandler | ||
| 95 | 96 | ||||
| 96 | - | // Focus and border management | 97 | + | // Focus tracking stays app-global. |
| 97 | - | focusMgr *focus.Manager | 98 | + | focusMgr *focus.Manager |
| 98 | - | borderMgr *focus.BorderManager | |||
| 99 | 99 | ||||
| 100 | resizeModeBorderTarget layout.Widget | 100 | resizeModeBorderTarget layout.Widget | ||
| 101 | 101 | ||||
| @@ -113,16 +113,6 @@ type App struct { | |||||
| 113 | // Web content (managed by content.Coordinator) | 113 | // Web content (managed by content.Coordinator) | ||
| 114 | faviconAdapter *adapter.FaviconAdapter | 114 | faviconAdapter *adapter.FaviconAdapter | ||
| 115 | 115 | ||||
| 116 | - | // App-level toaster for system notifications (filter status, etc.) | |||
| 117 | - | appToaster *component.Toaster | |||
| 118 | - | // Mode indicator toaster for modal mode notifications (pane, tab, session, resize) | |||
| 119 | - | modeToaster *component.Toaster | |||
| 120 | - | ||||
| 121 | - | // Session management | |||
| 122 | - | sessionManager *component.SessionManager | |||
| 123 | - | tabPicker *component.TabPicker | |||
| 124 | - | tabPickerWidget layout.Widget | |||
| 125 | - | tabPickerPaneID entity.PaneID | |||
| 126 | snapshotService port.SnapshotService | 116 | snapshotService port.SnapshotService | ||
| 127 | 117 | ||||
| 128 | // Update management | 118 | // Update management | ||
| @@ -138,8 +128,6 @@ type App struct { | |||||
| 138 | movePaneToTabUC *usecase.MovePaneToTabUseCase | 128 | movePaneToTabUC *usecase.MovePaneToTabUseCase | ||
| 139 | 129 | ||||
| 140 | // Accent picker for dead keys support | 130 | // Accent picker for dead keys support | ||
| 141 | - | accentPicker *component.AccentPicker | |||
| 142 | - | insertAccentUC *usecase.InsertAccentUseCase | |||
| 143 | accentFocusProvider port.FocusedInputProvider | 131 | accentFocusProvider port.FocusedInputProvider | ||
| 144 | 132 | ||||
| 145 | // Deferred initialization - runs after first load_started to avoid blocking initial navigation | 133 | // Deferred initialization - runs after first load_started to avoid blocking initial navigation | ||
| @@ -206,12 +194,10 @@ func New(deps *Dependencies) (*App, error) { | |||||
| 206 | }}, | 194 | }}, | ||
| 207 | OnClipboardCopied: func(textLen int) { | 195 | OnClipboardCopied: func(textLen int) { | ||
| 208 | cb := glib.SourceFunc(func(_ uintptr) bool { | 196 | cb := glib.SourceFunc(func(_ uintptr) bool { | ||
| 209 | - | if app.appToaster != nil { | 197 | + | app.showToastOnLastFocusedBrowserWindow(ctx, "Copied to clipboard", component.ToastInfo, |
| 210 | - | app.appToaster.Show(ctx, "Copied to clipboard", component.ToastInfo, | 198 | + | component.WithDuration(component.ToastBriefDurationMs), |
| 211 | - | component.WithDuration(component.ToastBriefDurationMs), | 199 | + | component.WithPosition(component.ToastPositionBottomRight), |
| 212 | - | component.WithPosition(component.ToastPositionBottomRight), | 200 | + | ) |
| 213 | - | ) | |||
| 214 | - | } | |||
| 215 | return false | 201 | return false | ||
| 216 | }) | 202 | }) | ||
| 217 | glib.IdleAdd(&cb, 0) | 203 | glib.IdleAdd(&cb, 0) | ||
| @@ -285,6 +271,7 @@ func (a *App) onActivate(ctx context.Context) { | |||||
| 285 | // Configure pool background color early (prevents white flash), but avoid | 271 | // Configure pool background color early (prevents white flash), but avoid | ||
| 286 | // synchronous prewarming that delays the first navigation on cold start. | 272 | // synchronous prewarming that delays the first navigation on cold start. | ||
| 287 | a.setupPoolBackgroundColor(ctx) | 273 | a.setupPoolBackgroundColor(ctx) | ||
| 274 | + | a.initLayoutInfrastructure() | |||
| 288 | 275 | ||||
| 289 | if err := a.OpenFreshWindow(ctx, a.initialWindowURL()); err != nil { | 276 | if err := a.OpenFreshWindow(ctx, a.initialWindowURL()); err != nil { | ||
| 290 | log.Error().Err(err).Msg("failed to create main window") | 277 | log.Error().Err(err).Msg("failed to create main window") | ||
| @@ -292,11 +279,8 @@ func (a *App) onActivate(ctx context.Context) { | |||||
| 292 | } | 279 | } | ||
| 293 | logging.Trace().Mark("window_created") | 280 | logging.Trace().Mark("window_created") | ||
| 294 | 281 | ||||
| 295 | - | a.initLayoutInfrastructure() | |||
| 296 | - | a.initAppToasterOverlay() | |||
| 297 | a.installCrashReportNotifier(ctx) | 282 | a.installCrashReportNotifier(ctx) | ||
| 298 | - | a.initFocusAndBorderOverlay() | 283 | + | a.initFocusManager() |
| 299 | - | a.initAccentPicker(ctx) | |||
| 300 | a.registerAccentHandlers(ctx) | 284 | a.registerAccentHandlers(ctx) | ||
| 301 | a.initDownloadHandler(ctx) | 285 | a.initDownloadHandler(ctx) | ||
| 302 | 286 | ||||
| @@ -306,8 +290,7 @@ func (a *App) onActivate(ctx context.Context) { | |||||
| 306 | a.initKeyboardHandler(ctx) | 290 | a.initKeyboardHandler(ctx) | ||
| 307 | a.initOmniboxConfig(ctx) | 291 | a.initOmniboxConfig(ctx) | ||
| 308 | a.initFindBarConfig(ctx) | 292 | a.initFindBarConfig(ctx) | ||
| 309 | - | a.initSessionManager(ctx) | 293 | + | a.wireSessionManagerShortcut() |
| 310 | - | a.initTabPicker(ctx) | |||
| 311 | a.initSnapshotService(ctx) | 294 | a.initSnapshotService(ctx) | ||
| 312 | a.initUpdateCoordinator(ctx) | 295 | a.initUpdateCoordinator(ctx) | ||
| 313 | a.createInitialTab(ctx) | 296 | a.createInitialTab(ctx) | ||
| @@ -395,7 +378,13 @@ func (a *App) prewarmWebViewPoolAsync(ctx context.Context) { | |||||
| 395 | 378 | ||||
| 396 | func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*browserWindow, error) { | 379 | func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*browserWindow, error) { | ||
| 397 | if a.browserWindowFactory != nil { | 380 | if a.browserWindowFactory != nil { | ||
| 398 | - | return a.browserWindowFactory(ctx, initialURL) | 381 | + | created, err := a.browserWindowFactory(ctx, initialURL) |
| 382 | + | if err != nil { | |||
| 383 | + | return nil, err | |||
| 384 | + | } | |||
| 385 | + | a.wireBrowserWindowActivationTracking(created) | |||
| 386 | + | created.initChrome(ctx, a) | |||
| 387 | + | return created, nil | |||
| 399 | } | 388 | } | ||
| 400 | 389 | ||||
| 401 | log := logging.FromContext(ctx) | 390 | log := logging.FromContext(ctx) | ||
| @@ -408,6 +397,7 @@ func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*brow | |||||
| 408 | initialURL: initialURL, | 397 | initialURL: initialURL, | ||
| 409 | mainWindow: mainWindow, | 398 | mainWindow: mainWindow, | ||
| 410 | } | 399 | } | ||
| 400 | + | a.wireBrowserWindowActivationTracking(browserWindow) | |||
| 411 | if a.mainWindow == nil { | 401 | if a.mainWindow == nil { | ||
| 412 | a.mainWindow = mainWindow | 402 | a.mainWindow = mainWindow | ||
| 413 | } | 403 | } | ||
| @@ -450,6 +440,7 @@ func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*brow | |||||
| 450 | if a.tabCoord != nil { | 440 | if a.tabCoord != nil { | ||
| 451 | a.wireBrowserWindowTabBar(ctx, browserWindow) | 441 | a.wireBrowserWindowTabBar(ctx, browserWindow) | ||
| 452 | } | 442 | } | ||
| 443 | + | browserWindow.initChrome(ctx, a) | |||
| 453 | 444 | ||||
| 454 | // Apply GTK CSS styling from theme manager. | 445 | // Apply GTK CSS styling from theme manager. | ||
| 455 | if a.deps == nil || a.deps.Theme == nil { | 446 | if a.deps == nil || a.deps.Theme == nil { | ||
| @@ -506,10 +497,82 @@ func (a *App) browserWindowForPane(paneID entity.PaneID) *browserWindow { | |||||
| 506 | return nil | 497 | return nil | ||
| 507 | } | 498 | } | ||
| 508 | 499 | ||||
| 500 | + | func (a *App) lastFocusedBrowserWindow() *browserWindow { | |||
| 501 | + | if a.lastFocusedWindowID == "" || a.browserWindows == nil { | |||
| 502 | + | return nil | |||
| 503 | + | } | |||
| 504 | + | return a.browserWindows[a.lastFocusedWindowID] | |||
| 505 | + | } | |||
| 506 | + | ||||
| 507 | + | func (a *App) showToastOnBrowserWindow( | |||
| 508 | + | ctx context.Context, | |||
| 509 | + | bw *browserWindow, | |||
| 510 | + | message string, | |||
| 511 | + | level component.ToastLevel, | |||
| 512 | + | opts ...component.ToastOption, | |||
| 513 | + | ) { | |||
| 514 | + | if bw == nil || bw.appToaster == nil { | |||
| 515 | + | return | |||
| 516 | + | } | |||
| 517 | + | bw.appToaster.Show(ctx, message, level, opts...) | |||
| 518 | + | } | |||
| 519 | + | ||||
| 520 | + | func (a *App) showToastOnLastFocusedBrowserWindow( | |||
| 521 | + | ctx context.Context, | |||
| 522 | + | message string, | |||
| 523 | + | level component.ToastLevel, | |||
| 524 | + | opts ...component.ToastOption, | |||
| 525 | + | ) { | |||
| 526 | + | a.showToastOnBrowserWindow(ctx, a.lastFocusedBrowserWindow(), message, level, opts...) | |||
| 527 | + | } | |||
| 528 | + | ||||
| 529 | + | func (a *App) ownerOrLastFocusedBrowserWindow(tabID entity.TabID, paneID entity.PaneID) *browserWindow { | |||
| 530 | + | if paneID != "" { | |||
| 531 | + | if bw := a.browserWindowForPane(paneID); bw != nil { | |||
| 532 | + | return bw | |||
| 533 | + | } | |||
| 534 | + | } | |||
| 535 | + | if tabID != "" { | |||
| 536 | + | if bw := a.browserWindowForTab(tabID); bw != nil { | |||
| 537 | + | return bw | |||
| 538 | + | } | |||
| 539 | + | } | |||
| 540 | + | return a.lastFocusedBrowserWindow() | |||
| 541 | + | } | |||
| 542 | + | ||||
| 543 | + | func (a *App) handlePaneWindowTitleChanged(paneID entity.PaneID, title string) { | |||
| 544 | + | a.updateWindowTitle(title, a.browserWindowForPane(paneID)) | |||
| 545 | + | } | |||
| 546 | + | ||||
| 547 | + | func (a *App) handlePaneFullscreenChanged(paneID entity.PaneID, entering bool) { | |||
| 548 | + | bw := a.browserWindowForPane(paneID) | |||
| 549 | + | if bw == nil || bw.mainWindow == nil || bw.mainWindow.TabBar() == nil { | |||
| 550 | + | return | |||
| 551 | + | } | |||
| 552 | + | if entering { | |||
| 553 | + | bw.mainWindow.TabBar().SetVisible(false) | |||
| 554 | + | return | |||
| 555 | + | } | |||
| 556 | + | a.updateBrowserWindowTabBarVisibility(bw) | |||
| 557 | + | } | |||
| 558 | + | ||||
| 559 | + | func (a *App) updateBrowserWindowTabBarVisibility(bw *browserWindow) { | |||
| 560 | + | if bw == nil || bw.mainWindow == nil || bw.mainWindow.TabBar() == nil { | |||
| 561 | + | return | |||
| 562 | + | } | |||
| 563 | + | if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.HideTabBarWhenSingleTab { | |||
| 564 | + | bw.mainWindow.TabBar().SetVisible(true) | |||
| 565 | + | return | |||
| 566 | + | } | |||
| 567 | + | shouldShow := bw.mainWindow.TabBar().Count() > 1 | |||
| 568 | + | bw.mainWindow.TabBar().SetVisible(shouldShow) | |||
| 569 | + | } | |||
| 570 | + | ||||
| 509 | func (a *App) activateBrowserWindow(bw *browserWindow) { | 571 | func (a *App) activateBrowserWindow(bw *browserWindow) { | ||
| 510 | if bw == nil { | 572 | if bw == nil { | ||
| 511 | return | 573 | return | ||
| 512 | } | 574 | } | ||
| 575 | + | a.lastFocusedWindowID = bw.id | |||
| 513 | if bw.mainWindow != nil { | 576 | if bw.mainWindow != nil { | ||
| 514 | a.mainWindow = bw.mainWindow | 577 | a.mainWindow = bw.mainWindow | ||
| 515 | } | 578 | } | ||
| @@ -521,6 +584,23 @@ func (a *App) activateBrowserWindow(bw *browserWindow) { | |||||
| 521 | if a.tabs != nil && bw.activeTabID != "" && a.tabs.Find(bw.activeTabID) != nil { | 584 | if a.tabs != nil && bw.activeTabID != "" && a.tabs.Find(bw.activeTabID) != nil { | ||
| 522 | a.tabs.SetActive(bw.activeTabID) | 585 | a.tabs.SetActive(bw.activeTabID) | ||
| 523 | } | 586 | } | ||
| 587 | + | a.updateWindowTitleFromActivePane(bw.activeTabID) | |||
| 588 | + | } | |||
| 589 | + | ||||
| 590 | + | func (a *App) handleBrowserWindowActivationChanged(bw *browserWindow, active bool) { | |||
| 591 | + | if !active || bw == nil { | |||
| 592 | + | return | |||
| 593 | + | } | |||
| 594 | + | a.activateBrowserWindow(bw) | |||
| 595 | + | } | |||
| 596 | + | ||||
| 597 | + | func (a *App) wireBrowserWindowActivationTracking(bw *browserWindow) { | |||
| 598 | + | if bw == nil || bw.mainWindow == nil { | |||
| 599 | + | return | |||
| 600 | + | } | |||
| 601 | + | bw.mainWindow.ConnectActiveNotify(func(active bool) { | |||
| 602 | + | a.handleBrowserWindowActivationChanged(bw, active) | |||
| 603 | + | }) | |||
| 524 | } | 604 | } | ||
| 525 | 605 | ||||
| 526 | func (a *App) setBrowserWindowForTab(tabID entity.TabID, bw *browserWindow) { | 606 | func (a *App) setBrowserWindowForTab(tabID entity.TabID, bw *browserWindow) { | ||
| @@ -674,36 +754,6 @@ func (a *App) initLayoutInfrastructure() { | |||||
| 674 | a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory) | 754 | a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory) | ||
| 675 | } | 755 | } | ||
| 676 | 756 | ||||
| 677 | - | func (a *App) initAppToasterOverlay() { | |||
| 678 | - | if a.mainWindow == nil { | |||
| 679 | - | return | |||
| 680 | - | } | |||
| 681 | - | ||||
| 682 | - | // Create app-level toaster for system notifications. | |||
| 683 | - | a.appToaster = component.NewToaster(a.widgetFactory) | |||
| 684 | - | toasterWidget := a.appToaster.Widget() | |||
| 685 | - | if toasterWidget == nil { | |||
| 686 | - | return | |||
| 687 | - | } | |||
| 688 | - | gtkWidget := toasterWidget.GtkWidget() | |||
| 689 | - | if gtkWidget == nil { | |||
| 690 | - | return | |||
| 691 | - | } | |||
| 692 | - | a.mainWindow.AddOverlay(gtkWidget) | |||
| 693 | - | ||||
| 694 | - | // Create mode indicator toaster for modal mode notifications. | |||
| 695 | - | a.modeToaster = component.NewToaster(a.widgetFactory) | |||
| 696 | - | modeToasterWidget := a.modeToaster.Widget() | |||
| 697 | - | if modeToasterWidget == nil { | |||
| 698 | - | return | |||
| 699 | - | } | |||
| 700 | - | modeGtkWidget := modeToasterWidget.GtkWidget() | |||
| 701 | - | if modeGtkWidget == nil { | |||
| 702 | - | return | |||
| 703 | - | } | |||
| 704 | - | a.mainWindow.AddOverlay(modeGtkWidget) | |||
| 705 | - | } | |||
| 706 | - | ||||
| 707 | func (a *App) installCrashReportNotifier(ctx context.Context) { | 757 | func (a *App) installCrashReportNotifier(ctx context.Context) { | ||
| 708 | if a.deps == nil { | 758 | if a.deps == nil { | ||
| 709 | return | 759 | return | ||
| @@ -714,92 +764,49 @@ func (a *App) installCrashReportNotifier(ctx context.Context) { | |||||
| 714 | } | 764 | } | ||
| 715 | } | 765 | } | ||
| 716 | 766 | ||||
| 717 | - | func (a *App) initFocusAndBorderOverlay() { | 767 | + | func (a *App) initFocusManager() { |
| 718 | - | // Initialize focus manager for geometric navigation. | |||
| 719 | a.focusMgr = focus.NewManager(a.panesUC) | 768 | a.focusMgr = focus.NewManager(a.panesUC) | ||
| 720 | - | ||||
| 721 | - | // Initialize border manager for mode indicators. | |||
| 722 | - | a.borderMgr = focus.NewBorderManager(a.widgetFactory) | |||
| 723 | - | ||||
| 724 | - | // Attach border overlay to main window (visible for all tabs). | |||
| 725 | - | if a.borderMgr == nil || a.mainWindow == nil { | |||
| 726 | - | return | |||
| 727 | - | } | |||
| 728 | - | borderWidget := a.borderMgr.Widget() | |||
| 729 | - | if borderWidget == nil { | |||
| 730 | - | return | |||
| 731 | - | } | |||
| 732 | - | gtkWidget := borderWidget.GtkWidget() | |||
| 733 | - | if gtkWidget == nil { | |||
| 734 | - | return | |||
| 735 | - | } | |||
| 736 | - | a.mainWindow.AddOverlay(gtkWidget) | |||
| 737 | } | 769 | } | ||
| 738 | 770 | ||||
| 739 | - | func (a *App) initAccentPicker(ctx context.Context) { | 771 | + | func (a *App) wireSessionManagerShortcut() { |
| 740 | - | log := logging.FromContext(ctx) | 772 | + | if a.kbDispatcher == nil { |
| 741 | - | ||||
| 742 | - | if a.widgetFactory == nil || a.mainWindow == nil { | |||
| 743 | - | log.Debug().Msg("widget factory or main window not available, skipping accent picker") | |||
| 744 | return | 773 | return | ||
| 745 | } | 774 | } | ||
| 746 | - | 775 | + | a.kbDispatcher.SetOnSessionOpen(func(ctx context.Context, paneID entity.PaneID) error { | |
| 747 | - | // Create accent picker component | 776 | + | bw := a.ownerOrLastFocusedBrowserWindow("", paneID) |
| 748 | - | a.accentPicker = component.NewAccentPicker(a.widgetFactory) | 777 | + | if bw == nil || bw.sessionManager == nil { |
| 749 | - | if a.accentPicker == nil { | 778 | + | return nil |
| 750 | - | log.Warn().Msg("failed to create accent picker") | |||
| 751 | - | return | |||
| 752 | - | } | |||
| 753 | - | ||||
| 754 | - | // Add to main window overlay | |||
| 755 | - | pickerWidget := a.accentPicker.Widget() | |||
| 756 | - | if pickerWidget != nil { | |||
| 757 | - | gtkWidget := pickerWidget.GtkWidget() | |||
| 758 | - | if gtkWidget != nil { | |||
| 759 | - | a.mainWindow.AddOverlay(gtkWidget) | |||
| 760 | } | 779 | } | ||
| 761 | - | } | 780 | + | bw.sessionManager.Toggle(ctx) |
| 762 | - | 781 | + | return nil | |
| 763 | - | // Use focus provider from deps (tracks which input has focus) | 782 | + | }) |
| 764 | - | a.accentFocusProvider = a.deps.AccentFocusProvider | |||
| 765 | - | ||||
| 766 | - | // Create the use case with glib.IdleAdd for thread-safe GTK calls | |||
| 767 | - | a.insertAccentUC = usecase.NewInsertAccentUseCase( | |||
| 768 | - | a.accentFocusProvider, | |||
| 769 | - | a.accentPicker, | |||
| 770 | - | func(fn func()) { | |||
| 771 | - | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 772 | - | fn() | |||
| 773 | - | return false // Don't repeat | |||
| 774 | - | }) | |||
| 775 | - | glib.IdleAdd(&cb, 0) | |||
| 776 | - | }, | |||
| 777 | - | ) | |||
| 778 | - | ||||
| 779 | - | log.Debug().Msg("accent picker initialized") | |||
| 780 | } | 783 | } | ||
| 781 | 784 | ||||
| 782 | // registerAccentHandlers registers the accent key press/release message handlers | 785 | // registerAccentHandlers registers the accent key press/release message handlers | ||
| 783 | - | // with the router. Must be called after initAccentPicker so insertAccentUC is non-nil. | 786 | + | // with the router. |
| 784 | func (a *App) registerAccentHandlers(ctx context.Context) { | 787 | func (a *App) registerAccentHandlers(ctx context.Context) { | ||
| 785 | log := logging.FromContext(ctx) | 788 | log := logging.FromContext(ctx) | ||
| 786 | 789 | ||||
| 787 | - | if a.insertAccentUC == nil { | |||
| 788 | - | log.Debug().Msg("insertAccentUC not available, skipping accent handler registration") | |||
| 789 | - | return | |||
| 790 | - | } | |||
| 791 | if a.engine == nil { | 790 | if a.engine == nil { | ||
| 792 | log.Debug().Msg("engine not available, skipping accent handler registration") | 791 | log.Debug().Msg("engine not available, skipping accent handler registration") | ||
| 793 | return | 792 | return | ||
| 794 | } | 793 | } | ||
| 795 | 794 | ||||
| 796 | - | if err := a.engine.RegisterAccentHandlers(ctx, a.insertAccentUC); err != nil { | 795 | + | if err := a.engine.RegisterAccentHandlers(ctx, a); err != nil { |
| 797 | log.Error().Err(err).Msg("failed to register accent handlers") | 796 | log.Error().Err(err).Msg("failed to register accent handlers") | ||
| 798 | } else { | 797 | } else { | ||
| 799 | log.Info().Msg("registered accent key handlers") | 798 | log.Info().Msg("registered accent key handlers") | ||
| 800 | } | 799 | } | ||
| 801 | } | 800 | } | ||
| 802 | 801 | ||||
| 802 | + | func (a *App) activeAccentHandler() input.AccentHandler { | |||
| 803 | + | bw := a.lastFocusedBrowserWindow() | |||
| 804 | + | if bw == nil { | |||
| 805 | + | return nil | |||
| 806 | + | } | |||
| 807 | + | return bw.insertAccentUC | |||
| 808 | + | } | |||
| 809 | + | ||||
| 803 | func (a *App) initDownloadHandler(ctx context.Context) { | 810 | func (a *App) initDownloadHandler(ctx context.Context) { | ||
| 804 | log := logging.FromContext(ctx) | 811 | log := logging.FromContext(ctx) | ||
| 805 | 812 | ||||
| @@ -853,17 +860,13 @@ type downloadEventAdapter struct { | |||||
| 853 | func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) { | 860 | func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) { | ||
| 854 | // Must schedule on GTK main thread. | 861 | // Must schedule on GTK main thread. | ||
| 855 | cb := glib.SourceFunc(func(_ uintptr) bool { | 862 | cb := glib.SourceFunc(func(_ uintptr) bool { | ||
| 856 | - | if d.app.appToaster == nil { | |||
| 857 | - | return false | |||
| 858 | - | } | |||
| 859 | - | ||||
| 860 | switch event.Type { | 863 | switch event.Type { | ||
| 861 | case port.DownloadEventStarted: | 864 | case port.DownloadEventStarted: | ||
| 862 | - | d.app.appToaster.Show(ctx, "Download started: "+event.Filename, component.ToastInfo) | 865 | + | d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download started: "+event.Filename, component.ToastInfo) |
| 863 | case port.DownloadEventFinished: | 866 | case port.DownloadEventFinished: | ||
| 864 | - | d.app.appToaster.Show(ctx, "Download complete: "+event.Filename, component.ToastSuccess) | 867 | + | d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download complete: "+event.Filename, component.ToastSuccess) |
| 865 | case port.DownloadEventFailed: | 868 | case port.DownloadEventFailed: | ||
| 866 | - | d.app.appToaster.Show(ctx, "Download failed: "+event.Filename, component.ToastError) | 869 | + | d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download failed: "+event.Filename, component.ToastError) |
| 867 | } | 870 | } | ||
| 868 | return false | 871 | return false | ||
| 869 | }) | 872 | }) | ||
| @@ -896,6 +899,9 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) { | |||||
| 896 | if bw == nil || bw.mainWindow == nil || a.deps == nil || a.deps.Config == nil || a.kbDispatcher == nil { | 899 | if bw == nil || bw.mainWindow == nil || a.deps == nil || a.deps.Config == nil || a.kbDispatcher == nil { | ||
| 897 | return | 900 | return | ||
| 898 | } | 901 | } | ||
| 902 | + | if bw.keyboardHandler != nil || bw.globalShortcutHandler != nil { | |||
| 903 | + | return | |||
| 904 | + | } | |||
| 899 | 905 | ||||
| 900 | bw.keyboardHandler = input.NewKeyboardHandler(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session) | 906 | bw.keyboardHandler = input.NewKeyboardHandler(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session) | ||
| 901 | bw.keyboardHandler.SetOnAction(func(actionCtx context.Context, action input.Action) error { | 907 | bw.keyboardHandler.SetOnAction(func(actionCtx context.Context, action input.Action) error { | ||
| @@ -916,10 +922,10 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) { | |||||
| 916 | a.handleModeChange(ctx, from, to) | 922 | a.handleModeChange(ctx, from, to) | ||
| 917 | }) | 923 | }) | ||
| 918 | bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute { | 924 | bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute { | ||
| 919 | - | if a.sessionManager != nil && a.sessionManager.IsVisible() { | 925 | + | if bw.sessionManager != nil && bw.sessionManager.IsVisible() { |
| 920 | return input.RoutePassToWidget | 926 | return input.RoutePassToWidget | ||
| 921 | } | 927 | } | ||
| 922 | - | if a.tabPicker != nil && a.tabPicker.IsVisible() { | 928 | + | if bw.tabPicker != nil && bw.tabPicker.IsVisible() { |
| 923 | return input.RoutePassToWidget | 929 | return input.RoutePassToWidget | ||
| 924 | } | 930 | } | ||
| 925 | 931 | ||||
| @@ -941,9 +947,7 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) { | |||||
| 941 | 947 | ||||
| 942 | return input.RouteHandleShortcuts | 948 | return input.RouteHandleShortcuts | ||
| 943 | }) | 949 | }) | ||
| 944 | - | if a.insertAccentUC != nil { | 950 | + | bw.keyboardHandler.SetAccentHandler(a) |
| 945 | - | bw.keyboardHandler.SetAccentHandler(a.insertAccentUC) | |||
| 946 | - | } | |||
| 947 | bw.keyboardHandler.AttachTo(bw.mainWindow.Window()) | 951 | bw.keyboardHandler.AttachTo(bw.mainWindow.Window()) | ||
| 948 | 952 | ||||
| 949 | bw.globalShortcutHandler = input.NewGlobalShortcutHandler( | 953 | bw.globalShortcutHandler = input.NewGlobalShortcutHandler( | ||
| @@ -981,7 +985,8 @@ func (a *App) wireBrowserWindowTabBar(ctx context.Context, bw *browserWindow) { | |||||
| 981 | // handleAccentKeyPress handles accent key press events for GTK entry widgets | 985 | // handleAccentKeyPress handles accent key press events for GTK entry widgets | ||
| 982 | // (omnibox and find bar). Returns true if the key was consumed. | 986 | // (omnibox and find bar). Returns true if the key was consumed. | ||
| 983 | func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.ModifierType) bool { | 987 | func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.ModifierType) bool { | ||
| 984 | - | if a.insertAccentUC == nil { | 988 | + | handler := a.activeAccentHandler() |
| 989 | + | if handler == nil { | |||
| 985 | return false | 990 | return false | ||
| 986 | } | 991 | } | ||
| 987 | if state&(gdk.ControlMaskValue|gdk.AltMaskValue) != 0 { | 992 | if state&(gdk.ControlMaskValue|gdk.AltMaskValue) != 0 { | ||
| @@ -989,7 +994,7 @@ func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.M | |||||
| 989 | } | 994 | } | ||
| 990 | shiftHeld := state&gdk.ShiftMaskValue != 0 | 995 | shiftHeld := state&gdk.ShiftMaskValue != 0 | ||
| 991 | if char := input.KeyvalToRune(keyval); char != 0 { | 996 | if char := input.KeyvalToRune(keyval); char != 0 { | ||
| 992 | - | return a.insertAccentUC.OnKeyPressed(ctx, char, shiftHeld) | 997 | + | return handler.OnKeyPressed(ctx, char, shiftHeld) |
| 993 | } | 998 | } | ||
| 994 | return false | 999 | return false | ||
| 995 | } | 1000 | } | ||
| @@ -997,12 +1002,45 @@ func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.M | |||||
| 997 | // handleAccentKeyRelease handles accent key release events for GTK entry widgets | 1002 | // handleAccentKeyRelease handles accent key release events for GTK entry widgets | ||
| 998 | // (omnibox and find bar). | 1003 | // (omnibox and find bar). | ||
| 999 | func (a *App) handleAccentKeyRelease(ctx context.Context, keyval uint) { | 1004 | func (a *App) handleAccentKeyRelease(ctx context.Context, keyval uint) { | ||
| 1000 | - | if a.insertAccentUC == nil { | 1005 | + | handler := a.activeAccentHandler() |
| 1006 | + | if handler == nil { | |||
| 1001 | return | 1007 | return | ||
| 1002 | } | 1008 | } | ||
| 1003 | if char := input.KeyvalToRune(keyval); char != 0 { | 1009 | if char := input.KeyvalToRune(keyval); char != 0 { | ||
| 1004 | - | a.insertAccentUC.OnKeyReleased(ctx, char) | 1010 | + | handler.OnKeyReleased(ctx, char) |
| 1011 | + | } | |||
| 1012 | + | } | |||
| 1013 | + | ||||
| 1014 | + | func (a *App) OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool { | |||
| 1015 | + | handler := a.activeAccentHandler() | |||
| 1016 | + | if handler == nil { | |||
| 1017 | + | return false | |||
| 1018 | + | } | |||
| 1019 | + | return handler.OnKeyPressed(ctx, char, shiftHeld) | |||
| 1020 | + | } | |||
| 1021 | + | ||||
| 1022 | + | func (a *App) OnKeyReleased(ctx context.Context, char rune) { | |||
| 1023 | + | handler := a.activeAccentHandler() | |||
| 1024 | + | if handler == nil { | |||
| 1025 | + | return | |||
| 1005 | } | 1026 | } | ||
| 1027 | + | handler.OnKeyReleased(ctx, char) | |||
| 1028 | + | } | |||
| 1029 | + | ||||
| 1030 | + | func (a *App) IsPickerVisible() bool { | |||
| 1031 | + | handler := a.activeAccentHandler() | |||
| 1032 | + | if handler == nil { | |||
| 1033 | + | return false | |||
| 1034 | + | } | |||
| 1035 | + | return handler.IsPickerVisible() | |||
| 1036 | + | } | |||
| 1037 | + | ||||
| 1038 | + | func (a *App) Cancel(ctx context.Context) { | |||
| 1039 | + | handler := a.activeAccentHandler() | |||
| 1040 | + | if handler == nil { | |||
| 1041 | + | return | |||
| 1042 | + | } | |||
| 1043 | + | handler.Cancel(ctx) | |||
| 1006 | } | 1044 | } | ||
| 1007 | 1045 | ||||
| 1008 | type omniboxCallbacks struct { | 1046 | type omniboxCallbacks struct { | ||
| @@ -1168,89 +1206,18 @@ func (a *App) initFindBarConfig(ctx context.Context) { | |||||
| 1168 | } | 1206 | } | ||
| 1169 | } | 1207 | } | ||
| 1170 | 1208 | ||||
| 1171 | - | func (a *App) initSessionManager(ctx context.Context) { | |||
| 1172 | - | log := logging.FromContext(ctx) | |||
| 1173 | - | ||||
| 1174 | - | if a.deps == nil { | |||
| 1175 | - | log.Debug().Msg("deps not available, skipping session manager") | |||
| 1176 | - | return | |||
| 1177 | - | } | |||
| 1178 | - | ||||
| 1179 | - | // Session manager can work without a repo - it just shows an empty list | |||
| 1180 | - | var listSessionsUC *usecase.ListSessionsUseCase | |||
| 1181 | - | var deleteSessionUC *usecase.DeleteSessionUseCase | |||
| 1182 | - | if a.deps.SessionRepo != nil && a.deps.SessionStateRepo != nil { | |||
| 1183 | - | listSessionsUC = usecase.NewListSessionsUseCase( | |||
| 1184 | - | a.deps.SessionRepo, | |||
| 1185 | - | a.deps.SessionStateRepo, | |||
| 1186 | - | ) | |||
| 1187 | - | deleteSessionUC = usecase.NewDeleteSessionUseCase( | |||
| 1188 | - | a.deps.SessionStateRepo, | |||
| 1189 | - | a.deps.SessionRepo, | |||
| 1190 | - | ) | |||
| 1191 | - | } | |||
| 1192 | - | ||||
| 1193 | - | // Use injected session spawner for restoration | |||
| 1194 | - | spawner := a.deps.SessionSpawner | |||
| 1195 | - | ||||
| 1196 | - | // Create session manager component | |||
| 1197 | - | a.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{ | |||
| 1198 | - | ListSessionsUC: listSessionsUC, | |||
| 1199 | - | DeleteSessionUC: deleteSessionUC, | |||
| 1200 | - | CurrentSession: a.deps.CurrentSessionID, | |||
| 1201 | - | UIScale: a.deps.Config.DefaultUIScale, | |||
| 1202 | - | OnClose: func() { | |||
| 1203 | - | log.Debug().Msg("session manager closed") | |||
| 1204 | - | }, | |||
| 1205 | - | OnOpen: func(sessionID entity.SessionID) { | |||
| 1206 | - | log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested") | |||
| 1207 | - | if spawner == nil { | |||
| 1208 | - | log.Warn().Str("session_id", string(sessionID)).Msg("session spawner not available") | |||
| 1209 | - | return | |||
| 1210 | - | } | |||
| 1211 | - | if err := spawner.SpawnWithSession(sessionID); err != nil { | |||
| 1212 | - | log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session") | |||
| 1213 | - | } | |||
| 1214 | - | }, | |||
| 1215 | - | OnToast: func(ctx context.Context, message string, level component.ToastLevel) { | |||
| 1216 | - | if a.appToaster != nil { | |||
| 1217 | - | a.appToaster.Show(ctx, message, level) | |||
| 1218 | - | } | |||
| 1219 | - | }, | |||
| 1220 | - | }) | |||
| 1221 | - | ||||
| 1222 | - | if a.sessionManager == nil { | |||
| 1223 | - | log.Warn().Msg("failed to create session manager") | |||
| 1224 | - | return | |||
| 1225 | - | } | |||
| 1226 | - | ||||
| 1227 | - | // Add session manager to main window overlay | |||
| 1228 | - | if a.mainWindow != nil { | |||
| 1229 | - | widget := a.sessionManager.Widget() | |||
| 1230 | - | if widget != nil { | |||
| 1231 | - | a.mainWindow.AddOverlay(widget) | |||
| 1232 | - | } | |||
| 1233 | - | } | |||
| 1234 | - | ||||
| 1235 | - | // Wire session manager action to keyboard dispatcher | |||
| 1236 | - | a.kbDispatcher.SetOnSessionOpen(func(ctx context.Context) error { | |||
| 1237 | - | a.ToggleSessionManager(ctx) | |||
| 1238 | - | return nil | |||
| 1239 | - | }) | |||
| 1240 | - | ||||
| 1241 | - | log.Debug().Msg("session manager initialized") | |||
| 1242 | - | } | |||
| 1243 | - | ||||
| 1244 | // ToggleSessionManager shows or hides the session manager. | 1209 | // ToggleSessionManager shows or hides the session manager. | ||
| 1245 | func (a *App) ToggleSessionManager(ctx context.Context) { | 1210 | func (a *App) ToggleSessionManager(ctx context.Context) { | ||
| 1246 | - | if a.sessionManager == nil { | 1211 | + | bw := a.lastFocusedBrowserWindow() |
| 1212 | + | if bw == nil || bw.sessionManager == nil { | |||
| 1247 | return | 1213 | return | ||
| 1248 | } | 1214 | } | ||
| 1249 | - | a.sessionManager.Toggle(ctx) | 1215 | + | bw.sessionManager.Toggle(ctx) |
| 1250 | } | 1216 | } | ||
| 1251 | 1217 | ||||
| 1252 | func (a *App) attachTabPickerToActivePane() { | 1218 | func (a *App) attachTabPickerToActivePane() { | ||
| 1253 | - | if a.tabPicker == nil || a.widgetFactory == nil { | 1219 | + | bw := a.lastFocusedBrowserWindow() |
| 1220 | + | if bw == nil || bw.tabPicker == nil || a.widgetFactory == nil { | |||
| 1254 | return | 1221 | return | ||
| 1255 | } | 1222 | } | ||
| 1256 | wsView := a.activeWorkspaceView() | 1223 | wsView := a.activeWorkspaceView() | ||
| @@ -1271,24 +1238,24 @@ func (a *App) attachTabPickerToActivePane() { | |||||
| 1271 | return | 1238 | return | ||
| 1272 | } | 1239 | } | ||
| 1273 | 1240 | ||||
| 1274 | - | if a.tabPickerWidget == nil { | 1241 | + | if bw.tabPickerWidget == nil { |
| 1275 | - | a.tabPickerWidget = a.tabPicker.WidgetAsLayout(a.widgetFactory) | 1242 | + | bw.tabPickerWidget = bw.tabPicker.WidgetAsLayout(a.widgetFactory) |
| 1276 | - | if a.tabPickerWidget == nil { | 1243 | + | if bw.tabPickerWidget == nil { |
| 1277 | return | 1244 | return | ||
| 1278 | } | 1245 | } | ||
| 1279 | } | 1246 | } | ||
| 1280 | 1247 | ||||
| 1281 | // If currently attached to a different pane overlay, detach. | 1248 | // If currently attached to a different pane overlay, detach. | ||
| 1282 | - | if a.tabPickerPaneID != "" && a.tabPickerPaneID != activePaneID { | 1249 | + | if bw.tabPickerPaneID != "" && bw.tabPickerPaneID != activePaneID { |
| 1283 | for _, view := range a.workspaceViews { | 1250 | for _, view := range a.workspaceViews { | ||
| 1284 | if view == nil { | 1251 | if view == nil { | ||
| 1285 | continue | 1252 | continue | ||
| 1286 | } | 1253 | } | ||
| 1287 | - | if oldPV := view.GetPaneView(a.tabPickerPaneID); oldPV != nil { | 1254 | + | if oldPV := view.GetPaneView(bw.tabPickerPaneID); oldPV != nil { |
| 1288 | - | if parent := a.tabPickerWidget.GetParent(); parent == oldPV.Overlay() { | 1255 | + | if parent := bw.tabPickerWidget.GetParent(); parent == oldPV.Overlay() { |
| 1289 | - | oldPV.RemoveOverlayWidget(a.tabPickerWidget) | 1256 | + | oldPV.RemoveOverlayWidget(bw.tabPickerWidget) |
| 1290 | } else if parent != nil { | 1257 | } else if parent != nil { | ||
| 1291 | - | a.tabPickerWidget.Unparent() | 1258 | + | bw.tabPickerWidget.Unparent() |
| 1292 | } | 1259 | } | ||
| 1293 | break | 1260 | break | ||
| 1294 | } | 1261 | } | ||
| @@ -1296,20 +1263,21 @@ func (a *App) attachTabPickerToActivePane() { | |||||
| 1296 | } | 1263 | } | ||
| 1297 | 1264 | ||||
| 1298 | // Ensure the widget can be reparented. | 1265 | // Ensure the widget can be reparented. | ||
| 1299 | - | if parent := a.tabPickerWidget.GetParent(); parent != nil { | 1266 | + | if parent := bw.tabPickerWidget.GetParent(); parent != nil { |
| 1300 | - | a.tabPickerWidget.Unparent() | 1267 | + | bw.tabPickerWidget.Unparent() |
| 1301 | } | 1268 | } | ||
| 1302 | 1269 | ||||
| 1303 | - | a.tabPicker.SetParentOverlay(pv.Overlay()) | 1270 | + | bw.tabPicker.SetParentOverlay(pv.Overlay()) |
| 1304 | - | pv.AddOverlayWidget(a.tabPickerWidget) | 1271 | + | pv.AddOverlayWidget(bw.tabPickerWidget) |
| 1305 | - | a.tabPickerPaneID = activePaneID | 1272 | + | bw.tabPickerPaneID = activePaneID |
| 1306 | } | 1273 | } | ||
| 1307 | 1274 | ||||
| 1308 | func (a *App) HandleMovePaneToTab(ctx context.Context) error { | 1275 | func (a *App) HandleMovePaneToTab(ctx context.Context) error { | ||
| 1309 | if a.movePaneToTabUC == nil { | 1276 | if a.movePaneToTabUC == nil { | ||
| 1310 | return nil | 1277 | return nil | ||
| 1311 | } | 1278 | } | ||
| 1312 | - | if a.tabPicker == nil { | 1279 | + | bw := a.lastFocusedBrowserWindow() |
| 1280 | + | if bw == nil || bw.tabPicker == nil { | |||
| 1313 | return nil | 1281 | return nil | ||
| 1314 | } | 1282 | } | ||
| 1315 | 1283 | ||||
| @@ -1341,7 +1309,7 @@ func (a *App) HandleMovePaneToTab(ctx context.Context) error { | |||||
| 1341 | items = append(items, component.TabPickerItem{IsNew: true, Index: -1}) | 1309 | items = append(items, component.TabPickerItem{IsNew: true, Index: -1}) | ||
| 1342 | 1310 | ||||
| 1343 | a.attachTabPickerToActivePane() | 1311 | a.attachTabPickerToActivePane() | ||
| 1344 | - | a.tabPicker.Show(ctx, items) | 1312 | + | bw.tabPicker.Show(ctx, items) |
| 1345 | return nil | 1313 | return nil | ||
| 1346 | } | 1314 | } | ||
| 1347 | 1315 | ||||
| @@ -1387,6 +1355,13 @@ func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID) | |||||
| 1387 | return nil | 1355 | return nil | ||
| 1388 | } | 1356 | } | ||
| 1389 | 1357 | ||||
| 1358 | + | func (a *App) moveActivePaneToTabFromBrowserWindow(ctx context.Context, bw *browserWindow, targetTabID entity.TabID) error { | |||
| 1359 | + | if bw != nil { | |||
| 1360 | + | a.activateBrowserWindow(bw) | |||
| 1361 | + | } | |||
| 1362 | + | return a.MoveActivePaneToTab(ctx, targetTabID) | |||
| 1363 | + | } | |||
| 1364 | + | ||||
| 1390 | func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePaneToTabInput, *entity.Tab) { | 1365 | func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePaneToTabInput, *entity.Tab) { | ||
| 1391 | if a.movePaneToTabUC == nil { | 1366 | if a.movePaneToTabUC == nil { | ||
| 1392 | return nil, nil | 1367 | return nil, nil | ||
| @@ -1518,42 +1493,6 @@ func (a *App) updateTabBarVisibility(ctx context.Context) { | |||||
| 1518 | } | 1493 | } | ||
| 1519 | } | 1494 | } | ||
| 1520 | 1495 | ||||
| 1521 | - | func (a *App) initTabPicker(ctx context.Context) { | |||
| 1522 | - | log := logging.FromContext(ctx) | |||
| 1523 | - | ||||
| 1524 | - | if a.deps == nil || a.deps.Config == nil { | |||
| 1525 | - | log.Debug().Msg("deps/config not available, skipping tab picker") | |||
| 1526 | - | return | |||
| 1527 | - | } | |||
| 1528 | - | ||||
| 1529 | - | a.tabPicker = component.NewTabPicker(ctx, component.TabPickerConfig{ | |||
| 1530 | - | UIScale: a.deps.Config.DefaultUIScale, | |||
| 1531 | - | OnClose: func() { | |||
| 1532 | - | log.Debug().Msg("tab picker closed") | |||
| 1533 | - | }, | |||
| 1534 | - | OnSelect: func(item component.TabPickerItem) { | |||
| 1535 | - | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 1536 | - | var targetID entity.TabID | |||
| 1537 | - | if !item.IsNew { | |||
| 1538 | - | targetID = item.TabID | |||
| 1539 | - | } | |||
| 1540 | - | if err := a.MoveActivePaneToTab(ctx, targetID); err != nil { | |||
| 1541 | - | log.Warn().Err(err).Msg("move pane to tab failed") | |||
| 1542 | - | } | |||
| 1543 | - | return false | |||
| 1544 | - | }) | |||
| 1545 | - | glib.IdleAdd(&cb, 0) | |||
| 1546 | - | }, | |||
| 1547 | - | }) | |||
| 1548 | - | ||||
| 1549 | - | if a.tabPicker == nil { | |||
| 1550 | - | log.Warn().Msg("failed to create tab picker") | |||
| 1551 | - | return | |||
| 1552 | - | } | |||
| 1553 | - | ||||
| 1554 | - | log.Debug().Msg("tab picker initialized") | |||
| 1555 | - | } | |||
| 1556 | - | ||||
| 1557 | func (a *App) initSnapshotService(ctx context.Context) { | 1496 | func (a *App) initSnapshotService(ctx context.Context) { | ||
| 1558 | log := logging.FromContext(ctx) | 1497 | log := logging.FromContext(ctx) | ||
| 1559 | 1498 | ||||
| @@ -1593,7 +1532,9 @@ func (a *App) initUpdateCoordinator(ctx context.Context) { | |||||
| 1593 | a.updateCoord = coordinator.NewUpdateCoordinator( | 1532 | a.updateCoord = coordinator.NewUpdateCoordinator( | ||
| 1594 | a.deps.CheckUpdateUC, | 1533 | a.deps.CheckUpdateUC, | ||
| 1595 | a.deps.ApplyUpdateUC, | 1534 | a.deps.ApplyUpdateUC, | ||
| 1596 | - | a.appToaster, | 1535 | + | func(ctx context.Context, msg string, level component.ToastLevel) { |
| 1536 | + | a.showToastOnLastFocusedBrowserWindow(ctx, msg, level) | |||
| 1537 | + | }, | |||
| 1597 | a.deps.Config.Update.EnableOnStartup, | 1538 | a.deps.Config.Update.EnableOnStartup, | ||
| 1598 | a.deps.Config.Update.AutoDownload, | 1539 | a.deps.Config.Update.AutoDownload, | ||
| 1599 | ) | 1540 | ) | ||
| @@ -1632,15 +1573,11 @@ func (a *App) createInitialTab(ctx context.Context) { | |||||
| 1632 | if err := a.restoreSession(ctx, entity.SessionID(a.deps.RestoreSessionID)); err != nil { | 1573 | if err := a.restoreSession(ctx, entity.SessionID(a.deps.RestoreSessionID)); err != nil { | ||
| 1633 | log.Error().Err(err).Str("session_id", a.deps.RestoreSessionID).Msg("failed to restore session, creating default tab") | 1574 | log.Error().Err(err).Str("session_id", a.deps.RestoreSessionID).Msg("failed to restore session, creating default tab") | ||
| 1634 | // Show error toast and fall through to create default tab | 1575 | // Show error toast and fall through to create default tab | ||
| 1635 | - | if a.appToaster != nil { | 1576 | + | a.showToastOnLastFocusedBrowserWindow(ctx, "Session restore failed", component.ToastWarning) |
| 1636 | - | a.appToaster.Show(ctx, "Session restore failed", component.ToastWarning) | |||
| 1637 | - | } | |||
| 1638 | } else { | 1577 | } else { | ||
| 1639 | log.Info().Str("session_id", a.deps.RestoreSessionID).Msg("session restored") | 1578 | log.Info().Str("session_id", a.deps.RestoreSessionID).Msg("session restored") | ||
| 1640 | // Show success toast for session restoration | 1579 | // Show success toast for session restoration | ||
| 1641 | - | if a.appToaster != nil { | 1580 | + | a.showToastOnLastFocusedBrowserWindow(ctx, "Session restored", component.ToastSuccess) |
| 1642 | - | a.appToaster.Show(ctx, "Session restored", component.ToastSuccess) | |||
| 1643 | - | } | |||
| 1644 | return | 1581 | return | ||
| 1645 | } | 1582 | } | ||
| 1646 | } | 1583 | } | ||
| @@ -1739,13 +1676,11 @@ func (a *App) showCrashReportToast(ctx context.Context, paths []string) { | |||||
| 1739 | log.Warn().Int("count", len(paths)).Msg("unexpected-close reports available") | 1676 | log.Warn().Int("count", len(paths)).Msg("unexpected-close reports available") | ||
| 1740 | 1677 | ||||
| 1741 | cb := glib.SourceFunc(func(_ uintptr) bool { | 1678 | cb := glib.SourceFunc(func(_ uintptr) bool { | ||
| 1742 | - | if a.appToaster != nil { | 1679 | + | msg := fmt.Sprintf("Detected %d unexpected close report(s). Run: dumber crashes issue latest", len(paths)) |
| 1743 | - | msg := fmt.Sprintf("Detected %d unexpected close report(s). Run: dumber crashes issue latest", len(paths)) | 1680 | + | a.showToastOnLastFocusedBrowserWindow(ctx, msg, component.ToastWarning, |
| 1744 | - | a.appToaster.Show(ctx, msg, component.ToastWarning, | 1681 | + | component.WithDuration(crashReportToastDurationMs), |
| 1745 | - | component.WithDuration(crashReportToastDurationMs), | 1682 | + | component.WithPosition(component.ToastPositionBottomRight), |
| 1746 | - | component.WithPosition(component.ToastPositionBottomRight), | 1683 | + | ) |
| 1747 | - | ) | |||
| 1748 | - | } | |||
| 1749 | return false | 1684 | return false | ||
| 1750 | }) | 1685 | }) | ||
| 1751 | glib.IdleAdd(&cb, 0) | 1686 | glib.IdleAdd(&cb, 0) | ||
| @@ -1919,17 +1854,8 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 1919 | } | 1854 | } | ||
| 1920 | 1855 | ||||
| 1921 | // Set fullscreen callback to hide/show tab bar (after tabCoord is initialized) | 1856 | // Set fullscreen callback to hide/show tab bar (after tabCoord is initialized) | ||
| 1922 | - | a.contentCoord.SetOnFullscreenChanged(func(entering bool) { | 1857 | + | a.contentCoord.SetOnFullscreenChanged(func(paneID entity.PaneID, entering bool) { |
| 1923 | - | if a.mainWindow == nil || a.mainWindow.TabBar() == nil { | 1858 | + | a.handlePaneFullscreenChanged(paneID, entering) |
| 1924 | - | return | |||
| 1925 | - | } | |||
| 1926 | - | if entering { | |||
| 1927 | - | // Hide tab bar when entering fullscreen video | |||
| 1928 | - | a.mainWindow.TabBar().SetVisible(false) | |||
| 1929 | - | } else { | |||
| 1930 | - | // Restore tab bar visibility based on normal logic | |||
| 1931 | - | a.tabCoord.UpdateBarVisibility(ctx) | |||
| 1932 | - | } | |||
| 1933 | }) | 1859 | }) | ||
| 1934 | 1860 | ||||
| 1935 | // 3. Workspace Coordinator | 1861 | // 3. Workspace Coordinator | ||
| @@ -2002,8 +1928,8 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 2002 | }) | 1928 | }) | ||
| 2003 | 1929 | ||||
| 2004 | // Wire window title updates when active pane's title changes | 1930 | // Wire window title updates when active pane's title changes | ||
| 2005 | - | a.contentCoord.SetOnWindowTitleChanged(func(title string) { | 1931 | + | a.contentCoord.SetOnWindowTitleChanged(func(paneID entity.PaneID, title string) { |
| 2006 | - | a.updateWindowTitle(title, a.browserWindowForTab(a.tabs.ActiveTabID)) | 1932 | + | a.handlePaneWindowTitleChanged(paneID, title) |
| 2007 | }) | 1933 | }) | ||
| 2008 | 1934 | ||||
| 2009 | // Wire pane URI updates for session snapshots (searches all tabs) | 1935 | // Wire pane URI updates for session snapshots (searches all tabs) | ||
| @@ -2048,6 +1974,7 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 2048 | a.deps.ZoomUC, | 1974 | a.deps.ZoomUC, | ||
| 2049 | a.deps.CopyURLUC, | 1975 | a.deps.CopyURLUC, | ||
| 2050 | a.deps.Config.Workspace.NewPaneURL, | 1976 | a.deps.Config.Workspace.NewPaneURL, | ||
| 1977 | + | a.contentCoord.ActivePaneID, | |||
| 2051 | ) | 1978 | ) | ||
| 2052 | a.wireKeyboardActions() | 1979 | a.wireKeyboardActions() | ||
| 2053 | for _, bw := range a.browserWindows { | 1980 | for _, bw := range a.browserWindows { | ||
| @@ -2075,10 +2002,16 @@ func (a *App) wireKeyboardActions() { | |||||
| 2075 | a.CloseFindBar(ctx) | 2002 | a.CloseFindBar(ctx) | ||
| 2076 | return nil | 2003 | return nil | ||
| 2077 | }) | 2004 | }) | ||
| 2078 | - | a.kbDispatcher.SetOnMovePaneToTab(func(ctx context.Context) error { | 2005 | + | a.kbDispatcher.SetOnMovePaneToTab(func(ctx context.Context, paneID entity.PaneID) error { |
| 2006 | + | if bw := a.ownerOrLastFocusedBrowserWindow("", paneID); bw != nil { | |||
| 2007 | + | a.activateBrowserWindow(bw) | |||
| 2008 | + | } | |||
| 2079 | return a.HandleMovePaneToTab(ctx) | 2009 | return a.HandleMovePaneToTab(ctx) | ||
| 2080 | }) | 2010 | }) | ||
| 2081 | - | a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context) error { | 2011 | + | a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context, paneID entity.PaneID) error { |
| 2012 | + | if bw := a.ownerOrLastFocusedBrowserWindow("", paneID); bw != nil { | |||
| 2013 | + | a.activateBrowserWindow(bw) | |||
| 2014 | + | } | |||
| 2082 | return a.HandleMovePaneToNextTab(ctx) | 2015 | return a.HandleMovePaneToNextTab(ctx) | ||
| 2083 | }) | 2016 | }) | ||
| 2084 | a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error { | 2017 | a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error { | ||
| @@ -2304,8 +2237,12 @@ func (a *App) updateWindowTitle(pageTitle string, target *browserWindow) { | |||||
| 2304 | // updateWindowTitleFromActivePane updates the window title based on the current active pane. | 2237 | // updateWindowTitleFromActivePane updates the window title based on the current active pane. | ||
| 2305 | func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) { | 2238 | func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) { | ||
| 2306 | var ws *entity.Workspace | 2239 | var ws *entity.Workspace | ||
| 2307 | - | if tab := a.tabs.Find(tabID); tab != nil { | 2240 | + | if a.tabs != nil { |
| 2308 | - | ws = tab.Workspace | 2241 | + | if tab := a.tabs.Find(tabID); tab != nil { |
| 2242 | + | ws = tab.Workspace | |||
| 2243 | + | } else { | |||
| 2244 | + | ws = a.activeWorkspace() | |||
| 2245 | + | } | |||
| 2309 | } else { | 2246 | } else { | ||
| 2310 | ws = a.activeWorkspace() | 2247 | ws = a.activeWorkspace() | ||
| 2311 | } | 2248 | } | ||
| @@ -2314,7 +2251,7 @@ func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) { | |||||
| 2314 | return | 2251 | return | ||
| 2315 | } | 2252 | } | ||
| 2316 | title := a.contentCoord.GetTitle(ws.ActivePaneID) | 2253 | title := a.contentCoord.GetTitle(ws.ActivePaneID) | ||
| 2317 | - | a.updateWindowTitle(title, a.browserWindowForTab(tabID)) | 2254 | + | a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, "")) |
| 2318 | } | 2255 | } | ||
| 2319 | 2256 | ||||
| 2320 | // handleModeChange is called when the input mode changes. | 2257 | // handleModeChange is called when the input mode changes. | ||
| @@ -2331,8 +2268,8 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) { | |||||
| 2331 | 2268 | ||||
| 2332 | // Update global border overlay visibility based on mode. | 2269 | // Update global border overlay visibility based on mode. | ||
| 2333 | // Note: resize mode border is handled per-pane (stack container), not via global overlay. | 2270 | // Note: resize mode border is handled per-pane (stack container), not via global overlay. | ||
| 2334 | - | if a.borderMgr != nil { | 2271 | + | if bw := a.lastFocusedBrowserWindow(); bw != nil && bw.borderMgr != nil { |
| 2335 | - | a.borderMgr.OnModeChange(ctx, from, to) | 2272 | + | bw.borderMgr.OnModeChange(ctx, from, to) |
| 2336 | } | 2273 | } | ||
| 2337 | 2274 | ||||
| 2338 | // Show/hide mode indicator toaster based on config. | 2275 | // Show/hide mode indicator toaster based on config. | ||
| @@ -2341,25 +2278,26 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) { | |||||
| 2341 | 2278 | ||||
| 2342 | // updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config. | 2279 | // updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config. | ||
| 2343 | func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) { | 2280 | func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) { | ||
| 2344 | - | if a.modeToaster == nil { | 2281 | + | bw := a.lastFocusedBrowserWindow() |
| 2282 | + | if bw == nil || bw.modeToaster == nil { | |||
| 2345 | return | 2283 | return | ||
| 2346 | } | 2284 | } | ||
| 2347 | 2285 | ||||
| 2348 | // Check if mode indicator toaster is enabled in config. | 2286 | // Check if mode indicator toaster is enabled in config. | ||
| 2349 | if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.Styling.ModeIndicatorToasterEnabled { | 2287 | if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.Styling.ModeIndicatorToasterEnabled { | ||
| 2350 | - | a.modeToaster.Hide() | 2288 | + | bw.modeToaster.Hide() |
| 2351 | return | 2289 | return | ||
| 2352 | } | 2290 | } | ||
| 2353 | 2291 | ||||
| 2354 | if mode == input.ModeNormal { | 2292 | if mode == input.ModeNormal { | ||
| 2355 | - | a.modeToaster.Hide() | 2293 | + | bw.modeToaster.Hide() |
| 2356 | return | 2294 | return | ||
| 2357 | } | 2295 | } | ||
| 2358 | 2296 | ||||
| 2359 | // Show persistent toaster at bottom-left with mode display name. | 2297 | // Show persistent toaster at bottom-left with mode display name. | ||
| 2360 | // Mode class is applied atomically with Show() to avoid visual flicker. | 2298 | // Mode class is applied atomically with Show() to avoid visual flicker. | ||
| 2361 | modeClass := getModeToastClass(mode) | 2299 | modeClass := getModeToastClass(mode) | ||
| 2362 | - | a.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo, | 2300 | + | bw.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo, |
| 2363 | component.WithDuration(0), // Persistent until mode exits. | 2301 | component.WithDuration(0), // Persistent until mode exits. | ||
| 2364 | component.WithPosition(component.ToastPositionBottomLeft), | 2302 | component.WithPosition(component.ToastPositionBottomLeft), | ||
| 2365 | component.WithModeClass(modeClass), | 2303 | component.WithModeClass(modeClass), | ||
| @@ -2512,6 +2450,9 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity. | |||||
| 2512 | 2450 | ||||
| 2513 | // activeWorkspace returns the workspace of the active tab. | 2451 | // activeWorkspace returns the workspace of the active tab. | ||
| 2514 | func (a *App) activeWorkspace() *entity.Workspace { | 2452 | func (a *App) activeWorkspace() *entity.Workspace { | ||
| 2453 | + | if a.tabs == nil { | |||
| 2454 | + | return nil | |||
| 2455 | + | } | |||
| 2515 | activeTab := a.tabs.ActiveTab() | 2456 | activeTab := a.tabs.ActiveTab() | ||
| 2516 | if activeTab == nil { | 2457 | if activeTab == nil { | ||
| 2517 | return nil | 2458 | return nil | ||
| @@ -3068,9 +3009,7 @@ func (a *App) showFloatingOmnibox(ctx context.Context, session *floatingWorkspac | |||||
| 3068 | if session.omnibox == nil { | 3009 | if session.omnibox == nil { | ||
| 3069 | cfg := a.omniboxCfg | 3010 | cfg := a.omniboxCfg | ||
| 3070 | cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) { | 3011 | cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) { | ||
| 3071 | - | if a.appToaster != nil { | 3012 | + | a.showToastOnLastFocusedBrowserWindow(toastCtx, message, level) |
| 3072 | - | a.appToaster.Show(toastCtx, message, level) | |||
| 3073 | - | } | |||
| 3074 | } | 3013 | } | ||
| 3075 | 3014 | ||||
| 3076 | omnibox := component.NewOmnibox(ctx, cfg) | 3015 | omnibox := component.NewOmnibox(ctx, cfg) | ||
| @@ -3594,21 +3533,15 @@ func (a *App) showFilterStatus(ctx context.Context, status port.FilterStatus) { | |||||
| 3594 | 3533 | ||||
| 3595 | switch status.State { | 3534 | switch status.State { | ||
| 3596 | case port.FilterStateLoading: | 3535 | case port.FilterStateLoading: | ||
| 3597 | - | if a.appToaster != nil { | 3536 | + | a.showToastOnLastFocusedBrowserWindow(ctx, status.Message, component.ToastInfo) |
| 3598 | - | a.appToaster.Show(ctx, status.Message, component.ToastInfo) | |||
| 3599 | - | } | |||
| 3600 | case port.FilterStateActive: | 3537 | case port.FilterStateActive: | ||
| 3601 | // Apply filters to existing webviews that were created before filters loaded | 3538 | // Apply filters to existing webviews that were created before filters loaded | ||
| 3602 | if a.contentCoord != nil && a.deps.FilterManager != nil { | 3539 | if a.contentCoord != nil && a.deps.FilterManager != nil { | ||
| 3603 | a.contentCoord.ApplyFiltersToAll(ctx) | 3540 | a.contentCoord.ApplyFiltersToAll(ctx) | ||
| 3604 | log.Debug().Msg("applied filters to all existing webviews") | 3541 | log.Debug().Msg("applied filters to all existing webviews") | ||
| 3605 | } | 3542 | } | ||
| 3606 | - | if a.appToaster != nil { | 3543 | + | a.showToastOnLastFocusedBrowserWindow(ctx, fmt.Sprintf("Ad blocker ready (%s)", status.Version), component.ToastInfo) |
| 3607 | - | a.appToaster.Show(ctx, fmt.Sprintf("Ad blocker ready (%s)", status.Version), component.ToastInfo) | |||
| 3608 | - | } | |||
| 3609 | case port.FilterStateError: | 3544 | case port.FilterStateError: | ||
| 3610 | - | if a.appToaster != nil { | 3545 | + | a.showToastOnLastFocusedBrowserWindow(ctx, "Filter load failed: "+status.Message, component.ToastError) |
| 3611 | - | a.appToaster.Show(ctx, "Filter load failed: "+status.Message, component.ToastError) | |||
| 3612 | - | } | |||
| 3613 | } | 3546 | } | ||
| 3614 | } | 3547 | } | ||
M internal/ui/app_browser_launch_test.go +515 -0
| @@ -8,11 +8,17 @@ import ( | |||
| 8 | 8 | "unsafe" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/dumber/internal/application/port" | |
| 11 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 11 | 12 | "github.com/bnema/dumber/internal/application/usecase" | |
| 12 | 13 | "github.com/bnema/dumber/internal/domain/entity" | |
| 14 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 13 | 15 | "github.com/bnema/dumber/internal/ui/component" | |
| 14 | 16 | "github.com/bnema/dumber/internal/ui/coordinator" | |
| 17 | + | contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content" | |
| 18 | + | "github.com/bnema/dumber/internal/ui/dispatcher" | |
| 15 | 19 | "github.com/bnema/dumber/internal/ui/input" | |
| 20 | + | "github.com/bnema/dumber/internal/ui/layout" | |
| 21 | + | layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks" | |
| 16 | 22 | "github.com/bnema/dumber/internal/ui/window" | |
| 17 | 23 | "github.com/bnema/puregotk/v4/gio" | |
| 18 | 24 | "github.com/bnema/puregotk/v4/gtk" | |
| @@ -94,6 +100,86 @@ func windowTabBarActiveID(t *testing.T, mw *window.MainWindow) entity.TabID { | |||
| 94 | 100 | return mw.TabBar().ActiveTabID() | |
| 95 | 101 | } | |
| 96 | 102 | ||
| 103 | + | func windowTitle(t *testing.T, mw *window.MainWindow) string { | |
| 104 | + | t.Helper() | |
| 105 | + | if mw == nil || mw.Window() == nil { | |
| 106 | + | return "" | |
| 107 | + | } | |
| 108 | + | return mw.Window().GetTitle() | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func windowTabBarVisible(t *testing.T, mw *window.MainWindow) bool { | |
| 112 | + | t.Helper() | |
| 113 | + | if mw == nil || mw.TabBar() == nil || mw.TabBar().Box() == nil { | |
| 114 | + | return false | |
| 115 | + | } | |
| 116 | + | return mw.TabBar().Box().GetVisible() | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func newTestShellToaster(t *testing.T) (*component.Toaster, *layoutmocks.MockBoxWidget, *layoutmocks.MockLabelWidget) { | |
| 120 | + | t.Helper() | |
| 121 | + | ||
| 122 | + | factory := layoutmocks.NewMockWidgetFactory(t) | |
| 123 | + | box := layoutmocks.NewMockBoxWidget(t) | |
| 124 | + | label := layoutmocks.NewMockLabelWidget(t) | |
| 125 | + | ||
| 126 | + | factory.EXPECT().NewBox(layout.OrientationHorizontal, 0).Return(box).Once() | |
| 127 | + | factory.EXPECT().NewLabel("").Return(label).Once() | |
| 128 | + | box.EXPECT().AddCssClass("toast").Once() | |
| 129 | + | box.EXPECT().AddCssClass("toast-info").Once() | |
| 130 | + | box.EXPECT().SetHalign(gtk.AlignStartValue).Twice() | |
| 131 | + | box.EXPECT().SetValign(gtk.AlignStartValue).Twice() | |
| 132 | + | box.EXPECT().SetHexpand(false).Once() | |
| 133 | + | box.EXPECT().SetVexpand(false).Once() | |
| 134 | + | box.EXPECT().SetCanTarget(false).Once() | |
| 135 | + | box.EXPECT().SetCanFocus(false).Once() | |
| 136 | + | box.EXPECT().SetVisible(false).Once() | |
| 137 | + | box.EXPECT().Append(label).Once() | |
| 138 | + | label.EXPECT().SetCanTarget(false).Once() | |
| 139 | + | label.EXPECT().SetCanFocus(false).Once() | |
| 140 | + | ||
| 141 | + | return component.NewToaster(factory), box, label | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) { | |
| 145 | + | ctx := context.Background() | |
| 146 | + | toaster, box, label := newTestShellToaster(t) | |
| 147 | + | box.EXPECT().SetVisible(true).Once() | |
| 148 | + | label.EXPECT().SetText("Ad blocker loading").Once() | |
| 149 | + | ||
| 150 | + | bw := &browserWindow{id: "window-1", appToaster: toaster} | |
| 151 | + | app := &App{ | |
| 152 | + | browserWindows: map[string]*browserWindow{bw.id: bw}, | |
| 153 | + | lastFocusedWindowID: bw.id, | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | app.showFilterStatus(ctx, port.FilterStatus{State: port.FilterStateLoading, Message: "Ad blocker loading"}) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func TestApp_CheckConfigMigrationUsesLastFocusedBrowserWindowToaster(t *testing.T) { | |
| 160 | + | ctx := context.Background() | |
| 161 | + | toaster, box, label := newTestShellToaster(t) | |
| 162 | + | box.EXPECT().SetVisible(true).Once() | |
| 163 | + | label.EXPECT().SetText("Config has 1 new settings. Run 'dumber config migrate'").Once() | |
| 164 | + | ||
| 165 | + | migrator := portmocks.NewMockConfigMigrator(t) | |
| 166 | + | migrator.EXPECT().CheckMigration().Return(&port.MigrationResult{MissingKeys: []string{"update.notify_on_new_settings"}}, nil).Once() | |
| 167 | + | ||
| 168 | + | bw := &browserWindow{id: "window-1", appToaster: toaster} | |
| 169 | + | app := &App{ | |
| 170 | + | deps: &Dependencies{ | |
| 171 | + | Config: &config.Config{ | |
| 172 | + | Update: config.UpdateConfig{NotifyOnNewSettings: true}, | |
| 173 | + | }, | |
| 174 | + | ConfigMigrator: migrator, | |
| 175 | + | }, | |
| 176 | + | browserWindows: map[string]*browserWindow{bw.id: bw}, | |
| 177 | + | lastFocusedWindowID: bw.id, | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | app.checkConfigMigration(ctx) | |
| 181 | + | } | |
| 182 | + | ||
| 97 | 183 | func TestApp_FinalizeActivationStartsBrowserLaunchRelayOnceAndClosesOnShutdown(t *testing.T) { | |
| 98 | 184 | relay := &testBrowserLaunchRelay{closer: &testCloser{}} | |
| 99 | 185 | deps := &Dependencies{} | |
| @@ -317,3 +403,432 @@ func TestApp_BrowserWindowForPaneFindsOwningWindow(t *testing.T) { | |||
| 317 | 403 | t.Fatalf("browserWindowForPane = %p, want %p", got, second) | |
| 318 | 404 | } | |
| 319 | 405 | } | |
| 406 | + | ||
| 407 | + | func TestApp_OwnerOrLastFocusedBrowserWindowPrefersPaneOwner(t *testing.T) { | |
| 408 | + | tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1"))) | |
| 409 | + | tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2"))) | |
| 410 | + | tabs := entity.NewTabList() | |
| 411 | + | tabs.Add(tab1) | |
| 412 | + | tabs.Add(tab2) | |
| 413 | + | first := &browserWindow{id: "window-1"} | |
| 414 | + | second := &browserWindow{id: "window-2"} | |
| 415 | + | app := &App{ | |
| 416 | + | tabs: tabs, | |
| 417 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |
| 418 | + | windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second}, | |
| 419 | + | lastFocusedWindowID: first.id, | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | got := app.ownerOrLastFocusedBrowserWindow("", entity.PaneID("pane-2")) | |
| 423 | + | ||
| 424 | + | if got != second { | |
| 425 | + | t.Fatalf("ownerOrLastFocusedBrowserWindow = %p, want %p", got, second) | |
| 426 | + | } | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | func TestApp_HandlePaneWindowTitleChangedTargetsOwningWindow(t *testing.T) { | |
| 430 | + | EnsureAdwaitaInitialized() | |
| 431 | + | appID := AppID | |
| 432 | + | gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue) | |
| 433 | + | if gtkApp == nil { | |
| 434 | + | t.Fatal("gtk application creation failed") | |
| 435 | + | } | |
| 436 | + | defer gtkApp.Unref() | |
| 437 | + | ||
| 438 | + | firstMainWindow, err := window.New(context.Background(), gtkApp, "top") | |
| 439 | + | if err != nil { | |
| 440 | + | t.Fatalf("first window creation failed: %v", err) | |
| 441 | + | } | |
| 442 | + | secondMainWindow, err := window.New(context.Background(), gtkApp, "top") | |
| 443 | + | if err != nil { | |
| 444 | + | t.Fatalf("second window creation failed: %v", err) | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1"))) | |
| 448 | + | tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2"))) | |
| 449 | + | tabs := entity.NewTabList() | |
| 450 | + | tabs.Add(tab1) | |
| 451 | + | tabs.Add(tab2) | |
| 452 | + | first := &browserWindow{id: "window-1", mainWindow: firstMainWindow} | |
| 453 | + | second := &browserWindow{id: "window-2", mainWindow: secondMainWindow} | |
| 454 | + | app := &App{ | |
| 455 | + | tabs: tabs, | |
| 456 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |
| 457 | + | windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second}, | |
| 458 | + | lastFocusedWindowID: first.id, | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | app.handlePaneWindowTitleChanged(entity.PaneID("pane-2"), "Pane Two") | |
| 462 | + | ||
| 463 | + | if got := windowTitle(t, firstMainWindow); got != "Dumber" { | |
| 464 | + | t.Fatalf("first window title = %q, want %q", got, "Dumber") | |
| 465 | + | } | |
| 466 | + | if got := windowTitle(t, secondMainWindow); got != "Pane Two - Dumber" { | |
| 467 | + | t.Fatalf("second window title = %q, want %q", got, "Pane Two - Dumber") | |
| 468 | + | } | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | func TestApp_ActivateBrowserWindowResyncsTitleFromBackgroundPane(t *testing.T) { | |
| 472 | + | EnsureAdwaitaInitialized() | |
| 473 | + | appID := AppID | |
| 474 | + | gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue) | |
| 475 | + | if gtkApp == nil { | |
| 476 | + | t.Fatal("gtk application creation failed") | |
| 477 | + | } | |
| 478 | + | defer gtkApp.Unref() | |
| 479 | + | ||
| 480 | + | firstMainWindow, err := window.New(context.Background(), gtkApp, "top") | |
| 481 | + | if err != nil { | |
| 482 | + | t.Fatalf("first window creation failed: %v", err) | |
| 483 | + | } | |
| 484 | + | secondMainWindow, err := window.New(context.Background(), gtkApp, "top") | |
| 485 | + | if err != nil { | |
| 486 | + | t.Fatalf("second window creation failed: %v", err) | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1"))) | |
| 490 | + | tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2"))) | |
| 491 | + | tabs := entity.NewTabList() | |
| 492 | + | tabs.Add(tab1) | |
| 493 | + | tabs.Add(tab2) | |
| 494 | + | first := &browserWindow{id: "window-1", mainWindow: firstMainWindow, activeTabID: tab1.ID} | |
| 495 | + | second := &browserWindow{id: "window-2", mainWindow: secondMainWindow, activeTabID: tab2.ID} | |
| 496 | + | contentCoord := &contentcoord.Coordinator{} | |
| 497 | + | contentTitles := reflect.ValueOf(contentCoord).Elem().FieldByName("paneTitles") | |
| 498 | + | reflect.NewAt(contentTitles.Type(), unsafe.Pointer(contentTitles.UnsafeAddr())).Elem().Set(reflect.ValueOf(map[entity.PaneID]string{tab2.Workspace.ActivePaneID: "Pane Two"})) | |
| 499 | + | app := &App{ | |
| 500 | + | tabs: tabs, | |
| 501 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |
| 502 | + | windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second}, | |
| 503 | + | contentCoord: contentCoord, | |
| 504 | + | } | |
| 505 | + | ||
| 506 | + | app.handlePaneWindowTitleChanged(tab2.Workspace.ActivePaneID, "Pane Two") | |
| 507 | + | secondMainWindow.SetTitle("Dumber") | |
| 508 | + | ||
| 509 | + | app.activateBrowserWindow(second) | |
| 510 | + | ||
| 511 | + | if got := windowTitle(t, secondMainWindow); got != "Pane Two - Dumber" { | |
| 512 | + | t.Fatalf("second window title = %q, want %q", got, "Pane Two - Dumber") | |
| 513 | + | } | |
| 514 | + | if got := windowTitle(t, firstMainWindow); got != "Dumber" { | |
| 515 | + | t.Fatalf("first window title = %q, want %q", got, "Dumber") | |
| 516 | + | } | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | func TestApp_HandlePaneFullscreenChangedTargetsOwningWindow(t *testing.T) { | |
| 520 | + | EnsureAdwaitaInitialized() | |
| 521 | + | appID := AppID | |
| 522 | + | gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue) | |
| 523 | + | if gtkApp == nil { | |
| 524 | + | t.Fatal("gtk application creation failed") | |
| 525 | + | } | |
| 526 | + | defer gtkApp.Unref() | |
| 527 | + | ||
| 528 | + | firstMainWindow, err := window.New(context.Background(), gtkApp, "top") | |
| 529 | + | if err != nil { | |
| 530 | + | t.Fatalf("first window creation failed: %v", err) | |
| 531 | + | } | |
| 532 | + | secondMainWindow, err := window.New(context.Background(), gtkApp, "top") | |
| 533 | + | if err != nil { | |
| 534 | + | t.Fatalf("second window creation failed: %v", err) | |
| 535 | + | } | |
| 536 | + | ||
| 537 | + | tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1"))) | |
| 538 | + | tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2"))) | |
| 539 | + | tabs := entity.NewTabList() | |
| 540 | + | tabs.Add(tab1) | |
| 541 | + | tabs.Add(tab2) | |
| 542 | + | secondMainWindow.TabBar().AddTab(tab1) | |
| 543 | + | secondMainWindow.TabBar().AddTab(tab2) | |
| 544 | + | first := &browserWindow{id: "window-1", mainWindow: firstMainWindow} | |
| 545 | + | second := &browserWindow{id: "window-2", mainWindow: secondMainWindow} | |
| 546 | + | app := &App{ | |
| 547 | + | tabs: tabs, | |
| 548 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |
| 549 | + | windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second}, | |
| 550 | + | lastFocusedWindowID: first.id, | |
| 551 | + | } | |
| 552 | + | ||
| 553 | + | app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), true) | |
| 554 | + | ||
| 555 | + | if windowTabBarVisible(t, firstMainWindow) != true { | |
| 556 | + | t.Fatalf("first window tab bar visibility changed unexpectedly") | |
| 557 | + | } | |
| 558 | + | if got := windowTabBarVisible(t, secondMainWindow); got { | |
| 559 | + | t.Fatalf("second window tab bar visible = %v, want false", got) | |
| 560 | + | } | |
| 561 | + | ||
| 562 | + | app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), false) | |
| 563 | + | ||
| 564 | + | if got := windowTabBarVisible(t, secondMainWindow); !got { | |
| 565 | + | t.Fatalf("second window tab bar visible = %v, want true", got) | |
| 566 | + | } | |
| 567 | + | } | |
| 568 | + | ||
| 569 | + | func TestApp_HandlePaneFullscreenChangedIgnoresUnresolvedPane(t *testing.T) { | |
| 570 | + | EnsureAdwaitaInitialized() | |
| 571 | + | appID := AppID | |
| 572 | + | gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue) | |
| 573 | + | if gtkApp == nil { | |
| 574 | + | t.Fatal("gtk application creation failed") | |
| 575 | + | } | |
| 576 | + | defer gtkApp.Unref() | |
| 577 | + | ||
| 578 | + | firstMainWindow, err := window.New(context.Background(), gtkApp, "top") | |
| 579 | + | if err != nil { | |
| 580 | + | t.Fatalf("first window creation failed: %v", err) | |
| 581 | + | } | |
| 582 | + | secondMainWindow, err := window.New(context.Background(), gtkApp, "top") | |
| 583 | + | if err != nil { | |
| 584 | + | t.Fatalf("second window creation failed: %v", err) | |
| 585 | + | } | |
| 586 | + | firstMainWindow.TabBar().SetVisible(true) | |
| 587 | + | secondMainWindow.TabBar().SetVisible(true) | |
| 588 | + | first := &browserWindow{id: "window-1", mainWindow: firstMainWindow} | |
| 589 | + | second := &browserWindow{id: "window-2", mainWindow: secondMainWindow} | |
| 590 | + | app := &App{ | |
| 591 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |
| 592 | + | lastFocusedWindowID: first.id, | |
| 593 | + | } | |
| 594 | + | ||
| 595 | + | app.handlePaneFullscreenChanged(entity.PaneID("missing-pane"), true) | |
| 596 | + | ||
| 597 | + | if got := windowTabBarVisible(t, firstMainWindow); !got { | |
| 598 | + | t.Fatalf("first window tab bar visible = %v, want true", got) | |
| 599 | + | } | |
| 600 | + | if got := windowTabBarVisible(t, secondMainWindow); !got { | |
| 601 | + | t.Fatalf("second window tab bar visible = %v, want true", got) | |
| 602 | + | } | |
| 603 | + | } | |
| 604 | + | ||
| 605 | + | func TestApp_HandleAccentKeyPressDelegatesToOwningWindow(t *testing.T) { | |
| 606 | + | first := newTestAccentUseCase(t, true) | |
| 607 | + | second := newTestAccentUseCase(t, false) | |
| 608 | + | firstWindow := &browserWindow{id: "window-1", insertAccentUC: first} | |
| 609 | + | secondWindow := &browserWindow{id: "window-2", insertAccentUC: second} | |
| 610 | + | app := &App{ | |
| 611 | + | browserWindows: map[string]*browserWindow{firstWindow.id: firstWindow, secondWindow.id: secondWindow}, | |
| 612 | + | lastFocusedWindowID: secondWindow.id, | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | if got := app.handleAccentKeyPress(context.Background(), uint('e'), 0); got { | |
| 616 | + | t.Fatalf("handleAccentKeyPress returned %v, want false from active shell handler", got) | |
| 617 | + | } | |
| 618 | + | if got := first.IsPickerVisible(); !got { | |
| 619 | + | t.Fatalf("first shell accent handler should remain visible") | |
| 620 | + | } | |
| 621 | + | if got := second.IsPickerVisible(); got { | |
| 622 | + | t.Fatalf("second shell accent handler should remain hidden") | |
| 623 | + | } | |
| 624 | + | } | |
| 625 | + | ||
| 626 | + | func TestApp_MoveActivePaneToTabFromBrowserWindowAnchorsOwningWindow(t *testing.T) { | |
| 627 | + | tabs := entity.NewTabList() | |
| 628 | + | tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1"))) | |
| 629 | + | tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2"))) | |
| 630 | + | tabs.Add(tab1) | |
| 631 | + | tabs.Add(tab2) | |
| 632 | + | first := &browserWindow{id: "window-1", activeTabID: tab1.ID} | |
| 633 | + | second := &browserWindow{id: "window-2", activeTabID: tab2.ID} | |
| 634 | + | app := &App{ | |
| 635 | + | tabs: tabs, | |
| 636 | + | movePaneToTabUC: usecase.NewMovePaneToTabUseCase(func() string { return "new-tab" }), | |
| 637 | + | deps: &Dependencies{Config: &config.Config{}}, | |
| 638 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |
| 639 | + | lastFocusedWindowID: second.id, | |
| 640 | + | } | |
| 641 | + | ||
| 642 | + | if err := app.moveActivePaneToTabFromBrowserWindow(context.Background(), first, ""); err != nil { | |
| 643 | + | t.Fatalf("moveActivePaneToTabFromBrowserWindow returned error: %v", err) | |
| 644 | + | } | |
| 645 | + | ||
| 646 | + | if tabs.Find(tab1.ID) != nil { | |
| 647 | + | t.Fatalf("tab-1 should have been moved from owning window") | |
| 648 | + | } | |
| 649 | + | if tabs.Find(tab2.ID) == nil { | |
| 650 | + | t.Fatalf("tab-2 should remain when owning window is preserved") | |
| 651 | + | } | |
| 652 | + | } | |
| 653 | + | ||
| 654 | + | func TestApp_InitKeyboardHandlerDoesNotReattachExistingWindowInput(t *testing.T) { | |
| 655 | + | EnsureAdwaitaInitialized() | |
| 656 | + | appID := AppID | |
| 657 | + | gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue) | |
| 658 | + | if gtkApp == nil { | |
| 659 | + | t.Fatal("gtk application creation failed") | |
| 660 | + | } | |
| 661 | + | defer gtkApp.Unref() | |
| 662 | + | ||
| 663 | + | mainWindow, err := window.New(context.Background(), gtkApp, "top") | |
| 664 | + | if err != nil { | |
| 665 | + | t.Fatalf("window creation failed: %v", err) | |
| 666 | + | } | |
| 667 | + | ||
| 668 | + | sentinelKeyboardHandler := &input.KeyboardHandler{} | |
| 669 | + | sentinelShortcutHandler := &input.GlobalShortcutHandler{} | |
| 670 | + | bw := &browserWindow{ | |
| 671 | + | id: "window-1", | |
| 672 | + | mainWindow: mainWindow, | |
| 673 | + | keyboardHandler: sentinelKeyboardHandler, | |
| 674 | + | globalShortcutHandler: sentinelShortcutHandler, | |
| 675 | + | } | |
| 676 | + | app := &App{ | |
| 677 | + | deps: &Dependencies{Config: &config.Config{}}, | |
| 678 | + | mainWindow: mainWindow, | |
| 679 | + | browserWindows: map[string]*browserWindow{bw.id: bw}, | |
| 680 | + | kbDispatcher: dispatcher.NewKeyboardDispatcher(context.Background(), nil, nil, nil, nil, nil, "", func(context.Context) entity.PaneID { return "" }), | |
| 681 | + | } | |
| 682 | + | ||
| 683 | + | app.initKeyboardHandler(context.Background()) | |
| 684 | + | ||
| 685 | + | if bw.keyboardHandler != sentinelKeyboardHandler { | |
| 686 | + | t.Fatalf("keyboardHandler was reattached") | |
| 687 | + | } | |
| 688 | + | if bw.globalShortcutHandler != sentinelShortcutHandler { | |
| 689 | + | t.Fatalf("globalShortcutHandler was reattached") | |
| 690 | + | } | |
| 691 | + | } | |
| 692 | + | ||
| 693 | + | func newTestAccentUseCase(t *testing.T, pickerVisible bool) *usecase.InsertAccentUseCase { | |
| 694 | + | t.Helper() | |
| 695 | + | uc := usecase.NewInsertAccentUseCase(&testFocusedInputProvider{}, nil, nil) | |
| 696 | + | if uc == nil { | |
| 697 | + | t.Fatal("failed to create accent use case") | |
| 698 | + | } | |
| 699 | + | setAccentUseCaseBoolField(t, uc, "pickerVisible", pickerVisible) | |
| 700 | + | return uc | |
| 701 | + | } | |
| 702 | + | ||
| 703 | + | func setAccentUseCaseBoolField(t *testing.T, uc *usecase.InsertAccentUseCase, field string, value bool) { | |
| 704 | + | t.Helper() | |
| 705 | + | rv := reflect.ValueOf(uc).Elem() | |
| 706 | + | fv := rv.FieldByName(field) | |
| 707 | + | if !fv.IsValid() { | |
| 708 | + | t.Fatalf("InsertAccentUseCase missing %s field", field) | |
| 709 | + | } | |
| 710 | + | reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().SetBool(value) | |
| 711 | + | } | |
| 712 | + | ||
| 713 | + | type testFocusedInputProvider struct{} | |
| 714 | + | ||
| 715 | + | func (*testFocusedInputProvider) GetFocusedInput() port.TextInputTarget { return nil } | |
| 716 | + | func (*testFocusedInputProvider) SetFocusedInput(port.TextInputTarget) {} | |
| 717 | + | ||
| 718 | + | func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(t *testing.T) { | |
| 719 | + | EnsureAdwaitaInitialized() | |
| 720 | + | appID := AppID | |
| 721 | + | gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue) | |
| 722 | + | if gtkApp == nil { | |
| 723 | + | t.Fatal("gtk application creation failed") | |
| 724 | + | } | |
| 725 | + | defer gtkApp.Unref() | |
| 726 | + | ||
| 727 | + | mainWindow := &window.MainWindow{} | |
| 728 | + | tabBar := component.NewTabBar() | |
| 729 | + | if tabBar == nil { | |
| 730 | + | t.Fatal("tab bar creation failed") | |
| 731 | + | } | |
| 732 | + | tabBar.AddTab(entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))) | |
| 733 | + | tabBar.SetVisible(false) | |
| 734 | + | setWindowTabBar(t, mainWindow, tabBar) | |
| 735 | + | bw := &browserWindow{id: "window-1", mainWindow: mainWindow} | |
| 736 | + | app := &App{ | |
| 737 | + | deps: &Dependencies{Config: &config.Config{}}, | |
| 738 | + | } | |
| 739 | + | app.deps.Config.Workspace.HideTabBarWhenSingleTab = false | |
| 740 | + | ||
| 741 | + | app.updateBrowserWindowTabBarVisibility(bw) | |
| 742 | + | ||
| 743 | + | if got := windowTabBarVisible(t, mainWindow); !got { | |
| 744 | + | t.Fatalf("tab bar visible = %v, want true", got) | |
| 745 | + | } | |
| 746 | + | } | |
| 747 | + | ||
| 748 | + | func TestApp_RemoveBrowserWindowPromotesDeterministicFallbackWithMainWindow(t *testing.T) { | |
| 749 | + | for i := 0; i < 20; i++ { | |
| 750 | + | removed := &browserWindow{id: "window-z", mainWindow: &window.MainWindow{}} | |
| 751 | + | nilWindow := &browserWindow{id: "window-a"} | |
| 752 | + | firstValid := &browserWindow{id: "window-b", mainWindow: &window.MainWindow{}} | |
| 753 | + | secondValid := &browserWindow{id: "window-c", mainWindow: &window.MainWindow{}} | |
| 754 | + | app := &App{ | |
| 755 | + | mainWindow: removed.mainWindow, | |
| 756 | + | browserWindows: map[string]*browserWindow{ | |
| 757 | + | removed.id: removed, | |
| 758 | + | nilWindow.id: nilWindow, | |
| 759 | + | firstValid.id: firstValid, | |
| 760 | + | secondValid.id: secondValid, | |
| 761 | + | }, | |
| 762 | + | lastFocusedWindowID: removed.id, | |
| 763 | + | } | |
| 764 | + | ||
| 765 | + | app.removeBrowserWindow(removed.id) | |
| 766 | + | ||
| 767 | + | if app.mainWindow != firstValid.mainWindow { | |
| 768 | + | t.Fatalf("mainWindow = %p, want %p", app.mainWindow, firstValid.mainWindow) | |
| 769 | + | } | |
| 770 | + | if app.lastFocusedWindowID != firstValid.id { | |
| 771 | + | t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, firstValid.id) | |
| 772 | + | } | |
| 773 | + | } | |
| 774 | + | } | |
| 775 | + | ||
| 776 | + | func TestApp_BrowserWindowActivationHookUpdatesLastFocusedWindowID(t *testing.T) { | |
| 777 | + | first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}} | |
| 778 | + | second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}} | |
| 779 | + | app := &App{ | |
| 780 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |
| 781 | + | lastFocusedWindowID: first.id, | |
| 782 | + | } | |
| 783 | + | ||
| 784 | + | app.handleBrowserWindowActivationChanged(second, true) | |
| 785 | + | ||
| 786 | + | if app.lastFocusedWindowID != second.id { | |
| 787 | + | t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, second.id) | |
| 788 | + | } | |
| 789 | + | } | |
| 790 | + | ||
| 791 | + | func TestApp_ActivateBrowserWindowTracksLastFocusedWindow(t *testing.T) { | |
| 792 | + | first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}} | |
| 793 | + | second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}} | |
| 794 | + | app := &App{ | |
| 795 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |
| 796 | + | } | |
| 797 | + | ||
| 798 | + | app.activateBrowserWindow(second) | |
| 799 | + | ||
| 800 | + | if app.lastFocusedWindowID != second.id { | |
| 801 | + | t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, second.id) | |
| 802 | + | } | |
| 803 | + | } | |
| 804 | + | ||
| 805 | + | func TestApp_LastFocusedBrowserWindowFallsBackWhenOwnerMissing(t *testing.T) { | |
| 806 | + | first := &browserWindow{id: "window-1"} | |
| 807 | + | second := &browserWindow{id: "window-2"} | |
| 808 | + | app := &App{ | |
| 809 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |
| 810 | + | lastFocusedWindowID: second.id, | |
| 811 | + | } | |
| 812 | + | ||
| 813 | + | got := app.lastFocusedBrowserWindow() | |
| 814 | + | ||
| 815 | + | if got != second { | |
| 816 | + | t.Fatalf("lastFocusedBrowserWindow = %p, want %p", got, second) | |
| 817 | + | } | |
| 818 | + | } | |
| 819 | + | ||
| 820 | + | func TestApp_RemoveBrowserWindowClearsLastFocusedFallback(t *testing.T) { | |
| 821 | + | first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}} | |
| 822 | + | second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}} | |
| 823 | + | app := &App{ | |
| 824 | + | mainWindow: second.mainWindow, | |
| 825 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |
| 826 | + | lastFocusedWindowID: second.id, | |
| 827 | + | } | |
| 828 | + | ||
| 829 | + | app.removeBrowserWindow(second.id) | |
| 830 | + | ||
| 831 | + | if app.lastFocusedWindowID != first.id { | |
| 832 | + | t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, first.id) | |
| 833 | + | } | |
| 834 | + | } | |
| @@ -8,11 +8,17 @@ import ( | |||||
| 8 | "unsafe" | 8 | "unsafe" | ||
| 9 | 9 | ||||
| 10 | "github.com/bnema/dumber/internal/application/port" | 10 | "github.com/bnema/dumber/internal/application/port" | ||
| 11 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 11 | "github.com/bnema/dumber/internal/application/usecase" | 12 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 12 | "github.com/bnema/dumber/internal/domain/entity" | 13 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 14 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 13 | "github.com/bnema/dumber/internal/ui/component" | 15 | "github.com/bnema/dumber/internal/ui/component" | ||
| 14 | "github.com/bnema/dumber/internal/ui/coordinator" | 16 | "github.com/bnema/dumber/internal/ui/coordinator" | ||
| 17 | + | contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content" | |||
| 18 | + | "github.com/bnema/dumber/internal/ui/dispatcher" | |||
| 15 | "github.com/bnema/dumber/internal/ui/input" | 19 | "github.com/bnema/dumber/internal/ui/input" | ||
| 20 | + | "github.com/bnema/dumber/internal/ui/layout" | |||
| 21 | + | layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks" | |||
| 16 | "github.com/bnema/dumber/internal/ui/window" | 22 | "github.com/bnema/dumber/internal/ui/window" | ||
| 17 | "github.com/bnema/puregotk/v4/gio" | 23 | "github.com/bnema/puregotk/v4/gio" | ||
| 18 | "github.com/bnema/puregotk/v4/gtk" | 24 | "github.com/bnema/puregotk/v4/gtk" | ||
| @@ -94,6 +100,86 @@ func windowTabBarActiveID(t *testing.T, mw *window.MainWindow) entity.TabID { | |||||
| 94 | return mw.TabBar().ActiveTabID() | 100 | return mw.TabBar().ActiveTabID() | ||
| 95 | } | 101 | } | ||
| 96 | 102 | ||||
| 103 | + | func windowTitle(t *testing.T, mw *window.MainWindow) string { | |||
| 104 | + | t.Helper() | |||
| 105 | + | if mw == nil || mw.Window() == nil { | |||
| 106 | + | return "" | |||
| 107 | + | } | |||
| 108 | + | return mw.Window().GetTitle() | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func windowTabBarVisible(t *testing.T, mw *window.MainWindow) bool { | |||
| 112 | + | t.Helper() | |||
| 113 | + | if mw == nil || mw.TabBar() == nil || mw.TabBar().Box() == nil { | |||
| 114 | + | return false | |||
| 115 | + | } | |||
| 116 | + | return mw.TabBar().Box().GetVisible() | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func newTestShellToaster(t *testing.T) (*component.Toaster, *layoutmocks.MockBoxWidget, *layoutmocks.MockLabelWidget) { | |||
| 120 | + | t.Helper() | |||
| 121 | + | ||||
| 122 | + | factory := layoutmocks.NewMockWidgetFactory(t) | |||
| 123 | + | box := layoutmocks.NewMockBoxWidget(t) | |||
| 124 | + | label := layoutmocks.NewMockLabelWidget(t) | |||
| 125 | + | ||||
| 126 | + | factory.EXPECT().NewBox(layout.OrientationHorizontal, 0).Return(box).Once() | |||
| 127 | + | factory.EXPECT().NewLabel("").Return(label).Once() | |||
| 128 | + | box.EXPECT().AddCssClass("toast").Once() | |||
| 129 | + | box.EXPECT().AddCssClass("toast-info").Once() | |||
| 130 | + | box.EXPECT().SetHalign(gtk.AlignStartValue).Twice() | |||
| 131 | + | box.EXPECT().SetValign(gtk.AlignStartValue).Twice() | |||
| 132 | + | box.EXPECT().SetHexpand(false).Once() | |||
| 133 | + | box.EXPECT().SetVexpand(false).Once() | |||
| 134 | + | box.EXPECT().SetCanTarget(false).Once() | |||
| 135 | + | box.EXPECT().SetCanFocus(false).Once() | |||
| 136 | + | box.EXPECT().SetVisible(false).Once() | |||
| 137 | + | box.EXPECT().Append(label).Once() | |||
| 138 | + | label.EXPECT().SetCanTarget(false).Once() | |||
| 139 | + | label.EXPECT().SetCanFocus(false).Once() | |||
| 140 | + | ||||
| 141 | + | return component.NewToaster(factory), box, label | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) { | |||
| 145 | + | ctx := context.Background() | |||
| 146 | + | toaster, box, label := newTestShellToaster(t) | |||
| 147 | + | box.EXPECT().SetVisible(true).Once() | |||
| 148 | + | label.EXPECT().SetText("Ad blocker loading").Once() | |||
| 149 | + | ||||
| 150 | + | bw := &browserWindow{id: "window-1", appToaster: toaster} | |||
| 151 | + | app := &App{ | |||
| 152 | + | browserWindows: map[string]*browserWindow{bw.id: bw}, | |||
| 153 | + | lastFocusedWindowID: bw.id, | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | app.showFilterStatus(ctx, port.FilterStatus{State: port.FilterStateLoading, Message: "Ad blocker loading"}) | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func TestApp_CheckConfigMigrationUsesLastFocusedBrowserWindowToaster(t *testing.T) { | |||
| 160 | + | ctx := context.Background() | |||
| 161 | + | toaster, box, label := newTestShellToaster(t) | |||
| 162 | + | box.EXPECT().SetVisible(true).Once() | |||
| 163 | + | label.EXPECT().SetText("Config has 1 new settings. Run 'dumber config migrate'").Once() | |||
| 164 | + | ||||
| 165 | + | migrator := portmocks.NewMockConfigMigrator(t) | |||
| 166 | + | migrator.EXPECT().CheckMigration().Return(&port.MigrationResult{MissingKeys: []string{"update.notify_on_new_settings"}}, nil).Once() | |||
| 167 | + | ||||
| 168 | + | bw := &browserWindow{id: "window-1", appToaster: toaster} | |||
| 169 | + | app := &App{ | |||
| 170 | + | deps: &Dependencies{ | |||
| 171 | + | Config: &config.Config{ | |||
| 172 | + | Update: config.UpdateConfig{NotifyOnNewSettings: true}, | |||
| 173 | + | }, | |||
| 174 | + | ConfigMigrator: migrator, | |||
| 175 | + | }, | |||
| 176 | + | browserWindows: map[string]*browserWindow{bw.id: bw}, | |||
| 177 | + | lastFocusedWindowID: bw.id, | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | app.checkConfigMigration(ctx) | |||
| 181 | + | } | |||
| 182 | + | ||||
| 97 | func TestApp_FinalizeActivationStartsBrowserLaunchRelayOnceAndClosesOnShutdown(t *testing.T) { | 183 | func TestApp_FinalizeActivationStartsBrowserLaunchRelayOnceAndClosesOnShutdown(t *testing.T) { | ||
| 98 | relay := &testBrowserLaunchRelay{closer: &testCloser{}} | 184 | relay := &testBrowserLaunchRelay{closer: &testCloser{}} | ||
| 99 | deps := &Dependencies{} | 185 | deps := &Dependencies{} | ||
| @@ -317,3 +403,432 @@ func TestApp_BrowserWindowForPaneFindsOwningWindow(t *testing.T) { | |||||
| 317 | t.Fatalf("browserWindowForPane = %p, want %p", got, second) | 403 | t.Fatalf("browserWindowForPane = %p, want %p", got, second) | ||
| 318 | } | 404 | } | ||
| 319 | } | 405 | } | ||
| 406 | + | ||||
| 407 | + | func TestApp_OwnerOrLastFocusedBrowserWindowPrefersPaneOwner(t *testing.T) { | |||
| 408 | + | tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1"))) | |||
| 409 | + | tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2"))) | |||
| 410 | + | tabs := entity.NewTabList() | |||
| 411 | + | tabs.Add(tab1) | |||
| 412 | + | tabs.Add(tab2) | |||
| 413 | + | first := &browserWindow{id: "window-1"} | |||
| 414 | + | second := &browserWindow{id: "window-2"} | |||
| 415 | + | app := &App{ | |||
| 416 | + | tabs: tabs, | |||
| 417 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |||
| 418 | + | windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second}, | |||
| 419 | + | lastFocusedWindowID: first.id, | |||
| 420 | + | } | |||
| 421 | + | ||||
| 422 | + | got := app.ownerOrLastFocusedBrowserWindow("", entity.PaneID("pane-2")) | |||
| 423 | + | ||||
| 424 | + | if got != second { | |||
| 425 | + | t.Fatalf("ownerOrLastFocusedBrowserWindow = %p, want %p", got, second) | |||
| 426 | + | } | |||
| 427 | + | } | |||
| 428 | + | ||||
| 429 | + | func TestApp_HandlePaneWindowTitleChangedTargetsOwningWindow(t *testing.T) { | |||
| 430 | + | EnsureAdwaitaInitialized() | |||
| 431 | + | appID := AppID | |||
| 432 | + | gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue) | |||
| 433 | + | if gtkApp == nil { | |||
| 434 | + | t.Fatal("gtk application creation failed") | |||
| 435 | + | } | |||
| 436 | + | defer gtkApp.Unref() | |||
| 437 | + | ||||
| 438 | + | firstMainWindow, err := window.New(context.Background(), gtkApp, "top") | |||
| 439 | + | if err != nil { | |||
| 440 | + | t.Fatalf("first window creation failed: %v", err) | |||
| 441 | + | } | |||
| 442 | + | secondMainWindow, err := window.New(context.Background(), gtkApp, "top") | |||
| 443 | + | if err != nil { | |||
| 444 | + | t.Fatalf("second window creation failed: %v", err) | |||
| 445 | + | } | |||
| 446 | + | ||||
| 447 | + | tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1"))) | |||
| 448 | + | tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2"))) | |||
| 449 | + | tabs := entity.NewTabList() | |||
| 450 | + | tabs.Add(tab1) | |||
| 451 | + | tabs.Add(tab2) | |||
| 452 | + | first := &browserWindow{id: "window-1", mainWindow: firstMainWindow} | |||
| 453 | + | second := &browserWindow{id: "window-2", mainWindow: secondMainWindow} | |||
| 454 | + | app := &App{ | |||
| 455 | + | tabs: tabs, | |||
| 456 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |||
| 457 | + | windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second}, | |||
| 458 | + | lastFocusedWindowID: first.id, | |||
| 459 | + | } | |||
| 460 | + | ||||
| 461 | + | app.handlePaneWindowTitleChanged(entity.PaneID("pane-2"), "Pane Two") | |||
| 462 | + | ||||
| 463 | + | if got := windowTitle(t, firstMainWindow); got != "Dumber" { | |||
| 464 | + | t.Fatalf("first window title = %q, want %q", got, "Dumber") | |||
| 465 | + | } | |||
| 466 | + | if got := windowTitle(t, secondMainWindow); got != "Pane Two - Dumber" { | |||
| 467 | + | t.Fatalf("second window title = %q, want %q", got, "Pane Two - Dumber") | |||
| 468 | + | } | |||
| 469 | + | } | |||
| 470 | + | ||||
| 471 | + | func TestApp_ActivateBrowserWindowResyncsTitleFromBackgroundPane(t *testing.T) { | |||
| 472 | + | EnsureAdwaitaInitialized() | |||
| 473 | + | appID := AppID | |||
| 474 | + | gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue) | |||
| 475 | + | if gtkApp == nil { | |||
| 476 | + | t.Fatal("gtk application creation failed") | |||
| 477 | + | } | |||
| 478 | + | defer gtkApp.Unref() | |||
| 479 | + | ||||
| 480 | + | firstMainWindow, err := window.New(context.Background(), gtkApp, "top") | |||
| 481 | + | if err != nil { | |||
| 482 | + | t.Fatalf("first window creation failed: %v", err) | |||
| 483 | + | } | |||
| 484 | + | secondMainWindow, err := window.New(context.Background(), gtkApp, "top") | |||
| 485 | + | if err != nil { | |||
| 486 | + | t.Fatalf("second window creation failed: %v", err) | |||
| 487 | + | } | |||
| 488 | + | ||||
| 489 | + | tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1"))) | |||
| 490 | + | tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2"))) | |||
| 491 | + | tabs := entity.NewTabList() | |||
| 492 | + | tabs.Add(tab1) | |||
| 493 | + | tabs.Add(tab2) | |||
| 494 | + | first := &browserWindow{id: "window-1", mainWindow: firstMainWindow, activeTabID: tab1.ID} | |||
| 495 | + | second := &browserWindow{id: "window-2", mainWindow: secondMainWindow, activeTabID: tab2.ID} | |||
| 496 | + | contentCoord := &contentcoord.Coordinator{} | |||
| 497 | + | contentTitles := reflect.ValueOf(contentCoord).Elem().FieldByName("paneTitles") | |||
| 498 | + | reflect.NewAt(contentTitles.Type(), unsafe.Pointer(contentTitles.UnsafeAddr())).Elem().Set(reflect.ValueOf(map[entity.PaneID]string{tab2.Workspace.ActivePaneID: "Pane Two"})) | |||
| 499 | + | app := &App{ | |||
| 500 | + | tabs: tabs, | |||
| 501 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |||
| 502 | + | windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second}, | |||
| 503 | + | contentCoord: contentCoord, | |||
| 504 | + | } | |||
| 505 | + | ||||
| 506 | + | app.handlePaneWindowTitleChanged(tab2.Workspace.ActivePaneID, "Pane Two") | |||
| 507 | + | secondMainWindow.SetTitle("Dumber") | |||
| 508 | + | ||||
| 509 | + | app.activateBrowserWindow(second) | |||
| 510 | + | ||||
| 511 | + | if got := windowTitle(t, secondMainWindow); got != "Pane Two - Dumber" { | |||
| 512 | + | t.Fatalf("second window title = %q, want %q", got, "Pane Two - Dumber") | |||
| 513 | + | } | |||
| 514 | + | if got := windowTitle(t, firstMainWindow); got != "Dumber" { | |||
| 515 | + | t.Fatalf("first window title = %q, want %q", got, "Dumber") | |||
| 516 | + | } | |||
| 517 | + | } | |||
| 518 | + | ||||
| 519 | + | func TestApp_HandlePaneFullscreenChangedTargetsOwningWindow(t *testing.T) { | |||
| 520 | + | EnsureAdwaitaInitialized() | |||
| 521 | + | appID := AppID | |||
| 522 | + | gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue) | |||
| 523 | + | if gtkApp == nil { | |||
| 524 | + | t.Fatal("gtk application creation failed") | |||
| 525 | + | } | |||
| 526 | + | defer gtkApp.Unref() | |||
| 527 | + | ||||
| 528 | + | firstMainWindow, err := window.New(context.Background(), gtkApp, "top") | |||
| 529 | + | if err != nil { | |||
| 530 | + | t.Fatalf("first window creation failed: %v", err) | |||
| 531 | + | } | |||
| 532 | + | secondMainWindow, err := window.New(context.Background(), gtkApp, "top") | |||
| 533 | + | if err != nil { | |||
| 534 | + | t.Fatalf("second window creation failed: %v", err) | |||
| 535 | + | } | |||
| 536 | + | ||||
| 537 | + | tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1"))) | |||
| 538 | + | tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2"))) | |||
| 539 | + | tabs := entity.NewTabList() | |||
| 540 | + | tabs.Add(tab1) | |||
| 541 | + | tabs.Add(tab2) | |||
| 542 | + | secondMainWindow.TabBar().AddTab(tab1) | |||
| 543 | + | secondMainWindow.TabBar().AddTab(tab2) | |||
| 544 | + | first := &browserWindow{id: "window-1", mainWindow: firstMainWindow} | |||
| 545 | + | second := &browserWindow{id: "window-2", mainWindow: secondMainWindow} | |||
| 546 | + | app := &App{ | |||
| 547 | + | tabs: tabs, | |||
| 548 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |||
| 549 | + | windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second}, | |||
| 550 | + | lastFocusedWindowID: first.id, | |||
| 551 | + | } | |||
| 552 | + | ||||
| 553 | + | app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), true) | |||
| 554 | + | ||||
| 555 | + | if windowTabBarVisible(t, firstMainWindow) != true { | |||
| 556 | + | t.Fatalf("first window tab bar visibility changed unexpectedly") | |||
| 557 | + | } | |||
| 558 | + | if got := windowTabBarVisible(t, secondMainWindow); got { | |||
| 559 | + | t.Fatalf("second window tab bar visible = %v, want false", got) | |||
| 560 | + | } | |||
| 561 | + | ||||
| 562 | + | app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), false) | |||
| 563 | + | ||||
| 564 | + | if got := windowTabBarVisible(t, secondMainWindow); !got { | |||
| 565 | + | t.Fatalf("second window tab bar visible = %v, want true", got) | |||
| 566 | + | } | |||
| 567 | + | } | |||
| 568 | + | ||||
| 569 | + | func TestApp_HandlePaneFullscreenChangedIgnoresUnresolvedPane(t *testing.T) { | |||
| 570 | + | EnsureAdwaitaInitialized() | |||
| 571 | + | appID := AppID | |||
| 572 | + | gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue) | |||
| 573 | + | if gtkApp == nil { | |||
| 574 | + | t.Fatal("gtk application creation failed") | |||
| 575 | + | } | |||
| 576 | + | defer gtkApp.Unref() | |||
| 577 | + | ||||
| 578 | + | firstMainWindow, err := window.New(context.Background(), gtkApp, "top") | |||
| 579 | + | if err != nil { | |||
| 580 | + | t.Fatalf("first window creation failed: %v", err) | |||
| 581 | + | } | |||
| 582 | + | secondMainWindow, err := window.New(context.Background(), gtkApp, "top") | |||
| 583 | + | if err != nil { | |||
| 584 | + | t.Fatalf("second window creation failed: %v", err) | |||
| 585 | + | } | |||
| 586 | + | firstMainWindow.TabBar().SetVisible(true) | |||
| 587 | + | secondMainWindow.TabBar().SetVisible(true) | |||
| 588 | + | first := &browserWindow{id: "window-1", mainWindow: firstMainWindow} | |||
| 589 | + | second := &browserWindow{id: "window-2", mainWindow: secondMainWindow} | |||
| 590 | + | app := &App{ | |||
| 591 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |||
| 592 | + | lastFocusedWindowID: first.id, | |||
| 593 | + | } | |||
| 594 | + | ||||
| 595 | + | app.handlePaneFullscreenChanged(entity.PaneID("missing-pane"), true) | |||
| 596 | + | ||||
| 597 | + | if got := windowTabBarVisible(t, firstMainWindow); !got { | |||
| 598 | + | t.Fatalf("first window tab bar visible = %v, want true", got) | |||
| 599 | + | } | |||
| 600 | + | if got := windowTabBarVisible(t, secondMainWindow); !got { | |||
| 601 | + | t.Fatalf("second window tab bar visible = %v, want true", got) | |||
| 602 | + | } | |||
| 603 | + | } | |||
| 604 | + | ||||
| 605 | + | func TestApp_HandleAccentKeyPressDelegatesToOwningWindow(t *testing.T) { | |||
| 606 | + | first := newTestAccentUseCase(t, true) | |||
| 607 | + | second := newTestAccentUseCase(t, false) | |||
| 608 | + | firstWindow := &browserWindow{id: "window-1", insertAccentUC: first} | |||
| 609 | + | secondWindow := &browserWindow{id: "window-2", insertAccentUC: second} | |||
| 610 | + | app := &App{ | |||
| 611 | + | browserWindows: map[string]*browserWindow{firstWindow.id: firstWindow, secondWindow.id: secondWindow}, | |||
| 612 | + | lastFocusedWindowID: secondWindow.id, | |||
| 613 | + | } | |||
| 614 | + | ||||
| 615 | + | if got := app.handleAccentKeyPress(context.Background(), uint('e'), 0); got { | |||
| 616 | + | t.Fatalf("handleAccentKeyPress returned %v, want false from active shell handler", got) | |||
| 617 | + | } | |||
| 618 | + | if got := first.IsPickerVisible(); !got { | |||
| 619 | + | t.Fatalf("first shell accent handler should remain visible") | |||
| 620 | + | } | |||
| 621 | + | if got := second.IsPickerVisible(); got { | |||
| 622 | + | t.Fatalf("second shell accent handler should remain hidden") | |||
| 623 | + | } | |||
| 624 | + | } | |||
| 625 | + | ||||
| 626 | + | func TestApp_MoveActivePaneToTabFromBrowserWindowAnchorsOwningWindow(t *testing.T) { | |||
| 627 | + | tabs := entity.NewTabList() | |||
| 628 | + | tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1"))) | |||
| 629 | + | tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2"))) | |||
| 630 | + | tabs.Add(tab1) | |||
| 631 | + | tabs.Add(tab2) | |||
| 632 | + | first := &browserWindow{id: "window-1", activeTabID: tab1.ID} | |||
| 633 | + | second := &browserWindow{id: "window-2", activeTabID: tab2.ID} | |||
| 634 | + | app := &App{ | |||
| 635 | + | tabs: tabs, | |||
| 636 | + | movePaneToTabUC: usecase.NewMovePaneToTabUseCase(func() string { return "new-tab" }), | |||
| 637 | + | deps: &Dependencies{Config: &config.Config{}}, | |||
| 638 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |||
| 639 | + | lastFocusedWindowID: second.id, | |||
| 640 | + | } | |||
| 641 | + | ||||
| 642 | + | if err := app.moveActivePaneToTabFromBrowserWindow(context.Background(), first, ""); err != nil { | |||
| 643 | + | t.Fatalf("moveActivePaneToTabFromBrowserWindow returned error: %v", err) | |||
| 644 | + | } | |||
| 645 | + | ||||
| 646 | + | if tabs.Find(tab1.ID) != nil { | |||
| 647 | + | t.Fatalf("tab-1 should have been moved from owning window") | |||
| 648 | + | } | |||
| 649 | + | if tabs.Find(tab2.ID) == nil { | |||
| 650 | + | t.Fatalf("tab-2 should remain when owning window is preserved") | |||
| 651 | + | } | |||
| 652 | + | } | |||
| 653 | + | ||||
| 654 | + | func TestApp_InitKeyboardHandlerDoesNotReattachExistingWindowInput(t *testing.T) { | |||
| 655 | + | EnsureAdwaitaInitialized() | |||
| 656 | + | appID := AppID | |||
| 657 | + | gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue) | |||
| 658 | + | if gtkApp == nil { | |||
| 659 | + | t.Fatal("gtk application creation failed") | |||
| 660 | + | } | |||
| 661 | + | defer gtkApp.Unref() | |||
| 662 | + | ||||
| 663 | + | mainWindow, err := window.New(context.Background(), gtkApp, "top") | |||
| 664 | + | if err != nil { | |||
| 665 | + | t.Fatalf("window creation failed: %v", err) | |||
| 666 | + | } | |||
| 667 | + | ||||
| 668 | + | sentinelKeyboardHandler := &input.KeyboardHandler{} | |||
| 669 | + | sentinelShortcutHandler := &input.GlobalShortcutHandler{} | |||
| 670 | + | bw := &browserWindow{ | |||
| 671 | + | id: "window-1", | |||
| 672 | + | mainWindow: mainWindow, | |||
| 673 | + | keyboardHandler: sentinelKeyboardHandler, | |||
| 674 | + | globalShortcutHandler: sentinelShortcutHandler, | |||
| 675 | + | } | |||
| 676 | + | app := &App{ | |||
| 677 | + | deps: &Dependencies{Config: &config.Config{}}, | |||
| 678 | + | mainWindow: mainWindow, | |||
| 679 | + | browserWindows: map[string]*browserWindow{bw.id: bw}, | |||
| 680 | + | kbDispatcher: dispatcher.NewKeyboardDispatcher(context.Background(), nil, nil, nil, nil, nil, "", func(context.Context) entity.PaneID { return "" }), | |||
| 681 | + | } | |||
| 682 | + | ||||
| 683 | + | app.initKeyboardHandler(context.Background()) | |||
| 684 | + | ||||
| 685 | + | if bw.keyboardHandler != sentinelKeyboardHandler { | |||
| 686 | + | t.Fatalf("keyboardHandler was reattached") | |||
| 687 | + | } | |||
| 688 | + | if bw.globalShortcutHandler != sentinelShortcutHandler { | |||
| 689 | + | t.Fatalf("globalShortcutHandler was reattached") | |||
| 690 | + | } | |||
| 691 | + | } | |||
| 692 | + | ||||
| 693 | + | func newTestAccentUseCase(t *testing.T, pickerVisible bool) *usecase.InsertAccentUseCase { | |||
| 694 | + | t.Helper() | |||
| 695 | + | uc := usecase.NewInsertAccentUseCase(&testFocusedInputProvider{}, nil, nil) | |||
| 696 | + | if uc == nil { | |||
| 697 | + | t.Fatal("failed to create accent use case") | |||
| 698 | + | } | |||
| 699 | + | setAccentUseCaseBoolField(t, uc, "pickerVisible", pickerVisible) | |||
| 700 | + | return uc | |||
| 701 | + | } | |||
| 702 | + | ||||
| 703 | + | func setAccentUseCaseBoolField(t *testing.T, uc *usecase.InsertAccentUseCase, field string, value bool) { | |||
| 704 | + | t.Helper() | |||
| 705 | + | rv := reflect.ValueOf(uc).Elem() | |||
| 706 | + | fv := rv.FieldByName(field) | |||
| 707 | + | if !fv.IsValid() { | |||
| 708 | + | t.Fatalf("InsertAccentUseCase missing %s field", field) | |||
| 709 | + | } | |||
| 710 | + | reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().SetBool(value) | |||
| 711 | + | } | |||
| 712 | + | ||||
| 713 | + | type testFocusedInputProvider struct{} | |||
| 714 | + | ||||
| 715 | + | func (*testFocusedInputProvider) GetFocusedInput() port.TextInputTarget { return nil } | |||
| 716 | + | func (*testFocusedInputProvider) SetFocusedInput(port.TextInputTarget) {} | |||
| 717 | + | ||||
| 718 | + | func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(t *testing.T) { | |||
| 719 | + | EnsureAdwaitaInitialized() | |||
| 720 | + | appID := AppID | |||
| 721 | + | gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue) | |||
| 722 | + | if gtkApp == nil { | |||
| 723 | + | t.Fatal("gtk application creation failed") | |||
| 724 | + | } | |||
| 725 | + | defer gtkApp.Unref() | |||
| 726 | + | ||||
| 727 | + | mainWindow := &window.MainWindow{} | |||
| 728 | + | tabBar := component.NewTabBar() | |||
| 729 | + | if tabBar == nil { | |||
| 730 | + | t.Fatal("tab bar creation failed") | |||
| 731 | + | } | |||
| 732 | + | tabBar.AddTab(entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))) | |||
| 733 | + | tabBar.SetVisible(false) | |||
| 734 | + | setWindowTabBar(t, mainWindow, tabBar) | |||
| 735 | + | bw := &browserWindow{id: "window-1", mainWindow: mainWindow} | |||
| 736 | + | app := &App{ | |||
| 737 | + | deps: &Dependencies{Config: &config.Config{}}, | |||
| 738 | + | } | |||
| 739 | + | app.deps.Config.Workspace.HideTabBarWhenSingleTab = false | |||
| 740 | + | ||||
| 741 | + | app.updateBrowserWindowTabBarVisibility(bw) | |||
| 742 | + | ||||
| 743 | + | if got := windowTabBarVisible(t, mainWindow); !got { | |||
| 744 | + | t.Fatalf("tab bar visible = %v, want true", got) | |||
| 745 | + | } | |||
| 746 | + | } | |||
| 747 | + | ||||
| 748 | + | func TestApp_RemoveBrowserWindowPromotesDeterministicFallbackWithMainWindow(t *testing.T) { | |||
| 749 | + | for i := 0; i < 20; i++ { | |||
| 750 | + | removed := &browserWindow{id: "window-z", mainWindow: &window.MainWindow{}} | |||
| 751 | + | nilWindow := &browserWindow{id: "window-a"} | |||
| 752 | + | firstValid := &browserWindow{id: "window-b", mainWindow: &window.MainWindow{}} | |||
| 753 | + | secondValid := &browserWindow{id: "window-c", mainWindow: &window.MainWindow{}} | |||
| 754 | + | app := &App{ | |||
| 755 | + | mainWindow: removed.mainWindow, | |||
| 756 | + | browserWindows: map[string]*browserWindow{ | |||
| 757 | + | removed.id: removed, | |||
| 758 | + | nilWindow.id: nilWindow, | |||
| 759 | + | firstValid.id: firstValid, | |||
| 760 | + | secondValid.id: secondValid, | |||
| 761 | + | }, | |||
| 762 | + | lastFocusedWindowID: removed.id, | |||
| 763 | + | } | |||
| 764 | + | ||||
| 765 | + | app.removeBrowserWindow(removed.id) | |||
| 766 | + | ||||
| 767 | + | if app.mainWindow != firstValid.mainWindow { | |||
| 768 | + | t.Fatalf("mainWindow = %p, want %p", app.mainWindow, firstValid.mainWindow) | |||
| 769 | + | } | |||
| 770 | + | if app.lastFocusedWindowID != firstValid.id { | |||
| 771 | + | t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, firstValid.id) | |||
| 772 | + | } | |||
| 773 | + | } | |||
| 774 | + | } | |||
| 775 | + | ||||
| 776 | + | func TestApp_BrowserWindowActivationHookUpdatesLastFocusedWindowID(t *testing.T) { | |||
| 777 | + | first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}} | |||
| 778 | + | second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}} | |||
| 779 | + | app := &App{ | |||
| 780 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |||
| 781 | + | lastFocusedWindowID: first.id, | |||
| 782 | + | } | |||
| 783 | + | ||||
| 784 | + | app.handleBrowserWindowActivationChanged(second, true) | |||
| 785 | + | ||||
| 786 | + | if app.lastFocusedWindowID != second.id { | |||
| 787 | + | t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, second.id) | |||
| 788 | + | } | |||
| 789 | + | } | |||
| 790 | + | ||||
| 791 | + | func TestApp_ActivateBrowserWindowTracksLastFocusedWindow(t *testing.T) { | |||
| 792 | + | first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}} | |||
| 793 | + | second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}} | |||
| 794 | + | app := &App{ | |||
| 795 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |||
| 796 | + | } | |||
| 797 | + | ||||
| 798 | + | app.activateBrowserWindow(second) | |||
| 799 | + | ||||
| 800 | + | if app.lastFocusedWindowID != second.id { | |||
| 801 | + | t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, second.id) | |||
| 802 | + | } | |||
| 803 | + | } | |||
| 804 | + | ||||
| 805 | + | func TestApp_LastFocusedBrowserWindowFallsBackWhenOwnerMissing(t *testing.T) { | |||
| 806 | + | first := &browserWindow{id: "window-1"} | |||
| 807 | + | second := &browserWindow{id: "window-2"} | |||
| 808 | + | app := &App{ | |||
| 809 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |||
| 810 | + | lastFocusedWindowID: second.id, | |||
| 811 | + | } | |||
| 812 | + | ||||
| 813 | + | got := app.lastFocusedBrowserWindow() | |||
| 814 | + | ||||
| 815 | + | if got != second { | |||
| 816 | + | t.Fatalf("lastFocusedBrowserWindow = %p, want %p", got, second) | |||
| 817 | + | } | |||
| 818 | + | } | |||
| 819 | + | ||||
| 820 | + | func TestApp_RemoveBrowserWindowClearsLastFocusedFallback(t *testing.T) { | |||
| 821 | + | first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}} | |||
| 822 | + | second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}} | |||
| 823 | + | app := &App{ | |||
| 824 | + | mainWindow: second.mainWindow, | |||
| 825 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |||
| 826 | + | lastFocusedWindowID: second.id, | |||
| 827 | + | } | |||
| 828 | + | ||||
| 829 | + | app.removeBrowserWindow(second.id) | |||
| 830 | + | ||||
| 831 | + | if app.lastFocusedWindowID != first.id { | |||
| 832 | + | t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, first.id) | |||
| 833 | + | } | |||
| 834 | + | } | |||
M internal/ui/browser_window.go +247 -9
| @@ -2,12 +2,18 @@ package ui | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "sort" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 7 | 9 | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | + | "github.com/bnema/dumber/internal/logging" | |
| 8 | 11 | "github.com/bnema/dumber/internal/ui/component" | |
| 12 | + | "github.com/bnema/dumber/internal/ui/focus" | |
| 9 | 13 | "github.com/bnema/dumber/internal/ui/input" | |
| 14 | + | "github.com/bnema/dumber/internal/ui/layout" | |
| 10 | 15 | "github.com/bnema/dumber/internal/ui/window" | |
| 16 | + | "github.com/bnema/puregotk/v4/glib" | |
| 11 | 17 | ) | |
| 12 | 18 | ||
| 13 | 19 | type browserWindow struct { | |
| @@ -15,12 +21,215 @@ type browserWindow struct { | |||
| 15 | 21 | initialURL string | |
| 16 | 22 | activeTabID entity.TabID | |
| 17 | 23 | mainWindow *window.MainWindow | |
| 24 | + | appToaster *component.Toaster | |
| 25 | + | modeToaster *component.Toaster | |
| 26 | + | borderMgr *focus.BorderManager | |
| 27 | + | sessionManager *component.SessionManager | |
| 28 | + | tabPicker *component.TabPicker | |
| 29 | + | tabPickerWidget layout.Widget | |
| 30 | + | tabPickerPaneID entity.PaneID | |
| 31 | + | insertAccentUC *usecase.InsertAccentUseCase | |
| 32 | + | accentPicker *component.AccentPicker | |
| 18 | 33 | keyboardHandler *input.KeyboardHandler | |
| 19 | 34 | globalShortcutHandler *input.GlobalShortcutHandler | |
| 20 | 35 | permissionDialog port.PermissionDialogPresenter | |
| 21 | 36 | webrtcIndicator *component.WebRTCPermissionIndicator | |
| 22 | 37 | } | |
| 23 | 38 | ||
| 39 | + | func (bw *browserWindow) clearShellState() { | |
| 40 | + | if bw == nil { | |
| 41 | + | return | |
| 42 | + | } | |
| 43 | + | bw.appToaster = nil | |
| 44 | + | bw.modeToaster = nil | |
| 45 | + | bw.borderMgr = nil | |
| 46 | + | bw.sessionManager = nil | |
| 47 | + | bw.tabPicker = nil | |
| 48 | + | bw.tabPickerWidget = nil | |
| 49 | + | bw.tabPickerPaneID = "" | |
| 50 | + | bw.insertAccentUC = nil | |
| 51 | + | bw.accentPicker = nil | |
| 52 | + | bw.keyboardHandler = nil | |
| 53 | + | bw.globalShortcutHandler = nil | |
| 54 | + | bw.permissionDialog = nil | |
| 55 | + | bw.webrtcIndicator = nil | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func (bw *browserWindow) initChrome(ctx context.Context, a *App) { | |
| 59 | + | if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil { | |
| 60 | + | return | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | bw.initToasterOverlay(a) | |
| 64 | + | bw.initBorderOverlay(a) | |
| 65 | + | bw.initAccentPicker(ctx, a) | |
| 66 | + | bw.initSessionManager(ctx, a) | |
| 67 | + | bw.initTabPicker(ctx, a) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func (bw *browserWindow) initToasterOverlay(a *App) { | |
| 71 | + | if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil { | |
| 72 | + | return | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | bw.appToaster = component.NewToaster(a.widgetFactory) | |
| 76 | + | if bw.appToaster != nil { | |
| 77 | + | if widget := bw.appToaster.Widget(); widget != nil { | |
| 78 | + | if gtkWidget := widget.GtkWidget(); gtkWidget != nil { | |
| 79 | + | bw.mainWindow.AddOverlay(gtkWidget) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | bw.modeToaster = component.NewToaster(a.widgetFactory) | |
| 85 | + | if bw.modeToaster != nil { | |
| 86 | + | if widget := bw.modeToaster.Widget(); widget != nil { | |
| 87 | + | if gtkWidget := widget.GtkWidget(); gtkWidget != nil { | |
| 88 | + | bw.mainWindow.AddOverlay(gtkWidget) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func (bw *browserWindow) initBorderOverlay(a *App) { | |
| 95 | + | if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil { | |
| 96 | + | return | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | bw.borderMgr = focus.NewBorderManager(a.widgetFactory) | |
| 100 | + | if bw.borderMgr == nil { | |
| 101 | + | return | |
| 102 | + | } | |
| 103 | + | if widget := bw.borderMgr.Widget(); widget != nil { | |
| 104 | + | if gtkWidget := widget.GtkWidget(); gtkWidget != nil { | |
| 105 | + | bw.mainWindow.AddOverlay(gtkWidget) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func (bw *browserWindow) initAccentPicker(ctx context.Context, a *App) { | |
| 111 | + | log := logging.FromContext(ctx) | |
| 112 | + | if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil || a.deps == nil { | |
| 113 | + | log.Debug().Msg("widget factory, deps, or main window not available, skipping accent picker") | |
| 114 | + | return | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | bw.accentPicker = component.NewAccentPicker(a.widgetFactory) | |
| 118 | + | if bw.accentPicker == nil { | |
| 119 | + | log.Warn().Msg("failed to create accent picker") | |
| 120 | + | return | |
| 121 | + | } | |
| 122 | + | if widget := bw.accentPicker.Widget(); widget != nil { | |
| 123 | + | if gtkWidget := widget.GtkWidget(); gtkWidget != nil { | |
| 124 | + | bw.mainWindow.AddOverlay(gtkWidget) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | a.accentFocusProvider = a.deps.AccentFocusProvider | |
| 129 | + | bw.insertAccentUC = usecase.NewInsertAccentUseCase( | |
| 130 | + | a.accentFocusProvider, | |
| 131 | + | bw.accentPicker, | |
| 132 | + | func(fn func()) { | |
| 133 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 134 | + | fn() | |
| 135 | + | return false | |
| 136 | + | }) | |
| 137 | + | glib.IdleAdd(&cb, 0) | |
| 138 | + | }, | |
| 139 | + | ) | |
| 140 | + | ||
| 141 | + | log.Debug().Msg("accent picker initialized") | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func (bw *browserWindow) initSessionManager(ctx context.Context, a *App) { | |
| 145 | + | log := logging.FromContext(ctx) | |
| 146 | + | if bw == nil || a == nil || a.deps == nil || a.deps.Config == nil || bw.mainWindow == nil { | |
| 147 | + | log.Debug().Msg("deps or main window not available, skipping session manager") | |
| 148 | + | return | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | var listSessionsUC *usecase.ListSessionsUseCase | |
| 152 | + | var deleteSessionUC *usecase.DeleteSessionUseCase | |
| 153 | + | if a.deps.SessionRepo != nil && a.deps.SessionStateRepo != nil { | |
| 154 | + | listSessionsUC = usecase.NewListSessionsUseCase( | |
| 155 | + | a.deps.SessionRepo, | |
| 156 | + | a.deps.SessionStateRepo, | |
| 157 | + | ) | |
| 158 | + | deleteSessionUC = usecase.NewDeleteSessionUseCase( | |
| 159 | + | a.deps.SessionStateRepo, | |
| 160 | + | a.deps.SessionRepo, | |
| 161 | + | ) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | bw.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{ | |
| 165 | + | ListSessionsUC: listSessionsUC, | |
| 166 | + | DeleteSessionUC: deleteSessionUC, | |
| 167 | + | CurrentSession: a.deps.CurrentSessionID, | |
| 168 | + | UIScale: a.deps.Config.DefaultUIScale, | |
| 169 | + | OnClose: func() { | |
| 170 | + | log.Debug().Msg("session manager closed") | |
| 171 | + | }, | |
| 172 | + | OnOpen: func(sessionID entity.SessionID) { | |
| 173 | + | log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested") | |
| 174 | + | if spawner := a.deps.SessionSpawner; spawner == nil { | |
| 175 | + | log.Warn().Str("session_id", string(sessionID)).Msg("session spawner not available") | |
| 176 | + | return | |
| 177 | + | } else if err := spawner.SpawnWithSession(sessionID); err != nil { | |
| 178 | + | log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session") | |
| 179 | + | } | |
| 180 | + | }, | |
| 181 | + | OnToast: func(ctx context.Context, message string, level component.ToastLevel) { | |
| 182 | + | if bw.appToaster != nil { | |
| 183 | + | bw.appToaster.Show(ctx, message, level) | |
| 184 | + | } | |
| 185 | + | }, | |
| 186 | + | }) | |
| 187 | + | ||
| 188 | + | if bw.sessionManager == nil { | |
| 189 | + | log.Warn().Msg("failed to create session manager") | |
| 190 | + | return | |
| 191 | + | } | |
| 192 | + | if widget := bw.sessionManager.Widget(); widget != nil { | |
| 193 | + | bw.mainWindow.AddOverlay(widget) | |
| 194 | + | } | |
| 195 | + | log.Debug().Msg("session manager initialized") | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func (bw *browserWindow) initTabPicker(ctx context.Context, a *App) { | |
| 199 | + | log := logging.FromContext(ctx) | |
| 200 | + | if bw == nil || a == nil || a.deps == nil || a.deps.Config == nil { | |
| 201 | + | log.Debug().Msg("deps/config not available, skipping tab picker") | |
| 202 | + | return | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | bw.tabPicker = component.NewTabPicker(ctx, component.TabPickerConfig{ | |
| 206 | + | UIScale: a.deps.Config.DefaultUIScale, | |
| 207 | + | OnClose: func() { | |
| 208 | + | log.Debug().Msg("tab picker closed") | |
| 209 | + | }, | |
| 210 | + | OnSelect: func(item component.TabPickerItem) { | |
| 211 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 212 | + | var targetID entity.TabID | |
| 213 | + | if !item.IsNew { | |
| 214 | + | targetID = item.TabID | |
| 215 | + | } | |
| 216 | + | if err := a.moveActivePaneToTabFromBrowserWindow(ctx, bw, targetID); err != nil { | |
| 217 | + | log.Warn().Err(err).Msg("move pane to tab failed") | |
| 218 | + | } | |
| 219 | + | return false | |
| 220 | + | }) | |
| 221 | + | glib.IdleAdd(&cb, 0) | |
| 222 | + | }, | |
| 223 | + | }) | |
| 224 | + | ||
| 225 | + | if bw.tabPicker == nil { | |
| 226 | + | log.Warn().Msg("failed to create tab picker") | |
| 227 | + | return | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | log.Debug().Msg("tab picker initialized") | |
| 231 | + | } | |
| 232 | + | ||
| 24 | 233 | func (a *App) registerBrowserWindow(bw *browserWindow) { | |
| 25 | 234 | if bw == nil { | |
| 26 | 235 | return | |
| @@ -35,28 +244,57 @@ func (a *App) removeBrowserWindow(id string) { | |||
| 35 | 244 | if id == "" || a.browserWindows == nil { | |
| 36 | 245 | return | |
| 37 | 246 | } | |
| 247 | + | removed := a.browserWindows[id] | |
| 248 | + | wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow | |
| 38 | 249 | for tabID, bw := range a.windowForTab { | |
| 39 | 250 | if bw != nil && bw.id == id { | |
| 40 | 251 | delete(a.windowForTab, tabID) | |
| 41 | 252 | } | |
| 42 | 253 | } | |
| 43 | - | if bw := a.browserWindows[id]; bw != nil && bw.mainWindow == a.mainWindow { | |
| 44 | - | delete(a.browserWindows, id) | |
| 45 | - | for _, remaining := range a.browserWindows { | |
| 46 | - | if remaining != nil && remaining.mainWindow != nil { | |
| 47 | - | a.activateBrowserWindow(remaining) | |
| 48 | - | return | |
| 49 | - | } | |
| 254 | + | if removed != nil { | |
| 255 | + | removed.clearShellState() | |
| 256 | + | } | |
| 257 | + | delete(a.browserWindows, id) | |
| 258 | + | fallback := a.deterministicBrowserWindowFallback() | |
| 259 | + | if a.lastFocusedWindowID == id { | |
| 260 | + | if fallback != nil { | |
| 261 | + | a.lastFocusedWindowID = fallback.id | |
| 262 | + | } else { | |
| 263 | + | a.lastFocusedWindowID = "" | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | if wasMainWindow { | |
| 267 | + | a.clearResizeModeBorder() | |
| 268 | + | if fallback != nil { | |
| 269 | + | a.activateBrowserWindow(fallback) | |
| 270 | + | return | |
| 50 | 271 | } | |
| 272 | + | a.lastFocusedWindowID = "" | |
| 51 | 273 | a.mainWindow = nil | |
| 52 | 274 | a.keyboardHandler = nil | |
| 53 | 275 | a.globalShortcutHandler = nil | |
| 54 | 276 | if a.tabCoord != nil { | |
| 55 | 277 | a.tabCoord.SetMainWindow(nil) | |
| 56 | 278 | } | |
| 57 | - | return | |
| 58 | 279 | } | |
| 59 | - | delete(a.browserWindows, id) | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | func (a *App) deterministicBrowserWindowFallback() *browserWindow { | |
| 283 | + | if a.browserWindows == nil { | |
| 284 | + | return nil | |
| 285 | + | } | |
| 286 | + | ids := make([]string, 0, len(a.browserWindows)) | |
| 287 | + | for id, bw := range a.browserWindows { | |
| 288 | + | if id == "" || bw == nil || bw.mainWindow == nil { | |
| 289 | + | continue | |
| 290 | + | } | |
| 291 | + | ids = append(ids, id) | |
| 292 | + | } | |
| 293 | + | sort.Strings(ids) | |
| 294 | + | if len(ids) == 0 { | |
| 295 | + | return nil | |
| 296 | + | } | |
| 297 | + | return a.browserWindows[ids[0]] | |
| 60 | 298 | } | |
| 61 | 299 | ||
| 62 | 300 | func (a *App) OpenFreshWindow(ctx context.Context, url string) error { | |
| @@ -2,12 +2,18 @@ package ui | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "sort" | |||
| 5 | 6 | ||||
| 6 | "github.com/bnema/dumber/internal/application/port" | 7 | "github.com/bnema/dumber/internal/application/port" | ||
| 8 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 7 | "github.com/bnema/dumber/internal/domain/entity" | 9 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 10 | + | "github.com/bnema/dumber/internal/logging" | |||
| 8 | "github.com/bnema/dumber/internal/ui/component" | 11 | "github.com/bnema/dumber/internal/ui/component" | ||
| 12 | + | "github.com/bnema/dumber/internal/ui/focus" | |||
| 9 | "github.com/bnema/dumber/internal/ui/input" | 13 | "github.com/bnema/dumber/internal/ui/input" | ||
| 14 | + | "github.com/bnema/dumber/internal/ui/layout" | |||
| 10 | "github.com/bnema/dumber/internal/ui/window" | 15 | "github.com/bnema/dumber/internal/ui/window" | ||
| 16 | + | "github.com/bnema/puregotk/v4/glib" | |||
| 11 | ) | 17 | ) | ||
| 12 | 18 | ||||
| 13 | type browserWindow struct { | 19 | type browserWindow struct { | ||
| @@ -15,12 +21,215 @@ type browserWindow struct { | |||||
| 15 | initialURL string | 21 | initialURL string | ||
| 16 | activeTabID entity.TabID | 22 | activeTabID entity.TabID | ||
| 17 | mainWindow *window.MainWindow | 23 | mainWindow *window.MainWindow | ||
| 24 | + | appToaster *component.Toaster | |||
| 25 | + | modeToaster *component.Toaster | |||
| 26 | + | borderMgr *focus.BorderManager | |||
| 27 | + | sessionManager *component.SessionManager | |||
| 28 | + | tabPicker *component.TabPicker | |||
| 29 | + | tabPickerWidget layout.Widget | |||
| 30 | + | tabPickerPaneID entity.PaneID | |||
| 31 | + | insertAccentUC *usecase.InsertAccentUseCase | |||
| 32 | + | accentPicker *component.AccentPicker | |||
| 18 | keyboardHandler *input.KeyboardHandler | 33 | keyboardHandler *input.KeyboardHandler | ||
| 19 | globalShortcutHandler *input.GlobalShortcutHandler | 34 | globalShortcutHandler *input.GlobalShortcutHandler | ||
| 20 | permissionDialog port.PermissionDialogPresenter | 35 | permissionDialog port.PermissionDialogPresenter | ||
| 21 | webrtcIndicator *component.WebRTCPermissionIndicator | 36 | webrtcIndicator *component.WebRTCPermissionIndicator | ||
| 22 | } | 37 | } | ||
| 23 | 38 | ||||
| 39 | + | func (bw *browserWindow) clearShellState() { | |||
| 40 | + | if bw == nil { | |||
| 41 | + | return | |||
| 42 | + | } | |||
| 43 | + | bw.appToaster = nil | |||
| 44 | + | bw.modeToaster = nil | |||
| 45 | + | bw.borderMgr = nil | |||
| 46 | + | bw.sessionManager = nil | |||
| 47 | + | bw.tabPicker = nil | |||
| 48 | + | bw.tabPickerWidget = nil | |||
| 49 | + | bw.tabPickerPaneID = "" | |||
| 50 | + | bw.insertAccentUC = nil | |||
| 51 | + | bw.accentPicker = nil | |||
| 52 | + | bw.keyboardHandler = nil | |||
| 53 | + | bw.globalShortcutHandler = nil | |||
| 54 | + | bw.permissionDialog = nil | |||
| 55 | + | bw.webrtcIndicator = nil | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func (bw *browserWindow) initChrome(ctx context.Context, a *App) { | |||
| 59 | + | if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil { | |||
| 60 | + | return | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | bw.initToasterOverlay(a) | |||
| 64 | + | bw.initBorderOverlay(a) | |||
| 65 | + | bw.initAccentPicker(ctx, a) | |||
| 66 | + | bw.initSessionManager(ctx, a) | |||
| 67 | + | bw.initTabPicker(ctx, a) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func (bw *browserWindow) initToasterOverlay(a *App) { | |||
| 71 | + | if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil { | |||
| 72 | + | return | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | bw.appToaster = component.NewToaster(a.widgetFactory) | |||
| 76 | + | if bw.appToaster != nil { | |||
| 77 | + | if widget := bw.appToaster.Widget(); widget != nil { | |||
| 78 | + | if gtkWidget := widget.GtkWidget(); gtkWidget != nil { | |||
| 79 | + | bw.mainWindow.AddOverlay(gtkWidget) | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | bw.modeToaster = component.NewToaster(a.widgetFactory) | |||
| 85 | + | if bw.modeToaster != nil { | |||
| 86 | + | if widget := bw.modeToaster.Widget(); widget != nil { | |||
| 87 | + | if gtkWidget := widget.GtkWidget(); gtkWidget != nil { | |||
| 88 | + | bw.mainWindow.AddOverlay(gtkWidget) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func (bw *browserWindow) initBorderOverlay(a *App) { | |||
| 95 | + | if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil { | |||
| 96 | + | return | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | bw.borderMgr = focus.NewBorderManager(a.widgetFactory) | |||
| 100 | + | if bw.borderMgr == nil { | |||
| 101 | + | return | |||
| 102 | + | } | |||
| 103 | + | if widget := bw.borderMgr.Widget(); widget != nil { | |||
| 104 | + | if gtkWidget := widget.GtkWidget(); gtkWidget != nil { | |||
| 105 | + | bw.mainWindow.AddOverlay(gtkWidget) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func (bw *browserWindow) initAccentPicker(ctx context.Context, a *App) { | |||
| 111 | + | log := logging.FromContext(ctx) | |||
| 112 | + | if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil || a.deps == nil { | |||
| 113 | + | log.Debug().Msg("widget factory, deps, or main window not available, skipping accent picker") | |||
| 114 | + | return | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | bw.accentPicker = component.NewAccentPicker(a.widgetFactory) | |||
| 118 | + | if bw.accentPicker == nil { | |||
| 119 | + | log.Warn().Msg("failed to create accent picker") | |||
| 120 | + | return | |||
| 121 | + | } | |||
| 122 | + | if widget := bw.accentPicker.Widget(); widget != nil { | |||
| 123 | + | if gtkWidget := widget.GtkWidget(); gtkWidget != nil { | |||
| 124 | + | bw.mainWindow.AddOverlay(gtkWidget) | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | a.accentFocusProvider = a.deps.AccentFocusProvider | |||
| 129 | + | bw.insertAccentUC = usecase.NewInsertAccentUseCase( | |||
| 130 | + | a.accentFocusProvider, | |||
| 131 | + | bw.accentPicker, | |||
| 132 | + | func(fn func()) { | |||
| 133 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 134 | + | fn() | |||
| 135 | + | return false | |||
| 136 | + | }) | |||
| 137 | + | glib.IdleAdd(&cb, 0) | |||
| 138 | + | }, | |||
| 139 | + | ) | |||
| 140 | + | ||||
| 141 | + | log.Debug().Msg("accent picker initialized") | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func (bw *browserWindow) initSessionManager(ctx context.Context, a *App) { | |||
| 145 | + | log := logging.FromContext(ctx) | |||
| 146 | + | if bw == nil || a == nil || a.deps == nil || a.deps.Config == nil || bw.mainWindow == nil { | |||
| 147 | + | log.Debug().Msg("deps or main window not available, skipping session manager") | |||
| 148 | + | return | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | var listSessionsUC *usecase.ListSessionsUseCase | |||
| 152 | + | var deleteSessionUC *usecase.DeleteSessionUseCase | |||
| 153 | + | if a.deps.SessionRepo != nil && a.deps.SessionStateRepo != nil { | |||
| 154 | + | listSessionsUC = usecase.NewListSessionsUseCase( | |||
| 155 | + | a.deps.SessionRepo, | |||
| 156 | + | a.deps.SessionStateRepo, | |||
| 157 | + | ) | |||
| 158 | + | deleteSessionUC = usecase.NewDeleteSessionUseCase( | |||
| 159 | + | a.deps.SessionStateRepo, | |||
| 160 | + | a.deps.SessionRepo, | |||
| 161 | + | ) | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | bw.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{ | |||
| 165 | + | ListSessionsUC: listSessionsUC, | |||
| 166 | + | DeleteSessionUC: deleteSessionUC, | |||
| 167 | + | CurrentSession: a.deps.CurrentSessionID, | |||
| 168 | + | UIScale: a.deps.Config.DefaultUIScale, | |||
| 169 | + | OnClose: func() { | |||
| 170 | + | log.Debug().Msg("session manager closed") | |||
| 171 | + | }, | |||
| 172 | + | OnOpen: func(sessionID entity.SessionID) { | |||
| 173 | + | log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested") | |||
| 174 | + | if spawner := a.deps.SessionSpawner; spawner == nil { | |||
| 175 | + | log.Warn().Str("session_id", string(sessionID)).Msg("session spawner not available") | |||
| 176 | + | return | |||
| 177 | + | } else if err := spawner.SpawnWithSession(sessionID); err != nil { | |||
| 178 | + | log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session") | |||
| 179 | + | } | |||
| 180 | + | }, | |||
| 181 | + | OnToast: func(ctx context.Context, message string, level component.ToastLevel) { | |||
| 182 | + | if bw.appToaster != nil { | |||
| 183 | + | bw.appToaster.Show(ctx, message, level) | |||
| 184 | + | } | |||
| 185 | + | }, | |||
| 186 | + | }) | |||
| 187 | + | ||||
| 188 | + | if bw.sessionManager == nil { | |||
| 189 | + | log.Warn().Msg("failed to create session manager") | |||
| 190 | + | return | |||
| 191 | + | } | |||
| 192 | + | if widget := bw.sessionManager.Widget(); widget != nil { | |||
| 193 | + | bw.mainWindow.AddOverlay(widget) | |||
| 194 | + | } | |||
| 195 | + | log.Debug().Msg("session manager initialized") | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | func (bw *browserWindow) initTabPicker(ctx context.Context, a *App) { | |||
| 199 | + | log := logging.FromContext(ctx) | |||
| 200 | + | if bw == nil || a == nil || a.deps == nil || a.deps.Config == nil { | |||
| 201 | + | log.Debug().Msg("deps/config not available, skipping tab picker") | |||
| 202 | + | return | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | bw.tabPicker = component.NewTabPicker(ctx, component.TabPickerConfig{ | |||
| 206 | + | UIScale: a.deps.Config.DefaultUIScale, | |||
| 207 | + | OnClose: func() { | |||
| 208 | + | log.Debug().Msg("tab picker closed") | |||
| 209 | + | }, | |||
| 210 | + | OnSelect: func(item component.TabPickerItem) { | |||
| 211 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 212 | + | var targetID entity.TabID | |||
| 213 | + | if !item.IsNew { | |||
| 214 | + | targetID = item.TabID | |||
| 215 | + | } | |||
| 216 | + | if err := a.moveActivePaneToTabFromBrowserWindow(ctx, bw, targetID); err != nil { | |||
| 217 | + | log.Warn().Err(err).Msg("move pane to tab failed") | |||
| 218 | + | } | |||
| 219 | + | return false | |||
| 220 | + | }) | |||
| 221 | + | glib.IdleAdd(&cb, 0) | |||
| 222 | + | }, | |||
| 223 | + | }) | |||
| 224 | + | ||||
| 225 | + | if bw.tabPicker == nil { | |||
| 226 | + | log.Warn().Msg("failed to create tab picker") | |||
| 227 | + | return | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | log.Debug().Msg("tab picker initialized") | |||
| 231 | + | } | |||
| 232 | + | ||||
| 24 | func (a *App) registerBrowserWindow(bw *browserWindow) { | 233 | func (a *App) registerBrowserWindow(bw *browserWindow) { | ||
| 25 | if bw == nil { | 234 | if bw == nil { | ||
| 26 | return | 235 | return | ||
| @@ -35,28 +244,57 @@ func (a *App) removeBrowserWindow(id string) { | |||||
| 35 | if id == "" || a.browserWindows == nil { | 244 | if id == "" || a.browserWindows == nil { | ||
| 36 | return | 245 | return | ||
| 37 | } | 246 | } | ||
| 247 | + | removed := a.browserWindows[id] | |||
| 248 | + | wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow | |||
| 38 | for tabID, bw := range a.windowForTab { | 249 | for tabID, bw := range a.windowForTab { | ||
| 39 | if bw != nil && bw.id == id { | 250 | if bw != nil && bw.id == id { | ||
| 40 | delete(a.windowForTab, tabID) | 251 | delete(a.windowForTab, tabID) | ||
| 41 | } | 252 | } | ||
| 42 | } | 253 | } | ||
| 43 | - | if bw := a.browserWindows[id]; bw != nil && bw.mainWindow == a.mainWindow { | 254 | + | if removed != nil { |
| 44 | - | delete(a.browserWindows, id) | 255 | + | removed.clearShellState() |
| 45 | - | for _, remaining := range a.browserWindows { | 256 | + | } |
| 46 | - | if remaining != nil && remaining.mainWindow != nil { | 257 | + | delete(a.browserWindows, id) |
| 47 | - | a.activateBrowserWindow(remaining) | 258 | + | fallback := a.deterministicBrowserWindowFallback() |
| 48 | - | return | 259 | + | if a.lastFocusedWindowID == id { |
| 49 | - | } | 260 | + | if fallback != nil { |
| 261 | + | a.lastFocusedWindowID = fallback.id | |||
| 262 | + | } else { | |||
| 263 | + | a.lastFocusedWindowID = "" | |||
| 264 | + | } | |||
| 265 | + | } | |||
| 266 | + | if wasMainWindow { | |||
| 267 | + | a.clearResizeModeBorder() | |||
| 268 | + | if fallback != nil { | |||
| 269 | + | a.activateBrowserWindow(fallback) | |||
| 270 | + | return | |||
| 50 | } | 271 | } | ||
| 272 | + | a.lastFocusedWindowID = "" | |||
| 51 | a.mainWindow = nil | 273 | a.mainWindow = nil | ||
| 52 | a.keyboardHandler = nil | 274 | a.keyboardHandler = nil | ||
| 53 | a.globalShortcutHandler = nil | 275 | a.globalShortcutHandler = nil | ||
| 54 | if a.tabCoord != nil { | 276 | if a.tabCoord != nil { | ||
| 55 | a.tabCoord.SetMainWindow(nil) | 277 | a.tabCoord.SetMainWindow(nil) | ||
| 56 | } | 278 | } | ||
| 57 | - | return | |||
| 58 | } | 279 | } | ||
| 59 | - | delete(a.browserWindows, id) | 280 | + | } |
| 281 | + | ||||
| 282 | + | func (a *App) deterministicBrowserWindowFallback() *browserWindow { | |||
| 283 | + | if a.browserWindows == nil { | |||
| 284 | + | return nil | |||
| 285 | + | } | |||
| 286 | + | ids := make([]string, 0, len(a.browserWindows)) | |||
| 287 | + | for id, bw := range a.browserWindows { | |||
| 288 | + | if id == "" || bw == nil || bw.mainWindow == nil { | |||
| 289 | + | continue | |||
| 290 | + | } | |||
| 291 | + | ids = append(ids, id) | |||
| 292 | + | } | |||
| 293 | + | sort.Strings(ids) | |||
| 294 | + | if len(ids) == 0 { | |||
| 295 | + | return nil | |||
| 296 | + | } | |||
| 297 | + | return a.browserWindows[ids[0]] | |||
| 60 | } | 298 | } | ||
| 61 | 299 | ||||
| 62 | func (a *App) OpenFreshWindow(ctx context.Context, url string) error { | 300 | func (a *App) OpenFreshWindow(ctx context.Context, url string) error { | ||
M internal/ui/browser_window_test.go +130 -0
| @@ -3,11 +3,64 @@ package ui | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | + | "reflect" | |
| 6 | 7 | "testing" | |
| 8 | + | "unsafe" | |
| 7 | 9 | ||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |
| 11 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | + | "github.com/bnema/dumber/internal/ui/component" | |
| 13 | + | "github.com/bnema/dumber/internal/ui/focus" | |
| 14 | + | "github.com/bnema/dumber/internal/ui/layout" | |
| 8 | 15 | "github.com/bnema/dumber/internal/ui/window" | |
| 16 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 9 | 17 | ) | |
| 10 | 18 | ||
| 19 | + | func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) { | |
| 20 | + | mainWindow := &window.MainWindow{} | |
| 21 | + | removed := &browserWindow{id: "window-1", mainWindow: mainWindow} | |
| 22 | + | remaining := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}} | |
| 23 | + | app := &App{ | |
| 24 | + | mainWindow: mainWindow, | |
| 25 | + | browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining}, | |
| 26 | + | lastFocusedWindowID: remaining.id, | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | setShellField(t, removed, "appToaster", &component.Toaster{}) | |
| 30 | + | setShellField(t, removed, "modeToaster", &component.Toaster{}) | |
| 31 | + | setShellField(t, removed, "borderMgr", &focus.BorderManager{}) | |
| 32 | + | setShellField(t, removed, "sessionManager", &component.SessionManager{}) | |
| 33 | + | setShellField(t, removed, "tabPicker", &component.TabPicker{}) | |
| 34 | + | setShellField(t, removed, "tabPickerWidget", (*testLayoutWidget)(nil)) | |
| 35 | + | setShellField(t, removed, "tabPickerPaneID", entity.PaneID("pane-1")) | |
| 36 | + | setShellField(t, removed, "insertAccentUC", newTestAccentUseCase(t, false)) | |
| 37 | + | setShellField(t, removed, "accentPicker", &component.AccentPicker{}) | |
| 38 | + | setShellField(t, removed, "permissionDialog", (*testPermissionDialogPresenter)(nil)) | |
| 39 | + | setShellField(t, removed, "webrtcIndicator", &component.WebRTCPermissionIndicator{}) | |
| 40 | + | ||
| 41 | + | app.removeBrowserWindow(removed.id) | |
| 42 | + | ||
| 43 | + | for _, name := range []string{ | |
| 44 | + | "appToaster", | |
| 45 | + | "modeToaster", | |
| 46 | + | "borderMgr", | |
| 47 | + | "sessionManager", | |
| 48 | + | "tabPicker", | |
| 49 | + | "tabPickerWidget", | |
| 50 | + | "tabPickerPaneID", | |
| 51 | + | "insertAccentUC", | |
| 52 | + | "accentPicker", | |
| 53 | + | "keyboardHandler", | |
| 54 | + | "globalShortcutHandler", | |
| 55 | + | "permissionDialog", | |
| 56 | + | "webrtcIndicator", | |
| 57 | + | } { | |
| 58 | + | if !fieldIsZero(t, removed, name) { | |
| 59 | + | t.Fatalf("browserWindow.%s was not cleared", name) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 11 | 64 | func TestBrowserWindow_RegisterAndRemoveTrackCollection(t *testing.T) { | |
| 12 | 65 | app := &App{browserWindows: make(map[string]*browserWindow)} | |
| 13 | 66 | first := &browserWindow{id: "window-1"} | |
| @@ -77,6 +130,25 @@ func TestBrowserWindow_RemoveLastClearsMainWindow(t *testing.T) { | |||
| 77 | 130 | } | |
| 78 | 131 | } | |
| 79 | 132 | ||
| 133 | + | func TestBrowserWindow_RemovePromotedWindowClearsResizeModeBorderTarget(t *testing.T) { | |
| 134 | + | mainWindow := &window.MainWindow{} | |
| 135 | + | otherWindow := &window.MainWindow{} | |
| 136 | + | removed := &browserWindow{id: "window-1", mainWindow: mainWindow} | |
| 137 | + | remaining := &browserWindow{id: "window-2", mainWindow: otherWindow} | |
| 138 | + | resizeTarget := &testLayoutWidget{} | |
| 139 | + | app := &App{ | |
| 140 | + | mainWindow: mainWindow, | |
| 141 | + | browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining}, | |
| 142 | + | resizeModeBorderTarget: resizeTarget, | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | app.removeBrowserWindow(removed.id) | |
| 146 | + | ||
| 147 | + | if app.resizeModeBorderTarget != nil { | |
| 148 | + | t.Fatalf("resizeModeBorderTarget = %p, want nil", app.resizeModeBorderTarget) | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 80 | 152 | func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) { | |
| 81 | 153 | app := &App{browserWindows: make(map[string]*browserWindow)} | |
| 82 | 154 | ||
| @@ -128,3 +200,61 @@ func TestOpenFreshWindow_PropagatesFactoryError(t *testing.T) { | |||
| 128 | 200 | t.Fatalf("browserWindows length = %d, want 0", got) | |
| 129 | 201 | } | |
| 130 | 202 | } | |
| 203 | + | ||
| 204 | + | func setShellField(t *testing.T, bw *browserWindow, name string, value any) { | |
| 205 | + | t.Helper() | |
| 206 | + | rv := reflect.ValueOf(bw).Elem() | |
| 207 | + | field := rv.FieldByName(name) | |
| 208 | + | if !field.IsValid() { | |
| 209 | + | t.Fatalf("browserWindow missing field %s", name) | |
| 210 | + | } | |
| 211 | + | reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Set(reflect.ValueOf(value)) | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func fieldIsZero(t *testing.T, bw *browserWindow, name string) bool { | |
| 215 | + | t.Helper() | |
| 216 | + | rv := reflect.ValueOf(bw).Elem() | |
| 217 | + | field := rv.FieldByName(name) | |
| 218 | + | if !field.IsValid() { | |
| 219 | + | t.Fatalf("browserWindow missing field %s", name) | |
| 220 | + | } | |
| 221 | + | return field.IsZero() | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | type testLayoutWidget struct{} | |
| 225 | + | ||
| 226 | + | func (*testLayoutWidget) Show() {} | |
| 227 | + | func (*testLayoutWidget) Hide() {} | |
| 228 | + | func (*testLayoutWidget) SetVisible(bool) {} | |
| 229 | + | func (*testLayoutWidget) IsVisible() bool { return false } | |
| 230 | + | func (*testLayoutWidget) SetOpacity(float64) {} | |
| 231 | + | func (*testLayoutWidget) GrabFocus() bool { return false } | |
| 232 | + | func (*testLayoutWidget) HasFocus() bool { return false } | |
| 233 | + | func (*testLayoutWidget) SetCanFocus(bool) {} | |
| 234 | + | func (*testLayoutWidget) SetFocusable(bool) {} | |
| 235 | + | func (*testLayoutWidget) SetFocusOnClick(bool) {} | |
| 236 | + | func (*testLayoutWidget) SetCanTarget(bool) {} | |
| 237 | + | func (*testLayoutWidget) SetHexpand(bool) {} | |
| 238 | + | func (*testLayoutWidget) SetVexpand(bool) {} | |
| 239 | + | func (*testLayoutWidget) GetHexpand() bool { return false } | |
| 240 | + | func (*testLayoutWidget) GetVexpand() bool { return false } | |
| 241 | + | func (*testLayoutWidget) SetHalign(gtk.Align) {} | |
| 242 | + | func (*testLayoutWidget) SetValign(gtk.Align) {} | |
| 243 | + | func (*testLayoutWidget) SetSizeRequest(int, int) {} | |
| 244 | + | func (*testLayoutWidget) GetAllocatedWidth() int { return 0 } | |
| 245 | + | func (*testLayoutWidget) GetAllocatedHeight() int { return 0 } | |
| 246 | + | func (*testLayoutWidget) ComputePoint(layout.Widget) (float64, float64, bool) { | |
| 247 | + | return 0, 0, false | |
| 248 | + | } | |
| 249 | + | func (*testLayoutWidget) AddCssClass(string) {} | |
| 250 | + | func (*testLayoutWidget) RemoveCssClass(string) {} | |
| 251 | + | func (*testLayoutWidget) HasCssClass(string) bool { return false } | |
| 252 | + | func (*testLayoutWidget) Unparent() {} | |
| 253 | + | func (*testLayoutWidget) GetParent() layout.Widget { return nil } | |
| 254 | + | func (*testLayoutWidget) GtkWidget() *gtk.Widget { return nil } | |
| 255 | + | func (*testLayoutWidget) AddController(*gtk.EventController) {} | |
| 256 | + | ||
| 257 | + | type testPermissionDialogPresenter struct{} | |
| 258 | + | ||
| 259 | + | func (*testPermissionDialogPresenter) ShowPermissionDialog(context.Context, string, []entity.PermissionType, entity.PermissionMetadata, func(port.PermissionDialogResult)) { | |
| 260 | + | } | |
| @@ -3,11 +3,64 @@ package ui | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | + | "reflect" | |||
| 6 | "testing" | 7 | "testing" | ||
| 8 | + | "unsafe" | |||
| 7 | 9 | ||||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 11 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 12 | + | "github.com/bnema/dumber/internal/ui/component" | |||
| 13 | + | "github.com/bnema/dumber/internal/ui/focus" | |||
| 14 | + | "github.com/bnema/dumber/internal/ui/layout" | |||
| 8 | "github.com/bnema/dumber/internal/ui/window" | 15 | "github.com/bnema/dumber/internal/ui/window" | ||
| 16 | + | "github.com/bnema/puregotk/v4/gtk" | |||
| 9 | ) | 17 | ) | ||
| 10 | 18 | ||||
| 19 | + | func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) { | |||
| 20 | + | mainWindow := &window.MainWindow{} | |||
| 21 | + | removed := &browserWindow{id: "window-1", mainWindow: mainWindow} | |||
| 22 | + | remaining := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}} | |||
| 23 | + | app := &App{ | |||
| 24 | + | mainWindow: mainWindow, | |||
| 25 | + | browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining}, | |||
| 26 | + | lastFocusedWindowID: remaining.id, | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | setShellField(t, removed, "appToaster", &component.Toaster{}) | |||
| 30 | + | setShellField(t, removed, "modeToaster", &component.Toaster{}) | |||
| 31 | + | setShellField(t, removed, "borderMgr", &focus.BorderManager{}) | |||
| 32 | + | setShellField(t, removed, "sessionManager", &component.SessionManager{}) | |||
| 33 | + | setShellField(t, removed, "tabPicker", &component.TabPicker{}) | |||
| 34 | + | setShellField(t, removed, "tabPickerWidget", (*testLayoutWidget)(nil)) | |||
| 35 | + | setShellField(t, removed, "tabPickerPaneID", entity.PaneID("pane-1")) | |||
| 36 | + | setShellField(t, removed, "insertAccentUC", newTestAccentUseCase(t, false)) | |||
| 37 | + | setShellField(t, removed, "accentPicker", &component.AccentPicker{}) | |||
| 38 | + | setShellField(t, removed, "permissionDialog", (*testPermissionDialogPresenter)(nil)) | |||
| 39 | + | setShellField(t, removed, "webrtcIndicator", &component.WebRTCPermissionIndicator{}) | |||
| 40 | + | ||||
| 41 | + | app.removeBrowserWindow(removed.id) | |||
| 42 | + | ||||
| 43 | + | for _, name := range []string{ | |||
| 44 | + | "appToaster", | |||
| 45 | + | "modeToaster", | |||
| 46 | + | "borderMgr", | |||
| 47 | + | "sessionManager", | |||
| 48 | + | "tabPicker", | |||
| 49 | + | "tabPickerWidget", | |||
| 50 | + | "tabPickerPaneID", | |||
| 51 | + | "insertAccentUC", | |||
| 52 | + | "accentPicker", | |||
| 53 | + | "keyboardHandler", | |||
| 54 | + | "globalShortcutHandler", | |||
| 55 | + | "permissionDialog", | |||
| 56 | + | "webrtcIndicator", | |||
| 57 | + | } { | |||
| 58 | + | if !fieldIsZero(t, removed, name) { | |||
| 59 | + | t.Fatalf("browserWindow.%s was not cleared", name) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 11 | func TestBrowserWindow_RegisterAndRemoveTrackCollection(t *testing.T) { | 64 | func TestBrowserWindow_RegisterAndRemoveTrackCollection(t *testing.T) { | ||
| 12 | app := &App{browserWindows: make(map[string]*browserWindow)} | 65 | app := &App{browserWindows: make(map[string]*browserWindow)} | ||
| 13 | first := &browserWindow{id: "window-1"} | 66 | first := &browserWindow{id: "window-1"} | ||
| @@ -77,6 +130,25 @@ func TestBrowserWindow_RemoveLastClearsMainWindow(t *testing.T) { | |||||
| 77 | } | 130 | } | ||
| 78 | } | 131 | } | ||
| 79 | 132 | ||||
| 133 | + | func TestBrowserWindow_RemovePromotedWindowClearsResizeModeBorderTarget(t *testing.T) { | |||
| 134 | + | mainWindow := &window.MainWindow{} | |||
| 135 | + | otherWindow := &window.MainWindow{} | |||
| 136 | + | removed := &browserWindow{id: "window-1", mainWindow: mainWindow} | |||
| 137 | + | remaining := &browserWindow{id: "window-2", mainWindow: otherWindow} | |||
| 138 | + | resizeTarget := &testLayoutWidget{} | |||
| 139 | + | app := &App{ | |||
| 140 | + | mainWindow: mainWindow, | |||
| 141 | + | browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining}, | |||
| 142 | + | resizeModeBorderTarget: resizeTarget, | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | app.removeBrowserWindow(removed.id) | |||
| 146 | + | ||||
| 147 | + | if app.resizeModeBorderTarget != nil { | |||
| 148 | + | t.Fatalf("resizeModeBorderTarget = %p, want nil", app.resizeModeBorderTarget) | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | ||||
| 80 | func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) { | 152 | func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) { | ||
| 81 | app := &App{browserWindows: make(map[string]*browserWindow)} | 153 | app := &App{browserWindows: make(map[string]*browserWindow)} | ||
| 82 | 154 | ||||
| @@ -128,3 +200,61 @@ func TestOpenFreshWindow_PropagatesFactoryError(t *testing.T) { | |||||
| 128 | t.Fatalf("browserWindows length = %d, want 0", got) | 200 | t.Fatalf("browserWindows length = %d, want 0", got) | ||
| 129 | } | 201 | } | ||
| 130 | } | 202 | } | ||
| 203 | + | ||||
| 204 | + | func setShellField(t *testing.T, bw *browserWindow, name string, value any) { | |||
| 205 | + | t.Helper() | |||
| 206 | + | rv := reflect.ValueOf(bw).Elem() | |||
| 207 | + | field := rv.FieldByName(name) | |||
| 208 | + | if !field.IsValid() { | |||
| 209 | + | t.Fatalf("browserWindow missing field %s", name) | |||
| 210 | + | } | |||
| 211 | + | reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Set(reflect.ValueOf(value)) | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | func fieldIsZero(t *testing.T, bw *browserWindow, name string) bool { | |||
| 215 | + | t.Helper() | |||
| 216 | + | rv := reflect.ValueOf(bw).Elem() | |||
| 217 | + | field := rv.FieldByName(name) | |||
| 218 | + | if !field.IsValid() { | |||
| 219 | + | t.Fatalf("browserWindow missing field %s", name) | |||
| 220 | + | } | |||
| 221 | + | return field.IsZero() | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | type testLayoutWidget struct{} | |||
| 225 | + | ||||
| 226 | + | func (*testLayoutWidget) Show() {} | |||
| 227 | + | func (*testLayoutWidget) Hide() {} | |||
| 228 | + | func (*testLayoutWidget) SetVisible(bool) {} | |||
| 229 | + | func (*testLayoutWidget) IsVisible() bool { return false } | |||
| 230 | + | func (*testLayoutWidget) SetOpacity(float64) {} | |||
| 231 | + | func (*testLayoutWidget) GrabFocus() bool { return false } | |||
| 232 | + | func (*testLayoutWidget) HasFocus() bool { return false } | |||
| 233 | + | func (*testLayoutWidget) SetCanFocus(bool) {} | |||
| 234 | + | func (*testLayoutWidget) SetFocusable(bool) {} | |||
| 235 | + | func (*testLayoutWidget) SetFocusOnClick(bool) {} | |||
| 236 | + | func (*testLayoutWidget) SetCanTarget(bool) {} | |||
| 237 | + | func (*testLayoutWidget) SetHexpand(bool) {} | |||
| 238 | + | func (*testLayoutWidget) SetVexpand(bool) {} | |||
| 239 | + | func (*testLayoutWidget) GetHexpand() bool { return false } | |||
| 240 | + | func (*testLayoutWidget) GetVexpand() bool { return false } | |||
| 241 | + | func (*testLayoutWidget) SetHalign(gtk.Align) {} | |||
| 242 | + | func (*testLayoutWidget) SetValign(gtk.Align) {} | |||
| 243 | + | func (*testLayoutWidget) SetSizeRequest(int, int) {} | |||
| 244 | + | func (*testLayoutWidget) GetAllocatedWidth() int { return 0 } | |||
| 245 | + | func (*testLayoutWidget) GetAllocatedHeight() int { return 0 } | |||
| 246 | + | func (*testLayoutWidget) ComputePoint(layout.Widget) (float64, float64, bool) { | |||
| 247 | + | return 0, 0, false | |||
| 248 | + | } | |||
| 249 | + | func (*testLayoutWidget) AddCssClass(string) {} | |||
| 250 | + | func (*testLayoutWidget) RemoveCssClass(string) {} | |||
| 251 | + | func (*testLayoutWidget) HasCssClass(string) bool { return false } | |||
| 252 | + | func (*testLayoutWidget) Unparent() {} | |||
| 253 | + | func (*testLayoutWidget) GetParent() layout.Widget { return nil } | |||
| 254 | + | func (*testLayoutWidget) GtkWidget() *gtk.Widget { return nil } | |||
| 255 | + | func (*testLayoutWidget) AddController(*gtk.EventController) {} | |||
| 256 | + | ||||
| 257 | + | type testPermissionDialogPresenter struct{} | |||
| 258 | + | ||||
| 259 | + | func (*testPermissionDialogPresenter) ShowPermissionDialog(context.Context, string, []entity.PermissionType, entity.PermissionMetadata, func(port.PermissionDialogResult)) { | |||
| 260 | + | } | |||
M internal/ui/config_migration.go +2 -2
| @@ -32,8 +32,8 @@ func (a *App) checkConfigMigration(ctx context.Context) { | |||
| 32 | 32 | Msg("config migration available") | |
| 33 | 33 | ||
| 34 | 34 | // Show toast notification if enabled | |
| 35 | - | if a.deps.Config != nil && a.deps.Config.Update.NotifyOnNewSettings && a.appToaster != nil { | |
| 35 | + | if a.deps.Config != nil && a.deps.Config.Update.NotifyOnNewSettings { | |
| 36 | 36 | msg := fmt.Sprintf("Config has %d new settings. Run 'dumber config migrate'", len(result.MissingKeys)) | |
| 37 | - | a.appToaster.Show(ctx, msg, component.ToastInfo) | |
| 37 | + | a.showToastOnLastFocusedBrowserWindow(ctx, msg, component.ToastInfo) | |
| 38 | 38 | } | |
| 39 | 39 | } | |
| @@ -32,8 +32,8 @@ func (a *App) checkConfigMigration(ctx context.Context) { | |||||
| 32 | Msg("config migration available") | 32 | Msg("config migration available") | ||
| 33 | 33 | ||||
| 34 | // Show toast notification if enabled | 34 | // Show toast notification if enabled | ||
| 35 | - | if a.deps.Config != nil && a.deps.Config.Update.NotifyOnNewSettings && a.appToaster != nil { | 35 | + | if a.deps.Config != nil && a.deps.Config.Update.NotifyOnNewSettings { |
| 36 | msg := fmt.Sprintf("Config has %d new settings. Run 'dumber config migrate'", len(result.MissingKeys)) | 36 | msg := fmt.Sprintf("Config has %d new settings. Run 'dumber config migrate'", len(result.MissingKeys)) | ||
| 37 | - | a.appToaster.Show(ctx, msg, component.ToastInfo) | 37 | + | a.showToastOnLastFocusedBrowserWindow(ctx, msg, component.ToastInfo) |
| 38 | } | 38 | } | ||
| 39 | } | 39 | } | ||
M internal/ui/coordinator/content/callbacks.go +2 -2
| @@ -146,7 +146,7 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P | |||
| 146 | 146 | } | |
| 147 | 147 | } | |
| 148 | 148 | if c.onFullscreenChanged != nil { | |
| 149 | - | c.onFullscreenChanged(true) | |
| 149 | + | c.onFullscreenChanged(paneID, true) | |
| 150 | 150 | } | |
| 151 | 151 | return false // Allow fullscreen | |
| 152 | 152 | } | |
| @@ -158,7 +158,7 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P | |||
| 158 | 158 | } | |
| 159 | 159 | } | |
| 160 | 160 | if c.onFullscreenChanged != nil { | |
| 161 | - | c.onFullscreenChanged(false) | |
| 161 | + | c.onFullscreenChanged(paneID, false) | |
| 162 | 162 | } | |
| 163 | 163 | return false // Allow leaving fullscreen | |
| 164 | 164 | } | |
| @@ -146,7 +146,7 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P | |||||
| 146 | } | 146 | } | ||
| 147 | } | 147 | } | ||
| 148 | if c.onFullscreenChanged != nil { | 148 | if c.onFullscreenChanged != nil { | ||
| 149 | - | c.onFullscreenChanged(true) | 149 | + | c.onFullscreenChanged(paneID, true) |
| 150 | } | 150 | } | ||
| 151 | return false // Allow fullscreen | 151 | return false // Allow fullscreen | ||
| 152 | } | 152 | } | ||
| @@ -158,7 +158,7 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P | |||||
| 158 | } | 158 | } | ||
| 159 | } | 159 | } | ||
| 160 | if c.onFullscreenChanged != nil { | 160 | if c.onFullscreenChanged != nil { | ||
| 161 | - | c.onFullscreenChanged(false) | 161 | + | c.onFullscreenChanged(paneID, false) |
| 162 | } | 162 | } | ||
| 163 | return false // Allow leaving fullscreen | 163 | return false // Allow leaving fullscreen | ||
| 164 | } | 164 | } | ||
M internal/ui/coordinator/content/coordinator.go +4 -4
| @@ -56,7 +56,7 @@ type Coordinator struct { | |||
| 56 | 56 | onPaneURIUpdated func(paneID entity.PaneID, url string) | |
| 57 | 57 | ||
| 58 | 58 | // Callback when active pane title changes (for window title updates) | |
| 59 | - | onWindowTitleChanged func(title string) | |
| 59 | + | onWindowTitleChanged func(paneID entity.PaneID, title string) | |
| 60 | 60 | ||
| 61 | 61 | // Callback when media permission activity changes (requesting/allowed/blocked). | |
| 62 | 62 | onPermissionActivity func(paneID entity.PaneID, origin string, permTypes []entity.PermissionType, state PermissionActivityState) | |
| @@ -103,7 +103,7 @@ type Coordinator struct { | |||
| 103 | 103 | idleInhibitor port.IdleInhibitor | |
| 104 | 104 | ||
| 105 | 105 | // Callback when fullscreen state changes (for hiding/showing tab bar) | |
| 106 | - | onFullscreenChanged func(entering bool) | |
| 106 | + | onFullscreenChanged func(paneID entity.PaneID, entering bool) | |
| 107 | 107 | ||
| 108 | 108 | // Callback when WebView gains focus (for accent picker text input targeting) | |
| 109 | 109 | onWebViewFocused func(paneID entity.PaneID, wv port.WebView) | |
| @@ -203,7 +203,7 @@ func (c *Coordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url stri | |||
| 203 | 203 | } | |
| 204 | 204 | ||
| 205 | 205 | // SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates). | |
| 206 | - | func (c *Coordinator) SetOnWindowTitleChanged(fn func(title string)) { | |
| 206 | + | func (c *Coordinator) SetOnWindowTitleChanged(fn func(paneID entity.PaneID, title string)) { | |
| 207 | 207 | c.onWindowTitleChanged = fn | |
| 208 | 208 | } | |
| 209 | 209 | ||
| @@ -223,7 +223,7 @@ func (c *Coordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) { | |||
| 223 | 223 | } | |
| 224 | 224 | ||
| 225 | 225 | // SetOnFullscreenChanged sets the callback for fullscreen state changes. | |
| 226 | - | func (c *Coordinator) SetOnFullscreenChanged(fn func(entering bool)) { | |
| 226 | + | func (c *Coordinator) SetOnFullscreenChanged(fn func(paneID entity.PaneID, entering bool)) { | |
| 227 | 227 | c.onFullscreenChanged = fn | |
| 228 | 228 | } | |
| 229 | 229 | ||
| @@ -56,7 +56,7 @@ type Coordinator struct { | |||||
| 56 | onPaneURIUpdated func(paneID entity.PaneID, url string) | 56 | onPaneURIUpdated func(paneID entity.PaneID, url string) | ||
| 57 | 57 | ||||
| 58 | // Callback when active pane title changes (for window title updates) | 58 | // Callback when active pane title changes (for window title updates) | ||
| 59 | - | onWindowTitleChanged func(title string) | 59 | + | onWindowTitleChanged func(paneID entity.PaneID, title string) |
| 60 | 60 | ||||
| 61 | // Callback when media permission activity changes (requesting/allowed/blocked). | 61 | // Callback when media permission activity changes (requesting/allowed/blocked). | ||
| 62 | onPermissionActivity func(paneID entity.PaneID, origin string, permTypes []entity.PermissionType, state PermissionActivityState) | 62 | onPermissionActivity func(paneID entity.PaneID, origin string, permTypes []entity.PermissionType, state PermissionActivityState) | ||
| @@ -103,7 +103,7 @@ type Coordinator struct { | |||||
| 103 | idleInhibitor port.IdleInhibitor | 103 | idleInhibitor port.IdleInhibitor | ||
| 104 | 104 | ||||
| 105 | // Callback when fullscreen state changes (for hiding/showing tab bar) | 105 | // Callback when fullscreen state changes (for hiding/showing tab bar) | ||
| 106 | - | onFullscreenChanged func(entering bool) | 106 | + | onFullscreenChanged func(paneID entity.PaneID, entering bool) |
| 107 | 107 | ||||
| 108 | // Callback when WebView gains focus (for accent picker text input targeting) | 108 | // Callback when WebView gains focus (for accent picker text input targeting) | ||
| 109 | onWebViewFocused func(paneID entity.PaneID, wv port.WebView) | 109 | onWebViewFocused func(paneID entity.PaneID, wv port.WebView) | ||
| @@ -203,7 +203,7 @@ func (c *Coordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url stri | |||||
| 203 | } | 203 | } | ||
| 204 | 204 | ||||
| 205 | // SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates). | 205 | // SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates). | ||
| 206 | - | func (c *Coordinator) SetOnWindowTitleChanged(fn func(title string)) { | 206 | + | func (c *Coordinator) SetOnWindowTitleChanged(fn func(paneID entity.PaneID, title string)) { |
| 207 | c.onWindowTitleChanged = fn | 207 | c.onWindowTitleChanged = fn | ||
| 208 | } | 208 | } | ||
| 209 | 209 | ||||
| @@ -223,7 +223,7 @@ func (c *Coordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) { | |||||
| 223 | } | 223 | } | ||
| 224 | 224 | ||||
| 225 | // SetOnFullscreenChanged sets the callback for fullscreen state changes. | 225 | // SetOnFullscreenChanged sets the callback for fullscreen state changes. | ||
| 226 | - | func (c *Coordinator) SetOnFullscreenChanged(fn func(entering bool)) { | 226 | + | func (c *Coordinator) SetOnFullscreenChanged(fn func(paneID entity.PaneID, entering bool)) { |
| 227 | c.onFullscreenChanged = fn | 227 | c.onFullscreenChanged = fn | ||
| 228 | } | 228 | } | ||
| 229 | 229 | ||||
M internal/ui/coordinator/content/title.go +17 -25
| @@ -29,30 +29,22 @@ func (c *Coordinator) onTitleChanged(ctx context.Context, paneID entity.PaneID, | |||
| 29 | 29 | c.paneTitles[paneID] = title | |
| 30 | 30 | c.titleMu.Unlock() | |
| 31 | 31 | ||
| 32 | - | // Update domain model and check if this is the active pane | |
| 33 | - | isActivePaneTitle := false | |
| 34 | - | if c.getActiveWS == nil { | |
| 35 | - | return | |
| 36 | - | } | |
| 37 | - | ws, wsView := c.getActiveWS() | |
| 38 | - | if ws != nil { | |
| 39 | - | paneNode := ws.FindPane(paneID) | |
| 40 | - | if paneNode != nil && paneNode.Pane != nil { | |
| 41 | - | paneNode.Pane.Title = title | |
| 42 | - | } | |
| 43 | - | // Check if this pane is the active one | |
| 44 | - | if ws.ActivePaneID == paneID { | |
| 45 | - | isActivePaneTitle = true | |
| 32 | + | // Update domain model and stacked title if available. | |
| 33 | + | if c.getActiveWS != nil { | |
| 34 | + | ws, wsView := c.getActiveWS() | |
| 35 | + | if ws != nil { | |
| 36 | + | paneNode := ws.FindPane(paneID) | |
| 37 | + | if paneNode != nil && paneNode.Pane != nil { | |
| 38 | + | paneNode.Pane.Title = title | |
| 39 | + | } | |
| 46 | 40 | } | |
| 47 | - | } | |
| 48 | 41 | ||
| 49 | - | // Update StackedView title bar if this pane is in a stack | |
| 50 | - | if wsView != nil { | |
| 51 | - | tr := wsView.TreeRenderer() | |
| 52 | - | if tr != nil { | |
| 53 | - | stackedView := tr.GetStackedViewForPane(string(paneID)) | |
| 54 | - | if stackedView != nil { | |
| 55 | - | c.updateStackedPaneTitle(ctx, stackedView, paneID, title) | |
| 42 | + | if wsView != nil { | |
| 43 | + | tr := wsView.TreeRenderer() | |
| 44 | + | if tr != nil { | |
| 45 | + | if stackedView := tr.GetStackedViewForPane(string(paneID)); stackedView != nil { | |
| 46 | + | c.updateStackedPaneTitle(ctx, stackedView, paneID, title) | |
| 47 | + | } | |
| 56 | 48 | } | |
| 57 | 49 | } | |
| 58 | 50 | } | |
| @@ -67,9 +59,9 @@ func (c *Coordinator) onTitleChanged(ctx context.Context, paneID entity.PaneID, | |||
| 67 | 59 | } | |
| 68 | 60 | } | |
| 69 | 61 | ||
| 70 | - | // Notify window title update if this is the active pane | |
| 71 | - | if isActivePaneTitle && c.onWindowTitleChanged != nil { | |
| 72 | - | c.onWindowTitleChanged(title) | |
| 62 | + | // Notify window title update for every pane title change. | |
| 63 | + | if c.onWindowTitleChanged != nil { | |
| 64 | + | c.onWindowTitleChanged(paneID, title) | |
| 73 | 65 | } | |
| 74 | 66 | ||
| 75 | 67 | log.Debug(). | |
| @@ -29,30 +29,22 @@ func (c *Coordinator) onTitleChanged(ctx context.Context, paneID entity.PaneID, | |||||
| 29 | c.paneTitles[paneID] = title | 29 | c.paneTitles[paneID] = title | ||
| 30 | c.titleMu.Unlock() | 30 | c.titleMu.Unlock() | ||
| 31 | 31 | ||||
| 32 | - | // Update domain model and check if this is the active pane | 32 | + | // Update domain model and stacked title if available. |
| 33 | - | isActivePaneTitle := false | 33 | + | if c.getActiveWS != nil { |
| 34 | - | if c.getActiveWS == nil { | 34 | + | ws, wsView := c.getActiveWS() |
| 35 | - | return | 35 | + | if ws != nil { |
| 36 | - | } | 36 | + | paneNode := ws.FindPane(paneID) |
| 37 | - | ws, wsView := c.getActiveWS() | 37 | + | if paneNode != nil && paneNode.Pane != nil { |
| 38 | - | if ws != nil { | 38 | + | paneNode.Pane.Title = title |
| 39 | - | paneNode := ws.FindPane(paneID) | 39 | + | } |
| 40 | - | if paneNode != nil && paneNode.Pane != nil { | |||
| 41 | - | paneNode.Pane.Title = title | |||
| 42 | - | } | |||
| 43 | - | // Check if this pane is the active one | |||
| 44 | - | if ws.ActivePaneID == paneID { | |||
| 45 | - | isActivePaneTitle = true | |||
| 46 | } | 40 | } | ||
| 47 | - | } | |||
| 48 | 41 | ||||
| 49 | - | // Update StackedView title bar if this pane is in a stack | 42 | + | if wsView != nil { |
| 50 | - | if wsView != nil { | 43 | + | tr := wsView.TreeRenderer() |
| 51 | - | tr := wsView.TreeRenderer() | 44 | + | if tr != nil { |
| 52 | - | if tr != nil { | 45 | + | if stackedView := tr.GetStackedViewForPane(string(paneID)); stackedView != nil { |
| 53 | - | stackedView := tr.GetStackedViewForPane(string(paneID)) | 46 | + | c.updateStackedPaneTitle(ctx, stackedView, paneID, title) |
| 54 | - | if stackedView != nil { | 47 | + | } |
| 55 | - | c.updateStackedPaneTitle(ctx, stackedView, paneID, title) | |||
| 56 | } | 48 | } | ||
| 57 | } | 49 | } | ||
| 58 | } | 50 | } | ||
| @@ -67,9 +59,9 @@ func (c *Coordinator) onTitleChanged(ctx context.Context, paneID entity.PaneID, | |||||
| 67 | } | 59 | } | ||
| 68 | } | 60 | } | ||
| 69 | 61 | ||||
| 70 | - | // Notify window title update if this is the active pane | 62 | + | // Notify window title update for every pane title change. |
| 71 | - | if isActivePaneTitle && c.onWindowTitleChanged != nil { | 63 | + | if c.onWindowTitleChanged != nil { |
| 72 | - | c.onWindowTitleChanged(title) | 64 | + | c.onWindowTitleChanged(paneID, title) |
| 73 | } | 65 | } | ||
| 74 | 66 | ||||
| 75 | log.Debug(). | 67 | log.Debug(). | ||
M internal/ui/coordinator/content/title_test.go +30 -0
| @@ -8,8 +8,38 @@ import ( | |||
| 8 | 8 | "github.com/stretchr/testify/assert" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | + | "github.com/bnema/dumber/internal/ui/component" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| 14 | + | func TestOnTitleChanged_EmitsCallbackForBackgroundPane(t *testing.T) { | |
| 15 | + | t.Parallel() | |
| 16 | + | ||
| 17 | + | pane1 := entity.PaneID("pane-1") | |
| 18 | + | pane2 := entity.PaneID("pane-2") | |
| 19 | + | root := &entity.PaneNode{ID: "root"} | |
| 20 | + | leaf1 := &entity.PaneNode{ID: string(pane1), Pane: entity.NewPane(pane1), Parent: root} | |
| 21 | + | leaf2 := &entity.PaneNode{ID: string(pane2), Pane: entity.NewPane(pane2), Parent: root} | |
| 22 | + | root.Children = []*entity.PaneNode{leaf1, leaf2} | |
| 23 | + | ||
| 24 | + | ws := &entity.Workspace{Root: root, ActivePaneID: pane1} | |
| 25 | + | var gotPaneID entity.PaneID | |
| 26 | + | var gotTitle string | |
| 27 | + | c := &Coordinator{ | |
| 28 | + | paneTitles: make(map[entity.PaneID]string), | |
| 29 | + | getActiveWS: func() (*entity.Workspace, *component.WorkspaceView) { return ws, nil }, | |
| 30 | + | onWindowTitleChanged: func(paneID entity.PaneID, title string) { | |
| 31 | + | gotPaneID = paneID | |
| 32 | + | gotTitle = title | |
| 33 | + | }, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | c.onTitleChanged(context.Background(), pane2, "Pane Two") | |
| 37 | + | ||
| 38 | + | assert.Equal(t, pane2, gotPaneID) | |
| 39 | + | assert.Equal(t, "Pane Two", gotTitle) | |
| 40 | + | assert.Equal(t, "Pane Two", c.GetTitle(pane2)) | |
| 41 | + | } | |
| 42 | + | ||
| 13 | 43 | // TestGetTitle tests the GetTitle method for title state retrieval. | |
| 14 | 44 | ||
| 15 | 45 | func TestGetTitle_ReturnsEmptyForUnknownPane(t *testing.T) { | |
| @@ -8,8 +8,38 @@ import ( | |||||
| 8 | "github.com/stretchr/testify/assert" | 8 | "github.com/stretchr/testify/assert" | ||
| 9 | 9 | ||||
| 10 | "github.com/bnema/dumber/internal/domain/entity" | 10 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 11 | + | "github.com/bnema/dumber/internal/ui/component" | |||
| 11 | ) | 12 | ) | ||
| 12 | 13 | ||||
| 14 | + | func TestOnTitleChanged_EmitsCallbackForBackgroundPane(t *testing.T) { | |||
| 15 | + | t.Parallel() | |||
| 16 | + | ||||
| 17 | + | pane1 := entity.PaneID("pane-1") | |||
| 18 | + | pane2 := entity.PaneID("pane-2") | |||
| 19 | + | root := &entity.PaneNode{ID: "root"} | |||
| 20 | + | leaf1 := &entity.PaneNode{ID: string(pane1), Pane: entity.NewPane(pane1), Parent: root} | |||
| 21 | + | leaf2 := &entity.PaneNode{ID: string(pane2), Pane: entity.NewPane(pane2), Parent: root} | |||
| 22 | + | root.Children = []*entity.PaneNode{leaf1, leaf2} | |||
| 23 | + | ||||
| 24 | + | ws := &entity.Workspace{Root: root, ActivePaneID: pane1} | |||
| 25 | + | var gotPaneID entity.PaneID | |||
| 26 | + | var gotTitle string | |||
| 27 | + | c := &Coordinator{ | |||
| 28 | + | paneTitles: make(map[entity.PaneID]string), | |||
| 29 | + | getActiveWS: func() (*entity.Workspace, *component.WorkspaceView) { return ws, nil }, | |||
| 30 | + | onWindowTitleChanged: func(paneID entity.PaneID, title string) { | |||
| 31 | + | gotPaneID = paneID | |||
| 32 | + | gotTitle = title | |||
| 33 | + | }, | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | c.onTitleChanged(context.Background(), pane2, "Pane Two") | |||
| 37 | + | ||||
| 38 | + | assert.Equal(t, pane2, gotPaneID) | |||
| 39 | + | assert.Equal(t, "Pane Two", gotTitle) | |||
| 40 | + | assert.Equal(t, "Pane Two", c.GetTitle(pane2)) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 13 | // TestGetTitle tests the GetTitle method for title state retrieval. | 43 | // TestGetTitle tests the GetTitle method for title state retrieval. | ||
| 14 | 44 | ||||
| 15 | func TestGetTitle_ReturnsEmptyForUnknownPane(t *testing.T) { | 45 | func TestGetTitle_ReturnsEmptyForUnknownPane(t *testing.T) { | ||
M internal/ui/coordinator/update.go +9 -5
| @@ -17,7 +17,7 @@ import ( | |||
| 17 | 17 | type UpdateCoordinator struct { | |
| 18 | 18 | checkUC *usecase.CheckUpdateUseCase | |
| 19 | 19 | applyUC *usecase.ApplyUpdateUseCase | |
| 20 | - | toaster *component.Toaster | |
| 20 | + | toastFn func(ctx context.Context, msg string, level component.ToastLevel) | |
| 21 | 21 | enableOnStartup bool | |
| 22 | 22 | autoDownload bool | |
| 23 | 23 | mu sync.RWMutex // Protects status and lastInfo | |
| @@ -31,14 +31,14 @@ type UpdateCoordinator struct { | |||
| 31 | 31 | func NewUpdateCoordinator( | |
| 32 | 32 | checkUC *usecase.CheckUpdateUseCase, | |
| 33 | 33 | applyUC *usecase.ApplyUpdateUseCase, | |
| 34 | - | toaster *component.Toaster, | |
| 34 | + | showToast func(ctx context.Context, msg string, level component.ToastLevel), | |
| 35 | 35 | enableOnStartup bool, | |
| 36 | 36 | autoDownload bool, | |
| 37 | 37 | ) *UpdateCoordinator { | |
| 38 | 38 | return &UpdateCoordinator{ | |
| 39 | 39 | checkUC: checkUC, | |
| 40 | 40 | applyUC: applyUC, | |
| 41 | - | toaster: toaster, | |
| 41 | + | toastFn: showToast, | |
| 42 | 42 | enableOnStartup: enableOnStartup, | |
| 43 | 43 | autoDownload: autoDownload, | |
| 44 | 44 | status: entity.UpdateStatusUnknown, | |
| @@ -104,7 +104,9 @@ func (c *UpdateCoordinator) showUpdateNotification(ctx context.Context, result * | |||
| 104 | 104 | } | |
| 105 | 105 | ||
| 106 | 106 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 107 | - | c.toaster.Show(ctx, msg, component.ToastInfo) | |
| 107 | + | if c.toastFn != nil { | |
| 108 | + | c.toastFn(ctx, msg, component.ToastInfo) | |
| 109 | + | } | |
| 108 | 110 | return false | |
| 109 | 111 | }) | |
| 110 | 112 | glib.IdleAdd(&cb, 0) | |
| @@ -145,7 +147,9 @@ func (c *UpdateCoordinator) downloadAsync(ctx context.Context, downloadURL strin | |||
| 145 | 147 | ||
| 146 | 148 | func (c *UpdateCoordinator) showToast(ctx context.Context, msg string, level component.ToastLevel) { | |
| 147 | 149 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 148 | - | c.toaster.Show(ctx, msg, level) | |
| 150 | + | if c.toastFn != nil { | |
| 151 | + | c.toastFn(ctx, msg, level) | |
| 152 | + | } | |
| 149 | 153 | return false | |
| 150 | 154 | }) | |
| 151 | 155 | glib.IdleAdd(&cb, 0) | |
| @@ -17,7 +17,7 @@ import ( | |||||
| 17 | type UpdateCoordinator struct { | 17 | type UpdateCoordinator struct { | ||
| 18 | checkUC *usecase.CheckUpdateUseCase | 18 | checkUC *usecase.CheckUpdateUseCase | ||
| 19 | applyUC *usecase.ApplyUpdateUseCase | 19 | applyUC *usecase.ApplyUpdateUseCase | ||
| 20 | - | toaster *component.Toaster | 20 | + | toastFn func(ctx context.Context, msg string, level component.ToastLevel) |
| 21 | enableOnStartup bool | 21 | enableOnStartup bool | ||
| 22 | autoDownload bool | 22 | autoDownload bool | ||
| 23 | mu sync.RWMutex // Protects status and lastInfo | 23 | mu sync.RWMutex // Protects status and lastInfo | ||
| @@ -31,14 +31,14 @@ type UpdateCoordinator struct { | |||||
| 31 | func NewUpdateCoordinator( | 31 | func NewUpdateCoordinator( | ||
| 32 | checkUC *usecase.CheckUpdateUseCase, | 32 | checkUC *usecase.CheckUpdateUseCase, | ||
| 33 | applyUC *usecase.ApplyUpdateUseCase, | 33 | applyUC *usecase.ApplyUpdateUseCase, | ||
| 34 | - | toaster *component.Toaster, | 34 | + | showToast func(ctx context.Context, msg string, level component.ToastLevel), |
| 35 | enableOnStartup bool, | 35 | enableOnStartup bool, | ||
| 36 | autoDownload bool, | 36 | autoDownload bool, | ||
| 37 | ) *UpdateCoordinator { | 37 | ) *UpdateCoordinator { | ||
| 38 | return &UpdateCoordinator{ | 38 | return &UpdateCoordinator{ | ||
| 39 | checkUC: checkUC, | 39 | checkUC: checkUC, | ||
| 40 | applyUC: applyUC, | 40 | applyUC: applyUC, | ||
| 41 | - | toaster: toaster, | 41 | + | toastFn: showToast, |
| 42 | enableOnStartup: enableOnStartup, | 42 | enableOnStartup: enableOnStartup, | ||
| 43 | autoDownload: autoDownload, | 43 | autoDownload: autoDownload, | ||
| 44 | status: entity.UpdateStatusUnknown, | 44 | status: entity.UpdateStatusUnknown, | ||
| @@ -104,7 +104,9 @@ func (c *UpdateCoordinator) showUpdateNotification(ctx context.Context, result * | |||||
| 104 | } | 104 | } | ||
| 105 | 105 | ||||
| 106 | cb := glib.SourceFunc(func(_ uintptr) bool { | 106 | cb := glib.SourceFunc(func(_ uintptr) bool { | ||
| 107 | - | c.toaster.Show(ctx, msg, component.ToastInfo) | 107 | + | if c.toastFn != nil { |
| 108 | + | c.toastFn(ctx, msg, component.ToastInfo) | |||
| 109 | + | } | |||
| 108 | return false | 110 | return false | ||
| 109 | }) | 111 | }) | ||
| 110 | glib.IdleAdd(&cb, 0) | 112 | glib.IdleAdd(&cb, 0) | ||
| @@ -145,7 +147,9 @@ func (c *UpdateCoordinator) downloadAsync(ctx context.Context, downloadURL strin | |||||
| 145 | 147 | ||||
| 146 | func (c *UpdateCoordinator) showToast(ctx context.Context, msg string, level component.ToastLevel) { | 148 | func (c *UpdateCoordinator) showToast(ctx context.Context, msg string, level component.ToastLevel) { | ||
| 147 | cb := glib.SourceFunc(func(_ uintptr) bool { | 149 | cb := glib.SourceFunc(func(_ uintptr) bool { | ||
| 148 | - | c.toaster.Show(ctx, msg, level) | 150 | + | if c.toastFn != nil { |
| 151 | + | c.toastFn(ctx, msg, level) | |||
| 152 | + | } | |||
| 149 | return false | 153 | return false | ||
| 150 | }) | 154 | }) | ||
| 151 | glib.IdleAdd(&cb, 0) | 155 | glib.IdleAdd(&cb, 0) | ||
M internal/ui/dispatcher/keyboard.go +18 -15
| @@ -28,9 +28,10 @@ type KeyboardDispatcher struct { | |||
| 28 | 28 | onFindNext func(ctx context.Context) error | |
| 29 | 29 | onFindPrev func(ctx context.Context) error | |
| 30 | 30 | onFindClose func(ctx context.Context) error | |
| 31 | - | onSessionOpen func(ctx context.Context) error | |
| 32 | - | onMovePaneToTab func(ctx context.Context) error | |
| 33 | - | onMovePaneToNext func(ctx context.Context) error | |
| 31 | + | activePaneID func(ctx context.Context) entity.PaneID | |
| 32 | + | onSessionOpen func(ctx context.Context, paneID entity.PaneID) error | |
| 33 | + | onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error | |
| 34 | + | onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error | |
| 34 | 35 | onToggleFloating func(ctx context.Context) error | |
| 35 | 36 | onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error | |
| 36 | 37 | } | |
| @@ -44,17 +45,19 @@ func NewKeyboardDispatcher( | |||
| 44 | 45 | zoomUC *usecase.ManageZoomUseCase, | |
| 45 | 46 | copyURLUC *usecase.CopyURLUseCase, | |
| 46 | 47 | newPaneURL string, | |
| 48 | + | activePaneID func(context.Context) entity.PaneID, | |
| 47 | 49 | ) *KeyboardDispatcher { | |
| 48 | 50 | log := logging.FromContext(ctx) | |
| 49 | 51 | log.Debug().Msg("creating keyboard dispatcher") | |
| 50 | 52 | ||
| 51 | 53 | dispatcher := &KeyboardDispatcher{ | |
| 52 | - | tabCoord: tabCoord, | |
| 53 | - | wsCoord: wsCoord, | |
| 54 | - | navCoord: navCoord, | |
| 55 | - | zoomUC: zoomUC, | |
| 56 | - | copyURLUC: copyURLUC, | |
| 57 | - | newPaneURL: newPaneURL, | |
| 54 | + | tabCoord: tabCoord, | |
| 55 | + | wsCoord: wsCoord, | |
| 56 | + | navCoord: navCoord, | |
| 57 | + | zoomUC: zoomUC, | |
| 58 | + | copyURLUC: copyURLUC, | |
| 59 | + | newPaneURL: newPaneURL, | |
| 60 | + | activePaneID: activePaneID, | |
| 58 | 61 | } | |
| 59 | 62 | dispatcher.initActionHandlers() | |
| 60 | 63 | return dispatcher | |
| @@ -86,15 +89,15 @@ func (d *KeyboardDispatcher) SetOnFindClose(fn func(ctx context.Context) error) | |||
| 86 | 89 | } | |
| 87 | 90 | ||
| 88 | 91 | // SetOnSessionOpen sets the callback for opening the session manager. | |
| 89 | - | func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context) error) { | |
| 92 | + | func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context, paneID entity.PaneID) error) { | |
| 90 | 93 | d.onSessionOpen = fn | |
| 91 | 94 | } | |
| 92 | 95 | ||
| 93 | - | func (d *KeyboardDispatcher) SetOnMovePaneToTab(fn func(ctx context.Context) error) { | |
| 96 | + | func (d *KeyboardDispatcher) SetOnMovePaneToTab(fn func(ctx context.Context, paneID entity.PaneID) error) { | |
| 94 | 97 | d.onMovePaneToTab = fn | |
| 95 | 98 | } | |
| 96 | 99 | ||
| 97 | - | func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context) error) { | |
| 100 | + | func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context, paneID entity.PaneID) error) { | |
| 98 | 101 | d.onMovePaneToNext = fn | |
| 99 | 102 | } | |
| 100 | 103 | ||
| @@ -289,7 +292,7 @@ func (d *KeyboardDispatcher) handleFindClose(ctx context.Context) error { | |||
| 289 | 292 | ||
| 290 | 293 | func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error { | |
| 291 | 294 | if d.onSessionOpen != nil { | |
| 292 | - | return d.onSessionOpen(ctx) | |
| 295 | + | return d.onSessionOpen(ctx, d.activePaneID(ctx)) | |
| 293 | 296 | } | |
| 294 | 297 | logging.FromContext(ctx).Debug().Msg("session open action (no handler)") | |
| 295 | 298 | return nil | |
| @@ -297,7 +300,7 @@ func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error { | |||
| 297 | 300 | ||
| 298 | 301 | func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error { | |
| 299 | 302 | if d.onMovePaneToTab != nil { | |
| 300 | - | return d.onMovePaneToTab(ctx) | |
| 303 | + | return d.onMovePaneToTab(ctx, d.activePaneID(ctx)) | |
| 301 | 304 | } | |
| 302 | 305 | logging.FromContext(ctx).Debug().Msg("move pane to tab action (no handler)") | |
| 303 | 306 | return nil | |
| @@ -305,7 +308,7 @@ func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error { | |||
| 305 | 308 | ||
| 306 | 309 | func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error { | |
| 307 | 310 | if d.onMovePaneToNext != nil { | |
| 308 | - | return d.onMovePaneToNext(ctx) | |
| 311 | + | return d.onMovePaneToNext(ctx, d.activePaneID(ctx)) | |
| 309 | 312 | } | |
| 310 | 313 | logging.FromContext(ctx).Debug().Msg("move pane to next tab action (no handler)") | |
| 311 | 314 | return nil | |
| @@ -28,9 +28,10 @@ type KeyboardDispatcher struct { | |||||
| 28 | onFindNext func(ctx context.Context) error | 28 | onFindNext func(ctx context.Context) error | ||
| 29 | onFindPrev func(ctx context.Context) error | 29 | onFindPrev func(ctx context.Context) error | ||
| 30 | onFindClose func(ctx context.Context) error | 30 | onFindClose func(ctx context.Context) error | ||
| 31 | - | onSessionOpen func(ctx context.Context) error | 31 | + | activePaneID func(ctx context.Context) entity.PaneID |
| 32 | - | onMovePaneToTab func(ctx context.Context) error | 32 | + | onSessionOpen func(ctx context.Context, paneID entity.PaneID) error |
| 33 | - | onMovePaneToNext func(ctx context.Context) error | 33 | + | onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error |
| 34 | + | onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error | |||
| 34 | onToggleFloating func(ctx context.Context) error | 35 | onToggleFloating func(ctx context.Context) error | ||
| 35 | onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error | 36 | onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error | ||
| 36 | } | 37 | } | ||
| @@ -44,17 +45,19 @@ func NewKeyboardDispatcher( | |||||
| 44 | zoomUC *usecase.ManageZoomUseCase, | 45 | zoomUC *usecase.ManageZoomUseCase, | ||
| 45 | copyURLUC *usecase.CopyURLUseCase, | 46 | copyURLUC *usecase.CopyURLUseCase, | ||
| 46 | newPaneURL string, | 47 | newPaneURL string, | ||
| 48 | + | activePaneID func(context.Context) entity.PaneID, | |||
| 47 | ) *KeyboardDispatcher { | 49 | ) *KeyboardDispatcher { | ||
| 48 | log := logging.FromContext(ctx) | 50 | log := logging.FromContext(ctx) | ||
| 49 | log.Debug().Msg("creating keyboard dispatcher") | 51 | log.Debug().Msg("creating keyboard dispatcher") | ||
| 50 | 52 | ||||
| 51 | dispatcher := &KeyboardDispatcher{ | 53 | dispatcher := &KeyboardDispatcher{ | ||
| 52 | - | tabCoord: tabCoord, | 54 | + | tabCoord: tabCoord, |
| 53 | - | wsCoord: wsCoord, | 55 | + | wsCoord: wsCoord, |
| 54 | - | navCoord: navCoord, | 56 | + | navCoord: navCoord, |
| 55 | - | zoomUC: zoomUC, | 57 | + | zoomUC: zoomUC, |
| 56 | - | copyURLUC: copyURLUC, | 58 | + | copyURLUC: copyURLUC, |
| 57 | - | newPaneURL: newPaneURL, | 59 | + | newPaneURL: newPaneURL, |
| 60 | + | activePaneID: activePaneID, | |||
| 58 | } | 61 | } | ||
| 59 | dispatcher.initActionHandlers() | 62 | dispatcher.initActionHandlers() | ||
| 60 | return dispatcher | 63 | return dispatcher | ||
| @@ -86,15 +89,15 @@ func (d *KeyboardDispatcher) SetOnFindClose(fn func(ctx context.Context) error) | |||||
| 86 | } | 89 | } | ||
| 87 | 90 | ||||
| 88 | // SetOnSessionOpen sets the callback for opening the session manager. | 91 | // SetOnSessionOpen sets the callback for opening the session manager. | ||
| 89 | - | func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context) error) { | 92 | + | func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context, paneID entity.PaneID) error) { |
| 90 | d.onSessionOpen = fn | 93 | d.onSessionOpen = fn | ||
| 91 | } | 94 | } | ||
| 92 | 95 | ||||
| 93 | - | func (d *KeyboardDispatcher) SetOnMovePaneToTab(fn func(ctx context.Context) error) { | 96 | + | func (d *KeyboardDispatcher) SetOnMovePaneToTab(fn func(ctx context.Context, paneID entity.PaneID) error) { |
| 94 | d.onMovePaneToTab = fn | 97 | d.onMovePaneToTab = fn | ||
| 95 | } | 98 | } | ||
| 96 | 99 | ||||
| 97 | - | func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context) error) { | 100 | + | func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context, paneID entity.PaneID) error) { |
| 98 | d.onMovePaneToNext = fn | 101 | d.onMovePaneToNext = fn | ||
| 99 | } | 102 | } | ||
| 100 | 103 | ||||
| @@ -289,7 +292,7 @@ func (d *KeyboardDispatcher) handleFindClose(ctx context.Context) error { | |||||
| 289 | 292 | ||||
| 290 | func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error { | 293 | func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error { | ||
| 291 | if d.onSessionOpen != nil { | 294 | if d.onSessionOpen != nil { | ||
| 292 | - | return d.onSessionOpen(ctx) | 295 | + | return d.onSessionOpen(ctx, d.activePaneID(ctx)) |
| 293 | } | 296 | } | ||
| 294 | logging.FromContext(ctx).Debug().Msg("session open action (no handler)") | 297 | logging.FromContext(ctx).Debug().Msg("session open action (no handler)") | ||
| 295 | return nil | 298 | return nil | ||
| @@ -297,7 +300,7 @@ func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error { | |||||
| 297 | 300 | ||||
| 298 | func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error { | 301 | func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error { | ||
| 299 | if d.onMovePaneToTab != nil { | 302 | if d.onMovePaneToTab != nil { | ||
| 300 | - | return d.onMovePaneToTab(ctx) | 303 | + | return d.onMovePaneToTab(ctx, d.activePaneID(ctx)) |
| 301 | } | 304 | } | ||
| 302 | logging.FromContext(ctx).Debug().Msg("move pane to tab action (no handler)") | 305 | logging.FromContext(ctx).Debug().Msg("move pane to tab action (no handler)") | ||
| 303 | return nil | 306 | return nil | ||
| @@ -305,7 +308,7 @@ func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error { | |||||
| 305 | 308 | ||||
| 306 | func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error { | 309 | func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error { | ||
| 307 | if d.onMovePaneToNext != nil { | 310 | if d.onMovePaneToNext != nil { | ||
| 308 | - | return d.onMovePaneToNext(ctx) | 311 | + | return d.onMovePaneToNext(ctx, d.activePaneID(ctx)) |
| 309 | } | 312 | } | ||
| 310 | logging.FromContext(ctx).Debug().Msg("move pane to next tab action (no handler)") | 313 | logging.FromContext(ctx).Debug().Msg("move pane to next tab action (no handler)") | ||
| 311 | return nil | 314 | return nil | ||
M internal/ui/dispatcher/keyboard_test.go +46 -3
| @@ -35,7 +35,7 @@ func TestKeyboardDispatcher_AltNumberCreatesTabsUntilRequestedIndexExists(t *tes | |||
| 35 | 35 | _, err := tabCoord.Create(ctx, "https://initial.example") | |
| 36 | 36 | require.NoError(t, err) | |
| 37 | 37 | ||
| 38 | - | d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "https://new.example") | |
| 38 | + | d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "https://new.example", func(context.Context) entity.PaneID { return "" }) | |
| 39 | 39 | ||
| 40 | 40 | err = d.Dispatch(ctx, input.ActionSwitchTabIndex3) | |
| 41 | 41 | require.NoError(t, err) | |
| @@ -76,7 +76,7 @@ func TestKeyboardDispatcher_AltNumberSwitchesToExistingTabWithoutNewPaneURL(t *t | |||
| 76 | 76 | _, err = tabCoord.Create(ctx, "https://second.example") | |
| 77 | 77 | require.NoError(t, err) | |
| 78 | 78 | ||
| 79 | - | d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "") | |
| 79 | + | d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID { return "" }) | |
| 80 | 80 | ||
| 81 | 81 | err = d.Dispatch(ctx, input.ActionSwitchTabIndex2) | |
| 82 | 82 | require.NoError(t, err) | |
| @@ -106,10 +106,53 @@ func TestKeyboardDispatcher_AltNumberErrorsWhenMissingTabRequiresCreationWithout | |||
| 106 | 106 | _, err := tabCoord.Create(ctx, "https://first.example") | |
| 107 | 107 | require.NoError(t, err) | |
| 108 | 108 | ||
| 109 | - | d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "") | |
| 109 | + | d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID { return "" }) | |
| 110 | 110 | ||
| 111 | 111 | err = d.Dispatch(ctx, input.ActionSwitchTabIndex2) | |
| 112 | 112 | require.Error(t, err) | |
| 113 | 113 | require.Len(t, tabs.Tabs, 1) | |
| 114 | 114 | assert.Equal(t, tabs.TabAt(0).ID, tabs.ActiveTabID) | |
| 115 | 115 | } | |
| 116 | + | ||
| 117 | + | func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) { | |
| 118 | + | ctx := context.Background() | |
| 119 | + | activePaneID := entity.PaneID("pane-1") | |
| 120 | + | d := NewKeyboardDispatcher(ctx, &coordinator.TabCoordinator{}, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID { | |
| 121 | + | return activePaneID | |
| 122 | + | }) | |
| 123 | + | ||
| 124 | + | tests := []struct { | |
| 125 | + | name string | |
| 126 | + | set func(func(context.Context, entity.PaneID) error) | |
| 127 | + | invoke func(context.Context) error | |
| 128 | + | }{ | |
| 129 | + | { | |
| 130 | + | name: "session open", | |
| 131 | + | set: d.SetOnSessionOpen, | |
| 132 | + | invoke: d.handleSessionOpen, | |
| 133 | + | }, | |
| 134 | + | { | |
| 135 | + | name: "move pane to tab", | |
| 136 | + | set: d.SetOnMovePaneToTab, | |
| 137 | + | invoke: d.handleMovePaneToTab, | |
| 138 | + | }, | |
| 139 | + | { | |
| 140 | + | name: "move pane to next tab", | |
| 141 | + | set: d.SetOnMovePaneToNextTab, | |
| 142 | + | invoke: d.handleMovePaneToNextTab, | |
| 143 | + | }, | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | for _, tc := range tests { | |
| 147 | + | t.Run(tc.name, func(t *testing.T) { | |
| 148 | + | var gotPaneID entity.PaneID | |
| 149 | + | tc.set(func(_ context.Context, paneID entity.PaneID) error { | |
| 150 | + | gotPaneID = paneID | |
| 151 | + | return nil | |
| 152 | + | }) | |
| 153 | + | ||
| 154 | + | require.NoError(t, tc.invoke(ctx)) | |
| 155 | + | assert.Equal(t, activePaneID, gotPaneID) | |
| 156 | + | }) | |
| 157 | + | } | |
| 158 | + | } | |
| @@ -35,7 +35,7 @@ func TestKeyboardDispatcher_AltNumberCreatesTabsUntilRequestedIndexExists(t *tes | |||||
| 35 | _, err := tabCoord.Create(ctx, "https://initial.example") | 35 | _, err := tabCoord.Create(ctx, "https://initial.example") | ||
| 36 | require.NoError(t, err) | 36 | require.NoError(t, err) | ||
| 37 | 37 | ||||
| 38 | - | d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "https://new.example") | 38 | + | d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "https://new.example", func(context.Context) entity.PaneID { return "" }) |
| 39 | 39 | ||||
| 40 | err = d.Dispatch(ctx, input.ActionSwitchTabIndex3) | 40 | err = d.Dispatch(ctx, input.ActionSwitchTabIndex3) | ||
| 41 | require.NoError(t, err) | 41 | require.NoError(t, err) | ||
| @@ -76,7 +76,7 @@ func TestKeyboardDispatcher_AltNumberSwitchesToExistingTabWithoutNewPaneURL(t *t | |||||
| 76 | _, err = tabCoord.Create(ctx, "https://second.example") | 76 | _, err = tabCoord.Create(ctx, "https://second.example") | ||
| 77 | require.NoError(t, err) | 77 | require.NoError(t, err) | ||
| 78 | 78 | ||||
| 79 | - | d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "") | 79 | + | d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID { return "" }) |
| 80 | 80 | ||||
| 81 | err = d.Dispatch(ctx, input.ActionSwitchTabIndex2) | 81 | err = d.Dispatch(ctx, input.ActionSwitchTabIndex2) | ||
| 82 | require.NoError(t, err) | 82 | require.NoError(t, err) | ||
| @@ -106,10 +106,53 @@ func TestKeyboardDispatcher_AltNumberErrorsWhenMissingTabRequiresCreationWithout | |||||
| 106 | _, err := tabCoord.Create(ctx, "https://first.example") | 106 | _, err := tabCoord.Create(ctx, "https://first.example") | ||
| 107 | require.NoError(t, err) | 107 | require.NoError(t, err) | ||
| 108 | 108 | ||||
| 109 | - | d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "") | 109 | + | d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID { return "" }) |
| 110 | 110 | ||||
| 111 | err = d.Dispatch(ctx, input.ActionSwitchTabIndex2) | 111 | err = d.Dispatch(ctx, input.ActionSwitchTabIndex2) | ||
| 112 | require.Error(t, err) | 112 | require.Error(t, err) | ||
| 113 | require.Len(t, tabs.Tabs, 1) | 113 | require.Len(t, tabs.Tabs, 1) | ||
| 114 | assert.Equal(t, tabs.TabAt(0).ID, tabs.ActiveTabID) | 114 | assert.Equal(t, tabs.TabAt(0).ID, tabs.ActiveTabID) | ||
| 115 | } | 115 | } | ||
| 116 | + | ||||
| 117 | + | func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) { | |||
| 118 | + | ctx := context.Background() | |||
| 119 | + | activePaneID := entity.PaneID("pane-1") | |||
| 120 | + | d := NewKeyboardDispatcher(ctx, &coordinator.TabCoordinator{}, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID { | |||
| 121 | + | return activePaneID | |||
| 122 | + | }) | |||
| 123 | + | ||||
| 124 | + | tests := []struct { | |||
| 125 | + | name string | |||
| 126 | + | set func(func(context.Context, entity.PaneID) error) | |||
| 127 | + | invoke func(context.Context) error | |||
| 128 | + | }{ | |||
| 129 | + | { | |||
| 130 | + | name: "session open", | |||
| 131 | + | set: d.SetOnSessionOpen, | |||
| 132 | + | invoke: d.handleSessionOpen, | |||
| 133 | + | }, | |||
| 134 | + | { | |||
| 135 | + | name: "move pane to tab", | |||
| 136 | + | set: d.SetOnMovePaneToTab, | |||
| 137 | + | invoke: d.handleMovePaneToTab, | |||
| 138 | + | }, | |||
| 139 | + | { | |||
| 140 | + | name: "move pane to next tab", | |||
| 141 | + | set: d.SetOnMovePaneToNextTab, | |||
| 142 | + | invoke: d.handleMovePaneToNextTab, | |||
| 143 | + | }, | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | for _, tc := range tests { | |||
| 147 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 148 | + | var gotPaneID entity.PaneID | |||
| 149 | + | tc.set(func(_ context.Context, paneID entity.PaneID) error { | |||
| 150 | + | gotPaneID = paneID | |||
| 151 | + | return nil | |||
| 152 | + | }) | |||
| 153 | + | ||||
| 154 | + | require.NoError(t, tc.invoke(ctx)) | |||
| 155 | + | assert.Equal(t, activePaneID, gotPaneID) | |||
| 156 | + | }) | |||
| 157 | + | } | |||
| 158 | + | } | |||
M internal/ui/window/main_window.go +12 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/logging" | |
| 8 | 8 | "github.com/bnema/dumber/internal/ui/component" | |
| 9 | + | "github.com/bnema/puregotk/v4/gobject" | |
| 9 | 10 | "github.com/bnema/puregotk/v4/gtk" | |
| 10 | 11 | "github.com/rs/zerolog" | |
| 11 | 12 | ) | |
| @@ -155,6 +156,17 @@ func (mw *MainWindow) Window() *gtk.ApplicationWindow { | |||
| 155 | 156 | return mw.window | |
| 156 | 157 | } | |
| 157 | 158 | ||
| 159 | + | // ConnectActiveNotify wires activation state changes for the top-level window. | |
| 160 | + | func (mw *MainWindow) ConnectActiveNotify(callback func(active bool)) uint { | |
| 161 | + | if mw == nil || mw.window == nil || callback == nil { | |
| 162 | + | return 0 | |
| 163 | + | } | |
| 164 | + | cb := func(_ gobject.Object, _ *gobject.ParamSpec) { | |
| 165 | + | callback(mw.window.IsActive()) | |
| 166 | + | } | |
| 167 | + | return mw.window.ConnectNotifyWithDetail("is-active", &cb) | |
| 168 | + | } | |
| 169 | + | ||
| 158 | 170 | // SetTitle sets the window title. | |
| 159 | 171 | func (mw *MainWindow) SetTitle(title string) { | |
| 160 | 172 | if mw.window == nil { | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | 6 | ||||
| 7 | "github.com/bnema/dumber/internal/logging" | 7 | "github.com/bnema/dumber/internal/logging" | ||
| 8 | "github.com/bnema/dumber/internal/ui/component" | 8 | "github.com/bnema/dumber/internal/ui/component" | ||
| 9 | + | "github.com/bnema/puregotk/v4/gobject" | |||
| 9 | "github.com/bnema/puregotk/v4/gtk" | 10 | "github.com/bnema/puregotk/v4/gtk" | ||
| 10 | "github.com/rs/zerolog" | 11 | "github.com/rs/zerolog" | ||
| 11 | ) | 12 | ) | ||
| @@ -155,6 +156,17 @@ func (mw *MainWindow) Window() *gtk.ApplicationWindow { | |||||
| 155 | return mw.window | 156 | return mw.window | ||
| 156 | } | 157 | } | ||
| 157 | 158 | ||||
| 159 | + | // ConnectActiveNotify wires activation state changes for the top-level window. | |||
| 160 | + | func (mw *MainWindow) ConnectActiveNotify(callback func(active bool)) uint { | |||
| 161 | + | if mw == nil || mw.window == nil || callback == nil { | |||
| 162 | + | return 0 | |||
| 163 | + | } | |||
| 164 | + | cb := func(_ gobject.Object, _ *gobject.ParamSpec) { | |||
| 165 | + | callback(mw.window.IsActive()) | |||
| 166 | + | } | |||
| 167 | + | return mw.window.ConnectNotifyWithDetail("is-active", &cb) | |||
| 168 | + | } | |||
| 169 | + | ||||
| 158 | // SetTitle sets the window title. | 170 | // SetTitle sets the window title. | ||
| 159 | func (mw *MainWindow) SetTitle(title string) { | 171 | func (mw *MainWindow) SetTitle(title string) { | ||
| 160 | if mw.window == nil { | 172 | if mw.window == nil { | ||