fix(core): chain stash leave and appear from what was drawn, order leaving entries deterministically
A hide during an entrance or a slide starts from the drawn rect, fade and veil with their speeds; a show during a hide continues the leaving motion instead of restarting at 90 %. withLeaving replaces the entries the layout still lists first, then inserts the absent ones in ID order.
- commit
- 6337cf25c4a4af71a5a1848ab6b6bf44c8d61f4d
- author
- brice · · 2026-10-05 00:09 UTC
- parent
- 684c464
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +302 -37
M internal/core/animate_alloc_test.go +13 -1
| @@ -39,13 +39,25 @@ func TestAnimateAllocations(t *testing.T) { | |||
| 39 | 39 | sc.rects[WindowID(100+i)] = rm | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | + | // A stash peek's veil moves alone (a navigation): dim-only motions, | |
| 43 | + | // some going below zero, as an offset from Stash.Dim. | |
| 44 | + | for i := range 4 { | |
| 45 | + | var rm rectMotion | |
| 46 | + | from := 0.5 | |
| 47 | + | if i%2 == 1 { | |
| 48 | + | from = -0.5 | |
| 49 | + | } | |
| 50 | + | rm.dim, rm.ddim = c.spring(viewSpring(from, 0), t0), from | |
| 51 | + | sc.rects[WindowID(200+i)] = rm | |
| 52 | + | } | |
| 53 | + | ||
| 42 | 54 | now := t0.Add(10 * time.Millisecond) | |
| 43 | 55 | for name, only := range map[string]*screen{"all": nil, "only": sc} { | |
| 44 | 56 | if n := testing.AllocsPerRun(100, func() { c.animate(now, only) }); n != 0 { | |
| 45 | 57 | t.Errorf("animate(%s) allocs = %v, want 0", name, n) | |
| 46 | 58 | } | |
| 47 | 59 | } | |
| 48 | - | if !ws.motion.on || !m.switchMotion.on || len(sc.rects) != 20 { | |
| 60 | + | if !ws.motion.on || !m.switchMotion.on || len(sc.rects) != 24 { | |
| 49 | 61 | t.Fatalf("motions settled: camera %v switch %v rects %d", ws.motion.on, m.switchMotion.on, len(sc.rects)) | |
| 50 | 62 | } | |
| 51 | 63 | } | |
| @@ -39,13 +39,25 @@ func TestAnimateAllocations(t *testing.T) { | |||||
| 39 | sc.rects[WindowID(100+i)] = rm | 39 | sc.rects[WindowID(100+i)] = rm | ||
| 40 | } | 40 | } | ||
| 41 | 41 | ||||
| 42 | + | // A stash peek's veil moves alone (a navigation): dim-only motions, | |||
| 43 | + | // some going below zero, as an offset from Stash.Dim. | |||
| 44 | + | for i := range 4 { | |||
| 45 | + | var rm rectMotion | |||
| 46 | + | from := 0.5 | |||
| 47 | + | if i%2 == 1 { | |||
| 48 | + | from = -0.5 | |||
| 49 | + | } | |||
| 50 | + | rm.dim, rm.ddim = c.spring(viewSpring(from, 0), t0), from | |||
| 51 | + | sc.rects[WindowID(200+i)] = rm | |||
| 52 | + | } | |||
| 53 | + | ||||
| 42 | now := t0.Add(10 * time.Millisecond) | 54 | now := t0.Add(10 * time.Millisecond) | ||
| 43 | for name, only := range map[string]*screen{"all": nil, "only": sc} { | 55 | for name, only := range map[string]*screen{"all": nil, "only": sc} { | ||
| 44 | if n := testing.AllocsPerRun(100, func() { c.animate(now, only) }); n != 0 { | 56 | if n := testing.AllocsPerRun(100, func() { c.animate(now, only) }); n != 0 { | ||
| 45 | t.Errorf("animate(%s) allocs = %v, want 0", name, n) | 57 | t.Errorf("animate(%s) allocs = %v, want 0", name, n) | ||
| 46 | } | 58 | } | ||
| 47 | } | 59 | } | ||
| 48 | - | if !ws.motion.on || !m.switchMotion.on || len(sc.rects) != 20 { | 60 | + | if !ws.motion.on || !m.switchMotion.on || len(sc.rects) != 24 { |
| 49 | t.Fatalf("motions settled: camera %v switch %v rects %d", ws.motion.on, m.switchMotion.on, len(sc.rects)) | 61 | t.Fatalf("motions settled: camera %v switch %v rects %d", ws.motion.on, m.switchMotion.on, len(sc.rects)) | ||
| 50 | } | 62 | } | ||
| 51 | } | 63 | } | ||
M internal/core/screens.go +2 -0
| @@ -40,6 +40,8 @@ type screen struct { | |||
| 40 | 40 | // shownBuf and settledBuf back shown and settledLayout while rect | |
| 41 | 41 | // motions run (refreshShown), reused across publishes. | |
| 42 | 42 | shownBuf, settledBuf []Placement | |
| 43 | + | // leaveIDs is withLeaving's scratch list. | |
| 44 | + | leaveIDs []WindowID | |
| 43 | 45 | // rects are the running per-window rect motions (transition.go) of | |
| 44 | 46 | // rectsWS, the workspace they began on (nil: the overview); created | |
| 45 | 47 | // lazily. | |
| @@ -40,6 +40,8 @@ type screen struct { | |||||
| 40 | // shownBuf and settledBuf back shown and settledLayout while rect | 40 | // shownBuf and settledBuf back shown and settledLayout while rect | ||
| 41 | // motions run (refreshShown), reused across publishes. | 41 | // motions run (refreshShown), reused across publishes. | ||
| 42 | shownBuf, settledBuf []Placement | 42 | shownBuf, settledBuf []Placement | ||
| 43 | + | // leaveIDs is withLeaving's scratch list. | |||
| 44 | + | leaveIDs []WindowID | |||
| 43 | // rects are the running per-window rect motions (transition.go) of | 45 | // rects are the running per-window rect motions (transition.go) of | ||
| 44 | // rectsWS, the workspace they began on (nil: the overview); created | 46 | // rectsWS, the workspace they began on (nil: the overview); created | ||
| 45 | // lazily. | 47 | // lazily. | ||
M internal/core/transition.go +102 -34
| @@ -261,44 +261,61 @@ func (c *Core) refreshShown() { | |||
| 261 | 261 | // is replaced where it is. A leaving tile goes where the tiles are painted: before | |
| 262 | 262 | // the first window that opens the floats (a float above the tiles, as the | |
| 263 | 263 | // renderer orders them), so it never flashes over a float; a leaving float | |
| 264 | - | // goes last, over the tiles. Within a group smallest ID first: a stable | |
| 265 | - | // order keeps the scenes comparable frame to frame. | |
| 264 | + | // goes last, over the tiles. Within a group the absent ones go smallest ID | |
| 265 | + | // first: a stable order keeps the scenes comparable frame to frame. | |
| 266 | 266 | func (s *screen) withLeaving(layout []Placement) []Placement { | |
| 267 | 267 | out := append(s.settledBuf[:0], layout...) | |
| 268 | + | // Pass 1: an entry the layout still lists, Hidden (a stash that hid), | |
| 269 | + | // replaces it where it is, never twice. Where the pass goes through the | |
| 270 | + | // map does not matter: every entry has its own place. The absent ones | |
| 271 | + | // are collected for pass 2. | |
| 272 | + | ids := s.leaveIDs[:0] | |
| 273 | + | for id, rm := range s.rects { | |
| 274 | + | if !rm.leaving { | |
| 275 | + | continue | |
| 276 | + | } | |
| 277 | + | j := slices.IndexFunc(out, func(q Placement) bool { return q.ID == id && q.Hidden && !q.Leaving }) | |
| 278 | + | if j < 0 { | |
| 279 | + | ids = append(ids, id) | |
| 280 | + | continue | |
| 281 | + | } | |
| 282 | + | out[j] = rm.leavingPlacement() | |
| 283 | + | } | |
| 284 | + | // Pass 2: the absent ones in ID order, each at the end of its group | |
| 285 | + | // (tiles before the first float, a leaving stash float included; | |
| 286 | + | // floats last), so the order never depends on the map's. | |
| 268 | 287 | floats := len(out) | |
| 269 | 288 | for i, p := range out { | |
| 270 | - | if p.Floating && !p.Below && p.Preview == 0 && !p.Hidden { | |
| 289 | + | if p.Floating && !p.Below && p.Preview == 0 && (!p.Hidden || p.Leaving) { | |
| 271 | 290 | floats = i | |
| 272 | 291 | break | |
| 273 | 292 | } | |
| 274 | 293 | } | |
| 275 | - | for _, rm := range s.rects { | |
| 276 | - | if !rm.leaving { | |
| 294 | + | slices.Sort(ids) | |
| 295 | + | for _, id := range ids { | |
| 296 | + | p := s.rects[id].leavingPlacement() | |
| 297 | + | if p.Floating { | |
| 298 | + | out = append(out, p) | |
| 277 | 299 | continue | |
| 278 | 300 | } | |
| 279 | - | p := rm.left | |
| 280 | - | p.Hidden, p.Leaving, p.Focused, p.Fade, p.Dim, p.Zoom = true, true, false, 0, 0, 0 | |
| 281 | - | if j := slices.IndexFunc(out, func(q Placement) bool { return q.ID == p.ID && q.Hidden && !q.Leaving }); j >= 0 { | |
| 282 | - | // The layout still lists the window, Hidden (a stash that | |
| 283 | - | // hid): the entry is drawn in its place, never twice. | |
| 284 | - | out[j] = p | |
| 285 | - | continue | |
| 286 | - | } | |
| 287 | - | lo, hi := floats, len(out) | |
| 288 | - | if !p.Floating { | |
| 289 | - | lo, hi = 0, floats | |
| 290 | - | floats++ | |
| 291 | - | } | |
| 292 | - | i := lo | |
| 293 | - | for i < hi && (!out[i].Leaving || out[i].ID < p.ID) { | |
| 294 | - | i++ | |
| 295 | - | } | |
| 296 | - | out = slices.Insert(out, i, p) | |
| 301 | + | out = slices.Insert(out, floats, p) | |
| 302 | + | floats++ | |
| 297 | 303 | } | |
| 298 | - | s.settledBuf = out | |
| 304 | + | s.leaveIDs, s.settledBuf = ids, out | |
| 299 | 305 | return out | |
| 300 | 306 | } | |
| 301 | 307 | ||
| 308 | + | // leavingPlacement is the placement a leaving motion draws: its last | |
| 309 | + | // settled one, Hidden and Leaving (the scene draws it; input, focus, | |
| 310 | + | // popups and configures see a hidden window, as the C0 design has it). | |
| 311 | + | // Hidden stays set on purpose: the leaving entry never takes input, even | |
| 312 | + | // though its stash window is still in the layout. | |
| 313 | + | func (m rectMotion) leavingPlacement() Placement { | |
| 314 | + | p := m.left | |
| 315 | + | p.Hidden, p.Leaving, p.Focused, p.Fade, p.Dim, p.Zoom = true, true, false, 0, 0, 0 | |
| 316 | + | return p | |
| 317 | + | } | |
| 318 | + | ||
| 302 | 319 | // appear starts the entrance of a window the action made visible on sc: | |
| 303 | 320 | // it fades in from invisible and grows from 90 % of its settled rect, | |
| 304 | 321 | // around its centre. Nothing with animations off or if the layout does not | |
| @@ -315,7 +332,10 @@ func (c *Core) appear(sc *screen, id WindowID, now time.Time) { | |||
| 315 | 332 | } | |
| 316 | 333 | } | |
| 317 | 334 | ||
| 318 | - | // appearAt is appear for a window whose settled placement is p. | |
| 335 | + | // appearAt is appear for a window whose settled placement is p. A leaving | |
| 336 | + | // motion of the same window (a show during a hide) is continued from, not | |
| 337 | + | // replaced: the entrance starts at the fade, rect and speeds it was drawn | |
| 338 | + | // with. | |
| 319 | 339 | func (c *Core) appearAt(sc *screen, p Placement, now time.Time) { | |
| 320 | 340 | if !c.animOn() || p.Hidden { | |
| 321 | 341 | return | |
| @@ -325,11 +345,38 @@ func (c *Core) appearAt(sc *screen, p Placement, now time.Time) { | |||
| 325 | 345 | } | |
| 326 | 346 | var rm rectMotion | |
| 327 | 347 | rm.scale = true | |
| 328 | - | c.scaleFrom(&rm, p.Rect, appearScale, now) | |
| 329 | - | c.retargetComponent(&rm.fade, &rm.df, 1, 0, now) | |
| 348 | + | if old, ok := sc.rects[p.ID]; ok && old.leaving { | |
| 349 | + | c.appearFrom(&rm, p, &old, now) | |
| 350 | + | } else { | |
| 351 | + | c.scaleFrom(&rm, p.Rect, appearScale, now) | |
| 352 | + | c.retargetComponent(&rm.fade, &rm.df, 1, 0, now) | |
| 353 | + | } | |
| 330 | 354 | sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current() | |
| 331 | 355 | } | |
| 332 | 356 | ||
| 357 | + | // appearFrom starts rm, the entrance to the settled placement p, from where | |
| 358 | + | // the leaving motion old draws its window at now: its rect (old.left.Rect | |
| 359 | + | // plus its offsets, sampled), fade, veil and their speeds. The leaving fade | |
| 360 | + | // is an offset from 1, the entrance's from 0. | |
| 361 | + | func (c *Core) appearFrom(rm *rectMotion, p Placement, old *rectMotion, now time.Time) { | |
| 362 | + | settled := p.Rect | |
| 363 | + | off, vel := old.sample(now) | |
| 364 | + | base := old.left.Rect | |
| 365 | + | fade, fadeV := old.df, 0.0 | |
| 366 | + | sampleComponent(old.fade, &fade, &fadeV, now) | |
| 367 | + | dim, dimV := old.ddim, 0.0 | |
| 368 | + | sampleComponent(old.dim, &dim, &dimV, now) | |
| 369 | + | // The veil the leaving entry drew (its peek's, plus the offset) to the | |
| 370 | + | // one the settled placement draws. | |
| 371 | + | dim += c.peekDim(old.left) - c.peekDim(p) | |
| 372 | + | c.retargetComponent(&rm.x, &rm.dx, float64(base.X-settled.X)+off.x, vel.x, now) | |
| 373 | + | c.retargetComponent(&rm.y, &rm.dy, float64(base.Y-settled.Y)+off.y, vel.y, now) | |
| 374 | + | c.retargetComponent(&rm.w, &rm.dw, float64(base.W-settled.W)+off.w, vel.w, now) | |
| 375 | + | c.retargetComponent(&rm.h, &rm.dh, float64(base.H-settled.H)+off.h, vel.h, now) | |
| 376 | + | c.retargetComponent(&rm.fade, &rm.df, 1+fade, fadeV, now) | |
| 377 | + | c.retargetComponent(&rm.dim, &rm.ddim, dim, dimV, now) | |
| 378 | + | } | |
| 379 | + | ||
| 333 | 380 | // mapWindow handles a window's map: it joins its workspace, the tiles the | |
| 334 | 381 | // new one displaces re-flow with rect motions, and a window that comes on | |
| 335 | 382 | // screen appears (appearMapped). A map is the one client event that | |
| @@ -430,6 +477,14 @@ func settledRect(m *Monitor, id WindowID) Rect { | |||
| 430 | 477 | // then its entry goes. Nothing with animations off, or for a placement that | |
| 431 | 478 | // was not drawn. | |
| 432 | 479 | func (c *Core) leave(sc *screen, p Placement, now time.Time) { | |
| 480 | + | c.leaveFrom(sc, p, nil, now) | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | // leaveFrom is leave for a window that was drawn as shot says (the | |
| 484 | + | // snapshot's record of it, nil for a window at rest): the exit starts at the | |
| 485 | + | // rect, fade and dim the window had on screen, with their speeds, so a hide | |
| 486 | + | // during an entrance or a slide does not jump. | |
| 487 | + | func (c *Core) leaveFrom(sc *screen, p Placement, shot *rectShot, now time.Time) { | |
| 433 | 488 | if !c.animOn() || p.Hidden || p.Preview > 0 || p.Rect.W <= 0 || p.Rect.H <= 0 { | |
| 434 | 489 | return | |
| 435 | 490 | } | |
| @@ -438,15 +493,24 @@ func (c *Core) leave(sc *screen, p Placement, now time.Time) { | |||
| 438 | 493 | } | |
| 439 | 494 | rm := rectMotion{scale: true, leaving: true, left: p} | |
| 440 | 495 | rm.left.Hidden, rm.left.Leaving, rm.left.Focused = true, true, false | |
| 496 | + | var off, vel rectOffsets | |
| 497 | + | fade, fadeV, dim, dimV := 0.0, 0.0, 0.0, 0.0 | |
| 498 | + | if shot != nil { | |
| 499 | + | off, vel, fade, fadeV, dim, dimV = shot.off, shot.vel, shot.fade, shot.fadeV, shot.dim, shot.dimV | |
| 500 | + | } | |
| 441 | 501 | // The entry settles at 90 % of the rect it left, its content zoomed | |
| 442 | - | // to match (show): the offsets run from the full rect to that. | |
| 502 | + | // to match (show): the offsets run from the rect it was drawn at (the | |
| 503 | + | // full one at rest) to that. | |
| 443 | 504 | end := scaledRect(p.Rect, appearScale) | |
| 444 | - | c.retargetComponent(&rm.x, &rm.dx, float64(p.Rect.X-end.X), 0, now) | |
| 445 | - | c.retargetComponent(&rm.y, &rm.dy, float64(p.Rect.Y-end.Y), 0, now) | |
| 446 | - | c.retargetComponent(&rm.w, &rm.dw, float64(p.Rect.W-end.W), 0, now) | |
| 447 | - | c.retargetComponent(&rm.h, &rm.dh, float64(p.Rect.H-end.H), 0, now) | |
| 505 | + | c.retargetComponent(&rm.x, &rm.dx, float64(p.Rect.X-end.X)+off.x, vel.x, now) | |
| 506 | + | c.retargetComponent(&rm.y, &rm.dy, float64(p.Rect.Y-end.Y)+off.y, vel.y, now) | |
| 507 | + | c.retargetComponent(&rm.w, &rm.dw, float64(p.Rect.W-end.W)+off.w, vel.w, now) | |
| 508 | + | c.retargetComponent(&rm.h, &rm.dh, float64(p.Rect.H-end.H)+off.h, vel.h, now) | |
| 448 | 509 | rm.left.Rect = end | |
| 449 | - | c.retargetComponent(&rm.fade, &rm.df, -1, 0, now) | |
| 510 | + | // The leaving fade is an offset from 1: the window drawn at fade f is | |
| 511 | + | // at f-1. | |
| 512 | + | c.retargetComponent(&rm.fade, &rm.df, fade-1, fadeV, now) | |
| 513 | + | c.retargetComponent(&rm.dim, &rm.ddim, dim, dimV, now) | |
| 450 | 514 | sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current() | |
| 451 | 515 | } | |
| 452 | 516 | ||
| @@ -704,7 +768,11 @@ func (c *Core) transitionStash(b *viewShot, now time.Time) { | |||
| 704 | 768 | for _, p := range b.stash { | |
| 705 | 769 | i := slices.IndexFunc(layout, func(q Placement) bool { return q.ID == p.ID }) | |
| 706 | 770 | if i >= 0 && layout[i].Hidden && !p.Fullscreen { | |
| 707 | - | c.leave(sc, p, now) | |
| 771 | + | var shot *rectShot | |
| 772 | + | if j := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID }); j >= 0 { | |
| 773 | + | shot = &b.rects[j] | |
| 774 | + | } | |
| 775 | + | c.leaveFrom(sc, p, shot, now) | |
| 708 | 776 | } | |
| 709 | 777 | } | |
| 710 | 778 | for _, p := range layout { | |
| @@ -261,44 +261,61 @@ func (c *Core) refreshShown() { | |||||
| 261 | // is replaced where it is. A leaving tile goes where the tiles are painted: before | 261 | // is replaced where it is. A leaving tile goes where the tiles are painted: before | ||
| 262 | // the first window that opens the floats (a float above the tiles, as the | 262 | // the first window that opens the floats (a float above the tiles, as the | ||
| 263 | // renderer orders them), so it never flashes over a float; a leaving float | 263 | // renderer orders them), so it never flashes over a float; a leaving float | ||
| 264 | - | // goes last, over the tiles. Within a group smallest ID first: a stable | 264 | + | // goes last, over the tiles. Within a group the absent ones go smallest ID |
| 265 | - | // order keeps the scenes comparable frame to frame. | 265 | + | // first: a stable order keeps the scenes comparable frame to frame. |
| 266 | func (s *screen) withLeaving(layout []Placement) []Placement { | 266 | func (s *screen) withLeaving(layout []Placement) []Placement { | ||
| 267 | out := append(s.settledBuf[:0], layout...) | 267 | out := append(s.settledBuf[:0], layout...) | ||
| 268 | + | // Pass 1: an entry the layout still lists, Hidden (a stash that hid), | |||
| 269 | + | // replaces it where it is, never twice. Where the pass goes through the | |||
| 270 | + | // map does not matter: every entry has its own place. The absent ones | |||
| 271 | + | // are collected for pass 2. | |||
| 272 | + | ids := s.leaveIDs[:0] | |||
| 273 | + | for id, rm := range s.rects { | |||
| 274 | + | if !rm.leaving { | |||
| 275 | + | continue | |||
| 276 | + | } | |||
| 277 | + | j := slices.IndexFunc(out, func(q Placement) bool { return q.ID == id && q.Hidden && !q.Leaving }) | |||
| 278 | + | if j < 0 { | |||
| 279 | + | ids = append(ids, id) | |||
| 280 | + | continue | |||
| 281 | + | } | |||
| 282 | + | out[j] = rm.leavingPlacement() | |||
| 283 | + | } | |||
| 284 | + | // Pass 2: the absent ones in ID order, each at the end of its group | |||
| 285 | + | // (tiles before the first float, a leaving stash float included; | |||
| 286 | + | // floats last), so the order never depends on the map's. | |||
| 268 | floats := len(out) | 287 | floats := len(out) | ||
| 269 | for i, p := range out { | 288 | for i, p := range out { | ||
| 270 | - | if p.Floating && !p.Below && p.Preview == 0 && !p.Hidden { | 289 | + | if p.Floating && !p.Below && p.Preview == 0 && (!p.Hidden || p.Leaving) { |
| 271 | floats = i | 290 | floats = i | ||
| 272 | break | 291 | break | ||
| 273 | } | 292 | } | ||
| 274 | } | 293 | } | ||
| 275 | - | for _, rm := range s.rects { | 294 | + | slices.Sort(ids) |
| 276 | - | if !rm.leaving { | 295 | + | for _, id := range ids { |
| 296 | + | p := s.rects[id].leavingPlacement() | |||
| 297 | + | if p.Floating { | |||
| 298 | + | out = append(out, p) | |||
| 277 | continue | 299 | continue | ||
| 278 | } | 300 | } | ||
| 279 | - | p := rm.left | 301 | + | out = slices.Insert(out, floats, p) |
| 280 | - | p.Hidden, p.Leaving, p.Focused, p.Fade, p.Dim, p.Zoom = true, true, false, 0, 0, 0 | 302 | + | floats++ |
| 281 | - | if j := slices.IndexFunc(out, func(q Placement) bool { return q.ID == p.ID && q.Hidden && !q.Leaving }); j >= 0 { | |||
| 282 | - | // The layout still lists the window, Hidden (a stash that | |||
| 283 | - | // hid): the entry is drawn in its place, never twice. | |||
| 284 | - | out[j] = p | |||
| 285 | - | continue | |||
| 286 | - | } | |||
| 287 | - | lo, hi := floats, len(out) | |||
| 288 | - | if !p.Floating { | |||
| 289 | - | lo, hi = 0, floats | |||
| 290 | - | floats++ | |||
| 291 | - | } | |||
| 292 | - | i := lo | |||
| 293 | - | for i < hi && (!out[i].Leaving || out[i].ID < p.ID) { | |||
| 294 | - | i++ | |||
| 295 | - | } | |||
| 296 | - | out = slices.Insert(out, i, p) | |||
| 297 | } | 303 | } | ||
| 298 | - | s.settledBuf = out | 304 | + | s.leaveIDs, s.settledBuf = ids, out |
| 299 | return out | 305 | return out | ||
| 300 | } | 306 | } | ||
| 301 | 307 | ||||
| 308 | + | // leavingPlacement is the placement a leaving motion draws: its last | |||
| 309 | + | // settled one, Hidden and Leaving (the scene draws it; input, focus, | |||
| 310 | + | // popups and configures see a hidden window, as the C0 design has it). | |||
| 311 | + | // Hidden stays set on purpose: the leaving entry never takes input, even | |||
| 312 | + | // though its stash window is still in the layout. | |||
| 313 | + | func (m rectMotion) leavingPlacement() Placement { | |||
| 314 | + | p := m.left | |||
| 315 | + | p.Hidden, p.Leaving, p.Focused, p.Fade, p.Dim, p.Zoom = true, true, false, 0, 0, 0 | |||
| 316 | + | return p | |||
| 317 | + | } | |||
| 318 | + | ||||
| 302 | // appear starts the entrance of a window the action made visible on sc: | 319 | // appear starts the entrance of a window the action made visible on sc: | ||
| 303 | // it fades in from invisible and grows from 90 % of its settled rect, | 320 | // it fades in from invisible and grows from 90 % of its settled rect, | ||
| 304 | // around its centre. Nothing with animations off or if the layout does not | 321 | // around its centre. Nothing with animations off or if the layout does not | ||
| @@ -315,7 +332,10 @@ func (c *Core) appear(sc *screen, id WindowID, now time.Time) { | |||||
| 315 | } | 332 | } | ||
| 316 | } | 333 | } | ||
| 317 | 334 | ||||
| 318 | - | // appearAt is appear for a window whose settled placement is p. | 335 | + | // appearAt is appear for a window whose settled placement is p. A leaving |
| 336 | + | // motion of the same window (a show during a hide) is continued from, not | |||
| 337 | + | // replaced: the entrance starts at the fade, rect and speeds it was drawn | |||
| 338 | + | // with. | |||
| 319 | func (c *Core) appearAt(sc *screen, p Placement, now time.Time) { | 339 | func (c *Core) appearAt(sc *screen, p Placement, now time.Time) { | ||
| 320 | if !c.animOn() || p.Hidden { | 340 | if !c.animOn() || p.Hidden { | ||
| 321 | return | 341 | return | ||
| @@ -325,11 +345,38 @@ func (c *Core) appearAt(sc *screen, p Placement, now time.Time) { | |||||
| 325 | } | 345 | } | ||
| 326 | var rm rectMotion | 346 | var rm rectMotion | ||
| 327 | rm.scale = true | 347 | rm.scale = true | ||
| 328 | - | c.scaleFrom(&rm, p.Rect, appearScale, now) | 348 | + | if old, ok := sc.rects[p.ID]; ok && old.leaving { |
| 329 | - | c.retargetComponent(&rm.fade, &rm.df, 1, 0, now) | 349 | + | c.appearFrom(&rm, p, &old, now) |
| 350 | + | } else { | |||
| 351 | + | c.scaleFrom(&rm, p.Rect, appearScale, now) | |||
| 352 | + | c.retargetComponent(&rm.fade, &rm.df, 1, 0, now) | |||
| 353 | + | } | |||
| 330 | sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current() | 354 | sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current() | ||
| 331 | } | 355 | } | ||
| 332 | 356 | ||||
| 357 | + | // appearFrom starts rm, the entrance to the settled placement p, from where | |||
| 358 | + | // the leaving motion old draws its window at now: its rect (old.left.Rect | |||
| 359 | + | // plus its offsets, sampled), fade, veil and their speeds. The leaving fade | |||
| 360 | + | // is an offset from 1, the entrance's from 0. | |||
| 361 | + | func (c *Core) appearFrom(rm *rectMotion, p Placement, old *rectMotion, now time.Time) { | |||
| 362 | + | settled := p.Rect | |||
| 363 | + | off, vel := old.sample(now) | |||
| 364 | + | base := old.left.Rect | |||
| 365 | + | fade, fadeV := old.df, 0.0 | |||
| 366 | + | sampleComponent(old.fade, &fade, &fadeV, now) | |||
| 367 | + | dim, dimV := old.ddim, 0.0 | |||
| 368 | + | sampleComponent(old.dim, &dim, &dimV, now) | |||
| 369 | + | // The veil the leaving entry drew (its peek's, plus the offset) to the | |||
| 370 | + | // one the settled placement draws. | |||
| 371 | + | dim += c.peekDim(old.left) - c.peekDim(p) | |||
| 372 | + | c.retargetComponent(&rm.x, &rm.dx, float64(base.X-settled.X)+off.x, vel.x, now) | |||
| 373 | + | c.retargetComponent(&rm.y, &rm.dy, float64(base.Y-settled.Y)+off.y, vel.y, now) | |||
| 374 | + | c.retargetComponent(&rm.w, &rm.dw, float64(base.W-settled.W)+off.w, vel.w, now) | |||
| 375 | + | c.retargetComponent(&rm.h, &rm.dh, float64(base.H-settled.H)+off.h, vel.h, now) | |||
| 376 | + | c.retargetComponent(&rm.fade, &rm.df, 1+fade, fadeV, now) | |||
| 377 | + | c.retargetComponent(&rm.dim, &rm.ddim, dim, dimV, now) | |||
| 378 | + | } | |||
| 379 | + | ||||
| 333 | // mapWindow handles a window's map: it joins its workspace, the tiles the | 380 | // mapWindow handles a window's map: it joins its workspace, the tiles the | ||
| 334 | // new one displaces re-flow with rect motions, and a window that comes on | 381 | // new one displaces re-flow with rect motions, and a window that comes on | ||
| 335 | // screen appears (appearMapped). A map is the one client event that | 382 | // screen appears (appearMapped). A map is the one client event that | ||
| @@ -430,6 +477,14 @@ func settledRect(m *Monitor, id WindowID) Rect { | |||||
| 430 | // then its entry goes. Nothing with animations off, or for a placement that | 477 | // then its entry goes. Nothing with animations off, or for a placement that | ||
| 431 | // was not drawn. | 478 | // was not drawn. | ||
| 432 | func (c *Core) leave(sc *screen, p Placement, now time.Time) { | 479 | func (c *Core) leave(sc *screen, p Placement, now time.Time) { | ||
| 480 | + | c.leaveFrom(sc, p, nil, now) | |||
| 481 | + | } | |||
| 482 | + | ||||
| 483 | + | // leaveFrom is leave for a window that was drawn as shot says (the | |||
| 484 | + | // snapshot's record of it, nil for a window at rest): the exit starts at the | |||
| 485 | + | // rect, fade and dim the window had on screen, with their speeds, so a hide | |||
| 486 | + | // during an entrance or a slide does not jump. | |||
| 487 | + | func (c *Core) leaveFrom(sc *screen, p Placement, shot *rectShot, now time.Time) { | |||
| 433 | if !c.animOn() || p.Hidden || p.Preview > 0 || p.Rect.W <= 0 || p.Rect.H <= 0 { | 488 | if !c.animOn() || p.Hidden || p.Preview > 0 || p.Rect.W <= 0 || p.Rect.H <= 0 { | ||
| 434 | return | 489 | return | ||
| 435 | } | 490 | } | ||
| @@ -438,15 +493,24 @@ func (c *Core) leave(sc *screen, p Placement, now time.Time) { | |||||
| 438 | } | 493 | } | ||
| 439 | rm := rectMotion{scale: true, leaving: true, left: p} | 494 | rm := rectMotion{scale: true, leaving: true, left: p} | ||
| 440 | rm.left.Hidden, rm.left.Leaving, rm.left.Focused = true, true, false | 495 | rm.left.Hidden, rm.left.Leaving, rm.left.Focused = true, true, false | ||
| 496 | + | var off, vel rectOffsets | |||
| 497 | + | fade, fadeV, dim, dimV := 0.0, 0.0, 0.0, 0.0 | |||
| 498 | + | if shot != nil { | |||
| 499 | + | off, vel, fade, fadeV, dim, dimV = shot.off, shot.vel, shot.fade, shot.fadeV, shot.dim, shot.dimV | |||
| 500 | + | } | |||
| 441 | // The entry settles at 90 % of the rect it left, its content zoomed | 501 | // The entry settles at 90 % of the rect it left, its content zoomed | ||
| 442 | - | // to match (show): the offsets run from the full rect to that. | 502 | + | // to match (show): the offsets run from the rect it was drawn at (the |
| 503 | + | // full one at rest) to that. | |||
| 443 | end := scaledRect(p.Rect, appearScale) | 504 | end := scaledRect(p.Rect, appearScale) | ||
| 444 | - | c.retargetComponent(&rm.x, &rm.dx, float64(p.Rect.X-end.X), 0, now) | 505 | + | c.retargetComponent(&rm.x, &rm.dx, float64(p.Rect.X-end.X)+off.x, vel.x, now) |
| 445 | - | c.retargetComponent(&rm.y, &rm.dy, float64(p.Rect.Y-end.Y), 0, now) | 506 | + | c.retargetComponent(&rm.y, &rm.dy, float64(p.Rect.Y-end.Y)+off.y, vel.y, now) |
| 446 | - | c.retargetComponent(&rm.w, &rm.dw, float64(p.Rect.W-end.W), 0, now) | 507 | + | c.retargetComponent(&rm.w, &rm.dw, float64(p.Rect.W-end.W)+off.w, vel.w, now) |
| 447 | - | c.retargetComponent(&rm.h, &rm.dh, float64(p.Rect.H-end.H), 0, now) | 508 | + | c.retargetComponent(&rm.h, &rm.dh, float64(p.Rect.H-end.H)+off.h, vel.h, now) |
| 448 | rm.left.Rect = end | 509 | rm.left.Rect = end | ||
| 449 | - | c.retargetComponent(&rm.fade, &rm.df, -1, 0, now) | 510 | + | // The leaving fade is an offset from 1: the window drawn at fade f is |
| 511 | + | // at f-1. | |||
| 512 | + | c.retargetComponent(&rm.fade, &rm.df, fade-1, fadeV, now) | |||
| 513 | + | c.retargetComponent(&rm.dim, &rm.ddim, dim, dimV, now) | |||
| 450 | sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current() | 514 | sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current() | ||
| 451 | } | 515 | } | ||
| 452 | 516 | ||||
| @@ -704,7 +768,11 @@ func (c *Core) transitionStash(b *viewShot, now time.Time) { | |||||
| 704 | for _, p := range b.stash { | 768 | for _, p := range b.stash { | ||
| 705 | i := slices.IndexFunc(layout, func(q Placement) bool { return q.ID == p.ID }) | 769 | i := slices.IndexFunc(layout, func(q Placement) bool { return q.ID == p.ID }) | ||
| 706 | if i >= 0 && layout[i].Hidden && !p.Fullscreen { | 770 | if i >= 0 && layout[i].Hidden && !p.Fullscreen { | ||
| 707 | - | c.leave(sc, p, now) | 771 | + | var shot *rectShot |
| 772 | + | if j := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID }); j >= 0 { | |||
| 773 | + | shot = &b.rects[j] | |||
| 774 | + | } | |||
| 775 | + | c.leaveFrom(sc, p, shot, now) | |||
| 708 | } | 776 | } | ||
| 709 | } | 777 | } | ||
| 710 | for _, p := range layout { | 778 | for _, p := range layout { | ||
M internal/core/transition_stash_internal_test.go +185 -2
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "math" | |
| 4 | 5 | "slices" | |
| 5 | 6 | "testing" | |
| 6 | 7 | "time" | |
| @@ -29,6 +30,21 @@ func stashRig(t *testing.T, on bool) (*Core, *indicatorClock, *screen, chan port | |||
| 29 | 30 | return c, ic, sc, cmds | |
| 30 | 31 | } | |
| 31 | 32 | ||
| 33 | + | // configuresOf drains the commands into the configures sent per window. | |
| 34 | + | func configuresOf(cmds chan ports.ClientCommand) map[WindowID][]ports.ConfigureWindow { | |
| 35 | + | out := map[WindowID][]ports.ConfigureWindow{} | |
| 36 | + | for { | |
| 37 | + | select { | |
| 38 | + | case cmd := <-cmds: | |
| 39 | + | if v, ok := cmd.(ports.ConfigureWindow); ok { | |
| 40 | + | out[v.ID] = append(out[v.ID], v) | |
| 41 | + | } | |
| 42 | + | default: | |
| 43 | + | return out | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 32 | 48 | // act is a bind: snapshot, action, transition. | |
| 33 | 49 | func act(c *Core, ic *indicatorClock, a Action) { | |
| 34 | 50 | shots := c.snapshot(ic.now) | |
| @@ -73,8 +89,15 @@ func TestStashShowAppears(t *testing.T) { | |||
| 73 | 89 | t.Fatalf("window 4 stays off screen: %+v", w) | |
| 74 | 90 | } | |
| 75 | 91 | // The action configures what it changes once, at the settled size. | |
| 76 | - | if ids := drainConfigures(cmds); !slices.Contains(ids, 2) { | |
| 77 | - | t.Fatalf("configures %v, want window 2 shown", ids) | |
| 92 | + | cfgs := configuresOf(cmds) | |
| 93 | + | for _, id := range []WindowID{2, 3} { | |
| 94 | + | if len(cfgs[id]) != 1 { | |
| 95 | + | t.Fatalf("window %d configured %d times, want once: %+v", id, len(cfgs[id]), cfgs[id]) | |
| 96 | + | } | |
| 97 | + | want := c.clientRect(settledPlacement(t, sc, id)) | |
| 98 | + | if v := cfgs[id][0]; !v.Visible || v.Width != want.W || v.Height != want.H { | |
| 99 | + | t.Fatalf("window %d configure %+v, want %dx%d (the settled client rect)", id, v, want.W, want.H) | |
| 100 | + | } | |
| 78 | 101 | } | |
| 79 | 102 | ||
| 80 | 103 | s = frame(t, c, ic, 40*time.Millisecond) | |
| @@ -310,3 +333,163 @@ func TestStashNavigationAppearsAndLeaves(t *testing.T) { | |||
| 310 | 333 | t.Fatal("still animating") | |
| 311 | 334 | } | |
| 312 | 335 | } | |
| 336 | + | ||
| 337 | + | // near fails the test unless a and b differ by less than eps. | |
| 338 | + | func near(t *testing.T, what string, a, b, eps float64) { | |
| 339 | + | t.Helper() | |
| 340 | + | if math.Abs(a-b) > eps { | |
| 341 | + | t.Fatalf("%s jumped: %v then %v", what, a, b) | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | // A hide during the show's fade-in continues from what was drawn: no jump | |
| 346 | + | // in fade or rect, then it settles gone. | |
| 347 | + | func TestStashHideDuringShowDoesNotJump(t *testing.T) { | |
| 348 | + | c, ic, sc, _ := stashRig(t, true) | |
| 349 | + | act(c, ic, ActionToggleStashVisible) | |
| 350 | + | indicatorScene(t, c) | |
| 351 | + | ic.now = ic.now.Add(5 * time.Second) | |
| 352 | + | c.animate(ic.now, nil) | |
| 353 | + | indicatorScene(t, c) | |
| 354 | + | act(c, ic, ActionToggleStashVisible) // show | |
| 355 | + | indicatorScene(t, c) | |
| 356 | + | s := frame(t, c, ic, 30*time.Millisecond) | |
| 357 | + | last := sceneWindow(t, s, 2) | |
| 358 | + | if !(last.Fade > 0 && last.Fade < 1) { | |
| 359 | + | t.Fatalf("setup: fade %v", last.Fade) | |
| 360 | + | } | |
| 361 | + | act(c, ic, ActionToggleStashVisible) // hide, same instant | |
| 362 | + | w := sceneWindow(t, indicatorScene(t, c), 2) | |
| 363 | + | if w.Hidden { | |
| 364 | + | t.Fatalf("window 2 vanished: %+v", w) | |
| 365 | + | } | |
| 366 | + | near(t, "fade", last.Fade, w.Fade, 0.02) | |
| 367 | + | near(t, "width", float64(last.Rect.W), float64(w.Rect.W), 2) | |
| 368 | + | near(t, "x", float64(last.Rect.X), float64(w.Rect.X), 2) | |
| 369 | + | near(t, "zoom", contentScale(last), contentScale(w), 0.03) | |
| 370 | + | s = frame(t, c, ic, 5*time.Second) | |
| 371 | + | if w := sceneWindow(t, s, 2); !w.Hidden || c.animating() || len(sc.rects) != 0 { | |
| 372 | + | t.Fatalf("after the settle: %+v, %d rects", w, len(sc.rects)) | |
| 373 | + | } | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | // A show during the hide's fade-out continues from what was drawn. | |
| 377 | + | func TestStashShowDuringHideDoesNotJump(t *testing.T) { | |
| 378 | + | c, ic, sc, _ := stashRig(t, true) | |
| 379 | + | act(c, ic, ActionToggleStashVisible) // hide | |
| 380 | + | indicatorScene(t, c) | |
| 381 | + | s := frame(t, c, ic, 30*time.Millisecond) | |
| 382 | + | last := sceneWindow(t, s, 2) | |
| 383 | + | if !(last.Fade > 0 && last.Fade < 1) { | |
| 384 | + | t.Fatalf("setup: fade %v", last.Fade) | |
| 385 | + | } | |
| 386 | + | act(c, ic, ActionToggleStashVisible) // show | |
| 387 | + | w := sceneWindow(t, indicatorScene(t, c), 2) | |
| 388 | + | if w.Hidden { | |
| 389 | + | t.Fatalf("window 2 hidden: %+v", w) | |
| 390 | + | } | |
| 391 | + | near(t, "fade", last.Fade, w.Fade, 0.02) | |
| 392 | + | near(t, "width", float64(last.Rect.W), float64(w.Rect.W), 2) | |
| 393 | + | near(t, "x", float64(last.Rect.X), float64(w.Rect.X), 2) | |
| 394 | + | near(t, "zoom", contentScale(last), contentScale(w), 0.03) | |
| 395 | + | // The entrance now runs back to opaque (the fade's speed carries over: | |
| 396 | + | // it may still rise a few frames before it turns). | |
| 397 | + | s = frame(t, c, ic, 150*time.Millisecond) | |
| 398 | + | if n := sceneWindow(t, s, 2); !(n.Fade < w.Fade) { | |
| 399 | + | t.Fatalf("fade %v then %v, want it to clear", w.Fade, n.Fade) | |
| 400 | + | } | |
| 401 | + | s = frame(t, c, ic, 5*time.Second) | |
| 402 | + | p := settledPlacement(t, sc, 2) | |
| 403 | + | if w := sceneWindow(t, s, 2); w.Fade != 0 || w.Zoom != 0 || w.Rect != p.Rect || c.animating() { | |
| 404 | + | t.Fatalf("settled %+v, want %+v", w, p.Rect) | |
| 405 | + | } | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | // A peek sliding to a margin that a fast second navigation sends off screen | |
| 409 | + | // leaves from where it was drawn, mid-slide, not from its settled rect. | |
| 410 | + | func TestStashNavigationLeavesFromSlide(t *testing.T) { | |
| 411 | + | c, ic, sc, _ := stashRig(t, true) | |
| 412 | + | // 2 selected, 3 peeks left. Left: 3 centred, 2 peeks right, 4 enters. | |
| 413 | + | act(c, ic, ActionFocusColumnLeft) | |
| 414 | + | indicatorScene(t, c) | |
| 415 | + | s := frame(t, c, ic, 30*time.Millisecond) | |
| 416 | + | last := sceneWindow(t, s, 2) | |
| 417 | + | settled := settledPlacement(t, sc, 2) | |
| 418 | + | if last.Rect == settled.Rect { | |
| 419 | + | t.Fatal("setup: window 2 already at rest") | |
| 420 | + | } | |
| 421 | + | // Left again: 4 centred, 3 peeks right, 2 goes off. | |
| 422 | + | act(c, ic, ActionFocusColumnLeft) | |
| 423 | + | w := sceneWindow(t, indicatorScene(t, c), 2) | |
| 424 | + | if w.Hidden { | |
| 425 | + | t.Fatalf("window 2 vanished: %+v", w) | |
| 426 | + | } | |
| 427 | + | near(t, "x", float64(last.Rect.X), float64(w.Rect.X), 2) | |
| 428 | + | near(t, "width", float64(last.Rect.W), float64(w.Rect.W), 2) | |
| 429 | + | near(t, "dim", last.Dim, w.Dim, 0.03) | |
| 430 | + | if s = frame(t, c, ic, 5*time.Second); !sceneWindow(t, s, 2).Hidden { | |
| 431 | + | t.Fatal("window 2 still drawn after the settle") | |
| 432 | + | } | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | // Leaving entries land in the same order whatever the map's: a hidden stash | |
| 436 | + | // float (replaced in place) and absent tiles and floats (ID order, tiles | |
| 437 | + | // before the first float, floats last). | |
| 438 | + | func TestWithLeavingOrderIsDeterministic(t *testing.T) { | |
| 439 | + | c, ic := indicatorCore(t) | |
| 440 | + | c.cfg.Animations.On = true | |
| 441 | + | sc := c.cur() | |
| 442 | + | w := sc.mon.Current() | |
| 443 | + | for id := WindowID(1); id <= 4; id++ { | |
| 444 | + | sc.mon.AddWindow(id) | |
| 445 | + | } | |
| 446 | + | w.FocusID(4) | |
| 447 | + | c.applyAction(ActionToggleWindowStash) // 4 stashed, shown | |
| 448 | + | w.AddFloating(5, 20, 10) | |
| 449 | + | settleShown(t, c) | |
| 450 | + | var left []Placement | |
| 451 | + | for _, p := range sc.settledLayout { | |
| 452 | + | if p.ID == 2 || p.ID == 3 || p.ID == 4 || p.ID == 5 { | |
| 453 | + | left = append(left, p) | |
| 454 | + | } | |
| 455 | + | } | |
| 456 | + | if len(left) != 4 { | |
| 457 | + | t.Fatalf("setup: %d placements", len(left)) | |
| 458 | + | } | |
| 459 | + | // 2 and 3 close (absent tiles), 5 closes (absent float), 4 hides. | |
| 460 | + | sc.mon.RemoveWindow(2) | |
| 461 | + | sc.mon.RemoveWindow(3) | |
| 462 | + | sc.mon.RemoveWindow(5) | |
| 463 | + | c.applyAction(ActionToggleStashVisible) | |
| 464 | + | for _, p := range left { | |
| 465 | + | c.leave(sc, p, ic.now) | |
| 466 | + | } | |
| 467 | + | var want []WindowID | |
| 468 | + | for i := range 40 { | |
| 469 | + | c.refreshShown() | |
| 470 | + | var ids []WindowID | |
| 471 | + | for _, p := range sc.settledLayout { | |
| 472 | + | ids = append(ids, p.ID) | |
| 473 | + | } | |
| 474 | + | if i == 0 { | |
| 475 | + | want = ids | |
| 476 | + | if n := len(ids); n < 4 { | |
| 477 | + | t.Fatalf("layout %v", ids) | |
| 478 | + | } | |
| 479 | + | i2, i3 := slices.Index(ids, 2), slices.Index(ids, 3) | |
| 480 | + | i4, i5 := slices.Index(ids, 4), slices.Index(ids, 5) | |
| 481 | + | // Absent tiles by ID, then the leaving stash float (in its | |
| 482 | + | // slot), the absent float last. | |
| 483 | + | if !(i2 >= 0 && i2 < i3 && i3 < i4 && i4 < i5 && i5 == len(ids)-1) { | |
| 484 | + | t.Fatalf("order %v: want tiles 2, 3, then the stash float 4, then 5", ids) | |
| 485 | + | } | |
| 486 | + | if k := slices.Index(ids, 4); !sc.settledLayout[k].Leaving { | |
| 487 | + | t.Fatal("the hidden stash window is not replaced in place") | |
| 488 | + | } | |
| 489 | + | continue | |
| 490 | + | } | |
| 491 | + | if !slices.Equal(ids, want) { | |
| 492 | + | t.Fatalf("run %d: order %v, first run %v", i, ids, want) | |
| 493 | + | } | |
| 494 | + | } | |
| 495 | + | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package core | 1 | package core | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "math" | |||
| 4 | "slices" | 5 | "slices" | ||
| 5 | "testing" | 6 | "testing" | ||
| 6 | "time" | 7 | "time" | ||
| @@ -29,6 +30,21 @@ func stashRig(t *testing.T, on bool) (*Core, *indicatorClock, *screen, chan port | |||||
| 29 | return c, ic, sc, cmds | 30 | return c, ic, sc, cmds | ||
| 30 | } | 31 | } | ||
| 31 | 32 | ||||
| 33 | + | // configuresOf drains the commands into the configures sent per window. | |||
| 34 | + | func configuresOf(cmds chan ports.ClientCommand) map[WindowID][]ports.ConfigureWindow { | |||
| 35 | + | out := map[WindowID][]ports.ConfigureWindow{} | |||
| 36 | + | for { | |||
| 37 | + | select { | |||
| 38 | + | case cmd := <-cmds: | |||
| 39 | + | if v, ok := cmd.(ports.ConfigureWindow); ok { | |||
| 40 | + | out[v.ID] = append(out[v.ID], v) | |||
| 41 | + | } | |||
| 42 | + | default: | |||
| 43 | + | return out | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 32 | // act is a bind: snapshot, action, transition. | 48 | // act is a bind: snapshot, action, transition. | ||
| 33 | func act(c *Core, ic *indicatorClock, a Action) { | 49 | func act(c *Core, ic *indicatorClock, a Action) { | ||
| 34 | shots := c.snapshot(ic.now) | 50 | shots := c.snapshot(ic.now) | ||
| @@ -73,8 +89,15 @@ func TestStashShowAppears(t *testing.T) { | |||||
| 73 | t.Fatalf("window 4 stays off screen: %+v", w) | 89 | t.Fatalf("window 4 stays off screen: %+v", w) | ||
| 74 | } | 90 | } | ||
| 75 | // The action configures what it changes once, at the settled size. | 91 | // The action configures what it changes once, at the settled size. | ||
| 76 | - | if ids := drainConfigures(cmds); !slices.Contains(ids, 2) { | 92 | + | cfgs := configuresOf(cmds) |
| 77 | - | t.Fatalf("configures %v, want window 2 shown", ids) | 93 | + | for _, id := range []WindowID{2, 3} { |
| 94 | + | if len(cfgs[id]) != 1 { | |||
| 95 | + | t.Fatalf("window %d configured %d times, want once: %+v", id, len(cfgs[id]), cfgs[id]) | |||
| 96 | + | } | |||
| 97 | + | want := c.clientRect(settledPlacement(t, sc, id)) | |||
| 98 | + | if v := cfgs[id][0]; !v.Visible || v.Width != want.W || v.Height != want.H { | |||
| 99 | + | t.Fatalf("window %d configure %+v, want %dx%d (the settled client rect)", id, v, want.W, want.H) | |||
| 100 | + | } | |||
| 78 | } | 101 | } | ||
| 79 | 102 | ||||
| 80 | s = frame(t, c, ic, 40*time.Millisecond) | 103 | s = frame(t, c, ic, 40*time.Millisecond) | ||
| @@ -310,3 +333,163 @@ func TestStashNavigationAppearsAndLeaves(t *testing.T) { | |||||
| 310 | t.Fatal("still animating") | 333 | t.Fatal("still animating") | ||
| 311 | } | 334 | } | ||
| 312 | } | 335 | } | ||
| 336 | + | ||||
| 337 | + | // near fails the test unless a and b differ by less than eps. | |||
| 338 | + | func near(t *testing.T, what string, a, b, eps float64) { | |||
| 339 | + | t.Helper() | |||
| 340 | + | if math.Abs(a-b) > eps { | |||
| 341 | + | t.Fatalf("%s jumped: %v then %v", what, a, b) | |||
| 342 | + | } | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | // A hide during the show's fade-in continues from what was drawn: no jump | |||
| 346 | + | // in fade or rect, then it settles gone. | |||
| 347 | + | func TestStashHideDuringShowDoesNotJump(t *testing.T) { | |||
| 348 | + | c, ic, sc, _ := stashRig(t, true) | |||
| 349 | + | act(c, ic, ActionToggleStashVisible) | |||
| 350 | + | indicatorScene(t, c) | |||
| 351 | + | ic.now = ic.now.Add(5 * time.Second) | |||
| 352 | + | c.animate(ic.now, nil) | |||
| 353 | + | indicatorScene(t, c) | |||
| 354 | + | act(c, ic, ActionToggleStashVisible) // show | |||
| 355 | + | indicatorScene(t, c) | |||
| 356 | + | s := frame(t, c, ic, 30*time.Millisecond) | |||
| 357 | + | last := sceneWindow(t, s, 2) | |||
| 358 | + | if !(last.Fade > 0 && last.Fade < 1) { | |||
| 359 | + | t.Fatalf("setup: fade %v", last.Fade) | |||
| 360 | + | } | |||
| 361 | + | act(c, ic, ActionToggleStashVisible) // hide, same instant | |||
| 362 | + | w := sceneWindow(t, indicatorScene(t, c), 2) | |||
| 363 | + | if w.Hidden { | |||
| 364 | + | t.Fatalf("window 2 vanished: %+v", w) | |||
| 365 | + | } | |||
| 366 | + | near(t, "fade", last.Fade, w.Fade, 0.02) | |||
| 367 | + | near(t, "width", float64(last.Rect.W), float64(w.Rect.W), 2) | |||
| 368 | + | near(t, "x", float64(last.Rect.X), float64(w.Rect.X), 2) | |||
| 369 | + | near(t, "zoom", contentScale(last), contentScale(w), 0.03) | |||
| 370 | + | s = frame(t, c, ic, 5*time.Second) | |||
| 371 | + | if w := sceneWindow(t, s, 2); !w.Hidden || c.animating() || len(sc.rects) != 0 { | |||
| 372 | + | t.Fatalf("after the settle: %+v, %d rects", w, len(sc.rects)) | |||
| 373 | + | } | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | // A show during the hide's fade-out continues from what was drawn. | |||
| 377 | + | func TestStashShowDuringHideDoesNotJump(t *testing.T) { | |||
| 378 | + | c, ic, sc, _ := stashRig(t, true) | |||
| 379 | + | act(c, ic, ActionToggleStashVisible) // hide | |||
| 380 | + | indicatorScene(t, c) | |||
| 381 | + | s := frame(t, c, ic, 30*time.Millisecond) | |||
| 382 | + | last := sceneWindow(t, s, 2) | |||
| 383 | + | if !(last.Fade > 0 && last.Fade < 1) { | |||
| 384 | + | t.Fatalf("setup: fade %v", last.Fade) | |||
| 385 | + | } | |||
| 386 | + | act(c, ic, ActionToggleStashVisible) // show | |||
| 387 | + | w := sceneWindow(t, indicatorScene(t, c), 2) | |||
| 388 | + | if w.Hidden { | |||
| 389 | + | t.Fatalf("window 2 hidden: %+v", w) | |||
| 390 | + | } | |||
| 391 | + | near(t, "fade", last.Fade, w.Fade, 0.02) | |||
| 392 | + | near(t, "width", float64(last.Rect.W), float64(w.Rect.W), 2) | |||
| 393 | + | near(t, "x", float64(last.Rect.X), float64(w.Rect.X), 2) | |||
| 394 | + | near(t, "zoom", contentScale(last), contentScale(w), 0.03) | |||
| 395 | + | // The entrance now runs back to opaque (the fade's speed carries over: | |||
| 396 | + | // it may still rise a few frames before it turns). | |||
| 397 | + | s = frame(t, c, ic, 150*time.Millisecond) | |||
| 398 | + | if n := sceneWindow(t, s, 2); !(n.Fade < w.Fade) { | |||
| 399 | + | t.Fatalf("fade %v then %v, want it to clear", w.Fade, n.Fade) | |||
| 400 | + | } | |||
| 401 | + | s = frame(t, c, ic, 5*time.Second) | |||
| 402 | + | p := settledPlacement(t, sc, 2) | |||
| 403 | + | if w := sceneWindow(t, s, 2); w.Fade != 0 || w.Zoom != 0 || w.Rect != p.Rect || c.animating() { | |||
| 404 | + | t.Fatalf("settled %+v, want %+v", w, p.Rect) | |||
| 405 | + | } | |||
| 406 | + | } | |||
| 407 | + | ||||
| 408 | + | // A peek sliding to a margin that a fast second navigation sends off screen | |||
| 409 | + | // leaves from where it was drawn, mid-slide, not from its settled rect. | |||
| 410 | + | func TestStashNavigationLeavesFromSlide(t *testing.T) { | |||
| 411 | + | c, ic, sc, _ := stashRig(t, true) | |||
| 412 | + | // 2 selected, 3 peeks left. Left: 3 centred, 2 peeks right, 4 enters. | |||
| 413 | + | act(c, ic, ActionFocusColumnLeft) | |||
| 414 | + | indicatorScene(t, c) | |||
| 415 | + | s := frame(t, c, ic, 30*time.Millisecond) | |||
| 416 | + | last := sceneWindow(t, s, 2) | |||
| 417 | + | settled := settledPlacement(t, sc, 2) | |||
| 418 | + | if last.Rect == settled.Rect { | |||
| 419 | + | t.Fatal("setup: window 2 already at rest") | |||
| 420 | + | } | |||
| 421 | + | // Left again: 4 centred, 3 peeks right, 2 goes off. | |||
| 422 | + | act(c, ic, ActionFocusColumnLeft) | |||
| 423 | + | w := sceneWindow(t, indicatorScene(t, c), 2) | |||
| 424 | + | if w.Hidden { | |||
| 425 | + | t.Fatalf("window 2 vanished: %+v", w) | |||
| 426 | + | } | |||
| 427 | + | near(t, "x", float64(last.Rect.X), float64(w.Rect.X), 2) | |||
| 428 | + | near(t, "width", float64(last.Rect.W), float64(w.Rect.W), 2) | |||
| 429 | + | near(t, "dim", last.Dim, w.Dim, 0.03) | |||
| 430 | + | if s = frame(t, c, ic, 5*time.Second); !sceneWindow(t, s, 2).Hidden { | |||
| 431 | + | t.Fatal("window 2 still drawn after the settle") | |||
| 432 | + | } | |||
| 433 | + | } | |||
| 434 | + | ||||
| 435 | + | // Leaving entries land in the same order whatever the map's: a hidden stash | |||
| 436 | + | // float (replaced in place) and absent tiles and floats (ID order, tiles | |||
| 437 | + | // before the first float, floats last). | |||
| 438 | + | func TestWithLeavingOrderIsDeterministic(t *testing.T) { | |||
| 439 | + | c, ic := indicatorCore(t) | |||
| 440 | + | c.cfg.Animations.On = true | |||
| 441 | + | sc := c.cur() | |||
| 442 | + | w := sc.mon.Current() | |||
| 443 | + | for id := WindowID(1); id <= 4; id++ { | |||
| 444 | + | sc.mon.AddWindow(id) | |||
| 445 | + | } | |||
| 446 | + | w.FocusID(4) | |||
| 447 | + | c.applyAction(ActionToggleWindowStash) // 4 stashed, shown | |||
| 448 | + | w.AddFloating(5, 20, 10) | |||
| 449 | + | settleShown(t, c) | |||
| 450 | + | var left []Placement | |||
| 451 | + | for _, p := range sc.settledLayout { | |||
| 452 | + | if p.ID == 2 || p.ID == 3 || p.ID == 4 || p.ID == 5 { | |||
| 453 | + | left = append(left, p) | |||
| 454 | + | } | |||
| 455 | + | } | |||
| 456 | + | if len(left) != 4 { | |||
| 457 | + | t.Fatalf("setup: %d placements", len(left)) | |||
| 458 | + | } | |||
| 459 | + | // 2 and 3 close (absent tiles), 5 closes (absent float), 4 hides. | |||
| 460 | + | sc.mon.RemoveWindow(2) | |||
| 461 | + | sc.mon.RemoveWindow(3) | |||
| 462 | + | sc.mon.RemoveWindow(5) | |||
| 463 | + | c.applyAction(ActionToggleStashVisible) | |||
| 464 | + | for _, p := range left { | |||
| 465 | + | c.leave(sc, p, ic.now) | |||
| 466 | + | } | |||
| 467 | + | var want []WindowID | |||
| 468 | + | for i := range 40 { | |||
| 469 | + | c.refreshShown() | |||
| 470 | + | var ids []WindowID | |||
| 471 | + | for _, p := range sc.settledLayout { | |||
| 472 | + | ids = append(ids, p.ID) | |||
| 473 | + | } | |||
| 474 | + | if i == 0 { | |||
| 475 | + | want = ids | |||
| 476 | + | if n := len(ids); n < 4 { | |||
| 477 | + | t.Fatalf("layout %v", ids) | |||
| 478 | + | } | |||
| 479 | + | i2, i3 := slices.Index(ids, 2), slices.Index(ids, 3) | |||
| 480 | + | i4, i5 := slices.Index(ids, 4), slices.Index(ids, 5) | |||
| 481 | + | // Absent tiles by ID, then the leaving stash float (in its | |||
| 482 | + | // slot), the absent float last. | |||
| 483 | + | if !(i2 >= 0 && i2 < i3 && i3 < i4 && i4 < i5 && i5 == len(ids)-1) { | |||
| 484 | + | t.Fatalf("order %v: want tiles 2, 3, then the stash float 4, then 5", ids) | |||
| 485 | + | } | |||
| 486 | + | if k := slices.Index(ids, 4); !sc.settledLayout[k].Leaving { | |||
| 487 | + | t.Fatal("the hidden stash window is not replaced in place") | |||
| 488 | + | } | |||
| 489 | + | continue | |||
| 490 | + | } | |||
| 491 | + | if !slices.Equal(ids, want) { | |||
| 492 | + | t.Fatalf("run %d: order %v, first run %v", i, ids, want) | |||
| 493 | + | } | |||
| 494 | + | } | |||
| 495 | + | } | |||