detached 9f09160

fix(wayland): damage a window cast when its popups change

commit
9f09160b189f63b20ca9087c6e3bf00840b1e866
author
brice · · 2026-10-15 02:27 UTC
parent
be1f2be
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +80 -1
M internal/adapters/wayland/popup.go +5 -0
@@ -66,6 +66,7 @@ func (s *Server) newPopup(x *xdgSurface, r *xdgshell.Surface, id uint32, parentX
6666 }
6767 case buffer && !w.mapped && x.acked && !pp.done:
6868 w.mapped = true
69+ s.castCommitted(w.id)
6970 s.emit(ports.PopupMapped{ID: w.id})
7071 s.log.Debug().Uint64("id", uint64(w.id)).Uint64("parent", uint64(pp.parentID())).Msg("popup mapped")
7172 case !buffer && w.mapped:
@@ -146,6 +147,10 @@ func (p *popup) configure(c ports.ConfigurePopup) {
146147 }
147148 p.resource.SendConfigure(int32(c.Rect.X), int32(c.Rect.Y), int32(c.Rect.W), int32(c.Rect.H))
148149 s := p.w.xdg.server
150+ if p.w.mapped {
151+ // A moved popup changes the toplevel's cast picture.
152+ s.castCommitted(p.w.id)
153+ }
149154 s.serial++
150155 p.w.xdg.resource.SendConfigure(s.serial)
151156 p.w.xdg.serials = append(p.w.xdg.serials, s.serial)
@@ -66,6 +66,7 @@ func (s *Server) newPopup(x *xdgSurface, r *xdgshell.Surface, id uint32, parentX
66 }66 }
67 case buffer && !w.mapped && x.acked && !pp.done:67 case buffer && !w.mapped && x.acked && !pp.done:
68 w.mapped = true68 w.mapped = true
69+ s.castCommitted(w.id)
69 s.emit(ports.PopupMapped{ID: w.id})70 s.emit(ports.PopupMapped{ID: w.id})
70 s.log.Debug().Uint64("id", uint64(w.id)).Uint64("parent", uint64(pp.parentID())).Msg("popup mapped")71 s.log.Debug().Uint64("id", uint64(w.id)).Uint64("parent", uint64(pp.parentID())).Msg("popup mapped")
71 case !buffer && w.mapped:72 case !buffer && w.mapped:
@@ -146,6 +147,10 @@ func (p *popup) configure(c ports.ConfigurePopup) {
146 }147 }
147 p.resource.SendConfigure(int32(c.Rect.X), int32(c.Rect.Y), int32(c.Rect.W), int32(c.Rect.H))148 p.resource.SendConfigure(int32(c.Rect.X), int32(c.Rect.Y), int32(c.Rect.W), int32(c.Rect.H))
148 s := p.w.xdg.server149 s := p.w.xdg.server
150+ if p.w.mapped {
151+ // A moved popup changes the toplevel's cast picture.
152+ s.castCommitted(p.w.id)
153+ }
149 s.serial++154 s.serial++
150 p.w.xdg.resource.SendConfigure(s.serial)155 p.w.xdg.resource.SendConfigure(s.serial)
151 p.w.xdg.serials = append(p.w.xdg.serials, s.serial)156 p.w.xdg.serials = append(p.w.xdg.serials, s.serial)
M internal/adapters/wayland/screencast.go +15 -1
@@ -396,8 +396,22 @@ func flipTime(f *ports.FlipInfo) time.Time {
396396 return time.Unix(ts.Sec, ts.Nsec)
397397 }
398398
399-// castCommitted marks the casts of a window that committed content.
399+// castCommitted marks the casts of a window that committed content. A popup
400+// is drawn with its toplevel (captureWindowScene), so its commits, map,
401+// unmap and reposition mark the casts of the toplevel it hangs from, found
402+// through the same parent chain as toplevelRoot.
400403 func (s *Server) castCommitted(id ports.WindowID) {
404+ if len(s.castSessions) == 0 {
405+ return
406+ }
407+ // A popup chain is as deep as menus nest; the bound only guards a cycle.
408+ for range 64 {
409+ w := s.windows[id]
410+ if w == nil || w.popup == nil || w.popup.parent == nil {
411+ break
412+ }
413+ id = w.popup.parent.id
414+ }
401415 for _, c := range s.castSessions {
402416 if c.src.kind == srcWindow && c.src.window == id {
403417 c.dirty = true
@@ -396,8 +396,22 @@ func flipTime(f *ports.FlipInfo) time.Time {
396 return time.Unix(ts.Sec, ts.Nsec)396 return time.Unix(ts.Sec, ts.Nsec)
397 }397 }
398 398
399-// castCommitted marks the casts of a window that committed content.399+// castCommitted marks the casts of a window that committed content. A popup
400+// is drawn with its toplevel (captureWindowScene), so its commits, map,
401+// unmap and reposition mark the casts of the toplevel it hangs from, found
402+// through the same parent chain as toplevelRoot.
400 func (s *Server) castCommitted(id ports.WindowID) {403 func (s *Server) castCommitted(id ports.WindowID) {
404+ if len(s.castSessions) == 0 {
405+ return
406+ }
407+ // A popup chain is as deep as menus nest; the bound only guards a cycle.
408+ for range 64 {
409+ w := s.windows[id]
410+ if w == nil || w.popup == nil || w.popup.parent == nil {
411+ break
412+ }
413+ id = w.popup.parent.id
414+ }
401 for _, c := range s.castSessions {415 for _, c := range s.castSessions {
402 if c.src.kind == srcWindow && c.src.window == id {416 if c.src.kind == srcWindow && c.src.window == id {
403 c.dirty = true417 c.dirty = true
M internal/adapters/wayland/screencast_test.go +60 -0
@@ -493,6 +493,66 @@ func TestCastFlipDamagesWindowCastAfterCommit(t *testing.T) {
493493 h.none() // two commits, one damage
494494 }
495495
496+// A popup is drawn with its toplevel: a static toplevel whose popup (or a
497+// popup of that popup) keeps committing, mapping, unmapping or moving still
498+// damages the window cast. The popups are windows of the server, as an
499+// xdg_popup makes them.
500+func TestCastWindowDamagedByPopupChanges(t *testing.T) {
501+ h := newCastHarness(t, true)
502+ c := protocolClient(t, h.s, h.dir)
503+ w, _, _ := surfaceMapper(t, c, h.events)()
504+ placeWindow(t, h.s, h.commands, w, "HEADLESS-1")
505+ h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 1, Window: w.ID}
506+ h.next(ports.CastSize)
507+
508+ var menu, sub ports.WindowID
509+ if !h.s.display.Do(func() {
510+ top := h.s.windows[w.ID]
511+ m := &window{id: h.s.nextWindow, xdg: top.xdg, mapped: true}
512+ m.popup = &popup{w: m, parent: top}
513+ h.s.windows[m.id] = m
514+ s2 := &window{id: m.id + 1, xdg: top.xdg, mapped: true}
515+ s2.popup = &popup{w: s2, parent: m}
516+ h.s.windows[s2.id] = s2
517+ h.s.nextWindow += 2
518+ menu, sub = m.id, s2.id
519+ }) {
520+ t.Fatal("display stopped")
521+ }
522+ t.Cleanup(func() {
523+ h.s.display.Do(func() { delete(h.s.windows, menu); delete(h.s.windows, sub) })
524+ })
525+ h.flip("HEADLESS-1")
526+ h.none() // nothing changed yet
527+ change := func(name string, f func()) {
528+ t.Helper()
529+ if !h.s.display.Do(f) {
530+ t.Fatal("display stopped")
531+ }
532+ h.flip("HEADLESS-1")
533+ if ev := h.next(ports.CastDamage); ev.ID != 1 {
534+ t.Fatalf("%s: damage %+v", name, ev)
535+ }
536+ h.flip("HEADLESS-1")
537+ h.none() // the damage consumed the change
538+ }
539+ content := func(id ports.WindowID) func() {
540+ return func() { h.s.emitContent(ports.SurfaceContent{ID: id}, damage{full: true}) }
541+ }
542+ change("popup commit", content(menu))
543+ change("popup commit again", content(menu))
544+ change("nested popup commit", content(sub))
545+ change("popup unmap", func() { h.s.windows[menu].mapped = false; content(menu)() })
546+ change("popup map", func() { h.s.windows[menu].mapped = true; h.s.castCommitted(menu) })
547+ change("popup reposition", func() { h.s.castCommitted(menu) })
548+ // A window nobody casts leaves the cast alone.
549+ if !h.s.display.Do(func() { h.s.emitContent(ports.SurfaceContent{ID: w.ID + 1000}, damage{full: true}) }) {
550+ t.Fatal("display stopped")
551+ }
552+ h.flip("HEADLESS-1")
553+ h.none()
554+}
555+
496556 // The inventory is published once per change, not once per frame or flip, and
497557 // changes of one display loop turn share one publication.
498558 func TestCastInventoryPublishedOncePerChange(t *testing.T) {
@@ -493,6 +493,66 @@ func TestCastFlipDamagesWindowCastAfterCommit(t *testing.T) {
493 h.none() // two commits, one damage493 h.none() // two commits, one damage
494 }494 }
495 495
496+// A popup is drawn with its toplevel: a static toplevel whose popup (or a
497+// popup of that popup) keeps committing, mapping, unmapping or moving still
498+// damages the window cast. The popups are windows of the server, as an
499+// xdg_popup makes them.
500+func TestCastWindowDamagedByPopupChanges(t *testing.T) {
501+ h := newCastHarness(t, true)
502+ c := protocolClient(t, h.s, h.dir)
503+ w, _, _ := surfaceMapper(t, c, h.events)()
504+ placeWindow(t, h.s, h.commands, w, "HEADLESS-1")
505+ h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 1, Window: w.ID}
506+ h.next(ports.CastSize)
507+
508+ var menu, sub ports.WindowID
509+ if !h.s.display.Do(func() {
510+ top := h.s.windows[w.ID]
511+ m := &window{id: h.s.nextWindow, xdg: top.xdg, mapped: true}
512+ m.popup = &popup{w: m, parent: top}
513+ h.s.windows[m.id] = m
514+ s2 := &window{id: m.id + 1, xdg: top.xdg, mapped: true}
515+ s2.popup = &popup{w: s2, parent: m}
516+ h.s.windows[s2.id] = s2
517+ h.s.nextWindow += 2
518+ menu, sub = m.id, s2.id
519+ }) {
520+ t.Fatal("display stopped")
521+ }
522+ t.Cleanup(func() {
523+ h.s.display.Do(func() { delete(h.s.windows, menu); delete(h.s.windows, sub) })
524+ })
525+ h.flip("HEADLESS-1")
526+ h.none() // nothing changed yet
527+ change := func(name string, f func()) {
528+ t.Helper()
529+ if !h.s.display.Do(f) {
530+ t.Fatal("display stopped")
531+ }
532+ h.flip("HEADLESS-1")
533+ if ev := h.next(ports.CastDamage); ev.ID != 1 {
534+ t.Fatalf("%s: damage %+v", name, ev)
535+ }
536+ h.flip("HEADLESS-1")
537+ h.none() // the damage consumed the change
538+ }
539+ content := func(id ports.WindowID) func() {
540+ return func() { h.s.emitContent(ports.SurfaceContent{ID: id}, damage{full: true}) }
541+ }
542+ change("popup commit", content(menu))
543+ change("popup commit again", content(menu))
544+ change("nested popup commit", content(sub))
545+ change("popup unmap", func() { h.s.windows[menu].mapped = false; content(menu)() })
546+ change("popup map", func() { h.s.windows[menu].mapped = true; h.s.castCommitted(menu) })
547+ change("popup reposition", func() { h.s.castCommitted(menu) })
548+ // A window nobody casts leaves the cast alone.
549+ if !h.s.display.Do(func() { h.s.emitContent(ports.SurfaceContent{ID: w.ID + 1000}, damage{full: true}) }) {
550+ t.Fatal("display stopped")
551+ }
552+ h.flip("HEADLESS-1")
553+ h.none()
554+}
555+
496 // The inventory is published once per change, not once per frame or flip, and556 // The inventory is published once per change, not once per frame or flip, and
497 // changes of one display loop turn share one publication.557 // changes of one display loop turn share one publication.
498 func TestCastInventoryPublishedOncePerChange(t *testing.T) {558 func TestCastInventoryPublishedOncePerChange(t *testing.T) {

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 25

Tags 10

↑↓ moveenter openesc close