perf(core): settle pixel springs once they stay within half a pixel
A rect, view or workspace spring ran to its 1e-4 epsilon although the drawn rect is rounded to whole logical pixels: its tail redrew nothing but still submitted a frame. A spring with Snap settles as soon as the critically damped solution is bounded within Snap of its target (|x|(1+1/e) + |v|/(βe) < Snap), so the last frame drawn already equals the settled one: no jump, exact settle, chaining unchanged. Rect and view springs snap at 0.5 logical px; the workspace slide at 0.5 px of the output height; fades and veils (levelSpring) keep the fine epsilon. Headless V6: empty-delta frames 11.2k -> 9.5-9.9k; per motion 100 px at 165 Hz: 54 -> 47 frames.
- commit
- 724a3a0f3e77a747d21ef92941a2e9dc2881be98
- author
- brice · · 2026-10-05 02:36 UTC
- parent
- be48d93
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +144 -25
M internal/core/animate_alloc_test.go +3 -3
| @@ -29,8 +29,8 @@ func TestAnimateAllocations(t *testing.T) { | |||
| 29 | 29 | x: c.spring(sp, t0), y: c.spring(sp, t0), | |
| 30 | 30 | w: c.spring(sp, t0), h: c.spring(sp, t0), | |
| 31 | 31 | dx: 50, dy: 50, dw: 50, dh: 50, | |
| 32 | - | fade: c.spring(viewSpring(1, 0), t0), df: 1, | |
| 33 | - | dim: c.spring(viewSpring(0.5, 0), t0), ddim: 0.5, | |
| 32 | + | fade: c.spring(levelSpring(1, 0), t0), df: 1, | |
| 33 | + | dim: c.spring(levelSpring(0.5, 0), t0), ddim: 0.5, | |
| 34 | 34 | scale: true, | |
| 35 | 35 | } | |
| 36 | 36 | if i%2 == 0 { | |
| @@ -47,7 +47,7 @@ func TestAnimateAllocations(t *testing.T) { | |||
| 47 | 47 | if i%2 == 1 { | |
| 48 | 48 | from = -0.5 | |
| 49 | 49 | } | |
| 50 | - | rm.dim, rm.ddim = c.spring(viewSpring(from, 0), t0), from | |
| 50 | + | rm.dim, rm.ddim = c.spring(levelSpring(from, 0), t0), from | |
| 51 | 51 | sc.rects[WindowID(200+i)] = rm | |
| 52 | 52 | } | |
| 53 | 53 | ||
| @@ -29,8 +29,8 @@ func TestAnimateAllocations(t *testing.T) { | |||||
| 29 | x: c.spring(sp, t0), y: c.spring(sp, t0), | 29 | x: c.spring(sp, t0), y: c.spring(sp, t0), | ||
| 30 | w: c.spring(sp, t0), h: c.spring(sp, t0), | 30 | w: c.spring(sp, t0), h: c.spring(sp, t0), | ||
| 31 | dx: 50, dy: 50, dw: 50, dh: 50, | 31 | dx: 50, dy: 50, dw: 50, dh: 50, | ||
| 32 | - | fade: c.spring(viewSpring(1, 0), t0), df: 1, | 32 | + | fade: c.spring(levelSpring(1, 0), t0), df: 1, |
| 33 | - | dim: c.spring(viewSpring(0.5, 0), t0), ddim: 0.5, | 33 | + | dim: c.spring(levelSpring(0.5, 0), t0), ddim: 0.5, |
| 34 | scale: true, | 34 | scale: true, | ||
| 35 | } | 35 | } | ||
| 36 | if i%2 == 0 { | 36 | if i%2 == 0 { | ||
| @@ -47,7 +47,7 @@ func TestAnimateAllocations(t *testing.T) { | |||||
| 47 | if i%2 == 1 { | 47 | if i%2 == 1 { | ||
| 48 | from = -0.5 | 48 | from = -0.5 | ||
| 49 | } | 49 | } | ||
| 50 | - | rm.dim, rm.ddim = c.spring(viewSpring(from, 0), t0), from | 50 | + | rm.dim, rm.ddim = c.spring(levelSpring(from, 0), t0), from |
| 51 | sc.rects[WindowID(200+i)] = rm | 51 | sc.rects[WindowID(200+i)] = rm | ||
| 52 | } | 52 | } | ||
| 53 | 53 | ||||
M internal/core/gesture.go +1 -1
| @@ -246,7 +246,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 246 | 246 | } | |
| 247 | 247 | m.switchOff, m.switchList = off, g.list | |
| 248 | 248 | if c.animOn() { | |
| 249 | - | m.switchMotion = c.spring(workspaceSpring(off, velocity), now) | |
| 249 | + | m.switchMotion = c.spring(m.switchSpring(off, velocity), now) | |
| 250 | 250 | } else { | |
| 251 | 251 | m.stopSwitch() | |
| 252 | 252 | } | |
| @@ -246,7 +246,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||||
| 246 | } | 246 | } | ||
| 247 | m.switchOff, m.switchList = off, g.list | 247 | m.switchOff, m.switchList = off, g.list | ||
| 248 | if c.animOn() { | 248 | if c.animOn() { | ||
| 249 | - | m.switchMotion = c.spring(workspaceSpring(off, velocity), now) | 249 | + | m.switchMotion = c.spring(m.switchSpring(off, velocity), now) |
| 250 | } else { | 250 | } else { | ||
| 251 | m.stopSwitch() | 251 | m.stopSwitch() | ||
| 252 | } | 252 | } | ||
M internal/core/motion.go +49 -5
| @@ -70,18 +70,57 @@ type spring struct { | |||
| 70 | 70 | DampingRatio float64 | |
| 71 | 71 | Stiffness float64 | |
| 72 | 72 | Epsilon float64 | |
| 73 | + | // Snap, when set, settles the motion as soon as it can no longer | |
| 74 | + | // stray that far from To: a position in pixels whose rounded value | |
| 75 | + | // is already the settled one draws no different frame, so the tail | |
| 76 | + | // of the spring (sub-pixel, hundreds of frames with nothing to show) | |
| 77 | + | // is cut. Fades and veils keep Epsilon alone. Critically damped | |
| 78 | + | // springs only (settled). | |
| 79 | + | Snap float64 | |
| 73 | 80 | } | |
| 74 | 81 | ||
| 75 | - | // viewSpring moves a scrolled view; workspaceSpring switches workspaces. | |
| 76 | - | // Both are critically damped: fast, with no overshoot. | |
| 82 | + | // pixelSnap is the Snap of a spring in logical pixels: offsets are rounded | |
| 83 | + | // to whole logical pixels before the renderer scales them (rectMotion.apply, | |
| 84 | + | // Workspace.shiftPixels), so a spring that stays under half a pixel from | |
| 85 | + | // its target draws the settled rect from then on, at any output scale. | |
| 86 | + | const pixelSnap = 0.5 | |
| 87 | + | ||
| 88 | + | // viewSpring moves a scrolled view, or a rect offset, in logical pixels; | |
| 89 | + | // workspaceSpring switches workspaces, in workspaces (the caller sets its | |
| 90 | + | // Snap from the output height). Both are critically damped: fast, with no | |
| 91 | + | // overshoot. | |
| 77 | 92 | func viewSpring(from, velocity float64) spring { | |
| 78 | - | return spring{From: from, Velocity: velocity, DampingRatio: 1, Stiffness: 800, Epsilon: 0.0001} | |
| 93 | + | return spring{From: from, Velocity: velocity, DampingRatio: 1, Stiffness: 800, Epsilon: 0.0001, Snap: pixelSnap} | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // levelSpring is viewSpring for a level in 0..1 (a fade, a veil): no snap, | |
| 97 | + | // a fraction of a level still shows. | |
| 98 | + | func levelSpring(from, velocity float64) spring { | |
| 99 | + | s := viewSpring(from, velocity) | |
| 100 | + | s.Snap = 0 | |
| 101 | + | return s | |
| 79 | 102 | } | |
| 80 | 103 | ||
| 81 | 104 | func workspaceSpring(from, velocity float64) spring { | |
| 82 | 105 | return spring{From: from, Velocity: velocity, DampingRatio: 1, Stiffness: 1000, Epsilon: 0.0001} | |
| 83 | 106 | } | |
| 84 | 107 | ||
| 108 | + | // settled reports whether a critically damped spring at pos with speed vel | |
| 109 | + | // stays within Snap of To from now on. From there the offset is | |
| 110 | + | // x(t) = e^(-βt)(x + (βx+v)t), bounded by |x| + (β|x|+|v|)·max(t·e^(-βt)) | |
| 111 | + | // = |x|(1+1/e) + |v|/(βe) (the peak is at t = 1/β). | |
| 112 | + | func (s spring) settled(pos, vel float64) bool { | |
| 113 | + | if s.Snap <= 0 { | |
| 114 | + | return false | |
| 115 | + | } | |
| 116 | + | beta, omega0 := s.coefficients() | |
| 117 | + | if math.Abs(beta-omega0) > f32Epsilon || beta <= 0 { | |
| 118 | + | return false | |
| 119 | + | } | |
| 120 | + | x := math.Abs(pos - s.To) | |
| 121 | + | return x*(1+1/math.E)+math.Abs(vel)/(beta*math.E) < s.Snap | |
| 122 | + | } | |
| 123 | + | ||
| 85 | 124 | // f32Epsilon compares damping terms: float64's epsilon is too small for | |
| 86 | 125 | // them (libadwaita, niri). | |
| 87 | 126 | const f32Epsilon = 1.1920929e-07 | |
| @@ -210,8 +249,13 @@ func (m *motion) at(now time.Time) (float64, bool) { | |||
| 210 | 249 | return m.spring.To, true | |
| 211 | 250 | } | |
| 212 | 251 | tau := time.Duration(float64(max(t, 0)) / m.slow) | |
| 213 | - | m.v = m.spring.velocityAt(tau) / m.slow | |
| 214 | - | return m.spring.valueAt(tau), false | |
| 252 | + | pos, vel := m.spring.valueAt(tau), m.spring.velocityAt(tau) | |
| 253 | + | if m.spring.settled(pos, vel) { | |
| 254 | + | m.v = 0 | |
| 255 | + | return m.spring.To, true | |
| 256 | + | } | |
| 257 | + | m.v = vel / m.slow | |
| 258 | + | return pos, false | |
| 215 | 259 | } | |
| 216 | 260 | ||
| 217 | 261 | // sampleAt is the position and speed m has at now, in units per second, | |
| @@ -70,18 +70,57 @@ type spring struct { | |||||
| 70 | DampingRatio float64 | 70 | DampingRatio float64 | ||
| 71 | Stiffness float64 | 71 | Stiffness float64 | ||
| 72 | Epsilon float64 | 72 | Epsilon float64 | ||
| 73 | + | // Snap, when set, settles the motion as soon as it can no longer | |||
| 74 | + | // stray that far from To: a position in pixels whose rounded value | |||
| 75 | + | // is already the settled one draws no different frame, so the tail | |||
| 76 | + | // of the spring (sub-pixel, hundreds of frames with nothing to show) | |||
| 77 | + | // is cut. Fades and veils keep Epsilon alone. Critically damped | |||
| 78 | + | // springs only (settled). | |||
| 79 | + | Snap float64 | |||
| 73 | } | 80 | } | ||
| 74 | 81 | ||||
| 75 | - | // viewSpring moves a scrolled view; workspaceSpring switches workspaces. | 82 | + | // pixelSnap is the Snap of a spring in logical pixels: offsets are rounded |
| 76 | - | // Both are critically damped: fast, with no overshoot. | 83 | + | // to whole logical pixels before the renderer scales them (rectMotion.apply, |
| 84 | + | // Workspace.shiftPixels), so a spring that stays under half a pixel from | |||
| 85 | + | // its target draws the settled rect from then on, at any output scale. | |||
| 86 | + | const pixelSnap = 0.5 | |||
| 87 | + | ||||
| 88 | + | // viewSpring moves a scrolled view, or a rect offset, in logical pixels; | |||
| 89 | + | // workspaceSpring switches workspaces, in workspaces (the caller sets its | |||
| 90 | + | // Snap from the output height). Both are critically damped: fast, with no | |||
| 91 | + | // overshoot. | |||
| 77 | func viewSpring(from, velocity float64) spring { | 92 | func viewSpring(from, velocity float64) spring { | ||
| 78 | - | return spring{From: from, Velocity: velocity, DampingRatio: 1, Stiffness: 800, Epsilon: 0.0001} | 93 | + | return spring{From: from, Velocity: velocity, DampingRatio: 1, Stiffness: 800, Epsilon: 0.0001, Snap: pixelSnap} |
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // levelSpring is viewSpring for a level in 0..1 (a fade, a veil): no snap, | |||
| 97 | + | // a fraction of a level still shows. | |||
| 98 | + | func levelSpring(from, velocity float64) spring { | |||
| 99 | + | s := viewSpring(from, velocity) | |||
| 100 | + | s.Snap = 0 | |||
| 101 | + | return s | |||
| 79 | } | 102 | } | ||
| 80 | 103 | ||||
| 81 | func workspaceSpring(from, velocity float64) spring { | 104 | func workspaceSpring(from, velocity float64) spring { | ||
| 82 | return spring{From: from, Velocity: velocity, DampingRatio: 1, Stiffness: 1000, Epsilon: 0.0001} | 105 | return spring{From: from, Velocity: velocity, DampingRatio: 1, Stiffness: 1000, Epsilon: 0.0001} | ||
| 83 | } | 106 | } | ||
| 84 | 107 | ||||
| 108 | + | // settled reports whether a critically damped spring at pos with speed vel | |||
| 109 | + | // stays within Snap of To from now on. From there the offset is | |||
| 110 | + | // x(t) = e^(-βt)(x + (βx+v)t), bounded by |x| + (β|x|+|v|)·max(t·e^(-βt)) | |||
| 111 | + | // = |x|(1+1/e) + |v|/(βe) (the peak is at t = 1/β). | |||
| 112 | + | func (s spring) settled(pos, vel float64) bool { | |||
| 113 | + | if s.Snap <= 0 { | |||
| 114 | + | return false | |||
| 115 | + | } | |||
| 116 | + | beta, omega0 := s.coefficients() | |||
| 117 | + | if math.Abs(beta-omega0) > f32Epsilon || beta <= 0 { | |||
| 118 | + | return false | |||
| 119 | + | } | |||
| 120 | + | x := math.Abs(pos - s.To) | |||
| 121 | + | return x*(1+1/math.E)+math.Abs(vel)/(beta*math.E) < s.Snap | |||
| 122 | + | } | |||
| 123 | + | ||||
| 85 | // f32Epsilon compares damping terms: float64's epsilon is too small for | 124 | // f32Epsilon compares damping terms: float64's epsilon is too small for | ||
| 86 | // them (libadwaita, niri). | 125 | // them (libadwaita, niri). | ||
| 87 | const f32Epsilon = 1.1920929e-07 | 126 | const f32Epsilon = 1.1920929e-07 | ||
| @@ -210,8 +249,13 @@ func (m *motion) at(now time.Time) (float64, bool) { | |||||
| 210 | return m.spring.To, true | 249 | return m.spring.To, true | ||
| 211 | } | 250 | } | ||
| 212 | tau := time.Duration(float64(max(t, 0)) / m.slow) | 251 | tau := time.Duration(float64(max(t, 0)) / m.slow) | ||
| 213 | - | m.v = m.spring.velocityAt(tau) / m.slow | 252 | + | pos, vel := m.spring.valueAt(tau), m.spring.velocityAt(tau) |
| 214 | - | return m.spring.valueAt(tau), false | 253 | + | if m.spring.settled(pos, vel) { |
| 254 | + | m.v = 0 | |||
| 255 | + | return m.spring.To, true | |||
| 256 | + | } | |||
| 257 | + | m.v = vel / m.slow | |||
| 258 | + | return pos, false | |||
| 215 | } | 259 | } | ||
| 216 | 260 | ||||
| 217 | // sampleAt is the position and speed m has at now, in units per second, | 261 | // sampleAt is the position and speed m has at now, in units per second, | ||
M internal/core/motion_test.go +54 -0
| @@ -458,3 +458,57 @@ func TestRetargetKeepsVelocityAndSlowdown(t *testing.T) { | |||
| 458 | 458 | t.Fatal("retarget without a slide must not start one") | |
| 459 | 459 | } | |
| 460 | 460 | } | |
| 461 | + | ||
| 462 | + | // A pixel spring settles once its rounded position can no longer differ | |
| 463 | + | // from the target: well before its epsilon tail, and from then on the | |
| 464 | + | // spring itself (run to its epsilon) never leaves half a pixel of the | |
| 465 | + | // target. A level spring (fade, veil) runs to its epsilon. | |
| 466 | + | func TestMotionPixelSnapCutsTail(t *testing.T) { | |
| 467 | + | start := time.Unix(10, 0) | |
| 468 | + | for _, tc := range []struct { | |
| 469 | + | name string | |
| 470 | + | from, v0 float64 | |
| 471 | + | }{{"from rest", 100, 0}, {"flung past", 100, -3000}, {"flung back", 100, 3000}, {"short", 3, 0}, {"stopped near", 0.4, 60}} { | |
| 472 | + | m := newMotion(viewSpring(tc.from, tc.v0), start, 1) | |
| 473 | + | var settledAt time.Duration | |
| 474 | + | for d := time.Duration(0); d <= m.end; d += time.Millisecond { | |
| 475 | + | if _, done := m.at(start.Add(d)); done { | |
| 476 | + | settledAt = d | |
| 477 | + | break | |
| 478 | + | } | |
| 479 | + | } | |
| 480 | + | if settledAt == 0 || settledAt >= m.end { | |
| 481 | + | t.Fatalf("%s: settled at %v of %v", tc.name, settledAt, m.end) | |
| 482 | + | } | |
| 483 | + | if v, done := m.at(start.Add(settledAt)); !done || v != 0 { | |
| 484 | + | t.Fatalf("%s: settled value %v %v", tc.name, v, done) | |
| 485 | + | } | |
| 486 | + | for d := settledAt; d <= m.end+time.Second; d += 100 * time.Microsecond { | |
| 487 | + | if x := m.spring.valueAt(d); math.Abs(x) >= pixelSnap { | |
| 488 | + | t.Fatalf("%s: %v past the snap at %v (settled at %v)", tc.name, x, d, settledAt) | |
| 489 | + | } | |
| 490 | + | } | |
| 491 | + | t.Logf("%s: settled at %v, epsilon end %v", tc.name, settledAt, m.end) | |
| 492 | + | } | |
| 493 | + | level := newMotion(levelSpring(1, 0), start, 1) | |
| 494 | + | if v, done := level.at(start.Add(level.end - time.Millisecond)); done || v == 0 { | |
| 495 | + | t.Fatalf("a level spring snapped: %v %v", v, done) | |
| 496 | + | } | |
| 497 | + | if _, done := level.at(start.Add(level.end)); !done { | |
| 498 | + | t.Fatal("a level spring never ends") | |
| 499 | + | } | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | // A workspace slide snaps in output pixels: at a 600 px output half a pixel | |
| 503 | + | // is 1/1200 of a workspace. | |
| 504 | + | func TestSwitchSpringSnapsAtOutputPixels(t *testing.T) { | |
| 505 | + | m := newMonitorWithIDs("", "", new(uint64)) | |
| 506 | + | m.template.Output = Rect{W: 800, H: 600} | |
| 507 | + | s := m.switchSpring(0.5, 0) | |
| 508 | + | if s.Snap != pixelSnap/600 { | |
| 509 | + | t.Fatalf("snap %v", s.Snap) | |
| 510 | + | } | |
| 511 | + | if s.settled(0.5/600, 0) || !s.settled(0.2/600, 0) { | |
| 512 | + | t.Fatal("snap threshold") | |
| 513 | + | } | |
| 514 | + | } | |
| @@ -458,3 +458,57 @@ func TestRetargetKeepsVelocityAndSlowdown(t *testing.T) { | |||||
| 458 | t.Fatal("retarget without a slide must not start one") | 458 | t.Fatal("retarget without a slide must not start one") | ||
| 459 | } | 459 | } | ||
| 460 | } | 460 | } | ||
| 461 | + | ||||
| 462 | + | // A pixel spring settles once its rounded position can no longer differ | |||
| 463 | + | // from the target: well before its epsilon tail, and from then on the | |||
| 464 | + | // spring itself (run to its epsilon) never leaves half a pixel of the | |||
| 465 | + | // target. A level spring (fade, veil) runs to its epsilon. | |||
| 466 | + | func TestMotionPixelSnapCutsTail(t *testing.T) { | |||
| 467 | + | start := time.Unix(10, 0) | |||
| 468 | + | for _, tc := range []struct { | |||
| 469 | + | name string | |||
| 470 | + | from, v0 float64 | |||
| 471 | + | }{{"from rest", 100, 0}, {"flung past", 100, -3000}, {"flung back", 100, 3000}, {"short", 3, 0}, {"stopped near", 0.4, 60}} { | |||
| 472 | + | m := newMotion(viewSpring(tc.from, tc.v0), start, 1) | |||
| 473 | + | var settledAt time.Duration | |||
| 474 | + | for d := time.Duration(0); d <= m.end; d += time.Millisecond { | |||
| 475 | + | if _, done := m.at(start.Add(d)); done { | |||
| 476 | + | settledAt = d | |||
| 477 | + | break | |||
| 478 | + | } | |||
| 479 | + | } | |||
| 480 | + | if settledAt == 0 || settledAt >= m.end { | |||
| 481 | + | t.Fatalf("%s: settled at %v of %v", tc.name, settledAt, m.end) | |||
| 482 | + | } | |||
| 483 | + | if v, done := m.at(start.Add(settledAt)); !done || v != 0 { | |||
| 484 | + | t.Fatalf("%s: settled value %v %v", tc.name, v, done) | |||
| 485 | + | } | |||
| 486 | + | for d := settledAt; d <= m.end+time.Second; d += 100 * time.Microsecond { | |||
| 487 | + | if x := m.spring.valueAt(d); math.Abs(x) >= pixelSnap { | |||
| 488 | + | t.Fatalf("%s: %v past the snap at %v (settled at %v)", tc.name, x, d, settledAt) | |||
| 489 | + | } | |||
| 490 | + | } | |||
| 491 | + | t.Logf("%s: settled at %v, epsilon end %v", tc.name, settledAt, m.end) | |||
| 492 | + | } | |||
| 493 | + | level := newMotion(levelSpring(1, 0), start, 1) | |||
| 494 | + | if v, done := level.at(start.Add(level.end - time.Millisecond)); done || v == 0 { | |||
| 495 | + | t.Fatalf("a level spring snapped: %v %v", v, done) | |||
| 496 | + | } | |||
| 497 | + | if _, done := level.at(start.Add(level.end)); !done { | |||
| 498 | + | t.Fatal("a level spring never ends") | |||
| 499 | + | } | |||
| 500 | + | } | |||
| 501 | + | ||||
| 502 | + | // A workspace slide snaps in output pixels: at a 600 px output half a pixel | |||
| 503 | + | // is 1/1200 of a workspace. | |||
| 504 | + | func TestSwitchSpringSnapsAtOutputPixels(t *testing.T) { | |||
| 505 | + | m := newMonitorWithIDs("", "", new(uint64)) | |||
| 506 | + | m.template.Output = Rect{W: 800, H: 600} | |||
| 507 | + | s := m.switchSpring(0.5, 0) | |||
| 508 | + | if s.Snap != pixelSnap/600 { | |||
| 509 | + | t.Fatalf("snap %v", s.Snap) | |||
| 510 | + | } | |||
| 511 | + | if s.settled(0.5/600, 0) || !s.settled(0.2/600, 0) { | |||
| 512 | + | t.Fatal("snap threshold") | |||
| 513 | + | } | |||
| 514 | + | } | |||
M internal/core/publish_alloc_test.go +2 -2
| @@ -86,8 +86,8 @@ func startRectMotions(c *Core, sc *screen, t0 time.Time) { | |||
| 86 | 86 | x: c.spring(sp, t0), y: c.spring(sp, t0), | |
| 87 | 87 | w: c.spring(sp, t0), h: c.spring(sp, t0), | |
| 88 | 88 | dx: 100, dy: 100, dw: 100, dh: 100, | |
| 89 | - | fade: c.spring(viewSpring(0.5, 0), t0), df: 0.5, | |
| 90 | - | dim: c.spring(viewSpring(0.5, 0), t0), ddim: 0.5, | |
| 89 | + | fade: c.spring(levelSpring(0.5, 0), t0), df: 0.5, | |
| 90 | + | dim: c.spring(levelSpring(0.5, 0), t0), ddim: 0.5, | |
| 91 | 91 | scale: true, | |
| 92 | 92 | } | |
| 93 | 93 | } | |
| @@ -86,8 +86,8 @@ func startRectMotions(c *Core, sc *screen, t0 time.Time) { | |||||
| 86 | x: c.spring(sp, t0), y: c.spring(sp, t0), | 86 | x: c.spring(sp, t0), y: c.spring(sp, t0), | ||
| 87 | w: c.spring(sp, t0), h: c.spring(sp, t0), | 87 | w: c.spring(sp, t0), h: c.spring(sp, t0), | ||
| 88 | dx: 100, dy: 100, dw: 100, dh: 100, | 88 | dx: 100, dy: 100, dw: 100, dh: 100, | ||
| 89 | - | fade: c.spring(viewSpring(0.5, 0), t0), df: 0.5, | 89 | + | fade: c.spring(levelSpring(0.5, 0), t0), df: 0.5, |
| 90 | - | dim: c.spring(viewSpring(0.5, 0), t0), ddim: 0.5, | 90 | + | dim: c.spring(levelSpring(0.5, 0), t0), ddim: 0.5, |
| 91 | scale: true, | 91 | scale: true, | ||
| 92 | } | 92 | } | ||
| 93 | } | 93 | } | ||
M internal/core/refresh_bench_test.go +1 -1
| @@ -37,7 +37,7 @@ func BenchmarkRefreshShown(b *testing.B) { | |||
| 37 | 37 | sc.rects[p.ID] = rectMotion{ | |
| 38 | 38 | x: c.spring(sp, t0), y: c.spring(sp, t0), w: c.spring(sp, t0), h: c.spring(sp, t0), | |
| 39 | 39 | dx: 100, dy: 100, dw: 100, dh: 100, | |
| 40 | - | fade: c.spring(viewSpring(0.5, 0), t0), df: 0.5, scale: true, | |
| 40 | + | fade: c.spring(levelSpring(0.5, 0), t0), df: 0.5, scale: true, | |
| 41 | 41 | } | |
| 42 | 42 | } | |
| 43 | 43 | for i, p := range sc.settledLayout[24:] { | |
| @@ -37,7 +37,7 @@ func BenchmarkRefreshShown(b *testing.B) { | |||||
| 37 | sc.rects[p.ID] = rectMotion{ | 37 | sc.rects[p.ID] = rectMotion{ | ||
| 38 | x: c.spring(sp, t0), y: c.spring(sp, t0), w: c.spring(sp, t0), h: c.spring(sp, t0), | 38 | x: c.spring(sp, t0), y: c.spring(sp, t0), w: c.spring(sp, t0), h: c.spring(sp, t0), | ||
| 39 | dx: 100, dy: 100, dw: 100, dh: 100, | 39 | dx: 100, dy: 100, dw: 100, dh: 100, | ||
| 40 | - | fade: c.spring(viewSpring(0.5, 0), t0), df: 0.5, scale: true, | 40 | + | fade: c.spring(levelSpring(0.5, 0), t0), df: 0.5, scale: true, |
| 41 | } | 41 | } | ||
| 42 | } | 42 | } | ||
| 43 | for i, p := range sc.settledLayout[24:] { | 43 | for i, p := range sc.settledLayout[24:] { | ||
M internal/core/slide.go +11 -0
| @@ -32,6 +32,17 @@ func (w *Workspace) retarget(before int) { | |||
| 32 | 32 | ||
| 33 | 33 | func (w *Workspace) shiftPixels() int { return int(math.Round(w.shift)) } | |
| 34 | 34 | ||
| 35 | + | // switchSpring is the workspace slide's spring from off: it snaps once it | |
| 36 | + | // is under half a logical pixel of the output height from its target | |
| 37 | + | // (slideLayout rounds the offset times the height). | |
| 38 | + | func (m *Monitor) switchSpring(off, velocity float64) spring { | |
| 39 | + | s := workspaceSpring(off, velocity) | |
| 40 | + | if h := m.template.Output.H; h > 0 { | |
| 41 | + | s.Snap = pixelSnap / float64(h) | |
| 42 | + | } | |
| 43 | + | return s | |
| 44 | + | } | |
| 45 | + | ||
| 35 | 46 | // swipeScale turns touchpad distance into pixels: viewSwipeMovement | |
| 36 | 47 | // scrolls one usable width. | |
| 37 | 48 | func (w *Workspace) swipeScale() float64 { | |
| @@ -32,6 +32,17 @@ func (w *Workspace) retarget(before int) { | |||||
| 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)) } | ||
| 34 | 34 | ||||
| 35 | + | // switchSpring is the workspace slide's spring from off: it snaps once it | |||
| 36 | + | // is under half a logical pixel of the output height from its target | |||
| 37 | + | // (slideLayout rounds the offset times the height). | |||
| 38 | + | func (m *Monitor) switchSpring(off, velocity float64) spring { | |||
| 39 | + | s := workspaceSpring(off, velocity) | |||
| 40 | + | if h := m.template.Output.H; h > 0 { | |||
| 41 | + | s.Snap = pixelSnap / float64(h) | |||
| 42 | + | } | |||
| 43 | + | return s | |||
| 44 | + | } | |||
| 45 | + | ||||
| 35 | // swipeScale turns touchpad distance into pixels: viewSwipeMovement | 46 | // swipeScale turns touchpad distance into pixels: viewSwipeMovement | ||
| 36 | // scrolls one usable width. | 47 | // scrolls one usable width. | ||
| 37 | func (w *Workspace) swipeScale() float64 { | 48 | func (w *Workspace) swipeScale() float64 { | ||
M internal/core/transition.go +22 -12
| @@ -198,12 +198,22 @@ func (m *rectMotion) show(p *Placement) { | |||
| 198 | 198 | // now, with the speed v it had then. A component with nothing to move and no | |
| 199 | 199 | // speed stops. | |
| 200 | 200 | func (c *Core) retargetComponent(m *motion, cur *float64, off, v float64, now time.Time) { | |
| 201 | - | *cur = off | |
| 202 | - | if off == 0 && v == 0 { | |
| 201 | + | c.retargetWith(m, cur, viewSpring(off, v), now) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // retargetLevel is retargetComponent for a fade or veil offset (a level in | |
| 205 | + | // 0..1): its spring runs to its fine epsilon, no pixel snap. | |
| 206 | + | func (c *Core) retargetLevel(m *motion, cur *float64, off, v float64, now time.Time) { | |
| 207 | + | c.retargetWith(m, cur, levelSpring(off, v), now) | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | func (c *Core) retargetWith(m *motion, cur *float64, s spring, now time.Time) { | |
| 211 | + | *cur = s.From | |
| 212 | + | if s.From == 0 && s.Velocity == 0 { | |
| 203 | 213 | *m = motion{} | |
| 204 | 214 | return | |
| 205 | 215 | } | |
| 206 | - | *m = c.spring(viewSpring(off, v), now) | |
| 216 | + | *m = c.spring(s, now) | |
| 207 | 217 | } | |
| 208 | 218 | ||
| 209 | 219 | // refreshShown builds the layouts of every screen once per publish: the | |
| @@ -349,7 +359,7 @@ func (c *Core) appearAt(sc *screen, p Placement, now time.Time) { | |||
| 349 | 359 | c.appearFrom(&rm, p, &old, now) | |
| 350 | 360 | } else { | |
| 351 | 361 | c.scaleFrom(&rm, p.Rect, appearScale, now) | |
| 352 | - | c.retargetComponent(&rm.fade, &rm.df, 1, 0, now) | |
| 362 | + | c.retargetLevel(&rm.fade, &rm.df, 1, 0, now) | |
| 353 | 363 | } | |
| 354 | 364 | sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current() | |
| 355 | 365 | } | |
| @@ -373,8 +383,8 @@ func (c *Core) appearFrom(rm *rectMotion, p Placement, old *rectMotion, now time | |||
| 373 | 383 | c.retargetComponent(&rm.y, &rm.dy, float64(base.Y-settled.Y)+off.y, vel.y, now) | |
| 374 | 384 | c.retargetComponent(&rm.w, &rm.dw, float64(base.W-settled.W)+off.w, vel.w, now) | |
| 375 | 385 | c.retargetComponent(&rm.h, &rm.dh, float64(base.H-settled.H)+off.h, vel.h, now) | |
| 376 | - | c.retargetComponent(&rm.fade, &rm.df, 1+fade, fadeV, now) | |
| 377 | - | c.retargetComponent(&rm.dim, &rm.ddim, dim, dimV, now) | |
| 386 | + | c.retargetLevel(&rm.fade, &rm.df, 1+fade, fadeV, now) | |
| 387 | + | c.retargetLevel(&rm.dim, &rm.ddim, dim, dimV, now) | |
| 378 | 388 | } | |
| 379 | 389 | ||
| 380 | 390 | // mapWindow handles a window's map: it joins its workspace, the tiles the | |
| @@ -562,8 +572,8 @@ func (c *Core) leaveFrom(sc *screen, p Placement, shot *rectShot, now time.Time) | |||
| 562 | 572 | rm.left.Rect = end | |
| 563 | 573 | // The leaving fade is an offset from 1: the window drawn at fade f is | |
| 564 | 574 | // at f-1. | |
| 565 | - | c.retargetComponent(&rm.fade, &rm.df, fade-1, fadeV, now) | |
| 566 | - | c.retargetComponent(&rm.dim, &rm.ddim, dim, dimV, now) | |
| 575 | + | c.retargetLevel(&rm.fade, &rm.df, fade-1, fadeV, now) | |
| 576 | + | c.retargetLevel(&rm.dim, &rm.ddim, dim, dimV, now) | |
| 567 | 577 | sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current() | |
| 568 | 578 | } | |
| 569 | 579 | ||
| @@ -729,7 +739,7 @@ func (c *Core) transitionCamera(b *viewShot, before []viewShot, now time.Time) { | |||
| 729 | 739 | m.stopSwitch() | |
| 730 | 740 | return | |
| 731 | 741 | } | |
| 732 | - | m.switchMotion = c.spring(workspaceSpring(off, b.switchV), now) | |
| 742 | + | m.switchMotion = c.spring(m.switchSpring(off, b.switchV), now) | |
| 733 | 743 | } | |
| 734 | 744 | } | |
| 735 | 745 | ||
| @@ -791,7 +801,7 @@ func (c *Core) transitionRects(b *viewShot, before []viewShot, now time.Time) { | |||
| 791 | 801 | c.retargetComponent(&rm.h, &rm.dh, float64(oldRect.H-p.Rect.H)+old.off.h, old.vel.h, now) | |
| 792 | 802 | } | |
| 793 | 803 | if dimMoves { | |
| 794 | - | c.retargetComponent(&rm.dim, &rm.ddim, dimOff, old.dimV, now) | |
| 804 | + | c.retargetLevel(&rm.dim, &rm.ddim, dimOff, old.dimV, now) | |
| 795 | 805 | } | |
| 796 | 806 | if rm.on() { | |
| 797 | 807 | sc.rects[p.ID], sc.rectsWS = rm, m.Current() | |
| @@ -869,10 +879,10 @@ func (c *Core) transitionOverview(b *viewShot, now time.Time) { | |||
| 869 | 879 | rm := sc.rects[p.ID] | |
| 870 | 880 | i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID }) | |
| 871 | 881 | if i >= 0 && changed { | |
| 872 | - | c.retargetComponent(&rm.fade, &rm.df, b.rects[i].fade, b.rects[i].fadeV, now) | |
| 882 | + | c.retargetLevel(&rm.fade, &rm.df, b.rects[i].fade, b.rects[i].fadeV, now) | |
| 873 | 883 | } | |
| 874 | 884 | if i < 0 { | |
| 875 | - | c.retargetComponent(&rm.fade, &rm.df, 1, 0, now) | |
| 885 | + | c.retargetLevel(&rm.fade, &rm.df, 1, 0, now) | |
| 876 | 886 | } else if old := &b.rects[i]; old.rect != p.Rect { | |
| 877 | 887 | rm.scale = true | |
| 878 | 888 | c.retargetComponent(&rm.x, &rm.dx, float64(old.rect.X-p.Rect.X)+old.off.x, old.vel.x, now) | |
| @@ -198,12 +198,22 @@ func (m *rectMotion) show(p *Placement) { | |||||
| 198 | // now, with the speed v it had then. A component with nothing to move and no | 198 | // now, with the speed v it had then. A component with nothing to move and no | ||
| 199 | // speed stops. | 199 | // speed stops. | ||
| 200 | func (c *Core) retargetComponent(m *motion, cur *float64, off, v float64, now time.Time) { | 200 | func (c *Core) retargetComponent(m *motion, cur *float64, off, v float64, now time.Time) { | ||
| 201 | - | *cur = off | 201 | + | c.retargetWith(m, cur, viewSpring(off, v), now) |
| 202 | - | if off == 0 && v == 0 { | 202 | + | } |
| 203 | + | ||||
| 204 | + | // retargetLevel is retargetComponent for a fade or veil offset (a level in | |||
| 205 | + | // 0..1): its spring runs to its fine epsilon, no pixel snap. | |||
| 206 | + | func (c *Core) retargetLevel(m *motion, cur *float64, off, v float64, now time.Time) { | |||
| 207 | + | c.retargetWith(m, cur, levelSpring(off, v), now) | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | func (c *Core) retargetWith(m *motion, cur *float64, s spring, now time.Time) { | |||
| 211 | + | *cur = s.From | |||
| 212 | + | if s.From == 0 && s.Velocity == 0 { | |||
| 203 | *m = motion{} | 213 | *m = motion{} | ||
| 204 | return | 214 | return | ||
| 205 | } | 215 | } | ||
| 206 | - | *m = c.spring(viewSpring(off, v), now) | 216 | + | *m = c.spring(s, now) |
| 207 | } | 217 | } | ||
| 208 | 218 | ||||
| 209 | // refreshShown builds the layouts of every screen once per publish: the | 219 | // refreshShown builds the layouts of every screen once per publish: the | ||
| @@ -349,7 +359,7 @@ func (c *Core) appearAt(sc *screen, p Placement, now time.Time) { | |||||
| 349 | c.appearFrom(&rm, p, &old, now) | 359 | c.appearFrom(&rm, p, &old, now) | ||
| 350 | } else { | 360 | } else { | ||
| 351 | c.scaleFrom(&rm, p.Rect, appearScale, now) | 361 | c.scaleFrom(&rm, p.Rect, appearScale, now) | ||
| 352 | - | c.retargetComponent(&rm.fade, &rm.df, 1, 0, now) | 362 | + | c.retargetLevel(&rm.fade, &rm.df, 1, 0, now) |
| 353 | } | 363 | } | ||
| 354 | sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current() | 364 | sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current() | ||
| 355 | } | 365 | } | ||
| @@ -373,8 +383,8 @@ func (c *Core) appearFrom(rm *rectMotion, p Placement, old *rectMotion, now time | |||||
| 373 | c.retargetComponent(&rm.y, &rm.dy, float64(base.Y-settled.Y)+off.y, vel.y, now) | 383 | c.retargetComponent(&rm.y, &rm.dy, float64(base.Y-settled.Y)+off.y, vel.y, now) | ||
| 374 | c.retargetComponent(&rm.w, &rm.dw, float64(base.W-settled.W)+off.w, vel.w, now) | 384 | c.retargetComponent(&rm.w, &rm.dw, float64(base.W-settled.W)+off.w, vel.w, now) | ||
| 375 | c.retargetComponent(&rm.h, &rm.dh, float64(base.H-settled.H)+off.h, vel.h, now) | 385 | c.retargetComponent(&rm.h, &rm.dh, float64(base.H-settled.H)+off.h, vel.h, now) | ||
| 376 | - | c.retargetComponent(&rm.fade, &rm.df, 1+fade, fadeV, now) | 386 | + | c.retargetLevel(&rm.fade, &rm.df, 1+fade, fadeV, now) |
| 377 | - | c.retargetComponent(&rm.dim, &rm.ddim, dim, dimV, now) | 387 | + | c.retargetLevel(&rm.dim, &rm.ddim, dim, dimV, now) |
| 378 | } | 388 | } | ||
| 379 | 389 | ||||
| 380 | // mapWindow handles a window's map: it joins its workspace, the tiles the | 390 | // mapWindow handles a window's map: it joins its workspace, the tiles the | ||
| @@ -562,8 +572,8 @@ func (c *Core) leaveFrom(sc *screen, p Placement, shot *rectShot, now time.Time) | |||||
| 562 | rm.left.Rect = end | 572 | rm.left.Rect = end | ||
| 563 | // The leaving fade is an offset from 1: the window drawn at fade f is | 573 | // The leaving fade is an offset from 1: the window drawn at fade f is | ||
| 564 | // at f-1. | 574 | // at f-1. | ||
| 565 | - | c.retargetComponent(&rm.fade, &rm.df, fade-1, fadeV, now) | 575 | + | c.retargetLevel(&rm.fade, &rm.df, fade-1, fadeV, now) |
| 566 | - | c.retargetComponent(&rm.dim, &rm.ddim, dim, dimV, now) | 576 | + | c.retargetLevel(&rm.dim, &rm.ddim, dim, dimV, now) |
| 567 | sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current() | 577 | sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current() | ||
| 568 | } | 578 | } | ||
| 569 | 579 | ||||
| @@ -729,7 +739,7 @@ func (c *Core) transitionCamera(b *viewShot, before []viewShot, now time.Time) { | |||||
| 729 | m.stopSwitch() | 739 | m.stopSwitch() | ||
| 730 | return | 740 | return | ||
| 731 | } | 741 | } | ||
| 732 | - | m.switchMotion = c.spring(workspaceSpring(off, b.switchV), now) | 742 | + | m.switchMotion = c.spring(m.switchSpring(off, b.switchV), now) |
| 733 | } | 743 | } | ||
| 734 | } | 744 | } | ||
| 735 | 745 | ||||
| @@ -791,7 +801,7 @@ func (c *Core) transitionRects(b *viewShot, before []viewShot, now time.Time) { | |||||
| 791 | c.retargetComponent(&rm.h, &rm.dh, float64(oldRect.H-p.Rect.H)+old.off.h, old.vel.h, now) | 801 | c.retargetComponent(&rm.h, &rm.dh, float64(oldRect.H-p.Rect.H)+old.off.h, old.vel.h, now) | ||
| 792 | } | 802 | } | ||
| 793 | if dimMoves { | 803 | if dimMoves { | ||
| 794 | - | c.retargetComponent(&rm.dim, &rm.ddim, dimOff, old.dimV, now) | 804 | + | c.retargetLevel(&rm.dim, &rm.ddim, dimOff, old.dimV, now) |
| 795 | } | 805 | } | ||
| 796 | if rm.on() { | 806 | if rm.on() { | ||
| 797 | sc.rects[p.ID], sc.rectsWS = rm, m.Current() | 807 | sc.rects[p.ID], sc.rectsWS = rm, m.Current() | ||
| @@ -869,10 +879,10 @@ func (c *Core) transitionOverview(b *viewShot, now time.Time) { | |||||
| 869 | rm := sc.rects[p.ID] | 879 | rm := sc.rects[p.ID] | ||
| 870 | i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID }) | 880 | i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID }) | ||
| 871 | if i >= 0 && changed { | 881 | if i >= 0 && changed { | ||
| 872 | - | c.retargetComponent(&rm.fade, &rm.df, b.rects[i].fade, b.rects[i].fadeV, now) | 882 | + | c.retargetLevel(&rm.fade, &rm.df, b.rects[i].fade, b.rects[i].fadeV, now) |
| 873 | } | 883 | } | ||
| 874 | if i < 0 { | 884 | if i < 0 { | ||
| 875 | - | c.retargetComponent(&rm.fade, &rm.df, 1, 0, now) | 885 | + | c.retargetLevel(&rm.fade, &rm.df, 1, 0, now) |
| 876 | } else if old := &b.rects[i]; old.rect != p.Rect { | 886 | } else if old := &b.rects[i]; old.rect != p.Rect { | ||
| 877 | rm.scale = true | 887 | rm.scale = true | ||
| 878 | c.retargetComponent(&rm.x, &rm.dx, float64(old.rect.X-p.Rect.X)+old.off.x, old.vel.x, now) | 888 | c.retargetComponent(&rm.x, &rm.dx, float64(old.rect.X-p.Rect.X)+old.off.x, old.vel.x, now) | ||
M internal/core/transition_fade_internal_test.go +1 -1
| @@ -254,7 +254,7 @@ func TestDimMotionAnimatesSceneDim(t *testing.T) { | |||
| 254 | 254 | settleShown(t, c) | |
| 255 | 255 | sc.rects = map[WindowID]rectMotion{} | |
| 256 | 256 | var rm rectMotion | |
| 257 | - | c.retargetComponent(&rm.dim, &rm.ddim, 0.5, 0, ic.now) | |
| 257 | + | c.retargetLevel(&rm.dim, &rm.ddim, 0.5, 0, ic.now) | |
| 258 | 258 | sc.rects[1], sc.rectsWS = rm, sc.mon.Current() | |
| 259 | 259 | if w := sceneWindow(t, indicatorScene(t, c), 1); w.Dim != 0.5 { | |
| 260 | 260 | t.Fatalf("dim %v, want the 0.5 offset", w.Dim) | |
| @@ -254,7 +254,7 @@ func TestDimMotionAnimatesSceneDim(t *testing.T) { | |||||
| 254 | settleShown(t, c) | 254 | settleShown(t, c) | ||
| 255 | sc.rects = map[WindowID]rectMotion{} | 255 | sc.rects = map[WindowID]rectMotion{} | ||
| 256 | var rm rectMotion | 256 | var rm rectMotion | ||
| 257 | - | c.retargetComponent(&rm.dim, &rm.ddim, 0.5, 0, ic.now) | 257 | + | c.retargetLevel(&rm.dim, &rm.ddim, 0.5, 0, ic.now) |
| 258 | sc.rects[1], sc.rectsWS = rm, sc.mon.Current() | 258 | sc.rects[1], sc.rectsWS = rm, sc.mon.Current() | ||
| 259 | if w := sceneWindow(t, indicatorScene(t, c), 1); w.Dim != 0.5 { | 259 | if w := sceneWindow(t, indicatorScene(t, c), 1); w.Dim != 0.5 { | ||
| 260 | t.Fatalf("dim %v, want the 0.5 offset", w.Dim) | 260 | t.Fatalf("dim %v, want the 0.5 offset", w.Dim) | ||