summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-05 22:46:20 +0200
committerPaul Buetow <paul@buetow.org>2026-03-05 22:46:20 +0200
commitfd75c310b7571a48db9135360d99a331638721bc (patch)
tree03838dfab287aa120c526e82117007d97d4cfda3
parent1943f4395d477c9a0f9dad4ce78339b7f1163862 (diff)
task 362: add flamegraph harmonica animation state
-rw-r--r--internal/tui/flamegraph/animation.go132
-rw-r--r--internal/tui/flamegraph/animation_test.go50
-rw-r--r--internal/tui/flamegraph/model.go2
3 files changed, 182 insertions, 2 deletions
diff --git a/internal/tui/flamegraph/animation.go b/internal/tui/flamegraph/animation.go
new file mode 100644
index 0000000..900231d
--- /dev/null
+++ b/internal/tui/flamegraph/animation.go
@@ -0,0 +1,132 @@
+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
+ 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
+ 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
+ }
+ if delta <= 0 {
+ delta = harmonica.FPS(a.fps)
+ }
+
+ active := false
+ for idx := range a.springs {
+ spring := &a.springs[idx]
+ 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 {
+ frames := make([]tuiFrame, 0, len(a.springs))
+ for _, 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)))
+ frames = append(frames, frame)
+ }
+ return frames
+}
+
+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
+}
diff --git a/internal/tui/flamegraph/animation_test.go b/internal/tui/flamegraph/animation_test.go
new file mode 100644
index 0000000..94272e2
--- /dev/null
+++ b/internal/tui/flamegraph/animation_test.go
@@ -0,0 +1,50 @@
+package flamegraph
+
+import "testing"
+
+func TestAnimationStateConvergesToTarget(t *testing.T) {
+ state := NewAnimationState(30, 6.0, 1.0)
+ state.SetTargets([]tuiFrame{{Path: "root", Col: 0, Width: 10}})
+ state.SetTargets([]tuiFrame{{Path: "root", Col: 100, Width: 50}})
+
+ active := true
+ for i := 0; i < 180 && active; i++ {
+ active = state.Tick(0)
+ }
+ if active {
+ t.Fatalf("expected springs to settle within 180 ticks")
+ }
+
+ frames := state.CurrentFrames()
+ if len(frames) != 1 {
+ t.Fatalf("expected one interpolated frame, got %d", len(frames))
+ }
+ if frames[0].Col != 100 || frames[0].Width != 50 {
+ t.Fatalf("expected settled frame at col=100 width=50, got col=%d width=%d", frames[0].Col, frames[0].Width)
+ }
+ if state.Tick(0) {
+ t.Fatalf("expected settled animation to remain inactive")
+ }
+}
+
+func TestAnimationStateHandlesAddedAndRemovedFrames(t *testing.T) {
+ state := NewAnimationState(30, 6.0, 1.0)
+ state.SetTargets([]tuiFrame{
+ {Path: "root", Col: 0, Width: 20},
+ {Path: "root\x1fchild", Col: 20, Width: 20},
+ })
+ if got := len(state.CurrentFrames()); got != 2 {
+ t.Fatalf("expected 2 frames after initial targets, got %d", got)
+ }
+
+ state.SetTargets([]tuiFrame{
+ {Path: "root\x1fchild", Col: 40, Width: 30},
+ })
+ frames := state.CurrentFrames()
+ if len(frames) != 1 {
+ t.Fatalf("expected removed frame to be dropped, got %d frames", len(frames))
+ }
+ if frames[0].Path != "root\x1fchild" {
+ t.Fatalf("expected remaining frame path root\\x1fchild, got %q", frames[0].Path)
+ }
+}
diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go
index ca3bab2..11475b8 100644
--- a/internal/tui/flamegraph/model.go
+++ b/internal/tui/flamegraph/model.go
@@ -25,8 +25,6 @@ type zoomState struct {
previousSelectedIdx int
}
-type frameSpring struct{}
-
type flameKeyMap struct {
MoveShallower key.Binding
MoveDeeper key.Binding