detached 724a3a0

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
2929 x: c.spring(sp, t0), y: c.spring(sp, t0),
3030 w: c.spring(sp, t0), h: c.spring(sp, t0),
3131 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,
3434 scale: true,
3535 }
3636 if i%2 == 0 {
@@ -47,7 +47,7 @@ func TestAnimateAllocations(t *testing.T) {
4747 if i%2 == 1 {
4848 from = -0.5
4949 }
50- rm.dim, rm.ddim = c.spring(viewSpring(from, 0), t0), from
50+ rm.dim, rm.ddim = c.spring(levelSpring(from, 0), t0), from
5151 sc.rects[WindowID(200+i)] = rm
5252 }
5353
@@ -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.548 from = -0.5
49 }49 }
50- rm.dim, rm.ddim = c.spring(viewSpring(from, 0), t0), from50+ rm.dim, rm.ddim = c.spring(levelSpring(from, 0), t0), from
51 sc.rects[WindowID(200+i)] = rm51 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) {
246246 }
247247 m.switchOff, m.switchList = off, g.list
248248 if c.animOn() {
249- m.switchMotion = c.spring(workspaceSpring(off, velocity), now)
249+ m.switchMotion = c.spring(m.switchSpring(off, velocity), now)
250250 } else {
251251 m.stopSwitch()
252252 }
@@ -246,7 +246,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
246 }246 }
247 m.switchOff, m.switchList = off, g.list247 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 {
7070 DampingRatio float64
7171 Stiffness float64
7272 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
7380 }
7481
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.
7792 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
79102 }
80103
81104 func workspaceSpring(from, velocity float64) spring {
82105 return spring{From: from, Velocity: velocity, DampingRatio: 1, Stiffness: 1000, Epsilon: 0.0001}
83106 }
84107
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+
85124 // f32Epsilon compares damping terms: float64's epsilon is too small for
86125 // them (libadwaita, niri).
87126 const f32Epsilon = 1.1920929e-07
@@ -210,8 +249,13 @@ func (m *motion) at(now time.Time) (float64, bool) {
210249 return m.spring.To, true
211250 }
212251 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
215259 }
216260
217261 // sampleAt is the position and speed m has at now, in units per second,
@@ -70,18 +70,57 @@ type spring struct {
70 DampingRatio float6470 DampingRatio float64
71 Stiffness float6471 Stiffness float64
72 Epsilon float6472 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 for124 // f32Epsilon compares damping terms: float64's epsilon is too small for
86 // them (libadwaita, niri).125 // them (libadwaita, niri).
87 const f32Epsilon = 1.1920929e-07126 const f32Epsilon = 1.1920929e-07
@@ -210,8 +249,13 @@ func (m *motion) at(now time.Time) (float64, bool) {
210 return m.spring.To, true249 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.slow252+ pos, vel := m.spring.valueAt(tau), m.spring.velocityAt(tau)
214- return m.spring.valueAt(tau), false253+ 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) {
458458 t.Fatal("retarget without a slide must not start one")
459459 }
460460 }
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) {
8686 x: c.spring(sp, t0), y: c.spring(sp, t0),
8787 w: c.spring(sp, t0), h: c.spring(sp, t0),
8888 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,
9191 scale: true,
9292 }
9393 }
@@ -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) {
3737 sc.rects[p.ID] = rectMotion{
3838 x: c.spring(sp, t0), y: c.spring(sp, t0), w: c.spring(sp, t0), h: c.spring(sp, t0),
3939 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,
4141 }
4242 }
4343 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) {
3232
3333 func (w *Workspace) shiftPixels() int { return int(math.Round(w.shift)) }
3434
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+
3546 // swipeScale turns touchpad distance into pixels: viewSwipeMovement
3647 // scrolls one usable width.
3748 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: viewSwipeMovement46 // 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) {
198198 // now, with the speed v it had then. A component with nothing to move and no
199199 // speed stops.
200200 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 {
203213 *m = motion{}
204214 return
205215 }
206- *m = c.spring(viewSpring(off, v), now)
216+ *m = c.spring(s, now)
207217 }
208218
209219 // 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) {
349359 c.appearFrom(&rm, p, &old, now)
350360 } else {
351361 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)
353363 }
354364 sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current()
355365 }
@@ -373,8 +383,8 @@ func (c *Core) appearFrom(rm *rectMotion, p Placement, old *rectMotion, now time
373383 c.retargetComponent(&rm.y, &rm.dy, float64(base.Y-settled.Y)+off.y, vel.y, now)
374384 c.retargetComponent(&rm.w, &rm.dw, float64(base.W-settled.W)+off.w, vel.w, now)
375385 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)
378388 }
379389
380390 // 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)
562572 rm.left.Rect = end
563573 // The leaving fade is an offset from 1: the window drawn at fade f is
564574 // 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)
567577 sc.rects[p.ID], sc.rectsWS = rm, sc.mon.Current()
568578 }
569579
@@ -729,7 +739,7 @@ func (c *Core) transitionCamera(b *viewShot, before []viewShot, now time.Time) {
729739 m.stopSwitch()
730740 return
731741 }
732- m.switchMotion = c.spring(workspaceSpring(off, b.switchV), now)
742+ m.switchMotion = c.spring(m.switchSpring(off, b.switchV), now)
733743 }
734744 }
735745
@@ -791,7 +801,7 @@ func (c *Core) transitionRects(b *viewShot, before []viewShot, now time.Time) {
791801 c.retargetComponent(&rm.h, &rm.dh, float64(oldRect.H-p.Rect.H)+old.off.h, old.vel.h, now)
792802 }
793803 if dimMoves {
794- c.retargetComponent(&rm.dim, &rm.ddim, dimOff, old.dimV, now)
804+ c.retargetLevel(&rm.dim, &rm.ddim, dimOff, old.dimV, now)
795805 }
796806 if rm.on() {
797807 sc.rects[p.ID], sc.rectsWS = rm, m.Current()
@@ -869,10 +879,10 @@ func (c *Core) transitionOverview(b *viewShot, now time.Time) {
869879 rm := sc.rects[p.ID]
870880 i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID })
871881 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)
873883 }
874884 if i < 0 {
875- c.retargetComponent(&rm.fade, &rm.df, 1, 0, now)
885+ c.retargetLevel(&rm.fade, &rm.df, 1, 0, now)
876886 } else if old := &b.rects[i]; old.rect != p.Rect {
877887 rm.scale = true
878888 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 no198 // 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 = off201+ 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 return214 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: the219 // 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 the390 // 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 = end572 rm.left.Rect = end
563 // The leaving fade is an offset from 1: the window drawn at fade f is573 // 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 return740 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 = true887 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) {
254254 settleShown(t, c)
255255 sc.rects = map[WindowID]rectMotion{}
256256 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)
258258 sc.rects[1], sc.rectsWS = rm, sc.mon.Current()
259259 if w := sceneWindow(t, indicatorScene(t, c), 1); w.Dim != 0.5 {
260260 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 rectMotion256 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)

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