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
17 files · +478 -86
M Makefile +1 -1
| @@ -69,7 +69,7 @@ adapter-imports: | |||
| 69 | 69 | [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; } | |
| 70 | 70 | # Fast, mandatory guards for tiled content publications and SHM copies. | |
| 71 | 71 | perf-check: | |
| 72 | - | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/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 | |
| 73 | 73 | check: vet test arch fakes-check log-check perf-check | |
| 74 | 74 | ||
| 75 | 75 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | |
| @@ -69,7 +69,7 @@ adapter-imports: | |||||
| 69 | [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; } | 69 | [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; } | ||
| 70 | # Fast, mandatory guards for tiled content publications and SHM copies. | 70 | # Fast, mandatory guards for tiled content publications and SHM copies. | ||
| 71 | perf-check: | 71 | perf-check: | ||
| 72 | - | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/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 |
| 73 | check: vet test arch fakes-check log-check perf-check | 73 | check: vet test arch fakes-check log-check perf-check | ||
| 74 | 74 | ||||
| 75 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | 75 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | ||
M internal/adapters/vulkan/frame.go +24 -4
| @@ -54,12 +54,24 @@ type callScratch struct { | |||
| 54 | 54 | 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 | 60 | type deferredFree struct { | |
| 59 | 61 | frame uint64 | |
| 62 | + | sem vk.Semaphore | |
| 60 | 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 | 75 | func (r *Renderer) createSlots() error { | |
| 64 | 76 | d := r.dd | |
| 65 | 77 | cmds := make([]vk.CommandBuffer, frameSlots) | |
| @@ -154,10 +166,19 @@ func (r *Renderer) retire(frame uint64, free func()) { | |||
| 154 | 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 | 178 | func (r *Renderer) runDeferred() { | |
| 158 | 179 | r.deferred = slices.DeleteFunc(r.deferred, func(f deferredFree) bool { | |
| 159 | 180 | if f.frame <= r.completed { | |
| 160 | - | f.free() | |
| 181 | + | r.free(f) | |
| 161 | 182 | return true | |
| 162 | 183 | } | |
| 163 | 184 | return false | |
| @@ -311,8 +332,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 311 | 332 | r.frameWaits = waits | |
| 312 | 333 | frame := r.frame | |
| 313 | 334 | for _, sem := range waits { | |
| 314 | - | sem := sem | |
| 315 | - | r.retire(frame, func() { d.DestroySemaphore(r.device, sem, nil) }) | |
| 335 | + | r.retireSemaphore(frame, sem) | |
| 316 | 336 | } | |
| 317 | 337 | sc.submit = vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &sc.cmd} | |
| 318 | 338 | submit := &sc.submit | |
| @@ -54,12 +54,24 @@ type callScratch struct { | |||||
| 54 | hdrNits float32 | 54 | 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 uint64 | 61 | 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.dd | 76 | 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 true | 182 | return true | ||
| 162 | } | 183 | } | ||
| 163 | return false | 184 | return false | ||
| @@ -311,8 +332,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||||
| 311 | r.frameWaits = waits | 332 | r.frameWaits = waits | ||
| 312 | frame := r.frame | 333 | frame := r.frame | ||
| 313 | for _, sem := range waits { | 334 | for _, sem := range waits { | ||
| 314 | - | sem := sem | 335 | + | 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.submit | 338 | submit := &sc.submit | ||
M internal/adapters/vulkan/scene_perf_test.go +62 -4
| @@ -140,8 +140,10 @@ func TestRenderSteadyStateAllocations(t *testing.T) { | |||
| 140 | 140 | // A steady frame that draws a client dmabuf also records the ownership | |
| 141 | 141 | // barriers (acquire and release) of its image, which allocate nothing. The | |
| 142 | 142 | // 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. | |
| 145 | 147 | func TestRenderSteadyStateDMABufAllocations(t *testing.T) { | |
| 146 | 148 | r, err := New(80, 32) | |
| 147 | 149 | if err != nil { | |
| @@ -182,8 +184,64 @@ func TestRenderSteadyStateDMABufAllocations(t *testing.T) { | |||
| 182 | 184 | moved() | |
| 183 | 185 | allocs := testing.AllocsPerRun(20, moved) | |
| 184 | 186 | 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)) | |
| 187 | 245 | } | |
| 188 | 246 | } | |
| 189 | 247 | ||
| @@ -140,8 +140,10 @@ func TestRenderSteadyStateAllocations(t *testing.T) { | |||||
| 140 | // A steady frame that draws a client dmabuf also records the ownership | 140 | // A steady frame that draws a client dmabuf also records the ownership | ||
| 141 | // barriers (acquire and release) of its image, which allocate nothing. The | 141 | // barriers (acquire and release) of its image, which allocate nothing. The | ||
| 142 | // frame still allocates more than the SHM scene: importing the client's | 142 | // 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 | 143 | + | // 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 @@ | |||
| 1 | 1 | package wayland | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "os" | |
| 5 | + | "slices" | |
| 4 | 6 | "testing" | |
| 5 | 7 | "time" | |
| 6 | 8 | ||
| 9 | + | "github.com/bnema/go-wayland-bindings/server/wayland" | |
| 7 | 10 | "github.com/bnema/neferwl/internal/ports" | |
| 8 | 11 | "github.com/stretchr/testify/mock" | |
| 12 | + | "golang.org/x/sys/unix" | |
| 9 | 13 | ) | |
| 10 | 14 | ||
| 11 | 15 | // 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 | 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 | + | } | |
| @@ -1,11 +1,15 @@ | |||||
| 1 | package wayland | 1 | 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 allocate | 15 | // 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) { | |||
| 253 | 253 | dest := s.frameOutput(owner) | |
| 254 | 254 | moved[dest] = append(moved[dest], cb) | |
| 255 | 255 | } | |
| 256 | + | clear(callbacks[len(kept):]) // the filtered tail must not pin callbacks | |
| 256 | 257 | if len(kept) == 0 { | |
| 257 | 258 | delete(s.awaiting, name) | |
| 258 | 259 | } else { | |
| @@ -270,8 +271,10 @@ func (s *Server) relocateCallbacks(root *surface) { | |||
| 270 | 271 | ||
| 271 | 272 | // dueFrames returns the queues to fire now: flipped outputs and those past | |
| 272 | 273 | // 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 | 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 | 278 | if s.framePeriods == nil { | |
| 276 | 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 | 287 | for name, callbacks := range s.awaiting { | |
| 285 | 288 | if _, ok := periods[name]; !ok && name != "" && name != suspendedFrameQueue { | |
| 286 | 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 | 295 | periods[""] = defaultFramePeriod | |
| @@ -330,6 +335,7 @@ func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []strin | |||
| 330 | 335 | wait = 0 | |
| 331 | 336 | } | |
| 332 | 337 | } | |
| 338 | + | s.frameFire = fire | |
| 333 | 339 | 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 | 347 | // sendFrames fires an output's callbacks; flipped: a flip of it fired them. | |
| 342 | 348 | func (s *Server) sendFrames(name string, flipped bool) { | |
| 343 | 349 | callbacks := s.awaiting[name] | |
| 344 | - | delete(s.awaiting, name) | |
| 345 | 350 | if len(callbacks) == 0 { | |
| 346 | 351 | return | |
| 347 | 352 | } | |
| @@ -360,6 +365,10 @@ func (s *Server) sendFrames(name string, flipped bool) { | |||
| 360 | 365 | cb.Destroy() | |
| 361 | 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 | 374 | // 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 past | 272 | // 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 when | 273 | // 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[""] = defaultFramePeriod | 295 | periods[""] = defaultFramePeriod | ||
| @@ -330,6 +335,7 @@ func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []strin | |||||
| 330 | wait = 0 | 335 | wait = 0 | ||
| 331 | } | 336 | } | ||
| 332 | } | 337 | } | ||
| 338 | + | s.frameFire = fire | |||
| 333 | return fire, max(wait, time.Millisecond), !waiting | 339 | 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 | return | 351 | 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) { | |||
| 144 | 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 | + | } | |
| @@ -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 ( | |||
| 7 | 7 | "github.com/bnema/go-wayland-bindings/server/wayland" | |
| 8 | 8 | "github.com/bnema/neferwl/internal/ports" | |
| 9 | 9 | "github.com/bnema/purego-libwayland/server" | |
| 10 | + | "golang.org/x/sys/unix" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | 13 | func registerGlobals(d *server.Display, o Options, s *Server) error { | |
| @@ -389,6 +390,12 @@ type buffer struct { | |||
| 389 | 390 | pool *pool | |
| 390 | 391 | offset, width, height, stride int | |
| 391 | 392 | 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 | 401 | func (*buffer) Destroy(*wayland.Buffer) {} | |
| @@ -417,13 +424,18 @@ func (s *Server) addBuffer(r *wayland.Buffer, b clientBuffer) { | |||
| 417 | 424 | } | |
| 418 | 425 | ||
| 419 | 426 | // 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 | 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 | 436 | return ports.SurfaceContent{}, false | |
| 424 | 437 | } | |
| 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 | |
| 427 | 439 | } | |
| 428 | 440 | ||
| 429 | 441 | 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 *pool | 390 | pool *pool | ||
| 390 | offset, width, height, stride int | 391 | offset, width, height, stride int | ||
| 391 | format uint32 | 392 | 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 buffer | 426 | // 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{}, false | 436 | 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 { | |||
| 189 | 189 | sink *presentSink | |
| 190 | 190 | // framePeriods is the tick's scratch (display goroutine only). | |
| 191 | 191 | framePeriods map[string]time.Duration | |
| 192 | + | // frameFire is dueFrames' result slice, reused across ticks. | |
| 193 | + | frameFire []string | |
| 192 | 194 | // held buffers wait until no output reads them (release.go); reports | |
| 193 | 195 | // are the outputs' latest ports.OutputPresented. | |
| 194 | 196 | held []heldBuffer | |
| @@ -189,6 +189,8 @@ type Server struct { | |||||
| 189 | sink *presentSink | 189 | 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.Duration | 191 | 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); reports | 194 | // 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 []heldBuffer | 196 | held []heldBuffer | ||
M internal/app/app.go +22 -12
| @@ -157,24 +157,34 @@ func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configError | |||
| 157 | 157 | } | |
| 158 | 158 | } | |
| 159 | 159 | 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) | |
| 172 | 161 | case err := <-configErrors: | |
| 173 | 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 | 188 | // openXwayland reserves an X11 display for xwayland-satellite, or returns | |
| 179 | 189 | // nil when X11 is off or unavailable: the session runs without it. | |
| 180 | 190 | 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 sender | 159 | 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 returns | 188 | // 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "iter" | |
| 5 | 5 | "slices" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 6 | 8 | ) | |
| 7 | 9 | ||
| 8 | 10 | // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers | |
| @@ -46,6 +48,12 @@ type Monitor struct { | |||
| 46 | 48 | // one its swipe began on); switchView.off is then from the current | |
| 47 | 49 | // workspace's place in it. | |
| 48 | 50 | 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 | 57 | // ov holds the overview selection and its Escape snapshot. | |
| 50 | 58 | ov overviewState | |
| 51 | 59 | // 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). Numbers | 10 | // 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 current | 48 | // 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 []*Workspace | 50 | 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 overviewState | 58 | 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) { | |||
| 326 | 326 | // in configured order within each group. Unattached rows follow the last | |
| 327 | 327 | // occupied numbered row (or the first). Empty invocation anchors stay visible; | |
| 328 | 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 | 333 | func (m *Monitor) overviewWorkspaces() []*Workspace { | |
| 330 | 334 | fallback := m.Workspaces[0] | |
| 331 | 335 | for _, w := range m.Workspaces { | |
| @@ -333,8 +337,6 @@ func (m *Monitor) overviewWorkspaces() []*Workspace { | |||
| 333 | 337 | fallback = w | |
| 334 | 338 | } | |
| 335 | 339 | } | |
| 336 | - | groups := make(map[*Workspace][]*Workspace) | |
| 337 | - | named := slices.Clone(m.hidden) | |
| 338 | 340 | rank := func(w *Workspace) int { | |
| 339 | 341 | for i, spec := range m.named { | |
| 340 | 342 | if spec.Name == w.Name { | |
| @@ -343,24 +345,35 @@ func (m *Monitor) overviewWorkspaces() []*Workspace { | |||
| 343 | 345 | } | |
| 344 | 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 | 352 | 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] | |
| 358 | 354 | 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] | |
| 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 | 377 | return rows | |
| 365 | 378 | } | |
| 366 | 379 | ||
| @@ -674,16 +687,40 @@ func (m *Monitor) OverviewPick(id WindowID) { | |||
| 674 | 687 | // workspace's layout. | |
| 675 | 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 | 713 | // overviewRows places the current workspace and its overview neighbors in | |
| 678 | 714 | // slots, in output-local pixels, along the overview axis; each workspace | |
| 679 | 715 | // 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 | 719 | // Vertical axis: full-width slots, the current one centred; the rule is | |
| 683 | 720 | // horizontal. Horizontal axis: full-height slots as wide as their content, | |
| 684 | 721 | // stack peeks included (the stash pile takes a band of the slot), the current one | |
| 685 | 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 | 724 | cur := m.Current() | |
| 688 | 725 | u := cur.overviewArea() | |
| 689 | 726 | gap := max(1, u.H*3/100) | |
| @@ -696,11 +733,12 @@ func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) { | |||
| 696 | 733 | hc := cur.rowHeight(axis) | |
| 697 | 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} | |
| 700 | - | var dividers []ports.Separator | |
| 736 | + | var rows overviewSlots | |
| 737 | + | rows.add(cur, cs) | |
| 738 | + | dividers := 0 | |
| 701 | 739 | list := m.overviewWorkspaces() | |
| 702 | 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 | 742 | if next < 0 || next >= len(list) { | |
| 705 | 743 | continue | |
| 706 | 744 | } | |
| @@ -711,12 +749,16 @@ func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) { | |||
| 711 | 749 | spacing *= 2 | |
| 712 | 750 | } | |
| 713 | 751 | slot, gapAt := neighborSlot(axis, u, cs, w, next < i, spacing) | |
| 714 | - | rows[w] = slot | |
| 752 | + | rows.add(w, slot) | |
| 715 | 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, dividers | |
| 758 | + | if dividers == 0 { | |
| 759 | + | return rows, nil | |
| 760 | + | } | |
| 761 | + | return rows, m.ovDividers[:dividers] | |
| 720 | 762 | } | |
| 721 | 763 | ||
| 722 | 764 | // 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 | 809 | var unfolded *Workspace | |
| 768 | 810 | var unfoldedSlot Rect | |
| 769 | 811 | for w := range m.all() { | |
| 770 | - | slot, shown := rows[w] | |
| 812 | + | slot, shown := rows.lookup(w) | |
| 771 | 813 | if shown { | |
| 772 | 814 | // The pile band goes under the row: the row is centred | |
| 773 | 815 | // with it. | |
| @@ -1274,12 +1316,17 @@ func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes | |||
| 1274 | 1316 | return tiles | |
| 1275 | 1317 | } | |
| 1276 | 1318 | ||
| 1319 | + | // outlineLines is the lines of an uncut outline: one per edge. | |
| 1320 | + | const outlineLines = 4 | |
| 1321 | + | ||
| 1277 | 1322 | // overviewOutline frames the selected preview with active lines of width | |
| 1278 | 1323 | // b, just outside it. The selection is one preview: a selected stash card | |
| 1279 | 1324 | // wins over any other focused one, which must then not be framed. A tile's | |
| 1280 | 1325 | // 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 { | |
| 1283 | 1330 | sel := -1 | |
| 1284 | 1331 | for i, p := range layout { | |
| 1285 | 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 | 1336 | if sel < 0 { | |
| 1290 | - | return nil | |
| 1337 | + | return dst | |
| 1291 | 1338 | } | |
| 1339 | + | dst = slices.Grow(dst, outlineLines) | |
| 1292 | 1340 | r := layout[sel].Rect | |
| 1293 | 1341 | edges := [4]Rect{ | |
| 1294 | 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 | 1344 | {X: r.X - b, Y: r.Y, W: b, H: r.H}, | |
| 1297 | 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 | 1347 | cut := false | |
| 1301 | 1348 | if !layout[sel].Card { | |
| 1302 | 1349 | for _, p := range layout { | |
| @@ -1308,9 +1355,9 @@ func overviewOutline(layout []Placement, b int) []ports.Separator { | |||
| 1308 | 1355 | } | |
| 1309 | 1356 | if !cut { | |
| 1310 | 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 out | |
| 1360 | + | return dst | |
| 1314 | 1361 | } | |
| 1315 | 1362 | // Stack buffers: the outline is rebuilt every overview frame. | |
| 1316 | 1363 | var cardBuf [overviewCards]Rect | |
| @@ -1323,10 +1370,10 @@ func overviewOutline(layout []Placement, b int) []ports.Separator { | |||
| 1323 | 1370 | } | |
| 1324 | 1371 | for _, e := range edges { | |
| 1325 | 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 out | |
| 1376 | + | return dst | |
| 1330 | 1377 | } | |
| 1331 | 1378 | ||
| 1332 | 1379 | // 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 last | 326 | // 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 = w | 337 | 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 rows | 377 | 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 in | 713 | // overviewRows places the current workspace and its overview neighbors in | ||
| 678 | // slots, in output-local pixels, along the overview axis; each workspace | 714 | // slots, in output-local pixels, along the overview axis; each workspace | ||
| 679 | // places its previews in its slot. The numbered/named boundary gets extra | 715 | // 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 is | 719 | // 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 one | 721 | // 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.Separator | 737 | + | 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 | continue | 743 | continue | ||
| 706 | } | 744 | } | ||
| @@ -711,12 +749,16 @@ func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) { | |||||
| 711 | spacing *= 2 | 749 | 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] = slot | 752 | + | 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, dividers | 758 | + | 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 current | 764 | // 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 *Workspace | 809 | var unfolded *Workspace | ||
| 768 | var unfoldedSlot Rect | 810 | 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 centred | 814 | // 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 tiles | 1316 | 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 width | 1322 | // overviewOutline frames the selected preview with active lines of width | ||
| 1278 | // b, just outside it. The selection is one preview: a selected stash card | 1323 | // 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's | 1324 | // 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 runs | 1325 | // 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 := -1 | 1330 | 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 nil | 1337 | + | return dst |
| 1291 | } | 1338 | } | ||
| 1339 | + | dst = slices.Grow(dst, outlineLines) | |||
| 1292 | r := layout[sel].Rect | 1340 | 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 := false | 1347 | 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 out | 1360 | + | 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]Rect | 1363 | 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 out | 1376 | + | return dst |
| 1330 | } | 1377 | } | ||
| 1331 | 1378 | ||||
| 1332 | // subtractRects is what of r no rect of cuts covers, as rects. The edges of | 1379 | // 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) { | |||
| 147 | 147 | m := cascadeOverview() | |
| 148 | 148 | m.ToggleOverview() | |
| 149 | 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 | 151 | if left.X+left.W > cur.X || right.X < cur.X+cur.W { | |
| 152 | 152 | t.Fatalf("slots overlap: %v %v %v", left, cur, right) | |
| 153 | 153 | } | |
| @@ -227,9 +227,9 @@ func TestOverviewCascadeStashInSlot(t *testing.T) { | |||
| 227 | 227 | m.ToggleOverview() | |
| 228 | 228 | rows, _ := m.overviewRows() | |
| 229 | 229 | 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)) | |
| 233 | 233 | } | |
| 234 | 234 | ps := m.Layout() | |
| 235 | 235 | lane := previewOf(t, ps, 8).Rect | |
| @@ -566,7 +566,7 @@ func TestOverviewCascadeNamedDivider(t *testing.T) { | |||
| 566 | 566 | if len(dividers) != 1 { | |
| 567 | 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 | 570 | if d.W != 1 || d.H <= 1 || dividers[0].Active { | |
| 571 | 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 | 638 | m.Focus(1) | |
| 639 | 639 | m.ToggleOverview() | |
| 640 | 640 | 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) | |
| 643 | 643 | } | |
| 644 | 644 | if p := previewOf(t, m.Layout(), 64); p.Hidden || !p.Peek { | |
| 645 | 645 | t.Fatalf("neighbor pile card: %+v", p) | |
| @@ -813,7 +813,7 @@ func TestOverviewMixedLayoutGestureAndCancel(t *testing.T) { | |||
| 813 | 813 | from := m.Current() | |
| 814 | 814 | m.ToggleOverview() | |
| 815 | 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 | 817 | t.Fatal("cascade neighbor not horizontal") | |
| 818 | 818 | } | |
| 819 | 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 | 995 | m.ToggleOverview() | |
| 996 | 996 | rows, _ := m.overviewRows() | |
| 997 | - | cur, slot := rows[m.Current()], rows[prev] | |
| 997 | + | cur, slot := rows.at(m.Current()), rows.at(prev) | |
| 998 | 998 | hidden, shown := 0, 0 | |
| 999 | 999 | for _, p := range m.Layout() { | |
| 1000 | 1000 | if !prev.has(p.ID) { | |
| @@ -1053,7 +1053,7 @@ func TestOverviewCascadeScroll(t *testing.T) { | |||
| 1053 | 1053 | ||
| 1054 | 1054 | // outlineHits reports whether any outline line of ps covers a point of r. | |
| 1055 | 1055 | func outlineHits(ps []Placement, r Rect) bool { | |
| 1056 | - | for _, s := range overviewOutline(ps, 2) { | |
| 1056 | + | for _, s := range overviewOutline(nil, ps, 2) { | |
| 1057 | 1057 | if s.Rect.Overlaps(r) { | |
| 1058 | 1058 | return true | |
| 1059 | 1059 | } | |
| @@ -1125,7 +1125,7 @@ func TestOverviewCascadeOutlineAndPile(t *testing.T) { | |||
| 1125 | 1125 | if framed(ps) != 1 || !previewOf(t, ps, 8).Focused { | |
| 1126 | 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 | 1129 | t.Fatal("lane selected: no frame") | |
| 1130 | 1130 | } | |
| 1131 | 1131 | for _, id := range []WindowID{40, 41, 42} { | |
| @@ -1154,7 +1154,7 @@ func TestOverviewCascadeOutlineAndPile(t *testing.T) { | |||
| 1154 | 1154 | if !card.Focused || !card.Card { | |
| 1155 | 1155 | t.Fatalf("selected card not framed: %+v", card) | |
| 1156 | 1156 | } | |
| 1157 | - | out := overviewOutline(ps, 2) | |
| 1157 | + | out := overviewOutline(nil, ps, 2) | |
| 1158 | 1158 | if len(out) != 4 { | |
| 1159 | 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 | 1188 | {ID: 1, Rect: Rect{X: 0, Y: 0, W: 100, H: 100}, Preview: 0.5, Focused: true}, | |
| 1189 | 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 | 1192 | if len(out) != 4 || out[0].Rect != (Rect{X: 38, Y: 78, W: 34, H: 2}) { | |
| 1193 | 1193 | t.Fatalf("outline: %+v", out) | |
| 1194 | 1194 | } | |
| @@ -1206,10 +1206,10 @@ func TestOverviewOutlinePileAllocations(t *testing.T) { | |||
| 1206 | 1206 | for _, p := range ps { | |
| 1207 | 1207 | 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 | 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 | 1213 | t.Fatalf("overviewOutline allocs = %v, want <= 2", n) | |
| 1214 | 1214 | } | |
| 1215 | 1215 | } | |
| @@ -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).Rect | 235 | 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, 0 | 998 | 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 true | 1058 | 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.Hidden | 1207 | 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 { | |||
| 22 | 22 | 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 | 43 | func TestOverviewNamedFollowsInvocationWorkspace(t *testing.T) { | |
| 26 | 44 | m := namedOverviewMonitor() | |
| 27 | 45 | numbered := slices.Clone(m.Workspaces) | |
| @@ -22,6 +22,24 @@ func namedOverviewMonitor() *Monitor { | |||||
| 22 | return m | 22 | 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) { | |||
| 508 | 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 | 512 | t.Fatal("missing selection outline") | |
| 513 | 513 | } | |
| 514 | 514 | m.OverviewMove(-1, 0) | |
| @@ -763,3 +763,9 @@ func TestOverviewFingerScroll(t *testing.T) { | |||
| 763 | 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 | + | } | |
| @@ -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 ( | |||
| 24 | 24 | // (copied only when it changed). What is left is the scenes themselves | |
| 25 | 25 | // (windows, separators, the scene set). Leaving entries (a closed window | |
| 26 | 26 | // 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. | |
| 30 | 34 | const ( | |
| 31 | 35 | publishAllocBudget = 5 | |
| 32 | 36 | publishLeavingAllocBudget = 3 | |
| 33 | - | publishOverviewAllocBudget = 18 | |
| 37 | + | publishOverviewAllocBudget = 7 | |
| 34 | 38 | ) | |
| 35 | 39 | ||
| 36 | 40 | // 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 | 234 | if neighbors { | |
| 231 | 235 | want, slots = 18, 3 | |
| 232 | 236 | } | |
| 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) | |
| 235 | 239 | } | |
| 236 | 240 | if !m.ov.open || m.overviewAxis() != m.Current().policy().workspace || len(c.overviewReal) != want || len(sc.rects) == 0 || !c.animating() { | |
| 237 | 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 | 247 | n := testing.AllocsPerRun(50, step) | |
| 240 | 248 | t.Logf("%s overview allocs per step = %v", name, n) | |
| 241 | 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 | 288 | // The column switcher's cards are previews too: a held switch shows | |
| 251 | 289 | // them, and a publish while their motions run costs a plain frame. | |
| 252 | 290 | t.Run("switcher", func(t *testing.T) { | |
| @@ -24,13 +24,17 @@ import ( | |||||
| 24 | // (copied only when it changed). What is left is the scenes themselves | 24 | // (copied only when it changed). What is left is the scenes themselves | ||
| 25 | // (windows, separators, the scene set). Leaving entries (a closed window | 25 | // (windows, separators, the scene set). Leaving entries (a closed window | ||
| 26 | // still fading) add none: 3, less than a frame with six moving windows | 26 | // still fading) add none: 3, less than a frame with six moving windows | ||
| 27 | - | // (fewer configure targets). The opening overview costs 18 (the stack | 27 | + | // (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; its | 28 | + | // 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 = 5 | 35 | publishAllocBudget = 5 | ||
| 32 | publishLeavingAllocBudget = 3 | 36 | publishLeavingAllocBudget = 3 | ||
| 33 | - | publishOverviewAllocBudget = 18 | 37 | + | publishOverviewAllocBudget = 7 |
| 34 | ) | 38 | ) | ||
| 35 | 39 | ||||
| 36 | // publishRig is a Core on the real clock (a mock clock would count its own | 40 | // 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, 3 | 235 | 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 shows | 288 | // 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 | |||
| 54 | 54 | } | |
| 55 | 55 | if sc.mon.ov.open { | |
| 56 | 56 | // 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)) | |
| 59 | 64 | } else if sc.mon.switcherShown() { | |
| 60 | 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 | 67 | } else { | |
| 63 | 68 | // Only the focused output lights the focused window's lines. | |
| 64 | 69 | 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) | ||