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
66 files · +2119 -1458
M .mockery.yml +5 -0
| @@ -91,6 +91,11 @@ packages: | |||
| 91 | 91 | dir: internal/app | |
| 92 | 92 | pkgname: app | |
| 93 | 93 | filename: seatdisable_mock_test.go | |
| 94 | + | leaseInventoryCard: | |
| 95 | + | config: | |
| 96 | + | dir: internal/app | |
| 97 | + | pkgname: app | |
| 98 | + | filename: leaseinventory_mock_test.go | |
| 94 | 99 | github.com/bnema/neferwl/internal/adapters/statefile: | |
| 95 | 100 | interfaces: | |
| 96 | 101 | 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 | |
| 2 | 2 | ||
| 3 | 3 | # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net. | |
| 4 | 4 | TTY_TIMEOUT ?= 0 | |
| @@ -56,8 +56,17 @@ LOGCOMPONENT := "component"|FieldComponent | |||
| 56 | 56 | log-check: | |
| 57 | 57 | @rc=0; grep -rnE '$(LOGCOMPONENT)' --include='*.go' --exclude='*_test.go' --exclude-dir=logging internal cmd || rc=$$?; \ | |
| 58 | 58 | [ $$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 | |
| 60 | 60 | $(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; } | |
| 61 | 70 | # Fast, mandatory guards for tiled content publications and SHM copies. | |
| 62 | 71 | perf-check: | |
| 63 | 72 | 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 ( | |||
| 6 | 6 | "os" | |
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | - | "github.com/bnema/neferwl/internal/adapters/capture" | |
| 9 | + | "github.com/bnema/neferwl/internal/adapters/outputkit/capture" | |
| 10 | 10 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 11 | 11 | "github.com/bnema/neferwl/internal/ports" | |
| 12 | 12 | "github.com/stretchr/testify/mock" | |
M internal/adapters/drm/capture_test.go +1 -1
| @@ -11,7 +11,7 @@ import ( | |||
| 11 | 11 | "testing/synctest" | |
| 12 | 12 | "time" | |
| 13 | 13 | ||
| 14 | - | "github.com/bnema/neferwl/internal/adapters/capture" | |
| 14 | + | "github.com/bnema/neferwl/internal/adapters/outputkit/capture" | |
| 15 | 15 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 16 | 16 | "github.com/bnema/neferwl/internal/ports" | |
| 17 | 17 | "github.com/stretchr/testify/mock" | |
M internal/adapters/drm/frame_submit.go +1 -1
| @@ -5,7 +5,7 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "time" | |
| 7 | 7 | ||
| 8 | - | "github.com/bnema/neferwl/internal/adapters/capture" | |
| 8 | + | "github.com/bnema/neferwl/internal/adapters/outputkit/capture" | |
| 9 | 9 | "github.com/bnema/neferwl/internal/ports" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
M internal/adapters/drm/leaving_test.go +1 -1
| @@ -3,7 +3,7 @@ package drm | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | - | "github.com/bnema/neferwl/internal/adapters/surfaces" | |
| 6 | + | "github.com/bnema/neferwl/internal/adapters/outputkit/surfaces" | |
| 7 | 7 | "github.com/bnema/neferwl/internal/ports" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
M internal/adapters/drm/output.go +20 -51
| @@ -9,10 +9,10 @@ import ( | |||
| 9 | 9 | "time" | |
| 10 | 10 | "unsafe" | |
| 11 | 11 | ||
| 12 | - | "github.com/bnema/neferwl/internal/adapters/capture" | |
| 13 | 12 | "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" | |
| 16 | 16 | ||
| 17 | 17 | "github.com/bnema/neferwl/internal/ports" | |
| 18 | 18 | "github.com/bnema/zerowrap" | |
| @@ -1044,9 +1044,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1044 | 1044 | cursorScale := -1.0 // not loaded yet | |
| 1045 | 1045 | var cursorTransform ports.BufferTransform | |
| 1046 | 1046 | frame := 0 | |
| 1047 | - | var requestStorage [capture.MaxRequests]ports.CaptureRequest | |
| 1048 | - | requests := requestStorage[:0] | |
| 1047 | + | var requests capture.Requests | |
| 1049 | 1048 | ctx, cancelCaptures := context.WithCancel(ctx) | |
| 1049 | + | requests.Init(ctx, captured) | |
| 1050 | 1050 | pipeline := capture.NewPipeline(ctx, captured) | |
| 1051 | 1051 | pipeline.Security = o.Security | |
| 1052 | 1052 | if o.NewCaptureRenderer != nil { | |
| @@ -1056,19 +1056,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1056 | 1056 | defer func() { | |
| 1057 | 1057 | cancelCaptures() | |
| 1058 | 1058 | 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) | |
| 1072 | 1060 | }() | |
| 1073 | 1061 | clk := o.clock | |
| 1074 | 1062 | if clk == nil { | |
| @@ -1119,12 +1107,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1119 | 1107 | o.cursor.image, o.cursor.later = false, nil | |
| 1120 | 1108 | } | |
| 1121 | 1109 | } | |
| 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) | |
| 1128 | 1112 | } | |
| 1129 | 1113 | if o.protected && enabled && !o.frame.pendingCommit() && !o.securityPrepared && !o.protectBackoffActive() { | |
| 1130 | 1114 | 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 | |||
| 1200 | 1184 | gapTimer.Stop() | |
| 1201 | 1185 | } | |
| 1202 | 1186 | var holdDue <-chan time.Time | |
| 1203 | - | if len(requests) > 0 { | |
| 1187 | + | if requests.Len() > 0 { | |
| 1204 | 1188 | var wait time.Duration | |
| 1205 | - | requests, wait = pipeline.Expire(scene, requests, time.Now()) | |
| 1189 | + | requests.List, wait = pipeline.Expire(scene, requests.List, time.Now()) | |
| 1206 | 1190 | if wait > 0 { | |
| 1207 | 1191 | holdTimer.Reset(wait) | |
| 1208 | 1192 | holdDue = holdTimer.C | |
| @@ -1271,17 +1255,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1271 | 1255 | continue | |
| 1272 | 1256 | case q := <-captures: | |
| 1273 | 1257 | o.observeSecurity() | |
| 1258 | + | var refuse error | |
| 1274 | 1259 | 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 | |
| 1284 | 1261 | } | |
| 1262 | + | dirty = requests.Admit(q, refuse, scene, haveScene) || dirty | |
| 1285 | 1263 | case ack := <-o.seatDisable: | |
| 1286 | 1264 | o.bypassForSeatDisable() | |
| 1287 | 1265 | // 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 | |||
| 1421 | 1399 | if o.protected && !o.securityPrepared { | |
| 1422 | 1400 | continue | |
| 1423 | 1401 | } | |
| 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) | |
| 1430 | 1404 | } | |
| 1431 | 1405 | if !enabled || o.frame.pendingCommit() { | |
| 1432 | 1406 | continue | |
| @@ -1476,20 +1450,15 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1476 | 1450 | continue | |
| 1477 | 1451 | } | |
| 1478 | 1452 | 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) | |
| 1480 | 1454 | var fatal renderError | |
| 1481 | 1455 | 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) | |
| 1490 | 1459 | } | |
| 1491 | 1460 | // 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) | |
| 1493 | 1462 | if errors.As(err, &fatal) { | |
| 1494 | 1463 | return err | |
| 1495 | 1464 | } | |
M internal/adapters/drm/output_test.go +1 -1
| @@ -14,7 +14,7 @@ import ( | |||
| 14 | 14 | "testing/synctest" | |
| 15 | 15 | "time" | |
| 16 | 16 | ||
| 17 | - | "github.com/bnema/neferwl/internal/adapters/presented" | |
| 17 | + | "github.com/bnema/neferwl/internal/adapters/outputkit/presented" | |
| 18 | 18 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 19 | 19 | "github.com/bnema/neferwl/internal/ports" | |
| 20 | 20 | "github.com/bnema/zerowrap" | |
M internal/adapters/drm/report.go +4 -8
| @@ -3,6 +3,7 @@ package drm | |||
| 3 | 3 | import ( | |
| 4 | 4 | "maps" | |
| 5 | 5 | ||
| 6 | + | "github.com/bnema/neferwl/internal/adapters/outputkit/surfaces" | |
| 6 | 7 | "github.com/bnema/neferwl/internal/ports" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| @@ -28,14 +29,9 @@ func (o *Output) shownBy(s ports.Scene, seen map[ports.WindowID]uint64) map[port | |||
| 28 | 29 | if o.showsScratch == nil { | |
| 29 | 30 | o.showsScratch = make(map[ports.WindowID]uint64) | |
| 30 | 31 | } | |
| 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) | |
| 39 | 35 | if !maps.Equal(o.showsSnapshot, o.showsScratch) { | |
| 40 | 36 | o.showsSnapshot = maps.Clone(o.showsScratch) | |
| 41 | 37 | } | |
M internal/adapters/drm/security_capture_test.go +1 -1
| @@ -7,7 +7,7 @@ import ( | |||
| 7 | 7 | "sync/atomic" | |
| 8 | 8 | "testing" | |
| 9 | 9 | ||
| 10 | - | "github.com/bnema/neferwl/internal/adapters/capture" | |
| 10 | + | "github.com/bnema/neferwl/internal/adapters/outputkit/capture" | |
| 11 | 11 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 12 | 12 | "github.com/bnema/neferwl/internal/ports" | |
| 13 | 13 | "github.com/stretchr/testify/mock" | |
M internal/adapters/headless/capture_bounds_test.go +1 -1
| @@ -7,7 +7,7 @@ import ( | |||
| 7 | 7 | "testing" | |
| 8 | 8 | "time" | |
| 9 | 9 | ||
| 10 | - | "github.com/bnema/neferwl/internal/adapters/capture" | |
| 10 | + | "github.com/bnema/neferwl/internal/adapters/outputkit/capture" | |
| 11 | 11 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 12 | 12 | "github.com/bnema/neferwl/internal/ports" | |
| 13 | 13 | "github.com/stretchr/testify/mock" | |
M internal/adapters/headless/capture_test.go +1 -1
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | "testing" | |
| 11 | 11 | "time" | |
| 12 | 12 | ||
| 13 | - | "github.com/bnema/neferwl/internal/adapters/capture" | |
| 13 | + | "github.com/bnema/neferwl/internal/adapters/outputkit/capture" | |
| 14 | 14 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 15 | 15 | "github.com/bnema/neferwl/internal/ports" | |
| 16 | 16 | "github.com/stretchr/testify/mock" | |
M internal/adapters/headless/leaving_test.go +1 -1
| @@ -3,7 +3,7 @@ package headless | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | - | "github.com/bnema/neferwl/internal/adapters/surfaces" | |
| 6 | + | "github.com/bnema/neferwl/internal/adapters/outputkit/surfaces" | |
| 7 | 7 | "github.com/bnema/neferwl/internal/ports" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
M internal/adapters/headless/output.go +30 -63
| @@ -2,7 +2,6 @@ package headless | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "errors" | |
| 6 | 5 | "fmt" | |
| 7 | 6 | "image" | |
| 8 | 7 | "image/png" | |
| @@ -12,10 +11,10 @@ import ( | |||
| 12 | 11 | ||
| 13 | 12 | "golang.org/x/sys/unix" | |
| 14 | 13 | ||
| 15 | - | "github.com/bnema/neferwl/internal/adapters/capture" | |
| 16 | 14 | "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" | |
| 19 | 18 | "github.com/bnema/neferwl/internal/adapters/syncfile" | |
| 20 | 19 | "github.com/bnema/neferwl/internal/ports" | |
| 21 | 20 | "github.com/bnema/zerowrap" | |
| @@ -93,9 +92,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 93 | 92 | var scene ports.Scene | |
| 94 | 93 | haveScene, dirty := false, false | |
| 95 | 94 | frame := 0 | |
| 96 | - | var requestStorage [capture.MaxRequests]ports.CaptureRequest | |
| 97 | - | requests := requestStorage[:0] | |
| 95 | + | var requests capture.Requests | |
| 98 | 96 | ctx, cancelCaptures := context.WithCancel(ctx) | |
| 97 | + | requests.Init(ctx, opts.Captured) | |
| 99 | 98 | pipeline := capture.NewPipeline(ctx, opts.Captured) | |
| 100 | 99 | pipeline.Security = opts.Security | |
| 101 | 100 | if opts.NewCaptureRenderer != nil { | |
| @@ -106,23 +105,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 106 | 105 | pipeline.EndGate(capture.ErrFrameNotPresented) | |
| 107 | 106 | cancelCaptures() | |
| 108 | 107 | 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) | |
| 126 | 109 | }() | |
| 127 | 110 | var want ports.CursorChange | |
| 128 | 111 | cursorScale := -1.0 // not loaded yet | |
| @@ -132,16 +115,10 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 132 | 115 | var reports presented.Queue | |
| 133 | 116 | var security ports.SecurityState | |
| 134 | 117 | securityKnown := false | |
| 135 | - | failRequests := func(reason string) { | |
| 118 | + | failRequests := func(reason error) { | |
| 136 | 119 | // 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) | |
| 145 | 122 | } | |
| 146 | 123 | // A transition is its own frame, never a locker-buffer-dependent frame. | |
| 147 | 124 | // 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 | |||
| 194 | 171 | } | |
| 195 | 172 | // Capture reply backpressure must not postpone the first black | |
| 196 | 173 | // completion/proof. Capture drainage is a separate parent barrier. | |
| 197 | - | failRequests("session protected") | |
| 174 | + | failRequests(capture.ErrSessionProtected) | |
| 198 | 175 | } | |
| 199 | 176 | } | |
| 200 | 177 | acceptScene := func(s ports.Scene) { | |
| @@ -245,9 +222,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 245 | 222 | // Requests wait for a scene that shows their indicator, at most | |
| 246 | 223 | // capture.HoldFor (see capture.Hold). | |
| 247 | 224 | var holdDue <-chan time.Time | |
| 248 | - | if len(requests) > 0 { | |
| 225 | + | if requests.Len() > 0 { | |
| 249 | 226 | var wait time.Duration | |
| 250 | - | requests, wait = pipeline.Expire(scene, requests, time.Now()) | |
| 227 | + | requests.List, wait = pipeline.Expire(scene, requests.List, time.Now()) | |
| 251 | 228 | if wait > 0 { | |
| 252 | 229 | holdTimer.Reset(wait) | |
| 253 | 230 | holdDue = holdTimer.C | |
| @@ -300,25 +277,19 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 300 | 277 | } | |
| 301 | 278 | if err := checkSecurity(); err != nil { | |
| 302 | 279 | // 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) | |
| 304 | 281 | if ctx.Err() != nil { | |
| 305 | 282 | return nil | |
| 306 | 283 | } | |
| 307 | 284 | return err | |
| 308 | 285 | } | |
| 286 | + | var refuse error | |
| 309 | 287 | if opts.protected() { | |
| 310 | - | capture.Fail(ctx, q, fmt.Errorf("session protected"), opts.Captured) | |
| 288 | + | refuse = capture.ErrSessionProtected | |
| 311 | 289 | } 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 | |
| 321 | 291 | } | |
| 292 | + | dirty = requests.Admit(q, refuse, scene, haveScene) || dirty | |
| 322 | 293 | case <-retry: | |
| 323 | 294 | if err := checkSecurity(); err != nil { | |
| 324 | 295 | if ctx.Err() != nil { | |
| @@ -419,14 +390,10 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 419 | 390 | return err | |
| 420 | 391 | } | |
| 421 | 392 | if opts.protected() { | |
| 422 | - | failRequests("session protected") | |
| 393 | + | failRequests(capture.ErrSessionProtected) | |
| 423 | 394 | } | |
| 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) | |
| 430 | 397 | } | |
| 431 | 398 | if !haveScene || !dirty || scene.Off { | |
| 432 | 399 | // 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 | |||
| 458 | 425 | // Requests the scene cannot serve (exclusion of another session, a | |
| 459 | 426 | // workspace that moved) are failed here; see capture.Pipeline.Split. | |
| 460 | 427 | // 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()) | |
| 462 | 429 | if len(scene.CaptureIndicators) > 0 { | |
| 463 | 430 | // The captures of this frame are delivered only once its frame, | |
| 464 | 431 | // with the indicator, is presented (capture.Pipeline.EndGate). | |
| @@ -470,7 +437,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 470 | 437 | // A hidden workspace: a child renderer draws it. The display's | |
| 471 | 438 | // fences do not cover the child, so wait for it before reporting. | |
| 472 | 439 | if opts.Security != nil && opts.Security.Snapshot() != scene.Security { | |
| 473 | - | failRequests("security epoch changed") | |
| 440 | + | failRequests(capture.ErrEpochChanged) | |
| 474 | 441 | continue | |
| 475 | 442 | } | |
| 476 | 443 | pipeline.SubmitHidden(scene, drawn, hidden) | |
| @@ -518,7 +485,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 518 | 485 | // Exclude requests: the frame without the excluded surfaces is | |
| 519 | 486 | // drawn first (same renderer, queue order), then the displayed one. | |
| 520 | 487 | if opts.Security != nil && opts.Security.Snapshot() != scene.Security { | |
| 521 | - | failRequests("security epoch changed") | |
| 488 | + | failRequests(capture.ErrEpochChanged) | |
| 522 | 489 | continue | |
| 523 | 490 | } | |
| 524 | 491 | xdone, renderErr := r.Render(pipeline.ExcludedScene(scene), drawn) | |
| @@ -539,14 +506,14 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 539 | 506 | return fmt.Errorf("render excluded frame: %w", renderErr) | |
| 540 | 507 | } | |
| 541 | 508 | if opts.Security != nil && opts.Security.Snapshot() != scene.Security { | |
| 542 | - | failRequests("security epoch changed") | |
| 509 | + | failRequests(capture.ErrEpochChanged) | |
| 543 | 510 | continue | |
| 544 | 511 | } | |
| 545 | 512 | pipeline.SubmitScoped(scene.Security, r, excluded) | |
| 546 | 513 | clear(excluded) | |
| 547 | 514 | } | |
| 548 | 515 | if opts.Security != nil && opts.Security.Snapshot() != scene.Security { | |
| 549 | - | failRequests("security epoch changed") | |
| 516 | + | failRequests(capture.ErrEpochChanged) | |
| 550 | 517 | continue | |
| 551 | 518 | } | |
| 552 | 519 | done, err := r.Render(scene, drawn) | |
| @@ -571,13 +538,13 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 571 | 538 | // Do not capture or report a frame that crossed a transition during | |
| 572 | 539 | // Render/fence wait. The next loop establishes the new black proof. | |
| 573 | 540 | if opts.Security != nil && opts.Security.Snapshot() != scene.Security { | |
| 574 | - | failRequests("security epoch changed") | |
| 541 | + | failRequests(capture.ErrEpochChanged) | |
| 575 | 542 | continue | |
| 576 | 543 | } | |
| 577 | 544 | pipeline.SubmitScoped(scene.Security, r, normal) | |
| 578 | 545 | // The indicated frame is presented: release what it gated. | |
| 579 | 546 | pipeline.EndGate(nil) | |
| 580 | - | requests = capture.Waiting(requests, scene) | |
| 547 | + | requests.List = capture.Waiting(requests.List, scene) | |
| 581 | 548 | frame++ | |
| 582 | 549 | opts.report(&reports, flipInfo(scene, drawn, table.Kept), seen) | |
| 583 | 550 | if opts.ScreenshotDir != "" && !opts.protected() { | |
| @@ -671,14 +638,14 @@ func (opts Options) flush(q *presented.Queue) { | |||
| 671 | 638 | // flipInfo describes a drawn frame as a flip at the current CLOCK_MONOTONIC | |
| 672 | 639 | // time (software clock, refresh unknown), so presentation feedback and | |
| 673 | 640 | // 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 { | |
| 675 | 642 | var ts unix.Timespec | |
| 676 | 643 | _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts) | |
| 677 | 644 | shows := map[ports.WindowID]uint64{} | |
| 678 | - | for id, c := range surfaces { | |
| 645 | + | for id, c := range contents { | |
| 679 | 646 | // A closed window drawn from its kept content is not shown to | |
| 680 | 647 | // its client. | |
| 681 | - | if scene.Shows(id) && !kept(id) { | |
| 648 | + | if surfaces.Shown(scene, id, kept) { | |
| 682 | 649 | shows[id] = c.Seq | |
| 683 | 650 | } | |
| 684 | 651 | } | |
M internal/adapters/headless/security_capture_test.go +1 -1
| @@ -8,7 +8,7 @@ import ( | |||
| 8 | 8 | "sync/atomic" | |
| 9 | 9 | "testing" | |
| 10 | 10 | ||
| 11 | - | "github.com/bnema/neferwl/internal/adapters/capture" | |
| 11 | + | "github.com/bnema/neferwl/internal/adapters/outputkit/capture" | |
| 12 | 12 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 13 | 13 | "github.com/bnema/neferwl/internal/ports" | |
| 14 | 14 | "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 @@ | |||
| 4 | 4 | package surfaces | |
| 5 | 5 | ||
| 6 | 6 | import ( | |
| 7 | - | "github.com/bnema/neferwl/internal/adapters/capture" | |
| 7 | + | "github.com/bnema/neferwl/internal/adapters/outputkit/capture" | |
| 8 | 8 | "github.com/bnema/neferwl/internal/ports" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| @@ -98,3 +98,21 @@ func (t *Table) Kept(id ports.WindowID) bool { | |||
| 98 | 98 | _, ok := t.kept[id] | |
| 99 | 99 | return ok | |
| 100 | 100 | } | |
| 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) { | |||
| 122 | 122 | t.Fatal("wayland's slice was modified") | |
| 123 | 123 | } | |
| 124 | 124 | } | |
| 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 ( | |||
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/go-wayland-bindings/server/fifo" | |
| 12 | 12 | "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" | |
| 14 | 14 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 15 | 15 | "github.com/bnema/neferwl/internal/ports" | |
| 16 | 16 | "github.com/stretchr/testify/mock" | |
M internal/adapters/wayland/session_lock_barrier_test.go +1 -1
| @@ -8,8 +8,8 @@ import ( | |||
| 8 | 8 | "testing" | |
| 9 | 9 | "time" | |
| 10 | 10 | ||
| 11 | - | "github.com/bnema/neferwl/internal/adapters/capture" | |
| 12 | 11 | "github.com/bnema/neferwl/internal/adapters/logging" | |
| 12 | + | "github.com/bnema/neferwl/internal/adapters/outputkit/capture" | |
| 13 | 13 | "github.com/bnema/neferwl/internal/adapters/sessionsecurity" | |
| 14 | 14 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 15 | 15 | "github.com/bnema/neferwl/internal/ports" | |
M internal/app/drm.go +13 -66
| @@ -8,7 +8,6 @@ import ( | |||
| 8 | 8 | "path/filepath" | |
| 9 | 9 | "runtime" | |
| 10 | 10 | "runtime/debug" | |
| 11 | - | "slices" | |
| 12 | 11 | "sort" | |
| 13 | 12 | "sync" | |
| 14 | 13 | "sync/atomic" | |
| @@ -22,7 +21,6 @@ import ( | |||
| 22 | 21 | "github.com/bnema/neferwl/internal/adapters/seat" | |
| 23 | 22 | "github.com/bnema/neferwl/internal/ports" | |
| 24 | 23 | "github.com/bnema/zerowrap" | |
| 25 | - | "golang.org/x/sys/unix" | |
| 26 | 24 | ) | |
| 27 | 25 | ||
| 28 | 26 | var errTimeout = errors.New("timeout") | |
| @@ -307,59 +305,8 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||
| 307 | 305 | set := newOutputSet(ctx, ch.captured) | |
| 308 | 306 | set.wireSecurity(ch) | |
| 309 | 307 | 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() | |
| 363 | 310 | hotplug := make(chan struct{}, 1) | |
| 364 | 311 | go func() { | |
| 365 | 312 | 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 | |||
| 408 | 355 | var errs []error | |
| 409 | 356 | for _, c := range b.cards { | |
| 410 | 357 | 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}) | |
| 412 | 359 | })) | |
| 413 | - | publishLeases(c) | |
| 360 | + | leases.publish(c) | |
| 414 | 361 | } | |
| 415 | 362 | errs = append(errs, discardedOutputErr) | |
| 416 | 363 | err := errors.Join(errs...) | |
| @@ -460,7 +407,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||
| 460 | 407 | scanErrors = append(scanErrors, fmt.Errorf("scan %s: %w", c.Path(), err)) | |
| 461 | 408 | continue | |
| 462 | 409 | } | |
| 463 | - | publishLeases(c) | |
| 410 | + | leases.publish(c) | |
| 464 | 411 | stop := func(name string, restart bool) { | |
| 465 | 412 | if r := set.outs[name]; r != nil && cards[name] == c { | |
| 466 | 413 | if _, ok := stopping[name]; !ok { | |
| @@ -640,7 +587,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||
| 640 | 587 | barrier() | |
| 641 | 588 | } else { | |
| 642 | 589 | for _, c := range b.cards { | |
| 643 | - | publishLeases(c) | |
| 590 | + | leases.publish(c) | |
| 644 | 591 | } | |
| 645 | 592 | } | |
| 646 | 593 | case <-revokeDeadline: | |
| @@ -664,14 +611,14 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||
| 664 | 611 | if err := c.Revoke(id); err != nil { | |
| 665 | 612 | log.Warn().Err(err).Uint32("lease", id).Msg("VT lease revoke") | |
| 666 | 613 | } else { | |
| 667 | - | sendLease(ports.LeaseFinished{Card: c.Path(), LeaseID: id}) | |
| 614 | + | leases.send(ports.LeaseFinished{Card: c.Path(), LeaseID: id}) | |
| 668 | 615 | } | |
| 669 | 616 | } | |
| 670 | - | publishLeases(c) | |
| 617 | + | leases.publish(c) | |
| 671 | 618 | } | |
| 672 | 619 | } else { | |
| 673 | 620 | for _, c := range b.cards { | |
| 674 | - | publishLeases(c) | |
| 621 | + | leases.publish(c) | |
| 675 | 622 | } | |
| 676 | 623 | } | |
| 677 | 624 | case event := <-ch.leaseRequests: | |
| @@ -680,24 +627,24 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||
| 680 | 627 | reply := ports.LeaseReply{ID: req.ID, Err: fmt.Errorf("unknown card %s", req.Card)} | |
| 681 | 628 | if !seatActive || protected() { | |
| 682 | 629 | reply.Err = errors.New("DRM seat inactive or session protected") | |
| 683 | - | sendLease(reply) | |
| 630 | + | leases.send(reply) | |
| 684 | 631 | break | |
| 685 | 632 | } | |
| 686 | 633 | for _, c := range b.cards { | |
| 687 | 634 | if c.Path() == req.Card { | |
| 688 | 635 | reply.FD, reply.LeaseID, reply.Err = guardedLease(c, ch.security, req.Connectors) | |
| 689 | - | publishLeases(c) | |
| 636 | + | leases.publish(c) | |
| 690 | 637 | break | |
| 691 | 638 | } | |
| 692 | 639 | } | |
| 693 | - | sendLease(reply) | |
| 640 | + | leases.send(reply) | |
| 694 | 641 | case ports.LeaseRevoke: | |
| 695 | 642 | for _, c := range b.cards { | |
| 696 | 643 | if c.Path() == req.Card { | |
| 697 | 644 | if err := c.Revoke(req.LeaseID); err != nil { | |
| 698 | 645 | log.Warn().Err(err).Uint32("lease", req.LeaseID).Msg("revoke lease") | |
| 699 | 646 | } else { | |
| 700 | - | publishLeases(c) | |
| 647 | + | leases.publish(c) | |
| 701 | 648 | } | |
| 702 | 649 | break | |
| 703 | 650 | } | |
M internal/app/headless.go +3 -9
| @@ -2,6 +2,7 @@ package app | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "os" | |
| 7 | 8 | "path/filepath" | |
| @@ -37,7 +38,7 @@ func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch | |||
| 37 | 38 | dir = filepath.Join(o.shots, name) | |
| 38 | 39 | if err := os.MkdirAll(dir, 0o755); err != nil { | |
| 39 | 40 | // Outputs already started stop and release their captures. | |
| 40 | - | return joinErr(err, set.wait()) | |
| 41 | + | return errors.Join(err, set.wait()) | |
| 41 | 42 | } | |
| 42 | 43 | } | |
| 43 | 44 | cur := &headless.Cursor{} | |
| @@ -105,14 +106,7 @@ func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch | |||
| 105 | 106 | } | |
| 106 | 107 | // An owner error is not protection or removal evidence. | |
| 107 | 108 | err := set.finish(stopped.name) | |
| 108 | - | return joinErr(err, set.wait()) | |
| 109 | + | return errors.Join(err, set.wait()) | |
| 109 | 110 | } | |
| 110 | 111 | } | |
| 111 | 112 | } | |
| 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | - | "fmt" | |
| 6 | - | "image" | |
| 7 | - | "maps" | |
| 8 | - | "math" | |
| 9 | - | "reflect" | |
| 10 | - | "slices" | |
| 11 | 5 | ||
| 6 | + | "github.com/bnema/neferwl/internal/core" | |
| 12 | 7 | "github.com/bnema/neferwl/internal/ports" | |
| 13 | 8 | "github.com/bnema/zerowrap" | |
| 14 | 9 | ) | |
| 15 | 10 | ||
| 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 | - | ||
| 167 | 11 | // errApplySuperseded answers a request whose configuration a reload replaced. | |
| 168 | 12 | var errApplySuperseded = errors.New("output apply superseded by reload") | |
| 169 | 13 | ||
| @@ -174,7 +18,7 @@ var errApplySuperseded = errors.New("output apply superseded by reload") | |||
| 174 | 18 | // configuration, Wayland replies and inventory) with sends that never block | |
| 175 | 19 | // the loop. | |
| 176 | 20 | type outputApply struct { | |
| 177 | - | state *outputOverrides | |
| 21 | + | state *core.OutputOverrides | |
| 178 | 22 | log zerowrap.Logger | |
| 179 | 23 | // running is set while the backend applies a configuration. The next | |
| 180 | 24 | // one waits in start until it finishes: configurations never overlap. | |
| @@ -195,20 +39,20 @@ type outputApply struct { | |||
| 195 | 39 | headsDue bool | |
| 196 | 40 | } | |
| 197 | 41 | ||
| 198 | - | func newOutputApply(state *outputOverrides, log zerowrap.Logger) *outputApply { | |
| 42 | + | func newOutputApply(state *core.OutputOverrides, log zerowrap.Logger) *outputApply { | |
| 199 | 43 | return &outputApply{state: state, log: log} | |
| 200 | 44 | } | |
| 201 | 45 | ||
| 202 | 46 | // reload replaces the file configuration and supersedes the pending request. | |
| 203 | 47 | // Only the latest reload needs backend work. | |
| 204 | 48 | func (a *outputApply) reload(file ports.Config) { | |
| 205 | - | cfg := a.state.reload(file) | |
| 49 | + | cfg := a.state.Reload(file) | |
| 206 | 50 | a.setConfig(cfg) | |
| 207 | 51 | if a.req != nil { | |
| 208 | 52 | a.reply(a.req.ID, errApplySuperseded) | |
| 209 | 53 | a.req = nil | |
| 210 | 54 | } | |
| 211 | - | if !a.state.headless { | |
| 55 | + | if !a.state.Headless() { | |
| 212 | 56 | a.due(cfg) | |
| 213 | 57 | } | |
| 214 | 58 | } | |
| @@ -216,7 +60,7 @@ func (a *outputApply) reload(file ports.Config) { | |||
| 216 | 60 | // heads records the backend inventory: it validates requests and waits for | |
| 217 | 61 | // Wayland in the outbox, latest only. | |
| 218 | 62 | func (a *outputApply) heads(heads ports.OutputHeads) { | |
| 219 | - | a.state.heads = heads | |
| 63 | + | a.state.SetHeads(heads) | |
| 220 | 64 | a.headsDue = true | |
| 221 | 65 | } | |
| 222 | 66 | ||
| @@ -227,15 +71,14 @@ func (a *outputApply) request(req ports.OutputApply) { | |||
| 227 | 71 | a.reply(req.ID, errors.New("output apply already pending")) | |
| 228 | 72 | return | |
| 229 | 73 | } | |
| 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) | |
| 233 | 76 | if err != nil || req.Test { | |
| 234 | 77 | a.reply(req.ID, err) | |
| 235 | 78 | return | |
| 236 | 79 | } | |
| 237 | 80 | a.setConfig(cfg) | |
| 238 | - | if a.state.headless { | |
| 81 | + | if a.state.Headless() { | |
| 239 | 82 | a.reply(req.ID, nil) | |
| 240 | 83 | return | |
| 241 | 84 | } | |
| @@ -277,8 +120,7 @@ func (a *outputApply) finished(err error) { | |||
| 277 | 120 | return | |
| 278 | 121 | } | |
| 279 | 122 | if err != nil { | |
| 280 | - | a.state.overrides = a.previous | |
| 281 | - | cfg := a.state.effective() | |
| 123 | + | cfg := a.state.Restore(a.previous) | |
| 282 | 124 | a.setConfig(cfg) | |
| 283 | 125 | a.rollback, a.failure = true, err | |
| 284 | 126 | a.due(cfg) | |
| @@ -338,7 +180,7 @@ func (a *outputApply) headsOut(ch chan<- ports.OutputHeads) (chan<- ports.Output | |||
| 338 | 180 | if !a.headsDue { | |
| 339 | 181 | return nil, ports.OutputHeads{} | |
| 340 | 182 | } | |
| 341 | - | return ch, a.state.heads | |
| 183 | + | return ch, a.state.Heads() | |
| 342 | 184 | } | |
| 343 | 185 | ||
| 344 | 186 | // headsSent records that Wayland received the latest inventory. | |
M internal/app/outputmanagement_test.go +3 -139
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/neferwl/internal/adapters/logging" | |
| 10 | + | "github.com/bnema/neferwl/internal/core" | |
| 10 | 11 | "github.com/bnema/neferwl/internal/ports" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| @@ -18,7 +19,7 @@ type applyRig struct { | |||
| 18 | 19 | ||
| 19 | 20 | func newApplyRig(t *testing.T, headless bool) applyRig { | |
| 20 | 21 | 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")) | |
| 22 | 23 | a.heads(ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}) | |
| 23 | 24 | return applyRig{t, a} | |
| 24 | 25 | } | |
| @@ -113,7 +114,7 @@ func TestOutputApply(t *testing.T) { | |||
| 113 | 114 | if len(got) != 1 || got[0].ID != 1 || !errors.Is(got[0].Err, tc.failure) || (got[0].Err != nil) != (tc.failure != nil) { | |
| 114 | 115 | t.Fatalf("replies: %+v, want %v", got, tc.failure) | |
| 115 | 116 | } | |
| 116 | - | if tc.failure != nil && len(r.a.state.effective().Outputs) != 0 { | |
| 117 | + | if tc.failure != nil && len(r.a.state.Effective().Outputs) != 0 { | |
| 117 | 118 | t.Fatal("override survived failure") | |
| 118 | 119 | } | |
| 119 | 120 | r.a.reload(ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.25}}}) | |
| @@ -176,20 +177,6 @@ func TestOutputApplyOutbox(t *testing.T) { | |||
| 176 | 177 | } | |
| 177 | 178 | } | |
| 178 | 179 | ||
| 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 | - | ||
| 193 | 180 | // A reload supersedes a request the backend is applying: the request fails at | |
| 194 | 181 | // once, and a late failure of its configuration cannot roll back the reload. | |
| 195 | 182 | func TestReloadDuringPendingApply(t *testing.T) { | |
| @@ -283,126 +270,3 @@ func TestOutputApplyAnsweredAtOnce(t *testing.T) { | |||
| 283 | 270 | t.Fatalf("headless reply: %+v", got) | |
| 284 | 271 | } | |
| 285 | 272 | } | |
| 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 ( | |||
| 6 | 6 | "sync" | |
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | - | "github.com/bnema/neferwl/internal/adapters/capture" | |
| 9 | + | "github.com/bnema/neferwl/internal/adapters/outputkit/capture" | |
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/neferwl/internal/ports" | |
| 12 | 12 | ) | |
M internal/app/session.go +1 -1
| @@ -335,7 +335,7 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent)) | |||
| 335 | 335 | go func() { | |
| 336 | 336 | defer workers.Done() | |
| 337 | 337 | 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")) | |
| 339 | 339 | if hw != nil { | |
| 340 | 340 | done <- safe("output", func() error { | |
| 341 | 341 | 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 { | |||
| 76 | 76 | // workspace while overviewReal is filled) and by state(); never retained. | |
| 77 | 77 | realBuf []Placement | |
| 78 | 78 | 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 | |
| 82 | 80 | inputKeys map[string]bool | |
| 83 | 81 | inputActive bool | |
| 84 | 82 | startup [][]string | |
| @@ -115,18 +113,15 @@ type Core struct { | |||
| 115 | 113 | seq uint64 | |
| 116 | 114 | // layerChanged is set once layer state arrives from wayland. | |
| 117 | 115 | layerChanged bool | |
| 118 | - | sentOutputs ports.SetOutputs | |
| 116 | + | sent sentCache | |
| 119 | 117 | specs []NamedWorkspace | |
| 120 | 118 | presets []Width | |
| 121 | 119 | slots map[slotKey]*slotState | |
| 122 | 120 | placement spawnPlacement | |
| 123 | 121 | // toSpawn holds slots to start; Run sends them (apply has no context). | |
| 124 | 122 | toSpawn []slotKey | |
| 125 | - | sentPending bool | |
| 126 | 123 | firstTerminalResolved bool | |
| 127 | 124 | windows windowRegistry | |
| 128 | - | sentState ports.State | |
| 129 | - | sentWorkspaces ports.Workspaces | |
| 130 | 125 | // popups are the placed xdg_popups; popupOrder stacks them, oldest first. | |
| 131 | 126 | popups map[WindowID]*popupState | |
| 132 | 127 | popupOrder []WindowID | |
| @@ -154,6 +149,15 @@ type Core struct { | |||
| 154 | 149 | pulse focusPulse | |
| 155 | 150 | } | |
| 156 | 151 | ||
| 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 | + | ||
| 157 | 161 | func keyName(s string) string { | |
| 158 | 162 | if len(s) == 1 && unicode.IsLetter(rune(s[0])) { | |
| 159 | 163 | return strings.ToLower(s) | |
| @@ -314,11 +318,11 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 314 | 318 | // publishPending tells wayland at once when slots start waiting, before | |
| 315 | 319 | // their windows can map. | |
| 316 | 320 | 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 { | |
| 318 | 322 | if err := c.command(ctx, ports.SlotsPending{Pending: p}); err != nil { | |
| 319 | 323 | return err | |
| 320 | 324 | } | |
| 321 | - | c.sentPending = p | |
| 325 | + | c.sent.pending = p | |
| 322 | 326 | } | |
| 323 | 327 | return nil | |
| 324 | 328 | } | |
| @@ -587,134 +591,11 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error { | |||
| 587 | 591 | // Before the first output (and after the last is unplugged) the | |
| 588 | 592 | // placeholder's scene has no output name: no renderer draws it. | |
| 589 | 593 | 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 | |
| 717 | 597 | } | |
| 598 | + | drawable = drawable || shown | |
| 718 | 599 | sc.last = scene | |
| 719 | 600 | scenes = append(scenes, scene) | |
| 720 | 601 | } | |
| @@ -958,16 +839,16 @@ func (c *Core) warpPointer(ctx context.Context, v ports.PointerWarp) error { | |||
| 958 | 839 | // geometry only when the outputs themselves moved. | |
| 959 | 840 | func (c *Core) syncOutputs(ctx context.Context) error { | |
| 960 | 841 | 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) { | |
| 962 | 843 | return nil | |
| 963 | 844 | } | |
| 964 | 845 | if err := c.command(ctx, v); err != nil { | |
| 965 | 846 | return err | |
| 966 | 847 | } | |
| 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) { | |
| 968 | 849 | latest(c.ch.Layouts, v.Outputs) | |
| 969 | 850 | } | |
| 970 | - | c.sentOutputs = v | |
| 851 | + | c.sent.outputs = v | |
| 971 | 852 | return nil | |
| 972 | 853 | } | |
| 973 | 854 | ||
| @@ -1117,140 +998,11 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1117 | 998 | if c.blockProtected(ev) { | |
| 1118 | 999 | continue | |
| 1119 | 1000 | } | |
| 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 | |
| 1252 | 1004 | } | |
| 1253 | - | if err := c.publish(ctx); err != nil { | |
| 1005 | + | if publish && c.publish(ctx) != nil { | |
| 1254 | 1006 | return nil | |
| 1255 | 1007 | } | |
| 1256 | 1008 | case f, ok := <-c.ch.Frames: | |
M internal/core/input.go +220 -200
| @@ -7,6 +7,8 @@ import ( | |||
| 7 | 7 | "github.com/bnema/neferwl/internal/ports" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | + | // handleInput dispatches one input event to its handler. A protected | |
| 11 | + | // session routes every event to protectedInput instead. | |
| 10 | 12 | func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |
| 11 | 13 | c.inputActive = true | |
| 12 | 14 | defer func() { c.inputActive = false }() | |
| @@ -18,173 +20,187 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||
| 18 | 20 | } | |
| 19 | 21 | switch v := ev.(type) { | |
| 20 | 22 | 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) | |
| 40 | 31 | } | |
| 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) | |
| 44 | 37 | } | |
| 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 { | |
| 50 | 64 | return err | |
| 51 | 65 | } | |
| 52 | 66 | } | |
| 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 | |
| 58 | 84 | } | |
| 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) | |
| 59 | 95 | 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 { | |
| 65 | 99 | delete(c.buttons, v.Button) | |
| 66 | - | return nil | |
| 100 | + | return c.endDrag(ctx) | |
| 67 | 101 | } | |
| 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 { | |
| 78 | 116 | return nil | |
| 79 | 117 | } | |
| 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 { | |
| 81 | 126 | return err | |
| 82 | 127 | } | |
| 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 | |
| 89 | 132 | } | |
| 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 | |
| 93 | 141 | } | |
| 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 { | |
| 97 | 145 | return err | |
| 98 | 146 | } | |
| 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 { | |
| 111 | 151 | return err | |
| 112 | 152 | } | |
| 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 | |
| 140 | 153 | } | |
| 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()) { | |
| 149 | 157 | 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 | |
| 159 | 164 | } | |
| 160 | - | return nil | |
| 161 | 165 | } | |
| 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 { | |
| 166 | 188 | return err | |
| 167 | 189 | } | |
| 168 | 190 | } | |
| 169 | 191 | 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)) | |
| 183 | 192 | } | |
| 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 | + | } | |
| 187 | 199 | } | |
| 200 | + | return nil | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func (c *Core) keyEvent(ctx context.Context, key ports.KeyEvent) error { | |
| 188 | 204 | c.mods = key.Mods | |
| 189 | 205 | if c.drag != nil && key.Pressed && key.Keysym == "Escape" { | |
| 190 | 206 | // Escape cancels the drag; its release goes nowhere either. | |
| @@ -253,68 +269,7 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||
| 253 | 269 | if key.Pressed { | |
| 254 | 270 | if bound { | |
| 255 | 271 | 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) | |
| 318 | 273 | } | |
| 319 | 274 | c.pressed[held] = false | |
| 320 | 275 | } | |
| @@ -325,3 +280,68 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||
| 325 | 280 | } | |
| 326 | 281 | return nil | |
| 327 | 282 | } | |
| 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 ( | |||
| 7 | 7 | "github.com/bnema/neferwl/internal/ports" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 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 | + | ||
| 10 | 18 | // syncSecurity runs only on the owner. The gate, not event selection order, | |
| 11 | 19 | // establishes the epoch; surface events can only fill in that exact epoch. | |
| 12 | 20 | func (c *Core) syncSecurity() bool { | |
| @@ -21,7 +29,7 @@ func (c *Core) syncSecurity() bool { | |||
| 21 | 29 | return false | |
| 22 | 30 | } | |
| 23 | 31 | c.security = state | |
| 24 | - | c.lockSurfaces, c.lockFocus, c.lockPinned = nil, 0, false | |
| 32 | + | c.lock = lockState{} | |
| 25 | 33 | c.pressed, c.buttons, c.inputKeys = map[string]bool{}, map[uint32]bool{}, map[string]bool{} | |
| 26 | 34 | c.pointer, c.grab, c.pointerOutput = 0, 0, "" | |
| 27 | 35 | 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 { | |||
| 95 | 103 | if !c.security.Protected { | |
| 96 | 104 | return true | |
| 97 | 105 | } | |
| 98 | - | c.lockSurfaces = slices.Clone(v.Surfaces) | |
| 106 | + | c.lock.surfaces = slices.Clone(v.Surfaces) | |
| 99 | 107 | c.lockKeyboardFocus() | |
| 100 | 108 | // A destroyed or resized role must not retain a pointer grab. | |
| 101 | 109 | if !c.validLockID(c.pointer) { | |
| @@ -110,7 +118,7 @@ func (c *Core) applyLockChanged(v ports.SessionLockChanged) bool { | |||
| 110 | 118 | ||
| 111 | 119 | func (c *Core) lockSurface(sc *screen) (ports.LockSurfacePlacement, bool) { | |
| 112 | 120 | o := sc.mon.Output() | |
| 113 | - | for _, s := range c.lockSurfaces { | |
| 121 | + | for _, s := range c.lock.surfaces { | |
| 114 | 122 | if s.ID != 0 && sc.name() != "" && s.Output == sc.name() && s.Width == o.W && s.Height == o.H && o.W > 0 && o.H > 0 { | |
| 115 | 123 | return s, true | |
| 116 | 124 | } | |
| @@ -136,27 +144,27 @@ func (c *Core) validLockID(id WindowID) bool { | |||
| 136 | 144 | // surface once it maps: lock clients map their surfaces one by one, in any | |
| 137 | 145 | // order. | |
| 138 | 146 | 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 | |
| 142 | 150 | } | |
| 143 | 151 | if c.focusScreen >= 0 && c.focusScreen < len(c.screens) { | |
| 144 | 152 | 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 | |
| 147 | 155 | } | |
| 148 | 156 | } | |
| 149 | 157 | if valid { | |
| 150 | - | return c.lockFocus | |
| 158 | + | return c.lock.focus | |
| 151 | 159 | } | |
| 152 | - | c.lockFocus, c.lockPinned = 0, false | |
| 160 | + | c.lock.focus, c.lock.pinned = 0, false | |
| 153 | 161 | for _, sc := range c.screens { | |
| 154 | 162 | if s, ok := c.lockSurface(sc); ok { | |
| 155 | - | c.lockFocus = s.ID | |
| 163 | + | c.lock.focus = s.ID | |
| 156 | 164 | break | |
| 157 | 165 | } | |
| 158 | 166 | } | |
| 159 | - | return c.lockFocus | |
| 167 | + | return c.lock.focus | |
| 160 | 168 | } | |
| 161 | 169 | ||
| 162 | 170 | 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 { | |||
| 242 | 250 | switch v := ev.(type) { | |
| 243 | 251 | case ports.KeyEvent: | |
| 244 | 252 | 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 | |
| 246 | 254 | return c.command(ctx, ports.ForwardKey{ID: id, Key: v}) | |
| 247 | 255 | } | |
| 248 | 256 | case ports.PointerMotion: | |
| @@ -277,8 +285,8 @@ func (c *Core) protectedInput(ctx context.Context, ev ports.InputEvent) error { | |||
| 277 | 285 | } | |
| 278 | 286 | if c.validLockID(id) { | |
| 279 | 287 | 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 | |
| 282 | 290 | if changed { | |
| 283 | 291 | if err := c.publishProtected(ctx); err != nil { | |
| 284 | 292 | return err | |
M internal/core/security_internal_test.go +1 -1
| @@ -62,7 +62,7 @@ func TestProtectedDropsPrivateOffscreenCaptureAndDesktopEffects(t *testing.T) { | |||
| 62 | 62 | c.pointer, c.grab = 10, 10 | |
| 63 | 63 | c.keyboard = keyboard{sent: 10} | |
| 64 | 64 | 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 { | |
| 66 | 66 | t.Fatal("release did not reset input ownership") | |
| 67 | 67 | } | |
| 68 | 68 | 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) { | |||
| 39 | 39 | return v | |
| 40 | 40 | }) | |
| 41 | 41 | 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()} | |
| 43 | 43 | commands := make(chan ports.ClientCommand) | |
| 44 | 44 | c.ch.Commands = commands | |
| 45 | 45 | done := make(chan error, 1) | |
M internal/core/state.go +2 -2
| @@ -14,10 +14,10 @@ func (c *Core) publishState() { | |||
| 14 | 14 | return | |
| 15 | 15 | } | |
| 16 | 16 | st := c.state() | |
| 17 | - | if reflect.DeepEqual(st, c.sentState) { | |
| 17 | + | if reflect.DeepEqual(st, c.sent.state) { | |
| 18 | 18 | return | |
| 19 | 19 | } | |
| 20 | - | c.sentState = st | |
| 20 | + | c.sent.state = st | |
| 21 | 21 | latest(c.ch.State, st) | |
| 22 | 22 | } | |
| 23 | 23 | ||
M internal/core/workspaces.go +2 -2
| @@ -32,9 +32,9 @@ func (c *Core) publishWorkspaces() { | |||
| 32 | 32 | } | |
| 33 | 33 | snapshot.Outputs = append(snapshot.Outputs, out) | |
| 34 | 34 | } | |
| 35 | - | if reflect.DeepEqual(snapshot, c.sentWorkspaces) { | |
| 35 | + | if reflect.DeepEqual(snapshot, c.sent.workspaces) { | |
| 36 | 36 | return | |
| 37 | 37 | } | |
| 38 | - | c.sentWorkspaces = snapshot | |
| 38 | + | c.sent.workspaces = snapshot | |
| 39 | 39 | latest(c.ch.Workspaces, snapshot) | |
| 40 | 40 | } | |
M internal/ports/client_events.go +0 -174
| @@ -110,30 +110,6 @@ type WindowActivate struct{ ID WindowID } | |||
| 110 | 110 | ||
| 111 | 111 | func (WindowActivate) clientEvent() {} | |
| 112 | 112 | ||
| 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 | - | ||
| 137 | 113 | // WindowAppID carries wayland → core an app ID set after the window mapped. | |
| 138 | 114 | type WindowAppID struct { | |
| 139 | 115 | ID WindowID | |
| @@ -157,153 +133,3 @@ type WindowResized struct { | |||
| 157 | 133 | } | |
| 158 | 134 | ||
| 159 | 135 | 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 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | - | import ( | |
| 4 | - | "image" | |
| 5 | - | "math" | |
| 6 | - | "os" | |
| 7 | - | "slices" | |
| 8 | - | ) | |
| 3 | + | import "slices" | |
| 9 | 4 | ||
| 10 | 5 | // Scene carries core → renderer immutable snapshots with fresh Windows slices, | |
| 11 | 6 | // one per output. Rects and the output size are logical and local to the | |
| @@ -142,269 +137,6 @@ type SceneLayer struct { | |||
| 142 | 137 | Rect Rect | |
| 143 | 138 | } | |
| 144 | 139 | ||
| 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 | - | ||
| 408 | 140 | // SceneWindow carries core → renderer window placement. | |
| 409 | 141 | type SceneWindow struct { | |
| 410 | 142 | 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 | + | } | |