Files
MujicaUI/code/flamegraph.go
T

280 lines
7.9 KiB
Go

package code
import (
"fmt"
"hash/fnv"
"log/slog"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/data"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/internal"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
)
// FlameNode is a profile frame; Value includes children.
type FlameNode struct {
Name string
Value float64
Children []FlameNode
}
// FlamegraphState holds zoom and focus paths and the search.
type FlamegraphState struct {
Zoom, Focus []int
Query string
said string
}
// FlamegraphOptions configure a Flamegraph.
type FlamegraphOptions struct {
// Unit follows values in the readout, as "ms".
Unit string
// Status shows loading or failure in place of the graph.
Status data.DataStatus
Error string
Disabled bool
}
// flameRow is a frame row's height, a full hit target.
const flameRow = theme.MinHitSize
// FlamegraphPalette colors frames at 3:1 on Surface.
func FlamegraphPalette(k theme.Tokens) []ui.Color {
// Ornament alone is too faint on ivory.
return []ui.Color{k.AccentText, k.Info, k.Success, k.Warning, k.Ornament.Mix(k.Text, 0.2), k.TextMuted}
}
// Flamegraph draws a zoomable, searchable profile.
func Flamegraph(c *ui.Context, root *FlameNode, s *FlamegraphState, opts FlamegraphOptions) core.ChoiceResult {
if s == nil {
panic("mujica: Flamegraph needs a state")
}
k := core.Tokens(c)
d := core.Density(c)
var res core.ChoiceResult
res.Element = CodePanel(c).Disabled(opts.Disabled)
res.Element.Children(func() {
CodeHeader(c, func() {
input.SearchInput(c, &s.Query, input.SearchInputOptions{Placeholder: core.Msg(c, "flamegraph.search"), Disabled: opts.Disabled}).Element.Grow(1)
})
if root == nil || opts.Status != data.DataReady {
ui.Column(c).Height(flameRow * 6).Children(func() {
res = core.NewChoiceResult(res.Element, data.DataPlaceholder(c, opts.Status, opts.Error, core.Msg(c, "flamegraph.empty"), core.Msg(c, "flamegraph.empty")))
})
return
}
flameCheck(root)
if flameAt(root, s.Zoom) == nil {
s.Zoom = nil
}
if flameAt(root, s.Focus) == nil {
s.Focus = nil
}
q := strings.ToLower(strings.TrimSpace(s.Query))
depth := flameDepth(root)
g := ui.Box(c).Height(float32(depth)*flameRow).Shrink(0).Margin(d.Unit()*2, d.Unit()*3, 0, d.Unit()*3).Focusable().Label(core.Msg(c, "flamegraph.label"))
internal.Ring(g, k.Focus, theme.ControlRadius)
hover := ui.Local(g, "flamegraph.hover", func() []int { return nil })
zoom, focus := s.Zoom, s.Focus
if !g.IsDisabled() {
if flameInput(c, g, root, s, hover) {
res = core.NewChoiceResult(res.Element, true)
}
}
b := g.Bounds()
g.Draw(func(p *ui.Painter, r ui.Rect) {
if r.W != b.W {
c.Invalidate()
}
flamePaint(p, r, k, root, zoom, focus, q, g.Focused(), core.FontSize(c, theme.CaptionSize))
})
readout := s.Focus
if *hover != nil {
readout = *hover
}
text := flameReadout(c, root, readout, opts.Unit)
ui.Text(c, text).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Padding(d.Unit(), d.Unit()*3).
Role(ui.RoleStatus).Shrink(0).SingleLine()
if g.Focused() && text != s.said {
s.said = text
c.Announce(text)
}
})
return res
}
// flameInput zooms, moves focus and tracks hover.
func flameInput(c *ui.Context, g *ui.Element, root *FlameNode, s *FlamegraphState, hover *[]int) bool {
b := g.Bounds()
x, y, over := g.PointerPosition()
*hover = nil
if over && b.W > 0 {
*hover = flameHit(root, s.Zoom, x/b.W, int(y/flameRow))
}
changed := false
zoomTo := func(path []int) {
s.Zoom, s.Focus, changed = slices.Clone(path), slices.Clone(path), true
slog.Debug("mujica: flamegraph zoom", "depth", len(path))
}
if g.Clicked() && *hover != nil {
zoomTo(*hover)
}
f := s.Focus
switch {
case g.Shortcut(0, ui.KeyEnter):
zoomTo(f)
case g.Shortcut(0, ui.KeyEscape) && len(s.Zoom) > 0:
zoomTo(s.Zoom[:len(s.Zoom)-1])
case g.Shortcut(0, ui.KeyUp) && len(f) > 0:
s.Focus, changed = f[:len(f)-1], true
case g.Shortcut(0, ui.KeyDown):
if n := flameAt(root, f); len(n.Children) > 0 {
s.Focus, changed = append(slices.Clone(f), 0), true
}
case g.Shortcut(0, ui.KeyLeft) && len(f) > 0 && f[len(f)-1] > 0:
s.Focus, changed = append(slices.Clone(f[:len(f)-1]), f[len(f)-1]-1), true
case g.Shortcut(0, ui.KeyRight) && len(f) > 0 && f[len(f)-1] < len(flameAt(root, f[:len(f)-1]).Children)-1:
s.Focus, changed = append(slices.Clone(f[:len(f)-1]), f[len(f)-1]+1), true
}
return changed
}
// flameCheck panics on impossible values.
func flameCheck(n *FlameNode) {
sum := 0.0
for i := range n.Children {
flameCheck(&n.Children[i])
sum += n.Children[i].Value
}
if n.Value < 0 || sum > n.Value*(1+1e-9) {
panic("mujica: FlameNode " + n.Name + " has a negative value or children larger than itself")
}
}
func flameAt(root *FlameNode, path []int) *FlameNode {
n := root
for _, i := range path {
if i < 0 || i >= len(n.Children) {
return nil
}
n = &n.Children[i]
}
return n
}
func flameDepth(n *FlameNode) int {
d := 0
for i := range n.Children {
d = max(d, flameDepth(&n.Children[i]))
}
return d + 1
}
// flameSpan returns a frame's fractional left and width.
func flameSpan(root *FlameNode, zoom, path []int) (x, w float64) {
if len(path) <= len(zoom) {
return 0, 1
}
z := flameAt(root, zoom)
x, w = 0, 1
n := z
for _, i := range path[len(zoom):] {
for j := range i {
x += w * n.Children[j].Value / max(n.Value, 1e-12)
}
w *= n.Children[i].Value / max(n.Value, 1e-12)
n = &n.Children[i]
}
return x, w
}
// flameHit finds the frame at fx in row.
func flameHit(root *FlameNode, zoom []int, fx float32, row int) []int {
if row < len(zoom) {
return slices.Clone(zoom[:row])
}
path := slices.Clone(zoom)
for len(path) < row {
n := flameAt(root, path)
next := -1
for i := range n.Children {
x, w := flameSpan(root, zoom, append(path, i))
if float64(fx) >= x && float64(fx) < x+w {
next = i
}
}
if next < 0 {
return nil
}
path = append(path, next)
}
if x, w := flameSpan(root, zoom, path); float64(fx) < x || float64(fx) >= x+w {
return nil
}
return path
}
func flameColor(k theme.Tokens, name string) ui.Color {
h := fnv.New32a()
h.Write([]byte(name))
pal := FlamegraphPalette(k)
return pal[h.Sum32()%uint32(len(pal))]
}
// flamePaint draws visible frames with search and focus marks.
func flamePaint(p *ui.Painter, r ui.Rect, k theme.Tokens, root *FlameNode, zoom, focus []int, q string, focused bool, size float32) {
var walk func(n *FlameNode, path []int)
walk = func(n *FlameNode, path []int) {
fx, fw := flameSpan(root, zoom, path)
w := float32(fw) * r.W
if w < 1 {
return
}
box := ui.Rect{X: internal.Snap(p, r.X+float32(fx)*r.W), Y: r.Y + float32(len(path))*flameRow, W: internal.Snap(p, w) - 1, H: flameRow - 2}
col := flameColor(k, n.Name)
face := col.Mix(k.Surface, 0.78)
match := q != "" && strings.Contains(strings.ToLower(n.Name), q)
above := len(path) < len(zoom)
if q != "" && !match || above {
face, col = k.Surface, k.Border
}
p.Fill(box, face, 2)
p.Stroke(box, col, 2, 1)
if match {
p.Stroke(box, k.AccentText, 2, 2)
}
if focused && slices.Equal(path, focus) {
internal.FocusRing(p, box, 2, k.Focus)
}
if box.W > 24 {
fg := k.Text
if above || q != "" && !match {
fg = k.TextMuted
}
p.Clip(box, 2, func() { p.Text(box.X+6, box.Y+(box.H-size*1.3)/2, n.Name, size, fg) })
}
for i := range n.Children {
if len(path) < len(zoom) && i != zoom[len(path)] {
continue
}
walk(&n.Children[i], append(slices.Clone(path), i))
}
}
walk(root, nil)
}
// flameReadout names a frame's time and share.
func flameReadout(c *ui.Context, root *FlameNode, path []int, unit string) string {
n := flameAt(root, path)
if n == nil {
return core.Msg(c, "flamegraph.hint")
}
share := 100 * n.Value / max(root.Value, 1e-12)
return fmt.Sprintf(core.Msg(c, "flamegraph.readout"), n.Name, n.Value, unit, share)
}