detached df88f4a

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +630 -58
M internal/adapters/drm/output.go +7 -7
@@ -12,6 +12,7 @@ import (
1212 "github.com/bnema/neferwl/internal/adapters/capture"
1313 "github.com/bnema/neferwl/internal/adapters/clock"
1414 "github.com/bnema/neferwl/internal/adapters/presented"
15+ "github.com/bnema/neferwl/internal/adapters/surfaces"
1516
1617 "github.com/bnema/neferwl/internal/ports"
1718 "github.com/bnema/zerowrap"
@@ -1028,7 +1029,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
10281029 o.probeAsync(r)
10291030 }
10301031 }
1031- surfaces := make(map[ports.WindowID]ports.SurfaceContent)
1032+ table := surfaces.New()
1033+ drawn := table.Map()
10321034 var scene ports.Scene
10331035 haveScene, dirty := false, false
10341036 var want ports.CursorChange
@@ -1353,6 +1355,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
13531355 }
13541356 scene, haveScene, dirty = s, true, true
13551357 o.wantOff = s.Off
1358+ // A closed window's last content goes once no scene draws it.
1359+ table.Prune(scene)
13561360 case c := <-cursor:
13571361 o.observeSecurity()
13581362 if o.protected {
@@ -1365,11 +1369,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
13651369 stateDirty = true
13661370 }
13671371 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)
13731373 if c.Seq > seen[c.ID] {
13741374 seen[c.ID] = c.Seq
13751375 reportDirty = true
@@ -1469,7 +1469,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
14691469 continue
14701470 }
14711471 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)
14731473 var fatal renderError
14741474 if errors.As(err, &fatal) || errors.Is(err, errSecurityScene) {
14751475 // 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.Scene1034 var scene ports.Scene
1033 haveScene, dirty := false, false1035 haveScene, dirty := false, false
1034 var want ports.CursorChange1036 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, true1356 scene, haveScene, dirty = s, true, true
1355 o.wantOff = s.Off1357 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 = true1369 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.Seq1374 seen[c.ID] = c.Seq
1375 reportDirty = true1375 reportDirty = true
@@ -1469,7 +1469,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1469 continue1469 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 renderError1473 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 (
55 "context"
66 "errors"
77 "image"
8+ "maps"
89 "os"
910 "strings"
1011 "sync"
@@ -1074,3 +1075,89 @@ func TestOutputSameSeqRendersOnce(t *testing.T) {
10741075 waitFor(t, func() bool { return sr.frameCommit() == 2 })
10751076 sr.stop()
10761077 }
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 (
1515 "github.com/bnema/neferwl/internal/adapters/capture"
1616 "github.com/bnema/neferwl/internal/adapters/clock"
1717 "github.com/bnema/neferwl/internal/adapters/presented"
18+ "github.com/bnema/neferwl/internal/adapters/surfaces"
1819 "github.com/bnema/neferwl/internal/adapters/syncfile"
1920 "github.com/bnema/neferwl/internal/ports"
2021 "github.com/bnema/zerowrap"
@@ -87,7 +88,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
8788 }
8889 }
8990 }
90- surfaces := make(map[ports.WindowID]ports.SurfaceContent)
91+ table := surfaces.New()
92+ drawn := table.Map()
9193 var scene ports.Scene
9294 haveScene, dirty := false, false
9395 frame := 0
@@ -204,15 +206,13 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
204206 return
205207 }
206208 scene, haveScene, dirty = s, true, true
209+ // A closed window's last content goes once no scene draws it.
210+ table.Prune(scene)
207211 }
208212 update := func(c ports.SurfaceContent) {
209213 seen[c.ID] = max(seen[c.ID], c.Seq)
210214 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)
216216 }
217217 clk := opts.Clock
218218 if clk == nil {
@@ -473,7 +473,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
473473 failRequests("security epoch changed")
474474 continue
475475 }
476- pipeline.SubmitHidden(scene, surfaces, hidden)
476+ pipeline.SubmitHidden(scene, drawn, hidden)
477477 clear(hidden)
478478 // A slow child may outlive Wayland's stale-report timeout even
479479 // 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
494494 }
495495 }
496496 // 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)
498498 if pdone != nil {
499499 // The plain render reads client buffers even when its requests
500500 // 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
521521 failRequests("security epoch changed")
522522 continue
523523 }
524- xdone, renderErr := r.Render(pipeline.ExcludedScene(scene), surfaces)
524+ xdone, renderErr := r.Render(pipeline.ExcludedScene(scene), drawn)
525525 if xdone != nil {
526526 // The excluded-frame composition reads client buffers even when the displayed
527527 // 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
549549 failRequests("security epoch changed")
550550 continue
551551 }
552- done, err := r.Render(scene, surfaces)
552+ done, err := r.Render(scene, drawn)
553553 if err != nil {
554554 return fmt.Errorf("render frame: %w", err)
555555 }
@@ -579,7 +579,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
579579 pipeline.EndGate(nil)
580580 requests = capture.Waiting(requests, scene)
581581 frame++
582- opts.report(&reports, flipInfo(scene, surfaces), seen)
582+ opts.report(&reports, flipInfo(scene, drawn), seen)
583583 if opts.ScreenshotDir != "" && !opts.protected() {
584584 shot := r.Pixels()
585585 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.Scene93 var scene ports.Scene
92 haveScene, dirty := false, false94 haveScene, dirty := false, false
93 frame := 095 frame := 0
@@ -204,15 +206,13 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
204 return206 return
205 }207 }
206 scene, haveScene, dirty = s, true, true208 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.Clock217 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 continue474 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 even478 // 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 requests499 // The plain render reads client buffers even when its requests
500 // are rejected by a gate transition: own and wait its fence500 // 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 continue522 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 displayed526 // The excluded-frame composition reads client buffers even when the displayed
527 // frame is skipped by a gate/off transition. Own and wait its527 // 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 continue550 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) {
8484 if s, c := f.snapshot(); len(s) != 1 || s[0].Seq != 2 || len(c[0]) != 2 {
8585 t.Errorf("coalescing: %+v %+v", s, c)
8686 }
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.
8789 contents <- ports.SurfaceContent{ID: 1}
8890 waitFrames(t, f, 2)
8991 if _, c := f.snapshot(); len(c) < 2 {
9092 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 {
92101 t.Error("content not deleted")
93102 }
94103 cancel()
95104 if err := <-done; err != nil {
96105 t.Fatal(err)
97106 }
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"} {
99108 file, err := os.Open(filepath.Join(dir, name))
100109 if err != nil {
101110 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 {
11541154 if c.drag != nil && c.drag.id == v.ID {
11551155 c.abortDrag()
11561156 }
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)
11611158 c.releaseSlots()
11621159 if c.pointer == v.ID {
11631160 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 = 01160 c.pointer = 0
M internal/core/transition.go +42 -0
@@ -425,6 +425,48 @@ func (c *Core) appearMapped(id WindowID, now time.Time) {
425425 }
426426 }
427427
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+
428470 // resizeFloating applies a floating window's committed size. A float maps
429471 // before its first commit, so core first lays it out at a default size;
430472 // 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 maps470 // 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 break472 // 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) {
648648 }
649649 }
650650
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.
655657 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+ })
669711 }
712+}
713+
714+// listedIn is the scene window id, shown or not.
715+func listedIn(s ports.Scene, id ports.WindowID) (ports.SceneWindow, bool) {
670716 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
673719 }
674720 }
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
690722 }
@@ -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, its651+// A window unmapped while its rect motion runs stops moving: with
652-// motion goes with it, the others keep theirs, and once those settle no652+// 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 with653+// 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).X666+ 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+}

Keyboard shortcuts

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

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

Branches 26

Tags 10

↑↓ moveenter openesc close