perf(core): wake core on flips only while a scene animates
- commit
- 99ddc5d3707e8d12d7b15e7f6995f69fb4c8cfc1
- author
- brice · · 2026-10-13 16:03 UTC
- parent
- fefcf43
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
20 files · +274 -58
M Makefile +1 -1
| @@ -69,7 +69,7 @@ adapter-imports: | |||
| 69 | 69 | [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; } | |
| 70 | 70 | # Fast, mandatory guards for tiled content publications and SHM copies. | |
| 71 | 71 | perf-check: | |
| 72 | - | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations)$$' -count=1 | |
| 72 | + | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations)$$' -count=1 | |
| 73 | 73 | check: vet test arch fakes-check log-check perf-check | |
| 74 | 74 | ||
| 75 | 75 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | |
| @@ -69,7 +69,7 @@ adapter-imports: | |||||
| 69 | [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; } | 69 | [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; } | ||
| 70 | # Fast, mandatory guards for tiled content publications and SHM copies. | 70 | # Fast, mandatory guards for tiled content publications and SHM copies. | ||
| 71 | perf-check: | 71 | perf-check: | ||
| 72 | - | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations)$$' -count=1 | 72 | + | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations)$$' -count=1 |
| 73 | check: vet test arch fakes-check log-check perf-check | 73 | check: vet test arch fakes-check log-check perf-check | ||
| 74 | 74 | ||||
| 75 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | 75 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | ||
M internal/adapters/drm/frame_lifecycle.go +3 -1
| @@ -31,7 +31,9 @@ type pendingFrame struct { | |||
| 31 | 31 | queued uint64 | |
| 32 | 32 | zeroCopy ports.WindowID // window shown without composition | |
| 33 | 33 | async bool | |
| 34 | - | shows map[ports.WindowID]uint64 | |
| 34 | + | // animating: the scene shows a running animation; its flip wakes core. | |
| 35 | + | animating bool | |
| 36 | + | shows map[ports.WindowID]uint64 | |
| 35 | 37 | // fences are duplicates of the fences the commit waits on, owned until | |
| 36 | 38 | // the commit ends. composed is set when the frame was rendered. | |
| 37 | 39 | fences []*os.File | |
| @@ -31,7 +31,9 @@ type pendingFrame struct { | |||||
| 31 | queued uint64 | 31 | queued uint64 | ||
| 32 | zeroCopy ports.WindowID // window shown without composition | 32 | zeroCopy ports.WindowID // window shown without composition | ||
| 33 | async bool | 33 | async bool | ||
| 34 | - | shows map[ports.WindowID]uint64 | 34 | + | // animating: the scene shows a running animation; its flip wakes core. |
| 35 | + | animating bool | |||
| 36 | + | shows map[ports.WindowID]uint64 | |||
| 35 | // fences are duplicates of the fences the commit waits on, owned until | 37 | // fences are duplicates of the fences the commit waits on, owned until | ||
| 36 | // the commit ends. composed is set when the frame was rendered. | 38 | // the commit ends. composed is set when the frame was rendered. | ||
| 37 | fences []*os.File | 39 | fences []*os.File | ||
M internal/adapters/drm/frame_submit.go +2 -2
| @@ -34,7 +34,7 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports. | |||
| 34 | 34 | } | |
| 35 | 35 | // shows is what the frame puts on screen: presentation feedback of | |
| 36 | 36 | // windows it does not draw is discarded, not presented. | |
| 37 | - | f := pendingFrame{security: scene.Security, shows: o.shownBy(scene, seen)} | |
| 37 | + | f := pendingFrame{security: scene.Security, animating: scene.Animating, shows: o.shownBy(scene, seen)} | |
| 38 | 38 | o.wantContent(scene, surfaces) | |
| 39 | 39 | // Requests that cannot be served (exclusion of another session, a | |
| 40 | 40 | // workspace that moved) are failed here, never answered from the | |
| @@ -82,7 +82,7 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports. | |||
| 82 | 82 | } | |
| 83 | 83 | if decision.fb != 0 { | |
| 84 | 84 | c := decision.content | |
| 85 | - | if direct, err = o.commitScanoutRect(decision.fb, c, pendingFrame{security: scene.Security, shows: o.directShownBy(c.ID, seen[c.ID])}, decision.rect, decision.color); direct { | |
| 85 | + | if direct, err = o.commitScanoutRect(decision.fb, c, pendingFrame{security: scene.Security, animating: scene.Animating, shows: o.directShownBy(c.ID, seen[c.ID])}, decision.rect, decision.color); direct { | |
| 86 | 86 | presented = err == nil | |
| 87 | 87 | return true, err | |
| 88 | 88 | } | |
| @@ -34,7 +34,7 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports. | |||||
| 34 | } | 34 | } | ||
| 35 | // shows is what the frame puts on screen: presentation feedback of | 35 | // shows is what the frame puts on screen: presentation feedback of | ||
| 36 | // windows it does not draw is discarded, not presented. | 36 | // windows it does not draw is discarded, not presented. | ||
| 37 | - | f := pendingFrame{security: scene.Security, shows: o.shownBy(scene, seen)} | 37 | + | f := pendingFrame{security: scene.Security, animating: scene.Animating, shows: o.shownBy(scene, seen)} |
| 38 | o.wantContent(scene, surfaces) | 38 | o.wantContent(scene, surfaces) | ||
| 39 | // Requests that cannot be served (exclusion of another session, a | 39 | // Requests that cannot be served (exclusion of another session, a | ||
| 40 | // workspace that moved) are failed here, never answered from the | 40 | // workspace that moved) are failed here, never answered from the | ||
| @@ -82,7 +82,7 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports. | |||||
| 82 | } | 82 | } | ||
| 83 | if decision.fb != 0 { | 83 | if decision.fb != 0 { | ||
| 84 | c := decision.content | 84 | c := decision.content | ||
| 85 | - | if direct, err = o.commitScanoutRect(decision.fb, c, pendingFrame{security: scene.Security, shows: o.directShownBy(c.ID, seen[c.ID])}, decision.rect, decision.color); direct { | 85 | + | if direct, err = o.commitScanoutRect(decision.fb, c, pendingFrame{security: scene.Security, animating: scene.Animating, shows: o.directShownBy(c.ID, seen[c.ID])}, decision.rect, decision.color); direct { |
| 86 | presented = err == nil | 86 | presented = err == nil | ||
| 87 | return true, err | 87 | return true, err | ||
| 88 | } | 88 | } | ||
A internal/adapters/drm/frames_test.go +74 -0
| @@ -0,0 +1,74 @@ | |||
| 1 | + | package drm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // A flip of an animating scene tells core the next frame is due; the flip | |
| 11 | + | // of any other frame does not. | |
| 12 | + | func TestCompletedSendsFrameOnlyForAnimatingScene(t *testing.T) { | |
| 13 | + | o, _, commits := testOutput(t) | |
| 14 | + | o.cursor = nil | |
| 15 | + | frames := make(chan ports.OutputFrame, 2) | |
| 16 | + | o.Frames = frames | |
| 17 | + | seen := map[ports.WindowID]uint64{} | |
| 18 | + | ||
| 19 | + | require.NoError(t, o.commitFrame(70, nil, false, false, pendingFrame{})) | |
| 20 | + | require.True(t, o.completed(eventOf((*commits)[0]), seen)) | |
| 21 | + | require.Empty(t, frames, "a settled scene wakes nobody") | |
| 22 | + | ||
| 23 | + | require.NoError(t, o.commitFrame(70, nil, false, false, pendingFrame{animating: true})) | |
| 24 | + | require.True(t, o.completed(eventOf((*commits)[1]), seen)) | |
| 25 | + | require.Equal(t, []ports.OutputFrame{{Output: "DP-1"}}, drain(frames)) | |
| 26 | + | ||
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // A frames channel core has not read stays full: the owner never waits. | |
| 30 | + | func TestCompletedFramesNeverBlock(t *testing.T) { | |
| 31 | + | o, _, commits := testOutput(t) | |
| 32 | + | o.cursor = nil | |
| 33 | + | frames := make(chan ports.OutputFrame, 1) | |
| 34 | + | o.Frames = frames | |
| 35 | + | seen := map[ports.WindowID]uint64{} | |
| 36 | + | for i := range 3 { | |
| 37 | + | require.NoError(t, o.commitFrame(70, nil, false, false, pendingFrame{animating: true})) | |
| 38 | + | require.True(t, o.completed(eventOf((*commits)[i]), seen)) | |
| 39 | + | } | |
| 40 | + | require.Len(t, frames, 1) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // An output without a frames channel (tests, nobody to wake) just flips. | |
| 44 | + | func TestCompletedWithoutFramesChannel(t *testing.T) { | |
| 45 | + | o, _, commits := testOutput(t) | |
| 46 | + | o.cursor = nil | |
| 47 | + | require.NoError(t, o.commitFrame(70, nil, false, false, pendingFrame{animating: true})) | |
| 48 | + | require.True(t, o.completed(eventOf((*commits)[0]), map[ports.WindowID]uint64{})) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func drain(ch chan ports.OutputFrame) []ports.OutputFrame { | |
| 52 | + | var out []ports.OutputFrame | |
| 53 | + | for len(ch) > 0 { | |
| 54 | + | out = append(out, <-ch) | |
| 55 | + | } | |
| 56 | + | return out | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // The frame send of an animating flip allocates nothing, full channel or not. | |
| 60 | + | func TestAnimatingFrameSendAllocations(t *testing.T) { | |
| 61 | + | o, _, _ := testOutput(t) | |
| 62 | + | frames := make(chan ports.OutputFrame, 1) | |
| 63 | + | o.Frames = frames | |
| 64 | + | if n := testing.AllocsPerRun(100, func() { | |
| 65 | + | o.wakeCore() // fills the channel, or finds it full | |
| 66 | + | select { | |
| 67 | + | case <-frames: | |
| 68 | + | default: | |
| 69 | + | } | |
| 70 | + | o.wakeCore() | |
| 71 | + | }); n != 0 { | |
| 72 | + | t.Fatalf("wakeCore: %v allocations", n) | |
| 73 | + | } | |
| 74 | + | } | |
| @@ -0,0 +1,74 @@ | |||||
| 1 | + | package drm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // A flip of an animating scene tells core the next frame is due; the flip | |||
| 11 | + | // of any other frame does not. | |||
| 12 | + | func TestCompletedSendsFrameOnlyForAnimatingScene(t *testing.T) { | |||
| 13 | + | o, _, commits := testOutput(t) | |||
| 14 | + | o.cursor = nil | |||
| 15 | + | frames := make(chan ports.OutputFrame, 2) | |||
| 16 | + | o.Frames = frames | |||
| 17 | + | seen := map[ports.WindowID]uint64{} | |||
| 18 | + | ||||
| 19 | + | require.NoError(t, o.commitFrame(70, nil, false, false, pendingFrame{})) | |||
| 20 | + | require.True(t, o.completed(eventOf((*commits)[0]), seen)) | |||
| 21 | + | require.Empty(t, frames, "a settled scene wakes nobody") | |||
| 22 | + | ||||
| 23 | + | require.NoError(t, o.commitFrame(70, nil, false, false, pendingFrame{animating: true})) | |||
| 24 | + | require.True(t, o.completed(eventOf((*commits)[1]), seen)) | |||
| 25 | + | require.Equal(t, []ports.OutputFrame{{Output: "DP-1"}}, drain(frames)) | |||
| 26 | + | ||||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // A frames channel core has not read stays full: the owner never waits. | |||
| 30 | + | func TestCompletedFramesNeverBlock(t *testing.T) { | |||
| 31 | + | o, _, commits := testOutput(t) | |||
| 32 | + | o.cursor = nil | |||
| 33 | + | frames := make(chan ports.OutputFrame, 1) | |||
| 34 | + | o.Frames = frames | |||
| 35 | + | seen := map[ports.WindowID]uint64{} | |||
| 36 | + | for i := range 3 { | |||
| 37 | + | require.NoError(t, o.commitFrame(70, nil, false, false, pendingFrame{animating: true})) | |||
| 38 | + | require.True(t, o.completed(eventOf((*commits)[i]), seen)) | |||
| 39 | + | } | |||
| 40 | + | require.Len(t, frames, 1) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // An output without a frames channel (tests, nobody to wake) just flips. | |||
| 44 | + | func TestCompletedWithoutFramesChannel(t *testing.T) { | |||
| 45 | + | o, _, commits := testOutput(t) | |||
| 46 | + | o.cursor = nil | |||
| 47 | + | require.NoError(t, o.commitFrame(70, nil, false, false, pendingFrame{animating: true})) | |||
| 48 | + | require.True(t, o.completed(eventOf((*commits)[0]), map[ports.WindowID]uint64{})) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func drain(ch chan ports.OutputFrame) []ports.OutputFrame { | |||
| 52 | + | var out []ports.OutputFrame | |||
| 53 | + | for len(ch) > 0 { | |||
| 54 | + | out = append(out, <-ch) | |||
| 55 | + | } | |||
| 56 | + | return out | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // The frame send of an animating flip allocates nothing, full channel or not. | |||
| 60 | + | func TestAnimatingFrameSendAllocations(t *testing.T) { | |||
| 61 | + | o, _, _ := testOutput(t) | |||
| 62 | + | frames := make(chan ports.OutputFrame, 1) | |||
| 63 | + | o.Frames = frames | |||
| 64 | + | if n := testing.AllocsPerRun(100, func() { | |||
| 65 | + | o.wakeCore() // fills the channel, or finds it full | |||
| 66 | + | select { | |||
| 67 | + | case <-frames: | |||
| 68 | + | default: | |||
| 69 | + | } | |||
| 70 | + | o.wakeCore() | |||
| 71 | + | }); n != 0 { | |||
| 72 | + | t.Fatalf("wakeCore: %v allocations", n) | |||
| 73 | + | } | |||
| 74 | + | } | |||
M internal/adapters/drm/output.go +22 -4
| @@ -27,10 +27,13 @@ import ( | |||
| 27 | 27 | // bounded (stuckTimeout, then a modeset). | |
| 28 | 28 | type Output struct { | |
| 29 | 29 | // Set before Run. The backend owns instance registration and wake routing. | |
| 30 | - | Security ports.SessionSecurity | |
| 31 | - | SecurityChanges <-chan ports.SecurityState | |
| 32 | - | Instance ports.OutputInstance | |
| 33 | - | SecurityEvents chan<- ports.SecurityBackendEvent | |
| 30 | + | Security ports.SessionSecurity | |
| 31 | + | SecurityChanges <-chan ports.SecurityState | |
| 32 | + | Instance ports.OutputInstance | |
| 33 | + | SecurityEvents chan<- ports.SecurityBackendEvent | |
| 34 | + | // Frames, set before Run, tells core a flip of an animating scene | |
| 35 | + | // completed (nil: nobody is told). Sends never block. | |
| 36 | + | Frames chan<- ports.OutputFrame | |
| 34 | 37 | securityState ports.SecurityState | |
| 35 | 38 | securityPrepared bool | |
| 36 | 39 | securityInvalid bool | |
| @@ -1651,9 +1654,24 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool { | |||
| 1651 | 1654 | o.queued = 0 | |
| 1652 | 1655 | } | |
| 1653 | 1656 | o.report(&ports.FlipInfo{When: ev.when, Seq: uint64(ev.seq), Refresh: o.refreshPeriod(), ZeroCopy: f.zeroCopy, Async: f.async, HardwareClock: true, Shows: f.shows}, seen) | |
| 1657 | + | if f.animating { | |
| 1658 | + | o.wakeCore() | |
| 1659 | + | } | |
| 1654 | 1660 | return true | |
| 1655 | 1661 | } | |
| 1656 | 1662 | ||
| 1663 | + | // wakeCore tells core the next frame of a running animation is due. A frame | |
| 1664 | + | // core has not read yet is enough, so the owner never waits. | |
| 1665 | + | func (o *Output) wakeCore() { | |
| 1666 | + | if o.Frames == nil { | |
| 1667 | + | return | |
| 1668 | + | } | |
| 1669 | + | select { | |
| 1670 | + | case o.Frames <- ports.OutputFrame{Output: o.conn.name}: | |
| 1671 | + | default: | |
| 1672 | + | } | |
| 1673 | + | } | |
| 1674 | + | ||
| 1657 | 1675 | // setCursor loads a cursor for scale and output transform t into the cursor | |
| 1658 | 1676 | // images. | |
| 1659 | 1677 | func (o *Output) setCursor(r ports.Renderer, load CursorLoader, c ports.CursorChange, scale float64, t ports.BufferTransform) { | |
| @@ -27,10 +27,13 @@ import ( | |||||
| 27 | // bounded (stuckTimeout, then a modeset). | 27 | // bounded (stuckTimeout, then a modeset). | ||
| 28 | type Output struct { | 28 | type Output struct { | ||
| 29 | // Set before Run. The backend owns instance registration and wake routing. | 29 | // Set before Run. The backend owns instance registration and wake routing. | ||
| 30 | - | Security ports.SessionSecurity | 30 | + | Security ports.SessionSecurity |
| 31 | - | SecurityChanges <-chan ports.SecurityState | 31 | + | SecurityChanges <-chan ports.SecurityState |
| 32 | - | Instance ports.OutputInstance | 32 | + | Instance ports.OutputInstance |
| 33 | - | SecurityEvents chan<- ports.SecurityBackendEvent | 33 | + | SecurityEvents chan<- ports.SecurityBackendEvent |
| 34 | + | // Frames, set before Run, tells core a flip of an animating scene | |||
| 35 | + | // completed (nil: nobody is told). Sends never block. | |||
| 36 | + | Frames chan<- ports.OutputFrame | |||
| 34 | securityState ports.SecurityState | 37 | securityState ports.SecurityState | ||
| 35 | securityPrepared bool | 38 | securityPrepared bool | ||
| 36 | securityInvalid bool | 39 | securityInvalid bool | ||
| @@ -1651,9 +1654,24 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool { | |||||
| 1651 | o.queued = 0 | 1654 | o.queued = 0 | ||
| 1652 | } | 1655 | } | ||
| 1653 | o.report(&ports.FlipInfo{When: ev.when, Seq: uint64(ev.seq), Refresh: o.refreshPeriod(), ZeroCopy: f.zeroCopy, Async: f.async, HardwareClock: true, Shows: f.shows}, seen) | 1656 | o.report(&ports.FlipInfo{When: ev.when, Seq: uint64(ev.seq), Refresh: o.refreshPeriod(), ZeroCopy: f.zeroCopy, Async: f.async, HardwareClock: true, Shows: f.shows}, seen) | ||
| 1657 | + | if f.animating { | |||
| 1658 | + | o.wakeCore() | |||
| 1659 | + | } | |||
| 1654 | return true | 1660 | return true | ||
| 1655 | } | 1661 | } | ||
| 1656 | 1662 | ||||
| 1663 | + | // wakeCore tells core the next frame of a running animation is due. A frame | |||
| 1664 | + | // core has not read yet is enough, so the owner never waits. | |||
| 1665 | + | func (o *Output) wakeCore() { | |||
| 1666 | + | if o.Frames == nil { | |||
| 1667 | + | return | |||
| 1668 | + | } | |||
| 1669 | + | select { | |||
| 1670 | + | case o.Frames <- ports.OutputFrame{Output: o.conn.name}: | |||
| 1671 | + | default: | |||
| 1672 | + | } | |||
| 1673 | + | } | |||
| 1674 | + | ||||
| 1657 | // setCursor loads a cursor for scale and output transform t into the cursor | 1675 | // setCursor loads a cursor for scale and output transform t into the cursor | ||
| 1658 | // images. | 1676 | // images. | ||
| 1659 | func (o *Output) setCursor(r ports.Renderer, load CursorLoader, c ports.CursorChange, scale float64, t ports.BufferTransform) { | 1677 | func (o *Output) setCursor(r ports.Renderer, load CursorLoader, c ports.CursorChange, scale float64, t ports.BufferTransform) { | ||
A internal/adapters/headless/frames_test.go +62 -0
| @@ -0,0 +1,62 @@ | |||
| 1 | + | package headless | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // runWithFrames runs an output with a frames channel of the given size and | |
| 13 | + | // returns what it needs to feed scenes and stop it. | |
| 14 | + | func runWithFrames(t *testing.T, size int) (scenes chan ports.Scene, frames chan ports.OutputFrame, f *frames, stop func()) { | |
| 15 | + | t.Helper() | |
| 16 | + | scenes = make(chan ports.Scene, 8) | |
| 17 | + | frames = make(chan ports.OutputFrame, size) | |
| 18 | + | r, rec := recordingRenderer(t, nil) | |
| 19 | + | sink, _ := presentedSink(t, 16) | |
| 20 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 21 | + | done := make(chan error, 1) | |
| 22 | + | go func() { | |
| 23 | + | done <- Run(ctx, Options{Width: 2, Height: 2, Name: "HEADLESS-1", Presented: sink, Frames: frames, | |
| 24 | + | NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil) | |
| 25 | + | }() | |
| 26 | + | return scenes, frames, rec, func() { | |
| 27 | + | cancel() | |
| 28 | + | require.NoError(t, <-done) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // The presentation of an animating scene tells core the next frame is due; | |
| 33 | + | // the one of a settled scene does not. | |
| 34 | + | func TestAnimatingSceneSendsFrame(t *testing.T) { | |
| 35 | + | scenes, frames, rec, stop := runWithFrames(t, 4) | |
| 36 | + | defer stop() | |
| 37 | + | ||
| 38 | + | scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2, Animating: true} | |
| 39 | + | select { | |
| 40 | + | case got := <-frames: | |
| 41 | + | require.Equal(t, ports.OutputFrame{Output: "HEADLESS-1"}, got) | |
| 42 | + | case <-time.After(3 * time.Second): | |
| 43 | + | t.Fatal("no frame for an animating scene") | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // The scene an animation settles on: drawn, nobody woken. | |
| 47 | + | scenes <- ports.Scene{Seq: 2, OutputWidth: 2, OutputHeight: 2} | |
| 48 | + | waitFrames(t, rec, 2) | |
| 49 | + | time.Sleep(50 * time.Millisecond) | |
| 50 | + | require.Empty(t, frames) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // A frames channel core has not read stays full: the owner never waits. | |
| 54 | + | func TestAnimatingFramesNeverBlock(t *testing.T) { | |
| 55 | + | scenes, frames, rec, stop := runWithFrames(t, 1) | |
| 56 | + | defer stop() | |
| 57 | + | for seq := uint64(1); seq <= 3; seq++ { | |
| 58 | + | scenes <- ports.Scene{Seq: seq, OutputWidth: 2, OutputHeight: 2, Animating: true} | |
| 59 | + | waitFrames(t, rec, int(seq)) | |
| 60 | + | } | |
| 61 | + | require.Len(t, frames, 1) | |
| 62 | + | } | |
| @@ -0,0 +1,62 @@ | |||||
| 1 | + | package headless | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // runWithFrames runs an output with a frames channel of the given size and | |||
| 13 | + | // returns what it needs to feed scenes and stop it. | |||
| 14 | + | func runWithFrames(t *testing.T, size int) (scenes chan ports.Scene, frames chan ports.OutputFrame, f *frames, stop func()) { | |||
| 15 | + | t.Helper() | |||
| 16 | + | scenes = make(chan ports.Scene, 8) | |||
| 17 | + | frames = make(chan ports.OutputFrame, size) | |||
| 18 | + | r, rec := recordingRenderer(t, nil) | |||
| 19 | + | sink, _ := presentedSink(t, 16) | |||
| 20 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 21 | + | done := make(chan error, 1) | |||
| 22 | + | go func() { | |||
| 23 | + | done <- Run(ctx, Options{Width: 2, Height: 2, Name: "HEADLESS-1", Presented: sink, Frames: frames, | |||
| 24 | + | NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil) | |||
| 25 | + | }() | |||
| 26 | + | return scenes, frames, rec, func() { | |||
| 27 | + | cancel() | |||
| 28 | + | require.NoError(t, <-done) | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // The presentation of an animating scene tells core the next frame is due; | |||
| 33 | + | // the one of a settled scene does not. | |||
| 34 | + | func TestAnimatingSceneSendsFrame(t *testing.T) { | |||
| 35 | + | scenes, frames, rec, stop := runWithFrames(t, 4) | |||
| 36 | + | defer stop() | |||
| 37 | + | ||||
| 38 | + | scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2, Animating: true} | |||
| 39 | + | select { | |||
| 40 | + | case got := <-frames: | |||
| 41 | + | require.Equal(t, ports.OutputFrame{Output: "HEADLESS-1"}, got) | |||
| 42 | + | case <-time.After(3 * time.Second): | |||
| 43 | + | t.Fatal("no frame for an animating scene") | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // The scene an animation settles on: drawn, nobody woken. | |||
| 47 | + | scenes <- ports.Scene{Seq: 2, OutputWidth: 2, OutputHeight: 2} | |||
| 48 | + | waitFrames(t, rec, 2) | |||
| 49 | + | time.Sleep(50 * time.Millisecond) | |||
| 50 | + | require.Empty(t, frames) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // A frames channel core has not read stays full: the owner never waits. | |||
| 54 | + | func TestAnimatingFramesNeverBlock(t *testing.T) { | |||
| 55 | + | scenes, frames, rec, stop := runWithFrames(t, 1) | |||
| 56 | + | defer stop() | |||
| 57 | + | for seq := uint64(1); seq <= 3; seq++ { | |||
| 58 | + | scenes <- ports.Scene{Seq: seq, OutputWidth: 2, OutputHeight: 2, Animating: true} | |||
| 59 | + | waitFrames(t, rec, int(seq)) | |||
| 60 | + | } | |||
| 61 | + | require.Len(t, frames, 1) | |||
| 62 | + | } | |||
M internal/adapters/headless/output.go +10 -1
| @@ -48,7 +48,10 @@ type Options struct { | |||
| 48 | 48 | // frame, so wayland can release client buffers (nil: no reports). | |
| 49 | 49 | Name string | |
| 50 | 50 | Presented ports.PresentationSink | |
| 51 | - | Captured chan<- ports.CaptureDone | |
| 51 | + | // Frames tells core a presented scene is animating: its next frame is | |
| 52 | + | // due. Sends never block (nil: nobody is told). | |
| 53 | + | Frames chan<- ports.OutputFrame | |
| 54 | + | Captured chan<- ports.CaptureDone | |
| 52 | 55 | // Clock paces the renderer trim (nil: system clock). | |
| 53 | 56 | Clock ports.Clock | |
| 54 | 57 | } | |
| @@ -543,6 +546,12 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 543 | 546 | if opts.report(&reports, flipInfo(scene, drawn, table.Kept), seen) { | |
| 544 | 547 | mergedFlips++ | |
| 545 | 548 | } | |
| 549 | + | if scene.Animating && opts.Frames != nil { | |
| 550 | + | select { | |
| 551 | + | case opts.Frames <- ports.OutputFrame{Output: opts.Name}: | |
| 552 | + | default: | |
| 553 | + | } | |
| 554 | + | } | |
| 546 | 555 | if opts.ScreenshotDir != "" && !opts.protected() { | |
| 547 | 556 | shot := r.Pixels() | |
| 548 | 557 | if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security { | |
| @@ -48,7 +48,10 @@ type Options struct { | |||||
| 48 | // frame, so wayland can release client buffers (nil: no reports). | 48 | // frame, so wayland can release client buffers (nil: no reports). | ||
| 49 | Name string | 49 | Name string | ||
| 50 | Presented ports.PresentationSink | 50 | Presented ports.PresentationSink | ||
| 51 | - | Captured chan<- ports.CaptureDone | 51 | + | // Frames tells core a presented scene is animating: its next frame is |
| 52 | + | // due. Sends never block (nil: nobody is told). | |||
| 53 | + | Frames chan<- ports.OutputFrame | |||
| 54 | + | Captured chan<- ports.CaptureDone | |||
| 52 | // Clock paces the renderer trim (nil: system clock). | 55 | // Clock paces the renderer trim (nil: system clock). | ||
| 53 | Clock ports.Clock | 56 | Clock ports.Clock | ||
| 54 | } | 57 | } | ||
| @@ -543,6 +546,12 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 543 | if opts.report(&reports, flipInfo(scene, drawn, table.Kept), seen) { | 546 | if opts.report(&reports, flipInfo(scene, drawn, table.Kept), seen) { | ||
| 544 | mergedFlips++ | 547 | mergedFlips++ | ||
| 545 | } | 548 | } | ||
| 549 | + | if scene.Animating && opts.Frames != nil { | |||
| 550 | + | select { | |||
| 551 | + | case opts.Frames <- ports.OutputFrame{Output: opts.Name}: | |||
| 552 | + | default: | |||
| 553 | + | } | |||
| 554 | + | } | |||
| 546 | if opts.ScreenshotDir != "" && !opts.protected() { | 555 | if opts.ScreenshotDir != "" && !opts.protected() { | ||
| 547 | shot := r.Pixels() | 556 | shot := r.Pixels() | ||
| 548 | if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security { | 557 | if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security { | ||
M internal/adapters/vulkan/damage.go +2 -0
| @@ -265,6 +265,8 @@ var ( | |||
| 265 | 265 | // separator present in only one scene adds its own. A new Scene field | |
| 266 | 266 | // belongs here too (see Scene.SameAs, TestSceneSameAsCoversEveryField); a | |
| 267 | 267 | // new SceneWindow field is checked by TestSceneWindowFieldCount. | |
| 268 | + | // Scene.Animating is not compared: it schedules the next frame, it draws | |
| 269 | + | // nothing. | |
| 268 | 270 | func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, limit int) (image.Rectangle, bool) { | |
| 269 | 271 | if old.Security != cur.Security || old.Output != cur.Output || | |
| 270 | 272 | old.OutputWidth != cur.OutputWidth || old.OutputHeight != cur.OutputHeight || | |
| @@ -265,6 +265,8 @@ var ( | |||||
| 265 | // separator present in only one scene adds its own. A new Scene field | 265 | // separator present in only one scene adds its own. A new Scene field | ||
| 266 | // belongs here too (see Scene.SameAs, TestSceneSameAsCoversEveryField); a | 266 | // belongs here too (see Scene.SameAs, TestSceneSameAsCoversEveryField); a | ||
| 267 | // new SceneWindow field is checked by TestSceneWindowFieldCount. | 267 | // new SceneWindow field is checked by TestSceneWindowFieldCount. | ||
| 268 | + | // Scene.Animating is not compared: it schedules the next frame, it draws | |||
| 269 | + | // nothing. | |||
| 268 | func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, limit int) (image.Rectangle, bool) { | 270 | func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, limit int) (image.Rectangle, bool) { | ||
| 269 | if old.Security != cur.Security || old.Output != cur.Output || | 271 | if old.Security != cur.Security || old.Output != cur.Output || | ||
| 270 | old.OutputWidth != cur.OutputWidth || old.OutputHeight != cur.OutputHeight || | 272 | old.OutputWidth != cur.OutputWidth || old.OutputHeight != cur.OutputHeight || | ||
M internal/adapters/vulkan/damage_delta_test.go +3 -1
| @@ -17,7 +17,9 @@ import ( | |||
| 17 | 17 | // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta | |
| 18 | 18 | // lists the fields that force a full redraw. | |
| 19 | 19 | func TestSceneFieldCount(t *testing.T) { | |
| 20 | - | const fields = 21 | |
| 20 | + | // 22 with Animating, which sceneDelta intentionally ignores: it only | |
| 21 | + | // schedules the next frame, it draws nothing. | |
| 22 | + | const fields = 22 | |
| 21 | 23 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))), | |
| 22 | 24 | "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count") | |
| 23 | 25 | } | |
| @@ -17,7 +17,9 @@ import ( | |||||
| 17 | // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta | 17 | // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta | ||
| 18 | // lists the fields that force a full redraw. | 18 | // lists the fields that force a full redraw. | ||
| 19 | func TestSceneFieldCount(t *testing.T) { | 19 | func TestSceneFieldCount(t *testing.T) { | ||
| 20 | - | const fields = 21 | 20 | + | // 22 with Animating, which sceneDelta intentionally ignores: it only |
| 21 | + | // schedules the next frame, it draws nothing. | |||
| 22 | + | const fields = 22 | |||
| 21 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))), | 23 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))), | ||
| 22 | "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count") | 24 | "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count") | ||
| 23 | } | 25 | } | ||
M internal/app/app.go +0 -19
| @@ -138,25 +138,6 @@ func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigCh | |||
| 138 | 138 | } | |
| 139 | 139 | } | |
| 140 | 140 | ||
| 141 | - | // framesTap is the sink outputs report to: it passes every report to wayland | |
| 142 | - | // and tells core about each flip (a frame for running animations). A frame | |
| 143 | - | // core has not read yet is enough, so frames never wait. | |
| 144 | - | type framesTap struct { | |
| 145 | - | sink ports.PresentationSink | |
| 146 | - | frames chan<- ports.OutputFrame | |
| 147 | - | } | |
| 148 | - | ||
| 149 | - | // Push implements ports.PresentationSink; it never blocks. | |
| 150 | - | func (t framesTap) Push(p ports.OutputPresented) { | |
| 151 | - | if p.Flip != nil { | |
| 152 | - | select { | |
| 153 | - | case t.frames <- ports.OutputFrame{Output: p.Output}: | |
| 154 | - | default: | |
| 155 | - | } | |
| 156 | - | } | |
| 157 | - | t.sink.Push(p) | |
| 158 | - | } | |
| 159 | - | ||
| 160 | 141 | func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configErrors <-chan error, tap chan<- []ports.Scene, renderScenes chan []ports.Scene) { | |
| 161 | 142 | log := logging.For(ctx, "core") | |
| 162 | 143 | configLog := logging.For(ctx, "config") | |
| @@ -138,25 +138,6 @@ func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigCh | |||||
| 138 | } | 138 | } | ||
| 139 | } | 139 | } | ||
| 140 | 140 | ||||
| 141 | - | // framesTap is the sink outputs report to: it passes every report to wayland | |||
| 142 | - | // and tells core about each flip (a frame for running animations). A frame | |||
| 143 | - | // core has not read yet is enough, so frames never wait. | |||
| 144 | - | type framesTap struct { | |||
| 145 | - | sink ports.PresentationSink | |||
| 146 | - | frames chan<- ports.OutputFrame | |||
| 147 | - | } | |||
| 148 | - | ||||
| 149 | - | // Push implements ports.PresentationSink; it never blocks. | |||
| 150 | - | func (t framesTap) Push(p ports.OutputPresented) { | |||
| 151 | - | if p.Flip != nil { | |||
| 152 | - | select { | |||
| 153 | - | case t.frames <- ports.OutputFrame{Output: p.Output}: | |||
| 154 | - | default: | |||
| 155 | - | } | |||
| 156 | - | } | |||
| 157 | - | t.sink.Push(p) | |||
| 158 | - | } | |||
| 159 | - | ||||
| 160 | func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configErrors <-chan error, tap chan<- []ports.Scene, renderScenes chan []ports.Scene) { | 141 | func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configErrors <-chan error, tap chan<- []ports.Scene, renderScenes chan []ports.Scene) { | ||
| 161 | log := logging.For(ctx, "core") | 142 | log := logging.For(ctx, "core") | ||
| 162 | configLog := logging.For(ctx, "config") | 143 | configLog := logging.For(ctx, "config") | ||
M internal/app/app_test.go +0 -24
| @@ -17,10 +17,7 @@ import ( | |||
| 17 | 17 | "github.com/bnema/neferwl/internal/adapters/config" | |
| 18 | 18 | "github.com/bnema/neferwl/internal/adapters/logging" | |
| 19 | 19 | "github.com/bnema/neferwl/internal/adapters/xkb" | |
| 20 | - | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 21 | 20 | "github.com/bnema/neferwl/internal/ports" | |
| 22 | - | "github.com/stretchr/testify/mock" | |
| 23 | - | "github.com/stretchr/testify/require" | |
| 24 | 21 | ) | |
| 25 | 22 | ||
| 26 | 23 | func TestHeadlessConfigReload(t *testing.T) { | |
| @@ -426,24 +423,3 @@ func TestHeadlessTwoOutputs(t *testing.T) { | |||
| 426 | 423 | } | |
| 427 | 424 | } | |
| 428 | 425 | } | |
| 429 | - | ||
| 430 | - | // framesTap passes every report to wayland and tells core about a flip only, | |
| 431 | - | // without ever blocking on a full frames channel. | |
| 432 | - | func TestFramesTapForwardsReportsAndFlipFrames(t *testing.T) { | |
| 433 | - | var got []ports.OutputPresented | |
| 434 | - | sink := portsmocks.NewMockPresentationSink(t) | |
| 435 | - | sink.EXPECT().Push(mock.Anything).RunAndReturn(func(r ports.OutputPresented) { got = append(got, r) }) | |
| 436 | - | frames := make(chan ports.OutputFrame, 1) | |
| 437 | - | tap := framesTap{sink: sink, frames: frames} | |
| 438 | - | ||
| 439 | - | tap.Push(ports.OutputPresented{Output: "DP-2"}) | |
| 440 | - | require.Empty(t, frames, "a report without a flip is not a frame") | |
| 441 | - | tap.Push(ports.OutputPresented{Output: "DP-2", Flip: &ports.FlipInfo{Seq: 1}}) | |
| 442 | - | require.Equal(t, ports.OutputFrame{Output: "DP-2"}, <-frames) | |
| 443 | - | ||
| 444 | - | tap.Push(ports.OutputPresented{Output: "DP-2", Flip: &ports.FlipInfo{Seq: 2}}) | |
| 445 | - | tap.Push(ports.OutputPresented{Output: "DP-2", Flip: &ports.FlipInfo{Seq: 3}}) // frames full: must not block | |
| 446 | - | require.Len(t, frames, 1) | |
| 447 | - | require.Len(t, got, 4, "wayland receives every report") | |
| 448 | - | require.Equal(t, uint64(3), got[3].Flip.Seq) | |
| 449 | - | } | |
| @@ -17,10 +17,7 @@ import ( | |||||
| 17 | "github.com/bnema/neferwl/internal/adapters/config" | 17 | "github.com/bnema/neferwl/internal/adapters/config" | ||
| 18 | "github.com/bnema/neferwl/internal/adapters/logging" | 18 | "github.com/bnema/neferwl/internal/adapters/logging" | ||
| 19 | "github.com/bnema/neferwl/internal/adapters/xkb" | 19 | "github.com/bnema/neferwl/internal/adapters/xkb" | ||
| 20 | - | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 21 | "github.com/bnema/neferwl/internal/ports" | 20 | "github.com/bnema/neferwl/internal/ports" | ||
| 22 | - | "github.com/stretchr/testify/mock" | |||
| 23 | - | "github.com/stretchr/testify/require" | |||
| 24 | ) | 21 | ) | ||
| 25 | 22 | ||||
| 26 | func TestHeadlessConfigReload(t *testing.T) { | 23 | func TestHeadlessConfigReload(t *testing.T) { | ||
| @@ -426,24 +423,3 @@ func TestHeadlessTwoOutputs(t *testing.T) { | |||||
| 426 | } | 423 | } | ||
| 427 | } | 424 | } | ||
| 428 | } | 425 | } | ||
| 429 | - | ||||
| 430 | - | // framesTap passes every report to wayland and tells core about a flip only, | |||
| 431 | - | // without ever blocking on a full frames channel. | |||
| 432 | - | func TestFramesTapForwardsReportsAndFlipFrames(t *testing.T) { | |||
| 433 | - | var got []ports.OutputPresented | |||
| 434 | - | sink := portsmocks.NewMockPresentationSink(t) | |||
| 435 | - | sink.EXPECT().Push(mock.Anything).RunAndReturn(func(r ports.OutputPresented) { got = append(got, r) }) | |||
| 436 | - | frames := make(chan ports.OutputFrame, 1) | |||
| 437 | - | tap := framesTap{sink: sink, frames: frames} | |||
| 438 | - | ||||
| 439 | - | tap.Push(ports.OutputPresented{Output: "DP-2"}) | |||
| 440 | - | require.Empty(t, frames, "a report without a flip is not a frame") | |||
| 441 | - | tap.Push(ports.OutputPresented{Output: "DP-2", Flip: &ports.FlipInfo{Seq: 1}}) | |||
| 442 | - | require.Equal(t, ports.OutputFrame{Output: "DP-2"}, <-frames) | |||
| 443 | - | ||||
| 444 | - | tap.Push(ports.OutputPresented{Output: "DP-2", Flip: &ports.FlipInfo{Seq: 2}}) | |||
| 445 | - | tap.Push(ports.OutputPresented{Output: "DP-2", Flip: &ports.FlipInfo{Seq: 3}}) // frames full: must not block | |||
| 446 | - | require.Len(t, frames, 1) | |||
| 447 | - | require.Len(t, got, 4, "wayland receives every report") | |||
| 448 | - | require.Equal(t, uint64(3), got[3].Flip.Seq) | |||
| 449 | - | } | |||
M internal/app/drm.go +1 -1
| @@ -458,7 +458,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||
| 458 | 458 | for _, o := range added { | |
| 459 | 459 | // Every physical lifetime gets the gate before any early exit: | |
| 460 | 460 | // Close must not restore saved desktop, even on a name collision. | |
| 461 | - | o.Security = ch.security | |
| 461 | + | o.Security, o.Frames = ch.security, ch.frames | |
| 462 | 462 | name := o.Info().Name | |
| 463 | 463 | if other := cards[name]; other != nil && other != c { | |
| 464 | 464 | // Core and clients know outputs by connector name. | |
| @@ -458,7 +458,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||||
| 458 | for _, o := range added { | 458 | for _, o := range added { | ||
| 459 | // Every physical lifetime gets the gate before any early exit: | 459 | // Every physical lifetime gets the gate before any early exit: | ||
| 460 | // Close must not restore saved desktop, even on a name collision. | 460 | // Close must not restore saved desktop, even on a name collision. | ||
| 461 | - | o.Security = ch.security | 461 | + | o.Security, o.Frames = ch.security, ch.frames |
| 462 | name := o.Info().Name | 462 | name := o.Info().Name | ||
| 463 | if other := cards[name]; other != nil && other != c { | 463 | if other := cards[name]; other != nil && other != c { | ||
| 464 | // Core and clients know outputs by connector name. | 464 | // Core and clients know outputs by connector name. | ||
M internal/app/headless.go +1 -1
| @@ -43,7 +43,7 @@ func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch | |||
| 43 | 43 | } | |
| 44 | 44 | cur := &headless.Cursor{} | |
| 45 | 45 | curs.set(name, cur) | |
| 46 | - | opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: o.hdr, RawHDR: o.raw, Formats: ch.formats, Log: log, NewRenderer: newRenderer, NewCaptureRenderer: newRenderer, Name: name, Presented: ch.presented, Captured: ch.captured} | |
| 46 | + | opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: o.hdr, RawHDR: o.raw, Formats: ch.formats, Log: log, NewRenderer: newRenderer, NewCaptureRenderer: newRenderer, Name: name, Presented: ch.presented, Frames: ch.frames, Captured: ch.captured} | |
| 47 | 47 | started := set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest, secure <-chan ports.SecurityState, instance ports.OutputInstance) error { | |
| 48 | 48 | opts.Security, opts.SecurityChanges, opts.Instance = ch.security, secure, instance | |
| 49 | 49 | return headless.Run(octx, opts, sc, cc, cu, cap) | |
| @@ -43,7 +43,7 @@ func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch | |||||
| 43 | } | 43 | } | ||
| 44 | cur := &headless.Cursor{} | 44 | cur := &headless.Cursor{} | ||
| 45 | curs.set(name, cur) | 45 | curs.set(name, cur) | ||
| 46 | - | opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: o.hdr, RawHDR: o.raw, Formats: ch.formats, Log: log, NewRenderer: newRenderer, NewCaptureRenderer: newRenderer, Name: name, Presented: ch.presented, Captured: ch.captured} | 46 | + | opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: o.hdr, RawHDR: o.raw, Formats: ch.formats, Log: log, NewRenderer: newRenderer, NewCaptureRenderer: newRenderer, Name: name, Presented: ch.presented, Frames: ch.frames, Captured: ch.captured} |
| 47 | started := set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest, secure <-chan ports.SecurityState, instance ports.OutputInstance) error { | 47 | started := set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest, secure <-chan ports.SecurityState, instance ports.OutputInstance) error { | ||
| 48 | opts.Security, opts.SecurityChanges, opts.Instance = ch.security, secure, instance | 48 | opts.Security, opts.SecurityChanges, opts.Instance = ch.security, secure, instance | ||
| 49 | return headless.Run(octx, opts, sc, cc, cu, cap) | 49 | return headless.Run(octx, opts, sc, cc, cu, cap) | ||
M internal/app/outputrouting.go +1 -0
| @@ -17,6 +17,7 @@ type outputChannels struct { | |||
| 17 | 17 | contents <-chan ports.SurfaceContent | |
| 18 | 18 | cursorChanges <-chan ports.CursorChange | |
| 19 | 19 | presented ports.PresentationSink | |
| 20 | + | frames chan<- ports.OutputFrame | |
| 20 | 21 | captures <-chan ports.CaptureRequest | |
| 21 | 22 | captured chan<- ports.CaptureDone | |
| 22 | 23 | formats chan<- ports.OutputFormats | |
| @@ -17,6 +17,7 @@ type outputChannels struct { | |||||
| 17 | contents <-chan ports.SurfaceContent | 17 | contents <-chan ports.SurfaceContent | ||
| 18 | cursorChanges <-chan ports.CursorChange | 18 | cursorChanges <-chan ports.CursorChange | ||
| 19 | presented ports.PresentationSink | 19 | presented ports.PresentationSink | ||
| 20 | + | frames chan<- ports.OutputFrame | |||
| 20 | captures <-chan ports.CaptureRequest | 21 | captures <-chan ports.CaptureRequest | ||
| 21 | captured chan<- ports.CaptureDone | 22 | captured chan<- ports.CaptureDone | ||
| 22 | formats chan<- ports.OutputFormats | 23 | formats chan<- ports.OutputFormats | ||
M internal/app/session.go +1 -1
| @@ -354,7 +354,7 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent)) | |||
| 354 | 354 | ||
| 355 | 355 | go func() { | |
| 356 | 356 | defer workers.Done() | |
| 357 | - | outputIO := p.outputs(security, framesTap{sink: server.Presentation(), frames: p.frames}) | |
| 357 | + | outputIO := p.outputs(security, server.Presentation()) | |
| 358 | 358 | apply := newOutputApply(core.NewOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app")) | |
| 359 | 359 | if hw != nil { | |
| 360 | 360 | done <- safe("output", func() error { | |
| @@ -354,7 +354,7 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent)) | |||||
| 354 | 354 | ||||
| 355 | go func() { | 355 | go func() { | ||
| 356 | defer workers.Done() | 356 | defer workers.Done() | ||
| 357 | - | outputIO := p.outputs(security, framesTap{sink: server.Presentation(), frames: p.frames}) | 357 | + | outputIO := p.outputs(security, server.Presentation()) |
| 358 | apply := newOutputApply(core.NewOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app")) | 358 | apply := newOutputApply(core.NewOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app")) | ||
| 359 | if hw != nil { | 359 | if hw != nil { | ||
| 360 | done <- safe("output", func() error { | 360 | done <- safe("output", func() error { | ||
M internal/app/wiring.go +1 -0
| @@ -168,6 +168,7 @@ func (p *pipes) outputs(security ports.SessionSecurity, presented ports.Presenta | |||
| 168 | 168 | contents: p.contents, | |
| 169 | 169 | cursorChanges: p.cursorChanges, | |
| 170 | 170 | presented: presented, | |
| 171 | + | frames: p.frames, | |
| 171 | 172 | captures: p.captures, | |
| 172 | 173 | captured: p.captured, | |
| 173 | 174 | formats: p.outputFormats, | |
| @@ -168,6 +168,7 @@ func (p *pipes) outputs(security ports.SessionSecurity, presented ports.Presenta | |||||
| 168 | contents: p.contents, | 168 | contents: p.contents, | ||
| 169 | cursorChanges: p.cursorChanges, | 169 | cursorChanges: p.cursorChanges, | ||
| 170 | presented: presented, | 170 | presented: presented, | ||
| 171 | + | frames: p.frames, | |||
| 171 | captures: p.captures, | 172 | captures: p.captures, | ||
| 172 | captured: p.captured, | 173 | captured: p.captured, | ||
| 173 | formats: p.outputFormats, | 174 | formats: p.outputFormats, | ||
M internal/core/scene.go +3 -0
| @@ -123,6 +123,9 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||
| 123 | 123 | scene.Windows = append(scene.Windows, c.scenePopups(sc)...) | |
| 124 | 124 | scene.CaptureIndicators = c.captureIndicators(sc) | |
| 125 | 125 | scene.Capture = c.captureSceneFor(sc, capture) | |
| 126 | + | // The springs of this frame are advanced: a scene shown while one still | |
| 127 | + | // runs wakes core when it flips; the one a spring settles on does not. | |
| 128 | + | scene.Animating = c.sliding(sc.name()) | |
| 126 | 129 | // A scene carrying a capture image always gets a fresh Seq, and so | |
| 127 | 130 | // does one of an animating screen (the one being stepped, or any | |
| 128 | 131 | // when none is): the outputs drop a scene they already show, and a | |
| @@ -123,6 +123,9 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||||
| 123 | scene.Windows = append(scene.Windows, c.scenePopups(sc)...) | 123 | scene.Windows = append(scene.Windows, c.scenePopups(sc)...) | ||
| 124 | scene.CaptureIndicators = c.captureIndicators(sc) | 124 | scene.CaptureIndicators = c.captureIndicators(sc) | ||
| 125 | scene.Capture = c.captureSceneFor(sc, capture) | 125 | scene.Capture = c.captureSceneFor(sc, capture) | ||
| 126 | + | // The springs of this frame are advanced: a scene shown while one still | |||
| 127 | + | // runs wakes core when it flips; the one a spring settles on does not. | |||
| 128 | + | scene.Animating = c.sliding(sc.name()) | |||
| 126 | // A scene carrying a capture image always gets a fresh Seq, and so | 129 | // A scene carrying a capture image always gets a fresh Seq, and so | ||
| 127 | // does one of an animating screen (the one being stepped, or any | 130 | // does one of an animating screen (the one being stepped, or any | ||
| 128 | // when none is): the outputs drop a scene they already show, and a | 131 | // when none is): the outputs drop a scene they already show, and a | ||
A internal/core/scene_animating_test.go +76 -0
| @@ -0,0 +1,76 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // A scene published while a spring runs carries Animating, so its flip wakes | |
| 12 | + | // core for the next frame; the scene the spring settles on does not, and | |
| 13 | + | // neither does any scene of an idle output. | |
| 14 | + | func TestSceneAnimatingFollowsSprings(t *testing.T) { | |
| 15 | + | c, ic := indicatorCore(t) | |
| 16 | + | c.cfg.Animations.On = true | |
| 17 | + | a := c.cur() | |
| 18 | + | a.mon.AddWindow(1) | |
| 19 | + | a.mon.AddWindow(2) | |
| 20 | + | if s := indicatorScene(t, c); s.Animating { | |
| 21 | + | t.Fatal("an idle output publishes an animating scene") | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // A slide starts. | |
| 25 | + | w := a.mon.Current() | |
| 26 | + | w.view.off = 100 | |
| 27 | + | w.view.motion = newMotion(viewSpring(w.view.off, 0), ic.now, 1) | |
| 28 | + | ic.now = ic.now.Add(10 * time.Millisecond) | |
| 29 | + | first := stepScenes(t, c, a)["A"] | |
| 30 | + | if !first.Animating { | |
| 31 | + | t.Fatal("a scene with a running spring is not animating") | |
| 32 | + | } | |
| 33 | + | ic.now = ic.now.Add(10 * time.Millisecond) | |
| 34 | + | if second := stepScenes(t, c, a)["A"]; !second.Animating || second.Seq == first.Seq { | |
| 35 | + | t.Fatalf("running slide: animating=%v Seq %d (was %d)", second.Animating, second.Seq, first.Seq) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // The step that lands the spring publishes the last scene: nothing more | |
| 39 | + | // is due, so it must not wake core again. | |
| 40 | + | ic.now = ic.now.Add(10 * time.Second) | |
| 41 | + | last := stepScenes(t, c, a)["A"] | |
| 42 | + | if last.Animating { | |
| 43 | + | t.Fatal("the scene a spring settles on is animating") | |
| 44 | + | } | |
| 45 | + | if a.springing() { | |
| 46 | + | t.Fatal("bad setup: the spring still runs") | |
| 47 | + | } | |
| 48 | + | // Idle again: still false, and the Seq is held (no extra recomposition). | |
| 49 | + | if err := c.publish(context.Background()); err != nil { | |
| 50 | + | t.Fatal(err) | |
| 51 | + | } | |
| 52 | + | idle := (<-c.ch.Scenes)[0] | |
| 53 | + | if idle.Animating || idle.Seq != last.Seq || !idle.SameAs(last) { | |
| 54 | + | t.Fatalf("idle publish: animating=%v Seq %d (was %d)", idle.Animating, idle.Seq, last.Seq) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // Animating is per output: a spring on one does not mark the other. | |
| 59 | + | func TestSceneAnimatingIsPerOutput(t *testing.T) { | |
| 60 | + | c, ic := indicatorCore(t) | |
| 61 | + | c.cfg.Animations.On = true | |
| 62 | + | a := c.cur() | |
| 63 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200}) | |
| 64 | + | b := c.screens[1] | |
| 65 | + | a.mon.AddWindow(1) | |
| 66 | + | b.mon.AddWindow(2) | |
| 67 | + | indicatorScene(t, c) | |
| 68 | + | w := b.mon.Current() | |
| 69 | + | w.view.off = 100 | |
| 70 | + | w.view.motion = newMotion(viewSpring(w.view.off, 0), ic.now, 1) | |
| 71 | + | ic.now = ic.now.Add(10 * time.Millisecond) | |
| 72 | + | got := stepScenes(t, c, b) | |
| 73 | + | if !got["B"].Animating || got["A"].Animating { | |
| 74 | + | t.Fatalf("animating A=%v B=%v, want B only", got["A"].Animating, got["B"].Animating) | |
| 75 | + | } | |
| 76 | + | } | |
| @@ -0,0 +1,76 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // A scene published while a spring runs carries Animating, so its flip wakes | |||
| 12 | + | // core for the next frame; the scene the spring settles on does not, and | |||
| 13 | + | // neither does any scene of an idle output. | |||
| 14 | + | func TestSceneAnimatingFollowsSprings(t *testing.T) { | |||
| 15 | + | c, ic := indicatorCore(t) | |||
| 16 | + | c.cfg.Animations.On = true | |||
| 17 | + | a := c.cur() | |||
| 18 | + | a.mon.AddWindow(1) | |||
| 19 | + | a.mon.AddWindow(2) | |||
| 20 | + | if s := indicatorScene(t, c); s.Animating { | |||
| 21 | + | t.Fatal("an idle output publishes an animating scene") | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // A slide starts. | |||
| 25 | + | w := a.mon.Current() | |||
| 26 | + | w.view.off = 100 | |||
| 27 | + | w.view.motion = newMotion(viewSpring(w.view.off, 0), ic.now, 1) | |||
| 28 | + | ic.now = ic.now.Add(10 * time.Millisecond) | |||
| 29 | + | first := stepScenes(t, c, a)["A"] | |||
| 30 | + | if !first.Animating { | |||
| 31 | + | t.Fatal("a scene with a running spring is not animating") | |||
| 32 | + | } | |||
| 33 | + | ic.now = ic.now.Add(10 * time.Millisecond) | |||
| 34 | + | if second := stepScenes(t, c, a)["A"]; !second.Animating || second.Seq == first.Seq { | |||
| 35 | + | t.Fatalf("running slide: animating=%v Seq %d (was %d)", second.Animating, second.Seq, first.Seq) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // The step that lands the spring publishes the last scene: nothing more | |||
| 39 | + | // is due, so it must not wake core again. | |||
| 40 | + | ic.now = ic.now.Add(10 * time.Second) | |||
| 41 | + | last := stepScenes(t, c, a)["A"] | |||
| 42 | + | if last.Animating { | |||
| 43 | + | t.Fatal("the scene a spring settles on is animating") | |||
| 44 | + | } | |||
| 45 | + | if a.springing() { | |||
| 46 | + | t.Fatal("bad setup: the spring still runs") | |||
| 47 | + | } | |||
| 48 | + | // Idle again: still false, and the Seq is held (no extra recomposition). | |||
| 49 | + | if err := c.publish(context.Background()); err != nil { | |||
| 50 | + | t.Fatal(err) | |||
| 51 | + | } | |||
| 52 | + | idle := (<-c.ch.Scenes)[0] | |||
| 53 | + | if idle.Animating || idle.Seq != last.Seq || !idle.SameAs(last) { | |||
| 54 | + | t.Fatalf("idle publish: animating=%v Seq %d (was %d)", idle.Animating, idle.Seq, last.Seq) | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // Animating is per output: a spring on one does not mark the other. | |||
| 59 | + | func TestSceneAnimatingIsPerOutput(t *testing.T) { | |||
| 60 | + | c, ic := indicatorCore(t) | |||
| 61 | + | c.cfg.Animations.On = true | |||
| 62 | + | a := c.cur() | |||
| 63 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200}) | |||
| 64 | + | b := c.screens[1] | |||
| 65 | + | a.mon.AddWindow(1) | |||
| 66 | + | b.mon.AddWindow(2) | |||
| 67 | + | indicatorScene(t, c) | |||
| 68 | + | w := b.mon.Current() | |||
| 69 | + | w.view.off = 100 | |||
| 70 | + | w.view.motion = newMotion(viewSpring(w.view.off, 0), ic.now, 1) | |||
| 71 | + | ic.now = ic.now.Add(10 * time.Millisecond) | |||
| 72 | + | got := stepScenes(t, c, b) | |||
| 73 | + | if !got["B"].Animating || got["A"].Animating { | |||
| 74 | + | t.Fatalf("animating A=%v B=%v, want B only", got["A"].Animating, got["B"].Animating) | |||
| 75 | + | } | |||
| 76 | + | } | |||
M internal/ports/scene.go +8 -1
| @@ -69,10 +69,17 @@ type Scene struct { | |||
| 69 | 69 | // drawn over everything; nil when none. Captures never hold them: the | |
| 70 | 70 | // capture pipeline serves requests from a scene without them. | |
| 71 | 71 | CaptureIndicators []CaptureIndicator | |
| 72 | + | // Animating is set by core when a spring or the focus pulse runs on | |
| 73 | + | // this output after this frame was computed: the output reports the | |
| 74 | + | // flip of the scene to core (OutputFrame), which moves the animation to | |
| 75 | + | // the next frame. A scene of a settled output carries false, so a flip | |
| 76 | + | // of it wakes nobody. It describes scheduling, not drawing: SameAs and | |
| 77 | + | // the renderer ignore it. | |
| 78 | + | Animating bool | |
| 72 | 79 | } | |
| 73 | 80 | ||
| 74 | 81 | // SameAs reports whether o draws exactly what s draws: every field other | |
| 75 | - | // than Seq is equal. A scene carrying a CaptureScene is never the same | |
| 82 | + | // than Seq and Animating (scheduling only, not drawn) is equal. A scene carrying a CaptureScene is never the same | |
| 76 | 83 | // (conservative: the capture image is not compared). Add every new Scene | |
| 77 | 84 | // field here; TestSceneSameAsCoversEveryField fails until the field count | |
| 78 | 85 | // is updated. | |
| @@ -69,10 +69,17 @@ type Scene struct { | |||||
| 69 | // drawn over everything; nil when none. Captures never hold them: the | 69 | // drawn over everything; nil when none. Captures never hold them: the | ||
| 70 | // capture pipeline serves requests from a scene without them. | 70 | // capture pipeline serves requests from a scene without them. | ||
| 71 | CaptureIndicators []CaptureIndicator | 71 | CaptureIndicators []CaptureIndicator | ||
| 72 | + | // Animating is set by core when a spring or the focus pulse runs on | |||
| 73 | + | // this output after this frame was computed: the output reports the | |||
| 74 | + | // flip of the scene to core (OutputFrame), which moves the animation to | |||
| 75 | + | // the next frame. A scene of a settled output carries false, so a flip | |||
| 76 | + | // of it wakes nobody. It describes scheduling, not drawing: SameAs and | |||
| 77 | + | // the renderer ignore it. | |||
| 78 | + | Animating bool | |||
| 72 | } | 79 | } | ||
| 73 | 80 | ||||
| 74 | // SameAs reports whether o draws exactly what s draws: every field other | 81 | // SameAs reports whether o draws exactly what s draws: every field other | ||
| 75 | - | // than Seq is equal. A scene carrying a CaptureScene is never the same | 82 | + | // than Seq and Animating (scheduling only, not drawn) is equal. A scene carrying a CaptureScene is never the same |
| 76 | // (conservative: the capture image is not compared). Add every new Scene | 83 | // (conservative: the capture image is not compared). Add every new Scene | ||
| 77 | // field here; TestSceneSameAsCoversEveryField fails until the field count | 84 | // field here; TestSceneSameAsCoversEveryField fails until the field count | ||
| 78 | // is updated. | 85 | // is updated. | ||
M internal/ports/scene_same_test.go +3 -1
| @@ -83,7 +83,9 @@ func TestSceneSameAs(t *testing.T) { | |||
| 83 | 83 | // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field: | |
| 84 | 84 | // update Scene.SameAs, then this count. | |
| 85 | 85 | func TestSceneSameAsCoversEveryField(t *testing.T) { | |
| 86 | - | const fields = 21 | |
| 86 | + | // 22 with Animating, which SameAs intentionally does not compare: it | |
| 87 | + | // only schedules the next frame, it draws nothing. | |
| 88 | + | const fields = 22 | |
| 87 | 89 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))), | |
| 88 | 90 | "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count") | |
| 89 | 91 | } | |
| @@ -83,7 +83,9 @@ func TestSceneSameAs(t *testing.T) { | |||||
| 83 | // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field: | 83 | // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field: | ||
| 84 | // update Scene.SameAs, then this count. | 84 | // update Scene.SameAs, then this count. | ||
| 85 | func TestSceneSameAsCoversEveryField(t *testing.T) { | 85 | func TestSceneSameAsCoversEveryField(t *testing.T) { | ||
| 86 | - | const fields = 21 | 86 | + | // 22 with Animating, which SameAs intentionally does not compare: it |
| 87 | + | // only schedules the next frame, it draws nothing. | |||
| 88 | + | const fields = 22 | |||
| 87 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))), | 89 | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))), | ||
| 88 | "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count") | 90 | "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count") | ||
| 89 | } | 91 | } | ||