detached 5f1ad00

Merge pull request #3: feat(surface): xdg popups with grab and reposition

commit
5f1ad008a71d407e4441b333311ec219cd351589
author
bnema · · 2026-10-09 04:11 UTC
merge of
2df0761 + f9fb2ee · diff against the first parent
signature
unsigned
refs
tag: v0.4.0
16 files · +1050 -10
M .github/workflows/ci.yml +7 -2
@@ -33,8 +33,13 @@ jobs:
3333 git config --global --add safe.directory "$GITHUB_WORKSPACE"
3434 install -d -m 700 "$XDG_RUNTIME_DIR"
3535 - run: |
36- go install github.com/vektra/mockery/v3@v3.8.0
37- go install honnef.co/go/tools/cmd/staticcheck@latest
36+ # Build the tools against the latest x/tools so they can read the
37+ # export data of the Go release installed above.
38+ cd "$(mktemp -d)"
39+ go mod init tools
40+ go get -tool github.com/vektra/mockery/v3@v3.8.0 honnef.co/go/tools/cmd/staticcheck@latest
41+ go get golang.org/x/tools@latest
42+ go install tool
3843 echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
3944 - run: make check
4045 - run: make race
@@ -33,8 +33,13 @@ jobs:
33 git config --global --add safe.directory "$GITHUB_WORKSPACE"33 git config --global --add safe.directory "$GITHUB_WORKSPACE"
34 install -d -m 700 "$XDG_RUNTIME_DIR"34 install -d -m 700 "$XDG_RUNTIME_DIR"
35 - run: |35 - run: |
36- go install github.com/vektra/mockery/v3@v3.8.036+ # Build the tools against the latest x/tools so they can read the
37- go install honnef.co/go/tools/cmd/staticcheck@latest37+ # export data of the Go release installed above.
38+ cd "$(mktemp -d)"
39+ go mod init tools
40+ go get -tool github.com/vektra/mockery/v3@v3.8.0 honnef.co/go/tools/cmd/staticcheck@latest
41+ go get golang.org/x/tools@latest
42+ go install tool
38 echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"43 echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
39 - run: make check44 - run: make check
40 - run: make race45 - run: make race
M README.md +36 -0
@@ -38,6 +38,42 @@ for {
3838 }
3939 ```
4040
41+### Popups
42+
43+`Conn.NewPopup` creates an `xdg_popup` on a layer surface, a toplevel or another popup: a menu or tooltip placed relative to an anchor rectangle of its parent. With `Grab` the popup takes the keyboard and the compositor dismisses it on a click outside; the grab needs the `PointerEvent.Serial` of the button press that opened it. The compositor can dismiss a popup at any time, so a handler must implement `PopupDone` (embedded `NopHandler` ignores it) and close the popup; close popups before their parent.
44+
45+```go
46+func (h *handler) Pointer(ev *neferclient.PointerEvent) {
47+ if ev.Kind != neferclient.PointerButton || !ev.Pressed {
48+ return
49+ }
50+ popup, err := h.conn.NewPopup(neferclient.PopupConfig{
51+ Parent: h.bar, // a layer surface
52+ Place: neferclient.PopupPlacement{
53+ Anchor: neferclient.Rect{X: 100, Y: 0, Width: 24, Height: 30}, // on the parent, logical px
54+ Edge: neferclient.EdgeBottom, Gravity: neferclient.EdgeBottomRight,
55+ Adjust: neferclient.AdjustSlideX | neferclient.AdjustFlipY,
56+ Width: 200, Height: 120,
57+ },
58+ Grab: true, Serial: ev.Serial,
59+ })
60+ if err != nil {
61+ h.err = err
62+ return
63+ }
64+ h.popup = popup // presentable after Configure, like any surface
65+}
66+
67+func (h *handler) PopupDone(id neferclient.SurfaceID) {
68+ if h.popup != nil && h.popup.ID() == id {
69+ h.err = h.popup.Close()
70+ h.popup = nil
71+ }
72+}
73+```
74+
75+`Surface.Reposition` moves a mapped popup (`xdg_wm_base` version 3).
76+
4177 - [`examples/layer`](examples/layer): a layer surface with a counter button.
4278 - [`examples/lock`](examples/lock): a session lock with a masked password field. It is a demo and authenticates nothing; read its header before running it.
4379
@@ -38,6 +38,42 @@ for {
38 }38 }
39 ```39 ```
40 40
41+### Popups
42+
43+`Conn.NewPopup` creates an `xdg_popup` on a layer surface, a toplevel or another popup: a menu or tooltip placed relative to an anchor rectangle of its parent. With `Grab` the popup takes the keyboard and the compositor dismisses it on a click outside; the grab needs the `PointerEvent.Serial` of the button press that opened it. The compositor can dismiss a popup at any time, so a handler must implement `PopupDone` (embedded `NopHandler` ignores it) and close the popup; close popups before their parent.
44+
45+```go
46+func (h *handler) Pointer(ev *neferclient.PointerEvent) {
47+ if ev.Kind != neferclient.PointerButton || !ev.Pressed {
48+ return
49+ }
50+ popup, err := h.conn.NewPopup(neferclient.PopupConfig{
51+ Parent: h.bar, // a layer surface
52+ Place: neferclient.PopupPlacement{
53+ Anchor: neferclient.Rect{X: 100, Y: 0, Width: 24, Height: 30}, // on the parent, logical px
54+ Edge: neferclient.EdgeBottom, Gravity: neferclient.EdgeBottomRight,
55+ Adjust: neferclient.AdjustSlideX | neferclient.AdjustFlipY,
56+ Width: 200, Height: 120,
57+ },
58+ Grab: true, Serial: ev.Serial,
59+ })
60+ if err != nil {
61+ h.err = err
62+ return
63+ }
64+ h.popup = popup // presentable after Configure, like any surface
65+}
66+
67+func (h *handler) PopupDone(id neferclient.SurfaceID) {
68+ if h.popup != nil && h.popup.ID() == id {
69+ h.err = h.popup.Close()
70+ h.popup = nil
71+ }
72+}
73+```
74+
75+`Surface.Reposition` moves a mapped popup (`xdg_wm_base` version 3).
76+
41 - [`examples/layer`](examples/layer): a layer surface with a counter button.77 - [`examples/layer`](examples/layer): a layer surface with a counter button.
42 - [`examples/lock`](examples/lock): a session lock with a masked password field. It is a demo and authenticates nothing; read its header before running it.78 - [`examples/lock`](examples/lock): a session lock with a masked password field. It is a demo and authenticates nothing; read its header before running it.
43 79
M alloc_test.go +50 -0
@@ -18,6 +18,7 @@ type countingHandler struct {
1818 neferclient.NopHandler
1919 fdReady, errs, frames int
2020 keys, pointers int
21+ configures, popupDone int
2122 lastText string // set only by the allocation-free comparison below
2223 }
2324
@@ -25,6 +26,9 @@ func (h *countingHandler) FDReady(uint64) { h.fdReady++ }
2526 func (h *countingHandler) Error(error) { h.errs++ }
2627 func (h *countingHandler) Frame(neferclient.SurfaceID) { h.frames++ }
2728
29+func (h *countingHandler) Configure(neferclient.SurfaceID, int32, int32) { h.configures++ }
30+func (h *countingHandler) PopupDone(neferclient.SurfaceID) { h.popupDone++ }
31+
2832 func (h *countingHandler) Pointer(*neferclient.PointerEvent) { h.pointers++ }
2933
3034 // Key records the text through a comparison that does not allocate: the
@@ -160,3 +164,49 @@ func TestAllocPresent(t *testing.T) {
160164 require.Positive(t, h.frames)
161165 require.Zero(t, h.errs)
162166 }
167+
168+// TestAllocPopupEvents measures the popup events in steady state: the
169+// xdg_popup configure (with its xdg_surface configure and the ack request it
170+// triggers) and popup_done.
171+func TestAllocPopupEvents(t *testing.T) {
172+ skipUnderRace(t)
173+ e := newPopupEnv(t, popupGlobals)
174+ p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place, Grab: true, Serial: 3})
175+ require.NoError(t, err)
176+ require.NoError(t, e.c.Roundtrip())
177+ xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID())
178+ _, gp := e.getPopup(t, xdg)
179+ popup := gp.u32(0)
180+ e.srv.mu.Lock()
181+ e.srv.hook = func(uint32, uint16, []byte) {} // parse and discard, as the real peer's requests would be
182+ e.srv.mu.Unlock()
183+
184+ const rounds = 40
185+ var batch []byte
186+ for i := range rounds {
187+ batch = append(batch, frame(popup, 0, appendU32(appendU32(appendU32(appendU32(nil, 0), 0), 60), 40))...)
188+ batch = append(batch, frame(xdg, 0, appendU32(nil, uint32(i+1)))...)
189+ batch = append(batch, frame(popup, 1, nil)...) // popup_done
190+ }
191+ h := &countingHandler{}
192+ step := func() {
193+ if _, err := e.srv.conn.Write(batch); err != nil {
194+ t.Fatal(err)
195+ }
196+ for e.c.QueueLen() < 3*rounds {
197+ runtime.Gosched()
198+ }
199+ if err := e.c.Dispatch(h); err != nil {
200+ t.Fatal(err)
201+ }
202+ }
203+ step()
204+ step()
205+ before := h.popupDone
206+ allocs := testing.AllocsPerRun(20, step)
207+ t.Logf("allocs per Dispatch of %d popup events: %v", 3*rounds, allocs)
208+ require.Zero(t, allocs)
209+ require.Greater(t, h.popupDone, before)
210+ require.Positive(t, h.configures)
211+ require.Zero(t, h.errs)
212+}
@@ -18,6 +18,7 @@ type countingHandler struct {
18 neferclient.NopHandler18 neferclient.NopHandler
19 fdReady, errs, frames int19 fdReady, errs, frames int
20 keys, pointers int20 keys, pointers int
21+ configures, popupDone int
21 lastText string // set only by the allocation-free comparison below22 lastText string // set only by the allocation-free comparison below
22 }23 }
23 24
@@ -25,6 +26,9 @@ func (h *countingHandler) FDReady(uint64) { h.fdReady++ }
25 func (h *countingHandler) Error(error) { h.errs++ }26 func (h *countingHandler) Error(error) { h.errs++ }
26 func (h *countingHandler) Frame(neferclient.SurfaceID) { h.frames++ }27 func (h *countingHandler) Frame(neferclient.SurfaceID) { h.frames++ }
27 28
29+func (h *countingHandler) Configure(neferclient.SurfaceID, int32, int32) { h.configures++ }
30+func (h *countingHandler) PopupDone(neferclient.SurfaceID) { h.popupDone++ }
31+
28 func (h *countingHandler) Pointer(*neferclient.PointerEvent) { h.pointers++ }32 func (h *countingHandler) Pointer(*neferclient.PointerEvent) { h.pointers++ }
29 33
30 // Key records the text through a comparison that does not allocate: the34 // Key records the text through a comparison that does not allocate: the
@@ -160,3 +164,49 @@ func TestAllocPresent(t *testing.T) {
160 require.Positive(t, h.frames)164 require.Positive(t, h.frames)
161 require.Zero(t, h.errs)165 require.Zero(t, h.errs)
162 }166 }
167+
168+// TestAllocPopupEvents measures the popup events in steady state: the
169+// xdg_popup configure (with its xdg_surface configure and the ack request it
170+// triggers) and popup_done.
171+func TestAllocPopupEvents(t *testing.T) {
172+ skipUnderRace(t)
173+ e := newPopupEnv(t, popupGlobals)
174+ p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place, Grab: true, Serial: 3})
175+ require.NoError(t, err)
176+ require.NoError(t, e.c.Roundtrip())
177+ xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID())
178+ _, gp := e.getPopup(t, xdg)
179+ popup := gp.u32(0)
180+ e.srv.mu.Lock()
181+ e.srv.hook = func(uint32, uint16, []byte) {} // parse and discard, as the real peer's requests would be
182+ e.srv.mu.Unlock()
183+
184+ const rounds = 40
185+ var batch []byte
186+ for i := range rounds {
187+ batch = append(batch, frame(popup, 0, appendU32(appendU32(appendU32(appendU32(nil, 0), 0), 60), 40))...)
188+ batch = append(batch, frame(xdg, 0, appendU32(nil, uint32(i+1)))...)
189+ batch = append(batch, frame(popup, 1, nil)...) // popup_done
190+ }
191+ h := &countingHandler{}
192+ step := func() {
193+ if _, err := e.srv.conn.Write(batch); err != nil {
194+ t.Fatal(err)
195+ }
196+ for e.c.QueueLen() < 3*rounds {
197+ runtime.Gosched()
198+ }
199+ if err := e.c.Dispatch(h); err != nil {
200+ t.Fatal(err)
201+ }
202+ }
203+ step()
204+ step()
205+ before := h.popupDone
206+ allocs := testing.AllocsPerRun(20, step)
207+ t.Logf("allocs per Dispatch of %d popup events: %v", 3*rounds, allocs)
208+ require.Zero(t, allocs)
209+ require.Greater(t, h.popupDone, before)
210+ require.Positive(t, h.configures)
211+ require.Zero(t, h.errs)
212+}
M conn.go +2 -1
@@ -83,7 +83,8 @@ type Conn struct {
8383 curLock *Lock // the live session lock, if any
8484 tableBuf []byte
8585 seat *Seat
86- seatErr error // a seat bind failure, reported by the next Dispatch
86+ seatErr error // a seat bind failure, reported by the next Dispatch
87+ grabTop *Surface // the topmost popup of the open grab chain, nil without one
8788
8889 ready bool // Connect finished; announcements go to the Handler
8990 dispatching bool
@@ -83,7 +83,8 @@ type Conn struct {
83 curLock *Lock // the live session lock, if any83 curLock *Lock // the live session lock, if any
84 tableBuf []byte84 tableBuf []byte
85 seat *Seat85 seat *Seat
86- seatErr error // a seat bind failure, reported by the next Dispatch86+ seatErr error // a seat bind failure, reported by the next Dispatch
87+ grabTop *Surface // the topmost popup of the open grab chain, nil without one
87 88
88 ready bool // Connect finished; announcements go to the Handler89 ready bool // Connect finished; announcements go to the Handler
89 dispatching bool90 dispatching bool
M export_test.go +3 -0
@@ -39,3 +39,6 @@ func (s *Surface) MarkPresented() { s.presented = true }
3939
4040 // DropFrameCallback marks the pending frame callback as answered.
4141 func (s *Surface) PendingFrame() bool { return !s.frameReady }
42+
43+// SetSeatErr stands in for a failed wl_seat bind.
44+func (c *Conn) SetSeatErr(err error) { c.seatErr = err }
@@ -39,3 +39,6 @@ func (s *Surface) MarkPresented() { s.presented = true }
39 39
40 // DropFrameCallback marks the pending frame callback as answered.40 // DropFrameCallback marks the pending frame callback as answered.
41 func (s *Surface) PendingFrame() bool { return !s.frameReady }41 func (s *Surface) PendingFrame() bool { return !s.frameReady }
42+
43+// SetSeatErr stands in for a failed wl_seat bind.
44+func (c *Conn) SetSeatErr(err error) { c.seatErr = err }
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 +93 -0
@@ -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+}
@@ -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 +264 -0
@@ -0,0 +1,264 @@
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+ return nil, errors.Join(e, pos.Destroy())
99+ }
100+ }
101+ return pos, nil
102+}
103+
104+// NewPopup creates an xdg_popup on a layer surface, a toplevel or another
105+// popup. It becomes presentable after the first Handler.Configure, which
106+// reports the size the compositor gave it. Handle [Handler.PopupDone]: the
107+// compositor dismisses the popup on an outside click or when the grab is
108+// denied, and the popup must then be closed.
109+//
110+// The parent must be mapped: configured by the compositor and, for the
111+// compositor to place the popup, presented at least once. NewPopup returns an
112+// error for a parent that has not been configured yet; a compositor dismisses
113+// (PopupDone) a popup whose parent shows nothing.
114+//
115+// A grab needs a seat and the serial of the user action. Its parent must be a
116+// toplevel or layer surface, or the grabbing popup that is the topmost of the
117+// connection's grab chain; only one chain exists at a time. Other grabs return
118+// an error before any request is sent (xdg-shell would raise a protocol error).
119+//
120+// Close popups before their parent: Close of a surface with open child popups
121+// fails.
122+func (c *Conn) NewPopup(cfg PopupConfig) (*Surface, error) {
123+ if c.closed {
124+ return nil, ErrClosed
125+ }
126+ parent := cfg.Parent
127+ switch {
128+ case parent == nil || parent.c != c:
129+ return nil, errors.New("neferclient: popup needs a parent surface of this connection")
130+ case parent.closed:
131+ return nil, fmt.Errorf("neferclient: popup parent: %w", ErrClosed)
132+ case parent.role != roleLayer && parent.role != roleToplevel && parent.role != rolePopup:
133+ return nil, errors.New("neferclient: popup parent must be a layer surface, a toplevel or a popup")
134+ case !parent.configured:
135+ return nil, errors.New("neferclient: popup parent is not configured yet: wait for its first Handler.Configure")
136+ }
137+ if err := validatePlacement(&cfg.Place); err != nil {
138+ return nil, err
139+ }
140+ if cfg.Grab {
141+ if cfg.Serial == 0 {
142+ return nil, errors.New("neferclient: popup grab needs the serial of the triggering input event")
143+ }
144+ c.ensureSeat()
145+ if c.seat == nil || c.seat.proxy == nil {
146+ cause := c.seatErr
147+ if cause == nil {
148+ cause = errors.New("popup grab needs a seat")
149+ }
150+ return nil, &CapabilityError{Name: "wl_seat", Cause: cause}
151+ }
152+ if parent.role == rolePopup && !parent.grabbed {
153+ return nil, errors.New("neferclient: popup grab needs a grabbing parent popup")
154+ }
155+ if c.grabTop != nil && parent != c.grabTop {
156+ return nil, errors.New("neferclient: popup grab needs the topmost grabbing popup as parent while a grab is open")
157+ }
158+ }
159+ if err := c.ensureXdg(); err != nil {
160+ return nil, err
161+ }
162+ c.pause()
163+ defer c.resume()
164+ s, err := c.newSurface(rolePopup, cfg.Place.Width, cfg.Place.Height)
165+ if err != nil {
166+ return nil, err
167+ }
168+ ok := false
169+ defer func() {
170+ if !ok {
171+ s.destroy()
172+ }
173+ }()
174+ if s.xdg, err = c.g.xdg.GetXdgSurface(s.surf); err != nil {
175+ return nil, err
176+ }
177+ pos, err := c.newPositioner(&cfg.Place)
178+ if err != nil {
179+ return nil, err
180+ }
181+ var parentXdg *xdgshell.XdgSurface
182+ if parent.role != roleLayer {
183+ parentXdg = parent.xdg
184+ }
185+ s.popup, err = s.xdg.GetPopup(parentXdg, pos)
186+ // The compositor copies the state at get_popup.
187+ if err = errors.Join(err, pos.Destroy()); err != nil {
188+ return nil, err
189+ }
190+ if parent.role == roleLayer {
191+ if err = parent.layer.GetPopup(s.popup); err != nil {
192+ return nil, err
193+ }
194+ }
195+ q, sid := c.q, uint32(s.id)
196+ s.popup.OnConfigure(func(_, _, w, h int32) {
197+ e := event{kind: evPopupConfigure, id: sid, a: w, b: h}
198+ if w < 0 || h < 0 || w > maxLayerDimension || h > maxLayerDimension {
199+ e = event{kind: evBadEvent, id: sid, a: badConfigure}
200+ }
201+ q.post(&e)
202+ })
203+ s.popup.OnPopupDone(func() {
204+ e := event{kind: evPopupDone, id: sid}
205+ q.post(&e)
206+ })
207+ s.xdg.OnConfigure(func(serial uint32) {
208+ e := event{kind: evXdgConfigure, id: sid, serial: serial}
209+ q.post(&e)
210+ })
211+ if cfg.Grab {
212+ if err = s.popup.Grab(c.seat.proxy, cfg.Serial); err != nil {
213+ return nil, err
214+ }
215+ s.grabbed, c.grabTop = true, s
216+ }
217+ if err = s.finishSurface(); err != nil {
218+ return nil, err
219+ }
220+ if err = s.surf.Commit(); err != nil {
221+ return nil, err
222+ }
223+ s.parent = parent
224+ parent.children++
225+ ok = true
226+ return s, nil
227+}
228+
229+// Reposition asks the compositor to place the popup again. The following
230+// Handler.Configure reports the resulting size. It needs xdg_wm_base version 3.
231+func (s *Surface) Reposition(p PopupPlacement) error {
232+ switch {
233+ case s.closed || s.c.closed:
234+ return ErrClosed
235+ case s.role != rolePopup:
236+ return errors.New("neferclient: Reposition needs a popup")
237+ case s.c.g.xdgVersion < 3:
238+ return &CapabilityError{Name: xdgshell.XdgWmBaseInterface, Cause: fmt.Errorf("reposition requires version 3, negotiated %d", s.c.g.xdgVersion)}
239+ }
240+ if err := validatePlacement(&p); err != nil {
241+ return err
242+ }
243+ pos, err := s.c.newPositioner(&p)
244+ if err != nil {
245+ return err
246+ }
247+ s.repoToken++
248+ return errors.Join(s.popup.Reposition(pos, s.repoToken), pos.Destroy())
249+}
250+
251+// dropGrab ends the surface's grab: the topmost grabbing popup becomes its
252+// parent, if that one still grabs.
253+func (s *Surface) dropGrab() {
254+ if !s.grabbed {
255+ return
256+ }
257+ s.grabbed = false
258+ if c := s.c; c.grabTop == s {
259+ c.grabTop = nil
260+ if p := s.parent; p != nil && p.grabbed {
261+ c.grabTop = p
262+ }
263+ }
264+}
@@ -0,0 +1,264 @@
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+ return nil, errors.Join(e, pos.Destroy())
99+ }
100+ }
101+ return pos, nil
102+}
103+
104+// NewPopup creates an xdg_popup on a layer surface, a toplevel or another
105+// popup. It becomes presentable after the first Handler.Configure, which
106+// reports the size the compositor gave it. Handle [Handler.PopupDone]: the
107+// compositor dismisses the popup on an outside click or when the grab is
108+// denied, and the popup must then be closed.
109+//
110+// The parent must be mapped: configured by the compositor and, for the
111+// compositor to place the popup, presented at least once. NewPopup returns an
112+// error for a parent that has not been configured yet; a compositor dismisses
113+// (PopupDone) a popup whose parent shows nothing.
114+//
115+// A grab needs a seat and the serial of the user action. Its parent must be a
116+// toplevel or layer surface, or the grabbing popup that is the topmost of the
117+// connection's grab chain; only one chain exists at a time. Other grabs return
118+// an error before any request is sent (xdg-shell would raise a protocol error).
119+//
120+// Close popups before their parent: Close of a surface with open child popups
121+// fails.
122+func (c *Conn) NewPopup(cfg PopupConfig) (*Surface, error) {
123+ if c.closed {
124+ return nil, ErrClosed
125+ }
126+ parent := cfg.Parent
127+ switch {
128+ case parent == nil || parent.c != c:
129+ return nil, errors.New("neferclient: popup needs a parent surface of this connection")
130+ case parent.closed:
131+ return nil, fmt.Errorf("neferclient: popup parent: %w", ErrClosed)
132+ case parent.role != roleLayer && parent.role != roleToplevel && parent.role != rolePopup:
133+ return nil, errors.New("neferclient: popup parent must be a layer surface, a toplevel or a popup")
134+ case !parent.configured:
135+ return nil, errors.New("neferclient: popup parent is not configured yet: wait for its first Handler.Configure")
136+ }
137+ if err := validatePlacement(&cfg.Place); err != nil {
138+ return nil, err
139+ }
140+ if cfg.Grab {
141+ if cfg.Serial == 0 {
142+ return nil, errors.New("neferclient: popup grab needs the serial of the triggering input event")
143+ }
144+ c.ensureSeat()
145+ if c.seat == nil || c.seat.proxy == nil {
146+ cause := c.seatErr
147+ if cause == nil {
148+ cause = errors.New("popup grab needs a seat")
149+ }
150+ return nil, &CapabilityError{Name: "wl_seat", Cause: cause}
151+ }
152+ if parent.role == rolePopup && !parent.grabbed {
153+ return nil, errors.New("neferclient: popup grab needs a grabbing parent popup")
154+ }
155+ if c.grabTop != nil && parent != c.grabTop {
156+ return nil, errors.New("neferclient: popup grab needs the topmost grabbing popup as parent while a grab is open")
157+ }
158+ }
159+ if err := c.ensureXdg(); err != nil {
160+ return nil, err
161+ }
162+ c.pause()
163+ defer c.resume()
164+ s, err := c.newSurface(rolePopup, cfg.Place.Width, cfg.Place.Height)
165+ if err != nil {
166+ return nil, err
167+ }
168+ ok := false
169+ defer func() {
170+ if !ok {
171+ s.destroy()
172+ }
173+ }()
174+ if s.xdg, err = c.g.xdg.GetXdgSurface(s.surf); err != nil {
175+ return nil, err
176+ }
177+ pos, err := c.newPositioner(&cfg.Place)
178+ if err != nil {
179+ return nil, err
180+ }
181+ var parentXdg *xdgshell.XdgSurface
182+ if parent.role != roleLayer {
183+ parentXdg = parent.xdg
184+ }
185+ s.popup, err = s.xdg.GetPopup(parentXdg, pos)
186+ // The compositor copies the state at get_popup.
187+ if err = errors.Join(err, pos.Destroy()); err != nil {
188+ return nil, err
189+ }
190+ if parent.role == roleLayer {
191+ if err = parent.layer.GetPopup(s.popup); err != nil {
192+ return nil, err
193+ }
194+ }
195+ q, sid := c.q, uint32(s.id)
196+ s.popup.OnConfigure(func(_, _, w, h int32) {
197+ e := event{kind: evPopupConfigure, id: sid, a: w, b: h}
198+ if w < 0 || h < 0 || w > maxLayerDimension || h > maxLayerDimension {
199+ e = event{kind: evBadEvent, id: sid, a: badConfigure}
200+ }
201+ q.post(&e)
202+ })
203+ s.popup.OnPopupDone(func() {
204+ e := event{kind: evPopupDone, id: sid}
205+ q.post(&e)
206+ })
207+ s.xdg.OnConfigure(func(serial uint32) {
208+ e := event{kind: evXdgConfigure, id: sid, serial: serial}
209+ q.post(&e)
210+ })
211+ if cfg.Grab {
212+ if err = s.popup.Grab(c.seat.proxy, cfg.Serial); err != nil {
213+ return nil, err
214+ }
215+ s.grabbed, c.grabTop = true, s
216+ }
217+ if err = s.finishSurface(); err != nil {
218+ return nil, err
219+ }
220+ if err = s.surf.Commit(); err != nil {
221+ return nil, err
222+ }
223+ s.parent = parent
224+ parent.children++
225+ ok = true
226+ return s, nil
227+}
228+
229+// Reposition asks the compositor to place the popup again. The following
230+// Handler.Configure reports the resulting size. It needs xdg_wm_base version 3.
231+func (s *Surface) Reposition(p PopupPlacement) error {
232+ switch {
233+ case s.closed || s.c.closed:
234+ return ErrClosed
235+ case s.role != rolePopup:
236+ return errors.New("neferclient: Reposition needs a popup")
237+ case s.c.g.xdgVersion < 3:
238+ return &CapabilityError{Name: xdgshell.XdgWmBaseInterface, Cause: fmt.Errorf("reposition requires version 3, negotiated %d", s.c.g.xdgVersion)}
239+ }
240+ if err := validatePlacement(&p); err != nil {
241+ return err
242+ }
243+ pos, err := s.c.newPositioner(&p)
244+ if err != nil {
245+ return err
246+ }
247+ s.repoToken++
248+ return errors.Join(s.popup.Reposition(pos, s.repoToken), pos.Destroy())
249+}
250+
251+// dropGrab ends the surface's grab: the topmost grabbing popup becomes its
252+// parent, if that one still grabs.
253+func (s *Surface) dropGrab() {
254+ if !s.grabbed {
255+ return
256+ }
257+ s.grabbed = false
258+ if c := s.c; c.grabTop == s {
259+ c.grabTop = nil
260+ if p := s.parent; p != nil && p.grabbed {
261+ c.grabTop = p
262+ }
263+ }
264+}
A popup_wire_test.go +474 -0
@@ -0,0 +1,474 @@
1+package neferclient_test
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "math"
7+ "slices"
8+ "sync"
9+ "testing"
10+
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/neferclient"
15+ neferclientmocks "github.com/bnema/neferclient/mocks"
16+)
17+
18+const (
19+ wmBaseGlobal = 5
20+ layerGlobal = 10
21+)
22+
23+var popupGlobals = append(append([]wireGlobal{}, seatGlobals...), wireGlobal{layerGlobal, "zwlr_layer_shell_v1", 4})
24+
25+// popupLog records every request the peer sees, in order.
26+type popupLog struct {
27+ mu sync.Mutex
28+ reqs []popupReq
29+}
30+
31+type popupReq struct {
32+ obj uint32
33+ op uint16
34+ body []byte
35+}
36+
37+func newPopupLog(srv *wireServer) *popupLog {
38+ l := &popupLog{}
39+ srv.mu.Lock()
40+ srv.hook = func(obj uint32, op uint16, body []byte) {
41+ l.mu.Lock()
42+ l.reqs = append(l.reqs, popupReq{obj, op, append([]byte(nil), body...)})
43+ l.mu.Unlock()
44+ }
45+ srv.mu.Unlock()
46+ return l
47+}
48+
49+func (l *popupLog) len() int {
50+ l.mu.Lock()
51+ defer l.mu.Unlock()
52+ return len(l.reqs)
53+}
54+
55+func (r popupReq) u32(i int) uint32 { return binary.LittleEndian.Uint32(r.body[4*i:]) }
56+
57+// find returns the index of the first request at or after from that matches.
58+func (l *popupLog) find(from int, match func(popupReq) bool) int {
59+ l.mu.Lock()
60+ defer l.mu.Unlock()
61+ for i := from; i < len(l.reqs); i++ {
62+ if match(l.reqs[i]) {
63+ return i
64+ }
65+ }
66+ return -1
67+}
68+
69+// findLast returns the index of the last request that matches, or -1. Object
70+// ids are recycled once the compositor confirms a destruction, so a lookup of
71+// a live object must take the newest request.
72+func (l *popupLog) findLast(match func(popupReq) bool) int {
73+ l.mu.Lock()
74+ defer l.mu.Unlock()
75+ for i := len(l.reqs) - 1; i >= 0; i-- {
76+ if match(l.reqs[i]) {
77+ return i
78+ }
79+ }
80+ return -1
81+}
82+
83+// mustFind is find that fails the test when nothing matches.
84+func (l *popupLog) mustFind(t *testing.T, from int, match func(popupReq) bool, msg string) int {
85+ t.Helper()
86+ i := l.find(from, match)
87+ require.GreaterOrEqual(t, i, 0, msg)
88+ return i
89+}
90+
91+func (l *popupLog) at(i int) popupReq {
92+ l.mu.Lock()
93+ defer l.mu.Unlock()
94+ return l.reqs[i]
95+}
96+
97+func (l *popupLog) all(match func(popupReq) bool) []popupReq {
98+ l.mu.Lock()
99+ defer l.mu.Unlock()
100+ var out []popupReq
101+ for _, r := range l.reqs {
102+ if match(r) {
103+ out = append(out, r)
104+ }
105+ }
106+ return out
107+}
108+
109+func (l *popupLog) op(obj uint32, op uint16) func(popupReq) bool {
110+ return func(r popupReq) bool { return r.obj == obj && r.op == op }
111+}
112+
113+// popupEnv is a connection with a layer surface, a toplevel and the request log.
114+type popupEnv struct {
115+ c *neferclient.Conn
116+ srv *wireServer
117+ log *popupLog
118+ layer *neferclient.Surface
119+ top *neferclient.Surface
120+}
121+
122+func newPopupEnv(t *testing.T, globals []wireGlobal) *popupEnv {
123+ t.Helper()
124+ c, srv := connectWire(t, globals, surfaceOutputs)
125+ e := &popupEnv{c: c, srv: srv, log: newPopupLog(srv)}
126+ var err error
127+ e.top, err = c.NewToplevel("parent", 64, 48)
128+ require.NoError(t, err)
129+ hasLayer := slices.ContainsFunc(globals, func(g wireGlobal) bool { return g.iface == "zwlr_layer_shell_v1" })
130+ e.top.MarkPresentable(64, 48) // stands in for the configure round: popups need a configured parent
131+ if hasLayer {
132+ e.layer, err = c.NewLayerSurface(neferclient.LayerConfig{Level: neferclient.LayerTop,
133+ Anchors: neferclient.AnchorTop | neferclient.AnchorLeft, Width: 200, Height: 30})
134+ require.NoError(t, err)
135+ e.layer.MarkPresentable(200, 30)
136+ }
137+ require.NoError(t, c.Roundtrip())
138+ return e
139+}
140+
141+func (e *popupEnv) wmBase() uint32 {
142+ e.srv.mu.Lock()
143+ defer e.srv.mu.Unlock()
144+ return e.srv.bound[wmBaseGlobal].id
145+}
146+
147+// xdgSurfaceOf returns the xdg_surface id created for wl_surface surf.
148+// getPopup returns the newest xdg_surface.get_popup request of xdg.
149+func (e *popupEnv) getPopup(t *testing.T, xdg uint32) (int, popupReq) {
150+ t.Helper()
151+ i := e.log.findLast(e.log.op(xdg, 2))
152+ require.GreaterOrEqual(t, i, 0, "xdg_surface.get_popup")
153+ return i, e.log.at(i)
154+}
155+
156+func (e *popupEnv) xdgSurfaceOf(t *testing.T, surf uint32) uint32 {
157+ t.Helper()
158+ wm := e.wmBase()
159+ i := e.log.findLast(func(r popupReq) bool { return r.obj == wm && r.op == 2 && len(r.body) == 8 && r.u32(1) == surf })
160+ require.GreaterOrEqual(t, i, 0, "xdg_wm_base.get_xdg_surface")
161+ return e.log.at(i).u32(0)
162+}
163+
164+// configurePopup has the peer configure popup p and dispatches it, which
165+// makes p a valid parent for further popups.
166+func (e *popupEnv) configurePopup(t *testing.T, p *neferclient.Surface) {
167+ t.Helper()
168+ require.NoError(t, e.c.Roundtrip())
169+ xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID())
170+ _, gp := e.getPopup(t, xdg)
171+ e.srv.write(frame(gp.u32(0), 0, appendU32(appendU32(appendU32(appendU32(nil, 0), 0), 60), 40)))
172+ e.srv.write(frame(xdg, 0, appendU32(nil, 1)))
173+ require.NoError(t, e.c.Roundtrip())
174+ dispatchUntil(t, e.c, neferclient.NopHandler{}, func() bool { w, _, _ := p.Size(); return w == 60 })
175+ require.NoError(t, e.c.Roundtrip()) // the ack_configure reached the peer
176+}
177+
178+// popupID returns the xdg_popup object id of p.
179+func (e *popupEnv) popupID(t *testing.T, p *neferclient.Surface) uint32 {
180+ t.Helper()
181+ _, gp := e.getPopup(t, e.xdgSurfaceOf(t, p.SurfaceObjectID()))
182+ return gp.u32(0)
183+}
184+
185+var place = neferclient.PopupPlacement{
186+ Anchor: neferclient.Rect{X: 10, Y: 0, Width: 20, Height: 30},
187+ Edge: neferclient.EdgeBottom, Gravity: neferclient.EdgeBottomRight,
188+ Adjust: neferclient.AdjustSlideX | neferclient.AdjustFlipY | neferclient.AdjustResizeY,
189+ Offset: [2]int32{3, 4}, Width: 120, Height: 80,
190+}
191+
192+func TestPopupOnLayerParentGrabsBeforeFirstCommit(t *testing.T) {
193+ e := newPopupEnv(t, popupGlobals)
194+ n := e.log.len()
195+ p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place, Grab: true, Serial: 42})
196+ require.NoError(t, err)
197+ require.NoError(t, e.c.Roundtrip())
198+
199+ popupSurf := p.SurfaceObjectID()
200+ xdg := e.xdgSurfaceOf(t, popupSurf)
201+ getPopup, gp := e.getPopup(t, xdg)
202+ popupID, positioner := gp.u32(0), gp.u32(2)
203+ require.Zero(t, gp.u32(1), "layer parent: null xdg parent")
204+
205+ // The layer surface is the one created for e.layer.
206+ layerID := e.log.at(e.log.mustFind(t, 0, func(r popupReq) bool { return r.op == 0 && len(r.body) > 8 && r.u32(1) == e.layer.SurfaceObjectID() }, "get_layer_surface")).u32(0)
207+ layerPopup := e.log.mustFind(t, n, e.log.op(layerID, 5), "layer_surface.get_popup")
208+ require.Greater(t, layerPopup, getPopup, "layer_surface.get_popup follows xdg_surface.get_popup")
209+ require.Equal(t, popupID, e.log.at(layerPopup).u32(0))
210+
211+ grab := e.log.mustFind(t, n, e.log.op(popupID, 1), "xdg_popup.grab")
212+ require.Greater(t, grab, layerPopup, "grab follows get_popup")
213+ require.Equal(t, uint32(42), e.log.at(grab).u32(1))
214+ require.NotZero(t, e.log.at(grab).u32(0), "seat")
215+ commit := e.log.mustFind(t, n, e.log.op(popupSurf, 6), "wl_surface.commit")
216+ require.Greater(t, commit, grab, "grab precedes the first commit")
217+
218+ // Positioner state.
219+ pos := func(op uint16) popupReq {
220+ i := e.log.mustFind(t, n, e.log.op(positioner, op), "positioner request")
221+ require.Less(t, i, getPopup)
222+ return e.log.at(i)
223+ }
224+ require.Equal(t, []uint32{120, 80}, []uint32{pos(1).u32(0), pos(1).u32(1)})
225+ 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)})
226+ require.Equal(t, uint32(neferclient.EdgeBottom), pos(3).u32(0))
227+ require.Equal(t, uint32(neferclient.EdgeBottomRight), pos(4).u32(0))
228+ require.Equal(t, uint32(1|8|32), pos(5).u32(0))
229+ require.Equal(t, []uint32{3, 4}, []uint32{pos(6).u32(0), pos(6).u32(1)})
230+ require.NotEmpty(t, e.log.all(e.log.op(positioner, 0)), "positioner destroyed")
231+}
232+
233+func TestPopupOnToplevelParentUsesParentXdgSurface(t *testing.T) {
234+ e := newPopupEnv(t, popupGlobals)
235+ n := e.log.len()
236+ p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.top, Place: place})
237+ require.NoError(t, err)
238+ require.NoError(t, e.c.Roundtrip())
239+ parentXdg := e.xdgSurfaceOf(t, e.top.SurfaceObjectID())
240+ xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID())
241+ _, gp := e.getPopup(t, xdg)
242+ require.Equal(t, parentXdg, gp.u32(1))
243+ popupID := gp.u32(0)
244+ require.Negative(t, e.log.find(n, e.log.op(popupID, 1)), "no grab requested")
245+}
246+
247+func TestPopupNestedOnPopup(t *testing.T) {
248+ e := newPopupEnv(t, popupGlobals)
249+ p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.top, Place: place})
250+ require.NoError(t, err)
251+ _, err = e.c.NewPopup(neferclient.PopupConfig{Parent: p, Place: place})
252+ require.ErrorContains(t, err, "not configured yet", "a popup parent is mapped only once configured")
253+ e.configurePopup(t, p)
254+ child, err := e.c.NewPopup(neferclient.PopupConfig{Parent: p, Place: place})
255+ require.NoError(t, err)
256+ require.NoError(t, e.c.Roundtrip())
257+ _, gp := e.getPopup(t, e.xdgSurfaceOf(t, child.SurfaceObjectID()))
258+ require.Equal(t, e.xdgSurfaceOf(t, p.SurfaceObjectID()), gp.u32(1))
259+ require.Error(t, p.Close(), "popup with an open child")
260+ require.NoError(t, child.Close())
261+ require.NoError(t, p.Close())
262+}
263+
264+func TestPopupConfigureAcksAndDone(t *testing.T) {
265+ e := newPopupEnv(t, popupGlobals)
266+ p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place})
267+ require.NoError(t, err)
268+ require.NoError(t, e.c.Roundtrip())
269+ xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID())
270+ _, gp := e.getPopup(t, xdg)
271+ popupID := gp.u32(0)
272+
273+ var w, h int32
274+ var done bool
275+ hd := neferclientmocks.NewMockHandler(t)
276+ hd.EXPECT().Configure(p.ID(), mock.Anything, mock.Anything).Run(func(_ neferclient.SurfaceID, cw, ch int32) { w, h = cw, ch }).Return().Once()
277+ hd.EXPECT().PopupDone(p.ID()).Run(func(neferclient.SurfaceID) { done = true }).Return().Once()
278+
279+ cfg := appendU32(appendU32(appendU32(appendU32(nil, 5), 6), 120), 80)
280+ e.srv.write(frame(popupID, 0, cfg)) // xdg_popup.configure(5,6,120,80)
281+ e.srv.write(frame(xdg, 0, appendU32(nil, 9))) // xdg_surface.configure(9)
282+ require.NoError(t, e.c.Roundtrip())
283+ dispatchUntil(t, e.c, hd, func() bool { return w != 0 })
284+ require.Equal(t, [2]int32{120, 80}, [2]int32{w, h})
285+ require.NoError(t, e.c.Roundtrip())
286+ ack := e.log.mustFind(t, 0, e.log.op(xdg, 4), "ack_configure")
287+ require.Equal(t, uint32(9), e.log.at(ack).u32(0))
288+ gw, gh, _ := p.Size()
289+ require.Equal(t, [2]int32{120, 80}, [2]int32{gw, gh})
290+
291+ e.srv.write(frame(popupID, 1, nil)) // popup_done
292+ require.NoError(t, e.c.Roundtrip())
293+ dispatchUntil(t, e.c, hd, func() bool { return done })
294+ require.NoError(t, p.Close())
295+ require.NoError(t, e.c.Roundtrip())
296+ destroyPopup := e.log.mustFind(t, 0, e.log.op(popupID, 0), "xdg_popup.destroy")
297+ destroyXdg := e.log.mustFind(t, 0, e.log.op(xdg, 0), "xdg_surface.destroy")
298+ require.Less(t, destroyPopup, destroyXdg, "popup destroyed before its xdg_surface")
299+}
300+
301+func TestPopupReposition(t *testing.T) {
302+ e := newPopupEnv(t, popupGlobals)
303+ p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place})
304+ require.NoError(t, err)
305+ require.NoError(t, e.c.Roundtrip())
306+ xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID())
307+ _, gp := e.getPopup(t, xdg)
308+ popupID := gp.u32(0)
309+
310+ np := place
311+ np.Anchor.X, np.Width = 50, 90
312+ n := e.log.len()
313+ require.NoError(t, p.Reposition(np))
314+ require.NoError(t, e.c.Roundtrip())
315+ i := e.log.mustFind(t, n, e.log.op(popupID, 2), "xdg_popup.reposition")
316+ positioner := e.log.at(i).u32(0)
317+ token := e.log.at(i).u32(1)
318+ setSize := e.log.at(e.log.mustFind(t, n, e.log.op(positioner, 1), "positioner.set_size"))
319+ require.Equal(t, uint32(90), setSize.u32(0))
320+ require.Less(t, e.log.mustFind(t, n, e.log.op(positioner, 1), "set_size"), i)
321+ require.Greater(t, e.log.mustFind(t, i, e.log.op(positioner, 0), "positioner.destroy"), i)
322+
323+ n = e.log.len()
324+ require.NoError(t, p.Reposition(np))
325+ require.NoError(t, e.c.Roundtrip())
326+ second := e.log.at(e.log.mustFind(t, n, e.log.op(popupID, 2), "second xdg_popup.reposition"))
327+ require.NotEqual(t, token, second.u32(1), "each reposition carries its own token")
328+
329+ bad := np
330+ bad.Width = 0
331+ require.Error(t, p.Reposition(bad))
332+ require.Error(t, e.top.Reposition(np), "not a popup")
333+}
334+
335+func TestPopupRepositionNeedsXdgV3(t *testing.T) {
336+ old := append([]wireGlobal{}, popupGlobals...)
337+ old[wmBaseGlobal-1].version = 2
338+ require.Equal(t, "xdg_wm_base", old[wmBaseGlobal-1].iface)
339+ e := newPopupEnv(t, old)
340+ p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place})
341+ require.NoError(t, err)
342+ require.NoError(t, e.c.Roundtrip())
343+ n := e.log.len()
344+ var ce *neferclient.CapabilityError
345+ require.ErrorAs(t, p.Reposition(place), &ce)
346+ require.NoError(t, e.c.Roundtrip())
347+ require.Equal(t, n, e.log.len(), "nothing sent")
348+}
349+
350+func TestPopupInvalidConfigsSendNothing(t *testing.T) {
351+ e := newPopupEnv(t, surfaceGlobals) // no seat, no layer shell
352+ closed, err := e.c.NewToplevel("closed", 64, 48)
353+ require.NoError(t, err)
354+ require.NoError(t, closed.Close())
355+ unconfigured, err := e.c.NewToplevel("unconfigured", 64, 48)
356+ require.NoError(t, err)
357+ l, err := e.c.Lock()
358+ require.NoError(t, err)
359+ lockSurf, err := l.NewSurface(7)
360+ require.NoError(t, err)
361+ lockSurf.MarkPresentable(64, 48)
362+ other, _ := connectWire(t, surfaceGlobals, surfaceOutputs)
363+ foreign, err := other.NewToplevel("foreign", 64, 48)
364+ require.NoError(t, err)
365+ foreign.MarkPresentable(64, 48)
366+ require.NoError(t, e.c.Roundtrip())
367+ n := e.log.len()
368+
369+ with := func(f func(*neferclient.PopupConfig)) neferclient.PopupConfig {
370+ cfg := neferclient.PopupConfig{Parent: e.top, Place: place}
371+ f(&cfg)
372+ return cfg
373+ }
374+ var capErr *neferclient.CapabilityError
375+ for name, tc := range map[string]struct {
376+ cfg neferclient.PopupConfig
377+ want func(error)
378+ }{
379+ "nil parent": {with(func(c *neferclient.PopupConfig) { c.Parent = nil }), func(err error) { require.ErrorContains(t, err, "needs a parent surface") }},
380+ "foreign parent": {with(func(c *neferclient.PopupConfig) { c.Parent = foreign }), func(err error) { require.ErrorContains(t, err, "of this connection") }},
381+ "closed parent": {with(func(c *neferclient.PopupConfig) { c.Parent = closed }), func(err error) { require.ErrorIs(t, err, neferclient.ErrClosed) }},
382+ "lock parent": {with(func(c *neferclient.PopupConfig) { c.Parent = lockSurf }), func(err error) { require.ErrorContains(t, err, "must be a layer surface, a toplevel or a popup") }},
383+ "unconfigured": {with(func(c *neferclient.PopupConfig) { c.Parent = unconfigured }), func(err error) { require.ErrorContains(t, err, "not configured yet") }},
384+ "zero width": {with(func(c *neferclient.PopupConfig) { c.Place.Width = 0 }), func(err error) { require.ErrorContains(t, err, "invalid popup size") }},
385+ "negative height": {with(func(c *neferclient.PopupConfig) { c.Place.Height = -1 }), func(err error) { require.ErrorContains(t, err, "invalid popup size") }},
386+ "empty anchor": {with(func(c *neferclient.PopupConfig) { c.Place.Anchor.Width = 0 }), func(err error) { require.ErrorContains(t, err, "anchor rectangle") }},
387+ "anchor overflow": {with(func(c *neferclient.PopupConfig) {
388+ c.Place.Anchor = neferclient.Rect{X: math.MaxInt32, Width: 10, Height: 5}
389+ }), func(err error) { require.ErrorContains(t, err, "overflows") }},
390+ "bad edge": {with(func(c *neferclient.PopupConfig) { c.Place.Edge = 9 }), func(err error) { require.ErrorContains(t, err, "invalid popup edge") }},
391+ "bad gravity": {with(func(c *neferclient.PopupConfig) { c.Place.Gravity = 9 }), func(err error) { require.ErrorContains(t, err, "invalid popup edge") }},
392+ "bad adjust": {with(func(c *neferclient.PopupConfig) { c.Place.Adjust = 64 }), func(err error) { require.ErrorContains(t, err, "constraint adjustment") }},
393+ "grab no serial": {with(func(c *neferclient.PopupConfig) { c.Grab = true }), func(err error) { require.ErrorContains(t, err, "serial") }},
394+ "grab no seat": {with(func(c *neferclient.PopupConfig) { c.Grab, c.Serial = true, 1 }), func(err error) { require.ErrorAs(t, err, &capErr) }},
395+ } {
396+ _, err := e.c.NewPopup(tc.cfg)
397+ require.Error(t, err, name)
398+ tc.want(err)
399+ }
400+ require.NoError(t, e.c.Roundtrip())
401+ require.Equal(t, n, e.log.len(), "no request was sent")
402+ require.NoError(t, e.top.Close(), "failed popups left no child behind")
403+}
404+
405+// A failed seat bind is the cause of the grab's CapabilityError.
406+func TestPopupGrabReportsSeatBindError(t *testing.T) {
407+ e := newPopupEnv(t, surfaceGlobals)
408+ bindErr := errors.New("bind refused")
409+ e.c.SetSeatErr(bindErr)
410+ _, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.top, Place: place, Grab: true, Serial: 1})
411+ var ce *neferclient.CapabilityError
412+ require.ErrorAs(t, err, &ce)
413+ require.Equal(t, "wl_seat", ce.Name)
414+ require.ErrorIs(t, err, bindErr)
415+}
416+
417+// A grab needs a grabbing parent popup, and while a grab chain is open only
418+// its topmost popup can be the parent of the next grab. Refusals send nothing.
419+func TestPopupGrabChain(t *testing.T) {
420+ e := newPopupEnv(t, popupGlobals)
421+ grab := func(parent *neferclient.Surface) (*neferclient.Surface, error) {
422+ return e.c.NewPopup(neferclient.PopupConfig{Parent: parent, Place: place, Grab: true, Serial: 7})
423+ }
424+ refused := func(parent *neferclient.Surface, want string) {
425+ t.Helper()
426+ require.NoError(t, e.c.Roundtrip()) // earlier requests are logged
427+ n := e.log.len()
428+ _, err := grab(parent)
429+ require.ErrorContains(t, err, want)
430+ require.NoError(t, e.c.Roundtrip())
431+ require.Equal(t, n, e.log.len(), "no request was sent")
432+ }
433+
434+ plain, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place})
435+ require.NoError(t, err)
436+ e.configurePopup(t, plain)
437+ refused(plain, "needs a grabbing parent popup")
438+
439+ a, err := grab(e.layer)
440+ require.NoError(t, err)
441+ e.configurePopup(t, a)
442+ refused(e.top, "topmost grabbing popup")
443+ refused(e.layer, "topmost grabbing popup")
444+ refused(plain, "needs a grabbing parent popup")
445+
446+ b, err := grab(a)
447+ require.NoError(t, err)
448+ e.configurePopup(t, b)
449+ refused(a, "topmost grabbing popup")
450+
451+ // Closing the topmost popup returns the grab to its parent.
452+ require.NoError(t, b.Close())
453+ c2, err := grab(a)
454+ require.NoError(t, err)
455+ require.NoError(t, c2.Close())
456+ require.NoError(t, a.Close())
457+
458+ // A popup_done ends the grab of the dismissed popup, too.
459+ a2, err := grab(e.layer)
460+ require.NoError(t, err)
461+ refused(e.top, "topmost grabbing popup")
462+ e.srv.write(frame(e.popupID(t, a2), 1, nil))
463+ require.NoError(t, e.c.Roundtrip())
464+ var done []neferclient.SurfaceID
465+ hd := neferclientmocks.NewMockHandler(t)
466+ hd.EXPECT().PopupDone(mock.Anything).Run(func(id neferclient.SurfaceID) { done = append(done, id) }).Return().Once()
467+ dispatchUntil(t, e.c, hd, func() bool { return len(done) > 0 })
468+ require.Equal(t, []neferclient.SurfaceID{a2.ID()}, done)
469+ top, err := grab(e.top)
470+ require.NoError(t, err, "the chain is closed after popup_done")
471+ require.NoError(t, top.Close())
472+ require.NoError(t, a2.Close())
473+ require.NoError(t, plain.Close())
474+}
@@ -0,0 +1,474 @@
1+package neferclient_test
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "math"
7+ "slices"
8+ "sync"
9+ "testing"
10+
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/neferclient"
15+ neferclientmocks "github.com/bnema/neferclient/mocks"
16+)
17+
18+const (
19+ wmBaseGlobal = 5
20+ layerGlobal = 10
21+)
22+
23+var popupGlobals = append(append([]wireGlobal{}, seatGlobals...), wireGlobal{layerGlobal, "zwlr_layer_shell_v1", 4})
24+
25+// popupLog records every request the peer sees, in order.
26+type popupLog struct {
27+ mu sync.Mutex
28+ reqs []popupReq
29+}
30+
31+type popupReq struct {
32+ obj uint32
33+ op uint16
34+ body []byte
35+}
36+
37+func newPopupLog(srv *wireServer) *popupLog {
38+ l := &popupLog{}
39+ srv.mu.Lock()
40+ srv.hook = func(obj uint32, op uint16, body []byte) {
41+ l.mu.Lock()
42+ l.reqs = append(l.reqs, popupReq{obj, op, append([]byte(nil), body...)})
43+ l.mu.Unlock()
44+ }
45+ srv.mu.Unlock()
46+ return l
47+}
48+
49+func (l *popupLog) len() int {
50+ l.mu.Lock()
51+ defer l.mu.Unlock()
52+ return len(l.reqs)
53+}
54+
55+func (r popupReq) u32(i int) uint32 { return binary.LittleEndian.Uint32(r.body[4*i:]) }
56+
57+// find returns the index of the first request at or after from that matches.
58+func (l *popupLog) find(from int, match func(popupReq) bool) int {
59+ l.mu.Lock()
60+ defer l.mu.Unlock()
61+ for i := from; i < len(l.reqs); i++ {
62+ if match(l.reqs[i]) {
63+ return i
64+ }
65+ }
66+ return -1
67+}
68+
69+// findLast returns the index of the last request that matches, or -1. Object
70+// ids are recycled once the compositor confirms a destruction, so a lookup of
71+// a live object must take the newest request.
72+func (l *popupLog) findLast(match func(popupReq) bool) int {
73+ l.mu.Lock()
74+ defer l.mu.Unlock()
75+ for i := len(l.reqs) - 1; i >= 0; i-- {
76+ if match(l.reqs[i]) {
77+ return i
78+ }
79+ }
80+ return -1
81+}
82+
83+// mustFind is find that fails the test when nothing matches.
84+func (l *popupLog) mustFind(t *testing.T, from int, match func(popupReq) bool, msg string) int {
85+ t.Helper()
86+ i := l.find(from, match)
87+ require.GreaterOrEqual(t, i, 0, msg)
88+ return i
89+}
90+
91+func (l *popupLog) at(i int) popupReq {
92+ l.mu.Lock()
93+ defer l.mu.Unlock()
94+ return l.reqs[i]
95+}
96+
97+func (l *popupLog) all(match func(popupReq) bool) []popupReq {
98+ l.mu.Lock()
99+ defer l.mu.Unlock()
100+ var out []popupReq
101+ for _, r := range l.reqs {
102+ if match(r) {
103+ out = append(out, r)
104+ }
105+ }
106+ return out
107+}
108+
109+func (l *popupLog) op(obj uint32, op uint16) func(popupReq) bool {
110+ return func(r popupReq) bool { return r.obj == obj && r.op == op }
111+}
112+
113+// popupEnv is a connection with a layer surface, a toplevel and the request log.
114+type popupEnv struct {
115+ c *neferclient.Conn
116+ srv *wireServer
117+ log *popupLog
118+ layer *neferclient.Surface
119+ top *neferclient.Surface
120+}
121+
122+func newPopupEnv(t *testing.T, globals []wireGlobal) *popupEnv {
123+ t.Helper()
124+ c, srv := connectWire(t, globals, surfaceOutputs)
125+ e := &popupEnv{c: c, srv: srv, log: newPopupLog(srv)}
126+ var err error
127+ e.top, err = c.NewToplevel("parent", 64, 48)
128+ require.NoError(t, err)
129+ hasLayer := slices.ContainsFunc(globals, func(g wireGlobal) bool { return g.iface == "zwlr_layer_shell_v1" })
130+ e.top.MarkPresentable(64, 48) // stands in for the configure round: popups need a configured parent
131+ if hasLayer {
132+ e.layer, err = c.NewLayerSurface(neferclient.LayerConfig{Level: neferclient.LayerTop,
133+ Anchors: neferclient.AnchorTop | neferclient.AnchorLeft, Width: 200, Height: 30})
134+ require.NoError(t, err)
135+ e.layer.MarkPresentable(200, 30)
136+ }
137+ require.NoError(t, c.Roundtrip())
138+ return e
139+}
140+
141+func (e *popupEnv) wmBase() uint32 {
142+ e.srv.mu.Lock()
143+ defer e.srv.mu.Unlock()
144+ return e.srv.bound[wmBaseGlobal].id
145+}
146+
147+// xdgSurfaceOf returns the xdg_surface id created for wl_surface surf.
148+// getPopup returns the newest xdg_surface.get_popup request of xdg.
149+func (e *popupEnv) getPopup(t *testing.T, xdg uint32) (int, popupReq) {
150+ t.Helper()
151+ i := e.log.findLast(e.log.op(xdg, 2))
152+ require.GreaterOrEqual(t, i, 0, "xdg_surface.get_popup")
153+ return i, e.log.at(i)
154+}
155+
156+func (e *popupEnv) xdgSurfaceOf(t *testing.T, surf uint32) uint32 {
157+ t.Helper()
158+ wm := e.wmBase()
159+ i := e.log.findLast(func(r popupReq) bool { return r.obj == wm && r.op == 2 && len(r.body) == 8 && r.u32(1) == surf })
160+ require.GreaterOrEqual(t, i, 0, "xdg_wm_base.get_xdg_surface")
161+ return e.log.at(i).u32(0)
162+}
163+
164+// configurePopup has the peer configure popup p and dispatches it, which
165+// makes p a valid parent for further popups.
166+func (e *popupEnv) configurePopup(t *testing.T, p *neferclient.Surface) {
167+ t.Helper()
168+ require.NoError(t, e.c.Roundtrip())
169+ xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID())
170+ _, gp := e.getPopup(t, xdg)
171+ e.srv.write(frame(gp.u32(0), 0, appendU32(appendU32(appendU32(appendU32(nil, 0), 0), 60), 40)))
172+ e.srv.write(frame(xdg, 0, appendU32(nil, 1)))
173+ require.NoError(t, e.c.Roundtrip())
174+ dispatchUntil(t, e.c, neferclient.NopHandler{}, func() bool { w, _, _ := p.Size(); return w == 60 })
175+ require.NoError(t, e.c.Roundtrip()) // the ack_configure reached the peer
176+}
177+
178+// popupID returns the xdg_popup object id of p.
179+func (e *popupEnv) popupID(t *testing.T, p *neferclient.Surface) uint32 {
180+ t.Helper()
181+ _, gp := e.getPopup(t, e.xdgSurfaceOf(t, p.SurfaceObjectID()))
182+ return gp.u32(0)
183+}
184+
185+var place = neferclient.PopupPlacement{
186+ Anchor: neferclient.Rect{X: 10, Y: 0, Width: 20, Height: 30},
187+ Edge: neferclient.EdgeBottom, Gravity: neferclient.EdgeBottomRight,
188+ Adjust: neferclient.AdjustSlideX | neferclient.AdjustFlipY | neferclient.AdjustResizeY,
189+ Offset: [2]int32{3, 4}, Width: 120, Height: 80,
190+}
191+
192+func TestPopupOnLayerParentGrabsBeforeFirstCommit(t *testing.T) {
193+ e := newPopupEnv(t, popupGlobals)
194+ n := e.log.len()
195+ p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place, Grab: true, Serial: 42})
196+ require.NoError(t, err)
197+ require.NoError(t, e.c.Roundtrip())
198+
199+ popupSurf := p.SurfaceObjectID()
200+ xdg := e.xdgSurfaceOf(t, popupSurf)
201+ getPopup, gp := e.getPopup(t, xdg)
202+ popupID, positioner := gp.u32(0), gp.u32(2)
203+ require.Zero(t, gp.u32(1), "layer parent: null xdg parent")
204+
205+ // The layer surface is the one created for e.layer.
206+ layerID := e.log.at(e.log.mustFind(t, 0, func(r popupReq) bool { return r.op == 0 && len(r.body) > 8 && r.u32(1) == e.layer.SurfaceObjectID() }, "get_layer_surface")).u32(0)
207+ layerPopup := e.log.mustFind(t, n, e.log.op(layerID, 5), "layer_surface.get_popup")
208+ require.Greater(t, layerPopup, getPopup, "layer_surface.get_popup follows xdg_surface.get_popup")
209+ require.Equal(t, popupID, e.log.at(layerPopup).u32(0))
210+
211+ grab := e.log.mustFind(t, n, e.log.op(popupID, 1), "xdg_popup.grab")
212+ require.Greater(t, grab, layerPopup, "grab follows get_popup")
213+ require.Equal(t, uint32(42), e.log.at(grab).u32(1))
214+ require.NotZero(t, e.log.at(grab).u32(0), "seat")
215+ commit := e.log.mustFind(t, n, e.log.op(popupSurf, 6), "wl_surface.commit")
216+ require.Greater(t, commit, grab, "grab precedes the first commit")
217+
218+ // Positioner state.
219+ pos := func(op uint16) popupReq {
220+ i := e.log.mustFind(t, n, e.log.op(positioner, op), "positioner request")
221+ require.Less(t, i, getPopup)
222+ return e.log.at(i)
223+ }
224+ require.Equal(t, []uint32{120, 80}, []uint32{pos(1).u32(0), pos(1).u32(1)})
225+ 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)})
226+ require.Equal(t, uint32(neferclient.EdgeBottom), pos(3).u32(0))
227+ require.Equal(t, uint32(neferclient.EdgeBottomRight), pos(4).u32(0))
228+ require.Equal(t, uint32(1|8|32), pos(5).u32(0))
229+ require.Equal(t, []uint32{3, 4}, []uint32{pos(6).u32(0), pos(6).u32(1)})
230+ require.NotEmpty(t, e.log.all(e.log.op(positioner, 0)), "positioner destroyed")
231+}
232+
233+func TestPopupOnToplevelParentUsesParentXdgSurface(t *testing.T) {
234+ e := newPopupEnv(t, popupGlobals)
235+ n := e.log.len()
236+ p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.top, Place: place})
237+ require.NoError(t, err)
238+ require.NoError(t, e.c.Roundtrip())
239+ parentXdg := e.xdgSurfaceOf(t, e.top.SurfaceObjectID())
240+ xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID())
241+ _, gp := e.getPopup(t, xdg)
242+ require.Equal(t, parentXdg, gp.u32(1))
243+ popupID := gp.u32(0)
244+ require.Negative(t, e.log.find(n, e.log.op(popupID, 1)), "no grab requested")
245+}
246+
247+func TestPopupNestedOnPopup(t *testing.T) {
248+ e := newPopupEnv(t, popupGlobals)
249+ p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.top, Place: place})
250+ require.NoError(t, err)
251+ _, err = e.c.NewPopup(neferclient.PopupConfig{Parent: p, Place: place})
252+ require.ErrorContains(t, err, "not configured yet", "a popup parent is mapped only once configured")
253+ e.configurePopup(t, p)
254+ child, err := e.c.NewPopup(neferclient.PopupConfig{Parent: p, Place: place})
255+ require.NoError(t, err)
256+ require.NoError(t, e.c.Roundtrip())
257+ _, gp := e.getPopup(t, e.xdgSurfaceOf(t, child.SurfaceObjectID()))
258+ require.Equal(t, e.xdgSurfaceOf(t, p.SurfaceObjectID()), gp.u32(1))
259+ require.Error(t, p.Close(), "popup with an open child")
260+ require.NoError(t, child.Close())
261+ require.NoError(t, p.Close())
262+}
263+
264+func TestPopupConfigureAcksAndDone(t *testing.T) {
265+ e := newPopupEnv(t, popupGlobals)
266+ p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place})
267+ require.NoError(t, err)
268+ require.NoError(t, e.c.Roundtrip())
269+ xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID())
270+ _, gp := e.getPopup(t, xdg)
271+ popupID := gp.u32(0)
272+
273+ var w, h int32
274+ var done bool
275+ hd := neferclientmocks.NewMockHandler(t)
276+ hd.EXPECT().Configure(p.ID(), mock.Anything, mock.Anything).Run(func(_ neferclient.SurfaceID, cw, ch int32) { w, h = cw, ch }).Return().Once()
277+ hd.EXPECT().PopupDone(p.ID()).Run(func(neferclient.SurfaceID) { done = true }).Return().Once()
278+
279+ cfg := appendU32(appendU32(appendU32(appendU32(nil, 5), 6), 120), 80)
280+ e.srv.write(frame(popupID, 0, cfg)) // xdg_popup.configure(5,6,120,80)
281+ e.srv.write(frame(xdg, 0, appendU32(nil, 9))) // xdg_surface.configure(9)
282+ require.NoError(t, e.c.Roundtrip())
283+ dispatchUntil(t, e.c, hd, func() bool { return w != 0 })
284+ require.Equal(t, [2]int32{120, 80}, [2]int32{w, h})
285+ require.NoError(t, e.c.Roundtrip())
286+ ack := e.log.mustFind(t, 0, e.log.op(xdg, 4), "ack_configure")
287+ require.Equal(t, uint32(9), e.log.at(ack).u32(0))
288+ gw, gh, _ := p.Size()
289+ require.Equal(t, [2]int32{120, 80}, [2]int32{gw, gh})
290+
291+ e.srv.write(frame(popupID, 1, nil)) // popup_done
292+ require.NoError(t, e.c.Roundtrip())
293+ dispatchUntil(t, e.c, hd, func() bool { return done })
294+ require.NoError(t, p.Close())
295+ require.NoError(t, e.c.Roundtrip())
296+ destroyPopup := e.log.mustFind(t, 0, e.log.op(popupID, 0), "xdg_popup.destroy")
297+ destroyXdg := e.log.mustFind(t, 0, e.log.op(xdg, 0), "xdg_surface.destroy")
298+ require.Less(t, destroyPopup, destroyXdg, "popup destroyed before its xdg_surface")
299+}
300+
301+func TestPopupReposition(t *testing.T) {
302+ e := newPopupEnv(t, popupGlobals)
303+ p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place})
304+ require.NoError(t, err)
305+ require.NoError(t, e.c.Roundtrip())
306+ xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID())
307+ _, gp := e.getPopup(t, xdg)
308+ popupID := gp.u32(0)
309+
310+ np := place
311+ np.Anchor.X, np.Width = 50, 90
312+ n := e.log.len()
313+ require.NoError(t, p.Reposition(np))
314+ require.NoError(t, e.c.Roundtrip())
315+ i := e.log.mustFind(t, n, e.log.op(popupID, 2), "xdg_popup.reposition")
316+ positioner := e.log.at(i).u32(0)
317+ token := e.log.at(i).u32(1)
318+ setSize := e.log.at(e.log.mustFind(t, n, e.log.op(positioner, 1), "positioner.set_size"))
319+ require.Equal(t, uint32(90), setSize.u32(0))
320+ require.Less(t, e.log.mustFind(t, n, e.log.op(positioner, 1), "set_size"), i)
321+ require.Greater(t, e.log.mustFind(t, i, e.log.op(positioner, 0), "positioner.destroy"), i)
322+
323+ n = e.log.len()
324+ require.NoError(t, p.Reposition(np))
325+ require.NoError(t, e.c.Roundtrip())
326+ second := e.log.at(e.log.mustFind(t, n, e.log.op(popupID, 2), "second xdg_popup.reposition"))
327+ require.NotEqual(t, token, second.u32(1), "each reposition carries its own token")
328+
329+ bad := np
330+ bad.Width = 0
331+ require.Error(t, p.Reposition(bad))
332+ require.Error(t, e.top.Reposition(np), "not a popup")
333+}
334+
335+func TestPopupRepositionNeedsXdgV3(t *testing.T) {
336+ old := append([]wireGlobal{}, popupGlobals...)
337+ old[wmBaseGlobal-1].version = 2
338+ require.Equal(t, "xdg_wm_base", old[wmBaseGlobal-1].iface)
339+ e := newPopupEnv(t, old)
340+ p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place})
341+ require.NoError(t, err)
342+ require.NoError(t, e.c.Roundtrip())
343+ n := e.log.len()
344+ var ce *neferclient.CapabilityError
345+ require.ErrorAs(t, p.Reposition(place), &ce)
346+ require.NoError(t, e.c.Roundtrip())
347+ require.Equal(t, n, e.log.len(), "nothing sent")
348+}
349+
350+func TestPopupInvalidConfigsSendNothing(t *testing.T) {
351+ e := newPopupEnv(t, surfaceGlobals) // no seat, no layer shell
352+ closed, err := e.c.NewToplevel("closed", 64, 48)
353+ require.NoError(t, err)
354+ require.NoError(t, closed.Close())
355+ unconfigured, err := e.c.NewToplevel("unconfigured", 64, 48)
356+ require.NoError(t, err)
357+ l, err := e.c.Lock()
358+ require.NoError(t, err)
359+ lockSurf, err := l.NewSurface(7)
360+ require.NoError(t, err)
361+ lockSurf.MarkPresentable(64, 48)
362+ other, _ := connectWire(t, surfaceGlobals, surfaceOutputs)
363+ foreign, err := other.NewToplevel("foreign", 64, 48)
364+ require.NoError(t, err)
365+ foreign.MarkPresentable(64, 48)
366+ require.NoError(t, e.c.Roundtrip())
367+ n := e.log.len()
368+
369+ with := func(f func(*neferclient.PopupConfig)) neferclient.PopupConfig {
370+ cfg := neferclient.PopupConfig{Parent: e.top, Place: place}
371+ f(&cfg)
372+ return cfg
373+ }
374+ var capErr *neferclient.CapabilityError
375+ for name, tc := range map[string]struct {
376+ cfg neferclient.PopupConfig
377+ want func(error)
378+ }{
379+ "nil parent": {with(func(c *neferclient.PopupConfig) { c.Parent = nil }), func(err error) { require.ErrorContains(t, err, "needs a parent surface") }},
380+ "foreign parent": {with(func(c *neferclient.PopupConfig) { c.Parent = foreign }), func(err error) { require.ErrorContains(t, err, "of this connection") }},
381+ "closed parent": {with(func(c *neferclient.PopupConfig) { c.Parent = closed }), func(err error) { require.ErrorIs(t, err, neferclient.ErrClosed) }},
382+ "lock parent": {with(func(c *neferclient.PopupConfig) { c.Parent = lockSurf }), func(err error) { require.ErrorContains(t, err, "must be a layer surface, a toplevel or a popup") }},
383+ "unconfigured": {with(func(c *neferclient.PopupConfig) { c.Parent = unconfigured }), func(err error) { require.ErrorContains(t, err, "not configured yet") }},
384+ "zero width": {with(func(c *neferclient.PopupConfig) { c.Place.Width = 0 }), func(err error) { require.ErrorContains(t, err, "invalid popup size") }},
385+ "negative height": {with(func(c *neferclient.PopupConfig) { c.Place.Height = -1 }), func(err error) { require.ErrorContains(t, err, "invalid popup size") }},
386+ "empty anchor": {with(func(c *neferclient.PopupConfig) { c.Place.Anchor.Width = 0 }), func(err error) { require.ErrorContains(t, err, "anchor rectangle") }},
387+ "anchor overflow": {with(func(c *neferclient.PopupConfig) {
388+ c.Place.Anchor = neferclient.Rect{X: math.MaxInt32, Width: 10, Height: 5}
389+ }), func(err error) { require.ErrorContains(t, err, "overflows") }},
390+ "bad edge": {with(func(c *neferclient.PopupConfig) { c.Place.Edge = 9 }), func(err error) { require.ErrorContains(t, err, "invalid popup edge") }},
391+ "bad gravity": {with(func(c *neferclient.PopupConfig) { c.Place.Gravity = 9 }), func(err error) { require.ErrorContains(t, err, "invalid popup edge") }},
392+ "bad adjust": {with(func(c *neferclient.PopupConfig) { c.Place.Adjust = 64 }), func(err error) { require.ErrorContains(t, err, "constraint adjustment") }},
393+ "grab no serial": {with(func(c *neferclient.PopupConfig) { c.Grab = true }), func(err error) { require.ErrorContains(t, err, "serial") }},
394+ "grab no seat": {with(func(c *neferclient.PopupConfig) { c.Grab, c.Serial = true, 1 }), func(err error) { require.ErrorAs(t, err, &capErr) }},
395+ } {
396+ _, err := e.c.NewPopup(tc.cfg)
397+ require.Error(t, err, name)
398+ tc.want(err)
399+ }
400+ require.NoError(t, e.c.Roundtrip())
401+ require.Equal(t, n, e.log.len(), "no request was sent")
402+ require.NoError(t, e.top.Close(), "failed popups left no child behind")
403+}
404+
405+// A failed seat bind is the cause of the grab's CapabilityError.
406+func TestPopupGrabReportsSeatBindError(t *testing.T) {
407+ e := newPopupEnv(t, surfaceGlobals)
408+ bindErr := errors.New("bind refused")
409+ e.c.SetSeatErr(bindErr)
410+ _, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.top, Place: place, Grab: true, Serial: 1})
411+ var ce *neferclient.CapabilityError
412+ require.ErrorAs(t, err, &ce)
413+ require.Equal(t, "wl_seat", ce.Name)
414+ require.ErrorIs(t, err, bindErr)
415+}
416+
417+// A grab needs a grabbing parent popup, and while a grab chain is open only
418+// its topmost popup can be the parent of the next grab. Refusals send nothing.
419+func TestPopupGrabChain(t *testing.T) {
420+ e := newPopupEnv(t, popupGlobals)
421+ grab := func(parent *neferclient.Surface) (*neferclient.Surface, error) {
422+ return e.c.NewPopup(neferclient.PopupConfig{Parent: parent, Place: place, Grab: true, Serial: 7})
423+ }
424+ refused := func(parent *neferclient.Surface, want string) {
425+ t.Helper()
426+ require.NoError(t, e.c.Roundtrip()) // earlier requests are logged
427+ n := e.log.len()
428+ _, err := grab(parent)
429+ require.ErrorContains(t, err, want)
430+ require.NoError(t, e.c.Roundtrip())
431+ require.Equal(t, n, e.log.len(), "no request was sent")
432+ }
433+
434+ plain, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place})
435+ require.NoError(t, err)
436+ e.configurePopup(t, plain)
437+ refused(plain, "needs a grabbing parent popup")
438+
439+ a, err := grab(e.layer)
440+ require.NoError(t, err)
441+ e.configurePopup(t, a)
442+ refused(e.top, "topmost grabbing popup")
443+ refused(e.layer, "topmost grabbing popup")
444+ refused(plain, "needs a grabbing parent popup")
445+
446+ b, err := grab(a)
447+ require.NoError(t, err)
448+ e.configurePopup(t, b)
449+ refused(a, "topmost grabbing popup")
450+
451+ // Closing the topmost popup returns the grab to its parent.
452+ require.NoError(t, b.Close())
453+ c2, err := grab(a)
454+ require.NoError(t, err)
455+ require.NoError(t, c2.Close())
456+ require.NoError(t, a.Close())
457+
458+ // A popup_done ends the grab of the dismissed popup, too.
459+ a2, err := grab(e.layer)
460+ require.NoError(t, err)
461+ refused(e.top, "topmost grabbing popup")
462+ e.srv.write(frame(e.popupID(t, a2), 1, nil))
463+ require.NoError(t, e.c.Roundtrip())
464+ var done []neferclient.SurfaceID
465+ hd := neferclientmocks.NewMockHandler(t)
466+ hd.EXPECT().PopupDone(mock.Anything).Run(func(id neferclient.SurfaceID) { done = append(done, id) }).Return().Once()
467+ dispatchUntil(t, e.c, hd, func() bool { return len(done) > 0 })
468+ require.Equal(t, []neferclient.SurfaceID{a2.ID()}, done)
469+ top, err := grab(e.top)
470+ require.NoError(t, err, "the chain is closed after popup_done")
471+ require.NoError(t, top.Close())
472+ require.NoError(t, a2.Close())
473+ require.NoError(t, plain.Close())
474+}
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 seat.go +3 -2
@@ -90,6 +90,7 @@ type PointerEvent struct {
9090 Pressed bool // PointerButton
9191 Axis uint32 // PointerAxis, PointerAxisStop: AxisVertical or AxisHorizontal
9292 Value120 int32 // PointerAxis: wheel value in 1/120 notches
93+ Serial uint32 // PointerEnter, PointerButton: the event serial (popup grabs, activation)
9394 }
9495
9596 // KeyEvent is one key press, repeat or release.
@@ -510,7 +511,7 @@ func (s *Seat) applyPointer(ev *event, h Handler) {
510511 s.ptrSurf = sf.id
511512 s.px, s.py = fixed(ev.a), fixed(ev.b)
512513 s.enterSerial, s.shape = ev.serial, 0 // the compositor forgets the cursor on leave
513- pe.Kind = PointerEnter
514+ pe.Kind, pe.Serial = PointerEnter, ev.serial
514515 case evPtrLeave:
515516 if sf := s.c.surfaceByObject(ev.global); s.ptrSurf == 0 || sf == nil || sf.id != s.ptrSurf {
516517 return
@@ -531,7 +532,7 @@ func (s *Seat) applyPointer(ev *event, h Handler) {
531532 s.px, s.py = fixed(ev.a), fixed(ev.b)
532533 pe.Kind = PointerMotion
533534 case evPtrButton:
534- pe.Kind, pe.Button, pe.Pressed = PointerButton, ev.flags, ev.a == 1
535+ pe.Kind, pe.Button, pe.Pressed, pe.Serial = PointerButton, ev.flags, ev.a == 1, ev.serial
535536 case evPtrAxis:
536537 pe.Kind, pe.Axis = PointerAxis, ev.flags
537538 if ev.flags == AxisVertical {
@@ -90,6 +90,7 @@ type PointerEvent struct {
90 Pressed bool // PointerButton90 Pressed bool // PointerButton
91 Axis uint32 // PointerAxis, PointerAxisStop: AxisVertical or AxisHorizontal91 Axis uint32 // PointerAxis, PointerAxisStop: AxisVertical or AxisHorizontal
92 Value120 int32 // PointerAxis: wheel value in 1/120 notches92 Value120 int32 // PointerAxis: wheel value in 1/120 notches
93+ Serial uint32 // PointerEnter, PointerButton: the event serial (popup grabs, activation)
93 }94 }
94 95
95 // KeyEvent is one key press, repeat or release.96 // KeyEvent is one key press, repeat or release.
@@ -510,7 +511,7 @@ func (s *Seat) applyPointer(ev *event, h Handler) {
510 s.ptrSurf = sf.id511 s.ptrSurf = sf.id
511 s.px, s.py = fixed(ev.a), fixed(ev.b)512 s.px, s.py = fixed(ev.a), fixed(ev.b)
512 s.enterSerial, s.shape = ev.serial, 0 // the compositor forgets the cursor on leave513 s.enterSerial, s.shape = ev.serial, 0 // the compositor forgets the cursor on leave
513- pe.Kind = PointerEnter514+ pe.Kind, pe.Serial = PointerEnter, ev.serial
514 case evPtrLeave:515 case evPtrLeave:
515 if sf := s.c.surfaceByObject(ev.global); s.ptrSurf == 0 || sf == nil || sf.id != s.ptrSurf {516 if sf := s.c.surfaceByObject(ev.global); s.ptrSurf == 0 || sf == nil || sf.id != s.ptrSurf {
516 return517 return
@@ -531,7 +532,7 @@ func (s *Seat) applyPointer(ev *event, h Handler) {
531 s.px, s.py = fixed(ev.a), fixed(ev.b)532 s.px, s.py = fixed(ev.a), fixed(ev.b)
532 pe.Kind = PointerMotion533 pe.Kind = PointerMotion
533 case evPtrButton:534 case evPtrButton:
534- pe.Kind, pe.Button, pe.Pressed = PointerButton, ev.flags, ev.a == 1535+ pe.Kind, pe.Button, pe.Pressed, pe.Serial = PointerButton, ev.flags, ev.a == 1, ev.serial
535 case evPtrAxis:536 case evPtrAxis:
536 pe.Kind, pe.Axis = PointerAxis, ev.flags537 pe.Kind, pe.Axis = PointerAxis, ev.flags
537 if ev.flags == AxisVertical {538 if ev.flags == AxisVertical {
M seat_wire_test.go +2 -2
@@ -157,9 +157,9 @@ func TestSeatPointerEvents(t *testing.T) {
157157
158158 require.Len(t, got, 7)
159159 id := s.ID()
160- require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerEnter, X: 10.5, Y: 20.25}, got[0])
160+ require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerEnter, X: 10.5, Y: 20.25, Serial: 77}, got[0])
161161 require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerMotion, X: 11, Y: 21}, got[1])
162- require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerButton, X: 11, Y: 21, Button: 0x110, Pressed: true}, got[2])
162+ require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerButton, X: 11, Y: 21, Button: 0x110, Pressed: true, Serial: 78}, got[2])
163163 require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerAxis, X: 11, Y: 21, Axis: 0, DY: 15}, got[3])
164164 require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerAxis, X: 11, Y: 21, Axis: 1, Value120: -120}, got[4])
165165 require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerAxisStop, X: 11, Y: 21}, got[5])
@@ -157,9 +157,9 @@ func TestSeatPointerEvents(t *testing.T) {
157 157
158 require.Len(t, got, 7)158 require.Len(t, got, 7)
159 id := s.ID()159 id := s.ID()
160- require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerEnter, X: 10.5, Y: 20.25}, got[0])160+ require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerEnter, X: 10.5, Y: 20.25, Serial: 77}, got[0])
161 require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerMotion, X: 11, Y: 21}, got[1])161 require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerMotion, X: 11, Y: 21}, got[1])
162- require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerButton, X: 11, Y: 21, Button: 0x110, Pressed: true}, got[2])162+ require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerButton, X: 11, Y: 21, Button: 0x110, Pressed: true, Serial: 78}, got[2])
163 require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerAxis, X: 11, Y: 21, Axis: 0, DY: 15}, got[3])163 require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerAxis, X: 11, Y: 21, Axis: 0, DY: 15}, got[3])
164 require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerAxis, X: 11, Y: 21, Axis: 1, Value120: -120}, got[4])164 require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerAxis, X: 11, Y: 21, Axis: 1, Value120: -120}, got[4])
165 require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerAxisStop, X: 11, Y: 21}, got[5])165 require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerAxisStop, X: 11, Y: 21}, got[5])
M surface.go +26 -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,16 @@ 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+ grabbed bool // popups: an explicit grab was taken and not dismissed yet
112+
106113 width, height int32
107114 scale float64
108115 pendW, pendH int32 // toplevel size announced before the xdg configure
@@ -564,8 +571,11 @@ func (c *Conn) applySurface(ev *event, h Handler) {
564571 h.Error(errors.New(badEventErrors[ev.a]))
565572 case evClosed:
566573 h.Closed(id)
567- case evXdgToplevel:
574+ case evXdgToplevel, evPopupConfigure:
568575 s.pendW, s.pendH = ev.a, ev.b
576+ case evPopupDone:
577+ s.dropGrab() // the compositor ended the grab
578+ h.PopupDone(id)
569579 case evXdgConfigure:
570580 if s.pendW > 0 && s.pendH > 0 {
571581 s.width, s.height = s.pendW, s.pendH
@@ -728,6 +738,10 @@ func (s *Surface) stageInputRects(rects []Rect) (bool, error) {
728738 // children before parents. It is safe to call more than once. The caller must
729739 // stop using buffers it imported first (the compositor keeps them until it
730740 // processes the destruction).
741+//
742+// xdg-shell requires the topmost popup to go first: Close fails, and destroys
743+// nothing, while popups created on this surface are still open. Close them
744+// first.
731745 func (s *Surface) Close() error {
732746 if s == nil || s.closed {
733747 return nil
@@ -736,6 +750,9 @@ func (s *Surface) Close() error {
736750 s.closed = true
737751 return nil
738752 }
753+ if s.children > 0 {
754+ return fmt.Errorf("neferclient: surface %d still has %d open popup(s): close them first", s.id, s.children)
755+ }
739756 return s.destroy()
740757 }
741758
@@ -743,6 +760,11 @@ func (s *Surface) destroy() error {
743760 s.closed = true
744761 delete(s.c.surfaces, s.id)
745762 s.c.seatSurfaceGone(s)
763+ s.dropGrab()
764+ if s.parent != nil {
765+ s.parent.children--
766+ s.parent = nil
767+ }
746768 var errs []error
747769 add := func(err error) {
748770 if err != nil {
@@ -772,6 +794,9 @@ func (s *Surface) destroy() error {
772794 if s.toplevel != nil {
773795 add(s.toplevel.Destroy())
774796 }
797+ if s.popup != nil {
798+ add(s.popup.Destroy())
799+ }
775800 if s.xdg != nil {
776801 add(s.xdg.Destroy())
777802 }
@@ -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,16 @@ 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+ grabbed bool // popups: an explicit grab was taken and not dismissed yet
112+
106 width, height int32113 width, height int32
107 scale float64114 scale float64
108 pendW, pendH int32 // toplevel size announced before the xdg configure115 pendW, pendH int32 // toplevel size announced before the xdg configure
@@ -564,8 +571,11 @@ func (c *Conn) applySurface(ev *event, h Handler) {
564 h.Error(errors.New(badEventErrors[ev.a]))571 h.Error(errors.New(badEventErrors[ev.a]))
565 case evClosed:572 case evClosed:
566 h.Closed(id)573 h.Closed(id)
567- case evXdgToplevel:574+ case evXdgToplevel, evPopupConfigure:
568 s.pendW, s.pendH = ev.a, ev.b575 s.pendW, s.pendH = ev.a, ev.b
576+ case evPopupDone:
577+ s.dropGrab() // the compositor ended the grab
578+ h.PopupDone(id)
569 case evXdgConfigure:579 case evXdgConfigure:
570 if s.pendW > 0 && s.pendH > 0 {580 if s.pendW > 0 && s.pendH > 0 {
571 s.width, s.height = s.pendW, s.pendH581 s.width, s.height = s.pendW, s.pendH
@@ -728,6 +738,10 @@ func (s *Surface) stageInputRects(rects []Rect) (bool, error) {
728 // children before parents. It is safe to call more than once. The caller must738 // 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 it739 // stop using buffers it imported first (the compositor keeps them until it
730 // processes the destruction).740 // processes the destruction).
741+//
742+// xdg-shell requires the topmost popup to go first: Close fails, and destroys
743+// nothing, while popups created on this surface are still open. Close them
744+// first.
731 func (s *Surface) Close() error {745 func (s *Surface) Close() error {
732 if s == nil || s.closed {746 if s == nil || s.closed {
733 return nil747 return nil
@@ -736,6 +750,9 @@ func (s *Surface) Close() error {
736 s.closed = true750 s.closed = true
737 return nil751 return nil
738 }752 }
753+ if s.children > 0 {
754+ return fmt.Errorf("neferclient: surface %d still has %d open popup(s): close them first", s.id, s.children)
755+ }
739 return s.destroy()756 return s.destroy()
740 }757 }
741 758
@@ -743,6 +760,11 @@ func (s *Surface) destroy() error {
743 s.closed = true760 s.closed = true
744 delete(s.c.surfaces, s.id)761 delete(s.c.surfaces, s.id)
745 s.c.seatSurfaceGone(s)762 s.c.seatSurfaceGone(s)
763+ s.dropGrab()
764+ if s.parent != nil {
765+ s.parent.children--
766+ s.parent = nil
767+ }
746 var errs []error768 var errs []error
747 add := func(err error) {769 add := func(err error) {
748 if err != nil {770 if err != nil {
@@ -772,6 +794,9 @@ func (s *Surface) destroy() error {
772 if s.toplevel != nil {794 if s.toplevel != nil {
773 add(s.toplevel.Destroy())795 add(s.toplevel.Destroy())
774 }796 }
797+ if s.popup != nil {
798+ add(s.popup.Destroy())
799+ }
775 if s.xdg != nil {800 if s.xdg != nil {
776 add(s.xdg.Destroy())801 add(s.xdg.Destroy())
777 }802 }

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