diff --git a/mention_input.go b/mention_input.go new file mode 100644 index 0000000..88125c2 --- /dev/null +++ b/mention_input.go @@ -0,0 +1,333 @@ +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() + } + } + }) + }) + }) +} diff --git a/mention_input_model.go b/mention_input_model.go new file mode 100644 index 0000000..2384235 --- /dev/null +++ b/mention_input_model.go @@ -0,0 +1,142 @@ +package mujica + +import ( + "slices" + "unicode" +) + +// Mention is a whole "@Name" token in a MentionInput's text, at rune offsets [Start, End). +type Mention struct { + ID string + Name string + Start, End int +} + +// mentionDoc is a MentionInput's text, mentions, caret and selection anchor, in runes. +type mentionDoc struct { + text []rune + mentions []Mention + caret, anchor int +} + +func (d mentionDoc) clone() mentionDoc { + d.text = slices.Clone(d.text) + d.mentions = slices.Clone(d.mentions) + return d +} + +// span returns the selection, widened to whole mentions it touches. +func (d *mentionDoc) span() (int, int) { + a, b := min(d.caret, d.anchor), max(d.caret, d.anchor) + for _, m := range d.mentions { + if m.Start < b && m.End > a { + a, b = min(a, m.Start), max(b, m.End) + } + } + return a, b +} + +// replace puts ins in place of [a, b), dropping mentions inside and shifting later ones; the caret lands after ins. +func (d *mentionDoc) replace(a, b int, ins []rune) { + d.text = slices.Concat(d.text[:a], ins, d.text[b:]) + shift := len(ins) - (b - a) + kept := d.mentions[:0] + for _, m := range d.mentions { + switch { + case m.End <= a: + kept = append(kept, m) + case m.Start >= b: + m.Start, m.End = m.Start+shift, m.End+shift + kept = append(kept, m) + } + } + d.mentions = kept + d.caret = a + len(ins) + d.anchor = d.caret +} + +// insert types s at the caret, over any selection. +func (d *mentionDoc) insert(s string) { + a, b := d.span() + d.replace(a, b, []rune(s)) +} + +// erase deletes the selection, else one rune or one whole mention back (or forward). +func (d *mentionDoc) erase(forward bool) bool { + if d.caret != d.anchor { + a, b := d.span() + d.replace(a, b, nil) + return true + } + for _, m := range d.mentions { + if !forward && m.End == d.caret || forward && m.Start == d.caret { + d.replace(m.Start, m.End, nil) + return true + } + } + switch { + case !forward && d.caret > 0: + d.replace(d.caret-1, d.caret, nil) + case forward && d.caret < len(d.text): + d.replace(d.caret, d.caret+1, nil) + default: + return false + } + return true +} + +// move steps the caret by dir runes, over whole mentions; extend keeps the anchor. +func (d *mentionDoc) move(dir int, extend bool) { + at := max(0, min(len(d.text), d.caret+dir)) + for _, m := range d.mentions { + if at > m.Start && at < m.End { + if dir < 0 { + at = m.Start + } else { + at = m.End + } + } + } + d.moveTo(at, extend) +} + +func (d *mentionDoc) moveTo(at int, extend bool) { + d.caret = at + if !extend { + d.anchor = at + } +} + +// query returns where an "@" being typed starts and what follows it, or -1. +func (d *mentionDoc) query() (int, string) { + if d.caret != d.anchor { + return -1, "" + } + for i := d.caret - 1; i >= 0; i-- { + r := d.text[i] + if unicode.IsSpace(r) { + return -1, "" + } + if r != '@' { + continue + } + for _, m := range d.mentions { + if i >= m.Start && i < m.End { + return -1, "" + } + } + if i > 0 && !unicode.IsSpace(d.text[i-1]) { + return -1, "" + } + return i, string(d.text[i+1 : d.caret]) + } + return -1, "" +} + +// choose turns the "@query" before the caret into a mention of id, followed by a space. +func (d *mentionDoc) choose(at int, id, name string) { + tok := []rune("@" + name) + d.replace(at, d.caret, append(slices.Clone(tok), ' ')) + d.mentions = append(d.mentions, Mention{ID: id, Name: name, Start: at, End: at + len(tok)}) + slices.SortFunc(d.mentions, func(a, b Mention) int { return a.Start - b.Start }) +} diff --git a/mention_input_test.go b/mention_input_test.go new file mode 100644 index 0000000..d9b87eb --- /dev/null +++ b/mention_input_test.go @@ -0,0 +1,149 @@ +package mujica + +import ( + "slices" + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +var mentionPeople = []MentionCandidate{{ID: "u1", Name: "Alice"}, {ID: "u2", Name: "Albert"}, {ID: "u3", Name: "Bob"}} + +func mentionFind(q string) []MentionCandidate { + var out []MentionCandidate + for _, p := range mentionPeople { + if strings.HasPrefix(strings.ToLower(p.Name), strings.ToLower(q)) { + out = append(out, p) + } + } + return out +} + +func TestMentionDocWholeTokens(t *testing.T) { + d := mentionDoc{text: []rune("hi @al"), caret: 6, anchor: 6} + at, q := d.query() + if at != 3 || q != "al" { + t.Fatalf("query %d %q", at, q) + } + d.choose(at, "u1", "Alice") + d.insert("there") + if string(d.text) != "hi @Alice there" || d.mentions[0] != (Mention{ID: "u1", Name: "Alice", Start: 3, End: 9}) { + t.Fatalf("after choose: %q %+v", string(d.text), d.mentions) + } + d.moveTo(10, false) + d.move(-1, false) // to the mention's end + d.move(-1, false) // over the whole mention + if d.caret != 3 { + t.Fatalf("Left over a mention lands at %d", d.caret) + } + d.moveTo(9, false) + d.erase(false) + if string(d.text) != "hi there" || len(d.mentions) != 0 || d.caret != 3 { + t.Fatalf("Backspace after a mention: %q %+v %d", string(d.text), d.mentions, d.caret) + } + if at, _ := (&mentionDoc{text: []rune("a@b"), caret: 3, anchor: 3}).query(); at != -1 { + t.Fatal("an email-like @ started a query") + } +} + +func mentionTester(v *string, ms *[]Mention, dis *bool, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Width(400).Children(func() { + if MentionInput(c, v, ms, MentionInputOptions{Candidates: mentionFind, Label: "Comment", Disabled: *dis}).Changed() { + *n++ + } + }) + }, 500, 400) +} + +func TestMentionInputCandidatesKeyboard(t *testing.T) { + v, ms := "", []Mention(nil) + dis, n := false, 0 + tt := mentionTester(&v, &ms, &dis, &n) + tt.Key(0, ui.KeyTab) + tt.Type("ping @al") + if !tt.HasText("@Alice") || !tt.HasText("@Albert") || tt.HasText("@Bob") { + t.Fatalf("candidates %q", tt.Texts()) + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if v != "ping @Albert " || len(ms) != 1 || ms[0].ID != "u2" || ms[0].Start != 5 || ms[0].End != 12 { + t.Fatalf("chose: %q %+v", v, ms) + } + if tt.HasText("@Alice") { + t.Fatal("popup stayed open") + } + tt.Type("ok") + tt.Key(0, ui.KeyBackspace) + tt.Key(0, ui.KeyBackspace) + tt.Key(0, ui.KeyBackspace) // the space + tt.Key(0, ui.KeyBackspace) // the whole mention + if v != "ping " || len(ms) != 0 { + t.Fatalf("whole delete: %q %+v", v, ms) + } + tt.Command("undo") + if v != "ping @Albert" || len(ms) != 1 { + t.Fatalf("undo: %q %+v", v, ms) + } + tt.Type(" @b") + tt.Key(0, ui.KeyEscape) + if tt.HasText("@Bob") { + t.Fatal("Escape did not close the candidates") + } +} + +func TestMentionInputClickCompositionDisabled(t *testing.T) { + v, ms := "", []Mention(nil) + dis, n := false, 0 + tt := mentionTester(&v, &ms, &dis, &n) + tt.Key(0, ui.KeyTab) + tt.Type("@b") + if err := tt.Click("Bob"); err != nil { + t.Fatal(err) + } + if v != "@Bob " || !slices.Equal(ms, []Mention{{ID: "u3", Name: "Bob", Start: 0, End: 4}}) { + t.Fatalf("click: %q %+v", v, ms) + } + tt.Compose("你", 1) + if v != "@Bob " { + t.Fatalf("composition committed early: %q", v) + } + tt.Type("你好") + if v != "@Bob 你好" { + t.Fatalf("commit: %q", v) + } + before := n + dis = true + tt.Frame() + tt.Type("x") + tt.Key(0, ui.KeyBackspace) + if v != "@Bob 你好" || n != before { + t.Fatalf("disabled edit: %q", v) + } +} + +func TestMentionInputRejectsBadMentions(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + v, ms := "hi Bob", []Mention{{ID: "u3", Name: "Bob", Start: 3, End: 6}} + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + MentionInput(c, &v, &ms, MentionInputOptions{Candidates: mentionFind}) + }, 300, 100) +} + +func TestMentionInputSnapshot(t *testing.T) { + v, ms := "thanks @Alice, see @Bob", []Mention{{ID: "u1", Name: "Alice", Start: 7, End: 13}, {ID: "u3", Name: "Bob", Start: 19, End: 23}} + e, none := "", []Mention(nil) + snapshot(t, "mention_input", 420, 120, func(c *ui.Context) { + ui.Column(c).Padding(16).Gap(12).Children(func() { + MentionInput(c, &v, &ms, MentionInputOptions{Candidates: mentionFind}) + MentionInput(c, &e, &none, MentionInputOptions{Candidates: mentionFind, Placeholder: "Write a comment…"}) + }) + }) +} diff --git a/testdata/snapshots/darwin/mention_input-dark.png b/testdata/snapshots/darwin/mention_input-dark.png new file mode 100644 index 0000000..1588c52 Binary files /dev/null and b/testdata/snapshots/darwin/mention_input-dark.png differ diff --git a/testdata/snapshots/darwin/mention_input-light.png b/testdata/snapshots/darwin/mention_input-light.png new file mode 100644 index 0000000..cba77f7 Binary files /dev/null and b/testdata/snapshots/darwin/mention_input-light.png differ