feat(surface): xdg popups with grab and reposition
- commit
- b8141074d34f010405b2be134bd32e6bcc20f20e
- author
- brice · · 2026-10-09 03:37 UTC
- parent
- 4cefc9a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +758 -4
M globals.go +3 -2
| @@ -38,6 +38,7 @@ type globals struct { | |||
| 38 | 38 | fscale *fractionalscale.WpFractionalScaleManager | |
| 39 | 39 | ||
| 40 | 40 | layerVersion uint32 | |
| 41 | + | xdgVersion uint32 | |
| 41 | 42 | triedViewport bool | |
| 42 | 43 | triedFScale bool | |
| 43 | 44 | } | |
| @@ -101,7 +102,7 @@ func (c *Conn) ensureXdg() error { | |||
| 101 | 102 | return nil | |
| 102 | 103 | } | |
| 103 | 104 | q := c.q | |
| 104 | - | x, _, err := c.require(xdgshell.XdgWmBaseInterface, 6, 1, func(ctx *wl.Context) *xdgshell.XdgWmBase { | |
| 105 | + | x, v, err := c.require(xdgshell.XdgWmBaseInterface, 6, 1, func(ctx *wl.Context) *xdgshell.XdgWmBase { | |
| 105 | 106 | x := xdgshell.NewXdgWmBase(ctx) | |
| 106 | 107 | x.OnPing(func(serial uint32) { | |
| 107 | 108 | e := event{kind: evPing, serial: serial} | |
| @@ -109,7 +110,7 @@ func (c *Conn) ensureXdg() error { | |||
| 109 | 110 | }) | |
| 110 | 111 | return x | |
| 111 | 112 | }) | |
| 112 | - | c.g.xdg = x | |
| 113 | + | c.g.xdg, c.g.xdgVersion = x, v | |
| 113 | 114 | return err | |
| 114 | 115 | } | |
| 115 | 116 | ||
| @@ -38,6 +38,7 @@ type globals struct { | |||||
| 38 | fscale *fractionalscale.WpFractionalScaleManager | 38 | fscale *fractionalscale.WpFractionalScaleManager | ||
| 39 | 39 | ||||
| 40 | layerVersion uint32 | 40 | layerVersion uint32 | ||
| 41 | + | xdgVersion uint32 | |||
| 41 | triedViewport bool | 42 | triedViewport bool | ||
| 42 | triedFScale bool | 43 | triedFScale bool | ||
| 43 | } | 44 | } | ||
| @@ -101,7 +102,7 @@ func (c *Conn) ensureXdg() error { | |||||
| 101 | return nil | 102 | return nil | ||
| 102 | } | 103 | } | ||
| 103 | q := c.q | 104 | q := c.q | ||
| 104 | - | x, _, err := c.require(xdgshell.XdgWmBaseInterface, 6, 1, func(ctx *wl.Context) *xdgshell.XdgWmBase { | 105 | + | x, v, err := c.require(xdgshell.XdgWmBaseInterface, 6, 1, func(ctx *wl.Context) *xdgshell.XdgWmBase { |
| 105 | x := xdgshell.NewXdgWmBase(ctx) | 106 | x := xdgshell.NewXdgWmBase(ctx) | ||
| 106 | x.OnPing(func(serial uint32) { | 107 | x.OnPing(func(serial uint32) { | ||
| 107 | e := event{kind: evPing, serial: serial} | 108 | e := event{kind: evPing, serial: serial} | ||
| @@ -109,7 +110,7 @@ func (c *Conn) ensureXdg() error { | |||||
| 109 | }) | 110 | }) | ||
| 110 | return x | 111 | return x | ||
| 111 | }) | 112 | }) | ||
| 112 | - | c.g.xdg = x | 113 | + | c.g.xdg, c.g.xdgVersion = x, v |
| 113 | return err | 114 | return err | ||
| 114 | } | 115 | } | ||
| 115 | 116 | ||||
M handler.go +5 -0
| @@ -40,6 +40,10 @@ type Handler interface { | |||
| 40 | 40 | // layer surface closed). Call [Surface.Close]; nothing else happens | |
| 41 | 41 | // automatically. | |
| 42 | 42 | Closed(id SurfaceID) | |
| 43 | + | // PopupDone reports that the compositor dismissed a popup created with | |
| 44 | + | // [Conn.NewPopup]: an outside click, a denied grab, or a gone parent. Call | |
| 45 | + | // [Surface.Close] on it (and on its own child popups first). | |
| 46 | + | PopupDone(id SurfaceID) | |
| 43 | 47 | // FeedbackDone reports that a complete linux-dmabuf feedback round was | |
| 44 | 48 | // applied; [Surface.Feedback] holds the new main device and formats. It | |
| 45 | 49 | // is sent again whenever the compositor changes its preference. | |
| @@ -87,6 +91,7 @@ func (NopHandler) Configure(SurfaceID, int32, int32) {} | |||
| 87 | 91 | func (NopHandler) Scale(SurfaceID, float64) {} | |
| 88 | 92 | func (NopHandler) Frame(SurfaceID) {} | |
| 89 | 93 | func (NopHandler) Closed(SurfaceID) {} | |
| 94 | + | func (NopHandler) PopupDone(SurfaceID) {} | |
| 90 | 95 | func (NopHandler) FeedbackDone(SurfaceID) {} | |
| 91 | 96 | func (NopHandler) Locked() {} | |
| 92 | 97 | func (NopHandler) LockFinished() {} | |
| @@ -40,6 +40,10 @@ type Handler interface { | |||||
| 40 | // layer surface closed). Call [Surface.Close]; nothing else happens | 40 | // layer surface closed). Call [Surface.Close]; nothing else happens | ||
| 41 | // automatically. | 41 | // automatically. | ||
| 42 | Closed(id SurfaceID) | 42 | Closed(id SurfaceID) | ||
| 43 | + | // PopupDone reports that the compositor dismissed a popup created with | |||
| 44 | + | // [Conn.NewPopup]: an outside click, a denied grab, or a gone parent. Call | |||
| 45 | + | // [Surface.Close] on it (and on its own child popups first). | |||
| 46 | + | PopupDone(id SurfaceID) | |||
| 43 | // FeedbackDone reports that a complete linux-dmabuf feedback round was | 47 | // FeedbackDone reports that a complete linux-dmabuf feedback round was | ||
| 44 | // applied; [Surface.Feedback] holds the new main device and formats. It | 48 | // applied; [Surface.Feedback] holds the new main device and formats. It | ||
| 45 | // is sent again whenever the compositor changes its preference. | 49 | // is sent again whenever the compositor changes its preference. | ||
| @@ -87,6 +91,7 @@ func (NopHandler) Configure(SurfaceID, int32, int32) {} | |||||
| 87 | func (NopHandler) Scale(SurfaceID, float64) {} | 91 | func (NopHandler) Scale(SurfaceID, float64) {} | ||
| 88 | func (NopHandler) Frame(SurfaceID) {} | 92 | func (NopHandler) Frame(SurfaceID) {} | ||
| 89 | func (NopHandler) Closed(SurfaceID) {} | 93 | func (NopHandler) Closed(SurfaceID) {} | ||
| 94 | + | func (NopHandler) PopupDone(SurfaceID) {} | |||
| 90 | func (NopHandler) FeedbackDone(SurfaceID) {} | 95 | func (NopHandler) FeedbackDone(SurfaceID) {} | ||
| 91 | func (NopHandler) Locked() {} | 96 | func (NopHandler) Locked() {} | ||
| 92 | func (NopHandler) LockFinished() {} | 97 | func (NopHandler) LockFinished() {} | ||
M handler_mock_test.go +40 -0
| @@ -568,6 +568,46 @@ func (_c *MockHandler_Pointer_Call) RunAndReturn(run func(ev *PointerEvent)) *Mo | |||
| 568 | 568 | return _c | |
| 569 | 569 | } | |
| 570 | 570 | ||
| 571 | + | // PopupDone provides a mock function for the type MockHandler | |
| 572 | + | func (_mock *MockHandler) PopupDone(id SurfaceID) { | |
| 573 | + | _mock.Called(id) | |
| 574 | + | return | |
| 575 | + | } | |
| 576 | + | ||
| 577 | + | // MockHandler_PopupDone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PopupDone' | |
| 578 | + | type MockHandler_PopupDone_Call struct { | |
| 579 | + | *mock.Call | |
| 580 | + | } | |
| 581 | + | ||
| 582 | + | // PopupDone is a helper method to define mock.On call | |
| 583 | + | // - id SurfaceID | |
| 584 | + | func (_e *MockHandler_Expecter) PopupDone(id any) *MockHandler_PopupDone_Call { | |
| 585 | + | return &MockHandler_PopupDone_Call{Call: _e.mock.On("PopupDone", id)} | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | func (_c *MockHandler_PopupDone_Call) Run(run func(id SurfaceID)) *MockHandler_PopupDone_Call { | |
| 589 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 590 | + | var arg0 SurfaceID | |
| 591 | + | if args[0] != nil { | |
| 592 | + | arg0 = args[0].(SurfaceID) | |
| 593 | + | } | |
| 594 | + | run( | |
| 595 | + | arg0, | |
| 596 | + | ) | |
| 597 | + | }) | |
| 598 | + | return _c | |
| 599 | + | } | |
| 600 | + | ||
| 601 | + | func (_c *MockHandler_PopupDone_Call) Return() *MockHandler_PopupDone_Call { | |
| 602 | + | _c.Call.Return() | |
| 603 | + | return _c | |
| 604 | + | } | |
| 605 | + | ||
| 606 | + | func (_c *MockHandler_PopupDone_Call) RunAndReturn(run func(id SurfaceID)) *MockHandler_PopupDone_Call { | |
| 607 | + | _c.Run(run) | |
| 608 | + | return _c | |
| 609 | + | } | |
| 610 | + | ||
| 571 | 611 | // Scale provides a mock function for the type MockHandler | |
| 572 | 612 | func (_mock *MockHandler) Scale(id SurfaceID, scale float64) { | |
| 573 | 613 | _mock.Called(id, scale) | |
| @@ -568,6 +568,46 @@ func (_c *MockHandler_Pointer_Call) RunAndReturn(run func(ev *PointerEvent)) *Mo | |||||
| 568 | return _c | 568 | return _c | ||
| 569 | } | 569 | } | ||
| 570 | 570 | ||||
| 571 | + | // PopupDone provides a mock function for the type MockHandler | |||
| 572 | + | func (_mock *MockHandler) PopupDone(id SurfaceID) { | |||
| 573 | + | _mock.Called(id) | |||
| 574 | + | return | |||
| 575 | + | } | |||
| 576 | + | ||||
| 577 | + | // MockHandler_PopupDone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PopupDone' | |||
| 578 | + | type MockHandler_PopupDone_Call struct { | |||
| 579 | + | *mock.Call | |||
| 580 | + | } | |||
| 581 | + | ||||
| 582 | + | // PopupDone is a helper method to define mock.On call | |||
| 583 | + | // - id SurfaceID | |||
| 584 | + | func (_e *MockHandler_Expecter) PopupDone(id any) *MockHandler_PopupDone_Call { | |||
| 585 | + | return &MockHandler_PopupDone_Call{Call: _e.mock.On("PopupDone", id)} | |||
| 586 | + | } | |||
| 587 | + | ||||
| 588 | + | func (_c *MockHandler_PopupDone_Call) Run(run func(id SurfaceID)) *MockHandler_PopupDone_Call { | |||
| 589 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 590 | + | var arg0 SurfaceID | |||
| 591 | + | if args[0] != nil { | |||
| 592 | + | arg0 = args[0].(SurfaceID) | |||
| 593 | + | } | |||
| 594 | + | run( | |||
| 595 | + | arg0, | |||
| 596 | + | ) | |||
| 597 | + | }) | |||
| 598 | + | return _c | |||
| 599 | + | } | |||
| 600 | + | ||||
| 601 | + | func (_c *MockHandler_PopupDone_Call) Return() *MockHandler_PopupDone_Call { | |||
| 602 | + | _c.Call.Return() | |||
| 603 | + | return _c | |||
| 604 | + | } | |||
| 605 | + | ||||
| 606 | + | func (_c *MockHandler_PopupDone_Call) RunAndReturn(run func(id SurfaceID)) *MockHandler_PopupDone_Call { | |||
| 607 | + | _c.Run(run) | |||
| 608 | + | return _c | |||
| 609 | + | } | |||
| 610 | + | ||||
| 571 | // Scale provides a mock function for the type MockHandler | 611 | // Scale provides a mock function for the type MockHandler | ||
| 572 | func (_mock *MockHandler) Scale(id SurfaceID, scale float64) { | 612 | func (_mock *MockHandler) Scale(id SurfaceID, scale float64) { | ||
| 573 | _mock.Called(id, scale) | 613 | _mock.Called(id, scale) | ||
M headless_surface_test.go +94 -1
| @@ -228,7 +228,7 @@ func TestHeadlessReconfigureWhilePending(t *testing.T) { | |||
| 228 | 228 | var frames int | |
| 229 | 229 | hd := neferclientmocks.NewMockHandler(t) | |
| 230 | 230 | hd.EXPECT().Configure(s.ID(), mock.Anything, mock.Anything).Run(func(neferclient.SurfaceID, int32, int32) { configured = true }).Return().Maybe() | |
| 231 | - | hd.EXPECT().FeedbackDone(s.ID()).Run(func(neferclient.SurfaceID) { feedback = true }).Return().Maybe() | |
| 231 | + | hd.EXPECT().FeedbackDone(mock.Anything).Run(func(id neferclient.SurfaceID) { feedback = feedback || id == s.ID() }).Return().Maybe() | |
| 232 | 232 | hd.EXPECT().Scale(s.ID(), mock.Anything).Return().Maybe() | |
| 233 | 233 | hd.EXPECT().Frame(s.ID()).Run(func(neferclient.SurfaceID) { frames++ }).Return().Maybe() | |
| 234 | 234 | dispatchUntil(t, c, hd, func() bool { return configured && feedback }) | |
| @@ -270,3 +270,96 @@ func TestHeadlessReconfigureWhilePending(t *testing.T) { | |||
| 270 | 270 | require.NoError(t, c.Roundtrip()) | |
| 271 | 271 | require.NoError(t, c.Dispatch(hd)) | |
| 272 | 272 | } | |
| 273 | + | ||
| 274 | + | // headlessLayer opens a layer surface, waits until it is configured and | |
| 275 | + | // presents one buffer: compositors only place popups of mapped parents. | |
| 276 | + | func headlessLayer(t *testing.T, c *neferclient.Conn, hd *neferclientmocks.MockHandler, configured map[neferclient.SurfaceID][2]int32, frames *int) *neferclient.Surface { | |
| 277 | + | t.Helper() | |
| 278 | + | s, err := c.NewLayerSurface(neferclient.LayerConfig{ | |
| 279 | + | Level: neferclient.LayerTop, Anchors: neferclient.AnchorTop | neferclient.AnchorLeft, Width: 200, Height: 50, | |
| 280 | + | }) | |
| 281 | + | require.NoError(t, err) | |
| 282 | + | var feedback bool | |
| 283 | + | hd.EXPECT().FeedbackDone(mock.Anything).Run(func(id neferclient.SurfaceID) { feedback = feedback || id == s.ID() }).Return().Maybe() | |
| 284 | + | dispatchUntil(t, c, hd, func() bool { _, ok := configured[s.ID()]; return ok && feedback }) | |
| 285 | + | pw, ph, err := s.PhysicalSize() | |
| 286 | + | require.NoError(t, err) | |
| 287 | + | dmabuf := newUdmabuf(t, int(pw), int(ph)) | |
| 288 | + | acquire, signal := newSyncobjFD(t) | |
| 289 | + | release, _ := newSyncobjFD(t) | |
| 290 | + | require.NoError(t, s.ImportBuffer(1, &neferclient.Buffer{Width: pw, Height: ph, FourCC: 0x34325258, PlaneCount: 1, | |
| 291 | + | Planes: [4]neferclient.Plane{{FD: dmabuf, Stride: uint32(pw) * 4}}})) | |
| 292 | + | require.NoError(t, s.ImportTimeline(1, acquire)) | |
| 293 | + | require.NoError(t, s.ImportTimeline(2, release)) | |
| 294 | + | signal(1) | |
| 295 | + | require.NoError(t, s.Present(&neferclient.Present{Buffer: 1, AcquireTimeline: 1, ReleaseTimeline: 2, | |
| 296 | + | AcquirePoint: 1, ReleasePoint: 1, Opaque: true})) | |
| 297 | + | dispatchUntil(t, c, hd, func() bool { return *frames == 1 }) | |
| 298 | + | return s | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | // A popup on a layer surface is configured with the requested size, can be | |
| 302 | + | // repositioned and is closed before its parent. | |
| 303 | + | func TestHeadlessPopupConfigure(t *testing.T) { | |
| 304 | + | c := connectHeadless(t) | |
| 305 | + | configured := map[neferclient.SurfaceID][2]int32{} | |
| 306 | + | var frames int | |
| 307 | + | hd := neferclientmocks.NewMockHandler(t) | |
| 308 | + | hd.EXPECT().Configure(mock.Anything, mock.Anything, mock.Anything).Run(func(id neferclient.SurfaceID, w, h int32) { | |
| 309 | + | configured[id] = [2]int32{w, h} | |
| 310 | + | }).Return().Maybe() | |
| 311 | + | hd.EXPECT().Frame(mock.Anything).Run(func(neferclient.SurfaceID) { frames++ }).Return().Maybe() | |
| 312 | + | hd.EXPECT().Scale(mock.Anything, mock.Anything).Return().Maybe() | |
| 313 | + | parent := headlessLayer(t, c, hd, configured, &frames) | |
| 314 | + | ||
| 315 | + | pl := neferclient.PopupPlacement{ | |
| 316 | + | Anchor: neferclient.Rect{X: 10, Y: 0, Width: 20, Height: 50}, | |
| 317 | + | Edge: neferclient.EdgeBottom, Gravity: neferclient.EdgeBottomRight, | |
| 318 | + | Adjust: neferclient.AdjustSlideX | neferclient.AdjustFlipY, Width: 120, Height: 80, | |
| 319 | + | } | |
| 320 | + | p, err := c.NewPopup(neferclient.PopupConfig{Parent: parent, Place: pl}) | |
| 321 | + | require.NoError(t, err) | |
| 322 | + | dispatchUntil(t, c, hd, func() bool { _, ok := configured[p.ID()]; return ok }) | |
| 323 | + | require.Equal(t, [2]int32{120, 80}, configured[p.ID()]) | |
| 324 | + | ||
| 325 | + | pl.Width, pl.Height = 90, 60 | |
| 326 | + | delete(configured, p.ID()) | |
| 327 | + | require.NoError(t, p.Reposition(pl)) | |
| 328 | + | dispatchUntil(t, c, hd, func() bool { _, ok := configured[p.ID()]; return ok }) | |
| 329 | + | require.Equal(t, [2]int32{90, 60}, configured[p.ID()]) | |
| 330 | + | ||
| 331 | + | require.Error(t, parent.Close(), "parent with an open popup") | |
| 332 | + | require.NoError(t, p.Close()) | |
| 333 | + | require.NoError(t, parent.Close()) | |
| 334 | + | require.NoError(t, c.Roundtrip()) | |
| 335 | + | require.NoError(t, c.Dispatch(hd)) | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | // A grab with a serial that is not the latest button press is dismissed. | |
| 339 | + | func TestHeadlessPopupStaleGrabIsDismissed(t *testing.T) { | |
| 340 | + | c := connectHeadless(t) | |
| 341 | + | configured := map[neferclient.SurfaceID][2]int32{} | |
| 342 | + | var frames int | |
| 343 | + | var done []neferclient.SurfaceID | |
| 344 | + | hd := neferclientmocks.NewMockHandler(t) | |
| 345 | + | hd.EXPECT().Configure(mock.Anything, mock.Anything, mock.Anything).Run(func(id neferclient.SurfaceID, w, h int32) { | |
| 346 | + | configured[id] = [2]int32{w, h} | |
| 347 | + | }).Return().Maybe() | |
| 348 | + | hd.EXPECT().Frame(mock.Anything).Run(func(neferclient.SurfaceID) { frames++ }).Return().Maybe() | |
| 349 | + | hd.EXPECT().Scale(mock.Anything, mock.Anything).Return().Maybe() | |
| 350 | + | hd.EXPECT().PopupDone(mock.Anything).Run(func(id neferclient.SurfaceID) { done = append(done, id) }).Return().Maybe() | |
| 351 | + | hd.EXPECT().Pointer(mock.Anything).Return().Maybe() | |
| 352 | + | hd.EXPECT().KeyboardFocus(mock.Anything, mock.Anything).Return().Maybe() | |
| 353 | + | parent := headlessLayer(t, c, hd, configured, &frames) | |
| 354 | + | ||
| 355 | + | p, err := c.NewPopup(neferclient.PopupConfig{Parent: parent, Grab: true, Serial: 1, Place: neferclient.PopupPlacement{ | |
| 356 | + | Anchor: neferclient.Rect{Width: 20, Height: 50}, Edge: neferclient.EdgeBottom, Gravity: neferclient.EdgeBottomRight, | |
| 357 | + | Width: 100, Height: 40, | |
| 358 | + | }}) | |
| 359 | + | require.NoError(t, err) | |
| 360 | + | dispatchUntil(t, c, hd, func() bool { return len(done) > 0 }) | |
| 361 | + | require.Equal(t, []neferclient.SurfaceID{p.ID()}, done) | |
| 362 | + | require.NoError(t, p.Close()) | |
| 363 | + | require.NoError(t, parent.Close()) | |
| 364 | + | require.NoError(t, c.Roundtrip()) | |
| 365 | + | } | |
| @@ -228,7 +228,7 @@ func TestHeadlessReconfigureWhilePending(t *testing.T) { | |||||
| 228 | var frames int | 228 | var frames int | ||
| 229 | hd := neferclientmocks.NewMockHandler(t) | 229 | hd := neferclientmocks.NewMockHandler(t) | ||
| 230 | hd.EXPECT().Configure(s.ID(), mock.Anything, mock.Anything).Run(func(neferclient.SurfaceID, int32, int32) { configured = true }).Return().Maybe() | 230 | hd.EXPECT().Configure(s.ID(), mock.Anything, mock.Anything).Run(func(neferclient.SurfaceID, int32, int32) { configured = true }).Return().Maybe() | ||
| 231 | - | hd.EXPECT().FeedbackDone(s.ID()).Run(func(neferclient.SurfaceID) { feedback = true }).Return().Maybe() | 231 | + | hd.EXPECT().FeedbackDone(mock.Anything).Run(func(id neferclient.SurfaceID) { feedback = feedback || id == s.ID() }).Return().Maybe() |
| 232 | hd.EXPECT().Scale(s.ID(), mock.Anything).Return().Maybe() | 232 | hd.EXPECT().Scale(s.ID(), mock.Anything).Return().Maybe() | ||
| 233 | hd.EXPECT().Frame(s.ID()).Run(func(neferclient.SurfaceID) { frames++ }).Return().Maybe() | 233 | hd.EXPECT().Frame(s.ID()).Run(func(neferclient.SurfaceID) { frames++ }).Return().Maybe() | ||
| 234 | dispatchUntil(t, c, hd, func() bool { return configured && feedback }) | 234 | dispatchUntil(t, c, hd, func() bool { return configured && feedback }) | ||
| @@ -270,3 +270,96 @@ func TestHeadlessReconfigureWhilePending(t *testing.T) { | |||||
| 270 | require.NoError(t, c.Roundtrip()) | 270 | require.NoError(t, c.Roundtrip()) | ||
| 271 | require.NoError(t, c.Dispatch(hd)) | 271 | require.NoError(t, c.Dispatch(hd)) | ||
| 272 | } | 272 | } | ||
| 273 | + | ||||
| 274 | + | // headlessLayer opens a layer surface, waits until it is configured and | |||
| 275 | + | // presents one buffer: compositors only place popups of mapped parents. | |||
| 276 | + | func headlessLayer(t *testing.T, c *neferclient.Conn, hd *neferclientmocks.MockHandler, configured map[neferclient.SurfaceID][2]int32, frames *int) *neferclient.Surface { | |||
| 277 | + | t.Helper() | |||
| 278 | + | s, err := c.NewLayerSurface(neferclient.LayerConfig{ | |||
| 279 | + | Level: neferclient.LayerTop, Anchors: neferclient.AnchorTop | neferclient.AnchorLeft, Width: 200, Height: 50, | |||
| 280 | + | }) | |||
| 281 | + | require.NoError(t, err) | |||
| 282 | + | var feedback bool | |||
| 283 | + | hd.EXPECT().FeedbackDone(mock.Anything).Run(func(id neferclient.SurfaceID) { feedback = feedback || id == s.ID() }).Return().Maybe() | |||
| 284 | + | dispatchUntil(t, c, hd, func() bool { _, ok := configured[s.ID()]; return ok && feedback }) | |||
| 285 | + | pw, ph, err := s.PhysicalSize() | |||
| 286 | + | require.NoError(t, err) | |||
| 287 | + | dmabuf := newUdmabuf(t, int(pw), int(ph)) | |||
| 288 | + | acquire, signal := newSyncobjFD(t) | |||
| 289 | + | release, _ := newSyncobjFD(t) | |||
| 290 | + | require.NoError(t, s.ImportBuffer(1, &neferclient.Buffer{Width: pw, Height: ph, FourCC: 0x34325258, PlaneCount: 1, | |||
| 291 | + | Planes: [4]neferclient.Plane{{FD: dmabuf, Stride: uint32(pw) * 4}}})) | |||
| 292 | + | require.NoError(t, s.ImportTimeline(1, acquire)) | |||
| 293 | + | require.NoError(t, s.ImportTimeline(2, release)) | |||
| 294 | + | signal(1) | |||
| 295 | + | require.NoError(t, s.Present(&neferclient.Present{Buffer: 1, AcquireTimeline: 1, ReleaseTimeline: 2, | |||
| 296 | + | AcquirePoint: 1, ReleasePoint: 1, Opaque: true})) | |||
| 297 | + | dispatchUntil(t, c, hd, func() bool { return *frames == 1 }) | |||
| 298 | + | return s | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | // A popup on a layer surface is configured with the requested size, can be | |||
| 302 | + | // repositioned and is closed before its parent. | |||
| 303 | + | func TestHeadlessPopupConfigure(t *testing.T) { | |||
| 304 | + | c := connectHeadless(t) | |||
| 305 | + | configured := map[neferclient.SurfaceID][2]int32{} | |||
| 306 | + | var frames int | |||
| 307 | + | hd := neferclientmocks.NewMockHandler(t) | |||
| 308 | + | hd.EXPECT().Configure(mock.Anything, mock.Anything, mock.Anything).Run(func(id neferclient.SurfaceID, w, h int32) { | |||
| 309 | + | configured[id] = [2]int32{w, h} | |||
| 310 | + | }).Return().Maybe() | |||
| 311 | + | hd.EXPECT().Frame(mock.Anything).Run(func(neferclient.SurfaceID) { frames++ }).Return().Maybe() | |||
| 312 | + | hd.EXPECT().Scale(mock.Anything, mock.Anything).Return().Maybe() | |||
| 313 | + | parent := headlessLayer(t, c, hd, configured, &frames) | |||
| 314 | + | ||||
| 315 | + | pl := neferclient.PopupPlacement{ | |||
| 316 | + | Anchor: neferclient.Rect{X: 10, Y: 0, Width: 20, Height: 50}, | |||
| 317 | + | Edge: neferclient.EdgeBottom, Gravity: neferclient.EdgeBottomRight, | |||
| 318 | + | Adjust: neferclient.AdjustSlideX | neferclient.AdjustFlipY, Width: 120, Height: 80, | |||
| 319 | + | } | |||
| 320 | + | p, err := c.NewPopup(neferclient.PopupConfig{Parent: parent, Place: pl}) | |||
| 321 | + | require.NoError(t, err) | |||
| 322 | + | dispatchUntil(t, c, hd, func() bool { _, ok := configured[p.ID()]; return ok }) | |||
| 323 | + | require.Equal(t, [2]int32{120, 80}, configured[p.ID()]) | |||
| 324 | + | ||||
| 325 | + | pl.Width, pl.Height = 90, 60 | |||
| 326 | + | delete(configured, p.ID()) | |||
| 327 | + | require.NoError(t, p.Reposition(pl)) | |||
| 328 | + | dispatchUntil(t, c, hd, func() bool { _, ok := configured[p.ID()]; return ok }) | |||
| 329 | + | require.Equal(t, [2]int32{90, 60}, configured[p.ID()]) | |||
| 330 | + | ||||
| 331 | + | require.Error(t, parent.Close(), "parent with an open popup") | |||
| 332 | + | require.NoError(t, p.Close()) | |||
| 333 | + | require.NoError(t, parent.Close()) | |||
| 334 | + | require.NoError(t, c.Roundtrip()) | |||
| 335 | + | require.NoError(t, c.Dispatch(hd)) | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | // A grab with a serial that is not the latest button press is dismissed. | |||
| 339 | + | func TestHeadlessPopupStaleGrabIsDismissed(t *testing.T) { | |||
| 340 | + | c := connectHeadless(t) | |||
| 341 | + | configured := map[neferclient.SurfaceID][2]int32{} | |||
| 342 | + | var frames int | |||
| 343 | + | var done []neferclient.SurfaceID | |||
| 344 | + | hd := neferclientmocks.NewMockHandler(t) | |||
| 345 | + | hd.EXPECT().Configure(mock.Anything, mock.Anything, mock.Anything).Run(func(id neferclient.SurfaceID, w, h int32) { | |||
| 346 | + | configured[id] = [2]int32{w, h} | |||
| 347 | + | }).Return().Maybe() | |||
| 348 | + | hd.EXPECT().Frame(mock.Anything).Run(func(neferclient.SurfaceID) { frames++ }).Return().Maybe() | |||
| 349 | + | hd.EXPECT().Scale(mock.Anything, mock.Anything).Return().Maybe() | |||
| 350 | + | hd.EXPECT().PopupDone(mock.Anything).Run(func(id neferclient.SurfaceID) { done = append(done, id) }).Return().Maybe() | |||
| 351 | + | hd.EXPECT().Pointer(mock.Anything).Return().Maybe() | |||
| 352 | + | hd.EXPECT().KeyboardFocus(mock.Anything, mock.Anything).Return().Maybe() | |||
| 353 | + | parent := headlessLayer(t, c, hd, configured, &frames) | |||
| 354 | + | ||||
| 355 | + | p, err := c.NewPopup(neferclient.PopupConfig{Parent: parent, Grab: true, Serial: 1, Place: neferclient.PopupPlacement{ | |||
| 356 | + | Anchor: neferclient.Rect{Width: 20, Height: 50}, Edge: neferclient.EdgeBottom, Gravity: neferclient.EdgeBottomRight, | |||
| 357 | + | Width: 100, Height: 40, | |||
| 358 | + | }}) | |||
| 359 | + | require.NoError(t, err) | |||
| 360 | + | dispatchUntil(t, c, hd, func() bool { return len(done) > 0 }) | |||
| 361 | + | require.Equal(t, []neferclient.SurfaceID{p.ID()}, done) | |||
| 362 | + | require.NoError(t, p.Close()) | |||
| 363 | + | require.NoError(t, parent.Close()) | |||
| 364 | + | require.NoError(t, c.Roundtrip()) | |||
| 365 | + | } | |||
M mocks/handler_mock.go +40 -0
| @@ -569,6 +569,46 @@ func (_c *MockHandler_Pointer_Call) RunAndReturn(run func(ev *neferclient.Pointe | |||
| 569 | 569 | return _c | |
| 570 | 570 | } | |
| 571 | 571 | ||
| 572 | + | // PopupDone provides a mock function for the type MockHandler | |
| 573 | + | func (_mock *MockHandler) PopupDone(id neferclient.SurfaceID) { | |
| 574 | + | _mock.Called(id) | |
| 575 | + | return | |
| 576 | + | } | |
| 577 | + | ||
| 578 | + | // MockHandler_PopupDone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PopupDone' | |
| 579 | + | type MockHandler_PopupDone_Call struct { | |
| 580 | + | *mock.Call | |
| 581 | + | } | |
| 582 | + | ||
| 583 | + | // PopupDone is a helper method to define mock.On call | |
| 584 | + | // - id neferclient.SurfaceID | |
| 585 | + | func (_e *MockHandler_Expecter) PopupDone(id any) *MockHandler_PopupDone_Call { | |
| 586 | + | return &MockHandler_PopupDone_Call{Call: _e.mock.On("PopupDone", id)} | |
| 587 | + | } | |
| 588 | + | ||
| 589 | + | func (_c *MockHandler_PopupDone_Call) Run(run func(id neferclient.SurfaceID)) *MockHandler_PopupDone_Call { | |
| 590 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 591 | + | var arg0 neferclient.SurfaceID | |
| 592 | + | if args[0] != nil { | |
| 593 | + | arg0 = args[0].(neferclient.SurfaceID) | |
| 594 | + | } | |
| 595 | + | run( | |
| 596 | + | arg0, | |
| 597 | + | ) | |
| 598 | + | }) | |
| 599 | + | return _c | |
| 600 | + | } | |
| 601 | + | ||
| 602 | + | func (_c *MockHandler_PopupDone_Call) Return() *MockHandler_PopupDone_Call { | |
| 603 | + | _c.Call.Return() | |
| 604 | + | return _c | |
| 605 | + | } | |
| 606 | + | ||
| 607 | + | func (_c *MockHandler_PopupDone_Call) RunAndReturn(run func(id neferclient.SurfaceID)) *MockHandler_PopupDone_Call { | |
| 608 | + | _c.Run(run) | |
| 609 | + | return _c | |
| 610 | + | } | |
| 611 | + | ||
| 572 | 612 | // Scale provides a mock function for the type MockHandler | |
| 573 | 613 | func (_mock *MockHandler) Scale(id neferclient.SurfaceID, scale float64) { | |
| 574 | 614 | _mock.Called(id, scale) | |
| @@ -569,6 +569,46 @@ func (_c *MockHandler_Pointer_Call) RunAndReturn(run func(ev *neferclient.Pointe | |||||
| 569 | return _c | 569 | return _c | ||
| 570 | } | 570 | } | ||
| 571 | 571 | ||||
| 572 | + | // PopupDone provides a mock function for the type MockHandler | |||
| 573 | + | func (_mock *MockHandler) PopupDone(id neferclient.SurfaceID) { | |||
| 574 | + | _mock.Called(id) | |||
| 575 | + | return | |||
| 576 | + | } | |||
| 577 | + | ||||
| 578 | + | // MockHandler_PopupDone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PopupDone' | |||
| 579 | + | type MockHandler_PopupDone_Call struct { | |||
| 580 | + | *mock.Call | |||
| 581 | + | } | |||
| 582 | + | ||||
| 583 | + | // PopupDone is a helper method to define mock.On call | |||
| 584 | + | // - id neferclient.SurfaceID | |||
| 585 | + | func (_e *MockHandler_Expecter) PopupDone(id any) *MockHandler_PopupDone_Call { | |||
| 586 | + | return &MockHandler_PopupDone_Call{Call: _e.mock.On("PopupDone", id)} | |||
| 587 | + | } | |||
| 588 | + | ||||
| 589 | + | func (_c *MockHandler_PopupDone_Call) Run(run func(id neferclient.SurfaceID)) *MockHandler_PopupDone_Call { | |||
| 590 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 591 | + | var arg0 neferclient.SurfaceID | |||
| 592 | + | if args[0] != nil { | |||
| 593 | + | arg0 = args[0].(neferclient.SurfaceID) | |||
| 594 | + | } | |||
| 595 | + | run( | |||
| 596 | + | arg0, | |||
| 597 | + | ) | |||
| 598 | + | }) | |||
| 599 | + | return _c | |||
| 600 | + | } | |||
| 601 | + | ||||
| 602 | + | func (_c *MockHandler_PopupDone_Call) Return() *MockHandler_PopupDone_Call { | |||
| 603 | + | _c.Call.Return() | |||
| 604 | + | return _c | |||
| 605 | + | } | |||
| 606 | + | ||||
| 607 | + | func (_c *MockHandler_PopupDone_Call) RunAndReturn(run func(id neferclient.SurfaceID)) *MockHandler_PopupDone_Call { | |||
| 608 | + | _c.Run(run) | |||
| 609 | + | return _c | |||
| 610 | + | } | |||
| 611 | + | ||||
| 572 | // Scale provides a mock function for the type MockHandler | 612 | // Scale provides a mock function for the type MockHandler | ||
| 573 | func (_mock *MockHandler) Scale(id neferclient.SurfaceID, scale float64) { | 613 | func (_mock *MockHandler) Scale(id neferclient.SurfaceID, scale float64) { | ||
| 574 | _mock.Called(id, scale) | 614 | _mock.Called(id, scale) | ||
A popup.go +231 -0
| @@ -0,0 +1,231 @@ | |||
| 1 | + | package neferclient | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "fmt" | |
| 6 | + | "math" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/go-wayland-bindings/client/xdgshell" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // PopupEdge names an edge or corner, used both as the anchor on the parent's | |
| 12 | + | // anchor rectangle and as the gravity of the popup. The values equal the | |
| 13 | + | // xdg_positioner anchor and gravity enums. | |
| 14 | + | type PopupEdge uint8 | |
| 15 | + | ||
| 16 | + | const ( | |
| 17 | + | EdgeNone PopupEdge = iota | |
| 18 | + | EdgeTop | |
| 19 | + | EdgeBottom | |
| 20 | + | EdgeLeft | |
| 21 | + | EdgeRight | |
| 22 | + | EdgeTopLeft | |
| 23 | + | EdgeBottomLeft | |
| 24 | + | EdgeTopRight | |
| 25 | + | EdgeBottomRight | |
| 26 | + | ) | |
| 27 | + | ||
| 28 | + | // PopupAdjust is a bit set telling the compositor how to move a popup that | |
| 29 | + | // would not fit on the output. The bits equal xdg_positioner | |
| 30 | + | // constraint_adjustment. | |
| 31 | + | type PopupAdjust uint8 | |
| 32 | + | ||
| 33 | + | const ( | |
| 34 | + | AdjustSlideX PopupAdjust = 1 << iota | |
| 35 | + | AdjustSlideY | |
| 36 | + | AdjustFlipX | |
| 37 | + | AdjustFlipY | |
| 38 | + | AdjustResizeX | |
| 39 | + | AdjustResizeY | |
| 40 | + | ) | |
| 41 | + | ||
| 42 | + | const adjustAll = AdjustSlideX | AdjustSlideY | AdjustFlipX | AdjustFlipY | AdjustResizeX | AdjustResizeY | |
| 43 | + | ||
| 44 | + | // PopupPlacement tells the compositor where a popup goes relative to its | |
| 45 | + | // parent. Every length is in logical pixels. | |
| 46 | + | type PopupPlacement struct { | |
| 47 | + | Anchor Rect // on the parent surface; Width and Height must be > 0 | |
| 48 | + | Edge, Gravity PopupEdge | |
| 49 | + | Adjust PopupAdjust | |
| 50 | + | Offset [2]int32 | |
| 51 | + | Width, Height int32 // popup size; must be > 0 | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // PopupConfig requests an xdg_popup. | |
| 55 | + | type PopupConfig struct { | |
| 56 | + | // Parent is a layer surface, a toplevel or another popup. It must be open. | |
| 57 | + | Parent *Surface | |
| 58 | + | Place PopupPlacement | |
| 59 | + | // Grab takes an explicit grab: the popup gets the keyboard and is | |
| 60 | + | // dismissed by a click outside it. It needs a bound seat. | |
| 61 | + | Grab bool | |
| 62 | + | // Serial is the [PointerEvent.Serial] of the press that opened the popup. | |
| 63 | + | // It is required (non-zero) with Grab. | |
| 64 | + | Serial uint32 | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func validatePlacement(p *PopupPlacement) error { | |
| 68 | + | switch { | |
| 69 | + | case p.Anchor.Width <= 0 || p.Anchor.Height <= 0: | |
| 70 | + | return fmt.Errorf("neferclient: popup anchor rectangle %dx%d must be positive", p.Anchor.Width, p.Anchor.Height) | |
| 71 | + | case p.Width <= 0 || p.Height <= 0: | |
| 72 | + | return fmt.Errorf("neferclient: invalid popup size %dx%d", p.Width, p.Height) | |
| 73 | + | case p.Edge > EdgeBottomRight || p.Gravity > EdgeBottomRight: | |
| 74 | + | return fmt.Errorf("neferclient: invalid popup edge %d or gravity %d", p.Edge, p.Gravity) | |
| 75 | + | case p.Adjust&^adjustAll != 0: | |
| 76 | + | return fmt.Errorf("neferclient: invalid popup constraint adjustment %#x", uint8(p.Adjust)) | |
| 77 | + | case int64(p.Anchor.X)+int64(p.Anchor.Width) > math.MaxInt32 || int64(p.Anchor.Y)+int64(p.Anchor.Height) > math.MaxInt32: | |
| 78 | + | return errors.New("neferclient: popup anchor rectangle overflows") | |
| 79 | + | } | |
| 80 | + | return nil | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // newPositioner builds an xdg_positioner from p. The caller destroys it. | |
| 84 | + | func (c *Conn) newPositioner(p *PopupPlacement) (*xdgshell.XdgPositioner, error) { | |
| 85 | + | pos, err := c.g.xdg.CreatePositioner() | |
| 86 | + | if err != nil { | |
| 87 | + | return nil, err | |
| 88 | + | } | |
| 89 | + | for _, e := range []error{ | |
| 90 | + | pos.SetSize(p.Width, p.Height), | |
| 91 | + | pos.SetAnchorRect(p.Anchor.X, p.Anchor.Y, p.Anchor.Width, p.Anchor.Height), | |
| 92 | + | pos.SetAnchor(uint32(p.Edge)), | |
| 93 | + | pos.SetGravity(uint32(p.Gravity)), | |
| 94 | + | pos.SetConstraintAdjustment(uint32(p.Adjust)), | |
| 95 | + | pos.SetOffset(p.Offset[0], p.Offset[1]), | |
| 96 | + | } { | |
| 97 | + | if e != nil { | |
| 98 | + | _ = pos.Destroy() | |
| 99 | + | return nil, e | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | return pos, nil | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // NewPopup creates an xdg_popup on a layer surface, a toplevel or another | |
| 106 | + | // popup. It becomes presentable after the first Handler.Configure, which | |
| 107 | + | // reports the size the compositor gave it. Handle [Handler.PopupDone]: the | |
| 108 | + | // compositor dismisses the popup on an outside click or when the grab is | |
| 109 | + | // denied, and the popup must then be closed. | |
| 110 | + | // | |
| 111 | + | // Close popups before their parent: Close of a surface with open child popups | |
| 112 | + | // fails. | |
| 113 | + | func (c *Conn) NewPopup(cfg PopupConfig) (*Surface, error) { | |
| 114 | + | if c.closed { | |
| 115 | + | return nil, ErrClosed | |
| 116 | + | } | |
| 117 | + | parent := cfg.Parent | |
| 118 | + | switch { | |
| 119 | + | case parent == nil || parent.c != c: | |
| 120 | + | return nil, errors.New("neferclient: popup needs a parent surface of this connection") | |
| 121 | + | case parent.closed: | |
| 122 | + | return nil, fmt.Errorf("neferclient: popup parent: %w", ErrClosed) | |
| 123 | + | case parent.role != roleLayer && parent.role != roleToplevel && parent.role != rolePopup: | |
| 124 | + | return nil, errors.New("neferclient: popup parent must be a layer surface, a toplevel or a popup") | |
| 125 | + | } | |
| 126 | + | if err := validatePlacement(&cfg.Place); err != nil { | |
| 127 | + | return nil, err | |
| 128 | + | } | |
| 129 | + | if cfg.Grab { | |
| 130 | + | if cfg.Serial == 0 { | |
| 131 | + | return nil, errors.New("neferclient: popup grab needs the serial of the triggering input event") | |
| 132 | + | } | |
| 133 | + | c.ensureSeat() | |
| 134 | + | if c.seat == nil || c.seat.proxy == nil { | |
| 135 | + | return nil, &CapabilityError{Name: "wl_seat", Cause: errors.New("popup grab needs a seat")} | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | if err := c.ensureXdg(); err != nil { | |
| 139 | + | return nil, err | |
| 140 | + | } | |
| 141 | + | c.pause() | |
| 142 | + | defer c.resume() | |
| 143 | + | s, err := c.newSurface(rolePopup, cfg.Place.Width, cfg.Place.Height) | |
| 144 | + | if err != nil { | |
| 145 | + | return nil, err | |
| 146 | + | } | |
| 147 | + | ok := false | |
| 148 | + | defer func() { | |
| 149 | + | if !ok { | |
| 150 | + | s.destroy() | |
| 151 | + | } | |
| 152 | + | }() | |
| 153 | + | if s.xdg, err = c.g.xdg.GetXdgSurface(s.surf); err != nil { | |
| 154 | + | return nil, err | |
| 155 | + | } | |
| 156 | + | pos, err := c.newPositioner(&cfg.Place) | |
| 157 | + | if err != nil { | |
| 158 | + | return nil, err | |
| 159 | + | } | |
| 160 | + | var parentXdg *xdgshell.XdgSurface | |
| 161 | + | if parent.role != roleLayer { | |
| 162 | + | parentXdg = parent.xdg | |
| 163 | + | } | |
| 164 | + | s.popup, err = s.xdg.GetPopup(parentXdg, pos) | |
| 165 | + | _ = pos.Destroy() // the compositor copies the state at get_popup | |
| 166 | + | if err != nil { | |
| 167 | + | return nil, err | |
| 168 | + | } | |
| 169 | + | if parent.role == roleLayer { | |
| 170 | + | if err = parent.layer.GetPopup(s.popup); err != nil { | |
| 171 | + | return nil, err | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | q, sid := c.q, uint32(s.id) | |
| 175 | + | s.popup.OnConfigure(func(_, _, w, h int32) { | |
| 176 | + | e := event{kind: evPopupConfigure, id: sid, a: w, b: h} | |
| 177 | + | if w < 0 || h < 0 || w > maxLayerDimension || h > maxLayerDimension { | |
| 178 | + | e = event{kind: evBadEvent, id: sid, a: badConfigure} | |
| 179 | + | } | |
| 180 | + | q.post(&e) | |
| 181 | + | }) | |
| 182 | + | s.popup.OnPopupDone(func() { | |
| 183 | + | e := event{kind: evPopupDone, id: sid} | |
| 184 | + | q.post(&e) | |
| 185 | + | }) | |
| 186 | + | s.xdg.OnConfigure(func(serial uint32) { | |
| 187 | + | e := event{kind: evXdgConfigure, id: sid, serial: serial} | |
| 188 | + | q.post(&e) | |
| 189 | + | }) | |
| 190 | + | if cfg.Grab { | |
| 191 | + | if err = s.popup.Grab(c.seat.proxy, cfg.Serial); err != nil { | |
| 192 | + | return nil, err | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | if err = s.finishSurface(); err != nil { | |
| 196 | + | return nil, err | |
| 197 | + | } | |
| 198 | + | if err = s.surf.Commit(); err != nil { | |
| 199 | + | return nil, err | |
| 200 | + | } | |
| 201 | + | s.parent = parent | |
| 202 | + | parent.children++ | |
| 203 | + | ok = true | |
| 204 | + | return s, nil | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // Reposition asks the compositor to place the popup again. The following | |
| 208 | + | // Handler.Configure reports the resulting size. It needs xdg_wm_base version 3. | |
| 209 | + | func (s *Surface) Reposition(p PopupPlacement) error { | |
| 210 | + | switch { | |
| 211 | + | case s.closed || s.c.closed: | |
| 212 | + | return ErrClosed | |
| 213 | + | case s.role != rolePopup: | |
| 214 | + | return errors.New("neferclient: Reposition needs a popup") | |
| 215 | + | case s.c.g.xdgVersion < 3: | |
| 216 | + | return &CapabilityError{Name: xdgshell.XdgWmBaseInterface, Cause: fmt.Errorf("reposition requires version 3, negotiated %d", s.c.g.xdgVersion)} | |
| 217 | + | } | |
| 218 | + | if err := validatePlacement(&p); err != nil { | |
| 219 | + | return err | |
| 220 | + | } | |
| 221 | + | pos, err := s.c.newPositioner(&p) | |
| 222 | + | if err != nil { | |
| 223 | + | return err | |
| 224 | + | } | |
| 225 | + | s.repoToken++ | |
| 226 | + | err = s.popup.Reposition(pos, s.repoToken) | |
| 227 | + | if e := pos.Destroy(); err == nil { | |
| 228 | + | err = e | |
| 229 | + | } | |
| 230 | + | return err | |
| 231 | + | } | |
| @@ -0,0 +1,231 @@ | |||||
| 1 | + | package neferclient | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "fmt" | |||
| 6 | + | "math" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/go-wayland-bindings/client/xdgshell" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // PopupEdge names an edge or corner, used both as the anchor on the parent's | |||
| 12 | + | // anchor rectangle and as the gravity of the popup. The values equal the | |||
| 13 | + | // xdg_positioner anchor and gravity enums. | |||
| 14 | + | type PopupEdge uint8 | |||
| 15 | + | ||||
| 16 | + | const ( | |||
| 17 | + | EdgeNone PopupEdge = iota | |||
| 18 | + | EdgeTop | |||
| 19 | + | EdgeBottom | |||
| 20 | + | EdgeLeft | |||
| 21 | + | EdgeRight | |||
| 22 | + | EdgeTopLeft | |||
| 23 | + | EdgeBottomLeft | |||
| 24 | + | EdgeTopRight | |||
| 25 | + | EdgeBottomRight | |||
| 26 | + | ) | |||
| 27 | + | ||||
| 28 | + | // PopupAdjust is a bit set telling the compositor how to move a popup that | |||
| 29 | + | // would not fit on the output. The bits equal xdg_positioner | |||
| 30 | + | // constraint_adjustment. | |||
| 31 | + | type PopupAdjust uint8 | |||
| 32 | + | ||||
| 33 | + | const ( | |||
| 34 | + | AdjustSlideX PopupAdjust = 1 << iota | |||
| 35 | + | AdjustSlideY | |||
| 36 | + | AdjustFlipX | |||
| 37 | + | AdjustFlipY | |||
| 38 | + | AdjustResizeX | |||
| 39 | + | AdjustResizeY | |||
| 40 | + | ) | |||
| 41 | + | ||||
| 42 | + | const adjustAll = AdjustSlideX | AdjustSlideY | AdjustFlipX | AdjustFlipY | AdjustResizeX | AdjustResizeY | |||
| 43 | + | ||||
| 44 | + | // PopupPlacement tells the compositor where a popup goes relative to its | |||
| 45 | + | // parent. Every length is in logical pixels. | |||
| 46 | + | type PopupPlacement struct { | |||
| 47 | + | Anchor Rect // on the parent surface; Width and Height must be > 0 | |||
| 48 | + | Edge, Gravity PopupEdge | |||
| 49 | + | Adjust PopupAdjust | |||
| 50 | + | Offset [2]int32 | |||
| 51 | + | Width, Height int32 // popup size; must be > 0 | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // PopupConfig requests an xdg_popup. | |||
| 55 | + | type PopupConfig struct { | |||
| 56 | + | // Parent is a layer surface, a toplevel or another popup. It must be open. | |||
| 57 | + | Parent *Surface | |||
| 58 | + | Place PopupPlacement | |||
| 59 | + | // Grab takes an explicit grab: the popup gets the keyboard and is | |||
| 60 | + | // dismissed by a click outside it. It needs a bound seat. | |||
| 61 | + | Grab bool | |||
| 62 | + | // Serial is the [PointerEvent.Serial] of the press that opened the popup. | |||
| 63 | + | // It is required (non-zero) with Grab. | |||
| 64 | + | Serial uint32 | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func validatePlacement(p *PopupPlacement) error { | |||
| 68 | + | switch { | |||
| 69 | + | case p.Anchor.Width <= 0 || p.Anchor.Height <= 0: | |||
| 70 | + | return fmt.Errorf("neferclient: popup anchor rectangle %dx%d must be positive", p.Anchor.Width, p.Anchor.Height) | |||
| 71 | + | case p.Width <= 0 || p.Height <= 0: | |||
| 72 | + | return fmt.Errorf("neferclient: invalid popup size %dx%d", p.Width, p.Height) | |||
| 73 | + | case p.Edge > EdgeBottomRight || p.Gravity > EdgeBottomRight: | |||
| 74 | + | return fmt.Errorf("neferclient: invalid popup edge %d or gravity %d", p.Edge, p.Gravity) | |||
| 75 | + | case p.Adjust&^adjustAll != 0: | |||
| 76 | + | return fmt.Errorf("neferclient: invalid popup constraint adjustment %#x", uint8(p.Adjust)) | |||
| 77 | + | case int64(p.Anchor.X)+int64(p.Anchor.Width) > math.MaxInt32 || int64(p.Anchor.Y)+int64(p.Anchor.Height) > math.MaxInt32: | |||
| 78 | + | return errors.New("neferclient: popup anchor rectangle overflows") | |||
| 79 | + | } | |||
| 80 | + | return nil | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // newPositioner builds an xdg_positioner from p. The caller destroys it. | |||
| 84 | + | func (c *Conn) newPositioner(p *PopupPlacement) (*xdgshell.XdgPositioner, error) { | |||
| 85 | + | pos, err := c.g.xdg.CreatePositioner() | |||
| 86 | + | if err != nil { | |||
| 87 | + | return nil, err | |||
| 88 | + | } | |||
| 89 | + | for _, e := range []error{ | |||
| 90 | + | pos.SetSize(p.Width, p.Height), | |||
| 91 | + | pos.SetAnchorRect(p.Anchor.X, p.Anchor.Y, p.Anchor.Width, p.Anchor.Height), | |||
| 92 | + | pos.SetAnchor(uint32(p.Edge)), | |||
| 93 | + | pos.SetGravity(uint32(p.Gravity)), | |||
| 94 | + | pos.SetConstraintAdjustment(uint32(p.Adjust)), | |||
| 95 | + | pos.SetOffset(p.Offset[0], p.Offset[1]), | |||
| 96 | + | } { | |||
| 97 | + | if e != nil { | |||
| 98 | + | _ = pos.Destroy() | |||
| 99 | + | return nil, e | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | return pos, nil | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // NewPopup creates an xdg_popup on a layer surface, a toplevel or another | |||
| 106 | + | // popup. It becomes presentable after the first Handler.Configure, which | |||
| 107 | + | // reports the size the compositor gave it. Handle [Handler.PopupDone]: the | |||
| 108 | + | // compositor dismisses the popup on an outside click or when the grab is | |||
| 109 | + | // denied, and the popup must then be closed. | |||
| 110 | + | // | |||
| 111 | + | // Close popups before their parent: Close of a surface with open child popups | |||
| 112 | + | // fails. | |||
| 113 | + | func (c *Conn) NewPopup(cfg PopupConfig) (*Surface, error) { | |||
| 114 | + | if c.closed { | |||
| 115 | + | return nil, ErrClosed | |||
| 116 | + | } | |||
| 117 | + | parent := cfg.Parent | |||
| 118 | + | switch { | |||
| 119 | + | case parent == nil || parent.c != c: | |||
| 120 | + | return nil, errors.New("neferclient: popup needs a parent surface of this connection") | |||
| 121 | + | case parent.closed: | |||
| 122 | + | return nil, fmt.Errorf("neferclient: popup parent: %w", ErrClosed) | |||
| 123 | + | case parent.role != roleLayer && parent.role != roleToplevel && parent.role != rolePopup: | |||
| 124 | + | return nil, errors.New("neferclient: popup parent must be a layer surface, a toplevel or a popup") | |||
| 125 | + | } | |||
| 126 | + | if err := validatePlacement(&cfg.Place); err != nil { | |||
| 127 | + | return nil, err | |||
| 128 | + | } | |||
| 129 | + | if cfg.Grab { | |||
| 130 | + | if cfg.Serial == 0 { | |||
| 131 | + | return nil, errors.New("neferclient: popup grab needs the serial of the triggering input event") | |||
| 132 | + | } | |||
| 133 | + | c.ensureSeat() | |||
| 134 | + | if c.seat == nil || c.seat.proxy == nil { | |||
| 135 | + | return nil, &CapabilityError{Name: "wl_seat", Cause: errors.New("popup grab needs a seat")} | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | if err := c.ensureXdg(); err != nil { | |||
| 139 | + | return nil, err | |||
| 140 | + | } | |||
| 141 | + | c.pause() | |||
| 142 | + | defer c.resume() | |||
| 143 | + | s, err := c.newSurface(rolePopup, cfg.Place.Width, cfg.Place.Height) | |||
| 144 | + | if err != nil { | |||
| 145 | + | return nil, err | |||
| 146 | + | } | |||
| 147 | + | ok := false | |||
| 148 | + | defer func() { | |||
| 149 | + | if !ok { | |||
| 150 | + | s.destroy() | |||
| 151 | + | } | |||
| 152 | + | }() | |||
| 153 | + | if s.xdg, err = c.g.xdg.GetXdgSurface(s.surf); err != nil { | |||
| 154 | + | return nil, err | |||
| 155 | + | } | |||
| 156 | + | pos, err := c.newPositioner(&cfg.Place) | |||
| 157 | + | if err != nil { | |||
| 158 | + | return nil, err | |||
| 159 | + | } | |||
| 160 | + | var parentXdg *xdgshell.XdgSurface | |||
| 161 | + | if parent.role != roleLayer { | |||
| 162 | + | parentXdg = parent.xdg | |||
| 163 | + | } | |||
| 164 | + | s.popup, err = s.xdg.GetPopup(parentXdg, pos) | |||
| 165 | + | _ = pos.Destroy() // the compositor copies the state at get_popup | |||
| 166 | + | if err != nil { | |||
| 167 | + | return nil, err | |||
| 168 | + | } | |||
| 169 | + | if parent.role == roleLayer { | |||
| 170 | + | if err = parent.layer.GetPopup(s.popup); err != nil { | |||
| 171 | + | return nil, err | |||
| 172 | + | } | |||
| 173 | + | } | |||
| 174 | + | q, sid := c.q, uint32(s.id) | |||
| 175 | + | s.popup.OnConfigure(func(_, _, w, h int32) { | |||
| 176 | + | e := event{kind: evPopupConfigure, id: sid, a: w, b: h} | |||
| 177 | + | if w < 0 || h < 0 || w > maxLayerDimension || h > maxLayerDimension { | |||
| 178 | + | e = event{kind: evBadEvent, id: sid, a: badConfigure} | |||
| 179 | + | } | |||
| 180 | + | q.post(&e) | |||
| 181 | + | }) | |||
| 182 | + | s.popup.OnPopupDone(func() { | |||
| 183 | + | e := event{kind: evPopupDone, id: sid} | |||
| 184 | + | q.post(&e) | |||
| 185 | + | }) | |||
| 186 | + | s.xdg.OnConfigure(func(serial uint32) { | |||
| 187 | + | e := event{kind: evXdgConfigure, id: sid, serial: serial} | |||
| 188 | + | q.post(&e) | |||
| 189 | + | }) | |||
| 190 | + | if cfg.Grab { | |||
| 191 | + | if err = s.popup.Grab(c.seat.proxy, cfg.Serial); err != nil { | |||
| 192 | + | return nil, err | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | if err = s.finishSurface(); err != nil { | |||
| 196 | + | return nil, err | |||
| 197 | + | } | |||
| 198 | + | if err = s.surf.Commit(); err != nil { | |||
| 199 | + | return nil, err | |||
| 200 | + | } | |||
| 201 | + | s.parent = parent | |||
| 202 | + | parent.children++ | |||
| 203 | + | ok = true | |||
| 204 | + | return s, nil | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | // Reposition asks the compositor to place the popup again. The following | |||
| 208 | + | // Handler.Configure reports the resulting size. It needs xdg_wm_base version 3. | |||
| 209 | + | func (s *Surface) Reposition(p PopupPlacement) error { | |||
| 210 | + | switch { | |||
| 211 | + | case s.closed || s.c.closed: | |||
| 212 | + | return ErrClosed | |||
| 213 | + | case s.role != rolePopup: | |||
| 214 | + | return errors.New("neferclient: Reposition needs a popup") | |||
| 215 | + | case s.c.g.xdgVersion < 3: | |||
| 216 | + | return &CapabilityError{Name: xdgshell.XdgWmBaseInterface, Cause: fmt.Errorf("reposition requires version 3, negotiated %d", s.c.g.xdgVersion)} | |||
| 217 | + | } | |||
| 218 | + | if err := validatePlacement(&p); err != nil { | |||
| 219 | + | return err | |||
| 220 | + | } | |||
| 221 | + | pos, err := s.c.newPositioner(&p) | |||
| 222 | + | if err != nil { | |||
| 223 | + | return err | |||
| 224 | + | } | |||
| 225 | + | s.repoToken++ | |||
| 226 | + | err = s.popup.Reposition(pos, s.repoToken) | |||
| 227 | + | if e := pos.Destroy(); err == nil { | |||
| 228 | + | err = e | |||
| 229 | + | } | |||
| 230 | + | return err | |||
| 231 | + | } | |||
A popup_wire_test.go +320 -0
| @@ -0,0 +1,320 @@ | |||
| 1 | + | package neferclient_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/binary" | |
| 5 | + | "sync" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/mock" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/neferclient" | |
| 12 | + | neferclientmocks "github.com/bnema/neferclient/mocks" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | const ( | |
| 16 | + | wmBaseGlobal = 5 | |
| 17 | + | layerGlobal = 10 | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | var popupGlobals = append(append([]wireGlobal{}, seatGlobals...), wireGlobal{layerGlobal, "zwlr_layer_shell_v1", 4}) | |
| 21 | + | ||
| 22 | + | // popupLog records every request the peer sees, in order. | |
| 23 | + | type popupLog struct { | |
| 24 | + | mu sync.Mutex | |
| 25 | + | reqs []popupReq | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | type popupReq struct { | |
| 29 | + | obj uint32 | |
| 30 | + | op uint16 | |
| 31 | + | body []byte | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func newPopupLog(srv *wireServer) *popupLog { | |
| 35 | + | l := &popupLog{} | |
| 36 | + | srv.mu.Lock() | |
| 37 | + | srv.hook = func(obj uint32, op uint16, body []byte) { | |
| 38 | + | l.mu.Lock() | |
| 39 | + | l.reqs = append(l.reqs, popupReq{obj, op, append([]byte(nil), body...)}) | |
| 40 | + | l.mu.Unlock() | |
| 41 | + | } | |
| 42 | + | srv.mu.Unlock() | |
| 43 | + | return l | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (l *popupLog) len() int { | |
| 47 | + | l.mu.Lock() | |
| 48 | + | defer l.mu.Unlock() | |
| 49 | + | return len(l.reqs) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func (r popupReq) u32(i int) uint32 { return binary.LittleEndian.Uint32(r.body[4*i:]) } | |
| 53 | + | ||
| 54 | + | // find returns the index of the first request at or after from that matches. | |
| 55 | + | func (l *popupLog) find(from int, match func(popupReq) bool) int { | |
| 56 | + | l.mu.Lock() | |
| 57 | + | defer l.mu.Unlock() | |
| 58 | + | for i := from; i < len(l.reqs); i++ { | |
| 59 | + | if match(l.reqs[i]) { | |
| 60 | + | return i | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | return -1 | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func (l *popupLog) at(i int) popupReq { | |
| 67 | + | l.mu.Lock() | |
| 68 | + | defer l.mu.Unlock() | |
| 69 | + | return l.reqs[i] | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func (l *popupLog) all(match func(popupReq) bool) []popupReq { | |
| 73 | + | l.mu.Lock() | |
| 74 | + | defer l.mu.Unlock() | |
| 75 | + | var out []popupReq | |
| 76 | + | for _, r := range l.reqs { | |
| 77 | + | if match(r) { | |
| 78 | + | out = append(out, r) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | return out | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func (l *popupLog) op(obj uint32, op uint16) func(popupReq) bool { | |
| 85 | + | return func(r popupReq) bool { return r.obj == obj && r.op == op } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // popupEnv is a connection with a layer surface, a toplevel and the request log. | |
| 89 | + | type popupEnv struct { | |
| 90 | + | c *neferclient.Conn | |
| 91 | + | srv *wireServer | |
| 92 | + | log *popupLog | |
| 93 | + | layer *neferclient.Surface | |
| 94 | + | top *neferclient.Surface | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func newPopupEnv(t *testing.T, globals []wireGlobal) *popupEnv { | |
| 98 | + | t.Helper() | |
| 99 | + | c, srv := connectWire(t, globals, surfaceOutputs) | |
| 100 | + | e := &popupEnv{c: c, srv: srv, log: newPopupLog(srv)} | |
| 101 | + | var err error | |
| 102 | + | e.top, err = c.NewToplevel("parent", 64, 48) | |
| 103 | + | require.NoError(t, err) | |
| 104 | + | if len(globals) > 8 { | |
| 105 | + | e.layer, err = c.NewLayerSurface(neferclient.LayerConfig{Level: neferclient.LayerTop, | |
| 106 | + | Anchors: neferclient.AnchorTop | neferclient.AnchorLeft, Width: 200, Height: 30}) | |
| 107 | + | require.NoError(t, err) | |
| 108 | + | } | |
| 109 | + | require.NoError(t, c.Roundtrip()) | |
| 110 | + | return e | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (e *popupEnv) wmBase() uint32 { | |
| 114 | + | e.srv.mu.Lock() | |
| 115 | + | defer e.srv.mu.Unlock() | |
| 116 | + | return e.srv.bound[wmBaseGlobal].id | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // xdgSurfaceOf returns the xdg_surface id created for wl_surface surf. | |
| 120 | + | func (e *popupEnv) xdgSurfaceOf(t *testing.T, surf uint32) uint32 { | |
| 121 | + | t.Helper() | |
| 122 | + | wm := e.wmBase() | |
| 123 | + | i := e.log.find(0, func(r popupReq) bool { return r.obj == wm && r.op == 2 && len(r.body) == 8 && r.u32(1) == surf }) | |
| 124 | + | require.GreaterOrEqual(t, i, 0, "xdg_wm_base.get_xdg_surface") | |
| 125 | + | return e.log.at(i).u32(0) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | var place = neferclient.PopupPlacement{ | |
| 129 | + | Anchor: neferclient.Rect{X: 10, Y: 0, Width: 20, Height: 30}, | |
| 130 | + | Edge: neferclient.EdgeBottom, Gravity: neferclient.EdgeBottomRight, | |
| 131 | + | Adjust: neferclient.AdjustSlideX | neferclient.AdjustFlipY | neferclient.AdjustResizeY, | |
| 132 | + | Offset: [2]int32{3, 4}, Width: 120, Height: 80, | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func TestPopupOnLayerParentGrabsBeforeFirstCommit(t *testing.T) { | |
| 136 | + | e := newPopupEnv(t, popupGlobals) | |
| 137 | + | n := e.log.len() | |
| 138 | + | p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place, Grab: true, Serial: 42}) | |
| 139 | + | require.NoError(t, err) | |
| 140 | + | require.NoError(t, e.c.Roundtrip()) | |
| 141 | + | ||
| 142 | + | popupSurf := p.SurfaceObjectID() | |
| 143 | + | xdg := e.xdgSurfaceOf(t, popupSurf) | |
| 144 | + | getPopup := e.log.find(n, e.log.op(xdg, 2)) | |
| 145 | + | require.GreaterOrEqual(t, getPopup, 0, "xdg_surface.get_popup") | |
| 146 | + | gp := e.log.at(getPopup) | |
| 147 | + | popupID, positioner := gp.u32(0), gp.u32(2) | |
| 148 | + | require.Zero(t, gp.u32(1), "layer parent: null xdg parent") | |
| 149 | + | ||
| 150 | + | // The layer surface is the one created for e.layer. | |
| 151 | + | layerID := e.log.at(e.log.find(0, func(r popupReq) bool { return r.op == 0 && len(r.body) > 8 && r.u32(1) == e.layer.SurfaceObjectID() })).u32(0) | |
| 152 | + | layerPopup := e.log.find(n, e.log.op(layerID, 5)) | |
| 153 | + | require.Greater(t, layerPopup, getPopup, "layer_surface.get_popup follows xdg_surface.get_popup") | |
| 154 | + | require.Equal(t, popupID, e.log.at(layerPopup).u32(0)) | |
| 155 | + | ||
| 156 | + | grab := e.log.find(n, e.log.op(popupID, 1)) | |
| 157 | + | require.Greater(t, grab, layerPopup, "grab follows get_popup") | |
| 158 | + | require.Equal(t, uint32(42), e.log.at(grab).u32(1)) | |
| 159 | + | require.NotZero(t, e.log.at(grab).u32(0), "seat") | |
| 160 | + | commit := e.log.find(n, e.log.op(popupSurf, 6)) | |
| 161 | + | require.Greater(t, commit, grab, "grab precedes the first commit") | |
| 162 | + | ||
| 163 | + | // Positioner state. | |
| 164 | + | pos := func(op uint16) popupReq { | |
| 165 | + | i := e.log.find(n, e.log.op(positioner, op)) | |
| 166 | + | require.GreaterOrEqual(t, i, 0, "positioner op %d", op) | |
| 167 | + | require.Less(t, i, getPopup) | |
| 168 | + | return e.log.at(i) | |
| 169 | + | } | |
| 170 | + | require.Equal(t, []uint32{120, 80}, []uint32{pos(1).u32(0), pos(1).u32(1)}) | |
| 171 | + | require.Equal(t, []uint32{10, 0, 20, 30}, []uint32{pos(2).u32(0), pos(2).u32(1), pos(2).u32(2), pos(2).u32(3)}) | |
| 172 | + | require.Equal(t, uint32(neferclient.EdgeBottom), pos(3).u32(0)) | |
| 173 | + | require.Equal(t, uint32(neferclient.EdgeBottomRight), pos(4).u32(0)) | |
| 174 | + | require.Equal(t, uint32(1|8|32), pos(5).u32(0)) | |
| 175 | + | require.Equal(t, []uint32{3, 4}, []uint32{pos(6).u32(0), pos(6).u32(1)}) | |
| 176 | + | require.NotEmpty(t, e.log.all(e.log.op(positioner, 0)), "positioner destroyed") | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func TestPopupOnToplevelParentUsesParentXdgSurface(t *testing.T) { | |
| 180 | + | e := newPopupEnv(t, popupGlobals) | |
| 181 | + | n := e.log.len() | |
| 182 | + | p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.top, Place: place}) | |
| 183 | + | require.NoError(t, err) | |
| 184 | + | require.NoError(t, e.c.Roundtrip()) | |
| 185 | + | parentXdg := e.xdgSurfaceOf(t, e.top.SurfaceObjectID()) | |
| 186 | + | xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID()) | |
| 187 | + | i := e.log.find(n, e.log.op(xdg, 2)) | |
| 188 | + | require.GreaterOrEqual(t, i, 0) | |
| 189 | + | require.Equal(t, parentXdg, e.log.at(i).u32(1)) | |
| 190 | + | popupID := e.log.at(i).u32(0) | |
| 191 | + | require.Negative(t, e.log.find(n, e.log.op(popupID, 1)), "no grab requested") | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func TestPopupNestedOnPopup(t *testing.T) { | |
| 195 | + | e := newPopupEnv(t, popupGlobals) | |
| 196 | + | p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.top, Place: place}) | |
| 197 | + | require.NoError(t, err) | |
| 198 | + | child, err := e.c.NewPopup(neferclient.PopupConfig{Parent: p, Place: place}) | |
| 199 | + | require.NoError(t, err) | |
| 200 | + | require.NoError(t, e.c.Roundtrip()) | |
| 201 | + | i := e.log.find(0, e.log.op(e.xdgSurfaceOf(t, child.SurfaceObjectID()), 2)) | |
| 202 | + | require.Equal(t, e.xdgSurfaceOf(t, p.SurfaceObjectID()), e.log.at(i).u32(1)) | |
| 203 | + | require.Error(t, p.Close(), "popup with an open child") | |
| 204 | + | require.NoError(t, child.Close()) | |
| 205 | + | require.NoError(t, p.Close()) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func TestPopupConfigureAcksAndDone(t *testing.T) { | |
| 209 | + | e := newPopupEnv(t, popupGlobals) | |
| 210 | + | p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place}) | |
| 211 | + | require.NoError(t, err) | |
| 212 | + | require.NoError(t, e.c.Roundtrip()) | |
| 213 | + | xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID()) | |
| 214 | + | popupID := e.log.at(e.log.find(0, e.log.op(xdg, 2))).u32(0) | |
| 215 | + | ||
| 216 | + | var w, h int32 | |
| 217 | + | var done bool | |
| 218 | + | hd := neferclientmocks.NewMockHandler(t) | |
| 219 | + | hd.EXPECT().Configure(p.ID(), mock.Anything, mock.Anything).Run(func(_ neferclient.SurfaceID, cw, ch int32) { w, h = cw, ch }).Return().Once() | |
| 220 | + | hd.EXPECT().PopupDone(p.ID()).Run(func(neferclient.SurfaceID) { done = true }).Return().Once() | |
| 221 | + | ||
| 222 | + | cfg := appendU32(appendU32(appendU32(appendU32(nil, 5), 6), 120), 80) | |
| 223 | + | e.srv.write(frame(popupID, 0, cfg)) // xdg_popup.configure(5,6,120,80) | |
| 224 | + | e.srv.write(frame(xdg, 0, appendU32(nil, 9))) // xdg_surface.configure(9) | |
| 225 | + | require.NoError(t, e.c.Roundtrip()) | |
| 226 | + | dispatchUntil(t, e.c, hd, func() bool { return w != 0 }) | |
| 227 | + | require.Equal(t, [2]int32{120, 80}, [2]int32{w, h}) | |
| 228 | + | require.NoError(t, e.c.Roundtrip()) | |
| 229 | + | ack := e.log.find(0, e.log.op(xdg, 4)) | |
| 230 | + | require.GreaterOrEqual(t, ack, 0, "ack_configure") | |
| 231 | + | require.Equal(t, uint32(9), e.log.at(ack).u32(0)) | |
| 232 | + | gw, gh, _ := p.Size() | |
| 233 | + | require.Equal(t, [2]int32{120, 80}, [2]int32{gw, gh}) | |
| 234 | + | ||
| 235 | + | e.srv.write(frame(popupID, 1, nil)) // popup_done | |
| 236 | + | require.NoError(t, e.c.Roundtrip()) | |
| 237 | + | dispatchUntil(t, e.c, hd, func() bool { return done }) | |
| 238 | + | require.NoError(t, p.Close()) | |
| 239 | + | require.NoError(t, e.c.Roundtrip()) | |
| 240 | + | require.NotEmpty(t, e.log.all(e.log.op(popupID, 0)), "xdg_popup.destroy") | |
| 241 | + | destroyPopup := e.log.find(0, e.log.op(popupID, 0)) | |
| 242 | + | destroyXdg := e.log.find(0, e.log.op(xdg, 0)) | |
| 243 | + | require.Less(t, destroyPopup, destroyXdg, "popup destroyed before its xdg_surface") | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | func TestPopupReposition(t *testing.T) { | |
| 247 | + | e := newPopupEnv(t, popupGlobals) | |
| 248 | + | p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place}) | |
| 249 | + | require.NoError(t, err) | |
| 250 | + | require.NoError(t, e.c.Roundtrip()) | |
| 251 | + | xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID()) | |
| 252 | + | popupID := e.log.at(e.log.find(0, e.log.op(xdg, 2))).u32(0) | |
| 253 | + | ||
| 254 | + | np := place | |
| 255 | + | np.Anchor.X, np.Width = 50, 90 | |
| 256 | + | require.NoError(t, p.Reposition(np)) | |
| 257 | + | require.NoError(t, e.c.Roundtrip()) | |
| 258 | + | i := e.log.find(0, e.log.op(popupID, 2)) | |
| 259 | + | require.GreaterOrEqual(t, i, 0, "xdg_popup.reposition") | |
| 260 | + | positioner := e.log.at(i).u32(0) | |
| 261 | + | require.NotEqual(t, uint32(0), e.log.at(i).u32(1), "token") | |
| 262 | + | setSize := e.log.at(e.log.find(i-8, e.log.op(positioner, 1))) | |
| 263 | + | require.Equal(t, uint32(90), setSize.u32(0)) | |
| 264 | + | require.NotEmpty(t, e.log.all(e.log.op(positioner, 0)), "positioner destroyed") | |
| 265 | + | ||
| 266 | + | bad := np | |
| 267 | + | bad.Width = 0 | |
| 268 | + | require.Error(t, p.Reposition(bad)) | |
| 269 | + | require.Error(t, e.top.Reposition(np), "not a popup") | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | func TestPopupRepositionNeedsXdgV3(t *testing.T) { | |
| 273 | + | old := append([]wireGlobal{}, popupGlobals...) | |
| 274 | + | old[wmBaseGlobal-1].version = 2 | |
| 275 | + | require.Equal(t, "xdg_wm_base", old[wmBaseGlobal-1].iface) | |
| 276 | + | e := newPopupEnv(t, old) | |
| 277 | + | p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place}) | |
| 278 | + | require.NoError(t, err) | |
| 279 | + | require.NoError(t, e.c.Roundtrip()) | |
| 280 | + | n := e.log.len() | |
| 281 | + | var ce *neferclient.CapabilityError | |
| 282 | + | require.ErrorAs(t, p.Reposition(place), &ce) | |
| 283 | + | require.NoError(t, e.c.Roundtrip()) | |
| 284 | + | require.Equal(t, n, e.log.len(), "nothing sent") | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | func TestPopupInvalidConfigsSendNothing(t *testing.T) { | |
| 288 | + | e := newPopupEnv(t, surfaceGlobals) // no seat, no layer shell | |
| 289 | + | closed, err := e.c.NewToplevel("closed", 64, 48) | |
| 290 | + | require.NoError(t, err) | |
| 291 | + | require.NoError(t, closed.Close()) | |
| 292 | + | require.NoError(t, e.c.Roundtrip()) | |
| 293 | + | n := e.log.len() | |
| 294 | + | ||
| 295 | + | with := func(f func(*neferclient.PopupConfig)) neferclient.PopupConfig { | |
| 296 | + | cfg := neferclient.PopupConfig{Parent: e.top, Place: place} | |
| 297 | + | f(&cfg) | |
| 298 | + | return cfg | |
| 299 | + | } | |
| 300 | + | for name, cfg := range map[string]neferclient.PopupConfig{ | |
| 301 | + | "nil parent": with(func(c *neferclient.PopupConfig) { c.Parent = nil }), | |
| 302 | + | "closed parent": with(func(c *neferclient.PopupConfig) { c.Parent = closed }), | |
| 303 | + | "zero width": with(func(c *neferclient.PopupConfig) { c.Place.Width = 0 }), | |
| 304 | + | "negative height": with(func(c *neferclient.PopupConfig) { c.Place.Height = -1 }), | |
| 305 | + | "empty anchor": with(func(c *neferclient.PopupConfig) { c.Place.Anchor.Width = 0 }), | |
| 306 | + | "bad edge": with(func(c *neferclient.PopupConfig) { c.Place.Edge = 9 }), | |
| 307 | + | "bad gravity": with(func(c *neferclient.PopupConfig) { c.Place.Gravity = 9 }), | |
| 308 | + | "bad adjust": with(func(c *neferclient.PopupConfig) { c.Place.Adjust = 64 }), | |
| 309 | + | "grab no serial": with(func(c *neferclient.PopupConfig) { c.Grab = true }), | |
| 310 | + | } { | |
| 311 | + | _, err := e.c.NewPopup(cfg) | |
| 312 | + | require.Error(t, err, name) | |
| 313 | + | } | |
| 314 | + | var ce *neferclient.CapabilityError | |
| 315 | + | _, err = e.c.NewPopup(with(func(c *neferclient.PopupConfig) { c.Grab, c.Serial = true, 1 })) | |
| 316 | + | require.ErrorAs(t, err, &ce, "grab without a seat") | |
| 317 | + | require.NoError(t, e.c.Roundtrip()) | |
| 318 | + | require.Equal(t, n, e.log.len(), "no request was sent") | |
| 319 | + | require.NoError(t, e.top.Close(), "failed popups left no child behind") | |
| 320 | + | } | |
| @@ -0,0 +1,320 @@ | |||||
| 1 | + | package neferclient_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/binary" | |||
| 5 | + | "sync" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/mock" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/neferclient" | |||
| 12 | + | neferclientmocks "github.com/bnema/neferclient/mocks" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | const ( | |||
| 16 | + | wmBaseGlobal = 5 | |||
| 17 | + | layerGlobal = 10 | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | var popupGlobals = append(append([]wireGlobal{}, seatGlobals...), wireGlobal{layerGlobal, "zwlr_layer_shell_v1", 4}) | |||
| 21 | + | ||||
| 22 | + | // popupLog records every request the peer sees, in order. | |||
| 23 | + | type popupLog struct { | |||
| 24 | + | mu sync.Mutex | |||
| 25 | + | reqs []popupReq | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | type popupReq struct { | |||
| 29 | + | obj uint32 | |||
| 30 | + | op uint16 | |||
| 31 | + | body []byte | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func newPopupLog(srv *wireServer) *popupLog { | |||
| 35 | + | l := &popupLog{} | |||
| 36 | + | srv.mu.Lock() | |||
| 37 | + | srv.hook = func(obj uint32, op uint16, body []byte) { | |||
| 38 | + | l.mu.Lock() | |||
| 39 | + | l.reqs = append(l.reqs, popupReq{obj, op, append([]byte(nil), body...)}) | |||
| 40 | + | l.mu.Unlock() | |||
| 41 | + | } | |||
| 42 | + | srv.mu.Unlock() | |||
| 43 | + | return l | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (l *popupLog) len() int { | |||
| 47 | + | l.mu.Lock() | |||
| 48 | + | defer l.mu.Unlock() | |||
| 49 | + | return len(l.reqs) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func (r popupReq) u32(i int) uint32 { return binary.LittleEndian.Uint32(r.body[4*i:]) } | |||
| 53 | + | ||||
| 54 | + | // find returns the index of the first request at or after from that matches. | |||
| 55 | + | func (l *popupLog) find(from int, match func(popupReq) bool) int { | |||
| 56 | + | l.mu.Lock() | |||
| 57 | + | defer l.mu.Unlock() | |||
| 58 | + | for i := from; i < len(l.reqs); i++ { | |||
| 59 | + | if match(l.reqs[i]) { | |||
| 60 | + | return i | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | return -1 | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func (l *popupLog) at(i int) popupReq { | |||
| 67 | + | l.mu.Lock() | |||
| 68 | + | defer l.mu.Unlock() | |||
| 69 | + | return l.reqs[i] | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func (l *popupLog) all(match func(popupReq) bool) []popupReq { | |||
| 73 | + | l.mu.Lock() | |||
| 74 | + | defer l.mu.Unlock() | |||
| 75 | + | var out []popupReq | |||
| 76 | + | for _, r := range l.reqs { | |||
| 77 | + | if match(r) { | |||
| 78 | + | out = append(out, r) | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | return out | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func (l *popupLog) op(obj uint32, op uint16) func(popupReq) bool { | |||
| 85 | + | return func(r popupReq) bool { return r.obj == obj && r.op == op } | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // popupEnv is a connection with a layer surface, a toplevel and the request log. | |||
| 89 | + | type popupEnv struct { | |||
| 90 | + | c *neferclient.Conn | |||
| 91 | + | srv *wireServer | |||
| 92 | + | log *popupLog | |||
| 93 | + | layer *neferclient.Surface | |||
| 94 | + | top *neferclient.Surface | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func newPopupEnv(t *testing.T, globals []wireGlobal) *popupEnv { | |||
| 98 | + | t.Helper() | |||
| 99 | + | c, srv := connectWire(t, globals, surfaceOutputs) | |||
| 100 | + | e := &popupEnv{c: c, srv: srv, log: newPopupLog(srv)} | |||
| 101 | + | var err error | |||
| 102 | + | e.top, err = c.NewToplevel("parent", 64, 48) | |||
| 103 | + | require.NoError(t, err) | |||
| 104 | + | if len(globals) > 8 { | |||
| 105 | + | e.layer, err = c.NewLayerSurface(neferclient.LayerConfig{Level: neferclient.LayerTop, | |||
| 106 | + | Anchors: neferclient.AnchorTop | neferclient.AnchorLeft, Width: 200, Height: 30}) | |||
| 107 | + | require.NoError(t, err) | |||
| 108 | + | } | |||
| 109 | + | require.NoError(t, c.Roundtrip()) | |||
| 110 | + | return e | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func (e *popupEnv) wmBase() uint32 { | |||
| 114 | + | e.srv.mu.Lock() | |||
| 115 | + | defer e.srv.mu.Unlock() | |||
| 116 | + | return e.srv.bound[wmBaseGlobal].id | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // xdgSurfaceOf returns the xdg_surface id created for wl_surface surf. | |||
| 120 | + | func (e *popupEnv) xdgSurfaceOf(t *testing.T, surf uint32) uint32 { | |||
| 121 | + | t.Helper() | |||
| 122 | + | wm := e.wmBase() | |||
| 123 | + | i := e.log.find(0, func(r popupReq) bool { return r.obj == wm && r.op == 2 && len(r.body) == 8 && r.u32(1) == surf }) | |||
| 124 | + | require.GreaterOrEqual(t, i, 0, "xdg_wm_base.get_xdg_surface") | |||
| 125 | + | return e.log.at(i).u32(0) | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | var place = neferclient.PopupPlacement{ | |||
| 129 | + | Anchor: neferclient.Rect{X: 10, Y: 0, Width: 20, Height: 30}, | |||
| 130 | + | Edge: neferclient.EdgeBottom, Gravity: neferclient.EdgeBottomRight, | |||
| 131 | + | Adjust: neferclient.AdjustSlideX | neferclient.AdjustFlipY | neferclient.AdjustResizeY, | |||
| 132 | + | Offset: [2]int32{3, 4}, Width: 120, Height: 80, | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func TestPopupOnLayerParentGrabsBeforeFirstCommit(t *testing.T) { | |||
| 136 | + | e := newPopupEnv(t, popupGlobals) | |||
| 137 | + | n := e.log.len() | |||
| 138 | + | p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place, Grab: true, Serial: 42}) | |||
| 139 | + | require.NoError(t, err) | |||
| 140 | + | require.NoError(t, e.c.Roundtrip()) | |||
| 141 | + | ||||
| 142 | + | popupSurf := p.SurfaceObjectID() | |||
| 143 | + | xdg := e.xdgSurfaceOf(t, popupSurf) | |||
| 144 | + | getPopup := e.log.find(n, e.log.op(xdg, 2)) | |||
| 145 | + | require.GreaterOrEqual(t, getPopup, 0, "xdg_surface.get_popup") | |||
| 146 | + | gp := e.log.at(getPopup) | |||
| 147 | + | popupID, positioner := gp.u32(0), gp.u32(2) | |||
| 148 | + | require.Zero(t, gp.u32(1), "layer parent: null xdg parent") | |||
| 149 | + | ||||
| 150 | + | // The layer surface is the one created for e.layer. | |||
| 151 | + | layerID := e.log.at(e.log.find(0, func(r popupReq) bool { return r.op == 0 && len(r.body) > 8 && r.u32(1) == e.layer.SurfaceObjectID() })).u32(0) | |||
| 152 | + | layerPopup := e.log.find(n, e.log.op(layerID, 5)) | |||
| 153 | + | require.Greater(t, layerPopup, getPopup, "layer_surface.get_popup follows xdg_surface.get_popup") | |||
| 154 | + | require.Equal(t, popupID, e.log.at(layerPopup).u32(0)) | |||
| 155 | + | ||||
| 156 | + | grab := e.log.find(n, e.log.op(popupID, 1)) | |||
| 157 | + | require.Greater(t, grab, layerPopup, "grab follows get_popup") | |||
| 158 | + | require.Equal(t, uint32(42), e.log.at(grab).u32(1)) | |||
| 159 | + | require.NotZero(t, e.log.at(grab).u32(0), "seat") | |||
| 160 | + | commit := e.log.find(n, e.log.op(popupSurf, 6)) | |||
| 161 | + | require.Greater(t, commit, grab, "grab precedes the first commit") | |||
| 162 | + | ||||
| 163 | + | // Positioner state. | |||
| 164 | + | pos := func(op uint16) popupReq { | |||
| 165 | + | i := e.log.find(n, e.log.op(positioner, op)) | |||
| 166 | + | require.GreaterOrEqual(t, i, 0, "positioner op %d", op) | |||
| 167 | + | require.Less(t, i, getPopup) | |||
| 168 | + | return e.log.at(i) | |||
| 169 | + | } | |||
| 170 | + | require.Equal(t, []uint32{120, 80}, []uint32{pos(1).u32(0), pos(1).u32(1)}) | |||
| 171 | + | require.Equal(t, []uint32{10, 0, 20, 30}, []uint32{pos(2).u32(0), pos(2).u32(1), pos(2).u32(2), pos(2).u32(3)}) | |||
| 172 | + | require.Equal(t, uint32(neferclient.EdgeBottom), pos(3).u32(0)) | |||
| 173 | + | require.Equal(t, uint32(neferclient.EdgeBottomRight), pos(4).u32(0)) | |||
| 174 | + | require.Equal(t, uint32(1|8|32), pos(5).u32(0)) | |||
| 175 | + | require.Equal(t, []uint32{3, 4}, []uint32{pos(6).u32(0), pos(6).u32(1)}) | |||
| 176 | + | require.NotEmpty(t, e.log.all(e.log.op(positioner, 0)), "positioner destroyed") | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func TestPopupOnToplevelParentUsesParentXdgSurface(t *testing.T) { | |||
| 180 | + | e := newPopupEnv(t, popupGlobals) | |||
| 181 | + | n := e.log.len() | |||
| 182 | + | p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.top, Place: place}) | |||
| 183 | + | require.NoError(t, err) | |||
| 184 | + | require.NoError(t, e.c.Roundtrip()) | |||
| 185 | + | parentXdg := e.xdgSurfaceOf(t, e.top.SurfaceObjectID()) | |||
| 186 | + | xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID()) | |||
| 187 | + | i := e.log.find(n, e.log.op(xdg, 2)) | |||
| 188 | + | require.GreaterOrEqual(t, i, 0) | |||
| 189 | + | require.Equal(t, parentXdg, e.log.at(i).u32(1)) | |||
| 190 | + | popupID := e.log.at(i).u32(0) | |||
| 191 | + | require.Negative(t, e.log.find(n, e.log.op(popupID, 1)), "no grab requested") | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | func TestPopupNestedOnPopup(t *testing.T) { | |||
| 195 | + | e := newPopupEnv(t, popupGlobals) | |||
| 196 | + | p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.top, Place: place}) | |||
| 197 | + | require.NoError(t, err) | |||
| 198 | + | child, err := e.c.NewPopup(neferclient.PopupConfig{Parent: p, Place: place}) | |||
| 199 | + | require.NoError(t, err) | |||
| 200 | + | require.NoError(t, e.c.Roundtrip()) | |||
| 201 | + | i := e.log.find(0, e.log.op(e.xdgSurfaceOf(t, child.SurfaceObjectID()), 2)) | |||
| 202 | + | require.Equal(t, e.xdgSurfaceOf(t, p.SurfaceObjectID()), e.log.at(i).u32(1)) | |||
| 203 | + | require.Error(t, p.Close(), "popup with an open child") | |||
| 204 | + | require.NoError(t, child.Close()) | |||
| 205 | + | require.NoError(t, p.Close()) | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | func TestPopupConfigureAcksAndDone(t *testing.T) { | |||
| 209 | + | e := newPopupEnv(t, popupGlobals) | |||
| 210 | + | p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place}) | |||
| 211 | + | require.NoError(t, err) | |||
| 212 | + | require.NoError(t, e.c.Roundtrip()) | |||
| 213 | + | xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID()) | |||
| 214 | + | popupID := e.log.at(e.log.find(0, e.log.op(xdg, 2))).u32(0) | |||
| 215 | + | ||||
| 216 | + | var w, h int32 | |||
| 217 | + | var done bool | |||
| 218 | + | hd := neferclientmocks.NewMockHandler(t) | |||
| 219 | + | hd.EXPECT().Configure(p.ID(), mock.Anything, mock.Anything).Run(func(_ neferclient.SurfaceID, cw, ch int32) { w, h = cw, ch }).Return().Once() | |||
| 220 | + | hd.EXPECT().PopupDone(p.ID()).Run(func(neferclient.SurfaceID) { done = true }).Return().Once() | |||
| 221 | + | ||||
| 222 | + | cfg := appendU32(appendU32(appendU32(appendU32(nil, 5), 6), 120), 80) | |||
| 223 | + | e.srv.write(frame(popupID, 0, cfg)) // xdg_popup.configure(5,6,120,80) | |||
| 224 | + | e.srv.write(frame(xdg, 0, appendU32(nil, 9))) // xdg_surface.configure(9) | |||
| 225 | + | require.NoError(t, e.c.Roundtrip()) | |||
| 226 | + | dispatchUntil(t, e.c, hd, func() bool { return w != 0 }) | |||
| 227 | + | require.Equal(t, [2]int32{120, 80}, [2]int32{w, h}) | |||
| 228 | + | require.NoError(t, e.c.Roundtrip()) | |||
| 229 | + | ack := e.log.find(0, e.log.op(xdg, 4)) | |||
| 230 | + | require.GreaterOrEqual(t, ack, 0, "ack_configure") | |||
| 231 | + | require.Equal(t, uint32(9), e.log.at(ack).u32(0)) | |||
| 232 | + | gw, gh, _ := p.Size() | |||
| 233 | + | require.Equal(t, [2]int32{120, 80}, [2]int32{gw, gh}) | |||
| 234 | + | ||||
| 235 | + | e.srv.write(frame(popupID, 1, nil)) // popup_done | |||
| 236 | + | require.NoError(t, e.c.Roundtrip()) | |||
| 237 | + | dispatchUntil(t, e.c, hd, func() bool { return done }) | |||
| 238 | + | require.NoError(t, p.Close()) | |||
| 239 | + | require.NoError(t, e.c.Roundtrip()) | |||
| 240 | + | require.NotEmpty(t, e.log.all(e.log.op(popupID, 0)), "xdg_popup.destroy") | |||
| 241 | + | destroyPopup := e.log.find(0, e.log.op(popupID, 0)) | |||
| 242 | + | destroyXdg := e.log.find(0, e.log.op(xdg, 0)) | |||
| 243 | + | require.Less(t, destroyPopup, destroyXdg, "popup destroyed before its xdg_surface") | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | func TestPopupReposition(t *testing.T) { | |||
| 247 | + | e := newPopupEnv(t, popupGlobals) | |||
| 248 | + | p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place}) | |||
| 249 | + | require.NoError(t, err) | |||
| 250 | + | require.NoError(t, e.c.Roundtrip()) | |||
| 251 | + | xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID()) | |||
| 252 | + | popupID := e.log.at(e.log.find(0, e.log.op(xdg, 2))).u32(0) | |||
| 253 | + | ||||
| 254 | + | np := place | |||
| 255 | + | np.Anchor.X, np.Width = 50, 90 | |||
| 256 | + | require.NoError(t, p.Reposition(np)) | |||
| 257 | + | require.NoError(t, e.c.Roundtrip()) | |||
| 258 | + | i := e.log.find(0, e.log.op(popupID, 2)) | |||
| 259 | + | require.GreaterOrEqual(t, i, 0, "xdg_popup.reposition") | |||
| 260 | + | positioner := e.log.at(i).u32(0) | |||
| 261 | + | require.NotEqual(t, uint32(0), e.log.at(i).u32(1), "token") | |||
| 262 | + | setSize := e.log.at(e.log.find(i-8, e.log.op(positioner, 1))) | |||
| 263 | + | require.Equal(t, uint32(90), setSize.u32(0)) | |||
| 264 | + | require.NotEmpty(t, e.log.all(e.log.op(positioner, 0)), "positioner destroyed") | |||
| 265 | + | ||||
| 266 | + | bad := np | |||
| 267 | + | bad.Width = 0 | |||
| 268 | + | require.Error(t, p.Reposition(bad)) | |||
| 269 | + | require.Error(t, e.top.Reposition(np), "not a popup") | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | func TestPopupRepositionNeedsXdgV3(t *testing.T) { | |||
| 273 | + | old := append([]wireGlobal{}, popupGlobals...) | |||
| 274 | + | old[wmBaseGlobal-1].version = 2 | |||
| 275 | + | require.Equal(t, "xdg_wm_base", old[wmBaseGlobal-1].iface) | |||
| 276 | + | e := newPopupEnv(t, old) | |||
| 277 | + | p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place}) | |||
| 278 | + | require.NoError(t, err) | |||
| 279 | + | require.NoError(t, e.c.Roundtrip()) | |||
| 280 | + | n := e.log.len() | |||
| 281 | + | var ce *neferclient.CapabilityError | |||
| 282 | + | require.ErrorAs(t, p.Reposition(place), &ce) | |||
| 283 | + | require.NoError(t, e.c.Roundtrip()) | |||
| 284 | + | require.Equal(t, n, e.log.len(), "nothing sent") | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | func TestPopupInvalidConfigsSendNothing(t *testing.T) { | |||
| 288 | + | e := newPopupEnv(t, surfaceGlobals) // no seat, no layer shell | |||
| 289 | + | closed, err := e.c.NewToplevel("closed", 64, 48) | |||
| 290 | + | require.NoError(t, err) | |||
| 291 | + | require.NoError(t, closed.Close()) | |||
| 292 | + | require.NoError(t, e.c.Roundtrip()) | |||
| 293 | + | n := e.log.len() | |||
| 294 | + | ||||
| 295 | + | with := func(f func(*neferclient.PopupConfig)) neferclient.PopupConfig { | |||
| 296 | + | cfg := neferclient.PopupConfig{Parent: e.top, Place: place} | |||
| 297 | + | f(&cfg) | |||
| 298 | + | return cfg | |||
| 299 | + | } | |||
| 300 | + | for name, cfg := range map[string]neferclient.PopupConfig{ | |||
| 301 | + | "nil parent": with(func(c *neferclient.PopupConfig) { c.Parent = nil }), | |||
| 302 | + | "closed parent": with(func(c *neferclient.PopupConfig) { c.Parent = closed }), | |||
| 303 | + | "zero width": with(func(c *neferclient.PopupConfig) { c.Place.Width = 0 }), | |||
| 304 | + | "negative height": with(func(c *neferclient.PopupConfig) { c.Place.Height = -1 }), | |||
| 305 | + | "empty anchor": with(func(c *neferclient.PopupConfig) { c.Place.Anchor.Width = 0 }), | |||
| 306 | + | "bad edge": with(func(c *neferclient.PopupConfig) { c.Place.Edge = 9 }), | |||
| 307 | + | "bad gravity": with(func(c *neferclient.PopupConfig) { c.Place.Gravity = 9 }), | |||
| 308 | + | "bad adjust": with(func(c *neferclient.PopupConfig) { c.Place.Adjust = 64 }), | |||
| 309 | + | "grab no serial": with(func(c *neferclient.PopupConfig) { c.Grab = true }), | |||
| 310 | + | } { | |||
| 311 | + | _, err := e.c.NewPopup(cfg) | |||
| 312 | + | require.Error(t, err, name) | |||
| 313 | + | } | |||
| 314 | + | var ce *neferclient.CapabilityError | |||
| 315 | + | _, err = e.c.NewPopup(with(func(c *neferclient.PopupConfig) { c.Grab, c.Serial = true, 1 })) | |||
| 316 | + | require.ErrorAs(t, err, &ce, "grab without a seat") | |||
| 317 | + | require.NoError(t, e.c.Roundtrip()) | |||
| 318 | + | require.Equal(t, n, e.log.len(), "no request was sent") | |||
| 319 | + | require.NoError(t, e.top.Close(), "failed popups left no child behind") | |||
| 320 | + | } | |||
M queue.go +2 -0
| @@ -38,6 +38,8 @@ const ( | |||
| 38 | 38 | evFeedbackDone // id | |
| 39 | 39 | evLocked // id = lock generation | |
| 40 | 40 | evLockFinished // id = lock generation | |
| 41 | + | evPopupConfigure // id, a = width, b = height (before the matching evXdgConfigure) | |
| 42 | + | evPopupDone // id | |
| 41 | 43 | evBadEvent // id, a = badEvent code: the reader met a malformed event | |
| 42 | 44 | ||
| 43 | 45 | // Seat events (kind >= evSeatCaps). Pointer and keyboard events carry the | |
| @@ -38,6 +38,8 @@ const ( | |||||
| 38 | evFeedbackDone // id | 38 | evFeedbackDone // id | ||
| 39 | evLocked // id = lock generation | 39 | evLocked // id = lock generation | ||
| 40 | evLockFinished // id = lock generation | 40 | evLockFinished // id = lock generation | ||
| 41 | + | evPopupConfigure // id, a = width, b = height (before the matching evXdgConfigure) | |||
| 42 | + | evPopupDone // id | |||
| 41 | evBadEvent // id, a = badEvent code: the reader met a malformed event | 43 | evBadEvent // id, a = badEvent code: the reader met a malformed event | ||
| 42 | 44 | ||||
| 43 | // Seat events (kind >= evSeatCaps). Pointer and keyboard events carry the | 45 | // Seat events (kind >= evSeatCaps). Pointer and keyboard events carry the | ||
M surface.go +23 -1
| @@ -83,6 +83,7 @@ const ( | |||
| 83 | 83 | roleToplevel surfaceRole = iota + 1 | |
| 84 | 84 | roleLayer | |
| 85 | 85 | roleLock | |
| 86 | + | rolePopup | |
| 86 | 87 | ) | |
| 87 | 88 | ||
| 88 | 89 | // Surface is one wl_surface with its role and the DMA-BUF presentation | |
| @@ -99,10 +100,15 @@ type Surface struct { | |||
| 99 | 100 | feedback *linuxdmabuf.LinuxDmabufFeedback | |
| 100 | 101 | xdg *xdgshell.XdgSurface | |
| 101 | 102 | toplevel *xdgshell.XdgToplevel | |
| 103 | + | popup *xdgshell.XdgPopup | |
| 102 | 104 | layer *wlrlayershell.LayerSurface | |
| 103 | 105 | lockSurf *extsessionlock.ExtSessionLockSurface | |
| 104 | 106 | lock *Lock | |
| 105 | 107 | ||
| 108 | + | parent *Surface // popups: the surface they were created on | |
| 109 | + | children int // open popups whose parent this surface is | |
| 110 | + | repoToken uint32 // popups: last xdg_popup.reposition token | |
| 111 | + | ||
| 106 | 112 | width, height int32 | |
| 107 | 113 | scale float64 | |
| 108 | 114 | pendW, pendH int32 // toplevel size announced before the xdg configure | |
| @@ -564,8 +570,10 @@ func (c *Conn) applySurface(ev *event, h Handler) { | |||
| 564 | 570 | h.Error(errors.New(badEventErrors[ev.a])) | |
| 565 | 571 | case evClosed: | |
| 566 | 572 | h.Closed(id) | |
| 567 | - | case evXdgToplevel: | |
| 573 | + | case evXdgToplevel, evPopupConfigure: | |
| 568 | 574 | s.pendW, s.pendH = ev.a, ev.b | |
| 575 | + | case evPopupDone: | |
| 576 | + | h.PopupDone(id) | |
| 569 | 577 | case evXdgConfigure: | |
| 570 | 578 | if s.pendW > 0 && s.pendH > 0 { | |
| 571 | 579 | s.width, s.height = s.pendW, s.pendH | |
| @@ -728,6 +736,10 @@ func (s *Surface) stageInputRects(rects []Rect) (bool, error) { | |||
| 728 | 736 | // children before parents. It is safe to call more than once. The caller must | |
| 729 | 737 | // stop using buffers it imported first (the compositor keeps them until it | |
| 730 | 738 | // processes the destruction). | |
| 739 | + | // | |
| 740 | + | // xdg-shell requires the topmost popup to go first: Close fails, and destroys | |
| 741 | + | // nothing, while popups created on this surface are still open. Close them | |
| 742 | + | // first. | |
| 731 | 743 | func (s *Surface) Close() error { | |
| 732 | 744 | if s == nil || s.closed { | |
| 733 | 745 | return nil | |
| @@ -736,6 +748,9 @@ func (s *Surface) Close() error { | |||
| 736 | 748 | s.closed = true | |
| 737 | 749 | return nil | |
| 738 | 750 | } | |
| 751 | + | if s.children > 0 { | |
| 752 | + | return fmt.Errorf("neferclient: surface %d still has %d open popup(s): close them first", s.id, s.children) | |
| 753 | + | } | |
| 739 | 754 | return s.destroy() | |
| 740 | 755 | } | |
| 741 | 756 | ||
| @@ -743,6 +758,10 @@ func (s *Surface) destroy() error { | |||
| 743 | 758 | s.closed = true | |
| 744 | 759 | delete(s.c.surfaces, s.id) | |
| 745 | 760 | s.c.seatSurfaceGone(s) | |
| 761 | + | if s.parent != nil { | |
| 762 | + | s.parent.children-- | |
| 763 | + | s.parent = nil | |
| 764 | + | } | |
| 746 | 765 | var errs []error | |
| 747 | 766 | add := func(err error) { | |
| 748 | 767 | if err != nil { | |
| @@ -772,6 +791,9 @@ func (s *Surface) destroy() error { | |||
| 772 | 791 | if s.toplevel != nil { | |
| 773 | 792 | add(s.toplevel.Destroy()) | |
| 774 | 793 | } | |
| 794 | + | if s.popup != nil { | |
| 795 | + | add(s.popup.Destroy()) | |
| 796 | + | } | |
| 775 | 797 | if s.xdg != nil { | |
| 776 | 798 | add(s.xdg.Destroy()) | |
| 777 | 799 | } | |
| @@ -83,6 +83,7 @@ const ( | |||||
| 83 | roleToplevel surfaceRole = iota + 1 | 83 | roleToplevel surfaceRole = iota + 1 | ||
| 84 | roleLayer | 84 | roleLayer | ||
| 85 | roleLock | 85 | roleLock | ||
| 86 | + | rolePopup | |||
| 86 | ) | 87 | ) | ||
| 87 | 88 | ||||
| 88 | // Surface is one wl_surface with its role and the DMA-BUF presentation | 89 | // Surface is one wl_surface with its role and the DMA-BUF presentation | ||
| @@ -99,10 +100,15 @@ type Surface struct { | |||||
| 99 | feedback *linuxdmabuf.LinuxDmabufFeedback | 100 | feedback *linuxdmabuf.LinuxDmabufFeedback | ||
| 100 | xdg *xdgshell.XdgSurface | 101 | xdg *xdgshell.XdgSurface | ||
| 101 | toplevel *xdgshell.XdgToplevel | 102 | toplevel *xdgshell.XdgToplevel | ||
| 103 | + | popup *xdgshell.XdgPopup | |||
| 102 | layer *wlrlayershell.LayerSurface | 104 | layer *wlrlayershell.LayerSurface | ||
| 103 | lockSurf *extsessionlock.ExtSessionLockSurface | 105 | lockSurf *extsessionlock.ExtSessionLockSurface | ||
| 104 | lock *Lock | 106 | lock *Lock | ||
| 105 | 107 | ||||
| 108 | + | parent *Surface // popups: the surface they were created on | |||
| 109 | + | children int // open popups whose parent this surface is | |||
| 110 | + | repoToken uint32 // popups: last xdg_popup.reposition token | |||
| 111 | + | ||||
| 106 | width, height int32 | 112 | width, height int32 | ||
| 107 | scale float64 | 113 | scale float64 | ||
| 108 | pendW, pendH int32 // toplevel size announced before the xdg configure | 114 | pendW, pendH int32 // toplevel size announced before the xdg configure | ||
| @@ -564,8 +570,10 @@ func (c *Conn) applySurface(ev *event, h Handler) { | |||||
| 564 | h.Error(errors.New(badEventErrors[ev.a])) | 570 | h.Error(errors.New(badEventErrors[ev.a])) | ||
| 565 | case evClosed: | 571 | case evClosed: | ||
| 566 | h.Closed(id) | 572 | h.Closed(id) | ||
| 567 | - | case evXdgToplevel: | 573 | + | case evXdgToplevel, evPopupConfigure: |
| 568 | s.pendW, s.pendH = ev.a, ev.b | 574 | s.pendW, s.pendH = ev.a, ev.b | ||
| 575 | + | case evPopupDone: | |||
| 576 | + | h.PopupDone(id) | |||
| 569 | case evXdgConfigure: | 577 | case evXdgConfigure: | ||
| 570 | if s.pendW > 0 && s.pendH > 0 { | 578 | if s.pendW > 0 && s.pendH > 0 { | ||
| 571 | s.width, s.height = s.pendW, s.pendH | 579 | s.width, s.height = s.pendW, s.pendH | ||
| @@ -728,6 +736,10 @@ func (s *Surface) stageInputRects(rects []Rect) (bool, error) { | |||||
| 728 | // children before parents. It is safe to call more than once. The caller must | 736 | // children before parents. It is safe to call more than once. The caller must | ||
| 729 | // stop using buffers it imported first (the compositor keeps them until it | 737 | // stop using buffers it imported first (the compositor keeps them until it | ||
| 730 | // processes the destruction). | 738 | // processes the destruction). | ||
| 739 | + | // | |||
| 740 | + | // xdg-shell requires the topmost popup to go first: Close fails, and destroys | |||
| 741 | + | // nothing, while popups created on this surface are still open. Close them | |||
| 742 | + | // first. | |||
| 731 | func (s *Surface) Close() error { | 743 | func (s *Surface) Close() error { | ||
| 732 | if s == nil || s.closed { | 744 | if s == nil || s.closed { | ||
| 733 | return nil | 745 | return nil | ||
| @@ -736,6 +748,9 @@ func (s *Surface) Close() error { | |||||
| 736 | s.closed = true | 748 | s.closed = true | ||
| 737 | return nil | 749 | return nil | ||
| 738 | } | 750 | } | ||
| 751 | + | if s.children > 0 { | |||
| 752 | + | return fmt.Errorf("neferclient: surface %d still has %d open popup(s): close them first", s.id, s.children) | |||
| 753 | + | } | |||
| 739 | return s.destroy() | 754 | return s.destroy() | ||
| 740 | } | 755 | } | ||
| 741 | 756 | ||||
| @@ -743,6 +758,10 @@ func (s *Surface) destroy() error { | |||||
| 743 | s.closed = true | 758 | s.closed = true | ||
| 744 | delete(s.c.surfaces, s.id) | 759 | delete(s.c.surfaces, s.id) | ||
| 745 | s.c.seatSurfaceGone(s) | 760 | s.c.seatSurfaceGone(s) | ||
| 761 | + | if s.parent != nil { | |||
| 762 | + | s.parent.children-- | |||
| 763 | + | s.parent = nil | |||
| 764 | + | } | |||
| 746 | var errs []error | 765 | var errs []error | ||
| 747 | add := func(err error) { | 766 | add := func(err error) { | ||
| 748 | if err != nil { | 767 | if err != nil { | ||
| @@ -772,6 +791,9 @@ func (s *Surface) destroy() error { | |||||
| 772 | if s.toplevel != nil { | 791 | if s.toplevel != nil { | ||
| 773 | add(s.toplevel.Destroy()) | 792 | add(s.toplevel.Destroy()) | ||
| 774 | } | 793 | } | ||
| 794 | + | if s.popup != nil { | |||
| 795 | + | add(s.popup.Destroy()) | |||
| 796 | + | } | |||
| 775 | if s.xdg != nil { | 797 | if s.xdg != nil { | ||
| 776 | add(s.xdg.Destroy()) | 798 | add(s.xdg.Destroy()) | ||
| 777 | } | 799 | } | ||