Centralize CEF viewport sync after layout changes
- commit
- 013796f86100705063c46df78dfc55498eee0e07
- author
- brice · · 2026-05-11 05:15 UTC
- parent
- 5714070
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +430 -24
M internal/application/port/webview.go +10 -0
| @@ -336,6 +336,16 @@ type PopupOpenerCapable interface { | |||
| 336 | 336 | HasActivePopupOpenerBridge() bool | |
| 337 | 337 | } | |
| 338 | 338 | ||
| 339 | + | // ViewportSyncCapable is implemented by WebViews that can explicitly resync | |
| 340 | + | // their internal viewport state after UI lifecycle events such as reparenting, | |
| 341 | + | // visibility toggles, or layout promotion that may not emit a size change. | |
| 342 | + | // | |
| 343 | + | // Implementations should make this cheap, safe to call often, and resilient to | |
| 344 | + | // duplicate requests. | |
| 345 | + | type ViewportSyncCapable interface { | |
| 346 | + | SyncViewport(ctx context.Context, reason string) | |
| 347 | + | } | |
| 348 | + | ||
| 339 | 349 | // OAuthCallbackCapable is implemented by WebViews that support OAuth auto-close callbacks. | |
| 340 | 350 | // It is an optional capability; callers should type-assert port.WebView to this interface | |
| 341 | 351 | // before use and degrade gracefully when it is absent. | |
| @@ -336,6 +336,16 @@ type PopupOpenerCapable interface { | |||||
| 336 | HasActivePopupOpenerBridge() bool | 336 | HasActivePopupOpenerBridge() bool | ||
| 337 | } | 337 | } | ||
| 338 | 338 | ||||
| 339 | + | // ViewportSyncCapable is implemented by WebViews that can explicitly resync | |||
| 340 | + | // their internal viewport state after UI lifecycle events such as reparenting, | |||
| 341 | + | // visibility toggles, or layout promotion that may not emit a size change. | |||
| 342 | + | // | |||
| 343 | + | // Implementations should make this cheap, safe to call often, and resilient to | |||
| 344 | + | // duplicate requests. | |||
| 345 | + | type ViewportSyncCapable interface { | |||
| 346 | + | SyncViewport(ctx context.Context, reason string) | |||
| 347 | + | } | |||
| 348 | + | ||||
| 339 | // OAuthCallbackCapable is implemented by WebViews that support OAuth auto-close callbacks. | 349 | // OAuthCallbackCapable is implemented by WebViews that support OAuth auto-close callbacks. | ||
| 340 | // It is an optional capability; callers should type-assert port.WebView to this interface | 350 | // It is an optional capability; callers should type-assert port.WebView to this interface | ||
| 341 | // before use and degrade gracefully when it is absent. | 351 | // before use and degrade gracefully when it is absent. | ||
M internal/infrastructure/cef/factory.go +2 -0
| @@ -113,6 +113,7 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) { | |||
| 113 | 113 | id: id, | |
| 114 | 114 | ctx: ctx, | |
| 115 | 115 | engine: f.engine, | |
| 116 | + | factory: f, | |
| 116 | 117 | viewBridge: viewBridge, | |
| 117 | 118 | audioOutputFactory: f.audioOutputFactory, | |
| 118 | 119 | windowlessFrameRate: f.windowlessFrameRate, | |
| @@ -131,6 +132,7 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) { | |||
| 131 | 132 | logging.FromContext(ctx).Warn().Err(err).Uint64("webview_id", uint64(id)).Msg("cef2gtk: failed to enable profiling") | |
| 132 | 133 | } | |
| 133 | 134 | } | |
| 135 | + | wv.installViewportSyncHooks() | |
| 134 | 136 | wv.startRenderStallWatchdog() | |
| 135 | 137 | return wv, nil | |
| 136 | 138 | } | |
| @@ -113,6 +113,7 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) { | |||||
| 113 | id: id, | 113 | id: id, | ||
| 114 | ctx: ctx, | 114 | ctx: ctx, | ||
| 115 | engine: f.engine, | 115 | engine: f.engine, | ||
| 116 | + | factory: f, | |||
| 116 | viewBridge: viewBridge, | 117 | viewBridge: viewBridge, | ||
| 117 | audioOutputFactory: f.audioOutputFactory, | 118 | audioOutputFactory: f.audioOutputFactory, | ||
| 118 | windowlessFrameRate: f.windowlessFrameRate, | 119 | windowlessFrameRate: f.windowlessFrameRate, | ||
| @@ -131,6 +132,7 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) { | |||||
| 131 | logging.FromContext(ctx).Warn().Err(err).Uint64("webview_id", uint64(id)).Msg("cef2gtk: failed to enable profiling") | 132 | logging.FromContext(ctx).Warn().Err(err).Uint64("webview_id", uint64(id)).Msg("cef2gtk: failed to enable profiling") | ||
| 132 | } | 133 | } | ||
| 133 | } | 134 | } | ||
| 135 | + | wv.installViewportSyncHooks() | |||
| 134 | wv.startRenderStallWatchdog() | 136 | wv.startRenderStallWatchdog() | ||
| 135 | return wv, nil | 137 | return wv, nil | ||
| 136 | } | 138 | } | ||
M internal/infrastructure/cef/webview.go +13 -7
| @@ -29,6 +29,7 @@ var ( | |||
| 29 | 29 | _ port.DevToolsOpener = (*WebView)(nil) | |
| 30 | 30 | _ port.PopupLifecycleCapable = (*WebView)(nil) | |
| 31 | 31 | _ port.PopupOpenerCapable = (*WebView)(nil) | |
| 32 | + | _ port.ViewportSyncCapable = (*WebView)(nil) | |
| 32 | 33 | _ port.OAuthCallbackCapable = (*WebView)(nil) | |
| 33 | 34 | ) | |
| 34 | 35 | ||
| @@ -72,6 +73,7 @@ type WebView struct { | |||
| 72 | 73 | id port.WebViewID | |
| 73 | 74 | ctx context.Context | |
| 74 | 75 | engine *Engine | |
| 76 | + | factory *WebViewFactory | |
| 75 | 77 | browser purecef.Browser | |
| 76 | 78 | host purecef.BrowserHost | |
| 77 | 79 | client purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it | |
| @@ -88,7 +90,13 @@ type WebView struct { | |||
| 88 | 90 | previousProfileSnapshot cef2gtk.ProfileSnapshot | |
| 89 | 91 | latestProfileSnapshotAt time.Time | |
| 90 | 92 | ||
| 91 | - | removeSizeObserver func() | |
| 93 | + | removeSizeObserver func() | |
| 94 | + | viewportSyncMu sync.Mutex | |
| 95 | + | viewportSyncPending bool | |
| 96 | + | viewportSyncReason string | |
| 97 | + | viewportMapFunc func(gtk.Widget) | |
| 98 | + | viewportShowFunc func(gtk.Widget) | |
| 99 | + | viewportRealizeFunc func(gtk.Widget) | |
| 92 | 100 | ||
| 93 | 101 | // beginFrameTick drives CEF external BeginFrame requests while the GTK | |
| 94 | 102 | // widget is visible. Access is guarded by mu. | |
| @@ -541,12 +549,7 @@ func (wv *WebView) Show() { | |||
| 541 | 549 | if wv == nil || wv.destroyed.Load() { | |
| 542 | 550 | return | |
| 543 | 551 | } | |
| 544 | - | wv.mu.RLock() | |
| 545 | - | host := wv.host | |
| 546 | - | wv.mu.RUnlock() | |
| 547 | - | if host != nil { | |
| 548 | - | host.WasHidden(0) | |
| 549 | - | } | |
| 552 | + | wv.SyncViewport(wv.ctx, "popup-show") | |
| 550 | 553 | } | |
| 551 | 554 | ||
| 552 | 555 | // PrimePopupNavigation implements port.PopupLifecycleCapable. | |
| @@ -1081,6 +1084,9 @@ func (wv *WebView) destroyViewBridgeOnGTKThread() { | |||
| 1081 | 1084 | wv.removeSizeObserver() | |
| 1082 | 1085 | wv.removeSizeObserver = nil | |
| 1083 | 1086 | } | |
| 1087 | + | wv.viewportMapFunc = nil | |
| 1088 | + | wv.viewportShowFunc = nil | |
| 1089 | + | wv.viewportRealizeFunc = nil | |
| 1084 | 1090 | _ = wv.viewBridge.DetachInput() | |
| 1085 | 1091 | _ = wv.viewBridge.Destroy() | |
| 1086 | 1092 | wv.viewBridge = nil | |
| @@ -29,6 +29,7 @@ var ( | |||||
| 29 | _ port.DevToolsOpener = (*WebView)(nil) | 29 | _ port.DevToolsOpener = (*WebView)(nil) | ||
| 30 | _ port.PopupLifecycleCapable = (*WebView)(nil) | 30 | _ port.PopupLifecycleCapable = (*WebView)(nil) | ||
| 31 | _ port.PopupOpenerCapable = (*WebView)(nil) | 31 | _ port.PopupOpenerCapable = (*WebView)(nil) | ||
| 32 | + | _ port.ViewportSyncCapable = (*WebView)(nil) | |||
| 32 | _ port.OAuthCallbackCapable = (*WebView)(nil) | 33 | _ port.OAuthCallbackCapable = (*WebView)(nil) | ||
| 33 | ) | 34 | ) | ||
| 34 | 35 | ||||
| @@ -72,6 +73,7 @@ type WebView struct { | |||||
| 72 | id port.WebViewID | 73 | id port.WebViewID | ||
| 73 | ctx context.Context | 74 | ctx context.Context | ||
| 74 | engine *Engine | 75 | engine *Engine | ||
| 76 | + | factory *WebViewFactory | |||
| 75 | browser purecef.Browser | 77 | browser purecef.Browser | ||
| 76 | host purecef.BrowserHost | 78 | host purecef.BrowserHost | ||
| 77 | client purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it | 79 | client purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it | ||
| @@ -88,7 +90,13 @@ type WebView struct { | |||||
| 88 | previousProfileSnapshot cef2gtk.ProfileSnapshot | 90 | previousProfileSnapshot cef2gtk.ProfileSnapshot | ||
| 89 | latestProfileSnapshotAt time.Time | 91 | latestProfileSnapshotAt time.Time | ||
| 90 | 92 | ||||
| 91 | - | removeSizeObserver func() | 93 | + | removeSizeObserver func() |
| 94 | + | viewportSyncMu sync.Mutex | |||
| 95 | + | viewportSyncPending bool | |||
| 96 | + | viewportSyncReason string | |||
| 97 | + | viewportMapFunc func(gtk.Widget) | |||
| 98 | + | viewportShowFunc func(gtk.Widget) | |||
| 99 | + | viewportRealizeFunc func(gtk.Widget) | |||
| 92 | 100 | ||||
| 93 | // beginFrameTick drives CEF external BeginFrame requests while the GTK | 101 | // beginFrameTick drives CEF external BeginFrame requests while the GTK | ||
| 94 | // widget is visible. Access is guarded by mu. | 102 | // widget is visible. Access is guarded by mu. | ||
| @@ -541,12 +549,7 @@ func (wv *WebView) Show() { | |||||
| 541 | if wv == nil || wv.destroyed.Load() { | 549 | if wv == nil || wv.destroyed.Load() { | ||
| 542 | return | 550 | return | ||
| 543 | } | 551 | } | ||
| 544 | - | wv.mu.RLock() | 552 | + | wv.SyncViewport(wv.ctx, "popup-show") |
| 545 | - | host := wv.host | |||
| 546 | - | wv.mu.RUnlock() | |||
| 547 | - | if host != nil { | |||
| 548 | - | host.WasHidden(0) | |||
| 549 | - | } | |||
| 550 | } | 553 | } | ||
| 551 | 554 | ||||
| 552 | // PrimePopupNavigation implements port.PopupLifecycleCapable. | 555 | // PrimePopupNavigation implements port.PopupLifecycleCapable. | ||
| @@ -1081,6 +1084,9 @@ func (wv *WebView) destroyViewBridgeOnGTKThread() { | |||||
| 1081 | wv.removeSizeObserver() | 1084 | wv.removeSizeObserver() | ||
| 1082 | wv.removeSizeObserver = nil | 1085 | wv.removeSizeObserver = nil | ||
| 1083 | } | 1086 | } | ||
| 1087 | + | wv.viewportMapFunc = nil | |||
| 1088 | + | wv.viewportShowFunc = nil | |||
| 1089 | + | wv.viewportRealizeFunc = nil | |||
| 1084 | _ = wv.viewBridge.DetachInput() | 1090 | _ = wv.viewBridge.DetachInput() | ||
| 1085 | _ = wv.viewBridge.Destroy() | 1091 | _ = wv.viewBridge.Destroy() | ||
| 1086 | wv.viewBridge = nil | 1092 | wv.viewBridge = nil | ||
A internal/infrastructure/cef/webview_viewport.go +196 -0
| @@ -0,0 +1,196 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "strings" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/logging" | |
| 8 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | type viewportSyncBrowserHost interface { | |
| 12 | + | resizeNotifiableBrowserHost | |
| 13 | + | NotifyScreenInfoChanged() | |
| 14 | + | WasHidden(int32) | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // SyncViewport requests an explicit viewport resync for CEF OSR browsers. | |
| 18 | + | // | |
| 19 | + | // This is a hardening path for UI lifecycle transitions that may not emit a | |
| 20 | + | // fresh size change (reparenting, stacked visibility toggles, floating pane | |
| 21 | + | // updates, popup show, sibling promotion after close, etc.). Calls are | |
| 22 | + | // coalesced and executed on the GTK thread before notifying the CEF host. | |
| 23 | + | func (wv *WebView) SyncViewport(ctx context.Context, reason string) { | |
| 24 | + | if wv == nil || wv.destroyed.Load() { | |
| 25 | + | return | |
| 26 | + | } | |
| 27 | + | reason = strings.TrimSpace(reason) | |
| 28 | + | if reason == "" { | |
| 29 | + | reason = "manual" | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | wv.viewportSyncMu.Lock() | |
| 33 | + | wv.viewportSyncReason = reason | |
| 34 | + | if wv.viewportSyncPending { | |
| 35 | + | wv.viewportSyncMu.Unlock() | |
| 36 | + | return | |
| 37 | + | } | |
| 38 | + | wv.viewportSyncPending = true | |
| 39 | + | wv.viewportSyncMu.Unlock() | |
| 40 | + | ||
| 41 | + | wv.runOnGTK(func() { | |
| 42 | + | wv.syncViewportOnGTK(wv.viewportSyncContext(ctx)) | |
| 43 | + | }) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (wv *WebView) viewportSyncContext(ctx context.Context) context.Context { | |
| 47 | + | if ctx != nil { | |
| 48 | + | return ctx | |
| 49 | + | } | |
| 50 | + | if wv != nil && wv.ctx != nil { | |
| 51 | + | return wv.ctx | |
| 52 | + | } | |
| 53 | + | return context.Background() | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func (wv *WebView) takeViewportSyncReason() string { | |
| 57 | + | wv.viewportSyncMu.Lock() | |
| 58 | + | defer wv.viewportSyncMu.Unlock() | |
| 59 | + | reason := wv.viewportSyncReason | |
| 60 | + | wv.viewportSyncReason = "" | |
| 61 | + | wv.viewportSyncPending = false | |
| 62 | + | return reason | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func (wv *WebView) syncViewportOnGTK(ctx context.Context) { | |
| 66 | + | if wv == nil { | |
| 67 | + | return | |
| 68 | + | } | |
| 69 | + | reason := wv.takeViewportSyncReason() | |
| 70 | + | if wv.destroyed.Load() || wv.viewBridge == nil { | |
| 71 | + | return | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | if wv.tryStartPendingBrowserCreateOnGTKThread(ctx, reason) { | |
| 75 | + | return | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | wv.mu.RLock() | |
| 79 | + | host := wv.host | |
| 80 | + | wv.mu.RUnlock() | |
| 81 | + | if host == nil { | |
| 82 | + | logging.FromContext(ctx).Debug(). | |
| 83 | + | Uint64("webview_id", uint64(wv.id)). | |
| 84 | + | Str("reason", reason). | |
| 85 | + | Msg("cef: viewport sync skipped; browser host not ready") | |
| 86 | + | return | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | widget := wv.viewBridge.Widget() | |
| 90 | + | visible := widget != nil && widget.IsVisible() | |
| 91 | + | notifyBrowserViewportSync(host, visible) | |
| 92 | + | logging.FromContext(ctx).Debug(). | |
| 93 | + | Uint64("webview_id", uint64(wv.id)). | |
| 94 | + | Bool("visible", visible). | |
| 95 | + | Str("reason", reason). | |
| 96 | + | Msg("cef: viewport sync requested") | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context, reason string) bool { | |
| 100 | + | if wv == nil || wv.destroyed.Load() || wv.factory == nil || wv.viewBridge == nil { | |
| 101 | + | return false | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | wv.mu.RLock() | |
| 105 | + | hasHost := wv.host != nil | |
| 106 | + | pending := wv.pendingCreate != nil | |
| 107 | + | wv.mu.RUnlock() | |
| 108 | + | if hasHost || !pending { | |
| 109 | + | return false | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | widget := wv.viewBridge.Widget() | |
| 113 | + | if widget == nil { | |
| 114 | + | return false | |
| 115 | + | } | |
| 116 | + | width := int32(widget.GetAllocatedWidth()) | |
| 117 | + | height := int32(widget.GetAllocatedHeight()) | |
| 118 | + | if width <= 0 { | |
| 119 | + | width = int32(widget.GetWidth()) | |
| 120 | + | } | |
| 121 | + | if height <= 0 { | |
| 122 | + | height = int32(widget.GetHeight()) | |
| 123 | + | } | |
| 124 | + | if width <= 0 || height <= 0 { | |
| 125 | + | logging.FromContext(ctx).Debug(). | |
| 126 | + | Uint64("webview_id", uint64(wv.id)). | |
| 127 | + | Int32("width", width). | |
| 128 | + | Int32("height", height). | |
| 129 | + | Str("reason", reason). | |
| 130 | + | Msg("cef: viewport sync skipped pending browser creation; widget has no allocation yet") | |
| 131 | + | return false | |
| 132 | + | } | |
| 133 | + | if err := wv.viewBridge.PrepareOnGTKThread(); err != nil { | |
| 134 | + | logging.FromContext(ctx).Debug(). | |
| 135 | + | Err(err). | |
| 136 | + | Uint64("webview_id", uint64(wv.id)). | |
| 137 | + | Int32("width", width). | |
| 138 | + | Int32("height", height). | |
| 139 | + | Str("reason", reason). | |
| 140 | + | Msg("cef: viewport sync could not prepare bridge for pending browser creation") | |
| 141 | + | return false | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | wv.factory.postPendingBrowserCreate(ctx, wv, width, height) | |
| 145 | + | logging.FromContext(ctx).Debug(). | |
| 146 | + | Uint64("webview_id", uint64(wv.id)). | |
| 147 | + | Int32("width", width). | |
| 148 | + | Int32("height", height). | |
| 149 | + | Str("reason", reason). | |
| 150 | + | Msg("cef: viewport sync nudged pending browser creation") | |
| 151 | + | return true | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func (wv *WebView) installViewportSyncHooks() { | |
| 155 | + | if wv == nil || wv.viewBridge == nil { | |
| 156 | + | return | |
| 157 | + | } | |
| 158 | + | wv.runOnGTKSync(func() { | |
| 159 | + | wv.installViewportSyncHooksOnGTKThread() | |
| 160 | + | }) | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func (wv *WebView) installViewportSyncHooksOnGTKThread() { | |
| 164 | + | if wv == nil || wv.viewBridge == nil || wv.viewportMapFunc != nil { | |
| 165 | + | return | |
| 166 | + | } | |
| 167 | + | widget := wv.viewBridge.Widget() | |
| 168 | + | if widget == nil { | |
| 169 | + | return | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | wv.viewportMapFunc = func(gtk.Widget) { | |
| 173 | + | wv.SyncViewport(wv.ctx, "gtk-map") | |
| 174 | + | } | |
| 175 | + | wv.viewportShowFunc = func(gtk.Widget) { | |
| 176 | + | wv.SyncViewport(wv.ctx, "gtk-show") | |
| 177 | + | } | |
| 178 | + | wv.viewportRealizeFunc = func(gtk.Widget) { | |
| 179 | + | wv.SyncViewport(wv.ctx, "gtk-realize") | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | widget.ConnectMap(&wv.viewportMapFunc) | |
| 183 | + | widget.ConnectShow(&wv.viewportShowFunc) | |
| 184 | + | widget.ConnectRealize(&wv.viewportRealizeFunc) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | func notifyBrowserViewportSync(host viewportSyncBrowserHost, visible bool) { | |
| 188 | + | if host == nil { | |
| 189 | + | return | |
| 190 | + | } | |
| 191 | + | if visible { | |
| 192 | + | host.WasHidden(0) | |
| 193 | + | } | |
| 194 | + | host.NotifyScreenInfoChanged() | |
| 195 | + | notifyBrowserResize(host) | |
| 196 | + | } | |
| @@ -0,0 +1,196 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "strings" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/logging" | |||
| 8 | + | "github.com/bnema/puregotk/v4/gtk" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | type viewportSyncBrowserHost interface { | |||
| 12 | + | resizeNotifiableBrowserHost | |||
| 13 | + | NotifyScreenInfoChanged() | |||
| 14 | + | WasHidden(int32) | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // SyncViewport requests an explicit viewport resync for CEF OSR browsers. | |||
| 18 | + | // | |||
| 19 | + | // This is a hardening path for UI lifecycle transitions that may not emit a | |||
| 20 | + | // fresh size change (reparenting, stacked visibility toggles, floating pane | |||
| 21 | + | // updates, popup show, sibling promotion after close, etc.). Calls are | |||
| 22 | + | // coalesced and executed on the GTK thread before notifying the CEF host. | |||
| 23 | + | func (wv *WebView) SyncViewport(ctx context.Context, reason string) { | |||
| 24 | + | if wv == nil || wv.destroyed.Load() { | |||
| 25 | + | return | |||
| 26 | + | } | |||
| 27 | + | reason = strings.TrimSpace(reason) | |||
| 28 | + | if reason == "" { | |||
| 29 | + | reason = "manual" | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | wv.viewportSyncMu.Lock() | |||
| 33 | + | wv.viewportSyncReason = reason | |||
| 34 | + | if wv.viewportSyncPending { | |||
| 35 | + | wv.viewportSyncMu.Unlock() | |||
| 36 | + | return | |||
| 37 | + | } | |||
| 38 | + | wv.viewportSyncPending = true | |||
| 39 | + | wv.viewportSyncMu.Unlock() | |||
| 40 | + | ||||
| 41 | + | wv.runOnGTK(func() { | |||
| 42 | + | wv.syncViewportOnGTK(wv.viewportSyncContext(ctx)) | |||
| 43 | + | }) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (wv *WebView) viewportSyncContext(ctx context.Context) context.Context { | |||
| 47 | + | if ctx != nil { | |||
| 48 | + | return ctx | |||
| 49 | + | } | |||
| 50 | + | if wv != nil && wv.ctx != nil { | |||
| 51 | + | return wv.ctx | |||
| 52 | + | } | |||
| 53 | + | return context.Background() | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func (wv *WebView) takeViewportSyncReason() string { | |||
| 57 | + | wv.viewportSyncMu.Lock() | |||
| 58 | + | defer wv.viewportSyncMu.Unlock() | |||
| 59 | + | reason := wv.viewportSyncReason | |||
| 60 | + | wv.viewportSyncReason = "" | |||
| 61 | + | wv.viewportSyncPending = false | |||
| 62 | + | return reason | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func (wv *WebView) syncViewportOnGTK(ctx context.Context) { | |||
| 66 | + | if wv == nil { | |||
| 67 | + | return | |||
| 68 | + | } | |||
| 69 | + | reason := wv.takeViewportSyncReason() | |||
| 70 | + | if wv.destroyed.Load() || wv.viewBridge == nil { | |||
| 71 | + | return | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | if wv.tryStartPendingBrowserCreateOnGTKThread(ctx, reason) { | |||
| 75 | + | return | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | wv.mu.RLock() | |||
| 79 | + | host := wv.host | |||
| 80 | + | wv.mu.RUnlock() | |||
| 81 | + | if host == nil { | |||
| 82 | + | logging.FromContext(ctx).Debug(). | |||
| 83 | + | Uint64("webview_id", uint64(wv.id)). | |||
| 84 | + | Str("reason", reason). | |||
| 85 | + | Msg("cef: viewport sync skipped; browser host not ready") | |||
| 86 | + | return | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | widget := wv.viewBridge.Widget() | |||
| 90 | + | visible := widget != nil && widget.IsVisible() | |||
| 91 | + | notifyBrowserViewportSync(host, visible) | |||
| 92 | + | logging.FromContext(ctx).Debug(). | |||
| 93 | + | Uint64("webview_id", uint64(wv.id)). | |||
| 94 | + | Bool("visible", visible). | |||
| 95 | + | Str("reason", reason). | |||
| 96 | + | Msg("cef: viewport sync requested") | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context, reason string) bool { | |||
| 100 | + | if wv == nil || wv.destroyed.Load() || wv.factory == nil || wv.viewBridge == nil { | |||
| 101 | + | return false | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | wv.mu.RLock() | |||
| 105 | + | hasHost := wv.host != nil | |||
| 106 | + | pending := wv.pendingCreate != nil | |||
| 107 | + | wv.mu.RUnlock() | |||
| 108 | + | if hasHost || !pending { | |||
| 109 | + | return false | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | widget := wv.viewBridge.Widget() | |||
| 113 | + | if widget == nil { | |||
| 114 | + | return false | |||
| 115 | + | } | |||
| 116 | + | width := int32(widget.GetAllocatedWidth()) | |||
| 117 | + | height := int32(widget.GetAllocatedHeight()) | |||
| 118 | + | if width <= 0 { | |||
| 119 | + | width = int32(widget.GetWidth()) | |||
| 120 | + | } | |||
| 121 | + | if height <= 0 { | |||
| 122 | + | height = int32(widget.GetHeight()) | |||
| 123 | + | } | |||
| 124 | + | if width <= 0 || height <= 0 { | |||
| 125 | + | logging.FromContext(ctx).Debug(). | |||
| 126 | + | Uint64("webview_id", uint64(wv.id)). | |||
| 127 | + | Int32("width", width). | |||
| 128 | + | Int32("height", height). | |||
| 129 | + | Str("reason", reason). | |||
| 130 | + | Msg("cef: viewport sync skipped pending browser creation; widget has no allocation yet") | |||
| 131 | + | return false | |||
| 132 | + | } | |||
| 133 | + | if err := wv.viewBridge.PrepareOnGTKThread(); err != nil { | |||
| 134 | + | logging.FromContext(ctx).Debug(). | |||
| 135 | + | Err(err). | |||
| 136 | + | Uint64("webview_id", uint64(wv.id)). | |||
| 137 | + | Int32("width", width). | |||
| 138 | + | Int32("height", height). | |||
| 139 | + | Str("reason", reason). | |||
| 140 | + | Msg("cef: viewport sync could not prepare bridge for pending browser creation") | |||
| 141 | + | return false | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | wv.factory.postPendingBrowserCreate(ctx, wv, width, height) | |||
| 145 | + | logging.FromContext(ctx).Debug(). | |||
| 146 | + | Uint64("webview_id", uint64(wv.id)). | |||
| 147 | + | Int32("width", width). | |||
| 148 | + | Int32("height", height). | |||
| 149 | + | Str("reason", reason). | |||
| 150 | + | Msg("cef: viewport sync nudged pending browser creation") | |||
| 151 | + | return true | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func (wv *WebView) installViewportSyncHooks() { | |||
| 155 | + | if wv == nil || wv.viewBridge == nil { | |||
| 156 | + | return | |||
| 157 | + | } | |||
| 158 | + | wv.runOnGTKSync(func() { | |||
| 159 | + | wv.installViewportSyncHooksOnGTKThread() | |||
| 160 | + | }) | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func (wv *WebView) installViewportSyncHooksOnGTKThread() { | |||
| 164 | + | if wv == nil || wv.viewBridge == nil || wv.viewportMapFunc != nil { | |||
| 165 | + | return | |||
| 166 | + | } | |||
| 167 | + | widget := wv.viewBridge.Widget() | |||
| 168 | + | if widget == nil { | |||
| 169 | + | return | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | wv.viewportMapFunc = func(gtk.Widget) { | |||
| 173 | + | wv.SyncViewport(wv.ctx, "gtk-map") | |||
| 174 | + | } | |||
| 175 | + | wv.viewportShowFunc = func(gtk.Widget) { | |||
| 176 | + | wv.SyncViewport(wv.ctx, "gtk-show") | |||
| 177 | + | } | |||
| 178 | + | wv.viewportRealizeFunc = func(gtk.Widget) { | |||
| 179 | + | wv.SyncViewport(wv.ctx, "gtk-realize") | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | widget.ConnectMap(&wv.viewportMapFunc) | |||
| 183 | + | widget.ConnectShow(&wv.viewportShowFunc) | |||
| 184 | + | widget.ConnectRealize(&wv.viewportRealizeFunc) | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | func notifyBrowserViewportSync(host viewportSyncBrowserHost, visible bool) { | |||
| 188 | + | if host == nil { | |||
| 189 | + | return | |||
| 190 | + | } | |||
| 191 | + | if visible { | |||
| 192 | + | host.WasHidden(0) | |||
| 193 | + | } | |||
| 194 | + | host.NotifyScreenInfoChanged() | |||
| 195 | + | notifyBrowserResize(host) | |||
| 196 | + | } | |||
A internal/infrastructure/cef/webview_viewport_test.go +47 -0
| @@ -0,0 +1,47 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | type viewportSyncOrderHost struct { | |
| 11 | + | calls []string | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | func (h *viewportSyncOrderHost) WasHidden(state int32) { | |
| 15 | + | h.calls = append(h.calls, "WasHidden") | |
| 16 | + | if state != 0 { | |
| 17 | + | h.calls = append(h.calls, "WasHidden(nonzero)") | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func (h *viewportSyncOrderHost) NotifyScreenInfoChanged() { | |
| 22 | + | h.calls = append(h.calls, "NotifyScreenInfoChanged") | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func (h *viewportSyncOrderHost) WasResized() { | |
| 26 | + | h.calls = append(h.calls, "WasResized") | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func (h *viewportSyncOrderHost) Invalidate(_ purecef.PaintElementType) { | |
| 30 | + | h.calls = append(h.calls, "Invalidate") | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestNotifyBrowserViewportSync_VisibleCallsFullSequence(t *testing.T) { | |
| 34 | + | host := &viewportSyncOrderHost{} | |
| 35 | + | ||
| 36 | + | notifyBrowserViewportSync(host, true) | |
| 37 | + | ||
| 38 | + | require.Equal(t, []string{"WasHidden", "NotifyScreenInfoChanged", "WasResized", "Invalidate"}, host.calls) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func TestNotifyBrowserViewportSync_HiddenSkipsWasHidden(t *testing.T) { | |
| 42 | + | host := &viewportSyncOrderHost{} | |
| 43 | + | ||
| 44 | + | notifyBrowserViewportSync(host, false) | |
| 45 | + | ||
| 46 | + | require.Equal(t, []string{"NotifyScreenInfoChanged", "WasResized", "Invalidate"}, host.calls) | |
| 47 | + | } | |
| @@ -0,0 +1,47 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | purecef "github.com/bnema/purego-cef/cef" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | type viewportSyncOrderHost struct { | |||
| 11 | + | calls []string | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | func (h *viewportSyncOrderHost) WasHidden(state int32) { | |||
| 15 | + | h.calls = append(h.calls, "WasHidden") | |||
| 16 | + | if state != 0 { | |||
| 17 | + | h.calls = append(h.calls, "WasHidden(nonzero)") | |||
| 18 | + | } | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func (h *viewportSyncOrderHost) NotifyScreenInfoChanged() { | |||
| 22 | + | h.calls = append(h.calls, "NotifyScreenInfoChanged") | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func (h *viewportSyncOrderHost) WasResized() { | |||
| 26 | + | h.calls = append(h.calls, "WasResized") | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func (h *viewportSyncOrderHost) Invalidate(_ purecef.PaintElementType) { | |||
| 30 | + | h.calls = append(h.calls, "Invalidate") | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestNotifyBrowserViewportSync_VisibleCallsFullSequence(t *testing.T) { | |||
| 34 | + | host := &viewportSyncOrderHost{} | |||
| 35 | + | ||||
| 36 | + | notifyBrowserViewportSync(host, true) | |||
| 37 | + | ||||
| 38 | + | require.Equal(t, []string{"WasHidden", "NotifyScreenInfoChanged", "WasResized", "Invalidate"}, host.calls) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestNotifyBrowserViewportSync_HiddenSkipsWasHidden(t *testing.T) { | |||
| 42 | + | host := &viewportSyncOrderHost{} | |||
| 43 | + | ||||
| 44 | + | notifyBrowserViewportSync(host, false) | |||
| 45 | + | ||||
| 46 | + | require.Equal(t, []string{"NotifyScreenInfoChanged", "WasResized", "Invalidate"}, host.calls) | |||
| 47 | + | } | |||
M internal/ui/app.go +21 -5
| @@ -3686,6 +3686,18 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity. | |||
| 3686 | 3686 | return | |
| 3687 | 3687 | } | |
| 3688 | 3688 | a.installFloatingOverlayPositioning(tab.ID, wsView.WorkspaceOverlayWidget()) | |
| 3689 | + | if a.contentCoord != nil { | |
| 3690 | + | syncCtx := ctx | |
| 3691 | + | if a.deps != nil && a.deps.Ctx != nil { | |
| 3692 | + | syncCtx = a.deps.Ctx | |
| 3693 | + | } | |
| 3694 | + | wsView.SetOnWebViewAttached(func(paneID entity.PaneID) { | |
| 3695 | + | a.contentCoord.SyncWebViewViewport(syncCtx, paneID, "workspace-widget-attached") | |
| 3696 | + | }) | |
| 3697 | + | wsView.SetOnActivePaneChanged(func(paneID entity.PaneID) { | |
| 3698 | + | a.contentCoord.SyncWebViewViewport(syncCtx, paneID, "workspace-pane-activated") | |
| 3699 | + | }) | |
| 3700 | + | } | |
| 3689 | 3701 | ||
| 3690 | 3702 | // Set the workspace | |
| 3691 | 3703 | if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil { | |
| @@ -3807,11 +3819,8 @@ func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *en | |||
| 3807 | 3819 | // Wrap and attach widget | |
| 3808 | 3820 | widget := a.contentCoord.WrapWidget(ctx, wv) | |
| 3809 | 3821 | if widget != nil { | |
| 3810 | - | paneView := wsView.GetPaneView(pane.ID) | |
| 3811 | - | if paneView != nil { | |
| 3812 | - | paneView.SetWebViewWidget(widget) | |
| 3813 | - | } else { | |
| 3814 | - | log.Warn().Str("pane_id", string(pane.ID)).Msg("pane view not found for popup") | |
| 3822 | + | if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil { | |
| 3823 | + | log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("pane view not found for popup") | |
| 3815 | 3824 | } | |
| 3816 | 3825 | } | |
| 3817 | 3826 | } | |
| @@ -4393,6 +4402,13 @@ func (a *App) resizeFloatingWidget(session *floatingWorkspaceSession) { | |||
| 4393 | 4402 | session.widget.SetSizeRequest(width, height) | |
| 4394 | 4403 | session.appliedWidth = width | |
| 4395 | 4404 | session.appliedHeight = height | |
| 4405 | + | if a.contentCoord != nil { | |
| 4406 | + | syncCtx := context.Background() | |
| 4407 | + | if a.deps != nil && a.deps.Ctx != nil { | |
| 4408 | + | syncCtx = a.deps.Ctx | |
| 4409 | + | } | |
| 4410 | + | a.contentCoord.SyncWebViewViewport(syncCtx, session.paneID, "floating-resize") | |
| 4411 | + | } | |
| 4396 | 4412 | } | |
| 4397 | 4413 | ||
| 4398 | 4414 | func (a *App) handleFloatingViewportTick(session *floatingWorkspaceSession) bool { | |
| @@ -3686,6 +3686,18 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity. | |||||
| 3686 | return | 3686 | return | ||
| 3687 | } | 3687 | } | ||
| 3688 | a.installFloatingOverlayPositioning(tab.ID, wsView.WorkspaceOverlayWidget()) | 3688 | a.installFloatingOverlayPositioning(tab.ID, wsView.WorkspaceOverlayWidget()) | ||
| 3689 | + | if a.contentCoord != nil { | |||
| 3690 | + | syncCtx := ctx | |||
| 3691 | + | if a.deps != nil && a.deps.Ctx != nil { | |||
| 3692 | + | syncCtx = a.deps.Ctx | |||
| 3693 | + | } | |||
| 3694 | + | wsView.SetOnWebViewAttached(func(paneID entity.PaneID) { | |||
| 3695 | + | a.contentCoord.SyncWebViewViewport(syncCtx, paneID, "workspace-widget-attached") | |||
| 3696 | + | }) | |||
| 3697 | + | wsView.SetOnActivePaneChanged(func(paneID entity.PaneID) { | |||
| 3698 | + | a.contentCoord.SyncWebViewViewport(syncCtx, paneID, "workspace-pane-activated") | |||
| 3699 | + | }) | |||
| 3700 | + | } | |||
| 3689 | 3701 | ||||
| 3690 | // Set the workspace | 3702 | // Set the workspace | ||
| 3691 | if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil { | 3703 | if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil { | ||
| @@ -3807,11 +3819,8 @@ func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *en | |||||
| 3807 | // Wrap and attach widget | 3819 | // Wrap and attach widget | ||
| 3808 | widget := a.contentCoord.WrapWidget(ctx, wv) | 3820 | widget := a.contentCoord.WrapWidget(ctx, wv) | ||
| 3809 | if widget != nil { | 3821 | if widget != nil { | ||
| 3810 | - | paneView := wsView.GetPaneView(pane.ID) | 3822 | + | if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil { |
| 3811 | - | if paneView != nil { | 3823 | + | log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("pane view not found for popup") |
| 3812 | - | paneView.SetWebViewWidget(widget) | |||
| 3813 | - | } else { | |||
| 3814 | - | log.Warn().Str("pane_id", string(pane.ID)).Msg("pane view not found for popup") | |||
| 3815 | } | 3824 | } | ||
| 3816 | } | 3825 | } | ||
| 3817 | } | 3826 | } | ||
| @@ -4393,6 +4402,13 @@ func (a *App) resizeFloatingWidget(session *floatingWorkspaceSession) { | |||||
| 4393 | session.widget.SetSizeRequest(width, height) | 4402 | session.widget.SetSizeRequest(width, height) | ||
| 4394 | session.appliedWidth = width | 4403 | session.appliedWidth = width | ||
| 4395 | session.appliedHeight = height | 4404 | session.appliedHeight = height | ||
| 4405 | + | if a.contentCoord != nil { | |||
| 4406 | + | syncCtx := context.Background() | |||
| 4407 | + | if a.deps != nil && a.deps.Ctx != nil { | |||
| 4408 | + | syncCtx = a.deps.Ctx | |||
| 4409 | + | } | |||
| 4410 | + | a.contentCoord.SyncWebViewViewport(syncCtx, session.paneID, "floating-resize") | |||
| 4411 | + | } | |||
| 4396 | } | 4412 | } | ||
| 4397 | 4413 | ||||
| 4398 | func (a *App) handleFloatingViewportTick(session *floatingWorkspaceSession) bool { | 4414 | func (a *App) handleFloatingViewportTick(session *floatingWorkspaceSession) bool { | ||
M internal/ui/component/workspace_view.go +33 -3
| @@ -51,6 +51,8 @@ type WorkspaceView struct { | |||
| 51 | 51 | paneViews map[entity.PaneID]*PaneView | |
| 52 | 52 | ||
| 53 | 53 | onPaneFocused func(paneID entity.PaneID) | |
| 54 | + | onActivePaneChanged func(paneID entity.PaneID) | |
| 55 | + | onWebViewAttached func(paneID entity.PaneID) | |
| 54 | 56 | onSplitRatioDragged func(nodeID string, ratio float64) | |
| 55 | 57 | ||
| 56 | 58 | // Hover suppression for keyboard navigation (Issue #89) | |
| @@ -231,9 +233,17 @@ func (wv *WorkspaceView) SetWorkspace(ctx context.Context, ws *entity.Workspace) | |||
| 231 | 233 | // SetActivePaneID updates which pane is visually marked as active. | |
| 232 | 234 | func (wv *WorkspaceView) SetActivePaneID(paneID entity.PaneID) error { | |
| 233 | 235 | wv.mu.Lock() | |
| 234 | - | defer wv.mu.Unlock() | |
| 235 | - | ||
| 236 | - | return wv.setActivePaneIDInternal(paneID) | |
| 236 | + | currentActiveID := wv.getActivePaneIDInternal() | |
| 237 | + | err := wv.setActivePaneIDInternal(paneID) | |
| 238 | + | callback := wv.onActivePaneChanged | |
| 239 | + | wv.mu.Unlock() | |
| 240 | + | if err != nil { | |
| 241 | + | return err | |
| 242 | + | } | |
| 243 | + | if currentActiveID != paneID && callback != nil { | |
| 244 | + | callback(paneID) | |
| 245 | + | } | |
| 246 | + | return nil | |
| 237 | 247 | } | |
| 238 | 248 | ||
| 239 | 249 | // setActivePaneIDInternal updates active pane without locking. | |
| @@ -357,6 +367,22 @@ func (wv *WorkspaceView) SetOnPaneFocused(fn func(paneID entity.PaneID)) { | |||
| 357 | 367 | wv.onPaneFocused = fn | |
| 358 | 368 | } | |
| 359 | 369 | ||
| 370 | + | // SetOnActivePaneChanged sets the callback invoked after the active pane changes. | |
| 371 | + | func (wv *WorkspaceView) SetOnActivePaneChanged(fn func(paneID entity.PaneID)) { | |
| 372 | + | wv.mu.Lock() | |
| 373 | + | defer wv.mu.Unlock() | |
| 374 | + | ||
| 375 | + | wv.onActivePaneChanged = fn | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | // SetOnWebViewAttached sets the callback invoked after a pane receives a WebView widget. | |
| 379 | + | func (wv *WorkspaceView) SetOnWebViewAttached(fn func(paneID entity.PaneID)) { | |
| 380 | + | wv.mu.Lock() | |
| 381 | + | defer wv.mu.Unlock() | |
| 382 | + | ||
| 383 | + | wv.onWebViewAttached = fn | |
| 384 | + | } | |
| 385 | + | ||
| 360 | 386 | func (wv *WorkspaceView) SetOnSplitRatioDragged(fn func(nodeID string, ratio float64)) { | |
| 361 | 387 | wv.mu.Lock() | |
| 362 | 388 | defer wv.mu.Unlock() | |
| @@ -408,6 +434,7 @@ func (wv *WorkspaceView) DeactivatePane(paneID entity.PaneID) { | |||
| 408 | 434 | func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Widget) error { | |
| 409 | 435 | wv.mu.RLock() | |
| 410 | 436 | pv, ok := wv.paneViews[paneID] | |
| 437 | + | callback := wv.onWebViewAttached | |
| 411 | 438 | wv.mu.RUnlock() | |
| 412 | 439 | ||
| 413 | 440 | if !ok { | |
| @@ -415,6 +442,9 @@ func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Wi | |||
| 415 | 442 | } | |
| 416 | 443 | ||
| 417 | 444 | pv.SetWebViewWidget(widget) | |
| 445 | + | if widget != nil && callback != nil { | |
| 446 | + | callback(paneID) | |
| 447 | + | } | |
| 418 | 448 | return nil | |
| 419 | 449 | } | |
| 420 | 450 | ||
| @@ -51,6 +51,8 @@ type WorkspaceView struct { | |||||
| 51 | paneViews map[entity.PaneID]*PaneView | 51 | paneViews map[entity.PaneID]*PaneView | ||
| 52 | 52 | ||||
| 53 | onPaneFocused func(paneID entity.PaneID) | 53 | onPaneFocused func(paneID entity.PaneID) | ||
| 54 | + | onActivePaneChanged func(paneID entity.PaneID) | |||
| 55 | + | onWebViewAttached func(paneID entity.PaneID) | |||
| 54 | onSplitRatioDragged func(nodeID string, ratio float64) | 56 | onSplitRatioDragged func(nodeID string, ratio float64) | ||
| 55 | 57 | ||||
| 56 | // Hover suppression for keyboard navigation (Issue #89) | 58 | // Hover suppression for keyboard navigation (Issue #89) | ||
| @@ -231,9 +233,17 @@ func (wv *WorkspaceView) SetWorkspace(ctx context.Context, ws *entity.Workspace) | |||||
| 231 | // SetActivePaneID updates which pane is visually marked as active. | 233 | // SetActivePaneID updates which pane is visually marked as active. | ||
| 232 | func (wv *WorkspaceView) SetActivePaneID(paneID entity.PaneID) error { | 234 | func (wv *WorkspaceView) SetActivePaneID(paneID entity.PaneID) error { | ||
| 233 | wv.mu.Lock() | 235 | wv.mu.Lock() | ||
| 234 | - | defer wv.mu.Unlock() | 236 | + | currentActiveID := wv.getActivePaneIDInternal() |
| 235 | - | 237 | + | err := wv.setActivePaneIDInternal(paneID) | |
| 236 | - | return wv.setActivePaneIDInternal(paneID) | 238 | + | callback := wv.onActivePaneChanged |
| 239 | + | wv.mu.Unlock() | |||
| 240 | + | if err != nil { | |||
| 241 | + | return err | |||
| 242 | + | } | |||
| 243 | + | if currentActiveID != paneID && callback != nil { | |||
| 244 | + | callback(paneID) | |||
| 245 | + | } | |||
| 246 | + | return nil | |||
| 237 | } | 247 | } | ||
| 238 | 248 | ||||
| 239 | // setActivePaneIDInternal updates active pane without locking. | 249 | // setActivePaneIDInternal updates active pane without locking. | ||
| @@ -357,6 +367,22 @@ func (wv *WorkspaceView) SetOnPaneFocused(fn func(paneID entity.PaneID)) { | |||||
| 357 | wv.onPaneFocused = fn | 367 | wv.onPaneFocused = fn | ||
| 358 | } | 368 | } | ||
| 359 | 369 | ||||
| 370 | + | // SetOnActivePaneChanged sets the callback invoked after the active pane changes. | |||
| 371 | + | func (wv *WorkspaceView) SetOnActivePaneChanged(fn func(paneID entity.PaneID)) { | |||
| 372 | + | wv.mu.Lock() | |||
| 373 | + | defer wv.mu.Unlock() | |||
| 374 | + | ||||
| 375 | + | wv.onActivePaneChanged = fn | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | // SetOnWebViewAttached sets the callback invoked after a pane receives a WebView widget. | |||
| 379 | + | func (wv *WorkspaceView) SetOnWebViewAttached(fn func(paneID entity.PaneID)) { | |||
| 380 | + | wv.mu.Lock() | |||
| 381 | + | defer wv.mu.Unlock() | |||
| 382 | + | ||||
| 383 | + | wv.onWebViewAttached = fn | |||
| 384 | + | } | |||
| 385 | + | ||||
| 360 | func (wv *WorkspaceView) SetOnSplitRatioDragged(fn func(nodeID string, ratio float64)) { | 386 | func (wv *WorkspaceView) SetOnSplitRatioDragged(fn func(nodeID string, ratio float64)) { | ||
| 361 | wv.mu.Lock() | 387 | wv.mu.Lock() | ||
| 362 | defer wv.mu.Unlock() | 388 | defer wv.mu.Unlock() | ||
| @@ -408,6 +434,7 @@ func (wv *WorkspaceView) DeactivatePane(paneID entity.PaneID) { | |||||
| 408 | func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Widget) error { | 434 | func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Widget) error { | ||
| 409 | wv.mu.RLock() | 435 | wv.mu.RLock() | ||
| 410 | pv, ok := wv.paneViews[paneID] | 436 | pv, ok := wv.paneViews[paneID] | ||
| 437 | + | callback := wv.onWebViewAttached | |||
| 411 | wv.mu.RUnlock() | 438 | wv.mu.RUnlock() | ||
| 412 | 439 | ||||
| 413 | if !ok { | 440 | if !ok { | ||
| @@ -415,6 +442,9 @@ func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Wi | |||||
| 415 | } | 442 | } | ||
| 416 | 443 | ||||
| 417 | pv.SetWebViewWidget(widget) | 444 | pv.SetWebViewWidget(widget) | ||
| 445 | + | if widget != nil && callback != nil { | |||
| 446 | + | callback(paneID) | |||
| 447 | + | } | |||
| 418 | return nil | 448 | return nil | ||
| 419 | } | 449 | } | ||
| 420 | 450 | ||||
M internal/ui/component/workspace_view_test.go +36 -1
| @@ -128,7 +128,7 @@ func setupWorkspacePaneViewMocks(t *testing.T, mockFactory *mocks.MockWidgetFact | |||
| 128 | 128 | mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once() | |
| 129 | 129 | ||
| 130 | 130 | // GtkWidget is called when attaching hover handler - return nil for tests | |
| 131 | - | mockOverlay.EXPECT().GtkWidget().Return(nil).Once() | |
| 131 | + | mockOverlay.EXPECT().GtkWidget().Return(nil).Maybe() | |
| 132 | 132 | ||
| 133 | 133 | return mockOverlay, mockBorderBox | |
| 134 | 134 | } | |
| @@ -415,6 +415,10 @@ func TestSetActivePaneID_UpdatesStyling(t *testing.T) { | |||
| 415 | 415 | // Now change active pane | |
| 416 | 416 | mockBorderBox1.EXPECT().RemoveCssClass("pane-active").Once() | |
| 417 | 417 | mockBorderBox2.EXPECT().AddCssClass("pane-active").Once() | |
| 418 | + | activatedPane := entity.PaneID("") | |
| 419 | + | wv.SetOnActivePaneChanged(func(paneID entity.PaneID) { | |
| 420 | + | activatedPane = paneID | |
| 421 | + | }) | |
| 418 | 422 | ||
| 419 | 423 | // Act | |
| 420 | 424 | err = wv.SetActivePaneID(pane2.ID) | |
| @@ -422,6 +426,7 @@ func TestSetActivePaneID_UpdatesStyling(t *testing.T) { | |||
| 422 | 426 | // Assert | |
| 423 | 427 | require.NoError(t, err) | |
| 424 | 428 | assert.Equal(t, pane2.ID, wv.GetActivePaneID()) | |
| 429 | + | assert.Equal(t, pane2.ID, activatedPane) | |
| 425 | 430 | } | |
| 426 | 431 | ||
| 427 | 432 | func TestSetActivePaneID_InvalidID_ReturnsError(t *testing.T) { | |
| @@ -437,3 +442,33 @@ func TestSetActivePaneID_InvalidID_ReturnsError(t *testing.T) { | |||
| 437 | 442 | // Assert | |
| 438 | 443 | assert.ErrorIs(t, err, component.ErrPaneNotFound) | |
| 439 | 444 | } | |
| 445 | + | ||
| 446 | + | func TestSetWebViewWidget_InvokesAttachedCallback(t *testing.T) { | |
| 447 | + | mockFactory := mocks.NewMockWidgetFactory(t) | |
| 448 | + | ctx, mockBox := setupWorkspaceViewBase(t, mockFactory) | |
| 449 | + | ||
| 450 | + | wv := component.NewWorkspaceView(ctx, mockFactory) | |
| 451 | + | mockOverlay, _ := setupWorkspacePaneViewMocks(t, mockFactory) | |
| 452 | + | pv := component.NewPaneView(ctx, mockFactory, entity.PaneID("pane-1"), nil) | |
| 453 | + | mockBox.EXPECT().AddCssClass("single-pane").Once() | |
| 454 | + | wv.RegisterPaneView(entity.PaneID("pane-1"), pv) | |
| 455 | + | ||
| 456 | + | attachedPane := entity.PaneID("") | |
| 457 | + | wv.SetOnWebViewAttached(func(paneID entity.PaneID) { | |
| 458 | + | attachedPane = paneID | |
| 459 | + | }) | |
| 460 | + | ||
| 461 | + | mockWebViewWidget := mocks.NewMockWidget(t) | |
| 462 | + | mockOverlay.EXPECT().GetAllocatedWidth().Return(0).Twice() | |
| 463 | + | mockOverlay.EXPECT().GetAllocatedHeight().Return(0).Twice() | |
| 464 | + | mockWebViewWidget.EXPECT().GetParent().Return(nil).Once() | |
| 465 | + | mockWebViewWidget.EXPECT().IsVisible().Return(false).Once() | |
| 466 | + | mockWebViewWidget.EXPECT().GetAllocatedWidth().Return(0).Twice() | |
| 467 | + | mockWebViewWidget.EXPECT().GetAllocatedHeight().Return(0).Twice() | |
| 468 | + | mockOverlay.EXPECT().SetChild(mockWebViewWidget).Once() | |
| 469 | + | ||
| 470 | + | err := wv.SetWebViewWidget(entity.PaneID("pane-1"), mockWebViewWidget) | |
| 471 | + | ||
| 472 | + | require.NoError(t, err) | |
| 473 | + | assert.Equal(t, entity.PaneID("pane-1"), attachedPane) | |
| 474 | + | } | |
| @@ -128,7 +128,7 @@ func setupWorkspacePaneViewMocks(t *testing.T, mockFactory *mocks.MockWidgetFact | |||||
| 128 | mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once() | 128 | mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once() | ||
| 129 | 129 | ||||
| 130 | // GtkWidget is called when attaching hover handler - return nil for tests | 130 | // GtkWidget is called when attaching hover handler - return nil for tests | ||
| 131 | - | mockOverlay.EXPECT().GtkWidget().Return(nil).Once() | 131 | + | mockOverlay.EXPECT().GtkWidget().Return(nil).Maybe() |
| 132 | 132 | ||||
| 133 | return mockOverlay, mockBorderBox | 133 | return mockOverlay, mockBorderBox | ||
| 134 | } | 134 | } | ||
| @@ -415,6 +415,10 @@ func TestSetActivePaneID_UpdatesStyling(t *testing.T) { | |||||
| 415 | // Now change active pane | 415 | // Now change active pane | ||
| 416 | mockBorderBox1.EXPECT().RemoveCssClass("pane-active").Once() | 416 | mockBorderBox1.EXPECT().RemoveCssClass("pane-active").Once() | ||
| 417 | mockBorderBox2.EXPECT().AddCssClass("pane-active").Once() | 417 | mockBorderBox2.EXPECT().AddCssClass("pane-active").Once() | ||
| 418 | + | activatedPane := entity.PaneID("") | |||
| 419 | + | wv.SetOnActivePaneChanged(func(paneID entity.PaneID) { | |||
| 420 | + | activatedPane = paneID | |||
| 421 | + | }) | |||
| 418 | 422 | ||||
| 419 | // Act | 423 | // Act | ||
| 420 | err = wv.SetActivePaneID(pane2.ID) | 424 | err = wv.SetActivePaneID(pane2.ID) | ||
| @@ -422,6 +426,7 @@ func TestSetActivePaneID_UpdatesStyling(t *testing.T) { | |||||
| 422 | // Assert | 426 | // Assert | ||
| 423 | require.NoError(t, err) | 427 | require.NoError(t, err) | ||
| 424 | assert.Equal(t, pane2.ID, wv.GetActivePaneID()) | 428 | assert.Equal(t, pane2.ID, wv.GetActivePaneID()) | ||
| 429 | + | assert.Equal(t, pane2.ID, activatedPane) | |||
| 425 | } | 430 | } | ||
| 426 | 431 | ||||
| 427 | func TestSetActivePaneID_InvalidID_ReturnsError(t *testing.T) { | 432 | func TestSetActivePaneID_InvalidID_ReturnsError(t *testing.T) { | ||
| @@ -437,3 +442,33 @@ func TestSetActivePaneID_InvalidID_ReturnsError(t *testing.T) { | |||||
| 437 | // Assert | 442 | // Assert | ||
| 438 | assert.ErrorIs(t, err, component.ErrPaneNotFound) | 443 | assert.ErrorIs(t, err, component.ErrPaneNotFound) | ||
| 439 | } | 444 | } | ||
| 445 | + | ||||
| 446 | + | func TestSetWebViewWidget_InvokesAttachedCallback(t *testing.T) { | |||
| 447 | + | mockFactory := mocks.NewMockWidgetFactory(t) | |||
| 448 | + | ctx, mockBox := setupWorkspaceViewBase(t, mockFactory) | |||
| 449 | + | ||||
| 450 | + | wv := component.NewWorkspaceView(ctx, mockFactory) | |||
| 451 | + | mockOverlay, _ := setupWorkspacePaneViewMocks(t, mockFactory) | |||
| 452 | + | pv := component.NewPaneView(ctx, mockFactory, entity.PaneID("pane-1"), nil) | |||
| 453 | + | mockBox.EXPECT().AddCssClass("single-pane").Once() | |||
| 454 | + | wv.RegisterPaneView(entity.PaneID("pane-1"), pv) | |||
| 455 | + | ||||
| 456 | + | attachedPane := entity.PaneID("") | |||
| 457 | + | wv.SetOnWebViewAttached(func(paneID entity.PaneID) { | |||
| 458 | + | attachedPane = paneID | |||
| 459 | + | }) | |||
| 460 | + | ||||
| 461 | + | mockWebViewWidget := mocks.NewMockWidget(t) | |||
| 462 | + | mockOverlay.EXPECT().GetAllocatedWidth().Return(0).Twice() | |||
| 463 | + | mockOverlay.EXPECT().GetAllocatedHeight().Return(0).Twice() | |||
| 464 | + | mockWebViewWidget.EXPECT().GetParent().Return(nil).Once() | |||
| 465 | + | mockWebViewWidget.EXPECT().IsVisible().Return(false).Once() | |||
| 466 | + | mockWebViewWidget.EXPECT().GetAllocatedWidth().Return(0).Twice() | |||
| 467 | + | mockWebViewWidget.EXPECT().GetAllocatedHeight().Return(0).Twice() | |||
| 468 | + | mockOverlay.EXPECT().SetChild(mockWebViewWidget).Once() | |||
| 469 | + | ||||
| 470 | + | err := wv.SetWebViewWidget(entity.PaneID("pane-1"), mockWebViewWidget) | |||
| 471 | + | ||||
| 472 | + | require.NoError(t, err) | |||
| 473 | + | assert.Equal(t, entity.PaneID("pane-1"), attachedPane) | |||
| 474 | + | } | |||
M internal/ui/coordinator/content/lifecycle.go +19 -0
| @@ -137,6 +137,7 @@ func (c *Coordinator) AttachToWorkspace(ctx context.Context, ws *entity.Workspac | |||
| 137 | 137 | ||
| 138 | 138 | if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil { | |
| 139 | 139 | log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget") | |
| 140 | + | continue | |
| 140 | 141 | } | |
| 141 | 142 | logging.Trace().Mark("webview_attached") | |
| 142 | 143 | } | |
| @@ -229,3 +230,21 @@ func (c *Coordinator) RegisterPopupWebView(paneID entity.PaneID, wv port.WebView | |||
| 229 | 230 | c.setWebViewLocked(paneID, wv) | |
| 230 | 231 | } | |
| 231 | 232 | } | |
| 233 | + | ||
| 234 | + | // SyncWebViewViewport requests an explicit viewport resync when the engine | |
| 235 | + | // supports it. This is useful after widget attachment, reparenting, visibility | |
| 236 | + | // changes, or layout promotion paths that may not emit a size change. | |
| 237 | + | func (c *Coordinator) SyncWebViewViewport(ctx context.Context, paneID entity.PaneID, reason string) { | |
| 238 | + | if c == nil || paneID == "" { | |
| 239 | + | return | |
| 240 | + | } | |
| 241 | + | wv := c.getWebViewLocked(paneID) | |
| 242 | + | if wv == nil { | |
| 243 | + | return | |
| 244 | + | } | |
| 245 | + | syncer, ok := wv.(port.ViewportSyncCapable) | |
| 246 | + | if !ok { | |
| 247 | + | return | |
| 248 | + | } | |
| 249 | + | syncer.SyncViewport(ctx, reason) | |
| 250 | + | } | |
| @@ -137,6 +137,7 @@ func (c *Coordinator) AttachToWorkspace(ctx context.Context, ws *entity.Workspac | |||||
| 137 | 137 | ||||
| 138 | if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil { | 138 | if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil { | ||
| 139 | log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget") | 139 | log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget") | ||
| 140 | + | continue | |||
| 140 | } | 141 | } | ||
| 141 | logging.Trace().Mark("webview_attached") | 142 | logging.Trace().Mark("webview_attached") | ||
| 142 | } | 143 | } | ||
| @@ -229,3 +230,21 @@ func (c *Coordinator) RegisterPopupWebView(paneID entity.PaneID, wv port.WebView | |||||
| 229 | c.setWebViewLocked(paneID, wv) | 230 | c.setWebViewLocked(paneID, wv) | ||
| 230 | } | 231 | } | ||
| 231 | } | 232 | } | ||
| 233 | + | ||||
| 234 | + | // SyncWebViewViewport requests an explicit viewport resync when the engine | |||
| 235 | + | // supports it. This is useful after widget attachment, reparenting, visibility | |||
| 236 | + | // changes, or layout promotion paths that may not emit a size change. | |||
| 237 | + | func (c *Coordinator) SyncWebViewViewport(ctx context.Context, paneID entity.PaneID, reason string) { | |||
| 238 | + | if c == nil || paneID == "" { | |||
| 239 | + | return | |||
| 240 | + | } | |||
| 241 | + | wv := c.getWebViewLocked(paneID) | |||
| 242 | + | if wv == nil { | |||
| 243 | + | return | |||
| 244 | + | } | |||
| 245 | + | syncer, ok := wv.(port.ViewportSyncCapable) | |||
| 246 | + | if !ok { | |||
| 247 | + | return | |||
| 248 | + | } | |||
| 249 | + | syncer.SyncViewport(ctx, reason) | |||
| 250 | + | } | |||
M internal/ui/coordinator/content/lifecycle_test.go +39 -0
| @@ -258,3 +258,42 @@ func TestLifecycle_RegisterPopupWebView_IgnoresNil(t *testing.T) { | |||
| 258 | 258 | ||
| 259 | 259 | assert.Nil(t, c.GetWebView("popup-1")) | |
| 260 | 260 | } | |
| 261 | + | ||
| 262 | + | type syncViewportCapableStub struct { | |
| 263 | + | port.WebView | |
| 264 | + | called bool | |
| 265 | + | ctx context.Context | |
| 266 | + | reason string | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | func (s *syncViewportCapableStub) SyncViewport(ctx context.Context, reason string) { | |
| 270 | + | s.called = true | |
| 271 | + | s.ctx = ctx | |
| 272 | + | s.reason = reason | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | func TestLifecycle_SyncWebViewViewport_DelegatesWhenCapabilityPresent(t *testing.T) { | |
| 276 | + | t.Parallel() | |
| 277 | + | ||
| 278 | + | base := mocks.NewMockWebView(t) | |
| 279 | + | wv := &syncViewportCapableStub{WebView: base} | |
| 280 | + | c := newMinimalCoordinator() | |
| 281 | + | c.webViews[entity.PaneID("pane-1")] = wv | |
| 282 | + | ||
| 283 | + | ctx := context.WithValue(context.Background(), struct{}{}, "marker") | |
| 284 | + | c.SyncWebViewViewport(ctx, "pane-1", "unit-test") | |
| 285 | + | ||
| 286 | + | require.True(t, wv.called) | |
| 287 | + | require.Equal(t, "unit-test", wv.reason) | |
| 288 | + | require.Same(t, ctx, wv.ctx) | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | func TestLifecycle_SyncWebViewViewport_NoopWithoutCapability(t *testing.T) { | |
| 292 | + | t.Parallel() | |
| 293 | + | ||
| 294 | + | wv := mocks.NewMockWebView(t) | |
| 295 | + | c := newMinimalCoordinator() | |
| 296 | + | c.webViews[entity.PaneID("pane-1")] = wv | |
| 297 | + | ||
| 298 | + | c.SyncWebViewViewport(context.Background(), "pane-1", "unit-test") | |
| 299 | + | } | |
| @@ -258,3 +258,42 @@ func TestLifecycle_RegisterPopupWebView_IgnoresNil(t *testing.T) { | |||||
| 258 | 258 | ||||
| 259 | assert.Nil(t, c.GetWebView("popup-1")) | 259 | assert.Nil(t, c.GetWebView("popup-1")) | ||
| 260 | } | 260 | } | ||
| 261 | + | ||||
| 262 | + | type syncViewportCapableStub struct { | |||
| 263 | + | port.WebView | |||
| 264 | + | called bool | |||
| 265 | + | ctx context.Context | |||
| 266 | + | reason string | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | func (s *syncViewportCapableStub) SyncViewport(ctx context.Context, reason string) { | |||
| 270 | + | s.called = true | |||
| 271 | + | s.ctx = ctx | |||
| 272 | + | s.reason = reason | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | func TestLifecycle_SyncWebViewViewport_DelegatesWhenCapabilityPresent(t *testing.T) { | |||
| 276 | + | t.Parallel() | |||
| 277 | + | ||||
| 278 | + | base := mocks.NewMockWebView(t) | |||
| 279 | + | wv := &syncViewportCapableStub{WebView: base} | |||
| 280 | + | c := newMinimalCoordinator() | |||
| 281 | + | c.webViews[entity.PaneID("pane-1")] = wv | |||
| 282 | + | ||||
| 283 | + | ctx := context.WithValue(context.Background(), struct{}{}, "marker") | |||
| 284 | + | c.SyncWebViewViewport(ctx, "pane-1", "unit-test") | |||
| 285 | + | ||||
| 286 | + | require.True(t, wv.called) | |||
| 287 | + | require.Equal(t, "unit-test", wv.reason) | |||
| 288 | + | require.Same(t, ctx, wv.ctx) | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | func TestLifecycle_SyncWebViewViewport_NoopWithoutCapability(t *testing.T) { | |||
| 292 | + | t.Parallel() | |||
| 293 | + | ||||
| 294 | + | wv := mocks.NewMockWebView(t) | |||
| 295 | + | c := newMinimalCoordinator() | |||
| 296 | + | c.webViews[entity.PaneID("pane-1")] = wv | |||
| 297 | + | ||||
| 298 | + | c.SyncWebViewViewport(context.Background(), "pane-1", "unit-test") | |||
| 299 | + | } | |||
M internal/ui/coordinator/workspace.go +14 -8
| @@ -605,7 +605,9 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit( | |||
| 605 | 605 | ||
| 606 | 606 | widget := c.contentCoord.WrapWidget(ctx, wv) | |
| 607 | 607 | if widget != nil { | |
| 608 | - | newPaneView.SetWebViewWidget(widget) | |
| 608 | + | if err := wsView.SetWebViewWidget(output.NewPaneNode.Pane.ID, widget); err != nil { | |
| 609 | + | log.Warn().Err(err).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to attach webview widget for stack split") | |
| 610 | + | } | |
| 609 | 611 | } | |
| 610 | 612 | ||
| 611 | 613 | log.Debug().Msg("incremental stack split completed successfully") | |
| @@ -743,7 +745,9 @@ func (c *WorkspaceCoordinator) doIncrementalSplit( | |||
| 743 | 745 | ||
| 744 | 746 | widget := c.contentCoord.WrapWidget(ctx, wv) | |
| 745 | 747 | if widget != nil { | |
| 746 | - | newPaneView.SetWebViewWidget(widget) | |
| 748 | + | if err := wsView.SetWebViewWidget(output.NewPaneNode.Pane.ID, widget); err != nil { | |
| 749 | + | log.Warn().Err(err).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to attach webview widget for split") | |
| 750 | + | } | |
| 747 | 751 | } | |
| 748 | 752 | ||
| 749 | 753 | log.Debug().Msg("incremental split completed successfully") | |
| @@ -1622,7 +1626,9 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error { | |||
| 1622 | 1626 | } else { | |
| 1623 | 1627 | widget := c.contentCoord.WrapWidget(ctx, wv) | |
| 1624 | 1628 | if widget != nil { | |
| 1625 | - | newPaneView.SetWebViewWidget(widget) | |
| 1629 | + | if err := stackCtx.wsView.SetWebViewWidget(newPaneID, widget); err != nil { | |
| 1630 | + | log.Warn().Err(err).Str("pane_id", string(newPaneID)).Msg("failed to attach webview widget for stacked pane") | |
| 1631 | + | } | |
| 1626 | 1632 | } | |
| 1627 | 1633 | // Load initial page for the new pane | |
| 1628 | 1634 | if err := wv.LoadURI(ctx, newPane.URI); err != nil { | |
| @@ -2089,11 +2095,9 @@ func (c *WorkspaceCoordinator) attachPopupWebView( | |||
| 2089 | 2095 | return | |
| 2090 | 2096 | } | |
| 2091 | 2097 | ||
| 2092 | - | paneView := wsView.GetPaneView(input.PopupPane.ID) | |
| 2093 | - | if paneView == nil { | |
| 2094 | - | return | |
| 2098 | + | if err := wsView.SetWebViewWidget(input.PopupPane.ID, widget); err != nil { | |
| 2099 | + | logging.FromContext(ctx).Warn().Err(err).Str("pane_id", string(input.PopupPane.ID)).Msg("failed to attach popup webview widget") | |
| 2095 | 2100 | } | |
| 2096 | - | paneView.SetWebViewWidget(widget) | |
| 2097 | 2101 | } | |
| 2098 | 2102 | ||
| 2099 | 2103 | // insertPopupStacked inserts a popup as a stacked pane on top of the parent. | |
| @@ -2259,7 +2263,9 @@ func (c *WorkspaceCoordinator) attachPopupPaneView( | |||
| 2259 | 2263 | if input.WebView != nil { | |
| 2260 | 2264 | widget := c.contentCoord.WrapWidget(ctx, input.WebView) | |
| 2261 | 2265 | if widget != nil { | |
| 2262 | - | newPaneView.SetWebViewWidget(widget) | |
| 2266 | + | if err := wsView.SetWebViewWidget(input.PopupPane.ID, widget); err != nil { | |
| 2267 | + | log.Warn().Err(err).Str("pane_id", string(input.PopupPane.ID)).Msg("failed to attach stacked popup webview widget") | |
| 2268 | + | } | |
| 2263 | 2269 | } | |
| 2264 | 2270 | } | |
| 2265 | 2271 | ||
| @@ -605,7 +605,9 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit( | |||||
| 605 | 605 | ||||
| 606 | widget := c.contentCoord.WrapWidget(ctx, wv) | 606 | widget := c.contentCoord.WrapWidget(ctx, wv) | ||
| 607 | if widget != nil { | 607 | if widget != nil { | ||
| 608 | - | newPaneView.SetWebViewWidget(widget) | 608 | + | if err := wsView.SetWebViewWidget(output.NewPaneNode.Pane.ID, widget); err != nil { |
| 609 | + | log.Warn().Err(err).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to attach webview widget for stack split") | |||
| 610 | + | } | |||
| 609 | } | 611 | } | ||
| 610 | 612 | ||||
| 611 | log.Debug().Msg("incremental stack split completed successfully") | 613 | log.Debug().Msg("incremental stack split completed successfully") | ||
| @@ -743,7 +745,9 @@ func (c *WorkspaceCoordinator) doIncrementalSplit( | |||||
| 743 | 745 | ||||
| 744 | widget := c.contentCoord.WrapWidget(ctx, wv) | 746 | widget := c.contentCoord.WrapWidget(ctx, wv) | ||
| 745 | if widget != nil { | 747 | if widget != nil { | ||
| 746 | - | newPaneView.SetWebViewWidget(widget) | 748 | + | if err := wsView.SetWebViewWidget(output.NewPaneNode.Pane.ID, widget); err != nil { |
| 749 | + | log.Warn().Err(err).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to attach webview widget for split") | |||
| 750 | + | } | |||
| 747 | } | 751 | } | ||
| 748 | 752 | ||||
| 749 | log.Debug().Msg("incremental split completed successfully") | 753 | log.Debug().Msg("incremental split completed successfully") | ||
| @@ -1622,7 +1626,9 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error { | |||||
| 1622 | } else { | 1626 | } else { | ||
| 1623 | widget := c.contentCoord.WrapWidget(ctx, wv) | 1627 | widget := c.contentCoord.WrapWidget(ctx, wv) | ||
| 1624 | if widget != nil { | 1628 | if widget != nil { | ||
| 1625 | - | newPaneView.SetWebViewWidget(widget) | 1629 | + | if err := stackCtx.wsView.SetWebViewWidget(newPaneID, widget); err != nil { |
| 1630 | + | log.Warn().Err(err).Str("pane_id", string(newPaneID)).Msg("failed to attach webview widget for stacked pane") | |||
| 1631 | + | } | |||
| 1626 | } | 1632 | } | ||
| 1627 | // Load initial page for the new pane | 1633 | // Load initial page for the new pane | ||
| 1628 | if err := wv.LoadURI(ctx, newPane.URI); err != nil { | 1634 | if err := wv.LoadURI(ctx, newPane.URI); err != nil { | ||
| @@ -2089,11 +2095,9 @@ func (c *WorkspaceCoordinator) attachPopupWebView( | |||||
| 2089 | return | 2095 | return | ||
| 2090 | } | 2096 | } | ||
| 2091 | 2097 | ||||
| 2092 | - | paneView := wsView.GetPaneView(input.PopupPane.ID) | 2098 | + | if err := wsView.SetWebViewWidget(input.PopupPane.ID, widget); err != nil { |
| 2093 | - | if paneView == nil { | 2099 | + | logging.FromContext(ctx).Warn().Err(err).Str("pane_id", string(input.PopupPane.ID)).Msg("failed to attach popup webview widget") |
| 2094 | - | return | |||
| 2095 | } | 2100 | } | ||
| 2096 | - | paneView.SetWebViewWidget(widget) | |||
| 2097 | } | 2101 | } | ||
| 2098 | 2102 | ||||
| 2099 | // insertPopupStacked inserts a popup as a stacked pane on top of the parent. | 2103 | // insertPopupStacked inserts a popup as a stacked pane on top of the parent. | ||
| @@ -2259,7 +2263,9 @@ func (c *WorkspaceCoordinator) attachPopupPaneView( | |||||
| 2259 | if input.WebView != nil { | 2263 | if input.WebView != nil { | ||
| 2260 | widget := c.contentCoord.WrapWidget(ctx, input.WebView) | 2264 | widget := c.contentCoord.WrapWidget(ctx, input.WebView) | ||
| 2261 | if widget != nil { | 2265 | if widget != nil { | ||
| 2262 | - | newPaneView.SetWebViewWidget(widget) | 2266 | + | if err := wsView.SetWebViewWidget(input.PopupPane.ID, widget); err != nil { |
| 2267 | + | log.Warn().Err(err).Str("pane_id", string(input.PopupPane.ID)).Msg("failed to attach stacked popup webview widget") | |||
| 2268 | + | } | |||
| 2263 | } | 2269 | } | ||
| 2264 | } | 2270 | } | ||
| 2265 | 2271 | ||||