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