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>
181 lines
5.7 KiB
Go
181 lines
5.7 KiB
Go
package chart
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
|
|
"github.com/egoist/mygo/ui"
|
|
|
|
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
|
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons"
|
|
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
|
|
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/internal"
|
|
)
|
|
|
|
// PriceAlertLine holds a draggable, caller-owned price threshold.
|
|
type PriceAlertLine struct {
|
|
ID string
|
|
Price float64
|
|
// Label shows beside the price; empty shows the price only.
|
|
Label string
|
|
}
|
|
|
|
// CompareSymbol holds aligned closes for percent comparison.
|
|
type CompareSymbol struct {
|
|
Name string
|
|
Closes []float64
|
|
}
|
|
|
|
// compareLines normalizes prices and compared symbols into percentages.
|
|
func (m *mktCore) compareLines() {
|
|
if m.opts.Compare == nil || len(*m.opts.Compare) == 0 || m.to <= m.from {
|
|
return
|
|
}
|
|
m.base = m.candles[m.from].Close
|
|
if m.base == 0 {
|
|
panic("mujica: percent comparison needs a nonzero base close")
|
|
}
|
|
pal := internal.Palette(m.k)
|
|
for i := range m.overlays {
|
|
line := &m.overlays[i]
|
|
values := line.values
|
|
base := m.base
|
|
line.values = ChartMemo(m.ch, fmt.Sprint("compare.overlay.", i), [2]any{ChartID(values), base}, func() []float64 {
|
|
out := make([]float64, len(values))
|
|
for j, v := range values {
|
|
out[j] = (v/base - 1) * 100
|
|
}
|
|
return out
|
|
})
|
|
}
|
|
for i, s := range *m.opts.Compare {
|
|
if s.Name == "" || len(s.Closes) != len(m.candles) {
|
|
panic(fmt.Sprintf("mujica: CompareSymbol %q needs a Name and one close per candle", s.Name))
|
|
}
|
|
ChartMemo(m.ch, "compare.check."+s.Name, ChartID(s.Closes), func() bool {
|
|
for _, v := range s.Closes {
|
|
if math.IsInf(v, 0) || (!math.IsNaN(v) && v <= 0) {
|
|
panic("mujica: compared closes must be positive or NaN")
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
base := math.NaN()
|
|
for j := m.from; j < m.to && math.IsNaN(base); j++ {
|
|
base = s.Closes[j]
|
|
}
|
|
closes := s.Closes
|
|
vals := ChartMemo(m.ch, "compare."+s.Name, [2]any{ChartID(closes), math.Float64bits(base)}, func() []float64 {
|
|
out := make([]float64, len(closes))
|
|
for j, v := range closes {
|
|
out[j] = (v/base - 1) * 100
|
|
}
|
|
return out
|
|
})
|
|
m.overlays = append(m.overlays, mktLine{name: s.Name, values: vals, color: pal[(i+1)%len(pal)], shape: i + 1})
|
|
}
|
|
}
|
|
|
|
// CompareLegend shows series markers and removal actions.
|
|
func CompareLegend(c *ui.Context, symbols *[]CompareSymbol) core.ChoiceResult {
|
|
if symbols == nil {
|
|
panic("mujica: CompareLegend needs symbols")
|
|
}
|
|
k := core.Tokens(c)
|
|
pal := internal.Palette(k)
|
|
u := core.Density(c).Unit()
|
|
removed := -1
|
|
row := ui.Row(c).Wrap().Gap(u * 2).Role(ui.RoleList).Label(core.Msg(c, "compare_symbol.label"))
|
|
row.Children(func() {
|
|
for i, s := range *symbols {
|
|
ui.Row(c.Key(s.Name)).AlignItems(ui.Center).Gap(u).Padding(0, 0, 0, u*2).Radius(6).Border(1, k.Border).Role(ui.RoleListItem).Label(s.Name).Children(func() {
|
|
col := pal[(i+1)%len(pal)]
|
|
ui.Box(c).Size(10, 10).Draw(func(p *ui.Painter, r ui.Rect) { internal.Marker(p, i+1, r.X+5, r.Y+5, 9, col) })
|
|
ui.Text(c, s.Name).SingleLine()
|
|
if core.IconAction(c, icons.Must("x"), fmt.Sprintf(core.Msg(c, "compare_symbol.remove"), s.Name)).Clicked() && !row.IsDisabled() {
|
|
removed = i
|
|
}
|
|
})
|
|
}
|
|
})
|
|
if removed >= 0 {
|
|
slog.Debug("mujica: compare_symbol removed", "name", (*symbols)[removed].Name)
|
|
*symbols = append((*symbols)[:removed:removed], (*symbols)[removed+1:]...)
|
|
}
|
|
return core.NewChoiceResult(row, removed >= 0)
|
|
}
|
|
|
|
// alertsCheck fires callbacks when the latest close crosses thresholds.
|
|
func (m *mktCore) alertsCheck() {
|
|
if m.opts.Alerts != nil {
|
|
seen := map[string]bool{}
|
|
for _, a := range *m.opts.Alerts {
|
|
if a.ID == "" || seen[a.ID] || math.IsNaN(a.Price) || math.IsInf(a.Price, 0) {
|
|
panic("mujica: alerts need unique IDs and finite prices")
|
|
}
|
|
seen[a.ID] = true
|
|
}
|
|
}
|
|
if m.opts.Alerts == nil || len(m.candles) == 0 {
|
|
return
|
|
}
|
|
last := m.candles[len(m.candles)-1]
|
|
if !m.ch.Res.Element.IsDisabled() && m.st.alertSeen && last.Close != m.st.lastClose && m.opts.OnAlert != nil {
|
|
for _, a := range *m.opts.Alerts {
|
|
if (m.st.lastClose-a.Price)*(last.Close-a.Price) <= 0 && m.st.lastClose != a.Price {
|
|
slog.Debug("mujica: price_alert_line crossed", "id", a.ID)
|
|
m.opts.OnAlert(a, last)
|
|
}
|
|
}
|
|
}
|
|
m.st.lastClose, m.st.alertSeen = last.Close, true
|
|
}
|
|
|
|
// alertsInput drags the threshold captured by the initial press.
|
|
func (m *mktCore) alertsInput(g mktGeom, x, y float32, held bool, dy float32) bool {
|
|
if m.opts.Alerts == nil || m.base != 0 {
|
|
return false
|
|
}
|
|
alerts := *m.opts.Alerts
|
|
if !held {
|
|
m.st.grab = -1
|
|
return false
|
|
}
|
|
if m.st.grab < 0 {
|
|
for i, a := range alerts {
|
|
if g.plot.Contains(x, y-dy) && math.Abs(float64(g.ys.Map(a.Price)-(y-dy))) <= 14 {
|
|
m.st.grab = i
|
|
}
|
|
}
|
|
}
|
|
if m.st.grab < 0 || m.st.grab >= len(alerts) {
|
|
return false
|
|
}
|
|
spec := input.NumberInputResolve(input.NumberInputOptions{Step: math.Pow10(-m.dec), Precision: m.dec})
|
|
alerts[m.st.grab].Price = spec.Round(g.ys.Invert(y))
|
|
slog.Debug("mujica: price_alert_line moved", "id", alerts[m.st.grab].ID)
|
|
return true
|
|
}
|
|
|
|
// paintAlerts draws dashed thresholds and price labels.
|
|
func (m *mktCore) paintAlerts(p *ui.Painter, g mktGeom, size float32) {
|
|
if m.opts.Alerts == nil || m.base != 0 {
|
|
return
|
|
}
|
|
for i, a := range *m.opts.Alerts {
|
|
y := internal.Snap(p, g.ys.Map(a.Price))
|
|
if i == m.st.alertFocus {
|
|
p.FillPath(internal.Diamond(internal.Snap(p, g.plot.X+8), y, 9), m.k.Warning)
|
|
}
|
|
for x := g.plot.X; x < g.plot.X+g.plot.W; x += 8 {
|
|
p.Line(x, y, min(x+4, g.plot.X+g.plot.W), y, 1, m.k.Warning)
|
|
}
|
|
text := core.PriceUngrouped(a.Price, m.dec)
|
|
if a.Label != "" {
|
|
text = a.Label + " " + text
|
|
}
|
|
mktTag(p, g.plot.X+g.plot.W, y, fmt.Sprintf(core.Msg(m.c, "price_alert_line.tag"), text), size, m.k.Warning, m.k.Surface)
|
|
}
|
|
}
|