detached 3aee6b3

feat(cef): popup rendering, OnPaint multi-thread safety, render pipeline improvements

- Add popup overlay rendering (OnPopupShow, OnPopupSize, handlePopupPaint)
- Multi-threaded OnPaint: copy buffer before GTK dispatch when using
  CEF's multi-threaded message loop
- Add paint sequence tracking for frame ordering
- Enable LocalStorage in BrowserSettings
- Remove video diagnostic JS injection (no longer needed)
- Add render pipeline tests
commit
3aee6b3e0ab698929647debea099681ad1f04eb7
author
brice · · 2026-03-24 05:43 UTC
committer
brice · · 2026-03-28 04:38 UTC
parent
4749d51
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +378 -48
M internal/infrastructure/cef/content_injector.go +1 -5
@@ -13,10 +13,6 @@ import (
1313 // Compile-time interface check.
1414 var _ port.ContentInjector = (*contentInjector)(nil)
1515
16-// internalSchemePrefix is used to detect internal pages that receive dark mode
17-// and message bridge scripts. Must match the engine's InternalSchemePath prefix.
18-const internalSchemePrefix = "dumb://"
19-
2016 // scrollbarCSS styles the scrollbar with auto-hide behavior: invisible by
2117 // default, fades in on scroll, widens on hover, fades out after 1s idle.
2218 // Uses --primary theme color for the thumb.
@@ -247,7 +243,7 @@ func (ci *contentInjector) RefreshScripts(ctx context.Context, wv port.WebView)
247243 // It injects the appropriate scripts based on whether the page is internal.
248244 func (ci *contentInjector) onLoadEnd(wv *WebView) {
249245 uri := wv.URI()
250- isInternal := strings.HasPrefix(uri, internalSchemePrefix)
246+ isInternal := isConceptualInternalURL(uri) || isActualInternalURL(uri)
251247
252248 ci.mu.RLock()
253249 themeCSS := ci.themeCSS
@@ -13,10 +13,6 @@ import (
13 // Compile-time interface check.13 // Compile-time interface check.
14 var _ port.ContentInjector = (*contentInjector)(nil)14 var _ port.ContentInjector = (*contentInjector)(nil)
15 15
16-// internalSchemePrefix is used to detect internal pages that receive dark mode
17-// and message bridge scripts. Must match the engine's InternalSchemePath prefix.
18-const internalSchemePrefix = "dumb://"
19-
20 // scrollbarCSS styles the scrollbar with auto-hide behavior: invisible by16 // scrollbarCSS styles the scrollbar with auto-hide behavior: invisible by
21 // default, fades in on scroll, widens on hover, fades out after 1s idle.17 // default, fades in on scroll, widens on hover, fades out after 1s idle.
22 // Uses --primary theme color for the thumb.18 // Uses --primary theme color for the thumb.
@@ -247,7 +243,7 @@ func (ci *contentInjector) RefreshScripts(ctx context.Context, wv port.WebView)
247 // It injects the appropriate scripts based on whether the page is internal.243 // It injects the appropriate scripts based on whether the page is internal.
248 func (ci *contentInjector) onLoadEnd(wv *WebView) {244 func (ci *contentInjector) onLoadEnd(wv *WebView) {
249 uri := wv.URI()245 uri := wv.URI()
250- isInternal := strings.HasPrefix(uri, internalSchemePrefix)246+ isInternal := isConceptualInternalURL(uri) || isActualInternalURL(uri)
251 247
252 ci.mu.RLock()248 ci.mu.RLock()
253 themeCSS := ci.themeCSS249 themeCSS := ci.themeCSS
M internal/infrastructure/cef/factory.go +1 -0
@@ -111,6 +111,7 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
111111 // Configure BrowserSettings.
112112 settings := purecef.DefaultBrowserSettings()
113113 settings.WindowlessFrameRate = f.windowlessFrameRate
114+ settings.LocalStorage = 1 // CEF_STATE_ENABLED
114115 if bg := f.bgColor.Load(); bg != 0 {
115116 settings.BackgroundColor = bg
116117 }
@@ -111,6 +111,7 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
111 // Configure BrowserSettings.111 // Configure BrowserSettings.
112 settings := purecef.DefaultBrowserSettings()112 settings := purecef.DefaultBrowserSettings()
113 settings.WindowlessFrameRate = f.windowlessFrameRate113 settings.WindowlessFrameRate = f.windowlessFrameRate
114+ settings.LocalStorage = 1 // CEF_STATE_ENABLED
114 if bg := f.bgColor.Load(); bg != 0 {115 if bg := f.bgColor.Load(); bg != 0 {
115 settings.BackgroundColor = bg116 settings.BackgroundColor = bg
116 }117 }
M internal/infrastructure/cef/render_pipeline.go +298 -36
@@ -29,12 +29,13 @@ type renderPipeline struct {
2929 glArea *gtk.GLArea
3030
3131 // GL resources (created on first render).
32- texture uint32
33- pbo [2]uint32
34- pboIndex int
35- program uint32
36- vao uint32
37- vbo uint32
32+ texture uint32
33+ popupTexture uint32
34+ pbo [2]uint32
35+ pboIndex int
36+ program uint32
37+ vao uint32
38+ vbo uint32
3839
3940 // Surface dimensions (in device pixels, i.e. scaled).
4041 width int32
@@ -52,6 +53,15 @@ type renderPipeline struct {
5253 needsUpload bool
5354 sizeChanged bool
5455
56+ // Popup staging for native CEF widgets like <select> menus.
57+ popupVisible bool
58+ popupRect rect
59+ popupStaging []byte
60+ popupWidth int32
61+ popupHeight int32
62+ popupNeedsUpload bool
63+ popupSizeChanged bool
64+
5565 // GL initialized flag.
5666 glReady bool
5767
@@ -179,12 +189,16 @@ func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects
179189 }
180190 rp.mu.Lock()
181191
182- bufSize := int(int64(width) * int64(height) * 4)
192+ bufSize, ok := bgraBufferSize(width, height)
193+ if !ok {
194+ rp.mu.Unlock()
195+ return
196+ }
183197 copiedBytes := uint64(0)
184198 sizeChanged := false
185199
186200 // Detect size change, or first paint (staging not yet allocated).
187- if width != rp.width || height != rp.height || rp.staging == nil {
201+ if stagingNeedsReset(rp.staging, rp.width, rp.height, width, height, bufSize) {
188202 rp.width = width
189203 rp.height = height
190204 rp.widthAtomic.Store(width)
@@ -193,27 +207,20 @@ func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects
193207 rp.sizeChanged = true
194208 sizeChanged = true
195209 // On size change, copy the entire buffer.
196- copy(rp.staging, buffer)
197- copiedBytes = uint64(bufSize)
210+ copiedBytes = uint64(copy(rp.staging, buffer))
198211 } else {
199212 // Copy only dirty rect rows.
200- stride := int(width) * 4
201- sanitized := make([]rect, 0, len(rects))
202- for _, rawRect := range rects {
203- r, ok := clampDirtyRect(rawRect, width, height)
204- if !ok {
205- continue
206- }
207- sanitized = append(sanitized, r)
208- for row := r.Y; row < r.Y+r.Height; row++ {
209- srcOff := int(row)*stride + int(r.X)*4
210- dstOff := srcOff
211- rowBytes := int(r.Width) * 4
212- copy(rp.staging[dstOff:dstOff+rowBytes], buffer[srcOff:srcOff+rowBytes])
213- copiedBytes += uint64(rowBytes)
214- }
213+ var truncated bool
214+ copiedBytes, rects, truncated = copyDirtyRectsIntoStaging(rp.staging, buffer, width, height, rects)
215+ if truncated && rp.ctx != nil {
216+ logging.FromContext(rp.ctx).Warn().
217+ Uint64("paint_seq", paintSeq).
218+ Int32("width", width).
219+ Int32("height", height).
220+ Int("buffer_len", len(buffer)).
221+ Int("expected_len", bufSize).
222+ Msg("cef: truncated paint buffer while copying dirty rects")
215223 }
216- rects = sanitized
217224 }
218225
219226 // Accumulate dirty rects across multiple OnPaint calls between renders.
@@ -237,6 +244,15 @@ func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects
237244
238245 rp.glArea.QueueRender()
239246 if rp.ctx != nil {
247+ if sizeChanged && copiedBytes < uint64(bufSize) {
248+ logging.FromContext(rp.ctx).Warn().
249+ Uint64("paint_seq", paintSeq).
250+ Int32("width", width).
251+ Int32("height", height).
252+ Int("buffer_len", len(buffer)).
253+ Int("expected_len", bufSize).
254+ Msg("cef: truncated full paint buffer on resize")
255+ }
240256 logging.FromContext(rp.ctx).Debug().
241257 Uint64("paint_seq", paintSeq).
242258 Uint64("copied_bytes", copiedBytes).
@@ -306,16 +322,16 @@ func (rp *renderPipeline) onGLRender() bool {
306322 }
307323 }
308324
309- // Draw fullscreen quad.
325+ // Draw the main view texture.
326+ w, h, _ := rp.viewRectSize()
327+ gl.viewport(0, 0, w, h)
310328 gl.clearColor(0, 0, 0, 1)
311329 gl.clear(glColorBufferBit)
312- gl.useProgram(rp.program)
313- gl.bindTexture(glTexture2D, rp.texture)
314- gl.bindVertexArray(rp.vao)
315- gl.drawArrays(glTriangleStrip, 0, 4)
316- gl.bindVertexArray(0)
317- gl.bindTexture(glTexture2D, 0)
318- gl.useProgram(0)
330+ rp.drawTexture(rp.texture)
331+
332+ if rp.uploadPopupTexture(renderSeq, paintSeq) {
333+ rp.drawPopupOverlay(w, h)
334+ }
319335
320336 rp.glRenderCount++
321337 renderDuration := time.Since(renderStartedAt)
@@ -337,6 +353,20 @@ func (rp *renderPipeline) onGLRender() bool {
337353 return true
338354 }
339355
356+func (rp *renderPipeline) drawTexture(texture uint32) {
357+ if texture == 0 {
358+ return
359+ }
360+ gl := rp.gl
361+ gl.useProgram(rp.program)
362+ gl.bindTexture(glTexture2D, texture)
363+ gl.bindVertexArray(rp.vao)
364+ gl.drawArrays(glTriangleStrip, 0, 4)
365+ gl.bindVertexArray(0)
366+ gl.bindTexture(glTexture2D, 0)
367+ gl.useProgram(0)
368+}
369+
340370 // uploadToPBO uploads dirty regions from the staging buffer into a PBO, then
341371 // transfers to the GL texture via async DMA.
342372 func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, renderSeq, paintSeq uint64) uint64 {
@@ -373,8 +403,11 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, render
373403
374404 rp.mu.Lock()
375405 if fullUpload {
376- copy(mappedSlice, rp.staging)
377- uploadedBytes = uint64(bufSize)
406+ copied := copy(mappedSlice, rp.staging)
407+ if copied < len(mappedSlice) {
408+ clear(mappedSlice[copied:])
409+ }
410+ uploadedBytes = uint64(copied)
378411 } else {
379412 for _, rawRect := range dirtyRects {
380413 r, ok := clampDirtyRect(rawRect, w, h)
@@ -440,6 +473,84 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, render
440473 return uploadedBytes
441474 }
442475
476+func (rp *renderPipeline) uploadPopupTexture(renderSeq, paintSeq uint64) bool {
477+ rp.mu.Lock()
478+ defer rp.mu.Unlock()
479+
480+ if !rp.popupVisible || rp.popupWidth <= 0 || rp.popupHeight <= 0 || len(rp.popupStaging) == 0 {
481+ return false
482+ }
483+
484+ if !rp.popupNeedsUpload && !rp.popupSizeChanged && rp.popupTexture != 0 {
485+ return true
486+ }
487+
488+ gl := rp.gl
489+
490+ if rp.popupTexture == 0 {
491+ gl.genTextures(1, &rp.popupTexture)
492+ gl.bindTexture(glTexture2D, rp.popupTexture)
493+ gl.texParameteri(glTexture2D, glTextureMinFilter, int32(glLinear))
494+ gl.texParameteri(glTexture2D, glTextureMagFilter, int32(glLinear))
495+ gl.texParameteri(glTexture2D, glTextureWrapS, int32(glClampToEdge))
496+ gl.texParameteri(glTexture2D, glTextureWrapT, int32(glClampToEdge))
497+ } else {
498+ gl.bindTexture(glTexture2D, rp.popupTexture)
499+ }
500+
501+ pixels := unsafe.Pointer(&rp.popupStaging[0])
502+ if rp.popupSizeChanged {
503+ gl.texImage2D(glTexture2D, 0, int32(glRGBA), rp.popupWidth, rp.popupHeight, 0, glBGRA, glUnsignedByte, pixels)
504+ } else {
505+ gl.texSubImage2D(glTexture2D, 0, 0, 0, rp.popupWidth, rp.popupHeight, glBGRA, glUnsignedByte, pixels)
506+ }
507+ gl.bindTexture(glTexture2D, 0)
508+
509+ rp.popupNeedsUpload = false
510+ rp.popupSizeChanged = false
511+
512+ if rp.ctx != nil {
513+ logging.FromContext(rp.ctx).Debug().
514+ Uint64("render_seq", renderSeq).
515+ Uint64("paint_seq", paintSeq).
516+ Int32("popup_width", rp.popupWidth).
517+ Int32("popup_height", rp.popupHeight).
518+ Msg("cef: popup texture uploaded")
519+ }
520+
521+ return true
522+}
523+
524+func (rp *renderPipeline) drawPopupOverlay(viewWidth, viewHeight int32) {
525+ rp.mu.Lock()
526+ popupVisible := rp.popupVisible
527+ popupRect := rp.popupRect
528+ popupWidth := rp.popupWidth
529+ popupHeight := rp.popupHeight
530+ texture := rp.popupTexture
531+ rp.mu.Unlock()
532+
533+ if !popupVisible || texture == 0 || popupWidth <= 0 || popupHeight <= 0 || viewWidth <= 0 || viewHeight <= 0 {
534+ return
535+ }
536+
537+ popupRect.Width = popupWidth
538+ popupRect.Height = popupHeight
539+ popupRect, ok := clampDirtyRect(popupRect, viewWidth, viewHeight)
540+ if !ok {
541+ return
542+ }
543+
544+ viewportY := viewHeight - popupRect.Y - popupRect.Height
545+ if viewportY < 0 {
546+ viewportY = 0
547+ }
548+
549+ rp.gl.viewport(popupRect.X, viewportY, popupRect.Width, popupRect.Height)
550+ rp.drawTexture(texture)
551+ rp.gl.viewport(0, 0, viewWidth, viewHeight)
552+}
553+
443554 // initGL creates or recreates all GL resources for the current surface size.
444555 func (rp *renderPipeline) initGL() {
445556 rp.mu.Lock()
@@ -662,6 +773,70 @@ func (rp *renderPipeline) onResize(width, height int32) {
662773 }
663774 }
664775
776+func (rp *renderPipeline) setPopupVisible(show bool) {
777+ rp.mu.Lock()
778+ rp.popupVisible = show
779+ if !show {
780+ rp.popupStaging = nil
781+ rp.popupWidth = 0
782+ rp.popupHeight = 0
783+ rp.popupNeedsUpload = false
784+ rp.popupSizeChanged = false
785+ }
786+ rp.mu.Unlock()
787+ rp.glArea.QueueRender()
788+}
789+
790+func (rp *renderPipeline) setPopupRect(popup rect) {
791+ rp.mu.Lock()
792+ rp.popupRect = rect{
793+ X: popup.X * rp.scale,
794+ Y: popup.Y * rp.scale,
795+ Width: popup.Width * rp.scale,
796+ Height: popup.Height * rp.scale,
797+ }
798+ rp.mu.Unlock()
799+ rp.glArea.QueueRender()
800+}
801+
802+func (rp *renderPipeline) handlePopupPaint(buffer []byte, width, height int32, paintSeq uint64) {
803+ if len(buffer) == 0 || width <= 0 || height <= 0 {
804+ return
805+ }
806+
807+ bufSize, ok := bgraBufferSize(width, height)
808+ if !ok {
809+ return
810+ }
811+
812+ rp.mu.Lock()
813+ if len(rp.popupStaging) != bufSize {
814+ rp.popupStaging = make([]byte, bufSize)
815+ rp.popupSizeChanged = true
816+ } else if rp.popupWidth != width || rp.popupHeight != height {
817+ rp.popupSizeChanged = true
818+ }
819+ copied := copy(rp.popupStaging, buffer)
820+ if copied < len(rp.popupStaging) {
821+ clear(rp.popupStaging[copied:])
822+ }
823+ rp.popupWidth = width
824+ rp.popupHeight = height
825+ rp.popupNeedsUpload = true
826+ rp.popupVisible = true
827+ rp.mu.Unlock()
828+
829+ if rp.ctx != nil {
830+ logging.FromContext(rp.ctx).Debug().
831+ Uint64("paint_seq", paintSeq).
832+ Int32("popup_width", width).
833+ Int32("popup_height", height).
834+ Msg("cef: handlePopupPaint")
835+ }
836+
837+ rp.glArea.QueueRender()
838+}
839+
665840 func (rp *renderPipeline) viewRectSize() (width, height, scale int32) {
666841 width = rp.widthAtomic.Load()
667842 height = rp.heightAtomic.Load()
@@ -728,6 +903,10 @@ func (rp *renderPipeline) deleteGLResources() {
728903 gl.deleteTextures(1, &rp.texture)
729904 rp.texture = 0
730905 }
906+ if rp.popupTexture != 0 {
907+ gl.deleteTextures(1, &rp.popupTexture)
908+ rp.popupTexture = 0
909+ }
731910 for i := 0; i < 2; i++ {
732911 if rp.pbo[i] != 0 {
733912 gl.deleteBuffers(1, &rp.pbo[i])
@@ -750,6 +929,89 @@ func (rp *renderPipeline) deleteGLResources() {
750929 }
751930 }
752931
932+func bgraBufferSize(width, height int32) (int, bool) {
933+ if width <= 0 || height <= 0 {
934+ return 0, false
935+ }
936+ size64 := int64(width) * int64(height) * 4
937+ if size64 <= 0 {
938+ return 0, false
939+ }
940+ return int(size64), true
941+}
942+
943+func stagingNeedsReset(staging []byte, currentWidth, currentHeight, nextWidth, nextHeight int32, expectedBytes int) bool {
944+ return staging == nil ||
945+ len(staging) != expectedBytes ||
946+ currentWidth != nextWidth ||
947+ currentHeight != nextHeight
948+}
949+
950+func copyDirtyRectsIntoStaging(dst, src []byte, width, height int32, rects []rect) (uint64, []rect, bool) {
951+ if len(dst) == 0 || len(src) == 0 || width <= 0 || height <= 0 {
952+ return 0, nil, false
953+ }
954+
955+ stride := int(width) * 4
956+ copiedBytes := uint64(0)
957+ truncated := false
958+ sanitized := make([]rect, 0, len(rects))
959+
960+ for _, rawRect := range rects {
961+ r, ok := clampDirtyRect(rawRect, width, height)
962+ if !ok {
963+ continue
964+ }
965+ sanitized = append(sanitized, r)
966+
967+ for row := r.Y; row < r.Y+r.Height; row++ {
968+ dstOff := int(row)*stride + int(r.X)*4
969+ if dstOff < 0 || dstOff >= len(dst) {
970+ truncated = true
971+ break
972+ }
973+
974+ rowBytes := int(r.Width) * 4
975+ dstEnd := dstOff + rowBytes
976+ if dstEnd > len(dst) {
977+ dstEnd = len(dst)
978+ truncated = true
979+ }
980+
981+ srcOff := int(row)*stride + int(r.X)*4
982+ if srcOff < 0 || srcOff >= len(src) {
983+ clear(dst[dstOff:dstEnd])
984+ truncated = true
985+ continue
986+ }
987+
988+ srcEnd := srcOff + rowBytes
989+ if srcEnd > len(src) {
990+ srcEnd = len(src)
991+ truncated = true
992+ }
993+
994+ copyLen := srcEnd - srcOff
995+ maxDstLen := dstEnd - dstOff
996+ if copyLen > maxDstLen {
997+ copyLen = maxDstLen
998+ truncated = true
999+ }
1000+
1001+ if copyLen > 0 {
1002+ copy(dst[dstOff:dstOff+copyLen], src[srcOff:srcOff+copyLen])
1003+ copiedBytes += uint64(copyLen)
1004+ }
1005+
1006+ if dstOff+copyLen < dstEnd {
1007+ clear(dst[dstOff+copyLen : dstEnd])
1008+ }
1009+ }
1010+ }
1011+
1012+ return copiedBytes, sanitized, truncated
1013+}
1014+
7531015 // pboOffset converts a byte offset into an unsafe.Pointer for use with
7541016 // glTexSubImage2D when a PBO is bound. OpenGL interprets the pointer as a
7551017 // byte offset into the bound buffer rather than a real memory address.
@@ -29,12 +29,13 @@ type renderPipeline struct {
29 glArea *gtk.GLArea29 glArea *gtk.GLArea
30 30
31 // GL resources (created on first render).31 // GL resources (created on first render).
32- texture uint3232+ texture uint32
33- pbo [2]uint3233+ popupTexture uint32
34- pboIndex int34+ pbo [2]uint32
35- program uint3235+ pboIndex int
36- vao uint3236+ program uint32
37- vbo uint3237+ vao uint32
38+ vbo uint32
38 39
39 // Surface dimensions (in device pixels, i.e. scaled).40 // Surface dimensions (in device pixels, i.e. scaled).
40 width int3241 width int32
@@ -52,6 +53,15 @@ type renderPipeline struct {
52 needsUpload bool53 needsUpload bool
53 sizeChanged bool54 sizeChanged bool
54 55
56+ // Popup staging for native CEF widgets like <select> menus.
57+ popupVisible bool
58+ popupRect rect
59+ popupStaging []byte
60+ popupWidth int32
61+ popupHeight int32
62+ popupNeedsUpload bool
63+ popupSizeChanged bool
64+
55 // GL initialized flag.65 // GL initialized flag.
56 glReady bool66 glReady bool
57 67
@@ -179,12 +189,16 @@ func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects
179 }189 }
180 rp.mu.Lock()190 rp.mu.Lock()
181 191
182- bufSize := int(int64(width) * int64(height) * 4)192+ bufSize, ok := bgraBufferSize(width, height)
193+ if !ok {
194+ rp.mu.Unlock()
195+ return
196+ }
183 copiedBytes := uint64(0)197 copiedBytes := uint64(0)
184 sizeChanged := false198 sizeChanged := false
185 199
186 // Detect size change, or first paint (staging not yet allocated).200 // Detect size change, or first paint (staging not yet allocated).
187- if width != rp.width || height != rp.height || rp.staging == nil {201+ if stagingNeedsReset(rp.staging, rp.width, rp.height, width, height, bufSize) {
188 rp.width = width202 rp.width = width
189 rp.height = height203 rp.height = height
190 rp.widthAtomic.Store(width)204 rp.widthAtomic.Store(width)
@@ -193,27 +207,20 @@ func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects
193 rp.sizeChanged = true207 rp.sizeChanged = true
194 sizeChanged = true208 sizeChanged = true
195 // On size change, copy the entire buffer.209 // On size change, copy the entire buffer.
196- copy(rp.staging, buffer)210+ copiedBytes = uint64(copy(rp.staging, buffer))
197- copiedBytes = uint64(bufSize)
198 } else {211 } else {
199 // Copy only dirty rect rows.212 // Copy only dirty rect rows.
200- stride := int(width) * 4213+ var truncated bool
201- sanitized := make([]rect, 0, len(rects))214+ copiedBytes, rects, truncated = copyDirtyRectsIntoStaging(rp.staging, buffer, width, height, rects)
202- for _, rawRect := range rects {215+ if truncated && rp.ctx != nil {
203- r, ok := clampDirtyRect(rawRect, width, height)216+ logging.FromContext(rp.ctx).Warn().
204- if !ok {217+ Uint64("paint_seq", paintSeq).
205- continue218+ Int32("width", width).
206- }219+ Int32("height", height).
207- sanitized = append(sanitized, r)220+ Int("buffer_len", len(buffer)).
208- for row := r.Y; row < r.Y+r.Height; row++ {221+ Int("expected_len", bufSize).
209- srcOff := int(row)*stride + int(r.X)*4222+ Msg("cef: truncated paint buffer while copying dirty rects")
210- dstOff := srcOff
211- rowBytes := int(r.Width) * 4
212- copy(rp.staging[dstOff:dstOff+rowBytes], buffer[srcOff:srcOff+rowBytes])
213- copiedBytes += uint64(rowBytes)
214- }
215 }223 }
216- rects = sanitized
217 }224 }
218 225
219 // Accumulate dirty rects across multiple OnPaint calls between renders.226 // Accumulate dirty rects across multiple OnPaint calls between renders.
@@ -237,6 +244,15 @@ func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects
237 244
238 rp.glArea.QueueRender()245 rp.glArea.QueueRender()
239 if rp.ctx != nil {246 if rp.ctx != nil {
247+ if sizeChanged && copiedBytes < uint64(bufSize) {
248+ logging.FromContext(rp.ctx).Warn().
249+ Uint64("paint_seq", paintSeq).
250+ Int32("width", width).
251+ Int32("height", height).
252+ Int("buffer_len", len(buffer)).
253+ Int("expected_len", bufSize).
254+ Msg("cef: truncated full paint buffer on resize")
255+ }
240 logging.FromContext(rp.ctx).Debug().256 logging.FromContext(rp.ctx).Debug().
241 Uint64("paint_seq", paintSeq).257 Uint64("paint_seq", paintSeq).
242 Uint64("copied_bytes", copiedBytes).258 Uint64("copied_bytes", copiedBytes).
@@ -306,16 +322,16 @@ func (rp *renderPipeline) onGLRender() bool {
306 }322 }
307 }323 }
308 324
309- // Draw fullscreen quad.325+ // Draw the main view texture.
326+ w, h, _ := rp.viewRectSize()
327+ gl.viewport(0, 0, w, h)
310 gl.clearColor(0, 0, 0, 1)328 gl.clearColor(0, 0, 0, 1)
311 gl.clear(glColorBufferBit)329 gl.clear(glColorBufferBit)
312- gl.useProgram(rp.program)330+ rp.drawTexture(rp.texture)
313- gl.bindTexture(glTexture2D, rp.texture)331+
314- gl.bindVertexArray(rp.vao)332+ if rp.uploadPopupTexture(renderSeq, paintSeq) {
315- gl.drawArrays(glTriangleStrip, 0, 4)333+ rp.drawPopupOverlay(w, h)
316- gl.bindVertexArray(0)334+ }
317- gl.bindTexture(glTexture2D, 0)
318- gl.useProgram(0)
319 335
320 rp.glRenderCount++336 rp.glRenderCount++
321 renderDuration := time.Since(renderStartedAt)337 renderDuration := time.Since(renderStartedAt)
@@ -337,6 +353,20 @@ func (rp *renderPipeline) onGLRender() bool {
337 return true353 return true
338 }354 }
339 355
356+func (rp *renderPipeline) drawTexture(texture uint32) {
357+ if texture == 0 {
358+ return
359+ }
360+ gl := rp.gl
361+ gl.useProgram(rp.program)
362+ gl.bindTexture(glTexture2D, texture)
363+ gl.bindVertexArray(rp.vao)
364+ gl.drawArrays(glTriangleStrip, 0, 4)
365+ gl.bindVertexArray(0)
366+ gl.bindTexture(glTexture2D, 0)
367+ gl.useProgram(0)
368+}
369+
340 // uploadToPBO uploads dirty regions from the staging buffer into a PBO, then370 // uploadToPBO uploads dirty regions from the staging buffer into a PBO, then
341 // transfers to the GL texture via async DMA.371 // transfers to the GL texture via async DMA.
342 func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, renderSeq, paintSeq uint64) uint64 {372 func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, renderSeq, paintSeq uint64) uint64 {
@@ -373,8 +403,11 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, render
373 403
374 rp.mu.Lock()404 rp.mu.Lock()
375 if fullUpload {405 if fullUpload {
376- copy(mappedSlice, rp.staging)406+ copied := copy(mappedSlice, rp.staging)
377- uploadedBytes = uint64(bufSize)407+ if copied < len(mappedSlice) {
408+ clear(mappedSlice[copied:])
409+ }
410+ uploadedBytes = uint64(copied)
378 } else {411 } else {
379 for _, rawRect := range dirtyRects {412 for _, rawRect := range dirtyRects {
380 r, ok := clampDirtyRect(rawRect, w, h)413 r, ok := clampDirtyRect(rawRect, w, h)
@@ -440,6 +473,84 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, render
440 return uploadedBytes473 return uploadedBytes
441 }474 }
442 475
476+func (rp *renderPipeline) uploadPopupTexture(renderSeq, paintSeq uint64) bool {
477+ rp.mu.Lock()
478+ defer rp.mu.Unlock()
479+
480+ if !rp.popupVisible || rp.popupWidth <= 0 || rp.popupHeight <= 0 || len(rp.popupStaging) == 0 {
481+ return false
482+ }
483+
484+ if !rp.popupNeedsUpload && !rp.popupSizeChanged && rp.popupTexture != 0 {
485+ return true
486+ }
487+
488+ gl := rp.gl
489+
490+ if rp.popupTexture == 0 {
491+ gl.genTextures(1, &rp.popupTexture)
492+ gl.bindTexture(glTexture2D, rp.popupTexture)
493+ gl.texParameteri(glTexture2D, glTextureMinFilter, int32(glLinear))
494+ gl.texParameteri(glTexture2D, glTextureMagFilter, int32(glLinear))
495+ gl.texParameteri(glTexture2D, glTextureWrapS, int32(glClampToEdge))
496+ gl.texParameteri(glTexture2D, glTextureWrapT, int32(glClampToEdge))
497+ } else {
498+ gl.bindTexture(glTexture2D, rp.popupTexture)
499+ }
500+
501+ pixels := unsafe.Pointer(&rp.popupStaging[0])
502+ if rp.popupSizeChanged {
503+ gl.texImage2D(glTexture2D, 0, int32(glRGBA), rp.popupWidth, rp.popupHeight, 0, glBGRA, glUnsignedByte, pixels)
504+ } else {
505+ gl.texSubImage2D(glTexture2D, 0, 0, 0, rp.popupWidth, rp.popupHeight, glBGRA, glUnsignedByte, pixels)
506+ }
507+ gl.bindTexture(glTexture2D, 0)
508+
509+ rp.popupNeedsUpload = false
510+ rp.popupSizeChanged = false
511+
512+ if rp.ctx != nil {
513+ logging.FromContext(rp.ctx).Debug().
514+ Uint64("render_seq", renderSeq).
515+ Uint64("paint_seq", paintSeq).
516+ Int32("popup_width", rp.popupWidth).
517+ Int32("popup_height", rp.popupHeight).
518+ Msg("cef: popup texture uploaded")
519+ }
520+
521+ return true
522+}
523+
524+func (rp *renderPipeline) drawPopupOverlay(viewWidth, viewHeight int32) {
525+ rp.mu.Lock()
526+ popupVisible := rp.popupVisible
527+ popupRect := rp.popupRect
528+ popupWidth := rp.popupWidth
529+ popupHeight := rp.popupHeight
530+ texture := rp.popupTexture
531+ rp.mu.Unlock()
532+
533+ if !popupVisible || texture == 0 || popupWidth <= 0 || popupHeight <= 0 || viewWidth <= 0 || viewHeight <= 0 {
534+ return
535+ }
536+
537+ popupRect.Width = popupWidth
538+ popupRect.Height = popupHeight
539+ popupRect, ok := clampDirtyRect(popupRect, viewWidth, viewHeight)
540+ if !ok {
541+ return
542+ }
543+
544+ viewportY := viewHeight - popupRect.Y - popupRect.Height
545+ if viewportY < 0 {
546+ viewportY = 0
547+ }
548+
549+ rp.gl.viewport(popupRect.X, viewportY, popupRect.Width, popupRect.Height)
550+ rp.drawTexture(texture)
551+ rp.gl.viewport(0, 0, viewWidth, viewHeight)
552+}
553+
443 // initGL creates or recreates all GL resources for the current surface size.554 // initGL creates or recreates all GL resources for the current surface size.
444 func (rp *renderPipeline) initGL() {555 func (rp *renderPipeline) initGL() {
445 rp.mu.Lock()556 rp.mu.Lock()
@@ -662,6 +773,70 @@ func (rp *renderPipeline) onResize(width, height int32) {
662 }773 }
663 }774 }
664 775
776+func (rp *renderPipeline) setPopupVisible(show bool) {
777+ rp.mu.Lock()
778+ rp.popupVisible = show
779+ if !show {
780+ rp.popupStaging = nil
781+ rp.popupWidth = 0
782+ rp.popupHeight = 0
783+ rp.popupNeedsUpload = false
784+ rp.popupSizeChanged = false
785+ }
786+ rp.mu.Unlock()
787+ rp.glArea.QueueRender()
788+}
789+
790+func (rp *renderPipeline) setPopupRect(popup rect) {
791+ rp.mu.Lock()
792+ rp.popupRect = rect{
793+ X: popup.X * rp.scale,
794+ Y: popup.Y * rp.scale,
795+ Width: popup.Width * rp.scale,
796+ Height: popup.Height * rp.scale,
797+ }
798+ rp.mu.Unlock()
799+ rp.glArea.QueueRender()
800+}
801+
802+func (rp *renderPipeline) handlePopupPaint(buffer []byte, width, height int32, paintSeq uint64) {
803+ if len(buffer) == 0 || width <= 0 || height <= 0 {
804+ return
805+ }
806+
807+ bufSize, ok := bgraBufferSize(width, height)
808+ if !ok {
809+ return
810+ }
811+
812+ rp.mu.Lock()
813+ if len(rp.popupStaging) != bufSize {
814+ rp.popupStaging = make([]byte, bufSize)
815+ rp.popupSizeChanged = true
816+ } else if rp.popupWidth != width || rp.popupHeight != height {
817+ rp.popupSizeChanged = true
818+ }
819+ copied := copy(rp.popupStaging, buffer)
820+ if copied < len(rp.popupStaging) {
821+ clear(rp.popupStaging[copied:])
822+ }
823+ rp.popupWidth = width
824+ rp.popupHeight = height
825+ rp.popupNeedsUpload = true
826+ rp.popupVisible = true
827+ rp.mu.Unlock()
828+
829+ if rp.ctx != nil {
830+ logging.FromContext(rp.ctx).Debug().
831+ Uint64("paint_seq", paintSeq).
832+ Int32("popup_width", width).
833+ Int32("popup_height", height).
834+ Msg("cef: handlePopupPaint")
835+ }
836+
837+ rp.glArea.QueueRender()
838+}
839+
665 func (rp *renderPipeline) viewRectSize() (width, height, scale int32) {840 func (rp *renderPipeline) viewRectSize() (width, height, scale int32) {
666 width = rp.widthAtomic.Load()841 width = rp.widthAtomic.Load()
667 height = rp.heightAtomic.Load()842 height = rp.heightAtomic.Load()
@@ -728,6 +903,10 @@ func (rp *renderPipeline) deleteGLResources() {
728 gl.deleteTextures(1, &rp.texture)903 gl.deleteTextures(1, &rp.texture)
729 rp.texture = 0904 rp.texture = 0
730 }905 }
906+ if rp.popupTexture != 0 {
907+ gl.deleteTextures(1, &rp.popupTexture)
908+ rp.popupTexture = 0
909+ }
731 for i := 0; i < 2; i++ {910 for i := 0; i < 2; i++ {
732 if rp.pbo[i] != 0 {911 if rp.pbo[i] != 0 {
733 gl.deleteBuffers(1, &rp.pbo[i])912 gl.deleteBuffers(1, &rp.pbo[i])
@@ -750,6 +929,89 @@ func (rp *renderPipeline) deleteGLResources() {
750 }929 }
751 }930 }
752 931
932+func bgraBufferSize(width, height int32) (int, bool) {
933+ if width <= 0 || height <= 0 {
934+ return 0, false
935+ }
936+ size64 := int64(width) * int64(height) * 4
937+ if size64 <= 0 {
938+ return 0, false
939+ }
940+ return int(size64), true
941+}
942+
943+func stagingNeedsReset(staging []byte, currentWidth, currentHeight, nextWidth, nextHeight int32, expectedBytes int) bool {
944+ return staging == nil ||
945+ len(staging) != expectedBytes ||
946+ currentWidth != nextWidth ||
947+ currentHeight != nextHeight
948+}
949+
950+func copyDirtyRectsIntoStaging(dst, src []byte, width, height int32, rects []rect) (uint64, []rect, bool) {
951+ if len(dst) == 0 || len(src) == 0 || width <= 0 || height <= 0 {
952+ return 0, nil, false
953+ }
954+
955+ stride := int(width) * 4
956+ copiedBytes := uint64(0)
957+ truncated := false
958+ sanitized := make([]rect, 0, len(rects))
959+
960+ for _, rawRect := range rects {
961+ r, ok := clampDirtyRect(rawRect, width, height)
962+ if !ok {
963+ continue
964+ }
965+ sanitized = append(sanitized, r)
966+
967+ for row := r.Y; row < r.Y+r.Height; row++ {
968+ dstOff := int(row)*stride + int(r.X)*4
969+ if dstOff < 0 || dstOff >= len(dst) {
970+ truncated = true
971+ break
972+ }
973+
974+ rowBytes := int(r.Width) * 4
975+ dstEnd := dstOff + rowBytes
976+ if dstEnd > len(dst) {
977+ dstEnd = len(dst)
978+ truncated = true
979+ }
980+
981+ srcOff := int(row)*stride + int(r.X)*4
982+ if srcOff < 0 || srcOff >= len(src) {
983+ clear(dst[dstOff:dstEnd])
984+ truncated = true
985+ continue
986+ }
987+
988+ srcEnd := srcOff + rowBytes
989+ if srcEnd > len(src) {
990+ srcEnd = len(src)
991+ truncated = true
992+ }
993+
994+ copyLen := srcEnd - srcOff
995+ maxDstLen := dstEnd - dstOff
996+ if copyLen > maxDstLen {
997+ copyLen = maxDstLen
998+ truncated = true
999+ }
1000+
1001+ if copyLen > 0 {
1002+ copy(dst[dstOff:dstOff+copyLen], src[srcOff:srcOff+copyLen])
1003+ copiedBytes += uint64(copyLen)
1004+ }
1005+
1006+ if dstOff+copyLen < dstEnd {
1007+ clear(dst[dstOff+copyLen : dstEnd])
1008+ }
1009+ }
1010+ }
1011+
1012+ return copiedBytes, sanitized, truncated
1013+}
1014+
753 // pboOffset converts a byte offset into an unsafe.Pointer for use with1015 // pboOffset converts a byte offset into an unsafe.Pointer for use with
754 // glTexSubImage2D when a PBO is bound. OpenGL interprets the pointer as a1016 // glTexSubImage2D when a PBO is bound. OpenGL interprets the pointer as a
755 // byte offset into the bound buffer rather than a real memory address.1017 // byte offset into the bound buffer rather than a real memory address.
A internal/infrastructure/cef/render_pipeline_test.go +41 -0
@@ -0,0 +1,41 @@
1+package cef
2+
3+import (
4+ "bytes"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestStagingNeedsResetWhenBufferSizeMismatches(t *testing.T) {
11+ staging := make([]byte, 8)
12+
13+ require.True(t, stagingNeedsReset(staging, 2, 2, 2, 2, 16))
14+ require.False(t, stagingNeedsReset(make([]byte, 16), 2, 2, 2, 2, 16))
15+}
16+
17+func TestCopyDirtyRectsIntoStagingClampsTruncatedBuffer(t *testing.T) {
18+ const (
19+ width = int32(4)
20+ height = int32(2)
21+ )
22+
23+ dst := bytes.Repeat([]byte{0xaa}, 32)
24+ src := make([]byte, 28)
25+ for i := range src {
26+ src[i] = byte(i + 1)
27+ }
28+
29+ copied, sanitized, truncated := copyDirtyRectsIntoStaging(dst, src, width, height, []rect{{
30+ X: 0,
31+ Y: 0,
32+ Width: width,
33+ Height: height,
34+ }})
35+
36+ require.True(t, truncated)
37+ require.Equal(t, uint64(len(src)), copied)
38+ require.Len(t, sanitized, 1)
39+ require.Equal(t, src, dst[:len(src)])
40+ require.Equal(t, []byte{0, 0, 0, 0}, dst[len(src):])
41+}
@@ -0,0 +1,41 @@
1+package cef
2+
3+import (
4+ "bytes"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestStagingNeedsResetWhenBufferSizeMismatches(t *testing.T) {
11+ staging := make([]byte, 8)
12+
13+ require.True(t, stagingNeedsReset(staging, 2, 2, 2, 2, 16))
14+ require.False(t, stagingNeedsReset(make([]byte, 16), 2, 2, 2, 2, 16))
15+}
16+
17+func TestCopyDirtyRectsIntoStagingClampsTruncatedBuffer(t *testing.T) {
18+ const (
19+ width = int32(4)
20+ height = int32(2)
21+ )
22+
23+ dst := bytes.Repeat([]byte{0xaa}, 32)
24+ src := make([]byte, 28)
25+ for i := range src {
26+ src[i] = byte(i + 1)
27+ }
28+
29+ copied, sanitized, truncated := copyDirtyRectsIntoStaging(dst, src, width, height, []rect{{
30+ X: 0,
31+ Y: 0,
32+ Width: width,
33+ Height: height,
34+ }})
35+
36+ require.True(t, truncated)
37+ require.Equal(t, uint64(len(src)), copied)
38+ require.Len(t, sanitized, 1)
39+ require.Equal(t, src, dst[:len(src)])
40+ require.Equal(t, []byte{0, 0, 0, 0}, dst[len(src):])
41+}
M internal/infrastructure/cef/webview.go +6 -4
@@ -116,17 +116,18 @@ func (wv *WebView) LoadURI(_ context.Context, uri string) error {
116116 if wv.destroyed.Load() {
117117 return errDestroyed
118118 }
119+ actualURI := toActualInternalURL(uri)
119120 wv.mu.Lock()
120121 browser := wv.browser
121122 if browser == nil {
122123 // Browser not yet created — queue the URI for OnAfterCreated.
123- wv.pendingURI = uri
124+ wv.pendingURI = actualURI
124125 wv.mu.Unlock()
125126 return nil
126127 }
127128 wv.mu.Unlock()
128129 if frame := browser.GetMainFrame(); frame != nil {
129- frame.LoadURL(uri)
130+ frame.LoadURL(actualURI)
130131 }
131132 return nil
132133 }
@@ -444,7 +445,7 @@ const colorScale = 255
444445 // SetBackgroundColor sets the background via JS injection (CEF has no runtime API).
445446 func (wv *WebView) SetBackgroundColor(r, g, b, a float64) {
446447 script := fmt.Sprintf(
447- `document.documentElement.style.backgroundColor='rgba(%d,%d,%d,%.2f)'`,
448+ `(function(){ if (document.documentElement) { document.documentElement.style.backgroundColor='rgba(%d,%d,%d,%.2f)'; } })()`,
448449 int(r*colorScale), int(g*colorScale), int(b*colorScale), a,
449450 )
450451 wv.RunJavaScript(context.Background(), script)
@@ -453,7 +454,7 @@ func (wv *WebView) SetBackgroundColor(r, g, b, a float64) {
453454 // ResetBackgroundToDefault clears the injected background color.
454455 func (wv *WebView) ResetBackgroundToDefault() {
455456 wv.RunJavaScript(context.Background(),
456- `document.documentElement.style.backgroundColor=''`)
457+ `(function(){ if (document.documentElement) { document.documentElement.style.backgroundColor=''; } })()`)
457458 }
458459
459460 // ---------------------------------------------------------------------------
@@ -499,6 +500,7 @@ func (wv *WebView) NativeWidget() uintptr {
499500 // ---------------------------------------------------------------------------
500501
501502 func (wv *WebView) updateURI(uri string) {
503+ uri = toConceptualInternalURL(uri)
502504 wv.mu.Lock()
503505 wv.uri = uri
504506 cb := wv.callbacks
@@ -116,17 +116,18 @@ func (wv *WebView) LoadURI(_ context.Context, uri string) error {
116 if wv.destroyed.Load() {116 if wv.destroyed.Load() {
117 return errDestroyed117 return errDestroyed
118 }118 }
119+ actualURI := toActualInternalURL(uri)
119 wv.mu.Lock()120 wv.mu.Lock()
120 browser := wv.browser121 browser := wv.browser
121 if browser == nil {122 if browser == nil {
122 // Browser not yet created — queue the URI for OnAfterCreated.123 // Browser not yet created — queue the URI for OnAfterCreated.
123- wv.pendingURI = uri124+ wv.pendingURI = actualURI
124 wv.mu.Unlock()125 wv.mu.Unlock()
125 return nil126 return nil
126 }127 }
127 wv.mu.Unlock()128 wv.mu.Unlock()
128 if frame := browser.GetMainFrame(); frame != nil {129 if frame := browser.GetMainFrame(); frame != nil {
129- frame.LoadURL(uri)130+ frame.LoadURL(actualURI)
130 }131 }
131 return nil132 return nil
132 }133 }
@@ -444,7 +445,7 @@ const colorScale = 255
444 // SetBackgroundColor sets the background via JS injection (CEF has no runtime API).445 // SetBackgroundColor sets the background via JS injection (CEF has no runtime API).
445 func (wv *WebView) SetBackgroundColor(r, g, b, a float64) {446 func (wv *WebView) SetBackgroundColor(r, g, b, a float64) {
446 script := fmt.Sprintf(447 script := fmt.Sprintf(
447- `document.documentElement.style.backgroundColor='rgba(%d,%d,%d,%.2f)'`,448+ `(function(){ if (document.documentElement) { document.documentElement.style.backgroundColor='rgba(%d,%d,%d,%.2f)'; } })()`,
448 int(r*colorScale), int(g*colorScale), int(b*colorScale), a,449 int(r*colorScale), int(g*colorScale), int(b*colorScale), a,
449 )450 )
450 wv.RunJavaScript(context.Background(), script)451 wv.RunJavaScript(context.Background(), script)
@@ -453,7 +454,7 @@ func (wv *WebView) SetBackgroundColor(r, g, b, a float64) {
453 // ResetBackgroundToDefault clears the injected background color.454 // ResetBackgroundToDefault clears the injected background color.
454 func (wv *WebView) ResetBackgroundToDefault() {455 func (wv *WebView) ResetBackgroundToDefault() {
455 wv.RunJavaScript(context.Background(),456 wv.RunJavaScript(context.Background(),
456- `document.documentElement.style.backgroundColor=''`)457+ `(function(){ if (document.documentElement) { document.documentElement.style.backgroundColor=''; } })()`)
457 }458 }
458 459
459 // ---------------------------------------------------------------------------460 // ---------------------------------------------------------------------------
@@ -499,6 +500,7 @@ func (wv *WebView) NativeWidget() uintptr {
499 // ---------------------------------------------------------------------------500 // ---------------------------------------------------------------------------
500 501
501 func (wv *WebView) updateURI(uri string) {502 func (wv *WebView) updateURI(uri string) {
503+ uri = toConceptualInternalURL(uri)
502 wv.mu.Lock()504 wv.mu.Lock()
503 wv.uri = uri505 wv.uri = uri
504 cb := wv.callbacks506 cb := wv.callbacks
M internal/infrastructure/cef/webview_handlers.go +31 -3
@@ -147,15 +147,35 @@ func (h *handlerSet) GetScreenInfo(_ purecef.Browser, info *purecef.ScreenInfo)
147147 return 1
148148 }
149149
150-func (h *handlerSet) OnPopupShow(_ purecef.Browser, _ int32) {}
150+func (h *handlerSet) OnPopupShow(_ purecef.Browser, show int32) {
151+ if h.wv == nil || h.wv.pipeline == nil {
152+ return
153+ }
154+ h.wv.runOnGTK(func() {
155+ h.wv.pipeline.setPopupVisible(show != 0)
156+ })
157+}
151158
152-func (h *handlerSet) OnPopupSize(_ purecef.Browser, _ *purecef.Rect) {}
159+func (h *handlerSet) OnPopupSize(_ purecef.Browser, popupRect *purecef.Rect) {
160+ if h.wv == nil || h.wv.pipeline == nil || popupRect == nil {
161+ return
162+ }
163+ popup := rect{
164+ X: popupRect.X,
165+ Y: popupRect.Y,
166+ Width: popupRect.Width,
167+ Height: popupRect.Height,
168+ }
169+ h.wv.runOnGTK(func() {
170+ h.wv.pipeline.setPopupRect(popup)
171+ })
172+}
153173
154174 // OnPaint receives the BGRA pixel buffer from CEF and forwards dirty rects
155175 // to the render pipeline for GPU upload. With a multi-threaded CEF UI loop we
156176 // must first copy the transient CEF buffer before hopping back to GTK.
157177 func (h *handlerSet) OnPaint(
158- _ purecef.Browser, _ purecef.PaintElementType,
178+ _ purecef.Browser, elementType purecef.PaintElementType,
159179 dirtyRects []purecef.Rect, buffer []byte, width, height int32,
160180 ) {
161181 if len(buffer) == 0 || width <= 0 || height <= 0 {
@@ -179,6 +199,10 @@ func (h *handlerSet) OnPaint(
179199 pixels := make([]byte, len(buffer))
180200 copy(pixels, buffer)
181201 h.wv.runOnGTK(func() {
202+ if elementType == purecef.PaintElementTypePetPopup {
203+ h.wv.pipeline.handlePopupPaint(pixels, width, height, paintSeq)
204+ return
205+ }
182206 h.wv.pipeline.handlePaint(pixels, width, height, rects, paintSeq)
183207 })
184208 if h.wv != nil && h.wv.ctx != nil {
@@ -188,6 +212,10 @@ func (h *handlerSet) OnPaint(
188212 }
189213 return
190214 }
215+ if elementType == purecef.PaintElementTypePetPopup {
216+ h.wv.pipeline.handlePopupPaint(buffer, width, height, paintSeq)
217+ return
218+ }
191219 h.wv.pipeline.handlePaint(buffer, width, height, rects, paintSeq)
192220 if h.wv != nil && h.wv.ctx != nil {
193221 logging.FromContext(h.wv.ctx).Debug().
@@ -147,15 +147,35 @@ func (h *handlerSet) GetScreenInfo(_ purecef.Browser, info *purecef.ScreenInfo)
147 return 1147 return 1
148 }148 }
149 149
150-func (h *handlerSet) OnPopupShow(_ purecef.Browser, _ int32) {}150+func (h *handlerSet) OnPopupShow(_ purecef.Browser, show int32) {
151+ if h.wv == nil || h.wv.pipeline == nil {
152+ return
153+ }
154+ h.wv.runOnGTK(func() {
155+ h.wv.pipeline.setPopupVisible(show != 0)
156+ })
157+}
151 158
152-func (h *handlerSet) OnPopupSize(_ purecef.Browser, _ *purecef.Rect) {}159+func (h *handlerSet) OnPopupSize(_ purecef.Browser, popupRect *purecef.Rect) {
160+ if h.wv == nil || h.wv.pipeline == nil || popupRect == nil {
161+ return
162+ }
163+ popup := rect{
164+ X: popupRect.X,
165+ Y: popupRect.Y,
166+ Width: popupRect.Width,
167+ Height: popupRect.Height,
168+ }
169+ h.wv.runOnGTK(func() {
170+ h.wv.pipeline.setPopupRect(popup)
171+ })
172+}
153 173
154 // OnPaint receives the BGRA pixel buffer from CEF and forwards dirty rects174 // OnPaint receives the BGRA pixel buffer from CEF and forwards dirty rects
155 // to the render pipeline for GPU upload. With a multi-threaded CEF UI loop we175 // to the render pipeline for GPU upload. With a multi-threaded CEF UI loop we
156 // must first copy the transient CEF buffer before hopping back to GTK.176 // must first copy the transient CEF buffer before hopping back to GTK.
157 func (h *handlerSet) OnPaint(177 func (h *handlerSet) OnPaint(
158- _ purecef.Browser, _ purecef.PaintElementType,178+ _ purecef.Browser, elementType purecef.PaintElementType,
159 dirtyRects []purecef.Rect, buffer []byte, width, height int32,179 dirtyRects []purecef.Rect, buffer []byte, width, height int32,
160 ) {180 ) {
161 if len(buffer) == 0 || width <= 0 || height <= 0 {181 if len(buffer) == 0 || width <= 0 || height <= 0 {
@@ -179,6 +199,10 @@ func (h *handlerSet) OnPaint(
179 pixels := make([]byte, len(buffer))199 pixels := make([]byte, len(buffer))
180 copy(pixels, buffer)200 copy(pixels, buffer)
181 h.wv.runOnGTK(func() {201 h.wv.runOnGTK(func() {
202+ if elementType == purecef.PaintElementTypePetPopup {
203+ h.wv.pipeline.handlePopupPaint(pixels, width, height, paintSeq)
204+ return
205+ }
182 h.wv.pipeline.handlePaint(pixels, width, height, rects, paintSeq)206 h.wv.pipeline.handlePaint(pixels, width, height, rects, paintSeq)
183 })207 })
184 if h.wv != nil && h.wv.ctx != nil {208 if h.wv != nil && h.wv.ctx != nil {
@@ -188,6 +212,10 @@ func (h *handlerSet) OnPaint(
188 }212 }
189 return213 return
190 }214 }
215+ if elementType == purecef.PaintElementTypePetPopup {
216+ h.wv.pipeline.handlePopupPaint(buffer, width, height, paintSeq)
217+ return
218+ }
191 h.wv.pipeline.handlePaint(buffer, width, height, rects, paintSeq)219 h.wv.pipeline.handlePaint(buffer, width, height, rects, paintSeq)
192 if h.wv != nil && h.wv.ctx != nil {220 if h.wv != nil && h.wv.ctx != nil {
193 logging.FromContext(h.wv.ctx).Debug().221 logging.FromContext(h.wv.ctx).Debug().

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