detached cae3aeb

Merge pull request 'refactor: architecture cleanup (audit findings #1–#7)' (#18) from refactor/architecture-cleanup into main

Reviewed-on: https://git.bnema.dev/bnema/neferwl/pulls/18
commit
cae3aeb166dfc7e95b332e91b8f026eb65ed9429
author
bnema · · 2026-10-08 06:53 UTC
merge of
b727aa4 + e0bbcba · diff against the first parent
signature
unsigned
66 files · +2119 -1458
M .mockery.yml +5 -0
@@ -91,6 +91,11 @@ packages:
9191 dir: internal/app
9292 pkgname: app
9393 filename: seatdisable_mock_test.go
94+ leaseInventoryCard:
95+ config:
96+ dir: internal/app
97+ pkgname: app
98+ filename: leaseinventory_mock_test.go
9499 github.com/bnema/neferwl/internal/adapters/statefile:
95100 interfaces:
96101 procTree:
M Makefile +11 -2
@@ -1,4 +1,4 @@
1-.PHONY: build test vet race mocks mocks-check spv-check fakes-check log-check arch check perf-check color-check bin tty logs pkg install
1+.PHONY: build test vet race mocks mocks-check spv-check fakes-check log-check arch adapter-imports check perf-check color-check bin tty logs pkg install
22
33 # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net.
44 TTY_TIMEOUT ?= 0
@@ -56,8 +56,17 @@ LOGCOMPONENT := "component"|FieldComponent
5656 log-check:
5757 @rc=0; grep -rnE '$(LOGCOMPONENT)' --include='*.go' --exclude='*_test.go' --exclude-dir=logging internal cmd || rc=$$?; \
5858 [ $$rc -eq 1 ] || { [ $$rc -eq 0 ] && echo 'component log field: derive a logger with logging.For instead (see AGENTS.md)' >&2; exit 1; }
59-arch:
59+arch: adapter-imports
6060 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
61+# Adapters stay independent: production code may import only the shared
62+# adapter libraries below. outputkit holds what both output backends (drm,
63+# headless) run; the others are leaf helpers.
64+ADAPTER_LIBS := outputkit/capture|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|sessionsecurity|syncfile|workspaceid|xkb
65+adapter-imports:
66+ @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \
67+ bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \
68+ | while read -r from to; do case $$to in $$from/*) ;; *) echo "$$from -> $$to";; esac; done); \
69+ [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
6170 # Fast, mandatory guards for tiled content publications and SHM copies.
6271 perf-check:
6372 CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
M internal/adapters/drm/capture_ownership_test.go +1 -1
@@ -6,7 +6,7 @@ import (
66 "os"
77 "testing"
88
9- "github.com/bnema/neferwl/internal/adapters/capture"
9+ "github.com/bnema/neferwl/internal/adapters/outputkit/capture"
1010 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
1111 "github.com/bnema/neferwl/internal/ports"
1212 "github.com/stretchr/testify/mock"
M internal/adapters/drm/capture_test.go +1 -1
@@ -11,7 +11,7 @@ import (
1111 "testing/synctest"
1212 "time"
1313
14- "github.com/bnema/neferwl/internal/adapters/capture"
14+ "github.com/bnema/neferwl/internal/adapters/outputkit/capture"
1515 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
1616 "github.com/bnema/neferwl/internal/ports"
1717 "github.com/stretchr/testify/mock"
M internal/adapters/drm/frame_submit.go +1 -1
@@ -5,7 +5,7 @@ import (
55 "errors"
66 "time"
77
8- "github.com/bnema/neferwl/internal/adapters/capture"
8+ "github.com/bnema/neferwl/internal/adapters/outputkit/capture"
99 "github.com/bnema/neferwl/internal/ports"
1010 )
1111
M internal/adapters/drm/leaving_test.go +1 -1
@@ -3,7 +3,7 @@ package drm
33 import (
44 "testing"
55
6- "github.com/bnema/neferwl/internal/adapters/surfaces"
6+ "github.com/bnema/neferwl/internal/adapters/outputkit/surfaces"
77 "github.com/bnema/neferwl/internal/ports"
88 )
99
M internal/adapters/drm/output.go +20 -51
@@ -9,10 +9,10 @@ import (
99 "time"
1010 "unsafe"
1111
12- "github.com/bnema/neferwl/internal/adapters/capture"
1312 "github.com/bnema/neferwl/internal/adapters/clock"
14- "github.com/bnema/neferwl/internal/adapters/presented"
15- "github.com/bnema/neferwl/internal/adapters/surfaces"
13+ "github.com/bnema/neferwl/internal/adapters/outputkit/capture"
14+ "github.com/bnema/neferwl/internal/adapters/outputkit/presented"
15+ "github.com/bnema/neferwl/internal/adapters/outputkit/surfaces"
1616
1717 "github.com/bnema/neferwl/internal/ports"
1818 "github.com/bnema/zerowrap"
@@ -1044,9 +1044,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
10441044 cursorScale := -1.0 // not loaded yet
10451045 var cursorTransform ports.BufferTransform
10461046 frame := 0
1047- var requestStorage [capture.MaxRequests]ports.CaptureRequest
1048- requests := requestStorage[:0]
1047+ var requests capture.Requests
10491048 ctx, cancelCaptures := context.WithCancel(ctx)
1049+ requests.Init(ctx, captured)
10501050 pipeline := capture.NewPipeline(ctx, captured)
10511051 pipeline.Security = o.Security
10521052 if o.NewCaptureRenderer != nil {
@@ -1056,19 +1056,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
10561056 defer func() {
10571057 cancelCaptures()
10581058 pipeline.Close(r)
1059- for _, q := range requests {
1060- if q.ID != 0 || q.Dst.File != nil {
1061- capture.Fail(ctx, q, fmt.Errorf("output stopped"), captured)
1062- }
1063- }
1064- for {
1065- select {
1066- case q := <-captures:
1067- capture.Fail(ctx, q, fmt.Errorf("output stopped"), captured)
1068- default:
1069- return
1070- }
1071- }
1059+ requests.Stop(captures)
10721060 }()
10731061 clk := o.clock
10741062 if clk == nil {
@@ -1119,12 +1107,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
11191107 o.cursor.image, o.cursor.later = false, nil
11201108 }
11211109 }
1122- if o.protected && len(requests) > 0 {
1123- for _, q := range requests {
1124- capture.Fail(ctx, q, fmt.Errorf("session protected"), captured)
1125- }
1126- clear(requests)
1127- requests = requestStorage[:0]
1110+ if o.protected && requests.Len() > 0 {
1111+ requests.FailPending(capture.ErrSessionProtected)
11281112 }
11291113 if o.protected && enabled && !o.frame.pendingCommit() && !o.securityPrepared && !o.protectBackoffActive() {
11301114 if err := o.prepareSecurity(ctx, r); err != nil {
@@ -1200,9 +1184,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
12001184 gapTimer.Stop()
12011185 }
12021186 var holdDue <-chan time.Time
1203- if len(requests) > 0 {
1187+ if requests.Len() > 0 {
12041188 var wait time.Duration
1205- requests, wait = pipeline.Expire(scene, requests, time.Now())
1189+ requests.List, wait = pipeline.Expire(scene, requests.List, time.Now())
12061190 if wait > 0 {
12071191 holdTimer.Reset(wait)
12081192 holdDue = holdTimer.C
@@ -1271,17 +1255,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
12711255 continue
12721256 case q := <-captures:
12731257 o.observeSecurity()
1258+ var refuse error
12741259 if o.protected || !enabled || o.off || o.wantOff {
1275- capture.Fail(ctx, q, fmt.Errorf("output off"), captured)
1276- } else if len(requests) == cap(requests) {
1277- capture.Fail(ctx, q, fmt.Errorf("output capture batch full"), captured)
1278- } else {
1279- q.Since = time.Now()
1280- requests = append(requests, q)
1281- // Its indicator may already be on screen; else it waits for the
1282- // scene that shows it.
1283- dirty = dirty || haveScene && capture.IndicatorShown(scene, q)
1260+ refuse = capture.ErrOutputOff
12841261 }
1262+ dirty = requests.Admit(q, refuse, scene, haveScene) || dirty
12851263 case ack := <-o.seatDisable:
12861264 o.bypassForSeatDisable()
12871265 // The seat is about to be disabled: no frame may select the
@@ -1421,12 +1399,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
14211399 if o.protected && !o.securityPrepared {
14221400 continue
14231401 }
1424- if len(requests) > 0 && (o.protected || !enabled || o.off || o.wantOff) {
1425- for _, q := range requests {
1426- capture.Fail(ctx, q, fmt.Errorf("output off"), captured)
1427- }
1428- clear(requests)
1429- requests = requestStorage[:0]
1402+ if requests.Len() > 0 && (o.protected || !enabled || o.off || o.wantOff) {
1403+ requests.FailPending(capture.ErrOutputOff)
14301404 }
14311405 if !enabled || o.frame.pendingCommit() {
14321406 continue
@@ -1476,20 +1450,15 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
14761450 continue
14771451 }
14781452 start := time.Now()
1479- direct, err := o.submitFrame(ctx, r, scene, drawn, seen, requests, pipeline)
1453+ direct, err := o.submitFrame(ctx, r, scene, drawn, seen, requests.List, pipeline)
14801454 var fatal renderError
14811455 if errors.As(err, &fatal) || errors.Is(err, errSecurityScene) {
1482- // A failed render or epoch rejection did not hand these requests to the worker.
1483- for _, q := range requests {
1484- if capture.Handed(q) {
1485- continue // already owned by the worker, or answered
1486- }
1487- capture.Fail(ctx, q, err, captured)
1488- }
1489- clear(requests) // held ones included: their frame is gone
1456+ // A failed render or epoch rejection did not hand these requests
1457+ // to the worker; held ones go too: their frame is gone.
1458+ requests.FailPending(err)
14901459 }
14911460 // Requests still waiting for their indicator stay; the rest is gone.
1492- requests = capture.Waiting(requests, scene)
1461+ requests.List = capture.Waiting(requests.List, scene)
14931462 if errors.As(err, &fatal) {
14941463 return err
14951464 }
M internal/adapters/drm/output_test.go +1 -1
@@ -14,7 +14,7 @@ import (
1414 "testing/synctest"
1515 "time"
1616
17- "github.com/bnema/neferwl/internal/adapters/presented"
17+ "github.com/bnema/neferwl/internal/adapters/outputkit/presented"
1818 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
1919 "github.com/bnema/neferwl/internal/ports"
2020 "github.com/bnema/zerowrap"
M internal/adapters/drm/report.go +4 -8
@@ -3,6 +3,7 @@ package drm
33 import (
44 "maps"
55
6+ "github.com/bnema/neferwl/internal/adapters/outputkit/surfaces"
67 "github.com/bnema/neferwl/internal/ports"
78 )
89
@@ -28,14 +29,9 @@ func (o *Output) shownBy(s ports.Scene, seen map[ports.WindowID]uint64) map[port
2829 if o.showsScratch == nil {
2930 o.showsScratch = make(map[ports.WindowID]uint64)
3031 }
31- clear(o.showsScratch)
32- for id, seq := range seen {
33- // A closed window drawn from its kept content is not shown to its
34- // client: the empty content has no frame to present.
35- if s.Shows(id) && (o.kept == nil || !o.kept(id)) {
36- o.showsScratch[id] = seq
37- }
38- }
32+ // A closed window drawn from its kept content is not shown to its
33+ // client: the empty content has no frame to present.
34+ surfaces.FillShown(o.showsScratch, seen, s, o.kept)
3935 if !maps.Equal(o.showsSnapshot, o.showsScratch) {
4036 o.showsSnapshot = maps.Clone(o.showsScratch)
4137 }
M internal/adapters/drm/security_capture_test.go +1 -1
@@ -7,7 +7,7 @@ import (
77 "sync/atomic"
88 "testing"
99
10- "github.com/bnema/neferwl/internal/adapters/capture"
10+ "github.com/bnema/neferwl/internal/adapters/outputkit/capture"
1111 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
1212 "github.com/bnema/neferwl/internal/ports"
1313 "github.com/stretchr/testify/mock"
M internal/adapters/headless/capture_bounds_test.go +1 -1
@@ -7,7 +7,7 @@ import (
77 "testing"
88 "time"
99
10- "github.com/bnema/neferwl/internal/adapters/capture"
10+ "github.com/bnema/neferwl/internal/adapters/outputkit/capture"
1111 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
1212 "github.com/bnema/neferwl/internal/ports"
1313 "github.com/stretchr/testify/mock"
M internal/adapters/headless/capture_test.go +1 -1
@@ -10,7 +10,7 @@ import (
1010 "testing"
1111 "time"
1212
13- "github.com/bnema/neferwl/internal/adapters/capture"
13+ "github.com/bnema/neferwl/internal/adapters/outputkit/capture"
1414 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
1515 "github.com/bnema/neferwl/internal/ports"
1616 "github.com/stretchr/testify/mock"
M internal/adapters/headless/leaving_test.go +1 -1
@@ -3,7 +3,7 @@ package headless
33 import (
44 "testing"
55
6- "github.com/bnema/neferwl/internal/adapters/surfaces"
6+ "github.com/bnema/neferwl/internal/adapters/outputkit/surfaces"
77 "github.com/bnema/neferwl/internal/ports"
88 )
99
M internal/adapters/headless/output.go +30 -63
@@ -2,7 +2,6 @@ package headless
22
33 import (
44 "context"
5- "errors"
65 "fmt"
76 "image"
87 "image/png"
@@ -12,10 +11,10 @@ import (
1211
1312 "golang.org/x/sys/unix"
1413
15- "github.com/bnema/neferwl/internal/adapters/capture"
1614 "github.com/bnema/neferwl/internal/adapters/clock"
17- "github.com/bnema/neferwl/internal/adapters/presented"
18- "github.com/bnema/neferwl/internal/adapters/surfaces"
15+ "github.com/bnema/neferwl/internal/adapters/outputkit/capture"
16+ "github.com/bnema/neferwl/internal/adapters/outputkit/presented"
17+ "github.com/bnema/neferwl/internal/adapters/outputkit/surfaces"
1918 "github.com/bnema/neferwl/internal/adapters/syncfile"
2019 "github.com/bnema/neferwl/internal/ports"
2120 "github.com/bnema/zerowrap"
@@ -93,9 +92,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
9392 var scene ports.Scene
9493 haveScene, dirty := false, false
9594 frame := 0
96- var requestStorage [capture.MaxRequests]ports.CaptureRequest
97- requests := requestStorage[:0]
95+ var requests capture.Requests
9896 ctx, cancelCaptures := context.WithCancel(ctx)
97+ requests.Init(ctx, opts.Captured)
9998 pipeline := capture.NewPipeline(ctx, opts.Captured)
10099 pipeline.Security = opts.Security
101100 if opts.NewCaptureRenderer != nil {
@@ -106,23 +105,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
106105 pipeline.EndGate(capture.ErrFrameNotPresented)
107106 cancelCaptures()
108107 pipeline.Close(r)
109- for _, q := range requests {
110- if capture.Handed(q) {
111- continue // already answered, or owned by a worker
112- }
113- capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured)
114- }
115- for {
116- select {
117- case q, ok := <-incoming:
118- if !ok {
119- return
120- }
121- capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured)
122- default:
123- return
124- }
125- }
108+ requests.Stop(incoming)
126109 }()
127110 var want ports.CursorChange
128111 cursorScale := -1.0 // not loaded yet
@@ -132,16 +115,10 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
132115 var reports presented.Queue
133116 var security ports.SecurityState
134117 securityKnown := false
135- failRequests := func(reason string) {
118+ failRequests := func(reason error) {
136119 // Whatever this frame had handed to a worker is failed with it.
137- pipeline.EndGate(capture.GateVerdict(errors.New(reason)))
138- for _, q := range requests {
139- if !capture.Handed(q) {
140- capture.Fail(ctx, q, fmt.Errorf("%s", reason), opts.Captured)
141- }
142- }
143- clear(requests)
144- requests = requestStorage[:0]
120+ pipeline.EndGate(capture.GateVerdict(reason))
121+ requests.FailPending(reason)
145122 }
146123 // A transition is its own frame, never a locker-buffer-dependent frame.
147124 // Keep its epoch through the fence wait and report only actual completion.
@@ -194,7 +171,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
194171 }
195172 // Capture reply backpressure must not postpone the first black
196173 // completion/proof. Capture drainage is a separate parent barrier.
197- failRequests("session protected")
174+ failRequests(capture.ErrSessionProtected)
198175 }
199176 }
200177 acceptScene := func(s ports.Scene) {
@@ -245,9 +222,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
245222 // Requests wait for a scene that shows their indicator, at most
246223 // capture.HoldFor (see capture.Hold).
247224 var holdDue <-chan time.Time
248- if len(requests) > 0 {
225+ if requests.Len() > 0 {
249226 var wait time.Duration
250- requests, wait = pipeline.Expire(scene, requests, time.Now())
227+ requests.List, wait = pipeline.Expire(scene, requests.List, time.Now())
251228 if wait > 0 {
252229 holdTimer.Reset(wait)
253230 holdDue = holdTimer.C
@@ -300,25 +277,19 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
300277 }
301278 if err := checkSecurity(); err != nil {
302279 // q has left incoming but is not yet owned by requests/workers.
303- capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured)
280+ capture.Fail(ctx, q, capture.ErrOutputStopped, opts.Captured)
304281 if ctx.Err() != nil {
305282 return nil
306283 }
307284 return err
308285 }
286+ var refuse error
309287 if opts.protected() {
310- capture.Fail(ctx, q, fmt.Errorf("session protected"), opts.Captured)
288+ refuse = capture.ErrSessionProtected
311289 } else if scene.Off {
312- capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured)
313- } else if len(requests) == cap(requests) {
314- capture.Fail(ctx, q, fmt.Errorf("output capture batch full"), opts.Captured)
315- } else {
316- q.Since = time.Now()
317- requests = append(requests, q)
318- // Its indicator may already be on screen; else it waits for the
319- // scene that shows it.
320- dirty = dirty || haveScene && capture.IndicatorShown(scene, q)
290+ refuse = capture.ErrOutputOff
321291 }
292+ dirty = requests.Admit(q, refuse, scene, haveScene) || dirty
322293 case <-retry:
323294 if err := checkSecurity(); err != nil {
324295 if ctx.Err() != nil {
@@ -419,14 +390,10 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
419390 return err
420391 }
421392 if opts.protected() {
422- failRequests("session protected")
393+ failRequests(capture.ErrSessionProtected)
423394 }
424- if scene.Off && len(requests) > 0 {
425- for _, q := range requests {
426- capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured)
427- }
428- clear(requests)
429- requests = requestStorage[:0]
395+ if scene.Off && requests.Len() > 0 {
396+ requests.FailPending(capture.ErrOutputOff)
430397 }
431398 if !haveScene || !dirty || scene.Off {
432399 // Contents not drawn are still read: report them. An output
@@ -458,7 +425,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
458425 // Requests the scene cannot serve (exclusion of another session, a
459426 // workspace that moved) are failed here; see capture.Pipeline.Split.
460427 // A request whose indicator the scene does not show yet stays held.
461- ready, _ := pipeline.Hold(scene, requests, time.Now())
428+ ready, _ := pipeline.Hold(scene, requests.List, time.Now())
462429 if len(scene.CaptureIndicators) > 0 {
463430 // The captures of this frame are delivered only once its frame,
464431 // with the indicator, is presented (capture.Pipeline.EndGate).
@@ -470,7 +437,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
470437 // A hidden workspace: a child renderer draws it. The display's
471438 // fences do not cover the child, so wait for it before reporting.
472439 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
473- failRequests("security epoch changed")
440+ failRequests(capture.ErrEpochChanged)
474441 continue
475442 }
476443 pipeline.SubmitHidden(scene, drawn, hidden)
@@ -518,7 +485,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
518485 // Exclude requests: the frame without the excluded surfaces is
519486 // drawn first (same renderer, queue order), then the displayed one.
520487 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
521- failRequests("security epoch changed")
488+ failRequests(capture.ErrEpochChanged)
522489 continue
523490 }
524491 xdone, renderErr := r.Render(pipeline.ExcludedScene(scene), drawn)
@@ -539,14 +506,14 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
539506 return fmt.Errorf("render excluded frame: %w", renderErr)
540507 }
541508 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
542- failRequests("security epoch changed")
509+ failRequests(capture.ErrEpochChanged)
543510 continue
544511 }
545512 pipeline.SubmitScoped(scene.Security, r, excluded)
546513 clear(excluded)
547514 }
548515 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
549- failRequests("security epoch changed")
516+ failRequests(capture.ErrEpochChanged)
550517 continue
551518 }
552519 done, err := r.Render(scene, drawn)
@@ -571,13 +538,13 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
571538 // Do not capture or report a frame that crossed a transition during
572539 // Render/fence wait. The next loop establishes the new black proof.
573540 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
574- failRequests("security epoch changed")
541+ failRequests(capture.ErrEpochChanged)
575542 continue
576543 }
577544 pipeline.SubmitScoped(scene.Security, r, normal)
578545 // The indicated frame is presented: release what it gated.
579546 pipeline.EndGate(nil)
580- requests = capture.Waiting(requests, scene)
547+ requests.List = capture.Waiting(requests.List, scene)
581548 frame++
582549 opts.report(&reports, flipInfo(scene, drawn, table.Kept), seen)
583550 if opts.ScreenshotDir != "" && !opts.protected() {
@@ -671,14 +638,14 @@ func (opts Options) flush(q *presented.Queue) {
671638 // flipInfo describes a drawn frame as a flip at the current CLOCK_MONOTONIC
672639 // time (software clock, refresh unknown), so presentation feedback and
673640 // frame callbacks follow headless frames.
674-func flipInfo(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, kept func(ports.WindowID) bool) *ports.FlipInfo {
641+func flipInfo(scene ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, kept func(ports.WindowID) bool) *ports.FlipInfo {
675642 var ts unix.Timespec
676643 _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)
677644 shows := map[ports.WindowID]uint64{}
678- for id, c := range surfaces {
645+ for id, c := range contents {
679646 // A closed window drawn from its kept content is not shown to
680647 // its client.
681- if scene.Shows(id) && !kept(id) {
648+ if surfaces.Shown(scene, id, kept) {
682649 shows[id] = c.Seq
683650 }
684651 }
M internal/adapters/headless/security_capture_test.go +1 -1
@@ -8,7 +8,7 @@ import (
88 "sync/atomic"
99 "testing"
1010
11- "github.com/bnema/neferwl/internal/adapters/capture"
11+ "github.com/bnema/neferwl/internal/adapters/outputkit/capture"
1212 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
1313 "github.com/bnema/neferwl/internal/ports"
1414 "github.com/stretchr/testify/mock"
R internal/adapters/capture/capture.go → internal/adapters/outputkit/capture/capture.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/adapters/capture/capture_test.go → internal/adapters/outputkit/capture/capture_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/adapters/capture/excluded.go → internal/adapters/outputkit/capture/excluded.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/adapters/capture/excluded_test.go → internal/adapters/outputkit/capture/excluded_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/adapters/capture/fence.go → internal/adapters/outputkit/capture/fence.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/adapters/capture/fence_test.go → internal/adapters/outputkit/capture/fence_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/adapters/capture/indicator_test.go → internal/adapters/outputkit/capture/indicator_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/adapters/capture/offscreen.go → internal/adapters/outputkit/capture/offscreen.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/adapters/capture/offscreen_fence_test.go → internal/adapters/outputkit/capture/offscreen_fence_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/adapters/capture/offscreen_test.go → internal/adapters/outputkit/capture/offscreen_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/adapters/capture/pipeline.go → internal/adapters/outputkit/capture/pipeline.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/adapters/capture/pipeline_fence_test.go → internal/adapters/outputkit/capture/pipeline_fence_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/adapters/capture/pipeline_test.go → internal/adapters/outputkit/capture/pipeline_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

A internal/adapters/outputkit/capture/requests.go +95 -0
@@ -0,0 +1,95 @@
1+package capture
2+
3+import (
4+ "context"
5+ "errors"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// Reasons an output owner fails a request with.
12+var (
13+ ErrOutputStopped = errors.New("output stopped")
14+ ErrOutputOff = errors.New("output off")
15+ ErrSessionProtected = errors.New("session protected")
16+ ErrBatchFull = errors.New("output capture batch full")
17+ ErrEpochChanged = errors.New("security epoch changed")
18+)
19+
20+// Requests are the capture requests an output owner collected for its next
21+// frame, in fixed storage (MaxRequests): no allocation. Its owner goroutine
22+// is the only user. List is the live slice the pipeline calls take and
23+// return; assign their result back to it.
24+type Requests struct {
25+ storage [MaxRequests]ports.CaptureRequest
26+ List []ports.CaptureRequest
27+ ctx context.Context
28+ replies chan<- ports.CaptureDone
29+}
30+
31+// Init binds the requests to the owner's context and reply channel. It must
32+// run before any other method.
33+func (r *Requests) Init(ctx context.Context, replies chan<- ports.CaptureDone) {
34+ r.List, r.ctx, r.replies = r.storage[:0], ctx, replies
35+}
36+
37+// Len is the number of requests held.
38+func (r *Requests) Len() int { return len(r.List) }
39+
40+// Admit takes q for the next frame, or fails it with refuse when that is not
41+// nil, or when the batch is full. It reports whether q needs a new frame: its
42+// indicator is already on the current scene.
43+func (r *Requests) Admit(q ports.CaptureRequest, refuse error, scene ports.Scene, haveScene bool) (redraw bool) {
44+ if refuse == nil && len(r.List) == cap(r.List) {
45+ refuse = ErrBatchFull
46+ }
47+ if refuse != nil {
48+ Fail(r.ctx, q, refuse, r.replies)
49+ return false
50+ }
51+ q.Since = time.Now()
52+ r.List = append(r.List, q)
53+ // Its indicator may already be on screen; else it waits for the scene
54+ // that shows it.
55+ return haveScene && IndicatorShown(scene, q)
56+}
57+
58+// FailPending fails every request not yet handed to the capture worker and
59+// empties the list.
60+func (r *Requests) FailPending(err error) {
61+ for _, q := range r.List {
62+ if !Handed(q) {
63+ Fail(r.ctx, q, err, r.replies)
64+ }
65+ }
66+ r.Reset()
67+}
68+
69+// Reset empties the list without answering: its requests were answered or
70+// handed over.
71+func (r *Requests) Reset() {
72+ clear(r.List)
73+ r.List = r.storage[:0]
74+}
75+
76+// Stop fails what the owner still holds, then what is queued on incoming,
77+// with ErrOutputStopped. It returns once incoming is empty or closed.
78+func (r *Requests) Stop(incoming <-chan ports.CaptureRequest) {
79+ for _, q := range r.List {
80+ if !Handed(q) {
81+ Fail(r.ctx, q, ErrOutputStopped, r.replies)
82+ }
83+ }
84+ for {
85+ select {
86+ case q, ok := <-incoming:
87+ if !ok {
88+ return
89+ }
90+ Fail(r.ctx, q, ErrOutputStopped, r.replies)
91+ default:
92+ return
93+ }
94+ }
95+}
A internal/adapters/outputkit/capture/requests_test.go +75 -0
@@ -0,0 +1,75 @@
1+package capture
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestRequestsAdmitRefuseAndFull(t *testing.T) {
12+ replies := make(chan ports.CaptureDone, MaxRequests+2)
13+ var r Requests
14+ r.Init(context.Background(), replies)
15+ scene := ports.Scene{OutputWidth: 10, OutputHeight: 10}
16+
17+ require.False(t, r.Admit(ports.CaptureRequest{ID: 1}, ErrOutputOff, scene, true))
18+ require.ErrorIs(t, (<-replies).Err, ErrOutputOff)
19+ require.Zero(t, r.Len())
20+
21+ // A request without an indicator is ready at once on a known scene.
22+ require.True(t, r.Admit(ports.CaptureRequest{ID: 2}, nil, scene, true))
23+ require.False(t, r.Admit(ports.CaptureRequest{ID: 3}, nil, scene, false), "no scene yet")
24+ require.False(t, r.List[0].Since.IsZero())
25+ for i := r.Len(); i < MaxRequests; i++ {
26+ r.Admit(ports.CaptureRequest{ID: uint64(10 + i)}, nil, scene, true)
27+ }
28+ require.False(t, r.Admit(ports.CaptureRequest{ID: 99}, nil, scene, true))
29+ done := <-replies
30+ require.Equal(t, uint64(99), done.ID)
31+ require.ErrorIs(t, done.Err, ErrBatchFull)
32+ require.Equal(t, MaxRequests, r.Len())
33+}
34+
35+func TestRequestsFailPendingSkipsHanded(t *testing.T) {
36+ replies := make(chan ports.CaptureDone, 4)
37+ var r Requests
38+ r.Init(context.Background(), replies)
39+ r.Admit(ports.CaptureRequest{ID: 1}, nil, ports.Scene{}, false)
40+ r.Admit(ports.CaptureRequest{ID: 2}, nil, ports.Scene{}, false)
41+ r.List[0] = ports.CaptureRequest{} // handed to the worker
42+ r.FailPending(ErrEpochChanged)
43+ require.Zero(t, r.Len())
44+ require.Len(t, replies, 1)
45+ done := <-replies
46+ require.Equal(t, uint64(2), done.ID)
47+ require.ErrorIs(t, done.Err, ErrEpochChanged)
48+}
49+
50+func TestRequestsStopDrainsIncoming(t *testing.T) {
51+ replies := make(chan ports.CaptureDone, 4)
52+ var r Requests
53+ r.Init(context.Background(), replies)
54+ r.Admit(ports.CaptureRequest{ID: 1}, nil, ports.Scene{}, false)
55+ r.Admit(ports.CaptureRequest{ID: 3}, nil, ports.Scene{}, false)
56+ r.List[1] = ports.CaptureRequest{} // handed to the pipeline
57+ incoming := make(chan ports.CaptureRequest, 2)
58+ incoming <- ports.CaptureRequest{ID: 2}
59+ close(incoming)
60+ r.Stop(incoming)
61+ require.Len(t, replies, 2)
62+ for _, id := range []uint64{1, 2} {
63+ done := <-replies
64+ require.Equal(t, id, done.ID)
65+ require.ErrorIs(t, done.Err, ErrOutputStopped)
66+ }
67+}
68+
69+func TestRequestsStopReturnsOnOpenEmptyChannel(t *testing.T) {
70+ replies := make(chan ports.CaptureDone, 1)
71+ var r Requests
72+ r.Init(context.Background(), replies)
73+ r.Stop(make(chan ports.CaptureRequest))
74+ require.Empty(t, replies)
75+}
R internal/adapters/capture/security_test.go → internal/adapters/outputkit/capture/security_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/adapters/capture/shows_test.go → internal/adapters/outputkit/capture/shows_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/adapters/capture/write_test.go → internal/adapters/outputkit/capture/write_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/adapters/presented/queue.go → internal/adapters/outputkit/presented/queue.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/adapters/presented/queue_test.go → internal/adapters/outputkit/presented/queue_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/adapters/surfaces/surfaces.go → internal/adapters/outputkit/surfaces/surfaces.go +19 -1 renamed, 84% similar
@@ -4,7 +4,7 @@
44 package surfaces
55
66 import (
7- "github.com/bnema/neferwl/internal/adapters/capture"
7+ "github.com/bnema/neferwl/internal/adapters/outputkit/capture"
88 "github.com/bnema/neferwl/internal/ports"
99 )
1010
@@ -98,3 +98,21 @@ func (t *Table) Kept(id ports.WindowID) bool {
9898 _, ok := t.kept[id]
9999 return ok
100100 }
101+
102+// Shown is the presentation rule both output backends report with: id was
103+// shown to its client when scene draws it and its content is not kept. A
104+// nil kept counts nothing as kept.
105+func Shown(scene ports.Scene, id ports.WindowID, kept func(ports.WindowID) bool) bool {
106+ return scene.Shows(id) && (kept == nil || !kept(id))
107+}
108+
109+// FillShown clears dst and fills it with the windows of seen that scene
110+// showed to their client (Shown), at their seen Seq.
111+func FillShown(dst, seen map[ports.WindowID]uint64, scene ports.Scene, kept func(ports.WindowID) bool) {
112+ clear(dst)
113+ for id, seq := range seen {
114+ if Shown(scene, id, kept) {
115+ dst[id] = seq
116+ }
117+ }
118+}
R internal/adapters/surfaces/surfaces_test.go → internal/adapters/outputkit/surfaces/surfaces_test.go +14 -0 renamed, 90% similar
@@ -122,3 +122,17 @@ func TestKeptChildrenFencesDroppedOnACopy(t *testing.T) {
122122 t.Fatal("wayland's slice was modified")
123123 }
124124 }
125+
126+// Both backends report a window shown only when the scene draws it and its
127+// content is not kept.
128+func TestFillShown(t *testing.T) {
129+ kept := func(id ports.WindowID) bool { return id == 2 }
130+ dst := map[ports.WindowID]uint64{9: 9}
131+ FillShown(dst, map[ports.WindowID]uint64{1: 3, 2: 4, 3: 5}, shows(1, 2), kept)
132+ if len(dst) != 1 || dst[1] != 3 {
133+ t.Fatalf("shown %v", dst)
134+ }
135+ if !Shown(shows(2), 2, nil) {
136+ t.Fatal("nil kept counts nothing as kept")
137+ }
138+}
M internal/adapters/wayland/release_test.go +1 -1
@@ -10,7 +10,7 @@ import (
1010
1111 "github.com/bnema/go-wayland-bindings/server/fifo"
1212 "github.com/bnema/go-wayland-bindings/server/wayland"
13- "github.com/bnema/neferwl/internal/adapters/capture"
13+ "github.com/bnema/neferwl/internal/adapters/outputkit/capture"
1414 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
1515 "github.com/bnema/neferwl/internal/ports"
1616 "github.com/stretchr/testify/mock"
M internal/adapters/wayland/session_lock_barrier_test.go +1 -1
@@ -8,8 +8,8 @@ import (
88 "testing"
99 "time"
1010
11- "github.com/bnema/neferwl/internal/adapters/capture"
1211 "github.com/bnema/neferwl/internal/adapters/logging"
12+ "github.com/bnema/neferwl/internal/adapters/outputkit/capture"
1313 "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
1414 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
1515 "github.com/bnema/neferwl/internal/ports"
M internal/app/drm.go +13 -66
@@ -8,7 +8,6 @@ import (
88 "path/filepath"
99 "runtime"
1010 "runtime/debug"
11- "slices"
1211 "sort"
1312 "sync"
1413 "sync/atomic"
@@ -22,7 +21,6 @@ import (
2221 "github.com/bnema/neferwl/internal/adapters/seat"
2322 "github.com/bnema/neferwl/internal/ports"
2423 "github.com/bnema/zerowrap"
25- "golang.org/x/sys/unix"
2624 )
2725
2826 var errTimeout = errors.New("timeout")
@@ -307,59 +305,8 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
307305 set := newOutputSet(ctx, ch.captured)
308306 set.wireSecurity(ch)
309307 protected := func() bool { return set.state.Protected || securitySnapshot(ch.security).Protected }
310- clientFDs := map[*drmCard]*os.File{}
311- lastLeaseConnectors := map[*drmCard][]ports.LeaseConnector{}
312- defer func() {
313- for _, f := range clientFDs {
314- f.Close()
315- }
316- }()
317- sendLease := func(msg ports.LeaseMessage) {
318- select {
319- case ch.leaseEvents <- msg:
320- case <-ctx.Done():
321- ports.CloseLeaseFiles(msg)
322- }
323- }
324- publishLeases := func(c *drmCard) {
325- for _, id := range c.FinishedLeases() {
326- sendLease(ports.LeaseFinished{Card: c.Path(), LeaseID: id})
327- }
328- var next []ports.LeaseConnector
329- if seatActive && !protected() {
330- next = c.Leasable()
331- }
332- if len(next) == 0 {
333- if protected() || clientFDs[c] != nil {
334- sendLease(ports.LeaseConnectors{Card: c.Path()})
335- }
336- if clientFDs[c] != nil {
337- clientFDs[c].Close()
338- delete(clientFDs, c)
339- }
340- delete(lastLeaseConnectors, c)
341- return
342- }
343- if slices.Equal(lastLeaseConnectors[c], next) && clientFDs[c] != nil {
344- return
345- }
346- if clientFDs[c] == nil {
347- f, err := c.ClientFD()
348- if err != nil {
349- log.Warn().Err(err).Str("card", c.Path()).Msg("lease client fd")
350- return
351- }
352- clientFDs[c] = f
353- }
354- // Each message owns its fd: a queued inventory outlives clientFDs[c].
355- fd, err := unix.FcntlInt(clientFDs[c].Fd(), unix.F_DUPFD_CLOEXEC, 0)
356- if err != nil {
357- log.Warn().Err(err).Str("card", c.Path()).Msg("lease client fd")
358- return
359- }
360- lastLeaseConnectors[c] = next
361- sendLease(ports.LeaseConnectors{Card: c.Path(), Device: os.NewFile(uintptr(fd), c.Path()), Connectors: next})
362- }
308+ leases := newLeasePublisher(ctx, ch.leaseEvents, func() bool { return seatActive }, protected, log)
309+ defer leases.close()
363310 hotplug := make(chan struct{}, 1)
364311 go func() {
365312 if err := safe("hotplug", func() error { return drm.WatchHotplug(ctx, hotplug) }); err != nil {
@@ -408,9 +355,9 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
408355 var errs []error
409356 for _, c := range b.cards {
410357 errs = append(errs, revokeSecurityLeases(c, func(id uint32) {
411- sendLease(ports.LeaseFinished{Card: c.Path(), LeaseID: id})
358+ leases.send(ports.LeaseFinished{Card: c.Path(), LeaseID: id})
412359 }))
413- publishLeases(c)
360+ leases.publish(c)
414361 }
415362 errs = append(errs, discardedOutputErr)
416363 err := errors.Join(errs...)
@@ -460,7 +407,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
460407 scanErrors = append(scanErrors, fmt.Errorf("scan %s: %w", c.Path(), err))
461408 continue
462409 }
463- publishLeases(c)
410+ leases.publish(c)
464411 stop := func(name string, restart bool) {
465412 if r := set.outs[name]; r != nil && cards[name] == c {
466413 if _, ok := stopping[name]; !ok {
@@ -640,7 +587,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
640587 barrier()
641588 } else {
642589 for _, c := range b.cards {
643- publishLeases(c)
590+ leases.publish(c)
644591 }
645592 }
646593 case <-revokeDeadline:
@@ -664,14 +611,14 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
664611 if err := c.Revoke(id); err != nil {
665612 log.Warn().Err(err).Uint32("lease", id).Msg("VT lease revoke")
666613 } else {
667- sendLease(ports.LeaseFinished{Card: c.Path(), LeaseID: id})
614+ leases.send(ports.LeaseFinished{Card: c.Path(), LeaseID: id})
668615 }
669616 }
670- publishLeases(c)
617+ leases.publish(c)
671618 }
672619 } else {
673620 for _, c := range b.cards {
674- publishLeases(c)
621+ leases.publish(c)
675622 }
676623 }
677624 case event := <-ch.leaseRequests:
@@ -680,24 +627,24 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
680627 reply := ports.LeaseReply{ID: req.ID, Err: fmt.Errorf("unknown card %s", req.Card)}
681628 if !seatActive || protected() {
682629 reply.Err = errors.New("DRM seat inactive or session protected")
683- sendLease(reply)
630+ leases.send(reply)
684631 break
685632 }
686633 for _, c := range b.cards {
687634 if c.Path() == req.Card {
688635 reply.FD, reply.LeaseID, reply.Err = guardedLease(c, ch.security, req.Connectors)
689- publishLeases(c)
636+ leases.publish(c)
690637 break
691638 }
692639 }
693- sendLease(reply)
640+ leases.send(reply)
694641 case ports.LeaseRevoke:
695642 for _, c := range b.cards {
696643 if c.Path() == req.Card {
697644 if err := c.Revoke(req.LeaseID); err != nil {
698645 log.Warn().Err(err).Uint32("lease", req.LeaseID).Msg("revoke lease")
699646 } else {
700- publishLeases(c)
647+ leases.publish(c)
701648 }
702649 break
703650 }
M internal/app/headless.go +3 -9
@@ -2,6 +2,7 @@ package app
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "os"
78 "path/filepath"
@@ -37,7 +38,7 @@ func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch
3738 dir = filepath.Join(o.shots, name)
3839 if err := os.MkdirAll(dir, 0o755); err != nil {
3940 // Outputs already started stop and release their captures.
40- return joinErr(err, set.wait())
41+ return errors.Join(err, set.wait())
4142 }
4243 }
4344 cur := &headless.Cursor{}
@@ -105,14 +106,7 @@ func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch
105106 }
106107 // An owner error is not protection or removal evidence.
107108 err := set.finish(stopped.name)
108- return joinErr(err, set.wait())
109+ return errors.Join(err, set.wait())
109110 }
110111 }
111112 }
112-
113-func joinErr(a, b error) error {
114- if a != nil {
115- return a
116- }
117- return b
118-}
A internal/app/leaseinventory_mock_test.go +239 -0
@@ -0,0 +1,239 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package app
6+
7+import (
8+ "os"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// newMockleaseInventoryCard creates a new instance of mockleaseInventoryCard. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func newMockleaseInventoryCard(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *mockleaseInventoryCard {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &mockleaseInventoryCard{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// mockleaseInventoryCard is an autogenerated mock type for the leaseInventoryCard type
38+type mockleaseInventoryCard struct {
39+ mock.Mock
40+}
41+
42+type mockleaseInventoryCard_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *mockleaseInventoryCard) EXPECT() *mockleaseInventoryCard_Expecter {
47+ return &mockleaseInventoryCard_Expecter{mock: &_m.Mock}
48+}
49+
50+// ClientFD provides a mock function for the type mockleaseInventoryCard
51+func (_mock *mockleaseInventoryCard) ClientFD() (*os.File, error) {
52+ ret := _mock.Called()
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for ClientFD")
56+ }
57+
58+ var r0 *os.File
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func() (*os.File, error)); ok {
61+ return returnFunc()
62+ }
63+ if returnFunc, ok := ret.Get(0).(func() *os.File); ok {
64+ r0 = returnFunc()
65+ } else {
66+ if ret.Get(0) != nil {
67+ r0 = ret.Get(0).(*os.File)
68+ }
69+ }
70+ if returnFunc, ok := ret.Get(1).(func() error); ok {
71+ r1 = returnFunc()
72+ } else {
73+ r1 = ret.Error(1)
74+ }
75+ return r0, r1
76+}
77+
78+// mockleaseInventoryCard_ClientFD_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClientFD'
79+type mockleaseInventoryCard_ClientFD_Call struct {
80+ *mock.Call
81+}
82+
83+// ClientFD is a helper method to define mock.On call
84+func (_e *mockleaseInventoryCard_Expecter) ClientFD() *mockleaseInventoryCard_ClientFD_Call {
85+ return &mockleaseInventoryCard_ClientFD_Call{Call: _e.mock.On("ClientFD")}
86+}
87+
88+func (_c *mockleaseInventoryCard_ClientFD_Call) Run(run func()) *mockleaseInventoryCard_ClientFD_Call {
89+ _c.Call.Run(func(args mock.Arguments) {
90+ run()
91+ })
92+ return _c
93+}
94+
95+func (_c *mockleaseInventoryCard_ClientFD_Call) Return(file *os.File, err error) *mockleaseInventoryCard_ClientFD_Call {
96+ _c.Call.Return(file, err)
97+ return _c
98+}
99+
100+func (_c *mockleaseInventoryCard_ClientFD_Call) RunAndReturn(run func() (*os.File, error)) *mockleaseInventoryCard_ClientFD_Call {
101+ _c.Call.Return(run)
102+ return _c
103+}
104+
105+// FinishedLeases provides a mock function for the type mockleaseInventoryCard
106+func (_mock *mockleaseInventoryCard) FinishedLeases() []uint32 {
107+ ret := _mock.Called()
108+
109+ if len(ret) == 0 {
110+ panic("no return value specified for FinishedLeases")
111+ }
112+
113+ var r0 []uint32
114+ if returnFunc, ok := ret.Get(0).(func() []uint32); ok {
115+ r0 = returnFunc()
116+ } else {
117+ if ret.Get(0) != nil {
118+ r0 = ret.Get(0).([]uint32)
119+ }
120+ }
121+ return r0
122+}
123+
124+// mockleaseInventoryCard_FinishedLeases_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FinishedLeases'
125+type mockleaseInventoryCard_FinishedLeases_Call struct {
126+ *mock.Call
127+}
128+
129+// FinishedLeases is a helper method to define mock.On call
130+func (_e *mockleaseInventoryCard_Expecter) FinishedLeases() *mockleaseInventoryCard_FinishedLeases_Call {
131+ return &mockleaseInventoryCard_FinishedLeases_Call{Call: _e.mock.On("FinishedLeases")}
132+}
133+
134+func (_c *mockleaseInventoryCard_FinishedLeases_Call) Run(run func()) *mockleaseInventoryCard_FinishedLeases_Call {
135+ _c.Call.Run(func(args mock.Arguments) {
136+ run()
137+ })
138+ return _c
139+}
140+
141+func (_c *mockleaseInventoryCard_FinishedLeases_Call) Return(uint32s []uint32) *mockleaseInventoryCard_FinishedLeases_Call {
142+ _c.Call.Return(uint32s)
143+ return _c
144+}
145+
146+func (_c *mockleaseInventoryCard_FinishedLeases_Call) RunAndReturn(run func() []uint32) *mockleaseInventoryCard_FinishedLeases_Call {
147+ _c.Call.Return(run)
148+ return _c
149+}
150+
151+// Leasable provides a mock function for the type mockleaseInventoryCard
152+func (_mock *mockleaseInventoryCard) Leasable() []ports.LeaseConnector {
153+ ret := _mock.Called()
154+
155+ if len(ret) == 0 {
156+ panic("no return value specified for Leasable")
157+ }
158+
159+ var r0 []ports.LeaseConnector
160+ if returnFunc, ok := ret.Get(0).(func() []ports.LeaseConnector); ok {
161+ r0 = returnFunc()
162+ } else {
163+ if ret.Get(0) != nil {
164+ r0 = ret.Get(0).([]ports.LeaseConnector)
165+ }
166+ }
167+ return r0
168+}
169+
170+// mockleaseInventoryCard_Leasable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Leasable'
171+type mockleaseInventoryCard_Leasable_Call struct {
172+ *mock.Call
173+}
174+
175+// Leasable is a helper method to define mock.On call
176+func (_e *mockleaseInventoryCard_Expecter) Leasable() *mockleaseInventoryCard_Leasable_Call {
177+ return &mockleaseInventoryCard_Leasable_Call{Call: _e.mock.On("Leasable")}
178+}
179+
180+func (_c *mockleaseInventoryCard_Leasable_Call) Run(run func()) *mockleaseInventoryCard_Leasable_Call {
181+ _c.Call.Run(func(args mock.Arguments) {
182+ run()
183+ })
184+ return _c
185+}
186+
187+func (_c *mockleaseInventoryCard_Leasable_Call) Return(leaseConnectors []ports.LeaseConnector) *mockleaseInventoryCard_Leasable_Call {
188+ _c.Call.Return(leaseConnectors)
189+ return _c
190+}
191+
192+func (_c *mockleaseInventoryCard_Leasable_Call) RunAndReturn(run func() []ports.LeaseConnector) *mockleaseInventoryCard_Leasable_Call {
193+ _c.Call.Return(run)
194+ return _c
195+}
196+
197+// Path provides a mock function for the type mockleaseInventoryCard
198+func (_mock *mockleaseInventoryCard) Path() string {
199+ ret := _mock.Called()
200+
201+ if len(ret) == 0 {
202+ panic("no return value specified for Path")
203+ }
204+
205+ var r0 string
206+ if returnFunc, ok := ret.Get(0).(func() string); ok {
207+ r0 = returnFunc()
208+ } else {
209+ r0 = ret.Get(0).(string)
210+ }
211+ return r0
212+}
213+
214+// mockleaseInventoryCard_Path_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Path'
215+type mockleaseInventoryCard_Path_Call struct {
216+ *mock.Call
217+}
218+
219+// Path is a helper method to define mock.On call
220+func (_e *mockleaseInventoryCard_Expecter) Path() *mockleaseInventoryCard_Path_Call {
221+ return &mockleaseInventoryCard_Path_Call{Call: _e.mock.On("Path")}
222+}
223+
224+func (_c *mockleaseInventoryCard_Path_Call) Run(run func()) *mockleaseInventoryCard_Path_Call {
225+ _c.Call.Run(func(args mock.Arguments) {
226+ run()
227+ })
228+ return _c
229+}
230+
231+func (_c *mockleaseInventoryCard_Path_Call) Return(s string) *mockleaseInventoryCard_Path_Call {
232+ _c.Call.Return(s)
233+ return _c
234+}
235+
236+func (_c *mockleaseInventoryCard_Path_Call) RunAndReturn(run func() string) *mockleaseInventoryCard_Path_Call {
237+ _c.Call.Return(run)
238+ return _c
239+}
A internal/app/leases.go +98 -0
@@ -0,0 +1,98 @@
1+package app
2+
3+import (
4+ "context"
5+ "os"
6+ "slices"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+ "github.com/bnema/zerowrap"
10+ "golang.org/x/sys/unix"
11+)
12+
13+// leaseInventoryCard is what the lease publisher reads from a DRM card.
14+type leaseInventoryCard interface {
15+ Path() string
16+ FinishedLeases() []uint32
17+ Leasable() []ports.LeaseConnector
18+ ClientFD() (*os.File, error)
19+}
20+
21+// leasePublisher tells wayland which connectors of each card can be leased
22+// (wp_drm_lease_device_v1), and which leases ended. It keeps one non-master
23+// client fd per advertised card and resends an inventory only when it
24+// changed. The DRM backend loop owns it.
25+type leasePublisher struct {
26+ ctx context.Context
27+ events chan<- ports.LeaseMessage
28+ log zerowrap.Logger
29+ // seatActive and protected say whether leases may be offered: only on an
30+ // active seat in an unprotected session.
31+ seatActive, protected func() bool
32+ clientFDs map[leaseInventoryCard]*os.File
33+ last map[leaseInventoryCard][]ports.LeaseConnector
34+}
35+
36+func newLeasePublisher(ctx context.Context, events chan<- ports.LeaseMessage, seatActive, protected func() bool, log zerowrap.Logger) *leasePublisher {
37+ return &leasePublisher{ctx: ctx, events: events, seatActive: seatActive, protected: protected, log: log, clientFDs: map[leaseInventoryCard]*os.File{}, last: map[leaseInventoryCard][]ports.LeaseConnector{}}
38+}
39+
40+// send delivers msg, closing its files when ctx ends first.
41+func (p *leasePublisher) send(msg ports.LeaseMessage) {
42+ select {
43+ case p.events <- msg:
44+ case <-p.ctx.Done():
45+ ports.CloseLeaseFiles(msg)
46+ }
47+}
48+
49+// publish reports the finished leases of c and its leasable connectors. An
50+// inactive seat or a protected session withdraws the inventory; protection
51+// always tells wayland so.
52+func (p *leasePublisher) publish(c leaseInventoryCard) {
53+ for _, id := range c.FinishedLeases() {
54+ p.send(ports.LeaseFinished{Card: c.Path(), LeaseID: id})
55+ }
56+ var next []ports.LeaseConnector
57+ if p.seatActive() && !p.protected() {
58+ next = c.Leasable()
59+ }
60+ if len(next) == 0 {
61+ if p.protected() || p.clientFDs[c] != nil {
62+ p.send(ports.LeaseConnectors{Card: c.Path()})
63+ }
64+ if p.clientFDs[c] != nil {
65+ p.clientFDs[c].Close()
66+ delete(p.clientFDs, c)
67+ }
68+ delete(p.last, c)
69+ return
70+ }
71+ if slices.Equal(p.last[c], next) && p.clientFDs[c] != nil {
72+ return
73+ }
74+ if p.clientFDs[c] == nil {
75+ f, err := c.ClientFD()
76+ if err != nil {
77+ p.log.Warn().Err(err).Str("card", c.Path()).Msg("lease client fd")
78+ return
79+ }
80+ p.clientFDs[c] = f
81+ }
82+ // Each message owns its fd: a queued inventory outlives clientFDs[c].
83+ fd, err := unix.FcntlInt(p.clientFDs[c].Fd(), unix.F_DUPFD_CLOEXEC, 0)
84+ if err != nil {
85+ p.log.Warn().Err(err).Str("card", c.Path()).Msg("lease client fd")
86+ return
87+ }
88+ p.last[c] = next
89+ p.send(ports.LeaseConnectors{Card: c.Path(), Device: os.NewFile(uintptr(fd), c.Path()), Connectors: next})
90+}
91+
92+// close releases the client fds.
93+func (p *leasePublisher) close() {
94+ for _, f := range p.clientFDs {
95+ f.Close()
96+ }
97+ clear(p.clientFDs)
98+}
A internal/app/leases_test.go +90 -0
@@ -0,0 +1,90 @@
1+package app
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/adapters/logging"
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+func leaseRig(t *testing.T, active, protected *bool) (*leasePublisher, chan ports.LeaseMessage, *mockleaseInventoryCard) {
13+ t.Helper()
14+ events := make(chan ports.LeaseMessage, 8)
15+ p := newLeasePublisher(context.Background(), events, func() bool { return *active }, func() bool { return *protected }, logging.For(context.Background(), "app"))
16+ t.Cleanup(p.close)
17+ card := newMockleaseInventoryCard(t)
18+ card.EXPECT().Path().Return("/dev/dri/card0").Maybe()
19+ return p, events, card
20+}
21+
22+func clientFile(t *testing.T) *os.File {
23+ t.Helper()
24+ f, err := os.CreateTemp(t.TempDir(), "card")
25+ if err != nil {
26+ t.Fatal(err)
27+ }
28+ return f
29+}
30+
31+// An inventory is sent once per change, each with its own fd, and finished
32+// leases go first.
33+func TestLeasePublisherSendsChangesOnly(t *testing.T) {
34+ active, protected := true, false
35+ p, events, card := leaseRig(t, &active, &protected)
36+ connectors := []ports.LeaseConnector{{Name: "HDMI-A-1"}}
37+ card.EXPECT().FinishedLeases().Return([]uint32{7}).Once()
38+ card.EXPECT().FinishedLeases().Return(nil)
39+ card.EXPECT().Leasable().Return(connectors)
40+ card.EXPECT().ClientFD().Return(clientFile(t), nil).Once()
41+
42+ p.publish(card)
43+ if fin, ok := (<-events).(ports.LeaseFinished); !ok || fin.LeaseID != 7 {
44+ t.Fatalf("finished lease first: %+v", fin)
45+ }
46+ inv, ok := (<-events).(ports.LeaseConnectors)
47+ if !ok || inv.Device == nil || len(inv.Connectors) != 1 {
48+ t.Fatalf("inventory: %+v", inv)
49+ }
50+ defer inv.Device.Close()
51+ if inv.Device.Fd() == p.clientFDs[card].Fd() {
52+ t.Fatal("message shares the publisher's fd")
53+ }
54+ p.publish(card)
55+ if len(events) != 0 {
56+ t.Fatalf("unchanged inventory resent: %+v", <-events)
57+ }
58+}
59+
60+// Protection withdraws the inventory and closes the client fd, even when
61+// nothing was offered before.
62+func TestLeasePublisherProtectedWithdraws(t *testing.T) {
63+ active, protected := true, true
64+ p, events, card := leaseRig(t, &active, &protected)
65+ card.EXPECT().FinishedLeases().Return(nil)
66+ p.publish(card)
67+ if inv, ok := (<-events).(ports.LeaseConnectors); !ok || inv.Device != nil || len(inv.Connectors) != 0 {
68+ t.Fatalf("protected inventory: %+v", inv)
69+ }
70+
71+ protected = false
72+ card.EXPECT().Leasable().Return([]ports.LeaseConnector{{Name: "DP-2"}}).Once()
73+ card.EXPECT().ClientFD().Return(clientFile(t), nil).Once()
74+ p.publish(card)
75+ inv := (<-events).(ports.LeaseConnectors)
76+ inv.Device.Close()
77+ held := p.clientFDs[card]
78+
79+ active = false // an inactive seat withdraws what was offered
80+ p.publish(card)
81+ if inv, ok := (<-events).(ports.LeaseConnectors); !ok || inv.Device != nil {
82+ t.Fatalf("withdrawn inventory: %+v", inv)
83+ }
84+ if _, err := held.Stat(); err == nil {
85+ t.Fatal("client fd kept after withdrawal")
86+ }
87+ if p.clientFDs[card] != nil {
88+ t.Fatal("client fd still tracked")
89+ }
90+}
M internal/app/outputmanagement.go +11 -169
@@ -2,168 +2,12 @@ package app
22
33 import (
44 "errors"
5- "fmt"
6- "image"
7- "maps"
8- "math"
9- "reflect"
10- "slices"
115
6+ "github.com/bnema/neferwl/internal/core"
127 "github.com/bnema/neferwl/internal/ports"
138 "github.com/bnema/zerowrap"
149 )
1510
16-// outputOverrides holds runtime-only output settings. Only outputApply uses
17-// it; the file configuration is never mutated.
18-type outputOverrides struct {
19- file ports.Config
20- overrides map[string]ports.OutputConfig
21- heads ports.OutputHeads
22- headless bool
23-}
24-
25-func newOutputOverrides(file ports.Config, headless bool) *outputOverrides {
26- return &outputOverrides{file: file, headless: headless, overrides: map[string]ports.OutputConfig{}}
27-}
28-func (o *outputOverrides) effective() ports.Config {
29- cfg := o.file
30- cfg.Outputs = slices.Clone(o.file.Outputs)
31- // Preserve file order and backend connection order; map iteration would
32- // make automatic placement change unpredictably.
33- for _, head := range o.heads.Heads {
34- entry, ok := o.overrides[head.Info.Name]
35- if !ok {
36- continue
37- }
38- index := slices.IndexFunc(cfg.Outputs, func(v ports.OutputConfig) bool { return v.Name == head.Info.Name })
39- if index < 0 {
40- cfg.Outputs = append(cfg.Outputs, entry)
41- } else {
42- cfg.Outputs[index] = entry
43- }
44- }
45- return cfg
46-}
47-
48-// reload replaces the file configuration. Runtime overrides are dropped,
49-// unless only output scales changed (a saved scale bind): then they stay and
50-// take the new file scales.
51-func (o *outputOverrides) reload(file ports.Config) ports.Config {
52- scales, onlyScales := scaleChanges(o.file, file)
53- o.file = file
54- if !onlyScales {
55- clear(o.overrides)
56- return o.effective()
57- }
58- for name, scale := range scales {
59- if entry, ok := o.overrides[name]; ok {
60- entry.Scale = scale
61- o.overrides[name] = entry
62- }
63- }
64- return o.effective()
65-}
66-
67-// scaleChanges lists the output scales that differ from old to cur, and
68-// reports whether nothing else differs.
69-func scaleChanges(old, cur ports.Config) (map[string]float64, bool) {
70- scaleOf := func(c ports.Config, name string) float64 {
71- for _, o := range c.Outputs {
72- if o.Name == name {
73- return o.Scale
74- }
75- }
76- return 0
77- }
78- // Without scales, an output.<name>.scale-only entry is empty: drop it.
79- strip := func(c ports.Config) ports.Config {
80- c.Outputs = slices.Clone(c.Outputs)
81- for i := range c.Outputs {
82- c.Outputs[i].Scale = 0
83- }
84- c.Outputs = slices.DeleteFunc(c.Outputs, func(o ports.OutputConfig) bool {
85- return o == ports.OutputConfig{Name: o.Name, ScaleOnly: true, SDRBrightness: ports.DefaultSDRBrightness}
86- })
87- return c
88- }
89- if !reflect.DeepEqual(strip(old), strip(cur)) {
90- return nil, false
91- }
92- scales := map[string]float64{}
93- for _, o := range cur.Outputs {
94- if o.Scale != scaleOf(old, o.Name) {
95- scales[o.Name] = o.Scale
96- }
97- }
98- return scales, true
99-}
100-func (o *outputOverrides) validate(req ports.OutputApply) error {
101- if len(req.Heads) != len(o.heads.Heads) {
102- return fmt.Errorf("incomplete head configuration")
103- }
104- seen := map[string]bool{}
105- for _, change := range req.Heads {
106- if seen[change.Name] {
107- return fmt.Errorf("duplicate head %s", change.Name)
108- }
109- seen[change.Name] = true
110- index := slices.IndexFunc(o.heads.Heads, func(h ports.OutputHead) bool { return h.Info.Name == change.Name })
111- if index < 0 {
112- return fmt.Errorf("unknown head %s", change.Name)
113- }
114- current := o.heads.Heads[index]
115- if change.Scale < 0 || change.Scale > 4 || (change.Scale > 0 && change.Scale < 1) || math.IsNaN(change.Scale) || math.IsInf(change.Scale, 0) {
116- return fmt.Errorf("invalid scale on %s", change.Name)
117- }
118- if change.Transform != nil && *change.Transform > 7 {
119- return fmt.Errorf("invalid transform on %s", change.Name)
120- }
121- if o.headless && (change.Enabled != current.Enabled || change.Mode != nil && (current.Current == nil || change.Mode.Width != current.Current.Width || change.Mode.Height != current.Current.Height || change.Mode.RefreshMilli != current.Current.RefreshMilli)) {
122- return fmt.Errorf("headless mode and enabled state are fixed")
123- }
124- if change.Mode != nil && !slices.ContainsFunc(current.Modes, func(m ports.OutputMode) bool {
125- return m.Width == change.Mode.Width && m.Height == change.Mode.Height && m.RefreshMilli == change.Mode.RefreshMilli
126- }) {
127- return fmt.Errorf("unknown mode on %s", change.Name)
128- }
129- }
130- return nil
131-}
132-func (o *outputOverrides) apply(req ports.OutputApply) (ports.Config, error) {
133- if err := o.validate(req); err != nil {
134- return ports.Config{}, err
135- }
136- if req.Test {
137- return o.effective(), nil
138- }
139- for _, h := range req.Heads {
140- entry, ok := o.overrides[h.Name]
141- if !ok {
142- index := slices.IndexFunc(o.file.Outputs, func(v ports.OutputConfig) bool { return v.Name == h.Name })
143- if index >= 0 {
144- entry = o.file.Outputs[index]
145- } else {
146- entry.Name = h.Name
147- }
148- }
149- entry.Off = !h.Enabled
150- if h.Scale > 0 {
151- entry.Scale = h.Scale
152- }
153- if h.Transform != nil {
154- entry.Transform = *h.Transform
155- }
156- if h.Pos != nil {
157- entry.Pos = &image.Point{X: h.Pos.X, Y: h.Pos.Y}
158- }
159- if h.Mode != nil {
160- entry.Mode = fmt.Sprintf("%dx%d@%.3f", h.Mode.Width, h.Mode.Height, float64(h.Mode.RefreshMilli)/1000)
161- }
162- o.overrides[h.Name] = entry
163- }
164- return o.effective(), nil
165-}
166-
16711 // errApplySuperseded answers a request whose configuration a reload replaced.
16812 var errApplySuperseded = errors.New("output apply superseded by reload")
16913
@@ -174,7 +18,7 @@ var errApplySuperseded = errors.New("output apply superseded by reload")
17418 // configuration, Wayland replies and inventory) with sends that never block
17519 // the loop.
17620 type outputApply struct {
177- state *outputOverrides
21+ state *core.OutputOverrides
17822 log zerowrap.Logger
17923 // running is set while the backend applies a configuration. The next
18024 // one waits in start until it finishes: configurations never overlap.
@@ -195,20 +39,20 @@ type outputApply struct {
19539 headsDue bool
19640 }
19741
198-func newOutputApply(state *outputOverrides, log zerowrap.Logger) *outputApply {
42+func newOutputApply(state *core.OutputOverrides, log zerowrap.Logger) *outputApply {
19943 return &outputApply{state: state, log: log}
20044 }
20145
20246 // reload replaces the file configuration and supersedes the pending request.
20347 // Only the latest reload needs backend work.
20448 func (a *outputApply) reload(file ports.Config) {
205- cfg := a.state.reload(file)
49+ cfg := a.state.Reload(file)
20650 a.setConfig(cfg)
20751 if a.req != nil {
20852 a.reply(a.req.ID, errApplySuperseded)
20953 a.req = nil
21054 }
211- if !a.state.headless {
55+ if !a.state.Headless() {
21256 a.due(cfg)
21357 }
21458 }
@@ -216,7 +60,7 @@ func (a *outputApply) reload(file ports.Config) {
21660 // heads records the backend inventory: it validates requests and waits for
21761 // Wayland in the outbox, latest only.
21862 func (a *outputApply) heads(heads ports.OutputHeads) {
219- a.state.heads = heads
63+ a.state.SetHeads(heads)
22064 a.headsDue = true
22165 }
22266
@@ -227,15 +71,14 @@ func (a *outputApply) request(req ports.OutputApply) {
22771 a.reply(req.ID, errors.New("output apply already pending"))
22872 return
22973 }
230- previous := make(map[string]ports.OutputConfig, len(a.state.overrides))
231- maps.Copy(previous, a.state.overrides)
232- cfg, err := a.state.apply(req)
74+ previous := a.state.Snapshot()
75+ cfg, err := a.state.Apply(req)
23376 if err != nil || req.Test {
23477 a.reply(req.ID, err)
23578 return
23679 }
23780 a.setConfig(cfg)
238- if a.state.headless {
81+ if a.state.Headless() {
23982 a.reply(req.ID, nil)
24083 return
24184 }
@@ -277,8 +120,7 @@ func (a *outputApply) finished(err error) {
277120 return
278121 }
279122 if err != nil {
280- a.state.overrides = a.previous
281- cfg := a.state.effective()
123+ cfg := a.state.Restore(a.previous)
282124 a.setConfig(cfg)
283125 a.rollback, a.failure = true, err
284126 a.due(cfg)
@@ -338,7 +180,7 @@ func (a *outputApply) headsOut(ch chan<- ports.OutputHeads) (chan<- ports.Output
338180 if !a.headsDue {
339181 return nil, ports.OutputHeads{}
340182 }
341- return ch, a.state.heads
183+ return ch, a.state.Heads()
342184 }
343185
344186 // headsSent records that Wayland received the latest inventory.
M internal/app/outputmanagement_test.go +3 -139
@@ -7,6 +7,7 @@ import (
77 "testing"
88
99 "github.com/bnema/neferwl/internal/adapters/logging"
10+ "github.com/bnema/neferwl/internal/core"
1011 "github.com/bnema/neferwl/internal/ports"
1112 )
1213
@@ -18,7 +19,7 @@ type applyRig struct {
1819
1920 func newApplyRig(t *testing.T, headless bool) applyRig {
2021 mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
21- a := newOutputApply(newOutputOverrides(ports.Config{}, headless), logging.For(context.Background(), "app"))
22+ a := newOutputApply(core.NewOutputOverrides(ports.Config{}, headless), logging.For(context.Background(), "app"))
2223 a.heads(ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}})
2324 return applyRig{t, a}
2425 }
@@ -113,7 +114,7 @@ func TestOutputApply(t *testing.T) {
113114 if len(got) != 1 || got[0].ID != 1 || !errors.Is(got[0].Err, tc.failure) || (got[0].Err != nil) != (tc.failure != nil) {
114115 t.Fatalf("replies: %+v, want %v", got, tc.failure)
115116 }
116- if tc.failure != nil && len(r.a.state.effective().Outputs) != 0 {
117+ if tc.failure != nil && len(r.a.state.Effective().Outputs) != 0 {
117118 t.Fatal("override survived failure")
118119 }
119120 r.a.reload(ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.25}}})
@@ -176,20 +177,6 @@ func TestOutputApplyOutbox(t *testing.T) {
176177 }
177178 }
178179
179-func TestOutputSettingsDisableAndHeadlessMode(t *testing.T) {
180- mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
181- state := newOutputOverrides(ports.Config{}, true)
182- state.heads = ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "HEADLESS-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
183- if _, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "HEADLESS-1", Enabled: false}}}); err == nil {
184- t.Fatal("headless disable accepted")
185- }
186- state.headless = false
187- cfg, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "HEADLESS-1", Enabled: false}}})
188- if err != nil || !cfg.Outputs[0].Off {
189- t.Fatalf("disable: %+v, %v", cfg, err)
190- }
191-}
192-
193180 // A reload supersedes a request the backend is applying: the request fails at
194181 // once, and a late failure of its configuration cannot roll back the reload.
195182 func TestReloadDuringPendingApply(t *testing.T) {
@@ -283,126 +270,3 @@ func TestOutputApplyAnsweredAtOnce(t *testing.T) {
283270 t.Fatalf("headless reply: %+v", got)
284271 }
285272 }
286-
287-// A reload that only changes output scales (a saved scale bind) keeps the
288-// runtime overrides; any other change drops them.
289-func TestReloadScaleOnlyKeepsOverrides(t *testing.T) {
290- mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
291- file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
292- state := newOutputOverrides(file, false)
293- state.heads = ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}, {Info: ports.OutputInfo{Name: "DP-2"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
294- if _, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Pos: &image.Point{X: 100}}, {Name: "DP-2", Enabled: true}}}); err != nil {
295- t.Fatal(err)
296- }
297- scaled := file
298- scaled.Outputs = []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}, {Name: "DP-2", Scale: 1.25, ScaleOnly: true, SDRBrightness: ports.DefaultSDRBrightness}}
299- cfg := state.reload(scaled)
300- if len(cfg.Outputs) != 2 || cfg.Outputs[0].Pos == nil || cfg.Outputs[0].Scale != 1.5 || cfg.Outputs[1].Scale != 1.25 {
301- t.Fatalf("scale-only reload: %+v", cfg.Outputs)
302- }
303- other := scaled
304- other.Layout.Gaps = 4
305- if cfg := state.reload(other); cfg.Outputs[0].Pos != nil {
306- t.Fatalf("other reload kept overrides: %+v", cfg.Outputs)
307- }
308-}
309-
310-// A transform change takes the full reload path; a scale change alone does not.
311-func TestScaleChangesTransform(t *testing.T) {
312- old := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
313- cur := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2, Transform: 1}}}
314- if _, only := scaleChanges(old, cur); only {
315- t.Fatal("a transform change must take the full reload path")
316- }
317- cur.Outputs[0].Transform = 0
318- cur.Outputs[0].Scale = 1.5
319- if _, only := scaleChanges(old, cur); !only {
320- t.Fatal("a scale-only change stays a scale change")
321- }
322-}
323-
324-// A runtime transform lands in the effective configuration and survives a
325-// reload that only changes file scales.
326-func TestOutputApplyTransform(t *testing.T) {
327- mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
328- file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
329- state := newOutputOverrides(file, false)
330- state.heads = ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
331- tr := ports.BufferTransform(3)
332- cfg, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Transform: &tr}}})
333- if err != nil {
334- t.Fatal(err)
335- }
336- if len(cfg.Outputs) != 1 || cfg.Outputs[0].Transform != 3 {
337- t.Fatalf("applied transform: %+v", cfg.Outputs)
338- }
339- scaled := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}}
340- cfg = state.reload(scaled)
341- if cfg.Outputs[0].Transform != 3 || cfg.Outputs[0].Scale != 1.5 {
342- t.Fatalf("scale reload lost the runtime transform: %+v", cfg.Outputs)
343- }
344- // Without a transform in the change, the current one stays.
345- cfg, err = state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true}}})
346- if err != nil || cfg.Outputs[0].Transform != 3 {
347- t.Fatalf("unset transform changed: %+v, %v", cfg.Outputs, err)
348- }
349- bad := ports.BufferTransform(8)
350- if _, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Transform: &bad}}}); err == nil {
351- t.Fatal("transform 8 accepted")
352- }
353-}
354-
355-// A runtime position never replaces a file relation: it only takes
356-// precedence over it in core, and the relation is followed again once the
357-// override is dropped.
358-func TestOutputApplyKeepsFileAnchor(t *testing.T) {
359- mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
360- anchor := ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-1", Offset: 40}
361- file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-2", Anchor: anchor, ScaleOnly: true}}}
362- o := newOutputOverrides(file, false)
363- head := func(name string) ports.OutputHead {
364- return ports.OutputHead{Info: ports.OutputInfo{Name: name}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}
365- }
366- o.heads = ports.OutputHeads{Heads: []ports.OutputHead{head("DP-1"), head("DP-2")}}
367- change := func(name string, x int) ports.HeadChange {
368- return ports.HeadChange{Name: name, Enabled: true, Pos: &image.Point{X: x}}
369- }
370- byName := func(cfg ports.Config, name string) ports.OutputConfig {
371- t.Helper()
372- var found []ports.OutputConfig
373- for _, v := range cfg.Outputs {
374- if v.Name == name {
375- found = append(found, v)
376- }
377- }
378- if len(found) != 1 {
379- t.Fatalf("%s entries: %+v", name, cfg.Outputs)
380- }
381- return found[0]
382- }
383-
384- cfg, err := o.apply(ports.OutputApply{ID: 1, Heads: []ports.HeadChange{change("DP-1", 100), {Name: "DP-2", Enabled: true}}})
385- if err != nil {
386- t.Fatal(err)
387- }
388- dp1, dp2 := byName(cfg, "DP-1"), byName(cfg, "DP-2")
389- if dp1.Pos == nil || dp1.Pos.X != 100 || dp2.Pos != nil || dp2.Anchor != anchor {
390- t.Fatalf("position on the reference only: DP-1 %+v, DP-2 %+v", dp1, dp2)
391- }
392-
393- cfg, err = o.apply(ports.OutputApply{ID: 2, Heads: []ports.HeadChange{change("DP-1", 100), change("DP-2", 900)}})
394- if err != nil {
395- t.Fatal(err)
396- }
397- dp1, dp2 = byName(cfg, "DP-1"), byName(cfg, "DP-2")
398- if dp1.Pos == nil || dp1.Pos.X != 100 || dp2.Pos == nil || dp2.Pos.X != 900 || dp2.Anchor != anchor {
399- t.Fatalf("position on both: DP-1 %+v, DP-2 %+v", dp1, dp2)
400- }
401-
402- // A non scale-only reload drops the overrides: the file relation is back alone.
403- file.Keyboard.RepeatRate = 33
404- cfg = o.reload(file)
405- if dp2 = byName(cfg, "DP-2"); dp2.Pos != nil || dp2.Anchor != anchor || len(cfg.Outputs) != 1 {
406- t.Fatalf("after reload: %+v", cfg.Outputs)
407- }
408-}
M internal/app/outputs.go +1 -1
@@ -6,7 +6,7 @@ import (
66 "sync"
77 "time"
88
9- "github.com/bnema/neferwl/internal/adapters/capture"
9+ "github.com/bnema/neferwl/internal/adapters/outputkit/capture"
1010
1111 "github.com/bnema/neferwl/internal/ports"
1212 )
M internal/app/session.go +1 -1
@@ -335,7 +335,7 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent))
335335 go func() {
336336 defer workers.Done()
337337 outputIO := p.outputs(security)
338- apply := newOutputApply(newOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))
338+ apply := newOutputApply(core.NewOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))
339339 if hw != nil {
340340 done <- safe("output", func() error {
341341 want := func(cfg ports.Config) drm.Want {
A internal/core/client.go +170 -0
@@ -0,0 +1,170 @@
1+package core
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// clientEvent applies one client event. publish asks for a new scene;
10+// stop ends Run (a command could not be delivered).
11+func (c *Core) clientEvent(ctx context.Context, ev ports.ClientEvent) (publish, stop bool) {
12+ switch v := ev.(type) {
13+ case ports.SessionLockChanged:
14+ if !c.applyLockChanged(v) {
15+ return false, false
16+ }
17+ case ports.CaptureSessionOpen:
18+ c.captureOpen(v)
19+ case ports.CaptureSessionClose:
20+ c.captureClose(v.ID)
21+ case ports.CaptureFrameTaken:
22+ // Nothing new to show (the target flashes already): no scene.
23+ if !c.captureFrame(v) {
24+ return false, false
25+ }
26+ case ports.CaptureExclusionBegin:
27+ c.captureExclusionBegin(v)
28+ case ports.CaptureExclusionLayer:
29+ c.captureExclusionLayer(v)
30+ case ports.CaptureExclusionEnd:
31+ c.captureExclusionEnd(v.Session)
32+ case ports.LayerChanged:
33+ c.layerChanged = true
34+ c.setLayers(v.Layers)
35+ case ports.InputRegionChanged:
36+ c.windows.setRegion(v)
37+ case ports.WindowMapped:
38+ c.mapWindow(v)
39+ case ports.WindowResized:
40+ c.resizeFloating(v)
41+ case ports.PopupRequest:
42+ if err := c.placePopup(ctx, v); err != nil {
43+ return false, true
44+ }
45+ case ports.PopupMapped:
46+ if p := c.popups[v.ID]; p != nil {
47+ p.mapped = true
48+ }
49+ case ports.ShortcutsInhibit:
50+ c.windows.setInhibitShortcuts(v.Window, v.Active)
51+ if err := c.updateInhibit(ctx); err != nil {
52+ return false, true
53+ }
54+ return false, false
55+ case ports.OutputPower:
56+ if i := c.screenIndex(v.Output); i >= 0 && c.screens[i].off == v.On {
57+ c.screens[i].off = !v.On
58+ }
59+ case ports.IdleInhibit:
60+ c.windows.setIdleInhibit(v.Window, v.Active)
61+ c.publishState()
62+ return false, false
63+ case ports.WindowAppID:
64+ c.windows.setAppID(v.ID, v.AppID)
65+ case ports.WindowParent:
66+ if _, w := c.screenOf(v.ID); w != nil {
67+ w.SetDialogParent(v.ID, v.Parent)
68+ }
69+ case ports.WindowUnmapped:
70+ if c.unmapClient(ctx, v) != nil {
71+ return false, true
72+ }
73+ case ports.PointerConstrained:
74+ c.constrained = v
75+ case ports.PointerWarp:
76+ if c.warpPointer(ctx, v) != nil {
77+ return false, true
78+ }
79+ case ports.WindowMoveRequest:
80+ if c.clientDrag(ctx, v) != nil {
81+ return false, true
82+ }
83+ case ports.WindowFullscreenRequest:
84+ if c.fullscreenRequest(ctx, v) != nil {
85+ return false, true
86+ }
87+ case ports.WorkspaceActivate:
88+ if c.activateWorkspaces(ctx, v) != nil {
89+ return false, true
90+ }
91+ case ports.WindowActivate:
92+ if c.activate(ctx, v.ID) != nil {
93+ return false, true
94+ }
95+ }
96+ return true, false
97+}
98+
99+// unmapClient drops a window or popup the client unmapped.
100+func (c *Core) unmapClient(ctx context.Context, v ports.WindowUnmapped) error {
101+ if c.popups[v.ID] != nil {
102+ if err := c.dropPopup(ctx, v.ID); err != nil {
103+ return err
104+ }
105+ }
106+ if err := c.closePopupsOf(ctx, v.ID); err != nil {
107+ return err
108+ }
109+ c.windows.drop(v.ID)
110+ if c.drag != nil && c.drag.id == v.ID {
111+ c.abortDrag()
112+ }
113+ c.unmapWindow(v)
114+ c.releaseSlots()
115+ if c.pointer == v.ID {
116+ c.pointer = 0
117+ if err := c.command(ctx, ports.PointerFocus{}); err != nil {
118+ return err
119+ }
120+ }
121+ return nil
122+}
123+
124+// fullscreenRequest answers xdg_toplevel set/unset_fullscreen, from the
125+// window itself or a taskbar (External).
126+func (c *Core) fullscreenRequest(ctx context.Context, v ports.WindowFullscreenRequest) error {
127+ c.configures.answer[v.ID] = true
128+ if v.Fullscreen && !v.External && c.now().Sub(c.windows.lookup(v.ID).mappedAt) < fullscreenGrace {
129+ return nil
130+ }
131+ // A taskbar request is a user action on that window, as an
132+ // activation: it comes on screen with the focus, leaving
133+ // another window's fullscreen.
134+ if v.External && v.Fullscreen {
135+ if err := c.activate(ctx, v.ID); err != nil {
136+ return err
137+ }
138+ }
139+ if s, _ := c.screenOf(v.ID); s != nil {
140+ s.mon.SetFullscreen(v.ID, v.Fullscreen)
141+ if w := s.mon.Current(); v.Fullscreen && w.fullscreen == v.ID {
142+ // A taskbar or late fullscreen request that took
143+ // effect: the window does not fade in, it keeps the
144+ // direct scanout path. One that was ignored (the
145+ // grace after the map) leaves the entrance running.
146+ delete(s.rects, v.ID)
147+ }
148+ }
149+ return nil
150+}
151+
152+// activateWorkspaces shows the workspaces of v by stable ID, in order.
153+func (c *Core) activateWorkspaces(ctx context.Context, v ports.WorkspaceActivate) error {
154+ before := c.cur().mon.Current()
155+ for _, id := range v.IDs {
156+ for i, sc := range c.screens {
157+ if sc.name() == "" {
158+ continue
159+ }
160+ for w := range sc.mon.all() {
161+ if w.ID == id {
162+ c.focusScreen = i
163+ sc.mon.show(w)
164+ break
165+ }
166+ }
167+ }
168+ }
169+ return c.workspaceVisible(ctx, c.cur().mon.Current() != before)
170+}
M internal/core/core.go +24 -272
@@ -76,9 +76,7 @@ type Core struct {
7676 // workspace while overviewReal is filled) and by state(); never retained.
7777 realBuf []Placement
7878 security ports.SecurityState
79- lockSurfaces []ports.LockSurfacePlacement
80- lockFocus WindowID
81- lockPinned bool // lockFocus was clicked or typed into: it keeps focus
79+ lock lockState
8280 inputKeys map[string]bool
8381 inputActive bool
8482 startup [][]string
@@ -115,18 +113,15 @@ type Core struct {
115113 seq uint64
116114 // layerChanged is set once layer state arrives from wayland.
117115 layerChanged bool
118- sentOutputs ports.SetOutputs
116+ sent sentCache
119117 specs []NamedWorkspace
120118 presets []Width
121119 slots map[slotKey]*slotState
122120 placement spawnPlacement
123121 // toSpawn holds slots to start; Run sends them (apply has no context).
124122 toSpawn []slotKey
125- sentPending bool
126123 firstTerminalResolved bool
127124 windows windowRegistry
128- sentState ports.State
129- sentWorkspaces ports.Workspaces
130125 // popups are the placed xdg_popups; popupOrder stacks them, oldest first.
131126 popups map[WindowID]*popupState
132127 popupOrder []WindowID
@@ -154,6 +149,15 @@ type Core struct {
154149 pulse focusPulse
155150 }
156151
152+// sentCache is what core last sent to wayland and on its latest-only
153+// channels; an unchanged value is not sent again.
154+type sentCache struct {
155+ outputs ports.SetOutputs
156+ state ports.State
157+ workspaces ports.Workspaces
158+ pending bool // spawn placement pending
159+}
160+
157161 func keyName(s string) string {
158162 if len(s) == 1 && unicode.IsLetter(rune(s[0])) {
159163 return strings.ToLower(s)
@@ -314,11 +318,11 @@ func (c *Core) apply(cfg ports.Config) error {
314318 // publishPending tells wayland at once when slots start waiting, before
315319 // their windows can map.
316320 func (c *Core) publishPending(ctx context.Context) error {
317- if p := c.placement.anyPending(); p != c.sentPending {
321+ if p := c.placement.anyPending(); p != c.sent.pending {
318322 if err := c.command(ctx, ports.SlotsPending{Pending: p}); err != nil {
319323 return err
320324 }
321- c.sentPending = p
325+ c.sent.pending = p
322326 }
323327 return nil
324328 }
@@ -587,134 +591,11 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
587591 // Before the first output (and after the last is unplugged) the
588592 // placeholder's scene has no output name: no renderer draws it.
589593 for i, sc := range c.screens {
590- o := sc.mon.Output()
591- // frame is the viewport of the workspace on screen: the whole output
592- // unless it has a size override (never in the overview).
593- frame := sc.mon.Frame()
594- var clip Rect
595- if frame != (Rect{W: o.W, H: o.H}) {
596- clip = frame
597- }
598- // layout is what is drawn; settled (same indexes) is where the
599- // windows are going, and alone sizes the configures.
600- layout, settled := sc.shown, sc.settledLayout
601- scene := ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)), Layers: shownLayers(sc)}
602- var real map[WindowID]Placement
603- if sc.mon.ov.open {
604- // Local to this screen's build: reused, cleared each time.
605- if c.overviewReal == nil {
606- c.overviewReal = make(map[WindowID]Placement)
607- }
608- clear(c.overviewReal)
609- real = c.overviewReal
610- for w := range sc.mon.all() {
611- c.realBuf = w.layoutInto(c.realBuf)
612- for _, p := range c.realBuf {
613- real[p.ID] = p
614- }
615- }
616- }
617- scene.Dim = floatDim(layout, frame, c.cfg.Floating.Dim)
618- if sc.mon.ov.open {
619- // Darken the wallpaper around the previews.
620- scene.Dim = c.cfg.Floating.Dim
621- scene.DimBehind = scene.Dim > 0
622- }
623- if d := c.drag; d != nil && d.target.screen == sc && d.target.kind != dropNone {
624- scene.DropHints = slices.Clone(d.target.hints)
625- }
626- if sc.mon.ov.open {
627- // Frame the selection and separate numbered and named row groups.
628- _, scene.Separators = sc.mon.overviewRows()
629- scene.Separators = append(scene.Separators, overviewOutline(layout, max(c.cfg.Border.Width, 2))...)
630- } else {
631- // Only the focused output lights the focused window's lines.
632- scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen)
633- }
634- // A window alone on screen needs no pulse to show it has the focus.
635- // A tile under a panel (the next cascade band) does not count.
636- alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, sc.mon.Current().Usable) == 1
637- for k, p := range layout {
638- ps := settled[k]
639- // Only the focused output has an activated window.
640- focused := p.Focused && i == c.focusScreen
641- sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade}
642- if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) {
643- // A scale motion: the content follows the drawn size. A
644- // card in flight drawn at its size still needs Zoom 1:
645- // without it the renderer would shrink it by Preview.
646- sw.Zoom = p.Zoom
647- }
648- // A peek's veil is the configured one plus its animated offset.
649- sw.Dim = max(0, min(p.Dim+c.peekDim(p), 1))
650- if p.Leaving {
651- // A window fading out after it closed or hid: drawn, but
652- // hidden to everything else, its configures included.
653- sw.Hidden = false
654- scene.Windows = append(scene.Windows, sw)
655- // A window the workspace still holds (a hidden stash
656- // window) keeps its last configure: prune would forget it,
657- // and its next one would start from nothing. A closed one
658- // was forgotten at its unmap and stays so.
659- if w, _ := sc.mon.find(p.ID); w != nil {
660- c.configures.keep(p.ID)
661- }
662- continue
663- }
664- if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open {
665- drawable = true
666- if p.ID == c.pulse.id {
667- sw.FocusEffect = pulse
668- }
669- }
670- scene.Windows = append(scene.Windows, sw)
671- t := configureTarget{output: sc.name(), area: frame, focused: focused, captured: capture != nil && capture.window == p.ID}
672- if !p.Hidden && p.Preview == 0 {
673- // Only a sized configure needs the client size.
674- t.client, t.imposed = c.clientRect(ps), sc.mon.Current().imposedFloat(p.ID)
675- } else if t.captured && p.Hidden {
676- // A captured hidden window is sized like its capture.
677- t.client = capture.windowSz
678- } else if p.Preview > 0 && !p.Hidden {
679- if rp, ok := real[p.ID]; ok && !rp.Hidden {
680- t.realTiled = !rp.Floating
681- t.client = c.clientRect(rp)
682- }
683- }
684- cp, t := c.captureConfigure(sc, ps, t)
685- if v, send := c.configures.nextWithCapture(ps, t, cp); send {
686- if err := c.command(ctx, v); err != nil {
687- return err
688- }
689- c.configures.mark(v)
690- }
691- }
692- scene.Windows = append(scene.Windows, c.scenePopups(sc)...)
693- scene.CaptureIndicators = c.captureIndicators(sc)
694- scene.Capture = c.captureSceneFor(sc, capture)
695- // A scene carrying a capture image always gets a fresh Seq, and so
696- // does one of an animating screen (the one being stepped, or any
697- // when none is): the outputs drop a scene they already show, and a
698- // spring that starts at the previous scene, or rounds to it in its
699- // tail, would get no flip and wait for the fallback timer. Any other
700- // keeps its output's Seq while it draws the same, so an idle output
701- // is not recomposed.
702- withCapture := scene.Capture != nil && (sc == capture.hiddenScr || sc == capture.windowScr)
703- animating := (sc.springing() || c.pulsing(sc)) && (only == nil || only == sc)
704- if !withCapture && !animating && sc.last.Seq != 0 && scene.SameAs(sc.last) {
705- scene.Seq = sc.last.Seq
706- } else {
707- c.seq++
708- scene.Seq = c.seq
709- }
710- if withCapture {
711- switch sc {
712- case capture.hiddenScr:
713- scene.CaptureScene = c.captureScene(scene.Seq)
714- case capture.windowScr:
715- scene.CaptureScene = c.captureWindowScene(capture, scene.Seq)
716- }
594+ scene, shown, err := c.sceneFor(ctx, i, sc, only, capture, pulse)
595+ if err != nil {
596+ return err
717597 }
598+ drawable = drawable || shown
718599 sc.last = scene
719600 scenes = append(scenes, scene)
720601 }
@@ -958,16 +839,16 @@ func (c *Core) warpPointer(ctx context.Context, v ports.PointerWarp) error {
958839 // geometry only when the outputs themselves moved.
959840 func (c *Core) syncOutputs(ctx context.Context) error {
960841 v := ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()}
961- if sameOutputs(v, c.sentOutputs) {
842+ if sameOutputs(v, c.sent.outputs) {
962843 return nil
963844 }
964845 if err := c.command(ctx, v); err != nil {
965846 return err
966847 }
967- if c.ch.Layouts != nil && !slices.Equal(v.Outputs, c.sentOutputs.Outputs) {
848+ if c.ch.Layouts != nil && !slices.Equal(v.Outputs, c.sent.outputs.Outputs) {
968849 latest(c.ch.Layouts, v.Outputs)
969850 }
970- c.sentOutputs = v
851+ c.sent.outputs = v
971852 return nil
972853 }
973854
@@ -1117,140 +998,11 @@ func (c *Core) Run(ctx context.Context) error {
1117998 if c.blockProtected(ev) {
1118999 continue
11191000 }
1120- switch v := ev.(type) {
1121- case ports.SessionLockChanged:
1122- if !c.applyLockChanged(v) {
1123- continue
1124- }
1125- case ports.CaptureSessionOpen:
1126- c.captureOpen(v)
1127- case ports.CaptureSessionClose:
1128- c.captureClose(v.ID)
1129- case ports.CaptureFrameTaken:
1130- // Nothing new to show (the target flashes already): no scene.
1131- if !c.captureFrame(v) {
1132- continue
1133- }
1134- case ports.CaptureExclusionBegin:
1135- c.captureExclusionBegin(v)
1136- case ports.CaptureExclusionLayer:
1137- c.captureExclusionLayer(v)
1138- case ports.CaptureExclusionEnd:
1139- c.captureExclusionEnd(v.Session)
1140- case ports.LayerChanged:
1141- c.layerChanged = true
1142- c.setLayers(v.Layers)
1143- case ports.InputRegionChanged:
1144- c.windows.setRegion(v)
1145- case ports.WindowMapped:
1146- c.mapWindow(v)
1147- case ports.WindowResized:
1148- c.resizeFloating(v)
1149- case ports.PopupRequest:
1150- if err := c.placePopup(ctx, v); err != nil {
1151- return nil
1152- }
1153- case ports.PopupMapped:
1154- if p := c.popups[v.ID]; p != nil {
1155- p.mapped = true
1156- }
1157- case ports.ShortcutsInhibit:
1158- c.windows.setInhibitShortcuts(v.Window, v.Active)
1159- if err := c.updateInhibit(ctx); err != nil {
1160- return nil
1161- }
1162- continue
1163- case ports.OutputPower:
1164- if i := c.screenIndex(v.Output); i >= 0 && c.screens[i].off == v.On {
1165- c.screens[i].off = !v.On
1166- }
1167- case ports.IdleInhibit:
1168- c.windows.setIdleInhibit(v.Window, v.Active)
1169- c.publishState()
1170- continue
1171- case ports.WindowAppID:
1172- c.windows.setAppID(v.ID, v.AppID)
1173- case ports.WindowParent:
1174- if _, w := c.screenOf(v.ID); w != nil {
1175- w.SetDialogParent(v.ID, v.Parent)
1176- }
1177- case ports.WindowUnmapped:
1178- if c.popups[v.ID] != nil {
1179- if err := c.dropPopup(ctx, v.ID); err != nil {
1180- return nil
1181- }
1182- }
1183- if err := c.closePopupsOf(ctx, v.ID); err != nil {
1184- return nil
1185- }
1186- c.windows.drop(v.ID)
1187- if c.drag != nil && c.drag.id == v.ID {
1188- c.abortDrag()
1189- }
1190- c.unmapWindow(v)
1191- c.releaseSlots()
1192- if c.pointer == v.ID {
1193- c.pointer = 0
1194- if err := c.command(ctx, ports.PointerFocus{}); err != nil {
1195- return nil
1196- }
1197- }
1198- case ports.PointerConstrained:
1199- c.constrained = v
1200- case ports.PointerWarp:
1201- if c.warpPointer(ctx, v) != nil {
1202- return nil
1203- }
1204- case ports.WindowMoveRequest:
1205- if c.clientDrag(ctx, v) != nil {
1206- return nil
1207- }
1208- case ports.WindowFullscreenRequest:
1209- c.configures.answer[v.ID] = true
1210- if v.Fullscreen && !v.External && c.now().Sub(c.windows.lookup(v.ID).mappedAt) < fullscreenGrace {
1211- break
1212- }
1213- // A taskbar request is a user action on that window, as an
1214- // activation: it comes on screen with the focus, leaving
1215- // another window's fullscreen.
1216- if v.External && v.Fullscreen && c.activate(ctx, v.ID) != nil {
1217- return nil
1218- }
1219- if s, _ := c.screenOf(v.ID); s != nil {
1220- s.mon.SetFullscreen(v.ID, v.Fullscreen)
1221- if w := s.mon.Current(); v.Fullscreen && w.fullscreen == v.ID {
1222- // A taskbar or late fullscreen request that took
1223- // effect: the window does not fade in, it keeps the
1224- // direct scanout path. One that was ignored (the
1225- // grace after the map) leaves the entrance running.
1226- delete(s.rects, v.ID)
1227- }
1228- }
1229- case ports.WorkspaceActivate:
1230- before := c.cur().mon.Current()
1231- for _, id := range v.IDs {
1232- for i, sc := range c.screens {
1233- if sc.name() == "" {
1234- continue
1235- }
1236- for w := range sc.mon.all() {
1237- if w.ID == id {
1238- c.focusScreen = i
1239- sc.mon.show(w)
1240- break
1241- }
1242- }
1243- }
1244- }
1245- if c.workspaceVisible(ctx, c.cur().mon.Current() != before) != nil {
1246- return nil
1247- }
1248- case ports.WindowActivate:
1249- if c.activate(ctx, v.ID) != nil {
1250- return nil
1251- }
1001+ publish, stop := c.clientEvent(ctx, ev)
1002+ if stop {
1003+ return nil
12521004 }
1253- if err := c.publish(ctx); err != nil {
1005+ if publish && c.publish(ctx) != nil {
12541006 return nil
12551007 }
12561008 case f, ok := <-c.ch.Frames:
M internal/core/input.go +220 -200
@@ -7,6 +7,8 @@ import (
77 "github.com/bnema/neferwl/internal/ports"
88 )
99
10+// handleInput dispatches one input event to its handler. A protected
11+// session routes every event to protectedInput instead.
1012 func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
1113 c.inputActive = true
1214 defer func() { c.inputActive = false }()
@@ -18,173 +20,187 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
1820 }
1921 switch v := ev.(type) {
2022 case ports.PointerMotion:
21- c.motionTime = v.Time
22- // A locked pointer stays still; relative motion still flows.
23- if c.constraint.Mode != ports.ConstraintLock {
24- c.cursorX, c.cursorY = c.constraint.Clamp(c.clampPointer(c.cursorX, c.cursorY, v.X, v.Y))
25- }
26- // The focused screen follows the pointer, so new windows
27- // and launchers open where the user is.
28- // Only a pointer entering another output switches: keyboard
29- // moves to another screen stick until then. Not mid-drag of a
30- // client; a window drag (drag.go) follows the pointer.
31- if i := c.screenAt(c.cursorX, c.cursorY); i >= 0 && c.screens[i].name() != c.pointerOutput && c.grab == 0 {
32- name := c.screens[i].name()
33- c.pointerOutput = name
34- if name != c.cur().name() {
35- c.focusScreen = i
36- if err := c.publish(ctx); err != nil {
37- return err
38- }
39- }
23+ return c.pointerMotion(ctx, v)
24+ case ports.PointerButton:
25+ return c.pointerButton(ctx, v)
26+ case ports.PointerAxis:
27+ return c.pointerAxis(ctx, v)
28+ case ports.SwipeBegin:
29+ if c.swipeBegin(v) {
30+ return c.slid(ctx, false)
4031 }
41- if c.drag != nil {
42- // The dragged window and the others get no pointer events.
43- return c.dragMotion(ctx, v.Time)
32+ return nil
33+ case ports.SwipeUpdate:
34+ before := c.cur().mon.Current()
35+ if c.swipeUpdate(v) {
36+ return c.slid(ctx, c.cur().mon.Current() != before)
4437 }
45- id, x, y := c.hit(c.cursorX, c.cursorY)
46- // Layout changes are intentionally re-hit-tested only on motion.
47- if id != c.pointer {
48- c.pointer = id
49- if err := c.command(ctx, ports.PointerFocus{ID: id, X: x, Y: y}); err != nil {
38+ return nil
39+ case ports.SwipeEnd:
40+ return c.slid(ctx, c.swipeEnd(v))
41+ case ports.KeyEvent:
42+ return c.keyEvent(ctx, v)
43+ }
44+ return nil
45+}
46+
47+func (c *Core) pointerMotion(ctx context.Context, v ports.PointerMotion) error {
48+ c.motionTime = v.Time
49+ // A locked pointer stays still; relative motion still flows.
50+ if c.constraint.Mode != ports.ConstraintLock {
51+ c.cursorX, c.cursorY = c.constraint.Clamp(c.clampPointer(c.cursorX, c.cursorY, v.X, v.Y))
52+ }
53+ // The focused screen follows the pointer, so new windows
54+ // and launchers open where the user is.
55+ // Only a pointer entering another output switches: keyboard
56+ // moves to another screen stick until then. Not mid-drag of a
57+ // client; a window drag (drag.go) follows the pointer.
58+ if i := c.screenAt(c.cursorX, c.cursorY); i >= 0 && c.screens[i].name() != c.pointerOutput && c.grab == 0 {
59+ name := c.screens[i].name()
60+ c.pointerOutput = name
61+ if name != c.cur().name() {
62+ c.focusScreen = i
63+ if err := c.publish(ctx); err != nil {
5064 return err
5165 }
5266 }
53- c.pointerAt = [2]float64{x, y}
54- if id != 0 {
55- if err := c.command(ctx, ports.PointerMotionTo{ID: id, X: x, Y: y, DX: v.DX, DY: v.DY, UnaccelDX: v.UnaccelDX, UnaccelDY: v.UnaccelDY, Time: v.Time}); err != nil {
56- return err
57- }
67+ }
68+ if c.drag != nil {
69+ // The dragged window and the others get no pointer events.
70+ return c.dragMotion(ctx, v.Time)
71+ }
72+ id, x, y := c.hit(c.cursorX, c.cursorY)
73+ // Layout changes are intentionally re-hit-tested only on motion.
74+ if id != c.pointer {
75+ c.pointer = id
76+ if err := c.command(ctx, ports.PointerFocus{ID: id, X: x, Y: y}); err != nil {
77+ return err
78+ }
79+ }
80+ c.pointerAt = [2]float64{x, y}
81+ if id != 0 {
82+ if err := c.command(ctx, ports.PointerMotionTo{ID: id, X: x, Y: y, DX: v.DX, DY: v.DY, UnaccelDX: v.UnaccelDX, UnaccelDY: v.UnaccelDY, Time: v.Time}); err != nil {
83+ return err
5884 }
85+ }
86+ return nil
87+}
88+
89+func (c *Core) pointerButton(ctx context.Context, v ports.PointerButton) error {
90+ // Buttons a drag took stay in c.buttons until released, so the
91+ // security gate admits their release; no client sees it.
92+ if !v.Pressed && c.swallow[v.Button] {
93+ delete(c.swallow, v.Button)
94+ delete(c.buttons, v.Button)
5995 return nil
60- case ports.PointerButton:
61- // Buttons a drag took stay in c.buttons until released, so the
62- // security gate admits their release; no client sees it.
63- if !v.Pressed && c.swallow[v.Button] {
64- delete(c.swallow, v.Button)
96+ }
97+ if d := c.drag; d != nil {
98+ if !v.Pressed && v.Button == d.button {
6599 delete(c.buttons, v.Button)
66- return nil
100+ return c.endDrag(ctx)
67101 }
68- if d := c.drag; d != nil {
69- if !v.Pressed && v.Button == d.button {
70- delete(c.buttons, v.Button)
71- return c.endDrag(ctx)
72- }
73- // Other buttons do nothing mid-drag; their releases neither.
74- if v.Pressed {
75- c.buttons[v.Button] = true
76- c.swallow[v.Button] = true
77- }
102+ // Other buttons do nothing mid-drag; their releases neither.
103+ if v.Pressed {
104+ c.buttons[v.Button] = true
105+ c.swallow[v.Button] = true
106+ }
107+ return nil
108+ }
109+ if started, err := c.startDrag(ctx, v); err != nil || started {
110+ return err
111+ }
112+ if v.Pressed && c.pointer == 0 && c.grab == 0 && c.held() == 0 {
113+ if picked, err := c.overviewClick(ctx); err != nil {
114+ return err
115+ } else if picked {
78116 return nil
79117 }
80- if started, err := c.startDrag(ctx, v); err != nil || started {
118+ }
119+ id := c.pointer
120+ if c.grab != 0 {
121+ id = c.grab
122+ }
123+ if v.Pressed && c.held() == 0 {
124+ // A click outside an open menu closes it.
125+ if err := c.dismissGrabs(ctx, id); err != nil {
81126 return err
82127 }
83- if v.Pressed && c.pointer == 0 && c.grab == 0 && c.held() == 0 {
84- if picked, err := c.overviewClick(ctx); err != nil {
85- return err
86- } else if picked {
87- return nil
88- }
128+ }
129+ if v.Pressed {
130+ if c.held() == 0 {
131+ c.grab = id
89132 }
90- id := c.pointer
91- if c.grab != 0 {
92- id = c.grab
133+ c.buttons[v.Button] = true
134+ c.lastButton = v.Button
135+ } else {
136+ delete(c.buttons, v.Button)
137+ }
138+ if id != 0 {
139+ if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, Time: v.Time}); err != nil {
140+ return err
93141 }
94- if v.Pressed && c.held() == 0 {
95- // A click outside an open menu closes it.
96- if err := c.dismissGrabs(ctx, id); err != nil {
142+ // A click on an on-demand layer gives it the keyboard.
143+ if v.Pressed && c.onDemand(id) && c.keyboard.clickLayer(id, c.windowFocus()) {
144+ if err := c.publish(ctx); err != nil {
97145 return err
98146 }
99- }
100- if v.Pressed {
101- if c.held() == 0 {
102- c.grab = id
103- }
104- c.buttons[v.Button] = true
105- c.lastButton = v.Button
106- } else {
107- delete(c.buttons, v.Button)
108- }
109- if id != 0 {
110- if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, Time: v.Time}); err != nil {
147+ } else if l := c.keyboard.layer; v.Pressed && l != 0 && id != l && c.popupRoot(id) != l {
148+ // A click anywhere else takes the keyboard back.
149+ c.keyboard.takeBack()
150+ if err := c.publish(ctx); err != nil {
111151 return err
112152 }
113- // A click on an on-demand layer gives it the keyboard.
114- if v.Pressed && c.onDemand(id) && c.keyboard.clickLayer(id, c.windowFocus()) {
115- if err := c.publish(ctx); err != nil {
116- return err
117- }
118- } else if l := c.keyboard.layer; v.Pressed && l != 0 && id != l && c.popupRoot(id) != l {
119- // A click anywhere else takes the keyboard back.
120- c.keyboard.takeBack()
121- if err := c.publish(ctx); err != nil {
122- return err
123- }
124- }
125- // A click focuses the window and its output.
126- s, w := c.screenOf(id)
127- if v.Pressed && s != nil && w == s.mon.Current() && (c.keyboard.sent != id || s != c.cur()) {
128- now := c.now()
129- before := c.snapshot(now)
130- w.Click(id)
131- c.focusScreen = c.screenIndex(s.name())
132- c.transition(before, now)
133- if err := c.publish(ctx); err != nil {
134- return err
135- }
136- }
137- }
138- if c.held() == 0 {
139- c.grab = 0
140153 }
141- return nil
142- case ports.PointerAxis:
143- c.scrollStop(v)
144- // Only wheel/continuous scrolling navigates the overview.
145- // Two-finger scrolling belongs to clients, never to navigation.
146- if v.Source != ports.AxisFinger && c.cur().mon.ov.open && !c.overviewKeyboardTaken() {
147- // overviewScroll owns which scrolls step; the shot costs no
148- // allocation once warm.
154+ // A click focuses the window and its output.
155+ s, w := c.screenOf(id)
156+ if v.Pressed && s != nil && w == s.mon.Current() && (c.keyboard.sent != id || s != c.cur()) {
149157 now := c.now()
150- shots := c.snapshot(now)
151- if c.cur().mon.overviewScroll(v) {
152- c.transition(shots, now)
153- if err := c.workspaceVisible(ctx, true); err != nil {
154- return err
155- }
156- if err := c.publish(ctx); err != nil {
157- return err
158- }
158+ before := c.snapshot(now)
159+ w.Click(id)
160+ c.focusScreen = c.screenIndex(s.name())
161+ c.transition(before, now)
162+ if err := c.publish(ctx); err != nil {
163+ return err
159164 }
160- return nil
161165 }
162- // Scroll goes to the window under the pointer, which has the
163- // pointer focus even mid-drag. None during a window drag.
164- if c.pointer != 0 && c.drag == nil {
165- if err := c.command(ctx, ports.PointerAxisTo{ID: c.pointer, Axis: v}); err != nil {
166+ }
167+ if c.held() == 0 {
168+ c.grab = 0
169+ }
170+ return nil
171+}
172+
173+func (c *Core) pointerAxis(ctx context.Context, v ports.PointerAxis) error {
174+ c.scrollStop(v)
175+ // Only wheel/continuous scrolling navigates the overview.
176+ // Two-finger scrolling belongs to clients, never to navigation.
177+ if v.Source != ports.AxisFinger && c.cur().mon.ov.open && !c.overviewKeyboardTaken() {
178+ // overviewScroll owns which scrolls step; the shot costs no
179+ // allocation once warm.
180+ now := c.now()
181+ shots := c.snapshot(now)
182+ if c.cur().mon.overviewScroll(v) {
183+ c.transition(shots, now)
184+ if err := c.workspaceVisible(ctx, true); err != nil {
185+ return err
186+ }
187+ if err := c.publish(ctx); err != nil {
166188 return err
167189 }
168190 }
169191 return nil
170- case ports.SwipeBegin:
171- if c.swipeBegin(v) {
172- return c.slid(ctx, false)
173- }
174- return nil
175- case ports.SwipeUpdate:
176- before := c.cur().mon.Current()
177- if c.swipeUpdate(v) {
178- return c.slid(ctx, c.cur().mon.Current() != before)
179- }
180- return nil
181- case ports.SwipeEnd:
182- return c.slid(ctx, c.swipeEnd(v))
183192 }
184- key, ok := ev.(ports.KeyEvent)
185- if !ok {
186- return nil
193+ // Scroll goes to the window under the pointer, which has the
194+ // pointer focus even mid-drag. None during a window drag.
195+ if c.pointer != 0 && c.drag == nil {
196+ if err := c.command(ctx, ports.PointerAxisTo{ID: c.pointer, Axis: v}); err != nil {
197+ return err
198+ }
187199 }
200+ return nil
201+}
202+
203+func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error {
188204 c.mods = key.Mods
189205 if c.drag != nil && key.Pressed && key.Keysym == "Escape" {
190206 // Escape cancels the drag; its release goes nowhere either.
@@ -253,68 +269,7 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
253269 if key.Pressed {
254270 if bound {
255271 c.pressed[held] = true
256- if action == ActionScaleUp || action == ActionScaleDown {
257- dir := 1
258- if action == ActionScaleDown {
259- dir = -1
260- }
261- sc := c.cur()
262- prev := sc.scale
263- sc.setScale(StepScale(sc.info.Width, sc.info.Height, sc.scale, dir))
264- c.order()
265- if c.ch.Scales != nil && sc.scale != prev && sc.name() != "" {
266- // Never wait on persistence: a stalled save drops the change.
267- select {
268- case c.ch.Scales <- ports.ScaleChanged{Output: sc.name(), Scale: sc.scale}:
269- default:
270- }
271- }
272- if err := c.publish(ctx); err != nil {
273- return err
274- }
275- return nil
276- }
277- before := c.cur().mon.Current()
278- swiped := c.swipedWorkspace()
279- now := c.now()
280- shots := c.snapshot(now)
281- c.keyboard.takeBack() // a bind acts on the windows
282- effect := c.applyAction(action)
283- if effect.Quit {
284- return ErrQuit
285- }
286- if effect.Spawn {
287- commands := effect.Commands
288- if commands == nil {
289- commands = [][]string{append([]string(nil), c.cfg.Terminal.Command...)}
290- }
291- // Explicit actions are suppressed on a full launcher queue;
292- // never keep the input owner waiting across an epoch change.
293- for _, argv := range commands {
294- if !c.trySpawn(ctx, ports.SpawnRequest{Argv: argv}) {
295- break
296- }
297- }
298- if c.inputEpochChanged() {
299- return errSecurityChanged
300- }
301- }
302- if c.swipedWorkspace() != swiped {
303- c.dropSwipe()
304- }
305- c.transition(shots, now)
306- if err := c.workspaceVisible(ctx, c.cur().mon.Current() != before); err != nil {
307- return err
308- }
309- if effect.Close != 0 {
310- if err := c.command(ctx, ports.CloseWindow{ID: effect.Close}); err != nil {
311- return err
312- }
313- }
314- if err := c.publish(ctx); err != nil {
315- return err
316- }
317- return nil
272+ return c.runBind(ctx, action)
318273 }
319274 c.pressed[held] = false
320275 }
@@ -325,3 +280,68 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
325280 }
326281 return nil
327282 }
283+
284+// runBind runs the action of a bound key press.
285+func (c *Core) runBind(ctx context.Context, action Action) error {
286+ if action == ActionScaleUp || action == ActionScaleDown {
287+ return c.scaleBind(ctx, action)
288+ }
289+ before := c.cur().mon.Current()
290+ swiped := c.swipedWorkspace()
291+ now := c.now()
292+ shots := c.snapshot(now)
293+ c.keyboard.takeBack() // a bind acts on the windows
294+ effect := c.applyAction(action)
295+ if effect.Quit {
296+ return ErrQuit
297+ }
298+ if effect.Spawn {
299+ commands := effect.Commands
300+ if commands == nil {
301+ commands = [][]string{append([]string(nil), c.cfg.Terminal.Command...)}
302+ }
303+ // Explicit actions are suppressed on a full launcher queue;
304+ // never keep the input owner waiting across an epoch change.
305+ for _, argv := range commands {
306+ if !c.trySpawn(ctx, ports.SpawnRequest{Argv: argv}) {
307+ break
308+ }
309+ }
310+ if c.inputEpochChanged() {
311+ return errSecurityChanged
312+ }
313+ }
314+ if c.swipedWorkspace() != swiped {
315+ c.dropSwipe()
316+ }
317+ c.transition(shots, now)
318+ if err := c.workspaceVisible(ctx, c.cur().mon.Current() != before); err != nil {
319+ return err
320+ }
321+ if effect.Close != 0 {
322+ if err := c.command(ctx, ports.CloseWindow{ID: effect.Close}); err != nil {
323+ return err
324+ }
325+ }
326+ return c.publish(ctx)
327+}
328+
329+// scaleBind steps the focused output's scale and asks for it to be saved.
330+func (c *Core) scaleBind(ctx context.Context, action Action) error {
331+ dir := 1
332+ if action == ActionScaleDown {
333+ dir = -1
334+ }
335+ sc := c.cur()
336+ prev := sc.scale
337+ sc.setScale(StepScale(sc.info.Width, sc.info.Height, sc.scale, dir))
338+ c.order()
339+ if c.ch.Scales != nil && sc.scale != prev && sc.name() != "" {
340+ // Never wait on persistence: a stalled save drops the change.
341+ select {
342+ case c.ch.Scales <- ports.ScaleChanged{Output: sc.name(), Scale: sc.scale}:
343+ default:
344+ }
345+ }
346+ return c.publish(ctx)
347+}
A internal/core/outputoverrides.go +196 -0
@@ -0,0 +1,196 @@
1+package core
2+
3+import (
4+ "fmt"
5+ "image"
6+ "maps"
7+ "math"
8+ "reflect"
9+ "slices"
10+
11+ "github.com/bnema/neferwl/internal/ports"
12+)
13+
14+// OutputOverrides holds runtime-only output settings applied through output
15+// management on top of the file configuration, which is never mutated. Its
16+// owner is the backend loop; it is not safe for concurrent use.
17+type OutputOverrides struct {
18+ file ports.Config
19+ overrides map[string]ports.OutputConfig
20+ heads ports.OutputHeads
21+ headless bool
22+}
23+
24+// NewOutputOverrides starts with no override. Headless outputs keep their
25+// mode and enabled state.
26+func NewOutputOverrides(file ports.Config, headless bool) *OutputOverrides {
27+ return &OutputOverrides{file: file, headless: headless, overrides: map[string]ports.OutputConfig{}}
28+}
29+
30+// Headless reports whether the outputs are headless.
31+func (o *OutputOverrides) Headless() bool { return o.headless }
32+
33+// Heads is the backend inventory requests are validated against.
34+func (o *OutputOverrides) Heads() ports.OutputHeads { return o.heads }
35+
36+// SetHeads records the backend inventory.
37+func (o *OutputOverrides) SetHeads(heads ports.OutputHeads) { o.heads = heads }
38+
39+// Snapshot copies the current overrides for a later Restore.
40+func (o *OutputOverrides) Snapshot() map[string]ports.OutputConfig {
41+ previous := make(map[string]ports.OutputConfig, len(o.overrides))
42+ maps.Copy(previous, o.overrides)
43+ return previous
44+}
45+
46+// Restore puts back overrides taken by Snapshot and returns the effective
47+// configuration.
48+func (o *OutputOverrides) Restore(previous map[string]ports.OutputConfig) ports.Config {
49+ o.overrides = previous
50+ return o.Effective()
51+}
52+
53+// Effective is the file configuration with the overrides applied.
54+func (o *OutputOverrides) Effective() ports.Config {
55+ cfg := o.file
56+ cfg.Outputs = slices.Clone(o.file.Outputs)
57+ // Preserve file order and backend connection order; map iteration would
58+ // make automatic placement change unpredictably.
59+ for _, head := range o.heads.Heads {
60+ entry, ok := o.overrides[head.Info.Name]
61+ if !ok {
62+ continue
63+ }
64+ index := slices.IndexFunc(cfg.Outputs, func(v ports.OutputConfig) bool { return v.Name == head.Info.Name })
65+ if index < 0 {
66+ cfg.Outputs = append(cfg.Outputs, entry)
67+ } else {
68+ cfg.Outputs[index] = entry
69+ }
70+ }
71+ return cfg
72+}
73+
74+// Reload replaces the file configuration. Runtime overrides are dropped,
75+// unless only output scales changed (a saved scale bind): then they stay and
76+// take the new file scales.
77+func (o *OutputOverrides) Reload(file ports.Config) ports.Config {
78+ scales, onlyScales := scaleChanges(o.file, file)
79+ o.file = file
80+ if !onlyScales {
81+ clear(o.overrides)
82+ return o.Effective()
83+ }
84+ for name, scale := range scales {
85+ if entry, ok := o.overrides[name]; ok {
86+ entry.Scale = scale
87+ o.overrides[name] = entry
88+ }
89+ }
90+ return o.Effective()
91+}
92+
93+// scaleChanges lists the output scales that differ from old to cur, and
94+// reports whether nothing else differs.
95+func scaleChanges(old, cur ports.Config) (map[string]float64, bool) {
96+ scaleOf := func(c ports.Config, name string) float64 {
97+ for _, o := range c.Outputs {
98+ if o.Name == name {
99+ return o.Scale
100+ }
101+ }
102+ return 0
103+ }
104+ // Without scales, an output.<name>.scale-only entry is empty: drop it.
105+ strip := func(c ports.Config) ports.Config {
106+ c.Outputs = slices.Clone(c.Outputs)
107+ for i := range c.Outputs {
108+ c.Outputs[i].Scale = 0
109+ }
110+ c.Outputs = slices.DeleteFunc(c.Outputs, func(o ports.OutputConfig) bool {
111+ return o == ports.OutputConfig{Name: o.Name, ScaleOnly: true, SDRBrightness: ports.DefaultSDRBrightness}
112+ })
113+ return c
114+ }
115+ if !reflect.DeepEqual(strip(old), strip(cur)) {
116+ return nil, false
117+ }
118+ scales := map[string]float64{}
119+ for _, o := range cur.Outputs {
120+ if o.Scale != scaleOf(old, o.Name) {
121+ scales[o.Name] = o.Scale
122+ }
123+ }
124+ return scales, true
125+}
126+
127+func (o *OutputOverrides) validate(req ports.OutputApply) error {
128+ if len(req.Heads) != len(o.heads.Heads) {
129+ return fmt.Errorf("incomplete head configuration")
130+ }
131+ seen := map[string]bool{}
132+ for _, change := range req.Heads {
133+ if seen[change.Name] {
134+ return fmt.Errorf("duplicate head %s", change.Name)
135+ }
136+ seen[change.Name] = true
137+ index := slices.IndexFunc(o.heads.Heads, func(h ports.OutputHead) bool { return h.Info.Name == change.Name })
138+ if index < 0 {
139+ return fmt.Errorf("unknown head %s", change.Name)
140+ }
141+ current := o.heads.Heads[index]
142+ if change.Scale < 0 || change.Scale > 4 || (change.Scale > 0 && change.Scale < 1) || math.IsNaN(change.Scale) || math.IsInf(change.Scale, 0) {
143+ return fmt.Errorf("invalid scale on %s", change.Name)
144+ }
145+ if change.Transform != nil && *change.Transform > 7 {
146+ return fmt.Errorf("invalid transform on %s", change.Name)
147+ }
148+ if o.headless && (change.Enabled != current.Enabled || change.Mode != nil && (current.Current == nil || change.Mode.Width != current.Current.Width || change.Mode.Height != current.Current.Height || change.Mode.RefreshMilli != current.Current.RefreshMilli)) {
149+ return fmt.Errorf("headless mode and enabled state are fixed")
150+ }
151+ if change.Mode != nil && !slices.ContainsFunc(current.Modes, func(m ports.OutputMode) bool {
152+ return m.Width == change.Mode.Width && m.Height == change.Mode.Height && m.RefreshMilli == change.Mode.RefreshMilli
153+ }) {
154+ return fmt.Errorf("unknown mode on %s", change.Name)
155+ }
156+ }
157+ return nil
158+}
159+
160+// Apply validates a request against the inventory and, unless it is a
161+// test, records its changes as overrides. It returns the effective
162+// configuration.
163+func (o *OutputOverrides) Apply(req ports.OutputApply) (ports.Config, error) {
164+ if err := o.validate(req); err != nil {
165+ return ports.Config{}, err
166+ }
167+ if req.Test {
168+ return o.Effective(), nil
169+ }
170+ for _, h := range req.Heads {
171+ entry, ok := o.overrides[h.Name]
172+ if !ok {
173+ index := slices.IndexFunc(o.file.Outputs, func(v ports.OutputConfig) bool { return v.Name == h.Name })
174+ if index >= 0 {
175+ entry = o.file.Outputs[index]
176+ } else {
177+ entry.Name = h.Name
178+ }
179+ }
180+ entry.Off = !h.Enabled
181+ if h.Scale > 0 {
182+ entry.Scale = h.Scale
183+ }
184+ if h.Transform != nil {
185+ entry.Transform = *h.Transform
186+ }
187+ if h.Pos != nil {
188+ entry.Pos = &image.Point{X: h.Pos.X, Y: h.Pos.Y}
189+ }
190+ if h.Mode != nil {
191+ entry.Mode = fmt.Sprintf("%dx%d@%.3f", h.Mode.Width, h.Mode.Height, float64(h.Mode.RefreshMilli)/1000)
192+ }
193+ o.overrides[h.Name] = entry
194+ }
195+ return o.Effective(), nil
196+}
A internal/core/outputoverrides_test.go +145 -0
@@ -0,0 +1,145 @@
1+package core
2+
3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func TestOutputSettingsDisableAndHeadlessMode(t *testing.T) {
11+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
12+ state := NewOutputOverrides(ports.Config{}, true)
13+ state.heads = ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "HEADLESS-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
14+ if _, err := state.Apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "HEADLESS-1", Enabled: false}}}); err == nil {
15+ t.Fatal("headless disable accepted")
16+ }
17+ state.headless = false
18+ cfg, err := state.Apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "HEADLESS-1", Enabled: false}}})
19+ if err != nil || !cfg.Outputs[0].Off {
20+ t.Fatalf("disable: %+v, %v", cfg, err)
21+ }
22+}
23+
24+// A reload that only changes output scales (a saved scale bind) keeps the
25+// runtime overrides; any other change drops them.
26+func TestReloadScaleOnlyKeepsOverrides(t *testing.T) {
27+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
28+ file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
29+ state := NewOutputOverrides(file, false)
30+ state.heads = ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}, {Info: ports.OutputInfo{Name: "DP-2"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
31+ if _, err := state.Apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Pos: &image.Point{X: 100}}, {Name: "DP-2", Enabled: true}}}); err != nil {
32+ t.Fatal(err)
33+ }
34+ scaled := file
35+ scaled.Outputs = []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}, {Name: "DP-2", Scale: 1.25, ScaleOnly: true, SDRBrightness: ports.DefaultSDRBrightness}}
36+ cfg := state.Reload(scaled)
37+ if len(cfg.Outputs) != 2 || cfg.Outputs[0].Pos == nil || cfg.Outputs[0].Scale != 1.5 || cfg.Outputs[1].Scale != 1.25 {
38+ t.Fatalf("scale-only reload: %+v", cfg.Outputs)
39+ }
40+ other := scaled
41+ other.Layout.Gaps = 4
42+ if cfg := state.Reload(other); cfg.Outputs[0].Pos != nil {
43+ t.Fatalf("other reload kept overrides: %+v", cfg.Outputs)
44+ }
45+}
46+
47+// A transform change takes the full reload path; a scale change alone does not.
48+func TestScaleChangesTransform(t *testing.T) {
49+ old := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
50+ cur := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2, Transform: 1}}}
51+ if _, only := scaleChanges(old, cur); only {
52+ t.Fatal("a transform change must take the full reload path")
53+ }
54+ cur.Outputs[0].Transform = 0
55+ cur.Outputs[0].Scale = 1.5
56+ if _, only := scaleChanges(old, cur); !only {
57+ t.Fatal("a scale-only change stays a scale change")
58+ }
59+}
60+
61+// A runtime transform lands in the effective configuration and survives a
62+// reload that only changes file scales.
63+func TestOutputApplyTransform(t *testing.T) {
64+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
65+ file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
66+ state := NewOutputOverrides(file, false)
67+ state.heads = ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
68+ tr := ports.BufferTransform(3)
69+ cfg, err := state.Apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Transform: &tr}}})
70+ if err != nil {
71+ t.Fatal(err)
72+ }
73+ if len(cfg.Outputs) != 1 || cfg.Outputs[0].Transform != 3 {
74+ t.Fatalf("applied transform: %+v", cfg.Outputs)
75+ }
76+ scaled := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}}
77+ cfg = state.Reload(scaled)
78+ if cfg.Outputs[0].Transform != 3 || cfg.Outputs[0].Scale != 1.5 {
79+ t.Fatalf("scale reload lost the runtime transform: %+v", cfg.Outputs)
80+ }
81+ // Without a transform in the change, the current one stays.
82+ cfg, err = state.Apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true}}})
83+ if err != nil || cfg.Outputs[0].Transform != 3 {
84+ t.Fatalf("unset transform changed: %+v, %v", cfg.Outputs, err)
85+ }
86+ bad := ports.BufferTransform(8)
87+ if _, err := state.Apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Transform: &bad}}}); err == nil {
88+ t.Fatal("transform 8 accepted")
89+ }
90+}
91+
92+// A runtime position never replaces a file relation: it only takes
93+// precedence over it in core, and the relation is followed again once the
94+// override is dropped.
95+func TestOutputApplyKeepsFileAnchor(t *testing.T) {
96+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
97+ anchor := ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-1", Offset: 40}
98+ file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-2", Anchor: anchor, ScaleOnly: true}}}
99+ o := NewOutputOverrides(file, false)
100+ head := func(name string) ports.OutputHead {
101+ return ports.OutputHead{Info: ports.OutputInfo{Name: name}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}
102+ }
103+ o.heads = ports.OutputHeads{Heads: []ports.OutputHead{head("DP-1"), head("DP-2")}}
104+ change := func(name string, x int) ports.HeadChange {
105+ return ports.HeadChange{Name: name, Enabled: true, Pos: &image.Point{X: x}}
106+ }
107+ byName := func(cfg ports.Config, name string) ports.OutputConfig {
108+ t.Helper()
109+ var found []ports.OutputConfig
110+ for _, v := range cfg.Outputs {
111+ if v.Name == name {
112+ found = append(found, v)
113+ }
114+ }
115+ if len(found) != 1 {
116+ t.Fatalf("%s entries: %+v", name, cfg.Outputs)
117+ }
118+ return found[0]
119+ }
120+
121+ cfg, err := o.Apply(ports.OutputApply{ID: 1, Heads: []ports.HeadChange{change("DP-1", 100), {Name: "DP-2", Enabled: true}}})
122+ if err != nil {
123+ t.Fatal(err)
124+ }
125+ dp1, dp2 := byName(cfg, "DP-1"), byName(cfg, "DP-2")
126+ if dp1.Pos == nil || dp1.Pos.X != 100 || dp2.Pos != nil || dp2.Anchor != anchor {
127+ t.Fatalf("position on the reference only: DP-1 %+v, DP-2 %+v", dp1, dp2)
128+ }
129+
130+ cfg, err = o.Apply(ports.OutputApply{ID: 2, Heads: []ports.HeadChange{change("DP-1", 100), change("DP-2", 900)}})
131+ if err != nil {
132+ t.Fatal(err)
133+ }
134+ dp1, dp2 = byName(cfg, "DP-1"), byName(cfg, "DP-2")
135+ if dp1.Pos == nil || dp1.Pos.X != 100 || dp2.Pos == nil || dp2.Pos.X != 900 || dp2.Anchor != anchor {
136+ t.Fatalf("position on both: DP-1 %+v, DP-2 %+v", dp1, dp2)
137+ }
138+
139+ // A non scale-only reload drops the overrides: the file relation is back alone.
140+ file.Keyboard.RepeatRate = 33
141+ cfg = o.Reload(file)
142+ if dp2 = byName(cfg, "DP-2"); dp2.Pos != nil || dp2.Anchor != anchor || len(cfg.Outputs) != 1 {
143+ t.Fatalf("after reload: %+v", cfg.Outputs)
144+ }
145+}
A internal/core/scene.go +142 -0
@@ -0,0 +1,142 @@
1+package core
2+
3+import (
4+ "context"
5+ "slices"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// sceneFor builds the scene of screen i and sends the configures it sizes.
11+// drawable reports that it can show the focus pulse on the focused window.
12+func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, capture *capView, pulse float64) (scene ports.Scene, drawable bool, err error) {
13+ o := sc.mon.Output()
14+ // frame is the viewport of the workspace on screen: the whole output
15+ // unless it has a size override (never in the overview).
16+ frame := sc.mon.Frame()
17+ var clip Rect
18+ if frame != (Rect{W: o.W, H: o.H}) {
19+ clip = frame
20+ }
21+ // layout is what is drawn; settled (same indexes) is where the
22+ // windows are going, and alone sizes the configures.
23+ layout, settled := sc.shown, sc.settledLayout
24+ scene = ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)), Layers: shownLayers(sc)}
25+ var real map[WindowID]Placement
26+ if sc.mon.ov.open {
27+ // Local to this screen's build: reused, cleared each time.
28+ if c.overviewReal == nil {
29+ c.overviewReal = make(map[WindowID]Placement)
30+ }
31+ clear(c.overviewReal)
32+ real = c.overviewReal
33+ for w := range sc.mon.all() {
34+ c.realBuf = w.layoutInto(c.realBuf)
35+ for _, p := range c.realBuf {
36+ real[p.ID] = p
37+ }
38+ }
39+ }
40+ scene.Dim = floatDim(layout, frame, c.cfg.Floating.Dim)
41+ if sc.mon.ov.open {
42+ // Darken the wallpaper around the previews.
43+ scene.Dim = c.cfg.Floating.Dim
44+ scene.DimBehind = scene.Dim > 0
45+ }
46+ if d := c.drag; d != nil && d.target.screen == sc && d.target.kind != dropNone {
47+ scene.DropHints = slices.Clone(d.target.hints)
48+ }
49+ if sc.mon.ov.open {
50+ // Frame the selection and separate numbered and named row groups.
51+ _, scene.Separators = sc.mon.overviewRows()
52+ scene.Separators = append(scene.Separators, overviewOutline(layout, max(c.cfg.Border.Width, 2))...)
53+ } else {
54+ // Only the focused output lights the focused window's lines.
55+ scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen)
56+ }
57+ // A window alone on screen needs no pulse to show it has the focus.
58+ // A tile under a panel (the next cascade band) does not count.
59+ alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, sc.mon.Current().Usable) == 1
60+ for k, p := range layout {
61+ ps := settled[k]
62+ // Only the focused output has an activated window.
63+ focused := p.Focused && i == c.focusScreen
64+ sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade}
65+ if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) {
66+ // A scale motion: the content follows the drawn size. A
67+ // card in flight drawn at its size still needs Zoom 1:
68+ // without it the renderer would shrink it by Preview.
69+ sw.Zoom = p.Zoom
70+ }
71+ // A peek's veil is the configured one plus its animated offset.
72+ sw.Dim = max(0, min(p.Dim+c.peekDim(p), 1))
73+ if p.Leaving {
74+ // A window fading out after it closed or hid: drawn, but
75+ // hidden to everything else, its configures included.
76+ sw.Hidden = false
77+ scene.Windows = append(scene.Windows, sw)
78+ // A window the workspace still holds (a hidden stash
79+ // window) keeps its last configure: prune would forget it,
80+ // and its next one would start from nothing. A closed one
81+ // was forgotten at its unmap and stays so.
82+ if w, _ := sc.mon.find(p.ID); w != nil {
83+ c.configures.keep(p.ID)
84+ }
85+ continue
86+ }
87+ if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open {
88+ drawable = true
89+ if p.ID == c.pulse.id {
90+ sw.FocusEffect = pulse
91+ }
92+ }
93+ scene.Windows = append(scene.Windows, sw)
94+ t := configureTarget{output: sc.name(), area: frame, focused: focused, captured: capture != nil && capture.window == p.ID}
95+ if !p.Hidden && p.Preview == 0 {
96+ // Only a sized configure needs the client size.
97+ t.client, t.imposed = c.clientRect(ps), sc.mon.Current().imposedFloat(p.ID)
98+ } else if t.captured && p.Hidden {
99+ // A captured hidden window is sized like its capture.
100+ t.client = capture.windowSz
101+ } else if p.Preview > 0 && !p.Hidden {
102+ if rp, ok := real[p.ID]; ok && !rp.Hidden {
103+ t.realTiled = !rp.Floating
104+ t.client = c.clientRect(rp)
105+ }
106+ }
107+ cp, t := c.captureConfigure(sc, ps, t)
108+ if v, send := c.configures.nextWithCapture(ps, t, cp); send {
109+ if err := c.command(ctx, v); err != nil {
110+ return ports.Scene{}, false, err
111+ }
112+ c.configures.mark(v)
113+ }
114+ }
115+ scene.Windows = append(scene.Windows, c.scenePopups(sc)...)
116+ scene.CaptureIndicators = c.captureIndicators(sc)
117+ scene.Capture = c.captureSceneFor(sc, capture)
118+ // A scene carrying a capture image always gets a fresh Seq, and so
119+ // does one of an animating screen (the one being stepped, or any
120+ // when none is): the outputs drop a scene they already show, and a
121+ // spring that starts at the previous scene, or rounds to it in its
122+ // tail, would get no flip and wait for the fallback timer. Any other
123+ // keeps its output's Seq while it draws the same, so an idle output
124+ // is not recomposed.
125+ withCapture := scene.Capture != nil && (sc == capture.hiddenScr || sc == capture.windowScr)
126+ animating := (sc.springing() || c.pulsing(sc)) && (only == nil || only == sc)
127+ if !withCapture && !animating && sc.last.Seq != 0 && scene.SameAs(sc.last) {
128+ scene.Seq = sc.last.Seq
129+ } else {
130+ c.seq++
131+ scene.Seq = c.seq
132+ }
133+ if withCapture {
134+ switch sc {
135+ case capture.hiddenScr:
136+ scene.CaptureScene = c.captureScene(scene.Seq)
137+ case capture.windowScr:
138+ scene.CaptureScene = c.captureWindowScene(capture, scene.Seq)
139+ }
140+ }
141+ return scene, drawable, nil
142+}
M internal/core/security.go +23 -15
@@ -7,6 +7,14 @@ import (
77 "github.com/bnema/neferwl/internal/ports"
88 )
99
10+// lockState is the session lock's surfaces and keyboard focus. A new
11+// security epoch resets it whole.
12+type lockState struct {
13+ surfaces []ports.LockSurfacePlacement
14+ focus WindowID
15+ pinned bool // focus was clicked or typed into: it keeps focus
16+}
17+
1018 // syncSecurity runs only on the owner. The gate, not event selection order,
1119 // establishes the epoch; surface events can only fill in that exact epoch.
1220 func (c *Core) syncSecurity() bool {
@@ -21,7 +29,7 @@ func (c *Core) syncSecurity() bool {
2129 return false
2230 }
2331 c.security = state
24- c.lockSurfaces, c.lockFocus, c.lockPinned = nil, 0, false
32+ c.lock = lockState{}
2533 c.pressed, c.buttons, c.inputKeys = map[string]bool{}, map[uint32]bool{}, map[string]bool{}
2634 c.pointer, c.grab, c.pointerOutput = 0, 0, ""
2735 c.drag, c.swallow, c.mods, c.lastButton = nil, map[uint32]bool{}, 0, 0
@@ -95,7 +103,7 @@ func (c *Core) applyLockChanged(v ports.SessionLockChanged) bool {
95103 if !c.security.Protected {
96104 return true
97105 }
98- c.lockSurfaces = slices.Clone(v.Surfaces)
106+ c.lock.surfaces = slices.Clone(v.Surfaces)
99107 c.lockKeyboardFocus()
100108 // A destroyed or resized role must not retain a pointer grab.
101109 if !c.validLockID(c.pointer) {
@@ -110,7 +118,7 @@ func (c *Core) applyLockChanged(v ports.SessionLockChanged) bool {
110118
111119 func (c *Core) lockSurface(sc *screen) (ports.LockSurfacePlacement, bool) {
112120 o := sc.mon.Output()
113- for _, s := range c.lockSurfaces {
121+ for _, s := range c.lock.surfaces {
114122 if s.ID != 0 && sc.name() != "" && s.Output == sc.name() && s.Width == o.W && s.Height == o.H && o.W > 0 && o.H > 0 {
115123 return s, true
116124 }
@@ -136,27 +144,27 @@ func (c *Core) validLockID(id WindowID) bool {
136144 // surface once it maps: lock clients map their surfaces one by one, in any
137145 // order.
138146 func (c *Core) lockKeyboardFocus() WindowID {
139- valid := c.validLockID(c.lockFocus)
140- if valid && c.lockPinned {
141- return c.lockFocus
147+ valid := c.validLockID(c.lock.focus)
148+ if valid && c.lock.pinned {
149+ return c.lock.focus
142150 }
143151 if c.focusScreen >= 0 && c.focusScreen < len(c.screens) {
144152 if s, ok := c.lockSurface(c.screens[c.focusScreen]); ok {
145- c.lockFocus, c.lockPinned = s.ID, false
146- return c.lockFocus
153+ c.lock.focus, c.lock.pinned = s.ID, false
154+ return c.lock.focus
147155 }
148156 }
149157 if valid {
150- return c.lockFocus
158+ return c.lock.focus
151159 }
152- c.lockFocus, c.lockPinned = 0, false
160+ c.lock.focus, c.lock.pinned = 0, false
153161 for _, sc := range c.screens {
154162 if s, ok := c.lockSurface(sc); ok {
155- c.lockFocus = s.ID
163+ c.lock.focus = s.ID
156164 break
157165 }
158166 }
159- return c.lockFocus
167+ return c.lock.focus
160168 }
161169
162170 func (c *Core) lockHit(x, y float64) (WindowID, float64, float64) {
@@ -242,7 +250,7 @@ func (c *Core) protectedInput(ctx context.Context, ev ports.InputEvent) error {
242250 switch v := ev.(type) {
243251 case ports.KeyEvent:
244252 if id := c.lockKeyboardFocus(); id != 0 {
245- c.lockPinned = true // typing must not move focus mid-secret
253+ c.lock.pinned = true // typing must not move focus mid-secret
246254 return c.command(ctx, ports.ForwardKey{ID: id, Key: v})
247255 }
248256 case ports.PointerMotion:
@@ -277,8 +285,8 @@ func (c *Core) protectedInput(ctx context.Context, ev ports.InputEvent) error {
277285 }
278286 if c.validLockID(id) {
279287 if v.Pressed {
280- changed := c.lockFocus != id
281- c.lockFocus, c.lockPinned = id, true
288+ changed := c.lock.focus != id
289+ c.lock.focus, c.lock.pinned = id, true
282290 if changed {
283291 if err := c.publishProtected(ctx); err != nil {
284292 return err
M internal/core/security_internal_test.go +1 -1
@@ -62,7 +62,7 @@ func TestProtectedDropsPrivateOffscreenCaptureAndDesktopEffects(t *testing.T) {
6262 c.pointer, c.grab = 10, 10
6363 c.keyboard = keyboard{sent: 10}
6464 state = ports.SecurityState{Generation: 2}
65- if !c.syncSecurity() || len(c.inputKeys) != 0 || c.grab != 0 || c.keyboard.sent != 0 || len(c.lockSurfaces) != 0 {
65+ if !c.syncSecurity() || len(c.inputKeys) != 0 || c.grab != 0 || c.keyboard.sent != 0 || len(c.lock.surfaces) != 0 {
6666 t.Fatal("release did not reset input ownership")
6767 }
6868 for _, ev := range []ports.InputEvent{ports.KeyEvent{Keycode: 30}, ports.PointerButton{Button: 0x110}} {
M internal/core/security_review_test.go +1 -1
@@ -39,7 +39,7 @@ func TestInputOutputSwitchBackpressureKeepsAdmittedEpoch(t *testing.T) {
3939 return v
4040 })
4141 c.opts.Security = gate
42- c.sentOutputs = ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()}
42+ c.sent.outputs = ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()}
4343 commands := make(chan ports.ClientCommand)
4444 c.ch.Commands = commands
4545 done := make(chan error, 1)
M internal/core/state.go +2 -2
@@ -14,10 +14,10 @@ func (c *Core) publishState() {
1414 return
1515 }
1616 st := c.state()
17- if reflect.DeepEqual(st, c.sentState) {
17+ if reflect.DeepEqual(st, c.sent.state) {
1818 return
1919 }
20- c.sentState = st
20+ c.sent.state = st
2121 latest(c.ch.State, st)
2222 }
2323
M internal/core/workspaces.go +2 -2
@@ -32,9 +32,9 @@ func (c *Core) publishWorkspaces() {
3232 }
3333 snapshot.Outputs = append(snapshot.Outputs, out)
3434 }
35- if reflect.DeepEqual(snapshot, c.sentWorkspaces) {
35+ if reflect.DeepEqual(snapshot, c.sent.workspaces) {
3636 return
3737 }
38- c.sentWorkspaces = snapshot
38+ c.sent.workspaces = snapshot
3939 latest(c.ch.Workspaces, snapshot)
4040 }
M internal/ports/client_events.go +0 -174
@@ -110,30 +110,6 @@ type WindowActivate struct{ ID WindowID }
110110
111111 func (WindowActivate) clientEvent() {}
112112
113-// WorkspaceActivate requests showing workspaces by stable ID, in commit order.
114-// Core applies the entire batch before publishing a new state.
115-type WorkspaceActivate struct{ IDs []uint64 }
116-
117-func (WorkspaceActivate) clientEvent() {}
118-
119-// Workspaces is an immutable, latest-only inventory of output workspaces.
120-type Workspaces struct{ Outputs []WorkspaceOutput }
121-type WorkspaceOutput struct {
122- Name string
123- Workspaces []WorkspaceInfo
124-}
125-type WorkspaceInfo struct {
126- ID uint64
127- Configured string // configured name; empty for dynamic workspaces. ID, not this, identifies the workspace
128- Name string
129- Index int // zero-based vertical coordinate
130- Active, Hidden bool
131- // Frame is the workspace viewport in output-local logical pixels
132- // (Monitor.Frame geometry: the whole output unless the workspace has a
133- // size override). It is where a capture session of it lies.
134- Frame Rect
135-}
136-
137113 // WindowAppID carries wayland → core an app ID set after the window mapped.
138114 type WindowAppID struct {
139115 ID WindowID
@@ -157,153 +133,3 @@ type WindowResized struct {
157133 }
158134
159135 func (WindowResized) clientEvent() {}
160-
161-// Popup anchors and gravities, as xdg_positioner values.
162-const (
163- EdgeNone uint32 = iota
164- EdgeTop
165- EdgeBottom
166- EdgeLeft
167- EdgeRight
168- EdgeTopLeft
169- EdgeBottomLeft
170- EdgeTopRight
171- EdgeBottomRight
172-)
173-
174-// Popup constraint adjustments, as xdg_positioner values.
175-const (
176- AdjustSlideX uint32 = 1 << iota
177- AdjustSlideY
178- AdjustFlipX
179- AdjustFlipY
180- AdjustResizeX
181- AdjustResizeY
182-)
183-
184-// Positioner places a popup relative to its parent's window geometry:
185-// the Anchor point of AnchorRect, moved by Offset, with the popup extending
186-// towards Gravity. Adjust says how core may move it to stay on screen.
187-type Positioner struct {
188- Width, Height int
189- AnchorRect Rect
190- Anchor, Gravity uint32
191- OffsetX, OffsetY int
192- Adjust uint32
193-}
194-
195-// PopupRequest carries wayland → core a popup to place (xdg_popup), on
196-// its first commit and on reposition (Reposition, with the client's
197-// Token). Parent is a window or popup. Grab popups take the keyboard and
198-// close on a click elsewhere.
199-type PopupRequest struct {
200- ID, Parent WindowID
201- Positioner Positioner
202- Grab bool
203- Reposition bool
204- Token uint32
205-}
206-
207-func (PopupRequest) clientEvent() {}
208-
209-// PopupMapped carries wayland → core a placed popup that now has content.
210-// It ends with WindowUnmapped.
211-type PopupMapped struct{ ID WindowID }
212-
213-func (PopupMapped) clientEvent() {}
214-
215-// Layer is a wlr-layer-shell stacking layer.
216-type Layer uint32
217-
218-const (
219- LayerBackground Layer = iota
220- LayerBottom
221- LayerTop
222- LayerOverlay
223-)
224-
225-// Anchor edges, as in zwlr_layer_surface_v1.anchor.
226-const (
227- AnchorTop uint32 = 1
228- AnchorBottom uint32 = 2
229- AnchorLeft uint32 = 4
230- AnchorRight uint32 = 8
231-)
232-
233-// LayerSurface is the committed state of one mapped layer surface. Width and
234-// Height are the committed surface size in logical pixels. Margin is top, right, bottom, left.
235-// IDs share the WindowID space with windows and never collide.
236-type LayerSurface struct {
237- ID WindowID
238- Layer Layer
239- Anchor uint32
240- ExclusiveZone int32
241- Margin [4]int32
242- Width, Height int
243- Namespace string
244- // Keyboard is the requested interactivity: 0 none, 1 exclusive, 2 on-demand.
245- Keyboard uint32
246- // Output is the connector the surface is on.
247- Output string
248-}
249-
250-// LayerChanged carries wayland → core the full list of mapped layer surfaces,
251-// sent whenever a layer surface maps, unmaps or commits new layer state.
252-type LayerChanged struct{ Layers []LayerSurface }
253-
254-func (LayerChanged) clientEvent() {}
255-
256-// ConstraintMode is how a pointer constraint holds the pointer.
257-type ConstraintMode uint8
258-
259-const (
260- ConstraintNone ConstraintMode = iota
261- // ConstraintLock keeps the pointer still: only relative motion flows.
262- ConstraintLock
263- // ConstraintConfine keeps the pointer inside Rect.
264- ConstraintConfine
265-)
266-
267-// PointerConstraint is an active pointer constraint. Rect is logical: in
268-// PointerConstrained it is window-local and empty means the whole window;
269-// core sends input the resolved global rectangle, and in X, Y its cursor
270-// position, where input holds a locked pointer. Warp moves input's
271-// pointer to X, Y whatever the mode (wp_pointer_warp_v1).
272-type PointerConstraint struct {
273- Mode ConstraintMode
274- Rect Rect
275- X, Y float64
276- Warp bool
277-}
278-
279-// Clamp keeps a global point inside the rectangle of a lock or confine;
280-// no constraint and an empty rectangle leave it unchanged.
281-func (c PointerConstraint) Clamp(x, y float64) (float64, float64) {
282- if c.Mode == ConstraintNone || c.Rect.W <= 0 || c.Rect.H <= 0 {
283- return x, y
284- }
285- x = min(max(x, float64(c.Rect.X)), float64(c.Rect.X+c.Rect.W-1))
286- y = min(max(y, float64(c.Rect.Y)), float64(c.Rect.Y+c.Rect.H-1))
287- return x, y
288-}
289-
290-// PointerConstrained carries wayland → core the constraint active on a
291-// window (zwp_pointer_constraints_v1); ID 0 means none is active. A
292-// constraint is active only on the window with both pointer and keyboard
293-// focus.
294-type PointerConstrained struct {
295- ID WindowID
296- PointerConstraint
297-}
298-
299-func (PointerConstrained) clientEvent() {}
300-
301-// PointerWarp carries wayland → core a client's request to move the
302-// pointer to X, Y, window-local logical (wp_pointer_warp_v1). Wayland has
303-// checked that the window has the pointer and the point is on it.
304-type PointerWarp struct {
305- ID WindowID
306- X, Y float64
307-}
308-
309-func (PointerWarp) clientEvent() {}
A internal/ports/layer_events.go +42 -0
@@ -0,0 +1,42 @@
1+package ports
2+
3+// Layer is a wlr-layer-shell stacking layer.
4+type Layer uint32
5+
6+const (
7+ LayerBackground Layer = iota
8+ LayerBottom
9+ LayerTop
10+ LayerOverlay
11+)
12+
13+// Anchor edges, as in zwlr_layer_surface_v1.anchor.
14+const (
15+ AnchorTop uint32 = 1
16+ AnchorBottom uint32 = 2
17+ AnchorLeft uint32 = 4
18+ AnchorRight uint32 = 8
19+)
20+
21+// LayerSurface is the committed state of one mapped layer surface. Width and
22+// Height are the committed surface size in logical pixels. Margin is top, right, bottom, left.
23+// IDs share the WindowID space with windows and never collide.
24+type LayerSurface struct {
25+ ID WindowID
26+ Layer Layer
27+ Anchor uint32
28+ ExclusiveZone int32
29+ Margin [4]int32
30+ Width, Height int
31+ Namespace string
32+ // Keyboard is the requested interactivity: 0 none, 1 exclusive, 2 on-demand.
33+ Keyboard uint32
34+ // Output is the connector the surface is on.
35+ Output string
36+}
37+
38+// LayerChanged carries wayland → core the full list of mapped layer surfaces,
39+// sent whenever a layer surface maps, unmaps or commits new layer state.
40+type LayerChanged struct{ Layers []LayerSurface }
41+
42+func (LayerChanged) clientEvent() {}
A internal/ports/pointer_events.go +56 -0
@@ -0,0 +1,56 @@
1+package ports
2+
3+// ConstraintMode is how a pointer constraint holds the pointer.
4+type ConstraintMode uint8
5+
6+const (
7+ ConstraintNone ConstraintMode = iota
8+ // ConstraintLock keeps the pointer still: only relative motion flows.
9+ ConstraintLock
10+ // ConstraintConfine keeps the pointer inside Rect.
11+ ConstraintConfine
12+)
13+
14+// PointerConstraint is an active pointer constraint. Rect is logical: in
15+// PointerConstrained it is window-local and empty means the whole window;
16+// core sends input the resolved global rectangle, and in X, Y its cursor
17+// position, where input holds a locked pointer. Warp moves input's
18+// pointer to X, Y whatever the mode (wp_pointer_warp_v1).
19+type PointerConstraint struct {
20+ Mode ConstraintMode
21+ Rect Rect
22+ X, Y float64
23+ Warp bool
24+}
25+
26+// Clamp keeps a global point inside the rectangle of a lock or confine;
27+// no constraint and an empty rectangle leave it unchanged.
28+func (c PointerConstraint) Clamp(x, y float64) (float64, float64) {
29+ if c.Mode == ConstraintNone || c.Rect.W <= 0 || c.Rect.H <= 0 {
30+ return x, y
31+ }
32+ x = min(max(x, float64(c.Rect.X)), float64(c.Rect.X+c.Rect.W-1))
33+ y = min(max(y, float64(c.Rect.Y)), float64(c.Rect.Y+c.Rect.H-1))
34+ return x, y
35+}
36+
37+// PointerConstrained carries wayland → core the constraint active on a
38+// window (zwp_pointer_constraints_v1); ID 0 means none is active. A
39+// constraint is active only on the window with both pointer and keyboard
40+// focus.
41+type PointerConstrained struct {
42+ ID WindowID
43+ PointerConstraint
44+}
45+
46+func (PointerConstrained) clientEvent() {}
47+
48+// PointerWarp carries wayland → core a client's request to move the
49+// pointer to X, Y, window-local logical (wp_pointer_warp_v1). Wayland has
50+// checked that the window has the pointer and the point is on it.
51+type PointerWarp struct {
52+ ID WindowID
53+ X, Y float64
54+}
55+
56+func (PointerWarp) clientEvent() {}
A internal/ports/popup_events.go +55 -0
@@ -0,0 +1,55 @@
1+package ports
2+
3+// Popup anchors and gravities, as xdg_positioner values.
4+const (
5+ EdgeNone uint32 = iota
6+ EdgeTop
7+ EdgeBottom
8+ EdgeLeft
9+ EdgeRight
10+ EdgeTopLeft
11+ EdgeBottomLeft
12+ EdgeTopRight
13+ EdgeBottomRight
14+)
15+
16+// Popup constraint adjustments, as xdg_positioner values.
17+const (
18+ AdjustSlideX uint32 = 1 << iota
19+ AdjustSlideY
20+ AdjustFlipX
21+ AdjustFlipY
22+ AdjustResizeX
23+ AdjustResizeY
24+)
25+
26+// Positioner places a popup relative to its parent's window geometry:
27+// the Anchor point of AnchorRect, moved by Offset, with the popup extending
28+// towards Gravity. Adjust says how core may move it to stay on screen.
29+type Positioner struct {
30+ Width, Height int
31+ AnchorRect Rect
32+ Anchor, Gravity uint32
33+ OffsetX, OffsetY int
34+ Adjust uint32
35+}
36+
37+// PopupRequest carries wayland → core a popup to place (xdg_popup), on
38+// its first commit and on reposition (Reposition, with the client's
39+// Token). Parent is a window or popup. Grab popups take the keyboard and
40+// close on a click elsewhere.
41+type PopupRequest struct {
42+ ID, Parent WindowID
43+ Positioner Positioner
44+ Grab bool
45+ Reposition bool
46+ Token uint32
47+}
48+
49+func (PopupRequest) clientEvent() {}
50+
51+// PopupMapped carries wayland → core a placed popup that now has content.
52+// It ends with WindowUnmapped.
53+type PopupMapped struct{ ID WindowID }
54+
55+func (PopupMapped) clientEvent() {}
M internal/ports/scene.go +1 -269
@@ -1,11 +1,6 @@
11 package ports
22
3-import (
4- "image"
5- "math"
6- "os"
7- "slices"
8-)
3+import "slices"
94
105 // Scene carries core → renderer immutable snapshots with fresh Windows slices,
116 // one per output. Rects and the output size are logical and local to the
@@ -142,269 +137,6 @@ type SceneLayer struct {
142137 Rect Rect
143138 }
144139
145-// SurfaceColor is the committed encoding of a surface. Zero means sRGB.
146-// PQ+BT.2020 and extended-linear sRGB are color-managed; zero is legacy
147-// sRGB. MaxCLL/MaxFALL are in nits. YUV metadata is per surface commit.
148-type SurfaceColor struct {
149- TF, Primaries uint8
150- MaxCLL, MaxFALL uint16
151- // Coefficients/range follow wp_color_representation_v1 (zero =
152- // unspecified, for YUV interpreted as BT.709 limited). Chroma is a
153- // H.273 Chroma420SampleLocType + 1 (zero defaults to type 0).
154- Coefficients, Range, Chroma uint8
155-}
156-
157-const (
158- ColorTFPQ uint8 = 11 // wp_color_manager_v1 ST2084 PQ
159- ColorTFExtendedLinear uint8 = 5 // wp_color_manager_v1 ext_linear
160- ColorPrimariesBT2020 uint8 = 6 // wp_color_manager_v1 BT.2020
161- ColorPrimariesSRGB uint8 = 1
162- ColorTFSRGB uint8 = 9 // wp_color_manager_v1 sRGB
163-)
164-
165-func (c SurfaceColor) IsPQ2020() bool {
166- return c.TF == ColorTFPQ && c.Primaries == ColorPrimariesBT2020
167-}
168-
169-func (c SurfaceColor) IsExtendedLinear() bool {
170- return c.TF == ColorTFExtendedLinear && c.Primaries == ColorPrimariesSRGB
171-}
172-
173-// ContentType is the committed wp_content_type_v1 hint.
174-type ContentType uint8
175-
176-const (
177- ContentNone ContentType = iota
178- ContentPhoto
179- ContentVideo
180- ContentGame
181-)
182-
183-// SurfaceContent carries wayland → output the latest committed content of a
184-// window: SHM, a client shared-memory buffer, DMABuf, a GPU buffer, or
185-// Solid, a single-pixel buffer. Renderers read them in place and never modify
186-// them. Empty means the window
187-// has no content. LogicalW and LogicalH are the surface size in logical
188-// pixels (buffer scale and viewport applied). Subsurfaces come in Children;
189-// Geometry is the part of the surface that is the window (xdg window
190-// geometry), the rest being client shadows.
191-type SurfaceContent struct {
192- ID WindowID
193- ContentType ContentType
194- // Surface is a never-reused wl_surface identity; Version identifies its
195- // applied buffer content independently of the window publication Seq.
196- Surface, Version uint64
197- // Seq counts the window's contents (wayland sets it), for release.
198- Seq uint64
199- Width, Height int
200- LogicalW, LogicalH int
201- // Source is the committed crop in buffer pixels: x, y, width, height.
202- // Zero width means the entire buffer is used.
203- Source [4]float32
204- // Transform is the wl_output.transform the client applied to the
205- // buffer: readers apply its inverse. Width and Height stay the buffer's.
206- Transform BufferTransform
207- // Opaque ignores the alpha byte: an x format, or an opaque region that
208- // covers the whole surface. It is false while Fade is set.
209- Opaque bool
210- // OpaqueRect is the largest rect of the opaque region inside a surface
211- // that is not wholly Opaque, in logical pixels from the surface origin:
212- // the pixels there ignore alpha too, so the surfaces below it are hidden
213- // by it. Empty when there is none or while Fade is set.
214- OpaqueRect Rect
215- // Fade is how much wp_alpha_modifier_v1 fades the surface: 0 none,
216- // 1 invisible. Its opacity is multiplied by 1-Fade.
217- Fade float32
218- Color SurfaceColor
219- SHM *SHMBuffer
220- DMABuf *DMABuf
221- // Solid is a single-pixel buffer (Width and Height are 1): the surface is
222- // one flat color.
223- Solid *SolidColor
224- // Children are the subsurfaces, bottom to top, flattened.
225- Children []Subsurface
226- // Geometry is in logical pixels from the surface origin; empty means
227- // the whole surface.
228- Geometry Rect
229- // Async asks for tearing presentation (wp_tearing_control_v1); outputs
230- // honour it only in direct scanout.
231- Async bool
232- // Acquire is the explicit-sync fence of this content's root buffer
233- // (wp_linux_drm_syncobj_v1): readers wait on it before reading the
234- // buffer, instead of the buffer's implicit fences. Wayland owns it and
235- // closes it when the buffer is released; a reader that keeps it past
236- // the call duplicates it under Acquire.SyscallConn. nil: implicit sync.
237- // Wayland closes it only after every output reported a later content of
238- // the window as seen, so a reader never gets it already closed.
239- Acquire *os.File
240- // DamageHistory is what changed in the root surface's buffer over the
241- // window's last contents, oldest first, ending with this one (Seq).
242- // A renderer holding an older content redraws the union of the
243- // entries after it, or everything when the history does not reach it.
244- DamageHistory []SeqDamage
245-}
246-
247-// BufferTransform is a wl_output.transform value: bit 0 rotates 90°
248-// counter-clockwise, bit 1 rotates 180°, bit 2 flips around the vertical
249-// axis first.
250-type BufferTransform uint8
251-
252-// Rotated reports whether width and height swap between buffer and surface.
253-func (t BufferTransform) Rotated() bool { return t&1 != 0 }
254-
255-// Size returns the size of a w×h space seen through t: width and height swap
256-// when t is Rotated.
257-func (t BufferTransform) Size(w, h int) (int, int) {
258- if t.Rotated() {
259- return h, w
260- }
261- return w, h
262-}
263-
264-// ToBuffer maps a point of a w×h surface (logical, before scale) to the
265-// same point of its buffer, in the buffer's untransformed w'×h' axes, where
266-// w' and h' swap when the transform is Rotated.
267-func (t BufferTransform) ToBuffer(x, y, w, h float64) (float64, float64) {
268- if t&4 != 0 {
269- x = w - x
270- }
271- switch t & 3 {
272- case 1:
273- return y, w - x
274- case 2:
275- return w - x, h - y
276- case 3:
277- return h - y, x
278- }
279- return x, y
280-}
281-
282-// Invert is the transform that maps target points back: for a w×h scene and
283-// its w'×h' target, t.Invert().ToBuffer(t.ToBuffer(p, w, h), w', h') == p.
284-func (t BufferTransform) Invert() BufferTransform {
285- if t&1 != 0 && t&4 == 0 {
286- return t ^ 2
287- }
288- return t
289-}
290-
291-// RectToBuffer maps a rectangle of a w×h surface to the buffer. Integer
292-// rectangles stay integer under these transforms; rounding absorbs float noise.
293-func (t BufferTransform) RectToBuffer(r image.Rectangle, w, h int) image.Rectangle {
294- ax, ay := t.ToBuffer(float64(r.Min.X), float64(r.Min.Y), float64(w), float64(h))
295- bx, by := t.ToBuffer(float64(r.Max.X), float64(r.Max.Y), float64(w), float64(h))
296- return image.Rect(int(math.Round(ax)), int(math.Round(ay)), int(math.Round(bx)), int(math.Round(by)))
297-}
298-
299-// SeqDamage is what content Seq changed from the one before: Rects in
300-// root buffer pixels, or everything when Full.
301-type SeqDamage struct {
302- Seq uint64
303- Full bool
304- Rects []Rect
305-}
306-
307-// DamageSince is the union of changes after content seq up to this one,
308-// and false when the history does not reach back to seq (redraw all).
309-func (c SurfaceContent) DamageSince(seq uint64) ([]Rect, bool) {
310- out, ok := c.AppendDamageSince(nil, seq)
311- if !ok {
312- return nil, false
313- }
314- return out, true
315-}
316-
317-// AppendDamageSince is DamageSince appending to dst, so a caller reusing
318-// its slice allocates nothing. When the history does not reach back to seq
319-// it returns dst unchanged and false.
320-func (c SurfaceContent) AppendDamageSince(dst []Rect, seq uint64) ([]Rect, bool) {
321- if seq >= c.Seq {
322- return dst, true
323- }
324- h := c.DamageHistory
325- if len(h) == 0 || h[0].Seq > seq+1 || h[len(h)-1].Seq != c.Seq {
326- return dst, false
327- }
328- n := len(dst)
329- for _, d := range h {
330- if d.Seq <= seq {
331- continue
332- }
333- if d.Full {
334- return dst[:n], false
335- }
336- dst = append(dst, d.Rects...)
337- }
338- return dst, true
339-}
340-
341-// Subsurface is a child surface at X, Y logical pixels from the root
342-// surface origin. Below children are drawn under the root surface.
343-type Subsurface struct {
344- X, Y int
345- Below bool
346- SurfaceContent
347-}
348-
349-// HasBuffer reports whether the surface itself carries a client buffer.
350-func (c SurfaceContent) HasBuffer() bool {
351- return c.SHM != nil || c.DMABuf != nil || c.Solid != nil
352-}
353-
354-// SolidColor is the color of a single-pixel buffer: premultiplied by alpha,
355-// 0 to 1, sRGB-encoded like the pixels of client buffers.
356-type SolidColor struct{ R, G, B, A float32 }
357-
358-// Empty reports whether the content has nothing to draw.
359-func (c SurfaceContent) Empty() bool {
360- return !c.HasBuffer() && len(c.Children) == 0
361-}
362-
363-// SHMBuffer is a client wl_shm buffer: B8G8R8A8 pixels (argb8888/xrgb8888
364-// little-endian) at Offset in the pool File, Stride bytes per row. The
365-// client keeps writing the file, so renderers copy from it when they draw.
366-// Pool is unique per wl_shm pool for the session: renderers map a pool
367-// once. File stays open while the client's buffer exists and follows the
368-// DMABuf rules: duplicate it under File.SyscallConn before keeping it. A
369-// client may shrink the file at any time, so readers must survive faults.
370-type SHMBuffer struct {
371- Pool uint64
372- File *os.File
373- Offset, Stride int
374-}
375-
376-// DMABuf is a client GPU buffer (linux-dmabuf). Its files stay open while
377-// the client's buffer exists; a renderer that keeps it must duplicate the
378-// descriptors under File.SyscallConn so a concurrent close cannot hand it a
379-// reused one. ID is unique per buffer for the whole session: renderers key
380-// their imports on it.
381-type DMABuf struct {
382- ID uint64
383- Width, Height int
384- Format uint32 // DRM fourcc
385- Modifier uint64
386- Planes []DMABufPlane
387-}
388-
389-// DMABufPlane is one plane of a DMABuf.
390-type DMABufPlane struct {
391- File *os.File
392- Offset, Stride uint32
393-}
394-
395-// DMABufFormat is a DRM fourcc with a modifier the renderer can import.
396-type DMABufFormat struct {
397- Format uint32
398- Modifier uint64
399-}
400-
401-// DMABufSupport is what the renderer imports: the render device (a dev_t,
402-// 0 when unknown) and the formats. No formats means no dmabuf.
403-type DMABufSupport struct {
404- Device uint64
405- Formats []DMABufFormat
406-}
407-
408140 // SceneWindow carries core → renderer window placement.
409141 type SceneWindow struct {
410142 ID WindowID
A internal/ports/surface_content.go +270 -0
@@ -0,0 +1,270 @@
1+package ports
2+
3+import (
4+ "image"
5+ "math"
6+ "os"
7+)
8+
9+// SurfaceColor is the committed encoding of a surface. Zero means sRGB.
10+// PQ+BT.2020 and extended-linear sRGB are color-managed; zero is legacy
11+// sRGB. MaxCLL/MaxFALL are in nits. YUV metadata is per surface commit.
12+type SurfaceColor struct {
13+ TF, Primaries uint8
14+ MaxCLL, MaxFALL uint16
15+ // Coefficients/range follow wp_color_representation_v1 (zero =
16+ // unspecified, for YUV interpreted as BT.709 limited). Chroma is a
17+ // H.273 Chroma420SampleLocType + 1 (zero defaults to type 0).
18+ Coefficients, Range, Chroma uint8
19+}
20+
21+const (
22+ ColorTFPQ uint8 = 11 // wp_color_manager_v1 ST2084 PQ
23+ ColorTFExtendedLinear uint8 = 5 // wp_color_manager_v1 ext_linear
24+ ColorPrimariesBT2020 uint8 = 6 // wp_color_manager_v1 BT.2020
25+ ColorPrimariesSRGB uint8 = 1
26+ ColorTFSRGB uint8 = 9 // wp_color_manager_v1 sRGB
27+)
28+
29+func (c SurfaceColor) IsPQ2020() bool {
30+ return c.TF == ColorTFPQ && c.Primaries == ColorPrimariesBT2020
31+}
32+
33+func (c SurfaceColor) IsExtendedLinear() bool {
34+ return c.TF == ColorTFExtendedLinear && c.Primaries == ColorPrimariesSRGB
35+}
36+
37+// ContentType is the committed wp_content_type_v1 hint.
38+type ContentType uint8
39+
40+const (
41+ ContentNone ContentType = iota
42+ ContentPhoto
43+ ContentVideo
44+ ContentGame
45+)
46+
47+// SurfaceContent carries wayland → output the latest committed content of a
48+// window: SHM, a client shared-memory buffer, DMABuf, a GPU buffer, or
49+// Solid, a single-pixel buffer. Renderers read them in place and never modify
50+// them. Empty means the window
51+// has no content. LogicalW and LogicalH are the surface size in logical
52+// pixels (buffer scale and viewport applied). Subsurfaces come in Children;
53+// Geometry is the part of the surface that is the window (xdg window
54+// geometry), the rest being client shadows.
55+type SurfaceContent struct {
56+ ID WindowID
57+ ContentType ContentType
58+ // Surface is a never-reused wl_surface identity; Version identifies its
59+ // applied buffer content independently of the window publication Seq.
60+ Surface, Version uint64
61+ // Seq counts the window's contents (wayland sets it), for release.
62+ Seq uint64
63+ Width, Height int
64+ LogicalW, LogicalH int
65+ // Source is the committed crop in buffer pixels: x, y, width, height.
66+ // Zero width means the entire buffer is used.
67+ Source [4]float32
68+ // Transform is the wl_output.transform the client applied to the
69+ // buffer: readers apply its inverse. Width and Height stay the buffer's.
70+ Transform BufferTransform
71+ // Opaque ignores the alpha byte: an x format, or an opaque region that
72+ // covers the whole surface. It is false while Fade is set.
73+ Opaque bool
74+ // OpaqueRect is the largest rect of the opaque region inside a surface
75+ // that is not wholly Opaque, in logical pixels from the surface origin:
76+ // the pixels there ignore alpha too, so the surfaces below it are hidden
77+ // by it. Empty when there is none or while Fade is set.
78+ OpaqueRect Rect
79+ // Fade is how much wp_alpha_modifier_v1 fades the surface: 0 none,
80+ // 1 invisible. Its opacity is multiplied by 1-Fade.
81+ Fade float32
82+ Color SurfaceColor
83+ SHM *SHMBuffer
84+ DMABuf *DMABuf
85+ // Solid is a single-pixel buffer (Width and Height are 1): the surface is
86+ // one flat color.
87+ Solid *SolidColor
88+ // Children are the subsurfaces, bottom to top, flattened.
89+ Children []Subsurface
90+ // Geometry is in logical pixels from the surface origin; empty means
91+ // the whole surface.
92+ Geometry Rect
93+ // Async asks for tearing presentation (wp_tearing_control_v1); outputs
94+ // honour it only in direct scanout.
95+ Async bool
96+ // Acquire is the explicit-sync fence of this content's root buffer
97+ // (wp_linux_drm_syncobj_v1): readers wait on it before reading the
98+ // buffer, instead of the buffer's implicit fences. Wayland owns it and
99+ // closes it when the buffer is released; a reader that keeps it past
100+ // the call duplicates it under Acquire.SyscallConn. nil: implicit sync.
101+ // Wayland closes it only after every output reported a later content of
102+ // the window as seen, so a reader never gets it already closed.
103+ Acquire *os.File
104+ // DamageHistory is what changed in the root surface's buffer over the
105+ // window's last contents, oldest first, ending with this one (Seq).
106+ // A renderer holding an older content redraws the union of the
107+ // entries after it, or everything when the history does not reach it.
108+ DamageHistory []SeqDamage
109+}
110+
111+// BufferTransform is a wl_output.transform value: bit 0 rotates 90°
112+// counter-clockwise, bit 1 rotates 180°, bit 2 flips around the vertical
113+// axis first.
114+type BufferTransform uint8
115+
116+// Rotated reports whether width and height swap between buffer and surface.
117+func (t BufferTransform) Rotated() bool { return t&1 != 0 }
118+
119+// Size returns the size of a w×h space seen through t: width and height swap
120+// when t is Rotated.
121+func (t BufferTransform) Size(w, h int) (int, int) {
122+ if t.Rotated() {
123+ return h, w
124+ }
125+ return w, h
126+}
127+
128+// ToBuffer maps a point of a w×h surface (logical, before scale) to the
129+// same point of its buffer, in the buffer's untransformed w'×h' axes, where
130+// w' and h' swap when the transform is Rotated.
131+func (t BufferTransform) ToBuffer(x, y, w, h float64) (float64, float64) {
132+ if t&4 != 0 {
133+ x = w - x
134+ }
135+ switch t & 3 {
136+ case 1:
137+ return y, w - x
138+ case 2:
139+ return w - x, h - y
140+ case 3:
141+ return h - y, x
142+ }
143+ return x, y
144+}
145+
146+// Invert is the transform that maps target points back: for a w×h scene and
147+// its w'×h' target, t.Invert().ToBuffer(t.ToBuffer(p, w, h), w', h') == p.
148+func (t BufferTransform) Invert() BufferTransform {
149+ if t&1 != 0 && t&4 == 0 {
150+ return t ^ 2
151+ }
152+ return t
153+}
154+
155+// RectToBuffer maps a rectangle of a w×h surface to the buffer. Integer
156+// rectangles stay integer under these transforms; rounding absorbs float noise.
157+func (t BufferTransform) RectToBuffer(r image.Rectangle, w, h int) image.Rectangle {
158+ ax, ay := t.ToBuffer(float64(r.Min.X), float64(r.Min.Y), float64(w), float64(h))
159+ bx, by := t.ToBuffer(float64(r.Max.X), float64(r.Max.Y), float64(w), float64(h))
160+ return image.Rect(int(math.Round(ax)), int(math.Round(ay)), int(math.Round(bx)), int(math.Round(by)))
161+}
162+
163+// SeqDamage is what content Seq changed from the one before: Rects in
164+// root buffer pixels, or everything when Full.
165+type SeqDamage struct {
166+ Seq uint64
167+ Full bool
168+ Rects []Rect
169+}
170+
171+// DamageSince is the union of changes after content seq up to this one,
172+// and false when the history does not reach back to seq (redraw all).
173+func (c SurfaceContent) DamageSince(seq uint64) ([]Rect, bool) {
174+ out, ok := c.AppendDamageSince(nil, seq)
175+ if !ok {
176+ return nil, false
177+ }
178+ return out, true
179+}
180+
181+// AppendDamageSince is DamageSince appending to dst, so a caller reusing
182+// its slice allocates nothing. When the history does not reach back to seq
183+// it returns dst unchanged and false.
184+func (c SurfaceContent) AppendDamageSince(dst []Rect, seq uint64) ([]Rect, bool) {
185+ if seq >= c.Seq {
186+ return dst, true
187+ }
188+ h := c.DamageHistory
189+ if len(h) == 0 || h[0].Seq > seq+1 || h[len(h)-1].Seq != c.Seq {
190+ return dst, false
191+ }
192+ n := len(dst)
193+ for _, d := range h {
194+ if d.Seq <= seq {
195+ continue
196+ }
197+ if d.Full {
198+ return dst[:n], false
199+ }
200+ dst = append(dst, d.Rects...)
201+ }
202+ return dst, true
203+}
204+
205+// Subsurface is a child surface at X, Y logical pixels from the root
206+// surface origin. Below children are drawn under the root surface.
207+type Subsurface struct {
208+ X, Y int
209+ Below bool
210+ SurfaceContent
211+}
212+
213+// HasBuffer reports whether the surface itself carries a client buffer.
214+func (c SurfaceContent) HasBuffer() bool {
215+ return c.SHM != nil || c.DMABuf != nil || c.Solid != nil
216+}
217+
218+// SolidColor is the color of a single-pixel buffer: premultiplied by alpha,
219+// 0 to 1, sRGB-encoded like the pixels of client buffers.
220+type SolidColor struct{ R, G, B, A float32 }
221+
222+// Empty reports whether the content has nothing to draw.
223+func (c SurfaceContent) Empty() bool {
224+ return !c.HasBuffer() && len(c.Children) == 0
225+}
226+
227+// SHMBuffer is a client wl_shm buffer: B8G8R8A8 pixels (argb8888/xrgb8888
228+// little-endian) at Offset in the pool File, Stride bytes per row. The
229+// client keeps writing the file, so renderers copy from it when they draw.
230+// Pool is unique per wl_shm pool for the session: renderers map a pool
231+// once. File stays open while the client's buffer exists and follows the
232+// DMABuf rules: duplicate it under File.SyscallConn before keeping it. A
233+// client may shrink the file at any time, so readers must survive faults.
234+type SHMBuffer struct {
235+ Pool uint64
236+ File *os.File
237+ Offset, Stride int
238+}
239+
240+// DMABuf is a client GPU buffer (linux-dmabuf). Its files stay open while
241+// the client's buffer exists; a renderer that keeps it must duplicate the
242+// descriptors under File.SyscallConn so a concurrent close cannot hand it a
243+// reused one. ID is unique per buffer for the whole session: renderers key
244+// their imports on it.
245+type DMABuf struct {
246+ ID uint64
247+ Width, Height int
248+ Format uint32 // DRM fourcc
249+ Modifier uint64
250+ Planes []DMABufPlane
251+}
252+
253+// DMABufPlane is one plane of a DMABuf.
254+type DMABufPlane struct {
255+ File *os.File
256+ Offset, Stride uint32
257+}
258+
259+// DMABufFormat is a DRM fourcc with a modifier the renderer can import.
260+type DMABufFormat struct {
261+ Format uint32
262+ Modifier uint64
263+}
264+
265+// DMABufSupport is what the renderer imports: the render device (a dev_t,
266+// 0 when unknown) and the formats. No formats means no dmabuf.
267+type DMABufSupport struct {
268+ Device uint64
269+ Formats []DMABufFormat
270+}
A internal/ports/workspace_events.go +25 -0
@@ -0,0 +1,25 @@
1+package ports
2+
3+// WorkspaceActivate requests showing workspaces by stable ID, in commit order.
4+// Core applies the entire batch before publishing a new state.
5+type WorkspaceActivate struct{ IDs []uint64 }
6+
7+func (WorkspaceActivate) clientEvent() {}
8+
9+// Workspaces is an immutable, latest-only inventory of output workspaces.
10+type Workspaces struct{ Outputs []WorkspaceOutput }
11+type WorkspaceOutput struct {
12+ Name string
13+ Workspaces []WorkspaceInfo
14+}
15+type WorkspaceInfo struct {
16+ ID uint64
17+ Configured string // configured name; empty for dynamic workspaces. ID, not this, identifies the workspace
18+ Name string
19+ Index int // zero-based vertical coordinate
20+ Active, Hidden bool
21+ // Frame is the workspace viewport in output-local logical pixels
22+ // (Monitor.Frame geometry: the whole output unless the workspace has a
23+ // size override). It is where a capture session of it lies.
24+ Frame Rect
25+}

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 26

Tags 10

↑↓ moveenter openesc close