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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 ( | |||
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | 8 | // 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 | 11 | func TestAnimateAllocations(t *testing.T) { | |
| 11 | 12 | c, ic := indicatorCore(t) | |
| 12 | 13 | c.cfg.Animations.On = true | |
| @@ -24,11 +25,18 @@ func TestAnimateAllocations(t *testing.T) { | |||
| 24 | 25 | sc.rects = make(map[WindowID]rectMotion, 20) | |
| 25 | 26 | for i := range 20 { | |
| 26 | 27 | sp := viewSpring(50, 0) | |
| 27 | - | sc.rects[WindowID(100+i)] = rectMotion{ | |
| 28 | + | rm := rectMotion{ | |
| 28 | 29 | x: c.spring(sp, t0), y: c.spring(sp, t0), | |
| 29 | 30 | w: c.spring(sp, t0), h: c.spring(sp, t0), | |
| 30 | 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 | 42 | 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 rect | 8 | // 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 = true | 13 | 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 { | |||
| 605 | 605 | ps := settled[k] | |
| 606 | 606 | // Only the focused output has an activated window. | |
| 607 | 607 | 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 | 616 | 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 | |
| 611 | 625 | } | |
| 612 | 626 | if focused && p.ID == c.pulse.target && !alone && !p.Fullscreen && !p.Hidden && p.Preview == 0 && !sc.mon.ov.open { | |
| 613 | 627 | 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.focusScreen | 607 | 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.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 | |||
| 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 = true | 627 | drawable = true | ||
M internal/core/layout.go +12 -0
| @@ -122,6 +122,18 @@ type Placement struct { | |||
| 122 | 122 | // Inset reserves room for drawn lines: all sides of a float; for | |
| 123 | 123 | // tiles without gaps, only the right and bottom shared sides. | |
| 124 | 124 | 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 | 139 | // 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; for | 122 | // 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.Sides | 124 | 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 | |||
| 49 | 49 | tiles := 0 | |
| 50 | 50 | for i := range ps { | |
| 51 | 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 | 55 | continue | |
| 54 | 56 | } | |
| 55 | 57 | if !p.Floating { | |
| @@ -49,7 +49,9 @@ func separators(ps []Placement, width, gap int, o Rect, lit bool) []ports.Separa | |||||
| 49 | tiles := 0 | 49 | 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 | continue | 55 | 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 { | |||
| 58 | 58 | ||
| 59 | 59 | // rectMotion takes a window's drawn rect to its settled one: each component | |
| 60 | 60 | // 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 | 73 | type rectMotion struct { | |
| 63 | 74 | x, y, w, h motion | |
| 64 | 75 | 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 | 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 | 111 | m.dy = stepComponent(&m.y, m.dy, now) | |
| 96 | 112 | m.dw = stepComponent(&m.w, m.dw, now) | |
| 97 | 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 | 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 | 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 | 165 | // retargetComponent starts m for an offset of off from its settled value at | |
| 116 | 166 | // 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 | 179 | // with the rect motions applied: what is drawn and hit-tested). Rect motions | |
| 130 | 180 | // belong to the workspace they began on and to a closed overview: any other | |
| 131 | 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 | 187 | func (c *Core) refreshShown() { | |
| 133 | 188 | for _, sc := range c.screens { | |
| 134 | 189 | m := sc.mon | |
| @@ -138,11 +193,13 @@ func (c *Core) refreshShown() { | |||
| 138 | 193 | sc.settledLayout = m.Layout() | |
| 139 | 194 | if len(sc.rects) > 0 { | |
| 140 | 195 | // 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 { | |
| 144 | 200 | return p.ID == id && !p.Hidden && p.Preview == 0 | |
| 145 | - | }) { | |
| 201 | + | }) | |
| 202 | + | if shown == rm.leaving { | |
| 146 | 203 | delete(sc.rects, id) | |
| 147 | 204 | } | |
| 148 | 205 | } | |
| @@ -151,20 +208,120 @@ func (c *Core) refreshShown() { | |||
| 151 | 208 | sc.shown = sc.settledLayout | |
| 152 | 209 | continue | |
| 153 | 210 | } | |
| 211 | + | sc.settledLayout = sc.appendLeaving(sc.settledLayout) | |
| 154 | 212 | buf := append(sc.shownBuf[:0], sc.settledLayout...) | |
| 155 | 213 | for i := range buf { | |
| 156 | 214 | p := &buf[i] | |
| 157 | - | if p.Hidden || p.Preview > 0 { | |
| 215 | + | if p.Hidden && !p.Leaving { | |
| 158 | 216 | continue | |
| 159 | 217 | } | |
| 160 | 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 | 222 | 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 | 325 | // snapshot records what every screen without an open overview shows at now. | |
| 169 | 326 | // Running motions are sampled at now on a copy, so a transition that | |
| 170 | 327 | // 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 component | 59 | // 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 the | 60 | // 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 motion | 74 | x, y, w, h motion | ||
| 64 | dx, dy, dw, dh float64 | 75 | 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 at | 165 | // 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 no | 166 | // 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 motions | 179 | // 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 other | 180 | // 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.mon | 189 | 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 == 0 | 200 | 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.settledLayout | 208 | sc.shown = sc.settledLayout | ||
| 152 | continue | 209 | 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 | continue | 216 | 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, buf | 222 | 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 that | 326 | // 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 the | 327 | // 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 | + | } | |||