detached 348689c

refactor: consolidate core capture, output reports, HDR state and ports layout (#78)

* refactor(core): share output and focus sync between publish paths

* refactor(ports): split channel messages into per-feature files

* style(wayland): list Server fields one per line

* refactor(output): share the presented report queue between drm and headless

* refactor(core): own capture sessions, flashes and timer in captureState

* refactor(drm): own HDR signal state and metadata blob lifecycle in hdrState

* test(drm): cover HDR metadata blob swap

* refactor(drm): move fence holds, VRR, images and reports out of output.go

* fix(drm,core): stop shadowing the presented package and skip clock reads without flashes

* test(wayland): wait for applied commands and forwarded events instead of fixed polls

* fix(xkb): pass name buffers to libxkbcommon as typed pointers

Stack buffers passed as uintptr may move during a stack growth while C writes into them, so a keysym name read back empty. Typed pointers make them escape to the heap.
commit
348689cb94a89607b10db7268767565b77e33287
author
bnema · · 2026-10-01 16:07 UTC
committer
GitHub · · 2026-10-01 16:07 UTC
parent
d79bf75
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
43 files · +2485 -2116
M .hexcheck.yaml +1 -1
@@ -26,7 +26,7 @@ allow:
2626 ruleSettings:
2727 missing-generated-mock-for-port:
2828 # Sealed message unions are channel data, not ports; tested by sending messages (ADR 005).
29- excludePaths: [internal/ports/messages.go, internal/ports/session_lock_messages.go, internal/ports/session_backend.go]
29+ excludePaths: [internal/ports/client_events.go, internal/ports/client_commands.go, internal/ports/input_events.go, internal/ports/output_events.go, internal/ports/lease.go, internal/ports/session_lock_messages.go, internal/ports/session_backend.go]
3030 suspicious-business-logic-in-adapter:
3131 # app is the composition root; starting core and wiring channels is its job.
3232 excludePaths: [internal/app/app.go]
M AGENTS.md +1 -1
@@ -17,7 +17,7 @@ Test doubles are always generated by Mockery v3 (`testify` template). Never hand
1717 - Adapter-internal seams (e.g. a kernel ioctl) stay unexported in the adapter. Mocks: `<name>_mock_test.go` in the same package.
1818 - **Add a `.mockery.yml` entry** for each new interface, run `make mocks` and commit the generated files. `make mocks-check` (CI) fails if they are stale or missing.
1919 - Use mocks through `.EXPECT()`. Record calls with `RunAndReturn` when a test inspects arguments.
20-- Channel ports (sealed message interfaces in `ports/messages.go`) are tested by sending messages, not with mocks.
20+- Channel ports (sealed message interfaces such as `ports.ClientEvent` or `ports.OutputEvent`) are tested by sending messages, not with mocks.
2121 - Not doubles, so allowed:
2222 - real protocol clients in wayland tests (`wlturbo` proxies);
2323 - real in-memory implementations of std interfaces (e.g. a locked `bytes.Buffer` as `io.Writer`);
M internal/adapters/drm/amdgpu_rules_test.go +2 -2
@@ -165,8 +165,8 @@ func amdgpuRuleWith(t *testing.T, live func(uint32) bool) func(*atomicReq, uint3
165165 }
166166
167167 func enableTestHDR(o *Output) {
168- o.hdrOn, o.hdrBlob = true, 55
169- o.hdrProps = connectorHDRProps{Metadata: pHDRMeta, Colorspace: pColorspace, MaxBPC: pMaxBPC, BT2020Value: colorspaceBT2020, MaxBPCValue: 8, HasDefault: true}
168+ o.hdr.on, o.hdr.blob = true, 55
169+ o.hdr.props = connectorHDRProps{Metadata: pHDRMeta, Colorspace: pColorspace, MaxBPC: pMaxBPC, BT2020Value: colorspaceBT2020, MaxBPCValue: 8, HasDefault: true}
170170 }
171171
172172 func TestAmdgpuRuleRejectsWhatHardwareRejected(t *testing.T) {
M internal/adapters/drm/capture_test.go +7 -7
@@ -263,10 +263,10 @@ func TestReportCapsPerWindowWhileChildReads(t *testing.T) {
263263 seen := map[ports.WindowID]uint64{1: 9, 5: 7} // window 1 belongs to the display
264264 o.report(nil, seen)
265265 require.True(t, o.capped, "an outstanding child read is reported repeatedly")
266- require.Equal(t, map[ports.WindowID]uint64{1: 9, 5: 2}, o.unsent[0].Seen)
266+ require.Equal(t, map[ports.WindowID]uint64{1: 9, 5: 2}, o.reports.Unsent()[0].Seen)
267267 require.Equal(t, uint64(7), seen[5], "the caller's map is untouched")
268- require.Equal(t, map[ports.WindowID]uint64{5: 2}, o.unsent[0].ChildReads)
269- previous := o.unsent[0].ChildReads
268+ require.Equal(t, map[ports.WindowID]uint64{5: 2}, o.reports.Unsent()[0].ChildReads)
269+ previous := o.reports.Unsent()[0].ChildReads
270270 allocs := testing.AllocsPerRun(100, func() { o.report(nil, seen) })
271271 require.LessOrEqual(t, allocs, float64(1), "only the fence poll may allocate")
272272 t.Logf("unchanged child report: %.1f allocs", allocs)
@@ -275,8 +275,8 @@ func TestReportCapsPerWindowWhileChildReads(t *testing.T) {
275275 require.NoError(t, err)
276276 o.report(nil, seen)
277277 require.False(t, o.capped)
278- require.Equal(t, map[ports.WindowID]uint64{1: 9, 5: 7}, o.unsent[0].Seen, "limit lifted")
279- require.Empty(t, o.unsent[0].ChildReads)
278+ require.Equal(t, map[ports.WindowID]uint64{1: 9, 5: 7}, o.reports.Unsent()[0].Seen, "limit lifted")
279+ require.Empty(t, o.reports.Unsent()[0].ChildReads)
280280 require.Equal(t, map[ports.WindowID]uint64{5: 2}, previous, "sent snapshot remains immutable")
281281 pipeline.Close(nil)
282282 }
@@ -316,8 +316,8 @@ func TestHiddenWorkspaceChildKeepsDirectScanout(t *testing.T) {
316316 require.NoError(t, err)
317317 require.True(t, direct, "the displayed fullscreen window stays scanned out")
318318 require.True(t, o.frame.pendingCommit(), "display flip has not completed")
319- require.Equal(t, map[ports.WindowID]uint64{5: 0}, o.unsent[0].ChildReads, "child hold published before the display flip")
320- require.Empty(t, o.unsent[0].Seen, "display reads are not advanced early")
319+ require.Equal(t, map[ports.WindowID]uint64{5: 0}, o.reports.Unsent()[0].ChildReads, "child hold published before the display flip")
320+ require.Empty(t, o.reports.Unsent()[0].Seen, "display reads are not advanced early")
321321 require.NoError(t, (<-replies).Err)
322322 _, err = writeEnd.Write([]byte{1})
323323 require.NoError(t, err)
M internal/adapters/drm/card.go +1 -1
@@ -281,5 +281,5 @@ func normalizedHDRSettings(settings HDRSettings) HDRSettings {
281281 // outputNeedsRestart applies the same replacement path to mode and HDR
282282 // configuration changes; Release drops the old output before reopening it.
283283 func outputNeedsRestart(o *Output, mode modeInfo, settings HDRSettings) bool {
284- return o.mode != mode || o.hdrSettings != normalizedHDRSettings(settings)
284+ return o.mode != mode || o.hdr.settings != normalizedHDRSettings(settings)
285285 }
M internal/adapters/drm/frame_submit.go +1 -1
@@ -67,7 +67,7 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
6767 // past the stale-report timeout. Do not advance the display's Seen
6868 // until its own GPU work finishes.
6969 if o.capHidden != nil {
70- o.report(nil, o.seenSnapshot)
70+ o.report(nil, o.reports.Seen())
7171 }
7272 clear(hidden)
7373 }
M internal/adapters/drm/hdr.go +82 -2
@@ -43,13 +43,93 @@ func maxFrameAverage(h HDRMetadata) float64 {
4343 return h.MaxLuminance
4444 }
4545
46+// hdrState is an output's HDR10 signal: what the display can do, what the
47+// config asks, and the metadata blob KMS shows. The output goroutine owns it.
48+type hdrState struct {
49+ cap hdrCapability
50+ props connectorHDRProps
51+ settings HDRSettings
52+ // failed disables retries until this Output is replaced; on is the
53+ // currently selected signal encoding, including on VT resume.
54+ on, failed bool
55+ // blob is the live metadata blob and blobData its content. A blob stays
56+ // alive while a modeset may still show it.
57+ blob uint32
58+ blobData hdrOutputMetadata
59+}
60+
61+// wanted reports whether the next modeset should try HDR10.
62+func (h *hdrState) wanted() bool {
63+ return h.settings.Enabled && h.cap.Capable && !h.failed
64+}
65+
66+// shown reports whether an HDR modeset may still be on screen: a live blob
67+// counts even after a failed SDR fallback (on is then false).
68+func (h *hdrState) shown() bool { return h.on || h.blob != 0 }
69+
70+// hdrBlobSwap is a metadata blob replacement waiting for its modeset.
71+type hdrBlobSwap struct {
72+ old uint32
73+ meta hdrOutputMetadata
74+ created bool
75+}
76+
77+// prepareBlob makes h.blob carry meta. The live blob is reused when its
78+// content matches (VT resume); otherwise a replacement is created and the
79+// old one stays alive until the swap is committed or rolled back.
80+func (h *hdrState) prepareBlob(k kms, meta hdrOutputMetadata) (hdrBlobSwap, error) {
81+ sw := hdrBlobSwap{old: h.blob, meta: meta}
82+ if sw.old != 0 && meta == h.blobData {
83+ return sw, nil
84+ }
85+ blob, err := k.createBlob(meta.bytes())
86+ if err != nil {
87+ return sw, err
88+ }
89+ h.blob, sw.created = blob, true
90+ return sw, nil
91+}
92+
93+// commitBlob runs once KMS accepted the modeset: the old blob is freed.
94+func (h *hdrState) commitBlob(k kms, sw hdrBlobSwap) {
95+ if !sw.created {
96+ return
97+ }
98+ h.blobData = sw.meta
99+ if sw.old != 0 {
100+ _ = k.destroyBlob(sw.old)
101+ }
102+}
103+
104+// rollbackBlob restores the old blob after a refused modeset: the existing
105+// modeset may still use it. Without an old blob the new one stays live.
106+func (h *hdrState) rollbackBlob(k kms, sw hdrBlobSwap) {
107+ if !sw.created {
108+ return
109+ }
110+ if sw.old != 0 {
111+ _ = k.destroyBlob(h.blob)
112+ h.blob = sw.old
113+ return
114+ }
115+ h.blobData = sw.meta
116+}
117+
118+// releaseBlob frees the blob once no modeset can show it.
119+func (h *hdrState) releaseBlob(k kms) {
120+ if h.blob != 0 {
121+ _ = k.destroyBlob(h.blob)
122+ }
123+ h.blob, h.blobData = 0, hdrOutputMetadata{}
124+}
125+
46126 // hdrConnectorProps sets the connector's signal metadata on a modeset or restore.
47127 func (o *Output) hdrConnectorProps(req *atomicReq, enabled bool) {
48- p := o.hdrProps
128+ p := o.hdr.props
49129 if enabled {
50130 req.set(o.conn.id, p.MaxBPC, 10)
51131 req.set(o.conn.id, p.Colorspace, p.BT2020Value)
52- req.set(o.conn.id, p.Metadata, uint64(o.hdrBlob))
132+ req.set(o.conn.id, p.Metadata, uint64(o.hdr.blob))
53133 return
54134 }
55135 req.set(o.conn.id, p.MaxBPC, p.MaxBPCValue)
M internal/adapters/drm/hdr_test.go +74 -30
@@ -33,15 +33,15 @@ func TestHDRMetadataLayoutAndContent(t *testing.T) {
3333
3434 func TestHDRModesetPropertiesAndPlaneRefusal(t *testing.T) {
3535 o, _, _ := testOutput(t)
36- o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, DefaultValue: 0, HasDefault: true, MaxBPCValue: 8}
37- o.hdrBlob, o.hdrOn = 321, true
36+ o.hdr.props = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, DefaultValue: 0, HasDefault: true, MaxBPCValue: 8}
37+ o.hdr.blob, o.hdr.on = 321, true
3838 for _, tc := range []struct {
3939 on bool
4040 bpc, colorspace, metadata uint64
4141 }{
4242 {true, 10, 7, 321}, {false, 8, 0, 0},
4343 } {
44- o.hdrOn = tc.on
44+ o.hdr.on = tc.on
4545 req := o.modesetReq(11, true)
4646 for _, prop := range []struct {
4747 id uint32
@@ -52,7 +52,7 @@ func TestHDRModesetPropertiesAndPlaneRefusal(t *testing.T) {
5252 }
5353 }
5454 }
55- o.hdrOn = true
55+ o.hdr.on = true
5656 if fb, _ := o.scanoutFrame(ports.Scene{}, nil); fb != 0 || o.reason != "no_fullscreen" {
5757 t.Fatalf("scanout: fb %d reason %q", fb, o.reason)
5858 }
@@ -60,14 +60,14 @@ func TestHDRModesetPropertiesAndPlaneRefusal(t *testing.T) {
6060 t.Fatalf("overlay: fb %d reason %q", ov.fb, o.overlayReason)
6161 }
6262 // Colorspace property without a Default enum must not receive invented 0.
63- o.hdrOn = false
64- o.hdrProps.HasDefault = false
65- if _, ok := o.modesetReq(11, true).value(o.conn.id, o.hdrProps.Colorspace); ok {
63+ o.hdr.on = false
64+ o.hdr.props.HasDefault = false
65+ if _, ok := o.modesetReq(11, true).value(o.conn.id, o.hdr.props.Colorspace); ok {
6666 t.Fatal("SDR set Colorspace with no Default enum")
6767 }
6868 restore := &atomicReq{}
6969 o.hdrConnectorProps(restore, false)
70- if _, ok := restore.value(o.conn.id, o.hdrProps.Colorspace); ok {
70+ if _, ok := restore.value(o.conn.id, o.hdr.props.Colorspace); ok {
7171 t.Fatal("restore set Colorspace with no Default enum")
7272 }
7373 }
@@ -75,9 +75,9 @@ func TestHDRModesetPropertiesAndPlaneRefusal(t *testing.T) {
7575 func TestHDRTestCommitFallbackReexportsSDR(t *testing.T) {
7676 o, k, commits := testOutput(t, unix.EINVAL, unix.EINVAL)
7777 o.cursor = nil
78- o.hdr = hdrCapability{Capable: true}
79- o.hdrSettings = HDRSettings{Enabled: true, SDRBrightness: 203}
80- o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}
78+ o.hdr.cap = hdrCapability{Capable: true}
79+ o.hdr.settings = HDRSettings{Enabled: true, SDRBrightness: 203}
80+ o.hdr.props = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}
8181 o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 0}}
8282 r := portsmocks.NewMockRenderer(t)
8383 buf := func() ports.DMABuf {
@@ -105,8 +105,8 @@ func TestHDRTestCommitFallbackReexportsSDR(t *testing.T) {
105105 if !slices.Equal(calls, []string{"hdr", "sdr", "drop"}) {
106106 t.Fatalf("fallback order: %v", calls)
107107 }
108- if o.hdrOn || !o.hdrFailed {
109- t.Fatalf("HDR on %t failed %t", o.hdrOn, o.hdrFailed)
108+ if o.hdr.on || !o.hdr.failed {
109+ t.Fatalf("HDR on %t failed %t", o.hdr.on, o.hdr.failed)
110110 }
111111 if len(*commits) != 4 {
112112 t.Fatalf("commits: %d", len(*commits))
@@ -137,11 +137,11 @@ func TestHDRSettingsRestartComparison(t *testing.T) {
137137 {HDRSettings{Enabled: true, SDRBrightness: 350}, true},
138138 {HDRSettings{SDRBrightness: ports.DefaultSDRBrightness}, true},
139139 } {
140- if restart := outputNeedsRestart(&Output{hdrSettings: current}, modeInfo{}, tc.settings); restart != tc.restart {
140+ if restart := outputNeedsRestart(&Output{hdr: hdrState{settings: current}}, modeInfo{}, tc.settings); restart != tc.restart {
141141 t.Fatalf("settings %+v: restart %t, want %t", tc.settings, restart, tc.restart)
142142 }
143143 }
144- if !outputNeedsRestart(&Output{hdrSettings: current}, modeInfo{VRefresh: 60}, current) {
144+ if !outputNeedsRestart(&Output{hdr: hdrState{settings: current}}, modeInfo{VRefresh: 60}, current) {
145145 t.Fatal("mode change did not restart")
146146 }
147147 }
@@ -149,9 +149,9 @@ func TestHDRSettingsRestartComparison(t *testing.T) {
149149 func TestHDRSuccessfulModeset(t *testing.T) {
150150 o, k, commits := testOutput(t)
151151 o.cursor = nil
152- o.hdr = hdrCapability{Capable: true}
153- o.hdrSettings = HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness}
154- o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}
152+ o.hdr.cap = hdrCapability{Capable: true}
153+ o.hdr.settings = HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness}
154+ o.hdr.props = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}
155155 o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 19}}
156156 r := portsmocks.NewMockRenderer(t)
157157 buf := func() ports.DMABuf {
@@ -170,8 +170,8 @@ func TestHDRSuccessfulModeset(t *testing.T) {
170170 if err := o.showImages(r, imagesDriver, nil); err != nil {
171171 t.Fatal(err)
172172 }
173- if !o.hdrOn || o.hdrFailed || len(*commits) != 2 {
174- t.Fatalf("on=%t failed=%t commits=%d", o.hdrOn, o.hdrFailed, len(*commits))
173+ if !o.hdr.on || o.hdr.failed || len(*commits) != 2 {
174+ t.Fatalf("on=%t failed=%t commits=%d", o.hdr.on, o.hdr.failed, len(*commits))
175175 }
176176 for _, c := range *commits {
177177 for _, tc := range []struct {
@@ -190,9 +190,9 @@ func TestHDRSuccessfulModeset(t *testing.T) {
190190 func TestHDRBlobReusedAcrossImageSetup(t *testing.T) {
191191 o, k, commits := testOutput(t)
192192 o.cursor = nil
193- o.hdr = hdrCapability{Capable: true}
194- o.hdrSettings = HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness}
195- o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}
193+ o.hdr.cap = hdrCapability{Capable: true}
194+ o.hdr.settings = HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness}
195+ o.hdr.props = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}
196196 o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 19}}
197197 r := portsmocks.NewMockRenderer(t)
198198 buf := func() ports.DMABuf {
@@ -224,8 +224,8 @@ func TestHDRBlobReusedAcrossImageSetup(t *testing.T) {
224224 if err := o.showImages(r, imagesDriver, nil); err != nil {
225225 t.Fatal(err)
226226 }
227- if o.hdrBlob != 321 || created != 1 || destroyed != 0 {
228- t.Fatalf("pass %d blob %d created %d destroyed %d", i, o.hdrBlob, created, destroyed)
227+ if o.hdr.blob != 321 || created != 1 || destroyed != 0 {
228+ t.Fatalf("pass %d blob %d created %d destroyed %d", i, o.hdr.blob, created, destroyed)
229229 }
230230 }
231231 if len(*commits) != 4 {
@@ -240,9 +240,9 @@ func TestHDRBlobReusedAcrossImageSetup(t *testing.T) {
240240 func TestHDRBlobReplacementWaitsForCommit(t *testing.T) {
241241 o, k, _ := testOutput(t)
242242 o.cursor = nil
243- o.hdr = hdrCapability{Capable: true}
244- o.hdrSettings = HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness}
245- o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}
243+ o.hdr.cap = hdrCapability{Capable: true}
244+ o.hdr.settings = HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness}
245+ o.hdr.props = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}
246246 o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 19}}
247247 r := portsmocks.NewMockRenderer(t)
248248 buf := func() ports.DMABuf {
@@ -278,11 +278,55 @@ func TestHDRBlobReplacementWaitsForCommit(t *testing.T) {
278278 if err := o.showImages(r, imagesDriver, nil); err != nil {
279279 t.Fatal(err)
280280 }
281- if o.hdrBlob != 322 || !slices.Equal(destroyed, []uint32{321}) {
282- t.Fatalf("blob %d destroyed %v", o.hdrBlob, destroyed)
281+ if o.hdr.blob != 322 || !slices.Equal(destroyed, []uint32{321}) {
282+ t.Fatalf("blob %d destroyed %v", o.hdr.blob, destroyed)
283283 }
284284 o.Close()
285285 if !slices.Equal(destroyed, []uint32{321, 322}) {
286286 t.Fatalf("destroyed %v", destroyed)
287287 }
288288 }
289+
290+// The metadata blob is reused when unchanged, and a replacement keeps the
291+// old blob alive until its modeset is committed or rolled back.
292+func TestHDRBlobSwap(t *testing.T) {
293+ k := newMockkms(t)
294+ meta := hdrMetadata(Monitor{HDR: HDRMetadata{MaxLuminance: 1000}})
295+ other := hdrMetadata(Monitor{HDR: HDRMetadata{MaxLuminance: 600}})
296+ k.EXPECT().createBlob(mock.Anything).Return(7, nil).Once()
297+ var h hdrState
298+ sw, err := h.prepareBlob(k, meta)
299+ if err != nil || !sw.created || h.blob != 7 {
300+ t.Fatalf("first blob: %v %+v %d", err, sw, h.blob)
301+ }
302+ h.commitBlob(k, sw)
303+
304+ // Unchanged metadata (VT resume): no new blob.
305+ if sw, err = h.prepareBlob(k, meta); err != nil || sw.created || h.blob != 7 {
306+ t.Fatalf("reuse: %v %+v %d", err, sw, h.blob)
307+ }
308+
309+ // A refused replacement restores the old blob and frees the new one.
310+ k.EXPECT().createBlob(mock.Anything).Return(8, nil).Once()
311+ k.EXPECT().destroyBlob(uint32(8)).Return(nil).Once()
312+ sw, _ = h.prepareBlob(k, other)
313+ h.rollbackBlob(k, sw)
314+ if h.blob != 7 || h.blobData != meta {
315+ t.Fatalf("rollback: blob %d", h.blob)
316+ }
317+
318+ // An accepted replacement frees the old blob.
319+ k.EXPECT().createBlob(mock.Anything).Return(9, nil).Once()
320+ k.EXPECT().destroyBlob(uint32(7)).Return(nil).Once()
321+ sw, _ = h.prepareBlob(k, other)
322+ h.commitBlob(k, sw)
323+ if h.blob != 9 || h.blobData != other {
324+ t.Fatalf("commit: blob %d", h.blob)
325+ }
326+
327+ k.EXPECT().destroyBlob(uint32(9)).Return(nil).Once()
328+ h.releaseBlob(k)
329+ if h.blob != 0 || h.shown() {
330+ t.Fatalf("release: blob %d", h.blob)
331+ }
332+}
A internal/adapters/drm/images.go +193 -0
@@ -0,0 +1,193 @@
1+package drm
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "slices"
9+
10+ "github.com/bnema/neferwl/internal/adapters/syncfile"
11+ "github.com/bnema/neferwl/internal/ports"
12+)
13+
14+// Output images: the renderer targets frames alternate between.
15+
16+// imageKind is how output images are made, best first.
17+type imageKind int
18+
19+const (
20+ imagesDriver imageKind = iota // exported, modifier chosen by the driver
21+ imagesLinear // exported, linear
22+)
23+
24+// showImages sets up images from kind on and modesets them. KMS may refuse
25+// an image only in a modeset: a TEST_ONLY modeset checks it first, and the
26+// next kind is tried on refusal. cause is why the
27+// previous kind failed, for the log.
28+func (o *Output) showImages(r ports.Renderer, kind imageKind, cause error) error {
29+ if o.hdr.wanted() {
30+ o.hdr.on = true
31+ // Reuse the live blob across VT resume. A changed EDID creates a
32+ // replacement, but the old one remains alive until KMS accepts it.
33+ sw, err := o.hdr.prepareBlob(o.k, hdrMetadata(o.monitor))
34+ if err == nil {
35+ r.SetHDR(float64(o.hdr.settings.SDRBrightness))
36+ err = o.showImageKind(r, imagesDriver, nil)
37+ }
38+ var pe protectedCommitError
39+ if errors.As(err, &pe) {
40+ // A protected KMS failure is not an HDR failure: images exist and
41+ // the owner retries the protected modeset. Never fall back to SDR
42+ // or latch hdr.failed for it.
43+ o.hdr.rollbackBlob(o.k, sw)
44+ return err
45+ }
46+ if err == nil {
47+ o.hdr.commitBlob(o.k, sw)
48+ o.log.Info().Str("connector", o.conn.name).Int("sdr_brightness", o.hdr.settings.SDRBrightness).Float64("max_luminance", o.hdr.cap.MaxLuminance).Float64("max_frame_average", o.hdr.cap.MaxFrameAverage).Msg("HDR10 on")
49+ return nil
50+ }
51+ if sw.old != 0 {
52+ // Failed replacement: the existing modeset may still use it.
53+ o.hdr.rollbackBlob(o.k, sw)
54+ }
55+ o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("HDR modeset unavailable; falling back to SDR")
56+ o.hdr.failed = true
57+ o.hdr.on = false
58+ o.freeImages()
59+ r.SetHDR(0)
60+ _, _ = r.ExportTargets(0, nil)
61+ // A failed test commit can leave the old HDR mode on screen;
62+ // retain its blob until the SDR modeset succeeds or Close restores it.
63+ } else {
64+ r.SetHDR(0)
65+ }
66+ err := o.showImageKind(r, kind, cause)
67+ if err == nil && o.hdr.failed && o.hdr.blob != 0 {
68+ // The SDR commit has completed; the old HDR metadata is no longer in use.
69+ o.hdr.releaseBlob(o.k)
70+ }
71+ return err
72+}
73+
74+func (o *Output) showImageKind(r ports.Renderer, kind imageKind, cause error) error {
75+ for {
76+ got, err := o.setupImages(r, kind, cause)
77+ if err != nil {
78+ return err
79+ }
80+ o.kind = got
81+ if err = o.testModeset(); err == nil {
82+ err = o.modeset()
83+ if err != nil && o.protected && !errors.Is(err, errSecurityScene) && !errors.Is(err, errProtectionPending) && !errors.As(err, new(protectedCommitError)) {
84+ err = protectedCommitError{err}
85+ }
86+ // A protected failure is not an image refusal: never fall back to
87+ // another image kind or SDR for it. The caller hands it to the
88+ // bounded retry (commitFailed).
89+ return err
90+ }
91+ if o.protected && !refused(err) {
92+ // EBUSY, lost master or a blob failure of the test: the images
93+ // are fine; retry the protected modeset later.
94+ return protectedCommitError{err}
95+ }
96+ if !refused(err) || got == imagesLinear {
97+ o.freeImages()
98+ return err
99+ }
100+ o.log.Warn().Err(err).Str("connector", o.conn.name).Int("kind", int(got)).Msg("modeset refused the output images")
101+ o.freeImages()
102+ kind, cause = got+1, err
103+ }
104+}
105+
106+// setupImages gives the output the two renderer images it flips between,
107+// exported as dmabufs. There is no CPU fallback (ADR 014): a GPU that
108+// cannot export a scanout-capable image cannot drive the output.
109+func (o *Output) setupImages(r ports.Renderer, kind imageKind, cause error) (imageKind, error) {
110+ err := cause
111+ for ; kind <= imagesLinear; kind++ {
112+ if o.runContext != nil && o.runContext.Err() != nil {
113+ return kind, o.runContext.Err()
114+ }
115+ mods := []uint64(nil)
116+ if o.hdr.on {
117+ for _, f := range o.primary.formats {
118+ if f.Format == fourccXR30 {
119+ mods = append(mods, f.Modifier)
120+ }
121+ }
122+ if len(mods) == 0 {
123+ return kind, fmt.Errorf("primary plane has no XRGB2101010 modifiers")
124+ }
125+ if kind == imagesLinear {
126+ if !slices.Contains(mods, uint64(0)) {
127+ continue
128+ }
129+ mods = []uint64{0}
130+ }
131+ } else if kind == imagesLinear {
132+ mods = []uint64{0}
133+ }
134+ if err = o.exportImages(r, mods); err == nil {
135+ return kind, nil
136+ }
137+ o.log.Info().Err(err).Str("connector", o.conn.name).Int("kind", int(kind)).Msg("output image export")
138+ }
139+ return imagesLinear, fmt.Errorf("%s: GPU cannot export scanout images (ADR 014): %w", o.conn.name, err)
140+}
141+
142+// exportImages makes the renderer's exported targets the output images.
143+func (o *Output) exportImages(r ports.Renderer, mods []uint64) error {
144+ bufs, err := r.ExportTargets(len(o.fbs), mods)
145+ if err != nil {
146+ return err
147+ }
148+ for i := range bufs {
149+ if err == nil {
150+ format := uint32(fourccXRGB)
151+ if o.hdr.on {
152+ format = fourccXR30
153+ }
154+ o.fbs[i], err = o.k.addFB(&bufs[i], format)
155+ }
156+ bufs[i].Planes[0].File.Close()
157+ }
158+ // GPU memory starts undefined (old VRAM contents): clear both images
159+ // before the modeset shows one.
160+ for i := range o.fbs {
161+ if err != nil {
162+ break
163+ }
164+ r.UseTarget(i)
165+ var done *os.File
166+ if done, err = r.Render(ports.Scene{Background: "#000000"}, nil); done != nil {
167+ // The modeset is a blocking commit: wait for the clear.
168+ waitCtx := o.runContext
169+ if waitCtx == nil {
170+ waitCtx = context.Background()
171+ }
172+ err = errors.Join(err, syncfile.Wait(waitCtx, done))
173+ done.Close()
174+ }
175+ }
176+ if err != nil {
177+ o.freeImages()
178+ _, _ = r.ExportTargets(0, nil)
179+ return err
180+ }
181+ o.log.Info().Str("connector", o.conn.name).Uint64("modifier", bufs[0].Modifier).Msg("zero-copy output")
182+ return nil
183+}
184+
185+// freeImages removes the output images' framebuffers.
186+func (o *Output) freeImages() {
187+ for i, fb := range o.fbs {
188+ if fb != 0 {
189+ _ = o.k.rmFB(fb)
190+ }
191+ o.fbs[i] = 0
192+ }
193+}
M internal/adapters/drm/output.go +31 -518
@@ -4,7 +4,6 @@ import (
44 "context"
55 "errors"
66 "fmt"
7- "maps"
87 "os"
98 "slices"
109 "time"
@@ -12,8 +11,8 @@ import (
1211
1312 "github.com/bnema/neferwl/internal/adapters/capture"
1413 "github.com/bnema/neferwl/internal/adapters/clock"
14+ "github.com/bnema/neferwl/internal/adapters/presented"
1515
16- "github.com/bnema/neferwl/internal/adapters/syncfile"
1716 "github.com/bnema/neferwl/internal/ports"
1817 "github.com/bnema/zerowrap"
1918 "golang.org/x/sys/unix"
@@ -37,22 +36,15 @@ type Output struct {
3736 inactiveOnClose bool // affirmative terminal KMS result, owned by Close
3837 runContext context.Context // bounds compositor clear waits during Run startup
3938
40- k kms
41- flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents
42- crtc uint32
43- conn connector
44- mode modeInfo
45- saved modeCrtc
46- monitor Monitor
47- hdr hdrCapability
48- hdrProps connectorHDRProps
49- hdrSettings HDRSettings
50- // hdrFailed disables retries until this Output is replaced; hdrOn is
51- // the currently selected signal encoding, including on VT resume.
52- hdrOn, hdrFailed bool
53- hdrBlob uint32
54- hdrBlobData hdrOutputMetadata
55- log zerowrap.Logger
39+ k kms
40+ flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents
41+ crtc uint32
42+ conn connector
43+ mode modeInfo
44+ saved modeCrtc
45+ monitor Monitor
46+ hdr hdrState
47+ log zerowrap.Logger
5648 // clock paces the periodic stats and renderer trim (nil: system).
5749 clock ports.Clock
5850 // Properties: CRTC and connector property IDs by name.
@@ -98,11 +90,9 @@ type Output struct {
9890 shown, queued uint64
9991 planeRect planeRect
10092 reason string // why the last frame was composed ("" = scanout)
101- unsent []ports.OutputPresented
102- // Last immutable snapshot sent in a report. Never mutate it: wayland may
103- // still be reading a prior report on another goroutine.
104- seenSnapshot map[ports.WindowID]uint64
105- childReadsSnapshot map[ports.WindowID]uint64
93+ reports presented.Queue
94+ // Last immutable shows snapshot sent in a flip. Never mutate it: wayland
95+ // may still be reading a prior report on another goroutine.
10696 showsSnapshot map[ports.WindowID]uint64
10797 directShowsSnapshot map[ports.WindowID]uint64
10898 showsScratch map[ports.WindowID]uint64
@@ -163,91 +153,6 @@ type Output struct {
163153 // Ready reports the result of the first modeset exactly once.
164154 func (o *Output) Ready() <-chan error { return o.ready }
165155
166-// dupFences duplicates the non-nil fences.
167-func dupFences(fs ...*os.File) []*os.File {
168- var out []*os.File
169- for _, f := range fs {
170- if d := dupFence(f); d != nil {
171- out = append(out, d)
172- }
173- }
174- return out
175-}
176-
177-// signalled reports whether every fence signalled (readable); an fd that
178-// cannot be polled counts as signalled.
179-func signalled(fs []*os.File) bool {
180- if len(fs) == 0 {
181- return true
182- }
183- pfds := make([]unix.PollFd, len(fs))
184- for i, f := range fs {
185- pfds[i] = unix.PollFd{Fd: int32(f.Fd()), Events: unix.POLLIN}
186- }
187- if _, err := unix.Poll(pfds, 0); err != nil {
188- return true
189- }
190- for _, p := range pfds {
191- if p.Revents == 0 {
192- return false
193- }
194- }
195- return true
196-}
197-
198-// holdRead keeps the fences of a frame that was not committed.
199-func (o *Output) holdRead(fs ...*os.File) {
200- o.readFences = append(o.readFences, dupFences(fs...)...)
201-}
202-
203-// readDone reports whether no uncommitted frame may still read client
204-// buffers, closing the fences that signalled.
205-func (o *Output) readDone() bool {
206- kept := o.readFences[:0]
207- for _, f := range o.readFences {
208- if signalled([]*os.File{f}) {
209- f.Close()
210- continue
211- }
212- kept = append(kept, f)
213- }
214- clear(o.readFences[len(kept):])
215- o.readFences = kept
216- return len(kept) == 0
217-}
218-
219-// dropRead closes the fences of uncommitted frames.
220-func (o *Output) dropRead() {
221- for _, f := range o.readFences {
222- f.Close()
223- }
224- o.readFences = nil
225-}
226-
227-// expire applies a lifecycle deadline and reports whether KMS needs a modeset.
228-// Fence polling and logging stay at the output boundary, not in the value
229-// transition; the lifecycle owns the decision and closes pending fences.
230-func (o *Output) expire() bool {
231- now := time.Now()
232- serial, frame, age, busy, fences := o.frame.deadlineInfo(now)
233- ready := serial == 0 || !o.frame.deadlineReached(now) || signalled(fences)
234- switch o.frame.timeout(now, ready) {
235- case timeoutFence:
236- o.log.Warn().Str("connector", o.conn.name).Uint64("serial", serial).Dur("age", age).Msg("frame waits for GPU fence")
237- case timeoutBusy:
238- o.log.Warn().Str("connector", o.conn.name).Str("kind", "busy").Dur("age", busy).Msg("commits refused as busy; modeset")
239- return true
240- case timeoutMissing:
241- kind := "state"
242- if frame {
243- kind = "frame"
244- }
245- o.log.Warn().Str("connector", o.conn.name).Uint64("serial", serial).Str("kind", kind).Dur("age", age).Msg("commit event missing; modeset")
246- return true
247- }
248- return false
249-}
250-
251156 // CursorLoader returns the image of a cursor at an output scale, at most
252157 // limit pixels on a side; an empty image hides the cursor.
253158 type CursorLoader func(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error)
@@ -264,11 +169,11 @@ func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, er
264169 if err := o.readProps(card.fd, pipe, card.taken, cursorSize(card.fd)); err != nil {
265170 return nil, err
266171 }
267- o.hdrSettings = normalizedHDRSettings(card.want.HDR[c.name])
268- o.hdr = detectHDR(o.monitor, o.hdrProps)
269- log.Info().Str("connector", c.name).Bool("hdr_capable", o.hdr.Capable).Str("reason", o.hdr.Reason).Float64("max_luminance", o.hdr.MaxLuminance).Float64("max_frame_average", o.hdr.MaxFrameAverage).Float64("min_luminance", o.hdr.MinLuminance).Msg("HDR capability")
270- if o.hdrSettings.Enabled && !o.hdr.Capable {
271- log.Warn().Str("connector", c.name).Str("reason", o.hdr.Reason).Msg("HDR requested but unavailable")
172+ o.hdr.settings = normalizedHDRSettings(card.want.HDR[c.name])
173+ o.hdr.cap = detectHDR(o.monitor, o.hdr.props)
174+ log.Info().Str("connector", c.name).Bool("hdr_capable", o.hdr.cap.Capable).Str("reason", o.hdr.cap.Reason).Float64("max_luminance", o.hdr.cap.MaxLuminance).Float64("max_frame_average", o.hdr.cap.MaxFrameAverage).Float64("min_luminance", o.hdr.cap.MinLuminance).Msg("HDR capability")
175+ if o.hdr.settings.Enabled && !o.hdr.cap.Capable {
176+ log.Warn().Str("connector", c.name).Str("reason", o.hdr.cap.Reason).Msg("HDR requested but unavailable")
272177 }
273178 o.tearing = card.async && !card.want.NoTearing
274179 if !card.want.NoVRR {
@@ -295,7 +200,7 @@ func (o *Output) readProps(fd, pipe int, taken map[uint32]bool, cursorSide int)
295200 }
296201 o.connCrtc = uint32(np["CRTC_ID"][0])
297202 // The connector's property metadata is needed for enum/range capability.
298- o.hdrProps = readConnectorHDRProps(fd, np)
203+ o.hdr.props = readConnectorHDRProps(fd, np)
299204 o.contentProp, o.contentValues = readContentTypeProp(fd, np)
300205 o.contentValue = np["content type"][1]
301206 o.contentWanted = o.contentValue
@@ -524,12 +429,12 @@ func (o *Output) sendFormats() {
524429 return
525430 }
526431 f := ports.OutputFormats{Output: o.conn.name, Device: o.device}
527- if o.hdrOn && !o.off {
528- f.HDR = &ports.OutputHDR{MaxLuminance: o.hdr.MaxLuminance, MaxFrameAverage: o.hdr.MaxFrameAverage, MinLuminance: o.hdr.MinLuminance}
432+ if o.hdr.on && !o.off {
433+ f.HDR = &ports.OutputHDR{MaxLuminance: o.hdr.cap.MaxLuminance, MaxFrameAverage: o.hdr.cap.MaxFrameAverage, MinLuminance: o.hdr.cap.MinLuminance}
529434 }
530435 if o.scanout && !o.off {
531436 for _, format := range o.scanoutFormats(o.sampled) {
532- if !isYUVFormat(format.Format) && isTenBit(format.Format) == o.hdrOn {
437+ if !isYUVFormat(format.Format) && isTenBit(format.Format) == o.hdr.on {
533438 f.Formats = append(f.Formats, format)
534439 }
535440 }
@@ -592,7 +497,7 @@ func (o *Output) modesetBaseReq(blob uint32, active bool) *atomicReq {
592497 req.set(o.crtc, o.vrrProp, 0)
593498 req.set(o.conn.id, o.contentProp, o.contentValues[0])
594499 req.set(o.conn.id, o.connCrtc, uint64(o.crtc))
595- o.hdrConnectorProps(req, o.hdrOn)
500+ o.hdrConnectorProps(req, o.hdr.on)
596501 return req
597502 }
598503
@@ -617,86 +522,6 @@ func (o *Output) modesetReq(blob uint32, active bool) *atomicReq {
617522 return req
618523 }
619524
620-// probeAsync checks once whether async commits may carry IN_FENCE_FD.
621-func (o *Output) probeAsync(r ports.Renderer) {
622- if o.asyncProbed || !o.tearing {
623- return
624- }
625- o.asyncProbed = true
626- r.UseTarget(o.back)
627- done, err := r.Render(ports.Scene{Background: "#000000"}, nil)
628- if err != nil {
629- return
630- }
631- req := &atomicReq{}
632- req.set(o.primary.id, o.primary.prop("FB_ID"), uint64(o.fbs[o.back]))
633- if done != nil {
634- defer done.Close()
635- req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(done.Fd()))
636- }
637- err = o.k.commit(req, atomicTestOnly|flipAsyncFlag, 0)
638- o.asyncFence = err == nil && done != nil
639- o.log.Info().Err(err).Str("connector", o.conn.name).Bool("async_fence", o.asyncFence).Msg("tearing probe")
640-}
641-
642-// wantVRR is the VRR state the next frame commit sets: on while game (a
643-// buffer is scanned out or a fullscreen window covers the output), off
644-// after vrrHold without it (each toggle may flicker, so short breaks keep
645-// it).
646-func (o *Output) wantVRR(game bool) bool {
647- if o.vrrProp == 0 {
648- return false
649- }
650- o.vrrGame = game
651- if game {
652- o.composedSince = time.Time{}
653- return true
654- }
655- if o.composedSince.IsZero() {
656- o.composedSince = time.Now()
657- }
658- return o.vrrOn && time.Since(o.composedSince) <= vrrHold
659-}
660-
661-// stateVRR is the VRR state of a commit without a new frame (cursor,
662-// vrrOff timer): the last frame's. A shown buffer is not enough: it may
663-// be a tiled window on the overlay plane.
664-func (o *Output) stateVRR() bool { return o.wantVRR(o.vrrGame) }
665-
666-// contentProps changes the connector hint only when it differs from the applied value.
667-func (o *Output) contentProps(req *atomicReq) {
668- if o.contentProp != 0 && o.contentWanted != o.contentValue {
669- req.set(o.conn.id, o.contentProp, o.contentWanted)
670- }
671-}
672-
673-// wantContent chooses a content hint for the only visible fullscreen window.
674-func (o *Output) wantContent(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) {
675- o.contentWanted = o.contentValues[0]
676- var full *ports.SceneWindow
677- for i := range s.Windows {
678- w := &s.Windows[i]
679- if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {
680- continue
681- }
682- if !w.Fullscreen || !covers(&s, w) || full != nil {
683- return
684- }
685- full = w
686- }
687- if full == nil {
688- return
689- }
690- switch surfaces[full.ID].ContentType {
691- case ports.ContentPhoto:
692- o.contentWanted = o.contentValues[2]
693- case ports.ContentVideo:
694- o.contentWanted = o.contentValues[3]
695- case ports.ContentGame:
696- o.contentWanted = o.contentValues[4]
697- }
698-}
699-
700525 // commitFrame flips fb in one commit with the cursor and VRR. An async
701526 // commit carries only the primary plane (kernel rule): a frame that must
702527 // also move the cursor or change VRR flips at vblank. fence is the
@@ -951,17 +776,6 @@ func (o *Output) commitScanoutRect(fb uint32, c ports.SurfaceContent, f pendingF
951776 return true, err
952777 }
953778
954-// vrrHold is how long composition runs before VRR turns off.
955-const vrrHold = 500 * time.Millisecond
956-
957-// setAsync logs when the output starts or stops tearing.
958-func (o *Output) setAsync(on bool) {
959- if on != o.async {
960- o.async = on
961- o.log.Info().Bool("tearing", on).Str("connector", o.conn.name).Msg("tearing")
962- }
963-}
964-
965779 // Close frees buffers and disables scanout. Without a security gate it
966780 // retains legacy restoration of the CRTC state found at startup; a wired
967781 // gate never admits saved desktop pixels, even if its snapshot is unlocked.
@@ -1016,10 +830,8 @@ func (o *Output) Close() {
1016830 detached = o.primary != nil && o.crtc != 0 && o.crtcProps["ACTIVE"] != 0
1017831 }
1018832 }
1019- // A live metadata blob means an HDR modeset may still be on screen, even
1020- // after a failed SDR fallback (hdrOn is then false): it is freed only once
1021- // an SDR modeset succeeds.
1022- hdrShown := o.hdrOn || o.hdrBlob != 0
833+ // A live metadata blob is freed only once an SDR modeset succeeds.
834+ hdrShown := o.hdr.shown()
1023835 // Best effort: at exit the card may already belong to another session.
1024836 if err := o.k.commit(req, atomicAllowModes, 0); err != nil {
1025837 ev := o.log.Debug()
@@ -1042,10 +854,7 @@ func (o *Output) Close() {
1042854 _ = o.k.destroyBlob(o.modeBlob)
1043855 o.modeBlob = 0
1044856 }
1045- if o.hdrBlob != 0 {
1046- _ = o.k.destroyBlob(o.hdrBlob)
1047- o.hdrBlob = 0
1048- }
857+ o.hdr.releaseBlob(o.k)
1049858 if o.cursor != nil {
1050859 o.cursor.free(o.k)
1051860 }
@@ -1063,8 +872,8 @@ func (o *Output) Close() {
1063872 func (o *Output) InactiveOnClose() bool { return o.inactiveOnClose }
1064873
1065874 // Run renders scenes and commits them until ctx ends. active reports seat
1066-// enable/disable. What the output shows and read is reported on presented.
1067-func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone) (runErr error) {
875+// enable/disable. What the output shows and read is reported on reportsCh.
876+func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, reportsCh chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone) (runErr error) {
1068877 defer func() {
1069878 p := recover()
1070879 if p != nil {
@@ -1234,15 +1043,15 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
12341043 // A display frame or fence that has not finished must not
12351044 // keep a finished child read held: child holds do not expire.
12361045 // Report them alone, at the last Seen that was safe.
1237- o.report(nil, o.seenSnapshot)
1046+ o.report(nil, o.reports.Seen())
12381047 readWait = readWait || o.capped
12391048 }
12401049 }
1241- o.flushReport(presented)
1050+ o.reports.Flush(reportsCh)
12421051 // An unsent report is retried soon, not only on the next event;
12431052 // so is one that waits for an uncommitted frame's fence.
12441053 var retry <-chan time.Time
1245- if len(o.unsent) > 0 || readWait {
1054+ if o.reports.Len() > 0 || readWait {
12461055 retryTimer.Reset(time.Millisecond)
12471056 retry = retryTimer.C
12481057 } else {
@@ -1667,187 +1476,6 @@ func (o *Output) setCursor(r ports.Renderer, load CursorLoader, c ports.CursorCh
16671476 o.log.Debug().Float64("scale", scale).Str("shape", c.Shape).Int("w", img.W).Int("h", img.H).Msg("cursor image")
16681477 }
16691478
1670-// maxUnsent bounds reports waiting for wayland; past it the oldest flip is
1671-// folded into the next one.
1672-const maxUnsent = 16
1673-
1674-// report queues what the output shows and has read, so replaced client
1675-// buffers can be released. A flip-less report merges into the newest
1676-// queued one; flips are never merged unless maxUnsent is reached.
1677-func (o *Output) report(flip *ports.FlipInfo, seen map[ports.WindowID]uint64) {
1678- o.capped = false
1679- var reads map[ports.WindowID]uint64
1680- if o.capHidden != nil {
1681- seen, reads, o.capped = o.capHidden(seen)
1682- }
1683- if !maps.Equal(o.seenSnapshot, seen) {
1684- o.seenSnapshot = maps.Clone(seen)
1685- }
1686- if !maps.Equal(o.childReadsSnapshot, reads) {
1687- o.childReadsSnapshot = maps.Clone(reads)
1688- }
1689- r := ports.OutputPresented{Output: o.conn.name, Flip: flip, Shown: o.shown, Queued: o.queued, Seen: o.seenSnapshot, ChildReads: o.childReadsSnapshot}
1690- if n := len(o.unsent); n > 0 && (flip == nil || o.unsent[n-1].Flip == nil) {
1691- if flip == nil {
1692- r.Flip = o.unsent[n-1].Flip
1693- }
1694- o.unsent[n-1] = r
1695- return
1696- }
1697- o.unsent = append(o.unsent, r)
1698- if len(o.unsent) > maxUnsent {
1699- old, next := o.unsent[0].Flip, o.unsent[1].Flip
1700- if next != nil && old != nil {
1701- next.Merged += 1 + old.Merged
1702- // A snapshot can be shared with previously sent flips. Clone only
1703- // when a merge actually needs to add an absent window.
1704- cloned := false
1705- for id, seq := range old.Shows {
1706- if _, ok := next.Shows[id]; !ok {
1707- if !cloned {
1708- next.Shows = maps.Clone(next.Shows)
1709- cloned = true
1710- }
1711- if next.Shows == nil {
1712- next.Shows = make(map[ports.WindowID]uint64)
1713- }
1714- next.Shows[id] = seq
1715- }
1716- }
1717- }
1718- o.unsent = slices.Delete(o.unsent, 0, 1)
1719- o.log.Warn().Str("connector", o.conn.name).Msg("wayland is not reading output reports; flips merged")
1720- }
1721-}
1722-
1723-// flushReport sends queued reports without blocking.
1724-func (o *Output) flushReport(presented chan<- ports.OutputPresented) {
1725- for len(o.unsent) > 0 {
1726- select {
1727- case presented <- o.unsent[0]:
1728- o.unsent = slices.Delete(o.unsent, 0, 1)
1729- default:
1730- return
1731- }
1732- }
1733-}
1734-
1735-// imageKind is how output images are made, best first.
1736-type imageKind int
1737-
1738-const (
1739- imagesDriver imageKind = iota // exported, modifier chosen by the driver
1740- imagesLinear // exported, linear
1741-)
1742-
1743-// showImages sets up images from kind on and modesets them. KMS may refuse
1744-// an image only in a modeset: a TEST_ONLY modeset checks it first, and the
1745-// next kind is tried on refusal. cause is why the
1746-// previous kind failed, for the log.
1747-func (o *Output) showImages(r ports.Renderer, kind imageKind, cause error) error {
1748- if o.hdrSettings.Enabled && o.hdr.Capable && !o.hdrFailed {
1749- o.hdrOn = true
1750- meta := hdrMetadata(o.monitor)
1751- // Reuse the live blob across VT resume. A changed EDID creates a
1752- // replacement, but the old one remains alive until KMS accepts it.
1753- oldBlob := o.hdrBlob
1754- created := false
1755- var err error
1756- if oldBlob == 0 || meta != o.hdrBlobData {
1757- var newBlob uint32
1758- newBlob, err = o.k.createBlob(meta.bytes())
1759- if err == nil {
1760- o.hdrBlob = newBlob
1761- created = true
1762- }
1763- }
1764- if err == nil {
1765- r.SetHDR(float64(o.hdrSettings.SDRBrightness))
1766- err = o.showImageKind(r, imagesDriver, nil)
1767- }
1768- var pe protectedCommitError
1769- if errors.As(err, &pe) {
1770- // A protected KMS failure is not an HDR failure: images exist and
1771- // the owner retries the protected modeset. Never fall back to SDR
1772- // or latch hdrFailed for it.
1773- if created {
1774- if oldBlob != 0 {
1775- _ = o.k.destroyBlob(o.hdrBlob)
1776- o.hdrBlob = oldBlob
1777- } else {
1778- o.hdrBlobData = meta
1779- }
1780- }
1781- return err
1782- }
1783- if err == nil {
1784- if created {
1785- o.hdrBlobData = meta
1786- if oldBlob != 0 {
1787- _ = o.k.destroyBlob(oldBlob)
1788- }
1789- }
1790- o.log.Info().Str("connector", o.conn.name).Int("sdr_brightness", o.hdrSettings.SDRBrightness).Float64("max_luminance", o.hdr.MaxLuminance).Float64("max_frame_average", o.hdr.MaxFrameAverage).Msg("HDR10 on")
1791- return nil
1792- }
1793- if created && oldBlob != 0 {
1794- // Failed replacement: the existing modeset may still use it.
1795- _ = o.k.destroyBlob(o.hdrBlob)
1796- o.hdrBlob = oldBlob
1797- }
1798- o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("HDR modeset unavailable; falling back to SDR")
1799- o.hdrFailed = true
1800- o.hdrOn = false
1801- o.freeImages()
1802- r.SetHDR(0)
1803- _, _ = r.ExportTargets(0, nil)
1804- // A failed test commit can leave the old HDR mode on screen;
1805- // retain its blob until the SDR modeset succeeds or Close restores it.
1806- } else {
1807- r.SetHDR(0)
1808- }
1809- err := o.showImageKind(r, kind, cause)
1810- if err == nil && o.hdrFailed && o.hdrBlob != 0 {
1811- // The SDR commit has completed; the old HDR metadata is no longer in use.
1812- _ = o.k.destroyBlob(o.hdrBlob)
1813- o.hdrBlob = 0
1814- o.hdrBlobData = hdrOutputMetadata{}
1815- }
1816- return err
1817-}
1818-
1819-func (o *Output) showImageKind(r ports.Renderer, kind imageKind, cause error) error {
1820- for {
1821- got, err := o.setupImages(r, kind, cause)
1822- if err != nil {
1823- return err
1824- }
1825- o.kind = got
1826- if err = o.testModeset(); err == nil {
1827- err = o.modeset()
1828- if err != nil && o.protected && !errors.Is(err, errSecurityScene) && !errors.Is(err, errProtectionPending) && !errors.As(err, new(protectedCommitError)) {
1829- err = protectedCommitError{err}
1830- }
1831- // A protected failure is not an image refusal: never fall back to
1832- // another image kind or SDR for it. The caller hands it to the
1833- // bounded retry (commitFailed).
1834- return err
1835- }
1836- if o.protected && !refused(err) {
1837- // EBUSY, lost master or a blob failure of the test: the images
1838- // are fine; retry the protected modeset later.
1839- return protectedCommitError{err}
1840- }
1841- if !refused(err) || got == imagesLinear {
1842- o.freeImages()
1843- return err
1844- }
1845- o.log.Warn().Err(err).Str("connector", o.conn.name).Int("kind", int(got)).Msg("modeset refused the output images")
1846- o.freeImages()
1847- kind, cause = got+1, err
1848- }
1849-}
1850-
18511479 // refused reports a modeset error meaning KMS rejects the images.
18521480 func refused(err error) bool {
18531481 return errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ERANGE)
@@ -1857,118 +1485,3 @@ func refused(err error) bool {
18571485 func lostMaster(err error) bool {
18581486 return errors.Is(err, unix.EACCES) || errors.Is(err, unix.EPERM)
18591487 }
1860-
1861-// setupImages gives the output the two renderer images it flips between,
1862-// exported as dmabufs. There is no CPU fallback (ADR 014): a GPU that
1863-// cannot export a scanout-capable image cannot drive the output.
1864-func (o *Output) setupImages(r ports.Renderer, kind imageKind, cause error) (imageKind, error) {
1865- err := cause
1866- for ; kind <= imagesLinear; kind++ {
1867- if o.runContext != nil && o.runContext.Err() != nil {
1868- return kind, o.runContext.Err()
1869- }
1870- mods := []uint64(nil)
1871- if o.hdrOn {
1872- for _, f := range o.primary.formats {
1873- if f.Format == fourccXR30 {
1874- mods = append(mods, f.Modifier)
1875- }
1876- }
1877- if len(mods) == 0 {
1878- return kind, fmt.Errorf("primary plane has no XRGB2101010 modifiers")
1879- }
1880- if kind == imagesLinear {
1881- if !slices.Contains(mods, uint64(0)) {
1882- continue
1883- }
1884- mods = []uint64{0}
1885- }
1886- } else if kind == imagesLinear {
1887- mods = []uint64{0}
1888- }
1889- if err = o.exportImages(r, mods); err == nil {
1890- return kind, nil
1891- }
1892- o.log.Info().Err(err).Str("connector", o.conn.name).Int("kind", int(kind)).Msg("output image export")
1893- }
1894- return imagesLinear, fmt.Errorf("%s: GPU cannot export scanout images (ADR 014): %w", o.conn.name, err)
1895-}
1896-
1897-// exportImages makes the renderer's exported targets the output images.
1898-func (o *Output) exportImages(r ports.Renderer, mods []uint64) error {
1899- bufs, err := r.ExportTargets(len(o.fbs), mods)
1900- if err != nil {
1901- return err
1902- }
1903- for i := range bufs {
1904- if err == nil {
1905- format := uint32(fourccXRGB)
1906- if o.hdrOn {
1907- format = fourccXR30
1908- }
1909- o.fbs[i], err = o.k.addFB(&bufs[i], format)
1910- }
1911- bufs[i].Planes[0].File.Close()
1912- }
1913- // GPU memory starts undefined (old VRAM contents): clear both images
1914- // before the modeset shows one.
1915- for i := range o.fbs {
1916- if err != nil {
1917- break
1918- }
1919- r.UseTarget(i)
1920- var done *os.File
1921- if done, err = r.Render(ports.Scene{Background: "#000000"}, nil); done != nil {
1922- // The modeset is a blocking commit: wait for the clear.
1923- waitCtx := o.runContext
1924- if waitCtx == nil {
1925- waitCtx = context.Background()
1926- }
1927- err = errors.Join(err, syncfile.Wait(waitCtx, done))
1928- done.Close()
1929- }
1930- }
1931- if err != nil {
1932- o.freeImages()
1933- _, _ = r.ExportTargets(0, nil)
1934- return err
1935- }
1936- o.log.Info().Str("connector", o.conn.name).Uint64("modifier", bufs[0].Modifier).Msg("zero-copy output")
1937- return nil
1938-}
1939-
1940-// freeImages removes the output images' framebuffers.
1941-func (o *Output) freeImages() {
1942- for i, fb := range o.fbs {
1943- if fb != 0 {
1944- _ = o.k.rmFB(fb)
1945- }
1946- o.fbs[i] = 0
1947- }
1948-}
1949-
1950-// shownBy returns an immutable snapshot of the content Seq of drawn windows.
1951-// The scratch map is private to the output; a snapshot may still be read by
1952-// wayland after a subsequent flip, so never refill a previously sent map.
1953-func (o *Output) shownBy(s ports.Scene, seen map[ports.WindowID]uint64) map[ports.WindowID]uint64 {
1954- if o.showsScratch == nil {
1955- o.showsScratch = make(map[ports.WindowID]uint64)
1956- }
1957- clear(o.showsScratch)
1958- for id, seq := range seen {
1959- if s.Shows(id) {
1960- o.showsScratch[id] = seq
1961- }
1962- }
1963- if !maps.Equal(o.showsSnapshot, o.showsScratch) {
1964- o.showsSnapshot = maps.Clone(o.showsScratch)
1965- }
1966- return o.showsSnapshot
1967-}
1968-
1969-func (o *Output) directShownBy(id ports.WindowID, seq uint64) map[ports.WindowID]uint64 {
1970- if len(o.directShowsSnapshot) != 1 || o.directShowsSnapshot[id] != seq {
1971- o.directShowsSnapshot = map[ports.WindowID]uint64{id: seq}
1972- }
1973- return o.directShowsSnapshot
1974-}
M internal/adapters/drm/output_test.go +14 -13
@@ -10,6 +10,7 @@ import (
1010 "testing"
1111 "time"
1212
13+ "github.com/bnema/neferwl/internal/adapters/presented"
1314 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
1415 "github.com/bnema/neferwl/internal/ports"
1516 "github.com/bnema/zerowrap"
@@ -196,8 +197,8 @@ func TestCursorCommitWaitsForEvent(t *testing.T) {
196197 // Its event is not a frame: nothing is reported.
197198 seen := map[ports.WindowID]uint64{}
198199 o.completed(eventOf(c), seen)
199- if o.frame.pendingCommit() || len(o.unsent) != 0 || o.flips != 0 {
200- t.Fatalf("pending=%v unsent=%d flips=%d", o.frame.pendingCommit(), len(o.unsent), o.flips)
200+ if o.frame.pendingCommit() || o.reports.Len() != 0 || o.flips != 0 {
201+ t.Fatalf("pending=%v unsent=%d flips=%d", o.frame.pendingCommit(), o.reports.Len(), o.flips)
201202 }
202203 // Nothing changed: no commit.
203204 if err := o.commitState(false); err != nil || len(*commits) != 1 {
@@ -289,10 +290,10 @@ func TestFlipReportsTimestampAndShownFrame(t *testing.T) {
289290 t.Fatal(err)
290291 }
291292 o.completed(flipEvent{user: (*commits)[0].user, when: 5 * time.Second, seq: 11}, map[ports.WindowID]uint64{1: 4})
292- if len(o.unsent) != 1 {
293- t.Fatalf("reports %d", len(o.unsent))
293+ if o.reports.Len() != 1 {
294+ t.Fatalf("reports %d", o.reports.Len())
294295 }
295- f := o.unsent[0].Flip
296+ f := o.reports.Unsent()[0].Flip
296297 if f == nil || f.When != 5*time.Second || f.Seq != 11 || f.Refresh != time.Second/60 || f.Shows[1] != 4 || !f.HardwareClock {
297298 t.Fatalf("flip %+v", f)
298299 }
@@ -310,11 +311,11 @@ func TestReportSeenAllocations(t *testing.T) {
310311 } else {
311312 t.Logf("unchanged report: %.1f allocs", allocs)
312313 }
313- previous := o.unsent[0].Seen
314+ previous := o.reports.Unsent()[0].Seen
314315 seen[1] = 3
315316 o.report(nil, seen)
316- if previous[1] != 1 || o.unsent[0].Seen[1] != 3 {
317- t.Fatalf("snapshot mutated: old %v new %v", previous, o.unsent[0].Seen)
317+ if previous[1] != 1 || o.reports.Unsent()[0].Seen[1] != 3 {
318+ t.Fatalf("snapshot mutated: old %v new %v", previous, o.reports.Unsent()[0].Seen)
318319 }
319320 }
320321
@@ -348,14 +349,14 @@ func TestReportsKeepEveryFlip(t *testing.T) {
348349 o.report(nil, seen)
349350 o.report(&ports.FlipInfo{Seq: 2}, seen)
350351 o.report(nil, map[ports.WindowID]uint64{1: 3})
351- if len(o.unsent) != 2 || o.unsent[0].Flip.Seq != 1 || o.unsent[1].Flip.Seq != 2 || o.unsent[1].Seen[1] != 3 {
352- t.Fatalf("unsent %+v", o.unsent)
352+ if o.reports.Len() != 2 || o.reports.Unsent()[0].Flip.Seq != 1 || o.reports.Unsent()[1].Flip.Seq != 2 || o.reports.Unsent()[1].Seen[1] != 3 {
353+ t.Fatalf("unsent %+v", o.reports.Unsent())
353354 }
354- for i := range maxUnsent + 3 {
355+ for i := range presented.MaxUnsent + 3 {
355356 o.report(&ports.FlipInfo{Seq: uint64(10 + i), Shows: map[ports.WindowID]uint64{}}, seen)
356357 }
357- if len(o.unsent) != maxUnsent || o.unsent[0].Flip.Merged == 0 {
358- t.Fatalf("cap: %d reports, first merged %d", len(o.unsent), o.unsent[0].Flip.Merged)
358+ if o.reports.Len() != presented.MaxUnsent || o.reports.Unsent()[0].Flip.Merged == 0 {
359+ t.Fatalf("cap: %d reports, first merged %d", o.reports.Len(), o.reports.Unsent()[0].Flip.Merged)
359360 }
360361 }
361362
M internal/adapters/drm/protection_retry_test.go +5 -5
@@ -250,9 +250,9 @@ func TestProtectedShowImagesHDRKeepsHDRForKMSErrors(t *testing.T) {
250250 o, k, _ := testOutput(t, errs...)
251251 o.cursor = nil
252252 o.protected = true
253- o.hdr = hdrCapability{Capable: true}
254- o.hdrSettings = HDRSettings{Enabled: true, SDRBrightness: 203}
255- o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}
253+ o.hdr.cap = hdrCapability{Capable: true}
254+ o.hdr.settings = HDRSettings{Enabled: true, SDRBrightness: 203}
255+ o.hdr.props = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}
256256 o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 0}}
257257 if errno == nil {
258258 o.primary.props = map[string]uint32{"CRTC_ID": pCrtcID} // incomplete: detach fails
@@ -279,8 +279,8 @@ func TestProtectedShowImagesHDRKeepsHDRForKMSErrors(t *testing.T) {
279279 if !o.commitFailed(err, &enabled) {
280280 t.Fatalf("protected failure not handled: %v", err)
281281 }
282- if o.hdrFailed || !o.hdrOn {
283- t.Fatalf("HDR fell back: failed=%v on=%v", o.hdrFailed, o.hdrOn)
282+ if o.hdr.failed || !o.hdr.on {
283+ t.Fatalf("HDR fell back: failed=%v on=%v", o.hdr.failed, o.hdr.on)
284284 }
285285 if lost == enabled {
286286 t.Fatalf("lost master=%v enabled=%v", lost, enabled)
M internal/adapters/drm/protection_review_test.go +1 -1
@@ -74,7 +74,7 @@ func TestProtectedModesetDisableSendsInactiveFormats(t *testing.T) {
7474 failures = []error{unix.EACCES}
7575 }
7676 o, k, _ := testOutput(t, failures...)
77- o.device, o.hdrOn = 42, true
77+ o.device, o.hdr.on = 42, true
7878 feedback := make(chan ports.OutputFormats, 2)
7979 o.formats = feedback
8080 r := portsmocks.NewMockRenderer(t)
A internal/adapters/drm/reads.go +95 -0
@@ -0,0 +1,95 @@
1+package drm
2+
3+import (
4+ "os"
5+ "time"
6+
7+ "golang.org/x/sys/unix"
8+)
9+
10+// Fence holds: client buffers the GPU may still read.
11+
12+// dupFences duplicates the non-nil fences.
13+func dupFences(fs ...*os.File) []*os.File {
14+ var out []*os.File
15+ for _, f := range fs {
16+ if d := dupFence(f); d != nil {
17+ out = append(out, d)
18+ }
19+ }
20+ return out
21+}
22+
23+// signalled reports whether every fence signalled (readable); an fd that
24+// cannot be polled counts as signalled.
25+func signalled(fs []*os.File) bool {
26+ if len(fs) == 0 {
27+ return true
28+ }
29+ pfds := make([]unix.PollFd, len(fs))
30+ for i, f := range fs {
31+ pfds[i] = unix.PollFd{Fd: int32(f.Fd()), Events: unix.POLLIN}
32+ }
33+ if _, err := unix.Poll(pfds, 0); err != nil {
34+ return true
35+ }
36+ for _, p := range pfds {
37+ if p.Revents == 0 {
38+ return false
39+ }
40+ }
41+ return true
42+}
43+
44+// holdRead keeps the fences of a frame that was not committed.
45+func (o *Output) holdRead(fs ...*os.File) {
46+ o.readFences = append(o.readFences, dupFences(fs...)...)
47+}
48+
49+// readDone reports whether no uncommitted frame may still read client
50+// buffers, closing the fences that signalled.
51+func (o *Output) readDone() bool {
52+ kept := o.readFences[:0]
53+ for _, f := range o.readFences {
54+ if signalled([]*os.File{f}) {
55+ f.Close()
56+ continue
57+ }
58+ kept = append(kept, f)
59+ }
60+ clear(o.readFences[len(kept):])
61+ o.readFences = kept
62+ return len(kept) == 0
63+}
64+
65+// dropRead closes the fences of uncommitted frames.
66+func (o *Output) dropRead() {
67+ for _, f := range o.readFences {
68+ f.Close()
69+ }
70+ o.readFences = nil
71+}
72+
73+// expire applies a lifecycle deadline and reports whether KMS needs a modeset.
74+// Fence polling and logging stay at the output boundary, not in the value
75+// transition; the lifecycle owns the decision and closes pending fences.
76+func (o *Output) expire() bool {
77+ now := time.Now()
78+ serial, frame, age, busy, fences := o.frame.deadlineInfo(now)
79+ ready := serial == 0 || !o.frame.deadlineReached(now) || signalled(fences)
80+ switch o.frame.timeout(now, ready) {
81+ case timeoutFence:
82+ o.log.Warn().Str("connector", o.conn.name).Uint64("serial", serial).Dur("age", age).Msg("frame waits for GPU fence")
83+ case timeoutBusy:
84+ o.log.Warn().Str("connector", o.conn.name).Str("kind", "busy").Dur("age", busy).Msg("commits refused as busy; modeset")
85+ return true
86+ case timeoutMissing:
87+ kind := "state"
88+ if frame {
89+ kind = "frame"
90+ }
91+ o.log.Warn().Str("connector", o.conn.name).Uint64("serial", serial).Str("kind", kind).Dur("age", age).Msg("commit event missing; modeset")
92+ return true
93+ }
94+ return false
95+}
A internal/adapters/drm/report.go +48 -0
@@ -0,0 +1,48 @@
1+package drm
2+
3+import (
4+ "maps"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// Reports to wayland: what the output shows and has read.
10+
11+// report queues what the output shows and has read, so replaced client
12+// buffers can be released (see presented.Queue for merging).
13+func (o *Output) report(flip *ports.FlipInfo, seen map[ports.WindowID]uint64) {
14+ o.capped = false
15+ var reads map[ports.WindowID]uint64
16+ if o.capHidden != nil {
17+ seen, reads, o.capped = o.capHidden(seen)
18+ }
19+ if o.reports.Push(ports.OutputPresented{Output: o.conn.name, Flip: flip, Shown: o.shown, Queued: o.queued, Seen: seen, ChildReads: reads}) {
20+ o.log.Warn().Str("connector", o.conn.name).Msg("wayland is not reading output reports; flips merged")
21+ }
22+}
23+
24+// shownBy returns an immutable snapshot of the content Seq of drawn windows.
25+// The scratch map is private to the output; a snapshot may still be read by
26+// wayland after a subsequent flip, so never refill a previously sent map.
27+func (o *Output) shownBy(s ports.Scene, seen map[ports.WindowID]uint64) map[ports.WindowID]uint64 {
28+ if o.showsScratch == nil {
29+ o.showsScratch = make(map[ports.WindowID]uint64)
30+ }
31+ clear(o.showsScratch)
32+ for id, seq := range seen {
33+ if s.Shows(id) {
34+ o.showsScratch[id] = seq
35+ }
36+ }
37+ if !maps.Equal(o.showsSnapshot, o.showsScratch) {
38+ o.showsSnapshot = maps.Clone(o.showsScratch)
39+ }
40+ return o.showsSnapshot
41+}
42+
43+func (o *Output) directShownBy(id ports.WindowID, seq uint64) map[ports.WindowID]uint64 {
44+ if len(o.directShowsSnapshot) != 1 || o.directShowsSnapshot[id] != seq {
45+ o.directShowsSnapshot = map[ports.WindowID]uint64{id: seq}
46+ }
47+ return o.directShowsSnapshot
48+}
M internal/adapters/drm/scanout.go +3 -3
@@ -196,14 +196,14 @@ func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]por
196196 if reason == "" && isYUVFormat(c.DMABuf.Format) {
197197 reason = "yuv"
198198 }
199- if o.hdrOn && reason == "" {
199+ if o.hdr.on && reason == "" {
200200 switch {
201201 case !c.Color.IsPQ2020():
202202 reason = "hdr_sdr_content"
203203 case !isTenBit(c.DMABuf.Format):
204204 reason = "hdr_format"
205205 }
206- } else if !o.hdrOn && reason == "" && c.Color.IsPQ2020() {
206+ } else if !o.hdr.on && reason == "" && c.Color.IsPQ2020() {
207207 // An SDR connector must never show raw PQ values.
208208 reason = "sdr_pq_content"
209209 }
@@ -243,7 +243,7 @@ func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]por
243243 func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (overlayWin, ports.Scene) {
244244 reason := "no_plane"
245245 var ov overlayWin
246- if o.hdrOn {
246+ if o.hdr.on {
247247 reason = "hdr"
248248 } else if o.overlay != nil && o.scanout {
249249 var w ports.SceneWindow
M internal/adapters/drm/scanout_test.go +7 -7
@@ -227,7 +227,7 @@ func TestModesetSendsScanoutFormats(t *testing.T) {
227227
228228 func TestHDRScanoutDecision(t *testing.T) {
229229 o, k, _ := testOutput(t)
230- o.hdrOn = true
230+ o.hdr.on = true
231231 o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30}}
232232 scene := ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Fullscreen: true, Rect: ports.Rect{W: 200, H: 100}}}}
233233 c := ports.SurfaceContent{ID: 1, Width: 200, Height: 100, LogicalW: 200, LogicalH: 100, DMABuf: &ports.DMABuf{ID: 11, Format: fourccXR30}}
@@ -247,15 +247,15 @@ func TestHDRScanoutDecision(t *testing.T) {
247247 c.DMABuf.Format = fourccXR30
248248 k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(uint32(77), nil).Once()
249249 check("")
250- o.hdrOn = false
250+ o.hdr.on = false
251251 check("sdr_pq_content")
252252 }
253253
254254 func TestModesetReportsConfirmedHDR(t *testing.T) {
255255 o, k, _ := testOutput(t)
256256 o.cursor = nil
257- o.hdrOn = true
258- o.hdr = hdrCapability{MaxLuminance: 1000, MaxFrameAverage: 400, MinLuminance: 0.005}
257+ o.hdr.on = true
258+ o.hdr.cap = hdrCapability{MaxLuminance: 1000, MaxFrameAverage: 400, MinLuminance: 0.005}
259259 o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30}, {Format: fourccXRGB}}
260260 o.sampled = []ports.DMABufFormat{{Format: fourccXR30}, {Format: fourccXRGB}}
261261 ch := make(chan ports.OutputFormats, 2)
@@ -268,7 +268,7 @@ func TestModesetReportsConfirmedHDR(t *testing.T) {
268268 if f := <-ch; f.HDR == nil || f.HDR.MaxLuminance != 1000 || f.HDR.MaxFrameAverage != 400 || f.HDR.MinLuminance != 0.005 || len(f.Formats) != 1 || f.Formats[0].Format != fourccXR30 {
269269 t.Fatalf("HDR report: %+v", f)
270270 }
271- o.hdrOn = false // fallback to SDR
271+ o.hdr.on = false // fallback to SDR
272272 if err := o.modeset(); err != nil {
273273 t.Fatal(err)
274274 }
@@ -282,8 +282,8 @@ func TestModesetReportsConfirmedHDR(t *testing.T) {
282282 func TestPowerOffWithdrawsHDRAndFormats(t *testing.T) {
283283 o, _, _ := testOutput(t)
284284 o.cursor = nil
285- o.hdrOn, o.scanout = true, true
286- o.hdr = hdrCapability{MaxLuminance: 1000}
285+ o.hdr.on, o.scanout = true, true
286+ o.hdr.cap = hdrCapability{MaxLuminance: 1000}
287287 o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30}}
288288 o.sampled = []ports.DMABufFormat{{Format: fourccXR30}}
289289 ch := make(chan ports.OutputFormats, 1)
A internal/adapters/drm/vrr.go +100 -0
@@ -0,0 +1,100 @@
1+package drm
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// Tearing, variable refresh and content type of frame commits.
10+
11+// probeAsync checks once whether async commits may carry IN_FENCE_FD.
12+func (o *Output) probeAsync(r ports.Renderer) {
13+ if o.asyncProbed || !o.tearing {
14+ return
15+ }
16+ o.asyncProbed = true
17+ r.UseTarget(o.back)
18+ done, err := r.Render(ports.Scene{Background: "#000000"}, nil)
19+ if err != nil {
20+ return
21+ }
22+ req := &atomicReq{}
23+ req.set(o.primary.id, o.primary.prop("FB_ID"), uint64(o.fbs[o.back]))
24+ if done != nil {
25+ defer done.Close()
26+ req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(done.Fd()))
27+ }
28+ err = o.k.commit(req, atomicTestOnly|flipAsyncFlag, 0)
29+ o.asyncFence = err == nil && done != nil
30+ o.log.Info().Err(err).Str("connector", o.conn.name).Bool("async_fence", o.asyncFence).Msg("tearing probe")
31+}
32+
33+// wantVRR is the VRR state the next frame commit sets: on while game (a
34+// buffer is scanned out or a fullscreen window covers the output), off
35+// after vrrHold without it (each toggle may flicker, so short breaks keep
36+// it).
37+func (o *Output) wantVRR(game bool) bool {
38+ if o.vrrProp == 0 {
39+ return false
40+ }
41+ o.vrrGame = game
42+ if game {
43+ o.composedSince = time.Time{}
44+ return true
45+ }
46+ if o.composedSince.IsZero() {
47+ o.composedSince = time.Now()
48+ }
49+ return o.vrrOn && time.Since(o.composedSince) <= vrrHold
50+}
51+
52+// stateVRR is the VRR state of a commit without a new frame (cursor,
53+// vrrOff timer): the last frame's. A shown buffer is not enough: it may
54+// be a tiled window on the overlay plane.
55+func (o *Output) stateVRR() bool { return o.wantVRR(o.vrrGame) }
56+
57+// contentProps changes the connector hint only when it differs from the applied value.
58+func (o *Output) contentProps(req *atomicReq) {
59+ if o.contentProp != 0 && o.contentWanted != o.contentValue {
60+ req.set(o.conn.id, o.contentProp, o.contentWanted)
61+ }
62+}
63+
64+// wantContent chooses a content hint for the only visible fullscreen window.
65+func (o *Output) wantContent(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) {
66+ o.contentWanted = o.contentValues[0]
67+ var full *ports.SceneWindow
68+ for i := range s.Windows {
69+ w := &s.Windows[i]
70+ if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {
71+ continue
72+ }
73+ if !w.Fullscreen || !covers(&s, w) || full != nil {
74+ return
75+ }
76+ full = w
77+ }
78+ if full == nil {
79+ return
80+ }
81+ switch surfaces[full.ID].ContentType {
82+ case ports.ContentPhoto:
83+ o.contentWanted = o.contentValues[2]
84+ case ports.ContentVideo:
85+ o.contentWanted = o.contentValues[3]
86+ case ports.ContentGame:
87+ o.contentWanted = o.contentValues[4]
88+ }
89+}
90+
91+// vrrHold is how long composition runs before VRR turns off.
92+const vrrHold = 500 * time.Millisecond
93+
94+// setAsync logs when the output starts or stops tearing.
95+func (o *Output) setAsync(on bool) {
96+ if on != o.async {
97+ o.async = on
98+ o.log.Info().Bool("tearing", on).Str("connector", o.conn.name).Msg("tearing")
99+ }
100+}
M internal/adapters/headless/capture_test.go +0 -28
@@ -5,7 +5,6 @@ import (
55 "errors"
66 "image"
77 "os"
8- "reflect"
98 "sync"
109 "sync/atomic"
1110 "testing"
@@ -259,33 +258,6 @@ func TestRenderErrorDoesNotAnswerHandedRequestsAgain(t *testing.T) {
259258 require.Equal(t, []uint64{1}, ids, "request 1 answered once by its worker, no pseudo request 0")
260259 }
261260
262-// An unchanged child-hold publication reuses its immutable snapshots;
263-// a change clones, and earlier reports keep their maps.
264-func TestHoldSnapshotsCloneOnlyOnChange(t *testing.T) {
265- var h holdSnapshots
266- seen := map[ports.WindowID]uint64{1: 4, 5: 7}
267- reads := map[ports.WindowID]uint64{5: 2}
268- first := h.report("HEADLESS-1", seen, reads)
269- require.Equal(t, seen, first.Seen)
270- require.Equal(t, reads, first.ChildReads)
271- seen[1] = 99 // the caller's scratch changes: the report does not
272- require.Equal(t, uint64(4), first.Seen[1])
273-
274- seen[1] = 4
275- again := h.report("HEADLESS-1", seen, reads)
276- require.Equal(t, reflect.ValueOf(first.Seen).Pointer(), reflect.ValueOf(again.Seen).Pointer(), "unchanged seen is shared")
277- require.Equal(t, reflect.ValueOf(first.ChildReads).Pointer(), reflect.ValueOf(again.ChildReads).Pointer(), "unchanged reads are shared")
278- allocs := testing.AllocsPerRun(100, func() { _ = h.report("HEADLESS-1", seen, reads) })
279- require.Zero(t, allocs, "unchanged publication allocates nothing")
280- t.Logf("unchanged hidden-capture publication: %.0f allocs", allocs)
281-
282- reads[5] = 3
283- changed := h.report("HEADLESS-1", seen, reads)
284- require.Equal(t, map[ports.WindowID]uint64{5: 3}, changed.ChildReads)
285- require.Equal(t, map[ports.WindowID]uint64{5: 2}, first.ChildReads, "sent snapshot stays immutable")
286- require.Equal(t, reflect.ValueOf(first.Seen).Pointer(), reflect.ValueOf(changed.Seen).Pointer(), "seen still unchanged")
287-}
288-
289261 // A scene that shows the capture indicator is never the source of a capture:
290262 // the standard capture is copied from a frame drawn without it, before the
291263 // displayed frame.
M internal/adapters/headless/output.go +31 -64
@@ -4,16 +4,17 @@ import (
44 "context"
55 "errors"
66 "fmt"
7- "golang.org/x/sys/unix"
87 "image"
98 "image/png"
10- "maps"
119 "os"
1210 "path/filepath"
1311 "time"
1412
13+ "golang.org/x/sys/unix"
14+
1515 "github.com/bnema/neferwl/internal/adapters/capture"
1616 "github.com/bnema/neferwl/internal/adapters/clock"
17+ "github.com/bnema/neferwl/internal/adapters/presented"
1718 "github.com/bnema/neferwl/internal/adapters/syncfile"
1819 "github.com/bnema/neferwl/internal/ports"
1920 "github.com/bnema/zerowrap"
@@ -120,9 +121,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
120121 var want ports.CursorChange
121122 cursorScale := -1.0 // not loaded yet
122123 seen := map[ports.WindowID]uint64{}
123- var holds holdSnapshots
124- // pending is a report the channel could not take, retried soon.
125- var pending *ports.OutputPresented
124+ // reports holds what the channel could not take yet, retried soon.
125+ var reports presented.Queue
126126 var security ports.SecurityState
127127 securityKnown := false
128128 failRequests := func(reason string) {
@@ -151,7 +151,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
151151 // confirmed software inactivity, independent of its old epoch.
152152 inactive := haveScene && scene.Off
153153 security, securityKnown = next, true
154- pending = nil
154+ reports.Reset()
155155 haveScene, dirty = false, false
156156 if !security.Protected {
157157 continue
@@ -230,7 +230,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
230230 return err
231231 }
232232 var retry <-chan time.Time
233- if pending != nil {
233+ if reports.Len() > 0 {
234234 retry = time.After(time.Millisecond)
235235 }
236236 // Requests wait for a scene that shows their indicator, at most
@@ -317,9 +317,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
317317 }
318318 return err
319319 }
320- if pending != nil {
321- pending = opts.send(pending)
322- }
320+ opts.flush(&reports)
323321 continue
324322 case <-trim.C():
325323 if err := checkSecurity(); err != nil {
@@ -424,7 +422,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
424422 if !haveScene || !dirty || scene.Off {
425423 // Contents not drawn are still read: report them. An output
426424 // turned off draws nothing.
427- pending = opts.report(pending, seen)
425+ opts.report(&reports, nil, seen)
428426 continue
429427 }
430428 dirty = false
@@ -473,10 +471,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
473471 if opts.Presented != nil {
474472 _, reads, _ := pipeline.CapHiddenSeen(seen)
475473 if len(reads) > 0 {
476- r := holds.report(opts.Name, seen, reads)
477- select {
478- case opts.Presented <- r:
479- case <-ctx.Done():
474+ reports.Push(ports.OutputPresented{Output: opts.Name, Seen: seen, ChildReads: reads})
475+ if reports.Drain(ctx, opts.Presented) != nil {
480476 return nil
481477 }
482478 }
@@ -574,7 +570,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
574570 pipeline.EndGate(nil)
575571 requests = capture.Waiting(requests, scene)
576572 frame++
577- pending = opts.flipped(pending, seen, scene, surfaces)
573+ opts.report(&reports, flipInfo(scene, surfaces), seen)
578574 if opts.ScreenshotDir != "" && !opts.protected() {
579575 shot := r.Pixels()
580576 if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
@@ -601,24 +597,6 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
601597 }
602598 }
603599
604-// holdSnapshots caches the immutable maps of the child-hold reports. The
605-// receiver may still read an earlier report on another goroutine, so a cached
606-// map is never mutated: it is replaced by a clone when the content changes,
607-// and an unchanged publication allocates nothing.
608-type holdSnapshots struct {
609- seen, reads map[ports.WindowID]uint64
610-}
611-
612-func (h *holdSnapshots) report(output string, seen, reads map[ports.WindowID]uint64) ports.OutputPresented {
613- if !maps.Equal(h.seen, seen) {
614- h.seen = maps.Clone(seen)
615- }
616- if !maps.Equal(h.reads, reads) {
617- h.reads = maps.Clone(reads)
618- }
619- return ports.OutputPresented{Output: output, Seen: h.seen, ChildReads: h.reads}
620-}
621-
622600 func (opts Options) protected() bool {
623601 return opts.Security != nil && opts.Security.Snapshot().Protected
624602 }
@@ -645,31 +623,29 @@ func writePNGSecure(path string, img *image.RGBA, security ports.SessionSecurity
645623 return os.Rename(f.Name(), path)
646624 }
647625
648-// report sends what the output read, or returns it to retry when the
649-// channel is full. Frames that were not drawn carry no flip.
650-func (opts Options) report(_ *ports.OutputPresented, seen map[ports.WindowID]uint64) *ports.OutputPresented {
626+// report queues what the output read, with flip for a drawn frame, and
627+// sends what the channel takes now; the rest is retried soon.
628+func (opts Options) report(q *presented.Queue, flip *ports.FlipInfo, seen map[ports.WindowID]uint64) {
651629 if opts.Presented == nil {
652- return nil
630+ return
631+ }
632+ if q.Push(ports.OutputPresented{Output: opts.Name, Flip: flip, Seen: seen}) {
633+ opts.Log.Warn().Str("component", "render").Str("output", opts.Name).Msg("wayland is not reading output reports; flips merged")
653634 }
654- return opts.send(&ports.OutputPresented{Output: opts.Name, Seen: maps.Clone(seen)})
635+ q.Flush(opts.Presented)
655636 }
656637
657-// flipped reports a drawn frame as a flip at the current CLOCK_MONOTONIC
658-// time (software clock, refresh unknown), so presentation feedback and
659-// frame callbacks follow headless frames. A report still waiting is
660-// sent first.
661-func (opts Options) flipped(pending *ports.OutputPresented, seen map[ports.WindowID]uint64, scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) *ports.OutputPresented {
662- if opts.Presented == nil {
663- return nil
664- }
665- var merged int
666- if pending != nil && opts.send(pending) != nil {
667- // The reader is behind: this frame's report replaces the waiting
668- // one, and its flip counts as merged (its feedback is discarded).
669- if pending.Flip != nil {
670- merged = 1 + pending.Flip.Merged
671- }
638+// flush retries reports the channel could not take.
639+func (opts Options) flush(q *presented.Queue) {
640+ if opts.Presented != nil {
641+ q.Flush(opts.Presented)
672642 }
643+}
644+
645+// flipInfo describes a drawn frame as a flip at the current CLOCK_MONOTONIC
646+// time (software clock, refresh unknown), so presentation feedback and
647+// frame callbacks follow headless frames.
648+func flipInfo(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) *ports.FlipInfo {
673649 var ts unix.Timespec
674650 _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)
675651 shows := map[ports.WindowID]uint64{}
@@ -678,14 +654,5 @@ func (opts Options) flipped(pending *ports.OutputPresented, seen map[ports.Windo
678654 shows[id] = c.Seq
679655 }
680656 }
681- return opts.send(&ports.OutputPresented{Output: opts.Name, Seen: maps.Clone(seen), Flip: &ports.FlipInfo{When: time.Duration(ts.Nano()), Shows: shows, Merged: merged}})
682-}
683-
684-func (opts Options) send(r *ports.OutputPresented) *ports.OutputPresented {
685- select {
686- case opts.Presented <- *r:
687- return nil
688- default:
689- return r
690- }
657+ return &ports.FlipInfo{When: time.Duration(ts.Nano()), Shows: shows}
691658 }
A internal/adapters/presented/queue.go +120 -0
@@ -0,0 +1,120 @@
1+// Package presented queues the OutputPresented reports an output owner sends
2+// to wayland: what the output read, held and flipped.
3+package presented
4+
5+import (
6+ "context"
7+ "maps"
8+ "slices"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+// MaxUnsent bounds reports waiting for wayland; past it the oldest flip is
14+// merged into the next one.
15+const MaxUnsent = 16
16+
17+// Queue holds reports wayland has not read yet. Its owner goroutine is the
18+// only user. The Seen and ChildReads maps of a queued report are immutable
19+// snapshots: wayland may still read an earlier report on another goroutine,
20+// so a snapshot is replaced by a clone when its content changes and an
21+// unchanged report allocates nothing.
22+type Queue struct {
23+ unsent []ports.OutputPresented
24+ seen, reads map[ports.WindowID]uint64
25+}
26+
27+// Push queues r. Its Seen and ChildReads may be the caller's scratch maps:
28+// the queue keeps snapshots. A flip-less report replaces the newest queued
29+// one when that one has no flip, or keeps its flip; flips are merged only
30+// past MaxUnsent. Push reports whether a flip was merged that way.
31+func (q *Queue) Push(r ports.OutputPresented) (merged bool) {
32+ if !maps.Equal(q.seen, r.Seen) {
33+ q.seen = maps.Clone(r.Seen)
34+ }
35+ if !maps.Equal(q.reads, r.ChildReads) {
36+ q.reads = maps.Clone(r.ChildReads)
37+ }
38+ r.Seen, r.ChildReads = q.seen, q.reads
39+ if n := len(q.unsent); n > 0 && (r.Flip == nil || q.unsent[n-1].Flip == nil) {
40+ if r.Flip == nil {
41+ r.Flip = q.unsent[n-1].Flip
42+ }
43+ q.unsent[n-1] = r
44+ return false
45+ }
46+ q.unsent = append(q.unsent, r)
47+ if len(q.unsent) <= MaxUnsent {
48+ return false
49+ }
50+ if old, next := q.unsent[0].Flip, q.unsent[1].Flip; next != nil && old != nil {
51+ mergeFlip(old, next)
52+ }
53+ q.unsent = slices.Delete(q.unsent, 0, 1)
54+ return true
55+}
56+
57+// mergeFlip folds old into next: next counts it as merged and shows what
58+// old showed, so feedbacks waiting on old are answered (as discarded).
59+func mergeFlip(old, next *ports.FlipInfo) {
60+ next.Merged += 1 + old.Merged
61+ // A snapshot can be shared with previously sent flips. Clone only when
62+ // a merge actually needs to add an absent window.
63+ cloned := false
64+ for id, seq := range old.Shows {
65+ if _, ok := next.Shows[id]; ok {
66+ continue
67+ }
68+ if !cloned {
69+ next.Shows = maps.Clone(next.Shows)
70+ cloned = true
71+ }
72+ if next.Shows == nil {
73+ next.Shows = make(map[ports.WindowID]uint64)
74+ }
75+ next.Shows[id] = seq
76+ }
77+}
78+
79+// Flush sends queued reports without blocking.
80+func (q *Queue) Flush(ch chan<- ports.OutputPresented) {
81+ for len(q.unsent) > 0 {
82+ select {
83+ case ch <- q.unsent[0]:
84+ q.unsent = slices.Delete(q.unsent, 0, 1)
85+ default:
86+ return
87+ }
88+ }
89+}
90+
91+// Drain sends every queued report, blocking until wayland reads them or ctx
92+// ends.
93+func (q *Queue) Drain(ctx context.Context, ch chan<- ports.OutputPresented) error {
94+ for len(q.unsent) > 0 {
95+ select {
96+ case ch <- q.unsent[0]:
97+ q.unsent = slices.Delete(q.unsent, 0, 1)
98+ case <-ctx.Done():
99+ return ctx.Err()
100+ }
101+ }
102+ return nil
103+}
104+
105+// Len is the number of queued reports.
106+func (q *Queue) Len() int { return len(q.unsent) }
107+
108+// Unsent returns the queued reports, oldest first. Callers must not modify
109+// them.
110+func (q *Queue) Unsent() []ports.OutputPresented { return q.unsent }
111+
112+// Seen is the Seen snapshot of the last pushed report.
113+func (q *Queue) Seen() map[ports.WindowID]uint64 { return q.seen }
114+
115+// Reset drops queued reports, e.g. when a security epoch makes them stale.
116+// Snapshots stay: they may be shared with reports already sent.
117+func (q *Queue) Reset() {
118+ clear(q.unsent)
119+ q.unsent = q.unsent[:0]
120+}
A internal/adapters/presented/queue_test.go +98 -0
@@ -0,0 +1,98 @@
1+package presented
2+
3+import (
4+ "context"
5+ "reflect"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func ptr(m map[ports.WindowID]uint64) uintptr { return reflect.ValueOf(m).Pointer() }
13+
14+// An unchanged publication reuses its immutable snapshots; a change clones,
15+// and earlier reports keep their maps.
16+func TestQueueSnapshotsCloneOnlyOnChange(t *testing.T) {
17+ var q Queue
18+ seen := map[ports.WindowID]uint64{1: 4, 5: 7}
19+ reads := map[ports.WindowID]uint64{5: 2}
20+ q.Push(ports.OutputPresented{Output: "A", Seen: seen, ChildReads: reads})
21+ first := q.Unsent()[0]
22+ require.Equal(t, seen, first.Seen)
23+ require.Equal(t, reads, first.ChildReads)
24+ seen[1] = 99 // the caller's scratch changes: the report does not
25+ require.Equal(t, uint64(4), first.Seen[1])
26+
27+ seen[1] = 4
28+ q.Push(ports.OutputPresented{Output: "A", Seen: seen, ChildReads: reads})
29+ again := q.Unsent()[0]
30+ require.Equal(t, ptr(first.Seen), ptr(again.Seen), "unchanged seen is shared")
31+ require.Equal(t, ptr(first.ChildReads), ptr(again.ChildReads), "unchanged reads are shared")
32+ allocs := testing.AllocsPerRun(100, func() { q.Push(ports.OutputPresented{Output: "A", Seen: seen, ChildReads: reads}) })
33+ require.Zero(t, allocs, "unchanged publication allocates nothing")
34+
35+ reads[5] = 3
36+ q.Push(ports.OutputPresented{Output: "A", Seen: seen, ChildReads: reads})
37+ changed := q.Unsent()[0]
38+ require.Equal(t, map[ports.WindowID]uint64{5: 3}, changed.ChildReads)
39+ require.Equal(t, map[ports.WindowID]uint64{5: 2}, first.ChildReads, "sent snapshot stays immutable")
40+ require.Equal(t, ptr(first.Seen), ptr(changed.Seen), "seen still unchanged")
41+}
42+
43+// Flip-less reports fold into the newest one and keep its flip; flips queue.
44+func TestQueueKeepsEveryFlip(t *testing.T) {
45+ var q Queue
46+ seen := map[ports.WindowID]uint64{1: 1}
47+ q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: 1}, Seen: seen})
48+ q.Push(ports.OutputPresented{Seen: seen})
49+ q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: 2}, Seen: seen})
50+ q.Push(ports.OutputPresented{Seen: map[ports.WindowID]uint64{1: 3}})
51+ u := q.Unsent()
52+ require.Len(t, u, 2)
53+ require.Equal(t, uint64(1), u[0].Flip.Seq)
54+ require.Equal(t, uint64(2), u[1].Flip.Seq, "a flip-less report keeps the queued flip")
55+ require.Equal(t, uint64(3), u[1].Seen[1])
56+}
57+
58+// Past MaxUnsent the oldest flip folds into the next: counted as merged and
59+// its shown windows carried, without mutating a shared Shows snapshot.
60+func TestQueueMergesOldestFlipPastBound(t *testing.T) {
61+ var q Queue
62+ shared := map[ports.WindowID]uint64{2: 5}
63+ q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: 1, Shows: map[ports.WindowID]uint64{1: 4}}})
64+ q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: 2, Shows: shared}})
65+ merged := false
66+ for i := range MaxUnsent - 1 {
67+ merged = q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: uint64(10 + i)}}) || merged
68+ }
69+ require.True(t, merged)
70+ u := q.Unsent()
71+ require.Len(t, u, MaxUnsent)
72+ require.Equal(t, uint64(2), u[0].Flip.Seq)
73+ require.Equal(t, 1, u[0].Flip.Merged)
74+ require.Equal(t, map[ports.WindowID]uint64{1: 4, 2: 5}, u[0].Flip.Shows)
75+ require.Equal(t, map[ports.WindowID]uint64{2: 5}, shared, "shared snapshot untouched")
76+}
77+
78+func TestQueueFlushStopsWhenFull(t *testing.T) {
79+ var q Queue
80+ q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: 1}})
81+ q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: 2}})
82+ ch := make(chan ports.OutputPresented, 1)
83+ q.Flush(ch)
84+ require.Equal(t, 1, q.Len())
85+ require.Equal(t, uint64(1), (<-ch).Flip.Seq)
86+ require.NoError(t, q.Drain(context.Background(), ch))
87+ require.Zero(t, q.Len())
88+ require.Equal(t, uint64(2), (<-ch).Flip.Seq)
89+
90+ q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: 3}})
91+ q.Push(ports.OutputPresented{Flip: &ports.FlipInfo{Seq: 4}})
92+ ctx, cancel := context.WithCancel(context.Background())
93+ cancel()
94+ ch <- ports.OutputPresented{}
95+ require.ErrorIs(t, q.Drain(ctx, ch), context.Canceled)
96+ q.Reset()
97+ require.Zero(t, q.Len())
98+}
M internal/adapters/wayland/imagecapture_test.go +44 -11
@@ -238,16 +238,39 @@ func noSession(t *testing.T, cc *cclient, ev chan []uint32) {
238238 // client received what followed.
239239 func (h *captureHarness) settle(t *testing.T, cc *cclient) {
240240 t.Helper()
241- deadline := time.Now().Add(2 * time.Second)
242- for len(h.commands) > 0 && time.Now().Before(deadline) {
243- time.Sleep(time.Millisecond)
244- }
245- if !h.s.display.Do(func() {}) {
241+ // The command loop takes commands off the channel before it applies
242+ // them on the display: an empty channel does not mean applied. Commands
243+ // apply in order, so a marker (SlotsPending, a plain flag) seen on the
244+ // display means every command sent before it was applied.
245+ var was bool
246+ if !h.s.display.Do(func() { was = h.s.slotsPending }) {
246247 t.Fatal("display stopped")
247248 }
249+ h.applied(t, !was)
250+ h.applied(t, was)
248251 cc.roundtrip(t)
249252 }
250253
254+// applied sends SlotsPending{pending} and waits until the display applied it.
255+func (h *captureHarness) applied(t *testing.T, pending bool) {
256+ t.Helper()
257+ h.commands <- ports.SlotsPending{Pending: pending}
258+ deadline := time.Now().Add(2 * time.Second)
259+ for {
260+ var now bool
261+ if !h.s.display.Do(func() { now = h.s.slotsPending }) {
262+ t.Fatal("display stopped")
263+ }
264+ if now == pending {
265+ return
266+ }
267+ if time.Now().After(deadline) {
268+ t.Fatal("commands not applied")
269+ }
270+ time.Sleep(time.Millisecond)
271+ }
272+}
273+
251274 func (h *captureHarness) send(cmd ports.ClientCommand) { h.commands <- cmd }
252275
253276 // captureShm makes a w x h XRGB8888 wl_buffer.
@@ -1264,14 +1287,24 @@ func TestExclusionAttachLimit(t *testing.T) {
12641287 }
12651288 over := hd.attachSurfaceNew(t, token)
12661289 expectAttachment(t, hd.c, over, imagecapture.NeferwlCaptureLayerV1EventFailed, int64(imagecapture.NeferwlCaptureLayerV1FailureTooManyLayers))
1267- n := 0
1268- for len(h.events) > 0 {
1269- if l, ok := (<-h.events).(ports.CaptureExclusionLayer); ok && l.Attached {
1270- n++
1290+ // Events reach core through a forwarding goroutine: wait for each.
1291+ for range ports.MaxExclusionLayers {
1292+ if l := nextEvent[ports.CaptureExclusionLayer](t, h.events); !l.Attached {
1293+ t.Fatalf("detach %+v", l)
12711294 }
12721295 }
1273- if n != ports.MaxExclusionLayers {
1274- t.Fatalf("%d layers reached core, want %d", n, ports.MaxExclusionLayers)
1296+ // The refused one never follows: it was refused before the failed event
1297+ // the client already read, so a short wait covers the forwarder.
1298+ deadline := time.After(50 * time.Millisecond)
1299+ for {
1300+ select {
1301+ case ev := <-h.events:
1302+ if l, ok := ev.(ports.CaptureExclusionLayer); ok && l.Attached {
1303+ t.Fatalf("layer over the limit reached core: %+v", l)
1304+ }
1305+ case <-deadline:
1306+ return
1307+ }
12751308 }
12761309 }
12771310
M internal/adapters/wayland/server.go +35 -1
@@ -286,7 +286,41 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
286286 d.Close()
287287 return nil, err
288288 }
289- s := &Server{securityWake: make(chan struct{}, 1), security: opts.Security, securityController: opts.Security, display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), reports: map[string]ports.OutputPresented{}, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), damage: map[ports.WindowID][]ports.SeqDamage{}, contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, seat: seatState{keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), enters: make(map[*server.Resource]uint32), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}}
289+ s := &Server{
290+ securityWake: make(chan struct{}, 1),
291+ security: opts.Security,
292+ securityController: opts.Security,
293+ display: d,
294+ awaiting: map[string][]*wayland.Callback{},
295+ frameDue: map[string]time.Time{},
296+ frameReady: make(chan struct{}, 1),
297+ reports: map[string]ports.OutputPresented{},
298+ env: linuxProcEnv{},
299+ name: name,
300+ cleanup: cleanup,
301+ log: log,
302+ channels: ch,
303+ surfaces: make(map[*server.Resource]*surface),
304+ buffers: make(map[*server.Resource]clientBuffer),
305+ windows: make(map[ports.WindowID]*window),
306+ layers: make(map[ports.WindowID]*layerSurface),
307+ nextWindow: 1,
308+ eventReady: make(chan struct{}, 1),
309+ contents: make(map[ports.WindowID]ports.SurfaceContent),
310+ contentSeq: make(map[ports.WindowID]uint64),
311+ damage: map[ports.WindowID][]ports.SeqDamage{},
312+ contentReady: make(chan struct{}, 1),
313+ cursorReady: make(chan struct{}, 1),
314+ dataSources: map[*server.Resource]*clipSource{},
315+ primarySources: map[*server.Resource]*clipSource{},
316+ controlSources: map[*server.Resource]*clipSource{},
317+ contentNotify: make(chan struct{}),
318+ regions: map[*server.Resource]*region{},
319+ relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{},
320+ constraints: map[*surface]*constraint{},
321+ positioners: map[*server.Resource]*positioner{},
322+ seat: seatState{keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), enters: make(map[*server.Resource]uint32), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay},
323+ }
290324 s.leaseDevices = map[string]*leaseDevice{}
291325 s.pendingLeases = map[uint64]*leaseObject{}
292326 s.pendingLeaseCards = map[uint64]string{}
M internal/adapters/wayland/workspaces_test.go +4 -2
@@ -197,8 +197,10 @@ func TestWorkspaceProtocol(t *testing.T) {
197197 default:
198198 }
199199 snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "1", Hidden: true}, {ID: 43, Name: "2", Index: 1}}}}}
200+ // The snapshot reaches the display through a forwarding goroutine:
201+ // poll by round trips until a deadline, not a fixed count.
200202 found := false
201- for i := 0; i < 8 && !found; i++ {
203+ for deadline := time.Now().Add(2 * time.Second); !found && time.Now().Before(deadline); time.Sleep(time.Millisecond) {
202204 if err := c.Roundtrip(); err != nil {
203205 t.Fatal(err)
204206 }
@@ -214,7 +216,7 @@ func TestWorkspaceProtocol(t *testing.T) {
214216 }
215217 snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{}}
216218 found = false
217- for i := 0; i < 8 && !found; i++ {
219+ for deadline := time.Now().Add(2 * time.Second); !found && time.Now().Before(deadline); time.Sleep(time.Millisecond) {
218220 if err := c.Roundtrip(); err != nil {
219221 t.Fatal(err)
220222 }
M internal/adapters/xkb/xkb.go +15 -13
@@ -35,15 +35,17 @@ type Keymap struct {
3535 keymapString func(uintptr, uint32) unsafe.Pointer
3636 free func(uintptr)
3737 getSym func(uintptr, uint32) uint32
38- symName func(uint32, uintptr, uintptr) int32
39- updateKey func(uintptr, uint32, uint32) uint32
40- serializeMods func(uintptr, uint32) uint32
41- serializeLayout func(uintptr, uint32) uint32
42- modActive func(uintptr, uintptr, uint32) int32
43- minCode func(uintptr) uint32
44- maxCode func(uintptr) uint32
45- symsLevel func(uintptr, uint32, uint32, uint32, *unsafe.Pointer) int32
46- fromName func(uintptr, uint32) uint32
38+ // Buffers go to C as typed pointers: they escape to the heap, where a
39+ // stack growth during the call cannot move them under C.
40+ symName func(uint32, *byte, uintptr) int32
41+ updateKey func(uintptr, uint32, uint32) uint32
42+ serializeMods func(uintptr, uint32) uint32
43+ serializeLayout func(uintptr, uint32) uint32
44+ modActive func(uintptr, *byte, uint32) int32
45+ minCode func(uintptr) uint32
46+ maxCode func(uintptr) uint32
47+ symsLevel func(uintptr, uint32, uint32, uint32, *unsafe.Pointer) int32
48+ fromName func(*byte, uint32) uint32
4749 }
4850
4951 type ruleNames struct{ rules, model, layout, variant, options uintptr }
@@ -132,7 +134,7 @@ func (k *Keymap) Key(evdevCode uint32, pressed bool, timeMsec uint32) ports.KeyE
132134 code := evdevCode + 8
133135 sym := k.getSym(k.state, code)
134136 var buf [64]byte
135- n := k.symName(sym, uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
137+ n := k.symName(sym, &buf[0], uintptr(len(buf)))
136138 name := ""
137139 if n > 0 && n < int32(len(buf)) {
138140 name = string(buf[:n])
@@ -156,7 +158,7 @@ func (k *Keymap) Key(evdevCode uint32, pressed bool, timeMsec uint32) ports.KeyE
156158 flag ports.Mods
157159 }{{"Shift", ports.ModShift}, {"Control", ports.ModCtrl}, {"Mod1", ports.ModAlt}, {"Mod4", ports.ModSuper}} {
158160 b := append([]byte(mod.name), 0)
159- if k.modActive(k.state, uintptr(unsafe.Pointer(&b[0])), modsEffective) > 0 {
161+ if k.modActive(k.state, &b[0], modsEffective) > 0 {
160162 event.Mods |= mod.flag
161163 }
162164 runtime.KeepAlive(b)
@@ -228,7 +230,7 @@ func (k *Keymap) levelName(code, layout, level uint32) string {
228230 return ""
229231 }
230232 var buf [64]byte
231- n := k.symName(*(*uint32)(ptr), uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
233+ n := k.symName(*(*uint32)(ptr), &buf[0], uintptr(len(buf)))
232234 if n <= 0 || n >= int32(len(buf)) {
233235 return ""
234236 }
@@ -237,7 +239,7 @@ func (k *Keymap) levelName(code, layout, level uint32) string {
237239
238240 func (k *Keymap) KeycodeFor(keysymName string) (evdev uint32, shift bool, ok bool) {
239241 b := append([]byte(keysymName), 0)
240- sym := k.fromName(uintptr(unsafe.Pointer(&b[0])), 0)
242+ sym := k.fromName(&b[0], 0)
241243 runtime.KeepAlive(b)
242244 if sym == 0 {
243245 return 0, false, false
M internal/core/capindicator.go +82 -57
@@ -43,7 +43,7 @@ type capFlash struct {
4343 // already does not.
4444 func (c *Core) captureFrame(v ports.CaptureFrameTaken) bool {
4545 changed := false
46- if s := c.capSession(v.Session); v.Session != 0 && s != nil && !s.recording {
46+ if s := c.capt.session(v.Session); v.Session != 0 && s != nil && !s.recording {
4747 s.recording, changed = true, true
4848 }
4949 t := capTarget{output: v.Output, workspace: v.Workspace, rect: v.Region}
@@ -51,30 +51,35 @@ func (c *Core) captureFrame(v ports.CaptureFrameTaken) bool {
5151 // Nothing of the target is left on screen to mark.
5252 return changed
5353 }
54- until := c.now().Add(ports.CaptureFlash)
55- for i := range c.capFlashes {
56- if c.capFlashes[i].target == t {
57- c.capFlashes[i].until = until
58- c.armCaptureTimer()
59- return changed
54+ return c.capt.flash(t, c.now(), c.ch.Clock) || changed
55+}
56+
57+// flash marks t until ports.CaptureFlash after now and arms the timer. It
58+// reports whether a new mark shows; extending one does not.
59+func (s *captureState) flash(t capTarget, now time.Time, clock ports.Clock) bool {
60+ until := now.Add(ports.CaptureFlash)
61+ defer s.armTimer(now, clock)
62+ for i := range s.flashes {
63+ if s.flashes[i].target == t {
64+ s.flashes[i].until = until
65+ return false
6066 }
6167 }
62- if len(c.capFlashes) >= maxCaptureFlashes {
63- c.collapseFlashes(t)
68+ if len(s.flashes) >= maxCaptureFlashes {
69+ s.collapseFlashes(t)
6470 }
65- c.capFlashes = append(c.capFlashes, capFlash{target: t, until: until})
66- c.armCaptureTimer()
71+ s.flashes = append(s.flashes, capFlash{target: t, until: until})
6772 return true
6873 }
6974
7075 // collapseFlashes makes room: the flashes of regions of t's output become one
7176 // whole-output flash lasting as long as the longest of them; when that is
7277 // not enough the oldest flash goes.
73-func (c *Core) collapseFlashes(t capTarget) {
78+func (s *captureState) collapseFlashes(t capTarget) {
7479 if t.workspace == 0 && t.output != "" {
7580 var until time.Time
7681 n := 0
77- c.capFlashes = slices.DeleteFunc(c.capFlashes, func(f capFlash) bool {
82+ s.flashes = slices.DeleteFunc(s.flashes, func(f capFlash) bool {
7883 if f.target.workspace != 0 || f.target.output != t.output || f.target.rect == (Rect{}) {
7984 return false
8085 }
@@ -85,82 +90,102 @@ func (c *Core) collapseFlashes(t capTarget) {
8590 return true
8691 })
8792 if n > 0 {
88- c.capFlashes = append(c.capFlashes, capFlash{target: capTarget{output: t.output}, until: until})
93+ s.flashes = append(s.flashes, capFlash{target: capTarget{output: t.output}, until: until})
8994 }
9095 }
91- if len(c.capFlashes) >= maxCaptureFlashes {
96+ if len(s.flashes) >= maxCaptureFlashes {
9297 old := 0
93- for i, f := range c.capFlashes {
94- if f.until.Before(c.capFlashes[old].until) {
98+ for i, f := range s.flashes {
99+ if f.until.Before(s.flashes[old].until) {
95100 old = i
96101 }
97102 }
98- c.capFlashes = slices.Delete(c.capFlashes, old, old+1)
103+ s.flashes = slices.Delete(s.flashes, old, old+1)
99104 }
100105 }
101106
102-// captureExpire forgets the flashes that ended.
103-func (c *Core) captureExpire() {
104- if len(c.capFlashes) == 0 {
105- return
106- }
107- now := c.now()
108- c.capFlashes = slices.DeleteFunc(c.capFlashes, func(f capFlash) bool { return !f.until.After(now) })
107+// expire forgets the flashes that ended by now.
108+func (s *captureState) expire(now time.Time) {
109+ s.flashes = slices.DeleteFunc(s.flashes, func(f capFlash) bool { return !f.until.After(now) })
109110 }
110111
111-// captureFlashTick runs when the flash timer fired: it forgets the flashes
112-// that ended and re-arms for the next. It reports whether one ended.
113-func (c *Core) captureFlashTick() bool {
114- n := len(c.capFlashes)
115- c.captureExpire()
116- c.armCaptureTimer()
117- return len(c.capFlashes) != n
118-}
119-
120-// armCaptureTimer arms the timer at the earliest flash when none runs. A
121-// flash that was extended leaves it: the timer fires early, finds nothing to
122-// forget and arms again. No flash stops it.
123-func (c *Core) armCaptureTimer() {
124- if len(c.capFlashes) == 0 {
125- c.stopCaptureTimer()
112+// armTimer arms the timer at the earliest flash when none runs. A flash that
113+// was extended leaves it: the timer fires early, finds nothing to forget and
114+// arms again. No flash stops it. A nil clock uses the system timer.
115+func (s *captureState) armTimer(now time.Time, clock ports.Clock) {
116+ if len(s.flashes) == 0 {
117+ s.stopTimer()
126118 return
127119 }
128- if c.capStop != nil {
120+ if s.timerStop != nil {
129121 return
130122 }
131- first := c.capFlashes[0].until
132- for _, f := range c.capFlashes[1:] {
123+ first := s.flashes[0].until
124+ for _, f := range s.flashes[1:] {
133125 if f.until.Before(first) {
134126 first = f.until
135127 }
136128 }
137- d := max(first.Sub(c.now()), time.Millisecond)
138- if c.ch.Clock != nil {
139- t := c.ch.Clock.NewTimer(d)
140- c.capC, c.capStop = t.C(), t.Stop
129+ d := max(first.Sub(now), time.Millisecond)
130+ if clock != nil {
131+ t := clock.NewTimer(d)
132+ s.timerC, s.timerStop = t.C(), t.Stop
141133 return
142134 }
143135 t := time.NewTimer(d)
144- c.capC, c.capStop = t.C, t.Stop
136+ s.timerC, s.timerStop = t.C, t.Stop
137+}
138+
139+func (s *captureState) stopTimer() {
140+ if s.timerStop != nil {
141+ s.timerStop()
142+ }
143+ s.timerC, s.timerStop = nil, nil
145144 }
146145
147-func (c *Core) stopCaptureTimer() {
148- if c.capStop != nil {
149- c.capStop()
146+// captureExpire forgets the flashes that ended. With none it reads no clock.
147+func (c *Core) captureExpire() {
148+ if len(c.capt.flashes) > 0 {
149+ c.capt.expire(c.now())
150+ }
151+}
152+
153+// captureFlashTick runs when the flash timer fired: it forgets the flashes
154+// that ended and re-arms for the next. It reports whether one ended.
155+func (c *Core) captureFlashTick() bool {
156+ n := len(c.capt.flashes)
157+ if n == 0 {
158+ c.capt.stopTimer()
159+ return false
150160 }
151- c.capC, c.capStop = nil, nil
161+ now := c.now()
162+ c.capt.expire(now)
163+ c.capt.armTimer(now, c.ch.Clock)
164+ return len(c.capt.flashes) != n
152165 }
153166
167+func (c *Core) armCaptureTimer() {
168+ if len(c.capt.flashes) == 0 {
169+ c.capt.stopTimer()
170+ return
171+ }
172+ if c.capt.timerStop == nil {
173+ c.capt.armTimer(c.now(), c.ch.Clock)
174+ }
175+}
176+
177+func (c *Core) stopCaptureTimer() { c.capt.stopTimer() }
178+
154179 // captureIndicators is the marks of one output's scene; nil when there are
155180 // none. The slice is immutable once published and shared with the next scene
156181 // of the screen while the marks are the same; it is cloned only when they
157182 // change, so a steady recording allocates nothing.
158183 func (c *Core) captureIndicators(sc *screen) []ports.CaptureIndicator {
159- if len(c.capSessions) == 0 && len(c.capFlashes) == 0 {
184+ if c.capt.idle() {
160185 sc.capMarks = nil
161186 return nil
162187 }
163- out := c.capMarks[:0]
188+ out := c.capt.marks[:0]
164189 add := func(t capTarget) {
165190 r, reason := c.capResolve(t)
166191 if reason != ports.CaptureReasonNone || r.sc != sc {
@@ -177,15 +202,15 @@ func (c *Core) captureIndicators(sc *screen) []ports.CaptureIndicator {
177202 out = append(out, m)
178203 }
179204 }
180- for _, s := range c.capSessions {
205+ for _, s := range c.capt.sessions {
181206 if s.recording {
182207 add(s.target())
183208 }
184209 }
185- for _, f := range c.capFlashes {
210+ for _, f := range c.capt.flashes {
186211 add(f.target)
187212 }
188- c.capMarks = out
213+ c.capt.marks = out
189214 switch {
190215 case len(out) == 0:
191216 sc.capMarks = nil
M internal/core/capindicator_test.go +9 -9
@@ -91,7 +91,7 @@ func TestCaptureIndicatorFlashLastsOneSecond(t *testing.T) {
9191 if s := indicatorScene(t, c); s.CaptureIndicators != nil {
9292 t.Fatalf("indicators %+v after the flash", s.CaptureIndicators)
9393 }
94- if c.capStop != nil || len(c.capFlashes) != 0 {
94+ if c.capt.timerStop != nil || len(c.capt.flashes) != 0 {
9595 t.Fatal("timer or flash left")
9696 }
9797 }
@@ -108,15 +108,15 @@ func TestCaptureIndicatorFlashClearedByProtectionAndWorksAfterUnlock(t *testing.
108108 if !c.captureFrame(ports.CaptureFrameTaken{Output: "A"}) {
109109 t.Fatal("first frame shows nothing new")
110110 }
111- if len(c.capFlashes) != 1 || c.capStop == nil {
112- t.Fatalf("flash %d, timer armed %v", len(c.capFlashes), c.capStop != nil)
111+ if len(c.capt.flashes) != 1 || c.capt.timerStop == nil {
112+ t.Fatalf("flash %d, timer armed %v", len(c.capt.flashes), c.capt.timerStop != nil)
113113 }
114114 state = ports.SecurityState{Generation: 2, Protected: true}
115115 if !c.syncSecurity() {
116116 t.Fatal("protection not engaged")
117117 }
118- if len(c.capFlashes) != 0 || c.capStop != nil || c.capC != nil {
119- t.Fatalf("flash %d, timer %v left under protection", len(c.capFlashes), c.capStop != nil)
118+ if len(c.capt.flashes) != 0 || c.capt.timerStop != nil || c.capt.timerC != nil {
119+ t.Fatalf("flash %d, timer %v left under protection", len(c.capt.flashes), c.capt.timerStop != nil)
120120 }
121121 if s := indicatorScene(t, c); s.CaptureIndicators != nil {
122122 t.Fatalf("indicators %+v while protected", s.CaptureIndicators)
@@ -137,7 +137,7 @@ func TestCaptureIndicatorFlashClearedByProtectionAndWorksAfterUnlock(t *testing.
137137 if s := indicatorScene(t, c); len(s.CaptureIndicators) != 1 {
138138 t.Fatalf("indicators %+v after the unlock", s.CaptureIndicators)
139139 }
140- if len(ic.timers) != timers+1 || c.capStop == nil {
140+ if len(ic.timers) != timers+1 || c.capt.timerStop == nil {
141141 t.Fatalf("timer not re-armed after the unlock: %v", ic.timers)
142142 }
143143 ic.now = ic.now.Add(ports.CaptureFlash)
@@ -158,8 +158,8 @@ func TestCaptureIndicatorRegionAndUnion(t *testing.T) {
158158 // A target with nothing on screen marks nothing and stores nothing.
159159 c.captureFrame(ports.CaptureFrameTaken{Output: "gone"})
160160 c.captureFrame(ports.CaptureFrameTaken{Output: "A", Region: Rect{X: 900, Y: 900, W: 5, H: 5}})
161- if len(c.capFlashes) != 2 {
162- t.Fatalf("flashes %+v", c.capFlashes)
161+ if len(c.capt.flashes) != 2 {
162+ t.Fatalf("flashes %+v", c.capt.flashes)
163163 }
164164 }
165165
@@ -270,7 +270,7 @@ func TestCaptureIndicatorInflatesThinTargets(t *testing.T) {
270270 {Rect{X: 100, Y: 50, W: 4, H: 4}, Rect{X: 100, Y: 50, W: 5, H: 5}},
271271 {Rect{X: 100, Y: 50, W: 5, H: 5}, Rect{X: 100, Y: 50, W: 5, H: 5}},
272272 } {
273- c.capFlashes = nil
273+ c.capt.flashes = nil
274274 c.captureFrame(ports.CaptureFrameTaken{Output: "A", Region: tc.region})
275275 s := indicatorScene(t, c)
276276 if len(s.CaptureIndicators) != 1 || s.CaptureIndicators[0].Rect != tc.want {
M internal/core/capture.go +62 -39
@@ -3,6 +3,7 @@ package core
33 import (
44 "context"
55 "slices"
6+ "time"
67
78 "github.com/bnema/neferwl/internal/ports"
89 )
@@ -21,6 +22,37 @@ import (
2122 // With no session, no exclusion and no flash none of this allocates: the
2223 // scenes carry no Capture and no CaptureIndicators.
2324
25+// captureState is everything core keeps about captures. Methods on it touch
26+// only this state; those that need screens, layers or popups stay on Core.
27+type captureState struct {
28+ sessions []*capSession
29+ excl *capExclusion // the one live exclusion, nil when none
30+ // flashes are the targets of captured frames still marked; timerC fires
31+ // at the earliest of them (capindicator.go).
32+ flashes []capFlash
33+ timerC <-chan time.Time
34+ timerStop func() bool
35+ // scratch, states and marks are reused by every publish.
36+ scratch capView
37+ states []ports.CaptureSessionState
38+ marks []ports.CaptureIndicator
39+}
40+
41+func (s *captureState) session(id uint64) *capSession {
42+ for _, cs := range s.sessions {
43+ if cs.open.ID == id {
44+ return cs
45+ }
46+ }
47+ return nil
48+}
49+
50+// idle reports whether nothing is captured or marked: scenes carry no
51+// capture state.
52+func (s *captureState) idle() bool {
53+ return len(s.sessions) == 0 && len(s.flashes) == 0
54+}
55+
2456 // capTarget is what a capture covers, as asked.
2557 type capTarget struct {
2658 output string
@@ -117,22 +149,13 @@ func (c *Core) capResolve(t capTarget) (capResolved, ports.CaptureReason) {
117149
118150 // captureOpen registers a session.
119151 func (c *Core) captureOpen(v ports.CaptureSessionOpen) {
120- c.capSessions = append(c.capSessions, &capSession{open: v})
121-}
122-
123-func (c *Core) capSession(id uint64) *capSession {
124- for _, s := range c.capSessions {
125- if s.open.ID == id {
126- return s
127- }
128- }
129- return nil
152+ c.capt.sessions = append(c.capt.sessions, &capSession{open: v})
130153 }
131154
132155 // captureClose forgets a session and its exclusion. Its flashes stay.
133156 func (c *Core) captureClose(id uint64) {
134- c.capSessions = slices.DeleteFunc(c.capSessions, func(s *capSession) bool { return s.open.ID == id })
135- if c.capExcl != nil && c.capExcl.session == id {
157+ c.capt.sessions = slices.DeleteFunc(c.capt.sessions, func(s *capSession) bool { return s.open.ID == id })
158+ if c.capt.excl != nil && c.capt.excl.session == id {
136159 c.dropExclusion()
137160 }
138161 }
@@ -140,24 +163,24 @@ func (c *Core) captureClose(id uint64) {
140163 // captureExclusionBegin starts the exclusion of a session. Wayland refused
141164 // it already when another one was live.
142165 func (c *Core) captureExclusionBegin(v ports.CaptureExclusionBegin) {
143- if c.capSession(v.Session) == nil {
166+ if c.capt.session(v.Session) == nil {
144167 return
145168 }
146- if old := c.capExcl; old != nil {
169+ if old := c.capt.excl; old != nil {
147170 if !old.ended {
148171 return
149172 }
150173 // An ended exclusion still excludes its listed HUD layers: a new one
151174 // inherits them, so they are left out until no screen lists them.
152- c.capExcl = &capExclusion{session: v.Session, retained: old.retained, rev: old.rev}
175+ c.capt.excl = &capExclusion{session: v.Session, retained: old.retained, rev: old.rev}
153176 return
154177 }
155- c.capExcl = &capExclusion{session: v.Session}
178+ c.capt.excl = &capExclusion{session: v.Session}
156179 }
157180
158181 // captureExclusionLayer attaches or detaches a layer surface.
159182 func (c *Core) captureExclusionLayer(v ports.CaptureExclusionLayer) {
160- e := c.capExcl
183+ e := c.capt.excl
161184 if e == nil || e.ended || e.session != v.Session {
162185 return
163186 }
@@ -180,7 +203,7 @@ func (c *Core) captureExclusionLayer(v ports.CaptureExclusionLayer) {
180203 // layers that a screen still lists stay excluded until none does (the
181204 // retained set): the recorder's frames never show a HUD that just ended.
182205 func (c *Core) captureExclusionEnd(session uint64) {
183- e := c.capExcl
206+ e := c.capt.excl
184207 if e == nil || e.ended || e.session != session {
185208 return
186209 }
@@ -191,7 +214,7 @@ func (c *Core) captureExclusionEnd(session uint64) {
191214 }
192215 e.layers, e.keep, e.ended = nil, nil, true
193216 c.pruneRetained()
194- if c.capExcl != nil {
217+ if c.capt.excl != nil {
195218 // Attached layers no longer stay over a fullscreen window.
196219 for _, sc := range c.screens {
197220 sc.capture = nil
@@ -202,16 +225,16 @@ func (c *Core) captureExclusionEnd(session uint64) {
202225 // dropCaptureSessions forgets every session and the exclusion: the session is
203226 // protected, no capture is served, and wayland has stopped them all.
204227 func (c *Core) dropCaptureSessions() {
205- c.capSessions = nil
228+ c.capt.sessions = nil
206229 // No capture indicator outlives the lock: nothing is drawn for a capture
207230 // that can no longer happen, and none is left to show after the unlock.
208- c.capFlashes = nil
231+ c.capt.flashes = nil
209232 c.stopCaptureTimer()
210233 c.dropExclusion()
211234 }
212235
213236 func (c *Core) dropExclusion() {
214- c.capExcl = nil
237+ c.capt.excl = nil
215238 c.configures.cw.reset()
216239 for _, sc := range c.screens {
217240 sc.capture = nil
@@ -233,7 +256,7 @@ func (c *Core) layerListed(id WindowID) bool {
233256 // pruneRetained forgets the detached layers no screen lists any more, and
234257 // the ended exclusion with the last of them.
235258 func (c *Core) pruneRetained() {
236- e := c.capExcl
259+ e := c.capt.excl
237260 if e == nil {
238261 return
239262 }
@@ -264,7 +287,7 @@ func (c *Core) exclusionKeep(e *capExclusion) map[WindowID]bool {
264287 // capture HUD never takes or steals the keyboard. Run whenever the layer
265288 // list or the attached set changes.
266289 func (c *Core) clampCaptureKeyboard() {
267- e := c.capExcl
290+ e := c.capt.excl
268291 if e == nil {
269292 return
270293 }
@@ -298,18 +321,18 @@ func (c *Core) captureExcluded(e *capExclusion) []WindowID {
298321 // there is no session and no exclusion. A terminal state
299322 // is sent once and the session is forgotten.
300323 func (c *Core) captureEvaluate(ctx context.Context) (*capView, error) {
301- if len(c.capSessions) == 0 && c.capExcl == nil {
324+ if len(c.capt.sessions) == 0 && c.capt.excl == nil {
302325 return nil, nil
303326 }
304327 c.pruneRetained()
305328 // The scratch is the view and the states of this evaluation; nothing the
306329 // scenes or the commands keep points into it.
307- v := &c.capScratch
330+ v := &c.capt.scratch
308331 *v = capView{}
309332 // Decide each session. The live ones are kept in place.
310- states := c.capStates[:0]
311- alive := c.capSessions[:0]
312- for i, s := range c.capSessions {
333+ states := c.capt.states[:0]
334+ alive := c.capt.sessions[:0]
335+ for i, s := range c.capt.sessions {
313336 st := ports.CaptureSessionState{ID: s.open.ID}
314337 r, reason := c.capResolve(s.target())
315338 if reason == ports.CaptureReasonNone {
@@ -324,21 +347,21 @@ func (c *Core) captureEvaluate(ctx context.Context) (*capView, error) {
324347 states = append(states, st)
325348 if reason != ports.CaptureReasonNone {
326349 if err := c.command(ctx, st); err != nil {
327- c.capSessions = append(alive, c.capSessions[i:]...)
350+ c.capt.sessions = append(alive, c.capt.sessions[i:]...)
328351 return nil, err
329352 }
330- if c.capExcl != nil && c.capExcl.session == s.open.ID {
353+ if c.capt.excl != nil && c.capt.excl.session == s.open.ID {
331354 c.dropExclusion()
332355 }
333356 continue
334357 }
335358 alive = append(alive, s)
336359 }
337- clear(c.capSessions[len(alive):])
338- c.capSessions = alive
339- c.capStates = states
360+ clear(c.capt.sessions[len(alive):])
361+ c.capt.sessions = alive
362+ c.capt.states = states
340363 // Exclusion.
341- if e := c.capExcl; e != nil {
364+ if e := c.capt.excl; e != nil {
342365 ex := c.captureExcluded(e)
343366 if len(ex) > ports.MaxCaptureExcluded {
344367 // Fail closed: the session cannot leave them all out.
@@ -346,7 +369,7 @@ func (c *Core) captureEvaluate(ctx context.Context) (*capView, error) {
346369 if err := c.command(ctx, st); err != nil {
347370 return nil, err
348371 }
349- c.capSessions = slices.DeleteFunc(c.capSessions, func(s *capSession) bool { return s.open.ID == e.session })
372+ c.capt.sessions = slices.DeleteFunc(c.capt.sessions, func(s *capSession) bool { return s.open.ID == e.session })
350373 c.dropExclusion()
351374 } else {
352375 v.exclusion = e
@@ -370,14 +393,14 @@ func (c *Core) captureEvaluate(ctx context.Context) (*capView, error) {
370393 }
371394 }
372395 // Tell wayland what changed.
373- for _, s := range c.capSessions {
396+ for _, s := range c.capt.sessions {
374397 var st ports.CaptureSessionState
375398 for _, x := range states {
376399 if x.ID == s.open.ID {
377400 st = x
378401 }
379402 }
380- if e := c.capExcl; e != nil && e.session == s.open.ID {
403+ if e := c.capt.excl; e != nil && e.session == s.open.ID {
381404 st.Exclusion, st.Revision = true, e.rev
382405 // The list is sent, read by another goroutine: shared, never
383406 // rebuilt, while the attached set is the same.
@@ -407,7 +430,7 @@ func sameCaptureState(a, b ports.CaptureSessionState) bool {
407430 // captureSceneFor is the capture state of one screen's scene; nil when
408431 // there is no session. Shown is the workspace drawn as itself on screen.
409432 func (c *Core) captureSceneFor(sc *screen, v *capView) *ports.SceneCapture {
410- if v == nil || len(c.capSessions) == 0 && v.exclusion == nil {
433+ if v == nil || len(c.capt.sessions) == 0 && v.exclusion == nil {
411434 return nil
412435 }
413436 want := ports.SceneCapture{}
M internal/core/capture_internal_test.go +6 -6
@@ -19,7 +19,7 @@ func TestExclusionKeepIsSharedAndBuiltOncePerChange(t *testing.T) {
1919 c.captureExclusionBegin(ports.CaptureExclusionBegin{Session: 1})
2020 c.captureExclusionLayer(ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true})
2121 c.captureExclusionLayer(ports.CaptureExclusionLayer{Session: 1, Layer: 11, Attached: true})
22- e := c.capExcl
22+ e := c.capt.excl
2323 keep := c.exclusionKeep(e)
2424 if len(keep) != 2 || !keep[10] || !keep[11] {
2525 t.Fatalf("keep %v", keep)
@@ -43,17 +43,17 @@ func TestExclusionKeepIsSharedAndBuiltOncePerChange(t *testing.T) {
4343 // An ended exclusion keeps nothing over a fullscreen window.
4444 c.screens[0].layers = []ports.LayerSurface{{ID: 10}}
4545 c.captureExclusionEnd(1)
46- if c.capExcl == nil || !c.capExcl.ended || !slices.Equal(c.capExcl.retained, []WindowID{10}) {
47- t.Fatalf("ended exclusion %+v", c.capExcl)
46+ if c.capt.excl == nil || !c.capt.excl.ended || !slices.Equal(c.capt.excl.retained, []WindowID{10}) {
47+ t.Fatalf("ended exclusion %+v", c.capt.excl)
4848 }
49- if k := c.exclusionKeep(c.capExcl); k != nil {
49+ if k := c.exclusionKeep(c.capt.excl); k != nil {
5050 t.Fatalf("keep of an ended exclusion %v", k)
5151 }
5252 // With no layer listed any more it is gone.
5353 c.screens[0].layers = nil
5454 c.pruneRetained()
55- if c.capExcl != nil {
56- t.Fatalf("exclusion survived its last listed layer: %+v", c.capExcl)
55+ if c.capt.excl != nil {
56+ t.Fatalf("exclusion survived its last listed layer: %+v", c.capt.excl)
5757 }
5858 }
5959
M internal/core/capture_workspace_test.go +4 -4
@@ -190,7 +190,7 @@ func TestCaptureWorkspaceReusesStorage(t *testing.T) {
190190 func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) {
191191 c, ws, _ := hiddenCaptureCommands(t)
192192 c.configures.cw.reset()
193- c.capSessions = []*capSession{{open: ports.CaptureSessionOpen{ID: 1, Workspace: ws.ID}}}
193+ c.capt.sessions = []*capSession{{open: ports.CaptureSessionOpen{ID: 1, Workspace: ws.ID}}}
194194 c.popups[10] = &popupState{id: 10, parent: 1, rect: Rect{X: 3, Y: 4, W: 10, H: 8}, mapped: true}
195195 c.popupOrder = append(c.popupOrder, 10)
196196 ws.motion = newMotion(viewSpring(10, 0), time.Time{})
@@ -198,7 +198,7 @@ func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) {
198198 if err := c.publish(ctx); err != nil {
199199 t.Fatal(err)
200200 }
201- if v := c.capSessions[0].last; !v.Hidden || !v.Active || v.Reason.Terminal() {
201+ if v := c.capt.sessions[0].last; !v.Hidden || !v.Active || v.Reason.Terminal() {
202202 t.Fatalf("session state %+v", v)
203203 }
204204 if !c.configures.cw.active() || c.popups[10] == nil {
@@ -212,7 +212,7 @@ func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) {
212212 t.Fatal(err)
213213 }
214214 if !c.configures.cw.active() {
215- t.Fatalf("captured workspace lost after the slide: %+v", c.capSessions[0].last)
215+ t.Fatalf("captured workspace lost after the slide: %+v", c.capt.sessions[0].last)
216216 }
217217 // The session ends: the captured workspace is forgotten and its popup goes.
218218 c.captureClose(1)
@@ -250,7 +250,7 @@ func TestCapturedWorkspaceInOverviewKeepsRealSize(t *testing.T) {
250250 before[p.ID] = v
251251 }
252252 m.ToggleOverview()
253- c.capSessions = []*capSession{{open: ports.CaptureSessionOpen{ID: 1, Workspace: ws.ID}}}
253+ c.capt.sessions = []*capSession{{open: ports.CaptureSessionOpen{ID: 1, Workspace: ws.ID}}}
254254 if err := c.publish(ctx); err != nil {
255255 t.Fatal(err)
256256 }
M internal/core/core.go +37 -27
@@ -121,18 +121,8 @@ type Core struct {
121121 // last position sent in the pointer's window.
122122 motionMsec uint32
123123 pointerAt [2]float64
124- // capSessions and capExcl are the capture state (capture.go).
125- capSessions []*capSession
126- capExcl *capExclusion
127- // capScratch, capStates and capMarks are scratch of one publish, reused.
128- capScratch capView
129- capStates []ports.CaptureSessionState
130- capMarks []ports.CaptureIndicator
131- // capFlashes are the targets of captured frames still flashing; capC
132- // fires at the earliest of them (capindicator.go).
133- capFlashes []capFlash
134- capC <-chan time.Time
135- capStop func() bool
124+ // capt is the capture state (capture.go, capindicator.go).
125+ capt captureState
136126 }
137127
138128 func keyName(s string) string {
@@ -489,14 +479,8 @@ func (c *Core) publish(ctx context.Context) error {
489479 return err
490480 }
491481 // Clients learn outputs and scales before the configures sized for them.
492- if v := (ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()}); !sameOutputs(v, c.sentOutputs) {
493- if err := c.command(ctx, v); err != nil {
494- return err
495- }
496- if !slices.Equal(v.Outputs, c.sentOutputs.Outputs) && c.ch.Layouts != nil {
497- latest(c.ch.Layouts, v.Outputs)
498- }
499- c.sentOutputs = v
482+ if err := c.syncOutputs(ctx); err != nil {
483+ return err
500484 }
501485 // Output layout must be known before an automatic terminal can map.
502486 if err := c.spawnEmpty(ctx); err != nil {
@@ -582,11 +566,8 @@ func (c *Core) publish(ctx context.Context) error {
582566 }
583567 }
584568 }
585- if focus != c.keyboard.sent {
586- if err := c.command(ctx, ports.FocusWindow{ID: focus}); err != nil {
587- return err
588- }
589- c.keyboard.sent = focus
569+ if err := c.syncFocus(ctx, focus); err != nil {
570+ return err
590571 }
591572 if err := c.updateInhibit(ctx); err != nil {
592573 return err
@@ -785,6 +766,35 @@ func (c *Core) warpPointer(ctx context.Context, v ports.PointerWarp) error {
785766 return c.rehit(ctx)
786767 }
787768
769+// syncOutputs sends the output layout when it changed; input learns the
770+// geometry only when the outputs themselves moved.
771+func (c *Core) syncOutputs(ctx context.Context) error {
772+ v := ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()}
773+ if sameOutputs(v, c.sentOutputs) {
774+ return nil
775+ }
776+ if err := c.command(ctx, v); err != nil {
777+ return err
778+ }
779+ if c.ch.Layouts != nil && !slices.Equal(v.Outputs, c.sentOutputs.Outputs) {
780+ latest(c.ch.Layouts, v.Outputs)
781+ }
782+ c.sentOutputs = v
783+ return nil
784+}
785+
786+// syncFocus tells wayland the keyboard focus when it changed.
787+func (c *Core) syncFocus(ctx context.Context, focus WindowID) error {
788+ if focus == c.keyboard.sent {
789+ return nil
790+ }
791+ if err := c.command(ctx, ports.FocusWindow{ID: focus}); err != nil {
792+ return err
793+ }
794+ c.keyboard.sent = focus
795+ return nil
796+}
797+
788798 // latest replaces any unread value: only the owner sends and drains;
789799 // consumers only receive.
790800 func latest[T any](ch chan T, v T) {
@@ -1062,8 +1072,8 @@ func (c *Core) Run(ctx context.Context) error {
10621072 return nil
10631073 }
10641074 continue
1065- case <-c.capC:
1066- c.capC, c.capStop = nil, nil
1075+ case <-c.capt.timerC:
1076+ c.capt.timerC, c.capt.timerStop = nil, nil
10671077 if c.captureFlashTick() && c.publish(ctx) != nil {
10681078 return nil
10691079 }
M internal/core/security.go +4 -14
@@ -170,15 +170,8 @@ func (c *Core) lockHit(x, y float64) (WindowID, float64, float64) {
170170 }
171171
172172 func (c *Core) publishProtected(ctx context.Context) error {
173- v := ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()}
174- if !sameOutputs(v, c.sentOutputs) {
175- if err := c.command(ctx, v); err != nil {
176- return err
177- }
178- if c.ch.Layouts != nil {
179- latest(c.ch.Layouts, v.Outputs)
180- }
181- c.sentOutputs = v
173+ if err := c.syncOutputs(ctx); err != nil {
174+ return err
182175 }
183176 focus := c.lockKeyboardFocus()
184177 scenes := make([]ports.Scene, 0, len(c.screens))
@@ -191,11 +184,8 @@ func (c *Core) publishProtected(ctx context.Context) error {
191184 }
192185 scenes = append(scenes, scene)
193186 }
194- if c.keyboard.sent != focus {
195- if err := c.command(ctx, ports.FocusWindow{ID: focus}); err != nil {
196- return err
197- }
198- c.keyboard.sent = focus
187+ if err := c.syncFocus(ctx, focus); err != nil {
188+ return err
199189 }
200190 // Surface updates may invalidate the role under a stationary pointer.
201191 if !c.validLockID(c.pointer) {
M internal/core/security_internal_test.go +2 -2
@@ -17,7 +17,7 @@ func TestProtectedDropsPrivateOffscreenCaptureAndDesktopEffects(t *testing.T) {
1717 c.ch.Security = gate
1818 c.captureOpen(ports.CaptureSessionOpen{ID: 7, Workspace: ws.ID})
1919 c.captureExclusionBegin(ports.CaptureExclusionBegin{Session: 7})
20- c.capFlashes = []capFlash{{target: capTarget{output: "A"}, until: c.now().Add(time.Hour)}}
20+ c.capt.flashes = []capFlash{{target: capTarget{output: "A"}, until: c.now().Add(time.Hour)}}
2121 c.screens[0].layers = []ports.LayerSurface{{ID: 50, Layer: ports.LayerOverlay, Keyboard: 1}}
2222 c.pressed["password"] = true
2323 c.buttons[0x110] = true
@@ -33,7 +33,7 @@ func TestProtectedDropsPrivateOffscreenCaptureAndDesktopEffects(t *testing.T) {
3333 if s.Security != state || s.Capture != nil || s.CaptureScene != nil || len(s.CaptureIndicators) != 0 || len(s.Windows) != 0 || len(s.Layers) != 0 || len(s.Separators) != 0 || s.Background != "#000000" {
3434 t.Fatalf("unsafe protected scene %+v", s)
3535 }
36- if len(c.capSessions) != 0 || c.capExcl != nil || len(c.capFlashes) != 0 || c.configures.cw.active() || len(c.pressed) != 0 || len(c.buttons) != 0 || c.pointer != 0 || c.grab != 0 || c.keyboard.layer != 0 || c.constrained.ID != 0 || c.swipe != nil {
36+ if len(c.capt.sessions) != 0 || c.capt.excl != nil || len(c.capt.flashes) != 0 || c.configures.cw.active() || len(c.pressed) != 0 || len(c.buttons) != 0 || c.pointer != 0 || c.grab != 0 || c.keyboard.layer != 0 || c.constrained.ID != 0 || c.swipe != nil {
3737 t.Fatal("transition kept desktop input/capture state")
3838 }
3939 // Logical dimensions must match exactly, including after output changes.
A internal/ports/capture_messages.go +58 -0
@@ -0,0 +1,58 @@
1+package ports
2+
3+import (
4+ "image"
5+ "time"
6+)
7+
8+// CaptureRequest transfers ownership of Dst.File to the output goroutine.
9+type CaptureRequest struct {
10+ ID uint64
11+ Output string
12+ Region image.Rectangle // clipped output buffer coordinates
13+ Cursor bool
14+ Dst SHMBuffer
15+ Width, Height, Stride int
16+ Format uint32
17+ // Session is the registered capture session (CaptureSessionOpen) the
18+ // request belongs to, 0 for wlr-screencopy. It is informational, except
19+ // with Exclude.
20+ Session uint64
21+ // Exclude asks for a capture without the exclusion's HUD layers and
22+ // popups (SceneCapture.Excluded). Wayland sets it, with Session and
23+ // CaptureRevision, only on requests of the session that owns the
24+ // exclusion. A renderer fails such a request unless its scene's
25+ // Capture.Session is Session and Capture.Revision at least
26+ // CaptureRevision (CaptureSessionState).
27+ Exclude bool
28+ CaptureRevision uint64
29+ // Workspace is not 0 for the capture of a workspace's frame. On screen
30+ // (OffScreen false) it is an ordinary region of the displayed frame, and
31+ // fails unless the scene's Capture.Shown is Workspace. OffScreen is set
32+ // when the workspace was not on screen when the request was made: it is
33+ // served from Scene.CaptureScene, whole (Region is the whole child
34+ // image), and fails unless the scene's Capture.Workspace is Workspace.
35+ // Either way a workspace that moved since fails instead of returning
36+ // another crop.
37+ Workspace uint64
38+ OffScreen bool
39+ // Indicate is set by wayland on every capture it requests: core shows the
40+ // capture indicator for it (CaptureFrameTaken), and the output owner
41+ // serves the request only from a scene that shows the mark it needs (a
42+ // pill for OffScreen, else a border covering Region), so the indicator is
43+ // on screen in the same frame as the capture or an earlier one. It holds
44+ // the request for a bounded time, then fails it.
45+ Indicate bool
46+ // Since is when the output owner took the request (monotonic), to bound
47+ // that wait. Only the output owner sets it.
48+ Since time.Time
49+}
50+
51+// CaptureDone is attempted once after the output closes the destination.
52+// Time holds CLOCK_MONOTONIC seconds and nanoseconds since boot, not wall time.
53+type CaptureDone struct {
54+ ID uint64
55+ Output string
56+ Err error
57+ Time time.Time
58+}
A internal/ports/client_commands.go +223 -0
@@ -0,0 +1,223 @@
1+package ports
2+
3+import "time"
4+
5+// ClientCommand carries core → wayland commands.
6+type ClientCommand interface{ clientCommand() }
7+
8+// ShortcutsInhibitState carries core → wayland whether a window's
9+// shortcuts inhibitor is in effect (it has keyboard focus).
10+type ShortcutsInhibitState struct {
11+ Window WindowID
12+ Active bool
13+}
14+
15+func (ShortcutsInhibitState) clientCommand() {}
16+
17+// UserActivity carries core → wayland that the user touched an input
18+// device, for idle notifications (ext_idle_notifier_v1). Core sends at
19+// most one per ActivityInterval.
20+type UserActivity struct{}
21+
22+func (UserActivity) clientCommand() {}
23+
24+// ActivityInterval is the most often core reports UserActivity: idle
25+// timers may start up to this much early, so no idle timeout is shorter.
26+const ActivityInterval = 100 * time.Millisecond
27+
28+// ConfigureWindow carries core → wayland geometry and state. Output is the
29+// connector showing the window, empty while it is hidden.
30+type ConfigureWindow struct {
31+ ID WindowID
32+ Width, Height int
33+ Fullscreen, Activated bool
34+ // Floating windows are not tiled: no tiled states.
35+ Floating bool
36+ // Output is the output the window belongs to, kept while it is not
37+ // visible (scale, foreign toplevel).
38+ Output string
39+ // Visible is set while part of the window is on its output: not on a
40+ // hidden workspace, scrolled off, behind a maximized column or a
41+ // hidden float. An invisible window is suspended and throttled.
42+ Visible bool
43+ // Captured marks a window drawn only for a capture session (its
44+ // workspace is not on screen): Visible stays the physical truth.
45+ Captured bool
46+}
47+
48+func (ConfigureWindow) clientCommand() {}
49+
50+// OutputPlacement is one output in the global layout. X, Y, Width and
51+// Height are logical; physical = logical × Scale.
52+type OutputPlacement struct {
53+ Info OutputInfo
54+ X, Y, Width, Height int
55+ Scale float64
56+ // Primary is output.<name>.primary: the pointer starts on it.
57+ Primary bool
58+}
59+
60+// Contains reports whether the logical point is on the output.
61+func (o OutputPlacement) Contains(x, y float64) bool {
62+ return x >= float64(o.X) && x < float64(o.X+o.Width) && y >= float64(o.Y) && y < float64(o.Y+o.Height)
63+}
64+
65+// Layout is the global arrangement of outputs, left to right.
66+type Layout []OutputPlacement
67+
68+// At returns the output under the logical point.
69+func (l Layout) At(x, y float64) (OutputPlacement, bool) {
70+ for _, o := range l {
71+ if o.Contains(x, y) {
72+ return o, true
73+ }
74+ }
75+ return OutputPlacement{}, false
76+}
77+
78+// Clamp keeps the pointer on an output: a point outside every output stays
79+// on the output under (fromX, fromY), or the first one, clamped to its edges.
80+func (l Layout) Clamp(fromX, fromY, x, y float64) (float64, float64) {
81+ if len(l) == 0 {
82+ return x, y
83+ }
84+ if _, ok := l.At(x, y); ok {
85+ return x, y
86+ }
87+ o, ok := l.At(fromX, fromY)
88+ if !ok {
89+ o = l[0]
90+ }
91+ x = min(max(x, float64(o.X)), float64(o.X+o.Width)-1)
92+ y = min(max(y, float64(o.Y)), float64(o.Y+o.Height)-1)
93+ return x, y
94+}
95+
96+// SetOutputs carries core → wayland every output and the focused one (where
97+// new surfaces start). It is sent when the list, a placement or the focus changes.
98+type SetOutputs struct {
99+ Outputs Layout
100+ Focused string
101+ // Off lists the outputs a client turned off (output power management),
102+ // in Outputs order.
103+ Off []string
104+}
105+
106+func (SetOutputs) clientCommand() {}
107+
108+// SlotsPending tells wayland whether a slot waits for its window. Only then
109+// does it read the SlotEnv of mapping clients.
110+type SlotsPending struct{ Pending bool }
111+
112+func (SlotsPending) clientCommand() {}
113+
114+// CloseWindow carries core → wayland close requests.
115+type CloseWindow struct{ ID WindowID }
116+
117+func (CloseWindow) clientCommand() {}
118+
119+// FocusWindow carries core → wayland keyboard focus (0 means none).
120+type FocusWindow struct{ ID WindowID }
121+
122+func (FocusWindow) clientCommand() {}
123+
124+// ConfigurePopup carries core → wayland a popup's place, relative to its
125+// parent's window geometry. Reposition answers a reposition request.
126+type ConfigurePopup struct {
127+ ID WindowID
128+ Rect Rect
129+ Reposition bool
130+ Token uint32
131+}
132+
133+func (ConfigurePopup) clientCommand() {}
134+
135+// ClosePopup carries core → wayland a popup to dismiss (popup_done).
136+type ClosePopup struct{ ID WindowID }
137+
138+func (ClosePopup) clientCommand() {}
139+
140+// PointerFocus changes the pointer surface; ID 0 clears focus. Coordinates are surface-local.
141+type PointerFocus struct {
142+ ID WindowID
143+ X, Y float64
144+}
145+
146+func (PointerFocus) clientCommand() {}
147+
148+// PointerMotionTo carries core → wayland motion for the pointer focus, in
149+// window coordinates. The deltas and TimeUsec feed zwp_relative_pointer_v1;
150+// While the window locks the pointer, wayland sends only relative motion.
151+type PointerMotionTo struct {
152+ ID WindowID
153+ X, Y float64
154+ DX, DY float64
155+ UnaccelDX, UnaccelDY float64
156+ TimeMsec uint32
157+ TimeUsec uint64
158+}
159+
160+func (PointerMotionTo) clientCommand() {}
161+
162+type PointerButtonTo struct {
163+ ID WindowID
164+ Button uint32
165+ Pressed bool
166+ TimeMsec uint32
167+}
168+
169+func (PointerButtonTo) clientCommand() {}
170+
171+// PointerAxisTo sends a scroll frame to a window.
172+type PointerAxisTo struct {
173+ ID WindowID
174+ Axis PointerAxis
175+}
176+
177+func (PointerAxisTo) clientCommand() {}
178+
179+// ForwardKey carries core → wayland unbound keys.
180+type ForwardKey struct {
181+ ID WindowID
182+ Key KeyEvent
183+}
184+
185+func (ForwardKey) clientCommand() {}
186+
187+// SetKeymap carries app → wayland repeat info and, unless Keymap is empty, a new
188+// xkb keymap in text format.
189+type SetKeymap struct {
190+ Keymap string
191+ RepeatRate, RepeatDelay int
192+}
193+
194+func (SetKeymap) clientCommand() {}
195+
196+// CursorImage is a cursor frame in premultiplied B8G8R8A8, W*4 bytes per
197+// row; (HotX, HotY) is the click point. Sizes are physical pixels.
198+type CursorImage struct {
199+ W, H, HotX, HotY int
200+ Pixels []byte
201+}
202+
203+// CursorChange carries wayland → outputs the cursor the focused client asks
204+// for: a CSS cursor name (wp_cursor_shape_v1), its own image, or none.
205+// The zero value is the default arrow.
206+type CursorChange struct {
207+ // Shape is a CSS cursor name such as "pointer" or "text".
208+ Shape string
209+ // Image is a client cursor at Scale buffer pixels per logical pixel.
210+ Image *CursorImage
211+ Scale int
212+ Hidden bool
213+}
214+
215+// SpawnRequest carries core → launcher process arguments.
216+type SpawnRequest struct {
217+ // Security retains the admitting owner epoch through launcher backpressure.
218+ // A wired launcher rejects stale or protected requests before execution.
219+ Security SecurityState
220+ Argv []string
221+ // Env adds KEY=value entries to the child environment.
222+ Env []string
223+}
A internal/ports/client_events.go +275 -0
@@ -0,0 +1,275 @@
1+package ports
2+
3+// ClientEvent carries wayland → core notifications.
4+type ClientEvent interface{ clientEvent() }
5+
6+// WindowMapped carries wayland → core mapping.
7+type WindowMapped struct {
8+ ID WindowID
9+ AppID string
10+ // Slot is the SlotEnv value of the client process, empty if none.
11+ Slot string
12+ // PID is the client process, 0 when unknown.
13+ PID int
14+ // Floating windows are dialogs (a parent) or fixed-size windows such
15+ // as splash screens: core shows them over the columns at their own
16+ // size, Width×Height logical (0 when unknown).
17+ Floating bool
18+ Width, Height int
19+}
20+
21+// SlotEnv is the environment variable neferwl sets on processes it spawns
22+// for a slot. The client's value maps its windows to the slot.
23+const SlotEnv = "NEFERWL_SLOT"
24+
25+func (WindowMapped) clientEvent() {}
26+
27+// InputRegionChanged replaces the surface-local hit area for a mapped surface.
28+// All means the full hit rectangle; otherwise Rects is the exact union.
29+type InputRegionChanged struct {
30+ ID WindowID
31+ All bool
32+ Rects []Rect
33+}
34+
35+func (InputRegionChanged) clientEvent() {}
36+
37+// WindowUnmapped carries wayland → core removal.
38+type WindowUnmapped struct{ ID WindowID }
39+
40+func (WindowUnmapped) clientEvent() {}
41+
42+// ShortcutsInhibit carries wayland → core that a window asks for (Active)
43+// or stops asking for all keys, compositor binds included, while it has
44+// keyboard focus (zwp_keyboard_shortcuts_inhibit_v1).
45+type ShortcutsInhibit struct {
46+ Window WindowID
47+ Active bool
48+}
49+
50+func (ShortcutsInhibit) clientEvent() {}
51+
52+// IdleInhibit carries wayland → core that a window keeps (Active) or
53+// stops keeping the session from going idle (zwp_idle_inhibit_v1).
54+type IdleInhibit struct {
55+ Window WindowID
56+ Active bool
57+}
58+
59+func (IdleInhibit) clientEvent() {}
60+
61+// OutputPower carries wayland → core a client turning a display off or
62+// back on (zwlr_output_power_v1).
63+type OutputPower struct {
64+ Output string
65+ On bool
66+}
67+
68+func (OutputPower) clientEvent() {}
69+
70+// WindowFullscreenRequest carries wayland → core fullscreen requests.
71+// External is set when another client (a taskbar, through foreign
72+// toplevel) asks for it, not the window itself.
73+type WindowFullscreenRequest struct {
74+ ID WindowID
75+ Fullscreen bool
76+ External bool
77+}
78+
79+func (WindowFullscreenRequest) clientEvent() {}
80+
81+// WindowActivate carries wayland → core a valid xdg-activation request:
82+// show the window's workspace and focus it.
83+type WindowActivate struct{ ID WindowID }
84+
85+func (WindowActivate) clientEvent() {}
86+
87+// WorkspaceActivate requests showing workspaces by stable ID, in commit order.
88+// Core applies the entire batch before publishing a new state.
89+type WorkspaceActivate struct{ IDs []uint64 }
90+
91+func (WorkspaceActivate) clientEvent() {}
92+
93+// Workspaces is an immutable, latest-only inventory of output workspaces.
94+type Workspaces struct{ Outputs []WorkspaceOutput }
95+type WorkspaceOutput struct {
96+ Name string
97+ Workspaces []WorkspaceInfo
98+}
99+type WorkspaceInfo struct {
100+ ID uint64
101+ Configured string // configured name; empty for dynamic workspaces. ID, not this, identifies the workspace
102+ Name string
103+ Index int // zero-based vertical coordinate
104+ Active, Hidden bool
105+ // Frame is the workspace viewport in output-local logical pixels
106+ // (Monitor.Frame geometry: the whole output unless the workspace has a
107+ // size override). It is where a capture session of it lies.
108+ Frame Rect
109+}
110+
111+// WindowAppID carries wayland → core an app ID set after the window mapped.
112+type WindowAppID struct {
113+ ID WindowID
114+ AppID string
115+}
116+
117+func (WindowAppID) clientEvent() {}
118+
119+// WindowResized carries wayland → core the new size of a floating window.
120+type WindowResized struct {
121+ ID WindowID
122+ Width, Height int
123+}
124+
125+func (WindowResized) clientEvent() {}
126+
127+// Popup anchors and gravities, as xdg_positioner values.
128+const (
129+ EdgeNone uint32 = iota
130+ EdgeTop
131+ EdgeBottom
132+ EdgeLeft
133+ EdgeRight
134+ EdgeTopLeft
135+ EdgeBottomLeft
136+ EdgeTopRight
137+ EdgeBottomRight
138+)
139+
140+// Popup constraint adjustments, as xdg_positioner values.
141+const (
142+ AdjustSlideX uint32 = 1 << iota
143+ AdjustSlideY
144+ AdjustFlipX
145+ AdjustFlipY
146+ AdjustResizeX
147+ AdjustResizeY
148+)
149+
150+// Positioner places a popup relative to its parent's window geometry:
151+// the Anchor point of AnchorRect, moved by Offset, with the popup extending
152+// towards Gravity. Adjust says how core may move it to stay on screen.
153+type Positioner struct {
154+ Width, Height int
155+ AnchorRect Rect
156+ Anchor, Gravity uint32
157+ OffsetX, OffsetY int
158+ Adjust uint32
159+}
160+
161+// PopupRequest carries wayland → core a popup to place (xdg_popup), on
162+// its first commit and on reposition (Reposition, with the client's
163+// Token). Parent is a window or popup. Grab popups take the keyboard and
164+// close on a click elsewhere.
165+type PopupRequest struct {
166+ ID, Parent WindowID
167+ Positioner Positioner
168+ Grab bool
169+ Reposition bool
170+ Token uint32
171+}
172+
173+func (PopupRequest) clientEvent() {}
174+
175+// PopupMapped carries wayland → core a placed popup that now has content.
176+// It ends with WindowUnmapped.
177+type PopupMapped struct{ ID WindowID }
178+
179+func (PopupMapped) clientEvent() {}
180+
181+// Layer is a wlr-layer-shell stacking layer.
182+type Layer uint32
183+
184+const (
185+ LayerBackground Layer = iota
186+ LayerBottom
187+ LayerTop
188+ LayerOverlay
189+)
190+
191+// Anchor edges, as in zwlr_layer_surface_v1.anchor.
192+const (
193+ AnchorTop uint32 = 1
194+ AnchorBottom uint32 = 2
195+ AnchorLeft uint32 = 4
196+ AnchorRight uint32 = 8
197+)
198+
199+// LayerSurface is the committed state of one mapped layer surface. Width and
200+// Height are the committed surface size in logical pixels. Margin is top, right, bottom, left.
201+// IDs share the WindowID space with windows and never collide.
202+type LayerSurface struct {
203+ ID WindowID
204+ Layer Layer
205+ Anchor uint32
206+ ExclusiveZone int32
207+ Margin [4]int32
208+ Width, Height int
209+ Namespace string
210+ // Keyboard is the requested interactivity: 0 none, 1 exclusive, 2 on-demand.
211+ Keyboard uint32
212+ // Output is the connector the surface is on.
213+ Output string
214+}
215+
216+// LayerChanged carries wayland → core the full list of mapped layer surfaces,
217+// sent whenever a layer surface maps, unmaps or commits new layer state.
218+type LayerChanged struct{ Layers []LayerSurface }
219+
220+func (LayerChanged) clientEvent() {}
221+
222+// ConstraintMode is how a pointer constraint holds the pointer.
223+type ConstraintMode uint8
224+
225+const (
226+ ConstraintNone ConstraintMode = iota
227+ // ConstraintLock keeps the pointer still: only relative motion flows.
228+ ConstraintLock
229+ // ConstraintConfine keeps the pointer inside Rect.
230+ ConstraintConfine
231+)
232+
233+// PointerConstraint is an active pointer constraint. Rect is logical: in
234+// PointerConstrained it is window-local and empty means the whole window;
235+// core sends input the resolved global rectangle, and in X, Y its cursor
236+// position, where input holds a locked pointer. Warp moves input's
237+// pointer to X, Y whatever the mode (wp_pointer_warp_v1).
238+type PointerConstraint struct {
239+ Mode ConstraintMode
240+ Rect Rect
241+ X, Y float64
242+ Warp bool
243+}
244+
245+// Clamp keeps a global point inside the rectangle of a lock or confine;
246+// no constraint and an empty rectangle leave it unchanged.
247+func (c PointerConstraint) Clamp(x, y float64) (float64, float64) {
248+ if c.Mode == ConstraintNone || c.Rect.W <= 0 || c.Rect.H <= 0 {
249+ return x, y
250+ }
251+ x = min(max(x, float64(c.Rect.X)), float64(c.Rect.X+c.Rect.W-1))
252+ y = min(max(y, float64(c.Rect.Y)), float64(c.Rect.Y+c.Rect.H-1))
253+ return x, y
254+}
255+
256+// PointerConstrained carries wayland → core the constraint active on a
257+// window (zwp_pointer_constraints_v1); ID 0 means none is active. A
258+// constraint is active only on the window with both pointer and keyboard
259+// focus.
260+type PointerConstrained struct {
261+ ID WindowID
262+ PointerConstraint
263+}
264+
265+func (PointerConstrained) clientEvent() {}
266+
267+// PointerWarp carries wayland → core a client's request to move the
268+// pointer to X, Y, window-local logical (wp_pointer_warp_v1). Wayland has
269+// checked that the window has the pointer and the point is on it.
270+type PointerWarp struct {
271+ ID WindowID
272+ X, Y float64
273+}
274+
275+func (PointerWarp) clientEvent() {}
A internal/ports/input_events.go +112 -0
@@ -0,0 +1,112 @@
1+package ports
2+
3+import "time"
4+
5+// Mods carries input → core modifier flags.
6+type Mods uint8
7+
8+const (
9+ ModShift Mods = 1 << iota
10+ ModCtrl
11+ ModAlt
12+ ModSuper
13+)
14+
15+// InputEvent carries input → core notifications.
16+type InputEvent interface{ inputEvent() }
17+
18+// KeyEvent carries input → core key transitions; Keysym is an xkb keysym name.
19+// Keycode is the evdev code (xkb keycode - 8) and State the xkb modifier state
20+// after this transition; wayland forwards both to clients unchanged.
21+type KeyEvent struct {
22+ Keysym string
23+ // Base is the key's unshifted keysym in the active layout, so Cmd+Shift+1
24+ // still matches a cmd+shift+1 bind although it prints exclam.
25+ Base string
26+ Mods Mods
27+ Pressed bool
28+ TimeMsec uint32
29+ Keycode uint32
30+ State ModState
31+}
32+
33+// ModState is the serialized xkb modifier state sent in wl_keyboard.modifiers.
34+type ModState struct{ Depressed, Latched, Locked, Group uint32 }
35+
36+func (KeyEvent) inputEvent() {}
37+
38+// PointerMotion uses global layout coordinates in logical pixels (see OutputPlacement).
39+// DX and DY are the accelerated logical deltas and UnaccelDX, UnaccelDY the
40+// raw device deltas; all four are 0 for absolute devices. TimeUsec is the
41+// device timestamp in microseconds.
42+type PointerMotion struct {
43+ X, Y float64
44+ DX, DY float64
45+ UnaccelDX, UnaccelDY float64
46+ TimeMsec uint32
47+ TimeUsec uint64
48+}
49+
50+func (PointerMotion) inputEvent() {}
51+
52+// PointerButton uses evdev button codes (BTN_LEFT is 0x110).
53+type PointerButton struct {
54+ Button uint32
55+ Pressed bool
56+ TimeMsec uint32
57+}
58+
59+func (PointerButton) inputEvent() {}
60+
61+// AxisSource tells how a scroll was made; values match wl_pointer.axis_source.
62+type AxisSource uint8
63+
64+const (
65+ AxisWheel AxisSource = iota
66+ AxisFinger
67+ AxisContinuous
68+)
69+
70+// ScrollAxis is one scroll direction in a pointer frame. Value is in
71+// surface pixels; V120 counts wheel detents in 1/120 steps (wheel only).
72+// Stop ends a finger or continuous scroll on this axis.
73+type ScrollAxis struct {
74+ Set bool
75+ Value float64
76+ V120 int32
77+ Stop bool
78+}
79+
80+// PointerAxis is one scroll frame: Vertical, then Horizontal.
81+type PointerAxis struct {
82+ Source AxisSource
83+ Vertical, Horizontal ScrollAxis
84+ TimeMsec uint32
85+}
86+
87+func (PointerAxis) inputEvent() {}
88+
89+// SwipeBegin starts a three- or four-finger touchpad swipe. Time is the
90+// device timestamp (CLOCK_MONOTONIC), shared by the updates and the end.
91+type SwipeBegin struct {
92+ Fingers int
93+ Time time.Duration
94+}
95+
96+// SwipeUpdate moves the fingers by the unaccelerated deltas DX, DY
97+// (touchpad units, as libinput reports them, not natural-scroll inverted).
98+type SwipeUpdate struct {
99+ DX, DY float64
100+ Time time.Duration
101+}
102+
103+// SwipeEnd lifts the fingers. Cancelled is set when libinput cancelled
104+// the swipe (another finger landed).
105+type SwipeEnd struct {
106+ Cancelled bool
107+ Time time.Duration
108+}
109+
110+func (SwipeBegin) inputEvent() {}
111+func (SwipeUpdate) inputEvent() {}
112+func (SwipeEnd) inputEvent() {}
A internal/ports/lease.go +71 -0
@@ -0,0 +1,71 @@
1+package ports
2+
3+import "os"
4+
5+// LeaseConnector identifies a non-desktop connector available for DRM leasing.
6+type LeaseConnector struct {
7+ Card, Name, Description string
8+ ConnectorID uint32
9+}
10+
11+// LeaseMessage is an adapter-to-adapter DRM lease channel message.
12+type LeaseMessage interface{ leaseMessage() }
13+
14+// LeaseConnectors replaces the available connector inventory for Card.
15+// Device is a non-master DRM fd owned by the receiver, which closes it.
16+// An empty Connectors list with nil Device removes the card's global and closes
17+// the display's fd. A later nonempty inventory creates a new global.
18+type LeaseConnectors struct {
19+ Card string
20+ Device *os.File
21+ Connectors []LeaseConnector
22+}
23+
24+func (LeaseConnectors) leaseMessage() {}
25+
26+// CloseLeaseFiles closes the fd a lease message owns, for a message that is
27+// dropped instead of delivered.
28+func CloseLeaseFiles(msg LeaseMessage) {
29+ switch m := msg.(type) {
30+ case LeaseConnectors:
31+ if m.Device != nil {
32+ m.Device.Close()
33+ }
34+ case LeaseReply:
35+ if m.FD != nil {
36+ m.FD.Close()
37+ }
38+ }
39+}
40+
41+type LeaseRequest struct {
42+ ID uint64
43+ Card string
44+ Connectors []string
45+}
46+
47+func (LeaseRequest) leaseMessage() {}
48+
49+// LeaseReply transfers ownership of FD to Wayland.
50+type LeaseReply struct {
51+ ID uint64
52+ FD *os.File
53+ LeaseID uint32
54+ Err error
55+}
56+
57+func (LeaseReply) leaseMessage() {}
58+
59+type LeaseRevoke struct {
60+ Card string
61+ LeaseID uint32
62+}
63+
64+func (LeaseRevoke) leaseMessage() {}
65+
66+type LeaseFinished struct {
67+ Card string
68+ LeaseID uint32
69+}
70+
71+func (LeaseFinished) leaseMessage() {}
D internal/ports/messages.go +0 -1247
@@ -1,1247 +0,0 @@
1-package ports
2-
3-import (
4- "image"
5- "os"
6- "time"
7-)
8-
9-// CaptureRequest transfers ownership of Dst.File to the output goroutine.
10-type CaptureRequest struct {
11- ID uint64
12- Output string
13- Region image.Rectangle // clipped output buffer coordinates
14- Cursor bool
15- Dst SHMBuffer
16- Width, Height, Stride int
17- Format uint32
18- // Session is the registered capture session (CaptureSessionOpen) the
19- // request belongs to, 0 for wlr-screencopy. It is informational, except
20- // with Exclude.
21- Session uint64
22- // Exclude asks for a capture without the exclusion's HUD layers and
23- // popups (SceneCapture.Excluded). Wayland sets it, with Session and
24- // CaptureRevision, only on requests of the session that owns the
25- // exclusion. A renderer fails such a request unless its scene's
26- // Capture.Session is Session and Capture.Revision at least
27- // CaptureRevision (CaptureSessionState).
28- Exclude bool
29- CaptureRevision uint64
30- // Workspace is not 0 for the capture of a workspace's frame. On screen
31- // (OffScreen false) it is an ordinary region of the displayed frame, and
32- // fails unless the scene's Capture.Shown is Workspace. OffScreen is set
33- // when the workspace was not on screen when the request was made: it is
34- // served from Scene.CaptureScene, whole (Region is the whole child
35- // image), and fails unless the scene's Capture.Workspace is Workspace.
36- // Either way a workspace that moved since fails instead of returning
37- // another crop.
38- Workspace uint64
39- OffScreen bool
40- // Indicate is set by wayland on every capture it requests: core shows the
41- // capture indicator for it (CaptureFrameTaken), and the output owner
42- // serves the request only from a scene that shows the mark it needs (a
43- // pill for OffScreen, else a border covering Region), so the indicator is
44- // on screen in the same frame as the capture or an earlier one. It holds
45- // the request for a bounded time, then fails it.
46- Indicate bool
47- // Since is when the output owner took the request (monotonic), to bound
48- // that wait. Only the output owner sets it.
49- Since time.Time
50-}
51-
52-// CaptureDone is attempted once after the output closes the destination.
53-// Time holds CLOCK_MONOTONIC seconds and nanoseconds since boot, not wall time.
54-type CaptureDone struct {
55- ID uint64
56- Output string
57- Err error
58- Time time.Time
59-}
60-
61-// ClientEvent carries wayland → core notifications.
62-type ClientEvent interface{ clientEvent() }
63-
64-// WindowMapped carries wayland → core mapping.
65-type WindowMapped struct {
66- ID WindowID
67- AppID string
68- // Slot is the SlotEnv value of the client process, empty if none.
69- Slot string
70- // PID is the client process, 0 when unknown.
71- PID int
72- // Floating windows are dialogs (a parent) or fixed-size windows such
73- // as splash screens: core shows them over the columns at their own
74- // size, Width×Height logical (0 when unknown).
75- Floating bool
76- Width, Height int
77-}
78-
79-// SlotEnv is the environment variable neferwl sets on processes it spawns
80-// for a slot. The client's value maps its windows to the slot.
81-const SlotEnv = "NEFERWL_SLOT"
82-
83-func (WindowMapped) clientEvent() {}
84-
85-// InputRegionChanged replaces the surface-local hit area for a mapped surface.
86-// All means the full hit rectangle; otherwise Rects is the exact union.
87-type InputRegionChanged struct {
88- ID WindowID
89- All bool
90- Rects []Rect
91-}
92-
93-func (InputRegionChanged) clientEvent() {}
94-
95-// WindowUnmapped carries wayland → core removal.
96-type WindowUnmapped struct{ ID WindowID }
97-
98-func (WindowUnmapped) clientEvent() {}
99-
100-// ShortcutsInhibit carries wayland → core that a window asks for (Active)
101-// or stops asking for all keys, compositor binds included, while it has
102-// keyboard focus (zwp_keyboard_shortcuts_inhibit_v1).
103-type ShortcutsInhibit struct {
104- Window WindowID
105- Active bool
106-}
107-
108-func (ShortcutsInhibit) clientEvent() {}
109-
110-// IdleInhibit carries wayland → core that a window keeps (Active) or
111-// stops keeping the session from going idle (zwp_idle_inhibit_v1).
112-type IdleInhibit struct {
113- Window WindowID
114- Active bool
115-}
116-
117-func (IdleInhibit) clientEvent() {}
118-
119-// OutputPower carries wayland → core a client turning a display off or
120-// back on (zwlr_output_power_v1).
121-type OutputPower struct {
122- Output string
123- On bool
124-}
125-
126-func (OutputPower) clientEvent() {}
127-
128-// WindowFullscreenRequest carries wayland → core fullscreen requests.
129-// External is set when another client (a taskbar, through foreign
130-// toplevel) asks for it, not the window itself.
131-type WindowFullscreenRequest struct {
132- ID WindowID
133- Fullscreen bool
134- External bool
135-}
136-
137-func (WindowFullscreenRequest) clientEvent() {}
138-
139-// WindowActivate carries wayland → core a valid xdg-activation request:
140-// show the window's workspace and focus it.
141-type WindowActivate struct{ ID WindowID }
142-
143-func (WindowActivate) clientEvent() {}
144-
145-// WorkspaceActivate requests showing workspaces by stable ID, in commit order.
146-// Core applies the entire batch before publishing a new state.
147-type WorkspaceActivate struct{ IDs []uint64 }
148-
149-func (WorkspaceActivate) clientEvent() {}
150-
151-// Workspaces is an immutable, latest-only inventory of output workspaces.
152-type Workspaces struct{ Outputs []WorkspaceOutput }
153-type WorkspaceOutput struct {
154- Name string
155- Workspaces []WorkspaceInfo
156-}
157-type WorkspaceInfo struct {
158- ID uint64
159- Configured string // configured name; empty for dynamic workspaces. ID, not this, identifies the workspace
160- Name string
161- Index int // zero-based vertical coordinate
162- Active, Hidden bool
163- // Frame is the workspace viewport in output-local logical pixels
164- // (Monitor.Frame geometry: the whole output unless the workspace has a
165- // size override). It is where a capture session of it lies.
166- Frame Rect
167-}
168-
169-// WindowAppID carries wayland → core an app ID set after the window mapped.
170-type WindowAppID struct {
171- ID WindowID
172- AppID string
173-}
174-
175-func (WindowAppID) clientEvent() {}
176-
177-// WindowResized carries wayland → core the new size of a floating window.
178-type WindowResized struct {
179- ID WindowID
180- Width, Height int
181-}
182-
183-func (WindowResized) clientEvent() {}
184-
185-// Popup anchors and gravities, as xdg_positioner values.
186-const (
187- EdgeNone uint32 = iota
188- EdgeTop
189- EdgeBottom
190- EdgeLeft
191- EdgeRight
192- EdgeTopLeft
193- EdgeBottomLeft
194- EdgeTopRight
195- EdgeBottomRight
196-)
197-
198-// Popup constraint adjustments, as xdg_positioner values.
199-const (
200- AdjustSlideX uint32 = 1 << iota
201- AdjustSlideY
202- AdjustFlipX
203- AdjustFlipY
204- AdjustResizeX
205- AdjustResizeY
206-)
207-
208-// Positioner places a popup relative to its parent's window geometry:
209-// the Anchor point of AnchorRect, moved by Offset, with the popup extending
210-// towards Gravity. Adjust says how core may move it to stay on screen.
211-type Positioner struct {
212- Width, Height int
213- AnchorRect Rect
214- Anchor, Gravity uint32
215- OffsetX, OffsetY int
216- Adjust uint32
217-}
218-
219-// PopupRequest carries wayland → core a popup to place (xdg_popup), on
220-// its first commit and on reposition (Reposition, with the client's
221-// Token). Parent is a window or popup. Grab popups take the keyboard and
222-// close on a click elsewhere.
223-type PopupRequest struct {
224- ID, Parent WindowID
225- Positioner Positioner
226- Grab bool
227- Reposition bool
228- Token uint32
229-}
230-
231-func (PopupRequest) clientEvent() {}
232-
233-// PopupMapped carries wayland → core a placed popup that now has content.
234-// It ends with WindowUnmapped.
235-type PopupMapped struct{ ID WindowID }
236-
237-func (PopupMapped) clientEvent() {}
238-
239-// Layer is a wlr-layer-shell stacking layer.
240-type Layer uint32
241-
242-const (
243- LayerBackground Layer = iota
244- LayerBottom
245- LayerTop
246- LayerOverlay
247-)
248-
249-// Anchor edges, as in zwlr_layer_surface_v1.anchor.
250-const (
251- AnchorTop uint32 = 1
252- AnchorBottom uint32 = 2
253- AnchorLeft uint32 = 4
254- AnchorRight uint32 = 8
255-)
256-
257-// LayerSurface is the committed state of one mapped layer surface. Width and
258-// Height are the committed surface size in logical pixels. Margin is top, right, bottom, left.
259-// IDs share the WindowID space with windows and never collide.
260-type LayerSurface struct {
261- ID WindowID
262- Layer Layer
263- Anchor uint32
264- ExclusiveZone int32
265- Margin [4]int32
266- Width, Height int
267- Namespace string
268- // Keyboard is the requested interactivity: 0 none, 1 exclusive, 2 on-demand.
269- Keyboard uint32
270- // Output is the connector the surface is on.
271- Output string
272-}
273-
274-// LayerChanged carries wayland → core the full list of mapped layer surfaces,
275-// sent whenever a layer surface maps, unmaps or commits new layer state.
276-type LayerChanged struct{ Layers []LayerSurface }
277-
278-func (LayerChanged) clientEvent() {}
279-
280-// ConstraintMode is how a pointer constraint holds the pointer.
281-type ConstraintMode uint8
282-
283-const (
284- ConstraintNone ConstraintMode = iota
285- // ConstraintLock keeps the pointer still: only relative motion flows.
286- ConstraintLock
287- // ConstraintConfine keeps the pointer inside Rect.
288- ConstraintConfine
289-)
290-
291-// PointerConstraint is an active pointer constraint. Rect is logical: in
292-// PointerConstrained it is window-local and empty means the whole window;
293-// core sends input the resolved global rectangle, and in X, Y its cursor
294-// position, where input holds a locked pointer. Warp moves input's
295-// pointer to X, Y whatever the mode (wp_pointer_warp_v1).
296-type PointerConstraint struct {
297- Mode ConstraintMode
298- Rect Rect
299- X, Y float64
300- Warp bool
301-}
302-
303-// Clamp keeps a global point inside the rectangle of a lock or confine;
304-// no constraint and an empty rectangle leave it unchanged.
305-func (c PointerConstraint) Clamp(x, y float64) (float64, float64) {
306- if c.Mode == ConstraintNone || c.Rect.W <= 0 || c.Rect.H <= 0 {
307- return x, y
308- }
309- x = min(max(x, float64(c.Rect.X)), float64(c.Rect.X+c.Rect.W-1))
310- y = min(max(y, float64(c.Rect.Y)), float64(c.Rect.Y+c.Rect.H-1))
311- return x, y
312-}
313-
314-// PointerConstrained carries wayland → core the constraint active on a
315-// window (zwp_pointer_constraints_v1); ID 0 means none is active. A
316-// constraint is active only on the window with both pointer and keyboard
317-// focus.
318-type PointerConstrained struct {
319- ID WindowID
320- PointerConstraint
321-}
322-
323-func (PointerConstrained) clientEvent() {}
324-
325-// PointerWarp carries wayland → core a client's request to move the
326-// pointer to X, Y, window-local logical (wp_pointer_warp_v1). Wayland has
327-// checked that the window has the pointer and the point is on it.
328-type PointerWarp struct {
329- ID WindowID
330- X, Y float64
331-}
332-
333-func (PointerWarp) clientEvent() {}
334-
335-// Mods carries input → core modifier flags.
336-type Mods uint8
337-
338-const (
339- ModShift Mods = 1 << iota
340- ModCtrl
341- ModAlt
342- ModSuper
343-)
344-
345-// InputEvent carries input → core notifications.
346-type InputEvent interface{ inputEvent() }
347-
348-// KeyEvent carries input → core key transitions; Keysym is an xkb keysym name.
349-// Keycode is the evdev code (xkb keycode - 8) and State the xkb modifier state
350-// after this transition; wayland forwards both to clients unchanged.
351-type KeyEvent struct {
352- Keysym string
353- // Base is the key's unshifted keysym in the active layout, so Cmd+Shift+1
354- // still matches a cmd+shift+1 bind although it prints exclam.
355- Base string
356- Mods Mods
357- Pressed bool
358- TimeMsec uint32
359- Keycode uint32
360- State ModState
361-}
362-
363-// ModState is the serialized xkb modifier state sent in wl_keyboard.modifiers.
364-type ModState struct{ Depressed, Latched, Locked, Group uint32 }
365-
366-func (KeyEvent) inputEvent() {}
367-
368-// PointerMotion uses global layout coordinates in logical pixels (see OutputPlacement).
369-// DX and DY are the accelerated logical deltas and UnaccelDX, UnaccelDY the
370-// raw device deltas; all four are 0 for absolute devices. TimeUsec is the
371-// device timestamp in microseconds.
372-type PointerMotion struct {
373- X, Y float64
374- DX, DY float64
375- UnaccelDX, UnaccelDY float64
376- TimeMsec uint32
377- TimeUsec uint64
378-}
379-
380-func (PointerMotion) inputEvent() {}
381-
382-// PointerButton uses evdev button codes (BTN_LEFT is 0x110).
383-type PointerButton struct {
384- Button uint32
385- Pressed bool
386- TimeMsec uint32
387-}
388-
389-func (PointerButton) inputEvent() {}
390-
391-// AxisSource tells how a scroll was made; values match wl_pointer.axis_source.
392-type AxisSource uint8
393-
394-const (
395- AxisWheel AxisSource = iota
396- AxisFinger
397- AxisContinuous
398-)
399-
400-// ScrollAxis is one scroll direction in a pointer frame. Value is in
401-// surface pixels; V120 counts wheel detents in 1/120 steps (wheel only).
402-// Stop ends a finger or continuous scroll on this axis.
403-type ScrollAxis struct {
404- Set bool
405- Value float64
406- V120 int32
407- Stop bool
408-}
409-
410-// PointerAxis is one scroll frame: Vertical, then Horizontal.
411-type PointerAxis struct {
412- Source AxisSource
413- Vertical, Horizontal ScrollAxis
414- TimeMsec uint32
415-}
416-
417-func (PointerAxis) inputEvent() {}
418-
419-// SwipeBegin starts a three- or four-finger touchpad swipe. Time is the
420-// device timestamp (CLOCK_MONOTONIC), shared by the updates and the end.
421-type SwipeBegin struct {
422- Fingers int
423- Time time.Duration
424-}
425-
426-// SwipeUpdate moves the fingers by the unaccelerated deltas DX, DY
427-// (touchpad units, as libinput reports them, not natural-scroll inverted).
428-type SwipeUpdate struct {
429- DX, DY float64
430- Time time.Duration
431-}
432-
433-// SwipeEnd lifts the fingers. Cancelled is set when libinput cancelled
434-// the swipe (another finger landed).
435-type SwipeEnd struct {
436- Cancelled bool
437- Time time.Duration
438-}
439-
440-func (SwipeBegin) inputEvent() {}
441-func (SwipeUpdate) inputEvent() {}
442-func (SwipeEnd) inputEvent() {}
443-
444-// OutputEvent carries output → core notifications.
445-type OutputEvent interface{ outputEvent() }
446-
447-// OutputInfo describes a connected display. Sizes are physical pixels.
448-type OutputInfo struct {
449- // Name is the connector (e.g. DP-2), matched against output.<name>.* config.
450- Name string
451- // Make, Model and Serial come from EDID; empty when unknown.
452- Make, Model, Serial string
453- Width, Height int
454- RefreshMilli int
455- PhysicalW, PhysicalH int // millimetres
456-}
457-
458-// Key identifies the monitor across connectors: make, model and serial, or
459-// the connector name when EDID has no serial.
460-func (i OutputInfo) Key() string {
461- if i.Serial == "" {
462- return i.Name
463- }
464- return i.Make + " " + i.Model + " " + i.Serial
465-}
466-
467-// OutputMode describes a connected connector's physical mode.
468-type OutputMode struct {
469- Width, Height, RefreshMilli int
470- Preferred bool
471-}
472-
473-// OutputHead describes a connected connector even when it is disabled.
474-// Current is the active mode; it is nil for disabled or not-yet-running heads.
475-type OutputHead struct {
476- Info OutputInfo
477- Modes []OutputMode
478- Current *OutputMode
479- Enabled bool
480-}
481-
482-// OutputHeads is a complete backend inventory, delivered to Wayland after a scan.
483-type OutputHeads struct{ Heads []OutputHead }
484-
485-// HeadChange is the complete desired state of one head in a configuration.
486-type HeadChange struct {
487- Name string
488- Enabled bool
489- Mode *OutputMode
490- Pos *image.Point
491- Scale float64
492-}
493-
494-// OutputApply asks the app owner to validate or apply a runtime configuration.
495-type OutputApply struct {
496- ID uint64
497- Test bool
498- Heads []HeadChange
499-}
500-
501-// OutputApplied reports completion (or failure) of a runtime configuration.
502-type OutputApplied struct {
503- ID uint64
504- Err error
505-}
506-
507-// OutputAdded carries output → core a new display, or a new mode for a known
508-// connector.
509-type OutputAdded struct{ Info OutputInfo }
510-
511-func (OutputAdded) outputEvent() {}
512-
513-// OutputPresented carries output → wayland what an output shows and reads.
514-// Flip is set when a page flip completed: frame callbacks of the surfaces
515-// on it are due. Shown and Queued are the DMABuf IDs scanned out directly
516-// (0: a composed image); Seen is the latest content Seq per window the
517-// output finished reading (the GPU is done with it). A replaced client
518-// buffer is released once every output that reports has seen a later
519-// content of its window and neither shows nor queues it.
520-type OutputPresented struct {
521- Output string
522- Flip *FlipInfo
523- Shown, Queued uint64
524- Seen map[WindowID]uint64
525- // ChildReads is the oldest content Seq per window still read by a child
526- // renderer on a separate device. Unlike Seen, these holds never time out:
527- // they end only with a completed render or device shutdown.
528- ChildReads map[WindowID]uint64
529-}
530-
531-// OutputFormats carries output → wayland the dmabuf formats an output can
532-// scan out directly (its primary plane's, that the renderer also samples,
533-// so a refused buffer can still be composed). Device is the KMS device
534-// (dev_t) clients allocate scanout buffers for. Sent after every successful
535-// modeset, including startup and VT resume; empty Formats means no direct
536-// scanout. HDR is non-nil only when the output's HDR10 modeset succeeded;
537-// nil means SDR, regardless of the monitor's HDR capability.
538-type OutputFormats struct {
539- Output string
540- Device uint64
541- Formats []DMABufFormat
542- HDR *OutputHDR
543-}
544-
545-// OutputHDR carries the active HDR10 output's EDID luminances in nits.
546-// These describe the display, not any client's content metadata.
547-type OutputHDR struct {
548- MaxLuminance, MaxFrameAverage, MinLuminance float64
549-}
550-
551-// FlipInfo is one completed page flip. When is its CLOCK_MONOTONIC time
552-// (hardware clock on DRM), Seq the output's vblank counter, Refresh the
553-// refresh period (0 while variable refresh is on). ZeroCopy is the window
554-// whose buffer was shown without composition (direct scanout or overlay
555-// plane; 0: none), Async set for a tearing flip.
556-// Shows is the content Seq per window the flipped frame shows. Merged
557-// counts earlier flips folded into this one when the reader fell behind:
558-// their presentation feedback is discarded.
559-type FlipInfo struct {
560- When time.Duration
561- Seq uint64
562- Refresh time.Duration
563- ZeroCopy WindowID
564- Async bool
565- HardwareClock bool
566- Merged int
567- Shows map[WindowID]uint64
568-}
569-
570-// OutputFrame carries output → core a completed page flip: the next frame
571-// of a running animation is due.
572-type OutputFrame struct{ Output string }
573-
574-// OutputRemoved carries output → core an unplugged display.
575-type OutputRemoved struct{ Name string }
576-
577-func (OutputRemoved) outputEvent() {}
578-
579-// ConfigChanged carries config → core reloads.
580-type ConfigChanged struct{ Config Config }
581-
582-// ScaleChanged carries core → persistence a scale set by a scale bind.
583-type ScaleChanged struct {
584- Output string
585- Scale float64
586-}
587-
588-// ClientCommand carries core → wayland commands.
589-type ClientCommand interface{ clientCommand() }
590-
591-// ShortcutsInhibitState carries core → wayland whether a window's
592-// shortcuts inhibitor is in effect (it has keyboard focus).
593-type ShortcutsInhibitState struct {
594- Window WindowID
595- Active bool
596-}
597-
598-func (ShortcutsInhibitState) clientCommand() {}
599-
600-// UserActivity carries core → wayland that the user touched an input
601-// device, for idle notifications (ext_idle_notifier_v1). Core sends at
602-// most one per ActivityInterval.
603-type UserActivity struct{}
604-
605-func (UserActivity) clientCommand() {}
606-
607-// ActivityInterval is the most often core reports UserActivity: idle
608-// timers may start up to this much early, so no idle timeout is shorter.
609-const ActivityInterval = 100 * time.Millisecond
610-
611-// ConfigureWindow carries core → wayland geometry and state. Output is the
612-// connector showing the window, empty while it is hidden.
613-type ConfigureWindow struct {
614- ID WindowID
615- Width, Height int
616- Fullscreen, Activated bool
617- // Floating windows are not tiled: no tiled states.
618- Floating bool
619- // Output is the output the window belongs to, kept while it is not
620- // visible (scale, foreign toplevel).
621- Output string
622- // Visible is set while part of the window is on its output: not on a
623- // hidden workspace, scrolled off, behind a maximized column or a
624- // hidden float. An invisible window is suspended and throttled.
625- Visible bool
626- // Captured marks a window drawn only for a capture session (its
627- // workspace is not on screen): Visible stays the physical truth.
628- Captured bool
629-}
630-
631-func (ConfigureWindow) clientCommand() {}
632-
633-// OutputPlacement is one output in the global layout. X, Y, Width and
634-// Height are logical; physical = logical × Scale.
635-type OutputPlacement struct {
636- Info OutputInfo
637- X, Y, Width, Height int
638- Scale float64
639- // Primary is output.<name>.primary: the pointer starts on it.
640- Primary bool
641-}
642-
643-// Contains reports whether the logical point is on the output.
644-func (o OutputPlacement) Contains(x, y float64) bool {
645- return x >= float64(o.X) && x < float64(o.X+o.Width) && y >= float64(o.Y) && y < float64(o.Y+o.Height)
646-}
647-
648-// Layout is the global arrangement of outputs, left to right.
649-type Layout []OutputPlacement
650-
651-// At returns the output under the logical point.
652-func (l Layout) At(x, y float64) (OutputPlacement, bool) {
653- for _, o := range l {
654- if o.Contains(x, y) {
655- return o, true
656- }
657- }
658- return OutputPlacement{}, false
659-}
660-
661-// Clamp keeps the pointer on an output: a point outside every output stays
662-// on the output under (fromX, fromY), or the first one, clamped to its edges.
663-func (l Layout) Clamp(fromX, fromY, x, y float64) (float64, float64) {
664- if len(l) == 0 {
665- return x, y
666- }
667- if _, ok := l.At(x, y); ok {
668- return x, y
669- }
670- o, ok := l.At(fromX, fromY)
671- if !ok {
672- o = l[0]
673- }
674- x = min(max(x, float64(o.X)), float64(o.X+o.Width)-1)
675- y = min(max(y, float64(o.Y)), float64(o.Y+o.Height)-1)
676- return x, y
677-}
678-
679-// SetOutputs carries core → wayland every output and the focused one (where
680-// new surfaces start). It is sent when the list, a placement or the focus changes.
681-type SetOutputs struct {
682- Outputs Layout
683- Focused string
684- // Off lists the outputs a client turned off (output power management),
685- // in Outputs order.
686- Off []string
687-}
688-
689-func (SetOutputs) clientCommand() {}
690-
691-// SlotsPending tells wayland whether a slot waits for its window. Only then
692-// does it read the SlotEnv of mapping clients.
693-type SlotsPending struct{ Pending bool }
694-
695-func (SlotsPending) clientCommand() {}
696-
697-// CloseWindow carries core → wayland close requests.
698-type CloseWindow struct{ ID WindowID }
699-
700-func (CloseWindow) clientCommand() {}
701-
702-// FocusWindow carries core → wayland keyboard focus (0 means none).
703-type FocusWindow struct{ ID WindowID }
704-
705-func (FocusWindow) clientCommand() {}
706-
707-// ConfigurePopup carries core → wayland a popup's place, relative to its
708-// parent's window geometry. Reposition answers a reposition request.
709-type ConfigurePopup struct {
710- ID WindowID
711- Rect Rect
712- Reposition bool
713- Token uint32
714-}
715-
716-func (ConfigurePopup) clientCommand() {}
717-
718-// ClosePopup carries core → wayland a popup to dismiss (popup_done).
719-type ClosePopup struct{ ID WindowID }
720-
721-func (ClosePopup) clientCommand() {}
722-
723-// PointerFocus changes the pointer surface; ID 0 clears focus. Coordinates are surface-local.
724-type PointerFocus struct {
725- ID WindowID
726- X, Y float64
727-}
728-
729-func (PointerFocus) clientCommand() {}
730-
731-// PointerMotionTo carries core → wayland motion for the pointer focus, in
732-// window coordinates. The deltas and TimeUsec feed zwp_relative_pointer_v1;
733-// While the window locks the pointer, wayland sends only relative motion.
734-type PointerMotionTo struct {
735- ID WindowID
736- X, Y float64
737- DX, DY float64
738- UnaccelDX, UnaccelDY float64
739- TimeMsec uint32
740- TimeUsec uint64
741-}
742-
743-func (PointerMotionTo) clientCommand() {}
744-
745-type PointerButtonTo struct {
746- ID WindowID
747- Button uint32
748- Pressed bool
749- TimeMsec uint32
750-}
751-
752-func (PointerButtonTo) clientCommand() {}
753-
754-// PointerAxisTo sends a scroll frame to a window.
755-type PointerAxisTo struct {
756- ID WindowID
757- Axis PointerAxis
758-}
759-
760-func (PointerAxisTo) clientCommand() {}
761-
762-// ForwardKey carries core → wayland unbound keys.
763-type ForwardKey struct {
764- ID WindowID
765- Key KeyEvent
766-}
767-
768-func (ForwardKey) clientCommand() {}
769-
770-// SetKeymap carries app → wayland repeat info and, unless Keymap is empty, a new
771-// xkb keymap in text format.
772-type SetKeymap struct {
773- Keymap string
774- RepeatRate, RepeatDelay int
775-}
776-
777-func (SetKeymap) clientCommand() {}
778-
779-// CursorImage is a cursor frame in premultiplied B8G8R8A8, W*4 bytes per
780-// row; (HotX, HotY) is the click point. Sizes are physical pixels.
781-type CursorImage struct {
782- W, H, HotX, HotY int
783- Pixels []byte
784-}
785-
786-// CursorChange carries wayland → outputs the cursor the focused client asks
787-// for: a CSS cursor name (wp_cursor_shape_v1), its own image, or none.
788-// The zero value is the default arrow.
789-type CursorChange struct {
790- // Shape is a CSS cursor name such as "pointer" or "text".
791- Shape string
792- // Image is a client cursor at Scale buffer pixels per logical pixel.
793- Image *CursorImage
794- Scale int
795- Hidden bool
796-}
797-
798-// SpawnRequest carries core → launcher process arguments.
799-type SpawnRequest struct {
800- // Security retains the admitting owner epoch through launcher backpressure.
801- // A wired launcher rejects stale or protected requests before execution.
802- Security SecurityState
803- Argv []string
804- // Env adds KEY=value entries to the child environment.
805- Env []string
806-}
807-
808-// Scene carries core → renderer immutable snapshots with fresh Windows slices,
809-// one per output. Rects and the output size are logical and local to the
810-// output; the renderer multiplies by Scale.
811-type Scene struct {
812- // Security is the owner epoch captured when this scene was published.
813- // Output owners reject stale scenes rather than showing a queued desktop
814- // or accepting old locker buffers across acquisition/release.
815- Security SecurityState
816- Output string
817- Seq uint64
818- OutputWidth, OutputHeight int
819- Scale float64
820- // Off turns the display off (output power management): nothing is
821- // drawn until a scene without it.
822- Off bool
823- Background string
824- Border Border
825- // WorkspaceClip bounds workspace windows, their popups and separators
826- // in logical output coordinates. Zero inherits the full output. Layers
827- // remain output-wide; overview previews do not use this clip.
828- WorkspaceClip Rect
829- // Dim darkens the background, bottom layers and tiles (tile lines
830- // included) inside WorkspaceClip when set, with black at this opacity,
831- // 0 to 1, under the first
832- // visible float. 0 draws nothing.
833- Dim float64
834- Windows []SceneWindow
835- // Separators are the lines between windows, drawn in slice order with
836- // the Border colors: tile lines over the tiles, under the floats; a
837- // float's border right after the float.
838- Separators []Separator
839- // Layers are the shown layer surfaces, drawn in slice order: background
840- // and bottom before windows, top and overlay after. Core leaves out
841- // those a fullscreen window hides (all but the background and a surface
842- // taking the keyboard exclusively); adapters draw every layer given.
843- // Window popups are drawn after the windows, layer popups
844- // (SceneWindow.OverLayers) last, over every layer.
845- Layers []SceneLayer
846- // Capture is the capture state of this output: exclusion and hidden
847- // workspace; nil while nothing is captured (capture.go).
848- Capture *SceneCapture
849- // CaptureScene is the workspace captured off screen (Capture.Workspace)
850- // drawn for capture only: a workspace that is not on screen. Only the
851- // root scene carries it; CaptureScene.Capture and .CaptureScene are nil.
852- CaptureScene *Scene
853- // CaptureIndicators are the marks of live captures of this output,
854- // drawn over everything; nil when none. Captures never hold them: the
855- // capture pipeline serves requests from a scene without them.
856- CaptureIndicators []CaptureIndicator
857-}
858-
859-// Shows reports whether the scene draws the surface of id: only its
860-// content changes need a new frame. A window scrolled off the output is
861-// not drawn. WorkspaceClip also excludes off-viewport windows; OverLayers
862-// popups remain output-wide.
863-func (s Scene) Shows(id WindowID) bool {
864- for _, w := range s.Windows {
865- if w.ID == id && !w.Hidden && w.Rect.Overlaps(Rect{W: s.OutputWidth, H: s.OutputHeight}) &&
866- (w.OverLayers || s.WorkspaceClip == (Rect{}) || w.Rect.Overlaps(s.WorkspaceClip)) {
867- return true
868- }
869- }
870- for _, l := range s.Layers {
871- if l.ID == id {
872- return true
873- }
874- }
875- return false
876-}
877-
878-// Border carries the window border style; colors are #rrggbb, "" skips
879-// drawing. Width is in logical pixels.
880-type Border struct {
881- Width int
882- Active, Inactive string
883-}
884-
885-// Separator is a line between windows, in logical pixels. Active lines
886-// mark the focused window and use Border.Active, others Border.Inactive.
887-// Window is the float whose border it is; 0 for lines between tiles.
888-type Separator struct {
889- Rect Rect
890- Active bool
891- Window WindowID
892-}
893-
894-// SceneLayer carries core → renderer layer surface placement.
895-type SceneLayer struct {
896- ID WindowID
897- Layer Layer
898- Rect Rect
899-}
900-
901-// SurfaceColor is the committed encoding of a surface. Zero means sRGB.
902-// PQ+BT.2020 and extended-linear sRGB are color-managed; zero is legacy
903-// sRGB. MaxCLL/MaxFALL are in nits. YUV metadata is per surface commit.
904-type SurfaceColor struct {
905- TF, Primaries uint8
906- MaxCLL, MaxFALL uint16
907- // Coefficients/range follow wp_color_representation_v1 (zero =
908- // unspecified, for YUV interpreted as BT.709 limited). Chroma is a
909- // H.273 Chroma420SampleLocType + 1 (zero defaults to type 0).
910- Coefficients, Range, Chroma uint8
911-}
912-
913-const (
914- ColorTFPQ uint8 = 11 // wp_color_manager_v1 ST2084 PQ
915- ColorTFExtendedLinear uint8 = 5 // wp_color_manager_v1 ext_linear
916- ColorPrimariesBT2020 uint8 = 6 // wp_color_manager_v1 BT.2020
917- ColorPrimariesSRGB uint8 = 1
918-)
919-
920-func (c SurfaceColor) IsPQ2020() bool {
921- return c.TF == ColorTFPQ && c.Primaries == ColorPrimariesBT2020
922-}
923-
924-func (c SurfaceColor) IsExtendedLinear() bool {
925- return c.TF == ColorTFExtendedLinear && c.Primaries == ColorPrimariesSRGB
926-}
927-
928-// ContentType is the committed wp_content_type_v1 hint.
929-type ContentType uint8
930-
931-const (
932- ContentNone ContentType = iota
933- ContentPhoto
934- ContentVideo
935- ContentGame
936-)
937-
938-// SurfaceContent carries wayland → output the latest committed content of a
939-// window: SHM, a client shared-memory buffer, or DMABuf, a GPU buffer.
940-// Renderers read both in place and never modify them. Empty means the window
941-// has no content. LogicalW and LogicalH are the surface size in logical
942-// pixels (buffer scale and viewport applied). Subsurfaces come in Children;
943-// Geometry is the part of the surface that is the window (xdg window
944-// geometry), the rest being client shadows.
945-type SurfaceContent struct {
946- ID WindowID
947- ContentType ContentType
948- // Surface is a never-reused wl_surface identity; Version identifies its
949- // applied buffer content independently of the window publication Seq.
950- Surface, Version uint64
951- // Seq counts the window's contents (wayland sets it), for release.
952- Seq uint64
953- Width, Height int
954- LogicalW, LogicalH int
955- // Source is the committed crop in buffer pixels: x, y, width, height.
956- // Zero width means the entire buffer is used.
957- Source [4]float32
958- // Transform is the wl_output.transform the client applied to the
959- // buffer: readers apply its inverse. Width and Height stay the buffer's.
960- Transform BufferTransform
961- // Opaque ignores the alpha byte: an x format, or an opaque region that
962- // covers the whole surface. It is false while Fade is set.
963- Opaque bool
964- // Fade is how much wp_alpha_modifier_v1 fades the surface: 0 none,
965- // 1 invisible. Its opacity is multiplied by 1-Fade.
966- Fade float32
967- Color SurfaceColor
968- SHM *SHMBuffer
969- DMABuf *DMABuf
970- // Children are the subsurfaces, bottom to top, flattened.
971- Children []Subsurface
972- // Geometry is in logical pixels from the surface origin; empty means
973- // the whole surface.
974- Geometry Rect
975- // Async asks for tearing presentation (wp_tearing_control_v1); outputs
976- // honour it only in direct scanout.
977- Async bool
978- // Acquire is the explicit-sync fence of this content's root buffer
979- // (wp_linux_drm_syncobj_v1): readers wait on it before reading the
980- // buffer, instead of the buffer's implicit fences. Wayland owns it and
981- // closes it when the buffer is released; a reader that keeps it past
982- // the call duplicates it under Acquire.SyscallConn. nil: implicit sync.
983- // Wayland closes it only after every output reported a later content of
984- // the window as seen, so a reader never gets it already closed.
985- Acquire *os.File
986- // DamageHistory is what changed in the root surface's buffer over the
987- // window's last contents, oldest first, ending with this one (Seq).
988- // A renderer holding an older content redraws the union of the
989- // entries after it, or everything when the history does not reach it.
990- DamageHistory []SeqDamage
991-}
992-
993-// BufferTransform is a wl_output.transform value: bit 0 rotates 90°
994-// counter-clockwise, bit 1 rotates 180°, bit 2 flips around the vertical
995-// axis first.
996-type BufferTransform uint8
997-
998-// Rotated reports whether width and height swap between buffer and surface.
999-func (t BufferTransform) Rotated() bool { return t&1 != 0 }
1000-
1001-// ToBuffer maps a point of a w×h surface (logical, before scale) to the
1002-// same point of its buffer, in the buffer's untransformed w'×h' axes, where
1003-// w' and h' swap when the transform is Rotated.
1004-func (t BufferTransform) ToBuffer(x, y, w, h float64) (float64, float64) {
1005- if t&4 != 0 {
1006- x = w - x
1007- }
1008- switch t & 3 {
1009- case 1:
1010- return y, w - x
1011- case 2:
1012- return w - x, h - y
1013- case 3:
1014- return h - y, x
1015- }
1016- return x, y
1017-}
1018-
1019-// SeqDamage is what content Seq changed from the one before: Rects in
1020-// root buffer pixels, or everything when Full.
1021-type SeqDamage struct {
1022- Seq uint64
1023- Full bool
1024- Rects []Rect
1025-}
1026-
1027-// DamageSince is the union of changes after content seq up to this one,
1028-// and false when the history does not reach back to seq (redraw all).
1029-func (c SurfaceContent) DamageSince(seq uint64) ([]Rect, bool) {
1030- if seq >= c.Seq {
1031- return nil, true
1032- }
1033- h := c.DamageHistory
1034- if len(h) == 0 || h[0].Seq > seq+1 || h[len(h)-1].Seq != c.Seq {
1035- return nil, false
1036- }
1037- var out []Rect
1038- for _, d := range h {
1039- if d.Seq <= seq {
1040- continue
1041- }
1042- if d.Full {
1043- return nil, false
1044- }
1045- out = append(out, d.Rects...)
1046- }
1047- return out, true
1048-}
1049-
1050-// Subsurface is a child surface at X, Y logical pixels from the root
1051-// surface origin. Below children are drawn under the root surface.
1052-type Subsurface struct {
1053- X, Y int
1054- Below bool
1055- SurfaceContent
1056-}
1057-
1058-// Empty reports whether the content has nothing to draw.
1059-func (c SurfaceContent) Empty() bool {
1060- return c.SHM == nil && c.DMABuf == nil && len(c.Children) == 0
1061-}
1062-
1063-// SHMBuffer is a client wl_shm buffer: B8G8R8A8 pixels (argb8888/xrgb8888
1064-// little-endian) at Offset in the pool File, Stride bytes per row. The
1065-// client keeps writing the file, so renderers copy from it when they draw.
1066-// Pool is unique per wl_shm pool for the session: renderers map a pool
1067-// once. File stays open while the client's buffer exists and follows the
1068-// DMABuf rules: duplicate it under File.SyscallConn before keeping it. A
1069-// client may shrink the file at any time, so readers must survive faults.
1070-type SHMBuffer struct {
1071- Pool uint64
1072- File *os.File
1073- Offset, Stride int
1074-}
1075-
1076-// DMABuf is a client GPU buffer (linux-dmabuf). Its files stay open while
1077-// the client's buffer exists; a renderer that keeps it must duplicate the
1078-// descriptors under File.SyscallConn so a concurrent close cannot hand it a
1079-// reused one. ID is unique per buffer for the whole session: renderers key
1080-// their imports on it.
1081-type DMABuf struct {
1082- ID uint64
1083- Width, Height int
1084- Format uint32 // DRM fourcc
1085- Modifier uint64
1086- Planes []DMABufPlane
1087-}
1088-
1089-// DMABufPlane is one plane of a DMABuf.
1090-type DMABufPlane struct {
1091- File *os.File
1092- Offset, Stride uint32
1093-}
1094-
1095-// DMABufFormat is a DRM fourcc with a modifier the renderer can import.
1096-type DMABufFormat struct {
1097- Format uint32
1098- Modifier uint64
1099-}
1100-
1101-// DMABufSupport is what the renderer imports: the render device (a dev_t,
1102-// 0 when unknown) and the formats. No formats means no dmabuf.
1103-type DMABufSupport struct {
1104- Device uint64
1105- Formats []DMABufFormat
1106-}
1107-
1108-// SceneWindow carries core → renderer window placement.
1109-type SceneWindow struct {
1110- ID WindowID
1111- Rect Rect
1112- Focused, Fullscreen, Hidden bool
1113- // Inset sides carry a separator line inside Rect: the client is drawn
1114- // inside it.
1115- Inset Sides
1116- // Floating windows draw at their placed size. Below marks a native
1117- // covering float behind the column group.
1118- Floating, Below bool
1119- // Dim darkens the window, border included, with black at this
1120- // opacity, 0 to 1: a stashed window peeking in. 0 draws nothing.
1121- Dim float64
1122- // Preview, above 0, draws the window's surfaces that much smaller in
1123- // Rect (an overview thumbnail): the client keeps its size.
1124- Preview float64
1125- // Popups are drawn from their content only: no border, no background.
1126- Popup bool
1127- // OverLayers popups hang from a layer surface: drawn over the top and
1128- // overlay layers. Window popups stay with the windows, under them.
1129- OverLayers bool
1130-}
1131-
1132-// State carries core → state publisher a snapshot of what is on screen, for
1133-// scripts (bars, status lines). Workspace numbers count from 1.
1134-type State struct {
1135- // Output is the focused output; "" before the first one.
1136- Output string
1137- Outputs []OutputState
1138- // Window is the focused window, nil when none.
1139- Window *WindowState
1140- Windows []WindowState
1141-}
1142-
1143-// OutputState is one output: its workspace on screen and how many numbered
1144-// workspaces it has. Active is 0 while a hidden (named) workspace is shown.
1145-type OutputState struct {
1146- Name string
1147- Active int
1148- Count int
1149- Workspace string // configured name of the workspace on screen, "" if unnamed
1150- // WorkspaceID is the core ID of the workspace on screen (WorkspaceInfo.ID).
1151- WorkspaceID uint64
1152-}
1153-
1154-// WindowState is one mapped window and where it is.
1155-type WindowState struct {
1156- ID WindowID
1157- AppID string
1158- PID int
1159- Output string
1160- // Workspace is the window's numbered workspace, 0 for a hidden one.
1161- Workspace int
1162- // WorkspaceID is the core ID of the window's workspace, numbered or not
1163- // (WorkspaceInfo.ID); WorkspaceName is its configured name, "" for a
1164- // dynamic one (WorkspaceInfo.Configured).
1165- WorkspaceID uint64
1166- WorkspaceName string
1167- // Visible means on the workspace on screen (it may be behind a
1168- // fullscreen window).
1169- Visible bool
1170- // IdleInhibit is set while the window keeps the session from idling.
1171- IdleInhibit bool `json:",omitempty"`
1172- // Floating is set for native floats and stashed windows.
1173- Floating bool
1174- // StashIndex is the 1-based place of a stashed window in its stash of
1175- // StashCount windows; both are 0 outside the stash.
1176- StashIndex, StashCount int
1177- // Hidden is set for a stashed window while its stash is hidden.
1178- Hidden bool
1179-}
1180-
1181-// LeaseConnector identifies a non-desktop connector available for DRM leasing.
1182-type LeaseConnector struct {
1183- Card, Name, Description string
1184- ConnectorID uint32
1185-}
1186-
1187-// LeaseMessage is an adapter-to-adapter DRM lease channel message.
1188-type LeaseMessage interface{ leaseMessage() }
1189-
1190-// LeaseConnectors replaces the available connector inventory for Card.
1191-// Device is a non-master DRM fd owned by the receiver, which closes it.
1192-// An empty Connectors list with nil Device removes the card's global and closes
1193-// the display's fd. A later nonempty inventory creates a new global.
1194-type LeaseConnectors struct {
1195- Card string
1196- Device *os.File
1197- Connectors []LeaseConnector
1198-}
1199-
1200-func (LeaseConnectors) leaseMessage() {}
1201-
1202-// CloseLeaseFiles closes the fd a lease message owns, for a message that is
1203-// dropped instead of delivered.
1204-func CloseLeaseFiles(msg LeaseMessage) {
1205- switch m := msg.(type) {
1206- case LeaseConnectors:
1207- if m.Device != nil {
1208- m.Device.Close()
1209- }
1210- case LeaseReply:
1211- if m.FD != nil {
1212- m.FD.Close()
1213- }
1214- }
1215-}
1216-
1217-type LeaseRequest struct {
1218- ID uint64
1219- Card string
1220- Connectors []string
1221-}
1222-
1223-func (LeaseRequest) leaseMessage() {}
1224-
1225-// LeaseReply transfers ownership of FD to Wayland.
1226-type LeaseReply struct {
1227- ID uint64
1228- FD *os.File
1229- LeaseID uint32
1230- Err error
1231-}
1232-
1233-func (LeaseReply) leaseMessage() {}
1234-
1235-type LeaseRevoke struct {
1236- Card string
1237- LeaseID uint32
1238-}
1239-
1240-func (LeaseRevoke) leaseMessage() {}
1241-
1242-type LeaseFinished struct {
1243- Card string
1244- LeaseID uint32
1245-}
1246-
1247-func (LeaseFinished) leaseMessage() {}
A internal/ports/output_events.go +150 -0
@@ -0,0 +1,150 @@
1+package ports
2+
3+import (
4+ "image"
5+ "time"
6+)
7+
8+// OutputEvent carries output → core notifications.
9+type OutputEvent interface{ outputEvent() }
10+
11+// OutputInfo describes a connected display. Sizes are physical pixels.
12+type OutputInfo struct {
13+ // Name is the connector (e.g. DP-2), matched against output.<name>.* config.
14+ Name string
15+ // Make, Model and Serial come from EDID; empty when unknown.
16+ Make, Model, Serial string
17+ Width, Height int
18+ RefreshMilli int
19+ PhysicalW, PhysicalH int // millimetres
20+}
21+
22+// Key identifies the monitor across connectors: make, model and serial, or
23+// the connector name when EDID has no serial.
24+func (i OutputInfo) Key() string {
25+ if i.Serial == "" {
26+ return i.Name
27+ }
28+ return i.Make + " " + i.Model + " " + i.Serial
29+}
30+
31+// OutputMode describes a connected connector's physical mode.
32+type OutputMode struct {
33+ Width, Height, RefreshMilli int
34+ Preferred bool
35+}
36+
37+// OutputHead describes a connected connector even when it is disabled.
38+// Current is the active mode; it is nil for disabled or not-yet-running heads.
39+type OutputHead struct {
40+ Info OutputInfo
41+ Modes []OutputMode
42+ Current *OutputMode
43+ Enabled bool
44+}
45+
46+// OutputHeads is a complete backend inventory, delivered to Wayland after a scan.
47+type OutputHeads struct{ Heads []OutputHead }
48+
49+// HeadChange is the complete desired state of one head in a configuration.
50+type HeadChange struct {
51+ Name string
52+ Enabled bool
53+ Mode *OutputMode
54+ Pos *image.Point
55+ Scale float64
56+}
57+
58+// OutputApply asks the app owner to validate or apply a runtime configuration.
59+type OutputApply struct {
60+ ID uint64
61+ Test bool
62+ Heads []HeadChange
63+}
64+
65+// OutputApplied reports completion (or failure) of a runtime configuration.
66+type OutputApplied struct {
67+ ID uint64
68+ Err error
69+}
70+
71+// OutputAdded carries output → core a new display, or a new mode for a known
72+// connector.
73+type OutputAdded struct{ Info OutputInfo }
74+
75+func (OutputAdded) outputEvent() {}
76+
77+// OutputPresented carries output → wayland what an output shows and reads.
78+// Flip is set when a page flip completed: frame callbacks of the surfaces
79+// on it are due. Shown and Queued are the DMABuf IDs scanned out directly
80+// (0: a composed image); Seen is the latest content Seq per window the
81+// output finished reading (the GPU is done with it). A replaced client
82+// buffer is released once every output that reports has seen a later
83+// content of its window and neither shows nor queues it.
84+type OutputPresented struct {
85+ Output string
86+ Flip *FlipInfo
87+ Shown, Queued uint64
88+ Seen map[WindowID]uint64
89+ // ChildReads is the oldest content Seq per window still read by a child
90+ // renderer on a separate device. Unlike Seen, these holds never time out:
91+ // they end only with a completed render or device shutdown.
92+ ChildReads map[WindowID]uint64
93+}
94+
95+// OutputFormats carries output → wayland the dmabuf formats an output can
96+// scan out directly (its primary plane's, that the renderer also samples,
97+// so a refused buffer can still be composed). Device is the KMS device
98+// (dev_t) clients allocate scanout buffers for. Sent after every successful
99+// modeset, including startup and VT resume; empty Formats means no direct
100+// scanout. HDR is non-nil only when the output's HDR10 modeset succeeded;
101+// nil means SDR, regardless of the monitor's HDR capability.
102+type OutputFormats struct {
103+ Output string
104+ Device uint64
105+ Formats []DMABufFormat
106+ HDR *OutputHDR
107+}
108+
109+// OutputHDR carries the active HDR10 output's EDID luminances in nits.
110+// These describe the display, not any client's content metadata.
111+type OutputHDR struct {
112+ MaxLuminance, MaxFrameAverage, MinLuminance float64
113+}
114+
115+// FlipInfo is one completed page flip. When is its CLOCK_MONOTONIC time
116+// (hardware clock on DRM), Seq the output's vblank counter, Refresh the
117+// refresh period (0 while variable refresh is on). ZeroCopy is the window
118+// whose buffer was shown without composition (direct scanout or overlay
119+// plane; 0: none), Async set for a tearing flip.
120+// Shows is the content Seq per window the flipped frame shows. Merged
121+// counts earlier flips folded into this one when the reader fell behind:
122+// their presentation feedback is discarded.
123+type FlipInfo struct {
124+ When time.Duration
125+ Seq uint64
126+ Refresh time.Duration
127+ ZeroCopy WindowID
128+ Async bool
129+ HardwareClock bool
130+ Merged int
131+ Shows map[WindowID]uint64
132+}
133+
134+// OutputFrame carries output → core a completed page flip: the next frame
135+// of a running animation is due.
136+type OutputFrame struct{ Output string }
137+
138+// OutputRemoved carries output → core an unplugged display.
139+type OutputRemoved struct{ Name string }
140+
141+func (OutputRemoved) outputEvent() {}
142+
143+// ConfigChanged carries config → core reloads.
144+type ConfigChanged struct{ Config Config }
145+
146+// ScaleChanged carries core → persistence a scale set by a scale bind.
147+type ScaleChanged struct {
148+ Output string
149+ Scale float64
150+}
A internal/ports/scene.go +327 -0
@@ -0,0 +1,327 @@
1+package ports
2+
3+import "os"
4+
5+// Scene carries core → renderer immutable snapshots with fresh Windows slices,
6+// one per output. Rects and the output size are logical and local to the
7+// output; the renderer multiplies by Scale.
8+type Scene struct {
9+ // Security is the owner epoch captured when this scene was published.
10+ // Output owners reject stale scenes rather than showing a queued desktop
11+ // or accepting old locker buffers across acquisition/release.
12+ Security SecurityState
13+ Output string
14+ Seq uint64
15+ OutputWidth, OutputHeight int
16+ Scale float64
17+ // Off turns the display off (output power management): nothing is
18+ // drawn until a scene without it.
19+ Off bool
20+ Background string
21+ Border Border
22+ // WorkspaceClip bounds workspace windows, their popups and separators
23+ // in logical output coordinates. Zero inherits the full output. Layers
24+ // remain output-wide; overview previews do not use this clip.
25+ WorkspaceClip Rect
26+ // Dim darkens the background, bottom layers and tiles (tile lines
27+ // included) inside WorkspaceClip when set, with black at this opacity,
28+ // 0 to 1, under the first
29+ // visible float. 0 draws nothing.
30+ Dim float64
31+ Windows []SceneWindow
32+ // Separators are the lines between windows, drawn in slice order with
33+ // the Border colors: tile lines over the tiles, under the floats; a
34+ // float's border right after the float.
35+ Separators []Separator
36+ // Layers are the shown layer surfaces, drawn in slice order: background
37+ // and bottom before windows, top and overlay after. Core leaves out
38+ // those a fullscreen window hides (all but the background and a surface
39+ // taking the keyboard exclusively); adapters draw every layer given.
40+ // Window popups are drawn after the windows, layer popups
41+ // (SceneWindow.OverLayers) last, over every layer.
42+ Layers []SceneLayer
43+ // Capture is the capture state of this output: exclusion and hidden
44+ // workspace; nil while nothing is captured (capture.go).
45+ Capture *SceneCapture
46+ // CaptureScene is the workspace captured off screen (Capture.Workspace)
47+ // drawn for capture only: a workspace that is not on screen. Only the
48+ // root scene carries it; CaptureScene.Capture and .CaptureScene are nil.
49+ CaptureScene *Scene
50+ // CaptureIndicators are the marks of live captures of this output,
51+ // drawn over everything; nil when none. Captures never hold them: the
52+ // capture pipeline serves requests from a scene without them.
53+ CaptureIndicators []CaptureIndicator
54+}
55+
56+// Shows reports whether the scene draws the surface of id: only its
57+// content changes need a new frame. A window scrolled off the output is
58+// not drawn. WorkspaceClip also excludes off-viewport windows; OverLayers
59+// popups remain output-wide.
60+func (s Scene) Shows(id WindowID) bool {
61+ for _, w := range s.Windows {
62+ if w.ID == id && !w.Hidden && w.Rect.Overlaps(Rect{W: s.OutputWidth, H: s.OutputHeight}) &&
63+ (w.OverLayers || s.WorkspaceClip == (Rect{}) || w.Rect.Overlaps(s.WorkspaceClip)) {
64+ return true
65+ }
66+ }
67+ for _, l := range s.Layers {
68+ if l.ID == id {
69+ return true
70+ }
71+ }
72+ return false
73+}
74+
75+// Border carries the window border style; colors are #rrggbb, "" skips
76+// drawing. Width is in logical pixels.
77+type Border struct {
78+ Width int
79+ Active, Inactive string
80+}
81+
82+// Separator is a line between windows, in logical pixels. Active lines
83+// mark the focused window and use Border.Active, others Border.Inactive.
84+// Window is the float whose border it is; 0 for lines between tiles.
85+type Separator struct {
86+ Rect Rect
87+ Active bool
88+ Window WindowID
89+}
90+
91+// SceneLayer carries core → renderer layer surface placement.
92+type SceneLayer struct {
93+ ID WindowID
94+ Layer Layer
95+ Rect Rect
96+}
97+
98+// SurfaceColor is the committed encoding of a surface. Zero means sRGB.
99+// PQ+BT.2020 and extended-linear sRGB are color-managed; zero is legacy
100+// sRGB. MaxCLL/MaxFALL are in nits. YUV metadata is per surface commit.
101+type SurfaceColor struct {
102+ TF, Primaries uint8
103+ MaxCLL, MaxFALL uint16
104+ // Coefficients/range follow wp_color_representation_v1 (zero =
105+ // unspecified, for YUV interpreted as BT.709 limited). Chroma is a
106+ // H.273 Chroma420SampleLocType + 1 (zero defaults to type 0).
107+ Coefficients, Range, Chroma uint8
108+}
109+
110+const (
111+ ColorTFPQ uint8 = 11 // wp_color_manager_v1 ST2084 PQ
112+ ColorTFExtendedLinear uint8 = 5 // wp_color_manager_v1 ext_linear
113+ ColorPrimariesBT2020 uint8 = 6 // wp_color_manager_v1 BT.2020
114+ ColorPrimariesSRGB uint8 = 1
115+)
116+
117+func (c SurfaceColor) IsPQ2020() bool {
118+ return c.TF == ColorTFPQ && c.Primaries == ColorPrimariesBT2020
119+}
120+
121+func (c SurfaceColor) IsExtendedLinear() bool {
122+ return c.TF == ColorTFExtendedLinear && c.Primaries == ColorPrimariesSRGB
123+}
124+
125+// ContentType is the committed wp_content_type_v1 hint.
126+type ContentType uint8
127+
128+const (
129+ ContentNone ContentType = iota
130+ ContentPhoto
131+ ContentVideo
132+ ContentGame
133+)
134+
135+// SurfaceContent carries wayland → output the latest committed content of a
136+// window: SHM, a client shared-memory buffer, or DMABuf, a GPU buffer.
137+// Renderers read both in place and never modify them. Empty means the window
138+// has no content. LogicalW and LogicalH are the surface size in logical
139+// pixels (buffer scale and viewport applied). Subsurfaces come in Children;
140+// Geometry is the part of the surface that is the window (xdg window
141+// geometry), the rest being client shadows.
142+type SurfaceContent struct {
143+ ID WindowID
144+ ContentType ContentType
145+ // Surface is a never-reused wl_surface identity; Version identifies its
146+ // applied buffer content independently of the window publication Seq.
147+ Surface, Version uint64
148+ // Seq counts the window's contents (wayland sets it), for release.
149+ Seq uint64
150+ Width, Height int
151+ LogicalW, LogicalH int
152+ // Source is the committed crop in buffer pixels: x, y, width, height.
153+ // Zero width means the entire buffer is used.
154+ Source [4]float32
155+ // Transform is the wl_output.transform the client applied to the
156+ // buffer: readers apply its inverse. Width and Height stay the buffer's.
157+ Transform BufferTransform
158+ // Opaque ignores the alpha byte: an x format, or an opaque region that
159+ // covers the whole surface. It is false while Fade is set.
160+ Opaque bool
161+ // Fade is how much wp_alpha_modifier_v1 fades the surface: 0 none,
162+ // 1 invisible. Its opacity is multiplied by 1-Fade.
163+ Fade float32
164+ Color SurfaceColor
165+ SHM *SHMBuffer
166+ DMABuf *DMABuf
167+ // Children are the subsurfaces, bottom to top, flattened.
168+ Children []Subsurface
169+ // Geometry is in logical pixels from the surface origin; empty means
170+ // the whole surface.
171+ Geometry Rect
172+ // Async asks for tearing presentation (wp_tearing_control_v1); outputs
173+ // honour it only in direct scanout.
174+ Async bool
175+ // Acquire is the explicit-sync fence of this content's root buffer
176+ // (wp_linux_drm_syncobj_v1): readers wait on it before reading the
177+ // buffer, instead of the buffer's implicit fences. Wayland owns it and
178+ // closes it when the buffer is released; a reader that keeps it past
179+ // the call duplicates it under Acquire.SyscallConn. nil: implicit sync.
180+ // Wayland closes it only after every output reported a later content of
181+ // the window as seen, so a reader never gets it already closed.
182+ Acquire *os.File
183+ // DamageHistory is what changed in the root surface's buffer over the
184+ // window's last contents, oldest first, ending with this one (Seq).
185+ // A renderer holding an older content redraws the union of the
186+ // entries after it, or everything when the history does not reach it.
187+ DamageHistory []SeqDamage
188+}
189+
190+// BufferTransform is a wl_output.transform value: bit 0 rotates 90°
191+// counter-clockwise, bit 1 rotates 180°, bit 2 flips around the vertical
192+// axis first.
193+type BufferTransform uint8
194+
195+// Rotated reports whether width and height swap between buffer and surface.
196+func (t BufferTransform) Rotated() bool { return t&1 != 0 }
197+
198+// ToBuffer maps a point of a w×h surface (logical, before scale) to the
199+// same point of its buffer, in the buffer's untransformed w'×h' axes, where
200+// w' and h' swap when the transform is Rotated.
201+func (t BufferTransform) ToBuffer(x, y, w, h float64) (float64, float64) {
202+ if t&4 != 0 {
203+ x = w - x
204+ }
205+ switch t & 3 {
206+ case 1:
207+ return y, w - x
208+ case 2:
209+ return w - x, h - y
210+ case 3:
211+ return h - y, x
212+ }
213+ return x, y
214+}
215+
216+// SeqDamage is what content Seq changed from the one before: Rects in
217+// root buffer pixels, or everything when Full.
218+type SeqDamage struct {
219+ Seq uint64
220+ Full bool
221+ Rects []Rect
222+}
223+
224+// DamageSince is the union of changes after content seq up to this one,
225+// and false when the history does not reach back to seq (redraw all).
226+func (c SurfaceContent) DamageSince(seq uint64) ([]Rect, bool) {
227+ if seq >= c.Seq {
228+ return nil, true
229+ }
230+ h := c.DamageHistory
231+ if len(h) == 0 || h[0].Seq > seq+1 || h[len(h)-1].Seq != c.Seq {
232+ return nil, false
233+ }
234+ var out []Rect
235+ for _, d := range h {
236+ if d.Seq <= seq {
237+ continue
238+ }
239+ if d.Full {
240+ return nil, false
241+ }
242+ out = append(out, d.Rects...)
243+ }
244+ return out, true
245+}
246+
247+// Subsurface is a child surface at X, Y logical pixels from the root
248+// surface origin. Below children are drawn under the root surface.
249+type Subsurface struct {
250+ X, Y int
251+ Below bool
252+ SurfaceContent
253+}
254+
255+// Empty reports whether the content has nothing to draw.
256+func (c SurfaceContent) Empty() bool {
257+ return c.SHM == nil && c.DMABuf == nil && len(c.Children) == 0
258+}
259+
260+// SHMBuffer is a client wl_shm buffer: B8G8R8A8 pixels (argb8888/xrgb8888
261+// little-endian) at Offset in the pool File, Stride bytes per row. The
262+// client keeps writing the file, so renderers copy from it when they draw.
263+// Pool is unique per wl_shm pool for the session: renderers map a pool
264+// once. File stays open while the client's buffer exists and follows the
265+// DMABuf rules: duplicate it under File.SyscallConn before keeping it. A
266+// client may shrink the file at any time, so readers must survive faults.
267+type SHMBuffer struct {
268+ Pool uint64
269+ File *os.File
270+ Offset, Stride int
271+}
272+
273+// DMABuf is a client GPU buffer (linux-dmabuf). Its files stay open while
274+// the client's buffer exists; a renderer that keeps it must duplicate the
275+// descriptors under File.SyscallConn so a concurrent close cannot hand it a
276+// reused one. ID is unique per buffer for the whole session: renderers key
277+// their imports on it.
278+type DMABuf struct {
279+ ID uint64
280+ Width, Height int
281+ Format uint32 // DRM fourcc
282+ Modifier uint64
283+ Planes []DMABufPlane
284+}
285+
286+// DMABufPlane is one plane of a DMABuf.
287+type DMABufPlane struct {
288+ File *os.File
289+ Offset, Stride uint32
290+}
291+
292+// DMABufFormat is a DRM fourcc with a modifier the renderer can import.
293+type DMABufFormat struct {
294+ Format uint32
295+ Modifier uint64
296+}
297+
298+// DMABufSupport is what the renderer imports: the render device (a dev_t,
299+// 0 when unknown) and the formats. No formats means no dmabuf.
300+type DMABufSupport struct {
301+ Device uint64
302+ Formats []DMABufFormat
303+}
304+
305+// SceneWindow carries core → renderer window placement.
306+type SceneWindow struct {
307+ ID WindowID
308+ Rect Rect
309+ Focused, Fullscreen, Hidden bool
310+ // Inset sides carry a separator line inside Rect: the client is drawn
311+ // inside it.
312+ Inset Sides
313+ // Floating windows draw at their placed size. Below marks a native
314+ // covering float behind the column group.
315+ Floating, Below bool
316+ // Dim darkens the window, border included, with black at this
317+ // opacity, 0 to 1: a stashed window peeking in. 0 draws nothing.
318+ Dim float64
319+ // Preview, above 0, draws the window's surfaces that much smaller in
320+ // Rect (an overview thumbnail): the client keeps its size.
321+ Preview float64
322+ // Popups are drawn from their content only: no border, no background.
323+ Popup bool
324+ // OverLayers popups hang from a layer surface: drawn over the top and
325+ // overlay layers. Window popups stay with the windows, under them.
326+ OverLayers bool
327+}
A internal/ports/state.go +50 -0
@@ -0,0 +1,50 @@
1+package ports
2+
3+// State carries core → state publisher a snapshot of what is on screen, for
4+// scripts (bars, status lines). Workspace numbers count from 1.
5+type State struct {
6+ // Output is the focused output; "" before the first one.
7+ Output string
8+ Outputs []OutputState
9+ // Window is the focused window, nil when none.
10+ Window *WindowState
11+ Windows []WindowState
12+}
13+
14+// OutputState is one output: its workspace on screen and how many numbered
15+// workspaces it has. Active is 0 while a hidden (named) workspace is shown.
16+type OutputState struct {
17+ Name string
18+ Active int
19+ Count int
20+ Workspace string // configured name of the workspace on screen, "" if unnamed
21+ // WorkspaceID is the core ID of the workspace on screen (WorkspaceInfo.ID).
22+ WorkspaceID uint64
23+}
24+
25+// WindowState is one mapped window and where it is.
26+type WindowState struct {
27+ ID WindowID
28+ AppID string
29+ PID int
30+ Output string
31+ // Workspace is the window's numbered workspace, 0 for a hidden one.
32+ Workspace int
33+ // WorkspaceID is the core ID of the window's workspace, numbered or not
34+ // (WorkspaceInfo.ID); WorkspaceName is its configured name, "" for a
35+ // dynamic one (WorkspaceInfo.Configured).
36+ WorkspaceID uint64
37+ WorkspaceName string
38+ // Visible means on the workspace on screen (it may be behind a
39+ // fullscreen window).
40+ Visible bool
41+ // IdleInhibit is set while the window keeps the session from idling.
42+ IdleInhibit bool `json:",omitempty"`
43+ // Floating is set for native floats and stashed windows.
44+ Floating bool
45+ // StashIndex is the 1-based place of a stashed window in its stash of
46+ // StashCount windows; both are 0 outside the stash.
47+ StashIndex, StashCount int
48+ // Hidden is set for a stashed window while its stash is hidden.
49+ Hidden bool
50+}

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close