Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down Expand Up @@ -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=
Expand Down
91 changes: 73 additions & 18 deletions internal/ui/compositor.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"image"
"image/color"
"sync"
"sync/atomic"

"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
45 changes: 41 additions & 4 deletions internal/ui/compositor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"github.com/stretchr/testify/assert"

wmTheme "fyshos.com/tyde/theme"
)
Expand Down Expand Up @@ -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.
Expand All @@ -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())
Expand All @@ -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())
Expand Down
1 change: 1 addition & 0 deletions internal/ui/settings_account.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 5 additions & 3 deletions internal/ui/settings_home.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Expand Down
3 changes: 2 additions & 1 deletion internal/ui/settings_ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
59 changes: 47 additions & 12 deletions internal/x11/composit/capture.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading