feat(core): animate the overview open, close and navigation
Rect motions now run in the overview: toggling it, moving its selection or clicking a card slides every card drawn before and after from its shown rect, content scaled through the derived Zoom (clamped to 1, emitted as 1 for a card at its size). Cards the other rows bring in fade in. The click path hit-tests the shown layout while the cards move.
- commit
- 18098d8a8a804ba5353f497d72fe20ce09d66dbe
- author
- brice · · 2026-10-04 23:04 UTC
- committer
- brice · · 2026-10-04 23:26 UTC
- parent
- f0176e6
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +493 -60
M internal/core/core.go +4 -2
| @@ -609,8 +609,10 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error { | |||
| 609 | 609 | // Only the focused output has an activated window. | |
| 610 | 610 | focused := p.Focused && i == c.focusScreen | |
| 611 | 611 | sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade, Dim: max(0, p.Dim)} | |
| 612 | - | if p.Zoom > 0 && p.Zoom < 1 { | |
| 613 | - | // A scale motion: the content follows the drawn size. | |
| 612 | + | if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) { | |
| 613 | + | // A scale motion: the content follows the drawn size. A | |
| 614 | + | // card in flight drawn at its size still needs Zoom 1: | |
| 615 | + | // without it the renderer would shrink it by Preview. | |
| 614 | 616 | sw.Zoom = p.Zoom | |
| 615 | 617 | } | |
| 616 | 618 | if p.Peek { | |
| @@ -609,8 +609,10 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error { | |||||
| 609 | // Only the focused output has an activated window. | 609 | // Only the focused output has an activated window. | ||
| 610 | focused := p.Focused && i == c.focusScreen | 610 | focused := p.Focused && i == c.focusScreen | ||
| 611 | sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade, Dim: max(0, p.Dim)} | 611 | sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade, Dim: max(0, p.Dim)} | ||
| 612 | - | if p.Zoom > 0 && p.Zoom < 1 { | 612 | + | if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) { |
| 613 | - | // A scale motion: the content follows the drawn size. | 613 | + | // A scale motion: the content follows the drawn size. A |
| 614 | + | // card in flight drawn at its size still needs Zoom 1: | |||
| 615 | + | // without it the renderer would shrink it by Preview. | |||
| 614 | sw.Zoom = p.Zoom | 616 | sw.Zoom = p.Zoom | ||
| 615 | } | 617 | } | ||
| 616 | if p.Peek { | 618 | if p.Peek { | ||
M internal/core/gesture.go +3 -1
| @@ -255,9 +255,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 255 | 255 | return false | |
| 256 | 256 | } | |
| 257 | 257 | before := m.Current() | |
| 258 | + | shots := c.snapshot(now) | |
| 258 | 259 | m.ToggleOverview() | |
| 259 | 260 | c.focusScreen = c.screenIndex(g.screen.name()) | |
| 260 | 261 | c.keyboard.takeBack() | |
| 262 | + | c.transition(shots, now) | |
| 261 | 263 | return m.Current() != before | |
| 262 | 264 | case swipeDiscrete: | |
| 263 | 265 | step := g.snap.step(e.Cancelled, e.Time) | |
| @@ -285,8 +287,8 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 285 | 287 | mon.overviewFocus(a) | |
| 286 | 288 | } else { | |
| 287 | 289 | c.applyAction(a) | |
| 288 | - | c.transition(shots, now) | |
| 289 | 290 | } | |
| 291 | + | c.transition(shots, now) | |
| 290 | 292 | return c.cur().mon.Current() != before | |
| 291 | 293 | } | |
| 292 | 294 | return shown | |
| @@ -255,9 +255,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||||
| 255 | return false | 255 | return false | ||
| 256 | } | 256 | } | ||
| 257 | before := m.Current() | 257 | before := m.Current() | ||
| 258 | + | shots := c.snapshot(now) | |||
| 258 | m.ToggleOverview() | 259 | m.ToggleOverview() | ||
| 259 | c.focusScreen = c.screenIndex(g.screen.name()) | 260 | c.focusScreen = c.screenIndex(g.screen.name()) | ||
| 260 | c.keyboard.takeBack() | 261 | c.keyboard.takeBack() | ||
| 262 | + | c.transition(shots, now) | |||
| 261 | return m.Current() != before | 263 | return m.Current() != before | ||
| 262 | case swipeDiscrete: | 264 | case swipeDiscrete: | ||
| 263 | step := g.snap.step(e.Cancelled, e.Time) | 265 | step := g.snap.step(e.Cancelled, e.Time) | ||
| @@ -285,8 +287,8 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||||
| 285 | mon.overviewFocus(a) | 287 | mon.overviewFocus(a) | ||
| 286 | } else { | 288 | } else { | ||
| 287 | c.applyAction(a) | 289 | c.applyAction(a) | ||
| 288 | - | c.transition(shots, now) | |||
| 289 | } | 290 | } | ||
| 291 | + | c.transition(shots, now) | |||
| 290 | return c.cur().mon.Current() != before | 292 | return c.cur().mon.Current() != before | ||
| 291 | } | 293 | } | ||
| 292 | return shown | 294 | return shown | ||
M internal/core/input.go +6 -0
| @@ -143,7 +143,10 @@ 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 | + | now := c.now() | |
| 147 | + | shots := c.snapshot(now) | |
| 146 | 148 | if c.cur().mon.overviewScroll(v) { | |
| 149 | + | c.transition(shots, now) | |
| 147 | 150 | if err := c.workspaceVisible(ctx, true); err != nil { | |
| 148 | 151 | return err | |
| 149 | 152 | } | |
| @@ -192,8 +195,11 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||
| 192 | 195 | // run binds, and are not forwarded. | |
| 193 | 196 | // A launcher or a menu holding the keyboard gets them first. | |
| 194 | 197 | if mon := c.cur().mon; mon.ov.open && !c.overviewKeyboardTaken() { | |
| 198 | + | now := c.now() | |
| 199 | + | shots := c.snapshot(now) | |
| 195 | 200 | if key.Pressed && mon.overviewKey(key) { | |
| 196 | 201 | c.pressed[heldKey(key)] = true | |
| 202 | + | c.transition(shots, now) | |
| 197 | 203 | if err := c.workspaceVisible(ctx, true); err != nil { | |
| 198 | 204 | return err | |
| 199 | 205 | } | |
| @@ -143,7 +143,10 @@ 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 | + | now := c.now() | |||
| 147 | + | shots := c.snapshot(now) | |||
| 146 | if c.cur().mon.overviewScroll(v) { | 148 | if c.cur().mon.overviewScroll(v) { | ||
| 149 | + | c.transition(shots, now) | |||
| 147 | if err := c.workspaceVisible(ctx, true); err != nil { | 150 | if err := c.workspaceVisible(ctx, true); err != nil { | ||
| 148 | return err | 151 | return err | ||
| 149 | } | 152 | } | ||
| @@ -192,8 +195,11 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||||
| 192 | // run binds, and are not forwarded. | 195 | // run binds, and are not forwarded. | ||
| 193 | // A launcher or a menu holding the keyboard gets them first. | 196 | // A launcher or a menu holding the keyboard gets them first. | ||
| 194 | if mon := c.cur().mon; mon.ov.open && !c.overviewKeyboardTaken() { | 197 | if mon := c.cur().mon; mon.ov.open && !c.overviewKeyboardTaken() { | ||
| 198 | + | now := c.now() | |||
| 199 | + | shots := c.snapshot(now) | |||
| 195 | if key.Pressed && mon.overviewKey(key) { | 200 | if key.Pressed && mon.overviewKey(key) { | ||
| 196 | c.pressed[heldKey(key)] = true | 201 | c.pressed[heldKey(key)] = true | ||
| 202 | + | c.transition(shots, now) | |||
| 197 | if err := c.workspaceVisible(ctx, true); err != nil { | 203 | if err := c.workspaceVisible(ctx, true); err != nil { | ||
| 198 | return err | 204 | return err | ||
| 199 | } | 205 | } | ||
M internal/core/overview.go +12 -2
| @@ -814,7 +814,12 @@ func overviewOutline(layout []Placement, b int) []ports.Separator { | |||
| 814 | 814 | // cards take priority over peeks, including peeks appended after a front | |
| 815 | 815 | // tile in another row's layout. | |
| 816 | 816 | func (m *Monitor) overviewAt(x, y float64) WindowID { | |
| 817 | - | layout := m.Layout() | |
| 817 | + | return overviewIn(m.Layout(), x, y) | |
| 818 | + | } | |
| 819 | + | ||
| 820 | + | // 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. | |
| 822 | + | func overviewIn(layout []Placement, x, y float64) WindowID { | |
| 818 | 823 | for i := len(layout) - 1; i >= 0; i-- { | |
| 819 | 824 | p := layout[i] | |
| 820 | 825 | r := p.Rect | |
| @@ -843,11 +848,16 @@ func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) { | |||
| 843 | 848 | if !sc.mon.ov.open { | |
| 844 | 849 | return false, nil | |
| 845 | 850 | } | |
| 846 | - | id := sc.mon.overviewAt(c.cursorX-float64(sc.x), c.cursorY-float64(sc.y)) | |
| 851 | + | // The cards are hit where they are drawn: a click during the open or a | |
| 852 | + | // navigation animation picks what moves under the pointer. | |
| 853 | + | id := overviewIn(sc.shownLayout(), c.cursorX-float64(sc.x), c.cursorY-float64(sc.y)) | |
| 847 | 854 | if id == 0 { | |
| 848 | 855 | return false, nil | |
| 849 | 856 | } | |
| 857 | + | now := c.now() | |
| 858 | + | shots := c.snapshot(now) | |
| 850 | 859 | sc.mon.OverviewPick(id) | |
| 860 | + | c.transition(shots, now) | |
| 851 | 861 | c.focusScreen = i | |
| 852 | 862 | c.keyboard.takeBack() | |
| 853 | 863 | if err := c.workspaceVisible(ctx, true); err != nil { | |
| @@ -814,7 +814,12 @@ func overviewOutline(layout []Placement, b int) []ports.Separator { | |||||
| 814 | // cards take priority over peeks, including peeks appended after a front | 814 | // cards take priority over peeks, including peeks appended after a front | ||
| 815 | // tile in another row's layout. | 815 | // tile in another row's layout. | ||
| 816 | func (m *Monitor) overviewAt(x, y float64) WindowID { | 816 | func (m *Monitor) overviewAt(x, y float64) WindowID { | ||
| 817 | - | layout := m.Layout() | 817 | + | return overviewIn(m.Layout(), x, y) |
| 818 | + | } | |||
| 819 | + | ||||
| 820 | + | // 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. | |||
| 822 | + | func overviewIn(layout []Placement, x, y float64) WindowID { | |||
| 818 | for i := len(layout) - 1; i >= 0; i-- { | 823 | for i := len(layout) - 1; i >= 0; i-- { | ||
| 819 | p := layout[i] | 824 | p := layout[i] | ||
| 820 | r := p.Rect | 825 | r := p.Rect | ||
| @@ -843,11 +848,16 @@ func (c *Core) overviewClick(ctx context.Context) (picked bool, err error) { | |||||
| 843 | if !sc.mon.ov.open { | 848 | if !sc.mon.ov.open { | ||
| 844 | return false, nil | 849 | return false, nil | ||
| 845 | } | 850 | } | ||
| 846 | - | id := sc.mon.overviewAt(c.cursorX-float64(sc.x), c.cursorY-float64(sc.y)) | 851 | + | // The cards are hit where they are drawn: a click during the open or a |
| 852 | + | // navigation animation picks what moves under the pointer. | |||
| 853 | + | id := overviewIn(sc.shownLayout(), c.cursorX-float64(sc.x), c.cursorY-float64(sc.y)) | |||
| 847 | if id == 0 { | 854 | if id == 0 { | ||
| 848 | return false, nil | 855 | return false, nil | ||
| 849 | } | 856 | } | ||
| 857 | + | now := c.now() | |||
| 858 | + | shots := c.snapshot(now) | |||
| 850 | sc.mon.OverviewPick(id) | 859 | sc.mon.OverviewPick(id) | ||
| 860 | + | c.transition(shots, now) | |||
| 851 | c.focusScreen = i | 861 | c.focusScreen = i | ||
| 852 | c.keyboard.takeBack() | 862 | c.keyboard.takeBack() | ||
| 853 | if err := c.workspaceVisible(ctx, true); err != nil { | 863 | if err := c.workspaceVisible(ctx, true); err != nil { | ||
M internal/core/overview_core_test.go +9 -2
| @@ -82,10 +82,17 @@ func TestOverviewCoreClickPicksOnPointerOutput(t *testing.T) { | |||
| 82 | 82 | } | |
| 83 | 83 | ||
| 84 | 84 | func TestOverviewCoreClickOutsidePreviewKeepsOverview(t *testing.T) { | |
| 85 | - | r := startMulti(t, nil, right) | |
| 85 | + | both(t, func(t *testing.T, animated bool) { | |
| 86 | + | overviewClickOutsidePreview(t, startLanding(t, animated, nil, right)) | |
| 87 | + | }) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func overviewClickOutsidePreview(t *testing.T, r *landRig) { | |
| 86 | 91 | r.mapWindow(t, 1) | |
| 87 | 92 | r.mapWindow(t, 2) | |
| 88 | - | set := r.key(t, "o", ports.ModAlt) | |
| 93 | + | // The cards settle where the overview lays them out: the opening | |
| 94 | + | // animation starts them at the windows' own rects. | |
| 95 | + | set := r.keyLanded(t, "o", ports.ModAlt) | |
| 89 | 96 | for _, w := range set[0].Windows { | |
| 90 | 97 | if w.Preview > 0 && w.Rect.X <= 1 && w.Rect.Y <= 1 && w.Rect.X+w.Rect.W > 1 && w.Rect.Y+w.Rect.H > 1 { | |
| 91 | 98 | t.Fatalf("(1, 1) is inside preview %+v", w) | |
| @@ -82,10 +82,17 @@ func TestOverviewCoreClickPicksOnPointerOutput(t *testing.T) { | |||||
| 82 | } | 82 | } | ||
| 83 | 83 | ||||
| 84 | func TestOverviewCoreClickOutsidePreviewKeepsOverview(t *testing.T) { | 84 | func TestOverviewCoreClickOutsidePreviewKeepsOverview(t *testing.T) { | ||
| 85 | - | r := startMulti(t, nil, right) | 85 | + | both(t, func(t *testing.T, animated bool) { |
| 86 | + | overviewClickOutsidePreview(t, startLanding(t, animated, nil, right)) | |||
| 87 | + | }) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func overviewClickOutsidePreview(t *testing.T, r *landRig) { | |||
| 86 | r.mapWindow(t, 1) | 91 | r.mapWindow(t, 1) | ||
| 87 | r.mapWindow(t, 2) | 92 | r.mapWindow(t, 2) | ||
| 88 | - | set := r.key(t, "o", ports.ModAlt) | 93 | + | // The cards settle where the overview lays them out: the opening |
| 94 | + | // animation starts them at the windows' own rects. | |||
| 95 | + | set := r.keyLanded(t, "o", ports.ModAlt) | |||
| 89 | for _, w := range set[0].Windows { | 96 | for _, w := range set[0].Windows { | ||
| 90 | if w.Preview > 0 && w.Rect.X <= 1 && w.Rect.Y <= 1 && w.Rect.X+w.Rect.W > 1 && w.Rect.Y+w.Rect.H > 1 { | 97 | if w.Preview > 0 && w.Rect.X <= 1 && w.Rect.Y <= 1 && w.Rect.X+w.Rect.W > 1 && w.Rect.Y+w.Rect.H > 1 { | ||
| 91 | t.Fatalf("(1, 1) is inside preview %+v", w) | 98 | t.Fatalf("(1, 1) is inside preview %+v", w) | ||
M internal/core/screens.go +11 -1
| @@ -41,7 +41,8 @@ type screen struct { | |||
| 41 | 41 | // motions run (refreshShown), reused across publishes. | |
| 42 | 42 | shownBuf, settledBuf []Placement | |
| 43 | 43 | // rects are the running per-window rect motions (transition.go) of | |
| 44 | - | // rectsWS, the workspace they began on; created lazily. | |
| 44 | + | // rectsWS, the workspace they began on (nil: the overview); created | |
| 45 | + | // lazily. | |
| 45 | 46 | rects map[WindowID]rectMotion | |
| 46 | 47 | rectsWS *Workspace | |
| 47 | 48 | } | |
| @@ -56,6 +57,15 @@ func (s *screen) settled() bool { return s.mon.settled() && len(s.rects) == 0 } | |||
| 56 | 57 | // stopRects drops the rect motions: the windows are drawn where they are. | |
| 57 | 58 | func (s *screen) stopRects() { clear(s.rects); s.rectsWS = nil } | |
| 58 | 59 | ||
| 60 | + | // rectsOwner is what the screen's rect motions belong to: its workspace on | |
| 61 | + | // screen, or nil in the overview, where the rows change under the cards. | |
| 62 | + | func (s *screen) rectsOwner() *Workspace { | |
| 63 | + | if s.mon.ov.open { | |
| 64 | + | return nil | |
| 65 | + | } | |
| 66 | + | return s.mon.Current() | |
| 67 | + | } | |
| 68 | + | ||
| 59 | 69 | // shownLayout is the layout as drawn, for hit-testing and popups; before | |
| 60 | 70 | // the first publish it is the monitor's. | |
| 61 | 71 | func (s *screen) shownLayout() []Placement { | |
| @@ -41,7 +41,8 @@ type screen struct { | |||||
| 41 | // motions run (refreshShown), reused across publishes. | 41 | // motions run (refreshShown), reused across publishes. | ||
| 42 | shownBuf, settledBuf []Placement | 42 | shownBuf, settledBuf []Placement | ||
| 43 | // rects are the running per-window rect motions (transition.go) of | 43 | // rects are the running per-window rect motions (transition.go) of | ||
| 44 | - | // rectsWS, the workspace they began on; created lazily. | 44 | + | // rectsWS, the workspace they began on (nil: the overview); created |
| 45 | + | // lazily. | |||
| 45 | rects map[WindowID]rectMotion | 46 | rects map[WindowID]rectMotion | ||
| 46 | rectsWS *Workspace | 47 | rectsWS *Workspace | ||
| 47 | } | 48 | } | ||
| @@ -56,6 +57,15 @@ func (s *screen) settled() bool { return s.mon.settled() && len(s.rects) == 0 } | |||||
| 56 | // stopRects drops the rect motions: the windows are drawn where they are. | 57 | // stopRects drops the rect motions: the windows are drawn where they are. | ||
| 57 | func (s *screen) stopRects() { clear(s.rects); s.rectsWS = nil } | 58 | func (s *screen) stopRects() { clear(s.rects); s.rectsWS = nil } | ||
| 58 | 59 | ||||
| 60 | + | // rectsOwner is what the screen's rect motions belong to: its workspace on | |||
| 61 | + | // screen, or nil in the overview, where the rows change under the cards. | |||
| 62 | + | func (s *screen) rectsOwner() *Workspace { | |||
| 63 | + | if s.mon.ov.open { | |||
| 64 | + | return nil | |||
| 65 | + | } | |||
| 66 | + | return s.mon.Current() | |||
| 67 | + | } | |||
| 68 | + | ||||
| 59 | // shownLayout is the layout as drawn, for hit-testing and popups; before | 69 | // shownLayout is the layout as drawn, for hit-testing and popups; before | ||
| 60 | // the first publish it is the monitor's. | 70 | // the first publish it is the monitor's. | ||
| 61 | func (s *screen) shownLayout() []Placement { | 71 | func (s *screen) shownLayout() []Placement { | ||
M internal/core/swipe_test.go +22 -10
| @@ -2,6 +2,7 @@ package core_test | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "slices" | |
| 5 | 6 | "testing" | |
| 6 | 7 | "time" | |
| 7 | 8 | ||
| @@ -692,17 +693,28 @@ func TestOverviewOpenedMidSwipeDropsIt(t *testing.T) { | |||
| 692 | 693 | if !closeFirst { | |
| 693 | 694 | r.input <- ports.KeyEvent{Keysym: "Escape", Pressed: true} | |
| 694 | 695 | } | |
| 695 | - | // Past any spring: the view shows where it settled. | |
| 696 | - | r.advance(5 * time.Second) | |
| 697 | - | r.frames <- ports.OutputFrame{Output: wide.Name} | |
| 698 | - | s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { | |
| 699 | - | for _, w := range s.Windows { | |
| 700 | - | if w.Preview > 0 { | |
| 701 | - | return false | |
| 702 | - | } | |
| 696 | + | // Past any spring: the view shows where it settled. The escape's | |
| 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. | |
| 699 | + | var s ports.Scene | |
| 700 | + | for deadline := time.Now().Add(time.Second); ; { | |
| 701 | + | r.advance(5 * time.Second) | |
| 702 | + | r.frames <- ports.OutputFrame{Output: wide.Name} | |
| 703 | + | select { | |
| 704 | + | case set := <-r.scenes: | |
| 705 | + | s = set[0] | |
| 706 | + | case <-time.After(20 * time.Millisecond): | |
| 703 | 707 | } | |
| 704 | - | return true | |
| 705 | - | }) | |
| 708 | + | if len(s.Windows) > 0 && !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { | |
| 709 | + | // Neither a card nor a window still zooming back. | |
| 710 | + | return w.Preview > 0 || w.Zoom > 0 || w.Fade > 0 | |
| 711 | + | }) { | |
| 712 | + | break | |
| 713 | + | } | |
| 714 | + | if time.Now().After(deadline) { | |
| 715 | + | t.Fatalf("closeFirst=%v: the scene never settled: %+v", closeFirst, s.Windows) | |
| 716 | + | } | |
| 717 | + | } | |
| 706 | 718 | for _, id := range []ports.WindowID{1, 2, 3} { | |
| 707 | 719 | before, _ := rectOf(settled, id) | |
| 708 | 720 | if after, _ := rectOf(s, id); before != after { | |
| @@ -2,6 +2,7 @@ package core_test | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "slices" | |||
| 5 | "testing" | 6 | "testing" | ||
| 6 | "time" | 7 | "time" | ||
| 7 | 8 | ||||
| @@ -692,17 +693,28 @@ func TestOverviewOpenedMidSwipeDropsIt(t *testing.T) { | |||||
| 692 | if !closeFirst { | 693 | if !closeFirst { | ||
| 693 | r.input <- ports.KeyEvent{Keysym: "Escape", Pressed: true} | 694 | r.input <- ports.KeyEvent{Keysym: "Escape", Pressed: true} | ||
| 694 | } | 695 | } | ||
| 695 | - | // Past any spring: the view shows where it settled. | 696 | + | // Past any spring: the view shows where it settled. The escape's |
| 696 | - | r.advance(5 * time.Second) | 697 | + | // own springs (the cards zooming back) start when it is handled, |
| 697 | - | r.frames <- ports.OutputFrame{Output: wide.Name} | 698 | + | // which may follow a first flip: flip until the scene is settled. |
| 698 | - | s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { | 699 | + | var s ports.Scene |
| 699 | - | for _, w := range s.Windows { | 700 | + | for deadline := time.Now().Add(time.Second); ; { |
| 700 | - | if w.Preview > 0 { | 701 | + | r.advance(5 * time.Second) |
| 701 | - | return false | 702 | + | r.frames <- ports.OutputFrame{Output: wide.Name} |
| 702 | - | } | 703 | + | select { |
| 704 | + | case set := <-r.scenes: | |||
| 705 | + | s = set[0] | |||
| 706 | + | case <-time.After(20 * time.Millisecond): | |||
| 703 | } | 707 | } | ||
| 704 | - | return true | 708 | + | if len(s.Windows) > 0 && !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { |
| 705 | - | }) | 709 | + | // Neither a card nor a window still zooming back. |
| 710 | + | return w.Preview > 0 || w.Zoom > 0 || w.Fade > 0 | |||
| 711 | + | }) { | |||
| 712 | + | break | |||
| 713 | + | } | |||
| 714 | + | if time.Now().After(deadline) { | |||
| 715 | + | t.Fatalf("closeFirst=%v: the scene never settled: %+v", closeFirst, s.Windows) | |||
| 716 | + | } | |||
| 717 | + | } | |||
| 706 | for _, id := range []ports.WindowID{1, 2, 3} { | 718 | for _, id := range []ports.WindowID{1, 2, 3} { | ||
| 707 | before, _ := rectOf(settled, id) | 719 | before, _ := rectOf(settled, id) | ||
| 708 | if after, _ := rectOf(s, id); before != after { | 720 | if after, _ := rectOf(s, id); before != after { | ||
M internal/core/transition.go +96 -39
| @@ -25,6 +25,9 @@ import ( | |||
| 25 | 25 | type viewShot struct { | |
| 26 | 26 | sc *screen | |
| 27 | 27 | ws *Workspace | |
| 28 | + | // overview is set when the screen showed its overview: ws is then the | |
| 29 | + | // row on screen, the camera fields stay zero, and rects are the cards. | |
| 30 | + | overview bool | |
| 28 | 31 | // viewX is ws's settled view and view where its columns were on screen | |
| 29 | 32 | // (viewX plus shift). | |
| 30 | 33 | viewX int | |
| @@ -40,8 +43,8 @@ type viewShot struct { | |||
| 40 | 43 | list []*Workspace | |
| 41 | 44 | pos float64 | |
| 42 | 45 | ok bool | |
| 43 | - | // rects are the windows of ws the screen drew, hidden windows and | |
| 44 | - | // previews left out. The slice is reused like list. | |
| 46 | + | // rects are the windows the screen drew (cards in the overview), | |
| 47 | + | // hidden windows left out. The slice is reused like list. | |
| 45 | 48 | rects []rectShot | |
| 46 | 49 | } | |
| 47 | 50 | ||
| @@ -67,7 +70,9 @@ type rectShot struct { | |||
| 67 | 70 | // placement gets a Zoom of the settled zoom (its Preview, or 1) times | |
| 68 | 71 | // shown.W / settled.W (refreshShown), which the scene carries as Preview. | |
| 69 | 72 | // The walk shrinks content toward the content rect's top-left, so this | |
| 70 | - | // suits aspect-preserving changes only. | |
| 73 | + | // suits aspect-preserving changes only. An overview card's settled zoom is | |
| 74 | + | // its Preview: a window that opens into a card goes from 1 to Preview with | |
| 75 | + | // its width, and back. | |
| 71 | 76 | // | |
| 72 | 77 | // A leaving motion draws a window that closed or hid while it fades out: | |
| 73 | 78 | // left is its last settled placement, which refreshShown appends (Hidden | |
| @@ -161,7 +166,9 @@ func (m *rectMotion) show(p *Placement) { | |||
| 161 | 166 | } | |
| 162 | 167 | zoom *= float64(p.Rect.W) / float64(settled.W) | |
| 163 | 168 | if zoom >= 0.999 { | |
| 164 | - | // Drawn at its size: no zoom (publish emits Preview 0). | |
| 169 | + | // Drawn at its size: content is never magnified. publish | |
| 170 | + | // emits no Zoom for a window, and Zoom 1 for a card (whose | |
| 171 | + | // Preview would shrink it). | |
| 165 | 172 | zoom = 1 | |
| 166 | 173 | } | |
| 167 | 174 | p.Zoom = zoom | |
| @@ -183,8 +190,8 @@ func (c *Core) retargetComponent(m *motion, cur *float64, off, v float64, now ti | |||
| 183 | 190 | // refreshShown builds the layouts of every screen once per publish: the | |
| 184 | 191 | // settled one (what configures are sized from) and the shown one (the same | |
| 185 | 192 | // with the rect motions applied: what is drawn and hit-tested). Rect motions | |
| 186 | - | // belong to the workspace they began on and to a closed overview: any other | |
| 187 | - | // change drops them. | |
| 193 | + | // belong to the workspace they began on, or to the open overview (rectsOwner): | |
| 194 | + | // any other change drops them. | |
| 188 | 195 | // | |
| 189 | 196 | // Leaving motions add an entry for a window the layout no longer shows: | |
| 190 | 197 | // appended to both layouts in ID order, Hidden (no input, focus, popups or | |
| @@ -193,7 +200,7 @@ func (c *Core) retargetComponent(m *motion, cur *float64, off, v float64, now ti | |||
| 193 | 200 | func (c *Core) refreshShown() { | |
| 194 | 201 | for _, sc := range c.screens { | |
| 195 | 202 | m := sc.mon | |
| 196 | - | if len(sc.rects) > 0 && (m.ov.open || sc.rectsWS != m.Current()) { | |
| 203 | + | if len(sc.rects) > 0 && sc.rectsWS != sc.rectsOwner() { | |
| 197 | 204 | sc.stopRects() | |
| 198 | 205 | } | |
| 199 | 206 | sc.settledLayout = m.Layout() | |
| @@ -203,7 +210,7 @@ func (c *Core) refreshShown() { | |||
| 203 | 210 | // is the opposite: it goes once the layout shows the window. | |
| 204 | 211 | for id, rm := range sc.rects { | |
| 205 | 212 | shown := slices.ContainsFunc(sc.settledLayout, func(p Placement) bool { | |
| 206 | - | return p.ID == id && !p.Hidden && p.Preview == 0 | |
| 213 | + | return p.ID == id && !p.Hidden | |
| 207 | 214 | }) | |
| 208 | 215 | if shown == rm.leaving { | |
| 209 | 216 | delete(sc.rects, id) | |
| @@ -450,37 +457,40 @@ func (c *Core) snapshot(now time.Time) []viewShot { | |||
| 450 | 457 | s.rects = full[len(out)].rects[:0] | |
| 451 | 458 | } | |
| 452 | 459 | s.sc = sc | |
| 460 | + | w := m.Current() | |
| 461 | + | s.ws, s.overview = w, m.ov.open | |
| 453 | 462 | if !m.ov.open { | |
| 454 | - | w := m.Current() | |
| 455 | - | s.ws, s.viewX, s.view = w, w.ViewX, float64(w.ViewX)+w.shift | |
| 456 | - | if w.motion.on { | |
| 457 | - | _, s.viewV = w.motion.sampleAt(now) | |
| 463 | + | // The overview does not scroll or slide: no camera. | |
| 464 | + | s.viewX, s.view = w.ViewX, float64(w.ViewX)+w.shift | |
| 465 | + | } | |
| 466 | + | if w.motion.on { | |
| 467 | + | _, s.viewV = w.motion.sampleAt(now) | |
| 468 | + | } | |
| 469 | + | if m.switchMotion.on { | |
| 470 | + | _, s.switchV = m.switchMotion.sampleAt(now) | |
| 471 | + | } | |
| 472 | + | // settledLayout and shown are index-aligned and differ only by | |
| 473 | + | // the offsets of sc.rects: the shot keeps both unrounded. The | |
| 474 | + | // overview's cards are recorded like windows. | |
| 475 | + | for _, p := range sc.settledLayout { | |
| 476 | + | if p.Hidden { | |
| 477 | + | continue | |
| 458 | 478 | } | |
| 459 | - | if m.switchMotion.on { | |
| 460 | - | _, s.switchV = m.switchMotion.sampleAt(now) | |
| 479 | + | r := rectShot{id: p.ID, rect: p.Rect} | |
| 480 | + | if rm, ok := sc.rects[p.ID]; ok { | |
| 481 | + | r.off, r.vel = rm.sample(now) | |
| 461 | 482 | } | |
| 462 | - | // settledLayout and shown are index-aligned and differ only by | |
| 463 | - | // the offsets of sc.rects: the shot keeps both unrounded. | |
| 464 | - | for _, p := range sc.settledLayout { | |
| 465 | - | if p.Hidden || p.Preview != 0 { | |
| 466 | - | continue | |
| 467 | - | } | |
| 468 | - | r := rectShot{id: p.ID, rect: p.Rect} | |
| 469 | - | if rm, ok := sc.rects[p.ID]; ok { | |
| 470 | - | r.off, r.vel = rm.sample(now) | |
| 471 | - | } | |
| 472 | - | s.rects = append(s.rects, r) | |
| 483 | + | s.rects = append(s.rects, r) | |
| 484 | + | } | |
| 485 | + | if m.shown == nil && !m.ov.open { | |
| 486 | + | // A landing slide measures in the list it began on. | |
| 487 | + | list := m.Workspaces | |
| 488 | + | if m.switchList != nil { | |
| 489 | + | list = m.switchList | |
| 473 | 490 | } | |
| 474 | - | if m.shown == nil { | |
| 475 | - | // A landing slide measures in the list it began on. | |
| 476 | - | list := m.Workspaces | |
| 477 | - | if m.switchList != nil { | |
| 478 | - | list = m.switchList | |
| 479 | - | } | |
| 480 | - | if i := indexOf(list, w); i >= 0 { | |
| 481 | - | s.list = append(s.list, list...) | |
| 482 | - | s.pos, s.ok = float64(i)+m.switchOff, true | |
| 483 | - | } | |
| 491 | + | if i := indexOf(list, w); i >= 0 { | |
| 492 | + | s.list = append(s.list, list...) | |
| 493 | + | s.pos, s.ok = float64(i)+m.switchOff, true | |
| 484 | 494 | } | |
| 485 | 495 | } | |
| 486 | 496 | out = append(out, s) | |
| @@ -494,8 +504,9 @@ func (c *Core) snapshot(now time.Time) []viewShot { | |||
| 494 | 504 | ||
| 495 | 505 | // transition starts the springs that take each screen from what before | |
| 496 | 506 | // showed to what the action left, at now. It does nothing with animations | |
| 497 | - | // off, with the overview open before or after, or while a swipe follows the | |
| 498 | - | // fingers on that screen. Per-window rect motions join the camera here. | |
| 507 | + | // off, or while a swipe follows the fingers on that screen. Per-window rect | |
| 508 | + | // motions join the camera here; with the overview open before or after | |
| 509 | + | // there is no camera, only the cards' (transitionOverview). | |
| 499 | 510 | // | |
| 500 | 511 | // A workspace moved to another monitor (move-workspace-to-monitor-*, or a | |
| 501 | 512 | // named workspace pulled to the focused one by a "workspace <name>" bind | |
| @@ -510,7 +521,11 @@ func (c *Core) transition(before []viewShot, now time.Time) { | |||
| 510 | 521 | } | |
| 511 | 522 | for i := range before { | |
| 512 | 523 | b := &before[i] | |
| 513 | - | if b.ws == nil || !c.hasScreen(b.sc) || b.sc.mon.ov.open || c.following(b.sc) { | |
| 524 | + | if b.ws == nil || !c.hasScreen(b.sc) || c.following(b.sc) { | |
| 525 | + | continue | |
| 526 | + | } | |
| 527 | + | if b.overview || b.sc.mon.ov.open { | |
| 528 | + | c.transitionOverview(b, now) | |
| 514 | 529 | continue | |
| 515 | 530 | } | |
| 516 | 531 | c.transitionCamera(b, before, now) | |
| @@ -616,12 +631,54 @@ func (c *Core) transitionRects(b *viewShot, before []viewShot, now time.Time) { | |||
| 616 | 631 | } | |
| 617 | 632 | } | |
| 618 | 633 | ||
| 634 | + | // transitionOverview starts the motions of an action that opened or closed | |
| 635 | + | // the overview, or moved inside it (the selection, the rows). The camera | |
| 636 | + | // springs stay stopped (ToggleOverview): every window drawn before and | |
| 637 | + | // after moves from its shown rect to its settled one, its content scaled | |
| 638 | + | // with it (a card's Zoom, rectMotion.show). A window drawn after and not | |
| 639 | + | // 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 | |
| 641 | + | // other state are dropped first: their rects are not the ones snapshot | |
| 642 | + | // measured from. | |
| 643 | + | func (c *Core) transitionOverview(b *viewShot, now time.Time) { | |
| 644 | + | sc := b.sc | |
| 645 | + | if b.overview != sc.mon.ov.open { | |
| 646 | + | sc.stopRects() | |
| 647 | + | } | |
| 648 | + | for _, p := range sc.mon.Layout() { | |
| 649 | + | if p.Hidden { | |
| 650 | + | continue | |
| 651 | + | } | |
| 652 | + | rm := sc.rects[p.ID] | |
| 653 | + | i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID }) | |
| 654 | + | if i < 0 { | |
| 655 | + | c.retargetComponent(&rm.fade, &rm.df, 1, 0, now) | |
| 656 | + | } else if old := &b.rects[i]; old.rect != p.Rect { | |
| 657 | + | rm.scale = true | |
| 658 | + | c.retargetComponent(&rm.x, &rm.dx, float64(old.rect.X-p.Rect.X)+old.off.x, old.vel.x, now) | |
| 659 | + | c.retargetComponent(&rm.y, &rm.dy, float64(old.rect.Y-p.Rect.Y)+old.off.y, old.vel.y, now) | |
| 660 | + | c.retargetComponent(&rm.w, &rm.dw, float64(old.rect.W-p.Rect.W)+old.off.w, old.vel.w, now) | |
| 661 | + | c.retargetComponent(&rm.h, &rm.dh, float64(old.rect.H-p.Rect.H)+old.off.h, old.vel.h, now) | |
| 662 | + | } else { | |
| 663 | + | continue | |
| 664 | + | } | |
| 665 | + | if !rm.on() { | |
| 666 | + | delete(sc.rects, p.ID) | |
| 667 | + | continue | |
| 668 | + | } | |
| 669 | + | if sc.rects == nil { | |
| 670 | + | sc.rects = make(map[WindowID]rectMotion) | |
| 671 | + | } | |
| 672 | + | sc.rects[p.ID], sc.rectsWS = rm, sc.rectsOwner() | |
| 673 | + | } | |
| 674 | + | } | |
| 675 | + | ||
| 619 | 676 | // movedFrom is the shot of the other screen that showed the workspace b's | |
| 620 | 677 | // screen shows now (moved to it by an action), or nil. | |
| 621 | 678 | func movedFrom(b *viewShot, before []viewShot) *viewShot { | |
| 622 | 679 | w := b.sc.mon.Current() | |
| 623 | 680 | for i := range before { | |
| 624 | - | if o := &before[i]; o.ws == w && o.sc != b.sc { | |
| 681 | + | if o := &before[i]; o.ws == w && o.sc != b.sc && !o.overview { | |
| 625 | 682 | return o | |
| 626 | 683 | } | |
| 627 | 684 | } | |
| @@ -25,6 +25,9 @@ import ( | |||||
| 25 | type viewShot struct { | 25 | type viewShot struct { | ||
| 26 | sc *screen | 26 | sc *screen | ||
| 27 | ws *Workspace | 27 | ws *Workspace | ||
| 28 | + | // overview is set when the screen showed its overview: ws is then the | |||
| 29 | + | // row on screen, the camera fields stay zero, and rects are the cards. | |||
| 30 | + | overview bool | |||
| 28 | // viewX is ws's settled view and view where its columns were on screen | 31 | // viewX is ws's settled view and view where its columns were on screen | ||
| 29 | // (viewX plus shift). | 32 | // (viewX plus shift). | ||
| 30 | viewX int | 33 | viewX int | ||
| @@ -40,8 +43,8 @@ type viewShot struct { | |||||
| 40 | list []*Workspace | 43 | list []*Workspace | ||
| 41 | pos float64 | 44 | pos float64 | ||
| 42 | ok bool | 45 | ok bool | ||
| 43 | - | // rects are the windows of ws the screen drew, hidden windows and | 46 | + | // rects are the windows the screen drew (cards in the overview), |
| 44 | - | // previews left out. The slice is reused like list. | 47 | + | // hidden windows left out. The slice is reused like list. |
| 45 | rects []rectShot | 48 | rects []rectShot | ||
| 46 | } | 49 | } | ||
| 47 | 50 | ||||
| @@ -67,7 +70,9 @@ type rectShot struct { | |||||
| 67 | // placement gets a Zoom of the settled zoom (its Preview, or 1) times | 70 | // placement gets a Zoom of the settled zoom (its Preview, or 1) times | ||
| 68 | // shown.W / settled.W (refreshShown), which the scene carries as Preview. | 71 | // shown.W / settled.W (refreshShown), which the scene carries as Preview. | ||
| 69 | // The walk shrinks content toward the content rect's top-left, so this | 72 | // The walk shrinks content toward the content rect's top-left, so this | ||
| 70 | - | // suits aspect-preserving changes only. | 73 | + | // suits aspect-preserving changes only. An overview card's settled zoom is |
| 74 | + | // its Preview: a window that opens into a card goes from 1 to Preview with | |||
| 75 | + | // its width, and back. | |||
| 71 | // | 76 | // | ||
| 72 | // A leaving motion draws a window that closed or hid while it fades out: | 77 | // A leaving motion draws a window that closed or hid while it fades out: | ||
| 73 | // left is its last settled placement, which refreshShown appends (Hidden | 78 | // left is its last settled placement, which refreshShown appends (Hidden | ||
| @@ -161,7 +166,9 @@ func (m *rectMotion) show(p *Placement) { | |||||
| 161 | } | 166 | } | ||
| 162 | zoom *= float64(p.Rect.W) / float64(settled.W) | 167 | zoom *= float64(p.Rect.W) / float64(settled.W) | ||
| 163 | if zoom >= 0.999 { | 168 | if zoom >= 0.999 { | ||
| 164 | - | // Drawn at its size: no zoom (publish emits Preview 0). | 169 | + | // Drawn at its size: content is never magnified. publish |
| 170 | + | // emits no Zoom for a window, and Zoom 1 for a card (whose | |||
| 171 | + | // Preview would shrink it). | |||
| 165 | zoom = 1 | 172 | zoom = 1 | ||
| 166 | } | 173 | } | ||
| 167 | p.Zoom = zoom | 174 | p.Zoom = zoom | ||
| @@ -183,8 +190,8 @@ func (c *Core) retargetComponent(m *motion, cur *float64, off, v float64, now ti | |||||
| 183 | // refreshShown builds the layouts of every screen once per publish: the | 190 | // refreshShown builds the layouts of every screen once per publish: the | ||
| 184 | // settled one (what configures are sized from) and the shown one (the same | 191 | // settled one (what configures are sized from) and the shown one (the same | ||
| 185 | // with the rect motions applied: what is drawn and hit-tested). Rect motions | 192 | // with the rect motions applied: what is drawn and hit-tested). Rect motions | ||
| 186 | - | // belong to the workspace they began on and to a closed overview: any other | 193 | + | // belong to the workspace they began on, or to the open overview (rectsOwner): |
| 187 | - | // change drops them. | 194 | + | // any other change drops them. |
| 188 | // | 195 | // | ||
| 189 | // Leaving motions add an entry for a window the layout no longer shows: | 196 | // Leaving motions add an entry for a window the layout no longer shows: | ||
| 190 | // appended to both layouts in ID order, Hidden (no input, focus, popups or | 197 | // appended to both layouts in ID order, Hidden (no input, focus, popups or | ||
| @@ -193,7 +200,7 @@ func (c *Core) retargetComponent(m *motion, cur *float64, off, v float64, now ti | |||||
| 193 | func (c *Core) refreshShown() { | 200 | func (c *Core) refreshShown() { | ||
| 194 | for _, sc := range c.screens { | 201 | for _, sc := range c.screens { | ||
| 195 | m := sc.mon | 202 | m := sc.mon | ||
| 196 | - | if len(sc.rects) > 0 && (m.ov.open || sc.rectsWS != m.Current()) { | 203 | + | if len(sc.rects) > 0 && sc.rectsWS != sc.rectsOwner() { |
| 197 | sc.stopRects() | 204 | sc.stopRects() | ||
| 198 | } | 205 | } | ||
| 199 | sc.settledLayout = m.Layout() | 206 | sc.settledLayout = m.Layout() | ||
| @@ -203,7 +210,7 @@ func (c *Core) refreshShown() { | |||||
| 203 | // is the opposite: it goes once the layout shows the window. | 210 | // is the opposite: it goes once the layout shows the window. | ||
| 204 | for id, rm := range sc.rects { | 211 | for id, rm := range sc.rects { | ||
| 205 | shown := slices.ContainsFunc(sc.settledLayout, func(p Placement) bool { | 212 | shown := slices.ContainsFunc(sc.settledLayout, func(p Placement) bool { | ||
| 206 | - | return p.ID == id && !p.Hidden && p.Preview == 0 | 213 | + | return p.ID == id && !p.Hidden |
| 207 | }) | 214 | }) | ||
| 208 | if shown == rm.leaving { | 215 | if shown == rm.leaving { | ||
| 209 | delete(sc.rects, id) | 216 | delete(sc.rects, id) | ||
| @@ -450,37 +457,40 @@ func (c *Core) snapshot(now time.Time) []viewShot { | |||||
| 450 | s.rects = full[len(out)].rects[:0] | 457 | s.rects = full[len(out)].rects[:0] | ||
| 451 | } | 458 | } | ||
| 452 | s.sc = sc | 459 | s.sc = sc | ||
| 460 | + | w := m.Current() | |||
| 461 | + | s.ws, s.overview = w, m.ov.open | |||
| 453 | if !m.ov.open { | 462 | if !m.ov.open { | ||
| 454 | - | w := m.Current() | 463 | + | // The overview does not scroll or slide: no camera. |
| 455 | - | s.ws, s.viewX, s.view = w, w.ViewX, float64(w.ViewX)+w.shift | 464 | + | s.viewX, s.view = w.ViewX, float64(w.ViewX)+w.shift |
| 456 | - | if w.motion.on { | 465 | + | } |
| 457 | - | _, s.viewV = w.motion.sampleAt(now) | 466 | + | if w.motion.on { |
| 467 | + | _, s.viewV = w.motion.sampleAt(now) | |||
| 468 | + | } | |||
| 469 | + | if m.switchMotion.on { | |||
| 470 | + | _, s.switchV = m.switchMotion.sampleAt(now) | |||
| 471 | + | } | |||
| 472 | + | // settledLayout and shown are index-aligned and differ only by | |||
| 473 | + | // the offsets of sc.rects: the shot keeps both unrounded. The | |||
| 474 | + | // overview's cards are recorded like windows. | |||
| 475 | + | for _, p := range sc.settledLayout { | |||
| 476 | + | if p.Hidden { | |||
| 477 | + | continue | |||
| 458 | } | 478 | } | ||
| 459 | - | if m.switchMotion.on { | 479 | + | r := rectShot{id: p.ID, rect: p.Rect} |
| 460 | - | _, s.switchV = m.switchMotion.sampleAt(now) | 480 | + | if rm, ok := sc.rects[p.ID]; ok { |
| 481 | + | r.off, r.vel = rm.sample(now) | |||
| 461 | } | 482 | } | ||
| 462 | - | // settledLayout and shown are index-aligned and differ only by | 483 | + | s.rects = append(s.rects, r) |
| 463 | - | // the offsets of sc.rects: the shot keeps both unrounded. | 484 | + | } |
| 464 | - | for _, p := range sc.settledLayout { | 485 | + | if m.shown == nil && !m.ov.open { |
| 465 | - | if p.Hidden || p.Preview != 0 { | 486 | + | // A landing slide measures in the list it began on. |
| 466 | - | continue | 487 | + | list := m.Workspaces |
| 467 | - | } | 488 | + | if m.switchList != nil { |
| 468 | - | r := rectShot{id: p.ID, rect: p.Rect} | 489 | + | list = m.switchList |
| 469 | - | if rm, ok := sc.rects[p.ID]; ok { | |||
| 470 | - | r.off, r.vel = rm.sample(now) | |||
| 471 | - | } | |||
| 472 | - | s.rects = append(s.rects, r) | |||
| 473 | } | 490 | } | ||
| 474 | - | if m.shown == nil { | 491 | + | if i := indexOf(list, w); i >= 0 { |
| 475 | - | // A landing slide measures in the list it began on. | 492 | + | s.list = append(s.list, list...) |
| 476 | - | list := m.Workspaces | 493 | + | s.pos, s.ok = float64(i)+m.switchOff, true |
| 477 | - | if m.switchList != nil { | |||
| 478 | - | list = m.switchList | |||
| 479 | - | } | |||
| 480 | - | if i := indexOf(list, w); i >= 0 { | |||
| 481 | - | s.list = append(s.list, list...) | |||
| 482 | - | s.pos, s.ok = float64(i)+m.switchOff, true | |||
| 483 | - | } | |||
| 484 | } | 494 | } | ||
| 485 | } | 495 | } | ||
| 486 | out = append(out, s) | 496 | out = append(out, s) | ||
| @@ -494,8 +504,9 @@ func (c *Core) snapshot(now time.Time) []viewShot { | |||||
| 494 | 504 | ||||
| 495 | // transition starts the springs that take each screen from what before | 505 | // transition starts the springs that take each screen from what before | ||
| 496 | // showed to what the action left, at now. It does nothing with animations | 506 | // showed to what the action left, at now. It does nothing with animations | ||
| 497 | - | // off, with the overview open before or after, or while a swipe follows the | 507 | + | // off, or while a swipe follows the fingers on that screen. Per-window rect |
| 498 | - | // fingers on that screen. Per-window rect motions join the camera here. | 508 | + | // motions join the camera here; with the overview open before or after |
| 509 | + | // there is no camera, only the cards' (transitionOverview). | |||
| 499 | // | 510 | // | ||
| 500 | // A workspace moved to another monitor (move-workspace-to-monitor-*, or a | 511 | // A workspace moved to another monitor (move-workspace-to-monitor-*, or a | ||
| 501 | // named workspace pulled to the focused one by a "workspace <name>" bind | 512 | // named workspace pulled to the focused one by a "workspace <name>" bind | ||
| @@ -510,7 +521,11 @@ func (c *Core) transition(before []viewShot, now time.Time) { | |||||
| 510 | } | 521 | } | ||
| 511 | for i := range before { | 522 | for i := range before { | ||
| 512 | b := &before[i] | 523 | b := &before[i] | ||
| 513 | - | if b.ws == nil || !c.hasScreen(b.sc) || b.sc.mon.ov.open || c.following(b.sc) { | 524 | + | if b.ws == nil || !c.hasScreen(b.sc) || c.following(b.sc) { |
| 525 | + | continue | |||
| 526 | + | } | |||
| 527 | + | if b.overview || b.sc.mon.ov.open { | |||
| 528 | + | c.transitionOverview(b, now) | |||
| 514 | continue | 529 | continue | ||
| 515 | } | 530 | } | ||
| 516 | c.transitionCamera(b, before, now) | 531 | c.transitionCamera(b, before, now) | ||
| @@ -616,12 +631,54 @@ func (c *Core) transitionRects(b *viewShot, before []viewShot, now time.Time) { | |||||
| 616 | } | 631 | } | ||
| 617 | } | 632 | } | ||
| 618 | 633 | ||||
| 634 | + | // transitionOverview starts the motions of an action that opened or closed | |||
| 635 | + | // the overview, or moved inside it (the selection, the rows). The camera | |||
| 636 | + | // springs stay stopped (ToggleOverview): every window drawn before and | |||
| 637 | + | // after moves from its shown rect to its settled one, its content scaled | |||
| 638 | + | // with it (a card's Zoom, rectMotion.show). A window drawn after and not | |||
| 639 | + | // 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 | |||
| 641 | + | // other state are dropped first: their rects are not the ones snapshot | |||
| 642 | + | // measured from. | |||
| 643 | + | func (c *Core) transitionOverview(b *viewShot, now time.Time) { | |||
| 644 | + | sc := b.sc | |||
| 645 | + | if b.overview != sc.mon.ov.open { | |||
| 646 | + | sc.stopRects() | |||
| 647 | + | } | |||
| 648 | + | for _, p := range sc.mon.Layout() { | |||
| 649 | + | if p.Hidden { | |||
| 650 | + | continue | |||
| 651 | + | } | |||
| 652 | + | rm := sc.rects[p.ID] | |||
| 653 | + | i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID }) | |||
| 654 | + | if i < 0 { | |||
| 655 | + | c.retargetComponent(&rm.fade, &rm.df, 1, 0, now) | |||
| 656 | + | } else if old := &b.rects[i]; old.rect != p.Rect { | |||
| 657 | + | rm.scale = true | |||
| 658 | + | c.retargetComponent(&rm.x, &rm.dx, float64(old.rect.X-p.Rect.X)+old.off.x, old.vel.x, now) | |||
| 659 | + | c.retargetComponent(&rm.y, &rm.dy, float64(old.rect.Y-p.Rect.Y)+old.off.y, old.vel.y, now) | |||
| 660 | + | c.retargetComponent(&rm.w, &rm.dw, float64(old.rect.W-p.Rect.W)+old.off.w, old.vel.w, now) | |||
| 661 | + | c.retargetComponent(&rm.h, &rm.dh, float64(old.rect.H-p.Rect.H)+old.off.h, old.vel.h, now) | |||
| 662 | + | } else { | |||
| 663 | + | continue | |||
| 664 | + | } | |||
| 665 | + | if !rm.on() { | |||
| 666 | + | delete(sc.rects, p.ID) | |||
| 667 | + | continue | |||
| 668 | + | } | |||
| 669 | + | if sc.rects == nil { | |||
| 670 | + | sc.rects = make(map[WindowID]rectMotion) | |||
| 671 | + | } | |||
| 672 | + | sc.rects[p.ID], sc.rectsWS = rm, sc.rectsOwner() | |||
| 673 | + | } | |||
| 674 | + | } | |||
| 675 | + | ||||
| 619 | // movedFrom is the shot of the other screen that showed the workspace b's | 676 | // movedFrom is the shot of the other screen that showed the workspace b's | ||
| 620 | // screen shows now (moved to it by an action), or nil. | 677 | // screen shows now (moved to it by an action), or nil. | ||
| 621 | func movedFrom(b *viewShot, before []viewShot) *viewShot { | 678 | func movedFrom(b *viewShot, before []viewShot) *viewShot { | ||
| 622 | w := b.sc.mon.Current() | 679 | w := b.sc.mon.Current() | ||
| 623 | for i := range before { | 680 | for i := range before { | ||
| 624 | - | if o := &before[i]; o.ws == w && o.sc != b.sc { | 681 | + | if o := &before[i]; o.ws == w && o.sc != b.sc && !o.overview { |
| 625 | return o | 682 | return o | ||
| 626 | } | 683 | } | ||
| 627 | } | 684 | } | ||
A internal/core/transition_overview_internal_test.go +323 -0
| @@ -0,0 +1,323 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "math" | |
| 6 | + | "slices" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // overviewRig is indicatorCore with animations on, windows 1 and 2 in two | |
| 14 | + | // columns of the first workspace and window 3 alone on the second, settled, | |
| 15 | + | // the first workspace on screen. | |
| 16 | + | func overviewRig(t *testing.T) (*Core, *indicatorClock, *screen) { | |
| 17 | + | t.Helper() | |
| 18 | + | c, ic := indicatorCore(t) | |
| 19 | + | c.cfg.Animations.On = true | |
| 20 | + | sc := c.cur() | |
| 21 | + | m := sc.mon | |
| 22 | + | m.AddWindow(1) | |
| 23 | + | m.AddWindow(2) | |
| 24 | + | m.Focus(1) | |
| 25 | + | m.AddWindow(3) | |
| 26 | + | m.Focus(0) | |
| 27 | + | settleShown(t, c) | |
| 28 | + | return c, ic, sc | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // toggleOverview is what a bind does: snapshot, toggle, transition. | |
| 32 | + | func toggleOverview(c *Core, sc *screen, now time.Time) { | |
| 33 | + | shots := c.snapshot(now) | |
| 34 | + | sc.mon.ToggleOverview() | |
| 35 | + | c.transition(shots, now) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // frame moves the clock by d, steps the springs and returns the scene. | |
| 39 | + | func frame(t *testing.T, c *Core, ic *indicatorClock, d time.Duration) ports.Scene { | |
| 40 | + | t.Helper() | |
| 41 | + | ic.now = ic.now.Add(d) | |
| 42 | + | c.animate(ic.now, nil) | |
| 43 | + | return indicatorScene(t, c) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // contentScale is the scale the renderer draws the window's content at: | |
| 47 | + | // Zoom, else Preview, else 1 (vulkan walk). | |
| 48 | + | func contentScale(w ports.SceneWindow) float64 { | |
| 49 | + | if w.Zoom > 0 { | |
| 50 | + | return w.Zoom | |
| 51 | + | } | |
| 52 | + | if w.Preview > 0 { | |
| 53 | + | return w.Preview | |
| 54 | + | } | |
| 55 | + | return 1 | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // checkContent fails unless the content scale the renderer will use draws | |
| 59 | + | // the content at the frame's size (within rounding) and never magnifies it. | |
| 60 | + | func checkContent(t *testing.T, w ports.SceneWindow, full ports.Rect, when string) { | |
| 61 | + | t.Helper() | |
| 62 | + | k := contentScale(w) | |
| 63 | + | if k <= 0 || k > 1 { | |
| 64 | + | t.Fatalf("%s: content scale %v out of (0, 1]: %+v", when, k, w) | |
| 65 | + | } | |
| 66 | + | if got := k * float64(full.W); math.Abs(got-float64(w.Rect.W)) > 1.5 { | |
| 67 | + | t.Fatalf("%s: content drawn %.1f wide in a %d wide frame (scale %v, full %d): %+v", when, got, w.Rect.W, k, full.W, w) | |
| 68 | + | } | |
| 69 | + | if got := k * float64(full.H); math.Abs(got-float64(w.Rect.H)) > 1.5 { | |
| 70 | + | t.Fatalf("%s: content drawn %.1f high in a %d high frame (scale %v, full %d): %+v", when, got, w.Rect.H, k, full.H, w) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // Opening: a card starts at its window's rect with its content at full | |
| 75 | + | // size, shrinks to its overview card with the content following every frame, | |
| 76 | + | // and settles on the exact overview layout; the other rows fade in. Closing | |
| 77 | + | // mirrors it and animations off are instant. | |
| 78 | + | func TestOverviewOpenCloseAnimatesCards(t *testing.T) { | |
| 79 | + | c, ic, sc := overviewRig(t) | |
| 80 | + | full := map[WindowID]ports.Rect{} | |
| 81 | + | for _, id := range []WindowID{1, 2} { | |
| 82 | + | full[id] = sceneWindow(t, indicatorScene(t, c), id).Rect | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | toggleOverview(c, sc, ic.now) | |
| 86 | + | if !sc.mon.ov.open || !c.animating() { | |
| 87 | + | t.Fatalf("overview open %v, animating %v", sc.mon.ov.open, c.animating()) | |
| 88 | + | } | |
| 89 | + | s := indicatorScene(t, c) | |
| 90 | + | card := map[WindowID]Placement{} | |
| 91 | + | for _, p := range sc.settledLayout { | |
| 92 | + | card[p.ID] = p | |
| 93 | + | } | |
| 94 | + | for _, id := range []WindowID{1, 2} { | |
| 95 | + | w := sceneWindow(t, s, id) | |
| 96 | + | settled := sc.mon.Layout()[slices.IndexFunc(sc.mon.Layout(), func(p Placement) bool { return p.ID == id })] | |
| 97 | + | if w.Rect != full[id] || w.Preview != settled.Preview || w.Zoom != 1 { | |
| 98 | + | t.Fatalf("window %d first frame %+v, want the full rect %+v at content scale 1 (card preview %v)", id, w, full[id], settled.Preview) | |
| 99 | + | } | |
| 100 | + | checkContent(t, w, full[id], "first frame") | |
| 101 | + | } | |
| 102 | + | // The other row: invisible, drawn as a card, no motion of its own. | |
| 103 | + | w3 := sceneWindow(t, s, 3) | |
| 104 | + | if w3.Hidden || w3.Fade != 1 || w3.Preview == 0 || w3.Zoom != 0 { | |
| 105 | + | t.Fatalf("other row first frame %+v, want invisible card without zoom", w3) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | for i := range 6 { | |
| 109 | + | s = frame(t, c, ic, 25*time.Millisecond) | |
| 110 | + | for _, id := range []WindowID{1, 2} { | |
| 111 | + | w := sceneWindow(t, s, id) | |
| 112 | + | checkContent(t, w, full[id], "opening") | |
| 113 | + | if i == 1 { | |
| 114 | + | p := sc.mon.Layout()[slices.IndexFunc(sc.mon.Layout(), func(p Placement) bool { return p.ID == id })] | |
| 115 | + | if !(w.Rect.W < full[id].W && w.Rect.W > p.Rect.W) || !(w.Zoom > p.Preview && w.Zoom < 1) { | |
| 116 | + | t.Fatalf("window %d mid-way %+v, want a frame between %+v and %+v and a zoom between %v and 1", id, w, full[id], p.Rect, p.Preview) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | if i == 1 { | |
| 121 | + | if w := sceneWindow(t, s, 3); !(w.Fade > 0 && w.Fade < 1) { | |
| 122 | + | t.Fatalf("other row mid-way fade %v, want strictly between 0 and 1", w.Fade) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | s = frame(t, c, ic, 5*time.Second) | |
| 127 | + | for _, p := range sc.mon.Layout() { | |
| 128 | + | if p.Hidden { | |
| 129 | + | continue | |
| 130 | + | } | |
| 131 | + | w := sceneWindow(t, s, p.ID) | |
| 132 | + | if w.Rect != p.Rect || w.Zoom != 0 || w.Fade != 0 || w.Preview != p.Preview { | |
| 133 | + | t.Fatalf("settled window %d %+v, want the exact overview layout %+v", p.ID, w, p) | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | if c.animating() || len(sc.rects) != 0 { | |
| 137 | + | t.Fatalf("still animating after the settle: %d rects", len(sc.rects)) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // Close: the cards grow back to the windows, content following. | |
| 141 | + | toggleOverview(c, sc, ic.now) | |
| 142 | + | if sc.mon.ov.open { | |
| 143 | + | t.Fatal("the overview did not close") | |
| 144 | + | } | |
| 145 | + | s = indicatorScene(t, c) | |
| 146 | + | for _, id := range []WindowID{1, 2} { | |
| 147 | + | w := sceneWindow(t, s, id) | |
| 148 | + | if w.Preview != 0 || w.Rect != card[id].Rect { | |
| 149 | + | t.Fatalf("window %d first closing frame %+v, want the card rect %+v as a window", id, w, card[id].Rect) | |
| 150 | + | } | |
| 151 | + | checkContent(t, w, full[id], "first closing frame") | |
| 152 | + | } | |
| 153 | + | for i := range 6 { | |
| 154 | + | s = frame(t, c, ic, 25*time.Millisecond) | |
| 155 | + | for _, id := range []WindowID{1, 2} { | |
| 156 | + | w := sceneWindow(t, s, id) | |
| 157 | + | checkContent(t, w, full[id], "closing") | |
| 158 | + | if i == 1 && !(w.Rect.W > card[id].Rect.W && w.Rect.W < full[id].W && w.Zoom > 0 && w.Zoom < 1) { | |
| 159 | + | t.Fatalf("window %d mid-way closing %+v", id, w) | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | s = frame(t, c, ic, 5*time.Second) | |
| 164 | + | for _, id := range []WindowID{1, 2} { | |
| 165 | + | if w := sceneWindow(t, s, id); w.Rect != full[id] || w.Zoom != 0 || w.Preview != 0 { | |
| 166 | + | t.Fatalf("window %d settled after closing %+v, want %+v", id, w, full[id]) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | if w := sceneWindow(t, s, 3); !w.Hidden { | |
| 170 | + | t.Fatalf("window 3 %+v after closing, want hidden (no fade-out)", w) | |
| 171 | + | } | |
| 172 | + | if c.animating() { | |
| 173 | + | t.Fatal("still animating after closing") | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // With animations off the overview opens and closes at once. | |
| 178 | + | func TestOverviewOffIsInstant(t *testing.T) { | |
| 179 | + | c, ic, sc := overviewRig(t) | |
| 180 | + | c.cfg.Animations.On = false | |
| 181 | + | toggleOverview(c, sc, ic.now) | |
| 182 | + | s := indicatorScene(t, c) | |
| 183 | + | for _, p := range sc.mon.Layout() { | |
| 184 | + | if p.Hidden { | |
| 185 | + | continue | |
| 186 | + | } | |
| 187 | + | if w := sceneWindow(t, s, p.ID); w.Rect != p.Rect || w.Zoom != 0 || w.Fade != 0 { | |
| 188 | + | t.Fatalf("window %d %+v with animations off, want %+v", p.ID, w, p) | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | if c.animating() || len(sc.rects) != 0 { | |
| 192 | + | t.Fatal("a motion runs with animations off") | |
| 193 | + | } | |
| 194 | + | toggleOverview(c, sc, ic.now) | |
| 195 | + | if indicatorScene(t, c); c.animating() || len(sc.rects) != 0 { | |
| 196 | + | t.Fatal("a motion runs after closing with animations off") | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // Moving the selection inside the open overview slides the cards (its rows | |
| 201 | + | // scroll): same rect motions, contents scaled; the row that comes in fades. | |
| 202 | + | func TestOverviewNavigationAnimates(t *testing.T) { | |
| 203 | + | c, ic, sc := overviewRig(t) | |
| 204 | + | toggleOverview(c, sc, ic.now) | |
| 205 | + | s := frame(t, c, ic, 5*time.Second) | |
| 206 | + | before := map[WindowID]ports.SceneWindow{} | |
| 207 | + | for _, w := range s.Windows { | |
| 208 | + | before[w.ID] = w | |
| 209 | + | } | |
| 210 | + | if !before[3].Hidden && before[3].Fade != 0 { | |
| 211 | + | t.Fatalf("setup: %+v", before[3]) | |
| 212 | + | } | |
| 213 | + | // Down: the second row becomes the current one; rows scroll up. | |
| 214 | + | shots := c.snapshot(ic.now) | |
| 215 | + | sc.mon.OverviewMove(0, 1) | |
| 216 | + | c.transition(shots, ic.now) | |
| 217 | + | s = indicatorScene(t, c) | |
| 218 | + | moved := 0 | |
| 219 | + | for _, w := range s.Windows { | |
| 220 | + | if w.Hidden { | |
| 221 | + | continue | |
| 222 | + | } | |
| 223 | + | b, ok := before[w.ID] | |
| 224 | + | if !ok || b.Hidden { | |
| 225 | + | continue | |
| 226 | + | } | |
| 227 | + | if w.Rect != b.Rect { | |
| 228 | + | t.Fatalf("window %d jumped on the first frame: %+v then %+v", w.ID, b.Rect, w.Rect) | |
| 229 | + | } | |
| 230 | + | moved++ | |
| 231 | + | } | |
| 232 | + | if moved == 0 { | |
| 233 | + | t.Fatalf("no card was shown before and after: %+v", s.Windows) | |
| 234 | + | } | |
| 235 | + | s = frame(t, c, ic, 40*time.Millisecond) | |
| 236 | + | slid := false | |
| 237 | + | for _, w := range s.Windows { | |
| 238 | + | if b, ok := before[w.ID]; ok && !b.Hidden && !w.Hidden && w.Rect != b.Rect { | |
| 239 | + | slid = true | |
| 240 | + | // A card moving between cards keeps its content scale. | |
| 241 | + | if k := contentScale(w); k <= 0 || k > 1 { | |
| 242 | + | t.Fatalf("window %d scale %v", w.ID, k) | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | } | |
| 246 | + | if !slid { | |
| 247 | + | t.Fatal("no card moved mid-way") | |
| 248 | + | } | |
| 249 | + | s = frame(t, c, ic, 5*time.Second) | |
| 250 | + | for _, p := range sc.mon.Layout() { | |
| 251 | + | if p.Hidden { | |
| 252 | + | continue | |
| 253 | + | } | |
| 254 | + | if w := sceneWindow(t, s, p.ID); w.Rect != p.Rect || w.Zoom != 0 || w.Fade != 0 { | |
| 255 | + | t.Fatalf("window %d settled %+v, want %+v", p.ID, w, p) | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | // A click while the cards move picks the card where it is drawn (the shown | |
| 261 | + | // layout), and a click on a still overview is unchanged. | |
| 262 | + | func TestOverviewClickDuringOpenUsesShownCards(t *testing.T) { | |
| 263 | + | c, ic, sc := overviewRig(t) | |
| 264 | + | toggleOverview(c, sc, ic.now) | |
| 265 | + | s := frame(t, c, ic, 30*time.Millisecond) | |
| 266 | + | // The pointer sits on the right half of the output: the shown card of | |
| 267 | + | // window 2 covers it; its settled card does not reach that far left. | |
| 268 | + | w2 := sceneWindow(t, s, 2) | |
| 269 | + | x, y := float64(w2.Rect.X+w2.Rect.W-1), float64(w2.Rect.Y+w2.Rect.H/2) | |
| 270 | + | settled := sc.mon.Layout()[slices.IndexFunc(sc.mon.Layout(), func(p Placement) bool { return p.ID == 2 })] | |
| 271 | + | if x < float64(settled.Rect.X+settled.Rect.W) { | |
| 272 | + | t.Fatalf("setup: x %v is inside the settled card %+v", x, settled.Rect) | |
| 273 | + | } | |
| 274 | + | if id := sc.mon.overviewAt(x, y); id == 2 { | |
| 275 | + | t.Fatalf("setup: the settled layout picks 2 at %v,%v", x, y) | |
| 276 | + | } | |
| 277 | + | c.cursorX, c.cursorY = x+float64(sc.x), y+float64(sc.y) | |
| 278 | + | picked, err := c.overviewClick(context.Background()) | |
| 279 | + | if err != nil || !picked { | |
| 280 | + | t.Fatalf("picked %v err %v during the opening", picked, err) | |
| 281 | + | } | |
| 282 | + | if sc.mon.ov.open { | |
| 283 | + | t.Fatal("the overview stayed open") | |
| 284 | + | } | |
| 285 | + | if id, _ := sc.mon.Focused(); id != 2 { | |
| 286 | + | t.Fatalf("focused %d, want 2 (the card drawn there)", id) | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | // A scale motion on a card never magnifies content: a shown frame wider than | |
| 291 | + | // the card's settled one gives at most scale 1, and a card in flight at its | |
| 292 | + | // full size still carries Zoom 1 (Preview alone would shrink it). | |
| 293 | + | func TestCardZoomClampedAndEmitted(t *testing.T) { | |
| 294 | + | var rm rectMotion | |
| 295 | + | rm.scale, rm.dw = true, 400 | |
| 296 | + | p := Placement{ID: 1, Rect: Rect{W: 100, H: 60}, Preview: overviewCardZoom} | |
| 297 | + | rm.show(&p) | |
| 298 | + | if p.Zoom != 1 || p.Preview != overviewCardZoom { | |
| 299 | + | t.Fatalf("shown %+v, want zoom clamped to 1 and the card's preview", p) | |
| 300 | + | } | |
| 301 | + | c, ic, sc := overviewRig(t) | |
| 302 | + | toggleOverview(c, sc, ic.now) | |
| 303 | + | if w := sceneWindow(t, indicatorScene(t, c), 1); w.Zoom != 1 || w.Preview == 0 { | |
| 304 | + | t.Fatalf("card at full size emitted %+v, want Zoom 1 with its Preview", w) | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | // Rect motions belong to the overview state: a state change with no | |
| 309 | + | // transition (animations off meanwhile, a session lock) drops them, and | |
| 310 | + | // leaving the overview never keeps a card's motion on a window. | |
| 311 | + | func TestOverviewRectsDroppedOnStateChange(t *testing.T) { | |
| 312 | + | c, ic, sc := overviewRig(t) | |
| 313 | + | toggleOverview(c, sc, ic.now) | |
| 314 | + | indicatorScene(t, c) | |
| 315 | + | if len(sc.rects) == 0 { | |
| 316 | + | t.Fatal("no motion for the opening") | |
| 317 | + | } | |
| 318 | + | sc.mon.ToggleOverview() | |
| 319 | + | indicatorScene(t, c) | |
| 320 | + | if len(sc.rects) != 0 { | |
| 321 | + | t.Fatalf("%d card motions survived the close without a transition", len(sc.rects)) | |
| 322 | + | } | |
| 323 | + | } | |
| @@ -0,0 +1,323 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "math" | |||
| 6 | + | "slices" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // overviewRig is indicatorCore with animations on, windows 1 and 2 in two | |||
| 14 | + | // columns of the first workspace and window 3 alone on the second, settled, | |||
| 15 | + | // the first workspace on screen. | |||
| 16 | + | func overviewRig(t *testing.T) (*Core, *indicatorClock, *screen) { | |||
| 17 | + | t.Helper() | |||
| 18 | + | c, ic := indicatorCore(t) | |||
| 19 | + | c.cfg.Animations.On = true | |||
| 20 | + | sc := c.cur() | |||
| 21 | + | m := sc.mon | |||
| 22 | + | m.AddWindow(1) | |||
| 23 | + | m.AddWindow(2) | |||
| 24 | + | m.Focus(1) | |||
| 25 | + | m.AddWindow(3) | |||
| 26 | + | m.Focus(0) | |||
| 27 | + | settleShown(t, c) | |||
| 28 | + | return c, ic, sc | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // toggleOverview is what a bind does: snapshot, toggle, transition. | |||
| 32 | + | func toggleOverview(c *Core, sc *screen, now time.Time) { | |||
| 33 | + | shots := c.snapshot(now) | |||
| 34 | + | sc.mon.ToggleOverview() | |||
| 35 | + | c.transition(shots, now) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // frame moves the clock by d, steps the springs and returns the scene. | |||
| 39 | + | func frame(t *testing.T, c *Core, ic *indicatorClock, d time.Duration) ports.Scene { | |||
| 40 | + | t.Helper() | |||
| 41 | + | ic.now = ic.now.Add(d) | |||
| 42 | + | c.animate(ic.now, nil) | |||
| 43 | + | return indicatorScene(t, c) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // contentScale is the scale the renderer draws the window's content at: | |||
| 47 | + | // Zoom, else Preview, else 1 (vulkan walk). | |||
| 48 | + | func contentScale(w ports.SceneWindow) float64 { | |||
| 49 | + | if w.Zoom > 0 { | |||
| 50 | + | return w.Zoom | |||
| 51 | + | } | |||
| 52 | + | if w.Preview > 0 { | |||
| 53 | + | return w.Preview | |||
| 54 | + | } | |||
| 55 | + | return 1 | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // checkContent fails unless the content scale the renderer will use draws | |||
| 59 | + | // the content at the frame's size (within rounding) and never magnifies it. | |||
| 60 | + | func checkContent(t *testing.T, w ports.SceneWindow, full ports.Rect, when string) { | |||
| 61 | + | t.Helper() | |||
| 62 | + | k := contentScale(w) | |||
| 63 | + | if k <= 0 || k > 1 { | |||
| 64 | + | t.Fatalf("%s: content scale %v out of (0, 1]: %+v", when, k, w) | |||
| 65 | + | } | |||
| 66 | + | if got := k * float64(full.W); math.Abs(got-float64(w.Rect.W)) > 1.5 { | |||
| 67 | + | t.Fatalf("%s: content drawn %.1f wide in a %d wide frame (scale %v, full %d): %+v", when, got, w.Rect.W, k, full.W, w) | |||
| 68 | + | } | |||
| 69 | + | if got := k * float64(full.H); math.Abs(got-float64(w.Rect.H)) > 1.5 { | |||
| 70 | + | t.Fatalf("%s: content drawn %.1f high in a %d high frame (scale %v, full %d): %+v", when, got, w.Rect.H, k, full.H, w) | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // Opening: a card starts at its window's rect with its content at full | |||
| 75 | + | // size, shrinks to its overview card with the content following every frame, | |||
| 76 | + | // and settles on the exact overview layout; the other rows fade in. Closing | |||
| 77 | + | // mirrors it and animations off are instant. | |||
| 78 | + | func TestOverviewOpenCloseAnimatesCards(t *testing.T) { | |||
| 79 | + | c, ic, sc := overviewRig(t) | |||
| 80 | + | full := map[WindowID]ports.Rect{} | |||
| 81 | + | for _, id := range []WindowID{1, 2} { | |||
| 82 | + | full[id] = sceneWindow(t, indicatorScene(t, c), id).Rect | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | toggleOverview(c, sc, ic.now) | |||
| 86 | + | if !sc.mon.ov.open || !c.animating() { | |||
| 87 | + | t.Fatalf("overview open %v, animating %v", sc.mon.ov.open, c.animating()) | |||
| 88 | + | } | |||
| 89 | + | s := indicatorScene(t, c) | |||
| 90 | + | card := map[WindowID]Placement{} | |||
| 91 | + | for _, p := range sc.settledLayout { | |||
| 92 | + | card[p.ID] = p | |||
| 93 | + | } | |||
| 94 | + | for _, id := range []WindowID{1, 2} { | |||
| 95 | + | w := sceneWindow(t, s, id) | |||
| 96 | + | settled := sc.mon.Layout()[slices.IndexFunc(sc.mon.Layout(), func(p Placement) bool { return p.ID == id })] | |||
| 97 | + | if w.Rect != full[id] || w.Preview != settled.Preview || w.Zoom != 1 { | |||
| 98 | + | t.Fatalf("window %d first frame %+v, want the full rect %+v at content scale 1 (card preview %v)", id, w, full[id], settled.Preview) | |||
| 99 | + | } | |||
| 100 | + | checkContent(t, w, full[id], "first frame") | |||
| 101 | + | } | |||
| 102 | + | // The other row: invisible, drawn as a card, no motion of its own. | |||
| 103 | + | w3 := sceneWindow(t, s, 3) | |||
| 104 | + | if w3.Hidden || w3.Fade != 1 || w3.Preview == 0 || w3.Zoom != 0 { | |||
| 105 | + | t.Fatalf("other row first frame %+v, want invisible card without zoom", w3) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | for i := range 6 { | |||
| 109 | + | s = frame(t, c, ic, 25*time.Millisecond) | |||
| 110 | + | for _, id := range []WindowID{1, 2} { | |||
| 111 | + | w := sceneWindow(t, s, id) | |||
| 112 | + | checkContent(t, w, full[id], "opening") | |||
| 113 | + | if i == 1 { | |||
| 114 | + | p := sc.mon.Layout()[slices.IndexFunc(sc.mon.Layout(), func(p Placement) bool { return p.ID == id })] | |||
| 115 | + | if !(w.Rect.W < full[id].W && w.Rect.W > p.Rect.W) || !(w.Zoom > p.Preview && w.Zoom < 1) { | |||
| 116 | + | t.Fatalf("window %d mid-way %+v, want a frame between %+v and %+v and a zoom between %v and 1", id, w, full[id], p.Rect, p.Preview) | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | if i == 1 { | |||
| 121 | + | if w := sceneWindow(t, s, 3); !(w.Fade > 0 && w.Fade < 1) { | |||
| 122 | + | t.Fatalf("other row mid-way fade %v, want strictly between 0 and 1", w.Fade) | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | s = frame(t, c, ic, 5*time.Second) | |||
| 127 | + | for _, p := range sc.mon.Layout() { | |||
| 128 | + | if p.Hidden { | |||
| 129 | + | continue | |||
| 130 | + | } | |||
| 131 | + | w := sceneWindow(t, s, p.ID) | |||
| 132 | + | if w.Rect != p.Rect || w.Zoom != 0 || w.Fade != 0 || w.Preview != p.Preview { | |||
| 133 | + | t.Fatalf("settled window %d %+v, want the exact overview layout %+v", p.ID, w, p) | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | if c.animating() || len(sc.rects) != 0 { | |||
| 137 | + | t.Fatalf("still animating after the settle: %d rects", len(sc.rects)) | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // Close: the cards grow back to the windows, content following. | |||
| 141 | + | toggleOverview(c, sc, ic.now) | |||
| 142 | + | if sc.mon.ov.open { | |||
| 143 | + | t.Fatal("the overview did not close") | |||
| 144 | + | } | |||
| 145 | + | s = indicatorScene(t, c) | |||
| 146 | + | for _, id := range []WindowID{1, 2} { | |||
| 147 | + | w := sceneWindow(t, s, id) | |||
| 148 | + | if w.Preview != 0 || w.Rect != card[id].Rect { | |||
| 149 | + | t.Fatalf("window %d first closing frame %+v, want the card rect %+v as a window", id, w, card[id].Rect) | |||
| 150 | + | } | |||
| 151 | + | checkContent(t, w, full[id], "first closing frame") | |||
| 152 | + | } | |||
| 153 | + | for i := range 6 { | |||
| 154 | + | s = frame(t, c, ic, 25*time.Millisecond) | |||
| 155 | + | for _, id := range []WindowID{1, 2} { | |||
| 156 | + | w := sceneWindow(t, s, id) | |||
| 157 | + | checkContent(t, w, full[id], "closing") | |||
| 158 | + | if i == 1 && !(w.Rect.W > card[id].Rect.W && w.Rect.W < full[id].W && w.Zoom > 0 && w.Zoom < 1) { | |||
| 159 | + | t.Fatalf("window %d mid-way closing %+v", id, w) | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | s = frame(t, c, ic, 5*time.Second) | |||
| 164 | + | for _, id := range []WindowID{1, 2} { | |||
| 165 | + | if w := sceneWindow(t, s, id); w.Rect != full[id] || w.Zoom != 0 || w.Preview != 0 { | |||
| 166 | + | t.Fatalf("window %d settled after closing %+v, want %+v", id, w, full[id]) | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | if w := sceneWindow(t, s, 3); !w.Hidden { | |||
| 170 | + | t.Fatalf("window 3 %+v after closing, want hidden (no fade-out)", w) | |||
| 171 | + | } | |||
| 172 | + | if c.animating() { | |||
| 173 | + | t.Fatal("still animating after closing") | |||
| 174 | + | } | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // With animations off the overview opens and closes at once. | |||
| 178 | + | func TestOverviewOffIsInstant(t *testing.T) { | |||
| 179 | + | c, ic, sc := overviewRig(t) | |||
| 180 | + | c.cfg.Animations.On = false | |||
| 181 | + | toggleOverview(c, sc, ic.now) | |||
| 182 | + | s := indicatorScene(t, c) | |||
| 183 | + | for _, p := range sc.mon.Layout() { | |||
| 184 | + | if p.Hidden { | |||
| 185 | + | continue | |||
| 186 | + | } | |||
| 187 | + | if w := sceneWindow(t, s, p.ID); w.Rect != p.Rect || w.Zoom != 0 || w.Fade != 0 { | |||
| 188 | + | t.Fatalf("window %d %+v with animations off, want %+v", p.ID, w, p) | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | if c.animating() || len(sc.rects) != 0 { | |||
| 192 | + | t.Fatal("a motion runs with animations off") | |||
| 193 | + | } | |||
| 194 | + | toggleOverview(c, sc, ic.now) | |||
| 195 | + | if indicatorScene(t, c); c.animating() || len(sc.rects) != 0 { | |||
| 196 | + | t.Fatal("a motion runs after closing with animations off") | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | // Moving the selection inside the open overview slides the cards (its rows | |||
| 201 | + | // scroll): same rect motions, contents scaled; the row that comes in fades. | |||
| 202 | + | func TestOverviewNavigationAnimates(t *testing.T) { | |||
| 203 | + | c, ic, sc := overviewRig(t) | |||
| 204 | + | toggleOverview(c, sc, ic.now) | |||
| 205 | + | s := frame(t, c, ic, 5*time.Second) | |||
| 206 | + | before := map[WindowID]ports.SceneWindow{} | |||
| 207 | + | for _, w := range s.Windows { | |||
| 208 | + | before[w.ID] = w | |||
| 209 | + | } | |||
| 210 | + | if !before[3].Hidden && before[3].Fade != 0 { | |||
| 211 | + | t.Fatalf("setup: %+v", before[3]) | |||
| 212 | + | } | |||
| 213 | + | // Down: the second row becomes the current one; rows scroll up. | |||
| 214 | + | shots := c.snapshot(ic.now) | |||
| 215 | + | sc.mon.OverviewMove(0, 1) | |||
| 216 | + | c.transition(shots, ic.now) | |||
| 217 | + | s = indicatorScene(t, c) | |||
| 218 | + | moved := 0 | |||
| 219 | + | for _, w := range s.Windows { | |||
| 220 | + | if w.Hidden { | |||
| 221 | + | continue | |||
| 222 | + | } | |||
| 223 | + | b, ok := before[w.ID] | |||
| 224 | + | if !ok || b.Hidden { | |||
| 225 | + | continue | |||
| 226 | + | } | |||
| 227 | + | if w.Rect != b.Rect { | |||
| 228 | + | t.Fatalf("window %d jumped on the first frame: %+v then %+v", w.ID, b.Rect, w.Rect) | |||
| 229 | + | } | |||
| 230 | + | moved++ | |||
| 231 | + | } | |||
| 232 | + | if moved == 0 { | |||
| 233 | + | t.Fatalf("no card was shown before and after: %+v", s.Windows) | |||
| 234 | + | } | |||
| 235 | + | s = frame(t, c, ic, 40*time.Millisecond) | |||
| 236 | + | slid := false | |||
| 237 | + | for _, w := range s.Windows { | |||
| 238 | + | if b, ok := before[w.ID]; ok && !b.Hidden && !w.Hidden && w.Rect != b.Rect { | |||
| 239 | + | slid = true | |||
| 240 | + | // A card moving between cards keeps its content scale. | |||
| 241 | + | if k := contentScale(w); k <= 0 || k > 1 { | |||
| 242 | + | t.Fatalf("window %d scale %v", w.ID, k) | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | } | |||
| 246 | + | if !slid { | |||
| 247 | + | t.Fatal("no card moved mid-way") | |||
| 248 | + | } | |||
| 249 | + | s = frame(t, c, ic, 5*time.Second) | |||
| 250 | + | for _, p := range sc.mon.Layout() { | |||
| 251 | + | if p.Hidden { | |||
| 252 | + | continue | |||
| 253 | + | } | |||
| 254 | + | if w := sceneWindow(t, s, p.ID); w.Rect != p.Rect || w.Zoom != 0 || w.Fade != 0 { | |||
| 255 | + | t.Fatalf("window %d settled %+v, want %+v", p.ID, w, p) | |||
| 256 | + | } | |||
| 257 | + | } | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | // A click while the cards move picks the card where it is drawn (the shown | |||
| 261 | + | // layout), and a click on a still overview is unchanged. | |||
| 262 | + | func TestOverviewClickDuringOpenUsesShownCards(t *testing.T) { | |||
| 263 | + | c, ic, sc := overviewRig(t) | |||
| 264 | + | toggleOverview(c, sc, ic.now) | |||
| 265 | + | s := frame(t, c, ic, 30*time.Millisecond) | |||
| 266 | + | // The pointer sits on the right half of the output: the shown card of | |||
| 267 | + | // window 2 covers it; its settled card does not reach that far left. | |||
| 268 | + | w2 := sceneWindow(t, s, 2) | |||
| 269 | + | x, y := float64(w2.Rect.X+w2.Rect.W-1), float64(w2.Rect.Y+w2.Rect.H/2) | |||
| 270 | + | settled := sc.mon.Layout()[slices.IndexFunc(sc.mon.Layout(), func(p Placement) bool { return p.ID == 2 })] | |||
| 271 | + | if x < float64(settled.Rect.X+settled.Rect.W) { | |||
| 272 | + | t.Fatalf("setup: x %v is inside the settled card %+v", x, settled.Rect) | |||
| 273 | + | } | |||
| 274 | + | if id := sc.mon.overviewAt(x, y); id == 2 { | |||
| 275 | + | t.Fatalf("setup: the settled layout picks 2 at %v,%v", x, y) | |||
| 276 | + | } | |||
| 277 | + | c.cursorX, c.cursorY = x+float64(sc.x), y+float64(sc.y) | |||
| 278 | + | picked, err := c.overviewClick(context.Background()) | |||
| 279 | + | if err != nil || !picked { | |||
| 280 | + | t.Fatalf("picked %v err %v during the opening", picked, err) | |||
| 281 | + | } | |||
| 282 | + | if sc.mon.ov.open { | |||
| 283 | + | t.Fatal("the overview stayed open") | |||
| 284 | + | } | |||
| 285 | + | if id, _ := sc.mon.Focused(); id != 2 { | |||
| 286 | + | t.Fatalf("focused %d, want 2 (the card drawn there)", id) | |||
| 287 | + | } | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | // A scale motion on a card never magnifies content: a shown frame wider than | |||
| 291 | + | // the card's settled one gives at most scale 1, and a card in flight at its | |||
| 292 | + | // full size still carries Zoom 1 (Preview alone would shrink it). | |||
| 293 | + | func TestCardZoomClampedAndEmitted(t *testing.T) { | |||
| 294 | + | var rm rectMotion | |||
| 295 | + | rm.scale, rm.dw = true, 400 | |||
| 296 | + | p := Placement{ID: 1, Rect: Rect{W: 100, H: 60}, Preview: overviewCardZoom} | |||
| 297 | + | rm.show(&p) | |||
| 298 | + | if p.Zoom != 1 || p.Preview != overviewCardZoom { | |||
| 299 | + | t.Fatalf("shown %+v, want zoom clamped to 1 and the card's preview", p) | |||
| 300 | + | } | |||
| 301 | + | c, ic, sc := overviewRig(t) | |||
| 302 | + | toggleOverview(c, sc, ic.now) | |||
| 303 | + | if w := sceneWindow(t, indicatorScene(t, c), 1); w.Zoom != 1 || w.Preview == 0 { | |||
| 304 | + | t.Fatalf("card at full size emitted %+v, want Zoom 1 with its Preview", w) | |||
| 305 | + | } | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | // Rect motions belong to the overview state: a state change with no | |||
| 309 | + | // transition (animations off meanwhile, a session lock) drops them, and | |||
| 310 | + | // leaving the overview never keeps a card's motion on a window. | |||
| 311 | + | func TestOverviewRectsDroppedOnStateChange(t *testing.T) { | |||
| 312 | + | c, ic, sc := overviewRig(t) | |||
| 313 | + | toggleOverview(c, sc, ic.now) | |||
| 314 | + | indicatorScene(t, c) | |||
| 315 | + | if len(sc.rects) == 0 { | |||
| 316 | + | t.Fatal("no motion for the opening") | |||
| 317 | + | } | |||
| 318 | + | sc.mon.ToggleOverview() | |||
| 319 | + | indicatorScene(t, c) | |||
| 320 | + | if len(sc.rects) != 0 { | |||
| 321 | + | t.Fatalf("%d card motions survived the close without a transition", len(sc.rects)) | |||
| 322 | + | } | |||
| 323 | + | } | |||
M internal/core/transition_test.go +7 -3
| @@ -130,9 +130,9 @@ func TestAnimationsOffKeysLandAtOnce(t *testing.T) { | |||
| 130 | 130 | r.noFrameScene(t) | |
| 131 | 131 | } | |
| 132 | 132 | ||
| 133 | - | // With the overview open before or after, nothing slides: opening it on a | |
| 134 | - | // scrolled view starts no motion, and neither does a bind that moves the view | |
| 135 | - | // while it is open. | |
| 133 | + | // The overview has no camera: opening it on a scrolled view, a bind that | |
| 134 | + | // moves the selection and closing it only move the cards (rect motions), which | |
| 135 | + | // settle: no spring runs afterwards and nothing slides the view. | |
| 136 | 136 | func TestNoTransitionWithOverview(t *testing.T) { | |
| 137 | 137 | for _, tc := range []struct { | |
| 138 | 138 | name string | |
| @@ -150,13 +150,17 @@ func TestNoTransitionWithOverview(t *testing.T) { | |||
| 150 | 150 | r.key(t, "Right", ports.ModAlt) // 2 is on screen: no scroll | |
| 151 | 151 | r.keySettled(t, "Right") // the view scrolls away from 0 | |
| 152 | 152 | r.keySettled(t, "Right") | |
| 153 | + | // settleAll: a bind that moves nothing starts no spring. | |
| 153 | 154 | r.key(t, "o", ports.ModAlt) | |
| 155 | + | r.settleAll(t, wide.Name) | |
| 154 | 156 | r.noFrameScene(t) | |
| 155 | 157 | if tc.sym != "" { | |
| 156 | 158 | r.key(t, tc.sym, tc.mods) | |
| 159 | + | r.settleAll(t, wide.Name) | |
| 157 | 160 | r.noFrameScene(t) | |
| 158 | 161 | } | |
| 159 | 162 | r.key(t, "o", ports.ModAlt) | |
| 163 | + | r.settleAll(t, wide.Name) | |
| 160 | 164 | r.noFrameScene(t) | |
| 161 | 165 | }) | |
| 162 | 166 | } | |
| @@ -130,9 +130,9 @@ func TestAnimationsOffKeysLandAtOnce(t *testing.T) { | |||||
| 130 | r.noFrameScene(t) | 130 | r.noFrameScene(t) | ||
| 131 | } | 131 | } | ||
| 132 | 132 | ||||
| 133 | - | // With the overview open before or after, nothing slides: opening it on a | 133 | + | // The overview has no camera: opening it on a scrolled view, a bind that |
| 134 | - | // scrolled view starts no motion, and neither does a bind that moves the view | 134 | + | // moves the selection and closing it only move the cards (rect motions), which |
| 135 | - | // while it is open. | 135 | + | // settle: no spring runs afterwards and nothing slides the view. |
| 136 | func TestNoTransitionWithOverview(t *testing.T) { | 136 | func TestNoTransitionWithOverview(t *testing.T) { | ||
| 137 | for _, tc := range []struct { | 137 | for _, tc := range []struct { | ||
| 138 | name string | 138 | name string | ||
| @@ -150,13 +150,17 @@ func TestNoTransitionWithOverview(t *testing.T) { | |||||
| 150 | r.key(t, "Right", ports.ModAlt) // 2 is on screen: no scroll | 150 | r.key(t, "Right", ports.ModAlt) // 2 is on screen: no scroll | ||
| 151 | r.keySettled(t, "Right") // the view scrolls away from 0 | 151 | r.keySettled(t, "Right") // the view scrolls away from 0 | ||
| 152 | r.keySettled(t, "Right") | 152 | r.keySettled(t, "Right") | ||
| 153 | + | // settleAll: a bind that moves nothing starts no spring. | |||
| 153 | r.key(t, "o", ports.ModAlt) | 154 | r.key(t, "o", ports.ModAlt) | ||
| 155 | + | r.settleAll(t, wide.Name) | |||
| 154 | r.noFrameScene(t) | 156 | r.noFrameScene(t) | ||
| 155 | if tc.sym != "" { | 157 | if tc.sym != "" { | ||
| 156 | r.key(t, tc.sym, tc.mods) | 158 | r.key(t, tc.sym, tc.mods) | ||
| 159 | + | r.settleAll(t, wide.Name) | |||
| 157 | r.noFrameScene(t) | 160 | r.noFrameScene(t) | ||
| 158 | } | 161 | } | ||
| 159 | r.key(t, "o", ports.ModAlt) | 162 | r.key(t, "o", ports.ModAlt) | ||
| 163 | + | r.settleAll(t, wide.Name) | |||
| 160 | r.noFrameScene(t) | 164 | r.noFrameScene(t) | ||
| 161 | }) | 165 | }) | ||
| 162 | } | 166 | } | ||