detached e92a9c4

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
1818 sc.mon.AddWindow(2)
1919 settleShown(t, c)
2020 c.focusScreen = c.screenIndex(sc.name())
21- before := c.snapshot()
21+ before := c.snapshot(ic.now)
2222 c.applyAction(ActionMoveColumnLeft)
2323 c.transition(before, ic.now)
2424 if len(sc.rects) != 2 {
@@ -173,7 +173,7 @@ func TestLastOutputUnplugMidRectMotion(t *testing.T) {
173173 }
174174
175175 // A new move animates from the settled rects and lands.
176- before := c.snapshot()
176+ before := c.snapshot(ic.now)
177177 c.applyAction(ActionMoveColumnRight)
178178 c.transition(before, ic.now)
179179 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 {
132132 swipe *swipeGesture
133133 frameC <-chan time.Time
134134 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
138135 // shots is the snapshot of what the screens show before an action
139136 // (transition.go), reused by every action.
140137 shots []viewShot
@@ -499,7 +496,13 @@ func (c *Core) setLayers(all []ports.LayerSurface) {
499496 }
500497 }
501498
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 {
503506 if c.inputEpochChanged() {
504507 return errSecurityChanged
505508 }
@@ -543,7 +546,7 @@ func (c *Core) publish(ctx context.Context) error {
543546 // its value so the focused scene keeps its Seq.
544547 if c.pulse.id == 0 {
545548 c.pulse.value = 0
546- } else if c.stepping == nil || c.stepping == c.cur() {
549+ } else if only == nil || only == c.cur() {
547550 c.pulse.value = c.advancePulse(c.now())
548551 }
549552 pulse := c.pulse.value
@@ -645,7 +648,7 @@ func (c *Core) publish(ctx context.Context) error {
645648 // keeps its output's Seq while it draws the same, so an idle output
646649 // is not recomposed.
647650 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)
649652 if !withCapture && !animating && sc.last.Seq != 0 && scene.SameAs(sc.last) {
650653 scene.Seq = sc.last.Seq
651654 } else {
@@ -689,7 +692,7 @@ func (c *Core) publish(ctx context.Context) error {
689692 return errSecurityChanged
690693 }
691694 if c.syncSecurity() {
692- return c.publish(ctx)
695+ return c.publishFrame(ctx, only)
693696 }
694697 latest(c.ch.Scenes, scenes)
695698 c.publishState()
@@ -715,9 +718,7 @@ func (c *Core) step(ctx context.Context, only *screen) error {
715718 c.stopFrame()
716719 }
717720 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)
721722 }
722723
723724 // animatingOther reports whether a spring or the pulse runs on a screen
@@ -734,10 +735,15 @@ func (c *Core) animatingOther(only *screen) bool {
734735 // slid publishes a moved view; shown reports another workspace came on
735736 // screen.
736737 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 {
737743 if err := c.workspaceVisible(ctx, shown); err != nil {
738744 return err
739745 }
740- if err := c.publish(ctx); err != nil {
746+ if err := c.publishFrame(ctx, only); err != nil {
741747 return err
742748 }
743749 return c.rehit(ctx)
@@ -132,9 +132,6 @@ type Core struct {
132 swipe *swipeGesture132 swipe *swipeGesture
133 frameC <-chan time.Time133 frameC <-chan time.Time
134 frameStop func() bool134 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 action135 // 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 []viewShot137 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 errSecurityChanged507 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 = 0548 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.value552 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 output648 // 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.Seq653 scene.Seq = sc.last.Seq
651 } else {654 } else {
@@ -689,7 +692,7 @@ func (c *Core) publish(ctx context.Context) error {
689 return errSecurityChanged692 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 = only721+ 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 screen724 // 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 on735 // 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 err744 return err
739 }745 }
740- if err := c.publish(ctx); err != nil {746+ if err := c.publishFrame(ctx, only); err != nil {
741 return err747 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) {
279279 return false
280280 }
281281 before := c.cur().mon.Current()
282- shots := c.snapshot()
282+ shots := c.snapshot(now)
283283 c.keyboard.takeBack()
284284 if mon := c.cur().mon; mon.ov.open {
285285 mon.overviewFocus(a)
@@ -279,7 +279,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
279 return false279 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 {
125125 // A click focuses the window and its output.
126126 s, w := c.screenOf(id)
127127 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())
129129 w.Click(id)
130130 c.focusScreen = c.screenIndex(s.name())
131131 c.transition(before, c.now())
@@ -262,7 +262,7 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
262262 }
263263 before := c.cur().mon.Current()
264264 swiped := c.swipedWorkspace()
265- shots := c.snapshot()
265+ shots := c.snapshot(c.now())
266266 c.keyboard.takeBack() // a bind acts on the windows
267267 effect := c.applyAction(action)
268268 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 windows266 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) {
214214 return m.spring.valueAt(tau), false
215215 }
216216
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+
217224 // velocity is the speed of the last sample, in units per second.
218225 func (m motion) velocity() float64 { return m.v }
219226
@@ -214,6 +214,13 @@ func (m *motion) at(now time.Time) (float64, bool) {
214 return m.spring.valueAt(tau), false214 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) {
209209 m.AddWindow(id)
210210 }
211211 indicatorScene(t, c)
212- before := c.snapshot()
212+ before := c.snapshot(now)
213213 for range 3 {
214214 c.applyAction(ActionFocusColumnLeft)
215215 }
@@ -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 {
2727 // (viewX plus shift).
2828 viewX int
2929 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
3233 // (Monitor.Focus), so the transition reads them here.
3334 viewV, switchV float64
3435 // list and pos are the numbered workspaces and the monitor's fractional
@@ -37,15 +38,22 @@ type viewShot struct {
3738 list []*Workspace
3839 pos float64
3940 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.
4243 rects []rectShot
4344 }
4445
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.
4653 type rectShot struct {
47- id WindowID
48- rect Rect
54+ id WindowID
55+ rect Rect
56+ off, vel rectOffsets
4957 }
5058
5159 // rectMotion takes a window's drawn rect to its settled one: each component
@@ -64,6 +72,23 @@ func (m *rectMotion) apply(r Rect) Rect {
6472 }
6573 }
6674
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+
6792 // advance moves the offsets to now; a settled component stops at 0.
6893 func (m *rectMotion) advance(now time.Time) {
6994 m.dx = stepComponent(&m.x, m.dx, now)
@@ -88,13 +113,9 @@ func stepComponent(m *motion, cur float64, now time.Time) float64 {
88113 func (m *rectMotion) on() bool { return m.x.on || m.y.on || m.w.on || m.h.on }
89114
90115 // 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
92117 // 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) {
98119 *cur = off
99120 if off == 0 && v == 0 {
100121 *m = motion{}
@@ -144,10 +165,14 @@ func (c *Core) refreshShown() {
144165 }
145166 }
146167
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 {
151176 full := c.shots[:cap(c.shots)]
152177 out := c.shots[:0]
153178 for _, sc := range c.screens {
@@ -162,15 +187,22 @@ func (c *Core) snapshot() []viewShot {
162187 w := m.Current()
163188 s.ws, s.viewX, s.view = w, w.ViewX, float64(w.ViewX)+w.shift
164189 if w.motion.on {
165- s.viewV = w.motion.velocity()
190+ _, s.viewV = w.motion.sampleAt(now)
166191 }
167192 if m.switchMotion.on {
168- s.switchV = m.switchMotion.velocity()
193+ _, s.switchV = m.switchMotion.sampleAt(now)
169194 }
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)
173204 }
205+ s.rects = append(s.rects, r)
174206 }
175207 if m.shown == nil {
176208 // A landing slide measures in the list it began on.
@@ -267,21 +299,21 @@ func (c *Core) transitionRects(b *viewShot, now time.Time) {
267299 if i < 0 {
268300 continue
269301 }
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
276307 }
277308 if sc.rects == nil {
278309 sc.rects = make(map[WindowID]rectMotion)
279310 }
311+ // Where the window was drawn, unrounded, relative to where it goes.
280312 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)
285317 if rm.on() {
286318 sc.rects[p.ID], sc.rectsWS = rm, b.ws
287319 } else {
@@ -27,8 +27,9 @@ type viewShot struct {
27 // (viewX plus shift).27 // (viewX plus shift).
28 viewX int28 viewX int
29 view float6429 view float64
30- // viewV and switchV are the speeds of the running view and workspace30+ // viewV and switchV are the speeds the running view and workspace
31- // springs (units per second, 0 without one). An action may stop them31+ // 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 float6434 viewV, switchV float64
34 // list and pos are the numbered workspaces and the monitor's fractional35 // list and pos are the numbered workspaces and the monitor's fractional
@@ -37,15 +38,22 @@ type viewShot struct {
37 list []*Workspace38 list []*Workspace
38 pos float6439 pos float64
39 ok bool40 ok bool
40- // rects are the rects the screen drew for the windows of ws, hidden41+ // 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 []rectShot43 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 WindowID54+ id WindowID
48- rect Rect55+ rect Rect
56+ off, vel rectOffsets
49 }57 }
50 58
51 // rectMotion takes a window's drawn rect to its settled one: each component59 // rectMotion takes a window's drawn rect to its settled one: each component
@@ -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 at115 // 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 no116+// 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 = off119 *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. The168+// 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 the169+// 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.shift188 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 continue300 continue
269 }301 }
270- old := b.rects[i].rect302+ 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- continue306+ 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.ws318 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
22
33 import (
44 "context"
5+ "math"
6+ "slices"
57 "testing"
68 "time"
79
@@ -17,45 +19,14 @@ func settleShown(t *testing.T, c *Core) {
1719 // A bind that stops a spring while it acts (Monitor.Focus) must not lose its
1820 // speed: the transition that follows starts from what the snapshot recorded.
1921 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() {
5223 t.Run(tt.name, func(t *testing.T) {
5324 c, ic := indicatorCore(t)
5425 c.cfg.Animations.On = true
5526 tt.setup(c)
5627 settleShown(t, c)
5728 act := func(actions []Action) {
58- before := c.snapshot()
29+ before := c.snapshot(ic.now)
5930 for _, a := range actions {
6031 c.applyAction(a)
6132 }
@@ -81,6 +52,85 @@ func TestChainedActionsKeepVelocity(t *testing.T) {
8152 }
8253 }
8354
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+
84134 // The fallback timer guards every animating output: a page flip of one
85135 // output (step with only set) keeps it while another output still animates.
86136 func TestStepKeepsFallbackTimerWhileAnotherOutputAnimates(t *testing.T) {
@@ -163,7 +213,7 @@ func TestNoCameraTransitionWhileSwipeFollows(t *testing.T) {
163213 w := sc.mon.Current()
164214 w.shift = 25
165215 c.swipe = &swipeGesture{screen: sc, ws: w, mode: swipeColumns}
166- before := c.snapshot()
216+ before := c.snapshot(ic.now)
167217 viewX := w.ViewX
168218 for range 3 {
169219 c.applyAction(ActionFocusColumnLeft)
@@ -194,7 +244,7 @@ func TestNoSwitchMotionForSizedWorkspace(t *testing.T) {
194244 m.Workspaces[1].SetSize(100, 100)
195245 }
196246 settleShown(t, c)
197- before := c.snapshot()
247+ before := c.snapshot(ic.now)
198248 m.Focus(1)
199249 c.transition(before, ic.now)
200250 if got := m.switchMotion.on; got == tc.sized {
@@ -206,3 +256,138 @@ func TestNoSwitchMotionForSizedWorkspace(t *testing.T) {
206256 })
207257 }
208258 }
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 its19 // 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 = true25 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 one134 // 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 = 25214 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.ViewX217 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close