detached 3ccf649

Merge branch 'feat/x6-configure' into feat/x6-xwm

Reconcile X6.4 and X6.5: one dropWindow/forgetWindow, client lists only via
addClient/removeClient flushed once in flushFocus, record.floating set by
props as the single floating source, unmap routed through dropWindow.
Streams regenerated (atom ids shifted); steady state is a focused window.
commit
3ccf649ae42c37a8bed3185e5e7dfbc78d5969ef
author
brice · · 2026-10-22 12:50 UTC
committer
brice · · 2026-10-22 17:54 UTC
merge of
ce78abc + 5021c6c · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +1086 -132
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|TestPropertyUnchangedAllocations|TestDispatchAllocations)$$' -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|TestPropertyUnchangedAllocations|TestDispatchAllocations|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|TestPropertyUnchangedAllocations|TestDispatchAllocations)$$' -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|TestPropertyUnchangedAllocations|TestDispatchAllocations|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 +219 -7
@@ -2,17 +2,229 @@ 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+// size is the size to send: the requested one, else the last known.
19+func (r *record) size() (w, h uint32) {
20+ w, h = r.pend.w, r.pend.h
21+ if w == 0 {
22+ w = r.w
23+ }
24+ if h == 0 {
25+ h = r.h
26+ }
27+ return w, h
28+}
29+
30+// differs reports whether the pending configure is not what the X server has.
31+func (r *record) differs() bool {
32+ w, h := r.size()
33+ p, s := &r.pend, &r.sent
34+ return p.x != s.x || p.y != s.y || w != s.w || h != s.h ||
35+ p.fullscreen != s.fullscreen || p.activated != s.activated
36+}
37+
38+// markDirty queues s for the next flush when its pending configure differs.
39+func (x *XWM) markDirty(s Slot, r *record) {
40+ if r.pend.dirty || !r.differs() {
41+ return
42+ }
43+ if len(x.dirty) == cap(x.dirty) {
44+ // Stale entries of freed and reused slots filled the queue: send
45+ // what is queued rather than grow it. r stays valid, nothing here
46+ // grows the table.
47+ x.flushConfigures()
48+ }
49+ r.pend.dirty = true
50+ x.dirty = append(x.dirty, s)
51+}
52+
53+// managedRec returns the record of a live managed window.
54+func (x *XWM) managedRec(s Slot) *record {
55+ if x.closed {
56+ return nil
57+ }
58+ if r := x.tab.rec(s); r != nil && r.kind == kindManaged {
59+ return r
60+ }
61+ return nil
62+}
63+
64+// Configure records the size and state core wants for s; sent at flush. A
65+// zero size keeps the current one.
66+func (x *XWM) Configure(s Slot, c ports.ConfigureWindow) {
67+ r := x.managedRec(s)
68+ if r == nil {
69+ return
70+ }
71+ if c.Width > 0 {
72+ r.pend.w = uint32(min(c.Width, maxClientSize))
73+ }
74+ if c.Height > 0 {
75+ r.pend.h = uint32(min(c.Height, maxClientSize))
76+ }
77+ r.pend.fullscreen, r.pend.activated = c.Fullscreen, c.Activated
78+ x.markDirty(s, r)
79+ x.wake()
80+}
1381
1482 // Place records the layout position of the client rectangle of s.
15-func (x *XWM) Place(s Slot, px, py int) { _, _, _ = s, px, py }
83+func (x *XWM) Place(s Slot, px, py int) {
84+ r := x.managedRec(s)
85+ if r == nil {
86+ return
87+ }
88+ r.pend.x, r.pend.y = int32(px), int32(py)
89+ x.markDirty(s, r)
90+ x.wake()
91+}
92+
93+// flushConfigures sends the pending configures, once per display iteration,
94+// then what focus left to do.
95+func (x *XWM) flushConfigures() {
96+ for _, s := range x.dirty {
97+ if r := x.tab.rec(s); r != nil && r.kind == kindManaged && r.pend.dirty {
98+ x.sendPending(s, r)
99+ }
100+ }
101+ x.dirty = x.dirty[:0]
102+ x.flushFocus()
103+}
104+
105+// sendPending writes the geometry (with the synthetic ConfigureNotify ICCCM
106+// 4.1.5 asks for, since Xwayland may not move the window) and the
107+// _NET_WM_STATE bits that changed.
108+func (x *XWM) sendPending(s Slot, r *record) {
109+ r.pend.dirty = false
110+ p := &r.pend
111+ w, h := r.size()
112+ if w > 0 && h > 0 && (p.x != r.sent.x || p.y != r.sent.y || w != r.sent.w || h != r.sent.h) {
113+ var v xproto.ConfigureWindowValues
114+ v.SetX(p.x).SetY(p.y).SetWidth(w).SetHeight(h).SetBorderWidth(0)
115+ x.expect(xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(r.xid), &v), kindConfigure, x.tab.tag(s), 0)
116+ r.x, r.y, r.w, r.h = p.x, p.y, w, h
117+ x.sendConfigureNotify(r)
118+ r.sent.x, r.sent.y, r.sent.w, r.sent.h = p.x, p.y, w, h
119+ }
120+ var bits uint8
121+ if p.fullscreen {
122+ bits |= stateFullscreen
123+ }
124+ if p.activated {
125+ bits |= stateFocused
126+ }
127+ r.sent.fullscreen, r.sent.activated = p.fullscreen, p.activated
128+ if bits != r.stateSent {
129+ r.stateSent = bits
130+ x.writeState(r)
131+ }
132+}
133+
134+// writeState rewrites _NET_WM_STATE of r from its bits.
135+func (x *XWM) writeState(r *record) {
136+ var list [3]uint32
137+ n := 0
138+ if r.modal {
139+ list[n] = x.atoms[atomNetWMStateModal]
140+ n++
141+ }
142+ if r.stateSent&stateFullscreen != 0 {
143+ list[n] = x.atoms[atomNetWMStateFullscreen]
144+ n++
145+ }
146+ if r.stateSent&stateFocused != 0 {
147+ list[n] = x.atoms[atomNetWMStateFocused]
148+ n++
149+ }
150+ x.setProp32(r.xid, atomNetWMState, xproto.AtomAtom, list[:n])
151+}
152+
153+// sendConfigureNotify tells the client its geometry as the XWM sees it.
154+func (x *XWM) sendConfigureNotify(r *record) {
155+ var raw [32]byte
156+ d := xproto.ConfigureNotifyEventData{
157+ Event: xproto.Window(r.xid), Window: xproto.Window(r.xid),
158+ X: int16(r.x), Y: int16(r.y), Width: uint16(r.w), Height: uint16(r.h),
159+ }
160+ d.Put(&raw)
161+ xproto.SendEvent(x.conn.Buf(), false, xproto.Window(r.xid), xproto.EventMaskStructureNotify, &raw)
162+}
163+
164+// onConfigureRequest answers a client resizing itself. Tiled windows are told
165+// their current geometry; floating ones get the size they ask for; unmanaged
166+// and unknown windows are configured as requested.
167+func (x *XWM) onConfigureRequest(ev *xturbo.Event) {
168+ e := xproto.ConfigureRequestOf(ev)
169+ xid := uint32(e.Window())
170+ mask := e.ValueMask()
171+ s := x.tab.lookup(xid)
172+ r := x.managedRec(s)
173+ if r == nil {
174+ var v xproto.ConfigureWindowValues
175+ if mask&xproto.ConfigWindowX != 0 {
176+ v.SetX(int32(e.X()))
177+ }
178+ if mask&xproto.ConfigWindowY != 0 {
179+ v.SetY(int32(e.Y()))
180+ }
181+ if mask&xproto.ConfigWindowWidth != 0 {
182+ v.SetWidth(uint32(e.Width()))
183+ }
184+ if mask&xproto.ConfigWindowHeight != 0 {
185+ v.SetHeight(uint32(e.Height()))
186+ }
187+ if mask&xproto.ConfigWindowBorderWidth != 0 {
188+ v.SetBorderWidth(uint32(e.BorderWidth()))
189+ }
190+ if mask&xproto.ConfigWindowSibling != 0 {
191+ v.SetSibling(e.Sibling())
192+ }
193+ if mask&xproto.ConfigWindowStackMode != 0 {
194+ v.SetStackMode(e.StackMode())
195+ }
196+ xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(xid), &v)
197+ return
198+ }
199+ if r.floating && mask&(xproto.ConfigWindowWidth|xproto.ConfigWindowHeight) != 0 {
200+ w, h := r.w, r.h
201+ if mask&xproto.ConfigWindowWidth != 0 {
202+ w = uint32(min(max(int(e.Width()), 1), maxClientSize))
203+ }
204+ if mask&xproto.ConfigWindowHeight != 0 {
205+ h = uint32(min(max(int(e.Height()), 1), maxClientSize))
206+ }
207+ if w > 0 && h > 0 {
208+ var v xproto.ConfigureWindowValues
209+ v.SetWidth(w).SetHeight(h)
210+ x.expect(xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(xid), &v), kindConfigure, x.tab.tag(s), 0)
211+ r.w, r.h = w, h
212+ // The size is the client's own: core learns it from the commit
213+ // and Configure only resends it when core wants another.
214+ r.pend.w, r.pend.h = w, h
215+ r.sent.w, r.sent.h = w, h
216+ }
217+ }
218+ x.sendConfigureNotify(r)
219+}
16220
17-// flushConfigures sends the pending configures, once per display iteration.
18-func (x *XWM) flushConfigures() {}
221+// onConfigureNotify keeps the known geometry of every top-level window.
222+func (x *XWM) onConfigureNotify(ev *xturbo.Event) {
223+ e := xproto.ConfigureNotifyOf(ev)
224+ if e.Event() != xproto.Window(x.root) {
225+ return
226+ }
227+ if r := x.tab.rec(x.tab.lookup(uint32(e.Window()))); r != nil && r.kind != kindFree {
228+ r.x, r.y, r.w, r.h = int32(e.X()), int32(e.Y()), uint32(e.Width()), uint32(e.Height())
229+ }
230+}
@@ -2,17 +2,229 @@ 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+// size is the size to send: the requested one, else the last known.
19+func (r *record) size() (w, h uint32) {
20+ w, h = r.pend.w, r.pend.h
21+ if w == 0 {
22+ w = r.w
23+ }
24+ if h == 0 {
25+ h = r.h
26+ }
27+ return w, h
28+}
29+
30+// differs reports whether the pending configure is not what the X server has.
31+func (r *record) differs() bool {
32+ w, h := r.size()
33+ p, s := &r.pend, &r.sent
34+ return p.x != s.x || p.y != s.y || w != s.w || h != s.h ||
35+ p.fullscreen != s.fullscreen || p.activated != s.activated
36+}
37+
38+// markDirty queues s for the next flush when its pending configure differs.
39+func (x *XWM) markDirty(s Slot, r *record) {
40+ if r.pend.dirty || !r.differs() {
41+ return
42+ }
43+ if len(x.dirty) == cap(x.dirty) {
44+ // Stale entries of freed and reused slots filled the queue: send
45+ // what is queued rather than grow it. r stays valid, nothing here
46+ // grows the table.
47+ x.flushConfigures()
48+ }
49+ r.pend.dirty = true
50+ x.dirty = append(x.dirty, s)
51+}
52+
53+// managedRec returns the record of a live managed window.
54+func (x *XWM) managedRec(s Slot) *record {
55+ if x.closed {
56+ return nil
57+ }
58+ if r := x.tab.rec(s); r != nil && r.kind == kindManaged {
59+ return r
60+ }
61+ return nil
62+}
63+
64+// Configure records the size and state core wants for s; sent at flush. A
65+// zero size keeps the current one.
66+func (x *XWM) Configure(s Slot, c ports.ConfigureWindow) {
67+ r := x.managedRec(s)
68+ if r == nil {
69+ return
70+ }
71+ if c.Width > 0 {
72+ r.pend.w = uint32(min(c.Width, maxClientSize))
73+ }
74+ if c.Height > 0 {
75+ r.pend.h = uint32(min(c.Height, maxClientSize))
76+ }
77+ r.pend.fullscreen, r.pend.activated = c.Fullscreen, c.Activated
78+ x.markDirty(s, r)
79+ x.wake()
80+}
13 81
14 // Place records the layout position of the client rectangle of s.82 // Place records the layout position of the client rectangle of s.
15-func (x *XWM) Place(s Slot, px, py int) { _, _, _ = s, px, py }83+func (x *XWM) Place(s Slot, px, py int) {
84+ r := x.managedRec(s)
85+ if r == nil {
86+ return
87+ }
88+ r.pend.x, r.pend.y = int32(px), int32(py)
89+ x.markDirty(s, r)
90+ x.wake()
91+}
92+
93+// flushConfigures sends the pending configures, once per display iteration,
94+// then what focus left to do.
95+func (x *XWM) flushConfigures() {
96+ for _, s := range x.dirty {
97+ if r := x.tab.rec(s); r != nil && r.kind == kindManaged && r.pend.dirty {
98+ x.sendPending(s, r)
99+ }
100+ }
101+ x.dirty = x.dirty[:0]
102+ x.flushFocus()
103+}
104+
105+// sendPending writes the geometry (with the synthetic ConfigureNotify ICCCM
106+// 4.1.5 asks for, since Xwayland may not move the window) and the
107+// _NET_WM_STATE bits that changed.
108+func (x *XWM) sendPending(s Slot, r *record) {
109+ r.pend.dirty = false
110+ p := &r.pend
111+ w, h := r.size()
112+ if w > 0 && h > 0 && (p.x != r.sent.x || p.y != r.sent.y || w != r.sent.w || h != r.sent.h) {
113+ var v xproto.ConfigureWindowValues
114+ v.SetX(p.x).SetY(p.y).SetWidth(w).SetHeight(h).SetBorderWidth(0)
115+ x.expect(xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(r.xid), &v), kindConfigure, x.tab.tag(s), 0)
116+ r.x, r.y, r.w, r.h = p.x, p.y, w, h
117+ x.sendConfigureNotify(r)
118+ r.sent.x, r.sent.y, r.sent.w, r.sent.h = p.x, p.y, w, h
119+ }
120+ var bits uint8
121+ if p.fullscreen {
122+ bits |= stateFullscreen
123+ }
124+ if p.activated {
125+ bits |= stateFocused
126+ }
127+ r.sent.fullscreen, r.sent.activated = p.fullscreen, p.activated
128+ if bits != r.stateSent {
129+ r.stateSent = bits
130+ x.writeState(r)
131+ }
132+}
133+
134+// writeState rewrites _NET_WM_STATE of r from its bits.
135+func (x *XWM) writeState(r *record) {
136+ var list [3]uint32
137+ n := 0
138+ if r.modal {
139+ list[n] = x.atoms[atomNetWMStateModal]
140+ n++
141+ }
142+ if r.stateSent&stateFullscreen != 0 {
143+ list[n] = x.atoms[atomNetWMStateFullscreen]
144+ n++
145+ }
146+ if r.stateSent&stateFocused != 0 {
147+ list[n] = x.atoms[atomNetWMStateFocused]
148+ n++
149+ }
150+ x.setProp32(r.xid, atomNetWMState, xproto.AtomAtom, list[:n])
151+}
152+
153+// sendConfigureNotify tells the client its geometry as the XWM sees it.
154+func (x *XWM) sendConfigureNotify(r *record) {
155+ var raw [32]byte
156+ d := xproto.ConfigureNotifyEventData{
157+ Event: xproto.Window(r.xid), Window: xproto.Window(r.xid),
158+ X: int16(r.x), Y: int16(r.y), Width: uint16(r.w), Height: uint16(r.h),
159+ }
160+ d.Put(&raw)
161+ xproto.SendEvent(x.conn.Buf(), false, xproto.Window(r.xid), xproto.EventMaskStructureNotify, &raw)
162+}
163+
164+// onConfigureRequest answers a client resizing itself. Tiled windows are told
165+// their current geometry; floating ones get the size they ask for; unmanaged
166+// and unknown windows are configured as requested.
167+func (x *XWM) onConfigureRequest(ev *xturbo.Event) {
168+ e := xproto.ConfigureRequestOf(ev)
169+ xid := uint32(e.Window())
170+ mask := e.ValueMask()
171+ s := x.tab.lookup(xid)
172+ r := x.managedRec(s)
173+ if r == nil {
174+ var v xproto.ConfigureWindowValues
175+ if mask&xproto.ConfigWindowX != 0 {
176+ v.SetX(int32(e.X()))
177+ }
178+ if mask&xproto.ConfigWindowY != 0 {
179+ v.SetY(int32(e.Y()))
180+ }
181+ if mask&xproto.ConfigWindowWidth != 0 {
182+ v.SetWidth(uint32(e.Width()))
183+ }
184+ if mask&xproto.ConfigWindowHeight != 0 {
185+ v.SetHeight(uint32(e.Height()))
186+ }
187+ if mask&xproto.ConfigWindowBorderWidth != 0 {
188+ v.SetBorderWidth(uint32(e.BorderWidth()))
189+ }
190+ if mask&xproto.ConfigWindowSibling != 0 {
191+ v.SetSibling(e.Sibling())
192+ }
193+ if mask&xproto.ConfigWindowStackMode != 0 {
194+ v.SetStackMode(e.StackMode())
195+ }
196+ xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(xid), &v)
197+ return
198+ }
199+ if r.floating && mask&(xproto.ConfigWindowWidth|xproto.ConfigWindowHeight) != 0 {
200+ w, h := r.w, r.h
201+ if mask&xproto.ConfigWindowWidth != 0 {
202+ w = uint32(min(max(int(e.Width()), 1), maxClientSize))
203+ }
204+ if mask&xproto.ConfigWindowHeight != 0 {
205+ h = uint32(min(max(int(e.Height()), 1), maxClientSize))
206+ }
207+ if w > 0 && h > 0 {
208+ var v xproto.ConfigureWindowValues
209+ v.SetWidth(w).SetHeight(h)
210+ x.expect(xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(xid), &v), kindConfigure, x.tab.tag(s), 0)
211+ r.w, r.h = w, h
212+ // The size is the client's own: core learns it from the commit
213+ // and Configure only resends it when core wants another.
214+ r.pend.w, r.pend.h = w, h
215+ r.sent.w, r.sent.h = w, h
216+ }
217+ }
218+ x.sendConfigureNotify(r)
219+}
16 220
17-// flushConfigures sends the pending configures, once per display iteration.221+// onConfigureNotify keeps the known geometry of every top-level window.
18-func (x *XWM) flushConfigures() {}222+func (x *XWM) onConfigureNotify(ev *xturbo.Event) {
223+ e := xproto.ConfigureNotifyOf(ev)
224+ if e.Event() != xproto.Window(x.root) {
225+ return
226+ }
227+ if r := x.tab.rec(x.tab.lookup(uint32(e.Window()))); r != nil && r.kind != kindFree {
228+ r.x, r.y, r.w, r.h = int32(e.X()), int32(e.Y()), uint32(e.Width()), uint32(e.Height())
229+ }
230+}
A internal/adapters/wayland/xwm/configure_test.go +562 -0
@@ -0,0 +1,562 @@
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+ x.Focus(sb)
363+ settle(t, x, p)
364+ act, _ := p.prop(peerRoot, "_NET_ACTIVE_WINDOW")
365+ require.Equal(t, []uint32{b}, atomsOf(act))
366+ sta, _ := p.prop(a, "_NET_WM_STATE")
367+ require.NotContains(t, atomsOf(sta), p.atom("_NET_WM_STATE_FOCUSED"), "old window loses FOCUSED")
368+ stb, _ := p.prop(b, "_NET_WM_STATE")
369+ require.Contains(t, atomsOf(stb), p.atom("_NET_WM_STATE_FOCUSED"))
370+
371+ n = settle(t, x, p)
372+ x.Focus(0)
373+ settle(t, x, p)
374+ none := since(p, n, opSetInputFocus)
375+ require.Len(t, none, 1)
376+ require.Zero(t, none[0].u32(4), "focus None")
377+ require.EqualValues(t, xproto.InputFocusNone, none[0].raw[1])
378+ act, _ = p.prop(peerRoot, "_NET_ACTIVE_WINDOW")
379+ require.Equal(t, []uint32{0}, atomsOf(act))
380+ stb, _ = p.prop(b, "_NET_WM_STATE")
381+ require.NotContains(t, atomsOf(stb), p.atom("_NET_WM_STATE_FOCUSED"))
382+ require.Zero(t, x.active)
383+}
384+
385+func TestFocusStackingLists(t *testing.T) {
386+ x, p, h, _, _ := attached(t)
387+ const a, b, c = 0x500001, 0x500002, 0x500003
388+ sa := newWin(t, x, p, h, a)
389+ newWin(t, x, p, h, b)
390+ newWin(t, x, p, h, c)
391+ for _, w := range []uint32{a, b, c} {
392+ x.addClient(w)
393+ }
394+ settle(t, x, p)
395+ list, _ := p.prop(peerRoot, "_NET_CLIENT_LIST")
396+ require.Equal(t, []uint32{a, b, c}, atomsOf(list))
397+ n := settle(t, x, p)
398+
399+ x.Focus(sa)
400+ settle(t, x, p)
401+ stack, _ := p.prop(peerRoot, "_NET_CLIENT_LIST_STACKING")
402+ require.Equal(t, []uint32{b, c, a}, atomsOf(stack), "bottom to top")
403+ list, _ = p.prop(peerRoot, "_NET_CLIENT_LIST")
404+ require.Equal(t, []uint32{a, b, c}, atomsOf(list), "map order")
405+ require.Empty(t, propsTo(p, n, peerRoot, "_NET_CLIENT_LIST"), "clients list untouched by focus")
406+ var raised bool
407+ for _, r := range since(p, n, opConfigureWindow) {
408+ if r.u32(4) == a && binary.LittleEndian.Uint16(r.raw[8:]) == uint16(xproto.ConfigWindowStackMode) {
409+ require.EqualValues(t, xproto.StackModeAbove, r.u32(12))
410+ raised = true
411+ }
412+ }
413+ require.True(t, raised)
414+
415+ // A burst costs one write per list.
416+ n = settle(t, x, p)
417+ x.removeClient(b)
418+ x.removeClient(c)
419+ settle(t, x, p)
420+ require.Len(t, propsTo(p, n, peerRoot, "_NET_CLIENT_LIST"), 1)
421+ require.Len(t, propsTo(p, n, peerRoot, "_NET_CLIENT_LIST_STACKING"), 1)
422+ stack, _ = p.prop(peerRoot, "_NET_CLIENT_LIST_STACKING")
423+ require.Equal(t, []uint32{a}, atomsOf(stack))
424+}
425+
426+func TestDestroyedActiveWindowClearsActive(t *testing.T) {
427+ x, p, h, _, _ := attached(t)
428+ const win = 0x500001
429+ s := newWin(t, x, p, h, win)
430+ x.addClient(win)
431+ x.Focus(s)
432+ settle(t, x, p)
433+ h.EXPECT().Dissociate(s, true).Once()
434+ p.destroyNotify(win)
435+ readUntil(t, x, func() bool { return x.Slot(win) == 0 })
436+ settle(t, x, p)
437+ require.Zero(t, x.active)
438+ act, _ := p.prop(peerRoot, "_NET_ACTIVE_WINDOW")
439+ require.Equal(t, []uint32{0}, atomsOf(act))
440+ list, _ := p.prop(peerRoot, "_NET_CLIENT_LIST")
441+ require.Empty(t, list.data)
442+}
443+
444+func TestFocusInDenied(t *testing.T) {
445+ x, p, h, _, _ := attached(t)
446+ const a, b = 0x500001, 0x500002
447+ sa := newWin(t, x, p, h, a)
448+ newWin(t, x, p, h, b)
449+ x.Focus(sa)
450+ n := settle(t, x, p)
451+
452+ focusIn := func(win uint32, detail byte) {
453+ p.event(9, func(e *[32]byte) {
454+ e[1] = detail
455+ binary.LittleEndian.PutUint32(e[4:], win)
456+ })
457+ }
458+ focusCount := func(skip int) int {
459+ c := 0
460+ for _, r := range since(p, skip, opSetInputFocus) {
461+ if r.u32(4) == a {
462+ c++
463+ }
464+ }
465+ return c
466+ }
467+ x.feed(t, p, func() { focusIn(a, 0) }) // the active window itself: fine
468+ x.feed(t, p, func() { focusIn(b, byte(xproto.NotifyDetailPointer)) })
469+ settle(t, x, p)
470+ require.Zero(t, focusCount(n))
471+
472+ x.feed(t, p, func() { focusIn(b, 0); focusIn(b, 0) })
473+ readUntil(t, x, func() bool { return x.stats.Events >= 5 })
474+ settle(t, x, p)
475+ require.Equal(t, 1, focusCount(n), "denied once per iteration")
476+ n = settle(t, x, p)
477+ settle(t, x, p)
478+ require.Zero(t, focusCount(n))
479+}
480+
481+func TestCloseWindow(t *testing.T) {
482+ x, p, h, _, _ := attached(t)
483+ const a, b = 0x500001, 0x500002
484+ sa, sb := newWin(t, x, p, h, a), newWin(t, x, p, h, b)
485+ x.tab.rec(sa).deleteWindow = true
486+ n := settle(t, x, p)
487+
488+ x.CloseWindow(sa)
489+ x.CloseWindow(sb)
490+ settle(t, x, p)
491+ evs := sentEvents(p, n, a)
492+ require.Len(t, evs, 1)
493+ require.EqualValues(t, 33, evs[0][0])
494+ require.EqualValues(t, 32, evs[0][1])
495+ require.Equal(t, p.atom("WM_PROTOCOLS"), binary.LittleEndian.Uint32(evs[0][8:]))
496+ require.Equal(t, p.atom("WM_DELETE_WINDOW"), binary.LittleEndian.Uint32(evs[0][12:]))
497+ require.Zero(t, binary.LittleEndian.Uint32(evs[0][16:]))
498+ require.Empty(t, sentEvents(p, n, b))
499+
500+ kills := since(p, n, opKillClient)
501+ require.Len(t, kills, 1)
502+ require.Equal(t, uint32(b), kills[0].u32(4))
503+}
504+
505+// Allocation guards. The peer sinks requests so the connection never blocks;
506+// its goroutine is idle in the sink loop before measuring.
507+
508+func sinkPeer(t *testing.T, x *XWM, p *xpeer) {
509+ t.Helper()
510+ p.sink.Store(true)
511+ n := len(p.requests())
512+ xproto.GetInputFocus(x.conn.Buf()) // served by the old loop iteration
513+ require.NoError(t, x.conn.Flush())
514+ p.waitReq(n, func(r xpeerReq) bool { return r.major == opGetInputFocus })
515+ time.Sleep(50 * time.Millisecond)
516+ x.src = nil // no source: write backpressure would not allocate either
517+ x.post = func(func()) {}
518+ x.warnedRing = true // the sink never answers: Expect slots stay used
519+}
520+
521+func TestConfigureFlushAllocations(t *testing.T) {
522+ x, p, h, _, _ := attached(t)
523+ s := newWin(t, x, p, h, 0x500001)
524+ sinkPeer(t, x, p)
525+ i := 0
526+ allocs := testing.AllocsPerRun(300, func() {
527+ i++
528+ x.Configure(s, ports.ConfigureWindow{Width: 100 + i%2, Height: 100, Fullscreen: i%3 == 0, Activated: i%2 == 0})
529+ x.Place(s, i%5, i%7)
530+ x.posted = false
531+ x.flush()
532+ })
533+ require.Zero(t, allocs)
534+ require.False(t, x.closed)
535+}
536+
537+func TestFocusAllocations(t *testing.T) {
538+ x, p, h, _, _ := attached(t)
539+ a, b := newWin(t, x, p, h, 0x500001), newWin(t, x, p, h, 0x500002)
540+ x.addClient(x.tab.rec(a).xid)
541+ x.addClient(x.tab.rec(b).xid)
542+ x.tab.rec(b).takeFocus = true
543+ x.tab.rec(a).input, x.tab.rec(b).input = true, true
544+ sinkPeer(t, x, p)
545+ i := 0
546+ allocs := testing.AllocsPerRun(150, func() {
547+ i++
548+ switch i % 3 {
549+ case 0:
550+ x.Focus(a)
551+ case 1:
552+ x.Focus(b)
553+ default:
554+ x.Focus(0)
555+ }
556+ x.refocus = i%5 == 0
557+ x.posted = false
558+ x.flush()
559+ })
560+ require.Zero(t, allocs)
561+ require.False(t, x.closed)
562+}
@@ -0,0 +1,562 @@
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+ x.Focus(sb)
363+ settle(t, x, p)
364+ act, _ := p.prop(peerRoot, "_NET_ACTIVE_WINDOW")
365+ require.Equal(t, []uint32{b}, atomsOf(act))
366+ sta, _ := p.prop(a, "_NET_WM_STATE")
367+ require.NotContains(t, atomsOf(sta), p.atom("_NET_WM_STATE_FOCUSED"), "old window loses FOCUSED")
368+ stb, _ := p.prop(b, "_NET_WM_STATE")
369+ require.Contains(t, atomsOf(stb), p.atom("_NET_WM_STATE_FOCUSED"))
370+
371+ n = settle(t, x, p)
372+ x.Focus(0)
373+ settle(t, x, p)
374+ none := since(p, n, opSetInputFocus)
375+ require.Len(t, none, 1)
376+ require.Zero(t, none[0].u32(4), "focus None")
377+ require.EqualValues(t, xproto.InputFocusNone, none[0].raw[1])
378+ act, _ = p.prop(peerRoot, "_NET_ACTIVE_WINDOW")
379+ require.Equal(t, []uint32{0}, atomsOf(act))
380+ stb, _ = p.prop(b, "_NET_WM_STATE")
381+ require.NotContains(t, atomsOf(stb), p.atom("_NET_WM_STATE_FOCUSED"))
382+ require.Zero(t, x.active)
383+}
384+
385+func TestFocusStackingLists(t *testing.T) {
386+ x, p, h, _, _ := attached(t)
387+ const a, b, c = 0x500001, 0x500002, 0x500003
388+ sa := newWin(t, x, p, h, a)
389+ newWin(t, x, p, h, b)
390+ newWin(t, x, p, h, c)
391+ for _, w := range []uint32{a, b, c} {
392+ x.addClient(w)
393+ }
394+ settle(t, x, p)
395+ list, _ := p.prop(peerRoot, "_NET_CLIENT_LIST")
396+ require.Equal(t, []uint32{a, b, c}, atomsOf(list))
397+ n := settle(t, x, p)
398+
399+ x.Focus(sa)
400+ settle(t, x, p)
401+ stack, _ := p.prop(peerRoot, "_NET_CLIENT_LIST_STACKING")
402+ require.Equal(t, []uint32{b, c, a}, atomsOf(stack), "bottom to top")
403+ list, _ = p.prop(peerRoot, "_NET_CLIENT_LIST")
404+ require.Equal(t, []uint32{a, b, c}, atomsOf(list), "map order")
405+ require.Empty(t, propsTo(p, n, peerRoot, "_NET_CLIENT_LIST"), "clients list untouched by focus")
406+ var raised bool
407+ for _, r := range since(p, n, opConfigureWindow) {
408+ if r.u32(4) == a && binary.LittleEndian.Uint16(r.raw[8:]) == uint16(xproto.ConfigWindowStackMode) {
409+ require.EqualValues(t, xproto.StackModeAbove, r.u32(12))
410+ raised = true
411+ }
412+ }
413+ require.True(t, raised)
414+
415+ // A burst costs one write per list.
416+ n = settle(t, x, p)
417+ x.removeClient(b)
418+ x.removeClient(c)
419+ settle(t, x, p)
420+ require.Len(t, propsTo(p, n, peerRoot, "_NET_CLIENT_LIST"), 1)
421+ require.Len(t, propsTo(p, n, peerRoot, "_NET_CLIENT_LIST_STACKING"), 1)
422+ stack, _ = p.prop(peerRoot, "_NET_CLIENT_LIST_STACKING")
423+ require.Equal(t, []uint32{a}, atomsOf(stack))
424+}
425+
426+func TestDestroyedActiveWindowClearsActive(t *testing.T) {
427+ x, p, h, _, _ := attached(t)
428+ const win = 0x500001
429+ s := newWin(t, x, p, h, win)
430+ x.addClient(win)
431+ x.Focus(s)
432+ settle(t, x, p)
433+ h.EXPECT().Dissociate(s, true).Once()
434+ p.destroyNotify(win)
435+ readUntil(t, x, func() bool { return x.Slot(win) == 0 })
436+ settle(t, x, p)
437+ require.Zero(t, x.active)
438+ act, _ := p.prop(peerRoot, "_NET_ACTIVE_WINDOW")
439+ require.Equal(t, []uint32{0}, atomsOf(act))
440+ list, _ := p.prop(peerRoot, "_NET_CLIENT_LIST")
441+ require.Empty(t, list.data)
442+}
443+
444+func TestFocusInDenied(t *testing.T) {
445+ x, p, h, _, _ := attached(t)
446+ const a, b = 0x500001, 0x500002
447+ sa := newWin(t, x, p, h, a)
448+ newWin(t, x, p, h, b)
449+ x.Focus(sa)
450+ n := settle(t, x, p)
451+
452+ focusIn := func(win uint32, detail byte) {
453+ p.event(9, func(e *[32]byte) {
454+ e[1] = detail
455+ binary.LittleEndian.PutUint32(e[4:], win)
456+ })
457+ }
458+ focusCount := func(skip int) int {
459+ c := 0
460+ for _, r := range since(p, skip, opSetInputFocus) {
461+ if r.u32(4) == a {
462+ c++
463+ }
464+ }
465+ return c
466+ }
467+ x.feed(t, p, func() { focusIn(a, 0) }) // the active window itself: fine
468+ x.feed(t, p, func() { focusIn(b, byte(xproto.NotifyDetailPointer)) })
469+ settle(t, x, p)
470+ require.Zero(t, focusCount(n))
471+
472+ x.feed(t, p, func() { focusIn(b, 0); focusIn(b, 0) })
473+ readUntil(t, x, func() bool { return x.stats.Events >= 5 })
474+ settle(t, x, p)
475+ require.Equal(t, 1, focusCount(n), "denied once per iteration")
476+ n = settle(t, x, p)
477+ settle(t, x, p)
478+ require.Zero(t, focusCount(n))
479+}
480+
481+func TestCloseWindow(t *testing.T) {
482+ x, p, h, _, _ := attached(t)
483+ const a, b = 0x500001, 0x500002
484+ sa, sb := newWin(t, x, p, h, a), newWin(t, x, p, h, b)
485+ x.tab.rec(sa).deleteWindow = true
486+ n := settle(t, x, p)
487+
488+ x.CloseWindow(sa)
489+ x.CloseWindow(sb)
490+ settle(t, x, p)
491+ evs := sentEvents(p, n, a)
492+ require.Len(t, evs, 1)
493+ require.EqualValues(t, 33, evs[0][0])
494+ require.EqualValues(t, 32, evs[0][1])
495+ require.Equal(t, p.atom("WM_PROTOCOLS"), binary.LittleEndian.Uint32(evs[0][8:]))
496+ require.Equal(t, p.atom("WM_DELETE_WINDOW"), binary.LittleEndian.Uint32(evs[0][12:]))
497+ require.Zero(t, binary.LittleEndian.Uint32(evs[0][16:]))
498+ require.Empty(t, sentEvents(p, n, b))
499+
500+ kills := since(p, n, opKillClient)
501+ require.Len(t, kills, 1)
502+ require.Equal(t, uint32(b), kills[0].u32(4))
503+}
504+
505+// Allocation guards. The peer sinks requests so the connection never blocks;
506+// its goroutine is idle in the sink loop before measuring.
507+
508+func sinkPeer(t *testing.T, x *XWM, p *xpeer) {
509+ t.Helper()
510+ p.sink.Store(true)
511+ n := len(p.requests())
512+ xproto.GetInputFocus(x.conn.Buf()) // served by the old loop iteration
513+ require.NoError(t, x.conn.Flush())
514+ p.waitReq(n, func(r xpeerReq) bool { return r.major == opGetInputFocus })
515+ time.Sleep(50 * time.Millisecond)
516+ x.src = nil // no source: write backpressure would not allocate either
517+ x.post = func(func()) {}
518+ x.warnedRing = true // the sink never answers: Expect slots stay used
519+}
520+
521+func TestConfigureFlushAllocations(t *testing.T) {
522+ x, p, h, _, _ := attached(t)
523+ s := newWin(t, x, p, h, 0x500001)
524+ sinkPeer(t, x, p)
525+ i := 0
526+ allocs := testing.AllocsPerRun(300, func() {
527+ i++
528+ x.Configure(s, ports.ConfigureWindow{Width: 100 + i%2, Height: 100, Fullscreen: i%3 == 0, Activated: i%2 == 0})
529+ x.Place(s, i%5, i%7)
530+ x.posted = false
531+ x.flush()
532+ })
533+ require.Zero(t, allocs)
534+ require.False(t, x.closed)
535+}
536+
537+func TestFocusAllocations(t *testing.T) {
538+ x, p, h, _, _ := attached(t)
539+ a, b := newWin(t, x, p, h, 0x500001), newWin(t, x, p, h, 0x500002)
540+ x.addClient(x.tab.rec(a).xid)
541+ x.addClient(x.tab.rec(b).xid)
542+ x.tab.rec(b).takeFocus = true
543+ x.tab.rec(a).input, x.tab.rec(b).input = true, true
544+ sinkPeer(t, x, p)
545+ i := 0
546+ allocs := testing.AllocsPerRun(150, func() {
547+ i++
548+ switch i % 3 {
549+ case 0:
550+ x.Focus(a)
551+ case 1:
552+ x.Focus(b)
553+ default:
554+ x.Focus(0)
555+ }
556+ x.refocus = i%5 == 0
557+ x.posted = false
558+ x.flush()
559+ })
560+ require.Zero(t, allocs)
561+ require.False(t, x.closed)
562+}
M internal/adapters/wayland/xwm/events.go +1 -5
@@ -176,11 +176,7 @@ func (x *XWM) onDestroyNotify(ev *xturbo.Event) {
176176 if s == 0 {
177177 return
178178 }
179- if r := x.tab.rec(s); r.kind == kindManaged && r.id != 0 && x.host != nil {
180- r.id = 0
181- x.host.Dissociate(s, true)
182- }
183- x.tab.remove(s)
179+ x.forgetWindow(s)
184180 }
185181
186182 // onTreeReply (boot): record every existing child and ask for its attributes.
@@ -176,11 +176,7 @@ func (x *XWM) onDestroyNotify(ev *xturbo.Event) {
176 if s == 0 {176 if s == 0 {
177 return177 return
178 }178 }
179- if r := x.tab.rec(s); r.kind == kindManaged && r.id != 0 && x.host != nil {179+ x.forgetWindow(s)
180- r.id = 0
181- x.host.Dissociate(s, true)
182- }
183- x.tab.remove(s)
184 }180 }
185 181
186 // onTreeReply (boot): record every existing child and ask for its attributes.182 // onTreeReply (boot): record every existing child and ask for its attributes.
M internal/adapters/wayland/xwm/focus.go +187 -4
@@ -1,11 +1,194 @@
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 takes s out of the client lists, the focus and the configure
150+// queue when it stops being mapped (unmap, destroy). It is idempotent.
151+func (x *XWM) dropWindow(s Slot) {
152+ r := x.tab.rec(s)
153+ if r == nil || r.kind != kindManaged {
154+ return
155+ }
156+ x.removeClient(r.xid)
157+ r.pend.dirty = false // the queued slot is skipped at flush
158+ if x.active == s {
159+ x.active = 0
160+ x.refocus = false
161+ x.setActive(0)
162+ }
163+}
164+
165+// forgetWindow ends the life of s (destroy, reparent away): it is dropped,
166+// the host is told if it knew the window, and the record is freed.
167+func (x *XWM) forgetWindow(s Slot) {
168+ r := x.tab.rec(s)
169+ if r == nil || r.kind == kindFree {
170+ return
171+ }
172+ x.dropWindow(s)
173+ if r.kind == kindManaged && r.id != 0 && x.host != nil {
174+ r.id = 0
175+ x.host.Dissociate(s, true)
176+ }
177+ x.tab.remove(s)
178+}
179+
180+// flushFocus rewrites the client lists that changed and re-issues a denied
181+// focus, once per display iteration.
182+func (x *XWM) flushFocus() {
183+ if x.listsDirty&listClients != 0 {
184+ x.setProp32(x.root, atomNetClientList, xproto.AtomWindow, x.clients)
185+ }
186+ if x.listsDirty&listStacking != 0 {
187+ x.setProp32(x.root, atomNetClientListStacking, xproto.AtomWindow, x.stacking)
188+ }
189+ x.listsDirty = 0
190+ if x.refocus {
191+ x.refocus = false
192+ x.issueFocus(x.active, x.managedRec(x.active))
193+ }
194+}
@@ -1,11 +1,194 @@
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 takes s out of the client lists, the focus and the configure
150+// queue when it stops being mapped (unmap, destroy). It is idempotent.
151+func (x *XWM) dropWindow(s Slot) {
152+ r := x.tab.rec(s)
153+ if r == nil || r.kind != kindManaged {
154+ return
155+ }
156+ x.removeClient(r.xid)
157+ r.pend.dirty = false // the queued slot is skipped at flush
158+ if x.active == s {
159+ x.active = 0
160+ x.refocus = false
161+ x.setActive(0)
162+ }
163+}
164+
165+// forgetWindow ends the life of s (destroy, reparent away): it is dropped,
166+// the host is told if it knew the window, and the record is freed.
167+func (x *XWM) forgetWindow(s Slot) {
168+ r := x.tab.rec(s)
169+ if r == nil || r.kind == kindFree {
170+ return
171+ }
172+ x.dropWindow(s)
173+ if r.kind == kindManaged && r.id != 0 && x.host != nil {
174+ r.id = 0
175+ x.host.Dissociate(s, true)
176+ }
177+ x.tab.remove(s)
178+}
179+
180+// flushFocus rewrites the client lists that changed and re-issues a denied
181+// focus, once per display iteration.
182+func (x *XWM) flushFocus() {
183+ if x.listsDirty&listClients != 0 {
184+ x.setProp32(x.root, atomNetClientList, xproto.AtomWindow, x.clients)
185+ }
186+ if x.listsDirty&listStacking != 0 {
187+ x.setProp32(x.root, atomNetClientListStacking, xproto.AtomWindow, x.stacking)
188+ }
189+ x.listsDirty = 0
190+ if x.refocus {
191+ x.refocus = false
192+ x.issueFocus(x.active, x.managedRec(x.active))
193+ }
194+}
M internal/adapters/wayland/xwm/mapflow.go +6 -49
@@ -68,13 +68,14 @@ func (x *XWM) onUnmapNotify(ev *xturbo.Event) {
6868 }
6969 wasMapping := rec.mapping
7070 rec.ready, rec.mapping, rec.pending = false, false, 0
71+ rec.pend.dirty = false
7172 rec.serial, rec.surface = 0, 0
7273 if wasMapping {
7374 xid := rec.xid
7475 x.setProp32Typed(xid, atomWMState, x.atoms[atomWMState], []uint32{wmStateWithdrawn, 0})
7576 xproto.DeleteProperty(x.conn.Buf(), xproto.Window(xid), xproto.Atom(x.atoms[atomNetWMState]))
76- x.unlist(xid)
7777 }
78+ x.dropWindow(s)
7879 if rec.id != 0 {
7980 x.host.Dissociate(s, false)
8081 }
@@ -92,6 +93,7 @@ func (x *XWM) checkReady(s Slot) {
9293 return
9394 }
9495 rec.ready = true
96+ x.classify(rec)
9597 info := x.info(s)
9698 xid := rec.xid
9799
@@ -107,9 +109,7 @@ func (x *XWM) checkReady(s Slot) {
107109 }
108110 x.setProp32(xid, atomNetWMState, xproto.AtomAtom, state[:n])
109111 x.setProp32Typed(xid, atomWMState, x.atoms[atomWMState], []uint32{wmStateNormal, 0})
110- x.clients = append(x.clients, xid)
111- x.stacking = append(x.stacking, xid)
112- x.publishLists()
112+ x.addClient(xid)
113113
114114 x.host.Ready(s, info)
115115 x.tryPair(s)
@@ -119,10 +119,9 @@ func (x *XWM) checkReady(s Slot) {
119119 func (x *XWM) info(s Slot) WindowInfo {
120120 rec := x.tab.rec(s)
121121 parent := x.managedSlot(rec.transient)
122- fixed := rec.minW > 0 && rec.minH > 0 && rec.minW == rec.maxW && rec.minH == rec.maxH
123122 info := WindowInfo{
124123 PID: int(rec.pid),
125- Floating: floatingType(rec.wtype) || parent != 0 || fixed,
124+ Floating: rec.floating,
126125 Parent: parent,
127126 Modal: rec.modal,
128127 Fullscreen: rec.fullscreen,
@@ -143,31 +142,6 @@ func (x *XWM) onGeomReply(s Slot, rec *record, r *xturbo.Reply) {
143142 x.checkReady(s)
144143 }
145144
146-// publishLists rewrites _NET_CLIENT_LIST and _NET_CLIENT_LIST_STACKING.
147-func (x *XWM) publishLists() {
148- x.setProp32(x.root, atomNetClientList, xproto.AtomWindow, x.clients)
149- x.setProp32(x.root, atomNetClientListStacking, xproto.AtomWindow, x.stacking)
150-}
151-
152-// unlist drops xid from both client lists and republishes them if it was in.
153-func (x *XWM) unlist(xid uint32) {
154- was := len(x.clients)
155- x.clients = removeID(x.clients, xid)
156- x.stacking = removeID(x.stacking, xid)
157- if len(x.clients) != was {
158- x.publishLists()
159- }
160-}
161-
162-func removeID(l []uint32, xid uint32) []uint32 {
163- for i, v := range l {
164- if v == xid {
165- return append(l[:i], l[i+1:]...)
166- }
167- }
168- return l
169-}
170-
171145 // onClientMessage handles WL_SURFACE_SERIAL (pairing), the legacy
172146 // WL_SURFACE_ID (counted, ignored) and _NET_WM_STATE requests.
173147 func (x *XWM) onClientMessage(ev *xturbo.Event) {
@@ -253,7 +227,7 @@ func (x *XWM) onReparentNotify(ev *xturbo.Event) {
253227 s := x.tab.lookup(xid)
254228 if uint32(e.Parent()) != x.root {
255229 if s != 0 {
256- x.dropWindow(s)
230+ x.forgetWindow(s)
257231 }
258232 return
259233 }
@@ -277,23 +251,6 @@ func (x *XWM) onReparentNotify(ev *xturbo.Event) {
277251 }
278252 }
279253
280-// dropWindow forgets s: the host is told if it knew the window.
281-func (x *XWM) dropWindow(s Slot) {
282- rec := x.tab.rec(s)
283- xid := rec.xid
284- if rec.kind == kindManaged {
285- x.unlist(xid)
286- if x.active == s {
287- x.active = 0
288- }
289- if rec.id != 0 && x.host != nil {
290- rec.id = 0
291- x.host.Dissociate(s, true)
292- }
293- }
294- x.tab.remove(s)
295-}
296-
297254 // tryPair links the window to the wl_surface carrying its serial, once both
298255 // are known and the window is ready (the host maps on the surface's first
299256 // buffer, so it must know the window's info by then). Unmanaged windows keep
@@ -68,13 +68,14 @@ func (x *XWM) onUnmapNotify(ev *xturbo.Event) {
68 }68 }
69 wasMapping := rec.mapping69 wasMapping := rec.mapping
70 rec.ready, rec.mapping, rec.pending = false, false, 070 rec.ready, rec.mapping, rec.pending = false, false, 0
71+ rec.pend.dirty = false
71 rec.serial, rec.surface = 0, 072 rec.serial, rec.surface = 0, 0
72 if wasMapping {73 if wasMapping {
73 xid := rec.xid74 xid := rec.xid
74 x.setProp32Typed(xid, atomWMState, x.atoms[atomWMState], []uint32{wmStateWithdrawn, 0})75 x.setProp32Typed(xid, atomWMState, x.atoms[atomWMState], []uint32{wmStateWithdrawn, 0})
75 xproto.DeleteProperty(x.conn.Buf(), xproto.Window(xid), xproto.Atom(x.atoms[atomNetWMState]))76 xproto.DeleteProperty(x.conn.Buf(), xproto.Window(xid), xproto.Atom(x.atoms[atomNetWMState]))
76- x.unlist(xid)
77 }77 }
78+ x.dropWindow(s)
78 if rec.id != 0 {79 if rec.id != 0 {
79 x.host.Dissociate(s, false)80 x.host.Dissociate(s, false)
80 }81 }
@@ -92,6 +93,7 @@ func (x *XWM) checkReady(s Slot) {
92 return93 return
93 }94 }
94 rec.ready = true95 rec.ready = true
96+ x.classify(rec)
95 info := x.info(s)97 info := x.info(s)
96 xid := rec.xid98 xid := rec.xid
97 99
@@ -107,9 +109,7 @@ func (x *XWM) checkReady(s Slot) {
107 }109 }
108 x.setProp32(xid, atomNetWMState, xproto.AtomAtom, state[:n])110 x.setProp32(xid, atomNetWMState, xproto.AtomAtom, state[:n])
109 x.setProp32Typed(xid, atomWMState, x.atoms[atomWMState], []uint32{wmStateNormal, 0})111 x.setProp32Typed(xid, atomWMState, x.atoms[atomWMState], []uint32{wmStateNormal, 0})
110- x.clients = append(x.clients, xid)112+ x.addClient(xid)
111- x.stacking = append(x.stacking, xid)
112- x.publishLists()
113 113
114 x.host.Ready(s, info)114 x.host.Ready(s, info)
115 x.tryPair(s)115 x.tryPair(s)
@@ -119,10 +119,9 @@ func (x *XWM) checkReady(s Slot) {
119 func (x *XWM) info(s Slot) WindowInfo {119 func (x *XWM) info(s Slot) WindowInfo {
120 rec := x.tab.rec(s)120 rec := x.tab.rec(s)
121 parent := x.managedSlot(rec.transient)121 parent := x.managedSlot(rec.transient)
122- fixed := rec.minW > 0 && rec.minH > 0 && rec.minW == rec.maxW && rec.minH == rec.maxH
123 info := WindowInfo{122 info := WindowInfo{
124 PID: int(rec.pid),123 PID: int(rec.pid),
125- Floating: floatingType(rec.wtype) || parent != 0 || fixed,124+ Floating: rec.floating,
126 Parent: parent,125 Parent: parent,
127 Modal: rec.modal,126 Modal: rec.modal,
128 Fullscreen: rec.fullscreen,127 Fullscreen: rec.fullscreen,
@@ -143,31 +142,6 @@ func (x *XWM) onGeomReply(s Slot, rec *record, r *xturbo.Reply) {
143 x.checkReady(s)142 x.checkReady(s)
144 }143 }
145 144
146-// publishLists rewrites _NET_CLIENT_LIST and _NET_CLIENT_LIST_STACKING.
147-func (x *XWM) publishLists() {
148- x.setProp32(x.root, atomNetClientList, xproto.AtomWindow, x.clients)
149- x.setProp32(x.root, atomNetClientListStacking, xproto.AtomWindow, x.stacking)
150-}
151-
152-// unlist drops xid from both client lists and republishes them if it was in.
153-func (x *XWM) unlist(xid uint32) {
154- was := len(x.clients)
155- x.clients = removeID(x.clients, xid)
156- x.stacking = removeID(x.stacking, xid)
157- if len(x.clients) != was {
158- x.publishLists()
159- }
160-}
161-
162-func removeID(l []uint32, xid uint32) []uint32 {
163- for i, v := range l {
164- if v == xid {
165- return append(l[:i], l[i+1:]...)
166- }
167- }
168- return l
169-}
170-
171 // onClientMessage handles WL_SURFACE_SERIAL (pairing), the legacy145 // onClientMessage handles WL_SURFACE_SERIAL (pairing), the legacy
172 // WL_SURFACE_ID (counted, ignored) and _NET_WM_STATE requests.146 // WL_SURFACE_ID (counted, ignored) and _NET_WM_STATE requests.
173 func (x *XWM) onClientMessage(ev *xturbo.Event) {147 func (x *XWM) onClientMessage(ev *xturbo.Event) {
@@ -253,7 +227,7 @@ func (x *XWM) onReparentNotify(ev *xturbo.Event) {
253 s := x.tab.lookup(xid)227 s := x.tab.lookup(xid)
254 if uint32(e.Parent()) != x.root {228 if uint32(e.Parent()) != x.root {
255 if s != 0 {229 if s != 0 {
256- x.dropWindow(s)230+ x.forgetWindow(s)
257 }231 }
258 return232 return
259 }233 }
@@ -277,23 +251,6 @@ func (x *XWM) onReparentNotify(ev *xturbo.Event) {
277 }251 }
278 }252 }
279 253
280-// dropWindow forgets s: the host is told if it knew the window.
281-func (x *XWM) dropWindow(s Slot) {
282- rec := x.tab.rec(s)
283- xid := rec.xid
284- if rec.kind == kindManaged {
285- x.unlist(xid)
286- if x.active == s {
287- x.active = 0
288- }
289- if rec.id != 0 && x.host != nil {
290- rec.id = 0
291- x.host.Dissociate(s, true)
292- }
293- }
294- x.tab.remove(s)
295-}
296-
297 // tryPair links the window to the wl_surface carrying its serial, once both254 // tryPair links the window to the wl_surface carrying its serial, once both
298 // are known and the window is ready (the host maps on the surface's first255 // are known and the window is ready (the host maps on the surface's first
299 // buffer, so it must know the window's info by then). Unmanaged windows keep256 // buffer, so it must know the window's info by then). Unmanaged windows keep
M internal/adapters/wayland/xwm/props.go +10 -0
@@ -203,6 +203,7 @@ func (x *XWM) applyProp(s Slot, idx int, format uint8, val []byte) {
203203 rec.maxW, rec.maxH = int32(word(val, 7)), int32(word(val, 8))
204204 }
205205 }
206+ x.classify(rec)
206207 case propProtocols:
207208 rec.deleteWindow, rec.takeFocus = false, false
208209 for i := 0; i+4 <= len(val); i += 4 {
@@ -222,6 +223,7 @@ func (x *XWM) applyProp(s Slot, idx int, format uint8, val []byte) {
222223 return
223224 }
224225 rec.transient = t
226+ x.classify(rec)
225227 if rec.ready {
226228 x.host.Parent(s, x.managedSlot(t))
227229 }
@@ -233,6 +235,7 @@ func (x *XWM) applyProp(s Slot, idx int, format uint8, val []byte) {
233235 break
234236 }
235237 }
238+ x.classify(rec)
236239 case propState:
237240 var modal, fs bool
238241 for i := 0; i+4 <= len(val); i += 4 {
@@ -292,6 +295,13 @@ func floatingType(t uint8) bool {
292295 return false
293296 }
294297
298+// classify sets rec.floating, the single source of truth: a dialog-like
299+// window type, a transient for a managed window, or a fixed size.
300+func (x *XWM) classify(rec *record) {
301+ fixed := rec.minW > 0 && rec.minH > 0 && rec.minW == rec.maxW && rec.minH == rec.maxH
302+ rec.floating = floatingType(rec.wtype) || x.managedSlot(rec.transient) != 0 || fixed
303+}
304+
295305 // managedSlot returns the slot of the managed window xid, or 0.
296306 func (x *XWM) managedSlot(xid uint32) Slot {
297307 if xid == 0 {
@@ -203,6 +203,7 @@ func (x *XWM) applyProp(s Slot, idx int, format uint8, val []byte) {
203 rec.maxW, rec.maxH = int32(word(val, 7)), int32(word(val, 8))203 rec.maxW, rec.maxH = int32(word(val, 7)), int32(word(val, 8))
204 }204 }
205 }205 }
206+ x.classify(rec)
206 case propProtocols:207 case propProtocols:
207 rec.deleteWindow, rec.takeFocus = false, false208 rec.deleteWindow, rec.takeFocus = false, false
208 for i := 0; i+4 <= len(val); i += 4 {209 for i := 0; i+4 <= len(val); i += 4 {
@@ -222,6 +223,7 @@ func (x *XWM) applyProp(s Slot, idx int, format uint8, val []byte) {
222 return223 return
223 }224 }
224 rec.transient = t225 rec.transient = t
226+ x.classify(rec)
225 if rec.ready {227 if rec.ready {
226 x.host.Parent(s, x.managedSlot(t))228 x.host.Parent(s, x.managedSlot(t))
227 }229 }
@@ -233,6 +235,7 @@ func (x *XWM) applyProp(s Slot, idx int, format uint8, val []byte) {
233 break235 break
234 }236 }
235 }237 }
238+ x.classify(rec)
236 case propState:239 case propState:
237 var modal, fs bool240 var modal, fs bool
238 for i := 0; i+4 <= len(val); i += 4 {241 for i := 0; i+4 <= len(val); i += 4 {
@@ -292,6 +295,13 @@ func floatingType(t uint8) bool {
292 return false295 return false
293 }296 }
294 297
298+// classify sets rec.floating, the single source of truth: a dialog-like
299+// window type, a transient for a managed window, or a fixed size.
300+func (x *XWM) classify(rec *record) {
301+ fixed := rec.minW > 0 && rec.minH > 0 && rec.minW == rec.maxW && rec.minH == rec.maxH
302+ rec.floating = floatingType(rec.wtype) || x.managedSlot(rec.transient) != 0 || fixed
303+}
304+
295 // managedSlot returns the slot of the managed window xid, or 0.305 // managedSlot returns the slot of the managed window xid, or 0.
296 func (x *XWM) managedSlot(xid uint32) Slot {306 func (x *XWM) managedSlot(xid uint32) Slot {
297 if xid == 0 {307 if xid == 0 {
M internal/adapters/wayland/xwm/streams_test.go +5 -0
@@ -64,6 +64,11 @@ func streamScenario(t *testing.T) *harness {
6464 hs := newHarness(t, nil)
6565 hs.classic(streamWin)
6666 hs.mapAndWait(streamWin)
67+ // The steady state is a focused window: a FocusIn on it is not denied,
68+ // so no request depends on how events are batched into flushes. The
69+ // poke makes sure the focus requests are out before the peer pauses.
70+ hs.x.Focus(hs.x.Slot(streamWin))
71+ poke(hs)
6772 return hs
6873 }
6974
@@ -64,6 +64,11 @@ func streamScenario(t *testing.T) *harness {
64 hs := newHarness(t, nil)64 hs := newHarness(t, nil)
65 hs.classic(streamWin)65 hs.classic(streamWin)
66 hs.mapAndWait(streamWin)66 hs.mapAndWait(streamWin)
67+ // The steady state is a focused window: a FocusIn on it is not denied,
68+ // so no request depends on how events are batched into flushes. The
69+ // poke makes sure the focus requests are out before the peer pauses.
70+ hs.x.Focus(hs.x.Slot(streamWin))
71+ poke(hs)
67 return hs72 return hs
68 }73 }
69 74
M internal/adapters/wayland/xwm/table.go +1 -2
@@ -1,5 +1,3 @@
1-//lint:file-ignore U1000 pend, sent and stateSent are used by the configure flow
2-
31 package xwm
42
53 import (
@@ -53,6 +51,7 @@ type record struct {
5351 minW, minH, maxW, maxH int32
5452 title, class string // owned, replaced only on change
5553 hasNetName bool
54+ floating bool // dialog-like type; set with the properties
5655
5756 // pairing
5857 serial uint64
@@ -1,5 +1,3 @@
1-//lint:file-ignore U1000 pend, sent and stateSent are used by the configure flow
2-
3 package xwm1 package xwm
4 2
5 import (3 import (
@@ -53,6 +51,7 @@ type record struct {
53 minW, minH, maxW, maxH int3251 minW, minH, maxW, maxH int32
54 title, class string // owned, replaced only on change52 title, class string // owned, replaced only on change
55 hasNetName bool53 hasNetName bool
54+ floating bool // dialog-like type; set with the properties
56 55
57 // pairing56 // pairing
58 serial uint6457 serial uint64
M internal/adapters/wayland/xwm/testdata/streams/property_unchanged.hex +19 -19
@@ -1,22 +1,3 @@
1-1c005f0001005000f30300000000000000000000000000000000000000000000
2-0108600003000000ef0300000000000009000000000000000000000000000000
3-6e6574207469746c650000001c00600001005000f30300000000000000000000
4-0000000000000000000000000108610003000000ef0300000000000009000000
5-0000000000000000000000006e6574207469746c650000001c00610001005000
6-f303000000000000000000000000000000000000000000000108620003000000
7-ef03000000000000090000000000000000000000000000006e6574207469746c
8-650000001c00620001005000f303000000000000000000000000000000000000
9-000000000108630003000000ef03000000000000090000000000000000000000
10-000000006e6574207469746c650000001c00630001005000f303000000000000
11-000000000000000000000000000000000108640003000000ef03000000000000
12-090000000000000000000000000000006e6574207469746c650000001c006400
13-01005000f3030000000000000000000000000000000000000000000001086500
14-03000000ef03000000000000090000000000000000000000000000006e657420
15-7469746c650000001c00650001005000f3030000000000000000000000000000
16-00000000000000000108660003000000ef030000000000000900000000000000
17-00000000000000006e6574207469746c650000001c00660001005000f3030000
18-00000000000000000000000000000000000000000108670003000000ef030000
19-00000000090000000000000000000000000000006e6574207469746c65000000
201 1c00670001005000f30300000000000000000000000000000000000000000000
212 0108680003000000ef0300000000000009000000000000000000000000000000
223 6e6574207469746c650000001c00680001005000f30300000000000000000000
@@ -948,3 +929,22 @@ ef03000000000000090000000000000000000000000000006e6574207469746c
948929 00000000000000006e6574207469746c650000001c00ee0101005000f3030000
949930 00000000000000000000000000000000000000000108ef0103000000ef030000
950931 00000000090000000000000000000000000000006e6574207469746c65000000
932+1c00ef0101005000f30300000000000000000000000000000000000000000000
933+0108f00103000000ef0300000000000009000000000000000000000000000000
934+6e6574207469746c650000001c00f00101005000f30300000000000000000000
935+0000000000000000000000000108f10103000000ef0300000000000009000000
936+0000000000000000000000006e6574207469746c650000001c00f10101005000
937+f303000000000000000000000000000000000000000000000108f20103000000
938+ef03000000000000090000000000000000000000000000006e6574207469746c
939+650000001c00f20101005000f303000000000000000000000000000000000000
940+000000000108f30103000000ef03000000000000090000000000000000000000
941+000000006e6574207469746c650000001c00f30101005000f303000000000000
942+000000000000000000000000000000000108f40103000000ef03000000000000
943+090000000000000000000000000000006e6574207469746c650000001c00f401
944+01005000f303000000000000000000000000000000000000000000000108f501
945+03000000ef03000000000000090000000000000000000000000000006e657420
946+7469746c650000001c00f50101005000f3030000000000000000000000000000
947+00000000000000000108f60103000000ef030000000000000900000000000000
948+00000000000000006e6574207469746c650000001c00f60101005000f3030000
949+00000000000000000000000000000000000000000108f70103000000ef030000
950+00000000090000000000000000000000000000006e6574207469746c65000000
@@ -1,22 +1,3 @@
1-1c005f0001005000f30300000000000000000000000000000000000000000000
2-0108600003000000ef0300000000000009000000000000000000000000000000
3-6e6574207469746c650000001c00600001005000f30300000000000000000000
4-0000000000000000000000000108610003000000ef0300000000000009000000
5-0000000000000000000000006e6574207469746c650000001c00610001005000
6-f303000000000000000000000000000000000000000000000108620003000000
7-ef03000000000000090000000000000000000000000000006e6574207469746c
8-650000001c00620001005000f303000000000000000000000000000000000000
9-000000000108630003000000ef03000000000000090000000000000000000000
10-000000006e6574207469746c650000001c00630001005000f303000000000000
11-000000000000000000000000000000000108640003000000ef03000000000000
12-090000000000000000000000000000006e6574207469746c650000001c006400
13-01005000f3030000000000000000000000000000000000000000000001086500
14-03000000ef03000000000000090000000000000000000000000000006e657420
15-7469746c650000001c00650001005000f3030000000000000000000000000000
16-00000000000000000108660003000000ef030000000000000900000000000000
17-00000000000000006e6574207469746c650000001c00660001005000f3030000
18-00000000000000000000000000000000000000000108670003000000ef030000
19-00000000090000000000000000000000000000006e6574207469746c65000000
20 1c00670001005000f303000000000000000000000000000000000000000000001 1c00670001005000f30300000000000000000000000000000000000000000000
21 0108680003000000ef03000000000000090000000000000000000000000000002 0108680003000000ef0300000000000009000000000000000000000000000000
22 6e6574207469746c650000001c00680001005000f303000000000000000000003 6e6574207469746c650000001c00680001005000f30300000000000000000000
@@ -948,3 +929,22 @@ ef03000000000000090000000000000000000000000000006e6574207469746c
948 00000000000000006e6574207469746c650000001c00ee0101005000f3030000929 00000000000000006e6574207469746c650000001c00ee0101005000f3030000
949 00000000000000000000000000000000000000000108ef0103000000ef030000930 00000000000000000000000000000000000000000108ef0103000000ef030000
950 00000000090000000000000000000000000000006e6574207469746c65000000931 00000000090000000000000000000000000000006e6574207469746c65000000
932+1c00ef0101005000f30300000000000000000000000000000000000000000000
933+0108f00103000000ef0300000000000009000000000000000000000000000000
934+6e6574207469746c650000001c00f00101005000f30300000000000000000000
935+0000000000000000000000000108f10103000000ef0300000000000009000000
936+0000000000000000000000006e6574207469746c650000001c00f10101005000
937+f303000000000000000000000000000000000000000000000108f20103000000
938+ef03000000000000090000000000000000000000000000006e6574207469746c
939+650000001c00f20101005000f303000000000000000000000000000000000000
940+000000000108f30103000000ef03000000000000090000000000000000000000
941+000000006e6574207469746c650000001c00f30101005000f303000000000000
942+000000000000000000000000000000000108f40103000000ef03000000000000
943+090000000000000000000000000000006e6574207469746c650000001c00f401
944+01005000f303000000000000000000000000000000000000000000000108f501
945+03000000ef03000000000000090000000000000000000000000000006e657420
946+7469746c650000001c00f50101005000f3030000000000000000000000000000
947+00000000000000000108f60103000000ef030000000000000900000000000000
948+00000000000000006e6574207469746c650000001c00f60101005000f3030000
949+00000000000000000000000000000000000000000108f70103000000ef030000
950+00000000090000000000000000000000000000006e6574207469746c65000000
M internal/adapters/wayland/xwm/testdata/streams/steady.hex +43 -43
@@ -1,46 +1,3 @@
1-1c005f0001005000f30300000000000000000000000000000000000000000000
2-0108600003000000ef0300000000000009000000000000000000000000000000
3-6e6574207469746c650000001600600001005000010050000000000000000000
4-0a0014002c01c800000000000900600001005000000000000000000000000000
5-0000000000000000000000001300600000010000010050000000000000000000
6-0000000000000000000000001c00600001005000f30300000000000000000000
7-0000000000000000000000000108610003000000ef0300000000000009000000
8-0000000000000000000000006e6574207469746c650000001600610001005000
9-0100500000000000000000000a0014002c01c800000000000900610001005000
10-0000000000000000000000000000000000000000000000001300610000010000
11-0100500000000000000000000000000000000000000000001c00610001005000
12-f303000000000000000000000000000000000000000000000108620003000000
13-ef03000000000000090000000000000000000000000000006e6574207469746c
14-6500000016006200010050000100500000000000000000000a0014002c01c800
15-0000000009006200010050000000000000000000000000000000000000000000
16-0000000013006200000100000100500000000000000000000000000000000000
17-000000001c00620001005000f303000000000000000000000000000000000000
18-000000000108630003000000ef03000000000000090000000000000000000000
19-000000006e6574207469746c6500000016006300010050000100500000000000
20-000000000a0014002c01c8000000000009006300010050000000000000000000
21-0000000000000000000000000000000013006300000100000100500000000000
22-000000000000000000000000000000001c00630001005000f303000000000000
23-000000000000000000000000000000000108640003000000ef03000000000000
24-090000000000000000000000000000006e6574207469746c6500000016006400
25-010050000100500000000000000000000a0014002c01c8000000000009006400
26-0100500000000000000000000000000000000000000000000000000013006400
27-000100000100500000000000000000000000000000000000000000001c006400
28-01005000f3030000000000000000000000000000000000000000000001086500
29-03000000ef03000000000000090000000000000000000000000000006e657420
30-7469746c6500000016006500010050000100500000000000000000000a001400
31-2c01c80000000000090065000100500000000000000000000000000000000000
32-0000000000000000130065000001000001005000000000000000000000000000
33-00000000000000001c00650001005000f3030000000000000000000000000000
34-00000000000000000108660003000000ef030000000000000900000000000000
35-00000000000000006e6574207469746c65000000160066000100500001005000
36-00000000000000000a0014002c01c80000000000090066000100500000000000
37-0000000000000000000000000000000000000000130066000001000001005000
38-00000000000000000000000000000000000000001c00660001005000f3030000
39-00000000000000000000000000000000000000000108670003000000ef030000
40-00000000090000000000000000000000000000006e6574207469746c65000000
41-16006700010050000100500000000000000000000a0014002c01c80000000000
42-0900670001005000000000000000000000000000000000000000000000000000
43-1300670000010000010050000000000000000000000000000000000000000000
441 1c00670001005000f30300000000000000000000000000000000000000000000
452 0108680003000000ef0300000000000009000000000000000000000000000000
463 6e6574207469746c650000001600680001005000010050000000000000000000
@@ -2148,3 +2105,46 @@ ef03000000000000090000000000000000000000000000006e6574207469746c
21482105 1600ef01010050000100500000000000000000000a0014002c01c80000000000
21492106 0900ef0101005000000000000000000000000000000000000000000000000000
21502107 1300ef0100010000010050000000000000000000000000000000000000000000
2108+1c00ef0101005000f30300000000000000000000000000000000000000000000
2109+0108f00103000000ef0300000000000009000000000000000000000000000000
2110+6e6574207469746c650000001600f00101005000010050000000000000000000
2111+0a0014002c01c800000000000900f00101005000000000000000000000000000
2112+0000000000000000000000001300f00100010000010050000000000000000000
2113+0000000000000000000000001c00f00101005000f30300000000000000000000
2114+0000000000000000000000000108f10103000000ef0300000000000009000000
2115+0000000000000000000000006e6574207469746c650000001600f10101005000
2116+0100500000000000000000000a0014002c01c800000000000900f10101005000
2117+0000000000000000000000000000000000000000000000001300f10100010000
2118+0100500000000000000000000000000000000000000000001c00f10101005000
2119+f303000000000000000000000000000000000000000000000108f20103000000
2120+ef03000000000000090000000000000000000000000000006e6574207469746c
2121+650000001600f201010050000100500000000000000000000a0014002c01c800
2122+000000000900f201010050000000000000000000000000000000000000000000
2123+000000001300f201000100000100500000000000000000000000000000000000
2124+000000001c00f20101005000f303000000000000000000000000000000000000
2125+000000000108f30103000000ef03000000000000090000000000000000000000
2126+000000006e6574207469746c650000001600f301010050000100500000000000
2127+000000000a0014002c01c800000000000900f301010050000000000000000000
2128+000000000000000000000000000000001300f301000100000100500000000000
2129+000000000000000000000000000000001c00f30101005000f303000000000000
2130+000000000000000000000000000000000108f40103000000ef03000000000000
2131+090000000000000000000000000000006e6574207469746c650000001600f401
2132+010050000100500000000000000000000a0014002c01c800000000000900f401
2133+010050000000000000000000000000000000000000000000000000001300f401
2134+000100000100500000000000000000000000000000000000000000001c00f401
2135+01005000f303000000000000000000000000000000000000000000000108f501
2136+03000000ef03000000000000090000000000000000000000000000006e657420
2137+7469746c650000001600f501010050000100500000000000000000000a001400
2138+2c01c800000000000900f5010100500000000000000000000000000000000000
2139+00000000000000001300f5010001000001005000000000000000000000000000
2140+00000000000000001c00f50101005000f3030000000000000000000000000000
2141+00000000000000000108f60103000000ef030000000000000900000000000000
2142+00000000000000006e6574207469746c650000001600f6010100500001005000
2143+00000000000000000a0014002c01c800000000000900f6010100500000000000
2144+00000000000000000000000000000000000000001300f6010001000001005000
2145+00000000000000000000000000000000000000001c00f60101005000f3030000
2146+00000000000000000000000000000000000000000108f70103000000ef030000
2147+00000000090000000000000000000000000000006e6574207469746c65000000
2148+1600f701010050000100500000000000000000000a0014002c01c80000000000
2149+0900f70101005000000000000000000000000000000000000000000000000000
2150+1300f70100010000010050000000000000000000000000000000000000000000
@@ -1,46 +1,3 @@
1-1c005f0001005000f30300000000000000000000000000000000000000000000
2-0108600003000000ef0300000000000009000000000000000000000000000000
3-6e6574207469746c650000001600600001005000010050000000000000000000
4-0a0014002c01c800000000000900600001005000000000000000000000000000
5-0000000000000000000000001300600000010000010050000000000000000000
6-0000000000000000000000001c00600001005000f30300000000000000000000
7-0000000000000000000000000108610003000000ef0300000000000009000000
8-0000000000000000000000006e6574207469746c650000001600610001005000
9-0100500000000000000000000a0014002c01c800000000000900610001005000
10-0000000000000000000000000000000000000000000000001300610000010000
11-0100500000000000000000000000000000000000000000001c00610001005000
12-f303000000000000000000000000000000000000000000000108620003000000
13-ef03000000000000090000000000000000000000000000006e6574207469746c
14-6500000016006200010050000100500000000000000000000a0014002c01c800
15-0000000009006200010050000000000000000000000000000000000000000000
16-0000000013006200000100000100500000000000000000000000000000000000
17-000000001c00620001005000f303000000000000000000000000000000000000
18-000000000108630003000000ef03000000000000090000000000000000000000
19-000000006e6574207469746c6500000016006300010050000100500000000000
20-000000000a0014002c01c8000000000009006300010050000000000000000000
21-0000000000000000000000000000000013006300000100000100500000000000
22-000000000000000000000000000000001c00630001005000f303000000000000
23-000000000000000000000000000000000108640003000000ef03000000000000
24-090000000000000000000000000000006e6574207469746c6500000016006400
25-010050000100500000000000000000000a0014002c01c8000000000009006400
26-0100500000000000000000000000000000000000000000000000000013006400
27-000100000100500000000000000000000000000000000000000000001c006400
28-01005000f3030000000000000000000000000000000000000000000001086500
29-03000000ef03000000000000090000000000000000000000000000006e657420
30-7469746c6500000016006500010050000100500000000000000000000a001400
31-2c01c80000000000090065000100500000000000000000000000000000000000
32-0000000000000000130065000001000001005000000000000000000000000000
33-00000000000000001c00650001005000f3030000000000000000000000000000
34-00000000000000000108660003000000ef030000000000000900000000000000
35-00000000000000006e6574207469746c65000000160066000100500001005000
36-00000000000000000a0014002c01c80000000000090066000100500000000000
37-0000000000000000000000000000000000000000130066000001000001005000
38-00000000000000000000000000000000000000001c00660001005000f3030000
39-00000000000000000000000000000000000000000108670003000000ef030000
40-00000000090000000000000000000000000000006e6574207469746c65000000
41-16006700010050000100500000000000000000000a0014002c01c80000000000
42-0900670001005000000000000000000000000000000000000000000000000000
43-1300670000010000010050000000000000000000000000000000000000000000
44 1c00670001005000f303000000000000000000000000000000000000000000001 1c00670001005000f30300000000000000000000000000000000000000000000
45 0108680003000000ef03000000000000090000000000000000000000000000002 0108680003000000ef0300000000000009000000000000000000000000000000
46 6e6574207469746c6500000016006800010050000100500000000000000000003 6e6574207469746c650000001600680001005000010050000000000000000000
@@ -2148,3 +2105,46 @@ ef03000000000000090000000000000000000000000000006e6574207469746c
2148 1600ef01010050000100500000000000000000000a0014002c01c800000000002105 1600ef01010050000100500000000000000000000a0014002c01c80000000000
2149 0900ef01010050000000000000000000000000000000000000000000000000002106 0900ef0101005000000000000000000000000000000000000000000000000000
2150 1300ef01000100000100500000000000000000000000000000000000000000002107 1300ef0100010000010050000000000000000000000000000000000000000000
2108+1c00ef0101005000f30300000000000000000000000000000000000000000000
2109+0108f00103000000ef0300000000000009000000000000000000000000000000
2110+6e6574207469746c650000001600f00101005000010050000000000000000000
2111+0a0014002c01c800000000000900f00101005000000000000000000000000000
2112+0000000000000000000000001300f00100010000010050000000000000000000
2113+0000000000000000000000001c00f00101005000f30300000000000000000000
2114+0000000000000000000000000108f10103000000ef0300000000000009000000
2115+0000000000000000000000006e6574207469746c650000001600f10101005000
2116+0100500000000000000000000a0014002c01c800000000000900f10101005000
2117+0000000000000000000000000000000000000000000000001300f10100010000
2118+0100500000000000000000000000000000000000000000001c00f10101005000
2119+f303000000000000000000000000000000000000000000000108f20103000000
2120+ef03000000000000090000000000000000000000000000006e6574207469746c
2121+650000001600f201010050000100500000000000000000000a0014002c01c800
2122+000000000900f201010050000000000000000000000000000000000000000000
2123+000000001300f201000100000100500000000000000000000000000000000000
2124+000000001c00f20101005000f303000000000000000000000000000000000000
2125+000000000108f30103000000ef03000000000000090000000000000000000000
2126+000000006e6574207469746c650000001600f301010050000100500000000000
2127+000000000a0014002c01c800000000000900f301010050000000000000000000
2128+000000000000000000000000000000001300f301000100000100500000000000
2129+000000000000000000000000000000001c00f30101005000f303000000000000
2130+000000000000000000000000000000000108f40103000000ef03000000000000
2131+090000000000000000000000000000006e6574207469746c650000001600f401
2132+010050000100500000000000000000000a0014002c01c800000000000900f401
2133+010050000000000000000000000000000000000000000000000000001300f401
2134+000100000100500000000000000000000000000000000000000000001c00f401
2135+01005000f303000000000000000000000000000000000000000000000108f501
2136+03000000ef03000000000000090000000000000000000000000000006e657420
2137+7469746c650000001600f501010050000100500000000000000000000a001400
2138+2c01c800000000000900f5010100500000000000000000000000000000000000
2139+00000000000000001300f5010001000001005000000000000000000000000000
2140+00000000000000001c00f50101005000f3030000000000000000000000000000
2141+00000000000000000108f60103000000ef030000000000000900000000000000
2142+00000000000000006e6574207469746c650000001600f6010100500001005000
2143+00000000000000000a0014002c01c800000000000900f6010100500000000000
2144+00000000000000000000000000000000000000001300f6010001000001005000
2145+00000000000000000000000000000000000000001c00f60101005000f3030000
2146+00000000000000000000000000000000000000000108f70103000000ef030000
2147+00000000090000000000000000000000000000006e6574207469746c65000000
2148+1600f701010050000100500000000000000000000a0014002c01c80000000000
2149+0900f70101005000000000000000000000000000000000000000000000000000
2150+1300f70100010000010050000000000000000000000000000000000000000000
M internal/adapters/wayland/xwm/xpeer_test.go +32 -0
@@ -90,6 +90,8 @@ type xpeer struct {
9090 record bool
9191
9292 gate atomic.Pointer[chan struct{}] // closed = reading allowed
93+ sbuf [4096]byte
94+ sink atomic.Bool // discard requests without recording or allocating
9395 done chan struct{}
9496 }
9597
@@ -159,6 +161,12 @@ func (p *xpeer) serve() {
159161 }
160162 for {
161163 <-*p.gate.Load()
164+ if p.sink.Load() {
165+ if !p.discard() {
166+ return
167+ }
168+ continue
169+ }
162170 var h [4]byte
163171 if _, err := io.ReadFull(p.f, h[:]); err != nil {
164172 return
@@ -187,6 +195,30 @@ func (p *xpeer) serve() {
187195 }
188196 }
189197
198+// discard reads and drops one request without allocating. It reports false
199+// when the connection ended.
200+func (p *xpeer) discard() bool {
201+ if _, err := io.ReadFull(p.f, p.sbuf[:4]); err != nil {
202+ return false
203+ }
204+ n := int(binary.LittleEndian.Uint16(p.sbuf[2:])) * 4
205+ left := n - 4
206+ if n == 0 {
207+ if _, err := io.ReadFull(p.f, p.sbuf[:4]); err != nil {
208+ return false
209+ }
210+ left = int(binary.LittleEndian.Uint32(p.sbuf[:4]))*4 - 8
211+ }
212+ for left > 0 {
213+ m, err := p.f.Read(p.sbuf[:min(left, len(p.sbuf))])
214+ if err != nil {
215+ return false
216+ }
217+ left -= m
218+ }
219+ return true
220+}
221+
190222 // write sends bytes to the client. The caller holds p.mu.
191223 func (p *xpeer) write(b []byte) {
192224 if p.record {
@@ -90,6 +90,8 @@ type xpeer struct {
90 record bool90 record bool
91 91
92 gate atomic.Pointer[chan struct{}] // closed = reading allowed92 gate atomic.Pointer[chan struct{}] // closed = reading allowed
93+ sbuf [4096]byte
94+ sink atomic.Bool // discard requests without recording or allocating
93 done chan struct{}95 done chan struct{}
94 }96 }
95 97
@@ -159,6 +161,12 @@ func (p *xpeer) serve() {
159 }161 }
160 for {162 for {
161 <-*p.gate.Load()163 <-*p.gate.Load()
164+ if p.sink.Load() {
165+ if !p.discard() {
166+ return
167+ }
168+ continue
169+ }
162 var h [4]byte170 var h [4]byte
163 if _, err := io.ReadFull(p.f, h[:]); err != nil {171 if _, err := io.ReadFull(p.f, h[:]); err != nil {
164 return172 return
@@ -187,6 +195,30 @@ func (p *xpeer) serve() {
187 }195 }
188 }196 }
189 197
198+// discard reads and drops one request without allocating. It reports false
199+// when the connection ended.
200+func (p *xpeer) discard() bool {
201+ if _, err := io.ReadFull(p.f, p.sbuf[:4]); err != nil {
202+ return false
203+ }
204+ n := int(binary.LittleEndian.Uint16(p.sbuf[2:])) * 4
205+ left := n - 4
206+ if n == 0 {
207+ if _, err := io.ReadFull(p.f, p.sbuf[:4]); err != nil {
208+ return false
209+ }
210+ left = int(binary.LittleEndian.Uint32(p.sbuf[:4]))*4 - 8
211+ }
212+ for left > 0 {
213+ m, err := p.f.Read(p.sbuf[:min(left, len(p.sbuf))])
214+ if err != nil {
215+ return false
216+ }
217+ left -= m
218+ }
219+ return true
220+}
221+
190 // write sends bytes to the client. The caller holds p.mu.222 // write sends bytes to the client. The caller holds p.mu.
191 func (p *xpeer) write(b []byte) {223 func (p *xpeer) write(b []byte) {
192 if p.record {224 if p.record {
M internal/adapters/wayland/xwm/xwm.go +0 -2
@@ -1,5 +1,3 @@
1-//lint:file-ignore U1000 refocus and listsDirty are used by the focus and configure flows
2-
31 // Package xwm is the built-in X11 window manager for Xwayland: it speaks the
42 // X protocol over the -wm socket with xturbo and reports managed windows to
53 // the Wayland server through the unexported host interface.
@@ -1,5 +1,3 @@
1-//lint:file-ignore U1000 refocus and listsDirty are used by the focus and configure flows
2-
3 // Package xwm is the built-in X11 window manager for Xwayland: it speaks the1 // Package xwm is the built-in X11 window manager for Xwayland: it speaks the
4 // X protocol over the -wm socket with xturbo and reports managed windows to2 // X protocol over the -wm socket with xturbo and reports managed windows to
5 // the Wayland server through the unexported host interface.3 // the Wayland server through the unexported host interface.

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 23

Tags 10

↑↓ moveenter openesc close