fix(wayland): damage a window cast when its popups change
- commit
- 9f09160b189f63b20ca9087c6e3bf00840b1e866
- author
- brice · · 2026-10-15 02:27 UTC
- parent
- be1f2be
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 66 | 66 | } | |
| 67 | 67 | case buffer && !w.mapped && x.acked && !pp.done: | |
| 68 | 68 | w.mapped = true | |
| 69 | + | s.castCommitted(w.id) | |
| 69 | 70 | s.emit(ports.PopupMapped{ID: w.id}) | |
| 70 | 71 | s.log.Debug().Uint64("id", uint64(w.id)).Uint64("parent", uint64(pp.parentID())).Msg("popup mapped") | |
| 71 | 72 | case !buffer && w.mapped: | |
| @@ -146,6 +147,10 @@ func (p *popup) configure(c ports.ConfigurePopup) { | |||
| 146 | 147 | } | |
| 147 | 148 | p.resource.SendConfigure(int32(c.Rect.X), int32(c.Rect.Y), int32(c.Rect.W), int32(c.Rect.H)) | |
| 148 | 149 | 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 | 154 | s.serial++ | |
| 150 | 155 | p.w.xdg.resource.SendConfigure(s.serial) | |
| 151 | 156 | 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 = true | 68 | 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.server | 149 | 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 { | |||
| 396 | 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 | 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 | 415 | for _, c := range s.castSessions { | |
| 402 | 416 | if c.src.kind == srcWindow && c.src.window == id { | |
| 403 | 417 | 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 = true | 417 | c.dirty = true | ||
M internal/adapters/wayland/screencast_test.go +60 -0
| @@ -493,6 +493,66 @@ func TestCastFlipDamagesWindowCastAfterCommit(t *testing.T) { | |||
| 493 | 493 | 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 | 556 | // The inventory is published once per change, not once per frame or flip, and | |
| 497 | 557 | // changes of one display loop turn share one publication. | |
| 498 | 558 | func TestCastInventoryPublishedOncePerChange(t *testing.T) { | |
| @@ -493,6 +493,66 @@ func TestCastFlipDamagesWindowCastAfterCommit(t *testing.T) { | |||||
| 493 | h.none() // two commits, one damage | 493 | 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, and | 556 | // 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) { | ||