detached b814107

feat(surface): xdg popups with grab and reposition

commit
b8141074d34f010405b2be134bd32e6bcc20f20e
author
brice · · 2026-10-09 03:37 UTC
parent
4cefc9a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +758 -4
M globals.go +3 -2
@@ -38,6 +38,7 @@ type globals struct {
3838 fscale *fractionalscale.WpFractionalScaleManager
3939
4040 layerVersion uint32
41+ xdgVersion uint32
4142 triedViewport bool
4243 triedFScale bool
4344 }
@@ -101,7 +102,7 @@ func (c *Conn) ensureXdg() error {
101102 return nil
102103 }
103104 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 {
105106 x := xdgshell.NewXdgWmBase(ctx)
106107 x.OnPing(func(serial uint32) {
107108 e := event{kind: evPing, serial: serial}
@@ -109,7 +110,7 @@ func (c *Conn) ensureXdg() error {
109110 })
110111 return x
111112 })
112- c.g.xdg = x
113+ c.g.xdg, c.g.xdgVersion = x, v
113114 return err
114115 }
115116
@@ -38,6 +38,7 @@ type globals struct {
38 fscale *fractionalscale.WpFractionalScaleManager38 fscale *fractionalscale.WpFractionalScaleManager
39 39
40 layerVersion uint3240 layerVersion uint32
41+ xdgVersion uint32
41 triedViewport bool42 triedViewport bool
42 triedFScale bool43 triedFScale bool
43 }44 }
@@ -101,7 +102,7 @@ func (c *Conn) ensureXdg() error {
101 return nil102 return nil
102 }103 }
103 q := c.q104 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 x111 return x
111 })112 })
112- c.g.xdg = x113+ c.g.xdg, c.g.xdgVersion = x, v
113 return err114 return err
114 }115 }
115 116
M handler.go +5 -0
@@ -40,6 +40,10 @@ type Handler interface {
4040 // layer surface closed). Call [Surface.Close]; nothing else happens
4141 // automatically.
4242 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)
4347 // FeedbackDone reports that a complete linux-dmabuf feedback round was
4448 // applied; [Surface.Feedback] holds the new main device and formats. It
4549 // is sent again whenever the compositor changes its preference.
@@ -87,6 +91,7 @@ func (NopHandler) Configure(SurfaceID, int32, int32) {}
8791 func (NopHandler) Scale(SurfaceID, float64) {}
8892 func (NopHandler) Frame(SurfaceID) {}
8993 func (NopHandler) Closed(SurfaceID) {}
94+func (NopHandler) PopupDone(SurfaceID) {}
9095 func (NopHandler) FeedbackDone(SurfaceID) {}
9196 func (NopHandler) Locked() {}
9297 func (NopHandler) LockFinished() {}
@@ -40,6 +40,10 @@ type Handler interface {
40 // layer surface closed). Call [Surface.Close]; nothing else happens40 // 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 was47 // FeedbackDone reports that a complete linux-dmabuf feedback round was
44 // applied; [Surface.Feedback] holds the new main device and formats. It48 // 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
568568 return _c
569569 }
570570
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+
571611 // Scale provides a mock function for the type MockHandler
572612 func (_mock *MockHandler) Scale(id SurfaceID, scale float64) {
573613 _mock.Called(id, scale)
@@ -568,6 +568,46 @@ func (_c *MockHandler_Pointer_Call) RunAndReturn(run func(ev *PointerEvent)) *Mo
568 return _c568 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 MockHandler611 // 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) {
228228 var frames int
229229 hd := neferclientmocks.NewMockHandler(t)
230230 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()
232232 hd.EXPECT().Scale(s.ID(), mock.Anything).Return().Maybe()
233233 hd.EXPECT().Frame(s.ID()).Run(func(neferclient.SurfaceID) { frames++ }).Return().Maybe()
234234 dispatchUntil(t, c, hd, func() bool { return configured && feedback })
@@ -270,3 +270,96 @@ func TestHeadlessReconfigureWhilePending(t *testing.T) {
270270 require.NoError(t, c.Roundtrip())
271271 require.NoError(t, c.Dispatch(hd))
272272 }
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 int228 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
569569 return _c
570570 }
571571
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+
572612 // Scale provides a mock function for the type MockHandler
573613 func (_mock *MockHandler) Scale(id neferclient.SurfaceID, scale float64) {
574614 _mock.Called(id, scale)
@@ -569,6 +569,46 @@ func (_c *MockHandler_Pointer_Call) RunAndReturn(run func(ev *neferclient.Pointe
569 return _c569 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 MockHandler612 // 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 (
3838 evFeedbackDone // id
3939 evLocked // id = lock generation
4040 evLockFinished // id = lock generation
41+ evPopupConfigure // id, a = width, b = height (before the matching evXdgConfigure)
42+ evPopupDone // id
4143 evBadEvent // id, a = badEvent code: the reader met a malformed event
4244
4345 // Seat events (kind >= evSeatCaps). Pointer and keyboard events carry the
@@ -38,6 +38,8 @@ const (
38 evFeedbackDone // id38 evFeedbackDone // id
39 evLocked // id = lock generation39 evLocked // id = lock generation
40 evLockFinished // id = lock generation40 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 event43 evBadEvent // id, a = badEvent code: the reader met a malformed event
42 44
43 // Seat events (kind >= evSeatCaps). Pointer and keyboard events carry the45 // Seat events (kind >= evSeatCaps). Pointer and keyboard events carry the
M surface.go +23 -1
@@ -83,6 +83,7 @@ const (
8383 roleToplevel surfaceRole = iota + 1
8484 roleLayer
8585 roleLock
86+ rolePopup
8687 )
8788
8889 // Surface is one wl_surface with its role and the DMA-BUF presentation
@@ -99,10 +100,15 @@ type Surface struct {
99100 feedback *linuxdmabuf.LinuxDmabufFeedback
100101 xdg *xdgshell.XdgSurface
101102 toplevel *xdgshell.XdgToplevel
103+ popup *xdgshell.XdgPopup
102104 layer *wlrlayershell.LayerSurface
103105 lockSurf *extsessionlock.ExtSessionLockSurface
104106 lock *Lock
105107
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+
106112 width, height int32
107113 scale float64
108114 pendW, pendH int32 // toplevel size announced before the xdg configure
@@ -564,8 +570,10 @@ func (c *Conn) applySurface(ev *event, h Handler) {
564570 h.Error(errors.New(badEventErrors[ev.a]))
565571 case evClosed:
566572 h.Closed(id)
567- case evXdgToplevel:
573+ case evXdgToplevel, evPopupConfigure:
568574 s.pendW, s.pendH = ev.a, ev.b
575+ case evPopupDone:
576+ h.PopupDone(id)
569577 case evXdgConfigure:
570578 if s.pendW > 0 && s.pendH > 0 {
571579 s.width, s.height = s.pendW, s.pendH
@@ -728,6 +736,10 @@ func (s *Surface) stageInputRects(rects []Rect) (bool, error) {
728736 // children before parents. It is safe to call more than once. The caller must
729737 // stop using buffers it imported first (the compositor keeps them until it
730738 // 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.
731743 func (s *Surface) Close() error {
732744 if s == nil || s.closed {
733745 return nil
@@ -736,6 +748,9 @@ func (s *Surface) Close() error {
736748 s.closed = true
737749 return nil
738750 }
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+ }
739754 return s.destroy()
740755 }
741756
@@ -743,6 +758,10 @@ func (s *Surface) destroy() error {
743758 s.closed = true
744759 delete(s.c.surfaces, s.id)
745760 s.c.seatSurfaceGone(s)
761+ if s.parent != nil {
762+ s.parent.children--
763+ s.parent = nil
764+ }
746765 var errs []error
747766 add := func(err error) {
748767 if err != nil {
@@ -772,6 +791,9 @@ func (s *Surface) destroy() error {
772791 if s.toplevel != nil {
773792 add(s.toplevel.Destroy())
774793 }
794+ if s.popup != nil {
795+ add(s.popup.Destroy())
796+ }
775797 if s.xdg != nil {
776798 add(s.xdg.Destroy())
777799 }
@@ -83,6 +83,7 @@ const (
83 roleToplevel surfaceRole = iota + 183 roleToplevel surfaceRole = iota + 1
84 roleLayer84 roleLayer
85 roleLock85 roleLock
86+ rolePopup
86 )87 )
87 88
88 // Surface is one wl_surface with its role and the DMA-BUF presentation89 // Surface is one wl_surface with its role and the DMA-BUF presentation
@@ -99,10 +100,15 @@ type Surface struct {
99 feedback *linuxdmabuf.LinuxDmabufFeedback100 feedback *linuxdmabuf.LinuxDmabufFeedback
100 xdg *xdgshell.XdgSurface101 xdg *xdgshell.XdgSurface
101 toplevel *xdgshell.XdgToplevel102 toplevel *xdgshell.XdgToplevel
103+ popup *xdgshell.XdgPopup
102 layer *wlrlayershell.LayerSurface104 layer *wlrlayershell.LayerSurface
103 lockSurf *extsessionlock.ExtSessionLockSurface105 lockSurf *extsessionlock.ExtSessionLockSurface
104 lock *Lock106 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 int32112 width, height int32
107 scale float64113 scale float64
108 pendW, pendH int32 // toplevel size announced before the xdg configure114 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.b574 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.pendH579 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 must736 // 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 it737 // 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 nil745 return nil
@@ -736,6 +748,9 @@ func (s *Surface) Close() error {
736 s.closed = true748 s.closed = true
737 return nil749 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 = true758 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 []error765 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 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 4

↑↓ moveenter openesc close