detached f1a2966

feat(xwm): add configure, focus, close and stacking lists for X11 windows

commit
f1a29669f353ea7311af503c5535d00ae1ef448a
author
brice · · 2026-10-22 12:20 UTC
committer
brice · · 2026-10-22 17:54 UTC
parent
50a2e95
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +997 -12
M Makefile +1 -1
@@ -69,7 +69,7 @@ adapter-imports:
6969 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
7070 # Fast, mandatory guards for tiled content publications and SHM copies.
7171 perf-check:
72- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast ./internal/adapters/wayland/xwm -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCommandForwardAllocations|TestOutboxQueuedDeliveryAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing|TestTableLookupAllocations|TestTableInsertRemoveAllocations)$$' -count=1
72+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast ./internal/adapters/wayland/xwm -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCommandForwardAllocations|TestOutboxQueuedDeliveryAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing|TestTableLookupAllocations|TestTableInsertRemoveAllocations|TestConfigureFlushAllocations|TestFocusAllocations)$$' -count=1
7373 CGO_ENABLED=0 go test -tags xturbo_poison ./internal/adapters/wayland/xwm -count=1
7474 check: vet test arch fakes-check log-check perf-check
7575
@@ -69,7 +69,7 @@ adapter-imports:
69 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }69 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
70 # Fast, mandatory guards for tiled content publications and SHM copies.70 # Fast, mandatory guards for tiled content publications and SHM copies.
71 perf-check:71 perf-check:
72- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast ./internal/adapters/wayland/xwm -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCommandForwardAllocations|TestOutboxQueuedDeliveryAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing|TestTableLookupAllocations|TestTableInsertRemoveAllocations)$$' -count=172+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast ./internal/adapters/wayland/xwm -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCommandForwardAllocations|TestOutboxQueuedDeliveryAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing|TestTableLookupAllocations|TestTableInsertRemoveAllocations|TestConfigureFlushAllocations|TestFocusAllocations)$$' -count=1
73 CGO_ENABLED=0 go test -tags xturbo_poison ./internal/adapters/wayland/xwm -count=173 CGO_ENABLED=0 go test -tags xturbo_poison ./internal/adapters/wayland/xwm -count=1
74 check: vet test arch fakes-check log-check perf-check74 check: vet test arch fakes-check log-check perf-check
75 75
M internal/adapters/wayland/xwm/configure.go +226 -7
@@ -2,17 +2,236 @@ package xwm
22
33 import (
44 "bnema.dev/xturbo"
5+ "bnema.dev/xturbo/xproto"
56 "github.com/bnema/neferwl/internal/ports"
67 )
78
8-func (x *XWM) onConfigureRequest(ev *xturbo.Event) { _ = ev }
9-func (x *XWM) onConfigureNotify(ev *xturbo.Event) { _ = ev }
9+// maxClientSize bounds the size a floating window may ask for.
10+const maxClientSize = 16384
1011
11-// Configure records the size and state core wants for s; sent at flush.
12-func (x *XWM) Configure(s Slot, c ports.ConfigureWindow) { _, _ = s, c }
12+// _NET_WM_STATE bits tracked in record.stateSent.
13+const (
14+ stateFullscreen uint8 = 1 << iota
15+ stateFocused
16+)
17+
18+// floats reports whether the window sizes itself: dialog-like windows are
19+// floating and may resize through ConfigureRequest, tiled ones may not.
20+func (r *record) floats() bool {
21+ fixed := r.minW > 0 && r.minH > 0 && r.minW == r.maxW && r.minH == r.maxH
22+ return r.floating || r.transient != 0 || fixed
23+}
24+
25+// size is the size to send: the requested one, else the last known.
26+func (r *record) size() (w, h uint32) {
27+ w, h = r.pend.w, r.pend.h
28+ if w == 0 {
29+ w = r.w
30+ }
31+ if h == 0 {
32+ h = r.h
33+ }
34+ return w, h
35+}
36+
37+// differs reports whether the pending configure is not what the X server has.
38+func (r *record) differs() bool {
39+ w, h := r.size()
40+ p, s := &r.pend, &r.sent
41+ return p.x != s.x || p.y != s.y || w != s.w || h != s.h ||
42+ p.fullscreen != s.fullscreen || p.activated != s.activated
43+}
44+
45+// markDirty queues s for the next flush when its pending configure differs.
46+func (x *XWM) markDirty(s Slot, r *record) {
47+ if r.pend.dirty || !r.differs() {
48+ return
49+ }
50+ if len(x.dirty) == cap(x.dirty) {
51+ // Stale entries of freed and reused slots filled the queue: send
52+ // what is queued rather than grow it. r stays valid, nothing here
53+ // grows the table.
54+ x.flushConfigures()
55+ }
56+ r.pend.dirty = true
57+ x.dirty = append(x.dirty, s)
58+}
59+
60+// managedRec returns the record of a live managed window.
61+func (x *XWM) managedRec(s Slot) *record {
62+ if x.closed {
63+ return nil
64+ }
65+ if r := x.tab.rec(s); r != nil && r.kind == kindManaged {
66+ return r
67+ }
68+ return nil
69+}
70+
71+// Configure records the size and state core wants for s; sent at flush. A
72+// zero size keeps the current one.
73+func (x *XWM) Configure(s Slot, c ports.ConfigureWindow) {
74+ r := x.managedRec(s)
75+ if r == nil {
76+ return
77+ }
78+ if c.Width > 0 {
79+ r.pend.w = uint32(min(c.Width, maxClientSize))
80+ }
81+ if c.Height > 0 {
82+ r.pend.h = uint32(min(c.Height, maxClientSize))
83+ }
84+ r.pend.fullscreen, r.pend.activated = c.Fullscreen, c.Activated
85+ x.markDirty(s, r)
86+ x.wake()
87+}
1388
1489 // Place records the layout position of the client rectangle of s.
15-func (x *XWM) Place(s Slot, px, py int) { _, _, _ = s, px, py }
90+func (x *XWM) Place(s Slot, px, py int) {
91+ r := x.managedRec(s)
92+ if r == nil {
93+ return
94+ }
95+ r.pend.x, r.pend.y = int32(px), int32(py)
96+ x.markDirty(s, r)
97+ x.wake()
98+}
99+
100+// flushConfigures sends the pending configures, once per display iteration,
101+// then what focus left to do.
102+func (x *XWM) flushConfigures() {
103+ for _, s := range x.dirty {
104+ if r := x.tab.rec(s); r != nil && r.kind == kindManaged && r.pend.dirty {
105+ x.sendPending(s, r)
106+ }
107+ }
108+ x.dirty = x.dirty[:0]
109+ x.flushFocus()
110+}
111+
112+// sendPending writes the geometry (with the synthetic ConfigureNotify ICCCM
113+// 4.1.5 asks for, since Xwayland may not move the window) and the
114+// _NET_WM_STATE bits that changed.
115+func (x *XWM) sendPending(s Slot, r *record) {
116+ r.pend.dirty = false
117+ p := &r.pend
118+ w, h := r.size()
119+ if w > 0 && h > 0 && (p.x != r.sent.x || p.y != r.sent.y || w != r.sent.w || h != r.sent.h) {
120+ var v xproto.ConfigureWindowValues
121+ v.SetX(p.x).SetY(p.y).SetWidth(w).SetHeight(h).SetBorderWidth(0)
122+ x.expect(xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(r.xid), &v), kindConfigure, x.tab.tag(s), 0)
123+ r.x, r.y, r.w, r.h = p.x, p.y, w, h
124+ x.sendConfigureNotify(r)
125+ r.sent.x, r.sent.y, r.sent.w, r.sent.h = p.x, p.y, w, h
126+ }
127+ var bits uint8
128+ if p.fullscreen {
129+ bits |= stateFullscreen
130+ }
131+ if p.activated {
132+ bits |= stateFocused
133+ }
134+ r.sent.fullscreen, r.sent.activated = p.fullscreen, p.activated
135+ if bits != r.stateSent {
136+ r.stateSent = bits
137+ x.writeState(r)
138+ }
139+}
140+
141+// writeState rewrites _NET_WM_STATE of r from its bits.
142+func (x *XWM) writeState(r *record) {
143+ var list [3]uint32
144+ n := 0
145+ if r.modal {
146+ list[n] = x.atoms[atomNetWMStateModal]
147+ n++
148+ }
149+ if r.stateSent&stateFullscreen != 0 {
150+ list[n] = x.atoms[atomNetWMStateFullscreen]
151+ n++
152+ }
153+ if r.stateSent&stateFocused != 0 {
154+ list[n] = x.atoms[atomNetWMStateFocused]
155+ n++
156+ }
157+ x.setProp32(r.xid, atomNetWMState, xproto.AtomAtom, list[:n])
158+}
159+
160+// sendConfigureNotify tells the client its geometry as the XWM sees it.
161+func (x *XWM) sendConfigureNotify(r *record) {
162+ var raw [32]byte
163+ d := xproto.ConfigureNotifyEventData{
164+ Event: xproto.Window(r.xid), Window: xproto.Window(r.xid),
165+ X: int16(r.x), Y: int16(r.y), Width: uint16(r.w), Height: uint16(r.h),
166+ }
167+ d.Put(&raw)
168+ xproto.SendEvent(x.conn.Buf(), false, xproto.Window(r.xid), xproto.EventMaskStructureNotify, &raw)
169+}
170+
171+// onConfigureRequest answers a client resizing itself. Tiled windows are told
172+// their current geometry; floating ones get the size they ask for; unmanaged
173+// and unknown windows are configured as requested.
174+func (x *XWM) onConfigureRequest(ev *xturbo.Event) {
175+ e := xproto.ConfigureRequestOf(ev)
176+ xid := uint32(e.Window())
177+ mask := e.ValueMask()
178+ s := x.tab.lookup(xid)
179+ r := x.managedRec(s)
180+ if r == nil {
181+ var v xproto.ConfigureWindowValues
182+ if mask&xproto.ConfigWindowX != 0 {
183+ v.SetX(int32(e.X()))
184+ }
185+ if mask&xproto.ConfigWindowY != 0 {
186+ v.SetY(int32(e.Y()))
187+ }
188+ if mask&xproto.ConfigWindowWidth != 0 {
189+ v.SetWidth(uint32(e.Width()))
190+ }
191+ if mask&xproto.ConfigWindowHeight != 0 {
192+ v.SetHeight(uint32(e.Height()))
193+ }
194+ if mask&xproto.ConfigWindowBorderWidth != 0 {
195+ v.SetBorderWidth(uint32(e.BorderWidth()))
196+ }
197+ if mask&xproto.ConfigWindowSibling != 0 {
198+ v.SetSibling(e.Sibling())
199+ }
200+ if mask&xproto.ConfigWindowStackMode != 0 {
201+ v.SetStackMode(e.StackMode())
202+ }
203+ xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(xid), &v)
204+ return
205+ }
206+ if r.floats() && mask&(xproto.ConfigWindowWidth|xproto.ConfigWindowHeight) != 0 {
207+ w, h := r.w, r.h
208+ if mask&xproto.ConfigWindowWidth != 0 {
209+ w = uint32(min(max(int(e.Width()), 1), maxClientSize))
210+ }
211+ if mask&xproto.ConfigWindowHeight != 0 {
212+ h = uint32(min(max(int(e.Height()), 1), maxClientSize))
213+ }
214+ if w > 0 && h > 0 {
215+ var v xproto.ConfigureWindowValues
216+ v.SetWidth(w).SetHeight(h)
217+ x.expect(xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(xid), &v), kindConfigure, x.tab.tag(s), 0)
218+ r.w, r.h = w, h
219+ // The size is the client's own: core learns it from the commit
220+ // and Configure only resends it when core wants another.
221+ r.pend.w, r.pend.h = w, h
222+ r.sent.w, r.sent.h = w, h
223+ }
224+ }
225+ x.sendConfigureNotify(r)
226+}
16227
17-// flushConfigures sends the pending configures, once per display iteration.
18-func (x *XWM) flushConfigures() {}
228+// onConfigureNotify keeps the known geometry of every top-level window.
229+func (x *XWM) onConfigureNotify(ev *xturbo.Event) {
230+ e := xproto.ConfigureNotifyOf(ev)
231+ if e.Event() != xproto.Window(x.root) {
232+ return
233+ }
234+ if r := x.tab.rec(x.tab.lookup(uint32(e.Window()))); r != nil && r.kind != kindFree {
235+ r.x, r.y, r.w, r.h = int32(e.X()), int32(e.Y()), uint32(e.Width()), uint32(e.Height())
236+ }
237+}
@@ -2,17 +2,236 @@ package xwm
2 2
3 import (3 import (
4 "bnema.dev/xturbo"4 "bnema.dev/xturbo"
5+ "bnema.dev/xturbo/xproto"
5 "github.com/bnema/neferwl/internal/ports"6 "github.com/bnema/neferwl/internal/ports"
6 )7 )
7 8
8-func (x *XWM) onConfigureRequest(ev *xturbo.Event) { _ = ev }9+// maxClientSize bounds the size a floating window may ask for.
9-func (x *XWM) onConfigureNotify(ev *xturbo.Event) { _ = ev }10+const maxClientSize = 16384
10 11
11-// Configure records the size and state core wants for s; sent at flush.12+// _NET_WM_STATE bits tracked in record.stateSent.
12-func (x *XWM) Configure(s Slot, c ports.ConfigureWindow) { _, _ = s, c }13+const (
14+ stateFullscreen uint8 = 1 << iota
15+ stateFocused
16+)
17+
18+// floats reports whether the window sizes itself: dialog-like windows are
19+// floating and may resize through ConfigureRequest, tiled ones may not.
20+func (r *record) floats() bool {
21+ fixed := r.minW > 0 && r.minH > 0 && r.minW == r.maxW && r.minH == r.maxH
22+ return r.floating || r.transient != 0 || fixed
23+}
24+
25+// size is the size to send: the requested one, else the last known.
26+func (r *record) size() (w, h uint32) {
27+ w, h = r.pend.w, r.pend.h
28+ if w == 0 {
29+ w = r.w
30+ }
31+ if h == 0 {
32+ h = r.h
33+ }
34+ return w, h
35+}
36+
37+// differs reports whether the pending configure is not what the X server has.
38+func (r *record) differs() bool {
39+ w, h := r.size()
40+ p, s := &r.pend, &r.sent
41+ return p.x != s.x || p.y != s.y || w != s.w || h != s.h ||
42+ p.fullscreen != s.fullscreen || p.activated != s.activated
43+}
44+
45+// markDirty queues s for the next flush when its pending configure differs.
46+func (x *XWM) markDirty(s Slot, r *record) {
47+ if r.pend.dirty || !r.differs() {
48+ return
49+ }
50+ if len(x.dirty) == cap(x.dirty) {
51+ // Stale entries of freed and reused slots filled the queue: send
52+ // what is queued rather than grow it. r stays valid, nothing here
53+ // grows the table.
54+ x.flushConfigures()
55+ }
56+ r.pend.dirty = true
57+ x.dirty = append(x.dirty, s)
58+}
59+
60+// managedRec returns the record of a live managed window.
61+func (x *XWM) managedRec(s Slot) *record {
62+ if x.closed {
63+ return nil
64+ }
65+ if r := x.tab.rec(s); r != nil && r.kind == kindManaged {
66+ return r
67+ }
68+ return nil
69+}
70+
71+// Configure records the size and state core wants for s; sent at flush. A
72+// zero size keeps the current one.
73+func (x *XWM) Configure(s Slot, c ports.ConfigureWindow) {
74+ r := x.managedRec(s)
75+ if r == nil {
76+ return
77+ }
78+ if c.Width > 0 {
79+ r.pend.w = uint32(min(c.Width, maxClientSize))
80+ }
81+ if c.Height > 0 {
82+ r.pend.h = uint32(min(c.Height, maxClientSize))
83+ }
84+ r.pend.fullscreen, r.pend.activated = c.Fullscreen, c.Activated
85+ x.markDirty(s, r)
86+ x.wake()
87+}
13 88
14 // Place records the layout position of the client rectangle of s.89 // Place records the layout position of the client rectangle of s.
15-func (x *XWM) Place(s Slot, px, py int) { _, _, _ = s, px, py }90+func (x *XWM) Place(s Slot, px, py int) {
91+ r := x.managedRec(s)
92+ if r == nil {
93+ return
94+ }
95+ r.pend.x, r.pend.y = int32(px), int32(py)
96+ x.markDirty(s, r)
97+ x.wake()
98+}
99+
100+// flushConfigures sends the pending configures, once per display iteration,
101+// then what focus left to do.
102+func (x *XWM) flushConfigures() {
103+ for _, s := range x.dirty {
104+ if r := x.tab.rec(s); r != nil && r.kind == kindManaged && r.pend.dirty {
105+ x.sendPending(s, r)
106+ }
107+ }
108+ x.dirty = x.dirty[:0]
109+ x.flushFocus()
110+}
111+
112+// sendPending writes the geometry (with the synthetic ConfigureNotify ICCCM
113+// 4.1.5 asks for, since Xwayland may not move the window) and the
114+// _NET_WM_STATE bits that changed.
115+func (x *XWM) sendPending(s Slot, r *record) {
116+ r.pend.dirty = false
117+ p := &r.pend
118+ w, h := r.size()
119+ if w > 0 && h > 0 && (p.x != r.sent.x || p.y != r.sent.y || w != r.sent.w || h != r.sent.h) {
120+ var v xproto.ConfigureWindowValues
121+ v.SetX(p.x).SetY(p.y).SetWidth(w).SetHeight(h).SetBorderWidth(0)
122+ x.expect(xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(r.xid), &v), kindConfigure, x.tab.tag(s), 0)
123+ r.x, r.y, r.w, r.h = p.x, p.y, w, h
124+ x.sendConfigureNotify(r)
125+ r.sent.x, r.sent.y, r.sent.w, r.sent.h = p.x, p.y, w, h
126+ }
127+ var bits uint8
128+ if p.fullscreen {
129+ bits |= stateFullscreen
130+ }
131+ if p.activated {
132+ bits |= stateFocused
133+ }
134+ r.sent.fullscreen, r.sent.activated = p.fullscreen, p.activated
135+ if bits != r.stateSent {
136+ r.stateSent = bits
137+ x.writeState(r)
138+ }
139+}
140+
141+// writeState rewrites _NET_WM_STATE of r from its bits.
142+func (x *XWM) writeState(r *record) {
143+ var list [3]uint32
144+ n := 0
145+ if r.modal {
146+ list[n] = x.atoms[atomNetWMStateModal]
147+ n++
148+ }
149+ if r.stateSent&stateFullscreen != 0 {
150+ list[n] = x.atoms[atomNetWMStateFullscreen]
151+ n++
152+ }
153+ if r.stateSent&stateFocused != 0 {
154+ list[n] = x.atoms[atomNetWMStateFocused]
155+ n++
156+ }
157+ x.setProp32(r.xid, atomNetWMState, xproto.AtomAtom, list[:n])
158+}
159+
160+// sendConfigureNotify tells the client its geometry as the XWM sees it.
161+func (x *XWM) sendConfigureNotify(r *record) {
162+ var raw [32]byte
163+ d := xproto.ConfigureNotifyEventData{
164+ Event: xproto.Window(r.xid), Window: xproto.Window(r.xid),
165+ X: int16(r.x), Y: int16(r.y), Width: uint16(r.w), Height: uint16(r.h),
166+ }
167+ d.Put(&raw)
168+ xproto.SendEvent(x.conn.Buf(), false, xproto.Window(r.xid), xproto.EventMaskStructureNotify, &raw)
169+}
170+
171+// onConfigureRequest answers a client resizing itself. Tiled windows are told
172+// their current geometry; floating ones get the size they ask for; unmanaged
173+// and unknown windows are configured as requested.
174+func (x *XWM) onConfigureRequest(ev *xturbo.Event) {
175+ e := xproto.ConfigureRequestOf(ev)
176+ xid := uint32(e.Window())
177+ mask := e.ValueMask()
178+ s := x.tab.lookup(xid)
179+ r := x.managedRec(s)
180+ if r == nil {
181+ var v xproto.ConfigureWindowValues
182+ if mask&xproto.ConfigWindowX != 0 {
183+ v.SetX(int32(e.X()))
184+ }
185+ if mask&xproto.ConfigWindowY != 0 {
186+ v.SetY(int32(e.Y()))
187+ }
188+ if mask&xproto.ConfigWindowWidth != 0 {
189+ v.SetWidth(uint32(e.Width()))
190+ }
191+ if mask&xproto.ConfigWindowHeight != 0 {
192+ v.SetHeight(uint32(e.Height()))
193+ }
194+ if mask&xproto.ConfigWindowBorderWidth != 0 {
195+ v.SetBorderWidth(uint32(e.BorderWidth()))
196+ }
197+ if mask&xproto.ConfigWindowSibling != 0 {
198+ v.SetSibling(e.Sibling())
199+ }
200+ if mask&xproto.ConfigWindowStackMode != 0 {
201+ v.SetStackMode(e.StackMode())
202+ }
203+ xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(xid), &v)
204+ return
205+ }
206+ if r.floats() && mask&(xproto.ConfigWindowWidth|xproto.ConfigWindowHeight) != 0 {
207+ w, h := r.w, r.h
208+ if mask&xproto.ConfigWindowWidth != 0 {
209+ w = uint32(min(max(int(e.Width()), 1), maxClientSize))
210+ }
211+ if mask&xproto.ConfigWindowHeight != 0 {
212+ h = uint32(min(max(int(e.Height()), 1), maxClientSize))
213+ }
214+ if w > 0 && h > 0 {
215+ var v xproto.ConfigureWindowValues
216+ v.SetWidth(w).SetHeight(h)
217+ x.expect(xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(xid), &v), kindConfigure, x.tab.tag(s), 0)
218+ r.w, r.h = w, h
219+ // The size is the client's own: core learns it from the commit
220+ // and Configure only resends it when core wants another.
221+ r.pend.w, r.pend.h = w, h
222+ r.sent.w, r.sent.h = w, h
223+ }
224+ }
225+ x.sendConfigureNotify(r)
226+}
16 227
17-// flushConfigures sends the pending configures, once per display iteration.228+// onConfigureNotify keeps the known geometry of every top-level window.
18-func (x *XWM) flushConfigures() {}229+func (x *XWM) onConfigureNotify(ev *xturbo.Event) {
230+ e := xproto.ConfigureNotifyOf(ev)
231+ if e.Event() != xproto.Window(x.root) {
232+ return
233+ }
234+ if r := x.tab.rec(x.tab.lookup(uint32(e.Window()))); r != nil && r.kind != kindFree {
235+ r.x, r.y, r.w, r.h = int32(e.X()), int32(e.Y()), uint32(e.Width()), uint32(e.Height())
236+ }
237+}
A internal/adapters/wayland/xwm/configure_test.go +566 -0
@@ -0,0 +1,566 @@
1+package xwm
2+
3+import (
4+ "encoding/binary"
5+ "testing"
6+ "time"
7+
8+ "bnema.dev/xturbo/xproto"
9+ "github.com/bnema/neferwl/internal/ports"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+// newWin makes a ready managed window known to the XWM and the mocked host.
15+func newWin(t *testing.T, x *XWM, p *xpeer, h *mockhost, xid uint32) Slot {
16+ t.Helper()
17+ h.EXPECT().Created(mock.Anything, xid).Return(ports.WindowID(xid)).Once()
18+ p.createNotify(xid, false)
19+ readUntil(t, x, func() bool { return x.Slot(xid) != 0 })
20+ s := x.Slot(xid)
21+ r := x.tab.rec(s)
22+ r.ready, r.w, r.h = true, 300, 200
23+ return s
24+}
25+
26+// settle flushes the XWM and waits until the peer has seen everything sent so
27+// far; it returns the number of requests received.
28+func settle(t *testing.T, x *XWM, p *xpeer) int {
29+ t.Helper()
30+ x.flush()
31+ n := len(p.requests())
32+ xproto.GetInputFocus(x.conn.Buf())
33+ require.NoError(t, x.conn.Flush())
34+ _, n = p.waitReq(n, func(r xpeerReq) bool { return r.major == opGetInputFocus })
35+ return n
36+}
37+
38+// since returns the requests of the given major opcode received after skip.
39+func since(p *xpeer, skip int, major uint8) []xpeerReq {
40+ var out []xpeerReq
41+ for _, r := range p.requests()[skip:] {
42+ if r.major == major {
43+ out = append(out, r)
44+ }
45+ }
46+ return out
47+}
48+
49+// sentEvents returns the 32-byte events of the SendEvent requests to dest.
50+func sentEvents(p *xpeer, skip int, dest uint32) [][]byte {
51+ var out [][]byte
52+ for _, r := range since(p, skip, opSendEvent) {
53+ if r.u32(4) == dest {
54+ out = append(out, r.raw[12:44])
55+ }
56+ }
57+ return out
58+}
59+
60+func propsTo(p *xpeer, skip int, win uint32, atom string) []xpeerReq {
61+ var out []xpeerReq
62+ a := p.atom(atom)
63+ for _, r := range since(p, skip, opChangeProperty) {
64+ if r.u32(4) == win && r.u32(8) == a {
65+ out = append(out, r)
66+ }
67+ }
68+ return out
69+}
70+
71+func (x *XWM) feed(t *testing.T, p *xpeer, send func()) {
72+ t.Helper()
73+ ev := x.stats.Events
74+ send()
75+ readUntil(t, x, func() bool { return x.stats.Events > ev })
76+}
77+
78+func TestConfigureCoalescesIntoOneRequest(t *testing.T) {
79+ x, p, h, _, runs := attached(t)
80+ const win = 0x500001
81+ s := newWin(t, x, p, h, win)
82+ n := settle(t, x, p)
83+
84+ x.Configure(s, ports.ConfigureWindow{Width: 640, Height: 480})
85+ x.Configure(s, ports.ConfigureWindow{Width: 800, Height: 600, Activated: true})
86+ x.Place(s, 10, 20)
87+ require.Len(t, *runs, 1, "one flush posted")
88+ n2 := settle(t, x, p)
89+
90+ cw := since(p, n, opConfigureWindow)
91+ require.Len(t, cw, 1)
92+ require.Equal(t, uint32(win), cw[0].u32(4))
93+ require.EqualValues(t, 0x1f, binary.LittleEndian.Uint16(cw[0].raw[8:]), "x y width height border")
94+ require.Equal(t, []uint32{10, 20, 800, 600, 0}, []uint32{cw[0].u32(12), cw[0].u32(16), cw[0].u32(20), cw[0].u32(24), cw[0].u32(28)})
95+
96+ evs := sentEvents(p, n, win)
97+ require.Len(t, evs, 1, "one synthetic ConfigureNotify")
98+ require.EqualValues(t, 22, evs[0][0])
99+ require.EqualValues(t, win, binary.LittleEndian.Uint32(evs[0][4:]))
100+ require.EqualValues(t, win, binary.LittleEndian.Uint32(evs[0][8:]))
101+ require.EqualValues(t, 10, binary.LittleEndian.Uint16(evs[0][16:]))
102+ require.EqualValues(t, 20, binary.LittleEndian.Uint16(evs[0][18:]))
103+ require.EqualValues(t, 800, binary.LittleEndian.Uint16(evs[0][20:]))
104+ require.EqualValues(t, 600, binary.LittleEndian.Uint16(evs[0][22:]))
105+ require.Equal(t, xproto.EventMaskStructureNotify, xproto.EventMask(since(p, n, opSendEvent)[0].u32(8)))
106+
107+ // Unchanged: nothing is sent.
108+ x.Configure(s, ports.ConfigureWindow{Width: 800, Height: 600, Activated: true})
109+ x.Place(s, 10, 20)
110+ settle(t, x, p)
111+ require.Len(t, since(p, n2, opConfigureWindow), 0)
112+ require.Len(t, sentEvents(p, n2, win), 0)
113+}
114+
115+func TestConfigureWritesNetWMState(t *testing.T) {
116+ x, p, h, _, _ := attached(t)
117+ const win = 0x500001
118+ s := newWin(t, x, p, h, win)
119+ x.tab.rec(s).modal = true
120+ n := settle(t, x, p)
121+
122+ x.Configure(s, ports.ConfigureWindow{Width: 100, Height: 100})
123+ settle(t, x, p)
124+ require.Empty(t, propsTo(p, n, win, "_NET_WM_STATE"), "no state bit changed")
125+
126+ x.Configure(s, ports.ConfigureWindow{Width: 100, Height: 100, Fullscreen: true, Activated: true})
127+ settle(t, x, p)
128+ st, ok := p.prop(win, "_NET_WM_STATE")
129+ require.True(t, ok)
130+ require.ElementsMatch(t, []uint32{p.atom("_NET_WM_STATE_MODAL"), p.atom("_NET_WM_STATE_FULLSCREEN"), p.atom("_NET_WM_STATE_FOCUSED")}, atomsOf(st))
131+
132+ x.Configure(s, ports.ConfigureWindow{Width: 100, Height: 100, Activated: true})
133+ settle(t, x, p)
134+ st, _ = p.prop(win, "_NET_WM_STATE")
135+ require.ElementsMatch(t, []uint32{p.atom("_NET_WM_STATE_MODAL"), p.atom("_NET_WM_STATE_FOCUSED")}, atomsOf(st))
136+}
137+
138+func TestConfigureBadWindowIsAttributedAndIgnored(t *testing.T) {
139+ x, p, h, _, _ := attached(t)
140+ const win = 0x500001
141+ s := newWin(t, x, p, h, win)
142+ n := settle(t, x, p)
143+ x.Configure(s, ports.ConfigureWindow{Width: 640, Height: 480})
144+ x.flush()
145+ cw := []xpeerReq{}
146+ r, _ := p.waitReq(n, func(r xpeerReq) bool { return r.major == opConfigureWindow })
147+ cw = append(cw, r)
148+
149+ p.mu.Lock()
150+ e := make([]byte, 32)
151+ e[1] = xproto.BadWindow
152+ binary.LittleEndian.PutUint16(e[2:], cw[0].seq)
153+ binary.LittleEndian.PutUint32(e[4:], win)
154+ e[10] = opConfigureWindow
155+ p.write(e)
156+ p.mu.Unlock()
157+ readUntil(t, x, func() bool { return x.stats.Errors == 1 })
158+ require.Zero(t, x.stats.Unattributed)
159+ require.False(t, x.closed)
160+}
161+
162+func sendConfigureRequest(p *xpeer, win uint32, mask uint16, x, y int16, w, h uint16) {
163+ p.event(23, func(b *[32]byte) {
164+ binary.LittleEndian.PutUint32(b[4:], peerRoot)
165+ binary.LittleEndian.PutUint32(b[8:], win)
166+ binary.LittleEndian.PutUint16(b[16:], uint16(x))
167+ binary.LittleEndian.PutUint16(b[18:], uint16(y))
168+ binary.LittleEndian.PutUint16(b[20:], w)
169+ binary.LittleEndian.PutUint16(b[22:], h)
170+ binary.LittleEndian.PutUint16(b[26:], mask)
171+ })
172+}
173+
174+func TestConfigureRequestTiledOnlyGetsNotify(t *testing.T) {
175+ x, p, h, _, _ := attached(t)
176+ const win = 0x500001
177+ newWin(t, x, p, h, win)
178+ n := settle(t, x, p)
179+
180+ x.feed(t, p, func() { sendConfigureRequest(p, win, 0xf, 5, 6, 1000, 900) })
181+ settle(t, x, p)
182+ require.Empty(t, since(p, n, opConfigureWindow), "a tiled window cannot resize itself")
183+ evs := sentEvents(p, n, win)
184+ require.Len(t, evs, 1)
185+ require.EqualValues(t, 22, evs[0][0])
186+ require.EqualValues(t, 300, binary.LittleEndian.Uint16(evs[0][20:]), "current geometry")
187+ require.EqualValues(t, 200, binary.LittleEndian.Uint16(evs[0][22:]))
188+}
189+
190+func TestConfigureRequestFloatingHonoursSize(t *testing.T) {
191+ x, p, h, _, _ := attached(t)
192+ const win = 0x500001
193+ s := newWin(t, x, p, h, win)
194+ x.tab.rec(s).floating = true
195+ n := settle(t, x, p)
196+
197+ x.feed(t, p, func() {
198+ sendConfigureRequest(p, win, uint16(xproto.ConfigWindowWidth|xproto.ConfigWindowHeight), 0, 0, 70000&0xffff, 20000)
199+ })
200+ settle(t, x, p)
201+ cw := since(p, n, opConfigureWindow)
202+ require.Len(t, cw, 1)
203+ require.EqualValues(t, xproto.ConfigWindowWidth|xproto.ConfigWindowHeight, binary.LittleEndian.Uint16(cw[0].raw[8:]))
204+ require.EqualValues(t, 4464, cw[0].u32(12))
205+ require.EqualValues(t, maxClientSize, cw[0].u32(16), "clamped")
206+ require.Len(t, sentEvents(p, n, win), 1)
207+}
208+
209+func TestConfigureRequestUnmanagedHonouredVerbatim(t *testing.T) {
210+ x, p, _, _, _ := attached(t)
211+ const win = 0x500009
212+ p.createNotify(win, true)
213+ readUntil(t, x, func() bool { return x.Slot(win) != 0 })
214+ n := settle(t, x, p)
215+
216+ x.feed(t, p, func() {
217+ sendConfigureRequest(p, win, uint16(xproto.ConfigWindowX|xproto.ConfigWindowY|xproto.ConfigWindowWidth), 30, 40, 50, 60)
218+ })
219+ settle(t, x, p)
220+ cw := since(p, n, opConfigureWindow)
221+ require.Len(t, cw, 1)
222+ require.EqualValues(t, 7, binary.LittleEndian.Uint16(cw[0].raw[8:]))
223+ require.Equal(t, []uint32{30, 40, 50}, []uint32{cw[0].u32(12), cw[0].u32(16), cw[0].u32(20)})
224+ require.Empty(t, sentEvents(p, n, win))
225+}
226+
227+func TestConfigureStaleAndDuplicateDirtySlots(t *testing.T) {
228+ x, p, h, _, _ := attached(t)
229+ const a, b = 0x500001, 0x500002
230+ s := newWin(t, x, p, h, a)
231+ for i := 1; i <= 50; i++ {
232+ x.Configure(s, ports.ConfigureWindow{Width: 100 + i, Height: 100})
233+ }
234+ require.Len(t, x.dirty, 1, "queued once per false→true transition")
235+
236+ // The window dies with its entry queued; the slot is reused.
237+ h.EXPECT().Dissociate(s, true).Once()
238+ p.destroyNotify(a)
239+ readUntil(t, x, func() bool { return x.Slot(a) == 0 })
240+ s2 := newWin(t, x, p, h, b)
241+ require.Equal(t, s, s2, "LIFO slot reuse")
242+ n := len(p.requests())
243+ x.Configure(s2, ports.ConfigureWindow{Width: 640, Height: 480})
244+ require.Len(t, x.dirty, 1)
245+ x.dirty = append(x.dirty, s, s) // stale and duplicate entries must be skipped
246+ settle(t, x, p)
247+ cw := since(p, n, opConfigureWindow)
248+ require.Len(t, cw, 1, "sent once despite duplicates")
249+ require.Equal(t, uint32(b), cw[0].u32(4))
250+ require.Empty(t, x.dirty)
251+ require.LessOrEqual(t, cap(x.dirty), len(x.tab.recs))
252+}
253+
254+func TestDirtyQueueNeverGrows(t *testing.T) {
255+ x, p, h, _, _ := attached(t)
256+ c0 := cap(x.dirty)
257+ const base = 0x600000
258+ for i := 0; i < 3*c0; i++ { // each round leaves a stale entry behind
259+ xid := uint32(base + i)
260+ h.EXPECT().Created(mock.Anything, xid).Return(ports.WindowID(1)).Once()
261+ p.createNotify(xid, false)
262+ readUntil(t, x, func() bool { return x.Slot(xid) != 0 })
263+ s := x.Slot(xid)
264+ x.tab.rec(s).ready = true
265+ x.Configure(s, ports.ConfigureWindow{Width: 10, Height: 10})
266+ h.EXPECT().Dissociate(s, true).Once()
267+ p.destroyNotify(xid)
268+ readUntil(t, x, func() bool { return x.Slot(xid) == 0 })
269+ }
270+ require.Equal(t, c0, cap(x.dirty))
271+ require.LessOrEqual(t, len(x.dirty), c0)
272+}
273+
274+func TestPublicCommandsIgnoredWhenClosed(t *testing.T) {
275+ x, p, h, src, _ := attached(t)
276+ const win = 0x500001
277+ s := newWin(t, x, p, h, win)
278+ h.EXPECT().Dissociate(s, true).Once()
279+ src.EXPECT().Remove().Once()
280+ x.Close()
281+ require.NotPanics(t, func() {
282+ x.Configure(s, ports.ConfigureWindow{Width: 1, Height: 1})
283+ x.Place(s, 1, 2)
284+ x.Focus(s)
285+ x.CloseWindow(s)
286+ })
287+ require.Empty(t, x.dirty)
288+ require.Zero(t, x.active)
289+}
290+
291+// Focus.
292+
293+type focusModel struct {
294+ name string
295+ input, take bool
296+ setFocus bool // SetInputFocus on the window
297+ takeFocus bool // WM_TAKE_FOCUS sent
298+}
299+
300+func TestFocusInputModels(t *testing.T) {
301+ for _, m := range []focusModel{
302+ {"passive", true, false, true, false},
303+ {"locally active", true, true, true, true},
304+ {"globally active", false, true, false, true},
305+ {"no input", false, false, false, false},
306+ } {
307+ t.Run(m.name, func(t *testing.T) {
308+ x, p, h, _, _ := attached(t)
309+ const win = 0x500001
310+ s := newWin(t, x, p, h, win)
311+ r := x.tab.rec(s)
312+ r.input, r.takeFocus = m.input, m.take
313+ n := settle(t, x, p)
314+
315+ x.Focus(s)
316+ settle(t, x, p)
317+ var focuses []xpeerReq
318+ for _, r := range since(p, n, opSetInputFocus) {
319+ if r.u32(4) == win {
320+ focuses = append(focuses, r)
321+ }
322+ }
323+ if m.setFocus {
324+ require.Len(t, focuses, 1)
325+ require.EqualValues(t, xproto.InputFocusNone, focuses[0].raw[1], "never PointerRoot")
326+ require.Zero(t, focuses[0].u32(8), "CurrentTime")
327+ } else {
328+ require.Empty(t, focuses)
329+ }
330+ var takes int
331+ for _, ev := range sentEvents(p, n, win) {
332+ if ev[0] == 33 && binary.LittleEndian.Uint32(ev[8:]) == p.atom("WM_PROTOCOLS") {
333+ require.EqualValues(t, 32, ev[1])
334+ require.Equal(t, p.atom("WM_TAKE_FOCUS"), binary.LittleEndian.Uint32(ev[12:]))
335+ require.Zero(t, binary.LittleEndian.Uint32(ev[16:]), "CurrentTime")
336+ takes++
337+ }
338+ }
339+ require.Equal(t, map[bool]int{true: 1, false: 0}[m.takeFocus], takes)
340+
341+ act, ok := p.prop(peerRoot, "_NET_ACTIVE_WINDOW")
342+ require.True(t, ok)
343+ require.Equal(t, []uint32{win}, atomsOf(act), "named even for No-input windows")
344+ st, ok := p.prop(win, "_NET_WM_STATE")
345+ require.True(t, ok)
346+ require.Contains(t, atomsOf(st), p.atom("_NET_WM_STATE_FOCUSED"))
347+ })
348+ }
349+}
350+
351+func TestFocusActiveWindowWrittenOnlyOnChange(t *testing.T) {
352+ x, p, h, _, _ := attached(t)
353+ const a, b = 0x500001, 0x500002
354+ sa, sb := newWin(t, x, p, h, a), newWin(t, x, p, h, b)
355+ n := settle(t, x, p)
356+
357+ x.Focus(sa)
358+ x.Focus(sa)
359+ settle(t, x, p)
360+ require.Len(t, propsTo(p, n, peerRoot, "_NET_ACTIVE_WINDOW"), 1)
361+
362+ n = settle(t, x, p)
363+ x.Focus(sb)
364+ settle(t, x, p)
365+ act, _ := p.prop(peerRoot, "_NET_ACTIVE_WINDOW")
366+ require.Equal(t, []uint32{b}, atomsOf(act))
367+ sta, _ := p.prop(a, "_NET_WM_STATE")
368+ require.NotContains(t, atomsOf(sta), p.atom("_NET_WM_STATE_FOCUSED"), "old window loses FOCUSED")
369+ stb, _ := p.prop(b, "_NET_WM_STATE")
370+ require.Contains(t, atomsOf(stb), p.atom("_NET_WM_STATE_FOCUSED"))
371+
372+ n = settle(t, x, p)
373+ x.Focus(0)
374+ settle(t, x, p)
375+ var none []xpeerReq
376+ for _, r := range since(p, n, opSetInputFocus) {
377+ none = append(none, r)
378+ }
379+ require.Len(t, none, 1)
380+ require.Zero(t, none[0].u32(4), "focus None")
381+ require.EqualValues(t, xproto.InputFocusNone, none[0].raw[1])
382+ act, _ = p.prop(peerRoot, "_NET_ACTIVE_WINDOW")
383+ require.Equal(t, []uint32{0}, atomsOf(act))
384+ stb, _ = p.prop(b, "_NET_WM_STATE")
385+ require.NotContains(t, atomsOf(stb), p.atom("_NET_WM_STATE_FOCUSED"))
386+ require.Zero(t, x.active)
387+}
388+
389+func TestFocusStackingLists(t *testing.T) {
390+ x, p, h, _, _ := attached(t)
391+ const a, b, c = 0x500001, 0x500002, 0x500003
392+ sa := newWin(t, x, p, h, a)
393+ newWin(t, x, p, h, b)
394+ newWin(t, x, p, h, c)
395+ for _, w := range []uint32{a, b, c} {
396+ x.addClient(w)
397+ }
398+ settle(t, x, p)
399+ list, _ := p.prop(peerRoot, "_NET_CLIENT_LIST")
400+ require.Equal(t, []uint32{a, b, c}, atomsOf(list))
401+ n := settle(t, x, p)
402+
403+ x.Focus(sa)
404+ settle(t, x, p)
405+ stack, _ := p.prop(peerRoot, "_NET_CLIENT_LIST_STACKING")
406+ require.Equal(t, []uint32{b, c, a}, atomsOf(stack), "bottom to top")
407+ list, _ = p.prop(peerRoot, "_NET_CLIENT_LIST")
408+ require.Equal(t, []uint32{a, b, c}, atomsOf(list), "map order")
409+ require.Empty(t, propsTo(p, n, peerRoot, "_NET_CLIENT_LIST"), "clients list untouched by focus")
410+ var raised bool
411+ for _, r := range since(p, n, opConfigureWindow) {
412+ if r.u32(4) == a && binary.LittleEndian.Uint16(r.raw[8:]) == uint16(xproto.ConfigWindowStackMode) {
413+ require.EqualValues(t, xproto.StackModeAbove, r.u32(12))
414+ raised = true
415+ }
416+ }
417+ require.True(t, raised)
418+
419+ // A burst costs one write per list.
420+ n = settle(t, x, p)
421+ x.removeClient(b)
422+ x.removeClient(c)
423+ settle(t, x, p)
424+ require.Len(t, propsTo(p, n, peerRoot, "_NET_CLIENT_LIST"), 1)
425+ require.Len(t, propsTo(p, n, peerRoot, "_NET_CLIENT_LIST_STACKING"), 1)
426+ stack, _ = p.prop(peerRoot, "_NET_CLIENT_LIST_STACKING")
427+ require.Equal(t, []uint32{a}, atomsOf(stack))
428+}
429+
430+func TestDestroyedActiveWindowClearsActive(t *testing.T) {
431+ x, p, h, _, _ := attached(t)
432+ const win = 0x500001
433+ s := newWin(t, x, p, h, win)
434+ x.addClient(win)
435+ x.Focus(s)
436+ settle(t, x, p)
437+ h.EXPECT().Dissociate(s, true).Once()
438+ p.destroyNotify(win)
439+ readUntil(t, x, func() bool { return x.Slot(win) == 0 })
440+ settle(t, x, p)
441+ require.Zero(t, x.active)
442+ act, _ := p.prop(peerRoot, "_NET_ACTIVE_WINDOW")
443+ require.Equal(t, []uint32{0}, atomsOf(act))
444+ list, _ := p.prop(peerRoot, "_NET_CLIENT_LIST")
445+ require.Empty(t, list.data)
446+}
447+
448+func TestFocusInDenied(t *testing.T) {
449+ x, p, h, _, _ := attached(t)
450+ const a, b = 0x500001, 0x500002
451+ sa := newWin(t, x, p, h, a)
452+ newWin(t, x, p, h, b)
453+ x.Focus(sa)
454+ n := settle(t, x, p)
455+
456+ focusIn := func(win uint32, detail byte) {
457+ p.event(9, func(e *[32]byte) {
458+ e[1] = detail
459+ binary.LittleEndian.PutUint32(e[4:], win)
460+ })
461+ }
462+ focusCount := func(skip int) int {
463+ c := 0
464+ for _, r := range since(p, skip, opSetInputFocus) {
465+ if r.u32(4) == a {
466+ c++
467+ }
468+ }
469+ return c
470+ }
471+ x.feed(t, p, func() { focusIn(a, 0) }) // the active window itself: fine
472+ x.feed(t, p, func() { focusIn(b, byte(xproto.NotifyDetailPointer)) })
473+ settle(t, x, p)
474+ require.Zero(t, focusCount(n))
475+
476+ x.feed(t, p, func() { focusIn(b, 0); focusIn(b, 0) })
477+ readUntil(t, x, func() bool { return x.stats.Events >= 5 })
478+ settle(t, x, p)
479+ require.Equal(t, 1, focusCount(n), "denied once per iteration")
480+ n = settle(t, x, p)
481+ settle(t, x, p)
482+ require.Zero(t, focusCount(n))
483+}
484+
485+func TestCloseWindow(t *testing.T) {
486+ x, p, h, _, _ := attached(t)
487+ const a, b = 0x500001, 0x500002
488+ sa, sb := newWin(t, x, p, h, a), newWin(t, x, p, h, b)
489+ x.tab.rec(sa).deleteWindow = true
490+ n := settle(t, x, p)
491+
492+ x.CloseWindow(sa)
493+ x.CloseWindow(sb)
494+ settle(t, x, p)
495+ evs := sentEvents(p, n, a)
496+ require.Len(t, evs, 1)
497+ require.EqualValues(t, 33, evs[0][0])
498+ require.EqualValues(t, 32, evs[0][1])
499+ require.Equal(t, p.atom("WM_PROTOCOLS"), binary.LittleEndian.Uint32(evs[0][8:]))
500+ require.Equal(t, p.atom("WM_DELETE_WINDOW"), binary.LittleEndian.Uint32(evs[0][12:]))
501+ require.Zero(t, binary.LittleEndian.Uint32(evs[0][16:]))
502+ require.Empty(t, sentEvents(p, n, b))
503+
504+ kills := since(p, n, opKillClient)
505+ require.Len(t, kills, 1)
506+ require.Equal(t, uint32(b), kills[0].u32(4))
507+}
508+
509+// Allocation guards. The peer sinks requests so the connection never blocks;
510+// its goroutine is idle in the sink loop before measuring.
511+
512+func sinkPeer(t *testing.T, x *XWM, p *xpeer) {
513+ t.Helper()
514+ p.sink.Store(true)
515+ n := len(p.requests())
516+ xproto.GetInputFocus(x.conn.Buf()) // served by the old loop iteration
517+ require.NoError(t, x.conn.Flush())
518+ p.waitReq(n, func(r xpeerReq) bool { return r.major == opGetInputFocus })
519+ time.Sleep(50 * time.Millisecond)
520+ x.src = nil // no source: write backpressure would not allocate either
521+ x.post = func(func()) {}
522+ x.warnedRing = true // the sink never answers: Expect slots stay used
523+}
524+
525+func TestConfigureFlushAllocations(t *testing.T) {
526+ x, p, h, _, _ := attached(t)
527+ s := newWin(t, x, p, h, 0x500001)
528+ sinkPeer(t, x, p)
529+ i := 0
530+ allocs := testing.AllocsPerRun(300, func() {
531+ i++
532+ x.Configure(s, ports.ConfigureWindow{Width: 100 + i%2, Height: 100, Fullscreen: i%3 == 0, Activated: i%2 == 0})
533+ x.Place(s, i%5, i%7)
534+ x.posted = false
535+ x.flush()
536+ })
537+ require.Zero(t, allocs)
538+ require.False(t, x.closed)
539+}
540+
541+func TestFocusAllocations(t *testing.T) {
542+ x, p, h, _, _ := attached(t)
543+ a, b := newWin(t, x, p, h, 0x500001), newWin(t, x, p, h, 0x500002)
544+ x.addClient(x.tab.rec(a).xid)
545+ x.addClient(x.tab.rec(b).xid)
546+ x.tab.rec(b).takeFocus = true
547+ x.tab.rec(a).input, x.tab.rec(b).input = true, true
548+ sinkPeer(t, x, p)
549+ i := 0
550+ allocs := testing.AllocsPerRun(150, func() {
551+ i++
552+ switch i % 3 {
553+ case 0:
554+ x.Focus(a)
555+ case 1:
556+ x.Focus(b)
557+ default:
558+ x.Focus(0)
559+ }
560+ x.refocus = i%5 == 0
561+ x.posted = false
562+ x.flush()
563+ })
564+ require.Zero(t, allocs)
565+ require.False(t, x.closed)
566+}
@@ -0,0 +1,566 @@
1+package xwm
2+
3+import (
4+ "encoding/binary"
5+ "testing"
6+ "time"
7+
8+ "bnema.dev/xturbo/xproto"
9+ "github.com/bnema/neferwl/internal/ports"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+// newWin makes a ready managed window known to the XWM and the mocked host.
15+func newWin(t *testing.T, x *XWM, p *xpeer, h *mockhost, xid uint32) Slot {
16+ t.Helper()
17+ h.EXPECT().Created(mock.Anything, xid).Return(ports.WindowID(xid)).Once()
18+ p.createNotify(xid, false)
19+ readUntil(t, x, func() bool { return x.Slot(xid) != 0 })
20+ s := x.Slot(xid)
21+ r := x.tab.rec(s)
22+ r.ready, r.w, r.h = true, 300, 200
23+ return s
24+}
25+
26+// settle flushes the XWM and waits until the peer has seen everything sent so
27+// far; it returns the number of requests received.
28+func settle(t *testing.T, x *XWM, p *xpeer) int {
29+ t.Helper()
30+ x.flush()
31+ n := len(p.requests())
32+ xproto.GetInputFocus(x.conn.Buf())
33+ require.NoError(t, x.conn.Flush())
34+ _, n = p.waitReq(n, func(r xpeerReq) bool { return r.major == opGetInputFocus })
35+ return n
36+}
37+
38+// since returns the requests of the given major opcode received after skip.
39+func since(p *xpeer, skip int, major uint8) []xpeerReq {
40+ var out []xpeerReq
41+ for _, r := range p.requests()[skip:] {
42+ if r.major == major {
43+ out = append(out, r)
44+ }
45+ }
46+ return out
47+}
48+
49+// sentEvents returns the 32-byte events of the SendEvent requests to dest.
50+func sentEvents(p *xpeer, skip int, dest uint32) [][]byte {
51+ var out [][]byte
52+ for _, r := range since(p, skip, opSendEvent) {
53+ if r.u32(4) == dest {
54+ out = append(out, r.raw[12:44])
55+ }
56+ }
57+ return out
58+}
59+
60+func propsTo(p *xpeer, skip int, win uint32, atom string) []xpeerReq {
61+ var out []xpeerReq
62+ a := p.atom(atom)
63+ for _, r := range since(p, skip, opChangeProperty) {
64+ if r.u32(4) == win && r.u32(8) == a {
65+ out = append(out, r)
66+ }
67+ }
68+ return out
69+}
70+
71+func (x *XWM) feed(t *testing.T, p *xpeer, send func()) {
72+ t.Helper()
73+ ev := x.stats.Events
74+ send()
75+ readUntil(t, x, func() bool { return x.stats.Events > ev })
76+}
77+
78+func TestConfigureCoalescesIntoOneRequest(t *testing.T) {
79+ x, p, h, _, runs := attached(t)
80+ const win = 0x500001
81+ s := newWin(t, x, p, h, win)
82+ n := settle(t, x, p)
83+
84+ x.Configure(s, ports.ConfigureWindow{Width: 640, Height: 480})
85+ x.Configure(s, ports.ConfigureWindow{Width: 800, Height: 600, Activated: true})
86+ x.Place(s, 10, 20)
87+ require.Len(t, *runs, 1, "one flush posted")
88+ n2 := settle(t, x, p)
89+
90+ cw := since(p, n, opConfigureWindow)
91+ require.Len(t, cw, 1)
92+ require.Equal(t, uint32(win), cw[0].u32(4))
93+ require.EqualValues(t, 0x1f, binary.LittleEndian.Uint16(cw[0].raw[8:]), "x y width height border")
94+ require.Equal(t, []uint32{10, 20, 800, 600, 0}, []uint32{cw[0].u32(12), cw[0].u32(16), cw[0].u32(20), cw[0].u32(24), cw[0].u32(28)})
95+
96+ evs := sentEvents(p, n, win)
97+ require.Len(t, evs, 1, "one synthetic ConfigureNotify")
98+ require.EqualValues(t, 22, evs[0][0])
99+ require.EqualValues(t, win, binary.LittleEndian.Uint32(evs[0][4:]))
100+ require.EqualValues(t, win, binary.LittleEndian.Uint32(evs[0][8:]))
101+ require.EqualValues(t, 10, binary.LittleEndian.Uint16(evs[0][16:]))
102+ require.EqualValues(t, 20, binary.LittleEndian.Uint16(evs[0][18:]))
103+ require.EqualValues(t, 800, binary.LittleEndian.Uint16(evs[0][20:]))
104+ require.EqualValues(t, 600, binary.LittleEndian.Uint16(evs[0][22:]))
105+ require.Equal(t, xproto.EventMaskStructureNotify, xproto.EventMask(since(p, n, opSendEvent)[0].u32(8)))
106+
107+ // Unchanged: nothing is sent.
108+ x.Configure(s, ports.ConfigureWindow{Width: 800, Height: 600, Activated: true})
109+ x.Place(s, 10, 20)
110+ settle(t, x, p)
111+ require.Len(t, since(p, n2, opConfigureWindow), 0)
112+ require.Len(t, sentEvents(p, n2, win), 0)
113+}
114+
115+func TestConfigureWritesNetWMState(t *testing.T) {
116+ x, p, h, _, _ := attached(t)
117+ const win = 0x500001
118+ s := newWin(t, x, p, h, win)
119+ x.tab.rec(s).modal = true
120+ n := settle(t, x, p)
121+
122+ x.Configure(s, ports.ConfigureWindow{Width: 100, Height: 100})
123+ settle(t, x, p)
124+ require.Empty(t, propsTo(p, n, win, "_NET_WM_STATE"), "no state bit changed")
125+
126+ x.Configure(s, ports.ConfigureWindow{Width: 100, Height: 100, Fullscreen: true, Activated: true})
127+ settle(t, x, p)
128+ st, ok := p.prop(win, "_NET_WM_STATE")
129+ require.True(t, ok)
130+ require.ElementsMatch(t, []uint32{p.atom("_NET_WM_STATE_MODAL"), p.atom("_NET_WM_STATE_FULLSCREEN"), p.atom("_NET_WM_STATE_FOCUSED")}, atomsOf(st))
131+
132+ x.Configure(s, ports.ConfigureWindow{Width: 100, Height: 100, Activated: true})
133+ settle(t, x, p)
134+ st, _ = p.prop(win, "_NET_WM_STATE")
135+ require.ElementsMatch(t, []uint32{p.atom("_NET_WM_STATE_MODAL"), p.atom("_NET_WM_STATE_FOCUSED")}, atomsOf(st))
136+}
137+
138+func TestConfigureBadWindowIsAttributedAndIgnored(t *testing.T) {
139+ x, p, h, _, _ := attached(t)
140+ const win = 0x500001
141+ s := newWin(t, x, p, h, win)
142+ n := settle(t, x, p)
143+ x.Configure(s, ports.ConfigureWindow{Width: 640, Height: 480})
144+ x.flush()
145+ cw := []xpeerReq{}
146+ r, _ := p.waitReq(n, func(r xpeerReq) bool { return r.major == opConfigureWindow })
147+ cw = append(cw, r)
148+
149+ p.mu.Lock()
150+ e := make([]byte, 32)
151+ e[1] = xproto.BadWindow
152+ binary.LittleEndian.PutUint16(e[2:], cw[0].seq)
153+ binary.LittleEndian.PutUint32(e[4:], win)
154+ e[10] = opConfigureWindow
155+ p.write(e)
156+ p.mu.Unlock()
157+ readUntil(t, x, func() bool { return x.stats.Errors == 1 })
158+ require.Zero(t, x.stats.Unattributed)
159+ require.False(t, x.closed)
160+}
161+
162+func sendConfigureRequest(p *xpeer, win uint32, mask uint16, x, y int16, w, h uint16) {
163+ p.event(23, func(b *[32]byte) {
164+ binary.LittleEndian.PutUint32(b[4:], peerRoot)
165+ binary.LittleEndian.PutUint32(b[8:], win)
166+ binary.LittleEndian.PutUint16(b[16:], uint16(x))
167+ binary.LittleEndian.PutUint16(b[18:], uint16(y))
168+ binary.LittleEndian.PutUint16(b[20:], w)
169+ binary.LittleEndian.PutUint16(b[22:], h)
170+ binary.LittleEndian.PutUint16(b[26:], mask)
171+ })
172+}
173+
174+func TestConfigureRequestTiledOnlyGetsNotify(t *testing.T) {
175+ x, p, h, _, _ := attached(t)
176+ const win = 0x500001
177+ newWin(t, x, p, h, win)
178+ n := settle(t, x, p)
179+
180+ x.feed(t, p, func() { sendConfigureRequest(p, win, 0xf, 5, 6, 1000, 900) })
181+ settle(t, x, p)
182+ require.Empty(t, since(p, n, opConfigureWindow), "a tiled window cannot resize itself")
183+ evs := sentEvents(p, n, win)
184+ require.Len(t, evs, 1)
185+ require.EqualValues(t, 22, evs[0][0])
186+ require.EqualValues(t, 300, binary.LittleEndian.Uint16(evs[0][20:]), "current geometry")
187+ require.EqualValues(t, 200, binary.LittleEndian.Uint16(evs[0][22:]))
188+}
189+
190+func TestConfigureRequestFloatingHonoursSize(t *testing.T) {
191+ x, p, h, _, _ := attached(t)
192+ const win = 0x500001
193+ s := newWin(t, x, p, h, win)
194+ x.tab.rec(s).floating = true
195+ n := settle(t, x, p)
196+
197+ x.feed(t, p, func() {
198+ sendConfigureRequest(p, win, uint16(xproto.ConfigWindowWidth|xproto.ConfigWindowHeight), 0, 0, 70000&0xffff, 20000)
199+ })
200+ settle(t, x, p)
201+ cw := since(p, n, opConfigureWindow)
202+ require.Len(t, cw, 1)
203+ require.EqualValues(t, xproto.ConfigWindowWidth|xproto.ConfigWindowHeight, binary.LittleEndian.Uint16(cw[0].raw[8:]))
204+ require.EqualValues(t, 4464, cw[0].u32(12))
205+ require.EqualValues(t, maxClientSize, cw[0].u32(16), "clamped")
206+ require.Len(t, sentEvents(p, n, win), 1)
207+}
208+
209+func TestConfigureRequestUnmanagedHonouredVerbatim(t *testing.T) {
210+ x, p, _, _, _ := attached(t)
211+ const win = 0x500009
212+ p.createNotify(win, true)
213+ readUntil(t, x, func() bool { return x.Slot(win) != 0 })
214+ n := settle(t, x, p)
215+
216+ x.feed(t, p, func() {
217+ sendConfigureRequest(p, win, uint16(xproto.ConfigWindowX|xproto.ConfigWindowY|xproto.ConfigWindowWidth), 30, 40, 50, 60)
218+ })
219+ settle(t, x, p)
220+ cw := since(p, n, opConfigureWindow)
221+ require.Len(t, cw, 1)
222+ require.EqualValues(t, 7, binary.LittleEndian.Uint16(cw[0].raw[8:]))
223+ require.Equal(t, []uint32{30, 40, 50}, []uint32{cw[0].u32(12), cw[0].u32(16), cw[0].u32(20)})
224+ require.Empty(t, sentEvents(p, n, win))
225+}
226+
227+func TestConfigureStaleAndDuplicateDirtySlots(t *testing.T) {
228+ x, p, h, _, _ := attached(t)
229+ const a, b = 0x500001, 0x500002
230+ s := newWin(t, x, p, h, a)
231+ for i := 1; i <= 50; i++ {
232+ x.Configure(s, ports.ConfigureWindow{Width: 100 + i, Height: 100})
233+ }
234+ require.Len(t, x.dirty, 1, "queued once per false→true transition")
235+
236+ // The window dies with its entry queued; the slot is reused.
237+ h.EXPECT().Dissociate(s, true).Once()
238+ p.destroyNotify(a)
239+ readUntil(t, x, func() bool { return x.Slot(a) == 0 })
240+ s2 := newWin(t, x, p, h, b)
241+ require.Equal(t, s, s2, "LIFO slot reuse")
242+ n := len(p.requests())
243+ x.Configure(s2, ports.ConfigureWindow{Width: 640, Height: 480})
244+ require.Len(t, x.dirty, 1)
245+ x.dirty = append(x.dirty, s, s) // stale and duplicate entries must be skipped
246+ settle(t, x, p)
247+ cw := since(p, n, opConfigureWindow)
248+ require.Len(t, cw, 1, "sent once despite duplicates")
249+ require.Equal(t, uint32(b), cw[0].u32(4))
250+ require.Empty(t, x.dirty)
251+ require.LessOrEqual(t, cap(x.dirty), len(x.tab.recs))
252+}
253+
254+func TestDirtyQueueNeverGrows(t *testing.T) {
255+ x, p, h, _, _ := attached(t)
256+ c0 := cap(x.dirty)
257+ const base = 0x600000
258+ for i := 0; i < 3*c0; i++ { // each round leaves a stale entry behind
259+ xid := uint32(base + i)
260+ h.EXPECT().Created(mock.Anything, xid).Return(ports.WindowID(1)).Once()
261+ p.createNotify(xid, false)
262+ readUntil(t, x, func() bool { return x.Slot(xid) != 0 })
263+ s := x.Slot(xid)
264+ x.tab.rec(s).ready = true
265+ x.Configure(s, ports.ConfigureWindow{Width: 10, Height: 10})
266+ h.EXPECT().Dissociate(s, true).Once()
267+ p.destroyNotify(xid)
268+ readUntil(t, x, func() bool { return x.Slot(xid) == 0 })
269+ }
270+ require.Equal(t, c0, cap(x.dirty))
271+ require.LessOrEqual(t, len(x.dirty), c0)
272+}
273+
274+func TestPublicCommandsIgnoredWhenClosed(t *testing.T) {
275+ x, p, h, src, _ := attached(t)
276+ const win = 0x500001
277+ s := newWin(t, x, p, h, win)
278+ h.EXPECT().Dissociate(s, true).Once()
279+ src.EXPECT().Remove().Once()
280+ x.Close()
281+ require.NotPanics(t, func() {
282+ x.Configure(s, ports.ConfigureWindow{Width: 1, Height: 1})
283+ x.Place(s, 1, 2)
284+ x.Focus(s)
285+ x.CloseWindow(s)
286+ })
287+ require.Empty(t, x.dirty)
288+ require.Zero(t, x.active)
289+}
290+
291+// Focus.
292+
293+type focusModel struct {
294+ name string
295+ input, take bool
296+ setFocus bool // SetInputFocus on the window
297+ takeFocus bool // WM_TAKE_FOCUS sent
298+}
299+
300+func TestFocusInputModels(t *testing.T) {
301+ for _, m := range []focusModel{
302+ {"passive", true, false, true, false},
303+ {"locally active", true, true, true, true},
304+ {"globally active", false, true, false, true},
305+ {"no input", false, false, false, false},
306+ } {
307+ t.Run(m.name, func(t *testing.T) {
308+ x, p, h, _, _ := attached(t)
309+ const win = 0x500001
310+ s := newWin(t, x, p, h, win)
311+ r := x.tab.rec(s)
312+ r.input, r.takeFocus = m.input, m.take
313+ n := settle(t, x, p)
314+
315+ x.Focus(s)
316+ settle(t, x, p)
317+ var focuses []xpeerReq
318+ for _, r := range since(p, n, opSetInputFocus) {
319+ if r.u32(4) == win {
320+ focuses = append(focuses, r)
321+ }
322+ }
323+ if m.setFocus {
324+ require.Len(t, focuses, 1)
325+ require.EqualValues(t, xproto.InputFocusNone, focuses[0].raw[1], "never PointerRoot")
326+ require.Zero(t, focuses[0].u32(8), "CurrentTime")
327+ } else {
328+ require.Empty(t, focuses)
329+ }
330+ var takes int
331+ for _, ev := range sentEvents(p, n, win) {
332+ if ev[0] == 33 && binary.LittleEndian.Uint32(ev[8:]) == p.atom("WM_PROTOCOLS") {
333+ require.EqualValues(t, 32, ev[1])
334+ require.Equal(t, p.atom("WM_TAKE_FOCUS"), binary.LittleEndian.Uint32(ev[12:]))
335+ require.Zero(t, binary.LittleEndian.Uint32(ev[16:]), "CurrentTime")
336+ takes++
337+ }
338+ }
339+ require.Equal(t, map[bool]int{true: 1, false: 0}[m.takeFocus], takes)
340+
341+ act, ok := p.prop(peerRoot, "_NET_ACTIVE_WINDOW")
342+ require.True(t, ok)
343+ require.Equal(t, []uint32{win}, atomsOf(act), "named even for No-input windows")
344+ st, ok := p.prop(win, "_NET_WM_STATE")
345+ require.True(t, ok)
346+ require.Contains(t, atomsOf(st), p.atom("_NET_WM_STATE_FOCUSED"))
347+ })
348+ }
349+}
350+
351+func TestFocusActiveWindowWrittenOnlyOnChange(t *testing.T) {
352+ x, p, h, _, _ := attached(t)
353+ const a, b = 0x500001, 0x500002
354+ sa, sb := newWin(t, x, p, h, a), newWin(t, x, p, h, b)
355+ n := settle(t, x, p)
356+
357+ x.Focus(sa)
358+ x.Focus(sa)
359+ settle(t, x, p)
360+ require.Len(t, propsTo(p, n, peerRoot, "_NET_ACTIVE_WINDOW"), 1)
361+
362+ n = settle(t, x, p)
363+ x.Focus(sb)
364+ settle(t, x, p)
365+ act, _ := p.prop(peerRoot, "_NET_ACTIVE_WINDOW")
366+ require.Equal(t, []uint32{b}, atomsOf(act))
367+ sta, _ := p.prop(a, "_NET_WM_STATE")
368+ require.NotContains(t, atomsOf(sta), p.atom("_NET_WM_STATE_FOCUSED"), "old window loses FOCUSED")
369+ stb, _ := p.prop(b, "_NET_WM_STATE")
370+ require.Contains(t, atomsOf(stb), p.atom("_NET_WM_STATE_FOCUSED"))
371+
372+ n = settle(t, x, p)
373+ x.Focus(0)
374+ settle(t, x, p)
375+ var none []xpeerReq
376+ for _, r := range since(p, n, opSetInputFocus) {
377+ none = append(none, r)
378+ }
379+ require.Len(t, none, 1)
380+ require.Zero(t, none[0].u32(4), "focus None")
381+ require.EqualValues(t, xproto.InputFocusNone, none[0].raw[1])
382+ act, _ = p.prop(peerRoot, "_NET_ACTIVE_WINDOW")
383+ require.Equal(t, []uint32{0}, atomsOf(act))
384+ stb, _ = p.prop(b, "_NET_WM_STATE")
385+ require.NotContains(t, atomsOf(stb), p.atom("_NET_WM_STATE_FOCUSED"))
386+ require.Zero(t, x.active)
387+}
388+
389+func TestFocusStackingLists(t *testing.T) {
390+ x, p, h, _, _ := attached(t)
391+ const a, b, c = 0x500001, 0x500002, 0x500003
392+ sa := newWin(t, x, p, h, a)
393+ newWin(t, x, p, h, b)
394+ newWin(t, x, p, h, c)
395+ for _, w := range []uint32{a, b, c} {
396+ x.addClient(w)
397+ }
398+ settle(t, x, p)
399+ list, _ := p.prop(peerRoot, "_NET_CLIENT_LIST")
400+ require.Equal(t, []uint32{a, b, c}, atomsOf(list))
401+ n := settle(t, x, p)
402+
403+ x.Focus(sa)
404+ settle(t, x, p)
405+ stack, _ := p.prop(peerRoot, "_NET_CLIENT_LIST_STACKING")
406+ require.Equal(t, []uint32{b, c, a}, atomsOf(stack), "bottom to top")
407+ list, _ = p.prop(peerRoot, "_NET_CLIENT_LIST")
408+ require.Equal(t, []uint32{a, b, c}, atomsOf(list), "map order")
409+ require.Empty(t, propsTo(p, n, peerRoot, "_NET_CLIENT_LIST"), "clients list untouched by focus")
410+ var raised bool
411+ for _, r := range since(p, n, opConfigureWindow) {
412+ if r.u32(4) == a && binary.LittleEndian.Uint16(r.raw[8:]) == uint16(xproto.ConfigWindowStackMode) {
413+ require.EqualValues(t, xproto.StackModeAbove, r.u32(12))
414+ raised = true
415+ }
416+ }
417+ require.True(t, raised)
418+
419+ // A burst costs one write per list.
420+ n = settle(t, x, p)
421+ x.removeClient(b)
422+ x.removeClient(c)
423+ settle(t, x, p)
424+ require.Len(t, propsTo(p, n, peerRoot, "_NET_CLIENT_LIST"), 1)
425+ require.Len(t, propsTo(p, n, peerRoot, "_NET_CLIENT_LIST_STACKING"), 1)
426+ stack, _ = p.prop(peerRoot, "_NET_CLIENT_LIST_STACKING")
427+ require.Equal(t, []uint32{a}, atomsOf(stack))
428+}
429+
430+func TestDestroyedActiveWindowClearsActive(t *testing.T) {
431+ x, p, h, _, _ := attached(t)
432+ const win = 0x500001
433+ s := newWin(t, x, p, h, win)
434+ x.addClient(win)
435+ x.Focus(s)
436+ settle(t, x, p)
437+ h.EXPECT().Dissociate(s, true).Once()
438+ p.destroyNotify(win)
439+ readUntil(t, x, func() bool { return x.Slot(win) == 0 })
440+ settle(t, x, p)
441+ require.Zero(t, x.active)
442+ act, _ := p.prop(peerRoot, "_NET_ACTIVE_WINDOW")
443+ require.Equal(t, []uint32{0}, atomsOf(act))
444+ list, _ := p.prop(peerRoot, "_NET_CLIENT_LIST")
445+ require.Empty(t, list.data)
446+}
447+
448+func TestFocusInDenied(t *testing.T) {
449+ x, p, h, _, _ := attached(t)
450+ const a, b = 0x500001, 0x500002
451+ sa := newWin(t, x, p, h, a)
452+ newWin(t, x, p, h, b)
453+ x.Focus(sa)
454+ n := settle(t, x, p)
455+
456+ focusIn := func(win uint32, detail byte) {
457+ p.event(9, func(e *[32]byte) {
458+ e[1] = detail
459+ binary.LittleEndian.PutUint32(e[4:], win)
460+ })
461+ }
462+ focusCount := func(skip int) int {
463+ c := 0
464+ for _, r := range since(p, skip, opSetInputFocus) {
465+ if r.u32(4) == a {
466+ c++
467+ }
468+ }
469+ return c
470+ }
471+ x.feed(t, p, func() { focusIn(a, 0) }) // the active window itself: fine
472+ x.feed(t, p, func() { focusIn(b, byte(xproto.NotifyDetailPointer)) })
473+ settle(t, x, p)
474+ require.Zero(t, focusCount(n))
475+
476+ x.feed(t, p, func() { focusIn(b, 0); focusIn(b, 0) })
477+ readUntil(t, x, func() bool { return x.stats.Events >= 5 })
478+ settle(t, x, p)
479+ require.Equal(t, 1, focusCount(n), "denied once per iteration")
480+ n = settle(t, x, p)
481+ settle(t, x, p)
482+ require.Zero(t, focusCount(n))
483+}
484+
485+func TestCloseWindow(t *testing.T) {
486+ x, p, h, _, _ := attached(t)
487+ const a, b = 0x500001, 0x500002
488+ sa, sb := newWin(t, x, p, h, a), newWin(t, x, p, h, b)
489+ x.tab.rec(sa).deleteWindow = true
490+ n := settle(t, x, p)
491+
492+ x.CloseWindow(sa)
493+ x.CloseWindow(sb)
494+ settle(t, x, p)
495+ evs := sentEvents(p, n, a)
496+ require.Len(t, evs, 1)
497+ require.EqualValues(t, 33, evs[0][0])
498+ require.EqualValues(t, 32, evs[0][1])
499+ require.Equal(t, p.atom("WM_PROTOCOLS"), binary.LittleEndian.Uint32(evs[0][8:]))
500+ require.Equal(t, p.atom("WM_DELETE_WINDOW"), binary.LittleEndian.Uint32(evs[0][12:]))
501+ require.Zero(t, binary.LittleEndian.Uint32(evs[0][16:]))
502+ require.Empty(t, sentEvents(p, n, b))
503+
504+ kills := since(p, n, opKillClient)
505+ require.Len(t, kills, 1)
506+ require.Equal(t, uint32(b), kills[0].u32(4))
507+}
508+
509+// Allocation guards. The peer sinks requests so the connection never blocks;
510+// its goroutine is idle in the sink loop before measuring.
511+
512+func sinkPeer(t *testing.T, x *XWM, p *xpeer) {
513+ t.Helper()
514+ p.sink.Store(true)
515+ n := len(p.requests())
516+ xproto.GetInputFocus(x.conn.Buf()) // served by the old loop iteration
517+ require.NoError(t, x.conn.Flush())
518+ p.waitReq(n, func(r xpeerReq) bool { return r.major == opGetInputFocus })
519+ time.Sleep(50 * time.Millisecond)
520+ x.src = nil // no source: write backpressure would not allocate either
521+ x.post = func(func()) {}
522+ x.warnedRing = true // the sink never answers: Expect slots stay used
523+}
524+
525+func TestConfigureFlushAllocations(t *testing.T) {
526+ x, p, h, _, _ := attached(t)
527+ s := newWin(t, x, p, h, 0x500001)
528+ sinkPeer(t, x, p)
529+ i := 0
530+ allocs := testing.AllocsPerRun(300, func() {
531+ i++
532+ x.Configure(s, ports.ConfigureWindow{Width: 100 + i%2, Height: 100, Fullscreen: i%3 == 0, Activated: i%2 == 0})
533+ x.Place(s, i%5, i%7)
534+ x.posted = false
535+ x.flush()
536+ })
537+ require.Zero(t, allocs)
538+ require.False(t, x.closed)
539+}
540+
541+func TestFocusAllocations(t *testing.T) {
542+ x, p, h, _, _ := attached(t)
543+ a, b := newWin(t, x, p, h, 0x500001), newWin(t, x, p, h, 0x500002)
544+ x.addClient(x.tab.rec(a).xid)
545+ x.addClient(x.tab.rec(b).xid)
546+ x.tab.rec(b).takeFocus = true
547+ x.tab.rec(a).input, x.tab.rec(b).input = true, true
548+ sinkPeer(t, x, p)
549+ i := 0
550+ allocs := testing.AllocsPerRun(150, func() {
551+ i++
552+ switch i % 3 {
553+ case 0:
554+ x.Focus(a)
555+ case 1:
556+ x.Focus(b)
557+ default:
558+ x.Focus(0)
559+ }
560+ x.refocus = i%5 == 0
561+ x.posted = false
562+ x.flush()
563+ })
564+ require.Zero(t, allocs)
565+ require.False(t, x.closed)
566+}
M internal/adapters/wayland/xwm/events.go +1 -0
@@ -168,6 +168,7 @@ func (x *XWM) onDestroyNotify(ev *xturbo.Event) {
168168 if s == 0 {
169169 return
170170 }
171+ x.dropWindow(s)
171172 if r := x.tab.rec(s); r.kind == kindManaged && r.id != 0 && x.host != nil {
172173 r.id = 0
173174 x.host.Dissociate(s, true)
@@ -168,6 +168,7 @@ func (x *XWM) onDestroyNotify(ev *xturbo.Event) {
168 if s == 0 {168 if s == 0 {
169 return169 return
170 }170 }
171+ x.dropWindow(s)
171 if r := x.tab.rec(s); r.kind == kindManaged && r.id != 0 && x.host != nil {172 if r := x.tab.rec(s); r.kind == kindManaged && r.id != 0 && x.host != nil {
172 r.id = 0173 r.id = 0
173 x.host.Dissociate(s, true)174 x.host.Dissociate(s, true)
M internal/adapters/wayland/xwm/focus.go +170 -4
@@ -1,11 +1,177 @@
11 package xwm
22
3-import "bnema.dev/xturbo"
3+import (
4+ "encoding/binary"
5+ "slices"
46
5-func (x *XWM) onFocusIn(ev *xturbo.Event) { _ = ev }
7+ "bnema.dev/xturbo"
8+ "bnema.dev/xturbo/xproto"
9+)
10+
11+// Bits of XWM.listsDirty: the client lists to rewrite at flush.
12+const (
13+ listClients uint8 = 1 << iota
14+ listStacking
15+)
16+
17+// onFocusIn denies a focus change the XWM did not make: Wayland is the truth.
18+// The active window's focus is re-issued once at the next flush.
19+func (x *XWM) onFocusIn(ev *xturbo.Event) {
20+ e := xproto.FocusInOf(ev)
21+ if e.Detail() == xproto.NotifyDetailPointer {
22+ return
23+ }
24+ s := x.tab.lookup(uint32(e.Event()))
25+ if s == 0 || s == x.active {
26+ return
27+ }
28+ x.refocus = true
29+ x.wake()
30+}
631
732 // Focus gives keyboard focus to s; 0 means a native Wayland window or none.
8-func (x *XWM) Focus(s Slot) { _ = s }
33+// Focus never uses PointerRoot: without an X window to focus it is None.
34+func (x *XWM) Focus(s Slot) {
35+ if x.closed {
36+ return
37+ }
38+ r := x.managedRec(s)
39+ if r == nil {
40+ s = 0
41+ }
42+ if s == x.active {
43+ return
44+ }
45+ if old := x.managedRec(x.active); old != nil {
46+ old.pend.activated = false
47+ x.markDirty(x.active, old)
48+ }
49+ x.active = s
50+ if r == nil {
51+ x.issueFocus(0, nil)
52+ x.setActive(0)
53+ x.wake()
54+ return
55+ }
56+ r.pend.activated = true
57+ x.markDirty(s, r)
58+ x.issueFocus(s, r)
59+ x.setActive(r.xid)
60+ x.raise(r)
61+ x.wake()
62+}
63+
64+// issueFocus applies the ICCCM input model of r: Passive and Locally active
65+// windows get SetInputFocus, Locally and Globally active ones WM_TAKE_FOCUS,
66+// No-input windows nothing. A nil r clears the X focus.
67+func (x *XWM) issueFocus(s Slot, r *record) {
68+ b := x.conn.Buf()
69+ if r == nil {
70+ xproto.SetInputFocus(b, xproto.InputFocusNone, xproto.WindowNone, 0)
71+ return
72+ }
73+ if r.input {
74+ x.expect(xproto.SetInputFocus(b, xproto.InputFocusNone, xproto.Window(r.xid), 0), kindFocus, x.tab.tag(s), 0)
75+ }
76+ if r.takeFocus {
77+ x.sendProtocol(r.xid, atomWMTakeFocus)
78+ }
79+}
80+
81+// sendProtocol sends a WM_PROTOCOLS client message [proto, CurrentTime].
82+func (x *XWM) sendProtocol(xid uint32, proto int) {
83+ var raw [32]byte
84+ raw[0], raw[1] = 33, 32 // ClientMessage, format 32
85+ binary.LittleEndian.PutUint32(raw[4:], xid)
86+ binary.LittleEndian.PutUint32(raw[8:], x.atoms[atomWMProtocols])
87+ binary.LittleEndian.PutUint32(raw[12:], x.atoms[proto])
88+ xproto.SendEvent(x.conn.Buf(), false, xproto.Window(xid), xproto.EventMaskNoEvent, &raw)
89+}
90+
91+// setActive writes _NET_ACTIVE_WINDOW on the root.
92+func (x *XWM) setActive(xid uint32) {
93+ v := [1]uint32{xid}
94+ x.setProp32(x.root, atomNetActiveWindow, xproto.AtomWindow, v[:])
95+}
96+
97+// raise restacks r above its siblings and at the top of the stacking list.
98+func (x *XWM) raise(r *record) {
99+ var v xproto.ConfigureWindowValues
100+ v.SetStackMode(xproto.StackModeAbove)
101+ xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(r.xid), &v)
102+ if i := slices.Index(x.stacking, r.xid); i >= 0 {
103+ x.stacking = append(x.stacking[:i], x.stacking[i+1:]...)
104+ x.stacking = append(x.stacking, r.xid)
105+ x.listsDirty |= listStacking
106+ }
107+}
9108
10109 // CloseWindow asks s to close (WM_DELETE_WINDOW), or kills its client.
11-func (x *XWM) CloseWindow(s Slot) { _ = s }
110+func (x *XWM) CloseWindow(s Slot) {
111+ r := x.managedRec(s)
112+ if r == nil {
113+ return
114+ }
115+ if r.deleteWindow {
116+ x.sendProtocol(r.xid, atomWMDeleteWindow)
117+ } else {
118+ xproto.KillClient(x.conn.Buf(), r.xid)
119+ }
120+ x.wake()
121+}
122+
123+// addClient puts a ready window at the end of both client lists.
124+func (x *XWM) addClient(xid uint32) {
125+ if !slices.Contains(x.clients, xid) {
126+ x.clients = append(x.clients, xid)
127+ x.listsDirty |= listClients
128+ }
129+ if !slices.Contains(x.stacking, xid) {
130+ x.stacking = append(x.stacking, xid)
131+ x.listsDirty |= listStacking
132+ }
133+ x.wake()
134+}
135+
136+// removeClient drops an unmapped or destroyed window from both client lists.
137+func (x *XWM) removeClient(xid uint32) {
138+ if i := slices.Index(x.clients, xid); i >= 0 {
139+ x.clients = slices.Delete(x.clients, i, i+1)
140+ x.listsDirty |= listClients
141+ }
142+ if i := slices.Index(x.stacking, xid); i >= 0 {
143+ x.stacking = slices.Delete(x.stacking, i, i+1)
144+ x.listsDirty |= listStacking
145+ }
146+ x.wake()
147+}
148+
149+// dropWindow forgets s in the lists and as the active window. It is idempotent
150+// and runs when a window goes away (unmap, destroy).
151+func (x *XWM) dropWindow(s Slot) {
152+ r := x.tab.rec(s)
153+ if r == nil || r.kind == kindFree {
154+ return
155+ }
156+ x.removeClient(r.xid)
157+ if x.active == s {
158+ x.active = 0
159+ x.setActive(0)
160+ }
161+}
162+
163+// flushFocus rewrites the client lists that changed and re-issues a denied
164+// focus, once per display iteration.
165+func (x *XWM) flushFocus() {
166+ if x.listsDirty&listClients != 0 {
167+ x.setProp32(x.root, atomNetClientList, xproto.AtomWindow, x.clients)
168+ }
169+ if x.listsDirty&listStacking != 0 {
170+ x.setProp32(x.root, atomNetClientListStacking, xproto.AtomWindow, x.stacking)
171+ }
172+ x.listsDirty = 0
173+ if x.refocus {
174+ x.refocus = false
175+ x.issueFocus(x.active, x.managedRec(x.active))
176+ }
177+}
@@ -1,11 +1,177 @@
1 package xwm1 package xwm
2 2
3-import "bnema.dev/xturbo"3+import (
4+ "encoding/binary"
5+ "slices"
4 6
5-func (x *XWM) onFocusIn(ev *xturbo.Event) { _ = ev }7+ "bnema.dev/xturbo"
8+ "bnema.dev/xturbo/xproto"
9+)
10+
11+// Bits of XWM.listsDirty: the client lists to rewrite at flush.
12+const (
13+ listClients uint8 = 1 << iota
14+ listStacking
15+)
16+
17+// onFocusIn denies a focus change the XWM did not make: Wayland is the truth.
18+// The active window's focus is re-issued once at the next flush.
19+func (x *XWM) onFocusIn(ev *xturbo.Event) {
20+ e := xproto.FocusInOf(ev)
21+ if e.Detail() == xproto.NotifyDetailPointer {
22+ return
23+ }
24+ s := x.tab.lookup(uint32(e.Event()))
25+ if s == 0 || s == x.active {
26+ return
27+ }
28+ x.refocus = true
29+ x.wake()
30+}
6 31
7 // Focus gives keyboard focus to s; 0 means a native Wayland window or none.32 // Focus gives keyboard focus to s; 0 means a native Wayland window or none.
8-func (x *XWM) Focus(s Slot) { _ = s }33+// Focus never uses PointerRoot: without an X window to focus it is None.
34+func (x *XWM) Focus(s Slot) {
35+ if x.closed {
36+ return
37+ }
38+ r := x.managedRec(s)
39+ if r == nil {
40+ s = 0
41+ }
42+ if s == x.active {
43+ return
44+ }
45+ if old := x.managedRec(x.active); old != nil {
46+ old.pend.activated = false
47+ x.markDirty(x.active, old)
48+ }
49+ x.active = s
50+ if r == nil {
51+ x.issueFocus(0, nil)
52+ x.setActive(0)
53+ x.wake()
54+ return
55+ }
56+ r.pend.activated = true
57+ x.markDirty(s, r)
58+ x.issueFocus(s, r)
59+ x.setActive(r.xid)
60+ x.raise(r)
61+ x.wake()
62+}
63+
64+// issueFocus applies the ICCCM input model of r: Passive and Locally active
65+// windows get SetInputFocus, Locally and Globally active ones WM_TAKE_FOCUS,
66+// No-input windows nothing. A nil r clears the X focus.
67+func (x *XWM) issueFocus(s Slot, r *record) {
68+ b := x.conn.Buf()
69+ if r == nil {
70+ xproto.SetInputFocus(b, xproto.InputFocusNone, xproto.WindowNone, 0)
71+ return
72+ }
73+ if r.input {
74+ x.expect(xproto.SetInputFocus(b, xproto.InputFocusNone, xproto.Window(r.xid), 0), kindFocus, x.tab.tag(s), 0)
75+ }
76+ if r.takeFocus {
77+ x.sendProtocol(r.xid, atomWMTakeFocus)
78+ }
79+}
80+
81+// sendProtocol sends a WM_PROTOCOLS client message [proto, CurrentTime].
82+func (x *XWM) sendProtocol(xid uint32, proto int) {
83+ var raw [32]byte
84+ raw[0], raw[1] = 33, 32 // ClientMessage, format 32
85+ binary.LittleEndian.PutUint32(raw[4:], xid)
86+ binary.LittleEndian.PutUint32(raw[8:], x.atoms[atomWMProtocols])
87+ binary.LittleEndian.PutUint32(raw[12:], x.atoms[proto])
88+ xproto.SendEvent(x.conn.Buf(), false, xproto.Window(xid), xproto.EventMaskNoEvent, &raw)
89+}
90+
91+// setActive writes _NET_ACTIVE_WINDOW on the root.
92+func (x *XWM) setActive(xid uint32) {
93+ v := [1]uint32{xid}
94+ x.setProp32(x.root, atomNetActiveWindow, xproto.AtomWindow, v[:])
95+}
96+
97+// raise restacks r above its siblings and at the top of the stacking list.
98+func (x *XWM) raise(r *record) {
99+ var v xproto.ConfigureWindowValues
100+ v.SetStackMode(xproto.StackModeAbove)
101+ xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(r.xid), &v)
102+ if i := slices.Index(x.stacking, r.xid); i >= 0 {
103+ x.stacking = append(x.stacking[:i], x.stacking[i+1:]...)
104+ x.stacking = append(x.stacking, r.xid)
105+ x.listsDirty |= listStacking
106+ }
107+}
9 108
10 // CloseWindow asks s to close (WM_DELETE_WINDOW), or kills its client.109 // CloseWindow asks s to close (WM_DELETE_WINDOW), or kills its client.
11-func (x *XWM) CloseWindow(s Slot) { _ = s }110+func (x *XWM) CloseWindow(s Slot) {
111+ r := x.managedRec(s)
112+ if r == nil {
113+ return
114+ }
115+ if r.deleteWindow {
116+ x.sendProtocol(r.xid, atomWMDeleteWindow)
117+ } else {
118+ xproto.KillClient(x.conn.Buf(), r.xid)
119+ }
120+ x.wake()
121+}
122+
123+// addClient puts a ready window at the end of both client lists.
124+func (x *XWM) addClient(xid uint32) {
125+ if !slices.Contains(x.clients, xid) {
126+ x.clients = append(x.clients, xid)
127+ x.listsDirty |= listClients
128+ }
129+ if !slices.Contains(x.stacking, xid) {
130+ x.stacking = append(x.stacking, xid)
131+ x.listsDirty |= listStacking
132+ }
133+ x.wake()
134+}
135+
136+// removeClient drops an unmapped or destroyed window from both client lists.
137+func (x *XWM) removeClient(xid uint32) {
138+ if i := slices.Index(x.clients, xid); i >= 0 {
139+ x.clients = slices.Delete(x.clients, i, i+1)
140+ x.listsDirty |= listClients
141+ }
142+ if i := slices.Index(x.stacking, xid); i >= 0 {
143+ x.stacking = slices.Delete(x.stacking, i, i+1)
144+ x.listsDirty |= listStacking
145+ }
146+ x.wake()
147+}
148+
149+// dropWindow forgets s in the lists and as the active window. It is idempotent
150+// and runs when a window goes away (unmap, destroy).
151+func (x *XWM) dropWindow(s Slot) {
152+ r := x.tab.rec(s)
153+ if r == nil || r.kind == kindFree {
154+ return
155+ }
156+ x.removeClient(r.xid)
157+ if x.active == s {
158+ x.active = 0
159+ x.setActive(0)
160+ }
161+}
162+
163+// flushFocus rewrites the client lists that changed and re-issues a denied
164+// focus, once per display iteration.
165+func (x *XWM) flushFocus() {
166+ if x.listsDirty&listClients != 0 {
167+ x.setProp32(x.root, atomNetClientList, xproto.AtomWindow, x.clients)
168+ }
169+ if x.listsDirty&listStacking != 0 {
170+ x.setProp32(x.root, atomNetClientListStacking, xproto.AtomWindow, x.stacking)
171+ }
172+ x.listsDirty = 0
173+ if x.refocus {
174+ x.refocus = false
175+ x.issueFocus(x.active, x.managedRec(x.active))
176+ }
177+}
M internal/adapters/wayland/xwm/table.go +1 -0
@@ -53,6 +53,7 @@ type record struct {
5353 minW, minH, maxW, maxH int32
5454 title, class string // owned, replaced only on change
5555 hasNetName bool
56+ floating bool // dialog-like type; set with the properties
5657
5758 // pairing
5859 serial uint64
@@ -53,6 +53,7 @@ type record struct {
53 minW, minH, maxW, maxH int3253 minW, minH, maxW, maxH int32
54 title, class string // owned, replaced only on change54 title, class string // owned, replaced only on change
55 hasNetName bool55 hasNetName bool
56+ floating bool // dialog-like type; set with the properties
56 57
57 // pairing58 // pairing
58 serial uint6459 serial uint64
M internal/adapters/wayland/xwm/xpeer_test.go +32 -0
@@ -83,6 +83,8 @@ type xpeer struct {
8383 denyRoot bool // answer the root event-mask request with BadAccess
8484
8585 gate atomic.Pointer[chan struct{}] // closed = reading allowed
86+ sbuf [4096]byte
87+ sink atomic.Bool // discard requests without recording or allocating
8688 done chan struct{}
8789 }
8890
@@ -150,6 +152,12 @@ func (p *xpeer) serve() {
150152 }
151153 for {
152154 <-*p.gate.Load()
155+ if p.sink.Load() {
156+ if !p.discard() {
157+ return
158+ }
159+ continue
160+ }
153161 var h [4]byte
154162 if _, err := io.ReadFull(p.f, h[:]); err != nil {
155163 return
@@ -178,6 +186,30 @@ func (p *xpeer) serve() {
178186 }
179187 }
180188
189+// discard reads and drops one request without allocating. It reports false
190+// when the connection ended.
191+func (p *xpeer) discard() bool {
192+ if _, err := io.ReadFull(p.f, p.sbuf[:4]); err != nil {
193+ return false
194+ }
195+ n := int(binary.LittleEndian.Uint16(p.sbuf[2:])) * 4
196+ left := n - 4
197+ if n == 0 {
198+ if _, err := io.ReadFull(p.f, p.sbuf[:4]); err != nil {
199+ return false
200+ }
201+ left = int(binary.LittleEndian.Uint32(p.sbuf[:4]))*4 - 8
202+ }
203+ for left > 0 {
204+ m, err := p.f.Read(p.sbuf[:min(left, len(p.sbuf))])
205+ if err != nil {
206+ return false
207+ }
208+ left -= m
209+ }
210+ return true
211+}
212+
181213 // write sends bytes to the client. The caller holds p.mu.
182214 func (p *xpeer) write(b []byte) { _, _ = p.f.Write(b) }
183215
@@ -83,6 +83,8 @@ type xpeer struct {
83 denyRoot bool // answer the root event-mask request with BadAccess83 denyRoot bool // answer the root event-mask request with BadAccess
84 84
85 gate atomic.Pointer[chan struct{}] // closed = reading allowed85 gate atomic.Pointer[chan struct{}] // closed = reading allowed
86+ sbuf [4096]byte
87+ sink atomic.Bool // discard requests without recording or allocating
86 done chan struct{}88 done chan struct{}
87 }89 }
88 90
@@ -150,6 +152,12 @@ func (p *xpeer) serve() {
150 }152 }
151 for {153 for {
152 <-*p.gate.Load()154 <-*p.gate.Load()
155+ if p.sink.Load() {
156+ if !p.discard() {
157+ return
158+ }
159+ continue
160+ }
153 var h [4]byte161 var h [4]byte
154 if _, err := io.ReadFull(p.f, h[:]); err != nil {162 if _, err := io.ReadFull(p.f, h[:]); err != nil {
155 return163 return
@@ -178,6 +186,30 @@ func (p *xpeer) serve() {
178 }186 }
179 }187 }
180 188
189+// discard reads and drops one request without allocating. It reports false
190+// when the connection ended.
191+func (p *xpeer) discard() bool {
192+ if _, err := io.ReadFull(p.f, p.sbuf[:4]); err != nil {
193+ return false
194+ }
195+ n := int(binary.LittleEndian.Uint16(p.sbuf[2:])) * 4
196+ left := n - 4
197+ if n == 0 {
198+ if _, err := io.ReadFull(p.f, p.sbuf[:4]); err != nil {
199+ return false
200+ }
201+ left = int(binary.LittleEndian.Uint32(p.sbuf[:4]))*4 - 8
202+ }
203+ for left > 0 {
204+ m, err := p.f.Read(p.sbuf[:min(left, len(p.sbuf))])
205+ if err != nil {
206+ return false
207+ }
208+ left -= m
209+ }
210+ return true
211+}
212+
181 // write sends bytes to the client. The caller holds p.mu.213 // write sends bytes to the client. The caller holds p.mu.
182 func (p *xpeer) write(b []byte) { _, _ = p.f.Write(b) }214 func (p *xpeer) write(b []byte) { _, _ = p.f.Write(b) }
183 215

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