fix(cef): stabilize webview resizing in split layouts
Keep split ratios applying until child allocations settle and ignore stale OSR paints that would restore the old view size. This keeps stacked split transitions from snapping webviews back to their pre-resize geometry.
- commit
- e8e2ac715b35646830a5fe72ccdb3b331f193dec
- author
- brice · · 2026-04-13 07:18 UTC
- parent
- 68fc589
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
17 files · +950 -37
M go.mod +1 -1
| @@ -71,4 +71,4 @@ require ( | |||
| 71 | 71 | modernc.org/sqlite v1.47.0 // indirect | |
| 72 | 72 | ) | |
| 73 | 73 | ||
| 74 | - | replace github.com/ebitengine/purego => github.com/bnema/purego v0.0.0-20260323095903-91aa177a2704 | |
| 74 | + | replace github.com/ebitengine/purego => github.com/bnema/purego v0.0.0-20260413043705-7850e384cde9 | |
| @@ -71,4 +71,4 @@ require ( | |||||
| 71 | modernc.org/sqlite v1.47.0 // indirect | 71 | modernc.org/sqlite v1.47.0 // indirect | ||
| 72 | ) | 72 | ) | ||
| 73 | 73 | ||||
| 74 | - | replace github.com/ebitengine/purego => github.com/bnema/purego v0.0.0-20260323095903-91aa177a2704 | 74 | + | replace github.com/ebitengine/purego => github.com/bnema/purego v0.0.0-20260413043705-7850e384cde9 |
M go.sum +2 -2 generated file: collapsed
M internal/infrastructure/cef/factory.go +53 -4
| @@ -38,6 +38,11 @@ type webViewFactoryOptions struct { | |||
| 38 | 38 | audioOutputFactory port.AudioOutputFactory | |
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | + | type resizeNotifiableBrowserHost interface { | |
| 42 | + | WasResized() | |
| 43 | + | Invalidate(purecef.PaintElementType) | |
| 44 | + | } | |
| 45 | + | ||
| 41 | 46 | // newWebViewFactory returns a factory that will create WebViews using the | |
| 42 | 47 | // given GL loader and HiDPI scale factor. | |
| 43 | 48 | func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) *WebViewFactory { | |
| @@ -72,7 +77,7 @@ func (f *WebViewFactory) setDefaultBackgroundColor(r, g, b, a float64) { | |||
| 72 | 77 | func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |
| 73 | 78 | id := port.WebViewID(f.nextID.Add(1)) | |
| 74 | 79 | ||
| 75 | - | pipeline := newRenderPipeline(ctx, f.gl, f.scale) | |
| 80 | + | pipeline := newRenderPipeline(ctx, f.gl, f.scale, id) | |
| 76 | 81 | ||
| 77 | 82 | wv := &WebView{ | |
| 78 | 83 | id: id, | |
| @@ -80,6 +85,7 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |||
| 80 | 85 | engine: f.engine, | |
| 81 | 86 | pipeline: pipeline, | |
| 82 | 87 | audioOutputFactory: f.audioOutputFactory, | |
| 88 | + | resizeReconciler: newResizeReconciler(ctx, id), | |
| 83 | 89 | } | |
| 84 | 90 | ||
| 85 | 91 | var transcodingHandler purecef.ResourceRequestHandler | |
| @@ -205,14 +211,57 @@ func (f *WebViewFactory) configureInitialBrowserCreation( | |||
| 205 | 211 | } | |
| 206 | 212 | ||
| 207 | 213 | // On subsequent resizes, notify CEF so it re-queries GetViewRect. | |
| 208 | - | pipeline.onResizeCB = func(_, _ int32) { | |
| 214 | + | pipeline.onResizeCB = func(w, h int32) { | |
| 215 | + | log := logging.FromContext(ctx) | |
| 209 | 216 | wv.mu.RLock() | |
| 210 | 217 | host := wv.host | |
| 211 | 218 | wv.mu.RUnlock() | |
| 212 | - | if host != nil { | |
| 213 | - | host.WasResized() | |
| 219 | + | if host == nil { | |
| 220 | + | log.Debug(). | |
| 221 | + | Uint64("webview_id", uint64(wv.id)). | |
| 222 | + | Int32("resize_width", w). | |
| 223 | + | Int32("resize_height", h). | |
| 224 | + | Bool("host_nil", true). | |
| 225 | + | Bool("browser_ready", false). | |
| 226 | + | Msg("cef: resize observed before browser host existed") | |
| 227 | + | return | |
| 214 | 228 | } | |
| 229 | + | log.Debug(). | |
| 230 | + | Uint64("webview_id", uint64(wv.id)). | |
| 231 | + | Int32("resize_width", w). | |
| 232 | + | Int32("resize_height", h). | |
| 233 | + | Bool("host_nil", false). | |
| 234 | + | Bool("browser_ready", true). | |
| 235 | + | Msg("cef: browser resize observed before WasResized") | |
| 236 | + | notifyBrowserResize(host) | |
| 237 | + | log.Debug(). | |
| 238 | + | Uint64("webview_id", uint64(wv.id)). | |
| 239 | + | Int32("resize_width", w). | |
| 240 | + | Int32("resize_height", h). | |
| 241 | + | Bool("host_nil", false). | |
| 242 | + | Bool("browser_ready", true). | |
| 243 | + | Msg("cef: browser host WasResized invoked") | |
| 244 | + | if wv.resizeReconciler != nil { | |
| 245 | + | resizeSeq, _ := wv.pipeline.latestResizeDiagnostics() | |
| 246 | + | wv.resizeReconciler.start( | |
| 247 | + | resizeSeq, | |
| 248 | + | func() resizeNotifiableBrowserHost { | |
| 249 | + | wv.mu.RLock() | |
| 250 | + | defer wv.mu.RUnlock() | |
| 251 | + | return wv.host | |
| 252 | + | }, | |
| 253 | + | func() bool { return wv.destroyed.Load() }, | |
| 254 | + | ) | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | func notifyBrowserResize(host resizeNotifiableBrowserHost) { | |
| 260 | + | if host == nil { | |
| 261 | + | return | |
| 215 | 262 | } | |
| 263 | + | host.WasResized() | |
| 264 | + | host.Invalidate(purecef.PaintElementTypePetView) | |
| 216 | 265 | } | |
| 217 | 266 | ||
| 218 | 267 | // CreateRelated creates a WebView that shares session/cookies with the parent. | |
| @@ -38,6 +38,11 @@ type webViewFactoryOptions struct { | |||||
| 38 | audioOutputFactory port.AudioOutputFactory | 38 | audioOutputFactory port.AudioOutputFactory | ||
| 39 | } | 39 | } | ||
| 40 | 40 | ||||
| 41 | + | type resizeNotifiableBrowserHost interface { | |||
| 42 | + | WasResized() | |||
| 43 | + | Invalidate(purecef.PaintElementType) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 41 | // newWebViewFactory returns a factory that will create WebViews using the | 46 | // newWebViewFactory returns a factory that will create WebViews using the | ||
| 42 | // given GL loader and HiDPI scale factor. | 47 | // given GL loader and HiDPI scale factor. | ||
| 43 | func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) *WebViewFactory { | 48 | func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) *WebViewFactory { | ||
| @@ -72,7 +77,7 @@ func (f *WebViewFactory) setDefaultBackgroundColor(r, g, b, a float64) { | |||||
| 72 | func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | 77 | func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | ||
| 73 | id := port.WebViewID(f.nextID.Add(1)) | 78 | id := port.WebViewID(f.nextID.Add(1)) | ||
| 74 | 79 | ||||
| 75 | - | pipeline := newRenderPipeline(ctx, f.gl, f.scale) | 80 | + | pipeline := newRenderPipeline(ctx, f.gl, f.scale, id) |
| 76 | 81 | ||||
| 77 | wv := &WebView{ | 82 | wv := &WebView{ | ||
| 78 | id: id, | 83 | id: id, | ||
| @@ -80,6 +85,7 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |||||
| 80 | engine: f.engine, | 85 | engine: f.engine, | ||
| 81 | pipeline: pipeline, | 86 | pipeline: pipeline, | ||
| 82 | audioOutputFactory: f.audioOutputFactory, | 87 | audioOutputFactory: f.audioOutputFactory, | ||
| 88 | + | resizeReconciler: newResizeReconciler(ctx, id), | |||
| 83 | } | 89 | } | ||
| 84 | 90 | ||||
| 85 | var transcodingHandler purecef.ResourceRequestHandler | 91 | var transcodingHandler purecef.ResourceRequestHandler | ||
| @@ -205,14 +211,57 @@ func (f *WebViewFactory) configureInitialBrowserCreation( | |||||
| 205 | } | 211 | } | ||
| 206 | 212 | ||||
| 207 | // On subsequent resizes, notify CEF so it re-queries GetViewRect. | 213 | // On subsequent resizes, notify CEF so it re-queries GetViewRect. | ||
| 208 | - | pipeline.onResizeCB = func(_, _ int32) { | 214 | + | pipeline.onResizeCB = func(w, h int32) { |
| 215 | + | log := logging.FromContext(ctx) | |||
| 209 | wv.mu.RLock() | 216 | wv.mu.RLock() | ||
| 210 | host := wv.host | 217 | host := wv.host | ||
| 211 | wv.mu.RUnlock() | 218 | wv.mu.RUnlock() | ||
| 212 | - | if host != nil { | 219 | + | if host == nil { |
| 213 | - | host.WasResized() | 220 | + | log.Debug(). |
| 221 | + | Uint64("webview_id", uint64(wv.id)). | |||
| 222 | + | Int32("resize_width", w). | |||
| 223 | + | Int32("resize_height", h). | |||
| 224 | + | Bool("host_nil", true). | |||
| 225 | + | Bool("browser_ready", false). | |||
| 226 | + | Msg("cef: resize observed before browser host existed") | |||
| 227 | + | return | |||
| 214 | } | 228 | } | ||
| 229 | + | log.Debug(). | |||
| 230 | + | Uint64("webview_id", uint64(wv.id)). | |||
| 231 | + | Int32("resize_width", w). | |||
| 232 | + | Int32("resize_height", h). | |||
| 233 | + | Bool("host_nil", false). | |||
| 234 | + | Bool("browser_ready", true). | |||
| 235 | + | Msg("cef: browser resize observed before WasResized") | |||
| 236 | + | notifyBrowserResize(host) | |||
| 237 | + | log.Debug(). | |||
| 238 | + | Uint64("webview_id", uint64(wv.id)). | |||
| 239 | + | Int32("resize_width", w). | |||
| 240 | + | Int32("resize_height", h). | |||
| 241 | + | Bool("host_nil", false). | |||
| 242 | + | Bool("browser_ready", true). | |||
| 243 | + | Msg("cef: browser host WasResized invoked") | |||
| 244 | + | if wv.resizeReconciler != nil { | |||
| 245 | + | resizeSeq, _ := wv.pipeline.latestResizeDiagnostics() | |||
| 246 | + | wv.resizeReconciler.start( | |||
| 247 | + | resizeSeq, | |||
| 248 | + | func() resizeNotifiableBrowserHost { | |||
| 249 | + | wv.mu.RLock() | |||
| 250 | + | defer wv.mu.RUnlock() | |||
| 251 | + | return wv.host | |||
| 252 | + | }, | |||
| 253 | + | func() bool { return wv.destroyed.Load() }, | |||
| 254 | + | ) | |||
| 255 | + | } | |||
| 256 | + | } | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | func notifyBrowserResize(host resizeNotifiableBrowserHost) { | |||
| 260 | + | if host == nil { | |||
| 261 | + | return | |||
| 215 | } | 262 | } | ||
| 263 | + | host.WasResized() | |||
| 264 | + | host.Invalidate(purecef.PaintElementTypePetView) | |||
| 216 | } | 265 | } | ||
| 217 | 266 | ||||
| 218 | // CreateRelated creates a WebView that shares session/cookies with the parent. | 267 | // CreateRelated creates a WebView that shares session/cookies with the parent. | ||
A internal/infrastructure/cef/factory_resize_test.go +28 -0
| @@ -0,0 +1,28 @@ | |||
| 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 resizeOrderHost struct { | |
| 11 | + | calls []string | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | func (h *resizeOrderHost) WasResized() { | |
| 15 | + | h.calls = append(h.calls, "WasResized") | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func (h *resizeOrderHost) Invalidate(_ purecef.PaintElementType) { | |
| 19 | + | h.calls = append(h.calls, "Invalidate") | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestNotifyBrowserResize_CallsWasResizedThenInvalidate(t *testing.T) { | |
| 23 | + | host := &resizeOrderHost{} | |
| 24 | + | ||
| 25 | + | notifyBrowserResize(host) | |
| 26 | + | ||
| 27 | + | require.Equal(t, []string{"WasResized", "Invalidate"}, host.calls) | |
| 28 | + | } | |
| @@ -0,0 +1,28 @@ | |||||
| 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 resizeOrderHost struct { | |||
| 11 | + | calls []string | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | func (h *resizeOrderHost) WasResized() { | |||
| 15 | + | h.calls = append(h.calls, "WasResized") | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func (h *resizeOrderHost) Invalidate(_ purecef.PaintElementType) { | |||
| 19 | + | h.calls = append(h.calls, "Invalidate") | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func TestNotifyBrowserResize_CallsWasResizedThenInvalidate(t *testing.T) { | |||
| 23 | + | host := &resizeOrderHost{} | |||
| 24 | + | ||||
| 25 | + | notifyBrowserResize(host) | |||
| 26 | + | ||||
| 27 | + | require.Equal(t, []string{"WasResized", "Invalidate"}, host.calls) | |||
| 28 | + | } | |||
M internal/infrastructure/cef/render_pipeline.go +100 -8
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "time" | |
| 8 | 8 | "unsafe" | |
| 9 | 9 | ||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | 11 | "github.com/bnema/dumber/internal/logging" | |
| 11 | 12 | "github.com/bnema/puregotk/v4/gdk" | |
| 12 | 13 | "github.com/bnema/puregotk/v4/gtk" | |
| @@ -25,9 +26,10 @@ type rect struct { | |||
| 25 | 26 | // and GTK later uploads from staging → PBO → texture before drawing a | |
| 26 | 27 | // fullscreen quad. Two PBOs alternate so GPU DMA and CPU writes overlap. | |
| 27 | 28 | type renderPipeline struct { | |
| 28 | - | ctx context.Context | |
| 29 | - | gl *glLoader | |
| 30 | - | glArea *gtk.GLArea | |
| 29 | + | webviewID port.WebViewID | |
| 30 | + | ctx context.Context | |
| 31 | + | gl *glLoader | |
| 32 | + | glArea *gtk.GLArea | |
| 31 | 33 | ||
| 32 | 34 | // GL resources (created on first render). | |
| 33 | 35 | texture uint32 | |
| @@ -71,7 +73,11 @@ type renderPipeline struct { | |||
| 71 | 73 | viewRectSeq atomic.Uint64 | |
| 72 | 74 | screenInfoSeq atomic.Uint64 | |
| 73 | 75 | paintSeq atomic.Uint64 | |
| 76 | + | resizeSeq atomic.Uint64 | |
| 74 | 77 | lastQueuedPaintSeq atomic.Uint64 | |
| 78 | + | lastResizeUnixNano atomic.Int64 | |
| 79 | + | lastResizePaintLogSeq atomic.Uint64 | |
| 80 | + | lastResizeStaleLogSeq atomic.Uint64 | |
| 75 | 81 | glRenderSeq atomic.Uint64 | |
| 76 | 82 | paintCount uint64 | |
| 77 | 83 | acceleratedPaintCount uint64 | |
| @@ -139,15 +145,16 @@ var quadVertices = [16]float32{ | |||
| 139 | 145 | ||
| 140 | 146 | // newRenderPipeline creates a GtkGLArea and wires up the render and resize | |
| 141 | 147 | // signals. The returned pipeline is ready to receive handlePaint calls. | |
| 142 | - | func newRenderPipeline(ctx context.Context, gl *glLoader, scale int32) *renderPipeline { | |
| 148 | + | func newRenderPipeline(ctx context.Context, gl *glLoader, scale int32, webviewID port.WebViewID) *renderPipeline { | |
| 143 | 149 | if scale < 1 { | |
| 144 | 150 | scale = 1 | |
| 145 | 151 | } | |
| 146 | 152 | ||
| 147 | 153 | rp := &renderPipeline{ | |
| 148 | - | ctx: ctx, | |
| 149 | - | gl: gl, | |
| 150 | - | scale: scale, | |
| 154 | + | webviewID: webviewID, | |
| 155 | + | ctx: ctx, | |
| 156 | + | gl: gl, | |
| 157 | + | scale: scale, | |
| 151 | 158 | } | |
| 152 | 159 | ||
| 153 | 160 | rp.glArea = gtk.NewGLArea() | |
| @@ -292,6 +299,21 @@ func (rp *renderPipeline) onGLRender() bool { | |||
| 292 | 299 | ||
| 293 | 300 | rp.mu.Unlock() | |
| 294 | 301 | ||
| 302 | + | if rp.ctx != nil && sizeChanged && !needsUpload { | |
| 303 | + | resizeSeq, resizeAgeMs := rp.latestResizeDiagnostics() | |
| 304 | + | logging.FromContext(rp.ctx).Warn(). | |
| 305 | + | Uint64("webview_id", uint64(rp.webviewID)). | |
| 306 | + | Uint64("resize_seq", resizeSeq). | |
| 307 | + | Int64("time_since_resize_ms", resizeAgeMs). | |
| 308 | + | Int32("upload_width", uploadWidth). | |
| 309 | + | Int32("upload_height", uploadHeight). | |
| 310 | + | Bool("gl_ready", rp.glReady). | |
| 311 | + | Bool("needs_upload", needsUpload). | |
| 312 | + | Bool("size_changed", sizeChanged). | |
| 313 | + | Uint64("last_queued_paint_seq", paintSeq). | |
| 314 | + | Msg("cef: render after resize without fresh paint") | |
| 315 | + | } | |
| 316 | + | ||
| 295 | 317 | if !rp.glReady || sizeChanged { | |
| 296 | 318 | rp.initGL(uploadWidth, uploadHeight) | |
| 297 | 319 | } | |
| @@ -821,11 +843,21 @@ func compileShader(gl *glLoader, shaderType uint32, source string) uint32 { | |||
| 821 | 843 | func (rp *renderPipeline) onResize(width, height int32) { | |
| 822 | 844 | rp.mu.Lock() | |
| 823 | 845 | ||
| 846 | + | prevWidth := rp.widthAtomic.Load() | |
| 847 | + | prevHeight := rp.heightAtomic.Load() | |
| 848 | + | scale := rp.scale | |
| 849 | + | if scale <= 0 { | |
| 850 | + | scale = 1 | |
| 851 | + | } | |
| 852 | + | ||
| 824 | 853 | scaled := func(v int32) int32 { | |
| 825 | - | return v * rp.scale | |
| 854 | + | return v * scale | |
| 826 | 855 | } | |
| 827 | 856 | ||
| 828 | 857 | w, h := scaled(width), scaled(height) | |
| 858 | + | resizeSeq := rp.resizeSeq.Add(1) | |
| 859 | + | now := time.Now() | |
| 860 | + | rp.lastResizeUnixNano.Store(now.UnixNano()) | |
| 829 | 861 | rp.widthAtomic.Store(w) | |
| 830 | 862 | rp.heightAtomic.Store(h) | |
| 831 | 863 | rp.sizeChanged = true | |
| @@ -837,6 +869,22 @@ func (rp *renderPipeline) onResize(width, height int32) { | |||
| 837 | 869 | resizeCB := rp.onResizeCB | |
| 838 | 870 | rp.mu.Unlock() | |
| 839 | 871 | ||
| 872 | + | if rp.ctx != nil { | |
| 873 | + | logging.FromContext(rp.ctx).Debug(). | |
| 874 | + | Uint64("webview_id", uint64(rp.webviewID)). | |
| 875 | + | Uint64("resize_seq", resizeSeq). | |
| 876 | + | Int32("css_width", width). | |
| 877 | + | Int32("css_height", height). | |
| 878 | + | Int32("scaled_width", w). | |
| 879 | + | Int32("scaled_height", h). | |
| 880 | + | Int32("prev_width", prevWidth). | |
| 881 | + | Int32("prev_height", prevHeight). | |
| 882 | + | Int32("scale", scale). | |
| 883 | + | Bool("first_resize", firstCB != nil). | |
| 884 | + | Bool("has_resize_cb", resizeCB != nil). | |
| 885 | + | Msg("cef: GLArea resize") | |
| 886 | + | } | |
| 887 | + | ||
| 840 | 888 | if firstCB != nil { | |
| 841 | 889 | firstCB(w, h) | |
| 842 | 890 | } else if resizeCB != nil { | |
| @@ -918,6 +966,50 @@ func (rp *renderPipeline) viewRectSize() (width, height, scale int32) { | |||
| 918 | 966 | return width, height, scale | |
| 919 | 967 | } | |
| 920 | 968 | ||
| 969 | + | func (rp *renderPipeline) latestResizeDiagnostics() (resizeSeq uint64, ageMs int64) { | |
| 970 | + | resizeSeq = rp.resizeSeq.Load() | |
| 971 | + | lastResizeUnixNano := rp.lastResizeUnixNano.Load() | |
| 972 | + | if lastResizeUnixNano == 0 { | |
| 973 | + | return resizeSeq, 0 | |
| 974 | + | } | |
| 975 | + | age := time.Since(time.Unix(0, lastResizeUnixNano)) | |
| 976 | + | if age <= 0 { | |
| 977 | + | return resizeSeq, 0 | |
| 978 | + | } | |
| 979 | + | return resizeSeq, age.Milliseconds() | |
| 980 | + | } | |
| 981 | + | ||
| 982 | + | func (rp *renderPipeline) markFirstPaintAfterResize() (resizeSeq uint64, ageMs int64, shouldLog bool) { | |
| 983 | + | resizeSeq, ageMs = rp.latestResizeDiagnostics() | |
| 984 | + | if resizeSeq == 0 { | |
| 985 | + | return resizeSeq, ageMs, false | |
| 986 | + | } | |
| 987 | + | for { | |
| 988 | + | lastLogged := rp.lastResizePaintLogSeq.Load() | |
| 989 | + | if lastLogged >= resizeSeq { | |
| 990 | + | return resizeSeq, ageMs, false | |
| 991 | + | } | |
| 992 | + | if rp.lastResizePaintLogSeq.CompareAndSwap(lastLogged, resizeSeq) { | |
| 993 | + | return resizeSeq, ageMs, true | |
| 994 | + | } | |
| 995 | + | } | |
| 996 | + | } | |
| 997 | + | ||
| 998 | + | func (rp *renderPipeline) markStalePaintAfterResize(resizeSeq uint64) bool { | |
| 999 | + | if resizeSeq == 0 { | |
| 1000 | + | return false | |
| 1001 | + | } | |
| 1002 | + | for { | |
| 1003 | + | lastLogged := rp.lastResizeStaleLogSeq.Load() | |
| 1004 | + | if lastLogged >= resizeSeq { | |
| 1005 | + | return false | |
| 1006 | + | } | |
| 1007 | + | if rp.lastResizeStaleLogSeq.CompareAndSwap(lastLogged, resizeSeq) { | |
| 1008 | + | return true | |
| 1009 | + | } | |
| 1010 | + | } | |
| 1011 | + | } | |
| 1012 | + | ||
| 921 | 1013 | func (rp *renderPipeline) nextViewRectSeq() uint64 { | |
| 922 | 1014 | return rp.viewRectSeq.Add(1) | |
| 923 | 1015 | } | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "time" | 7 | "time" | ||
| 8 | "unsafe" | 8 | "unsafe" | ||
| 9 | 9 | ||||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 10 | "github.com/bnema/dumber/internal/logging" | 11 | "github.com/bnema/dumber/internal/logging" | ||
| 11 | "github.com/bnema/puregotk/v4/gdk" | 12 | "github.com/bnema/puregotk/v4/gdk" | ||
| 12 | "github.com/bnema/puregotk/v4/gtk" | 13 | "github.com/bnema/puregotk/v4/gtk" | ||
| @@ -25,9 +26,10 @@ type rect struct { | |||||
| 25 | // and GTK later uploads from staging → PBO → texture before drawing a | 26 | // and GTK later uploads from staging → PBO → texture before drawing a | ||
| 26 | // fullscreen quad. Two PBOs alternate so GPU DMA and CPU writes overlap. | 27 | // fullscreen quad. Two PBOs alternate so GPU DMA and CPU writes overlap. | ||
| 27 | type renderPipeline struct { | 28 | type renderPipeline struct { | ||
| 28 | - | ctx context.Context | 29 | + | webviewID port.WebViewID |
| 29 | - | gl *glLoader | 30 | + | ctx context.Context |
| 30 | - | glArea *gtk.GLArea | 31 | + | gl *glLoader |
| 32 | + | glArea *gtk.GLArea | |||
| 31 | 33 | ||||
| 32 | // GL resources (created on first render). | 34 | // GL resources (created on first render). | ||
| 33 | texture uint32 | 35 | texture uint32 | ||
| @@ -71,7 +73,11 @@ type renderPipeline struct { | |||||
| 71 | viewRectSeq atomic.Uint64 | 73 | viewRectSeq atomic.Uint64 | ||
| 72 | screenInfoSeq atomic.Uint64 | 74 | screenInfoSeq atomic.Uint64 | ||
| 73 | paintSeq atomic.Uint64 | 75 | paintSeq atomic.Uint64 | ||
| 76 | + | resizeSeq atomic.Uint64 | |||
| 74 | lastQueuedPaintSeq atomic.Uint64 | 77 | lastQueuedPaintSeq atomic.Uint64 | ||
| 78 | + | lastResizeUnixNano atomic.Int64 | |||
| 79 | + | lastResizePaintLogSeq atomic.Uint64 | |||
| 80 | + | lastResizeStaleLogSeq atomic.Uint64 | |||
| 75 | glRenderSeq atomic.Uint64 | 81 | glRenderSeq atomic.Uint64 | ||
| 76 | paintCount uint64 | 82 | paintCount uint64 | ||
| 77 | acceleratedPaintCount uint64 | 83 | acceleratedPaintCount uint64 | ||
| @@ -139,15 +145,16 @@ var quadVertices = [16]float32{ | |||||
| 139 | 145 | ||||
| 140 | // newRenderPipeline creates a GtkGLArea and wires up the render and resize | 146 | // newRenderPipeline creates a GtkGLArea and wires up the render and resize | ||
| 141 | // signals. The returned pipeline is ready to receive handlePaint calls. | 147 | // signals. The returned pipeline is ready to receive handlePaint calls. | ||
| 142 | - | func newRenderPipeline(ctx context.Context, gl *glLoader, scale int32) *renderPipeline { | 148 | + | func newRenderPipeline(ctx context.Context, gl *glLoader, scale int32, webviewID port.WebViewID) *renderPipeline { |
| 143 | if scale < 1 { | 149 | if scale < 1 { | ||
| 144 | scale = 1 | 150 | scale = 1 | ||
| 145 | } | 151 | } | ||
| 146 | 152 | ||||
| 147 | rp := &renderPipeline{ | 153 | rp := &renderPipeline{ | ||
| 148 | - | ctx: ctx, | 154 | + | webviewID: webviewID, |
| 149 | - | gl: gl, | 155 | + | ctx: ctx, |
| 150 | - | scale: scale, | 156 | + | gl: gl, |
| 157 | + | scale: scale, | |||
| 151 | } | 158 | } | ||
| 152 | 159 | ||||
| 153 | rp.glArea = gtk.NewGLArea() | 160 | rp.glArea = gtk.NewGLArea() | ||
| @@ -292,6 +299,21 @@ func (rp *renderPipeline) onGLRender() bool { | |||||
| 292 | 299 | ||||
| 293 | rp.mu.Unlock() | 300 | rp.mu.Unlock() | ||
| 294 | 301 | ||||
| 302 | + | if rp.ctx != nil && sizeChanged && !needsUpload { | |||
| 303 | + | resizeSeq, resizeAgeMs := rp.latestResizeDiagnostics() | |||
| 304 | + | logging.FromContext(rp.ctx).Warn(). | |||
| 305 | + | Uint64("webview_id", uint64(rp.webviewID)). | |||
| 306 | + | Uint64("resize_seq", resizeSeq). | |||
| 307 | + | Int64("time_since_resize_ms", resizeAgeMs). | |||
| 308 | + | Int32("upload_width", uploadWidth). | |||
| 309 | + | Int32("upload_height", uploadHeight). | |||
| 310 | + | Bool("gl_ready", rp.glReady). | |||
| 311 | + | Bool("needs_upload", needsUpload). | |||
| 312 | + | Bool("size_changed", sizeChanged). | |||
| 313 | + | Uint64("last_queued_paint_seq", paintSeq). | |||
| 314 | + | Msg("cef: render after resize without fresh paint") | |||
| 315 | + | } | |||
| 316 | + | ||||
| 295 | if !rp.glReady || sizeChanged { | 317 | if !rp.glReady || sizeChanged { | ||
| 296 | rp.initGL(uploadWidth, uploadHeight) | 318 | rp.initGL(uploadWidth, uploadHeight) | ||
| 297 | } | 319 | } | ||
| @@ -821,11 +843,21 @@ func compileShader(gl *glLoader, shaderType uint32, source string) uint32 { | |||||
| 821 | func (rp *renderPipeline) onResize(width, height int32) { | 843 | func (rp *renderPipeline) onResize(width, height int32) { | ||
| 822 | rp.mu.Lock() | 844 | rp.mu.Lock() | ||
| 823 | 845 | ||||
| 846 | + | prevWidth := rp.widthAtomic.Load() | |||
| 847 | + | prevHeight := rp.heightAtomic.Load() | |||
| 848 | + | scale := rp.scale | |||
| 849 | + | if scale <= 0 { | |||
| 850 | + | scale = 1 | |||
| 851 | + | } | |||
| 852 | + | ||||
| 824 | scaled := func(v int32) int32 { | 853 | scaled := func(v int32) int32 { | ||
| 825 | - | return v * rp.scale | 854 | + | return v * scale |
| 826 | } | 855 | } | ||
| 827 | 856 | ||||
| 828 | w, h := scaled(width), scaled(height) | 857 | w, h := scaled(width), scaled(height) | ||
| 858 | + | resizeSeq := rp.resizeSeq.Add(1) | |||
| 859 | + | now := time.Now() | |||
| 860 | + | rp.lastResizeUnixNano.Store(now.UnixNano()) | |||
| 829 | rp.widthAtomic.Store(w) | 861 | rp.widthAtomic.Store(w) | ||
| 830 | rp.heightAtomic.Store(h) | 862 | rp.heightAtomic.Store(h) | ||
| 831 | rp.sizeChanged = true | 863 | rp.sizeChanged = true | ||
| @@ -837,6 +869,22 @@ func (rp *renderPipeline) onResize(width, height int32) { | |||||
| 837 | resizeCB := rp.onResizeCB | 869 | resizeCB := rp.onResizeCB | ||
| 838 | rp.mu.Unlock() | 870 | rp.mu.Unlock() | ||
| 839 | 871 | ||||
| 872 | + | if rp.ctx != nil { | |||
| 873 | + | logging.FromContext(rp.ctx).Debug(). | |||
| 874 | + | Uint64("webview_id", uint64(rp.webviewID)). | |||
| 875 | + | Uint64("resize_seq", resizeSeq). | |||
| 876 | + | Int32("css_width", width). | |||
| 877 | + | Int32("css_height", height). | |||
| 878 | + | Int32("scaled_width", w). | |||
| 879 | + | Int32("scaled_height", h). | |||
| 880 | + | Int32("prev_width", prevWidth). | |||
| 881 | + | Int32("prev_height", prevHeight). | |||
| 882 | + | Int32("scale", scale). | |||
| 883 | + | Bool("first_resize", firstCB != nil). | |||
| 884 | + | Bool("has_resize_cb", resizeCB != nil). | |||
| 885 | + | Msg("cef: GLArea resize") | |||
| 886 | + | } | |||
| 887 | + | ||||
| 840 | if firstCB != nil { | 888 | if firstCB != nil { | ||
| 841 | firstCB(w, h) | 889 | firstCB(w, h) | ||
| 842 | } else if resizeCB != nil { | 890 | } else if resizeCB != nil { | ||
| @@ -918,6 +966,50 @@ func (rp *renderPipeline) viewRectSize() (width, height, scale int32) { | |||||
| 918 | return width, height, scale | 966 | return width, height, scale | ||
| 919 | } | 967 | } | ||
| 920 | 968 | ||||
| 969 | + | func (rp *renderPipeline) latestResizeDiagnostics() (resizeSeq uint64, ageMs int64) { | |||
| 970 | + | resizeSeq = rp.resizeSeq.Load() | |||
| 971 | + | lastResizeUnixNano := rp.lastResizeUnixNano.Load() | |||
| 972 | + | if lastResizeUnixNano == 0 { | |||
| 973 | + | return resizeSeq, 0 | |||
| 974 | + | } | |||
| 975 | + | age := time.Since(time.Unix(0, lastResizeUnixNano)) | |||
| 976 | + | if age <= 0 { | |||
| 977 | + | return resizeSeq, 0 | |||
| 978 | + | } | |||
| 979 | + | return resizeSeq, age.Milliseconds() | |||
| 980 | + | } | |||
| 981 | + | ||||
| 982 | + | func (rp *renderPipeline) markFirstPaintAfterResize() (resizeSeq uint64, ageMs int64, shouldLog bool) { | |||
| 983 | + | resizeSeq, ageMs = rp.latestResizeDiagnostics() | |||
| 984 | + | if resizeSeq == 0 { | |||
| 985 | + | return resizeSeq, ageMs, false | |||
| 986 | + | } | |||
| 987 | + | for { | |||
| 988 | + | lastLogged := rp.lastResizePaintLogSeq.Load() | |||
| 989 | + | if lastLogged >= resizeSeq { | |||
| 990 | + | return resizeSeq, ageMs, false | |||
| 991 | + | } | |||
| 992 | + | if rp.lastResizePaintLogSeq.CompareAndSwap(lastLogged, resizeSeq) { | |||
| 993 | + | return resizeSeq, ageMs, true | |||
| 994 | + | } | |||
| 995 | + | } | |||
| 996 | + | } | |||
| 997 | + | ||||
| 998 | + | func (rp *renderPipeline) markStalePaintAfterResize(resizeSeq uint64) bool { | |||
| 999 | + | if resizeSeq == 0 { | |||
| 1000 | + | return false | |||
| 1001 | + | } | |||
| 1002 | + | for { | |||
| 1003 | + | lastLogged := rp.lastResizeStaleLogSeq.Load() | |||
| 1004 | + | if lastLogged >= resizeSeq { | |||
| 1005 | + | return false | |||
| 1006 | + | } | |||
| 1007 | + | if rp.lastResizeStaleLogSeq.CompareAndSwap(lastLogged, resizeSeq) { | |||
| 1008 | + | return true | |||
| 1009 | + | } | |||
| 1010 | + | } | |||
| 1011 | + | } | |||
| 1012 | + | ||||
| 921 | func (rp *renderPipeline) nextViewRectSeq() uint64 { | 1013 | func (rp *renderPipeline) nextViewRectSeq() uint64 { | ||
| 922 | return rp.viewRectSeq.Add(1) | 1014 | return rp.viewRectSeq.Add(1) | ||
| 923 | } | 1015 | } | ||
A internal/infrastructure/cef/resize_reconciler.go +271 -0
| @@ -0,0 +1,271 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 10 | + | "github.com/bnema/puregotk/v4/glib" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/dumber/internal/logging" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | const ( | |
| 16 | + | resizeReconcileRetryDelay = 8 * time.Millisecond | |
| 17 | + | resizeReconcileWindow = 64 * time.Millisecond | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | type resizeReconcileTimer interface { | |
| 21 | + | stop() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | type resizeReconcileScheduler interface { | |
| 25 | + | afterFunc(delay time.Duration, fn func()) resizeReconcileTimer | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | type glibResizeTimer struct{ id uint } | |
| 29 | + | ||
| 30 | + | func (t *glibResizeTimer) stop() { | |
| 31 | + | if t == nil || t.id == 0 { | |
| 32 | + | return | |
| 33 | + | } | |
| 34 | + | glib.SourceRemove(t.id) | |
| 35 | + | t.id = 0 | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | type glibResizeScheduler struct{} | |
| 39 | + | ||
| 40 | + | func (glibResizeScheduler) afterFunc(delay time.Duration, fn func()) resizeReconcileTimer { | |
| 41 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 42 | + | if fn != nil { | |
| 43 | + | fn() | |
| 44 | + | } | |
| 45 | + | return false | |
| 46 | + | }) | |
| 47 | + | return &glibResizeTimer{id: glib.TimeoutAdd(uint(delay.Milliseconds()), &cb, 0)} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | type resizeReconciler struct { | |
| 51 | + | mu sync.Mutex | |
| 52 | + | webviewID port.WebViewID | |
| 53 | + | ctx context.Context | |
| 54 | + | scheduler resizeReconcileScheduler | |
| 55 | + | now func() time.Time | |
| 56 | + | session *resizeReconcileSession | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | type resizeReconcileSession struct { | |
| 60 | + | seq uint64 | |
| 61 | + | startedAt time.Time | |
| 62 | + | retryCount int | |
| 63 | + | timer resizeReconcileTimer | |
| 64 | + | hostGetter func() resizeNotifiableBrowserHost | |
| 65 | + | destroyed func() bool | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func newResizeReconciler(ctx context.Context, webviewID port.WebViewID) *resizeReconciler { | |
| 69 | + | return &resizeReconciler{ | |
| 70 | + | webviewID: webviewID, | |
| 71 | + | ctx: ctx, | |
| 72 | + | scheduler: glibResizeScheduler{}, | |
| 73 | + | now: time.Now, | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func (r *resizeReconciler) clock() time.Time { | |
| 78 | + | if r != nil && r.now != nil { | |
| 79 | + | return r.now() | |
| 80 | + | } | |
| 81 | + | return time.Now() | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func (r *resizeReconciler) schedulerOrDefault() resizeReconcileScheduler { | |
| 85 | + | if r != nil && r.scheduler != nil { | |
| 86 | + | return r.scheduler | |
| 87 | + | } | |
| 88 | + | return glibResizeScheduler{} | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (r *resizeReconciler) start(seq uint64, hostGetter func() resizeNotifiableBrowserHost, destroyed func() bool) { | |
| 92 | + | if r == nil || seq == 0 || hostGetter == nil { | |
| 93 | + | return | |
| 94 | + | } | |
| 95 | + | if destroyed != nil && destroyed() { | |
| 96 | + | return | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | now := r.clock() | |
| 100 | + | var superseded *resizeReconcileSession | |
| 101 | + | ||
| 102 | + | r.mu.Lock() | |
| 103 | + | if r.session != nil { | |
| 104 | + | superseded = r.session | |
| 105 | + | if superseded.timer != nil { | |
| 106 | + | superseded.timer.stop() | |
| 107 | + | } | |
| 108 | + | r.session = nil | |
| 109 | + | } | |
| 110 | + | session := &resizeReconcileSession{ | |
| 111 | + | seq: seq, | |
| 112 | + | startedAt: now, | |
| 113 | + | hostGetter: hostGetter, | |
| 114 | + | destroyed: destroyed, | |
| 115 | + | } | |
| 116 | + | r.session = session | |
| 117 | + | r.mu.Unlock() | |
| 118 | + | ||
| 119 | + | if superseded != nil { | |
| 120 | + | r.logStopped("superseded", superseded.seq, superseded.retryCount, now.Sub(superseded.startedAt).Milliseconds()) | |
| 121 | + | } | |
| 122 | + | r.logStarted(seq) | |
| 123 | + | r.schedule(session) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func (r *resizeReconciler) notePaint(seq uint64, sizeMatches bool) { | |
| 127 | + | if r == nil || seq == 0 || !sizeMatches { | |
| 128 | + | return | |
| 129 | + | } | |
| 130 | + | now := r.clock() | |
| 131 | + | ||
| 132 | + | r.mu.Lock() | |
| 133 | + | session := r.session | |
| 134 | + | if session == nil || session.seq != seq { | |
| 135 | + | r.mu.Unlock() | |
| 136 | + | return | |
| 137 | + | } | |
| 138 | + | if session.timer != nil { | |
| 139 | + | session.timer.stop() | |
| 140 | + | session.timer = nil | |
| 141 | + | } | |
| 142 | + | r.session = nil | |
| 143 | + | retryCount := session.retryCount | |
| 144 | + | elapsed := now.Sub(session.startedAt).Milliseconds() | |
| 145 | + | r.mu.Unlock() | |
| 146 | + | ||
| 147 | + | r.logStopped("paint-arrived", seq, retryCount, elapsed) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func (r *resizeReconciler) stop() { | |
| 151 | + | if r == nil { | |
| 152 | + | return | |
| 153 | + | } | |
| 154 | + | r.mu.Lock() | |
| 155 | + | session := r.session | |
| 156 | + | r.session = nil | |
| 157 | + | r.mu.Unlock() | |
| 158 | + | if session != nil && session.timer != nil { | |
| 159 | + | session.timer.stop() | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func (r *resizeReconciler) schedule(session *resizeReconcileSession) { | |
| 164 | + | if r == nil || session == nil { | |
| 165 | + | return | |
| 166 | + | } | |
| 167 | + | timer := r.schedulerOrDefault().afterFunc(resizeReconcileRetryDelay, func() { | |
| 168 | + | r.runTick(session) | |
| 169 | + | }) | |
| 170 | + | r.mu.Lock() | |
| 171 | + | if r.session == session { | |
| 172 | + | session.timer = timer | |
| 173 | + | } else if timer != nil { | |
| 174 | + | timer.stop() | |
| 175 | + | } | |
| 176 | + | r.mu.Unlock() | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func (r *resizeReconciler) runTick(session *resizeReconcileSession) { | |
| 180 | + | if r == nil || session == nil { | |
| 181 | + | return | |
| 182 | + | } | |
| 183 | + | now := r.clock() | |
| 184 | + | ||
| 185 | + | r.mu.Lock() | |
| 186 | + | if r.session != session { | |
| 187 | + | r.mu.Unlock() | |
| 188 | + | return | |
| 189 | + | } | |
| 190 | + | if session.destroyed != nil && session.destroyed() { | |
| 191 | + | r.session = nil | |
| 192 | + | r.mu.Unlock() | |
| 193 | + | return | |
| 194 | + | } | |
| 195 | + | host := session.hostGetter() | |
| 196 | + | if host == nil { | |
| 197 | + | r.session = nil | |
| 198 | + | r.mu.Unlock() | |
| 199 | + | return | |
| 200 | + | } | |
| 201 | + | elapsed := now.Sub(session.startedAt) | |
| 202 | + | if elapsed > resizeReconcileWindow { | |
| 203 | + | r.session = nil | |
| 204 | + | retryCount := session.retryCount | |
| 205 | + | r.mu.Unlock() | |
| 206 | + | r.logStopped("expired", session.seq, retryCount, elapsed.Milliseconds()) | |
| 207 | + | return | |
| 208 | + | } | |
| 209 | + | session.retryCount++ | |
| 210 | + | retryCount := session.retryCount | |
| 211 | + | seq := session.seq | |
| 212 | + | r.mu.Unlock() | |
| 213 | + | ||
| 214 | + | host.Invalidate(purecef.PaintElementTypePetView) | |
| 215 | + | r.logRetry(seq, retryCount, elapsed.Milliseconds()) | |
| 216 | + | ||
| 217 | + | elapsedAfterInvalidate := r.clock().Sub(session.startedAt) | |
| 218 | + | r.mu.Lock() | |
| 219 | + | if r.session != session { | |
| 220 | + | r.mu.Unlock() | |
| 221 | + | return | |
| 222 | + | } | |
| 223 | + | if elapsedAfterInvalidate >= resizeReconcileWindow { | |
| 224 | + | r.session = nil | |
| 225 | + | r.mu.Unlock() | |
| 226 | + | r.logStopped("expired", seq, retryCount, elapsedAfterInvalidate.Milliseconds()) | |
| 227 | + | return | |
| 228 | + | } | |
| 229 | + | newTimer := r.schedulerOrDefault().afterFunc(resizeReconcileRetryDelay, func() { | |
| 230 | + | r.runTick(session) | |
| 231 | + | }) | |
| 232 | + | session.timer = newTimer | |
| 233 | + | r.mu.Unlock() | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | func (r *resizeReconciler) logStarted(seq uint64) { | |
| 237 | + | if r == nil || r.ctx == nil { | |
| 238 | + | return | |
| 239 | + | } | |
| 240 | + | logging.FromContext(r.ctx).Debug(). | |
| 241 | + | Uint64("webview_id", uint64(r.webviewID)). | |
| 242 | + | Uint64("resize_seq", seq). | |
| 243 | + | Int("retry_count", 0). | |
| 244 | + | Int64("elapsed_ms", 0). | |
| 245 | + | Msg("cef: resize reconciliation started") | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func (r *resizeReconciler) logRetry(seq uint64, retryCount int, elapsedMs int64) { | |
| 249 | + | if r == nil || r.ctx == nil { | |
| 250 | + | return | |
| 251 | + | } | |
| 252 | + | logging.FromContext(r.ctx).Debug(). | |
| 253 | + | Uint64("webview_id", uint64(r.webviewID)). | |
| 254 | + | Uint64("resize_seq", seq). | |
| 255 | + | Int("retry_count", retryCount). | |
| 256 | + | Int64("elapsed_ms", elapsedMs). | |
| 257 | + | Msg("cef: resize reconciliation retry") | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func (r *resizeReconciler) logStopped(reason string, seq uint64, retryCount int, elapsedMs int64) { | |
| 261 | + | if r == nil || r.ctx == nil { | |
| 262 | + | return | |
| 263 | + | } | |
| 264 | + | logging.FromContext(r.ctx).Debug(). | |
| 265 | + | Uint64("webview_id", uint64(r.webviewID)). | |
| 266 | + | Uint64("resize_seq", seq). | |
| 267 | + | Int("retry_count", retryCount). | |
| 268 | + | Int64("elapsed_ms", elapsedMs). | |
| 269 | + | Str("reason", reason). | |
| 270 | + | Msg("cef: resize reconciliation stopped") | |
| 271 | + | } | |
| @@ -0,0 +1,271 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "sync" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 9 | + | purecef "github.com/bnema/purego-cef/cef" | |||
| 10 | + | "github.com/bnema/puregotk/v4/glib" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/dumber/internal/logging" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | const ( | |||
| 16 | + | resizeReconcileRetryDelay = 8 * time.Millisecond | |||
| 17 | + | resizeReconcileWindow = 64 * time.Millisecond | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | type resizeReconcileTimer interface { | |||
| 21 | + | stop() | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | type resizeReconcileScheduler interface { | |||
| 25 | + | afterFunc(delay time.Duration, fn func()) resizeReconcileTimer | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | type glibResizeTimer struct{ id uint } | |||
| 29 | + | ||||
| 30 | + | func (t *glibResizeTimer) stop() { | |||
| 31 | + | if t == nil || t.id == 0 { | |||
| 32 | + | return | |||
| 33 | + | } | |||
| 34 | + | glib.SourceRemove(t.id) | |||
| 35 | + | t.id = 0 | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | type glibResizeScheduler struct{} | |||
| 39 | + | ||||
| 40 | + | func (glibResizeScheduler) afterFunc(delay time.Duration, fn func()) resizeReconcileTimer { | |||
| 41 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 42 | + | if fn != nil { | |||
| 43 | + | fn() | |||
| 44 | + | } | |||
| 45 | + | return false | |||
| 46 | + | }) | |||
| 47 | + | return &glibResizeTimer{id: glib.TimeoutAdd(uint(delay.Milliseconds()), &cb, 0)} | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | type resizeReconciler struct { | |||
| 51 | + | mu sync.Mutex | |||
| 52 | + | webviewID port.WebViewID | |||
| 53 | + | ctx context.Context | |||
| 54 | + | scheduler resizeReconcileScheduler | |||
| 55 | + | now func() time.Time | |||
| 56 | + | session *resizeReconcileSession | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | type resizeReconcileSession struct { | |||
| 60 | + | seq uint64 | |||
| 61 | + | startedAt time.Time | |||
| 62 | + | retryCount int | |||
| 63 | + | timer resizeReconcileTimer | |||
| 64 | + | hostGetter func() resizeNotifiableBrowserHost | |||
| 65 | + | destroyed func() bool | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func newResizeReconciler(ctx context.Context, webviewID port.WebViewID) *resizeReconciler { | |||
| 69 | + | return &resizeReconciler{ | |||
| 70 | + | webviewID: webviewID, | |||
| 71 | + | ctx: ctx, | |||
| 72 | + | scheduler: glibResizeScheduler{}, | |||
| 73 | + | now: time.Now, | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func (r *resizeReconciler) clock() time.Time { | |||
| 78 | + | if r != nil && r.now != nil { | |||
| 79 | + | return r.now() | |||
| 80 | + | } | |||
| 81 | + | return time.Now() | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func (r *resizeReconciler) schedulerOrDefault() resizeReconcileScheduler { | |||
| 85 | + | if r != nil && r.scheduler != nil { | |||
| 86 | + | return r.scheduler | |||
| 87 | + | } | |||
| 88 | + | return glibResizeScheduler{} | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func (r *resizeReconciler) start(seq uint64, hostGetter func() resizeNotifiableBrowserHost, destroyed func() bool) { | |||
| 92 | + | if r == nil || seq == 0 || hostGetter == nil { | |||
| 93 | + | return | |||
| 94 | + | } | |||
| 95 | + | if destroyed != nil && destroyed() { | |||
| 96 | + | return | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | now := r.clock() | |||
| 100 | + | var superseded *resizeReconcileSession | |||
| 101 | + | ||||
| 102 | + | r.mu.Lock() | |||
| 103 | + | if r.session != nil { | |||
| 104 | + | superseded = r.session | |||
| 105 | + | if superseded.timer != nil { | |||
| 106 | + | superseded.timer.stop() | |||
| 107 | + | } | |||
| 108 | + | r.session = nil | |||
| 109 | + | } | |||
| 110 | + | session := &resizeReconcileSession{ | |||
| 111 | + | seq: seq, | |||
| 112 | + | startedAt: now, | |||
| 113 | + | hostGetter: hostGetter, | |||
| 114 | + | destroyed: destroyed, | |||
| 115 | + | } | |||
| 116 | + | r.session = session | |||
| 117 | + | r.mu.Unlock() | |||
| 118 | + | ||||
| 119 | + | if superseded != nil { | |||
| 120 | + | r.logStopped("superseded", superseded.seq, superseded.retryCount, now.Sub(superseded.startedAt).Milliseconds()) | |||
| 121 | + | } | |||
| 122 | + | r.logStarted(seq) | |||
| 123 | + | r.schedule(session) | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func (r *resizeReconciler) notePaint(seq uint64, sizeMatches bool) { | |||
| 127 | + | if r == nil || seq == 0 || !sizeMatches { | |||
| 128 | + | return | |||
| 129 | + | } | |||
| 130 | + | now := r.clock() | |||
| 131 | + | ||||
| 132 | + | r.mu.Lock() | |||
| 133 | + | session := r.session | |||
| 134 | + | if session == nil || session.seq != seq { | |||
| 135 | + | r.mu.Unlock() | |||
| 136 | + | return | |||
| 137 | + | } | |||
| 138 | + | if session.timer != nil { | |||
| 139 | + | session.timer.stop() | |||
| 140 | + | session.timer = nil | |||
| 141 | + | } | |||
| 142 | + | r.session = nil | |||
| 143 | + | retryCount := session.retryCount | |||
| 144 | + | elapsed := now.Sub(session.startedAt).Milliseconds() | |||
| 145 | + | r.mu.Unlock() | |||
| 146 | + | ||||
| 147 | + | r.logStopped("paint-arrived", seq, retryCount, elapsed) | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func (r *resizeReconciler) stop() { | |||
| 151 | + | if r == nil { | |||
| 152 | + | return | |||
| 153 | + | } | |||
| 154 | + | r.mu.Lock() | |||
| 155 | + | session := r.session | |||
| 156 | + | r.session = nil | |||
| 157 | + | r.mu.Unlock() | |||
| 158 | + | if session != nil && session.timer != nil { | |||
| 159 | + | session.timer.stop() | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func (r *resizeReconciler) schedule(session *resizeReconcileSession) { | |||
| 164 | + | if r == nil || session == nil { | |||
| 165 | + | return | |||
| 166 | + | } | |||
| 167 | + | timer := r.schedulerOrDefault().afterFunc(resizeReconcileRetryDelay, func() { | |||
| 168 | + | r.runTick(session) | |||
| 169 | + | }) | |||
| 170 | + | r.mu.Lock() | |||
| 171 | + | if r.session == session { | |||
| 172 | + | session.timer = timer | |||
| 173 | + | } else if timer != nil { | |||
| 174 | + | timer.stop() | |||
| 175 | + | } | |||
| 176 | + | r.mu.Unlock() | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func (r *resizeReconciler) runTick(session *resizeReconcileSession) { | |||
| 180 | + | if r == nil || session == nil { | |||
| 181 | + | return | |||
| 182 | + | } | |||
| 183 | + | now := r.clock() | |||
| 184 | + | ||||
| 185 | + | r.mu.Lock() | |||
| 186 | + | if r.session != session { | |||
| 187 | + | r.mu.Unlock() | |||
| 188 | + | return | |||
| 189 | + | } | |||
| 190 | + | if session.destroyed != nil && session.destroyed() { | |||
| 191 | + | r.session = nil | |||
| 192 | + | r.mu.Unlock() | |||
| 193 | + | return | |||
| 194 | + | } | |||
| 195 | + | host := session.hostGetter() | |||
| 196 | + | if host == nil { | |||
| 197 | + | r.session = nil | |||
| 198 | + | r.mu.Unlock() | |||
| 199 | + | return | |||
| 200 | + | } | |||
| 201 | + | elapsed := now.Sub(session.startedAt) | |||
| 202 | + | if elapsed > resizeReconcileWindow { | |||
| 203 | + | r.session = nil | |||
| 204 | + | retryCount := session.retryCount | |||
| 205 | + | r.mu.Unlock() | |||
| 206 | + | r.logStopped("expired", session.seq, retryCount, elapsed.Milliseconds()) | |||
| 207 | + | return | |||
| 208 | + | } | |||
| 209 | + | session.retryCount++ | |||
| 210 | + | retryCount := session.retryCount | |||
| 211 | + | seq := session.seq | |||
| 212 | + | r.mu.Unlock() | |||
| 213 | + | ||||
| 214 | + | host.Invalidate(purecef.PaintElementTypePetView) | |||
| 215 | + | r.logRetry(seq, retryCount, elapsed.Milliseconds()) | |||
| 216 | + | ||||
| 217 | + | elapsedAfterInvalidate := r.clock().Sub(session.startedAt) | |||
| 218 | + | r.mu.Lock() | |||
| 219 | + | if r.session != session { | |||
| 220 | + | r.mu.Unlock() | |||
| 221 | + | return | |||
| 222 | + | } | |||
| 223 | + | if elapsedAfterInvalidate >= resizeReconcileWindow { | |||
| 224 | + | r.session = nil | |||
| 225 | + | r.mu.Unlock() | |||
| 226 | + | r.logStopped("expired", seq, retryCount, elapsedAfterInvalidate.Milliseconds()) | |||
| 227 | + | return | |||
| 228 | + | } | |||
| 229 | + | newTimer := r.schedulerOrDefault().afterFunc(resizeReconcileRetryDelay, func() { | |||
| 230 | + | r.runTick(session) | |||
| 231 | + | }) | |||
| 232 | + | session.timer = newTimer | |||
| 233 | + | r.mu.Unlock() | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | func (r *resizeReconciler) logStarted(seq uint64) { | |||
| 237 | + | if r == nil || r.ctx == nil { | |||
| 238 | + | return | |||
| 239 | + | } | |||
| 240 | + | logging.FromContext(r.ctx).Debug(). | |||
| 241 | + | Uint64("webview_id", uint64(r.webviewID)). | |||
| 242 | + | Uint64("resize_seq", seq). | |||
| 243 | + | Int("retry_count", 0). | |||
| 244 | + | Int64("elapsed_ms", 0). | |||
| 245 | + | Msg("cef: resize reconciliation started") | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | func (r *resizeReconciler) logRetry(seq uint64, retryCount int, elapsedMs int64) { | |||
| 249 | + | if r == nil || r.ctx == nil { | |||
| 250 | + | return | |||
| 251 | + | } | |||
| 252 | + | logging.FromContext(r.ctx).Debug(). | |||
| 253 | + | Uint64("webview_id", uint64(r.webviewID)). | |||
| 254 | + | Uint64("resize_seq", seq). | |||
| 255 | + | Int("retry_count", retryCount). | |||
| 256 | + | Int64("elapsed_ms", elapsedMs). | |||
| 257 | + | Msg("cef: resize reconciliation retry") | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | func (r *resizeReconciler) logStopped(reason string, seq uint64, retryCount int, elapsedMs int64) { | |||
| 261 | + | if r == nil || r.ctx == nil { | |||
| 262 | + | return | |||
| 263 | + | } | |||
| 264 | + | logging.FromContext(r.ctx).Debug(). | |||
| 265 | + | Uint64("webview_id", uint64(r.webviewID)). | |||
| 266 | + | Uint64("resize_seq", seq). | |||
| 267 | + | Int("retry_count", retryCount). | |||
| 268 | + | Int64("elapsed_ms", elapsedMs). | |||
| 269 | + | Str("reason", reason). | |||
| 270 | + | Msg("cef: resize reconciliation stopped") | |||
| 271 | + | } | |||
A internal/infrastructure/cef/resize_reconciler_test.go +192 -0
| @@ -0,0 +1,192 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type resizeHostRecorder struct { | |
| 14 | + | mu sync.Mutex | |
| 15 | + | invalidations []purecef.PaintElementType | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func (h *resizeHostRecorder) WasResized() {} | |
| 19 | + | ||
| 20 | + | func (h *resizeHostRecorder) Invalidate(elementType purecef.PaintElementType) { | |
| 21 | + | h.mu.Lock() | |
| 22 | + | defer h.mu.Unlock() | |
| 23 | + | h.invalidations = append(h.invalidations, elementType) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (h *resizeHostRecorder) invalidationCount() int { | |
| 27 | + | h.mu.Lock() | |
| 28 | + | defer h.mu.Unlock() | |
| 29 | + | return len(h.invalidations) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | type fakeResizeTimer struct { | |
| 33 | + | id uint | |
| 34 | + | cancel func() | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (t *fakeResizeTimer) stop() { | |
| 38 | + | if t != nil && t.cancel != nil { | |
| 39 | + | t.cancel() | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type fakeResizeScheduler struct { | |
| 44 | + | mu sync.Mutex | |
| 45 | + | nextID uint | |
| 46 | + | timers map[uint]func() | |
| 47 | + | delays []time.Duration | |
| 48 | + | stopped map[uint]bool | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func newFakeResizeScheduler() *fakeResizeScheduler { | |
| 52 | + | return &fakeResizeScheduler{ | |
| 53 | + | timers: make(map[uint]func()), | |
| 54 | + | stopped: make(map[uint]bool), | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func (s *fakeResizeScheduler) afterFunc(delay time.Duration, fn func()) resizeReconcileTimer { | |
| 59 | + | s.mu.Lock() | |
| 60 | + | defer s.mu.Unlock() | |
| 61 | + | s.nextID++ | |
| 62 | + | id := s.nextID | |
| 63 | + | s.timers[id] = fn | |
| 64 | + | s.delays = append(s.delays, delay) | |
| 65 | + | return &fakeResizeTimer{ | |
| 66 | + | id: id, | |
| 67 | + | cancel: func() { | |
| 68 | + | s.mu.Lock() | |
| 69 | + | defer s.mu.Unlock() | |
| 70 | + | s.stopped[id] = true | |
| 71 | + | }, | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func (s *fakeResizeScheduler) fireLast() { | |
| 76 | + | s.mu.Lock() | |
| 77 | + | id := s.nextID | |
| 78 | + | fn := s.timers[id] | |
| 79 | + | stopped := s.stopped[id] | |
| 80 | + | s.mu.Unlock() | |
| 81 | + | if fn == nil || stopped { | |
| 82 | + | return | |
| 83 | + | } | |
| 84 | + | fn() | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func (s *fakeResizeScheduler) delayCount() int { | |
| 88 | + | s.mu.Lock() | |
| 89 | + | defer s.mu.Unlock() | |
| 90 | + | return len(s.delays) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestResizeReconciler_SchedulesRetriesWhenNoPaintObserved(t *testing.T) { | |
| 94 | + | clock := time.Unix(0, 0) | |
| 95 | + | scheduler := newFakeResizeScheduler() | |
| 96 | + | host := &resizeHostRecorder{} | |
| 97 | + | rr := &resizeReconciler{ | |
| 98 | + | ctx: context.Background(), | |
| 99 | + | scheduler: scheduler, | |
| 100 | + | now: func() time.Time { return clock }, | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | rr.start(7, func() resizeNotifiableBrowserHost { return host }, func() bool { return false }) | |
| 104 | + | require.Equal(t, 1, scheduler.delayCount()) | |
| 105 | + | require.Equal(t, 0, host.invalidationCount()) | |
| 106 | + | ||
| 107 | + | clock = clock.Add(8 * time.Millisecond) | |
| 108 | + | scheduler.fireLast() | |
| 109 | + | require.Equal(t, 1, host.invalidationCount()) | |
| 110 | + | require.Equal(t, 2, scheduler.delayCount()) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestResizeReconciler_StopsAfterMatchingPaintArrives(t *testing.T) { | |
| 114 | + | clock := time.Unix(0, 0) | |
| 115 | + | scheduler := newFakeResizeScheduler() | |
| 116 | + | host := &resizeHostRecorder{} | |
| 117 | + | rr := &resizeReconciler{ | |
| 118 | + | ctx: context.Background(), | |
| 119 | + | scheduler: scheduler, | |
| 120 | + | now: func() time.Time { return clock }, | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | rr.start(9, func() resizeNotifiableBrowserHost { return host }, func() bool { return false }) | |
| 124 | + | rr.notePaint(9, true) | |
| 125 | + | ||
| 126 | + | clock = clock.Add(8 * time.Millisecond) | |
| 127 | + | scheduler.fireLast() | |
| 128 | + | require.Zero(t, host.invalidationCount()) | |
| 129 | + | require.Equal(t, 1, scheduler.delayCount()) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestResizeReconciler_StaleSizePaintDoesNotStopRetries(t *testing.T) { | |
| 133 | + | clock := time.Unix(0, 0) | |
| 134 | + | scheduler := newFakeResizeScheduler() | |
| 135 | + | host := &resizeHostRecorder{} | |
| 136 | + | rr := &resizeReconciler{ | |
| 137 | + | ctx: context.Background(), | |
| 138 | + | scheduler: scheduler, | |
| 139 | + | now: func() time.Time { return clock }, | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | rr.start(10, func() resizeNotifiableBrowserHost { return host }, func() bool { return false }) | |
| 143 | + | rr.notePaint(10, false) | |
| 144 | + | ||
| 145 | + | clock = clock.Add(8 * time.Millisecond) | |
| 146 | + | scheduler.fireLast() | |
| 147 | + | require.Equal(t, 1, host.invalidationCount()) | |
| 148 | + | require.Equal(t, 2, scheduler.delayCount()) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestResizeReconciler_NewerResizeSupersedesOlderSession(t *testing.T) { | |
| 152 | + | clock := time.Unix(0, 0) | |
| 153 | + | scheduler := newFakeResizeScheduler() | |
| 154 | + | host := &resizeHostRecorder{} | |
| 155 | + | rr := &resizeReconciler{ | |
| 156 | + | ctx: context.Background(), | |
| 157 | + | scheduler: scheduler, | |
| 158 | + | now: func() time.Time { return clock }, | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | rr.start(1, func() resizeNotifiableBrowserHost { return host }, func() bool { return false }) | |
| 162 | + | firstDelayCount := scheduler.delayCount() | |
| 163 | + | rr.start(2, func() resizeNotifiableBrowserHost { return host }, func() bool { return false }) | |
| 164 | + | require.Equal(t, firstDelayCount+1, scheduler.delayCount()) | |
| 165 | + | ||
| 166 | + | clock = clock.Add(8 * time.Millisecond) | |
| 167 | + | scheduler.fireLast() | |
| 168 | + | require.Equal(t, 1, host.invalidationCount()) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func TestResizeReconciler_ExpiresAfter64ms(t *testing.T) { | |
| 172 | + | clock := time.Unix(0, 0) | |
| 173 | + | scheduler := newFakeResizeScheduler() | |
| 174 | + | host := &resizeHostRecorder{} | |
| 175 | + | rr := &resizeReconciler{ | |
| 176 | + | ctx: context.Background(), | |
| 177 | + | scheduler: scheduler, | |
| 178 | + | now: func() time.Time { return clock }, | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | rr.start(11, func() resizeNotifiableBrowserHost { return host }, func() bool { return false }) | |
| 182 | + | for i := 0; i < 8; i++ { | |
| 183 | + | clock = clock.Add(8 * time.Millisecond) | |
| 184 | + | scheduler.fireLast() | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | require.Equal(t, 8, host.invalidationCount()) | |
| 188 | + | require.Equal(t, 8, scheduler.delayCount()) | |
| 189 | + | clock = clock.Add(8 * time.Millisecond) | |
| 190 | + | scheduler.fireLast() | |
| 191 | + | require.Equal(t, 8, host.invalidationCount()) | |
| 192 | + | } | |
| @@ -0,0 +1,192 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "sync" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | purecef "github.com/bnema/purego-cef/cef" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | type resizeHostRecorder struct { | |||
| 14 | + | mu sync.Mutex | |||
| 15 | + | invalidations []purecef.PaintElementType | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func (h *resizeHostRecorder) WasResized() {} | |||
| 19 | + | ||||
| 20 | + | func (h *resizeHostRecorder) Invalidate(elementType purecef.PaintElementType) { | |||
| 21 | + | h.mu.Lock() | |||
| 22 | + | defer h.mu.Unlock() | |||
| 23 | + | h.invalidations = append(h.invalidations, elementType) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func (h *resizeHostRecorder) invalidationCount() int { | |||
| 27 | + | h.mu.Lock() | |||
| 28 | + | defer h.mu.Unlock() | |||
| 29 | + | return len(h.invalidations) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | type fakeResizeTimer struct { | |||
| 33 | + | id uint | |||
| 34 | + | cancel func() | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func (t *fakeResizeTimer) stop() { | |||
| 38 | + | if t != nil && t.cancel != nil { | |||
| 39 | + | t.cancel() | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | type fakeResizeScheduler struct { | |||
| 44 | + | mu sync.Mutex | |||
| 45 | + | nextID uint | |||
| 46 | + | timers map[uint]func() | |||
| 47 | + | delays []time.Duration | |||
| 48 | + | stopped map[uint]bool | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func newFakeResizeScheduler() *fakeResizeScheduler { | |||
| 52 | + | return &fakeResizeScheduler{ | |||
| 53 | + | timers: make(map[uint]func()), | |||
| 54 | + | stopped: make(map[uint]bool), | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func (s *fakeResizeScheduler) afterFunc(delay time.Duration, fn func()) resizeReconcileTimer { | |||
| 59 | + | s.mu.Lock() | |||
| 60 | + | defer s.mu.Unlock() | |||
| 61 | + | s.nextID++ | |||
| 62 | + | id := s.nextID | |||
| 63 | + | s.timers[id] = fn | |||
| 64 | + | s.delays = append(s.delays, delay) | |||
| 65 | + | return &fakeResizeTimer{ | |||
| 66 | + | id: id, | |||
| 67 | + | cancel: func() { | |||
| 68 | + | s.mu.Lock() | |||
| 69 | + | defer s.mu.Unlock() | |||
| 70 | + | s.stopped[id] = true | |||
| 71 | + | }, | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func (s *fakeResizeScheduler) fireLast() { | |||
| 76 | + | s.mu.Lock() | |||
| 77 | + | id := s.nextID | |||
| 78 | + | fn := s.timers[id] | |||
| 79 | + | stopped := s.stopped[id] | |||
| 80 | + | s.mu.Unlock() | |||
| 81 | + | if fn == nil || stopped { | |||
| 82 | + | return | |||
| 83 | + | } | |||
| 84 | + | fn() | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func (s *fakeResizeScheduler) delayCount() int { | |||
| 88 | + | s.mu.Lock() | |||
| 89 | + | defer s.mu.Unlock() | |||
| 90 | + | return len(s.delays) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestResizeReconciler_SchedulesRetriesWhenNoPaintObserved(t *testing.T) { | |||
| 94 | + | clock := time.Unix(0, 0) | |||
| 95 | + | scheduler := newFakeResizeScheduler() | |||
| 96 | + | host := &resizeHostRecorder{} | |||
| 97 | + | rr := &resizeReconciler{ | |||
| 98 | + | ctx: context.Background(), | |||
| 99 | + | scheduler: scheduler, | |||
| 100 | + | now: func() time.Time { return clock }, | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | rr.start(7, func() resizeNotifiableBrowserHost { return host }, func() bool { return false }) | |||
| 104 | + | require.Equal(t, 1, scheduler.delayCount()) | |||
| 105 | + | require.Equal(t, 0, host.invalidationCount()) | |||
| 106 | + | ||||
| 107 | + | clock = clock.Add(8 * time.Millisecond) | |||
| 108 | + | scheduler.fireLast() | |||
| 109 | + | require.Equal(t, 1, host.invalidationCount()) | |||
| 110 | + | require.Equal(t, 2, scheduler.delayCount()) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestResizeReconciler_StopsAfterMatchingPaintArrives(t *testing.T) { | |||
| 114 | + | clock := time.Unix(0, 0) | |||
| 115 | + | scheduler := newFakeResizeScheduler() | |||
| 116 | + | host := &resizeHostRecorder{} | |||
| 117 | + | rr := &resizeReconciler{ | |||
| 118 | + | ctx: context.Background(), | |||
| 119 | + | scheduler: scheduler, | |||
| 120 | + | now: func() time.Time { return clock }, | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | rr.start(9, func() resizeNotifiableBrowserHost { return host }, func() bool { return false }) | |||
| 124 | + | rr.notePaint(9, true) | |||
| 125 | + | ||||
| 126 | + | clock = clock.Add(8 * time.Millisecond) | |||
| 127 | + | scheduler.fireLast() | |||
| 128 | + | require.Zero(t, host.invalidationCount()) | |||
| 129 | + | require.Equal(t, 1, scheduler.delayCount()) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func TestResizeReconciler_StaleSizePaintDoesNotStopRetries(t *testing.T) { | |||
| 133 | + | clock := time.Unix(0, 0) | |||
| 134 | + | scheduler := newFakeResizeScheduler() | |||
| 135 | + | host := &resizeHostRecorder{} | |||
| 136 | + | rr := &resizeReconciler{ | |||
| 137 | + | ctx: context.Background(), | |||
| 138 | + | scheduler: scheduler, | |||
| 139 | + | now: func() time.Time { return clock }, | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | rr.start(10, func() resizeNotifiableBrowserHost { return host }, func() bool { return false }) | |||
| 143 | + | rr.notePaint(10, false) | |||
| 144 | + | ||||
| 145 | + | clock = clock.Add(8 * time.Millisecond) | |||
| 146 | + | scheduler.fireLast() | |||
| 147 | + | require.Equal(t, 1, host.invalidationCount()) | |||
| 148 | + | require.Equal(t, 2, scheduler.delayCount()) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func TestResizeReconciler_NewerResizeSupersedesOlderSession(t *testing.T) { | |||
| 152 | + | clock := time.Unix(0, 0) | |||
| 153 | + | scheduler := newFakeResizeScheduler() | |||
| 154 | + | host := &resizeHostRecorder{} | |||
| 155 | + | rr := &resizeReconciler{ | |||
| 156 | + | ctx: context.Background(), | |||
| 157 | + | scheduler: scheduler, | |||
| 158 | + | now: func() time.Time { return clock }, | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | rr.start(1, func() resizeNotifiableBrowserHost { return host }, func() bool { return false }) | |||
| 162 | + | firstDelayCount := scheduler.delayCount() | |||
| 163 | + | rr.start(2, func() resizeNotifiableBrowserHost { return host }, func() bool { return false }) | |||
| 164 | + | require.Equal(t, firstDelayCount+1, scheduler.delayCount()) | |||
| 165 | + | ||||
| 166 | + | clock = clock.Add(8 * time.Millisecond) | |||
| 167 | + | scheduler.fireLast() | |||
| 168 | + | require.Equal(t, 1, host.invalidationCount()) | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func TestResizeReconciler_ExpiresAfter64ms(t *testing.T) { | |||
| 172 | + | clock := time.Unix(0, 0) | |||
| 173 | + | scheduler := newFakeResizeScheduler() | |||
| 174 | + | host := &resizeHostRecorder{} | |||
| 175 | + | rr := &resizeReconciler{ | |||
| 176 | + | ctx: context.Background(), | |||
| 177 | + | scheduler: scheduler, | |||
| 178 | + | now: func() time.Time { return clock }, | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | rr.start(11, func() resizeNotifiableBrowserHost { return host }, func() bool { return false }) | |||
| 182 | + | for i := 0; i < 8; i++ { | |||
| 183 | + | clock = clock.Add(8 * time.Millisecond) | |||
| 184 | + | scheduler.fireLast() | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | require.Equal(t, 8, host.invalidationCount()) | |||
| 188 | + | require.Equal(t, 8, scheduler.delayCount()) | |||
| 189 | + | clock = clock.Add(8 * time.Millisecond) | |||
| 190 | + | scheduler.fireLast() | |||
| 191 | + | require.Equal(t, 8, host.invalidationCount()) | |||
| 192 | + | } | |||
M internal/infrastructure/cef/webview.go +12 -10
| @@ -33,16 +33,17 @@ var errNoBrowser = errors.New("cef: browser not yet created") | |||
| 33 | 33 | // WebView implements port.WebView using a CEF off-screen browser rendered | |
| 34 | 34 | // through a renderPipeline and driven by an inputBridge. | |
| 35 | 35 | type WebView struct { | |
| 36 | - | id port.WebViewID | |
| 37 | - | ctx context.Context | |
| 38 | - | engine *Engine | |
| 39 | - | browser purecef.Browser | |
| 40 | - | host purecef.BrowserHost | |
| 41 | - | client purecef.Client // prevent GC from collecting the client before CEF AddRef's it | |
| 42 | - | pipeline *renderPipeline | |
| 43 | - | input *inputBridge | |
| 44 | - | handlers *handlerSet | |
| 45 | - | findCtrl *cefFindController | |
| 36 | + | id port.WebViewID | |
| 37 | + | ctx context.Context | |
| 38 | + | engine *Engine | |
| 39 | + | browser purecef.Browser | |
| 40 | + | host purecef.BrowserHost | |
| 41 | + | client purecef.Client // prevent GC from collecting the client before CEF AddRef's it | |
| 42 | + | pipeline *renderPipeline | |
| 43 | + | input *inputBridge | |
| 44 | + | handlers *handlerSet | |
| 45 | + | findCtrl *cefFindController | |
| 46 | + | resizeReconciler *resizeReconciler | |
| 46 | 47 | ||
| 47 | 48 | // beginFrameTick drives CEF external BeginFrame requests while the GTK | |
| 48 | 49 | // widget is visible. Access is guarded by mu. | |
| @@ -472,6 +473,7 @@ func (wv *WebView) Destroy() { | |||
| 472 | 473 | if !wv.destroyed.CompareAndSwap(false, true) { | |
| 473 | 474 | return | |
| 474 | 475 | } | |
| 476 | + | wv.resizeReconciler.stop() | |
| 475 | 477 | wv.closeAudioStream() | |
| 476 | 478 | wv.scheduleStopBeginFrameLoop() | |
| 477 | 479 | wv.mu.RLock() | |
| @@ -33,16 +33,17 @@ var errNoBrowser = errors.New("cef: browser not yet created") | |||||
| 33 | // WebView implements port.WebView using a CEF off-screen browser rendered | 33 | // WebView implements port.WebView using a CEF off-screen browser rendered | ||
| 34 | // through a renderPipeline and driven by an inputBridge. | 34 | // through a renderPipeline and driven by an inputBridge. | ||
| 35 | type WebView struct { | 35 | type WebView struct { | ||
| 36 | - | id port.WebViewID | 36 | + | id port.WebViewID |
| 37 | - | ctx context.Context | 37 | + | ctx context.Context |
| 38 | - | engine *Engine | 38 | + | engine *Engine |
| 39 | - | browser purecef.Browser | 39 | + | browser purecef.Browser |
| 40 | - | host purecef.BrowserHost | 40 | + | host purecef.BrowserHost |
| 41 | - | client purecef.Client // prevent GC from collecting the client before CEF AddRef's it | 41 | + | client purecef.Client // prevent GC from collecting the client before CEF AddRef's it |
| 42 | - | pipeline *renderPipeline | 42 | + | pipeline *renderPipeline |
| 43 | - | input *inputBridge | 43 | + | input *inputBridge |
| 44 | - | handlers *handlerSet | 44 | + | handlers *handlerSet |
| 45 | - | findCtrl *cefFindController | 45 | + | findCtrl *cefFindController |
| 46 | + | resizeReconciler *resizeReconciler | |||
| 46 | 47 | ||||
| 47 | // beginFrameTick drives CEF external BeginFrame requests while the GTK | 48 | // beginFrameTick drives CEF external BeginFrame requests while the GTK | ||
| 48 | // widget is visible. Access is guarded by mu. | 49 | // widget is visible. Access is guarded by mu. | ||
| @@ -472,6 +473,7 @@ func (wv *WebView) Destroy() { | |||||
| 472 | if !wv.destroyed.CompareAndSwap(false, true) { | 473 | if !wv.destroyed.CompareAndSwap(false, true) { | ||
| 473 | return | 474 | return | ||
| 474 | } | 475 | } | ||
| 476 | + | wv.resizeReconciler.stop() | |||
| 475 | wv.closeAudioStream() | 477 | wv.closeAudioStream() | ||
| 476 | wv.scheduleStopBeginFrameLoop() | 478 | wv.scheduleStopBeginFrameLoop() | ||
| 477 | wv.mu.RLock() | 479 | wv.mu.RLock() | ||
M internal/infrastructure/cef/webview_handlers.go +52 -3
| @@ -114,6 +114,7 @@ func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) { | |||
| 114 | 114 | rect.Height = ht | |
| 115 | 115 | if h.wv != nil && h.wv.ctx != nil { | |
| 116 | 116 | logging.FromContext(h.wv.ctx).Debug(). | |
| 117 | + | Uint64("webview_id", uint64(h.wv.id)). | |
| 117 | 118 | Uint64("view_rect_seq", callSeq). | |
| 118 | 119 | Int32("width", rect.Width). | |
| 119 | 120 | Int32("height", rect.Height). | |
| @@ -155,6 +156,7 @@ func (h *handlerSet) GetScreenInfo(_ purecef.Browser, info *purecef.ScreenInfo) | |||
| 155 | 156 | *info = si | |
| 156 | 157 | if h.wv != nil && h.wv.ctx != nil { | |
| 157 | 158 | logging.FromContext(h.wv.ctx).Debug(). | |
| 159 | + | Uint64("webview_id", uint64(h.wv.id)). | |
| 158 | 160 | Uint64("screen_info_seq", callSeq). | |
| 159 | 161 | Int32("width", w). | |
| 160 | 162 | Int32("height", ht). | |
| @@ -201,14 +203,60 @@ func (h *handlerSet) OnPaint( | |||
| 201 | 203 | return | |
| 202 | 204 | } | |
| 203 | 205 | paintSeq := h.wv.pipeline.nextPaintSeq() | |
| 206 | + | resizeSeq, resizeAgeMs := uint64(0), int64(0) | |
| 207 | + | if elementType != purecef.PaintElementTypePetPopup { | |
| 208 | + | resizeSeq, resizeAgeMs = h.wv.pipeline.latestResizeDiagnostics() | |
| 209 | + | } | |
| 204 | 210 | if h.wv != nil && h.wv.ctx != nil { | |
| 205 | - | logging.FromContext(h.wv.ctx).Trace(). | |
| 211 | + | log := logging.FromContext(h.wv.ctx).Trace(). | |
| 206 | 212 | Uint64("paint_seq", paintSeq). | |
| 207 | 213 | Int32("width", width). | |
| 208 | 214 | Int32("height", height). | |
| 209 | 215 | Int("dirty_rect_count", len(dirtyRects)). | |
| 210 | - | Int("buffer_len", len(buffer)). | |
| 211 | - | Msg("cef: OnPaint begin") | |
| 216 | + | Int("buffer_len", len(buffer)) | |
| 217 | + | if elementType != purecef.PaintElementTypePetPopup { | |
| 218 | + | log = log. | |
| 219 | + | Uint64("resize_seq", resizeSeq). | |
| 220 | + | Int64("time_since_resize_ms", resizeAgeMs) | |
| 221 | + | } | |
| 222 | + | log.Msg("cef: OnPaint begin") | |
| 223 | + | } | |
| 224 | + | if elementType != purecef.PaintElementTypePetPopup && h.wv != nil && h.wv.ctx != nil { | |
| 225 | + | resizeSeq, resizeAgeMs := h.wv.pipeline.latestResizeDiagnostics() | |
| 226 | + | expectedWidth, expectedHeight, _ := h.wv.pipeline.viewRectSize() | |
| 227 | + | sizeMatchesCurrentView := width == expectedWidth && height == expectedHeight | |
| 228 | + | if h.wv.resizeReconciler != nil { | |
| 229 | + | h.wv.resizeReconciler.notePaint(resizeSeq, sizeMatchesCurrentView) | |
| 230 | + | } | |
| 231 | + | if sizeMatchesCurrentView { | |
| 232 | + | _, _, shouldLog := h.wv.pipeline.markFirstPaintAfterResize() | |
| 233 | + | if shouldLog { | |
| 234 | + | logging.FromContext(h.wv.ctx).Debug(). | |
| 235 | + | Uint64("webview_id", uint64(h.wv.id)). | |
| 236 | + | Uint64("paint_seq", paintSeq). | |
| 237 | + | Uint64("resize_seq", resizeSeq). | |
| 238 | + | Int64("time_since_resize_ms", resizeAgeMs). | |
| 239 | + | Int32("width", width). | |
| 240 | + | Int32("height", height). | |
| 241 | + | Int("dirty_rect_count", len(dirtyRects)). | |
| 242 | + | Msg("cef: first paint after resize") | |
| 243 | + | } | |
| 244 | + | } else if h.wv.pipeline.markStalePaintAfterResize(resizeSeq) { | |
| 245 | + | logging.FromContext(h.wv.ctx).Debug(). | |
| 246 | + | Uint64("webview_id", uint64(h.wv.id)). | |
| 247 | + | Uint64("paint_seq", paintSeq). | |
| 248 | + | Uint64("resize_seq", resizeSeq). | |
| 249 | + | Int64("time_since_resize_ms", resizeAgeMs). | |
| 250 | + | Int32("width", width). | |
| 251 | + | Int32("height", height). | |
| 252 | + | Int32("expected_width", expectedWidth). | |
| 253 | + | Int32("expected_height", expectedHeight). | |
| 254 | + | Int("dirty_rect_count", len(dirtyRects)). | |
| 255 | + | Msg("cef: stale-size paint after resize") | |
| 256 | + | } | |
| 257 | + | if !sizeMatchesCurrentView { | |
| 258 | + | return | |
| 259 | + | } | |
| 212 | 260 | } | |
| 213 | 261 | rects := make([]rect, len(dirtyRects)) | |
| 214 | 262 | for i, dr := range dirtyRects { | |
| @@ -561,6 +609,7 @@ func (h *handlerSet) OnBeforeClose(_ purecef.Browser) { | |||
| 561 | 609 | h.wv.findCtrl.setHost(nil) | |
| 562 | 610 | } | |
| 563 | 611 | h.wv.mu.Unlock() | |
| 612 | + | h.wv.resizeReconciler.stop() | |
| 564 | 613 | h.wv.scheduleStopBeginFrameLoop() | |
| 565 | 614 | ||
| 566 | 615 | h.wv.mu.RLock() | |
| @@ -114,6 +114,7 @@ func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) { | |||||
| 114 | rect.Height = ht | 114 | rect.Height = ht | ||
| 115 | if h.wv != nil && h.wv.ctx != nil { | 115 | if h.wv != nil && h.wv.ctx != nil { | ||
| 116 | logging.FromContext(h.wv.ctx).Debug(). | 116 | logging.FromContext(h.wv.ctx).Debug(). | ||
| 117 | + | Uint64("webview_id", uint64(h.wv.id)). | |||
| 117 | Uint64("view_rect_seq", callSeq). | 118 | Uint64("view_rect_seq", callSeq). | ||
| 118 | Int32("width", rect.Width). | 119 | Int32("width", rect.Width). | ||
| 119 | Int32("height", rect.Height). | 120 | Int32("height", rect.Height). | ||
| @@ -155,6 +156,7 @@ func (h *handlerSet) GetScreenInfo(_ purecef.Browser, info *purecef.ScreenInfo) | |||||
| 155 | *info = si | 156 | *info = si | ||
| 156 | if h.wv != nil && h.wv.ctx != nil { | 157 | if h.wv != nil && h.wv.ctx != nil { | ||
| 157 | logging.FromContext(h.wv.ctx).Debug(). | 158 | logging.FromContext(h.wv.ctx).Debug(). | ||
| 159 | + | Uint64("webview_id", uint64(h.wv.id)). | |||
| 158 | Uint64("screen_info_seq", callSeq). | 160 | Uint64("screen_info_seq", callSeq). | ||
| 159 | Int32("width", w). | 161 | Int32("width", w). | ||
| 160 | Int32("height", ht). | 162 | Int32("height", ht). | ||
| @@ -201,14 +203,60 @@ func (h *handlerSet) OnPaint( | |||||
| 201 | return | 203 | return | ||
| 202 | } | 204 | } | ||
| 203 | paintSeq := h.wv.pipeline.nextPaintSeq() | 205 | paintSeq := h.wv.pipeline.nextPaintSeq() | ||
| 206 | + | resizeSeq, resizeAgeMs := uint64(0), int64(0) | |||
| 207 | + | if elementType != purecef.PaintElementTypePetPopup { | |||
| 208 | + | resizeSeq, resizeAgeMs = h.wv.pipeline.latestResizeDiagnostics() | |||
| 209 | + | } | |||
| 204 | if h.wv != nil && h.wv.ctx != nil { | 210 | if h.wv != nil && h.wv.ctx != nil { | ||
| 205 | - | logging.FromContext(h.wv.ctx).Trace(). | 211 | + | log := logging.FromContext(h.wv.ctx).Trace(). |
| 206 | Uint64("paint_seq", paintSeq). | 212 | Uint64("paint_seq", paintSeq). | ||
| 207 | Int32("width", width). | 213 | Int32("width", width). | ||
| 208 | Int32("height", height). | 214 | Int32("height", height). | ||
| 209 | Int("dirty_rect_count", len(dirtyRects)). | 215 | Int("dirty_rect_count", len(dirtyRects)). | ||
| 210 | - | Int("buffer_len", len(buffer)). | 216 | + | Int("buffer_len", len(buffer)) |
| 211 | - | Msg("cef: OnPaint begin") | 217 | + | if elementType != purecef.PaintElementTypePetPopup { |
| 218 | + | log = log. | |||
| 219 | + | Uint64("resize_seq", resizeSeq). | |||
| 220 | + | Int64("time_since_resize_ms", resizeAgeMs) | |||
| 221 | + | } | |||
| 222 | + | log.Msg("cef: OnPaint begin") | |||
| 223 | + | } | |||
| 224 | + | if elementType != purecef.PaintElementTypePetPopup && h.wv != nil && h.wv.ctx != nil { | |||
| 225 | + | resizeSeq, resizeAgeMs := h.wv.pipeline.latestResizeDiagnostics() | |||
| 226 | + | expectedWidth, expectedHeight, _ := h.wv.pipeline.viewRectSize() | |||
| 227 | + | sizeMatchesCurrentView := width == expectedWidth && height == expectedHeight | |||
| 228 | + | if h.wv.resizeReconciler != nil { | |||
| 229 | + | h.wv.resizeReconciler.notePaint(resizeSeq, sizeMatchesCurrentView) | |||
| 230 | + | } | |||
| 231 | + | if sizeMatchesCurrentView { | |||
| 232 | + | _, _, shouldLog := h.wv.pipeline.markFirstPaintAfterResize() | |||
| 233 | + | if shouldLog { | |||
| 234 | + | logging.FromContext(h.wv.ctx).Debug(). | |||
| 235 | + | Uint64("webview_id", uint64(h.wv.id)). | |||
| 236 | + | Uint64("paint_seq", paintSeq). | |||
| 237 | + | Uint64("resize_seq", resizeSeq). | |||
| 238 | + | Int64("time_since_resize_ms", resizeAgeMs). | |||
| 239 | + | Int32("width", width). | |||
| 240 | + | Int32("height", height). | |||
| 241 | + | Int("dirty_rect_count", len(dirtyRects)). | |||
| 242 | + | Msg("cef: first paint after resize") | |||
| 243 | + | } | |||
| 244 | + | } else if h.wv.pipeline.markStalePaintAfterResize(resizeSeq) { | |||
| 245 | + | logging.FromContext(h.wv.ctx).Debug(). | |||
| 246 | + | Uint64("webview_id", uint64(h.wv.id)). | |||
| 247 | + | Uint64("paint_seq", paintSeq). | |||
| 248 | + | Uint64("resize_seq", resizeSeq). | |||
| 249 | + | Int64("time_since_resize_ms", resizeAgeMs). | |||
| 250 | + | Int32("width", width). | |||
| 251 | + | Int32("height", height). | |||
| 252 | + | Int32("expected_width", expectedWidth). | |||
| 253 | + | Int32("expected_height", expectedHeight). | |||
| 254 | + | Int("dirty_rect_count", len(dirtyRects)). | |||
| 255 | + | Msg("cef: stale-size paint after resize") | |||
| 256 | + | } | |||
| 257 | + | if !sizeMatchesCurrentView { | |||
| 258 | + | return | |||
| 259 | + | } | |||
| 212 | } | 260 | } | ||
| 213 | rects := make([]rect, len(dirtyRects)) | 261 | rects := make([]rect, len(dirtyRects)) | ||
| 214 | for i, dr := range dirtyRects { | 262 | for i, dr := range dirtyRects { | ||
| @@ -561,6 +609,7 @@ func (h *handlerSet) OnBeforeClose(_ purecef.Browser) { | |||||
| 561 | h.wv.findCtrl.setHost(nil) | 609 | h.wv.findCtrl.setHost(nil) | ||
| 562 | } | 610 | } | ||
| 563 | h.wv.mu.Unlock() | 611 | h.wv.mu.Unlock() | ||
| 612 | + | h.wv.resizeReconciler.stop() | |||
| 564 | h.wv.scheduleStopBeginFrameLoop() | 613 | h.wv.scheduleStopBeginFrameLoop() | ||
| 565 | 614 | ||||
| 566 | h.wv.mu.RLock() | 615 | h.wv.mu.RLock() | ||
M internal/infrastructure/cef/webview_handlers_test.go +25 -0
| @@ -137,3 +137,28 @@ func TestOptionalHandlersRespectFactoryFlags(t *testing.T) { | |||
| 137 | 137 | ||
| 138 | 138 | require.Same(t, h, h.GetContextMenuHandler()) | |
| 139 | 139 | } | |
| 140 | + | ||
| 141 | + | func TestOnPaint_DropsStaleMainViewPaint(t *testing.T) { | |
| 142 | + | wv := &WebView{ | |
| 143 | + | id: 42, | |
| 144 | + | ctx: context.Background(), | |
| 145 | + | pipeline: &renderPipeline{ | |
| 146 | + | ctx: context.Background(), | |
| 147 | + | scale: 1, | |
| 148 | + | }, | |
| 149 | + | } | |
| 150 | + | wv.pipeline.widthAtomic.Store(1269) | |
| 151 | + | wv.pipeline.heightAtomic.Store(1035) | |
| 152 | + | ||
| 153 | + | h := &handlerSet{wv: wv} | |
| 154 | + | buffer := make([]byte, 1269*2106*4) | |
| 155 | + | ||
| 156 | + | h.OnPaint(nil, purecef.PaintElementTypePetView, []purecef.Rect{{X: 0, Y: 0, Width: 1269, Height: 2106}}, buffer, 1269, 2106) | |
| 157 | + | ||
| 158 | + | require.Equal(t, int32(1269), wv.pipeline.widthAtomic.Load()) | |
| 159 | + | require.Equal(t, int32(1035), wv.pipeline.heightAtomic.Load()) | |
| 160 | + | require.Nil(t, wv.pipeline.staging) | |
| 161 | + | require.Zero(t, wv.pipeline.lastQueuedPaintSeq.Load()) | |
| 162 | + | require.False(t, wv.pipeline.needsUpload) | |
| 163 | + | require.Empty(t, wv.pipeline.dirtyRects) | |
| 164 | + | } | |
| @@ -137,3 +137,28 @@ func TestOptionalHandlersRespectFactoryFlags(t *testing.T) { | |||||
| 137 | 137 | ||||
| 138 | require.Same(t, h, h.GetContextMenuHandler()) | 138 | require.Same(t, h, h.GetContextMenuHandler()) | ||
| 139 | } | 139 | } | ||
| 140 | + | ||||
| 141 | + | func TestOnPaint_DropsStaleMainViewPaint(t *testing.T) { | |||
| 142 | + | wv := &WebView{ | |||
| 143 | + | id: 42, | |||
| 144 | + | ctx: context.Background(), | |||
| 145 | + | pipeline: &renderPipeline{ | |||
| 146 | + | ctx: context.Background(), | |||
| 147 | + | scale: 1, | |||
| 148 | + | }, | |||
| 149 | + | } | |||
| 150 | + | wv.pipeline.widthAtomic.Store(1269) | |||
| 151 | + | wv.pipeline.heightAtomic.Store(1035) | |||
| 152 | + | ||||
| 153 | + | h := &handlerSet{wv: wv} | |||
| 154 | + | buffer := make([]byte, 1269*2106*4) | |||
| 155 | + | ||||
| 156 | + | h.OnPaint(nil, purecef.PaintElementTypePetView, []purecef.Rect{{X: 0, Y: 0, Width: 1269, Height: 2106}}, buffer, 1269, 2106) | |||
| 157 | + | ||||
| 158 | + | require.Equal(t, int32(1269), wv.pipeline.widthAtomic.Load()) | |||
| 159 | + | require.Equal(t, int32(1035), wv.pipeline.heightAtomic.Load()) | |||
| 160 | + | require.Nil(t, wv.pipeline.staging) | |||
| 161 | + | require.Zero(t, wv.pipeline.lastQueuedPaintSeq.Load()) | |||
| 162 | + | require.False(t, wv.pipeline.needsUpload) | |||
| 163 | + | require.Empty(t, wv.pipeline.dirtyRects) | |||
| 164 | + | } | |||
M internal/ui/component/pane_view.go +21 -5
| @@ -148,14 +148,16 @@ func (pv *PaneView) SetWebViewWidget(widget layout.Widget) { | |||
| 148 | 148 | pv.webViewWidget = widget | |
| 149 | 149 | ||
| 150 | 150 | if widget != nil { | |
| 151 | + | overlayAllocWidthBefore := pv.overlay.GetAllocatedWidth() | |
| 152 | + | overlayAllocHeightBefore := pv.overlay.GetAllocatedHeight() | |
| 153 | + | widgetAllocWidthBefore := widget.GetAllocatedWidth() | |
| 154 | + | widgetAllocHeightBefore := widget.GetAllocatedHeight() | |
| 155 | + | hadParent := widget.GetParent() != nil | |
| 156 | + | wasVisible := widget.IsVisible() | |
| 157 | + | ||
| 151 | 158 | // If this widget already had a parent, we're reparenting an existing WebView | |
| 152 | 159 | // (e.g. after rebuilding/moving panes). In that case the WebView has likely | |
| 153 | 160 | // already painted, so the loading skeleton should be hidden immediately. | |
| 154 | - | hadParent := widget.GetParent() != nil | |
| 155 | - | wasVisible := false | |
| 156 | - | if hadParent { | |
| 157 | - | wasVisible = widget.IsVisible() | |
| 158 | - | } | |
| 159 | 161 | ||
| 160 | 162 | // Unparent widget from any previous parent (critical for rebuild scenarios) | |
| 161 | 163 | // In GTK4, a widget can only have one parent at a time | |
| @@ -170,6 +172,20 @@ func (pv *PaneView) SetWebViewWidget(widget layout.Widget) { | |||
| 170 | 172 | if hadParent && pv.loading != nil { | |
| 171 | 173 | pv.loading.SetVisible(false) | |
| 172 | 174 | } | |
| 175 | + | ||
| 176 | + | logging.FromContext(pv.ctx).Debug(). | |
| 177 | + | Str("pane_id", string(pv.paneID)). | |
| 178 | + | Bool("had_parent", hadParent). | |
| 179 | + | Bool("was_visible", wasVisible). | |
| 180 | + | Int("overlay_alloc_width_before", overlayAllocWidthBefore). | |
| 181 | + | Int("overlay_alloc_height_before", overlayAllocHeightBefore). | |
| 182 | + | Int("widget_alloc_width_before", widgetAllocWidthBefore). | |
| 183 | + | Int("widget_alloc_height_before", widgetAllocHeightBefore). | |
| 184 | + | Int("overlay_alloc_width_after", pv.overlay.GetAllocatedWidth()). | |
| 185 | + | Int("overlay_alloc_height_after", pv.overlay.GetAllocatedHeight()). | |
| 186 | + | Int("widget_alloc_width_after", widget.GetAllocatedWidth()). | |
| 187 | + | Int("widget_alloc_height_after", widget.GetAllocatedHeight()). | |
| 188 | + | Msg("pane view webview widget attached") | |
| 173 | 189 | } | |
| 174 | 190 | } | |
| 175 | 191 | ||
| @@ -148,14 +148,16 @@ func (pv *PaneView) SetWebViewWidget(widget layout.Widget) { | |||||
| 148 | pv.webViewWidget = widget | 148 | pv.webViewWidget = widget | ||
| 149 | 149 | ||||
| 150 | if widget != nil { | 150 | if widget != nil { | ||
| 151 | + | overlayAllocWidthBefore := pv.overlay.GetAllocatedWidth() | |||
| 152 | + | overlayAllocHeightBefore := pv.overlay.GetAllocatedHeight() | |||
| 153 | + | widgetAllocWidthBefore := widget.GetAllocatedWidth() | |||
| 154 | + | widgetAllocHeightBefore := widget.GetAllocatedHeight() | |||
| 155 | + | hadParent := widget.GetParent() != nil | |||
| 156 | + | wasVisible := widget.IsVisible() | |||
| 157 | + | ||||
| 151 | // If this widget already had a parent, we're reparenting an existing WebView | 158 | // If this widget already had a parent, we're reparenting an existing WebView | ||
| 152 | // (e.g. after rebuilding/moving panes). In that case the WebView has likely | 159 | // (e.g. after rebuilding/moving panes). In that case the WebView has likely | ||
| 153 | // already painted, so the loading skeleton should be hidden immediately. | 160 | // already painted, so the loading skeleton should be hidden immediately. | ||
| 154 | - | hadParent := widget.GetParent() != nil | |||
| 155 | - | wasVisible := false | |||
| 156 | - | if hadParent { | |||
| 157 | - | wasVisible = widget.IsVisible() | |||
| 158 | - | } | |||
| 159 | 161 | ||||
| 160 | // Unparent widget from any previous parent (critical for rebuild scenarios) | 162 | // Unparent widget from any previous parent (critical for rebuild scenarios) | ||
| 161 | // In GTK4, a widget can only have one parent at a time | 163 | // In GTK4, a widget can only have one parent at a time | ||
| @@ -170,6 +172,20 @@ func (pv *PaneView) SetWebViewWidget(widget layout.Widget) { | |||||
| 170 | if hadParent && pv.loading != nil { | 172 | if hadParent && pv.loading != nil { | ||
| 171 | pv.loading.SetVisible(false) | 173 | pv.loading.SetVisible(false) | ||
| 172 | } | 174 | } | ||
| 175 | + | ||||
| 176 | + | logging.FromContext(pv.ctx).Debug(). | |||
| 177 | + | Str("pane_id", string(pv.paneID)). | |||
| 178 | + | Bool("had_parent", hadParent). | |||
| 179 | + | Bool("was_visible", wasVisible). | |||
| 180 | + | Int("overlay_alloc_width_before", overlayAllocWidthBefore). | |||
| 181 | + | Int("overlay_alloc_height_before", overlayAllocHeightBefore). | |||
| 182 | + | Int("widget_alloc_width_before", widgetAllocWidthBefore). | |||
| 183 | + | Int("widget_alloc_height_before", widgetAllocHeightBefore). | |||
| 184 | + | Int("overlay_alloc_width_after", pv.overlay.GetAllocatedWidth()). | |||
| 185 | + | Int("overlay_alloc_height_after", pv.overlay.GetAllocatedHeight()). | |||
| 186 | + | Int("widget_alloc_width_after", widget.GetAllocatedWidth()). | |||
| 187 | + | Int("widget_alloc_height_after", widget.GetAllocatedHeight()). | |||
| 188 | + | Msg("pane view webview widget attached") | |||
| 173 | } | 189 | } | ||
| 174 | } | 190 | } | ||
| 175 | 191 | ||||
M internal/ui/component/pane_view_test.go +10 -0
| @@ -211,7 +211,12 @@ func TestSetWebViewWidget_ReplacesWidget(t *testing.T) { | |||
| 211 | 211 | ||
| 212 | 212 | // Expect removal of old widget and addition of new | |
| 213 | 213 | mockOverlay.EXPECT().SetChild(nil).Once() | |
| 214 | + | mockOverlay.EXPECT().GetAllocatedWidth().Return(0).Twice() | |
| 215 | + | mockOverlay.EXPECT().GetAllocatedHeight().Return(0).Twice() | |
| 214 | 216 | mockNewWebView.EXPECT().GetParent().Return(nil).Once() | |
| 217 | + | mockNewWebView.EXPECT().IsVisible().Return(false).Once() | |
| 218 | + | mockNewWebView.EXPECT().GetAllocatedWidth().Return(0).Twice() | |
| 219 | + | mockNewWebView.EXPECT().GetAllocatedHeight().Return(0).Twice() | |
| 215 | 220 | mockOverlay.EXPECT().SetChild(mockNewWebView).Once() | |
| 216 | 221 | ||
| 217 | 222 | // Act | |
| @@ -233,7 +238,12 @@ func TestSetWebViewWidget_FromNil(t *testing.T) { | |||
| 233 | 238 | pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), nil) | |
| 234 | 239 | ||
| 235 | 240 | // Expect only setting new child (no removal since old was nil) | |
| 241 | + | mockOverlay.EXPECT().GetAllocatedWidth().Return(0).Twice() | |
| 242 | + | mockOverlay.EXPECT().GetAllocatedHeight().Return(0).Twice() | |
| 236 | 243 | mockNewWebView.EXPECT().GetParent().Return(nil).Once() | |
| 244 | + | mockNewWebView.EXPECT().IsVisible().Return(false).Once() | |
| 245 | + | mockNewWebView.EXPECT().GetAllocatedWidth().Return(0).Twice() | |
| 246 | + | mockNewWebView.EXPECT().GetAllocatedHeight().Return(0).Twice() | |
| 237 | 247 | mockOverlay.EXPECT().SetChild(mockNewWebView).Once() | |
| 238 | 248 | ||
| 239 | 249 | // Act | |
| @@ -211,7 +211,12 @@ func TestSetWebViewWidget_ReplacesWidget(t *testing.T) { | |||||
| 211 | 211 | ||||
| 212 | // Expect removal of old widget and addition of new | 212 | // Expect removal of old widget and addition of new | ||
| 213 | mockOverlay.EXPECT().SetChild(nil).Once() | 213 | mockOverlay.EXPECT().SetChild(nil).Once() | ||
| 214 | + | mockOverlay.EXPECT().GetAllocatedWidth().Return(0).Twice() | |||
| 215 | + | mockOverlay.EXPECT().GetAllocatedHeight().Return(0).Twice() | |||
| 214 | mockNewWebView.EXPECT().GetParent().Return(nil).Once() | 216 | mockNewWebView.EXPECT().GetParent().Return(nil).Once() | ||
| 217 | + | mockNewWebView.EXPECT().IsVisible().Return(false).Once() | |||
| 218 | + | mockNewWebView.EXPECT().GetAllocatedWidth().Return(0).Twice() | |||
| 219 | + | mockNewWebView.EXPECT().GetAllocatedHeight().Return(0).Twice() | |||
| 215 | mockOverlay.EXPECT().SetChild(mockNewWebView).Once() | 220 | mockOverlay.EXPECT().SetChild(mockNewWebView).Once() | ||
| 216 | 221 | ||||
| 217 | // Act | 222 | // Act | ||
| @@ -233,7 +238,12 @@ func TestSetWebViewWidget_FromNil(t *testing.T) { | |||||
| 233 | pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), nil) | 238 | pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), nil) | ||
| 234 | 239 | ||||
| 235 | // Expect only setting new child (no removal since old was nil) | 240 | // Expect only setting new child (no removal since old was nil) | ||
| 241 | + | mockOverlay.EXPECT().GetAllocatedWidth().Return(0).Twice() | |||
| 242 | + | mockOverlay.EXPECT().GetAllocatedHeight().Return(0).Twice() | |||
| 236 | mockNewWebView.EXPECT().GetParent().Return(nil).Once() | 243 | mockNewWebView.EXPECT().GetParent().Return(nil).Once() | ||
| 244 | + | mockNewWebView.EXPECT().IsVisible().Return(false).Once() | |||
| 245 | + | mockNewWebView.EXPECT().GetAllocatedWidth().Return(0).Twice() | |||
| 246 | + | mockNewWebView.EXPECT().GetAllocatedHeight().Return(0).Twice() | |||
| 237 | mockOverlay.EXPECT().SetChild(mockNewWebView).Once() | 247 | mockOverlay.EXPECT().SetChild(mockNewWebView).Once() | ||
| 238 | 248 | ||||
| 239 | // Act | 249 | // Act | ||
M internal/ui/coordinator/content/lifecycle.go +15 -1
| @@ -18,6 +18,10 @@ func (c *Coordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) ( | |||
| 18 | 18 | log := logging.FromContext(ctx) | |
| 19 | 19 | ||
| 20 | 20 | if wv := c.getWebViewLocked(paneID); wv != nil && !wv.IsDestroyed() { | |
| 21 | + | log.Debug(). | |
| 22 | + | Str("pane_id", string(paneID)). | |
| 23 | + | Uint64("webview_id", uint64(wv.ID())). | |
| 24 | + | Msg("webview reused for pane") | |
| 21 | 25 | return wv, nil | |
| 22 | 26 | } | |
| 23 | 27 | ||
| @@ -39,7 +43,10 @@ func (c *Coordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) ( | |||
| 39 | 43 | c.setWebViewLocked(paneID, wv) | |
| 40 | 44 | c.setupWebViewCallbacks(ctx, paneID, wv) | |
| 41 | 45 | ||
| 42 | - | log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane") | |
| 46 | + | log.Debug(). | |
| 47 | + | Str("pane_id", string(paneID)). | |
| 48 | + | Uint64("webview_id", uint64(wv.ID())). | |
| 49 | + | Msg("webview acquired for pane") | |
| 43 | 50 | return wv, nil | |
| 44 | 51 | } | |
| 45 | 52 | ||
| @@ -121,6 +128,13 @@ func (c *Coordinator) AttachToWorkspace(ctx context.Context, ws *entity.Workspac | |||
| 121 | 128 | continue | |
| 122 | 129 | } | |
| 123 | 130 | ||
| 131 | + | log.Debug(). | |
| 132 | + | Str("pane_id", string(pane.ID)). | |
| 133 | + | Uint64("webview_id", uint64(wv.ID())). | |
| 134 | + | Int("wrapped_widget_alloc_width", widget.GetAllocatedWidth()). | |
| 135 | + | Int("wrapped_widget_alloc_height", widget.GetAllocatedHeight()). | |
| 136 | + | Msg("wrapped webview widget prepared for pane") | |
| 137 | + | ||
| 124 | 138 | if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil { | |
| 125 | 139 | log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget") | |
| 126 | 140 | } | |
| @@ -18,6 +18,10 @@ func (c *Coordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) ( | |||||
| 18 | log := logging.FromContext(ctx) | 18 | log := logging.FromContext(ctx) | ||
| 19 | 19 | ||||
| 20 | if wv := c.getWebViewLocked(paneID); wv != nil && !wv.IsDestroyed() { | 20 | if wv := c.getWebViewLocked(paneID); wv != nil && !wv.IsDestroyed() { | ||
| 21 | + | log.Debug(). | |||
| 22 | + | Str("pane_id", string(paneID)). | |||
| 23 | + | Uint64("webview_id", uint64(wv.ID())). | |||
| 24 | + | Msg("webview reused for pane") | |||
| 21 | return wv, nil | 25 | return wv, nil | ||
| 22 | } | 26 | } | ||
| 23 | 27 | ||||
| @@ -39,7 +43,10 @@ func (c *Coordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) ( | |||||
| 39 | c.setWebViewLocked(paneID, wv) | 43 | c.setWebViewLocked(paneID, wv) | ||
| 40 | c.setupWebViewCallbacks(ctx, paneID, wv) | 44 | c.setupWebViewCallbacks(ctx, paneID, wv) | ||
| 41 | 45 | ||||
| 42 | - | log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane") | 46 | + | log.Debug(). |
| 47 | + | Str("pane_id", string(paneID)). | |||
| 48 | + | Uint64("webview_id", uint64(wv.ID())). | |||
| 49 | + | Msg("webview acquired for pane") | |||
| 43 | return wv, nil | 50 | return wv, nil | ||
| 44 | } | 51 | } | ||
| 45 | 52 | ||||
| @@ -121,6 +128,13 @@ func (c *Coordinator) AttachToWorkspace(ctx context.Context, ws *entity.Workspac | |||||
| 121 | continue | 128 | continue | ||
| 122 | } | 129 | } | ||
| 123 | 130 | ||||
| 131 | + | log.Debug(). | |||
| 132 | + | Str("pane_id", string(pane.ID)). | |||
| 133 | + | Uint64("webview_id", uint64(wv.ID())). | |||
| 134 | + | Int("wrapped_widget_alloc_width", widget.GetAllocatedWidth()). | |||
| 135 | + | Int("wrapped_widget_alloc_height", widget.GetAllocatedHeight()). | |||
| 136 | + | Msg("wrapped webview widget prepared for pane") | |||
| 137 | + | ||||
| 124 | if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil { | 138 | if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil { | ||
| 125 | 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") | ||
| 126 | } | 140 | } | ||
M internal/ui/coordinator/content/lifecycle_test.go +3 -0
| @@ -34,6 +34,7 @@ func TestLifecycle_EnsureWebView_ReusesExistingNonDestroyed(t *testing.T) { | |||
| 34 | 34 | ||
| 35 | 35 | wv := mocks.NewMockWebView(t) | |
| 36 | 36 | wv.EXPECT().IsDestroyed().Return(false) | |
| 37 | + | wv.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 37 | 38 | ||
| 38 | 39 | pool := mocks.NewMockWebViewPool(t) | |
| 39 | 40 | // pool.Acquire must NOT be called | |
| @@ -54,6 +55,7 @@ func TestLifecycle_EnsureWebView_AcquiresFromPoolWhenNoneExists(t *testing.T) { | |||
| 54 | 55 | newWV := mocks.NewMockWebView(t) | |
| 55 | 56 | newWV.EXPECT().SetCallbacks(mock.Anything).Maybe() | |
| 56 | 57 | newWV.EXPECT().Generation().Return(uint64(0)).Maybe() | |
| 58 | + | newWV.EXPECT().ID().Return(port.WebViewID(102)).Once() | |
| 57 | 59 | ||
| 58 | 60 | pool := mocks.NewMockWebViewPool(t) | |
| 59 | 61 | pool.EXPECT().Acquire(mock.Anything).Return(newWV, nil) | |
| @@ -77,6 +79,7 @@ func TestLifecycle_EnsureWebView_AcquiresFromPoolWhenExistingIsDestroyed(t *test | |||
| 77 | 79 | newWV := mocks.NewMockWebView(t) | |
| 78 | 80 | newWV.EXPECT().SetCallbacks(mock.Anything).Maybe() | |
| 79 | 81 | newWV.EXPECT().Generation().Return(uint64(0)).Maybe() | |
| 82 | + | newWV.EXPECT().ID().Return(port.WebViewID(103)).Once() | |
| 80 | 83 | ||
| 81 | 84 | pool := mocks.NewMockWebViewPool(t) | |
| 82 | 85 | pool.EXPECT().Acquire(mock.Anything).Return(newWV, nil) | |
| @@ -34,6 +34,7 @@ func TestLifecycle_EnsureWebView_ReusesExistingNonDestroyed(t *testing.T) { | |||||
| 34 | 34 | ||||
| 35 | wv := mocks.NewMockWebView(t) | 35 | wv := mocks.NewMockWebView(t) | ||
| 36 | wv.EXPECT().IsDestroyed().Return(false) | 36 | wv.EXPECT().IsDestroyed().Return(false) | ||
| 37 | + | wv.EXPECT().ID().Return(port.WebViewID(101)).Once() | |||
| 37 | 38 | ||||
| 38 | pool := mocks.NewMockWebViewPool(t) | 39 | pool := mocks.NewMockWebViewPool(t) | ||
| 39 | // pool.Acquire must NOT be called | 40 | // pool.Acquire must NOT be called | ||
| @@ -54,6 +55,7 @@ func TestLifecycle_EnsureWebView_AcquiresFromPoolWhenNoneExists(t *testing.T) { | |||||
| 54 | newWV := mocks.NewMockWebView(t) | 55 | newWV := mocks.NewMockWebView(t) | ||
| 55 | newWV.EXPECT().SetCallbacks(mock.Anything).Maybe() | 56 | newWV.EXPECT().SetCallbacks(mock.Anything).Maybe() | ||
| 56 | newWV.EXPECT().Generation().Return(uint64(0)).Maybe() | 57 | newWV.EXPECT().Generation().Return(uint64(0)).Maybe() | ||
| 58 | + | newWV.EXPECT().ID().Return(port.WebViewID(102)).Once() | |||
| 57 | 59 | ||||
| 58 | pool := mocks.NewMockWebViewPool(t) | 60 | pool := mocks.NewMockWebViewPool(t) | ||
| 59 | pool.EXPECT().Acquire(mock.Anything).Return(newWV, nil) | 61 | pool.EXPECT().Acquire(mock.Anything).Return(newWV, nil) | ||
| @@ -77,6 +79,7 @@ func TestLifecycle_EnsureWebView_AcquiresFromPoolWhenExistingIsDestroyed(t *test | |||||
| 77 | newWV := mocks.NewMockWebView(t) | 79 | newWV := mocks.NewMockWebView(t) | ||
| 78 | newWV.EXPECT().SetCallbacks(mock.Anything).Maybe() | 80 | newWV.EXPECT().SetCallbacks(mock.Anything).Maybe() | ||
| 79 | newWV.EXPECT().Generation().Return(uint64(0)).Maybe() | 81 | newWV.EXPECT().Generation().Return(uint64(0)).Maybe() | ||
| 82 | + | newWV.EXPECT().ID().Return(port.WebViewID(103)).Once() | |||
| 80 | 83 | ||||
| 81 | pool := mocks.NewMockWebViewPool(t) | 84 | pool := mocks.NewMockWebViewPool(t) | ||
| 82 | pool.EXPECT().Acquire(mock.Anything).Return(newWV, nil) | 85 | pool.EXPECT().Acquire(mock.Anything).Return(newWV, nil) | ||
M internal/ui/coordinator/workspace.go +41 -3
| @@ -88,6 +88,13 @@ const ( | |||
| 88 | 88 | defaultResizeMinPanePercent = 0.1 | |
| 89 | 89 | ) | |
| 90 | 90 | ||
| 91 | + | func widgetAllocation(widget layout.Widget) (int, int) { | |
| 92 | + | if widget == nil { | |
| 93 | + | return 0, 0 | |
| 94 | + | } | |
| 95 | + | return widget.GetAllocatedWidth(), widget.GetAllocatedHeight() | |
| 96 | + | } | |
| 97 | + | ||
| 91 | 98 | // clampResizeStep clamps resizeStepPercent to (0, 0.5], returning the default (0.05) if out of range. | |
| 92 | 99 | func clampResizeStep(v float64) float64 { | |
| 93 | 100 | if v <= 0 || v > 0.5 { | |
| @@ -516,6 +523,12 @@ func (c *WorkspaceCoordinator) doIncrementalSplit( | |||
| 516 | 523 | return fmt.Errorf("no stacked view found for active pane") | |
| 517 | 524 | } | |
| 518 | 525 | activePaneWidget := activeStackedView.Widget() | |
| 526 | + | activeStackWidth, activeStackHeight := widgetAllocation(activePaneWidget) | |
| 527 | + | log.Debug(). | |
| 528 | + | Str("old_active_pane_id", string(oldActivePaneID)). | |
| 529 | + | Int("active_stack_widget_alloc_width", activeStackWidth). | |
| 530 | + | Int("active_stack_widget_alloc_height", activeStackHeight). | |
| 531 | + | Msg("incremental split: active stacked view before replacement") | |
| 519 | 532 | ||
| 520 | 533 | // 3. Determine if this is a root split by checking domain tree | |
| 521 | 534 | // output.ParentNode is the NEW split node (h0) | |
| @@ -558,6 +571,7 @@ func (c *WorkspaceCoordinator) doIncrementalSplit( | |||
| 558 | 571 | output, | |
| 559 | 572 | orientation, | |
| 560 | 573 | existingFirst, | |
| 574 | + | oldActivePaneID, | |
| 561 | 575 | ); err != nil { | |
| 562 | 576 | return err | |
| 563 | 577 | } | |
| @@ -572,6 +586,7 @@ func (c *WorkspaceCoordinator) doIncrementalSplit( | |||
| 572 | 586 | newStackedView, | |
| 573 | 587 | orientation, | |
| 574 | 588 | existingFirst, | |
| 589 | + | oldActivePaneID, | |
| 575 | 590 | ); err != nil { | |
| 576 | 591 | return err | |
| 577 | 592 | } | |
| @@ -645,6 +660,7 @@ func (c *WorkspaceCoordinator) replaceRootSplit( | |||
| 645 | 660 | output *usecase.SplitPaneOutput, | |
| 646 | 661 | orientation layout.Orientation, | |
| 647 | 662 | existingFirst bool, | |
| 663 | + | oldActivePaneID entity.PaneID, | |
| 648 | 664 | ) error { | |
| 649 | 665 | wsView.ClearRootWidgetRef() | |
| 650 | 666 | wsView.Container().Remove(existingRootWidget) | |
| @@ -661,8 +677,19 @@ func (c *WorkspaceCoordinator) replaceRootSplit( | |||
| 661 | 677 | ||
| 662 | 678 | wsView.SetRootWidgetDirect(splitView.Widget()) | |
| 663 | 679 | tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), orientation) | |
| 680 | + | newSplitWidth, newSplitHeight := widgetAllocation(splitView.Widget()) | |
| 681 | + | logging.FromContext(ctx).Debug(). | |
| 682 | + | Str("old_active_pane_id", string(oldActivePaneID)). | |
| 683 | + | Int("new_split_widget_alloc_width", newSplitWidth). | |
| 684 | + | Int("new_split_widget_alloc_height", newSplitHeight). | |
| 685 | + | Str("orientation", func() string { | |
| 686 | + | if orientation == layout.OrientationHorizontal { | |
| 687 | + | return "horizontal" | |
| 688 | + | } | |
| 689 | + | return "vertical" | |
| 690 | + | }()). | |
| 691 | + | Msg("incremental split: replaced root with new split view") | |
| 664 | 692 | ||
| 665 | - | logging.FromContext(ctx).Debug().Msg("root split: replaced root with new split view") | |
| 666 | 693 | return nil | |
| 667 | 694 | } | |
| 668 | 695 | ||
| @@ -676,6 +703,7 @@ func (c *WorkspaceCoordinator) replaceNonRootSplit( | |||
| 676 | 703 | newStackedView *layout.StackedView, | |
| 677 | 704 | orientation layout.Orientation, | |
| 678 | 705 | existingFirst bool, | |
| 706 | + | oldActivePaneID entity.PaneID, | |
| 679 | 707 | ) error { | |
| 680 | 708 | log := logging.FromContext(ctx) | |
| 681 | 709 | grandparentWidget := tr.Lookup(grandparentNode.ID) | |
| @@ -726,10 +754,20 @@ func (c *WorkspaceCoordinator) replaceNonRootSplit( | |||
| 726 | 754 | } | |
| 727 | 755 | ||
| 728 | 756 | tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), orientation) | |
| 729 | - | ||
| 757 | + | newSplitWidth, newSplitHeight := widgetAllocation(splitView.Widget()) | |
| 730 | 758 | log.Debug(). | |
| 759 | + | Str("old_active_pane_id", string(oldActivePaneID)). | |
| 731 | 760 | Bool("was_start_child", isStartChild). | |
| 732 | - | Msg("non-root split: replaced pane in grandparent with new split view") | |
| 761 | + | Int("new_split_widget_alloc_width", newSplitWidth). | |
| 762 | + | Int("new_split_widget_alloc_height", newSplitHeight). | |
| 763 | + | Str("orientation", func() string { | |
| 764 | + | if orientation == layout.OrientationHorizontal { | |
| 765 | + | return "horizontal" | |
| 766 | + | } | |
| 767 | + | return "vertical" | |
| 768 | + | }()). | |
| 769 | + | Msg("incremental split: replaced pane in grandparent with new split view") | |
| 770 | + | ||
| 733 | 771 | return nil | |
| 734 | 772 | } | |
| 735 | 773 | ||
| @@ -88,6 +88,13 @@ const ( | |||||
| 88 | defaultResizeMinPanePercent = 0.1 | 88 | defaultResizeMinPanePercent = 0.1 | ||
| 89 | ) | 89 | ) | ||
| 90 | 90 | ||||
| 91 | + | func widgetAllocation(widget layout.Widget) (int, int) { | |||
| 92 | + | if widget == nil { | |||
| 93 | + | return 0, 0 | |||
| 94 | + | } | |||
| 95 | + | return widget.GetAllocatedWidth(), widget.GetAllocatedHeight() | |||
| 96 | + | } | |||
| 97 | + | ||||
| 91 | // clampResizeStep clamps resizeStepPercent to (0, 0.5], returning the default (0.05) if out of range. | 98 | // clampResizeStep clamps resizeStepPercent to (0, 0.5], returning the default (0.05) if out of range. | ||
| 92 | func clampResizeStep(v float64) float64 { | 99 | func clampResizeStep(v float64) float64 { | ||
| 93 | if v <= 0 || v > 0.5 { | 100 | if v <= 0 || v > 0.5 { | ||
| @@ -516,6 +523,12 @@ func (c *WorkspaceCoordinator) doIncrementalSplit( | |||||
| 516 | return fmt.Errorf("no stacked view found for active pane") | 523 | return fmt.Errorf("no stacked view found for active pane") | ||
| 517 | } | 524 | } | ||
| 518 | activePaneWidget := activeStackedView.Widget() | 525 | activePaneWidget := activeStackedView.Widget() | ||
| 526 | + | activeStackWidth, activeStackHeight := widgetAllocation(activePaneWidget) | |||
| 527 | + | log.Debug(). | |||
| 528 | + | Str("old_active_pane_id", string(oldActivePaneID)). | |||
| 529 | + | Int("active_stack_widget_alloc_width", activeStackWidth). | |||
| 530 | + | Int("active_stack_widget_alloc_height", activeStackHeight). | |||
| 531 | + | Msg("incremental split: active stacked view before replacement") | |||
| 519 | 532 | ||||
| 520 | // 3. Determine if this is a root split by checking domain tree | 533 | // 3. Determine if this is a root split by checking domain tree | ||
| 521 | // output.ParentNode is the NEW split node (h0) | 534 | // output.ParentNode is the NEW split node (h0) | ||
| @@ -558,6 +571,7 @@ func (c *WorkspaceCoordinator) doIncrementalSplit( | |||||
| 558 | output, | 571 | output, | ||
| 559 | orientation, | 572 | orientation, | ||
| 560 | existingFirst, | 573 | existingFirst, | ||
| 574 | + | oldActivePaneID, | |||
| 561 | ); err != nil { | 575 | ); err != nil { | ||
| 562 | return err | 576 | return err | ||
| 563 | } | 577 | } | ||
| @@ -572,6 +586,7 @@ func (c *WorkspaceCoordinator) doIncrementalSplit( | |||||
| 572 | newStackedView, | 586 | newStackedView, | ||
| 573 | orientation, | 587 | orientation, | ||
| 574 | existingFirst, | 588 | existingFirst, | ||
| 589 | + | oldActivePaneID, | |||
| 575 | ); err != nil { | 590 | ); err != nil { | ||
| 576 | return err | 591 | return err | ||
| 577 | } | 592 | } | ||
| @@ -645,6 +660,7 @@ func (c *WorkspaceCoordinator) replaceRootSplit( | |||||
| 645 | output *usecase.SplitPaneOutput, | 660 | output *usecase.SplitPaneOutput, | ||
| 646 | orientation layout.Orientation, | 661 | orientation layout.Orientation, | ||
| 647 | existingFirst bool, | 662 | existingFirst bool, | ||
| 663 | + | oldActivePaneID entity.PaneID, | |||
| 648 | ) error { | 664 | ) error { | ||
| 649 | wsView.ClearRootWidgetRef() | 665 | wsView.ClearRootWidgetRef() | ||
| 650 | wsView.Container().Remove(existingRootWidget) | 666 | wsView.Container().Remove(existingRootWidget) | ||
| @@ -661,8 +677,19 @@ func (c *WorkspaceCoordinator) replaceRootSplit( | |||||
| 661 | 677 | ||||
| 662 | wsView.SetRootWidgetDirect(splitView.Widget()) | 678 | wsView.SetRootWidgetDirect(splitView.Widget()) | ||
| 663 | tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), orientation) | 679 | tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), orientation) | ||
| 680 | + | newSplitWidth, newSplitHeight := widgetAllocation(splitView.Widget()) | |||
| 681 | + | logging.FromContext(ctx).Debug(). | |||
| 682 | + | Str("old_active_pane_id", string(oldActivePaneID)). | |||
| 683 | + | Int("new_split_widget_alloc_width", newSplitWidth). | |||
| 684 | + | Int("new_split_widget_alloc_height", newSplitHeight). | |||
| 685 | + | Str("orientation", func() string { | |||
| 686 | + | if orientation == layout.OrientationHorizontal { | |||
| 687 | + | return "horizontal" | |||
| 688 | + | } | |||
| 689 | + | return "vertical" | |||
| 690 | + | }()). | |||
| 691 | + | Msg("incremental split: replaced root with new split view") | |||
| 664 | 692 | ||||
| 665 | - | logging.FromContext(ctx).Debug().Msg("root split: replaced root with new split view") | |||
| 666 | return nil | 693 | return nil | ||
| 667 | } | 694 | } | ||
| 668 | 695 | ||||
| @@ -676,6 +703,7 @@ func (c *WorkspaceCoordinator) replaceNonRootSplit( | |||||
| 676 | newStackedView *layout.StackedView, | 703 | newStackedView *layout.StackedView, | ||
| 677 | orientation layout.Orientation, | 704 | orientation layout.Orientation, | ||
| 678 | existingFirst bool, | 705 | existingFirst bool, | ||
| 706 | + | oldActivePaneID entity.PaneID, | |||
| 679 | ) error { | 707 | ) error { | ||
| 680 | log := logging.FromContext(ctx) | 708 | log := logging.FromContext(ctx) | ||
| 681 | grandparentWidget := tr.Lookup(grandparentNode.ID) | 709 | grandparentWidget := tr.Lookup(grandparentNode.ID) | ||
| @@ -726,10 +754,20 @@ func (c *WorkspaceCoordinator) replaceNonRootSplit( | |||||
| 726 | } | 754 | } | ||
| 727 | 755 | ||||
| 728 | tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), orientation) | 756 | tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), orientation) | ||
| 729 | - | 757 | + | newSplitWidth, newSplitHeight := widgetAllocation(splitView.Widget()) | |
| 730 | log.Debug(). | 758 | log.Debug(). | ||
| 759 | + | Str("old_active_pane_id", string(oldActivePaneID)). | |||
| 731 | Bool("was_start_child", isStartChild). | 760 | Bool("was_start_child", isStartChild). | ||
| 732 | - | Msg("non-root split: replaced pane in grandparent with new split view") | 761 | + | Int("new_split_widget_alloc_width", newSplitWidth). |
| 762 | + | Int("new_split_widget_alloc_height", newSplitHeight). | |||
| 763 | + | Str("orientation", func() string { | |||
| 764 | + | if orientation == layout.OrientationHorizontal { | |||
| 765 | + | return "horizontal" | |||
| 766 | + | } | |||
| 767 | + | return "vertical" | |||
| 768 | + | }()). | |||
| 769 | + | Msg("incremental split: replaced pane in grandparent with new split view") | |||
| 770 | + | ||||
| 733 | return nil | 771 | return nil | ||
| 734 | } | 772 | } | ||
| 735 | 773 | ||||
M internal/ui/layout/split.go +81 -0
| @@ -36,6 +36,54 @@ const maxRetryFrames = 120 | |||
| 36 | 36 | ||
| 37 | 37 | const notifyPositionDebounceDelay = 100 * time.Millisecond | |
| 38 | 38 | const notifyPositionSuppressAfterApplyDelay = 50 * time.Millisecond | |
| 39 | + | const splitAllocationSettledTolerance = 2 | |
| 40 | + | ||
| 41 | + | func childAllocation(widget Widget) (int, int) { | |
| 42 | + | if widget == nil { | |
| 43 | + | return 0, 0 | |
| 44 | + | } | |
| 45 | + | return widget.GetAllocatedWidth(), widget.GetAllocatedHeight() | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func splitAllocationsSettled( | |
| 49 | + | orientation Orientation, totalSize, targetPosition, actualPosition int, | |
| 50 | + | startWidth, startHeight, endWidth, endHeight int, | |
| 51 | + | ) bool { | |
| 52 | + | if absInt(actualPosition-targetPosition) > splitAllocationSettledTolerance { | |
| 53 | + | return false | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | if targetPosition <= 0 || targetPosition >= totalSize { | |
| 57 | + | return true | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | startSize, endSize := startWidth, endWidth | |
| 61 | + | if orientation == OrientationVertical { | |
| 62 | + | startSize, endSize = startHeight, endHeight | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | if startSize <= splitAllocationSettledTolerance || endSize <= splitAllocationSettledTolerance { | |
| 66 | + | return false | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | expectedStart := targetPosition | |
| 70 | + | expectedEnd := totalSize - targetPosition | |
| 71 | + | if absInt(startSize-expectedStart) > splitAllocationSettledTolerance { | |
| 72 | + | return false | |
| 73 | + | } | |
| 74 | + | if absInt(endSize-expectedEnd) > splitAllocationSettledTolerance { | |
| 75 | + | return false | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | return true | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func absInt(v int) int { | |
| 82 | + | if v < 0 { | |
| 83 | + | return -v | |
| 84 | + | } | |
| 85 | + | return v | |
| 86 | + | } | |
| 39 | 87 | ||
| 40 | 88 | // NewSplitView creates a new split view with the given orientation and children. | |
| 41 | 89 | // The ratio determines the initial divider position (0.0-1.0). | |
| @@ -194,6 +242,8 @@ func (sv *SplitView) ApplyRatio() bool { | |||
| 194 | 242 | sv.mu.RLock() | |
| 195 | 243 | ratio := sv.ratio | |
| 196 | 244 | orientation := sv.orientation | |
| 245 | + | startChild := sv.startChild | |
| 246 | + | endChild := sv.endChild | |
| 197 | 247 | sv.mu.RUnlock() | |
| 198 | 248 | ||
| 199 | 249 | // Get the relevant dimension based on orientation | |
| @@ -217,11 +267,19 @@ func (sv *SplitView) ApplyRatio() bool { | |||
| 217 | 267 | ||
| 218 | 268 | // Calculate position from ratio | |
| 219 | 269 | position := int(float64(totalSize) * ratio) | |
| 270 | + | startWidthBefore, startHeightBefore := childAllocation(startChild) | |
| 271 | + | endWidthBefore, endHeightBefore := childAllocation(endChild) | |
| 272 | + | currentPositionBeforeSet := sv.paned.GetPosition() | |
| 220 | 273 | sv.logger.Debug(). | |
| 221 | 274 | Str("orientation", orientStr). | |
| 222 | 275 | Int("total_size", totalSize). | |
| 223 | 276 | Float64("ratio", ratio). | |
| 224 | 277 | Int("position", position). | |
| 278 | + | Int("current_position_before_set", currentPositionBeforeSet). | |
| 279 | + | Int("start_child_alloc_width", startWidthBefore). | |
| 280 | + | Int("start_child_alloc_height", startHeightBefore). | |
| 281 | + | Int("end_child_alloc_width", endWidthBefore). | |
| 282 | + | Int("end_child_alloc_height", endHeightBefore). | |
| 225 | 283 | Msg("ApplyRatio: setting position") | |
| 226 | 284 | ||
| 227 | 285 | // Avoid treating programmatic SetPosition as a user resize. | |
| @@ -232,6 +290,29 @@ func (sv *SplitView) ApplyRatio() bool { | |||
| 232 | 290 | ||
| 233 | 291 | sv.paned.SetPosition(position) | |
| 234 | 292 | ||
| 293 | + | startWidthAfter, startHeightAfter := childAllocation(startChild) | |
| 294 | + | endWidthAfter, endHeightAfter := childAllocation(endChild) | |
| 295 | + | actualPositionAfterSet := sv.paned.GetPosition() | |
| 296 | + | childAllocationsSettled := splitAllocationsSettled( | |
| 297 | + | orientation, totalSize, position, actualPositionAfterSet, | |
| 298 | + | startWidthAfter, startHeightAfter, endWidthAfter, endHeightAfter, | |
| 299 | + | ) | |
| 300 | + | sv.logger.Debug(). | |
| 301 | + | Str("orientation", orientStr). | |
| 302 | + | Int("total_size", totalSize). | |
| 303 | + | Int("target_position", position). | |
| 304 | + | Int("actual_position_after_set", actualPositionAfterSet). | |
| 305 | + | Int("start_child_alloc_width", startWidthAfter). | |
| 306 | + | Int("start_child_alloc_height", startHeightAfter). | |
| 307 | + | Int("end_child_alloc_width", endWidthAfter). | |
| 308 | + | Int("end_child_alloc_height", endHeightAfter). | |
| 309 | + | Bool("child_allocations_settled", childAllocationsSettled). | |
| 310 | + | Msg("ApplyRatio: position applied") | |
| 311 | + | ||
| 312 | + | if !childAllocationsSettled { | |
| 313 | + | return false | |
| 314 | + | } | |
| 315 | + | ||
| 235 | 316 | sv.mu.Lock() | |
| 236 | 317 | sv.hasAppliedRatio = true | |
| 237 | 318 | sv.mu.Unlock() | |
| @@ -36,6 +36,54 @@ const maxRetryFrames = 120 | |||||
| 36 | 36 | ||||
| 37 | const notifyPositionDebounceDelay = 100 * time.Millisecond | 37 | const notifyPositionDebounceDelay = 100 * time.Millisecond | ||
| 38 | const notifyPositionSuppressAfterApplyDelay = 50 * time.Millisecond | 38 | const notifyPositionSuppressAfterApplyDelay = 50 * time.Millisecond | ||
| 39 | + | const splitAllocationSettledTolerance = 2 | |||
| 40 | + | ||||
| 41 | + | func childAllocation(widget Widget) (int, int) { | |||
| 42 | + | if widget == nil { | |||
| 43 | + | return 0, 0 | |||
| 44 | + | } | |||
| 45 | + | return widget.GetAllocatedWidth(), widget.GetAllocatedHeight() | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func splitAllocationsSettled( | |||
| 49 | + | orientation Orientation, totalSize, targetPosition, actualPosition int, | |||
| 50 | + | startWidth, startHeight, endWidth, endHeight int, | |||
| 51 | + | ) bool { | |||
| 52 | + | if absInt(actualPosition-targetPosition) > splitAllocationSettledTolerance { | |||
| 53 | + | return false | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | if targetPosition <= 0 || targetPosition >= totalSize { | |||
| 57 | + | return true | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | startSize, endSize := startWidth, endWidth | |||
| 61 | + | if orientation == OrientationVertical { | |||
| 62 | + | startSize, endSize = startHeight, endHeight | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | if startSize <= splitAllocationSettledTolerance || endSize <= splitAllocationSettledTolerance { | |||
| 66 | + | return false | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | expectedStart := targetPosition | |||
| 70 | + | expectedEnd := totalSize - targetPosition | |||
| 71 | + | if absInt(startSize-expectedStart) > splitAllocationSettledTolerance { | |||
| 72 | + | return false | |||
| 73 | + | } | |||
| 74 | + | if absInt(endSize-expectedEnd) > splitAllocationSettledTolerance { | |||
| 75 | + | return false | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | return true | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func absInt(v int) int { | |||
| 82 | + | if v < 0 { | |||
| 83 | + | return -v | |||
| 84 | + | } | |||
| 85 | + | return v | |||
| 86 | + | } | |||
| 39 | 87 | ||||
| 40 | // NewSplitView creates a new split view with the given orientation and children. | 88 | // NewSplitView creates a new split view with the given orientation and children. | ||
| 41 | // The ratio determines the initial divider position (0.0-1.0). | 89 | // The ratio determines the initial divider position (0.0-1.0). | ||
| @@ -194,6 +242,8 @@ func (sv *SplitView) ApplyRatio() bool { | |||||
| 194 | sv.mu.RLock() | 242 | sv.mu.RLock() | ||
| 195 | ratio := sv.ratio | 243 | ratio := sv.ratio | ||
| 196 | orientation := sv.orientation | 244 | orientation := sv.orientation | ||
| 245 | + | startChild := sv.startChild | |||
| 246 | + | endChild := sv.endChild | |||
| 197 | sv.mu.RUnlock() | 247 | sv.mu.RUnlock() | ||
| 198 | 248 | ||||
| 199 | // Get the relevant dimension based on orientation | 249 | // Get the relevant dimension based on orientation | ||
| @@ -217,11 +267,19 @@ func (sv *SplitView) ApplyRatio() bool { | |||||
| 217 | 267 | ||||
| 218 | // Calculate position from ratio | 268 | // Calculate position from ratio | ||
| 219 | position := int(float64(totalSize) * ratio) | 269 | position := int(float64(totalSize) * ratio) | ||
| 270 | + | startWidthBefore, startHeightBefore := childAllocation(startChild) | |||
| 271 | + | endWidthBefore, endHeightBefore := childAllocation(endChild) | |||
| 272 | + | currentPositionBeforeSet := sv.paned.GetPosition() | |||
| 220 | sv.logger.Debug(). | 273 | sv.logger.Debug(). | ||
| 221 | Str("orientation", orientStr). | 274 | Str("orientation", orientStr). | ||
| 222 | Int("total_size", totalSize). | 275 | Int("total_size", totalSize). | ||
| 223 | Float64("ratio", ratio). | 276 | Float64("ratio", ratio). | ||
| 224 | Int("position", position). | 277 | Int("position", position). | ||
| 278 | + | Int("current_position_before_set", currentPositionBeforeSet). | |||
| 279 | + | Int("start_child_alloc_width", startWidthBefore). | |||
| 280 | + | Int("start_child_alloc_height", startHeightBefore). | |||
| 281 | + | Int("end_child_alloc_width", endWidthBefore). | |||
| 282 | + | Int("end_child_alloc_height", endHeightBefore). | |||
| 225 | Msg("ApplyRatio: setting position") | 283 | Msg("ApplyRatio: setting position") | ||
| 226 | 284 | ||||
| 227 | // Avoid treating programmatic SetPosition as a user resize. | 285 | // Avoid treating programmatic SetPosition as a user resize. | ||
| @@ -232,6 +290,29 @@ func (sv *SplitView) ApplyRatio() bool { | |||||
| 232 | 290 | ||||
| 233 | sv.paned.SetPosition(position) | 291 | sv.paned.SetPosition(position) | ||
| 234 | 292 | ||||
| 293 | + | startWidthAfter, startHeightAfter := childAllocation(startChild) | |||
| 294 | + | endWidthAfter, endHeightAfter := childAllocation(endChild) | |||
| 295 | + | actualPositionAfterSet := sv.paned.GetPosition() | |||
| 296 | + | childAllocationsSettled := splitAllocationsSettled( | |||
| 297 | + | orientation, totalSize, position, actualPositionAfterSet, | |||
| 298 | + | startWidthAfter, startHeightAfter, endWidthAfter, endHeightAfter, | |||
| 299 | + | ) | |||
| 300 | + | sv.logger.Debug(). | |||
| 301 | + | Str("orientation", orientStr). | |||
| 302 | + | Int("total_size", totalSize). | |||
| 303 | + | Int("target_position", position). | |||
| 304 | + | Int("actual_position_after_set", actualPositionAfterSet). | |||
| 305 | + | Int("start_child_alloc_width", startWidthAfter). | |||
| 306 | + | Int("start_child_alloc_height", startHeightAfter). | |||
| 307 | + | Int("end_child_alloc_width", endWidthAfter). | |||
| 308 | + | Int("end_child_alloc_height", endHeightAfter). | |||
| 309 | + | Bool("child_allocations_settled", childAllocationsSettled). | |||
| 310 | + | Msg("ApplyRatio: position applied") | |||
| 311 | + | ||||
| 312 | + | if !childAllocationsSettled { | |||
| 313 | + | return false | |||
| 314 | + | } | |||
| 315 | + | ||||
| 235 | sv.mu.Lock() | 316 | sv.mu.Lock() | ||
| 236 | sv.hasAppliedRatio = true | 317 | sv.hasAppliedRatio = true | ||
| 237 | sv.mu.Unlock() | 318 | sv.mu.Unlock() | ||
M internal/ui/layout/split_test.go +43 -0
| @@ -46,6 +46,10 @@ func TestNewSplitView_Horizontal(t *testing.T) { | |||
| 46 | 46 | ||
| 47 | 47 | mockFactory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(mockPaned).Once() | |
| 48 | 48 | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) | |
| 49 | + | mockStartChild.EXPECT().GetAllocatedWidth().Return(0).Maybe() | |
| 50 | + | mockStartChild.EXPECT().GetAllocatedHeight().Return(0).Maybe() | |
| 51 | + | mockEndChild.EXPECT().GetAllocatedWidth().Return(0).Maybe() | |
| 52 | + | mockEndChild.EXPECT().GetAllocatedHeight().Return(0).Maybe() | |
| 49 | 53 | mockStartChild.EXPECT().SetVisible(true).Once() | |
| 50 | 54 | mockEndChild.EXPECT().SetVisible(true).Once() | |
| 51 | 55 | mockPaned.EXPECT().SetStartChild(mockStartChild).Once() | |
| @@ -72,6 +76,10 @@ func TestNewSplitView_Vertical(t *testing.T) { | |||
| 72 | 76 | ||
| 73 | 77 | mockFactory.EXPECT().NewPaned(layout.OrientationVertical).Return(mockPaned).Once() | |
| 74 | 78 | setupSplitViewMocks(mockPaned, layout.OrientationVertical) | |
| 79 | + | mockStartChild.EXPECT().GetAllocatedWidth().Return(0).Maybe() | |
| 80 | + | mockStartChild.EXPECT().GetAllocatedHeight().Return(0).Maybe() | |
| 81 | + | mockEndChild.EXPECT().GetAllocatedWidth().Return(0).Maybe() | |
| 82 | + | mockEndChild.EXPECT().GetAllocatedHeight().Return(0).Maybe() | |
| 75 | 83 | mockStartChild.EXPECT().SetVisible(true).Once() | |
| 76 | 84 | mockEndChild.EXPECT().SetVisible(true).Once() | |
| 77 | 85 | mockPaned.EXPECT().SetStartChild(mockStartChild).Once() | |
| @@ -184,6 +192,8 @@ func TestSwapStart_ReplacesChild(t *testing.T) { | |||
| 184 | 192 | ||
| 185 | 193 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | |
| 186 | 194 | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) | |
| 195 | + | mockOldChild.EXPECT().GetAllocatedWidth().Return(0).Maybe() | |
| 196 | + | mockOldChild.EXPECT().GetAllocatedHeight().Return(0).Maybe() | |
| 187 | 197 | mockOldChild.EXPECT().SetVisible(true).Once() | |
| 188 | 198 | mockPaned.EXPECT().SetStartChild(mockOldChild).Once() | |
| 189 | 199 | ||
| @@ -210,6 +220,8 @@ func TestSwapEnd_ReplacesChild(t *testing.T) { | |||
| 210 | 220 | ||
| 211 | 221 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | |
| 212 | 222 | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) | |
| 223 | + | mockOldChild.EXPECT().GetAllocatedWidth().Return(0).Maybe() | |
| 224 | + | mockOldChild.EXPECT().GetAllocatedHeight().Return(0).Maybe() | |
| 213 | 225 | mockOldChild.EXPECT().SetVisible(true).Once() | |
| 214 | 226 | mockPaned.EXPECT().SetEndChild(mockOldChild).Once() | |
| 215 | 227 | ||
| @@ -257,6 +269,8 @@ func TestSwapEnd_ToNil(t *testing.T) { | |||
| 257 | 269 | ||
| 258 | 270 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | |
| 259 | 271 | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) | |
| 272 | + | mockOldChild.EXPECT().GetAllocatedWidth().Return(0).Maybe() | |
| 273 | + | mockOldChild.EXPECT().GetAllocatedHeight().Return(0).Maybe() | |
| 260 | 274 | mockOldChild.EXPECT().SetVisible(true).Once() | |
| 261 | 275 | mockPaned.EXPECT().SetEndChild(mockOldChild).Once() | |
| 262 | 276 | ||
| @@ -309,6 +323,35 @@ func TestSetPosition_DelegatesToPaned(t *testing.T) { | |||
| 309 | 323 | // Assert - verified by mock expectations | |
| 310 | 324 | } | |
| 311 | 325 | ||
| 326 | + | func TestApplyRatio_Vertical_ContinuesWhenChildrenStayDegenerate(t *testing.T) { | |
| 327 | + | ctx := context.Background() | |
| 328 | + | mockFactory := mocks.NewMockWidgetFactory(t) | |
| 329 | + | mockPaned := mocks.NewMockPanedWidget(t) | |
| 330 | + | mockStartChild := mocks.NewMockWidget(t) | |
| 331 | + | mockEndChild := mocks.NewMockWidget(t) | |
| 332 | + | ||
| 333 | + | mockFactory.EXPECT().NewPaned(layout.OrientationVertical).Return(mockPaned).Once() | |
| 334 | + | setupSplitViewMocks(mockPaned, layout.OrientationVertical) | |
| 335 | + | mockStartChild.EXPECT().GetAllocatedWidth().Return(0).Maybe() | |
| 336 | + | mockStartChild.EXPECT().GetAllocatedHeight().Return(2143).Twice() | |
| 337 | + | mockStartChild.EXPECT().SetVisible(true).Once() | |
| 338 | + | mockEndChild.EXPECT().GetAllocatedWidth().Return(0).Maybe() | |
| 339 | + | mockEndChild.EXPECT().GetAllocatedHeight().Return(1).Twice() | |
| 340 | + | mockEndChild.EXPECT().SetVisible(true).Once() | |
| 341 | + | mockPaned.EXPECT().SetStartChild(mockStartChild).Once() | |
| 342 | + | mockPaned.EXPECT().SetEndChild(mockEndChild).Once() | |
| 343 | + | ||
| 344 | + | sv := layout.NewSplitView(ctx, mockFactory, layout.OrientationVertical, mockStartChild, mockEndChild, 0.5) | |
| 345 | + | require.NotNil(t, sv) | |
| 346 | + | ||
| 347 | + | mockPaned.EXPECT().GetAllocatedHeight().Return(2144).Once() | |
| 348 | + | mockPaned.EXPECT().GetPosition().Return(0).Once() | |
| 349 | + | mockPaned.EXPECT().SetPosition(1072).Once() | |
| 350 | + | mockPaned.EXPECT().GetPosition().Return(1072).Once() | |
| 351 | + | ||
| 352 | + | assert.False(t, sv.ApplyRatio()) | |
| 353 | + | } | |
| 354 | + | ||
| 312 | 355 | func TestGetPosition_DelegatesToPaned(t *testing.T) { | |
| 313 | 356 | // Arrange | |
| 314 | 357 | ctx := context.Background() | |
| @@ -46,6 +46,10 @@ func TestNewSplitView_Horizontal(t *testing.T) { | |||||
| 46 | 46 | ||||
| 47 | mockFactory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(mockPaned).Once() | 47 | mockFactory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(mockPaned).Once() | ||
| 48 | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) | 48 | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) | ||
| 49 | + | mockStartChild.EXPECT().GetAllocatedWidth().Return(0).Maybe() | |||
| 50 | + | mockStartChild.EXPECT().GetAllocatedHeight().Return(0).Maybe() | |||
| 51 | + | mockEndChild.EXPECT().GetAllocatedWidth().Return(0).Maybe() | |||
| 52 | + | mockEndChild.EXPECT().GetAllocatedHeight().Return(0).Maybe() | |||
| 49 | mockStartChild.EXPECT().SetVisible(true).Once() | 53 | mockStartChild.EXPECT().SetVisible(true).Once() | ||
| 50 | mockEndChild.EXPECT().SetVisible(true).Once() | 54 | mockEndChild.EXPECT().SetVisible(true).Once() | ||
| 51 | mockPaned.EXPECT().SetStartChild(mockStartChild).Once() | 55 | mockPaned.EXPECT().SetStartChild(mockStartChild).Once() | ||
| @@ -72,6 +76,10 @@ func TestNewSplitView_Vertical(t *testing.T) { | |||||
| 72 | 76 | ||||
| 73 | mockFactory.EXPECT().NewPaned(layout.OrientationVertical).Return(mockPaned).Once() | 77 | mockFactory.EXPECT().NewPaned(layout.OrientationVertical).Return(mockPaned).Once() | ||
| 74 | setupSplitViewMocks(mockPaned, layout.OrientationVertical) | 78 | setupSplitViewMocks(mockPaned, layout.OrientationVertical) | ||
| 79 | + | mockStartChild.EXPECT().GetAllocatedWidth().Return(0).Maybe() | |||
| 80 | + | mockStartChild.EXPECT().GetAllocatedHeight().Return(0).Maybe() | |||
| 81 | + | mockEndChild.EXPECT().GetAllocatedWidth().Return(0).Maybe() | |||
| 82 | + | mockEndChild.EXPECT().GetAllocatedHeight().Return(0).Maybe() | |||
| 75 | mockStartChild.EXPECT().SetVisible(true).Once() | 83 | mockStartChild.EXPECT().SetVisible(true).Once() | ||
| 76 | mockEndChild.EXPECT().SetVisible(true).Once() | 84 | mockEndChild.EXPECT().SetVisible(true).Once() | ||
| 77 | mockPaned.EXPECT().SetStartChild(mockStartChild).Once() | 85 | mockPaned.EXPECT().SetStartChild(mockStartChild).Once() | ||
| @@ -184,6 +192,8 @@ func TestSwapStart_ReplacesChild(t *testing.T) { | |||||
| 184 | 192 | ||||
| 185 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | 193 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | ||
| 186 | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) | 194 | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) | ||
| 195 | + | mockOldChild.EXPECT().GetAllocatedWidth().Return(0).Maybe() | |||
| 196 | + | mockOldChild.EXPECT().GetAllocatedHeight().Return(0).Maybe() | |||
| 187 | mockOldChild.EXPECT().SetVisible(true).Once() | 197 | mockOldChild.EXPECT().SetVisible(true).Once() | ||
| 188 | mockPaned.EXPECT().SetStartChild(mockOldChild).Once() | 198 | mockPaned.EXPECT().SetStartChild(mockOldChild).Once() | ||
| 189 | 199 | ||||
| @@ -210,6 +220,8 @@ func TestSwapEnd_ReplacesChild(t *testing.T) { | |||||
| 210 | 220 | ||||
| 211 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | 221 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | ||
| 212 | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) | 222 | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) | ||
| 223 | + | mockOldChild.EXPECT().GetAllocatedWidth().Return(0).Maybe() | |||
| 224 | + | mockOldChild.EXPECT().GetAllocatedHeight().Return(0).Maybe() | |||
| 213 | mockOldChild.EXPECT().SetVisible(true).Once() | 225 | mockOldChild.EXPECT().SetVisible(true).Once() | ||
| 214 | mockPaned.EXPECT().SetEndChild(mockOldChild).Once() | 226 | mockPaned.EXPECT().SetEndChild(mockOldChild).Once() | ||
| 215 | 227 | ||||
| @@ -257,6 +269,8 @@ func TestSwapEnd_ToNil(t *testing.T) { | |||||
| 257 | 269 | ||||
| 258 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | 270 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | ||
| 259 | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) | 271 | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) | ||
| 272 | + | mockOldChild.EXPECT().GetAllocatedWidth().Return(0).Maybe() | |||
| 273 | + | mockOldChild.EXPECT().GetAllocatedHeight().Return(0).Maybe() | |||
| 260 | mockOldChild.EXPECT().SetVisible(true).Once() | 274 | mockOldChild.EXPECT().SetVisible(true).Once() | ||
| 261 | mockPaned.EXPECT().SetEndChild(mockOldChild).Once() | 275 | mockPaned.EXPECT().SetEndChild(mockOldChild).Once() | ||
| 262 | 276 | ||||
| @@ -309,6 +323,35 @@ func TestSetPosition_DelegatesToPaned(t *testing.T) { | |||||
| 309 | // Assert - verified by mock expectations | 323 | // Assert - verified by mock expectations | ||
| 310 | } | 324 | } | ||
| 311 | 325 | ||||
| 326 | + | func TestApplyRatio_Vertical_ContinuesWhenChildrenStayDegenerate(t *testing.T) { | |||
| 327 | + | ctx := context.Background() | |||
| 328 | + | mockFactory := mocks.NewMockWidgetFactory(t) | |||
| 329 | + | mockPaned := mocks.NewMockPanedWidget(t) | |||
| 330 | + | mockStartChild := mocks.NewMockWidget(t) | |||
| 331 | + | mockEndChild := mocks.NewMockWidget(t) | |||
| 332 | + | ||||
| 333 | + | mockFactory.EXPECT().NewPaned(layout.OrientationVertical).Return(mockPaned).Once() | |||
| 334 | + | setupSplitViewMocks(mockPaned, layout.OrientationVertical) | |||
| 335 | + | mockStartChild.EXPECT().GetAllocatedWidth().Return(0).Maybe() | |||
| 336 | + | mockStartChild.EXPECT().GetAllocatedHeight().Return(2143).Twice() | |||
| 337 | + | mockStartChild.EXPECT().SetVisible(true).Once() | |||
| 338 | + | mockEndChild.EXPECT().GetAllocatedWidth().Return(0).Maybe() | |||
| 339 | + | mockEndChild.EXPECT().GetAllocatedHeight().Return(1).Twice() | |||
| 340 | + | mockEndChild.EXPECT().SetVisible(true).Once() | |||
| 341 | + | mockPaned.EXPECT().SetStartChild(mockStartChild).Once() | |||
| 342 | + | mockPaned.EXPECT().SetEndChild(mockEndChild).Once() | |||
| 343 | + | ||||
| 344 | + | sv := layout.NewSplitView(ctx, mockFactory, layout.OrientationVertical, mockStartChild, mockEndChild, 0.5) | |||
| 345 | + | require.NotNil(t, sv) | |||
| 346 | + | ||||
| 347 | + | mockPaned.EXPECT().GetAllocatedHeight().Return(2144).Once() | |||
| 348 | + | mockPaned.EXPECT().GetPosition().Return(0).Once() | |||
| 349 | + | mockPaned.EXPECT().SetPosition(1072).Once() | |||
| 350 | + | mockPaned.EXPECT().GetPosition().Return(1072).Once() | |||
| 351 | + | ||||
| 352 | + | assert.False(t, sv.ApplyRatio()) | |||
| 353 | + | } | |||
| 354 | + | ||||
| 312 | func TestGetPosition_DelegatesToPaned(t *testing.T) { | 355 | func TestGetPosition_DelegatesToPaned(t *testing.T) { | ||
| 313 | // Arrange | 356 | // Arrange | ||
| 314 | ctx := context.Background() | 357 | ctx := context.Background() | ||