detached a9d1985

Merge branch 'main' into feat/pointer-gestures

commit
a9d19850efea238aa0b87dd6b2759fc24ae77270
author
bnema · · 2026-10-08 12:28 UTC
merge of
84d935b + 21b6ce6 · diff against the first parent
signature
unsigned
9 files · +237 -37
M Makefile +1 -1
@@ -69,7 +69,7 @@ adapter-imports:
6969 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
7070 # Fast, mandatory guards for tiled content publications and SHM copies.
7171 perf-check:
72- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|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
7373 check: vet test arch fakes-check log-check perf-check
7474
7575 # On-demand SDR/HDR colour check with examples/testpattern on a headless
@@ -69,7 +69,7 @@ adapter-imports:
69 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }69 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
70 # Fast, mandatory guards for tiled content publications and SHM copies.70 # Fast, mandatory guards for tiled content publications and SHM copies.
71 perf-check:71 perf-check:
72- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=172+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
73 check: vet test arch fakes-check log-check perf-check73 check: vet test arch fakes-check log-check perf-check
74 74
75 # On-demand SDR/HDR colour check with examples/testpattern on a headless75 # On-demand SDR/HDR colour check with examples/testpattern on a headless
M internal/adapters/config/watch_test.go +14 -4
@@ -81,10 +81,20 @@ func TestWatch(t *testing.T) {
8181 time.Sleep(15 * time.Millisecond)
8282 }
8383 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+ }
8898 }
8999 // An invalid value keeps the default for that key; other keys still apply.
90100 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) {
163163 token = newToken(t, c, manager, serial+100, seat)
164164 requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf)
165165 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.
167169 commands <- ports.FocusWindow{ID: w.ID}
170+ waitFocus(t, s, w.ID)
168171 commands <- ports.FocusWindow{}
169172 waitFocus(t, s, 0)
170173 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 {
7575 // realBuf backs the layout read through at once by publish (each
7676 // workspace while overviewReal is filled) and by state(); never retained.
7777 realBuf []Placement
78+ scratch publishScratch
7879 security ports.SecurityState
7980 lock lockState
8081 inputKeys map[string]bool
@@ -152,6 +153,17 @@ type Core struct {
152153 pulse focusPulse
153154 }
154155
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+
155167 // sentCache is what core last sent to wayland and on its latest-only
156168 // channels; an unchanged value is not sent again.
157169 type sentCache struct {
@@ -841,10 +853,13 @@ func (c *Core) warpPointer(ctx context.Context, v ports.PointerWarp) error {
841853 // syncOutputs sends the output layout when it changed; input learns the
842854 // geometry only when the outputs themselves moved.
843855 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()}
845859 if sameOutputs(v, c.sent.outputs) {
846860 return nil
847861 }
862+ v.Outputs = append(make(ports.Layout, 0, len(v.Outputs)), v.Outputs...)
848863 if err := c.command(ctx, v); err != nil {
849864 return err
850865 }
@@ -75,6 +75,7 @@ type Core struct {
75 // realBuf backs the layout read through at once by publish (each75 // 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 []Placement77 realBuf []Placement
78+ scratch publishScratch
78 security ports.SecurityState79 security ports.SecurityState
79 lock lockState80 lock lockState
80 inputKeys map[string]bool81 inputKeys map[string]bool
@@ -152,6 +153,17 @@ type Core struct {
152 pulse focusPulse153 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-only167 // 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 the853 // 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 nil860 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 err864 return err
850 }865 }
M internal/core/layout.go +8 -6
@@ -305,18 +305,20 @@ func (w *Workspace) appendHidden(dst []Placement, floats bool) []Placement {
305305
306306 // windows lists every window: columns first, then the stash, then native
307307 // 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 {
310312 for _, c := range w.Columns {
311- ids = append(ids, c.Windows...)
313+ dst = append(dst, c.Windows...)
312314 }
313315 for _, f := range w.Stash {
314- ids = append(ids, f.ID)
316+ dst = append(dst, f.ID)
315317 }
316318 for _, f := range w.Floats {
317- ids = append(ids, f.ID)
319+ dst = append(dst, f.ID)
318320 }
319- return ids
321+ return dst
320322 }
321323
322324 // 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 native306 // 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 []WindowID309+
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 ids321+ 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
22
33 import (
44 "context"
5+ "reflect"
6+ "slices"
57 "testing"
68 "time"
79
@@ -18,15 +20,16 @@ import (
1820 // flip path with another screen animating keeps it and costs the same), then
1921 // 6 once the layouts are built in the owner's reused buffers (layoutInto:
2022 // 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
2427 // (fewer configure targets). The opening overview costs 18 (the stack
2528 // items, the row map and the neighbor list it rebuilds every frame; its
2629 // layouts reuse the buffers too).
2730 const (
28- publishAllocBudget = 6
29- publishLeavingAllocBudget = 4
31+ publishAllocBudget = 5
32+ publishLeavingAllocBudget = 3
3033 publishOverviewAllocBudget = 18
3134 )
3235
@@ -34,7 +37,7 @@ const (
3437 // bookkeeping; the fallback timer is then the system one) with screens
3538 // screens of six windows each. The Slowdown cap keeps every motion running
3639 // 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 {
3841 t.Helper()
3942 var cfg ports.Config
4043 cfg.Keyboard.CmdKey = "super"
@@ -302,3 +305,118 @@ func TestPublishLeavingAllocations(t *testing.T) {
302305 t.Errorf("publish allocs per frame with leaving entries = %v, budget %d", n, publishLeavingAllocBudget)
303306 }
304307 }
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), then20 // 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 of22 // 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 window24+// (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 windows25+// (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 stack27 // (fewer configure targets). The opening overview costs 18 (the stack
25 // items, the row map and the neighbor list it rebuilds every frame; its28 // 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 = 631+ publishAllocBudget = 5
29- publishLeavingAllocBudget = 432+ publishLeavingAllocBudget = 3
30 publishOverviewAllocBudget = 1833 publishOverviewAllocBudget = 18
31 )34 )
32 35
@@ -34,7 +37,7 @@ const (
34 // bookkeeping; the fallback timer is then the system one) with screens37 // bookkeeping; the fallback timer is then the system one) with screens
35 // screens of six windows each. The Slowdown cap keeps every motion running38 // 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.Config42 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) {
157157 }
158158
159159 // 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]
162165 for _, s := range c.screens {
163166 if s.name() != "" {
164167 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
22
33 import (
44 "cmp"
5- "reflect"
65 "slices"
76
87 "github.com/bnema/neferwl/internal/ports"
98 )
109
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.
1213 func (c *Core) publishState() {
1314 if c.ch.State == nil {
1415 return
1516 }
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) {
1820 return
1921 }
22+ st := cloneState(c.scratch.state)
2023 c.sent.state = st
2124 latest(c.ch.State, st)
2225 }
2326
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
2740 focus, _ := c.cur().mon.Focused()
2841 for _, s := range c.screens {
2942 if s.name() == "" {
@@ -37,7 +50,7 @@ func (c *Core) state() ports.State {
3750 }
3851 st.Outputs = append(st.Outputs, o)
3952 // Visible follows the configures: drawn on the output.
40- shown := map[WindowID]bool{}
53+ clear(shown)
4154 // Read at once, into publish's scratch: the snapshot holds no
4255 // placement.
4356 c.realBuf = m.layoutInto(c.realBuf)
@@ -46,7 +59,8 @@ func (c *Core) state() ports.State {
4659 }
4760 for w := range m.all() {
4861 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 {
5064 rec := c.windows.lookup(id)
5165 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)}
5266 if i := w.stashIndex(id); i >= 0 {
@@ -62,13 +76,28 @@ func (c *Core) state() ports.State {
6276 slices.SortFunc(st.Windows, func(a, b ports.WindowState) int { return cmp.Compare(a.ID, b.ID) })
6377 for i := range st.Windows {
6478 if st.Windows[i].ID == focus && focus != 0 {
65- w := st.Windows[i]
66- st.Window = &w
79+ st.Window = &st.Windows[i]
6780 }
6881 }
6982 return st
7083 }
7184
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+
72101 // numbered counts numbered workspaces, without the trailing spare one
73102 // unless it is on screen.
74103 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 return15 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 return20 return
19 }21 }
22+ st := cloneState(c.scratch.state)
20 c.sent.state = st23 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 no54 // 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) + 161 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 st82 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 one101 // 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 @@
11 package core
22
33 import (
4- "reflect"
4+ "slices"
55 "strconv"
66
77 "github.com/bnema/neferwl/internal/ports"
@@ -10,17 +10,25 @@ import (
1010 // publishWorkspaces describes the numbered vertical stack and configured hidden
1111 // workspaces. Config-hidden named workspaces and empty numbered spares are
1212 // 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.
1315 func (c *Core) publishWorkspaces() {
1416 if c.ch.Workspaces == nil {
1517 return
1618 }
17- snapshot := ports.Workspaces{Outputs: []ports.WorkspaceOutput{}}
19+ buf := &c.scratch.workspaces
20+ buf.Outputs = buf.Outputs[:0]
1821 for _, sc := range c.screens {
1922 if sc.name() == "" {
2023 continue
2124 }
2225 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}
2432 i := -1
2533 for w := range m.all() {
2634 i++
@@ -30,11 +38,23 @@ func (c *Core) publishWorkspaces() {
3038 }
3139 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})
3240 }
33- snapshot.Outputs = append(snapshot.Outputs, out)
41+ buf.Outputs = append(buf.Outputs, out)
3442 }
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) {
3645 return
3746 }
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+ }
3852 c.sent.workspaces = snapshot
3953 latest(c.ch.Workspaces, snapshot)
4054 }
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 core1 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 hidden10 // publishWorkspaces describes the numbered vertical stack and configured hidden
11 // workspaces. Config-hidden named workspaces and empty numbered spares are11 // 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 return17 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 continue23 continue
21 }24 }
22 m := sc.mon25 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 := -132 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 return45 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 = snapshot52 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+}

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close