#21 WIP: feat(xwayland): add built-in Xwayland launcher

state
draft
branch
feat/xwayland-builtin → main
opened
bnema ·
updated
CommitSubjectAuthorDateSignatureStatus
8545a93Merge branch 'fix/hdr-sdr-plane' into feat/xwayland-builtinbriceGPG Verified
8342bf4Merge branch 'fix/x11-fullscreen-restore' into feat/xwayland-builtinbriceGPG Verified
7f7b429fix(drm): compose SDR buffers on HDR outputs until the plane pipeline is verifiedbriceGPG Verified
61f9ab3fix(core): restore fullscreen when focus returns to a window that left itbriceGPG Verified
41da69cfix(core): honour the fullscreen state a window maps withbriceGPG Verified
5eb9885test(xwm): make sinkPeer deterministicbriceGPG Verified
13ff6acfix(xwm): announce scale 1 to X11 clients while Xwayland runs at logical sizebriceGPG Verified
f2f4074test(xwm): measure the Wayland-to-X alloc path through onSelectionRequestbriceGPG Verified
04b38e5feat(xwm): answer TIMESTAMP and list it in TARGETSbriceGPG Verified
6168668fix(xwm): harden selection transfers on close, late replies and theme truncationbriceGPG Verified
8fb84bfrefactor(xwm): replace addFD/after function seams with a mockery-generated loop interfacebriceGPG Verified
07dab5dfix(xwm): never adopt the XSETTINGS window as a client windowbriceGPG Verified
a50e31efix(xwm): drop stale TARGETS replies after Wayland takes ownershipbriceGPG Verified
4afca7adocs(xwayland): clipboard bridge, XSETTINGS and root cursorbriceGPG Verified
def433btest(app): e2e clipboard bridge, XSETTINGS and root cursor on the built-in XWMbriceGPG Verified
3e19081Merge branch 'feat/x10' into feat/xwayland-builtinbriceGPG Verified
d7ef3dbMerge branch 'feat/x9' into feat/xwayland-builtinbriceGPG Verified
9bbf554feat(xwm): XSETTINGS manager, xwayland.xsettings key and root cursorbriceGPG Verified
46fd2a1Merge branch 'feat/x8' into feat/xwayland-builtinbriceGPG Verified
1f67695feat(wayland): Wayland to X text selection with INCR and X-owned clipboard sourcesbriceGPG Verified
3df83c3docs(xwayland): describe fullscreen, keyboard grab, output hint and steam_app rulesbriceGPG Verified
8c66911feat(xwm): X to Wayland text selection transfers with INCR, buffer pool and timeoutsbriceGPG Verified
ad55fe8feat(xwm): host timer seam After for selection timeoutsbriceGPG Verified
41edcc9feat(xwm): selection window, XFixes ownership events and TARGETS probebriceGPG Verified
d71e4a4test(app): cover X11 fullscreen, keyboard grab, output hint and class rules end to endbriceGPG Verified
43a1ce7test(wayland): wait for the output change before counting surface entersbriceGPG Verified
1b0bfc7fix(core): keep rule workspaces ahead of the X11 output hint and read the keyboard grab once per syncbriceGPG Verified
56a0abdMerge branch 'feat/x8-pointer' into feat/x8briceGPG Verified
b77ab13Merge branch 'feat/x8-grab' into feat/x8briceGPG Verified
224cf30feat(xwm): place output-sized X11 windows on their output, read bypass hint, steam_app rulesbriceGPG Verified
c5b7312docs(xwayland): state that builtin shows menus, popups and tooltipsbriceGPG Verified
864c005test(wayland): cover X11 pointer lock, confine, relative motion and single-output enterbriceGPG Verified
65eceabfeat(wayland): serve zwp_xwayland_keyboard_grab as a fullscreen shortcuts inhibitorbriceGPG Verified
47739datest(app): cover override-redirect X11 windows end to endbriceGPG Verified
dc722bbrefactor(xwm): share one Space with the wayland server and drop dead codebriceGPG Verified
8ab1d07fix(core): clear pointer and grab when an unmanaged window unmapsbriceGPG Verified
b3f6b3afeat(wayland): host override-redirect X11 windows as unmanaged windowsbriceGPG Verified
27b8b9eMerge branch 'feat/x7-xdgoutput' into feat/x7briceGPG Verified
83719a6Merge branch 'feat/x7-core' into feat/x7briceGPG Verified
7ef926ffeat(xwm): convert Place through Space and track override-redirect windowsbriceGPG Verified
5a832c8feat(xwm): add Space mapping layout coordinates to X coordinatesbriceGPG Verified
305c2c8feat(wayland): private X-space xdg-output manager for XwaylandbriceGPG Verified
bf24250feat(core): place, draw and hit-test unmanaged X11 windowsbriceGPG Verified
58a7346refactor(xwm): log counters on close, drop the test-only Slot lookup and state atoms in present tensebriceGPG Verified
80258b6docs(xwayland): describe the built-in window manager and the xwayland e2e checkbriceGPG Verified
b64a207test(app): add headless Xwayland end-to-end test with an x11probe clientbriceGPG Verified
ace7dd1fix(wayland): forward X11 pointer constraints and modal changes, remap on surface replacement, clear dangling parentsbriceGPG Verified
24f47ebfeat(wayland): integrate the built-in XWM with the serverbriceGPG Verified
e1de5a5test(xwm): replace the sinkPeer sleep with a peer lock barrierbriceGPG Verified
9439437fix(xwm): stop _NET_WM_STATE feedback, honour pre-ready geometry, drop stale map repliesbriceGPG Verified
3ccf649Merge branch 'feat/x6-configure' into feat/x6-xwmbriceGPG Verified
ce78abcfeat(xwm): add map flow, property decoding, serial pairing and reparent handlingbriceGPG Verified
5021c6ctest(xwm): fix staticcheck findings in configure testsbriceGPG Verified
50a2e95docs(xwm): state that Boot takes a component-scoped loggerbriceGPG Verified
f1a2966feat(xwm): add configure, focus, close and stacking lists for X11 windowsbriceGPG Verified
01f9a3dfix(xwm): check root ownership before claiming WM state and never adopt the check windowbriceGPG Verified
69b4382feat(xwm): add window table, boot and fd flush skeleton for the built-in X11 window managerbriceGPG Verified
3ed03aabuild(deps): add bnema.dev/xturbo v0.1.0briceGPG Verified
7646086Merge branch 'feat/x6-place' into feat/xwayland-builtinbriceGPG Verified
aa24d35Merge branch 'feat/x6-window-role' into feat/xwayland-builtinbriceGPG Verified
4eada3btest(xwayland): assert AttachWM is skipped when Xwayland exits before readinessbriceGPG Verified
04fea8dtest(vulkan): skip purego allocation budgets under the race runtimebriceGPG Verified
cac0ab6feat(core): send PlaceWindow for positioned windows on position changebriceGPG Verified
957ec9crefactor(wayland): route window access through role-neutral accessorsbriceGPG Verified
57e2ff1refactor(xwayland): split attachment into Wayland fd at start and WM fd after readinessbriceGPG Verified
db0a693docs(xwayland): state that the built-in backend does not manage X11 windowsbriceGPG Verified
e519f1fbuild(deps): bump purego-libwayland to v0.12.0 and purego to v0.13.0-bnema.2briceGPG Verified
a97cce0docs(xwayland): describe the built-in backend and drop the launcher handoff notebriceGPG Verified
e82a03efeat(xwayland): add experimental built-in launcher and client attachmentbriceGPG Verified

Checks 1

note: a failing or unfinished check blocks the merge.

