feat(core): animate a window's close with a leaving entry, drawn from its last content
An unmap is wrapped with snapshot/transition so the neighbours re-flow, and a window that was drawn as a tile or an ordinary float (animations on, no session lock, overview closed, not fullscreen) leaves from its last settled placement: fade 0→1 and scale 1→0.9 (leave). Lock, animations off, an overview toggle, a workspace change and an unplug drop it via stopRects. The empty content wayland emits right after WindowUnmapped would strip the renderer of the buffer before the scene listing the leaving window arrives. Outputs now keep their contents in a surfaces.Table: an empty content of a window the current scene draws keeps the previous one with Acquire zeroed (the first draw waited on it, wayland closes it on release); the entry goes once an accepted scene no longer lists the window. The renderer reuses its existing dmabuf import or shm copy: no vkCmdCopyImage, no extra pass. Tests: core lifecycle (tiled, float, mid-motion, off, exceptions, lock); TestUnmapMidRectMotion gains an on variant; surfaces table unit tests (kept/pruned/Acquire nil/zero allocs); drm and headless Run intake.
- commit
- df88f4a3f7d960c9c7ddb868dfc23016f4f51185
- author
- brice · · 2026-10-05 00:35 UTC
- committer
- brice · · 2026-10-05 00:36 UTC
- parent
- ae1af86
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +630 -58
M internal/adapters/drm/output.go +7 -7
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "github.com/bnema/neferwl/internal/adapters/capture" | |
| 13 | 13 | "github.com/bnema/neferwl/internal/adapters/clock" | |
| 14 | 14 | "github.com/bnema/neferwl/internal/adapters/presented" | |
| 15 | + | "github.com/bnema/neferwl/internal/adapters/surfaces" | |
| 15 | 16 | ||
| 16 | 17 | "github.com/bnema/neferwl/internal/ports" | |
| 17 | 18 | "github.com/bnema/zerowrap" | |
| @@ -1028,7 +1029,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1028 | 1029 | o.probeAsync(r) | |
| 1029 | 1030 | } | |
| 1030 | 1031 | } | |
| 1031 | - | surfaces := make(map[ports.WindowID]ports.SurfaceContent) | |
| 1032 | + | table := surfaces.New() | |
| 1033 | + | drawn := table.Map() | |
| 1032 | 1034 | var scene ports.Scene | |
| 1033 | 1035 | haveScene, dirty := false, false | |
| 1034 | 1036 | var want ports.CursorChange | |
| @@ -1353,6 +1355,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1353 | 1355 | } | |
| 1354 | 1356 | scene, haveScene, dirty = s, true, true | |
| 1355 | 1357 | o.wantOff = s.Off | |
| 1358 | + | // A closed window's last content goes once no scene draws it. | |
| 1359 | + | table.Prune(scene) | |
| 1356 | 1360 | case c := <-cursor: | |
| 1357 | 1361 | o.observeSecurity() | |
| 1358 | 1362 | if o.protected { | |
| @@ -1365,11 +1369,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1365 | 1369 | stateDirty = true | |
| 1366 | 1370 | } | |
| 1367 | 1371 | case c := <-contents: | |
| 1368 | - | if c.Empty() { | |
| 1369 | - | delete(surfaces, c.ID) | |
| 1370 | - | } else { | |
| 1371 | - | surfaces[c.ID] = c | |
| 1372 | - | } | |
| 1372 | + | table.Update(c, scene) | |
| 1373 | 1373 | if c.Seq > seen[c.ID] { | |
| 1374 | 1374 | seen[c.ID] = c.Seq | |
| 1375 | 1375 | reportDirty = true | |
| @@ -1469,7 +1469,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1469 | 1469 | continue | |
| 1470 | 1470 | } | |
| 1471 | 1471 | start := time.Now() | |
| 1472 | - | direct, err := o.submitFrame(ctx, r, scene, surfaces, seen, requests, pipeline) | |
| 1472 | + | direct, err := o.submitFrame(ctx, r, scene, drawn, seen, requests, pipeline) | |
| 1473 | 1473 | var fatal renderError | |
| 1474 | 1474 | if errors.As(err, &fatal) || errors.Is(err, errSecurityScene) { | |
| 1475 | 1475 | // A failed render or epoch rejection did not hand these requests to the worker. | |
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | "github.com/bnema/neferwl/internal/adapters/capture" | 12 | "github.com/bnema/neferwl/internal/adapters/capture" | ||
| 13 | "github.com/bnema/neferwl/internal/adapters/clock" | 13 | "github.com/bnema/neferwl/internal/adapters/clock" | ||
| 14 | "github.com/bnema/neferwl/internal/adapters/presented" | 14 | "github.com/bnema/neferwl/internal/adapters/presented" | ||
| 15 | + | "github.com/bnema/neferwl/internal/adapters/surfaces" | |||
| 15 | 16 | ||||
| 16 | "github.com/bnema/neferwl/internal/ports" | 17 | "github.com/bnema/neferwl/internal/ports" | ||
| 17 | "github.com/bnema/zerowrap" | 18 | "github.com/bnema/zerowrap" | ||
| @@ -1028,7 +1029,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1028 | o.probeAsync(r) | 1029 | o.probeAsync(r) | ||
| 1029 | } | 1030 | } | ||
| 1030 | } | 1031 | } | ||
| 1031 | - | surfaces := make(map[ports.WindowID]ports.SurfaceContent) | 1032 | + | table := surfaces.New() |
| 1033 | + | drawn := table.Map() | |||
| 1032 | var scene ports.Scene | 1034 | var scene ports.Scene | ||
| 1033 | haveScene, dirty := false, false | 1035 | haveScene, dirty := false, false | ||
| 1034 | var want ports.CursorChange | 1036 | var want ports.CursorChange | ||
| @@ -1353,6 +1355,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1353 | } | 1355 | } | ||
| 1354 | scene, haveScene, dirty = s, true, true | 1356 | scene, haveScene, dirty = s, true, true | ||
| 1355 | o.wantOff = s.Off | 1357 | o.wantOff = s.Off | ||
| 1358 | + | // A closed window's last content goes once no scene draws it. | |||
| 1359 | + | table.Prune(scene) | |||
| 1356 | case c := <-cursor: | 1360 | case c := <-cursor: | ||
| 1357 | o.observeSecurity() | 1361 | o.observeSecurity() | ||
| 1358 | if o.protected { | 1362 | if o.protected { | ||
| @@ -1365,11 +1369,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1365 | stateDirty = true | 1369 | stateDirty = true | ||
| 1366 | } | 1370 | } | ||
| 1367 | case c := <-contents: | 1371 | case c := <-contents: | ||
| 1368 | - | if c.Empty() { | 1372 | + | table.Update(c, scene) |
| 1369 | - | delete(surfaces, c.ID) | |||
| 1370 | - | } else { | |||
| 1371 | - | surfaces[c.ID] = c | |||
| 1372 | - | } | |||
| 1373 | if c.Seq > seen[c.ID] { | 1373 | if c.Seq > seen[c.ID] { | ||
| 1374 | seen[c.ID] = c.Seq | 1374 | seen[c.ID] = c.Seq | ||
| 1375 | reportDirty = true | 1375 | reportDirty = true | ||
| @@ -1469,7 +1469,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1469 | continue | 1469 | continue | ||
| 1470 | } | 1470 | } | ||
| 1471 | start := time.Now() | 1471 | start := time.Now() | ||
| 1472 | - | direct, err := o.submitFrame(ctx, r, scene, surfaces, seen, requests, pipeline) | 1472 | + | direct, err := o.submitFrame(ctx, r, scene, drawn, seen, requests, pipeline) |
| 1473 | var fatal renderError | 1473 | var fatal renderError | ||
| 1474 | if errors.As(err, &fatal) || errors.Is(err, errSecurityScene) { | 1474 | if errors.As(err, &fatal) || errors.Is(err, errSecurityScene) { | ||
| 1475 | // A failed render or epoch rejection did not hand these requests to the worker. | 1475 | // A failed render or epoch rejection did not hand these requests to the worker. | ||
M internal/adapters/drm/output_test.go +87 -0
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | 7 | "image" | |
| 8 | + | "maps" | |
| 8 | 9 | "os" | |
| 9 | 10 | "strings" | |
| 10 | 11 | "sync" | |
| @@ -1074,3 +1075,89 @@ func TestOutputSameSeqRendersOnce(t *testing.T) { | |||
| 1074 | 1075 | waitFor(t, func() bool { return sr.frameCommit() == 2 }) | |
| 1075 | 1076 | sr.stop() | |
| 1076 | 1077 | } | |
| 1078 | + | ||
| 1079 | + | // Run: the empty content of a window that unmapped while the scene still | |
| 1080 | + | // draws it (its close animation) keeps the last content for the renderer, | |
| 1081 | + | // without its Acquire fence; a scene that no longer lists the window drops | |
| 1082 | + | // it. | |
| 1083 | + | func TestRunKeepsLastContentOfLeavingWindow(t *testing.T) { | |
| 1084 | + | o, k, _ := testOutput(t) | |
| 1085 | + | o.cursor, o.tearing = nil, false | |
| 1086 | + | r := portsmocks.NewMockRenderer(t) | |
| 1087 | + | pipeBuf := func() ports.DMABuf { | |
| 1088 | + | f, w, _ := os.Pipe() | |
| 1089 | + | w.Close() | |
| 1090 | + | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 1091 | + | } | |
| 1092 | + | var mu sync.Mutex | |
| 1093 | + | var last map[ports.WindowID]ports.SurfaceContent | |
| 1094 | + | var lastSeq uint64 | |
| 1095 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |
| 1096 | + | r.EXPECT().ExportTargets(2, mock.Anything, false).Return([]ports.DMABuf{pipeBuf(), pipeBuf()}, nil).Once() | |
| 1097 | + | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 1098 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, c map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |
| 1099 | + | mu.Lock() | |
| 1100 | + | last, lastSeq = maps.Clone(c), s.Seq | |
| 1101 | + | mu.Unlock() | |
| 1102 | + | f, w, _ := os.Pipe() | |
| 1103 | + | _, _ = w.Write([]byte{1}) | |
| 1104 | + | w.Close() | |
| 1105 | + | return f, nil | |
| 1106 | + | }) | |
| 1107 | + | r.EXPECT().Close().Return().Once() | |
| 1108 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice() | |
| 1109 | + | k.EXPECT().createBlob(mock.Anything).Return(99, nil) | |
| 1110 | + | k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe() | |
| 1111 | + | k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe() | |
| 1112 | + | // drawn waits for a frame of scene seq and returns what it drew for | |
| 1113 | + | // window 1. | |
| 1114 | + | drawn := func(seq uint64, cond func(ports.SurfaceContent, bool) bool) (ports.SurfaceContent, bool) { | |
| 1115 | + | t.Helper() | |
| 1116 | + | var c ports.SurfaceContent | |
| 1117 | + | var ok bool | |
| 1118 | + | waitFor(t, func() bool { | |
| 1119 | + | mu.Lock() | |
| 1120 | + | defer mu.Unlock() | |
| 1121 | + | c, ok = last[1] | |
| 1122 | + | return lastSeq == seq && cond(c, ok) | |
| 1123 | + | }) | |
| 1124 | + | return c, ok | |
| 1125 | + | } | |
| 1126 | + | scenes := make(chan ports.Scene, 1) | |
| 1127 | + | contents := make(chan ports.SurfaceContent, 1) | |
| 1128 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 1129 | + | done := make(chan error, 1) | |
| 1130 | + | go func() { | |
| 1131 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, contents, nil, make(chan ports.OutputPresented, 64), nil, nil) | |
| 1132 | + | }() | |
| 1133 | + | acq, acqW, _ := os.Pipe() | |
| 1134 | + | defer acq.Close() | |
| 1135 | + | defer acqW.Close() | |
| 1136 | + | win := ports.SceneWindow{ID: 1, Rect: ports.Rect{W: 10, H: 10}} | |
| 1137 | + | scenes <- ports.Scene{Seq: 1, OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{win}} | |
| 1138 | + | full := ports.SurfaceContent{ID: 1, Seq: 1, Width: 10, Height: 10, DMABuf: &ports.DMABuf{ID: 5, Planes: []ports.DMABufPlane{{}}}, Acquire: acq} | |
| 1139 | + | contents <- full | |
| 1140 | + | drawn(1, func(c ports.SurfaceContent, ok bool) bool { return ok && c.Acquire == acq }) | |
| 1141 | + | // The window unmaps: its empty content arrives before the scene that | |
| 1142 | + | // draws it leaving (fading). The last content stays, fence dropped. | |
| 1143 | + | contents <- ports.SurfaceContent{ID: 1, Seq: 2} | |
| 1144 | + | kept, _ := drawn(1, func(c ports.SurfaceContent, ok bool) bool { return !ok || c.Acquire == nil }) | |
| 1145 | + | if kept.DMABuf != full.DMABuf || kept.Seq != 1 { | |
| 1146 | + | t.Fatalf("frame after the empty content drew %+v, want the last content", kept) | |
| 1147 | + | } | |
| 1148 | + | // A leaving frame: still drawn from the kept content. | |
| 1149 | + | win.Fade = 0.5 | |
| 1150 | + | scenes <- ports.Scene{Seq: 2, OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{win}} | |
| 1151 | + | if c, ok := drawn(2, func(ports.SurfaceContent, bool) bool { return true }); !ok || c.DMABuf != full.DMABuf { | |
| 1152 | + | t.Fatalf("the leaving frame drew %+v (ok %t), want the kept content", c, ok) | |
| 1153 | + | } | |
| 1154 | + | // The scene no longer lists it: gone. | |
| 1155 | + | scenes <- ports.Scene{Seq: 3, OutputWidth: 200, OutputHeight: 100} | |
| 1156 | + | if c, ok := drawn(3, func(ports.SurfaceContent, bool) bool { return true }); ok { | |
| 1157 | + | t.Fatalf("the kept content %+v survived a scene without the window", c) | |
| 1158 | + | } | |
| 1159 | + | cancel() | |
| 1160 | + | if err := <-done; err != nil { | |
| 1161 | + | t.Fatal(err) | |
| 1162 | + | } | |
| 1163 | + | } | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "context" | 5 | "context" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "image" | 7 | "image" | ||
| 8 | + | "maps" | |||
| 8 | "os" | 9 | "os" | ||
| 9 | "strings" | 10 | "strings" | ||
| 10 | "sync" | 11 | "sync" | ||
| @@ -1074,3 +1075,89 @@ func TestOutputSameSeqRendersOnce(t *testing.T) { | |||||
| 1074 | waitFor(t, func() bool { return sr.frameCommit() == 2 }) | 1075 | waitFor(t, func() bool { return sr.frameCommit() == 2 }) | ||
| 1075 | sr.stop() | 1076 | sr.stop() | ||
| 1076 | } | 1077 | } | ||
| 1078 | + | ||||
| 1079 | + | // Run: the empty content of a window that unmapped while the scene still | |||
| 1080 | + | // draws it (its close animation) keeps the last content for the renderer, | |||
| 1081 | + | // without its Acquire fence; a scene that no longer lists the window drops | |||
| 1082 | + | // it. | |||
| 1083 | + | func TestRunKeepsLastContentOfLeavingWindow(t *testing.T) { | |||
| 1084 | + | o, k, _ := testOutput(t) | |||
| 1085 | + | o.cursor, o.tearing = nil, false | |||
| 1086 | + | r := portsmocks.NewMockRenderer(t) | |||
| 1087 | + | pipeBuf := func() ports.DMABuf { | |||
| 1088 | + | f, w, _ := os.Pipe() | |||
| 1089 | + | w.Close() | |||
| 1090 | + | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |||
| 1091 | + | } | |||
| 1092 | + | var mu sync.Mutex | |||
| 1093 | + | var last map[ports.WindowID]ports.SurfaceContent | |||
| 1094 | + | var lastSeq uint64 | |||
| 1095 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |||
| 1096 | + | r.EXPECT().ExportTargets(2, mock.Anything, false).Return([]ports.DMABuf{pipeBuf(), pipeBuf()}, nil).Once() | |||
| 1097 | + | r.EXPECT().UseTarget(mock.Anything).Return() | |||
| 1098 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, c map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |||
| 1099 | + | mu.Lock() | |||
| 1100 | + | last, lastSeq = maps.Clone(c), s.Seq | |||
| 1101 | + | mu.Unlock() | |||
| 1102 | + | f, w, _ := os.Pipe() | |||
| 1103 | + | _, _ = w.Write([]byte{1}) | |||
| 1104 | + | w.Close() | |||
| 1105 | + | return f, nil | |||
| 1106 | + | }) | |||
| 1107 | + | r.EXPECT().Close().Return().Once() | |||
| 1108 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice() | |||
| 1109 | + | k.EXPECT().createBlob(mock.Anything).Return(99, nil) | |||
| 1110 | + | k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe() | |||
| 1111 | + | k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe() | |||
| 1112 | + | // drawn waits for a frame of scene seq and returns what it drew for | |||
| 1113 | + | // window 1. | |||
| 1114 | + | drawn := func(seq uint64, cond func(ports.SurfaceContent, bool) bool) (ports.SurfaceContent, bool) { | |||
| 1115 | + | t.Helper() | |||
| 1116 | + | var c ports.SurfaceContent | |||
| 1117 | + | var ok bool | |||
| 1118 | + | waitFor(t, func() bool { | |||
| 1119 | + | mu.Lock() | |||
| 1120 | + | defer mu.Unlock() | |||
| 1121 | + | c, ok = last[1] | |||
| 1122 | + | return lastSeq == seq && cond(c, ok) | |||
| 1123 | + | }) | |||
| 1124 | + | return c, ok | |||
| 1125 | + | } | |||
| 1126 | + | scenes := make(chan ports.Scene, 1) | |||
| 1127 | + | contents := make(chan ports.SurfaceContent, 1) | |||
| 1128 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 1129 | + | done := make(chan error, 1) | |||
| 1130 | + | go func() { | |||
| 1131 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, contents, nil, make(chan ports.OutputPresented, 64), nil, nil) | |||
| 1132 | + | }() | |||
| 1133 | + | acq, acqW, _ := os.Pipe() | |||
| 1134 | + | defer acq.Close() | |||
| 1135 | + | defer acqW.Close() | |||
| 1136 | + | win := ports.SceneWindow{ID: 1, Rect: ports.Rect{W: 10, H: 10}} | |||
| 1137 | + | scenes <- ports.Scene{Seq: 1, OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{win}} | |||
| 1138 | + | full := ports.SurfaceContent{ID: 1, Seq: 1, Width: 10, Height: 10, DMABuf: &ports.DMABuf{ID: 5, Planes: []ports.DMABufPlane{{}}}, Acquire: acq} | |||
| 1139 | + | contents <- full | |||
| 1140 | + | drawn(1, func(c ports.SurfaceContent, ok bool) bool { return ok && c.Acquire == acq }) | |||
| 1141 | + | // The window unmaps: its empty content arrives before the scene that | |||
| 1142 | + | // draws it leaving (fading). The last content stays, fence dropped. | |||
| 1143 | + | contents <- ports.SurfaceContent{ID: 1, Seq: 2} | |||
| 1144 | + | kept, _ := drawn(1, func(c ports.SurfaceContent, ok bool) bool { return !ok || c.Acquire == nil }) | |||
| 1145 | + | if kept.DMABuf != full.DMABuf || kept.Seq != 1 { | |||
| 1146 | + | t.Fatalf("frame after the empty content drew %+v, want the last content", kept) | |||
| 1147 | + | } | |||
| 1148 | + | // A leaving frame: still drawn from the kept content. | |||
| 1149 | + | win.Fade = 0.5 | |||
| 1150 | + | scenes <- ports.Scene{Seq: 2, OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{win}} | |||
| 1151 | + | if c, ok := drawn(2, func(ports.SurfaceContent, bool) bool { return true }); !ok || c.DMABuf != full.DMABuf { | |||
| 1152 | + | t.Fatalf("the leaving frame drew %+v (ok %t), want the kept content", c, ok) | |||
| 1153 | + | } | |||
| 1154 | + | // The scene no longer lists it: gone. | |||
| 1155 | + | scenes <- ports.Scene{Seq: 3, OutputWidth: 200, OutputHeight: 100} | |||
| 1156 | + | if c, ok := drawn(3, func(ports.SurfaceContent, bool) bool { return true }); ok { | |||
| 1157 | + | t.Fatalf("the kept content %+v survived a scene without the window", c) | |||
| 1158 | + | } | |||
| 1159 | + | cancel() | |||
| 1160 | + | if err := <-done; err != nil { | |||
| 1161 | + | t.Fatal(err) | |||
| 1162 | + | } | |||
| 1163 | + | } | |||
M internal/adapters/headless/output.go +11 -11
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | "github.com/bnema/neferwl/internal/adapters/capture" | |
| 16 | 16 | "github.com/bnema/neferwl/internal/adapters/clock" | |
| 17 | 17 | "github.com/bnema/neferwl/internal/adapters/presented" | |
| 18 | + | "github.com/bnema/neferwl/internal/adapters/surfaces" | |
| 18 | 19 | "github.com/bnema/neferwl/internal/adapters/syncfile" | |
| 19 | 20 | "github.com/bnema/neferwl/internal/ports" | |
| 20 | 21 | "github.com/bnema/zerowrap" | |
| @@ -87,7 +88,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 87 | 88 | } | |
| 88 | 89 | } | |
| 89 | 90 | } | |
| 90 | - | surfaces := make(map[ports.WindowID]ports.SurfaceContent) | |
| 91 | + | table := surfaces.New() | |
| 92 | + | drawn := table.Map() | |
| 91 | 93 | var scene ports.Scene | |
| 92 | 94 | haveScene, dirty := false, false | |
| 93 | 95 | frame := 0 | |
| @@ -204,15 +206,13 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 204 | 206 | return | |
| 205 | 207 | } | |
| 206 | 208 | scene, haveScene, dirty = s, true, true | |
| 209 | + | // A closed window's last content goes once no scene draws it. | |
| 210 | + | table.Prune(scene) | |
| 207 | 211 | } | |
| 208 | 212 | update := func(c ports.SurfaceContent) { | |
| 209 | 213 | seen[c.ID] = max(seen[c.ID], c.Seq) | |
| 210 | 214 | dirty = dirty || capture.Shows(scene, c.ID) | |
| 211 | - | if c.Empty() { | |
| 212 | - | delete(surfaces, c.ID) | |
| 213 | - | } else { | |
| 214 | - | surfaces[c.ID] = c | |
| 215 | - | } | |
| 215 | + | table.Update(c, scene) | |
| 216 | 216 | } | |
| 217 | 217 | clk := opts.Clock | |
| 218 | 218 | if clk == nil { | |
| @@ -473,7 +473,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 473 | 473 | failRequests("security epoch changed") | |
| 474 | 474 | continue | |
| 475 | 475 | } | |
| 476 | - | pipeline.SubmitHidden(scene, surfaces, hidden) | |
| 476 | + | pipeline.SubmitHidden(scene, drawn, hidden) | |
| 477 | 477 | clear(hidden) | |
| 478 | 478 | // A slow child may outlive Wayland's stale-report timeout even | |
| 479 | 479 | // though this owner waits. Publish its non-expiring holds first. | |
| @@ -494,7 +494,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 494 | 494 | } | |
| 495 | 495 | } | |
| 496 | 496 | // Captures never show the capture indicator: their frame goes first. | |
| 497 | - | took, pdone, perr := pipeline.SubmitPlain(r, scene, surfaces, normal) | |
| 497 | + | took, pdone, perr := pipeline.SubmitPlain(r, scene, drawn, normal) | |
| 498 | 498 | if pdone != nil { | |
| 499 | 499 | // The plain render reads client buffers even when its requests | |
| 500 | 500 | // are rejected by a gate transition: own and wait its fence | |
| @@ -521,7 +521,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 521 | 521 | failRequests("security epoch changed") | |
| 522 | 522 | continue | |
| 523 | 523 | } | |
| 524 | - | xdone, renderErr := r.Render(pipeline.ExcludedScene(scene), surfaces) | |
| 524 | + | xdone, renderErr := r.Render(pipeline.ExcludedScene(scene), drawn) | |
| 525 | 525 | if xdone != nil { | |
| 526 | 526 | // The excluded-frame composition reads client buffers even when the displayed | |
| 527 | 527 | // frame is skipped by a gate/off transition. Own and wait its | |
| @@ -549,7 +549,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 549 | 549 | failRequests("security epoch changed") | |
| 550 | 550 | continue | |
| 551 | 551 | } | |
| 552 | - | done, err := r.Render(scene, surfaces) | |
| 552 | + | done, err := r.Render(scene, drawn) | |
| 553 | 553 | if err != nil { | |
| 554 | 554 | return fmt.Errorf("render frame: %w", err) | |
| 555 | 555 | } | |
| @@ -579,7 +579,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 579 | 579 | pipeline.EndGate(nil) | |
| 580 | 580 | requests = capture.Waiting(requests, scene) | |
| 581 | 581 | frame++ | |
| 582 | - | opts.report(&reports, flipInfo(scene, surfaces), seen) | |
| 582 | + | opts.report(&reports, flipInfo(scene, drawn), seen) | |
| 583 | 583 | if opts.ScreenshotDir != "" && !opts.protected() { | |
| 584 | 584 | shot := r.Pixels() | |
| 585 | 585 | if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security { | |
| @@ -15,6 +15,7 @@ import ( | |||||
| 15 | "github.com/bnema/neferwl/internal/adapters/capture" | 15 | "github.com/bnema/neferwl/internal/adapters/capture" | ||
| 16 | "github.com/bnema/neferwl/internal/adapters/clock" | 16 | "github.com/bnema/neferwl/internal/adapters/clock" | ||
| 17 | "github.com/bnema/neferwl/internal/adapters/presented" | 17 | "github.com/bnema/neferwl/internal/adapters/presented" | ||
| 18 | + | "github.com/bnema/neferwl/internal/adapters/surfaces" | |||
| 18 | "github.com/bnema/neferwl/internal/adapters/syncfile" | 19 | "github.com/bnema/neferwl/internal/adapters/syncfile" | ||
| 19 | "github.com/bnema/neferwl/internal/ports" | 20 | "github.com/bnema/neferwl/internal/ports" | ||
| 20 | "github.com/bnema/zerowrap" | 21 | "github.com/bnema/zerowrap" | ||
| @@ -87,7 +88,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 87 | } | 88 | } | ||
| 88 | } | 89 | } | ||
| 89 | } | 90 | } | ||
| 90 | - | surfaces := make(map[ports.WindowID]ports.SurfaceContent) | 91 | + | table := surfaces.New() |
| 92 | + | drawn := table.Map() | |||
| 91 | var scene ports.Scene | 93 | var scene ports.Scene | ||
| 92 | haveScene, dirty := false, false | 94 | haveScene, dirty := false, false | ||
| 93 | frame := 0 | 95 | frame := 0 | ||
| @@ -204,15 +206,13 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 204 | return | 206 | return | ||
| 205 | } | 207 | } | ||
| 206 | scene, haveScene, dirty = s, true, true | 208 | scene, haveScene, dirty = s, true, true | ||
| 209 | + | // A closed window's last content goes once no scene draws it. | |||
| 210 | + | table.Prune(scene) | |||
| 207 | } | 211 | } | ||
| 208 | update := func(c ports.SurfaceContent) { | 212 | update := func(c ports.SurfaceContent) { | ||
| 209 | seen[c.ID] = max(seen[c.ID], c.Seq) | 213 | seen[c.ID] = max(seen[c.ID], c.Seq) | ||
| 210 | dirty = dirty || capture.Shows(scene, c.ID) | 214 | dirty = dirty || capture.Shows(scene, c.ID) | ||
| 211 | - | if c.Empty() { | 215 | + | table.Update(c, scene) |
| 212 | - | delete(surfaces, c.ID) | |||
| 213 | - | } else { | |||
| 214 | - | surfaces[c.ID] = c | |||
| 215 | - | } | |||
| 216 | } | 216 | } | ||
| 217 | clk := opts.Clock | 217 | clk := opts.Clock | ||
| 218 | if clk == nil { | 218 | if clk == nil { | ||
| @@ -473,7 +473,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 473 | failRequests("security epoch changed") | 473 | failRequests("security epoch changed") | ||
| 474 | continue | 474 | continue | ||
| 475 | } | 475 | } | ||
| 476 | - | pipeline.SubmitHidden(scene, surfaces, hidden) | 476 | + | pipeline.SubmitHidden(scene, drawn, hidden) |
| 477 | clear(hidden) | 477 | clear(hidden) | ||
| 478 | // A slow child may outlive Wayland's stale-report timeout even | 478 | // A slow child may outlive Wayland's stale-report timeout even | ||
| 479 | // though this owner waits. Publish its non-expiring holds first. | 479 | // though this owner waits. Publish its non-expiring holds first. | ||
| @@ -494,7 +494,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 494 | } | 494 | } | ||
| 495 | } | 495 | } | ||
| 496 | // Captures never show the capture indicator: their frame goes first. | 496 | // Captures never show the capture indicator: their frame goes first. | ||
| 497 | - | took, pdone, perr := pipeline.SubmitPlain(r, scene, surfaces, normal) | 497 | + | took, pdone, perr := pipeline.SubmitPlain(r, scene, drawn, normal) |
| 498 | if pdone != nil { | 498 | if pdone != nil { | ||
| 499 | // The plain render reads client buffers even when its requests | 499 | // The plain render reads client buffers even when its requests | ||
| 500 | // are rejected by a gate transition: own and wait its fence | 500 | // are rejected by a gate transition: own and wait its fence | ||
| @@ -521,7 +521,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 521 | failRequests("security epoch changed") | 521 | failRequests("security epoch changed") | ||
| 522 | continue | 522 | continue | ||
| 523 | } | 523 | } | ||
| 524 | - | xdone, renderErr := r.Render(pipeline.ExcludedScene(scene), surfaces) | 524 | + | xdone, renderErr := r.Render(pipeline.ExcludedScene(scene), drawn) |
| 525 | if xdone != nil { | 525 | if xdone != nil { | ||
| 526 | // The excluded-frame composition reads client buffers even when the displayed | 526 | // The excluded-frame composition reads client buffers even when the displayed | ||
| 527 | // frame is skipped by a gate/off transition. Own and wait its | 527 | // frame is skipped by a gate/off transition. Own and wait its | ||
| @@ -549,7 +549,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 549 | failRequests("security epoch changed") | 549 | failRequests("security epoch changed") | ||
| 550 | continue | 550 | continue | ||
| 551 | } | 551 | } | ||
| 552 | - | done, err := r.Render(scene, surfaces) | 552 | + | done, err := r.Render(scene, drawn) |
| 553 | if err != nil { | 553 | if err != nil { | ||
| 554 | return fmt.Errorf("render frame: %w", err) | 554 | return fmt.Errorf("render frame: %w", err) | ||
| 555 | } | 555 | } | ||
| @@ -579,7 +579,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 579 | pipeline.EndGate(nil) | 579 | pipeline.EndGate(nil) | ||
| 580 | requests = capture.Waiting(requests, scene) | 580 | requests = capture.Waiting(requests, scene) | ||
| 581 | frame++ | 581 | frame++ | ||
| 582 | - | opts.report(&reports, flipInfo(scene, surfaces), seen) | 582 | + | opts.report(&reports, flipInfo(scene, drawn), seen) |
| 583 | if opts.ScreenshotDir != "" && !opts.protected() { | 583 | if opts.ScreenshotDir != "" && !opts.protected() { | ||
| 584 | shot := r.Pixels() | 584 | shot := r.Pixels() | ||
| 585 | if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security { | 585 | if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security { | ||
M internal/adapters/headless/output_test.go +11 -2
| @@ -84,18 +84,27 @@ func TestRun(t *testing.T) { | |||
| 84 | 84 | if s, c := f.snapshot(); len(s) != 1 || s[0].Seq != 2 || len(c[0]) != 2 { | |
| 85 | 85 | t.Errorf("coalescing: %+v %+v", s, c) | |
| 86 | 86 | } | |
| 87 | + | // An empty content of a window the scene still draws (it unmapped | |
| 88 | + | // and fades out) keeps the last one; a scene without it drops it. | |
| 87 | 89 | contents <- ports.SurfaceContent{ID: 1} | |
| 88 | 90 | waitFrames(t, f, 2) | |
| 89 | 91 | if _, c := f.snapshot(); len(c) < 2 { | |
| 90 | 92 | t.Fatal("no second frame") | |
| 91 | - | } else if _, ok := c[1][1]; ok { | |
| 93 | + | } else if kept, ok := c[1][1]; !ok || kept.SHM == nil || kept.SHM.Pool != 1 { | |
| 94 | + | t.Errorf("content of the shown window dropped on its empty content: %+v", c[1]) | |
| 95 | + | } | |
| 96 | + | scenes <- ports.Scene{Seq: 3, OutputWidth: 2, OutputHeight: 2, Windows: shown[1:]} | |
| 97 | + | waitFrames(t, f, 3) | |
| 98 | + | if _, c := f.snapshot(); len(c) < 3 { | |
| 99 | + | t.Fatal("no third frame") | |
| 100 | + | } else if _, ok := c[2][1]; ok { | |
| 92 | 101 | t.Error("content not deleted") | |
| 93 | 102 | } | |
| 94 | 103 | cancel() | |
| 95 | 104 | if err := <-done; err != nil { | |
| 96 | 105 | t.Fatal(err) | |
| 97 | 106 | } | |
| 98 | - | for _, name := range []string{"frame-000001.png", "frame-000002.png", "latest.png"} { | |
| 107 | + | for _, name := range []string{"frame-000001.png", "frame-000002.png", "frame-000003.png", "latest.png"} { | |
| 99 | 108 | file, err := os.Open(filepath.Join(dir, name)) | |
| 100 | 109 | if err != nil { | |
| 101 | 110 | t.Fatal(err) | |
| @@ -84,18 +84,27 @@ func TestRun(t *testing.T) { | |||||
| 84 | if s, c := f.snapshot(); len(s) != 1 || s[0].Seq != 2 || len(c[0]) != 2 { | 84 | if s, c := f.snapshot(); len(s) != 1 || s[0].Seq != 2 || len(c[0]) != 2 { | ||
| 85 | t.Errorf("coalescing: %+v %+v", s, c) | 85 | t.Errorf("coalescing: %+v %+v", s, c) | ||
| 86 | } | 86 | } | ||
| 87 | + | // An empty content of a window the scene still draws (it unmapped | |||
| 88 | + | // and fades out) keeps the last one; a scene without it drops it. | |||
| 87 | contents <- ports.SurfaceContent{ID: 1} | 89 | contents <- ports.SurfaceContent{ID: 1} | ||
| 88 | waitFrames(t, f, 2) | 90 | waitFrames(t, f, 2) | ||
| 89 | if _, c := f.snapshot(); len(c) < 2 { | 91 | if _, c := f.snapshot(); len(c) < 2 { | ||
| 90 | t.Fatal("no second frame") | 92 | t.Fatal("no second frame") | ||
| 91 | - | } else if _, ok := c[1][1]; ok { | 93 | + | } else if kept, ok := c[1][1]; !ok || kept.SHM == nil || kept.SHM.Pool != 1 { |
| 94 | + | t.Errorf("content of the shown window dropped on its empty content: %+v", c[1]) | |||
| 95 | + | } | |||
| 96 | + | scenes <- ports.Scene{Seq: 3, OutputWidth: 2, OutputHeight: 2, Windows: shown[1:]} | |||
| 97 | + | waitFrames(t, f, 3) | |||
| 98 | + | if _, c := f.snapshot(); len(c) < 3 { | |||
| 99 | + | t.Fatal("no third frame") | |||
| 100 | + | } else if _, ok := c[2][1]; ok { | |||
| 92 | t.Error("content not deleted") | 101 | t.Error("content not deleted") | ||
| 93 | } | 102 | } | ||
| 94 | cancel() | 103 | cancel() | ||
| 95 | if err := <-done; err != nil { | 104 | if err := <-done; err != nil { | ||
| 96 | t.Fatal(err) | 105 | t.Fatal(err) | ||
| 97 | } | 106 | } | ||
| 98 | - | for _, name := range []string{"frame-000001.png", "frame-000002.png", "latest.png"} { | 107 | + | for _, name := range []string{"frame-000001.png", "frame-000002.png", "frame-000003.png", "latest.png"} { |
| 99 | file, err := os.Open(filepath.Join(dir, name)) | 108 | file, err := os.Open(filepath.Join(dir, name)) | ||
| 100 | if err != nil { | 109 | if err != nil { | ||
| 101 | t.Fatal(err) | 110 | t.Fatal(err) | ||
A internal/adapters/surfaces/surfaces.go +79 -0
| @@ -0,0 +1,79 @@ | |||
| 1 | + | // Package surfaces keeps the latest content of every window for an output's | |
| 2 | + | // renderer, and the last content of a window that closed while the scene | |
| 3 | + | // still draws it. | |
| 4 | + | package surfaces | |
| 5 | + | ||
| 6 | + | import ( | |
| 7 | + | "github.com/bnema/neferwl/internal/adapters/capture" | |
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Table is what an output passes to Renderer.Render: the latest content per | |
| 12 | + | // window. Its owner goroutine is the only user. | |
| 13 | + | // | |
| 14 | + | // A window that unmaps sends an empty content right after the core learns | |
| 15 | + | // of it; the scene that follows may keep drawing the window while it fades | |
| 16 | + | // out (ports.SceneWindow of a leaving window). The table then keeps the | |
| 17 | + | // window's last content until a scene no longer lists it, so the renderer | |
| 18 | + | // draws it from the buffer it already imported or copied: no new upload, | |
| 19 | + | // no copy. Its Acquire fence is dropped: the first frame that drew the | |
| 20 | + | // content waited on it, and wayland closes it once the buffer is released. | |
| 21 | + | // | |
| 22 | + | // Buffer lifetime: wayland releases the buffer to the client once the | |
| 23 | + | // output reports the empty content as seen, so the client may draw into or | |
| 24 | + | // destroy it during the fade. A dmabuf stays importable through the | |
| 25 | + | // renderer's own duplicated descriptors (a destroyed one shows as stale | |
| 26 | + | // pixels at worst); a wl_shm pool stays mapped by the renderer while it is | |
| 27 | + | // drawn, so a destroyed pool is still readable (a client that truncates the | |
| 28 | + | // file under a mapping has always been able to fault a reader). | |
| 29 | + | type Table struct { | |
| 30 | + | m map[ports.WindowID]ports.SurfaceContent | |
| 31 | + | // kept are the windows drawn from a content their client withdrew. | |
| 32 | + | kept map[ports.WindowID]struct{} | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // New returns an empty table. | |
| 36 | + | func New() *Table { | |
| 37 | + | return &Table{m: make(map[ports.WindowID]ports.SurfaceContent), kept: make(map[ports.WindowID]struct{})} | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // Map is the contents the renderer draws from. It is the table's own map: | |
| 41 | + | // read it, never keep it across an Update or Prune. | |
| 42 | + | func (t *Table) Map() map[ports.WindowID]ports.SurfaceContent { return t.m } | |
| 43 | + | ||
| 44 | + | // Update takes a window's new content. An empty one (the window unmapped) | |
| 45 | + | // keeps the previous content, without its Acquire fence, while scene draws | |
| 46 | + | // the window; it drops it otherwise. | |
| 47 | + | func (t *Table) Update(c ports.SurfaceContent, scene ports.Scene) { | |
| 48 | + | if !c.Empty() { | |
| 49 | + | t.m[c.ID] = c | |
| 50 | + | delete(t.kept, c.ID) | |
| 51 | + | return | |
| 52 | + | } | |
| 53 | + | prev, ok := t.m[c.ID] | |
| 54 | + | if !ok || !capture.Shows(scene, c.ID) { | |
| 55 | + | delete(t.m, c.ID) | |
| 56 | + | delete(t.kept, c.ID) | |
| 57 | + | return | |
| 58 | + | } | |
| 59 | + | prev.Acquire = nil | |
| 60 | + | t.m[c.ID] = prev | |
| 61 | + | t.kept[c.ID] = struct{}{} | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // Prune drops the kept contents of windows scene no longer draws. Outputs | |
| 65 | + | // call it with every scene they accept. | |
| 66 | + | func (t *Table) Prune(scene ports.Scene) { | |
| 67 | + | for id := range t.kept { | |
| 68 | + | if !capture.Shows(scene, id) { | |
| 69 | + | delete(t.m, id) | |
| 70 | + | delete(t.kept, id) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // Kept reports whether id is drawn from a content its client withdrew. | |
| 76 | + | func (t *Table) Kept(id ports.WindowID) bool { | |
| 77 | + | _, ok := t.kept[id] | |
| 78 | + | return ok | |
| 79 | + | } | |
| @@ -0,0 +1,79 @@ | |||||
| 1 | + | // Package surfaces keeps the latest content of every window for an output's | |||
| 2 | + | // renderer, and the last content of a window that closed while the scene | |||
| 3 | + | // still draws it. | |||
| 4 | + | package surfaces | |||
| 5 | + | ||||
| 6 | + | import ( | |||
| 7 | + | "github.com/bnema/neferwl/internal/adapters/capture" | |||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // Table is what an output passes to Renderer.Render: the latest content per | |||
| 12 | + | // window. Its owner goroutine is the only user. | |||
| 13 | + | // | |||
| 14 | + | // A window that unmaps sends an empty content right after the core learns | |||
| 15 | + | // of it; the scene that follows may keep drawing the window while it fades | |||
| 16 | + | // out (ports.SceneWindow of a leaving window). The table then keeps the | |||
| 17 | + | // window's last content until a scene no longer lists it, so the renderer | |||
| 18 | + | // draws it from the buffer it already imported or copied: no new upload, | |||
| 19 | + | // no copy. Its Acquire fence is dropped: the first frame that drew the | |||
| 20 | + | // content waited on it, and wayland closes it once the buffer is released. | |||
| 21 | + | // | |||
| 22 | + | // Buffer lifetime: wayland releases the buffer to the client once the | |||
| 23 | + | // output reports the empty content as seen, so the client may draw into or | |||
| 24 | + | // destroy it during the fade. A dmabuf stays importable through the | |||
| 25 | + | // renderer's own duplicated descriptors (a destroyed one shows as stale | |||
| 26 | + | // pixels at worst); a wl_shm pool stays mapped by the renderer while it is | |||
| 27 | + | // drawn, so a destroyed pool is still readable (a client that truncates the | |||
| 28 | + | // file under a mapping has always been able to fault a reader). | |||
| 29 | + | type Table struct { | |||
| 30 | + | m map[ports.WindowID]ports.SurfaceContent | |||
| 31 | + | // kept are the windows drawn from a content their client withdrew. | |||
| 32 | + | kept map[ports.WindowID]struct{} | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // New returns an empty table. | |||
| 36 | + | func New() *Table { | |||
| 37 | + | return &Table{m: make(map[ports.WindowID]ports.SurfaceContent), kept: make(map[ports.WindowID]struct{})} | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // Map is the contents the renderer draws from. It is the table's own map: | |||
| 41 | + | // read it, never keep it across an Update or Prune. | |||
| 42 | + | func (t *Table) Map() map[ports.WindowID]ports.SurfaceContent { return t.m } | |||
| 43 | + | ||||
| 44 | + | // Update takes a window's new content. An empty one (the window unmapped) | |||
| 45 | + | // keeps the previous content, without its Acquire fence, while scene draws | |||
| 46 | + | // the window; it drops it otherwise. | |||
| 47 | + | func (t *Table) Update(c ports.SurfaceContent, scene ports.Scene) { | |||
| 48 | + | if !c.Empty() { | |||
| 49 | + | t.m[c.ID] = c | |||
| 50 | + | delete(t.kept, c.ID) | |||
| 51 | + | return | |||
| 52 | + | } | |||
| 53 | + | prev, ok := t.m[c.ID] | |||
| 54 | + | if !ok || !capture.Shows(scene, c.ID) { | |||
| 55 | + | delete(t.m, c.ID) | |||
| 56 | + | delete(t.kept, c.ID) | |||
| 57 | + | return | |||
| 58 | + | } | |||
| 59 | + | prev.Acquire = nil | |||
| 60 | + | t.m[c.ID] = prev | |||
| 61 | + | t.kept[c.ID] = struct{}{} | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // Prune drops the kept contents of windows scene no longer draws. Outputs | |||
| 65 | + | // call it with every scene they accept. | |||
| 66 | + | func (t *Table) Prune(scene ports.Scene) { | |||
| 67 | + | for id := range t.kept { | |||
| 68 | + | if !capture.Shows(scene, id) { | |||
| 69 | + | delete(t.m, id) | |||
| 70 | + | delete(t.kept, id) | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // Kept reports whether id is drawn from a content its client withdrew. | |||
| 76 | + | func (t *Table) Kept(id ports.WindowID) bool { | |||
| 77 | + | _, ok := t.kept[id] | |||
| 78 | + | return ok | |||
| 79 | + | } | |||
A internal/adapters/surfaces/surfaces_test.go +101 -0
| @@ -0,0 +1,101 @@ | |||
| 1 | + | package surfaces | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func shows(ids ...ports.WindowID) ports.Scene { | |
| 11 | + | s := ports.Scene{OutputWidth: 100, OutputHeight: 100} | |
| 12 | + | for _, id := range ids { | |
| 13 | + | s.Windows = append(s.Windows, ports.SceneWindow{ID: id, Rect: ports.Rect{W: 10, H: 10}}) | |
| 14 | + | } | |
| 15 | + | return s | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // An empty content of a window the scene draws keeps the previous one with | |
| 19 | + | // its Acquire fence dropped; the content goes once a scene no longer lists | |
| 20 | + | // the window. | |
| 21 | + | func TestEmptyContentKeptWhileShown(t *testing.T) { | |
| 22 | + | tb := New() | |
| 23 | + | r, w, err := os.Pipe() | |
| 24 | + | if err != nil { | |
| 25 | + | t.Fatal(err) | |
| 26 | + | } | |
| 27 | + | defer r.Close() | |
| 28 | + | defer w.Close() | |
| 29 | + | full := ports.SurfaceContent{ID: 1, Seq: 4, Width: 10, Height: 10, DMABuf: &ports.DMABuf{ID: 7}, Acquire: r} | |
| 30 | + | tb.Update(full, shows(1)) | |
| 31 | + | tb.Update(ports.SurfaceContent{ID: 1, Seq: 5}, shows(1)) | |
| 32 | + | kept, ok := tb.Map()[1] | |
| 33 | + | if !ok || kept.DMABuf != full.DMABuf || kept.Seq != 4 { | |
| 34 | + | t.Fatalf("kept %+v, want the previous content", kept) | |
| 35 | + | } | |
| 36 | + | if kept.Acquire != nil { | |
| 37 | + | t.Fatal("the kept content keeps its Acquire fence") | |
| 38 | + | } | |
| 39 | + | if !tb.Kept(1) { | |
| 40 | + | t.Fatal("Kept false for a content drawn after its empty one") | |
| 41 | + | } | |
| 42 | + | // A scene still listing it keeps it; one without it drops it. | |
| 43 | + | tb.Prune(shows(1, 2)) | |
| 44 | + | if _, ok := tb.Map()[1]; !ok { | |
| 45 | + | t.Fatal("dropped while still listed") | |
| 46 | + | } | |
| 47 | + | tb.Prune(shows(2)) | |
| 48 | + | if _, ok := tb.Map()[1]; ok || tb.Kept(1) { | |
| 49 | + | t.Fatal("not dropped once unlisted") | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // An empty content of a window the scene does not draw drops it at once. | |
| 54 | + | func TestEmptyContentOfUnshownWindowDropped(t *testing.T) { | |
| 55 | + | tb := New() | |
| 56 | + | tb.Update(ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{Pool: 1}}, shows(1)) | |
| 57 | + | tb.Update(ports.SurfaceContent{ID: 1}, shows(2)) | |
| 58 | + | if _, ok := tb.Map()[1]; ok || tb.Kept(1) { | |
| 59 | + | t.Fatal("content of an unshown window kept") | |
| 60 | + | } | |
| 61 | + | // Hidden counts as not drawn. | |
| 62 | + | tb.Update(ports.SurfaceContent{ID: 3, SHM: &ports.SHMBuffer{Pool: 3}}, shows()) | |
| 63 | + | hidden := ports.Scene{OutputWidth: 100, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 3, Hidden: true, Rect: ports.Rect{W: 10, H: 10}}}} | |
| 64 | + | tb.Update(ports.SurfaceContent{ID: 3}, hidden) | |
| 65 | + | if _, ok := tb.Map()[3]; ok { | |
| 66 | + | t.Fatal("content of a hidden window kept") | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // A new content replaces a kept one and the window is no longer kept (the | |
| 71 | + | // client mapped again). | |
| 72 | + | func TestNewContentReplacesKept(t *testing.T) { | |
| 73 | + | tb := New() | |
| 74 | + | tb.Update(ports.SurfaceContent{ID: 1, Seq: 1, SHM: &ports.SHMBuffer{Pool: 1}}, shows(1)) | |
| 75 | + | tb.Update(ports.SurfaceContent{ID: 1, Seq: 2}, shows(1)) | |
| 76 | + | tb.Update(ports.SurfaceContent{ID: 1, Seq: 3, SHM: &ports.SHMBuffer{Pool: 2}}, shows(1)) | |
| 77 | + | if c := tb.Map()[1]; c.Seq != 3 || c.SHM.Pool != 2 || tb.Kept(1) { | |
| 78 | + | t.Fatalf("after a new content: %+v kept %t", c, tb.Kept(1)) | |
| 79 | + | } | |
| 80 | + | // The map handed out is the table's own: the renderer sees updates. | |
| 81 | + | m := tb.Map() | |
| 82 | + | tb.Update(ports.SurfaceContent{ID: 2, Seq: 1, SHM: &ports.SHMBuffer{Pool: 9}}, shows(1, 2)) | |
| 83 | + | if _, ok := m[2]; !ok { | |
| 84 | + | t.Fatal("Map is a copy") | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // Update and Prune allocate nothing in steady state. | |
| 89 | + | func TestUpdateAllocations(t *testing.T) { | |
| 90 | + | tb := New() | |
| 91 | + | s := shows(1, 2) | |
| 92 | + | c := ports.SurfaceContent{ID: 1, Seq: 1, SHM: &ports.SHMBuffer{Pool: 1}} | |
| 93 | + | tb.Update(c, s) | |
| 94 | + | if n := testing.AllocsPerRun(100, func() { | |
| 95 | + | c.Seq++ | |
| 96 | + | tb.Update(c, s) | |
| 97 | + | tb.Prune(s) | |
| 98 | + | }); n != 0 { | |
| 99 | + | t.Fatalf("%v allocations per update", n) | |
| 100 | + | } | |
| 101 | + | } | |
| @@ -0,0 +1,101 @@ | |||||
| 1 | + | package surfaces | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func shows(ids ...ports.WindowID) ports.Scene { | |||
| 11 | + | s := ports.Scene{OutputWidth: 100, OutputHeight: 100} | |||
| 12 | + | for _, id := range ids { | |||
| 13 | + | s.Windows = append(s.Windows, ports.SceneWindow{ID: id, Rect: ports.Rect{W: 10, H: 10}}) | |||
| 14 | + | } | |||
| 15 | + | return s | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // An empty content of a window the scene draws keeps the previous one with | |||
| 19 | + | // its Acquire fence dropped; the content goes once a scene no longer lists | |||
| 20 | + | // the window. | |||
| 21 | + | func TestEmptyContentKeptWhileShown(t *testing.T) { | |||
| 22 | + | tb := New() | |||
| 23 | + | r, w, err := os.Pipe() | |||
| 24 | + | if err != nil { | |||
| 25 | + | t.Fatal(err) | |||
| 26 | + | } | |||
| 27 | + | defer r.Close() | |||
| 28 | + | defer w.Close() | |||
| 29 | + | full := ports.SurfaceContent{ID: 1, Seq: 4, Width: 10, Height: 10, DMABuf: &ports.DMABuf{ID: 7}, Acquire: r} | |||
| 30 | + | tb.Update(full, shows(1)) | |||
| 31 | + | tb.Update(ports.SurfaceContent{ID: 1, Seq: 5}, shows(1)) | |||
| 32 | + | kept, ok := tb.Map()[1] | |||
| 33 | + | if !ok || kept.DMABuf != full.DMABuf || kept.Seq != 4 { | |||
| 34 | + | t.Fatalf("kept %+v, want the previous content", kept) | |||
| 35 | + | } | |||
| 36 | + | if kept.Acquire != nil { | |||
| 37 | + | t.Fatal("the kept content keeps its Acquire fence") | |||
| 38 | + | } | |||
| 39 | + | if !tb.Kept(1) { | |||
| 40 | + | t.Fatal("Kept false for a content drawn after its empty one") | |||
| 41 | + | } | |||
| 42 | + | // A scene still listing it keeps it; one without it drops it. | |||
| 43 | + | tb.Prune(shows(1, 2)) | |||
| 44 | + | if _, ok := tb.Map()[1]; !ok { | |||
| 45 | + | t.Fatal("dropped while still listed") | |||
| 46 | + | } | |||
| 47 | + | tb.Prune(shows(2)) | |||
| 48 | + | if _, ok := tb.Map()[1]; ok || tb.Kept(1) { | |||
| 49 | + | t.Fatal("not dropped once unlisted") | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // An empty content of a window the scene does not draw drops it at once. | |||
| 54 | + | func TestEmptyContentOfUnshownWindowDropped(t *testing.T) { | |||
| 55 | + | tb := New() | |||
| 56 | + | tb.Update(ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{Pool: 1}}, shows(1)) | |||
| 57 | + | tb.Update(ports.SurfaceContent{ID: 1}, shows(2)) | |||
| 58 | + | if _, ok := tb.Map()[1]; ok || tb.Kept(1) { | |||
| 59 | + | t.Fatal("content of an unshown window kept") | |||
| 60 | + | } | |||
| 61 | + | // Hidden counts as not drawn. | |||
| 62 | + | tb.Update(ports.SurfaceContent{ID: 3, SHM: &ports.SHMBuffer{Pool: 3}}, shows()) | |||
| 63 | + | hidden := ports.Scene{OutputWidth: 100, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 3, Hidden: true, Rect: ports.Rect{W: 10, H: 10}}}} | |||
| 64 | + | tb.Update(ports.SurfaceContent{ID: 3}, hidden) | |||
| 65 | + | if _, ok := tb.Map()[3]; ok { | |||
| 66 | + | t.Fatal("content of a hidden window kept") | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // A new content replaces a kept one and the window is no longer kept (the | |||
| 71 | + | // client mapped again). | |||
| 72 | + | func TestNewContentReplacesKept(t *testing.T) { | |||
| 73 | + | tb := New() | |||
| 74 | + | tb.Update(ports.SurfaceContent{ID: 1, Seq: 1, SHM: &ports.SHMBuffer{Pool: 1}}, shows(1)) | |||
| 75 | + | tb.Update(ports.SurfaceContent{ID: 1, Seq: 2}, shows(1)) | |||
| 76 | + | tb.Update(ports.SurfaceContent{ID: 1, Seq: 3, SHM: &ports.SHMBuffer{Pool: 2}}, shows(1)) | |||
| 77 | + | if c := tb.Map()[1]; c.Seq != 3 || c.SHM.Pool != 2 || tb.Kept(1) { | |||
| 78 | + | t.Fatalf("after a new content: %+v kept %t", c, tb.Kept(1)) | |||
| 79 | + | } | |||
| 80 | + | // The map handed out is the table's own: the renderer sees updates. | |||
| 81 | + | m := tb.Map() | |||
| 82 | + | tb.Update(ports.SurfaceContent{ID: 2, Seq: 1, SHM: &ports.SHMBuffer{Pool: 9}}, shows(1, 2)) | |||
| 83 | + | if _, ok := m[2]; !ok { | |||
| 84 | + | t.Fatal("Map is a copy") | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // Update and Prune allocate nothing in steady state. | |||
| 89 | + | func TestUpdateAllocations(t *testing.T) { | |||
| 90 | + | tb := New() | |||
| 91 | + | s := shows(1, 2) | |||
| 92 | + | c := ports.SurfaceContent{ID: 1, Seq: 1, SHM: &ports.SHMBuffer{Pool: 1}} | |||
| 93 | + | tb.Update(c, s) | |||
| 94 | + | if n := testing.AllocsPerRun(100, func() { | |||
| 95 | + | c.Seq++ | |||
| 96 | + | tb.Update(c, s) | |||
| 97 | + | tb.Prune(s) | |||
| 98 | + | }); n != 0 { | |||
| 99 | + | t.Fatalf("%v allocations per update", n) | |||
| 100 | + | } | |||
| 101 | + | } | |||
M internal/core/core.go +1 -4
| @@ -1154,10 +1154,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1154 | 1154 | if c.drag != nil && c.drag.id == v.ID { | |
| 1155 | 1155 | c.abortDrag() | |
| 1156 | 1156 | } | |
| 1157 | - | if s, _ := c.screenOf(v.ID); s != nil { | |
| 1158 | - | s.mon.RemoveWindow(v.ID) | |
| 1159 | - | delete(s.rects, v.ID) | |
| 1160 | - | } | |
| 1157 | + | c.unmapWindow(v) | |
| 1161 | 1158 | c.releaseSlots() | |
| 1162 | 1159 | if c.pointer == v.ID { | |
| 1163 | 1160 | c.pointer = 0 | |
| @@ -1154,10 +1154,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1154 | if c.drag != nil && c.drag.id == v.ID { | 1154 | if c.drag != nil && c.drag.id == v.ID { | ||
| 1155 | c.abortDrag() | 1155 | c.abortDrag() | ||
| 1156 | } | 1156 | } | ||
| 1157 | - | if s, _ := c.screenOf(v.ID); s != nil { | 1157 | + | c.unmapWindow(v) |
| 1158 | - | s.mon.RemoveWindow(v.ID) | |||
| 1159 | - | delete(s.rects, v.ID) | |||
| 1160 | - | } | |||
| 1161 | c.releaseSlots() | 1158 | c.releaseSlots() | ||
| 1162 | if c.pointer == v.ID { | 1159 | if c.pointer == v.ID { | ||
| 1163 | c.pointer = 0 | 1160 | c.pointer = 0 | ||
M internal/core/transition.go +42 -0
| @@ -425,6 +425,48 @@ func (c *Core) appearMapped(id WindowID, now time.Time) { | |||
| 425 | 425 | } | |
| 426 | 426 | } | |
| 427 | 427 | ||
| 428 | + | // unmapWindow handles a window's unmap: it leaves its workspace, the tiles | |
| 429 | + | // around it re-flow with rect motions, and a window that was drawn as a | |
| 430 | + | // tile or an ordinary float keeps fading out from its last settled | |
| 431 | + | // placement (leave). Like a map, an unmap is a client event that animates; | |
| 432 | + | // the settled layout is the same with animations off. A fullscreen window, | |
| 433 | + | // one under a session lock or with the overview open just goes. | |
| 434 | + | func (c *Core) unmapWindow(v ports.WindowUnmapped) { | |
| 435 | + | sc, w := c.screenOf(v.ID) | |
| 436 | + | if sc == nil { | |
| 437 | + | return | |
| 438 | + | } | |
| 439 | + | if !c.animOn() || c.security.Protected || sc.mon.ov.open { | |
| 440 | + | sc.mon.RemoveWindow(v.ID) | |
| 441 | + | delete(sc.rects, v.ID) | |
| 442 | + | return | |
| 443 | + | } | |
| 444 | + | now := c.now() | |
| 445 | + | // The placement that leaves is the settled one of the last publish: | |
| 446 | + | // where the window was going (its rect motion, if any, ends with it). | |
| 447 | + | var left Placement | |
| 448 | + | if sc.mon.Current() == w { | |
| 449 | + | for _, p := range sc.settledLayout { | |
| 450 | + | if p.ID == v.ID { | |
| 451 | + | left = p | |
| 452 | + | break | |
| 453 | + | } | |
| 454 | + | } | |
| 455 | + | } | |
| 456 | + | shots := c.snapshot(now) | |
| 457 | + | for i := range shots { | |
| 458 | + | if shots[i].overview { | |
| 459 | + | shots[i].ws = nil | |
| 460 | + | } | |
| 461 | + | } | |
| 462 | + | sc.mon.RemoveWindow(v.ID) | |
| 463 | + | delete(sc.rects, v.ID) | |
| 464 | + | c.transition(shots, now) | |
| 465 | + | if left.ID == v.ID && !left.Leaving && !left.Fullscreen && !left.Below && !left.Peek { | |
| 466 | + | c.leave(sc, left, now) | |
| 467 | + | } | |
| 468 | + | } | |
| 469 | + | ||
| 428 | 470 | // resizeFloating applies a floating window's committed size. A float maps | |
| 429 | 471 | // before its first commit, so core first lays it out at a default size; | |
| 430 | 472 | // when its entrance runs, the settled rect changing under it would break | |
| @@ -425,6 +425,48 @@ func (c *Core) appearMapped(id WindowID, now time.Time) { | |||||
| 425 | } | 425 | } | ||
| 426 | } | 426 | } | ||
| 427 | 427 | ||||
| 428 | + | // unmapWindow handles a window's unmap: it leaves its workspace, the tiles | |||
| 429 | + | // around it re-flow with rect motions, and a window that was drawn as a | |||
| 430 | + | // tile or an ordinary float keeps fading out from its last settled | |||
| 431 | + | // placement (leave). Like a map, an unmap is a client event that animates; | |||
| 432 | + | // the settled layout is the same with animations off. A fullscreen window, | |||
| 433 | + | // one under a session lock or with the overview open just goes. | |||
| 434 | + | func (c *Core) unmapWindow(v ports.WindowUnmapped) { | |||
| 435 | + | sc, w := c.screenOf(v.ID) | |||
| 436 | + | if sc == nil { | |||
| 437 | + | return | |||
| 438 | + | } | |||
| 439 | + | if !c.animOn() || c.security.Protected || sc.mon.ov.open { | |||
| 440 | + | sc.mon.RemoveWindow(v.ID) | |||
| 441 | + | delete(sc.rects, v.ID) | |||
| 442 | + | return | |||
| 443 | + | } | |||
| 444 | + | now := c.now() | |||
| 445 | + | // The placement that leaves is the settled one of the last publish: | |||
| 446 | + | // where the window was going (its rect motion, if any, ends with it). | |||
| 447 | + | var left Placement | |||
| 448 | + | if sc.mon.Current() == w { | |||
| 449 | + | for _, p := range sc.settledLayout { | |||
| 450 | + | if p.ID == v.ID { | |||
| 451 | + | left = p | |||
| 452 | + | break | |||
| 453 | + | } | |||
| 454 | + | } | |||
| 455 | + | } | |||
| 456 | + | shots := c.snapshot(now) | |||
| 457 | + | for i := range shots { | |||
| 458 | + | if shots[i].overview { | |||
| 459 | + | shots[i].ws = nil | |||
| 460 | + | } | |||
| 461 | + | } | |||
| 462 | + | sc.mon.RemoveWindow(v.ID) | |||
| 463 | + | delete(sc.rects, v.ID) | |||
| 464 | + | c.transition(shots, now) | |||
| 465 | + | if left.ID == v.ID && !left.Leaving && !left.Fullscreen && !left.Below && !left.Peek { | |||
| 466 | + | c.leave(sc, left, now) | |||
| 467 | + | } | |||
| 468 | + | } | |||
| 469 | + | ||||
| 428 | // resizeFloating applies a floating window's committed size. A float maps | 470 | // resizeFloating applies a floating window's committed size. A float maps | ||
| 429 | // before its first commit, so core first lays it out at a default size; | 471 | // before its first commit, so core first lays it out at a default size; | ||
| 430 | // when its entrance runs, the settled rect changing under it would break | 472 | // when its entrance runs, the settled rect changing under it would break | ||
M internal/core/transition_test.go +66 -34
| @@ -648,43 +648,75 @@ func TestDiscreteSwipeTransition(t *testing.T) { | |||
| 648 | 648 | } | |
| 649 | 649 | } | |
| 650 | 650 | ||
| 651 | - | // A window unmapped while its rect motion runs leaves the scene at once, its | |
| 652 | - | // motion goes with it, the others keep theirs, and once those settle no | |
| 653 | - | // frame is wanted: a motion kept for a gone window would ask for frames with | |
| 654 | - | // nothing to move. | |
| 651 | + | // A window unmapped while its rect motion runs stops moving: with | |
| 652 | + | // animations on it stays in the scene only as a leaving entry (drawn where | |
| 653 | + | // it was going, fading out, hidden to input), the others keep their | |
| 654 | + | // motions, and once everything settles it is gone and no frame is wanted: | |
| 655 | + | // a motion kept for a gone window would ask for frames with nothing to | |
| 656 | + | // move. With animations off it leaves the scene at once. | |
| 655 | 657 | func TestUnmapMidRectMotion(t *testing.T) { | |
| 656 | - | r := startSwipe(t, nil) | |
| 657 | - | threeColumns(t, r) | |
| 658 | - | // 3, focused, swaps with 2: both move. | |
| 659 | - | r.key(t, "Left", ports.ModAlt|ports.ModShift) | |
| 660 | - | mid := r.frame(t, 16*time.Millisecond) | |
| 661 | - | if x := rectNow(t, mid, 3).X; x <= 0 || x >= 400 { | |
| 662 | - | t.Fatalf("window 3 at %d on the first frame, want it moving", x) | |
| 663 | - | } | |
| 664 | - | moving := rectNow(t, mid, 2).X | |
| 665 | - | r.client <- ports.WindowUnmapped{ID: 3} | |
| 666 | - | s := scene(t, r.scenes) | |
| 667 | - | if _, ok := rectOf(s, 3); ok { | |
| 668 | - | t.Fatal("the unmapped window is still shown") | |
| 658 | + | for _, tt := range []struct { | |
| 659 | + | name string | |
| 660 | + | edit func(*ports.Config) | |
| 661 | + | }{{"on", nil}, {"off", animationsOff}} { | |
| 662 | + | t.Run(tt.name, func(t *testing.T) { | |
| 663 | + | r := startSwipe(t, tt.edit) | |
| 664 | + | threeColumns(t, r) | |
| 665 | + | // 3, focused, swaps with 2: both move. | |
| 666 | + | mid := r.key(t, "Left", ports.ModAlt|ports.ModShift)[0] | |
| 667 | + | if tt.edit == nil { | |
| 668 | + | // Off: the swap is instant, a flip draws no new scene. | |
| 669 | + | mid = r.frame(t, 16*time.Millisecond) | |
| 670 | + | if x := rectNow(t, mid, 3).X; x <= 0 || x >= 400 { | |
| 671 | + | t.Fatalf("window 3 at %d on the first frame, want it moving", x) | |
| 672 | + | } | |
| 673 | + | } | |
| 674 | + | moving := rectNow(t, mid, 2).X | |
| 675 | + | r.client <- ports.WindowUnmapped{ID: 3} | |
| 676 | + | s := scene(t, r.scenes) | |
| 677 | + | w3, shown3 := listedIn(s, 3) | |
| 678 | + | switch { | |
| 679 | + | case tt.edit != nil && shown3: | |
| 680 | + | t.Fatalf("the unmapped window is still shown with animations off: %+v", w3) | |
| 681 | + | case tt.edit == nil && (!shown3 || w3.Fade != 0 || w3.Rect.X != 0 || w3.Rect.W != 400): | |
| 682 | + | // It leaves from the settled rect it was moving to. | |
| 683 | + | t.Fatalf("the unmapped window right after the unmap: %+v (shown %t), want it leaving from its settled slot", w3, shown3) | |
| 684 | + | } | |
| 685 | + | // The neighbour's own motion carries on from where it was drawn. | |
| 686 | + | if got := rectNow(t, s, 2).X; got != moving { | |
| 687 | + | t.Fatalf("window 2 jumped from %d to %d on the unmap", moving, got) | |
| 688 | + | } | |
| 689 | + | if tt.edit == nil { | |
| 690 | + | next := r.frame(t, 16*time.Millisecond) | |
| 691 | + | if got := rectNow(t, next, 2).X; got == moving { | |
| 692 | + | t.Fatalf("window 2 stopped at %d: its motion died with the unmapped window", got) | |
| 693 | + | } | |
| 694 | + | if w, ok := listedIn(next, 3); !ok || !(w.Fade > 0 && w.Fade < 1) || w.Rect.W >= 400 { | |
| 695 | + | t.Fatalf("the leaving window on the next frame: %+v (shown %t), want it fading and shrinking", w, ok) | |
| 696 | + | } | |
| 697 | + | } else if moving != 400 { | |
| 698 | + | t.Fatalf("window 2 at %d with animations off, want 400", moving) | |
| 699 | + | } | |
| 700 | + | if tt.edit == nil { | |
| 701 | + | s = r.settle(t) | |
| 702 | + | } | |
| 703 | + | if _, ok := listedIn(s, 3); ok { | |
| 704 | + | t.Fatal("the unmapped window came back") | |
| 705 | + | } | |
| 706 | + | if got := rectNow(t, s, 2).X; got != 400 { | |
| 707 | + | t.Fatalf("window 2 settled at %d, want 400", got) | |
| 708 | + | } | |
| 709 | + | r.noFrameScene(t) | |
| 710 | + | }) | |
| 669 | 711 | } | |
| 712 | + | } | |
| 713 | + | ||
| 714 | + | // listedIn is the scene window id, shown or not. | |
| 715 | + | func listedIn(s ports.Scene, id ports.WindowID) (ports.SceneWindow, bool) { | |
| 670 | 716 | for _, w := range s.Windows { | |
| 671 | - | if w.ID == 3 { | |
| 672 | - | t.Fatalf("window 3 still in the scene: %+v", w) | |
| 717 | + | if w.ID == id { | |
| 718 | + | return w, true | |
| 673 | 719 | } | |
| 674 | 720 | } | |
| 675 | - | // The neighbour's own motion carries on from where it was drawn. | |
| 676 | - | if got := rectNow(t, s, 2).X; got != moving { | |
| 677 | - | t.Fatalf("window 2 jumped from %d to %d on the unmap", moving, got) | |
| 678 | - | } | |
| 679 | - | if got := rectNow(t, r.frame(t, 16*time.Millisecond), 2).X; got == moving { | |
| 680 | - | t.Fatalf("window 2 stopped at %d: its motion died with the unmapped window", got) | |
| 681 | - | } | |
| 682 | - | s = r.settle(t) | |
| 683 | - | if _, ok := rectOf(s, 3); ok { | |
| 684 | - | t.Fatal("the unmapped window came back") | |
| 685 | - | } | |
| 686 | - | if got := rectNow(t, s, 2).X; got != 400 { | |
| 687 | - | t.Fatalf("window 2 settled at %d, want 400", got) | |
| 688 | - | } | |
| 689 | - | r.noFrameScene(t) | |
| 721 | + | return ports.SceneWindow{}, false | |
| 690 | 722 | } | |
| @@ -648,43 +648,75 @@ func TestDiscreteSwipeTransition(t *testing.T) { | |||||
| 648 | } | 648 | } | ||
| 649 | } | 649 | } | ||
| 650 | 650 | ||||
| 651 | - | // A window unmapped while its rect motion runs leaves the scene at once, its | 651 | + | // A window unmapped while its rect motion runs stops moving: with |
| 652 | - | // motion goes with it, the others keep theirs, and once those settle no | 652 | + | // animations on it stays in the scene only as a leaving entry (drawn where |
| 653 | - | // frame is wanted: a motion kept for a gone window would ask for frames with | 653 | + | // it was going, fading out, hidden to input), the others keep their |
| 654 | - | // nothing to move. | 654 | + | // motions, and once everything settles it is gone and no frame is wanted: |
| 655 | + | // a motion kept for a gone window would ask for frames with nothing to | |||
| 656 | + | // move. With animations off it leaves the scene at once. | |||
| 655 | func TestUnmapMidRectMotion(t *testing.T) { | 657 | func TestUnmapMidRectMotion(t *testing.T) { | ||
| 656 | - | r := startSwipe(t, nil) | 658 | + | for _, tt := range []struct { |
| 657 | - | threeColumns(t, r) | 659 | + | name string |
| 658 | - | // 3, focused, swaps with 2: both move. | 660 | + | edit func(*ports.Config) |
| 659 | - | r.key(t, "Left", ports.ModAlt|ports.ModShift) | 661 | + | }{{"on", nil}, {"off", animationsOff}} { |
| 660 | - | mid := r.frame(t, 16*time.Millisecond) | 662 | + | t.Run(tt.name, func(t *testing.T) { |
| 661 | - | if x := rectNow(t, mid, 3).X; x <= 0 || x >= 400 { | 663 | + | r := startSwipe(t, tt.edit) |
| 662 | - | t.Fatalf("window 3 at %d on the first frame, want it moving", x) | 664 | + | threeColumns(t, r) |
| 663 | - | } | 665 | + | // 3, focused, swaps with 2: both move. |
| 664 | - | moving := rectNow(t, mid, 2).X | 666 | + | mid := r.key(t, "Left", ports.ModAlt|ports.ModShift)[0] |
| 665 | - | r.client <- ports.WindowUnmapped{ID: 3} | 667 | + | if tt.edit == nil { |
| 666 | - | s := scene(t, r.scenes) | 668 | + | // Off: the swap is instant, a flip draws no new scene. |
| 667 | - | if _, ok := rectOf(s, 3); ok { | 669 | + | mid = r.frame(t, 16*time.Millisecond) |
| 668 | - | t.Fatal("the unmapped window is still shown") | 670 | + | if x := rectNow(t, mid, 3).X; x <= 0 || x >= 400 { |
| 671 | + | t.Fatalf("window 3 at %d on the first frame, want it moving", x) | |||
| 672 | + | } | |||
| 673 | + | } | |||
| 674 | + | moving := rectNow(t, mid, 2).X | |||
| 675 | + | r.client <- ports.WindowUnmapped{ID: 3} | |||
| 676 | + | s := scene(t, r.scenes) | |||
| 677 | + | w3, shown3 := listedIn(s, 3) | |||
| 678 | + | switch { | |||
| 679 | + | case tt.edit != nil && shown3: | |||
| 680 | + | t.Fatalf("the unmapped window is still shown with animations off: %+v", w3) | |||
| 681 | + | case tt.edit == nil && (!shown3 || w3.Fade != 0 || w3.Rect.X != 0 || w3.Rect.W != 400): | |||
| 682 | + | // It leaves from the settled rect it was moving to. | |||
| 683 | + | t.Fatalf("the unmapped window right after the unmap: %+v (shown %t), want it leaving from its settled slot", w3, shown3) | |||
| 684 | + | } | |||
| 685 | + | // The neighbour's own motion carries on from where it was drawn. | |||
| 686 | + | if got := rectNow(t, s, 2).X; got != moving { | |||
| 687 | + | t.Fatalf("window 2 jumped from %d to %d on the unmap", moving, got) | |||
| 688 | + | } | |||
| 689 | + | if tt.edit == nil { | |||
| 690 | + | next := r.frame(t, 16*time.Millisecond) | |||
| 691 | + | if got := rectNow(t, next, 2).X; got == moving { | |||
| 692 | + | t.Fatalf("window 2 stopped at %d: its motion died with the unmapped window", got) | |||
| 693 | + | } | |||
| 694 | + | if w, ok := listedIn(next, 3); !ok || !(w.Fade > 0 && w.Fade < 1) || w.Rect.W >= 400 { | |||
| 695 | + | t.Fatalf("the leaving window on the next frame: %+v (shown %t), want it fading and shrinking", w, ok) | |||
| 696 | + | } | |||
| 697 | + | } else if moving != 400 { | |||
| 698 | + | t.Fatalf("window 2 at %d with animations off, want 400", moving) | |||
| 699 | + | } | |||
| 700 | + | if tt.edit == nil { | |||
| 701 | + | s = r.settle(t) | |||
| 702 | + | } | |||
| 703 | + | if _, ok := listedIn(s, 3); ok { | |||
| 704 | + | t.Fatal("the unmapped window came back") | |||
| 705 | + | } | |||
| 706 | + | if got := rectNow(t, s, 2).X; got != 400 { | |||
| 707 | + | t.Fatalf("window 2 settled at %d, want 400", got) | |||
| 708 | + | } | |||
| 709 | + | r.noFrameScene(t) | |||
| 710 | + | }) | |||
| 669 | } | 711 | } | ||
| 712 | + | } | |||
| 713 | + | ||||
| 714 | + | // listedIn is the scene window id, shown or not. | |||
| 715 | + | func listedIn(s ports.Scene, id ports.WindowID) (ports.SceneWindow, bool) { | |||
| 670 | for _, w := range s.Windows { | 716 | for _, w := range s.Windows { | ||
| 671 | - | if w.ID == 3 { | 717 | + | if w.ID == id { |
| 672 | - | t.Fatalf("window 3 still in the scene: %+v", w) | 718 | + | return w, true |
| 673 | } | 719 | } | ||
| 674 | } | 720 | } | ||
| 675 | - | // The neighbour's own motion carries on from where it was drawn. | 721 | + | return ports.SceneWindow{}, false |
| 676 | - | if got := rectNow(t, s, 2).X; got != moving { | |||
| 677 | - | t.Fatalf("window 2 jumped from %d to %d on the unmap", moving, got) | |||
| 678 | - | } | |||
| 679 | - | if got := rectNow(t, r.frame(t, 16*time.Millisecond), 2).X; got == moving { | |||
| 680 | - | t.Fatalf("window 2 stopped at %d: its motion died with the unmapped window", got) | |||
| 681 | - | } | |||
| 682 | - | s = r.settle(t) | |||
| 683 | - | if _, ok := rectOf(s, 3); ok { | |||
| 684 | - | t.Fatal("the unmapped window came back") | |||
| 685 | - | } | |||
| 686 | - | if got := rectNow(t, s, 2).X; got != 400 { | |||
| 687 | - | t.Fatalf("window 2 settled at %d, want 400", got) | |||
| 688 | - | } | |||
| 689 | - | r.noFrameScene(t) | |||
| 690 | } | 722 | } | ||
A internal/core/transition_unmap_internal_test.go +225 -0
| @@ -0,0 +1,225 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "slices" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // twoSettled maps two tiles with animations on and settles them. | |
| 12 | + | func twoSettled(t *testing.T) (*Core, *indicatorClock, chan ports.ClientCommand, *screen) { | |
| 13 | + | t.Helper() | |
| 14 | + | c, ic, cmds := fadeCore(t) | |
| 15 | + | c.cfg.Animations.On = true | |
| 16 | + | sc := c.cur() | |
| 17 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |
| 18 | + | c.mapWindow(ports.WindowMapped{ID: 2}) | |
| 19 | + | indicatorScene(t, c) | |
| 20 | + | ic.now = ic.now.Add(5 * time.Second) | |
| 21 | + | c.animate(ic.now, nil) | |
| 22 | + | indicatorScene(t, c) | |
| 23 | + | if len(sc.rects) != 0 { | |
| 24 | + | t.Fatalf("motions left after the settle: %d", len(sc.rects)) | |
| 25 | + | } | |
| 26 | + | drainConfigures(cmds) | |
| 27 | + | return c, ic, cmds, sc | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // A tiled unmap: the closed window stays drawn as a leaving entry from its | |
| 31 | + | // settled rect, fading out and shrinking, hidden to input and configures; | |
| 32 | + | // its neighbour slides from where it was to the whole frame; the client is | |
| 33 | + | // configured once; everything settles exactly and no motion is left. | |
| 34 | + | func TestUnmapTiledLeavesAndReflows(t *testing.T) { | |
| 35 | + | c, ic, cmds, sc := twoSettled(t) | |
| 36 | + | old1 := sceneWindow(t, indicatorScene(t, c), 1).Rect | |
| 37 | + | left := settledOf(t, sc, 2) | |
| 38 | + | ||
| 39 | + | c.unmapWindow(ports.WindowUnmapped{ID: 2}) | |
| 40 | + | s := indicatorScene(t, c) | |
| 41 | + | w2 := sceneWindow(t, s, 2) | |
| 42 | + | if w2.Hidden || w2.Fade != 0 || w2.Rect != left.Rect || w2.Zoom != 0 { | |
| 43 | + | t.Fatalf("closed window right after the unmap: %+v, want drawn opaque at %+v", w2, left.Rect) | |
| 44 | + | } | |
| 45 | + | if got := sceneWindow(t, s, 1).Rect; got != old1 { | |
| 46 | + | t.Fatalf("neighbour right after the unmap at %+v, want the old %+v", got, old1) | |
| 47 | + | } | |
| 48 | + | settled1 := settledOf(t, sc, 1) | |
| 49 | + | if settled1.Rect == old1 { | |
| 50 | + | t.Fatal("the closed column did not move the neighbour: the case checks nothing") | |
| 51 | + | } | |
| 52 | + | if ids := drainConfigures(cmds); !slices.Equal(ids, []WindowID{1}) { | |
| 53 | + | t.Fatalf("configures %v, want one for 1 only", ids) | |
| 54 | + | } | |
| 55 | + | if _, w := c.screenOf(2); w != nil { | |
| 56 | + | t.Fatal("the unmapped window is still in a workspace") | |
| 57 | + | } | |
| 58 | + | cx, cy := float64(left.Rect.X+left.Rect.W/2), float64(left.Rect.Y+left.Rect.H/2) | |
| 59 | + | if id, _, _ := c.hit(cx, cy); id == 2 { | |
| 60 | + | t.Fatal("the leaving window is hit") | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | ic.now = ic.now.Add(40 * time.Millisecond) | |
| 64 | + | c.animate(ic.now, nil) | |
| 65 | + | s = indicatorScene(t, c) | |
| 66 | + | w2 = sceneWindow(t, s, 2) | |
| 67 | + | if !(w2.Fade > 0 && w2.Fade < 1) || w2.Rect.W >= left.Rect.W || !(w2.Zoom > appearScale && w2.Zoom < 1) { | |
| 68 | + | t.Fatalf("first frame of the leaving window: %+v", w2) | |
| 69 | + | } | |
| 70 | + | if r := sceneWindow(t, s, 1).Rect; !(r.W > old1.W && r.W < settled1.Rect.W) { | |
| 71 | + | t.Fatalf("neighbour on the first frame %+v, want between %+v and %+v", r, old1, settled1.Rect) | |
| 72 | + | } | |
| 73 | + | if ids := drainConfigures(cmds); len(ids) != 0 { | |
| 74 | + | t.Fatalf("frames reconfigured %v", ids) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | ic.now = ic.now.Add(5 * time.Second) | |
| 78 | + | c.animate(ic.now, nil) | |
| 79 | + | s = indicatorScene(t, c) | |
| 80 | + | if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) { | |
| 81 | + | t.Fatalf("the closed window is still in the scene: %+v", s.Windows) | |
| 82 | + | } | |
| 83 | + | if w := sceneWindow(t, s, 1); w.Rect != settled1.Rect { | |
| 84 | + | t.Fatalf("neighbour settled at %+v, want %+v", w.Rect, settled1.Rect) | |
| 85 | + | } | |
| 86 | + | if len(sc.rects) != 0 || c.animating() { | |
| 87 | + | t.Fatal("still animating after the settle") | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // A float leaves too, from its settled rect. | |
| 92 | + | func TestUnmapFloatingLeaves(t *testing.T) { | |
| 93 | + | c, ic, _ := fadeCore(t) | |
| 94 | + | c.cfg.Animations.On = true | |
| 95 | + | sc := c.cur() | |
| 96 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |
| 97 | + | c.mapWindow(ports.WindowMapped{ID: 2, Floating: true, Width: 80, Height: 60}) | |
| 98 | + | indicatorScene(t, c) | |
| 99 | + | ic.now = ic.now.Add(5 * time.Second) | |
| 100 | + | c.animate(ic.now, nil) | |
| 101 | + | indicatorScene(t, c) | |
| 102 | + | left := settledOf(t, sc, 2) | |
| 103 | + | c.unmapWindow(ports.WindowUnmapped{ID: 2}) | |
| 104 | + | s := indicatorScene(t, c) | |
| 105 | + | if w := sceneWindow(t, s, 2); !w.Floating || w.Fade != 0 || w.Rect != left.Rect { | |
| 106 | + | t.Fatalf("float right after the unmap: %+v, settled %+v", w, left.Rect) | |
| 107 | + | } | |
| 108 | + | ic.now = ic.now.Add(5 * time.Second) | |
| 109 | + | c.animate(ic.now, nil) | |
| 110 | + | if s := indicatorScene(t, c); slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) { | |
| 111 | + | t.Fatalf("the closed float is still in the scene: %+v", s.Windows) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // A window closed while its own rect motion runs leaves from the rect it | |
| 116 | + | // was going to, its motion replaced by the leaving one. | |
| 117 | + | func TestUnmapMidMotionLeavesFromSettled(t *testing.T) { | |
| 118 | + | c, ic, _, sc := twoSettled(t) | |
| 119 | + | c.mapWindow(ports.WindowMapped{ID: 3}) | |
| 120 | + | indicatorScene(t, c) | |
| 121 | + | ic.now = ic.now.Add(30 * time.Millisecond) | |
| 122 | + | c.animate(ic.now, nil) | |
| 123 | + | indicatorScene(t, c) | |
| 124 | + | settled3 := settledOf(t, sc, 3) | |
| 125 | + | if drawn := sceneWindow(t, indicatorScene(t, c), 3).Rect; drawn == settled3.Rect { | |
| 126 | + | t.Fatal("window 3 is not in flight: the case checks nothing") | |
| 127 | + | } | |
| 128 | + | c.unmapWindow(ports.WindowUnmapped{ID: 3}) | |
| 129 | + | s := indicatorScene(t, c) | |
| 130 | + | w := sceneWindow(t, s, 3) | |
| 131 | + | if w.Rect != settled3.Rect || w.Fade != 0 { | |
| 132 | + | t.Fatalf("leaving window right after the unmap: %+v, want its settled %+v", w, settled3.Rect) | |
| 133 | + | } | |
| 134 | + | if rm, ok := sc.rects[3]; !ok || !rm.leaving { | |
| 135 | + | t.Fatalf("no leaving motion for window 3: %+v", rm) | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // With animations off an unmap is instant: the window is gone, no motion. | |
| 140 | + | func TestUnmapOffIsInstant(t *testing.T) { | |
| 141 | + | c, _, _ := fadeCore(t) | |
| 142 | + | sc := c.cur() | |
| 143 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |
| 144 | + | c.mapWindow(ports.WindowMapped{ID: 2}) | |
| 145 | + | indicatorScene(t, c) | |
| 146 | + | c.unmapWindow(ports.WindowUnmapped{ID: 2}) | |
| 147 | + | s := indicatorScene(t, c) | |
| 148 | + | if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) || len(sc.rects) != 0 || c.animating() { | |
| 149 | + | t.Fatalf("an unmap animated with animations off: %+v", s.Windows) | |
| 150 | + | } | |
| 151 | + | if w := sceneWindow(t, s, 1); w.Rect != settledOf(t, sc, 1).Rect { | |
| 152 | + | t.Fatalf("neighbour not at its settled rect: %+v", w) | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // Windows that are not drawn as a tile or an ordinary float do not leave: | |
| 157 | + | // fullscreen, on another workspace, under a session lock, with the overview | |
| 158 | + | // open. Nothing is left of them. | |
| 159 | + | func TestUnmapExceptions(t *testing.T) { | |
| 160 | + | gone := func(t *testing.T, c *Core, sc *screen, id WindowID) { | |
| 161 | + | t.Helper() | |
| 162 | + | s := indicatorScene(t, c) | |
| 163 | + | if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == id }) || len(sc.rects) != 0 { | |
| 164 | + | t.Fatalf("window %d left something behind: %+v rects %+v", id, s.Windows, sc.rects) | |
| 165 | + | } | |
| 166 | + | if _, w := c.screenOf(id); w != nil { | |
| 167 | + | t.Fatalf("window %d still placed", id) | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | t.Run("fullscreen", func(t *testing.T) { | |
| 171 | + | c, _, _, sc := twoSettled(t) | |
| 172 | + | sc.mon.SetFullscreen(2, true) | |
| 173 | + | indicatorScene(t, c) | |
| 174 | + | c.unmapWindow(ports.WindowUnmapped{ID: 2}) | |
| 175 | + | gone(t, c, sc, 2) | |
| 176 | + | }) | |
| 177 | + | t.Run("other workspace", func(t *testing.T) { | |
| 178 | + | c, _, _, sc := twoSettled(t) | |
| 179 | + | sc.mon.Focus(1) | |
| 180 | + | indicatorScene(t, c) | |
| 181 | + | c.stopAnimations() | |
| 182 | + | c.unmapWindow(ports.WindowUnmapped{ID: 2}) | |
| 183 | + | gone(t, c, sc, 2) | |
| 184 | + | }) | |
| 185 | + | t.Run("session lock", func(t *testing.T) { | |
| 186 | + | c, _, _, sc := twoSettled(t) | |
| 187 | + | c.security = ports.SecurityState{Generation: 1, Protected: true} | |
| 188 | + | c.unmapWindow(ports.WindowUnmapped{ID: 2}) | |
| 189 | + | if len(sc.rects) != 0 || c.animating() { | |
| 190 | + | t.Fatalf("an unmap under a session lock animated: %+v", sc.rects) | |
| 191 | + | } | |
| 192 | + | if _, w := c.screenOf(2); w != nil { | |
| 193 | + | t.Fatal("window 2 still placed") | |
| 194 | + | } | |
| 195 | + | }) | |
| 196 | + | t.Run("overview open", func(t *testing.T) { | |
| 197 | + | c, _, _, sc := twoSettled(t) | |
| 198 | + | sc.mon.ToggleOverview() | |
| 199 | + | indicatorScene(t, c) | |
| 200 | + | c.unmapWindow(ports.WindowUnmapped{ID: 2}) | |
| 201 | + | gone(t, c, sc, 2) | |
| 202 | + | }) | |
| 203 | + | t.Run("never placed", func(t *testing.T) { | |
| 204 | + | c, _, _, sc := twoSettled(t) | |
| 205 | + | c.unmapWindow(ports.WindowUnmapped{ID: 9}) | |
| 206 | + | if len(sc.rects) != 0 { | |
| 207 | + | t.Fatalf("an unknown window animated: %+v", sc.rects) | |
| 208 | + | } | |
| 209 | + | }) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // A session lock during the fade drops the leaving entry with the other | |
| 213 | + | // motions (stopRects), as do animations turned off. | |
| 214 | + | func TestUnmapLeavingDroppedByLock(t *testing.T) { | |
| 215 | + | c, _, _, sc := twoSettled(t) | |
| 216 | + | c.unmapWindow(ports.WindowUnmapped{ID: 2}) | |
| 217 | + | if s := indicatorScene(t, c); !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) { | |
| 218 | + | t.Fatal("no leaving entry") | |
| 219 | + | } | |
| 220 | + | c.stopAnimations() | |
| 221 | + | s := indicatorScene(t, c) | |
| 222 | + | if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) || len(sc.rects) != 0 || len(sc.shown) != 1 { | |
| 223 | + | t.Fatalf("after the stop: %+v", s.Windows) | |
| 224 | + | } | |
| 225 | + | } | |
| @@ -0,0 +1,225 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "slices" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // twoSettled maps two tiles with animations on and settles them. | |||
| 12 | + | func twoSettled(t *testing.T) (*Core, *indicatorClock, chan ports.ClientCommand, *screen) { | |||
| 13 | + | t.Helper() | |||
| 14 | + | c, ic, cmds := fadeCore(t) | |||
| 15 | + | c.cfg.Animations.On = true | |||
| 16 | + | sc := c.cur() | |||
| 17 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |||
| 18 | + | c.mapWindow(ports.WindowMapped{ID: 2}) | |||
| 19 | + | indicatorScene(t, c) | |||
| 20 | + | ic.now = ic.now.Add(5 * time.Second) | |||
| 21 | + | c.animate(ic.now, nil) | |||
| 22 | + | indicatorScene(t, c) | |||
| 23 | + | if len(sc.rects) != 0 { | |||
| 24 | + | t.Fatalf("motions left after the settle: %d", len(sc.rects)) | |||
| 25 | + | } | |||
| 26 | + | drainConfigures(cmds) | |||
| 27 | + | return c, ic, cmds, sc | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // A tiled unmap: the closed window stays drawn as a leaving entry from its | |||
| 31 | + | // settled rect, fading out and shrinking, hidden to input and configures; | |||
| 32 | + | // its neighbour slides from where it was to the whole frame; the client is | |||
| 33 | + | // configured once; everything settles exactly and no motion is left. | |||
| 34 | + | func TestUnmapTiledLeavesAndReflows(t *testing.T) { | |||
| 35 | + | c, ic, cmds, sc := twoSettled(t) | |||
| 36 | + | old1 := sceneWindow(t, indicatorScene(t, c), 1).Rect | |||
| 37 | + | left := settledOf(t, sc, 2) | |||
| 38 | + | ||||
| 39 | + | c.unmapWindow(ports.WindowUnmapped{ID: 2}) | |||
| 40 | + | s := indicatorScene(t, c) | |||
| 41 | + | w2 := sceneWindow(t, s, 2) | |||
| 42 | + | if w2.Hidden || w2.Fade != 0 || w2.Rect != left.Rect || w2.Zoom != 0 { | |||
| 43 | + | t.Fatalf("closed window right after the unmap: %+v, want drawn opaque at %+v", w2, left.Rect) | |||
| 44 | + | } | |||
| 45 | + | if got := sceneWindow(t, s, 1).Rect; got != old1 { | |||
| 46 | + | t.Fatalf("neighbour right after the unmap at %+v, want the old %+v", got, old1) | |||
| 47 | + | } | |||
| 48 | + | settled1 := settledOf(t, sc, 1) | |||
| 49 | + | if settled1.Rect == old1 { | |||
| 50 | + | t.Fatal("the closed column did not move the neighbour: the case checks nothing") | |||
| 51 | + | } | |||
| 52 | + | if ids := drainConfigures(cmds); !slices.Equal(ids, []WindowID{1}) { | |||
| 53 | + | t.Fatalf("configures %v, want one for 1 only", ids) | |||
| 54 | + | } | |||
| 55 | + | if _, w := c.screenOf(2); w != nil { | |||
| 56 | + | t.Fatal("the unmapped window is still in a workspace") | |||
| 57 | + | } | |||
| 58 | + | cx, cy := float64(left.Rect.X+left.Rect.W/2), float64(left.Rect.Y+left.Rect.H/2) | |||
| 59 | + | if id, _, _ := c.hit(cx, cy); id == 2 { | |||
| 60 | + | t.Fatal("the leaving window is hit") | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | ic.now = ic.now.Add(40 * time.Millisecond) | |||
| 64 | + | c.animate(ic.now, nil) | |||
| 65 | + | s = indicatorScene(t, c) | |||
| 66 | + | w2 = sceneWindow(t, s, 2) | |||
| 67 | + | if !(w2.Fade > 0 && w2.Fade < 1) || w2.Rect.W >= left.Rect.W || !(w2.Zoom > appearScale && w2.Zoom < 1) { | |||
| 68 | + | t.Fatalf("first frame of the leaving window: %+v", w2) | |||
| 69 | + | } | |||
| 70 | + | if r := sceneWindow(t, s, 1).Rect; !(r.W > old1.W && r.W < settled1.Rect.W) { | |||
| 71 | + | t.Fatalf("neighbour on the first frame %+v, want between %+v and %+v", r, old1, settled1.Rect) | |||
| 72 | + | } | |||
| 73 | + | if ids := drainConfigures(cmds); len(ids) != 0 { | |||
| 74 | + | t.Fatalf("frames reconfigured %v", ids) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | ic.now = ic.now.Add(5 * time.Second) | |||
| 78 | + | c.animate(ic.now, nil) | |||
| 79 | + | s = indicatorScene(t, c) | |||
| 80 | + | if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) { | |||
| 81 | + | t.Fatalf("the closed window is still in the scene: %+v", s.Windows) | |||
| 82 | + | } | |||
| 83 | + | if w := sceneWindow(t, s, 1); w.Rect != settled1.Rect { | |||
| 84 | + | t.Fatalf("neighbour settled at %+v, want %+v", w.Rect, settled1.Rect) | |||
| 85 | + | } | |||
| 86 | + | if len(sc.rects) != 0 || c.animating() { | |||
| 87 | + | t.Fatal("still animating after the settle") | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // A float leaves too, from its settled rect. | |||
| 92 | + | func TestUnmapFloatingLeaves(t *testing.T) { | |||
| 93 | + | c, ic, _ := fadeCore(t) | |||
| 94 | + | c.cfg.Animations.On = true | |||
| 95 | + | sc := c.cur() | |||
| 96 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |||
| 97 | + | c.mapWindow(ports.WindowMapped{ID: 2, Floating: true, Width: 80, Height: 60}) | |||
| 98 | + | indicatorScene(t, c) | |||
| 99 | + | ic.now = ic.now.Add(5 * time.Second) | |||
| 100 | + | c.animate(ic.now, nil) | |||
| 101 | + | indicatorScene(t, c) | |||
| 102 | + | left := settledOf(t, sc, 2) | |||
| 103 | + | c.unmapWindow(ports.WindowUnmapped{ID: 2}) | |||
| 104 | + | s := indicatorScene(t, c) | |||
| 105 | + | if w := sceneWindow(t, s, 2); !w.Floating || w.Fade != 0 || w.Rect != left.Rect { | |||
| 106 | + | t.Fatalf("float right after the unmap: %+v, settled %+v", w, left.Rect) | |||
| 107 | + | } | |||
| 108 | + | ic.now = ic.now.Add(5 * time.Second) | |||
| 109 | + | c.animate(ic.now, nil) | |||
| 110 | + | if s := indicatorScene(t, c); slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) { | |||
| 111 | + | t.Fatalf("the closed float is still in the scene: %+v", s.Windows) | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // A window closed while its own rect motion runs leaves from the rect it | |||
| 116 | + | // was going to, its motion replaced by the leaving one. | |||
| 117 | + | func TestUnmapMidMotionLeavesFromSettled(t *testing.T) { | |||
| 118 | + | c, ic, _, sc := twoSettled(t) | |||
| 119 | + | c.mapWindow(ports.WindowMapped{ID: 3}) | |||
| 120 | + | indicatorScene(t, c) | |||
| 121 | + | ic.now = ic.now.Add(30 * time.Millisecond) | |||
| 122 | + | c.animate(ic.now, nil) | |||
| 123 | + | indicatorScene(t, c) | |||
| 124 | + | settled3 := settledOf(t, sc, 3) | |||
| 125 | + | if drawn := sceneWindow(t, indicatorScene(t, c), 3).Rect; drawn == settled3.Rect { | |||
| 126 | + | t.Fatal("window 3 is not in flight: the case checks nothing") | |||
| 127 | + | } | |||
| 128 | + | c.unmapWindow(ports.WindowUnmapped{ID: 3}) | |||
| 129 | + | s := indicatorScene(t, c) | |||
| 130 | + | w := sceneWindow(t, s, 3) | |||
| 131 | + | if w.Rect != settled3.Rect || w.Fade != 0 { | |||
| 132 | + | t.Fatalf("leaving window right after the unmap: %+v, want its settled %+v", w, settled3.Rect) | |||
| 133 | + | } | |||
| 134 | + | if rm, ok := sc.rects[3]; !ok || !rm.leaving { | |||
| 135 | + | t.Fatalf("no leaving motion for window 3: %+v", rm) | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // With animations off an unmap is instant: the window is gone, no motion. | |||
| 140 | + | func TestUnmapOffIsInstant(t *testing.T) { | |||
| 141 | + | c, _, _ := fadeCore(t) | |||
| 142 | + | sc := c.cur() | |||
| 143 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |||
| 144 | + | c.mapWindow(ports.WindowMapped{ID: 2}) | |||
| 145 | + | indicatorScene(t, c) | |||
| 146 | + | c.unmapWindow(ports.WindowUnmapped{ID: 2}) | |||
| 147 | + | s := indicatorScene(t, c) | |||
| 148 | + | if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) || len(sc.rects) != 0 || c.animating() { | |||
| 149 | + | t.Fatalf("an unmap animated with animations off: %+v", s.Windows) | |||
| 150 | + | } | |||
| 151 | + | if w := sceneWindow(t, s, 1); w.Rect != settledOf(t, sc, 1).Rect { | |||
| 152 | + | t.Fatalf("neighbour not at its settled rect: %+v", w) | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // Windows that are not drawn as a tile or an ordinary float do not leave: | |||
| 157 | + | // fullscreen, on another workspace, under a session lock, with the overview | |||
| 158 | + | // open. Nothing is left of them. | |||
| 159 | + | func TestUnmapExceptions(t *testing.T) { | |||
| 160 | + | gone := func(t *testing.T, c *Core, sc *screen, id WindowID) { | |||
| 161 | + | t.Helper() | |||
| 162 | + | s := indicatorScene(t, c) | |||
| 163 | + | if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == id }) || len(sc.rects) != 0 { | |||
| 164 | + | t.Fatalf("window %d left something behind: %+v rects %+v", id, s.Windows, sc.rects) | |||
| 165 | + | } | |||
| 166 | + | if _, w := c.screenOf(id); w != nil { | |||
| 167 | + | t.Fatalf("window %d still placed", id) | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | t.Run("fullscreen", func(t *testing.T) { | |||
| 171 | + | c, _, _, sc := twoSettled(t) | |||
| 172 | + | sc.mon.SetFullscreen(2, true) | |||
| 173 | + | indicatorScene(t, c) | |||
| 174 | + | c.unmapWindow(ports.WindowUnmapped{ID: 2}) | |||
| 175 | + | gone(t, c, sc, 2) | |||
| 176 | + | }) | |||
| 177 | + | t.Run("other workspace", func(t *testing.T) { | |||
| 178 | + | c, _, _, sc := twoSettled(t) | |||
| 179 | + | sc.mon.Focus(1) | |||
| 180 | + | indicatorScene(t, c) | |||
| 181 | + | c.stopAnimations() | |||
| 182 | + | c.unmapWindow(ports.WindowUnmapped{ID: 2}) | |||
| 183 | + | gone(t, c, sc, 2) | |||
| 184 | + | }) | |||
| 185 | + | t.Run("session lock", func(t *testing.T) { | |||
| 186 | + | c, _, _, sc := twoSettled(t) | |||
| 187 | + | c.security = ports.SecurityState{Generation: 1, Protected: true} | |||
| 188 | + | c.unmapWindow(ports.WindowUnmapped{ID: 2}) | |||
| 189 | + | if len(sc.rects) != 0 || c.animating() { | |||
| 190 | + | t.Fatalf("an unmap under a session lock animated: %+v", sc.rects) | |||
| 191 | + | } | |||
| 192 | + | if _, w := c.screenOf(2); w != nil { | |||
| 193 | + | t.Fatal("window 2 still placed") | |||
| 194 | + | } | |||
| 195 | + | }) | |||
| 196 | + | t.Run("overview open", func(t *testing.T) { | |||
| 197 | + | c, _, _, sc := twoSettled(t) | |||
| 198 | + | sc.mon.ToggleOverview() | |||
| 199 | + | indicatorScene(t, c) | |||
| 200 | + | c.unmapWindow(ports.WindowUnmapped{ID: 2}) | |||
| 201 | + | gone(t, c, sc, 2) | |||
| 202 | + | }) | |||
| 203 | + | t.Run("never placed", func(t *testing.T) { | |||
| 204 | + | c, _, _, sc := twoSettled(t) | |||
| 205 | + | c.unmapWindow(ports.WindowUnmapped{ID: 9}) | |||
| 206 | + | if len(sc.rects) != 0 { | |||
| 207 | + | t.Fatalf("an unknown window animated: %+v", sc.rects) | |||
| 208 | + | } | |||
| 209 | + | }) | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | // A session lock during the fade drops the leaving entry with the other | |||
| 213 | + | // motions (stopRects), as do animations turned off. | |||
| 214 | + | func TestUnmapLeavingDroppedByLock(t *testing.T) { | |||
| 215 | + | c, _, _, sc := twoSettled(t) | |||
| 216 | + | c.unmapWindow(ports.WindowUnmapped{ID: 2}) | |||
| 217 | + | if s := indicatorScene(t, c); !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) { | |||
| 218 | + | t.Fatal("no leaving entry") | |||
| 219 | + | } | |||
| 220 | + | c.stopAnimations() | |||
| 221 | + | s := indicatorScene(t, c) | |||
| 222 | + | if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) || len(sc.rects) != 0 || len(sc.shown) != 1 { | |||
| 223 | + | t.Fatalf("after the stop: %+v", s.Windows) | |||
| 224 | + | } | |||
| 225 | + | } | |||