detached 5c55b10

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
193193 c.capt.sessions = []*capSession{{open: ports.CaptureSessionOpen{ID: 1, Workspace: ws.ID}}}
194194 c.popups[10] = &popupState{id: 10, parent: 1, rect: Rect{X: 3, Y: 4, W: 10, H: 8}, mapped: true}
195195 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)
197197 ctx := context.Background()
198198 if err := c.publish(ctx); err != nil {
199199 t.Fatal(err)
@@ -207,7 +207,7 @@ func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) {
207207 if v, ok := c.configures.sent[1]; !ok || !v.Captured || v.Visible || v.Activated || v.Width <= 0 {
208208 t.Fatalf("window during the slide %+v (sent %v)", v, ok)
209209 }
210- ws.motion = nil
210+ ws.motion = motion{}
211211 if err := c.publish(ctx); err != nil {
212212 t.Fatal(err)
213213 }
@@ -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 = nil210+ 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) {
171171 g.mode, g.snap = swipeDiscrete, newStepSwipe()
172172 case g.horizontal && w.slidable():
173173 g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints()
174- w.motion = nil
174+ w.motion = motion{}
175175 g.snap = newSnapSwipe(float64(w.ViewX)+w.shift, float64(w.ViewX), w.swipeScale(), g.points, workspaceBand.scaled(float64(w.Usable.W)))
176176 case !g.horizontal && m.shown == nil:
177177 // A landing slide measured in an older list lands at once first.
@@ -180,7 +180,7 @@ func (c *Core) decide(g *swipeGesture) {
180180 }
181181 g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active]
182182 g.list = slices.Clone(m.Workspaces)
183- m.switchMotion, m.switchList = nil, nil
183+ m.switchMotion, m.switchList = motion{}, nil
184184 g.snap = newSnapSwipe(float64(m.Active)+m.switchOff, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand)
185185 default:
186186 g.mode, g.snap = swipeDiscrete, newStepSwipe()
@@ -216,7 +216,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
216216 c.keyboard.takeBack()
217217 }
218218 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)
220220 case swipeWorkspaces:
221221 if g.listChanged(m) {
222222 m.stopSwitch()
@@ -240,7 +240,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
240240 c.keyboard.takeBack()
241241 }
242242 m.switchOff, m.switchList = off, g.list
243- m.switchMotion = newMotion(workspaceSpring(off, velocity), now)
243+ m.switchMotion = newMotion(workspaceSpring(off, velocity), now, 1)
244244 case swipeOverview:
245245 step := g.snap.step(e.Cancelled, e.Time)
246246 if step == 0 || (step < 0) == m.ov.open {
@@ -321,7 +321,7 @@ func (c *Core) hasScreen(s *screen) bool {
321321 func (c *Core) animate(now time.Time) {
322322 for _, sc := range c.screens {
323323 m := sc.mon
324- if m.switchMotion != nil {
324+ if m.switchMotion.on {
325325 v, done := m.switchMotion.at(now)
326326 m.switchOff = v
327327 if done {
@@ -329,7 +329,7 @@ func (c *Core) animate(now time.Time) {
329329 }
330330 }
331331 for w := range m.all() {
332- if w.motion != nil {
332+ if w.motion.on {
333333 v, done := w.motion.at(now)
334334 w.shift = v
335335 if done {
@@ -360,11 +360,11 @@ func (c *Core) animating() bool {
360360 }
361361
362362 func (m *Monitor) springing() bool {
363- if m.switchMotion != nil {
363+ if m.switchMotion.on {
364364 return true
365365 }
366366 for w := range m.all() {
367- if w.motion != nil {
367+ if w.motion.on {
368368 return true
369369 }
370370 }
@@ -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 = nil174+ 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, nil183+ 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.list242 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.mon323 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 = v326 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 = v334 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 true364 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 true368 return true
369 }369 }
370 }370 }
M internal/core/layout.go +1 -1
@@ -154,7 +154,7 @@ type Workspace struct {
154154 // shift slides the columns on screen past ViewX, in logical pixels,
155155 // while a swipe follows the fingers or its spring (motion) lands.
156156 shift float64
157- motion *motion
157+ motion motion
158158 // Output is the effective viewport in monitor coordinates: the whole
159159 // monitor unless the workspace has a size override, then a centered
160160 // 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 float64156 shift float64
157- motion *motion157+ motion motion
158 // Output is the effective viewport in monitor coordinates: the whole158 // Output is the effective viewport in monitor coordinates: the whole
159 // monitor unless the workspace has a size override, then a centered159 // 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 {
3434 // at Active+switchOff while a swipe follows the fingers or its spring
3535 // (switchMotion) lands.
3636 switchOff float64
37- switchMotion *motion
37+ switchMotion motion
3838 // switchList is the numbered list a landing slide measures from (the
3939 // one its swipe began on); switchOff is then from the current
4040 // workspace's place in it.
@@ -34,7 +34,7 @@ type Monitor struct {
34 // at Active+switchOff while a swipe follows the fingers or its spring34 // at Active+switchOff while a swipe follows the fingers or its spring
35 // (switchMotion) lands.35 // (switchMotion) lands.
36 switchOff float6436 switchOff float64
37- switchMotion *motion37+ switchMotion motion
38 // switchList is the numbered list a landing slide measures from (the38 // switchList is the numbered list a landing slide measures from (the
39 // one its swipe began on); switchOff is then from the current39 // 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 {
150150
151151 func seconds(s float64) time.Duration { return time.Duration(s * float64(time.Second)) }
152152
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+
153176 // 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.
155180 type motion struct {
156181 spring spring
157182 start time.Time
158183 end time.Duration
184+ slow float64
185+ v float64
186+ on bool
159187 }
160188
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}
163202 }
164203
165204 // 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) {
169208 }
170209 t := now.Sub(m.start)
171210 if t >= m.end {
211+ m.v = 0
172212 return m.spring.To, true
173213 }
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
175217 }
176218
219+// velocity is the speed of the last sample, in units per second.
220+func (m motion) velocity() float64 { return m.v }
221+
177222 // rubberBand resists movement past the edges: the further out, the less
178223 // it moves, never more than limit.
179224 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 the176 // 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 spring181 spring spring
157 start time.Time182 start time.Time
158 end time.Duration183 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, true212 return m.spring.To, true
173 }213 }
174- return m.spring.valueAt(max(t, 0)), false214+ 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 less222 // 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) {
9090
9191 func TestMotionEndsOnTarget(t *testing.T) {
9292 start := time.Unix(10, 0)
93- m := newMotion(workspaceSpring(0.4, 0), start)
93+ m := newMotion(workspaceSpring(0.4, 0), start, 1)
9494 if v, done := m.at(start); done || v != 0.4 {
9595 t.Fatalf("start %v %t", v, done)
9696 }
@@ -366,3 +366,95 @@ func TestSnapFocusWideColumn(t *testing.T) {
366366 }
367367 }
368368 }
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 {
1717 return w.Overflow != OverflowFixed && len(w.Columns) > 0 && w.fullscreen == 0 && !w.onFloat()
1818 }
1919
20-func (w *Workspace) stopSlide() { w.shift, w.motion = 0, nil }
20+func (w *Workspace) stopSlide() { w.shift, w.motion = 0, motion{} }
2121
2222 // retarget keeps the view where it is on screen when ViewX moved from
2323 // before during a landing slide: the slide heads for the new view.
2424 // Without a landing slide the view moves at once, as it always has.
2525 func (w *Workspace) retarget(before int) {
26- if w.ViewX == before || w.motion == nil {
26+ if w.ViewX == before || !w.motion.on {
2727 return
2828 }
2929 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)
3131 }
3232
3333 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) {
105105 return focus
106106 }
107107
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 }
109109
110110 // framedSwitch keeps transitions involving a sized workspace settled. A
111111 // 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 from22 // 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 return27 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 focus105 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. A110 // framedSwitch keeps transitions involving a sized workspace settled. A
111 // monitor-wide animation has no per-workspace crop; it must not expose111 // 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) {
825825 }
826826 }
827827 }
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+}

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