detached e8e2ac7

fix(cef): stabilize webview resizing in split layouts

Keep split ratios applying until child allocations settle and ignore stale OSR paints that would restore the old view size. This keeps stacked split transitions from snapping webviews back to their pre-resize geometry.
commit
e8e2ac715b35646830a5fe72ccdb3b331f193dec
author
brice · · 2026-04-13 07:18 UTC
parent
68fc589
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
17 files · +950 -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 +100 -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 }
@@ -821,11 +843,21 @@ func compileShader(gl *glLoader, shaderType uint32, source string) uint32 {
821843 func (rp *renderPipeline) onResize(width, height int32) {
822844 rp.mu.Lock()
823845
846+ prevWidth := rp.widthAtomic.Load()
847+ prevHeight := rp.heightAtomic.Load()
848+ scale := rp.scale
849+ if scale <= 0 {
850+ scale = 1
851+ }
852+
824853 scaled := func(v int32) int32 {
825- return v * rp.scale
854+ return v * scale
826855 }
827856
828857 w, h := scaled(width), scaled(height)
858+ resizeSeq := rp.resizeSeq.Add(1)
859+ now := time.Now()
860+ rp.lastResizeUnixNano.Store(now.UnixNano())
829861 rp.widthAtomic.Store(w)
830862 rp.heightAtomic.Store(h)
831863 rp.sizeChanged = true
@@ -837,6 +869,22 @@ func (rp *renderPipeline) onResize(width, height int32) {
837869 resizeCB := rp.onResizeCB
838870 rp.mu.Unlock()
839871
872+ if rp.ctx != nil {
873+ logging.FromContext(rp.ctx).Debug().
874+ Uint64("webview_id", uint64(rp.webviewID)).
875+ Uint64("resize_seq", resizeSeq).
876+ Int32("css_width", width).
877+ Int32("css_height", height).
878+ Int32("scaled_width", w).
879+ Int32("scaled_height", h).
880+ Int32("prev_width", prevWidth).
881+ Int32("prev_height", prevHeight).
882+ Int32("scale", scale).
883+ Bool("first_resize", firstCB != nil).
884+ Bool("has_resize_cb", resizeCB != nil).
885+ Msg("cef: GLArea resize")
886+ }
887+
840888 if firstCB != nil {
841889 firstCB(w, h)
842890 } else if resizeCB != nil {
@@ -918,6 +966,50 @@ func (rp *renderPipeline) viewRectSize() (width, height, scale int32) {
918966 return width, height, scale
919967 }
920968
969+func (rp *renderPipeline) latestResizeDiagnostics() (resizeSeq uint64, ageMs int64) {
970+ resizeSeq = rp.resizeSeq.Load()
971+ lastResizeUnixNano := rp.lastResizeUnixNano.Load()
972+ if lastResizeUnixNano == 0 {
973+ return resizeSeq, 0
974+ }
975+ age := time.Since(time.Unix(0, lastResizeUnixNano))
976+ if age <= 0 {
977+ return resizeSeq, 0
978+ }
979+ return resizeSeq, age.Milliseconds()
980+}
981+
982+func (rp *renderPipeline) markFirstPaintAfterResize() (resizeSeq uint64, ageMs int64, shouldLog bool) {
983+ resizeSeq, ageMs = rp.latestResizeDiagnostics()
984+ if resizeSeq == 0 {
985+ return resizeSeq, ageMs, false
986+ }
987+ for {
988+ lastLogged := rp.lastResizePaintLogSeq.Load()
989+ if lastLogged >= resizeSeq {
990+ return resizeSeq, ageMs, false
991+ }
992+ if rp.lastResizePaintLogSeq.CompareAndSwap(lastLogged, resizeSeq) {
993+ return resizeSeq, ageMs, true
994+ }
995+ }
996+}
997+
998+func (rp *renderPipeline) markStalePaintAfterResize(resizeSeq uint64) bool {
999+ if resizeSeq == 0 {
1000+ return false
1001+ }
1002+ for {
1003+ lastLogged := rp.lastResizeStaleLogSeq.Load()
1004+ if lastLogged >= resizeSeq {
1005+ return false
1006+ }
1007+ if rp.lastResizeStaleLogSeq.CompareAndSwap(lastLogged, resizeSeq) {
1008+ return true
1009+ }
1010+ }
1011+}
1012+
9211013 func (rp *renderPipeline) nextViewRectSeq() uint64 {
9221014 return rp.viewRectSeq.Add(1)
9231015 }
@@ -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 }
@@ -821,11 +843,21 @@ func compileShader(gl *glLoader, shaderType uint32, source string) uint32 {
821 func (rp *renderPipeline) onResize(width, height int32) {843 func (rp *renderPipeline) onResize(width, height int32) {
822 rp.mu.Lock()844 rp.mu.Lock()
823 845
846+ prevWidth := rp.widthAtomic.Load()
847+ prevHeight := rp.heightAtomic.Load()
848+ scale := rp.scale
849+ if scale <= 0 {
850+ scale = 1
851+ }
852+
824 scaled := func(v int32) int32 {853 scaled := func(v int32) int32 {
825- return v * rp.scale854+ return v * scale
826 }855 }
827 856
828 w, h := scaled(width), scaled(height)857 w, h := scaled(width), scaled(height)
858+ resizeSeq := rp.resizeSeq.Add(1)
859+ now := time.Now()
860+ rp.lastResizeUnixNano.Store(now.UnixNano())
829 rp.widthAtomic.Store(w)861 rp.widthAtomic.Store(w)
830 rp.heightAtomic.Store(h)862 rp.heightAtomic.Store(h)
831 rp.sizeChanged = true863 rp.sizeChanged = true
@@ -837,6 +869,22 @@ func (rp *renderPipeline) onResize(width, height int32) {
837 resizeCB := rp.onResizeCB869 resizeCB := rp.onResizeCB
838 rp.mu.Unlock()870 rp.mu.Unlock()
839 871
872+ if rp.ctx != nil {
873+ logging.FromContext(rp.ctx).Debug().
874+ Uint64("webview_id", uint64(rp.webviewID)).
875+ Uint64("resize_seq", resizeSeq).
876+ Int32("css_width", width).
877+ Int32("css_height", height).
878+ Int32("scaled_width", w).
879+ Int32("scaled_height", h).
880+ Int32("prev_width", prevWidth).
881+ Int32("prev_height", prevHeight).
882+ Int32("scale", scale).
883+ Bool("first_resize", firstCB != nil).
884+ Bool("has_resize_cb", resizeCB != nil).
885+ Msg("cef: GLArea resize")
886+ }
887+
840 if firstCB != nil {888 if firstCB != nil {
841 firstCB(w, h)889 firstCB(w, h)
842 } else if resizeCB != nil {890 } else if resizeCB != nil {
@@ -918,6 +966,50 @@ func (rp *renderPipeline) viewRectSize() (width, height, scale int32) {
918 return width, height, scale966 return width, height, scale
919 }967 }
920 968
969+func (rp *renderPipeline) latestResizeDiagnostics() (resizeSeq uint64, ageMs int64) {
970+ resizeSeq = rp.resizeSeq.Load()
971+ lastResizeUnixNano := rp.lastResizeUnixNano.Load()
972+ if lastResizeUnixNano == 0 {
973+ return resizeSeq, 0
974+ }
975+ age := time.Since(time.Unix(0, lastResizeUnixNano))
976+ if age <= 0 {
977+ return resizeSeq, 0
978+ }
979+ return resizeSeq, age.Milliseconds()
980+}
981+
982+func (rp *renderPipeline) markFirstPaintAfterResize() (resizeSeq uint64, ageMs int64, shouldLog bool) {
983+ resizeSeq, ageMs = rp.latestResizeDiagnostics()
984+ if resizeSeq == 0 {
985+ return resizeSeq, ageMs, false
986+ }
987+ for {
988+ lastLogged := rp.lastResizePaintLogSeq.Load()
989+ if lastLogged >= resizeSeq {
990+ return resizeSeq, ageMs, false
991+ }
992+ if rp.lastResizePaintLogSeq.CompareAndSwap(lastLogged, resizeSeq) {
993+ return resizeSeq, ageMs, true
994+ }
995+ }
996+}
997+
998+func (rp *renderPipeline) markStalePaintAfterResize(resizeSeq uint64) bool {
999+ if resizeSeq == 0 {
1000+ return false
1001+ }
1002+ for {
1003+ lastLogged := rp.lastResizeStaleLogSeq.Load()
1004+ if lastLogged >= resizeSeq {
1005+ return false
1006+ }
1007+ if rp.lastResizeStaleLogSeq.CompareAndSwap(lastLogged, resizeSeq) {
1008+ return true
1009+ }
1010+ }
1011+}
1012+
921 func (rp *renderPipeline) nextViewRectSeq() uint64 {1013 func (rp *renderPipeline) nextViewRectSeq() uint64 {
922 return rp.viewRectSeq.Add(1)1014 return rp.viewRectSeq.Add(1)
923 }1015 }
A internal/infrastructure/cef/resize_reconciler.go +271 -0
@@ -0,0 +1,271 @@
1+package cef
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ purecef "github.com/bnema/purego-cef/cef"
10+ "github.com/bnema/puregotk/v4/glib"
11+
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+const (
16+ resizeReconcileRetryDelay = 8 * time.Millisecond
17+ resizeReconcileWindow = 64 * time.Millisecond
18+)
19+
20+type resizeReconcileTimer interface {
21+ stop()
22+}
23+
24+type resizeReconcileScheduler interface {
25+ afterFunc(delay time.Duration, fn func()) resizeReconcileTimer
26+}
27+
28+type glibResizeTimer struct{ id uint }
29+
30+func (t *glibResizeTimer) stop() {
31+ if t == nil || t.id == 0 {
32+ return
33+ }
34+ glib.SourceRemove(t.id)
35+ t.id = 0
36+}
37+
38+type glibResizeScheduler struct{}
39+
40+func (glibResizeScheduler) afterFunc(delay time.Duration, fn func()) resizeReconcileTimer {
41+ cb := glib.SourceFunc(func(_ uintptr) bool {
42+ if fn != nil {
43+ fn()
44+ }
45+ return false
46+ })
47+ return &glibResizeTimer{id: glib.TimeoutAdd(uint(delay.Milliseconds()), &cb, 0)}
48+}
49+
50+type resizeReconciler struct {
51+ mu sync.Mutex
52+ webviewID port.WebViewID
53+ ctx context.Context
54+ scheduler resizeReconcileScheduler
55+ now func() time.Time
56+ session *resizeReconcileSession
57+}
58+
59+type resizeReconcileSession struct {
60+ seq uint64
61+ startedAt time.Time
62+ retryCount int
63+ timer resizeReconcileTimer
64+ hostGetter func() resizeNotifiableBrowserHost
65+ destroyed func() bool
66+}
67+
68+func newResizeReconciler(ctx context.Context, webviewID port.WebViewID) *resizeReconciler {
69+ return &resizeReconciler{
70+ webviewID: webviewID,
71+ ctx: ctx,
72+ scheduler: glibResizeScheduler{},
73+ now: time.Now,
74+ }
75+}
76+
77+func (r *resizeReconciler) clock() time.Time {
78+ if r != nil && r.now != nil {
79+ return r.now()
80+ }
81+ return time.Now()
82+}
83+
84+func (r *resizeReconciler) schedulerOrDefault() resizeReconcileScheduler {
85+ if r != nil && r.scheduler != nil {
86+ return r.scheduler
87+ }
88+ return glibResizeScheduler{}
89+}
90+
91+func (r *resizeReconciler) start(seq uint64, hostGetter func() resizeNotifiableBrowserHost, destroyed func() bool) {
92+ if r == nil || seq == 0 || hostGetter == nil {
93+ return
94+ }
95+ if destroyed != nil && destroyed() {
96+ return
97+ }
98+
99+ now := r.clock()
100+ var superseded *resizeReconcileSession
101+
102+ r.mu.Lock()
103+ if r.session != nil {
104+ superseded = r.session
105+ if superseded.timer != nil {
106+ superseded.timer.stop()
107+ }
108+ r.session = nil
109+ }
110+ session := &resizeReconcileSession{
111+ seq: seq,
112+ startedAt: now,
113+ hostGetter: hostGetter,
114+ destroyed: destroyed,
115+ }
116+ r.session = session
117+ r.mu.Unlock()
118+
119+ if superseded != nil {
120+ r.logStopped("superseded", superseded.seq, superseded.retryCount, now.Sub(superseded.startedAt).Milliseconds())
121+ }
122+ r.logStarted(seq)
123+ r.schedule(session)
124+}
125+
126+func (r *resizeReconciler) notePaint(seq uint64, sizeMatches bool) {
127+ if r == nil || seq == 0 || !sizeMatches {
128+ return
129+ }
130+ now := r.clock()
131+
132+ r.mu.Lock()
133+ session := r.session
134+ if session == nil || session.seq != seq {
135+ r.mu.Unlock()
136+ return
137+ }
138+ if session.timer != nil {
139+ session.timer.stop()
140+ session.timer = nil
141+ }
142+ r.session = nil
143+ retryCount := session.retryCount
144+ elapsed := now.Sub(session.startedAt).Milliseconds()
145+ r.mu.Unlock()
146+
147+ r.logStopped("paint-arrived", seq, retryCount, elapsed)
148+}
149+
150+func (r *resizeReconciler) stop() {
151+ if r == nil {
152+ return
153+ }
154+ r.mu.Lock()
155+ session := r.session
156+ r.session = nil
157+ r.mu.Unlock()
158+ if session != nil && session.timer != nil {
159+ session.timer.stop()
160+ }
161+}
162+
163+func (r *resizeReconciler) schedule(session *resizeReconcileSession) {
164+ if r == nil || session == nil {
165+ return
166+ }
167+ timer := r.schedulerOrDefault().afterFunc(resizeReconcileRetryDelay, func() {
168+ r.runTick(session)
169+ })
170+ r.mu.Lock()
171+ if r.session == session {
172+ session.timer = timer
173+ } else if timer != nil {
174+ timer.stop()
175+ }
176+ r.mu.Unlock()
177+}
178+
179+func (r *resizeReconciler) runTick(session *resizeReconcileSession) {
180+ if r == nil || session == nil {
181+ return
182+ }
183+ now := r.clock()
184+
185+ r.mu.Lock()
186+ if r.session != session {
187+ r.mu.Unlock()
188+ return
189+ }
190+ if session.destroyed != nil && session.destroyed() {
191+ r.session = nil
192+ r.mu.Unlock()
193+ return
194+ }
195+ host := session.hostGetter()
196+ if host == nil {
197+ r.session = nil
198+ r.mu.Unlock()
199+ return
200+ }
201+ elapsed := now.Sub(session.startedAt)
202+ if elapsed > resizeReconcileWindow {
203+ r.session = nil
204+ retryCount := session.retryCount
205+ r.mu.Unlock()
206+ r.logStopped("expired", session.seq, retryCount, elapsed.Milliseconds())
207+ return
208+ }
209+ session.retryCount++
210+ retryCount := session.retryCount
211+ seq := session.seq
212+ r.mu.Unlock()
213+
214+ host.Invalidate(purecef.PaintElementTypePetView)
215+ r.logRetry(seq, retryCount, elapsed.Milliseconds())
216+
217+ elapsedAfterInvalidate := r.clock().Sub(session.startedAt)
218+ r.mu.Lock()
219+ if r.session != session {
220+ r.mu.Unlock()
221+ return
222+ }
223+ if elapsedAfterInvalidate >= resizeReconcileWindow {
224+ r.session = nil
225+ r.mu.Unlock()
226+ r.logStopped("expired", seq, retryCount, elapsedAfterInvalidate.Milliseconds())
227+ return
228+ }
229+ newTimer := r.schedulerOrDefault().afterFunc(resizeReconcileRetryDelay, func() {
230+ r.runTick(session)
231+ })
232+ session.timer = newTimer
233+ r.mu.Unlock()
234+}
235+
236+func (r *resizeReconciler) logStarted(seq uint64) {
237+ if r == nil || r.ctx == nil {
238+ return
239+ }
240+ logging.FromContext(r.ctx).Debug().
241+ Uint64("webview_id", uint64(r.webviewID)).
242+ Uint64("resize_seq", seq).
243+ Int("retry_count", 0).
244+ Int64("elapsed_ms", 0).
245+ Msg("cef: resize reconciliation started")
246+}
247+
248+func (r *resizeReconciler) logRetry(seq uint64, retryCount int, elapsedMs int64) {
249+ if r == nil || r.ctx == nil {
250+ return
251+ }
252+ logging.FromContext(r.ctx).Debug().
253+ Uint64("webview_id", uint64(r.webviewID)).
254+ Uint64("resize_seq", seq).
255+ Int("retry_count", retryCount).
256+ Int64("elapsed_ms", elapsedMs).
257+ Msg("cef: resize reconciliation retry")
258+}
259+
260+func (r *resizeReconciler) logStopped(reason string, seq uint64, retryCount int, elapsedMs int64) {
261+ if r == nil || r.ctx == nil {
262+ return
263+ }
264+ logging.FromContext(r.ctx).Debug().
265+ Uint64("webview_id", uint64(r.webviewID)).
266+ Uint64("resize_seq", seq).
267+ Int("retry_count", retryCount).
268+ Int64("elapsed_ms", elapsedMs).
269+ Str("reason", reason).
270+ Msg("cef: resize reconciliation stopped")
271+}
@@ -0,0 +1,271 @@
1+package cef
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ purecef "github.com/bnema/purego-cef/cef"
10+ "github.com/bnema/puregotk/v4/glib"
11+
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+const (
16+ resizeReconcileRetryDelay = 8 * time.Millisecond
17+ resizeReconcileWindow = 64 * time.Millisecond
18+)
19+
20+type resizeReconcileTimer interface {
21+ stop()
22+}
23+
24+type resizeReconcileScheduler interface {
25+ afterFunc(delay time.Duration, fn func()) resizeReconcileTimer
26+}
27+
28+type glibResizeTimer struct{ id uint }
29+
30+func (t *glibResizeTimer) stop() {
31+ if t == nil || t.id == 0 {
32+ return
33+ }
34+ glib.SourceRemove(t.id)
35+ t.id = 0
36+}
37+
38+type glibResizeScheduler struct{}
39+
40+func (glibResizeScheduler) afterFunc(delay time.Duration, fn func()) resizeReconcileTimer {
41+ cb := glib.SourceFunc(func(_ uintptr) bool {
42+ if fn != nil {
43+ fn()
44+ }
45+ return false
46+ })
47+ return &glibResizeTimer{id: glib.TimeoutAdd(uint(delay.Milliseconds()), &cb, 0)}
48+}
49+
50+type resizeReconciler struct {
51+ mu sync.Mutex
52+ webviewID port.WebViewID
53+ ctx context.Context
54+ scheduler resizeReconcileScheduler
55+ now func() time.Time
56+ session *resizeReconcileSession
57+}
58+
59+type resizeReconcileSession struct {
60+ seq uint64
61+ startedAt time.Time
62+ retryCount int
63+ timer resizeReconcileTimer
64+ hostGetter func() resizeNotifiableBrowserHost
65+ destroyed func() bool
66+}
67+
68+func newResizeReconciler(ctx context.Context, webviewID port.WebViewID) *resizeReconciler {
69+ return &resizeReconciler{
70+ webviewID: webviewID,
71+ ctx: ctx,
72+ scheduler: glibResizeScheduler{},
73+ now: time.Now,
74+ }
75+}
76+
77+func (r *resizeReconciler) clock() time.Time {
78+ if r != nil && r.now != nil {
79+ return r.now()
80+ }
81+ return time.Now()
82+}
83+
84+func (r *resizeReconciler) schedulerOrDefault() resizeReconcileScheduler {
85+ if r != nil && r.scheduler != nil {
86+ return r.scheduler
87+ }
88+ return glibResizeScheduler{}
89+}
90+
91+func (r *resizeReconciler) start(seq uint64, hostGetter func() resizeNotifiableBrowserHost, destroyed func() bool) {
92+ if r == nil || seq == 0 || hostGetter == nil {
93+ return
94+ }
95+ if destroyed != nil && destroyed() {
96+ return
97+ }
98+
99+ now := r.clock()
100+ var superseded *resizeReconcileSession
101+
102+ r.mu.Lock()
103+ if r.session != nil {
104+ superseded = r.session
105+ if superseded.timer != nil {
106+ superseded.timer.stop()
107+ }
108+ r.session = nil
109+ }
110+ session := &resizeReconcileSession{
111+ seq: seq,
112+ startedAt: now,
113+ hostGetter: hostGetter,
114+ destroyed: destroyed,
115+ }
116+ r.session = session
117+ r.mu.Unlock()
118+
119+ if superseded != nil {
120+ r.logStopped("superseded", superseded.seq, superseded.retryCount, now.Sub(superseded.startedAt).Milliseconds())
121+ }
122+ r.logStarted(seq)
123+ r.schedule(session)
124+}
125+
126+func (r *resizeReconciler) notePaint(seq uint64, sizeMatches bool) {
127+ if r == nil || seq == 0 || !sizeMatches {
128+ return
129+ }
130+ now := r.clock()
131+
132+ r.mu.Lock()
133+ session := r.session
134+ if session == nil || session.seq != seq {
135+ r.mu.Unlock()
136+ return
137+ }
138+ if session.timer != nil {
139+ session.timer.stop()
140+ session.timer = nil
141+ }
142+ r.session = nil
143+ retryCount := session.retryCount
144+ elapsed := now.Sub(session.startedAt).Milliseconds()
145+ r.mu.Unlock()
146+
147+ r.logStopped("paint-arrived", seq, retryCount, elapsed)
148+}
149+
150+func (r *resizeReconciler) stop() {
151+ if r == nil {
152+ return
153+ }
154+ r.mu.Lock()
155+ session := r.session
156+ r.session = nil
157+ r.mu.Unlock()
158+ if session != nil && session.timer != nil {
159+ session.timer.stop()
160+ }
161+}
162+
163+func (r *resizeReconciler) schedule(session *resizeReconcileSession) {
164+ if r == nil || session == nil {
165+ return
166+ }
167+ timer := r.schedulerOrDefault().afterFunc(resizeReconcileRetryDelay, func() {
168+ r.runTick(session)
169+ })
170+ r.mu.Lock()
171+ if r.session == session {
172+ session.timer = timer
173+ } else if timer != nil {
174+ timer.stop()
175+ }
176+ r.mu.Unlock()
177+}
178+
179+func (r *resizeReconciler) runTick(session *resizeReconcileSession) {
180+ if r == nil || session == nil {
181+ return
182+ }
183+ now := r.clock()
184+
185+ r.mu.Lock()
186+ if r.session != session {
187+ r.mu.Unlock()
188+ return
189+ }
190+ if session.destroyed != nil && session.destroyed() {
191+ r.session = nil
192+ r.mu.Unlock()
193+ return
194+ }
195+ host := session.hostGetter()
196+ if host == nil {
197+ r.session = nil
198+ r.mu.Unlock()
199+ return
200+ }
201+ elapsed := now.Sub(session.startedAt)
202+ if elapsed > resizeReconcileWindow {
203+ r.session = nil
204+ retryCount := session.retryCount
205+ r.mu.Unlock()
206+ r.logStopped("expired", session.seq, retryCount, elapsed.Milliseconds())
207+ return
208+ }
209+ session.retryCount++
210+ retryCount := session.retryCount
211+ seq := session.seq
212+ r.mu.Unlock()
213+
214+ host.Invalidate(purecef.PaintElementTypePetView)
215+ r.logRetry(seq, retryCount, elapsed.Milliseconds())
216+
217+ elapsedAfterInvalidate := r.clock().Sub(session.startedAt)
218+ r.mu.Lock()
219+ if r.session != session {
220+ r.mu.Unlock()
221+ return
222+ }
223+ if elapsedAfterInvalidate >= resizeReconcileWindow {
224+ r.session = nil
225+ r.mu.Unlock()
226+ r.logStopped("expired", seq, retryCount, elapsedAfterInvalidate.Milliseconds())
227+ return
228+ }
229+ newTimer := r.schedulerOrDefault().afterFunc(resizeReconcileRetryDelay, func() {
230+ r.runTick(session)
231+ })
232+ session.timer = newTimer
233+ r.mu.Unlock()
234+}
235+
236+func (r *resizeReconciler) logStarted(seq uint64) {
237+ if r == nil || r.ctx == nil {
238+ return
239+ }
240+ logging.FromContext(r.ctx).Debug().
241+ Uint64("webview_id", uint64(r.webviewID)).
242+ Uint64("resize_seq", seq).
243+ Int("retry_count", 0).
244+ Int64("elapsed_ms", 0).
245+ Msg("cef: resize reconciliation started")
246+}
247+
248+func (r *resizeReconciler) logRetry(seq uint64, retryCount int, elapsedMs int64) {
249+ if r == nil || r.ctx == nil {
250+ return
251+ }
252+ logging.FromContext(r.ctx).Debug().
253+ Uint64("webview_id", uint64(r.webviewID)).
254+ Uint64("resize_seq", seq).
255+ Int("retry_count", retryCount).
256+ Int64("elapsed_ms", elapsedMs).
257+ Msg("cef: resize reconciliation retry")
258+}
259+
260+func (r *resizeReconciler) logStopped(reason string, seq uint64, retryCount int, elapsedMs int64) {
261+ if r == nil || r.ctx == nil {
262+ return
263+ }
264+ logging.FromContext(r.ctx).Debug().
265+ Uint64("webview_id", uint64(r.webviewID)).
266+ Uint64("resize_seq", seq).
267+ Int("retry_count", retryCount).
268+ Int64("elapsed_ms", elapsedMs).
269+ Str("reason", reason).
270+ Msg("cef: resize reconciliation stopped")
271+}
A internal/infrastructure/cef/resize_reconciler_test.go +192 -0
@@ -0,0 +1,192 @@
1+package cef
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ purecef "github.com/bnema/purego-cef/cef"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+type resizeHostRecorder struct {
14+ mu sync.Mutex
15+ invalidations []purecef.PaintElementType
16+}
17+
18+func (h *resizeHostRecorder) WasResized() {}
19+
20+func (h *resizeHostRecorder) Invalidate(elementType purecef.PaintElementType) {
21+ h.mu.Lock()
22+ defer h.mu.Unlock()
23+ h.invalidations = append(h.invalidations, elementType)
24+}
25+
26+func (h *resizeHostRecorder) invalidationCount() int {
27+ h.mu.Lock()
28+ defer h.mu.Unlock()
29+ return len(h.invalidations)
30+}
31+
32+type fakeResizeTimer struct {
33+ id uint
34+ cancel func()
35+}
36+
37+func (t *fakeResizeTimer) stop() {
38+ if t != nil && t.cancel != nil {
39+ t.cancel()
40+ }
41+}
42+
43+type fakeResizeScheduler struct {
44+ mu sync.Mutex
45+ nextID uint
46+ timers map[uint]func()
47+ delays []time.Duration
48+ stopped map[uint]bool
49+}
50+
51+func newFakeResizeScheduler() *fakeResizeScheduler {
52+ return &fakeResizeScheduler{
53+ timers: make(map[uint]func()),
54+ stopped: make(map[uint]bool),
55+ }
56+}
57+
58+func (s *fakeResizeScheduler) afterFunc(delay time.Duration, fn func()) resizeReconcileTimer {
59+ s.mu.Lock()
60+ defer s.mu.Unlock()
61+ s.nextID++
62+ id := s.nextID
63+ s.timers[id] = fn
64+ s.delays = append(s.delays, delay)
65+ return &fakeResizeTimer{
66+ id: id,
67+ cancel: func() {
68+ s.mu.Lock()
69+ defer s.mu.Unlock()
70+ s.stopped[id] = true
71+ },
72+ }
73+}
74+
75+func (s *fakeResizeScheduler) fireLast() {
76+ s.mu.Lock()
77+ id := s.nextID
78+ fn := s.timers[id]
79+ stopped := s.stopped[id]
80+ s.mu.Unlock()
81+ if fn == nil || stopped {
82+ return
83+ }
84+ fn()
85+}
86+
87+func (s *fakeResizeScheduler) delayCount() int {
88+ s.mu.Lock()
89+ defer s.mu.Unlock()
90+ return len(s.delays)
91+}
92+
93+func TestResizeReconciler_SchedulesRetriesWhenNoPaintObserved(t *testing.T) {
94+ clock := time.Unix(0, 0)
95+ scheduler := newFakeResizeScheduler()
96+ host := &resizeHostRecorder{}
97+ rr := &resizeReconciler{
98+ ctx: context.Background(),
99+ scheduler: scheduler,
100+ now: func() time.Time { return clock },
101+ }
102+
103+ rr.start(7, func() resizeNotifiableBrowserHost { return host }, func() bool { return false })
104+ require.Equal(t, 1, scheduler.delayCount())
105+ require.Equal(t, 0, host.invalidationCount())
106+
107+ clock = clock.Add(8 * time.Millisecond)
108+ scheduler.fireLast()
109+ require.Equal(t, 1, host.invalidationCount())
110+ require.Equal(t, 2, scheduler.delayCount())
111+}
112+
113+func TestResizeReconciler_StopsAfterMatchingPaintArrives(t *testing.T) {
114+ clock := time.Unix(0, 0)
115+ scheduler := newFakeResizeScheduler()
116+ host := &resizeHostRecorder{}
117+ rr := &resizeReconciler{
118+ ctx: context.Background(),
119+ scheduler: scheduler,
120+ now: func() time.Time { return clock },
121+ }
122+
123+ rr.start(9, func() resizeNotifiableBrowserHost { return host }, func() bool { return false })
124+ rr.notePaint(9, true)
125+
126+ clock = clock.Add(8 * time.Millisecond)
127+ scheduler.fireLast()
128+ require.Zero(t, host.invalidationCount())
129+ require.Equal(t, 1, scheduler.delayCount())
130+}
131+
132+func TestResizeReconciler_StaleSizePaintDoesNotStopRetries(t *testing.T) {
133+ clock := time.Unix(0, 0)
134+ scheduler := newFakeResizeScheduler()
135+ host := &resizeHostRecorder{}
136+ rr := &resizeReconciler{
137+ ctx: context.Background(),
138+ scheduler: scheduler,
139+ now: func() time.Time { return clock },
140+ }
141+
142+ rr.start(10, func() resizeNotifiableBrowserHost { return host }, func() bool { return false })
143+ rr.notePaint(10, false)
144+
145+ clock = clock.Add(8 * time.Millisecond)
146+ scheduler.fireLast()
147+ require.Equal(t, 1, host.invalidationCount())
148+ require.Equal(t, 2, scheduler.delayCount())
149+}
150+
151+func TestResizeReconciler_NewerResizeSupersedesOlderSession(t *testing.T) {
152+ clock := time.Unix(0, 0)
153+ scheduler := newFakeResizeScheduler()
154+ host := &resizeHostRecorder{}
155+ rr := &resizeReconciler{
156+ ctx: context.Background(),
157+ scheduler: scheduler,
158+ now: func() time.Time { return clock },
159+ }
160+
161+ rr.start(1, func() resizeNotifiableBrowserHost { return host }, func() bool { return false })
162+ firstDelayCount := scheduler.delayCount()
163+ rr.start(2, func() resizeNotifiableBrowserHost { return host }, func() bool { return false })
164+ require.Equal(t, firstDelayCount+1, scheduler.delayCount())
165+
166+ clock = clock.Add(8 * time.Millisecond)
167+ scheduler.fireLast()
168+ require.Equal(t, 1, host.invalidationCount())
169+}
170+
171+func TestResizeReconciler_ExpiresAfter64ms(t *testing.T) {
172+ clock := time.Unix(0, 0)
173+ scheduler := newFakeResizeScheduler()
174+ host := &resizeHostRecorder{}
175+ rr := &resizeReconciler{
176+ ctx: context.Background(),
177+ scheduler: scheduler,
178+ now: func() time.Time { return clock },
179+ }
180+
181+ rr.start(11, func() resizeNotifiableBrowserHost { return host }, func() bool { return false })
182+ for i := 0; i < 8; i++ {
183+ clock = clock.Add(8 * time.Millisecond)
184+ scheduler.fireLast()
185+ }
186+
187+ require.Equal(t, 8, host.invalidationCount())
188+ require.Equal(t, 8, scheduler.delayCount())
189+ clock = clock.Add(8 * time.Millisecond)
190+ scheduler.fireLast()
191+ require.Equal(t, 8, host.invalidationCount())
192+}
@@ -0,0 +1,192 @@
1+package cef
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ purecef "github.com/bnema/purego-cef/cef"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+type resizeHostRecorder struct {
14+ mu sync.Mutex
15+ invalidations []purecef.PaintElementType
16+}
17+
18+func (h *resizeHostRecorder) WasResized() {}
19+
20+func (h *resizeHostRecorder) Invalidate(elementType purecef.PaintElementType) {
21+ h.mu.Lock()
22+ defer h.mu.Unlock()
23+ h.invalidations = append(h.invalidations, elementType)
24+}
25+
26+func (h *resizeHostRecorder) invalidationCount() int {
27+ h.mu.Lock()
28+ defer h.mu.Unlock()
29+ return len(h.invalidations)
30+}
31+
32+type fakeResizeTimer struct {
33+ id uint
34+ cancel func()
35+}
36+
37+func (t *fakeResizeTimer) stop() {
38+ if t != nil && t.cancel != nil {
39+ t.cancel()
40+ }
41+}
42+
43+type fakeResizeScheduler struct {
44+ mu sync.Mutex
45+ nextID uint
46+ timers map[uint]func()
47+ delays []time.Duration
48+ stopped map[uint]bool
49+}
50+
51+func newFakeResizeScheduler() *fakeResizeScheduler {
52+ return &fakeResizeScheduler{
53+ timers: make(map[uint]func()),
54+ stopped: make(map[uint]bool),
55+ }
56+}
57+
58+func (s *fakeResizeScheduler) afterFunc(delay time.Duration, fn func()) resizeReconcileTimer {
59+ s.mu.Lock()
60+ defer s.mu.Unlock()
61+ s.nextID++
62+ id := s.nextID
63+ s.timers[id] = fn
64+ s.delays = append(s.delays, delay)
65+ return &fakeResizeTimer{
66+ id: id,
67+ cancel: func() {
68+ s.mu.Lock()
69+ defer s.mu.Unlock()
70+ s.stopped[id] = true
71+ },
72+ }
73+}
74+
75+func (s *fakeResizeScheduler) fireLast() {
76+ s.mu.Lock()
77+ id := s.nextID
78+ fn := s.timers[id]
79+ stopped := s.stopped[id]
80+ s.mu.Unlock()
81+ if fn == nil || stopped {
82+ return
83+ }
84+ fn()
85+}
86+
87+func (s *fakeResizeScheduler) delayCount() int {
88+ s.mu.Lock()
89+ defer s.mu.Unlock()
90+ return len(s.delays)
91+}
92+
93+func TestResizeReconciler_SchedulesRetriesWhenNoPaintObserved(t *testing.T) {
94+ clock := time.Unix(0, 0)
95+ scheduler := newFakeResizeScheduler()
96+ host := &resizeHostRecorder{}
97+ rr := &resizeReconciler{
98+ ctx: context.Background(),
99+ scheduler: scheduler,
100+ now: func() time.Time { return clock },
101+ }
102+
103+ rr.start(7, func() resizeNotifiableBrowserHost { return host }, func() bool { return false })
104+ require.Equal(t, 1, scheduler.delayCount())
105+ require.Equal(t, 0, host.invalidationCount())
106+
107+ clock = clock.Add(8 * time.Millisecond)
108+ scheduler.fireLast()
109+ require.Equal(t, 1, host.invalidationCount())
110+ require.Equal(t, 2, scheduler.delayCount())
111+}
112+
113+func TestResizeReconciler_StopsAfterMatchingPaintArrives(t *testing.T) {
114+ clock := time.Unix(0, 0)
115+ scheduler := newFakeResizeScheduler()
116+ host := &resizeHostRecorder{}
117+ rr := &resizeReconciler{
118+ ctx: context.Background(),
119+ scheduler: scheduler,
120+ now: func() time.Time { return clock },
121+ }
122+
123+ rr.start(9, func() resizeNotifiableBrowserHost { return host }, func() bool { return false })
124+ rr.notePaint(9, true)
125+
126+ clock = clock.Add(8 * time.Millisecond)
127+ scheduler.fireLast()
128+ require.Zero(t, host.invalidationCount())
129+ require.Equal(t, 1, scheduler.delayCount())
130+}
131+
132+func TestResizeReconciler_StaleSizePaintDoesNotStopRetries(t *testing.T) {
133+ clock := time.Unix(0, 0)
134+ scheduler := newFakeResizeScheduler()
135+ host := &resizeHostRecorder{}
136+ rr := &resizeReconciler{
137+ ctx: context.Background(),
138+ scheduler: scheduler,
139+ now: func() time.Time { return clock },
140+ }
141+
142+ rr.start(10, func() resizeNotifiableBrowserHost { return host }, func() bool { return false })
143+ rr.notePaint(10, false)
144+
145+ clock = clock.Add(8 * time.Millisecond)
146+ scheduler.fireLast()
147+ require.Equal(t, 1, host.invalidationCount())
148+ require.Equal(t, 2, scheduler.delayCount())
149+}
150+
151+func TestResizeReconciler_NewerResizeSupersedesOlderSession(t *testing.T) {
152+ clock := time.Unix(0, 0)
153+ scheduler := newFakeResizeScheduler()
154+ host := &resizeHostRecorder{}
155+ rr := &resizeReconciler{
156+ ctx: context.Background(),
157+ scheduler: scheduler,
158+ now: func() time.Time { return clock },
159+ }
160+
161+ rr.start(1, func() resizeNotifiableBrowserHost { return host }, func() bool { return false })
162+ firstDelayCount := scheduler.delayCount()
163+ rr.start(2, func() resizeNotifiableBrowserHost { return host }, func() bool { return false })
164+ require.Equal(t, firstDelayCount+1, scheduler.delayCount())
165+
166+ clock = clock.Add(8 * time.Millisecond)
167+ scheduler.fireLast()
168+ require.Equal(t, 1, host.invalidationCount())
169+}
170+
171+func TestResizeReconciler_ExpiresAfter64ms(t *testing.T) {
172+ clock := time.Unix(0, 0)
173+ scheduler := newFakeResizeScheduler()
174+ host := &resizeHostRecorder{}
175+ rr := &resizeReconciler{
176+ ctx: context.Background(),
177+ scheduler: scheduler,
178+ now: func() time.Time { return clock },
179+ }
180+
181+ rr.start(11, func() resizeNotifiableBrowserHost { return host }, func() bool { return false })
182+ for i := 0; i < 8; i++ {
183+ clock = clock.Add(8 * time.Millisecond)
184+ scheduler.fireLast()
185+ }
186+
187+ require.Equal(t, 8, host.invalidationCount())
188+ require.Equal(t, 8, scheduler.delayCount())
189+ clock = clock.Add(8 * time.Millisecond)
190+ scheduler.fireLast()
191+ require.Equal(t, 8, host.invalidationCount())
192+}
M internal/infrastructure/cef/webview.go +12 -10
@@ -33,16 +33,17 @@ var errNoBrowser = errors.New("cef: browser not yet created")
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 +52 -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,60 @@ 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.wv != nil && h.wv.ctx != nil {
225+ resizeSeq, resizeAgeMs := h.wv.pipeline.latestResizeDiagnostics()
226+ expectedWidth, expectedHeight, _ := h.wv.pipeline.viewRectSize()
227+ sizeMatchesCurrentView := width == expectedWidth && height == expectedHeight
228+ if h.wv.resizeReconciler != nil {
229+ h.wv.resizeReconciler.notePaint(resizeSeq, sizeMatchesCurrentView)
230+ }
231+ if sizeMatchesCurrentView {
232+ _, _, shouldLog := h.wv.pipeline.markFirstPaintAfterResize()
233+ if shouldLog {
234+ logging.FromContext(h.wv.ctx).Debug().
235+ Uint64("webview_id", uint64(h.wv.id)).
236+ Uint64("paint_seq", paintSeq).
237+ Uint64("resize_seq", resizeSeq).
238+ Int64("time_since_resize_ms", resizeAgeMs).
239+ Int32("width", width).
240+ Int32("height", height).
241+ Int("dirty_rect_count", len(dirtyRects)).
242+ Msg("cef: first paint after resize")
243+ }
244+ } else if h.wv.pipeline.markStalePaintAfterResize(resizeSeq) {
245+ logging.FromContext(h.wv.ctx).Debug().
246+ Uint64("webview_id", uint64(h.wv.id)).
247+ Uint64("paint_seq", paintSeq).
248+ Uint64("resize_seq", resizeSeq).
249+ Int64("time_since_resize_ms", resizeAgeMs).
250+ Int32("width", width).
251+ Int32("height", height).
252+ Int32("expected_width", expectedWidth).
253+ Int32("expected_height", expectedHeight).
254+ Int("dirty_rect_count", len(dirtyRects)).
255+ Msg("cef: stale-size paint after resize")
256+ }
257+ if !sizeMatchesCurrentView {
258+ return
259+ }
212260 }
213261 rects := make([]rect, len(dirtyRects))
214262 for i, dr := range dirtyRects {
@@ -561,6 +609,7 @@ func (h *handlerSet) OnBeforeClose(_ purecef.Browser) {
561609 h.wv.findCtrl.setHost(nil)
562610 }
563611 h.wv.mu.Unlock()
612+ h.wv.resizeReconciler.stop()
564613 h.wv.scheduleStopBeginFrameLoop()
565614
566615 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,60 @@ 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.wv != nil && h.wv.ctx != nil {
225+ resizeSeq, resizeAgeMs := h.wv.pipeline.latestResizeDiagnostics()
226+ expectedWidth, expectedHeight, _ := h.wv.pipeline.viewRectSize()
227+ sizeMatchesCurrentView := width == expectedWidth && height == expectedHeight
228+ if h.wv.resizeReconciler != nil {
229+ h.wv.resizeReconciler.notePaint(resizeSeq, sizeMatchesCurrentView)
230+ }
231+ if sizeMatchesCurrentView {
232+ _, _, shouldLog := h.wv.pipeline.markFirstPaintAfterResize()
233+ if shouldLog {
234+ logging.FromContext(h.wv.ctx).Debug().
235+ Uint64("webview_id", uint64(h.wv.id)).
236+ Uint64("paint_seq", paintSeq).
237+ Uint64("resize_seq", resizeSeq).
238+ Int64("time_since_resize_ms", resizeAgeMs).
239+ Int32("width", width).
240+ Int32("height", height).
241+ Int("dirty_rect_count", len(dirtyRects)).
242+ Msg("cef: first paint after resize")
243+ }
244+ } else if h.wv.pipeline.markStalePaintAfterResize(resizeSeq) {
245+ logging.FromContext(h.wv.ctx).Debug().
246+ Uint64("webview_id", uint64(h.wv.id)).
247+ Uint64("paint_seq", paintSeq).
248+ Uint64("resize_seq", resizeSeq).
249+ Int64("time_since_resize_ms", resizeAgeMs).
250+ Int32("width", width).
251+ Int32("height", height).
252+ Int32("expected_width", expectedWidth).
253+ Int32("expected_height", expectedHeight).
254+ Int("dirty_rect_count", len(dirtyRects)).
255+ Msg("cef: stale-size paint after resize")
256+ }
257+ if !sizeMatchesCurrentView {
258+ return
259+ }
212 }260 }
213 rects := make([]rect, len(dirtyRects))261 rects := make([]rect, len(dirtyRects))
214 for i, dr := range dirtyRects {262 for i, dr := range dirtyRects {
@@ -561,6 +609,7 @@ func (h *handlerSet) OnBeforeClose(_ purecef.Browser) {
561 h.wv.findCtrl.setHost(nil)609 h.wv.findCtrl.setHost(nil)
562 }610 }
563 h.wv.mu.Unlock()611 h.wv.mu.Unlock()
612+ h.wv.resizeReconciler.stop()
564 h.wv.scheduleStopBeginFrameLoop()613 h.wv.scheduleStopBeginFrameLoop()
565 614
566 h.wv.mu.RLock()615 h.wv.mu.RLock()
M internal/infrastructure/cef/webview_handlers_test.go +25 -0
@@ -137,3 +137,28 @@ func TestOptionalHandlersRespectFactoryFlags(t *testing.T) {
137137
138138 require.Same(t, h, h.GetContextMenuHandler())
139139 }
140+
141+func TestOnPaint_DropsStaleMainViewPaint(t *testing.T) {
142+ wv := &WebView{
143+ id: 42,
144+ ctx: context.Background(),
145+ pipeline: &renderPipeline{
146+ ctx: context.Background(),
147+ scale: 1,
148+ },
149+ }
150+ wv.pipeline.widthAtomic.Store(1269)
151+ wv.pipeline.heightAtomic.Store(1035)
152+
153+ h := &handlerSet{wv: wv}
154+ buffer := make([]byte, 1269*2106*4)
155+
156+ h.OnPaint(nil, purecef.PaintElementTypePetView, []purecef.Rect{{X: 0, Y: 0, Width: 1269, Height: 2106}}, buffer, 1269, 2106)
157+
158+ require.Equal(t, int32(1269), wv.pipeline.widthAtomic.Load())
159+ require.Equal(t, int32(1035), wv.pipeline.heightAtomic.Load())
160+ require.Nil(t, wv.pipeline.staging)
161+ require.Zero(t, wv.pipeline.lastQueuedPaintSeq.Load())
162+ require.False(t, wv.pipeline.needsUpload)
163+ require.Empty(t, wv.pipeline.dirtyRects)
164+}
@@ -137,3 +137,28 @@ func TestOptionalHandlersRespectFactoryFlags(t *testing.T) {
137 137
138 require.Same(t, h, h.GetContextMenuHandler())138 require.Same(t, h, h.GetContextMenuHandler())
139 }139 }
140+
141+func TestOnPaint_DropsStaleMainViewPaint(t *testing.T) {
142+ wv := &WebView{
143+ id: 42,
144+ ctx: context.Background(),
145+ pipeline: &renderPipeline{
146+ ctx: context.Background(),
147+ scale: 1,
148+ },
149+ }
150+ wv.pipeline.widthAtomic.Store(1269)
151+ wv.pipeline.heightAtomic.Store(1035)
152+
153+ h := &handlerSet{wv: wv}
154+ buffer := make([]byte, 1269*2106*4)
155+
156+ h.OnPaint(nil, purecef.PaintElementTypePetView, []purecef.Rect{{X: 0, Y: 0, Width: 1269, Height: 2106}}, buffer, 1269, 2106)
157+
158+ require.Equal(t, int32(1269), wv.pipeline.widthAtomic.Load())
159+ require.Equal(t, int32(1035), wv.pipeline.heightAtomic.Load())
160+ require.Nil(t, wv.pipeline.staging)
161+ require.Zero(t, wv.pipeline.lastQueuedPaintSeq.Load())
162+ require.False(t, wv.pipeline.needsUpload)
163+ require.Empty(t, wv.pipeline.dirtyRects)
164+}
M internal/ui/component/pane_view.go +21 -5
@@ -148,14 +148,16 @@ func (pv *PaneView) SetWebViewWidget(widget layout.Widget) {
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