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:
26 ruleSettings:26 ruleSettings:
27 missing-generated-mock-for-port:27 missing-generated-mock-for-port:
28 # Sealed message unions are channel data, not ports; tested by sending messages (ADR 005).28 # 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]
30 suspicious-business-logic-in-adapter:30 suspicious-business-logic-in-adapter:
31 # app is the composition root; starting core and wiring channels is its job.31 # app is the composition root; starting core and wiring channels is its job.
32 excludePaths: [internal/app/app.go]32 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
17 - Adapter-internal seams (e.g. a kernel ioctl) stay unexported in the adapter. Mocks: `<name>_mock_test.go` in the same package.17 - Adapter-internal seams (e.g. a kernel ioctl) stay unexported in the adapter. Mocks: `<name>_mock_test.go` in the same package.
18 - **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.18 - **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.
19 - Use mocks through `.EXPECT()`. Record calls with `RunAndReturn` when a test inspects arguments.19 - 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.
21 - Not doubles, so allowed:21 - Not doubles, so allowed:
22 - real protocol clients in wayland tests (`wlturbo` proxies);22 - real protocol clients in wayland tests (`wlturbo` proxies);
23 - real in-memory implementations of std interfaces (e.g. a locked `bytes.Buffer` as `io.Writer`);23 - 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
165 }165 }
166 166
167 func enableTestHDR(o *Output) {167 func enableTestHDR(o *Output) {
168- o.hdrOn, o.hdrBlob = true, 55168+ o.hdr.on, o.hdr.blob = true, 55
169- o.hdrProps = connectorHDRProps{Metadata: pHDRMeta, Colorspace: pColorspace, MaxBPC: pMaxBPC, BT2020Value: colorspaceBT2020, MaxBPCValue: 8, HasDefault: true}169+ o.hdr.props = connectorHDRProps{Metadata: pHDRMeta, Colorspace: pColorspace, MaxBPC: pMaxBPC, BT2020Value: colorspaceBT2020, MaxBPCValue: 8, HasDefault: true}
170 }170 }
171 171
172 func TestAmdgpuRuleRejectsWhatHardwareRejected(t *testing.T) {172 func TestAmdgpuRuleRejectsWhatHardwareRejected(t *testing.T) {
M internal/adapters/drm/capture_test.go +7 -7
@@ -263,10 +263,10 @@ func TestReportCapsPerWindowWhileChildReads(t *testing.T) {
263 seen := map[ports.WindowID]uint64{1: 9, 5: 7} // window 1 belongs to the display263 seen := map[ports.WindowID]uint64{1: 9, 5: 7} // window 1 belongs to the display
264 o.report(nil, seen)264 o.report(nil, seen)
265 require.True(t, o.capped, "an outstanding child read is reported repeatedly")265 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)
267 require.Equal(t, uint64(7), seen[5], "the caller's map is untouched")267 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)268+ require.Equal(t, map[ports.WindowID]uint64{5: 2}, o.reports.Unsent()[0].ChildReads)
269- previous := o.unsent[0].ChildReads269+ previous := o.reports.Unsent()[0].ChildReads
270 allocs := testing.AllocsPerRun(100, func() { o.report(nil, seen) })270 allocs := testing.AllocsPerRun(100, func() { o.report(nil, seen) })
271 require.LessOrEqual(t, allocs, float64(1), "only the fence poll may allocate")271 require.LessOrEqual(t, allocs, float64(1), "only the fence poll may allocate")
272 t.Logf("unchanged child report: %.1f allocs", allocs)272 t.Logf("unchanged child report: %.1f allocs", allocs)
@@ -275,8 +275,8 @@ func TestReportCapsPerWindowWhileChildReads(t *testing.T) {
275 require.NoError(t, err)275 require.NoError(t, err)
276 o.report(nil, seen)276 o.report(nil, seen)
277 require.False(t, o.capped)277 require.False(t, o.capped)
278- require.Equal(t, map[ports.WindowID]uint64{1: 9, 5: 7}, o.unsent[0].Seen, "limit lifted")278+ require.Equal(t, map[ports.WindowID]uint64{1: 9, 5: 7}, o.reports.Unsent()[0].Seen, "limit lifted")
279- require.Empty(t, o.unsent[0].ChildReads)279+ require.Empty(t, o.reports.Unsent()[0].ChildReads)
280 require.Equal(t, map[ports.WindowID]uint64{5: 2}, previous, "sent snapshot remains immutable")280 require.Equal(t, map[ports.WindowID]uint64{5: 2}, previous, "sent snapshot remains immutable")
281 pipeline.Close(nil)281 pipeline.Close(nil)
282 }282 }
@@ -316,8 +316,8 @@ func TestHiddenWorkspaceChildKeepsDirectScanout(t *testing.T) {
316 require.NoError(t, err)316 require.NoError(t, err)
317 require.True(t, direct, "the displayed fullscreen window stays scanned out")317 require.True(t, direct, "the displayed fullscreen window stays scanned out")
318 require.True(t, o.frame.pendingCommit(), "display flip has not completed")318 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")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.unsent[0].Seen, "display reads are not advanced early")320+ require.Empty(t, o.reports.Unsent()[0].Seen, "display reads are not advanced early")
321 require.NoError(t, (<-replies).Err)321 require.NoError(t, (<-replies).Err)
322 _, err = writeEnd.Write([]byte{1})322 _, err = writeEnd.Write([]byte{1})
323 require.NoError(t, err)323 require.NoError(t, err)
M internal/adapters/drm/card.go +1 -1
@@ -281,5 +281,5 @@ func normalizedHDRSettings(settings HDRSettings) HDRSettings {
281 // outputNeedsRestart applies the same replacement path to mode and HDR281 // outputNeedsRestart applies the same replacement path to mode and HDR
282 // configuration changes; Release drops the old output before reopening it.282 // configuration changes; Release drops the old output before reopening it.
283 func outputNeedsRestart(o *Output, mode modeInfo, settings HDRSettings) bool {283 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)
285 }285 }
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.
67 // past the stale-report timeout. Do not advance the display's Seen67 // past the stale-report timeout. Do not advance the display's Seen
68 // until its own GPU work finishes.68 // until its own GPU work finishes.
69 if o.capHidden != nil {69 if o.capHidden != nil {
70- o.report(nil, o.seenSnapshot)70+ o.report(nil, o.reports.Seen())
71 }71 }
72 clear(hidden)72 clear(hidden)
73 }73 }
M internal/adapters/drm/hdr.go +82 -2
@@ -43,13 +43,93 @@ func maxFrameAverage(h HDRMetadata) float64 {
43 return h.MaxLuminance43 return h.MaxLuminance
44 }44 }
45 45
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+
46 // hdrConnectorProps sets the connector's signal metadata on a modeset or restore.126 // hdrConnectorProps sets the connector's signal metadata on a modeset or restore.
47 func (o *Output) hdrConnectorProps(req *atomicReq, enabled bool) {127 func (o *Output) hdrConnectorProps(req *atomicReq, enabled bool) {
48- p := o.hdrProps128+ p := o.hdr.props
49 if enabled {129 if enabled {
50 req.set(o.conn.id, p.MaxBPC, 10)130 req.set(o.conn.id, p.MaxBPC, 10)
51 req.set(o.conn.id, p.Colorspace, p.BT2020Value)131 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))
53 return133 return
54 }134 }
55 req.set(o.conn.id, p.MaxBPC, p.MaxBPCValue)135 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) {
33 33
34 func TestHDRModesetPropertiesAndPlaneRefusal(t *testing.T) {34 func TestHDRModesetPropertiesAndPlaneRefusal(t *testing.T) {
35 o, _, _ := testOutput(t)35 o, _, _ := testOutput(t)
36- o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, DefaultValue: 0, HasDefault: true, MaxBPCValue: 8}36+ o.hdr.props = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, DefaultValue: 0, HasDefault: true, MaxBPCValue: 8}
37- o.hdrBlob, o.hdrOn = 321, true37+ o.hdr.blob, o.hdr.on = 321, true
38 for _, tc := range []struct {38 for _, tc := range []struct {
39 on bool39 on bool
40 bpc, colorspace, metadata uint6440 bpc, colorspace, metadata uint64
41 }{41 }{
42 {true, 10, 7, 321}, {false, 8, 0, 0},42 {true, 10, 7, 321}, {false, 8, 0, 0},
43 } {43 } {
44- o.hdrOn = tc.on44+ o.hdr.on = tc.on
45 req := o.modesetReq(11, true)45 req := o.modesetReq(11, true)
46 for _, prop := range []struct {46 for _, prop := range []struct {
47 id uint3247 id uint32
@@ -52,7 +52,7 @@ func TestHDRModesetPropertiesAndPlaneRefusal(t *testing.T) {
52 }52 }
53 }53 }
54 }54 }
55- o.hdrOn = true55+ o.hdr.on = true
56 if fb, _ := o.scanoutFrame(ports.Scene{}, nil); fb != 0 || o.reason != "no_fullscreen" {56 if fb, _ := o.scanoutFrame(ports.Scene{}, nil); fb != 0 || o.reason != "no_fullscreen" {
57 t.Fatalf("scanout: fb %d reason %q", fb, o.reason)57 t.Fatalf("scanout: fb %d reason %q", fb, o.reason)
58 }58 }
@@ -60,14 +60,14 @@ func TestHDRModesetPropertiesAndPlaneRefusal(t *testing.T) {
60 t.Fatalf("overlay: fb %d reason %q", ov.fb, o.overlayReason)60 t.Fatalf("overlay: fb %d reason %q", ov.fb, o.overlayReason)
61 }61 }
62 // Colorspace property without a Default enum must not receive invented 0.62 // Colorspace property without a Default enum must not receive invented 0.
63- o.hdrOn = false63+ o.hdr.on = false
64- o.hdrProps.HasDefault = false64+ o.hdr.props.HasDefault = false
65- if _, ok := o.modesetReq(11, true).value(o.conn.id, o.hdrProps.Colorspace); ok {65+ if _, ok := o.modesetReq(11, true).value(o.conn.id, o.hdr.props.Colorspace); ok {
66 t.Fatal("SDR set Colorspace with no Default enum")66 t.Fatal("SDR set Colorspace with no Default enum")
67 }67 }
68 restore := &atomicReq{}68 restore := &atomicReq{}
69 o.hdrConnectorProps(restore, false)69 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 {
71 t.Fatal("restore set Colorspace with no Default enum")71 t.Fatal("restore set Colorspace with no Default enum")
72 }72 }
73 }73 }
@@ -75,9 +75,9 @@ func TestHDRModesetPropertiesAndPlaneRefusal(t *testing.T) {
75 func TestHDRTestCommitFallbackReexportsSDR(t *testing.T) {75 func TestHDRTestCommitFallbackReexportsSDR(t *testing.T) {
76 o, k, commits := testOutput(t, unix.EINVAL, unix.EINVAL)76 o, k, commits := testOutput(t, unix.EINVAL, unix.EINVAL)
77 o.cursor = nil77 o.cursor = nil
78- o.hdr = hdrCapability{Capable: true}78+ o.hdr.cap = hdrCapability{Capable: true}
79- o.hdrSettings = HDRSettings{Enabled: true, SDRBrightness: 203}79+ o.hdr.settings = HDRSettings{Enabled: true, SDRBrightness: 203}
80- o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}80+ o.hdr.props = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}
81 o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 0}}81 o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 0}}
82 r := portsmocks.NewMockRenderer(t)82 r := portsmocks.NewMockRenderer(t)
83 buf := func() ports.DMABuf {83 buf := func() ports.DMABuf {
@@ -105,8 +105,8 @@ func TestHDRTestCommitFallbackReexportsSDR(t *testing.T) {
105 if !slices.Equal(calls, []string{"hdr", "sdr", "drop"}) {105 if !slices.Equal(calls, []string{"hdr", "sdr", "drop"}) {
106 t.Fatalf("fallback order: %v", calls)106 t.Fatalf("fallback order: %v", calls)
107 }107 }
108- if o.hdrOn || !o.hdrFailed {108+ if o.hdr.on || !o.hdr.failed {
109- t.Fatalf("HDR on %t failed %t", o.hdrOn, o.hdrFailed)109+ t.Fatalf("HDR on %t failed %t", o.hdr.on, o.hdr.failed)
110 }110 }
111 if len(*commits) != 4 {111 if len(*commits) != 4 {
112 t.Fatalf("commits: %d", len(*commits))112 t.Fatalf("commits: %d", len(*commits))
@@ -137,11 +137,11 @@ func TestHDRSettingsRestartComparison(t *testing.T) {
137 {HDRSettings{Enabled: true, SDRBrightness: 350}, true},137 {HDRSettings{Enabled: true, SDRBrightness: 350}, true},
138 {HDRSettings{SDRBrightness: ports.DefaultSDRBrightness}, true},138 {HDRSettings{SDRBrightness: ports.DefaultSDRBrightness}, true},
139 } {139 } {
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 {
141 t.Fatalf("settings %+v: restart %t, want %t", tc.settings, restart, tc.restart)141 t.Fatalf("settings %+v: restart %t, want %t", tc.settings, restart, tc.restart)
142 }142 }
143 }143 }
144- if !outputNeedsRestart(&Output{hdrSettings: current}, modeInfo{VRefresh: 60}, current) {144+ if !outputNeedsRestart(&Output{hdr: hdrState{settings: current}}, modeInfo{VRefresh: 60}, current) {
145 t.Fatal("mode change did not restart")145 t.Fatal("mode change did not restart")
146 }146 }
147 }147 }
@@ -149,9 +149,9 @@ func TestHDRSettingsRestartComparison(t *testing.T) {
149 func TestHDRSuccessfulModeset(t *testing.T) {149 func TestHDRSuccessfulModeset(t *testing.T) {
150 o, k, commits := testOutput(t)150 o, k, commits := testOutput(t)
151 o.cursor = nil151 o.cursor = nil
152- o.hdr = hdrCapability{Capable: true}152+ o.hdr.cap = hdrCapability{Capable: true}
153- o.hdrSettings = HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness}153+ o.hdr.settings = HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness}
154- o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}154+ o.hdr.props = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}
155 o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 19}}155 o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 19}}
156 r := portsmocks.NewMockRenderer(t)156 r := portsmocks.NewMockRenderer(t)
157 buf := func() ports.DMABuf {157 buf := func() ports.DMABuf {
@@ -170,8 +170,8 @@ func TestHDRSuccessfulModeset(t *testing.T) {
170 if err := o.showImages(r, imagesDriver, nil); err != nil {170 if err := o.showImages(r, imagesDriver, nil); err != nil {
171 t.Fatal(err)171 t.Fatal(err)
172 }172 }
173- if !o.hdrOn || o.hdrFailed || len(*commits) != 2 {173+ if !o.hdr.on || o.hdr.failed || len(*commits) != 2 {
174- t.Fatalf("on=%t failed=%t commits=%d", o.hdrOn, o.hdrFailed, len(*commits))174+ t.Fatalf("on=%t failed=%t commits=%d", o.hdr.on, o.hdr.failed, len(*commits))
175 }175 }
176 for _, c := range *commits {176 for _, c := range *commits {
177 for _, tc := range []struct {177 for _, tc := range []struct {
@@ -190,9 +190,9 @@ func TestHDRSuccessfulModeset(t *testing.T) {
190 func TestHDRBlobReusedAcrossImageSetup(t *testing.T) {190 func TestHDRBlobReusedAcrossImageSetup(t *testing.T) {
191 o, k, commits := testOutput(t)191 o, k, commits := testOutput(t)
192 o.cursor = nil192 o.cursor = nil
193- o.hdr = hdrCapability{Capable: true}193+ o.hdr.cap = hdrCapability{Capable: true}
194- o.hdrSettings = HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness}194+ o.hdr.settings = HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness}
195- o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}195+ o.hdr.props = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}
196 o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 19}}196 o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 19}}
197 r := portsmocks.NewMockRenderer(t)197 r := portsmocks.NewMockRenderer(t)
198 buf := func() ports.DMABuf {198 buf := func() ports.DMABuf {
@@ -224,8 +224,8 @@ func TestHDRBlobReusedAcrossImageSetup(t *testing.T) {
224 if err := o.showImages(r, imagesDriver, nil); err != nil {224 if err := o.showImages(r, imagesDriver, nil); err != nil {
225 t.Fatal(err)225 t.Fatal(err)
226 }226 }
227- if o.hdrBlob != 321 || created != 1 || destroyed != 0 {227+ if o.hdr.blob != 321 || created != 1 || destroyed != 0 {
228- t.Fatalf("pass %d blob %d created %d destroyed %d", i, o.hdrBlob, created, destroyed)228+ t.Fatalf("pass %d blob %d created %d destroyed %d", i, o.hdr.blob, created, destroyed)
229 }229 }
230 }230 }
231 if len(*commits) != 4 {231 if len(*commits) != 4 {
@@ -240,9 +240,9 @@ func TestHDRBlobReusedAcrossImageSetup(t *testing.T) {
240 func TestHDRBlobReplacementWaitsForCommit(t *testing.T) {240 func TestHDRBlobReplacementWaitsForCommit(t *testing.T) {
241 o, k, _ := testOutput(t)241 o, k, _ := testOutput(t)
242 o.cursor = nil242 o.cursor = nil
243- o.hdr = hdrCapability{Capable: true}243+ o.hdr.cap = hdrCapability{Capable: true}
244- o.hdrSettings = HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness}244+ o.hdr.settings = HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness}
245- o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}245+ o.hdr.props = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}
246 o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 19}}246 o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 19}}
247 r := portsmocks.NewMockRenderer(t)247 r := portsmocks.NewMockRenderer(t)
248 buf := func() ports.DMABuf {248 buf := func() ports.DMABuf {
@@ -278,11 +278,55 @@ func TestHDRBlobReplacementWaitsForCommit(t *testing.T) {
278 if err := o.showImages(r, imagesDriver, nil); err != nil {278 if err := o.showImages(r, imagesDriver, nil); err != nil {
279 t.Fatal(err)279 t.Fatal(err)
280 }280 }
281- if o.hdrBlob != 322 || !slices.Equal(destroyed, []uint32{321}) {281+ if o.hdr.blob != 322 || !slices.Equal(destroyed, []uint32{321}) {
282- t.Fatalf("blob %d destroyed %v", o.hdrBlob, destroyed)282+ t.Fatalf("blob %d destroyed %v", o.hdr.blob, destroyed)
283 }283 }
284 o.Close()284 o.Close()
285 if !slices.Equal(destroyed, []uint32{321, 322}) {285 if !slices.Equal(destroyed, []uint32{321, 322}) {
286 t.Fatalf("destroyed %v", destroyed)286 t.Fatalf("destroyed %v", destroyed)
287 }287 }
288 }288 }
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 (
4 "context"4 "context"
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7- "maps"
8 "os"7 "os"
9 "slices"8 "slices"
10 "time"9 "time"
@@ -12,8 +11,8 @@ import (
12 11
13 "github.com/bnema/neferwl/internal/adapters/capture"12 "github.com/bnema/neferwl/internal/adapters/capture"
14 "github.com/bnema/neferwl/internal/adapters/clock"13 "github.com/bnema/neferwl/internal/adapters/clock"
14+ "github.com/bnema/neferwl/internal/adapters/presented"
15 15
16- "github.com/bnema/neferwl/internal/adapters/syncfile"
17 "github.com/bnema/neferwl/internal/ports"16 "github.com/bnema/neferwl/internal/ports"
18 "github.com/bnema/zerowrap"17 "github.com/bnema/zerowrap"
19 "golang.org/x/sys/unix"18 "golang.org/x/sys/unix"
@@ -37,22 +36,15 @@ type Output struct {
37 inactiveOnClose bool // affirmative terminal KMS result, owned by Close36 inactiveOnClose bool // affirmative terminal KMS result, owned by Close
38 runContext context.Context // bounds compositor clear waits during Run startup37 runContext context.Context // bounds compositor clear waits during Run startup
39 38
40- k kms39+ k kms
41- flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents40+ flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents
42- crtc uint3241+ crtc uint32
43- conn connector42+ conn connector
44- mode modeInfo43+ mode modeInfo
45- saved modeCrtc44+ saved modeCrtc
46- monitor Monitor45+ monitor Monitor
47- hdr hdrCapability46+ hdr hdrState
48- hdrProps connectorHDRProps47+ log zerowrap.Logger
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
56 // clock paces the periodic stats and renderer trim (nil: system).48 // clock paces the periodic stats and renderer trim (nil: system).
57 clock ports.Clock49 clock ports.Clock
58 // Properties: CRTC and connector property IDs by name.50 // Properties: CRTC and connector property IDs by name.
@@ -98,11 +90,9 @@ type Output struct {
98 shown, queued uint6490 shown, queued uint64
99 planeRect planeRect91 planeRect planeRect
100 reason string // why the last frame was composed ("" = scanout)92 reason string // why the last frame was composed ("" = scanout)
101- unsent []ports.OutputPresented93+ reports presented.Queue
102- // Last immutable snapshot sent in a report. Never mutate it: wayland may94+ // Last immutable shows snapshot sent in a flip. Never mutate it: wayland
103- // still be reading a prior report on another goroutine.95+ // may still be reading a prior report on another goroutine.
104- seenSnapshot map[ports.WindowID]uint64
105- childReadsSnapshot map[ports.WindowID]uint64
106 showsSnapshot map[ports.WindowID]uint6496 showsSnapshot map[ports.WindowID]uint64
107 directShowsSnapshot map[ports.WindowID]uint6497 directShowsSnapshot map[ports.WindowID]uint64
108 showsScratch map[ports.WindowID]uint6498 showsScratch map[ports.WindowID]uint64
@@ -163,91 +153,6 @@ type Output struct {
163 // Ready reports the result of the first modeset exactly once.153 // Ready reports the result of the first modeset exactly once.
164 func (o *Output) Ready() <-chan error { return o.ready }154 func (o *Output) Ready() <-chan error { return o.ready }
165 155
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-
251 // CursorLoader returns the image of a cursor at an output scale, at most156 // CursorLoader returns the image of a cursor at an output scale, at most
252 // limit pixels on a side; an empty image hides the cursor.157 // limit pixels on a side; an empty image hides the cursor.
253 type CursorLoader func(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error)158 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
264 if err := o.readProps(card.fd, pipe, card.taken, cursorSize(card.fd)); err != nil {169 if err := o.readProps(card.fd, pipe, card.taken, cursorSize(card.fd)); err != nil {
265 return nil, err170 return nil, err
266 }171 }
267- o.hdrSettings = normalizedHDRSettings(card.want.HDR[c.name])172+ o.hdr.settings = normalizedHDRSettings(card.want.HDR[c.name])
268- o.hdr = detectHDR(o.monitor, o.hdrProps)173+ o.hdr.cap = detectHDR(o.monitor, o.hdr.props)
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")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")
270- if o.hdrSettings.Enabled && !o.hdr.Capable {175+ if o.hdr.settings.Enabled && !o.hdr.cap.Capable {
271- log.Warn().Str("connector", c.name).Str("reason", o.hdr.Reason).Msg("HDR requested but unavailable")176+ log.Warn().Str("connector", c.name).Str("reason", o.hdr.cap.Reason).Msg("HDR requested but unavailable")
272 }177 }
273 o.tearing = card.async && !card.want.NoTearing178 o.tearing = card.async && !card.want.NoTearing
274 if !card.want.NoVRR {179 if !card.want.NoVRR {
@@ -295,7 +200,7 @@ func (o *Output) readProps(fd, pipe int, taken map[uint32]bool, cursorSide int)
295 }200 }
296 o.connCrtc = uint32(np["CRTC_ID"][0])201 o.connCrtc = uint32(np["CRTC_ID"][0])
297 // The connector's property metadata is needed for enum/range capability.202 // The connector's property metadata is needed for enum/range capability.
298- o.hdrProps = readConnectorHDRProps(fd, np)203+ o.hdr.props = readConnectorHDRProps(fd, np)
299 o.contentProp, o.contentValues = readContentTypeProp(fd, np)204 o.contentProp, o.contentValues = readContentTypeProp(fd, np)
300 o.contentValue = np["content type"][1]205 o.contentValue = np["content type"][1]
301 o.contentWanted = o.contentValue206 o.contentWanted = o.contentValue
@@ -524,12 +429,12 @@ func (o *Output) sendFormats() {
524 return429 return
525 }430 }
526 f := ports.OutputFormats{Output: o.conn.name, Device: o.device}431 f := ports.OutputFormats{Output: o.conn.name, Device: o.device}
527- if o.hdrOn && !o.off {432+ if o.hdr.on && !o.off {
528- f.HDR = &ports.OutputHDR{MaxLuminance: o.hdr.MaxLuminance, MaxFrameAverage: o.hdr.MaxFrameAverage, MinLuminance: o.hdr.MinLuminance}433+ f.HDR = &ports.OutputHDR{MaxLuminance: o.hdr.cap.MaxLuminance, MaxFrameAverage: o.hdr.cap.MaxFrameAverage, MinLuminance: o.hdr.cap.MinLuminance}
529 }434 }
530 if o.scanout && !o.off {435 if o.scanout && !o.off {
531 for _, format := range o.scanoutFormats(o.sampled) {436 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 {
533 f.Formats = append(f.Formats, format)438 f.Formats = append(f.Formats, format)
534 }439 }
535 }440 }
@@ -592,7 +497,7 @@ func (o *Output) modesetBaseReq(blob uint32, active bool) *atomicReq {
592 req.set(o.crtc, o.vrrProp, 0)497 req.set(o.crtc, o.vrrProp, 0)
593 req.set(o.conn.id, o.contentProp, o.contentValues[0])498 req.set(o.conn.id, o.contentProp, o.contentValues[0])
594 req.set(o.conn.id, o.connCrtc, uint64(o.crtc))499 req.set(o.conn.id, o.connCrtc, uint64(o.crtc))
595- o.hdrConnectorProps(req, o.hdrOn)500+ o.hdrConnectorProps(req, o.hdr.on)
596 return req501 return req
597 }502 }
598 503
@@ -617,86 +522,6 @@ func (o *Output) modesetReq(blob uint32, active bool) *atomicReq {
617 return req522 return req
618 }523 }
619 524
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-
700 // commitFrame flips fb in one commit with the cursor and VRR. An async525 // commitFrame flips fb in one commit with the cursor and VRR. An async
701 // commit carries only the primary plane (kernel rule): a frame that must526 // commit carries only the primary plane (kernel rule): a frame that must
702 // also move the cursor or change VRR flips at vblank. fence is the527 // 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
951 return true, err776 return true, err
952 }777 }
953 778
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-
965 // Close frees buffers and disables scanout. Without a security gate it779 // Close frees buffers and disables scanout. Without a security gate it
966 // retains legacy restoration of the CRTC state found at startup; a wired780 // retains legacy restoration of the CRTC state found at startup; a wired
967 // gate never admits saved desktop pixels, even if its snapshot is unlocked.781 // gate never admits saved desktop pixels, even if its snapshot is unlocked.
@@ -1016,10 +830,8 @@ func (o *Output) Close() {
1016 detached = o.primary != nil && o.crtc != 0 && o.crtcProps["ACTIVE"] != 0830 detached = o.primary != nil && o.crtc != 0 && o.crtcProps["ACTIVE"] != 0
1017 }831 }
1018 }832 }
1019- // A live metadata blob means an HDR modeset may still be on screen, even833+ // A live metadata blob is freed only once an SDR modeset succeeds.
1020- // after a failed SDR fallback (hdrOn is then false): it is freed only once834+ hdrShown := o.hdr.shown()
1021- // an SDR modeset succeeds.
1022- hdrShown := o.hdrOn || o.hdrBlob != 0
1023 // Best effort: at exit the card may already belong to another session.835 // Best effort: at exit the card may already belong to another session.
1024 if err := o.k.commit(req, atomicAllowModes, 0); err != nil {836 if err := o.k.commit(req, atomicAllowModes, 0); err != nil {
1025 ev := o.log.Debug()837 ev := o.log.Debug()
@@ -1042,10 +854,7 @@ func (o *Output) Close() {
1042 _ = o.k.destroyBlob(o.modeBlob)854 _ = o.k.destroyBlob(o.modeBlob)
1043 o.modeBlob = 0855 o.modeBlob = 0
1044 }856 }
1045- if o.hdrBlob != 0 {857+ o.hdr.releaseBlob(o.k)
1046- _ = o.k.destroyBlob(o.hdrBlob)
1047- o.hdrBlob = 0
1048- }
1049 if o.cursor != nil {858 if o.cursor != nil {
1050 o.cursor.free(o.k)859 o.cursor.free(o.k)
1051 }860 }
@@ -1063,8 +872,8 @@ func (o *Output) Close() {
1063 func (o *Output) InactiveOnClose() bool { return o.inactiveOnClose }872 func (o *Output) InactiveOnClose() bool { return o.inactiveOnClose }
1064 873
1065 // Run renders scenes and commits them until ctx ends. active reports seat874 // 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.875+// enable/disable. What the output shows and read is reported on reportsCh.
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) {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) {
1068 defer func() {877 defer func() {
1069 p := recover()878 p := recover()
1070 if p != nil {879 if p != nil {
@@ -1234,15 +1043,15 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1234 // A display frame or fence that has not finished must not1043 // A display frame or fence that has not finished must not
1235 // keep a finished child read held: child holds do not expire.1044 // keep a finished child read held: child holds do not expire.
1236 // Report them alone, at the last Seen that was safe.1045 // Report them alone, at the last Seen that was safe.
1237- o.report(nil, o.seenSnapshot)1046+ o.report(nil, o.reports.Seen())
1238 readWait = readWait || o.capped1047 readWait = readWait || o.capped
1239 }1048 }
1240 }1049 }
1241- o.flushReport(presented)1050+ o.reports.Flush(reportsCh)
1242 // An unsent report is retried soon, not only on the next event;1051 // An unsent report is retried soon, not only on the next event;
1243 // so is one that waits for an uncommitted frame's fence.1052 // so is one that waits for an uncommitted frame's fence.
1244 var retry <-chan time.Time1053 var retry <-chan time.Time
1245- if len(o.unsent) > 0 || readWait {1054+ if o.reports.Len() > 0 || readWait {
1246 retryTimer.Reset(time.Millisecond)1055 retryTimer.Reset(time.Millisecond)
1247 retry = retryTimer.C1056 retry = retryTimer.C
1248 } else {1057 } else {
@@ -1667,187 +1476,6 @@ func (o *Output) setCursor(r ports.Renderer, load CursorLoader, c ports.CursorCh
1667 o.log.Debug().Float64("scale", scale).Str("shape", c.Shape).Int("w", img.W).Int("h", img.H).Msg("cursor image")1476 o.log.Debug().Float64("scale", scale).Str("shape", c.Shape).Int("w", img.W).Int("h", img.H).Msg("cursor image")
1668 }1477 }
1669 1478
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-
1851 // refused reports a modeset error meaning KMS rejects the images.1479 // refused reports a modeset error meaning KMS rejects the images.
1852 func refused(err error) bool {1480 func refused(err error) bool {
1853 return errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ERANGE)1481 return errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ERANGE)
@@ -1857,118 +1485,3 @@ func refused(err error) bool {
1857 func lostMaster(err error) bool {1485 func lostMaster(err error) bool {
1858 return errors.Is(err, unix.EACCES) || errors.Is(err, unix.EPERM)1486 return errors.Is(err, unix.EACCES) || errors.Is(err, unix.EPERM)
1859 }1487 }
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 (
10 "testing"10 "testing"
11 "time"11 "time"
12 12
13+ "github.com/bnema/neferwl/internal/adapters/presented"
13 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"14 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
14 "github.com/bnema/neferwl/internal/ports"15 "github.com/bnema/neferwl/internal/ports"
15 "github.com/bnema/zerowrap"16 "github.com/bnema/zerowrap"
@@ -196,8 +197,8 @@ func TestCursorCommitWaitsForEvent(t *testing.T) {
196 // Its event is not a frame: nothing is reported.197 // Its event is not a frame: nothing is reported.
197 seen := map[ports.WindowID]uint64{}198 seen := map[ports.WindowID]uint64{}
198 o.completed(eventOf(c), seen)199 o.completed(eventOf(c), seen)
199- if o.frame.pendingCommit() || len(o.unsent) != 0 || o.flips != 0 {200+ if o.frame.pendingCommit() || o.reports.Len() != 0 || o.flips != 0 {
200- t.Fatalf("pending=%v unsent=%d flips=%d", o.frame.pendingCommit(), len(o.unsent), o.flips)201+ t.Fatalf("pending=%v unsent=%d flips=%d", o.frame.pendingCommit(), o.reports.Len(), o.flips)
201 }202 }
202 // Nothing changed: no commit.203 // Nothing changed: no commit.
203 if err := o.commitState(false); err != nil || len(*commits) != 1 {204 if err := o.commitState(false); err != nil || len(*commits) != 1 {
@@ -289,10 +290,10 @@ func TestFlipReportsTimestampAndShownFrame(t *testing.T) {
289 t.Fatal(err)290 t.Fatal(err)
290 }291 }
291 o.completed(flipEvent{user: (*commits)[0].user, when: 5 * time.Second, seq: 11}, map[ports.WindowID]uint64{1: 4})292 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+ if o.reports.Len() != 1 {
293- t.Fatalf("reports %d", len(o.unsent))294+ t.Fatalf("reports %d", o.reports.Len())
294 }295 }
295- f := o.unsent[0].Flip296+ f := o.reports.Unsent()[0].Flip
296 if f == nil || f.When != 5*time.Second || f.Seq != 11 || f.Refresh != time.Second/60 || f.Shows[1] != 4 || !f.HardwareClock {297 if f == nil || f.When != 5*time.Second || f.Seq != 11 || f.Refresh != time.Second/60 || f.Shows[1] != 4 || !f.HardwareClock {
297 t.Fatalf("flip %+v", f)298 t.Fatalf("flip %+v", f)
298 }299 }
@@ -310,11 +311,11 @@ func TestReportSeenAllocations(t *testing.T) {
310 } else {311 } else {
311 t.Logf("unchanged report: %.1f allocs", allocs)312 t.Logf("unchanged report: %.1f allocs", allocs)
312 }313 }
313- previous := o.unsent[0].Seen314+ previous := o.reports.Unsent()[0].Seen
314 seen[1] = 3315 seen[1] = 3
315 o.report(nil, seen)316 o.report(nil, seen)
316- if previous[1] != 1 || o.unsent[0].Seen[1] != 3 {317+ if previous[1] != 1 || o.reports.Unsent()[0].Seen[1] != 3 {
317- t.Fatalf("snapshot mutated: old %v new %v", previous, o.unsent[0].Seen)318+ t.Fatalf("snapshot mutated: old %v new %v", previous, o.reports.Unsent()[0].Seen)
318 }319 }
319 }320 }
320 321
@@ -348,14 +349,14 @@ func TestReportsKeepEveryFlip(t *testing.T) {
348 o.report(nil, seen)349 o.report(nil, seen)
349 o.report(&ports.FlipInfo{Seq: 2}, seen)350 o.report(&ports.FlipInfo{Seq: 2}, seen)
350 o.report(nil, map[ports.WindowID]uint64{1: 3})351 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+ 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 {
352- t.Fatalf("unsent %+v", o.unsent)353+ t.Fatalf("unsent %+v", o.reports.Unsent())
353 }354 }
354- for i := range maxUnsent + 3 {355+ for i := range presented.MaxUnsent + 3 {
355 o.report(&ports.FlipInfo{Seq: uint64(10 + i), Shows: map[ports.WindowID]uint64{}}, seen)356 o.report(&ports.FlipInfo{Seq: uint64(10 + i), Shows: map[ports.WindowID]uint64{}}, seen)
356 }357 }
357- if len(o.unsent) != maxUnsent || o.unsent[0].Flip.Merged == 0 {358+ if o.reports.Len() != presented.MaxUnsent || o.reports.Unsent()[0].Flip.Merged == 0 {
358- t.Fatalf("cap: %d reports, first merged %d", len(o.unsent), o.unsent[0].Flip.Merged)359+ t.Fatalf("cap: %d reports, first merged %d", o.reports.Len(), o.reports.Unsent()[0].Flip.Merged)
359 }360 }
360 }361 }
361 362
M internal/adapters/drm/protection_retry_test.go +5 -5
@@ -250,9 +250,9 @@ func TestProtectedShowImagesHDRKeepsHDRForKMSErrors(t *testing.T) {
250 o, k, _ := testOutput(t, errs...)250 o, k, _ := testOutput(t, errs...)
251 o.cursor = nil251 o.cursor = nil
252 o.protected = true252 o.protected = true
253- o.hdr = hdrCapability{Capable: true}253+ o.hdr.cap = hdrCapability{Capable: true}
254- o.hdrSettings = HDRSettings{Enabled: true, SDRBrightness: 203}254+ o.hdr.settings = HDRSettings{Enabled: true, SDRBrightness: 203}
255- o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}255+ o.hdr.props = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}
256 o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 0}}256 o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 0}}
257 if errno == nil {257 if errno == nil {
258 o.primary.props = map[string]uint32{"CRTC_ID": pCrtcID} // incomplete: detach fails258 o.primary.props = map[string]uint32{"CRTC_ID": pCrtcID} // incomplete: detach fails
@@ -279,8 +279,8 @@ func TestProtectedShowImagesHDRKeepsHDRForKMSErrors(t *testing.T) {
279 if !o.commitFailed(err, &enabled) {279 if !o.commitFailed(err, &enabled) {
280 t.Fatalf("protected failure not handled: %v", err)280 t.Fatalf("protected failure not handled: %v", err)
281 }281 }
282- if o.hdrFailed || !o.hdrOn {282+ if o.hdr.failed || !o.hdr.on {
283- t.Fatalf("HDR fell back: failed=%v on=%v", o.hdrFailed, o.hdrOn)283+ t.Fatalf("HDR fell back: failed=%v on=%v", o.hdr.failed, o.hdr.on)
284 }284 }
285 if lost == enabled {285 if lost == enabled {
286 t.Fatalf("lost master=%v enabled=%v", lost, enabled)286 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) {
74 failures = []error{unix.EACCES}74 failures = []error{unix.EACCES}
75 }75 }
76 o, k, _ := testOutput(t, failures...)76 o, k, _ := testOutput(t, failures...)
77- o.device, o.hdrOn = 42, true77+ o.device, o.hdr.on = 42, true
78 feedback := make(chan ports.OutputFormats, 2)78 feedback := make(chan ports.OutputFormats, 2)
79 o.formats = feedback79 o.formats = feedback
80 r := portsmocks.NewMockRenderer(t)80 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
196 if reason == "" && isYUVFormat(c.DMABuf.Format) {196 if reason == "" && isYUVFormat(c.DMABuf.Format) {
197 reason = "yuv"197 reason = "yuv"
198 }198 }
199- if o.hdrOn && reason == "" {199+ if o.hdr.on && reason == "" {
200 switch {200 switch {
201 case !c.Color.IsPQ2020():201 case !c.Color.IsPQ2020():
202 reason = "hdr_sdr_content"202 reason = "hdr_sdr_content"
203 case !isTenBit(c.DMABuf.Format):203 case !isTenBit(c.DMABuf.Format):
204 reason = "hdr_format"204 reason = "hdr_format"
205 }205 }
206- } else if !o.hdrOn && reason == "" && c.Color.IsPQ2020() {206+ } else if !o.hdr.on && reason == "" && c.Color.IsPQ2020() {
207 // An SDR connector must never show raw PQ values.207 // An SDR connector must never show raw PQ values.
208 reason = "sdr_pq_content"208 reason = "sdr_pq_content"
209 }209 }
@@ -243,7 +243,7 @@ func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]por
243 func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (overlayWin, ports.Scene) {243 func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (overlayWin, ports.Scene) {
244 reason := "no_plane"244 reason := "no_plane"
245 var ov overlayWin245 var ov overlayWin
246- if o.hdrOn {246+ if o.hdr.on {
247 reason = "hdr"247 reason = "hdr"
248 } else if o.overlay != nil && o.scanout {248 } else if o.overlay != nil && o.scanout {
249 var w ports.SceneWindow249 var w ports.SceneWindow
M internal/adapters/drm/scanout_test.go +7 -7
@@ -227,7 +227,7 @@ func TestModesetSendsScanoutFormats(t *testing.T) {
227 227
228 func TestHDRScanoutDecision(t *testing.T) {228 func TestHDRScanoutDecision(t *testing.T) {
229 o, k, _ := testOutput(t)229 o, k, _ := testOutput(t)
230- o.hdrOn = true230+ o.hdr.on = true
231 o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30}}231 o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30}}
232 scene := ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Fullscreen: true, Rect: ports.Rect{W: 200, H: 100}}}}232 scene := ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Fullscreen: true, Rect: ports.Rect{W: 200, H: 100}}}}
233 c := ports.SurfaceContent{ID: 1, Width: 200, Height: 100, LogicalW: 200, LogicalH: 100, DMABuf: &ports.DMABuf{ID: 11, Format: fourccXR30}}233 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) {
247 c.DMABuf.Format = fourccXR30247 c.DMABuf.Format = fourccXR30
248 k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(uint32(77), nil).Once()248 k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(uint32(77), nil).Once()
249 check("")249 check("")
250- o.hdrOn = false250+ o.hdr.on = false
251 check("sdr_pq_content")251 check("sdr_pq_content")
252 }252 }
253 253
254 func TestModesetReportsConfirmedHDR(t *testing.T) {254 func TestModesetReportsConfirmedHDR(t *testing.T) {
255 o, k, _ := testOutput(t)255 o, k, _ := testOutput(t)
256 o.cursor = nil256 o.cursor = nil
257- o.hdrOn = true257+ o.hdr.on = true
258- o.hdr = hdrCapability{MaxLuminance: 1000, MaxFrameAverage: 400, MinLuminance: 0.005}258+ o.hdr.cap = hdrCapability{MaxLuminance: 1000, MaxFrameAverage: 400, MinLuminance: 0.005}
259 o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30}, {Format: fourccXRGB}}259 o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30}, {Format: fourccXRGB}}
260 o.sampled = []ports.DMABufFormat{{Format: fourccXR30}, {Format: fourccXRGB}}260 o.sampled = []ports.DMABufFormat{{Format: fourccXR30}, {Format: fourccXRGB}}
261 ch := make(chan ports.OutputFormats, 2)261 ch := make(chan ports.OutputFormats, 2)
@@ -268,7 +268,7 @@ func TestModesetReportsConfirmedHDR(t *testing.T) {
268 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 {268 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 {
269 t.Fatalf("HDR report: %+v", f)269 t.Fatalf("HDR report: %+v", f)
270 }270 }
271- o.hdrOn = false // fallback to SDR271+ o.hdr.on = false // fallback to SDR
272 if err := o.modeset(); err != nil {272 if err := o.modeset(); err != nil {
273 t.Fatal(err)273 t.Fatal(err)
274 }274 }
@@ -282,8 +282,8 @@ func TestModesetReportsConfirmedHDR(t *testing.T) {
282 func TestPowerOffWithdrawsHDRAndFormats(t *testing.T) {282 func TestPowerOffWithdrawsHDRAndFormats(t *testing.T) {
283 o, _, _ := testOutput(t)283 o, _, _ := testOutput(t)
284 o.cursor = nil284 o.cursor = nil
285- o.hdrOn, o.scanout = true, true285+ o.hdr.on, o.scanout = true, true
286- o.hdr = hdrCapability{MaxLuminance: 1000}286+ o.hdr.cap = hdrCapability{MaxLuminance: 1000}
287 o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30}}287 o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30}}
288 o.sampled = []ports.DMABufFormat{{Format: fourccXR30}}288 o.sampled = []ports.DMABufFormat{{Format: fourccXR30}}
289 ch := make(chan ports.OutputFormats, 1)289 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 (
5 "errors"5 "errors"
6 "image"6 "image"
7 "os"7 "os"
8- "reflect"
9 "sync"8 "sync"
10 "sync/atomic"9 "sync/atomic"
11 "testing"10 "testing"
@@ -259,33 +258,6 @@ func TestRenderErrorDoesNotAnswerHandedRequestsAgain(t *testing.T) {
259 require.Equal(t, []uint64{1}, ids, "request 1 answered once by its worker, no pseudo request 0")258 require.Equal(t, []uint64{1}, ids, "request 1 answered once by its worker, no pseudo request 0")
260 }259 }
261 260
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-
289 // A scene that shows the capture indicator is never the source of a capture:261 // A scene that shows the capture indicator is never the source of a capture:
290 // the standard capture is copied from a frame drawn without it, before the262 // the standard capture is copied from a frame drawn without it, before the
291 // displayed frame.263 // displayed frame.
M internal/adapters/headless/output.go +31 -64
@@ -4,16 +4,17 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7- "golang.org/x/sys/unix"
8 "image"7 "image"
9 "image/png"8 "image/png"
10- "maps"
11 "os"9 "os"
12 "path/filepath"10 "path/filepath"
13 "time"11 "time"
14 12
13+ "golang.org/x/sys/unix"
14+
15 "github.com/bnema/neferwl/internal/adapters/capture"15 "github.com/bnema/neferwl/internal/adapters/capture"
16 "github.com/bnema/neferwl/internal/adapters/clock"16 "github.com/bnema/neferwl/internal/adapters/clock"
17+ "github.com/bnema/neferwl/internal/adapters/presented"
17 "github.com/bnema/neferwl/internal/adapters/syncfile"18 "github.com/bnema/neferwl/internal/adapters/syncfile"
18 "github.com/bnema/neferwl/internal/ports"19 "github.com/bnema/neferwl/internal/ports"
19 "github.com/bnema/zerowrap"20 "github.com/bnema/zerowrap"
@@ -120,9 +121,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
120 var want ports.CursorChange121 var want ports.CursorChange
121 cursorScale := -1.0 // not loaded yet122 cursorScale := -1.0 // not loaded yet
122 seen := map[ports.WindowID]uint64{}123 seen := map[ports.WindowID]uint64{}
123- var holds holdSnapshots124+ // reports holds what the channel could not take yet, retried soon.
124- // pending is a report the channel could not take, retried soon.125+ var reports presented.Queue
125- var pending *ports.OutputPresented
126 var security ports.SecurityState126 var security ports.SecurityState
127 securityKnown := false127 securityKnown := false
128 failRequests := func(reason string) {128 failRequests := func(reason string) {
@@ -151,7 +151,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
151 // confirmed software inactivity, independent of its old epoch.151 // confirmed software inactivity, independent of its old epoch.
152 inactive := haveScene && scene.Off152 inactive := haveScene && scene.Off
153 security, securityKnown = next, true153 security, securityKnown = next, true
154- pending = nil154+ reports.Reset()
155 haveScene, dirty = false, false155 haveScene, dirty = false, false
156 if !security.Protected {156 if !security.Protected {
157 continue157 continue
@@ -230,7 +230,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
230 return err230 return err
231 }231 }
232 var retry <-chan time.Time232 var retry <-chan time.Time
233- if pending != nil {233+ if reports.Len() > 0 {
234 retry = time.After(time.Millisecond)234 retry = time.After(time.Millisecond)
235 }235 }
236 // Requests wait for a scene that shows their indicator, at most236 // 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
317 }317 }
318 return err318 return err
319 }319 }
320- if pending != nil {320+ opts.flush(&reports)
321- pending = opts.send(pending)
322- }
323 continue321 continue
324 case <-trim.C():322 case <-trim.C():
325 if err := checkSecurity(); err != nil {323 if err := checkSecurity(); err != nil {
@@ -424,7 +422,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
424 if !haveScene || !dirty || scene.Off {422 if !haveScene || !dirty || scene.Off {
425 // Contents not drawn are still read: report them. An output423 // Contents not drawn are still read: report them. An output
426 // turned off draws nothing.424 // turned off draws nothing.
427- pending = opts.report(pending, seen)425+ opts.report(&reports, nil, seen)
428 continue426 continue
429 }427 }
430 dirty = false428 dirty = false
@@ -473,10 +471,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
473 if opts.Presented != nil {471 if opts.Presented != nil {
474 _, reads, _ := pipeline.CapHiddenSeen(seen)472 _, reads, _ := pipeline.CapHiddenSeen(seen)
475 if len(reads) > 0 {473 if len(reads) > 0 {
476- r := holds.report(opts.Name, seen, reads)474+ reports.Push(ports.OutputPresented{Output: opts.Name, Seen: seen, ChildReads: reads})
477- select {475+ if reports.Drain(ctx, opts.Presented) != nil {
478- case opts.Presented <- r:
479- case <-ctx.Done():
480 return nil476 return nil
481 }477 }
482 }478 }
@@ -574,7 +570,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
574 pipeline.EndGate(nil)570 pipeline.EndGate(nil)
575 requests = capture.Waiting(requests, scene)571 requests = capture.Waiting(requests, scene)
576 frame++572 frame++
577- pending = opts.flipped(pending, seen, scene, surfaces)573+ opts.report(&reports, flipInfo(scene, surfaces), seen)
578 if opts.ScreenshotDir != "" && !opts.protected() {574 if opts.ScreenshotDir != "" && !opts.protected() {
579 shot := r.Pixels()575 shot := r.Pixels()
580 if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {576 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
601 }597 }
602 }598 }
603 599
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-
622 func (opts Options) protected() bool {600 func (opts Options) protected() bool {
623 return opts.Security != nil && opts.Security.Snapshot().Protected601 return opts.Security != nil && opts.Security.Snapshot().Protected
624 }602 }
@@ -645,31 +623,29 @@ func writePNGSecure(path string, img *image.RGBA, security ports.SessionSecurity
645 return os.Rename(f.Name(), path)623 return os.Rename(f.Name(), path)
646 }624 }
647 625
648-// report sends what the output read, or returns it to retry when the626+// report queues what the output read, with flip for a drawn frame, and
649-// channel is full. Frames that were not drawn carry no flip.627+// sends what the channel takes now; the rest is retried soon.
650-func (opts Options) report(_ *ports.OutputPresented, seen map[ports.WindowID]uint64) *ports.OutputPresented {628+func (opts Options) report(q *presented.Queue, flip *ports.FlipInfo, seen map[ports.WindowID]uint64) {
651 if opts.Presented == nil {629 if opts.Presented == nil {
652- return nil630+ 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")
653 }634 }
654- return opts.send(&ports.OutputPresented{Output: opts.Name, Seen: maps.Clone(seen)})635+ q.Flush(opts.Presented)
655 }636 }
656 637
657-// flipped reports a drawn frame as a flip at the current CLOCK_MONOTONIC638+// flush retries reports the channel could not take.
658-// time (software clock, refresh unknown), so presentation feedback and639+func (opts Options) flush(q *presented.Queue) {
659-// frame callbacks follow headless frames. A report still waiting is640+ if opts.Presented != nil {
660-// sent first.641+ q.Flush(opts.Presented)
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- }
672 }642 }
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 {
673 var ts unix.Timespec649 var ts unix.Timespec
674 _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)650 _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)
675 shows := map[ports.WindowID]uint64{}651 shows := map[ports.WindowID]uint64{}
@@ -678,14 +654,5 @@ func (opts Options) flipped(pending *ports.OutputPresented, seen map[ports.Windo
678 shows[id] = c.Seq654 shows[id] = c.Seq
679 }655 }
680 }656 }
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}})657+ return &ports.FlipInfo{When: time.Duration(ts.Nano()), Shows: shows}
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- }
691 }658 }
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) {
238 // client received what followed.238 // client received what followed.
239 func (h *captureHarness) settle(t *testing.T, cc *cclient) {239 func (h *captureHarness) settle(t *testing.T, cc *cclient) {
240 t.Helper()240 t.Helper()
241- deadline := time.Now().Add(2 * time.Second)241+ // The command loop takes commands off the channel before it applies
242- for len(h.commands) > 0 && time.Now().Before(deadline) {242+ // them on the display: an empty channel does not mean applied. Commands
243- time.Sleep(time.Millisecond)243+ // apply in order, so a marker (SlotsPending, a plain flag) seen on the
244- }244+ // display means every command sent before it was applied.
245- if !h.s.display.Do(func() {}) {245+ var was bool
246+ if !h.s.display.Do(func() { was = h.s.slotsPending }) {
246 t.Fatal("display stopped")247 t.Fatal("display stopped")
247 }248 }
249+ h.applied(t, !was)
250+ h.applied(t, was)
248 cc.roundtrip(t)251 cc.roundtrip(t)
249 }252 }
250 253
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+
251 func (h *captureHarness) send(cmd ports.ClientCommand) { h.commands <- cmd }274 func (h *captureHarness) send(cmd ports.ClientCommand) { h.commands <- cmd }
252 275
253 // captureShm makes a w x h XRGB8888 wl_buffer.276 // captureShm makes a w x h XRGB8888 wl_buffer.
@@ -1264,14 +1287,24 @@ func TestExclusionAttachLimit(t *testing.T) {
1264 }1287 }
1265 over := hd.attachSurfaceNew(t, token)1288 over := hd.attachSurfaceNew(t, token)
1266 expectAttachment(t, hd.c, over, imagecapture.NeferwlCaptureLayerV1EventFailed, int64(imagecapture.NeferwlCaptureLayerV1FailureTooManyLayers))1289 expectAttachment(t, hd.c, over, imagecapture.NeferwlCaptureLayerV1EventFailed, int64(imagecapture.NeferwlCaptureLayerV1FailureTooManyLayers))
1267- n := 01290+ // Events reach core through a forwarding goroutine: wait for each.
1268- for len(h.events) > 0 {1291+ for range ports.MaxExclusionLayers {
1269- if l, ok := (<-h.events).(ports.CaptureExclusionLayer); ok && l.Attached {1292+ if l := nextEvent[ports.CaptureExclusionLayer](t, h.events); !l.Attached {
1270- n++1293+ t.Fatalf("detach %+v", l)
1271 }1294 }
1272 }1295 }
1273- if n != ports.MaxExclusionLayers {1296+ // The refused one never follows: it was refused before the failed event
1274- t.Fatalf("%d layers reached core, want %d", n, ports.MaxExclusionLayers)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+ }
1275 }1308 }
1276 }1309 }
1277 1310
M internal/adapters/wayland/server.go +35 -1
@@ -286,7 +286,41 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
286 d.Close()286 d.Close()
287 return nil, err287 return nil, err
288 }288 }
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+ }
290 s.leaseDevices = map[string]*leaseDevice{}324 s.leaseDevices = map[string]*leaseDevice{}
291 s.pendingLeases = map[uint64]*leaseObject{}325 s.pendingLeases = map[uint64]*leaseObject{}
292 s.pendingLeaseCards = map[uint64]string{}326 s.pendingLeaseCards = map[uint64]string{}
M internal/adapters/wayland/workspaces_test.go +4 -2
@@ -197,8 +197,10 @@ func TestWorkspaceProtocol(t *testing.T) {
197 default:197 default:
198 }198 }
199 snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "1", Hidden: true}, {ID: 43, Name: "2", Index: 1}}}}}199 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.
200 found := false202 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) {
202 if err := c.Roundtrip(); err != nil {204 if err := c.Roundtrip(); err != nil {
203 t.Fatal(err)205 t.Fatal(err)
204 }206 }
@@ -214,7 +216,7 @@ func TestWorkspaceProtocol(t *testing.T) {
214 }216 }
215 snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{}}217 snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{}}
216 found = false218 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) {
218 if err := c.Roundtrip(); err != nil {220 if err := c.Roundtrip(); err != nil {
219 t.Fatal(err)221 t.Fatal(err)
220 }222 }
M internal/adapters/xkb/xkb.go +15 -13
@@ -35,15 +35,17 @@ type Keymap struct {
35 keymapString func(uintptr, uint32) unsafe.Pointer35 keymapString func(uintptr, uint32) unsafe.Pointer
36 free func(uintptr)36 free func(uintptr)
37 getSym func(uintptr, uint32) uint3237 getSym func(uintptr, uint32) uint32
38- symName func(uint32, uintptr, uintptr) int3238+ // Buffers go to C as typed pointers: they escape to the heap, where a
39- updateKey func(uintptr, uint32, uint32) uint3239+ // stack growth during the call cannot move them under C.
40- serializeMods func(uintptr, uint32) uint3240+ symName func(uint32, *byte, uintptr) int32
41- serializeLayout func(uintptr, uint32) uint3241+ updateKey func(uintptr, uint32, uint32) uint32
42- modActive func(uintptr, uintptr, uint32) int3242+ serializeMods func(uintptr, uint32) uint32
43- minCode func(uintptr) uint3243+ serializeLayout func(uintptr, uint32) uint32
44- maxCode func(uintptr) uint3244+ modActive func(uintptr, *byte, uint32) int32
45- symsLevel func(uintptr, uint32, uint32, uint32, *unsafe.Pointer) int3245+ minCode func(uintptr) uint32
46- fromName func(uintptr, uint32) uint3246+ maxCode func(uintptr) uint32
47+ symsLevel func(uintptr, uint32, uint32, uint32, *unsafe.Pointer) int32
48+ fromName func(*byte, uint32) uint32
47 }49 }
48 50
49 type ruleNames struct{ rules, model, layout, variant, options uintptr }51 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
132 code := evdevCode + 8134 code := evdevCode + 8
133 sym := k.getSym(k.state, code)135 sym := k.getSym(k.state, code)
134 var buf [64]byte136 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)))
136 name := ""138 name := ""
137 if n > 0 && n < int32(len(buf)) {139 if n > 0 && n < int32(len(buf)) {
138 name = string(buf[:n])140 name = string(buf[:n])
@@ -156,7 +158,7 @@ func (k *Keymap) Key(evdevCode uint32, pressed bool, timeMsec uint32) ports.KeyE
156 flag ports.Mods158 flag ports.Mods
157 }{{"Shift", ports.ModShift}, {"Control", ports.ModCtrl}, {"Mod1", ports.ModAlt}, {"Mod4", ports.ModSuper}} {159 }{{"Shift", ports.ModShift}, {"Control", ports.ModCtrl}, {"Mod1", ports.ModAlt}, {"Mod4", ports.ModSuper}} {
158 b := append([]byte(mod.name), 0)160 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 {
160 event.Mods |= mod.flag162 event.Mods |= mod.flag
161 }163 }
162 runtime.KeepAlive(b)164 runtime.KeepAlive(b)
@@ -228,7 +230,7 @@ func (k *Keymap) levelName(code, layout, level uint32) string {
228 return ""230 return ""
229 }231 }
230 var buf [64]byte232 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)))
232 if n <= 0 || n >= int32(len(buf)) {234 if n <= 0 || n >= int32(len(buf)) {
233 return ""235 return ""
234 }236 }
@@ -237,7 +239,7 @@ func (k *Keymap) levelName(code, layout, level uint32) string {
237 239
238 func (k *Keymap) KeycodeFor(keysymName string) (evdev uint32, shift bool, ok bool) {240 func (k *Keymap) KeycodeFor(keysymName string) (evdev uint32, shift bool, ok bool) {
239 b := append([]byte(keysymName), 0)241 b := append([]byte(keysymName), 0)
240- sym := k.fromName(uintptr(unsafe.Pointer(&b[0])), 0)242+ sym := k.fromName(&b[0], 0)
241 runtime.KeepAlive(b)243 runtime.KeepAlive(b)
242 if sym == 0 {244 if sym == 0 {
243 return 0, false, false245 return 0, false, false
M internal/core/capindicator.go +82 -57
@@ -43,7 +43,7 @@ type capFlash struct {
43 // already does not.43 // already does not.
44 func (c *Core) captureFrame(v ports.CaptureFrameTaken) bool {44 func (c *Core) captureFrame(v ports.CaptureFrameTaken) bool {
45 changed := false45 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 {
47 s.recording, changed = true, true47 s.recording, changed = true, true
48 }48 }
49 t := capTarget{output: v.Output, workspace: v.Workspace, rect: v.Region}49 t := capTarget{output: v.Output, workspace: v.Workspace, rect: v.Region}
@@ -51,30 +51,35 @@ func (c *Core) captureFrame(v ports.CaptureFrameTaken) bool {
51 // Nothing of the target is left on screen to mark.51 // Nothing of the target is left on screen to mark.
52 return changed52 return changed
53 }53 }
54- until := c.now().Add(ports.CaptureFlash)54+ return c.capt.flash(t, c.now(), c.ch.Clock) || changed
55- for i := range c.capFlashes {55+}
56- if c.capFlashes[i].target == t {56+
57- c.capFlashes[i].until = until57+// flash marks t until ports.CaptureFlash after now and arms the timer. It
58- c.armCaptureTimer()58+// reports whether a new mark shows; extending one does not.
59- return changed59+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
60 }66 }
61 }67 }
62- if len(c.capFlashes) >= maxCaptureFlashes {68+ if len(s.flashes) >= maxCaptureFlashes {
63- c.collapseFlashes(t)69+ s.collapseFlashes(t)
64 }70 }
65- c.capFlashes = append(c.capFlashes, capFlash{target: t, until: until})71+ s.flashes = append(s.flashes, capFlash{target: t, until: until})
66- c.armCaptureTimer()
67 return true72 return true
68 }73 }
69 74
70 // collapseFlashes makes room: the flashes of regions of t's output become one75 // collapseFlashes makes room: the flashes of regions of t's output become one
71 // whole-output flash lasting as long as the longest of them; when that is76 // whole-output flash lasting as long as the longest of them; when that is
72 // not enough the oldest flash goes.77 // not enough the oldest flash goes.
73-func (c *Core) collapseFlashes(t capTarget) {78+func (s *captureState) collapseFlashes(t capTarget) {
74 if t.workspace == 0 && t.output != "" {79 if t.workspace == 0 && t.output != "" {
75 var until time.Time80 var until time.Time
76 n := 081 n := 0
77- c.capFlashes = slices.DeleteFunc(c.capFlashes, func(f capFlash) bool {82+ s.flashes = slices.DeleteFunc(s.flashes, func(f capFlash) bool {
78 if f.target.workspace != 0 || f.target.output != t.output || f.target.rect == (Rect{}) {83 if f.target.workspace != 0 || f.target.output != t.output || f.target.rect == (Rect{}) {
79 return false84 return false
80 }85 }
@@ -85,82 +90,102 @@ func (c *Core) collapseFlashes(t capTarget) {
85 return true90 return true
86 })91 })
87 if n > 0 {92 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})
89 }94 }
90 }95 }
91- if len(c.capFlashes) >= maxCaptureFlashes {96+ if len(s.flashes) >= maxCaptureFlashes {
92 old := 097 old := 0
93- for i, f := range c.capFlashes {98+ for i, f := range s.flashes {
94- if f.until.Before(c.capFlashes[old].until) {99+ if f.until.Before(s.flashes[old].until) {
95 old = i100 old = i
96 }101 }
97 }102 }
98- c.capFlashes = slices.Delete(c.capFlashes, old, old+1)103+ s.flashes = slices.Delete(s.flashes, old, old+1)
99 }104 }
100 }105 }
101 106
102-// captureExpire forgets the flashes that ended.107+// expire forgets the flashes that ended by now.
103-func (c *Core) captureExpire() {108+func (s *captureState) expire(now time.Time) {
104- if len(c.capFlashes) == 0 {109+ s.flashes = slices.DeleteFunc(s.flashes, func(f capFlash) bool { return !f.until.After(now) })
105- return
106- }
107- now := c.now()
108- c.capFlashes = slices.DeleteFunc(c.capFlashes, func(f capFlash) bool { return !f.until.After(now) })
109 }110 }
110 111
111-// captureFlashTick runs when the flash timer fired: it forgets the flashes112+// armTimer arms the timer at the earliest flash when none runs. A flash that
112-// that ended and re-arms for the next. It reports whether one ended.113+// was extended leaves it: the timer fires early, finds nothing to forget and
113-func (c *Core) captureFlashTick() bool {114+// arms again. No flash stops it. A nil clock uses the system timer.
114- n := len(c.capFlashes)115+func (s *captureState) armTimer(now time.Time, clock ports.Clock) {
115- c.captureExpire()116+ if len(s.flashes) == 0 {
116- c.armCaptureTimer()117+ s.stopTimer()
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()
126 return118 return
127 }119 }
128- if c.capStop != nil {120+ if s.timerStop != nil {
129 return121 return
130 }122 }
131- first := c.capFlashes[0].until123+ first := s.flashes[0].until
132- for _, f := range c.capFlashes[1:] {124+ for _, f := range s.flashes[1:] {
133 if f.until.Before(first) {125 if f.until.Before(first) {
134 first = f.until126 first = f.until
135 }127 }
136 }128 }
137- d := max(first.Sub(c.now()), time.Millisecond)129+ d := max(first.Sub(now), time.Millisecond)
138- if c.ch.Clock != nil {130+ if clock != nil {
139- t := c.ch.Clock.NewTimer(d)131+ t := clock.NewTimer(d)
140- c.capC, c.capStop = t.C(), t.Stop132+ s.timerC, s.timerStop = t.C(), t.Stop
141 return133 return
142 }134 }
143 t := time.NewTimer(d)135 t := time.NewTimer(d)
144- c.capC, c.capStop = t.C, t.Stop136+ 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
145 }144 }
146 145
147-func (c *Core) stopCaptureTimer() {146+// captureExpire forgets the flashes that ended. With none it reads no clock.
148- if c.capStop != nil {147+func (c *Core) captureExpire() {
149- c.capStop()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
150 }160 }
151- c.capC, c.capStop = nil, nil161+ now := c.now()
162+ c.capt.expire(now)
163+ c.capt.armTimer(now, c.ch.Clock)
164+ return len(c.capt.flashes) != n
152 }165 }
153 166
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+
154 // captureIndicators is the marks of one output's scene; nil when there are179 // captureIndicators is the marks of one output's scene; nil when there are
155 // none. The slice is immutable once published and shared with the next scene180 // none. The slice is immutable once published and shared with the next scene
156 // of the screen while the marks are the same; it is cloned only when they181 // of the screen while the marks are the same; it is cloned only when they
157 // change, so a steady recording allocates nothing.182 // change, so a steady recording allocates nothing.
158 func (c *Core) captureIndicators(sc *screen) []ports.CaptureIndicator {183 func (c *Core) captureIndicators(sc *screen) []ports.CaptureIndicator {
159- if len(c.capSessions) == 0 && len(c.capFlashes) == 0 {184+ if c.capt.idle() {
160 sc.capMarks = nil185 sc.capMarks = nil
161 return nil186 return nil
162 }187 }
163- out := c.capMarks[:0]188+ out := c.capt.marks[:0]
164 add := func(t capTarget) {189 add := func(t capTarget) {
165 r, reason := c.capResolve(t)190 r, reason := c.capResolve(t)
166 if reason != ports.CaptureReasonNone || r.sc != sc {191 if reason != ports.CaptureReasonNone || r.sc != sc {
@@ -177,15 +202,15 @@ func (c *Core) captureIndicators(sc *screen) []ports.CaptureIndicator {
177 out = append(out, m)202 out = append(out, m)
178 }203 }
179 }204 }
180- for _, s := range c.capSessions {205+ for _, s := range c.capt.sessions {
181 if s.recording {206 if s.recording {
182 add(s.target())207 add(s.target())
183 }208 }
184 }209 }
185- for _, f := range c.capFlashes {210+ for _, f := range c.capt.flashes {
186 add(f.target)211 add(f.target)
187 }212 }
188- c.capMarks = out213+ c.capt.marks = out
189 switch {214 switch {
190 case len(out) == 0:215 case len(out) == 0:
191 sc.capMarks = nil216 sc.capMarks = nil
M internal/core/capindicator_test.go +9 -9
@@ -91,7 +91,7 @@ func TestCaptureIndicatorFlashLastsOneSecond(t *testing.T) {
91 if s := indicatorScene(t, c); s.CaptureIndicators != nil {91 if s := indicatorScene(t, c); s.CaptureIndicators != nil {
92 t.Fatalf("indicators %+v after the flash", s.CaptureIndicators)92 t.Fatalf("indicators %+v after the flash", s.CaptureIndicators)
93 }93 }
94- if c.capStop != nil || len(c.capFlashes) != 0 {94+ if c.capt.timerStop != nil || len(c.capt.flashes) != 0 {
95 t.Fatal("timer or flash left")95 t.Fatal("timer or flash left")
96 }96 }
97 }97 }
@@ -108,15 +108,15 @@ func TestCaptureIndicatorFlashClearedByProtectionAndWorksAfterUnlock(t *testing.
108 if !c.captureFrame(ports.CaptureFrameTaken{Output: "A"}) {108 if !c.captureFrame(ports.CaptureFrameTaken{Output: "A"}) {
109 t.Fatal("first frame shows nothing new")109 t.Fatal("first frame shows nothing new")
110 }110 }
111- if len(c.capFlashes) != 1 || c.capStop == nil {111+ if len(c.capt.flashes) != 1 || c.capt.timerStop == nil {
112- t.Fatalf("flash %d, timer armed %v", len(c.capFlashes), c.capStop != nil)112+ t.Fatalf("flash %d, timer armed %v", len(c.capt.flashes), c.capt.timerStop != nil)
113 }113 }
114 state = ports.SecurityState{Generation: 2, Protected: true}114 state = ports.SecurityState{Generation: 2, Protected: true}
115 if !c.syncSecurity() {115 if !c.syncSecurity() {
116 t.Fatal("protection not engaged")116 t.Fatal("protection not engaged")
117 }117 }
118- if len(c.capFlashes) != 0 || c.capStop != nil || c.capC != 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.capFlashes), c.capStop != nil)119+ t.Fatalf("flash %d, timer %v left under protection", len(c.capt.flashes), c.capt.timerStop != nil)
120 }120 }
121 if s := indicatorScene(t, c); s.CaptureIndicators != nil {121 if s := indicatorScene(t, c); s.CaptureIndicators != nil {
122 t.Fatalf("indicators %+v while protected", s.CaptureIndicators)122 t.Fatalf("indicators %+v while protected", s.CaptureIndicators)
@@ -137,7 +137,7 @@ func TestCaptureIndicatorFlashClearedByProtectionAndWorksAfterUnlock(t *testing.
137 if s := indicatorScene(t, c); len(s.CaptureIndicators) != 1 {137 if s := indicatorScene(t, c); len(s.CaptureIndicators) != 1 {
138 t.Fatalf("indicators %+v after the unlock", s.CaptureIndicators)138 t.Fatalf("indicators %+v after the unlock", s.CaptureIndicators)
139 }139 }
140- if len(ic.timers) != timers+1 || c.capStop == nil {140+ if len(ic.timers) != timers+1 || c.capt.timerStop == nil {
141 t.Fatalf("timer not re-armed after the unlock: %v", ic.timers)141 t.Fatalf("timer not re-armed after the unlock: %v", ic.timers)
142 }142 }
143 ic.now = ic.now.Add(ports.CaptureFlash)143 ic.now = ic.now.Add(ports.CaptureFlash)
@@ -158,8 +158,8 @@ func TestCaptureIndicatorRegionAndUnion(t *testing.T) {
158 // A target with nothing on screen marks nothing and stores nothing.158 // A target with nothing on screen marks nothing and stores nothing.
159 c.captureFrame(ports.CaptureFrameTaken{Output: "gone"})159 c.captureFrame(ports.CaptureFrameTaken{Output: "gone"})
160 c.captureFrame(ports.CaptureFrameTaken{Output: "A", Region: Rect{X: 900, Y: 900, W: 5, H: 5}})160 c.captureFrame(ports.CaptureFrameTaken{Output: "A", Region: Rect{X: 900, Y: 900, W: 5, H: 5}})
161- if len(c.capFlashes) != 2 {161+ if len(c.capt.flashes) != 2 {
162- t.Fatalf("flashes %+v", c.capFlashes)162+ t.Fatalf("flashes %+v", c.capt.flashes)
163 }163 }
164 }164 }
165 165
@@ -270,7 +270,7 @@ func TestCaptureIndicatorInflatesThinTargets(t *testing.T) {
270 {Rect{X: 100, Y: 50, W: 4, H: 4}, Rect{X: 100, Y: 50, W: 5, H: 5}},270 {Rect{X: 100, Y: 50, W: 4, H: 4}, Rect{X: 100, Y: 50, W: 5, H: 5}},
271 {Rect{X: 100, Y: 50, W: 5, H: 5}, Rect{X: 100, Y: 50, W: 5, H: 5}},271 {Rect{X: 100, Y: 50, W: 5, H: 5}, Rect{X: 100, Y: 50, W: 5, H: 5}},
272 } {272 } {
273- c.capFlashes = nil273+ c.capt.flashes = nil
274 c.captureFrame(ports.CaptureFrameTaken{Output: "A", Region: tc.region})274 c.captureFrame(ports.CaptureFrameTaken{Output: "A", Region: tc.region})
275 s := indicatorScene(t, c)275 s := indicatorScene(t, c)
276 if len(s.CaptureIndicators) != 1 || s.CaptureIndicators[0].Rect != tc.want {276 if len(s.CaptureIndicators) != 1 || s.CaptureIndicators[0].Rect != tc.want {
M internal/core/capture.go +62 -39
@@ -3,6 +3,7 @@ package core
3 import (3 import (
4 "context"4 "context"
5 "slices"5 "slices"
6+ "time"
6 7
7 "github.com/bnema/neferwl/internal/ports"8 "github.com/bnema/neferwl/internal/ports"
8 )9 )
@@ -21,6 +22,37 @@ import (
21 // With no session, no exclusion and no flash none of this allocates: the22 // With no session, no exclusion and no flash none of this allocates: the
22 // scenes carry no Capture and no CaptureIndicators.23 // scenes carry no Capture and no CaptureIndicators.
23 24
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+
24 // capTarget is what a capture covers, as asked.56 // capTarget is what a capture covers, as asked.
25 type capTarget struct {57 type capTarget struct {
26 output string58 output string
@@ -117,22 +149,13 @@ func (c *Core) capResolve(t capTarget) (capResolved, ports.CaptureReason) {
117 149
118 // captureOpen registers a session.150 // captureOpen registers a session.
119 func (c *Core) captureOpen(v ports.CaptureSessionOpen) {151 func (c *Core) captureOpen(v ports.CaptureSessionOpen) {
120- c.capSessions = append(c.capSessions, &capSession{open: v})152+ c.capt.sessions = append(c.capt.sessions, &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
130 }153 }
131 154
132 // captureClose forgets a session and its exclusion. Its flashes stay.155 // captureClose forgets a session and its exclusion. Its flashes stay.
133 func (c *Core) captureClose(id uint64) {156 func (c *Core) captureClose(id uint64) {
134- c.capSessions = slices.DeleteFunc(c.capSessions, func(s *capSession) bool { return s.open.ID == id })157+ c.capt.sessions = slices.DeleteFunc(c.capt.sessions, func(s *capSession) bool { return s.open.ID == id })
135- if c.capExcl != nil && c.capExcl.session == id {158+ if c.capt.excl != nil && c.capt.excl.session == id {
136 c.dropExclusion()159 c.dropExclusion()
137 }160 }
138 }161 }
@@ -140,24 +163,24 @@ func (c *Core) captureClose(id uint64) {
140 // captureExclusionBegin starts the exclusion of a session. Wayland refused163 // captureExclusionBegin starts the exclusion of a session. Wayland refused
141 // it already when another one was live.164 // it already when another one was live.
142 func (c *Core) captureExclusionBegin(v ports.CaptureExclusionBegin) {165 func (c *Core) captureExclusionBegin(v ports.CaptureExclusionBegin) {
143- if c.capSession(v.Session) == nil {166+ if c.capt.session(v.Session) == nil {
144 return167 return
145 }168 }
146- if old := c.capExcl; old != nil {169+ if old := c.capt.excl; old != nil {
147 if !old.ended {170 if !old.ended {
148 return171 return
149 }172 }
150 // An ended exclusion still excludes its listed HUD layers: a new one173 // An ended exclusion still excludes its listed HUD layers: a new one
151 // inherits them, so they are left out until no screen lists them.174 // 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}
153 return176 return
154 }177 }
155- c.capExcl = &capExclusion{session: v.Session}178+ c.capt.excl = &capExclusion{session: v.Session}
156 }179 }
157 180
158 // captureExclusionLayer attaches or detaches a layer surface.181 // captureExclusionLayer attaches or detaches a layer surface.
159 func (c *Core) captureExclusionLayer(v ports.CaptureExclusionLayer) {182 func (c *Core) captureExclusionLayer(v ports.CaptureExclusionLayer) {
160- e := c.capExcl183+ e := c.capt.excl
161 if e == nil || e.ended || e.session != v.Session {184 if e == nil || e.ended || e.session != v.Session {
162 return185 return
163 }186 }
@@ -180,7 +203,7 @@ func (c *Core) captureExclusionLayer(v ports.CaptureExclusionLayer) {
180 // layers that a screen still lists stay excluded until none does (the203 // layers that a screen still lists stay excluded until none does (the
181 // retained set): the recorder's frames never show a HUD that just ended.204 // retained set): the recorder's frames never show a HUD that just ended.
182 func (c *Core) captureExclusionEnd(session uint64) {205 func (c *Core) captureExclusionEnd(session uint64) {
183- e := c.capExcl206+ e := c.capt.excl
184 if e == nil || e.ended || e.session != session {207 if e == nil || e.ended || e.session != session {
185 return208 return
186 }209 }
@@ -191,7 +214,7 @@ func (c *Core) captureExclusionEnd(session uint64) {
191 }214 }
192 e.layers, e.keep, e.ended = nil, nil, true215 e.layers, e.keep, e.ended = nil, nil, true
193 c.pruneRetained()216 c.pruneRetained()
194- if c.capExcl != nil {217+ if c.capt.excl != nil {
195 // Attached layers no longer stay over a fullscreen window.218 // Attached layers no longer stay over a fullscreen window.
196 for _, sc := range c.screens {219 for _, sc := range c.screens {
197 sc.capture = nil220 sc.capture = nil
@@ -202,16 +225,16 @@ func (c *Core) captureExclusionEnd(session uint64) {
202 // dropCaptureSessions forgets every session and the exclusion: the session is225 // dropCaptureSessions forgets every session and the exclusion: the session is
203 // protected, no capture is served, and wayland has stopped them all.226 // protected, no capture is served, and wayland has stopped them all.
204 func (c *Core) dropCaptureSessions() {227 func (c *Core) dropCaptureSessions() {
205- c.capSessions = nil228+ c.capt.sessions = nil
206 // No capture indicator outlives the lock: nothing is drawn for a capture229 // No capture indicator outlives the lock: nothing is drawn for a capture
207 // that can no longer happen, and none is left to show after the unlock.230 // that can no longer happen, and none is left to show after the unlock.
208- c.capFlashes = nil231+ c.capt.flashes = nil
209 c.stopCaptureTimer()232 c.stopCaptureTimer()
210 c.dropExclusion()233 c.dropExclusion()
211 }234 }
212 235
213 func (c *Core) dropExclusion() {236 func (c *Core) dropExclusion() {
214- c.capExcl = nil237+ c.capt.excl = nil
215 c.configures.cw.reset()238 c.configures.cw.reset()
216 for _, sc := range c.screens {239 for _, sc := range c.screens {
217 sc.capture = nil240 sc.capture = nil
@@ -233,7 +256,7 @@ func (c *Core) layerListed(id WindowID) bool {
233 // pruneRetained forgets the detached layers no screen lists any more, and256 // pruneRetained forgets the detached layers no screen lists any more, and
234 // the ended exclusion with the last of them.257 // the ended exclusion with the last of them.
235 func (c *Core) pruneRetained() {258 func (c *Core) pruneRetained() {
236- e := c.capExcl259+ e := c.capt.excl
237 if e == nil {260 if e == nil {
238 return261 return
239 }262 }
@@ -264,7 +287,7 @@ func (c *Core) exclusionKeep(e *capExclusion) map[WindowID]bool {
264 // capture HUD never takes or steals the keyboard. Run whenever the layer287 // capture HUD never takes or steals the keyboard. Run whenever the layer
265 // list or the attached set changes.288 // list or the attached set changes.
266 func (c *Core) clampCaptureKeyboard() {289 func (c *Core) clampCaptureKeyboard() {
267- e := c.capExcl290+ e := c.capt.excl
268 if e == nil {291 if e == nil {
269 return292 return
270 }293 }
@@ -298,18 +321,18 @@ func (c *Core) captureExcluded(e *capExclusion) []WindowID {
298 // there is no session and no exclusion. A terminal state321 // there is no session and no exclusion. A terminal state
299 // is sent once and the session is forgotten.322 // is sent once and the session is forgotten.
300 func (c *Core) captureEvaluate(ctx context.Context) (*capView, error) {323 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 {
302 return nil, nil325 return nil, nil
303 }326 }
304 c.pruneRetained()327 c.pruneRetained()
305 // The scratch is the view and the states of this evaluation; nothing the328 // The scratch is the view and the states of this evaluation; nothing the
306 // scenes or the commands keep points into it.329 // scenes or the commands keep points into it.
307- v := &c.capScratch330+ v := &c.capt.scratch
308 *v = capView{}331 *v = capView{}
309 // Decide each session. The live ones are kept in place.332 // Decide each session. The live ones are kept in place.
310- states := c.capStates[:0]333+ states := c.capt.states[:0]
311- alive := c.capSessions[:0]334+ alive := c.capt.sessions[:0]
312- for i, s := range c.capSessions {335+ for i, s := range c.capt.sessions {
313 st := ports.CaptureSessionState{ID: s.open.ID}336 st := ports.CaptureSessionState{ID: s.open.ID}
314 r, reason := c.capResolve(s.target())337 r, reason := c.capResolve(s.target())
315 if reason == ports.CaptureReasonNone {338 if reason == ports.CaptureReasonNone {
@@ -324,21 +347,21 @@ func (c *Core) captureEvaluate(ctx context.Context) (*capView, error) {
324 states = append(states, st)347 states = append(states, st)
325 if reason != ports.CaptureReasonNone {348 if reason != ports.CaptureReasonNone {
326 if err := c.command(ctx, st); err != nil {349 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:]...)
328 return nil, err351 return nil, err
329 }352 }
330- if c.capExcl != nil && c.capExcl.session == s.open.ID {353+ if c.capt.excl != nil && c.capt.excl.session == s.open.ID {
331 c.dropExclusion()354 c.dropExclusion()
332 }355 }
333 continue356 continue
334 }357 }
335 alive = append(alive, s)358 alive = append(alive, s)
336 }359 }
337- clear(c.capSessions[len(alive):])360+ clear(c.capt.sessions[len(alive):])
338- c.capSessions = alive361+ c.capt.sessions = alive
339- c.capStates = states362+ c.capt.states = states
340 // Exclusion.363 // Exclusion.
341- if e := c.capExcl; e != nil {364+ if e := c.capt.excl; e != nil {
342 ex := c.captureExcluded(e)365 ex := c.captureExcluded(e)
343 if len(ex) > ports.MaxCaptureExcluded {366 if len(ex) > ports.MaxCaptureExcluded {
344 // Fail closed: the session cannot leave them all out.367 // Fail closed: the session cannot leave them all out.
@@ -346,7 +369,7 @@ func (c *Core) captureEvaluate(ctx context.Context) (*capView, error) {
346 if err := c.command(ctx, st); err != nil {369 if err := c.command(ctx, st); err != nil {
347 return nil, err370 return nil, err
348 }371 }
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 })
350 c.dropExclusion()373 c.dropExclusion()
351 } else {374 } else {
352 v.exclusion = e375 v.exclusion = e
@@ -370,14 +393,14 @@ func (c *Core) captureEvaluate(ctx context.Context) (*capView, error) {
370 }393 }
371 }394 }
372 // Tell wayland what changed.395 // Tell wayland what changed.
373- for _, s := range c.capSessions {396+ for _, s := range c.capt.sessions {
374 var st ports.CaptureSessionState397 var st ports.CaptureSessionState
375 for _, x := range states {398 for _, x := range states {
376 if x.ID == s.open.ID {399 if x.ID == s.open.ID {
377 st = x400 st = x
378 }401 }
379 }402 }
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 {
381 st.Exclusion, st.Revision = true, e.rev404 st.Exclusion, st.Revision = true, e.rev
382 // The list is sent, read by another goroutine: shared, never405 // The list is sent, read by another goroutine: shared, never
383 // rebuilt, while the attached set is the same.406 // rebuilt, while the attached set is the same.
@@ -407,7 +430,7 @@ func sameCaptureState(a, b ports.CaptureSessionState) bool {
407 // captureSceneFor is the capture state of one screen's scene; nil when430 // captureSceneFor is the capture state of one screen's scene; nil when
408 // there is no session. Shown is the workspace drawn as itself on screen.431 // there is no session. Shown is the workspace drawn as itself on screen.
409 func (c *Core) captureSceneFor(sc *screen, v *capView) *ports.SceneCapture {432 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 {
411 return nil434 return nil
412 }435 }
413 want := ports.SceneCapture{}436 want := ports.SceneCapture{}
M internal/core/capture_internal_test.go +6 -6
@@ -19,7 +19,7 @@ func TestExclusionKeepIsSharedAndBuiltOncePerChange(t *testing.T) {
19 c.captureExclusionBegin(ports.CaptureExclusionBegin{Session: 1})19 c.captureExclusionBegin(ports.CaptureExclusionBegin{Session: 1})
20 c.captureExclusionLayer(ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true})20 c.captureExclusionLayer(ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true})
21 c.captureExclusionLayer(ports.CaptureExclusionLayer{Session: 1, Layer: 11, Attached: true})21 c.captureExclusionLayer(ports.CaptureExclusionLayer{Session: 1, Layer: 11, Attached: true})
22- e := c.capExcl22+ e := c.capt.excl
23 keep := c.exclusionKeep(e)23 keep := c.exclusionKeep(e)
24 if len(keep) != 2 || !keep[10] || !keep[11] {24 if len(keep) != 2 || !keep[10] || !keep[11] {
25 t.Fatalf("keep %v", keep)25 t.Fatalf("keep %v", keep)
@@ -43,17 +43,17 @@ func TestExclusionKeepIsSharedAndBuiltOncePerChange(t *testing.T) {
43 // An ended exclusion keeps nothing over a fullscreen window.43 // An ended exclusion keeps nothing over a fullscreen window.
44 c.screens[0].layers = []ports.LayerSurface{{ID: 10}}44 c.screens[0].layers = []ports.LayerSurface{{ID: 10}}
45 c.captureExclusionEnd(1)45 c.captureExclusionEnd(1)
46- if c.capExcl == nil || !c.capExcl.ended || !slices.Equal(c.capExcl.retained, []WindowID{10}) {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.capExcl)47+ t.Fatalf("ended exclusion %+v", c.capt.excl)
48 }48 }
49- if k := c.exclusionKeep(c.capExcl); k != nil {49+ if k := c.exclusionKeep(c.capt.excl); k != nil {
50 t.Fatalf("keep of an ended exclusion %v", k)50 t.Fatalf("keep of an ended exclusion %v", k)
51 }51 }
52 // With no layer listed any more it is gone.52 // With no layer listed any more it is gone.
53 c.screens[0].layers = nil53 c.screens[0].layers = nil
54 c.pruneRetained()54 c.pruneRetained()
55- if c.capExcl != nil {55+ if c.capt.excl != nil {
56- t.Fatalf("exclusion survived its last listed layer: %+v", c.capExcl)56+ t.Fatalf("exclusion survived its last listed layer: %+v", c.capt.excl)
57 }57 }
58 }58 }
59 59
M internal/core/capture_workspace_test.go +4 -4
@@ -190,7 +190,7 @@ func TestCaptureWorkspaceReusesStorage(t *testing.T) {
190 func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) {190 func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) {
191 c, ws, _ := hiddenCaptureCommands(t)191 c, ws, _ := hiddenCaptureCommands(t)
192 c.configures.cw.reset()192 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}}}
194 c.popups[10] = &popupState{id: 10, parent: 1, rect: Rect{X: 3, Y: 4, W: 10, H: 8}, mapped: true}194 c.popups[10] = &popupState{id: 10, parent: 1, rect: Rect{X: 3, Y: 4, W: 10, H: 8}, mapped: true}
195 c.popupOrder = append(c.popupOrder, 10)195 c.popupOrder = append(c.popupOrder, 10)
196 ws.motion = newMotion(viewSpring(10, 0), time.Time{})196 ws.motion = newMotion(viewSpring(10, 0), time.Time{})
@@ -198,7 +198,7 @@ func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) {
198 if err := c.publish(ctx); err != nil {198 if err := c.publish(ctx); err != nil {
199 t.Fatal(err)199 t.Fatal(err)
200 }200 }
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() {
202 t.Fatalf("session state %+v", v)202 t.Fatalf("session state %+v", v)
203 }203 }
204 if !c.configures.cw.active() || c.popups[10] == nil {204 if !c.configures.cw.active() || c.popups[10] == nil {
@@ -212,7 +212,7 @@ func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) {
212 t.Fatal(err)212 t.Fatal(err)
213 }213 }
214 if !c.configures.cw.active() {214 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)
216 }216 }
217 // The session ends: the captured workspace is forgotten and its popup goes.217 // The session ends: the captured workspace is forgotten and its popup goes.
218 c.captureClose(1)218 c.captureClose(1)
@@ -250,7 +250,7 @@ func TestCapturedWorkspaceInOverviewKeepsRealSize(t *testing.T) {
250 before[p.ID] = v250 before[p.ID] = v
251 }251 }
252 m.ToggleOverview()252 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}}}
254 if err := c.publish(ctx); err != nil {254 if err := c.publish(ctx); err != nil {
255 t.Fatal(err)255 t.Fatal(err)
256 }256 }
M internal/core/core.go +37 -27
@@ -121,18 +121,8 @@ type Core struct {
121 // last position sent in the pointer's window.121 // last position sent in the pointer's window.
122 motionMsec uint32122 motionMsec uint32
123 pointerAt [2]float64123 pointerAt [2]float64
124- // capSessions and capExcl are the capture state (capture.go).124+ // capt is the capture state (capture.go, capindicator.go).
125- capSessions []*capSession125+ capt captureState
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
136 }126 }
137 127
138 func keyName(s string) string {128 func keyName(s string) string {
@@ -489,14 +479,8 @@ func (c *Core) publish(ctx context.Context) error {
489 return err479 return err
490 }480 }
491 // Clients learn outputs and scales before the configures sized for them.481 // 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) {482+ if err := c.syncOutputs(ctx); err != nil {
493- if err := c.command(ctx, v); err != nil {483+ return err
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
500 }484 }
501 // Output layout must be known before an automatic terminal can map.485 // Output layout must be known before an automatic terminal can map.
502 if err := c.spawnEmpty(ctx); err != nil {486 if err := c.spawnEmpty(ctx); err != nil {
@@ -582,11 +566,8 @@ func (c *Core) publish(ctx context.Context) error {
582 }566 }
583 }567 }
584 }568 }
585- if focus != c.keyboard.sent {569+ if err := c.syncFocus(ctx, focus); err != nil {
586- if err := c.command(ctx, ports.FocusWindow{ID: focus}); err != nil {570+ return err
587- return err
588- }
589- c.keyboard.sent = focus
590 }571 }
591 if err := c.updateInhibit(ctx); err != nil {572 if err := c.updateInhibit(ctx); err != nil {
592 return err573 return err
@@ -785,6 +766,35 @@ func (c *Core) warpPointer(ctx context.Context, v ports.PointerWarp) error {
785 return c.rehit(ctx)766 return c.rehit(ctx)
786 }767 }
787 768
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+
788 // latest replaces any unread value: only the owner sends and drains;798 // latest replaces any unread value: only the owner sends and drains;
789 // consumers only receive.799 // consumers only receive.
790 func latest[T any](ch chan T, v T) {800 func latest[T any](ch chan T, v T) {
@@ -1062,8 +1072,8 @@ func (c *Core) Run(ctx context.Context) error {
1062 return nil1072 return nil
1063 }1073 }
1064 continue1074 continue
1065- case <-c.capC:1075+ case <-c.capt.timerC:
1066- c.capC, c.capStop = nil, nil1076+ c.capt.timerC, c.capt.timerStop = nil, nil
1067 if c.captureFlashTick() && c.publish(ctx) != nil {1077 if c.captureFlashTick() && c.publish(ctx) != nil {
1068 return nil1078 return nil
1069 }1079 }
M internal/core/security.go +4 -14
@@ -170,15 +170,8 @@ func (c *Core) lockHit(x, y float64) (WindowID, float64, float64) {
170 }170 }
171 171
172 func (c *Core) publishProtected(ctx context.Context) error {172 func (c *Core) publishProtected(ctx context.Context) error {
173- v := ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()}173+ if err := c.syncOutputs(ctx); err != nil {
174- if !sameOutputs(v, c.sentOutputs) {174+ return err
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
182 }175 }
183 focus := c.lockKeyboardFocus()176 focus := c.lockKeyboardFocus()
184 scenes := make([]ports.Scene, 0, len(c.screens))177 scenes := make([]ports.Scene, 0, len(c.screens))
@@ -191,11 +184,8 @@ func (c *Core) publishProtected(ctx context.Context) error {
191 }184 }
192 scenes = append(scenes, scene)185 scenes = append(scenes, scene)
193 }186 }
194- if c.keyboard.sent != focus {187+ if err := c.syncFocus(ctx, focus); err != nil {
195- if err := c.command(ctx, ports.FocusWindow{ID: focus}); err != nil {188+ return err
196- return err
197- }
198- c.keyboard.sent = focus
199 }189 }
200 // Surface updates may invalidate the role under a stationary pointer.190 // Surface updates may invalidate the role under a stationary pointer.
201 if !c.validLockID(c.pointer) {191 if !c.validLockID(c.pointer) {
M internal/core/security_internal_test.go +2 -2
@@ -17,7 +17,7 @@ func TestProtectedDropsPrivateOffscreenCaptureAndDesktopEffects(t *testing.T) {
17 c.ch.Security = gate17 c.ch.Security = gate
18 c.captureOpen(ports.CaptureSessionOpen{ID: 7, Workspace: ws.ID})18 c.captureOpen(ports.CaptureSessionOpen{ID: 7, Workspace: ws.ID})
19 c.captureExclusionBegin(ports.CaptureExclusionBegin{Session: 7})19 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)}}
21 c.screens[0].layers = []ports.LayerSurface{{ID: 50, Layer: ports.LayerOverlay, Keyboard: 1}}21 c.screens[0].layers = []ports.LayerSurface{{ID: 50, Layer: ports.LayerOverlay, Keyboard: 1}}
22 c.pressed["password"] = true22 c.pressed["password"] = true
23 c.buttons[0x110] = true23 c.buttons[0x110] = true
@@ -33,7 +33,7 @@ func TestProtectedDropsPrivateOffscreenCaptureAndDesktopEffects(t *testing.T) {
33 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" {33 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" {
34 t.Fatalf("unsafe protected scene %+v", s)34 t.Fatalf("unsafe protected scene %+v", s)
35 }35 }
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 {
37 t.Fatal("transition kept desktop input/capture state")37 t.Fatal("transition kept desktop input/capture state")
38 }38 }
39 // Logical dimensions must match exactly, including after output changes.39 // 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