feat(core): animate a window's open and re-flow its neighbours
A mapped tiled or floating toplevel that shows on the current workspace fades in and grows from 90 % of its settled rect; the tiles its column displaces slide from where they were. The map handling is wrapped with snapshot and transition, the client is configured once, to the final size. Fullscreen, below floats, stash peeks, a session lock, the overview and animations off stay instant. Tests that read the scene right after a map get an animations-off variant and an on variant that settles.
- commit
- 5a2f88ef3ae9da9006345a99127e3adc80e8de51
- author
- brice · · 2026-10-04 22:56 UTC
- committer
- brice · · 2026-10-04 23:05 UTC
- parent
- 937984f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +674 -92
M internal/app/app_test.go +46 -5
| @@ -101,7 +101,13 @@ func TestQuitJoinsWorkers(t *testing.T) { | |||
| 101 | 101 | } | |
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | - | func TestHeadlessSpawnClose(t *testing.T) { | |
| 104 | + | // Animations off: the lone window is at its final rect in the first scene. | |
| 105 | + | func TestHeadlessSpawnClose(t *testing.T) { headlessSpawnClose(t, false) } | |
| 106 | + | ||
| 107 | + | // Animations on: the window appears (faded, 90 %), then settles at 1920. | |
| 108 | + | func TestHeadlessSpawnCloseAnimated(t *testing.T) { headlessSpawnClose(t, true) } | |
| 109 | + | ||
| 110 | + | func headlessSpawnClose(t *testing.T, animated bool) { | |
| 105 | 111 | if _, err := exec.LookPath("weston-simple-shm"); err != nil { | |
| 106 | 112 | t.Skip("weston-simple-shm not installed") | |
| 107 | 113 | } | |
| @@ -109,7 +115,8 @@ func TestHeadlessSpawnClose(t *testing.T) { | |||
| 109 | 115 | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |
| 110 | 116 | cfg := config.Defaults() | |
| 111 | 117 | cfg.Terminal.Command = []string{"weston-simple-shm"} | |
| 112 | - | scenes := make(chan []ports.Scene, 64) | |
| 118 | + | cfg.Animations.On = animated | |
| 119 | + | scenes := make(chan []ports.Scene, 4096) | |
| 113 | 120 | ctx, cancel := context.WithCancel(context.Background()) | |
| 114 | 121 | defer cancel() | |
| 115 | 122 | inputReady := make(chan chan<- ports.InputEvent, 1) | |
| @@ -144,6 +151,12 @@ func TestHeadlessSpawnClose(t *testing.T) { | |||
| 144 | 151 | case set := <-scenes: | |
| 145 | 152 | s := set[0] | |
| 146 | 153 | if len(s.Windows) == count { | |
| 154 | + | if count == 1 && animated { | |
| 155 | + | // The entrance runs first: wait for the settled scene. | |
| 156 | + | if w := s.Windows[0]; w.Fade != 0 || w.Rect.W != 1920 { | |
| 157 | + | continue | |
| 158 | + | } | |
| 159 | + | } | |
| 147 | 160 | // A lone column fills the 1920 output (default zero gaps). | |
| 148 | 161 | if count == 1 && s.Windows[0].Rect.W != 1920 { | |
| 149 | 162 | t.Errorf("width = %d, want 1920", s.Windows[0].Rect.W) | |
| @@ -214,7 +227,13 @@ func TestHeadlessTyping(t *testing.T) { | |||
| 214 | 227 | } | |
| 215 | 228 | } | |
| 216 | 229 | ||
| 217 | - | func TestHeadlessPointerClickFocus(t *testing.T) { | |
| 230 | + | func TestHeadlessPointerClickFocus(t *testing.T) { headlessPointerClickFocus(t, false) } | |
| 231 | + | ||
| 232 | + | // Animations on: the maps' frames and the settled scenes are all tapped; the | |
| 233 | + | // click still lands on window 1 once the second column settled. | |
| 234 | + | func TestHeadlessPointerClickFocusAnimated(t *testing.T) { headlessPointerClickFocus(t, true) } | |
| 235 | + | ||
| 236 | + | func headlessPointerClickFocus(t *testing.T, animated bool) { | |
| 218 | 237 | if _, err := exec.LookPath("foot"); err != nil { | |
| 219 | 238 | t.Skip("foot unavailable") | |
| 220 | 239 | } | |
| @@ -225,7 +244,24 @@ func TestHeadlessPointerClickFocus(t *testing.T) { | |||
| 225 | 244 | cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"} | |
| 226 | 245 | // Pulse frames would fill the scene tap before the click. | |
| 227 | 246 | cfg.Focus.Animation = ports.FocusAnimationOff | |
| 247 | + | cfg.Animations.On = animated | |
| 248 | + | // The tap drops a scene it has no room for: a collector keeps up with | |
| 249 | + | // the animation frames. | |
| 228 | 250 | scenes := make(chan []ports.Scene, 128) | |
| 251 | + | var got [][]ports.Scene | |
| 252 | + | stop := make(chan struct{}) | |
| 253 | + | collected := make(chan struct{}) | |
| 254 | + | go func() { | |
| 255 | + | defer close(collected) | |
| 256 | + | for { | |
| 257 | + | select { | |
| 258 | + | case set := <-scenes: | |
| 259 | + | got = append(got, set) | |
| 260 | + | case <-stop: | |
| 261 | + | return | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | }() | |
| 229 | 265 | err := Run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: cfg, Script: io.NopCloser(strings.NewReader("sleep 1s\nkey Super+Return\nsleep 1s\nmove 600 300\nclick\nsleep 500ms\n")), Timeout: 5 * time.Second, testScenes: scenes}) | |
| 230 | 266 | if err != nil { | |
| 231 | 267 | if strings.Contains(strings.ToLower(err.Error()), "vulkan") { | |
| @@ -233,9 +269,14 @@ func TestHeadlessPointerClickFocus(t *testing.T) { | |||
| 233 | 269 | } | |
| 234 | 270 | t.Fatal(err) | |
| 235 | 271 | } | |
| 236 | - | two, focused := false, false | |
| 272 | + | close(stop) | |
| 273 | + | <-collected | |
| 237 | 274 | for len(scenes) > 0 { | |
| 238 | - | s := (<-scenes)[0] | |
| 275 | + | got = append(got, <-scenes) | |
| 276 | + | } | |
| 277 | + | two, focused := false, false | |
| 278 | + | for _, set := range got { | |
| 279 | + | s := set[0] | |
| 239 | 280 | if len(s.Windows) == 2 { | |
| 240 | 281 | two = true | |
| 241 | 282 | for _, w := range s.Windows { | |
| @@ -101,7 +101,13 @@ func TestQuitJoinsWorkers(t *testing.T) { | |||||
| 101 | } | 101 | } | ||
| 102 | } | 102 | } | ||
| 103 | 103 | ||||
| 104 | - | func TestHeadlessSpawnClose(t *testing.T) { | 104 | + | // Animations off: the lone window is at its final rect in the first scene. |
| 105 | + | func TestHeadlessSpawnClose(t *testing.T) { headlessSpawnClose(t, false) } | |||
| 106 | + | ||||
| 107 | + | // Animations on: the window appears (faded, 90 %), then settles at 1920. | |||
| 108 | + | func TestHeadlessSpawnCloseAnimated(t *testing.T) { headlessSpawnClose(t, true) } | |||
| 109 | + | ||||
| 110 | + | func headlessSpawnClose(t *testing.T, animated bool) { | |||
| 105 | if _, err := exec.LookPath("weston-simple-shm"); err != nil { | 111 | if _, err := exec.LookPath("weston-simple-shm"); err != nil { | ||
| 106 | t.Skip("weston-simple-shm not installed") | 112 | t.Skip("weston-simple-shm not installed") | ||
| 107 | } | 113 | } | ||
| @@ -109,7 +115,8 @@ func TestHeadlessSpawnClose(t *testing.T) { | |||||
| 109 | t.Setenv("XDG_STATE_HOME", t.TempDir()) | 115 | t.Setenv("XDG_STATE_HOME", t.TempDir()) | ||
| 110 | cfg := config.Defaults() | 116 | cfg := config.Defaults() | ||
| 111 | cfg.Terminal.Command = []string{"weston-simple-shm"} | 117 | cfg.Terminal.Command = []string{"weston-simple-shm"} | ||
| 112 | - | scenes := make(chan []ports.Scene, 64) | 118 | + | cfg.Animations.On = animated |
| 119 | + | scenes := make(chan []ports.Scene, 4096) | |||
| 113 | ctx, cancel := context.WithCancel(context.Background()) | 120 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 114 | defer cancel() | 121 | defer cancel() | ||
| 115 | inputReady := make(chan chan<- ports.InputEvent, 1) | 122 | inputReady := make(chan chan<- ports.InputEvent, 1) | ||
| @@ -144,6 +151,12 @@ func TestHeadlessSpawnClose(t *testing.T) { | |||||
| 144 | case set := <-scenes: | 151 | case set := <-scenes: | ||
| 145 | s := set[0] | 152 | s := set[0] | ||
| 146 | if len(s.Windows) == count { | 153 | if len(s.Windows) == count { | ||
| 154 | + | if count == 1 && animated { | |||
| 155 | + | // The entrance runs first: wait for the settled scene. | |||
| 156 | + | if w := s.Windows[0]; w.Fade != 0 || w.Rect.W != 1920 { | |||
| 157 | + | continue | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 147 | // A lone column fills the 1920 output (default zero gaps). | 160 | // A lone column fills the 1920 output (default zero gaps). | ||
| 148 | if count == 1 && s.Windows[0].Rect.W != 1920 { | 161 | if count == 1 && s.Windows[0].Rect.W != 1920 { | ||
| 149 | t.Errorf("width = %d, want 1920", s.Windows[0].Rect.W) | 162 | t.Errorf("width = %d, want 1920", s.Windows[0].Rect.W) | ||
| @@ -214,7 +227,13 @@ func TestHeadlessTyping(t *testing.T) { | |||||
| 214 | } | 227 | } | ||
| 215 | } | 228 | } | ||
| 216 | 229 | ||||
| 217 | - | func TestHeadlessPointerClickFocus(t *testing.T) { | 230 | + | func TestHeadlessPointerClickFocus(t *testing.T) { headlessPointerClickFocus(t, false) } |
| 231 | + | ||||
| 232 | + | // Animations on: the maps' frames and the settled scenes are all tapped; the | |||
| 233 | + | // click still lands on window 1 once the second column settled. | |||
| 234 | + | func TestHeadlessPointerClickFocusAnimated(t *testing.T) { headlessPointerClickFocus(t, true) } | |||
| 235 | + | ||||
| 236 | + | func headlessPointerClickFocus(t *testing.T, animated bool) { | |||
| 218 | if _, err := exec.LookPath("foot"); err != nil { | 237 | if _, err := exec.LookPath("foot"); err != nil { | ||
| 219 | t.Skip("foot unavailable") | 238 | t.Skip("foot unavailable") | ||
| 220 | } | 239 | } | ||
| @@ -225,7 +244,24 @@ func TestHeadlessPointerClickFocus(t *testing.T) { | |||||
| 225 | cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"} | 244 | cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"} | ||
| 226 | // Pulse frames would fill the scene tap before the click. | 245 | // Pulse frames would fill the scene tap before the click. | ||
| 227 | cfg.Focus.Animation = ports.FocusAnimationOff | 246 | cfg.Focus.Animation = ports.FocusAnimationOff | ||
| 247 | + | cfg.Animations.On = animated | |||
| 248 | + | // The tap drops a scene it has no room for: a collector keeps up with | |||
| 249 | + | // the animation frames. | |||
| 228 | scenes := make(chan []ports.Scene, 128) | 250 | scenes := make(chan []ports.Scene, 128) | ||
| 251 | + | var got [][]ports.Scene | |||
| 252 | + | stop := make(chan struct{}) | |||
| 253 | + | collected := make(chan struct{}) | |||
| 254 | + | go func() { | |||
| 255 | + | defer close(collected) | |||
| 256 | + | for { | |||
| 257 | + | select { | |||
| 258 | + | case set := <-scenes: | |||
| 259 | + | got = append(got, set) | |||
| 260 | + | case <-stop: | |||
| 261 | + | return | |||
| 262 | + | } | |||
| 263 | + | } | |||
| 264 | + | }() | |||
| 229 | err := Run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: cfg, Script: io.NopCloser(strings.NewReader("sleep 1s\nkey Super+Return\nsleep 1s\nmove 600 300\nclick\nsleep 500ms\n")), Timeout: 5 * time.Second, testScenes: scenes}) | 265 | err := Run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: cfg, Script: io.NopCloser(strings.NewReader("sleep 1s\nkey Super+Return\nsleep 1s\nmove 600 300\nclick\nsleep 500ms\n")), Timeout: 5 * time.Second, testScenes: scenes}) | ||
| 230 | if err != nil { | 266 | if err != nil { | ||
| 231 | if strings.Contains(strings.ToLower(err.Error()), "vulkan") { | 267 | if strings.Contains(strings.ToLower(err.Error()), "vulkan") { | ||
| @@ -233,9 +269,14 @@ func TestHeadlessPointerClickFocus(t *testing.T) { | |||||
| 233 | } | 269 | } | ||
| 234 | t.Fatal(err) | 270 | t.Fatal(err) | ||
| 235 | } | 271 | } | ||
| 236 | - | two, focused := false, false | 272 | + | close(stop) |
| 273 | + | <-collected | |||
| 237 | for len(scenes) > 0 { | 274 | for len(scenes) > 0 { | ||
| 238 | - | s := (<-scenes)[0] | 275 | + | got = append(got, <-scenes) |
| 276 | + | } | |||
| 277 | + | two, focused := false, false | |||
| 278 | + | for _, set := range got { | |||
| 279 | + | s := set[0] | |||
| 239 | if len(s.Windows) == 2 { | 280 | if len(s.Windows) == 2 { | ||
| 240 | two = true | 281 | two = true | ||
| 241 | for _, w := range s.Windows { | 282 | for _, w := range s.Windows { | ||
M internal/core/core.go +6 -2
| @@ -1105,8 +1105,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1105 | 1105 | case ports.InputRegionChanged: | |
| 1106 | 1106 | c.windows.setRegion(v) | |
| 1107 | 1107 | case ports.WindowMapped: | |
| 1108 | - | c.windows.mapped(v, c.now()) | |
| 1109 | - | c.placement.place(c, v) | |
| 1108 | + | c.mapWindow(v) | |
| 1110 | 1109 | case ports.WindowResized: | |
| 1111 | 1110 | if _, w := c.screenOf(v.ID); w != nil { | |
| 1112 | 1111 | w.ResizeFloating(v.ID, v.Width, v.Height) | |
| @@ -1186,6 +1185,11 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1186 | 1185 | } | |
| 1187 | 1186 | if s, _ := c.screenOf(v.ID); s != nil { | |
| 1188 | 1187 | s.mon.SetFullscreen(v.ID, v.Fullscreen) | |
| 1188 | + | if v.Fullscreen { | |
| 1189 | + | // A game asking for fullscreen right after its map | |
| 1190 | + | // does not fade in: it keeps the direct scanout path. | |
| 1191 | + | delete(s.rects, v.ID) | |
| 1192 | + | } | |
| 1189 | 1193 | } | |
| 1190 | 1194 | case ports.WorkspaceActivate: | |
| 1191 | 1195 | before := c.cur().mon.Current() | |
| @@ -1105,8 +1105,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1105 | case ports.InputRegionChanged: | 1105 | case ports.InputRegionChanged: | ||
| 1106 | c.windows.setRegion(v) | 1106 | c.windows.setRegion(v) | ||
| 1107 | case ports.WindowMapped: | 1107 | case ports.WindowMapped: | ||
| 1108 | - | c.windows.mapped(v, c.now()) | 1108 | + | c.mapWindow(v) |
| 1109 | - | c.placement.place(c, v) | |||
| 1110 | case ports.WindowResized: | 1109 | case ports.WindowResized: | ||
| 1111 | if _, w := c.screenOf(v.ID); w != nil { | 1110 | if _, w := c.screenOf(v.ID); w != nil { | ||
| 1112 | w.ResizeFloating(v.ID, v.Width, v.Height) | 1111 | w.ResizeFloating(v.ID, v.Width, v.Height) | ||
| @@ -1186,6 +1185,11 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1186 | } | 1185 | } | ||
| 1187 | if s, _ := c.screenOf(v.ID); s != nil { | 1186 | if s, _ := c.screenOf(v.ID); s != nil { | ||
| 1188 | s.mon.SetFullscreen(v.ID, v.Fullscreen) | 1187 | s.mon.SetFullscreen(v.ID, v.Fullscreen) | ||
| 1188 | + | if v.Fullscreen { | |||
| 1189 | + | // A game asking for fullscreen right after its map | |||
| 1190 | + | // does not fade in: it keeps the direct scanout path. | |||
| 1191 | + | delete(s.rects, v.ID) | |||
| 1192 | + | } | |||
| 1189 | } | 1193 | } | ||
| 1190 | case ports.WorkspaceActivate: | 1194 | case ports.WorkspaceActivate: | ||
| 1191 | before := c.cur().mon.Current() | 1195 | before := c.cur().mon.Current() | ||
M internal/core/core_test.go +110 -29
| @@ -5,13 +5,11 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "reflect" | |
| 7 | 7 | "slices" | |
| 8 | - | "sync/atomic" | |
| 9 | 8 | "testing" | |
| 10 | 9 | "time" | |
| 11 | 10 | ||
| 12 | 11 | "github.com/bnema/neferwl/internal/adapters/config" | |
| 13 | 12 | "github.com/bnema/neferwl/internal/core" | |
| 14 | - | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 15 | 13 | "github.com/bnema/neferwl/internal/ports" | |
| 16 | 14 | ) | |
| 17 | 15 | ||
| @@ -46,7 +44,56 @@ func command(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand { | |||
| 46 | 44 | } | |
| 47 | 45 | } | |
| 48 | 46 | ||
| 49 | - | func TestOwner(t *testing.T) { | |
| 47 | + | // modeLanding gives a hand-rolled core test its animation mode: off, or on with | |
| 48 | + | // a clock the test moves and the page flips it sends. land returns the scene | |
| 49 | + | // set once every spring has landed (the set itself when none runs, and | |
| 50 | + | // always with animations off). An on run that never saw a spring would be | |
| 51 | + | // the off run again, so it fails. | |
| 52 | + | type modeLanding struct { | |
| 53 | + | t *testing.T | |
| 54 | + | clk *stepClock | |
| 55 | + | frames chan ports.OutputFrame | |
| 56 | + | scenes chan []ports.Scene | |
| 57 | + | outputs []string | |
| 58 | + | lands int | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // newLanding sets cfg's animations and, when on, the clock and flips of ch. | |
| 62 | + | func newLanding(t *testing.T, animated bool, cfg *ports.Config, ch *core.Channels, outputs ...string) *modeLanding { | |
| 63 | + | t.Helper() | |
| 64 | + | cfg.Animations.On = animated | |
| 65 | + | l := &modeLanding{t: t, scenes: ch.Scenes, outputs: outputs} | |
| 66 | + | if animated { | |
| 67 | + | l.clk, l.frames = newStepClock(t), make(chan ports.OutputFrame) | |
| 68 | + | ch.Clock, ch.Frames = l.clk.clock, l.frames | |
| 69 | + | t.Cleanup(func() { | |
| 70 | + | if l.lands == 0 { | |
| 71 | + | t.Error("no spring ever ran: the animations-on variant checked nothing") | |
| 72 | + | } | |
| 73 | + | }) | |
| 74 | + | } | |
| 75 | + | return l | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func (l *modeLanding) land(set []ports.Scene) []ports.Scene { | |
| 79 | + | l.t.Helper() | |
| 80 | + | if l.clk == nil { | |
| 81 | + | return set | |
| 82 | + | } | |
| 83 | + | landed, ok := l.clk.settle(l.t, l.frames, l.scenes, l.outputs...) | |
| 84 | + | if !ok { | |
| 85 | + | return set | |
| 86 | + | } | |
| 87 | + | l.lands++ | |
| 88 | + | return landed | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // one is land for the first output's scene. | |
| 92 | + | func (l *modeLanding) one(s ports.Scene) ports.Scene { return l.land([]ports.Scene{s})[0] } | |
| 93 | + | ||
| 94 | + | func TestOwner(t *testing.T) { both(t, owner) } | |
| 95 | + | ||
| 96 | + | func owner(t *testing.T, animated bool) { | |
| 50 | 97 | cfg := altCmdDefaults() | |
| 51 | 98 | cfg.Border.Width = 0 | |
| 52 | 99 | cfg.Layout.Gaps = 8 | |
| @@ -58,7 +105,9 @@ func TestOwner(t *testing.T) { | |||
| 58 | 105 | spawn := make(chan ports.SpawnRequest, 8) | |
| 59 | 106 | scenes := make(chan []ports.Scene, 1) | |
| 60 | 107 | errs := make(chan error, 8) | |
| 61 | - | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: errs}) | |
| 108 | + | ch := core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: errs} | |
| 109 | + | l := newLanding(t, animated, &cfg, &ch, "OUT-1") | |
| 110 | + | c, err := core.New(cfg, ch) | |
| 62 | 111 | if err != nil { | |
| 63 | 112 | t.Fatal(err) | |
| 64 | 113 | } | |
| @@ -72,7 +121,7 @@ func TestOwner(t *testing.T) { | |||
| 72 | 121 | t.Fatal(s) | |
| 73 | 122 | } | |
| 74 | 123 | client <- ports.WindowMapped{ID: 1} | |
| 75 | - | s = scene(t, scenes) | |
| 124 | + | s = l.one(scene(t, scenes)) | |
| 76 | 125 | // A single column fills the usable width. | |
| 77 | 126 | if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || s.Windows[0].Inset != 0 { | |
| 78 | 127 | t.Fatal(s) | |
| @@ -117,7 +166,7 @@ func TestOwner(t *testing.T) { | |||
| 117 | 166 | } | |
| 118 | 167 | cfg.Layout.Gaps = 4 | |
| 119 | 168 | reload <- ports.ConfigChanged{Config: cfg} | |
| 120 | - | s = scene(t, scenes) | |
| 169 | + | s = l.one(scene(t, scenes)) | |
| 121 | 170 | if s.Windows[0].Rect.W != 92 { | |
| 122 | 171 | t.Fatal(s) | |
| 123 | 172 | } | |
| @@ -283,7 +332,9 @@ func TestPointerFocusAndGrab(t *testing.T) { | |||
| 283 | 332 | } | |
| 284 | 333 | } | |
| 285 | 334 | ||
| 286 | - | func TestBorderInset(t *testing.T) { | |
| 335 | + | func TestBorderInset(t *testing.T) { both(t, borderInset) } | |
| 336 | + | ||
| 337 | + | func borderInset(t *testing.T, animated bool) { | |
| 287 | 338 | cfg := config.Defaults() | |
| 288 | 339 | cfg.Border.Width = 2 | |
| 289 | 340 | client := make(chan ports.ClientEvent, 8) | |
| @@ -291,7 +342,9 @@ func TestBorderInset(t *testing.T) { | |||
| 291 | 342 | output := make(chan ports.OutputEvent, 8) | |
| 292 | 343 | commands := make(chan ports.ClientCommand, 64) | |
| 293 | 344 | scenes := make(chan []ports.Scene, 1) | |
| 294 | - | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |
| 345 | + | ch := core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes} | |
| 346 | + | l := newLanding(t, animated, &cfg, &ch, "OUT-1") | |
| 347 | + | c, err := core.New(cfg, ch) | |
| 295 | 348 | if err != nil { | |
| 296 | 349 | t.Fatal(err) | |
| 297 | 350 | } | |
| @@ -302,14 +355,14 @@ func TestBorderInset(t *testing.T) { | |||
| 302 | 355 | scene(t, scenes) | |
| 303 | 356 | // Alone, the window is borderless and gets the full rect. | |
| 304 | 357 | client <- ports.WindowMapped{ID: 1} | |
| 305 | - | r := scene(t, scenes).Windows[0].Rect | |
| 358 | + | r := l.one(scene(t, scenes)).Windows[0].Rect | |
| 306 | 359 | if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true, Output: "OUT-1", Visible: true}) { | |
| 307 | 360 | t.Fatalf("%v for outer %v", v, r) | |
| 308 | 361 | } | |
| 309 | 362 | // With a second column, the left window owns the shared separator: its | |
| 310 | 363 | // client is 2px narrower on the right; the right window keeps its rect. | |
| 311 | 364 | client <- ports.WindowMapped{ID: 2} | |
| 312 | - | s := scene(t, scenes) | |
| 365 | + | s := l.one(scene(t, scenes)) | |
| 313 | 366 | r = s.Windows[1].Rect | |
| 314 | 367 | if s.Windows[0].Inset != ports.SideRight || s.Windows[1].Inset != 0 || len(s.Separators) == 0 { | |
| 315 | 368 | t.Fatal(s) | |
| @@ -556,7 +609,9 @@ func TestShiftReleasedFirst(t *testing.T) { | |||
| 556 | 609 | } | |
| 557 | 610 | } | |
| 558 | 611 | ||
| 559 | - | func TestOutputScale(t *testing.T) { | |
| 612 | + | func TestOutputScale(t *testing.T) { both(t, outputScale) } | |
| 613 | + | ||
| 614 | + | func outputScale(t *testing.T, animated bool) { | |
| 560 | 615 | cfg := config.Defaults() | |
| 561 | 616 | cfg.Border.Width = 0 | |
| 562 | 617 | cfg.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 2}} | |
| @@ -568,7 +623,9 @@ func TestOutputScale(t *testing.T) { | |||
| 568 | 623 | reload := make(chan ports.ConfigChanged, 1) | |
| 569 | 624 | commands := make(chan ports.ClientCommand, 64) | |
| 570 | 625 | scenes := make(chan []ports.Scene, 1) | |
| 571 | - | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Scenes: scenes}) | |
| 626 | + | ch := core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Scenes: scenes} | |
| 627 | + | l := newLanding(t, animated, &cfg, &ch, "DP-2") | |
| 628 | + | c, err := core.New(cfg, ch) | |
| 572 | 629 | if err != nil { | |
| 573 | 630 | t.Fatal(err) | |
| 574 | 631 | } | |
| @@ -585,7 +642,7 @@ func TestOutputScale(t *testing.T) { | |||
| 585 | 642 | t.Fatal(v) | |
| 586 | 643 | } | |
| 587 | 644 | client <- ports.WindowMapped{ID: 1} | |
| 588 | - | scene(t, scenes) | |
| 645 | + | l.one(scene(t, scenes)) | |
| 589 | 646 | if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 2560, Height: 1080, Activated: true, Output: "DP-2", Visible: true}) { | |
| 590 | 647 | t.Fatal(v) | |
| 591 | 648 | } | |
| @@ -594,7 +651,13 @@ func TestOutputScale(t *testing.T) { | |||
| 594 | 651 | } | |
| 595 | 652 | // The pointer arrives in global logical pixels. | |
| 596 | 653 | input <- ports.PointerMotion{X: 500, Y: 200} | |
| 597 | - | if v := command(t, commands); v != (ports.PointerFocus{ID: 1, X: 500, Y: 200}) { | |
| 654 | + | if animated { | |
| 655 | + | // The page flips that landed the map's springs re-hit the pointer | |
| 656 | + | // (still at the origin, over the window): focus was already sent. | |
| 657 | + | if v, ok := command(t, commands).(ports.PointerMotionTo); !ok || v.ID != 1 || v.X != 500 || v.Y != 200 { | |
| 658 | + | t.Fatal(v) | |
| 659 | + | } | |
| 660 | + | } else if v := command(t, commands); v != (ports.PointerFocus{ID: 1, X: 500, Y: 200}) { | |
| 598 | 661 | t.Fatal(v) | |
| 599 | 662 | } | |
| 600 | 663 | // Cmd+- steps down through the clean scales of 5120x2160: 2 → 5/3. | |
| @@ -679,7 +742,9 @@ func TestPointerWarp(t *testing.T) { | |||
| 679 | 742 | } | |
| 680 | 743 | } | |
| 681 | 744 | ||
| 682 | - | func TestPointerConstraint(t *testing.T) { | |
| 745 | + | func TestPointerConstraint(t *testing.T) { both(t, pointerConstraint) } | |
| 746 | + | ||
| 747 | + | func pointerConstraint(t *testing.T, animated bool) { | |
| 683 | 748 | cfg := config.Defaults() | |
| 684 | 749 | cfg.Border.Width = 0 | |
| 685 | 750 | cfg.Layout.Gaps = 0 | |
| @@ -689,7 +754,9 @@ func TestPointerConstraint(t *testing.T) { | |||
| 689 | 754 | commands := make(chan ports.ClientCommand, 64) | |
| 690 | 755 | scenes := make(chan []ports.Scene, 1) | |
| 691 | 756 | constraints := make(chan ports.PointerConstraint, 1) | |
| 692 | - | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Constraints: constraints}) | |
| 757 | + | ch := core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Constraints: constraints} | |
| 758 | + | l := newLanding(t, animated, &cfg, &ch, "A", "B") | |
| 759 | + | c, err := core.New(cfg, ch) | |
| 693 | 760 | if err != nil { | |
| 694 | 761 | t.Fatal(err) | |
| 695 | 762 | } | |
| @@ -703,7 +770,7 @@ func TestPointerConstraint(t *testing.T) { | |||
| 703 | 770 | input <- ports.PointerMotion{X: 150, Y: 40} | |
| 704 | 771 | scene(t, scenes) // the pointer moves the focus to B | |
| 705 | 772 | client <- ports.WindowMapped{ID: 1} | |
| 706 | - | r := receive(t, scenes)[1].Windows[0].Rect | |
| 773 | + | r := l.land(receive(t, scenes))[1].Windows[0].Rect | |
| 707 | 774 | // The confine region is window-local and clipped to the window. | |
| 708 | 775 | client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 10, Y: 10, W: 1000, H: 20}}} | |
| 709 | 776 | scene(t, scenes) | |
| @@ -887,7 +954,9 @@ func TestDialogOverFullscreenHit(t *testing.T) { | |||
| 887 | 954 | // A window that asks for fullscreen as it maps (Wine at a remembered | |
| 888 | 955 | // monitor size) stays in its column: the windows it opens next are seen. | |
| 889 | 956 | // A later request is honoured. | |
| 890 | - | func TestFullscreenAtMapIgnored(t *testing.T) { | |
| 957 | + | func TestFullscreenAtMapIgnored(t *testing.T) { both(t, fullscreenAtMapIgnored) } | |
| 958 | + | ||
| 959 | + | func fullscreenAtMapIgnored(t *testing.T, animated bool) { | |
| 891 | 960 | cfg := config.Defaults() | |
| 892 | 961 | cfg.Layout.Overflow = "fixed" | |
| 893 | 962 | cfg.Focus.Animation = ports.FocusAnimationOff | |
| @@ -895,11 +964,15 @@ func TestFullscreenAtMapIgnored(t *testing.T) { | |||
| 895 | 964 | output := make(chan ports.OutputEvent, 8) | |
| 896 | 965 | commands := make(chan ports.ClientCommand, 64) | |
| 897 | 966 | scenes := make(chan []ports.Scene, 1) | |
| 898 | - | // Core reads the time on its goroutine. | |
| 899 | - | var now atomic.Int64 | |
| 900 | - | clock := portsmocks.NewMockClock(t) | |
| 901 | - | clock.EXPECT().Now().RunAndReturn(func() time.Time { return time.Unix(now.Load(), 0) }) | |
| 902 | - | c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: clock}) | |
| 967 | + | // Core reads the time on its goroutine: the clock is the test's. | |
| 968 | + | ch := core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes} | |
| 969 | + | l := newLanding(t, animated, &cfg, &ch, "OUT-1") | |
| 970 | + | clk := l.clk | |
| 971 | + | if clk == nil { | |
| 972 | + | clk = newStepClock(t) | |
| 973 | + | ch.Clock = clk.clock | |
| 974 | + | } | |
| 975 | + | c, err := core.New(cfg, ch) | |
| 903 | 976 | if err != nil { | |
| 904 | 977 | t.Fatal(err) | |
| 905 | 978 | } | |
| @@ -923,7 +996,10 @@ func TestFullscreenAtMapIgnored(t *testing.T) { | |||
| 923 | 996 | t.Fatalf("startup fullscreen applied: %+v", s.Windows) | |
| 924 | 997 | } | |
| 925 | 998 | } | |
| 926 | - | now.Add(1) | |
| 999 | + | // With animations on this lands the maps' springs: the clock moves past | |
| 1000 | + | // the grace, as the next line does anyway. | |
| 1001 | + | l.one(s) | |
| 1002 | + | clk.advance(time.Second) | |
| 927 | 1003 | // Only the focused window may cover the screen (ADR 011). | |
| 928 | 1004 | client <- ports.WindowUnmapped{ID: 3} | |
| 929 | 1005 | scene(t, scenes) | |
| @@ -968,16 +1044,21 @@ func TestFixedFullscreenArrivalWaits(t *testing.T) { | |||
| 968 | 1044 | } | |
| 969 | 1045 | ||
| 970 | 1046 | // A taskbar's fullscreen request is the user's: it applies at once. | |
| 971 | - | func TestExternalFullscreenAtMapApplies(t *testing.T) { | |
| 1047 | + | func TestExternalFullscreenAtMapApplies(t *testing.T) { both(t, externalFullscreenAtMapApplies) } | |
| 1048 | + | ||
| 1049 | + | func externalFullscreenAtMapApplies(t *testing.T, animated bool) { | |
| 972 | 1050 | cfg := config.Defaults() | |
| 973 | 1051 | cfg.Focus.Animation = ports.FocusAnimationOff | |
| 974 | 1052 | client := make(chan ports.ClientEvent, 8) | |
| 975 | 1053 | output := make(chan ports.OutputEvent, 8) | |
| 976 | 1054 | commands := make(chan ports.ClientCommand, 64) | |
| 977 | 1055 | scenes := make(chan []ports.Scene, 1) | |
| 978 | - | clock := portsmocks.NewMockClock(t) | |
| 979 | - | clock.EXPECT().Now().Return(time.Unix(0, 0)) | |
| 980 | - | c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: clock}) | |
| 1056 | + | ch := core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes} | |
| 1057 | + | l := newLanding(t, animated, &cfg, &ch, "OUT-1") | |
| 1058 | + | if l.clk == nil { | |
| 1059 | + | ch.Clock = newStepClock(t).clock | |
| 1060 | + | } | |
| 1061 | + | c, err := core.New(cfg, ch) | |
| 981 | 1062 | if err != nil { | |
| 982 | 1063 | t.Fatal(err) | |
| 983 | 1064 | } | |
| @@ -987,7 +1068,7 @@ func TestExternalFullscreenAtMapApplies(t *testing.T) { | |||
| 987 | 1068 | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |
| 988 | 1069 | scene(t, scenes) | |
| 989 | 1070 | client <- ports.WindowMapped{ID: 1} | |
| 990 | - | scene(t, scenes) | |
| 1071 | + | l.one(scene(t, scenes)) | |
| 991 | 1072 | client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true} | |
| 992 | 1073 | s := scene(t, scenes) | |
| 993 | 1074 | if len(s.Windows) != 1 || !s.Windows[0].Fullscreen { | |
| @@ -5,13 +5,11 @@ import ( | |||||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "reflect" | 6 | "reflect" | ||
| 7 | "slices" | 7 | "slices" | ||
| 8 | - | "sync/atomic" | |||
| 9 | "testing" | 8 | "testing" | ||
| 10 | "time" | 9 | "time" | ||
| 11 | 10 | ||||
| 12 | "github.com/bnema/neferwl/internal/adapters/config" | 11 | "github.com/bnema/neferwl/internal/adapters/config" | ||
| 13 | "github.com/bnema/neferwl/internal/core" | 12 | "github.com/bnema/neferwl/internal/core" | ||
| 14 | - | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 15 | "github.com/bnema/neferwl/internal/ports" | 13 | "github.com/bnema/neferwl/internal/ports" | ||
| 16 | ) | 14 | ) | ||
| 17 | 15 | ||||
| @@ -46,7 +44,56 @@ func command(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand { | |||||
| 46 | } | 44 | } | ||
| 47 | } | 45 | } | ||
| 48 | 46 | ||||
| 49 | - | func TestOwner(t *testing.T) { | 47 | + | // modeLanding gives a hand-rolled core test its animation mode: off, or on with |
| 48 | + | // a clock the test moves and the page flips it sends. land returns the scene | |||
| 49 | + | // set once every spring has landed (the set itself when none runs, and | |||
| 50 | + | // always with animations off). An on run that never saw a spring would be | |||
| 51 | + | // the off run again, so it fails. | |||
| 52 | + | type modeLanding struct { | |||
| 53 | + | t *testing.T | |||
| 54 | + | clk *stepClock | |||
| 55 | + | frames chan ports.OutputFrame | |||
| 56 | + | scenes chan []ports.Scene | |||
| 57 | + | outputs []string | |||
| 58 | + | lands int | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // newLanding sets cfg's animations and, when on, the clock and flips of ch. | |||
| 62 | + | func newLanding(t *testing.T, animated bool, cfg *ports.Config, ch *core.Channels, outputs ...string) *modeLanding { | |||
| 63 | + | t.Helper() | |||
| 64 | + | cfg.Animations.On = animated | |||
| 65 | + | l := &modeLanding{t: t, scenes: ch.Scenes, outputs: outputs} | |||
| 66 | + | if animated { | |||
| 67 | + | l.clk, l.frames = newStepClock(t), make(chan ports.OutputFrame) | |||
| 68 | + | ch.Clock, ch.Frames = l.clk.clock, l.frames | |||
| 69 | + | t.Cleanup(func() { | |||
| 70 | + | if l.lands == 0 { | |||
| 71 | + | t.Error("no spring ever ran: the animations-on variant checked nothing") | |||
| 72 | + | } | |||
| 73 | + | }) | |||
| 74 | + | } | |||
| 75 | + | return l | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func (l *modeLanding) land(set []ports.Scene) []ports.Scene { | |||
| 79 | + | l.t.Helper() | |||
| 80 | + | if l.clk == nil { | |||
| 81 | + | return set | |||
| 82 | + | } | |||
| 83 | + | landed, ok := l.clk.settle(l.t, l.frames, l.scenes, l.outputs...) | |||
| 84 | + | if !ok { | |||
| 85 | + | return set | |||
| 86 | + | } | |||
| 87 | + | l.lands++ | |||
| 88 | + | return landed | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // one is land for the first output's scene. | |||
| 92 | + | func (l *modeLanding) one(s ports.Scene) ports.Scene { return l.land([]ports.Scene{s})[0] } | |||
| 93 | + | ||||
| 94 | + | func TestOwner(t *testing.T) { both(t, owner) } | |||
| 95 | + | ||||
| 96 | + | func owner(t *testing.T, animated bool) { | |||
| 50 | cfg := altCmdDefaults() | 97 | cfg := altCmdDefaults() | ||
| 51 | cfg.Border.Width = 0 | 98 | cfg.Border.Width = 0 | ||
| 52 | cfg.Layout.Gaps = 8 | 99 | cfg.Layout.Gaps = 8 | ||
| @@ -58,7 +105,9 @@ func TestOwner(t *testing.T) { | |||||
| 58 | spawn := make(chan ports.SpawnRequest, 8) | 105 | spawn := make(chan ports.SpawnRequest, 8) | ||
| 59 | scenes := make(chan []ports.Scene, 1) | 106 | scenes := make(chan []ports.Scene, 1) | ||
| 60 | errs := make(chan error, 8) | 107 | errs := make(chan error, 8) | ||
| 61 | - | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: errs}) | 108 | + | ch := core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: errs} |
| 109 | + | l := newLanding(t, animated, &cfg, &ch, "OUT-1") | |||
| 110 | + | c, err := core.New(cfg, ch) | |||
| 62 | if err != nil { | 111 | if err != nil { | ||
| 63 | t.Fatal(err) | 112 | t.Fatal(err) | ||
| 64 | } | 113 | } | ||
| @@ -72,7 +121,7 @@ func TestOwner(t *testing.T) { | |||||
| 72 | t.Fatal(s) | 121 | t.Fatal(s) | ||
| 73 | } | 122 | } | ||
| 74 | client <- ports.WindowMapped{ID: 1} | 123 | client <- ports.WindowMapped{ID: 1} | ||
| 75 | - | s = scene(t, scenes) | 124 | + | s = l.one(scene(t, scenes)) |
| 76 | // A single column fills the usable width. | 125 | // A single column fills the usable width. | ||
| 77 | if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || s.Windows[0].Inset != 0 { | 126 | if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || s.Windows[0].Inset != 0 { | ||
| 78 | t.Fatal(s) | 127 | t.Fatal(s) | ||
| @@ -117,7 +166,7 @@ func TestOwner(t *testing.T) { | |||||
| 117 | } | 166 | } | ||
| 118 | cfg.Layout.Gaps = 4 | 167 | cfg.Layout.Gaps = 4 | ||
| 119 | reload <- ports.ConfigChanged{Config: cfg} | 168 | reload <- ports.ConfigChanged{Config: cfg} | ||
| 120 | - | s = scene(t, scenes) | 169 | + | s = l.one(scene(t, scenes)) |
| 121 | if s.Windows[0].Rect.W != 92 { | 170 | if s.Windows[0].Rect.W != 92 { | ||
| 122 | t.Fatal(s) | 171 | t.Fatal(s) | ||
| 123 | } | 172 | } | ||
| @@ -283,7 +332,9 @@ func TestPointerFocusAndGrab(t *testing.T) { | |||||
| 283 | } | 332 | } | ||
| 284 | } | 333 | } | ||
| 285 | 334 | ||||
| 286 | - | func TestBorderInset(t *testing.T) { | 335 | + | func TestBorderInset(t *testing.T) { both(t, borderInset) } |
| 336 | + | ||||
| 337 | + | func borderInset(t *testing.T, animated bool) { | |||
| 287 | cfg := config.Defaults() | 338 | cfg := config.Defaults() | ||
| 288 | cfg.Border.Width = 2 | 339 | cfg.Border.Width = 2 | ||
| 289 | client := make(chan ports.ClientEvent, 8) | 340 | client := make(chan ports.ClientEvent, 8) | ||
| @@ -291,7 +342,9 @@ func TestBorderInset(t *testing.T) { | |||||
| 291 | output := make(chan ports.OutputEvent, 8) | 342 | output := make(chan ports.OutputEvent, 8) | ||
| 292 | commands := make(chan ports.ClientCommand, 64) | 343 | commands := make(chan ports.ClientCommand, 64) | ||
| 293 | scenes := make(chan []ports.Scene, 1) | 344 | scenes := make(chan []ports.Scene, 1) | ||
| 294 | - | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | 345 | + | ch := core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes} |
| 346 | + | l := newLanding(t, animated, &cfg, &ch, "OUT-1") | |||
| 347 | + | c, err := core.New(cfg, ch) | |||
| 295 | if err != nil { | 348 | if err != nil { | ||
| 296 | t.Fatal(err) | 349 | t.Fatal(err) | ||
| 297 | } | 350 | } | ||
| @@ -302,14 +355,14 @@ func TestBorderInset(t *testing.T) { | |||||
| 302 | scene(t, scenes) | 355 | scene(t, scenes) | ||
| 303 | // Alone, the window is borderless and gets the full rect. | 356 | // Alone, the window is borderless and gets the full rect. | ||
| 304 | client <- ports.WindowMapped{ID: 1} | 357 | client <- ports.WindowMapped{ID: 1} | ||
| 305 | - | r := scene(t, scenes).Windows[0].Rect | 358 | + | r := l.one(scene(t, scenes)).Windows[0].Rect |
| 306 | if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true, Output: "OUT-1", Visible: true}) { | 359 | if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true, Output: "OUT-1", Visible: true}) { | ||
| 307 | t.Fatalf("%v for outer %v", v, r) | 360 | t.Fatalf("%v for outer %v", v, r) | ||
| 308 | } | 361 | } | ||
| 309 | // With a second column, the left window owns the shared separator: its | 362 | // With a second column, the left window owns the shared separator: its | ||
| 310 | // client is 2px narrower on the right; the right window keeps its rect. | 363 | // client is 2px narrower on the right; the right window keeps its rect. | ||
| 311 | client <- ports.WindowMapped{ID: 2} | 364 | client <- ports.WindowMapped{ID: 2} | ||
| 312 | - | s := scene(t, scenes) | 365 | + | s := l.one(scene(t, scenes)) |
| 313 | r = s.Windows[1].Rect | 366 | r = s.Windows[1].Rect | ||
| 314 | if s.Windows[0].Inset != ports.SideRight || s.Windows[1].Inset != 0 || len(s.Separators) == 0 { | 367 | if s.Windows[0].Inset != ports.SideRight || s.Windows[1].Inset != 0 || len(s.Separators) == 0 { | ||
| 315 | t.Fatal(s) | 368 | t.Fatal(s) | ||
| @@ -556,7 +609,9 @@ func TestShiftReleasedFirst(t *testing.T) { | |||||
| 556 | } | 609 | } | ||
| 557 | } | 610 | } | ||
| 558 | 611 | ||||
| 559 | - | func TestOutputScale(t *testing.T) { | 612 | + | func TestOutputScale(t *testing.T) { both(t, outputScale) } |
| 613 | + | ||||
| 614 | + | func outputScale(t *testing.T, animated bool) { | |||
| 560 | cfg := config.Defaults() | 615 | cfg := config.Defaults() | ||
| 561 | cfg.Border.Width = 0 | 616 | cfg.Border.Width = 0 | ||
| 562 | cfg.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 2}} | 617 | cfg.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 2}} | ||
| @@ -568,7 +623,9 @@ func TestOutputScale(t *testing.T) { | |||||
| 568 | reload := make(chan ports.ConfigChanged, 1) | 623 | reload := make(chan ports.ConfigChanged, 1) | ||
| 569 | commands := make(chan ports.ClientCommand, 64) | 624 | commands := make(chan ports.ClientCommand, 64) | ||
| 570 | scenes := make(chan []ports.Scene, 1) | 625 | scenes := make(chan []ports.Scene, 1) | ||
| 571 | - | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Scenes: scenes}) | 626 | + | ch := core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Scenes: scenes} |
| 627 | + | l := newLanding(t, animated, &cfg, &ch, "DP-2") | |||
| 628 | + | c, err := core.New(cfg, ch) | |||
| 572 | if err != nil { | 629 | if err != nil { | ||
| 573 | t.Fatal(err) | 630 | t.Fatal(err) | ||
| 574 | } | 631 | } | ||
| @@ -585,7 +642,7 @@ func TestOutputScale(t *testing.T) { | |||||
| 585 | t.Fatal(v) | 642 | t.Fatal(v) | ||
| 586 | } | 643 | } | ||
| 587 | client <- ports.WindowMapped{ID: 1} | 644 | client <- ports.WindowMapped{ID: 1} | ||
| 588 | - | scene(t, scenes) | 645 | + | l.one(scene(t, scenes)) |
| 589 | if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 2560, Height: 1080, Activated: true, Output: "DP-2", Visible: true}) { | 646 | if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 2560, Height: 1080, Activated: true, Output: "DP-2", Visible: true}) { | ||
| 590 | t.Fatal(v) | 647 | t.Fatal(v) | ||
| 591 | } | 648 | } | ||
| @@ -594,7 +651,13 @@ func TestOutputScale(t *testing.T) { | |||||
| 594 | } | 651 | } | ||
| 595 | // The pointer arrives in global logical pixels. | 652 | // The pointer arrives in global logical pixels. | ||
| 596 | input <- ports.PointerMotion{X: 500, Y: 200} | 653 | input <- ports.PointerMotion{X: 500, Y: 200} | ||
| 597 | - | if v := command(t, commands); v != (ports.PointerFocus{ID: 1, X: 500, Y: 200}) { | 654 | + | if animated { |
| 655 | + | // The page flips that landed the map's springs re-hit the pointer | |||
| 656 | + | // (still at the origin, over the window): focus was already sent. | |||
| 657 | + | if v, ok := command(t, commands).(ports.PointerMotionTo); !ok || v.ID != 1 || v.X != 500 || v.Y != 200 { | |||
| 658 | + | t.Fatal(v) | |||
| 659 | + | } | |||
| 660 | + | } else if v := command(t, commands); v != (ports.PointerFocus{ID: 1, X: 500, Y: 200}) { | |||
| 598 | t.Fatal(v) | 661 | t.Fatal(v) | ||
| 599 | } | 662 | } | ||
| 600 | // Cmd+- steps down through the clean scales of 5120x2160: 2 → 5/3. | 663 | // Cmd+- steps down through the clean scales of 5120x2160: 2 → 5/3. | ||
| @@ -679,7 +742,9 @@ func TestPointerWarp(t *testing.T) { | |||||
| 679 | } | 742 | } | ||
| 680 | } | 743 | } | ||
| 681 | 744 | ||||
| 682 | - | func TestPointerConstraint(t *testing.T) { | 745 | + | func TestPointerConstraint(t *testing.T) { both(t, pointerConstraint) } |
| 746 | + | ||||
| 747 | + | func pointerConstraint(t *testing.T, animated bool) { | |||
| 683 | cfg := config.Defaults() | 748 | cfg := config.Defaults() | ||
| 684 | cfg.Border.Width = 0 | 749 | cfg.Border.Width = 0 | ||
| 685 | cfg.Layout.Gaps = 0 | 750 | cfg.Layout.Gaps = 0 | ||
| @@ -689,7 +754,9 @@ func TestPointerConstraint(t *testing.T) { | |||||
| 689 | commands := make(chan ports.ClientCommand, 64) | 754 | commands := make(chan ports.ClientCommand, 64) | ||
| 690 | scenes := make(chan []ports.Scene, 1) | 755 | scenes := make(chan []ports.Scene, 1) | ||
| 691 | constraints := make(chan ports.PointerConstraint, 1) | 756 | constraints := make(chan ports.PointerConstraint, 1) | ||
| 692 | - | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Constraints: constraints}) | 757 | + | ch := core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Constraints: constraints} |
| 758 | + | l := newLanding(t, animated, &cfg, &ch, "A", "B") | |||
| 759 | + | c, err := core.New(cfg, ch) | |||
| 693 | if err != nil { | 760 | if err != nil { | ||
| 694 | t.Fatal(err) | 761 | t.Fatal(err) | ||
| 695 | } | 762 | } | ||
| @@ -703,7 +770,7 @@ func TestPointerConstraint(t *testing.T) { | |||||
| 703 | input <- ports.PointerMotion{X: 150, Y: 40} | 770 | input <- ports.PointerMotion{X: 150, Y: 40} | ||
| 704 | scene(t, scenes) // the pointer moves the focus to B | 771 | scene(t, scenes) // the pointer moves the focus to B | ||
| 705 | client <- ports.WindowMapped{ID: 1} | 772 | client <- ports.WindowMapped{ID: 1} | ||
| 706 | - | r := receive(t, scenes)[1].Windows[0].Rect | 773 | + | r := l.land(receive(t, scenes))[1].Windows[0].Rect |
| 707 | // The confine region is window-local and clipped to the window. | 774 | // The confine region is window-local and clipped to the window. | ||
| 708 | client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 10, Y: 10, W: 1000, H: 20}}} | 775 | client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 10, Y: 10, W: 1000, H: 20}}} | ||
| 709 | scene(t, scenes) | 776 | scene(t, scenes) | ||
| @@ -887,7 +954,9 @@ func TestDialogOverFullscreenHit(t *testing.T) { | |||||
| 887 | // A window that asks for fullscreen as it maps (Wine at a remembered | 954 | // A window that asks for fullscreen as it maps (Wine at a remembered | ||
| 888 | // monitor size) stays in its column: the windows it opens next are seen. | 955 | // monitor size) stays in its column: the windows it opens next are seen. | ||
| 889 | // A later request is honoured. | 956 | // A later request is honoured. | ||
| 890 | - | func TestFullscreenAtMapIgnored(t *testing.T) { | 957 | + | func TestFullscreenAtMapIgnored(t *testing.T) { both(t, fullscreenAtMapIgnored) } |
| 958 | + | ||||
| 959 | + | func fullscreenAtMapIgnored(t *testing.T, animated bool) { | |||
| 891 | cfg := config.Defaults() | 960 | cfg := config.Defaults() | ||
| 892 | cfg.Layout.Overflow = "fixed" | 961 | cfg.Layout.Overflow = "fixed" | ||
| 893 | cfg.Focus.Animation = ports.FocusAnimationOff | 962 | cfg.Focus.Animation = ports.FocusAnimationOff | ||
| @@ -895,11 +964,15 @@ func TestFullscreenAtMapIgnored(t *testing.T) { | |||||
| 895 | output := make(chan ports.OutputEvent, 8) | 964 | output := make(chan ports.OutputEvent, 8) | ||
| 896 | commands := make(chan ports.ClientCommand, 64) | 965 | commands := make(chan ports.ClientCommand, 64) | ||
| 897 | scenes := make(chan []ports.Scene, 1) | 966 | scenes := make(chan []ports.Scene, 1) | ||
| 898 | - | // Core reads the time on its goroutine. | 967 | + | // Core reads the time on its goroutine: the clock is the test's. |
| 899 | - | var now atomic.Int64 | 968 | + | ch := core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes} |
| 900 | - | clock := portsmocks.NewMockClock(t) | 969 | + | l := newLanding(t, animated, &cfg, &ch, "OUT-1") |
| 901 | - | clock.EXPECT().Now().RunAndReturn(func() time.Time { return time.Unix(now.Load(), 0) }) | 970 | + | clk := l.clk |
| 902 | - | c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: clock}) | 971 | + | if clk == nil { |
| 972 | + | clk = newStepClock(t) | |||
| 973 | + | ch.Clock = clk.clock | |||
| 974 | + | } | |||
| 975 | + | c, err := core.New(cfg, ch) | |||
| 903 | if err != nil { | 976 | if err != nil { | ||
| 904 | t.Fatal(err) | 977 | t.Fatal(err) | ||
| 905 | } | 978 | } | ||
| @@ -923,7 +996,10 @@ func TestFullscreenAtMapIgnored(t *testing.T) { | |||||
| 923 | t.Fatalf("startup fullscreen applied: %+v", s.Windows) | 996 | t.Fatalf("startup fullscreen applied: %+v", s.Windows) | ||
| 924 | } | 997 | } | ||
| 925 | } | 998 | } | ||
| 926 | - | now.Add(1) | 999 | + | // With animations on this lands the maps' springs: the clock moves past |
| 1000 | + | // the grace, as the next line does anyway. | |||
| 1001 | + | l.one(s) | |||
| 1002 | + | clk.advance(time.Second) | |||
| 927 | // Only the focused window may cover the screen (ADR 011). | 1003 | // Only the focused window may cover the screen (ADR 011). | ||
| 928 | client <- ports.WindowUnmapped{ID: 3} | 1004 | client <- ports.WindowUnmapped{ID: 3} | ||
| 929 | scene(t, scenes) | 1005 | scene(t, scenes) | ||
| @@ -968,16 +1044,21 @@ func TestFixedFullscreenArrivalWaits(t *testing.T) { | |||||
| 968 | } | 1044 | } | ||
| 969 | 1045 | ||||
| 970 | // A taskbar's fullscreen request is the user's: it applies at once. | 1046 | // A taskbar's fullscreen request is the user's: it applies at once. | ||
| 971 | - | func TestExternalFullscreenAtMapApplies(t *testing.T) { | 1047 | + | func TestExternalFullscreenAtMapApplies(t *testing.T) { both(t, externalFullscreenAtMapApplies) } |
| 1048 | + | ||||
| 1049 | + | func externalFullscreenAtMapApplies(t *testing.T, animated bool) { | |||
| 972 | cfg := config.Defaults() | 1050 | cfg := config.Defaults() | ||
| 973 | cfg.Focus.Animation = ports.FocusAnimationOff | 1051 | cfg.Focus.Animation = ports.FocusAnimationOff | ||
| 974 | client := make(chan ports.ClientEvent, 8) | 1052 | client := make(chan ports.ClientEvent, 8) | ||
| 975 | output := make(chan ports.OutputEvent, 8) | 1053 | output := make(chan ports.OutputEvent, 8) | ||
| 976 | commands := make(chan ports.ClientCommand, 64) | 1054 | commands := make(chan ports.ClientCommand, 64) | ||
| 977 | scenes := make(chan []ports.Scene, 1) | 1055 | scenes := make(chan []ports.Scene, 1) | ||
| 978 | - | clock := portsmocks.NewMockClock(t) | 1056 | + | ch := core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes} |
| 979 | - | clock.EXPECT().Now().Return(time.Unix(0, 0)) | 1057 | + | l := newLanding(t, animated, &cfg, &ch, "OUT-1") |
| 980 | - | c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: clock}) | 1058 | + | if l.clk == nil { |
| 1059 | + | ch.Clock = newStepClock(t).clock | |||
| 1060 | + | } | |||
| 1061 | + | c, err := core.New(cfg, ch) | |||
| 981 | if err != nil { | 1062 | if err != nil { | ||
| 982 | t.Fatal(err) | 1063 | t.Fatal(err) | ||
| 983 | } | 1064 | } | ||
| @@ -987,7 +1068,7 @@ func TestExternalFullscreenAtMapApplies(t *testing.T) { | |||||
| 987 | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | 1068 | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | ||
| 988 | scene(t, scenes) | 1069 | scene(t, scenes) | ||
| 989 | client <- ports.WindowMapped{ID: 1} | 1070 | client <- ports.WindowMapped{ID: 1} | ||
| 990 | - | scene(t, scenes) | 1071 | + | l.one(scene(t, scenes)) |
| 991 | client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true} | 1072 | client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true} | ||
| 992 | s := scene(t, scenes) | 1073 | s := scene(t, scenes) | ||
| 993 | if len(s.Windows) != 1 || !s.Windows[0].Fullscreen { | 1074 | if len(s.Windows) != 1 || !s.Windows[0].Fullscreen { | ||
A internal/core/map_animation_test.go +93 -0
| @@ -0,0 +1,93 @@ | |||
| 1 | + | package core_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // A window that maps through the owner loop appears: the scene right after | |
| 11 | + | // the map shows it invisible at 90 %, a frame later it is in between, and it | |
| 12 | + | // settles exactly; its neighbour slides over. The client is configured once | |
| 13 | + | // per window. With animations off the first scene is the final one. | |
| 14 | + | func TestMapAnimates(t *testing.T) { | |
| 15 | + | r := startSwipe(t, nil) | |
| 16 | + | r.mapWindow(t, 1) | |
| 17 | + | for len(r.commands) > 0 { | |
| 18 | + | <-r.commands | |
| 19 | + | } | |
| 20 | + | first := r.multiRig.mapWindow(t, 2)[0] | |
| 21 | + | w := windowOf(t, first, 2) | |
| 22 | + | if w.Fade != 1 || w.Hidden { | |
| 23 | + | t.Fatalf("new window right after the map: %+v", w) | |
| 24 | + | } | |
| 25 | + | if got := rectNow(t, first, 1); got.W != 800 { | |
| 26 | + | t.Fatalf("neighbour right after the map is %d wide, want its old 800", got.W) | |
| 27 | + | } | |
| 28 | + | mid := r.frame(t, 30*time.Millisecond) | |
| 29 | + | if m := windowOf(t, mid, 2); !(m.Fade > 0 && m.Fade < 1 && m.Rect.W > w.Rect.W && m.Rect.W < 400) { | |
| 30 | + | t.Fatalf("new window on the first frame: %+v (was %+v)", m, w) | |
| 31 | + | } | |
| 32 | + | if got := rectNow(t, mid, 1).W; got <= 400 || got >= 800 { | |
| 33 | + | t.Fatalf("neighbour on the first frame is %d wide, want between 400 and 800", got) | |
| 34 | + | } | |
| 35 | + | end := r.settle(t) | |
| 36 | + | if m := windowOf(t, end, 2); m.Fade != 0 || m.Zoom != 0 || m.Rect.W != 400 { | |
| 37 | + | t.Fatalf("settled new window: %+v", m) | |
| 38 | + | } | |
| 39 | + | if got := rectNow(t, end, 1).W; got != 400 { | |
| 40 | + | t.Fatalf("settled neighbour is %d wide", got) | |
| 41 | + | } | |
| 42 | + | configures := map[ports.WindowID]int{} | |
| 43 | + | for len(r.commands) > 0 { | |
| 44 | + | if v, ok := (<-r.commands).(ports.ConfigureWindow); ok { | |
| 45 | + | configures[v.ID]++ | |
| 46 | + | if v.Width != 400 { | |
| 47 | + | t.Fatalf("configure to %d wide, want the final 400: %+v", v.Width, v) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | if configures[2] != 1 || configures[1] != 1 { | |
| 52 | + | t.Fatalf("configures %v, want one each", configures) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // With animations off a map shows the final layout at once. | |
| 57 | + | func TestMapAnimationsOffIsInstant(t *testing.T) { | |
| 58 | + | r := startSwipe(t, animationsOff) | |
| 59 | + | r.mapWindow(t, 1) | |
| 60 | + | s := r.multiRig.mapWindow(t, 2)[0] | |
| 61 | + | if w := windowOf(t, s, 2); w.Fade != 0 || w.Zoom != 0 || w.Rect.W != 400 { | |
| 62 | + | t.Fatalf("new window: %+v", w) | |
| 63 | + | } | |
| 64 | + | if got := rectNow(t, s, 1).W; got != 400 { | |
| 65 | + | t.Fatalf("neighbour is %d wide, want 400", got) | |
| 66 | + | } | |
| 67 | + | r.noFrameScene(t) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // A window that goes fullscreen right after its map drops its entrance. | |
| 71 | + | func TestMapFullscreenRequestDropsEntrance(t *testing.T) { | |
| 72 | + | r := startSwipe(t, nil) | |
| 73 | + | s := r.multiRig.mapWindow(t, 1)[0] | |
| 74 | + | if windowOf(t, s, 1).Fade != 1 { | |
| 75 | + | t.Fatal("no entrance to drop") | |
| 76 | + | } | |
| 77 | + | r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true} | |
| 78 | + | s = scene(t, r.scenes) | |
| 79 | + | if w := windowOf(t, s, 1); w.Fade != 0 || w.Zoom != 0 || !w.Fullscreen { | |
| 80 | + | t.Fatalf("fullscreen window still fading: %+v", w) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func windowOf(t *testing.T, s ports.Scene, id ports.WindowID) ports.SceneWindow { | |
| 85 | + | t.Helper() | |
| 86 | + | for _, w := range s.Windows { | |
| 87 | + | if w.ID == id { | |
| 88 | + | return w | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | t.Fatalf("window %d not in %+v", id, s.Windows) | |
| 92 | + | return ports.SceneWindow{} | |
| 93 | + | } | |
| @@ -0,0 +1,93 @@ | |||||
| 1 | + | package core_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // A window that maps through the owner loop appears: the scene right after | |||
| 11 | + | // the map shows it invisible at 90 %, a frame later it is in between, and it | |||
| 12 | + | // settles exactly; its neighbour slides over. The client is configured once | |||
| 13 | + | // per window. With animations off the first scene is the final one. | |||
| 14 | + | func TestMapAnimates(t *testing.T) { | |||
| 15 | + | r := startSwipe(t, nil) | |||
| 16 | + | r.mapWindow(t, 1) | |||
| 17 | + | for len(r.commands) > 0 { | |||
| 18 | + | <-r.commands | |||
| 19 | + | } | |||
| 20 | + | first := r.multiRig.mapWindow(t, 2)[0] | |||
| 21 | + | w := windowOf(t, first, 2) | |||
| 22 | + | if w.Fade != 1 || w.Hidden { | |||
| 23 | + | t.Fatalf("new window right after the map: %+v", w) | |||
| 24 | + | } | |||
| 25 | + | if got := rectNow(t, first, 1); got.W != 800 { | |||
| 26 | + | t.Fatalf("neighbour right after the map is %d wide, want its old 800", got.W) | |||
| 27 | + | } | |||
| 28 | + | mid := r.frame(t, 30*time.Millisecond) | |||
| 29 | + | if m := windowOf(t, mid, 2); !(m.Fade > 0 && m.Fade < 1 && m.Rect.W > w.Rect.W && m.Rect.W < 400) { | |||
| 30 | + | t.Fatalf("new window on the first frame: %+v (was %+v)", m, w) | |||
| 31 | + | } | |||
| 32 | + | if got := rectNow(t, mid, 1).W; got <= 400 || got >= 800 { | |||
| 33 | + | t.Fatalf("neighbour on the first frame is %d wide, want between 400 and 800", got) | |||
| 34 | + | } | |||
| 35 | + | end := r.settle(t) | |||
| 36 | + | if m := windowOf(t, end, 2); m.Fade != 0 || m.Zoom != 0 || m.Rect.W != 400 { | |||
| 37 | + | t.Fatalf("settled new window: %+v", m) | |||
| 38 | + | } | |||
| 39 | + | if got := rectNow(t, end, 1).W; got != 400 { | |||
| 40 | + | t.Fatalf("settled neighbour is %d wide", got) | |||
| 41 | + | } | |||
| 42 | + | configures := map[ports.WindowID]int{} | |||
| 43 | + | for len(r.commands) > 0 { | |||
| 44 | + | if v, ok := (<-r.commands).(ports.ConfigureWindow); ok { | |||
| 45 | + | configures[v.ID]++ | |||
| 46 | + | if v.Width != 400 { | |||
| 47 | + | t.Fatalf("configure to %d wide, want the final 400: %+v", v.Width, v) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | if configures[2] != 1 || configures[1] != 1 { | |||
| 52 | + | t.Fatalf("configures %v, want one each", configures) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // With animations off a map shows the final layout at once. | |||
| 57 | + | func TestMapAnimationsOffIsInstant(t *testing.T) { | |||
| 58 | + | r := startSwipe(t, animationsOff) | |||
| 59 | + | r.mapWindow(t, 1) | |||
| 60 | + | s := r.multiRig.mapWindow(t, 2)[0] | |||
| 61 | + | if w := windowOf(t, s, 2); w.Fade != 0 || w.Zoom != 0 || w.Rect.W != 400 { | |||
| 62 | + | t.Fatalf("new window: %+v", w) | |||
| 63 | + | } | |||
| 64 | + | if got := rectNow(t, s, 1).W; got != 400 { | |||
| 65 | + | t.Fatalf("neighbour is %d wide, want 400", got) | |||
| 66 | + | } | |||
| 67 | + | r.noFrameScene(t) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // A window that goes fullscreen right after its map drops its entrance. | |||
| 71 | + | func TestMapFullscreenRequestDropsEntrance(t *testing.T) { | |||
| 72 | + | r := startSwipe(t, nil) | |||
| 73 | + | s := r.multiRig.mapWindow(t, 1)[0] | |||
| 74 | + | if windowOf(t, s, 1).Fade != 1 { | |||
| 75 | + | t.Fatal("no entrance to drop") | |||
| 76 | + | } | |||
| 77 | + | r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true} | |||
| 78 | + | s = scene(t, r.scenes) | |||
| 79 | + | if w := windowOf(t, s, 1); w.Fade != 0 || w.Zoom != 0 || !w.Fullscreen { | |||
| 80 | + | t.Fatalf("fullscreen window still fading: %+v", w) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func windowOf(t *testing.T, s ports.Scene, id ports.WindowID) ports.SceneWindow { | |||
| 85 | + | t.Helper() | |||
| 86 | + | for _, w := range s.Windows { | |||
| 87 | + | if w.ID == id { | |||
| 88 | + | return w | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | t.Fatalf("window %d not in %+v", id, s.Windows) | |||
| 92 | + | return ports.SceneWindow{} | |||
| 93 | + | } | |||
M internal/core/screens_test.go +58 -31
| @@ -127,15 +127,21 @@ func TestOutputsLeftToRight(t *testing.T) { | |||
| 127 | 127 | } | |
| 128 | 128 | ||
| 129 | 129 | func TestLayoutPerOutput(t *testing.T) { | |
| 130 | - | r := startMulti(t, func(c *ports.Config) { | |
| 131 | - | c.Layout.MaxColumns = 2 | |
| 132 | - | c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", MaxColumns: 4, Overflow: "fixed"}} | |
| 133 | - | }, left, right) | |
| 134 | - | r.mapWindow(t, 1) | |
| 135 | - | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 130 | + | both(t, func(t *testing.T, animated bool) { | |
| 131 | + | r := startLanding(t, animated, func(c *ports.Config) { | |
| 132 | + | c.Layout.MaxColumns = 2 | |
| 133 | + | c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", MaxColumns: 4, Overflow: "fixed"}} | |
| 134 | + | }, left, right) | |
| 135 | + | layoutPerOutput(t, r) | |
| 136 | + | }) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func layoutPerOutput(t *testing.T, r *landRig) { | |
| 140 | + | r.mapLanded(t, 1) | |
| 141 | + | r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 136 | 142 | var set []ports.Scene | |
| 137 | 143 | for id := ports.WindowID(2); id <= 6; id++ { | |
| 138 | - | set = r.mapWindow(t, id) | |
| 144 | + | set = r.mapLanded(t, id) | |
| 139 | 145 | } | |
| 140 | 146 | // DP-2: four columns of 100; the fifth window splits the last one | |
| 141 | 147 | // (fixed) instead of scrolling. | |
| @@ -166,26 +172,32 @@ func widths(set []ports.Scene, output string) []int { | |||
| 166 | 172 | } | |
| 167 | 173 | ||
| 168 | 174 | func TestLayoutPerOutputFollowsMonitorAndReload(t *testing.T) { | |
| 169 | - | r := startMulti(t, func(c *ports.Config) { | |
| 170 | - | c.Layout.MaxColumns = 1 | |
| 171 | - | c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", LayoutRules: ports.LayoutRules{MaxColumns: 2}}} | |
| 172 | - | }, right) | |
| 173 | - | r.mapWindow(t, 1) | |
| 174 | - | if got := widths(r.mapWindow(t, 2), "DP-2"); !slices.Equal(got, []int{200, 200}) { | |
| 175 | + | both(t, func(t *testing.T, animated bool) { | |
| 176 | + | r := startLanding(t, animated, func(c *ports.Config) { | |
| 177 | + | c.Layout.MaxColumns = 1 | |
| 178 | + | c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", LayoutRules: ports.LayoutRules{MaxColumns: 2}}} | |
| 179 | + | }, right) | |
| 180 | + | layoutPerOutputFollowsMonitorAndReload(t, r) | |
| 181 | + | }) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func layoutPerOutputFollowsMonitorAndReload(t *testing.T, r *landRig) { | |
| 185 | + | r.mapLanded(t, 1) | |
| 186 | + | if got := widths(r.mapLanded(t, 2), "DP-2"); !slices.Equal(got, []int{200, 200}) { | |
| 175 | 187 | t.Fatal(got) | |
| 176 | 188 | } | |
| 177 | 189 | // Another monitor on DP-2: the key rule no longer applies. | |
| 178 | 190 | other := right | |
| 179 | 191 | other.Serial = "3" | |
| 180 | - | if got := widths(r.plug(t, other), "DP-2"); !slices.Equal(got, []int{400, 400}) { | |
| 192 | + | if got := widths(r.land(r.plug(t, other)), "DP-2"); !slices.Equal(got, []int{400, 400}) { | |
| 181 | 193 | t.Fatal(got) | |
| 182 | 194 | } | |
| 183 | 195 | // Back to the first monitor, then a reload drops the rule. | |
| 184 | - | r.plug(t, right) | |
| 196 | + | r.land(r.plug(t, right)) | |
| 185 | 197 | cfg := r.cfg | |
| 186 | 198 | cfg.Layout.Outputs = nil | |
| 187 | 199 | r.reload <- ports.ConfigChanged{Config: cfg} | |
| 188 | - | if got := widths(receive(t, r.scenes), "DP-2"); !slices.Equal(got, []int{400, 400}) { | |
| 200 | + | if got := widths(r.land(receive(t, r.scenes)), "DP-2"); !slices.Equal(got, []int{400, 400}) { | |
| 189 | 201 | t.Fatal(got) | |
| 190 | 202 | } | |
| 191 | 203 | } | |
| @@ -301,10 +313,15 @@ func TestFocusColumnCrossesOutputs(t *testing.T) { | |||
| 301 | 313 | } | |
| 302 | 314 | ||
| 303 | 315 | func TestPointerCrossesOutputs(t *testing.T) { | |
| 304 | - | r := startMulti(t, nil, left, right) | |
| 305 | - | r.mapWindow(t, 1) | |
| 306 | - | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 307 | - | r.mapWindow(t, 2) | |
| 316 | + | both(t, func(t *testing.T, animated bool) { | |
| 317 | + | pointerCrossesOutputs(t, startLanding(t, animated, nil, left, right)) | |
| 318 | + | }) | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func pointerCrossesOutputs(t *testing.T, r *landRig) { | |
| 322 | + | r.mapLanded(t, 1) | |
| 323 | + | r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 324 | + | r.mapLanded(t, 2) | |
| 308 | 325 | for len(r.commands) > 0 { | |
| 309 | 326 | <-r.commands | |
| 310 | 327 | } | |
| @@ -1186,11 +1203,16 @@ func TestDialogFocusStaysOnMonitor(t *testing.T) { | |||
| 1186 | 1203 | // A click on a tile behind the shown stash hides the stash and focuses | |
| 1187 | 1204 | // the tile. | |
| 1188 | 1205 | func TestStashHidesOnBackgroundClick(t *testing.T) { | |
| 1189 | - | // Gap 10 at 80%: no peeks, the margins show the tile behind. | |
| 1190 | - | r := startMulti(t, func(c *ports.Config) { c.Stash.Gap = 10 }, left) | |
| 1191 | - | r.mapWindow(t, 1) | |
| 1192 | - | r.mapWindow(t, 2) | |
| 1193 | - | r.key(t, "s", ports.ModAlt|ports.ModShift) // 2 stashed and focused, over tile 1 | |
| 1206 | + | both(t, func(t *testing.T, animated bool) { | |
| 1207 | + | // Gap 10 at 80%: no peeks, the margins show the tile behind. | |
| 1208 | + | stashHidesOnBackgroundClick(t, startLanding(t, animated, func(c *ports.Config) { c.Stash.Gap = 10 }, left)) | |
| 1209 | + | }) | |
| 1210 | + | } | |
| 1211 | + | ||
| 1212 | + | func stashHidesOnBackgroundClick(t *testing.T, r *landRig) { | |
| 1213 | + | r.mapLanded(t, 1) | |
| 1214 | + | r.mapLanded(t, 2) | |
| 1215 | + | r.keyLanded(t, "s", ports.ModAlt|ports.ModShift) // 2 stashed and focused, over tile 1 | |
| 1194 | 1216 | r.input <- ports.PointerMotion{X: 5, Y: 50} | |
| 1195 | 1217 | r.input <- ports.PointerButton{Button: 0x110, Pressed: true} | |
| 1196 | 1218 | st := stateAfter(t, r.state, func(st ports.State) bool { return st.Window != nil && st.Window.ID == 1 }) | |
| @@ -1514,12 +1536,17 @@ func TestTransformReload(t *testing.T) { | |||
| 1514 | 1536 | // A reload that shrinks the layout under a still pointer brings it back onto | |
| 1515 | 1537 | // an output. | |
| 1516 | 1538 | func TestTransformReloadClampsPointer(t *testing.T) { | |
| 1517 | - | r := startMulti(t, func(c *ports.Config) { | |
| 1518 | - | c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2"}} | |
| 1519 | - | }, left, right) | |
| 1520 | - | r.mapWindow(t, 1) | |
| 1521 | - | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 1522 | - | r.mapWindow(t, 2) | |
| 1539 | + | both(t, func(t *testing.T, animated bool) { | |
| 1540 | + | transformReloadClampsPointer(t, startLanding(t, animated, func(c *ports.Config) { | |
| 1541 | + | c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2"}} | |
| 1542 | + | }, left, right)) | |
| 1543 | + | }) | |
| 1544 | + | } | |
| 1545 | + | ||
| 1546 | + | func transformReloadClampsPointer(t *testing.T, r *landRig) { | |
| 1547 | + | r.mapLanded(t, 1) | |
| 1548 | + | r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 1549 | + | r.mapLanded(t, 2) | |
| 1523 | 1550 | // DP-2 spans x 200..600: (550, 50) is (350, 50) on window 2. | |
| 1524 | 1551 | for len(r.commands) > 0 { | |
| 1525 | 1552 | <-r.commands | |
| @@ -127,15 +127,21 @@ func TestOutputsLeftToRight(t *testing.T) { | |||||
| 127 | } | 127 | } | ||
| 128 | 128 | ||||
| 129 | func TestLayoutPerOutput(t *testing.T) { | 129 | func TestLayoutPerOutput(t *testing.T) { | ||
| 130 | - | r := startMulti(t, func(c *ports.Config) { | 130 | + | both(t, func(t *testing.T, animated bool) { |
| 131 | - | c.Layout.MaxColumns = 2 | 131 | + | r := startLanding(t, animated, func(c *ports.Config) { |
| 132 | - | c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", MaxColumns: 4, Overflow: "fixed"}} | 132 | + | c.Layout.MaxColumns = 2 |
| 133 | - | }, left, right) | 133 | + | c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", MaxColumns: 4, Overflow: "fixed"}} |
| 134 | - | r.mapWindow(t, 1) | 134 | + | }, left, right) |
| 135 | - | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | 135 | + | layoutPerOutput(t, r) |
| 136 | + | }) | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func layoutPerOutput(t *testing.T, r *landRig) { | |||
| 140 | + | r.mapLanded(t, 1) | |||
| 141 | + | r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl) | |||
| 136 | var set []ports.Scene | 142 | var set []ports.Scene | ||
| 137 | for id := ports.WindowID(2); id <= 6; id++ { | 143 | for id := ports.WindowID(2); id <= 6; id++ { | ||
| 138 | - | set = r.mapWindow(t, id) | 144 | + | set = r.mapLanded(t, id) |
| 139 | } | 145 | } | ||
| 140 | // DP-2: four columns of 100; the fifth window splits the last one | 146 | // DP-2: four columns of 100; the fifth window splits the last one | ||
| 141 | // (fixed) instead of scrolling. | 147 | // (fixed) instead of scrolling. | ||
| @@ -166,26 +172,32 @@ func widths(set []ports.Scene, output string) []int { | |||||
| 166 | } | 172 | } | ||
| 167 | 173 | ||||
| 168 | func TestLayoutPerOutputFollowsMonitorAndReload(t *testing.T) { | 174 | func TestLayoutPerOutputFollowsMonitorAndReload(t *testing.T) { | ||
| 169 | - | r := startMulti(t, func(c *ports.Config) { | 175 | + | both(t, func(t *testing.T, animated bool) { |
| 170 | - | c.Layout.MaxColumns = 1 | 176 | + | r := startLanding(t, animated, func(c *ports.Config) { |
| 171 | - | c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", LayoutRules: ports.LayoutRules{MaxColumns: 2}}} | 177 | + | c.Layout.MaxColumns = 1 |
| 172 | - | }, right) | 178 | + | c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", LayoutRules: ports.LayoutRules{MaxColumns: 2}}} |
| 173 | - | r.mapWindow(t, 1) | 179 | + | }, right) |
| 174 | - | if got := widths(r.mapWindow(t, 2), "DP-2"); !slices.Equal(got, []int{200, 200}) { | 180 | + | layoutPerOutputFollowsMonitorAndReload(t, r) |
| 181 | + | }) | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func layoutPerOutputFollowsMonitorAndReload(t *testing.T, r *landRig) { | |||
| 185 | + | r.mapLanded(t, 1) | |||
| 186 | + | if got := widths(r.mapLanded(t, 2), "DP-2"); !slices.Equal(got, []int{200, 200}) { | |||
| 175 | t.Fatal(got) | 187 | t.Fatal(got) | ||
| 176 | } | 188 | } | ||
| 177 | // Another monitor on DP-2: the key rule no longer applies. | 189 | // Another monitor on DP-2: the key rule no longer applies. | ||
| 178 | other := right | 190 | other := right | ||
| 179 | other.Serial = "3" | 191 | other.Serial = "3" | ||
| 180 | - | if got := widths(r.plug(t, other), "DP-2"); !slices.Equal(got, []int{400, 400}) { | 192 | + | if got := widths(r.land(r.plug(t, other)), "DP-2"); !slices.Equal(got, []int{400, 400}) { |
| 181 | t.Fatal(got) | 193 | t.Fatal(got) | ||
| 182 | } | 194 | } | ||
| 183 | // Back to the first monitor, then a reload drops the rule. | 195 | // Back to the first monitor, then a reload drops the rule. | ||
| 184 | - | r.plug(t, right) | 196 | + | r.land(r.plug(t, right)) |
| 185 | cfg := r.cfg | 197 | cfg := r.cfg | ||
| 186 | cfg.Layout.Outputs = nil | 198 | cfg.Layout.Outputs = nil | ||
| 187 | r.reload <- ports.ConfigChanged{Config: cfg} | 199 | r.reload <- ports.ConfigChanged{Config: cfg} | ||
| 188 | - | if got := widths(receive(t, r.scenes), "DP-2"); !slices.Equal(got, []int{400, 400}) { | 200 | + | if got := widths(r.land(receive(t, r.scenes)), "DP-2"); !slices.Equal(got, []int{400, 400}) { |
| 189 | t.Fatal(got) | 201 | t.Fatal(got) | ||
| 190 | } | 202 | } | ||
| 191 | } | 203 | } | ||
| @@ -301,10 +313,15 @@ func TestFocusColumnCrossesOutputs(t *testing.T) { | |||||
| 301 | } | 313 | } | ||
| 302 | 314 | ||||
| 303 | func TestPointerCrossesOutputs(t *testing.T) { | 315 | func TestPointerCrossesOutputs(t *testing.T) { | ||
| 304 | - | r := startMulti(t, nil, left, right) | 316 | + | both(t, func(t *testing.T, animated bool) { |
| 305 | - | r.mapWindow(t, 1) | 317 | + | pointerCrossesOutputs(t, startLanding(t, animated, nil, left, right)) |
| 306 | - | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | 318 | + | }) |
| 307 | - | r.mapWindow(t, 2) | 319 | + | } |
| 320 | + | ||||
| 321 | + | func pointerCrossesOutputs(t *testing.T, r *landRig) { | |||
| 322 | + | r.mapLanded(t, 1) | |||
| 323 | + | r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl) | |||
| 324 | + | r.mapLanded(t, 2) | |||
| 308 | for len(r.commands) > 0 { | 325 | for len(r.commands) > 0 { | ||
| 309 | <-r.commands | 326 | <-r.commands | ||
| 310 | } | 327 | } | ||
| @@ -1186,11 +1203,16 @@ func TestDialogFocusStaysOnMonitor(t *testing.T) { | |||||
| 1186 | // A click on a tile behind the shown stash hides the stash and focuses | 1203 | // A click on a tile behind the shown stash hides the stash and focuses | ||
| 1187 | // the tile. | 1204 | // the tile. | ||
| 1188 | func TestStashHidesOnBackgroundClick(t *testing.T) { | 1205 | func TestStashHidesOnBackgroundClick(t *testing.T) { | ||
| 1189 | - | // Gap 10 at 80%: no peeks, the margins show the tile behind. | 1206 | + | both(t, func(t *testing.T, animated bool) { |
| 1190 | - | r := startMulti(t, func(c *ports.Config) { c.Stash.Gap = 10 }, left) | 1207 | + | // Gap 10 at 80%: no peeks, the margins show the tile behind. |
| 1191 | - | r.mapWindow(t, 1) | 1208 | + | stashHidesOnBackgroundClick(t, startLanding(t, animated, func(c *ports.Config) { c.Stash.Gap = 10 }, left)) |
| 1192 | - | r.mapWindow(t, 2) | 1209 | + | }) |
| 1193 | - | r.key(t, "s", ports.ModAlt|ports.ModShift) // 2 stashed and focused, over tile 1 | 1210 | + | } |
| 1211 | + | ||||
| 1212 | + | func stashHidesOnBackgroundClick(t *testing.T, r *landRig) { | |||
| 1213 | + | r.mapLanded(t, 1) | |||
| 1214 | + | r.mapLanded(t, 2) | |||
| 1215 | + | r.keyLanded(t, "s", ports.ModAlt|ports.ModShift) // 2 stashed and focused, over tile 1 | |||
| 1194 | r.input <- ports.PointerMotion{X: 5, Y: 50} | 1216 | r.input <- ports.PointerMotion{X: 5, Y: 50} | ||
| 1195 | r.input <- ports.PointerButton{Button: 0x110, Pressed: true} | 1217 | r.input <- ports.PointerButton{Button: 0x110, Pressed: true} | ||
| 1196 | st := stateAfter(t, r.state, func(st ports.State) bool { return st.Window != nil && st.Window.ID == 1 }) | 1218 | st := stateAfter(t, r.state, func(st ports.State) bool { return st.Window != nil && st.Window.ID == 1 }) | ||
| @@ -1514,12 +1536,17 @@ func TestTransformReload(t *testing.T) { | |||||
| 1514 | // A reload that shrinks the layout under a still pointer brings it back onto | 1536 | // A reload that shrinks the layout under a still pointer brings it back onto | ||
| 1515 | // an output. | 1537 | // an output. | ||
| 1516 | func TestTransformReloadClampsPointer(t *testing.T) { | 1538 | func TestTransformReloadClampsPointer(t *testing.T) { | ||
| 1517 | - | r := startMulti(t, func(c *ports.Config) { | 1539 | + | both(t, func(t *testing.T, animated bool) { |
| 1518 | - | c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2"}} | 1540 | + | transformReloadClampsPointer(t, startLanding(t, animated, func(c *ports.Config) { |
| 1519 | - | }, left, right) | 1541 | + | c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2"}} |
| 1520 | - | r.mapWindow(t, 1) | 1542 | + | }, left, right)) |
| 1521 | - | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | 1543 | + | }) |
| 1522 | - | r.mapWindow(t, 2) | 1544 | + | } |
| 1545 | + | ||||
| 1546 | + | func transformReloadClampsPointer(t *testing.T, r *landRig) { | |||
| 1547 | + | r.mapLanded(t, 1) | |||
| 1548 | + | r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl) | |||
| 1549 | + | r.mapLanded(t, 2) | |||
| 1523 | // DP-2 spans x 200..600: (550, 50) is (350, 50) on window 2. | 1550 | // DP-2 spans x 200..600: (550, 50) is (350, 50) on window 2. | ||
| 1524 | for len(r.commands) > 0 { | 1551 | for len(r.commands) > 0 { | ||
| 1525 | <-r.commands | 1552 | <-r.commands | ||
M internal/core/slots_test.go +53 -5
| @@ -21,14 +21,30 @@ type slotRig struct { | |||
| 21 | 21 | workspaces chan ports.Workspaces | |
| 22 | 22 | errs chan error | |
| 23 | 23 | cfg ports.Config | |
| 24 | + | // With animations on and a clock the test moves (startSlotsMode), | |
| 25 | + | // land flips the output past every spring. | |
| 26 | + | clk *stepClock | |
| 27 | + | frames chan ports.OutputFrame | |
| 28 | + | lands int | |
| 24 | 29 | } | |
| 25 | 30 | ||
| 26 | 31 | // startSlots runs core with a hidden "dev" workspace of two slots, toggled | |
| 27 | 32 | // by Alt+d, on a 100x80 output. One flag makes dev numbered, two also set | |
| 28 | 33 | // fixed overflow. | |
| 29 | 34 | func startSlots(t *testing.T, numbered ...bool) *slotRig { | |
| 35 | + | t.Helper() | |
| 36 | + | return startSlotsMode(t, nil, numbered...) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // startSlotsMode is startSlots with animations forced off (*false) or on | |
| 40 | + | // with a clock the test moves (*true), whose springs land through land; | |
| 41 | + | // nil keeps the default config on the system clock. | |
| 42 | + | func startSlotsMode(t *testing.T, animated *bool, numbered ...bool) *slotRig { | |
| 30 | 43 | t.Helper() | |
| 31 | 44 | cfg := altCmdDefaults() | |
| 45 | + | if animated != nil { | |
| 46 | + | cfg.Animations.On = *animated | |
| 47 | + | } | |
| 32 | 48 | cfg.Border.Width = 0 | |
| 33 | 49 | cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{ | |
| 34 | 50 | {Index: 1, Width: "70%", Argv: []string{"code"}}, | |
| @@ -45,7 +61,17 @@ func startSlots(t *testing.T, numbered ...bool) *slotRig { | |||
| 45 | 61 | } | |
| 46 | 62 | output := make(chan ports.OutputEvent, 1) | |
| 47 | 63 | commands := make(chan ports.ClientCommand, 1024) | |
| 48 | - | c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: output, Config: r.reload, Commands: commands, Spawn: r.spawn, Scenes: r.scenes, Workspaces: r.workspaces, ConfigErrors: r.errs}) | |
| 64 | + | ch := core.Channels{Client: r.client, Input: r.input, Output: output, Config: r.reload, Commands: commands, Spawn: r.spawn, Scenes: r.scenes, Workspaces: r.workspaces, ConfigErrors: r.errs} | |
| 65 | + | if animated != nil && *animated { | |
| 66 | + | r.clk, r.frames = newStepClock(t), make(chan ports.OutputFrame) | |
| 67 | + | ch.Clock, ch.Frames = r.clk.clock, r.frames | |
| 68 | + | t.Cleanup(func() { | |
| 69 | + | if r.lands == 0 { | |
| 70 | + | t.Error("no spring ever ran: the animations-on variant checked nothing") | |
| 71 | + | } | |
| 72 | + | }) | |
| 73 | + | } | |
| 74 | + | c, err := core.New(cfg, ch) | |
| 49 | 75 | if err != nil { | |
| 50 | 76 | t.Fatal(err) | |
| 51 | 77 | } | |
| @@ -62,6 +88,22 @@ func startSlots(t *testing.T, numbered ...bool) *slotRig { | |||
| 62 | 88 | return r | |
| 63 | 89 | } | |
| 64 | 90 | ||
| 91 | + | // land is s once every spring has landed: the scene the flips publish, or s | |
| 92 | + | // itself when nothing animates (always with animations off or on the | |
| 93 | + | // system clock). | |
| 94 | + | func (r *slotRig) land(t *testing.T, s ports.Scene) ports.Scene { | |
| 95 | + | t.Helper() | |
| 96 | + | if r.clk == nil { | |
| 97 | + | return s | |
| 98 | + | } | |
| 99 | + | set, ok := r.clk.settle(t, r.frames, r.scenes, "OUT-1") | |
| 100 | + | if !ok { | |
| 101 | + | return s | |
| 102 | + | } | |
| 103 | + | r.lands++ | |
| 104 | + | return set[0] | |
| 105 | + | } | |
| 106 | + | ||
| 65 | 107 | // token returns the SlotEnv value of a spawn request. | |
| 66 | 108 | func token(t *testing.T, req ports.SpawnRequest) string { | |
| 67 | 109 | t.Helper() | |
| @@ -194,7 +236,10 @@ func TestSlotsSpawnAtStartAndFillInOrder(t *testing.T) { | |||
| 194 | 236 | } | |
| 195 | 237 | ||
| 196 | 238 | func TestSlotRefilledOnlyWhenShown(t *testing.T) { | |
| 197 | - | r := startSlots(t) | |
| 239 | + | both(t, func(t *testing.T, animated bool) { slotRefilledOnlyWhenShown(t, startSlotsMode(t, &animated)) }) | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | func slotRefilledOnlyWhenShown(t *testing.T, r *slotRig) { | |
| 198 | 243 | // Wait for both windows: a one-window scene from before the unmap | |
| 199 | 244 | // would let the key below race it. | |
| 200 | 245 | _, foot := r.fill(t) | |
| @@ -209,7 +254,7 @@ func TestSlotRefilledOnlyWhenShown(t *testing.T) { | |||
| 209 | 254 | } | |
| 210 | 255 | r.client <- ports.WindowMapped{ID: 4, Slot: token(t, again)} | |
| 211 | 256 | s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 2 }) | |
| 212 | - | if got := visible(s); got[4].X != 70 { | |
| 257 | + | if got := visible(r.land(t, s)); got[4].X != 70 { | |
| 213 | 258 | t.Fatal(got) | |
| 214 | 259 | } | |
| 215 | 260 | // Leaving and showing dev again with full slots spawns nothing. | |
| @@ -344,7 +389,10 @@ func TestSlotOldTokenIsNormal(t *testing.T) { | |||
| 344 | 389 | // A slot whose command never maps a window gets one show to do it; on the | |
| 345 | 390 | // next show it is respawned and its old token no longer fills it. | |
| 346 | 391 | func TestPendingSlotRespawnedOnSecondShow(t *testing.T) { | |
| 347 | - | r := startSlots(t) | |
| 392 | + | both(t, func(t *testing.T, animated bool) { pendingSlotRespawnedOnSecondShow(t, startSlotsMode(t, &animated)) }) | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | func pendingSlotRespawnedOnSecondShow(t *testing.T, r *slotRig) { | |
| 348 | 396 | code, foot := receive(t, r.spawn), receive(t, r.spawn) | |
| 349 | 397 | r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)} | |
| 350 | 398 | sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 }) | |
| @@ -363,7 +411,7 @@ func TestPendingSlotRespawnedOnSecondShow(t *testing.T) { | |||
| 363 | 411 | } | |
| 364 | 412 | r.client <- ports.WindowMapped{ID: 8, Slot: token(t, again)} | |
| 365 | 413 | s = sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 }) | |
| 366 | - | if got := visible(s); got[8].W != 30 { | |
| 414 | + | if got := visible(r.land(t, s)); got[8].W != 30 { | |
| 367 | 415 | t.Fatalf("respawned window not in its slot: %v", got) | |
| 368 | 416 | } | |
| 369 | 417 | } | |
| @@ -21,14 +21,30 @@ type slotRig struct { | |||||
| 21 | workspaces chan ports.Workspaces | 21 | workspaces chan ports.Workspaces | ||
| 22 | errs chan error | 22 | errs chan error | ||
| 23 | cfg ports.Config | 23 | cfg ports.Config | ||
| 24 | + | // With animations on and a clock the test moves (startSlotsMode), | |||
| 25 | + | // land flips the output past every spring. | |||
| 26 | + | clk *stepClock | |||
| 27 | + | frames chan ports.OutputFrame | |||
| 28 | + | lands int | |||
| 24 | } | 29 | } | ||
| 25 | 30 | ||||
| 26 | // startSlots runs core with a hidden "dev" workspace of two slots, toggled | 31 | // startSlots runs core with a hidden "dev" workspace of two slots, toggled | ||
| 27 | // by Alt+d, on a 100x80 output. One flag makes dev numbered, two also set | 32 | // by Alt+d, on a 100x80 output. One flag makes dev numbered, two also set | ||
| 28 | // fixed overflow. | 33 | // fixed overflow. | ||
| 29 | func startSlots(t *testing.T, numbered ...bool) *slotRig { | 34 | func startSlots(t *testing.T, numbered ...bool) *slotRig { | ||
| 35 | + | t.Helper() | |||
| 36 | + | return startSlotsMode(t, nil, numbered...) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // startSlotsMode is startSlots with animations forced off (*false) or on | |||
| 40 | + | // with a clock the test moves (*true), whose springs land through land; | |||
| 41 | + | // nil keeps the default config on the system clock. | |||
| 42 | + | func startSlotsMode(t *testing.T, animated *bool, numbered ...bool) *slotRig { | |||
| 30 | t.Helper() | 43 | t.Helper() | ||
| 31 | cfg := altCmdDefaults() | 44 | cfg := altCmdDefaults() | ||
| 45 | + | if animated != nil { | |||
| 46 | + | cfg.Animations.On = *animated | |||
| 47 | + | } | |||
| 32 | cfg.Border.Width = 0 | 48 | cfg.Border.Width = 0 | ||
| 33 | cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{ | 49 | cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{ | ||
| 34 | {Index: 1, Width: "70%", Argv: []string{"code"}}, | 50 | {Index: 1, Width: "70%", Argv: []string{"code"}}, | ||
| @@ -45,7 +61,17 @@ func startSlots(t *testing.T, numbered ...bool) *slotRig { | |||||
| 45 | } | 61 | } | ||
| 46 | output := make(chan ports.OutputEvent, 1) | 62 | output := make(chan ports.OutputEvent, 1) | ||
| 47 | commands := make(chan ports.ClientCommand, 1024) | 63 | commands := make(chan ports.ClientCommand, 1024) | ||
| 48 | - | c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: output, Config: r.reload, Commands: commands, Spawn: r.spawn, Scenes: r.scenes, Workspaces: r.workspaces, ConfigErrors: r.errs}) | 64 | + | ch := core.Channels{Client: r.client, Input: r.input, Output: output, Config: r.reload, Commands: commands, Spawn: r.spawn, Scenes: r.scenes, Workspaces: r.workspaces, ConfigErrors: r.errs} |
| 65 | + | if animated != nil && *animated { | |||
| 66 | + | r.clk, r.frames = newStepClock(t), make(chan ports.OutputFrame) | |||
| 67 | + | ch.Clock, ch.Frames = r.clk.clock, r.frames | |||
| 68 | + | t.Cleanup(func() { | |||
| 69 | + | if r.lands == 0 { | |||
| 70 | + | t.Error("no spring ever ran: the animations-on variant checked nothing") | |||
| 71 | + | } | |||
| 72 | + | }) | |||
| 73 | + | } | |||
| 74 | + | c, err := core.New(cfg, ch) | |||
| 49 | if err != nil { | 75 | if err != nil { | ||
| 50 | t.Fatal(err) | 76 | t.Fatal(err) | ||
| 51 | } | 77 | } | ||
| @@ -62,6 +88,22 @@ func startSlots(t *testing.T, numbered ...bool) *slotRig { | |||||
| 62 | return r | 88 | return r | ||
| 63 | } | 89 | } | ||
| 64 | 90 | ||||
| 91 | + | // land is s once every spring has landed: the scene the flips publish, or s | |||
| 92 | + | // itself when nothing animates (always with animations off or on the | |||
| 93 | + | // system clock). | |||
| 94 | + | func (r *slotRig) land(t *testing.T, s ports.Scene) ports.Scene { | |||
| 95 | + | t.Helper() | |||
| 96 | + | if r.clk == nil { | |||
| 97 | + | return s | |||
| 98 | + | } | |||
| 99 | + | set, ok := r.clk.settle(t, r.frames, r.scenes, "OUT-1") | |||
| 100 | + | if !ok { | |||
| 101 | + | return s | |||
| 102 | + | } | |||
| 103 | + | r.lands++ | |||
| 104 | + | return set[0] | |||
| 105 | + | } | |||
| 106 | + | ||||
| 65 | // token returns the SlotEnv value of a spawn request. | 107 | // token returns the SlotEnv value of a spawn request. | ||
| 66 | func token(t *testing.T, req ports.SpawnRequest) string { | 108 | func token(t *testing.T, req ports.SpawnRequest) string { | ||
| 67 | t.Helper() | 109 | t.Helper() | ||
| @@ -194,7 +236,10 @@ func TestSlotsSpawnAtStartAndFillInOrder(t *testing.T) { | |||||
| 194 | } | 236 | } | ||
| 195 | 237 | ||||
| 196 | func TestSlotRefilledOnlyWhenShown(t *testing.T) { | 238 | func TestSlotRefilledOnlyWhenShown(t *testing.T) { | ||
| 197 | - | r := startSlots(t) | 239 | + | both(t, func(t *testing.T, animated bool) { slotRefilledOnlyWhenShown(t, startSlotsMode(t, &animated)) }) |
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | func slotRefilledOnlyWhenShown(t *testing.T, r *slotRig) { | |||
| 198 | // Wait for both windows: a one-window scene from before the unmap | 243 | // Wait for both windows: a one-window scene from before the unmap | ||
| 199 | // would let the key below race it. | 244 | // would let the key below race it. | ||
| 200 | _, foot := r.fill(t) | 245 | _, foot := r.fill(t) | ||
| @@ -209,7 +254,7 @@ func TestSlotRefilledOnlyWhenShown(t *testing.T) { | |||||
| 209 | } | 254 | } | ||
| 210 | r.client <- ports.WindowMapped{ID: 4, Slot: token(t, again)} | 255 | r.client <- ports.WindowMapped{ID: 4, Slot: token(t, again)} | ||
| 211 | s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 2 }) | 256 | s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 2 }) | ||
| 212 | - | if got := visible(s); got[4].X != 70 { | 257 | + | if got := visible(r.land(t, s)); got[4].X != 70 { |
| 213 | t.Fatal(got) | 258 | t.Fatal(got) | ||
| 214 | } | 259 | } | ||
| 215 | // Leaving and showing dev again with full slots spawns nothing. | 260 | // Leaving and showing dev again with full slots spawns nothing. | ||
| @@ -344,7 +389,10 @@ func TestSlotOldTokenIsNormal(t *testing.T) { | |||||
| 344 | // A slot whose command never maps a window gets one show to do it; on the | 389 | // A slot whose command never maps a window gets one show to do it; on the | ||
| 345 | // next show it is respawned and its old token no longer fills it. | 390 | // next show it is respawned and its old token no longer fills it. | ||
| 346 | func TestPendingSlotRespawnedOnSecondShow(t *testing.T) { | 391 | func TestPendingSlotRespawnedOnSecondShow(t *testing.T) { | ||
| 347 | - | r := startSlots(t) | 392 | + | both(t, func(t *testing.T, animated bool) { pendingSlotRespawnedOnSecondShow(t, startSlotsMode(t, &animated)) }) |
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | func pendingSlotRespawnedOnSecondShow(t *testing.T, r *slotRig) { | |||
| 348 | code, foot := receive(t, r.spawn), receive(t, r.spawn) | 396 | code, foot := receive(t, r.spawn), receive(t, r.spawn) | ||
| 349 | r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)} | 397 | r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)} | ||
| 350 | sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 }) | 398 | sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 }) | ||
| @@ -363,7 +411,7 @@ func TestPendingSlotRespawnedOnSecondShow(t *testing.T) { | |||||
| 363 | } | 411 | } | ||
| 364 | r.client <- ports.WindowMapped{ID: 8, Slot: token(t, again)} | 412 | r.client <- ports.WindowMapped{ID: 8, Slot: token(t, again)} | ||
| 365 | s = sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 }) | 413 | s = sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 }) | ||
| 366 | - | if got := visible(s); got[8].W != 30 { | 414 | + | if got := visible(r.land(t, s)); got[8].W != 30 { |
| 367 | t.Fatalf("respawned window not in its slot: %v", got) | 415 | t.Fatalf("respawned window not in its slot: %v", got) | ||
| 368 | } | 416 | } | ||
| 369 | } | 417 | } | ||
M internal/core/swipe_test.go +13 -0
| @@ -63,6 +63,19 @@ func (r *swipeRig) advance(d time.Duration) { | |||
| 63 | 63 | r.clk.advance(d) | |
| 64 | 64 | } | |
| 65 | 65 | ||
| 66 | + | // mapWindow maps a window and, with animations on, lands its entrance (and | |
| 67 | + | // the neighbours' re-flow) so the scene is the settled one: the tests of a | |
| 68 | + | // swipe rig look at what the map leaves. The entrance itself is checked on | |
| 69 | + | // the raw multiRig.mapWindow (TestMap*). | |
| 70 | + | func (r *swipeRig) mapWindow(t *testing.T, id ports.WindowID) []ports.Scene { | |
| 71 | + | t.Helper() | |
| 72 | + | set := r.multiRig.mapWindow(t, id) | |
| 73 | + | if landed, ok := r.settleAll(t, r.outs...); ok { | |
| 74 | + | return landed | |
| 75 | + | } | |
| 76 | + | return set | |
| 77 | + | } | |
| 78 | + | ||
| 66 | 79 | // begin starts a swipe; nothing moves until it picks an axis. | |
| 67 | 80 | func (r *swipeRig) begin() { | |
| 68 | 81 | r.at += time.Second | |
| @@ -63,6 +63,19 @@ func (r *swipeRig) advance(d time.Duration) { | |||||
| 63 | r.clk.advance(d) | 63 | r.clk.advance(d) | ||
| 64 | } | 64 | } | ||
| 65 | 65 | ||||
| 66 | + | // mapWindow maps a window and, with animations on, lands its entrance (and | |||
| 67 | + | // the neighbours' re-flow) so the scene is the settled one: the tests of a | |||
| 68 | + | // swipe rig look at what the map leaves. The entrance itself is checked on | |||
| 69 | + | // the raw multiRig.mapWindow (TestMap*). | |||
| 70 | + | func (r *swipeRig) mapWindow(t *testing.T, id ports.WindowID) []ports.Scene { | |||
| 71 | + | t.Helper() | |||
| 72 | + | set := r.multiRig.mapWindow(t, id) | |||
| 73 | + | if landed, ok := r.settleAll(t, r.outs...); ok { | |||
| 74 | + | return landed | |||
| 75 | + | } | |||
| 76 | + | return set | |||
| 77 | + | } | |||
| 78 | + | ||||
| 66 | // begin starts a swipe; nothing moves until it picks an axis. | 79 | // begin starts a swipe; nothing moves until it picks an axis. | ||
| 67 | func (r *swipeRig) begin() { | 80 | func (r *swipeRig) begin() { | ||
| 68 | r.at += time.Second | 81 | r.at += time.Second | ||
M internal/core/transition.go +43 -0
| @@ -4,6 +4,8 @@ import ( | |||
| 4 | 4 | "math" | |
| 5 | 5 | "slices" | |
| 6 | 6 | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |
| 7 | 9 | ) | |
| 8 | 10 | ||
| 9 | 11 | // Transitions start after a user action (a bind, a click that focuses, a | |
| @@ -295,6 +297,47 @@ func (c *Core) appearAt(sc *screen, p Placement, now time.Time) { | |||
| 295 | 297 | sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current() | |
| 296 | 298 | } | |
| 297 | 299 | ||
| 300 | + | // mapWindow handles a window's map: it joins its workspace, the tiles the | |
| 301 | + | // new one displaces re-flow with rect motions, and a window that comes on | |
| 302 | + | // screen appears (appearMapped). A map is the one client event that | |
| 303 | + | // animates; the settled layout is the same with animations off, so the | |
| 304 | + | // client is configured once, to its final size. | |
| 305 | + | func (c *Core) mapWindow(v ports.WindowMapped) { | |
| 306 | + | now := c.now() | |
| 307 | + | c.windows.mapped(v, now) | |
| 308 | + | if s, _ := c.screenOf(v.ID); s != nil || !c.animOn() || c.security.Protected { | |
| 309 | + | // Mapped before (a re-map keeps its place), animations off or a | |
| 310 | + | // session lock: nothing to animate. | |
| 311 | + | c.placement.place(c, v) | |
| 312 | + | return | |
| 313 | + | } | |
| 314 | + | shots := c.snapshot(now) | |
| 315 | + | c.placement.place(c, v) | |
| 316 | + | c.transition(shots, now) | |
| 317 | + | c.appearMapped(v.ID, now) | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | // appearMapped starts the entrance of a window that just mapped when it | |
| 321 | + | // shows on its screen's current workspace as a tile or an ordinary float: | |
| 322 | + | // not with the overview open or a session lock, not fullscreen (it keeps | |
| 323 | + | // the direct scanout path), not a float below the columns, not a stash | |
| 324 | + | // peek. | |
| 325 | + | func (c *Core) appearMapped(id WindowID, now time.Time) { | |
| 326 | + | sc, w := c.screenOf(id) | |
| 327 | + | if sc == nil || c.security.Protected || sc.mon.ov.open || sc.mon.Current() != w { | |
| 328 | + | return | |
| 329 | + | } | |
| 330 | + | for _, p := range sc.mon.Layout() { | |
| 331 | + | if p.ID != id { | |
| 332 | + | continue | |
| 333 | + | } | |
| 334 | + | if !p.Hidden && !p.Fullscreen && !p.Below && !p.Peek && p.Preview == 0 { | |
| 335 | + | c.appearAt(sc, p, now) | |
| 336 | + | } | |
| 337 | + | return | |
| 338 | + | } | |
| 339 | + | } | |
| 340 | + | ||
| 298 | 341 | // leave keeps drawing a window the action closed or hid, from its last | |
| 299 | 342 | // settled placement p: it fades out and shrinks to 90 % around its centre, | |
| 300 | 343 | // then its entry goes. Nothing with animations off, or for a placement that | |
| @@ -4,6 +4,8 @@ import ( | |||||
| 4 | "math" | 4 | "math" | ||
| 5 | "slices" | 5 | "slices" | ||
| 6 | "time" | 6 | "time" | ||
| 7 | + | ||||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 7 | ) | 9 | ) | ||
| 8 | 10 | ||||
| 9 | // Transitions start after a user action (a bind, a click that focuses, a | 11 | // Transitions start after a user action (a bind, a click that focuses, a | ||
| @@ -295,6 +297,47 @@ func (c *Core) appearAt(sc *screen, p Placement, now time.Time) { | |||||
| 295 | sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current() | 297 | sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current() | ||
| 296 | } | 298 | } | ||
| 297 | 299 | ||||
| 300 | + | // mapWindow handles a window's map: it joins its workspace, the tiles the | |||
| 301 | + | // new one displaces re-flow with rect motions, and a window that comes on | |||
| 302 | + | // screen appears (appearMapped). A map is the one client event that | |||
| 303 | + | // animates; the settled layout is the same with animations off, so the | |||
| 304 | + | // client is configured once, to its final size. | |||
| 305 | + | func (c *Core) mapWindow(v ports.WindowMapped) { | |||
| 306 | + | now := c.now() | |||
| 307 | + | c.windows.mapped(v, now) | |||
| 308 | + | if s, _ := c.screenOf(v.ID); s != nil || !c.animOn() || c.security.Protected { | |||
| 309 | + | // Mapped before (a re-map keeps its place), animations off or a | |||
| 310 | + | // session lock: nothing to animate. | |||
| 311 | + | c.placement.place(c, v) | |||
| 312 | + | return | |||
| 313 | + | } | |||
| 314 | + | shots := c.snapshot(now) | |||
| 315 | + | c.placement.place(c, v) | |||
| 316 | + | c.transition(shots, now) | |||
| 317 | + | c.appearMapped(v.ID, now) | |||
| 318 | + | } | |||
| 319 | + | ||||
| 320 | + | // appearMapped starts the entrance of a window that just mapped when it | |||
| 321 | + | // shows on its screen's current workspace as a tile or an ordinary float: | |||
| 322 | + | // not with the overview open or a session lock, not fullscreen (it keeps | |||
| 323 | + | // the direct scanout path), not a float below the columns, not a stash | |||
| 324 | + | // peek. | |||
| 325 | + | func (c *Core) appearMapped(id WindowID, now time.Time) { | |||
| 326 | + | sc, w := c.screenOf(id) | |||
| 327 | + | if sc == nil || c.security.Protected || sc.mon.ov.open || sc.mon.Current() != w { | |||
| 328 | + | return | |||
| 329 | + | } | |||
| 330 | + | for _, p := range sc.mon.Layout() { | |||
| 331 | + | if p.ID != id { | |||
| 332 | + | continue | |||
| 333 | + | } | |||
| 334 | + | if !p.Hidden && !p.Fullscreen && !p.Below && !p.Peek && p.Preview == 0 { | |||
| 335 | + | c.appearAt(sc, p, now) | |||
| 336 | + | } | |||
| 337 | + | return | |||
| 338 | + | } | |||
| 339 | + | } | |||
| 340 | + | ||||
| 298 | // leave keeps drawing a window the action closed or hid, from its last | 341 | // leave keeps drawing a window the action closed or hid, from its last | ||
| 299 | // settled placement p: it fades out and shrinks to 90 % around its centre, | 342 | // settled placement p: it fades out and shrinks to 90 % around its centre, | ||
| 300 | // then its entry goes. Nothing with animations off, or for a placement that | 343 | // then its entry goes. Nothing with animations off, or for a placement that | ||
A internal/core/transition_map_internal_test.go +200 -0
| @@ -0,0 +1,200 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "slices" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func settledOf(t *testing.T, sc *screen, id WindowID) Placement { | |
| 13 | + | t.Helper() | |
| 14 | + | i := slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == id }) | |
| 15 | + | if i < 0 { | |
| 16 | + | t.Fatalf("window %d not in the settled layout %+v", id, sc.settledLayout) | |
| 17 | + | } | |
| 18 | + | return sc.settledLayout[i] | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // A tiled map: the new window fades in from 90 % of its settled rect, its | |
| 22 | + | // neighbour slides from where it was, the client is configured once, to | |
| 23 | + | // the final size, and everything settles exactly. | |
| 24 | + | func TestMapTiledAppearsAndReflows(t *testing.T) { | |
| 25 | + | c, ic, cmds := fadeCore(t) | |
| 26 | + | c.cfg.Animations.On = true | |
| 27 | + | sc := c.cur() | |
| 28 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |
| 29 | + | indicatorScene(t, c) | |
| 30 | + | c.animate(ic.now.Add(5*time.Second), nil) | |
| 31 | + | ic.now = ic.now.Add(5 * time.Second) | |
| 32 | + | indicatorScene(t, c) | |
| 33 | + | if len(sc.rects) != 0 { | |
| 34 | + | t.Fatalf("motions left after the first window settled: %d", len(sc.rects)) | |
| 35 | + | } | |
| 36 | + | drainConfigures(cmds) | |
| 37 | + | old1 := sceneWindow(t, indicatorScene(t, c), 1).Rect | |
| 38 | + | ||
| 39 | + | c.mapWindow(ports.WindowMapped{ID: 2}) | |
| 40 | + | s := indicatorScene(t, c) | |
| 41 | + | settled2 := settledOf(t, sc, 2) | |
| 42 | + | w2 := sceneWindow(t, s, 2) | |
| 43 | + | if w2.Fade != 1 || w2.Hidden || w2.Rect != scaledRect(settled2.Rect, appearScale) || math.Abs(w2.Zoom-appearScale) > 0.02 || w2.Preview != 0 { | |
| 44 | + | t.Fatalf("new window right after the map: %+v, settled %+v", w2, settled2.Rect) | |
| 45 | + | } | |
| 46 | + | // The neighbour is drawn where it was, and moves to its new half. | |
| 47 | + | if got := sceneWindow(t, s, 1).Rect; got != old1 { | |
| 48 | + | t.Fatalf("neighbour right after the map at %+v, want the old %+v", got, old1) | |
| 49 | + | } | |
| 50 | + | settled1 := settledOf(t, sc, 1) | |
| 51 | + | if settled1.Rect == old1 { | |
| 52 | + | t.Fatal("the new column did not move the neighbour: the case checks nothing") | |
| 53 | + | } | |
| 54 | + | // One configure per window, to the settled size. | |
| 55 | + | got := drainConfigures(cmds) | |
| 56 | + | slices.Sort(got) | |
| 57 | + | if !slices.Equal(got, []WindowID{1, 2}) { | |
| 58 | + | t.Fatalf("configures %v, want one each for 1 and 2", got) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | ic.now = ic.now.Add(40 * time.Millisecond) | |
| 62 | + | c.animate(ic.now, nil) | |
| 63 | + | s = indicatorScene(t, c) | |
| 64 | + | w2 = sceneWindow(t, s, 2) | |
| 65 | + | if !(w2.Fade > 0 && w2.Fade < 1) || !(w2.Zoom > appearScale && w2.Zoom < 1) || !(w2.Rect.W > scaledRect(settled2.Rect, appearScale).W && w2.Rect.W < settled2.Rect.W) { | |
| 66 | + | t.Fatalf("first frame of the new window: %+v", w2) | |
| 67 | + | } | |
| 68 | + | if r := sceneWindow(t, s, 1).Rect; !(r.W < old1.W && r.W > settled1.Rect.W) { | |
| 69 | + | t.Fatalf("neighbour on the first frame %+v, want between %+v and %+v", r, old1, settled1.Rect) | |
| 70 | + | } | |
| 71 | + | if ids := drainConfigures(cmds); len(ids) != 0 { | |
| 72 | + | t.Fatalf("frames reconfigured %v", ids) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | ic.now = ic.now.Add(5 * time.Second) | |
| 76 | + | c.animate(ic.now, nil) | |
| 77 | + | s = indicatorScene(t, c) | |
| 78 | + | if w := sceneWindow(t, s, 2); w.Fade != 0 || w.Zoom != 0 || w.Rect != settled2.Rect { | |
| 79 | + | t.Fatalf("new window settled: %+v, want %+v", w, settled2.Rect) | |
| 80 | + | } | |
| 81 | + | if w := sceneWindow(t, s, 1); w.Rect != settled1.Rect { | |
| 82 | + | t.Fatalf("neighbour settled at %+v, want %+v", w.Rect, settled1.Rect) | |
| 83 | + | } | |
| 84 | + | if len(sc.rects) != 0 || c.animating() { | |
| 85 | + | t.Fatal("still animating after the settle") | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // A floating toplevel appears too. | |
| 90 | + | func TestMapFloatingAppears(t *testing.T) { | |
| 91 | + | c, ic, _ := fadeCore(t) | |
| 92 | + | c.cfg.Animations.On = true | |
| 93 | + | sc := c.cur() | |
| 94 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |
| 95 | + | indicatorScene(t, c) | |
| 96 | + | c.mapWindow(ports.WindowMapped{ID: 2, Floating: true, Width: 80, Height: 60}) | |
| 97 | + | s := indicatorScene(t, c) | |
| 98 | + | settled := settledOf(t, sc, 2) | |
| 99 | + | w := sceneWindow(t, s, 2) | |
| 100 | + | if !w.Floating && !settled.Floating { | |
| 101 | + | t.Fatalf("window 2 is not a float: %+v", settled) | |
| 102 | + | } | |
| 103 | + | if w.Fade != 1 || w.Rect != scaledRect(settled.Rect, appearScale) { | |
| 104 | + | t.Fatalf("float right after the map: %+v, settled %+v", w, settled.Rect) | |
| 105 | + | } | |
| 106 | + | ic.now = ic.now.Add(5 * time.Second) | |
| 107 | + | c.animate(ic.now, nil) | |
| 108 | + | if w := sceneWindow(t, indicatorScene(t, c), 2); w.Fade != 0 || w.Rect != settled.Rect { | |
| 109 | + | t.Fatalf("float settled: %+v", w) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // With animations off a map is instant: no motion, no fade. | |
| 114 | + | func TestMapOffIsInstant(t *testing.T) { | |
| 115 | + | c, _, _ := fadeCore(t) | |
| 116 | + | sc := c.cur() | |
| 117 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |
| 118 | + | indicatorScene(t, c) | |
| 119 | + | c.mapWindow(ports.WindowMapped{ID: 2}) | |
| 120 | + | s := indicatorScene(t, c) | |
| 121 | + | if len(sc.rects) != 0 || c.animating() { | |
| 122 | + | t.Fatal("a map started a motion with animations off") | |
| 123 | + | } | |
| 124 | + | for _, w := range s.Windows { | |
| 125 | + | if w.Fade != 0 || w.Zoom != 0 || w.Rect != settledOf(t, sc, w.ID).Rect { | |
| 126 | + | t.Fatalf("window %d not at its settled rect: %+v", w.ID, w) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // A window that is not shown tiled or floating on screen does not appear: | |
| 132 | + | // fullscreen, hidden behind a fullscreen game, with a session lock or with | |
| 133 | + | // the overview open. Neighbours stay put as well in the last two. | |
| 134 | + | func TestMapExceptions(t *testing.T) { | |
| 135 | + | t.Run("fullscreen", func(t *testing.T) { | |
| 136 | + | c, ic, _ := fadeCore(t) | |
| 137 | + | c.cfg.Animations.On = true | |
| 138 | + | sc := c.cur() | |
| 139 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |
| 140 | + | c.stopAnimations() // the first window's own entrance | |
| 141 | + | sc.mon.SetFullscreen(1, true) | |
| 142 | + | c.appearMapped(1, ic.now) | |
| 143 | + | indicatorScene(t, c) | |
| 144 | + | if len(sc.rects) != 0 || c.animating() { | |
| 145 | + | t.Fatalf("a fullscreen window appeared: %+v", sc.rects) | |
| 146 | + | } | |
| 147 | + | }) | |
| 148 | + | t.Run("behind a fullscreen game", func(t *testing.T) { | |
| 149 | + | c, _, _ := fadeCore(t) | |
| 150 | + | c.cfg.Animations.On = true | |
| 151 | + | sc := c.cur() | |
| 152 | + | sc.mon.Current().Overflow = OverflowFixed | |
| 153 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |
| 154 | + | c.stopAnimations() // the first window's own entrance | |
| 155 | + | sc.mon.SetFullscreen(1, true) | |
| 156 | + | indicatorScene(t, c) | |
| 157 | + | c.mapWindow(ports.WindowMapped{ID: 2}) | |
| 158 | + | if s := indicatorScene(t, c); len(sc.rects) != 0 || sceneWindow(t, s, 2).Fade != 0 { | |
| 159 | + | t.Fatalf("a window mapped behind a fullscreen one appeared: %+v", sc.rects) | |
| 160 | + | } | |
| 161 | + | }) | |
| 162 | + | t.Run("session lock", func(t *testing.T) { | |
| 163 | + | c, _, _ := fadeCore(t) | |
| 164 | + | c.cfg.Animations.On = true | |
| 165 | + | sc := c.cur() | |
| 166 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |
| 167 | + | c.stopAnimations() // the first window's own entrance | |
| 168 | + | c.security = ports.SecurityState{Generation: 1, Protected: true} | |
| 169 | + | c.mapWindow(ports.WindowMapped{ID: 2}) | |
| 170 | + | if len(sc.rects) != 0 || c.animating() { | |
| 171 | + | t.Fatalf("a window mapped under a session lock animated: %+v", sc.rects) | |
| 172 | + | } | |
| 173 | + | }) | |
| 174 | + | t.Run("overview open", func(t *testing.T) { | |
| 175 | + | c, _, _ := fadeCore(t) | |
| 176 | + | c.cfg.Animations.On = true | |
| 177 | + | sc := c.cur() | |
| 178 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |
| 179 | + | c.stopAnimations() // the first window's own entrance | |
| 180 | + | sc.mon.ToggleOverview() | |
| 181 | + | c.mapWindow(ports.WindowMapped{ID: 2}) | |
| 182 | + | if len(sc.rects) != 0 { | |
| 183 | + | t.Fatalf("a window mapped in the overview animated: %+v", sc.rects) | |
| 184 | + | } | |
| 185 | + | }) | |
| 186 | + | t.Run("map again", func(t *testing.T) { | |
| 187 | + | c, ic, _ := fadeCore(t) | |
| 188 | + | c.cfg.Animations.On = true | |
| 189 | + | sc := c.cur() | |
| 190 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |
| 191 | + | c.stopAnimations() // the first window's own entrance | |
| 192 | + | indicatorScene(t, c) | |
| 193 | + | ic.now = ic.now.Add(5 * time.Second) | |
| 194 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |
| 195 | + | c.stopAnimations() // the first window's own entrance | |
| 196 | + | if len(sc.rects) != 0 { | |
| 197 | + | t.Fatalf("a repeated map restarted the entrance: %+v", sc.rects) | |
| 198 | + | } | |
| 199 | + | }) | |
| 200 | + | } | |
| @@ -0,0 +1,200 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math" | |||
| 5 | + | "slices" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func settledOf(t *testing.T, sc *screen, id WindowID) Placement { | |||
| 13 | + | t.Helper() | |||
| 14 | + | i := slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == id }) | |||
| 15 | + | if i < 0 { | |||
| 16 | + | t.Fatalf("window %d not in the settled layout %+v", id, sc.settledLayout) | |||
| 17 | + | } | |||
| 18 | + | return sc.settledLayout[i] | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // A tiled map: the new window fades in from 90 % of its settled rect, its | |||
| 22 | + | // neighbour slides from where it was, the client is configured once, to | |||
| 23 | + | // the final size, and everything settles exactly. | |||
| 24 | + | func TestMapTiledAppearsAndReflows(t *testing.T) { | |||
| 25 | + | c, ic, cmds := fadeCore(t) | |||
| 26 | + | c.cfg.Animations.On = true | |||
| 27 | + | sc := c.cur() | |||
| 28 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |||
| 29 | + | indicatorScene(t, c) | |||
| 30 | + | c.animate(ic.now.Add(5*time.Second), nil) | |||
| 31 | + | ic.now = ic.now.Add(5 * time.Second) | |||
| 32 | + | indicatorScene(t, c) | |||
| 33 | + | if len(sc.rects) != 0 { | |||
| 34 | + | t.Fatalf("motions left after the first window settled: %d", len(sc.rects)) | |||
| 35 | + | } | |||
| 36 | + | drainConfigures(cmds) | |||
| 37 | + | old1 := sceneWindow(t, indicatorScene(t, c), 1).Rect | |||
| 38 | + | ||||
| 39 | + | c.mapWindow(ports.WindowMapped{ID: 2}) | |||
| 40 | + | s := indicatorScene(t, c) | |||
| 41 | + | settled2 := settledOf(t, sc, 2) | |||
| 42 | + | w2 := sceneWindow(t, s, 2) | |||
| 43 | + | if w2.Fade != 1 || w2.Hidden || w2.Rect != scaledRect(settled2.Rect, appearScale) || math.Abs(w2.Zoom-appearScale) > 0.02 || w2.Preview != 0 { | |||
| 44 | + | t.Fatalf("new window right after the map: %+v, settled %+v", w2, settled2.Rect) | |||
| 45 | + | } | |||
| 46 | + | // The neighbour is drawn where it was, and moves to its new half. | |||
| 47 | + | if got := sceneWindow(t, s, 1).Rect; got != old1 { | |||
| 48 | + | t.Fatalf("neighbour right after the map at %+v, want the old %+v", got, old1) | |||
| 49 | + | } | |||
| 50 | + | settled1 := settledOf(t, sc, 1) | |||
| 51 | + | if settled1.Rect == old1 { | |||
| 52 | + | t.Fatal("the new column did not move the neighbour: the case checks nothing") | |||
| 53 | + | } | |||
| 54 | + | // One configure per window, to the settled size. | |||
| 55 | + | got := drainConfigures(cmds) | |||
| 56 | + | slices.Sort(got) | |||
| 57 | + | if !slices.Equal(got, []WindowID{1, 2}) { | |||
| 58 | + | t.Fatalf("configures %v, want one each for 1 and 2", got) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | ic.now = ic.now.Add(40 * time.Millisecond) | |||
| 62 | + | c.animate(ic.now, nil) | |||
| 63 | + | s = indicatorScene(t, c) | |||
| 64 | + | w2 = sceneWindow(t, s, 2) | |||
| 65 | + | if !(w2.Fade > 0 && w2.Fade < 1) || !(w2.Zoom > appearScale && w2.Zoom < 1) || !(w2.Rect.W > scaledRect(settled2.Rect, appearScale).W && w2.Rect.W < settled2.Rect.W) { | |||
| 66 | + | t.Fatalf("first frame of the new window: %+v", w2) | |||
| 67 | + | } | |||
| 68 | + | if r := sceneWindow(t, s, 1).Rect; !(r.W < old1.W && r.W > settled1.Rect.W) { | |||
| 69 | + | t.Fatalf("neighbour on the first frame %+v, want between %+v and %+v", r, old1, settled1.Rect) | |||
| 70 | + | } | |||
| 71 | + | if ids := drainConfigures(cmds); len(ids) != 0 { | |||
| 72 | + | t.Fatalf("frames reconfigured %v", ids) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | ic.now = ic.now.Add(5 * time.Second) | |||
| 76 | + | c.animate(ic.now, nil) | |||
| 77 | + | s = indicatorScene(t, c) | |||
| 78 | + | if w := sceneWindow(t, s, 2); w.Fade != 0 || w.Zoom != 0 || w.Rect != settled2.Rect { | |||
| 79 | + | t.Fatalf("new window settled: %+v, want %+v", w, settled2.Rect) | |||
| 80 | + | } | |||
| 81 | + | if w := sceneWindow(t, s, 1); w.Rect != settled1.Rect { | |||
| 82 | + | t.Fatalf("neighbour settled at %+v, want %+v", w.Rect, settled1.Rect) | |||
| 83 | + | } | |||
| 84 | + | if len(sc.rects) != 0 || c.animating() { | |||
| 85 | + | t.Fatal("still animating after the settle") | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // A floating toplevel appears too. | |||
| 90 | + | func TestMapFloatingAppears(t *testing.T) { | |||
| 91 | + | c, ic, _ := fadeCore(t) | |||
| 92 | + | c.cfg.Animations.On = true | |||
| 93 | + | sc := c.cur() | |||
| 94 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |||
| 95 | + | indicatorScene(t, c) | |||
| 96 | + | c.mapWindow(ports.WindowMapped{ID: 2, Floating: true, Width: 80, Height: 60}) | |||
| 97 | + | s := indicatorScene(t, c) | |||
| 98 | + | settled := settledOf(t, sc, 2) | |||
| 99 | + | w := sceneWindow(t, s, 2) | |||
| 100 | + | if !w.Floating && !settled.Floating { | |||
| 101 | + | t.Fatalf("window 2 is not a float: %+v", settled) | |||
| 102 | + | } | |||
| 103 | + | if w.Fade != 1 || w.Rect != scaledRect(settled.Rect, appearScale) { | |||
| 104 | + | t.Fatalf("float right after the map: %+v, settled %+v", w, settled.Rect) | |||
| 105 | + | } | |||
| 106 | + | ic.now = ic.now.Add(5 * time.Second) | |||
| 107 | + | c.animate(ic.now, nil) | |||
| 108 | + | if w := sceneWindow(t, indicatorScene(t, c), 2); w.Fade != 0 || w.Rect != settled.Rect { | |||
| 109 | + | t.Fatalf("float settled: %+v", w) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | // With animations off a map is instant: no motion, no fade. | |||
| 114 | + | func TestMapOffIsInstant(t *testing.T) { | |||
| 115 | + | c, _, _ := fadeCore(t) | |||
| 116 | + | sc := c.cur() | |||
| 117 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |||
| 118 | + | indicatorScene(t, c) | |||
| 119 | + | c.mapWindow(ports.WindowMapped{ID: 2}) | |||
| 120 | + | s := indicatorScene(t, c) | |||
| 121 | + | if len(sc.rects) != 0 || c.animating() { | |||
| 122 | + | t.Fatal("a map started a motion with animations off") | |||
| 123 | + | } | |||
| 124 | + | for _, w := range s.Windows { | |||
| 125 | + | if w.Fade != 0 || w.Zoom != 0 || w.Rect != settledOf(t, sc, w.ID).Rect { | |||
| 126 | + | t.Fatalf("window %d not at its settled rect: %+v", w.ID, w) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // A window that is not shown tiled or floating on screen does not appear: | |||
| 132 | + | // fullscreen, hidden behind a fullscreen game, with a session lock or with | |||
| 133 | + | // the overview open. Neighbours stay put as well in the last two. | |||
| 134 | + | func TestMapExceptions(t *testing.T) { | |||
| 135 | + | t.Run("fullscreen", func(t *testing.T) { | |||
| 136 | + | c, ic, _ := fadeCore(t) | |||
| 137 | + | c.cfg.Animations.On = true | |||
| 138 | + | sc := c.cur() | |||
| 139 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |||
| 140 | + | c.stopAnimations() // the first window's own entrance | |||
| 141 | + | sc.mon.SetFullscreen(1, true) | |||
| 142 | + | c.appearMapped(1, ic.now) | |||
| 143 | + | indicatorScene(t, c) | |||
| 144 | + | if len(sc.rects) != 0 || c.animating() { | |||
| 145 | + | t.Fatalf("a fullscreen window appeared: %+v", sc.rects) | |||
| 146 | + | } | |||
| 147 | + | }) | |||
| 148 | + | t.Run("behind a fullscreen game", func(t *testing.T) { | |||
| 149 | + | c, _, _ := fadeCore(t) | |||
| 150 | + | c.cfg.Animations.On = true | |||
| 151 | + | sc := c.cur() | |||
| 152 | + | sc.mon.Current().Overflow = OverflowFixed | |||
| 153 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |||
| 154 | + | c.stopAnimations() // the first window's own entrance | |||
| 155 | + | sc.mon.SetFullscreen(1, true) | |||
| 156 | + | indicatorScene(t, c) | |||
| 157 | + | c.mapWindow(ports.WindowMapped{ID: 2}) | |||
| 158 | + | if s := indicatorScene(t, c); len(sc.rects) != 0 || sceneWindow(t, s, 2).Fade != 0 { | |||
| 159 | + | t.Fatalf("a window mapped behind a fullscreen one appeared: %+v", sc.rects) | |||
| 160 | + | } | |||
| 161 | + | }) | |||
| 162 | + | t.Run("session lock", func(t *testing.T) { | |||
| 163 | + | c, _, _ := fadeCore(t) | |||
| 164 | + | c.cfg.Animations.On = true | |||
| 165 | + | sc := c.cur() | |||
| 166 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |||
| 167 | + | c.stopAnimations() // the first window's own entrance | |||
| 168 | + | c.security = ports.SecurityState{Generation: 1, Protected: true} | |||
| 169 | + | c.mapWindow(ports.WindowMapped{ID: 2}) | |||
| 170 | + | if len(sc.rects) != 0 || c.animating() { | |||
| 171 | + | t.Fatalf("a window mapped under a session lock animated: %+v", sc.rects) | |||
| 172 | + | } | |||
| 173 | + | }) | |||
| 174 | + | t.Run("overview open", func(t *testing.T) { | |||
| 175 | + | c, _, _ := fadeCore(t) | |||
| 176 | + | c.cfg.Animations.On = true | |||
| 177 | + | sc := c.cur() | |||
| 178 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |||
| 179 | + | c.stopAnimations() // the first window's own entrance | |||
| 180 | + | sc.mon.ToggleOverview() | |||
| 181 | + | c.mapWindow(ports.WindowMapped{ID: 2}) | |||
| 182 | + | if len(sc.rects) != 0 { | |||
| 183 | + | t.Fatalf("a window mapped in the overview animated: %+v", sc.rects) | |||
| 184 | + | } | |||
| 185 | + | }) | |||
| 186 | + | t.Run("map again", func(t *testing.T) { | |||
| 187 | + | c, ic, _ := fadeCore(t) | |||
| 188 | + | c.cfg.Animations.On = true | |||
| 189 | + | sc := c.cur() | |||
| 190 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |||
| 191 | + | c.stopAnimations() // the first window's own entrance | |||
| 192 | + | indicatorScene(t, c) | |||
| 193 | + | ic.now = ic.now.Add(5 * time.Second) | |||
| 194 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |||
| 195 | + | c.stopAnimations() // the first window's own entrance | |||
| 196 | + | if len(sc.rects) != 0 { | |||
| 197 | + | t.Fatalf("a repeated map restarted the entrance: %+v", sc.rects) | |||
| 198 | + | } | |||
| 199 | + | }) | |||
| 200 | + | } | |||
M internal/core/workspace_size_test.go +52 -20
| @@ -46,18 +46,26 @@ func within(r, o ports.Rect) bool { | |||
| 46 | 46 | } | |
| 47 | 47 | ||
| 48 | 48 | func TestWorkspaceSizeInheritsByDefault(t *testing.T) { | |
| 49 | - | r := startMulti(t, func(c *ports.Config) { c.Layout.Gaps = 0 }, ultrawide) | |
| 50 | - | set := r.mapWindow(t, 1) | |
| 51 | - | if set[0].WorkspaceClip != (ports.Rect{}) || set[0].Windows[0].Rect != (ports.Rect{W: 3440, H: 1440}) { | |
| 52 | - | t.Fatalf("%+v", set[0]) | |
| 53 | - | } | |
| 49 | + | both(t, func(t *testing.T, animated bool) { | |
| 50 | + | r := startLanding(t, animated, func(c *ports.Config) { c.Layout.Gaps = 0 }, ultrawide) | |
| 51 | + | set := r.mapLanded(t, 1) | |
| 52 | + | if set[0].WorkspaceClip != (ports.Rect{}) || set[0].Windows[0].Rect != (ports.Rect{W: 3440, H: 1440}) { | |
| 53 | + | t.Fatalf("%+v", set[0]) | |
| 54 | + | } | |
| 55 | + | }) | |
| 54 | 56 | } | |
| 55 | 57 | ||
| 56 | 58 | func TestWorkspaceSizeScrollColumnsStayInFrame(t *testing.T) { | |
| 57 | - | r, _ := sizedRig(t, "scroll", nil) | |
| 58 | - | r.mapWindow(t, 1) | |
| 59 | - | r.mapWindow(t, 2) | |
| 60 | - | s := r.mapWindow(t, 3)[0] | |
| 59 | + | both(t, func(t *testing.T, animated bool) { | |
| 60 | + | r, _ := sizedLanding(t, animated, "scroll", nil) | |
| 61 | + | workspaceSizeScrollColumnsStayInFrame(t, r) | |
| 62 | + | }) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func workspaceSizeScrollColumnsStayInFrame(t *testing.T, r *landRig) { | |
| 66 | + | r.mapLanded(t, 1) | |
| 67 | + | r.mapLanded(t, 2) | |
| 68 | + | s := r.mapLanded(t, 3)[0] | |
| 61 | 69 | if s.WorkspaceClip != frame || s.OutputWidth != 3440 || s.OutputHeight != 1440 { | |
| 62 | 70 | t.Fatalf("clip %+v output %dx%d", s.WorkspaceClip, s.OutputWidth, s.OutputHeight) | |
| 63 | 71 | } | |
| @@ -77,9 +85,15 @@ func TestWorkspaceSizeScrollColumnsStayInFrame(t *testing.T) { | |||
| 77 | 85 | } | |
| 78 | 86 | ||
| 79 | 87 | func TestWorkspaceSizeFixedColumnsFillFrame(t *testing.T) { | |
| 80 | - | r, _ := sizedRig(t, "fixed", nil) | |
| 81 | - | r.mapWindow(t, 1) | |
| 82 | - | s := r.mapWindow(t, 2)[0] | |
| 88 | + | both(t, func(t *testing.T, animated bool) { | |
| 89 | + | r, _ := sizedLanding(t, animated, "fixed", nil) | |
| 90 | + | workspaceSizeFixedColumnsFillFrame(t, r) | |
| 91 | + | }) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func workspaceSizeFixedColumnsFillFrame(t *testing.T, r *landRig) { | |
| 95 | + | r.mapLanded(t, 1) | |
| 96 | + | s := r.mapLanded(t, 2)[0] | |
| 83 | 97 | for i, want := range []ports.Rect{{X: 760, Y: 180, W: 960, H: 1080}, {X: 1720, Y: 180, W: 960, H: 1080}} { | |
| 84 | 98 | if s.Windows[i].Rect != want { | |
| 85 | 99 | t.Fatalf("%d: %+v", i, s.Windows[i]) | |
| @@ -149,8 +163,14 @@ func TestWorkspaceSizeFloatsContained(t *testing.T) { | |||
| 149 | 163 | } | |
| 150 | 164 | ||
| 151 | 165 | func TestWorkspaceSizeReloadAndRemoval(t *testing.T) { | |
| 152 | - | r, _ := sizedRig(t, "scroll", nil) | |
| 153 | - | r.mapWindow(t, 1) | |
| 166 | + | both(t, func(t *testing.T, animated bool) { | |
| 167 | + | r, _ := sizedLanding(t, animated, "scroll", nil) | |
| 168 | + | workspaceSizeReloadAndRemoval(t, r) | |
| 169 | + | }) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func workspaceSizeReloadAndRemoval(t *testing.T, r *landRig) { | |
| 173 | + | r.mapLanded(t, 1) | |
| 154 | 174 | cfg := r.cfg | |
| 155 | 175 | cfg.Workspaces = []ports.WorkspaceConfig{{Name: "focus", Size: [2]int{1000, 600}}} | |
| 156 | 176 | r.reload <- ports.ConfigChanged{Config: cfg} | |
| @@ -178,28 +198,40 @@ func TestWorkspaceSizeReloadAndRemoval(t *testing.T) { | |||
| 178 | 198 | } | |
| 179 | 199 | ||
| 180 | 200 | func TestWorkspaceSizeFollowsOutputResize(t *testing.T) { | |
| 181 | - | r, _ := sizedRig(t, "scroll", nil) | |
| 182 | - | r.mapWindow(t, 1) | |
| 201 | + | both(t, func(t *testing.T, animated bool) { | |
| 202 | + | r, _ := sizedLanding(t, animated, "scroll", nil) | |
| 203 | + | workspaceSizeFollowsOutputResize(t, r) | |
| 204 | + | }) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | func workspaceSizeFollowsOutputResize(t *testing.T, r *landRig) { | |
| 208 | + | r.mapLanded(t, 1) | |
| 183 | 209 | smaller := ultrawide | |
| 184 | 210 | smaller.Width, smaller.Height = 1600, 900 | |
| 185 | - | s := r.plug(t, smaller)[0] | |
| 211 | + | s := r.land(r.plug(t, smaller))[0] | |
| 186 | 212 | // 1920x1080 no longer fits: it clamps to the monitor. | |
| 187 | 213 | if s.WorkspaceClip != (ports.Rect{}) || s.Windows[0].Rect != (ports.Rect{W: 1600, H: 900}) { | |
| 188 | 214 | t.Fatalf("%+v %+v", s.WorkspaceClip, s.Windows[0]) | |
| 189 | 215 | } | |
| 190 | - | s = r.plug(t, ultrawide)[0] | |
| 216 | + | s = r.land(r.plug(t, ultrawide))[0] | |
| 191 | 217 | if s.WorkspaceClip != frame || s.Windows[0].Rect != frame { | |
| 192 | 218 | t.Fatalf("%+v %+v", s.WorkspaceClip, s.Windows[0]) | |
| 193 | 219 | } | |
| 194 | 220 | } | |
| 195 | 221 | ||
| 196 | 222 | func TestWorkspaceSizeReservedZoneIntersectsFrame(t *testing.T) { | |
| 197 | - | r, _ := sizedRig(t, "scroll", nil) | |
| 223 | + | both(t, func(t *testing.T, animated bool) { | |
| 224 | + | r, _ := sizedLanding(t, animated, "scroll", nil) | |
| 225 | + | workspaceSizeReservedZoneIntersectsFrame(t, r) | |
| 226 | + | }) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func workspaceSizeReservedZoneIntersectsFrame(t *testing.T, r *landRig) { | |
| 198 | 230 | // A bar inside the top of the monitor but above the frame reserves nothing of it. | |
| 199 | 231 | bar := ports.LayerSurface{ID: 9, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 100, ExclusiveZone: 100} | |
| 200 | 232 | r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}} | |
| 201 | 233 | receive(t, r.scenes) | |
| 202 | - | s := r.mapWindow(t, 1)[0] | |
| 234 | + | s := r.mapLanded(t, 1)[0] | |
| 203 | 235 | if s.Windows[0].Rect != frame { | |
| 204 | 236 | t.Fatalf("%+v", s.Windows[0]) | |
| 205 | 237 | } | |
| @@ -46,18 +46,26 @@ func within(r, o ports.Rect) bool { | |||||
| 46 | } | 46 | } | ||
| 47 | 47 | ||||
| 48 | func TestWorkspaceSizeInheritsByDefault(t *testing.T) { | 48 | func TestWorkspaceSizeInheritsByDefault(t *testing.T) { | ||
| 49 | - | r := startMulti(t, func(c *ports.Config) { c.Layout.Gaps = 0 }, ultrawide) | 49 | + | both(t, func(t *testing.T, animated bool) { |
| 50 | - | set := r.mapWindow(t, 1) | 50 | + | r := startLanding(t, animated, func(c *ports.Config) { c.Layout.Gaps = 0 }, ultrawide) |
| 51 | - | if set[0].WorkspaceClip != (ports.Rect{}) || set[0].Windows[0].Rect != (ports.Rect{W: 3440, H: 1440}) { | 51 | + | set := r.mapLanded(t, 1) |
| 52 | - | t.Fatalf("%+v", set[0]) | 52 | + | if set[0].WorkspaceClip != (ports.Rect{}) || set[0].Windows[0].Rect != (ports.Rect{W: 3440, H: 1440}) { |
| 53 | - | } | 53 | + | t.Fatalf("%+v", set[0]) |
| 54 | + | } | |||
| 55 | + | }) | |||
| 54 | } | 56 | } | ||
| 55 | 57 | ||||
| 56 | func TestWorkspaceSizeScrollColumnsStayInFrame(t *testing.T) { | 58 | func TestWorkspaceSizeScrollColumnsStayInFrame(t *testing.T) { | ||
| 57 | - | r, _ := sizedRig(t, "scroll", nil) | 59 | + | both(t, func(t *testing.T, animated bool) { |
| 58 | - | r.mapWindow(t, 1) | 60 | + | r, _ := sizedLanding(t, animated, "scroll", nil) |
| 59 | - | r.mapWindow(t, 2) | 61 | + | workspaceSizeScrollColumnsStayInFrame(t, r) |
| 60 | - | s := r.mapWindow(t, 3)[0] | 62 | + | }) |
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func workspaceSizeScrollColumnsStayInFrame(t *testing.T, r *landRig) { | |||
| 66 | + | r.mapLanded(t, 1) | |||
| 67 | + | r.mapLanded(t, 2) | |||
| 68 | + | s := r.mapLanded(t, 3)[0] | |||
| 61 | if s.WorkspaceClip != frame || s.OutputWidth != 3440 || s.OutputHeight != 1440 { | 69 | if s.WorkspaceClip != frame || s.OutputWidth != 3440 || s.OutputHeight != 1440 { | ||
| 62 | t.Fatalf("clip %+v output %dx%d", s.WorkspaceClip, s.OutputWidth, s.OutputHeight) | 70 | t.Fatalf("clip %+v output %dx%d", s.WorkspaceClip, s.OutputWidth, s.OutputHeight) | ||
| 63 | } | 71 | } | ||
| @@ -77,9 +85,15 @@ func TestWorkspaceSizeScrollColumnsStayInFrame(t *testing.T) { | |||||
| 77 | } | 85 | } | ||
| 78 | 86 | ||||
| 79 | func TestWorkspaceSizeFixedColumnsFillFrame(t *testing.T) { | 87 | func TestWorkspaceSizeFixedColumnsFillFrame(t *testing.T) { | ||
| 80 | - | r, _ := sizedRig(t, "fixed", nil) | 88 | + | both(t, func(t *testing.T, animated bool) { |
| 81 | - | r.mapWindow(t, 1) | 89 | + | r, _ := sizedLanding(t, animated, "fixed", nil) |
| 82 | - | s := r.mapWindow(t, 2)[0] | 90 | + | workspaceSizeFixedColumnsFillFrame(t, r) |
| 91 | + | }) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func workspaceSizeFixedColumnsFillFrame(t *testing.T, r *landRig) { | |||
| 95 | + | r.mapLanded(t, 1) | |||
| 96 | + | s := r.mapLanded(t, 2)[0] | |||
| 83 | for i, want := range []ports.Rect{{X: 760, Y: 180, W: 960, H: 1080}, {X: 1720, Y: 180, W: 960, H: 1080}} { | 97 | for i, want := range []ports.Rect{{X: 760, Y: 180, W: 960, H: 1080}, {X: 1720, Y: 180, W: 960, H: 1080}} { | ||
| 84 | if s.Windows[i].Rect != want { | 98 | if s.Windows[i].Rect != want { | ||
| 85 | t.Fatalf("%d: %+v", i, s.Windows[i]) | 99 | t.Fatalf("%d: %+v", i, s.Windows[i]) | ||
| @@ -149,8 +163,14 @@ func TestWorkspaceSizeFloatsContained(t *testing.T) { | |||||
| 149 | } | 163 | } | ||
| 150 | 164 | ||||
| 151 | func TestWorkspaceSizeReloadAndRemoval(t *testing.T) { | 165 | func TestWorkspaceSizeReloadAndRemoval(t *testing.T) { | ||
| 152 | - | r, _ := sizedRig(t, "scroll", nil) | 166 | + | both(t, func(t *testing.T, animated bool) { |
| 153 | - | r.mapWindow(t, 1) | 167 | + | r, _ := sizedLanding(t, animated, "scroll", nil) |
| 168 | + | workspaceSizeReloadAndRemoval(t, r) | |||
| 169 | + | }) | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func workspaceSizeReloadAndRemoval(t *testing.T, r *landRig) { | |||
| 173 | + | r.mapLanded(t, 1) | |||
| 154 | cfg := r.cfg | 174 | cfg := r.cfg | ||
| 155 | cfg.Workspaces = []ports.WorkspaceConfig{{Name: "focus", Size: [2]int{1000, 600}}} | 175 | cfg.Workspaces = []ports.WorkspaceConfig{{Name: "focus", Size: [2]int{1000, 600}}} | ||
| 156 | r.reload <- ports.ConfigChanged{Config: cfg} | 176 | r.reload <- ports.ConfigChanged{Config: cfg} | ||
| @@ -178,28 +198,40 @@ func TestWorkspaceSizeReloadAndRemoval(t *testing.T) { | |||||
| 178 | } | 198 | } | ||
| 179 | 199 | ||||
| 180 | func TestWorkspaceSizeFollowsOutputResize(t *testing.T) { | 200 | func TestWorkspaceSizeFollowsOutputResize(t *testing.T) { | ||
| 181 | - | r, _ := sizedRig(t, "scroll", nil) | 201 | + | both(t, func(t *testing.T, animated bool) { |
| 182 | - | r.mapWindow(t, 1) | 202 | + | r, _ := sizedLanding(t, animated, "scroll", nil) |
| 203 | + | workspaceSizeFollowsOutputResize(t, r) | |||
| 204 | + | }) | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | func workspaceSizeFollowsOutputResize(t *testing.T, r *landRig) { | |||
| 208 | + | r.mapLanded(t, 1) | |||
| 183 | smaller := ultrawide | 209 | smaller := ultrawide | ||
| 184 | smaller.Width, smaller.Height = 1600, 900 | 210 | smaller.Width, smaller.Height = 1600, 900 | ||
| 185 | - | s := r.plug(t, smaller)[0] | 211 | + | s := r.land(r.plug(t, smaller))[0] |
| 186 | // 1920x1080 no longer fits: it clamps to the monitor. | 212 | // 1920x1080 no longer fits: it clamps to the monitor. | ||
| 187 | if s.WorkspaceClip != (ports.Rect{}) || s.Windows[0].Rect != (ports.Rect{W: 1600, H: 900}) { | 213 | if s.WorkspaceClip != (ports.Rect{}) || s.Windows[0].Rect != (ports.Rect{W: 1600, H: 900}) { | ||
| 188 | t.Fatalf("%+v %+v", s.WorkspaceClip, s.Windows[0]) | 214 | t.Fatalf("%+v %+v", s.WorkspaceClip, s.Windows[0]) | ||
| 189 | } | 215 | } | ||
| 190 | - | s = r.plug(t, ultrawide)[0] | 216 | + | s = r.land(r.plug(t, ultrawide))[0] |
| 191 | if s.WorkspaceClip != frame || s.Windows[0].Rect != frame { | 217 | if s.WorkspaceClip != frame || s.Windows[0].Rect != frame { | ||
| 192 | t.Fatalf("%+v %+v", s.WorkspaceClip, s.Windows[0]) | 218 | t.Fatalf("%+v %+v", s.WorkspaceClip, s.Windows[0]) | ||
| 193 | } | 219 | } | ||
| 194 | } | 220 | } | ||
| 195 | 221 | ||||
| 196 | func TestWorkspaceSizeReservedZoneIntersectsFrame(t *testing.T) { | 222 | func TestWorkspaceSizeReservedZoneIntersectsFrame(t *testing.T) { | ||
| 197 | - | r, _ := sizedRig(t, "scroll", nil) | 223 | + | both(t, func(t *testing.T, animated bool) { |
| 224 | + | r, _ := sizedLanding(t, animated, "scroll", nil) | |||
| 225 | + | workspaceSizeReservedZoneIntersectsFrame(t, r) | |||
| 226 | + | }) | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | func workspaceSizeReservedZoneIntersectsFrame(t *testing.T, r *landRig) { | |||
| 198 | // A bar inside the top of the monitor but above the frame reserves nothing of it. | 230 | // A bar inside the top of the monitor but above the frame reserves nothing of it. | ||
| 199 | bar := ports.LayerSurface{ID: 9, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 100, ExclusiveZone: 100} | 231 | bar := ports.LayerSurface{ID: 9, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 100, ExclusiveZone: 100} | ||
| 200 | r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}} | 232 | r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}} | ||
| 201 | receive(t, r.scenes) | 233 | receive(t, r.scenes) | ||
| 202 | - | s := r.mapWindow(t, 1)[0] | 234 | + | s := r.mapLanded(t, 1)[0] |
| 203 | if s.Windows[0].Rect != frame { | 235 | if s.Windows[0].Rect != frame { | ||
| 204 | t.Fatalf("%+v", s.Windows[0]) | 236 | t.Fatalf("%+v", s.Windows[0]) | ||
| 205 | } | 237 | } | ||