detached ef6bf5d

Merge pull request 'perf: cut hot-path allocations in app, overview, wayland and vulkan' (#59) from perf/hot-path-allocs into main

Reviewed-on: https://git.bnema.dev/bnema/neferwl/pulls/59
commit
ef6bf5da3bff4082db0afdda48a1ac58db409c63
author
bnema · · 2026-10-16 14:20 UTC
merge of
9dcad8d + 4a9b1d1 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
17 files · +478 -86
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/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations)$$' -count=1
72+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing)$$' -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/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations)$$' -count=172+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing)$$' -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/vulkan/frame.go +24 -4
@@ -54,12 +54,24 @@ type callScratch struct {
5454 hdrNits float32
5555 }
5656
57-// deferredFree runs once frame completed.
57+// deferredFree runs once frame completed. The hot path, a wait semaphore
58+// of the frame, is typed (sem, with free nil) so it costs no closure; cold
59+// paths set free.
5860 type deferredFree struct {
5961 frame uint64
62+ sem vk.Semaphore
6063 free func()
6164 }
6265
66+// free frees what f holds.
67+func (r *Renderer) free(f deferredFree) {
68+ if f.free != nil {
69+ f.free()
70+ return
71+ }
72+ r.dd.DestroySemaphore(r.device, f.sem, nil)
73+}
74+
6375 func (r *Renderer) createSlots() error {
6476 d := r.dd
6577 cmds := make([]vk.CommandBuffer, frameSlots)
@@ -154,10 +166,19 @@ func (r *Renderer) retire(frame uint64, free func()) {
154166 r.deferred = append(r.deferred, deferredFree{frame: frame, free: free})
155167 }
156168
169+// retireSemaphore destroys sem once the frame that last used it completed.
170+func (r *Renderer) retireSemaphore(frame uint64, sem vk.Semaphore) {
171+ if frame <= r.completed {
172+ r.dd.DestroySemaphore(r.device, sem, nil)
173+ return
174+ }
175+ r.deferred = append(r.deferred, deferredFree{frame: frame, sem: sem})
176+}
177+
157178 func (r *Renderer) runDeferred() {
158179 r.deferred = slices.DeleteFunc(r.deferred, func(f deferredFree) bool {
159180 if f.frame <= r.completed {
160- f.free()
181+ r.free(f)
161182 return true
162183 }
163184 return false
@@ -311,8 +332,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
311332 r.frameWaits = waits
312333 frame := r.frame
313334 for _, sem := range waits {
314- sem := sem
315- r.retire(frame, func() { d.DestroySemaphore(r.device, sem, nil) })
335+ r.retireSemaphore(frame, sem)
316336 }
317337 sc.submit = vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &sc.cmd}
318338 submit := &sc.submit
@@ -54,12 +54,24 @@ type callScratch struct {
54 hdrNits float3254 hdrNits float32
55 }55 }
56 56
57-// deferredFree runs once frame completed.57+// deferredFree runs once frame completed. The hot path, a wait semaphore
58+// of the frame, is typed (sem, with free nil) so it costs no closure; cold
59+// paths set free.
58 type deferredFree struct {60 type deferredFree struct {
59 frame uint6461 frame uint64
62+ sem vk.Semaphore
60 free func()63 free func()
61 }64 }
62 65
66+// free frees what f holds.
67+func (r *Renderer) free(f deferredFree) {
68+ if f.free != nil {
69+ f.free()
70+ return
71+ }
72+ r.dd.DestroySemaphore(r.device, f.sem, nil)
73+}
74+
63 func (r *Renderer) createSlots() error {75 func (r *Renderer) createSlots() error {
64 d := r.dd76 d := r.dd
65 cmds := make([]vk.CommandBuffer, frameSlots)77 cmds := make([]vk.CommandBuffer, frameSlots)
@@ -154,10 +166,19 @@ func (r *Renderer) retire(frame uint64, free func()) {
154 r.deferred = append(r.deferred, deferredFree{frame: frame, free: free})166 r.deferred = append(r.deferred, deferredFree{frame: frame, free: free})
155 }167 }
156 168
169+// retireSemaphore destroys sem once the frame that last used it completed.
170+func (r *Renderer) retireSemaphore(frame uint64, sem vk.Semaphore) {
171+ if frame <= r.completed {
172+ r.dd.DestroySemaphore(r.device, sem, nil)
173+ return
174+ }
175+ r.deferred = append(r.deferred, deferredFree{frame: frame, sem: sem})
176+}
177+
157 func (r *Renderer) runDeferred() {178 func (r *Renderer) runDeferred() {
158 r.deferred = slices.DeleteFunc(r.deferred, func(f deferredFree) bool {179 r.deferred = slices.DeleteFunc(r.deferred, func(f deferredFree) bool {
159 if f.frame <= r.completed {180 if f.frame <= r.completed {
160- f.free()181+ r.free(f)
161 return true182 return true
162 }183 }
163 return false184 return false
@@ -311,8 +332,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
311 r.frameWaits = waits332 r.frameWaits = waits
312 frame := r.frame333 frame := r.frame
313 for _, sem := range waits {334 for _, sem := range waits {
314- sem := sem335+ r.retireSemaphore(frame, sem)
315- r.retire(frame, func() { d.DestroySemaphore(r.device, sem, nil) })
316 }336 }
317 sc.submit = vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &sc.cmd}337 sc.submit = vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &sc.cmd}
318 submit := &sc.submit338 submit := &sc.submit
M internal/adapters/vulkan/scene_perf_test.go +62 -4
@@ -140,8 +140,10 @@ func TestRenderSteadyStateAllocations(t *testing.T) {
140140 // A steady frame that draws a client dmabuf also records the ownership
141141 // barriers (acquire and release) of its image, which allocate nothing. The
142142 // frame still allocates more than the SHM scene: importing the client's
143-// read fence and its retire closure (about 4 per frame). Skipped without a
144-// GPU or /dev/udmabuf.
143+// read fence (measured 5.0 on radv; the semaphore retire is typed and costs
144+// no closure, locked by TestRetireSemaphoreAllocatesNothing). The budget
145+// keeps one alloc of headroom for drivers whose import path differs.
146+// Skipped without a GPU or /dev/udmabuf.
145147 func TestRenderSteadyStateDMABufAllocations(t *testing.T) {
146148 r, err := New(80, 32)
147149 if err != nil {
@@ -182,8 +184,64 @@ func TestRenderSteadyStateDMABufAllocations(t *testing.T) {
182184 moved()
183185 allocs := testing.AllocsPerRun(20, moved)
184186 t.Logf("dmabuf Render: %.1f allocs/frame", allocs)
185- if allocs > 7 {
186- t.Errorf("dmabuf steady-state Render: %.1f allocs/frame, want <=7 (syncFD path: %v; a driver without sync-file export takes another path)", allocs, r.syncFD)
187+ if allocs > 6 {
188+ t.Errorf("dmabuf steady-state Render: %.1f allocs/frame, want <=6 (syncFD path: %v; a driver without sync-file export takes another path)", allocs, r.syncFD)
189+ }
190+}
191+
192+// Retiring a frame's wait semaphore appends a typed entry to the reused
193+// deferred list: no closure, no allocation once the list has its capacity.
194+func TestRetireSemaphoreAllocatesNothing(t *testing.T) {
195+ r, err := New(32, 32)
196+ if err != nil {
197+ t.Skipf("Vulkan unavailable: %v", err)
198+ }
199+ defer r.Close()
200+ r.completed = 0
201+ retire := func() {
202+ // A null handle is a valid vkDestroySemaphore argument.
203+ r.retireSemaphore(r.completed+1, 0)
204+ r.completed++
205+ r.runDeferred()
206+ }
207+ retire()
208+ if allocs := testing.AllocsPerRun(50, retire); allocs != 0 {
209+ t.Errorf("retireSemaphore: %.1f allocs, want 0", allocs)
210+ }
211+ if len(r.deferred) != 0 {
212+ t.Errorf("%d deferred entries left after their frame completed", len(r.deferred))
213+ }
214+}
215+
216+// Semaphore and closure entries share the deferred list: each is freed once
217+// its frame completes, in order, and a semaphore whose frame already
218+// completed is destroyed at once instead of queued.
219+func TestRetireMixedDeferredEntries(t *testing.T) {
220+ r, err := New(32, 32)
221+ if err != nil {
222+ t.Skipf("Vulkan unavailable: %v", err)
223+ }
224+ defer r.Close()
225+ r.runDeferred()
226+ r.completed = 5
227+ // A null handle is a valid vkDestroySemaphore argument.
228+ r.retireSemaphore(5, 0)
229+ if len(r.deferred) != 0 {
230+ t.Fatalf("completed frame's semaphore queued: %d entries", len(r.deferred))
231+ }
232+ var freed []string
233+ r.retire(7, func() { freed = append(freed, "late") })
234+ r.retireSemaphore(6, 0)
235+ r.retire(6, func() { freed = append(freed, "early") })
236+ r.completed = 6
237+ r.runDeferred()
238+ if !slices.Equal(freed, []string{"early"}) || len(r.deferred) != 1 || r.deferred[0].frame != 7 {
239+ t.Fatalf("frame 6: freed %v, %d entries left", freed, len(r.deferred))
240+ }
241+ r.completed = 7
242+ r.runDeferred()
243+ if !slices.Equal(freed, []string{"early", "late"}) || len(r.deferred) != 0 {
244+ t.Fatalf("frame 7: freed %v, %d entries left", freed, len(r.deferred))
187245 }
188246 }
189247
@@ -140,8 +140,10 @@ func TestRenderSteadyStateAllocations(t *testing.T) {
140 // A steady frame that draws a client dmabuf also records the ownership140 // A steady frame that draws a client dmabuf also records the ownership
141 // barriers (acquire and release) of its image, which allocate nothing. The141 // barriers (acquire and release) of its image, which allocate nothing. The
142 // frame still allocates more than the SHM scene: importing the client's142 // frame still allocates more than the SHM scene: importing the client's
143-// read fence and its retire closure (about 4 per frame). Skipped without a143+// read fence (measured 5.0 on radv; the semaphore retire is typed and costs
144-// GPU or /dev/udmabuf.144+// no closure, locked by TestRetireSemaphoreAllocatesNothing). The budget
145+// keeps one alloc of headroom for drivers whose import path differs.
146+// Skipped without a GPU or /dev/udmabuf.
145 func TestRenderSteadyStateDMABufAllocations(t *testing.T) {147 func TestRenderSteadyStateDMABufAllocations(t *testing.T) {
146 r, err := New(80, 32)148 r, err := New(80, 32)
147 if err != nil {149 if err != nil {
@@ -182,8 +184,64 @@ func TestRenderSteadyStateDMABufAllocations(t *testing.T) {
182 moved()184 moved()
183 allocs := testing.AllocsPerRun(20, moved)185 allocs := testing.AllocsPerRun(20, moved)
184 t.Logf("dmabuf Render: %.1f allocs/frame", allocs)186 t.Logf("dmabuf Render: %.1f allocs/frame", allocs)
185- if allocs > 7 {187+ if allocs > 6 {
186- t.Errorf("dmabuf steady-state Render: %.1f allocs/frame, want <=7 (syncFD path: %v; a driver without sync-file export takes another path)", allocs, r.syncFD)188+ t.Errorf("dmabuf steady-state Render: %.1f allocs/frame, want <=6 (syncFD path: %v; a driver without sync-file export takes another path)", allocs, r.syncFD)
189+ }
190+}
191+
192+// Retiring a frame's wait semaphore appends a typed entry to the reused
193+// deferred list: no closure, no allocation once the list has its capacity.
194+func TestRetireSemaphoreAllocatesNothing(t *testing.T) {
195+ r, err := New(32, 32)
196+ if err != nil {
197+ t.Skipf("Vulkan unavailable: %v", err)
198+ }
199+ defer r.Close()
200+ r.completed = 0
201+ retire := func() {
202+ // A null handle is a valid vkDestroySemaphore argument.
203+ r.retireSemaphore(r.completed+1, 0)
204+ r.completed++
205+ r.runDeferred()
206+ }
207+ retire()
208+ if allocs := testing.AllocsPerRun(50, retire); allocs != 0 {
209+ t.Errorf("retireSemaphore: %.1f allocs, want 0", allocs)
210+ }
211+ if len(r.deferred) != 0 {
212+ t.Errorf("%d deferred entries left after their frame completed", len(r.deferred))
213+ }
214+}
215+
216+// Semaphore and closure entries share the deferred list: each is freed once
217+// its frame completes, in order, and a semaphore whose frame already
218+// completed is destroyed at once instead of queued.
219+func TestRetireMixedDeferredEntries(t *testing.T) {
220+ r, err := New(32, 32)
221+ if err != nil {
222+ t.Skipf("Vulkan unavailable: %v", err)
223+ }
224+ defer r.Close()
225+ r.runDeferred()
226+ r.completed = 5
227+ // A null handle is a valid vkDestroySemaphore argument.
228+ r.retireSemaphore(5, 0)
229+ if len(r.deferred) != 0 {
230+ t.Fatalf("completed frame's semaphore queued: %d entries", len(r.deferred))
231+ }
232+ var freed []string
233+ r.retire(7, func() { freed = append(freed, "late") })
234+ r.retireSemaphore(6, 0)
235+ r.retire(6, func() { freed = append(freed, "early") })
236+ r.completed = 6
237+ r.runDeferred()
238+ if !slices.Equal(freed, []string{"early"}) || len(r.deferred) != 1 || r.deferred[0].frame != 7 {
239+ t.Fatalf("frame 6: freed %v, %d entries left", freed, len(r.deferred))
240+ }
241+ r.completed = 7
242+ r.runDeferred()
243+ if !slices.Equal(freed, []string{"early", "late"}) || len(r.deferred) != 0 {
244+ t.Fatalf("frame 7: freed %v, %d entries left", freed, len(r.deferred))
187 }245 }
188 }246 }
189 247
M internal/adapters/wayland/alloc_hotpath_test.go +83 -0
@@ -1,11 +1,15 @@
11 package wayland
22
33 import (
4+ "os"
5+ "slices"
46 "testing"
57 "time"
68
9+ "github.com/bnema/go-wayland-bindings/server/wayland"
710 "github.com/bnema/neferwl/internal/ports"
811 "github.com/stretchr/testify/mock"
12+ "golang.org/x/sys/unix"
913 )
1014
1115 // The steady-state hot paths that run for every frame or commit allocate
@@ -125,3 +129,82 @@ func TestEmitContentAllocations(t *testing.T) {
125129 }
126130 t.Logf("emitContent: %.2f allocs", allocs)
127131 }
132+
133+// Firing an output's callbacks keeps its queue's backing array, so the next
134+// commit's queueFrames appends in place (before: the queue was deleted by
135+// every fire and each commit grew a fresh slice). An emptied queue reads
136+// like an absent one, and pins no fired callback.
137+func TestFrameQueueKeepsBackingArray(t *testing.T) {
138+ s, events, commands, _, _, dir := presentServerOn(t, testOutputs)
139+ c := protocolClient(t, s, dir)
140+ w, surf, _ := surfaceMapper(t, c, events)()
141+ placeWindow(t, s, commands, w, "HEADLESS-1")
142+ awaitFrame(t, c, requestFrame(t, c, surf))
143+ awaitFrame(t, c, requestFrame(t, c, surf))
144+ var first *wayland.Callback
145+ var head **wayland.Callback
146+ s.display.Do(func() {
147+ // The pacer fired the queue during the awaitFrame calls above.
148+ queue := s.awaiting["HEADLESS-1"]
149+ if len(queue) != 0 || cap(queue) == 0 {
150+ t.Errorf("fired queue: len %d cap %d", len(queue), cap(queue))
151+ return
152+ }
153+ first, head = queue[:1][0], &queue[:1][0]
154+ })
155+ if first != nil {
156+ t.Fatal("fired queue pins a callback")
157+ }
158+ awaitFrame(t, c, requestFrame(t, c, surf))
159+ s.display.Do(func() {
160+ if again := s.awaiting["HEADLESS-1"]; cap(again) == 0 || &again[:1][0] != head {
161+ t.Error("queue reallocated between frames")
162+ }
163+ })
164+}
165+
166+// dueFrames hands out one scratch slice for every tick.
167+func TestDueFramesFireScratch(t *testing.T) {
168+ out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}}
169+ s := &Server{outputs: []*output{out}, frameDue: map[string]time.Time{}, awaiting: map[string][]*wayland.Callback{"DP-2": {nil}}}
170+ now := time.Unix(100, 0)
171+ flipped := map[string]bool{"DP-2": true}
172+ fire, _, _ := s.dueFrames(now, flipped)
173+ if !slices.Equal(fire, []string{"DP-2"}) {
174+ t.Fatalf("fire %v", fire)
175+ }
176+ if allocs := testing.AllocsPerRun(100, func() { s.dueFrames(now, flipped) }); allocs != 0 {
177+ t.Errorf("firing dueFrames: %.1f allocs, want 0", allocs)
178+ }
179+}
180+
181+// A buffer sends the same SHMBuffer on every commit and still checks the
182+// file's size each time: a client may truncate it.
183+func TestBufferContentAllocations(t *testing.T) {
184+ fd, err := unix.MemfdCreate("content-allocs", unix.MFD_CLOEXEC)
185+ if err != nil {
186+ t.Fatal(err)
187+ }
188+ f := os.NewFile(uintptr(fd), "content-allocs")
189+ defer f.Close()
190+ if err := f.Truncate(28); err != nil {
191+ t.Fatal(err)
192+ }
193+ b := &buffer{pool: &pool{id: 3, file: f, size: 28}, offset: 4, width: 2, height: 2, stride: 16}
194+ first, ok := b.content(1)
195+ if !ok {
196+ t.Fatal("no content")
197+ }
198+ if allocs := testing.AllocsPerRun(100, func() { b.content(1) }); allocs != 0 {
199+ t.Errorf("buffer.content: %.1f allocs, want 0", allocs)
200+ }
201+ if again, _ := b.content(2); again.SHM != first.SHM {
202+ t.Fatal("SHMBuffer rebuilt per commit")
203+ }
204+ if err := f.Truncate(27); err != nil {
205+ t.Fatal(err)
206+ }
207+ if _, ok := b.content(1); ok {
208+ t.Fatal("content from a truncated pool")
209+ }
210+}
@@ -1,11 +1,15 @@
1 package wayland1 package wayland
2 2
3 import (3 import (
4+ "os"
5+ "slices"
4 "testing"6 "testing"
5 "time"7 "time"
6 8
9+ "github.com/bnema/go-wayland-bindings/server/wayland"
7 "github.com/bnema/neferwl/internal/ports"10 "github.com/bnema/neferwl/internal/ports"
8 "github.com/stretchr/testify/mock"11 "github.com/stretchr/testify/mock"
12+ "golang.org/x/sys/unix"
9 )13 )
10 14
11 // The steady-state hot paths that run for every frame or commit allocate15 // The steady-state hot paths that run for every frame or commit allocate
@@ -125,3 +129,82 @@ func TestEmitContentAllocations(t *testing.T) {
125 }129 }
126 t.Logf("emitContent: %.2f allocs", allocs)130 t.Logf("emitContent: %.2f allocs", allocs)
127 }131 }
132+
133+// Firing an output's callbacks keeps its queue's backing array, so the next
134+// commit's queueFrames appends in place (before: the queue was deleted by
135+// every fire and each commit grew a fresh slice). An emptied queue reads
136+// like an absent one, and pins no fired callback.
137+func TestFrameQueueKeepsBackingArray(t *testing.T) {
138+ s, events, commands, _, _, dir := presentServerOn(t, testOutputs)
139+ c := protocolClient(t, s, dir)
140+ w, surf, _ := surfaceMapper(t, c, events)()
141+ placeWindow(t, s, commands, w, "HEADLESS-1")
142+ awaitFrame(t, c, requestFrame(t, c, surf))
143+ awaitFrame(t, c, requestFrame(t, c, surf))
144+ var first *wayland.Callback
145+ var head **wayland.Callback
146+ s.display.Do(func() {
147+ // The pacer fired the queue during the awaitFrame calls above.
148+ queue := s.awaiting["HEADLESS-1"]
149+ if len(queue) != 0 || cap(queue) == 0 {
150+ t.Errorf("fired queue: len %d cap %d", len(queue), cap(queue))
151+ return
152+ }
153+ first, head = queue[:1][0], &queue[:1][0]
154+ })
155+ if first != nil {
156+ t.Fatal("fired queue pins a callback")
157+ }
158+ awaitFrame(t, c, requestFrame(t, c, surf))
159+ s.display.Do(func() {
160+ if again := s.awaiting["HEADLESS-1"]; cap(again) == 0 || &again[:1][0] != head {
161+ t.Error("queue reallocated between frames")
162+ }
163+ })
164+}
165+
166+// dueFrames hands out one scratch slice for every tick.
167+func TestDueFramesFireScratch(t *testing.T) {
168+ out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}}
169+ s := &Server{outputs: []*output{out}, frameDue: map[string]time.Time{}, awaiting: map[string][]*wayland.Callback{"DP-2": {nil}}}
170+ now := time.Unix(100, 0)
171+ flipped := map[string]bool{"DP-2": true}
172+ fire, _, _ := s.dueFrames(now, flipped)
173+ if !slices.Equal(fire, []string{"DP-2"}) {
174+ t.Fatalf("fire %v", fire)
175+ }
176+ if allocs := testing.AllocsPerRun(100, func() { s.dueFrames(now, flipped) }); allocs != 0 {
177+ t.Errorf("firing dueFrames: %.1f allocs, want 0", allocs)
178+ }
179+}
180+
181+// A buffer sends the same SHMBuffer on every commit and still checks the
182+// file's size each time: a client may truncate it.
183+func TestBufferContentAllocations(t *testing.T) {
184+ fd, err := unix.MemfdCreate("content-allocs", unix.MFD_CLOEXEC)
185+ if err != nil {
186+ t.Fatal(err)
187+ }
188+ f := os.NewFile(uintptr(fd), "content-allocs")
189+ defer f.Close()
190+ if err := f.Truncate(28); err != nil {
191+ t.Fatal(err)
192+ }
193+ b := &buffer{pool: &pool{id: 3, file: f, size: 28}, offset: 4, width: 2, height: 2, stride: 16}
194+ first, ok := b.content(1)
195+ if !ok {
196+ t.Fatal("no content")
197+ }
198+ if allocs := testing.AllocsPerRun(100, func() { b.content(1) }); allocs != 0 {
199+ t.Errorf("buffer.content: %.1f allocs, want 0", allocs)
200+ }
201+ if again, _ := b.content(2); again.SHM != first.SHM {
202+ t.Fatal("SHMBuffer rebuilt per commit")
203+ }
204+ if err := f.Truncate(27); err != nil {
205+ t.Fatal(err)
206+ }
207+ if _, ok := b.content(1); ok {
208+ t.Fatal("content from a truncated pool")
209+ }
210+}
M internal/adapters/wayland/frames.go +12 -3
@@ -253,6 +253,7 @@ func (s *Server) relocateCallbacks(root *surface) {
253253 dest := s.frameOutput(owner)
254254 moved[dest] = append(moved[dest], cb)
255255 }
256+ clear(callbacks[len(kept):]) // the filtered tail must not pin callbacks
256257 if len(kept) == 0 {
257258 delete(s.awaiting, name)
258259 } else {
@@ -270,8 +271,10 @@ func (s *Server) relocateCallbacks(root *surface) {
270271
271272 // dueFrames returns the queues to fire now: flipped outputs and those past
272273 // their deadline. wait is the time to the next deadline; idle is set when
273-// no callback waits after firing.
274+// no callback waits after firing. fire is the tick's scratch, valid until
275+// the next call (display goroutine only).
274276 func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []string, wait time.Duration, idle bool) {
277+ fire = s.frameFire[:0]
275278 if s.framePeriods == nil {
276279 s.framePeriods = make(map[string]time.Duration, len(s.outputs)+1)
277280 }
@@ -284,7 +287,9 @@ func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []strin
284287 for name, callbacks := range s.awaiting {
285288 if _, ok := periods[name]; !ok && name != "" && name != suspendedFrameQueue {
286289 delete(s.awaiting, name)
287- s.awaiting[""] = append(s.awaiting[""], callbacks...)
290+ if len(callbacks) > 0 {
291+ s.awaiting[""] = append(s.awaiting[""], callbacks...)
292+ }
288293 }
289294 }
290295 periods[""] = defaultFramePeriod
@@ -330,6 +335,7 @@ func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []strin
330335 wait = 0
331336 }
332337 }
338+ s.frameFire = fire
333339 return fire, max(wait, time.Millisecond), !waiting
334340 }
335341
@@ -341,7 +347,6 @@ func paces(p ports.OutputPresented) bool { return p.Flip != nil && p.Flip.Hardwa
341347 // sendFrames fires an output's callbacks; flipped: a flip of it fired them.
342348 func (s *Server) sendFrames(name string, flipped bool) {
343349 callbacks := s.awaiting[name]
344- delete(s.awaiting, name)
345350 if len(callbacks) == 0 {
346351 return
347352 }
@@ -360,6 +365,10 @@ func (s *Server) sendFrames(name string, flipped bool) {
360365 cb.Destroy()
361366 s.frames++
362367 }
368+ // The queue keeps its backing array for the next frame (an empty queue
369+ // reads like an absent one). Nothing above queues a callback.
370+ clear(callbacks)
371+ s.awaiting[name] = callbacks[:0]
363372 }
364373
365374 // framePeriod is one refresh at refreshMilli mHz, 60 Hz when unknown.
@@ -253,6 +253,7 @@ func (s *Server) relocateCallbacks(root *surface) {
253 dest := s.frameOutput(owner)253 dest := s.frameOutput(owner)
254 moved[dest] = append(moved[dest], cb)254 moved[dest] = append(moved[dest], cb)
255 }255 }
256+ clear(callbacks[len(kept):]) // the filtered tail must not pin callbacks
256 if len(kept) == 0 {257 if len(kept) == 0 {
257 delete(s.awaiting, name)258 delete(s.awaiting, name)
258 } else {259 } else {
@@ -270,8 +271,10 @@ func (s *Server) relocateCallbacks(root *surface) {
270 271
271 // dueFrames returns the queues to fire now: flipped outputs and those past272 // dueFrames returns the queues to fire now: flipped outputs and those past
272 // their deadline. wait is the time to the next deadline; idle is set when273 // their deadline. wait is the time to the next deadline; idle is set when
273-// no callback waits after firing.274+// no callback waits after firing. fire is the tick's scratch, valid until
275+// the next call (display goroutine only).
274 func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []string, wait time.Duration, idle bool) {276 func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []string, wait time.Duration, idle bool) {
277+ fire = s.frameFire[:0]
275 if s.framePeriods == nil {278 if s.framePeriods == nil {
276 s.framePeriods = make(map[string]time.Duration, len(s.outputs)+1)279 s.framePeriods = make(map[string]time.Duration, len(s.outputs)+1)
277 }280 }
@@ -284,7 +287,9 @@ func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []strin
284 for name, callbacks := range s.awaiting {287 for name, callbacks := range s.awaiting {
285 if _, ok := periods[name]; !ok && name != "" && name != suspendedFrameQueue {288 if _, ok := periods[name]; !ok && name != "" && name != suspendedFrameQueue {
286 delete(s.awaiting, name)289 delete(s.awaiting, name)
287- s.awaiting[""] = append(s.awaiting[""], callbacks...)290+ if len(callbacks) > 0 {
291+ s.awaiting[""] = append(s.awaiting[""], callbacks...)
292+ }
288 }293 }
289 }294 }
290 periods[""] = defaultFramePeriod295 periods[""] = defaultFramePeriod
@@ -330,6 +335,7 @@ func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []strin
330 wait = 0335 wait = 0
331 }336 }
332 }337 }
338+ s.frameFire = fire
333 return fire, max(wait, time.Millisecond), !waiting339 return fire, max(wait, time.Millisecond), !waiting
334 }340 }
335 341
@@ -341,7 +347,6 @@ func paces(p ports.OutputPresented) bool { return p.Flip != nil && p.Flip.Hardwa
341 // sendFrames fires an output's callbacks; flipped: a flip of it fired them.347 // sendFrames fires an output's callbacks; flipped: a flip of it fired them.
342 func (s *Server) sendFrames(name string, flipped bool) {348 func (s *Server) sendFrames(name string, flipped bool) {
343 callbacks := s.awaiting[name]349 callbacks := s.awaiting[name]
344- delete(s.awaiting, name)
345 if len(callbacks) == 0 {350 if len(callbacks) == 0 {
346 return351 return
347 }352 }
@@ -360,6 +365,10 @@ func (s *Server) sendFrames(name string, flipped bool) {
360 cb.Destroy()365 cb.Destroy()
361 s.frames++366 s.frames++
362 }367 }
368+ // The queue keeps its backing array for the next frame (an empty queue
369+ // reads like an absent one). Nothing above queues a callback.
370+ clear(callbacks)
371+ s.awaiting[name] = callbacks[:0]
363 }372 }
364 373
365 // framePeriod is one refresh at refreshMilli mHz, 60 Hz when unknown.374 // framePeriod is one refresh at refreshMilli mHz, 60 Hz when unknown.
M internal/adapters/wayland/frames_test.go +24 -0
@@ -144,3 +144,27 @@ func TestCapturedWindowKeepsOutputFramePacing(t *testing.T) {
144144 t.Fatalf("released: queues %+v", s.awaiting)
145145 }
146146 }
147+
148+// Relocation filters a queue in place: the callbacks that move away leave
149+// no pointer in the kept queue's backing array.
150+func TestRelocateCallbacksClearsFilteredTail(t *testing.T) {
151+ out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}}
152+ s := &Server{outputs: []*output{out}, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}}
153+ hidden := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2", Visible: true}}
154+ shown := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2", Visible: true}}
155+ root := &surface{xdg: &xdgSurface{window: hidden}}
156+ other := &surface{xdg: &xdgSurface{window: shown}}
157+ moving, staying := new(wayland.Callback), new(wayland.Callback)
158+ s.queueFrames(root, []*wayland.Callback{moving})
159+ s.queueFrames(other, []*wayland.Callback{staying})
160+ before := s.awaiting["DP-2"]
161+ hidden.last.Visible = false
162+ s.relocateCallbacks(root)
163+ kept := s.awaiting["DP-2"]
164+ if len(kept) != 1 || kept[0] != staying || len(s.awaiting[suspendedFrameQueue]) != 1 {
165+ t.Fatalf("relocation: queues %+v", s.awaiting)
166+ }
167+ if tail := before[:2][1]; tail != nil {
168+ t.Error("the filtered tail still pins the moved callback")
169+ }
170+}
@@ -144,3 +144,27 @@ func TestCapturedWindowKeepsOutputFramePacing(t *testing.T) {
144 t.Fatalf("released: queues %+v", s.awaiting)144 t.Fatalf("released: queues %+v", s.awaiting)
145 }145 }
146 }146 }
147+
148+// Relocation filters a queue in place: the callbacks that move away leave
149+// no pointer in the kept queue's backing array.
150+func TestRelocateCallbacksClearsFilteredTail(t *testing.T) {
151+ out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}}
152+ s := &Server{outputs: []*output{out}, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}}
153+ hidden := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2", Visible: true}}
154+ shown := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2", Visible: true}}
155+ root := &surface{xdg: &xdgSurface{window: hidden}}
156+ other := &surface{xdg: &xdgSurface{window: shown}}
157+ moving, staying := new(wayland.Callback), new(wayland.Callback)
158+ s.queueFrames(root, []*wayland.Callback{moving})
159+ s.queueFrames(other, []*wayland.Callback{staying})
160+ before := s.awaiting["DP-2"]
161+ hidden.last.Visible = false
162+ s.relocateCallbacks(root)
163+ kept := s.awaiting["DP-2"]
164+ if len(kept) != 1 || kept[0] != staying || len(s.awaiting[suspendedFrameQueue]) != 1 {
165+ t.Fatalf("relocation: queues %+v", s.awaiting)
166+ }
167+ if tail := before[:2][1]; tail != nil {
168+ t.Error("the filtered tail still pins the moved callback")
169+ }
170+}
M internal/adapters/wayland/globals.go +16 -4
@@ -7,6 +7,7 @@ import (
77 "github.com/bnema/go-wayland-bindings/server/wayland"
88 "github.com/bnema/neferwl/internal/ports"
99 "github.com/bnema/purego-libwayland/server"
10+ "golang.org/x/sys/unix"
1011 )
1112
1213 func registerGlobals(d *server.Display, o Options, s *Server) error {
@@ -389,6 +390,12 @@ type buffer struct {
389390 pool *pool
390391 offset, width, height, stride int
391392 format uint32
393+ // shm and fd are built on the first content call. The pool, offset and
394+ // stride never change, so every commit sends the same read-only
395+ // SHMBuffer. The pool keeps the file open while the buffer lives, which
396+ // keeps fd valid.
397+ shm *ports.SHMBuffer
398+ fd int
392399 }
393400
394401 func (*buffer) Destroy(*wayland.Buffer) {}
@@ -417,13 +424,18 @@ func (s *Server) addBuffer(r *wayland.Buffer, b clientBuffer) {
417424 }
418425
419426 // content points the renderer at the pool; a file shrunk below the buffer
420-// is refused.
427+// is refused. The size is checked on every call: a client may truncate the
428+// file at any time, so it is never cached.
421429 func (b *buffer) content(id ports.WindowID) (ports.SurfaceContent, bool) {
422- if !fileHolds(b.pool.file, int64(b.offset+(b.height-1)*b.stride+b.width*4)) {
430+ if b.shm == nil {
431+ b.shm = &ports.SHMBuffer{Pool: b.pool.id, File: b.pool.file, Offset: b.offset, Stride: b.stride}
432+ b.fd = int(b.pool.file.Fd())
433+ }
434+ var st unix.Stat_t
435+ if unix.Fstat(b.fd, &st) != nil || st.Size < int64(b.offset+(b.height-1)*b.stride+b.width*4) {
423436 return ports.SurfaceContent{}, false
424437 }
425- shm := &ports.SHMBuffer{Pool: b.pool.id, File: b.pool.file, Offset: b.offset, Stride: b.stride}
426- return ports.SurfaceContent{ID: id, Width: b.width, Height: b.height, Opaque: b.format == uint32(wayland.ShmFormatXrgb8888), SHM: shm}, true
438+ return ports.SurfaceContent{ID: id, Width: b.width, Height: b.height, Opaque: b.format == uint32(wayland.ShmFormatXrgb8888), SHM: b.shm}, true
427439 }
428440
429441 type seat struct{ server *Server }
@@ -7,6 +7,7 @@ import (
7 "github.com/bnema/go-wayland-bindings/server/wayland"7 "github.com/bnema/go-wayland-bindings/server/wayland"
8 "github.com/bnema/neferwl/internal/ports"8 "github.com/bnema/neferwl/internal/ports"
9 "github.com/bnema/purego-libwayland/server"9 "github.com/bnema/purego-libwayland/server"
10+ "golang.org/x/sys/unix"
10 )11 )
11 12
12 func registerGlobals(d *server.Display, o Options, s *Server) error {13 func registerGlobals(d *server.Display, o Options, s *Server) error {
@@ -389,6 +390,12 @@ type buffer struct {
389 pool *pool390 pool *pool
390 offset, width, height, stride int391 offset, width, height, stride int
391 format uint32392 format uint32
393+ // shm and fd are built on the first content call. The pool, offset and
394+ // stride never change, so every commit sends the same read-only
395+ // SHMBuffer. The pool keeps the file open while the buffer lives, which
396+ // keeps fd valid.
397+ shm *ports.SHMBuffer
398+ fd int
392 }399 }
393 400
394 func (*buffer) Destroy(*wayland.Buffer) {}401 func (*buffer) Destroy(*wayland.Buffer) {}
@@ -417,13 +424,18 @@ func (s *Server) addBuffer(r *wayland.Buffer, b clientBuffer) {
417 }424 }
418 425
419 // content points the renderer at the pool; a file shrunk below the buffer426 // content points the renderer at the pool; a file shrunk below the buffer
420-// is refused.427+// is refused. The size is checked on every call: a client may truncate the
428+// file at any time, so it is never cached.
421 func (b *buffer) content(id ports.WindowID) (ports.SurfaceContent, bool) {429 func (b *buffer) content(id ports.WindowID) (ports.SurfaceContent, bool) {
422- if !fileHolds(b.pool.file, int64(b.offset+(b.height-1)*b.stride+b.width*4)) {430+ if b.shm == nil {
431+ b.shm = &ports.SHMBuffer{Pool: b.pool.id, File: b.pool.file, Offset: b.offset, Stride: b.stride}
432+ b.fd = int(b.pool.file.Fd())
433+ }
434+ var st unix.Stat_t
435+ if unix.Fstat(b.fd, &st) != nil || st.Size < int64(b.offset+(b.height-1)*b.stride+b.width*4) {
423 return ports.SurfaceContent{}, false436 return ports.SurfaceContent{}, false
424 }437 }
425- shm := &ports.SHMBuffer{Pool: b.pool.id, File: b.pool.file, Offset: b.offset, Stride: b.stride}438+ return ports.SurfaceContent{ID: id, Width: b.width, Height: b.height, Opaque: b.format == uint32(wayland.ShmFormatXrgb8888), SHM: b.shm}, true
426- return ports.SurfaceContent{ID: id, Width: b.width, Height: b.height, Opaque: b.format == uint32(wayland.ShmFormatXrgb8888), SHM: shm}, true
427 }439 }
428 440
429 type seat struct{ server *Server }441 type seat struct{ server *Server }
M internal/adapters/wayland/server.go +2 -0
@@ -189,6 +189,8 @@ type Server struct {
189189 sink *presentSink
190190 // framePeriods is the tick's scratch (display goroutine only).
191191 framePeriods map[string]time.Duration
192+ // frameFire is dueFrames' result slice, reused across ticks.
193+ frameFire []string
192194 // held buffers wait until no output reads them (release.go); reports
193195 // are the outputs' latest ports.OutputPresented.
194196 held []heldBuffer
@@ -189,6 +189,8 @@ type Server struct {
189 sink *presentSink189 sink *presentSink
190 // framePeriods is the tick's scratch (display goroutine only).190 // framePeriods is the tick's scratch (display goroutine only).
191 framePeriods map[string]time.Duration191 framePeriods map[string]time.Duration
192+ // frameFire is dueFrames' result slice, reused across ticks.
193+ frameFire []string
192 // held buffers wait until no output reads them (release.go); reports194 // held buffers wait until no output reads them (release.go); reports
193 // are the outputs' latest ports.OutputPresented.195 // are the outputs' latest ports.OutputPresented.
194 held []heldBuffer196 held []heldBuffer
M internal/app/app.go +22 -12
@@ -157,24 +157,34 @@ func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configError
157157 }
158158 }
159159 latest(renderScenes, set) // this goroutine is the only sender
160- for _, s := range set {
161- ev := log.Debug().Str("output", s.Output).Uint64("seq", s.Seq).Int("out_w", s.OutputWidth).Int("out_h", s.OutputHeight).Float64("scale", s.Scale)
162- rects := make([]string, 0, len(s.Windows))
163- for _, w := range s.Windows {
164- rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))
165- if w.FocusEffect > 0 {
166- // The focus indicator frame by frame: window and effect.
167- ev = ev.Uint64("focus_effect_window", uint64(w.ID)).Float64("focus_effect", w.FocusEffect)
168- }
169- }
170- ev.Strs("windows", rects).Msg("scene")
171- }
160+ logScenes(log, set)
172161 case err := <-configErrors:
173162 configLog.Warn().Err(err).Msg("config rejected")
174163 }
175164 }
176165 }
177166
167+// logScenes logs each scene at debug level. The rect strings are built only
168+// when debug is on: at the default level a frame costs no allocation here.
169+func logScenes(log zerowrap.Logger, set []ports.Scene) {
170+ for _, s := range set {
171+ ev := log.Debug()
172+ if !ev.Enabled() {
173+ continue
174+ }
175+ ev = ev.Str("output", s.Output).Uint64("seq", s.Seq).Int("out_w", s.OutputWidth).Int("out_h", s.OutputHeight).Float64("scale", s.Scale)
176+ rects := make([]string, 0, len(s.Windows))
177+ for _, w := range s.Windows {
178+ rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))
179+ if w.FocusEffect > 0 {
180+ // The focus indicator frame by frame: window and effect.
181+ ev = ev.Uint64("focus_effect_window", uint64(w.ID)).Float64("focus_effect", w.FocusEffect)
182+ }
183+ }
184+ ev.Strs("windows", rects).Msg("scene")
185+ }
186+}
187+
178188 // openXwayland reserves an X11 display for xwayland-satellite, or returns
179189 // nil when X11 is off or unavailable: the session runs without it.
180190 func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger) *xwayland.Display {
@@ -157,24 +157,34 @@ func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configError
157 }157 }
158 }158 }
159 latest(renderScenes, set) // this goroutine is the only sender159 latest(renderScenes, set) // this goroutine is the only sender
160- for _, s := range set {160+ logScenes(log, set)
161- ev := log.Debug().Str("output", s.Output).Uint64("seq", s.Seq).Int("out_w", s.OutputWidth).Int("out_h", s.OutputHeight).Float64("scale", s.Scale)
162- rects := make([]string, 0, len(s.Windows))
163- for _, w := range s.Windows {
164- rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))
165- if w.FocusEffect > 0 {
166- // The focus indicator frame by frame: window and effect.
167- ev = ev.Uint64("focus_effect_window", uint64(w.ID)).Float64("focus_effect", w.FocusEffect)
168- }
169- }
170- ev.Strs("windows", rects).Msg("scene")
171- }
172 case err := <-configErrors:161 case err := <-configErrors:
173 configLog.Warn().Err(err).Msg("config rejected")162 configLog.Warn().Err(err).Msg("config rejected")
174 }163 }
175 }164 }
176 }165 }
177 166
167+// logScenes logs each scene at debug level. The rect strings are built only
168+// when debug is on: at the default level a frame costs no allocation here.
169+func logScenes(log zerowrap.Logger, set []ports.Scene) {
170+ for _, s := range set {
171+ ev := log.Debug()
172+ if !ev.Enabled() {
173+ continue
174+ }
175+ ev = ev.Str("output", s.Output).Uint64("seq", s.Seq).Int("out_w", s.OutputWidth).Int("out_h", s.OutputHeight).Float64("scale", s.Scale)
176+ rects := make([]string, 0, len(s.Windows))
177+ for _, w := range s.Windows {
178+ rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))
179+ if w.FocusEffect > 0 {
180+ // The focus indicator frame by frame: window and effect.
181+ ev = ev.Uint64("focus_effect_window", uint64(w.ID)).Float64("focus_effect", w.FocusEffect)
182+ }
183+ }
184+ ev.Strs("windows", rects).Msg("scene")
185+ }
186+}
187+
178 // openXwayland reserves an X11 display for xwayland-satellite, or returns188 // openXwayland reserves an X11 display for xwayland-satellite, or returns
179 // nil when X11 is off or unavailable: the session runs without it.189 // nil when X11 is off or unavailable: the session runs without it.
180 func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger) *xwayland.Display {190 func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger) *xwayland.Display {
A internal/app/scenelog_test.go +52 -0
@@ -0,0 +1,52 @@
1+package app
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/bnema/zerowrap"
9+)
10+
11+func logTestScenes() []ports.Scene {
12+ return []ports.Scene{{
13+ Output: "A", Seq: 7, OutputWidth: 1920, OutputHeight: 1080, Scale: 1,
14+ Windows: []ports.SceneWindow{
15+ {ID: 1, Rect: ports.Rect{X: 10, Y: 20, W: 300, H: 200}},
16+ {ID: 2, Rect: ports.Rect{X: 5, Y: 6, W: 7, H: 8}, FocusEffect: 0.5},
17+ },
18+ }, {Output: "B"}}
19+}
20+
21+// With debug off the scene log builds nothing: no rect strings, no event.
22+// Measured before the level check: 3 allocations per run for two scenes.
23+func TestLogScenesAllocations(t *testing.T) {
24+ out := &lockedBuffer{}
25+ log := zerowrap.New(zerowrap.Config{Format: "json", Level: "info", Output: out})
26+ set := logTestScenes()
27+ if got := testing.AllocsPerRun(100, func() { logScenes(log, set) }); got != 0 {
28+ t.Fatalf("logScenes at info level: %g allocations per run, want 0", got)
29+ }
30+ if out.String() != "" {
31+ t.Fatalf("info level logged: %s", out.String())
32+ }
33+}
34+
35+func TestLogScenesDebug(t *testing.T) {
36+ out := &lockedBuffer{}
37+ log := zerowrap.New(zerowrap.Config{Format: "json", Level: "debug", Output: out})
38+ logScenes(log, logTestScenes())
39+ got := out.String()
40+ for _, want := range []string{
41+ `"output":"A"`, `"seq":7`, `"out_w":1920`, `"out_h":1080`, `"scale":1`,
42+ `"windows":["1:10,20 300x200","2:5,6 7x8"]`,
43+ `"focus_effect_window":2`, `"focus_effect":0.5`, `"message":"scene"`,
44+ } {
45+ if !strings.Contains(got, want) {
46+ t.Fatalf("debug log lacks %s: %s", want, got)
47+ }
48+ }
49+ if n := strings.Count(got, `"message":"scene"`); n != 2 {
50+ t.Fatalf("%d scene entries, want 2: %s", n, got)
51+ }
52+}
@@ -0,0 +1,52 @@
1+package app
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/bnema/zerowrap"
9+)
10+
11+func logTestScenes() []ports.Scene {
12+ return []ports.Scene{{
13+ Output: "A", Seq: 7, OutputWidth: 1920, OutputHeight: 1080, Scale: 1,
14+ Windows: []ports.SceneWindow{
15+ {ID: 1, Rect: ports.Rect{X: 10, Y: 20, W: 300, H: 200}},
16+ {ID: 2, Rect: ports.Rect{X: 5, Y: 6, W: 7, H: 8}, FocusEffect: 0.5},
17+ },
18+ }, {Output: "B"}}
19+}
20+
21+// With debug off the scene log builds nothing: no rect strings, no event.
22+// Measured before the level check: 3 allocations per run for two scenes.
23+func TestLogScenesAllocations(t *testing.T) {
24+ out := &lockedBuffer{}
25+ log := zerowrap.New(zerowrap.Config{Format: "json", Level: "info", Output: out})
26+ set := logTestScenes()
27+ if got := testing.AllocsPerRun(100, func() { logScenes(log, set) }); got != 0 {
28+ t.Fatalf("logScenes at info level: %g allocations per run, want 0", got)
29+ }
30+ if out.String() != "" {
31+ t.Fatalf("info level logged: %s", out.String())
32+ }
33+}
34+
35+func TestLogScenesDebug(t *testing.T) {
36+ out := &lockedBuffer{}
37+ log := zerowrap.New(zerowrap.Config{Format: "json", Level: "debug", Output: out})
38+ logScenes(log, logTestScenes())
39+ got := out.String()
40+ for _, want := range []string{
41+ `"output":"A"`, `"seq":7`, `"out_w":1920`, `"out_h":1080`, `"scale":1`,
42+ `"windows":["1:10,20 300x200","2:5,6 7x8"]`,
43+ `"focus_effect_window":2`, `"focus_effect":0.5`, `"message":"scene"`,
44+ } {
45+ if !strings.Contains(got, want) {
46+ t.Fatalf("debug log lacks %s: %s", want, got)
47+ }
48+ }
49+ if n := strings.Count(got, `"message":"scene"`); n != 2 {
50+ t.Fatalf("%d scene entries, want 2: %s", n, got)
51+ }
52+}
M internal/core/monitor.go +8 -0
@@ -3,6 +3,8 @@ package core
33 import (
44 "iter"
55 "slices"
6+
7+ "github.com/bnema/neferwl/internal/ports"
68 )
79
810 // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers
@@ -46,6 +48,12 @@ type Monitor struct {
4648 // one its swipe began on); switchView.off is then from the current
4749 // workspace's place in it.
4850 switchList []*Workspace
51+ // ovNamed, ovList and ovDividers are overviewWorkspaces' and
52+ // overviewRows' scratch (the ordered named rows, the rows and the
53+ // dividers), reused and never handed out past the call.
54+ ovNamed []*Workspace
55+ ovList []*Workspace
56+ ovDividers [2]ports.Separator
4957 // ov holds the overview selection and its Escape snapshot.
5058 ov overviewState
5159 // sw is the column switcher (switcher.go).
@@ -3,6 +3,8 @@ package core
3 import (3 import (
4 "iter"4 "iter"
5 "slices"5 "slices"
6+
7+ "github.com/bnema/neferwl/internal/ports"
6 )8 )
7 9
8 // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers10 // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers
@@ -46,6 +48,12 @@ type Monitor struct {
46 // one its swipe began on); switchView.off is then from the current48 // one its swipe began on); switchView.off is then from the current
47 // workspace's place in it.49 // workspace's place in it.
48 switchList []*Workspace50 switchList []*Workspace
51+ // ovNamed, ovList and ovDividers are overviewWorkspaces' and
52+ // overviewRows' scratch (the ordered named rows, the rows and the
53+ // dividers), reused and never handed out past the call.
54+ ovNamed []*Workspace
55+ ovList []*Workspace
56+ ovDividers [2]ports.Separator
49 // ov holds the overview selection and its Escape snapshot.57 // ov holds the overview selection and its Escape snapshot.
50 ov overviewState58 ov overviewState
51 // sw is the column switcher (switcher.go).59 // sw is the column switcher (switcher.go).
M internal/core/overview.go +80 -33
@@ -326,6 +326,10 @@ func restoreRow(w *Workspace, s rowSnapshot) {
326326 // in configured order within each group. Unattached rows follow the last
327327 // occupied numbered row (or the first). Empty invocation anchors stay visible;
328328 // other empty rows are skipped unless current.
329+//
330+// The result is the monitor's scratch, rebuilt by the next call: callers
331+// read it and drop it before calling again (Clone it to keep it). It keeps
332+// the last result's workspaces reachable until then.
329333 func (m *Monitor) overviewWorkspaces() []*Workspace {
330334 fallback := m.Workspaces[0]
331335 for _, w := range m.Workspaces {
@@ -333,8 +337,6 @@ func (m *Monitor) overviewWorkspaces() []*Workspace {
333337 fallback = w
334338 }
335339 }
336- groups := make(map[*Workspace][]*Workspace)
337- named := slices.Clone(m.hidden)
338340 rank := func(w *Workspace) int {
339341 for i, spec := range m.named {
340342 if spec.Name == w.Name {
@@ -343,24 +345,35 @@ func (m *Monitor) overviewWorkspaces() []*Workspace {
343345 }
344346 return len(m.named)
345347 }
348+ cur := m.Current()
349+ named := append(m.ovNamed[:0], m.hidden...)
350+ named = slices.DeleteFunc(named, func(n *Workspace) bool { return n.empty() && n != cur })
351+ m.ovNamed = named
346352 slices.SortStableFunc(named, func(a, b *Workspace) int { return rank(a) - rank(b) })
347- for _, w := range named {
348- if w.empty() && w != m.Current() {
349- continue
350- }
351- anchor := w.overviewAfter
352- if indexOf(m.Workspaces, anchor) < 0 {
353- anchor = fallback
354- }
355- groups[anchor] = append(groups[anchor], w)
356- }
357- var rows []*Workspace
353+ rows := m.ovList[:0]
358354 for _, w := range m.Workspaces {
359- if !w.empty() || w == m.Current() || len(groups[w]) > 0 {
360- rows = append(rows, w)
361- rows = append(rows, groups[w]...)
355+ // The row is kept when occupied, current or anchoring a named
356+ // row: placed first, dropped again if none of those holds.
357+ at := len(rows)
358+ rows = append(rows, w)
359+ for _, n := range named {
360+ anchor := n.overviewAfter
361+ if indexOf(m.Workspaces, anchor) < 0 {
362+ anchor = fallback
363+ }
364+ if anchor == w {
365+ rows = append(rows, n)
366+ }
367+ }
368+ if len(rows) == at+1 && w.empty() && w != cur {
369+ rows = rows[:at]
362370 }
363371 }
372+ // named is only needed here; the rows' tail is cleared so the scratch
373+ // holds no more than the last result.
374+ clear(named[:cap(named)])
375+ clear(rows[len(rows):cap(rows)])
376+ m.ovList = rows
364377 return rows
365378 }
366379
@@ -674,16 +687,40 @@ func (m *Monitor) OverviewPick(id WindowID) {
674687 // workspace's layout.
675688 func (m *Monitor) overviewAxis() layoutAxis { return m.Current().policy().workspace }
676689
690+// overviewSlots are the slots of the overview rows: the current workspace
691+// and at most one neighbor on each side, in a fixed array (no allocation).
692+type overviewSlots struct {
693+ n int
694+ w [3]*Workspace
695+ r [3]Rect
696+}
697+
698+func (s *overviewSlots) add(w *Workspace, r Rect) {
699+ s.w[s.n], s.r[s.n] = w, r
700+ s.n++
701+}
702+
703+// lookup is the slot of w, if it has one.
704+func (s *overviewSlots) lookup(w *Workspace) (Rect, bool) {
705+ for i := range s.n {
706+ if s.w[i] == w {
707+ return s.r[i], true
708+ }
709+ }
710+ return Rect{}, false
711+}
712+
677713 // overviewRows places the current workspace and its overview neighbors in
678714 // slots, in output-local pixels, along the overview axis; each workspace
679715 // places its previews in its slot. The numbered/named boundary gets extra
680-// spacing and an inactive rule across the axis.
716+// spacing and an inactive rule across the axis. The dividers are the
717+// monitor's scratch, rewritten by the next call: callers copy them.
681718 //
682719 // Vertical axis: full-width slots, the current one centred; the rule is
683720 // horizontal. Horizontal axis: full-height slots as wide as their content,
684721 // stack peeks included (the stash pile takes a band of the slot), the current one
685722 // centred; the rule is vertical, in the spacing between two slots.
686-func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {
723+func (m *Monitor) overviewRows() (overviewSlots, []ports.Separator) {
687724 cur := m.Current()
688725 u := cur.overviewArea()
689726 gap := max(1, u.H*3/100)
@@ -696,11 +733,12 @@ func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {
696733 hc := cur.rowHeight(axis)
697734 cs = Rect{X: u.X, Y: u.Y + (u.H-hc)/2, W: u.W, H: hc}
698735 }
699- rows := map[*Workspace]Rect{cur: cs}
700- var dividers []ports.Separator
736+ var rows overviewSlots
737+ rows.add(cur, cs)
738+ dividers := 0
701739 list := m.overviewWorkspaces()
702740 i := indexOf(list, cur)
703- for _, next := range []int{i - 1, i + 1} {
741+ for _, next := range [2]int{i - 1, i + 1} {
704742 if next < 0 || next >= len(list) {
705743 continue
706744 }
@@ -711,12 +749,16 @@ func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {
711749 spacing *= 2
712750 }
713751 slot, gapAt := neighborSlot(axis, u, cs, w, next < i, spacing)
714- rows[w] = slot
752+ rows.add(w, slot)
715753 if boundary {
716- dividers = append(dividers, overviewDivider(axis, u, gapAt+spacing/2))
754+ m.ovDividers[dividers] = overviewDivider(axis, u, gapAt+spacing/2)
755+ dividers++
717756 }
718757 }
719- return rows, dividers
758+ if dividers == 0 {
759+ return rows, nil
760+ }
761+ return rows, m.ovDividers[:dividers]
720762 }
721763
722764 // neighborSlot is the slot of w, the neighbor before or after the current
@@ -767,7 +809,7 @@ func (m *Monitor) overviewLayout(result []Placement) []Placement {
767809 var unfolded *Workspace
768810 var unfoldedSlot Rect
769811 for w := range m.all() {
770- slot, shown := rows[w]
812+ slot, shown := rows.lookup(w)
771813 if shown {
772814 // The pile band goes under the row: the row is centred
773815 // with it.
@@ -1274,12 +1316,17 @@ func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes
12741316 return tiles
12751317 }
12761318
1319+// outlineLines is the lines of an uncut outline: one per edge.
1320+const outlineLines = 4
1321+
12771322 // overviewOutline frames the selected preview with active lines of width
12781323 // b, just outside it. The selection is one preview: a selected stash card
12791324 // wins over any other focused one, which must then not be framed. A tile's
12801325 // frame is cut where the cards of the pile sit over it, so it never runs
1281-// across the pile; a selected card keeps its complete frame.
1282-func overviewOutline(layout []Placement, b int) []ports.Separator {
1326+// across the pile; a selected card keeps its complete frame. The lines are
1327+// appended to dst, which the caller sizes for them (room for outlineLines
1328+// past its length) so the scene's separators are allocated once.
1329+func overviewOutline(dst []ports.Separator, layout []Placement, b int) []ports.Separator {
12831330 sel := -1
12841331 for i, p := range layout {
12851332 if p.Preview > 0 && p.Focused && !p.Hidden && (sel < 0 || p.Card && !layout[sel].Card) {
@@ -1287,8 +1334,9 @@ func overviewOutline(layout []Placement, b int) []ports.Separator {
12871334 }
12881335 }
12891336 if sel < 0 {
1290- return nil
1337+ return dst
12911338 }
1339+ dst = slices.Grow(dst, outlineLines)
12921340 r := layout[sel].Rect
12931341 edges := [4]Rect{
12941342 {X: r.X - b, Y: r.Y - b, W: r.W + 2*b, H: b},
@@ -1296,7 +1344,6 @@ func overviewOutline(layout []Placement, b int) []ports.Separator {
12961344 {X: r.X - b, Y: r.Y, W: b, H: r.H},
12971345 {X: r.X + r.W, Y: r.Y, W: b, H: r.H},
12981346 }
1299- out := make([]ports.Separator, 0, len(edges))
13001347 cut := false
13011348 if !layout[sel].Card {
13021349 for _, p := range layout {
@@ -1308,9 +1355,9 @@ func overviewOutline(layout []Placement, b int) []ports.Separator {
13081355 }
13091356 if !cut {
13101357 for _, e := range edges {
1311- out = append(out, ports.Separator{Rect: e, Active: true})
1358+ dst = append(dst, ports.Separator{Rect: e, Active: true})
13121359 }
1313- return out
1360+ return dst
13141361 }
13151362 // Stack buffers: the outline is rebuilt every overview frame.
13161363 var cardBuf [overviewCards]Rect
@@ -1323,10 +1370,10 @@ func overviewOutline(layout []Placement, b int) []ports.Separator {
13231370 }
13241371 for _, e := range edges {
13251372 for _, piece := range subtractRects(pieceBuf[:0], e, cards) {
1326- out = append(out, ports.Separator{Rect: piece, Active: true})
1373+ dst = append(dst, ports.Separator{Rect: piece, Active: true})
13271374 }
13281375 }
1329- return out
1376+ return dst
13301377 }
13311378
13321379 // subtractRects is what of r no rect of cuts covers, as rects. The edges of
@@ -326,6 +326,10 @@ func restoreRow(w *Workspace, s rowSnapshot) {
326 // in configured order within each group. Unattached rows follow the last326 // in configured order within each group. Unattached rows follow the last
327 // occupied numbered row (or the first). Empty invocation anchors stay visible;327 // occupied numbered row (or the first). Empty invocation anchors stay visible;
328 // other empty rows are skipped unless current.328 // other empty rows are skipped unless current.
329+//
330+// The result is the monitor's scratch, rebuilt by the next call: callers
331+// read it and drop it before calling again (Clone it to keep it). It keeps
332+// the last result's workspaces reachable until then.
329 func (m *Monitor) overviewWorkspaces() []*Workspace {333 func (m *Monitor) overviewWorkspaces() []*Workspace {
330 fallback := m.Workspaces[0]334 fallback := m.Workspaces[0]
331 for _, w := range m.Workspaces {335 for _, w := range m.Workspaces {
@@ -333,8 +337,6 @@ func (m *Monitor) overviewWorkspaces() []*Workspace {
333 fallback = w337 fallback = w
334 }338 }
335 }339 }
336- groups := make(map[*Workspace][]*Workspace)
337- named := slices.Clone(m.hidden)
338 rank := func(w *Workspace) int {340 rank := func(w *Workspace) int {
339 for i, spec := range m.named {341 for i, spec := range m.named {
340 if spec.Name == w.Name {342 if spec.Name == w.Name {
@@ -343,24 +345,35 @@ func (m *Monitor) overviewWorkspaces() []*Workspace {
343 }345 }
344 return len(m.named)346 return len(m.named)
345 }347 }
348+ cur := m.Current()
349+ named := append(m.ovNamed[:0], m.hidden...)
350+ named = slices.DeleteFunc(named, func(n *Workspace) bool { return n.empty() && n != cur })
351+ m.ovNamed = named
346 slices.SortStableFunc(named, func(a, b *Workspace) int { return rank(a) - rank(b) })352 slices.SortStableFunc(named, func(a, b *Workspace) int { return rank(a) - rank(b) })
347- for _, w := range named {353+ rows := m.ovList[:0]
348- if w.empty() && w != m.Current() {
349- continue
350- }
351- anchor := w.overviewAfter
352- if indexOf(m.Workspaces, anchor) < 0 {
353- anchor = fallback
354- }
355- groups[anchor] = append(groups[anchor], w)
356- }
357- var rows []*Workspace
358 for _, w := range m.Workspaces {354 for _, w := range m.Workspaces {
359- if !w.empty() || w == m.Current() || len(groups[w]) > 0 {355+ // The row is kept when occupied, current or anchoring a named
360- rows = append(rows, w)356+ // row: placed first, dropped again if none of those holds.
361- rows = append(rows, groups[w]...)357+ at := len(rows)
358+ rows = append(rows, w)
359+ for _, n := range named {
360+ anchor := n.overviewAfter
361+ if indexOf(m.Workspaces, anchor) < 0 {
362+ anchor = fallback
363+ }
364+ if anchor == w {
365+ rows = append(rows, n)
366+ }
367+ }
368+ if len(rows) == at+1 && w.empty() && w != cur {
369+ rows = rows[:at]
362 }370 }
363 }371 }
372+ // named is only needed here; the rows' tail is cleared so the scratch
373+ // holds no more than the last result.
374+ clear(named[:cap(named)])
375+ clear(rows[len(rows):cap(rows)])
376+ m.ovList = rows
364 return rows377 return rows
365 }378 }
366 379
@@ -674,16 +687,40 @@ func (m *Monitor) OverviewPick(id WindowID) {
674 // workspace's layout.687 // workspace's layout.
675 func (m *Monitor) overviewAxis() layoutAxis { return m.Current().policy().workspace }688 func (m *Monitor) overviewAxis() layoutAxis { return m.Current().policy().workspace }
676 689
690+// overviewSlots are the slots of the overview rows: the current workspace
691+// and at most one neighbor on each side, in a fixed array (no allocation).
692+type overviewSlots struct {
693+ n int
694+ w [3]*Workspace
695+ r [3]Rect
696+}
697+
698+func (s *overviewSlots) add(w *Workspace, r Rect) {
699+ s.w[s.n], s.r[s.n] = w, r
700+ s.n++
701+}
702+
703+// lookup is the slot of w, if it has one.
704+func (s *overviewSlots) lookup(w *Workspace) (Rect, bool) {
705+ for i := range s.n {
706+ if s.w[i] == w {
707+ return s.r[i], true
708+ }
709+ }
710+ return Rect{}, false
711+}
712+
677 // overviewRows places the current workspace and its overview neighbors in713 // overviewRows places the current workspace and its overview neighbors in
678 // slots, in output-local pixels, along the overview axis; each workspace714 // slots, in output-local pixels, along the overview axis; each workspace
679 // places its previews in its slot. The numbered/named boundary gets extra715 // places its previews in its slot. The numbered/named boundary gets extra
680-// spacing and an inactive rule across the axis.716+// spacing and an inactive rule across the axis. The dividers are the
717+// monitor's scratch, rewritten by the next call: callers copy them.
681 //718 //
682 // Vertical axis: full-width slots, the current one centred; the rule is719 // Vertical axis: full-width slots, the current one centred; the rule is
683 // horizontal. Horizontal axis: full-height slots as wide as their content,720 // horizontal. Horizontal axis: full-height slots as wide as their content,
684 // stack peeks included (the stash pile takes a band of the slot), the current one721 // stack peeks included (the stash pile takes a band of the slot), the current one
685 // centred; the rule is vertical, in the spacing between two slots.722 // centred; the rule is vertical, in the spacing between two slots.
686-func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {723+func (m *Monitor) overviewRows() (overviewSlots, []ports.Separator) {
687 cur := m.Current()724 cur := m.Current()
688 u := cur.overviewArea()725 u := cur.overviewArea()
689 gap := max(1, u.H*3/100)726 gap := max(1, u.H*3/100)
@@ -696,11 +733,12 @@ func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {
696 hc := cur.rowHeight(axis)733 hc := cur.rowHeight(axis)
697 cs = Rect{X: u.X, Y: u.Y + (u.H-hc)/2, W: u.W, H: hc}734 cs = Rect{X: u.X, Y: u.Y + (u.H-hc)/2, W: u.W, H: hc}
698 }735 }
699- rows := map[*Workspace]Rect{cur: cs}736+ var rows overviewSlots
700- var dividers []ports.Separator737+ rows.add(cur, cs)
738+ dividers := 0
701 list := m.overviewWorkspaces()739 list := m.overviewWorkspaces()
702 i := indexOf(list, cur)740 i := indexOf(list, cur)
703- for _, next := range []int{i - 1, i + 1} {741+ for _, next := range [2]int{i - 1, i + 1} {
704 if next < 0 || next >= len(list) {742 if next < 0 || next >= len(list) {
705 continue743 continue
706 }744 }
@@ -711,12 +749,16 @@ func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {
711 spacing *= 2749 spacing *= 2
712 }750 }
713 slot, gapAt := neighborSlot(axis, u, cs, w, next < i, spacing)751 slot, gapAt := neighborSlot(axis, u, cs, w, next < i, spacing)
714- rows[w] = slot752+ rows.add(w, slot)
715 if boundary {753 if boundary {
716- dividers = append(dividers, overviewDivider(axis, u, gapAt+spacing/2))754+ m.ovDividers[dividers] = overviewDivider(axis, u, gapAt+spacing/2)
755+ dividers++
717 }756 }
718 }757 }
719- return rows, dividers758+ if dividers == 0 {
759+ return rows, nil
760+ }
761+ return rows, m.ovDividers[:dividers]
720 }762 }
721 763
722 // neighborSlot is the slot of w, the neighbor before or after the current764 // neighborSlot is the slot of w, the neighbor before or after the current
@@ -767,7 +809,7 @@ func (m *Monitor) overviewLayout(result []Placement) []Placement {
767 var unfolded *Workspace809 var unfolded *Workspace
768 var unfoldedSlot Rect810 var unfoldedSlot Rect
769 for w := range m.all() {811 for w := range m.all() {
770- slot, shown := rows[w]812+ slot, shown := rows.lookup(w)
771 if shown {813 if shown {
772 // The pile band goes under the row: the row is centred814 // The pile band goes under the row: the row is centred
773 // with it.815 // with it.
@@ -1274,12 +1316,17 @@ func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes
1274 return tiles1316 return tiles
1275 }1317 }
1276 1318
1319+// outlineLines is the lines of an uncut outline: one per edge.
1320+const outlineLines = 4
1321+
1277 // overviewOutline frames the selected preview with active lines of width1322 // overviewOutline frames the selected preview with active lines of width
1278 // b, just outside it. The selection is one preview: a selected stash card1323 // b, just outside it. The selection is one preview: a selected stash card
1279 // wins over any other focused one, which must then not be framed. A tile's1324 // wins over any other focused one, which must then not be framed. A tile's
1280 // frame is cut where the cards of the pile sit over it, so it never runs1325 // frame is cut where the cards of the pile sit over it, so it never runs
1281-// across the pile; a selected card keeps its complete frame.1326+// across the pile; a selected card keeps its complete frame. The lines are
1282-func overviewOutline(layout []Placement, b int) []ports.Separator {1327+// appended to dst, which the caller sizes for them (room for outlineLines
1328+// past its length) so the scene's separators are allocated once.
1329+func overviewOutline(dst []ports.Separator, layout []Placement, b int) []ports.Separator {
1283 sel := -11330 sel := -1
1284 for i, p := range layout {1331 for i, p := range layout {
1285 if p.Preview > 0 && p.Focused && !p.Hidden && (sel < 0 || p.Card && !layout[sel].Card) {1332 if p.Preview > 0 && p.Focused && !p.Hidden && (sel < 0 || p.Card && !layout[sel].Card) {
@@ -1287,8 +1334,9 @@ func overviewOutline(layout []Placement, b int) []ports.Separator {
1287 }1334 }
1288 }1335 }
1289 if sel < 0 {1336 if sel < 0 {
1290- return nil1337+ return dst
1291 }1338 }
1339+ dst = slices.Grow(dst, outlineLines)
1292 r := layout[sel].Rect1340 r := layout[sel].Rect
1293 edges := [4]Rect{1341 edges := [4]Rect{
1294 {X: r.X - b, Y: r.Y - b, W: r.W + 2*b, H: b},1342 {X: r.X - b, Y: r.Y - b, W: r.W + 2*b, H: b},
@@ -1296,7 +1344,6 @@ func overviewOutline(layout []Placement, b int) []ports.Separator {
1296 {X: r.X - b, Y: r.Y, W: b, H: r.H},1344 {X: r.X - b, Y: r.Y, W: b, H: r.H},
1297 {X: r.X + r.W, Y: r.Y, W: b, H: r.H},1345 {X: r.X + r.W, Y: r.Y, W: b, H: r.H},
1298 }1346 }
1299- out := make([]ports.Separator, 0, len(edges))
1300 cut := false1347 cut := false
1301 if !layout[sel].Card {1348 if !layout[sel].Card {
1302 for _, p := range layout {1349 for _, p := range layout {
@@ -1308,9 +1355,9 @@ func overviewOutline(layout []Placement, b int) []ports.Separator {
1308 }1355 }
1309 if !cut {1356 if !cut {
1310 for _, e := range edges {1357 for _, e := range edges {
1311- out = append(out, ports.Separator{Rect: e, Active: true})1358+ dst = append(dst, ports.Separator{Rect: e, Active: true})
1312 }1359 }
1313- return out1360+ return dst
1314 }1361 }
1315 // Stack buffers: the outline is rebuilt every overview frame.1362 // Stack buffers: the outline is rebuilt every overview frame.
1316 var cardBuf [overviewCards]Rect1363 var cardBuf [overviewCards]Rect
@@ -1323,10 +1370,10 @@ func overviewOutline(layout []Placement, b int) []ports.Separator {
1323 }1370 }
1324 for _, e := range edges {1371 for _, e := range edges {
1325 for _, piece := range subtractRects(pieceBuf[:0], e, cards) {1372 for _, piece := range subtractRects(pieceBuf[:0], e, cards) {
1326- out = append(out, ports.Separator{Rect: piece, Active: true})1373+ dst = append(dst, ports.Separator{Rect: piece, Active: true})
1327 }1374 }
1328 }1375 }
1329- return out1376+ return dst
1330 }1377 }
1331 1378
1332 // subtractRects is what of r no rect of cuts covers, as rects. The edges of1379 // subtractRects is what of r no rect of cuts covers, as rects. The edges of
M internal/core/overview_cascade_test.go +15 -15
@@ -147,7 +147,7 @@ func TestOverviewCascadeNeighbors(t *testing.T) {
147147 m := cascadeOverview()
148148 m.ToggleOverview()
149149 rows, _ := m.overviewRows()
150- cur, left, right := rows[m.Workspaces[1]], rows[m.Workspaces[0]], rows[m.Workspaces[2]]
150+ cur, left, right := rows.at(m.Workspaces[1]), rows.at(m.Workspaces[0]), rows.at(m.Workspaces[2])
151151 if left.X+left.W > cur.X || right.X < cur.X+cur.W {
152152 t.Fatalf("slots overlap: %v %v %v", left, cur, right)
153153 }
@@ -227,9 +227,9 @@ func TestOverviewCascadeStashInSlot(t *testing.T) {
227227 m.ToggleOverview()
228228 rows, _ := m.overviewRows()
229229 w := m.Current()
230- cur, prev := rows[w], rows[m.Workspaces[0]]
231- if cur != bare[w] {
232- t.Fatalf("the pile changed the slot: %v, without it %v", cur, bare[w])
230+ cur, prev := rows.at(w), rows.at(m.Workspaces[0])
231+ if cur != bare.at(w) {
232+ t.Fatalf("the pile changed the slot: %v, without it %v", cur, bare.at(w))
233233 }
234234 ps := m.Layout()
235235 lane := previewOf(t, ps, 8).Rect
@@ -566,7 +566,7 @@ func TestOverviewCascadeNamedDivider(t *testing.T) {
566566 if len(dividers) != 1 {
567567 t.Fatalf("dividers: %+v", dividers)
568568 }
569- d, cur, dev := dividers[0].Rect, rows[m.Workspaces[1]], rows[m.byName("dev")]
569+ d, cur, dev := dividers[0].Rect, rows.at(m.Workspaces[1]), rows.at(m.byName("dev"))
570570 if d.W != 1 || d.H <= 1 || dividers[0].Active {
571571 t.Fatalf("divider is not an inactive vertical rule: %+v", dividers[0])
572572 }
@@ -638,8 +638,8 @@ func TestOverviewCascadeNeighborStashKeepsSlot(t *testing.T) {
638638 m.Focus(1)
639639 m.ToggleOverview()
640640 rows, _ := m.overviewRows()
641- if rows[prev] != bare[prev] || prev.overviewZoom(horizontalAxis) != zoom {
642- t.Fatalf("neighbor slot %v zoom %v with a stash, %v %v without", rows[prev], prev.overviewZoom(horizontalAxis), bare[prev], zoom)
641+ if rows.at(prev) != bare.at(prev) || prev.overviewZoom(horizontalAxis) != zoom {
642+ t.Fatalf("neighbor slot %v zoom %v with a stash, %v %v without", rows.at(prev), prev.overviewZoom(horizontalAxis), bare.at(prev), zoom)
643643 }
644644 if p := previewOf(t, m.Layout(), 64); p.Hidden || !p.Peek {
645645 t.Fatalf("neighbor pile card: %+v", p)
@@ -813,7 +813,7 @@ func TestOverviewMixedLayoutGestureAndCancel(t *testing.T) {
813813 from := m.Current()
814814 m.ToggleOverview()
815815 rows, _ := m.overviewRows()
816- if rows[m.Workspaces[1]].X <= rows[from].X {
816+ if rows.at(m.Workspaces[1]).X <= rows.at(from).X {
817817 t.Fatal("cascade neighbor not horizontal")
818818 }
819819 m.overviewScroll(ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: overviewScrollStep}})
@@ -994,7 +994,7 @@ func TestOverviewCascadeWideScrollNeighborIsClipped(t *testing.T) {
994994 }
995995 m.ToggleOverview()
996996 rows, _ := m.overviewRows()
997- cur, slot := rows[m.Current()], rows[prev]
997+ cur, slot := rows.at(m.Current()), rows.at(prev)
998998 hidden, shown := 0, 0
999999 for _, p := range m.Layout() {
10001000 if !prev.has(p.ID) {
@@ -1053,7 +1053,7 @@ func TestOverviewCascadeScroll(t *testing.T) {
10531053
10541054 // outlineHits reports whether any outline line of ps covers a point of r.
10551055 func outlineHits(ps []Placement, r Rect) bool {
1056- for _, s := range overviewOutline(ps, 2) {
1056+ for _, s := range overviewOutline(nil, ps, 2) {
10571057 if s.Rect.Overlaps(r) {
10581058 return true
10591059 }
@@ -1125,7 +1125,7 @@ func TestOverviewCascadeOutlineAndPile(t *testing.T) {
11251125 if framed(ps) != 1 || !previewOf(t, ps, 8).Focused {
11261126 t.Fatalf("lane selected: %d framed", framed(ps))
11271127 }
1128- if len(overviewOutline(ps, 2)) == 0 {
1128+ if len(overviewOutline(nil, ps, 2)) == 0 {
11291129 t.Fatal("lane selected: no frame")
11301130 }
11311131 for _, id := range []WindowID{40, 41, 42} {
@@ -1154,7 +1154,7 @@ func TestOverviewCascadeOutlineAndPile(t *testing.T) {
11541154 if !card.Focused || !card.Card {
11551155 t.Fatalf("selected card not framed: %+v", card)
11561156 }
1157- out := overviewOutline(ps, 2)
1157+ out := overviewOutline(nil, ps, 2)
11581158 if len(out) != 4 {
11591159 t.Fatalf("card frame is %d lines, want 4: %+v", len(out), out)
11601160 }
@@ -1188,7 +1188,7 @@ func TestOverviewOutlinePrefersCard(t *testing.T) {
11881188 {ID: 1, Rect: Rect{X: 0, Y: 0, W: 100, H: 100}, Preview: 0.5, Focused: true},
11891189 {ID: 2, Rect: Rect{X: 40, Y: 80, W: 30, H: 30}, Preview: 0.2, Focused: true, Card: true},
11901190 }
1191- out := overviewOutline(ps, 2)
1191+ out := overviewOutline(nil, ps, 2)
11921192 if len(out) != 4 || out[0].Rect != (Rect{X: 38, Y: 78, W: 34, H: 2}) {
11931193 t.Fatalf("outline: %+v", out)
11941194 }
@@ -1206,10 +1206,10 @@ func TestOverviewOutlinePileAllocations(t *testing.T) {
12061206 for _, p := range ps {
12071207 card = card || p.Card && p.Preview > 0 && !p.Hidden
12081208 }
1209- if !card || len(overviewOutline(ps, 2)) == 0 {
1209+ if !card || len(overviewOutline(nil, ps, 2)) == 0 {
12101210 t.Fatal("setup: no pile under a framed lane tile")
12111211 }
1212- if n := testing.AllocsPerRun(50, func() { _ = overviewOutline(ps, 2) }); n > 2 {
1212+ if n := testing.AllocsPerRun(50, func() { _ = overviewOutline(nil, ps, 2) }); n > 2 {
12131213 t.Fatalf("overviewOutline allocs = %v, want <= 2", n)
12141214 }
12151215 }
@@ -147,7 +147,7 @@ func TestOverviewCascadeNeighbors(t *testing.T) {
147 m := cascadeOverview()147 m := cascadeOverview()
148 m.ToggleOverview()148 m.ToggleOverview()
149 rows, _ := m.overviewRows()149 rows, _ := m.overviewRows()
150- cur, left, right := rows[m.Workspaces[1]], rows[m.Workspaces[0]], rows[m.Workspaces[2]]150+ cur, left, right := rows.at(m.Workspaces[1]), rows.at(m.Workspaces[0]), rows.at(m.Workspaces[2])
151 if left.X+left.W > cur.X || right.X < cur.X+cur.W {151 if left.X+left.W > cur.X || right.X < cur.X+cur.W {
152 t.Fatalf("slots overlap: %v %v %v", left, cur, right)152 t.Fatalf("slots overlap: %v %v %v", left, cur, right)
153 }153 }
@@ -227,9 +227,9 @@ func TestOverviewCascadeStashInSlot(t *testing.T) {
227 m.ToggleOverview()227 m.ToggleOverview()
228 rows, _ := m.overviewRows()228 rows, _ := m.overviewRows()
229 w := m.Current()229 w := m.Current()
230- cur, prev := rows[w], rows[m.Workspaces[0]]230+ cur, prev := rows.at(w), rows.at(m.Workspaces[0])
231- if cur != bare[w] {231+ if cur != bare.at(w) {
232- t.Fatalf("the pile changed the slot: %v, without it %v", cur, bare[w])232+ t.Fatalf("the pile changed the slot: %v, without it %v", cur, bare.at(w))
233 }233 }
234 ps := m.Layout()234 ps := m.Layout()
235 lane := previewOf(t, ps, 8).Rect235 lane := previewOf(t, ps, 8).Rect
@@ -566,7 +566,7 @@ func TestOverviewCascadeNamedDivider(t *testing.T) {
566 if len(dividers) != 1 {566 if len(dividers) != 1 {
567 t.Fatalf("dividers: %+v", dividers)567 t.Fatalf("dividers: %+v", dividers)
568 }568 }
569- d, cur, dev := dividers[0].Rect, rows[m.Workspaces[1]], rows[m.byName("dev")]569+ d, cur, dev := dividers[0].Rect, rows.at(m.Workspaces[1]), rows.at(m.byName("dev"))
570 if d.W != 1 || d.H <= 1 || dividers[0].Active {570 if d.W != 1 || d.H <= 1 || dividers[0].Active {
571 t.Fatalf("divider is not an inactive vertical rule: %+v", dividers[0])571 t.Fatalf("divider is not an inactive vertical rule: %+v", dividers[0])
572 }572 }
@@ -638,8 +638,8 @@ func TestOverviewCascadeNeighborStashKeepsSlot(t *testing.T) {
638 m.Focus(1)638 m.Focus(1)
639 m.ToggleOverview()639 m.ToggleOverview()
640 rows, _ := m.overviewRows()640 rows, _ := m.overviewRows()
641- if rows[prev] != bare[prev] || prev.overviewZoom(horizontalAxis) != zoom {641+ if rows.at(prev) != bare.at(prev) || prev.overviewZoom(horizontalAxis) != zoom {
642- t.Fatalf("neighbor slot %v zoom %v with a stash, %v %v without", rows[prev], prev.overviewZoom(horizontalAxis), bare[prev], zoom)642+ t.Fatalf("neighbor slot %v zoom %v with a stash, %v %v without", rows.at(prev), prev.overviewZoom(horizontalAxis), bare.at(prev), zoom)
643 }643 }
644 if p := previewOf(t, m.Layout(), 64); p.Hidden || !p.Peek {644 if p := previewOf(t, m.Layout(), 64); p.Hidden || !p.Peek {
645 t.Fatalf("neighbor pile card: %+v", p)645 t.Fatalf("neighbor pile card: %+v", p)
@@ -813,7 +813,7 @@ func TestOverviewMixedLayoutGestureAndCancel(t *testing.T) {
813 from := m.Current()813 from := m.Current()
814 m.ToggleOverview()814 m.ToggleOverview()
815 rows, _ := m.overviewRows()815 rows, _ := m.overviewRows()
816- if rows[m.Workspaces[1]].X <= rows[from].X {816+ if rows.at(m.Workspaces[1]).X <= rows.at(from).X {
817 t.Fatal("cascade neighbor not horizontal")817 t.Fatal("cascade neighbor not horizontal")
818 }818 }
819 m.overviewScroll(ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: overviewScrollStep}})819 m.overviewScroll(ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: overviewScrollStep}})
@@ -994,7 +994,7 @@ func TestOverviewCascadeWideScrollNeighborIsClipped(t *testing.T) {
994 }994 }
995 m.ToggleOverview()995 m.ToggleOverview()
996 rows, _ := m.overviewRows()996 rows, _ := m.overviewRows()
997- cur, slot := rows[m.Current()], rows[prev]997+ cur, slot := rows.at(m.Current()), rows.at(prev)
998 hidden, shown := 0, 0998 hidden, shown := 0, 0
999 for _, p := range m.Layout() {999 for _, p := range m.Layout() {
1000 if !prev.has(p.ID) {1000 if !prev.has(p.ID) {
@@ -1053,7 +1053,7 @@ func TestOverviewCascadeScroll(t *testing.T) {
1053 1053
1054 // outlineHits reports whether any outline line of ps covers a point of r.1054 // outlineHits reports whether any outline line of ps covers a point of r.
1055 func outlineHits(ps []Placement, r Rect) bool {1055 func outlineHits(ps []Placement, r Rect) bool {
1056- for _, s := range overviewOutline(ps, 2) {1056+ for _, s := range overviewOutline(nil, ps, 2) {
1057 if s.Rect.Overlaps(r) {1057 if s.Rect.Overlaps(r) {
1058 return true1058 return true
1059 }1059 }
@@ -1125,7 +1125,7 @@ func TestOverviewCascadeOutlineAndPile(t *testing.T) {
1125 if framed(ps) != 1 || !previewOf(t, ps, 8).Focused {1125 if framed(ps) != 1 || !previewOf(t, ps, 8).Focused {
1126 t.Fatalf("lane selected: %d framed", framed(ps))1126 t.Fatalf("lane selected: %d framed", framed(ps))
1127 }1127 }
1128- if len(overviewOutline(ps, 2)) == 0 {1128+ if len(overviewOutline(nil, ps, 2)) == 0 {
1129 t.Fatal("lane selected: no frame")1129 t.Fatal("lane selected: no frame")
1130 }1130 }
1131 for _, id := range []WindowID{40, 41, 42} {1131 for _, id := range []WindowID{40, 41, 42} {
@@ -1154,7 +1154,7 @@ func TestOverviewCascadeOutlineAndPile(t *testing.T) {
1154 if !card.Focused || !card.Card {1154 if !card.Focused || !card.Card {
1155 t.Fatalf("selected card not framed: %+v", card)1155 t.Fatalf("selected card not framed: %+v", card)
1156 }1156 }
1157- out := overviewOutline(ps, 2)1157+ out := overviewOutline(nil, ps, 2)
1158 if len(out) != 4 {1158 if len(out) != 4 {
1159 t.Fatalf("card frame is %d lines, want 4: %+v", len(out), out)1159 t.Fatalf("card frame is %d lines, want 4: %+v", len(out), out)
1160 }1160 }
@@ -1188,7 +1188,7 @@ func TestOverviewOutlinePrefersCard(t *testing.T) {
1188 {ID: 1, Rect: Rect{X: 0, Y: 0, W: 100, H: 100}, Preview: 0.5, Focused: true},1188 {ID: 1, Rect: Rect{X: 0, Y: 0, W: 100, H: 100}, Preview: 0.5, Focused: true},
1189 {ID: 2, Rect: Rect{X: 40, Y: 80, W: 30, H: 30}, Preview: 0.2, Focused: true, Card: true},1189 {ID: 2, Rect: Rect{X: 40, Y: 80, W: 30, H: 30}, Preview: 0.2, Focused: true, Card: true},
1190 }1190 }
1191- out := overviewOutline(ps, 2)1191+ out := overviewOutline(nil, ps, 2)
1192 if len(out) != 4 || out[0].Rect != (Rect{X: 38, Y: 78, W: 34, H: 2}) {1192 if len(out) != 4 || out[0].Rect != (Rect{X: 38, Y: 78, W: 34, H: 2}) {
1193 t.Fatalf("outline: %+v", out)1193 t.Fatalf("outline: %+v", out)
1194 }1194 }
@@ -1206,10 +1206,10 @@ func TestOverviewOutlinePileAllocations(t *testing.T) {
1206 for _, p := range ps {1206 for _, p := range ps {
1207 card = card || p.Card && p.Preview > 0 && !p.Hidden1207 card = card || p.Card && p.Preview > 0 && !p.Hidden
1208 }1208 }
1209- if !card || len(overviewOutline(ps, 2)) == 0 {1209+ if !card || len(overviewOutline(nil, ps, 2)) == 0 {
1210 t.Fatal("setup: no pile under a framed lane tile")1210 t.Fatal("setup: no pile under a framed lane tile")
1211 }1211 }
1212- if n := testing.AllocsPerRun(50, func() { _ = overviewOutline(ps, 2) }); n > 2 {1212+ if n := testing.AllocsPerRun(50, func() { _ = overviewOutline(nil, ps, 2) }); n > 2 {
1213 t.Fatalf("overviewOutline allocs = %v, want <= 2", n)1213 t.Fatalf("overviewOutline allocs = %v, want <= 2", n)
1214 }1214 }
1215 }1215 }
M internal/core/overview_named_test.go +18 -0
@@ -22,6 +22,24 @@ func namedOverviewMonitor() *Monitor {
2222 return m
2323 }
2424
25+// The overview rebuilds its row order and slots every frame: with named
26+// workspaces to group and sort, from the monitor's scratch, it costs nothing.
27+func TestOverviewNamedRowsAllocations(t *testing.T) {
28+ m := namedOverviewMonitor()
29+ m.Focus(0)
30+ m.ToggleNamed("dev")
31+ m.ToggleOverview()
32+ if len(m.overviewWorkspaces()) < 4 {
33+ t.Fatal("setup: named rows missing")
34+ }
35+ if n := testing.AllocsPerRun(50, func() { m.overviewWorkspaces() }); n != 0 {
36+ t.Errorf("overviewWorkspaces allocs = %v, want 0", n)
37+ }
38+ if n := testing.AllocsPerRun(50, func() { m.overviewRows() }); n != 0 {
39+ t.Errorf("overviewRows allocs = %v, want 0", n)
40+ }
41+}
42+
2543 func TestOverviewNamedFollowsInvocationWorkspace(t *testing.T) {
2644 m := namedOverviewMonitor()
2745 numbered := slices.Clone(m.Workspaces)
@@ -22,6 +22,24 @@ func namedOverviewMonitor() *Monitor {
22 return m22 return m
23 }23 }
24 24
25+// The overview rebuilds its row order and slots every frame: with named
26+// workspaces to group and sort, from the monitor's scratch, it costs nothing.
27+func TestOverviewNamedRowsAllocations(t *testing.T) {
28+ m := namedOverviewMonitor()
29+ m.Focus(0)
30+ m.ToggleNamed("dev")
31+ m.ToggleOverview()
32+ if len(m.overviewWorkspaces()) < 4 {
33+ t.Fatal("setup: named rows missing")
34+ }
35+ if n := testing.AllocsPerRun(50, func() { m.overviewWorkspaces() }); n != 0 {
36+ t.Errorf("overviewWorkspaces allocs = %v, want 0", n)
37+ }
38+ if n := testing.AllocsPerRun(50, func() { m.overviewRows() }); n != 0 {
39+ t.Errorf("overviewRows allocs = %v, want 0", n)
40+ }
41+}
42+
25 func TestOverviewNamedFollowsInvocationWorkspace(t *testing.T) {43 func TestOverviewNamedFollowsInvocationWorkspace(t *testing.T) {
26 m := namedOverviewMonitor()44 m := namedOverviewMonitor()
27 numbered := slices.Clone(m.Workspaces)45 numbered := slices.Clone(m.Workspaces)
M internal/core/overview_test.go +7 -1
@@ -508,7 +508,7 @@ func TestOverviewFixedOverflowGeometry(t *testing.T) {
508508 t.Fatalf("%d not hit at %+v", id, p.Rect)
509509 }
510510 }
511- if len(overviewOutline(ps, 2)) != 4 {
511+ if len(overviewOutline(nil, ps, 2)) != 4 {
512512 t.Fatal("missing selection outline")
513513 }
514514 m.OverviewMove(-1, 0)
@@ -763,3 +763,9 @@ func TestOverviewFingerScroll(t *testing.T) {
763763 t.Fatal("continuous scroll held by the finger scroll")
764764 }
765765 }
766+
767+// at is the slot of w, the zero Rect if it has none.
768+func (s *overviewSlots) at(w *Workspace) Rect {
769+ r, _ := s.lookup(w)
770+ return r
771+}
@@ -508,7 +508,7 @@ func TestOverviewFixedOverflowGeometry(t *testing.T) {
508 t.Fatalf("%d not hit at %+v", id, p.Rect)508 t.Fatalf("%d not hit at %+v", id, p.Rect)
509 }509 }
510 }510 }
511- if len(overviewOutline(ps, 2)) != 4 {511+ if len(overviewOutline(nil, ps, 2)) != 4 {
512 t.Fatal("missing selection outline")512 t.Fatal("missing selection outline")
513 }513 }
514 m.OverviewMove(-1, 0)514 m.OverviewMove(-1, 0)
@@ -763,3 +763,9 @@ func TestOverviewFingerScroll(t *testing.T) {
763 t.Fatal("continuous scroll held by the finger scroll")763 t.Fatal("continuous scroll held by the finger scroll")
764 }764 }
765 }765 }
766+
767+// at is the slot of w, the zero Rect if it has none.
768+func (s *overviewSlots) at(w *Workspace) Rect {
769+ r, _ := s.lookup(w)
770+ return r
771+}
M internal/core/publish_alloc_test.go +44 -6
@@ -24,13 +24,17 @@ import (
2424 // (copied only when it changed). What is left is the scenes themselves
2525 // (windows, separators, the scene set). Leaving entries (a closed window
2626 // still fading) add none: 3, less than a frame with six moving windows
27-// (fewer configure targets). The opening overview costs 18 (the stack
28-// items, the row map and the neighbor list it rebuilds every frame; its
29-// layouts reuse the buffers too).
27+// (fewer configure targets). The opening overview costs 4 alone and 6 with
28+// neighbors (5 and 7 under -race); it was 11 and 13 when it rebuilt the row
29+// map, the neighbor list, the workspace order (groups map, hidden clone) and
30+// the outline slice (appended onto the dividers) every frame. The rows are
31+// now a fixed array, the order and dividers monitor scratch, and the scene's
32+// separators one slice. What is left is the scene, the stack items and the
33+// preview rows.
3034 const (
3135 publishAllocBudget = 5
3236 publishLeavingAllocBudget = 3
33- publishOverviewAllocBudget = 18
37+ publishOverviewAllocBudget = 7
3438 )
3539
3640 // publishRig is a Core on the real clock (a mock clock would count its own
@@ -230,12 +234,16 @@ func TestPublishOverviewAllocations(t *testing.T) {
230234 if neighbors {
231235 want, slots = 18, 3
232236 }
233- if rows, _ := m.overviewRows(); len(rows) != slots {
234- t.Fatalf("setup: %d overview slots, want %d", len(rows), slots)
237+ if rows, _ := m.overviewRows(); rows.n != slots {
238+ t.Fatalf("setup: %d overview slots, want %d", rows.n, slots)
235239 }
236240 if !m.ov.open || m.overviewAxis() != m.Current().policy().workspace || len(c.overviewReal) != want || len(sc.rects) == 0 || !c.animating() {
237241 t.Fatalf("setup: overview %v, real layouts %d (want %d), rects %d, animating %v", m.ov.open, len(c.overviewReal), want, len(sc.rects), c.animating())
238242 }
243+ // The rows themselves cost nothing: array slots, scratch order.
244+ if n := testing.AllocsPerRun(50, func() { m.overviewRows() }); n != 0 {
245+ t.Errorf("overviewRows allocs = %v, want 0", n)
246+ }
239247 n := testing.AllocsPerRun(50, step)
240248 t.Logf("%s overview allocs per step = %v", name, n)
241249 if len(sc.rects) == 0 {
@@ -247,6 +255,36 @@ func TestPublishOverviewAllocations(t *testing.T) {
247255 })
248256 }
249257 }
258+ // A named row below its anchor adds the boundary divider: the scene
259+ // copies it into its own separators, one slice like the outline alone.
260+ t.Run("named", func(t *testing.T) {
261+ c := publishRig(t, 1)
262+ sc := c.cur()
263+ m := sc.mon
264+ m.SetNamed([]NamedWorkspace{{Name: "dev"}})
265+ m.ToggleNamed("dev")
266+ m.AddWindow(30)
267+ m.ToggleNamed("dev")
268+ sc.arrange()
269+ c.refreshShown()
270+ now := time.Now()
271+ shots := c.snapshot(now)
272+ m.ToggleOverview()
273+ c.transition(shots, now)
274+ step := publishStep(t, c, nil)
275+ step()
276+ if _, dividers := m.overviewRows(); len(dividers) != 1 || !m.ov.open || len(sc.rects) == 0 {
277+ t.Fatalf("setup: %d dividers, overview %v, rects %d", len(dividers), m.ov.open, len(sc.rects))
278+ }
279+ n := testing.AllocsPerRun(50, step)
280+ t.Logf("named overview allocs per step = %v", n)
281+ if len(sc.rects) == 0 {
282+ t.Fatal("card motions settled during the measure")
283+ }
284+ if n > publishOverviewAllocBudget {
285+ t.Errorf("named overview publish allocs per frame = %v, budget %d", n, publishOverviewAllocBudget)
286+ }
287+ })
250288 // The column switcher's cards are previews too: a held switch shows
251289 // them, and a publish while their motions run costs a plain frame.
252290 t.Run("switcher", func(t *testing.T) {
@@ -24,13 +24,17 @@ import (
24 // (copied only when it changed). What is left is the scenes themselves24 // (copied only when it changed). What is left is the scenes themselves
25 // (windows, separators, the scene set). Leaving entries (a closed window25 // (windows, separators, the scene set). Leaving entries (a closed window
26 // still fading) add none: 3, less than a frame with six moving windows26 // still fading) add none: 3, less than a frame with six moving windows
27-// (fewer configure targets). The opening overview costs 18 (the stack27+// (fewer configure targets). The opening overview costs 4 alone and 6 with
28-// items, the row map and the neighbor list it rebuilds every frame; its28+// neighbors (5 and 7 under -race); it was 11 and 13 when it rebuilt the row
29-// layouts reuse the buffers too).29+// map, the neighbor list, the workspace order (groups map, hidden clone) and
30+// the outline slice (appended onto the dividers) every frame. The rows are
31+// now a fixed array, the order and dividers monitor scratch, and the scene's
32+// separators one slice. What is left is the scene, the stack items and the
33+// preview rows.
30 const (34 const (
31 publishAllocBudget = 535 publishAllocBudget = 5
32 publishLeavingAllocBudget = 336 publishLeavingAllocBudget = 3
33- publishOverviewAllocBudget = 1837+ publishOverviewAllocBudget = 7
34 )38 )
35 39
36 // publishRig is a Core on the real clock (a mock clock would count its own40 // publishRig is a Core on the real clock (a mock clock would count its own
@@ -230,12 +234,16 @@ func TestPublishOverviewAllocations(t *testing.T) {
230 if neighbors {234 if neighbors {
231 want, slots = 18, 3235 want, slots = 18, 3
232 }236 }
233- if rows, _ := m.overviewRows(); len(rows) != slots {237+ if rows, _ := m.overviewRows(); rows.n != slots {
234- t.Fatalf("setup: %d overview slots, want %d", len(rows), slots)238+ t.Fatalf("setup: %d overview slots, want %d", rows.n, slots)
235 }239 }
236 if !m.ov.open || m.overviewAxis() != m.Current().policy().workspace || len(c.overviewReal) != want || len(sc.rects) == 0 || !c.animating() {240 if !m.ov.open || m.overviewAxis() != m.Current().policy().workspace || len(c.overviewReal) != want || len(sc.rects) == 0 || !c.animating() {
237 t.Fatalf("setup: overview %v, real layouts %d (want %d), rects %d, animating %v", m.ov.open, len(c.overviewReal), want, len(sc.rects), c.animating())241 t.Fatalf("setup: overview %v, real layouts %d (want %d), rects %d, animating %v", m.ov.open, len(c.overviewReal), want, len(sc.rects), c.animating())
238 }242 }
243+ // The rows themselves cost nothing: array slots, scratch order.
244+ if n := testing.AllocsPerRun(50, func() { m.overviewRows() }); n != 0 {
245+ t.Errorf("overviewRows allocs = %v, want 0", n)
246+ }
239 n := testing.AllocsPerRun(50, step)247 n := testing.AllocsPerRun(50, step)
240 t.Logf("%s overview allocs per step = %v", name, n)248 t.Logf("%s overview allocs per step = %v", name, n)
241 if len(sc.rects) == 0 {249 if len(sc.rects) == 0 {
@@ -247,6 +255,36 @@ func TestPublishOverviewAllocations(t *testing.T) {
247 })255 })
248 }256 }
249 }257 }
258+ // A named row below its anchor adds the boundary divider: the scene
259+ // copies it into its own separators, one slice like the outline alone.
260+ t.Run("named", func(t *testing.T) {
261+ c := publishRig(t, 1)
262+ sc := c.cur()
263+ m := sc.mon
264+ m.SetNamed([]NamedWorkspace{{Name: "dev"}})
265+ m.ToggleNamed("dev")
266+ m.AddWindow(30)
267+ m.ToggleNamed("dev")
268+ sc.arrange()
269+ c.refreshShown()
270+ now := time.Now()
271+ shots := c.snapshot(now)
272+ m.ToggleOverview()
273+ c.transition(shots, now)
274+ step := publishStep(t, c, nil)
275+ step()
276+ if _, dividers := m.overviewRows(); len(dividers) != 1 || !m.ov.open || len(sc.rects) == 0 {
277+ t.Fatalf("setup: %d dividers, overview %v, rects %d", len(dividers), m.ov.open, len(sc.rects))
278+ }
279+ n := testing.AllocsPerRun(50, step)
280+ t.Logf("named overview allocs per step = %v", n)
281+ if len(sc.rects) == 0 {
282+ t.Fatal("card motions settled during the measure")
283+ }
284+ if n > publishOverviewAllocBudget {
285+ t.Errorf("named overview publish allocs per frame = %v, budget %d", n, publishOverviewAllocBudget)
286+ }
287+ })
250 // The column switcher's cards are previews too: a held switch shows288 // The column switcher's cards are previews too: a held switch shows
251 // them, and a publish while their motions run costs a plain frame.289 // them, and a publish while their motions run costs a plain frame.
252 t.Run("switcher", func(t *testing.T) {290 t.Run("switcher", func(t *testing.T) {
M internal/core/scene.go +8 -3
@@ -54,11 +54,16 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
5454 }
5555 if sc.mon.ov.open {
5656 // Frame the selection and separate numbered and named row groups.
57- _, scene.Separators = sc.mon.overviewRows()
58- scene.Separators = append(scene.Separators, overviewOutline(layout, max(c.cfg.Border.Width, 2))...)
57+ // The scene's own slice (immutable once sent), sized for both.
58+ _, dividers := sc.mon.overviewRows()
59+ var seps []ports.Separator
60+ if len(dividers) > 0 {
61+ seps = append(make([]ports.Separator, 0, len(dividers)+outlineLines), dividers...)
62+ }
63+ scene.Separators = overviewOutline(seps, layout, max(c.cfg.Border.Width, 2))
5964 } else if sc.mon.switcherShown() {
6065 // The switcher's cards: only the selection is framed.
61- scene.Separators = overviewOutline(layout, max(c.cfg.Border.Width, 2))
66+ scene.Separators = overviewOutline(nil, layout, max(c.cfg.Border.Width, 2))
6267 } else {
6368 // Only the focused output lights the focused window's lines.
6469 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), view, i == c.focusScreen)
@@ -54,11 +54,16 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
54 }54 }
55 if sc.mon.ov.open {55 if sc.mon.ov.open {
56 // Frame the selection and separate numbered and named row groups.56 // Frame the selection and separate numbered and named row groups.
57- _, scene.Separators = sc.mon.overviewRows()57+ // The scene's own slice (immutable once sent), sized for both.
58- scene.Separators = append(scene.Separators, overviewOutline(layout, max(c.cfg.Border.Width, 2))...)58+ _, dividers := sc.mon.overviewRows()
59+ var seps []ports.Separator
60+ if len(dividers) > 0 {
61+ seps = append(make([]ports.Separator, 0, len(dividers)+outlineLines), dividers...)
62+ }
63+ scene.Separators = overviewOutline(seps, layout, max(c.cfg.Border.Width, 2))
59 } else if sc.mon.switcherShown() {64 } else if sc.mon.switcherShown() {
60 // The switcher's cards: only the selection is framed.65 // The switcher's cards: only the selection is framed.
61- scene.Separators = overviewOutline(layout, max(c.cfg.Border.Width, 2))66+ scene.Separators = overviewOutline(nil, layout, max(c.cfg.Border.Width, 2))
62 } else {67 } else {
63 // Only the focused output lights the focused window's lines.68 // Only the focused output lights the focused window's lines.
64 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), view, i == c.focusScreen)69 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), view, i == c.focusScreen)

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close