107 files · +18450 -160
1/1
M .mockery.yml +30 -0
@@ -87,6 +87,11 @@ packages:
8787 dir: internal/adapters/wayland
8888 pkgname: wayland
8989 filename: acquire_poller_mock_test.go
90+ xwmSelection:
91+ config:
92+ dir: internal/adapters/wayland
93+ pkgname: wayland
94+ filename: xwm_selection_mock_test.go
9095 syncobjDevice:
9196 config:
9297 dir: internal/adapters/wayland
@@ -187,3 +192,28 @@ packages:
187192 dir: internal/adapters/screencast
188193 pkgname: screencast
189194 filename: pipewire_opener_mock_test.go
195+
196+ github.com/bnema/neferwl/internal/adapters/xwayland:
197+ interfaces:
198+ starter:
199+ config:
200+ dir: internal/adapters/xwayland
201+ pkgname: xwayland
202+ filename: starter_mock_test.go
203+ github.com/bnema/neferwl/internal/adapters/wayland/xwm:
204+ interfaces:
205+ host:
206+ config:
207+ dir: internal/adapters/wayland/xwm
208+ pkgname: xwm
209+ filename: host_mock_test.go
210+ fdSource:
211+ config:
212+ dir: internal/adapters/wayland/xwm
213+ pkgname: xwm
214+ filename: fdsource_mock_test.go
215+ loop:
216+ config:
217+ dir: internal/adapters/wayland/xwm
218+ pkgname: xwm
219+ filename: loop_mock_test.go
@@ -87,6 +87,11 @@ packages:
87 dir: internal/adapters/wayland87 dir: internal/adapters/wayland
88 pkgname: wayland88 pkgname: wayland
89 filename: acquire_poller_mock_test.go89 filename: acquire_poller_mock_test.go
90+ xwmSelection:
91+ config:
92+ dir: internal/adapters/wayland
93+ pkgname: wayland
94+ filename: xwm_selection_mock_test.go
90 syncobjDevice:95 syncobjDevice:
91 config:96 config:
92 dir: internal/adapters/wayland97 dir: internal/adapters/wayland
@@ -187,3 +192,28 @@ packages:
187 dir: internal/adapters/screencast192 dir: internal/adapters/screencast
188 pkgname: screencast193 pkgname: screencast
189 filename: pipewire_opener_mock_test.go194 filename: pipewire_opener_mock_test.go
195+
196+ github.com/bnema/neferwl/internal/adapters/xwayland:
197+ interfaces:
198+ starter:
199+ config:
200+ dir: internal/adapters/xwayland
201+ pkgname: xwayland
202+ filename: starter_mock_test.go
203+ github.com/bnema/neferwl/internal/adapters/wayland/xwm:
204+ interfaces:
205+ host:
206+ config:
207+ dir: internal/adapters/wayland/xwm
208+ pkgname: xwm
209+ filename: host_mock_test.go
210+ fdSource:
211+ config:
212+ dir: internal/adapters/wayland/xwm
213+ pkgname: xwm
214+ filename: fdsource_mock_test.go
215+ loop:
216+ config:
217+ dir: internal/adapters/wayland/xwm
218+ pkgname: xwm
219+ filename: loop_mock_test.go
M Makefile +9 -2
@@ -1,4 +1,4 @@
1-.PHONY: build test vet race mocks mocks-check spv-check fakes-check log-check arch adapter-imports check perf-check color-check bin tty logs pkg install
1+.PHONY: build test vet race e2e-xwayland mocks mocks-check spv-check fakes-check log-check arch adapter-imports check perf-check color-check bin tty logs pkg install
22
33 # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net.
44 TTY_TIMEOUT ?= 0
@@ -26,6 +26,7 @@ test:
2626 CGO_ENABLED=0 go test ./...
2727 vet:
2828 CGO_ENABLED=0 go vet ./...
29+ CGO_ENABLED=0 go vet -tags xwayland ./internal/app
2930 race:
3031 # cgo is enabled only for the race test binary.
3132 CGO_ENABLED=1 go test -race ./...
@@ -69,7 +70,8 @@ adapter-imports:
6970 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
7071 # Fast, mandatory guards for tiled content publications and SHM copies.
7172 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 -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)$$' -count=1
73+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast ./internal/adapters/wayland/xwm -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCommandForwardAllocations|TestOutboxQueuedDeliveryAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing|TestTableLookupAllocations|TestTableInsertRemoveAllocations|TestPropertyUnchangedAllocations|TestDispatchAllocations|TestConfigureFlushAllocations|TestFocusAllocations|TestSpaceAllocations|TestUnmanagedGeometryAllocations|TestFocusInUnmanagedAllocations|TestUnmanagedPublishAllocations|TestUnmanagedHitAllocations|TestOverrideMovedUnchangedAllocations|TestFullscreenConfigureAllocations|TestKeyboardGrabSyncAllocations|TestSelectionCopyAllocations|TestSelectionCopyAllocationsWaylandToX|TestSelectionINCRChunkAllocations|TestXSettingsNoChangeAllocations|TestSettleLeftAllocs)$$' -count=1
74+ CGO_ENABLED=0 go test -tags xturbo_poison ./internal/adapters/wayland/xwm -count=1
7375 check: vet test arch fakes-check log-check perf-check
7476
7577 # On-demand SDR/HDR colour check with examples/testpattern on a headless
@@ -77,6 +79,11 @@ check: vet test arch fakes-check log-check perf-check
7779 color-check:
7880 CGO_ENABLED=0 go test -tags colorcheck ./internal/app -run '^TestColorCheck' -count=1 -v
7981
82+# End-to-end test of the built-in XWM: a real Xwayland and an X11 probe on a
83+# headless NeferWL. Needs Xwayland and a GPU; not part of check.
84+e2e-xwayland:
85+ CGO_ENABLED=1 go test -tags xwayland -race ./internal/app -run TestXwayland -count=1 -v
86+
8087 # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules
8188 # come from the module proxy in prepare(); the build itself runs offline.
8289 # pacman-ordered version: 0.0.0.r<commits>.g<hash>; a tag replaces 0.0.0.
@@ -1,4 +1,4 @@
1-.PHONY: build test vet race mocks mocks-check spv-check fakes-check log-check arch adapter-imports check perf-check color-check bin tty logs pkg install1+.PHONY: build test vet race e2e-xwayland mocks mocks-check spv-check fakes-check log-check arch adapter-imports check perf-check color-check bin tty logs pkg install
2 2
3 # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net.3 # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net.
4 TTY_TIMEOUT ?= 04 TTY_TIMEOUT ?= 0
@@ -26,6 +26,7 @@ test:
26 CGO_ENABLED=0 go test ./...26 CGO_ENABLED=0 go test ./...
27 vet:27 vet:
28 CGO_ENABLED=0 go vet ./...28 CGO_ENABLED=0 go vet ./...
29+ CGO_ENABLED=0 go vet -tags xwayland ./internal/app
29 race:30 race:
30 # cgo is enabled only for the race test binary.31 # cgo is enabled only for the race test binary.
31 CGO_ENABLED=1 go test -race ./...32 CGO_ENABLED=1 go test -race ./...
@@ -69,7 +70,8 @@ adapter-imports:
69 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }70 [ -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.71 # Fast, mandatory guards for tiled content publications and SHM copies.
71 perf-check:72 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 -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)$$' -count=173+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast ./internal/adapters/wayland/xwm -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCommandForwardAllocations|TestOutboxQueuedDeliveryAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing|TestTableLookupAllocations|TestTableInsertRemoveAllocations|TestPropertyUnchangedAllocations|TestDispatchAllocations|TestConfigureFlushAllocations|TestFocusAllocations|TestSpaceAllocations|TestUnmanagedGeometryAllocations|TestFocusInUnmanagedAllocations|TestUnmanagedPublishAllocations|TestUnmanagedHitAllocations|TestOverrideMovedUnchangedAllocations|TestFullscreenConfigureAllocations|TestKeyboardGrabSyncAllocations|TestSelectionCopyAllocations|TestSelectionCopyAllocationsWaylandToX|TestSelectionINCRChunkAllocations|TestXSettingsNoChangeAllocations|TestSettleLeftAllocs)$$' -count=1
74+ CGO_ENABLED=0 go test -tags xturbo_poison ./internal/adapters/wayland/xwm -count=1
73 check: vet test arch fakes-check log-check perf-check75 check: vet test arch fakes-check log-check perf-check
74 76
75 # On-demand SDR/HDR colour check with examples/testpattern on a headless77 # On-demand SDR/HDR colour check with examples/testpattern on a headless
@@ -77,6 +79,11 @@ check: vet test arch fakes-check log-check perf-check
77 color-check:79 color-check:
78 CGO_ENABLED=0 go test -tags colorcheck ./internal/app -run '^TestColorCheck' -count=1 -v80 CGO_ENABLED=0 go test -tags colorcheck ./internal/app -run '^TestColorCheck' -count=1 -v
79 81
82+# End-to-end test of the built-in XWM: a real Xwayland and an X11 probe on a
83+# headless NeferWL. Needs Xwayland and a GPU; not part of check.
84+e2e-xwayland:
85+ CGO_ENABLED=1 go test -tags xwayland -race ./internal/app -run TestXwayland -count=1 -v
86+
80 # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules87 # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules
81 # come from the module proxy in prepare(); the build itself runs offline.88 # come from the module proxy in prepare(); the build itself runs offline.
82 # pacman-ordered version: 0.0.0.r<commits>.g<hash>; a tag replaces 0.0.0.89 # pacman-ordered version: 0.0.0.r<commits>.g<hash>; a tag replaces 0.0.0.
M README.md +1 -1
@@ -149,7 +149,7 @@ See [Configuration](docs/config.md) for all keys, actions, bindings and automati
149149 - Explicit sync carries client fences to the GPU and KMS, enabled by default with no flag. HDR10 on capable displays includes configurable SDR brightness.
150150 - Separate workspaces and fractional scale per output; workspaces move when a monitor is unplugged and return when it reconnects.
151151 - Hidden apps get frame callbacks once per second and the suspended state, whether on another workspace, in a hidden stash, off screen or under fullscreen. Captured windows keep their output's refresh rate.
152-- Steam and other X11 clients run through xwayland-satellite; Wine can run natively on Wayland.
152+- Steam and other X11 clients run through xwayland-satellite, or through the experimental built-in window manager (`xwayland = builtin`; menus, popups and tooltips, fullscreen games with keyboard grab; text clipboard and primary selection in both directions, XSETTINGS and a themed root cursor). Wine can run natively on Wayland.
153153
154154 ### Windows and workspaces
155155
@@ -149,7 +149,7 @@ See [Configuration](docs/config.md) for all keys, actions, bindings and automati
149 - Explicit sync carries client fences to the GPU and KMS, enabled by default with no flag. HDR10 on capable displays includes configurable SDR brightness.149 - Explicit sync carries client fences to the GPU and KMS, enabled by default with no flag. HDR10 on capable displays includes configurable SDR brightness.
150 - Separate workspaces and fractional scale per output; workspaces move when a monitor is unplugged and return when it reconnects.150 - Separate workspaces and fractional scale per output; workspaces move when a monitor is unplugged and return when it reconnects.
151 - Hidden apps get frame callbacks once per second and the suspended state, whether on another workspace, in a hidden stash, off screen or under fullscreen. Captured windows keep their output's refresh rate.151 - Hidden apps get frame callbacks once per second and the suspended state, whether on another workspace, in a hidden stash, off screen or under fullscreen. Captured windows keep their output's refresh rate.
152-- Steam and other X11 clients run through xwayland-satellite; Wine can run natively on Wayland.152+- Steam and other X11 clients run through xwayland-satellite, or through the experimental built-in window manager (`xwayland = builtin`; menus, popups and tooltips, fullscreen games with keyboard grab; text clipboard and primary selection in both directions, XSETTINGS and a themed root cursor). Wine can run natively on Wayland.
153 153
154 ### Windows and workspaces154 ### Windows and workspaces
155 155
M docs/config.md +9 -3
@@ -1,6 +1,6 @@
11 # Configuration
22
3-NeferWL reads `~/.config/neferwl/config` (or `$XDG_CONFIG_HOME/neferwl/config`). Changes apply when you save, except `startup` and `xwayland`, which apply at the next start.
3+NeferWL reads `~/.config/neferwl/config` (or `$XDG_CONFIG_HOME/neferwl/config`). Changes apply when you save, except `startup`, `xwayland` and `xwayland.xsettings`, which apply at the next start.
44
55 ```sh
66 neferwl --config <path> # use another file
@@ -27,7 +27,8 @@ See [examples/config](../examples/config) for every key with its default.
2727 | `terminal` | `$TERMINAL` or `foot` | Terminal command |
2828 | `terminal.auto-open` | `first` | Open a terminal on the first workspace (`first`), on every empty one (`all`), or never (`off`) |
2929 | `startup` | none | Commands run at session start, separated by `;` |
30-| `xwayland` | `xwayland-satellite` | X11 support; `off` disables it |
30+| `xwayland` | `satellite` | `off`, `satellite`, `builtin` or a satellite binary path |
31+| `xwayland.xsettings` | `on` | With `xwayland = builtin`, publish `Gtk/CursorThemeName`, `Gtk/CursorThemeSize`, `Xft/DPI`, `Gdk/WindowScalingFactor` and `Gdk/UnscaledDPI` to X11 apps through XSETTINGS (restart required); the scale is always announced as 1 (96 dpi) because X11 apps see logical sizes and neferwl upscales their windows; `off` leaves them to the apps |
3132 | `background` | `#111111` | Background color |
3233 | `border.radius` | `0` | Corner radius in logical pixels, `0` to `64`; floats always, tiles only with gaps |
3334 | `border.width` | `2` | Border width in pixels; `0` hides borders |
@@ -103,6 +104,8 @@ destination output, even if that output has no windows. Same-screen workspace
103104 switches and mouse focus do not trigger a flash.
104105 It runs when `animations = on`.
105106
107+`xwayland = satellite` uses `xwayland-satellite` from `PATH`; a custom path also selects satellite. `off` disables X11. `builtin` (experimental): neferwl starts `Xwayland` itself and manages X11 windows as its window manager: windows tile, take focus, close with `close-window` (`WM_DELETE_WINDOW`) and show their title and class. Menus, popups and tooltips (override-redirect windows) are drawn above their window at the right place and never appear as windows in `neferwl state`. X11 apps get the cursor theme and size from `XCURSOR_THEME` and `XCURSOR_SIZE`, a scale-1 DPI (96) through XSETTINGS (X11 apps see logical sizes; neferwl upscales their windows) (`xwayland.xsettings = off` turns it off), and the themed arrow on the X background. The clipboard and the primary selection are shared with X11 apps in both directions (text only; nothing crosses while the session is locked or otherwise protected).
108+
106109 ## Automatic terminal
107110
108111 The command comes from `terminal`, then `$TERMINAL`, then `foot`.
@@ -144,7 +147,7 @@ Rules place an app's windows when they first open.
144147 | `rule.<name>.width` | Column width in [cells](#column-widths), 1 to 16 |
145148
146149 ```ini
147-rule.games.app-id = steam_app_.*
150+rule.games.app-id = steam_app_\d+
148151 rule.games.workspace = 3
149152 rule.chat.app-id = discord|org\.telegram\..*
150153 rule.chat.workspace = chat
@@ -155,6 +158,9 @@ rule.calc.floating = on
155158
156159 - Every matching rule applies, in file order; later rules win.
157160 - A rule never moves your focus or view.
161+- Proton games (X11) use `steam_app_<id>` as app ID, taken from `WM_CLASS`.
162+- An X11 window that opens exactly on one output's area opens on that output, unless a rule sets `monitor`.
163+- A rule can float a Proton game: `rule.games.app-id = steam_app_\d+` with `rule.games.floating = on`. Without a rule, X11 games tile until they ask for fullscreen.
158164 - To find an app ID: `neferwl state | jq '.windows[].app_id'`.
159165
160166 ## Output placement
@@ -1,6 +1,6 @@
1 # Configuration1 # Configuration
2 2
3-NeferWL reads `~/.config/neferwl/config` (or `$XDG_CONFIG_HOME/neferwl/config`). Changes apply when you save, except `startup` and `xwayland`, which apply at the next start.3+NeferWL reads `~/.config/neferwl/config` (or `$XDG_CONFIG_HOME/neferwl/config`). Changes apply when you save, except `startup`, `xwayland` and `xwayland.xsettings`, which apply at the next start.
4 4
5 ```sh5 ```sh
6 neferwl --config <path> # use another file6 neferwl --config <path> # use another file
@@ -27,7 +27,8 @@ See [examples/config](../examples/config) for every key with its default.
27 | `terminal` | `$TERMINAL` or `foot` | Terminal command |27 | `terminal` | `$TERMINAL` or `foot` | Terminal command |
28 | `terminal.auto-open` | `first` | Open a terminal on the first workspace (`first`), on every empty one (`all`), or never (`off`) |28 | `terminal.auto-open` | `first` | Open a terminal on the first workspace (`first`), on every empty one (`all`), or never (`off`) |
29 | `startup` | none | Commands run at session start, separated by `;` |29 | `startup` | none | Commands run at session start, separated by `;` |
30-| `xwayland` | `xwayland-satellite` | X11 support; `off` disables it |30+| `xwayland` | `satellite` | `off`, `satellite`, `builtin` or a satellite binary path |
31+| `xwayland.xsettings` | `on` | With `xwayland = builtin`, publish `Gtk/CursorThemeName`, `Gtk/CursorThemeSize`, `Xft/DPI`, `Gdk/WindowScalingFactor` and `Gdk/UnscaledDPI` to X11 apps through XSETTINGS (restart required); the scale is always announced as 1 (96 dpi) because X11 apps see logical sizes and neferwl upscales their windows; `off` leaves them to the apps |
31 | `background` | `#111111` | Background color |32 | `background` | `#111111` | Background color |
32 | `border.radius` | `0` | Corner radius in logical pixels, `0` to `64`; floats always, tiles only with gaps |33 | `border.radius` | `0` | Corner radius in logical pixels, `0` to `64`; floats always, tiles only with gaps |
33 | `border.width` | `2` | Border width in pixels; `0` hides borders |34 | `border.width` | `2` | Border width in pixels; `0` hides borders |
@@ -103,6 +104,8 @@ destination output, even if that output has no windows. Same-screen workspace
103 switches and mouse focus do not trigger a flash.104 switches and mouse focus do not trigger a flash.
104 It runs when `animations = on`.105 It runs when `animations = on`.
105 106
107+`xwayland = satellite` uses `xwayland-satellite` from `PATH`; a custom path also selects satellite. `off` disables X11. `builtin` (experimental): neferwl starts `Xwayland` itself and manages X11 windows as its window manager: windows tile, take focus, close with `close-window` (`WM_DELETE_WINDOW`) and show their title and class. Menus, popups and tooltips (override-redirect windows) are drawn above their window at the right place and never appear as windows in `neferwl state`. X11 apps get the cursor theme and size from `XCURSOR_THEME` and `XCURSOR_SIZE`, a scale-1 DPI (96) through XSETTINGS (X11 apps see logical sizes; neferwl upscales their windows) (`xwayland.xsettings = off` turns it off), and the themed arrow on the X background. The clipboard and the primary selection are shared with X11 apps in both directions (text only; nothing crosses while the session is locked or otherwise protected).
108+
106 ## Automatic terminal109 ## Automatic terminal
107 110
108 The command comes from `terminal`, then `$TERMINAL`, then `foot`.111 The command comes from `terminal`, then `$TERMINAL`, then `foot`.
@@ -144,7 +147,7 @@ Rules place an app's windows when they first open.
144 | `rule.<name>.width` | Column width in [cells](#column-widths), 1 to 16 |147 | `rule.<name>.width` | Column width in [cells](#column-widths), 1 to 16 |
145 148
146 ```ini149 ```ini
147-rule.games.app-id = steam_app_.*150+rule.games.app-id = steam_app_\d+
148 rule.games.workspace = 3151 rule.games.workspace = 3
149 rule.chat.app-id = discord|org\.telegram\..*152 rule.chat.app-id = discord|org\.telegram\..*
150 rule.chat.workspace = chat153 rule.chat.workspace = chat
@@ -155,6 +158,9 @@ rule.calc.floating = on
155 158
156 - Every matching rule applies, in file order; later rules win.159 - Every matching rule applies, in file order; later rules win.
157 - A rule never moves your focus or view.160 - A rule never moves your focus or view.
161+- Proton games (X11) use `steam_app_<id>` as app ID, taken from `WM_CLASS`.
162+- An X11 window that opens exactly on one output's area opens on that output, unless a rule sets `monitor`.
163+- A rule can float a Proton game: `rule.games.app-id = steam_app_\d+` with `rule.games.floating = on`. Without a rule, X11 games tile until they ask for fullscreen.
158 - To find an app ID: `neferwl state | jq '.windows[].app_id'`.164 - To find an app ID: `neferwl state | jq '.windows[].app_id'`.
159 165
160 ## Output placement166 ## Output placement
M docs/desktop.md +12 -1
@@ -52,12 +52,23 @@ Candidate popups are not drawn yet.
5252
5353 ## X11 apps
5454
55-X11 apps such as Steam run through [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite) 0.7+, found in `PATH`. NeferWL starts it when the first X11 app connects; do not start it yourself.
55+By default, X11 apps such as Steam run through [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite) 0.7+, found in `PATH`. NeferWL starts it when the first X11 app connects; do not start it yourself.
5656
5757 ```text
5858 xwayland = off # no X11 support
5959 ```
6060
61+With `xwayland = builtin` (experimental), neferwl starts `Xwayland` itself and manages X11 windows: they tile, take focus and close like Wayland windows. Menus, popups and tooltips of X11 apps are shown above their window and do not take the keyboard focus. X11 apps get the cursor theme (`XCURSOR_THEME`), cursor size (`XCURSOR_SIZE`) and DPI through XSETTINGS, always announced as scale 1 (96 dpi) because X11 apps see logical sizes and neferwl upscales their windows (`Gtk/CursorThemeName`, `Gtk/CursorThemeSize`, `Xft/DPI`, `Gdk/WindowScalingFactor`, `Gdk/UnscaledDPI`; `xwayland.xsettings = off` to disable), and the X background shows the themed arrow. The clipboard and the primary selection work between X11 and Wayland apps in both directions, for text; large copies are transferred in chunks (INCR). While the session is locked, no selection data crosses.
62+
63+Games with `builtin`:
64+
65+- **Fullscreen:** an X11 window can ask for fullscreen (`_NET_WM_STATE_FULLSCREEN`) and leave it again; `toggle-fullscreen` works too. A window that maps with the fullscreen state already set (games do) opens fullscreen in a column of its own, focused, even if its size is fixed. A request made within 1 second *after* the map is ignored, so a launcher that remembers a fullscreen size does not cover the windows it opens next. The app can ask again later.
66+- **Output:** a window that opens exactly at one output's area opens on that output, and its fullscreen fills it.
67+- **Keyboard grab:** an X11 app that grabs the keyboard inhibits the compositor binds only while it is focused and fullscreen; a windowed app cannot lock the binds. There is no escape bind, as with Wayland shortcut inhibitors; leave by unfullscreening from the app or switching away with the mouse.
68+- **Pointer:** mouse-look works through relative pointer and pointer constraints.
69+- **App ID:** Proton windows are named `steam_app_<id>`; match them with a [window rule](config.md#window-rules).
70+- **Bypass hint:** `_NET_WM_BYPASS_COMPOSITOR` is read but changes nothing; direct scanout is chosen from the window state.
71+
6172 ## Session locking
6273
6374 NeferWL supports `ext-session-lock-v1` with an external locker such as swaylock or hyprlock. The locker checks the password; NeferWL hides the desktop and blocks input.
@@ -52,12 +52,23 @@ Candidate popups are not drawn yet.
52 52
53 ## X11 apps53 ## X11 apps
54 54
55-X11 apps such as Steam run through [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite) 0.7+, found in `PATH`. NeferWL starts it when the first X11 app connects; do not start it yourself.55+By default, X11 apps such as Steam run through [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite) 0.7+, found in `PATH`. NeferWL starts it when the first X11 app connects; do not start it yourself.
56 56
57 ```text57 ```text
58 xwayland = off # no X11 support58 xwayland = off # no X11 support
59 ```59 ```
60 60
61+With `xwayland = builtin` (experimental), neferwl starts `Xwayland` itself and manages X11 windows: they tile, take focus and close like Wayland windows. Menus, popups and tooltips of X11 apps are shown above their window and do not take the keyboard focus. X11 apps get the cursor theme (`XCURSOR_THEME`), cursor size (`XCURSOR_SIZE`) and DPI through XSETTINGS, always announced as scale 1 (96 dpi) because X11 apps see logical sizes and neferwl upscales their windows (`Gtk/CursorThemeName`, `Gtk/CursorThemeSize`, `Xft/DPI`, `Gdk/WindowScalingFactor`, `Gdk/UnscaledDPI`; `xwayland.xsettings = off` to disable), and the X background shows the themed arrow. The clipboard and the primary selection work between X11 and Wayland apps in both directions, for text; large copies are transferred in chunks (INCR). While the session is locked, no selection data crosses.
62+
63+Games with `builtin`:
64+
65+- **Fullscreen:** an X11 window can ask for fullscreen (`_NET_WM_STATE_FULLSCREEN`) and leave it again; `toggle-fullscreen` works too. A window that maps with the fullscreen state already set (games do) opens fullscreen in a column of its own, focused, even if its size is fixed. A request made within 1 second *after* the map is ignored, so a launcher that remembers a fullscreen size does not cover the windows it opens next. The app can ask again later.
66+- **Output:** a window that opens exactly at one output's area opens on that output, and its fullscreen fills it.
67+- **Keyboard grab:** an X11 app that grabs the keyboard inhibits the compositor binds only while it is focused and fullscreen; a windowed app cannot lock the binds. There is no escape bind, as with Wayland shortcut inhibitors; leave by unfullscreening from the app or switching away with the mouse.
68+- **Pointer:** mouse-look works through relative pointer and pointer constraints.
69+- **App ID:** Proton windows are named `steam_app_<id>`; match them with a [window rule](config.md#window-rules).
70+- **Bypass hint:** `_NET_WM_BYPASS_COMPOSITOR` is read but changes nothing; direct scanout is chosen from the window state.
71+
61 ## Session locking72 ## Session locking
62 73
63 NeferWL supports `ext-session-lock-v1` with an external locker such as swaylock or hyprlock. The locker checks the password; NeferWL hides the desktop and blocks input.74 NeferWL supports `ext-session-lock-v1` with an external locker such as swaylock or hyprlock. The locker checks the password; NeferWL hides the desktop and blocks input.
M docs/dev/headless.md +25 -0
@@ -62,6 +62,31 @@ WAYLAND_DISPLAY=<name> go run ./examples/testime -preedit nihon -commit 日本
6262
6363 `-count <n>` exits after n commits; `-delay` sets the time between preedit and commit.
6464
65+## X11 check
66+
67+`make e2e-xwayland` runs the end-to-end test of the built-in window manager (`xwayland = builtin`). It is behind the `xwayland` build tag, so `go test ./...` and `make check` only compile it (`go vet -tags xwayland`). It needs `Xwayland` on `PATH` and a GPU, and skips without Xwayland. The clipboard test also needs `wl-copy` and `wl-paste` (wl-clipboard) and skips without them; they run with the headless `WAYLAND_DISPLAY` and need no extra focus step, since the focused probe window is enough for `wl-copy --foreground` to set the selection.
68+
69+The test starts a headless NeferWL with a real `Xwayland` on the first free X display (a live session's `:0` stays untouched) and runs `internal/app/testdata/x11probe`, a small X11 client (`-class`, `-title`, `-size`, `-sleep`, `-at X,Y`) that paints its window green, prints `mapped WxH` and `focus`, and exits 0 on `WM_DELETE_WINDOW`. With `-override` it also maps a magenta override-redirect window (`-override-at`, `-override-size`, `-override-type menu|tooltip`, `-override-for`, `-override-shape`, `-override-dismiss-on-focusout`) and prints `override mapped`, `override unmapped` or `dismissed`. `-fullscreen` (with `-fullscreen-delay`, `-fullscreen-off`) requests `_NET_WM_STATE_FULLSCREEN` by client message and prints `fullscreen WxH` / `fullscreen off WxH` when the window manager publishes the state; `-keyboard-grab` calls `XGrabKeyboard` and prints `grab`. Windowless modes: `-copy TEXT|@file` (with `-copy-sel CLIPBOARD|PRIMARY`, `-copy-for`) owns a selection and serves `TARGETS`, `UTF8_STRING`, `STRING` and `TEXT`, with INCR above 64 KiB, and prints `copy owned`; `-paste CLIPBOARD|PRIMARY` prints `paste "<text>"`, `paste len=N sha256=H` above 4 KiB, or `paste none`; `-xsettings` prints `xsettings serial=N Xft/DPI=… Gdk/WindowScalingFactor=… Gdk/UnscaledDPI=… Gtk/CursorThemeName=… Gtk/CursorThemeSize=…` or `xsettings none`; `-root-cursor` prints `cursor WxH` from XFixes `GetCursorImage`. `-at` also sends a ConfigureRequest with the window rect before the map, like games do. It checks:
70+
71+- the state lists one visible `X11Probe` window with the probe's pid, and the screenshot is mostly green inside its tile;
72+- the probe receives focus;
73+- a second probe tiles beside the first;
74+- `key Super+q` closes the focused probe within 2 seconds and the state drops to one window;
75+- an override-redirect menu is drawn magenta above the green toplevel at its X position, is not listed in the state, leaves focus on the toplevel and disappears from the screenshot once the probe unmaps it;
76+- a menu is dismissed when its owner gets `FocusOut` because another X11 window took the focus;
77+- a menu with an empty input shape is still drawn and still not a window;
78+- a fullscreen request 1.5 s after the map fills the output (all green, no gap) and `Super+Shift+f` leaves it; the client can also leave it itself; a request at the map stays tiled (the 1 s grace);
79+- with the keyboard grab, `Super+q` does not close a fullscreen focused client and works again once it leaves fullscreen; on a windowed client it closes it;
80+- on two outputs, a window created at the second output's origin opens on `HEADLESS-2` and fullscreens there;
81+- clipboard: `wl-copy` text of 4 KiB and 1 MiB (INCR) is read by `x11probe -paste`, and an `x11probe -copy` of the same sizes is read byte-exact by `wl-paste`, for CLIPBOARD and PRIMARY; `wl-copy --clear` gives `paste none`;
82+- XSETTINGS: serial, `Xft/DPI=98304` and `Gdk/WindowScalingFactor=1` at any output scale (also with `output.HEADLESS-1.scale = 2`), and the cursor theme and size from `XCURSOR_THEME`/`XCURSOR_SIZE`; `xwayland.xsettings = off` leaves no owner;
83+- root cursor: `GetCursorImage` over the root is the theme arrow at the theme size (a throwaway theme in `XCURSOR_PATH`);
84+- a window rule `rule.games.app-id = steam_app_\d+` floats an X11 window of class `steam_app_413150`, and not another class.
85+
86+Not covered headless: relative pointer lock (needs a real pointer device) and Xwayland output hotplug (`--size` is fixed); these are unit-tested in the `wayland` adapter.
87+
88+Run it by hand with `go test -tags xwayland -race ./internal/app -run TestXwayland -count=1 -v`.
89+
6590 ## Colour check
6691
6792 `make color-check` renders `examples/testpattern` on a headless output and checks the colours, in SDR and in HDR (`--headless-hdr`). It needs a GPU and `/dev/udmabuf`, and is not part of `make check`.
@@ -62,6 +62,31 @@ WAYLAND_DISPLAY=<name> go run ./examples/testime -preedit nihon -commit 日本
62 62
63 `-count <n>` exits after n commits; `-delay` sets the time between preedit and commit.63 `-count <n>` exits after n commits; `-delay` sets the time between preedit and commit.
64 64
65+## X11 check
66+
67+`make e2e-xwayland` runs the end-to-end test of the built-in window manager (`xwayland = builtin`). It is behind the `xwayland` build tag, so `go test ./...` and `make check` only compile it (`go vet -tags xwayland`). It needs `Xwayland` on `PATH` and a GPU, and skips without Xwayland. The clipboard test also needs `wl-copy` and `wl-paste` (wl-clipboard) and skips without them; they run with the headless `WAYLAND_DISPLAY` and need no extra focus step, since the focused probe window is enough for `wl-copy --foreground` to set the selection.
68+
69+The test starts a headless NeferWL with a real `Xwayland` on the first free X display (a live session's `:0` stays untouched) and runs `internal/app/testdata/x11probe`, a small X11 client (`-class`, `-title`, `-size`, `-sleep`, `-at X,Y`) that paints its window green, prints `mapped WxH` and `focus`, and exits 0 on `WM_DELETE_WINDOW`. With `-override` it also maps a magenta override-redirect window (`-override-at`, `-override-size`, `-override-type menu|tooltip`, `-override-for`, `-override-shape`, `-override-dismiss-on-focusout`) and prints `override mapped`, `override unmapped` or `dismissed`. `-fullscreen` (with `-fullscreen-delay`, `-fullscreen-off`) requests `_NET_WM_STATE_FULLSCREEN` by client message and prints `fullscreen WxH` / `fullscreen off WxH` when the window manager publishes the state; `-keyboard-grab` calls `XGrabKeyboard` and prints `grab`. Windowless modes: `-copy TEXT|@file` (with `-copy-sel CLIPBOARD|PRIMARY`, `-copy-for`) owns a selection and serves `TARGETS`, `UTF8_STRING`, `STRING` and `TEXT`, with INCR above 64 KiB, and prints `copy owned`; `-paste CLIPBOARD|PRIMARY` prints `paste "<text>"`, `paste len=N sha256=H` above 4 KiB, or `paste none`; `-xsettings` prints `xsettings serial=N Xft/DPI=… Gdk/WindowScalingFactor=… Gdk/UnscaledDPI=… Gtk/CursorThemeName=… Gtk/CursorThemeSize=…` or `xsettings none`; `-root-cursor` prints `cursor WxH` from XFixes `GetCursorImage`. `-at` also sends a ConfigureRequest with the window rect before the map, like games do. It checks:
70+
71+- the state lists one visible `X11Probe` window with the probe's pid, and the screenshot is mostly green inside its tile;
72+- the probe receives focus;
73+- a second probe tiles beside the first;
74+- `key Super+q` closes the focused probe within 2 seconds and the state drops to one window;
75+- an override-redirect menu is drawn magenta above the green toplevel at its X position, is not listed in the state, leaves focus on the toplevel and disappears from the screenshot once the probe unmaps it;
76+- a menu is dismissed when its owner gets `FocusOut` because another X11 window took the focus;
77+- a menu with an empty input shape is still drawn and still not a window;
78+- a fullscreen request 1.5 s after the map fills the output (all green, no gap) and `Super+Shift+f` leaves it; the client can also leave it itself; a request at the map stays tiled (the 1 s grace);
79+- with the keyboard grab, `Super+q` does not close a fullscreen focused client and works again once it leaves fullscreen; on a windowed client it closes it;
80+- on two outputs, a window created at the second output's origin opens on `HEADLESS-2` and fullscreens there;
81+- clipboard: `wl-copy` text of 4 KiB and 1 MiB (INCR) is read by `x11probe -paste`, and an `x11probe -copy` of the same sizes is read byte-exact by `wl-paste`, for CLIPBOARD and PRIMARY; `wl-copy --clear` gives `paste none`;
82+- XSETTINGS: serial, `Xft/DPI=98304` and `Gdk/WindowScalingFactor=1` at any output scale (also with `output.HEADLESS-1.scale = 2`), and the cursor theme and size from `XCURSOR_THEME`/`XCURSOR_SIZE`; `xwayland.xsettings = off` leaves no owner;
83+- root cursor: `GetCursorImage` over the root is the theme arrow at the theme size (a throwaway theme in `XCURSOR_PATH`);
84+- a window rule `rule.games.app-id = steam_app_\d+` floats an X11 window of class `steam_app_413150`, and not another class.
85+
86+Not covered headless: relative pointer lock (needs a real pointer device) and Xwayland output hotplug (`--size` is fixed); these are unit-tested in the `wayland` adapter.
87+
88+Run it by hand with `go test -tags xwayland -race ./internal/app -run TestXwayland -count=1 -v`.
89+
65 ## Colour check90 ## Colour check
66 91
67 `make color-check` renders `examples/testpattern` on a headless output and checks the colours, in SDR and in HDR (`--headless-hdr`). It needs a GPU and `/dev/udmabuf`, and is not part of `make check`.92 `make color-check` renders `examples/testpattern` on a headless output and checks the colours, in SDR and in HDR (`--headless-hdr`). It needs a GPU and `/dev/udmabuf`, and is not part of `make check`.
M go.mod +4 -3
@@ -3,10 +3,11 @@ module github.com/bnema/neferwl
33 go 1.27
44
55 require (
6+ bnema.dev/xturbo v0.1.0
67 github.com/bnema/go-wayland-bindings v0.1.0
78 github.com/bnema/kvconf v0.1.2
8- github.com/bnema/purego v0.13.0-bnema.1
9- github.com/bnema/purego-libwayland v0.11.0
9+ github.com/bnema/purego v0.13.0-bnema.2
10+ github.com/bnema/purego-libwayland v0.12.0
1011 github.com/bnema/purego-pipewire v0.2.0
1112 github.com/bnema/purego-vulkan v0.6.0
1213 github.com/bnema/wlturbo v0.5.0
@@ -16,7 +17,7 @@ require (
1617 github.com/rs/zerolog v1.35.1
1718 github.com/stretchr/testify v1.12.1
1819 golang.org/x/image v0.46.0
19- golang.org/x/sys v0.48.0
20+ golang.org/x/sys v0.49.0
2021 golang.org/x/term v0.46.0
2122 )
2223
@@ -3,10 +3,11 @@ module github.com/bnema/neferwl
3 go 1.273 go 1.27
4 4
5 require (5 require (
6+ bnema.dev/xturbo v0.1.0
6 github.com/bnema/go-wayland-bindings v0.1.07 github.com/bnema/go-wayland-bindings v0.1.0
7 github.com/bnema/kvconf v0.1.28 github.com/bnema/kvconf v0.1.2
8- github.com/bnema/purego v0.13.0-bnema.19+ github.com/bnema/purego v0.13.0-bnema.2
9- github.com/bnema/purego-libwayland v0.11.010+ github.com/bnema/purego-libwayland v0.12.0
10 github.com/bnema/purego-pipewire v0.2.011 github.com/bnema/purego-pipewire v0.2.0
11 github.com/bnema/purego-vulkan v0.6.012 github.com/bnema/purego-vulkan v0.6.0
12 github.com/bnema/wlturbo v0.5.013 github.com/bnema/wlturbo v0.5.0
@@ -16,7 +17,7 @@ require (
16 github.com/rs/zerolog v1.35.117 github.com/rs/zerolog v1.35.1
17 github.com/stretchr/testify v1.12.118 github.com/stretchr/testify v1.12.1
18 golang.org/x/image v0.46.019 golang.org/x/image v0.46.0
19- golang.org/x/sys v0.48.020+ golang.org/x/sys v0.49.0
20 golang.org/x/term v0.46.021 golang.org/x/term v0.46.0
21 )22 )
22 23
M go.sum +8 -6
@@ -1,11 +1,13 @@
1+bnema.dev/xturbo v0.1.0 h1:UkdSoJLRvJeGTiT+KmgDBIuKQ85i+TcC/PYSeK05Trg=
2+bnema.dev/xturbo v0.1.0/go.mod h1:iPphe/cFZVK1d4kLgCLaen4QRH7cGwHYQ+9HBtzaLeY=
13 github.com/bnema/go-wayland-bindings v0.1.0 h1:mi2vNa8BB5ZCo/hq/9d6Af01yhqY6QXAZ6pkljFI1xk=
24 github.com/bnema/go-wayland-bindings v0.1.0/go.mod h1:eWOgjp9vzZEqxm059q1G9F5NXplmv9UAmrDJiivxVjA=
35 github.com/bnema/kvconf v0.1.2 h1:1hDKL9jkQSBdR9dSKbwTONhUUBfJgAdh5vu+yFHOb70=
46 github.com/bnema/kvconf v0.1.2/go.mod h1:+P2X9I/sLx+Lfir45s9a7WA0GnC22WgKbZWE9LK6lcU=
5-github.com/bnema/purego v0.13.0-bnema.1 h1:fKRdztnvTmRqAjANMoN+utEIH73dvPqVPJh2iZCCAO8=
6-github.com/bnema/purego v0.13.0-bnema.1/go.mod h1:xS1iX3noypxnbOUEUAqOvkfnUzgHuu19VtL4+dfUMJM=
7-github.com/bnema/purego-libwayland v0.11.0 h1:9DHAgg1M9+blG5rAC9EWlJt2GFRnA/pxuXE0S0S2Cl8=
8-github.com/bnema/purego-libwayland v0.11.0/go.mod h1:ZGc8Hz68KwJEHztCBU68o0pRFmDZgGFe3dJ+U1MHgwA=
7+github.com/bnema/purego v0.13.0-bnema.2 h1:FgGAFNswiShd+A4lFuP5broqjOzW4Lf8f6OZKjgXzAM=
8+github.com/bnema/purego v0.13.0-bnema.2/go.mod h1:xS1iX3noypxnbOUEUAqOvkfnUzgHuu19VtL4+dfUMJM=
9+github.com/bnema/purego-libwayland v0.12.0 h1:TTISNNN3zfEwD0G46ZDzI7MMzXRmduLq+NMqBPhB+9g=
10+github.com/bnema/purego-libwayland v0.12.0/go.mod h1:HHDHathJMypu+/pDKWuyD8xcDneU5GoUt3nRen6SKU4=
911 github.com/bnema/purego-pipewire v0.2.0 h1:drIE0NjxynoCn9gCHTQFKkQVYEh/qsBNt2L2qZFYwro=
1012 github.com/bnema/purego-pipewire v0.2.0/go.mod h1:FxEYkYIWIP2g8Zo25jlnQSoP9yjsmb5vIuZwnqPg9uQ=
1113 github.com/bnema/purego-vulkan v0.6.0 h1:SJgu0smuDC2vtBv0dJ6dmRKewJW5EUXQwTXyb/p9JSE=
@@ -32,8 +34,8 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
3234 go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
3335 golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
3436 golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
35-golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
36-golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
37+golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
38+golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
3739 golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
3840 golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
3941 golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
@@ -1,11 +1,13 @@
1+bnema.dev/xturbo v0.1.0 h1:UkdSoJLRvJeGTiT+KmgDBIuKQ85i+TcC/PYSeK05Trg=
2+bnema.dev/xturbo v0.1.0/go.mod h1:iPphe/cFZVK1d4kLgCLaen4QRH7cGwHYQ+9HBtzaLeY=
1 github.com/bnema/go-wayland-bindings v0.1.0 h1:mi2vNa8BB5ZCo/hq/9d6Af01yhqY6QXAZ6pkljFI1xk=3 github.com/bnema/go-wayland-bindings v0.1.0 h1:mi2vNa8BB5ZCo/hq/9d6Af01yhqY6QXAZ6pkljFI1xk=
2 github.com/bnema/go-wayland-bindings v0.1.0/go.mod h1:eWOgjp9vzZEqxm059q1G9F5NXplmv9UAmrDJiivxVjA=4 github.com/bnema/go-wayland-bindings v0.1.0/go.mod h1:eWOgjp9vzZEqxm059q1G9F5NXplmv9UAmrDJiivxVjA=
3 github.com/bnema/kvconf v0.1.2 h1:1hDKL9jkQSBdR9dSKbwTONhUUBfJgAdh5vu+yFHOb70=5 github.com/bnema/kvconf v0.1.2 h1:1hDKL9jkQSBdR9dSKbwTONhUUBfJgAdh5vu+yFHOb70=
4 github.com/bnema/kvconf v0.1.2/go.mod h1:+P2X9I/sLx+Lfir45s9a7WA0GnC22WgKbZWE9LK6lcU=6 github.com/bnema/kvconf v0.1.2/go.mod h1:+P2X9I/sLx+Lfir45s9a7WA0GnC22WgKbZWE9LK6lcU=
5-github.com/bnema/purego v0.13.0-bnema.1 h1:fKRdztnvTmRqAjANMoN+utEIH73dvPqVPJh2iZCCAO8=7+github.com/bnema/purego v0.13.0-bnema.2 h1:FgGAFNswiShd+A4lFuP5broqjOzW4Lf8f6OZKjgXzAM=
6-github.com/bnema/purego v0.13.0-bnema.1/go.mod h1:xS1iX3noypxnbOUEUAqOvkfnUzgHuu19VtL4+dfUMJM=8+github.com/bnema/purego v0.13.0-bnema.2/go.mod h1:xS1iX3noypxnbOUEUAqOvkfnUzgHuu19VtL4+dfUMJM=
7-github.com/bnema/purego-libwayland v0.11.0 h1:9DHAgg1M9+blG5rAC9EWlJt2GFRnA/pxuXE0S0S2Cl8=9+github.com/bnema/purego-libwayland v0.12.0 h1:TTISNNN3zfEwD0G46ZDzI7MMzXRmduLq+NMqBPhB+9g=
8-github.com/bnema/purego-libwayland v0.11.0/go.mod h1:ZGc8Hz68KwJEHztCBU68o0pRFmDZgGFe3dJ+U1MHgwA=10+github.com/bnema/purego-libwayland v0.12.0/go.mod h1:HHDHathJMypu+/pDKWuyD8xcDneU5GoUt3nRen6SKU4=
9 github.com/bnema/purego-pipewire v0.2.0 h1:drIE0NjxynoCn9gCHTQFKkQVYEh/qsBNt2L2qZFYwro=11 github.com/bnema/purego-pipewire v0.2.0 h1:drIE0NjxynoCn9gCHTQFKkQVYEh/qsBNt2L2qZFYwro=
10 github.com/bnema/purego-pipewire v0.2.0/go.mod h1:FxEYkYIWIP2g8Zo25jlnQSoP9yjsmb5vIuZwnqPg9uQ=12 github.com/bnema/purego-pipewire v0.2.0/go.mod h1:FxEYkYIWIP2g8Zo25jlnQSoP9yjsmb5vIuZwnqPg9uQ=
11 github.com/bnema/purego-vulkan v0.6.0 h1:SJgu0smuDC2vtBv0dJ6dmRKewJW5EUXQwTXyb/p9JSE=13 github.com/bnema/purego-vulkan v0.6.0 h1:SJgu0smuDC2vtBv0dJ6dmRKewJW5EUXQwTXyb/p9JSE=
@@ -32,8 +34,8 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
32 go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=34 go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
33 golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=35 golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
34 golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=36 golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
35-golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=37+golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
36-golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=38+golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
37 golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=39 golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
38 golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=40 golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
39 golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=41 golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
M internal/adapters/config/config.go +18 -4
@@ -167,7 +167,7 @@ func Defaults() ports.Config {
167167 // Hint mode is experimental: off unless the config turns it on.
168168 c.Hint.Enable = false
169169 c.Hint.TextClick = ports.ModCtrl | ports.ModShift
170- c.Xwayland = "xwayland-satellite"
170+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite", XSettings: true}
171171 c.Background.Color = "#111111"
172172 c.Floating.Dim = 0.3
173173 c.Stash.Width = 80
@@ -724,13 +724,27 @@ func set(c *ports.Config, key, v string) error {
724724 }
725725 c.Terminal.AutoOpen = v
726726 case "xwayland":
727+ xs := c.Xwayland.XSettings // independent of the backend
727728 switch {
728729 case v == "off":
729- c.Xwayland = ""
730+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandOff, XSettings: xs}
731+ case v == "satellite":
732+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite", XSettings: xs}
733+ case v == "builtin":
734+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland", XSettings: xs}
730735 case v == "" || strings.ContainsAny(v, " \t"):
731- return fmt.Errorf("must be off or the xwayland-satellite program")
736+ return fmt.Errorf("must be off, satellite, builtin or a satellite program")
732737 default:
733- c.Xwayland = v
738+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: v, XSettings: xs}
739+ }
740+ case "xwayland.xsettings":
741+ switch v {
742+ case "on":
743+ c.Xwayland.XSettings = true
744+ case "off":
745+ c.Xwayland.XSettings = false
746+ default:
747+ return fmt.Errorf("must be on or off")
734748 }
735749 case "background":
736750 if !color.MatchString(v) {
@@ -167,7 +167,7 @@ func Defaults() ports.Config {
167 // Hint mode is experimental: off unless the config turns it on.167 // Hint mode is experimental: off unless the config turns it on.
168 c.Hint.Enable = false168 c.Hint.Enable = false
169 c.Hint.TextClick = ports.ModCtrl | ports.ModShift169 c.Hint.TextClick = ports.ModCtrl | ports.ModShift
170- c.Xwayland = "xwayland-satellite"170+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite", XSettings: true}
171 c.Background.Color = "#111111"171 c.Background.Color = "#111111"
172 c.Floating.Dim = 0.3172 c.Floating.Dim = 0.3
173 c.Stash.Width = 80173 c.Stash.Width = 80
@@ -724,13 +724,27 @@ func set(c *ports.Config, key, v string) error {
724 }724 }
725 c.Terminal.AutoOpen = v725 c.Terminal.AutoOpen = v
726 case "xwayland":726 case "xwayland":
727+ xs := c.Xwayland.XSettings // independent of the backend
727 switch {728 switch {
728 case v == "off":729 case v == "off":
729- c.Xwayland = ""730+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandOff, XSettings: xs}
731+ case v == "satellite":
732+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite", XSettings: xs}
733+ case v == "builtin":
734+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland", XSettings: xs}
730 case v == "" || strings.ContainsAny(v, " \t"):735 case v == "" || strings.ContainsAny(v, " \t"):
731- return fmt.Errorf("must be off or the xwayland-satellite program")736+ return fmt.Errorf("must be off, satellite, builtin or a satellite program")
732 default:737 default:
733- c.Xwayland = v738+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: v, XSettings: xs}
739+ }
740+ case "xwayland.xsettings":
741+ switch v {
742+ case "on":
743+ c.Xwayland.XSettings = true
744+ case "off":
745+ c.Xwayland.XSettings = false
746+ default:
747+ return fmt.Errorf("must be on or off")
734 }748 }
735 case "background":749 case "background":
736 if !color.MatchString(v) {750 if !color.MatchString(v) {
M internal/adapters/config/config_test.go +36 -4
@@ -340,13 +340,45 @@ func TestWorkspacePrevNextAction(t *testing.T) {
340340 }
341341
342342 func TestXwayland(t *testing.T) {
343- if Defaults().Xwayland != "xwayland-satellite" {
343+ if Defaults().Xwayland != (ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite", XSettings: true}) {
344344 t.Fatal(Defaults().Xwayland)
345345 }
346- if c, w := parseString(t, "xwayland = off"); c.Xwayland != "" || len(w) != 0 {
347- t.Fatal(c.Xwayland, w)
346+ for _, tt := range []struct {
347+ value string
348+ want ports.XwaylandConfig
349+ }{
350+ {"off", ports.XwaylandConfig{Backend: ports.XwaylandOff, XSettings: true}},
351+ {"satellite", ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite", XSettings: true}},
352+ {"builtin", ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland", XSettings: true}},
353+ {"/opt/xwls", ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "/opt/xwls", XSettings: true}},
354+ } {
355+ t.Run(tt.value, func(t *testing.T) {
356+ c, w := parseString(t, "xwayland = "+tt.value)
357+ if c.Xwayland != tt.want || len(w) != 0 {
358+ t.Fatal(c.Xwayland, w)
359+ }
360+ })
361+ }
362+}
363+
364+func TestXwaylandXSettings(t *testing.T) {
365+ for _, tt := range []struct {
366+ src string
367+ want bool
368+ }{
369+ {"", true},
370+ {"xwayland.xsettings = on", true},
371+ {"xwayland.xsettings = off", false},
372+ {"xwayland.xsettings = off\nxwayland = builtin", false},
373+ {"xwayland = builtin\nxwayland.xsettings = off", false},
374+ } {
375+ c, w := parseString(t, tt.src)
376+ if c.Xwayland.XSettings != tt.want || len(w) != 0 {
377+ t.Fatal(tt.src, c.Xwayland, w)
378+ }
348379 }
349- if c, w := parseString(t, "xwayland = /opt/xwls"); c.Xwayland != "/opt/xwls" || len(w) != 0 {
380+ c, w := parseString(t, "xwayland.xsettings = maybe")
381+ if !c.Xwayland.XSettings || len(w) != 1 || !strings.Contains(w[0].Msg, "must be on or off") {
350382 t.Fatal(c.Xwayland, w)
351383 }
352384 }
@@ -340,13 +340,45 @@ func TestWorkspacePrevNextAction(t *testing.T) {
340 }340 }
341 341
342 func TestXwayland(t *testing.T) {342 func TestXwayland(t *testing.T) {
343- if Defaults().Xwayland != "xwayland-satellite" {343+ if Defaults().Xwayland != (ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite", XSettings: true}) {
344 t.Fatal(Defaults().Xwayland)344 t.Fatal(Defaults().Xwayland)
345 }345 }
346- if c, w := parseString(t, "xwayland = off"); c.Xwayland != "" || len(w) != 0 {346+ for _, tt := range []struct {
347- t.Fatal(c.Xwayland, w)347+ value string
348+ want ports.XwaylandConfig
349+ }{
350+ {"off", ports.XwaylandConfig{Backend: ports.XwaylandOff, XSettings: true}},
351+ {"satellite", ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite", XSettings: true}},
352+ {"builtin", ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland", XSettings: true}},
353+ {"/opt/xwls", ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "/opt/xwls", XSettings: true}},
354+ } {
355+ t.Run(tt.value, func(t *testing.T) {
356+ c, w := parseString(t, "xwayland = "+tt.value)
357+ if c.Xwayland != tt.want || len(w) != 0 {
358+ t.Fatal(c.Xwayland, w)
359+ }
360+ })
361+ }
362+}
363+
364+func TestXwaylandXSettings(t *testing.T) {
365+ for _, tt := range []struct {
366+ src string
367+ want bool
368+ }{
369+ {"", true},
370+ {"xwayland.xsettings = on", true},
371+ {"xwayland.xsettings = off", false},
372+ {"xwayland.xsettings = off\nxwayland = builtin", false},
373+ {"xwayland = builtin\nxwayland.xsettings = off", false},
374+ } {
375+ c, w := parseString(t, tt.src)
376+ if c.Xwayland.XSettings != tt.want || len(w) != 0 {
377+ t.Fatal(tt.src, c.Xwayland, w)
378+ }
348 }379 }
349- if c, w := parseString(t, "xwayland = /opt/xwls"); c.Xwayland != "/opt/xwls" || len(w) != 0 {380+ c, w := parseString(t, "xwayland.xsettings = maybe")
381+ if !c.Xwayland.XSettings || len(w) != 1 || !strings.Contains(w[0].Msg, "must be on or off") {
350 t.Fatal(c.Xwayland, w)382 t.Fatal(c.Xwayland, w)
351 }383 }
352 }384 }
M internal/adapters/drm/colorop_match.go +11 -0
@@ -4,6 +4,7 @@ import (
44 "encoding/binary"
55 "fmt"
66 "math"
7+ "os"
78 "slices"
89 "strings"
910
@@ -244,6 +245,13 @@ func ctmBytes(m [3][3]float64) []byte {
244245 return b
245246 }
246247
248+// sdrPlanePipeline lets sRGB buffers on an HDR output scan out through the
249+// plane colour pipeline. Off by default: on amdgpu (DP-2, 5120x2160) that path
250+// looked oversaturated against the composed one and its cause is not yet
251+// established; the buffers are composed ("hdr_sdr_content"). Set
252+// NEFERWL_DRM_SDR_PLANE=1 to test the pipeline path.
253+var sdrPlanePipeline = os.Getenv("NEFERWL_DRM_SDR_PLANE") == "1"
254+
247255 // planeColor decides how plane (with pipeline, nil: none) shows a client
248256 // buffer of format whose content is c, on an output in HDR10 mode when hdrOn.
249257 // opaque is the surface's Opaque. A buffer already in the output's encoding
@@ -267,6 +275,9 @@ func planeColor(c ports.SurfaceColor, format uint32, opaque, hdrOn bool, pipelin
267275 return colorBypass, "hdr_format"
268276 }
269277 return colorBypass, ""
278+ case !sdrPlanePipeline:
279+ // Unverified on hardware: the owner saw oversaturated colours.
280+ return colorBypass, "hdr_sdr_content"
270281 case pipeline == nil || !opaque || !isSDRColor(c):
271282 // The kernel applies the pipeline to premultiplied pixels: exact
272283 // only without alpha.
@@ -4,6 +4,7 @@ import (
4 "encoding/binary"4 "encoding/binary"
5 "fmt"5 "fmt"
6 "math"6 "math"
7+ "os"
7 "slices"8 "slices"
8 "strings"9 "strings"
9 10
@@ -244,6 +245,13 @@ func ctmBytes(m [3][3]float64) []byte {
244 return b245 return b
245 }246 }
246 247
248+// sdrPlanePipeline lets sRGB buffers on an HDR output scan out through the
249+// plane colour pipeline. Off by default: on amdgpu (DP-2, 5120x2160) that path
250+// looked oversaturated against the composed one and its cause is not yet
251+// established; the buffers are composed ("hdr_sdr_content"). Set
252+// NEFERWL_DRM_SDR_PLANE=1 to test the pipeline path.
253+var sdrPlanePipeline = os.Getenv("NEFERWL_DRM_SDR_PLANE") == "1"
254+
247 // planeColor decides how plane (with pipeline, nil: none) shows a client255 // planeColor decides how plane (with pipeline, nil: none) shows a client
248 // buffer of format whose content is c, on an output in HDR10 mode when hdrOn.256 // buffer of format whose content is c, on an output in HDR10 mode when hdrOn.
249 // opaque is the surface's Opaque. A buffer already in the output's encoding257 // opaque is the surface's Opaque. A buffer already in the output's encoding
@@ -267,6 +275,9 @@ func planeColor(c ports.SurfaceColor, format uint32, opaque, hdrOn bool, pipelin
267 return colorBypass, "hdr_format"275 return colorBypass, "hdr_format"
268 }276 }
269 return colorBypass, ""277 return colorBypass, ""
278+ case !sdrPlanePipeline:
279+ // Unverified on hardware: the owner saw oversaturated colours.
280+ return colorBypass, "hdr_sdr_content"
270 case pipeline == nil || !opaque || !isSDRColor(c):281 case pipeline == nil || !opaque || !isSDRColor(c):
271 // The kernel applies the pipeline to premultiplied pixels: exact282 // The kernel applies the pipeline to premultiplied pixels: exact
272 // only without alpha.283 // only without alpha.
A internal/adapters/drm/sdrplane_test.go +30 -0
@@ -0,0 +1,30 @@
1+package drm
2+
3+import (
4+ "encoding/binary"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func init() { sdrPlanePipeline = true } // the pipeline tests exercise the path itself
11+
12+// Golden bytes: row-major 3x4, little-endian S31.32 sign-magnitude, zero offset column.
13+func TestCTMBlobGolden(t *testing.T) {
14+ b := ctmBytes([3][3]float64{{1, -0.5, 0}, {0, 1, 0}, {0, 0, 1}})
15+ want := []uint64{1 << 32, 1<<63 | 1<<31, 0, 0, 0, 1 << 32, 0, 0, 0, 0, 1 << 32, 0}
16+ for i, w := range want {
17+ if g := binary.LittleEndian.Uint64(b[i*8:]); g != w {
18+ t.Errorf("word %d = %#x, want %#x", i, g, w)
19+ }
20+ }
21+}
22+
23+func TestPlaneColorSwitchOff(t *testing.T) {
24+ sdrPlanePipeline = false
25+ defer func() { sdrPlanePipeline = true }()
26+ m, why := planeColor(ports.SurfaceColor{}, fourccXRGB, true, true, &colorPipeline{})
27+ if m != colorBypass || why != "hdr_sdr_content" {
28+ t.Fatalf("got %d %q", m, why)
29+ }
30+}
@@ -0,0 +1,30 @@
1+package drm
2+
3+import (
4+ "encoding/binary"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func init() { sdrPlanePipeline = true } // the pipeline tests exercise the path itself
11+
12+// Golden bytes: row-major 3x4, little-endian S31.32 sign-magnitude, zero offset column.
13+func TestCTMBlobGolden(t *testing.T) {
14+ b := ctmBytes([3][3]float64{{1, -0.5, 0}, {0, 1, 0}, {0, 0, 1}})
15+ want := []uint64{1 << 32, 1<<63 | 1<<31, 0, 0, 0, 1 << 32, 0, 0, 0, 0, 1 << 32, 0}
16+ for i, w := range want {
17+ if g := binary.LittleEndian.Uint64(b[i*8:]); g != w {
18+ t.Errorf("word %d = %#x, want %#x", i, g, w)
19+ }
20+ }
21+}
22+
23+func TestPlaneColorSwitchOff(t *testing.T) {
24+ sdrPlanePipeline = false
25+ defer func() { sdrPlanePipeline = true }()
26+ m, why := planeColor(ports.SurfaceColor{}, fourccXRGB, true, true, &colorPipeline{})
27+ if m != colorBypass || why != "hdr_sdr_content" {
28+ t.Fatalf("got %d %q", m, why)
29+ }
30+}
M internal/adapters/vulkan/capture_test.go +3 -0
@@ -358,6 +358,9 @@ func openFDs(t *testing.T) int {
358358 // slot keeps its sync file for every lease. No mock stands in the measured
359359 // closure.
360360 func TestCaptureCycleAllocations(t *testing.T) {
361+ if raceEnabled {
362+ t.Skip("allocation budgets are not stable under the race runtime")
363+ }
361364 for _, tc := range []struct {
362365 name string
363366 new func(*testing.T) *Renderer
@@ -358,6 +358,9 @@ func openFDs(t *testing.T) int {
358 // slot keeps its sync file for every lease. No mock stands in the measured358 // slot keeps its sync file for every lease. No mock stands in the measured
359 // closure.359 // closure.
360 func TestCaptureCycleAllocations(t *testing.T) {360 func TestCaptureCycleAllocations(t *testing.T) {
361+ if raceEnabled {
362+ t.Skip("allocation budgets are not stable under the race runtime")
363+ }
361 for _, tc := range []struct {364 for _, tc := range []struct {
362 name string365 name string
363 new func(*testing.T) *Renderer366 new func(*testing.T) *Renderer
A internal/adapters/vulkan/norace_test.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build !race
2+
3+package vulkan
4+
5+const raceEnabled = false
@@ -0,0 +1,5 @@
1+//go:build !race
2+
3+package vulkan
4+
5+const raceEnabled = false
A internal/adapters/vulkan/race_test.go +8 -0
@@ -0,0 +1,8 @@
1+//go:build race
2+
3+package vulkan
4+
5+// raceEnabled: sync.Pool drops items at random under the race runtime, so the
6+// allocation budgets of calls that go through purego cannot hold. perf-check
7+// runs them without it.
8+const raceEnabled = true
@@ -0,0 +1,8 @@
1+//go:build race
2+
3+package vulkan
4+
5+// raceEnabled: sync.Pool drops items at random under the race runtime, so the
6+// allocation budgets of calls that go through purego cannot hold. perf-check
7+// runs them without it.
8+const raceEnabled = true
M internal/adapters/vulkan/scene_perf_test.go +6 -0
@@ -16,6 +16,9 @@ import (
1616 // A new scene Seq with one of two windows moved is a partial redraw seeded by
1717 // sceneDelta and allocates nothing either.
1818 func TestRenderSteadyStateAllocations(t *testing.T) {
19+ if raceEnabled {
20+ t.Skip("allocation budgets are not stable under the race runtime")
21+ }
1922 for _, tc := range []struct {
2023 name string
2124 transform ports.BufferTransform
@@ -145,6 +148,9 @@ func TestRenderSteadyStateAllocations(t *testing.T) {
145148 // keeps one alloc of headroom for drivers whose import path differs.
146149 // Skipped without a GPU or /dev/udmabuf.
147150 func TestRenderSteadyStateDMABufAllocations(t *testing.T) {
151+ if raceEnabled {
152+ t.Skip("allocation budgets are not stable under the race runtime")
153+ }
148154 r, err := New(80, 32)
149155 if err != nil {
150156 t.Skipf("Vulkan unavailable: %v", err)
@@ -16,6 +16,9 @@ import (
16 // A new scene Seq with one of two windows moved is a partial redraw seeded by16 // A new scene Seq with one of two windows moved is a partial redraw seeded by
17 // sceneDelta and allocates nothing either.17 // sceneDelta and allocates nothing either.
18 func TestRenderSteadyStateAllocations(t *testing.T) {18 func TestRenderSteadyStateAllocations(t *testing.T) {
19+ if raceEnabled {
20+ t.Skip("allocation budgets are not stable under the race runtime")
21+ }
19 for _, tc := range []struct {22 for _, tc := range []struct {
20 name string23 name string
21 transform ports.BufferTransform24 transform ports.BufferTransform
@@ -145,6 +148,9 @@ func TestRenderSteadyStateAllocations(t *testing.T) {
145 // keeps one alloc of headroom for drivers whose import path differs.148 // keeps one alloc of headroom for drivers whose import path differs.
146 // Skipped without a GPU or /dev/udmabuf.149 // Skipped without a GPU or /dev/udmabuf.
147 func TestRenderSteadyStateDMABufAllocations(t *testing.T) {150 func TestRenderSteadyStateDMABufAllocations(t *testing.T) {
151+ if raceEnabled {
152+ t.Skip("allocation budgets are not stable under the race runtime")
153+ }
148 r, err := New(80, 32)154 r, err := New(80, 32)
149 if err != nil {155 if err != nil {
150 t.Skipf("Vulkan unavailable: %v", err)156 t.Skipf("Vulkan unavailable: %v", err)
M internal/adapters/wayland/activation.go +3 -3
@@ -66,7 +66,7 @@ func (a activation) Activate(_ *xdgactivation.ActivationV1, token string, surf *
6666 }
6767 var w *window
6868 if reason == "" {
69- if w = s.windows[state.root().windowID()]; w == nil || w.toplevel == nil || !w.mapped {
69+ if w = s.windows[state.root().windowID()]; w == nil || !w.isToplevel() || !w.mapped {
7070 reason = "not_toplevel"
7171 }
7272 }
@@ -116,8 +116,8 @@ func (t *tokenRequest) Commit(r *xdgactivation.ActivationTokenV1) {
116116
117117 // focusClient is the client with the keyboard focus.
118118 func (s *Server) focusClient() server.Client {
119- if w := s.windows[s.seat.focused]; w != nil && w.xdg.resource.Resource.Alive() {
120- return w.xdg.resource.Client()
119+ if w := s.windows[s.seat.focused]; w != nil && w.alive() {
120+ return w.client()
121121 }
122122 if l := s.layers[s.seat.focused]; l != nil && l.resource.Resource.Alive() {
123123 return l.resource.Client()
@@ -66,7 +66,7 @@ func (a activation) Activate(_ *xdgactivation.ActivationV1, token string, surf *
66 }66 }
67 var w *window67 var w *window
68 if reason == "" {68 if reason == "" {
69- if w = s.windows[state.root().windowID()]; w == nil || w.toplevel == nil || !w.mapped {69+ if w = s.windows[state.root().windowID()]; w == nil || !w.isToplevel() || !w.mapped {
70 reason = "not_toplevel"70 reason = "not_toplevel"
71 }71 }
72 }72 }
@@ -116,8 +116,8 @@ func (t *tokenRequest) Commit(r *xdgactivation.ActivationTokenV1) {
116 116
117 // focusClient is the client with the keyboard focus.117 // focusClient is the client with the keyboard focus.
118 func (s *Server) focusClient() server.Client {118 func (s *Server) focusClient() server.Client {
119- if w := s.windows[s.seat.focused]; w != nil && w.xdg.resource.Resource.Alive() {119+ if w := s.windows[s.seat.focused]; w != nil && w.alive() {
120- return w.xdg.resource.Client()120+ return w.client()
121 }121 }
122 if l := s.layers[s.seat.focused]; l != nil && l.resource.Resource.Alive() {122 if l := s.layers[s.seat.focused]; l != nil && l.resource.Resource.Alive() {
123 return l.resource.Client()123 return l.resource.Client()
M internal/adapters/wayland/alloc_hotpath_test.go +9 -0
@@ -246,3 +246,12 @@ func TestCommandForwardAllocations(t *testing.T) {
246246 t.Errorf("command push + merge + drain + stats: %.1f allocs, want 0", allocs)
247247 }
248248 }
249+
250+// A settled grab (none, or unchanged) is re-checked at every focus and
251+// configure without allocating.
252+func TestKeyboardGrabSyncAllocations(t *testing.T) {
253+ s := &Server{windows: map[ports.WindowID]*window{}}
254+ if n := testing.AllocsPerRun(100, s.syncKeyboardGrab); n != 0 {
255+ t.Fatalf("%v allocs", n)
256+ }
257+}
@@ -246,3 +246,12 @@ func TestCommandForwardAllocations(t *testing.T) {
246 t.Errorf("command push + merge + drain + stats: %.1f allocs, want 0", allocs)246 t.Errorf("command push + merge + drain + stats: %.1f allocs, want 0", allocs)
247 }247 }
248 }248 }
249+
250+// A settled grab (none, or unchanged) is re-checked at every focus and
251+// configure without allocating.
252+func TestKeyboardGrabSyncAllocations(t *testing.T) {
253+ s := &Server{windows: map[ports.WindowID]*window{}}
254+ if n := testing.AllocsPerRun(100, s.syncKeyboardGrab); n != 0 {
255+ t.Fatalf("%v allocs", n)
256+ }
257+}
M internal/adapters/wayland/clipboard.go +5 -0
@@ -37,6 +37,8 @@ type clipSource struct {
3737 // used: the source was set, rejected or dragged; it cannot be set
3838 // again and takes no more MIME types.
3939 used bool
40+ // x11: the offer is an X client's selection (xSource), not a Wayland one.
41+ x11 bool
4042 // res is the wl_data_source (nil for the other protocols); actions are
4143 // its set_actions, for drag and drop only (dnd.go).
4244 res *wayland.DataSource
@@ -105,6 +107,7 @@ func (s *Server) setSelection(kind int, src *clipSource) {
105107 if src != nil && src != s.selections[kind] && src.alive() {
106108 src.cancel()
107109 }
110+ s.exposeToX(kind, nil) // X clients must not read a protected selection
108111 return
109112 }
110113 old := s.selections[kind]
@@ -116,6 +119,7 @@ func (s *Server) setSelection(kind int, src *clipSource) {
116119 old.cancel()
117120 }
118121 s.announceAll(kind)
122+ s.exposeToX(kind, src)
119123 }
120124
121125 // announceAll sends the selection of kind to the focused client and to
@@ -187,6 +191,7 @@ func (s *Server) sourceGone(src *clipSource) {
187191 if s.selections[kind] == src {
188192 s.selections[kind] = nil
189193 s.announceAll(kind)
194+ s.exposeToX(kind, nil)
190195 }
191196 }
192197 }
@@ -37,6 +37,8 @@ type clipSource struct {
37 // used: the source was set, rejected or dragged; it cannot be set37 // used: the source was set, rejected or dragged; it cannot be set
38 // again and takes no more MIME types.38 // again and takes no more MIME types.
39 used bool39 used bool
40+ // x11: the offer is an X client's selection (xSource), not a Wayland one.
41+ x11 bool
40 // res is the wl_data_source (nil for the other protocols); actions are42 // res is the wl_data_source (nil for the other protocols); actions are
41 // its set_actions, for drag and drop only (dnd.go).43 // its set_actions, for drag and drop only (dnd.go).
42 res *wayland.DataSource44 res *wayland.DataSource
@@ -105,6 +107,7 @@ func (s *Server) setSelection(kind int, src *clipSource) {
105 if src != nil && src != s.selections[kind] && src.alive() {107 if src != nil && src != s.selections[kind] && src.alive() {
106 src.cancel()108 src.cancel()
107 }109 }
110+ s.exposeToX(kind, nil) // X clients must not read a protected selection
108 return111 return
109 }112 }
110 old := s.selections[kind]113 old := s.selections[kind]
@@ -116,6 +119,7 @@ func (s *Server) setSelection(kind int, src *clipSource) {
116 old.cancel()119 old.cancel()
117 }120 }
118 s.announceAll(kind)121 s.announceAll(kind)
122+ s.exposeToX(kind, src)
119 }123 }
120 124
121 // announceAll sends the selection of kind to the focused client and to125 // announceAll sends the selection of kind to the focused client and to
@@ -187,6 +191,7 @@ func (s *Server) sourceGone(src *clipSource) {
187 if s.selections[kind] == src {191 if s.selections[kind] == src {
188 s.selections[kind] = nil192 s.selections[kind] = nil
189 s.announceAll(kind)193 s.announceAll(kind)
194+ s.exposeToX(kind, nil)
190 }195 }
191 }196 }
192 }197 }
A internal/adapters/wayland/clipboard_xwm.go +93 -0
@@ -0,0 +1,93 @@
1+package wayland
2+
3+import (
4+ "slices"
5+
6+ "golang.org/x/sys/unix"
7+)
8+
9+// The text clipboard bridge between Wayland and the built-in XWM. An X
10+// client's selection becomes a clipSource like any other (xSource); a Wayland
11+// selection is exposed to X through exposeToX. The X protocol side lives in
12+// the xwm package; nothing of it reaches core or ports.
13+
14+// xwmSelection is the XWM as the clipboard sees it.
15+type xwmSelection interface {
16+ // WaylandSelection: a Wayland client owns kind with these MIME types;
17+ // nil mimes: none does (or it is protected).
18+ WaylandSelection(kind int, mimes []string)
19+ // SelectionReceive hands the XWM the write end of a reader's pipe (it
20+ // closes it) to fill with the X selection as mime.
21+ SelectionReceive(kind int, mime string, fd int)
22+}
23+
24+// xSource is the clipSource of an X-owned selection: no Wayland client, the
25+// data comes from the XWM. It is valid while it is the current X source.
26+func (s *Server) xSource(kind int, mimes []string) *clipSource {
27+ src := &clipSource{mimes: slices.Clone(mimes), used: true, x11: true}
28+ src.alive = func() bool { return s.xsel[kind] == src }
29+ src.cancel = func() {}
30+ src.send = func(mime string, fd int) {
31+ x := s.xwmSel
32+ if x == nil {
33+ return
34+ }
35+ // receive closes its fd after send: the XWM gets its own.
36+ dup, err := unix.FcntlInt(uintptr(fd), unix.F_DUPFD_CLOEXEC, 0)
37+ if err != nil {
38+ return
39+ }
40+ x.SelectionReceive(kind, mime, dup)
41+ }
42+ return src
43+}
44+
45+// setXSelection is the XWM's report: an X client owns kind (mimes), or none
46+// does. A Wayland owner that already replaced the X one is never clobbered.
47+func (s *Server) setXSelection(kind int, mimes []string) {
48+ if len(mimes) == 0 {
49+ cur := s.xsel[kind]
50+ s.xsel[kind] = nil
51+ if cur != nil && s.selections[kind] == cur {
52+ s.setSelection(kind, nil)
53+ }
54+ return
55+ }
56+ src := s.xSource(kind, mimes)
57+ s.xsel[kind] = src
58+ s.setSelection(kind, src)
59+}
60+
61+// exposeToX tells the XWM what Wayland offers. An X source is not exposed
62+// back (it is X's own); nil withdraws.
63+func (s *Server) exposeToX(kind int, src *clipSource) {
64+ if s.xwmSel == nil {
65+ return
66+ }
67+ if src == nil || src.x11 {
68+ s.xwmSel.WaylandSelection(kind, nil)
69+ return
70+ }
71+ s.xwmSel.WaylandSelection(kind, src.mimes)
72+}
73+
74+// xSelectionRead returns a non-blocking read end of the Wayland selection
75+// kind as mime, or -1.
76+func (s *Server) xSelectionRead(kind int, mime string) int {
77+ src := s.selections[kind]
78+ if src == nil || src.x11 || s.protected() || !src.alive() || !slices.Contains(src.mimes, mime) {
79+ return -1
80+ }
81+ var p [2]int
82+ if unix.Pipe2(p[:], unix.O_CLOEXEC) != nil {
83+ return -1
84+ }
85+ if unix.SetNonblock(p[0], true) != nil {
86+ unix.Close(p[0])
87+ unix.Close(p[1])
88+ return -1
89+ }
90+ src.send(mime, p[1])
91+ unix.Close(p[1])
92+ return p[0]
93+}
@@ -0,0 +1,93 @@
1+package wayland
2+
3+import (
4+ "slices"
5+
6+ "golang.org/x/sys/unix"
7+)
8+
9+// The text clipboard bridge between Wayland and the built-in XWM. An X
10+// client's selection becomes a clipSource like any other (xSource); a Wayland
11+// selection is exposed to X through exposeToX. The X protocol side lives in
12+// the xwm package; nothing of it reaches core or ports.
13+
14+// xwmSelection is the XWM as the clipboard sees it.
15+type xwmSelection interface {
16+ // WaylandSelection: a Wayland client owns kind with these MIME types;
17+ // nil mimes: none does (or it is protected).
18+ WaylandSelection(kind int, mimes []string)
19+ // SelectionReceive hands the XWM the write end of a reader's pipe (it
20+ // closes it) to fill with the X selection as mime.
21+ SelectionReceive(kind int, mime string, fd int)
22+}
23+
24+// xSource is the clipSource of an X-owned selection: no Wayland client, the
25+// data comes from the XWM. It is valid while it is the current X source.
26+func (s *Server) xSource(kind int, mimes []string) *clipSource {
27+ src := &clipSource{mimes: slices.Clone(mimes), used: true, x11: true}
28+ src.alive = func() bool { return s.xsel[kind] == src }
29+ src.cancel = func() {}
30+ src.send = func(mime string, fd int) {
31+ x := s.xwmSel
32+ if x == nil {
33+ return
34+ }
35+ // receive closes its fd after send: the XWM gets its own.
36+ dup, err := unix.FcntlInt(uintptr(fd), unix.F_DUPFD_CLOEXEC, 0)
37+ if err != nil {
38+ return
39+ }
40+ x.SelectionReceive(kind, mime, dup)
41+ }
42+ return src
43+}
44+
45+// setXSelection is the XWM's report: an X client owns kind (mimes), or none
46+// does. A Wayland owner that already replaced the X one is never clobbered.
47+func (s *Server) setXSelection(kind int, mimes []string) {
48+ if len(mimes) == 0 {
49+ cur := s.xsel[kind]
50+ s.xsel[kind] = nil
51+ if cur != nil && s.selections[kind] == cur {
52+ s.setSelection(kind, nil)
53+ }
54+ return
55+ }
56+ src := s.xSource(kind, mimes)
57+ s.xsel[kind] = src
58+ s.setSelection(kind, src)
59+}
60+
61+// exposeToX tells the XWM what Wayland offers. An X source is not exposed
62+// back (it is X's own); nil withdraws.
63+func (s *Server) exposeToX(kind int, src *clipSource) {
64+ if s.xwmSel == nil {
65+ return
66+ }
67+ if src == nil || src.x11 {
68+ s.xwmSel.WaylandSelection(kind, nil)
69+ return
70+ }
71+ s.xwmSel.WaylandSelection(kind, src.mimes)
72+}
73+
74+// xSelectionRead returns a non-blocking read end of the Wayland selection
75+// kind as mime, or -1.
76+func (s *Server) xSelectionRead(kind int, mime string) int {
77+ src := s.selections[kind]
78+ if src == nil || src.x11 || s.protected() || !src.alive() || !slices.Contains(src.mimes, mime) {
79+ return -1
80+ }
81+ var p [2]int
82+ if unix.Pipe2(p[:], unix.O_CLOEXEC) != nil {
83+ return -1
84+ }
85+ if unix.SetNonblock(p[0], true) != nil {
86+ unix.Close(p[0])
87+ unix.Close(p[1])
88+ return -1
89+ }
90+ src.send(mime, p[1])
91+ unix.Close(p[1])
92+ return p[0]
93+}
A internal/adapters/wayland/clipboard_xwm_test.go +147 -0
@@ -0,0 +1,147 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/go-wayland-bindings/server/wayland"
8+ "github.com/stretchr/testify/mock"
9+ "golang.org/x/sys/unix"
10+)
11+
12+func onDisplay(t *testing.T, s *Server, f func()) {
13+ t.Helper()
14+ if !s.display.Do(f) {
15+ t.Fatal("display stopped")
16+ }
17+}
18+
19+// receiveVia asks the offer for mime into a fresh pipe and returns its read end.
20+func receiveVia(t *testing.T, cc *clipClient, offer uint32, mime string) int {
21+ t.Helper()
22+ var fds [2]int
23+ if err := unix.Pipe2(fds[:], unix.O_CLOEXEC); err != nil {
24+ t.Fatal(err)
25+ }
26+ t.Cleanup(func() { unix.Close(fds[0]) })
27+ if err := wireRequest(cc.c, offer, uint16(wayland.DataOfferRequestReceive), []int{fds[1]}, mime); err != nil {
28+ t.Fatal(err)
29+ }
30+ _ = unix.Close(fds[1])
31+ if err := cc.c.Roundtrip(); err != nil {
32+ t.Fatal(err)
33+ }
34+ return fds[0]
35+}
36+
37+// An X selection reaches the focused client and not the others, is replaced
38+// when a Wayland client copies (and is then not read back from X), and is
39+// never read while the session is protected.
40+func TestXSelectionReachesWaylandClients(t *testing.T) {
41+ s, events, commands, dir := keyboardServer(t)
42+ state := installSecurity(t, s)
43+ xm := newMockxwmSelection(t)
44+ onDisplay(t, s, func() { s.xwmSel = xm })
45+ a := newClipClient(t, s, dir, events)
46+ b := newClipClient(t, s, dir, events)
47+ a.focus(t, commands)
48+
49+ // X owns CLIPBOARD: the focused client gets an offer, the other none.
50+ xm.EXPECT().WaylandSelection(selClipboard, []string(nil)).Maybe()
51+ onDisplay(t, s, func() { s.setXSelection(selClipboard, []string{"text/plain;charset=utf-8", "UTF8_STRING"}) })
52+ offer, ok := a.selection(t)
53+ if !ok || offer == 0 {
54+ t.Fatalf("focused client got no X offer: %d %v", offer, ok)
55+ }
56+ if id, ok := b.selection(t); ok {
57+ t.Fatalf("unfocused client got %d", id)
58+ }
59+
60+ // Reading reaches the XWM with a pipe it owns.
61+ got := make(chan string, 1)
62+ xm.EXPECT().SelectionReceive(selClipboard, "UTF8_STRING", mock.Anything).Run(func(_ int, mime string, fd int) {
63+ _, _ = unix.Write(fd, []byte("from x"))
64+ _ = unix.Close(fd)
65+ got <- mime
66+ }).Once()
67+ r := receiveVia(t, a, offer, "UTF8_STRING")
68+ select {
69+ case <-got:
70+ case <-time.After(2 * time.Second):
71+ t.Fatal("XWM never asked to receive")
72+ }
73+ buf := make([]byte, 16)
74+ n, err := unix.Read(r, buf)
75+ if err != nil || string(buf[:n]) != "from x" {
76+ t.Fatalf("pipe: %q %v", buf[:n], err)
77+ }
78+
79+ // Focus follows: b gets the X offer too (focus replay).
80+ if id := b.focus(t, commands); id == 0 {
81+ t.Fatal("no X offer on focus")
82+ }
83+
84+ // A Wayland copy replaces it and is exposed to X.
85+ xm.EXPECT().WaylandSelection(selClipboard, []string{"text/plain"}).Once()
86+ b.copy(t)
87+ if id, ok := b.selection(t); !ok || id == 0 {
88+ t.Fatal("copy gave no offer")
89+ }
90+ // X clearing afterwards never clobbers the Wayland owner.
91+ onDisplay(t, s, func() { s.setXSelection(selClipboard, nil) })
92+ if id, ok := b.selection(t); ok && id == 0 {
93+ t.Fatal("the Wayland selection was cleared by X")
94+ }
95+
96+ // Protection: the X offer is not readable, and X cannot read Wayland.
97+ onDisplay(t, s, func() { s.setXSelection(selClipboard, []string{"UTF8_STRING"}) })
98+ protect(t, s, state, 1, true)
99+ var fd int
100+ onDisplay(t, s, func() { fd = s.xSelectionRead(selClipboard, "text/plain") })
101+ if fd >= 0 {
102+ unix.Close(fd)
103+ t.Fatal("protected selection readable by X")
104+ }
105+ protect(t, s, state, 2, false)
106+}
107+
108+// A Wayland client's text selection is read for X through a pipe it fills.
109+func TestXSelectionReadFromWayland(t *testing.T) {
110+ s, events, commands, dir := keyboardServer(t)
111+ xm := newMockxwmSelection(t)
112+ onDisplay(t, s, func() { s.xwmSel = xm })
113+ a := newClipClient(t, s, dir, events)
114+ a.focus(t, commands)
115+ xm.EXPECT().WaylandSelection(selClipboard, []string(nil)).Maybe() // teardown destroys the source
116+ xm.EXPECT().WaylandSelection(selClipboard, []string{"text/plain"}).Once()
117+ src := a.copy(t)
118+ var fd int
119+ onDisplay(t, s, func() { fd = s.xSelectionRead(selClipboard, "text/plain") })
120+ if fd < 0 {
121+ t.Fatal("no read end")
122+ }
123+ defer unix.Close(fd)
124+ deadline := time.After(2 * time.Second)
125+ for len(src.sends) == 0 {
126+ if err := a.c.Roundtrip(); err != nil {
127+ t.Fatal(err)
128+ }
129+ select {
130+ case <-deadline:
131+ t.Fatal("source never asked to send")
132+ case <-time.After(5 * time.Millisecond):
133+ }
134+ }
135+ buf := make([]byte, 16)
136+ n, err := unix.Read(fd, buf)
137+ if err != nil || string(buf[:n]) != "pasted" {
138+ t.Fatalf("read %q %v", buf[:n], err)
139+ }
140+ onDisplay(t, s, func() {
141+ fd2 := s.xSelectionRead(selClipboard, "image/png")
142+ if fd2 >= 0 {
143+ unix.Close(fd2)
144+ t.Error("unoffered mime readable")
145+ }
146+ })
147+}
@@ -0,0 +1,147 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/go-wayland-bindings/server/wayland"
8+ "github.com/stretchr/testify/mock"
9+ "golang.org/x/sys/unix"
10+)
11+
12+func onDisplay(t *testing.T, s *Server, f func()) {
13+ t.Helper()
14+ if !s.display.Do(f) {
15+ t.Fatal("display stopped")
16+ }
17+}
18+
19+// receiveVia asks the offer for mime into a fresh pipe and returns its read end.
20+func receiveVia(t *testing.T, cc *clipClient, offer uint32, mime string) int {
21+ t.Helper()
22+ var fds [2]int
23+ if err := unix.Pipe2(fds[:], unix.O_CLOEXEC); err != nil {
24+ t.Fatal(err)
25+ }
26+ t.Cleanup(func() { unix.Close(fds[0]) })
27+ if err := wireRequest(cc.c, offer, uint16(wayland.DataOfferRequestReceive), []int{fds[1]}, mime); err != nil {
28+ t.Fatal(err)
29+ }
30+ _ = unix.Close(fds[1])
31+ if err := cc.c.Roundtrip(); err != nil {
32+ t.Fatal(err)
33+ }
34+ return fds[0]
35+}
36+
37+// An X selection reaches the focused client and not the others, is replaced
38+// when a Wayland client copies (and is then not read back from X), and is
39+// never read while the session is protected.
40+func TestXSelectionReachesWaylandClients(t *testing.T) {
41+ s, events, commands, dir := keyboardServer(t)
42+ state := installSecurity(t, s)
43+ xm := newMockxwmSelection(t)
44+ onDisplay(t, s, func() { s.xwmSel = xm })
45+ a := newClipClient(t, s, dir, events)
46+ b := newClipClient(t, s, dir, events)
47+ a.focus(t, commands)
48+
49+ // X owns CLIPBOARD: the focused client gets an offer, the other none.
50+ xm.EXPECT().WaylandSelection(selClipboard, []string(nil)).Maybe()
51+ onDisplay(t, s, func() { s.setXSelection(selClipboard, []string{"text/plain;charset=utf-8", "UTF8_STRING"}) })
52+ offer, ok := a.selection(t)
53+ if !ok || offer == 0 {
54+ t.Fatalf("focused client got no X offer: %d %v", offer, ok)
55+ }
56+ if id, ok := b.selection(t); ok {
57+ t.Fatalf("unfocused client got %d", id)
58+ }
59+
60+ // Reading reaches the XWM with a pipe it owns.
61+ got := make(chan string, 1)
62+ xm.EXPECT().SelectionReceive(selClipboard, "UTF8_STRING", mock.Anything).Run(func(_ int, mime string, fd int) {
63+ _, _ = unix.Write(fd, []byte("from x"))
64+ _ = unix.Close(fd)
65+ got <- mime
66+ }).Once()
67+ r := receiveVia(t, a, offer, "UTF8_STRING")
68+ select {
69+ case <-got:
70+ case <-time.After(2 * time.Second):
71+ t.Fatal("XWM never asked to receive")
72+ }
73+ buf := make([]byte, 16)
74+ n, err := unix.Read(r, buf)
75+ if err != nil || string(buf[:n]) != "from x" {
76+ t.Fatalf("pipe: %q %v", buf[:n], err)
77+ }
78+
79+ // Focus follows: b gets the X offer too (focus replay).
80+ if id := b.focus(t, commands); id == 0 {
81+ t.Fatal("no X offer on focus")
82+ }
83+
84+ // A Wayland copy replaces it and is exposed to X.
85+ xm.EXPECT().WaylandSelection(selClipboard, []string{"text/plain"}).Once()
86+ b.copy(t)
87+ if id, ok := b.selection(t); !ok || id == 0 {
88+ t.Fatal("copy gave no offer")
89+ }
90+ // X clearing afterwards never clobbers the Wayland owner.
91+ onDisplay(t, s, func() { s.setXSelection(selClipboard, nil) })
92+ if id, ok := b.selection(t); ok && id == 0 {
93+ t.Fatal("the Wayland selection was cleared by X")
94+ }
95+
96+ // Protection: the X offer is not readable, and X cannot read Wayland.
97+ onDisplay(t, s, func() { s.setXSelection(selClipboard, []string{"UTF8_STRING"}) })
98+ protect(t, s, state, 1, true)
99+ var fd int
100+ onDisplay(t, s, func() { fd = s.xSelectionRead(selClipboard, "text/plain") })
101+ if fd >= 0 {
102+ unix.Close(fd)
103+ t.Fatal("protected selection readable by X")
104+ }
105+ protect(t, s, state, 2, false)
106+}
107+
108+// A Wayland client's text selection is read for X through a pipe it fills.
109+func TestXSelectionReadFromWayland(t *testing.T) {
110+ s, events, commands, dir := keyboardServer(t)
111+ xm := newMockxwmSelection(t)
112+ onDisplay(t, s, func() { s.xwmSel = xm })
113+ a := newClipClient(t, s, dir, events)
114+ a.focus(t, commands)
115+ xm.EXPECT().WaylandSelection(selClipboard, []string(nil)).Maybe() // teardown destroys the source
116+ xm.EXPECT().WaylandSelection(selClipboard, []string{"text/plain"}).Once()
117+ src := a.copy(t)
118+ var fd int
119+ onDisplay(t, s, func() { fd = s.xSelectionRead(selClipboard, "text/plain") })
120+ if fd < 0 {
121+ t.Fatal("no read end")
122+ }
123+ defer unix.Close(fd)
124+ deadline := time.After(2 * time.Second)
125+ for len(src.sends) == 0 {
126+ if err := a.c.Roundtrip(); err != nil {
127+ t.Fatal(err)
128+ }
129+ select {
130+ case <-deadline:
131+ t.Fatal("source never asked to send")
132+ case <-time.After(5 * time.Millisecond):
133+ }
134+ }
135+ buf := make([]byte, 16)
136+ n, err := unix.Read(fd, buf)
137+ if err != nil || string(buf[:n]) != "pasted" {
138+ t.Fatalf("read %q %v", buf[:n], err)
139+ }
140+ onDisplay(t, s, func() {
141+ fd2 := s.xSelectionRead(selClipboard, "image/png")
142+ if fd2 >= 0 {
143+ unix.Close(fd2)
144+ t.Error("unoffered mime readable")
145+ }
146+ })
147+}
M internal/adapters/wayland/constraints.go +14 -13
@@ -61,11 +61,11 @@ func (h pointerWarp) WarpPointer(r *pointerwarp.WpPointerWarpV1, surf *wayland.S
6161 return
6262 }
6363 w := s.windows[s.seat.pointerFocus]
64- if w == nil || !w.mapped || w.xdg.resource.Client() != r.Client() || s.surfaces[surf.Resource] != w.xdg.surface {
64+ if w == nil || !w.mapped || w.client() != r.Client() || s.surfaces[surf.Resource] != w.surf() {
6565 return
6666 }
6767 // Surface-local to window-local (core's, from the geometry origin).
68- g := w.xdg.geometry
68+ g := w.geometry()
6969 s.emit(ports.PointerWarp{ID: w.id, X: x.Float() - float64(g.X), Y: y.Float() - float64(g.Y)})
7070 }
7171
@@ -80,12 +80,12 @@ func (s *Server) relativeMotion(w *window, c ports.PointerMotionTo) bool {
8080 if s.protected() || c.DX == 0 && c.DY == 0 && c.UnaccelDX == 0 && c.UnaccelDY == 0 {
8181 return false
8282 }
83- if !w.mapped || !w.xdg.resource.Resource.Alive() {
83+ if !w.mapped || !w.alive() {
8484 return false
8585 }
8686 sent := false
8787 usec := uint64(c.Time / time.Microsecond)
88- for _, rel := range s.relatives[w.xdg.resource.Client()] {
88+ for _, rel := range s.relatives[w.client()] {
8989 if rel.Resource.Alive() {
9090 rel.SendRelativeMotion(uint32(usec>>32), uint32(usec), server.FixedFromFloat(c.DX), server.FixedFromFloat(c.DY), server.FixedFromFloat(c.UnaccelDX), server.FixedFromFloat(c.UnaccelDY))
9191 sent = true
@@ -272,7 +272,7 @@ func (s *Server) updateConstraint() {
272272 }
273273 var want *constraint
274274 if w := s.windows[s.seat.pointerFocus]; w != nil && w.mapped && s.seat.pointerFocus == s.seat.focused {
275- if c := s.constraints[w.xdg.surface]; c != nil && !c.defunct && (c.active || c.contains(w)) {
275+ if c := s.constraints[w.surf()]; c != nil && !c.defunct && (c.active || c.contains(w)) {
276276 want = c
277277 }
278278 }
@@ -298,8 +298,8 @@ func (s *Server) emitConstraint(c *constraint) {
298298 if s.protected() {
299299 return
300300 }
301- x := c.surface.xdg
302- if x == nil || x.window == nil {
301+ w := c.surface.toplevelWindow()
302+ if w == nil {
303303 return
304304 }
305305 mode := ports.ConstraintConfine
@@ -308,8 +308,9 @@ func (s *Server) emitConstraint(c *constraint) {
308308 }
309309 r := c.region
310310 if r.W > 0 && r.H > 0 {
311- r.X -= x.geometry.X
312- r.Y -= x.geometry.Y
311+ g := w.geometry()
312+ r.X -= g.X
313+ r.Y -= g.Y
313314 }
314315 // Core supports one confinement rect, so this bounds the intersection;
315316 // contains uses the exact input region to decide activation.
@@ -333,17 +334,17 @@ func (s *Server) emitConstraint(c *constraint) {
333334 r = box
334335 }
335336 }
336- s.emit(ports.PointerConstrained{ID: x.window.id, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}})
337+ s.emit(ports.PointerConstrained{ID: w.id, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}})
337338 }
338339
339340 // contains reports whether the pointer is in the region: a constraint
340341 // activates only there, so it never warps the pointer.
341342 func (c *constraint) contains(w *window) bool {
342343 r := c.region
343- x, y := w.surfacePoint(w.xdg.server.seat.pointerX, w.xdg.server.seat.pointerY)
344+ x, y := w.surfacePoint(c.surface.server.seat.pointerX, c.surface.server.seat.pointerY)
344345 all, rects := c.surface.effectiveInput()
345346 if !all {
346- localX, localY := x-float64(w.xdg.geometry.X), y-float64(w.xdg.geometry.Y)
347+ localX, localY := x-float64(w.geometry().X), y-float64(w.geometry().Y)
347348 inside := false
348349 for _, input := range rects {
349350 if localX >= float64(input.X) && localX < float64(input.X+input.W) && localY >= float64(input.Y) && localY < float64(input.Y+input.H) {
@@ -363,5 +364,5 @@ func (c *constraint) contains(w *window) bool {
363364
364365 // locked reports whether the window's pointer is locked.
365366 func (s *Server) locked(w *window) bool {
366- return !s.protected() && s.constraint != nil && s.constraint.lock != nil && s.constraint.surface == w.xdg.surface
367+ return !s.protected() && s.constraint != nil && s.constraint.lock != nil && s.constraint.surface == w.surf()
367368 }
@@ -61,11 +61,11 @@ func (h pointerWarp) WarpPointer(r *pointerwarp.WpPointerWarpV1, surf *wayland.S
61 return61 return
62 }62 }
63 w := s.windows[s.seat.pointerFocus]63 w := s.windows[s.seat.pointerFocus]
64- if w == nil || !w.mapped || w.xdg.resource.Client() != r.Client() || s.surfaces[surf.Resource] != w.xdg.surface {64+ if w == nil || !w.mapped || w.client() != r.Client() || s.surfaces[surf.Resource] != w.surf() {
65 return65 return
66 }66 }
67 // Surface-local to window-local (core's, from the geometry origin).67 // Surface-local to window-local (core's, from the geometry origin).
68- g := w.xdg.geometry68+ g := w.geometry()
69 s.emit(ports.PointerWarp{ID: w.id, X: x.Float() - float64(g.X), Y: y.Float() - float64(g.Y)})69 s.emit(ports.PointerWarp{ID: w.id, X: x.Float() - float64(g.X), Y: y.Float() - float64(g.Y)})
70 }70 }
71 71
@@ -80,12 +80,12 @@ func (s *Server) relativeMotion(w *window, c ports.PointerMotionTo) bool {
80 if s.protected() || c.DX == 0 && c.DY == 0 && c.UnaccelDX == 0 && c.UnaccelDY == 0 {80 if s.protected() || c.DX == 0 && c.DY == 0 && c.UnaccelDX == 0 && c.UnaccelDY == 0 {
81 return false81 return false
82 }82 }
83- if !w.mapped || !w.xdg.resource.Resource.Alive() {83+ if !w.mapped || !w.alive() {
84 return false84 return false
85 }85 }
86 sent := false86 sent := false
87 usec := uint64(c.Time / time.Microsecond)87 usec := uint64(c.Time / time.Microsecond)
88- for _, rel := range s.relatives[w.xdg.resource.Client()] {88+ for _, rel := range s.relatives[w.client()] {
89 if rel.Resource.Alive() {89 if rel.Resource.Alive() {
90 rel.SendRelativeMotion(uint32(usec>>32), uint32(usec), server.FixedFromFloat(c.DX), server.FixedFromFloat(c.DY), server.FixedFromFloat(c.UnaccelDX), server.FixedFromFloat(c.UnaccelDY))90 rel.SendRelativeMotion(uint32(usec>>32), uint32(usec), server.FixedFromFloat(c.DX), server.FixedFromFloat(c.DY), server.FixedFromFloat(c.UnaccelDX), server.FixedFromFloat(c.UnaccelDY))
91 sent = true91 sent = true
@@ -272,7 +272,7 @@ func (s *Server) updateConstraint() {
272 }272 }
273 var want *constraint273 var want *constraint
274 if w := s.windows[s.seat.pointerFocus]; w != nil && w.mapped && s.seat.pointerFocus == s.seat.focused {274 if w := s.windows[s.seat.pointerFocus]; w != nil && w.mapped && s.seat.pointerFocus == s.seat.focused {
275- if c := s.constraints[w.xdg.surface]; c != nil && !c.defunct && (c.active || c.contains(w)) {275+ if c := s.constraints[w.surf()]; c != nil && !c.defunct && (c.active || c.contains(w)) {
276 want = c276 want = c
277 }277 }
278 }278 }
@@ -298,8 +298,8 @@ func (s *Server) emitConstraint(c *constraint) {
298 if s.protected() {298 if s.protected() {
299 return299 return
300 }300 }
301- x := c.surface.xdg301+ w := c.surface.toplevelWindow()
302- if x == nil || x.window == nil {302+ if w == nil {
303 return303 return
304 }304 }
305 mode := ports.ConstraintConfine305 mode := ports.ConstraintConfine
@@ -308,8 +308,9 @@ func (s *Server) emitConstraint(c *constraint) {
308 }308 }
309 r := c.region309 r := c.region
310 if r.W > 0 && r.H > 0 {310 if r.W > 0 && r.H > 0 {
311- r.X -= x.geometry.X311+ g := w.geometry()
312- r.Y -= x.geometry.Y312+ r.X -= g.X
313+ r.Y -= g.Y
313 }314 }
314 // Core supports one confinement rect, so this bounds the intersection;315 // Core supports one confinement rect, so this bounds the intersection;
315 // contains uses the exact input region to decide activation.316 // contains uses the exact input region to decide activation.
@@ -333,17 +334,17 @@ func (s *Server) emitConstraint(c *constraint) {
333 r = box334 r = box
334 }335 }
335 }336 }
336- s.emit(ports.PointerConstrained{ID: x.window.id, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}})337+ s.emit(ports.PointerConstrained{ID: w.id, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}})
337 }338 }
338 339
339 // contains reports whether the pointer is in the region: a constraint340 // contains reports whether the pointer is in the region: a constraint
340 // activates only there, so it never warps the pointer.341 // activates only there, so it never warps the pointer.
341 func (c *constraint) contains(w *window) bool {342 func (c *constraint) contains(w *window) bool {
342 r := c.region343 r := c.region
343- x, y := w.surfacePoint(w.xdg.server.seat.pointerX, w.xdg.server.seat.pointerY)344+ x, y := w.surfacePoint(c.surface.server.seat.pointerX, c.surface.server.seat.pointerY)
344 all, rects := c.surface.effectiveInput()345 all, rects := c.surface.effectiveInput()
345 if !all {346 if !all {
346- localX, localY := x-float64(w.xdg.geometry.X), y-float64(w.xdg.geometry.Y)347+ localX, localY := x-float64(w.geometry().X), y-float64(w.geometry().Y)
347 inside := false348 inside := false
348 for _, input := range rects {349 for _, input := range rects {
349 if localX >= float64(input.X) && localX < float64(input.X+input.W) && localY >= float64(input.Y) && localY < float64(input.Y+input.H) {350 if localX >= float64(input.X) && localX < float64(input.X+input.W) && localY >= float64(input.Y) && localY < float64(input.Y+input.H) {
@@ -363,5 +364,5 @@ func (c *constraint) contains(w *window) bool {
363 364
364 // locked reports whether the window's pointer is locked.365 // locked reports whether the window's pointer is locked.
365 func (s *Server) locked(w *window) bool {366 func (s *Server) locked(w *window) bool {
366- return !s.protected() && s.constraint != nil && s.constraint.lock != nil && s.constraint.surface == w.xdg.surface367+ return !s.protected() && s.constraint != nil && s.constraint.lock != nil && s.constraint.surface == w.surf()
367 }368 }
M internal/adapters/wayland/decoration.go +1 -1
@@ -80,7 +80,7 @@ func (d *decoration) UnsetMode(r *xdgdecoration.ZxdgToplevelDecorationV1)
8080 func (d *decoration) send(r *xdgdecoration.ZxdgToplevelDecorationV1) {
8181 r.SendConfigure(uint32(xdgdecoration.ZxdgToplevelDecorationV1ModeServerSide))
8282 for _, w := range d.s.windows {
83- if w.toplevel == d.toplevel && w.hasLast && w.toplevel.Resource.Alive() && w.xdg.resource.Resource.Alive() {
83+ if w.toplevel == d.toplevel && w.hasLast && w.toplevelAlive() && w.alive() {
8484 w.sendConfigure()
8585 return
8686 }
@@ -80,7 +80,7 @@ func (d *decoration) UnsetMode(r *xdgdecoration.ZxdgToplevelDecorationV1)
80 func (d *decoration) send(r *xdgdecoration.ZxdgToplevelDecorationV1) {80 func (d *decoration) send(r *xdgdecoration.ZxdgToplevelDecorationV1) {
81 r.SendConfigure(uint32(xdgdecoration.ZxdgToplevelDecorationV1ModeServerSide))81 r.SendConfigure(uint32(xdgdecoration.ZxdgToplevelDecorationV1ModeServerSide))
82 for _, w := range d.s.windows {82 for _, w := range d.s.windows {
83- if w.toplevel == d.toplevel && w.hasLast && w.toplevel.Resource.Alive() && w.xdg.resource.Resource.Alive() {83+ if w.toplevel == d.toplevel && w.hasLast && w.toplevelAlive() && w.alive() {
84 w.sendConfigure()84 w.sendConfigure()
85 return85 return
86 }86 }
M internal/adapters/wayland/dialog.go +1 -1
@@ -77,6 +77,6 @@ func (w *window) setModal(modal bool) {
7777 }
7878 w.modal = modal
7979 if w.mapped {
80- w.xdg.server.emit(ports.WindowModal{ID: w.id, Modal: modal})
80+ w.srv().emit(ports.WindowModal{ID: w.id, Modal: modal})
8181 }
8282 }
@@ -77,6 +77,6 @@ func (w *window) setModal(modal bool) {
77 }77 }
78 w.modal = modal78 w.modal = modal
79 if w.mapped {79 if w.mapped {
80- w.xdg.server.emit(ports.WindowModal{ID: w.id, Modal: modal})80+ w.srv().emit(ports.WindowModal{ID: w.id, Modal: modal})
81 }81 }
82 }82 }
M internal/adapters/wayland/dmabuf.go +3 -2
@@ -223,10 +223,11 @@ func (g *dmabufGlobal) fullscreenOutput(surf *surface) (string, bool) {
223223 return "", false
224224 }
225225 root := toplevelRoot(surf)
226- if root.xdg == nil || root.xdg.window == nil || !root.xdg.window.hasLast || !root.xdg.window.last.Fullscreen || g.server.invisible(surf) {
226+ w := root.toplevelWindow()
227+ if w == nil || !w.hasLast || !w.last.Fullscreen || g.server.invisible(surf) {
227228 return "", false
228229 }
229- return root.xdg.window.last.Output, true
230+ return w.last.Output, true
230231 }
231232
232233 // scanoutFor is the scanout offer for a surface: its window is fullscreen
@@ -223,10 +223,11 @@ func (g *dmabufGlobal) fullscreenOutput(surf *surface) (string, bool) {
223 return "", false223 return "", false
224 }224 }
225 root := toplevelRoot(surf)225 root := toplevelRoot(surf)
226- if root.xdg == nil || root.xdg.window == nil || !root.xdg.window.hasLast || !root.xdg.window.last.Fullscreen || g.server.invisible(surf) {226+ w := root.toplevelWindow()
227+ if w == nil || !w.hasLast || !w.last.Fullscreen || g.server.invisible(surf) {
227 return "", false228 return "", false
228 }229 }
229- return root.xdg.window.last.Output, true230+ return w.last.Output, true
230 }231 }
231 232
232 // scanoutFor is the scanout offer for a surface: its window is fullscreen233 // scanoutFor is the scanout offer for a surface: its window is fullscreen
M internal/adapters/wayland/extforeigntoplevel.go +1 -1
M internal/adapters/wayland/foreigntoplevel.go +6 -6
M internal/adapters/wayland/frames.go +10 -6
M internal/adapters/wayland/globals.go +11 -1
M internal/adapters/wayland/imagecopy.go +1 -1
M internal/adapters/wayland/inhibit.go +5 -0
M internal/adapters/wayland/outputs.go +72 -1
M internal/adapters/wayland/popup.go +2 -2
M internal/adapters/wayland/scale.go +5 -3
M internal/adapters/wayland/screencast.go +1 -1
M internal/adapters/wayland/seat.go +11 -9
M internal/adapters/wayland/server.go +51 -16
M internal/adapters/wayland/surface.go +15 -2
A internal/adapters/wayland/window.go +112 -0
M internal/adapters/wayland/wire_client_test.go +6 -0
A internal/adapters/wayland/x11override_test.go +107 -0
A internal/adapters/wayland/x11peer_test.go +332 -0
A internal/adapters/wayland/x11window.go +458 -0
A internal/adapters/wayland/x11window_test.go +627 -0
M internal/adapters/wayland/xdg.go +32 -21
A internal/adapters/wayland/xdgoutputx_test.go +143 -0
A internal/adapters/wayland/xwayland.go +75 -0
A internal/adapters/wayland/xwayland_test.go +84 -0
A internal/adapters/wayland/xwaylandgrab.go +90 -0
A internal/adapters/wayland/xwm/atoms.go +118 -0
A internal/adapters/wayland/xwm/configure.go +317 -0
A internal/adapters/wayland/xwm/configure_test.go +591 -0
A internal/adapters/wayland/xwm/cursor.go +109 -0
A internal/adapters/wayland/xwm/cursor_test.go +86 -0
A internal/adapters/wayland/xwm/events.go +303 -0
A internal/adapters/wayland/xwm/fdsource_mock_test.go +129 -0
A internal/adapters/wayland/xwm/fixes_test.go +230 -0
A internal/adapters/wayland/xwm/focus.go +218 -0
A internal/adapters/wayland/xwm/host.go +82 -0
A internal/adapters/wayland/xwm/host_mock_test.go +775 -0
A internal/adapters/wayland/xwm/loop_mock_test.go +180 -0
A internal/adapters/wayland/xwm/mapflow.go +359 -0
A internal/adapters/wayland/xwm/mapflow_test.go +806 -0
A internal/adapters/wayland/xwm/props.go +376 -0
A internal/adapters/wayland/xwm/selection.go +1050 -0
A internal/adapters/wayland/xwm/selection_test.go +801 -0
A internal/adapters/wayland/xwm/selection_xsettings.go +198 -0
A internal/adapters/wayland/xwm/space.go +158 -0
A internal/adapters/wayland/xwm/space_test.go +110 -0
A internal/adapters/wayland/xwm/streams_test.go +197 -0
A internal/adapters/wayland/xwm/table.go +242 -0
A internal/adapters/wayland/xwm/table_test.go +138 -0
A internal/adapters/wayland/xwm/testdata/streams/property_unchanged.hex +950 -0
A internal/adapters/wayland/xwm/testdata/streams/steady.hex +2150 -0
A internal/adapters/wayland/xwm/unmanaged.go +109 -0
A internal/adapters/wayland/xwm/unmanaged_test.go +247 -0
A internal/adapters/wayland/xwm/xpeer_test.go +655 -0
A internal/adapters/wayland/xwm/xsettings_test.go +244 -0
A internal/adapters/wayland/xwm/xwm.go +509 -0
A internal/adapters/wayland/xwm/xwm_test.go +387 -0
A internal/adapters/wayland/xwm_selection_mock_test.go +143 -0
A internal/adapters/xwayland/builtin.go +216 -0
A internal/adapters/xwayland/starter_mock_test.go +110 -0
M internal/adapters/xwayland/xwayland.go +46 -18
M internal/adapters/xwayland/xwayland_test.go +207 -0
M internal/app/app.go +13 -6
M internal/app/cursor.go +18 -0
M internal/app/session.go +2 -1
A internal/app/testdata/x11probe/main.go +692 -0
A internal/app/xwayland_test.go +808 -0
M internal/core/client.go +8 -0
M internal/core/core.go +16 -5
M internal/core/core_test.go +52 -3
M internal/core/datadrag.go +1 -1
M internal/core/fullscreen_left_core_test.go +1 -6
M internal/core/fullscreen_left_test.go +29 -0
M internal/core/layout.go +7 -1
A internal/core/place_test.go +80 -0
M internal/core/rules_test.go +44 -0
M internal/core/scene.go +12 -1
M internal/core/spawn_placement.go +9 -1
M internal/core/transition.go +20 -0
A internal/core/unmanaged.go +117 -0
A internal/core/unmanaged_test.go +145 -0
M internal/ports/client_commands.go +10 -0
M internal/ports/client_events.go +37 -0
M internal/ports/config.go +35 -3

Comments0 open / 0

no comments yet.

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close