feat(core): spring velocity, value-type motion with slowdown, retarget keeps velocity
- commit
- 5c55b105eb5702c80d44b73d33209557f7e16c53
- author
- brice · · 2026-10-04 15:39 UTC
- committer
- brice · · 2026-10-04 19:08 UTC
- parent
- d26124e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +193 -21
M internal/core/capture_workspace_test.go +2 -2
| @@ -193,7 +193,7 @@ func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) { | |||
| 193 | 193 | c.capt.sessions = []*capSession{{open: ports.CaptureSessionOpen{ID: 1, Workspace: ws.ID}}} | |
| 194 | 194 | c.popups[10] = &popupState{id: 10, parent: 1, rect: Rect{X: 3, Y: 4, W: 10, H: 8}, mapped: true} | |
| 195 | 195 | c.popupOrder = append(c.popupOrder, 10) | |
| 196 | - | ws.motion = newMotion(viewSpring(10, 0), time.Time{}) | |
| 196 | + | ws.motion = newMotion(viewSpring(10, 0), time.Time{}, 1) | |
| 197 | 197 | ctx := context.Background() | |
| 198 | 198 | if err := c.publish(ctx); err != nil { | |
| 199 | 199 | t.Fatal(err) | |
| @@ -207,7 +207,7 @@ func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) { | |||
| 207 | 207 | if v, ok := c.configures.sent[1]; !ok || !v.Captured || v.Visible || v.Activated || v.Width <= 0 { | |
| 208 | 208 | t.Fatalf("window during the slide %+v (sent %v)", v, ok) | |
| 209 | 209 | } | |
| 210 | - | ws.motion = nil | |
| 210 | + | ws.motion = motion{} | |
| 211 | 211 | if err := c.publish(ctx); err != nil { | |
| 212 | 212 | t.Fatal(err) | |
| 213 | 213 | } | |
| @@ -193,7 +193,7 @@ func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) { | |||||
| 193 | c.capt.sessions = []*capSession{{open: ports.CaptureSessionOpen{ID: 1, Workspace: ws.ID}}} | 193 | c.capt.sessions = []*capSession{{open: ports.CaptureSessionOpen{ID: 1, Workspace: ws.ID}}} | ||
| 194 | c.popups[10] = &popupState{id: 10, parent: 1, rect: Rect{X: 3, Y: 4, W: 10, H: 8}, mapped: true} | 194 | c.popups[10] = &popupState{id: 10, parent: 1, rect: Rect{X: 3, Y: 4, W: 10, H: 8}, mapped: true} | ||
| 195 | c.popupOrder = append(c.popupOrder, 10) | 195 | c.popupOrder = append(c.popupOrder, 10) | ||
| 196 | - | ws.motion = newMotion(viewSpring(10, 0), time.Time{}) | 196 | + | ws.motion = newMotion(viewSpring(10, 0), time.Time{}, 1) |
| 197 | ctx := context.Background() | 197 | ctx := context.Background() | ||
| 198 | if err := c.publish(ctx); err != nil { | 198 | if err := c.publish(ctx); err != nil { | ||
| 199 | t.Fatal(err) | 199 | t.Fatal(err) | ||
| @@ -207,7 +207,7 @@ func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) { | |||||
| 207 | if v, ok := c.configures.sent[1]; !ok || !v.Captured || v.Visible || v.Activated || v.Width <= 0 { | 207 | if v, ok := c.configures.sent[1]; !ok || !v.Captured || v.Visible || v.Activated || v.Width <= 0 { | ||
| 208 | t.Fatalf("window during the slide %+v (sent %v)", v, ok) | 208 | t.Fatalf("window during the slide %+v (sent %v)", v, ok) | ||
| 209 | } | 209 | } | ||
| 210 | - | ws.motion = nil | 210 | + | ws.motion = motion{} |
| 211 | if err := c.publish(ctx); err != nil { | 211 | if err := c.publish(ctx); err != nil { | ||
| 212 | t.Fatal(err) | 212 | t.Fatal(err) | ||
| 213 | } | 213 | } | ||
M internal/core/gesture.go +8 -8
| @@ -171,7 +171,7 @@ func (c *Core) decide(g *swipeGesture) { | |||
| 171 | 171 | g.mode, g.snap = swipeDiscrete, newStepSwipe() | |
| 172 | 172 | case g.horizontal && w.slidable(): | |
| 173 | 173 | g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints() | |
| 174 | - | w.motion = nil | |
| 174 | + | w.motion = motion{} | |
| 175 | 175 | g.snap = newSnapSwipe(float64(w.ViewX)+w.shift, float64(w.ViewX), w.swipeScale(), g.points, workspaceBand.scaled(float64(w.Usable.W))) | |
| 176 | 176 | case !g.horizontal && m.shown == nil: | |
| 177 | 177 | // A landing slide measured in an older list lands at once first. | |
| @@ -180,7 +180,7 @@ func (c *Core) decide(g *swipeGesture) { | |||
| 180 | 180 | } | |
| 181 | 181 | g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active] | |
| 182 | 182 | g.list = slices.Clone(m.Workspaces) | |
| 183 | - | m.switchMotion, m.switchList = nil, nil | |
| 183 | + | m.switchMotion, m.switchList = motion{}, nil | |
| 184 | 184 | g.snap = newSnapSwipe(float64(m.Active)+m.switchOff, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand) | |
| 185 | 185 | default: | |
| 186 | 186 | g.mode, g.snap = swipeDiscrete, newStepSwipe() | |
| @@ -216,7 +216,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 216 | 216 | c.keyboard.takeBack() | |
| 217 | 217 | } | |
| 218 | 218 | w.shift = shown - float64(w.ViewX) | |
| 219 | - | w.motion = newMotion(viewSpring(w.shift, velocity), now) | |
| 219 | + | w.motion = newMotion(viewSpring(w.shift, velocity), now, 1) | |
| 220 | 220 | case swipeWorkspaces: | |
| 221 | 221 | if g.listChanged(m) { | |
| 222 | 222 | m.stopSwitch() | |
| @@ -240,7 +240,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 240 | 240 | c.keyboard.takeBack() | |
| 241 | 241 | } | |
| 242 | 242 | m.switchOff, m.switchList = off, g.list | |
| 243 | - | m.switchMotion = newMotion(workspaceSpring(off, velocity), now) | |
| 243 | + | m.switchMotion = newMotion(workspaceSpring(off, velocity), now, 1) | |
| 244 | 244 | case swipeOverview: | |
| 245 | 245 | step := g.snap.step(e.Cancelled, e.Time) | |
| 246 | 246 | if step == 0 || (step < 0) == m.ov.open { | |
| @@ -321,7 +321,7 @@ func (c *Core) hasScreen(s *screen) bool { | |||
| 321 | 321 | func (c *Core) animate(now time.Time) { | |
| 322 | 322 | for _, sc := range c.screens { | |
| 323 | 323 | m := sc.mon | |
| 324 | - | if m.switchMotion != nil { | |
| 324 | + | if m.switchMotion.on { | |
| 325 | 325 | v, done := m.switchMotion.at(now) | |
| 326 | 326 | m.switchOff = v | |
| 327 | 327 | if done { | |
| @@ -329,7 +329,7 @@ func (c *Core) animate(now time.Time) { | |||
| 329 | 329 | } | |
| 330 | 330 | } | |
| 331 | 331 | for w := range m.all() { | |
| 332 | - | if w.motion != nil { | |
| 332 | + | if w.motion.on { | |
| 333 | 333 | v, done := w.motion.at(now) | |
| 334 | 334 | w.shift = v | |
| 335 | 335 | if done { | |
| @@ -360,11 +360,11 @@ func (c *Core) animating() bool { | |||
| 360 | 360 | } | |
| 361 | 361 | ||
| 362 | 362 | func (m *Monitor) springing() bool { | |
| 363 | - | if m.switchMotion != nil { | |
| 363 | + | if m.switchMotion.on { | |
| 364 | 364 | return true | |
| 365 | 365 | } | |
| 366 | 366 | for w := range m.all() { | |
| 367 | - | if w.motion != nil { | |
| 367 | + | if w.motion.on { | |
| 368 | 368 | return true | |
| 369 | 369 | } | |
| 370 | 370 | } | |
| @@ -171,7 +171,7 @@ func (c *Core) decide(g *swipeGesture) { | |||||
| 171 | g.mode, g.snap = swipeDiscrete, newStepSwipe() | 171 | g.mode, g.snap = swipeDiscrete, newStepSwipe() | ||
| 172 | case g.horizontal && w.slidable(): | 172 | case g.horizontal && w.slidable(): | ||
| 173 | g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints() | 173 | g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints() | ||
| 174 | - | w.motion = nil | 174 | + | w.motion = motion{} |
| 175 | g.snap = newSnapSwipe(float64(w.ViewX)+w.shift, float64(w.ViewX), w.swipeScale(), g.points, workspaceBand.scaled(float64(w.Usable.W))) | 175 | g.snap = newSnapSwipe(float64(w.ViewX)+w.shift, float64(w.ViewX), w.swipeScale(), g.points, workspaceBand.scaled(float64(w.Usable.W))) | ||
| 176 | case !g.horizontal && m.shown == nil: | 176 | case !g.horizontal && m.shown == nil: | ||
| 177 | // A landing slide measured in an older list lands at once first. | 177 | // A landing slide measured in an older list lands at once first. | ||
| @@ -180,7 +180,7 @@ func (c *Core) decide(g *swipeGesture) { | |||||
| 180 | } | 180 | } | ||
| 181 | g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active] | 181 | g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active] | ||
| 182 | g.list = slices.Clone(m.Workspaces) | 182 | g.list = slices.Clone(m.Workspaces) | ||
| 183 | - | m.switchMotion, m.switchList = nil, nil | 183 | + | m.switchMotion, m.switchList = motion{}, nil |
| 184 | g.snap = newSnapSwipe(float64(m.Active)+m.switchOff, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand) | 184 | g.snap = newSnapSwipe(float64(m.Active)+m.switchOff, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand) | ||
| 185 | default: | 185 | default: | ||
| 186 | g.mode, g.snap = swipeDiscrete, newStepSwipe() | 186 | g.mode, g.snap = swipeDiscrete, newStepSwipe() | ||
| @@ -216,7 +216,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||||
| 216 | c.keyboard.takeBack() | 216 | c.keyboard.takeBack() | ||
| 217 | } | 217 | } | ||
| 218 | w.shift = shown - float64(w.ViewX) | 218 | w.shift = shown - float64(w.ViewX) | ||
| 219 | - | w.motion = newMotion(viewSpring(w.shift, velocity), now) | 219 | + | w.motion = newMotion(viewSpring(w.shift, velocity), now, 1) |
| 220 | case swipeWorkspaces: | 220 | case swipeWorkspaces: | ||
| 221 | if g.listChanged(m) { | 221 | if g.listChanged(m) { | ||
| 222 | m.stopSwitch() | 222 | m.stopSwitch() | ||
| @@ -240,7 +240,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||||
| 240 | c.keyboard.takeBack() | 240 | c.keyboard.takeBack() | ||
| 241 | } | 241 | } | ||
| 242 | m.switchOff, m.switchList = off, g.list | 242 | m.switchOff, m.switchList = off, g.list | ||
| 243 | - | m.switchMotion = newMotion(workspaceSpring(off, velocity), now) | 243 | + | m.switchMotion = newMotion(workspaceSpring(off, velocity), now, 1) |
| 244 | case swipeOverview: | 244 | case swipeOverview: | ||
| 245 | step := g.snap.step(e.Cancelled, e.Time) | 245 | step := g.snap.step(e.Cancelled, e.Time) | ||
| 246 | if step == 0 || (step < 0) == m.ov.open { | 246 | if step == 0 || (step < 0) == m.ov.open { | ||
| @@ -321,7 +321,7 @@ func (c *Core) hasScreen(s *screen) bool { | |||||
| 321 | func (c *Core) animate(now time.Time) { | 321 | func (c *Core) animate(now time.Time) { | ||
| 322 | for _, sc := range c.screens { | 322 | for _, sc := range c.screens { | ||
| 323 | m := sc.mon | 323 | m := sc.mon | ||
| 324 | - | if m.switchMotion != nil { | 324 | + | if m.switchMotion.on { |
| 325 | v, done := m.switchMotion.at(now) | 325 | v, done := m.switchMotion.at(now) | ||
| 326 | m.switchOff = v | 326 | m.switchOff = v | ||
| 327 | if done { | 327 | if done { | ||
| @@ -329,7 +329,7 @@ func (c *Core) animate(now time.Time) { | |||||
| 329 | } | 329 | } | ||
| 330 | } | 330 | } | ||
| 331 | for w := range m.all() { | 331 | for w := range m.all() { | ||
| 332 | - | if w.motion != nil { | 332 | + | if w.motion.on { |
| 333 | v, done := w.motion.at(now) | 333 | v, done := w.motion.at(now) | ||
| 334 | w.shift = v | 334 | w.shift = v | ||
| 335 | if done { | 335 | if done { | ||
| @@ -360,11 +360,11 @@ func (c *Core) animating() bool { | |||||
| 360 | } | 360 | } | ||
| 361 | 361 | ||||
| 362 | func (m *Monitor) springing() bool { | 362 | func (m *Monitor) springing() bool { | ||
| 363 | - | if m.switchMotion != nil { | 363 | + | if m.switchMotion.on { |
| 364 | return true | 364 | return true | ||
| 365 | } | 365 | } | ||
| 366 | for w := range m.all() { | 366 | for w := range m.all() { | ||
| 367 | - | if w.motion != nil { | 367 | + | if w.motion.on { |
| 368 | return true | 368 | return true | ||
| 369 | } | 369 | } | ||
| 370 | } | 370 | } | ||
M internal/core/layout.go +1 -1
| @@ -154,7 +154,7 @@ type Workspace struct { | |||
| 154 | 154 | // shift slides the columns on screen past ViewX, in logical pixels, | |
| 155 | 155 | // while a swipe follows the fingers or its spring (motion) lands. | |
| 156 | 156 | shift float64 | |
| 157 | - | motion *motion | |
| 157 | + | motion motion | |
| 158 | 158 | // Output is the effective viewport in monitor coordinates: the whole | |
| 159 | 159 | // monitor unless the workspace has a size override, then a centered | |
| 160 | 160 | // rectangle no larger than the monitor. Fullscreen fills it. | |
| @@ -154,7 +154,7 @@ type Workspace struct { | |||||
| 154 | // shift slides the columns on screen past ViewX, in logical pixels, | 154 | // shift slides the columns on screen past ViewX, in logical pixels, | ||
| 155 | // while a swipe follows the fingers or its spring (motion) lands. | 155 | // while a swipe follows the fingers or its spring (motion) lands. | ||
| 156 | shift float64 | 156 | shift float64 | ||
| 157 | - | motion *motion | 157 | + | motion motion |
| 158 | // Output is the effective viewport in monitor coordinates: the whole | 158 | // Output is the effective viewport in monitor coordinates: the whole | ||
| 159 | // monitor unless the workspace has a size override, then a centered | 159 | // monitor unless the workspace has a size override, then a centered | ||
| 160 | // rectangle no larger than the monitor. Fullscreen fills it. | 160 | // rectangle no larger than the monitor. Fullscreen fills it. | ||
M internal/core/monitor.go +1 -1
| @@ -34,7 +34,7 @@ type Monitor struct { | |||
| 34 | 34 | // at Active+switchOff while a swipe follows the fingers or its spring | |
| 35 | 35 | // (switchMotion) lands. | |
| 36 | 36 | switchOff float64 | |
| 37 | - | switchMotion *motion | |
| 37 | + | switchMotion motion | |
| 38 | 38 | // switchList is the numbered list a landing slide measures from (the | |
| 39 | 39 | // one its swipe began on); switchOff is then from the current | |
| 40 | 40 | // workspace's place in it. | |
| @@ -34,7 +34,7 @@ type Monitor struct { | |||||
| 34 | // at Active+switchOff while a swipe follows the fingers or its spring | 34 | // at Active+switchOff while a swipe follows the fingers or its spring | ||
| 35 | // (switchMotion) lands. | 35 | // (switchMotion) lands. | ||
| 36 | switchOff float64 | 36 | switchOff float64 | ||
| 37 | - | switchMotion *motion | 37 | + | switchMotion motion |
| 38 | // switchList is the numbered list a landing slide measures from (the | 38 | // switchList is the numbered list a landing slide measures from (the | ||
| 39 | // one its swipe began on); switchOff is then from the current | 39 | // one its swipe began on); switchOff is then from the current | ||
| 40 | // workspace's place in it. | 40 | // workspace's place in it. | ||
M internal/core/motion.go +49 -4
| @@ -150,16 +150,55 @@ func (s spring) duration() time.Duration { | |||
| 150 | 150 | ||
| 151 | 151 | func seconds(s float64) time.Duration { return time.Duration(s * float64(time.Second)) } | |
| 152 | 152 | ||
| 153 | + | // velocityAt is the spring speed t after it started, in units per second. | |
| 154 | + | func (s spring) velocityAt(t time.Duration) float64 { | |
| 155 | + | sec := t.Seconds() | |
| 156 | + | beta, omega0 := s.coefficients() | |
| 157 | + | x0 := s.From - s.To | |
| 158 | + | envelope := math.Exp(-beta * sec) | |
| 159 | + | switch { | |
| 160 | + | case math.Abs(beta-omega0) <= f32Epsilon: | |
| 161 | + | // Critically damped. | |
| 162 | + | return envelope * (s.Velocity - beta*(beta*x0+s.Velocity)*sec) | |
| 163 | + | case beta < omega0: | |
| 164 | + | w := math.Sqrt(omega0*omega0 - beta*beta) | |
| 165 | + | b := (beta*x0 + s.Velocity) / w | |
| 166 | + | cos, sin := math.Cos(w*sec), math.Sin(w*sec) | |
| 167 | + | return envelope * (-beta*(x0*cos+b*sin) + w*(b*cos-x0*sin)) | |
| 168 | + | default: | |
| 169 | + | w := math.Sqrt(beta*beta - omega0*omega0) | |
| 170 | + | b := (beta*x0 + s.Velocity) / w | |
| 171 | + | cosh, sinh := math.Cosh(w*sec), math.Sinh(w*sec) | |
| 172 | + | return envelope * (-beta*(x0*cosh+b*sinh) + w*(x0*sinh+b*cosh)) | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 153 | 176 | // motion is a spring started at a given time; a zero start begins at the | |
| 154 | - | // first frame. | |
| 177 | + | // first frame. It is a value: on tells whether one runs. slow stretches | |
| 178 | + | // the spring's time (a slowdown of 2 takes twice as long) and v is the | |
| 179 | + | // current speed in units per second, so a retarget can keep it. | |
| 155 | 180 | type motion struct { | |
| 156 | 181 | spring spring | |
| 157 | 182 | start time.Time | |
| 158 | 183 | end time.Duration | |
| 184 | + | slow float64 | |
| 185 | + | v float64 | |
| 186 | + | on bool | |
| 159 | 187 | } | |
| 160 | 188 | ||
| 161 | - | func newMotion(s spring, now time.Time) *motion { | |
| 162 | - | return &motion{spring: s, start: now, end: s.duration()} | |
| 189 | + | // newMotion starts s at now, slowed down slow times (slow <= 0 is 1). The | |
| 190 | + | // spring's Velocity is in units per second of real time. | |
| 191 | + | func newMotion(s spring, now time.Time, slow float64) motion { | |
| 192 | + | if slow <= 0 { | |
| 193 | + | slow = 1 | |
| 194 | + | } | |
| 195 | + | v := s.Velocity | |
| 196 | + | s.Velocity *= slow | |
| 197 | + | end := time.Duration(math.MaxInt64) | |
| 198 | + | if f := float64(s.duration()) * slow; f < float64(math.MaxInt64) { | |
| 199 | + | end = time.Duration(f) | |
| 200 | + | } | |
| 201 | + | return motion{spring: s, start: now, end: end, slow: slow, v: v, on: true} | |
| 163 | 202 | } | |
| 164 | 203 | ||
| 165 | 204 | // at is the position at now and whether the motion has settled. | |
| @@ -169,11 +208,17 @@ func (m *motion) at(now time.Time) (float64, bool) { | |||
| 169 | 208 | } | |
| 170 | 209 | t := now.Sub(m.start) | |
| 171 | 210 | if t >= m.end { | |
| 211 | + | m.v = 0 | |
| 172 | 212 | return m.spring.To, true | |
| 173 | 213 | } | |
| 174 | - | return m.spring.valueAt(max(t, 0)), false | |
| 214 | + | tau := time.Duration(float64(max(t, 0)) / m.slow) | |
| 215 | + | m.v = m.spring.velocityAt(tau) / m.slow | |
| 216 | + | return m.spring.valueAt(tau), false | |
| 175 | 217 | } | |
| 176 | 218 | ||
| 219 | + | // velocity is the speed of the last sample, in units per second. | |
| 220 | + | func (m motion) velocity() float64 { return m.v } | |
| 221 | + | ||
| 177 | 222 | // rubberBand resists movement past the edges: the further out, the less | |
| 178 | 223 | // it moves, never more than limit. | |
| 179 | 224 | type rubberBand struct{ stiffness, limit float64 } | |
| @@ -150,16 +150,55 @@ func (s spring) duration() time.Duration { | |||||
| 150 | 150 | ||||
| 151 | func seconds(s float64) time.Duration { return time.Duration(s * float64(time.Second)) } | 151 | func seconds(s float64) time.Duration { return time.Duration(s * float64(time.Second)) } | ||
| 152 | 152 | ||||
| 153 | + | // velocityAt is the spring speed t after it started, in units per second. | |||
| 154 | + | func (s spring) velocityAt(t time.Duration) float64 { | |||
| 155 | + | sec := t.Seconds() | |||
| 156 | + | beta, omega0 := s.coefficients() | |||
| 157 | + | x0 := s.From - s.To | |||
| 158 | + | envelope := math.Exp(-beta * sec) | |||
| 159 | + | switch { | |||
| 160 | + | case math.Abs(beta-omega0) <= f32Epsilon: | |||
| 161 | + | // Critically damped. | |||
| 162 | + | return envelope * (s.Velocity - beta*(beta*x0+s.Velocity)*sec) | |||
| 163 | + | case beta < omega0: | |||
| 164 | + | w := math.Sqrt(omega0*omega0 - beta*beta) | |||
| 165 | + | b := (beta*x0 + s.Velocity) / w | |||
| 166 | + | cos, sin := math.Cos(w*sec), math.Sin(w*sec) | |||
| 167 | + | return envelope * (-beta*(x0*cos+b*sin) + w*(b*cos-x0*sin)) | |||
| 168 | + | default: | |||
| 169 | + | w := math.Sqrt(beta*beta - omega0*omega0) | |||
| 170 | + | b := (beta*x0 + s.Velocity) / w | |||
| 171 | + | cosh, sinh := math.Cosh(w*sec), math.Sinh(w*sec) | |||
| 172 | + | return envelope * (-beta*(x0*cosh+b*sinh) + w*(x0*sinh+b*cosh)) | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | ||||
| 153 | // motion is a spring started at a given time; a zero start begins at the | 176 | // motion is a spring started at a given time; a zero start begins at the | ||
| 154 | - | // first frame. | 177 | + | // first frame. It is a value: on tells whether one runs. slow stretches |
| 178 | + | // the spring's time (a slowdown of 2 takes twice as long) and v is the | |||
| 179 | + | // current speed in units per second, so a retarget can keep it. | |||
| 155 | type motion struct { | 180 | type motion struct { | ||
| 156 | spring spring | 181 | spring spring | ||
| 157 | start time.Time | 182 | start time.Time | ||
| 158 | end time.Duration | 183 | end time.Duration | ||
| 184 | + | slow float64 | |||
| 185 | + | v float64 | |||
| 186 | + | on bool | |||
| 159 | } | 187 | } | ||
| 160 | 188 | ||||
| 161 | - | func newMotion(s spring, now time.Time) *motion { | 189 | + | // newMotion starts s at now, slowed down slow times (slow <= 0 is 1). The |
| 162 | - | return &motion{spring: s, start: now, end: s.duration()} | 190 | + | // spring's Velocity is in units per second of real time. |
| 191 | + | func newMotion(s spring, now time.Time, slow float64) motion { | |||
| 192 | + | if slow <= 0 { | |||
| 193 | + | slow = 1 | |||
| 194 | + | } | |||
| 195 | + | v := s.Velocity | |||
| 196 | + | s.Velocity *= slow | |||
| 197 | + | end := time.Duration(math.MaxInt64) | |||
| 198 | + | if f := float64(s.duration()) * slow; f < float64(math.MaxInt64) { | |||
| 199 | + | end = time.Duration(f) | |||
| 200 | + | } | |||
| 201 | + | return motion{spring: s, start: now, end: end, slow: slow, v: v, on: true} | |||
| 163 | } | 202 | } | ||
| 164 | 203 | ||||
| 165 | // at is the position at now and whether the motion has settled. | 204 | // at is the position at now and whether the motion has settled. | ||
| @@ -169,11 +208,17 @@ func (m *motion) at(now time.Time) (float64, bool) { | |||||
| 169 | } | 208 | } | ||
| 170 | t := now.Sub(m.start) | 209 | t := now.Sub(m.start) | ||
| 171 | if t >= m.end { | 210 | if t >= m.end { | ||
| 211 | + | m.v = 0 | |||
| 172 | return m.spring.To, true | 212 | return m.spring.To, true | ||
| 173 | } | 213 | } | ||
| 174 | - | return m.spring.valueAt(max(t, 0)), false | 214 | + | tau := time.Duration(float64(max(t, 0)) / m.slow) |
| 215 | + | m.v = m.spring.velocityAt(tau) / m.slow | |||
| 216 | + | return m.spring.valueAt(tau), false | |||
| 175 | } | 217 | } | ||
| 176 | 218 | ||||
| 219 | + | // velocity is the speed of the last sample, in units per second. | |||
| 220 | + | func (m motion) velocity() float64 { return m.v } | |||
| 221 | + | ||||
| 177 | // rubberBand resists movement past the edges: the further out, the less | 222 | // rubberBand resists movement past the edges: the further out, the less | ||
| 178 | // it moves, never more than limit. | 223 | // it moves, never more than limit. | ||
| 179 | type rubberBand struct{ stiffness, limit float64 } | 224 | type rubberBand struct{ stiffness, limit float64 } | ||
M internal/core/motion_test.go +93 -1
| @@ -90,7 +90,7 @@ func TestSpringOverdamped(t *testing.T) { | |||
| 90 | 90 | ||
| 91 | 91 | func TestMotionEndsOnTarget(t *testing.T) { | |
| 92 | 92 | start := time.Unix(10, 0) | |
| 93 | - | m := newMotion(workspaceSpring(0.4, 0), start) | |
| 93 | + | m := newMotion(workspaceSpring(0.4, 0), start, 1) | |
| 94 | 94 | if v, done := m.at(start); done || v != 0.4 { | |
| 95 | 95 | t.Fatalf("start %v %t", v, done) | |
| 96 | 96 | } | |
| @@ -366,3 +366,95 @@ func TestSnapFocusWideColumn(t *testing.T) { | |||
| 366 | 366 | } | |
| 367 | 367 | } | |
| 368 | 368 | } | |
| 369 | + | ||
| 370 | + | func TestSpringVelocityAt(t *testing.T) { | |
| 371 | + | springs := map[string]spring{ | |
| 372 | + | "critical": {From: -300, DampingRatio: 1, Stiffness: 800, Epsilon: 0.0001}, | |
| 373 | + | "under": {From: -300, DampingRatio: 0.5, Stiffness: 800, Epsilon: 0.0001}, | |
| 374 | + | "over": {From: -300, DampingRatio: 6, Stiffness: 1200, Epsilon: 0.0001}, | |
| 375 | + | } | |
| 376 | + | const h = time.Microsecond | |
| 377 | + | for name, s := range springs { | |
| 378 | + | for _, v0 := range []float64{0, 3000} { | |
| 379 | + | s := s | |
| 380 | + | s.Velocity = v0 | |
| 381 | + | if got := s.velocityAt(0); math.Abs(got-v0) > 1e-9*max(1, math.Abs(v0)) { | |
| 382 | + | t.Errorf("%s v0=%v: velocityAt(0) = %v", name, v0, got) | |
| 383 | + | } | |
| 384 | + | for _, at := range []time.Duration{5, 50, 200} { | |
| 385 | + | at *= time.Millisecond | |
| 386 | + | want := (s.valueAt(at+h) - s.valueAt(at-h)) / (2 * h.Seconds()) | |
| 387 | + | got := s.velocityAt(at) | |
| 388 | + | if math.Abs(got-want) > 1e-3*max(1, math.Abs(want)) { | |
| 389 | + | t.Errorf("%s v0=%v at %v: velocityAt = %v, derivative %v", name, v0, at, got, want) | |
| 390 | + | } | |
| 391 | + | } | |
| 392 | + | } | |
| 393 | + | } | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | func TestMotionSlowdownScalesTime(t *testing.T) { | |
| 397 | + | start := time.Unix(10, 0) | |
| 398 | + | s := viewSpring(-300, 0) | |
| 399 | + | one, two := newMotion(s, start, 1), newMotion(s, start, 2) | |
| 400 | + | if two.end != 2*one.end { | |
| 401 | + | t.Fatalf("end %v, want %v", two.end, 2*one.end) | |
| 402 | + | } | |
| 403 | + | for _, d := range []time.Duration{5 * time.Millisecond, 40 * time.Millisecond, 150 * time.Millisecond} { | |
| 404 | + | a, _ := one.at(start.Add(d)) | |
| 405 | + | b, _ := two.at(start.Add(2 * d)) | |
| 406 | + | if math.Abs(a-b) > 1e-9 { | |
| 407 | + | t.Errorf("at %v: %v vs %v", d, a, b) | |
| 408 | + | } | |
| 409 | + | } | |
| 410 | + | if m := newMotion(s, start, 0); m.slow != 1 || m.end != one.end { | |
| 411 | + | t.Fatalf("slowdown 0 is not 1: %+v", m) | |
| 412 | + | } | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | func TestMotionVelocity(t *testing.T) { | |
| 416 | + | start := time.Unix(10, 0) | |
| 417 | + | for _, slow := range []float64{1, 2.5} { | |
| 418 | + | m := newMotion(viewSpring(-300, 3000), start, slow) | |
| 419 | + | if v := m.velocity(); v != 3000 { | |
| 420 | + | t.Fatalf("slow %v: initial velocity %v", slow, v) | |
| 421 | + | } | |
| 422 | + | d := 30 * time.Millisecond | |
| 423 | + | m.at(start.Add(d)) | |
| 424 | + | tau := time.Duration(float64(d) / slow) | |
| 425 | + | if want := m.spring.velocityAt(tau) / slow; m.velocity() != want { | |
| 426 | + | t.Fatalf("slow %v: velocity %v, want %v", slow, m.velocity(), want) | |
| 427 | + | } | |
| 428 | + | if _, done := m.at(start.Add(time.Hour)); !done || m.velocity() != 0 { | |
| 429 | + | t.Fatalf("slow %v: settled velocity %v", slow, m.velocity()) | |
| 430 | + | } | |
| 431 | + | } | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | func TestRetargetKeepsVelocityAndSlowdown(t *testing.T) { | |
| 435 | + | start := time.Unix(10, 0) | |
| 436 | + | w := &Workspace{ViewX: 100, shift: -300} | |
| 437 | + | w.motion = newMotion(viewSpring(w.shift, 2000), start, 2) | |
| 438 | + | w.motion.at(start.Add(20 * time.Millisecond)) | |
| 439 | + | v := w.motion.velocity() | |
| 440 | + | if v <= 0 { | |
| 441 | + | t.Fatalf("velocity %v", v) | |
| 442 | + | } | |
| 443 | + | w.ViewX = 40 | |
| 444 | + | w.retarget(100) | |
| 445 | + | if w.shift != -240 || !w.motion.on || w.motion.slow != 2 { | |
| 446 | + | t.Fatalf("shift %v motion %+v", w.shift, w.motion) | |
| 447 | + | } | |
| 448 | + | if got := w.motion.velocity(); got != v { | |
| 449 | + | t.Fatalf("velocity %v after retarget, want %v", got, v) | |
| 450 | + | } | |
| 451 | + | if got := w.motion.spring.Velocity; math.Abs(got-2*v) > 1e-9 { | |
| 452 | + | t.Fatalf("spring velocity %v, want %v in spring time", got, 2*v) | |
| 453 | + | } | |
| 454 | + | var idle Workspace | |
| 455 | + | idle.ViewX = 5 | |
| 456 | + | idle.retarget(0) | |
| 457 | + | if idle.motion.on || idle.shift != 0 { | |
| 458 | + | t.Fatal("retarget without a slide must not start one") | |
| 459 | + | } | |
| 460 | + | } | |
| @@ -90,7 +90,7 @@ func TestSpringOverdamped(t *testing.T) { | |||||
| 90 | 90 | ||||
| 91 | func TestMotionEndsOnTarget(t *testing.T) { | 91 | func TestMotionEndsOnTarget(t *testing.T) { | ||
| 92 | start := time.Unix(10, 0) | 92 | start := time.Unix(10, 0) | ||
| 93 | - | m := newMotion(workspaceSpring(0.4, 0), start) | 93 | + | m := newMotion(workspaceSpring(0.4, 0), start, 1) |
| 94 | if v, done := m.at(start); done || v != 0.4 { | 94 | if v, done := m.at(start); done || v != 0.4 { | ||
| 95 | t.Fatalf("start %v %t", v, done) | 95 | t.Fatalf("start %v %t", v, done) | ||
| 96 | } | 96 | } | ||
| @@ -366,3 +366,95 @@ func TestSnapFocusWideColumn(t *testing.T) { | |||||
| 366 | } | 366 | } | ||
| 367 | } | 367 | } | ||
| 368 | } | 368 | } | ||
| 369 | + | ||||
| 370 | + | func TestSpringVelocityAt(t *testing.T) { | |||
| 371 | + | springs := map[string]spring{ | |||
| 372 | + | "critical": {From: -300, DampingRatio: 1, Stiffness: 800, Epsilon: 0.0001}, | |||
| 373 | + | "under": {From: -300, DampingRatio: 0.5, Stiffness: 800, Epsilon: 0.0001}, | |||
| 374 | + | "over": {From: -300, DampingRatio: 6, Stiffness: 1200, Epsilon: 0.0001}, | |||
| 375 | + | } | |||
| 376 | + | const h = time.Microsecond | |||
| 377 | + | for name, s := range springs { | |||
| 378 | + | for _, v0 := range []float64{0, 3000} { | |||
| 379 | + | s := s | |||
| 380 | + | s.Velocity = v0 | |||
| 381 | + | if got := s.velocityAt(0); math.Abs(got-v0) > 1e-9*max(1, math.Abs(v0)) { | |||
| 382 | + | t.Errorf("%s v0=%v: velocityAt(0) = %v", name, v0, got) | |||
| 383 | + | } | |||
| 384 | + | for _, at := range []time.Duration{5, 50, 200} { | |||
| 385 | + | at *= time.Millisecond | |||
| 386 | + | want := (s.valueAt(at+h) - s.valueAt(at-h)) / (2 * h.Seconds()) | |||
| 387 | + | got := s.velocityAt(at) | |||
| 388 | + | if math.Abs(got-want) > 1e-3*max(1, math.Abs(want)) { | |||
| 389 | + | t.Errorf("%s v0=%v at %v: velocityAt = %v, derivative %v", name, v0, at, got, want) | |||
| 390 | + | } | |||
| 391 | + | } | |||
| 392 | + | } | |||
| 393 | + | } | |||
| 394 | + | } | |||
| 395 | + | ||||
| 396 | + | func TestMotionSlowdownScalesTime(t *testing.T) { | |||
| 397 | + | start := time.Unix(10, 0) | |||
| 398 | + | s := viewSpring(-300, 0) | |||
| 399 | + | one, two := newMotion(s, start, 1), newMotion(s, start, 2) | |||
| 400 | + | if two.end != 2*one.end { | |||
| 401 | + | t.Fatalf("end %v, want %v", two.end, 2*one.end) | |||
| 402 | + | } | |||
| 403 | + | for _, d := range []time.Duration{5 * time.Millisecond, 40 * time.Millisecond, 150 * time.Millisecond} { | |||
| 404 | + | a, _ := one.at(start.Add(d)) | |||
| 405 | + | b, _ := two.at(start.Add(2 * d)) | |||
| 406 | + | if math.Abs(a-b) > 1e-9 { | |||
| 407 | + | t.Errorf("at %v: %v vs %v", d, a, b) | |||
| 408 | + | } | |||
| 409 | + | } | |||
| 410 | + | if m := newMotion(s, start, 0); m.slow != 1 || m.end != one.end { | |||
| 411 | + | t.Fatalf("slowdown 0 is not 1: %+v", m) | |||
| 412 | + | } | |||
| 413 | + | } | |||
| 414 | + | ||||
| 415 | + | func TestMotionVelocity(t *testing.T) { | |||
| 416 | + | start := time.Unix(10, 0) | |||
| 417 | + | for _, slow := range []float64{1, 2.5} { | |||
| 418 | + | m := newMotion(viewSpring(-300, 3000), start, slow) | |||
| 419 | + | if v := m.velocity(); v != 3000 { | |||
| 420 | + | t.Fatalf("slow %v: initial velocity %v", slow, v) | |||
| 421 | + | } | |||
| 422 | + | d := 30 * time.Millisecond | |||
| 423 | + | m.at(start.Add(d)) | |||
| 424 | + | tau := time.Duration(float64(d) / slow) | |||
| 425 | + | if want := m.spring.velocityAt(tau) / slow; m.velocity() != want { | |||
| 426 | + | t.Fatalf("slow %v: velocity %v, want %v", slow, m.velocity(), want) | |||
| 427 | + | } | |||
| 428 | + | if _, done := m.at(start.Add(time.Hour)); !done || m.velocity() != 0 { | |||
| 429 | + | t.Fatalf("slow %v: settled velocity %v", slow, m.velocity()) | |||
| 430 | + | } | |||
| 431 | + | } | |||
| 432 | + | } | |||
| 433 | + | ||||
| 434 | + | func TestRetargetKeepsVelocityAndSlowdown(t *testing.T) { | |||
| 435 | + | start := time.Unix(10, 0) | |||
| 436 | + | w := &Workspace{ViewX: 100, shift: -300} | |||
| 437 | + | w.motion = newMotion(viewSpring(w.shift, 2000), start, 2) | |||
| 438 | + | w.motion.at(start.Add(20 * time.Millisecond)) | |||
| 439 | + | v := w.motion.velocity() | |||
| 440 | + | if v <= 0 { | |||
| 441 | + | t.Fatalf("velocity %v", v) | |||
| 442 | + | } | |||
| 443 | + | w.ViewX = 40 | |||
| 444 | + | w.retarget(100) | |||
| 445 | + | if w.shift != -240 || !w.motion.on || w.motion.slow != 2 { | |||
| 446 | + | t.Fatalf("shift %v motion %+v", w.shift, w.motion) | |||
| 447 | + | } | |||
| 448 | + | if got := w.motion.velocity(); got != v { | |||
| 449 | + | t.Fatalf("velocity %v after retarget, want %v", got, v) | |||
| 450 | + | } | |||
| 451 | + | if got := w.motion.spring.Velocity; math.Abs(got-2*v) > 1e-9 { | |||
| 452 | + | t.Fatalf("spring velocity %v, want %v in spring time", got, 2*v) | |||
| 453 | + | } | |||
| 454 | + | var idle Workspace | |||
| 455 | + | idle.ViewX = 5 | |||
| 456 | + | idle.retarget(0) | |||
| 457 | + | if idle.motion.on || idle.shift != 0 { | |||
| 458 | + | t.Fatal("retarget without a slide must not start one") | |||
| 459 | + | } | |||
| 460 | + | } | |||
M internal/core/slide.go +4 -4
| @@ -17,17 +17,17 @@ func (w *Workspace) slidable() bool { | |||
| 17 | 17 | return w.Overflow != OverflowFixed && len(w.Columns) > 0 && w.fullscreen == 0 && !w.onFloat() | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | - | func (w *Workspace) stopSlide() { w.shift, w.motion = 0, nil } | |
| 20 | + | func (w *Workspace) stopSlide() { w.shift, w.motion = 0, motion{} } | |
| 21 | 21 | ||
| 22 | 22 | // retarget keeps the view where it is on screen when ViewX moved from | |
| 23 | 23 | // before during a landing slide: the slide heads for the new view. | |
| 24 | 24 | // Without a landing slide the view moves at once, as it always has. | |
| 25 | 25 | func (w *Workspace) retarget(before int) { | |
| 26 | - | if w.ViewX == before || w.motion == nil { | |
| 26 | + | if w.ViewX == before || !w.motion.on { | |
| 27 | 27 | return | |
| 28 | 28 | } | |
| 29 | 29 | w.shift += float64(before - w.ViewX) | |
| 30 | - | w.motion = newMotion(viewSpring(w.shift, 0), time.Time{}) | |
| 30 | + | w.motion = newMotion(viewSpring(w.shift, w.motion.velocity()), time.Time{}, w.motion.slow) | |
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | 33 | func (w *Workspace) shiftPixels() int { return int(math.Round(w.shift)) } | |
| @@ -105,7 +105,7 @@ func (w *Workspace) snapFocus(view int, forward bool) (focus int) { | |||
| 105 | 105 | return focus | |
| 106 | 106 | } | |
| 107 | 107 | ||
| 108 | - | func (m *Monitor) stopSwitch() { m.switchOff, m.switchMotion, m.switchList = 0, nil, nil } | |
| 108 | + | func (m *Monitor) stopSwitch() { m.switchOff, m.switchMotion, m.switchList = 0, motion{}, nil } | |
| 109 | 109 | ||
| 110 | 110 | // framedSwitch keeps transitions involving a sized workspace settled. A | |
| 111 | 111 | // monitor-wide animation has no per-workspace crop; it must not expose | |
| @@ -17,17 +17,17 @@ func (w *Workspace) slidable() bool { | |||||
| 17 | return w.Overflow != OverflowFixed && len(w.Columns) > 0 && w.fullscreen == 0 && !w.onFloat() | 17 | return w.Overflow != OverflowFixed && len(w.Columns) > 0 && w.fullscreen == 0 && !w.onFloat() | ||
| 18 | } | 18 | } | ||
| 19 | 19 | ||||
| 20 | - | func (w *Workspace) stopSlide() { w.shift, w.motion = 0, nil } | 20 | + | func (w *Workspace) stopSlide() { w.shift, w.motion = 0, motion{} } |
| 21 | 21 | ||||
| 22 | // retarget keeps the view where it is on screen when ViewX moved from | 22 | // retarget keeps the view where it is on screen when ViewX moved from | ||
| 23 | // before during a landing slide: the slide heads for the new view. | 23 | // before during a landing slide: the slide heads for the new view. | ||
| 24 | // Without a landing slide the view moves at once, as it always has. | 24 | // Without a landing slide the view moves at once, as it always has. | ||
| 25 | func (w *Workspace) retarget(before int) { | 25 | func (w *Workspace) retarget(before int) { | ||
| 26 | - | if w.ViewX == before || w.motion == nil { | 26 | + | if w.ViewX == before || !w.motion.on { |
| 27 | return | 27 | return | ||
| 28 | } | 28 | } | ||
| 29 | w.shift += float64(before - w.ViewX) | 29 | w.shift += float64(before - w.ViewX) | ||
| 30 | - | w.motion = newMotion(viewSpring(w.shift, 0), time.Time{}) | 30 | + | w.motion = newMotion(viewSpring(w.shift, w.motion.velocity()), time.Time{}, w.motion.slow) |
| 31 | } | 31 | } | ||
| 32 | 32 | ||||
| 33 | func (w *Workspace) shiftPixels() int { return int(math.Round(w.shift)) } | 33 | func (w *Workspace) shiftPixels() int { return int(math.Round(w.shift)) } | ||
| @@ -105,7 +105,7 @@ func (w *Workspace) snapFocus(view int, forward bool) (focus int) { | |||||
| 105 | return focus | 105 | return focus | ||
| 106 | } | 106 | } | ||
| 107 | 107 | ||||
| 108 | - | func (m *Monitor) stopSwitch() { m.switchOff, m.switchMotion, m.switchList = 0, nil, nil } | 108 | + | func (m *Monitor) stopSwitch() { m.switchOff, m.switchMotion, m.switchList = 0, motion{}, nil } |
| 109 | 109 | ||||
| 110 | // framedSwitch keeps transitions involving a sized workspace settled. A | 110 | // framedSwitch keeps transitions involving a sized workspace settled. A | ||
| 111 | // monitor-wide animation has no per-workspace crop; it must not expose | 111 | // monitor-wide animation has no per-workspace crop; it must not expose | ||
M internal/core/swipe_test.go +35 -0
| @@ -825,3 +825,38 @@ func TestSwipeWindowMappedMidSwipe(t *testing.T) { | |||
| 825 | 825 | } | |
| 826 | 826 | } | |
| 827 | 827 | } | |
| 828 | + | ||
| 829 | + | // A press that moves the view during a landing slide redirects the slide | |
| 830 | + | // without stopping or reversing it: the view keeps its speed. | |
| 831 | + | func TestSwipeRetargetKeepsVelocity(t *testing.T) { | |
| 832 | + | r := startSwipe(t, nil) | |
| 833 | + | for id := ports.WindowID(1); id <= 4; id++ { | |
| 834 | + | r.mapWindow(t, id) | |
| 835 | + | } | |
| 836 | + | // Focus on 4, the view shows 3 and 4. Go back to the first column. | |
| 837 | + | for range 3 { | |
| 838 | + | r.key(t, "Left", ports.ModAlt) | |
| 839 | + | } | |
| 840 | + | // A flick toward the right lands on a later column: the view moves | |
| 841 | + | // right, so column 1 moves left. | |
| 842 | + | r.flick(t, 4, 40, 0) | |
| 843 | + | mid := r.frame(t, 16*time.Millisecond) | |
| 844 | + | prev, ok := rectOf(mid, 1) | |
| 845 | + | if !ok { | |
| 846 | + | t.Fatal("column 1 hidden during the landing") | |
| 847 | + | } | |
| 848 | + | if prev.X >= 0 { | |
| 849 | + | t.Fatalf("view did not move right: column 1 at %d", prev.X) | |
| 850 | + | } | |
| 851 | + | r.key(t, "Right", ports.ModAlt) | |
| 852 | + | // The retargeted spring takes its start time at the first frame, which | |
| 853 | + | // leaves the view where it is; it moves on from the second, never back. | |
| 854 | + | for i := range 2 { | |
| 855 | + | s := r.frame(t, 16*time.Millisecond) | |
| 856 | + | got, _ := rectOf(s, 1) | |
| 857 | + | if got.X > prev.X || (i == 1 && got.X == prev.X) { | |
| 858 | + | t.Fatalf("frame %d: column 1 at %d after %d, the view reversed or stopped", i, got.X, prev.X) | |
| 859 | + | } | |
| 860 | + | prev = got | |
| 861 | + | } | |
| 862 | + | } | |
| @@ -825,3 +825,38 @@ func TestSwipeWindowMappedMidSwipe(t *testing.T) { | |||||
| 825 | } | 825 | } | ||
| 826 | } | 826 | } | ||
| 827 | } | 827 | } | ||
| 828 | + | ||||
| 829 | + | // A press that moves the view during a landing slide redirects the slide | |||
| 830 | + | // without stopping or reversing it: the view keeps its speed. | |||
| 831 | + | func TestSwipeRetargetKeepsVelocity(t *testing.T) { | |||
| 832 | + | r := startSwipe(t, nil) | |||
| 833 | + | for id := ports.WindowID(1); id <= 4; id++ { | |||
| 834 | + | r.mapWindow(t, id) | |||
| 835 | + | } | |||
| 836 | + | // Focus on 4, the view shows 3 and 4. Go back to the first column. | |||
| 837 | + | for range 3 { | |||
| 838 | + | r.key(t, "Left", ports.ModAlt) | |||
| 839 | + | } | |||
| 840 | + | // A flick toward the right lands on a later column: the view moves | |||
| 841 | + | // right, so column 1 moves left. | |||
| 842 | + | r.flick(t, 4, 40, 0) | |||
| 843 | + | mid := r.frame(t, 16*time.Millisecond) | |||
| 844 | + | prev, ok := rectOf(mid, 1) | |||
| 845 | + | if !ok { | |||
| 846 | + | t.Fatal("column 1 hidden during the landing") | |||
| 847 | + | } | |||
| 848 | + | if prev.X >= 0 { | |||
| 849 | + | t.Fatalf("view did not move right: column 1 at %d", prev.X) | |||
| 850 | + | } | |||
| 851 | + | r.key(t, "Right", ports.ModAlt) | |||
| 852 | + | // The retargeted spring takes its start time at the first frame, which | |||
| 853 | + | // leaves the view where it is; it moves on from the second, never back. | |||
| 854 | + | for i := range 2 { | |||
| 855 | + | s := r.frame(t, 16*time.Millisecond) | |||
| 856 | + | got, _ := rectOf(s, 1) | |||
| 857 | + | if got.X > prev.X || (i == 1 && got.X == prev.X) { | |||
| 858 | + | t.Fatalf("frame %d: column 1 at %d after %d, the view reversed or stopped", i, got.X, prev.X) | |||
| 859 | + | } | |||
| 860 | + | prev = got | |||
| 861 | + | } | |||
| 862 | + | } | |||