Merge pull request #3: feat(surface): xdg popups with grab and reposition
16 files · +1050 -10
M .github/workflows/ci.yml +7 -2
| @@ -33,8 +33,13 @@ jobs: | |||
| 33 | 33 | git config --global --add safe.directory "$GITHUB_WORKSPACE" | |
| 34 | 34 | install -d -m 700 "$XDG_RUNTIME_DIR" | |
| 35 | 35 | - 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 | |
| 38 | 43 | echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" | |
| 39 | 44 | - run: make check | |
| 40 | 45 | - 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.0 | 36 | + | # Build the tools against the latest x/tools so they can read the |
| 37 | - | go install honnef.co/go/tools/cmd/staticcheck@latest | 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 | |||
| 38 | echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" | 43 | echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" | ||
| 39 | - run: make check | 44 | - run: make check | ||
| 40 | - run: make race | 45 | - run: make race | ||
M README.md +36 -0
| @@ -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 | 77 | - [`examples/layer`](examples/layer): a layer surface with a counter button. | |
| 42 | 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 | ||
| @@ -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 { | |||
| 18 | 18 | neferclient.NopHandler | |
| 19 | 19 | fdReady, errs, frames int | |
| 20 | 20 | keys, pointers int | |
| 21 | + | configures, popupDone int | |
| 21 | 22 | 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 | 26 | func (h *countingHandler) Error(error) { h.errs++ } | |
| 26 | 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 | 32 | func (h *countingHandler) Pointer(*neferclient.PointerEvent) { h.pointers++ } | |
| 29 | 33 | ||
| 30 | 34 | // Key records the text through a comparison that does not allocate: the | |
| @@ -160,3 +164,49 @@ func TestAllocPresent(t *testing.T) { | |||
| 160 | 164 | require.Positive(t, h.frames) | |
| 161 | 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 | + | } | |
| @@ -18,6 +18,7 @@ type countingHandler struct { | |||||
| 18 | neferclient.NopHandler | 18 | neferclient.NopHandler | ||
| 19 | fdReady, errs, frames int | 19 | fdReady, errs, frames int | ||
| 20 | keys, pointers int | 20 | keys, pointers int | ||
| 21 | + | configures, popupDone int | |||
| 21 | lastText string // set only by the allocation-free comparison below | 22 | 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: the | 34 | // 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 { | |||
| 83 | 83 | curLock *Lock // the live session lock, if any | |
| 84 | 84 | tableBuf []byte | |
| 85 | 85 | 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 | |
| 87 | 88 | ||
| 88 | 89 | ready bool // Connect finished; announcements go to the Handler | |
| 89 | 90 | dispatching bool | |
| @@ -83,7 +83,8 @@ type Conn struct { | |||||
| 83 | curLock *Lock // the live session lock, if any | 83 | curLock *Lock // the live session lock, if any | ||
| 84 | tableBuf []byte | 84 | tableBuf []byte | ||
| 85 | seat *Seat | 85 | 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 | |||
| 87 | 88 | ||||
| 88 | ready bool // Connect finished; announcements go to the Handler | 89 | ready bool // Connect finished; announcements go to the Handler | ||
| 89 | dispatching bool | 90 | dispatching bool | ||
M export_test.go +3 -0
| @@ -39,3 +39,6 @@ func (s *Surface) MarkPresented() { s.presented = true } | |||
| 39 | 39 | ||
| 40 | 40 | // DropFrameCallback marks the pending frame callback as answered. | |
| 41 | 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 } | |
| @@ -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 { | |||
| 38 | 38 | fscale *fractionalscale.WpFractionalScaleManager | |
| 39 | 39 | ||
| 40 | 40 | layerVersion uint32 | |
| 41 | + | xdgVersion uint32 | |
| 41 | 42 | triedViewport bool | |
| 42 | 43 | triedFScale bool | |
| 43 | 44 | } | |
| @@ -101,7 +102,7 @@ func (c *Conn) ensureXdg() error { | |||
| 101 | 102 | return nil | |
| 102 | 103 | } | |
| 103 | 104 | q := c.q | |
| 104 | - | x, _, err := c.require(xdgshell.XdgWmBaseInterface, 6, 1, func(ctx *wl.Context) *xdgshell.XdgWmBase { | |
| 105 | + | x, v, err := c.require(xdgshell.XdgWmBaseInterface, 6, 1, func(ctx *wl.Context) *xdgshell.XdgWmBase { | |
| 105 | 106 | x := xdgshell.NewXdgWmBase(ctx) | |
| 106 | 107 | x.OnPing(func(serial uint32) { | |
| 107 | 108 | e := event{kind: evPing, serial: serial} | |
| @@ -109,7 +110,7 @@ func (c *Conn) ensureXdg() error { | |||
| 109 | 110 | }) | |
| 110 | 111 | return x | |
| 111 | 112 | }) | |
| 112 | - | c.g.xdg = x | |
| 113 | + | c.g.xdg, c.g.xdgVersion = x, v | |
| 113 | 114 | return err | |
| 114 | 115 | } | |
| 115 | 116 | ||
| @@ -38,6 +38,7 @@ type globals struct { | |||||
| 38 | fscale *fractionalscale.WpFractionalScaleManager | 38 | fscale *fractionalscale.WpFractionalScaleManager | ||
| 39 | 39 | ||||
| 40 | layerVersion uint32 | 40 | layerVersion uint32 | ||
| 41 | + | xdgVersion uint32 | |||
| 41 | triedViewport bool | 42 | triedViewport bool | ||
| 42 | triedFScale bool | 43 | triedFScale bool | ||
| 43 | } | 44 | } | ||
| @@ -101,7 +102,7 @@ func (c *Conn) ensureXdg() error { | |||||
| 101 | return nil | 102 | return nil | ||
| 102 | } | 103 | } | ||
| 103 | q := c.q | 104 | q := c.q | ||
| 104 | - | x, _, err := c.require(xdgshell.XdgWmBaseInterface, 6, 1, func(ctx *wl.Context) *xdgshell.XdgWmBase { | 105 | + | x, v, err := c.require(xdgshell.XdgWmBaseInterface, 6, 1, func(ctx *wl.Context) *xdgshell.XdgWmBase { |
| 105 | x := xdgshell.NewXdgWmBase(ctx) | 106 | x := xdgshell.NewXdgWmBase(ctx) | ||
| 106 | x.OnPing(func(serial uint32) { | 107 | x.OnPing(func(serial uint32) { | ||
| 107 | e := event{kind: evPing, serial: serial} | 108 | e := event{kind: evPing, serial: serial} | ||
| @@ -109,7 +110,7 @@ func (c *Conn) ensureXdg() error { | |||||
| 109 | }) | 110 | }) | ||
| 110 | return x | 111 | return x | ||
| 111 | }) | 112 | }) | ||
| 112 | - | c.g.xdg = x | 113 | + | c.g.xdg, c.g.xdgVersion = x, v |
| 113 | return err | 114 | return err | ||
| 114 | } | 115 | } | ||
| 115 | 116 | ||||
M handler.go +5 -0
| @@ -40,6 +40,10 @@ type Handler interface { | |||
| 40 | 40 | // layer surface closed). Call [Surface.Close]; nothing else happens | |
| 41 | 41 | // automatically. | |
| 42 | 42 | Closed(id SurfaceID) | |
| 43 | + | // PopupDone reports that the compositor dismissed a popup created with | |
| 44 | + | // [Conn.NewPopup]: an outside click, a denied grab, or a gone parent. Call | |
| 45 | + | // [Surface.Close] on it (and on its own child popups first). | |
| 46 | + | PopupDone(id SurfaceID) | |
| 43 | 47 | // FeedbackDone reports that a complete linux-dmabuf feedback round was | |
| 44 | 48 | // applied; [Surface.Feedback] holds the new main device and formats. It | |
| 45 | 49 | // is sent again whenever the compositor changes its preference. | |
| @@ -87,6 +91,7 @@ func (NopHandler) Configure(SurfaceID, int32, int32) {} | |||
| 87 | 91 | func (NopHandler) Scale(SurfaceID, float64) {} | |
| 88 | 92 | func (NopHandler) Frame(SurfaceID) {} | |
| 89 | 93 | func (NopHandler) Closed(SurfaceID) {} | |
| 94 | + | func (NopHandler) PopupDone(SurfaceID) {} | |
| 90 | 95 | func (NopHandler) FeedbackDone(SurfaceID) {} | |
| 91 | 96 | func (NopHandler) Locked() {} | |
| 92 | 97 | func (NopHandler) LockFinished() {} | |
| @@ -40,6 +40,10 @@ type Handler interface { | |||||
| 40 | // layer surface closed). Call [Surface.Close]; nothing else happens | 40 | // layer surface closed). Call [Surface.Close]; nothing else happens | ||
| 41 | // automatically. | 41 | // automatically. | ||
| 42 | Closed(id SurfaceID) | 42 | Closed(id SurfaceID) | ||
| 43 | + | // PopupDone reports that the compositor dismissed a popup created with | |||
| 44 | + | // [Conn.NewPopup]: an outside click, a denied grab, or a gone parent. Call | |||
| 45 | + | // [Surface.Close] on it (and on its own child popups first). | |||
| 46 | + | PopupDone(id SurfaceID) | |||
| 43 | // FeedbackDone reports that a complete linux-dmabuf feedback round was | 47 | // FeedbackDone reports that a complete linux-dmabuf feedback round was | ||
| 44 | // applied; [Surface.Feedback] holds the new main device and formats. It | 48 | // applied; [Surface.Feedback] holds the new main device and formats. It | ||
| 45 | // is sent again whenever the compositor changes its preference. | 49 | // is sent again whenever the compositor changes its preference. | ||
| @@ -87,6 +91,7 @@ func (NopHandler) Configure(SurfaceID, int32, int32) {} | |||||
| 87 | func (NopHandler) Scale(SurfaceID, float64) {} | 91 | func (NopHandler) Scale(SurfaceID, float64) {} | ||
| 88 | func (NopHandler) Frame(SurfaceID) {} | 92 | func (NopHandler) Frame(SurfaceID) {} | ||
| 89 | func (NopHandler) Closed(SurfaceID) {} | 93 | func (NopHandler) Closed(SurfaceID) {} | ||
| 94 | + | func (NopHandler) PopupDone(SurfaceID) {} | |||
| 90 | func (NopHandler) FeedbackDone(SurfaceID) {} | 95 | func (NopHandler) FeedbackDone(SurfaceID) {} | ||
| 91 | func (NopHandler) Locked() {} | 96 | func (NopHandler) Locked() {} | ||
| 92 | func (NopHandler) LockFinished() {} | 97 | func (NopHandler) LockFinished() {} | ||
M handler_mock_test.go +40 -0
| @@ -568,6 +568,46 @@ func (_c *MockHandler_Pointer_Call) RunAndReturn(run func(ev *PointerEvent)) *Mo | |||
| 568 | 568 | return _c | |
| 569 | 569 | } | |
| 570 | 570 | ||
| 571 | + | // PopupDone provides a mock function for the type MockHandler | |
| 572 | + | func (_mock *MockHandler) PopupDone(id SurfaceID) { | |
| 573 | + | _mock.Called(id) | |
| 574 | + | return | |
| 575 | + | } | |
| 576 | + | ||
| 577 | + | // MockHandler_PopupDone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PopupDone' | |
| 578 | + | type MockHandler_PopupDone_Call struct { | |
| 579 | + | *mock.Call | |
| 580 | + | } | |
| 581 | + | ||
| 582 | + | // PopupDone is a helper method to define mock.On call | |
| 583 | + | // - id SurfaceID | |
| 584 | + | func (_e *MockHandler_Expecter) PopupDone(id any) *MockHandler_PopupDone_Call { | |
| 585 | + | return &MockHandler_PopupDone_Call{Call: _e.mock.On("PopupDone", id)} | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | func (_c *MockHandler_PopupDone_Call) Run(run func(id SurfaceID)) *MockHandler_PopupDone_Call { | |
| 589 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 590 | + | var arg0 SurfaceID | |
| 591 | + | if args[0] != nil { | |
| 592 | + | arg0 = args[0].(SurfaceID) | |
| 593 | + | } | |
| 594 | + | run( | |
| 595 | + | arg0, | |
| 596 | + | ) | |
| 597 | + | }) | |
| 598 | + | return _c | |
| 599 | + | } | |
| 600 | + | ||
| 601 | + | func (_c *MockHandler_PopupDone_Call) Return() *MockHandler_PopupDone_Call { | |
| 602 | + | _c.Call.Return() | |
| 603 | + | return _c | |
| 604 | + | } | |
| 605 | + | ||
| 606 | + | func (_c *MockHandler_PopupDone_Call) RunAndReturn(run func(id SurfaceID)) *MockHandler_PopupDone_Call { | |
| 607 | + | _c.Run(run) | |
| 608 | + | return _c | |
| 609 | + | } | |
| 610 | + | ||
| 571 | 611 | // Scale provides a mock function for the type MockHandler | |
| 572 | 612 | func (_mock *MockHandler) Scale(id SurfaceID, scale float64) { | |
| 573 | 613 | _mock.Called(id, scale) | |
| @@ -568,6 +568,46 @@ func (_c *MockHandler_Pointer_Call) RunAndReturn(run func(ev *PointerEvent)) *Mo | |||||
| 568 | return _c | 568 | return _c | ||
| 569 | } | 569 | } | ||
| 570 | 570 | ||||
| 571 | + | // PopupDone provides a mock function for the type MockHandler | |||
| 572 | + | func (_mock *MockHandler) PopupDone(id SurfaceID) { | |||
| 573 | + | _mock.Called(id) | |||
| 574 | + | return | |||
| 575 | + | } | |||
| 576 | + | ||||
| 577 | + | // MockHandler_PopupDone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PopupDone' | |||
| 578 | + | type MockHandler_PopupDone_Call struct { | |||
| 579 | + | *mock.Call | |||
| 580 | + | } | |||
| 581 | + | ||||
| 582 | + | // PopupDone is a helper method to define mock.On call | |||
| 583 | + | // - id SurfaceID | |||
| 584 | + | func (_e *MockHandler_Expecter) PopupDone(id any) *MockHandler_PopupDone_Call { | |||
| 585 | + | return &MockHandler_PopupDone_Call{Call: _e.mock.On("PopupDone", id)} | |||
| 586 | + | } | |||
| 587 | + | ||||
| 588 | + | func (_c *MockHandler_PopupDone_Call) Run(run func(id SurfaceID)) *MockHandler_PopupDone_Call { | |||
| 589 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 590 | + | var arg0 SurfaceID | |||
| 591 | + | if args[0] != nil { | |||
| 592 | + | arg0 = args[0].(SurfaceID) | |||
| 593 | + | } | |||
| 594 | + | run( | |||
| 595 | + | arg0, | |||
| 596 | + | ) | |||
| 597 | + | }) | |||
| 598 | + | return _c | |||
| 599 | + | } | |||
| 600 | + | ||||
| 601 | + | func (_c *MockHandler_PopupDone_Call) Return() *MockHandler_PopupDone_Call { | |||
| 602 | + | _c.Call.Return() | |||
| 603 | + | return _c | |||
| 604 | + | } | |||
| 605 | + | ||||
| 606 | + | func (_c *MockHandler_PopupDone_Call) RunAndReturn(run func(id SurfaceID)) *MockHandler_PopupDone_Call { | |||
| 607 | + | _c.Run(run) | |||
| 608 | + | return _c | |||
| 609 | + | } | |||
| 610 | + | ||||
| 571 | // Scale provides a mock function for the type MockHandler | 611 | // Scale provides a mock function for the type MockHandler | ||
| 572 | func (_mock *MockHandler) Scale(id SurfaceID, scale float64) { | 612 | func (_mock *MockHandler) Scale(id SurfaceID, scale float64) { | ||
| 573 | _mock.Called(id, scale) | 613 | _mock.Called(id, scale) | ||
M headless_surface_test.go +93 -0
| @@ -270,3 +270,96 @@ func TestHeadlessReconfigureWhilePending(t *testing.T) { | |||
| 270 | 270 | require.NoError(t, c.Roundtrip()) | |
| 271 | 271 | require.NoError(t, c.Dispatch(hd)) | |
| 272 | 272 | } | |
| 273 | + | ||
| 274 | + | // headlessLayer opens a layer surface, waits until it is configured and | |
| 275 | + | // presents one buffer: compositors only place popups of mapped parents. | |
| 276 | + | func headlessLayer(t *testing.T, c *neferclient.Conn, hd *neferclientmocks.MockHandler, configured map[neferclient.SurfaceID][2]int32, frames *int) *neferclient.Surface { | |
| 277 | + | t.Helper() | |
| 278 | + | s, err := c.NewLayerSurface(neferclient.LayerConfig{ | |
| 279 | + | Level: neferclient.LayerTop, Anchors: neferclient.AnchorTop | neferclient.AnchorLeft, Width: 200, Height: 50, | |
| 280 | + | }) | |
| 281 | + | require.NoError(t, err) | |
| 282 | + | var feedback bool | |
| 283 | + | hd.EXPECT().FeedbackDone(mock.Anything).Run(func(id neferclient.SurfaceID) { feedback = feedback || id == s.ID() }).Return().Maybe() | |
| 284 | + | dispatchUntil(t, c, hd, func() bool { _, ok := configured[s.ID()]; return ok && feedback }) | |
| 285 | + | pw, ph, err := s.PhysicalSize() | |
| 286 | + | require.NoError(t, err) | |
| 287 | + | dmabuf := newUdmabuf(t, int(pw), int(ph)) | |
| 288 | + | acquire, signal := newSyncobjFD(t) | |
| 289 | + | release, _ := newSyncobjFD(t) | |
| 290 | + | require.NoError(t, s.ImportBuffer(1, &neferclient.Buffer{Width: pw, Height: ph, FourCC: 0x34325258, PlaneCount: 1, | |
| 291 | + | Planes: [4]neferclient.Plane{{FD: dmabuf, Stride: uint32(pw) * 4}}})) | |
| 292 | + | require.NoError(t, s.ImportTimeline(1, acquire)) | |
| 293 | + | require.NoError(t, s.ImportTimeline(2, release)) | |
| 294 | + | signal(1) | |
| 295 | + | require.NoError(t, s.Present(&neferclient.Present{Buffer: 1, AcquireTimeline: 1, ReleaseTimeline: 2, | |
| 296 | + | AcquirePoint: 1, ReleasePoint: 1, Opaque: true})) | |
| 297 | + | dispatchUntil(t, c, hd, func() bool { return *frames == 1 }) | |
| 298 | + | return s | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | // A popup on a layer surface is configured with the requested size, can be | |
| 302 | + | // repositioned and is closed before its parent. | |
| 303 | + | func TestHeadlessPopupConfigure(t *testing.T) { | |
| 304 | + | c := connectHeadless(t) | |
| 305 | + | configured := map[neferclient.SurfaceID][2]int32{} | |
| 306 | + | var frames int | |
| 307 | + | hd := neferclientmocks.NewMockHandler(t) | |
| 308 | + | hd.EXPECT().Configure(mock.Anything, mock.Anything, mock.Anything).Run(func(id neferclient.SurfaceID, w, h int32) { | |
| 309 | + | configured[id] = [2]int32{w, h} | |
| 310 | + | }).Return().Maybe() | |
| 311 | + | hd.EXPECT().Frame(mock.Anything).Run(func(neferclient.SurfaceID) { frames++ }).Return().Maybe() | |
| 312 | + | hd.EXPECT().Scale(mock.Anything, mock.Anything).Return().Maybe() | |
| 313 | + | parent := headlessLayer(t, c, hd, configured, &frames) | |
| 314 | + | ||
| 315 | + | pl := neferclient.PopupPlacement{ | |
| 316 | + | Anchor: neferclient.Rect{X: 10, Y: 0, Width: 20, Height: 50}, | |
| 317 | + | Edge: neferclient.EdgeBottom, Gravity: neferclient.EdgeBottomRight, | |
| 318 | + | Adjust: neferclient.AdjustSlideX | neferclient.AdjustFlipY, Width: 120, Height: 80, | |
| 319 | + | } | |
| 320 | + | p, err := c.NewPopup(neferclient.PopupConfig{Parent: parent, Place: pl}) | |
| 321 | + | require.NoError(t, err) | |
| 322 | + | dispatchUntil(t, c, hd, func() bool { _, ok := configured[p.ID()]; return ok }) | |
| 323 | + | require.Equal(t, [2]int32{120, 80}, configured[p.ID()]) | |
| 324 | + | ||
| 325 | + | pl.Width, pl.Height = 90, 60 | |
| 326 | + | delete(configured, p.ID()) | |
| 327 | + | require.NoError(t, p.Reposition(pl)) | |
| 328 | + | dispatchUntil(t, c, hd, func() bool { _, ok := configured[p.ID()]; return ok }) | |
| 329 | + | require.Equal(t, [2]int32{90, 60}, configured[p.ID()]) | |
| 330 | + | ||
| 331 | + | require.Error(t, parent.Close(), "parent with an open popup") | |
| 332 | + | require.NoError(t, p.Close()) | |
| 333 | + | require.NoError(t, parent.Close()) | |
| 334 | + | require.NoError(t, c.Roundtrip()) | |
| 335 | + | require.NoError(t, c.Dispatch(hd)) | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | // A grab with a serial that is not the latest button press is dismissed. | |
| 339 | + | func TestHeadlessPopupStaleGrabIsDismissed(t *testing.T) { | |
| 340 | + | c := connectHeadless(t) | |
| 341 | + | configured := map[neferclient.SurfaceID][2]int32{} | |
| 342 | + | var frames int | |
| 343 | + | var done []neferclient.SurfaceID | |
| 344 | + | hd := neferclientmocks.NewMockHandler(t) | |
| 345 | + | hd.EXPECT().Configure(mock.Anything, mock.Anything, mock.Anything).Run(func(id neferclient.SurfaceID, w, h int32) { | |
| 346 | + | configured[id] = [2]int32{w, h} | |
| 347 | + | }).Return().Maybe() | |
| 348 | + | hd.EXPECT().Frame(mock.Anything).Run(func(neferclient.SurfaceID) { frames++ }).Return().Maybe() | |
| 349 | + | hd.EXPECT().Scale(mock.Anything, mock.Anything).Return().Maybe() | |
| 350 | + | hd.EXPECT().PopupDone(mock.Anything).Run(func(id neferclient.SurfaceID) { done = append(done, id) }).Return().Maybe() | |
| 351 | + | hd.EXPECT().Pointer(mock.Anything).Return().Maybe() | |
| 352 | + | hd.EXPECT().KeyboardFocus(mock.Anything, mock.Anything).Return().Maybe() | |
| 353 | + | parent := headlessLayer(t, c, hd, configured, &frames) | |
| 354 | + | ||
| 355 | + | p, err := c.NewPopup(neferclient.PopupConfig{Parent: parent, Grab: true, Serial: 1, Place: neferclient.PopupPlacement{ | |
| 356 | + | Anchor: neferclient.Rect{Width: 20, Height: 50}, Edge: neferclient.EdgeBottom, Gravity: neferclient.EdgeBottomRight, | |
| 357 | + | Width: 100, Height: 40, | |
| 358 | + | }}) | |
| 359 | + | require.NoError(t, err) | |
| 360 | + | dispatchUntil(t, c, hd, func() bool { return len(done) > 0 }) | |
| 361 | + | require.Equal(t, []neferclient.SurfaceID{p.ID()}, done) | |
| 362 | + | require.NoError(t, p.Close()) | |
| 363 | + | require.NoError(t, parent.Close()) | |
| 364 | + | require.NoError(t, c.Roundtrip()) | |
| 365 | + | } | |
| @@ -270,3 +270,96 @@ func TestHeadlessReconfigureWhilePending(t *testing.T) { | |||||
| 270 | require.NoError(t, c.Roundtrip()) | 270 | require.NoError(t, c.Roundtrip()) | ||
| 271 | require.NoError(t, c.Dispatch(hd)) | 271 | require.NoError(t, c.Dispatch(hd)) | ||
| 272 | } | 272 | } | ||
| 273 | + | ||||
| 274 | + | // headlessLayer opens a layer surface, waits until it is configured and | |||
| 275 | + | // presents one buffer: compositors only place popups of mapped parents. | |||
| 276 | + | func headlessLayer(t *testing.T, c *neferclient.Conn, hd *neferclientmocks.MockHandler, configured map[neferclient.SurfaceID][2]int32, frames *int) *neferclient.Surface { | |||
| 277 | + | t.Helper() | |||
| 278 | + | s, err := c.NewLayerSurface(neferclient.LayerConfig{ | |||
| 279 | + | Level: neferclient.LayerTop, Anchors: neferclient.AnchorTop | neferclient.AnchorLeft, Width: 200, Height: 50, | |||
| 280 | + | }) | |||
| 281 | + | require.NoError(t, err) | |||
| 282 | + | var feedback bool | |||
| 283 | + | hd.EXPECT().FeedbackDone(mock.Anything).Run(func(id neferclient.SurfaceID) { feedback = feedback || id == s.ID() }).Return().Maybe() | |||
| 284 | + | dispatchUntil(t, c, hd, func() bool { _, ok := configured[s.ID()]; return ok && feedback }) | |||
| 285 | + | pw, ph, err := s.PhysicalSize() | |||
| 286 | + | require.NoError(t, err) | |||
| 287 | + | dmabuf := newUdmabuf(t, int(pw), int(ph)) | |||
| 288 | + | acquire, signal := newSyncobjFD(t) | |||
| 289 | + | release, _ := newSyncobjFD(t) | |||
| 290 | + | require.NoError(t, s.ImportBuffer(1, &neferclient.Buffer{Width: pw, Height: ph, FourCC: 0x34325258, PlaneCount: 1, | |||
| 291 | + | Planes: [4]neferclient.Plane{{FD: dmabuf, Stride: uint32(pw) * 4}}})) | |||
| 292 | + | require.NoError(t, s.ImportTimeline(1, acquire)) | |||
| 293 | + | require.NoError(t, s.ImportTimeline(2, release)) | |||
| 294 | + | signal(1) | |||
| 295 | + | require.NoError(t, s.Present(&neferclient.Present{Buffer: 1, AcquireTimeline: 1, ReleaseTimeline: 2, | |||
| 296 | + | AcquirePoint: 1, ReleasePoint: 1, Opaque: true})) | |||
| 297 | + | dispatchUntil(t, c, hd, func() bool { return *frames == 1 }) | |||
| 298 | + | return s | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | // A popup on a layer surface is configured with the requested size, can be | |||
| 302 | + | // repositioned and is closed before its parent. | |||
| 303 | + | func TestHeadlessPopupConfigure(t *testing.T) { | |||
| 304 | + | c := connectHeadless(t) | |||
| 305 | + | configured := map[neferclient.SurfaceID][2]int32{} | |||
| 306 | + | var frames int | |||
| 307 | + | hd := neferclientmocks.NewMockHandler(t) | |||
| 308 | + | hd.EXPECT().Configure(mock.Anything, mock.Anything, mock.Anything).Run(func(id neferclient.SurfaceID, w, h int32) { | |||
| 309 | + | configured[id] = [2]int32{w, h} | |||
| 310 | + | }).Return().Maybe() | |||
| 311 | + | hd.EXPECT().Frame(mock.Anything).Run(func(neferclient.SurfaceID) { frames++ }).Return().Maybe() | |||
| 312 | + | hd.EXPECT().Scale(mock.Anything, mock.Anything).Return().Maybe() | |||
| 313 | + | parent := headlessLayer(t, c, hd, configured, &frames) | |||
| 314 | + | ||||
| 315 | + | pl := neferclient.PopupPlacement{ | |||
| 316 | + | Anchor: neferclient.Rect{X: 10, Y: 0, Width: 20, Height: 50}, | |||
| 317 | + | Edge: neferclient.EdgeBottom, Gravity: neferclient.EdgeBottomRight, | |||
| 318 | + | Adjust: neferclient.AdjustSlideX | neferclient.AdjustFlipY, Width: 120, Height: 80, | |||
| 319 | + | } | |||
| 320 | + | p, err := c.NewPopup(neferclient.PopupConfig{Parent: parent, Place: pl}) | |||
| 321 | + | require.NoError(t, err) | |||
| 322 | + | dispatchUntil(t, c, hd, func() bool { _, ok := configured[p.ID()]; return ok }) | |||
| 323 | + | require.Equal(t, [2]int32{120, 80}, configured[p.ID()]) | |||
| 324 | + | ||||
| 325 | + | pl.Width, pl.Height = 90, 60 | |||
| 326 | + | delete(configured, p.ID()) | |||
| 327 | + | require.NoError(t, p.Reposition(pl)) | |||
| 328 | + | dispatchUntil(t, c, hd, func() bool { _, ok := configured[p.ID()]; return ok }) | |||
| 329 | + | require.Equal(t, [2]int32{90, 60}, configured[p.ID()]) | |||
| 330 | + | ||||
| 331 | + | require.Error(t, parent.Close(), "parent with an open popup") | |||
| 332 | + | require.NoError(t, p.Close()) | |||
| 333 | + | require.NoError(t, parent.Close()) | |||
| 334 | + | require.NoError(t, c.Roundtrip()) | |||
| 335 | + | require.NoError(t, c.Dispatch(hd)) | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | // A grab with a serial that is not the latest button press is dismissed. | |||
| 339 | + | func TestHeadlessPopupStaleGrabIsDismissed(t *testing.T) { | |||
| 340 | + | c := connectHeadless(t) | |||
| 341 | + | configured := map[neferclient.SurfaceID][2]int32{} | |||
| 342 | + | var frames int | |||
| 343 | + | var done []neferclient.SurfaceID | |||
| 344 | + | hd := neferclientmocks.NewMockHandler(t) | |||
| 345 | + | hd.EXPECT().Configure(mock.Anything, mock.Anything, mock.Anything).Run(func(id neferclient.SurfaceID, w, h int32) { | |||
| 346 | + | configured[id] = [2]int32{w, h} | |||
| 347 | + | }).Return().Maybe() | |||
| 348 | + | hd.EXPECT().Frame(mock.Anything).Run(func(neferclient.SurfaceID) { frames++ }).Return().Maybe() | |||
| 349 | + | hd.EXPECT().Scale(mock.Anything, mock.Anything).Return().Maybe() | |||
| 350 | + | hd.EXPECT().PopupDone(mock.Anything).Run(func(id neferclient.SurfaceID) { done = append(done, id) }).Return().Maybe() | |||
| 351 | + | hd.EXPECT().Pointer(mock.Anything).Return().Maybe() | |||
| 352 | + | hd.EXPECT().KeyboardFocus(mock.Anything, mock.Anything).Return().Maybe() | |||
| 353 | + | parent := headlessLayer(t, c, hd, configured, &frames) | |||
| 354 | + | ||||
| 355 | + | p, err := c.NewPopup(neferclient.PopupConfig{Parent: parent, Grab: true, Serial: 1, Place: neferclient.PopupPlacement{ | |||
| 356 | + | Anchor: neferclient.Rect{Width: 20, Height: 50}, Edge: neferclient.EdgeBottom, Gravity: neferclient.EdgeBottomRight, | |||
| 357 | + | Width: 100, Height: 40, | |||
| 358 | + | }}) | |||
| 359 | + | require.NoError(t, err) | |||
| 360 | + | dispatchUntil(t, c, hd, func() bool { return len(done) > 0 }) | |||
| 361 | + | require.Equal(t, []neferclient.SurfaceID{p.ID()}, done) | |||
| 362 | + | require.NoError(t, p.Close()) | |||
| 363 | + | require.NoError(t, parent.Close()) | |||
| 364 | + | require.NoError(t, c.Roundtrip()) | |||
| 365 | + | } | |||
M mocks/handler_mock.go +40 -0
| @@ -569,6 +569,46 @@ func (_c *MockHandler_Pointer_Call) RunAndReturn(run func(ev *neferclient.Pointe | |||
| 569 | 569 | return _c | |
| 570 | 570 | } | |
| 571 | 571 | ||
| 572 | + | // PopupDone provides a mock function for the type MockHandler | |
| 573 | + | func (_mock *MockHandler) PopupDone(id neferclient.SurfaceID) { | |
| 574 | + | _mock.Called(id) | |
| 575 | + | return | |
| 576 | + | } | |
| 577 | + | ||
| 578 | + | // MockHandler_PopupDone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PopupDone' | |
| 579 | + | type MockHandler_PopupDone_Call struct { | |
| 580 | + | *mock.Call | |
| 581 | + | } | |
| 582 | + | ||
| 583 | + | // PopupDone is a helper method to define mock.On call | |
| 584 | + | // - id neferclient.SurfaceID | |
| 585 | + | func (_e *MockHandler_Expecter) PopupDone(id any) *MockHandler_PopupDone_Call { | |
| 586 | + | return &MockHandler_PopupDone_Call{Call: _e.mock.On("PopupDone", id)} | |
| 587 | + | } | |
| 588 | + | ||
| 589 | + | func (_c *MockHandler_PopupDone_Call) Run(run func(id neferclient.SurfaceID)) *MockHandler_PopupDone_Call { | |
| 590 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 591 | + | var arg0 neferclient.SurfaceID | |
| 592 | + | if args[0] != nil { | |
| 593 | + | arg0 = args[0].(neferclient.SurfaceID) | |
| 594 | + | } | |
| 595 | + | run( | |
| 596 | + | arg0, | |
| 597 | + | ) | |
| 598 | + | }) | |
| 599 | + | return _c | |
| 600 | + | } | |
| 601 | + | ||
| 602 | + | func (_c *MockHandler_PopupDone_Call) Return() *MockHandler_PopupDone_Call { | |
| 603 | + | _c.Call.Return() | |
| 604 | + | return _c | |
| 605 | + | } | |
| 606 | + | ||
| 607 | + | func (_c *MockHandler_PopupDone_Call) RunAndReturn(run func(id neferclient.SurfaceID)) *MockHandler_PopupDone_Call { | |
| 608 | + | _c.Run(run) | |
| 609 | + | return _c | |
| 610 | + | } | |
| 611 | + | ||
| 572 | 612 | // Scale provides a mock function for the type MockHandler | |
| 573 | 613 | func (_mock *MockHandler) Scale(id neferclient.SurfaceID, scale float64) { | |
| 574 | 614 | _mock.Called(id, scale) | |
| @@ -569,6 +569,46 @@ func (_c *MockHandler_Pointer_Call) RunAndReturn(run func(ev *neferclient.Pointe | |||||
| 569 | return _c | 569 | return _c | ||
| 570 | } | 570 | } | ||
| 571 | 571 | ||||
| 572 | + | // PopupDone provides a mock function for the type MockHandler | |||
| 573 | + | func (_mock *MockHandler) PopupDone(id neferclient.SurfaceID) { | |||
| 574 | + | _mock.Called(id) | |||
| 575 | + | return | |||
| 576 | + | } | |||
| 577 | + | ||||
| 578 | + | // MockHandler_PopupDone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PopupDone' | |||
| 579 | + | type MockHandler_PopupDone_Call struct { | |||
| 580 | + | *mock.Call | |||
| 581 | + | } | |||
| 582 | + | ||||
| 583 | + | // PopupDone is a helper method to define mock.On call | |||
| 584 | + | // - id neferclient.SurfaceID | |||
| 585 | + | func (_e *MockHandler_Expecter) PopupDone(id any) *MockHandler_PopupDone_Call { | |||
| 586 | + | return &MockHandler_PopupDone_Call{Call: _e.mock.On("PopupDone", id)} | |||
| 587 | + | } | |||
| 588 | + | ||||
| 589 | + | func (_c *MockHandler_PopupDone_Call) Run(run func(id neferclient.SurfaceID)) *MockHandler_PopupDone_Call { | |||
| 590 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 591 | + | var arg0 neferclient.SurfaceID | |||
| 592 | + | if args[0] != nil { | |||
| 593 | + | arg0 = args[0].(neferclient.SurfaceID) | |||
| 594 | + | } | |||
| 595 | + | run( | |||
| 596 | + | arg0, | |||
| 597 | + | ) | |||
| 598 | + | }) | |||
| 599 | + | return _c | |||
| 600 | + | } | |||
| 601 | + | ||||
| 602 | + | func (_c *MockHandler_PopupDone_Call) Return() *MockHandler_PopupDone_Call { | |||
| 603 | + | _c.Call.Return() | |||
| 604 | + | return _c | |||
| 605 | + | } | |||
| 606 | + | ||||
| 607 | + | func (_c *MockHandler_PopupDone_Call) RunAndReturn(run func(id neferclient.SurfaceID)) *MockHandler_PopupDone_Call { | |||
| 608 | + | _c.Run(run) | |||
| 609 | + | return _c | |||
| 610 | + | } | |||
| 611 | + | ||||
| 572 | // Scale provides a mock function for the type MockHandler | 612 | // Scale provides a mock function for the type MockHandler | ||
| 573 | func (_mock *MockHandler) Scale(id neferclient.SurfaceID, scale float64) { | 613 | func (_mock *MockHandler) Scale(id neferclient.SurfaceID, scale float64) { | ||
| 574 | _mock.Called(id, scale) | 614 | _mock.Called(id, scale) | ||
A popup.go +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 ( | |||
| 38 | 38 | evFeedbackDone // id | |
| 39 | 39 | evLocked // id = lock generation | |
| 40 | 40 | evLockFinished // id = lock generation | |
| 41 | + | evPopupConfigure // id, a = width, b = height (before the matching evXdgConfigure) | |
| 42 | + | evPopupDone // id | |
| 41 | 43 | evBadEvent // id, a = badEvent code: the reader met a malformed event | |
| 42 | 44 | ||
| 43 | 45 | // Seat events (kind >= evSeatCaps). Pointer and keyboard events carry the | |
| @@ -38,6 +38,8 @@ const ( | |||||
| 38 | evFeedbackDone // id | 38 | evFeedbackDone // id | ||
| 39 | evLocked // id = lock generation | 39 | evLocked // id = lock generation | ||
| 40 | evLockFinished // id = lock generation | 40 | evLockFinished // id = lock generation | ||
| 41 | + | evPopupConfigure // id, a = width, b = height (before the matching evXdgConfigure) | |||
| 42 | + | evPopupDone // id | |||
| 41 | evBadEvent // id, a = badEvent code: the reader met a malformed event | 43 | evBadEvent // id, a = badEvent code: the reader met a malformed event | ||
| 42 | 44 | ||||
| 43 | // Seat events (kind >= evSeatCaps). Pointer and keyboard events carry the | 45 | // Seat events (kind >= evSeatCaps). Pointer and keyboard events carry the | ||
M seat.go +3 -2
| @@ -90,6 +90,7 @@ type PointerEvent struct { | |||
| 90 | 90 | Pressed bool // PointerButton | |
| 91 | 91 | Axis uint32 // PointerAxis, PointerAxisStop: AxisVertical or AxisHorizontal | |
| 92 | 92 | 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 | 96 | // KeyEvent is one key press, repeat or release. | |
| @@ -510,7 +511,7 @@ func (s *Seat) applyPointer(ev *event, h Handler) { | |||
| 510 | 511 | s.ptrSurf = sf.id | |
| 511 | 512 | s.px, s.py = fixed(ev.a), fixed(ev.b) | |
| 512 | 513 | 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 | |
| 514 | 515 | case evPtrLeave: | |
| 515 | 516 | if sf := s.c.surfaceByObject(ev.global); s.ptrSurf == 0 || sf == nil || sf.id != s.ptrSurf { | |
| 516 | 517 | return | |
| @@ -531,7 +532,7 @@ func (s *Seat) applyPointer(ev *event, h Handler) { | |||
| 531 | 532 | s.px, s.py = fixed(ev.a), fixed(ev.b) | |
| 532 | 533 | pe.Kind = PointerMotion | |
| 533 | 534 | 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 | |
| 535 | 536 | case evPtrAxis: | |
| 536 | 537 | pe.Kind, pe.Axis = PointerAxis, ev.flags | |
| 537 | 538 | if ev.flags == AxisVertical { | |
| @@ -90,6 +90,7 @@ type PointerEvent struct { | |||||
| 90 | Pressed bool // PointerButton | 90 | Pressed bool // PointerButton | ||
| 91 | Axis uint32 // PointerAxis, PointerAxisStop: AxisVertical or AxisHorizontal | 91 | Axis uint32 // PointerAxis, PointerAxisStop: AxisVertical or AxisHorizontal | ||
| 92 | Value120 int32 // PointerAxis: wheel value in 1/120 notches | 92 | 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.id | 511 | 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 leave | 513 | 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 |
| 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 | return | 517 | 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 = PointerMotion | 533 | pe.Kind = PointerMotion | ||
| 533 | case evPtrButton: | 534 | 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 |
| 535 | case evPtrAxis: | 536 | case evPtrAxis: | ||
| 536 | pe.Kind, pe.Axis = PointerAxis, ev.flags | 537 | 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) { | |||
| 157 | 157 | ||
| 158 | 158 | require.Len(t, got, 7) | |
| 159 | 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 | 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 | 163 | require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerAxis, X: 11, Y: 21, Axis: 0, DY: 15}, got[3]) | |
| 164 | 164 | require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerAxis, X: 11, Y: 21, Axis: 1, Value120: -120}, got[4]) | |
| 165 | 165 | 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 ( | |||
| 83 | 83 | roleToplevel surfaceRole = iota + 1 | |
| 84 | 84 | roleLayer | |
| 85 | 85 | roleLock | |
| 86 | + | rolePopup | |
| 86 | 87 | ) | |
| 87 | 88 | ||
| 88 | 89 | // Surface is one wl_surface with its role and the DMA-BUF presentation | |
| @@ -99,10 +100,16 @@ type Surface struct { | |||
| 99 | 100 | feedback *linuxdmabuf.LinuxDmabufFeedback | |
| 100 | 101 | xdg *xdgshell.XdgSurface | |
| 101 | 102 | toplevel *xdgshell.XdgToplevel | |
| 103 | + | popup *xdgshell.XdgPopup | |
| 102 | 104 | layer *wlrlayershell.LayerSurface | |
| 103 | 105 | lockSurf *extsessionlock.ExtSessionLockSurface | |
| 104 | 106 | lock *Lock | |
| 105 | 107 | ||
| 108 | + | parent *Surface // popups: the surface they were created on | |
| 109 | + | children int // open popups whose parent this surface is | |
| 110 | + | repoToken uint32 // popups: last xdg_popup.reposition token | |
| 111 | + | grabbed bool // popups: an explicit grab was taken and not dismissed yet | |
| 112 | + | ||
| 106 | 113 | width, height int32 | |
| 107 | 114 | scale float64 | |
| 108 | 115 | pendW, pendH int32 // toplevel size announced before the xdg configure | |
| @@ -564,8 +571,11 @@ func (c *Conn) applySurface(ev *event, h Handler) { | |||
| 564 | 571 | h.Error(errors.New(badEventErrors[ev.a])) | |
| 565 | 572 | case evClosed: | |
| 566 | 573 | h.Closed(id) | |
| 567 | - | case evXdgToplevel: | |
| 574 | + | case evXdgToplevel, evPopupConfigure: | |
| 568 | 575 | s.pendW, s.pendH = ev.a, ev.b | |
| 576 | + | case evPopupDone: | |
| 577 | + | s.dropGrab() // the compositor ended the grab | |
| 578 | + | h.PopupDone(id) | |
| 569 | 579 | case evXdgConfigure: | |
| 570 | 580 | if s.pendW > 0 && s.pendH > 0 { | |
| 571 | 581 | s.width, s.height = s.pendW, s.pendH | |
| @@ -728,6 +738,10 @@ func (s *Surface) stageInputRects(rects []Rect) (bool, error) { | |||
| 728 | 738 | // children before parents. It is safe to call more than once. The caller must | |
| 729 | 739 | // stop using buffers it imported first (the compositor keeps them until it | |
| 730 | 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 | 745 | func (s *Surface) Close() error { | |
| 732 | 746 | if s == nil || s.closed { | |
| 733 | 747 | return nil | |
| @@ -736,6 +750,9 @@ func (s *Surface) Close() error { | |||
| 736 | 750 | s.closed = true | |
| 737 | 751 | 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 | 756 | return s.destroy() | |
| 740 | 757 | } | |
| 741 | 758 | ||
| @@ -743,6 +760,11 @@ func (s *Surface) destroy() error { | |||
| 743 | 760 | s.closed = true | |
| 744 | 761 | delete(s.c.surfaces, s.id) | |
| 745 | 762 | s.c.seatSurfaceGone(s) | |
| 763 | + | s.dropGrab() | |
| 764 | + | if s.parent != nil { | |
| 765 | + | s.parent.children-- | |
| 766 | + | s.parent = nil | |
| 767 | + | } | |
| 746 | 768 | var errs []error | |
| 747 | 769 | add := func(err error) { | |
| 748 | 770 | if err != nil { | |
| @@ -772,6 +794,9 @@ func (s *Surface) destroy() error { | |||
| 772 | 794 | if s.toplevel != nil { | |
| 773 | 795 | add(s.toplevel.Destroy()) | |
| 774 | 796 | } | |
| 797 | + | if s.popup != nil { | |
| 798 | + | add(s.popup.Destroy()) | |
| 799 | + | } | |
| 775 | 800 | if s.xdg != nil { | |
| 776 | 801 | add(s.xdg.Destroy()) | |
| 777 | 802 | } | |
| @@ -83,6 +83,7 @@ const ( | |||||
| 83 | roleToplevel surfaceRole = iota + 1 | 83 | roleToplevel surfaceRole = iota + 1 | ||
| 84 | roleLayer | 84 | roleLayer | ||
| 85 | roleLock | 85 | roleLock | ||
| 86 | + | rolePopup | |||
| 86 | ) | 87 | ) | ||
| 87 | 88 | ||||
| 88 | // Surface is one wl_surface with its role and the DMA-BUF presentation | 89 | // Surface is one wl_surface with its role and the DMA-BUF presentation | ||
| @@ -99,10 +100,16 @@ type Surface struct { | |||||
| 99 | feedback *linuxdmabuf.LinuxDmabufFeedback | 100 | feedback *linuxdmabuf.LinuxDmabufFeedback | ||
| 100 | xdg *xdgshell.XdgSurface | 101 | xdg *xdgshell.XdgSurface | ||
| 101 | toplevel *xdgshell.XdgToplevel | 102 | toplevel *xdgshell.XdgToplevel | ||
| 103 | + | popup *xdgshell.XdgPopup | |||
| 102 | layer *wlrlayershell.LayerSurface | 104 | layer *wlrlayershell.LayerSurface | ||
| 103 | lockSurf *extsessionlock.ExtSessionLockSurface | 105 | lockSurf *extsessionlock.ExtSessionLockSurface | ||
| 104 | lock *Lock | 106 | lock *Lock | ||
| 105 | 107 | ||||
| 108 | + | parent *Surface // popups: the surface they were created on | |||
| 109 | + | children int // open popups whose parent this surface is | |||
| 110 | + | repoToken uint32 // popups: last xdg_popup.reposition token | |||
| 111 | + | grabbed bool // popups: an explicit grab was taken and not dismissed yet | |||
| 112 | + | ||||
| 106 | width, height int32 | 113 | width, height int32 | ||
| 107 | scale float64 | 114 | scale float64 | ||
| 108 | pendW, pendH int32 // toplevel size announced before the xdg configure | 115 | 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.b | 575 | 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.pendH | 581 | 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 must | 738 | // children before parents. It is safe to call more than once. The caller must | ||
| 729 | // stop using buffers it imported first (the compositor keeps them until it | 739 | // 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 nil | 747 | return nil | ||
| @@ -736,6 +750,9 @@ func (s *Surface) Close() error { | |||||
| 736 | s.closed = true | 750 | s.closed = true | ||
| 737 | return nil | 751 | 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 = true | 760 | 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 []error | 768 | 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 | } | ||