detached ace7dd1

fix(wayland): forward X11 pointer constraints and modal changes, remap on surface replacement, clear dangling parents

commit
ace7dd1493faa536f734ad928164f978e9b46094
author
brice · · 2026-10-22 13:42 UTC
committer
brice · · 2026-10-22 17:54 UTC
parent
24f47eb
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +46 -7
M internal/adapters/wayland/constraints.go +6 -5
@@ -298,8 +298,8 @@ func (s *Server) emitConstraint(c *constraint) {
298298 if s.protected() {
299299 return
300300 }
301- x := c.surface.xdg
302- if x == nil || x.window == nil {
301+ w := c.surface.toplevelWindow()
302+ if w == nil {
303303 return
304304 }
305305 mode := ports.ConstraintConfine
@@ -308,8 +308,9 @@ func (s *Server) emitConstraint(c *constraint) {
308308 }
309309 r := c.region
310310 if r.W > 0 && r.H > 0 {
311- r.X -= x.geometry.X
312- r.Y -= x.geometry.Y
311+ g := w.geometry()
312+ r.X -= g.X
313+ r.Y -= g.Y
313314 }
314315 // Core supports one confinement rect, so this bounds the intersection;
315316 // contains uses the exact input region to decide activation.
@@ -333,7 +334,7 @@ func (s *Server) emitConstraint(c *constraint) {
333334 r = box
334335 }
335336 }
336- s.emit(ports.PointerConstrained{ID: x.window.id, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}})
337+ s.emit(ports.PointerConstrained{ID: w.id, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}})
337338 }
338339
339340 // contains reports whether the pointer is in the region: a constraint
@@ -298,8 +298,8 @@ func (s *Server) emitConstraint(c *constraint) {
298 if s.protected() {298 if s.protected() {
299 return299 return
300 }300 }
301- x := c.surface.xdg301+ w := c.surface.toplevelWindow()
302- if x == nil || x.window == nil {302+ if w == nil {
303 return303 return
304 }304 }
305 mode := ports.ConstraintConfine305 mode := ports.ConstraintConfine
@@ -308,8 +308,9 @@ func (s *Server) emitConstraint(c *constraint) {
308 }308 }
309 r := c.region309 r := c.region
310 if r.W > 0 && r.H > 0 {310 if r.W > 0 && r.H > 0 {
311- r.X -= x.geometry.X311+ g := w.geometry()
312- r.Y -= x.geometry.Y312+ r.X -= g.X
313+ r.Y -= g.Y
313 }314 }
314 // Core supports one confinement rect, so this bounds the intersection;315 // Core supports one confinement rect, so this bounds the intersection;
315 // contains uses the exact input region to decide activation.316 // contains uses the exact input region to decide activation.
@@ -333,7 +334,7 @@ func (s *Server) emitConstraint(c *constraint) {
333 r = box334 r = box
334 }335 }
335 }336 }
336- s.emit(ports.PointerConstrained{ID: x.window.id, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}})337+ s.emit(ports.PointerConstrained{ID: w.id, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}})
337 }338 }
338 339
339 // contains reports whether the pointer is in the region: a constraint340 // contains reports whether the pointer is in the region: a constraint
M internal/adapters/wayland/dialog.go +2 -2
@@ -76,7 +76,7 @@ func (w *window) setModal(modal bool) {
7676 return
7777 }
7878 w.modal = modal
79- if w.mapped && w.xdg != nil {
80- w.xdg.server.emit(ports.WindowModal{ID: w.id, Modal: modal})
79+ if w.mapped {
80+ w.srv().emit(ports.WindowModal{ID: w.id, Modal: modal})
8181 }
8282 }
@@ -76,7 +76,7 @@ func (w *window) setModal(modal bool) {
76 return76 return
77 }77 }
78 w.modal = modal78 w.modal = modal
79- if w.mapped && w.xdg != nil {79+ if w.mapped {
80- w.xdg.server.emit(ports.WindowModal{ID: w.id, Modal: modal})80+ w.srv().emit(ports.WindowModal{ID: w.id, Modal: modal})
81 }81 }
82 }82 }
M internal/adapters/wayland/x11window.go +9 -0
@@ -175,6 +175,10 @@ func (h hostImpl) Associate(slot xwm.Slot, surfaceID uint64) {
175175 return
176176 }
177177 x := w.x11
178+ if x.surface != nil && x.surface != surf {
179+ // A new surface replaces the shown one: core sees a remap.
180+ w.unmap()
181+ }
178182 x.drop()
179183 x.surface, surf.x11win = surf, w
180184 surf.role = func(buffer bool) {
@@ -204,6 +208,11 @@ func (h hostImpl) Dissociate(slot xwm.Slot, destroyed bool) {
204208 w.x11.drop()
205209 w.x11.ready = false
206210 if destroyed {
211+ for _, c := range h.s.windows {
212+ if c.parent == w {
213+ c.parent = nil
214+ }
215+ }
207216 delete(h.s.windows, w.id)
208217 delete(h.s.x11Slots, slot)
209218 h.s.foreignToplevelGone(w)
@@ -175,6 +175,10 @@ func (h hostImpl) Associate(slot xwm.Slot, surfaceID uint64) {
175 return175 return
176 }176 }
177 x := w.x11177 x := w.x11
178+ if x.surface != nil && x.surface != surf {
179+ // A new surface replaces the shown one: core sees a remap.
180+ w.unmap()
181+ }
178 x.drop()182 x.drop()
179 x.surface, surf.x11win = surf, w183 x.surface, surf.x11win = surf, w
180 surf.role = func(buffer bool) {184 surf.role = func(buffer bool) {
@@ -204,6 +208,11 @@ func (h hostImpl) Dissociate(slot xwm.Slot, destroyed bool) {
204 w.x11.drop()208 w.x11.drop()
205 w.x11.ready = false209 w.x11.ready = false
206 if destroyed {210 if destroyed {
211+ for _, c := range h.s.windows {
212+ if c.parent == w {
213+ c.parent = nil
214+ }
215+ }
207 delete(h.s.windows, w.id)216 delete(h.s.windows, w.id)
208 delete(h.s.x11Slots, slot)217 delete(h.s.x11Slots, slot)
209 h.s.foreignToplevelGone(w)218 h.s.foreignToplevelGone(w)
M internal/adapters/wayland/x11window_test.go +29 -0
@@ -8,6 +8,7 @@ import (
88 "testing"
99 "time"
1010
11+ "github.com/bnema/go-wayland-bindings/server/pointerconstraints"
1112 "github.com/bnema/go-wayland-bindings/server/wayland"
1213 "github.com/bnema/go-wayland-bindings/server/xdgshell"
1314 "github.com/bnema/go-wayland-bindings/server/xwaylandshell"
@@ -322,3 +323,31 @@ func TestAttachXWMBootAbortsWithContext(t *testing.T) {
322323 assertFDClosed(t, fds[0])
323324 protocolClient(t, s, dir) // still serving
324325 }
326+
327+// Xwayland locks the pointer for games: the constraint reaches core for the
328+// X11 window like for an xdg toplevel.
329+func TestX11WindowPointerLock(t *testing.T) {
330+ r := newX11Rig(t)
331+ win := uint32(x11Win)
332+ r.x11Window(win)
333+ surf := r.pair(win, x11Serial)
334+ m := awaitEvent[ports.WindowMapped](t, r.events)
335+
336+ seat := bindProtocol(t, r.xc, "wl_seat")
337+ registerProtocol(t, r.xc, seat)
338+ pointer := r.xc.AllocateID()
339+ requestProtocol(t, r.xc, seat, wayland.SeatRequestGetPointer, pointer)
340+ registerProtocol(t, r.xc, pointer)
341+ constraints := bindProtocol(t, r.xc, "zwp_pointer_constraints_v1")
342+ lock := r.xc.AllocateID()
343+ requestProtocol(t, r.xc, constraints, pointerconstraints.ZwpPointerConstraintsV1RequestLockPointer, lock, surf, pointer, uint32(0), uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimePersistent))
344+ registerProtocol(t, r.xc, lock)
345+ if err := r.xc.Roundtrip(); err != nil {
346+ t.Fatal(err)
347+ }
348+ r.commands <- ports.PointerFocus{ID: m.ID}
349+ r.commands <- ports.FocusWindow{ID: m.ID}
350+ if got := constrained(t, r.events); got.ID != m.ID || got.Mode != ports.ConstraintLock {
351+ t.Fatalf("constrained %+v", got)
352+ }
353+}
@@ -8,6 +8,7 @@ import (
8 "testing"8 "testing"
9 "time"9 "time"
10 10
11+ "github.com/bnema/go-wayland-bindings/server/pointerconstraints"
11 "github.com/bnema/go-wayland-bindings/server/wayland"12 "github.com/bnema/go-wayland-bindings/server/wayland"
12 "github.com/bnema/go-wayland-bindings/server/xdgshell"13 "github.com/bnema/go-wayland-bindings/server/xdgshell"
13 "github.com/bnema/go-wayland-bindings/server/xwaylandshell"14 "github.com/bnema/go-wayland-bindings/server/xwaylandshell"
@@ -322,3 +323,31 @@ func TestAttachXWMBootAbortsWithContext(t *testing.T) {
322 assertFDClosed(t, fds[0])323 assertFDClosed(t, fds[0])
323 protocolClient(t, s, dir) // still serving324 protocolClient(t, s, dir) // still serving
324 }325 }
326+
327+// Xwayland locks the pointer for games: the constraint reaches core for the
328+// X11 window like for an xdg toplevel.
329+func TestX11WindowPointerLock(t *testing.T) {
330+ r := newX11Rig(t)
331+ win := uint32(x11Win)
332+ r.x11Window(win)
333+ surf := r.pair(win, x11Serial)
334+ m := awaitEvent[ports.WindowMapped](t, r.events)
335+
336+ seat := bindProtocol(t, r.xc, "wl_seat")
337+ registerProtocol(t, r.xc, seat)
338+ pointer := r.xc.AllocateID()
339+ requestProtocol(t, r.xc, seat, wayland.SeatRequestGetPointer, pointer)
340+ registerProtocol(t, r.xc, pointer)
341+ constraints := bindProtocol(t, r.xc, "zwp_pointer_constraints_v1")
342+ lock := r.xc.AllocateID()
343+ requestProtocol(t, r.xc, constraints, pointerconstraints.ZwpPointerConstraintsV1RequestLockPointer, lock, surf, pointer, uint32(0), uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimePersistent))
344+ registerProtocol(t, r.xc, lock)
345+ if err := r.xc.Roundtrip(); err != nil {
346+ t.Fatal(err)
347+ }
348+ r.commands <- ports.PointerFocus{ID: m.ID}
349+ r.commands <- ports.FocusWindow{ID: m.ID}
350+ if got := constrained(t, r.events); got.ID != m.ID || got.Mode != ports.ConstraintLock {
351+ t.Fatalf("constrained %+v", got)
352+ }
353+}

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close