package mujica import ( "fmt" "log/slog" "slices" "github.com/egoist/mygo/ui" "mujicaui/theme" ) // MentionCandidate is someone a MentionInput may mention. type MentionCandidate struct { ID, Name string } // MentionInputOptions configure a MentionInput. type MentionInputOptions struct { // Candidates is required: it returns matches for the text after "@", called when that text changes. Candidates func(query string) []MentionCandidate Placeholder string Label string Disabled bool // Error marks the value invalid and shows the message below. Error string } // mentionInputState is one field's document, history and popup. type mentionInputState struct { doc mentionDoc synced string // the value as last read or written syncedMentions []Mention undo, redo []mentionDoc compose string query string at, pick int found []MentionCandidate dismissed bool panel ui.Rect // the popup's box last frame, which keeps it through a click offset float32 changed, submitted bool } // MentionInput edits *value with "@" mentions kept in *mentions; a mention is deleted and stepped over whole. func MentionInput(c *ui.Context, value *string, mentions *[]Mention, opts MentionInputOptions) InputResult { if value == nil || mentions == nil || opts.Candidates == nil { panic("mujica: MentionInput needs a value, mentions and Candidates") } text := []rune(*value) for _, m := range *mentions { if m.Start < 0 || m.End > len(text) || m.Start >= m.End || string(text[m.Start:m.End]) != "@"+m.Name { panic(fmt.Sprintf("mujica: MentionInput mention %+v does not match its text", m)) } } k := Tokens(c) var r InputResult look := &textInputLook{disabled: opts.Disabled, err: opts.Error} r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { in := ui.Box(c).Grow(1).MinWidth(Density(c).Unit() * 20).Focusable().Clip().Role(ui.RoleTextField).Label(opts.Label).Cursor(ui.CursorText) s := *ui.Local(in, "mention_input", func() *mentionInputState { return &mentionInputState{at: -1} }) if s.doc.text == nil || s.synced != *value || !slices.Equal(s.syncedMentions, *mentions) { // Set from outside: start over at the end. s.doc = mentionDoc{text: text, mentions: slices.Clone(*mentions), caret: len(text), anchor: len(text)} s.undo, s.redo = nil, nil s.synced, s.syncedMentions = *value, slices.Clone(*mentions) } mentionInputQuery(s, opts) px, py, _ := frame.PointerPosition() fb := frame.Bounds() onPanel := s.panel.Contains(fb.X+px, fb.Y+py) popup := (in.Focused() || onPanel) && s.at >= 0 && !s.dismissed && len(s.found) > 0 in.HandleInput(func(ev ui.InputEvent) bool { if in.IsDisabled() { return false } return mentionInputEvent(c, s, ev, popup, in) }) if s.changed && !look.disabled { *value, *mentions = string(s.doc.text), slices.Clone(s.doc.mentions) s.synced, s.syncedMentions = *value, slices.Clone(*mentions) r.changed = true } r.submitted = s.submitted && !look.disabled s.changed, s.submitted = false, false mentionInputView(c, k, s, in, opts.Placeholder) s.panel = ui.Rect{} if popup { mentionInputPopup(c, k, s, frame, in) } return in }, nil) if r.changed { slog.Debug("mujica: mention_input change", "mentions", len(*mentions)) } return r } // mentionInputQuery refreshes the candidates when the "@" text changes. func mentionInputQuery(s *mentionInputState, opts MentionInputOptions) { at, q := s.doc.query() if at != s.at || q != s.query { s.at, s.query, s.pick, s.dismissed = at, q, 0, false s.found = nil if at >= 0 { s.found = opts.Candidates(q) } } } // mentionInputEvent applies one input event to the document and reports whether it took it. func mentionInputEvent(c *ui.Context, s *mentionInputState, ev ui.InputEvent, popup bool, in *ui.Element) bool { edit := func(f func()) { s.undo = append(s.undo, s.doc.clone()) s.redo = nil f() s.changed = true } extend := ev.Mods&ui.Shift != 0 switch ev.Kind { case ui.InputText: s.compose = "" edit(func() { s.doc.insert(ev.Text) }) case ui.InputCompose: s.compose = ev.Text case ui.InputPointerDown: in.Focus() s.doc.moveTo(mentionInputHit(c, s, ev.X), extend) case ui.InputPointerMove: if ev.Button != 0 { return false } s.doc.moveTo(mentionInputHit(c, s, ev.X), true) case ui.InputCommand: a, b := s.doc.span() switch ev.Text { case "copy", "cut": c.WriteClipboard(string(s.doc.text[a:b])) if ev.Text == "cut" && a != b { edit(func() { s.doc.erase(false) }) } case "paste": edit(func() { s.doc.insert(c.ReadClipboard()) }) case "selectAll": s.doc.anchor, s.doc.caret = 0, len(s.doc.text) case "delete": edit(func() { s.doc.erase(true) }) case "undo", "redo": from, to := &s.undo, &s.redo if ev.Text == "redo" { from, to = to, from } if n := len(*from); n > 0 { *to = append(*to, s.doc.clone()) s.doc, *from = (*from)[n-1], (*from)[:n-1] s.changed = true } default: return false } case ui.InputKeyDown: return s.compose == "" && mentionInputKey(s, ev.Key, extend, popup, edit) default: return false } return true } // mentionInputKey handles editing and popup keys; others go on to Tab and shortcuts. func mentionInputKey(s *mentionInputState, key ui.Key, extend, popup bool, edit func(func())) bool { if popup { switch key { case ui.KeyDown, ui.KeyUp: n := len(s.found) if key == ui.KeyDown { s.pick = (s.pick + 1) % n } else { s.pick = (s.pick + n - 1) % n } return true case ui.KeyEnter, ui.KeyTab: m := s.found[s.pick] edit(func() { s.doc.choose(s.at, m.ID, m.Name) }) slog.Debug("mujica: mention_input mention", "id", m.ID) return true case ui.KeyEscape: s.dismissed = true return true } } switch key { case ui.KeyBackspace, ui.KeyDelete: if s.doc.caret == s.doc.anchor && (key == ui.KeyBackspace && s.doc.caret == 0 || key == ui.KeyDelete && s.doc.caret == len(s.doc.text)) { return true } edit(func() { s.doc.erase(key == ui.KeyDelete) }) case ui.KeyLeft: s.doc.move(-1, extend) case ui.KeyRight: s.doc.move(1, extend) case ui.KeyHome: s.doc.moveTo(0, extend) case ui.KeyEnd: s.doc.moveTo(len(s.doc.text), extend) case ui.KeyEnter: s.submitted = true default: return false } return true } // mentionInputHit is the rune boundary nearest x in the field, outside any mention. func mentionInputHit(c *ui.Context, s *mentionInputState, x float32) int { x += s.offset best, bestD := 0, float32(-1) for i := 0; i <= len(s.doc.text); i++ { w, _ := c.MeasureText(0, ui.Span{Text: string(s.doc.text[:i]), Size: FontSize(c, theme.BodySize)}) if d := max(w-x, x-w); bestD < 0 || d < bestD { best, bestD = i, d } } for _, m := range s.doc.mentions { if best > m.Start && best < m.End { best = m.End } } return best } // mentionInputView draws the text with mention chips, the selection and the caret, scrolled to keep the caret in view. func mentionInputView(c *ui.Context, k theme.Tokens, s *mentionInputState, in *ui.Element, placeholder string) { size := FontSize(c, theme.BodySize) measure := func(i int) float32 { w, _ := c.MeasureText(0, ui.Span{Text: string(s.doc.text[:i]), Size: size}) return w } _, h := c.MeasureText(0, ui.Span{Text: "@", Size: size}) x := measure(s.doc.caret) if box := in.Bounds().W; box > 0 { s.offset = max(0, min(s.offset, x), x-box+2) } in.MinHeight(h).TextCaret(ui.Rect{X: x - s.offset, Y: 0, W: 1, H: h}) a, b := min(s.doc.caret, s.doc.anchor), max(s.doc.caret, s.doc.anchor) sel := [2]float32{measure(a), measure(b)} chips := make([][2]float32, len(s.doc.mentions)) for i, m := range s.doc.mentions { chips[i] = [2]float32{measure(m.Start), measure(m.End)} } spans := mentionInputSpans(k, s, size, placeholder) in.Draw(func(p *ui.Painter, r ui.Rect) { y := r.Y + (r.H-h)/2 for _, ch := range chips { p.Fill(ui.Rect{X: r.X + ch[0] - s.offset - 1, Y: y, W: ch[1] - ch[0] + 2, H: h}, k.Selection, theme.ControlRadius) } if a != b && in.Focused() { p.Fill(ui.Rect{X: r.X + sel[0] - s.offset, Y: y, W: sel[1] - sel[0], H: h}, k.Selection.Mix(k.AccentText, 0.25), 0) } p.RichText(r.X-s.offset, y, 0, spans...) }) in.DrawOver(func(p *ui.Painter, r ui.Rect) { if in.Focused() && s.compose == "" { p.Fill(ui.Rect{X: r.X + x - s.offset, Y: r.Y + (r.H-h)/2, W: 1, H: h}, k.Text, 0) } }) } // mentionInputSpans is the field's text: mentions in AccentText, a composition underlined. func mentionInputSpans(k theme.Tokens, s *mentionInputState, size float32, placeholder string) []ui.Span { if len(s.doc.text) == 0 && s.compose == "" { return []ui.Span{{Text: placeholder, Size: size, Color: k.TextMuted}} } var spans []ui.Span at := 0 seg := func(to int, fg ui.Color) { if to > at { spans = append(spans, ui.Span{Text: string(s.doc.text[at:to]), Size: size, Color: fg}) at = to } } composed := s.compose == "" for _, m := range s.doc.mentions { if !composed && s.doc.caret <= m.Start { seg(s.doc.caret, k.Text) spans = append(spans, ui.Span{Text: s.compose, Size: size, Color: k.Text, Underline: true}) composed = true } seg(m.Start, k.Text) seg(m.End, k.AccentText) } if !composed { seg(s.doc.caret, k.Text) spans = append(spans, ui.Span{Text: s.compose, Size: size, Color: k.Text, Underline: true}) } seg(len(s.doc.text), k.Text) return spans } // mentionInputPopup lists the candidates under the field; a click or Enter picks one. func mentionInputPopup(c *ui.Context, k theme.Tokens, s *mentionInputState, frame, in *ui.Element) { b := frame.Bounds() u := Density(c).Unit() ui.Overlay(c, func() { ww, wh := c.Size() h := float32(len(s.found)) * Density(c).ControlHeight() x, y := overlayPlace(c, b, min(b.W, 320), min(h+2*u, wh/2), 4) panel := ui.Column(c).Absolute().Left(x).Top(y).Width(min(max(b.W, 200), ww-8)).MaxHeight(wh / 2).Padding(u).Role(ui.RoleList) overlayStyle(c, panel, overlayEnter(c, panel)) s.panel = panel.Bounds() panel.Children(func() { ui.Scroll(c).Shrink(1).Children(func() { for i, m := range s.found { it := ui.Row(c).Key(m.ID).MinHeight(Density(c).ControlHeight()).AlignItems(ui.Center).Padding(0, u*2). Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(m.Name).Cursor(ui.CursorPointer) if i == s.pick { it.Selected(true).Background(k.Selection).TextColor(k.AccentText) it.Draw(func(p *ui.Painter, r ui.Rect) { p.Fill(ui.Rect{X: r.X, Y: r.Y + 6, W: 2, H: r.H - 12}, k.AccentText, 0) }) } else if it.Hovered() { it.Background(k.SurfaceHover) } it.Children(func() { ui.Text(c, "@"+m.Name).SingleLine() }) if it.Clicked() { s.undo = append(s.undo, s.doc.clone()) s.doc.choose(s.at, m.ID, m.Name) s.changed = true in.Focus() c.Invalidate() } } }) }) }) }