Files
MujicaUI/chart/chart_frame.go
T
ZacharyZhang-NYandClaude Opus 5.5 bcce34cda1 Upgrade MyGo to v0.5.5
MyGo v0.5.5 returns ui.Element as a checked value handle instead of a
pointer. Migrated with `mygo migrate-ui`, then fixed what the codemod
leaves to review:

- Element nil checks become .Valid(); absent elements are ui.Element{}.
- Input handlers and paint callbacks run outside the build pass, where
  elements expire and Context methods are inert: read IsDisabled into a
  local during the build, keep a ui.Handle for Focus, use c.Services()
  for clipboard access, create ui.Local state during the build.
- Add core.AutoFocus: v0.5.5 records an overlay's opener after its
  content builds, so same-frame autofocus lost focus restore on close.
  It focuses one frame later; cascader rows defer their focus likewise.
- core.OverlayLayer identifies build passes by the root element, not
  c.Now(), which repeats across passes and under the Tester clock.
- Context menus survive the opening press counted as PressedOutside;
  split button tooltip no longer reorders its open menu; disabled
  menubar rows pass presses to the panel; Form defers blur-started
  async checks so the growing message no longer moves Submit away
  from the release; Combobox drops its disabled copy now that MyGo
  blocks typing under a disabled wrapper (canary flipped to a guard).
- The Tester clock is virtual: tests use Advance/Now instead of
  time.Sleep/time.Now and read element state inside the view. The web
  mirror advances the clock to wall time before each frame and input.
- AGENTS.md and docs describe the value Element API and the new pin.

Snapshot goldens are darwin-only and were not re-verified here.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 01:37:54 -04:00

254 lines
7.9 KiB
Go

