fix(ui): allow independent dumber process launches
Switch GTK application flags to non-unique mode so launching dumber again creates an independent process/window instead of re-activating the existing instance and adding a new tab.
- commit
- 15ee0d7aad57875e3aa0d5080183df9a6de88f2d
- author
- brice · · 2026-02-09 06:41 UTC
- parent
- 72ba574
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +555 -21
M internal/ui/app.go +540 -21
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "os" | |
| 8 | 8 | "path/filepath" | |
| 9 | + | "strings" | |
| 9 | 10 | "sync" | |
| 10 | 11 | ||
| 11 | 12 | "github.com/bnema/dumber/internal/application/port" | |
| @@ -44,8 +45,16 @@ const ( | |||
| 44 | 45 | AppID = "com.github.bnema.dumber" | |
| 45 | 46 | // crashReportToastDurationMs keeps startup crash-report toast visible longer. | |
| 46 | 47 | crashReportToastDurationMs = 5000 | |
| 48 | + | floatingPaneFallbackWidth = 1200 | |
| 49 | + | floatingPaneFallbackHeight = 800 | |
| 50 | + | floatingPaneIDPrefix = "floating-pane:" | |
| 51 | + | floatingSessionIDDefault = "default" | |
| 47 | 52 | ) | |
| 48 | 53 | ||
| 54 | + | func gtkApplicationFlags() gio.ApplicationFlags { | |
| 55 | + | return gio.GApplicationNonUniqueValue | |
| 56 | + | } | |
| 57 | + | ||
| 49 | 58 | // App wraps the GTK Application and manages the browser lifecycle. | |
| 50 | 59 | type App struct { | |
| 51 | 60 | deps *Dependencies | |
| @@ -81,9 +90,12 @@ type App struct { | |||
| 81 | 90 | resizeModeBorderTarget layout.Widget | |
| 82 | 91 | ||
| 83 | 92 | // Omnibox configuration (omnibox is created per workspace view) | |
| 84 | - | omniboxCfg component.OmniboxConfig | |
| 93 | + | omniboxCfg component.OmniboxConfig | |
| 94 | + | omniboxNavigateFn func(url string) | |
| 85 | 95 | // Find bar configuration (find bar is created per workspace view) | |
| 86 | 96 | findBarCfg component.FindBarConfig | |
| 97 | + | // Floating pane sessions keyed by tab and profile session ID. | |
| 98 | + | floatingSessions map[floatingSessionKey]*floatingWorkspaceSession | |
| 87 | 99 | ||
| 88 | 100 | // Web content (managed by ContentCoordinator) | |
| 89 | 101 | pool *webkit.WebViewPool | |
| @@ -130,6 +142,25 @@ type App struct { | |||
| 130 | 142 | cancel context.CancelCauseFunc | |
| 131 | 143 | } | |
| 132 | 144 | ||
| 145 | + | type floatingWorkspaceSession struct { | |
| 146 | + | paneID entity.PaneID | |
| 147 | + | pane *component.FloatingPane | |
| 148 | + | webView *webkit.WebView | |
| 149 | + | overlay layout.OverlayWidget | |
| 150 | + | widget layout.Widget | |
| 151 | + | omnibox *component.Omnibox | |
| 152 | + | omniboxWidget layout.Widget | |
| 153 | + | resizeWatcherActive bool | |
| 154 | + | resizeTickID uint | |
| 155 | + | appliedWidth int | |
| 156 | + | appliedHeight int | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | type floatingSessionKey struct { | |
| 160 | + | tabID entity.TabID | |
| 161 | + | sessionID string | |
| 162 | + | } | |
| 163 | + | ||
| 133 | 164 | // New creates a new App with the given dependencies. | |
| 134 | 165 | func New(deps *Dependencies) (*App, error) { | |
| 135 | 166 | if err := deps.Validate(); err != nil { | |
| @@ -138,17 +169,18 @@ func New(deps *Dependencies) (*App, error) { | |||
| 138 | 169 | ctx, cancel := context.WithCancelCause(deps.Ctx) | |
| 139 | 170 | ||
| 140 | 171 | app := &App{ | |
| 141 | - | deps: deps, | |
| 142 | - | tabs: entity.NewTabList(), | |
| 143 | - | tabsUC: deps.TabsUC, | |
| 144 | - | panesUC: deps.PanesUC, | |
| 145 | - | workspaceViews: make(map[entity.TabID]*component.WorkspaceView), | |
| 146 | - | pool: deps.Pool, | |
| 147 | - | injector: deps.Injector, | |
| 148 | - | router: deps.MessageRouter, | |
| 149 | - | settings: deps.Settings, | |
| 150 | - | configManager: config.GetManager(), | |
| 151 | - | cancel: cancel, | |
| 172 | + | deps: deps, | |
| 173 | + | tabs: entity.NewTabList(), | |
| 174 | + | tabsUC: deps.TabsUC, | |
| 175 | + | panesUC: deps.PanesUC, | |
| 176 | + | workspaceViews: make(map[entity.TabID]*component.WorkspaceView), | |
| 177 | + | floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession), | |
| 178 | + | pool: deps.Pool, | |
| 179 | + | injector: deps.Injector, | |
| 180 | + | router: deps.MessageRouter, | |
| 181 | + | settings: deps.Settings, | |
| 182 | + | configManager: config.GetManager(), | |
| 183 | + | cancel: cancel, | |
| 152 | 184 | } | |
| 153 | 185 | if app.router == nil { | |
| 154 | 186 | app.router = webkit.NewMessageRouter(ctx) | |
| @@ -208,7 +240,7 @@ func (a *App) Run(ctx context.Context, args []string) int { | |||
| 208 | 240 | } | |
| 209 | 241 | ||
| 210 | 242 | appID := AppID | |
| 211 | - | a.gtkApp = gtk.NewApplication(&appID, gio.GApplicationFlagsNoneValue) | |
| 243 | + | a.gtkApp = gtk.NewApplication(&appID, gtkApplicationFlags()) | |
| 212 | 244 | if a.gtkApp == nil { | |
| 213 | 245 | log.Error().Msg("failed to create GTK application") | |
| 214 | 246 | return 1 | |
| @@ -582,6 +614,11 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||
| 582 | 614 | // Create keyboard handler and wire to dispatcher. | |
| 583 | 615 | a.keyboardHandler = input.NewKeyboardHandler(ctx, a.deps.Config) | |
| 584 | 616 | a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error { | |
| 617 | + | if action == input.ActionClosePane { | |
| 618 | + | if a.closeActiveFloatingPane(ctx) { | |
| 619 | + | return nil | |
| 620 | + | } | |
| 621 | + | } | |
| 585 | 622 | return a.kbDispatcher.Dispatch(ctx, action) | |
| 586 | 623 | }) | |
| 587 | 624 | a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) { | |
| @@ -656,7 +693,7 @@ func (a *App) initOmniboxConfig(ctx context.Context) { | |||
| 656 | 693 | InitialBehavior: a.deps.Config.Omnibox.InitialBehavior, | |
| 657 | 694 | UIScale: a.deps.Config.DefaultUIScale, | |
| 658 | 695 | OnNavigate: func(url string) { | |
| 659 | - | if err := a.navCoord.Navigate(ctx, url); err != nil { | |
| 696 | + | if err := a.navigateFromOmnibox(ctx, url); err != nil { | |
| 660 | 697 | log.Error().Err(err).Str("url", url).Msg("navigation failed") | |
| 661 | 698 | } | |
| 662 | 699 | }, | |
| @@ -967,6 +1004,7 @@ func (a *App) applyMovePaneToTabUI(ctx context.Context, out *usecase.MovePaneToT | |||
| 967 | 1004 | } | |
| 968 | 1005 | ||
| 969 | 1006 | func (a *App) removeSourceTabUI(sourceTabID entity.TabID) { | |
| 1007 | + | a.releaseFloatingSessionsForTab(context.Background(), sourceTabID) | |
| 970 | 1008 | delete(a.workspaceViews, sourceTabID) | |
| 971 | 1009 | if a.mainWindow != nil && a.mainWindow.TabBar() != nil { | |
| 972 | 1010 | a.mainWindow.TabBar().RemoveTab(sourceTabID) | |
| @@ -1309,6 +1347,17 @@ func (a *App) onShutdown(ctx context.Context) { | |||
| 1309 | 1347 | if a.faviconAdapter != nil { | |
| 1310 | 1348 | a.faviconAdapter.Close() | |
| 1311 | 1349 | } | |
| 1350 | + | tabIDSet := make(map[entity.TabID]struct{}, len(a.floatingSessions)) | |
| 1351 | + | for key := range a.floatingSessions { | |
| 1352 | + | tabIDSet[key.tabID] = struct{}{} | |
| 1353 | + | } | |
| 1354 | + | tabIDs := make([]entity.TabID, 0, len(tabIDSet)) | |
| 1355 | + | for tabID := range tabIDSet { | |
| 1356 | + | tabIDs = append(tabIDs, tabID) | |
| 1357 | + | } | |
| 1358 | + | for _, tabID := range tabIDs { | |
| 1359 | + | a.releaseFloatingSessionsForTab(ctx, tabID) | |
| 1360 | + | } | |
| 1312 | 1361 | if a.deps.Pool != nil { | |
| 1313 | 1362 | a.deps.Pool.Close(ctx) | |
| 1314 | 1363 | } | |
| @@ -1484,6 +1533,7 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 1484 | 1533 | // Wire pane URI updates for session snapshots (searches all tabs) | |
| 1485 | 1534 | a.contentCoord.SetOnPaneURIUpdated(func(paneID entity.PaneID, url string) { | |
| 1486 | 1535 | a.updatePaneURIInAllTabs(paneID, url) | |
| 1536 | + | a.updateFloatingSessionURI(paneID, url) | |
| 1487 | 1537 | // Mark dirty so snapshot captures the new URI | |
| 1488 | 1538 | a.MarkDirty() | |
| 1489 | 1539 | }) | |
| @@ -1522,6 +1572,12 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 1522 | 1572 | a.deps.ZoomUC, | |
| 1523 | 1573 | a.deps.CopyURLUC, | |
| 1524 | 1574 | ) | |
| 1575 | + | a.wireKeyboardActions() | |
| 1576 | + | ||
| 1577 | + | log.Debug().Msg("coordinators initialized") | |
| 1578 | + | } | |
| 1579 | + | ||
| 1580 | + | func (a *App) wireKeyboardActions() { | |
| 1525 | 1581 | a.kbDispatcher.SetOnQuit(a.Quit) | |
| 1526 | 1582 | a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error { | |
| 1527 | 1583 | a.ToggleFindBar(ctx) | |
| @@ -1545,13 +1601,17 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 1545 | 1601 | a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context) error { | |
| 1546 | 1602 | return a.HandleMovePaneToNextTab(ctx) | |
| 1547 | 1603 | }) | |
| 1604 | + | a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error { | |
| 1605 | + | return a.ToggleFloatingPane(ctx) | |
| 1606 | + | }) | |
| 1607 | + | a.kbDispatcher.SetOnOpenFloatingTarget(func(ctx context.Context, target input.FloatingProfileTarget) error { | |
| 1608 | + | return a.OpenFloatingPaneProfileURL(ctx, target.SessionID, target.URL) | |
| 1609 | + | }) | |
| 1548 | 1610 | ||
| 1549 | 1611 | // Wire gesture handler to dispatcher (for mouse button 8/9 navigation) | |
| 1550 | 1612 | a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error { | |
| 1551 | 1613 | return a.kbDispatcher.Dispatch(ctx, action) | |
| 1552 | 1614 | }) | |
| 1553 | - | ||
| 1554 | - | log.Debug().Msg("coordinators initialized") | |
| 1555 | 1615 | } | |
| 1556 | 1616 | ||
| 1557 | 1617 | func (a *App) wireWebRTCPermissionIndicator() { | |
| @@ -1889,6 +1949,17 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity. | |||
| 1889 | 1949 | ||
| 1890 | 1950 | // Store in map | |
| 1891 | 1951 | a.workspaceViews[tab.ID] = wsView | |
| 1952 | + | for key, session := range a.floatingSessions { | |
| 1953 | + | if key.tabID != tab.ID || session == nil || session.pane == nil { | |
| 1954 | + | continue | |
| 1955 | + | } | |
| 1956 | + | session.pane.SetParentOverlay(wsView.WorkspaceOverlayWidget()) | |
| 1957 | + | if session.widget != nil { | |
| 1958 | + | wsView.AddWorkspaceOverlayWidget(session.widget) | |
| 1959 | + | session.widget.SetVisible(session.pane.IsVisible()) | |
| 1960 | + | } | |
| 1961 | + | } | |
| 1962 | + | a.syncFloatingFocus() | |
| 1892 | 1963 | ||
| 1893 | 1964 | log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created") | |
| 1894 | 1965 | } | |
| @@ -1929,12 +2000,11 @@ func (a *App) activeWorkspaceView() *component.WorkspaceView { | |||
| 1929 | 2000 | // getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView. | |
| 1930 | 2001 | // Used by the accent picker to insert accented characters into web content. | |
| 1931 | 2002 | func (a *App) getActiveWebViewTarget() port.TextInputTarget { | |
| 1932 | - | ws := a.activeWorkspace() | |
| 1933 | - | if ws == nil || a.contentCoord == nil || a.deps == nil || a.deps.Clipboard == nil { | |
| 2003 | + | if a.contentCoord == nil || a.deps == nil || a.deps.Clipboard == nil { | |
| 1934 | 2004 | return nil | |
| 1935 | 2005 | } | |
| 1936 | 2006 | ||
| 1937 | - | wv := a.contentCoord.GetWebView(ws.ActivePaneID) | |
| 2007 | + | wv := a.contentCoord.ActiveWebView(context.Background()) | |
| 1938 | 2008 | if wv == nil { | |
| 1939 | 2009 | return nil | |
| 1940 | 2010 | } | |
| @@ -2006,14 +2076,464 @@ func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) { | |||
| 2006 | 2076 | ||
| 2007 | 2077 | // Update window title with the new active pane's title | |
| 2008 | 2078 | a.updateWindowTitleFromActivePane() | |
| 2079 | + | a.syncFloatingFocus() | |
| 2009 | 2080 | ||
| 2010 | 2081 | log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched") | |
| 2011 | 2082 | } | |
| 2012 | 2083 | ||
| 2084 | + | func normalizeFloatingSessionID(sessionID string) string { | |
| 2085 | + | sessionID = strings.TrimSpace(sessionID) | |
| 2086 | + | if sessionID == "" { | |
| 2087 | + | return floatingSessionIDDefault | |
| 2088 | + | } | |
| 2089 | + | return sessionID | |
| 2090 | + | } | |
| 2091 | + | ||
| 2092 | + | func floatingPaneIDForSession(tabID entity.TabID, sessionID string) entity.PaneID { | |
| 2093 | + | return entity.PaneID(floatingPaneIDPrefix + string(tabID) + ":" + normalizeFloatingSessionID(sessionID)) | |
| 2094 | + | } | |
| 2095 | + | ||
| 2096 | + | func floatingSessionMapKey(tabID entity.TabID, sessionID string) floatingSessionKey { | |
| 2097 | + | return floatingSessionKey{tabID: tabID, sessionID: normalizeFloatingSessionID(sessionID)} | |
| 2098 | + | } | |
| 2099 | + | ||
| 2100 | + | func decorateFloatingOverlay(overlay layout.OverlayWidget) { | |
| 2101 | + | if overlay == nil { | |
| 2102 | + | return | |
| 2103 | + | } | |
| 2104 | + | ||
| 2105 | + | overlay.AddCssClass("floating-pane-container") | |
| 2106 | + | overlay.AddCssClass("pane-border") | |
| 2107 | + | overlay.AddCssClass("pane-active") | |
| 2108 | + | } | |
| 2109 | + | ||
| 2110 | + | func (a *App) currentFloatingConfig() config.FloatingPaneConfig { | |
| 2111 | + | if a.deps != nil && a.deps.Config != nil { | |
| 2112 | + | return a.deps.Config.Workspace.FloatingPane | |
| 2113 | + | } | |
| 2114 | + | return config.Get().Workspace.FloatingPane | |
| 2115 | + | } | |
| 2116 | + | ||
| 2117 | + | func (a *App) ensureFloatingSession( | |
| 2118 | + | ctx context.Context, | |
| 2119 | + | tabID entity.TabID, | |
| 2120 | + | sessionID string, | |
| 2121 | + | wsView *component.WorkspaceView, | |
| 2122 | + | ) (*floatingWorkspaceSession, error) { | |
| 2123 | + | key := floatingSessionMapKey(tabID, sessionID) | |
| 2124 | + | if session, ok := a.floatingSessions[key]; ok && session != nil { | |
| 2125 | + | if wsView == nil { | |
| 2126 | + | return session, nil | |
| 2127 | + | } | |
| 2128 | + | session.pane.SetParentOverlay(wsView.WorkspaceOverlayWidget()) | |
| 2129 | + | if session.widget != nil { | |
| 2130 | + | wsView.AddWorkspaceOverlayWidget(session.widget) | |
| 2131 | + | } | |
| 2132 | + | return session, nil | |
| 2133 | + | } | |
| 2134 | + | ||
| 2135 | + | if wsView == nil { | |
| 2136 | + | return nil, fmt.Errorf("workspace view not found for tab %s", tabID) | |
| 2137 | + | } | |
| 2138 | + | if a.contentCoord == nil { | |
| 2139 | + | return nil, fmt.Errorf("content coordinator not initialized") | |
| 2140 | + | } | |
| 2141 | + | if a.widgetFactory == nil { | |
| 2142 | + | return nil, fmt.Errorf("widget factory not initialized") | |
| 2143 | + | } | |
| 2144 | + | ||
| 2145 | + | paneID := floatingPaneIDForSession(tabID, sessionID) | |
| 2146 | + | wv, err := a.contentCoord.EnsureWebView(ctx, paneID) | |
| 2147 | + | if err != nil { | |
| 2148 | + | return nil, fmt.Errorf("ensure floating webview: %w", err) | |
| 2149 | + | } | |
| 2150 | + | ||
| 2151 | + | webViewWidget := a.contentCoord.WrapWidget(ctx, wv) | |
| 2152 | + | if webViewWidget == nil { | |
| 2153 | + | return nil, fmt.Errorf("wrap floating webview widget") | |
| 2154 | + | } | |
| 2155 | + | webViewWidget.SetHexpand(true) | |
| 2156 | + | webViewWidget.SetVexpand(true) | |
| 2157 | + | ||
| 2158 | + | overlay := a.widgetFactory.NewOverlay() | |
| 2159 | + | overlay.SetChild(webViewWidget) | |
| 2160 | + | decorateFloatingOverlay(overlay) | |
| 2161 | + | overlay.SetHalign(gtk.AlignCenterValue) | |
| 2162 | + | overlay.SetValign(gtk.AlignCenterValue) | |
| 2163 | + | overlay.SetVisible(false) | |
| 2164 | + | wsView.AddWorkspaceOverlayWidget(overlay) | |
| 2165 | + | ||
| 2166 | + | floatingCfg := a.currentFloatingConfig() | |
| 2167 | + | floatingPane := component.NewFloatingPane(wsView.WorkspaceOverlayWidget(), component.FloatingPaneOptions{ | |
| 2168 | + | WidthPct: floatingCfg.WidthPct, | |
| 2169 | + | HeightPct: floatingCfg.HeightPct, | |
| 2170 | + | FallbackWidth: floatingPaneFallbackWidth, | |
| 2171 | + | FallbackHeight: floatingPaneFallbackHeight, | |
| 2172 | + | OnNavigate: func(navCtx context.Context, url string) error { | |
| 2173 | + | return wv.LoadURI(navCtx, url) | |
| 2174 | + | }, | |
| 2175 | + | }) | |
| 2176 | + | ||
| 2177 | + | session := &floatingWorkspaceSession{ | |
| 2178 | + | paneID: paneID, | |
| 2179 | + | pane: floatingPane, | |
| 2180 | + | webView: wv, | |
| 2181 | + | overlay: overlay, | |
| 2182 | + | widget: overlay, | |
| 2183 | + | } | |
| 2184 | + | a.floatingSessions[key] = session | |
| 2185 | + | return session, nil | |
| 2186 | + | } | |
| 2187 | + | ||
| 2188 | + | func (a *App) floatingSessionsForTab(tabID entity.TabID) map[floatingSessionKey]*floatingWorkspaceSession { | |
| 2189 | + | sessions := make(map[floatingSessionKey]*floatingWorkspaceSession) | |
| 2190 | + | for key, session := range a.floatingSessions { | |
| 2191 | + | if key.tabID != tabID || session == nil { | |
| 2192 | + | continue | |
| 2193 | + | } | |
| 2194 | + | sessions[key] = session | |
| 2195 | + | } | |
| 2196 | + | return sessions | |
| 2197 | + | } | |
| 2198 | + | ||
| 2199 | + | func (a *App) releaseFloatingSessionsForTab(ctx context.Context, tabID entity.TabID) { | |
| 2200 | + | sessions := a.floatingSessionsForTab(tabID) | |
| 2201 | + | if len(sessions) == 0 { | |
| 2202 | + | return | |
| 2203 | + | } | |
| 2204 | + | ||
| 2205 | + | for key, session := range sessions { | |
| 2206 | + | a.stopFloatingResizeWatcher(session) | |
| 2207 | + | if session.pane != nil { | |
| 2208 | + | session.pane.Hide(ctx) | |
| 2209 | + | } | |
| 2210 | + | a.hideFloatingOmnibox(ctx, session) | |
| 2211 | + | if wsView := a.workspaceViews[tabID]; wsView != nil && session.widget != nil { | |
| 2212 | + | wsView.RemoveWorkspaceOverlayWidget(session.widget) | |
| 2213 | + | } | |
| 2214 | + | if a.contentCoord != nil && session.paneID != "" { | |
| 2215 | + | a.contentCoord.ReleaseWebView(ctx, session.paneID) | |
| 2216 | + | } | |
| 2217 | + | delete(a.floatingSessions, key) | |
| 2218 | + | } | |
| 2219 | + | a.syncFloatingFocus() | |
| 2220 | + | } | |
| 2221 | + | ||
| 2222 | + | func (a *App) activeFloatingSession() (*floatingWorkspaceSession, entity.TabID) { | |
| 2223 | + | activeTab := a.tabs.ActiveTab() | |
| 2224 | + | if activeTab == nil { | |
| 2225 | + | return nil, "" | |
| 2226 | + | } | |
| 2227 | + | for key, session := range a.floatingSessions { | |
| 2228 | + | if key.tabID != activeTab.ID || session == nil || session.pane == nil { | |
| 2229 | + | continue | |
| 2230 | + | } | |
| 2231 | + | if session.pane.IsVisible() { | |
| 2232 | + | return session, activeTab.ID | |
| 2233 | + | } | |
| 2234 | + | } | |
| 2235 | + | return nil, activeTab.ID | |
| 2236 | + | } | |
| 2237 | + | ||
| 2238 | + | func (a *App) updateFloatingSessionURI(paneID entity.PaneID, url string) { | |
| 2239 | + | if paneID == "" { | |
| 2240 | + | return | |
| 2241 | + | } | |
| 2242 | + | ||
| 2243 | + | for _, session := range a.floatingSessions { | |
| 2244 | + | if session == nil || session.pane == nil { | |
| 2245 | + | continue | |
| 2246 | + | } | |
| 2247 | + | if session.paneID != paneID { | |
| 2248 | + | continue | |
| 2249 | + | } | |
| 2250 | + | session.pane.RecordLoadedURL(url) | |
| 2251 | + | return | |
| 2252 | + | } | |
| 2253 | + | } | |
| 2254 | + | ||
| 2255 | + | func (a *App) syncFloatingFocus() { | |
| 2256 | + | for _, wsView := range a.workspaceViews { | |
| 2257 | + | if wsView != nil { | |
| 2258 | + | wsView.SetHoverFocusLocked(false) | |
| 2259 | + | } | |
| 2260 | + | } | |
| 2261 | + | ||
| 2262 | + | if a.contentCoord == nil { | |
| 2263 | + | return | |
| 2264 | + | } | |
| 2265 | + | ||
| 2266 | + | activeTab := a.tabs.ActiveTab() | |
| 2267 | + | if activeTab == nil { | |
| 2268 | + | a.contentCoord.ClearActivePaneOverride() | |
| 2269 | + | return | |
| 2270 | + | } | |
| 2271 | + | ||
| 2272 | + | if session, _ := a.activeFloatingSession(); session != nil { | |
| 2273 | + | if wsView := a.workspaceViews[activeTab.ID]; wsView != nil { | |
| 2274 | + | wsView.SetHoverFocusLocked(true) | |
| 2275 | + | } | |
| 2276 | + | a.startFloatingResizeWatcher(session) | |
| 2277 | + | a.contentCoord.SetActivePaneOverride(session.paneID) | |
| 2278 | + | return | |
| 2279 | + | } | |
| 2280 | + | ||
| 2281 | + | a.contentCoord.ClearActivePaneOverride() | |
| 2282 | + | } | |
| 2283 | + | ||
| 2284 | + | func (a *App) showFloatingOmnibox(ctx context.Context, session *floatingWorkspaceSession) { | |
| 2285 | + | if session == nil || session.overlay == nil || a.widgetFactory == nil { | |
| 2286 | + | return | |
| 2287 | + | } | |
| 2288 | + | ||
| 2289 | + | if session.omnibox == nil { | |
| 2290 | + | cfg := a.omniboxCfg | |
| 2291 | + | cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) { | |
| 2292 | + | if a.appToaster != nil { | |
| 2293 | + | a.appToaster.Show(toastCtx, message, level) | |
| 2294 | + | } | |
| 2295 | + | } | |
| 2296 | + | ||
| 2297 | + | omnibox := component.NewOmnibox(ctx, cfg) | |
| 2298 | + | if omnibox == nil { | |
| 2299 | + | return | |
| 2300 | + | } | |
| 2301 | + | ||
| 2302 | + | omnibox.SetParentOverlay(session.overlay) | |
| 2303 | + | omniboxWidget := omnibox.WidgetAsLayout(a.widgetFactory) | |
| 2304 | + | if omniboxWidget == nil { | |
| 2305 | + | return | |
| 2306 | + | } | |
| 2307 | + | ||
| 2308 | + | session.overlay.AddOverlay(omniboxWidget) | |
| 2309 | + | session.overlay.SetClipOverlay(omniboxWidget, false) | |
| 2310 | + | session.overlay.SetMeasureOverlay(omniboxWidget, false) | |
| 2311 | + | ||
| 2312 | + | session.omnibox = omnibox | |
| 2313 | + | session.omniboxWidget = omniboxWidget | |
| 2314 | + | } | |
| 2315 | + | ||
| 2316 | + | session.pane.SetOmniboxVisible(true) | |
| 2317 | + | session.omnibox.Show(ctx, "") | |
| 2318 | + | } | |
| 2319 | + | ||
| 2320 | + | func (a *App) hideFloatingOmnibox(ctx context.Context, session *floatingWorkspaceSession) { | |
| 2321 | + | if session == nil || session.pane == nil { | |
| 2322 | + | return | |
| 2323 | + | } | |
| 2324 | + | ||
| 2325 | + | session.pane.SetOmniboxVisible(false) | |
| 2326 | + | if session.omnibox == nil { | |
| 2327 | + | return | |
| 2328 | + | } | |
| 2329 | + | ||
| 2330 | + | session.omnibox.Hide(ctx) | |
| 2331 | + | if session.overlay != nil && session.omniboxWidget != nil { | |
| 2332 | + | if parent := session.omniboxWidget.GetParent(); parent == session.overlay { | |
| 2333 | + | session.overlay.RemoveOverlay(session.omniboxWidget) | |
| 2334 | + | } else if parent != nil { | |
| 2335 | + | session.omniboxWidget.Unparent() | |
| 2336 | + | } | |
| 2337 | + | } | |
| 2338 | + | session.omnibox = nil | |
| 2339 | + | session.omniboxWidget = nil | |
| 2340 | + | } | |
| 2341 | + | ||
| 2342 | + | func (a *App) resizeFloatingWidget(session *floatingWorkspaceSession) { | |
| 2343 | + | if session == nil || session.pane == nil { | |
| 2344 | + | return | |
| 2345 | + | } | |
| 2346 | + | session.pane.Resize() | |
| 2347 | + | if session.widget == nil { | |
| 2348 | + | return | |
| 2349 | + | } | |
| 2350 | + | width, height := session.pane.Dimensions() | |
| 2351 | + | if session.appliedWidth == width && session.appliedHeight == height { | |
| 2352 | + | return | |
| 2353 | + | } | |
| 2354 | + | session.widget.SetSizeRequest(width, height) | |
| 2355 | + | session.appliedWidth = width | |
| 2356 | + | session.appliedHeight = height | |
| 2357 | + | } | |
| 2358 | + | ||
| 2359 | + | func (a *App) handleFloatingViewportTick(session *floatingWorkspaceSession) bool { | |
| 2360 | + | if session == nil || session.pane == nil { | |
| 2361 | + | return false | |
| 2362 | + | } | |
| 2363 | + | if !session.pane.IsVisible() { | |
| 2364 | + | session.resizeWatcherActive = false | |
| 2365 | + | session.resizeTickID = 0 | |
| 2366 | + | return false | |
| 2367 | + | } | |
| 2368 | + | ||
| 2369 | + | a.resizeFloatingWidget(session) | |
| 2370 | + | return true | |
| 2371 | + | } | |
| 2372 | + | ||
| 2373 | + | func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) { | |
| 2374 | + | if session == nil || session.overlay == nil || session.resizeWatcherActive { | |
| 2375 | + | return | |
| 2376 | + | } | |
| 2377 | + | ||
| 2378 | + | overlayWidget := session.overlay.GtkWidget() | |
| 2379 | + | if overlayWidget == nil { | |
| 2380 | + | return | |
| 2381 | + | } | |
| 2382 | + | ||
| 2383 | + | session.resizeWatcherActive = true | |
| 2384 | + | tickCallback := gtk.TickCallback(func(_ uintptr, _ uintptr, _ uintptr) bool { | |
| 2385 | + | keepRunning := a.handleFloatingViewportTick(session) | |
| 2386 | + | if !keepRunning { | |
| 2387 | + | session.resizeWatcherActive = false | |
| 2388 | + | session.resizeTickID = 0 | |
| 2389 | + | } | |
| 2390 | + | return keepRunning | |
| 2391 | + | }) | |
| 2392 | + | session.resizeTickID = overlayWidget.AddTickCallback(&tickCallback, 0, nil) | |
| 2393 | + | } | |
| 2394 | + | ||
| 2395 | + | func (a *App) stopFloatingResizeWatcher(session *floatingWorkspaceSession) { | |
| 2396 | + | if session == nil { | |
| 2397 | + | return | |
| 2398 | + | } | |
| 2399 | + | ||
| 2400 | + | if session.resizeTickID != 0 && session.overlay != nil { | |
| 2401 | + | if overlayWidget := session.overlay.GtkWidget(); overlayWidget != nil { | |
| 2402 | + | overlayWidget.RemoveTickCallback(session.resizeTickID) | |
| 2403 | + | } | |
| 2404 | + | } | |
| 2405 | + | session.resizeTickID = 0 | |
| 2406 | + | session.resizeWatcherActive = false | |
| 2407 | + | } | |
| 2408 | + | ||
| 2409 | + | func (a *App) hideFloatingSession(ctx context.Context, session *floatingWorkspaceSession) { | |
| 2410 | + | if session == nil || session.pane == nil { | |
| 2411 | + | return | |
| 2412 | + | } | |
| 2413 | + | ||
| 2414 | + | a.stopFloatingResizeWatcher(session) | |
| 2415 | + | session.pane.Hide(ctx) | |
| 2416 | + | a.hideFloatingOmnibox(ctx, session) | |
| 2417 | + | if session.widget != nil { | |
| 2418 | + | session.widget.SetVisible(false) | |
| 2419 | + | } | |
| 2420 | + | } | |
| 2421 | + | ||
| 2422 | + | func (a *App) hideVisibleFloatingSessions(ctx context.Context, tabID entity.TabID, except *floatingWorkspaceSession) { | |
| 2423 | + | for key, session := range a.floatingSessions { | |
| 2424 | + | if key.tabID != tabID || session == nil || session == except || session.pane == nil { | |
| 2425 | + | continue | |
| 2426 | + | } | |
| 2427 | + | if !session.pane.IsVisible() { | |
| 2428 | + | continue | |
| 2429 | + | } | |
| 2430 | + | a.hideFloatingSession(ctx, session) | |
| 2431 | + | } | |
| 2432 | + | } | |
| 2433 | + | ||
| 2434 | + | func (a *App) closeActiveFloatingPane(ctx context.Context) bool { | |
| 2435 | + | session, _ := a.activeFloatingSession() | |
| 2436 | + | if session == nil { | |
| 2437 | + | return false | |
| 2438 | + | } | |
| 2439 | + | a.hideFloatingSession(ctx, session) | |
| 2440 | + | a.syncFloatingFocus() | |
| 2441 | + | return true | |
| 2442 | + | } | |
| 2443 | + | ||
| 2444 | + | // ToggleFloatingPane toggles the active workspace floating pane visibility. | |
| 2445 | + | func (a *App) ToggleFloatingPane(ctx context.Context) error { | |
| 2446 | + | activeTab := a.tabs.ActiveTab() | |
| 2447 | + | if activeTab == nil { | |
| 2448 | + | return nil | |
| 2449 | + | } | |
| 2450 | + | if a.closeActiveFloatingPane(ctx) { | |
| 2451 | + | return nil | |
| 2452 | + | } | |
| 2453 | + | return a.openFloatingPaneSession(ctx, floatingSessionIDDefault) | |
| 2454 | + | } | |
| 2455 | + | ||
| 2456 | + | // OpenFloatingPaneURL opens the active workspace floating pane directly to a URL. | |
| 2457 | + | func (a *App) OpenFloatingPaneURL(ctx context.Context, url string) error { | |
| 2458 | + | return a.openFloatingPaneSession(ctx, floatingSessionIDDefault, url) | |
| 2459 | + | } | |
| 2460 | + | ||
| 2461 | + | // OpenFloatingPaneProfileURL opens a named floating pane profile session directly to a URL. | |
| 2462 | + | func (a *App) OpenFloatingPaneProfileURL(ctx context.Context, sessionID, url string) error { | |
| 2463 | + | return a.openFloatingPaneSession(ctx, sessionID, url) | |
| 2464 | + | } | |
| 2465 | + | ||
| 2466 | + | func (a *App) openFloatingPaneSession(ctx context.Context, sessionID string, url ...string) error { | |
| 2467 | + | activeTab := a.tabs.ActiveTab() | |
| 2468 | + | if activeTab == nil { | |
| 2469 | + | return nil | |
| 2470 | + | } | |
| 2471 | + | ||
| 2472 | + | wsView := a.workspaceViews[activeTab.ID] | |
| 2473 | + | session, err := a.ensureFloatingSession(ctx, activeTab.ID, sessionID, wsView) | |
| 2474 | + | if err != nil { | |
| 2475 | + | return err | |
| 2476 | + | } | |
| 2477 | + | a.hideVisibleFloatingSessions(ctx, activeTab.ID, session) | |
| 2478 | + | ||
| 2479 | + | hasURL := len(url) > 0 && strings.TrimSpace(url[0]) != "" | |
| 2480 | + | if hasURL { | |
| 2481 | + | if err := session.pane.ShowURL(ctx, url[0]); err != nil { | |
| 2482 | + | return err | |
| 2483 | + | } | |
| 2484 | + | a.hideFloatingOmnibox(ctx, session) | |
| 2485 | + | } else { | |
| 2486 | + | if err := session.pane.ShowToggle(ctx); err != nil { | |
| 2487 | + | return err | |
| 2488 | + | } | |
| 2489 | + | if session.pane.IsOmniboxVisible() { | |
| 2490 | + | a.showFloatingOmnibox(ctx, session) | |
| 2491 | + | } | |
| 2492 | + | } | |
| 2493 | + | ||
| 2494 | + | a.resizeFloatingWidget(session) | |
| 2495 | + | if session.widget != nil { | |
| 2496 | + | session.widget.SetVisible(session.pane.IsVisible()) | |
| 2497 | + | } | |
| 2498 | + | a.startFloatingResizeWatcher(session) | |
| 2499 | + | a.syncFloatingFocus() | |
| 2500 | + | ||
| 2501 | + | return nil | |
| 2502 | + | } | |
| 2503 | + | ||
| 2504 | + | func (a *App) navigateFromOmnibox(ctx context.Context, url string) error { | |
| 2505 | + | session, _ := a.activeFloatingSession() | |
| 2506 | + | if session != nil && session.pane != nil && session.pane.IsVisible() && session.pane.IsOmniboxVisible() { | |
| 2507 | + | return session.pane.Navigate(ctx, url) | |
| 2508 | + | } | |
| 2509 | + | if a.omniboxNavigateFn != nil { | |
| 2510 | + | a.omniboxNavigateFn(url) | |
| 2511 | + | return nil | |
| 2512 | + | } | |
| 2513 | + | if a.navCoord == nil { | |
| 2514 | + | return fmt.Errorf("navigation coordinator not initialized") | |
| 2515 | + | } | |
| 2516 | + | return a.navCoord.Navigate(ctx, url) | |
| 2517 | + | } | |
| 2518 | + | ||
| 2013 | 2519 | // ToggleOmnibox implements OmniboxProvider. | |
| 2014 | 2520 | // Toggles the omnibox visibility in the active workspace view. | |
| 2015 | 2521 | func (a *App) ToggleOmnibox(ctx context.Context) { | |
| 2016 | 2522 | log := logging.FromContext(ctx) | |
| 2523 | + | if session, _ := a.activeFloatingSession(); session != nil && session.pane != nil && session.pane.IsVisible() { | |
| 2524 | + | wsView := a.activeWorkspaceView() | |
| 2525 | + | if wsView != nil && wsView.IsOmniboxVisible() { | |
| 2526 | + | wsView.HideOmnibox() | |
| 2527 | + | } | |
| 2528 | + | ||
| 2529 | + | if session.omnibox != nil && session.pane.IsOmniboxVisible() { | |
| 2530 | + | a.hideFloatingOmnibox(ctx, session) | |
| 2531 | + | } else { | |
| 2532 | + | a.showFloatingOmnibox(ctx, session) | |
| 2533 | + | } | |
| 2534 | + | a.syncFloatingFocus() | |
| 2535 | + | return | |
| 2536 | + | } | |
| 2017 | 2537 | ||
| 2018 | 2538 | wsView := a.activeWorkspaceView() | |
| 2019 | 2539 | if wsView == nil { | |
| @@ -2098,8 +2618,7 @@ func (a *App) UpdateOmniboxZoom(factor float64) { | |||
| 2098 | 2618 | // This is called to set the navigation callback on new omniboxes. | |
| 2099 | 2619 | // Since omniboxes are created per-pane, we store the config with the callback. | |
| 2100 | 2620 | func (a *App) SetOmniboxOnNavigate(fn func(url string)) { | |
| 2101 | - | // The navigate callback is set when the omnibox is created via WorkspaceView.ShowOmnibox | |
| 2102 | - | // The WorkspaceView uses the stored omniboxCfg and sets up navigation via the omnibox's SetOnNavigate | |
| 2621 | + | a.omniboxNavigateFn = fn | |
| 2103 | 2622 | } | |
| 2104 | 2623 | ||
| 2105 | 2624 | // initFilteringAsync starts background filter loading with toast feedback. | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "os" | 7 | "os" | ||
| 8 | "path/filepath" | 8 | "path/filepath" | ||
| 9 | + | "strings" | |||
| 9 | "sync" | 10 | "sync" | ||
| 10 | 11 | ||||
| 11 | "github.com/bnema/dumber/internal/application/port" | 12 | "github.com/bnema/dumber/internal/application/port" | ||
| @@ -44,8 +45,16 @@ const ( | |||||
| 44 | AppID = "com.github.bnema.dumber" | 45 | AppID = "com.github.bnema.dumber" | ||
| 45 | // crashReportToastDurationMs keeps startup crash-report toast visible longer. | 46 | // crashReportToastDurationMs keeps startup crash-report toast visible longer. | ||
| 46 | crashReportToastDurationMs = 5000 | 47 | crashReportToastDurationMs = 5000 | ||
| 48 | + | floatingPaneFallbackWidth = 1200 | |||
| 49 | + | floatingPaneFallbackHeight = 800 | |||
| 50 | + | floatingPaneIDPrefix = "floating-pane:" | |||
| 51 | + | floatingSessionIDDefault = "default" | |||
| 47 | ) | 52 | ) | ||
| 48 | 53 | ||||
| 54 | + | func gtkApplicationFlags() gio.ApplicationFlags { | |||
| 55 | + | return gio.GApplicationNonUniqueValue | |||
| 56 | + | } | |||
| 57 | + | ||||
| 49 | // App wraps the GTK Application and manages the browser lifecycle. | 58 | // App wraps the GTK Application and manages the browser lifecycle. | ||
| 50 | type App struct { | 59 | type App struct { | ||
| 51 | deps *Dependencies | 60 | deps *Dependencies | ||
| @@ -81,9 +90,12 @@ type App struct { | |||||
| 81 | resizeModeBorderTarget layout.Widget | 90 | resizeModeBorderTarget layout.Widget | ||
| 82 | 91 | ||||
| 83 | // Omnibox configuration (omnibox is created per workspace view) | 92 | // Omnibox configuration (omnibox is created per workspace view) | ||
| 84 | - | omniboxCfg component.OmniboxConfig | 93 | + | omniboxCfg component.OmniboxConfig |
| 94 | + | omniboxNavigateFn func(url string) | |||
| 85 | // Find bar configuration (find bar is created per workspace view) | 95 | // Find bar configuration (find bar is created per workspace view) | ||
| 86 | findBarCfg component.FindBarConfig | 96 | findBarCfg component.FindBarConfig | ||
| 97 | + | // Floating pane sessions keyed by tab and profile session ID. | |||
| 98 | + | floatingSessions map[floatingSessionKey]*floatingWorkspaceSession | |||
| 87 | 99 | ||||
| 88 | // Web content (managed by ContentCoordinator) | 100 | // Web content (managed by ContentCoordinator) | ||
| 89 | pool *webkit.WebViewPool | 101 | pool *webkit.WebViewPool | ||
| @@ -130,6 +142,25 @@ type App struct { | |||||
| 130 | cancel context.CancelCauseFunc | 142 | cancel context.CancelCauseFunc | ||
| 131 | } | 143 | } | ||
| 132 | 144 | ||||
| 145 | + | type floatingWorkspaceSession struct { | |||
| 146 | + | paneID entity.PaneID | |||
| 147 | + | pane *component.FloatingPane | |||
| 148 | + | webView *webkit.WebView | |||
| 149 | + | overlay layout.OverlayWidget | |||
| 150 | + | widget layout.Widget | |||
| 151 | + | omnibox *component.Omnibox | |||
| 152 | + | omniboxWidget layout.Widget | |||
| 153 | + | resizeWatcherActive bool | |||
| 154 | + | resizeTickID uint | |||
| 155 | + | appliedWidth int | |||
| 156 | + | appliedHeight int | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | type floatingSessionKey struct { | |||
| 160 | + | tabID entity.TabID | |||
| 161 | + | sessionID string | |||
| 162 | + | } | |||
| 163 | + | ||||
| 133 | // New creates a new App with the given dependencies. | 164 | // New creates a new App with the given dependencies. | ||
| 134 | func New(deps *Dependencies) (*App, error) { | 165 | func New(deps *Dependencies) (*App, error) { | ||
| 135 | if err := deps.Validate(); err != nil { | 166 | if err := deps.Validate(); err != nil { | ||
| @@ -138,17 +169,18 @@ func New(deps *Dependencies) (*App, error) { | |||||
| 138 | ctx, cancel := context.WithCancelCause(deps.Ctx) | 169 | ctx, cancel := context.WithCancelCause(deps.Ctx) | ||
| 139 | 170 | ||||
| 140 | app := &App{ | 171 | app := &App{ | ||
| 141 | - | deps: deps, | 172 | + | deps: deps, |
| 142 | - | tabs: entity.NewTabList(), | 173 | + | tabs: entity.NewTabList(), |
| 143 | - | tabsUC: deps.TabsUC, | 174 | + | tabsUC: deps.TabsUC, |
| 144 | - | panesUC: deps.PanesUC, | 175 | + | panesUC: deps.PanesUC, |
| 145 | - | workspaceViews: make(map[entity.TabID]*component.WorkspaceView), | 176 | + | workspaceViews: make(map[entity.TabID]*component.WorkspaceView), |
| 146 | - | pool: deps.Pool, | 177 | + | floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession), |
| 147 | - | injector: deps.Injector, | 178 | + | pool: deps.Pool, |
| 148 | - | router: deps.MessageRouter, | 179 | + | injector: deps.Injector, |
| 149 | - | settings: deps.Settings, | 180 | + | router: deps.MessageRouter, |
| 150 | - | configManager: config.GetManager(), | 181 | + | settings: deps.Settings, |
| 151 | - | cancel: cancel, | 182 | + | configManager: config.GetManager(), |
| 183 | + | cancel: cancel, | |||
| 152 | } | 184 | } | ||
| 153 | if app.router == nil { | 185 | if app.router == nil { | ||
| 154 | app.router = webkit.NewMessageRouter(ctx) | 186 | app.router = webkit.NewMessageRouter(ctx) | ||
| @@ -208,7 +240,7 @@ func (a *App) Run(ctx context.Context, args []string) int { | |||||
| 208 | } | 240 | } | ||
| 209 | 241 | ||||
| 210 | appID := AppID | 242 | appID := AppID | ||
| 211 | - | a.gtkApp = gtk.NewApplication(&appID, gio.GApplicationFlagsNoneValue) | 243 | + | a.gtkApp = gtk.NewApplication(&appID, gtkApplicationFlags()) |
| 212 | if a.gtkApp == nil { | 244 | if a.gtkApp == nil { | ||
| 213 | log.Error().Msg("failed to create GTK application") | 245 | log.Error().Msg("failed to create GTK application") | ||
| 214 | return 1 | 246 | return 1 | ||
| @@ -582,6 +614,11 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||||
| 582 | // Create keyboard handler and wire to dispatcher. | 614 | // Create keyboard handler and wire to dispatcher. | ||
| 583 | a.keyboardHandler = input.NewKeyboardHandler(ctx, a.deps.Config) | 615 | a.keyboardHandler = input.NewKeyboardHandler(ctx, a.deps.Config) | ||
| 584 | a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error { | 616 | a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error { | ||
| 617 | + | if action == input.ActionClosePane { | |||
| 618 | + | if a.closeActiveFloatingPane(ctx) { | |||
| 619 | + | return nil | |||
| 620 | + | } | |||
| 621 | + | } | |||
| 585 | return a.kbDispatcher.Dispatch(ctx, action) | 622 | return a.kbDispatcher.Dispatch(ctx, action) | ||
| 586 | }) | 623 | }) | ||
| 587 | a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) { | 624 | a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) { | ||
| @@ -656,7 +693,7 @@ func (a *App) initOmniboxConfig(ctx context.Context) { | |||||
| 656 | InitialBehavior: a.deps.Config.Omnibox.InitialBehavior, | 693 | InitialBehavior: a.deps.Config.Omnibox.InitialBehavior, | ||
| 657 | UIScale: a.deps.Config.DefaultUIScale, | 694 | UIScale: a.deps.Config.DefaultUIScale, | ||
| 658 | OnNavigate: func(url string) { | 695 | OnNavigate: func(url string) { | ||
| 659 | - | if err := a.navCoord.Navigate(ctx, url); err != nil { | 696 | + | if err := a.navigateFromOmnibox(ctx, url); err != nil { |
| 660 | log.Error().Err(err).Str("url", url).Msg("navigation failed") | 697 | log.Error().Err(err).Str("url", url).Msg("navigation failed") | ||
| 661 | } | 698 | } | ||
| 662 | }, | 699 | }, | ||
| @@ -967,6 +1004,7 @@ func (a *App) applyMovePaneToTabUI(ctx context.Context, out *usecase.MovePaneToT | |||||
| 967 | } | 1004 | } | ||
| 968 | 1005 | ||||
| 969 | func (a *App) removeSourceTabUI(sourceTabID entity.TabID) { | 1006 | func (a *App) removeSourceTabUI(sourceTabID entity.TabID) { | ||
| 1007 | + | a.releaseFloatingSessionsForTab(context.Background(), sourceTabID) | |||
| 970 | delete(a.workspaceViews, sourceTabID) | 1008 | delete(a.workspaceViews, sourceTabID) | ||
| 971 | if a.mainWindow != nil && a.mainWindow.TabBar() != nil { | 1009 | if a.mainWindow != nil && a.mainWindow.TabBar() != nil { | ||
| 972 | a.mainWindow.TabBar().RemoveTab(sourceTabID) | 1010 | a.mainWindow.TabBar().RemoveTab(sourceTabID) | ||
| @@ -1309,6 +1347,17 @@ func (a *App) onShutdown(ctx context.Context) { | |||||
| 1309 | if a.faviconAdapter != nil { | 1347 | if a.faviconAdapter != nil { | ||
| 1310 | a.faviconAdapter.Close() | 1348 | a.faviconAdapter.Close() | ||
| 1311 | } | 1349 | } | ||
| 1350 | + | tabIDSet := make(map[entity.TabID]struct{}, len(a.floatingSessions)) | |||
| 1351 | + | for key := range a.floatingSessions { | |||
| 1352 | + | tabIDSet[key.tabID] = struct{}{} | |||
| 1353 | + | } | |||
| 1354 | + | tabIDs := make([]entity.TabID, 0, len(tabIDSet)) | |||
| 1355 | + | for tabID := range tabIDSet { | |||
| 1356 | + | tabIDs = append(tabIDs, tabID) | |||
| 1357 | + | } | |||
| 1358 | + | for _, tabID := range tabIDs { | |||
| 1359 | + | a.releaseFloatingSessionsForTab(ctx, tabID) | |||
| 1360 | + | } | |||
| 1312 | if a.deps.Pool != nil { | 1361 | if a.deps.Pool != nil { | ||
| 1313 | a.deps.Pool.Close(ctx) | 1362 | a.deps.Pool.Close(ctx) | ||
| 1314 | } | 1363 | } | ||
| @@ -1484,6 +1533,7 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 1484 | // Wire pane URI updates for session snapshots (searches all tabs) | 1533 | // Wire pane URI updates for session snapshots (searches all tabs) | ||
| 1485 | a.contentCoord.SetOnPaneURIUpdated(func(paneID entity.PaneID, url string) { | 1534 | a.contentCoord.SetOnPaneURIUpdated(func(paneID entity.PaneID, url string) { | ||
| 1486 | a.updatePaneURIInAllTabs(paneID, url) | 1535 | a.updatePaneURIInAllTabs(paneID, url) | ||
| 1536 | + | a.updateFloatingSessionURI(paneID, url) | |||
| 1487 | // Mark dirty so snapshot captures the new URI | 1537 | // Mark dirty so snapshot captures the new URI | ||
| 1488 | a.MarkDirty() | 1538 | a.MarkDirty() | ||
| 1489 | }) | 1539 | }) | ||
| @@ -1522,6 +1572,12 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 1522 | a.deps.ZoomUC, | 1572 | a.deps.ZoomUC, | ||
| 1523 | a.deps.CopyURLUC, | 1573 | a.deps.CopyURLUC, | ||
| 1524 | ) | 1574 | ) | ||
| 1575 | + | a.wireKeyboardActions() | |||
| 1576 | + | ||||
| 1577 | + | log.Debug().Msg("coordinators initialized") | |||
| 1578 | + | } | |||
| 1579 | + | ||||
| 1580 | + | func (a *App) wireKeyboardActions() { | |||
| 1525 | a.kbDispatcher.SetOnQuit(a.Quit) | 1581 | a.kbDispatcher.SetOnQuit(a.Quit) | ||
| 1526 | a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error { | 1582 | a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error { | ||
| 1527 | a.ToggleFindBar(ctx) | 1583 | a.ToggleFindBar(ctx) | ||
| @@ -1545,13 +1601,17 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 1545 | a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context) error { | 1601 | a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context) error { | ||
| 1546 | return a.HandleMovePaneToNextTab(ctx) | 1602 | return a.HandleMovePaneToNextTab(ctx) | ||
| 1547 | }) | 1603 | }) | ||
| 1604 | + | a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error { | |||
| 1605 | + | return a.ToggleFloatingPane(ctx) | |||
| 1606 | + | }) | |||
| 1607 | + | a.kbDispatcher.SetOnOpenFloatingTarget(func(ctx context.Context, target input.FloatingProfileTarget) error { | |||
| 1608 | + | return a.OpenFloatingPaneProfileURL(ctx, target.SessionID, target.URL) | |||
| 1609 | + | }) | |||
| 1548 | 1610 | ||||
| 1549 | // Wire gesture handler to dispatcher (for mouse button 8/9 navigation) | 1611 | // Wire gesture handler to dispatcher (for mouse button 8/9 navigation) | ||
| 1550 | a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error { | 1612 | a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error { | ||
| 1551 | return a.kbDispatcher.Dispatch(ctx, action) | 1613 | return a.kbDispatcher.Dispatch(ctx, action) | ||
| 1552 | }) | 1614 | }) | ||
| 1553 | - | ||||
| 1554 | - | log.Debug().Msg("coordinators initialized") | |||
| 1555 | } | 1615 | } | ||
| 1556 | 1616 | ||||
| 1557 | func (a *App) wireWebRTCPermissionIndicator() { | 1617 | func (a *App) wireWebRTCPermissionIndicator() { | ||
| @@ -1889,6 +1949,17 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity. | |||||
| 1889 | 1949 | ||||
| 1890 | // Store in map | 1950 | // Store in map | ||
| 1891 | a.workspaceViews[tab.ID] = wsView | 1951 | a.workspaceViews[tab.ID] = wsView | ||
| 1952 | + | for key, session := range a.floatingSessions { | |||
| 1953 | + | if key.tabID != tab.ID || session == nil || session.pane == nil { | |||
| 1954 | + | continue | |||
| 1955 | + | } | |||
| 1956 | + | session.pane.SetParentOverlay(wsView.WorkspaceOverlayWidget()) | |||
| 1957 | + | if session.widget != nil { | |||
| 1958 | + | wsView.AddWorkspaceOverlayWidget(session.widget) | |||
| 1959 | + | session.widget.SetVisible(session.pane.IsVisible()) | |||
| 1960 | + | } | |||
| 1961 | + | } | |||
| 1962 | + | a.syncFloatingFocus() | |||
| 1892 | 1963 | ||||
| 1893 | log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created") | 1964 | log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created") | ||
| 1894 | } | 1965 | } | ||
| @@ -1929,12 +2000,11 @@ func (a *App) activeWorkspaceView() *component.WorkspaceView { | |||||
| 1929 | // getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView. | 2000 | // getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView. | ||
| 1930 | // Used by the accent picker to insert accented characters into web content. | 2001 | // Used by the accent picker to insert accented characters into web content. | ||
| 1931 | func (a *App) getActiveWebViewTarget() port.TextInputTarget { | 2002 | func (a *App) getActiveWebViewTarget() port.TextInputTarget { | ||
| 1932 | - | ws := a.activeWorkspace() | 2003 | + | if a.contentCoord == nil || a.deps == nil || a.deps.Clipboard == nil { |
| 1933 | - | if ws == nil || a.contentCoord == nil || a.deps == nil || a.deps.Clipboard == nil { | |||
| 1934 | return nil | 2004 | return nil | ||
| 1935 | } | 2005 | } | ||
| 1936 | 2006 | ||||
| 1937 | - | wv := a.contentCoord.GetWebView(ws.ActivePaneID) | 2007 | + | wv := a.contentCoord.ActiveWebView(context.Background()) |
| 1938 | if wv == nil { | 2008 | if wv == nil { | ||
| 1939 | return nil | 2009 | return nil | ||
| 1940 | } | 2010 | } | ||
| @@ -2006,14 +2076,464 @@ func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) { | |||||
| 2006 | 2076 | ||||
| 2007 | // Update window title with the new active pane's title | 2077 | // Update window title with the new active pane's title | ||
| 2008 | a.updateWindowTitleFromActivePane() | 2078 | a.updateWindowTitleFromActivePane() | ||
| 2079 | + | a.syncFloatingFocus() | |||
| 2009 | 2080 | ||||
| 2010 | log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched") | 2081 | log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched") | ||
| 2011 | } | 2082 | } | ||
| 2012 | 2083 | ||||
| 2084 | + | func normalizeFloatingSessionID(sessionID string) string { | |||
| 2085 | + | sessionID = strings.TrimSpace(sessionID) | |||
| 2086 | + | if sessionID == "" { | |||
| 2087 | + | return floatingSessionIDDefault | |||
| 2088 | + | } | |||
| 2089 | + | return sessionID | |||
| 2090 | + | } | |||
| 2091 | + | ||||
| 2092 | + | func floatingPaneIDForSession(tabID entity.TabID, sessionID string) entity.PaneID { | |||
| 2093 | + | return entity.PaneID(floatingPaneIDPrefix + string(tabID) + ":" + normalizeFloatingSessionID(sessionID)) | |||
| 2094 | + | } | |||
| 2095 | + | ||||
| 2096 | + | func floatingSessionMapKey(tabID entity.TabID, sessionID string) floatingSessionKey { | |||
| 2097 | + | return floatingSessionKey{tabID: tabID, sessionID: normalizeFloatingSessionID(sessionID)} | |||
| 2098 | + | } | |||
| 2099 | + | ||||
| 2100 | + | func decorateFloatingOverlay(overlay layout.OverlayWidget) { | |||
| 2101 | + | if overlay == nil { | |||
| 2102 | + | return | |||
| 2103 | + | } | |||
| 2104 | + | ||||
| 2105 | + | overlay.AddCssClass("floating-pane-container") | |||
| 2106 | + | overlay.AddCssClass("pane-border") | |||
| 2107 | + | overlay.AddCssClass("pane-active") | |||
| 2108 | + | } | |||
| 2109 | + | ||||
| 2110 | + | func (a *App) currentFloatingConfig() config.FloatingPaneConfig { | |||
| 2111 | + | if a.deps != nil && a.deps.Config != nil { | |||
| 2112 | + | return a.deps.Config.Workspace.FloatingPane | |||
| 2113 | + | } | |||
| 2114 | + | return config.Get().Workspace.FloatingPane | |||
| 2115 | + | } | |||
| 2116 | + | ||||
| 2117 | + | func (a *App) ensureFloatingSession( | |||
| 2118 | + | ctx context.Context, | |||
| 2119 | + | tabID entity.TabID, | |||
| 2120 | + | sessionID string, | |||
| 2121 | + | wsView *component.WorkspaceView, | |||
| 2122 | + | ) (*floatingWorkspaceSession, error) { | |||
| 2123 | + | key := floatingSessionMapKey(tabID, sessionID) | |||
| 2124 | + | if session, ok := a.floatingSessions[key]; ok && session != nil { | |||
| 2125 | + | if wsView == nil { | |||
| 2126 | + | return session, nil | |||
| 2127 | + | } | |||
| 2128 | + | session.pane.SetParentOverlay(wsView.WorkspaceOverlayWidget()) | |||
| 2129 | + | if session.widget != nil { | |||
| 2130 | + | wsView.AddWorkspaceOverlayWidget(session.widget) | |||
| 2131 | + | } | |||
| 2132 | + | return session, nil | |||
| 2133 | + | } | |||
| 2134 | + | ||||
| 2135 | + | if wsView == nil { | |||
| 2136 | + | return nil, fmt.Errorf("workspace view not found for tab %s", tabID) | |||
| 2137 | + | } | |||
| 2138 | + | if a.contentCoord == nil { | |||
| 2139 | + | return nil, fmt.Errorf("content coordinator not initialized") | |||
| 2140 | + | } | |||
| 2141 | + | if a.widgetFactory == nil { | |||
| 2142 | + | return nil, fmt.Errorf("widget factory not initialized") | |||
| 2143 | + | } | |||
| 2144 | + | ||||
| 2145 | + | paneID := floatingPaneIDForSession(tabID, sessionID) | |||
| 2146 | + | wv, err := a.contentCoord.EnsureWebView(ctx, paneID) | |||
| 2147 | + | if err != nil { | |||
| 2148 | + | return nil, fmt.Errorf("ensure floating webview: %w", err) | |||
| 2149 | + | } | |||
| 2150 | + | ||||
| 2151 | + | webViewWidget := a.contentCoord.WrapWidget(ctx, wv) | |||
| 2152 | + | if webViewWidget == nil { | |||
| 2153 | + | return nil, fmt.Errorf("wrap floating webview widget") | |||
| 2154 | + | } | |||
| 2155 | + | webViewWidget.SetHexpand(true) | |||
| 2156 | + | webViewWidget.SetVexpand(true) | |||
| 2157 | + | ||||
| 2158 | + | overlay := a.widgetFactory.NewOverlay() | |||
| 2159 | + | overlay.SetChild(webViewWidget) | |||
| 2160 | + | decorateFloatingOverlay(overlay) | |||
| 2161 | + | overlay.SetHalign(gtk.AlignCenterValue) | |||
| 2162 | + | overlay.SetValign(gtk.AlignCenterValue) | |||
| 2163 | + | overlay.SetVisible(false) | |||
| 2164 | + | wsView.AddWorkspaceOverlayWidget(overlay) | |||
| 2165 | + | ||||
| 2166 | + | floatingCfg := a.currentFloatingConfig() | |||
| 2167 | + | floatingPane := component.NewFloatingPane(wsView.WorkspaceOverlayWidget(), component.FloatingPaneOptions{ | |||
| 2168 | + | WidthPct: floatingCfg.WidthPct, | |||
| 2169 | + | HeightPct: floatingCfg.HeightPct, | |||
| 2170 | + | FallbackWidth: floatingPaneFallbackWidth, | |||
| 2171 | + | FallbackHeight: floatingPaneFallbackHeight, | |||
| 2172 | + | OnNavigate: func(navCtx context.Context, url string) error { | |||
| 2173 | + | return wv.LoadURI(navCtx, url) | |||
| 2174 | + | }, | |||
| 2175 | + | }) | |||
| 2176 | + | ||||
| 2177 | + | session := &floatingWorkspaceSession{ | |||
| 2178 | + | paneID: paneID, | |||
| 2179 | + | pane: floatingPane, | |||
| 2180 | + | webView: wv, | |||
| 2181 | + | overlay: overlay, | |||
| 2182 | + | widget: overlay, | |||
| 2183 | + | } | |||
| 2184 | + | a.floatingSessions[key] = session | |||
| 2185 | + | return session, nil | |||
| 2186 | + | } | |||
| 2187 | + | ||||
| 2188 | + | func (a *App) floatingSessionsForTab(tabID entity.TabID) map[floatingSessionKey]*floatingWorkspaceSession { | |||
| 2189 | + | sessions := make(map[floatingSessionKey]*floatingWorkspaceSession) | |||
| 2190 | + | for key, session := range a.floatingSessions { | |||
| 2191 | + | if key.tabID != tabID || session == nil { | |||
| 2192 | + | continue | |||
| 2193 | + | } | |||
| 2194 | + | sessions[key] = session | |||
| 2195 | + | } | |||
| 2196 | + | return sessions | |||
| 2197 | + | } | |||
| 2198 | + | ||||
| 2199 | + | func (a *App) releaseFloatingSessionsForTab(ctx context.Context, tabID entity.TabID) { | |||
| 2200 | + | sessions := a.floatingSessionsForTab(tabID) | |||
| 2201 | + | if len(sessions) == 0 { | |||
| 2202 | + | return | |||
| 2203 | + | } | |||
| 2204 | + | ||||
| 2205 | + | for key, session := range sessions { | |||
| 2206 | + | a.stopFloatingResizeWatcher(session) | |||
| 2207 | + | if session.pane != nil { | |||
| 2208 | + | session.pane.Hide(ctx) | |||
| 2209 | + | } | |||
| 2210 | + | a.hideFloatingOmnibox(ctx, session) | |||
| 2211 | + | if wsView := a.workspaceViews[tabID]; wsView != nil && session.widget != nil { | |||
| 2212 | + | wsView.RemoveWorkspaceOverlayWidget(session.widget) | |||
| 2213 | + | } | |||
| 2214 | + | if a.contentCoord != nil && session.paneID != "" { | |||
| 2215 | + | a.contentCoord.ReleaseWebView(ctx, session.paneID) | |||
| 2216 | + | } | |||
| 2217 | + | delete(a.floatingSessions, key) | |||
| 2218 | + | } | |||
| 2219 | + | a.syncFloatingFocus() | |||
| 2220 | + | } | |||
| 2221 | + | ||||
| 2222 | + | func (a *App) activeFloatingSession() (*floatingWorkspaceSession, entity.TabID) { | |||
| 2223 | + | activeTab := a.tabs.ActiveTab() | |||
| 2224 | + | if activeTab == nil { | |||
| 2225 | + | return nil, "" | |||
| 2226 | + | } | |||
| 2227 | + | for key, session := range a.floatingSessions { | |||
| 2228 | + | if key.tabID != activeTab.ID || session == nil || session.pane == nil { | |||
| 2229 | + | continue | |||
| 2230 | + | } | |||
| 2231 | + | if session.pane.IsVisible() { | |||
| 2232 | + | return session, activeTab.ID | |||
| 2233 | + | } | |||
| 2234 | + | } | |||
| 2235 | + | return nil, activeTab.ID | |||
| 2236 | + | } | |||
| 2237 | + | ||||
| 2238 | + | func (a *App) updateFloatingSessionURI(paneID entity.PaneID, url string) { | |||
| 2239 | + | if paneID == "" { | |||
| 2240 | + | return | |||
| 2241 | + | } | |||
| 2242 | + | ||||
| 2243 | + | for _, session := range a.floatingSessions { | |||
| 2244 | + | if session == nil || session.pane == nil { | |||
| 2245 | + | continue | |||
| 2246 | + | } | |||
| 2247 | + | if session.paneID != paneID { | |||
| 2248 | + | continue | |||
| 2249 | + | } | |||
| 2250 | + | session.pane.RecordLoadedURL(url) | |||
| 2251 | + | return | |||
| 2252 | + | } | |||
| 2253 | + | } | |||
| 2254 | + | ||||
| 2255 | + | func (a *App) syncFloatingFocus() { | |||
| 2256 | + | for _, wsView := range a.workspaceViews { | |||
| 2257 | + | if wsView != nil { | |||
| 2258 | + | wsView.SetHoverFocusLocked(false) | |||
| 2259 | + | } | |||
| 2260 | + | } | |||
| 2261 | + | ||||
| 2262 | + | if a.contentCoord == nil { | |||
| 2263 | + | return | |||
| 2264 | + | } | |||
| 2265 | + | ||||
| 2266 | + | activeTab := a.tabs.ActiveTab() | |||
| 2267 | + | if activeTab == nil { | |||
| 2268 | + | a.contentCoord.ClearActivePaneOverride() | |||
| 2269 | + | return | |||
| 2270 | + | } | |||
| 2271 | + | ||||
| 2272 | + | if session, _ := a.activeFloatingSession(); session != nil { | |||
| 2273 | + | if wsView := a.workspaceViews[activeTab.ID]; wsView != nil { | |||
| 2274 | + | wsView.SetHoverFocusLocked(true) | |||
| 2275 | + | } | |||
| 2276 | + | a.startFloatingResizeWatcher(session) | |||
| 2277 | + | a.contentCoord.SetActivePaneOverride(session.paneID) | |||
| 2278 | + | return | |||
| 2279 | + | } | |||
| 2280 | + | ||||
| 2281 | + | a.contentCoord.ClearActivePaneOverride() | |||
| 2282 | + | } | |||
| 2283 | + | ||||
| 2284 | + | func (a *App) showFloatingOmnibox(ctx context.Context, session *floatingWorkspaceSession) { | |||
| 2285 | + | if session == nil || session.overlay == nil || a.widgetFactory == nil { | |||
| 2286 | + | return | |||
| 2287 | + | } | |||
| 2288 | + | ||||
| 2289 | + | if session.omnibox == nil { | |||
| 2290 | + | cfg := a.omniboxCfg | |||
| 2291 | + | cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) { | |||
| 2292 | + | if a.appToaster != nil { | |||
| 2293 | + | a.appToaster.Show(toastCtx, message, level) | |||
| 2294 | + | } | |||
| 2295 | + | } | |||
| 2296 | + | ||||
| 2297 | + | omnibox := component.NewOmnibox(ctx, cfg) | |||
| 2298 | + | if omnibox == nil { | |||
| 2299 | + | return | |||
| 2300 | + | } | |||
| 2301 | + | ||||
| 2302 | + | omnibox.SetParentOverlay(session.overlay) | |||
| 2303 | + | omniboxWidget := omnibox.WidgetAsLayout(a.widgetFactory) | |||
| 2304 | + | if omniboxWidget == nil { | |||
| 2305 | + | return | |||
| 2306 | + | } | |||
| 2307 | + | ||||
| 2308 | + | session.overlay.AddOverlay(omniboxWidget) | |||
| 2309 | + | session.overlay.SetClipOverlay(omniboxWidget, false) | |||
| 2310 | + | session.overlay.SetMeasureOverlay(omniboxWidget, false) | |||
| 2311 | + | ||||
| 2312 | + | session.omnibox = omnibox | |||
| 2313 | + | session.omniboxWidget = omniboxWidget | |||
| 2314 | + | } | |||
| 2315 | + | ||||
| 2316 | + | session.pane.SetOmniboxVisible(true) | |||
| 2317 | + | session.omnibox.Show(ctx, "") | |||
| 2318 | + | } | |||
| 2319 | + | ||||
| 2320 | + | func (a *App) hideFloatingOmnibox(ctx context.Context, session *floatingWorkspaceSession) { | |||
| 2321 | + | if session == nil || session.pane == nil { | |||
| 2322 | + | return | |||
| 2323 | + | } | |||
| 2324 | + | ||||
| 2325 | + | session.pane.SetOmniboxVisible(false) | |||
| 2326 | + | if session.omnibox == nil { | |||
| 2327 | + | return | |||
| 2328 | + | } | |||
| 2329 | + | ||||
| 2330 | + | session.omnibox.Hide(ctx) | |||
| 2331 | + | if session.overlay != nil && session.omniboxWidget != nil { | |||
| 2332 | + | if parent := session.omniboxWidget.GetParent(); parent == session.overlay { | |||
| 2333 | + | session.overlay.RemoveOverlay(session.omniboxWidget) | |||
| 2334 | + | } else if parent != nil { | |||
| 2335 | + | session.omniboxWidget.Unparent() | |||
| 2336 | + | } | |||
| 2337 | + | } | |||
| 2338 | + | session.omnibox = nil | |||
| 2339 | + | session.omniboxWidget = nil | |||
| 2340 | + | } | |||
| 2341 | + | ||||
| 2342 | + | func (a *App) resizeFloatingWidget(session *floatingWorkspaceSession) { | |||
| 2343 | + | if session == nil || session.pane == nil { | |||
| 2344 | + | return | |||
| 2345 | + | } | |||
| 2346 | + | session.pane.Resize() | |||
| 2347 | + | if session.widget == nil { | |||
| 2348 | + | return | |||
| 2349 | + | } | |||
| 2350 | + | width, height := session.pane.Dimensions() | |||
| 2351 | + | if session.appliedWidth == width && session.appliedHeight == height { | |||
| 2352 | + | return | |||
| 2353 | + | } | |||
| 2354 | + | session.widget.SetSizeRequest(width, height) | |||
| 2355 | + | session.appliedWidth = width | |||
| 2356 | + | session.appliedHeight = height | |||
| 2357 | + | } | |||
| 2358 | + | ||||
| 2359 | + | func (a *App) handleFloatingViewportTick(session *floatingWorkspaceSession) bool { | |||
| 2360 | + | if session == nil || session.pane == nil { | |||
| 2361 | + | return false | |||
| 2362 | + | } | |||
| 2363 | + | if !session.pane.IsVisible() { | |||
| 2364 | + | session.resizeWatcherActive = false | |||
| 2365 | + | session.resizeTickID = 0 | |||
| 2366 | + | return false | |||
| 2367 | + | } | |||
| 2368 | + | ||||
| 2369 | + | a.resizeFloatingWidget(session) | |||
| 2370 | + | return true | |||
| 2371 | + | } | |||
| 2372 | + | ||||
| 2373 | + | func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) { | |||
| 2374 | + | if session == nil || session.overlay == nil || session.resizeWatcherActive { | |||
| 2375 | + | return | |||
| 2376 | + | } | |||
| 2377 | + | ||||
| 2378 | + | overlayWidget := session.overlay.GtkWidget() | |||
| 2379 | + | if overlayWidget == nil { | |||
| 2380 | + | return | |||
| 2381 | + | } | |||
| 2382 | + | ||||
| 2383 | + | session.resizeWatcherActive = true | |||
| 2384 | + | tickCallback := gtk.TickCallback(func(_ uintptr, _ uintptr, _ uintptr) bool { | |||
| 2385 | + | keepRunning := a.handleFloatingViewportTick(session) | |||
| 2386 | + | if !keepRunning { | |||
| 2387 | + | session.resizeWatcherActive = false | |||
| 2388 | + | session.resizeTickID = 0 | |||
| 2389 | + | } | |||
| 2390 | + | return keepRunning | |||
| 2391 | + | }) | |||
| 2392 | + | session.resizeTickID = overlayWidget.AddTickCallback(&tickCallback, 0, nil) | |||
| 2393 | + | } | |||
| 2394 | + | ||||
| 2395 | + | func (a *App) stopFloatingResizeWatcher(session *floatingWorkspaceSession) { | |||
| 2396 | + | if session == nil { | |||
| 2397 | + | return | |||
| 2398 | + | } | |||
| 2399 | + | ||||
| 2400 | + | if session.resizeTickID != 0 && session.overlay != nil { | |||
| 2401 | + | if overlayWidget := session.overlay.GtkWidget(); overlayWidget != nil { | |||
| 2402 | + | overlayWidget.RemoveTickCallback(session.resizeTickID) | |||
| 2403 | + | } | |||
| 2404 | + | } | |||
| 2405 | + | session.resizeTickID = 0 | |||
| 2406 | + | session.resizeWatcherActive = false | |||
| 2407 | + | } | |||
| 2408 | + | ||||
| 2409 | + | func (a *App) hideFloatingSession(ctx context.Context, session *floatingWorkspaceSession) { | |||
| 2410 | + | if session == nil || session.pane == nil { | |||
| 2411 | + | return | |||
| 2412 | + | } | |||
| 2413 | + | ||||
| 2414 | + | a.stopFloatingResizeWatcher(session) | |||
| 2415 | + | session.pane.Hide(ctx) | |||
| 2416 | + | a.hideFloatingOmnibox(ctx, session) | |||
| 2417 | + | if session.widget != nil { | |||
| 2418 | + | session.widget.SetVisible(false) | |||
| 2419 | + | } | |||
| 2420 | + | } | |||
| 2421 | + | ||||
| 2422 | + | func (a *App) hideVisibleFloatingSessions(ctx context.Context, tabID entity.TabID, except *floatingWorkspaceSession) { | |||
| 2423 | + | for key, session := range a.floatingSessions { | |||
| 2424 | + | if key.tabID != tabID || session == nil || session == except || session.pane == nil { | |||
| 2425 | + | continue | |||
| 2426 | + | } | |||
| 2427 | + | if !session.pane.IsVisible() { | |||
| 2428 | + | continue | |||
| 2429 | + | } | |||
| 2430 | + | a.hideFloatingSession(ctx, session) | |||
| 2431 | + | } | |||
| 2432 | + | } | |||
| 2433 | + | ||||
| 2434 | + | func (a *App) closeActiveFloatingPane(ctx context.Context) bool { | |||
| 2435 | + | session, _ := a.activeFloatingSession() | |||
| 2436 | + | if session == nil { | |||
| 2437 | + | return false | |||
| 2438 | + | } | |||
| 2439 | + | a.hideFloatingSession(ctx, session) | |||
| 2440 | + | a.syncFloatingFocus() | |||
| 2441 | + | return true | |||
| 2442 | + | } | |||
| 2443 | + | ||||
| 2444 | + | // ToggleFloatingPane toggles the active workspace floating pane visibility. | |||
| 2445 | + | func (a *App) ToggleFloatingPane(ctx context.Context) error { | |||
| 2446 | + | activeTab := a.tabs.ActiveTab() | |||
| 2447 | + | if activeTab == nil { | |||
| 2448 | + | return nil | |||
| 2449 | + | } | |||
| 2450 | + | if a.closeActiveFloatingPane(ctx) { | |||
| 2451 | + | return nil | |||
| 2452 | + | } | |||
| 2453 | + | return a.openFloatingPaneSession(ctx, floatingSessionIDDefault) | |||
| 2454 | + | } | |||
| 2455 | + | ||||
| 2456 | + | // OpenFloatingPaneURL opens the active workspace floating pane directly to a URL. | |||
| 2457 | + | func (a *App) OpenFloatingPaneURL(ctx context.Context, url string) error { | |||
| 2458 | + | return a.openFloatingPaneSession(ctx, floatingSessionIDDefault, url) | |||
| 2459 | + | } | |||
| 2460 | + | ||||
| 2461 | + | // OpenFloatingPaneProfileURL opens a named floating pane profile session directly to a URL. | |||
| 2462 | + | func (a *App) OpenFloatingPaneProfileURL(ctx context.Context, sessionID, url string) error { | |||
| 2463 | + | return a.openFloatingPaneSession(ctx, sessionID, url) | |||
| 2464 | + | } | |||
| 2465 | + | ||||
| 2466 | + | func (a *App) openFloatingPaneSession(ctx context.Context, sessionID string, url ...string) error { | |||
| 2467 | + | activeTab := a.tabs.ActiveTab() | |||
| 2468 | + | if activeTab == nil { | |||
| 2469 | + | return nil | |||
| 2470 | + | } | |||
| 2471 | + | ||||
| 2472 | + | wsView := a.workspaceViews[activeTab.ID] | |||
| 2473 | + | session, err := a.ensureFloatingSession(ctx, activeTab.ID, sessionID, wsView) | |||
| 2474 | + | if err != nil { | |||
| 2475 | + | return err | |||
| 2476 | + | } | |||
| 2477 | + | a.hideVisibleFloatingSessions(ctx, activeTab.ID, session) | |||
| 2478 | + | ||||
| 2479 | + | hasURL := len(url) > 0 && strings.TrimSpace(url[0]) != "" | |||
| 2480 | + | if hasURL { | |||
| 2481 | + | if err := session.pane.ShowURL(ctx, url[0]); err != nil { | |||
| 2482 | + | return err | |||
| 2483 | + | } | |||
| 2484 | + | a.hideFloatingOmnibox(ctx, session) | |||
| 2485 | + | } else { | |||
| 2486 | + | if err := session.pane.ShowToggle(ctx); err != nil { | |||
| 2487 | + | return err | |||
| 2488 | + | } | |||
| 2489 | + | if session.pane.IsOmniboxVisible() { | |||
| 2490 | + | a.showFloatingOmnibox(ctx, session) | |||
| 2491 | + | } | |||
| 2492 | + | } | |||
| 2493 | + | ||||
| 2494 | + | a.resizeFloatingWidget(session) | |||
| 2495 | + | if session.widget != nil { | |||
| 2496 | + | session.widget.SetVisible(session.pane.IsVisible()) | |||
| 2497 | + | } | |||
| 2498 | + | a.startFloatingResizeWatcher(session) | |||
| 2499 | + | a.syncFloatingFocus() | |||
| 2500 | + | ||||
| 2501 | + | return nil | |||
| 2502 | + | } | |||
| 2503 | + | ||||
| 2504 | + | func (a *App) navigateFromOmnibox(ctx context.Context, url string) error { | |||
| 2505 | + | session, _ := a.activeFloatingSession() | |||
| 2506 | + | if session != nil && session.pane != nil && session.pane.IsVisible() && session.pane.IsOmniboxVisible() { | |||
| 2507 | + | return session.pane.Navigate(ctx, url) | |||
| 2508 | + | } | |||
| 2509 | + | if a.omniboxNavigateFn != nil { | |||
| 2510 | + | a.omniboxNavigateFn(url) | |||
| 2511 | + | return nil | |||
| 2512 | + | } | |||
| 2513 | + | if a.navCoord == nil { | |||
| 2514 | + | return fmt.Errorf("navigation coordinator not initialized") | |||
| 2515 | + | } | |||
| 2516 | + | return a.navCoord.Navigate(ctx, url) | |||
| 2517 | + | } | |||
| 2518 | + | ||||
| 2013 | // ToggleOmnibox implements OmniboxProvider. | 2519 | // ToggleOmnibox implements OmniboxProvider. | ||
| 2014 | // Toggles the omnibox visibility in the active workspace view. | 2520 | // Toggles the omnibox visibility in the active workspace view. | ||
| 2015 | func (a *App) ToggleOmnibox(ctx context.Context) { | 2521 | func (a *App) ToggleOmnibox(ctx context.Context) { | ||
| 2016 | log := logging.FromContext(ctx) | 2522 | log := logging.FromContext(ctx) | ||
| 2523 | + | if session, _ := a.activeFloatingSession(); session != nil && session.pane != nil && session.pane.IsVisible() { | |||
| 2524 | + | wsView := a.activeWorkspaceView() | |||
| 2525 | + | if wsView != nil && wsView.IsOmniboxVisible() { | |||
| 2526 | + | wsView.HideOmnibox() | |||
| 2527 | + | } | |||
| 2528 | + | ||||
| 2529 | + | if session.omnibox != nil && session.pane.IsOmniboxVisible() { | |||
| 2530 | + | a.hideFloatingOmnibox(ctx, session) | |||
| 2531 | + | } else { | |||
| 2532 | + | a.showFloatingOmnibox(ctx, session) | |||
| 2533 | + | } | |||
| 2534 | + | a.syncFloatingFocus() | |||
| 2535 | + | return | |||
| 2536 | + | } | |||
| 2017 | 2537 | ||||
| 2018 | wsView := a.activeWorkspaceView() | 2538 | wsView := a.activeWorkspaceView() | ||
| 2019 | if wsView == nil { | 2539 | if wsView == nil { | ||
| @@ -2098,8 +2618,7 @@ func (a *App) UpdateOmniboxZoom(factor float64) { | |||||
| 2098 | // This is called to set the navigation callback on new omniboxes. | 2618 | // This is called to set the navigation callback on new omniboxes. | ||
| 2099 | // Since omniboxes are created per-pane, we store the config with the callback. | 2619 | // Since omniboxes are created per-pane, we store the config with the callback. | ||
| 2100 | func (a *App) SetOmniboxOnNavigate(fn func(url string)) { | 2620 | func (a *App) SetOmniboxOnNavigate(fn func(url string)) { | ||
| 2101 | - | // The navigate callback is set when the omnibox is created via WorkspaceView.ShowOmnibox | 2621 | + | a.omniboxNavigateFn = fn |
| 2102 | - | // The WorkspaceView uses the stored omniboxCfg and sets up navigation via the omnibox's SetOnNavigate | |||
| 2103 | } | 2622 | } | ||
| 2104 | 2623 | ||||
| 2105 | // initFilteringAsync starts background filter loading with toast feedback. | 2624 | // initFilteringAsync starts background filter loading with toast feedback. | ||
A internal/ui/app_flags_test.go +15 -0
| @@ -0,0 +1,15 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/jwijenbergh/puregotk/v4/gio" | |
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestGTKApplicationFlags_AreNonUnique(t *testing.T) { | |
| 11 | + | flags := gtkApplicationFlags() | |
| 12 | + | ||
| 13 | + | assert.Equal(t, gio.GApplicationNonUniqueValue, flags) | |
| 14 | + | assert.NotEqual(t, gio.GApplicationFlagsNoneValue, flags) | |
| 15 | + | } | |
| @@ -0,0 +1,15 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/jwijenbergh/puregotk/v4/gio" | |||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestGTKApplicationFlags_AreNonUnique(t *testing.T) { | |||
| 11 | + | flags := gtkApplicationFlags() | |||
| 12 | + | ||||
| 13 | + | assert.Equal(t, gio.GApplicationNonUniqueValue, flags) | |||
| 14 | + | assert.NotEqual(t, gio.GApplicationFlagsNoneValue, flags) | |||
| 15 | + | } | |||