detached 5a2f88e

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
101101 }
102102 }
103103
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) {
105111 if _, err := exec.LookPath("weston-simple-shm"); err != nil {
106112 t.Skip("weston-simple-shm not installed")
107113 }
@@ -109,7 +115,8 @@ func TestHeadlessSpawnClose(t *testing.T) {
109115 t.Setenv("XDG_STATE_HOME", t.TempDir())
110116 cfg := config.Defaults()
111117 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)
113120 ctx, cancel := context.WithCancel(context.Background())
114121 defer cancel()
115122 inputReady := make(chan chan<- ports.InputEvent, 1)
@@ -144,6 +151,12 @@ func TestHeadlessSpawnClose(t *testing.T) {
144151 case set := <-scenes:
145152 s := set[0]
146153 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+ }
147160 // A lone column fills the 1920 output (default zero gaps).
148161 if count == 1 && s.Windows[0].Rect.W != 1920 {
149162 t.Errorf("width = %d, want 1920", s.Windows[0].Rect.W)
@@ -214,7 +227,13 @@ func TestHeadlessTyping(t *testing.T) {
214227 }
215228 }
216229
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) {
218237 if _, err := exec.LookPath("foot"); err != nil {
219238 t.Skip("foot unavailable")
220239 }
@@ -225,7 +244,24 @@ func TestHeadlessPointerClickFocus(t *testing.T) {
225244 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}
226245 // Pulse frames would fill the scene tap before the click.
227246 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.
228250 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+ }()
229265 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})
230266 if err != nil {
231267 if strings.Contains(strings.ToLower(err.Error()), "vulkan") {
@@ -233,9 +269,14 @@ func TestHeadlessPointerClickFocus(t *testing.T) {
233269 }
234270 t.Fatal(err)
235271 }
236- two, focused := false, false
272+ close(stop)
273+ <-collected
237274 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]
239280 if len(s.Windows) == 2 {
240281 two = true
241282 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.FocusAnimationOff246 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, false272+ 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 = true281 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 {
11051105 case ports.InputRegionChanged:
11061106 c.windows.setRegion(v)
11071107 case ports.WindowMapped:
1108- c.windows.mapped(v, c.now())
1109- c.placement.place(c, v)
1108+ c.mapWindow(v)
11101109 case ports.WindowResized:
11111110 if _, w := c.screenOf(v.ID); w != nil {
11121111 w.ResizeFloating(v.ID, v.Width, v.Height)
@@ -1186,6 +1185,11 @@ func (c *Core) Run(ctx context.Context) error {
11861185 }
11871186 if s, _ := c.screenOf(v.ID); s != nil {
11881187 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+ }
11891193 }
11901194 case ports.WorkspaceActivate:
11911195 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 (
55 "errors"
66 "reflect"
77 "slices"
8- "sync/atomic"
98 "testing"
109 "time"
1110
1211 "github.com/bnema/neferwl/internal/adapters/config"
1312 "github.com/bnema/neferwl/internal/core"
14- portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
1513 "github.com/bnema/neferwl/internal/ports"
1614 )
1715
@@ -46,7 +44,56 @@ func command(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand {
4644 }
4745 }
4846
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) {
5097 cfg := altCmdDefaults()
5198 cfg.Border.Width = 0
5299 cfg.Layout.Gaps = 8
@@ -58,7 +105,9 @@ func TestOwner(t *testing.T) {
58105 spawn := make(chan ports.SpawnRequest, 8)
59106 scenes := make(chan []ports.Scene, 1)
60107 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)
62111 if err != nil {
63112 t.Fatal(err)
64113 }
@@ -72,7 +121,7 @@ func TestOwner(t *testing.T) {
72121 t.Fatal(s)
73122 }
74123 client <- ports.WindowMapped{ID: 1}
75- s = scene(t, scenes)
124+ s = l.one(scene(t, scenes))
76125 // A single column fills the usable width.
77126 if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || s.Windows[0].Inset != 0 {
78127 t.Fatal(s)
@@ -117,7 +166,7 @@ func TestOwner(t *testing.T) {
117166 }
118167 cfg.Layout.Gaps = 4
119168 reload <- ports.ConfigChanged{Config: cfg}
120- s = scene(t, scenes)
169+ s = l.one(scene(t, scenes))
121170 if s.Windows[0].Rect.W != 92 {
122171 t.Fatal(s)
123172 }
@@ -283,7 +332,9 @@ func TestPointerFocusAndGrab(t *testing.T) {
283332 }
284333 }
285334
286-func TestBorderInset(t *testing.T) {
335+func TestBorderInset(t *testing.T) { both(t, borderInset) }
336+
337+func borderInset(t *testing.T, animated bool) {
287338 cfg := config.Defaults()
288339 cfg.Border.Width = 2
289340 client := make(chan ports.ClientEvent, 8)
@@ -291,7 +342,9 @@ func TestBorderInset(t *testing.T) {
291342 output := make(chan ports.OutputEvent, 8)
292343 commands := make(chan ports.ClientCommand, 64)
293344 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)
295348 if err != nil {
296349 t.Fatal(err)
297350 }
@@ -302,14 +355,14 @@ func TestBorderInset(t *testing.T) {
302355 scene(t, scenes)
303356 // Alone, the window is borderless and gets the full rect.
304357 client <- ports.WindowMapped{ID: 1}
305- r := scene(t, scenes).Windows[0].Rect
358+ r := l.one(scene(t, scenes)).Windows[0].Rect
306359 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true, Output: "OUT-1", Visible: true}) {
307360 t.Fatalf("%v for outer %v", v, r)
308361 }
309362 // With a second column, the left window owns the shared separator: its
310363 // client is 2px narrower on the right; the right window keeps its rect.
311364 client <- ports.WindowMapped{ID: 2}
312- s := scene(t, scenes)
365+ s := l.one(scene(t, scenes))
313366 r = s.Windows[1].Rect
314367 if s.Windows[0].Inset != ports.SideRight || s.Windows[1].Inset != 0 || len(s.Separators) == 0 {
315368 t.Fatal(s)
@@ -556,7 +609,9 @@ func TestShiftReleasedFirst(t *testing.T) {
556609 }
557610 }
558611
559-func TestOutputScale(t *testing.T) {
612+func TestOutputScale(t *testing.T) { both(t, outputScale) }
613+
614+func outputScale(t *testing.T, animated bool) {
560615 cfg := config.Defaults()
561616 cfg.Border.Width = 0
562617 cfg.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 2}}
@@ -568,7 +623,9 @@ func TestOutputScale(t *testing.T) {
568623 reload := make(chan ports.ConfigChanged, 1)
569624 commands := make(chan ports.ClientCommand, 64)
570625 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)
572629 if err != nil {
573630 t.Fatal(err)
574631 }
@@ -585,7 +642,7 @@ func TestOutputScale(t *testing.T) {
585642 t.Fatal(v)
586643 }
587644 client <- ports.WindowMapped{ID: 1}
588- scene(t, scenes)
645+ l.one(scene(t, scenes))
589646 if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 2560, Height: 1080, Activated: true, Output: "DP-2", Visible: true}) {
590647 t.Fatal(v)
591648 }
@@ -594,7 +651,13 @@ func TestOutputScale(t *testing.T) {
594651 }
595652 // The pointer arrives in global logical pixels.
596653 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}) {
598661 t.Fatal(v)
599662 }
600663 // Cmd+- steps down through the clean scales of 5120x2160: 2 → 5/3.
@@ -679,7 +742,9 @@ func TestPointerWarp(t *testing.T) {
679742 }
680743 }
681744
682-func TestPointerConstraint(t *testing.T) {
745+func TestPointerConstraint(t *testing.T) { both(t, pointerConstraint) }
746+
747+func pointerConstraint(t *testing.T, animated bool) {
683748 cfg := config.Defaults()
684749 cfg.Border.Width = 0
685750 cfg.Layout.Gaps = 0
@@ -689,7 +754,9 @@ func TestPointerConstraint(t *testing.T) {
689754 commands := make(chan ports.ClientCommand, 64)
690755 scenes := make(chan []ports.Scene, 1)
691756 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)
693760 if err != nil {
694761 t.Fatal(err)
695762 }
@@ -703,7 +770,7 @@ func TestPointerConstraint(t *testing.T) {
703770 input <- ports.PointerMotion{X: 150, Y: 40}
704771 scene(t, scenes) // the pointer moves the focus to B
705772 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
707774 // The confine region is window-local and clipped to the window.
708775 client <- ports.PointerConstrained{ID: 1, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 10, Y: 10, W: 1000, H: 20}}}
709776 scene(t, scenes)
@@ -887,7 +954,9 @@ func TestDialogOverFullscreenHit(t *testing.T) {
887954 // A window that asks for fullscreen as it maps (Wine at a remembered
888955 // monitor size) stays in its column: the windows it opens next are seen.
889956 // 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) {
891960 cfg := config.Defaults()
892961 cfg.Layout.Overflow = "fixed"
893962 cfg.Focus.Animation = ports.FocusAnimationOff
@@ -895,11 +964,15 @@ func TestFullscreenAtMapIgnored(t *testing.T) {
895964 output := make(chan ports.OutputEvent, 8)
896965 commands := make(chan ports.ClientCommand, 64)
897966 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)
903976 if err != nil {
904977 t.Fatal(err)
905978 }
@@ -923,7 +996,10 @@ func TestFullscreenAtMapIgnored(t *testing.T) {
923996 t.Fatalf("startup fullscreen applied: %+v", s.Windows)
924997 }
925998 }
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)
9271003 // Only the focused window may cover the screen (ADR 011).
9281004 client <- ports.WindowUnmapped{ID: 3}
9291005 scene(t, scenes)
@@ -968,16 +1044,21 @@ func TestFixedFullscreenArrivalWaits(t *testing.T) {
9681044 }
9691045
9701046 // 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) {
9721050 cfg := config.Defaults()
9731051 cfg.Focus.Animation = ports.FocusAnimationOff
9741052 client := make(chan ports.ClientEvent, 8)
9751053 output := make(chan ports.OutputEvent, 8)
9761054 commands := make(chan ports.ClientCommand, 64)
9771055 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)
9811062 if err != nil {
9821063 t.Fatal(err)
9831064 }
@@ -987,7 +1068,7 @@ func TestExternalFullscreenAtMapApplies(t *testing.T) {
9871068 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
9881069 scene(t, scenes)
9891070 client <- ports.WindowMapped{ID: 1}
990- scene(t, scenes)
1071+ l.one(scene(t, scenes))
9911072 client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true}
9921073 s := scene(t, scenes)
9931074 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 = 098 cfg.Border.Width = 0
52 cfg.Layout.Gaps = 899 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 = 4167 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 = 2339 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].Rect358+ 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: its362 // 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].Rect366 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 = 0616 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 = 0749 cfg.Border.Width = 0
685 cfg.Layout.Gaps = 0750 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 B771 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].Rect773+ 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 remembered954 // 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.FocusAnimationOff962 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.Int64968+ 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.FocusAnimationOff1051 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) {
127127 }
128128
129129 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)
136142 var set []ports.Scene
137143 for id := ports.WindowID(2); id <= 6; id++ {
138- set = r.mapWindow(t, id)
144+ set = r.mapLanded(t, id)
139145 }
140146 // DP-2: four columns of 100; the fifth window splits the last one
141147 // (fixed) instead of scrolling.
@@ -166,26 +172,32 @@ func widths(set []ports.Scene, output string) []int {
166172 }
167173
168174 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}) {
175187 t.Fatal(got)
176188 }
177189 // Another monitor on DP-2: the key rule no longer applies.
178190 other := right
179191 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}) {
181193 t.Fatal(got)
182194 }
183195 // Back to the first monitor, then a reload drops the rule.
184- r.plug(t, right)
196+ r.land(r.plug(t, right))
185197 cfg := r.cfg
186198 cfg.Layout.Outputs = nil
187199 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}) {
189201 t.Fatal(got)
190202 }
191203 }
@@ -301,10 +313,15 @@ func TestFocusColumnCrossesOutputs(t *testing.T) {
301313 }
302314
303315 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)
308325 for len(r.commands) > 0 {
309326 <-r.commands
310327 }
@@ -1186,11 +1203,16 @@ func TestDialogFocusStaysOnMonitor(t *testing.T) {
11861203 // A click on a tile behind the shown stash hides the stash and focuses
11871204 // the tile.
11881205 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
11941216 r.input <- ports.PointerMotion{X: 5, Y: 50}
11951217 r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
11961218 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) {
15141536 // A reload that shrinks the layout under a still pointer brings it back onto
15151537 // an output.
15161538 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)
15231550 // DP-2 spans x 200..600: (550, 50) is (350, 50) on window 2.
15241551 for len(r.commands) > 0 {
15251552 <-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 = 2131+ 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.Scene142 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 one146 // 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 = 1176+ 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 := right190 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.cfg197 cfg := r.cfg
186 cfg.Layout.Outputs = nil198 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.commands326 <-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 focuses1203 // 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 11210+}
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 onto1536 // 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.commands1552 <-r.commands
M internal/core/slots_test.go +53 -5
@@ -21,14 +21,30 @@ type slotRig struct {
2121 workspaces chan ports.Workspaces
2222 errs chan error
2323 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
2429 }
2530
2631 // startSlots runs core with a hidden "dev" workspace of two slots, toggled
2732 // by Alt+d, on a 100x80 output. One flag makes dev numbered, two also set
2833 // fixed overflow.
2934 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 {
3043 t.Helper()
3144 cfg := altCmdDefaults()
45+ if animated != nil {
46+ cfg.Animations.On = *animated
47+ }
3248 cfg.Border.Width = 0
3349 cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{
3450 {Index: 1, Width: "70%", Argv: []string{"code"}},
@@ -45,7 +61,17 @@ func startSlots(t *testing.T, numbered ...bool) *slotRig {
4561 }
4662 output := make(chan ports.OutputEvent, 1)
4763 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)
4975 if err != nil {
5076 t.Fatal(err)
5177 }
@@ -62,6 +88,22 @@ func startSlots(t *testing.T, numbered ...bool) *slotRig {
6288 return r
6389 }
6490
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+
65107 // token returns the SlotEnv value of a spawn request.
66108 func token(t *testing.T, req ports.SpawnRequest) string {
67109 t.Helper()
@@ -194,7 +236,10 @@ func TestSlotsSpawnAtStartAndFillInOrder(t *testing.T) {
194236 }
195237
196238 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) {
198243 // Wait for both windows: a one-window scene from before the unmap
199244 // would let the key below race it.
200245 _, foot := r.fill(t)
@@ -209,7 +254,7 @@ func TestSlotRefilledOnlyWhenShown(t *testing.T) {
209254 }
210255 r.client <- ports.WindowMapped{ID: 4, Slot: token(t, again)}
211256 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 {
213258 t.Fatal(got)
214259 }
215260 // Leaving and showing dev again with full slots spawns nothing.
@@ -344,7 +389,10 @@ func TestSlotOldTokenIsNormal(t *testing.T) {
344389 // A slot whose command never maps a window gets one show to do it; on the
345390 // next show it is respawned and its old token no longer fills it.
346391 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) {
348396 code, foot := receive(t, r.spawn), receive(t, r.spawn)
349397 r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
350398 sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
@@ -363,7 +411,7 @@ func TestPendingSlotRespawnedOnSecondShow(t *testing.T) {
363411 }
364412 r.client <- ports.WindowMapped{ID: 8, Slot: token(t, again)}
365413 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 {
367415 t.Fatalf("respawned window not in its slot: %v", got)
368416 }
369417 }
@@ -21,14 +21,30 @@ type slotRig struct {
21 workspaces chan ports.Workspaces21 workspaces chan ports.Workspaces
22 errs chan error22 errs chan error
23 cfg ports.Config23 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, toggled31 // 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 set32 // 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 = 048 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 r88 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 unmap243 // 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 the389 // 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) {
6363 r.clk.advance(d)
6464 }
6565
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+
6679 // begin starts a swipe; nothing moves until it picks an axis.
6780 func (r *swipeRig) begin() {
6881 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.Second81 r.at += time.Second
M internal/core/transition.go +43 -0
@@ -4,6 +4,8 @@ import (
44 "math"
55 "slices"
66 "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
79 )
810
911 // 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) {
295297 sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current()
296298 }
297299
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+
298341 // leave keeps drawing a window the action closed or hid, from its last
299342 // settled placement p: it fades out and shrinks to 90 % around its centre,
300343 // 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, a11 // 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 last341 // 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 that343 // 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 {
4646 }
4747
4848 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+ })
5456 }
5557
5658 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]
6169 if s.WorkspaceClip != frame || s.OutputWidth != 3440 || s.OutputHeight != 1440 {
6270 t.Fatalf("clip %+v output %dx%d", s.WorkspaceClip, s.OutputWidth, s.OutputHeight)
6371 }
@@ -77,9 +85,15 @@ func TestWorkspaceSizeScrollColumnsStayInFrame(t *testing.T) {
7785 }
7886
7987 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]
8397 for i, want := range []ports.Rect{{X: 760, Y: 180, W: 960, H: 1080}, {X: 1720, Y: 180, W: 960, H: 1080}} {
8498 if s.Windows[i].Rect != want {
8599 t.Fatalf("%d: %+v", i, s.Windows[i])
@@ -149,8 +163,14 @@ func TestWorkspaceSizeFloatsContained(t *testing.T) {
149163 }
150164
151165 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)
154174 cfg := r.cfg
155175 cfg.Workspaces = []ports.WorkspaceConfig{{Name: "focus", Size: [2]int{1000, 600}}}
156176 r.reload <- ports.ConfigChanged{Config: cfg}
@@ -178,28 +198,40 @@ func TestWorkspaceSizeReloadAndRemoval(t *testing.T) {
178198 }
179199
180200 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)
183209 smaller := ultrawide
184210 smaller.Width, smaller.Height = 1600, 900
185- s := r.plug(t, smaller)[0]
211+ s := r.land(r.plug(t, smaller))[0]
186212 // 1920x1080 no longer fits: it clamps to the monitor.
187213 if s.WorkspaceClip != (ports.Rect{}) || s.Windows[0].Rect != (ports.Rect{W: 1600, H: 900}) {
188214 t.Fatalf("%+v %+v", s.WorkspaceClip, s.Windows[0])
189215 }
190- s = r.plug(t, ultrawide)[0]
216+ s = r.land(r.plug(t, ultrawide))[0]
191217 if s.WorkspaceClip != frame || s.Windows[0].Rect != frame {
192218 t.Fatalf("%+v %+v", s.WorkspaceClip, s.Windows[0])
193219 }
194220 }
195221
196222 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) {
198230 // A bar inside the top of the monitor but above the frame reserves nothing of it.
199231 bar := ports.LayerSurface{ID: 9, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 100, ExclusiveZone: 100}
200232 r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
201233 receive(t, r.scenes)
202- s := r.mapWindow(t, 1)[0]
234+ s := r.mapLanded(t, 1)[0]
203235 if s.Windows[0].Rect != frame {
204236 t.Fatalf("%+v", s.Windows[0])
205237 }
@@ -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.cfg174 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 := ultrawide209 smaller := ultrawide
184 smaller.Width, smaller.Height = 1600, 900210 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 }

Keyboard shortcuts

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

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

Branches 23

Tags 10

↑↓ moveenter openesc close