fix(core): chain rect, camera and switch motions from their exact state at the action time
snapshot(now) samples a copy of every running motion at now for value and speed, and rectShot keeps the settled rect plus the float offsets, so a retarget no longer starts from the rounded rect or the last frame's speed. publish(ctx) wraps publishFrame(ctx, only); c.stepping is gone.
- commit
- e92a9c4cb0a5eb9dab207237b683013f19238f7a
- author
- brice · · 2026-10-04 20:55 UTC
- committer
- brice · · 2026-10-04 21:04 UTC
- parent
- a126bb4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +314 -84
M internal/core/animations_interrupt_internal_test.go +2 -2
| @@ -18,7 +18,7 @@ func movingRects(t *testing.T, c *Core, ic *indicatorClock, sc *screen) { | |||
| 18 | 18 | sc.mon.AddWindow(2) | |
| 19 | 19 | settleShown(t, c) | |
| 20 | 20 | c.focusScreen = c.screenIndex(sc.name()) | |
| 21 | - | before := c.snapshot() | |
| 21 | + | before := c.snapshot(ic.now) | |
| 22 | 22 | c.applyAction(ActionMoveColumnLeft) | |
| 23 | 23 | c.transition(before, ic.now) | |
| 24 | 24 | if len(sc.rects) != 2 { | |
| @@ -173,7 +173,7 @@ func TestLastOutputUnplugMidRectMotion(t *testing.T) { | |||
| 173 | 173 | } | |
| 174 | 174 | ||
| 175 | 175 | // A new move animates from the settled rects and lands. | |
| 176 | - | before := c.snapshot() | |
| 176 | + | before := c.snapshot(ic.now) | |
| 177 | 177 | c.applyAction(ActionMoveColumnRight) | |
| 178 | 178 | c.transition(before, ic.now) | |
| 179 | 179 | start := indicatorScene(t, c) | |
| @@ -18,7 +18,7 @@ func movingRects(t *testing.T, c *Core, ic *indicatorClock, sc *screen) { | |||||
| 18 | sc.mon.AddWindow(2) | 18 | sc.mon.AddWindow(2) | ||
| 19 | settleShown(t, c) | 19 | settleShown(t, c) | ||
| 20 | c.focusScreen = c.screenIndex(sc.name()) | 20 | c.focusScreen = c.screenIndex(sc.name()) | ||
| 21 | - | before := c.snapshot() | 21 | + | before := c.snapshot(ic.now) |
| 22 | c.applyAction(ActionMoveColumnLeft) | 22 | c.applyAction(ActionMoveColumnLeft) | ||
| 23 | c.transition(before, ic.now) | 23 | c.transition(before, ic.now) | ||
| 24 | if len(sc.rects) != 2 { | 24 | if len(sc.rects) != 2 { | ||
| @@ -173,7 +173,7 @@ func TestLastOutputUnplugMidRectMotion(t *testing.T) { | |||||
| 173 | } | 173 | } | ||
| 174 | 174 | ||||
| 175 | // A new move animates from the settled rects and lands. | 175 | // A new move animates from the settled rects and lands. | ||
| 176 | - | before := c.snapshot() | 176 | + | before := c.snapshot(ic.now) |
| 177 | c.applyAction(ActionMoveColumnRight) | 177 | c.applyAction(ActionMoveColumnRight) | ||
| 178 | c.transition(before, ic.now) | 178 | c.transition(before, ic.now) | ||
| 179 | start := indicatorScene(t, c) | 179 | start := indicatorScene(t, c) | ||
M internal/core/core.go +17 -11
| @@ -132,9 +132,6 @@ type Core struct { | |||
| 132 | 132 | swipe *swipeGesture | |
| 133 | 133 | frameC <-chan time.Time | |
| 134 | 134 | frameStop func() bool | |
| 135 | - | // stepping is the screen a page flip advances (nil: all of them); it is | |
| 136 | - | // set while step publishes. | |
| 137 | - | stepping *screen | |
| 138 | 135 | // shots is the snapshot of what the screens show before an action | |
| 139 | 136 | // (transition.go), reused by every action. | |
| 140 | 137 | shots []viewShot | |
| @@ -499,7 +496,13 @@ func (c *Core) setLayers(all []ports.LayerSurface) { | |||
| 499 | 496 | } | |
| 500 | 497 | } | |
| 501 | 498 | ||
| 502 | - | func (c *Core) publish(ctx context.Context) error { | |
| 499 | + | func (c *Core) publish(ctx context.Context) error { return c.publishFrame(ctx, nil) } | |
| 500 | + | ||
| 501 | + | // publishFrame publishes the scenes. only is the screen whose page flip | |
| 502 | + | // moved the springs (nil: any, or none moved): the pulse advances with the | |
| 503 | + | // focused screen's flip, and only a flip's own screen gets a fresh Seq for | |
| 504 | + | // its animation, so another output keeps its Seq while it draws the same. | |
| 505 | + | func (c *Core) publishFrame(ctx context.Context, only *screen) error { | |
| 503 | 506 | if c.inputEpochChanged() { | |
| 504 | 507 | return errSecurityChanged | |
| 505 | 508 | } | |
| @@ -543,7 +546,7 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 543 | 546 | // its value so the focused scene keeps its Seq. | |
| 544 | 547 | if c.pulse.id == 0 { | |
| 545 | 548 | c.pulse.value = 0 | |
| 546 | - | } else if c.stepping == nil || c.stepping == c.cur() { | |
| 549 | + | } else if only == nil || only == c.cur() { | |
| 547 | 550 | c.pulse.value = c.advancePulse(c.now()) | |
| 548 | 551 | } | |
| 549 | 552 | pulse := c.pulse.value | |
| @@ -645,7 +648,7 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 645 | 648 | // keeps its output's Seq while it draws the same, so an idle output | |
| 646 | 649 | // is not recomposed. | |
| 647 | 650 | withCapture := scene.Capture != nil && (sc == capture.hiddenScr || sc == capture.windowScr) | |
| 648 | - | animating := (sc.springing() || c.pulsing(sc)) && (c.stepping == nil || c.stepping == sc) | |
| 651 | + | animating := (sc.springing() || c.pulsing(sc)) && (only == nil || only == sc) | |
| 649 | 652 | if !withCapture && !animating && sc.last.Seq != 0 && scene.SameAs(sc.last) { | |
| 650 | 653 | scene.Seq = sc.last.Seq | |
| 651 | 654 | } else { | |
| @@ -689,7 +692,7 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 689 | 692 | return errSecurityChanged | |
| 690 | 693 | } | |
| 691 | 694 | if c.syncSecurity() { | |
| 692 | - | return c.publish(ctx) | |
| 695 | + | return c.publishFrame(ctx, only) | |
| 693 | 696 | } | |
| 694 | 697 | latest(c.ch.Scenes, scenes) | |
| 695 | 698 | c.publishState() | |
| @@ -715,9 +718,7 @@ func (c *Core) step(ctx context.Context, only *screen) error { | |||
| 715 | 718 | c.stopFrame() | |
| 716 | 719 | } | |
| 717 | 720 | c.animate(c.now(), only) | |
| 718 | - | c.stepping = only | |
| 719 | - | defer func() { c.stepping = nil }() | |
| 720 | - | return c.slid(ctx, false) | |
| 721 | + | return c.slidFrame(ctx, false, only) | |
| 721 | 722 | } | |
| 722 | 723 | ||
| 723 | 724 | // animatingOther reports whether a spring or the pulse runs on a screen | |
| @@ -734,10 +735,15 @@ func (c *Core) animatingOther(only *screen) bool { | |||
| 734 | 735 | // slid publishes a moved view; shown reports another workspace came on | |
| 735 | 736 | // screen. | |
| 736 | 737 | func (c *Core) slid(ctx context.Context, shown bool) error { | |
| 738 | + | return c.slidFrame(ctx, shown, nil) | |
| 739 | + | } | |
| 740 | + | ||
| 741 | + | // slidFrame is slid for the page flip of only (nil: any screen). | |
| 742 | + | func (c *Core) slidFrame(ctx context.Context, shown bool, only *screen) error { | |
| 737 | 743 | if err := c.workspaceVisible(ctx, shown); err != nil { | |
| 738 | 744 | return err | |
| 739 | 745 | } | |
| 740 | - | if err := c.publish(ctx); err != nil { | |
| 746 | + | if err := c.publishFrame(ctx, only); err != nil { | |
| 741 | 747 | return err | |
| 742 | 748 | } | |
| 743 | 749 | return c.rehit(ctx) | |
| @@ -132,9 +132,6 @@ type Core struct { | |||||
| 132 | swipe *swipeGesture | 132 | swipe *swipeGesture | ||
| 133 | frameC <-chan time.Time | 133 | frameC <-chan time.Time | ||
| 134 | frameStop func() bool | 134 | frameStop func() bool | ||
| 135 | - | // stepping is the screen a page flip advances (nil: all of them); it is | |||
| 136 | - | // set while step publishes. | |||
| 137 | - | stepping *screen | |||
| 138 | // shots is the snapshot of what the screens show before an action | 135 | // shots is the snapshot of what the screens show before an action | ||
| 139 | // (transition.go), reused by every action. | 136 | // (transition.go), reused by every action. | ||
| 140 | shots []viewShot | 137 | shots []viewShot | ||
| @@ -499,7 +496,13 @@ func (c *Core) setLayers(all []ports.LayerSurface) { | |||||
| 499 | } | 496 | } | ||
| 500 | } | 497 | } | ||
| 501 | 498 | ||||
| 502 | - | func (c *Core) publish(ctx context.Context) error { | 499 | + | func (c *Core) publish(ctx context.Context) error { return c.publishFrame(ctx, nil) } |
| 500 | + | ||||
| 501 | + | // publishFrame publishes the scenes. only is the screen whose page flip | |||
| 502 | + | // moved the springs (nil: any, or none moved): the pulse advances with the | |||
| 503 | + | // focused screen's flip, and only a flip's own screen gets a fresh Seq for | |||
| 504 | + | // its animation, so another output keeps its Seq while it draws the same. | |||
| 505 | + | func (c *Core) publishFrame(ctx context.Context, only *screen) error { | |||
| 503 | if c.inputEpochChanged() { | 506 | if c.inputEpochChanged() { | ||
| 504 | return errSecurityChanged | 507 | return errSecurityChanged | ||
| 505 | } | 508 | } | ||
| @@ -543,7 +546,7 @@ func (c *Core) publish(ctx context.Context) error { | |||||
| 543 | // its value so the focused scene keeps its Seq. | 546 | // its value so the focused scene keeps its Seq. | ||
| 544 | if c.pulse.id == 0 { | 547 | if c.pulse.id == 0 { | ||
| 545 | c.pulse.value = 0 | 548 | c.pulse.value = 0 | ||
| 546 | - | } else if c.stepping == nil || c.stepping == c.cur() { | 549 | + | } else if only == nil || only == c.cur() { |
| 547 | c.pulse.value = c.advancePulse(c.now()) | 550 | c.pulse.value = c.advancePulse(c.now()) | ||
| 548 | } | 551 | } | ||
| 549 | pulse := c.pulse.value | 552 | pulse := c.pulse.value | ||
| @@ -645,7 +648,7 @@ func (c *Core) publish(ctx context.Context) error { | |||||
| 645 | // keeps its output's Seq while it draws the same, so an idle output | 648 | // keeps its output's Seq while it draws the same, so an idle output | ||
| 646 | // is not recomposed. | 649 | // is not recomposed. | ||
| 647 | withCapture := scene.Capture != nil && (sc == capture.hiddenScr || sc == capture.windowScr) | 650 | withCapture := scene.Capture != nil && (sc == capture.hiddenScr || sc == capture.windowScr) | ||
| 648 | - | animating := (sc.springing() || c.pulsing(sc)) && (c.stepping == nil || c.stepping == sc) | 651 | + | animating := (sc.springing() || c.pulsing(sc)) && (only == nil || only == sc) |
| 649 | if !withCapture && !animating && sc.last.Seq != 0 && scene.SameAs(sc.last) { | 652 | if !withCapture && !animating && sc.last.Seq != 0 && scene.SameAs(sc.last) { | ||
| 650 | scene.Seq = sc.last.Seq | 653 | scene.Seq = sc.last.Seq | ||
| 651 | } else { | 654 | } else { | ||
| @@ -689,7 +692,7 @@ func (c *Core) publish(ctx context.Context) error { | |||||
| 689 | return errSecurityChanged | 692 | return errSecurityChanged | ||
| 690 | } | 693 | } | ||
| 691 | if c.syncSecurity() { | 694 | if c.syncSecurity() { | ||
| 692 | - | return c.publish(ctx) | 695 | + | return c.publishFrame(ctx, only) |
| 693 | } | 696 | } | ||
| 694 | latest(c.ch.Scenes, scenes) | 697 | latest(c.ch.Scenes, scenes) | ||
| 695 | c.publishState() | 698 | c.publishState() | ||
| @@ -715,9 +718,7 @@ func (c *Core) step(ctx context.Context, only *screen) error { | |||||
| 715 | c.stopFrame() | 718 | c.stopFrame() | ||
| 716 | } | 719 | } | ||
| 717 | c.animate(c.now(), only) | 720 | c.animate(c.now(), only) | ||
| 718 | - | c.stepping = only | 721 | + | return c.slidFrame(ctx, false, only) |
| 719 | - | defer func() { c.stepping = nil }() | |||
| 720 | - | return c.slid(ctx, false) | |||
| 721 | } | 722 | } | ||
| 722 | 723 | ||||
| 723 | // animatingOther reports whether a spring or the pulse runs on a screen | 724 | // animatingOther reports whether a spring or the pulse runs on a screen | ||
| @@ -734,10 +735,15 @@ func (c *Core) animatingOther(only *screen) bool { | |||||
| 734 | // slid publishes a moved view; shown reports another workspace came on | 735 | // slid publishes a moved view; shown reports another workspace came on | ||
| 735 | // screen. | 736 | // screen. | ||
| 736 | func (c *Core) slid(ctx context.Context, shown bool) error { | 737 | func (c *Core) slid(ctx context.Context, shown bool) error { | ||
| 738 | + | return c.slidFrame(ctx, shown, nil) | |||
| 739 | + | } | |||
| 740 | + | ||||
| 741 | + | // slidFrame is slid for the page flip of only (nil: any screen). | |||
| 742 | + | func (c *Core) slidFrame(ctx context.Context, shown bool, only *screen) error { | |||
| 737 | if err := c.workspaceVisible(ctx, shown); err != nil { | 743 | if err := c.workspaceVisible(ctx, shown); err != nil { | ||
| 738 | return err | 744 | return err | ||
| 739 | } | 745 | } | ||
| 740 | - | if err := c.publish(ctx); err != nil { | 746 | + | if err := c.publishFrame(ctx, only); err != nil { |
| 741 | return err | 747 | return err | ||
| 742 | } | 748 | } | ||
| 743 | return c.rehit(ctx) | 749 | return c.rehit(ctx) | ||
M internal/core/gesture.go +1 -1
| @@ -279,7 +279,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 279 | 279 | return false | |
| 280 | 280 | } | |
| 281 | 281 | before := c.cur().mon.Current() | |
| 282 | - | shots := c.snapshot() | |
| 282 | + | shots := c.snapshot(now) | |
| 283 | 283 | c.keyboard.takeBack() | |
| 284 | 284 | if mon := c.cur().mon; mon.ov.open { | |
| 285 | 285 | mon.overviewFocus(a) | |
| @@ -279,7 +279,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||||
| 279 | return false | 279 | return false | ||
| 280 | } | 280 | } | ||
| 281 | before := c.cur().mon.Current() | 281 | before := c.cur().mon.Current() | ||
| 282 | - | shots := c.snapshot() | 282 | + | shots := c.snapshot(now) |
| 283 | c.keyboard.takeBack() | 283 | c.keyboard.takeBack() | ||
| 284 | if mon := c.cur().mon; mon.ov.open { | 284 | if mon := c.cur().mon; mon.ov.open { | ||
| 285 | mon.overviewFocus(a) | 285 | mon.overviewFocus(a) | ||
M internal/core/input.go +2 -2
| @@ -125,7 +125,7 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||
| 125 | 125 | // A click focuses the window and its output. | |
| 126 | 126 | s, w := c.screenOf(id) | |
| 127 | 127 | if v.Pressed && s != nil && w == s.mon.Current() && (c.keyboard.sent != id || s != c.cur()) { | |
| 128 | - | before := c.snapshot() | |
| 128 | + | before := c.snapshot(c.now()) | |
| 129 | 129 | w.Click(id) | |
| 130 | 130 | c.focusScreen = c.screenIndex(s.name()) | |
| 131 | 131 | c.transition(before, c.now()) | |
| @@ -262,7 +262,7 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||
| 262 | 262 | } | |
| 263 | 263 | before := c.cur().mon.Current() | |
| 264 | 264 | swiped := c.swipedWorkspace() | |
| 265 | - | shots := c.snapshot() | |
| 265 | + | shots := c.snapshot(c.now()) | |
| 266 | 266 | c.keyboard.takeBack() // a bind acts on the windows | |
| 267 | 267 | effect := c.applyAction(action) | |
| 268 | 268 | if effect.Quit { | |
| @@ -125,7 +125,7 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||||
| 125 | // A click focuses the window and its output. | 125 | // A click focuses the window and its output. | ||
| 126 | s, w := c.screenOf(id) | 126 | s, w := c.screenOf(id) | ||
| 127 | if v.Pressed && s != nil && w == s.mon.Current() && (c.keyboard.sent != id || s != c.cur()) { | 127 | if v.Pressed && s != nil && w == s.mon.Current() && (c.keyboard.sent != id || s != c.cur()) { | ||
| 128 | - | before := c.snapshot() | 128 | + | before := c.snapshot(c.now()) |
| 129 | w.Click(id) | 129 | w.Click(id) | ||
| 130 | c.focusScreen = c.screenIndex(s.name()) | 130 | c.focusScreen = c.screenIndex(s.name()) | ||
| 131 | c.transition(before, c.now()) | 131 | c.transition(before, c.now()) | ||
| @@ -262,7 +262,7 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error { | |||||
| 262 | } | 262 | } | ||
| 263 | before := c.cur().mon.Current() | 263 | before := c.cur().mon.Current() | ||
| 264 | swiped := c.swipedWorkspace() | 264 | swiped := c.swipedWorkspace() | ||
| 265 | - | shots := c.snapshot() | 265 | + | shots := c.snapshot(c.now()) |
| 266 | c.keyboard.takeBack() // a bind acts on the windows | 266 | c.keyboard.takeBack() // a bind acts on the windows | ||
| 267 | effect := c.applyAction(action) | 267 | effect := c.applyAction(action) | ||
| 268 | if effect.Quit { | 268 | if effect.Quit { | ||
M internal/core/motion.go +7 -0
| @@ -214,6 +214,13 @@ func (m *motion) at(now time.Time) (float64, bool) { | |||
| 214 | 214 | return m.spring.valueAt(tau), false | |
| 215 | 215 | } | |
| 216 | 216 | ||
| 217 | + | // sampleAt is the position and speed m has at now, in units per second, | |
| 218 | + | // without moving m: m is a copy, so at's bookkeeping (start, v) stays out. | |
| 219 | + | func (m motion) sampleAt(now time.Time) (pos, vel float64) { | |
| 220 | + | pos, _ = m.at(now) | |
| 221 | + | return pos, m.v | |
| 222 | + | } | |
| 223 | + | ||
| 217 | 224 | // velocity is the speed of the last sample, in units per second. | |
| 218 | 225 | func (m motion) velocity() float64 { return m.v } | |
| 219 | 226 | ||
| @@ -214,6 +214,13 @@ func (m *motion) at(now time.Time) (float64, bool) { | |||||
| 214 | return m.spring.valueAt(tau), false | 214 | return m.spring.valueAt(tau), false | ||
| 215 | } | 215 | } | ||
| 216 | 216 | ||||
| 217 | + | // sampleAt is the position and speed m has at now, in units per second, | |||
| 218 | + | // without moving m: m is a copy, so at's bookkeeping (start, v) stays out. | |||
| 219 | + | func (m motion) sampleAt(now time.Time) (pos, vel float64) { | |||
| 220 | + | pos, _ = m.at(now) | |||
| 221 | + | return pos, m.v | |||
| 222 | + | } | |||
| 223 | + | ||||
| 217 | // velocity is the speed of the last sample, in units per second. | 224 | // velocity is the speed of the last sample, in units per second. | ||
| 218 | func (m motion) velocity() float64 { return m.v } | 225 | func (m motion) velocity() float64 { return m.v } | ||
| 219 | 226 | ||||
M internal/core/pulse_test.go +1 -1
| @@ -209,7 +209,7 @@ func TestFocusPulseWaitsForSlide(t *testing.T) { | |||
| 209 | 209 | m.AddWindow(id) | |
| 210 | 210 | } | |
| 211 | 211 | indicatorScene(t, c) | |
| 212 | - | before := c.snapshot() | |
| 212 | + | before := c.snapshot(now) | |
| 213 | 213 | for range 3 { | |
| 214 | 214 | c.applyAction(ActionFocusColumnLeft) | |
| 215 | 215 | } | |
| @@ -209,7 +209,7 @@ func TestFocusPulseWaitsForSlide(t *testing.T) { | |||||
| 209 | m.AddWindow(id) | 209 | m.AddWindow(id) | ||
| 210 | } | 210 | } | ||
| 211 | indicatorScene(t, c) | 211 | indicatorScene(t, c) | ||
| 212 | - | before := c.snapshot() | 212 | + | before := c.snapshot(now) |
| 213 | for range 3 { | 213 | for range 3 { | ||
| 214 | c.applyAction(ActionFocusColumnLeft) | 214 | c.applyAction(ActionFocusColumnLeft) | ||
| 215 | } | 215 | } | ||
M internal/core/transition.go +64 -32
| @@ -27,8 +27,9 @@ type viewShot struct { | |||
| 27 | 27 | // (viewX plus shift). | |
| 28 | 28 | viewX int | |
| 29 | 29 | view float64 | |
| 30 | - | // viewV and switchV are the speeds of the running view and workspace | |
| 31 | - | // springs (units per second, 0 without one). An action may stop them | |
| 30 | + | // viewV and switchV are the speeds the running view and workspace | |
| 31 | + | // springs have at the snapshot's time (units per second, 0 without | |
| 32 | + | // one), not at their last frame. An action may stop them | |
| 32 | 33 | // (Monitor.Focus), so the transition reads them here. | |
| 33 | 34 | viewV, switchV float64 | |
| 34 | 35 | // list and pos are the numbered workspaces and the monitor's fractional | |
| @@ -37,15 +38,22 @@ type viewShot struct { | |||
| 37 | 38 | list []*Workspace | |
| 38 | 39 | pos float64 | |
| 39 | 40 | ok bool | |
| 40 | - | // rects are the rects the screen drew for the windows of ws, hidden | |
| 41 | - | // windows and previews left out. The slice is reused like list. | |
| 41 | + | // rects are the windows of ws the screen drew, hidden windows and | |
| 42 | + | // previews left out. The slice is reused like list. | |
| 42 | 43 | rects []rectShot | |
| 43 | 44 | } | |
| 44 | 45 | ||
| 45 | - | // rectShot is the rect a window was drawn at. | |
| 46 | + | // rectOffsets are the four components of a rect motion: x, y, w, h. | |
| 47 | + | type rectOffsets struct{ x, y, w, h float64 } | |
| 48 | + | ||
| 49 | + | // rectShot is where a window was drawn: its settled rect plus the float | |
| 50 | + | // offsets of its running rect motion (zero without one), which the drawn | |
| 51 | + | // rect only rounds. vel is the speed of each offset component, in units per | |
| 52 | + | // second. Offsets and speeds are sampled at the snapshot's time. | |
| 46 | 53 | type rectShot struct { | |
| 47 | - | id WindowID | |
| 48 | - | rect Rect | |
| 54 | + | id WindowID | |
| 55 | + | rect Rect | |
| 56 | + | off, vel rectOffsets | |
| 49 | 57 | } | |
| 50 | 58 | ||
| 51 | 59 | // rectMotion takes a window's drawn rect to its settled one: each component | |
| @@ -64,6 +72,23 @@ func (m *rectMotion) apply(r Rect) Rect { | |||
| 64 | 72 | } | |
| 65 | 73 | } | |
| 66 | 74 | ||
| 75 | + | // sample is where the offsets and their speeds would be at now, without | |
| 76 | + | // moving the motion: a running component is sampled on a copy. | |
| 77 | + | func (m *rectMotion) sample(now time.Time) (off, vel rectOffsets) { | |
| 78 | + | off = rectOffsets{m.dx, m.dy, m.dw, m.dh} | |
| 79 | + | sampleComponent(m.x, &off.x, &vel.x, now) | |
| 80 | + | sampleComponent(m.y, &off.y, &vel.y, now) | |
| 81 | + | sampleComponent(m.w, &off.w, &vel.w, now) | |
| 82 | + | sampleComponent(m.h, &off.h, &vel.h, now) | |
| 83 | + | return off, vel | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func sampleComponent(m motion, off, vel *float64, now time.Time) { | |
| 87 | + | if m.on { | |
| 88 | + | *off, *vel = m.sampleAt(now) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 67 | 92 | // advance moves the offsets to now; a settled component stops at 0. | |
| 68 | 93 | func (m *rectMotion) advance(now time.Time) { | |
| 69 | 94 | m.dx = stepComponent(&m.x, m.dx, now) | |
| @@ -88,13 +113,9 @@ func stepComponent(m *motion, cur float64, now time.Time) float64 { | |||
| 88 | 113 | func (m *rectMotion) on() bool { return m.x.on || m.y.on || m.w.on || m.h.on } | |
| 89 | 114 | ||
| 90 | 115 | // retargetComponent starts m for an offset of off from its settled value at | |
| 91 | - | // now, keeping the speed it has now. A component with nothing to move and no | |
| 116 | + | // now, with the speed v it had then. A component with nothing to move and no | |
| 92 | 117 | // speed stops. | |
| 93 | - | func (c *Core) retargetComponent(m *motion, cur *float64, off float64, now time.Time) { | |
| 94 | - | v := m.velocity() | |
| 95 | - | if !m.on { | |
| 96 | - | v = 0 | |
| 97 | - | } | |
| 118 | + | func (c *Core) retargetComponent(m *motion, cur *float64, off, v float64, now time.Time) { | |
| 98 | 119 | *cur = off | |
| 99 | 120 | if off == 0 && v == 0 { | |
| 100 | 121 | *m = motion{} | |
| @@ -144,10 +165,14 @@ func (c *Core) refreshShown() { | |||
| 144 | 165 | } | |
| 145 | 166 | } | |
| 146 | 167 | ||
| 147 | - | // snapshot records what every screen without an open overview shows. The | |
| 148 | - | // returned slice and the lists in it belong to Core and are reused by the | |
| 149 | - | // next snapshot: use it before the next action only. | |
| 150 | - | func (c *Core) snapshot() []viewShot { | |
| 168 | + | // snapshot records what every screen without an open overview shows at now. | |
| 169 | + | // Running motions are sampled at now on a copy, so a transition that | |
| 170 | + | // follows chains from their speed at the time of the action, not from the | |
| 171 | + | // last frame's. The view is the one drawn (the layouts it is measured | |
| 172 | + | // against are), only its speed is sampled. The returned slice and the lists | |
| 173 | + | // in it belong to Core and are reused by the next snapshot: use it before | |
| 174 | + | // the next action only. | |
| 175 | + | func (c *Core) snapshot(now time.Time) []viewShot { | |
| 151 | 176 | full := c.shots[:cap(c.shots)] | |
| 152 | 177 | out := c.shots[:0] | |
| 153 | 178 | for _, sc := range c.screens { | |
| @@ -162,15 +187,22 @@ func (c *Core) snapshot() []viewShot { | |||
| 162 | 187 | w := m.Current() | |
| 163 | 188 | s.ws, s.viewX, s.view = w, w.ViewX, float64(w.ViewX)+w.shift | |
| 164 | 189 | if w.motion.on { | |
| 165 | - | s.viewV = w.motion.velocity() | |
| 190 | + | _, s.viewV = w.motion.sampleAt(now) | |
| 166 | 191 | } | |
| 167 | 192 | if m.switchMotion.on { | |
| 168 | - | s.switchV = m.switchMotion.velocity() | |
| 193 | + | _, s.switchV = m.switchMotion.sampleAt(now) | |
| 169 | 194 | } | |
| 170 | - | for _, p := range sc.shown { | |
| 171 | - | if !p.Hidden && p.Preview == 0 { | |
| 172 | - | s.rects = append(s.rects, rectShot{p.ID, p.Rect}) | |
| 195 | + | // settledLayout and shown are index-aligned and differ only by | |
| 196 | + | // the offsets of sc.rects: the shot keeps both unrounded. | |
| 197 | + | for _, p := range sc.settledLayout { | |
| 198 | + | if p.Hidden || p.Preview != 0 { | |
| 199 | + | continue | |
| 200 | + | } | |
| 201 | + | r := rectShot{id: p.ID, rect: p.Rect} | |
| 202 | + | if rm, ok := sc.rects[p.ID]; ok { | |
| 203 | + | r.off, r.vel = rm.sample(now) | |
| 173 | 204 | } | |
| 205 | + | s.rects = append(s.rects, r) | |
| 174 | 206 | } | |
| 175 | 207 | if m.shown == nil { | |
| 176 | 208 | // A landing slide measures in the list it began on. | |
| @@ -267,21 +299,21 @@ func (c *Core) transitionRects(b *viewShot, now time.Time) { | |||
| 267 | 299 | if i < 0 { | |
| 268 | 300 | continue | |
| 269 | 301 | } | |
| 270 | - | old := b.rects[i].rect | |
| 271 | - | if old == p.Rect { | |
| 272 | - | // Still sliding to the settled rect, or already there. | |
| 273 | - | if _, ok := sc.rects[p.ID]; !ok { | |
| 274 | - | continue | |
| 275 | - | } | |
| 302 | + | old := &b.rects[i] | |
| 303 | + | if old.rect == p.Rect { | |
| 304 | + | // The settled rect did not change: a running motion goes on | |
| 305 | + | // as it is, and there is none to start. | |
| 306 | + | continue | |
| 276 | 307 | } | |
| 277 | 308 | if sc.rects == nil { | |
| 278 | 309 | sc.rects = make(map[WindowID]rectMotion) | |
| 279 | 310 | } | |
| 311 | + | // Where the window was drawn, unrounded, relative to where it goes. | |
| 280 | 312 | rm := sc.rects[p.ID] | |
| 281 | - | c.retargetComponent(&rm.x, &rm.dx, float64(old.X-p.Rect.X), now) | |
| 282 | - | c.retargetComponent(&rm.y, &rm.dy, float64(old.Y-p.Rect.Y), now) | |
| 283 | - | c.retargetComponent(&rm.w, &rm.dw, float64(old.W-p.Rect.W), now) | |
| 284 | - | c.retargetComponent(&rm.h, &rm.dh, float64(old.H-p.Rect.H), now) | |
| 313 | + | c.retargetComponent(&rm.x, &rm.dx, float64(old.rect.X-p.Rect.X)+old.off.x, old.vel.x, now) | |
| 314 | + | c.retargetComponent(&rm.y, &rm.dy, float64(old.rect.Y-p.Rect.Y)+old.off.y, old.vel.y, now) | |
| 315 | + | c.retargetComponent(&rm.w, &rm.dw, float64(old.rect.W-p.Rect.W)+old.off.w, old.vel.w, now) | |
| 316 | + | c.retargetComponent(&rm.h, &rm.dh, float64(old.rect.H-p.Rect.H)+old.off.h, old.vel.h, now) | |
| 285 | 317 | if rm.on() { | |
| 286 | 318 | sc.rects[p.ID], sc.rectsWS = rm, b.ws | |
| 287 | 319 | } else { | |
| @@ -27,8 +27,9 @@ type viewShot struct { | |||||
| 27 | // (viewX plus shift). | 27 | // (viewX plus shift). | ||
| 28 | viewX int | 28 | viewX int | ||
| 29 | view float64 | 29 | view float64 | ||
| 30 | - | // viewV and switchV are the speeds of the running view and workspace | 30 | + | // viewV and switchV are the speeds the running view and workspace |
| 31 | - | // springs (units per second, 0 without one). An action may stop them | 31 | + | // springs have at the snapshot's time (units per second, 0 without |
| 32 | + | // one), not at their last frame. An action may stop them | |||
| 32 | // (Monitor.Focus), so the transition reads them here. | 33 | // (Monitor.Focus), so the transition reads them here. | ||
| 33 | viewV, switchV float64 | 34 | viewV, switchV float64 | ||
| 34 | // list and pos are the numbered workspaces and the monitor's fractional | 35 | // list and pos are the numbered workspaces and the monitor's fractional | ||
| @@ -37,15 +38,22 @@ type viewShot struct { | |||||
| 37 | list []*Workspace | 38 | list []*Workspace | ||
| 38 | pos float64 | 39 | pos float64 | ||
| 39 | ok bool | 40 | ok bool | ||
| 40 | - | // rects are the rects the screen drew for the windows of ws, hidden | 41 | + | // rects are the windows of ws the screen drew, hidden windows and |
| 41 | - | // windows and previews left out. The slice is reused like list. | 42 | + | // previews left out. The slice is reused like list. |
| 42 | rects []rectShot | 43 | rects []rectShot | ||
| 43 | } | 44 | } | ||
| 44 | 45 | ||||
| 45 | - | // rectShot is the rect a window was drawn at. | 46 | + | // rectOffsets are the four components of a rect motion: x, y, w, h. |
| 47 | + | type rectOffsets struct{ x, y, w, h float64 } | |||
| 48 | + | ||||
| 49 | + | // rectShot is where a window was drawn: its settled rect plus the float | |||
| 50 | + | // offsets of its running rect motion (zero without one), which the drawn | |||
| 51 | + | // rect only rounds. vel is the speed of each offset component, in units per | |||
| 52 | + | // second. Offsets and speeds are sampled at the snapshot's time. | |||
| 46 | type rectShot struct { | 53 | type rectShot struct { | ||
| 47 | - | id WindowID | 54 | + | id WindowID |
| 48 | - | rect Rect | 55 | + | rect Rect |
| 56 | + | off, vel rectOffsets | |||
| 49 | } | 57 | } | ||
| 50 | 58 | ||||
| 51 | // rectMotion takes a window's drawn rect to its settled one: each component | 59 | // rectMotion takes a window's drawn rect to its settled one: each component | ||
| @@ -64,6 +72,23 @@ func (m *rectMotion) apply(r Rect) Rect { | |||||
| 64 | } | 72 | } | ||
| 65 | } | 73 | } | ||
| 66 | 74 | ||||
| 75 | + | // sample is where the offsets and their speeds would be at now, without | |||
| 76 | + | // moving the motion: a running component is sampled on a copy. | |||
| 77 | + | func (m *rectMotion) sample(now time.Time) (off, vel rectOffsets) { | |||
| 78 | + | off = rectOffsets{m.dx, m.dy, m.dw, m.dh} | |||
| 79 | + | sampleComponent(m.x, &off.x, &vel.x, now) | |||
| 80 | + | sampleComponent(m.y, &off.y, &vel.y, now) | |||
| 81 | + | sampleComponent(m.w, &off.w, &vel.w, now) | |||
| 82 | + | sampleComponent(m.h, &off.h, &vel.h, now) | |||
| 83 | + | return off, vel | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func sampleComponent(m motion, off, vel *float64, now time.Time) { | |||
| 87 | + | if m.on { | |||
| 88 | + | *off, *vel = m.sampleAt(now) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | ||||
| 67 | // advance moves the offsets to now; a settled component stops at 0. | 92 | // advance moves the offsets to now; a settled component stops at 0. | ||
| 68 | func (m *rectMotion) advance(now time.Time) { | 93 | func (m *rectMotion) advance(now time.Time) { | ||
| 69 | m.dx = stepComponent(&m.x, m.dx, now) | 94 | m.dx = stepComponent(&m.x, m.dx, now) | ||
| @@ -88,13 +113,9 @@ func stepComponent(m *motion, cur float64, now time.Time) float64 { | |||||
| 88 | func (m *rectMotion) on() bool { return m.x.on || m.y.on || m.w.on || m.h.on } | 113 | func (m *rectMotion) on() bool { return m.x.on || m.y.on || m.w.on || m.h.on } | ||
| 89 | 114 | ||||
| 90 | // retargetComponent starts m for an offset of off from its settled value at | 115 | // retargetComponent starts m for an offset of off from its settled value at | ||
| 91 | - | // now, keeping the speed it has now. A component with nothing to move and no | 116 | + | // now, with the speed v it had then. A component with nothing to move and no |
| 92 | // speed stops. | 117 | // speed stops. | ||
| 93 | - | func (c *Core) retargetComponent(m *motion, cur *float64, off float64, now time.Time) { | 118 | + | func (c *Core) retargetComponent(m *motion, cur *float64, off, v float64, now time.Time) { |
| 94 | - | v := m.velocity() | |||
| 95 | - | if !m.on { | |||
| 96 | - | v = 0 | |||
| 97 | - | } | |||
| 98 | *cur = off | 119 | *cur = off | ||
| 99 | if off == 0 && v == 0 { | 120 | if off == 0 && v == 0 { | ||
| 100 | *m = motion{} | 121 | *m = motion{} | ||
| @@ -144,10 +165,14 @@ func (c *Core) refreshShown() { | |||||
| 144 | } | 165 | } | ||
| 145 | } | 166 | } | ||
| 146 | 167 | ||||
| 147 | - | // snapshot records what every screen without an open overview shows. The | 168 | + | // snapshot records what every screen without an open overview shows at now. |
| 148 | - | // returned slice and the lists in it belong to Core and are reused by the | 169 | + | // Running motions are sampled at now on a copy, so a transition that |
| 149 | - | // next snapshot: use it before the next action only. | 170 | + | // follows chains from their speed at the time of the action, not from the |
| 150 | - | func (c *Core) snapshot() []viewShot { | 171 | + | // last frame's. The view is the one drawn (the layouts it is measured |
| 172 | + | // against are), only its speed is sampled. The returned slice and the lists | |||
| 173 | + | // in it belong to Core and are reused by the next snapshot: use it before | |||
| 174 | + | // the next action only. | |||
| 175 | + | func (c *Core) snapshot(now time.Time) []viewShot { | |||
| 151 | full := c.shots[:cap(c.shots)] | 176 | full := c.shots[:cap(c.shots)] | ||
| 152 | out := c.shots[:0] | 177 | out := c.shots[:0] | ||
| 153 | for _, sc := range c.screens { | 178 | for _, sc := range c.screens { | ||
| @@ -162,15 +187,22 @@ func (c *Core) snapshot() []viewShot { | |||||
| 162 | w := m.Current() | 187 | w := m.Current() | ||
| 163 | s.ws, s.viewX, s.view = w, w.ViewX, float64(w.ViewX)+w.shift | 188 | s.ws, s.viewX, s.view = w, w.ViewX, float64(w.ViewX)+w.shift | ||
| 164 | if w.motion.on { | 189 | if w.motion.on { | ||
| 165 | - | s.viewV = w.motion.velocity() | 190 | + | _, s.viewV = w.motion.sampleAt(now) |
| 166 | } | 191 | } | ||
| 167 | if m.switchMotion.on { | 192 | if m.switchMotion.on { | ||
| 168 | - | s.switchV = m.switchMotion.velocity() | 193 | + | _, s.switchV = m.switchMotion.sampleAt(now) |
| 169 | } | 194 | } | ||
| 170 | - | for _, p := range sc.shown { | 195 | + | // settledLayout and shown are index-aligned and differ only by |
| 171 | - | if !p.Hidden && p.Preview == 0 { | 196 | + | // the offsets of sc.rects: the shot keeps both unrounded. |
| 172 | - | s.rects = append(s.rects, rectShot{p.ID, p.Rect}) | 197 | + | for _, p := range sc.settledLayout { |
| 198 | + | if p.Hidden || p.Preview != 0 { | |||
| 199 | + | continue | |||
| 200 | + | } | |||
| 201 | + | r := rectShot{id: p.ID, rect: p.Rect} | |||
| 202 | + | if rm, ok := sc.rects[p.ID]; ok { | |||
| 203 | + | r.off, r.vel = rm.sample(now) | |||
| 173 | } | 204 | } | ||
| 205 | + | s.rects = append(s.rects, r) | |||
| 174 | } | 206 | } | ||
| 175 | if m.shown == nil { | 207 | if m.shown == nil { | ||
| 176 | // A landing slide measures in the list it began on. | 208 | // A landing slide measures in the list it began on. | ||
| @@ -267,21 +299,21 @@ func (c *Core) transitionRects(b *viewShot, now time.Time) { | |||||
| 267 | if i < 0 { | 299 | if i < 0 { | ||
| 268 | continue | 300 | continue | ||
| 269 | } | 301 | } | ||
| 270 | - | old := b.rects[i].rect | 302 | + | old := &b.rects[i] |
| 271 | - | if old == p.Rect { | 303 | + | if old.rect == p.Rect { |
| 272 | - | // Still sliding to the settled rect, or already there. | 304 | + | // The settled rect did not change: a running motion goes on |
| 273 | - | if _, ok := sc.rects[p.ID]; !ok { | 305 | + | // as it is, and there is none to start. |
| 274 | - | continue | 306 | + | continue |
| 275 | - | } | |||
| 276 | } | 307 | } | ||
| 277 | if sc.rects == nil { | 308 | if sc.rects == nil { | ||
| 278 | sc.rects = make(map[WindowID]rectMotion) | 309 | sc.rects = make(map[WindowID]rectMotion) | ||
| 279 | } | 310 | } | ||
| 311 | + | // Where the window was drawn, unrounded, relative to where it goes. | |||
| 280 | rm := sc.rects[p.ID] | 312 | rm := sc.rects[p.ID] | ||
| 281 | - | c.retargetComponent(&rm.x, &rm.dx, float64(old.X-p.Rect.X), now) | 313 | + | c.retargetComponent(&rm.x, &rm.dx, float64(old.rect.X-p.Rect.X)+old.off.x, old.vel.x, now) |
| 282 | - | c.retargetComponent(&rm.y, &rm.dy, float64(old.Y-p.Rect.Y), now) | 314 | + | c.retargetComponent(&rm.y, &rm.dy, float64(old.rect.Y-p.Rect.Y)+old.off.y, old.vel.y, now) |
| 283 | - | c.retargetComponent(&rm.w, &rm.dw, float64(old.W-p.Rect.W), now) | 315 | + | c.retargetComponent(&rm.w, &rm.dw, float64(old.rect.W-p.Rect.W)+old.off.w, old.vel.w, now) |
| 284 | - | c.retargetComponent(&rm.h, &rm.dh, float64(old.H-p.Rect.H), now) | 316 | + | c.retargetComponent(&rm.h, &rm.dh, float64(old.rect.H-p.Rect.H)+old.off.h, old.vel.h, now) |
| 285 | if rm.on() { | 317 | if rm.on() { | ||
| 286 | sc.rects[p.ID], sc.rectsWS = rm, b.ws | 318 | sc.rects[p.ID], sc.rectsWS = rm, b.ws | ||
| 287 | } else { | 319 | } else { | ||
M internal/core/transition_chain_internal_test.go +220 -35
| @@ -2,6 +2,8 @@ package core | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "math" | |
| 6 | + | "slices" | |
| 5 | 7 | "testing" | |
| 6 | 8 | "time" | |
| 7 | 9 | ||
| @@ -17,45 +19,14 @@ func settleShown(t *testing.T, c *Core) { | |||
| 17 | 19 | // A bind that stops a spring while it acts (Monitor.Focus) must not lose its | |
| 18 | 20 | // speed: the transition that follows starts from what the snapshot recorded. | |
| 19 | 21 | func TestChainedActionsKeepVelocity(t *testing.T) { | |
| 20 | - | tests := []struct { | |
| 21 | - | name string | |
| 22 | - | setup func(*Core) | |
| 23 | - | first []Action | |
| 24 | - | second []Action | |
| 25 | - | // velocity is the speed of the motion the first action started. | |
| 26 | - | velocity func(*Core) float64 | |
| 27 | - | }{ | |
| 28 | - | {"camera", func(c *Core) { | |
| 29 | - | m := c.cur().mon | |
| 30 | - | for id := WindowID(1); id <= 4; id++ { | |
| 31 | - | m.AddWindow(id) | |
| 32 | - | } | |
| 33 | - | }, []Action{ActionFocusColumnLeft, ActionFocusColumnLeft, ActionFocusColumnLeft}, | |
| 34 | - | []Action{ActionFocusColumnRight, ActionFocusColumnRight, ActionFocusColumnRight}, | |
| 35 | - | func(c *Core) float64 { return c.cur().mon.Current().motion.velocity() }}, | |
| 36 | - | {"workspace switch", func(c *Core) { | |
| 37 | - | m := c.cur().mon | |
| 38 | - | m.AddWindow(1) | |
| 39 | - | m.Focus(1) | |
| 40 | - | m.AddWindow(2) | |
| 41 | - | m.Focus(0) | |
| 42 | - | }, []Action{ActionFocusWorkspaceDown}, []Action{ActionFocusWorkspaceDown}, | |
| 43 | - | func(c *Core) float64 { return c.cur().mon.switchMotion.velocity() }}, | |
| 44 | - | {"rect", func(c *Core) { | |
| 45 | - | m := c.cur().mon | |
| 46 | - | m.AddWindow(1) | |
| 47 | - | m.AddWindow(2) | |
| 48 | - | }, []Action{ActionMoveColumnLeft}, []Action{ActionMoveColumnRight}, | |
| 49 | - | func(c *Core) float64 { return c.cur().rects[1].x.velocity() }}, | |
| 50 | - | } | |
| 51 | - | for _, tt := range tests { | |
| 22 | + | for _, tt := range chainCases() { | |
| 52 | 23 | t.Run(tt.name, func(t *testing.T) { | |
| 53 | 24 | c, ic := indicatorCore(t) | |
| 54 | 25 | c.cfg.Animations.On = true | |
| 55 | 26 | tt.setup(c) | |
| 56 | 27 | settleShown(t, c) | |
| 57 | 28 | act := func(actions []Action) { | |
| 58 | - | before := c.snapshot() | |
| 29 | + | before := c.snapshot(ic.now) | |
| 59 | 30 | for _, a := range actions { | |
| 60 | 31 | c.applyAction(a) | |
| 61 | 32 | } | |
| @@ -81,6 +52,85 @@ func TestChainedActionsKeepVelocity(t *testing.T) { | |||
| 81 | 52 | } | |
| 82 | 53 | } | |
| 83 | 54 | ||
| 55 | + | // An action between two frames chains from the speed its motion has at the | |
| 56 | + | // action's time (1 ms after the last frame), within 1e-6, not the last | |
| 57 | + | // frame's. | |
| 58 | + | func TestChainedActionsSampleVelocityAtActionTime(t *testing.T) { | |
| 59 | + | for _, tt := range chainCases() { | |
| 60 | + | t.Run(tt.name, func(t *testing.T) { | |
| 61 | + | c, ic := indicatorCore(t) | |
| 62 | + | c.cfg.Animations.On = true | |
| 63 | + | tt.setup(c) | |
| 64 | + | settleShown(t, c) | |
| 65 | + | before := c.snapshot(ic.now) | |
| 66 | + | for _, a := range tt.first { | |
| 67 | + | c.applyAction(a) | |
| 68 | + | } | |
| 69 | + | c.transition(before, ic.now) | |
| 70 | + | settleShown(t, c) | |
| 71 | + | ic.now = ic.now.Add(30 * time.Millisecond) | |
| 72 | + | c.animate(ic.now, nil) | |
| 73 | + | settleShown(t, c) | |
| 74 | + | m := tt.motion(c) | |
| 75 | + | last := m.velocity() | |
| 76 | + | ic.now = ic.now.Add(time.Millisecond) | |
| 77 | + | // The spring's own speed at now, computed without motion.at. | |
| 78 | + | tau := time.Duration(float64(ic.now.Sub(m.start)) / m.slow) | |
| 79 | + | want := m.spring.velocityAt(tau) / m.slow | |
| 80 | + | if math.Abs(want-last) < 1e-3 { | |
| 81 | + | t.Fatalf("speed %v did not change in 1 ms (%v): the case checks nothing", last, want) | |
| 82 | + | } | |
| 83 | + | before = c.snapshot(ic.now) | |
| 84 | + | for _, a := range tt.second { | |
| 85 | + | c.applyAction(a) | |
| 86 | + | } | |
| 87 | + | c.transition(before, ic.now) | |
| 88 | + | if got := tt.velocity(c); math.Abs(got-want) > 1e-6 { | |
| 89 | + | t.Fatalf("velocity %v after the second action, want the sampled %v (last frame's %v)", got, want, last) | |
| 90 | + | } | |
| 91 | + | }) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | type chainCase struct { | |
| 96 | + | name string | |
| 97 | + | setup func(*Core) | |
| 98 | + | first []Action | |
| 99 | + | second []Action | |
| 100 | + | // motion is the motion the first action started. | |
| 101 | + | motion func(*Core) motion | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // velocity is the speed of the motion the first action started. | |
| 105 | + | func (tt chainCase) velocity(c *Core) float64 { return tt.motion(c).velocity() } | |
| 106 | + | ||
| 107 | + | func chainCases() []chainCase { | |
| 108 | + | return []chainCase{ | |
| 109 | + | {"camera", func(c *Core) { | |
| 110 | + | m := c.cur().mon | |
| 111 | + | for id := WindowID(1); id <= 4; id++ { | |
| 112 | + | m.AddWindow(id) | |
| 113 | + | } | |
| 114 | + | }, []Action{ActionFocusColumnLeft, ActionFocusColumnLeft, ActionFocusColumnLeft}, | |
| 115 | + | []Action{ActionFocusColumnRight, ActionFocusColumnRight, ActionFocusColumnRight}, | |
| 116 | + | func(c *Core) motion { return c.cur().mon.Current().motion }}, | |
| 117 | + | {"workspace switch", func(c *Core) { | |
| 118 | + | m := c.cur().mon | |
| 119 | + | m.AddWindow(1) | |
| 120 | + | m.Focus(1) | |
| 121 | + | m.AddWindow(2) | |
| 122 | + | m.Focus(0) | |
| 123 | + | }, []Action{ActionFocusWorkspaceDown}, []Action{ActionFocusWorkspaceDown}, | |
| 124 | + | func(c *Core) motion { return c.cur().mon.switchMotion }}, | |
| 125 | + | {"rect", func(c *Core) { | |
| 126 | + | m := c.cur().mon | |
| 127 | + | m.AddWindow(1) | |
| 128 | + | m.AddWindow(2) | |
| 129 | + | }, []Action{ActionMoveColumnLeft}, []Action{ActionMoveColumnRight}, | |
| 130 | + | func(c *Core) motion { return c.cur().rects[1].x }}, | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 84 | 134 | // The fallback timer guards every animating output: a page flip of one | |
| 85 | 135 | // output (step with only set) keeps it while another output still animates. | |
| 86 | 136 | func TestStepKeepsFallbackTimerWhileAnotherOutputAnimates(t *testing.T) { | |
| @@ -163,7 +213,7 @@ func TestNoCameraTransitionWhileSwipeFollows(t *testing.T) { | |||
| 163 | 213 | w := sc.mon.Current() | |
| 164 | 214 | w.shift = 25 | |
| 165 | 215 | c.swipe = &swipeGesture{screen: sc, ws: w, mode: swipeColumns} | |
| 166 | - | before := c.snapshot() | |
| 216 | + | before := c.snapshot(ic.now) | |
| 167 | 217 | viewX := w.ViewX | |
| 168 | 218 | for range 3 { | |
| 169 | 219 | c.applyAction(ActionFocusColumnLeft) | |
| @@ -194,7 +244,7 @@ func TestNoSwitchMotionForSizedWorkspace(t *testing.T) { | |||
| 194 | 244 | m.Workspaces[1].SetSize(100, 100) | |
| 195 | 245 | } | |
| 196 | 246 | settleShown(t, c) | |
| 197 | - | before := c.snapshot() | |
| 247 | + | before := c.snapshot(ic.now) | |
| 198 | 248 | m.Focus(1) | |
| 199 | 249 | c.transition(before, ic.now) | |
| 200 | 250 | if got := m.switchMotion.on; got == tc.sized { | |
| @@ -206,3 +256,138 @@ func TestNoSwitchMotionForSizedWorkspace(t *testing.T) { | |||
| 206 | 256 | }) | |
| 207 | 257 | } | |
| 208 | 258 | } | |
| 259 | + | ||
| 260 | + | // chainedRects is a core with two windows whose first action moves them, | |
| 261 | + | // leaving rect motions running one frame in: the clock moved 30 ms and | |
| 262 | + | // animate sampled them. | |
| 263 | + | func chainedRects(t *testing.T, first Action) (*Core, *indicatorClock) { | |
| 264 | + | t.Helper() | |
| 265 | + | c, ic := indicatorCore(t) | |
| 266 | + | c.cfg.Animations.On = true | |
| 267 | + | m := c.cur().mon | |
| 268 | + | m.AddWindow(1) | |
| 269 | + | m.AddWindow(2) | |
| 270 | + | settleShown(t, c) | |
| 271 | + | before := c.snapshot(ic.now) | |
| 272 | + | c.applyAction(first) | |
| 273 | + | c.transition(before, ic.now) | |
| 274 | + | settleShown(t, c) | |
| 275 | + | ic.now = ic.now.Add(30 * time.Millisecond) | |
| 276 | + | c.animate(ic.now, nil) | |
| 277 | + | settleShown(t, c) | |
| 278 | + | return c, ic | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | // A bind pressed between two frames chains from the speed the springs have | |
| 282 | + | // at the bind's time, not the one of the last frame: a second change 1 ms | |
| 283 | + | // after a frame keeps the sampled speed (within 1e-6) of the component the | |
| 284 | + | // first one started, for a width change and for a move. | |
| 285 | + | func TestChainedRectVelocityIsSampledAtActionTime(t *testing.T) { | |
| 286 | + | for _, tc := range []struct { | |
| 287 | + | name string | |
| 288 | + | first, second Action | |
| 289 | + | comp func(*rectMotion) *motion | |
| 290 | + | sampled func(rectShot) float64 | |
| 291 | + | }{ | |
| 292 | + | {"width", ActionMaximizeColumn, ActionMaximizeColumn, | |
| 293 | + | func(m *rectMotion) *motion { return &m.w }, func(r rectShot) float64 { return r.vel.w }}, | |
| 294 | + | {"position", ActionMoveColumnLeft, ActionMoveColumnRight, | |
| 295 | + | func(m *rectMotion) *motion { return &m.x }, func(r rectShot) float64 { return r.vel.x }}, | |
| 296 | + | } { | |
| 297 | + | t.Run(tc.name, func(t *testing.T) { | |
| 298 | + | c, ic := chainedRects(t, tc.first) | |
| 299 | + | sc := c.cur() | |
| 300 | + | id := WindowID(0) | |
| 301 | + | for k, rm := range sc.rects { | |
| 302 | + | if tc.comp(&rm).on { | |
| 303 | + | id = k | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | if id == 0 { | |
| 307 | + | t.Fatalf("no window with a %s motion in %v", tc.name, sc.rects) | |
| 308 | + | } | |
| 309 | + | rm := sc.rects[id] | |
| 310 | + | m := tc.comp(&rm) | |
| 311 | + | last := m.velocity() | |
| 312 | + | ic.now = ic.now.Add(time.Millisecond) | |
| 313 | + | ||
| 314 | + | // The expected speed is the spring's own at now, sampled independently. | |
| 315 | + | tau := time.Duration(float64(ic.now.Sub(m.start)) / m.slow) | |
| 316 | + | want := m.spring.velocityAt(tau) / m.slow | |
| 317 | + | if math.Abs(want-last) < 1e-3 { | |
| 318 | + | t.Fatalf("the speed did not change in 1 ms (%v to %v): the case checks nothing", last, want) | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | before := c.snapshot(ic.now) | |
| 322 | + | var shot rectShot | |
| 323 | + | for _, r := range before[0].rects { | |
| 324 | + | if r.id == id { | |
| 325 | + | shot = r | |
| 326 | + | } | |
| 327 | + | } | |
| 328 | + | if got := tc.sampled(shot); math.Abs(got-want) > 1e-6 { | |
| 329 | + | t.Fatalf("sampled speed %v, want %v", got, want) | |
| 330 | + | } | |
| 331 | + | // Sampling left the motion alone. | |
| 332 | + | cur := sc.rects[id] | |
| 333 | + | if got := tc.comp(&cur).velocity(); got != last { | |
| 334 | + | t.Fatalf("snapshot moved the motion: speed %v, was %v", got, last) | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | c.applyAction(tc.second) | |
| 338 | + | c.transition(before, ic.now) | |
| 339 | + | after := sc.rects[id] | |
| 340 | + | n := tc.comp(&after) | |
| 341 | + | if !n.on { | |
| 342 | + | t.Fatalf("the second action left no %s motion", tc.name) | |
| 343 | + | } | |
| 344 | + | if v := n.velocity(); math.Abs(v-want) > 1e-6 { | |
| 345 | + | t.Fatalf("speed %v after the second action, want the sampled %v", v, want) | |
| 346 | + | } | |
| 347 | + | }) | |
| 348 | + | } | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | // A retarget keeps the sub-pixel offsets: the old rect is the settled one | |
| 352 | + | // plus the float offsets, not the rounded rect that was drawn. | |
| 353 | + | func TestRetargetKeepsSubPixelOffsets(t *testing.T) { | |
| 354 | + | c, ic := chainedRects(t, ActionMoveColumnLeft) | |
| 355 | + | sc := c.cur() | |
| 356 | + | rm := sc.rects[1] | |
| 357 | + | if rm.dx == math.Round(rm.dx) { | |
| 358 | + | t.Fatalf("dx = %v is whole: the case checks nothing", rm.dx) | |
| 359 | + | } | |
| 360 | + | settledOld := sc.settledLayout[slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == 1 })].Rect | |
| 361 | + | ||
| 362 | + | before := c.snapshot(ic.now) | |
| 363 | + | var shot rectShot | |
| 364 | + | for _, r := range before[0].rects { | |
| 365 | + | if r.id == 1 { | |
| 366 | + | shot = r | |
| 367 | + | } | |
| 368 | + | } | |
| 369 | + | if shot.rect != settledOld { | |
| 370 | + | t.Fatalf("shot rect %+v, want the settled %+v", shot.rect, settledOld) | |
| 371 | + | } | |
| 372 | + | if shot.off.x != rm.dx || shot.off.w != rm.dw { | |
| 373 | + | t.Fatalf("shot offsets %+v, want the motion's x %v w %v", shot.off, rm.dx, rm.dw) | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | c.applyAction(ActionMoveColumnRight) | |
| 377 | + | c.transition(before, ic.now) | |
| 378 | + | settledNew := c.cur().mon.Layout()[slices.IndexFunc(c.cur().mon.Layout(), func(p Placement) bool { return p.ID == 1 })].Rect | |
| 379 | + | if settledNew == settledOld { | |
| 380 | + | t.Fatal("the second action did not move window 1") | |
| 381 | + | } | |
| 382 | + | got := sc.rects[1] | |
| 383 | + | // Old rect = settled old + float offset: the new offset from the new | |
| 384 | + | // settled rect carries the fraction of the old one. | |
| 385 | + | want := float64(settledOld.X-settledNew.X) + rm.dx | |
| 386 | + | if math.Abs(got.dx-want) > 1e-9 { | |
| 387 | + | t.Fatalf("dx = %v after the retarget, want %v (settled %d to %d plus the offset %v)", got.dx, want, settledOld.X, settledNew.X, rm.dx) | |
| 388 | + | } | |
| 389 | + | rounded := float64(settledOld.X-settledNew.X) + math.Round(rm.dx) | |
| 390 | + | if math.Abs(got.dx-rounded) < 1e-6 { | |
| 391 | + | t.Fatalf("dx = %v equals the rounded rect's: the fraction was lost", got.dx) | |
| 392 | + | } | |
| 393 | + | } | |
| @@ -2,6 +2,8 @@ package core | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "math" | |||
| 6 | + | "slices" | |||
| 5 | "testing" | 7 | "testing" | ||
| 6 | "time" | 8 | "time" | ||
| 7 | 9 | ||||
| @@ -17,45 +19,14 @@ func settleShown(t *testing.T, c *Core) { | |||||
| 17 | // A bind that stops a spring while it acts (Monitor.Focus) must not lose its | 19 | // A bind that stops a spring while it acts (Monitor.Focus) must not lose its | ||
| 18 | // speed: the transition that follows starts from what the snapshot recorded. | 20 | // speed: the transition that follows starts from what the snapshot recorded. | ||
| 19 | func TestChainedActionsKeepVelocity(t *testing.T) { | 21 | func TestChainedActionsKeepVelocity(t *testing.T) { | ||
| 20 | - | tests := []struct { | 22 | + | for _, tt := range chainCases() { |
| 21 | - | name string | |||
| 22 | - | setup func(*Core) | |||
| 23 | - | first []Action | |||
| 24 | - | second []Action | |||
| 25 | - | // velocity is the speed of the motion the first action started. | |||
| 26 | - | velocity func(*Core) float64 | |||
| 27 | - | }{ | |||
| 28 | - | {"camera", func(c *Core) { | |||
| 29 | - | m := c.cur().mon | |||
| 30 | - | for id := WindowID(1); id <= 4; id++ { | |||
| 31 | - | m.AddWindow(id) | |||
| 32 | - | } | |||
| 33 | - | }, []Action{ActionFocusColumnLeft, ActionFocusColumnLeft, ActionFocusColumnLeft}, | |||
| 34 | - | []Action{ActionFocusColumnRight, ActionFocusColumnRight, ActionFocusColumnRight}, | |||
| 35 | - | func(c *Core) float64 { return c.cur().mon.Current().motion.velocity() }}, | |||
| 36 | - | {"workspace switch", func(c *Core) { | |||
| 37 | - | m := c.cur().mon | |||
| 38 | - | m.AddWindow(1) | |||
| 39 | - | m.Focus(1) | |||
| 40 | - | m.AddWindow(2) | |||
| 41 | - | m.Focus(0) | |||
| 42 | - | }, []Action{ActionFocusWorkspaceDown}, []Action{ActionFocusWorkspaceDown}, | |||
| 43 | - | func(c *Core) float64 { return c.cur().mon.switchMotion.velocity() }}, | |||
| 44 | - | {"rect", func(c *Core) { | |||
| 45 | - | m := c.cur().mon | |||
| 46 | - | m.AddWindow(1) | |||
| 47 | - | m.AddWindow(2) | |||
| 48 | - | }, []Action{ActionMoveColumnLeft}, []Action{ActionMoveColumnRight}, | |||
| 49 | - | func(c *Core) float64 { return c.cur().rects[1].x.velocity() }}, | |||
| 50 | - | } | |||
| 51 | - | for _, tt := range tests { | |||
| 52 | t.Run(tt.name, func(t *testing.T) { | 23 | t.Run(tt.name, func(t *testing.T) { | ||
| 53 | c, ic := indicatorCore(t) | 24 | c, ic := indicatorCore(t) | ||
| 54 | c.cfg.Animations.On = true | 25 | c.cfg.Animations.On = true | ||
| 55 | tt.setup(c) | 26 | tt.setup(c) | ||
| 56 | settleShown(t, c) | 27 | settleShown(t, c) | ||
| 57 | act := func(actions []Action) { | 28 | act := func(actions []Action) { | ||
| 58 | - | before := c.snapshot() | 29 | + | before := c.snapshot(ic.now) |
| 59 | for _, a := range actions { | 30 | for _, a := range actions { | ||
| 60 | c.applyAction(a) | 31 | c.applyAction(a) | ||
| 61 | } | 32 | } | ||
| @@ -81,6 +52,85 @@ func TestChainedActionsKeepVelocity(t *testing.T) { | |||||
| 81 | } | 52 | } | ||
| 82 | } | 53 | } | ||
| 83 | 54 | ||||
| 55 | + | // An action between two frames chains from the speed its motion has at the | |||
| 56 | + | // action's time (1 ms after the last frame), within 1e-6, not the last | |||
| 57 | + | // frame's. | |||
| 58 | + | func TestChainedActionsSampleVelocityAtActionTime(t *testing.T) { | |||
| 59 | + | for _, tt := range chainCases() { | |||
| 60 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 61 | + | c, ic := indicatorCore(t) | |||
| 62 | + | c.cfg.Animations.On = true | |||
| 63 | + | tt.setup(c) | |||
| 64 | + | settleShown(t, c) | |||
| 65 | + | before := c.snapshot(ic.now) | |||
| 66 | + | for _, a := range tt.first { | |||
| 67 | + | c.applyAction(a) | |||
| 68 | + | } | |||
| 69 | + | c.transition(before, ic.now) | |||
| 70 | + | settleShown(t, c) | |||
| 71 | + | ic.now = ic.now.Add(30 * time.Millisecond) | |||
| 72 | + | c.animate(ic.now, nil) | |||
| 73 | + | settleShown(t, c) | |||
| 74 | + | m := tt.motion(c) | |||
| 75 | + | last := m.velocity() | |||
| 76 | + | ic.now = ic.now.Add(time.Millisecond) | |||
| 77 | + | // The spring's own speed at now, computed without motion.at. | |||
| 78 | + | tau := time.Duration(float64(ic.now.Sub(m.start)) / m.slow) | |||
| 79 | + | want := m.spring.velocityAt(tau) / m.slow | |||
| 80 | + | if math.Abs(want-last) < 1e-3 { | |||
| 81 | + | t.Fatalf("speed %v did not change in 1 ms (%v): the case checks nothing", last, want) | |||
| 82 | + | } | |||
| 83 | + | before = c.snapshot(ic.now) | |||
| 84 | + | for _, a := range tt.second { | |||
| 85 | + | c.applyAction(a) | |||
| 86 | + | } | |||
| 87 | + | c.transition(before, ic.now) | |||
| 88 | + | if got := tt.velocity(c); math.Abs(got-want) > 1e-6 { | |||
| 89 | + | t.Fatalf("velocity %v after the second action, want the sampled %v (last frame's %v)", got, want, last) | |||
| 90 | + | } | |||
| 91 | + | }) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | type chainCase struct { | |||
| 96 | + | name string | |||
| 97 | + | setup func(*Core) | |||
| 98 | + | first []Action | |||
| 99 | + | second []Action | |||
| 100 | + | // motion is the motion the first action started. | |||
| 101 | + | motion func(*Core) motion | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // velocity is the speed of the motion the first action started. | |||
| 105 | + | func (tt chainCase) velocity(c *Core) float64 { return tt.motion(c).velocity() } | |||
| 106 | + | ||||
| 107 | + | func chainCases() []chainCase { | |||
| 108 | + | return []chainCase{ | |||
| 109 | + | {"camera", func(c *Core) { | |||
| 110 | + | m := c.cur().mon | |||
| 111 | + | for id := WindowID(1); id <= 4; id++ { | |||
| 112 | + | m.AddWindow(id) | |||
| 113 | + | } | |||
| 114 | + | }, []Action{ActionFocusColumnLeft, ActionFocusColumnLeft, ActionFocusColumnLeft}, | |||
| 115 | + | []Action{ActionFocusColumnRight, ActionFocusColumnRight, ActionFocusColumnRight}, | |||
| 116 | + | func(c *Core) motion { return c.cur().mon.Current().motion }}, | |||
| 117 | + | {"workspace switch", func(c *Core) { | |||
| 118 | + | m := c.cur().mon | |||
| 119 | + | m.AddWindow(1) | |||
| 120 | + | m.Focus(1) | |||
| 121 | + | m.AddWindow(2) | |||
| 122 | + | m.Focus(0) | |||
| 123 | + | }, []Action{ActionFocusWorkspaceDown}, []Action{ActionFocusWorkspaceDown}, | |||
| 124 | + | func(c *Core) motion { return c.cur().mon.switchMotion }}, | |||
| 125 | + | {"rect", func(c *Core) { | |||
| 126 | + | m := c.cur().mon | |||
| 127 | + | m.AddWindow(1) | |||
| 128 | + | m.AddWindow(2) | |||
| 129 | + | }, []Action{ActionMoveColumnLeft}, []Action{ActionMoveColumnRight}, | |||
| 130 | + | func(c *Core) motion { return c.cur().rects[1].x }}, | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 84 | // The fallback timer guards every animating output: a page flip of one | 134 | // The fallback timer guards every animating output: a page flip of one | ||
| 85 | // output (step with only set) keeps it while another output still animates. | 135 | // output (step with only set) keeps it while another output still animates. | ||
| 86 | func TestStepKeepsFallbackTimerWhileAnotherOutputAnimates(t *testing.T) { | 136 | func TestStepKeepsFallbackTimerWhileAnotherOutputAnimates(t *testing.T) { | ||
| @@ -163,7 +213,7 @@ func TestNoCameraTransitionWhileSwipeFollows(t *testing.T) { | |||||
| 163 | w := sc.mon.Current() | 213 | w := sc.mon.Current() | ||
| 164 | w.shift = 25 | 214 | w.shift = 25 | ||
| 165 | c.swipe = &swipeGesture{screen: sc, ws: w, mode: swipeColumns} | 215 | c.swipe = &swipeGesture{screen: sc, ws: w, mode: swipeColumns} | ||
| 166 | - | before := c.snapshot() | 216 | + | before := c.snapshot(ic.now) |
| 167 | viewX := w.ViewX | 217 | viewX := w.ViewX | ||
| 168 | for range 3 { | 218 | for range 3 { | ||
| 169 | c.applyAction(ActionFocusColumnLeft) | 219 | c.applyAction(ActionFocusColumnLeft) | ||
| @@ -194,7 +244,7 @@ func TestNoSwitchMotionForSizedWorkspace(t *testing.T) { | |||||
| 194 | m.Workspaces[1].SetSize(100, 100) | 244 | m.Workspaces[1].SetSize(100, 100) | ||
| 195 | } | 245 | } | ||
| 196 | settleShown(t, c) | 246 | settleShown(t, c) | ||
| 197 | - | before := c.snapshot() | 247 | + | before := c.snapshot(ic.now) |
| 198 | m.Focus(1) | 248 | m.Focus(1) | ||
| 199 | c.transition(before, ic.now) | 249 | c.transition(before, ic.now) | ||
| 200 | if got := m.switchMotion.on; got == tc.sized { | 250 | if got := m.switchMotion.on; got == tc.sized { | ||
| @@ -206,3 +256,138 @@ func TestNoSwitchMotionForSizedWorkspace(t *testing.T) { | |||||
| 206 | }) | 256 | }) | ||
| 207 | } | 257 | } | ||
| 208 | } | 258 | } | ||
| 259 | + | ||||
| 260 | + | // chainedRects is a core with two windows whose first action moves them, | |||
| 261 | + | // leaving rect motions running one frame in: the clock moved 30 ms and | |||
| 262 | + | // animate sampled them. | |||
| 263 | + | func chainedRects(t *testing.T, first Action) (*Core, *indicatorClock) { | |||
| 264 | + | t.Helper() | |||
| 265 | + | c, ic := indicatorCore(t) | |||
| 266 | + | c.cfg.Animations.On = true | |||
| 267 | + | m := c.cur().mon | |||
| 268 | + | m.AddWindow(1) | |||
| 269 | + | m.AddWindow(2) | |||
| 270 | + | settleShown(t, c) | |||
| 271 | + | before := c.snapshot(ic.now) | |||
| 272 | + | c.applyAction(first) | |||
| 273 | + | c.transition(before, ic.now) | |||
| 274 | + | settleShown(t, c) | |||
| 275 | + | ic.now = ic.now.Add(30 * time.Millisecond) | |||
| 276 | + | c.animate(ic.now, nil) | |||
| 277 | + | settleShown(t, c) | |||
| 278 | + | return c, ic | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | // A bind pressed between two frames chains from the speed the springs have | |||
| 282 | + | // at the bind's time, not the one of the last frame: a second change 1 ms | |||
| 283 | + | // after a frame keeps the sampled speed (within 1e-6) of the component the | |||
| 284 | + | // first one started, for a width change and for a move. | |||
| 285 | + | func TestChainedRectVelocityIsSampledAtActionTime(t *testing.T) { | |||
| 286 | + | for _, tc := range []struct { | |||
| 287 | + | name string | |||
| 288 | + | first, second Action | |||
| 289 | + | comp func(*rectMotion) *motion | |||
| 290 | + | sampled func(rectShot) float64 | |||
| 291 | + | }{ | |||
| 292 | + | {"width", ActionMaximizeColumn, ActionMaximizeColumn, | |||
| 293 | + | func(m *rectMotion) *motion { return &m.w }, func(r rectShot) float64 { return r.vel.w }}, | |||
| 294 | + | {"position", ActionMoveColumnLeft, ActionMoveColumnRight, | |||
| 295 | + | func(m *rectMotion) *motion { return &m.x }, func(r rectShot) float64 { return r.vel.x }}, | |||
| 296 | + | } { | |||
| 297 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 298 | + | c, ic := chainedRects(t, tc.first) | |||
| 299 | + | sc := c.cur() | |||
| 300 | + | id := WindowID(0) | |||
| 301 | + | for k, rm := range sc.rects { | |||
| 302 | + | if tc.comp(&rm).on { | |||
| 303 | + | id = k | |||
| 304 | + | } | |||
| 305 | + | } | |||
| 306 | + | if id == 0 { | |||
| 307 | + | t.Fatalf("no window with a %s motion in %v", tc.name, sc.rects) | |||
| 308 | + | } | |||
| 309 | + | rm := sc.rects[id] | |||
| 310 | + | m := tc.comp(&rm) | |||
| 311 | + | last := m.velocity() | |||
| 312 | + | ic.now = ic.now.Add(time.Millisecond) | |||
| 313 | + | ||||
| 314 | + | // The expected speed is the spring's own at now, sampled independently. | |||
| 315 | + | tau := time.Duration(float64(ic.now.Sub(m.start)) / m.slow) | |||
| 316 | + | want := m.spring.velocityAt(tau) / m.slow | |||
| 317 | + | if math.Abs(want-last) < 1e-3 { | |||
| 318 | + | t.Fatalf("the speed did not change in 1 ms (%v to %v): the case checks nothing", last, want) | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | before := c.snapshot(ic.now) | |||
| 322 | + | var shot rectShot | |||
| 323 | + | for _, r := range before[0].rects { | |||
| 324 | + | if r.id == id { | |||
| 325 | + | shot = r | |||
| 326 | + | } | |||
| 327 | + | } | |||
| 328 | + | if got := tc.sampled(shot); math.Abs(got-want) > 1e-6 { | |||
| 329 | + | t.Fatalf("sampled speed %v, want %v", got, want) | |||
| 330 | + | } | |||
| 331 | + | // Sampling left the motion alone. | |||
| 332 | + | cur := sc.rects[id] | |||
| 333 | + | if got := tc.comp(&cur).velocity(); got != last { | |||
| 334 | + | t.Fatalf("snapshot moved the motion: speed %v, was %v", got, last) | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | c.applyAction(tc.second) | |||
| 338 | + | c.transition(before, ic.now) | |||
| 339 | + | after := sc.rects[id] | |||
| 340 | + | n := tc.comp(&after) | |||
| 341 | + | if !n.on { | |||
| 342 | + | t.Fatalf("the second action left no %s motion", tc.name) | |||
| 343 | + | } | |||
| 344 | + | if v := n.velocity(); math.Abs(v-want) > 1e-6 { | |||
| 345 | + | t.Fatalf("speed %v after the second action, want the sampled %v", v, want) | |||
| 346 | + | } | |||
| 347 | + | }) | |||
| 348 | + | } | |||
| 349 | + | } | |||
| 350 | + | ||||
| 351 | + | // A retarget keeps the sub-pixel offsets: the old rect is the settled one | |||
| 352 | + | // plus the float offsets, not the rounded rect that was drawn. | |||
| 353 | + | func TestRetargetKeepsSubPixelOffsets(t *testing.T) { | |||
| 354 | + | c, ic := chainedRects(t, ActionMoveColumnLeft) | |||
| 355 | + | sc := c.cur() | |||
| 356 | + | rm := sc.rects[1] | |||
| 357 | + | if rm.dx == math.Round(rm.dx) { | |||
| 358 | + | t.Fatalf("dx = %v is whole: the case checks nothing", rm.dx) | |||
| 359 | + | } | |||
| 360 | + | settledOld := sc.settledLayout[slices.IndexFunc(sc.settledLayout, func(p Placement) bool { return p.ID == 1 })].Rect | |||
| 361 | + | ||||
| 362 | + | before := c.snapshot(ic.now) | |||
| 363 | + | var shot rectShot | |||
| 364 | + | for _, r := range before[0].rects { | |||
| 365 | + | if r.id == 1 { | |||
| 366 | + | shot = r | |||
| 367 | + | } | |||
| 368 | + | } | |||
| 369 | + | if shot.rect != settledOld { | |||
| 370 | + | t.Fatalf("shot rect %+v, want the settled %+v", shot.rect, settledOld) | |||
| 371 | + | } | |||
| 372 | + | if shot.off.x != rm.dx || shot.off.w != rm.dw { | |||
| 373 | + | t.Fatalf("shot offsets %+v, want the motion's x %v w %v", shot.off, rm.dx, rm.dw) | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | c.applyAction(ActionMoveColumnRight) | |||
| 377 | + | c.transition(before, ic.now) | |||
| 378 | + | settledNew := c.cur().mon.Layout()[slices.IndexFunc(c.cur().mon.Layout(), func(p Placement) bool { return p.ID == 1 })].Rect | |||
| 379 | + | if settledNew == settledOld { | |||
| 380 | + | t.Fatal("the second action did not move window 1") | |||
| 381 | + | } | |||
| 382 | + | got := sc.rects[1] | |||
| 383 | + | // Old rect = settled old + float offset: the new offset from the new | |||
| 384 | + | // settled rect carries the fraction of the old one. | |||
| 385 | + | want := float64(settledOld.X-settledNew.X) + rm.dx | |||
| 386 | + | if math.Abs(got.dx-want) > 1e-9 { | |||
| 387 | + | t.Fatalf("dx = %v after the retarget, want %v (settled %d to %d plus the offset %v)", got.dx, want, settledOld.X, settledNew.X, rm.dx) | |||
| 388 | + | } | |||
| 389 | + | rounded := float64(settledOld.X-settledNew.X) + math.Round(rm.dx) | |||
| 390 | + | if math.Abs(got.dx-rounded) < 1e-6 { | |||
| 391 | + | t.Fatalf("dx = %v equals the rounded rect's: the fraction was lost", got.dx) | |||
| 392 | + | } | |||
| 393 | + | } | |||