detached 684c464

feat(core): animate the stash show, hide and navigation

toggle-stash-visible makes the windows it brings on screen appear and
the ones it hides leave (fade and 90 % scale, from their last settled
placement); a navigation that brings a window to a margin or sends a
peek off does the same. The peek veil goes through the dim component,
as an offset from the configured Stash.Dim.
commit
684c46430f96958d1e61f0e9a1955f2678235151
author
brice · · 2026-10-04 23:52 UTC
parent
4e1c07b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +429 -24
M internal/core/configures.go +4 -0
@@ -130,6 +130,10 @@ func (s *configures) mark(v ports.ConfigureWindow) {
130130 delete(s.answer, v.ID)
131131 }
132132
133+// keep marks id as seen without a configure: a window drawn while it leaves
134+// keeps what was sent to it.
135+func (s *configures) keep(id WindowID) { s.seen[id] = true }
136+
133137 // prune forgets the windows no next call saw since the last prune: they
134138 // left the layout.
135139 func (s *configures) prune() {
@@ -130,6 +130,10 @@ func (s *configures) mark(v ports.ConfigureWindow) {
130 delete(s.answer, v.ID)130 delete(s.answer, v.ID)
131 }131 }
132 132
133+// keep marks id as seen without a configure: a window drawn while it leaves
134+// keeps what was sent to it.
135+func (s *configures) keep(id WindowID) { s.seen[id] = true }
136+
133 // prune forgets the windows no next call saw since the last prune: they137 // prune forgets the windows no next call saw since the last prune: they
134 // left the layout.138 // left the layout.
135 func (s *configures) prune() {139 func (s *configures) prune() {
M internal/core/core.go +7 -4
@@ -608,21 +608,24 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
608608 ps := settled[k]
609609 // Only the focused output has an activated window.
610610 focused := p.Focused && i == c.focusScreen
611- sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade, Dim: max(0, p.Dim)}
611+ sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade}
612612 if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) {
613613 // A scale motion: the content follows the drawn size. A
614614 // card in flight drawn at its size still needs Zoom 1:
615615 // without it the renderer would shrink it by Preview.
616616 sw.Zoom = p.Zoom
617617 }
618- if p.Peek {
619- sw.Dim = min(sw.Dim+c.cfg.Stash.Dim, 1)
620- }
618+ // A peek's veil is the configured one plus its animated offset.
619+ sw.Dim = max(0, min(p.Dim+c.peekDim(p), 1))
621620 if p.Leaving {
622621 // A window fading out after it closed or hid: drawn, but
623622 // hidden to everything else, its configures included.
624623 sw.Hidden = false
625624 scene.Windows = append(scene.Windows, sw)
625+ // A window the layout still holds keeps its last configure
626+ // (prune would forget it, and its next one would start from
627+ // nothing).
628+ c.configures.keep(p.ID)
626629 continue
627630 }
628631 if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open {
@@ -608,21 +608,24 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
608 ps := settled[k]608 ps := settled[k]
609 // Only the focused output has an activated window.609 // Only the focused output has an activated window.
610 focused := p.Focused && i == c.focusScreen610 focused := p.Focused && i == c.focusScreen
611- sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade, Dim: max(0, p.Dim)}611+ sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade}
612 if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) {612 if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) {
613 // A scale motion: the content follows the drawn size. A613 // A scale motion: the content follows the drawn size. A
614 // card in flight drawn at its size still needs Zoom 1:614 // card in flight drawn at its size still needs Zoom 1:
615 // without it the renderer would shrink it by Preview.615 // without it the renderer would shrink it by Preview.
616 sw.Zoom = p.Zoom616 sw.Zoom = p.Zoom
617 }617 }
618- if p.Peek {618+ // A peek's veil is the configured one plus its animated offset.
619- sw.Dim = min(sw.Dim+c.cfg.Stash.Dim, 1)619+ sw.Dim = max(0, min(p.Dim+c.peekDim(p), 1))
620- }
621 if p.Leaving {620 if p.Leaving {
622 // A window fading out after it closed or hid: drawn, but621 // A window fading out after it closed or hid: drawn, but
623 // hidden to everything else, its configures included.622 // hidden to everything else, its configures included.
624 sw.Hidden = false623 sw.Hidden = false
625 scene.Windows = append(scene.Windows, sw)624 scene.Windows = append(scene.Windows, sw)
625+ // A window the layout still holds keeps its last configure
626+ // (prune would forget it, and its next one would start from
627+ // nothing).
628+ c.configures.keep(p.ID)
626 continue629 continue
627 }630 }
628 if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open {631 if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open {
M internal/core/screens_test.go +15 -9
@@ -1024,18 +1024,24 @@ func TestFocusColumnFromFloatStaysOnMonitor(t *testing.T) {
10241024 // and their popups wait, a click selects a peek, and the script state
10251025 // tells the stash and whether it is hidden.
10261026 func TestStashEndToEnd(t *testing.T) {
1027- r := startMulti(t, func(c *ports.Config) { c.Stash.Gap, c.Stash.Dim = 0, 0.5 }, left)
1027+ both(t, func(t *testing.T, animated bool) {
1028+ r := startLanding(t, animated, func(c *ports.Config) { c.Stash.Gap, c.Stash.Dim = 0, 0.5 }, left)
1029+ stashEndToEnd(t, r)
1030+ })
1031+}
1032+
1033+func stashEndToEnd(t *testing.T, r *landRig) {
10281034 for id := ports.WindowID(1); id <= 3; id++ {
1029- r.mapWindow(t, id)
1035+ r.mapLanded(t, id)
10301036 }
1031- r.key(t, "s", ports.ModAlt|ports.ModShift) // 3 stashed
1032- r.key(t, "Left", ports.ModAlt) // stays on 3: its stash's only window
1033- sc := r.key(t, "s", ports.ModAlt)
1037+ r.keyLanded(t, "s", ports.ModAlt|ports.ModShift) // 3 stashed
1038+ r.keyLanded(t, "Left", ports.ModAlt) // stays on 3: its stash's only window
1039+ sc := r.keyLanded(t, "s", ports.ModAlt)
10341040 if got := shown(sc)["DP-1"]; !slices.Equal(got, []ports.WindowID{1, 2}) {
10351041 t.Fatalf("hidden stash: %v", got)
10361042 }
1037- r.key(t, "Left", ports.ModAlt) // tiles: from 2 to 1
1038- sc = r.key(t, "s", ports.ModAlt|ports.ModShift)
1043+ r.keyLanded(t, "Left", ports.ModAlt) // tiles: from 2 to 1
1044+ sc = r.keyLanded(t, "s", ports.ModAlt|ports.ModShift)
10391045 // Stash 3 1: 1 selected in the middle, 3 peeking left, dimmed.
10401046 var peek, sel ports.SceneWindow
10411047 for _, w := range sc[0].Windows {
@@ -1061,7 +1067,7 @@ func TestStashEndToEnd(t *testing.T) {
10611067 // A click on the peek (x 0..19 of 200) selects it.
10621068 r.input <- ports.PointerMotion{X: 5, Y: 50}
10631069 r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
1064- receive(t, r.scenes)
1070+ r.land(receive(t, r.scenes))
10651071 st := receive(t, r.state)
10661072 for st.Window == nil || st.Window.ID != 3 {
10671073 st = receive(t, r.state)
@@ -1077,7 +1083,7 @@ func TestStashEndToEnd(t *testing.T) {
10771083 t.Fatalf("tile %+v", w)
10781084 }
10791085 r.input <- ports.PointerButton{Button: 0x110}
1080- r.key(t, "s", ports.ModAlt)
1086+ r.keyLanded(t, "s", ports.ModAlt)
10811087 st = receive(t, r.state)
10821088 for !st.Windows[0].Hidden {
10831089 st = receive(t, r.state)
@@ -1024,18 +1024,24 @@ func TestFocusColumnFromFloatStaysOnMonitor(t *testing.T) {
1024 // and their popups wait, a click selects a peek, and the script state1024 // and their popups wait, a click selects a peek, and the script state
1025 // tells the stash and whether it is hidden.1025 // tells the stash and whether it is hidden.
1026 func TestStashEndToEnd(t *testing.T) {1026 func TestStashEndToEnd(t *testing.T) {
1027- r := startMulti(t, func(c *ports.Config) { c.Stash.Gap, c.Stash.Dim = 0, 0.5 }, left)1027+ both(t, func(t *testing.T, animated bool) {
1028+ r := startLanding(t, animated, func(c *ports.Config) { c.Stash.Gap, c.Stash.Dim = 0, 0.5 }, left)
1029+ stashEndToEnd(t, r)
1030+ })
1031+}
1032+
1033+func stashEndToEnd(t *testing.T, r *landRig) {
1028 for id := ports.WindowID(1); id <= 3; id++ {1034 for id := ports.WindowID(1); id <= 3; id++ {
1029- r.mapWindow(t, id)1035+ r.mapLanded(t, id)
1030 }1036 }
1031- r.key(t, "s", ports.ModAlt|ports.ModShift) // 3 stashed1037+ r.keyLanded(t, "s", ports.ModAlt|ports.ModShift) // 3 stashed
1032- r.key(t, "Left", ports.ModAlt) // stays on 3: its stash's only window1038+ r.keyLanded(t, "Left", ports.ModAlt) // stays on 3: its stash's only window
1033- sc := r.key(t, "s", ports.ModAlt)1039+ sc := r.keyLanded(t, "s", ports.ModAlt)
1034 if got := shown(sc)["DP-1"]; !slices.Equal(got, []ports.WindowID{1, 2}) {1040 if got := shown(sc)["DP-1"]; !slices.Equal(got, []ports.WindowID{1, 2}) {
1035 t.Fatalf("hidden stash: %v", got)1041 t.Fatalf("hidden stash: %v", got)
1036 }1042 }
1037- r.key(t, "Left", ports.ModAlt) // tiles: from 2 to 11043+ r.keyLanded(t, "Left", ports.ModAlt) // tiles: from 2 to 1
1038- sc = r.key(t, "s", ports.ModAlt|ports.ModShift)1044+ sc = r.keyLanded(t, "s", ports.ModAlt|ports.ModShift)
1039 // Stash 3 1: 1 selected in the middle, 3 peeking left, dimmed.1045 // Stash 3 1: 1 selected in the middle, 3 peeking left, dimmed.
1040 var peek, sel ports.SceneWindow1046 var peek, sel ports.SceneWindow
1041 for _, w := range sc[0].Windows {1047 for _, w := range sc[0].Windows {
@@ -1061,7 +1067,7 @@ func TestStashEndToEnd(t *testing.T) {
1061 // A click on the peek (x 0..19 of 200) selects it.1067 // A click on the peek (x 0..19 of 200) selects it.
1062 r.input <- ports.PointerMotion{X: 5, Y: 50}1068 r.input <- ports.PointerMotion{X: 5, Y: 50}
1063 r.input <- ports.PointerButton{Button: 0x110, Pressed: true}1069 r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
1064- receive(t, r.scenes)1070+ r.land(receive(t, r.scenes))
1065 st := receive(t, r.state)1071 st := receive(t, r.state)
1066 for st.Window == nil || st.Window.ID != 3 {1072 for st.Window == nil || st.Window.ID != 3 {
1067 st = receive(t, r.state)1073 st = receive(t, r.state)
@@ -1077,7 +1083,7 @@ func TestStashEndToEnd(t *testing.T) {
1077 t.Fatalf("tile %+v", w)1083 t.Fatalf("tile %+v", w)
1078 }1084 }
1079 r.input <- ports.PointerButton{Button: 0x110}1085 r.input <- ports.PointerButton{Button: 0x110}
1080- r.key(t, "s", ports.ModAlt)1086+ r.keyLanded(t, "s", ports.ModAlt)
1081 st = receive(t, r.state)1087 st = receive(t, r.state)
1082 for !st.Windows[0].Hidden {1088 for !st.Windows[0].Hidden {
1083 st = receive(t, r.state)1089 st = receive(t, r.state)
M internal/core/transition.go +91 -11
@@ -46,6 +46,9 @@ type viewShot struct {
4646 // rects are the windows the screen drew (cards in the overview),
4747 // hidden windows left out. The slice is reused like list.
4848 rects []rectShot
49+ // stash holds the settled placements of the stash windows the screen
50+ // drew, for the ones an action hides (transitionStash). Reused too.
51+ stash []Placement
4952 }
5053
5154 // rectOffsets are the four components of a rect motion: x, y, w, h.
@@ -62,6 +65,11 @@ type rectShot struct {
6265 // fade and fadeV are the fade offset of a running fade motion and its
6366 // speed, so an overview toggle carries it over (transitionOverview).
6467 fade, fadeV float64
68+ // dim and dimV are the dim offset of a running dim motion and its
69+ // speed; veil is the peek veil the window's settled placement drew
70+ // (Stash.Dim, 0 for another window). A stash navigation takes the
71+ // veil from what was on screen to the new one (transitionRects).
72+ dim, dimV, veil float64
6573 }
6674
6775 // rectMotion takes a window's drawn rect to its settled one: each component
@@ -159,7 +167,13 @@ func (m *rectMotion) show(p *Placement) {
159167 settled := p.Rect
160168 p.Rect = m.apply(settled)
161169 p.Fade = max(0, min(1, m.settledFade()+m.df))
162- p.Dim = max(0, min(1, p.Dim+m.ddim))
170+ // A peek's Dim is an offset from its veil (publish adds Stash.Dim and
171+ // clamps the sum): it may go below 0, down to cancelling the veil.
172+ lo := 0.0
173+ if p.Peek {
174+ lo = -1
175+ }
176+ p.Dim = max(lo, min(1, p.Dim+m.ddim))
163177 if m.scale && settled.W > 0 && settled.H > 0 {
164178 zoom := p.Preview
165179 if zoom == 0 {
@@ -243,7 +257,8 @@ func (c *Core) refreshShown() {
243257
244258 // withLeaving is layout with the leaving windows' last placements added,
245259 // Hidden and Leaving, into the screen's reused buffer (the layout itself
246-// is left alone). A leaving tile goes where the tiles are painted: before
260+// is left alone). A window the layout still lists, Hidden (a hidden stash),
261+// is replaced where it is. A leaving tile goes where the tiles are painted: before
247262 // the first window that opens the floats (a float above the tiles, as the
248263 // renderer orders them), so it never flashes over a float; a leaving float
249264 // goes last, over the tiles. Within a group smallest ID first: a stable
@@ -263,6 +278,12 @@ func (s *screen) withLeaving(layout []Placement) []Placement {
263278 }
264279 p := rm.left
265280 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+ }
266287 lo, hi := floats, len(out)
267288 if !p.Floating {
268289 lo, hi = 0, floats
@@ -467,6 +488,7 @@ func (c *Core) snapshot(now time.Time) []viewShot {
467488 if len(out) < len(full) {
468489 s.list = full[len(out)].list[:0]
469490 s.rects = full[len(out)].rects[:0]
491+ s.stash = full[len(out)].stash[:0]
470492 }
471493 s.sc = sc
472494 w := m.Current()
@@ -493,8 +515,14 @@ func (c *Core) snapshot(now time.Time) []viewShot {
493515 r.off, r.vel = rm.sample(now)
494516 r.fade = rm.df
495517 sampleComponent(rm.fade, &r.fade, &r.fadeV, now)
518+ r.dim = rm.ddim
519+ sampleComponent(rm.dim, &r.dim, &r.dimV, now)
496520 }
521+ r.veil = c.peekDim(p)
497522 s.rects = append(s.rects, r)
523+ if !m.ov.open && w.stashIndex(p.ID) >= 0 {
524+ s.stash = append(s.stash, p)
525+ }
498526 }
499527 if m.shown == nil && !m.ov.open {
500528 // A landing slide measures in the list it began on.
@@ -544,6 +572,7 @@ func (c *Core) transition(before []viewShot, now time.Time) {
544572 }
545573 c.transitionCamera(b, before, now)
546574 c.transitionRects(b, before, now)
575+ c.transitionStash(b, now)
547576 }
548577 }
549578
@@ -619,7 +648,12 @@ func (c *Core) transitionRects(b *viewShot, before []viewShot, now time.Time) {
619648 }
620649 old := &from.rects[i]
621650 oldRect := Rect{X: old.rect.X + dx, Y: old.rect.Y + dy, W: old.rect.W, H: old.rect.H}
622- if oldRect == p.Rect {
651+ // A window that becomes a peek or stops being one (a stash
652+ // navigation) changes its veil: the offset takes the one drawn to
653+ // the new one. Otherwise a running dim motion goes on as it is.
654+ dimOff := old.dim + old.veil - c.peekDim(p)
655+ dimMoves := old.veil != c.peekDim(p)
656+ if oldRect == p.Rect && !dimMoves {
623657 // The settled rect did not change: a running motion goes on
624658 // as it is, and there is none to start.
625659 continue
@@ -629,14 +663,19 @@ func (c *Core) transitionRects(b *viewShot, before []viewShot, now time.Time) {
629663 }
630664 // Where the window was drawn, unrounded, relative to where it goes.
631665 rm := sc.rects[p.ID]
632- // A re-flow may change the aspect: the content zoom (appear) stops
633- // following the frame and snaps to the settled size; the fade goes
634- // on.
635- rm.scale = false
636- c.retargetComponent(&rm.x, &rm.dx, float64(oldRect.X-p.Rect.X)+old.off.x, old.vel.x, now)
637- c.retargetComponent(&rm.y, &rm.dy, float64(oldRect.Y-p.Rect.Y)+old.off.y, old.vel.y, now)
638- c.retargetComponent(&rm.w, &rm.dw, float64(oldRect.W-p.Rect.W)+old.off.w, old.vel.w, now)
639- c.retargetComponent(&rm.h, &rm.dh, float64(oldRect.H-p.Rect.H)+old.off.h, old.vel.h, now)
666+ if oldRect != p.Rect {
667+ // A re-flow may change the aspect: the content zoom (appear)
668+ // stops following the frame and snaps to the settled size; the
669+ // fade goes on.
670+ rm.scale = false
671+ c.retargetComponent(&rm.x, &rm.dx, float64(oldRect.X-p.Rect.X)+old.off.x, old.vel.x, now)
672+ c.retargetComponent(&rm.y, &rm.dy, float64(oldRect.Y-p.Rect.Y)+old.off.y, old.vel.y, now)
673+ c.retargetComponent(&rm.w, &rm.dw, float64(oldRect.W-p.Rect.W)+old.off.w, old.vel.w, now)
674+ c.retargetComponent(&rm.h, &rm.dh, float64(oldRect.H-p.Rect.H)+old.off.h, old.vel.h, now)
675+ }
676+ if dimMoves {
677+ c.retargetComponent(&rm.dim, &rm.ddim, dimOff, old.dimV, now)
678+ }
640679 if rm.on() {
641680 sc.rects[p.ID], sc.rectsWS = rm, m.Current()
642681 } else {
@@ -645,6 +684,47 @@ func (c *Core) transitionRects(b *viewShot, before []viewShot, now time.Time) {
645684 }
646685 }
647686
687+// transitionStash animates the stash windows an action shows or hides on
688+// the workspace b's screen keeps on screen (a toggle-stash-visible, a click
689+// that closes the stash, a navigation that brings a window to a margin or
690+// sends it off): one drawn after and not before appears (appearAt), one
691+// drawn before and hidden after leaves from its last settled placement
692+// (leave). A window that is gone from the layout (unstashed, moved) or
693+// fullscreen is not touched: its rect motion, if any, is transitionRects's.
694+// Nothing during a session lock or with a covering fullscreen window (it
695+// keeps its direct scanout path).
696+func (c *Core) transitionStash(b *viewShot, now time.Time) {
697+ sc := b.sc
698+ m := sc.mon
699+ w := m.Current()
700+ if w != b.ws || len(w.Stash) == 0 || c.security.Protected || w.cover() != 0 {
701+ return
702+ }
703+ layout := m.Layout()
704+ for _, p := range b.stash {
705+ i := slices.IndexFunc(layout, func(q Placement) bool { return q.ID == p.ID })
706+ if i >= 0 && layout[i].Hidden && !p.Fullscreen {
707+ c.leave(sc, p, now)
708+ }
709+ }
710+ for _, p := range layout {
711+ if p.Hidden || p.Fullscreen || w.stashIndex(p.ID) < 0 || slices.ContainsFunc(b.rects, func(r rectShot) bool { return r.id == p.ID }) {
712+ continue
713+ }
714+ c.appearAt(sc, p, now)
715+ }
716+}
717+
718+// peekDim is the veil the settled placement p draws: the configured stash
719+// dim for a peek, none for another window. The placement's Dim is an
720+// offset from it.
721+func (c *Core) peekDim(p Placement) float64 {
722+ if p.Peek {
723+ return c.cfg.Stash.Dim
724+ }
725+ return 0
726+}
727+
648728 // transitionOverview starts the motions of an action that opened or closed
649729 // the overview, or moved inside it (the selection, the rows). The camera
650730 // springs stay stopped (ToggleOverview): every window drawn before and
@@ -46,6 +46,9 @@ type viewShot struct {
46 // rects are the windows the screen drew (cards in the overview),46 // rects are the windows the screen drew (cards in the overview),
47 // hidden windows left out. The slice is reused like list.47 // hidden windows left out. The slice is reused like list.
48 rects []rectShot48 rects []rectShot
49+ // stash holds the settled placements of the stash windows the screen
50+ // drew, for the ones an action hides (transitionStash). Reused too.
51+ stash []Placement
49 }52 }
50 53
51 // rectOffsets are the four components of a rect motion: x, y, w, h.54 // rectOffsets are the four components of a rect motion: x, y, w, h.
@@ -62,6 +65,11 @@ type rectShot struct {
62 // fade and fadeV are the fade offset of a running fade motion and its65 // fade and fadeV are the fade offset of a running fade motion and its
63 // speed, so an overview toggle carries it over (transitionOverview).66 // speed, so an overview toggle carries it over (transitionOverview).
64 fade, fadeV float6467 fade, fadeV float64
68+ // dim and dimV are the dim offset of a running dim motion and its
69+ // speed; veil is the peek veil the window's settled placement drew
70+ // (Stash.Dim, 0 for another window). A stash navigation takes the
71+ // veil from what was on screen to the new one (transitionRects).
72+ dim, dimV, veil float64
65 }73 }
66 74
67 // rectMotion takes a window's drawn rect to its settled one: each component75 // rectMotion takes a window's drawn rect to its settled one: each component
@@ -159,7 +167,13 @@ func (m *rectMotion) show(p *Placement) {
159 settled := p.Rect167 settled := p.Rect
160 p.Rect = m.apply(settled)168 p.Rect = m.apply(settled)
161 p.Fade = max(0, min(1, m.settledFade()+m.df))169 p.Fade = max(0, min(1, m.settledFade()+m.df))
162- p.Dim = max(0, min(1, p.Dim+m.ddim))170+ // A peek's Dim is an offset from its veil (publish adds Stash.Dim and
171+ // clamps the sum): it may go below 0, down to cancelling the veil.
172+ lo := 0.0
173+ if p.Peek {
174+ lo = -1
175+ }
176+ p.Dim = max(lo, min(1, p.Dim+m.ddim))
163 if m.scale && settled.W > 0 && settled.H > 0 {177 if m.scale && settled.W > 0 && settled.H > 0 {
164 zoom := p.Preview178 zoom := p.Preview
165 if zoom == 0 {179 if zoom == 0 {
@@ -243,7 +257,8 @@ func (c *Core) refreshShown() {
243 257
244 // withLeaving is layout with the leaving windows' last placements added,258 // withLeaving is layout with the leaving windows' last placements added,
245 // Hidden and Leaving, into the screen's reused buffer (the layout itself259 // Hidden and Leaving, into the screen's reused buffer (the layout itself
246-// is left alone). A leaving tile goes where the tiles are painted: before260+// is left alone). A window the layout still lists, Hidden (a hidden stash),
261+// is replaced where it is. A leaving tile goes where the tiles are painted: before
247 // the first window that opens the floats (a float above the tiles, as the262 // the first window that opens the floats (a float above the tiles, as the
248 // renderer orders them), so it never flashes over a float; a leaving float263 // renderer orders them), so it never flashes over a float; a leaving float
249 // goes last, over the tiles. Within a group smallest ID first: a stable264 // goes last, over the tiles. Within a group smallest ID first: a stable
@@ -263,6 +278,12 @@ func (s *screen) withLeaving(layout []Placement) []Placement {
263 }278 }
264 p := rm.left279 p := rm.left
265 p.Hidden, p.Leaving, p.Focused, p.Fade, p.Dim, p.Zoom = true, true, false, 0, 0, 0280 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+ }
266 lo, hi := floats, len(out)287 lo, hi := floats, len(out)
267 if !p.Floating {288 if !p.Floating {
268 lo, hi = 0, floats289 lo, hi = 0, floats
@@ -467,6 +488,7 @@ func (c *Core) snapshot(now time.Time) []viewShot {
467 if len(out) < len(full) {488 if len(out) < len(full) {
468 s.list = full[len(out)].list[:0]489 s.list = full[len(out)].list[:0]
469 s.rects = full[len(out)].rects[:0]490 s.rects = full[len(out)].rects[:0]
491+ s.stash = full[len(out)].stash[:0]
470 }492 }
471 s.sc = sc493 s.sc = sc
472 w := m.Current()494 w := m.Current()
@@ -493,8 +515,14 @@ func (c *Core) snapshot(now time.Time) []viewShot {
493 r.off, r.vel = rm.sample(now)515 r.off, r.vel = rm.sample(now)
494 r.fade = rm.df516 r.fade = rm.df
495 sampleComponent(rm.fade, &r.fade, &r.fadeV, now)517 sampleComponent(rm.fade, &r.fade, &r.fadeV, now)
518+ r.dim = rm.ddim
519+ sampleComponent(rm.dim, &r.dim, &r.dimV, now)
496 }520 }
521+ r.veil = c.peekDim(p)
497 s.rects = append(s.rects, r)522 s.rects = append(s.rects, r)
523+ if !m.ov.open && w.stashIndex(p.ID) >= 0 {
524+ s.stash = append(s.stash, p)
525+ }
498 }526 }
499 if m.shown == nil && !m.ov.open {527 if m.shown == nil && !m.ov.open {
500 // A landing slide measures in the list it began on.528 // A landing slide measures in the list it began on.
@@ -544,6 +572,7 @@ func (c *Core) transition(before []viewShot, now time.Time) {
544 }572 }
545 c.transitionCamera(b, before, now)573 c.transitionCamera(b, before, now)
546 c.transitionRects(b, before, now)574 c.transitionRects(b, before, now)
575+ c.transitionStash(b, now)
547 }576 }
548 }577 }
549 578
@@ -619,7 +648,12 @@ func (c *Core) transitionRects(b *viewShot, before []viewShot, now time.Time) {
619 }648 }
620 old := &from.rects[i]649 old := &from.rects[i]
621 oldRect := Rect{X: old.rect.X + dx, Y: old.rect.Y + dy, W: old.rect.W, H: old.rect.H}650 oldRect := Rect{X: old.rect.X + dx, Y: old.rect.Y + dy, W: old.rect.W, H: old.rect.H}
622- if oldRect == p.Rect {651+ // A window that becomes a peek or stops being one (a stash
652+ // navigation) changes its veil: the offset takes the one drawn to
653+ // the new one. Otherwise a running dim motion goes on as it is.
654+ dimOff := old.dim + old.veil - c.peekDim(p)
655+ dimMoves := old.veil != c.peekDim(p)
656+ if oldRect == p.Rect && !dimMoves {
623 // The settled rect did not change: a running motion goes on657 // The settled rect did not change: a running motion goes on
624 // as it is, and there is none to start.658 // as it is, and there is none to start.
625 continue659 continue
@@ -629,14 +663,19 @@ func (c *Core) transitionRects(b *viewShot, before []viewShot, now time.Time) {
629 }663 }
630 // Where the window was drawn, unrounded, relative to where it goes.664 // Where the window was drawn, unrounded, relative to where it goes.
631 rm := sc.rects[p.ID]665 rm := sc.rects[p.ID]
632- // A re-flow may change the aspect: the content zoom (appear) stops666+ if oldRect != p.Rect {
633- // following the frame and snaps to the settled size; the fade goes667+ // A re-flow may change the aspect: the content zoom (appear)
634- // on.668+ // stops following the frame and snaps to the settled size; the
635- rm.scale = false669+ // fade goes on.
636- c.retargetComponent(&rm.x, &rm.dx, float64(oldRect.X-p.Rect.X)+old.off.x, old.vel.x, now)670+ rm.scale = false
637- c.retargetComponent(&rm.y, &rm.dy, float64(oldRect.Y-p.Rect.Y)+old.off.y, old.vel.y, now)671+ c.retargetComponent(&rm.x, &rm.dx, float64(oldRect.X-p.Rect.X)+old.off.x, old.vel.x, now)
638- c.retargetComponent(&rm.w, &rm.dw, float64(oldRect.W-p.Rect.W)+old.off.w, old.vel.w, now)672+ c.retargetComponent(&rm.y, &rm.dy, float64(oldRect.Y-p.Rect.Y)+old.off.y, old.vel.y, now)
639- c.retargetComponent(&rm.h, &rm.dh, float64(oldRect.H-p.Rect.H)+old.off.h, old.vel.h, now)673+ c.retargetComponent(&rm.w, &rm.dw, float64(oldRect.W-p.Rect.W)+old.off.w, old.vel.w, now)
674+ c.retargetComponent(&rm.h, &rm.dh, float64(oldRect.H-p.Rect.H)+old.off.h, old.vel.h, now)
675+ }
676+ if dimMoves {
677+ c.retargetComponent(&rm.dim, &rm.ddim, dimOff, old.dimV, now)
678+ }
640 if rm.on() {679 if rm.on() {
641 sc.rects[p.ID], sc.rectsWS = rm, m.Current()680 sc.rects[p.ID], sc.rectsWS = rm, m.Current()
642 } else {681 } else {
@@ -645,6 +684,47 @@ func (c *Core) transitionRects(b *viewShot, before []viewShot, now time.Time) {
645 }684 }
646 }685 }
647 686
687+// transitionStash animates the stash windows an action shows or hides on
688+// the workspace b's screen keeps on screen (a toggle-stash-visible, a click
689+// that closes the stash, a navigation that brings a window to a margin or
690+// sends it off): one drawn after and not before appears (appearAt), one
691+// drawn before and hidden after leaves from its last settled placement
692+// (leave). A window that is gone from the layout (unstashed, moved) or
693+// fullscreen is not touched: its rect motion, if any, is transitionRects's.
694+// Nothing during a session lock or with a covering fullscreen window (it
695+// keeps its direct scanout path).
696+func (c *Core) transitionStash(b *viewShot, now time.Time) {
697+ sc := b.sc
698+ m := sc.mon
699+ w := m.Current()
700+ if w != b.ws || len(w.Stash) == 0 || c.security.Protected || w.cover() != 0 {
701+ return
702+ }
703+ layout := m.Layout()
704+ for _, p := range b.stash {
705+ i := slices.IndexFunc(layout, func(q Placement) bool { return q.ID == p.ID })
706+ if i >= 0 && layout[i].Hidden && !p.Fullscreen {
707+ c.leave(sc, p, now)
708+ }
709+ }
710+ for _, p := range layout {
711+ if p.Hidden || p.Fullscreen || w.stashIndex(p.ID) < 0 || slices.ContainsFunc(b.rects, func(r rectShot) bool { return r.id == p.ID }) {
712+ continue
713+ }
714+ c.appearAt(sc, p, now)
715+ }
716+}
717+
718+// peekDim is the veil the settled placement p draws: the configured stash
719+// dim for a peek, none for another window. The placement's Dim is an
720+// offset from it.
721+func (c *Core) peekDim(p Placement) float64 {
722+ if p.Peek {
723+ return c.cfg.Stash.Dim
724+ }
725+ return 0
726+}
727+
648 // transitionOverview starts the motions of an action that opened or closed728 // transitionOverview starts the motions of an action that opened or closed
649 // the overview, or moved inside it (the selection, the rows). The camera729 // the overview, or moved inside it (the selection, the rows). The camera
650 // springs stay stopped (ToggleOverview): every window drawn before and730 // springs stay stopped (ToggleOverview): every window drawn before and
A internal/core/transition_stash_internal_test.go +312 -0
@@ -0,0 +1,312 @@
1+package core
2+
3+import (
4+ "slices"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// stashRig is indicatorCore with animations on and a stash of windows 4, 3
12+// and 2 (2 selected, 3 peeking on its left) over the tile 1, settled. The
13+// peek veil is 0.5.
14+func stashRig(t *testing.T, on bool) (*Core, *indicatorClock, *screen, chan ports.ClientCommand) {
15+ t.Helper()
16+ c, ic, cmds := fadeCore(t)
17+ c.cfg.Animations.On = on
18+ c.cfg.Stash.Dim = 0.5
19+ sc := c.cur()
20+ for id := WindowID(1); id <= 4; id++ {
21+ sc.mon.AddWindow(id)
22+ }
23+ for _, id := range []WindowID{4, 3, 2} {
24+ sc.mon.Current().FocusID(id)
25+ c.applyAction(ActionToggleWindowStash)
26+ }
27+ settleShown(t, c)
28+ drainConfigures(cmds)
29+ return c, ic, sc, cmds
30+}
31+
32+// act is a bind: snapshot, action, transition.
33+func act(c *Core, ic *indicatorClock, a Action) {
34+ shots := c.snapshot(ic.now)
35+ c.applyAction(a)
36+ c.transition(shots, ic.now)
37+}
38+
39+func settledPlacement(t *testing.T, sc *screen, id WindowID) Placement {
40+ t.Helper()
41+ i := slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == id })
42+ if i < 0 {
43+ t.Fatalf("window %d not in the settled layout", id)
44+ }
45+ return sc.settledLayout[i]
46+}
47+
48+// Showing the stash fades in and grows every window it brings on screen;
49+// the windows it keeps off screen stay out; the settled layout is the
50+// final one at once, with no extra configure.
51+func TestStashShowAppears(t *testing.T) {
52+ c, ic, sc, cmds := stashRig(t, true)
53+ act(c, ic, ActionToggleStashVisible)
54+ settleShown(t, c)
55+ if !sc.mon.Current().stashHidden {
56+ t.Fatal("setup: the stash is shown")
57+ }
58+ ic.now = ic.now.Add(5 * time.Second)
59+ c.animate(ic.now, nil)
60+ settleShown(t, c)
61+ drainConfigures(cmds)
62+
63+ act(c, ic, ActionToggleStashVisible)
64+ s := indicatorScene(t, c)
65+ for _, id := range []WindowID{2, 3} {
66+ p := settledPlacement(t, sc, id)
67+ w := sceneWindow(t, s, id)
68+ if w.Hidden || w.Fade != 1 || w.Rect != scaledRect(p.Rect, appearScale) || w.Zoom < 0.85 || w.Zoom > 0.95 {
69+ t.Fatalf("window %d first frame %+v, want invisible at 90 %% of %+v", id, w, p.Rect)
70+ }
71+ }
72+ if w := sceneWindow(t, s, 4); !w.Hidden || w.Fade != 0 {
73+ t.Fatalf("window 4 stays off screen: %+v", w)
74+ }
75+ // 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)
78+ }
79+
80+ s = frame(t, c, ic, 40*time.Millisecond)
81+ for _, id := range []WindowID{2, 3} {
82+ w := sceneWindow(t, s, id)
83+ if !(w.Fade > 0 && w.Fade < 1) || !(w.Zoom > 0.9 && w.Zoom < 1) {
84+ t.Fatalf("window %d mid-way fade %v zoom %v, want strictly between", id, w.Fade, w.Zoom)
85+ }
86+ }
87+ s = frame(t, c, ic, 5*time.Second)
88+ for _, id := range []WindowID{2, 3} {
89+ p := settledPlacement(t, sc, id)
90+ if w := sceneWindow(t, s, id); w.Fade != 0 || w.Zoom != 0 || w.Rect != p.Rect {
91+ t.Fatalf("window %d settled %+v, want %+v", id, w, p.Rect)
92+ }
93+ }
94+ if c.animating() || len(sc.rects) != 0 {
95+ t.Fatalf("still animating: %d rects", len(sc.rects))
96+ }
97+ if ids := drainConfigures(cmds); len(ids) != 0 {
98+ t.Fatalf("the animation reconfigured %v", ids)
99+ }
100+}
101+
102+// Hiding the stash keeps its windows drawn while they fade out and shrink,
103+// out of reach of input and focus, until they settle.
104+func TestStashHideLeaves(t *testing.T) {
105+ c, ic, sc, cmds := stashRig(t, true)
106+ left := map[WindowID]Placement{}
107+ for _, id := range []WindowID{2, 3} {
108+ left[id] = settledPlacement(t, sc, id)
109+ }
110+ act(c, ic, ActionToggleStashVisible)
111+ s := indicatorScene(t, c)
112+ if len(sc.settledLayout) != len(sc.shown) {
113+ t.Fatalf("settled %d shown %d", len(sc.settledLayout), len(sc.shown))
114+ }
115+ for _, id := range []WindowID{2, 3} {
116+ w := sceneWindow(t, s, id)
117+ if w.Hidden || w.Fade != 0 || w.Rect != left[id].Rect {
118+ t.Fatalf("window %d first frame %+v, want drawn opaque at %+v", id, w, left[id].Rect)
119+ }
120+ if _, _, ok := c.windowRect(id); ok {
121+ t.Fatalf("window %d is still on screen for input", id)
122+ }
123+ }
124+ // The selected window is under the tile: its centre hits the tile.
125+ r := left[2].Rect
126+ if id, _, _ := c.hit(float64(r.X+r.W/2), float64(r.Y+r.H/2)); id == 2 || id == 3 {
127+ t.Fatalf("hit %d on a leaving window", id)
128+ }
129+ if id, _ := sc.mon.Focused(); id == 2 || id == 3 {
130+ t.Fatalf("focused %d", id)
131+ }
132+ if ids := drainConfigures(cmds); slices.Contains(ids, 2) || slices.Contains(ids, 3) {
133+ t.Fatalf("a leaving window was configured: %v", ids)
134+ }
135+
136+ s = frame(t, c, ic, 40*time.Millisecond)
137+ for _, id := range []WindowID{2, 3} {
138+ if w := sceneWindow(t, s, id); !(w.Fade > 0 && w.Fade < 1) || !(w.Rect.W < left[id].Rect.W) {
139+ t.Fatalf("window %d mid-way %+v", id, w)
140+ }
141+ }
142+ s = frame(t, c, ic, 5*time.Second)
143+ for _, id := range []WindowID{2, 3} {
144+ if w := sceneWindow(t, s, id); !w.Hidden {
145+ t.Fatalf("window %d still drawn after the settle: %+v", id, w)
146+ }
147+ }
148+ if c.animating() || len(sc.rects) != 0 {
149+ t.Fatalf("still animating: %d rects", len(sc.rects))
150+ }
151+ // The hide's own configure comes once the fade is over, from the size
152+ // the window had (not from nothing).
153+ var hidden []ports.ConfigureWindow
154+ for {
155+ select {
156+ case cmd := <-cmds:
157+ if v, ok := cmd.(ports.ConfigureWindow); ok && v.ID == 2 {
158+ hidden = append(hidden, v)
159+ }
160+ continue
161+ default:
162+ }
163+ break
164+ }
165+ if len(hidden) != 1 || hidden[0].Visible || hidden[0].Activated {
166+ t.Fatalf("configures of window 2 after the hide: %+v", hidden)
167+ }
168+}
169+
170+// With animations off both are instant.
171+func TestStashToggleOffIsInstant(t *testing.T) {
172+ c, ic, sc, _ := stashRig(t, false)
173+ act(c, ic, ActionToggleStashVisible)
174+ s := indicatorScene(t, c)
175+ if w := sceneWindow(t, s, 2); !w.Hidden || len(sc.rects) != 0 || c.animating() {
176+ t.Fatalf("hide with animations off: %+v, %d rects", w, len(sc.rects))
177+ }
178+ act(c, ic, ActionToggleStashVisible)
179+ s = indicatorScene(t, c)
180+ if w := sceneWindow(t, s, 2); w.Hidden || w.Fade != 0 || w.Zoom != 0 || len(sc.rects) != 0 || c.animating() {
181+ t.Fatalf("show with animations off: %+v, %d rects", w, len(sc.rects))
182+ }
183+}
184+
185+// Navigating in the stash animates the peek veil: the window that leaves the
186+// centre darkens from 0, the one that reaches it clears from the veil.
187+func TestStashNavigationAnimatesPeekDim(t *testing.T) {
188+ c, ic, sc, _ := stashRig(t, true)
189+ if w := sceneWindow(t, indicatorScene(t, c), 3); w.Dim != 0.5 {
190+ t.Fatalf("setup: peek dim %v", w.Dim)
191+ }
192+ act(c, ic, ActionFocusColumnLeft) // 3 selected, 2 peeks on its right
193+ s := indicatorScene(t, c)
194+ if w := sceneWindow(t, s, 3); w.Dim != 0.5 || w.Focused != true {
195+ t.Fatalf("window 3 right after: %+v, want the veil it had", w)
196+ }
197+ if w := sceneWindow(t, s, 2); w.Dim != 0 {
198+ t.Fatalf("window 2 right after: dim %v, want 0 (it was selected)", w.Dim)
199+ }
200+ s = frame(t, c, ic, 40*time.Millisecond)
201+ if w := sceneWindow(t, s, 3); !(w.Dim > 0 && w.Dim < 0.5) {
202+ t.Fatalf("window 3 mid-way dim %v, want strictly between 0 and 0.5", w.Dim)
203+ }
204+ if w := sceneWindow(t, s, 2); !(w.Dim > 0 && w.Dim < 0.5) {
205+ t.Fatalf("window 2 mid-way dim %v, want strictly between 0 and 0.5", w.Dim)
206+ }
207+ s = frame(t, c, ic, 5*time.Second)
208+ if w := sceneWindow(t, s, 3); w.Dim != 0 {
209+ t.Fatalf("window 3 settled dim %v, want 0", w.Dim)
210+ }
211+ if w := sceneWindow(t, s, 2); w.Dim != 0.5 {
212+ t.Fatalf("window 2 settled dim %v, want 0.5", w.Dim)
213+ }
214+ if c.animating() || len(sc.rects) != 0 {
215+ t.Fatalf("still animating: %d rects", len(sc.rects))
216+ }
217+}
218+
219+// A navigation that chains on a running one starts the veil where it was.
220+func TestStashNavigationChainsPeekDim(t *testing.T) {
221+ c, ic, _, _ := stashRig(t, true)
222+ act(c, ic, ActionFocusColumnLeft)
223+ indicatorScene(t, c)
224+ ic.now = ic.now.Add(30 * time.Millisecond)
225+ c.animate(ic.now, nil)
226+ mid := sceneWindow(t, indicatorScene(t, c), 3).Dim
227+ act(c, ic, ActionFocusColumnRight) // 3 goes back to a peek
228+ if got := sceneWindow(t, indicatorScene(t, c), 3).Dim; got < mid-0.02 || got > mid+0.02 {
229+ t.Fatalf("window 3 veil %v after the chained action, want the %v it had", got, mid)
230+ }
231+}
232+
233+// The overview pile: the stash cards shrink from the stash windows' rects
234+// and settle on the pile layout (C1's rules).
235+func TestOverviewPileScalesFromStash(t *testing.T) {
236+ c, ic, sc, _ := stashRig(t, true)
237+ from := map[WindowID]ports.Rect{}
238+ s := indicatorScene(t, c)
239+ for _, id := range []WindowID{2, 3} {
240+ from[id] = sceneWindow(t, s, id).Rect
241+ }
242+ toggleOverview(c, sc, ic.now)
243+ s = indicatorScene(t, c)
244+ for _, id := range []WindowID{2, 3} {
245+ w := sceneWindow(t, s, id)
246+ p := settledPlacement(t, sc, id)
247+ if w.Hidden || w.Preview != overviewCardZoom || w.Rect != from[id] || w.Zoom != 1 {
248+ t.Fatalf("window %d first frame %+v, want its stash rect %+v at content scale 1 (card %+v)", id, w, from[id], p.Rect)
249+ }
250+ checkContent(t, w, from[id], "first frame")
251+ }
252+ s = frame(t, c, ic, 40*time.Millisecond)
253+ for _, id := range []WindowID{2, 3} {
254+ w := sceneWindow(t, s, id)
255+ p := settledPlacement(t, sc, id)
256+ if !(w.Rect.W < from[id].W && w.Rect.W > p.Rect.W) || !(w.Zoom > overviewCardZoom && w.Zoom < 1) {
257+ t.Fatalf("window %d mid-way %+v, want between %+v and %+v", id, w, from[id], p.Rect)
258+ }
259+ checkContent(t, w, from[id], "mid-way")
260+ }
261+ s = frame(t, c, ic, 5*time.Second)
262+ for _, id := range []WindowID{2, 3} {
263+ w := sceneWindow(t, s, id)
264+ p := settledPlacement(t, sc, id)
265+ if w.Rect != p.Rect || w.Zoom != 0 || w.Fade != 0 || w.Preview != overviewCardZoom {
266+ t.Fatalf("window %d settled %+v, want the pile card %+v", id, w, p)
267+ }
268+ }
269+}
270+
271+// A navigation that brings a window to a margin makes it appear, one that
272+// sends a peek off screen makes it leave; the others only change their veil
273+// and rect.
274+func TestStashNavigationAppearsAndLeaves(t *testing.T) {
275+ c, ic, sc, cmds := stashRig(t, true)
276+ w := sc.mon.Current()
277+ w.AddWindow(5)
278+ w.stashFocus = false // the new tile has the focus, not the stash
279+ c.applyAction(ActionToggleWindowStash) // stash 4 3 2 5: 5 selected, 2 peeks
280+ if len(w.Stash) != 4 || w.stashAt != 3 {
281+ t.Fatalf("setup: stash %+v at %d", w.Stash, w.stashAt)
282+ }
283+ settleShown(t, c)
284+ act(c, ic, ActionFocusColumnLeft) // 2 selected, 3 comes in, 5 stays a peek
285+ s := indicatorScene(t, c)
286+ if w := sceneWindow(t, s, 3); w.Hidden || w.Fade != 1 {
287+ t.Fatalf("window 3 comes to the margin: %+v, want invisible at first", w)
288+ }
289+ if w := sceneWindow(t, s, 5); w.Hidden || w.Fade != 0 {
290+ t.Fatalf("window 5 stays a peek: %+v", w)
291+ }
292+ settledAll := frame(t, c, ic, 5*time.Second)
293+ if w := sceneWindow(t, settledAll, 3); w.Fade != 0 || w.Hidden {
294+ t.Fatalf("window 3 settled %+v", w)
295+ }
296+ drainConfigures(cmds)
297+ act(c, ic, ActionFocusColumnLeft) // 3 selected, 4 comes in, 5 goes
298+ s = indicatorScene(t, c)
299+ if w := sceneWindow(t, s, 4); w.Hidden || w.Fade != 1 {
300+ t.Fatalf("window 4 comes to the margin: %+v", w)
301+ }
302+ if w := sceneWindow(t, s, 5); w.Hidden || w.Fade != 0 || w.Rect.W == 0 {
303+ t.Fatalf("window 5 leaves from its peek rect: %+v", w)
304+ }
305+ s = frame(t, c, ic, 5*time.Second)
306+ if w := sceneWindow(t, s, 5); !w.Hidden {
307+ t.Fatalf("window 5 still drawn after the settle: %+v", w)
308+ }
309+ if c.animating() {
310+ t.Fatal("still animating")
311+ }
312+}
@@ -0,0 +1,312 @@
1+package core
2+
3+import (
4+ "slices"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// stashRig is indicatorCore with animations on and a stash of windows 4, 3
12+// and 2 (2 selected, 3 peeking on its left) over the tile 1, settled. The
13+// peek veil is 0.5.
14+func stashRig(t *testing.T, on bool) (*Core, *indicatorClock, *screen, chan ports.ClientCommand) {
15+ t.Helper()
16+ c, ic, cmds := fadeCore(t)
17+ c.cfg.Animations.On = on
18+ c.cfg.Stash.Dim = 0.5
19+ sc := c.cur()
20+ for id := WindowID(1); id <= 4; id++ {
21+ sc.mon.AddWindow(id)
22+ }
23+ for _, id := range []WindowID{4, 3, 2} {
24+ sc.mon.Current().FocusID(id)
25+ c.applyAction(ActionToggleWindowStash)
26+ }
27+ settleShown(t, c)
28+ drainConfigures(cmds)
29+ return c, ic, sc, cmds
30+}
31+
32+// act is a bind: snapshot, action, transition.
33+func act(c *Core, ic *indicatorClock, a Action) {
34+ shots := c.snapshot(ic.now)
35+ c.applyAction(a)
36+ c.transition(shots, ic.now)
37+}
38+
39+func settledPlacement(t *testing.T, sc *screen, id WindowID) Placement {
40+ t.Helper()
41+ i := slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == id })
42+ if i < 0 {
43+ t.Fatalf("window %d not in the settled layout", id)
44+ }
45+ return sc.settledLayout[i]
46+}
47+
48+// Showing the stash fades in and grows every window it brings on screen;
49+// the windows it keeps off screen stay out; the settled layout is the
50+// final one at once, with no extra configure.
51+func TestStashShowAppears(t *testing.T) {
52+ c, ic, sc, cmds := stashRig(t, true)
53+ act(c, ic, ActionToggleStashVisible)
54+ settleShown(t, c)
55+ if !sc.mon.Current().stashHidden {
56+ t.Fatal("setup: the stash is shown")
57+ }
58+ ic.now = ic.now.Add(5 * time.Second)
59+ c.animate(ic.now, nil)
60+ settleShown(t, c)
61+ drainConfigures(cmds)
62+
63+ act(c, ic, ActionToggleStashVisible)
64+ s := indicatorScene(t, c)
65+ for _, id := range []WindowID{2, 3} {
66+ p := settledPlacement(t, sc, id)
67+ w := sceneWindow(t, s, id)
68+ if w.Hidden || w.Fade != 1 || w.Rect != scaledRect(p.Rect, appearScale) || w.Zoom < 0.85 || w.Zoom > 0.95 {
69+ t.Fatalf("window %d first frame %+v, want invisible at 90 %% of %+v", id, w, p.Rect)
70+ }
71+ }
72+ if w := sceneWindow(t, s, 4); !w.Hidden || w.Fade != 0 {
73+ t.Fatalf("window 4 stays off screen: %+v", w)
74+ }
75+ // 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)
78+ }
79+
80+ s = frame(t, c, ic, 40*time.Millisecond)
81+ for _, id := range []WindowID{2, 3} {
82+ w := sceneWindow(t, s, id)
83+ if !(w.Fade > 0 && w.Fade < 1) || !(w.Zoom > 0.9 && w.Zoom < 1) {
84+ t.Fatalf("window %d mid-way fade %v zoom %v, want strictly between", id, w.Fade, w.Zoom)
85+ }
86+ }
87+ s = frame(t, c, ic, 5*time.Second)
88+ for _, id := range []WindowID{2, 3} {
89+ p := settledPlacement(t, sc, id)
90+ if w := sceneWindow(t, s, id); w.Fade != 0 || w.Zoom != 0 || w.Rect != p.Rect {
91+ t.Fatalf("window %d settled %+v, want %+v", id, w, p.Rect)
92+ }
93+ }
94+ if c.animating() || len(sc.rects) != 0 {
95+ t.Fatalf("still animating: %d rects", len(sc.rects))
96+ }
97+ if ids := drainConfigures(cmds); len(ids) != 0 {
98+ t.Fatalf("the animation reconfigured %v", ids)
99+ }
100+}
101+
102+// Hiding the stash keeps its windows drawn while they fade out and shrink,
103+// out of reach of input and focus, until they settle.
104+func TestStashHideLeaves(t *testing.T) {
105+ c, ic, sc, cmds := stashRig(t, true)
106+ left := map[WindowID]Placement{}
107+ for _, id := range []WindowID{2, 3} {
108+ left[id] = settledPlacement(t, sc, id)
109+ }
110+ act(c, ic, ActionToggleStashVisible)
111+ s := indicatorScene(t, c)
112+ if len(sc.settledLayout) != len(sc.shown) {
113+ t.Fatalf("settled %d shown %d", len(sc.settledLayout), len(sc.shown))
114+ }
115+ for _, id := range []WindowID{2, 3} {
116+ w := sceneWindow(t, s, id)
117+ if w.Hidden || w.Fade != 0 || w.Rect != left[id].Rect {
118+ t.Fatalf("window %d first frame %+v, want drawn opaque at %+v", id, w, left[id].Rect)
119+ }
120+ if _, _, ok := c.windowRect(id); ok {
121+ t.Fatalf("window %d is still on screen for input", id)
122+ }
123+ }
124+ // The selected window is under the tile: its centre hits the tile.
125+ r := left[2].Rect
126+ if id, _, _ := c.hit(float64(r.X+r.W/2), float64(r.Y+r.H/2)); id == 2 || id == 3 {
127+ t.Fatalf("hit %d on a leaving window", id)
128+ }
129+ if id, _ := sc.mon.Focused(); id == 2 || id == 3 {
130+ t.Fatalf("focused %d", id)
131+ }
132+ if ids := drainConfigures(cmds); slices.Contains(ids, 2) || slices.Contains(ids, 3) {
133+ t.Fatalf("a leaving window was configured: %v", ids)
134+ }
135+
136+ s = frame(t, c, ic, 40*time.Millisecond)
137+ for _, id := range []WindowID{2, 3} {
138+ if w := sceneWindow(t, s, id); !(w.Fade > 0 && w.Fade < 1) || !(w.Rect.W < left[id].Rect.W) {
139+ t.Fatalf("window %d mid-way %+v", id, w)
140+ }
141+ }
142+ s = frame(t, c, ic, 5*time.Second)
143+ for _, id := range []WindowID{2, 3} {
144+ if w := sceneWindow(t, s, id); !w.Hidden {
145+ t.Fatalf("window %d still drawn after the settle: %+v", id, w)
146+ }
147+ }
148+ if c.animating() || len(sc.rects) != 0 {
149+ t.Fatalf("still animating: %d rects", len(sc.rects))
150+ }
151+ // The hide's own configure comes once the fade is over, from the size
152+ // the window had (not from nothing).
153+ var hidden []ports.ConfigureWindow
154+ for {
155+ select {
156+ case cmd := <-cmds:
157+ if v, ok := cmd.(ports.ConfigureWindow); ok && v.ID == 2 {
158+ hidden = append(hidden, v)
159+ }
160+ continue
161+ default:
162+ }
163+ break
164+ }
165+ if len(hidden) != 1 || hidden[0].Visible || hidden[0].Activated {
166+ t.Fatalf("configures of window 2 after the hide: %+v", hidden)
167+ }
168+}
169+
170+// With animations off both are instant.
171+func TestStashToggleOffIsInstant(t *testing.T) {
172+ c, ic, sc, _ := stashRig(t, false)
173+ act(c, ic, ActionToggleStashVisible)
174+ s := indicatorScene(t, c)
175+ if w := sceneWindow(t, s, 2); !w.Hidden || len(sc.rects) != 0 || c.animating() {
176+ t.Fatalf("hide with animations off: %+v, %d rects", w, len(sc.rects))
177+ }
178+ act(c, ic, ActionToggleStashVisible)
179+ s = indicatorScene(t, c)
180+ if w := sceneWindow(t, s, 2); w.Hidden || w.Fade != 0 || w.Zoom != 0 || len(sc.rects) != 0 || c.animating() {
181+ t.Fatalf("show with animations off: %+v, %d rects", w, len(sc.rects))
182+ }
183+}
184+
185+// Navigating in the stash animates the peek veil: the window that leaves the
186+// centre darkens from 0, the one that reaches it clears from the veil.
187+func TestStashNavigationAnimatesPeekDim(t *testing.T) {
188+ c, ic, sc, _ := stashRig(t, true)
189+ if w := sceneWindow(t, indicatorScene(t, c), 3); w.Dim != 0.5 {
190+ t.Fatalf("setup: peek dim %v", w.Dim)
191+ }
192+ act(c, ic, ActionFocusColumnLeft) // 3 selected, 2 peeks on its right
193+ s := indicatorScene(t, c)
194+ if w := sceneWindow(t, s, 3); w.Dim != 0.5 || w.Focused != true {
195+ t.Fatalf("window 3 right after: %+v, want the veil it had", w)
196+ }
197+ if w := sceneWindow(t, s, 2); w.Dim != 0 {
198+ t.Fatalf("window 2 right after: dim %v, want 0 (it was selected)", w.Dim)
199+ }
200+ s = frame(t, c, ic, 40*time.Millisecond)
201+ if w := sceneWindow(t, s, 3); !(w.Dim > 0 && w.Dim < 0.5) {
202+ t.Fatalf("window 3 mid-way dim %v, want strictly between 0 and 0.5", w.Dim)
203+ }
204+ if w := sceneWindow(t, s, 2); !(w.Dim > 0 && w.Dim < 0.5) {
205+ t.Fatalf("window 2 mid-way dim %v, want strictly between 0 and 0.5", w.Dim)
206+ }
207+ s = frame(t, c, ic, 5*time.Second)
208+ if w := sceneWindow(t, s, 3); w.Dim != 0 {
209+ t.Fatalf("window 3 settled dim %v, want 0", w.Dim)
210+ }
211+ if w := sceneWindow(t, s, 2); w.Dim != 0.5 {
212+ t.Fatalf("window 2 settled dim %v, want 0.5", w.Dim)
213+ }
214+ if c.animating() || len(sc.rects) != 0 {
215+ t.Fatalf("still animating: %d rects", len(sc.rects))
216+ }
217+}
218+
219+// A navigation that chains on a running one starts the veil where it was.
220+func TestStashNavigationChainsPeekDim(t *testing.T) {
221+ c, ic, _, _ := stashRig(t, true)
222+ act(c, ic, ActionFocusColumnLeft)
223+ indicatorScene(t, c)
224+ ic.now = ic.now.Add(30 * time.Millisecond)
225+ c.animate(ic.now, nil)
226+ mid := sceneWindow(t, indicatorScene(t, c), 3).Dim
227+ act(c, ic, ActionFocusColumnRight) // 3 goes back to a peek
228+ if got := sceneWindow(t, indicatorScene(t, c), 3).Dim; got < mid-0.02 || got > mid+0.02 {
229+ t.Fatalf("window 3 veil %v after the chained action, want the %v it had", got, mid)
230+ }
231+}
232+
233+// The overview pile: the stash cards shrink from the stash windows' rects
234+// and settle on the pile layout (C1's rules).
235+func TestOverviewPileScalesFromStash(t *testing.T) {
236+ c, ic, sc, _ := stashRig(t, true)
237+ from := map[WindowID]ports.Rect{}
238+ s := indicatorScene(t, c)
239+ for _, id := range []WindowID{2, 3} {
240+ from[id] = sceneWindow(t, s, id).Rect
241+ }
242+ toggleOverview(c, sc, ic.now)
243+ s = indicatorScene(t, c)
244+ for _, id := range []WindowID{2, 3} {
245+ w := sceneWindow(t, s, id)
246+ p := settledPlacement(t, sc, id)
247+ if w.Hidden || w.Preview != overviewCardZoom || w.Rect != from[id] || w.Zoom != 1 {
248+ t.Fatalf("window %d first frame %+v, want its stash rect %+v at content scale 1 (card %+v)", id, w, from[id], p.Rect)
249+ }
250+ checkContent(t, w, from[id], "first frame")
251+ }
252+ s = frame(t, c, ic, 40*time.Millisecond)
253+ for _, id := range []WindowID{2, 3} {
254+ w := sceneWindow(t, s, id)
255+ p := settledPlacement(t, sc, id)
256+ if !(w.Rect.W < from[id].W && w.Rect.W > p.Rect.W) || !(w.Zoom > overviewCardZoom && w.Zoom < 1) {
257+ t.Fatalf("window %d mid-way %+v, want between %+v and %+v", id, w, from[id], p.Rect)
258+ }
259+ checkContent(t, w, from[id], "mid-way")
260+ }
261+ s = frame(t, c, ic, 5*time.Second)
262+ for _, id := range []WindowID{2, 3} {
263+ w := sceneWindow(t, s, id)
264+ p := settledPlacement(t, sc, id)
265+ if w.Rect != p.Rect || w.Zoom != 0 || w.Fade != 0 || w.Preview != overviewCardZoom {
266+ t.Fatalf("window %d settled %+v, want the pile card %+v", id, w, p)
267+ }
268+ }
269+}
270+
271+// A navigation that brings a window to a margin makes it appear, one that
272+// sends a peek off screen makes it leave; the others only change their veil
273+// and rect.
274+func TestStashNavigationAppearsAndLeaves(t *testing.T) {
275+ c, ic, sc, cmds := stashRig(t, true)
276+ w := sc.mon.Current()
277+ w.AddWindow(5)
278+ w.stashFocus = false // the new tile has the focus, not the stash
279+ c.applyAction(ActionToggleWindowStash) // stash 4 3 2 5: 5 selected, 2 peeks
280+ if len(w.Stash) != 4 || w.stashAt != 3 {
281+ t.Fatalf("setup: stash %+v at %d", w.Stash, w.stashAt)
282+ }
283+ settleShown(t, c)
284+ act(c, ic, ActionFocusColumnLeft) // 2 selected, 3 comes in, 5 stays a peek
285+ s := indicatorScene(t, c)
286+ if w := sceneWindow(t, s, 3); w.Hidden || w.Fade != 1 {
287+ t.Fatalf("window 3 comes to the margin: %+v, want invisible at first", w)
288+ }
289+ if w := sceneWindow(t, s, 5); w.Hidden || w.Fade != 0 {
290+ t.Fatalf("window 5 stays a peek: %+v", w)
291+ }
292+ settledAll := frame(t, c, ic, 5*time.Second)
293+ if w := sceneWindow(t, settledAll, 3); w.Fade != 0 || w.Hidden {
294+ t.Fatalf("window 3 settled %+v", w)
295+ }
296+ drainConfigures(cmds)
297+ act(c, ic, ActionFocusColumnLeft) // 3 selected, 4 comes in, 5 goes
298+ s = indicatorScene(t, c)
299+ if w := sceneWindow(t, s, 4); w.Hidden || w.Fade != 1 {
300+ t.Fatalf("window 4 comes to the margin: %+v", w)
301+ }
302+ if w := sceneWindow(t, s, 5); w.Hidden || w.Fade != 0 || w.Rect.W == 0 {
303+ t.Fatalf("window 5 leaves from its peek rect: %+v", w)
304+ }
305+ s = frame(t, c, ic, 5*time.Second)
306+ if w := sceneWindow(t, s, 5); !w.Hidden {
307+ t.Fatalf("window 5 still drawn after the settle: %+v", w)
308+ }
309+ if c.animating() {
310+ t.Fatal("still animating")
311+ }
312+}

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