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>
172 lines
4.5 KiB
Go
172 lines
4.5 KiB
Go
package navigation
|
|
|
|
import (
|
|
"log/slog"
|
|
|
|
"github.com/egoist/mygo/ui"
|
|
|
|
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
|
|
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/internal"
|
|
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
|
)
|
|
|
|
// Tab is one tab of Tabs.
|
|
type Tab struct {
|
|
// Label is required; it names the tab and its panel.
|
|
Label string
|
|
Icon *ui.SVG
|
|
Disabled bool
|
|
// Panel builds the tab's content while it is chosen; nil shows none.
|
|
Panel func()
|
|
}
|
|
|
|
// TabsOptions configure Tabs.
|
|
type TabsOptions struct {
|
|
// Vertical stacks the tabs at the start with the panel beside them.
|
|
Vertical bool
|
|
// Label names the tab list for assistive technology.
|
|
Label string
|
|
}
|
|
|
|
// Tabs shows tabs choosing *selected and the chosen tab's panel. Disabled tabs are skipped by the arrows.
|
|
func Tabs(c *ui.Context, selected *int, tabs []Tab, opts TabsOptions) core.ChoiceResult {
|
|
if len(tabs) == 0 {
|
|
panic("mujica: Tabs needs at least one tab")
|
|
}
|
|
if *selected < 0 || *selected >= len(tabs) {
|
|
panic("mujica: Tabs selected index out of range")
|
|
}
|
|
if tabs[*selected].Disabled {
|
|
panic("mujica: Tabs selected tab is disabled")
|
|
}
|
|
k := core.Tokens(c)
|
|
d := core.Density(c)
|
|
u := d.Unit()
|
|
pad := theme.FocusGap + theme.FocusWidth
|
|
// TabsBase only sees enabled tabs, so the arrows skip disabled ones.
|
|
var enabled []int
|
|
at := 0
|
|
for i, t := range tabs {
|
|
if !t.Disabled {
|
|
if i == *selected {
|
|
at = len(enabled)
|
|
}
|
|
enabled = append(enabled, i)
|
|
}
|
|
}
|
|
root := ui.Column(c).AlignItems(ui.Stretch)
|
|
if root.IsDisabled() {
|
|
for _, key := range []ui.Key{ui.KeyRight, ui.KeyDown, ui.KeyLeft, ui.KeyUp, ui.KeyHome, ui.KeyEnd} {
|
|
root.Shortcut(0, key)
|
|
}
|
|
}
|
|
if opts.Vertical {
|
|
root.Row()
|
|
}
|
|
res := core.ChoiceResult{Element: root}
|
|
root.Children(func() {
|
|
var strip ui.Element
|
|
if opts.Vertical {
|
|
strip = ui.Scroll(c).Shrink(0).Padding(pad)
|
|
} else {
|
|
strip = ui.ScrollHorizontal(c).Shrink(0).MinWidth(0).Padding(pad, pad, 0, pad)
|
|
}
|
|
strip.Draw(func(p *ui.Painter, r ui.Rect) {
|
|
if opts.Vertical {
|
|
p.Fill(ui.Rect{X: r.X + r.W - 1, Y: r.Y, W: 1, H: r.H}, k.Border, 0)
|
|
} else {
|
|
p.Fill(ui.Rect{X: r.X, Y: r.Y + r.H - 1, W: r.W, H: 1}, k.Border, 0)
|
|
}
|
|
})
|
|
strip.Children(func() {
|
|
base := ui.TabsBase(c, &at, len(enabled))
|
|
list := base.List.Gap(u)
|
|
if opts.Vertical {
|
|
list.Column().AlignItems(ui.Stretch)
|
|
}
|
|
if opts.Label != "" {
|
|
list.Label(opts.Label)
|
|
}
|
|
built := make([]ui.Element, len(tabs))
|
|
list.Children(func() {
|
|
n := 0
|
|
for i, t := range tabs {
|
|
var tab ui.Element
|
|
if t.Disabled {
|
|
tab = ui.Row(c).Shrink(0).Role(ui.RoleTab).Disabled(true)
|
|
} else {
|
|
tab = base.Tab(n)
|
|
n++
|
|
}
|
|
TabsStyle(c, tab, t, i == enabled[at], opts.Vertical)
|
|
built[i] = tab
|
|
}
|
|
})
|
|
if !list.IsDisabled() && list.Changed() {
|
|
prev := *selected
|
|
*selected = enabled[at]
|
|
res = core.NewChoiceResult(res.Element, true)
|
|
built[*selected].ScrollIntoView()
|
|
slog.Debug("mujica: tabs change", "from", prev, "to", *selected)
|
|
}
|
|
})
|
|
panel := ui.Column(c).MinWidth(0).Padding(u * 4).Role(ui.RoleGroup).Label(tabs[*selected].Label)
|
|
if opts.Vertical {
|
|
panel.Grow(1)
|
|
}
|
|
if p := tabs[*selected].Panel; p != nil {
|
|
panel.Children(p)
|
|
}
|
|
})
|
|
return res
|
|
}
|
|
|
|
func TabsStyle(c *ui.Context, tab ui.Element, t Tab, on, vertical bool) {
|
|
k := core.Tokens(c)
|
|
d := core.Density(c)
|
|
u := d.Unit()
|
|
tab.MinHeight(d.ControlHeight()).Padding(0, u*4).Gap(u * 2).AlignItems(ui.Center).Cursor(ui.CursorPointer)
|
|
if vertical {
|
|
tab.Radius(theme.ControlRadius, 0, 0, theme.ControlRadius)
|
|
} else {
|
|
tab.Radius(theme.ControlRadius, theme.ControlRadius, 0, 0)
|
|
}
|
|
if t.Disabled {
|
|
tab.Cursor(ui.CursorNotAllowed)
|
|
}
|
|
if t.Label == "" {
|
|
panic("mujica: Tab needs a Label")
|
|
}
|
|
fg := k.TextMuted
|
|
switch {
|
|
case on:
|
|
tab.Background(k.Selection).FontWeight(600)
|
|
fg = k.Text
|
|
case !t.Disabled && tab.Pressed():
|
|
tab.Background(k.Selection)
|
|
fg = k.Text
|
|
case !t.Disabled && tab.Hovered():
|
|
tab.Background(k.SurfaceHover)
|
|
fg = k.Text
|
|
}
|
|
internal.Ease(tab, core.Motion(c, theme.StateDuration), fg)
|
|
tab.FocusRing(false).DrawOver(func(p *ui.Painter, r ui.Rect) {
|
|
if on {
|
|
if vertical {
|
|
p.Fill(ui.Rect{X: r.X + r.W - 2, Y: r.Y, W: 2, H: r.H}, k.AccentText, 0)
|
|
} else {
|
|
p.Fill(ui.Rect{X: r.X, Y: r.Y + r.H - 2, W: r.W, H: 2}, k.AccentText, 0)
|
|
}
|
|
}
|
|
if tab.FocusVisible() {
|
|
internal.FocusRing(p, r, theme.ControlRadius, k.Focus)
|
|
}
|
|
})
|
|
tab.Children(func() {
|
|
if t.Icon != nil {
|
|
ui.Icon(c, t.Icon).Size(16, 16)
|
|
}
|
|
ui.Text(c, t.Label).SingleLine()
|
|
})
|
|
}
|