Merge branch 'fix/cef-resize-tracing'
18 files · +988 -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
| @@ -4,8 +4,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE | |||
| 4 | 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= | |
| 5 | 5 | github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= | |
| 6 | 6 | github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= | |
| 7 | - | github.com/bnema/purego v0.0.0-20260323095903-91aa177a2704 h1:0GaiO4102gOdp0es0MO2d0bRSvyeMw5A2D4F4WdIw0k= | |
| 8 | - | github.com/bnema/purego v0.0.0-20260323095903-91aa177a2704/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= | |
| 7 | + | github.com/bnema/purego v0.0.0-20260413043705-7850e384cde9 h1:uY4oWlNAXh+5KArWcF7bjHaDwNoT9H3cr2zPa1vBvyI= | |
| 8 | + | github.com/bnema/purego v0.0.0-20260413043705-7850e384cde9/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= | |
| 9 | 9 | github.com/bnema/purego-cef v0.8.1 h1:BtzCcKSuCZ/dS/LROi2eildY9zaZ8Axagy5dNzj6hV4= | |
| 10 | 10 | github.com/bnema/purego-cef v0.8.1/go.mod h1:8XatJEof+SzApTWd8MSar1Vdkkivln/WH7yH++rcf5o= | |
| 11 | 11 | github.com/bnema/purego-ffmpeg v0.3.0 h1:GRwW5RS2flUBvgZbluJkM5dKF0gvyUszMb5gDbvofzk= | |
| @@ -4,8 +4,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE | |||||
| 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= | 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= | ||
| 5 | github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= | 5 | github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= | ||
| 6 | github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= | 6 | github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= | ||
| 7 | - | github.com/bnema/purego v0.0.0-20260323095903-91aa177a2704 h1:0GaiO4102gOdp0es0MO2d0bRSvyeMw5A2D4F4WdIw0k= | 7 | + | github.com/bnema/purego v0.0.0-20260413043705-7850e384cde9 h1:uY4oWlNAXh+5KArWcF7bjHaDwNoT9H3cr2zPa1vBvyI= |
| 8 | - | github.com/bnema/purego v0.0.0-20260323095903-91aa177a2704/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= | 8 | + | github.com/bnema/purego v0.0.0-20260413043705-7850e384cde9/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= |
| 9 | github.com/bnema/purego-cef v0.8.1 h1:BtzCcKSuCZ/dS/LROi2eildY9zaZ8Axagy5dNzj6hV4= | 9 | github.com/bnema/purego-cef v0.8.1 h1:BtzCcKSuCZ/dS/LROi2eildY9zaZ8Axagy5dNzj6hV4= | ||
| 10 | github.com/bnema/purego-cef v0.8.1/go.mod h1:8XatJEof+SzApTWd8MSar1Vdkkivln/WH7yH++rcf5o= | 10 | github.com/bnema/purego-cef v0.8.1/go.mod h1:8XatJEof+SzApTWd8MSar1Vdkkivln/WH7yH++rcf5o= | ||
| 11 | github.com/bnema/purego-ffmpeg v0.3.0 h1:GRwW5RS2flUBvgZbluJkM5dKF0gvyUszMb5gDbvofzk= | 11 | github.com/bnema/purego-ffmpeg v0.3.0 h1:GRwW5RS2flUBvgZbluJkM5dKF0gvyUszMb5gDbvofzk= | ||
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 +104 -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 | } | |
| @@ -819,13 +841,27 @@ func compileShader(gl *glLoader, shaderType uint32, source string) uint32 { | |||
| 819 | 841 | // onResize is the GTK "resize" signal handler. Dimensions are in CSS pixels; | |
| 820 | 842 | // we multiply by scale to get device pixels. | |
| 821 | 843 | func (rp *renderPipeline) onResize(width, height int32) { | |
| 844 | + | if width <= 0 || height <= 0 { | |
| 845 | + | return | |
| 846 | + | } | |
| 847 | + | ||
| 822 | 848 | rp.mu.Lock() | |
| 823 | 849 | ||
| 850 | + | prevWidth := rp.widthAtomic.Load() | |
| 851 | + | prevHeight := rp.heightAtomic.Load() | |
| 852 | + | scale := rp.scale | |
| 853 | + | if scale <= 0 { | |
| 854 | + | scale = 1 | |
| 855 | + | } | |
| 856 | + | ||
| 824 | 857 | scaled := func(v int32) int32 { | |
| 825 | - | return v * rp.scale | |
| 858 | + | return v * scale | |
| 826 | 859 | } | |
| 827 | 860 | ||
| 828 | 861 | w, h := scaled(width), scaled(height) | |
| 862 | + | resizeSeq := rp.resizeSeq.Add(1) | |
| 863 | + | now := time.Now() | |
| 864 | + | rp.lastResizeUnixNano.Store(now.UnixNano()) | |
| 829 | 865 | rp.widthAtomic.Store(w) | |
| 830 | 866 | rp.heightAtomic.Store(h) | |
| 831 | 867 | rp.sizeChanged = true | |
| @@ -837,6 +873,22 @@ func (rp *renderPipeline) onResize(width, height int32) { | |||
| 837 | 873 | resizeCB := rp.onResizeCB | |
| 838 | 874 | rp.mu.Unlock() | |
| 839 | 875 | ||
| 876 | + | if rp.ctx != nil { | |
| 877 | + | logging.FromContext(rp.ctx).Debug(). | |
| 878 | + | Uint64("webview_id", uint64(rp.webviewID)). | |
| 879 | + | Uint64("resize_seq", resizeSeq). | |
| 880 | + | Int32("css_width", width). | |
| 881 | + | Int32("css_height", height). | |
| 882 | + | Int32("scaled_width", w). | |
| 883 | + | Int32("scaled_height", h). | |
| 884 | + | Int32("prev_width", prevWidth). | |
| 885 | + | Int32("prev_height", prevHeight). | |
| 886 | + | Int32("scale", scale). | |
| 887 | + | Bool("first_resize", firstCB != nil). | |
| 888 | + | Bool("has_resize_cb", resizeCB != nil). | |
| 889 | + | Msg("cef: GLArea resize") | |
| 890 | + | } | |
| 891 | + | ||
| 840 | 892 | if firstCB != nil { | |
| 841 | 893 | firstCB(w, h) | |
| 842 | 894 | } else if resizeCB != nil { | |
| @@ -918,6 +970,50 @@ func (rp *renderPipeline) viewRectSize() (width, height, scale int32) { | |||
| 918 | 970 | return width, height, scale | |
| 919 | 971 | } | |
| 920 | 972 | ||
| 973 | + | func (rp *renderPipeline) latestResizeDiagnostics() (resizeSeq uint64, ageMs int64) { | |
| 974 | + | resizeSeq = rp.resizeSeq.Load() | |
| 975 | + | lastResizeUnixNano := rp.lastResizeUnixNano.Load() | |
| 976 | + | if lastResizeUnixNano == 0 { | |
| 977 | + | return resizeSeq, 0 | |
| 978 | + | } | |
| 979 | + | age := time.Since(time.Unix(0, lastResizeUnixNano)) | |
| 980 | + | if age <= 0 { | |
| 981 | + | return resizeSeq, 0 | |
| 982 | + | } | |
| 983 | + | return resizeSeq, age.Milliseconds() | |
| 984 | + | } | |
| 985 | + | ||
| 986 | + | func (rp *renderPipeline) markFirstPaintAfterResize() (resizeSeq uint64, ageMs int64, shouldLog bool) { | |
| 987 | + | resizeSeq, ageMs = rp.latestResizeDiagnostics() | |
| 988 | + | if resizeSeq == 0 { | |
| 989 | + | return resizeSeq, ageMs, false | |
| 990 | + | } | |
| 991 | + | for { | |
| 992 | + | lastLogged := rp.lastResizePaintLogSeq.Load() | |
| 993 | + | if lastLogged >= resizeSeq { | |
| 994 | + | return resizeSeq, ageMs, false | |
| 995 | + | } | |
| 996 | + | if rp.lastResizePaintLogSeq.CompareAndSwap(lastLogged, resizeSeq) { | |
| 997 | + | return resizeSeq, ageMs, true | |
| 998 | + | } | |
| 999 | + | } | |
| 1000 | + | } | |
| 1001 | + | ||
| 1002 | + | func (rp *renderPipeline) markStalePaintAfterResize(resizeSeq uint64) bool { | |
| 1003 | + | if resizeSeq == 0 { | |
| 1004 | + | return false | |
| 1005 | + | } | |
| 1006 | + | for { | |
| 1007 | + | lastLogged := rp.lastResizeStaleLogSeq.Load() | |
| 1008 | + | if lastLogged >= resizeSeq { | |
| 1009 | + | return false | |
| 1010 | + | } | |
| 1011 | + | if rp.lastResizeStaleLogSeq.CompareAndSwap(lastLogged, resizeSeq) { | |
| 1012 | + | return true | |
| 1013 | + | } | |
| 1014 | + | } | |
| 1015 | + | } | |
| 1016 | + | ||
| 921 | 1017 | func (rp *renderPipeline) nextViewRectSeq() uint64 { | |
| 922 | 1018 | return rp.viewRectSeq.Add(1) | |
| 923 | 1019 | } | |
| @@ -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 | } | ||
| @@ -819,13 +841,27 @@ func compileShader(gl *glLoader, shaderType uint32, source string) uint32 { | |||||
| 819 | // onResize is the GTK "resize" signal handler. Dimensions are in CSS pixels; | 841 | // onResize is the GTK "resize" signal handler. Dimensions are in CSS pixels; | ||
| 820 | // we multiply by scale to get device pixels. | 842 | // we multiply by scale to get device pixels. | ||
| 821 | func (rp *renderPipeline) onResize(width, height int32) { | 843 | func (rp *renderPipeline) onResize(width, height int32) { | ||
| 844 | + | if width <= 0 || height <= 0 { | |||
| 845 | + | return | |||
| 846 | + | } | |||
| 847 | + | ||||
| 822 | rp.mu.Lock() | 848 | rp.mu.Lock() | ||
| 823 | 849 | ||||
| 850 | + | prevWidth := rp.widthAtomic.Load() | |||
| 851 | + | prevHeight := rp.heightAtomic.Load() | |||
| 852 | + | scale := rp.scale | |||
| 853 | + | if scale <= 0 { | |||
| 854 | + | scale = 1 | |||
| 855 | + | } | |||
| 856 | + | ||||
| 824 | scaled := func(v int32) int32 { | 857 | scaled := func(v int32) int32 { | ||
| 825 | - | return v * rp.scale | 858 | + | return v * scale |
| 826 | } | 859 | } | ||
| 827 | 860 | ||||
| 828 | w, h := scaled(width), scaled(height) | 861 | w, h := scaled(width), scaled(height) | ||
| 862 | + | resizeSeq := rp.resizeSeq.Add(1) | |||
| 863 | + | now := time.Now() | |||
| 864 | + | rp.lastResizeUnixNano.Store(now.UnixNano()) | |||
| 829 | rp.widthAtomic.Store(w) | 865 | rp.widthAtomic.Store(w) | ||
| 830 | rp.heightAtomic.Store(h) | 866 | rp.heightAtomic.Store(h) | ||
| 831 | rp.sizeChanged = true | 867 | rp.sizeChanged = true | ||
| @@ -837,6 +873,22 @@ func (rp *renderPipeline) onResize(width, height int32) { | |||||
| 837 | resizeCB := rp.onResizeCB | 873 | resizeCB := rp.onResizeCB | ||
| 838 | rp.mu.Unlock() | 874 | rp.mu.Unlock() | ||
| 839 | 875 | ||||
| 876 | + | if rp.ctx != nil { | |||
| 877 | + | logging.FromContext(rp.ctx).Debug(). | |||
| 878 | + | Uint64("webview_id", uint64(rp.webviewID)). | |||
| 879 | + | Uint64("resize_seq", resizeSeq). | |||
| 880 | + | Int32("css_width", width). | |||
| 881 | + | Int32("css_height", height). | |||
| 882 | + | Int32("scaled_width", w). | |||
| 883 | + | Int32("scaled_height", h). | |||
| 884 | + | Int32("prev_width", prevWidth). | |||
| 885 | + | Int32("prev_height", prevHeight). | |||
| 886 | + | Int32("scale", scale). | |||
| 887 | + | Bool("first_resize", firstCB != nil). | |||
| 888 | + | Bool("has_resize_cb", resizeCB != nil). | |||
| 889 | + | Msg("cef: GLArea resize") | |||
| 890 | + | } | |||
| 891 | + | ||||
| 840 | if firstCB != nil { | 892 | if firstCB != nil { | ||
| 841 | firstCB(w, h) | 893 | firstCB(w, h) | ||
| 842 | } else if resizeCB != nil { | 894 | } else if resizeCB != nil { | ||
| @@ -918,6 +970,50 @@ func (rp *renderPipeline) viewRectSize() (width, height, scale int32) { | |||||
| 918 | return width, height, scale | 970 | return width, height, scale | ||
| 919 | } | 971 | } | ||
| 920 | 972 | ||||
| 973 | + | func (rp *renderPipeline) latestResizeDiagnostics() (resizeSeq uint64, ageMs int64) { | |||
| 974 | + | resizeSeq = rp.resizeSeq.Load() | |||
| 975 | + | lastResizeUnixNano := rp.lastResizeUnixNano.Load() | |||
| 976 | + | if lastResizeUnixNano == 0 { | |||
| 977 | + | return resizeSeq, 0 | |||
| 978 | + | } | |||
| 979 | + | age := time.Since(time.Unix(0, lastResizeUnixNano)) | |||
| 980 | + | if age <= 0 { | |||
| 981 | + | return resizeSeq, 0 | |||
| 982 | + | } | |||
| 983 | + | return resizeSeq, age.Milliseconds() | |||
| 984 | + | } | |||
| 985 | + | ||||
| 986 | + | func (rp *renderPipeline) markFirstPaintAfterResize() (resizeSeq uint64, ageMs int64, shouldLog bool) { | |||
| 987 | + | resizeSeq, ageMs = rp.latestResizeDiagnostics() | |||
| 988 | + | if resizeSeq == 0 { | |||
| 989 | + | return resizeSeq, ageMs, false | |||
| 990 | + | } | |||
| 991 | + | for { | |||
| 992 | + | lastLogged := rp.lastResizePaintLogSeq.Load() | |||
| 993 | + | if lastLogged >= resizeSeq { | |||
| 994 | + | return resizeSeq, ageMs, false | |||
| 995 | + | } | |||
| 996 | + | if rp.lastResizePaintLogSeq.CompareAndSwap(lastLogged, resizeSeq) { | |||
| 997 | + | return resizeSeq, ageMs, true | |||
| 998 | + | } | |||
| 999 | + | } | |||
| 1000 | + | } | |||
| 1001 | + | ||||
| 1002 | + | func (rp *renderPipeline) markStalePaintAfterResize(resizeSeq uint64) bool { | |||
| 1003 | + | if resizeSeq == 0 { | |||
| 1004 | + | return false | |||
| 1005 | + | } | |||
| 1006 | + | for { | |||
| 1007 | + | lastLogged := rp.lastResizeStaleLogSeq.Load() | |||
| 1008 | + | if lastLogged >= resizeSeq { | |||
| 1009 | + | return false | |||
| 1010 | + | } | |||
| 1011 | + | if rp.lastResizeStaleLogSeq.CompareAndSwap(lastLogged, resizeSeq) { | |||
| 1012 | + | return true | |||
| 1013 | + | } | |||
| 1014 | + | } | |||
| 1015 | + | } | |||
| 1016 | + | ||||
| 921 | func (rp *renderPipeline) nextViewRectSeq() uint64 { | 1017 | func (rp *renderPipeline) nextViewRectSeq() uint64 { | ||
| 922 | return rp.viewRectSeq.Add(1) | 1018 | return rp.viewRectSeq.Add(1) | ||
| 923 | } | 1019 | } | ||
M internal/infrastructure/cef/render_pipeline_test.go +28 -0
| @@ -80,6 +80,34 @@ func TestHandlePaintCopiesDirtyRectsIntoPersistentStaging(t *testing.T) { | |||
| 80 | 80 | require.False(t, rp.forceFullUpload) | |
| 81 | 81 | } | |
| 82 | 82 | ||
| 83 | + | func TestOnResizeIgnoresZeroFirstResize(t *testing.T) { | |
| 84 | + | called := false | |
| 85 | + | rp := &renderPipeline{ | |
| 86 | + | scale: 1, | |
| 87 | + | onFirstResize: func(_, _ int32) { | |
| 88 | + | called = true | |
| 89 | + | }, | |
| 90 | + | } | |
| 91 | + | rp.widthAtomic.Store(123) | |
| 92 | + | rp.heightAtomic.Store(456) | |
| 93 | + | ||
| 94 | + | rp.onResize(0, 0) | |
| 95 | + | ||
| 96 | + | require.False(t, called) | |
| 97 | + | require.Equal(t, int32(123), rp.widthAtomic.Load()) | |
| 98 | + | require.Equal(t, int32(456), rp.heightAtomic.Load()) | |
| 99 | + | require.False(t, rp.sizeChanged) | |
| 100 | + | require.NotNil(t, rp.onFirstResize) | |
| 101 | + | ||
| 102 | + | rp.onResize(10, 20) | |
| 103 | + | ||
| 104 | + | require.True(t, called) | |
| 105 | + | require.Equal(t, int32(10), rp.widthAtomic.Load()) | |
| 106 | + | require.Equal(t, int32(20), rp.heightAtomic.Load()) | |
| 107 | + | require.True(t, rp.sizeChanged) | |
| 108 | + | require.Nil(t, rp.onFirstResize) | |
| 109 | + | } | |
| 110 | + | ||
| 83 | 111 | // sortRects sorts rects by (X, Y) so tests can compare without caring about order. | |
| 84 | 112 | func sortRects(rects []rect) { | |
| 85 | 113 | sort.Slice(rects, func(i, j int) bool { | |
| @@ -80,6 +80,34 @@ func TestHandlePaintCopiesDirtyRectsIntoPersistentStaging(t *testing.T) { | |||||
| 80 | require.False(t, rp.forceFullUpload) | 80 | require.False(t, rp.forceFullUpload) | ||
| 81 | } | 81 | } | ||
| 82 | 82 | ||||
| 83 | + | func TestOnResizeIgnoresZeroFirstResize(t *testing.T) { | |||
| 84 | + | called := false | |||
| 85 | + | rp := &renderPipeline{ | |||
| 86 | + | scale: 1, | |||
| 87 | + | onFirstResize: func(_, _ int32) { | |||
| 88 | + | called = true | |||
| 89 | + | }, | |||
| 90 | + | } | |||
| 91 | + | rp.widthAtomic.Store(123) | |||
| 92 | + | rp.heightAtomic.Store(456) | |||
| 93 | + | ||||
| 94 | + | rp.onResize(0, 0) | |||
| 95 | + | ||||
| 96 | + | require.False(t, called) | |||
| 97 | + | require.Equal(t, int32(123), rp.widthAtomic.Load()) | |||
| 98 | + | require.Equal(t, int32(456), rp.heightAtomic.Load()) | |||
| 99 | + | require.False(t, rp.sizeChanged) | |||
| 100 | + | require.NotNil(t, rp.onFirstResize) | |||
| 101 | + | ||||
| 102 | + | rp.onResize(10, 20) | |||
| 103 | + | ||||
| 104 | + | require.True(t, called) | |||
| 105 | + | require.Equal(t, int32(10), rp.widthAtomic.Load()) | |||
| 106 | + | require.Equal(t, int32(20), rp.heightAtomic.Load()) | |||
| 107 | + | require.True(t, rp.sizeChanged) | |||
| 108 | + | require.Nil(t, rp.onFirstResize) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 83 | // sortRects sorts rects by (X, Y) so tests can compare without caring about order. | 111 | // sortRects sorts rects by (X, Y) so tests can compare without caring about order. | ||
| 84 | func sortRects(rects []rect) { | 112 | func sortRects(rects []rect) { | ||
| 85 | sort.Slice(rects, func(i, j int) bool { | 113 | sort.Slice(rects, func(i, j int) bool { | ||
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 +59 -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,26 @@ 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.shouldAcceptMainViewPaint(width, height, dirtyRects, paintSeq) { | |
| 225 | + | return | |
| 212 | 226 | } | |
| 213 | 227 | rects := make([]rect, len(dirtyRects)) | |
| 214 | 228 | for i, dr := range dirtyRects { | |
| @@ -240,6 +254,47 @@ func (h *handlerSet) OnPaint( | |||
| 240 | 254 | } | |
| 241 | 255 | } | |
| 242 | 256 | ||
| 257 | + | func (h *handlerSet) shouldAcceptMainViewPaint(width, height int32, dirtyRects []purecef.Rect, paintSeq uint64) bool { | |
| 258 | + | resizeSeq, resizeAgeMs := h.wv.pipeline.latestResizeDiagnostics() | |
| 259 | + | expectedWidth, expectedHeight, _ := h.wv.pipeline.viewRectSize() | |
| 260 | + | sizeMatchesCurrentView := width == expectedWidth && height == expectedHeight | |
| 261 | + | ||
| 262 | + | if h.wv.resizeReconciler != nil { | |
| 263 | + | h.wv.resizeReconciler.notePaint(resizeSeq, sizeMatchesCurrentView) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | if h.wv.ctx != nil { | |
| 267 | + | if sizeMatchesCurrentView { | |
| 268 | + | _, _, shouldLog := h.wv.pipeline.markFirstPaintAfterResize() | |
| 269 | + | if shouldLog { | |
| 270 | + | logging.FromContext(h.wv.ctx).Debug(). | |
| 271 | + | Uint64("webview_id", uint64(h.wv.id)). | |
| 272 | + | Uint64("paint_seq", paintSeq). | |
| 273 | + | Uint64("resize_seq", resizeSeq). | |
| 274 | + | Int64("time_since_resize_ms", resizeAgeMs). | |
| 275 | + | Int32("width", width). | |
| 276 | + | Int32("height", height). | |
| 277 | + | Int("dirty_rect_count", len(dirtyRects)). | |
| 278 | + | Msg("cef: first paint after resize") | |
| 279 | + | } | |
| 280 | + | } else if h.wv.pipeline.markStalePaintAfterResize(resizeSeq) { | |
| 281 | + | logging.FromContext(h.wv.ctx).Debug(). | |
| 282 | + | Uint64("webview_id", uint64(h.wv.id)). | |
| 283 | + | Uint64("paint_seq", paintSeq). | |
| 284 | + | Uint64("resize_seq", resizeSeq). | |
| 285 | + | Int64("time_since_resize_ms", resizeAgeMs). | |
| 286 | + | Int32("width", width). | |
| 287 | + | Int32("height", height). | |
| 288 | + | Int32("expected_width", expectedWidth). | |
| 289 | + | Int32("expected_height", expectedHeight). | |
| 290 | + | Int("dirty_rect_count", len(dirtyRects)). | |
| 291 | + | Msg("cef: stale-size paint after resize") | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | return sizeMatchesCurrentView | |
| 296 | + | } | |
| 297 | + | ||
| 243 | 298 | func (h *handlerSet) OnAcceleratedPaint(_ purecef.Browser, _ purecef.PaintElementType, _ []purecef.Rect, _ *purecef.AcceleratedPaintInfo) { | |
| 244 | 299 | count := h.wv.pipeline.recordAcceleratedPaint() | |
| 245 | 300 | if count <= 5 || count%100 == 0 { | |
| @@ -561,6 +616,7 @@ func (h *handlerSet) OnBeforeClose(_ purecef.Browser) { | |||
| 561 | 616 | h.wv.findCtrl.setHost(nil) | |
| 562 | 617 | } | |
| 563 | 618 | h.wv.mu.Unlock() | |
| 619 | + | h.wv.resizeReconciler.stop() | |
| 564 | 620 | h.wv.scheduleStopBeginFrameLoop() | |
| 565 | 621 | ||
| 566 | 622 | 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,26 @@ 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.shouldAcceptMainViewPaint(width, height, dirtyRects, paintSeq) { | |||
| 225 | + | return | |||
| 212 | } | 226 | } | ||
| 213 | rects := make([]rect, len(dirtyRects)) | 227 | rects := make([]rect, len(dirtyRects)) | ||
| 214 | for i, dr := range dirtyRects { | 228 | for i, dr := range dirtyRects { | ||
| @@ -240,6 +254,47 @@ func (h *handlerSet) OnPaint( | |||||
| 240 | } | 254 | } | ||
| 241 | } | 255 | } | ||
| 242 | 256 | ||||
| 257 | + | func (h *handlerSet) shouldAcceptMainViewPaint(width, height int32, dirtyRects []purecef.Rect, paintSeq uint64) bool { | |||
| 258 | + | resizeSeq, resizeAgeMs := h.wv.pipeline.latestResizeDiagnostics() | |||
| 259 | + | expectedWidth, expectedHeight, _ := h.wv.pipeline.viewRectSize() | |||
| 260 | + | sizeMatchesCurrentView := width == expectedWidth && height == expectedHeight | |||
| 261 | + | ||||
| 262 | + | if h.wv.resizeReconciler != nil { | |||
| 263 | + | h.wv.resizeReconciler.notePaint(resizeSeq, sizeMatchesCurrentView) | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | if h.wv.ctx != nil { | |||
| 267 | + | if sizeMatchesCurrentView { | |||
| 268 | + | _, _, shouldLog := h.wv.pipeline.markFirstPaintAfterResize() | |||
| 269 | + | if shouldLog { | |||
| 270 | + | logging.FromContext(h.wv.ctx).Debug(). | |||
| 271 | + | Uint64("webview_id", uint64(h.wv.id)). | |||
| 272 | + | Uint64("paint_seq", paintSeq). | |||
| 273 | + | Uint64("resize_seq", resizeSeq). | |||
| 274 | + | Int64("time_since_resize_ms", resizeAgeMs). | |||
| 275 | + | Int32("width", width). | |||
| 276 | + | Int32("height", height). | |||
| 277 | + | Int("dirty_rect_count", len(dirtyRects)). | |||
| 278 | + | Msg("cef: first paint after resize") | |||
| 279 | + | } | |||
| 280 | + | } else if h.wv.pipeline.markStalePaintAfterResize(resizeSeq) { | |||
| 281 | + | logging.FromContext(h.wv.ctx).Debug(). | |||
| 282 | + | Uint64("webview_id", uint64(h.wv.id)). | |||
| 283 | + | Uint64("paint_seq", paintSeq). | |||
| 284 | + | Uint64("resize_seq", resizeSeq). | |||
| 285 | + | Int64("time_since_resize_ms", resizeAgeMs). | |||
| 286 | + | Int32("width", width). | |||
| 287 | + | Int32("height", height). | |||
| 288 | + | Int32("expected_width", expectedWidth). | |||
| 289 | + | Int32("expected_height", expectedHeight). | |||
| 290 | + | Int("dirty_rect_count", len(dirtyRects)). | |||
| 291 | + | Msg("cef: stale-size paint after resize") | |||
| 292 | + | } | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | return sizeMatchesCurrentView | |||
| 296 | + | } | |||
| 297 | + | ||||
| 243 | func (h *handlerSet) OnAcceleratedPaint(_ purecef.Browser, _ purecef.PaintElementType, _ []purecef.Rect, _ *purecef.AcceleratedPaintInfo) { | 298 | func (h *handlerSet) OnAcceleratedPaint(_ purecef.Browser, _ purecef.PaintElementType, _ []purecef.Rect, _ *purecef.AcceleratedPaintInfo) { | ||
| 244 | count := h.wv.pipeline.recordAcceleratedPaint() | 299 | count := h.wv.pipeline.recordAcceleratedPaint() | ||
| 245 | if count <= 5 || count%100 == 0 { | 300 | if count <= 5 || count%100 == 0 { | ||
| @@ -561,6 +616,7 @@ func (h *handlerSet) OnBeforeClose(_ purecef.Browser) { | |||||
| 561 | h.wv.findCtrl.setHost(nil) | 616 | h.wv.findCtrl.setHost(nil) | ||
| 562 | } | 617 | } | ||
| 563 | h.wv.mu.Unlock() | 618 | h.wv.mu.Unlock() | ||
| 619 | + | h.wv.resizeReconciler.stop() | |||
| 564 | h.wv.scheduleStopBeginFrameLoop() | 620 | h.wv.scheduleStopBeginFrameLoop() | ||
| 565 | 621 | ||||
| 566 | h.wv.mu.RLock() | 622 | h.wv.mu.RLock() | ||
M internal/infrastructure/cef/webview_handlers_test.go +24 -0
| @@ -137,3 +137,27 @@ 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 | + | pipeline: &renderPipeline{ | |
| 145 | + | ctx: context.Background(), | |
| 146 | + | scale: 1, | |
| 147 | + | }, | |
| 148 | + | } | |
| 149 | + | wv.pipeline.widthAtomic.Store(1269) | |
| 150 | + | wv.pipeline.heightAtomic.Store(1035) | |
| 151 | + | ||
| 152 | + | h := &handlerSet{wv: wv} | |
| 153 | + | buffer := make([]byte, 1269*2106*4) | |
| 154 | + | ||
| 155 | + | h.OnPaint(nil, purecef.PaintElementTypePetView, []purecef.Rect{{X: 0, Y: 0, Width: 1269, Height: 2106}}, buffer, 1269, 2106) | |
| 156 | + | ||
| 157 | + | require.Equal(t, int32(1269), wv.pipeline.widthAtomic.Load()) | |
| 158 | + | require.Equal(t, int32(1035), wv.pipeline.heightAtomic.Load()) | |
| 159 | + | require.Nil(t, wv.pipeline.staging) | |
| 160 | + | require.Zero(t, wv.pipeline.lastQueuedPaintSeq.Load()) | |
| 161 | + | require.False(t, wv.pipeline.needsUpload) | |
| 162 | + | require.Empty(t, wv.pipeline.dirtyRects) | |
| 163 | + | } | |
| @@ -137,3 +137,27 @@ 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 | + | pipeline: &renderPipeline{ | |||
| 145 | + | ctx: context.Background(), | |||
| 146 | + | scale: 1, | |||
| 147 | + | }, | |||
| 148 | + | } | |||
| 149 | + | wv.pipeline.widthAtomic.Store(1269) | |||
| 150 | + | wv.pipeline.heightAtomic.Store(1035) | |||
| 151 | + | ||||
| 152 | + | h := &handlerSet{wv: wv} | |||
| 153 | + | buffer := make([]byte, 1269*2106*4) | |||
| 154 | + | ||||
| 155 | + | h.OnPaint(nil, purecef.PaintElementTypePetView, []purecef.Rect{{X: 0, Y: 0, Width: 1269, Height: 2106}}, buffer, 1269, 2106) | |||
| 156 | + | ||||
| 157 | + | require.Equal(t, int32(1269), wv.pipeline.widthAtomic.Load()) | |||
| 158 | + | require.Equal(t, int32(1035), wv.pipeline.heightAtomic.Load()) | |||
| 159 | + | require.Nil(t, wv.pipeline.staging) | |||
| 160 | + | require.Zero(t, wv.pipeline.lastQueuedPaintSeq.Load()) | |||
| 161 | + | require.False(t, wv.pipeline.needsUpload) | |||
| 162 | + | require.Empty(t, wv.pipeline.dirtyRects) | |||
| 163 | + | } | |||
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() | ||