1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
|
package flamegraph
import (
"math"
"github.com/charmbracelet/harmonica"
)
const springEpsilon = 0.01
type frameSpring struct {
path string
base tuiFrame
widthSpring harmonica.Spring
colSpring harmonica.Spring
currentW float64
currentCol float64
velocityW float64
velocityCol float64
targetW float64
targetCol float64
}
// AnimationState stores per-frame spring interpolation state.
type AnimationState struct {
springs []frameSpring
frames []tuiFrame
settled bool
fps int
angularVelocity float64
damping float64
}
// NewAnimationState builds a spring animation state with the provided parameters.
func NewAnimationState(fps int, angularVelocity, damping float64) AnimationState {
if fps <= 0 {
fps = 30
}
return AnimationState{
fps: fps,
angularVelocity: angularVelocity,
damping: damping,
settled: true,
}
}
// SetTargets sets new frame targets, preserving spring motion for matching paths.
func (a *AnimationState) SetTargets(targets []tuiFrame) {
existing := make(map[string]frameSpring, len(a.springs))
for _, spring := range a.springs {
existing[spring.path] = spring
}
next := make([]frameSpring, 0, len(targets))
for _, target := range targets {
spring, ok := existing[target.Path]
if !ok {
spring = frameSpring{
path: target.Path,
currentW: float64(target.Width),
currentCol: float64(target.Col),
}
}
spring.base = target
spring.targetW = float64(target.Width)
spring.targetCol = float64(target.Col)
spring.widthSpring = harmonica.NewSpring(harmonica.FPS(a.fps), a.angularVelocity, a.damping)
spring.colSpring = harmonica.NewSpring(harmonica.FPS(a.fps), a.angularVelocity, a.damping)
next = append(next, spring)
}
a.springs = next
if cap(a.frames) < len(a.springs) {
a.frames = make([]tuiFrame, len(a.springs))
} else {
a.frames = a.frames[:len(a.springs)]
}
a.settled = len(a.springs) == 0
for _, spring := range a.springs {
if !isSpringSettled(spring) {
a.settled = false
break
}
}
}
// Tick advances springs by delta seconds and returns true while animation is active.
func (a *AnimationState) Tick(delta float64) bool {
if len(a.springs) == 0 {
a.settled = true
return false
}
baseDelta := harmonica.FPS(a.fps)
if delta <= 0 {
delta = baseDelta
}
active := false
for idx := range a.springs {
spring := &a.springs[idx]
if delta != baseDelta {
spring.widthSpring = harmonica.NewSpring(delta, a.angularVelocity, a.damping)
spring.colSpring = harmonica.NewSpring(delta, a.angularVelocity, a.damping)
}
spring.currentW, spring.velocityW = spring.widthSpring.Update(spring.currentW, spring.velocityW, spring.targetW)
spring.currentCol, spring.velocityCol = spring.colSpring.Update(spring.currentCol, spring.velocityCol, spring.targetCol)
if !isSpringSettled(*spring) {
active = true
}
}
a.settled = !active
return active
}
// CurrentFrames returns interpolated frames for the current animation step.
func (a *AnimationState) CurrentFrames() []tuiFrame {
for idx, spring := range a.springs {
frame := spring.base
frame.Col = maxInt(0, int(math.Round(spring.currentCol)))
frame.Width = maxInt(1, int(math.Round(spring.currentW)))
a.frames[idx] = frame
}
return a.frames
}
// Settled reports whether all active springs are at rest.
func (a AnimationState) Settled() bool {
return a.settled
}
func isSpringSettled(s frameSpring) bool {
return math.Abs(s.currentW-s.targetW) < springEpsilon &&
math.Abs(s.currentCol-s.targetCol) < springEpsilon &&
math.Abs(s.velocityW) < springEpsilon &&
math.Abs(s.velocityCol) < springEpsilon
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
|