detached ac8eeb6

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
326326 return p
327327 }
328328
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.
349335 func (c *Core) appearAt(sc *screen, p Placement, now time.Time) {
350336 if !c.animOn() || p.Hidden {
351337 return
@@ -481,7 +467,12 @@ func (c *Core) unmapWindow(v ports.WindowUnmapped) {
481467 }
482468 }
483469 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+ }
485476 c.transition(shots, now)
486477 if left.ID == v.ID && !left.Leaving && !left.Fullscreen && !left.Below && !left.Peek {
487478 c.leaveFrom(sc, left, shot, now)
@@ -535,17 +526,12 @@ func settledRect(m *Monitor, id WindowID) Rect {
535526 return Rect{}
536527 }
537528
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
539530 // settled placement p: it fades out and shrinks to 90 % around its centre,
540531 // 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
549535 // during an entrance or a slide does not jump.
550536 func (c *Core) leaveFrom(sc *screen, p Placement, shot *rectShot, now time.Time) {
551537 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 p326 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 not331+// 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 return337 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 last529+// 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 that531 // 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) {
403403 t.Fatalf("remapped window settled: %+v", w)
404404 }
405405 }
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+}

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