detached 4f41458

perf(core): wake core on flips only while a scene animates

commit
4f41458f8e0963412f65140a9f4054ef73319a49
author
brice · · 2026-10-13 16:03 UTC
committer
brice · · 2026-10-14 04:34 UTC
parent
c5afe88
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
20 files · +274 -58
M Makefile +1 -1
@@ -69,7 +69,7 @@ adapter-imports:
6969 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
7070 # Fast, mandatory guards for tiled content publications and SHM copies.
7171 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
7373 check: vet test arch fakes-check log-check perf-check
7474
7575 # 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=172+ 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-check73 check: vet test arch fakes-check log-check perf-check
74 74
75 # On-demand SDR/HDR colour check with examples/testpattern on a headless75 # 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 {
3131 queued uint64
3232 zeroCopy ports.WindowID // window shown without composition
3333 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
3537 // fences are duplicates of the fences the commit waits on, owned until
3638 // the commit ends. composed is set when the frame was rendered.
3739 fences []*os.File
@@ -31,7 +31,9 @@ type pendingFrame struct {
31 queued uint6431 queued uint64
32 zeroCopy ports.WindowID // window shown without composition32 zeroCopy ports.WindowID // window shown without composition
33 async bool33 async bool
34- shows map[ports.WindowID]uint6434+ // 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 until37 // 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.File39 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.
3434 }
3535 // shows is what the frame puts on screen: presentation feedback of
3636 // 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)}
3838 o.wantContent(scene, surfaces)
3939 // Requests that cannot be served (exclusion of another session, a
4040 // 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.
8282 }
8383 if decision.fb != 0 {
8484 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 {
8686 presented = err == nil
8787 return true, err
8888 }
@@ -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 of35 // 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, a39 // Requests that cannot be served (exclusion of another session, a
40 // workspace that moved) are failed here, never answered from the40 // 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.content84 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 == nil86 presented = err == nil
87 return true, err87 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 (
2727 // bounded (stuckTimeout, then a modeset).
2828 type Output struct {
2929 // 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
3437 securityState ports.SecurityState
3538 securityPrepared bool
3639 securityInvalid bool
@@ -1651,9 +1654,24 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool {
16511654 o.queued = 0
16521655 }
16531656 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+ }
16541660 return true
16551661 }
16561662
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+
16571675 // setCursor loads a cursor for scale and output transform t into the cursor
16581676 // images.
16591677 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.SessionSecurity30+ Security ports.SessionSecurity
31- SecurityChanges <-chan ports.SecurityState31+ SecurityChanges <-chan ports.SecurityState
32- Instance ports.OutputInstance32+ Instance ports.OutputInstance
33- SecurityEvents chan<- ports.SecurityBackendEvent33+ 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.SecurityState37 securityState ports.SecurityState
35 securityPrepared bool38 securityPrepared bool
36 securityInvalid bool39 securityInvalid bool
@@ -1651,9 +1654,24 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool {
1651 o.queued = 01654 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 true1660 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 cursor1675 // 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 {
4848 // frame, so wayland can release client buffers (nil: no reports).
4949 Name string
5050 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
5255 // Clock paces the renderer trim (nil: system clock).
5356 Clock ports.Clock
5457 }
@@ -543,6 +546,12 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
543546 if opts.report(&reports, flipInfo(scene, drawn, table.Kept), seen) {
544547 mergedFlips++
545548 }
549+ if scene.Animating && opts.Frames != nil {
550+ select {
551+ case opts.Frames <- ports.OutputFrame{Output: opts.Name}:
552+ default:
553+ }
554+ }
546555 if opts.ScreenshotDir != "" && !opts.protected() {
547556 shot := r.Pixels()
548557 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 string49 Name string
50 Presented ports.PresentationSink50 Presented ports.PresentationSink
51- Captured chan<- ports.CaptureDone51+ // 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.Clock56 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 (
265265 // separator present in only one scene adds its own. A new Scene field
266266 // belongs here too (see Scene.SameAs, TestSceneSameAsCoversEveryField); a
267267 // new SceneWindow field is checked by TestSceneWindowFieldCount.
268+// Scene.Animating is not compared: it schedules the next frame, it draws
269+// nothing.
268270 func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, limit int) (image.Rectangle, bool) {
269271 if old.Security != cur.Security || old.Output != cur.Output ||
270272 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 field265 // separator present in only one scene adds its own. A new Scene field
266 // belongs here too (see Scene.SameAs, TestSceneSameAsCoversEveryField); a266 // 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 (
1717 // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta
1818 // lists the fields that force a full redraw.
1919 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
2123 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))),
2224 "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count")
2325 }
@@ -17,7 +17,9 @@ import (
17 // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta17 // 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 = 2120+ // 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
138138 }
139139 }
140140
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-
160141 func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configErrors <-chan error, tap chan<- []ports.Scene, renderScenes chan []ports.Scene) {
161142 log := logging.For(ctx, "core")
162143 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 (
1717 "github.com/bnema/neferwl/internal/adapters/config"
1818 "github.com/bnema/neferwl/internal/adapters/logging"
1919 "github.com/bnema/neferwl/internal/adapters/xkb"
20- portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
2120 "github.com/bnema/neferwl/internal/ports"
22- "github.com/stretchr/testify/mock"
23- "github.com/stretchr/testify/require"
2421 )
2522
2623 func TestHeadlessConfigReload(t *testing.T) {
@@ -426,24 +423,3 @@ func TestHeadlessTwoOutputs(t *testing.T) {
426423 }
427424 }
428425 }
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
458458 for _, o := range added {
459459 // Every physical lifetime gets the gate before any early exit:
460460 // 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
462462 name := o.Info().Name
463463 if other := cards[name]; other != nil && other != c {
464464 // 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.security461+ o.Security, o.Frames = ch.security, ch.frames
462 name := o.Info().Name462 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
4343 }
4444 cur := &headless.Cursor{}
4545 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}
4747 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 {
4848 opts.Security, opts.SecurityChanges, opts.Instance = ch.security, secure, instance
4949 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, instance48 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 {
1717 contents <-chan ports.SurfaceContent
1818 cursorChanges <-chan ports.CursorChange
1919 presented ports.PresentationSink
20+ frames chan<- ports.OutputFrame
2021 captures <-chan ports.CaptureRequest
2122 captured chan<- ports.CaptureDone
2223 formats chan<- ports.OutputFormats
@@ -17,6 +17,7 @@ type outputChannels struct {
17 contents <-chan ports.SurfaceContent17 contents <-chan ports.SurfaceContent
18 cursorChanges <-chan ports.CursorChange18 cursorChanges <-chan ports.CursorChange
19 presented ports.PresentationSink19 presented ports.PresentationSink
20+ frames chan<- ports.OutputFrame
20 captures <-chan ports.CaptureRequest21 captures <-chan ports.CaptureRequest
21 captured chan<- ports.CaptureDone22 captured chan<- ports.CaptureDone
22 formats chan<- ports.OutputFormats23 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))
354354
355355 go func() {
356356 defer workers.Done()
357- outputIO := p.outputs(security, framesTap{sink: server.Presentation(), frames: p.frames})
357+ outputIO := p.outputs(security, server.Presentation())
358358 apply := newOutputApply(core.NewOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))
359359 if hw != nil {
360360 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
168168 contents: p.contents,
169169 cursorChanges: p.cursorChanges,
170170 presented: presented,
171+ frames: p.frames,
171172 captures: p.captures,
172173 captured: p.captured,
173174 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
123123 scene.Windows = append(scene.Windows, c.scenePopups(sc)...)
124124 scene.CaptureIndicators = c.captureIndicators(sc)
125125 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())
126129 // A scene carrying a capture image always gets a fresh Seq, and so
127130 // does one of an animating screen (the one being stepped, or any
128131 // 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 so129 // 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 any130 // 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 a131 // 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 {
6969 // drawn over everything; nil when none. Captures never hold them: the
7070 // capture pipeline serves requests from a scene without them.
7171 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
7279 }
7380
7481 // 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
7683 // (conservative: the capture image is not compared). Add every new Scene
7784 // field here; TestSceneSameAsCoversEveryField fails until the field count
7885 // is updated.
@@ -69,10 +69,17 @@ type Scene struct {
69 // drawn over everything; nil when none. Captures never hold them: the69 // 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 []CaptureIndicator71 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 other81 // 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 same82+// 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 Scene83 // (conservative: the capture image is not compared). Add every new Scene
77 // field here; TestSceneSameAsCoversEveryField fails until the field count84 // 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) {
8383 // TestSceneSameAsCoversEveryField fails when Scene gains or loses a field:
8484 // update Scene.SameAs, then this count.
8585 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
8789 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),
8890 "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")
8991 }
@@ -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 = 2186+ // 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 }

Keyboard shortcuts

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

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

Branches 26

Tags 10

↑↓ moveenter openesc close