detached 8ecc5f8

feat(core): fade, dim and scale rect motions, leaving entries and the appear helper

rectMotion gains fade and dim offsets (sprung to the settled Fade, 0 or
1 for a leaving window, and Dim), a scale flag and, for leaving windows,
the last settled placement. refreshShown applies them to the shown
layout: Fade, an added Dim and a Zoom (settled Preview or 1 times the
drawn/settled width) that publish emits as the scene's Preview while the
settled Preview keeps telling input and popups a card from a window.

A leaving motion keeps drawing a window that closed or hid: refreshShown
appends its last placement, Hidden and Leaving, to both the settled and
the shown layout in ID order, so publish's index alignment holds; input,
focus, popups and configures ignore it (Hidden), the scene shows it
(Hidden false, Fade, Preview). It goes once its springs settle, when the
layout shows the window again, or with stopRects. A leaving float keeps
its own border lines.

c.appear(sc, id, now) fades a window in from invisible and grows it from
90 % of its settled rect; c.leave(sc, p, now) does the reverse. Both are
no-ops with animations off. animate stays allocation-free with fade, dim
and scale components; publish budgets are unchanged (36, overview 70).
commit
8ecc5f89e6b3083117a46f42bb54dc27ec55f894
author
brice · · 2026-10-04 22:10 UTC
parent
fd1b483
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +530 -13
M internal/core/animate_alloc_test.go +10 -2
@@ -6,7 +6,8 @@ import (
66 )
77
88 // TestAnimateAllocations guards animate: with a camera, a switch and 20 rect
9-// motions running it allocates nothing.
9+// motions running (fade, dim and scale components, half of them leaving) it
10+// allocates nothing.
1011 func TestAnimateAllocations(t *testing.T) {
1112 c, ic := indicatorCore(t)
1213 c.cfg.Animations.On = true
@@ -24,11 +25,18 @@ func TestAnimateAllocations(t *testing.T) {
2425 sc.rects = make(map[WindowID]rectMotion, 20)
2526 for i := range 20 {
2627 sp := viewSpring(50, 0)
27- sc.rects[WindowID(100+i)] = rectMotion{
28+ rm := rectMotion{
2829 x: c.spring(sp, t0), y: c.spring(sp, t0),
2930 w: c.spring(sp, t0), h: c.spring(sp, t0),
3031 dx: 50, dy: 50, dw: 50, dh: 50,
32+ fade: c.spring(viewSpring(1, 0), t0), df: 1,
33+ dim: c.spring(viewSpring(0.5, 0), t0), ddim: 0.5,
34+ scale: true,
3135 }
36+ if i%2 == 0 {
37+ rm.leaving, rm.left = true, Placement{ID: WindowID(100 + i), Rect: Rect{W: 10, H: 10}}
38+ }
39+ sc.rects[WindowID(100+i)] = rm
3240 }
3341
3442 now := t0.Add(10 * time.Millisecond)
@@ -6,7 +6,8 @@ import (
6 )6 )
7 7
8 // TestAnimateAllocations guards animate: with a camera, a switch and 20 rect8 // TestAnimateAllocations guards animate: with a camera, a switch and 20 rect
9-// motions running it allocates nothing.9+// motions running (fade, dim and scale components, half of them leaving) it
10+// allocates nothing.
10 func TestAnimateAllocations(t *testing.T) {11 func TestAnimateAllocations(t *testing.T) {
11 c, ic := indicatorCore(t)12 c, ic := indicatorCore(t)
12 c.cfg.Animations.On = true13 c.cfg.Animations.On = true
@@ -24,11 +25,18 @@ func TestAnimateAllocations(t *testing.T) {
24 sc.rects = make(map[WindowID]rectMotion, 20)25 sc.rects = make(map[WindowID]rectMotion, 20)
25 for i := range 20 {26 for i := range 20 {
26 sp := viewSpring(50, 0)27 sp := viewSpring(50, 0)
27- sc.rects[WindowID(100+i)] = rectMotion{28+ rm := rectMotion{
28 x: c.spring(sp, t0), y: c.spring(sp, t0),29 x: c.spring(sp, t0), y: c.spring(sp, t0),
29 w: c.spring(sp, t0), h: c.spring(sp, t0),30 w: c.spring(sp, t0), h: c.spring(sp, t0),
30 dx: 50, dy: 50, dw: 50, dh: 50,31 dx: 50, dy: 50, dw: 50, dh: 50,
32+ fade: c.spring(viewSpring(1, 0), t0), df: 1,
33+ dim: c.spring(viewSpring(0.5, 0), t0), ddim: 0.5,
34+ scale: true,
31 }35 }
36+ if i%2 == 0 {
37+ rm.leaving, rm.left = true, Placement{ID: WindowID(100 + i), Rect: Rect{W: 10, H: 10}}
38+ }
39+ sc.rects[WindowID(100+i)] = rm
32 }40 }
33 41
34 now := t0.Add(10 * time.Millisecond)42 now := t0.Add(10 * time.Millisecond)
M internal/core/core.go +16 -2
@@ -605,9 +605,23 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
605605 ps := settled[k]
606606 // Only the focused output has an activated window.
607607 focused := p.Focused && i == c.focusScreen
608- 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}
608+ 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: p.Dim}
609+ if p.Zoom > 0 {
610+ // A scale motion: the content follows the drawn size.
611+ sw.Preview = p.Zoom
612+ if sw.Preview >= 1 {
613+ sw.Preview = 0
614+ }
615+ }
609616 if p.Peek {
610- sw.Dim = c.cfg.Stash.Dim
617+ sw.Dim = min(sw.Dim+c.cfg.Stash.Dim, 1)
618+ }
619+ if p.Leaving {
620+ // A window fading out after it closed or hid: drawn, but
621+ // hidden to everything else, its configures included.
622+ sw.Hidden = false
623+ scene.Windows = append(scene.Windows, sw)
624+ continue
611625 }
612626 if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open {
613627 drawable = true
@@ -605,9 +605,23 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
605 ps := settled[k]605 ps := settled[k]
606 // Only the focused output has an activated window.606 // Only the focused output has an activated window.
607 focused := p.Focused && i == c.focusScreen607 focused := p.Focused && i == c.focusScreen
608- 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}608+ 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: p.Dim}
609+ if p.Zoom > 0 {
610+ // A scale motion: the content follows the drawn size.
611+ sw.Preview = p.Zoom
612+ if sw.Preview >= 1 {
613+ sw.Preview = 0
614+ }
615+ }
609 if p.Peek {616 if p.Peek {
610- sw.Dim = c.cfg.Stash.Dim617+ sw.Dim = min(sw.Dim+c.cfg.Stash.Dim, 1)
618+ }
619+ if p.Leaving {
620+ // A window fading out after it closed or hid: drawn, but
621+ // hidden to everything else, its configures included.
622+ sw.Hidden = false
623+ scene.Windows = append(scene.Windows, sw)
624+ continue
611 }625 }
612 if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open {626 if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open {
613 drawable = true627 drawable = true
M internal/core/layout.go +12 -0
@@ -122,6 +122,18 @@ type Placement struct {
122122 // Inset reserves room for drawn lines: all sides of a float; for
123123 // tiles without gaps, only the right and bottom shared sides.
124124 Neighbors, Inset ports.Sides
125+ // Leaving marks a window that closed or hid but is still drawn while
126+ // it fades out (transition.go). It stays Hidden, so input, focus,
127+ // popups and configures ignore it; only the scene shows it.
128+ Leaving bool
129+ // Fade, Dim and Zoom are set on shown layouts only (refreshShown),
130+ // animated: the window's fade (0 opaque, 1 invisible), a veil opacity
131+ // added to the one Peek draws, and the content zoom as drawn when a
132+ // scale motion runs (Preview times the drawn/settled width, 1 when the
133+ // content is drawn at its size; 0 without one). Preview itself stays
134+ // the settled one: input and popups read it to tell a card from a
135+ // window.
136+ Fade, Dim, Zoom float64
125137 }
126138
127139 // Overflow says what happens past MaxColumns columns.
@@ -122,6 +122,18 @@ type Placement struct {
122 // Inset reserves room for drawn lines: all sides of a float; for122 // Inset reserves room for drawn lines: all sides of a float; for
123 // tiles without gaps, only the right and bottom shared sides.123 // tiles without gaps, only the right and bottom shared sides.
124 Neighbors, Inset ports.Sides124 Neighbors, Inset ports.Sides
125+ // Leaving marks a window that closed or hid but is still drawn while
126+ // it fades out (transition.go). It stays Hidden, so input, focus,
127+ // popups and configures ignore it; only the scene shows it.
128+ Leaving bool
129+ // Fade, Dim and Zoom are set on shown layouts only (refreshShown),
130+ // animated: the window's fade (0 opaque, 1 invisible), a veil opacity
131+ // added to the one Peek draws, and the content zoom as drawn when a
132+ // scale motion runs (Preview times the drawn/settled width, 1 when the
133+ // content is drawn at its size; 0 without one). Preview itself stays
134+ // the settled one: input and popups read it to tell a card from a
135+ // window.
136+ Fade, Dim, Zoom float64
125 }137 }
126 138
127 // Overflow says what happens past MaxColumns columns.139 // Overflow says what happens past MaxColumns columns.
M internal/core/separators.go +3 -1
@@ -49,7 +49,9 @@ func separators(ps []Placement, width, gap int, o Rect, lit bool) []ports.Separa
4949 tiles := 0
5050 for i := range ps {
5151 p := &ps[i]
52- if p.Fullscreen || !onScreen(*p, o) {
52+ // A leaving float keeps its own lines while it fades; a leaving
53+ // tile's were shared with its neighbours, which re-flowed.
54+ if p.Fullscreen || !(onScreen(*p, o) || p.Leaving && p.Floating && p.Rect.Overlaps(o)) {
5355 continue
5456 }
5557 if !p.Floating {
@@ -49,7 +49,9 @@ func separators(ps []Placement, width, gap int, o Rect, lit bool) []ports.Separa
49 tiles := 049 tiles := 0
50 for i := range ps {50 for i := range ps {
51 p := &ps[i]51 p := &ps[i]
52- if p.Fullscreen || !onScreen(*p, o) {52+ // A leaving float keeps its own lines while it fades; a leaving
53+ // tile's were shared with its neighbours, which re-flowed.
54+ if p.Fullscreen || !(onScreen(*p, o) || p.Leaving && p.Floating && p.Rect.Overlaps(o)) {
53 continue55 continue
54 }56 }
55 if !p.Floating {57 if !p.Floating {
M internal/core/transition.go +165 -8
@@ -58,10 +58,26 @@ type rectShot struct {
5858
5959 // rectMotion takes a window's drawn rect to its settled one: each component
6060 // is an offset from the settled value, sprung to 0 (dx, dy, dw, dh hold the
61-// current offsets, updated by advance).
61+// current offsets, updated by advance). fade and dim are offsets too, from
62+// the settled Fade (0, or 1 for a leaving window) and the settled Dim.
63+//
64+// With scale, the window's content follows its drawn size: the shown
65+// placement gets a Zoom of the settled zoom (its Preview, or 1) times
66+// shown.W / settled.W (refreshShown), which the scene carries as Preview.
67+// The walk shrinks content toward the content rect's top-left, so this
68+// suits aspect-preserving changes only.
69+//
70+// A leaving motion draws a window that closed or hid while it fades out:
71+// left is its last settled placement, which refreshShown appends (Hidden
72+// and Leaving) to the settled and shown layouts, keeping them aligned.
6273 type rectMotion struct {
6374 x, y, w, h motion
6475 dx, dy, dw, dh float64
76+ fade, dim motion
77+ df, ddim float64
78+ scale bool
79+ leaving bool
80+ left Placement
6581 }
6682
6783 // apply is r moved by the current offsets; a size never drops below 1.
@@ -95,6 +111,8 @@ func (m *rectMotion) advance(now time.Time) {
95111 m.dy = stepComponent(&m.y, m.dy, now)
96112 m.dw = stepComponent(&m.w, m.dw, now)
97113 m.dh = stepComponent(&m.h, m.dh, now)
114+ m.df = stepComponent(&m.fade, m.df, now)
115+ m.ddim = stepComponent(&m.dim, m.ddim, now)
98116 }
99117
100118 func stepComponent(m *motion, cur float64, now time.Time) float64 {
@@ -110,7 +128,39 @@ func stepComponent(m *motion, cur float64, now time.Time) float64 {
110128 }
111129
112130 // on reports whether any component still runs.
113-func (m *rectMotion) on() bool { return m.x.on || m.y.on || m.w.on || m.h.on }
131+func (m *rectMotion) on() bool {
132+ return m.x.on || m.y.on || m.w.on || m.h.on || m.fade.on || m.dim.on
133+}
134+
135+// settledFade is the Fade the motion springs to: a leaving window fades
136+// out, any other is opaque.
137+func (m *rectMotion) settledFade() float64 {
138+ if m.leaving {
139+ return 1
140+ }
141+ return 0
142+}
143+
144+// show is p as drawn: its rect moved by the offsets, its fade and dim
145+// offsets added, and its content zoomed with its size when scale is set.
146+func (m *rectMotion) show(p *Placement) {
147+ settled := p.Rect
148+ p.Rect = m.apply(settled)
149+ p.Fade = max(0, min(1, m.settledFade()+m.df))
150+ p.Dim = m.ddim
151+ if m.scale && settled.W > 0 {
152+ zoom := p.Preview
153+ if zoom == 0 {
154+ zoom = 1
155+ }
156+ zoom *= float64(p.Rect.W) / float64(settled.W)
157+ if zoom >= 0.999 {
158+ // Drawn at its size: no zoom (publish emits Preview 0).
159+ zoom = 1
160+ }
161+ p.Zoom = zoom
162+ }
163+}
114164
115165 // retargetComponent starts m for an offset of off from its settled value at
116166 // now, with the speed v it had then. A component with nothing to move and no
@@ -129,6 +179,11 @@ func (c *Core) retargetComponent(m *motion, cur *float64, off, v float64, now ti
129179 // with the rect motions applied: what is drawn and hit-tested). Rect motions
130180 // belong to the workspace they began on and to a closed overview: any other
131181 // change drops them.
182+//
183+// Leaving motions add an entry for a window the layout no longer shows:
184+// appended to both layouts in ID order, Hidden (no input, focus, popups or
185+// configure) and Leaving (drawn, fading). The two layouts stay
186+// index-aligned: publish reads the settled entry at the shown one's index.
132187 func (c *Core) refreshShown() {
133188 for _, sc := range c.screens {
134189 m := sc.mon
@@ -138,11 +193,13 @@ func (c *Core) refreshShown() {
138193 sc.settledLayout = m.Layout()
139194 if len(sc.rects) > 0 {
140195 // A window that left the layout (or is hidden) keeps no motion:
141- // it would ask for frames with nothing to move.
142- for id := range sc.rects {
143- if !slices.ContainsFunc(sc.settledLayout, func(p Placement) bool {
196+ // it would ask for frames with nothing to move. A leaving one
197+ // is the opposite: it goes once the layout shows the window.
198+ for id, rm := range sc.rects {
199+ shown := slices.ContainsFunc(sc.settledLayout, func(p Placement) bool {
144200 return p.ID == id && !p.Hidden && p.Preview == 0
145- }) {
201+ })
202+ if shown == rm.leaving {
146203 delete(sc.rects, id)
147204 }
148205 }
@@ -151,20 +208,120 @@ func (c *Core) refreshShown() {
151208 sc.shown = sc.settledLayout
152209 continue
153210 }
211+ sc.settledLayout = sc.appendLeaving(sc.settledLayout)
154212 buf := append(sc.shownBuf[:0], sc.settledLayout...)
155213 for i := range buf {
156214 p := &buf[i]
157- if p.Hidden || p.Preview > 0 {
215+ if p.Hidden && !p.Leaving {
158216 continue
159217 }
160218 if rm, ok := sc.rects[p.ID]; ok {
161- p.Rect = rm.apply(p.Rect)
219+ rm.show(p)
162220 }
163221 }
164222 sc.shownBuf, sc.shown = buf, buf
165223 }
166224 }
167225
226+// appendLeaving appends the leaving windows' last placements to layout,
227+// Hidden and Leaving, smallest ID first (a stable order keeps the scenes
228+// comparable frame to frame).
229+func (s *screen) appendLeaving(layout []Placement) []Placement {
230+ n := len(layout)
231+ for _, rm := range s.rects {
232+ if !rm.leaving {
233+ continue
234+ }
235+ p := rm.left
236+ p.Hidden, p.Leaving, p.Focused, p.Fade, p.Dim, p.Zoom = true, true, false, 0, 0, 0
237+ i := n
238+ for i < len(layout) && layout[i].ID < p.ID {
239+ i++
240+ }
241+ layout = slices.Insert(layout, i, p)
242+ }
243+ return layout
244+}
245+
246+// appear starts the entrance of a window the action made visible on sc:
247+// it fades in from invisible and grows from 90 % of its settled rect,
248+// around its centre. Nothing with animations off or if the layout does not
249+// show it. A leaving motion of the same window is replaced.
250+func (c *Core) appear(sc *screen, id WindowID, now time.Time) {
251+ if !c.animOn() {
252+ return
253+ }
254+ for _, p := range sc.mon.Layout() {
255+ if p.ID == id && !p.Hidden {
256+ c.appearAt(sc, p, now)
257+ return
258+ }
259+ }
260+}
261+
262+// appearAt is appear for a window whose settled placement is p.
263+func (c *Core) appearAt(sc *screen, p Placement, now time.Time) {
264+ if !c.animOn() || p.Hidden {
265+ return
266+ }
267+ if sc.rects == nil {
268+ sc.rects = make(map[WindowID]rectMotion)
269+ }
270+ var rm rectMotion
271+ rm.scale = true
272+ c.scaleFrom(&rm, p.Rect, appearScale, now)
273+ c.retargetComponent(&rm.fade, &rm.df, 1, 0, now)
274+ sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current()
275+}
276+
277+// leave keeps drawing a window the action closed or hid, from its last
278+// settled placement p: it fades out and shrinks to 90 % around its centre,
279+// then its entry goes. Nothing with animations off, or for a placement that
280+// was not drawn.
281+func (c *Core) leave(sc *screen, p Placement, now time.Time) {
282+ if !c.animOn() || p.Hidden || p.Preview > 0 || p.Rect.W <= 0 || p.Rect.H <= 0 {
283+ return
284+ }
285+ if sc.rects == nil {
286+ sc.rects = make(map[WindowID]rectMotion)
287+ }
288+ rm := rectMotion{scale: true, leaving: true, left: p}
289+ rm.left.Hidden, rm.left.Leaving, rm.left.Focused = true, true, false
290+ // The entry settles at 90 % of the rect it left, its content zoomed
291+ // to match (Preview): the offsets run from the full rect to that.
292+ end := scaledRect(p.Rect, appearScale)
293+ rm.left.Preview = appearScale
294+ c.retargetComponent(&rm.x, &rm.dx, float64(p.Rect.X-end.X), 0, now)
295+ c.retargetComponent(&rm.y, &rm.dy, float64(p.Rect.Y-end.Y), 0, now)
296+ c.retargetComponent(&rm.w, &rm.dw, float64(p.Rect.W-end.W), 0, now)
297+ c.retargetComponent(&rm.h, &rm.dh, float64(p.Rect.H-end.H), 0, now)
298+ rm.left.Rect = end
299+ c.retargetComponent(&rm.fade, &rm.df, -1, 0, now)
300+ sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current()
301+}
302+
303+// appearScale is the size, relative to the settled rect, a window appears
304+// from and leaves to.
305+const appearScale = 0.9
306+
307+// scaledRect is r scaled by k around its centre, in float.
308+func scaledRect(r Rect, k float64) Rect {
309+ w, h := float64(r.W)*k, float64(r.H)*k
310+ x := float64(r.X) + (float64(r.W)-w)/2
311+ y := float64(r.Y) + (float64(r.H)-h)/2
312+ return Rect{X: int(math.Round(x)), Y: int(math.Round(y)), W: int(math.Round(w)), H: int(math.Round(h))}
313+}
314+
315+// scaleFrom starts rm's rect components from settled scaled by k around
316+// its centre, toward settled.
317+func (c *Core) scaleFrom(rm *rectMotion, settled Rect, k float64, now time.Time) {
318+ w, h := float64(settled.W)*k, float64(settled.H)*k
319+ c.retargetComponent(&rm.x, &rm.dx, (float64(settled.W)-w)/2, 0, now)
320+ c.retargetComponent(&rm.y, &rm.dy, (float64(settled.H)-h)/2, 0, now)
321+ c.retargetComponent(&rm.w, &rm.dw, w-float64(settled.W), 0, now)
322+ c.retargetComponent(&rm.h, &rm.dh, h-float64(settled.H), 0, now)
323+}
324+
168325 // snapshot records what every screen without an open overview shows at now.
169326 // Running motions are sampled at now on a copy, so a transition that
170327 // follows chains from their speed at the time of the action, not from the
@@ -58,10 +58,26 @@ type rectShot struct {
58 58
59 // rectMotion takes a window's drawn rect to its settled one: each component59 // rectMotion takes a window's drawn rect to its settled one: each component
60 // is an offset from the settled value, sprung to 0 (dx, dy, dw, dh hold the60 // is an offset from the settled value, sprung to 0 (dx, dy, dw, dh hold the
61-// current offsets, updated by advance).61+// current offsets, updated by advance). fade and dim are offsets too, from
62+// the settled Fade (0, or 1 for a leaving window) and the settled Dim.
63+//
64+// With scale, the window's content follows its drawn size: the shown
65+// placement gets a Zoom of the settled zoom (its Preview, or 1) times
66+// shown.W / settled.W (refreshShown), which the scene carries as Preview.
67+// The walk shrinks content toward the content rect's top-left, so this
68+// suits aspect-preserving changes only.
69+//
70+// A leaving motion draws a window that closed or hid while it fades out:
71+// left is its last settled placement, which refreshShown appends (Hidden
72+// and Leaving) to the settled and shown layouts, keeping them aligned.
62 type rectMotion struct {73 type rectMotion struct {
63 x, y, w, h motion74 x, y, w, h motion
64 dx, dy, dw, dh float6475 dx, dy, dw, dh float64
76+ fade, dim motion
77+ df, ddim float64
78+ scale bool
79+ leaving bool
80+ left Placement
65 }81 }
66 82
67 // apply is r moved by the current offsets; a size never drops below 1.83 // apply is r moved by the current offsets; a size never drops below 1.
@@ -95,6 +111,8 @@ func (m *rectMotion) advance(now time.Time) {
95 m.dy = stepComponent(&m.y, m.dy, now)111 m.dy = stepComponent(&m.y, m.dy, now)
96 m.dw = stepComponent(&m.w, m.dw, now)112 m.dw = stepComponent(&m.w, m.dw, now)
97 m.dh = stepComponent(&m.h, m.dh, now)113 m.dh = stepComponent(&m.h, m.dh, now)
114+ m.df = stepComponent(&m.fade, m.df, now)
115+ m.ddim = stepComponent(&m.dim, m.ddim, now)
98 }116 }
99 117
100 func stepComponent(m *motion, cur float64, now time.Time) float64 {118 func stepComponent(m *motion, cur float64, now time.Time) float64 {
@@ -110,7 +128,39 @@ func stepComponent(m *motion, cur float64, now time.Time) float64 {
110 }128 }
111 129
112 // on reports whether any component still runs.130 // on reports whether any component still runs.
113-func (m *rectMotion) on() bool { return m.x.on || m.y.on || m.w.on || m.h.on }131+func (m *rectMotion) on() bool {
132+ return m.x.on || m.y.on || m.w.on || m.h.on || m.fade.on || m.dim.on
133+}
134+
135+// settledFade is the Fade the motion springs to: a leaving window fades
136+// out, any other is opaque.
137+func (m *rectMotion) settledFade() float64 {
138+ if m.leaving {
139+ return 1
140+ }
141+ return 0
142+}
143+
144+// show is p as drawn: its rect moved by the offsets, its fade and dim
145+// offsets added, and its content zoomed with its size when scale is set.
146+func (m *rectMotion) show(p *Placement) {
147+ settled := p.Rect
148+ p.Rect = m.apply(settled)
149+ p.Fade = max(0, min(1, m.settledFade()+m.df))
150+ p.Dim = m.ddim
151+ if m.scale && settled.W > 0 {
152+ zoom := p.Preview
153+ if zoom == 0 {
154+ zoom = 1
155+ }
156+ zoom *= float64(p.Rect.W) / float64(settled.W)
157+ if zoom >= 0.999 {
158+ // Drawn at its size: no zoom (publish emits Preview 0).
159+ zoom = 1
160+ }
161+ p.Zoom = zoom
162+ }
163+}
114 164
115 // retargetComponent starts m for an offset of off from its settled value at165 // retargetComponent starts m for an offset of off from its settled value at
116 // now, with the speed v it had then. A component with nothing to move and no166 // now, with the speed v it had then. A component with nothing to move and no
@@ -129,6 +179,11 @@ func (c *Core) retargetComponent(m *motion, cur *float64, off, v float64, now ti
129 // with the rect motions applied: what is drawn and hit-tested). Rect motions179 // with the rect motions applied: what is drawn and hit-tested). Rect motions
130 // belong to the workspace they began on and to a closed overview: any other180 // belong to the workspace they began on and to a closed overview: any other
131 // change drops them.181 // change drops them.
182+//
183+// Leaving motions add an entry for a window the layout no longer shows:
184+// appended to both layouts in ID order, Hidden (no input, focus, popups or
185+// configure) and Leaving (drawn, fading). The two layouts stay
186+// index-aligned: publish reads the settled entry at the shown one's index.
132 func (c *Core) refreshShown() {187 func (c *Core) refreshShown() {
133 for _, sc := range c.screens {188 for _, sc := range c.screens {
134 m := sc.mon189 m := sc.mon
@@ -138,11 +193,13 @@ func (c *Core) refreshShown() {
138 sc.settledLayout = m.Layout()193 sc.settledLayout = m.Layout()
139 if len(sc.rects) > 0 {194 if len(sc.rects) > 0 {
140 // A window that left the layout (or is hidden) keeps no motion:195 // A window that left the layout (or is hidden) keeps no motion:
141- // it would ask for frames with nothing to move.196+ // it would ask for frames with nothing to move. A leaving one
142- for id := range sc.rects {197+ // is the opposite: it goes once the layout shows the window.
143- if !slices.ContainsFunc(sc.settledLayout, func(p Placement) bool {198+ for id, rm := range sc.rects {
199+ shown := slices.ContainsFunc(sc.settledLayout, func(p Placement) bool {
144 return p.ID == id && !p.Hidden && p.Preview == 0200 return p.ID == id && !p.Hidden && p.Preview == 0
145- }) {201+ })
202+ if shown == rm.leaving {
146 delete(sc.rects, id)203 delete(sc.rects, id)
147 }204 }
148 }205 }
@@ -151,20 +208,120 @@ func (c *Core) refreshShown() {
151 sc.shown = sc.settledLayout208 sc.shown = sc.settledLayout
152 continue209 continue
153 }210 }
211+ sc.settledLayout = sc.appendLeaving(sc.settledLayout)
154 buf := append(sc.shownBuf[:0], sc.settledLayout...)212 buf := append(sc.shownBuf[:0], sc.settledLayout...)
155 for i := range buf {213 for i := range buf {
156 p := &buf[i]214 p := &buf[i]
157- if p.Hidden || p.Preview > 0 {215+ if p.Hidden && !p.Leaving {
158 continue216 continue
159 }217 }
160 if rm, ok := sc.rects[p.ID]; ok {218 if rm, ok := sc.rects[p.ID]; ok {
161- p.Rect = rm.apply(p.Rect)219+ rm.show(p)
162 }220 }
163 }221 }
164 sc.shownBuf, sc.shown = buf, buf222 sc.shownBuf, sc.shown = buf, buf
165 }223 }
166 }224 }
167 225
226+// appendLeaving appends the leaving windows' last placements to layout,
227+// Hidden and Leaving, smallest ID first (a stable order keeps the scenes
228+// comparable frame to frame).
229+func (s *screen) appendLeaving(layout []Placement) []Placement {
230+ n := len(layout)
231+ for _, rm := range s.rects {
232+ if !rm.leaving {
233+ continue
234+ }
235+ p := rm.left
236+ p.Hidden, p.Leaving, p.Focused, p.Fade, p.Dim, p.Zoom = true, true, false, 0, 0, 0
237+ i := n
238+ for i < len(layout) && layout[i].ID < p.ID {
239+ i++
240+ }
241+ layout = slices.Insert(layout, i, p)
242+ }
243+ return layout
244+}
245+
246+// appear starts the entrance of a window the action made visible on sc:
247+// it fades in from invisible and grows from 90 % of its settled rect,
248+// around its centre. Nothing with animations off or if the layout does not
249+// show it. A leaving motion of the same window is replaced.
250+func (c *Core) appear(sc *screen, id WindowID, now time.Time) {
251+ if !c.animOn() {
252+ return
253+ }
254+ for _, p := range sc.mon.Layout() {
255+ if p.ID == id && !p.Hidden {
256+ c.appearAt(sc, p, now)
257+ return
258+ }
259+ }
260+}
261+
262+// appearAt is appear for a window whose settled placement is p.
263+func (c *Core) appearAt(sc *screen, p Placement, now time.Time) {
264+ if !c.animOn() || p.Hidden {
265+ return
266+ }
267+ if sc.rects == nil {
268+ sc.rects = make(map[WindowID]rectMotion)
269+ }
270+ var rm rectMotion
271+ rm.scale = true
272+ c.scaleFrom(&rm, p.Rect, appearScale, now)
273+ c.retargetComponent(&rm.fade, &rm.df, 1, 0, now)
274+ sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current()
275+}
276+
277+// leave keeps drawing a window the action closed or hid, from its last
278+// settled placement p: it fades out and shrinks to 90 % around its centre,
279+// then its entry goes. Nothing with animations off, or for a placement that
280+// was not drawn.
281+func (c *Core) leave(sc *screen, p Placement, now time.Time) {
282+ if !c.animOn() || p.Hidden || p.Preview > 0 || p.Rect.W <= 0 || p.Rect.H <= 0 {
283+ return
284+ }
285+ if sc.rects == nil {
286+ sc.rects = make(map[WindowID]rectMotion)
287+ }
288+ rm := rectMotion{scale: true, leaving: true, left: p}
289+ rm.left.Hidden, rm.left.Leaving, rm.left.Focused = true, true, false
290+ // The entry settles at 90 % of the rect it left, its content zoomed
291+ // to match (Preview): the offsets run from the full rect to that.
292+ end := scaledRect(p.Rect, appearScale)
293+ rm.left.Preview = appearScale
294+ c.retargetComponent(&rm.x, &rm.dx, float64(p.Rect.X-end.X), 0, now)
295+ c.retargetComponent(&rm.y, &rm.dy, float64(p.Rect.Y-end.Y), 0, now)
296+ c.retargetComponent(&rm.w, &rm.dw, float64(p.Rect.W-end.W), 0, now)
297+ c.retargetComponent(&rm.h, &rm.dh, float64(p.Rect.H-end.H), 0, now)
298+ rm.left.Rect = end
299+ c.retargetComponent(&rm.fade, &rm.df, -1, 0, now)
300+ sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current()
301+}
302+
303+// appearScale is the size, relative to the settled rect, a window appears
304+// from and leaves to.
305+const appearScale = 0.9
306+
307+// scaledRect is r scaled by k around its centre, in float.
308+func scaledRect(r Rect, k float64) Rect {
309+ w, h := float64(r.W)*k, float64(r.H)*k
310+ x := float64(r.X) + (float64(r.W)-w)/2
311+ y := float64(r.Y) + (float64(r.H)-h)/2
312+ return Rect{X: int(math.Round(x)), Y: int(math.Round(y)), W: int(math.Round(w)), H: int(math.Round(h))}
313+}
314+
315+// scaleFrom starts rm's rect components from settled scaled by k around
316+// its centre, toward settled.
317+func (c *Core) scaleFrom(rm *rectMotion, settled Rect, k float64, now time.Time) {
318+ w, h := float64(settled.W)*k, float64(settled.H)*k
319+ c.retargetComponent(&rm.x, &rm.dx, (float64(settled.W)-w)/2, 0, now)
320+ c.retargetComponent(&rm.y, &rm.dy, (float64(settled.H)-h)/2, 0, now)
321+ c.retargetComponent(&rm.w, &rm.dw, w-float64(settled.W), 0, now)
322+ c.retargetComponent(&rm.h, &rm.dh, h-float64(settled.H), 0, now)
323+}
324+
168 // snapshot records what every screen without an open overview shows at now.325 // snapshot records what every screen without an open overview shows at now.
169 // Running motions are sampled at now on a copy, so a transition that326 // Running motions are sampled at now on a copy, so a transition that
170 // follows chains from their speed at the time of the action, not from the327 // follows chains from their speed at the time of the action, not from the
A internal/core/transition_fade_internal_test.go +324 -0
@@ -0,0 +1,324 @@
1+package core
2+
3+import (
4+ "math"
5+ "slices"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// fadeCore is indicatorCore with its commands readable.
13+func fadeCore(t *testing.T) (*Core, *indicatorClock, chan ports.ClientCommand) {
14+ t.Helper()
15+ c, ic := indicatorCore(t)
16+ cmds := make(chan ports.ClientCommand, 64)
17+ c.ch.Commands = cmds
18+ return c, ic, cmds
19+}
20+
21+// drainConfigures returns the IDs configured since the last drain.
22+func drainConfigures(cmds chan ports.ClientCommand) []WindowID {
23+ var ids []WindowID
24+ for {
25+ select {
26+ case cmd := <-cmds:
27+ if v, ok := cmd.(ports.ConfigureWindow); ok {
28+ ids = append(ids, v.ID)
29+ }
30+ default:
31+ return ids
32+ }
33+ }
34+}
35+
36+func sceneWindow(t *testing.T, s ports.Scene, id WindowID) ports.SceneWindow {
37+ t.Helper()
38+ i := slices.IndexFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == id })
39+ if i < 0 {
40+ t.Fatalf("window %d not in the scene: %+v", id, s.Windows)
41+ }
42+ return s.Windows[i]
43+}
44+
45+// appear fades a window in from invisible and grows it from 90 % of its
46+// settled rect, its content zoomed to the drawn size; the shown placement
47+// carries the derived Fade and Preview, the settled one neither.
48+func TestAppearDerivesFadeAndPreview(t *testing.T) {
49+ c, ic, cmds := fadeCore(t)
50+ c.cfg.Animations.On = true
51+ sc := c.cur()
52+ sc.mon.AddWindow(1)
53+ sc.mon.AddWindow(2)
54+ settleShown(t, c)
55+ drainConfigures(cmds)
56+ settled := sc.settledLayout[slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == 2 })]
57+
58+ c.appear(sc, 2, ic.now)
59+ s := indicatorScene(t, c)
60+ w := sceneWindow(t, s, 2)
61+ if w.Fade != 1 || w.Hidden {
62+ t.Fatalf("right after appear: fade %v hidden %v, want invisible and shown", w.Fade, w.Hidden)
63+ }
64+ want := scaledRect(settled.Rect, appearScale)
65+ if w.Rect != want {
66+ t.Fatalf("first rect %+v, want 90 %% of %+v = %+v", w.Rect, settled.Rect, want)
67+ }
68+ if math.Abs(w.Preview-0.9) > 0.02 {
69+ t.Fatalf("preview %v, want ~0.9 (content follows the drawn size)", w.Preview)
70+ }
71+ if ps := sc.settledLayout[slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == 2 })]; ps != settled {
72+ t.Fatalf("settled placement changed: %+v vs %+v", ps, settled)
73+ }
74+ if ids := drainConfigures(cmds); len(ids) != 0 {
75+ t.Fatalf("appear reconfigured %v", ids)
76+ }
77+ // The derived zoom is not a card: the window takes input and keeps
78+ // its popups while it appears.
79+ if id, _, _ := c.hit(float64(w.Rect.X+w.Rect.W/2), float64(w.Rect.Y+w.Rect.H/2)); id != 2 {
80+ t.Fatalf("hit %d while appearing, want 2", id)
81+ }
82+ if _, _, ok := c.windowRect(2); !ok {
83+ t.Fatal("windowRect hides the appearing window")
84+ }
85+
86+ ic.now = ic.now.Add(40 * time.Millisecond)
87+ c.animate(ic.now, nil)
88+ s = indicatorScene(t, c)
89+ w = sceneWindow(t, s, 2)
90+ if !(w.Fade > 0 && w.Fade < 1) || !(w.Preview > 0.9 && w.Preview < 1) {
91+ t.Fatalf("mid-way fade %v preview %v, want strictly between", w.Fade, w.Preview)
92+ }
93+ if !(w.Rect.W > want.W && w.Rect.W < settled.Rect.W) {
94+ t.Fatalf("mid-way width %d, want between %d and %d", w.Rect.W, want.W, settled.Rect.W)
95+ }
96+
97+ ic.now = ic.now.Add(5 * time.Second)
98+ c.animate(ic.now, nil)
99+ s = indicatorScene(t, c)
100+ w = sceneWindow(t, s, 2)
101+ if w.Fade != 0 || w.Preview != 0 || w.Rect != settled.Rect || len(sc.rects) != 0 {
102+ t.Fatalf("settled: %+v, rects %d", w, len(sc.rects))
103+ }
104+ if c.animating() {
105+ t.Fatal("still animating after the settle")
106+ }
107+}
108+
109+// With animations off appear does nothing.
110+func TestAppearOffIsInstant(t *testing.T) {
111+ c, ic := indicatorCore(t)
112+ sc := c.cur()
113+ sc.mon.AddWindow(1)
114+ settleShown(t, c)
115+ c.appear(sc, 1, ic.now)
116+ if len(sc.rects) != 0 || c.animating() {
117+ t.Fatal("appear started a motion with animations off")
118+ }
119+ if w := sceneWindow(t, indicatorScene(t, c), 1); w.Fade != 0 || w.Preview != 0 {
120+ t.Fatalf("faded with animations off: %+v", w)
121+ }
122+}
123+
124+// A leaving window stays in the scene while it fades out and shrinks, is
125+// hidden to input, focus and configures, keeps settled and shown aligned,
126+// and goes once settled.
127+func TestLeavingEntryLifecycle(t *testing.T) {
128+ c, ic, cmds := fadeCore(t)
129+ c.cfg.Animations.On = true
130+ sc := c.cur()
131+ sc.mon.AddWindow(1)
132+ sc.mon.AddWindow(2)
133+ settleShown(t, c)
134+ drainConfigures(cmds)
135+ left := sc.settledLayout[slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == 2 })]
136+
137+ // Close window 2: the layout re-flows; its last placement leaves.
138+ sc.mon.RemoveWindow(2)
139+ c.leave(sc, left, ic.now)
140+ s := indicatorScene(t, c)
141+ if len(sc.settledLayout) != len(sc.shown) {
142+ t.Fatalf("settled %d and shown %d entries", len(sc.settledLayout), len(sc.shown))
143+ }
144+ for k := range sc.shown {
145+ if sc.shown[k].ID != sc.settledLayout[k].ID {
146+ t.Fatalf("index %d: shown %d, settled %d", k, sc.shown[k].ID, sc.settledLayout[k].ID)
147+ }
148+ }
149+ k := slices.IndexFunc(sc.shown, func(p Placement) bool { return p.ID == 2 })
150+ if k < 0 || !sc.shown[k].Leaving || !sc.shown[k].Hidden || !sc.settledLayout[k].Hidden {
151+ t.Fatalf("leaving entry: shown %+v settled %+v", sc.shown[k], sc.settledLayout[k])
152+ }
153+ w := sceneWindow(t, s, 2)
154+ if w.Hidden || w.Fade != 0 || w.Rect != left.Rect || w.Preview != 0 {
155+ t.Fatalf("first leaving frame %+v, want drawn opaque at %+v", w, left.Rect)
156+ }
157+ if ids := drainConfigures(cmds); slices.Contains(ids, 2) {
158+ t.Fatalf("the leaving window was configured: %v", ids)
159+ }
160+ // Window 1 took the whole frame: the leaving one over it is not hit.
161+ cx, cy := float64(left.Rect.X+left.Rect.W/2), float64(left.Rect.Y+left.Rect.H/2)
162+ if id, _, _ := c.hit(cx, cy); id != 1 {
163+ t.Fatalf("hit %d at the leaving window's centre, want 1", id)
164+ }
165+ if id, _ := sc.mon.Focused(); id == 2 {
166+ t.Fatal("the leaving window is focused")
167+ }
168+ if _, _, ok := c.windowRect(2); ok {
169+ t.Fatal("windowRect reports the leaving window on screen")
170+ }
171+ if !c.animating() {
172+ t.Fatal("no motion runs for the leaving window")
173+ }
174+
175+ ic.now = ic.now.Add(40 * time.Millisecond)
176+ c.animate(ic.now, nil)
177+ s = indicatorScene(t, c)
178+ w = sceneWindow(t, s, 2)
179+ if !(w.Fade > 0 && w.Fade < 1) || !(w.Rect.W < left.Rect.W) || !(w.Preview > 0.9 && w.Preview < 1) {
180+ t.Fatalf("mid-way %+v", w)
181+ }
182+
183+ ic.now = ic.now.Add(5 * time.Second)
184+ c.animate(ic.now, nil)
185+ s = indicatorScene(t, c)
186+ if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) {
187+ t.Fatalf("the leaving window is still in the scene: %+v", s.Windows)
188+ }
189+ if len(sc.rects) != 0 || c.animating() || len(sc.shown) != len(sc.settledLayout) {
190+ t.Fatalf("after the settle: rects %d animating %v", len(sc.rects), c.animating())
191+ }
192+}
193+
194+// A leaving window the layout shows again (the stash reopened before the
195+// fade ended) drops its leaving entry at once: the layout's placement wins.
196+func TestLeavingEntryDroppedWhenShownAgain(t *testing.T) {
197+ c, ic := indicatorCore(t)
198+ c.cfg.Animations.On = true
199+ sc := c.cur()
200+ sc.mon.AddWindow(1)
201+ sc.mon.AddWindow(2)
202+ settleShown(t, c)
203+ left := sc.settledLayout[1]
204+ sc.mon.RemoveWindow(2)
205+ c.leave(sc, left, ic.now)
206+ if s := indicatorScene(t, c); len(s.Windows) != 2 || !sc.shown[1].Leaving {
207+ t.Fatalf("no leaving entry: %+v", s.Windows)
208+ }
209+ sc.mon.AddWindow(2)
210+ s := indicatorScene(t, c)
211+ n := 0
212+ for _, w := range s.Windows {
213+ if w.ID == 2 {
214+ n++
215+ if w.Fade != 0 || w.Preview != 0 {
216+ t.Fatalf("the re-added window keeps the leaving fade: %+v", w)
217+ }
218+ }
219+ }
220+ if n != 1 || len(sc.rects) != 0 || slices.ContainsFunc(sc.shown, func(p Placement) bool { return p.Leaving }) {
221+ t.Fatalf("window 2 listed %d times, rects %d: %+v", n, len(sc.rects), sc.shown)
222+ }
223+}
224+
225+// stopRects (lock, off, overview, workspace change, unplug) drops leaving
226+// entries with the other motions.
227+func TestLeavingEntryDroppedByStopRects(t *testing.T) {
228+ c, ic := indicatorCore(t)
229+ c.cfg.Animations.On = true
230+ sc := c.cur()
231+ sc.mon.AddWindow(1)
232+ sc.mon.AddWindow(2)
233+ settleShown(t, c)
234+ left := sc.settledLayout[1]
235+ sc.mon.RemoveWindow(2)
236+ c.leave(sc, left, ic.now)
237+ if s := indicatorScene(t, c); len(s.Windows) != 2 {
238+ t.Fatalf("%d windows, want the tile and the leaving entry", len(s.Windows))
239+ }
240+ c.stopAnimations()
241+ s := indicatorScene(t, c)
242+ if len(s.Windows) != 1 || s.Windows[0].ID != 1 || len(sc.shown) != 1 {
243+ t.Fatalf("after stop: %+v", s.Windows)
244+ }
245+}
246+
247+// With animations off a leaving window is simply gone.
248+func TestLeaveOffIsInstant(t *testing.T) {
249+ c, ic := indicatorCore(t)
250+ sc := c.cur()
251+ sc.mon.AddWindow(1)
252+ settleShown(t, c)
253+ left := sc.settledLayout[0]
254+ sc.mon.RemoveWindow(1)
255+ c.leave(sc, left, ic.now)
256+ if s := indicatorScene(t, c); len(s.Windows) != 0 || len(sc.rects) != 0 {
257+ t.Fatalf("leaving entry with animations off: %+v", s.Windows)
258+ }
259+}
260+
261+// A dim motion animates the veil of a shown window from an offset to the
262+// settled Dim; a peeking window adds it to the configured stash dim.
263+func TestDimMotionAnimatesSceneDim(t *testing.T) {
264+ c, ic := indicatorCore(t)
265+ c.cfg.Animations.On = true
266+ sc := c.cur()
267+ sc.mon.AddWindow(1)
268+ settleShown(t, c)
269+ sc.rects = map[WindowID]rectMotion{}
270+ var rm rectMotion
271+ c.retargetComponent(&rm.dim, &rm.ddim, 0.5, 0, ic.now)
272+ sc.rects[1], sc.rectsWS = rm, sc.mon.Current()
273+ if w := sceneWindow(t, indicatorScene(t, c), 1); w.Dim != 0.5 {
274+ t.Fatalf("dim %v, want the 0.5 offset", w.Dim)
275+ }
276+ ic.now = ic.now.Add(40 * time.Millisecond)
277+ c.animate(ic.now, nil)
278+ if w := sceneWindow(t, indicatorScene(t, c), 1); !(w.Dim > 0 && w.Dim < 0.5) {
279+ t.Fatalf("mid-way dim %v", w.Dim)
280+ }
281+ ic.now = ic.now.Add(5 * time.Second)
282+ c.animate(ic.now, nil)
283+ if w := sceneWindow(t, indicatorScene(t, c), 1); w.Dim != 0 || len(sc.rects) != 0 {
284+ t.Fatalf("settled dim %v, rects %d", w.Dim, len(sc.rects))
285+ }
286+}
287+
288+// A scale motion on an overview card multiplies the card's settled zoom
289+// into Zoom; Preview stays settled (input tells cards by it).
290+func TestScaleMotionMultipliesSettledPreview(t *testing.T) {
291+ var rm rectMotion
292+ rm.scale, rm.dw = true, 50
293+ p := Placement{ID: 1, Rect: Rect{W: 100, H: 100}, Preview: 0.2}
294+ rm.show(&p)
295+ if p.Rect.W != 150 || math.Abs(p.Zoom-0.3) > 1e-9 || p.Preview != 0.2 {
296+ t.Fatalf("shown %+v, want width 150, zoom 0.3 and the settled preview", p)
297+ }
298+ // A plain window drawn at its size: Zoom 1 (publish emits Preview 0).
299+ rm.dw = 0.01
300+ p = Placement{ID: 1, Rect: Rect{W: 100, H: 100}}
301+ rm.show(&p)
302+ if p.Zoom != 1 {
303+ t.Fatalf("zoom %v for an unscaled window, want 1", p.Zoom)
304+ }
305+}
306+
307+// snapshot leaves leaving entries out (they are Hidden), so a transition
308+// after a close never moves them; the neighbours' rects are recorded.
309+func TestSnapshotSkipsLeavingEntries(t *testing.T) {
310+ c, ic := indicatorCore(t)
311+ c.cfg.Animations.On = true
312+ sc := c.cur()
313+ sc.mon.AddWindow(1)
314+ sc.mon.AddWindow(2)
315+ settleShown(t, c)
316+ left := sc.settledLayout[1]
317+ sc.mon.RemoveWindow(2)
318+ c.leave(sc, left, ic.now)
319+ settleShown(t, c)
320+ shots := c.snapshot(ic.now)
321+ if len(shots) != 1 || len(shots[0].rects) != 1 || shots[0].rects[0].id != 1 {
322+ t.Fatalf("snapshot rects %+v, want window 1 only", shots[0].rects)
323+ }
324+}
@@ -0,0 +1,324 @@
1+package core
2+
3+import (
4+ "math"
5+ "slices"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/ports"
10+)
11+
12+// fadeCore is indicatorCore with its commands readable.
13+func fadeCore(t *testing.T) (*Core, *indicatorClock, chan ports.ClientCommand) {
14+ t.Helper()
15+ c, ic := indicatorCore(t)
16+ cmds := make(chan ports.ClientCommand, 64)
17+ c.ch.Commands = cmds
18+ return c, ic, cmds
19+}
20+
21+// drainConfigures returns the IDs configured since the last drain.
22+func drainConfigures(cmds chan ports.ClientCommand) []WindowID {
23+ var ids []WindowID
24+ for {
25+ select {
26+ case cmd := <-cmds:
27+ if v, ok := cmd.(ports.ConfigureWindow); ok {
28+ ids = append(ids, v.ID)
29+ }
30+ default:
31+ return ids
32+ }
33+ }
34+}
35+
36+func sceneWindow(t *testing.T, s ports.Scene, id WindowID) ports.SceneWindow {
37+ t.Helper()
38+ i := slices.IndexFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == id })
39+ if i < 0 {
40+ t.Fatalf("window %d not in the scene: %+v", id, s.Windows)
41+ }
42+ return s.Windows[i]
43+}
44+
45+// appear fades a window in from invisible and grows it from 90 % of its
46+// settled rect, its content zoomed to the drawn size; the shown placement
47+// carries the derived Fade and Preview, the settled one neither.
48+func TestAppearDerivesFadeAndPreview(t *testing.T) {
49+ c, ic, cmds := fadeCore(t)
50+ c.cfg.Animations.On = true
51+ sc := c.cur()
52+ sc.mon.AddWindow(1)
53+ sc.mon.AddWindow(2)
54+ settleShown(t, c)
55+ drainConfigures(cmds)
56+ settled := sc.settledLayout[slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == 2 })]
57+
58+ c.appear(sc, 2, ic.now)
59+ s := indicatorScene(t, c)
60+ w := sceneWindow(t, s, 2)
61+ if w.Fade != 1 || w.Hidden {
62+ t.Fatalf("right after appear: fade %v hidden %v, want invisible and shown", w.Fade, w.Hidden)
63+ }
64+ want := scaledRect(settled.Rect, appearScale)
65+ if w.Rect != want {
66+ t.Fatalf("first rect %+v, want 90 %% of %+v = %+v", w.Rect, settled.Rect, want)
67+ }
68+ if math.Abs(w.Preview-0.9) > 0.02 {
69+ t.Fatalf("preview %v, want ~0.9 (content follows the drawn size)", w.Preview)
70+ }
71+ if ps := sc.settledLayout[slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == 2 })]; ps != settled {
72+ t.Fatalf("settled placement changed: %+v vs %+v", ps, settled)
73+ }
74+ if ids := drainConfigures(cmds); len(ids) != 0 {
75+ t.Fatalf("appear reconfigured %v", ids)
76+ }
77+ // The derived zoom is not a card: the window takes input and keeps
78+ // its popups while it appears.
79+ if id, _, _ := c.hit(float64(w.Rect.X+w.Rect.W/2), float64(w.Rect.Y+w.Rect.H/2)); id != 2 {
80+ t.Fatalf("hit %d while appearing, want 2", id)
81+ }
82+ if _, _, ok := c.windowRect(2); !ok {
83+ t.Fatal("windowRect hides the appearing window")
84+ }
85+
86+ ic.now = ic.now.Add(40 * time.Millisecond)
87+ c.animate(ic.now, nil)
88+ s = indicatorScene(t, c)
89+ w = sceneWindow(t, s, 2)
90+ if !(w.Fade > 0 && w.Fade < 1) || !(w.Preview > 0.9 && w.Preview < 1) {
91+ t.Fatalf("mid-way fade %v preview %v, want strictly between", w.Fade, w.Preview)
92+ }
93+ if !(w.Rect.W > want.W && w.Rect.W < settled.Rect.W) {
94+ t.Fatalf("mid-way width %d, want between %d and %d", w.Rect.W, want.W, settled.Rect.W)
95+ }
96+
97+ ic.now = ic.now.Add(5 * time.Second)
98+ c.animate(ic.now, nil)
99+ s = indicatorScene(t, c)
100+ w = sceneWindow(t, s, 2)
101+ if w.Fade != 0 || w.Preview != 0 || w.Rect != settled.Rect || len(sc.rects) != 0 {
102+ t.Fatalf("settled: %+v, rects %d", w, len(sc.rects))
103+ }
104+ if c.animating() {
105+ t.Fatal("still animating after the settle")
106+ }
107+}
108+
109+// With animations off appear does nothing.
110+func TestAppearOffIsInstant(t *testing.T) {
111+ c, ic := indicatorCore(t)
112+ sc := c.cur()
113+ sc.mon.AddWindow(1)
114+ settleShown(t, c)
115+ c.appear(sc, 1, ic.now)
116+ if len(sc.rects) != 0 || c.animating() {
117+ t.Fatal("appear started a motion with animations off")
118+ }
119+ if w := sceneWindow(t, indicatorScene(t, c), 1); w.Fade != 0 || w.Preview != 0 {
120+ t.Fatalf("faded with animations off: %+v", w)
121+ }
122+}
123+
124+// A leaving window stays in the scene while it fades out and shrinks, is
125+// hidden to input, focus and configures, keeps settled and shown aligned,
126+// and goes once settled.
127+func TestLeavingEntryLifecycle(t *testing.T) {
128+ c, ic, cmds := fadeCore(t)
129+ c.cfg.Animations.On = true
130+ sc := c.cur()
131+ sc.mon.AddWindow(1)
132+ sc.mon.AddWindow(2)
133+ settleShown(t, c)
134+ drainConfigures(cmds)
135+ left := sc.settledLayout[slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == 2 })]
136+
137+ // Close window 2: the layout re-flows; its last placement leaves.
138+ sc.mon.RemoveWindow(2)
139+ c.leave(sc, left, ic.now)
140+ s := indicatorScene(t, c)
141+ if len(sc.settledLayout) != len(sc.shown) {
142+ t.Fatalf("settled %d and shown %d entries", len(sc.settledLayout), len(sc.shown))
143+ }
144+ for k := range sc.shown {
145+ if sc.shown[k].ID != sc.settledLayout[k].ID {
146+ t.Fatalf("index %d: shown %d, settled %d", k, sc.shown[k].ID, sc.settledLayout[k].ID)
147+ }
148+ }
149+ k := slices.IndexFunc(sc.shown, func(p Placement) bool { return p.ID == 2 })
150+ if k < 0 || !sc.shown[k].Leaving || !sc.shown[k].Hidden || !sc.settledLayout[k].Hidden {
151+ t.Fatalf("leaving entry: shown %+v settled %+v", sc.shown[k], sc.settledLayout[k])
152+ }
153+ w := sceneWindow(t, s, 2)
154+ if w.Hidden || w.Fade != 0 || w.Rect != left.Rect || w.Preview != 0 {
155+ t.Fatalf("first leaving frame %+v, want drawn opaque at %+v", w, left.Rect)
156+ }
157+ if ids := drainConfigures(cmds); slices.Contains(ids, 2) {
158+ t.Fatalf("the leaving window was configured: %v", ids)
159+ }
160+ // Window 1 took the whole frame: the leaving one over it is not hit.
161+ cx, cy := float64(left.Rect.X+left.Rect.W/2), float64(left.Rect.Y+left.Rect.H/2)
162+ if id, _, _ := c.hit(cx, cy); id != 1 {
163+ t.Fatalf("hit %d at the leaving window's centre, want 1", id)
164+ }
165+ if id, _ := sc.mon.Focused(); id == 2 {
166+ t.Fatal("the leaving window is focused")
167+ }
168+ if _, _, ok := c.windowRect(2); ok {
169+ t.Fatal("windowRect reports the leaving window on screen")
170+ }
171+ if !c.animating() {
172+ t.Fatal("no motion runs for the leaving window")
173+ }
174+
175+ ic.now = ic.now.Add(40 * time.Millisecond)
176+ c.animate(ic.now, nil)
177+ s = indicatorScene(t, c)
178+ w = sceneWindow(t, s, 2)
179+ if !(w.Fade > 0 && w.Fade < 1) || !(w.Rect.W < left.Rect.W) || !(w.Preview > 0.9 && w.Preview < 1) {
180+ t.Fatalf("mid-way %+v", w)
181+ }
182+
183+ ic.now = ic.now.Add(5 * time.Second)
184+ c.animate(ic.now, nil)
185+ s = indicatorScene(t, c)
186+ if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) {
187+ t.Fatalf("the leaving window is still in the scene: %+v", s.Windows)
188+ }
189+ if len(sc.rects) != 0 || c.animating() || len(sc.shown) != len(sc.settledLayout) {
190+ t.Fatalf("after the settle: rects %d animating %v", len(sc.rects), c.animating())
191+ }
192+}
193+
194+// A leaving window the layout shows again (the stash reopened before the
195+// fade ended) drops its leaving entry at once: the layout's placement wins.
196+func TestLeavingEntryDroppedWhenShownAgain(t *testing.T) {
197+ c, ic := indicatorCore(t)
198+ c.cfg.Animations.On = true
199+ sc := c.cur()
200+ sc.mon.AddWindow(1)
201+ sc.mon.AddWindow(2)
202+ settleShown(t, c)
203+ left := sc.settledLayout[1]
204+ sc.mon.RemoveWindow(2)
205+ c.leave(sc, left, ic.now)
206+ if s := indicatorScene(t, c); len(s.Windows) != 2 || !sc.shown[1].Leaving {
207+ t.Fatalf("no leaving entry: %+v", s.Windows)
208+ }
209+ sc.mon.AddWindow(2)
210+ s := indicatorScene(t, c)
211+ n := 0
212+ for _, w := range s.Windows {
213+ if w.ID == 2 {
214+ n++
215+ if w.Fade != 0 || w.Preview != 0 {
216+ t.Fatalf("the re-added window keeps the leaving fade: %+v", w)
217+ }
218+ }
219+ }
220+ if n != 1 || len(sc.rects) != 0 || slices.ContainsFunc(sc.shown, func(p Placement) bool { return p.Leaving }) {
221+ t.Fatalf("window 2 listed %d times, rects %d: %+v", n, len(sc.rects), sc.shown)
222+ }
223+}
224+
225+// stopRects (lock, off, overview, workspace change, unplug) drops leaving
226+// entries with the other motions.
227+func TestLeavingEntryDroppedByStopRects(t *testing.T) {
228+ c, ic := indicatorCore(t)
229+ c.cfg.Animations.On = true
230+ sc := c.cur()
231+ sc.mon.AddWindow(1)
232+ sc.mon.AddWindow(2)
233+ settleShown(t, c)
234+ left := sc.settledLayout[1]
235+ sc.mon.RemoveWindow(2)
236+ c.leave(sc, left, ic.now)
237+ if s := indicatorScene(t, c); len(s.Windows) != 2 {
238+ t.Fatalf("%d windows, want the tile and the leaving entry", len(s.Windows))
239+ }
240+ c.stopAnimations()
241+ s := indicatorScene(t, c)
242+ if len(s.Windows) != 1 || s.Windows[0].ID != 1 || len(sc.shown) != 1 {
243+ t.Fatalf("after stop: %+v", s.Windows)
244+ }
245+}
246+
247+// With animations off a leaving window is simply gone.
248+func TestLeaveOffIsInstant(t *testing.T) {
249+ c, ic := indicatorCore(t)
250+ sc := c.cur()
251+ sc.mon.AddWindow(1)
252+ settleShown(t, c)
253+ left := sc.settledLayout[0]
254+ sc.mon.RemoveWindow(1)
255+ c.leave(sc, left, ic.now)
256+ if s := indicatorScene(t, c); len(s.Windows) != 0 || len(sc.rects) != 0 {
257+ t.Fatalf("leaving entry with animations off: %+v", s.Windows)
258+ }
259+}
260+
261+// A dim motion animates the veil of a shown window from an offset to the
262+// settled Dim; a peeking window adds it to the configured stash dim.
263+func TestDimMotionAnimatesSceneDim(t *testing.T) {
264+ c, ic := indicatorCore(t)
265+ c.cfg.Animations.On = true
266+ sc := c.cur()
267+ sc.mon.AddWindow(1)
268+ settleShown(t, c)
269+ sc.rects = map[WindowID]rectMotion{}
270+ var rm rectMotion
271+ c.retargetComponent(&rm.dim, &rm.ddim, 0.5, 0, ic.now)
272+ sc.rects[1], sc.rectsWS = rm, sc.mon.Current()
273+ if w := sceneWindow(t, indicatorScene(t, c), 1); w.Dim != 0.5 {
274+ t.Fatalf("dim %v, want the 0.5 offset", w.Dim)
275+ }
276+ ic.now = ic.now.Add(40 * time.Millisecond)
277+ c.animate(ic.now, nil)
278+ if w := sceneWindow(t, indicatorScene(t, c), 1); !(w.Dim > 0 && w.Dim < 0.5) {
279+ t.Fatalf("mid-way dim %v", w.Dim)
280+ }
281+ ic.now = ic.now.Add(5 * time.Second)
282+ c.animate(ic.now, nil)
283+ if w := sceneWindow(t, indicatorScene(t, c), 1); w.Dim != 0 || len(sc.rects) != 0 {
284+ t.Fatalf("settled dim %v, rects %d", w.Dim, len(sc.rects))
285+ }
286+}
287+
288+// A scale motion on an overview card multiplies the card's settled zoom
289+// into Zoom; Preview stays settled (input tells cards by it).
290+func TestScaleMotionMultipliesSettledPreview(t *testing.T) {
291+ var rm rectMotion
292+ rm.scale, rm.dw = true, 50
293+ p := Placement{ID: 1, Rect: Rect{W: 100, H: 100}, Preview: 0.2}
294+ rm.show(&p)
295+ if p.Rect.W != 150 || math.Abs(p.Zoom-0.3) > 1e-9 || p.Preview != 0.2 {
296+ t.Fatalf("shown %+v, want width 150, zoom 0.3 and the settled preview", p)
297+ }
298+ // A plain window drawn at its size: Zoom 1 (publish emits Preview 0).
299+ rm.dw = 0.01
300+ p = Placement{ID: 1, Rect: Rect{W: 100, H: 100}}
301+ rm.show(&p)
302+ if p.Zoom != 1 {
303+ t.Fatalf("zoom %v for an unscaled window, want 1", p.Zoom)
304+ }
305+}
306+
307+// snapshot leaves leaving entries out (they are Hidden), so a transition
308+// after a close never moves them; the neighbours' rects are recorded.
309+func TestSnapshotSkipsLeavingEntries(t *testing.T) {
310+ c, ic := indicatorCore(t)
311+ c.cfg.Animations.On = true
312+ sc := c.cur()
313+ sc.mon.AddWindow(1)
314+ sc.mon.AddWindow(2)
315+ settleShown(t, c)
316+ left := sc.settledLayout[1]
317+ sc.mon.RemoveWindow(2)
318+ c.leave(sc, left, ic.now)
319+ settleShown(t, c)
320+ shots := c.snapshot(ic.now)
321+ if len(shots) != 1 || len(shots[0].rects) != 1 || shots[0].rects[0].id != 1 {
322+ t.Fatalf("snapshot rects %+v, want window 1 only", shots[0].rects)
323+ }
324+}

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