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>
226 lines
7.3 KiB
Go
226 lines
7.3 KiB
Go
package input
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"slices"
|
|
"strings"
|
|
|
|
"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/internal"
|
|
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
|
|
)
|
|
|
|
// TransferItem is one entry of a Transfer; ID must be unique and stable.
|
|
type TransferItem struct {
|
|
ID string
|
|
Label string
|
|
Disabled bool
|
|
}
|
|
|
|
// TransferOptions configure a Transfer.
|
|
type TransferOptions struct {
|
|
// SourceTitle and TargetTitle replace the built-in "Available" and "Chosen".
|
|
SourceTitle, TargetTitle string
|
|
// Height of each list in DIPs; 240 when zero.
|
|
Height float32
|
|
Disabled bool
|
|
}
|
|
|
|
type transferState struct {
|
|
checked map[string]bool
|
|
query [2]string
|
|
}
|
|
|
|
// Transfer moves items between a source list and *target, the chosen IDs in order.
|
|
func Transfer(c *ui.Context, target *[]string, items []TransferItem, opts TransferOptions) core.ChoiceResult {
|
|
byID := make(map[string]TransferItem, len(items))
|
|
for _, it := range items {
|
|
if _, dup := byID[it.ID]; dup || it.ID == "" {
|
|
panic(fmt.Sprintf("mujica: Transfer item ID %q is empty or repeated", it.ID))
|
|
}
|
|
byID[it.ID] = it
|
|
}
|
|
for _, id := range *target {
|
|
if _, ok := byID[id]; !ok {
|
|
panic(fmt.Sprintf("mujica: Transfer target ID %q is not an item", id))
|
|
}
|
|
}
|
|
d := core.Density(c)
|
|
parts := core.ChoiceResult{}
|
|
row := ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Disabled(opts.Disabled)
|
|
parts.Element = row
|
|
st := ui.Local(row, "transfer", func() transferState { return transferState{checked: map[string]bool{}} })
|
|
for id := range st.checked {
|
|
if it, ok := byID[id]; !ok || it.Disabled {
|
|
delete(st.checked, id)
|
|
}
|
|
}
|
|
var source, chosen []TransferItem
|
|
for _, it := range items {
|
|
if !slices.Contains(*target, it.ID) {
|
|
source = append(source, it)
|
|
}
|
|
}
|
|
for _, id := range *target {
|
|
chosen = append(chosen, byID[id])
|
|
}
|
|
move := func(from []TransferItem, right bool) {
|
|
var ids []string
|
|
for _, it := range from {
|
|
if st.checked[it.ID] && !it.Disabled {
|
|
ids = append(ids, it.ID)
|
|
delete(st.checked, it.ID)
|
|
}
|
|
}
|
|
if len(ids) == 0 {
|
|
return
|
|
}
|
|
if right {
|
|
*target = append(slices.Clone(*target), ids...)
|
|
} else {
|
|
*target = slices.DeleteFunc(slices.Clone(*target), func(id string) bool { return slices.Contains(ids, id) })
|
|
}
|
|
parts = core.NewChoiceResult(parts.Element, true)
|
|
slog.Debug("mujica: transfer moved", "count", len(ids), "right", right)
|
|
}
|
|
height := opts.Height
|
|
if height == 0 {
|
|
height = 240
|
|
}
|
|
srcTitle, dstTitle := opts.SourceTitle, opts.TargetTitle
|
|
if srcTitle == "" {
|
|
srcTitle = core.Msg(c, "transfer.source")
|
|
}
|
|
if dstTitle == "" {
|
|
dstTitle = core.Msg(c, "transfer.target")
|
|
}
|
|
row.Children(func() {
|
|
transferPanel(c, st, 0, srcTitle, source, height)
|
|
ui.Column(c).Gap(d.Unit() * 2).Children(func() {
|
|
right := transferCount(st, source) > 0
|
|
left := transferCount(st, chosen) > 0
|
|
if Button(c, "", ButtonOptions{Variant: Secondary, Icon: icons.Must("chevron-right"), Label: fmt.Sprintf(core.Msg(c, "transfer.to"), dstTitle), Disabled: !right}).Clicked() {
|
|
move(source, true)
|
|
}
|
|
if Button(c, "", ButtonOptions{Variant: Secondary, Icon: icons.Must("chevron-left"), Label: fmt.Sprintf(core.Msg(c, "transfer.to"), srcTitle), Disabled: !left}).Clicked() {
|
|
move(chosen, false)
|
|
}
|
|
})
|
|
transferPanel(c, st, 1, dstTitle, chosen, height)
|
|
})
|
|
return parts
|
|
}
|
|
|
|
// transferCount counts the checked items that can move.
|
|
func transferCount(st *transferState, items []TransferItem) int {
|
|
n := 0
|
|
for _, it := range items {
|
|
if st.checked[it.ID] && !it.Disabled {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// transferPanel is one side: header with select-all, search, and the list.
|
|
func transferPanel(c *ui.Context, st *transferState, side int, title string, items []TransferItem, height float32) {
|
|
k := core.Tokens(c)
|
|
d := core.Density(c)
|
|
q := strings.ToLower(strings.TrimSpace(st.query[side]))
|
|
var shown []TransferItem
|
|
enabled := 0
|
|
for _, it := range items {
|
|
if q == "" || strings.Contains(strings.ToLower(it.Label), q) {
|
|
shown = append(shown, it)
|
|
if !it.Disabled {
|
|
enabled++
|
|
}
|
|
}
|
|
}
|
|
checked := transferCount(st, shown)
|
|
ui.Column(c).Width(240).Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).Children(func() {
|
|
head := ui.Row(c).Gap(d.Unit()*2).AlignItems(ui.Center).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3, 0, d.Unit()*3-(theme.MinHitSize-16)/2).
|
|
BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border)
|
|
head.Children(func() {
|
|
all := checked > 0 && checked == enabled
|
|
box := ui.CheckboxBase(c, &all).Size(theme.MinHitSize, theme.MinHitSize).Justify(ui.Center).Label(fmt.Sprintf(core.Msg(c, "transfer.all"), title)).Disabled(enabled == 0)
|
|
transferMark(c, box, all, checked > 0 && !all)
|
|
box.Description(transferPartial(c, checked, enabled))
|
|
if box.Changed() {
|
|
for _, it := range shown {
|
|
if !it.Disabled {
|
|
st.checked[it.ID] = all
|
|
}
|
|
}
|
|
}
|
|
ui.Text(c, title).Font(theme.SerifFont).FontWeight(600).TextColor(k.Text).Grow(1)
|
|
ui.Text(c, fmt.Sprintf("%d / %d", checked, len(items))).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize)).FontFeatures("tnum")
|
|
})
|
|
ui.Box(c).Padding(d.Unit() * 2).Children(func() {
|
|
InputGroup(c, &st.query[side], InputGroupOptions{Placeholder: core.Msg(c, "transfer.search"), Label: fmt.Sprintf(core.Msg(c, "transfer.search_in"), title)})
|
|
})
|
|
list := ui.Scroll(c).Height(height).Padding(0, d.Unit(), d.Unit(), d.Unit()).FocusGroup(ui.Vertical).Role(ui.RoleList).Label(title)
|
|
list.Children(func() {
|
|
if len(shown) == 0 {
|
|
empty := core.Msg(c, "transfer.empty")
|
|
if q != "" {
|
|
empty = core.Msg(c, "transfer.no_match")
|
|
}
|
|
ui.Text(c, empty).TextColor(k.TextMuted).Padding(d.Unit() * 3).TextAlign(ui.Center).FillWidth()
|
|
return
|
|
}
|
|
for _, it := range shown {
|
|
on := st.checked[it.ID]
|
|
ui.Box(c.Key(it.ID)).Children(func() {
|
|
cb := ui.CheckboxBase(c, &on).Gap(d.Unit()*2).MinHeight(max(d.ControlHeight()-d.Unit(), theme.MinHitSize)).Padding(0, d.Unit()*2).
|
|
Radius(theme.ControlRadius).FillWidth().Disabled(it.Disabled)
|
|
if cb.Changed() {
|
|
st.checked[it.ID] = on
|
|
}
|
|
if on {
|
|
cb.Background(k.Selection)
|
|
} else if cb.Hovered() {
|
|
cb.Background(k.SurfaceHover)
|
|
}
|
|
transferMark(c, cb, on, false)
|
|
cb.Children(func() { ui.Text(c, it.Label).SingleLine().Ellipsis("…").MinWidth(0) })
|
|
})
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
// transferPartial describes a select-all box that is neither clear nor full.
|
|
func transferPartial(c *ui.Context, checked, enabled int) string {
|
|
if checked == 0 || checked == enabled {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf(core.Msg(c, "transfer.partial"), checked, enabled)
|
|
}
|
|
|
|
// transferMark draws a check box's square inside box, which takes the focus ring.
|
|
func transferMark(c *ui.Context, box ui.Element, on, mixed bool) {
|
|
k := core.Tokens(c)
|
|
internal.Ring(box, k.Focus, theme.ControlRadius)
|
|
box.Children(func() {
|
|
sq := ui.Box(c).Size(16, 16).Shrink(0).Center().Radius(theme.ControlRadius).Role(ui.RoleNone)
|
|
if on || mixed {
|
|
sq.Background(k.Accent).TextColor(k.OnAccent)
|
|
} else {
|
|
sq.Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder)
|
|
}
|
|
sq.Children(func() {
|
|
switch {
|
|
case on:
|
|
ui.Icon(c, icons.Must("check")).Size(12, 12)
|
|
case mixed:
|
|
ui.Icon(c, icons.Must("minus")).Size(12, 12)
|
|
}
|
|
})
|
|
})
|
|
}
|