detached 2671432

fix(surface): reject popup grabs without a grabbing parent

Track the open grab chain per connection: a grab needs a grabbing parent
popup and, while a chain is open, its topmost popup. NewPopup also
refuses unconfigured parents, reports the seat bind error as the cause of
a missing seat and keeps positioner destroy errors.
commit
2671432a50649fce0a897c3f62e4aa99d0283364
author
brice · · 2026-10-09 03:55 UTC
parent
1bb0040
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +247 -53
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 popup.go +42 -9
@@ -95,8 +95,7 @@ func (c *Conn) newPositioner(p *PopupPlacement) (*xdgshell.XdgPositioner, error)
9595 pos.SetOffset(p.Offset[0], p.Offset[1]),
9696 } {
9797 if e != nil {
98- _ = pos.Destroy()
99- return nil, e
98+ return nil, errors.Join(e, pos.Destroy())
10099 }
101100 }
102101 return pos, nil
@@ -108,6 +107,16 @@ func (c *Conn) newPositioner(p *PopupPlacement) (*xdgshell.XdgPositioner, error)
108107 // compositor dismisses the popup on an outside click or when the grab is
109108 // denied, and the popup must then be closed.
110109 //
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+//
111120 // Close popups before their parent: Close of a surface with open child popups
112121 // fails.
113122 func (c *Conn) NewPopup(cfg PopupConfig) (*Surface, error) {
@@ -122,6 +131,8 @@ func (c *Conn) NewPopup(cfg PopupConfig) (*Surface, error) {
122131 return nil, fmt.Errorf("neferclient: popup parent: %w", ErrClosed)
123132 case parent.role != roleLayer && parent.role != roleToplevel && parent.role != rolePopup:
124133 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")
125136 }
126137 if err := validatePlacement(&cfg.Place); err != nil {
127138 return nil, err
@@ -132,7 +143,17 @@ func (c *Conn) NewPopup(cfg PopupConfig) (*Surface, error) {
132143 }
133144 c.ensureSeat()
134145 if c.seat == nil || c.seat.proxy == nil {
135- return nil, &CapabilityError{Name: "wl_seat", Cause: errors.New("popup grab needs a seat")}
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")
136157 }
137158 }
138159 if err := c.ensureXdg(); err != nil {
@@ -162,8 +183,8 @@ func (c *Conn) NewPopup(cfg PopupConfig) (*Surface, error) {
162183 parentXdg = parent.xdg
163184 }
164185 s.popup, err = s.xdg.GetPopup(parentXdg, pos)
165- _ = pos.Destroy() // the compositor copies the state at get_popup
166- if err != nil {
186+ // The compositor copies the state at get_popup.
187+ if err = errors.Join(err, pos.Destroy()); err != nil {
167188 return nil, err
168189 }
169190 if parent.role == roleLayer {
@@ -191,6 +212,7 @@ func (c *Conn) NewPopup(cfg PopupConfig) (*Surface, error) {
191212 if err = s.popup.Grab(c.seat.proxy, cfg.Serial); err != nil {
192213 return nil, err
193214 }
215+ s.grabbed, c.grabTop = true, s
194216 }
195217 if err = s.finishSurface(); err != nil {
196218 return nil, err
@@ -223,9 +245,20 @@ func (s *Surface) Reposition(p PopupPlacement) error {
223245 return err
224246 }
225247 s.repoToken++
226- err = s.popup.Reposition(pos, s.repoToken)
227- if e := pos.Destroy(); err == nil {
228- err = e
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+ }
229263 }
230- return err
231264 }
@@ -95,8 +95,7 @@ func (c *Conn) newPositioner(p *PopupPlacement) (*xdgshell.XdgPositioner, error)
95 pos.SetOffset(p.Offset[0], p.Offset[1]),95 pos.SetOffset(p.Offset[0], p.Offset[1]),
96 } {96 } {
97 if e != nil {97 if e != nil {
98- _ = pos.Destroy()98+ return nil, errors.Join(e, pos.Destroy())
99- return nil, e
100 }99 }
101 }100 }
102 return pos, nil101 return pos, nil
@@ -108,6 +107,16 @@ func (c *Conn) newPositioner(p *PopupPlacement) (*xdgshell.XdgPositioner, error)
108 // compositor dismisses the popup on an outside click or when the grab is107 // compositor dismisses the popup on an outside click or when the grab is
109 // denied, and the popup must then be closed.108 // denied, and the popup must then be closed.
110 //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+//
111 // Close popups before their parent: Close of a surface with open child popups120 // Close popups before their parent: Close of a surface with open child popups
112 // fails.121 // fails.
113 func (c *Conn) NewPopup(cfg PopupConfig) (*Surface, error) {122 func (c *Conn) NewPopup(cfg PopupConfig) (*Surface, error) {
@@ -122,6 +131,8 @@ func (c *Conn) NewPopup(cfg PopupConfig) (*Surface, error) {
122 return nil, fmt.Errorf("neferclient: popup parent: %w", ErrClosed)131 return nil, fmt.Errorf("neferclient: popup parent: %w", ErrClosed)
123 case parent.role != roleLayer && parent.role != roleToplevel && parent.role != rolePopup:132 case parent.role != roleLayer && parent.role != roleToplevel && parent.role != rolePopup:
124 return nil, errors.New("neferclient: popup parent must be a layer surface, a toplevel or a popup")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")
125 }136 }
126 if err := validatePlacement(&cfg.Place); err != nil {137 if err := validatePlacement(&cfg.Place); err != nil {
127 return nil, err138 return nil, err
@@ -132,7 +143,17 @@ func (c *Conn) NewPopup(cfg PopupConfig) (*Surface, error) {
132 }143 }
133 c.ensureSeat()144 c.ensureSeat()
134 if c.seat == nil || c.seat.proxy == nil {145 if c.seat == nil || c.seat.proxy == nil {
135- return nil, &CapabilityError{Name: "wl_seat", Cause: errors.New("popup grab needs a seat")}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")
136 }157 }
137 }158 }
138 if err := c.ensureXdg(); err != nil {159 if err := c.ensureXdg(); err != nil {
@@ -162,8 +183,8 @@ func (c *Conn) NewPopup(cfg PopupConfig) (*Surface, error) {
162 parentXdg = parent.xdg183 parentXdg = parent.xdg
163 }184 }
164 s.popup, err = s.xdg.GetPopup(parentXdg, pos)185 s.popup, err = s.xdg.GetPopup(parentXdg, pos)
165- _ = pos.Destroy() // the compositor copies the state at get_popup186+ // The compositor copies the state at get_popup.
166- if err != nil {187+ if err = errors.Join(err, pos.Destroy()); err != nil {
167 return nil, err188 return nil, err
168 }189 }
169 if parent.role == roleLayer {190 if parent.role == roleLayer {
@@ -191,6 +212,7 @@ func (c *Conn) NewPopup(cfg PopupConfig) (*Surface, error) {
191 if err = s.popup.Grab(c.seat.proxy, cfg.Serial); err != nil {212 if err = s.popup.Grab(c.seat.proxy, cfg.Serial); err != nil {
192 return nil, err213 return nil, err
193 }214 }
215+ s.grabbed, c.grabTop = true, s
194 }216 }
195 if err = s.finishSurface(); err != nil {217 if err = s.finishSurface(); err != nil {
196 return nil, err218 return nil, err
@@ -223,9 +245,20 @@ func (s *Surface) Reposition(p PopupPlacement) error {
223 return err245 return err
224 }246 }
225 s.repoToken++247 s.repoToken++
226- err = s.popup.Reposition(pos, s.repoToken)248+ return errors.Join(s.popup.Reposition(pos, s.repoToken), pos.Destroy())
227- if e := pos.Destroy(); err == nil {249+}
228- err = e250+
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+ }
229 }263 }
230- return err
231 }264 }
M popup_wire_test.go +197 -43
@@ -2,6 +2,9 @@ package neferclient_test
22
33 import (
44 "encoding/binary"
5+ "errors"
6+ "math"
7+ "slices"
58 "sync"
69 "testing"
710
@@ -63,6 +66,28 @@ func (l *popupLog) find(from int, match func(popupReq) bool) int {
6366 return -1
6467 }
6568
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+
6691 func (l *popupLog) at(i int) popupReq {
6792 l.mu.Lock()
6893 defer l.mu.Unlock()
@@ -101,10 +126,13 @@ func newPopupEnv(t *testing.T, globals []wireGlobal) *popupEnv {
101126 var err error
102127 e.top, err = c.NewToplevel("parent", 64, 48)
103128 require.NoError(t, err)
104- if len(globals) > 8 {
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 {
105132 e.layer, err = c.NewLayerSurface(neferclient.LayerConfig{Level: neferclient.LayerTop,
106133 Anchors: neferclient.AnchorTop | neferclient.AnchorLeft, Width: 200, Height: 30})
107134 require.NoError(t, err)
135+ e.layer.MarkPresentable(200, 30)
108136 }
109137 require.NoError(t, c.Roundtrip())
110138 return e
@@ -117,14 +145,43 @@ func (e *popupEnv) wmBase() uint32 {
117145 }
118146
119147 // 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+
120156 func (e *popupEnv) xdgSurfaceOf(t *testing.T, surf uint32) uint32 {
121157 t.Helper()
122158 wm := e.wmBase()
123- i := e.log.find(0, func(r popupReq) bool { return r.obj == wm && r.op == 2 && len(r.body) == 8 && r.u32(1) == surf })
159+ i := e.log.findLast(func(r popupReq) bool { return r.obj == wm && r.op == 2 && len(r.body) == 8 && r.u32(1) == surf })
124160 require.GreaterOrEqual(t, i, 0, "xdg_wm_base.get_xdg_surface")
125161 return e.log.at(i).u32(0)
126162 }
127163
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+
128185 var place = neferclient.PopupPlacement{
129186 Anchor: neferclient.Rect{X: 10, Y: 0, Width: 20, Height: 30},
130187 Edge: neferclient.EdgeBottom, Gravity: neferclient.EdgeBottomRight,
@@ -141,29 +198,26 @@ func TestPopupOnLayerParentGrabsBeforeFirstCommit(t *testing.T) {
141198
142199 popupSurf := p.SurfaceObjectID()
143200 xdg := e.xdgSurfaceOf(t, popupSurf)
144- getPopup := e.log.find(n, e.log.op(xdg, 2))
145- require.GreaterOrEqual(t, getPopup, 0, "xdg_surface.get_popup")
146- gp := e.log.at(getPopup)
201+ getPopup, gp := e.getPopup(t, xdg)
147202 popupID, positioner := gp.u32(0), gp.u32(2)
148203 require.Zero(t, gp.u32(1), "layer parent: null xdg parent")
149204
150205 // The layer surface is the one created for e.layer.
151- layerID := e.log.at(e.log.find(0, func(r popupReq) bool { return r.op == 0 && len(r.body) > 8 && r.u32(1) == e.layer.SurfaceObjectID() })).u32(0)
152- layerPopup := e.log.find(n, e.log.op(layerID, 5))
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")
153208 require.Greater(t, layerPopup, getPopup, "layer_surface.get_popup follows xdg_surface.get_popup")
154209 require.Equal(t, popupID, e.log.at(layerPopup).u32(0))
155210
156- grab := e.log.find(n, e.log.op(popupID, 1))
211+ grab := e.log.mustFind(t, n, e.log.op(popupID, 1), "xdg_popup.grab")
157212 require.Greater(t, grab, layerPopup, "grab follows get_popup")
158213 require.Equal(t, uint32(42), e.log.at(grab).u32(1))
159214 require.NotZero(t, e.log.at(grab).u32(0), "seat")
160- commit := e.log.find(n, e.log.op(popupSurf, 6))
215+ commit := e.log.mustFind(t, n, e.log.op(popupSurf, 6), "wl_surface.commit")
161216 require.Greater(t, commit, grab, "grab precedes the first commit")
162217
163218 // Positioner state.
164219 pos := func(op uint16) popupReq {
165- i := e.log.find(n, e.log.op(positioner, op))
166- require.GreaterOrEqual(t, i, 0, "positioner op %d", op)
220+ i := e.log.mustFind(t, n, e.log.op(positioner, op), "positioner request")
167221 require.Less(t, i, getPopup)
168222 return e.log.at(i)
169223 }
@@ -184,10 +238,9 @@ func TestPopupOnToplevelParentUsesParentXdgSurface(t *testing.T) {
184238 require.NoError(t, e.c.Roundtrip())
185239 parentXdg := e.xdgSurfaceOf(t, e.top.SurfaceObjectID())
186240 xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID())
187- i := e.log.find(n, e.log.op(xdg, 2))
188- require.GreaterOrEqual(t, i, 0)
189- require.Equal(t, parentXdg, e.log.at(i).u32(1))
190- popupID := e.log.at(i).u32(0)
241+ _, gp := e.getPopup(t, xdg)
242+ require.Equal(t, parentXdg, gp.u32(1))
243+ popupID := gp.u32(0)
191244 require.Negative(t, e.log.find(n, e.log.op(popupID, 1)), "no grab requested")
192245 }
193246
@@ -195,11 +248,14 @@ func TestPopupNestedOnPopup(t *testing.T) {
195248 e := newPopupEnv(t, popupGlobals)
196249 p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.top, Place: place})
197250 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)
198254 child, err := e.c.NewPopup(neferclient.PopupConfig{Parent: p, Place: place})
199255 require.NoError(t, err)
200256 require.NoError(t, e.c.Roundtrip())
201- i := e.log.find(0, e.log.op(e.xdgSurfaceOf(t, child.SurfaceObjectID()), 2))
202- require.Equal(t, e.xdgSurfaceOf(t, p.SurfaceObjectID()), e.log.at(i).u32(1))
257+ _, gp := e.getPopup(t, e.xdgSurfaceOf(t, child.SurfaceObjectID()))
258+ require.Equal(t, e.xdgSurfaceOf(t, p.SurfaceObjectID()), gp.u32(1))
203259 require.Error(t, p.Close(), "popup with an open child")
204260 require.NoError(t, child.Close())
205261 require.NoError(t, p.Close())
@@ -211,7 +267,8 @@ func TestPopupConfigureAcksAndDone(t *testing.T) {
211267 require.NoError(t, err)
212268 require.NoError(t, e.c.Roundtrip())
213269 xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID())
214- popupID := e.log.at(e.log.find(0, e.log.op(xdg, 2))).u32(0)
270+ _, gp := e.getPopup(t, xdg)
271+ popupID := gp.u32(0)
215272
216273 var w, h int32
217274 var done bool
@@ -226,8 +283,7 @@ func TestPopupConfigureAcksAndDone(t *testing.T) {
226283 dispatchUntil(t, e.c, hd, func() bool { return w != 0 })
227284 require.Equal(t, [2]int32{120, 80}, [2]int32{w, h})
228285 require.NoError(t, e.c.Roundtrip())
229- ack := e.log.find(0, e.log.op(xdg, 4))
230- require.GreaterOrEqual(t, ack, 0, "ack_configure")
286+ ack := e.log.mustFind(t, 0, e.log.op(xdg, 4), "ack_configure")
231287 require.Equal(t, uint32(9), e.log.at(ack).u32(0))
232288 gw, gh, _ := p.Size()
233289 require.Equal(t, [2]int32{120, 80}, [2]int32{gw, gh})
@@ -237,9 +293,8 @@ func TestPopupConfigureAcksAndDone(t *testing.T) {
237293 dispatchUntil(t, e.c, hd, func() bool { return done })
238294 require.NoError(t, p.Close())
239295 require.NoError(t, e.c.Roundtrip())
240- require.NotEmpty(t, e.log.all(e.log.op(popupID, 0)), "xdg_popup.destroy")
241- destroyPopup := e.log.find(0, e.log.op(popupID, 0))
242- destroyXdg := e.log.find(0, e.log.op(xdg, 0))
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")
243298 require.Less(t, destroyPopup, destroyXdg, "popup destroyed before its xdg_surface")
244299 }
245300
@@ -249,19 +304,27 @@ func TestPopupReposition(t *testing.T) {
249304 require.NoError(t, err)
250305 require.NoError(t, e.c.Roundtrip())
251306 xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID())
252- popupID := e.log.at(e.log.find(0, e.log.op(xdg, 2))).u32(0)
307+ _, gp := e.getPopup(t, xdg)
308+ popupID := gp.u32(0)
253309
254310 np := place
255311 np.Anchor.X, np.Width = 50, 90
312+ n := e.log.len()
256313 require.NoError(t, p.Reposition(np))
257314 require.NoError(t, e.c.Roundtrip())
258- i := e.log.find(0, e.log.op(popupID, 2))
259- require.GreaterOrEqual(t, i, 0, "xdg_popup.reposition")
315+ i := e.log.mustFind(t, n, e.log.op(popupID, 2), "xdg_popup.reposition")
260316 positioner := e.log.at(i).u32(0)
261- require.NotEqual(t, uint32(0), e.log.at(i).u32(1), "token")
262- setSize := e.log.at(e.log.find(i-8, e.log.op(positioner, 1)))
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"))
263319 require.Equal(t, uint32(90), setSize.u32(0))
264- require.NotEmpty(t, e.log.all(e.log.op(positioner, 0)), "positioner destroyed")
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")
265328
266329 bad := np
267330 bad.Width = 0
@@ -289,6 +352,17 @@ func TestPopupInvalidConfigsSendNothing(t *testing.T) {
289352 closed, err := e.c.NewToplevel("closed", 64, 48)
290353 require.NoError(t, err)
291354 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)
292366 require.NoError(t, e.c.Roundtrip())
293367 n := e.log.len()
294368
@@ -297,24 +371,104 @@ func TestPopupInvalidConfigsSendNothing(t *testing.T) {
297371 f(&cfg)
298372 return cfg
299373 }
300- for name, cfg := range map[string]neferclient.PopupConfig{
301- "nil parent": with(func(c *neferclient.PopupConfig) { c.Parent = nil }),
302- "closed parent": with(func(c *neferclient.PopupConfig) { c.Parent = closed }),
303- "zero width": with(func(c *neferclient.PopupConfig) { c.Place.Width = 0 }),
304- "negative height": with(func(c *neferclient.PopupConfig) { c.Place.Height = -1 }),
305- "empty anchor": with(func(c *neferclient.PopupConfig) { c.Place.Anchor.Width = 0 }),
306- "bad edge": with(func(c *neferclient.PopupConfig) { c.Place.Edge = 9 }),
307- "bad gravity": with(func(c *neferclient.PopupConfig) { c.Place.Gravity = 9 }),
308- "bad adjust": with(func(c *neferclient.PopupConfig) { c.Place.Adjust = 64 }),
309- "grab no serial": with(func(c *neferclient.PopupConfig) { c.Grab = true }),
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) }},
310395 } {
311- _, err := e.c.NewPopup(cfg)
396+ _, err := e.c.NewPopup(tc.cfg)
312397 require.Error(t, err, name)
398+ tc.want(err)
313399 }
314- var ce *neferclient.CapabilityError
315- _, err = e.c.NewPopup(with(func(c *neferclient.PopupConfig) { c.Grab, c.Serial = true, 1 }))
316- require.ErrorAs(t, err, &ce, "grab without a seat")
317400 require.NoError(t, e.c.Roundtrip())
318401 require.Equal(t, n, e.log.len(), "no request was sent")
319402 require.NoError(t, e.top.Close(), "failed popups left no child behind")
320403 }
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+}
@@ -2,6 +2,9 @@ package neferclient_test
2 2
3 import (3 import (
4 "encoding/binary"4 "encoding/binary"
5+ "errors"
6+ "math"
7+ "slices"
5 "sync"8 "sync"
6 "testing"9 "testing"
7 10
@@ -63,6 +66,28 @@ func (l *popupLog) find(from int, match func(popupReq) bool) int {
63 return -166 return -1
64 }67 }
65 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+
66 func (l *popupLog) at(i int) popupReq {91 func (l *popupLog) at(i int) popupReq {
67 l.mu.Lock()92 l.mu.Lock()
68 defer l.mu.Unlock()93 defer l.mu.Unlock()
@@ -101,10 +126,13 @@ func newPopupEnv(t *testing.T, globals []wireGlobal) *popupEnv {
101 var err error126 var err error
102 e.top, err = c.NewToplevel("parent", 64, 48)127 e.top, err = c.NewToplevel("parent", 64, 48)
103 require.NoError(t, err)128 require.NoError(t, err)
104- if len(globals) > 8 {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 {
105 e.layer, err = c.NewLayerSurface(neferclient.LayerConfig{Level: neferclient.LayerTop,132 e.layer, err = c.NewLayerSurface(neferclient.LayerConfig{Level: neferclient.LayerTop,
106 Anchors: neferclient.AnchorTop | neferclient.AnchorLeft, Width: 200, Height: 30})133 Anchors: neferclient.AnchorTop | neferclient.AnchorLeft, Width: 200, Height: 30})
107 require.NoError(t, err)134 require.NoError(t, err)
135+ e.layer.MarkPresentable(200, 30)
108 }136 }
109 require.NoError(t, c.Roundtrip())137 require.NoError(t, c.Roundtrip())
110 return e138 return e
@@ -117,14 +145,43 @@ func (e *popupEnv) wmBase() uint32 {
117 }145 }
118 146
119 // xdgSurfaceOf returns the xdg_surface id created for wl_surface surf.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+
120 func (e *popupEnv) xdgSurfaceOf(t *testing.T, surf uint32) uint32 {156 func (e *popupEnv) xdgSurfaceOf(t *testing.T, surf uint32) uint32 {
121 t.Helper()157 t.Helper()
122 wm := e.wmBase()158 wm := e.wmBase()
123- i := e.log.find(0, func(r popupReq) bool { return r.obj == wm && r.op == 2 && len(r.body) == 8 && r.u32(1) == surf })159+ i := e.log.findLast(func(r popupReq) bool { return r.obj == wm && r.op == 2 && len(r.body) == 8 && r.u32(1) == surf })
124 require.GreaterOrEqual(t, i, 0, "xdg_wm_base.get_xdg_surface")160 require.GreaterOrEqual(t, i, 0, "xdg_wm_base.get_xdg_surface")
125 return e.log.at(i).u32(0)161 return e.log.at(i).u32(0)
126 }162 }
127 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+
128 var place = neferclient.PopupPlacement{185 var place = neferclient.PopupPlacement{
129 Anchor: neferclient.Rect{X: 10, Y: 0, Width: 20, Height: 30},186 Anchor: neferclient.Rect{X: 10, Y: 0, Width: 20, Height: 30},
130 Edge: neferclient.EdgeBottom, Gravity: neferclient.EdgeBottomRight,187 Edge: neferclient.EdgeBottom, Gravity: neferclient.EdgeBottomRight,
@@ -141,29 +198,26 @@ func TestPopupOnLayerParentGrabsBeforeFirstCommit(t *testing.T) {
141 198
142 popupSurf := p.SurfaceObjectID()199 popupSurf := p.SurfaceObjectID()
143 xdg := e.xdgSurfaceOf(t, popupSurf)200 xdg := e.xdgSurfaceOf(t, popupSurf)
144- getPopup := e.log.find(n, e.log.op(xdg, 2))201+ getPopup, gp := e.getPopup(t, xdg)
145- require.GreaterOrEqual(t, getPopup, 0, "xdg_surface.get_popup")
146- gp := e.log.at(getPopup)
147 popupID, positioner := gp.u32(0), gp.u32(2)202 popupID, positioner := gp.u32(0), gp.u32(2)
148 require.Zero(t, gp.u32(1), "layer parent: null xdg parent")203 require.Zero(t, gp.u32(1), "layer parent: null xdg parent")
149 204
150 // The layer surface is the one created for e.layer.205 // The layer surface is the one created for e.layer.
151- layerID := e.log.at(e.log.find(0, func(r popupReq) bool { return r.op == 0 && len(r.body) > 8 && r.u32(1) == e.layer.SurfaceObjectID() })).u32(0)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)
152- layerPopup := e.log.find(n, e.log.op(layerID, 5))207+ layerPopup := e.log.mustFind(t, n, e.log.op(layerID, 5), "layer_surface.get_popup")
153 require.Greater(t, layerPopup, getPopup, "layer_surface.get_popup follows xdg_surface.get_popup")208 require.Greater(t, layerPopup, getPopup, "layer_surface.get_popup follows xdg_surface.get_popup")
154 require.Equal(t, popupID, e.log.at(layerPopup).u32(0))209 require.Equal(t, popupID, e.log.at(layerPopup).u32(0))
155 210
156- grab := e.log.find(n, e.log.op(popupID, 1))211+ grab := e.log.mustFind(t, n, e.log.op(popupID, 1), "xdg_popup.grab")
157 require.Greater(t, grab, layerPopup, "grab follows get_popup")212 require.Greater(t, grab, layerPopup, "grab follows get_popup")
158 require.Equal(t, uint32(42), e.log.at(grab).u32(1))213 require.Equal(t, uint32(42), e.log.at(grab).u32(1))
159 require.NotZero(t, e.log.at(grab).u32(0), "seat")214 require.NotZero(t, e.log.at(grab).u32(0), "seat")
160- commit := e.log.find(n, e.log.op(popupSurf, 6))215+ commit := e.log.mustFind(t, n, e.log.op(popupSurf, 6), "wl_surface.commit")
161 require.Greater(t, commit, grab, "grab precedes the first commit")216 require.Greater(t, commit, grab, "grab precedes the first commit")
162 217
163 // Positioner state.218 // Positioner state.
164 pos := func(op uint16) popupReq {219 pos := func(op uint16) popupReq {
165- i := e.log.find(n, e.log.op(positioner, op))220+ i := e.log.mustFind(t, n, e.log.op(positioner, op), "positioner request")
166- require.GreaterOrEqual(t, i, 0, "positioner op %d", op)
167 require.Less(t, i, getPopup)221 require.Less(t, i, getPopup)
168 return e.log.at(i)222 return e.log.at(i)
169 }223 }
@@ -184,10 +238,9 @@ func TestPopupOnToplevelParentUsesParentXdgSurface(t *testing.T) {
184 require.NoError(t, e.c.Roundtrip())238 require.NoError(t, e.c.Roundtrip())
185 parentXdg := e.xdgSurfaceOf(t, e.top.SurfaceObjectID())239 parentXdg := e.xdgSurfaceOf(t, e.top.SurfaceObjectID())
186 xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID())240 xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID())
187- i := e.log.find(n, e.log.op(xdg, 2))241+ _, gp := e.getPopup(t, xdg)
188- require.GreaterOrEqual(t, i, 0)242+ require.Equal(t, parentXdg, gp.u32(1))
189- require.Equal(t, parentXdg, e.log.at(i).u32(1))243+ popupID := gp.u32(0)
190- popupID := e.log.at(i).u32(0)
191 require.Negative(t, e.log.find(n, e.log.op(popupID, 1)), "no grab requested")244 require.Negative(t, e.log.find(n, e.log.op(popupID, 1)), "no grab requested")
192 }245 }
193 246
@@ -195,11 +248,14 @@ func TestPopupNestedOnPopup(t *testing.T) {
195 e := newPopupEnv(t, popupGlobals)248 e := newPopupEnv(t, popupGlobals)
196 p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.top, Place: place})249 p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.top, Place: place})
197 require.NoError(t, err)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)
198 child, err := e.c.NewPopup(neferclient.PopupConfig{Parent: p, Place: place})254 child, err := e.c.NewPopup(neferclient.PopupConfig{Parent: p, Place: place})
199 require.NoError(t, err)255 require.NoError(t, err)
200 require.NoError(t, e.c.Roundtrip())256 require.NoError(t, e.c.Roundtrip())
201- i := e.log.find(0, e.log.op(e.xdgSurfaceOf(t, child.SurfaceObjectID()), 2))257+ _, gp := e.getPopup(t, e.xdgSurfaceOf(t, child.SurfaceObjectID()))
202- require.Equal(t, e.xdgSurfaceOf(t, p.SurfaceObjectID()), e.log.at(i).u32(1))258+ require.Equal(t, e.xdgSurfaceOf(t, p.SurfaceObjectID()), gp.u32(1))
203 require.Error(t, p.Close(), "popup with an open child")259 require.Error(t, p.Close(), "popup with an open child")
204 require.NoError(t, child.Close())260 require.NoError(t, child.Close())
205 require.NoError(t, p.Close())261 require.NoError(t, p.Close())
@@ -211,7 +267,8 @@ func TestPopupConfigureAcksAndDone(t *testing.T) {
211 require.NoError(t, err)267 require.NoError(t, err)
212 require.NoError(t, e.c.Roundtrip())268 require.NoError(t, e.c.Roundtrip())
213 xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID())269 xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID())
214- popupID := e.log.at(e.log.find(0, e.log.op(xdg, 2))).u32(0)270+ _, gp := e.getPopup(t, xdg)
271+ popupID := gp.u32(0)
215 272
216 var w, h int32273 var w, h int32
217 var done bool274 var done bool
@@ -226,8 +283,7 @@ func TestPopupConfigureAcksAndDone(t *testing.T) {
226 dispatchUntil(t, e.c, hd, func() bool { return w != 0 })283 dispatchUntil(t, e.c, hd, func() bool { return w != 0 })
227 require.Equal(t, [2]int32{120, 80}, [2]int32{w, h})284 require.Equal(t, [2]int32{120, 80}, [2]int32{w, h})
228 require.NoError(t, e.c.Roundtrip())285 require.NoError(t, e.c.Roundtrip())
229- ack := e.log.find(0, e.log.op(xdg, 4))286+ ack := e.log.mustFind(t, 0, e.log.op(xdg, 4), "ack_configure")
230- require.GreaterOrEqual(t, ack, 0, "ack_configure")
231 require.Equal(t, uint32(9), e.log.at(ack).u32(0))287 require.Equal(t, uint32(9), e.log.at(ack).u32(0))
232 gw, gh, _ := p.Size()288 gw, gh, _ := p.Size()
233 require.Equal(t, [2]int32{120, 80}, [2]int32{gw, gh})289 require.Equal(t, [2]int32{120, 80}, [2]int32{gw, gh})
@@ -237,9 +293,8 @@ func TestPopupConfigureAcksAndDone(t *testing.T) {
237 dispatchUntil(t, e.c, hd, func() bool { return done })293 dispatchUntil(t, e.c, hd, func() bool { return done })
238 require.NoError(t, p.Close())294 require.NoError(t, p.Close())
239 require.NoError(t, e.c.Roundtrip())295 require.NoError(t, e.c.Roundtrip())
240- require.NotEmpty(t, e.log.all(e.log.op(popupID, 0)), "xdg_popup.destroy")296+ destroyPopup := e.log.mustFind(t, 0, e.log.op(popupID, 0), "xdg_popup.destroy")
241- destroyPopup := e.log.find(0, e.log.op(popupID, 0))297+ destroyXdg := e.log.mustFind(t, 0, e.log.op(xdg, 0), "xdg_surface.destroy")
242- destroyXdg := e.log.find(0, e.log.op(xdg, 0))
243 require.Less(t, destroyPopup, destroyXdg, "popup destroyed before its xdg_surface")298 require.Less(t, destroyPopup, destroyXdg, "popup destroyed before its xdg_surface")
244 }299 }
245 300
@@ -249,19 +304,27 @@ func TestPopupReposition(t *testing.T) {
249 require.NoError(t, err)304 require.NoError(t, err)
250 require.NoError(t, e.c.Roundtrip())305 require.NoError(t, e.c.Roundtrip())
251 xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID())306 xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID())
252- popupID := e.log.at(e.log.find(0, e.log.op(xdg, 2))).u32(0)307+ _, gp := e.getPopup(t, xdg)
308+ popupID := gp.u32(0)
253 309
254 np := place310 np := place
255 np.Anchor.X, np.Width = 50, 90311 np.Anchor.X, np.Width = 50, 90
312+ n := e.log.len()
256 require.NoError(t, p.Reposition(np))313 require.NoError(t, p.Reposition(np))
257 require.NoError(t, e.c.Roundtrip())314 require.NoError(t, e.c.Roundtrip())
258- i := e.log.find(0, e.log.op(popupID, 2))315+ i := e.log.mustFind(t, n, e.log.op(popupID, 2), "xdg_popup.reposition")
259- require.GreaterOrEqual(t, i, 0, "xdg_popup.reposition")
260 positioner := e.log.at(i).u32(0)316 positioner := e.log.at(i).u32(0)
261- require.NotEqual(t, uint32(0), e.log.at(i).u32(1), "token")317+ token := e.log.at(i).u32(1)
262- setSize := e.log.at(e.log.find(i-8, e.log.op(positioner, 1)))318+ setSize := e.log.at(e.log.mustFind(t, n, e.log.op(positioner, 1), "positioner.set_size"))
263 require.Equal(t, uint32(90), setSize.u32(0))319 require.Equal(t, uint32(90), setSize.u32(0))
264- require.NotEmpty(t, e.log.all(e.log.op(positioner, 0)), "positioner destroyed")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")
265 328
266 bad := np329 bad := np
267 bad.Width = 0330 bad.Width = 0
@@ -289,6 +352,17 @@ func TestPopupInvalidConfigsSendNothing(t *testing.T) {
289 closed, err := e.c.NewToplevel("closed", 64, 48)352 closed, err := e.c.NewToplevel("closed", 64, 48)
290 require.NoError(t, err)353 require.NoError(t, err)
291 require.NoError(t, closed.Close())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)
292 require.NoError(t, e.c.Roundtrip())366 require.NoError(t, e.c.Roundtrip())
293 n := e.log.len()367 n := e.log.len()
294 368
@@ -297,24 +371,104 @@ func TestPopupInvalidConfigsSendNothing(t *testing.T) {
297 f(&cfg)371 f(&cfg)
298 return cfg372 return cfg
299 }373 }
300- for name, cfg := range map[string]neferclient.PopupConfig{374+ var capErr *neferclient.CapabilityError
301- "nil parent": with(func(c *neferclient.PopupConfig) { c.Parent = nil }),375+ for name, tc := range map[string]struct {
302- "closed parent": with(func(c *neferclient.PopupConfig) { c.Parent = closed }),376+ cfg neferclient.PopupConfig
303- "zero width": with(func(c *neferclient.PopupConfig) { c.Place.Width = 0 }),377+ want func(error)
304- "negative height": with(func(c *neferclient.PopupConfig) { c.Place.Height = -1 }),378+ }{
305- "empty anchor": with(func(c *neferclient.PopupConfig) { c.Place.Anchor.Width = 0 }),379+ "nil parent": {with(func(c *neferclient.PopupConfig) { c.Parent = nil }), func(err error) { require.ErrorContains(t, err, "needs a parent surface") }},
306- "bad edge": with(func(c *neferclient.PopupConfig) { c.Place.Edge = 9 }),380+ "foreign parent": {with(func(c *neferclient.PopupConfig) { c.Parent = foreign }), func(err error) { require.ErrorContains(t, err, "of this connection") }},
307- "bad gravity": with(func(c *neferclient.PopupConfig) { c.Place.Gravity = 9 }),381+ "closed parent": {with(func(c *neferclient.PopupConfig) { c.Parent = closed }), func(err error) { require.ErrorIs(t, err, neferclient.ErrClosed) }},
308- "bad adjust": with(func(c *neferclient.PopupConfig) { c.Place.Adjust = 64 }),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") }},
309- "grab no serial": with(func(c *neferclient.PopupConfig) { c.Grab = true }),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) }},
310 } {395 } {
311- _, err := e.c.NewPopup(cfg)396+ _, err := e.c.NewPopup(tc.cfg)
312 require.Error(t, err, name)397 require.Error(t, err, name)
398+ tc.want(err)
313 }399 }
314- var ce *neferclient.CapabilityError
315- _, err = e.c.NewPopup(with(func(c *neferclient.PopupConfig) { c.Grab, c.Serial = true, 1 }))
316- require.ErrorAs(t, err, &ce, "grab without a seat")
317 require.NoError(t, e.c.Roundtrip())400 require.NoError(t, e.c.Roundtrip())
318 require.Equal(t, n, e.log.len(), "no request was sent")401 require.Equal(t, n, e.log.len(), "no request was sent")
319 require.NoError(t, e.top.Close(), "failed popups left no child behind")402 require.NoError(t, e.top.Close(), "failed popups left no child behind")
320 }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 surface.go +3 -0
@@ -108,6 +108,7 @@ type Surface struct {
108108 parent *Surface // popups: the surface they were created on
109109 children int // open popups whose parent this surface is
110110 repoToken uint32 // popups: last xdg_popup.reposition token
111+ grabbed bool // popups: an explicit grab was taken and not dismissed yet
111112
112113 width, height int32
113114 scale float64
@@ -573,6 +574,7 @@ func (c *Conn) applySurface(ev *event, h Handler) {
573574 case evXdgToplevel, evPopupConfigure:
574575 s.pendW, s.pendH = ev.a, ev.b
575576 case evPopupDone:
577+ s.dropGrab() // the compositor ended the grab
576578 h.PopupDone(id)
577579 case evXdgConfigure:
578580 if s.pendW > 0 && s.pendH > 0 {
@@ -758,6 +760,7 @@ func (s *Surface) destroy() error {
758760 s.closed = true
759761 delete(s.c.surfaces, s.id)
760762 s.c.seatSurfaceGone(s)
763+ s.dropGrab()
761764 if s.parent != nil {
762765 s.parent.children--
763766 s.parent = nil
@@ -108,6 +108,7 @@ type Surface struct {
108 parent *Surface // popups: the surface they were created on108 parent *Surface // popups: the surface they were created on
109 children int // open popups whose parent this surface is109 children int // open popups whose parent this surface is
110 repoToken uint32 // popups: last xdg_popup.reposition token110 repoToken uint32 // popups: last xdg_popup.reposition token
111+ grabbed bool // popups: an explicit grab was taken and not dismissed yet
111 112
112 width, height int32113 width, height int32
113 scale float64114 scale float64
@@ -573,6 +574,7 @@ func (c *Conn) applySurface(ev *event, h Handler) {
573 case evXdgToplevel, evPopupConfigure:574 case evXdgToplevel, evPopupConfigure:
574 s.pendW, s.pendH = ev.a, ev.b575 s.pendW, s.pendH = ev.a, ev.b
575 case evPopupDone:576 case evPopupDone:
577+ s.dropGrab() // the compositor ended the grab
576 h.PopupDone(id)578 h.PopupDone(id)
577 case evXdgConfigure:579 case evXdgConfigure:
578 if s.pendW > 0 && s.pendH > 0 {580 if s.pendW > 0 && s.pendH > 0 {
@@ -758,6 +760,7 @@ func (s *Surface) destroy() error {
758 s.closed = true760 s.closed = true
759 delete(s.c.surfaces, s.id)761 delete(s.c.surfaces, s.id)
760 s.c.seatSurfaceGone(s)762 s.c.seatSurfaceGone(s)
763+ s.dropGrab()
761 if s.parent != nil {764 if s.parent != nil {
762 s.parent.children--765 s.parent.children--
763 s.parent = nil766 s.parent = nil

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