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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 1334 | 1334 | if !o.sceneCurrent(s) { | |
| 1335 | 1335 | 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 | 1341 | if o.protected { | |
| 1338 | 1342 | s.CaptureScene = nil | |
| 1339 | 1343 | 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 | continue | 1335 | 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 = nil | 1342 | 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) { | |||
| 1052 | 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 | + | } | |
| @@ -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 | |||
| 199 | 199 | if opts.Security != nil && (s.Security != security || s.Security != opts.Security.Snapshot()) { | |
| 200 | 200 | 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 | 206 | scene, haveScene, dirty = s, true, true | |
| 203 | 207 | } | |
| 204 | 208 | 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 | return | 200 | 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, true | 206 | 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) { | |||
| 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 | 144 | // Content of a window the output does not show draws no frame. | |
| 112 | 145 | func TestRunSkipsContentNotShown(t *testing.T) { | |
| 113 | 146 | 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 { | |||
| 540 | 540 | // Before the first output (and after the last is unplugged) the | |
| 541 | 541 | // placeholder's scene has no output name: no renderer draws it. | |
| 542 | 542 | for i, sc := range c.screens { | |
| 543 | - | c.seq++ | |
| 544 | 543 | o := sc.mon.Output() | |
| 545 | 544 | // frame is the viewport of the workspace on screen: the whole output | |
| 546 | 545 | // unless it has a size override (never in the overview). | |
| @@ -549,7 +548,7 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 549 | 548 | if frame != (Rect{W: o.W, H: o.H}) { | |
| 550 | 549 | 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 | 552 | layout := sc.mon.Layout() | |
| 554 | 553 | var real map[WindowID]Placement | |
| 555 | 554 | if sc.mon.ov.open { | |
| @@ -615,7 +614,18 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 615 | 614 | } | |
| 616 | 615 | scene.Windows = append(scene.Windows, c.scenePopups(sc)...) | |
| 617 | 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 | 629 | switch sc { | |
| 620 | 630 | case capture.hiddenScr: | |
| 621 | 631 | scene.CaptureScene = c.captureScene(scene.Seq) | |
| @@ -623,6 +633,7 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 623 | 633 | scene.CaptureScene = c.captureWindowScene(capture, scene.Seq) | |
| 624 | 634 | } | |
| 625 | 635 | } | |
| 636 | + | sc.last = scene | |
| 626 | 637 | scenes = append(scenes, scene) | |
| 627 | 638 | } | |
| 628 | 639 | 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) the | 540 | // 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 output | 544 | // 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 = frame | 549 | 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]Placement | 553 | 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 { | |||
| 28 | 28 | // CaptureIndicators): immutable, shared by the next one while unchanged. | |
| 29 | 29 | capScene *ports.SceneCapture | |
| 30 | 30 | 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 | 36 | 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.SceneCapture | 29 | capScene *ports.SceneCapture | ||
| 30 | capMarks []ports.CaptureIndicator | 30 | 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 { | |||
| 185 | 185 | scenes := make([]ports.Scene, 0, len(c.screens)) | |
| 186 | 186 | for _, sc := range c.screens { | |
| 187 | 187 | c.seq++ | |
| 188 | + | // The desktop scene that follows must not reuse a Seq from before. | |
| 189 | + | sc.last = ports.Scene{} | |
| 188 | 190 | o := sc.mon.Output() | |
| 189 | 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 | 192 | 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 ( | |||
| 4 | 4 | "image" | |
| 5 | 5 | "math" | |
| 6 | 6 | "os" | |
| 7 | + | "slices" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | 10 | // Scene carries core → renderer immutable snapshots with fresh Windows slices, | |
| @@ -68,6 +69,37 @@ type Scene struct { | |||
| 68 | 69 | 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 | 103 | // Shows reports whether the scene draws the surface of id: only its | |
| 72 | 104 | // content changes need a new frame. A window scrolled off the output is | |
| 73 | 105 | // 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 []CaptureIndicator | 69 | 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 its | 103 | // 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 is | 104 | // content changes need a new frame. A window scrolled off the output is | ||
| 73 | // not drawn. WorkspaceClip also excludes off-viewport windows; OverLayers | 105 | // 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 | + | } | |||