package mujica import ( "fmt" "log/slog" "net/mail" "slices" "strings" "github.com/egoist/mygo/ui" "mujicaui/internal" "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) TagsInputParts { k := Tokens(c) if opts.Label == "" { opts.Label = 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 TagsInputParts wrap.Children(func() { parts = TagsInput(c, recipients, 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(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 = TagsInputParts{Element: wrap, Input: in, changed: 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))*Density(c).ControlHeight()+16, max(0, wh-8)) x, y := overlayPlace(c, b, w, h, 4) panel := ui.Column(c).Absolute().Left(x).Top(y).Width(w).MaxHeight(h).Padding(4).Role(ui.RoleList). Label(msg(c, "recipient_input.suggestions")) overlayStyle(c, panel, 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(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() { Avatar(c, ct.Name, AvatarOptions{Size: 22}) ui.Text(c, ct.Name).SingleLine() ui.Text(c, ct.Email).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine() }) if row.Clicked() && !row.IsDisabled() { pick(ct) parts.changed = 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() }