fix(core): a window closed while its stash hide fades keeps that fade
unmapWindow dropped the window's rect motion before starting a fresh exit; when the motion was already a leaving one (a stash hide still fading) the fade restarted. It is kept and runs on from where it was, drawn from the content the renderer kept.
- commit
- ac8eeb64d80cf1cf10ae45b2857bf3aeb8d46f33
- author
- brice · · 2026-10-05 02:45 UTC
- parent
- 512d7fc
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +72 -30
M internal/core/transition.go +16 -30
| @@ -326,26 +326,12 @@ func (m rectMotion) leavingPlacement() Placement { | |||
| 326 | 326 | return p | |
| 327 | 327 | } | |
| 328 | 328 | ||
| 329 | - | // appear starts the entrance of a window the action made visible on sc: | |
| 330 | - | // it fades in from invisible and grows from 90 % of its settled rect, | |
| 331 | - | // around its centre. Nothing with animations off or if the layout does not | |
| 332 | - | // show it. A leaving motion of the same window is replaced. | |
| 333 | - | func (c *Core) appear(sc *screen, id WindowID, now time.Time) { | |
| 334 | - | if !c.animOn() { | |
| 335 | - | return | |
| 336 | - | } | |
| 337 | - | for _, p := range sc.mon.Layout() { | |
| 338 | - | if p.ID == id && !p.Hidden { | |
| 339 | - | c.appearAt(sc, p, now) | |
| 340 | - | return | |
| 341 | - | } | |
| 342 | - | } | |
| 343 | - | } | |
| 344 | - | ||
| 345 | - | // appearAt is appear for a window whose settled placement is p. A leaving | |
| 346 | - | // motion of the same window (a show during a hide) is continued from, not | |
| 347 | - | // replaced: the entrance starts at the fade, rect and speeds it was drawn | |
| 348 | - | // with. | |
| 329 | + | // appearAt starts the entrance of a window the action made visible on sc, | |
| 330 | + | // whose settled placement is p: it fades in from invisible and grows from | |
| 331 | + | // 90 % of its settled rect, around its centre. Nothing with animations off | |
| 332 | + | // or for a hidden placement. A leaving motion of the same window (a show | |
| 333 | + | // during a hide) is continued from, not replaced: the entrance starts at | |
| 334 | + | // the fade, rect and speeds it was drawn with. | |
| 349 | 335 | func (c *Core) appearAt(sc *screen, p Placement, now time.Time) { | |
| 350 | 336 | if !c.animOn() || p.Hidden { | |
| 351 | 337 | return | |
| @@ -481,7 +467,12 @@ func (c *Core) unmapWindow(v ports.WindowUnmapped) { | |||
| 481 | 467 | } | |
| 482 | 468 | } | |
| 483 | 469 | sc.mon.RemoveWindow(v.ID) | |
| 484 | - | delete(sc.rects, v.ID) | |
| 470 | + | // A window already leaving (a stash hide still fading) keeps its | |
| 471 | + | // motion: the fade goes on from where it is, drawn from the content | |
| 472 | + | // the renderer kept. Any other motion goes with the window. | |
| 473 | + | if rm, ok := sc.rects[v.ID]; !ok || !rm.leaving { | |
| 474 | + | delete(sc.rects, v.ID) | |
| 475 | + | } | |
| 485 | 476 | c.transition(shots, now) | |
| 486 | 477 | if left.ID == v.ID && !left.Leaving && !left.Fullscreen && !left.Below && !left.Peek { | |
| 487 | 478 | c.leaveFrom(sc, left, shot, now) | |
| @@ -535,17 +526,12 @@ func settledRect(m *Monitor, id WindowID) Rect { | |||
| 535 | 526 | return Rect{} | |
| 536 | 527 | } | |
| 537 | 528 | ||
| 538 | - | // leave keeps drawing a window the action closed or hid, from its last | |
| 529 | + | // leaveFrom keeps drawing a window the action closed or hid, from its last | |
| 539 | 530 | // settled placement p: it fades out and shrinks to 90 % around its centre, | |
| 540 | 531 | // then its entry goes. Nothing with animations off, or for a placement that | |
| 541 | - | // was not drawn. | |
| 542 | - | func (c *Core) leave(sc *screen, p Placement, now time.Time) { | |
| 543 | - | c.leaveFrom(sc, p, nil, now) | |
| 544 | - | } | |
| 545 | - | ||
| 546 | - | // leaveFrom is leave for a window that was drawn as shot says (the | |
| 547 | - | // snapshot's record of it, nil for a window at rest): the exit starts at the | |
| 548 | - | // rect, fade and dim the window had on screen, with their speeds, so a hide | |
| 532 | + | // was not drawn. shot is the snapshot's record of how the window was drawn | |
| 533 | + | // (nil for a window at rest): the exit starts at the rect, fade and dim the | |
| 534 | + | // window had on screen, with their speeds, so a hide | |
| 549 | 535 | // during an entrance or a slide does not jump. | |
| 550 | 536 | func (c *Core) leaveFrom(sc *screen, p Placement, shot *rectShot, now time.Time) { | |
| 551 | 537 | if !c.animOn() || p.Hidden || p.Preview > 0 || p.Rect.W <= 0 || p.Rect.H <= 0 { | |
| @@ -326,26 +326,12 @@ func (m rectMotion) leavingPlacement() Placement { | |||||
| 326 | return p | 326 | return p | ||
| 327 | } | 327 | } | ||
| 328 | 328 | ||||
| 329 | - | // appear starts the entrance of a window the action made visible on sc: | 329 | + | // appearAt starts the entrance of a window the action made visible on sc, |
| 330 | - | // it fades in from invisible and grows from 90 % of its settled rect, | 330 | + | // whose settled placement is p: it fades in from invisible and grows from |
| 331 | - | // around its centre. Nothing with animations off or if the layout does not | 331 | + | // 90 % of its settled rect, around its centre. Nothing with animations off |
| 332 | - | // show it. A leaving motion of the same window is replaced. | 332 | + | // or for a hidden placement. A leaving motion of the same window (a show |
| 333 | - | func (c *Core) appear(sc *screen, id WindowID, now time.Time) { | 333 | + | // during a hide) is continued from, not replaced: the entrance starts at |
| 334 | - | if !c.animOn() { | 334 | + | // the fade, rect and speeds it was drawn with. |
| 335 | - | return | |||
| 336 | - | } | |||
| 337 | - | for _, p := range sc.mon.Layout() { | |||
| 338 | - | if p.ID == id && !p.Hidden { | |||
| 339 | - | c.appearAt(sc, p, now) | |||
| 340 | - | return | |||
| 341 | - | } | |||
| 342 | - | } | |||
| 343 | - | } | |||
| 344 | - | ||||
| 345 | - | // appearAt is appear for a window whose settled placement is p. A leaving | |||
| 346 | - | // motion of the same window (a show during a hide) is continued from, not | |||
| 347 | - | // replaced: the entrance starts at the fade, rect and speeds it was drawn | |||
| 348 | - | // with. | |||
| 349 | func (c *Core) appearAt(sc *screen, p Placement, now time.Time) { | 335 | func (c *Core) appearAt(sc *screen, p Placement, now time.Time) { | ||
| 350 | if !c.animOn() || p.Hidden { | 336 | if !c.animOn() || p.Hidden { | ||
| 351 | return | 337 | return | ||
| @@ -481,7 +467,12 @@ func (c *Core) unmapWindow(v ports.WindowUnmapped) { | |||||
| 481 | } | 467 | } | ||
| 482 | } | 468 | } | ||
| 483 | sc.mon.RemoveWindow(v.ID) | 469 | sc.mon.RemoveWindow(v.ID) | ||
| 484 | - | delete(sc.rects, v.ID) | 470 | + | // A window already leaving (a stash hide still fading) keeps its |
| 471 | + | // motion: the fade goes on from where it is, drawn from the content | |||
| 472 | + | // the renderer kept. Any other motion goes with the window. | |||
| 473 | + | if rm, ok := sc.rects[v.ID]; !ok || !rm.leaving { | |||
| 474 | + | delete(sc.rects, v.ID) | |||
| 475 | + | } | |||
| 485 | c.transition(shots, now) | 476 | c.transition(shots, now) | ||
| 486 | if left.ID == v.ID && !left.Leaving && !left.Fullscreen && !left.Below && !left.Peek { | 477 | if left.ID == v.ID && !left.Leaving && !left.Fullscreen && !left.Below && !left.Peek { | ||
| 487 | c.leaveFrom(sc, left, shot, now) | 478 | c.leaveFrom(sc, left, shot, now) | ||
| @@ -535,17 +526,12 @@ func settledRect(m *Monitor, id WindowID) Rect { | |||||
| 535 | return Rect{} | 526 | return Rect{} | ||
| 536 | } | 527 | } | ||
| 537 | 528 | ||||
| 538 | - | // leave keeps drawing a window the action closed or hid, from its last | 529 | + | // leaveFrom keeps drawing a window the action closed or hid, from its last |
| 539 | // settled placement p: it fades out and shrinks to 90 % around its centre, | 530 | // settled placement p: it fades out and shrinks to 90 % around its centre, | ||
| 540 | // then its entry goes. Nothing with animations off, or for a placement that | 531 | // then its entry goes. Nothing with animations off, or for a placement that | ||
| 541 | - | // was not drawn. | 532 | + | // was not drawn. shot is the snapshot's record of how the window was drawn |
| 542 | - | func (c *Core) leave(sc *screen, p Placement, now time.Time) { | 533 | + | // (nil for a window at rest): the exit starts at the rect, fade and dim the |
| 543 | - | c.leaveFrom(sc, p, nil, now) | 534 | + | // window had on screen, with their speeds, so a hide |
| 544 | - | } | |||
| 545 | - | ||||
| 546 | - | // leaveFrom is leave for a window that was drawn as shot says (the | |||
| 547 | - | // snapshot's record of it, nil for a window at rest): the exit starts at the | |||
| 548 | - | // rect, fade and dim the window had on screen, with their speeds, so a hide | |||
| 549 | // during an entrance or a slide does not jump. | 535 | // during an entrance or a slide does not jump. | ||
| 550 | func (c *Core) leaveFrom(sc *screen, p Placement, shot *rectShot, now time.Time) { | 536 | func (c *Core) leaveFrom(sc *screen, p Placement, shot *rectShot, now time.Time) { | ||
| 551 | if !c.animOn() || p.Hidden || p.Preview > 0 || p.Rect.W <= 0 || p.Rect.H <= 0 { | 537 | if !c.animOn() || p.Hidden || p.Preview > 0 || p.Rect.W <= 0 || p.Rect.H <= 0 { | ||
M internal/core/transition_unmap_internal_test.go +56 -0
| @@ -403,3 +403,59 @@ func TestUnmapThenRemapDuringFade(t *testing.T) { | |||
| 403 | 403 | t.Fatalf("remapped window settled: %+v", w) | |
| 404 | 404 | } | |
| 405 | 405 | } | |
| 406 | + | ||
| 407 | + | // A stash window that closes while its hide still fades keeps that fade: | |
| 408 | + | // the motion runs on from where it was (the renderer draws it from the | |
| 409 | + | // content it kept), and it is not restarted as a fresh exit. Its configure | |
| 410 | + | // record is forgotten at the unmap and not kept while it fades, so a | |
| 411 | + | // remapped window with the same ID starts from nothing. | |
| 412 | + | func TestUnmapDuringStashHideKeepsTheFade(t *testing.T) { | |
| 413 | + | c, ic, sc, _ := stashRig(t, true) | |
| 414 | + | act(c, ic, ActionToggleStashVisible) // 2 and 3 hide, leaving in place | |
| 415 | + | indicatorScene(t, c) | |
| 416 | + | ic.now = ic.now.Add(40 * time.Millisecond) | |
| 417 | + | c.animate(ic.now, nil) | |
| 418 | + | s := indicatorScene(t, c) | |
| 419 | + | before := sceneWindow(t, s, 2) | |
| 420 | + | rm, ok := sc.rects[2] | |
| 421 | + | if !ok || !rm.leaving || !(before.Fade > 0 && before.Fade < 1) { | |
| 422 | + | t.Fatalf("setup: leaving %v fade %v", ok && rm.leaving, before.Fade) | |
| 423 | + | } | |
| 424 | + | c.configures.sent[2] = ports.ConfigureWindow{ID: 2, Width: 10} | |
| 425 | + | c.unmapWindow(ports.WindowUnmapped{ID: 2}) | |
| 426 | + | after, ok := sc.rects[2] | |
| 427 | + | if !ok || !after.leaving || after.fade != rm.fade || after.x != rm.x { | |
| 428 | + | t.Fatalf("the hide's motion was replaced: before %+v after %+v", rm, after) | |
| 429 | + | } | |
| 430 | + | s = indicatorScene(t, c) | |
| 431 | + | if w := sceneWindow(t, s, 2); w.Fade < before.Fade || w.Hidden { | |
| 432 | + | t.Fatalf("after the unmap: %+v, want the fade to go on from %v", w, before.Fade) | |
| 433 | + | } | |
| 434 | + | if _, kept := c.configures.sent[2]; kept { | |
| 435 | + | t.Fatal("a closed window's configure survived the publish") | |
| 436 | + | } | |
| 437 | + | ic.now = ic.now.Add(5 * time.Second) | |
| 438 | + | c.animate(ic.now, nil) | |
| 439 | + | s = indicatorScene(t, c) | |
| 440 | + | if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) || len(sc.rects) != 0 { | |
| 441 | + | t.Fatalf("after the settle: %+v", s.Windows) | |
| 442 | + | } | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | // A stash window that hides keeps its configure while it fades: the | |
| 446 | + | // workspace still holds it, and its next show must not start from nothing. | |
| 447 | + | func TestStashHideKeepsConfigureWhileFading(t *testing.T) { | |
| 448 | + | c, ic, sc, _ := stashRig(t, true) | |
| 449 | + | act(c, ic, ActionToggleStashVisible) | |
| 450 | + | for range 3 { | |
| 451 | + | ic.now = ic.now.Add(20 * time.Millisecond) | |
| 452 | + | c.animate(ic.now, nil) | |
| 453 | + | indicatorScene(t, c) | |
| 454 | + | } | |
| 455 | + | if _, ok := sc.rects[2]; !ok { | |
| 456 | + | t.Fatal("setup: window 2 is not fading") | |
| 457 | + | } | |
| 458 | + | if _, ok := c.configures.sent[2]; !ok { | |
| 459 | + | t.Fatal("the hidden stash window's configure was pruned while it fades") | |
| 460 | + | } | |
| 461 | + | } | |
| @@ -403,3 +403,59 @@ func TestUnmapThenRemapDuringFade(t *testing.T) { | |||||
| 403 | t.Fatalf("remapped window settled: %+v", w) | 403 | t.Fatalf("remapped window settled: %+v", w) | ||
| 404 | } | 404 | } | ||
| 405 | } | 405 | } | ||
| 406 | + | ||||
| 407 | + | // A stash window that closes while its hide still fades keeps that fade: | |||
| 408 | + | // the motion runs on from where it was (the renderer draws it from the | |||
| 409 | + | // content it kept), and it is not restarted as a fresh exit. Its configure | |||
| 410 | + | // record is forgotten at the unmap and not kept while it fades, so a | |||
| 411 | + | // remapped window with the same ID starts from nothing. | |||
| 412 | + | func TestUnmapDuringStashHideKeepsTheFade(t *testing.T) { | |||
| 413 | + | c, ic, sc, _ := stashRig(t, true) | |||
| 414 | + | act(c, ic, ActionToggleStashVisible) // 2 and 3 hide, leaving in place | |||
| 415 | + | indicatorScene(t, c) | |||
| 416 | + | ic.now = ic.now.Add(40 * time.Millisecond) | |||
| 417 | + | c.animate(ic.now, nil) | |||
| 418 | + | s := indicatorScene(t, c) | |||
| 419 | + | before := sceneWindow(t, s, 2) | |||
| 420 | + | rm, ok := sc.rects[2] | |||
| 421 | + | if !ok || !rm.leaving || !(before.Fade > 0 && before.Fade < 1) { | |||
| 422 | + | t.Fatalf("setup: leaving %v fade %v", ok && rm.leaving, before.Fade) | |||
| 423 | + | } | |||
| 424 | + | c.configures.sent[2] = ports.ConfigureWindow{ID: 2, Width: 10} | |||
| 425 | + | c.unmapWindow(ports.WindowUnmapped{ID: 2}) | |||
| 426 | + | after, ok := sc.rects[2] | |||
| 427 | + | if !ok || !after.leaving || after.fade != rm.fade || after.x != rm.x { | |||
| 428 | + | t.Fatalf("the hide's motion was replaced: before %+v after %+v", rm, after) | |||
| 429 | + | } | |||
| 430 | + | s = indicatorScene(t, c) | |||
| 431 | + | if w := sceneWindow(t, s, 2); w.Fade < before.Fade || w.Hidden { | |||
| 432 | + | t.Fatalf("after the unmap: %+v, want the fade to go on from %v", w, before.Fade) | |||
| 433 | + | } | |||
| 434 | + | if _, kept := c.configures.sent[2]; kept { | |||
| 435 | + | t.Fatal("a closed window's configure survived the publish") | |||
| 436 | + | } | |||
| 437 | + | ic.now = ic.now.Add(5 * time.Second) | |||
| 438 | + | c.animate(ic.now, nil) | |||
| 439 | + | s = indicatorScene(t, c) | |||
| 440 | + | if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) || len(sc.rects) != 0 { | |||
| 441 | + | t.Fatalf("after the settle: %+v", s.Windows) | |||
| 442 | + | } | |||
| 443 | + | } | |||
| 444 | + | ||||
| 445 | + | // A stash window that hides keeps its configure while it fades: the | |||
| 446 | + | // workspace still holds it, and its next show must not start from nothing. | |||
| 447 | + | func TestStashHideKeepsConfigureWhileFading(t *testing.T) { | |||
| 448 | + | c, ic, sc, _ := stashRig(t, true) | |||
| 449 | + | act(c, ic, ActionToggleStashVisible) | |||
| 450 | + | for range 3 { | |||
| 451 | + | ic.now = ic.now.Add(20 * time.Millisecond) | |||
| 452 | + | c.animate(ic.now, nil) | |||
| 453 | + | indicatorScene(t, c) | |||
| 454 | + | } | |||
| 455 | + | if _, ok := sc.rects[2]; !ok { | |||
| 456 | + | t.Fatal("setup: window 2 is not fading") | |||
| 457 | + | } | |||
| 458 | + | if _, ok := c.configures.sent[2]; !ok { | |||
| 459 | + | t.Fatal("the hidden stash window's configure was pruned while it fades") | |||
| 460 | + | } | |||
| 461 | + | } | |||