fix(core): follow a float's first commit in its entrance, drop it only for a fullscreen that applied
A float maps with no size (it commits after the map), so its entrance was laid out from the default rect and broke the aspect once WindowResized moved the settled rect. WindowResized now scales the running entrance's offsets and springs by the settled size ratio, keeping progress and speed. A fullscreen request that was ignored (grace after the map) no longer drops the entrance. The headless collector stops in a cleanup.
- commit
- f0176e656ea4a9550928d41021705dcf5504a034
- author
- brice · · 2026-10-04 23:07 UTC
- parent
- 5a2f88e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +148 -8
M internal/app/app_test.go +7 -2
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "os/exec" | |
| 10 | 10 | "path/filepath" | |
| 11 | 11 | "strings" | |
| 12 | + | "sync" | |
| 12 | 13 | "testing" | |
| 13 | 14 | "time" | |
| 14 | 15 | ||
| @@ -262,6 +263,11 @@ func headlessPointerClickFocus(t *testing.T, animated bool) { | |||
| 262 | 263 | } | |
| 263 | 264 | } | |
| 264 | 265 | }() | |
| 266 | + | // Stopped before the scenes are read, or at the latest when the test | |
| 267 | + | // ends (an early Fatal or Skip). | |
| 268 | + | var halt sync.Once | |
| 269 | + | stopCollector := func() { halt.Do(func() { close(stop); <-collected }) } | |
| 270 | + | t.Cleanup(stopCollector) | |
| 265 | 271 | 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}) | |
| 266 | 272 | if err != nil { | |
| 267 | 273 | if strings.Contains(strings.ToLower(err.Error()), "vulkan") { | |
| @@ -269,8 +275,7 @@ func headlessPointerClickFocus(t *testing.T, animated bool) { | |||
| 269 | 275 | } | |
| 270 | 276 | t.Fatal(err) | |
| 271 | 277 | } | |
| 272 | - | close(stop) | |
| 273 | - | <-collected | |
| 278 | + | stopCollector() | |
| 274 | 279 | for len(scenes) > 0 { | |
| 275 | 280 | got = append(got, <-scenes) | |
| 276 | 281 | } | |
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "os/exec" | 9 | "os/exec" | ||
| 10 | "path/filepath" | 10 | "path/filepath" | ||
| 11 | "strings" | 11 | "strings" | ||
| 12 | + | "sync" | |||
| 12 | "testing" | 13 | "testing" | ||
| 13 | "time" | 14 | "time" | ||
| 14 | 15 | ||||
| @@ -262,6 +263,11 @@ func headlessPointerClickFocus(t *testing.T, animated bool) { | |||||
| 262 | } | 263 | } | ||
| 263 | } | 264 | } | ||
| 264 | }() | 265 | }() | ||
| 266 | + | // Stopped before the scenes are read, or at the latest when the test | |||
| 267 | + | // ends (an early Fatal or Skip). | |||
| 268 | + | var halt sync.Once | |||
| 269 | + | stopCollector := func() { halt.Do(func() { close(stop); <-collected }) } | |||
| 270 | + | t.Cleanup(stopCollector) | |||
| 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}) | 271 | 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}) | ||
| 266 | if err != nil { | 272 | if err != nil { | ||
| 267 | if strings.Contains(strings.ToLower(err.Error()), "vulkan") { | 273 | if strings.Contains(strings.ToLower(err.Error()), "vulkan") { | ||
| @@ -269,8 +275,7 @@ func headlessPointerClickFocus(t *testing.T, animated bool) { | |||||
| 269 | } | 275 | } | ||
| 270 | t.Fatal(err) | 276 | t.Fatal(err) | ||
| 271 | } | 277 | } | ||
| 272 | - | close(stop) | 278 | + | stopCollector() |
| 273 | - | <-collected | |||
| 274 | for len(scenes) > 0 { | 279 | for len(scenes) > 0 { | ||
| 275 | got = append(got, <-scenes) | 280 | got = append(got, <-scenes) | ||
| 276 | } | 281 | } | ||
M internal/core/core.go +6 -6
| @@ -1107,9 +1107,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1107 | 1107 | case ports.WindowMapped: | |
| 1108 | 1108 | c.mapWindow(v) | |
| 1109 | 1109 | case ports.WindowResized: | |
| 1110 | - | if _, w := c.screenOf(v.ID); w != nil { | |
| 1111 | - | w.ResizeFloating(v.ID, v.Width, v.Height) | |
| 1112 | - | } | |
| 1110 | + | c.resizeFloating(v) | |
| 1113 | 1111 | case ports.PopupRequest: | |
| 1114 | 1112 | if err := c.placePopup(ctx, v); err != nil { | |
| 1115 | 1113 | return nil | |
| @@ -1185,9 +1183,11 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1185 | 1183 | } | |
| 1186 | 1184 | if s, _ := c.screenOf(v.ID); s != nil { | |
| 1187 | 1185 | 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. | |
| 1186 | + | if w := s.mon.Current(); v.Fullscreen && w.fullscreen == v.ID { | |
| 1187 | + | // A taskbar or late fullscreen request that took | |
| 1188 | + | // effect: the window does not fade in, it keeps the | |
| 1189 | + | // direct scanout path. One that was ignored (the | |
| 1190 | + | // grace after the map) leaves the entrance running. | |
| 1191 | 1191 | delete(s.rects, v.ID) | |
| 1192 | 1192 | } | |
| 1193 | 1193 | } | |
| @@ -1107,9 +1107,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1107 | case ports.WindowMapped: | 1107 | case ports.WindowMapped: | ||
| 1108 | c.mapWindow(v) | 1108 | c.mapWindow(v) | ||
| 1109 | case ports.WindowResized: | 1109 | case ports.WindowResized: | ||
| 1110 | - | if _, w := c.screenOf(v.ID); w != nil { | 1110 | + | c.resizeFloating(v) |
| 1111 | - | w.ResizeFloating(v.ID, v.Width, v.Height) | |||
| 1112 | - | } | |||
| 1113 | case ports.PopupRequest: | 1111 | case ports.PopupRequest: | ||
| 1114 | if err := c.placePopup(ctx, v); err != nil { | 1112 | if err := c.placePopup(ctx, v); err != nil { | ||
| 1115 | return nil | 1113 | return nil | ||
| @@ -1185,9 +1183,11 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1185 | } | 1183 | } | ||
| 1186 | if s, _ := c.screenOf(v.ID); s != nil { | 1184 | if s, _ := c.screenOf(v.ID); s != nil { | ||
| 1187 | s.mon.SetFullscreen(v.ID, v.Fullscreen) | 1185 | s.mon.SetFullscreen(v.ID, v.Fullscreen) | ||
| 1188 | - | if v.Fullscreen { | 1186 | + | if w := s.mon.Current(); v.Fullscreen && w.fullscreen == v.ID { |
| 1189 | - | // A game asking for fullscreen right after its map | 1187 | + | // A taskbar or late fullscreen request that took |
| 1190 | - | // does not fade in: it keeps the direct scanout path. | 1188 | + | // effect: the window does not fade in, it keeps the |
| 1189 | + | // direct scanout path. One that was ignored (the | |||
| 1190 | + | // grace after the map) leaves the entrance running. | |||
| 1191 | delete(s.rects, v.ID) | 1191 | delete(s.rects, v.ID) | ||
| 1192 | } | 1192 | } | ||
| 1193 | } | 1193 | } | ||
M internal/core/map_animation_test.go +19 -0
| @@ -81,6 +81,25 @@ func TestMapFullscreenRequestDropsEntrance(t *testing.T) { | |||
| 81 | 81 | } | |
| 82 | 82 | } | |
| 83 | 83 | ||
| 84 | + | // A fullscreen request inside the grace after the map is ignored (Wine at a | |
| 85 | + | // remembered size): the entrance goes on. | |
| 86 | + | func TestMapFullscreenRequestWithinGraceKeepsEntrance(t *testing.T) { | |
| 87 | + | r := startSwipe(t, nil) | |
| 88 | + | s := r.multiRig.mapWindow(t, 1)[0] | |
| 89 | + | if windowOf(t, s, 1).Fade != 1 { | |
| 90 | + | t.Fatal("no entrance to keep") | |
| 91 | + | } | |
| 92 | + | r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true} | |
| 93 | + | // The flip is a barrier: the request is handled, a leftover scene dropped. | |
| 94 | + | set, ok := r.clk.flip(t, r.frames, r.scenes, 30*time.Millisecond, wide.Name) | |
| 95 | + | if !ok { | |
| 96 | + | t.Fatal("no frame: the entrance stopped") | |
| 97 | + | } | |
| 98 | + | if w := windowOf(t, set[0], 1); w.Fullscreen || !(w.Fade > 0 && w.Fade < 1) { | |
| 99 | + | t.Fatalf("ignored fullscreen request changed the window: %+v", w) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 84 | 103 | func windowOf(t *testing.T, s ports.Scene, id ports.WindowID) ports.SceneWindow { | |
| 85 | 104 | t.Helper() | |
| 86 | 105 | for _, w := range s.Windows { | |
| @@ -81,6 +81,25 @@ func TestMapFullscreenRequestDropsEntrance(t *testing.T) { | |||||
| 81 | } | 81 | } | ||
| 82 | } | 82 | } | ||
| 83 | 83 | ||||
| 84 | + | // A fullscreen request inside the grace after the map is ignored (Wine at a | |||
| 85 | + | // remembered size): the entrance goes on. | |||
| 86 | + | func TestMapFullscreenRequestWithinGraceKeepsEntrance(t *testing.T) { | |||
| 87 | + | r := startSwipe(t, nil) | |||
| 88 | + | s := r.multiRig.mapWindow(t, 1)[0] | |||
| 89 | + | if windowOf(t, s, 1).Fade != 1 { | |||
| 90 | + | t.Fatal("no entrance to keep") | |||
| 91 | + | } | |||
| 92 | + | r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true} | |||
| 93 | + | // The flip is a barrier: the request is handled, a leftover scene dropped. | |||
| 94 | + | set, ok := r.clk.flip(t, r.frames, r.scenes, 30*time.Millisecond, wide.Name) | |||
| 95 | + | if !ok { | |||
| 96 | + | t.Fatal("no frame: the entrance stopped") | |||
| 97 | + | } | |||
| 98 | + | if w := windowOf(t, set[0], 1); w.Fullscreen || !(w.Fade > 0 && w.Fade < 1) { | |||
| 99 | + | t.Fatalf("ignored fullscreen request changed the window: %+v", w) | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | ||||
| 84 | func windowOf(t *testing.T, s ports.Scene, id ports.WindowID) ports.SceneWindow { | 103 | func windowOf(t *testing.T, s ports.Scene, id ports.WindowID) ports.SceneWindow { | ||
| 85 | t.Helper() | 104 | t.Helper() | ||
| 86 | for _, w := range s.Windows { | 105 | for _, w := range s.Windows { | ||
M internal/core/motion.go +11 -0
| @@ -221,6 +221,17 @@ func (m motion) sampleAt(now time.Time) (pos, vel float64) { | |||
| 221 | 221 | return pos, m.v | |
| 222 | 222 | } | |
| 223 | 223 | ||
| 224 | + | // scale multiplies the motion's distance and speed by k: the spring is | |
| 225 | + | // linear in its start offset and speed, so the progress is unchanged. | |
| 226 | + | func (m *motion) scale(k float64) { | |
| 227 | + | if !m.on { | |
| 228 | + | return | |
| 229 | + | } | |
| 230 | + | m.spring.From *= k | |
| 231 | + | m.spring.Velocity *= k | |
| 232 | + | m.v *= k | |
| 233 | + | } | |
| 234 | + | ||
| 224 | 235 | // velocity is the speed of the last sample, in units per second. | |
| 225 | 236 | func (m motion) velocity() float64 { return m.v } | |
| 226 | 237 | ||
| @@ -221,6 +221,17 @@ func (m motion) sampleAt(now time.Time) (pos, vel float64) { | |||||
| 221 | return pos, m.v | 221 | return pos, m.v | ||
| 222 | } | 222 | } | ||
| 223 | 223 | ||||
| 224 | + | // scale multiplies the motion's distance and speed by k: the spring is | |||
| 225 | + | // linear in its start offset and speed, so the progress is unchanged. | |||
| 226 | + | func (m *motion) scale(k float64) { | |||
| 227 | + | if !m.on { | |||
| 228 | + | return | |||
| 229 | + | } | |||
| 230 | + | m.spring.From *= k | |||
| 231 | + | m.spring.Velocity *= k | |||
| 232 | + | m.v *= k | |||
| 233 | + | } | |||
| 234 | + | ||||
| 224 | // velocity is the speed of the last sample, in units per second. | 235 | // velocity is the speed of the last sample, in units per second. | ||
| 225 | func (m motion) velocity() float64 { return m.v } | 236 | func (m motion) velocity() float64 { return m.v } | ||
| 226 | 237 | ||||
M internal/core/transition.go +47 -0
| @@ -338,6 +338,53 @@ func (c *Core) appearMapped(id WindowID, now time.Time) { | |||
| 338 | 338 | } | |
| 339 | 339 | } | |
| 340 | 340 | ||
| 341 | + | // resizeFloating applies a floating window's committed size. A float maps | |
| 342 | + | // before its first commit, so core first lays it out at a default size; | |
| 343 | + | // when its entrance runs, the settled rect changing under it would break | |
| 344 | + | // the aspect of the 90 % rect. The entrance's rect offsets (and their | |
| 345 | + | // springs, which are linear in their start and speed) are scaled by the | |
| 346 | + | // settled size ratio: the progress and speed carry over, and the window | |
| 347 | + | // still grows from 90 % of its real size around its centre. | |
| 348 | + | func (c *Core) resizeFloating(v ports.WindowResized) { | |
| 349 | + | sc, w := c.screenOf(v.ID) | |
| 350 | + | if w == nil { | |
| 351 | + | return | |
| 352 | + | } | |
| 353 | + | rm, running := sc.rects[v.ID] | |
| 354 | + | running = running && rm.scale && !rm.leaving | |
| 355 | + | var old Rect | |
| 356 | + | if running { | |
| 357 | + | old = settledRect(sc.mon, v.ID) | |
| 358 | + | } | |
| 359 | + | w.ResizeFloating(v.ID, v.Width, v.Height) | |
| 360 | + | if !running { | |
| 361 | + | return | |
| 362 | + | } | |
| 363 | + | cur := settledRect(sc.mon, v.ID) | |
| 364 | + | if old.W <= 0 || old.H <= 0 || cur.W <= 0 || cur.H <= 0 || old == cur { | |
| 365 | + | return | |
| 366 | + | } | |
| 367 | + | kw, kh := float64(cur.W)/float64(old.W), float64(cur.H)/float64(old.H) | |
| 368 | + | rm.dx, rm.dw = rm.dx*kw, rm.dw*kw | |
| 369 | + | rm.dy, rm.dh = rm.dy*kh, rm.dh*kh | |
| 370 | + | rm.x.scale(kw) | |
| 371 | + | rm.w.scale(kw) | |
| 372 | + | rm.y.scale(kh) | |
| 373 | + | rm.h.scale(kh) | |
| 374 | + | sc.rects[v.ID] = rm | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | // settledRect is where m's layout puts the visible window id, zero if it | |
| 378 | + | // shows none. | |
| 379 | + | func settledRect(m *Monitor, id WindowID) Rect { | |
| 380 | + | for _, p := range m.Layout() { | |
| 381 | + | if p.ID == id && !p.Hidden { | |
| 382 | + | return p.Rect | |
| 383 | + | } | |
| 384 | + | } | |
| 385 | + | return Rect{} | |
| 386 | + | } | |
| 387 | + | ||
| 341 | 388 | // leave keeps drawing a window the action closed or hid, from its last | |
| 342 | 389 | // settled placement p: it fades out and shrinks to 90 % around its centre, | |
| 343 | 390 | // then its entry goes. Nothing with animations off, or for a placement that | |
| @@ -338,6 +338,53 @@ func (c *Core) appearMapped(id WindowID, now time.Time) { | |||||
| 338 | } | 338 | } | ||
| 339 | } | 339 | } | ||
| 340 | 340 | ||||
| 341 | + | // resizeFloating applies a floating window's committed size. A float maps | |||
| 342 | + | // before its first commit, so core first lays it out at a default size; | |||
| 343 | + | // when its entrance runs, the settled rect changing under it would break | |||
| 344 | + | // the aspect of the 90 % rect. The entrance's rect offsets (and their | |||
| 345 | + | // springs, which are linear in their start and speed) are scaled by the | |||
| 346 | + | // settled size ratio: the progress and speed carry over, and the window | |||
| 347 | + | // still grows from 90 % of its real size around its centre. | |||
| 348 | + | func (c *Core) resizeFloating(v ports.WindowResized) { | |||
| 349 | + | sc, w := c.screenOf(v.ID) | |||
| 350 | + | if w == nil { | |||
| 351 | + | return | |||
| 352 | + | } | |||
| 353 | + | rm, running := sc.rects[v.ID] | |||
| 354 | + | running = running && rm.scale && !rm.leaving | |||
| 355 | + | var old Rect | |||
| 356 | + | if running { | |||
| 357 | + | old = settledRect(sc.mon, v.ID) | |||
| 358 | + | } | |||
| 359 | + | w.ResizeFloating(v.ID, v.Width, v.Height) | |||
| 360 | + | if !running { | |||
| 361 | + | return | |||
| 362 | + | } | |||
| 363 | + | cur := settledRect(sc.mon, v.ID) | |||
| 364 | + | if old.W <= 0 || old.H <= 0 || cur.W <= 0 || cur.H <= 0 || old == cur { | |||
| 365 | + | return | |||
| 366 | + | } | |||
| 367 | + | kw, kh := float64(cur.W)/float64(old.W), float64(cur.H)/float64(old.H) | |||
| 368 | + | rm.dx, rm.dw = rm.dx*kw, rm.dw*kw | |||
| 369 | + | rm.dy, rm.dh = rm.dy*kh, rm.dh*kh | |||
| 370 | + | rm.x.scale(kw) | |||
| 371 | + | rm.w.scale(kw) | |||
| 372 | + | rm.y.scale(kh) | |||
| 373 | + | rm.h.scale(kh) | |||
| 374 | + | sc.rects[v.ID] = rm | |||
| 375 | + | } | |||
| 376 | + | ||||
| 377 | + | // settledRect is where m's layout puts the visible window id, zero if it | |||
| 378 | + | // shows none. | |||
| 379 | + | func settledRect(m *Monitor, id WindowID) Rect { | |||
| 380 | + | for _, p := range m.Layout() { | |||
| 381 | + | if p.ID == id && !p.Hidden { | |||
| 382 | + | return p.Rect | |||
| 383 | + | } | |||
| 384 | + | } | |||
| 385 | + | return Rect{} | |||
| 386 | + | } | |||
| 387 | + | ||||
| 341 | // leave keeps drawing a window the action closed or hid, from its last | 388 | // leave keeps drawing a window the action closed or hid, from its last | ||
| 342 | // settled placement p: it fades out and shrinks to 90 % around its centre, | 389 | // settled placement p: it fades out and shrinks to 90 % around its centre, | ||
| 343 | // then its entry goes. Nothing with animations off, or for a placement that | 390 | // then its entry goes. Nothing with animations off, or for a placement that | ||
M internal/core/transition_map_internal_test.go +58 -0
| @@ -198,3 +198,61 @@ func TestMapExceptions(t *testing.T) { | |||
| 198 | 198 | } | |
| 199 | 199 | }) | |
| 200 | 200 | } | |
| 201 | + | ||
| 202 | + | // A float maps before its first commit: Width and Height are 0, so core lays | |
| 203 | + | // it out at a default size, and the real one comes in WindowResized. The | |
| 204 | + | // entrance must restart from 90 % of the real size, not stay distorted. | |
| 205 | + | func TestMapFloatEntranceFollowsItsFirstCommit(t *testing.T) { | |
| 206 | + | c, ic, _ := fadeCore(t) | |
| 207 | + | c.cfg.Animations.On = true | |
| 208 | + | sc := c.cur() | |
| 209 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |
| 210 | + | c.stopAnimations() | |
| 211 | + | c.mapWindow(ports.WindowMapped{ID: 2, Floating: true}) | |
| 212 | + | indicatorScene(t, c) | |
| 213 | + | guess := settledOf(t, sc, 2).Rect | |
| 214 | + | ||
| 215 | + | c.resizeFloating(ports.WindowResized{ID: 2, Width: 60, Height: 20}) | |
| 216 | + | s := indicatorScene(t, c) | |
| 217 | + | real := settledOf(t, sc, 2).Rect | |
| 218 | + | if real == guess || real.W == real.H*guess.W/guess.H { | |
| 219 | + | t.Fatalf("the resize did not change the settled shape: %+v then %+v", guess, real) | |
| 220 | + | } | |
| 221 | + | w := sceneWindow(t, s, 2) | |
| 222 | + | want := scaledRect(real, appearScale) | |
| 223 | + | near := func(a, b int) bool { return a-b <= 1 && b-a <= 1 } | |
| 224 | + | if w.Fade != 1 || !near(w.Rect.X, want.X) || !near(w.Rect.Y, want.Y) || !near(w.Rect.W, want.W) || !near(w.Rect.H, want.H) { | |
| 225 | + | t.Fatalf("first frame %+v, want 90 %% of the real %+v = %+v", w, real, want) | |
| 226 | + | } | |
| 227 | + | if math.Abs(w.Zoom-appearScale) > 0.02 { | |
| 228 | + | t.Fatalf("zoom %v, want ~0.9 (aspect kept)", w.Zoom) | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | // Mid-way the frame keeps the real aspect, the zoom follows its width. | |
| 232 | + | ic.now = ic.now.Add(40 * time.Millisecond) | |
| 233 | + | c.animate(ic.now, nil) | |
| 234 | + | w = sceneWindow(t, indicatorScene(t, c), 2) | |
| 235 | + | ratio, wantRatio := float64(w.Rect.W)/float64(w.Rect.H), float64(real.W)/float64(real.H) | |
| 236 | + | if math.Abs(ratio-wantRatio)/wantRatio > 0.06 || !(w.Rect.W > want.W && w.Rect.W < real.W) || math.Abs(w.Zoom-float64(w.Rect.W)/float64(real.W)) > 0.01 { | |
| 237 | + | t.Fatalf("mid-way %+v: aspect %.3f want %.3f", w, ratio, wantRatio) | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | ic.now = ic.now.Add(5 * time.Second) | |
| 241 | + | c.animate(ic.now, nil) | |
| 242 | + | if w := sceneWindow(t, indicatorScene(t, c), 2); w.Fade != 0 || w.Zoom != 0 || w.Rect != real { | |
| 243 | + | t.Fatalf("settled %+v, want %+v", w, real) | |
| 244 | + | } | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | // A resize with no entrance running only moves the settled rect. | |
| 248 | + | func TestResizeFloatWithoutEntranceKeepsNoMotion(t *testing.T) { | |
| 249 | + | c, _, _ := fadeCore(t) | |
| 250 | + | c.cfg.Animations.On = true | |
| 251 | + | sc := c.cur() | |
| 252 | + | c.mapWindow(ports.WindowMapped{ID: 1, Floating: true}) | |
| 253 | + | c.stopAnimations() | |
| 254 | + | c.resizeFloating(ports.WindowResized{ID: 1, Width: 60, Height: 20}) | |
| 255 | + | if len(sc.rects) != 0 { | |
| 256 | + | t.Fatalf("a resize started a motion: %+v", sc.rects) | |
| 257 | + | } | |
| 258 | + | } | |
| @@ -198,3 +198,61 @@ func TestMapExceptions(t *testing.T) { | |||||
| 198 | } | 198 | } | ||
| 199 | }) | 199 | }) | ||
| 200 | } | 200 | } | ||
| 201 | + | ||||
| 202 | + | // A float maps before its first commit: Width and Height are 0, so core lays | |||
| 203 | + | // it out at a default size, and the real one comes in WindowResized. The | |||
| 204 | + | // entrance must restart from 90 % of the real size, not stay distorted. | |||
| 205 | + | func TestMapFloatEntranceFollowsItsFirstCommit(t *testing.T) { | |||
| 206 | + | c, ic, _ := fadeCore(t) | |||
| 207 | + | c.cfg.Animations.On = true | |||
| 208 | + | sc := c.cur() | |||
| 209 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |||
| 210 | + | c.stopAnimations() | |||
| 211 | + | c.mapWindow(ports.WindowMapped{ID: 2, Floating: true}) | |||
| 212 | + | indicatorScene(t, c) | |||
| 213 | + | guess := settledOf(t, sc, 2).Rect | |||
| 214 | + | ||||
| 215 | + | c.resizeFloating(ports.WindowResized{ID: 2, Width: 60, Height: 20}) | |||
| 216 | + | s := indicatorScene(t, c) | |||
| 217 | + | real := settledOf(t, sc, 2).Rect | |||
| 218 | + | if real == guess || real.W == real.H*guess.W/guess.H { | |||
| 219 | + | t.Fatalf("the resize did not change the settled shape: %+v then %+v", guess, real) | |||
| 220 | + | } | |||
| 221 | + | w := sceneWindow(t, s, 2) | |||
| 222 | + | want := scaledRect(real, appearScale) | |||
| 223 | + | near := func(a, b int) bool { return a-b <= 1 && b-a <= 1 } | |||
| 224 | + | if w.Fade != 1 || !near(w.Rect.X, want.X) || !near(w.Rect.Y, want.Y) || !near(w.Rect.W, want.W) || !near(w.Rect.H, want.H) { | |||
| 225 | + | t.Fatalf("first frame %+v, want 90 %% of the real %+v = %+v", w, real, want) | |||
| 226 | + | } | |||
| 227 | + | if math.Abs(w.Zoom-appearScale) > 0.02 { | |||
| 228 | + | t.Fatalf("zoom %v, want ~0.9 (aspect kept)", w.Zoom) | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | // Mid-way the frame keeps the real aspect, the zoom follows its width. | |||
| 232 | + | ic.now = ic.now.Add(40 * time.Millisecond) | |||
| 233 | + | c.animate(ic.now, nil) | |||
| 234 | + | w = sceneWindow(t, indicatorScene(t, c), 2) | |||
| 235 | + | ratio, wantRatio := float64(w.Rect.W)/float64(w.Rect.H), float64(real.W)/float64(real.H) | |||
| 236 | + | if math.Abs(ratio-wantRatio)/wantRatio > 0.06 || !(w.Rect.W > want.W && w.Rect.W < real.W) || math.Abs(w.Zoom-float64(w.Rect.W)/float64(real.W)) > 0.01 { | |||
| 237 | + | t.Fatalf("mid-way %+v: aspect %.3f want %.3f", w, ratio, wantRatio) | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | ic.now = ic.now.Add(5 * time.Second) | |||
| 241 | + | c.animate(ic.now, nil) | |||
| 242 | + | if w := sceneWindow(t, indicatorScene(t, c), 2); w.Fade != 0 || w.Zoom != 0 || w.Rect != real { | |||
| 243 | + | t.Fatalf("settled %+v, want %+v", w, real) | |||
| 244 | + | } | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | // A resize with no entrance running only moves the settled rect. | |||
| 248 | + | func TestResizeFloatWithoutEntranceKeepsNoMotion(t *testing.T) { | |||
| 249 | + | c, _, _ := fadeCore(t) | |||
| 250 | + | c.cfg.Animations.On = true | |||
| 251 | + | sc := c.cur() | |||
| 252 | + | c.mapWindow(ports.WindowMapped{ID: 1, Floating: true}) | |||
| 253 | + | c.stopAnimations() | |||
| 254 | + | c.resizeFloating(ports.WindowResized{ID: 1, Width: 60, Height: 20}) | |||
| 255 | + | if len(sc.rects) != 0 { | |||
| 256 | + | t.Fatalf("a resize started a motion: %+v", sc.rects) | |||
| 257 | + | } | |||
| 258 | + | } | |||