Files
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

183 lines
5.6 KiB
Go

package messaging
import (
"fmt"
"log/slog"
"net/mail"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/internal"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
)
// Contact is a person RecipientInput can suggest.
type Contact struct {
Name, Email string
}
// RecipientInputOptions configure a RecipientInput.
type RecipientInputOptions struct {
// Contacts supplies suggestions matched by name or address.
Contacts []Contact
Placeholder string
// Label names the field, as "To".
Label string
Disabled bool
}
// recipientMax is how many suggestions show.
const recipientMax = 6
type recipientState struct {
query string
matchedOn string
matches []Contact
contacts *Contact
count int
added []string
hi int
dismissed string
}
type recipientKey struct{}
// RecipientInput validates addresses and offers contact suggestions.
func RecipientInput(c *ui.Context, recipients *[]string, opts RecipientInputOptions) input.TagsInputParts {
k := core.Tokens(c)
if opts.Label == "" {
opts.Label = core.Msg(c, "recipient_input.label")
}
wrap := ui.Column(c).Disabled(opts.Disabled)
st := ui.Local(wrap, recipientKey{}, func() *recipientState { return &recipientState{hi: -1} })
s := *st
// Suggestions are found again only when the typed text changes.
var first *Contact
if len(opts.Contacts) > 0 {
first = &opts.Contacts[0]
}
if q := strings.ToLower(strings.TrimSpace(s.query)); q != s.matchedOn || first != s.contacts || len(opts.Contacts) != s.count || !slices.Equal(s.added, *recipients) {
s.matchedOn, s.matches, s.hi = q, recipientFind(opts.Contacts, *recipients, q), -1
s.contacts, s.count, s.added = first, len(opts.Contacts), slices.Clone(*recipients)
}
typed := s.query
var parts input.TagsInputParts
wrap.Children(func() {
parts = input.TagsInput(c, recipients, input.TagsInputOptions{
Placeholder: opts.Placeholder, Label: opts.Label, Disabled: opts.Disabled, Query: &s.query,
Validate: func(tag string) string {
if s.hi >= 0 && tag == strings.TrimSpace(typed) {
return "" // replaced by the highlighted contact below
}
if _, err := mail.ParseAddress(tag); err != nil {
return fmt.Sprintf(core.Msg(c, "recipient_input.invalid"), tag)
}
return ""
},
})
})
parts.Element = wrap
in := parts.Input
live := !wrap.IsDisabled()
showing := live && len(s.matches) > 0 && s.dismissed != s.query && s.query != ""
if showing && in.Focused() {
switch {
case in.Shortcut(0, ui.KeyDown):
s.hi = min(len(s.matches)-1, s.hi+1)
case in.Shortcut(0, ui.KeyUp):
s.hi = max(-1, s.hi-1)
case in.Shortcut(0, ui.KeyEscape):
s.dismissed, s.hi = s.query, -1
}
}
pick := func(ct Contact) {
addr := recipientFormat(ct)
if n := len(*recipients); n > 0 && (*recipients)[n-1] == strings.TrimSpace(typed) {
*recipients = (*recipients)[:n-1] // the raw text Enter added
}
if !slices.Contains(*recipients, addr) {
*recipients = append(slices.Clone(*recipients), addr)
}
s.query, s.hi = "", -1
in.Focus()
slog.Debug("mujica: recipient_input pick", "count", len(*recipients))
}
if live && in.Submitted() && s.hi >= 0 && s.hi < len(s.matches) {
pick(s.matches[s.hi])
parts = input.NewTagsInputParts(wrap, in, true)
}
if showing {
b := wrap.Bounds()
ui.Overlay(c, func() {
ww, wh := c.Size()
w := min(max(240, b.W), max(0, ww-8))
h := min(float32(len(s.matches))*core.Density(c).ControlHeight()+16, max(0, wh-8))
x, y := core.PlaceOverlay(c, b, w, h, 4)
panel := ui.Column(c).Absolute().Left(x).Top(y).Width(w).MaxHeight(h).Padding(4).Role(ui.RoleList).
Label(core.Msg(c, "recipient_input.suggestions"))
core.OverlayStyle(c, panel, core.OverlayEnter(c, panel))
panel.Children(func() {
ui.Scroll(c).Shrink(1).Padding(4).Children(func() {
for i, ct := range s.matches {
row := ui.ButtonBase(c.Key(ct.Email)).MinHeight(core.Density(c).ControlHeight()).Justify(ui.Start).Gap(8).Padding(0, 8).
Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(recipientFormat(ct)).FocusRing(false)
if i == s.hi || row.Hovered() {
row.Background(k.Selection)
}
if i == s.hi || row.FocusVisible() {
row.DrawOver(func(p *ui.Painter, r ui.Rect) { internal.FocusRing(p, r, theme.ControlRadius, k.Focus) })
}
row.Children(func() {
display.Avatar(c, ct.Name, display.AvatarOptions{Size: 22})
ui.Text(c, ct.Name).SingleLine()
ui.Text(c, ct.Email).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
})
if row.Clicked() && !row.IsDisabled() {
pick(ct)
parts = input.NewTagsInputParts(parts.Element, parts.Input, true)
}
}
})
})
if !in.Focused() && !panel.FocusWithin() {
panel.Invisible()
}
})
}
return parts
}
func recipientFind(contacts []Contact, added []string, q string) []Contact {
if q == "" {
return nil
}
var out []Contact
for _, ct := range contacts {
if len(out) == recipientMax {
break
}
if (strings.Contains(strings.ToLower(ct.Name), q) || strings.Contains(strings.ToLower(ct.Email), q)) &&
!slices.Contains(added, recipientFormat(ct)) {
out = append(out, ct)
}
}
return out
}
func recipientFormat(ct Contact) string {
if ct.Name == "" {
return ct.Email
}
plain := ct.Name + " <" + ct.Email + ">"
if _, err := mail.ParseAddress(plain); err == nil {
return plain
}
address := mail.Address{Name: ct.Name, Address: ct.Email}
return address.String()
}