diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-05 22:46:20 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-05 22:46:20 +0200 |
| commit | fd75c310b7571a48db9135360d99a331638721bc (patch) | |
| tree | 03838dfab287aa120c526e82117007d97d4cfda3 | |
| parent | 1943f4395d477c9a0f9dad4ce78339b7f1163862 (diff) | |
task 362: add flamegraph harmonica animation state
| -rw-r--r-- | internal/tui/flamegraph/animation.go | 132 | ||||
| -rw-r--r-- | internal/tui/flamegraph/animation_test.go | 50 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model.go | 2 |
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 |
