detached 892d98e

fix(wayland): redraw a subsurface's area when it stops drawing

Partial damage was kept whenever the published tree had no drawn subsurface, so a synced child hidden by a null attach (applied by the parent's commit) left its old pixels on screen. The root now remembers whether its last tree drew children and only keeps partial damage when neither did. This also covers destroying a drawn child, so detach no longer forces a full change for an empty one.
commit
892d98ea798c967714cb42671f612facfe5e934e
author
brice · · 2026-10-08 05:12 UTC
parent
c0d1352
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +58 -6
M internal/adapters/wayland/damage_test.go +50 -0
@@ -124,4 +124,54 @@ func TestDamageHistory(t *testing.T) {
124124 if got, ok := fourth.DamageSince(third.Seq); !ok || len(got) != 1 || got[0] != (ports.Rect{X: 1, Y: 1, W: 2, H: 2}) {
125125 t.Fatalf("empty subsurface: damage %v %v", got, ok)
126126 }
127+ // A synced child drawn, then hidden by a null attach the parent's
128+ // commit applies: its old area must be redrawn, both times.
129+ rootCommit := func(what string, wantChildren int) {
130+ t.Helper()
131+ prev := fourth
132+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
133+ fourth = next()
134+ if len(fourth.Children) != wantChildren {
135+ t.Fatalf("%s: %d children, want %d", what, len(fourth.Children), wantChildren)
136+ }
137+ if got, ok := fourth.DamageSince(prev.Seq); ok {
138+ t.Fatalf("%s kept partial damage %v", what, got)
139+ }
140+ }
141+ requestProtocol(t, c, child, wayland.SurfaceRequestAttach, bufs[1], int32(0), int32(0))
142+ requestProtocol(t, c, child, wayland.SurfaceRequestCommit)
143+ rootCommit("child appearing", 1)
144+ requestProtocol(t, c, child, wayland.SurfaceRequestAttach, uint32(0), int32(0), int32(0))
145+ requestProtocol(t, c, child, wayland.SurfaceRequestCommit)
146+ rootCommit("child hidden", 0)
147+ // Destroying a child that draws nothing changes nothing on screen.
148+ prev := fourth
149+ requestProtocol(t, c, sub, wayland.SubsurfaceRequestDestroy)
150+ fourth = next()
151+ if got, ok := fourth.DamageSince(prev.Seq); !ok || len(got) != 0 {
152+ t.Fatalf("empty child destroyed: damage %v %v", got, ok)
153+ }
154+ prev = fourth
155+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, bufs[0], int32(0), int32(0))
156+ requestProtocol(t, c, surf, wayland.SurfaceRequestDamageBuffer, int32(1), int32(1), int32(2), int32(2))
157+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
158+ fourth = next()
159+ if got, ok := fourth.DamageSince(prev.Seq); !ok || len(got) != 1 {
160+ t.Fatalf("after empty child: damage %v %v", got, ok)
161+ }
162+ // Destroying a drawn child redraws its area.
163+ child2, sub2 := c.AllocateID(), c.AllocateID()
164+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, child2)
165+ registerProtocol(t, c, child2)
166+ registerProtocol(t, c, sub2)
167+ requestProtocol(t, c, subc, wayland.SubcompositorRequestGetSubsurface, sub2, child2, surf)
168+ requestProtocol(t, c, child2, wayland.SurfaceRequestAttach, bufs[1], int32(0), int32(0))
169+ requestProtocol(t, c, child2, wayland.SurfaceRequestCommit)
170+ rootCommit("second child appearing", 1)
171+ prev = fourth
172+ requestProtocol(t, c, sub2, wayland.SubsurfaceRequestDestroy)
173+ fourth = next()
174+ if got, ok := fourth.DamageSince(prev.Seq); ok || len(fourth.Children) != 0 {
175+ t.Fatalf("drawn child destroyed: damage %v %v, %d children", got, ok, len(fourth.Children))
176+ }
127177 }
@@ -124,4 +124,54 @@ func TestDamageHistory(t *testing.T) {
124 if got, ok := fourth.DamageSince(third.Seq); !ok || len(got) != 1 || got[0] != (ports.Rect{X: 1, Y: 1, W: 2, H: 2}) {124 if got, ok := fourth.DamageSince(third.Seq); !ok || len(got) != 1 || got[0] != (ports.Rect{X: 1, Y: 1, W: 2, H: 2}) {
125 t.Fatalf("empty subsurface: damage %v %v", got, ok)125 t.Fatalf("empty subsurface: damage %v %v", got, ok)
126 }126 }
127+ // A synced child drawn, then hidden by a null attach the parent's
128+ // commit applies: its old area must be redrawn, both times.
129+ rootCommit := func(what string, wantChildren int) {
130+ t.Helper()
131+ prev := fourth
132+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
133+ fourth = next()
134+ if len(fourth.Children) != wantChildren {
135+ t.Fatalf("%s: %d children, want %d", what, len(fourth.Children), wantChildren)
136+ }
137+ if got, ok := fourth.DamageSince(prev.Seq); ok {
138+ t.Fatalf("%s kept partial damage %v", what, got)
139+ }
140+ }
141+ requestProtocol(t, c, child, wayland.SurfaceRequestAttach, bufs[1], int32(0), int32(0))
142+ requestProtocol(t, c, child, wayland.SurfaceRequestCommit)
143+ rootCommit("child appearing", 1)
144+ requestProtocol(t, c, child, wayland.SurfaceRequestAttach, uint32(0), int32(0), int32(0))
145+ requestProtocol(t, c, child, wayland.SurfaceRequestCommit)
146+ rootCommit("child hidden", 0)
147+ // Destroying a child that draws nothing changes nothing on screen.
148+ prev := fourth
149+ requestProtocol(t, c, sub, wayland.SubsurfaceRequestDestroy)
150+ fourth = next()
151+ if got, ok := fourth.DamageSince(prev.Seq); !ok || len(got) != 0 {
152+ t.Fatalf("empty child destroyed: damage %v %v", got, ok)
153+ }
154+ prev = fourth
155+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, bufs[0], int32(0), int32(0))
156+ requestProtocol(t, c, surf, wayland.SurfaceRequestDamageBuffer, int32(1), int32(1), int32(2), int32(2))
157+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
158+ fourth = next()
159+ if got, ok := fourth.DamageSince(prev.Seq); !ok || len(got) != 1 {
160+ t.Fatalf("after empty child: damage %v %v", got, ok)
161+ }
162+ // Destroying a drawn child redraws its area.
163+ child2, sub2 := c.AllocateID(), c.AllocateID()
164+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, child2)
165+ registerProtocol(t, c, child2)
166+ registerProtocol(t, c, sub2)
167+ requestProtocol(t, c, subc, wayland.SubcompositorRequestGetSubsurface, sub2, child2, surf)
168+ requestProtocol(t, c, child2, wayland.SurfaceRequestAttach, bufs[1], int32(0), int32(0))
169+ requestProtocol(t, c, child2, wayland.SurfaceRequestCommit)
170+ rootCommit("second child appearing", 1)
171+ prev = fourth
172+ requestProtocol(t, c, sub2, wayland.SubsurfaceRequestDestroy)
173+ fourth = next()
174+ if got, ok := fourth.DamageSince(prev.Seq); ok || len(fourth.Children) != 0 {
175+ t.Fatalf("drawn child destroyed: damage %v %v, %d children", got, ok, len(fourth.Children))
176+ }
127 }177 }
M internal/adapters/wayland/surface.go +8 -6
@@ -98,6 +98,9 @@ type surface struct {
9898 // committed is what the last commit changed, in buffer pixels (full:
9999 // everything), read by the window's damage history.
100100 committed damage
101+ // drewChildren is whether the root's last published tree drew a
102+ // subsurface: one that stops drawing leaves its area to redraw.
103+ drewChildren bool
101104 // Explicit sync (syncobj.go): the surface's syncobj object and the
102105 // current buffer's hold.
103106 sync *syncState
@@ -252,17 +255,18 @@ func (s *surface) appendTree(out *[]ports.Subsurface, x, y int, below bool) {
252255 }
253256
254257 // redraw sends the tree of a mapped root to the outputs. Only a commit
255-// of the root's own buffer with no other change keeps partial damage:
256-// subsurfaces without a buffer (clients often keep one for later) are not
257-// drawn, so they do not make it full.
258+// of the root's own buffer, with no subsurface drawn before or now, keeps
259+// partial damage: subsurfaces without a buffer (clients often keep one
260+// for later) are not drawn, so they do not make it full.
258261 func (s *surface) redraw() {
259262 r := s.root()
260263 if id := r.windowID(); id != 0 && s.server.channels.Contents != nil {
261264 c := r.tree(id)
262265 d := damage{full: true}
263- if s == r && len(c.Children) == 0 {
266+ if s == r && len(c.Children) == 0 && !r.drewChildren {
264267 d = r.committed
265268 }
269+ r.drewChildren = len(c.Children) > 0
266270 s.server.emitContent(c, d)
267271 }
268272 }
@@ -289,8 +293,6 @@ func (s *surface) detach() {
289293 // Parent updates cannot make a detached child's bound commits visible.
290294 s.dropQueue()
291295 s.flushDesync()
292- // The child's area changes, not the parent's buffer: redraw it all.
293- p.root().committed = damage{full: true}
294296 p.redraw()
295297 p.emitInput()
296298 s.sendScale()
@@ -98,6 +98,9 @@ type surface struct {
98 // committed is what the last commit changed, in buffer pixels (full:98 // committed is what the last commit changed, in buffer pixels (full:
99 // everything), read by the window's damage history.99 // everything), read by the window's damage history.
100 committed damage100 committed damage
101+ // drewChildren is whether the root's last published tree drew a
102+ // subsurface: one that stops drawing leaves its area to redraw.
103+ drewChildren bool
101 // Explicit sync (syncobj.go): the surface's syncobj object and the104 // Explicit sync (syncobj.go): the surface's syncobj object and the
102 // current buffer's hold.105 // current buffer's hold.
103 sync *syncState106 sync *syncState
@@ -252,17 +255,18 @@ func (s *surface) appendTree(out *[]ports.Subsurface, x, y int, below bool) {
252 }255 }
253 256
254 // redraw sends the tree of a mapped root to the outputs. Only a commit257 // redraw sends the tree of a mapped root to the outputs. Only a commit
255-// of the root's own buffer with no other change keeps partial damage:258+// of the root's own buffer, with no subsurface drawn before or now, keeps
256-// subsurfaces without a buffer (clients often keep one for later) are not259+// partial damage: subsurfaces without a buffer (clients often keep one
257-// drawn, so they do not make it full.260+// for later) are not drawn, so they do not make it full.
258 func (s *surface) redraw() {261 func (s *surface) redraw() {
259 r := s.root()262 r := s.root()
260 if id := r.windowID(); id != 0 && s.server.channels.Contents != nil {263 if id := r.windowID(); id != 0 && s.server.channels.Contents != nil {
261 c := r.tree(id)264 c := r.tree(id)
262 d := damage{full: true}265 d := damage{full: true}
263- if s == r && len(c.Children) == 0 {266+ if s == r && len(c.Children) == 0 && !r.drewChildren {
264 d = r.committed267 d = r.committed
265 }268 }
269+ r.drewChildren = len(c.Children) > 0
266 s.server.emitContent(c, d)270 s.server.emitContent(c, d)
267 }271 }
268 }272 }
@@ -289,8 +293,6 @@ func (s *surface) detach() {
289 // Parent updates cannot make a detached child's bound commits visible.293 // Parent updates cannot make a detached child's bound commits visible.
290 s.dropQueue()294 s.dropQueue()
291 s.flushDesync()295 s.flushDesync()
292- // The child's area changes, not the parent's buffer: redraw it all.
293- p.root().committed = damage{full: true}
294 p.redraw()296 p.redraw()
295 p.emitInput()297 p.emitInput()
296 s.sendScale()298 s.sendScale()

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 26

Tags 10

↑↓ moveenter openesc close