From a22e646a239866e5cd302369baf59710f538071a Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Fri, 9 Oct 2026 11:41:17 +0100 Subject: [PATCH 1/4] Attempting partial refresh where possible --- go.mod | 4 +- go.sum | 8 +- internal/ui/compositor.go | 85 ++++++++++++++++++---- internal/ui/compositor_test.go | 40 ++++++++++ internal/x11/composit/capture.go | 59 ++++++++++++--- internal/x11/composit/capture_test.go | 59 +++++++++++++++ internal/x11/composit/gocompositor.go | 101 ++++++++++++++++---------- 7 files changed, 285 insertions(+), 71 deletions(-) diff --git a/go.mod b/go.mod index ad29e1f6..19eae80e 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.24.4 require ( codeberg.org/sdassow/unyts v0.5.1 - fyne.io/fyne/v2 v2.8.2-0.20260917213848-4df059826be6 + fyne.io/fyne/v2 v2.8.3-0.20261018211521-847e15d76d82 github.com/BurntSushi/toml v1.6.0 // indirect github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046 @@ -16,7 +16,7 @@ require ( github.com/Knetic/govaluate v3.0.0+incompatible github.com/disintegration/imaging v1.6.2 github.com/fyne-io/image v0.1.1 - github.com/fyne-io/oksvg v0.2.0 // indirect + github.com/fyne-io/oksvg v0.2.1-0.20260918172519-a9af55fa95d9 // indirect github.com/godbus/dbus/v5 v5.2.2 github.com/hack-pad/go-indexeddb v0.3.2 // indirect github.com/hack-pad/safejs v0.1.0 // indirect diff --git a/go.sum b/go.sum index a049991a..b637525c 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ codeberg.org/sdassow/unyts v0.5.1 h1:7mgAqMsgrpsFNXKXFZOzXDlCb3hAW4q0mYyOIN31lLk= codeberg.org/sdassow/unyts v0.5.1/go.mod h1:G7IVCrmY/57ojDjMqkmIlRpyLvLF5FrQEbaJv5RzS2k= -fyne.io/fyne/v2 v2.8.2-0.20260917213848-4df059826be6 h1:4Ei5VDsdyI+5ZHBfAVxoemyOSMP6GRCVsWY6Zh905+k= -fyne.io/fyne/v2 v2.8.2-0.20260917213848-4df059826be6/go.mod h1:8bBm+lI+VRjvKUHjWHn3wqcZJbqn+PuiYaCMciOjUnE= +fyne.io/fyne/v2 v2.8.3-0.20261018211521-847e15d76d82 h1:Xpfl7G4eE3S1yr6bLsySyIPgXhs6P5vkwUWUSwNSAgk= +fyne.io/fyne/v2 v2.8.3-0.20261018211521-847e15d76d82/go.mod h1:eXHJ/D9Y58UcDw7SkguadCRI64M4flXyjoMn827Q5SI= fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4 h1:149/+Wa5EsLLXfyj2pdTmvnQf2VIlgCIwSjcCTHYhIo= fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= github.com/ActiveState/termtest/conpty v0.5.0 h1:JLUe6YDs4Jw4xNPCU+8VwTpniYOGeKzQg4SM2YHQNA8= @@ -55,8 +55,8 @@ github.com/fyne-io/glfw-js v0.4.0 h1:I9hREBeFyI10cNIqbMKYb1PRidyPDgwob8o2la9SfQo github.com/fyne-io/glfw-js v0.4.0/go.mod h1:SDchsFZh4n7nVuBoiowOhOgIBdz+qUQVeC1w9fe2yVU= github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= -github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8= -github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= +github.com/fyne-io/oksvg v0.2.1-0.20260918172519-a9af55fa95d9 h1:owsdpq6a2KhaAA/hoHGQmFM7kOgKo3srXZVGnbEUzr0= +github.com/fyne-io/oksvg v0.2.1-0.20260918172519-a9af55fa95d9/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= github.com/fyne-io/terminal v0.0.0-20260618170948-86c23cc49e34 h1:qnWMN0lahPnKjWzhRKOjSXVg3rKy3Oowvf/aNkElMn8= github.com/fyne-io/terminal v0.0.0-20260618170948-86c23cc49e34/go.mod h1:EExhIhaPGBI6PlTGDTWwdbyQ2zD+k0A80O/V4qYNI3w= github.com/fyne-io/xgb v0.0.0-20260101193816-5f7669354be2 h1:doVVjeoVBO6zBa03j3YWRLa29OLF9nS6Dh1EHaxoJKY= diff --git a/internal/ui/compositor.go b/internal/ui/compositor.go index be59ce3a..9b41180a 100644 --- a/internal/ui/compositor.go +++ b/internal/ui/compositor.go @@ -4,7 +4,6 @@ import ( "image" "image/color" "sync" - "sync/atomic" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" @@ -19,11 +18,10 @@ import ( // WindowImage holds the Fyne image and pixel-space geometry for a single // composited window. The platform compositor populates these fields. // -// Images are double-buffered: the compositor writes to Back (from any -// goroutine) and the renderer swaps Back→Img.Image during its Refresh. -// Pending is set by the compositor after a push; the renderer clears it -// after consuming the frame. The compositor should skip capture while -// Pending is true to avoid wasteful work. +// Images are double-buffered: the compositor hands over each frame with Push +// (from any goroutine) and the renderer swaps the latest one into Img.Image +// during its Refresh. A frame comes with the region of it that changed, so +// that the renderer uploads only those pixels. type WindowImage struct { ID uint32 Img *canvas.Image @@ -34,12 +32,66 @@ type WindowImage struct { // Active deepens that shadow for the window that has focus. Shadow, Active bool - Back atomic.Value // image.Image — latest frame from compositor - Pending atomic.Bool // true = refresh requested, not yet rendered + mu sync.Mutex + back image.Image // latest frame from compositor + frame uint64 // the number of that frame + dirty image.Rectangle // where back differs from the frame last rendered + pending bool // true = refresh requested, not yet rendered shadow *canvas.Rectangle // drawn beneath Img when Shadow is set } +// Push hands over a frame of the window for the renderer to show on its next +// Refresh, and returns whether that Refresh has yet to be requested. Frames +// are numbered in sequence and only the pixels in dirty differ from those of +// the frame before; a window image that missed that frame takes all of them. +func (wi *WindowImage) Push(img image.Image, dirty image.Rectangle, frame uint64) bool { + wi.mu.Lock() + defer wi.mu.Unlock() + + if frame != wi.frame+1 { + dirty = img.Bounds() + } + wi.back, wi.frame = img, frame + wi.dirty = wi.dirty.Union(dirty) + + refresh := !wi.pending + wi.pending = true + return refresh +} + +// Pending returns whether a pushed frame is still waiting to be rendered. The +// renderer may read the image of that frame, or the one before, at any time. +func (wi *WindowImage) Pending() bool { + wi.mu.Lock() + defer wi.mu.Unlock() + + return wi.pending +} + +// Latest returns the frame most recently pushed, or nil if there is none. +func (wi *WindowImage) Latest() image.Image { + wi.mu.Lock() + defer wi.mu.Unlock() + + return wi.back +} + +// take returns the frame waiting to be rendered and the region of it that +// differs from the frame rendered before, or false if there is none waiting. +func (wi *WindowImage) take() (image.Image, image.Rectangle, bool) { + wi.mu.Lock() + defer wi.mu.Unlock() + + if !wi.pending { + return nil, image.Rectangle{}, false + } + dirty := wi.dirty + wi.dirty = image.Rectangle{} + wi.pending = false + return wi.back, dirty, true +} + // CompositorWidget is a Fyne widget that displays composited window images. // It is platform-agnostic; the platform-specific compositor (e.g. X11) is // responsible for capturing window content and calling the methods below. @@ -247,21 +299,22 @@ func (r *compositorRenderer) Refresh() { objs := make([]fyne.CanvasObject, 0, len(r.widget.images)) for _, wi := range r.widget.images { // Swap back→front: pick up the latest frame from the compositor. - swapped := wi.Pending.Load() + img, dirty, swapped := wi.take() + resized := swapped && (wi.Img.Image == nil || wi.Img.Image.Bounds() != img.Bounds()) if swapped { - if back := wi.Back.Load(); back != nil { - wi.Img.Image = back.(image.Image) - } - wi.Pending.Store(false) + wi.Img.Image = img } before := wi.Img.Size() r.widget.placeWindow(wi) // Only an image with new content or a new size needs its texture - // rebuilt; refreshing every child on each capture is what makes a - // resize crawl. - if swapped || wi.Img.Size() != before { + // touched; refreshing every child on each capture is what makes a + // resize crawl. New content of the same size replaces just the pixels + // that changed. + if resized || wi.Img.Size() != before { wi.Img.Refresh() + } else if swapped { + wi.Img.RefreshRegion(dirty) } if wi.Shadow { objs = append(objs, wi.shadow) diff --git a/internal/ui/compositor_test.go b/internal/ui/compositor_test.go index a3fc44c6..d1d5e36a 100644 --- a/internal/ui/compositor_test.go +++ b/internal/ui/compositor_test.go @@ -1,11 +1,13 @@ package ui import ( + "image" "image/color" "testing" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" + "github.com/stretchr/testify/assert" wmTheme "fyshos.com/tyde/theme" ) @@ -143,3 +145,41 @@ func TestCompositorShadowBeneathManagedWindow(t *testing.T) { t.Fatal("unfocused window should have the inactive shadow") } } + +// TestWindowImagePush verifies that the renderer is given the latest frame with +// all that changed since the one it rendered before, and is asked to refresh +// only once for it. +func TestWindowImagePush(t *testing.T) { + wi := &WindowImage{} + _, _, ok := wi.take() + assert.False(t, ok) + assert.False(t, wi.Pending()) + + bounds := image.Rect(0, 0, 100, 80) + first := image.NewRGBA(bounds) + assert.True(t, wi.Push(first, bounds, 1)) + assert.True(t, wi.Pending()) + assert.Equal(t, first, wi.Latest()) + + img, dirty, ok := wi.take() + assert.True(t, ok) + assert.Same(t, first, img) + assert.Equal(t, bounds, dirty) + assert.False(t, wi.Pending()) + + // Frames that the renderer does not get to are folded into the next. + second, third := image.NewRGBA(bounds), image.NewRGBA(bounds) + assert.True(t, wi.Push(second, image.Rect(10, 10, 20, 20), 2)) + assert.False(t, wi.Push(third, image.Rect(50, 15, 60, 30), 3)) + img, dirty, ok = wi.take() + assert.True(t, ok) + assert.Same(t, third, img) + assert.Equal(t, image.Rect(10, 10, 60, 30), dirty) + _, _, ok = wi.take() + assert.False(t, ok) + + // A frame that follows one this window image was not given may differ anywhere. + assert.True(t, wi.Push(second, image.Rect(10, 10, 20, 20), 5)) + _, dirty, _ = wi.take() + assert.Equal(t, bounds, dirty) +} diff --git a/internal/x11/composit/capture.go b/internal/x11/composit/capture.go index 0d5cc0ff..95c43501 100644 --- a/internal/x11/composit/capture.go +++ b/internal/x11/composit/capture.go @@ -22,26 +22,61 @@ func capturePixmap(conn *xgb.Conn, drawable xproto.Drawable, width, height uint1 return nil } - reply, err := xproto.GetImage(conn, xproto.ImageFormatZPixmap, drawable, - 0, 0, width, height, math.MaxUint32).Reply() - if err != nil || reply == nil { - return nil - } - img := buf if img == nil || img.Rect.Dx() != w || img.Rect.Dy() != h || img.Stride != w*4 { img = image.NewRGBA(image.Rect(0, 0, w, h)) } - data := reply.Data + if !captureRegion(conn, drawable, img.Rect, argb, img) { + return nil + } + return img +} + +// captureRegion reads the pixels in region r of an X pixmap into the same +// region of img, converting them as capturePixmap does. The rest of img is +// left as it was. It returns false if the pixels could not be read. +func captureRegion(conn *xgb.Conn, drawable xproto.Drawable, r image.Rectangle, argb bool, img *image.RGBA) bool { + reply, err := xproto.GetImage(conn, xproto.ImageFormatZPixmap, drawable, + int16(r.Min.X), int16(r.Min.Y), uint16(r.Dx()), uint16(r.Dy()), math.MaxUint32).Reply() + if err != nil || reply == nil { + return false + } // X11 ZPixmap format: 4 bytes per pixel in BGRx or BGRA order - expectedLen := w * h * 4 - if len(data) < expectedLen { - return nil + if len(reply.Data) < r.Dx()*r.Dy()*4 { + return false } + convertZPixmapRegion(img, r, reply.Data, argb) + return true +} - convertZPixmap(img.Pix, data, argb) - return img +// convertZPixmapRegion converts X11 ZPixmap data holding the rows of region r +// into that region of img, as convertZPixmap does. +func convertZPixmapRegion(img *image.RGBA, r image.Rectangle, data []byte, argb bool) { + row := r.Dx() * 4 + for y := r.Min.Y; y < r.Max.Y; y++ { + off := img.PixOffset(r.Min.X, y) + convertZPixmap(img.Pix[off:off+row], data[:row], argb) + data = data[row:] + } +} + +// copyRegion copies the pixels in region r of src into the same region of dst, +// an image with the same bounds. +func copyRegion(dst, src *image.RGBA, r image.Rectangle) { + row := r.Dx() * 4 + for y := r.Min.Y; y < r.Max.Y; y++ { + off := dst.PixOffset(r.Min.X, y) + copy(dst.Pix[off:off+row], src.Pix[off:off+row]) + } +} + +// damagedRegion returns where in the pixmap of a window with the given border +// width a damage notification's area lies. Damage is reported from the origin +// of the window, which the pixmap surrounds with its border. +func damagedRegion(area xproto.Rectangle, border uint16) image.Rectangle { + x, y := int(area.X)+int(border), int(area.Y)+int(border) + return image.Rect(x, y, x+int(area.Width), y+int(area.Height)) } // convertZPixmap converts X11 ZPixmap data (4 bytes/pixel in BGRx or BGRA diff --git a/internal/x11/composit/capture_test.go b/internal/x11/composit/capture_test.go index 8840d331..5ee6bc63 100644 --- a/internal/x11/composit/capture_test.go +++ b/internal/x11/composit/capture_test.go @@ -4,7 +4,12 @@ package composit import ( + "image" + "image/color" "testing" + + "github.com/BurntSushi/xgb/xproto" + "github.com/stretchr/testify/assert" ) // convertNaive is a straightforward, independent reference implementation of @@ -85,3 +90,57 @@ func benchConvert(b *testing.B, fn func(dst, src []byte, argb bool)) { func BenchmarkConvertNaive(b *testing.B) { benchConvert(b, convertNaive) } func BenchmarkConvertZPixmap(b *testing.B) { benchConvert(b, convertZPixmap) } + +func TestConvertZPixmapRegion(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 6, 5)) + before := color.RGBA{R: 1, G: 2, B: 3, A: 4} + for i := 0; i < len(img.Pix); i += 4 { + img.SetRGBA(i/4%6, i/4/6, before) + } + + r := image.Rect(2, 1, 5, 3) + src := sampleZPixmap(r.Dx(), r.Dy()) + want := make([]byte, len(src)) + convertNaive(want, src, false) + convertZPixmapRegion(img, r, src, false) + + for y := 0; y < 5; y++ { + for x := 0; x < 6; x++ { + if !image.Pt(x, y).In(r) { + assert.Equal(t, before, img.RGBAAt(x, y), "pixel %d,%d outside the region", x, y) + continue + } + i := ((y-r.Min.Y)*r.Dx() + x - r.Min.X) * 4 + assert.Equal(t, color.RGBA{R: want[i], G: want[i+1], B: want[i+2], A: want[i+3]}, img.RGBAAt(x, y), + "pixel %d,%d", x, y) + } + } +} + +func TestCopyRegion(t *testing.T) { + src := image.NewRGBA(image.Rect(0, 0, 6, 5)) + for i := range src.Pix { + src.Pix[i] = byte(i) + } + dst := image.NewRGBA(src.Rect) + + r := image.Rect(1, 2, 4, 5) + copyRegion(dst, src, r) + for y := 0; y < 5; y++ { + for x := 0; x < 6; x++ { + want := color.RGBA{} + if image.Pt(x, y).In(r) { + want = src.RGBAAt(x, y) + } + assert.Equal(t, want, dst.RGBAAt(x, y), "pixel %d,%d", x, y) + } + } + + copyRegion(dst, src, image.Rectangle{}) // nothing to copy, must not panic +} + +func TestDamagedRegion(t *testing.T) { + area := xproto.Rectangle{X: 10, Y: 20, Width: 30, Height: 40} + assert.Equal(t, image.Rect(10, 20, 40, 60), damagedRegion(area, 0)) + assert.Equal(t, image.Rect(12, 22, 42, 62), damagedRegion(area, 2)) +} diff --git a/internal/x11/composit/gocompositor.go b/internal/x11/composit/gocompositor.go index ddad28c6..c393b123 100644 --- a/internal/x11/composit/gocompositor.go +++ b/internal/x11/composit/gocompositor.go @@ -41,7 +41,6 @@ type client struct { opacity uint32 opacitySet bool // true if the window has an explicit _NET_WM_WINDOW_OPACITY value opaqueType opaqueType - damaged bool skipped bool // desktop root window or other skipped windows fullscreened bool // unredirected for fullscreen bypass visualMoving bool // position being managed by VisualMoveCallback (drag/animation) @@ -53,13 +52,25 @@ type client struct { damage damage.Damage pixmap xproto.Pixmap // cached NameWindowPixmap + // dirty is the part of the pixmap drawn to since it was last captured. + dirty image.Rectangle + // Double-buffered capture: the compositor writes to bufs[writeIdx] and // toggles writeIdx on each successful push so that the buffer being - // displayed by the renderer is never mutated. + // displayed by the renderer is never mutated. Only what was drawn to is + // captured, so a buffer misses what was captured into the others since it + // was written: stale is the part of each that latest, the frame last + // pushed, has newer pixels for. bufs [2]*image.RGBA + stale [2]image.Rectangle writeIdx int + latest *image.RGBA + frame uint64 // the number of the frame last pushed } +// wholeWindow is the dirty region of a window that is to be captured in full. +var wholeWindow = image.Rect(0, 0, math.MaxUint16, math.MaxUint16) + var ( defaultScreen int xu *xgbutil.XUtil @@ -405,7 +416,7 @@ func Run(done chan struct{}, screenComps []ui.ScreenCompositors) error { } if e.Atom == decorationAtom { // the WM has a new frame to paint if c := getClientFromWindow(e.Window); c != nil { - c.damaged = true + c.dirty = wholeWindow allDamage = true } } @@ -563,7 +574,7 @@ func (ws *widgets) applyPending(conn *xgb.Conn) bool { } else { ensureWindowOnScreens(ws, c) } - c.damaged = true + c.dirty = wholeWindow } syncOrder(ws) refreshWindows(conn, ws) @@ -683,9 +694,7 @@ func copyImageFromOtherScreen(ws *widgets, winID uint32, target *ui.WindowImage, // Prefer the displayed image, but fall back to the latest captured buffer. img := src.Img.Image if img == nil { - if back := src.Back.Load(); back != nil { - img, _ = back.(image.Image) - } + img = src.Latest() } if img != nil { target.Img.Image = img @@ -744,13 +753,13 @@ func updateFullscreen(conn *xgb.Conn, ws *widgets, c *client) { if c.damage == 0 { dmg, err := damage.NewDamageId(conn) if err == nil { - if err = damage.CreateChecked(conn, dmg, xproto.Drawable(c.win), damage.ReportLevelNonEmpty).Check(); err == nil { + if err = damage.CreateChecked(conn, dmg, xproto.Drawable(c.win), damage.ReportLevelBoundingBox).Check(); err == nil { c.damage = dmg } } } ensureWindowOnScreens(ws, c) - c.damaged = true + c.dirty = wholeWindow allDamage = true syncOrder(ws) ws.refreshAll() @@ -830,11 +839,11 @@ func refreshWindows(conn *xgb.Conn, ws *widgets) { flushRefresh(refreshed) } -// captureClient grabs window c's current pixmap into its back buffer and marks -// the affected compositor widgets for refresh (via flushRefresh). It returns -// whether a capture happened. +// captureClient grabs what was drawn to window c's pixmap into its back buffer +// and marks the affected compositor widgets for refresh (via flushRefresh). It +// returns whether a capture happened. func captureClient(conn *xgb.Conn, ws *widgets, c *client, refreshed map[*ui.CompositorWidget]bool) bool { - if !c.damaged || c.skipped || c.fullscreened || c.visualMoving { + if c.dirty.Empty() || c.skipped || c.fullscreened || c.visualMoving { return false } if c.attributes.MapState != xproto.MapStateViewable { @@ -864,21 +873,44 @@ func captureClient(conn *xgb.Conn, ws *widgets, c *client, refreshed map[*ui.Com isARGB := c.opaqueType == argb // When a refresh is pending the renderer may read the previous - // write buffer via Back, so use a fresh allocation to avoid a race. - buf := c.bufs[c.writeIdx] + // write buffer, so use a fresh allocation to avoid a race. + buf, stale := c.bufs[c.writeIdx], c.stale[c.writeIdx] if pending { buf = nil } - img := capturePixmap(conn, xproto.Drawable(c.pixmap), totalW, totalH, isARGB, buf) - if img == nil { - return false + + bounds := image.Rect(0, 0, int(totalW), int(totalH)) + dirty := c.dirty.Intersect(bounds) + img := buf + if c.latest != nil && c.latest.Rect == bounds && dirty != bounds { + // Most of the last frame still stands: bring the buffer up to date + // with it and capture only what was drawn to since. + if img == nil || img.Rect != bounds { + img, stale = image.NewRGBA(bounds), bounds + } + copyRegion(img, c.latest, stale) + if !captureRegion(conn, xproto.Drawable(c.pixmap), dirty, isARGB, img) { + return false + } + } else { + dirty = bounds + img = capturePixmap(conn, xproto.Drawable(c.pixmap), totalW, totalH, isARGB, buf) + if img == nil { + return false + } } - c.damaged = false + c.dirty = image.Rectangle{} + for i := range c.stale { + c.stale[i] = c.stale[i].Union(dirty) + } if !pending { c.bufs[c.writeIdx] = img + c.stale[c.writeIdx] = image.Rectangle{} c.writeIdx = 1 - c.writeIdx } + c.latest = img + c.frame++ w := wmWindow(c) if xw, ok := w.(x11.XWin); ok { @@ -898,8 +930,8 @@ func captureClient(conn *xgb.Conn, ws *widgets, c *client, refreshed map[*ui.Com translucency := computeTranslucency(conn, c) - // Push to back buffer on all screen widgets for this window. - // The renderer will swap Back→Img.Image on its next Refresh. + // Push the frame to all screen widgets for this window. + // The renderer will swap it into Img.Image on its next Refresh. for _, sw := range ws.screensForClient(c) { var target *ui.CompositorWidget if isFullScreen { @@ -912,7 +944,6 @@ func captureClient(conn *xgb.Conn, ws *widgets, c *client, refreshed map[*ui.Com continue } - wi.Back.Store(img) wi.Img.Translucency = translucency localX := c.geom.X - int16(sw.screen.X) @@ -929,16 +960,13 @@ func captureClient(conn *xgb.Conn, ws *widgets, c *client, refreshed map[*ui.Com } // Only request a refresh when none is pending — the pending - // refresh reads Back at render time so it always gets the - // latest frame. - if !pending { - wi.Pending.Store(true) + // refresh takes the frame at render time so it always gets the + // latest one. + if wi.Push(img, dirty, c.frame) { refreshed[target] = true } } - if !pending { - c.pending = true - } + c.pending = true return true } @@ -1048,7 +1076,7 @@ func checkPending(c *client, ws *widgets) (uint32, bool, bool) { } else { target = sw.normal } - if wi := target.GetWindow(winID); wi != nil && wi.Pending.Load() { + if wi := target.GetWindow(winID); wi != nil && wi.Pending() { pending = true break } @@ -1192,7 +1220,6 @@ func addClient(conn *xgb.Conn, window xproto.Window) error { attributes: *attr, geom: *geom, opacity: opaque, - damaged: false, } // Skip the desktop root window, skip-hinted windows, and screensavers. @@ -1206,7 +1233,7 @@ func addClient(conn *xgb.Conn, window xproto.Window) error { if err != nil { return err } - if err = damage.CreateChecked(conn, c.damage, xproto.Drawable(window), damage.ReportLevelNonEmpty).Check(); err != nil { + if err = damage.CreateChecked(conn, c.damage, xproto.Drawable(window), damage.ReportLevelBoundingBox).Check(); err != nil { return err } } @@ -1228,7 +1255,7 @@ func mapWin(conn *xgb.Conn, ws *widgets, window xproto.Window) error { _ = xproto.ChangeWindowAttributes(conn, window, xproto.CwEventMask, mask) c.attributes.MapState = xproto.MapStateViewable - c.damaged = true + c.dirty = wholeWindow c.priority = true // a just-mapped window is what the user is waiting to see updateOpacity(conn, 1, c) @@ -1263,7 +1290,7 @@ func unmapWin(conn *xgb.Conn, ws *widgets, window xproto.Window) { return } c.attributes.MapState = xproto.MapStateUnmapped - c.damaged = false + c.dirty = image.Rectangle{} freeClientPixmap(conn, c) if ws != nil && !c.skipped { @@ -1368,7 +1395,7 @@ func configureClient(conn *xgb.Conn, ws *widgets, e xproto.ConfigureNotifyEvent) if screenChanged { // Force recapture so the new screen gets a fresh image - client.damaged = true + client.dirty = wholeWindow allDamage = true } @@ -1401,7 +1428,7 @@ func configureClient(conn *xgb.Conn, ws *widgets, e xproto.ConfigureNotifyEvent) wi.H = totalH target.Refresh() }) - client.damaged = true + client.dirty = wholeWindow allDamage = true } else { wi.X = localX @@ -1481,7 +1508,7 @@ func damageClient(conn *xgb.Conn, e *damage.NotifyEvent) error { return err } - client.damaged = true + client.dirty = client.dirty.Union(damagedRegion(e.Area, client.geom.BorderWidth)) allDamage = true return nil } From eaf2ec732a6ebc75875a7f123a4edc1290c626a4 Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Mon, 19 Oct 2026 09:44:12 +0100 Subject: [PATCH 2/4] Fix gofumpt --- internal/ui/settings_account.go | 1 + internal/ui/settings_home.go | 8 +++++--- internal/ui/settings_ui.go | 3 ++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/internal/ui/settings_account.go b/internal/ui/settings_account.go index 0c9de59a..ea607e95 100644 --- a/internal/ui/settings_account.go +++ b/internal/ui/settings_account.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "image" + // Register decoders so any common image can be re-encoded as the .face PNG. _ "image/gif" _ "image/jpeg" diff --git a/internal/ui/settings_home.go b/internal/ui/settings_home.go index 1f32a0ca..67018418 100644 --- a/internal/ui/settings_home.go +++ b/internal/ui/settings_home.go @@ -144,7 +144,8 @@ func newSettingsNav(groups []settingsGroup, headerIcon fyne.Resource) *settingsN // usual top-left, the icon docks top-right and the title centres between. iconSlot := container.NewGridWrap(fyne.NewSquareSize(headerIconSize), n.detailIcon) header := container.NewStack(n.detailTitle, container.NewHBox( - container.NewCenter(n.backButton), layout.NewSpacer(), iconSlot)) + container.NewCenter(n.backButton), layout.NewSpacer(), iconSlot, + )) n.detail = container.NewPadded(container.NewBorder( container.NewVBox(header, widget.NewSeparator()), nil, nil, nil, n.detailContent, @@ -189,8 +190,9 @@ func (n *settingsNav) buildHome() fyne.CanvasObject { iconSlot := container.NewGridWrap(fyne.NewSquareSize(headerIconSize), homeIcon) title := widget.NewLabelWithStyle("Settings", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}) title.SizeName = theme.SizeNameHeadingText - head := container.NewVBox(container.NewStack(title, - container.NewHBox(layout.NewSpacer(), iconSlot)), + head := container.NewVBox( + container.NewStack(title, + container.NewHBox(layout.NewSpacer(), iconSlot)), widget.NewSeparator(), ) body := container.NewPadded(container.NewBorder(head, nil, nil, nil, scroll)) diff --git a/internal/ui/settings_ui.go b/internal/ui/settings_ui.go index b0e8fd95..9b62c4f3 100644 --- a/internal/ui/settings_ui.go +++ b/internal/ui/settings_ui.go @@ -509,7 +509,8 @@ func (d *settingsUI) loadDesktopsScreen() fyne.CanvasObject { container.NewVBox(widget.NewRichTextFromMarkdown("## Virtual Desktops"), widget.NewForm(widget.NewFormItem("Number of desktops", count)), widget.NewSeparator(), widget.NewRichTextFromMarkdown("## Names\n\nLeave blank to show the number.")), - nil, nil, nil, container.NewVScroll(names)) + nil, nil, nil, container.NewVScroll(names), + ) } func (d *settingsUI) loadKeyboardScreen() fyne.CanvasObject { From ba330fe062640fcbd0270ef07a87cadd4f265f40 Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Mon, 19 Oct 2026 10:07:21 +0100 Subject: [PATCH 3/4] Refactor to reduce complexity --- internal/x11/composit/gocompositor.go | 183 +++++++++++++++----------- 1 file changed, 108 insertions(+), 75 deletions(-) diff --git a/internal/x11/composit/gocompositor.go b/internal/x11/composit/gocompositor.go index dc6823e4..f204cc11 100644 --- a/internal/x11/composit/gocompositor.go +++ b/internal/x11/composit/gocompositor.go @@ -862,6 +862,51 @@ func refreshWindows(conn *xgb.Conn, ws *widgets) { // and marks the affected compositor widgets for refresh (via flushRefresh). It // returns whether a capture happened. func captureClient(conn *xgb.Conn, ws *widgets, c *client, refreshed map[*ui.CompositorWidget]bool) bool { + if !clientCapturable(c) || !ensureNamedPixmap(conn, c) { + return false + } + + winID, isFullScreen, pending := checkPending(c, ws) + + img, dirty, ok := captureFrame(conn, c, pending) + if !ok { + return false + } + c.dirty = image.Rectangle{} + stopRepaintTimer(c) + + for i := range c.stale { + c.stale[i] = c.stale[i].Union(dirty) + } + if !pending { + c.bufs[c.writeIdx] = img + c.stale[c.writeIdx] = image.Rectangle{} + c.writeIdx = 1 - c.writeIdx + } + c.latest = img + c.frame++ + + decorateFrame(ws, c, img) + translucency := computeTranslucency(conn, c) + + // Push the frame to all screen widgets for this window. + // The renderer will swap it into Img.Image on its next Refresh. + for _, sw := range ws.screensForClient(c) { + target := sw.normal + if isFullScreen { + target = sw.overlay + } + if pushFrame(target, sw, c, winID, img, dirty, translucency) { + refreshed[target] = true + } + } + c.pending = true + return true +} + +// clientCapturable reports whether c is damaged, mapped and at least partly +// on screen, so that there is something worth capturing. +func clientCapturable(c *client) bool { if c.dirty.Empty() || c.skipped || c.fullscreened || c.visualMoving { return false } @@ -872,21 +917,29 @@ func captureClient(conn *xgb.Conn, ws *widgets, c *client, refreshed map[*ui.Com c.geom.X >= int16(rootWidth) || c.geom.Y >= int16(rootHeight) { return false } + return true +} - // Ensure we have a named pixmap - if c.pixmap == 0 { - pixmap, err := xproto.NewPixmapId(conn) - if err != nil { - return false - } - if err = composite.NameWindowPixmapChecked(conn, c.win, pixmap).Check(); err != nil { - return false - } - c.pixmap = pixmap +// ensureNamedPixmap makes sure c has a named pixmap to capture from. +func ensureNamedPixmap(conn *xgb.Conn, c *client) bool { + if c.pixmap != 0 { + return true } + pixmap, err := xproto.NewPixmapId(conn) + if err != nil { + return false + } + if err = composite.NameWindowPixmapChecked(conn, c.win, pixmap).Check(); err != nil { + return false + } + c.pixmap = pixmap + return true +} - winID, isFullScreen, pending := checkPending(c, ws) - +// captureFrame reads c's current content into an image, capturing only the +// damaged region on top of the last frame when possible. It returns the image, +// the region of it that changed and whether the capture succeeded. +func captureFrame(conn *xgb.Conn, c *client, pending bool) (*image.RGBA, image.Rectangle, bool) { totalW := c.geom.Width + c.geom.BorderWidth*2 totalH := c.geom.Height + c.geom.BorderWidth*2 isARGB := c.opaqueType == argb @@ -900,86 +953,66 @@ func captureClient(conn *xgb.Conn, ws *widgets, c *client, refreshed map[*ui.Com bounds := image.Rect(0, 0, int(totalW), int(totalH)) dirty := c.dirty.Intersect(bounds) - img := buf - if c.latest != nil && c.latest.Rect == bounds && dirty != bounds { - // Most of the last frame still stands: bring the buffer up to date - // with it and capture only what was drawn to since. - if img == nil || img.Rect != bounds { - img, stale = image.NewRGBA(bounds), bounds - } - copyRegion(img, c.latest, stale) - if !captureRegion(conn, xproto.Drawable(c.pixmap), dirty, isARGB, img) { - return false - } - } else { - dirty = bounds - img = capturePixmap(conn, xproto.Drawable(c.pixmap), totalW, totalH, isARGB, buf) - if img == nil { - return false - } + if c.latest == nil || c.latest.Rect != bounds || dirty == bounds { + img := capturePixmap(conn, xproto.Drawable(c.pixmap), totalW, totalH, isARGB, buf) + return img, bounds, img != nil } - c.dirty = image.Rectangle{} - stopRepaintTimer(c) - for i := range c.stale { - c.stale[i] = c.stale[i].Union(dirty) + // Most of the last frame still stands: bring the buffer up to date + // with it and capture only what was drawn to since. + img := buf + if img == nil || img.Rect != bounds { + img, stale = image.NewRGBA(bounds), bounds } - if !pending { - c.bufs[c.writeIdx] = img - c.stale[c.writeIdx] = image.Rectangle{} - c.writeIdx = 1 - c.writeIdx + copyRegion(img, c.latest, stale) + if !captureRegion(conn, xproto.Drawable(c.pixmap), dirty, isARGB, img) { + return nil, image.Rectangle{}, false } - c.latest = img - c.frame++ + return img, dirty, true +} +// decorateFrame lets the WM paint its frame over img and rounds the corners +// of windows that are not filling the screen. +func decorateFrame(ws *widgets, c *client, img *image.RGBA) { w := wmWindow(c) if xw, ok := w.(x11.XWin); ok { xw.Decorate(img) // the WM paints the frame over its own border pixels } + if w != nil && (w.Fullscreened() || w.Maximized()) { + return + } radius := float32(0) fyne.DoAndWait(func() { radius = theme.Size(theme.SizeNameInnerWindowRadius) }) - if w == nil || (!w.Fullscreened() && !w.Maximized()) { - scale := float32(1) - if len(ws.screens) > 0 { - scale = ws.screens[0].screen.CanvasScale() - } - roundCorners(img, int(radius*scale)) + scale := float32(1) + if len(ws.screens) > 0 { + scale = ws.screens[0].screen.CanvasScale() } + roundCorners(img, int(radius*scale)) +} - translucency := computeTranslucency(conn, c) - - // Push the frame to all screen widgets for this window. - // The renderer will swap it into Img.Image on its next Refresh. - for _, sw := range ws.screensForClient(c) { - var target *ui.CompositorWidget - if isFullScreen { - target = sw.overlay - } else { - target = sw.normal - } - wi := target.GetWindow(winID) - if wi == nil { - continue - } - - // The renderer lays the window out at the image's size when it swaps it in. - if wi.W == 0 { - wi.X = c.geom.X - int16(sw.screen.X) - wi.Y = c.geom.Y - int16(sw.screen.Y) - } - wi.Img.Translucency = translucency +// pushFrame hands img to the window item for c in target, returning whether +// target needs a refresh as a result. +func pushFrame(target *ui.CompositorWidget, sw *screenWidgets, c *client, winID uint32, + img *image.RGBA, dirty image.Rectangle, translucency float64, +) bool { + wi := target.GetWindow(winID) + if wi == nil { + return false + } - // Only request a refresh when none is pending — the pending - // refresh takes the frame at render time so it always gets the - // latest one. - if wi.Push(img, dirty, c.frame) { - refreshed[target] = true - } + // The renderer lays the window out at the image's size when it swaps it in. + if wi.W == 0 { + wi.X = c.geom.X - int16(sw.screen.X) + wi.Y = c.geom.Y - int16(sw.screen.Y) } - c.pending = true - return true + wi.Img.Translucency = translucency + + // Only request a refresh when none is pending — the pending + // refresh takes the frame at render time so it always gets the + // latest one. + return wi.Push(img, dirty, c.frame) } // flushRefresh issues a single widget-level refresh for each compositor marked From d0c06307586059017803d8b8b194af91a4c3922f Mon Sep 17 00:00:00 2001 From: Andy Williams Date: Mon, 19 Oct 2026 11:03:51 +0100 Subject: [PATCH 4/4] New staticcheck --- internal/x11/win/frame.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/x11/win/frame.go b/internal/x11/win/frame.go index 153e96aa..ae2f0f975 100644 --- a/internal/x11/win/frame.go +++ b/internal/x11/win/frame.go @@ -373,7 +373,7 @@ func (f *frame) renderDecoration() { f.canvas.SetScale(scale) right := f.topRightPixelWidth() - drawWidth := fyne.Max(f.canvas.Content().MinSize().Width, float32(f.width)/scale) + drawWidth := max(f.canvas.Content().MinSize().Width, float32(f.width)/scale) f.canvas.Resize(fyne.NewSize(drawWidth, wmTheme.TitleHeight+gripSize)) img := f.canvas.Capture()