detached 371af32

Merge branch 'fix/cef-resize-tracing'

commit
371af321304a0b703a4e860331ea4034dd80960b
author
brice · · 2026-04-13 08:03 UTC
merge of
dfcdb26 + b6fcae6 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
18 files · +988 -37
M go.mod +1 -1
@@ -71,4 +71,4 @@ require (
7171 modernc.org/sqlite v1.47.0 // indirect
7272 )
7373
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 // indirect71 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-91aa177a270474+replace github.com/ebitengine/purego => github.com/bnema/purego v0.0.0-20260413043705-7850e384cde9
M go.sum +2 -2 generated file: collapsed
M internal/infrastructure/cef/factory.go +53 -4
@@ -38,6 +38,11 @@ type webViewFactoryOptions struct {
3838 audioOutputFactory port.AudioOutputFactory
3939 }
4040
41+type resizeNotifiableBrowserHost interface {
42+ WasResized()
43+ Invalidate(purecef.PaintElementType)
44+}
45+
4146 // newWebViewFactory returns a factory that will create WebViews using the
4247 // given GL loader and HiDPI scale factor.
4348 func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) *WebViewFactory {
@@ -72,7 +77,7 @@ func (f *WebViewFactory) setDefaultBackgroundColor(r, g, b, a float64) {
7277 func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
7378 id := port.WebViewID(f.nextID.Add(1))
7479
75- pipeline := newRenderPipeline(ctx, f.gl, f.scale)
80+ pipeline := newRenderPipeline(ctx, f.gl, f.scale, id)
7681
7782 wv := &WebView{
7883 id: id,
@@ -80,6 +85,7 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
8085 engine: f.engine,
8186 pipeline: pipeline,
8287 audioOutputFactory: f.audioOutputFactory,
88+ resizeReconciler: newResizeReconciler(ctx, id),
8389 }
8490
8591 var transcodingHandler purecef.ResourceRequestHandler
@@ -205,14 +211,57 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
205211 }
206212
207213 // 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)
209216 wv.mu.RLock()
210217 host := wv.host
211218 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
214228 }
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
215262 }
263+ host.WasResized()
264+ host.Invalidate(purecef.PaintElementTypePetView)
216265 }
217266
218267 // CreateRelated creates a WebView that shares session/cookies with the parent.
@@ -38,6 +38,11 @@ type webViewFactoryOptions struct {
38 audioOutputFactory port.AudioOutputFactory38 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 the46 // 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.ResourceRequestHandler91 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.host217 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 (
77 "time"
88 "unsafe"
99
10+ "github.com/bnema/dumber/internal/application/port"
1011 "github.com/bnema/dumber/internal/logging"
1112 "github.com/bnema/puregotk/v4/gdk"
1213 "github.com/bnema/puregotk/v4/gtk"
@@ -25,9 +26,10 @@ type rect struct {
2526 // and GTK later uploads from staging → PBO → texture before drawing a
2627 // fullscreen quad. Two PBOs alternate so GPU DMA and CPU writes overlap.
2728 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
3133
3234 // GL resources (created on first render).
3335 texture uint32
@@ -71,7 +73,11 @@ type renderPipeline struct {
7173 viewRectSeq atomic.Uint64
7274 screenInfoSeq atomic.Uint64
7375 paintSeq atomic.Uint64
76+ resizeSeq atomic.Uint64
7477 lastQueuedPaintSeq atomic.Uint64
78+ lastResizeUnixNano atomic.Int64
79+ lastResizePaintLogSeq atomic.Uint64
80+ lastResizeStaleLogSeq atomic.Uint64
7581 glRenderSeq atomic.Uint64
7682 paintCount uint64
7783 acceleratedPaintCount uint64
@@ -139,15 +145,16 @@ var quadVertices = [16]float32{
139145
140146 // newRenderPipeline creates a GtkGLArea and wires up the render and resize
141147 // 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 {
143149 if scale < 1 {
144150 scale = 1
145151 }
146152
147153 rp := &renderPipeline{
148- ctx: ctx,
149- gl: gl,
150- scale: scale,
154+ webviewID: webviewID,
155+ ctx: ctx,
156+ gl: gl,
157+ scale: scale,
151158 }
152159
153160 rp.glArea = gtk.NewGLArea()
@@ -292,6 +299,21 @@ func (rp *renderPipeline) onGLRender() bool {
292299
293300 rp.mu.Unlock()
294301
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+
295317 if !rp.glReady || sizeChanged {
296318 rp.initGL(uploadWidth, uploadHeight)
297319 }
@@ -819,13 +841,27 @@ func compileShader(gl *glLoader, shaderType uint32, source string) uint32 {
819841 // onResize is the GTK "resize" signal handler. Dimensions are in CSS pixels;
820842 // we multiply by scale to get device pixels.
821843 func (rp *renderPipeline) onResize(width, height int32) {
844+ if width <= 0 || height <= 0 {
845+ return
846+ }
847+
822848 rp.mu.Lock()
823849
850+ prevWidth := rp.widthAtomic.Load()
851+ prevHeight := rp.heightAtomic.Load()
852+ scale := rp.scale
853+ if scale <= 0 {
854+ scale = 1
855+ }
856+
824857 scaled := func(v int32) int32 {
825- return v * rp.scale
858+ return v * scale
826859 }
827860
828861 w, h := scaled(width), scaled(height)
862+ resizeSeq := rp.resizeSeq.Add(1)
863+ now := time.Now()
864+ rp.lastResizeUnixNano.Store(now.UnixNano())
829865 rp.widthAtomic.Store(w)
830866 rp.heightAtomic.Store(h)
831867 rp.sizeChanged = true
@@ -837,6 +873,22 @@ func (rp *renderPipeline) onResize(width, height int32) {
837873 resizeCB := rp.onResizeCB
838874 rp.mu.Unlock()
839875
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+
840892 if firstCB != nil {
841893 firstCB(w, h)
842894 } else if resizeCB != nil {
@@ -918,6 +970,50 @@ func (rp *renderPipeline) viewRectSize() (width, height, scale int32) {
918970 return width, height, scale
919971 }
920972
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+
9211017 func (rp *renderPipeline) nextViewRectSeq() uint64 {
9221018 return rp.viewRectSeq.Add(1)
9231019 }
@@ -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 a26 // 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.Context29+ webviewID port.WebViewID
29- gl *glLoader30+ ctx context.Context
30- glArea *gtk.GLArea31+ gl *glLoader
32+ glArea *gtk.GLArea
31 33
32 // GL resources (created on first render).34 // GL resources (created on first render).
33 texture uint3235 texture uint32
@@ -71,7 +73,11 @@ type renderPipeline struct {
71 viewRectSeq atomic.Uint6473 viewRectSeq atomic.Uint64
72 screenInfoSeq atomic.Uint6474 screenInfoSeq atomic.Uint64
73 paintSeq atomic.Uint6475 paintSeq atomic.Uint64
76+ resizeSeq atomic.Uint64
74 lastQueuedPaintSeq atomic.Uint6477 lastQueuedPaintSeq atomic.Uint64
78+ lastResizeUnixNano atomic.Int64
79+ lastResizePaintLogSeq atomic.Uint64
80+ lastResizeStaleLogSeq atomic.Uint64
75 glRenderSeq atomic.Uint6481 glRenderSeq atomic.Uint64
76 paintCount uint6482 paintCount uint64
77 acceleratedPaintCount uint6483 acceleratedPaintCount uint64
@@ -139,15 +145,16 @@ var quadVertices = [16]float32{
139 145
140 // newRenderPipeline creates a GtkGLArea and wires up the render and resize146 // 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 = 1150 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.scale858+ 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 = true867 rp.sizeChanged = true
@@ -837,6 +873,22 @@ func (rp *renderPipeline) onResize(width, height int32) {
837 resizeCB := rp.onResizeCB873 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, scale970 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) {
8080 require.False(t, rp.forceFullUpload)
8181 }
8282
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+
83111 // sortRects sorts rects by (X, Y) so tests can compare without caring about order.
84112 func sortRects(rects []rect) {
85113 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")
3333 // WebView implements port.WebView using a CEF off-screen browser rendered
3434 // through a renderPipeline and driven by an inputBridge.
3535 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
4647
4748 // beginFrameTick drives CEF external BeginFrame requests while the GTK
4849 // widget is visible. Access is guarded by mu.
@@ -472,6 +473,7 @@ func (wv *WebView) Destroy() {
472473 if !wv.destroyed.CompareAndSwap(false, true) {
473474 return
474475 }
476+ wv.resizeReconciler.stop()
475477 wv.closeAudioStream()
476478 wv.scheduleStopBeginFrameLoop()
477479 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 rendered33 // 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.WebViewID36+ id port.WebViewID
37- ctx context.Context37+ ctx context.Context
38- engine *Engine38+ engine *Engine
39- browser purecef.Browser39+ browser purecef.Browser
40- host purecef.BrowserHost40+ host purecef.BrowserHost
41- client purecef.Client // prevent GC from collecting the client before CEF AddRef's it41+ client purecef.Client // prevent GC from collecting the client before CEF AddRef's it
42- pipeline *renderPipeline42+ pipeline *renderPipeline
43- input *inputBridge43+ input *inputBridge
44- handlers *handlerSet44+ handlers *handlerSet
45- findCtrl *cefFindController45+ findCtrl *cefFindController
46+ resizeReconciler *resizeReconciler
46 47
47 // beginFrameTick drives CEF external BeginFrame requests while the GTK48 // 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 return474 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) {
114114 rect.Height = ht
115115 if h.wv != nil && h.wv.ctx != nil {
116116 logging.FromContext(h.wv.ctx).Debug().
117+ Uint64("webview_id", uint64(h.wv.id)).
117118 Uint64("view_rect_seq", callSeq).
118119 Int32("width", rect.Width).
119120 Int32("height", rect.Height).
@@ -155,6 +156,7 @@ func (h *handlerSet) GetScreenInfo(_ purecef.Browser, info *purecef.ScreenInfo)
155156 *info = si
156157 if h.wv != nil && h.wv.ctx != nil {
157158 logging.FromContext(h.wv.ctx).Debug().
159+ Uint64("webview_id", uint64(h.wv.id)).
158160 Uint64("screen_info_seq", callSeq).
159161 Int32("width", w).
160162 Int32("height", ht).
@@ -201,14 +203,26 @@ func (h *handlerSet) OnPaint(
201203 return
202204 }
203205 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+ }
204210 if h.wv != nil && h.wv.ctx != nil {
205- logging.FromContext(h.wv.ctx).Trace().
211+ log := logging.FromContext(h.wv.ctx).Trace().
206212 Uint64("paint_seq", paintSeq).
207213 Int32("width", width).
208214 Int32("height", height).
209215 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
212226 }
213227 rects := make([]rect, len(dirtyRects))
214228 for i, dr := range dirtyRects {
@@ -240,6 +254,47 @@ func (h *handlerSet) OnPaint(
240254 }
241255 }
242256
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+
243298 func (h *handlerSet) OnAcceleratedPaint(_ purecef.Browser, _ purecef.PaintElementType, _ []purecef.Rect, _ *purecef.AcceleratedPaintInfo) {
244299 count := h.wv.pipeline.recordAcceleratedPaint()
245300 if count <= 5 || count%100 == 0 {
@@ -561,6 +616,7 @@ func (h *handlerSet) OnBeforeClose(_ purecef.Browser) {
561616 h.wv.findCtrl.setHost(nil)
562617 }
563618 h.wv.mu.Unlock()
619+ h.wv.resizeReconciler.stop()
564620 h.wv.scheduleStopBeginFrameLoop()
565621
566622 h.wv.mu.RLock()
@@ -114,6 +114,7 @@ func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) {
114 rect.Height = ht114 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 = si156 *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 return203 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) {
137137
138138 require.Same(t, h, h.GetContextMenuHandler())
139139 }
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) {
148148 pv.webViewWidget = widget
149149
150150 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+
151158 // If this widget already had a parent, we're reparenting an existing WebView
152159 // (e.g. after rebuilding/moving panes). In that case the WebView has likely
153160 // 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- }
159161
160162 // Unparent widget from any previous parent (critical for rebuild scenarios)
161163 // In GTK4, a widget can only have one parent at a time
@@ -170,6 +172,20 @@ func (pv *PaneView) SetWebViewWidget(widget layout.Widget) {
170172 if hadParent && pv.loading != nil {
171173 pv.loading.SetVisible(false)
172174 }
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")
173189 }
174190 }
175191
@@ -148,14 +148,16 @@ func (pv *PaneView) SetWebViewWidget(widget layout.Widget) {
148 pv.webViewWidget = widget148 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 WebView158 // 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 likely159 // (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 time163 // 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) {
211211
212212 // Expect removal of old widget and addition of new
213213 mockOverlay.EXPECT().SetChild(nil).Once()
214+ mockOverlay.EXPECT().GetAllocatedWidth().Return(0).Twice()
215+ mockOverlay.EXPECT().GetAllocatedHeight().Return(0).Twice()
214216 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()
215220 mockOverlay.EXPECT().SetChild(mockNewWebView).Once()
216221
217222 // Act
@@ -233,7 +238,12 @@ func TestSetWebViewWidget_FromNil(t *testing.T) {
233238 pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), nil)
234239
235240 // 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()
236243 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()
237247 mockOverlay.EXPECT().SetChild(mockNewWebView).Once()
238248
239249 // Act
@@ -211,7 +211,12 @@ func TestSetWebViewWidget_ReplacesWidget(t *testing.T) {
211 211
212 // Expect removal of old widget and addition of new212 // 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 // Act222 // 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 // Act249 // Act
M internal/ui/coordinator/content/lifecycle.go +15 -1
@@ -18,6 +18,10 @@ func (c *Coordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (
1818 log := logging.FromContext(ctx)
1919
2020 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")
2125 return wv, nil
2226 }
2327
@@ -39,7 +43,10 @@ func (c *Coordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (
3943 c.setWebViewLocked(paneID, wv)
4044 c.setupWebViewCallbacks(ctx, paneID, wv)
4145
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")
4350 return wv, nil
4451 }
4552
@@ -121,6 +128,13 @@ func (c *Coordinator) AttachToWorkspace(ctx context.Context, ws *entity.Workspac
121128 continue
122129 }
123130
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+
124138 if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil {
125139 log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget")
126140 }
@@ -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, nil25 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, nil50 return wv, nil
44 }51 }
45 52
@@ -121,6 +128,13 @@ func (c *Coordinator) AttachToWorkspace(ctx context.Context, ws *entity.Workspac
121 continue128 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) {
3434
3535 wv := mocks.NewMockWebView(t)
3636 wv.EXPECT().IsDestroyed().Return(false)
37+ wv.EXPECT().ID().Return(port.WebViewID(101)).Once()
3738
3839 pool := mocks.NewMockWebViewPool(t)
3940 // pool.Acquire must NOT be called
@@ -54,6 +55,7 @@ func TestLifecycle_EnsureWebView_AcquiresFromPoolWhenNoneExists(t *testing.T) {
5455 newWV := mocks.NewMockWebView(t)
5556 newWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
5657 newWV.EXPECT().Generation().Return(uint64(0)).Maybe()
58+ newWV.EXPECT().ID().Return(port.WebViewID(102)).Once()
5759
5860 pool := mocks.NewMockWebViewPool(t)
5961 pool.EXPECT().Acquire(mock.Anything).Return(newWV, nil)
@@ -77,6 +79,7 @@ func TestLifecycle_EnsureWebView_AcquiresFromPoolWhenExistingIsDestroyed(t *test
7779 newWV := mocks.NewMockWebView(t)
7880 newWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
7981 newWV.EXPECT().Generation().Return(uint64(0)).Maybe()
82+ newWV.EXPECT().ID().Return(port.WebViewID(103)).Once()
8083
8184 pool := mocks.NewMockWebViewPool(t)
8285 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 called40 // 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 (
8888 defaultResizeMinPanePercent = 0.1
8989 )
9090
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+
9198 // clampResizeStep clamps resizeStepPercent to (0, 0.5], returning the default (0.05) if out of range.
9299 func clampResizeStep(v float64) float64 {
93100 if v <= 0 || v > 0.5 {
@@ -516,6 +523,12 @@ func (c *WorkspaceCoordinator) doIncrementalSplit(
516523 return fmt.Errorf("no stacked view found for active pane")
517524 }
518525 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")
519532
520533 // 3. Determine if this is a root split by checking domain tree
521534 // output.ParentNode is the NEW split node (h0)
@@ -558,6 +571,7 @@ func (c *WorkspaceCoordinator) doIncrementalSplit(
558571 output,
559572 orientation,
560573 existingFirst,
574+ oldActivePaneID,
561575 ); err != nil {
562576 return err
563577 }
@@ -572,6 +586,7 @@ func (c *WorkspaceCoordinator) doIncrementalSplit(
572586 newStackedView,
573587 orientation,
574588 existingFirst,
589+ oldActivePaneID,
575590 ); err != nil {
576591 return err
577592 }
@@ -645,6 +660,7 @@ func (c *WorkspaceCoordinator) replaceRootSplit(
645660 output *usecase.SplitPaneOutput,
646661 orientation layout.Orientation,
647662 existingFirst bool,
663+ oldActivePaneID entity.PaneID,
648664 ) error {
649665 wsView.ClearRootWidgetRef()
650666 wsView.Container().Remove(existingRootWidget)
@@ -661,8 +677,19 @@ func (c *WorkspaceCoordinator) replaceRootSplit(
661677
662678 wsView.SetRootWidgetDirect(splitView.Widget())
663679 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")
664692
665- logging.FromContext(ctx).Debug().Msg("root split: replaced root with new split view")
666693 return nil
667694 }
668695
@@ -676,6 +703,7 @@ func (c *WorkspaceCoordinator) replaceNonRootSplit(
676703 newStackedView *layout.StackedView,
677704 orientation layout.Orientation,
678705 existingFirst bool,
706+ oldActivePaneID entity.PaneID,
679707 ) error {
680708 log := logging.FromContext(ctx)
681709 grandparentWidget := tr.Lookup(grandparentNode.ID)
@@ -726,10 +754,20 @@ func (c *WorkspaceCoordinator) replaceNonRootSplit(
726754 }
727755
728756 tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), orientation)
729-
757+ newSplitWidth, newSplitHeight := widgetAllocation(splitView.Widget())
730758 log.Debug().
759+ Str("old_active_pane_id", string(oldActivePaneID)).
731760 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+
733771 return nil
734772 }
735773
@@ -88,6 +88,13 @@ const (
88 defaultResizeMinPanePercent = 0.188 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 tree533 // 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 err576 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 err591 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 nil693 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 nil771 return nil
734 }772 }
735 773
M internal/ui/layout/split.go +81 -0
@@ -36,6 +36,54 @@ const maxRetryFrames = 120
3636
3737 const notifyPositionDebounceDelay = 100 * time.Millisecond
3838 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+}
3987
4088 // NewSplitView creates a new split view with the given orientation and children.
4189 // The ratio determines the initial divider position (0.0-1.0).
@@ -194,6 +242,8 @@ func (sv *SplitView) ApplyRatio() bool {
194242 sv.mu.RLock()
195243 ratio := sv.ratio
196244 orientation := sv.orientation
245+ startChild := sv.startChild
246+ endChild := sv.endChild
197247 sv.mu.RUnlock()
198248
199249 // Get the relevant dimension based on orientation
@@ -217,11 +267,19 @@ func (sv *SplitView) ApplyRatio() bool {
217267
218268 // Calculate position from ratio
219269 position := int(float64(totalSize) * ratio)
270+ startWidthBefore, startHeightBefore := childAllocation(startChild)
271+ endWidthBefore, endHeightBefore := childAllocation(endChild)
272+ currentPositionBeforeSet := sv.paned.GetPosition()
220273 sv.logger.Debug().
221274 Str("orientation", orientStr).
222275 Int("total_size", totalSize).
223276 Float64("ratio", ratio).
224277 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).
225283 Msg("ApplyRatio: setting position")
226284
227285 // Avoid treating programmatic SetPosition as a user resize.
@@ -232,6 +290,29 @@ func (sv *SplitView) ApplyRatio() bool {
232290
233291 sv.paned.SetPosition(position)
234292
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+
235316 sv.mu.Lock()
236317 sv.hasAppliedRatio = true
237318 sv.mu.Unlock()
@@ -36,6 +36,54 @@ const maxRetryFrames = 120
36 36
37 const notifyPositionDebounceDelay = 100 * time.Millisecond37 const notifyPositionDebounceDelay = 100 * time.Millisecond
38 const notifyPositionSuppressAfterApplyDelay = 50 * time.Millisecond38 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.ratio243 ratio := sv.ratio
196 orientation := sv.orientation244 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 orientation249 // Get the relevant dimension based on orientation
@@ -217,11 +267,19 @@ func (sv *SplitView) ApplyRatio() bool {
217 267
218 // Calculate position from ratio268 // 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 = true317 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) {
4646
4747 mockFactory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(mockPaned).Once()
4848 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()
4953 mockStartChild.EXPECT().SetVisible(true).Once()
5054 mockEndChild.EXPECT().SetVisible(true).Once()
5155 mockPaned.EXPECT().SetStartChild(mockStartChild).Once()
@@ -72,6 +76,10 @@ func TestNewSplitView_Vertical(t *testing.T) {
7276
7377 mockFactory.EXPECT().NewPaned(layout.OrientationVertical).Return(mockPaned).Once()
7478 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()
7583 mockStartChild.EXPECT().SetVisible(true).Once()
7684 mockEndChild.EXPECT().SetVisible(true).Once()
7785 mockPaned.EXPECT().SetStartChild(mockStartChild).Once()
@@ -184,6 +192,8 @@ func TestSwapStart_ReplacesChild(t *testing.T) {
184192
185193 mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once()
186194 setupSplitViewMocks(mockPaned, layout.OrientationHorizontal)
195+ mockOldChild.EXPECT().GetAllocatedWidth().Return(0).Maybe()
196+ mockOldChild.EXPECT().GetAllocatedHeight().Return(0).Maybe()
187197 mockOldChild.EXPECT().SetVisible(true).Once()
188198 mockPaned.EXPECT().SetStartChild(mockOldChild).Once()
189199
@@ -210,6 +220,8 @@ func TestSwapEnd_ReplacesChild(t *testing.T) {
210220
211221 mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once()
212222 setupSplitViewMocks(mockPaned, layout.OrientationHorizontal)
223+ mockOldChild.EXPECT().GetAllocatedWidth().Return(0).Maybe()
224+ mockOldChild.EXPECT().GetAllocatedHeight().Return(0).Maybe()
213225 mockOldChild.EXPECT().SetVisible(true).Once()
214226 mockPaned.EXPECT().SetEndChild(mockOldChild).Once()
215227
@@ -257,6 +269,8 @@ func TestSwapEnd_ToNil(t *testing.T) {
257269
258270 mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once()
259271 setupSplitViewMocks(mockPaned, layout.OrientationHorizontal)
272+ mockOldChild.EXPECT().GetAllocatedWidth().Return(0).Maybe()
273+ mockOldChild.EXPECT().GetAllocatedHeight().Return(0).Maybe()
260274 mockOldChild.EXPECT().SetVisible(true).Once()
261275 mockPaned.EXPECT().SetEndChild(mockOldChild).Once()
262276
@@ -309,6 +323,35 @@ func TestSetPosition_DelegatesToPaned(t *testing.T) {
309323 // Assert - verified by mock expectations
310324 }
311325
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+
312355 func TestGetPosition_DelegatesToPaned(t *testing.T) {
313356 // Arrange
314357 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 expectations323 // 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 // Arrange356 // Arrange
314 ctx := context.Background()357 ctx := context.Background()

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close