fix(core): review of the overview animation: faded cards, carried fades, min-ratio zoom, filtered snapshots
Cards with Fade >= 0.5 take no click, a running fade carries over an overview state change, a scale motion's zoom follows the smaller of the width and height ratios, input snapshots only for events that can step the overview, a map with the overview open is not an overview action, and the no-camera-slide check is restored as an internal test.
- commit
- 4e1c07bc19ccf6087aa7aaccec32fe4ad7f3d848
- author
- brice · · 2026-10-04 23:29 UTC
- parent
- 18098d8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +191 -18
M internal/core/input.go +10 -2
| @@ -143,8 +143,13 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||
| 143 | 143 | c.scrollStop(v) | |
| 144 | 144 | // In the overview, scrolling moves the selection. | |
| 145 | 145 | if c.cur().mon.ov.open && !c.overviewKeyboardTaken() { | |
| 146 | + | // Only a scroll that can step needs the shot (not the end of | |
| 147 | + | // one, nor the rest of a finger scroll that stepped). | |
| 146 | 148 | now := c.now() | |
| 147 | - | shots := c.snapshot(now) | |
| 149 | + | var shots []viewShot | |
| 150 | + | if m := c.cur().mon; !v.Vertical.Stop && !v.Horizontal.Stop && !(v.Source == ports.AxisFinger && m.ov.scrolled) { | |
| 151 | + | shots = c.snapshot(now) | |
| 152 | + | } | |
| 148 | 153 | if c.cur().mon.overviewScroll(v) { | |
| 149 | 154 | c.transition(shots, now) | |
| 150 | 155 | if err := c.workspaceVisible(ctx, true); err != nil { | |
| @@ -196,7 +201,10 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||
| 196 | 201 | // A launcher or a menu holding the keyboard gets them first. | |
| 197 | 202 | if mon := c.cur().mon; mon.ov.open && !c.overviewKeyboardTaken() { | |
| 198 | 203 | now := c.now() | |
| 199 | - | shots := c.snapshot(now) | |
| 204 | + | var shots []viewShot | |
| 205 | + | if key.Pressed && overviewKeyNamed(key) { | |
| 206 | + | shots = c.snapshot(now) | |
| 207 | + | } | |
| 200 | 208 | if key.Pressed && mon.overviewKey(key) { | |
| 201 | 209 | c.pressed[heldKey(key)] = true | |
| 202 | 210 | c.transition(shots, now) | |
| @@ -143,8 +143,13 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||||
| 143 | c.scrollStop(v) | 143 | c.scrollStop(v) | ||
| 144 | // In the overview, scrolling moves the selection. | 144 | // In the overview, scrolling moves the selection. | ||
| 145 | if c.cur().mon.ov.open && !c.overviewKeyboardTaken() { | 145 | if c.cur().mon.ov.open && !c.overviewKeyboardTaken() { | ||
| 146 | + | // Only a scroll that can step needs the shot (not the end of | |||
| 147 | + | // one, nor the rest of a finger scroll that stepped). | |||
| 146 | now := c.now() | 148 | now := c.now() | ||
| 147 | - | shots := c.snapshot(now) | 149 | + | var shots []viewShot |
| 150 | + | if m := c.cur().mon; !v.Vertical.Stop && !v.Horizontal.Stop && !(v.Source == ports.AxisFinger && m.ov.scrolled) { | |||
| 151 | + | shots = c.snapshot(now) | |||
| 152 | + | } | |||
| 148 | if c.cur().mon.overviewScroll(v) { | 153 | if c.cur().mon.overviewScroll(v) { | ||
| 149 | c.transition(shots, now) | 154 | c.transition(shots, now) | ||
| 150 | if err := c.workspaceVisible(ctx, true); err != nil { | 155 | if err := c.workspaceVisible(ctx, true); err != nil { | ||
| @@ -196,7 +201,10 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||||
| 196 | // A launcher or a menu holding the keyboard gets them first. | 201 | // A launcher or a menu holding the keyboard gets them first. | ||
| 197 | if mon := c.cur().mon; mon.ov.open && !c.overviewKeyboardTaken() { | 202 | if mon := c.cur().mon; mon.ov.open && !c.overviewKeyboardTaken() { | ||
| 198 | now := c.now() | 203 | now := c.now() | ||
| 199 | - | shots := c.snapshot(now) | 204 | + | var shots []viewShot |
| 205 | + | if key.Pressed && overviewKeyNamed(key) { | |||
| 206 | + | shots = c.snapshot(now) | |||
| 207 | + | } | |||
| 200 | if key.Pressed && mon.overviewKey(key) { | 208 | if key.Pressed && mon.overviewKey(key) { | ||
| 201 | c.pressed[heldKey(key)] = true | 209 | c.pressed[heldKey(key)] = true | ||
| 202 | c.transition(shots, now) | 210 | c.transition(shots, now) | ||
M internal/core/overview.go +20 -3
| @@ -810,6 +810,9 @@ func overviewOutline(layout []Placement, b int) []ports.Separator { | |||
| 810 | 810 | return nil | |
| 811 | 811 | } | |
| 812 | 812 | ||
| 813 | + | // overviewClickFade is the fade from which a card takes no click. | |
| 814 | + | const overviewClickFade = 0.5 | |
| 815 | + | ||
| 813 | 816 | // overviewAt is the preview under the output-local point, if any. Front | |
| 814 | 817 | // cards take priority over peeks, including peeks appended after a front | |
| 815 | 818 | // tile in another row's layout. | |
| @@ -818,19 +821,20 @@ func (m *Monitor) overviewAt(x, y float64) WindowID { | |||
| 818 | 821 | } | |
| 819 | 822 | ||
| 820 | 823 | // overviewIn is overviewAt in a given layout: the click path passes the | |
| 821 | - | // shown one, so a card is hit where it is drawn while it moves. | |
| 824 | + | // shown one, so a card is hit where it is drawn while it moves. A card | |
| 825 | + | // that is nearly invisible (still fading in) is not clickable. | |
| 822 | 826 | func overviewIn(layout []Placement, x, y float64) WindowID { | |
| 823 | 827 | for i := len(layout) - 1; i >= 0; i-- { | |
| 824 | 828 | p := layout[i] | |
| 825 | 829 | r := p.Rect | |
| 826 | - | if p.Preview > 0 && !p.Hidden && !p.Peek && x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) { | |
| 830 | + | if p.Preview > 0 && !p.Hidden && !p.Peek && p.Fade < overviewClickFade && x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) { | |
| 827 | 831 | return p.ID | |
| 828 | 832 | } | |
| 829 | 833 | } | |
| 830 | 834 | for i := len(layout) - 1; i >= 0; i-- { | |
| 831 | 835 | p := layout[i] | |
| 832 | 836 | r := p.Rect | |
| 833 | - | if p.Preview > 0 && !p.Hidden && x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) { | |
| 837 | + | if p.Preview > 0 && !p.Hidden && p.Fade < overviewClickFade && x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) { | |
| 834 | 838 | return p.ID | |
| 835 | 839 | } | |
| 836 | 840 | } | |
| @@ -1006,6 +1010,19 @@ func (c *Core) overviewKeyboardTaken() bool { | |||
| 1006 | 1010 | return layer || c.popups[id] != nil | |
| 1007 | 1011 | } | |
| 1008 | 1012 | ||
| 1013 | + | // overviewKeyNamed reports whether key is one overviewKey handles (a bare key); it | |
| 1014 | + | // lets input snapshot only for those. | |
| 1015 | + | func overviewKeyNamed(key ports.KeyEvent) bool { | |
| 1016 | + | if key.Mods != 0 { | |
| 1017 | + | return false | |
| 1018 | + | } | |
| 1019 | + | switch keyName(key.Keysym) { | |
| 1020 | + | case "h", "Left", "l", "Right", "k", "Up", "j", "Down", "Return", "KP_Enter", "Escape": | |
| 1021 | + | return true | |
| 1022 | + | } | |
| 1023 | + | return false | |
| 1024 | + | } | |
| 1025 | + | ||
| 1009 | 1026 | // overviewKey runs an overview key: true when the key was one. | |
| 1010 | 1027 | func (m *Monitor) overviewKey(key ports.KeyEvent) bool { | |
| 1011 | 1028 | if key.Mods != 0 { | |
| @@ -810,6 +810,9 @@ func overviewOutline(layout []Placement, b int) []ports.Separator { | |||||
| 810 | return nil | 810 | return nil | ||
| 811 | } | 811 | } | ||
| 812 | 812 | ||||
| 813 | + | // overviewClickFade is the fade from which a card takes no click. | |||
| 814 | + | const overviewClickFade = 0.5 | |||
| 815 | + | ||||
| 813 | // overviewAt is the preview under the output-local point, if any. Front | 816 | // overviewAt is the preview under the output-local point, if any. Front | ||
| 814 | // cards take priority over peeks, including peeks appended after a front | 817 | // cards take priority over peeks, including peeks appended after a front | ||
| 815 | // tile in another row's layout. | 818 | // tile in another row's layout. | ||
| @@ -818,19 +821,20 @@ func (m *Monitor) overviewAt(x, y float64) WindowID { | |||||
| 818 | } | 821 | } | ||
| 819 | 822 | ||||
| 820 | // overviewIn is overviewAt in a given layout: the click path passes the | 823 | // overviewIn is overviewAt in a given layout: the click path passes the | ||
| 821 | - | // shown one, so a card is hit where it is drawn while it moves. | 824 | + | // shown one, so a card is hit where it is drawn while it moves. A card |
| 825 | + | // that is nearly invisible (still fading in) is not clickable. | |||
| 822 | func overviewIn(layout []Placement, x, y float64) WindowID { | 826 | func overviewIn(layout []Placement, x, y float64) WindowID { | ||
| 823 | for i := len(layout) - 1; i >= 0; i-- { | 827 | for i := len(layout) - 1; i >= 0; i-- { | ||
| 824 | p := layout[i] | 828 | p := layout[i] | ||
| 825 | r := p.Rect | 829 | r := p.Rect | ||
| 826 | - | if p.Preview > 0 && !p.Hidden && !p.Peek && x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) { | 830 | + | if p.Preview > 0 && !p.Hidden && !p.Peek && p.Fade < overviewClickFade && x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) { |
| 827 | return p.ID | 831 | return p.ID | ||
| 828 | } | 832 | } | ||
| 829 | } | 833 | } | ||
| 830 | for i := len(layout) - 1; i >= 0; i-- { | 834 | for i := len(layout) - 1; i >= 0; i-- { | ||
| 831 | p := layout[i] | 835 | p := layout[i] | ||
| 832 | r := p.Rect | 836 | r := p.Rect | ||
| 833 | - | if p.Preview > 0 && !p.Hidden && x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) { | 837 | + | if p.Preview > 0 && !p.Hidden && p.Fade < overviewClickFade && x >= float64(r.X) && x < float64(r.X+r.W) && y >= float64(r.Y) && y < float64(r.Y+r.H) { |
| 834 | return p.ID | 838 | return p.ID | ||
| 835 | } | 839 | } | ||
| 836 | } | 840 | } | ||
| @@ -1006,6 +1010,19 @@ func (c *Core) overviewKeyboardTaken() bool { | |||||
| 1006 | return layer || c.popups[id] != nil | 1010 | return layer || c.popups[id] != nil | ||
| 1007 | } | 1011 | } | ||
| 1008 | 1012 | ||||
| 1013 | + | // overviewKeyNamed reports whether key is one overviewKey handles (a bare key); it | |||
| 1014 | + | // lets input snapshot only for those. | |||
| 1015 | + | func overviewKeyNamed(key ports.KeyEvent) bool { | |||
| 1016 | + | if key.Mods != 0 { | |||
| 1017 | + | return false | |||
| 1018 | + | } | |||
| 1019 | + | switch keyName(key.Keysym) { | |||
| 1020 | + | case "h", "Left", "l", "Right", "k", "Up", "j", "Down", "Return", "KP_Enter", "Escape": | |||
| 1021 | + | return true | |||
| 1022 | + | } | |||
| 1023 | + | return false | |||
| 1024 | + | } | |||
| 1025 | + | ||||
| 1009 | // overviewKey runs an overview key: true when the key was one. | 1026 | // overviewKey runs an overview key: true when the key was one. | ||
| 1010 | func (m *Monitor) overviewKey(key ports.KeyEvent) bool { | 1027 | func (m *Monitor) overviewKey(key ports.KeyEvent) bool { | ||
| 1011 | if key.Mods != 0 { | 1028 | if key.Mods != 0 { | ||
M internal/core/overview_core_test.go +12 -6
| @@ -49,12 +49,18 @@ func noButtonSent(t *testing.T, ch chan ports.ClientCommand) { | |||
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | 51 | func TestOverviewCoreClickPicksOnPointerOutput(t *testing.T) { | |
| 52 | - | r := startMulti(t, nil, left, right) | |
| 53 | - | r.mapWindow(t, 1) | |
| 54 | - | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 55 | - | r.mapWindow(t, 2) | |
| 56 | - | r.mapWindow(t, 3) | |
| 57 | - | set := r.key(t, "o", ports.ModAlt) | |
| 52 | + | both(t, func(t *testing.T, animated bool) { | |
| 53 | + | overviewClickPicksOnPointerOutput(t, startLanding(t, animated, nil, left, right)) | |
| 54 | + | }) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func overviewClickPicksOnPointerOutput(t *testing.T, r *landRig) { | |
| 58 | + | r.mapLanded(t, 1) | |
| 59 | + | r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 60 | + | r.mapLanded(t, 2) | |
| 61 | + | r.mapLanded(t, 3) | |
| 62 | + | // Settled: a card still fading in takes no click. | |
| 63 | + | set := r.keyLanded(t, "o", ports.ModAlt) | |
| 58 | 64 | p, ok := scenePreview(outputScene(t, set, "DP-2"), 2) | |
| 59 | 65 | if !ok { | |
| 60 | 66 | t.Fatal("no preview of window 2 on DP-2") | |
| @@ -49,12 +49,18 @@ func noButtonSent(t *testing.T, ch chan ports.ClientCommand) { | |||||
| 49 | } | 49 | } | ||
| 50 | 50 | ||||
| 51 | func TestOverviewCoreClickPicksOnPointerOutput(t *testing.T) { | 51 | func TestOverviewCoreClickPicksOnPointerOutput(t *testing.T) { | ||
| 52 | - | r := startMulti(t, nil, left, right) | 52 | + | both(t, func(t *testing.T, animated bool) { |
| 53 | - | r.mapWindow(t, 1) | 53 | + | overviewClickPicksOnPointerOutput(t, startLanding(t, animated, nil, left, right)) |
| 54 | - | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | 54 | + | }) |
| 55 | - | r.mapWindow(t, 2) | 55 | + | } |
| 56 | - | r.mapWindow(t, 3) | 56 | + | |
| 57 | - | set := r.key(t, "o", ports.ModAlt) | 57 | + | func overviewClickPicksOnPointerOutput(t *testing.T, r *landRig) { |
| 58 | + | r.mapLanded(t, 1) | |||
| 59 | + | r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl) | |||
| 60 | + | r.mapLanded(t, 2) | |||
| 61 | + | r.mapLanded(t, 3) | |||
| 62 | + | // Settled: a card still fading in takes no click. | |||
| 63 | + | set := r.keyLanded(t, "o", ports.ModAlt) | |||
| 58 | p, ok := scenePreview(outputScene(t, set, "DP-2"), 2) | 64 | p, ok := scenePreview(outputScene(t, set, "DP-2"), 2) | ||
| 59 | if !ok { | 65 | if !ok { | ||
| 60 | t.Fatal("no preview of window 2 on DP-2") | 66 | t.Fatal("no preview of window 2 on DP-2") | ||
M internal/core/swipe_test.go +1 -1
| @@ -697,7 +697,7 @@ func TestOverviewOpenedMidSwipeDropsIt(t *testing.T) { | |||
| 697 | 697 | // own springs (the cards zooming back) start when it is handled, | |
| 698 | 698 | // which may follow a first flip: flip until the scene is settled. | |
| 699 | 699 | var s ports.Scene | |
| 700 | - | for deadline := time.Now().Add(time.Second); ; { | |
| 700 | + | for deadline := time.Now().Add(5 * time.Second); ; { | |
| 701 | 701 | r.advance(5 * time.Second) | |
| 702 | 702 | r.frames <- ports.OutputFrame{Output: wide.Name} | |
| 703 | 703 | select { | |
| @@ -697,7 +697,7 @@ func TestOverviewOpenedMidSwipeDropsIt(t *testing.T) { | |||||
| 697 | // own springs (the cards zooming back) start when it is handled, | 697 | // own springs (the cards zooming back) start when it is handled, | ||
| 698 | // which may follow a first flip: flip until the scene is settled. | 698 | // which may follow a first flip: flip until the scene is settled. | ||
| 699 | var s ports.Scene | 699 | var s ports.Scene | ||
| 700 | - | for deadline := time.Now().Add(time.Second); ; { | 700 | + | for deadline := time.Now().Add(5 * time.Second); ; { |
| 701 | r.advance(5 * time.Second) | 701 | r.advance(5 * time.Second) | ||
| 702 | r.frames <- ports.OutputFrame{Output: wide.Name} | 702 | r.frames <- ports.OutputFrame{Output: wide.Name} | ||
| 703 | select { | 703 | select { | ||
M internal/core/transition.go +24 -5
| @@ -59,6 +59,9 @@ type rectShot struct { | |||
| 59 | 59 | id WindowID | |
| 60 | 60 | rect Rect | |
| 61 | 61 | off, vel rectOffsets | |
| 62 | + | // fade and fadeV are the fade offset of a running fade motion and its | |
| 63 | + | // speed, so an overview toggle carries it over (transitionOverview). | |
| 64 | + | fade, fadeV float64 | |
| 62 | 65 | } | |
| 63 | 66 | ||
| 64 | 67 | // rectMotion takes a window's drawn rect to its settled one: each component | |
| @@ -70,7 +73,9 @@ type rectShot struct { | |||
| 70 | 73 | // placement gets a Zoom of the settled zoom (its Preview, or 1) times | |
| 71 | 74 | // shown.W / settled.W (refreshShown), which the scene carries as Preview. | |
| 72 | 75 | // The walk shrinks content toward the content rect's top-left, so this | |
| 73 | - | // suits aspect-preserving changes only. An overview card's settled zoom is | |
| 76 | + | // suits aspect-preserving changes: the zoom follows the smaller of the width | |
| 77 | + | // and height ratios, so content never exceeds its frame when the aspect | |
| 78 | + | // differs a little (rounding) or a lot (the frame is then letterboxed). An overview card's settled zoom is | |
| 74 | 79 | // its Preview: a window that opens into a card goes from 1 to Preview with | |
| 75 | 80 | // its width, and back. | |
| 76 | 81 | // | |
| @@ -155,7 +160,7 @@ func (m *rectMotion) show(p *Placement) { | |||
| 155 | 160 | p.Rect = m.apply(settled) | |
| 156 | 161 | p.Fade = max(0, min(1, m.settledFade()+m.df)) | |
| 157 | 162 | p.Dim = max(0, min(1, p.Dim+m.ddim)) | |
| 158 | - | if m.scale && settled.W > 0 { | |
| 163 | + | if m.scale && settled.W > 0 && settled.H > 0 { | |
| 159 | 164 | zoom := p.Preview | |
| 160 | 165 | if zoom == 0 { | |
| 161 | 166 | zoom = 1 | |
| @@ -164,7 +169,7 @@ func (m *rectMotion) show(p *Placement) { | |||
| 164 | 169 | zoom = appearScale | |
| 165 | 170 | } | |
| 166 | 171 | } | |
| 167 | - | zoom *= float64(p.Rect.W) / float64(settled.W) | |
| 172 | + | zoom *= min(float64(p.Rect.W)/float64(settled.W), float64(p.Rect.H)/float64(settled.H)) | |
| 168 | 173 | if zoom >= 0.999 { | |
| 169 | 174 | // Drawn at its size: content is never magnified. publish | |
| 170 | 175 | // emits no Zoom for a window, and Zoom 1 for a card (whose | |
| @@ -319,6 +324,13 @@ func (c *Core) mapWindow(v ports.WindowMapped) { | |||
| 319 | 324 | return | |
| 320 | 325 | } | |
| 321 | 326 | shots := c.snapshot(now) | |
| 327 | + | for i := range shots { | |
| 328 | + | // A screen with its overview open re-lays its cards at once: a map | |
| 329 | + | // is not an overview action (transition skips a shot without ws). | |
| 330 | + | if shots[i].overview { | |
| 331 | + | shots[i].ws = nil | |
| 332 | + | } | |
| 333 | + | } | |
| 322 | 334 | c.placement.place(c, v) | |
| 323 | 335 | c.transition(shots, now) | |
| 324 | 336 | c.appearMapped(v.ID, now) | |
| @@ -479,6 +491,8 @@ func (c *Core) snapshot(now time.Time) []viewShot { | |||
| 479 | 491 | r := rectShot{id: p.ID, rect: p.Rect} | |
| 480 | 492 | if rm, ok := sc.rects[p.ID]; ok { | |
| 481 | 493 | r.off, r.vel = rm.sample(now) | |
| 494 | + | r.fade = rm.df | |
| 495 | + | sampleComponent(rm.fade, &r.fade, &r.fadeV, now) | |
| 482 | 496 | } | |
| 483 | 497 | s.rects = append(s.rects, r) | |
| 484 | 498 | } | |
| @@ -639,10 +653,12 @@ func (c *Core) transitionRects(b *viewShot, before []viewShot, now time.Time) { | |||
| 639 | 653 | // before (another row, a column the overview hid) fades in with no motion | |
| 640 | 654 | // of its own; one drawn before and not after just goes. Motions of the | |
| 641 | 655 | // other state are dropped first: their rects are not the ones snapshot | |
| 642 | - | // measured from. | |
| 656 | + | // measured from. A fade that ran on a window drawn in both states carries | |
| 657 | + | // over (picking a card that still fades in does not pop it). | |
| 643 | 658 | func (c *Core) transitionOverview(b *viewShot, now time.Time) { | |
| 644 | 659 | sc := b.sc | |
| 645 | - | if b.overview != sc.mon.ov.open { | |
| 660 | + | changed := b.overview != sc.mon.ov.open | |
| 661 | + | if changed { | |
| 646 | 662 | sc.stopRects() | |
| 647 | 663 | } | |
| 648 | 664 | for _, p := range sc.mon.Layout() { | |
| @@ -651,6 +667,9 @@ func (c *Core) transitionOverview(b *viewShot, now time.Time) { | |||
| 651 | 667 | } | |
| 652 | 668 | rm := sc.rects[p.ID] | |
| 653 | 669 | i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID }) | |
| 670 | + | if i >= 0 && changed { | |
| 671 | + | c.retargetComponent(&rm.fade, &rm.df, b.rects[i].fade, b.rects[i].fadeV, now) | |
| 672 | + | } | |
| 654 | 673 | if i < 0 { | |
| 655 | 674 | c.retargetComponent(&rm.fade, &rm.df, 1, 0, now) | |
| 656 | 675 | } else if old := &b.rects[i]; old.rect != p.Rect { | |
| @@ -59,6 +59,9 @@ type rectShot struct { | |||||
| 59 | id WindowID | 59 | id WindowID | ||
| 60 | rect Rect | 60 | rect Rect | ||
| 61 | off, vel rectOffsets | 61 | off, vel rectOffsets | ||
| 62 | + | // fade and fadeV are the fade offset of a running fade motion and its | |||
| 63 | + | // speed, so an overview toggle carries it over (transitionOverview). | |||
| 64 | + | fade, fadeV float64 | |||
| 62 | } | 65 | } | ||
| 63 | 66 | ||||
| 64 | // rectMotion takes a window's drawn rect to its settled one: each component | 67 | // rectMotion takes a window's drawn rect to its settled one: each component | ||
| @@ -70,7 +73,9 @@ type rectShot struct { | |||||
| 70 | // placement gets a Zoom of the settled zoom (its Preview, or 1) times | 73 | // placement gets a Zoom of the settled zoom (its Preview, or 1) times | ||
| 71 | // shown.W / settled.W (refreshShown), which the scene carries as Preview. | 74 | // shown.W / settled.W (refreshShown), which the scene carries as Preview. | ||
| 72 | // The walk shrinks content toward the content rect's top-left, so this | 75 | // The walk shrinks content toward the content rect's top-left, so this | ||
| 73 | - | // suits aspect-preserving changes only. An overview card's settled zoom is | 76 | + | // suits aspect-preserving changes: the zoom follows the smaller of the width |
| 77 | + | // and height ratios, so content never exceeds its frame when the aspect | |||
| 78 | + | // differs a little (rounding) or a lot (the frame is then letterboxed). An overview card's settled zoom is | |||
| 74 | // its Preview: a window that opens into a card goes from 1 to Preview with | 79 | // its Preview: a window that opens into a card goes from 1 to Preview with | ||
| 75 | // its width, and back. | 80 | // its width, and back. | ||
| 76 | // | 81 | // | ||
| @@ -155,7 +160,7 @@ func (m *rectMotion) show(p *Placement) { | |||||
| 155 | p.Rect = m.apply(settled) | 160 | p.Rect = m.apply(settled) | ||
| 156 | p.Fade = max(0, min(1, m.settledFade()+m.df)) | 161 | p.Fade = max(0, min(1, m.settledFade()+m.df)) | ||
| 157 | p.Dim = max(0, min(1, p.Dim+m.ddim)) | 162 | p.Dim = max(0, min(1, p.Dim+m.ddim)) | ||
| 158 | - | if m.scale && settled.W > 0 { | 163 | + | if m.scale && settled.W > 0 && settled.H > 0 { |
| 159 | zoom := p.Preview | 164 | zoom := p.Preview | ||
| 160 | if zoom == 0 { | 165 | if zoom == 0 { | ||
| 161 | zoom = 1 | 166 | zoom = 1 | ||
| @@ -164,7 +169,7 @@ func (m *rectMotion) show(p *Placement) { | |||||
| 164 | zoom = appearScale | 169 | zoom = appearScale | ||
| 165 | } | 170 | } | ||
| 166 | } | 171 | } | ||
| 167 | - | zoom *= float64(p.Rect.W) / float64(settled.W) | 172 | + | zoom *= min(float64(p.Rect.W)/float64(settled.W), float64(p.Rect.H)/float64(settled.H)) |
| 168 | if zoom >= 0.999 { | 173 | if zoom >= 0.999 { | ||
| 169 | // Drawn at its size: content is never magnified. publish | 174 | // Drawn at its size: content is never magnified. publish | ||
| 170 | // emits no Zoom for a window, and Zoom 1 for a card (whose | 175 | // emits no Zoom for a window, and Zoom 1 for a card (whose | ||
| @@ -319,6 +324,13 @@ func (c *Core) mapWindow(v ports.WindowMapped) { | |||||
| 319 | return | 324 | return | ||
| 320 | } | 325 | } | ||
| 321 | shots := c.snapshot(now) | 326 | shots := c.snapshot(now) | ||
| 327 | + | for i := range shots { | |||
| 328 | + | // A screen with its overview open re-lays its cards at once: a map | |||
| 329 | + | // is not an overview action (transition skips a shot without ws). | |||
| 330 | + | if shots[i].overview { | |||
| 331 | + | shots[i].ws = nil | |||
| 332 | + | } | |||
| 333 | + | } | |||
| 322 | c.placement.place(c, v) | 334 | c.placement.place(c, v) | ||
| 323 | c.transition(shots, now) | 335 | c.transition(shots, now) | ||
| 324 | c.appearMapped(v.ID, now) | 336 | c.appearMapped(v.ID, now) | ||
| @@ -479,6 +491,8 @@ func (c *Core) snapshot(now time.Time) []viewShot { | |||||
| 479 | r := rectShot{id: p.ID, rect: p.Rect} | 491 | r := rectShot{id: p.ID, rect: p.Rect} | ||
| 480 | if rm, ok := sc.rects[p.ID]; ok { | 492 | if rm, ok := sc.rects[p.ID]; ok { | ||
| 481 | r.off, r.vel = rm.sample(now) | 493 | r.off, r.vel = rm.sample(now) | ||
| 494 | + | r.fade = rm.df | |||
| 495 | + | sampleComponent(rm.fade, &r.fade, &r.fadeV, now) | |||
| 482 | } | 496 | } | ||
| 483 | s.rects = append(s.rects, r) | 497 | s.rects = append(s.rects, r) | ||
| 484 | } | 498 | } | ||
| @@ -639,10 +653,12 @@ func (c *Core) transitionRects(b *viewShot, before []viewShot, now time.Time) { | |||||
| 639 | // before (another row, a column the overview hid) fades in with no motion | 653 | // before (another row, a column the overview hid) fades in with no motion | ||
| 640 | // of its own; one drawn before and not after just goes. Motions of the | 654 | // of its own; one drawn before and not after just goes. Motions of the | ||
| 641 | // other state are dropped first: their rects are not the ones snapshot | 655 | // other state are dropped first: their rects are not the ones snapshot | ||
| 642 | - | // measured from. | 656 | + | // measured from. A fade that ran on a window drawn in both states carries |
| 657 | + | // over (picking a card that still fades in does not pop it). | |||
| 643 | func (c *Core) transitionOverview(b *viewShot, now time.Time) { | 658 | func (c *Core) transitionOverview(b *viewShot, now time.Time) { | ||
| 644 | sc := b.sc | 659 | sc := b.sc | ||
| 645 | - | if b.overview != sc.mon.ov.open { | 660 | + | changed := b.overview != sc.mon.ov.open |
| 661 | + | if changed { | |||
| 646 | sc.stopRects() | 662 | sc.stopRects() | ||
| 647 | } | 663 | } | ||
| 648 | for _, p := range sc.mon.Layout() { | 664 | for _, p := range sc.mon.Layout() { | ||
| @@ -651,6 +667,9 @@ func (c *Core) transitionOverview(b *viewShot, now time.Time) { | |||||
| 651 | } | 667 | } | ||
| 652 | rm := sc.rects[p.ID] | 668 | rm := sc.rects[p.ID] | ||
| 653 | i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID }) | 669 | i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID }) | ||
| 670 | + | if i >= 0 && changed { | |||
| 671 | + | c.retargetComponent(&rm.fade, &rm.df, b.rects[i].fade, b.rects[i].fadeV, now) | |||
| 672 | + | } | |||
| 654 | if i < 0 { | 673 | if i < 0 { | ||
| 655 | c.retargetComponent(&rm.fade, &rm.df, 1, 0, now) | 674 | c.retargetComponent(&rm.fade, &rm.df, 1, 0, now) | ||
| 656 | } else if old := &b.rects[i]; old.rect != p.Rect { | 675 | } else if old := &b.rects[i]; old.rect != p.Rect { | ||
M internal/core/transition_fade_internal_test.go +1 -0
| @@ -292,6 +292,7 @@ func TestScaleMotionMultipliesSettledPreview(t *testing.T) { | |||
| 292 | 292 | var rm rectMotion | |
| 293 | 293 | rm.scale, rm.dw = true, 50 | |
| 294 | 294 | p := Placement{ID: 1, Rect: Rect{W: 100, H: 100}, Preview: 0.2} | |
| 295 | + | rm.dh = 50 // aspect kept: the zoom follows the smaller ratio | |
| 295 | 296 | rm.show(&p) | |
| 296 | 297 | if p.Rect.W != 150 || math.Abs(p.Zoom-0.3) > 1e-9 || p.Preview != 0.2 { | |
| 297 | 298 | t.Fatalf("shown %+v, want width 150, zoom 0.3 and the settled preview", p) | |
| @@ -292,6 +292,7 @@ func TestScaleMotionMultipliesSettledPreview(t *testing.T) { | |||||
| 292 | var rm rectMotion | 292 | var rm rectMotion | ||
| 293 | rm.scale, rm.dw = true, 50 | 293 | rm.scale, rm.dw = true, 50 | ||
| 294 | p := Placement{ID: 1, Rect: Rect{W: 100, H: 100}, Preview: 0.2} | 294 | p := Placement{ID: 1, Rect: Rect{W: 100, H: 100}, Preview: 0.2} | ||
| 295 | + | rm.dh = 50 // aspect kept: the zoom follows the smaller ratio | |||
| 295 | rm.show(&p) | 296 | rm.show(&p) | ||
| 296 | if p.Rect.W != 150 || math.Abs(p.Zoom-0.3) > 1e-9 || p.Preview != 0.2 { | 297 | if p.Rect.W != 150 || math.Abs(p.Zoom-0.3) > 1e-9 || p.Preview != 0.2 { | ||
| 297 | t.Fatalf("shown %+v, want width 150, zoom 0.3 and the settled preview", p) | 298 | t.Fatalf("shown %+v, want width 150, zoom 0.3 and the settled preview", p) | ||
M internal/core/transition_overview_internal_test.go +123 -1
| @@ -292,7 +292,7 @@ func TestOverviewClickDuringOpenUsesShownCards(t *testing.T) { | |||
| 292 | 292 | // full size still carries Zoom 1 (Preview alone would shrink it). | |
| 293 | 293 | func TestCardZoomClampedAndEmitted(t *testing.T) { | |
| 294 | 294 | var rm rectMotion | |
| 295 | - | rm.scale, rm.dw = true, 400 | |
| 295 | + | rm.scale, rm.dw, rm.dh = true, 400, 240 | |
| 296 | 296 | p := Placement{ID: 1, Rect: Rect{W: 100, H: 60}, Preview: overviewCardZoom} | |
| 297 | 297 | rm.show(&p) | |
| 298 | 298 | if p.Zoom != 1 || p.Preview != overviewCardZoom { | |
| @@ -321,3 +321,125 @@ func TestOverviewRectsDroppedOnStateChange(t *testing.T) { | |||
| 321 | 321 | t.Fatalf("%d card motions survived the close without a transition", len(sc.rects)) | |
| 322 | 322 | } | |
| 323 | 323 | } | |
| 324 | + | ||
| 325 | + | // The overview has no camera: toggling it, moving in it and closing it never | |
| 326 | + | // start a column or workspace slide, right after the action (settled or not). | |
| 327 | + | func TestOverviewStartsNoCameraSlide(t *testing.T) { | |
| 328 | + | c, ic, sc := overviewRig(t) | |
| 329 | + | m := sc.mon | |
| 330 | + | for _, id := range []WindowID{4, 5} { | |
| 331 | + | m.AddWindow(id) // more columns than fit: the view scrolls | |
| 332 | + | } | |
| 333 | + | settleShown(t, c) | |
| 334 | + | c.animate(ic.now.Add(5*time.Second), nil) | |
| 335 | + | ic.now = ic.now.Add(5 * time.Second) | |
| 336 | + | noCamera := func(when string) { | |
| 337 | + | t.Helper() | |
| 338 | + | if m.switchMotion.on || m.Current().motion.on { | |
| 339 | + | t.Fatalf("%s: a camera spring runs (switch %v, view %v)", when, m.switchMotion.on, m.Current().motion.on) | |
| 340 | + | } | |
| 341 | + | } | |
| 342 | + | noCamera("before") | |
| 343 | + | toggleOverview(c, sc, ic.now) | |
| 344 | + | noCamera("after open") | |
| 345 | + | frame(t, c, ic, 30*time.Millisecond) | |
| 346 | + | noCamera("mid open") | |
| 347 | + | for _, mv := range [][2]int{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} { | |
| 348 | + | shots := c.snapshot(ic.now) | |
| 349 | + | m.OverviewMove(mv[0], mv[1]) | |
| 350 | + | c.transition(shots, ic.now) | |
| 351 | + | noCamera("after move") | |
| 352 | + | frame(t, c, ic, 30*time.Millisecond) | |
| 353 | + | } | |
| 354 | + | toggleOverview(c, sc, ic.now) | |
| 355 | + | noCamera("after close") | |
| 356 | + | frame(t, c, ic, 5*time.Second) | |
| 357 | + | noCamera("settled") | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | // A card that still fades in (Fade >= 0.5) takes no click; a visible one | |
| 361 | + | // does, and a faded card lets the one under it be picked. | |
| 362 | + | func TestOverviewInSkipsFadedCards(t *testing.T) { | |
| 363 | + | r := Rect{X: 0, Y: 0, W: 100, H: 100} | |
| 364 | + | layout := []Placement{ | |
| 365 | + | {ID: 1, Rect: r, Preview: 0.2}, | |
| 366 | + | {ID: 2, Rect: r, Preview: 0.2, Fade: 0.6}, | |
| 367 | + | } | |
| 368 | + | if id := overviewIn(layout, 50, 50); id != 1 { | |
| 369 | + | t.Fatalf("picked %d, want 1 (2 is nearly invisible)", id) | |
| 370 | + | } | |
| 371 | + | layout[1].Fade = 0.4 | |
| 372 | + | if id := overviewIn(layout, 50, 50); id != 2 { | |
| 373 | + | t.Fatalf("picked %d, want 2 (visible enough)", id) | |
| 374 | + | } | |
| 375 | + | layout = layout[1:] | |
| 376 | + | layout[0].Fade = 0.9 | |
| 377 | + | if id := overviewIn(layout, 50, 50); id != 0 { | |
| 378 | + | t.Fatalf("picked %d, want none", id) | |
| 379 | + | } | |
| 380 | + | // And through the shown layout of a running opening. | |
| 381 | + | c, ic, sc := overviewRig(t) | |
| 382 | + | toggleOverview(c, sc, ic.now) | |
| 383 | + | s := indicatorScene(t, c) | |
| 384 | + | w3 := sceneWindow(t, s, 3) | |
| 385 | + | if w3.Fade != 1 { | |
| 386 | + | t.Fatalf("setup: window 3 fade %v", w3.Fade) | |
| 387 | + | } | |
| 388 | + | if id := overviewIn(sc.shownLayout(), float64(w3.Rect.X+w3.Rect.W/2), float64(w3.Rect.Y+w3.Rect.H/2)); id == 3 { | |
| 389 | + | t.Fatal("the invisible card of the next row was picked") | |
| 390 | + | } | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | // A fade that runs on a card carries over a state change: picking it | |
| 394 | + | // mid-fade (here by closing the overview on it) starts from the fade it | |
| 395 | + | // had, it does not jump to opaque. | |
| 396 | + | func TestOverviewCarriesFadeAcrossState(t *testing.T) { | |
| 397 | + | c, ic, sc := overviewRig(t) | |
| 398 | + | toggleOverview(c, sc, ic.now) | |
| 399 | + | s := frame(t, c, ic, 40*time.Millisecond) | |
| 400 | + | mid := sceneWindow(t, s, 3).Fade | |
| 401 | + | if !(mid > 0.05 && mid < 0.95) { | |
| 402 | + | t.Fatalf("setup: window 3 fade %v mid-way", mid) | |
| 403 | + | } | |
| 404 | + | shots := c.snapshot(ic.now) | |
| 405 | + | sc.mon.OverviewPick(3) | |
| 406 | + | c.transition(shots, ic.now) | |
| 407 | + | if sc.mon.ov.open { | |
| 408 | + | t.Fatal("the overview stayed open") | |
| 409 | + | } | |
| 410 | + | got := sceneWindow(t, indicatorScene(t, c), 3) | |
| 411 | + | if math.Abs(got.Fade-mid) > 0.02 { | |
| 412 | + | t.Fatalf("fade %v after the pick, want the sampled %v", got.Fade, mid) | |
| 413 | + | } | |
| 414 | + | s = frame(t, c, ic, 5*time.Second) | |
| 415 | + | if w := sceneWindow(t, s, 3); w.Fade != 0 { | |
| 416 | + | t.Fatalf("settled fade %v", w.Fade) | |
| 417 | + | } | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | // The content zoom follows the smaller of the width and height ratios: when | |
| 421 | + | // the aspect does not hold, content never exceeds the frame. | |
| 422 | + | func TestScaleZoomFollowsSmallerRatio(t *testing.T) { | |
| 423 | + | for _, tc := range []struct { | |
| 424 | + | name string | |
| 425 | + | dw, dh float64 | |
| 426 | + | want float64 | |
| 427 | + | }{ | |
| 428 | + | {"narrower", -50, 0, 0.5}, | |
| 429 | + | {"shorter", 0, -50, 0.5}, | |
| 430 | + | {"both", -20, -40, 0.6}, | |
| 431 | + | {"aspect kept", -30, -30, 0.7}, | |
| 432 | + | } { | |
| 433 | + | t.Run(tc.name, func(t *testing.T) { | |
| 434 | + | rm := rectMotion{scale: true, dw: tc.dw, dh: tc.dh} | |
| 435 | + | p := Placement{ID: 1, Rect: Rect{W: 100, H: 100}} | |
| 436 | + | rm.show(&p) | |
| 437 | + | if math.Abs(p.Zoom-tc.want) > 1e-9 { | |
| 438 | + | t.Fatalf("zoom %v, want %v", p.Zoom, tc.want) | |
| 439 | + | } | |
| 440 | + | if float64(p.Rect.W)*1 < p.Zoom*100-1e-9 || float64(p.Rect.H) < p.Zoom*100-1e-9 { | |
| 441 | + | t.Fatalf("content %vx%v exceeds the frame %+v", p.Zoom*100, p.Zoom*100, p.Rect) | |
| 442 | + | } | |
| 443 | + | }) | |
| 444 | + | } | |
| 445 | + | } | |
| @@ -292,7 +292,7 @@ func TestOverviewClickDuringOpenUsesShownCards(t *testing.T) { | |||||
| 292 | // full size still carries Zoom 1 (Preview alone would shrink it). | 292 | // full size still carries Zoom 1 (Preview alone would shrink it). | ||
| 293 | func TestCardZoomClampedAndEmitted(t *testing.T) { | 293 | func TestCardZoomClampedAndEmitted(t *testing.T) { | ||
| 294 | var rm rectMotion | 294 | var rm rectMotion | ||
| 295 | - | rm.scale, rm.dw = true, 400 | 295 | + | rm.scale, rm.dw, rm.dh = true, 400, 240 |
| 296 | p := Placement{ID: 1, Rect: Rect{W: 100, H: 60}, Preview: overviewCardZoom} | 296 | p := Placement{ID: 1, Rect: Rect{W: 100, H: 60}, Preview: overviewCardZoom} | ||
| 297 | rm.show(&p) | 297 | rm.show(&p) | ||
| 298 | if p.Zoom != 1 || p.Preview != overviewCardZoom { | 298 | if p.Zoom != 1 || p.Preview != overviewCardZoom { | ||
| @@ -321,3 +321,125 @@ func TestOverviewRectsDroppedOnStateChange(t *testing.T) { | |||||
| 321 | t.Fatalf("%d card motions survived the close without a transition", len(sc.rects)) | 321 | t.Fatalf("%d card motions survived the close without a transition", len(sc.rects)) | ||
| 322 | } | 322 | } | ||
| 323 | } | 323 | } | ||
| 324 | + | ||||
| 325 | + | // The overview has no camera: toggling it, moving in it and closing it never | |||
| 326 | + | // start a column or workspace slide, right after the action (settled or not). | |||
| 327 | + | func TestOverviewStartsNoCameraSlide(t *testing.T) { | |||
| 328 | + | c, ic, sc := overviewRig(t) | |||
| 329 | + | m := sc.mon | |||
| 330 | + | for _, id := range []WindowID{4, 5} { | |||
| 331 | + | m.AddWindow(id) // more columns than fit: the view scrolls | |||
| 332 | + | } | |||
| 333 | + | settleShown(t, c) | |||
| 334 | + | c.animate(ic.now.Add(5*time.Second), nil) | |||
| 335 | + | ic.now = ic.now.Add(5 * time.Second) | |||
| 336 | + | noCamera := func(when string) { | |||
| 337 | + | t.Helper() | |||
| 338 | + | if m.switchMotion.on || m.Current().motion.on { | |||
| 339 | + | t.Fatalf("%s: a camera spring runs (switch %v, view %v)", when, m.switchMotion.on, m.Current().motion.on) | |||
| 340 | + | } | |||
| 341 | + | } | |||
| 342 | + | noCamera("before") | |||
| 343 | + | toggleOverview(c, sc, ic.now) | |||
| 344 | + | noCamera("after open") | |||
| 345 | + | frame(t, c, ic, 30*time.Millisecond) | |||
| 346 | + | noCamera("mid open") | |||
| 347 | + | for _, mv := range [][2]int{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} { | |||
| 348 | + | shots := c.snapshot(ic.now) | |||
| 349 | + | m.OverviewMove(mv[0], mv[1]) | |||
| 350 | + | c.transition(shots, ic.now) | |||
| 351 | + | noCamera("after move") | |||
| 352 | + | frame(t, c, ic, 30*time.Millisecond) | |||
| 353 | + | } | |||
| 354 | + | toggleOverview(c, sc, ic.now) | |||
| 355 | + | noCamera("after close") | |||
| 356 | + | frame(t, c, ic, 5*time.Second) | |||
| 357 | + | noCamera("settled") | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | // A card that still fades in (Fade >= 0.5) takes no click; a visible one | |||
| 361 | + | // does, and a faded card lets the one under it be picked. | |||
| 362 | + | func TestOverviewInSkipsFadedCards(t *testing.T) { | |||
| 363 | + | r := Rect{X: 0, Y: 0, W: 100, H: 100} | |||
| 364 | + | layout := []Placement{ | |||
| 365 | + | {ID: 1, Rect: r, Preview: 0.2}, | |||
| 366 | + | {ID: 2, Rect: r, Preview: 0.2, Fade: 0.6}, | |||
| 367 | + | } | |||
| 368 | + | if id := overviewIn(layout, 50, 50); id != 1 { | |||
| 369 | + | t.Fatalf("picked %d, want 1 (2 is nearly invisible)", id) | |||
| 370 | + | } | |||
| 371 | + | layout[1].Fade = 0.4 | |||
| 372 | + | if id := overviewIn(layout, 50, 50); id != 2 { | |||
| 373 | + | t.Fatalf("picked %d, want 2 (visible enough)", id) | |||
| 374 | + | } | |||
| 375 | + | layout = layout[1:] | |||
| 376 | + | layout[0].Fade = 0.9 | |||
| 377 | + | if id := overviewIn(layout, 50, 50); id != 0 { | |||
| 378 | + | t.Fatalf("picked %d, want none", id) | |||
| 379 | + | } | |||
| 380 | + | // And through the shown layout of a running opening. | |||
| 381 | + | c, ic, sc := overviewRig(t) | |||
| 382 | + | toggleOverview(c, sc, ic.now) | |||
| 383 | + | s := indicatorScene(t, c) | |||
| 384 | + | w3 := sceneWindow(t, s, 3) | |||
| 385 | + | if w3.Fade != 1 { | |||
| 386 | + | t.Fatalf("setup: window 3 fade %v", w3.Fade) | |||
| 387 | + | } | |||
| 388 | + | if id := overviewIn(sc.shownLayout(), float64(w3.Rect.X+w3.Rect.W/2), float64(w3.Rect.Y+w3.Rect.H/2)); id == 3 { | |||
| 389 | + | t.Fatal("the invisible card of the next row was picked") | |||
| 390 | + | } | |||
| 391 | + | } | |||
| 392 | + | ||||
| 393 | + | // A fade that runs on a card carries over a state change: picking it | |||
| 394 | + | // mid-fade (here by closing the overview on it) starts from the fade it | |||
| 395 | + | // had, it does not jump to opaque. | |||
| 396 | + | func TestOverviewCarriesFadeAcrossState(t *testing.T) { | |||
| 397 | + | c, ic, sc := overviewRig(t) | |||
| 398 | + | toggleOverview(c, sc, ic.now) | |||
| 399 | + | s := frame(t, c, ic, 40*time.Millisecond) | |||
| 400 | + | mid := sceneWindow(t, s, 3).Fade | |||
| 401 | + | if !(mid > 0.05 && mid < 0.95) { | |||
| 402 | + | t.Fatalf("setup: window 3 fade %v mid-way", mid) | |||
| 403 | + | } | |||
| 404 | + | shots := c.snapshot(ic.now) | |||
| 405 | + | sc.mon.OverviewPick(3) | |||
| 406 | + | c.transition(shots, ic.now) | |||
| 407 | + | if sc.mon.ov.open { | |||
| 408 | + | t.Fatal("the overview stayed open") | |||
| 409 | + | } | |||
| 410 | + | got := sceneWindow(t, indicatorScene(t, c), 3) | |||
| 411 | + | if math.Abs(got.Fade-mid) > 0.02 { | |||
| 412 | + | t.Fatalf("fade %v after the pick, want the sampled %v", got.Fade, mid) | |||
| 413 | + | } | |||
| 414 | + | s = frame(t, c, ic, 5*time.Second) | |||
| 415 | + | if w := sceneWindow(t, s, 3); w.Fade != 0 { | |||
| 416 | + | t.Fatalf("settled fade %v", w.Fade) | |||
| 417 | + | } | |||
| 418 | + | } | |||
| 419 | + | ||||
| 420 | + | // The content zoom follows the smaller of the width and height ratios: when | |||
| 421 | + | // the aspect does not hold, content never exceeds the frame. | |||
| 422 | + | func TestScaleZoomFollowsSmallerRatio(t *testing.T) { | |||
| 423 | + | for _, tc := range []struct { | |||
| 424 | + | name string | |||
| 425 | + | dw, dh float64 | |||
| 426 | + | want float64 | |||
| 427 | + | }{ | |||
| 428 | + | {"narrower", -50, 0, 0.5}, | |||
| 429 | + | {"shorter", 0, -50, 0.5}, | |||
| 430 | + | {"both", -20, -40, 0.6}, | |||
| 431 | + | {"aspect kept", -30, -30, 0.7}, | |||
| 432 | + | } { | |||
| 433 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 434 | + | rm := rectMotion{scale: true, dw: tc.dw, dh: tc.dh} | |||
| 435 | + | p := Placement{ID: 1, Rect: Rect{W: 100, H: 100}} | |||
| 436 | + | rm.show(&p) | |||
| 437 | + | if math.Abs(p.Zoom-tc.want) > 1e-9 { | |||
| 438 | + | t.Fatalf("zoom %v, want %v", p.Zoom, tc.want) | |||
| 439 | + | } | |||
| 440 | + | if float64(p.Rect.W)*1 < p.Zoom*100-1e-9 || float64(p.Rect.H) < p.Zoom*100-1e-9 { | |||
| 441 | + | t.Fatalf("content %vx%v exceeds the frame %+v", p.Zoom*100, p.Zoom*100, p.Rect) | |||
| 442 | + | } | |||
| 443 | + | }) | |||
| 444 | + | } | |||
| 445 | + | } | |||