fix(drm): never put a closed window's kept buffer on a plane; forget its configures
GO-001: wayland releases a closed window's buffer once the output reports its empty content as seen, so the client may already draw into it while the leaving entry is composed. The first leaving scene carries Fade 0 and Zoom 0, so the plane paths need the output's own knowledge: Output.kept (surfaces.Table.Kept) is passed to scanoutCandidate and overlayCandidate, which refuse a kept window with reason "leaving". shownBy and headless flipInfo skip kept IDs (GO-005): nothing is presented to the client. GO-002: configures.keep held the destroyed window's last configure; xdg reuses window IDs, so a remap during the fade would withhold the new toplevel's unchanged tile configure. unmapWindow forgets the ID's sent and answer state up front. The remap chains the entrance over the leaving motion (appearFrom), lists the window once and configures it. GO-003: the kept content's Children are copied with their Acquire fences zeroed (one allocation per unmap); the package doc states the invariant (composed only; window capture ends before the leaving scene). Tests: drm overlay + single leaving tile composed with reason leaving, direct scanout of a lone leaving fullscreen window refused, shownBy skips kept; headless flipInfo skips kept; surfaces children copy; core remap during the fade; leaving dropped by a real lock, unplug, overview toggle and workspace switch (GO-004).
- commit
- 27cdcf17e40da3731051d56948c8c092c7f2f9bc
- author
- brice · · 2026-10-05 01:12 UTC
- parent
- d7e6236
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
15 files · +354 -53
M internal/adapters/drm/drop_hint_test.go +3 -3
| @@ -9,15 +9,15 @@ import ( | |||
| 9 | 9 | func TestDropHintsBlockPlanes(t *testing.T) { | |
| 10 | 10 | s, c := overlayScene() | |
| 11 | 11 | s.DropHints = []ports.Rect{{X: 10, W: 4, H: 100}} | |
| 12 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "drop_hint" { | |
| 12 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "drop_hint" { | |
| 13 | 13 | t.Fatalf("overlay reason %q", reason) | |
| 14 | 14 | } | |
| 15 | 15 | fs, fc := fullscreenScene() | |
| 16 | - | if _, reason := scanoutCandidate(fs, fc, 200, 100); reason == "drop_hint" { | |
| 16 | + | if _, reason := scanoutCandidate(fs, fc, 200, 100, nil); reason == "drop_hint" { | |
| 17 | 17 | t.Fatal("no hints, still refused") | |
| 18 | 18 | } | |
| 19 | 19 | fs.DropHints = []ports.Rect{{X: 10, W: 4, H: 100}} | |
| 20 | - | if _, reason := scanoutCandidate(fs, fc, 200, 100); reason != "drop_hint" { | |
| 20 | + | if _, reason := scanoutCandidate(fs, fc, 200, 100, nil); reason != "drop_hint" { | |
| 21 | 21 | t.Fatalf("scanout reason %q", reason) | |
| 22 | 22 | } | |
| 23 | 23 | } | |
| @@ -9,15 +9,15 @@ import ( | |||||
| 9 | func TestDropHintsBlockPlanes(t *testing.T) { | 9 | func TestDropHintsBlockPlanes(t *testing.T) { | ||
| 10 | s, c := overlayScene() | 10 | s, c := overlayScene() | ||
| 11 | s.DropHints = []ports.Rect{{X: 10, W: 4, H: 100}} | 11 | s.DropHints = []ports.Rect{{X: 10, W: 4, H: 100}} | ||
| 12 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "drop_hint" { | 12 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "drop_hint" { |
| 13 | t.Fatalf("overlay reason %q", reason) | 13 | t.Fatalf("overlay reason %q", reason) | ||
| 14 | } | 14 | } | ||
| 15 | fs, fc := fullscreenScene() | 15 | fs, fc := fullscreenScene() | ||
| 16 | - | if _, reason := scanoutCandidate(fs, fc, 200, 100); reason == "drop_hint" { | 16 | + | if _, reason := scanoutCandidate(fs, fc, 200, 100, nil); reason == "drop_hint" { |
| 17 | t.Fatal("no hints, still refused") | 17 | t.Fatal("no hints, still refused") | ||
| 18 | } | 18 | } | ||
| 19 | fs.DropHints = []ports.Rect{{X: 10, W: 4, H: 100}} | 19 | fs.DropHints = []ports.Rect{{X: 10, W: 4, H: 100}} | ||
| 20 | - | if _, reason := scanoutCandidate(fs, fc, 200, 100); reason != "drop_hint" { | 20 | + | if _, reason := scanoutCandidate(fs, fc, 200, 100, nil); reason != "drop_hint" { |
| 21 | t.Fatalf("scanout reason %q", reason) | 21 | t.Fatalf("scanout reason %q", reason) | ||
| 22 | } | 22 | } | ||
| 23 | } | 23 | } | ||
A internal/adapters/drm/leaving_test.go +84 -0
| @@ -0,0 +1,84 @@ | |||
| 1 | + | package drm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/neferwl/internal/adapters/surfaces" | |
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // A window drawn from a content its client withdrew (it closed and fades | |
| 11 | + | // out) never goes on a plane: wayland has released its buffer, so the | |
| 12 | + | // client may already draw into it. The first leaving scene carries Fade 0 | |
| 13 | + | // and Zoom 0 (the fade starts at opaque), so the kept flag alone refuses it. | |
| 14 | + | func TestLeavingWindowNeverOnAPlane(t *testing.T) { | |
| 15 | + | t.Run("overlay plane, a single leaving tile", func(t *testing.T) { | |
| 16 | + | o, _, _ := overlayOutput(t) | |
| 17 | + | o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}} | |
| 18 | + | table := surfaces.New() | |
| 19 | + | o.kept = table.Kept | |
| 20 | + | s, c := overlayScene() | |
| 21 | + | table.Update(c[2], s) | |
| 22 | + | // Window 2 unmaps: its empty content keeps the last one, Acquire | |
| 23 | + | // dropped, while the scene lists it. | |
| 24 | + | table.Update(ports.SurfaceContent{ID: 2}, s) | |
| 25 | + | if !table.Kept(2) { | |
| 26 | + | t.Fatal("setup: content not kept") | |
| 27 | + | } | |
| 28 | + | if _, _, _, reason := overlayCandidate(s, table.Map(), false, nil, o.kept); reason != "leaving" { | |
| 29 | + | t.Fatalf("overlay reason %q, want leaving", reason) | |
| 30 | + | } | |
| 31 | + | ov, rest := o.overlayFrame(s, table.Map()) | |
| 32 | + | if ov.fb != 0 || len(rest.Windows) != 2 || o.overlayReason != "leaving" { | |
| 33 | + | t.Fatalf("leaving window put on the overlay: %+v rest %d reason %q", ov, len(rest.Windows), o.overlayReason) | |
| 34 | + | } | |
| 35 | + | // Without the kept flag the same scene goes on the plane: the | |
| 36 | + | // refusal is the flag's. | |
| 37 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "" { | |
| 38 | + | t.Fatalf("control: reason %q", reason) | |
| 39 | + | } | |
| 40 | + | // A new content of a remapped window frees it again. | |
| 41 | + | table.Update(c[2], s) | |
| 42 | + | if _, _, _, reason := overlayCandidate(s, table.Map(), false, nil, o.kept); reason != "" { | |
| 43 | + | t.Fatalf("remapped window refused: %q", reason) | |
| 44 | + | } | |
| 45 | + | }) | |
| 46 | + | t.Run("direct scanout of a lone leaving fullscreen window", func(t *testing.T) { | |
| 47 | + | s, c := fullscreenScene() | |
| 48 | + | table := surfaces.New() | |
| 49 | + | for _, cc := range c { | |
| 50 | + | table.Update(cc, s) | |
| 51 | + | } | |
| 52 | + | id := s.Windows[0].ID | |
| 53 | + | if _, reason := scanoutCandidate(s, table.Map(), 200, 100, table.Kept); reason != "" { | |
| 54 | + | t.Fatalf("control: reason %q", reason) | |
| 55 | + | } | |
| 56 | + | table.Update(ports.SurfaceContent{ID: id}, s) | |
| 57 | + | if _, reason := scanoutCandidate(s, table.Map(), 200, 100, table.Kept); reason != "leaving" { | |
| 58 | + | t.Fatalf("scanout reason %q, want leaving", reason) | |
| 59 | + | } | |
| 60 | + | // Through the output: composed, reason logged. | |
| 61 | + | o, _, _ := testOutput(t) | |
| 62 | + | o.kept = table.Kept | |
| 63 | + | o.scanout = true | |
| 64 | + | if fb, _, _ := o.scanoutFrame(s, table.Map()); fb != 0 || o.reason != "leaving" { | |
| 65 | + | t.Fatalf("direct scanout of a leaving window: fb %d reason %q", fb, o.reason) | |
| 66 | + | } | |
| 67 | + | }) | |
| 68 | + | t.Run("not reported shown", func(t *testing.T) { | |
| 69 | + | o, _, _ := testOutput(t) | |
| 70 | + | table := surfaces.New() | |
| 71 | + | o.kept = table.Kept | |
| 72 | + | s, c := overlayScene() | |
| 73 | + | table.Update(c[2], s) | |
| 74 | + | seen := map[ports.WindowID]uint64{2: 3} | |
| 75 | + | if shows := o.shownBy(s, seen); shows[2] != 3 { | |
| 76 | + | t.Fatalf("control: shows %v", shows) | |
| 77 | + | } | |
| 78 | + | table.Update(ports.SurfaceContent{ID: 2, Seq: 4}, s) | |
| 79 | + | seen[2] = 4 | |
| 80 | + | if shows := o.shownBy(s, seen); len(shows) != 0 { | |
| 81 | + | t.Fatalf("a leaving window is reported shown: %v", shows) | |
| 82 | + | } | |
| 83 | + | }) | |
| 84 | + | } | |
| @@ -0,0 +1,84 @@ | |||||
| 1 | + | package drm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/neferwl/internal/adapters/surfaces" | |||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // A window drawn from a content its client withdrew (it closed and fades | |||
| 11 | + | // out) never goes on a plane: wayland has released its buffer, so the | |||
| 12 | + | // client may already draw into it. The first leaving scene carries Fade 0 | |||
| 13 | + | // and Zoom 0 (the fade starts at opaque), so the kept flag alone refuses it. | |||
| 14 | + | func TestLeavingWindowNeverOnAPlane(t *testing.T) { | |||
| 15 | + | t.Run("overlay plane, a single leaving tile", func(t *testing.T) { | |||
| 16 | + | o, _, _ := overlayOutput(t) | |||
| 17 | + | o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}} | |||
| 18 | + | table := surfaces.New() | |||
| 19 | + | o.kept = table.Kept | |||
| 20 | + | s, c := overlayScene() | |||
| 21 | + | table.Update(c[2], s) | |||
| 22 | + | // Window 2 unmaps: its empty content keeps the last one, Acquire | |||
| 23 | + | // dropped, while the scene lists it. | |||
| 24 | + | table.Update(ports.SurfaceContent{ID: 2}, s) | |||
| 25 | + | if !table.Kept(2) { | |||
| 26 | + | t.Fatal("setup: content not kept") | |||
| 27 | + | } | |||
| 28 | + | if _, _, _, reason := overlayCandidate(s, table.Map(), false, nil, o.kept); reason != "leaving" { | |||
| 29 | + | t.Fatalf("overlay reason %q, want leaving", reason) | |||
| 30 | + | } | |||
| 31 | + | ov, rest := o.overlayFrame(s, table.Map()) | |||
| 32 | + | if ov.fb != 0 || len(rest.Windows) != 2 || o.overlayReason != "leaving" { | |||
| 33 | + | t.Fatalf("leaving window put on the overlay: %+v rest %d reason %q", ov, len(rest.Windows), o.overlayReason) | |||
| 34 | + | } | |||
| 35 | + | // Without the kept flag the same scene goes on the plane: the | |||
| 36 | + | // refusal is the flag's. | |||
| 37 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "" { | |||
| 38 | + | t.Fatalf("control: reason %q", reason) | |||
| 39 | + | } | |||
| 40 | + | // A new content of a remapped window frees it again. | |||
| 41 | + | table.Update(c[2], s) | |||
| 42 | + | if _, _, _, reason := overlayCandidate(s, table.Map(), false, nil, o.kept); reason != "" { | |||
| 43 | + | t.Fatalf("remapped window refused: %q", reason) | |||
| 44 | + | } | |||
| 45 | + | }) | |||
| 46 | + | t.Run("direct scanout of a lone leaving fullscreen window", func(t *testing.T) { | |||
| 47 | + | s, c := fullscreenScene() | |||
| 48 | + | table := surfaces.New() | |||
| 49 | + | for _, cc := range c { | |||
| 50 | + | table.Update(cc, s) | |||
| 51 | + | } | |||
| 52 | + | id := s.Windows[0].ID | |||
| 53 | + | if _, reason := scanoutCandidate(s, table.Map(), 200, 100, table.Kept); reason != "" { | |||
| 54 | + | t.Fatalf("control: reason %q", reason) | |||
| 55 | + | } | |||
| 56 | + | table.Update(ports.SurfaceContent{ID: id}, s) | |||
| 57 | + | if _, reason := scanoutCandidate(s, table.Map(), 200, 100, table.Kept); reason != "leaving" { | |||
| 58 | + | t.Fatalf("scanout reason %q, want leaving", reason) | |||
| 59 | + | } | |||
| 60 | + | // Through the output: composed, reason logged. | |||
| 61 | + | o, _, _ := testOutput(t) | |||
| 62 | + | o.kept = table.Kept | |||
| 63 | + | o.scanout = true | |||
| 64 | + | if fb, _, _ := o.scanoutFrame(s, table.Map()); fb != 0 || o.reason != "leaving" { | |||
| 65 | + | t.Fatalf("direct scanout of a leaving window: fb %d reason %q", fb, o.reason) | |||
| 66 | + | } | |||
| 67 | + | }) | |||
| 68 | + | t.Run("not reported shown", func(t *testing.T) { | |||
| 69 | + | o, _, _ := testOutput(t) | |||
| 70 | + | table := surfaces.New() | |||
| 71 | + | o.kept = table.Kept | |||
| 72 | + | s, c := overlayScene() | |||
| 73 | + | table.Update(c[2], s) | |||
| 74 | + | seen := map[ports.WindowID]uint64{2: 3} | |||
| 75 | + | if shows := o.shownBy(s, seen); shows[2] != 3 { | |||
| 76 | + | t.Fatalf("control: shows %v", shows) | |||
| 77 | + | } | |||
| 78 | + | table.Update(ports.SurfaceContent{ID: 2, Seq: 4}, s) | |||
| 79 | + | seen[2] = 4 | |||
| 80 | + | if shows := o.shownBy(s, seen); len(shows) != 0 { | |||
| 81 | + | t.Fatalf("a leaving window is reported shown: %v", shows) | |||
| 82 | + | } | |||
| 83 | + | }) | |||
| 84 | + | } | |||
M internal/adapters/drm/output.go +5 -0
| @@ -36,6 +36,10 @@ type Output struct { | |||
| 36 | 36 | securityInvalid bool | |
| 37 | 37 | inactiveOnClose bool // affirmative terminal KMS result, owned by Close | |
| 38 | 38 | runContext context.Context // bounds compositor clear waits during Run startup | |
| 39 | + | // kept reports a window drawn from a content its client withdrew (it | |
| 40 | + | // closed and fades out; surfaces.Table.Kept): composed only, never on | |
| 41 | + | // a plane, never reported shown. Set by Run; nil before. | |
| 42 | + | kept func(ports.WindowID) bool | |
| 39 | 43 | ||
| 40 | 44 | k kms | |
| 41 | 45 | flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents | |
| @@ -1031,6 +1035,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1031 | 1035 | } | |
| 1032 | 1036 | table := surfaces.New() | |
| 1033 | 1037 | drawn := table.Map() | |
| 1038 | + | o.kept = table.Kept | |
| 1034 | 1039 | var scene ports.Scene | |
| 1035 | 1040 | haveScene, dirty := false, false | |
| 1036 | 1041 | var want ports.CursorChange | |
| @@ -36,6 +36,10 @@ type Output struct { | |||||
| 36 | securityInvalid bool | 36 | securityInvalid bool | ||
| 37 | inactiveOnClose bool // affirmative terminal KMS result, owned by Close | 37 | inactiveOnClose bool // affirmative terminal KMS result, owned by Close | ||
| 38 | runContext context.Context // bounds compositor clear waits during Run startup | 38 | runContext context.Context // bounds compositor clear waits during Run startup | ||
| 39 | + | // kept reports a window drawn from a content its client withdrew (it | |||
| 40 | + | // closed and fades out; surfaces.Table.Kept): composed only, never on | |||
| 41 | + | // a plane, never reported shown. Set by Run; nil before. | |||
| 42 | + | kept func(ports.WindowID) bool | |||
| 39 | 43 | ||||
| 40 | k kms | 44 | k kms | ||
| 41 | flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents | 45 | flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents | ||
| @@ -1031,6 +1035,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1031 | } | 1035 | } | ||
| 1032 | table := surfaces.New() | 1036 | table := surfaces.New() | ||
| 1033 | drawn := table.Map() | 1037 | drawn := table.Map() | ||
| 1038 | + | o.kept = table.Kept | |||
| 1034 | var scene ports.Scene | 1039 | var scene ports.Scene | ||
| 1035 | haveScene, dirty := false, false | 1040 | haveScene, dirty := false, false | ||
| 1036 | var want ports.CursorChange | 1041 | var want ports.CursorChange | ||
M internal/adapters/drm/overlay.go +14 -2
| @@ -59,7 +59,10 @@ type overlayWin struct { | |||
| 59 | 59 | // overlayCandidate finds the one window the overlay can show: an opaque | |
| 60 | 60 | // dmabuf at integer physical coordinates, drawn 1:1, with no other window | |
| 61 | 61 | // or layer above it, and the plane colour mode it needs. reason is why none. | |
| 62 | - | func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, hdrOn bool, pipeline *colorPipeline) (ports.SceneWindow, ports.SurfaceContent, colorMode, string) { | |
| 62 | + | // | |
| 63 | + | // kept is scanoutCandidate's: a window drawn from a withdrawn content is | |
| 64 | + | // composed, never put on the plane. | |
| 65 | + | func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, hdrOn bool, pipeline *colorPipeline, kept func(ports.WindowID) bool) (ports.SceneWindow, ports.SurfaceContent, colorMode, string) { | |
| 63 | 66 | if s.Transform != 0 { | |
| 64 | 67 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform" | |
| 65 | 68 | } | |
| @@ -85,7 +88,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 85 | 88 | // window (a peeking stashed one) needs the veil drawn over it, a | |
| 86 | 89 | // fading one its translucency, and one with the focus effect its | |
| 87 | 90 | // lift; an overview preview is drawn smaller than its buffer. | |
| 88 | - | if w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 0 && w.FocusEffect <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) { | |
| 91 | + | if w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 0 && w.FocusEffect <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && (kept == nil || !kept(w.ID)) { | |
| 89 | 92 | if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" { | |
| 90 | 93 | pick, mode = w, m | |
| 91 | 94 | continue | |
| @@ -97,6 +100,15 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 97 | 100 | } | |
| 98 | 101 | } | |
| 99 | 102 | if pick == nil { | |
| 103 | + | if kept != nil && len(s.Windows) > 0 { | |
| 104 | + | // The one window that could have gone on the plane fades out | |
| 105 | + | // after its close: composed from its kept content. | |
| 106 | + | for i := range s.Windows { | |
| 107 | + | if w := &s.Windows[i]; !w.Hidden && w.Rect.W > 0 && w.Rect.H > 0 && kept(w.ID) { | |
| 108 | + | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "leaving" | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | } | |
| 100 | 112 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "no_candidate" | |
| 101 | 113 | } | |
| 102 | 114 | if s.Dim > 0 { | |
| @@ -59,7 +59,10 @@ type overlayWin struct { | |||||
| 59 | // overlayCandidate finds the one window the overlay can show: an opaque | 59 | // overlayCandidate finds the one window the overlay can show: an opaque | ||
| 60 | // dmabuf at integer physical coordinates, drawn 1:1, with no other window | 60 | // dmabuf at integer physical coordinates, drawn 1:1, with no other window | ||
| 61 | // or layer above it, and the plane colour mode it needs. reason is why none. | 61 | // or layer above it, and the plane colour mode it needs. reason is why none. | ||
| 62 | - | func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, hdrOn bool, pipeline *colorPipeline) (ports.SceneWindow, ports.SurfaceContent, colorMode, string) { | 62 | + | // |
| 63 | + | // kept is scanoutCandidate's: a window drawn from a withdrawn content is | |||
| 64 | + | // composed, never put on the plane. | |||
| 65 | + | func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, hdrOn bool, pipeline *colorPipeline, kept func(ports.WindowID) bool) (ports.SceneWindow, ports.SurfaceContent, colorMode, string) { | |||
| 63 | if s.Transform != 0 { | 66 | if s.Transform != 0 { | ||
| 64 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform" | 67 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform" | ||
| 65 | } | 68 | } | ||
| @@ -85,7 +88,7 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||||
| 85 | // window (a peeking stashed one) needs the veil drawn over it, a | 88 | // window (a peeking stashed one) needs the veil drawn over it, a | ||
| 86 | // fading one its translucency, and one with the focus effect its | 89 | // fading one its translucency, and one with the focus effect its | ||
| 87 | // lift; an overview preview is drawn smaller than its buffer. | 90 | // lift; an overview preview is drawn smaller than its buffer. | ||
| 88 | - | if w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 0 && w.FocusEffect <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) { | 91 | + | if w.Dim <= 0 && w.Fade <= 0 && w.Zoom <= 0 && w.FocusEffect <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && (kept == nil || !kept(w.ID)) { |
| 89 | if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" { | 92 | if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" { | ||
| 90 | pick, mode = w, m | 93 | pick, mode = w, m | ||
| 91 | continue | 94 | continue | ||
| @@ -97,6 +100,15 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||||
| 97 | } | 100 | } | ||
| 98 | } | 101 | } | ||
| 99 | if pick == nil { | 102 | if pick == nil { | ||
| 103 | + | if kept != nil && len(s.Windows) > 0 { | |||
| 104 | + | // The one window that could have gone on the plane fades out | |||
| 105 | + | // after its close: composed from its kept content. | |||
| 106 | + | for i := range s.Windows { | |||
| 107 | + | if w := &s.Windows[i]; !w.Hidden && w.Rect.W > 0 && w.Rect.H > 0 && kept(w.ID) { | |||
| 108 | + | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "leaving" | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 100 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "no_candidate" | 112 | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "no_candidate" | ||
| 101 | } | 113 | } | ||
| 102 | if s.Dim > 0 { | 114 | if s.Dim > 0 { | ||
M internal/adapters/drm/overlay_test.go +20 -20
| @@ -43,7 +43,7 @@ func overlayScene() (ports.Scene, map[ports.WindowID]ports.SurfaceContent) { | |||
| 43 | 43 | func TestScanoutWorkspaceClipCannotBeBypassed(t *testing.T) { | |
| 44 | 44 | s, contents := fullscreenScene() | |
| 45 | 45 | s.WorkspaceClip = ports.Rect{X: 50, W: 100, H: 100} | |
| 46 | - | if _, reason := scanoutCandidate(s, contents, 200, 100); reason != "workspace_clip" { | |
| 46 | + | if _, reason := scanoutCandidate(s, contents, 200, 100, nil); reason != "workspace_clip" { | |
| 47 | 47 | t.Fatalf("fullscreen bypassed clip: %q", reason) | |
| 48 | 48 | } | |
| 49 | 49 | if fullscreenShown(&s) { | |
| @@ -54,28 +54,28 @@ func TestScanoutWorkspaceClipCannotBeBypassed(t *testing.T) { | |||
| 54 | 54 | func TestOverlayWorkspaceClip(t *testing.T) { | |
| 55 | 55 | s, contents := overlayScene() | |
| 56 | 56 | s.WorkspaceClip = ports.Rect{X: 50, W: 100, H: 100} | |
| 57 | - | if _, _, _, reason := overlayCandidate(s, contents, false, nil); reason != "workspace_clip" { | |
| 57 | + | if _, _, _, reason := overlayCandidate(s, contents, false, nil, nil); reason != "workspace_clip" { | |
| 58 | 58 | t.Fatalf("overflowing workspace overlay: %q", reason) | |
| 59 | 59 | } | |
| 60 | 60 | s.WorkspaceClip = ports.Rect{X: 100, W: 100, H: 100} | |
| 61 | - | if win, _, _, reason := overlayCandidate(s, contents, false, nil); reason != "" || win.ID != 2 { | |
| 61 | + | if win, _, _, reason := overlayCandidate(s, contents, false, nil, nil); reason != "" || win.ID != 2 { | |
| 62 | 62 | t.Fatalf("contained workspace overlay: %v %q", win.ID, reason) | |
| 63 | 63 | } | |
| 64 | 64 | } | |
| 65 | 65 | ||
| 66 | 66 | func TestOverlayCandidate(t *testing.T) { | |
| 67 | 67 | s, c := overlayScene() | |
| 68 | - | if w, _, _, reason := overlayCandidate(s, c, false, nil); reason != "" || w.ID != 2 { | |
| 68 | + | if w, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "" || w.ID != 2 { | |
| 69 | 69 | t.Fatalf("candidate %v %q", w.ID, reason) | |
| 70 | 70 | } | |
| 71 | 71 | // A rotated output composes: planes are not rotated. | |
| 72 | 72 | rs, rc := overlayScene() | |
| 73 | 73 | rs.Transform = 1 | |
| 74 | - | if _, _, _, reason := overlayCandidate(rs, rc, false, nil); reason != "output_transform" { | |
| 74 | + | if _, _, _, reason := overlayCandidate(rs, rc, false, nil, nil); reason != "output_transform" { | |
| 75 | 75 | t.Fatalf("rotated output reason %q", reason) | |
| 76 | 76 | } | |
| 77 | 77 | c[2] = ports.SurfaceContent{ID: 2, Width: 100, Height: 100, Opaque: true, DMABuf: &ports.DMABuf{Format: fourccNV12}} | |
| 78 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" { | |
| 78 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" { | |
| 79 | 79 | t.Fatalf("YUV overlay reason %q", reason) | |
| 80 | 80 | } | |
| 81 | 81 | // A transformed buffer is composed: the plane would show it unrotated. | |
| @@ -83,58 +83,58 @@ func TestOverlayCandidate(t *testing.T) { | |||
| 83 | 83 | rotated := c[2] | |
| 84 | 84 | rotated.Transform = 1 | |
| 85 | 85 | c[2] = rotated | |
| 86 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" { | |
| 86 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" { | |
| 87 | 87 | t.Fatalf("transformed overlay reason %q", reason) | |
| 88 | 88 | } | |
| 89 | 89 | _, c = overlayScene() | |
| 90 | 90 | s.Windows = append(s.Windows, ports.SceneWindow{ID: 3, Rect: ports.Rect{W: 10, H: 10}}) | |
| 91 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "window_above" { | |
| 91 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "window_above" { | |
| 92 | 92 | t.Fatalf("reason %q", reason) | |
| 93 | 93 | } | |
| 94 | 94 | s, _ = overlayScene() | |
| 95 | 95 | s.Scale = 1.5 | |
| 96 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "scaled" { | |
| 96 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "scaled" { | |
| 97 | 97 | t.Fatalf("reason %q", reason) | |
| 98 | 98 | } | |
| 99 | 99 | s, _ = overlayScene() | |
| 100 | 100 | s.Layers = []ports.SceneLayer{{ID: 5, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 5, H: 5}}} | |
| 101 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "layer_above" { | |
| 101 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "layer_above" { | |
| 102 | 102 | t.Fatalf("reason %q", reason) | |
| 103 | 103 | } | |
| 104 | 104 | // The dim veil is composed under the float: nothing leaves for the plane. | |
| 105 | 105 | s, _ = overlayScene() | |
| 106 | 106 | s.Dim = 0.3 | |
| 107 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "dim" { | |
| 107 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "dim" { | |
| 108 | 108 | t.Fatalf("dim reason %q", reason) | |
| 109 | 109 | } | |
| 110 | 110 | // A dimmed window (a peeking stashed one) needs its veil composed. | |
| 111 | 111 | s, _ = overlayScene() | |
| 112 | 112 | s.Windows[len(s.Windows)-1].Dim = 0.5 | |
| 113 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" { | |
| 113 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" { | |
| 114 | 114 | t.Fatalf("dimmed window reason %q", reason) | |
| 115 | 115 | } | |
| 116 | 116 | // A fading window needs its translucency composed. | |
| 117 | 117 | s, _ = overlayScene() | |
| 118 | 118 | s.Windows[len(s.Windows)-1].Fade = 0.5 | |
| 119 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" { | |
| 119 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" { | |
| 120 | 120 | t.Fatalf("fading window reason %q", reason) | |
| 121 | 121 | } | |
| 122 | 122 | // A zoomed (animating) window is drawn smaller than its buffer. | |
| 123 | 123 | s, _ = overlayScene() | |
| 124 | 124 | s.Windows[len(s.Windows)-1].Zoom = 0.9 | |
| 125 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" { | |
| 125 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" { | |
| 126 | 126 | t.Fatalf("zoomed window reason %q", reason) | |
| 127 | 127 | } | |
| 128 | 128 | // A window with the focus effect needs it composed. | |
| 129 | 129 | s, _ = overlayScene() | |
| 130 | 130 | s.Windows[len(s.Windows)-1].FocusEffect = 0.05 | |
| 131 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" { | |
| 131 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" { | |
| 132 | 132 | t.Fatalf("pulsing window reason %q", reason) | |
| 133 | 133 | } | |
| 134 | 134 | // An overview preview is drawn smaller than its buffer: composed. | |
| 135 | 135 | s, _ = overlayScene() | |
| 136 | 136 | s.Windows[len(s.Windows)-1].Preview = 0.5 | |
| 137 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" { | |
| 137 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" { | |
| 138 | 138 | t.Fatalf("preview reason %q", reason) | |
| 139 | 139 | } | |
| 140 | 140 | // A viewport crop is composed: the plane would show the whole buffer. | |
| @@ -142,13 +142,13 @@ func TestOverlayCandidate(t *testing.T) { | |||
| 142 | 142 | cropped := c[2] | |
| 143 | 143 | cropped.Source = [4]float32{10, 10, 50, 50} | |
| 144 | 144 | c[2] = cropped | |
| 145 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" { | |
| 145 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" { | |
| 146 | 146 | t.Fatalf("cropped overlay reason %q", reason) | |
| 147 | 147 | } | |
| 148 | 148 | // A full-buffer source is no crop. | |
| 149 | 149 | cropped.Source = [4]float32{0, 0, 100, 100} | |
| 150 | 150 | c[2] = cropped | |
| 151 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "" { | |
| 151 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "" { | |
| 152 | 152 | t.Fatalf("full source reason %q", reason) | |
| 153 | 153 | } | |
| 154 | 154 | } | |
| @@ -224,12 +224,12 @@ func TestOverlayTestRefusedFallsBack(t *testing.T) { | |||
| 224 | 224 | func TestOverlayPopupAndBorder(t *testing.T) { | |
| 225 | 225 | s, c := overlayScene() | |
| 226 | 226 | s.Windows = append([]ports.SceneWindow{{ID: 7, Popup: true, Rect: ports.Rect{W: 5, H: 5}}}, s.Windows...) | |
| 227 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "window_above" { | |
| 227 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "window_above" { | |
| 228 | 228 | t.Fatalf("popup: %q", reason) | |
| 229 | 229 | } | |
| 230 | 230 | s, _ = overlayScene() | |
| 231 | 231 | s.Separators = []ports.Separator{{Rect: ports.Rect{W: 2, H: 2}}} | |
| 232 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "border" { | |
| 232 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "border" { | |
| 233 | 233 | t.Fatalf("border: %q", reason) | |
| 234 | 234 | } | |
| 235 | 235 | } | |
| @@ -43,7 +43,7 @@ func overlayScene() (ports.Scene, map[ports.WindowID]ports.SurfaceContent) { | |||||
| 43 | func TestScanoutWorkspaceClipCannotBeBypassed(t *testing.T) { | 43 | func TestScanoutWorkspaceClipCannotBeBypassed(t *testing.T) { | ||
| 44 | s, contents := fullscreenScene() | 44 | s, contents := fullscreenScene() | ||
| 45 | s.WorkspaceClip = ports.Rect{X: 50, W: 100, H: 100} | 45 | s.WorkspaceClip = ports.Rect{X: 50, W: 100, H: 100} | ||
| 46 | - | if _, reason := scanoutCandidate(s, contents, 200, 100); reason != "workspace_clip" { | 46 | + | if _, reason := scanoutCandidate(s, contents, 200, 100, nil); reason != "workspace_clip" { |
| 47 | t.Fatalf("fullscreen bypassed clip: %q", reason) | 47 | t.Fatalf("fullscreen bypassed clip: %q", reason) | ||
| 48 | } | 48 | } | ||
| 49 | if fullscreenShown(&s) { | 49 | if fullscreenShown(&s) { | ||
| @@ -54,28 +54,28 @@ func TestScanoutWorkspaceClipCannotBeBypassed(t *testing.T) { | |||||
| 54 | func TestOverlayWorkspaceClip(t *testing.T) { | 54 | func TestOverlayWorkspaceClip(t *testing.T) { | ||
| 55 | s, contents := overlayScene() | 55 | s, contents := overlayScene() | ||
| 56 | s.WorkspaceClip = ports.Rect{X: 50, W: 100, H: 100} | 56 | s.WorkspaceClip = ports.Rect{X: 50, W: 100, H: 100} | ||
| 57 | - | if _, _, _, reason := overlayCandidate(s, contents, false, nil); reason != "workspace_clip" { | 57 | + | if _, _, _, reason := overlayCandidate(s, contents, false, nil, nil); reason != "workspace_clip" { |
| 58 | t.Fatalf("overflowing workspace overlay: %q", reason) | 58 | t.Fatalf("overflowing workspace overlay: %q", reason) | ||
| 59 | } | 59 | } | ||
| 60 | s.WorkspaceClip = ports.Rect{X: 100, W: 100, H: 100} | 60 | s.WorkspaceClip = ports.Rect{X: 100, W: 100, H: 100} | ||
| 61 | - | if win, _, _, reason := overlayCandidate(s, contents, false, nil); reason != "" || win.ID != 2 { | 61 | + | if win, _, _, reason := overlayCandidate(s, contents, false, nil, nil); reason != "" || win.ID != 2 { |
| 62 | t.Fatalf("contained workspace overlay: %v %q", win.ID, reason) | 62 | t.Fatalf("contained workspace overlay: %v %q", win.ID, reason) | ||
| 63 | } | 63 | } | ||
| 64 | } | 64 | } | ||
| 65 | 65 | ||||
| 66 | func TestOverlayCandidate(t *testing.T) { | 66 | func TestOverlayCandidate(t *testing.T) { | ||
| 67 | s, c := overlayScene() | 67 | s, c := overlayScene() | ||
| 68 | - | if w, _, _, reason := overlayCandidate(s, c, false, nil); reason != "" || w.ID != 2 { | 68 | + | if w, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "" || w.ID != 2 { |
| 69 | t.Fatalf("candidate %v %q", w.ID, reason) | 69 | t.Fatalf("candidate %v %q", w.ID, reason) | ||
| 70 | } | 70 | } | ||
| 71 | // A rotated output composes: planes are not rotated. | 71 | // A rotated output composes: planes are not rotated. | ||
| 72 | rs, rc := overlayScene() | 72 | rs, rc := overlayScene() | ||
| 73 | rs.Transform = 1 | 73 | rs.Transform = 1 | ||
| 74 | - | if _, _, _, reason := overlayCandidate(rs, rc, false, nil); reason != "output_transform" { | 74 | + | if _, _, _, reason := overlayCandidate(rs, rc, false, nil, nil); reason != "output_transform" { |
| 75 | t.Fatalf("rotated output reason %q", reason) | 75 | t.Fatalf("rotated output reason %q", reason) | ||
| 76 | } | 76 | } | ||
| 77 | c[2] = ports.SurfaceContent{ID: 2, Width: 100, Height: 100, Opaque: true, DMABuf: &ports.DMABuf{Format: fourccNV12}} | 77 | c[2] = ports.SurfaceContent{ID: 2, Width: 100, Height: 100, Opaque: true, DMABuf: &ports.DMABuf{Format: fourccNV12}} | ||
| 78 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" { | 78 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" { |
| 79 | t.Fatalf("YUV overlay reason %q", reason) | 79 | t.Fatalf("YUV overlay reason %q", reason) | ||
| 80 | } | 80 | } | ||
| 81 | // A transformed buffer is composed: the plane would show it unrotated. | 81 | // A transformed buffer is composed: the plane would show it unrotated. | ||
| @@ -83,58 +83,58 @@ func TestOverlayCandidate(t *testing.T) { | |||||
| 83 | rotated := c[2] | 83 | rotated := c[2] | ||
| 84 | rotated.Transform = 1 | 84 | rotated.Transform = 1 | ||
| 85 | c[2] = rotated | 85 | c[2] = rotated | ||
| 86 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" { | 86 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" { |
| 87 | t.Fatalf("transformed overlay reason %q", reason) | 87 | t.Fatalf("transformed overlay reason %q", reason) | ||
| 88 | } | 88 | } | ||
| 89 | _, c = overlayScene() | 89 | _, c = overlayScene() | ||
| 90 | s.Windows = append(s.Windows, ports.SceneWindow{ID: 3, Rect: ports.Rect{W: 10, H: 10}}) | 90 | s.Windows = append(s.Windows, ports.SceneWindow{ID: 3, Rect: ports.Rect{W: 10, H: 10}}) | ||
| 91 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "window_above" { | 91 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "window_above" { |
| 92 | t.Fatalf("reason %q", reason) | 92 | t.Fatalf("reason %q", reason) | ||
| 93 | } | 93 | } | ||
| 94 | s, _ = overlayScene() | 94 | s, _ = overlayScene() | ||
| 95 | s.Scale = 1.5 | 95 | s.Scale = 1.5 | ||
| 96 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "scaled" { | 96 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "scaled" { |
| 97 | t.Fatalf("reason %q", reason) | 97 | t.Fatalf("reason %q", reason) | ||
| 98 | } | 98 | } | ||
| 99 | s, _ = overlayScene() | 99 | s, _ = overlayScene() | ||
| 100 | s.Layers = []ports.SceneLayer{{ID: 5, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 5, H: 5}}} | 100 | s.Layers = []ports.SceneLayer{{ID: 5, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 5, H: 5}}} | ||
| 101 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "layer_above" { | 101 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "layer_above" { |
| 102 | t.Fatalf("reason %q", reason) | 102 | t.Fatalf("reason %q", reason) | ||
| 103 | } | 103 | } | ||
| 104 | // The dim veil is composed under the float: nothing leaves for the plane. | 104 | // The dim veil is composed under the float: nothing leaves for the plane. | ||
| 105 | s, _ = overlayScene() | 105 | s, _ = overlayScene() | ||
| 106 | s.Dim = 0.3 | 106 | s.Dim = 0.3 | ||
| 107 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "dim" { | 107 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "dim" { |
| 108 | t.Fatalf("dim reason %q", reason) | 108 | t.Fatalf("dim reason %q", reason) | ||
| 109 | } | 109 | } | ||
| 110 | // A dimmed window (a peeking stashed one) needs its veil composed. | 110 | // A dimmed window (a peeking stashed one) needs its veil composed. | ||
| 111 | s, _ = overlayScene() | 111 | s, _ = overlayScene() | ||
| 112 | s.Windows[len(s.Windows)-1].Dim = 0.5 | 112 | s.Windows[len(s.Windows)-1].Dim = 0.5 | ||
| 113 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" { | 113 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" { |
| 114 | t.Fatalf("dimmed window reason %q", reason) | 114 | t.Fatalf("dimmed window reason %q", reason) | ||
| 115 | } | 115 | } | ||
| 116 | // A fading window needs its translucency composed. | 116 | // A fading window needs its translucency composed. | ||
| 117 | s, _ = overlayScene() | 117 | s, _ = overlayScene() | ||
| 118 | s.Windows[len(s.Windows)-1].Fade = 0.5 | 118 | s.Windows[len(s.Windows)-1].Fade = 0.5 | ||
| 119 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" { | 119 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" { |
| 120 | t.Fatalf("fading window reason %q", reason) | 120 | t.Fatalf("fading window reason %q", reason) | ||
| 121 | } | 121 | } | ||
| 122 | // A zoomed (animating) window is drawn smaller than its buffer. | 122 | // A zoomed (animating) window is drawn smaller than its buffer. | ||
| 123 | s, _ = overlayScene() | 123 | s, _ = overlayScene() | ||
| 124 | s.Windows[len(s.Windows)-1].Zoom = 0.9 | 124 | s.Windows[len(s.Windows)-1].Zoom = 0.9 | ||
| 125 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" { | 125 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" { |
| 126 | t.Fatalf("zoomed window reason %q", reason) | 126 | t.Fatalf("zoomed window reason %q", reason) | ||
| 127 | } | 127 | } | ||
| 128 | // A window with the focus effect needs it composed. | 128 | // A window with the focus effect needs it composed. | ||
| 129 | s, _ = overlayScene() | 129 | s, _ = overlayScene() | ||
| 130 | s.Windows[len(s.Windows)-1].FocusEffect = 0.05 | 130 | s.Windows[len(s.Windows)-1].FocusEffect = 0.05 | ||
| 131 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" { | 131 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" { |
| 132 | t.Fatalf("pulsing window reason %q", reason) | 132 | t.Fatalf("pulsing window reason %q", reason) | ||
| 133 | } | 133 | } | ||
| 134 | // An overview preview is drawn smaller than its buffer: composed. | 134 | // An overview preview is drawn smaller than its buffer: composed. | ||
| 135 | s, _ = overlayScene() | 135 | s, _ = overlayScene() | ||
| 136 | s.Windows[len(s.Windows)-1].Preview = 0.5 | 136 | s.Windows[len(s.Windows)-1].Preview = 0.5 | ||
| 137 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" { | 137 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" { |
| 138 | t.Fatalf("preview reason %q", reason) | 138 | t.Fatalf("preview reason %q", reason) | ||
| 139 | } | 139 | } | ||
| 140 | // A viewport crop is composed: the plane would show the whole buffer. | 140 | // A viewport crop is composed: the plane would show the whole buffer. | ||
| @@ -142,13 +142,13 @@ func TestOverlayCandidate(t *testing.T) { | |||||
| 142 | cropped := c[2] | 142 | cropped := c[2] | ||
| 143 | cropped.Source = [4]float32{10, 10, 50, 50} | 143 | cropped.Source = [4]float32{10, 10, 50, 50} | ||
| 144 | c[2] = cropped | 144 | c[2] = cropped | ||
| 145 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" { | 145 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "no_candidate" { |
| 146 | t.Fatalf("cropped overlay reason %q", reason) | 146 | t.Fatalf("cropped overlay reason %q", reason) | ||
| 147 | } | 147 | } | ||
| 148 | // A full-buffer source is no crop. | 148 | // A full-buffer source is no crop. | ||
| 149 | cropped.Source = [4]float32{0, 0, 100, 100} | 149 | cropped.Source = [4]float32{0, 0, 100, 100} | ||
| 150 | c[2] = cropped | 150 | c[2] = cropped | ||
| 151 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "" { | 151 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "" { |
| 152 | t.Fatalf("full source reason %q", reason) | 152 | t.Fatalf("full source reason %q", reason) | ||
| 153 | } | 153 | } | ||
| 154 | } | 154 | } | ||
| @@ -224,12 +224,12 @@ func TestOverlayTestRefusedFallsBack(t *testing.T) { | |||||
| 224 | func TestOverlayPopupAndBorder(t *testing.T) { | 224 | func TestOverlayPopupAndBorder(t *testing.T) { | ||
| 225 | s, c := overlayScene() | 225 | s, c := overlayScene() | ||
| 226 | s.Windows = append([]ports.SceneWindow{{ID: 7, Popup: true, Rect: ports.Rect{W: 5, H: 5}}}, s.Windows...) | 226 | s.Windows = append([]ports.SceneWindow{{ID: 7, Popup: true, Rect: ports.Rect{W: 5, H: 5}}}, s.Windows...) | ||
| 227 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "window_above" { | 227 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "window_above" { |
| 228 | t.Fatalf("popup: %q", reason) | 228 | t.Fatalf("popup: %q", reason) | ||
| 229 | } | 229 | } | ||
| 230 | s, _ = overlayScene() | 230 | s, _ = overlayScene() | ||
| 231 | s.Separators = []ports.Separator{{Rect: ports.Rect{W: 2, H: 2}}} | 231 | s.Separators = []ports.Separator{{Rect: ports.Rect{W: 2, H: 2}}} | ||
| 232 | - | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "border" { | 232 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil, nil); reason != "border" { |
| 233 | t.Fatalf("border: %q", reason) | 233 | t.Fatalf("border: %q", reason) | ||
| 234 | } | 234 | } | ||
| 235 | } | 235 | } | ||
M internal/adapters/drm/report.go +3 -1
| @@ -30,7 +30,9 @@ func (o *Output) shownBy(s ports.Scene, seen map[ports.WindowID]uint64) map[port | |||
| 30 | 30 | } | |
| 31 | 31 | clear(o.showsScratch) | |
| 32 | 32 | for id, seq := range seen { | |
| 33 | - | if s.Shows(id) { | |
| 33 | + | // A closed window drawn from its kept content is not shown to its | |
| 34 | + | // client: the empty content has no frame to present. | |
| 35 | + | if s.Shows(id) && (o.kept == nil || !o.kept(id)) { | |
| 34 | 36 | o.showsScratch[id] = seq | |
| 35 | 37 | } | |
| 36 | 38 | } | |
| @@ -30,7 +30,9 @@ func (o *Output) shownBy(s ports.Scene, seen map[ports.WindowID]uint64) map[port | |||||
| 30 | } | 30 | } | ||
| 31 | clear(o.showsScratch) | 31 | clear(o.showsScratch) | ||
| 32 | for id, seq := range seen { | 32 | for id, seq := range seen { | ||
| 33 | - | if s.Shows(id) { | 33 | + | // A closed window drawn from its kept content is not shown to its |
| 34 | + | // client: the empty content has no frame to present. | |||
| 35 | + | if s.Shows(id) && (o.kept == nil || !o.kept(id)) { | |||
| 34 | o.showsScratch[id] = seq | 36 | o.showsScratch[id] = seq | ||
| 35 | } | 37 | } | ||
| 36 | } | 38 | } | ||
M internal/adapters/drm/scanout.go +10 -3
| @@ -85,7 +85,11 @@ func fullscreenShown(s *ports.Scene) bool { | |||
| 85 | 85 | // scanoutCandidate returns the window content the output can scan out | |
| 86 | 86 | // directly, or a reason why it cannot. w, h are the output's physical size. | |
| 87 | 87 | // The plane's formats are checked by scanoutFB. | |
| 88 | - | func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int) (ports.SurfaceContent, string) { | |
| 88 | + | // | |
| 89 | + | // kept reports a window drawn from a content its client withdrew (it | |
| 90 | + | // closed and fades out): the client may already have its buffer back, so | |
| 91 | + | // it is never put on a plane (nil: none is). | |
| 92 | + | func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int, kept func(ports.WindowID) bool) (ports.SurfaceContent, string) { | |
| 89 | 93 | if s.Transform != 0 { | |
| 90 | 94 | // The scanout buffer would have to be pre-rotated: rotated outputs compose. | |
| 91 | 95 | return ports.SurfaceContent{}, "output_transform" | |
| @@ -107,6 +111,9 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 107 | 111 | if full == nil { | |
| 108 | 112 | return ports.SurfaceContent{}, "no_fullscreen" | |
| 109 | 113 | } | |
| 114 | + | if kept != nil && kept(full.ID) { | |
| 115 | + | return ports.SurfaceContent{}, "leaving" | |
| 116 | + | } | |
| 110 | 117 | if len(s.DropHints) > 0 { | |
| 111 | 118 | // Drag hints are drawn over the window. | |
| 112 | 119 | return ports.SurfaceContent{}, "drop_hint" | |
| @@ -205,7 +212,7 @@ func clientSubsurface(c ports.SurfaceContent) (ports.SurfaceContent, string) { | |||
| 205 | 212 | func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent, mode colorMode) { | |
| 206 | 213 | reason := "disabled" | |
| 207 | 214 | if o.scanout { | |
| 208 | - | c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height()) | |
| 215 | + | c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height(), o.kept) | |
| 209 | 216 | if reason == "" { | |
| 210 | 217 | mode, reason = planeColor(c.Color, c.DMABuf.Format, c.Opaque, o.hdr.on, o.primary.pipeline) | |
| 211 | 218 | } | |
| @@ -260,7 +267,7 @@ func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.S | |||
| 260 | 267 | var w ports.SceneWindow | |
| 261 | 268 | var c ports.SurfaceContent | |
| 262 | 269 | var mode colorMode | |
| 263 | - | w, c, mode, reason = overlayCandidate(s, surfaces, o.hdr.on, o.overlay.pipeline) | |
| 270 | + | w, c, mode, reason = overlayCandidate(s, surfaces, o.hdr.on, o.overlay.pipeline, o.kept) | |
| 264 | 271 | if reason == "" && mode != colorBypass { | |
| 265 | 272 | if ok, known := o.overlay.verdict(c.DMABuf.Format, o.cursorShown()); known && !ok { | |
| 266 | 273 | reason = "color_refused" | |
| @@ -85,7 +85,11 @@ func fullscreenShown(s *ports.Scene) bool { | |||||
| 85 | // scanoutCandidate returns the window content the output can scan out | 85 | // scanoutCandidate returns the window content the output can scan out | ||
| 86 | // directly, or a reason why it cannot. w, h are the output's physical size. | 86 | // directly, or a reason why it cannot. w, h are the output's physical size. | ||
| 87 | // The plane's formats are checked by scanoutFB. | 87 | // The plane's formats are checked by scanoutFB. | ||
| 88 | - | func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int) (ports.SurfaceContent, string) { | 88 | + | // |
| 89 | + | // kept reports a window drawn from a content its client withdrew (it | |||
| 90 | + | // closed and fades out): the client may already have its buffer back, so | |||
| 91 | + | // it is never put on a plane (nil: none is). | |||
| 92 | + | func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, w, h int, kept func(ports.WindowID) bool) (ports.SurfaceContent, string) { | |||
| 89 | if s.Transform != 0 { | 93 | if s.Transform != 0 { | ||
| 90 | // The scanout buffer would have to be pre-rotated: rotated outputs compose. | 94 | // The scanout buffer would have to be pre-rotated: rotated outputs compose. | ||
| 91 | return ports.SurfaceContent{}, "output_transform" | 95 | return ports.SurfaceContent{}, "output_transform" | ||
| @@ -107,6 +111,9 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||||
| 107 | if full == nil { | 111 | if full == nil { | ||
| 108 | return ports.SurfaceContent{}, "no_fullscreen" | 112 | return ports.SurfaceContent{}, "no_fullscreen" | ||
| 109 | } | 113 | } | ||
| 114 | + | if kept != nil && kept(full.ID) { | |||
| 115 | + | return ports.SurfaceContent{}, "leaving" | |||
| 116 | + | } | |||
| 110 | if len(s.DropHints) > 0 { | 117 | if len(s.DropHints) > 0 { | ||
| 111 | // Drag hints are drawn over the window. | 118 | // Drag hints are drawn over the window. | ||
| 112 | return ports.SurfaceContent{}, "drop_hint" | 119 | return ports.SurfaceContent{}, "drop_hint" | ||
| @@ -205,7 +212,7 @@ func clientSubsurface(c ports.SurfaceContent) (ports.SurfaceContent, string) { | |||||
| 205 | func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent, mode colorMode) { | 212 | func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent, mode colorMode) { | ||
| 206 | reason := "disabled" | 213 | reason := "disabled" | ||
| 207 | if o.scanout { | 214 | if o.scanout { | ||
| 208 | - | c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height()) | 215 | + | c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height(), o.kept) |
| 209 | if reason == "" { | 216 | if reason == "" { | ||
| 210 | mode, reason = planeColor(c.Color, c.DMABuf.Format, c.Opaque, o.hdr.on, o.primary.pipeline) | 217 | mode, reason = planeColor(c.Color, c.DMABuf.Format, c.Opaque, o.hdr.on, o.primary.pipeline) | ||
| 211 | } | 218 | } | ||
| @@ -260,7 +267,7 @@ func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.S | |||||
| 260 | var w ports.SceneWindow | 267 | var w ports.SceneWindow | ||
| 261 | var c ports.SurfaceContent | 268 | var c ports.SurfaceContent | ||
| 262 | var mode colorMode | 269 | var mode colorMode | ||
| 263 | - | w, c, mode, reason = overlayCandidate(s, surfaces, o.hdr.on, o.overlay.pipeline) | 270 | + | w, c, mode, reason = overlayCandidate(s, surfaces, o.hdr.on, o.overlay.pipeline, o.kept) |
| 264 | if reason == "" && mode != colorBypass { | 271 | if reason == "" && mode != colorBypass { | ||
| 265 | if ok, known := o.overlay.verdict(c.DMABuf.Format, o.cursorShown()); known && !ok { | 272 | if ok, known := o.overlay.verdict(c.DMABuf.Format, o.cursorShown()); known && !ok { | ||
| 266 | reason = "color_refused" | 273 | reason = "color_refused" | ||
M internal/adapters/drm/scanout_test.go +4 -4
| @@ -76,7 +76,7 @@ func TestScanoutCandidate(t *testing.T) { | |||
| 76 | 76 | if tc.cont != nil { | |
| 77 | 77 | tc.cont(&c) | |
| 78 | 78 | } | |
| 79 | - | if _, reason := scanoutCandidate(s, map[ports.WindowID]ports.SurfaceContent{1: c}, 200, 100); reason != tc.reason { | |
| 79 | + | if _, reason := scanoutCandidate(s, map[ports.WindowID]ports.SurfaceContent{1: c}, 200, 100, nil); reason != tc.reason { | |
| 80 | 80 | t.Errorf("%s: reason %q, want %q", tc.name, reason, tc.reason) | |
| 81 | 81 | } | |
| 82 | 82 | } | |
| @@ -105,7 +105,7 @@ func TestScanoutCandidateClientSubsurface(t *testing.T) { | |||
| 105 | 105 | root := ports.SurfaceContent{ID: 1, Seq: 7, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, SHM: &ports.SHMBuffer{}, | |
| 106 | 106 | Geometry: ports.Rect{W: 100, H: 50}, | |
| 107 | 107 | Children: []ports.Subsurface{{SurfaceContent: ports.SurfaceContent{Seq: 3, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, Opaque: true, DMABuf: game, Async: true, Acquire: acquire}}}} | |
| 108 | - | c, reason := scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: root}, 200, 100) | |
| 108 | + | c, reason := scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: root}, 200, 100, nil) | |
| 109 | 109 | if reason != "" || c.DMABuf != game || c.ID != 1 || c.Seq != 7 || !c.Async || c.Acquire != acquire || c.Geometry != (ports.Rect{}) { | |
| 110 | 110 | t.Fatalf("candidate %+v, reason %q", c, reason) | |
| 111 | 111 | } | |
| @@ -114,12 +114,12 @@ func TestScanoutCandidateClientSubsurface(t *testing.T) { | |||
| 114 | 114 | // one; alone it is not a dmabuf. | |
| 115 | 115 | solidRoot := root | |
| 116 | 116 | solidRoot.SHM, solidRoot.Solid = nil, &ports.SolidColor{A: 1} | |
| 117 | - | c, reason = scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: solidRoot}, 200, 100) | |
| 117 | + | c, reason = scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: solidRoot}, 200, 100, nil) | |
| 118 | 118 | if reason != "" || c.DMABuf != game || c.Solid != nil || c.ID != 1 || c.Seq != 7 { | |
| 119 | 119 | t.Fatalf("solid root with dmabuf child: candidate %+v, reason %q", c, reason) | |
| 120 | 120 | } | |
| 121 | 121 | solidRoot.Children = nil | |
| 122 | - | if _, reason = scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: solidRoot}, 200, 100); reason != "not_dmabuf" { | |
| 122 | + | if _, reason = scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: solidRoot}, 200, 100, nil); reason != "not_dmabuf" { | |
| 123 | 123 | t.Fatalf("solid root alone: reason %q, want not_dmabuf", reason) | |
| 124 | 124 | } | |
| 125 | 125 | } | |
| @@ -76,7 +76,7 @@ func TestScanoutCandidate(t *testing.T) { | |||||
| 76 | if tc.cont != nil { | 76 | if tc.cont != nil { | ||
| 77 | tc.cont(&c) | 77 | tc.cont(&c) | ||
| 78 | } | 78 | } | ||
| 79 | - | if _, reason := scanoutCandidate(s, map[ports.WindowID]ports.SurfaceContent{1: c}, 200, 100); reason != tc.reason { | 79 | + | if _, reason := scanoutCandidate(s, map[ports.WindowID]ports.SurfaceContent{1: c}, 200, 100, nil); reason != tc.reason { |
| 80 | t.Errorf("%s: reason %q, want %q", tc.name, reason, tc.reason) | 80 | t.Errorf("%s: reason %q, want %q", tc.name, reason, tc.reason) | ||
| 81 | } | 81 | } | ||
| 82 | } | 82 | } | ||
| @@ -105,7 +105,7 @@ func TestScanoutCandidateClientSubsurface(t *testing.T) { | |||||
| 105 | root := ports.SurfaceContent{ID: 1, Seq: 7, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, SHM: &ports.SHMBuffer{}, | 105 | root := ports.SurfaceContent{ID: 1, Seq: 7, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, SHM: &ports.SHMBuffer{}, | ||
| 106 | Geometry: ports.Rect{W: 100, H: 50}, | 106 | Geometry: ports.Rect{W: 100, H: 50}, | ||
| 107 | Children: []ports.Subsurface{{SurfaceContent: ports.SurfaceContent{Seq: 3, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, Opaque: true, DMABuf: game, Async: true, Acquire: acquire}}}} | 107 | Children: []ports.Subsurface{{SurfaceContent: ports.SurfaceContent{Seq: 3, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, Opaque: true, DMABuf: game, Async: true, Acquire: acquire}}}} | ||
| 108 | - | c, reason := scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: root}, 200, 100) | 108 | + | c, reason := scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: root}, 200, 100, nil) |
| 109 | if reason != "" || c.DMABuf != game || c.ID != 1 || c.Seq != 7 || !c.Async || c.Acquire != acquire || c.Geometry != (ports.Rect{}) { | 109 | if reason != "" || c.DMABuf != game || c.ID != 1 || c.Seq != 7 || !c.Async || c.Acquire != acquire || c.Geometry != (ports.Rect{}) { | ||
| 110 | t.Fatalf("candidate %+v, reason %q", c, reason) | 110 | t.Fatalf("candidate %+v, reason %q", c, reason) | ||
| 111 | } | 111 | } | ||
| @@ -114,12 +114,12 @@ func TestScanoutCandidateClientSubsurface(t *testing.T) { | |||||
| 114 | // one; alone it is not a dmabuf. | 114 | // one; alone it is not a dmabuf. | ||
| 115 | solidRoot := root | 115 | solidRoot := root | ||
| 116 | solidRoot.SHM, solidRoot.Solid = nil, &ports.SolidColor{A: 1} | 116 | solidRoot.SHM, solidRoot.Solid = nil, &ports.SolidColor{A: 1} | ||
| 117 | - | c, reason = scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: solidRoot}, 200, 100) | 117 | + | c, reason = scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: solidRoot}, 200, 100, nil) |
| 118 | if reason != "" || c.DMABuf != game || c.Solid != nil || c.ID != 1 || c.Seq != 7 { | 118 | if reason != "" || c.DMABuf != game || c.Solid != nil || c.ID != 1 || c.Seq != 7 { | ||
| 119 | t.Fatalf("solid root with dmabuf child: candidate %+v, reason %q", c, reason) | 119 | t.Fatalf("solid root with dmabuf child: candidate %+v, reason %q", c, reason) | ||
| 120 | } | 120 | } | ||
| 121 | solidRoot.Children = nil | 121 | solidRoot.Children = nil | ||
| 122 | - | if _, reason = scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: solidRoot}, 200, 100); reason != "not_dmabuf" { | 122 | + | if _, reason = scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: solidRoot}, 200, 100, nil); reason != "not_dmabuf" { |
| 123 | t.Fatalf("solid root alone: reason %q, want not_dmabuf", reason) | 123 | t.Fatalf("solid root alone: reason %q, want not_dmabuf", reason) | ||
| 124 | } | 124 | } | ||
| 125 | } | 125 | } | ||
A internal/adapters/headless/leaving_test.go +25 -0
| @@ -0,0 +1,25 @@ | |||
| 1 | + | package headless | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/neferwl/internal/adapters/surfaces" | |
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // A window drawn from a content its client withdrew (closed, fading out) is | |
| 11 | + | // not reported shown: the empty content has no frame to present. | |
| 12 | + | func TestFlipInfoSkipsKeptContent(t *testing.T) { | |
| 13 | + | table := surfaces.New() | |
| 14 | + | s := ports.Scene{OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}, {ID: 2, Rect: ports.Rect{W: 1, H: 1}}}} | |
| 15 | + | table.Update(ports.SurfaceContent{ID: 1, Seq: 3, SHM: &ports.SHMBuffer{Pool: 1}}, s) | |
| 16 | + | table.Update(ports.SurfaceContent{ID: 2, Seq: 1, SHM: &ports.SHMBuffer{Pool: 2}}, s) | |
| 17 | + | if f := flipInfo(s, table.Map(), table.Kept); f.Shows[1] != 3 || f.Shows[2] != 1 { | |
| 18 | + | t.Fatalf("control: %v", f.Shows) | |
| 19 | + | } | |
| 20 | + | table.Update(ports.SurfaceContent{ID: 1, Seq: 4}, s) | |
| 21 | + | f := flipInfo(s, table.Map(), table.Kept) | |
| 22 | + | if _, ok := f.Shows[1]; ok || f.Shows[2] != 1 { | |
| 23 | + | t.Fatalf("a leaving window is reported shown: %v", f.Shows) | |
| 24 | + | } | |
| 25 | + | } | |
| @@ -0,0 +1,25 @@ | |||||
| 1 | + | package headless | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/neferwl/internal/adapters/surfaces" | |||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // A window drawn from a content its client withdrew (closed, fading out) is | |||
| 11 | + | // not reported shown: the empty content has no frame to present. | |||
| 12 | + | func TestFlipInfoSkipsKeptContent(t *testing.T) { | |||
| 13 | + | table := surfaces.New() | |||
| 14 | + | s := ports.Scene{OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}, {ID: 2, Rect: ports.Rect{W: 1, H: 1}}}} | |||
| 15 | + | table.Update(ports.SurfaceContent{ID: 1, Seq: 3, SHM: &ports.SHMBuffer{Pool: 1}}, s) | |||
| 16 | + | table.Update(ports.SurfaceContent{ID: 2, Seq: 1, SHM: &ports.SHMBuffer{Pool: 2}}, s) | |||
| 17 | + | if f := flipInfo(s, table.Map(), table.Kept); f.Shows[1] != 3 || f.Shows[2] != 1 { | |||
| 18 | + | t.Fatalf("control: %v", f.Shows) | |||
| 19 | + | } | |||
| 20 | + | table.Update(ports.SurfaceContent{ID: 1, Seq: 4}, s) | |||
| 21 | + | f := flipInfo(s, table.Map(), table.Kept) | |||
| 22 | + | if _, ok := f.Shows[1]; ok || f.Shows[2] != 1 { | |||
| 23 | + | t.Fatalf("a leaving window is reported shown: %v", f.Shows) | |||
| 24 | + | } | |||
| 25 | + | } | |||
M internal/adapters/headless/output.go +5 -3
| @@ -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, drawn), seen) | |
| 582 | + | opts.report(&reports, flipInfo(scene, drawn, table.Kept), 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 { | |
| @@ -671,12 +671,14 @@ func (opts Options) flush(q *presented.Queue) { | |||
| 671 | 671 | // flipInfo describes a drawn frame as a flip at the current CLOCK_MONOTONIC | |
| 672 | 672 | // time (software clock, refresh unknown), so presentation feedback and | |
| 673 | 673 | // frame callbacks follow headless frames. | |
| 674 | - | func flipInfo(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) *ports.FlipInfo { | |
| 674 | + | func flipInfo(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, kept func(ports.WindowID) bool) *ports.FlipInfo { | |
| 675 | 675 | var ts unix.Timespec | |
| 676 | 676 | _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts) | |
| 677 | 677 | shows := map[ports.WindowID]uint64{} | |
| 678 | 678 | for id, c := range surfaces { | |
| 679 | - | if scene.Shows(id) { | |
| 679 | + | // A closed window drawn from its kept content is not shown to | |
| 680 | + | // its client. | |
| 681 | + | if scene.Shows(id) && !kept(id) { | |
| 680 | 682 | shows[id] = c.Seq | |
| 681 | 683 | } | |
| 682 | 684 | } | |
| @@ -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, drawn), seen) | 582 | + | opts.report(&reports, flipInfo(scene, drawn, table.Kept), 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 { | ||
| @@ -671,12 +671,14 @@ func (opts Options) flush(q *presented.Queue) { | |||||
| 671 | // flipInfo describes a drawn frame as a flip at the current CLOCK_MONOTONIC | 671 | // flipInfo describes a drawn frame as a flip at the current CLOCK_MONOTONIC | ||
| 672 | // time (software clock, refresh unknown), so presentation feedback and | 672 | // time (software clock, refresh unknown), so presentation feedback and | ||
| 673 | // frame callbacks follow headless frames. | 673 | // frame callbacks follow headless frames. | ||
| 674 | - | func flipInfo(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) *ports.FlipInfo { | 674 | + | func flipInfo(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, kept func(ports.WindowID) bool) *ports.FlipInfo { |
| 675 | var ts unix.Timespec | 675 | var ts unix.Timespec | ||
| 676 | _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts) | 676 | _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts) | ||
| 677 | shows := map[ports.WindowID]uint64{} | 677 | shows := map[ports.WindowID]uint64{} | ||
| 678 | for id, c := range surfaces { | 678 | for id, c := range surfaces { | ||
| 679 | - | if scene.Shows(id) { | 679 | + | // A closed window drawn from its kept content is not shown to |
| 680 | + | // its client. | |||
| 681 | + | if scene.Shows(id) && !kept(id) { | |||
| 680 | shows[id] = c.Seq | 682 | shows[id] = c.Seq | ||
| 681 | } | 683 | } | ||
| 682 | } | 684 | } | ||
M internal/adapters/surfaces/surfaces.go +28 -7
| @@ -20,12 +20,21 @@ import ( | |||
| 20 | 20 | // content waited on it, and wayland closes it once the buffer is released. | |
| 21 | 21 | // | |
| 22 | 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). | |
| 23 | + | // output reports the empty content as seen, so the client may draw into, | |
| 24 | + | // destroy or reuse it during the fade. A kept content is therefore only | |
| 25 | + | // ever composed by the renderer, from what it already holds: the dmabuf | |
| 26 | + | // import (its own duplicated descriptors keep the memory alive; a reused | |
| 27 | + | // buffer shows stale pixels at worst) or the GPU copy of the wl_shm buffer | |
| 28 | + | // (the GPU never reads the pool mapping, and a pool's fd close does not | |
| 29 | + | // unmap it). A kept content is never scanned out on a plane and never | |
| 30 | + | // reported as shown: outputs ask Kept. Window capture never reaches it: | |
| 31 | + | // core resolves a window session through its workspace, which the closed | |
| 32 | + | // window has left, so the session ends (CaptureReasonWindowGone) before | |
| 33 | + | // the leaving scene; an output or workspace capture composes the fading | |
| 34 | + | // window like the screen does. | |
| 35 | + | // Every Acquire fence of the kept content (root and children) is dropped: | |
| 36 | + | // the frame that first drew it waited on them, and wayland closes them | |
| 37 | + | // when the buffers are released. | |
| 29 | 38 | type Table struct { | |
| 30 | 39 | m map[ports.WindowID]ports.SurfaceContent | |
| 31 | 40 | // kept are the windows drawn from a content their client withdrew. | |
| @@ -57,6 +66,16 @@ func (t *Table) Update(c ports.SurfaceContent, scene ports.Scene) { | |||
| 57 | 66 | return | |
| 58 | 67 | } | |
| 59 | 68 | prev.Acquire = nil | |
| 69 | + | if len(prev.Children) > 0 { | |
| 70 | + | // The slice is shared with wayland's publication: copy before | |
| 71 | + | // zeroing (one allocation per unmap). | |
| 72 | + | children := make([]ports.Subsurface, len(prev.Children)) | |
| 73 | + | copy(children, prev.Children) | |
| 74 | + | for i := range children { | |
| 75 | + | children[i].Acquire = nil | |
| 76 | + | } | |
| 77 | + | prev.Children = children | |
| 78 | + | } | |
| 60 | 79 | t.m[c.ID] = prev | |
| 61 | 80 | t.kept[c.ID] = struct{}{} | |
| 62 | 81 | } | |
| @@ -72,7 +91,9 @@ func (t *Table) Prune(scene ports.Scene) { | |||
| 72 | 91 | } | |
| 73 | 92 | } | |
| 74 | 93 | ||
| 75 | - | // Kept reports whether id is drawn from a content its client withdrew. | |
| 94 | + | // Kept reports whether id is drawn from a content its client withdrew: a | |
| 95 | + | // buffer the client may have got back. Outputs compose it only: no plane | |
| 96 | + | // scans it out, no capture or presentation report counts it as shown. | |
| 76 | 97 | func (t *Table) Kept(id ports.WindowID) bool { | |
| 77 | 98 | _, ok := t.kept[id] | |
| 78 | 99 | return ok | |
| @@ -20,12 +20,21 @@ import ( | |||||
| 20 | // content waited on it, and wayland closes it once the buffer is released. | 20 | // content waited on it, and wayland closes it once the buffer is released. | ||
| 21 | // | 21 | // | ||
| 22 | // Buffer lifetime: wayland releases the buffer to the client once the | 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 | 23 | + | // output reports the empty content as seen, so the client may draw into, |
| 24 | - | // destroy it during the fade. A dmabuf stays importable through the | 24 | + | // destroy or reuse it during the fade. A kept content is therefore only |
| 25 | - | // renderer's own duplicated descriptors (a destroyed one shows as stale | 25 | + | // ever composed by the renderer, from what it already holds: the dmabuf |
| 26 | - | // pixels at worst); a wl_shm pool stays mapped by the renderer while it is | 26 | + | // import (its own duplicated descriptors keep the memory alive; a reused |
| 27 | - | // drawn, so a destroyed pool is still readable (a client that truncates the | 27 | + | // buffer shows stale pixels at worst) or the GPU copy of the wl_shm buffer |
| 28 | - | // file under a mapping has always been able to fault a reader). | 28 | + | // (the GPU never reads the pool mapping, and a pool's fd close does not |
| 29 | + | // unmap it). A kept content is never scanned out on a plane and never | |||
| 30 | + | // reported as shown: outputs ask Kept. Window capture never reaches it: | |||
| 31 | + | // core resolves a window session through its workspace, which the closed | |||
| 32 | + | // window has left, so the session ends (CaptureReasonWindowGone) before | |||
| 33 | + | // the leaving scene; an output or workspace capture composes the fading | |||
| 34 | + | // window like the screen does. | |||
| 35 | + | // Every Acquire fence of the kept content (root and children) is dropped: | |||
| 36 | + | // the frame that first drew it waited on them, and wayland closes them | |||
| 37 | + | // when the buffers are released. | |||
| 29 | type Table struct { | 38 | type Table struct { | ||
| 30 | m map[ports.WindowID]ports.SurfaceContent | 39 | m map[ports.WindowID]ports.SurfaceContent | ||
| 31 | // kept are the windows drawn from a content their client withdrew. | 40 | // kept are the windows drawn from a content their client withdrew. | ||
| @@ -57,6 +66,16 @@ func (t *Table) Update(c ports.SurfaceContent, scene ports.Scene) { | |||||
| 57 | return | 66 | return | ||
| 58 | } | 67 | } | ||
| 59 | prev.Acquire = nil | 68 | prev.Acquire = nil | ||
| 69 | + | if len(prev.Children) > 0 { | |||
| 70 | + | // The slice is shared with wayland's publication: copy before | |||
| 71 | + | // zeroing (one allocation per unmap). | |||
| 72 | + | children := make([]ports.Subsurface, len(prev.Children)) | |||
| 73 | + | copy(children, prev.Children) | |||
| 74 | + | for i := range children { | |||
| 75 | + | children[i].Acquire = nil | |||
| 76 | + | } | |||
| 77 | + | prev.Children = children | |||
| 78 | + | } | |||
| 60 | t.m[c.ID] = prev | 79 | t.m[c.ID] = prev | ||
| 61 | t.kept[c.ID] = struct{}{} | 80 | t.kept[c.ID] = struct{}{} | ||
| 62 | } | 81 | } | ||
| @@ -72,7 +91,9 @@ func (t *Table) Prune(scene ports.Scene) { | |||||
| 72 | } | 91 | } | ||
| 73 | } | 92 | } | ||
| 74 | 93 | ||||
| 75 | - | // Kept reports whether id is drawn from a content its client withdrew. | 94 | + | // Kept reports whether id is drawn from a content its client withdrew: a |
| 95 | + | // buffer the client may have got back. Outputs compose it only: no plane | |||
| 96 | + | // scans it out, no capture or presentation report counts it as shown. | |||
| 76 | func (t *Table) Kept(id ports.WindowID) bool { | 97 | func (t *Table) Kept(id ports.WindowID) bool { | ||
| 77 | _, ok := t.kept[id] | 98 | _, ok := t.kept[id] | ||
| 78 | return ok | 99 | return ok | ||
M internal/adapters/surfaces/surfaces_test.go +23 -0
| @@ -99,3 +99,26 @@ func TestUpdateAllocations(t *testing.T) { | |||
| 99 | 99 | t.Fatalf("%v allocations per update", n) | |
| 100 | 100 | } | |
| 101 | 101 | } | |
| 102 | + | ||
| 103 | + | // The kept content's children lose their Acquire fences too, on a copy: | |
| 104 | + | // wayland's own slice (shared with its publication) is left alone. | |
| 105 | + | func TestKeptChildrenFencesDroppedOnACopy(t *testing.T) { | |
| 106 | + | tb := New() | |
| 107 | + | r, w, err := os.Pipe() | |
| 108 | + | if err != nil { | |
| 109 | + | t.Fatal(err) | |
| 110 | + | } | |
| 111 | + | defer r.Close() | |
| 112 | + | defer w.Close() | |
| 113 | + | children := []ports.Subsurface{{SurfaceContent: ports.SurfaceContent{DMABuf: &ports.DMABuf{ID: 2}, Acquire: r}}} | |
| 114 | + | full := ports.SurfaceContent{ID: 1, Seq: 1, SHM: &ports.SHMBuffer{Pool: 1}, Children: children} | |
| 115 | + | tb.Update(full, shows(1)) | |
| 116 | + | tb.Update(ports.SurfaceContent{ID: 1, Seq: 2}, shows(1)) | |
| 117 | + | kept := tb.Map()[1] | |
| 118 | + | if len(kept.Children) != 1 || kept.Children[0].Acquire != nil || kept.Children[0].DMABuf != children[0].DMABuf { | |
| 119 | + | t.Fatalf("kept children %+v", kept.Children) | |
| 120 | + | } | |
| 121 | + | if children[0].Acquire != r { | |
| 122 | + | t.Fatal("wayland's slice was modified") | |
| 123 | + | } | |
| 124 | + | } | |
| @@ -99,3 +99,26 @@ func TestUpdateAllocations(t *testing.T) { | |||||
| 99 | t.Fatalf("%v allocations per update", n) | 99 | t.Fatalf("%v allocations per update", n) | ||
| 100 | } | 100 | } | ||
| 101 | } | 101 | } | ||
| 102 | + | ||||
| 103 | + | // The kept content's children lose their Acquire fences too, on a copy: | |||
| 104 | + | // wayland's own slice (shared with its publication) is left alone. | |||
| 105 | + | func TestKeptChildrenFencesDroppedOnACopy(t *testing.T) { | |||
| 106 | + | tb := New() | |||
| 107 | + | r, w, err := os.Pipe() | |||
| 108 | + | if err != nil { | |||
| 109 | + | t.Fatal(err) | |||
| 110 | + | } | |||
| 111 | + | defer r.Close() | |||
| 112 | + | defer w.Close() | |||
| 113 | + | children := []ports.Subsurface{{SurfaceContent: ports.SurfaceContent{DMABuf: &ports.DMABuf{ID: 2}, Acquire: r}}} | |||
| 114 | + | full := ports.SurfaceContent{ID: 1, Seq: 1, SHM: &ports.SHMBuffer{Pool: 1}, Children: children} | |||
| 115 | + | tb.Update(full, shows(1)) | |||
| 116 | + | tb.Update(ports.SurfaceContent{ID: 1, Seq: 2}, shows(1)) | |||
| 117 | + | kept := tb.Map()[1] | |||
| 118 | + | if len(kept.Children) != 1 || kept.Children[0].Acquire != nil || kept.Children[0].DMABuf != children[0].DMABuf { | |||
| 119 | + | t.Fatalf("kept children %+v", kept.Children) | |||
| 120 | + | } | |||
| 121 | + | if children[0].Acquire != r { | |||
| 122 | + | t.Fatal("wayland's slice was modified") | |||
| 123 | + | } | |||
| 124 | + | } | |||
M internal/core/configures.go +11 -1
| @@ -131,9 +131,19 @@ func (s *configures) mark(v ports.ConfigureWindow) { | |||
| 131 | 131 | } | |
| 132 | 132 | ||
| 133 | 133 | // keep marks id as seen without a configure: a window drawn while it leaves | |
| 134 | - | // keeps what was sent to it. | |
| 134 | + | // keeps what was sent to it. Only for a window the layout still holds (a | |
| 135 | + | // hidden stash window): a destroyed one is forgotten (forget). | |
| 135 | 136 | func (s *configures) keep(id WindowID) { s.seen[id] = true } | |
| 136 | 137 | ||
| 138 | + | // forget drops what was sent to id: the window is destroyed. xdg reuses | |
| 139 | + | // window IDs, so a remapped one must start from nothing, or an unchanged | |
| 140 | + | // first configure would be withheld. | |
| 141 | + | func (s *configures) forget(id WindowID) { | |
| 142 | + | delete(s.sent, id) | |
| 143 | + | delete(s.answer, id) | |
| 144 | + | delete(s.seen, id) | |
| 145 | + | } | |
| 146 | + | ||
| 137 | 147 | // prune forgets the windows no next call saw since the last prune: they | |
| 138 | 148 | // left the layout. | |
| 139 | 149 | func (s *configures) prune() { | |
| @@ -131,9 +131,19 @@ func (s *configures) mark(v ports.ConfigureWindow) { | |||||
| 131 | } | 131 | } | ||
| 132 | 132 | ||||
| 133 | // keep marks id as seen without a configure: a window drawn while it leaves | 133 | // keep marks id as seen without a configure: a window drawn while it leaves | ||
| 134 | - | // keeps what was sent to it. | 134 | + | // keeps what was sent to it. Only for a window the layout still holds (a |
| 135 | + | // hidden stash window): a destroyed one is forgotten (forget). | |||
| 135 | func (s *configures) keep(id WindowID) { s.seen[id] = true } | 136 | func (s *configures) keep(id WindowID) { s.seen[id] = true } | ||
| 136 | 137 | ||||
| 138 | + | // forget drops what was sent to id: the window is destroyed. xdg reuses | |||
| 139 | + | // window IDs, so a remapped one must start from nothing, or an unchanged | |||
| 140 | + | // first configure would be withheld. | |||
| 141 | + | func (s *configures) forget(id WindowID) { | |||
| 142 | + | delete(s.sent, id) | |||
| 143 | + | delete(s.answer, id) | |||
| 144 | + | delete(s.seen, id) | |||
| 145 | + | } | |||
| 146 | + | ||||
| 137 | // prune forgets the windows no next call saw since the last prune: they | 147 | // prune forgets the windows no next call saw since the last prune: they | ||
| 138 | // left the layout. | 148 | // left the layout. | ||
| 139 | func (s *configures) prune() { | 149 | func (s *configures) prune() { | ||
M internal/core/transition.go +3 -0
| @@ -434,6 +434,9 @@ func (c *Core) appearMapped(id WindowID, now time.Time) { | |||
| 434 | 434 | // is the same with animations off. A fullscreen window, one under a | |
| 435 | 435 | // session lock or with the overview open just goes. | |
| 436 | 436 | func (c *Core) unmapWindow(v ports.WindowUnmapped) { | |
| 437 | + | // The window is destroyed: its ID may come back for another one, which | |
| 438 | + | // must be configured from nothing (a leaving entry keeps no configure). | |
| 439 | + | c.configures.forget(v.ID) | |
| 437 | 440 | sc, w := c.screenOf(v.ID) | |
| 438 | 441 | if sc == nil { | |
| 439 | 442 | return | |
| @@ -434,6 +434,9 @@ func (c *Core) appearMapped(id WindowID, now time.Time) { | |||||
| 434 | // is the same with animations off. A fullscreen window, one under a | 434 | // is the same with animations off. A fullscreen window, one under a | ||
| 435 | // session lock or with the overview open just goes. | 435 | // session lock or with the overview open just goes. | ||
| 436 | func (c *Core) unmapWindow(v ports.WindowUnmapped) { | 436 | func (c *Core) unmapWindow(v ports.WindowUnmapped) { | ||
| 437 | + | // The window is destroyed: its ID may come back for another one, which | |||
| 438 | + | // must be configured from nothing (a leaving entry keeps no configure). | |||
| 439 | + | c.configures.forget(v.ID) | |||
| 437 | sc, w := c.screenOf(v.ID) | 440 | sc, w := c.screenOf(v.ID) | ||
| 438 | if sc == nil { | 441 | if sc == nil { | ||
| 439 | return | 442 | return | ||
M internal/core/transition_unmap_internal_test.go +116 -9
| @@ -1,11 +1,13 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "math" | |
| 5 | 6 | "slices" | |
| 6 | 7 | "testing" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| 10 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 9 | 11 | "github.com/bnema/neferwl/internal/ports" | |
| 10 | 12 | ) | |
| 11 | 13 | ||
| @@ -282,17 +284,122 @@ func TestUnmapExceptions(t *testing.T) { | |||
| 282 | 284 | }) | |
| 283 | 285 | } | |
| 284 | 286 | ||
| 285 | - | // A session lock during the fade drops the leaving entry with the other | |
| 286 | - | // motions (stopRects), as do animations turned off. | |
| 287 | - | func TestUnmapLeavingDroppedByLock(t *testing.T) { | |
| 288 | - | c, _, _, sc := twoSettled(t) | |
| 287 | + | // An interruption during the fade drops the leaving entry with the other | |
| 288 | + | // motions (stopRects): a real session lock, an output unplug, an overview | |
| 289 | + | // toggle and a workspace switch. Nothing of the closed window is left. | |
| 290 | + | func TestUnmapLeavingDroppedByInterruptions(t *testing.T) { | |
| 291 | + | leaving := func(t *testing.T) (*Core, *indicatorClock, *screen) { | |
| 292 | + | t.Helper() | |
| 293 | + | c, ic, _, sc := twoSettled(t) | |
| 294 | + | c.unmapWindow(ports.WindowUnmapped{ID: 2}) | |
| 295 | + | if s := indicatorScene(t, c); !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) { | |
| 296 | + | t.Fatal("no leaving entry") | |
| 297 | + | } | |
| 298 | + | return c, ic, sc | |
| 299 | + | } | |
| 300 | + | gone := func(t *testing.T, c *Core, sc *screen, s ports.Scene) { | |
| 301 | + | t.Helper() | |
| 302 | + | if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) { | |
| 303 | + | t.Fatalf("the leaving entry survived: %+v", s.Windows) | |
| 304 | + | } | |
| 305 | + | // Other motions may run (the overview's cards): none for 2. | |
| 306 | + | if _, ok := sc.rects[2]; ok || slices.ContainsFunc(sc.shown, func(p Placement) bool { return p.Leaving }) { | |
| 307 | + | t.Fatalf("rects %+v shown %+v", sc.rects, sc.shown) | |
| 308 | + | } | |
| 309 | + | } | |
| 310 | + | t.Run("session lock", func(t *testing.T) { | |
| 311 | + | c, _, sc := leaving(t) | |
| 312 | + | state := ports.SecurityState{Generation: 1, Protected: true} | |
| 313 | + | gate := portsmocks.NewMockSessionSecurity(t) | |
| 314 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }) | |
| 315 | + | c.ch.Security = gate | |
| 316 | + | if err := c.publish(context.Background()); err != nil { | |
| 317 | + | t.Fatal(err) | |
| 318 | + | } | |
| 319 | + | locked := (<-c.ch.Scenes)[0] | |
| 320 | + | if len(locked.Windows) != 0 || len(sc.rects) != 0 || c.animating() { | |
| 321 | + | t.Fatalf("locked scene %+v rects %d animating %t", locked.Windows, len(sc.rects), c.animating()) | |
| 322 | + | } | |
| 323 | + | state = ports.SecurityState{Generation: 2} | |
| 324 | + | gone(t, c, sc, indicatorScene(t, c)) | |
| 325 | + | }) | |
| 326 | + | t.Run("unplug", func(t *testing.T) { | |
| 327 | + | c, _, sc := leaving(t) | |
| 328 | + | c.removeScreen("A") | |
| 329 | + | if c.animating() || len(sc.rects) != 0 { | |
| 330 | + | t.Fatalf("motions survived the unplug: %d", len(sc.rects)) | |
| 331 | + | } | |
| 332 | + | c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200}) | |
| 333 | + | gone(t, c, c.cur(), indicatorScene(t, c)) | |
| 334 | + | }) | |
| 335 | + | t.Run("overview toggle", func(t *testing.T) { | |
| 336 | + | c, ic, sc := leaving(t) | |
| 337 | + | act(c, ic, ActionToggleOverview) | |
| 338 | + | gone(t, c, sc, indicatorScene(t, c)) | |
| 339 | + | }) | |
| 340 | + | t.Run("workspace switch", func(t *testing.T) { | |
| 341 | + | c, ic, sc := leaving(t) | |
| 342 | + | act(c, ic, ActionFocusWorkspaceDown) | |
| 343 | + | gone(t, c, sc, indicatorScene(t, c)) | |
| 344 | + | }) | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | // A window ID reused by a new toplevel while the old one fades out (xdg | |
| 348 | + | // reuses IDs): the leaving entry goes, the new window appears from where the | |
| 349 | + | // old one was drawn (appearFrom), is listed once, and gets its tile | |
| 350 | + | // configure (the destroyed window's last configure is forgotten). | |
| 351 | + | func TestUnmapThenRemapDuringFade(t *testing.T) { | |
| 352 | + | c, ic, cmds, sc := twoSettled(t) | |
| 289 | 353 | c.unmapWindow(ports.WindowUnmapped{ID: 2}) | |
| 290 | - | if s := indicatorScene(t, c); !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) { | |
| 291 | - | t.Fatal("no leaving entry") | |
| 354 | + | indicatorScene(t, c) | |
| 355 | + | ic.now = ic.now.Add(40 * time.Millisecond) | |
| 356 | + | c.animate(ic.now, nil) | |
| 357 | + | mid := sceneWindow(t, indicatorScene(t, c), 2) | |
| 358 | + | if !(mid.Fade > 0 && mid.Fade < 1) { | |
| 359 | + | t.Fatalf("not fading: %+v", mid) | |
| 360 | + | } | |
| 361 | + | drainConfigures(cmds) | |
| 362 | + | if _, ok := c.configures.sent[2]; ok { | |
| 363 | + | t.Fatal("the destroyed window's configure is remembered") | |
| 292 | 364 | } | |
| 293 | - | c.stopAnimations() | |
| 365 | + | ||
| 366 | + | c.mapWindow(ports.WindowMapped{ID: 2}) | |
| 294 | 367 | s := indicatorScene(t, c) | |
| 295 | - | if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) || len(sc.rects) != 0 || len(sc.shown) != 1 { | |
| 296 | - | t.Fatalf("after the stop: %+v", s.Windows) | |
| 368 | + | n := 0 | |
| 369 | + | for _, w := range s.Windows { | |
| 370 | + | if w.ID == 2 { | |
| 371 | + | n++ | |
| 372 | + | } | |
| 373 | + | } | |
| 374 | + | if n != 1 { | |
| 375 | + | t.Fatalf("window 2 listed %d times: %+v", n, s.Windows) | |
| 376 | + | } | |
| 377 | + | if slices.ContainsFunc(sc.shown, func(p Placement) bool { return p.Leaving }) || slices.ContainsFunc(sc.settledLayout, func(p Placement) bool { return p.Leaving }) { | |
| 378 | + | t.Fatalf("leaving entry left: %+v", sc.shown) | |
| 379 | + | } | |
| 380 | + | rm, ok := sc.rects[2] | |
| 381 | + | if !ok || rm.leaving { | |
| 382 | + | t.Fatalf("no entrance for the remapped window: %+v", rm) | |
| 383 | + | } | |
| 384 | + | w := sceneWindow(t, s, 2) | |
| 385 | + | // The entrance chains over the exit: drawn as the fade left it, not | |
| 386 | + | // invisible at 90 %. | |
| 387 | + | if w.Hidden || math.Abs(w.Fade-mid.Fade) > 1e-9 || w.Rect != mid.Rect { | |
| 388 | + | t.Fatalf("remapped window right after the map: %+v, want as drawn before %+v", w, mid) | |
| 389 | + | } | |
| 390 | + | settled := settledOf(t, sc, 2) | |
| 391 | + | var got []ports.ConfigureWindow | |
| 392 | + | for len(cmds) > 0 { | |
| 393 | + | if v, ok := (<-cmds).(ports.ConfigureWindow); ok && v.ID == 2 { | |
| 394 | + | got = append(got, v) | |
| 395 | + | } | |
| 396 | + | } | |
| 397 | + | if len(got) != 1 || got[0].Width != settled.Rect.W || !got[0].Visible { | |
| 398 | + | t.Fatalf("configures of the remapped window: %+v, want one to %+v", got, settled.Rect) | |
| 399 | + | } | |
| 400 | + | ic.now = ic.now.Add(5 * time.Second) | |
| 401 | + | c.animate(ic.now, nil) | |
| 402 | + | if w := sceneWindow(t, indicatorScene(t, c), 2); w.Fade != 0 || w.Rect != settled.Rect || len(sc.rects) != 0 { | |
| 403 | + | t.Fatalf("remapped window settled: %+v", w) | |
| 297 | 404 | } | |
| 298 | 405 | } | |
| @@ -1,11 +1,13 @@ | |||||
| 1 | package core | 1 | package core | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "math" | 5 | "math" | ||
| 5 | "slices" | 6 | "slices" | ||
| 6 | "testing" | 7 | "testing" | ||
| 7 | "time" | 8 | "time" | ||
| 8 | 9 | ||||
| 10 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 9 | "github.com/bnema/neferwl/internal/ports" | 11 | "github.com/bnema/neferwl/internal/ports" | ||
| 10 | ) | 12 | ) | ||
| 11 | 13 | ||||
| @@ -282,17 +284,122 @@ func TestUnmapExceptions(t *testing.T) { | |||||
| 282 | }) | 284 | }) | ||
| 283 | } | 285 | } | ||
| 284 | 286 | ||||
| 285 | - | // A session lock during the fade drops the leaving entry with the other | 287 | + | // An interruption during the fade drops the leaving entry with the other |
| 286 | - | // motions (stopRects), as do animations turned off. | 288 | + | // motions (stopRects): a real session lock, an output unplug, an overview |
| 287 | - | func TestUnmapLeavingDroppedByLock(t *testing.T) { | 289 | + | // toggle and a workspace switch. Nothing of the closed window is left. |
| 288 | - | c, _, _, sc := twoSettled(t) | 290 | + | func TestUnmapLeavingDroppedByInterruptions(t *testing.T) { |
| 291 | + | leaving := func(t *testing.T) (*Core, *indicatorClock, *screen) { | |||
| 292 | + | t.Helper() | |||
| 293 | + | c, ic, _, sc := twoSettled(t) | |||
| 294 | + | c.unmapWindow(ports.WindowUnmapped{ID: 2}) | |||
| 295 | + | if s := indicatorScene(t, c); !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) { | |||
| 296 | + | t.Fatal("no leaving entry") | |||
| 297 | + | } | |||
| 298 | + | return c, ic, sc | |||
| 299 | + | } | |||
| 300 | + | gone := func(t *testing.T, c *Core, sc *screen, s ports.Scene) { | |||
| 301 | + | t.Helper() | |||
| 302 | + | if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) { | |||
| 303 | + | t.Fatalf("the leaving entry survived: %+v", s.Windows) | |||
| 304 | + | } | |||
| 305 | + | // Other motions may run (the overview's cards): none for 2. | |||
| 306 | + | if _, ok := sc.rects[2]; ok || slices.ContainsFunc(sc.shown, func(p Placement) bool { return p.Leaving }) { | |||
| 307 | + | t.Fatalf("rects %+v shown %+v", sc.rects, sc.shown) | |||
| 308 | + | } | |||
| 309 | + | } | |||
| 310 | + | t.Run("session lock", func(t *testing.T) { | |||
| 311 | + | c, _, sc := leaving(t) | |||
| 312 | + | state := ports.SecurityState{Generation: 1, Protected: true} | |||
| 313 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 314 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }) | |||
| 315 | + | c.ch.Security = gate | |||
| 316 | + | if err := c.publish(context.Background()); err != nil { | |||
| 317 | + | t.Fatal(err) | |||
| 318 | + | } | |||
| 319 | + | locked := (<-c.ch.Scenes)[0] | |||
| 320 | + | if len(locked.Windows) != 0 || len(sc.rects) != 0 || c.animating() { | |||
| 321 | + | t.Fatalf("locked scene %+v rects %d animating %t", locked.Windows, len(sc.rects), c.animating()) | |||
| 322 | + | } | |||
| 323 | + | state = ports.SecurityState{Generation: 2} | |||
| 324 | + | gone(t, c, sc, indicatorScene(t, c)) | |||
| 325 | + | }) | |||
| 326 | + | t.Run("unplug", func(t *testing.T) { | |||
| 327 | + | c, _, sc := leaving(t) | |||
| 328 | + | c.removeScreen("A") | |||
| 329 | + | if c.animating() || len(sc.rects) != 0 { | |||
| 330 | + | t.Fatalf("motions survived the unplug: %d", len(sc.rects)) | |||
| 331 | + | } | |||
| 332 | + | c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200}) | |||
| 333 | + | gone(t, c, c.cur(), indicatorScene(t, c)) | |||
| 334 | + | }) | |||
| 335 | + | t.Run("overview toggle", func(t *testing.T) { | |||
| 336 | + | c, ic, sc := leaving(t) | |||
| 337 | + | act(c, ic, ActionToggleOverview) | |||
| 338 | + | gone(t, c, sc, indicatorScene(t, c)) | |||
| 339 | + | }) | |||
| 340 | + | t.Run("workspace switch", func(t *testing.T) { | |||
| 341 | + | c, ic, sc := leaving(t) | |||
| 342 | + | act(c, ic, ActionFocusWorkspaceDown) | |||
| 343 | + | gone(t, c, sc, indicatorScene(t, c)) | |||
| 344 | + | }) | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | // A window ID reused by a new toplevel while the old one fades out (xdg | |||
| 348 | + | // reuses IDs): the leaving entry goes, the new window appears from where the | |||
| 349 | + | // old one was drawn (appearFrom), is listed once, and gets its tile | |||
| 350 | + | // configure (the destroyed window's last configure is forgotten). | |||
| 351 | + | func TestUnmapThenRemapDuringFade(t *testing.T) { | |||
| 352 | + | c, ic, cmds, sc := twoSettled(t) | |||
| 289 | c.unmapWindow(ports.WindowUnmapped{ID: 2}) | 353 | c.unmapWindow(ports.WindowUnmapped{ID: 2}) | ||
| 290 | - | if s := indicatorScene(t, c); !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) { | 354 | + | indicatorScene(t, c) |
| 291 | - | t.Fatal("no leaving entry") | 355 | + | ic.now = ic.now.Add(40 * time.Millisecond) |
| 356 | + | c.animate(ic.now, nil) | |||
| 357 | + | mid := sceneWindow(t, indicatorScene(t, c), 2) | |||
| 358 | + | if !(mid.Fade > 0 && mid.Fade < 1) { | |||
| 359 | + | t.Fatalf("not fading: %+v", mid) | |||
| 360 | + | } | |||
| 361 | + | drainConfigures(cmds) | |||
| 362 | + | if _, ok := c.configures.sent[2]; ok { | |||
| 363 | + | t.Fatal("the destroyed window's configure is remembered") | |||
| 292 | } | 364 | } | ||
| 293 | - | c.stopAnimations() | 365 | + | |
| 366 | + | c.mapWindow(ports.WindowMapped{ID: 2}) | |||
| 294 | s := indicatorScene(t, c) | 367 | s := indicatorScene(t, c) | ||
| 295 | - | if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) || len(sc.rects) != 0 || len(sc.shown) != 1 { | 368 | + | n := 0 |
| 296 | - | t.Fatalf("after the stop: %+v", s.Windows) | 369 | + | for _, w := range s.Windows { |
| 370 | + | if w.ID == 2 { | |||
| 371 | + | n++ | |||
| 372 | + | } | |||
| 373 | + | } | |||
| 374 | + | if n != 1 { | |||
| 375 | + | t.Fatalf("window 2 listed %d times: %+v", n, s.Windows) | |||
| 376 | + | } | |||
| 377 | + | if slices.ContainsFunc(sc.shown, func(p Placement) bool { return p.Leaving }) || slices.ContainsFunc(sc.settledLayout, func(p Placement) bool { return p.Leaving }) { | |||
| 378 | + | t.Fatalf("leaving entry left: %+v", sc.shown) | |||
| 379 | + | } | |||
| 380 | + | rm, ok := sc.rects[2] | |||
| 381 | + | if !ok || rm.leaving { | |||
| 382 | + | t.Fatalf("no entrance for the remapped window: %+v", rm) | |||
| 383 | + | } | |||
| 384 | + | w := sceneWindow(t, s, 2) | |||
| 385 | + | // The entrance chains over the exit: drawn as the fade left it, not | |||
| 386 | + | // invisible at 90 %. | |||
| 387 | + | if w.Hidden || math.Abs(w.Fade-mid.Fade) > 1e-9 || w.Rect != mid.Rect { | |||
| 388 | + | t.Fatalf("remapped window right after the map: %+v, want as drawn before %+v", w, mid) | |||
| 389 | + | } | |||
| 390 | + | settled := settledOf(t, sc, 2) | |||
| 391 | + | var got []ports.ConfigureWindow | |||
| 392 | + | for len(cmds) > 0 { | |||
| 393 | + | if v, ok := (<-cmds).(ports.ConfigureWindow); ok && v.ID == 2 { | |||
| 394 | + | got = append(got, v) | |||
| 395 | + | } | |||
| 396 | + | } | |||
| 397 | + | if len(got) != 1 || got[0].Width != settled.Rect.W || !got[0].Visible { | |||
| 398 | + | t.Fatalf("configures of the remapped window: %+v, want one to %+v", got, settled.Rect) | |||
| 399 | + | } | |||
| 400 | + | ic.now = ic.now.Add(5 * time.Second) | |||
| 401 | + | c.animate(ic.now, nil) | |||
| 402 | + | if w := sceneWindow(t, indicatorScene(t, c), 2); w.Fade != 0 || w.Rect != settled.Rect || len(sc.rects) != 0 { | |||
| 403 | + | t.Fatalf("remapped window settled: %+v", w) | |||
| 297 | } | 404 | } | ||
| 298 | } | 405 | } | ||