detached 6e31b43

perf(core): keep the Seq of an unchanged scene and drop it in outputs

commit
6e31b43f2f0cb12512dc8f056690ff28b4428970
author
brice · · 2026-10-04 15:40 UTC
committer
brice · · 2026-10-04 19:08 UTC
parent
5c55b10
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +221 -3
M internal/adapters/drm/output.go +4 -0
@@ -1334,6 +1334,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
13341334 if !o.sceneCurrent(s) {
13351335 continue
13361336 }
1337+ // The core keeps the Seq of a scene that draws the same: nothing to do.
1338+ if haveScene && s.Seq != 0 && s.Seq == scene.Seq && s.Security == scene.Security {
1339+ continue
1340+ }
13371341 if o.protected {
13381342 s.CaptureScene = nil
13391343 if o.wantOff != s.Off {
@@ -1334,6 +1334,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1334 if !o.sceneCurrent(s) {1334 if !o.sceneCurrent(s) {
1335 continue1335 continue
1336 }1336 }
1337+ // The core keeps the Seq of a scene that draws the same: nothing to do.
1338+ if haveScene && s.Seq != 0 && s.Seq == scene.Seq && s.Security == scene.Security {
1339+ continue
1340+ }
1337 if o.protected {1341 if o.protected {
1338 s.CaptureScene = nil1342 s.CaptureScene = nil
1339 if o.wantOff != s.Off {1343 if o.wantOff != s.Off {
M internal/adapters/drm/output_test.go +22 -0
@@ -1052,3 +1052,25 @@ func TestRunSecurityResetClearsWantedAt(t *testing.T) {
10521052 t.Fatalf("stamp kept across the security reset: %s", sr.o.wantedAt)
10531053 }
10541054 }
1055+
1056+// A scene whose Seq the output already holds draws nothing new: no frame,
1057+// until a scene with a new Seq arrives.
1058+func TestOutputSameSeqRendersOnce(t *testing.T) {
1059+ sr := startStampRun(t)
1060+ scene := ports.Scene{Seq: 5, OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 10, H: 10}}}}
1061+ sr.scenes <- scene
1062+ waitFor(t, func() bool { return sr.frameCommit() == 1 })
1063+ sr.flips <- flipEvent{crtc: tCrtc, user: sr.lastCommit().user, when: time.Second, seq: 1}
1064+ sr.scenes <- scene
1065+ // Unshown content only sends Run round its loop: the flip and the held
1066+ // scene are handled by the time the second one is taken.
1067+ sr.contents <- ports.SurfaceContent{ID: 99, Seq: 1, SHM: &ports.SHMBuffer{}}
1068+ sr.contents <- ports.SurfaceContent{ID: 99, Seq: 2, SHM: &ports.SHMBuffer{}}
1069+ if n := sr.frameCommit(); n != 1 {
1070+ t.Fatalf("%d frames after a scene with the held Seq", n)
1071+ }
1072+ scene.Seq = 6
1073+ sr.scenes <- scene
1074+ waitFor(t, func() bool { return sr.frameCommit() == 2 })
1075+ sr.stop()
1076+}
@@ -1052,3 +1052,25 @@ func TestRunSecurityResetClearsWantedAt(t *testing.T) {
1052 t.Fatalf("stamp kept across the security reset: %s", sr.o.wantedAt)1052 t.Fatalf("stamp kept across the security reset: %s", sr.o.wantedAt)
1053 }1053 }
1054 }1054 }
1055+
1056+// A scene whose Seq the output already holds draws nothing new: no frame,
1057+// until a scene with a new Seq arrives.
1058+func TestOutputSameSeqRendersOnce(t *testing.T) {
1059+ sr := startStampRun(t)
1060+ scene := ports.Scene{Seq: 5, OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 10, H: 10}}}}
1061+ sr.scenes <- scene
1062+ waitFor(t, func() bool { return sr.frameCommit() == 1 })
1063+ sr.flips <- flipEvent{crtc: tCrtc, user: sr.lastCommit().user, when: time.Second, seq: 1}
1064+ sr.scenes <- scene
1065+ // Unshown content only sends Run round its loop: the flip and the held
1066+ // scene are handled by the time the second one is taken.
1067+ sr.contents <- ports.SurfaceContent{ID: 99, Seq: 1, SHM: &ports.SHMBuffer{}}
1068+ sr.contents <- ports.SurfaceContent{ID: 99, Seq: 2, SHM: &ports.SHMBuffer{}}
1069+ if n := sr.frameCommit(); n != 1 {
1070+ t.Fatalf("%d frames after a scene with the held Seq", n)
1071+ }
1072+ scene.Seq = 6
1073+ sr.scenes <- scene
1074+ waitFor(t, func() bool { return sr.frameCommit() == 2 })
1075+ sr.stop()
1076+}
M internal/adapters/headless/output.go +4 -0
@@ -199,6 +199,10 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
199199 if opts.Security != nil && (s.Security != security || s.Security != opts.Security.Snapshot()) {
200200 return
201201 }
202+ // The core keeps the Seq of a scene that draws the same: nothing to do.
203+ if haveScene && s.Seq != 0 && s.Seq == scene.Seq && s.Security == scene.Security {
204+ return
205+ }
202206 scene, haveScene, dirty = s, true, true
203207 }
204208 update := func(c ports.SurfaceContent) {
@@ -199,6 +199,10 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
199 if opts.Security != nil && (s.Security != security || s.Security != opts.Security.Snapshot()) {199 if opts.Security != nil && (s.Security != security || s.Security != opts.Security.Snapshot()) {
200 return200 return
201 }201 }
202+ // The core keeps the Seq of a scene that draws the same: nothing to do.
203+ if haveScene && s.Seq != 0 && s.Seq == scene.Seq && s.Security == scene.Security {
204+ return
205+ }
202 scene, haveScene, dirty = s, true, true206 scene, haveScene, dirty = s, true, true
203 }207 }
204 update := func(c ports.SurfaceContent) {208 update := func(c ports.SurfaceContent) {
M internal/adapters/headless/output_test.go +33 -0
@@ -108,6 +108,39 @@ func TestRun(t *testing.T) {
108108 }
109109 }
110110
111+// A scene whose Seq the output already holds is not drawn again.
112+func TestHeadlessSameSeqRendersOnce(t *testing.T) {
113+ scenes := make(chan ports.Scene)
114+ contents := make(chan ports.SurfaceContent)
115+ r, f := recordingRenderer(t, nil)
116+ ctx, cancel := context.WithCancel(context.Background())
117+ done := make(chan error, 1)
118+ go func() {
119+ done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil, nil)
120+ }()
121+ scene := ports.Scene{Seq: 5, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}}}
122+ scenes <- scene
123+ waitFrames(t, f, 1)
124+ scenes <- scene
125+ // Unshown content sends Run round its loop: the held scene is handled.
126+ contents <- ports.SurfaceContent{ID: 99, Seq: 1, SHM: &ports.SHMBuffer{}}
127+ contents <- ports.SurfaceContent{ID: 99, Seq: 2, SHM: &ports.SHMBuffer{}}
128+ time.Sleep(50 * time.Millisecond)
129+ if s, _ := f.snapshot(); len(s) != 1 {
130+ t.Fatalf("%d frames for a scene with the held Seq", len(s))
131+ }
132+ scene.Seq = 6
133+ scenes <- scene
134+ waitFrames(t, f, 2)
135+ cancel()
136+ if err := <-done; err != nil {
137+ t.Fatal(err)
138+ }
139+ if s, _ := f.snapshot(); len(s) != 2 {
140+ t.Fatalf("%d frames, want 2", len(s))
141+ }
142+}
143+
111144 // Content of a window the output does not show draws no frame.
112145 func TestRunSkipsContentNotShown(t *testing.T) {
113146 scenes := make(chan ports.Scene, 1)
@@ -108,6 +108,39 @@ func TestRun(t *testing.T) {
108 }108 }
109 }109 }
110 110
111+// A scene whose Seq the output already holds is not drawn again.
112+func TestHeadlessSameSeqRendersOnce(t *testing.T) {
113+ scenes := make(chan ports.Scene)
114+ contents := make(chan ports.SurfaceContent)
115+ r, f := recordingRenderer(t, nil)
116+ ctx, cancel := context.WithCancel(context.Background())
117+ done := make(chan error, 1)
118+ go func() {
119+ done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil, nil)
120+ }()
121+ scene := ports.Scene{Seq: 5, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}}}
122+ scenes <- scene
123+ waitFrames(t, f, 1)
124+ scenes <- scene
125+ // Unshown content sends Run round its loop: the held scene is handled.
126+ contents <- ports.SurfaceContent{ID: 99, Seq: 1, SHM: &ports.SHMBuffer{}}
127+ contents <- ports.SurfaceContent{ID: 99, Seq: 2, SHM: &ports.SHMBuffer{}}
128+ time.Sleep(50 * time.Millisecond)
129+ if s, _ := f.snapshot(); len(s) != 1 {
130+ t.Fatalf("%d frames for a scene with the held Seq", len(s))
131+ }
132+ scene.Seq = 6
133+ scenes <- scene
134+ waitFrames(t, f, 2)
135+ cancel()
136+ if err := <-done; err != nil {
137+ t.Fatal(err)
138+ }
139+ if s, _ := f.snapshot(); len(s) != 2 {
140+ t.Fatalf("%d frames, want 2", len(s))
141+ }
142+}
143+
111 // Content of a window the output does not show draws no frame.144 // Content of a window the output does not show draws no frame.
112 func TestRunSkipsContentNotShown(t *testing.T) {145 func TestRunSkipsContentNotShown(t *testing.T) {
113 scenes := make(chan ports.Scene, 1)146 scenes := make(chan ports.Scene, 1)
M internal/core/core.go +14 -3
@@ -540,7 +540,6 @@ func (c *Core) publish(ctx context.Context) error {
540540 // Before the first output (and after the last is unplugged) the
541541 // placeholder's scene has no output name: no renderer draws it.
542542 for i, sc := range c.screens {
543- c.seq++
544543 o := sc.mon.Output()
545544 // frame is the viewport of the workspace on screen: the whole output
546545 // unless it has a size override (never in the overview).
@@ -549,7 +548,7 @@ func (c *Core) publish(ctx context.Context) error {
549548 if frame != (Rect{W: o.W, H: o.H}) {
550549 clip = frame
551550 }
552- scene := ports.Scene{Security: c.security, Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: shownLayers(sc)}
551+ scene := ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: shownLayers(sc)}
553552 layout := sc.mon.Layout()
554553 var real map[WindowID]Placement
555554 if sc.mon.ov.open {
@@ -615,7 +614,18 @@ func (c *Core) publish(ctx context.Context) error {
615614 }
616615 scene.Windows = append(scene.Windows, c.scenePopups(sc)...)
617616 scene.CaptureIndicators = c.captureIndicators(sc)
618- if scene.Capture = c.captureSceneFor(sc, capture); scene.Capture != nil {
617+ scene.Capture = c.captureSceneFor(sc, capture)
618+ // A scene carrying a capture image always gets a fresh Seq; any other
619+ // keeps its output's Seq while it draws the same, so an idle output
620+ // is not recomposed.
621+ withCapture := scene.Capture != nil && (sc == capture.hiddenScr || sc == capture.windowScr)
622+ if !withCapture && sc.last.Seq != 0 && scene.SameAs(sc.last) {
623+ scene.Seq = sc.last.Seq
624+ } else {
625+ c.seq++
626+ scene.Seq = c.seq
627+ }
628+ if withCapture {
619629 switch sc {
620630 case capture.hiddenScr:
621631 scene.CaptureScene = c.captureScene(scene.Seq)
@@ -623,6 +633,7 @@ func (c *Core) publish(ctx context.Context) error {
623633 scene.CaptureScene = c.captureWindowScene(capture, scene.Seq)
624634 }
625635 }
636+ sc.last = scene
626637 scenes = append(scenes, scene)
627638 }
628639 c.configures.prune()
@@ -540,7 +540,6 @@ func (c *Core) publish(ctx context.Context) error {
540 // Before the first output (and after the last is unplugged) the540 // Before the first output (and after the last is unplugged) the
541 // placeholder's scene has no output name: no renderer draws it.541 // placeholder's scene has no output name: no renderer draws it.
542 for i, sc := range c.screens {542 for i, sc := range c.screens {
543- c.seq++
544 o := sc.mon.Output()543 o := sc.mon.Output()
545 // frame is the viewport of the workspace on screen: the whole output544 // frame is the viewport of the workspace on screen: the whole output
546 // unless it has a size override (never in the overview).545 // unless it has a size override (never in the overview).
@@ -549,7 +548,7 @@ func (c *Core) publish(ctx context.Context) error {
549 if frame != (Rect{W: o.W, H: o.H}) {548 if frame != (Rect{W: o.W, H: o.H}) {
550 clip = frame549 clip = frame
551 }550 }
552- scene := ports.Scene{Security: c.security, Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: shownLayers(sc)}551+ scene := ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: shownLayers(sc)}
553 layout := sc.mon.Layout()552 layout := sc.mon.Layout()
554 var real map[WindowID]Placement553 var real map[WindowID]Placement
555 if sc.mon.ov.open {554 if sc.mon.ov.open {
@@ -615,7 +614,18 @@ func (c *Core) publish(ctx context.Context) error {
615 }614 }
616 scene.Windows = append(scene.Windows, c.scenePopups(sc)...)615 scene.Windows = append(scene.Windows, c.scenePopups(sc)...)
617 scene.CaptureIndicators = c.captureIndicators(sc)616 scene.CaptureIndicators = c.captureIndicators(sc)
618- if scene.Capture = c.captureSceneFor(sc, capture); scene.Capture != nil {617+ scene.Capture = c.captureSceneFor(sc, capture)
618+ // A scene carrying a capture image always gets a fresh Seq; any other
619+ // keeps its output's Seq while it draws the same, so an idle output
620+ // is not recomposed.
621+ withCapture := scene.Capture != nil && (sc == capture.hiddenScr || sc == capture.windowScr)
622+ if !withCapture && sc.last.Seq != 0 && scene.SameAs(sc.last) {
623+ scene.Seq = sc.last.Seq
624+ } else {
625+ c.seq++
626+ scene.Seq = c.seq
627+ }
628+ if withCapture {
619 switch sc {629 switch sc {
620 case capture.hiddenScr:630 case capture.hiddenScr:
621 scene.CaptureScene = c.captureScene(scene.Seq)631 scene.CaptureScene = c.captureScene(scene.Seq)
@@ -623,6 +633,7 @@ func (c *Core) publish(ctx context.Context) error {
623 scene.CaptureScene = c.captureWindowScene(capture, scene.Seq)633 scene.CaptureScene = c.captureWindowScene(capture, scene.Seq)
624 }634 }
625 }635 }
636+ sc.last = scene
626 scenes = append(scenes, scene)637 scenes = append(scenes, scene)
627 }638 }
628 c.configures.prune()639 c.configures.prune()
A internal/core/scene_seq_test.go +26 -0
@@ -0,0 +1,26 @@
1+package core_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// A publish that changes nothing on an output keeps that output's Seq, so
10+// its owner does not recompose; a real change takes a new one.
11+func TestSceneSeqKeptWhenNothingChanged(t *testing.T) {
12+ r := startSwipe(t, nil)
13+ first := r.mapWindow(t, 1)[0]
14+ if first.Seq == 0 {
15+ t.Fatal("scene without Seq")
16+ }
17+ // Focus-column-left at the left edge changes nothing but still publishes.
18+ again := r.key(t, "Left", ports.ModAlt)[0]
19+ if again.Seq != first.Seq || !again.SameAs(first) {
20+ t.Fatalf("Seq %d, want the held %d", again.Seq, first.Seq)
21+ }
22+ next := r.mapWindow(t, 2)[0]
23+ if next.Seq <= first.Seq || len(next.Windows) != 2 {
24+ t.Fatalf("Seq %d after mapping a window (was %d), %d windows", next.Seq, first.Seq, len(next.Windows))
25+ }
26+}
@@ -0,0 +1,26 @@
1+package core_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// A publish that changes nothing on an output keeps that output's Seq, so
10+// its owner does not recompose; a real change takes a new one.
11+func TestSceneSeqKeptWhenNothingChanged(t *testing.T) {
12+ r := startSwipe(t, nil)
13+ first := r.mapWindow(t, 1)[0]
14+ if first.Seq == 0 {
15+ t.Fatal("scene without Seq")
16+ }
17+ // Focus-column-left at the left edge changes nothing but still publishes.
18+ again := r.key(t, "Left", ports.ModAlt)[0]
19+ if again.Seq != first.Seq || !again.SameAs(first) {
20+ t.Fatalf("Seq %d, want the held %d", again.Seq, first.Seq)
21+ }
22+ next := r.mapWindow(t, 2)[0]
23+ if next.Seq <= first.Seq || len(next.Windows) != 2 {
24+ t.Fatalf("Seq %d after mapping a window (was %d), %d windows", next.Seq, first.Seq, len(next.Windows))
25+ }
26+}
M internal/core/screens.go +3 -0
@@ -28,6 +28,9 @@ type screen struct {
2828 // CaptureIndicators): immutable, shared by the next one while unchanged.
2929 capScene *ports.SceneCapture
3030 capMarks []ports.CaptureIndicator
31+ // last is the scene published for this screen: an identical next one
32+ // keeps its Seq (Scene.SameAs). Zero after a protected publish.
33+ last ports.Scene
3134 }
3235
3336 func (s *screen) name() string { return s.info.Name }
@@ -28,6 +28,9 @@ type screen struct {
28 // CaptureIndicators): immutable, shared by the next one while unchanged.28 // CaptureIndicators): immutable, shared by the next one while unchanged.
29 capScene *ports.SceneCapture29 capScene *ports.SceneCapture
30 capMarks []ports.CaptureIndicator30 capMarks []ports.CaptureIndicator
31+ // last is the scene published for this screen: an identical next one
32+ // keeps its Seq (Scene.SameAs). Zero after a protected publish.
33+ last ports.Scene
31 }34 }
32 35
33 func (s *screen) name() string { return s.info.Name }36 func (s *screen) name() string { return s.info.Name }
M internal/core/security.go +2 -0
@@ -185,6 +185,8 @@ func (c *Core) publishProtected(ctx context.Context) error {
185185 scenes := make([]ports.Scene, 0, len(c.screens))
186186 for _, sc := range c.screens {
187187 c.seq++
188+ // The desktop scene that follows must not reuse a Seq from before.
189+ sc.last = ports.Scene{}
188190 o := sc.mon.Output()
189191 scene := ports.Scene{Security: c.security, Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: "#000000"}
190192 if s, ok := c.lockSurface(sc); ok {
@@ -185,6 +185,8 @@ func (c *Core) publishProtected(ctx context.Context) error {
185 scenes := make([]ports.Scene, 0, len(c.screens))185 scenes := make([]ports.Scene, 0, len(c.screens))
186 for _, sc := range c.screens {186 for _, sc := range c.screens {
187 c.seq++187 c.seq++
188+ // The desktop scene that follows must not reuse a Seq from before.
189+ sc.last = ports.Scene{}
188 o := sc.mon.Output()190 o := sc.mon.Output()
189 scene := ports.Scene{Security: c.security, Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: "#000000"}191 scene := ports.Scene{Security: c.security, Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: "#000000"}
190 if s, ok := c.lockSurface(sc); ok {192 if s, ok := c.lockSurface(sc); ok {
M internal/ports/scene.go +32 -0
@@ -4,6 +4,7 @@ import (
44 "image"
55 "math"
66 "os"
7+ "slices"
78 )
89
910 // Scene carries core → renderer immutable snapshots with fresh Windows slices,
@@ -68,6 +69,37 @@ type Scene struct {
6869 CaptureIndicators []CaptureIndicator
6970 }
7071
72+// SameAs reports whether o draws exactly what s draws: every field other
73+// than Seq is equal. A scene carrying a CaptureScene is never the same
74+// (conservative: the capture image is not compared). Add every new Scene
75+// field here; TestSceneSameAsCoversEveryField fails until the field count
76+// is updated.
77+func (s Scene) SameAs(o Scene) bool {
78+ if s.Security != o.Security || s.Output != o.Output ||
79+ s.OutputWidth != o.OutputWidth || s.OutputHeight != o.OutputHeight ||
80+ s.Scale != o.Scale || s.Transform != o.Transform || s.Off != o.Off ||
81+ s.Background != o.Background || s.Border != o.Border ||
82+ s.WorkspaceClip != o.WorkspaceClip || s.Dim != o.Dim || s.DimBehind != o.DimBehind {
83+ return false
84+ }
85+ if s.CaptureScene != nil || o.CaptureScene != nil {
86+ return false
87+ }
88+ if (s.Capture == nil) != (o.Capture == nil) {
89+ return false
90+ }
91+ if s.Capture != nil {
92+ a, b := *s.Capture, *o.Capture
93+ if a.Shown != b.Shown || a.Session != b.Session || a.Revision != b.Revision ||
94+ a.Workspace != b.Workspace || a.Window != b.Window || !slices.Equal(a.Excluded, b.Excluded) {
95+ return false
96+ }
97+ }
98+ return slices.Equal(s.Windows, o.Windows) && slices.Equal(s.Separators, o.Separators) &&
99+ slices.Equal(s.DropHints, o.DropHints) && slices.Equal(s.Layers, o.Layers) &&
100+ slices.Equal(s.CaptureIndicators, o.CaptureIndicators)
101+}
102+
71103 // Shows reports whether the scene draws the surface of id: only its
72104 // content changes need a new frame. A window scrolled off the output is
73105 // not drawn. WorkspaceClip also excludes off-viewport windows; OverLayers
@@ -4,6 +4,7 @@ import (
4 "image"4 "image"
5 "math"5 "math"
6 "os"6 "os"
7+ "slices"
7 )8 )
8 9
9 // Scene carries core → renderer immutable snapshots with fresh Windows slices,10 // Scene carries core → renderer immutable snapshots with fresh Windows slices,
@@ -68,6 +69,37 @@ type Scene struct {
68 CaptureIndicators []CaptureIndicator69 CaptureIndicators []CaptureIndicator
69 }70 }
70 71
72+// SameAs reports whether o draws exactly what s draws: every field other
73+// than Seq is equal. A scene carrying a CaptureScene is never the same
74+// (conservative: the capture image is not compared). Add every new Scene
75+// field here; TestSceneSameAsCoversEveryField fails until the field count
76+// is updated.
77+func (s Scene) SameAs(o Scene) bool {
78+ if s.Security != o.Security || s.Output != o.Output ||
79+ s.OutputWidth != o.OutputWidth || s.OutputHeight != o.OutputHeight ||
80+ s.Scale != o.Scale || s.Transform != o.Transform || s.Off != o.Off ||
81+ s.Background != o.Background || s.Border != o.Border ||
82+ s.WorkspaceClip != o.WorkspaceClip || s.Dim != o.Dim || s.DimBehind != o.DimBehind {
83+ return false
84+ }
85+ if s.CaptureScene != nil || o.CaptureScene != nil {
86+ return false
87+ }
88+ if (s.Capture == nil) != (o.Capture == nil) {
89+ return false
90+ }
91+ if s.Capture != nil {
92+ a, b := *s.Capture, *o.Capture
93+ if a.Shown != b.Shown || a.Session != b.Session || a.Revision != b.Revision ||
94+ a.Workspace != b.Workspace || a.Window != b.Window || !slices.Equal(a.Excluded, b.Excluded) {
95+ return false
96+ }
97+ }
98+ return slices.Equal(s.Windows, o.Windows) && slices.Equal(s.Separators, o.Separators) &&
99+ slices.Equal(s.DropHints, o.DropHints) && slices.Equal(s.Layers, o.Layers) &&
100+ slices.Equal(s.CaptureIndicators, o.CaptureIndicators)
101+}
102+
71 // Shows reports whether the scene draws the surface of id: only its103 // Shows reports whether the scene draws the surface of id: only its
72 // content changes need a new frame. A window scrolled off the output is104 // content changes need a new frame. A window scrolled off the output is
73 // not drawn. WorkspaceClip also excludes off-viewport windows; OverLayers105 // not drawn. WorkspaceClip also excludes off-viewport windows; OverLayers
A internal/ports/scene_same_test.go +81 -0
@@ -0,0 +1,81 @@
1+package ports
2+
3+import (
4+ "reflect"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func sameSceneBase() Scene {
11+ return Scene{
12+ Security: SecurityState{Generation: 1},
13+ Output: "A",
14+ Seq: 1,
15+ OutputWidth: 100,
16+ OutputHeight: 50,
17+ Scale: 1,
18+ Background: "#000000",
19+ Border: Border{Width: 2, Active: "#ffffff", Inactive: "#222222"},
20+ Windows: []SceneWindow{{ID: 1, Rect: Rect{X: 1, W: 10, H: 10}}},
21+ Separators: []Separator{{Rect: Rect{W: 1, H: 10}}},
22+ DropHints: []Rect{{W: 5, H: 5}},
23+ Layers: []SceneLayer{{ID: 9, Rect: Rect{W: 100, H: 5}}},
24+ Capture: &SceneCapture{Shown: 1, Excluded: []WindowID{3}},
25+
26+ CaptureIndicators: []CaptureIndicator{{Rect: Rect{W: 4, H: 4}}},
27+ }
28+}
29+
30+func TestSceneSameAs(t *testing.T) {
31+ base := sameSceneBase()
32+
33+ same := sameSceneBase()
34+ same.Seq = 99
35+ assert.True(t, base.SameAs(same), "Seq alone must not matter")
36+ assert.True(t, Scene{}.SameAs(Scene{Seq: 3}))
37+
38+ for name, edit := range map[string]func(*Scene){
39+ "window rect": func(s *Scene) { s.Windows[0].Rect.X++ },
40+ "window count": func(s *Scene) { s.Windows = nil },
41+ "security": func(s *Scene) { s.Security.Generation++ },
42+ "background": func(s *Scene) { s.Background = "#111111" },
43+ "border": func(s *Scene) { s.Border.Width++ },
44+ "output": func(s *Scene) { s.Output = "B" },
45+ "size": func(s *Scene) { s.OutputWidth++ },
46+ "scale": func(s *Scene) { s.Scale = 2 },
47+ "off": func(s *Scene) { s.Off = true },
48+ "clip": func(s *Scene) { s.WorkspaceClip.W = 1 },
49+ "dim": func(s *Scene) { s.Dim = 0.5 },
50+ "dim behind": func(s *Scene) { s.DimBehind = true },
51+ "separator": func(s *Scene) { s.Separators[0].Active = true },
52+ "drop hint": func(s *Scene) { s.DropHints[0].W++ },
53+ "layer": func(s *Scene) { s.Layers[0].Rect.H++ },
54+ "indicator": func(s *Scene) { s.CaptureIndicators[0].Pill = true },
55+ "excluded": func(s *Scene) { s.Capture.Excluded[0] = 4 },
56+ "capture field": func(s *Scene) { s.Capture.Revision++ },
57+ "capture nil": func(s *Scene) { s.Capture = nil },
58+ "capture scene": func(s *Scene) { s.CaptureScene = &Scene{} },
59+ } {
60+ t.Run(name, func(t *testing.T) {
61+ o := sameSceneBase()
62+ edit(&o)
63+ assert.False(t, base.SameAs(o))
64+ assert.False(t, o.SameAs(base))
65+ })
66+ }
67+
68+ both := sameSceneBase()
69+ both.CaptureScene = &Scene{}
70+ other := sameSceneBase()
71+ other.CaptureScene = &Scene{}
72+ assert.False(t, both.SameAs(other), "a capture scene is never the same")
73+}
74+
75+// TestSceneSameAsCoversEveryField fails when Scene gains or loses a field:
76+// update Scene.SameAs, then this count.
77+func TestSceneSameAsCoversEveryField(t *testing.T) {
78+ const fields = 20
79+ assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),
80+ "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")
81+}
@@ -0,0 +1,81 @@
1+package ports
2+
3+import (
4+ "reflect"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func sameSceneBase() Scene {
11+ return Scene{
12+ Security: SecurityState{Generation: 1},
13+ Output: "A",
14+ Seq: 1,
15+ OutputWidth: 100,
16+ OutputHeight: 50,
17+ Scale: 1,
18+ Background: "#000000",
19+ Border: Border{Width: 2, Active: "#ffffff", Inactive: "#222222"},
20+ Windows: []SceneWindow{{ID: 1, Rect: Rect{X: 1, W: 10, H: 10}}},
21+ Separators: []Separator{{Rect: Rect{W: 1, H: 10}}},
22+ DropHints: []Rect{{W: 5, H: 5}},
23+ Layers: []SceneLayer{{ID: 9, Rect: Rect{W: 100, H: 5}}},
24+ Capture: &SceneCapture{Shown: 1, Excluded: []WindowID{3}},
25+
26+ CaptureIndicators: []CaptureIndicator{{Rect: Rect{W: 4, H: 4}}},
27+ }
28+}
29+
30+func TestSceneSameAs(t *testing.T) {
31+ base := sameSceneBase()
32+
33+ same := sameSceneBase()
34+ same.Seq = 99
35+ assert.True(t, base.SameAs(same), "Seq alone must not matter")
36+ assert.True(t, Scene{}.SameAs(Scene{Seq: 3}))
37+
38+ for name, edit := range map[string]func(*Scene){
39+ "window rect": func(s *Scene) { s.Windows[0].Rect.X++ },
40+ "window count": func(s *Scene) { s.Windows = nil },
41+ "security": func(s *Scene) { s.Security.Generation++ },
42+ "background": func(s *Scene) { s.Background = "#111111" },
43+ "border": func(s *Scene) { s.Border.Width++ },
44+ "output": func(s *Scene) { s.Output = "B" },
45+ "size": func(s *Scene) { s.OutputWidth++ },
46+ "scale": func(s *Scene) { s.Scale = 2 },
47+ "off": func(s *Scene) { s.Off = true },
48+ "clip": func(s *Scene) { s.WorkspaceClip.W = 1 },
49+ "dim": func(s *Scene) { s.Dim = 0.5 },
50+ "dim behind": func(s *Scene) { s.DimBehind = true },
51+ "separator": func(s *Scene) { s.Separators[0].Active = true },
52+ "drop hint": func(s *Scene) { s.DropHints[0].W++ },
53+ "layer": func(s *Scene) { s.Layers[0].Rect.H++ },
54+ "indicator": func(s *Scene) { s.CaptureIndicators[0].Pill = true },
55+ "excluded": func(s *Scene) { s.Capture.Excluded[0] = 4 },
56+ "capture field": func(s *Scene) { s.Capture.Revision++ },
57+ "capture nil": func(s *Scene) { s.Capture = nil },
58+ "capture scene": func(s *Scene) { s.CaptureScene = &Scene{} },
59+ } {
60+ t.Run(name, func(t *testing.T) {
61+ o := sameSceneBase()
62+ edit(&o)
63+ assert.False(t, base.SameAs(o))
64+ assert.False(t, o.SameAs(base))
65+ })
66+ }
67+
68+ both := sameSceneBase()
69+ both.CaptureScene = &Scene{}
70+ other := sameSceneBase()
71+ other.CaptureScene = &Scene{}
72+ assert.False(t, both.SameAs(other), "a capture scene is never the same")
73+}
74+
75+// TestSceneSameAsCoversEveryField fails when Scene gains or loses a field:
76+// update Scene.SameAs, then this count.
77+func TestSceneSameAsCoversEveryField(t *testing.T) {
78+ const fields = 20
79+ assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(Scene{}))),
80+ "Scene fields changed: update Scene.SameAs (and TestSceneSameAs), then this count")
81+}

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 23

Tags 10

↑↓ moveenter openesc close