package chart
import (
"log/slog"
"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/internal"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
)
// ChartFrame is what every chart shares.
type ChartFrame struct {
// Label names the chart for assistive technology; required.
Label string
// Height is the plot height in DIPs; 0 means 240.
Height float32
// Status shows loading or failure in the plot's place.
Status data.DataStatus
Error string
// Empty replaces the built-in "No data" text.
Empty string
}
// ChartResult is a built chart and this frame's events.
type ChartResult struct {
Element ui.Element
// Point is the hovered or focused mark, -1 for none.
Point int
changed, retry bool
toggled string
value bool
}
// Changed reports that the readout moved to another mark.
func (r ChartResult) Changed() bool { return r.changed }
// Retried reports a click on a failed load's retry.
func (r ChartResult) Retried() bool { return r.retry }
// ValueChanged reports a changed selection, path, year or pause.
func (r ChartResult) ValueChanged() bool { return r.value }
// Toggled returns the legend series switched in this frame.
func (r ChartResult) Toggled() (string, bool) { return r.toggled, r.toggled != "" }
// ChartSpec is a chart's marks and drawing, for Chart; r is the chart box in each callback.
type ChartSpec struct {
// Marks counts what the keys step through; 0 shows the empty state.
Marks int
// Legend lists series keys above the plot.
Legend []ChartLegendItem
// Hidden, when set, makes legend keys check boxes switching series off.
Hidden *map[string]bool
// Draw paints the plot; active is the read mark, or -1.
Draw func(p *ui.Painter, r ui.Rect, active int)
// Pick returns the mark at (x, y), or -1; required.
Pick func(r ui.Rect, x, y float32) int
// Readout describes mark i; it is shown, announced and the tooltip's default.
Readout func(i int) string
// Move steps from mark i for an arrow key; nil steps in order.
Move func(i int, key ui.Key) int
// Activate runs on Enter, Space or a click on a mark.
Activate func(i int)
// Anchor places mark i's tooltip for the keyboard; nil uses the top middle.
Anchor func(r ui.Rect, i int) (x, y float32)
// Tooltip writes mark i's tooltip; nil splits Readout at " · ".
Tooltip func(i int) []ChartTooltipLine
// Input takes the plot's other input, as a ChartAnnotation's drags.
Input func(plot ui.Element, r ui.Rect)
}
// Chart frames a custom chart: legend, focus, keys, readout, tooltip and empty states.
func Chart(c *ui.Context, frame ChartFrame, spec ChartSpec) ChartResult {
if spec.Marks < 0 || spec.Draw == nil || spec.Pick == nil || spec.Readout == nil {
panic("mujica: Chart needs Marks ≥ 0, Draw, Pick and Readout")
}
res, _ := NewChart(c, frame).Build(spec)
return res
}
type chartState struct {
focus, last int
hidden map[string]bool
}
// chart is a chart being built.
type chart struct {
c *ui.Context
frame ChartFrame
st *chartState
Res ChartResult
}
// NewChart starts a chart; ask it which series are hidden.
func NewChart(c *ui.Context, frame ChartFrame) *chart {
if frame.Label == "" || frame.Height < 0 {
panic("mujica: chart needs a Label and a non-negative Height")
}
if frame.Height == 0 {
frame.Height = 240
}
ch := &chart{c: c, frame: frame}
ch.Res.Element = ui.Column(c).Gap(core.Density(c).Unit() * 2).MinWidth(0)
ch.st = ui.Local(ch.Res.Element, "mujica.chart", func() chartState { return chartState{focus: -1, last: -1, hidden: map[string]bool{}} })
return ch
}
// Toggles returns the legend's hidden set when series can be switched off.
func (ch *chart) Toggles(on bool) *map[string]bool {
if on {
return &ch.st.hidden
}
return nil
}
// Hidden reports whether the legend switched series name off.
func (ch *chart) Hidden(name string) bool { return ch.st.hidden[name] }
// Build lays out legend, plot and readout; returns the plot.
func (ch *chart) Build(spec ChartSpec) (ChartResult, ui.Element) {
c, k, st := ch.c, core.Tokens(ch.c), ch.st
u := core.Density(c).Unit()
var plot ui.Element
ch.Res.Element.Children(func() {
if len(spec.Legend) > 0 {
if name, ok := ChartLegend(c, spec.Hidden, spec.Legend, ChartLegendOptions{}).Toggled(); ok {
ch.Res.toggled = name
}
}
if ch.frame.Status == data.DataLoading || ch.frame.Status == data.DataFailed || spec.Marks == 0 {
st.focus = -1
ch.Res.retry = ChartEmptyState(c, ch.frame.Status, ChartEmptyStateOptions{Height: ch.frame.Height, Error: ch.frame.Error, Empty: ch.frame.Empty}).Changed()
ch.Res.Point = -1
ui.Box(c).Height(u * 5)
return
}
box := ui.Box(c).Height(ch.frame.Height).FillWidth().MinWidth(0)
plot = box.Focusable().Label(ch.frame.Label).Role(ui.RoleGroup)
internal.Ring(plot, k.Focus, theme.ControlRadius)
st.focus = min(st.focus, spec.Marks-1)
ch.input(plot, spec)
if spec.Input != nil && !plot.IsDisabled() {
b := plot.Bounds()
spec.Input(plot, ui.Rect{W: b.W, H: b.H})
}
active := -1
b := plot.Bounds()
local := ui.Rect{W: b.W, H: b.H}
x, y, over := plot.PointerPosition()
if over && !plot.IsDisabled() {
active = spec.Pick(local, x, y)
}
if active < 0 && plot.Focused() && st.focus >= 0 && !plot.IsDisabled() {
active = st.focus
x, y = local.W/2, 0
if spec.Anchor != nil {
x, y = spec.Anchor(local, active)
}
}
ch.Res.Point = active
if active >= 0 && b.W > 0 {
lines := chartTipLines(spec.Readout(active))
if spec.Tooltip != nil {
lines = spec.Tooltip(active)
}
ChartTooltip(c, b.X+x, b.Y+y, lines)
}
plot.Draw(func(p *ui.Painter, r ui.Rect) { spec.Draw(p, r, active) })
text, col := core.Msg(c, "chart.hint"), k.TextMuted
if active >= 0 {
text, col = spec.Readout(active), k.Text
}
ui.Text(c, text).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(col).Role(ui.RoleStatus).SingleLine().Height(u * 5)
})
ch.Res.changed = ch.Res.Point != st.last
st.last = ch.Res.Point
return ch.Res, plot
}
// input moves the keyboard mark, and takes clicks on marks.
func (ch *chart) input(plot ui.Element, spec ChartSpec) {
st := ch.st
if plot.IsDisabled() {
return
}
to := st.focus
for _, key := range []ui.Key{ui.KeyLeft, ui.KeyRight, ui.KeyUp, ui.KeyDown} {
if !plot.Shortcut(0, key) {
continue
}
switch {
case st.focus < 0:
to = 0
case spec.Move != nil:
to = spec.Move(st.focus, key)
case key == ui.KeyLeft || key == ui.KeyUp:
to = max(st.focus-1, 0)
default:
to = min(st.focus+1, spec.Marks-1)
}
}
if plot.Shortcut(0, ui.KeyHome) {
to = 0
}
if plot.Shortcut(0, ui.KeyEnd) {
to = spec.Marks - 1
}
if plot.Shortcut(0, ui.KeyEscape) {
to = -1
}
if to != st.focus {
st.focus = to
if to >= 0 {
ch.c.Announce(spec.Readout(to))
}
slog.Debug("mujica: chart focus", "label", ch.frame.Label, "mark", to)
}
act := st.focus >= 0 && (plot.Shortcut(0, ui.KeyEnter) || plot.Shortcut(0, ui.KeySpace))
if plot.Clicked() {
plot.Focus()
x, y, _ := plot.PointerPosition()
b := plot.Bounds()
if i := spec.Pick(ui.Rect{W: b.W, H: b.H}, x, y); i >= 0 {
st.focus, act = i, true
}
}
if act && spec.Activate != nil {
spec.Activate(st.focus)
}
}
// ChartText draws s anchored at (x, y) by ax, ay.
func ChartText(p *ui.Painter, s string, x, y, size float32, col ui.Color, ax, ay float32) (w, h float32) {
w, h = p.MeasureText(0, ui.Span{Text: s, Size: size})
p.Text(x-w*ax, y-h*ay, s, size, col)
return w, h
}
// ChartInset is r less room for axis labels.
func ChartInset(r ui.Rect, left, bottom float32) ui.Rect {
return ui.Rect{X: r.X + left, Y: r.Y + 8, W: max(r.W-left-12, 1), H: max(r.H-bottom-8, 1)}
}
// ChartInk is Text or Surface, whichever reads better on bg.
func ChartInk(k theme.Tokens, bg ui.Color) ui.Color {
if theme.Contrast(k.Text, bg) >= theme.Contrast(k.Surface, bg) {
return k.Text
}
return k.Surface
}