From 7f5b67fc2c9f7190b5bfaa4c8a7636435aab92ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 5 Oct 2026 14:23:10 -0400 Subject: [PATCH] WIP essentials 8 --- mention_input.go | 333 ++++++++++++++++++ mention_input_model.go | 142 ++++++++ mention_input_test.go | 149 ++++++++ .../snapshots/darwin/mention_input-dark.png | Bin 0 -> 7082 bytes .../snapshots/darwin/mention_input-light.png | Bin 0 -> 6909 bytes 5 files changed, 624 insertions(+) create mode 100644 mention_input.go create mode 100644 mention_input_model.go create mode 100644 mention_input_test.go create mode 100644 testdata/snapshots/darwin/mention_input-dark.png create mode 100644 testdata/snapshots/darwin/mention_input-light.png 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 0000000000000000000000000000000000000000..1588c52048c107292597cb68244528082d1c4e70 GIT binary patch literal 7082 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�Qf<)2x*g~#<5y&asJO=F@}sg~`DSZX>ByG#;C4wFGh zy66s&EC2A2tEZ8lUzs913!S(lqufPrziJXSa5QH`)F?A`$qv!Cr!z%ICx2RFg7z@t za(?|9FxD)0Fpq~llK^!jd2KM_TBqmSoR|Fo%gO8%6#T$mr2YLiaFkJu6W$>&cbFUo zEui!X3z7;S^7n$Drj8GS>c*P=+pz25l2MB6u7g--3dY>*{E1Dn(><853Sf3#{0&l0 zgxA~;%h3=;`5RdhcCaQ+$nrYADJWPuL1fD#kCoSqk&eNC7|wslDKC*Fqvx|)Bz?CX zt@(qu0+GNX1?K%LaRIR4ZSH3-2wbA)pQHrK?9?g4lySYVr&TG-EzvGAWbYgDyI{#f zb)A~asPy-lL3jtpWaVX1B~zs2PuTcYekwh)$Cb>2g_G>Kt^5&r$kSMwG#7 z!J%F!jAGlYco^MR&ofkOiF9nvFL#gq2w4nt7exq+!T0L}6u?6#*h@^iA{CY2-^rGQ zFDqBfeDadm^H^^`u>WLxA^Pjo(J0E9&kSA9lX-KtYdU~=UN{~=Wd;~zt11c8;!MR+RZo}o7rPY`hxK5F&%m4k5B-R ztDUu-b+aDxr~8j~7fHrh&6z(%;yT|zbvgS4{ zi{I*G|0=d`Awn$ot841OA;cEBe|!)iIDCFM9@5MDa=cNGD?9hX)9A0dmlyv=!uWYa zOi&g#zts7I&GUK&gn%ZAn_;iV_iRPhrlMsR*P z%FuY%DfI<}FIv!IS9_PceYrkV{K?(Bnx04RnEp{;V_W-p|5b2RbKG$^;wSlklKB4z h!_(g+;Fb;tRq=vx(BEm}?hXz|U0Fw|8fg3BKLDm!+l~ML literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/mention_input-light.png b/testdata/snapshots/darwin/mention_input-light.png new file mode 100644 index 0000000000000000000000000000000000000000..cba77f7292026525b6b9e81f8bb5dd33e1df2959 GIT binary patch literal 6909 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 literal 0 HcmV?d00001