WIP essentials 8
This commit is contained in:
5 files changed
+624
No files matched your search
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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…"})
|
||||
})
|
||||
})
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
Reference in new issue
Block a user