Merge branch 'main' into feat/pointer-gestures
9 files · +237 -37
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/adapters/config/watch_test.go +14 -4
| @@ -81,10 +81,20 @@ func TestWatch(t *testing.T) { | |||
| 81 | 81 | time.Sleep(15 * time.Millisecond) | |
| 82 | 82 | } | |
| 83 | 83 | receive("#00ff00") | |
| 84 | - | select { | |
| 85 | - | case <-out: | |
| 86 | - | t.Fatal("duplicate reload") | |
| 87 | - | case <-time.After(250 * time.Millisecond): | |
| 84 | + | // A loaded machine may pause past the debounce inside the burst | |
| 85 | + | // and reload the truncated file in between: that is a change, not a | |
| 86 | + | // duplicate. Only the same config sent twice in a row is one. | |
| 87 | + | prev := "#00ff00" | |
| 88 | + | for quiet := false; !quiet; { | |
| 89 | + | select { | |
| 90 | + | case c := <-out: | |
| 91 | + | if c.Config.Background.Color == prev { | |
| 92 | + | t.Fatal("duplicate reload") | |
| 93 | + | } | |
| 94 | + | prev = c.Config.Background.Color | |
| 95 | + | case <-time.After(250 * time.Millisecond): | |
| 96 | + | quiet = true | |
| 97 | + | } | |
| 88 | 98 | } | |
| 89 | 99 | // An invalid value keeps the default for that key; other keys still apply. | |
| 90 | 100 | write(path, "background = bad\nborder.width = 5\n") | |
| @@ -81,10 +81,20 @@ func TestWatch(t *testing.T) { | |||||
| 81 | time.Sleep(15 * time.Millisecond) | 81 | time.Sleep(15 * time.Millisecond) | ||
| 82 | } | 82 | } | ||
| 83 | receive("#00ff00") | 83 | receive("#00ff00") | ||
| 84 | - | select { | 84 | + | // A loaded machine may pause past the debounce inside the burst |
| 85 | - | case <-out: | 85 | + | // and reload the truncated file in between: that is a change, not a |
| 86 | - | t.Fatal("duplicate reload") | 86 | + | // duplicate. Only the same config sent twice in a row is one. |
| 87 | - | case <-time.After(250 * time.Millisecond): | 87 | + | prev := "#00ff00" |
| 88 | + | for quiet := false; !quiet; { | |||
| 89 | + | select { | |||
| 90 | + | case c := <-out: | |||
| 91 | + | if c.Config.Background.Color == prev { | |||
| 92 | + | t.Fatal("duplicate reload") | |||
| 93 | + | } | |||
| 94 | + | prev = c.Config.Background.Color | |||
| 95 | + | case <-time.After(250 * time.Millisecond): | |||
| 96 | + | quiet = true | |||
| 97 | + | } | |||
| 88 | } | 98 | } | ||
| 89 | // An invalid value keeps the default for that key; other keys still apply. | 99 | // An invalid value keeps the default for that key; other keys still apply. | ||
| 90 | write(path, "background = bad\nborder.width = 5\n") | 100 | write(path, "background = bad\nborder.width = 5\n") | ||
M internal/adapters/wayland/activation_test.go +4 -1
| @@ -163,8 +163,11 @@ func TestActivationPressSerial(t *testing.T) { | |||
| 163 | 163 | token = newToken(t, c, manager, serial+100, seat) | |
| 164 | 164 | requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf) | |
| 165 | 165 | noActivation(t, c, events) | |
| 166 | - | // The focus moved after the press: stale. | |
| 166 | + | // The focus moved after the press: stale. Wait for each step: the | |
| 167 | + | // focus starts at 0, so a single wait could pass before either | |
| 168 | + | // command applies and leave the client focused. | |
| 167 | 169 | commands <- ports.FocusWindow{ID: w.ID} | |
| 170 | + | waitFocus(t, s, w.ID) | |
| 168 | 171 | commands <- ports.FocusWindow{} | |
| 169 | 172 | waitFocus(t, s, 0) | |
| 170 | 173 | token = newToken(t, c, manager, serial, seat) | |
| @@ -163,8 +163,11 @@ func TestActivationPressSerial(t *testing.T) { | |||||
| 163 | token = newToken(t, c, manager, serial+100, seat) | 163 | token = newToken(t, c, manager, serial+100, seat) | ||
| 164 | requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf) | 164 | requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf) | ||
| 165 | noActivation(t, c, events) | 165 | noActivation(t, c, events) | ||
| 166 | - | // The focus moved after the press: stale. | 166 | + | // The focus moved after the press: stale. Wait for each step: the |
| 167 | + | // focus starts at 0, so a single wait could pass before either | |||
| 168 | + | // command applies and leave the client focused. | |||
| 167 | commands <- ports.FocusWindow{ID: w.ID} | 169 | commands <- ports.FocusWindow{ID: w.ID} | ||
| 170 | + | waitFocus(t, s, w.ID) | |||
| 168 | commands <- ports.FocusWindow{} | 171 | commands <- ports.FocusWindow{} | ||
| 169 | waitFocus(t, s, 0) | 172 | waitFocus(t, s, 0) | ||
| 170 | token = newToken(t, c, manager, serial, seat) | 173 | token = newToken(t, c, manager, serial, seat) | ||
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 | |
| @@ -152,6 +153,17 @@ type Core struct { | |||
| 152 | 153 | pulse focusPulse | |
| 153 | 154 | } | |
| 154 | 155 | ||
| 156 | + | // publishScratch is where publishState and publishWorkspaces build their | |
| 157 | + | // snapshots each publish: owner goroutine storage, never sent (a snapshot | |
| 158 | + | // is copied out of it when it changed). | |
| 159 | + | type publishScratch struct { | |
| 160 | + | state ports.State | |
| 161 | + | workspaces ports.Workspaces | |
| 162 | + | shown map[WindowID]bool | |
| 163 | + | ids []WindowID | |
| 164 | + | layout ports.Layout | |
| 165 | + | } | |
| 166 | + | ||
| 155 | 167 | // sentCache is what core last sent to wayland and on its latest-only | |
| 156 | 168 | // channels; an unchanged value is not sent again. | |
| 157 | 169 | type sentCache struct { | |
| @@ -841,10 +853,13 @@ func (c *Core) warpPointer(ctx context.Context, v ports.PointerWarp) error { | |||
| 841 | 853 | // syncOutputs sends the output layout when it changed; input learns the | |
| 842 | 854 | // geometry only when the outputs themselves moved. | |
| 843 | 855 | func (c *Core) syncOutputs(ctx context.Context) error { | |
| 844 | - | v := ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()} | |
| 856 | + | // Built in the scratch every publish, copied only when sent. | |
| 857 | + | c.scratch.layout = c.layoutInto(c.scratch.layout) | |
| 858 | + | v := ports.SetOutputs{Outputs: c.scratch.layout, Focused: c.cur().name(), Off: c.offOutputs()} | |
| 845 | 859 | if sameOutputs(v, c.sent.outputs) { | |
| 846 | 860 | return nil | |
| 847 | 861 | } | |
| 862 | + | v.Outputs = append(make(ports.Layout, 0, len(v.Outputs)), v.Outputs...) | |
| 848 | 863 | if err := c.command(ctx, v); err != nil { | |
| 849 | 864 | return err | |
| 850 | 865 | } | |
| @@ -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 | ||
| @@ -152,6 +153,17 @@ type Core struct { | |||||
| 152 | pulse focusPulse | 153 | pulse focusPulse | ||
| 153 | } | 154 | } | ||
| 154 | 155 | ||||
| 156 | + | // publishScratch is where publishState and publishWorkspaces build their | |||
| 157 | + | // snapshots each publish: owner goroutine storage, never sent (a snapshot | |||
| 158 | + | // is copied out of it when it changed). | |||
| 159 | + | type publishScratch struct { | |||
| 160 | + | state ports.State | |||
| 161 | + | workspaces ports.Workspaces | |||
| 162 | + | shown map[WindowID]bool | |||
| 163 | + | ids []WindowID | |||
| 164 | + | layout ports.Layout | |||
| 165 | + | } | |||
| 166 | + | ||||
| 155 | // sentCache is what core last sent to wayland and on its latest-only | 167 | // sentCache is what core last sent to wayland and on its latest-only | ||
| 156 | // channels; an unchanged value is not sent again. | 168 | // channels; an unchanged value is not sent again. | ||
| 157 | type sentCache struct { | 169 | type sentCache struct { | ||
| @@ -841,10 +853,13 @@ func (c *Core) warpPointer(ctx context.Context, v ports.PointerWarp) error { | |||||
| 841 | // syncOutputs sends the output layout when it changed; input learns the | 853 | // syncOutputs sends the output layout when it changed; input learns the | ||
| 842 | // geometry only when the outputs themselves moved. | 854 | // geometry only when the outputs themselves moved. | ||
| 843 | func (c *Core) syncOutputs(ctx context.Context) error { | 855 | func (c *Core) syncOutputs(ctx context.Context) error { | ||
| 844 | - | v := ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()} | 856 | + | // Built in the scratch every publish, copied only when sent. |
| 857 | + | c.scratch.layout = c.layoutInto(c.scratch.layout) | |||
| 858 | + | v := ports.SetOutputs{Outputs: c.scratch.layout, Focused: c.cur().name(), Off: c.offOutputs()} | |||
| 845 | if sameOutputs(v, c.sent.outputs) { | 859 | if sameOutputs(v, c.sent.outputs) { | ||
| 846 | return nil | 860 | return nil | ||
| 847 | } | 861 | } | ||
| 862 | + | v.Outputs = append(make(ports.Layout, 0, len(v.Outputs)), v.Outputs...) | |||
| 848 | if err := c.command(ctx, v); err != nil { | 863 | if err := c.command(ctx, v); err != nil { | ||
| 849 | return err | 864 | return err | ||
| 850 | } | 865 | } | ||
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 | + | } | |||