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>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-17 01:37:54 -04:00
1 parent bc716d27be
commit bcce34cda1
603 files changed
+1366 -1277

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
package core
import "github.com/egoist/mygo/ui"
type autoFocusKey struct{}
// AutoFocus focuses e the frame after it appears, once MyGo has recorded an overlay's opener from the focus it had.
func AutoFocus(c *ui.Context, e ui.Element) ui.Element {
step := ui.Local(e, autoFocusKey{}, func() int { return 0 })
switch *step {
case 0:
*step = 1
c.Invalidate()
case 1:
*step = 2
e.Focus()
}
return e
}
+10 -9
View File
@@ -8,11 +8,11 @@ import (
// TestBasesBlockedByDisabledWrapper flips a focused MyGo base's wrapper to Disabled in the frame Space arrives.
func TestBasesBlockedByDisabledWrapper(t *testing.T) {
for name, base := range map[string]func(c *ui.Context, on *bool) *ui.Element{
"CheckboxBase": func(c *ui.Context, on *bool) *ui.Element { return ui.CheckboxBase(c, on) },
"RadioBase": func(c *ui.Context, on *bool) *ui.Element { return ui.RadioBase(c, on, true) },
"SwitchBase": func(c *ui.Context, on *bool) *ui.Element { return ui.SwitchBase(c, on) },
"ToggleBase": func(c *ui.Context, on *bool) *ui.Element { return ui.ToggleBase(c, on) },
for name, base := range map[string]func(c *ui.Context, on *bool) ui.Element{
"CheckboxBase": func(c *ui.Context, on *bool) ui.Element { return ui.CheckboxBase(c, on) },
"RadioBase": func(c *ui.Context, on *bool) ui.Element { return ui.RadioBase(c, on, true) },
"SwitchBase": func(c *ui.Context, on *bool) ui.Element { return ui.SwitchBase(c, on) },
"ToggleBase": func(c *ui.Context, on *bool) ui.Element { return ui.ToggleBase(c, on) },
} {
for _, disable := range []bool{false, true} {
on, off := false, false
@@ -35,8 +35,8 @@ func TestBasesBlockedByDisabledWrapper(t *testing.T) {
}
}
// TestComboboxBaseIgnoresDisabledWrapper shows why Combobox hands its base a copy: a focused one still takes typing.
func TestComboboxBaseIgnoresDisabledWrapper(t *testing.T) {
// TestComboboxBaseBlockedByDisabledWrapper guards Combobox, which binds its base directly: a focused one takes no typing.
func TestComboboxBaseBlockedByDisabledWrapper(t *testing.T) {
q, off := "", false
tt := ui.NewTester(func(c *ui.Context) {
ui.Row(c).Disabled(off).Children(func() {
@@ -48,9 +48,10 @@ func TestComboboxBaseIgnoresDisabledWrapper(t *testing.T) {
t.Fatal("not focused")
}
off = true
tt.Frame()
tt.Type("a")
tt.Frame()
if q != "a" {
t.Fatalf("typing left %q; the wrapper now blocks it and Combobox can drop its copy", q)
if q != "" {
t.Fatalf("typing under a disabled wrapper set %q; Combobox must edit a copy again", q)
}
}
+2 -2
View File
@@ -6,7 +6,7 @@ import (
// ChoiceResult is a choice control's element and whether its value changed this frame.
type ChoiceResult struct {
Element *ui.Element
Element ui.Element
changed bool
}
@@ -14,4 +14,4 @@ type ChoiceResult struct {
func (r ChoiceResult) Changed() bool { return r.changed }
// NewChoiceResult reports e and whether its value changed this frame.
func NewChoiceResult(e *ui.Element, changed bool) ChoiceResult { return ChoiceResult{e, changed} }
func NewChoiceResult(e ui.Element, changed bool) ChoiceResult { return ChoiceResult{e, changed} }
+4 -4
View File
@@ -108,7 +108,7 @@ func DatetimeDayLabel(c *ui.Context, d Date) string {
}
// DatetimeField styles row as a single-line field: solid fill and fine border.
func DatetimeField(c *ui.Context, row *ui.Element, invalid, readOnly bool) *ui.Element {
func DatetimeField(c *ui.Context, row ui.Element, invalid, readOnly bool) ui.Element {
k := Tokens(c)
d := Density(c)
bg, border := k.Surface, k.ControlBorder
@@ -127,7 +127,7 @@ func DatetimeField(c *ui.Context, row *ui.Element, invalid, readOnly bool) *ui.E
}
// DatetimeTrigger is the popup button inside a field; Cmd+C copies its text.
func DatetimeTrigger(c *ui.Context, text, placeholder, icon string) *ui.Element {
func DatetimeTrigger(c *ui.Context, text, placeholder, icon string) ui.Element {
k := Tokens(c)
b := ui.ButtonBase(c).Grow(1).Shrink(1).Justify(ui.Start).Gap(Density(c).Unit() * 2).
MinHeight(theme.MinHitSize).Radius(theme.ControlRadius).Role(ui.RolePopUpButton)
@@ -147,13 +147,13 @@ func DatetimeTrigger(c *ui.Context, text, placeholder, icon string) *ui.Element
}
// DatetimePopup shows fn below anchor while *open; a disabled or read-only anchor closes it.
func DatetimePopup(c *ui.Context, anchor *ui.Element, open *bool, readOnly bool, fn func()) *ui.Element {
func DatetimePopup(c *ui.Context, anchor ui.Element, open *bool, readOnly bool, fn func()) ui.Element {
d := Density(c)
if *open && (readOnly || anchor.IsDisabled()) {
slog.Debug("mujica: datetime popup closed by state", "readOnly", readOnly)
*open = false
}
return ui.PopoverBase(c, anchor, open, func(panel *ui.Element) {
return ui.PopoverBase(c, anchor, open, func(panel ui.Element) {
OverlayStyle(c, panel, OverlayEnter(c, panel))
panel.MaxWidth(d.ControlHeight()*7+d.Unit()*6+2*theme.BorderWidth).Margin(d.Unit(), 0, 0, 0).Padding(d.Unit() * 3).Gap(d.Unit() * 3)
fn()
+1 -1
View File
@@ -8,7 +8,7 @@ import (
)
// IconAction is a square, borderless button showing icon, named label.
func IconAction(c *ui.Context, icon *ui.SVG, label string) *ui.Element {
func IconAction(c *ui.Context, icon *ui.SVG, label string) ui.Element {
k := Tokens(c)
b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).Label(label).TextColor(k.TextMuted)
internal.Ring(b, k.Focus, theme.ControlRadius)
+14 -13
View File
@@ -2,16 +2,16 @@ package core
import (
"log/slog"
"time"
"github.com/egoist/mygo/ui"
)
// overlayLayers is the window's open MujicaUI layers, oldest first.
type overlayLayers struct {
frame, prev time.Time
keys []any
seen []time.Time
root ui.Element // compared only: a new root means a new build pass
pass int
keys []any
seen []int
}
type overlayLayersKey struct{}
@@ -19,11 +19,12 @@ type overlayLayersKey struct{}
// OverlayLayer marks the layer key open in this frame and reports whether it is on top.
func OverlayLayer(c *ui.Context, key any) bool {
s := ui.Local(c.Root(), overlayLayersKey{}, func() overlayLayers { return overlayLayers{} })
if now := c.Now(); now != s.frame {
s.prev, s.frame = s.frame, now
if root := c.Root(); root != s.root {
s.root = root
s.pass++
n := 0
for i, k := range s.keys {
if !s.seen[i].Before(s.prev) { // drop layers the last frame did not build
if s.seen[i] >= s.pass-1 { // drop layers the last pass did not build
s.keys[n], s.seen[n] = k, s.seen[i]
n++
}
@@ -38,10 +39,10 @@ func OverlayLayer(c *ui.Context, key any) bool {
}
}
if at < 0 {
s.keys, s.seen = append(s.keys, key), append(s.seen, s.frame)
s.keys, s.seen = append(s.keys, key), append(s.seen, s.pass)
at = len(s.keys) - 1
}
s.seen[at] = s.frame
s.seen[at] = s.pass
return at == len(s.keys)-1
}
@@ -76,7 +77,7 @@ type overlaySiteState struct {
}
// OverlaySite builds the invisible focus fallback at a layer's call site.
func OverlaySite(c *ui.Context) (*ui.Element, *overlaySiteState) {
func OverlaySite(c *ui.Context) (ui.Element, *overlaySiteState) {
site := ui.Box(c).Size(0, 0).FocusRing(false)
st := ui.Local(site, "mujica.overlay.site", func() overlaySiteState { return overlaySiteState{} })
st.holding = st.holding && site.FocusWithin()
@@ -89,12 +90,12 @@ func OverlaySite(c *ui.Context) (*ui.Element, *overlaySiteState) {
}
// Shown records whether the focus is in the open layer's panel.
func (st *overlaySiteState) Shown(panel *ui.Element) {
st.focusIn, st.wait = panel != nil && panel.FocusWithin(), 0
func (st *overlaySiteState) Shown(panel ui.Element) {
st.focusIn, st.wait = panel.Valid() && panel.FocusWithin(), 0
}
// Closed moves a lost focus to the site once MyGo has had its chance to restore it.
func (st *overlaySiteState) Closed(c *ui.Context, site *ui.Element) {
func (st *overlaySiteState) Closed(c *ui.Context, site ui.Element) {
if st.focusIn {
st.focusIn, st.wait = false, 2 // MyGo prunes the panel at the end of the next frame
}
+2 -2
View File
@@ -9,7 +9,7 @@ import (
)
// OverlayEnter returns how far, 0 to 1, the popup born with e has entered.
func OverlayEnter(c *ui.Context, e *ui.Element) float32 {
func OverlayEnter(c *ui.Context, e ui.Element) float32 {
born := ui.Local(e, "mujica.overlay.born", func() time.Time { return c.Now() })
d := Motion(c, theme.PopupDuration)
if d == 0 {
@@ -24,7 +24,7 @@ func OverlayEnter(c *ui.Context, e *ui.Element) float32 {
}
// OverlayStyle gives a popup panel the surface, edge and shadow of the theme.
func OverlayStyle(c *ui.Context, panel *ui.Element, p float32) {
func OverlayStyle(c *ui.Context, panel ui.Element, p float32) {
k := Tokens(c)
panel.Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.PopupRadius).
TextColor(k.Text).Opacity(p).Shadow(0, 6, 18, 0, ui.RGBA(0, 0, 0, 0.16*p))
+1 -1
View File
@@ -51,7 +51,7 @@ func SeverityLook(k theme.Tokens, s Severity) (ui.Color, *ui.SVG) {
// AlertResult is what an Alert built and reported this frame.
type AlertResult struct {
// Element is nil while the alert is closed.
Element *ui.Element
Element ui.Element
// Action reports a click on the action button.
Action bool
// Closed reports the user dismissing the alert.
+2 -2
View File
@@ -20,7 +20,7 @@ type SpinnerOptions struct {
}
// Spinner turns four diamonds only while painted; hidden or under reduced motion it stops.
func Spinner(c *ui.Context, opts SpinnerOptions) *ui.Element {
func Spinner(c *ui.Context, opts SpinnerOptions) ui.Element {
if !(opts.Size >= 0) || math.IsInf(float64(opts.Size), 1) {
panic(fmt.Sprintf("mujica: Spinner Size %v invalid", opts.Size))
}
@@ -41,7 +41,7 @@ func Spinner(c *ui.Context, opts SpinnerOptions) *ui.Element {
row := ui.Row(c).Gap(Density(c).Unit() * 2).AlignItems(ui.Center)
row.Children(func() {
// Keyed by motion so a flip repaints; MyGo's spinner carries the progress role.
ui.Box(c).Key(still).Shrink(0).Children(func() { spinnerMark(c, label, size, color, still) })
ui.Box(c.Key(still)).Shrink(0).Children(func() { spinnerMark(c, label, size, color, still) })
if opts.ShowLabel {
ui.Text(c, label).TextColor(k.TextMuted).SingleLine()
}