277 lines
7.6 KiB
Go
277 lines
7.6 KiB
Go
package mujica
|
|
|
|
import (
|
|
"fmt"
|
|
"hash/fnv"
|
|
"log/slog"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/egoist/mygo/ui"
|
|
|
|
"mujicaui/internal"
|
|
"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 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) ChoiceResult {
|
|
if s == nil {
|
|
panic("mujica: Flamegraph needs a state")
|
|
}
|
|
k := Tokens(c)
|
|
d := Density(c)
|
|
var res ChoiceResult
|
|
res.Element = codePanel(c).Disabled(opts.Disabled)
|
|
res.Element.Children(func() {
|
|
codeHeader(c, func() {
|
|
SearchInput(c, &s.Query, SearchInputOptions{Placeholder: msg(c, "flamegraph.search"), Disabled: opts.Disabled}).Element.Grow(1)
|
|
})
|
|
if root == nil || opts.Status != DataReady {
|
|
ui.Column(c).Height(flameRow * 6).Children(func() {
|
|
res.changed = dataPlaceholder(c, opts.Status, opts.Error, msg(c, "flamegraph.empty"), 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(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.changed = 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(), FontSize(c, theme.CaptionSize))
|
|
})
|
|
readout := s.Focus
|
|
if *hover != nil {
|
|
readout = *hover
|
|
}
|
|
text := flameReadout(c, root, readout, opts.Unit)
|
|
ui.Text(c, text).FontSize(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 msg(c, "flamegraph.hint")
|
|
}
|
|
share := 100 * n.Value / max(root.Value, 1e-12)
|
|
return fmt.Sprintf(msg(c, "flamegraph.readout"), n.Name, n.Value, unit, share)
|
|
}
|