perf(core): build state, workspace and output snapshots in owner scratch
publishState, publishWorkspaces and syncOutputs built fresh snapshots on every publish only to compare them with the last sent one. They now build into reused owner-goroutine scratch and copy out only when the snapshot changed, so a sent value never shares storage with the scratch. An animation frame on two screens with the state and workspace subscribers attached: 32 -> 5 allocs, 10.7 KB -> 4.9 KB, 12.7 -> 8.7 us (BenchmarkPublishSubscribers). The publish budgets drop to 5 (frame) and 3 (leaving). TestPublishSubscribersAllocations guards the budget and the storage separation and joins perf-check. Refs: Forgejo #9, GitHub #135
- commit
- d2493da2bf9da57b95d9f40fc69d210efdd9fd0c
- author
- brice · · 2026-10-08 09:04 UTC
- committer
- brice · · 2026-10-08 09:59 UTC
- parent
- 4fd770d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +219 -32
M Makefile +1 -1
| @@ -69,7 +69,7 @@ adapter-imports: | |||
| 69 | 69 | [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; } | |
| 70 | 70 | # Fast, mandatory guards for tiled content publications and SHM copies. | |
| 71 | 71 | perf-check: | |
| 72 | - | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1 | |
| 72 | + | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1 | |
| 73 | 73 | check: vet test arch fakes-check log-check perf-check | |
| 74 | 74 | ||
| 75 | 75 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | |
| @@ -69,7 +69,7 @@ adapter-imports: | |||||
| 69 | [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; } | 69 | [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; } | ||
| 70 | # Fast, mandatory guards for tiled content publications and SHM copies. | 70 | # Fast, mandatory guards for tiled content publications and SHM copies. | ||
| 71 | perf-check: | 71 | perf-check: | ||
| 72 | - | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1 | 72 | + | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -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/core/core.go +16 -1
| @@ -75,6 +75,7 @@ type Core struct { | |||
| 75 | 75 | // realBuf backs the layout read through at once by publish (each | |
| 76 | 76 | // workspace while overviewReal is filled) and by state(); never retained. | |
| 77 | 77 | realBuf []Placement | |
| 78 | + | scratch publishScratch | |
| 78 | 79 | security ports.SecurityState | |
| 79 | 80 | lock lockState | |
| 80 | 81 | inputKeys map[string]bool | |
| @@ -149,6 +150,17 @@ type Core struct { | |||
| 149 | 150 | pulse focusPulse | |
| 150 | 151 | } | |
| 151 | 152 | ||
| 153 | + | // publishScratch is where publishState and publishWorkspaces build their | |
| 154 | + | // snapshots each publish: owner goroutine storage, never sent (a snapshot | |
| 155 | + | // is copied out of it when it changed). | |
| 156 | + | type publishScratch struct { | |
| 157 | + | state ports.State | |
| 158 | + | workspaces ports.Workspaces | |
| 159 | + | shown map[WindowID]bool | |
| 160 | + | ids []WindowID | |
| 161 | + | layout ports.Layout | |
| 162 | + | } | |
| 163 | + | ||
| 152 | 164 | // sentCache is what core last sent to wayland and on its latest-only | |
| 153 | 165 | // channels; an unchanged value is not sent again. | |
| 154 | 166 | type sentCache struct { | |
| @@ -838,10 +850,13 @@ func (c *Core) warpPointer(ctx context.Context, v ports.PointerWarp) error { | |||
| 838 | 850 | // syncOutputs sends the output layout when it changed; input learns the | |
| 839 | 851 | // geometry only when the outputs themselves moved. | |
| 840 | 852 | func (c *Core) syncOutputs(ctx context.Context) error { | |
| 841 | - | v := ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()} | |
| 853 | + | // Built in the scratch every publish, copied only when sent. | |
| 854 | + | c.scratch.layout = c.layoutInto(c.scratch.layout) | |
| 855 | + | v := ports.SetOutputs{Outputs: c.scratch.layout, Focused: c.cur().name(), Off: c.offOutputs()} | |
| 842 | 856 | if sameOutputs(v, c.sent.outputs) { | |
| 843 | 857 | return nil | |
| 844 | 858 | } | |
| 859 | + | v.Outputs = append(make(ports.Layout, 0, len(v.Outputs)), v.Outputs...) | |
| 845 | 860 | if err := c.command(ctx, v); err != nil { | |
| 846 | 861 | return err | |
| 847 | 862 | } | |
| @@ -75,6 +75,7 @@ type Core struct { | |||||
| 75 | // realBuf backs the layout read through at once by publish (each | 75 | // realBuf backs the layout read through at once by publish (each | ||
| 76 | // workspace while overviewReal is filled) and by state(); never retained. | 76 | // workspace while overviewReal is filled) and by state(); never retained. | ||
| 77 | realBuf []Placement | 77 | realBuf []Placement | ||
| 78 | + | scratch publishScratch | |||
| 78 | security ports.SecurityState | 79 | security ports.SecurityState | ||
| 79 | lock lockState | 80 | lock lockState | ||
| 80 | inputKeys map[string]bool | 81 | inputKeys map[string]bool | ||
| @@ -149,6 +150,17 @@ type Core struct { | |||||
| 149 | pulse focusPulse | 150 | pulse focusPulse | ||
| 150 | } | 151 | } | ||
| 151 | 152 | ||||
| 153 | + | // publishScratch is where publishState and publishWorkspaces build their | |||
| 154 | + | // snapshots each publish: owner goroutine storage, never sent (a snapshot | |||
| 155 | + | // is copied out of it when it changed). | |||
| 156 | + | type publishScratch struct { | |||
| 157 | + | state ports.State | |||
| 158 | + | workspaces ports.Workspaces | |||
| 159 | + | shown map[WindowID]bool | |||
| 160 | + | ids []WindowID | |||
| 161 | + | layout ports.Layout | |||
| 162 | + | } | |||
| 163 | + | ||||
| 152 | // sentCache is what core last sent to wayland and on its latest-only | 164 | // sentCache is what core last sent to wayland and on its latest-only | ||
| 153 | // channels; an unchanged value is not sent again. | 165 | // channels; an unchanged value is not sent again. | ||
| 154 | type sentCache struct { | 166 | type sentCache struct { | ||
| @@ -838,10 +850,13 @@ func (c *Core) warpPointer(ctx context.Context, v ports.PointerWarp) error { | |||||
| 838 | // syncOutputs sends the output layout when it changed; input learns the | 850 | // syncOutputs sends the output layout when it changed; input learns the | ||
| 839 | // geometry only when the outputs themselves moved. | 851 | // geometry only when the outputs themselves moved. | ||
| 840 | func (c *Core) syncOutputs(ctx context.Context) error { | 852 | func (c *Core) syncOutputs(ctx context.Context) error { | ||
| 841 | - | v := ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()} | 853 | + | // Built in the scratch every publish, copied only when sent. |
| 854 | + | c.scratch.layout = c.layoutInto(c.scratch.layout) | |||
| 855 | + | v := ports.SetOutputs{Outputs: c.scratch.layout, Focused: c.cur().name(), Off: c.offOutputs()} | |||
| 842 | if sameOutputs(v, c.sent.outputs) { | 856 | if sameOutputs(v, c.sent.outputs) { | ||
| 843 | return nil | 857 | return nil | ||
| 844 | } | 858 | } | ||
| 859 | + | v.Outputs = append(make(ports.Layout, 0, len(v.Outputs)), v.Outputs...) | |||
| 845 | if err := c.command(ctx, v); err != nil { | 860 | if err := c.command(ctx, v); err != nil { | ||
| 846 | return err | 861 | return err | ||
| 847 | } | 862 | } | ||
M internal/core/layout.go +8 -6
| @@ -305,18 +305,20 @@ func (w *Workspace) appendHidden(dst []Placement, floats bool) []Placement { | |||
| 305 | 305 | ||
| 306 | 306 | // windows lists every window: columns first, then the stash, then native | |
| 307 | 307 | // floats. | |
| 308 | - | func (w *Workspace) windows() []WindowID { | |
| 309 | - | var ids []WindowID | |
| 308 | + | func (w *Workspace) windows() []WindowID { return w.appendWindows(nil) } | |
| 309 | + | ||
| 310 | + | // appendWindows appends windows' list to dst. | |
| 311 | + | func (w *Workspace) appendWindows(dst []WindowID) []WindowID { | |
| 310 | 312 | for _, c := range w.Columns { | |
| 311 | - | ids = append(ids, c.Windows...) | |
| 313 | + | dst = append(dst, c.Windows...) | |
| 312 | 314 | } | |
| 313 | 315 | for _, f := range w.Stash { | |
| 314 | - | ids = append(ids, f.ID) | |
| 316 | + | dst = append(dst, f.ID) | |
| 315 | 317 | } | |
| 316 | 318 | for _, f := range w.Floats { | |
| 317 | - | ids = append(ids, f.ID) | |
| 319 | + | dst = append(dst, f.ID) | |
| 318 | 320 | } | |
| 319 | - | return ids | |
| 321 | + | return dst | |
| 320 | 322 | } | |
| 321 | 323 | ||
| 322 | 324 | // cell is the 1-based column and row of a tiled window, 0, 0 otherwise. | |
| @@ -305,18 +305,20 @@ func (w *Workspace) appendHidden(dst []Placement, floats bool) []Placement { | |||||
| 305 | 305 | ||||
| 306 | // windows lists every window: columns first, then the stash, then native | 306 | // windows lists every window: columns first, then the stash, then native | ||
| 307 | // floats. | 307 | // floats. | ||
| 308 | - | func (w *Workspace) windows() []WindowID { | 308 | + | func (w *Workspace) windows() []WindowID { return w.appendWindows(nil) } |
| 309 | - | var ids []WindowID | 309 | + | |
| 310 | + | // appendWindows appends windows' list to dst. | |||
| 311 | + | func (w *Workspace) appendWindows(dst []WindowID) []WindowID { | |||
| 310 | for _, c := range w.Columns { | 312 | for _, c := range w.Columns { | ||
| 311 | - | ids = append(ids, c.Windows...) | 313 | + | dst = append(dst, c.Windows...) |
| 312 | } | 314 | } | ||
| 313 | for _, f := range w.Stash { | 315 | for _, f := range w.Stash { | ||
| 314 | - | ids = append(ids, f.ID) | 316 | + | dst = append(dst, f.ID) |
| 315 | } | 317 | } | ||
| 316 | for _, f := range w.Floats { | 318 | for _, f := range w.Floats { | ||
| 317 | - | ids = append(ids, f.ID) | 319 | + | dst = append(dst, f.ID) |
| 318 | } | 320 | } | ||
| 319 | - | return ids | 321 | + | return dst |
| 320 | } | 322 | } | ||
| 321 | 323 | ||||
| 322 | // cell is the 1-based column and row of a tiled window, 0, 0 otherwise. | 324 | // cell is the 1-based column and row of a tiled window, 0, 0 otherwise. | ||
M internal/core/publish_alloc_test.go +124 -6
| @@ -2,6 +2,8 @@ package core | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "reflect" | |
| 6 | + | "slices" | |
| 5 | 7 | "testing" | |
| 6 | 8 | "time" | |
| 7 | 9 | ||
| @@ -18,15 +20,16 @@ import ( | |||
| 18 | 20 | // flip path with another screen animating keeps it and costs the same), then | |
| 19 | 21 | // 6 once the layouts are built in the owner's reused buffers (layoutInto: | |
| 20 | 22 | // the screens' layout, the workspaces' column, row and tile rects) instead of | |
| 21 | - | // fresh slices. What is left is the scenes themselves (windows, separators, | |
| 22 | - | // the scene set) and the screen placements. Leaving entries (a closed window | |
| 23 | - | // still fading) add none: 4, less than a frame with six moving windows | |
| 23 | + | // fresh slices, and 5 once the output layout is built in the scratch too | |
| 24 | + | // (copied only when it changed). What is left is the scenes themselves | |
| 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 | |
| 24 | 27 | // (fewer configure targets). The opening overview costs 18 (the stack | |
| 25 | 28 | // items, the row map and the neighbor list it rebuilds every frame; its | |
| 26 | 29 | // layouts reuse the buffers too). | |
| 27 | 30 | const ( | |
| 28 | - | publishAllocBudget = 6 | |
| 29 | - | publishLeavingAllocBudget = 4 | |
| 31 | + | publishAllocBudget = 5 | |
| 32 | + | publishLeavingAllocBudget = 3 | |
| 30 | 33 | publishOverviewAllocBudget = 18 | |
| 31 | 34 | ) | |
| 32 | 35 | ||
| @@ -34,7 +37,7 @@ const ( | |||
| 34 | 37 | // bookkeeping; the fallback timer is then the system one) with screens | |
| 35 | 38 | // screens of six windows each. The Slowdown cap keeps every motion running | |
| 36 | 39 | // for seconds, so none settles during a measure even under -race or load. | |
| 37 | - | func publishRig(t *testing.T, screens int) *Core { | |
| 40 | + | func publishRig(t testing.TB, screens int) *Core { | |
| 38 | 41 | t.Helper() | |
| 39 | 42 | var cfg ports.Config | |
| 40 | 43 | cfg.Keyboard.CmdKey = "super" | |
| @@ -302,3 +305,118 @@ func TestPublishLeavingAllocations(t *testing.T) { | |||
| 302 | 305 | t.Errorf("publish allocs per frame with leaving entries = %v, budget %d", n, publishLeavingAllocBudget) | |
| 303 | 306 | } | |
| 304 | 307 | } | |
| 308 | + | ||
| 309 | + | // TestPublishSubscribersAllocations pins the cost of an animation frame | |
| 310 | + | // with the state and workspace subscribers attached: their snapshots are | |
| 311 | + | // built in the owner's scratch and, unchanged during a camera and rect | |
| 312 | + | // animation, add nothing to the frame. Measured: 32 when each publish built | |
| 313 | + | // fresh snapshots (state, workspaces and output layout), 5 since. Sent | |
| 314 | + | // values never share storage with the scratch they were built in. | |
| 315 | + | func TestPublishSubscribersAllocations(t *testing.T) { | |
| 316 | + | c := publishRig(t, 2) | |
| 317 | + | c.ch.State = make(chan ports.State, 1) | |
| 318 | + | c.ch.Workspaces = make(chan ports.Workspaces, 1) | |
| 319 | + | t0 := time.Now() | |
| 320 | + | for _, sc := range c.screens { | |
| 321 | + | if sc.name() == "" { | |
| 322 | + | continue | |
| 323 | + | } | |
| 324 | + | ws := sc.mon.Current() | |
| 325 | + | ws.view.motion = c.spring(viewSpring(100, 0), t0) | |
| 326 | + | ws.view.off = 100 | |
| 327 | + | startRectMotions(c, sc, t0) | |
| 328 | + | } | |
| 329 | + | step := publishStep(t, c, nil) | |
| 330 | + | step() | |
| 331 | + | checkRunning(t, c, "setup") | |
| 332 | + | if len(c.ch.State) != 1 || len(c.ch.Workspaces) != 1 { | |
| 333 | + | t.Fatal("setup: no snapshot sent") | |
| 334 | + | } | |
| 335 | + | st := <-c.ch.State | |
| 336 | + | ws := <-c.ch.Workspaces | |
| 337 | + | n := testing.AllocsPerRun(50, step) | |
| 338 | + | t.Logf("subscribers allocs per step = %v", n) | |
| 339 | + | checkRunning(t, c, "motions settled during the measure") | |
| 340 | + | if n > publishAllocBudget { | |
| 341 | + | t.Errorf("publish allocs per frame with subscribers = %v, budget %d", n, publishAllocBudget) | |
| 342 | + | } | |
| 343 | + | if len(c.ch.State) != 0 || len(c.ch.Workspaces) != 0 { | |
| 344 | + | t.Error("an unchanged snapshot was sent again") | |
| 345 | + | } | |
| 346 | + | // Sent values own their storage: the next publish rewrites the scratch | |
| 347 | + | // in place. | |
| 348 | + | sc := &c.scratch | |
| 349 | + | if st.Window == nil || len(st.Windows) == 0 || len(ws.Outputs) == 0 || len(c.sent.outputs.Outputs) == 0 { | |
| 350 | + | t.Fatalf("setup: empty snapshots %+v %+v", st, ws) | |
| 351 | + | } | |
| 352 | + | if &st.Windows[0] == &sc.state.Windows[:1][0] || &st.Outputs[0] == &sc.state.Outputs[:1][0] || inSlice(st.Window, sc.state.Windows) || inSlice(st.Window, st.Windows) { | |
| 353 | + | t.Error("the sent state shares the scratch") | |
| 354 | + | } | |
| 355 | + | for _, sent := range []ports.Workspaces{ws, c.sent.workspaces} { | |
| 356 | + | if &sent.Outputs[0] == &sc.workspaces.Outputs[:1][0] { | |
| 357 | + | t.Error("the sent workspaces share the scratch") | |
| 358 | + | } | |
| 359 | + | for k, o := range sent.Outputs { | |
| 360 | + | if &o.Workspaces[0] == &sc.workspaces.Outputs[k].Workspaces[:1][0] { | |
| 361 | + | t.Errorf("the sent workspaces of %s share the scratch", o.Name) | |
| 362 | + | } | |
| 363 | + | } | |
| 364 | + | } | |
| 365 | + | if &c.sent.outputs.Outputs[0] == &sc.layout[:1][0] { | |
| 366 | + | t.Error("the sent output layout shares the scratch") | |
| 367 | + | } | |
| 368 | + | // And a later change leaves them as they were. | |
| 369 | + | want := ports.State{Output: st.Output, Outputs: slices.Clone(st.Outputs), Windows: slices.Clone(st.Windows)} | |
| 370 | + | w := *st.Window | |
| 371 | + | want.Window = &w | |
| 372 | + | c.windows.setIdleInhibit(st.Windows[0].ID, true) | |
| 373 | + | c.publishState() | |
| 374 | + | // A sent value aliasing the scratch would always equal it: nothing sent. | |
| 375 | + | select { | |
| 376 | + | case got := <-c.ch.State: | |
| 377 | + | if !got.Windows[0].IdleInhibit { | |
| 378 | + | t.Fatal("change not published") | |
| 379 | + | } | |
| 380 | + | default: | |
| 381 | + | t.Fatal("change not published") | |
| 382 | + | } | |
| 383 | + | if !reflect.DeepEqual(st, want) { | |
| 384 | + | t.Fatal("a sent snapshot changed after a later publish") | |
| 385 | + | } | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | func inSlice[T any](p *T, s []T) bool { | |
| 389 | + | s = s[:cap(s)] | |
| 390 | + | for i := range s { | |
| 391 | + | if p == &s[i] { | |
| 392 | + | return true | |
| 393 | + | } | |
| 394 | + | } | |
| 395 | + | return false | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | // BenchmarkPublishSubscribers is one animation frame on two screens with | |
| 399 | + | // the state and workspace subscribers attached. | |
| 400 | + | func BenchmarkPublishSubscribers(b *testing.B) { | |
| 401 | + | c := publishRig(b, 2) | |
| 402 | + | c.ch.State = make(chan ports.State, 1) | |
| 403 | + | c.ch.Workspaces = make(chan ports.Workspaces, 1) | |
| 404 | + | t0 := time.Now() | |
| 405 | + | for _, sc := range c.screens { | |
| 406 | + | if sc.name() == "" { | |
| 407 | + | continue | |
| 408 | + | } | |
| 409 | + | ws := sc.mon.Current() | |
| 410 | + | ws.view.motion = c.spring(viewSpring(100, 0), t0) | |
| 411 | + | ws.view.off = 100 | |
| 412 | + | startRectMotions(c, sc, t0) | |
| 413 | + | } | |
| 414 | + | ctx := context.Background() | |
| 415 | + | b.ReportAllocs() | |
| 416 | + | for b.Loop() { | |
| 417 | + | if err := c.step(ctx, nil); err != nil { | |
| 418 | + | b.Fatal(err) | |
| 419 | + | } | |
| 420 | + | <-c.ch.Scenes | |
| 421 | + | } | |
| 422 | + | } | |
| @@ -2,6 +2,8 @@ package core | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "reflect" | |||
| 6 | + | "slices" | |||
| 5 | "testing" | 7 | "testing" | ||
| 6 | "time" | 8 | "time" | ||
| 7 | 9 | ||||
| @@ -18,15 +20,16 @@ import ( | |||||
| 18 | // flip path with another screen animating keeps it and costs the same), then | 20 | // flip path with another screen animating keeps it and costs the same), then | ||
| 19 | // 6 once the layouts are built in the owner's reused buffers (layoutInto: | 21 | // 6 once the layouts are built in the owner's reused buffers (layoutInto: | ||
| 20 | // the screens' layout, the workspaces' column, row and tile rects) instead of | 22 | // the screens' layout, the workspaces' column, row and tile rects) instead of | ||
| 21 | - | // fresh slices. What is left is the scenes themselves (windows, separators, | 23 | + | // fresh slices, and 5 once the output layout is built in the scratch too |
| 22 | - | // the scene set) and the screen placements. Leaving entries (a closed window | 24 | + | // (copied only when it changed). What is left is the scenes themselves |
| 23 | - | // still fading) add none: 4, less than a frame with six moving windows | 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 | |||
| 24 | // (fewer configure targets). The opening overview costs 18 (the stack | 27 | // (fewer configure targets). The opening overview costs 18 (the stack | ||
| 25 | // items, the row map and the neighbor list it rebuilds every frame; its | 28 | // items, the row map and the neighbor list it rebuilds every frame; its | ||
| 26 | // layouts reuse the buffers too). | 29 | // layouts reuse the buffers too). | ||
| 27 | const ( | 30 | const ( | ||
| 28 | - | publishAllocBudget = 6 | 31 | + | publishAllocBudget = 5 |
| 29 | - | publishLeavingAllocBudget = 4 | 32 | + | publishLeavingAllocBudget = 3 |
| 30 | publishOverviewAllocBudget = 18 | 33 | publishOverviewAllocBudget = 18 | ||
| 31 | ) | 34 | ) | ||
| 32 | 35 | ||||
| @@ -34,7 +37,7 @@ const ( | |||||
| 34 | // bookkeeping; the fallback timer is then the system one) with screens | 37 | // bookkeeping; the fallback timer is then the system one) with screens | ||
| 35 | // screens of six windows each. The Slowdown cap keeps every motion running | 38 | // screens of six windows each. The Slowdown cap keeps every motion running | ||
| 36 | // for seconds, so none settles during a measure even under -race or load. | 39 | // for seconds, so none settles during a measure even under -race or load. | ||
| 37 | - | func publishRig(t *testing.T, screens int) *Core { | 40 | + | func publishRig(t testing.TB, screens int) *Core { |
| 38 | t.Helper() | 41 | t.Helper() | ||
| 39 | var cfg ports.Config | 42 | var cfg ports.Config | ||
| 40 | cfg.Keyboard.CmdKey = "super" | 43 | cfg.Keyboard.CmdKey = "super" | ||
| @@ -302,3 +305,118 @@ func TestPublishLeavingAllocations(t *testing.T) { | |||||
| 302 | t.Errorf("publish allocs per frame with leaving entries = %v, budget %d", n, publishLeavingAllocBudget) | 305 | t.Errorf("publish allocs per frame with leaving entries = %v, budget %d", n, publishLeavingAllocBudget) | ||
| 303 | } | 306 | } | ||
| 304 | } | 307 | } | ||
| 308 | + | ||||
| 309 | + | // TestPublishSubscribersAllocations pins the cost of an animation frame | |||
| 310 | + | // with the state and workspace subscribers attached: their snapshots are | |||
| 311 | + | // built in the owner's scratch and, unchanged during a camera and rect | |||
| 312 | + | // animation, add nothing to the frame. Measured: 32 when each publish built | |||
| 313 | + | // fresh snapshots (state, workspaces and output layout), 5 since. Sent | |||
| 314 | + | // values never share storage with the scratch they were built in. | |||
| 315 | + | func TestPublishSubscribersAllocations(t *testing.T) { | |||
| 316 | + | c := publishRig(t, 2) | |||
| 317 | + | c.ch.State = make(chan ports.State, 1) | |||
| 318 | + | c.ch.Workspaces = make(chan ports.Workspaces, 1) | |||
| 319 | + | t0 := time.Now() | |||
| 320 | + | for _, sc := range c.screens { | |||
| 321 | + | if sc.name() == "" { | |||
| 322 | + | continue | |||
| 323 | + | } | |||
| 324 | + | ws := sc.mon.Current() | |||
| 325 | + | ws.view.motion = c.spring(viewSpring(100, 0), t0) | |||
| 326 | + | ws.view.off = 100 | |||
| 327 | + | startRectMotions(c, sc, t0) | |||
| 328 | + | } | |||
| 329 | + | step := publishStep(t, c, nil) | |||
| 330 | + | step() | |||
| 331 | + | checkRunning(t, c, "setup") | |||
| 332 | + | if len(c.ch.State) != 1 || len(c.ch.Workspaces) != 1 { | |||
| 333 | + | t.Fatal("setup: no snapshot sent") | |||
| 334 | + | } | |||
| 335 | + | st := <-c.ch.State | |||
| 336 | + | ws := <-c.ch.Workspaces | |||
| 337 | + | n := testing.AllocsPerRun(50, step) | |||
| 338 | + | t.Logf("subscribers allocs per step = %v", n) | |||
| 339 | + | checkRunning(t, c, "motions settled during the measure") | |||
| 340 | + | if n > publishAllocBudget { | |||
| 341 | + | t.Errorf("publish allocs per frame with subscribers = %v, budget %d", n, publishAllocBudget) | |||
| 342 | + | } | |||
| 343 | + | if len(c.ch.State) != 0 || len(c.ch.Workspaces) != 0 { | |||
| 344 | + | t.Error("an unchanged snapshot was sent again") | |||
| 345 | + | } | |||
| 346 | + | // Sent values own their storage: the next publish rewrites the scratch | |||
| 347 | + | // in place. | |||
| 348 | + | sc := &c.scratch | |||
| 349 | + | if st.Window == nil || len(st.Windows) == 0 || len(ws.Outputs) == 0 || len(c.sent.outputs.Outputs) == 0 { | |||
| 350 | + | t.Fatalf("setup: empty snapshots %+v %+v", st, ws) | |||
| 351 | + | } | |||
| 352 | + | if &st.Windows[0] == &sc.state.Windows[:1][0] || &st.Outputs[0] == &sc.state.Outputs[:1][0] || inSlice(st.Window, sc.state.Windows) || inSlice(st.Window, st.Windows) { | |||
| 353 | + | t.Error("the sent state shares the scratch") | |||
| 354 | + | } | |||
| 355 | + | for _, sent := range []ports.Workspaces{ws, c.sent.workspaces} { | |||
| 356 | + | if &sent.Outputs[0] == &sc.workspaces.Outputs[:1][0] { | |||
| 357 | + | t.Error("the sent workspaces share the scratch") | |||
| 358 | + | } | |||
| 359 | + | for k, o := range sent.Outputs { | |||
| 360 | + | if &o.Workspaces[0] == &sc.workspaces.Outputs[k].Workspaces[:1][0] { | |||
| 361 | + | t.Errorf("the sent workspaces of %s share the scratch", o.Name) | |||
| 362 | + | } | |||
| 363 | + | } | |||
| 364 | + | } | |||
| 365 | + | if &c.sent.outputs.Outputs[0] == &sc.layout[:1][0] { | |||
| 366 | + | t.Error("the sent output layout shares the scratch") | |||
| 367 | + | } | |||
| 368 | + | // And a later change leaves them as they were. | |||
| 369 | + | want := ports.State{Output: st.Output, Outputs: slices.Clone(st.Outputs), Windows: slices.Clone(st.Windows)} | |||
| 370 | + | w := *st.Window | |||
| 371 | + | want.Window = &w | |||
| 372 | + | c.windows.setIdleInhibit(st.Windows[0].ID, true) | |||
| 373 | + | c.publishState() | |||
| 374 | + | // A sent value aliasing the scratch would always equal it: nothing sent. | |||
| 375 | + | select { | |||
| 376 | + | case got := <-c.ch.State: | |||
| 377 | + | if !got.Windows[0].IdleInhibit { | |||
| 378 | + | t.Fatal("change not published") | |||
| 379 | + | } | |||
| 380 | + | default: | |||
| 381 | + | t.Fatal("change not published") | |||
| 382 | + | } | |||
| 383 | + | if !reflect.DeepEqual(st, want) { | |||
| 384 | + | t.Fatal("a sent snapshot changed after a later publish") | |||
| 385 | + | } | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | func inSlice[T any](p *T, s []T) bool { | |||
| 389 | + | s = s[:cap(s)] | |||
| 390 | + | for i := range s { | |||
| 391 | + | if p == &s[i] { | |||
| 392 | + | return true | |||
| 393 | + | } | |||
| 394 | + | } | |||
| 395 | + | return false | |||
| 396 | + | } | |||
| 397 | + | ||||
| 398 | + | // BenchmarkPublishSubscribers is one animation frame on two screens with | |||
| 399 | + | // the state and workspace subscribers attached. | |||
| 400 | + | func BenchmarkPublishSubscribers(b *testing.B) { | |||
| 401 | + | c := publishRig(b, 2) | |||
| 402 | + | c.ch.State = make(chan ports.State, 1) | |||
| 403 | + | c.ch.Workspaces = make(chan ports.Workspaces, 1) | |||
| 404 | + | t0 := time.Now() | |||
| 405 | + | for _, sc := range c.screens { | |||
| 406 | + | if sc.name() == "" { | |||
| 407 | + | continue | |||
| 408 | + | } | |||
| 409 | + | ws := sc.mon.Current() | |||
| 410 | + | ws.view.motion = c.spring(viewSpring(100, 0), t0) | |||
| 411 | + | ws.view.off = 100 | |||
| 412 | + | startRectMotions(c, sc, t0) | |||
| 413 | + | } | |||
| 414 | + | ctx := context.Background() | |||
| 415 | + | b.ReportAllocs() | |||
| 416 | + | for b.Loop() { | |||
| 417 | + | if err := c.step(ctx, nil); err != nil { | |||
| 418 | + | b.Fatal(err) | |||
| 419 | + | } | |||
| 420 | + | <-c.ch.Scenes | |||
| 421 | + | } | |||
| 422 | + | } | |||
M internal/core/screens.go +5 -2
| @@ -157,8 +157,11 @@ func (c *Core) outputConfig(name string) (ports.OutputConfig, bool) { | |||
| 157 | 157 | } | |
| 158 | 158 | ||
| 159 | 159 | // layout lists the connected outputs; the placeholder is not one. | |
| 160 | - | func (c *Core) layout() ports.Layout { | |
| 161 | - | l := make(ports.Layout, 0, len(c.screens)) | |
| 160 | + | func (c *Core) layout() ports.Layout { return c.layoutInto(make(ports.Layout, 0, len(c.screens))) } | |
| 161 | + | ||
| 162 | + | // layoutInto is layout built in l[:0]: the result aliases l's storage. | |
| 163 | + | func (c *Core) layoutInto(l ports.Layout) ports.Layout { | |
| 164 | + | l = l[:0] | |
| 162 | 165 | for _, s := range c.screens { | |
| 163 | 166 | if s.name() != "" { | |
| 164 | 167 | l = append(l, s.placement()) | |
| @@ -157,8 +157,11 @@ func (c *Core) outputConfig(name string) (ports.OutputConfig, bool) { | |||||
| 157 | } | 157 | } | ||
| 158 | 158 | ||||
| 159 | // layout lists the connected outputs; the placeholder is not one. | 159 | // layout lists the connected outputs; the placeholder is not one. | ||
| 160 | - | func (c *Core) layout() ports.Layout { | 160 | + | func (c *Core) layout() ports.Layout { return c.layoutInto(make(ports.Layout, 0, len(c.screens))) } |
| 161 | - | l := make(ports.Layout, 0, len(c.screens)) | 161 | + | |
| 162 | + | // layoutInto is layout built in l[:0]: the result aliases l's storage. | |||
| 163 | + | func (c *Core) layoutInto(l ports.Layout) ports.Layout { | |||
| 164 | + | l = l[:0] | |||
| 162 | for _, s := range c.screens { | 165 | for _, s := range c.screens { | ||
| 163 | if s.name() != "" { | 166 | if s.name() != "" { | ||
| 164 | l = append(l, s.placement()) | 167 | l = append(l, s.placement()) | ||
M internal/core/state.go +40 -11
| @@ -2,28 +2,41 @@ package core | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "cmp" | |
| 5 | - | "reflect" | |
| 6 | 5 | "slices" | |
| 7 | 6 | ||
| 8 | 7 | "github.com/bnema/neferwl/internal/ports" | |
| 9 | 8 | ) | |
| 10 | 9 | ||
| 11 | - | // publishState sends the script-facing snapshot when it changed. | |
| 10 | + | // publishState sends the script-facing snapshot when it changed. It is | |
| 11 | + | // built in the owner's scratch every publish and copied only when sent: an | |
| 12 | + | // animation frame that changes nothing allocates nothing here. | |
| 12 | 13 | func (c *Core) publishState() { | |
| 13 | 14 | if c.ch.State == nil { | |
| 14 | 15 | return | |
| 15 | 16 | } | |
| 16 | - | st := c.state() | |
| 17 | - | if reflect.DeepEqual(st, c.sent.state) { | |
| 17 | + | c.scratch.state = c.stateInto(c.scratch.state) | |
| 18 | + | // The first snapshot is always sent, even an empty one. | |
| 19 | + | if c.sent.state.Outputs != nil && sameState(c.scratch.state, c.sent.state) { | |
| 18 | 20 | return | |
| 19 | 21 | } | |
| 22 | + | st := cloneState(c.scratch.state) | |
| 20 | 23 | c.sent.state = st | |
| 21 | 24 | latest(c.ch.State, st) | |
| 22 | 25 | } | |
| 23 | 26 | ||
| 24 | - | // state builds the snapshot. The placeholder screen is not an output. | |
| 25 | - | func (c *Core) state() ports.State { | |
| 26 | - | st := ports.State{Output: c.cur().name(), Outputs: []ports.OutputState{}, Windows: []ports.WindowState{}} | |
| 27 | + | // state is a fresh snapshot, the caller's to keep. | |
| 28 | + | func (c *Core) state() ports.State { return cloneState(c.stateInto(ports.State{})) } | |
| 29 | + | ||
| 30 | + | // stateInto builds the snapshot in st's storage: the result aliases it and | |
| 31 | + | // its Window points into its Windows. The placeholder screen is not an | |
| 32 | + | // output. | |
| 33 | + | func (c *Core) stateInto(st ports.State) ports.State { | |
| 34 | + | st.Output, st.Window = c.cur().name(), nil | |
| 35 | + | st.Outputs, st.Windows = st.Outputs[:0], st.Windows[:0] | |
| 36 | + | if c.scratch.shown == nil { | |
| 37 | + | c.scratch.shown = map[WindowID]bool{} | |
| 38 | + | } | |
| 39 | + | shown := c.scratch.shown | |
| 27 | 40 | focus, _ := c.cur().mon.Focused() | |
| 28 | 41 | for _, s := range c.screens { | |
| 29 | 42 | if s.name() == "" { | |
| @@ -37,7 +50,7 @@ func (c *Core) state() ports.State { | |||
| 37 | 50 | } | |
| 38 | 51 | st.Outputs = append(st.Outputs, o) | |
| 39 | 52 | // Visible follows the configures: drawn on the output. | |
| 40 | - | shown := map[WindowID]bool{} | |
| 53 | + | clear(shown) | |
| 41 | 54 | // Read at once, into publish's scratch: the snapshot holds no | |
| 42 | 55 | // placement. | |
| 43 | 56 | c.realBuf = m.layoutInto(c.realBuf) | |
| @@ -46,7 +59,8 @@ func (c *Core) state() ports.State { | |||
| 46 | 59 | } | |
| 47 | 60 | for w := range m.all() { | |
| 48 | 61 | n := indexOf(m.Workspaces, w) + 1 | |
| 49 | - | for _, id := range w.windows() { | |
| 62 | + | c.scratch.ids = w.appendWindows(c.scratch.ids[:0]) | |
| 63 | + | for _, id := range c.scratch.ids { | |
| 50 | 64 | rec := c.windows.lookup(id) | |
| 51 | 65 | v := ports.WindowState{ID: id, AppID: rec.client.AppID, PID: rec.client.PID, Output: s.name(), Workspace: n, WorkspaceID: w.ID, WorkspaceName: w.Name, Visible: shown[id], IdleInhibit: rec.idleInhibit, Floating: w.isFloat(id)} | |
| 52 | 66 | if i := w.stashIndex(id); i >= 0 { | |
| @@ -62,13 +76,28 @@ func (c *Core) state() ports.State { | |||
| 62 | 76 | slices.SortFunc(st.Windows, func(a, b ports.WindowState) int { return cmp.Compare(a.ID, b.ID) }) | |
| 63 | 77 | for i := range st.Windows { | |
| 64 | 78 | if st.Windows[i].ID == focus && focus != 0 { | |
| 65 | - | w := st.Windows[i] | |
| 66 | - | st.Window = &w | |
| 79 | + | st.Window = &st.Windows[i] | |
| 67 | 80 | } | |
| 68 | 81 | } | |
| 69 | 82 | return st | |
| 70 | 83 | } | |
| 71 | 84 | ||
| 85 | + | func sameState(a, b ports.State) bool { | |
| 86 | + | return a.Output == b.Output && slices.Equal(a.Outputs, b.Outputs) && slices.Equal(a.Windows, b.Windows) && | |
| 87 | + | (a.Window == nil) == (b.Window == nil) && (a.Window == nil || *a.Window == *b.Window) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // cloneState copies st out of the scratch: empty lists stay non-nil (JSON | |
| 91 | + | // []), and Window is its own copy. | |
| 92 | + | func cloneState(st ports.State) ports.State { | |
| 93 | + | out := ports.State{Output: st.Output, Outputs: append([]ports.OutputState{}, st.Outputs...), Windows: append([]ports.WindowState{}, st.Windows...)} | |
| 94 | + | if st.Window != nil { | |
| 95 | + | w := *st.Window | |
| 96 | + | out.Window = &w | |
| 97 | + | } | |
| 98 | + | return out | |
| 99 | + | } | |
| 100 | + | ||
| 72 | 101 | // numbered counts numbered workspaces, without the trailing spare one | |
| 73 | 102 | // unless it is on screen. | |
| 74 | 103 | func numbered(m *Monitor) int { | |
| @@ -2,28 +2,41 @@ package core | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "cmp" | 4 | "cmp" | ||
| 5 | - | "reflect" | |||
| 6 | "slices" | 5 | "slices" | ||
| 7 | 6 | ||||
| 8 | "github.com/bnema/neferwl/internal/ports" | 7 | "github.com/bnema/neferwl/internal/ports" | ||
| 9 | ) | 8 | ) | ||
| 10 | 9 | ||||
| 11 | - | // publishState sends the script-facing snapshot when it changed. | 10 | + | // publishState sends the script-facing snapshot when it changed. It is |
| 11 | + | // built in the owner's scratch every publish and copied only when sent: an | |||
| 12 | + | // animation frame that changes nothing allocates nothing here. | |||
| 12 | func (c *Core) publishState() { | 13 | func (c *Core) publishState() { | ||
| 13 | if c.ch.State == nil { | 14 | if c.ch.State == nil { | ||
| 14 | return | 15 | return | ||
| 15 | } | 16 | } | ||
| 16 | - | st := c.state() | 17 | + | c.scratch.state = c.stateInto(c.scratch.state) |
| 17 | - | if reflect.DeepEqual(st, c.sent.state) { | 18 | + | // The first snapshot is always sent, even an empty one. |
| 19 | + | if c.sent.state.Outputs != nil && sameState(c.scratch.state, c.sent.state) { | |||
| 18 | return | 20 | return | ||
| 19 | } | 21 | } | ||
| 22 | + | st := cloneState(c.scratch.state) | |||
| 20 | c.sent.state = st | 23 | c.sent.state = st | ||
| 21 | latest(c.ch.State, st) | 24 | latest(c.ch.State, st) | ||
| 22 | } | 25 | } | ||
| 23 | 26 | ||||
| 24 | - | // state builds the snapshot. The placeholder screen is not an output. | 27 | + | // state is a fresh snapshot, the caller's to keep. |
| 25 | - | func (c *Core) state() ports.State { | 28 | + | func (c *Core) state() ports.State { return cloneState(c.stateInto(ports.State{})) } |
| 26 | - | st := ports.State{Output: c.cur().name(), Outputs: []ports.OutputState{}, Windows: []ports.WindowState{}} | 29 | + | |
| 30 | + | // stateInto builds the snapshot in st's storage: the result aliases it and | |||
| 31 | + | // its Window points into its Windows. The placeholder screen is not an | |||
| 32 | + | // output. | |||
| 33 | + | func (c *Core) stateInto(st ports.State) ports.State { | |||
| 34 | + | st.Output, st.Window = c.cur().name(), nil | |||
| 35 | + | st.Outputs, st.Windows = st.Outputs[:0], st.Windows[:0] | |||
| 36 | + | if c.scratch.shown == nil { | |||
| 37 | + | c.scratch.shown = map[WindowID]bool{} | |||
| 38 | + | } | |||
| 39 | + | shown := c.scratch.shown | |||
| 27 | focus, _ := c.cur().mon.Focused() | 40 | focus, _ := c.cur().mon.Focused() | ||
| 28 | for _, s := range c.screens { | 41 | for _, s := range c.screens { | ||
| 29 | if s.name() == "" { | 42 | if s.name() == "" { | ||
| @@ -37,7 +50,7 @@ func (c *Core) state() ports.State { | |||||
| 37 | } | 50 | } | ||
| 38 | st.Outputs = append(st.Outputs, o) | 51 | st.Outputs = append(st.Outputs, o) | ||
| 39 | // Visible follows the configures: drawn on the output. | 52 | // Visible follows the configures: drawn on the output. | ||
| 40 | - | shown := map[WindowID]bool{} | 53 | + | clear(shown) |
| 41 | // Read at once, into publish's scratch: the snapshot holds no | 54 | // Read at once, into publish's scratch: the snapshot holds no | ||
| 42 | // placement. | 55 | // placement. | ||
| 43 | c.realBuf = m.layoutInto(c.realBuf) | 56 | c.realBuf = m.layoutInto(c.realBuf) | ||
| @@ -46,7 +59,8 @@ func (c *Core) state() ports.State { | |||||
| 46 | } | 59 | } | ||
| 47 | for w := range m.all() { | 60 | for w := range m.all() { | ||
| 48 | n := indexOf(m.Workspaces, w) + 1 | 61 | n := indexOf(m.Workspaces, w) + 1 | ||
| 49 | - | for _, id := range w.windows() { | 62 | + | c.scratch.ids = w.appendWindows(c.scratch.ids[:0]) |
| 63 | + | for _, id := range c.scratch.ids { | |||
| 50 | rec := c.windows.lookup(id) | 64 | rec := c.windows.lookup(id) | ||
| 51 | v := ports.WindowState{ID: id, AppID: rec.client.AppID, PID: rec.client.PID, Output: s.name(), Workspace: n, WorkspaceID: w.ID, WorkspaceName: w.Name, Visible: shown[id], IdleInhibit: rec.idleInhibit, Floating: w.isFloat(id)} | 65 | v := ports.WindowState{ID: id, AppID: rec.client.AppID, PID: rec.client.PID, Output: s.name(), Workspace: n, WorkspaceID: w.ID, WorkspaceName: w.Name, Visible: shown[id], IdleInhibit: rec.idleInhibit, Floating: w.isFloat(id)} | ||
| 52 | if i := w.stashIndex(id); i >= 0 { | 66 | if i := w.stashIndex(id); i >= 0 { | ||
| @@ -62,13 +76,28 @@ func (c *Core) state() ports.State { | |||||
| 62 | slices.SortFunc(st.Windows, func(a, b ports.WindowState) int { return cmp.Compare(a.ID, b.ID) }) | 76 | slices.SortFunc(st.Windows, func(a, b ports.WindowState) int { return cmp.Compare(a.ID, b.ID) }) | ||
| 63 | for i := range st.Windows { | 77 | for i := range st.Windows { | ||
| 64 | if st.Windows[i].ID == focus && focus != 0 { | 78 | if st.Windows[i].ID == focus && focus != 0 { | ||
| 65 | - | w := st.Windows[i] | 79 | + | st.Window = &st.Windows[i] |
| 66 | - | st.Window = &w | |||
| 67 | } | 80 | } | ||
| 68 | } | 81 | } | ||
| 69 | return st | 82 | return st | ||
| 70 | } | 83 | } | ||
| 71 | 84 | ||||
| 85 | + | func sameState(a, b ports.State) bool { | |||
| 86 | + | return a.Output == b.Output && slices.Equal(a.Outputs, b.Outputs) && slices.Equal(a.Windows, b.Windows) && | |||
| 87 | + | (a.Window == nil) == (b.Window == nil) && (a.Window == nil || *a.Window == *b.Window) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // cloneState copies st out of the scratch: empty lists stay non-nil (JSON | |||
| 91 | + | // []), and Window is its own copy. | |||
| 92 | + | func cloneState(st ports.State) ports.State { | |||
| 93 | + | out := ports.State{Output: st.Output, Outputs: append([]ports.OutputState{}, st.Outputs...), Windows: append([]ports.WindowState{}, st.Windows...)} | |||
| 94 | + | if st.Window != nil { | |||
| 95 | + | w := *st.Window | |||
| 96 | + | out.Window = &w | |||
| 97 | + | } | |||
| 98 | + | return out | |||
| 99 | + | } | |||
| 100 | + | ||||
| 72 | // numbered counts numbered workspaces, without the trailing spare one | 101 | // numbered counts numbered workspaces, without the trailing spare one | ||
| 73 | // unless it is on screen. | 102 | // unless it is on screen. | ||
| 74 | func numbered(m *Monitor) int { | 103 | func numbered(m *Monitor) int { | ||
M internal/core/workspaces.go +25 -5
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "reflect" | |
| 4 | + | "slices" | |
| 5 | 5 | "strconv" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/neferwl/internal/ports" | |
| @@ -10,17 +10,25 @@ import ( | |||
| 10 | 10 | // publishWorkspaces describes the numbered vertical stack and configured hidden | |
| 11 | 11 | // workspaces. Config-hidden named workspaces and empty numbered spares are | |
| 12 | 12 | // hidden unless shown; unlike State, hidden named workspaces remain addressable. | |
| 13 | + | // Like publishState, it is built in the owner's scratch and copied only | |
| 14 | + | // when sent. | |
| 13 | 15 | func (c *Core) publishWorkspaces() { | |
| 14 | 16 | if c.ch.Workspaces == nil { | |
| 15 | 17 | return | |
| 16 | 18 | } | |
| 17 | - | snapshot := ports.Workspaces{Outputs: []ports.WorkspaceOutput{}} | |
| 19 | + | buf := &c.scratch.workspaces | |
| 20 | + | buf.Outputs = buf.Outputs[:0] | |
| 18 | 21 | for _, sc := range c.screens { | |
| 19 | 22 | if sc.name() == "" { | |
| 20 | 23 | continue | |
| 21 | 24 | } | |
| 22 | 25 | m := sc.mon | |
| 23 | - | out := ports.WorkspaceOutput{Name: sc.name(), Workspaces: []ports.WorkspaceInfo{}} | |
| 26 | + | // Reuse the list of the entry this one overwrites. | |
| 27 | + | var list []ports.WorkspaceInfo | |
| 28 | + | if n := len(buf.Outputs); n < cap(buf.Outputs) { | |
| 29 | + | list = buf.Outputs[:n+1][n].Workspaces[:0] | |
| 30 | + | } | |
| 31 | + | out := ports.WorkspaceOutput{Name: sc.name(), Workspaces: list} | |
| 24 | 32 | i := -1 | |
| 25 | 33 | for w := range m.all() { | |
| 26 | 34 | i++ | |
| @@ -30,11 +38,23 @@ func (c *Core) publishWorkspaces() { | |||
| 30 | 38 | } | |
| 31 | 39 | out.Workspaces = append(out.Workspaces, ports.WorkspaceInfo{ID: w.ID, Name: name, Index: i, Active: m.Current() == w, Hidden: m.Current() != w && (m.isHidden(w) || w.empty()), Configured: w.Name, Frame: w.Output}) | |
| 32 | 40 | } | |
| 33 | - | snapshot.Outputs = append(snapshot.Outputs, out) | |
| 41 | + | buf.Outputs = append(buf.Outputs, out) | |
| 34 | 42 | } | |
| 35 | - | if reflect.DeepEqual(snapshot, c.sent.workspaces) { | |
| 43 | + | // The first snapshot is always sent, even an empty one. | |
| 44 | + | if c.sent.workspaces.Outputs != nil && sameWorkspaces(*buf, c.sent.workspaces) { | |
| 36 | 45 | return | |
| 37 | 46 | } | |
| 47 | + | // Empty lists stay non-nil (JSON []). | |
| 48 | + | snapshot := ports.Workspaces{Outputs: make([]ports.WorkspaceOutput, len(buf.Outputs))} | |
| 49 | + | for k, o := range buf.Outputs { | |
| 50 | + | snapshot.Outputs[k] = ports.WorkspaceOutput{Name: o.Name, Workspaces: append([]ports.WorkspaceInfo{}, o.Workspaces...)} | |
| 51 | + | } | |
| 38 | 52 | c.sent.workspaces = snapshot | |
| 39 | 53 | latest(c.ch.Workspaces, snapshot) | |
| 40 | 54 | } | |
| 55 | + | ||
| 56 | + | func sameWorkspaces(a, b ports.Workspaces) bool { | |
| 57 | + | return slices.EqualFunc(a.Outputs, b.Outputs, func(x, y ports.WorkspaceOutput) bool { | |
| 58 | + | return x.Name == y.Name && slices.Equal(x.Workspaces, y.Workspaces) | |
| 59 | + | }) | |
| 60 | + | } | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | package core | 1 | package core | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "reflect" | 4 | + | "slices" |
| 5 | "strconv" | 5 | "strconv" | ||
| 6 | 6 | ||||
| 7 | "github.com/bnema/neferwl/internal/ports" | 7 | "github.com/bnema/neferwl/internal/ports" | ||
| @@ -10,17 +10,25 @@ import ( | |||||
| 10 | // publishWorkspaces describes the numbered vertical stack and configured hidden | 10 | // publishWorkspaces describes the numbered vertical stack and configured hidden | ||
| 11 | // workspaces. Config-hidden named workspaces and empty numbered spares are | 11 | // workspaces. Config-hidden named workspaces and empty numbered spares are | ||
| 12 | // hidden unless shown; unlike State, hidden named workspaces remain addressable. | 12 | // hidden unless shown; unlike State, hidden named workspaces remain addressable. | ||
| 13 | + | // Like publishState, it is built in the owner's scratch and copied only | |||
| 14 | + | // when sent. | |||
| 13 | func (c *Core) publishWorkspaces() { | 15 | func (c *Core) publishWorkspaces() { | ||
| 14 | if c.ch.Workspaces == nil { | 16 | if c.ch.Workspaces == nil { | ||
| 15 | return | 17 | return | ||
| 16 | } | 18 | } | ||
| 17 | - | snapshot := ports.Workspaces{Outputs: []ports.WorkspaceOutput{}} | 19 | + | buf := &c.scratch.workspaces |
| 20 | + | buf.Outputs = buf.Outputs[:0] | |||
| 18 | for _, sc := range c.screens { | 21 | for _, sc := range c.screens { | ||
| 19 | if sc.name() == "" { | 22 | if sc.name() == "" { | ||
| 20 | continue | 23 | continue | ||
| 21 | } | 24 | } | ||
| 22 | m := sc.mon | 25 | m := sc.mon | ||
| 23 | - | out := ports.WorkspaceOutput{Name: sc.name(), Workspaces: []ports.WorkspaceInfo{}} | 26 | + | // Reuse the list of the entry this one overwrites. |
| 27 | + | var list []ports.WorkspaceInfo | |||
| 28 | + | if n := len(buf.Outputs); n < cap(buf.Outputs) { | |||
| 29 | + | list = buf.Outputs[:n+1][n].Workspaces[:0] | |||
| 30 | + | } | |||
| 31 | + | out := ports.WorkspaceOutput{Name: sc.name(), Workspaces: list} | |||
| 24 | i := -1 | 32 | i := -1 | ||
| 25 | for w := range m.all() { | 33 | for w := range m.all() { | ||
| 26 | i++ | 34 | i++ | ||
| @@ -30,11 +38,23 @@ func (c *Core) publishWorkspaces() { | |||||
| 30 | } | 38 | } | ||
| 31 | out.Workspaces = append(out.Workspaces, ports.WorkspaceInfo{ID: w.ID, Name: name, Index: i, Active: m.Current() == w, Hidden: m.Current() != w && (m.isHidden(w) || w.empty()), Configured: w.Name, Frame: w.Output}) | 39 | out.Workspaces = append(out.Workspaces, ports.WorkspaceInfo{ID: w.ID, Name: name, Index: i, Active: m.Current() == w, Hidden: m.Current() != w && (m.isHidden(w) || w.empty()), Configured: w.Name, Frame: w.Output}) | ||
| 32 | } | 40 | } | ||
| 33 | - | snapshot.Outputs = append(snapshot.Outputs, out) | 41 | + | buf.Outputs = append(buf.Outputs, out) |
| 34 | } | 42 | } | ||
| 35 | - | if reflect.DeepEqual(snapshot, c.sent.workspaces) { | 43 | + | // The first snapshot is always sent, even an empty one. |
| 44 | + | if c.sent.workspaces.Outputs != nil && sameWorkspaces(*buf, c.sent.workspaces) { | |||
| 36 | return | 45 | return | ||
| 37 | } | 46 | } | ||
| 47 | + | // Empty lists stay non-nil (JSON []). | |||
| 48 | + | snapshot := ports.Workspaces{Outputs: make([]ports.WorkspaceOutput, len(buf.Outputs))} | |||
| 49 | + | for k, o := range buf.Outputs { | |||
| 50 | + | snapshot.Outputs[k] = ports.WorkspaceOutput{Name: o.Name, Workspaces: append([]ports.WorkspaceInfo{}, o.Workspaces...)} | |||
| 51 | + | } | |||
| 38 | c.sent.workspaces = snapshot | 52 | c.sent.workspaces = snapshot | ||
| 39 | latest(c.ch.Workspaces, snapshot) | 53 | latest(c.ch.Workspaces, snapshot) | ||
| 40 | } | 54 | } | ||
| 55 | + | ||||
| 56 | + | func sameWorkspaces(a, b ports.Workspaces) bool { | |||
| 57 | + | return slices.EqualFunc(a.Outputs, b.Outputs, func(x, y ports.WorkspaceOutput) bool { | |||
| 58 | + | return x.Name == y.Name && slices.Equal(x.Workspaces, y.Workspaces) | |||
| 59 | + | }) | |||
| 60 | + | } | |||