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 5b199ad5..cdf1114a 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,23 +299,26 @@ 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, ok := wi.Back.Load().(image.Image); ok && back != nil { - wi.Img.Image = back - bounds := back.Bounds() - wi.W, wi.H = uint16(bounds.Dx()), uint16(bounds.Dy()) - } - wi.Pending.Store(false) + wi.Img.Image = img + // The frame carries the window's size: a stale image is never + // stretched to a new geometry, it is replaced by the capture at it. + bounds := img.Bounds() + wi.W, wi.H = uint16(bounds.Dx()), uint16(bounds.Dy()) } 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 dfe2e123..54582bc6 100644 --- a/internal/ui/compositor_test.go +++ b/internal/ui/compositor_test.go @@ -7,6 +7,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" + "github.com/stretchr/testify/assert" wmTheme "fyshos.com/tyde/theme" ) @@ -145,6 +146,44 @@ func TestCompositorShadowBeneathManagedWindow(t *testing.T) { } } +// 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) +} + // TestCompositorFrameCarriesItsSize verifies that a window keeps its old pixels // at their old size until the capture at a new size is swapped in, so a resize // never stretches a stale image, while its position follows the window at once. @@ -155,8 +194,7 @@ func TestCompositorFrameCarriesItsSize(t *testing.T) { r := cw.CreateRenderer() old := image.NewRGBA(image.Rect(0, 0, 400, 300)) - wi.Back.Store(old) - wi.Pending.Store(true) + wi.Push(old, old.Bounds(), 1) r.Refresh() if wi.Img.Size() != fyne.NewSize(400, 300) || wi.Img.Position() != fyne.NewPos(100, 200) { t.Fatalf("window not laid out from its first frame: %v at %v", wi.Img.Size(), wi.Img.Position()) @@ -174,8 +212,7 @@ func TestCompositorFrameCarriesItsSize(t *testing.T) { } // The capture at the new size is laid out at its own bounds. grown := image.NewRGBA(image.Rect(0, 0, 500, 350)) - wi.Back.Store(grown) - wi.Pending.Store(true) + wi.Push(grown, grown.Bounds(), 2) r.Refresh() if wi.Img.Image != grown || wi.Img.Size() != fyne.NewSize(500, 350) { t.Fatalf("new frame not laid out at its own size: %v", wi.Img.Size()) 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 { 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 7de6ab9a..f204cc11 100644 --- a/internal/x11/composit/gocompositor.go +++ b/internal/x11/composit/gocompositor.go @@ -42,7 +42,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) @@ -60,13 +59,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 @@ -424,7 +435,7 @@ func Run(done chan struct{}, screenComps []ui.ScreenCompositors) error { // A window awaiting its repaint after a resize is painted // with the latest frame when that capture happens. if c := getClientFromWindow(e.Window); c != nil && c.repaintTimer == nil { - c.damaged = true + c.dirty = wholeWindow allDamage = true } } @@ -582,7 +593,7 @@ func (ws *widgets) applyPending(conn *xgb.Conn) bool { } else { ensureWindowOnScreens(ws, c) } - c.damaged = true + c.dirty = wholeWindow } syncOrder(ws) refreshWindows(conn, ws) @@ -702,9 +713,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, ok := src.Back.Load().(image.Image); ok { - img = back - } + img = src.Latest() } if img != nil { target.Img.Image = img @@ -763,13 +772,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() @@ -849,11 +858,56 @@ 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 !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 } if c.attributes.MapState != xproto.MapStateViewable { @@ -863,95 +917,102 @@ 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 // 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) + 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.damaged = false - stopRepaintTimer(c) - if !pending { - c.bufs[c.writeIdx] = img - c.writeIdx = 1 - c.writeIdx + // 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 + } + copyRegion(img, c.latest, stale) + if !captureRegion(conn, xproto.Drawable(c.pixmap), dirty, isARGB, img) { + return nil, image.Rectangle{}, false } + 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 to back buffer on all screen widgets for this window. - // The renderer will swap Back→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.Back.Store(img) - wi.Img.Translucency = translucency - - // 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) - refreshed[target] = true - } +// 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 } - if !pending { - c.pending = 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) } - 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 @@ -1088,7 +1149,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 } @@ -1232,7 +1293,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. @@ -1246,7 +1306,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 } } @@ -1268,7 +1328,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) @@ -1303,7 +1363,7 @@ func unmapWin(conn *xgb.Conn, ws *widgets, window xproto.Window) { return } c.attributes.MapState = xproto.MapStateUnmapped - c.damaged = false + c.dirty = image.Rectangle{} stopRepaintTimer(c) freeClientPixmap(conn, c) @@ -1367,7 +1427,7 @@ func configureClient(conn *xgb.Conn, ws *widgets, e xproto.ConfigureNotifyEvent) // was beneath it rather than the client's content. Keep showing the // last good frame at its old size and wait for the client to repaint. freeClientPixmap(conn, client) - client.damaged = false + client.dirty = image.Rectangle{} } client.geom.X = e.X @@ -1418,7 +1478,7 @@ func configureClient(conn *xgb.Conn, ws *widgets, e xproto.ConfigureNotifyEvent) if screenChanged && !resized { // Force recapture so the new screen gets a fresh image. A resized // window is captured for every screen once the client repaints. - client.damaged = true + client.dirty = wholeWindow allDamage = true } @@ -1529,7 +1589,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 } 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()