Add pixel text editor and built-in messages
This commit is contained in:
11 files changed
+1069
-11
No files matched your search
@@ -47,7 +47,7 @@ Imports point down only. Same-tier imports are forbidden. `internal/archtest` en
|
||||
## MyGo rules
|
||||
|
||||
- Use Bases, containers, `List`, `GridView`, `Outline`, `Router`, `Overlay`, `DialogBase`, `PopoverBase`, `FocusGroup`, `Local`, `HandleInput`, `TextCaret`, `Shortcut`, clipboard. Full list: PRD §2.4.
|
||||
- Never call styled widgets, `Background`, `Border*`, `Radius`, `Shadow`, `Gradient`, `Opacity`, `Stripes`, `Tooltip`, `Menu`, `ContextMenu`, `Transition`, `Animate*`, `Loop`, `c.Toast*`, or painter drawing other than `Fill`. Only `pixel` calls `After`.
|
||||
- Never call styled widgets, `Background`, `Border*`, `Radius`, `Shadow`, `Gradient`, `Opacity`, `Stripes`, `Tooltip`, `Menu`, `ContextMenu`, `Transition`, `Animate*`, `Loop`, `c.Toast*`, or painter calls other than `Fill` and `Clip`. Only `pixel` calls `After`.
|
||||
- A disabled element never paints. Interactive components draw on an enabled shell; the base inside fills it, carries behavior, label and `Disabled`, and turns MyGo's focus ring off.
|
||||
|
||||
## Component rules
|
||||
|
||||
@@ -20,6 +20,7 @@ type Canvas struct {
|
||||
// painter is what Canvas needs of *ui.Painter.
|
||||
type painter interface {
|
||||
Fill(r ui.Rect, c ui.Color, radius float32)
|
||||
Clip(r ui.Rect, radius float32, fn func())
|
||||
After(d time.Duration)
|
||||
Now() time.Time
|
||||
}
|
||||
@@ -45,6 +46,12 @@ func (g Canvas) Rect(x, y, w, h int, s Slot) {
|
||||
g.p.Fill(ui.Rect{X: float32(g.gx+x) * d, Y: float32(g.gy+y) * d, W: float32(w) * d, H: float32(h) * d}, g.k.C(s), 0)
|
||||
}
|
||||
|
||||
// Clip limits fn's drawing to a dot rectangle.
|
||||
func (g Canvas) Clip(x, y, w, h int, fn func()) {
|
||||
d := g.k.dot
|
||||
g.p.Clip(ui.Rect{X: float32(g.gx+x) * d, Y: float32(g.gy+y) * d, W: float32(w) * d, H: float32(h) * d}, 0, fn)
|
||||
}
|
||||
|
||||
func (g Canvas) Dot(x, y int, s Slot) { g.Rect(x, y, 1, 1, s) }
|
||||
func (g Canvas) HLine(x, y, w int, s Slot) { g.Rect(x, y, w, 1, s) }
|
||||
func (g Canvas) VLine(x, y, h int, s Slot) { g.Rect(x, y, 1, h, s) }
|
||||
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
package pixel
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type snapshot struct {
|
||||
text []rune
|
||||
caret, anchor int
|
||||
}
|
||||
|
||||
// editState is one Editor's text, caret and history.
|
||||
type editState struct {
|
||||
text []rune
|
||||
caret, anchor int
|
||||
compose []rune
|
||||
composeCaret int
|
||||
undo, redo []snapshot
|
||||
changed bool
|
||||
submitted bool
|
||||
scroll int
|
||||
dragging bool
|
||||
pressAt time.Time
|
||||
pressX, pressY float32
|
||||
clicks int
|
||||
}
|
||||
|
||||
func (s *editState) reset(v string) {
|
||||
*s = editState{text: []rune(v)}
|
||||
s.caret = len(s.text)
|
||||
s.anchor = s.caret
|
||||
}
|
||||
|
||||
func (s *editState) span() (int, int) { return min(s.caret, s.anchor), max(s.caret, s.anchor) }
|
||||
|
||||
func (s *editState) save() {
|
||||
s.undo = append(s.undo, snapshot{slices.Clone(s.text), s.caret, s.anchor})
|
||||
if len(s.undo) > 100 {
|
||||
s.undo = s.undo[1:]
|
||||
}
|
||||
s.redo = nil
|
||||
}
|
||||
|
||||
// replace puts r over the selection, within maxLen runes.
|
||||
func (s *editState) replace(r []rune, maxLen int) {
|
||||
a, b := s.span()
|
||||
if maxLen > 0 {
|
||||
r = r[:min(len(r), max(0, maxLen-len(s.text)+b-a))]
|
||||
}
|
||||
if a == b && len(r) == 0 {
|
||||
return
|
||||
}
|
||||
s.save()
|
||||
s.text = slices.Concat(s.text[:a], r, s.text[b:])
|
||||
s.caret = a + len(r)
|
||||
s.anchor = s.caret
|
||||
s.changed = true
|
||||
}
|
||||
|
||||
func (s *editState) erase(a, b int) {
|
||||
a, b = max(0, min(a, b)), min(len(s.text), max(a, b))
|
||||
if a == b {
|
||||
return
|
||||
}
|
||||
s.save()
|
||||
s.text = slices.Delete(slices.Clone(s.text), a, b)
|
||||
s.caret, s.anchor = a, a
|
||||
s.changed = true
|
||||
}
|
||||
|
||||
// eraseOr erases the selection, or else a..b.
|
||||
func (s *editState) eraseOr(a, b int) {
|
||||
if x, y := s.span(); x != y {
|
||||
a, b = x, y
|
||||
}
|
||||
s.erase(a, b)
|
||||
}
|
||||
|
||||
func (s *editState) move(to int, extend bool) {
|
||||
s.caret = max(0, min(to, len(s.text)))
|
||||
if !extend {
|
||||
s.anchor = s.caret
|
||||
}
|
||||
}
|
||||
|
||||
// restore pops a snapshot from one history onto the other.
|
||||
func (s *editState) restore(from, to *[]snapshot) {
|
||||
if len(*from) == 0 {
|
||||
return
|
||||
}
|
||||
*to = append(*to, snapshot{slices.Clone(s.text), s.caret, s.anchor})
|
||||
last := (*from)[len(*from)-1]
|
||||
*from = (*from)[:len(*from)-1]
|
||||
s.text, s.caret, s.anchor = last.text, last.caret, last.anchor
|
||||
s.changed = true
|
||||
}
|
||||
|
||||
func (s *editState) selected() string {
|
||||
a, b := s.span()
|
||||
return string(s.text[a:b])
|
||||
}
|
||||
|
||||
// clean drops control runes; one line turns breaks into spaces.
|
||||
func clean(t string, multiline bool) []rune {
|
||||
var out []rune
|
||||
for _, r := range strings.ReplaceAll(t, "\r\n", "\n") {
|
||||
switch {
|
||||
case r == '\n' && multiline:
|
||||
out = append(out, r)
|
||||
case r == '\n' || r == '\t' || r == '\r':
|
||||
out = append(out, ' ')
|
||||
case !unicode.IsControl(r):
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func class(r rune) int {
|
||||
switch {
|
||||
case unicode.IsSpace(r):
|
||||
return 0
|
||||
case wide(r):
|
||||
return 2
|
||||
case unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_':
|
||||
return 1
|
||||
}
|
||||
return 3
|
||||
}
|
||||
|
||||
func wordRight(rs []rune, i int) int {
|
||||
for i < len(rs) && class(rs[i]) == 0 {
|
||||
i++
|
||||
}
|
||||
if i < len(rs) {
|
||||
c := class(rs[i])
|
||||
for i < len(rs) && class(rs[i]) == c {
|
||||
i++
|
||||
}
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func wordLeft(rs []rune, i int) int {
|
||||
for i > 0 && class(rs[i-1]) == 0 {
|
||||
i--
|
||||
}
|
||||
if i > 0 {
|
||||
c := class(rs[i-1])
|
||||
for i > 0 && class(rs[i-1]) == c {
|
||||
i--
|
||||
}
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// wordAt is the run of one class around rune i.
|
||||
func wordAt(rs []rune, i int) (int, int) {
|
||||
if len(rs) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
i = min(i, len(rs)-1)
|
||||
c := class(rs[i])
|
||||
a, b := i, i+1
|
||||
for a > 0 && class(rs[a-1]) == c {
|
||||
a--
|
||||
}
|
||||
for b < len(rs) && class(rs[b]) == c {
|
||||
b++
|
||||
}
|
||||
return a, b
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package pixel
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEditModel(t *testing.T) {
|
||||
var s editState
|
||||
s.reset("hello")
|
||||
if s.caret != 5 || s.anchor != 5 {
|
||||
t.Fatalf("reset caret %d anchor %d", s.caret, s.anchor)
|
||||
}
|
||||
s.anchor = 1
|
||||
s.replace([]rune("EY"), 0)
|
||||
if string(s.text) != "hEY" || s.caret != 3 || !s.changed {
|
||||
t.Fatalf("replace gave %q caret %d", string(s.text), s.caret)
|
||||
}
|
||||
s.replace([]rune("1234"), 5)
|
||||
if string(s.text) != "hEY12" {
|
||||
t.Fatalf("maxLen kept %q", string(s.text))
|
||||
}
|
||||
s.erase(9, -3)
|
||||
if string(s.text) != "" || s.caret != 0 {
|
||||
t.Fatalf("erase clamps: %q caret %d", string(s.text), s.caret)
|
||||
}
|
||||
s.restore(&s.undo, &s.redo)
|
||||
s.restore(&s.undo, &s.redo)
|
||||
if string(s.text) != "hEY" || len(s.redo) != 2 {
|
||||
t.Fatalf("two undos gave %q, redo %d", string(s.text), len(s.redo))
|
||||
}
|
||||
s.restore(&s.redo, &s.undo)
|
||||
if string(s.text) != "hEY12" {
|
||||
t.Fatalf("redo gave %q", string(s.text))
|
||||
}
|
||||
for range 150 {
|
||||
s.replace([]rune("x"), 0)
|
||||
}
|
||||
if len(s.undo) != 100 || len(s.redo) != 0 {
|
||||
t.Fatalf("history %d undo %d redo, want 100 and 0", len(s.undo), len(s.redo))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClean(t *testing.T) {
|
||||
if got := string(clean("a\tb\r\nc\x07d", false)); got != "a b cd" {
|
||||
t.Fatalf("one line: %q", got)
|
||||
}
|
||||
if got := string(clean("a\r\nb\nc", true)); got != "a\nb\nc" {
|
||||
t.Fatalf("multiline: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWords(t *testing.T) {
|
||||
rs := []rune("foo bar, 像素x")
|
||||
if wordRight(rs, 0) != 3 || wordRight(rs, 3) != 8 || wordRight(rs, 8) != 9 || wordRight(rs, 10) != 12 {
|
||||
t.Fatalf("wordRight %d %d %d %d", wordRight(rs, 0), wordRight(rs, 3), wordRight(rs, 8), wordRight(rs, 10))
|
||||
}
|
||||
if wordLeft(rs, len(rs)) != 12 || wordLeft(rs, 8) != 5 || wordLeft(rs, 5) != 0 {
|
||||
t.Fatalf("wordLeft %d %d %d", wordLeft(rs, len(rs)), wordLeft(rs, 8), wordLeft(rs, 5))
|
||||
}
|
||||
if a, b := wordAt(rs, 6); a != 5 || b != 8 {
|
||||
t.Fatalf("wordAt 6 = %d..%d", a, b)
|
||||
}
|
||||
if a, b := wordAt(rs, 11); a != 10 || b != 12 {
|
||||
t.Fatalf("wordAt in CJK = %d..%d", a, b)
|
||||
}
|
||||
if a, b := wordAt(nil, 0); a != 0 || b != 0 {
|
||||
t.Fatal("wordAt on empty text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditLayoutAndCaret(t *testing.T) {
|
||||
rs := []rune("ab cd\nef")
|
||||
lines := editLayout(rs, Measure("ab "), true)
|
||||
want := []editLine{{0, 2}, {3, 5}, {6, 8}}
|
||||
if !slices.Equal(lines, want) {
|
||||
t.Fatalf("layout %v, want %v", lines, want)
|
||||
}
|
||||
if x, li := caretAt(lines, rs, 3); x != 0 || li != 1 {
|
||||
t.Fatalf("caret at wrap is %d on line %d", x, li)
|
||||
}
|
||||
if x, li := caretAt(lines, rs, 5); x != Measure("cd") || li != 1 {
|
||||
t.Fatalf("caret before newline is %d on line %d", x, li)
|
||||
}
|
||||
if lineEnd(lines, 0) != 2 || lineEnd(lines, 1) != 5 || lineEnd(lines, 2) != 8 {
|
||||
t.Fatalf("lineEnd %d %d %d", lineEnd(lines, 0), lineEnd(lines, 1), lineEnd(lines, 2))
|
||||
}
|
||||
if hitLine(lines, rs, 2, 0) != 6 || hitLine(lines, rs, 2, 999) != 8 || hitLine(lines, rs, 0, Measure("a")) != 1 {
|
||||
t.Fatal("hitLine misses rune boundaries")
|
||||
}
|
||||
gap := []rune("ab cd")
|
||||
gl := editLayout(gap, Measure("ab "), true)
|
||||
if x, li := caretAt(gl, gap, 3); li != 0 || x != Measure("ab") {
|
||||
t.Fatalf("caret in the break gap at %d on line %d", x, li)
|
||||
}
|
||||
if one := editLayout(rs, 1, false); !slices.Equal(one, []editLine{{0, 8}}) {
|
||||
t.Fatalf("single line layout %v", one)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeepVisible(t *testing.T) {
|
||||
var s editState
|
||||
s.keepVisible(100, 120, 40)
|
||||
if s.scroll != 61 {
|
||||
t.Fatalf("scroll %d, want 61", s.scroll)
|
||||
}
|
||||
s.keepVisible(10, 120, 40)
|
||||
if s.scroll != 10 {
|
||||
t.Fatalf("scroll back %d, want 10", s.scroll)
|
||||
}
|
||||
s.keepVisible(10, 30, 40)
|
||||
if s.scroll != 0 {
|
||||
t.Fatalf("short text scroll %d, want 0", s.scroll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskedComposition(t *testing.T) {
|
||||
var s editState
|
||||
s.reset("ab")
|
||||
s.compose = []rune("ni")
|
||||
if got := string(s.view('*', true)); got != "****" {
|
||||
t.Fatalf("masked view with composition = %q", got)
|
||||
}
|
||||
if got := string(s.view(0, true)); got != "abni" {
|
||||
t.Fatalf("plain view with composition = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultilineLeavesRoomForCaret(t *testing.T) {
|
||||
rs := []rune("ab")
|
||||
if lines := editLayout(rs, Measure("ab"), true); len(lines) != 2 {
|
||||
t.Fatalf("text as wide as the field: %v, want a wrap before the caret column", lines)
|
||||
}
|
||||
}
|
||||
+409
@@ -0,0 +1,409 @@
|
||||
package pixel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
type EditorOptions struct {
|
||||
Multiline bool
|
||||
MaxLength int
|
||||
// Mask hides each rune behind Mask and turns copy off.
|
||||
Mask rune
|
||||
Placeholder string
|
||||
Label string
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type Edit struct {
|
||||
Element *ui.Element
|
||||
Changed, Submitted, Focused bool
|
||||
}
|
||||
|
||||
type editKey struct{}
|
||||
|
||||
type editLine struct{ start, end int }
|
||||
|
||||
type editEnv struct {
|
||||
multi bool
|
||||
maxLen int
|
||||
mask rune
|
||||
width int
|
||||
dot float32
|
||||
read func() string
|
||||
write func(string)
|
||||
}
|
||||
|
||||
// Editor edits *value in the pixel font.
|
||||
func Editor(c *ui.Context, value *string, o EditorOptions) Edit {
|
||||
k := K(c)
|
||||
if o.MaxLength < 0 {
|
||||
panic(fmt.Sprintf("pixel: MaxLength %d", o.MaxLength))
|
||||
}
|
||||
shell, base, _ := Shell(c, func() *ui.Element {
|
||||
return ui.Box(c).Focusable().Role(ui.RoleTextField).Label(o.Label).Disabled(o.Disabled)
|
||||
})
|
||||
s := ui.Local(base, editKey{}, func() editState { return editState{} })
|
||||
if string(s.text) != *value {
|
||||
s.reset(*value)
|
||||
}
|
||||
if o.Mask == 0 {
|
||||
base.Description(*value)
|
||||
}
|
||||
width, check := WrapWidth(c, shell)
|
||||
env := editEnv{multi: o.Multiline, maxLen: o.MaxLength, mask: o.Mask, width: width, dot: k.dot, read: c.ReadClipboard, write: c.WriteClipboard}
|
||||
shown := s.view(o.Mask, true)
|
||||
caret := s.caret + s.composeCaret
|
||||
lines := editLayout(shown, width, o.Multiline)
|
||||
cx, cl := caretAt(lines, shown, caret)
|
||||
if o.Multiline {
|
||||
shell.Height(k.D(len(lines) * LineHeight))
|
||||
} else {
|
||||
shell.Height(k.D(LineHeight))
|
||||
s.keepVisible(cx, Measure(string(shown)), width)
|
||||
}
|
||||
scroll, sa, sb := s.scroll, s.caret, s.anchor
|
||||
sa, sb = min(sa, sb), max(sa, sb)
|
||||
ca, cb := s.caret, s.caret+len(s.compose)
|
||||
composing := cb > ca
|
||||
base.TextCaret(ui.Rect{X: k.D(cx - scroll), Y: k.D(cl * LineHeight), W: k.dot, H: k.D(LineHeight)})
|
||||
base.HandleInput(func(ev ui.InputEvent) bool {
|
||||
took := s.handle(ev, env)
|
||||
if s.changed {
|
||||
*value = string(s.text)
|
||||
}
|
||||
return took
|
||||
})
|
||||
res := Edit{Element: shell, Changed: s.changed, Submitted: s.submitted, Focused: base.Focused()}
|
||||
if s.submitted {
|
||||
slog.Debug("8bit: editor submit", "label", o.Label)
|
||||
}
|
||||
s.changed, s.submitted = false, false
|
||||
shell.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
check(r)
|
||||
g := k.Canvas(p, r)
|
||||
g.Clip(0, 0, g.W, g.H, func() {
|
||||
if len(shown) == 0 {
|
||||
g.Text(0, 0, o.Placeholder, Faint, 1)
|
||||
}
|
||||
ink := Ink
|
||||
if o.Disabled {
|
||||
ink = Faint
|
||||
}
|
||||
for li, l := range lines {
|
||||
y, x0 := li*LineHeight, -scroll
|
||||
g.Text(x0, y, string(shown[l.start:l.end]), ink, 1)
|
||||
if a, b := max(sa, l.start), min(sb, l.end); a < b && !composing {
|
||||
xa := x0 + Measure(string(shown[l.start:a]))
|
||||
g.Rect(xa, y, Measure(string(shown[a:b])), LineHeight, Accent)
|
||||
g.Text(xa, y, string(shown[a:b]), AccentInk, 1)
|
||||
}
|
||||
if a, b := max(ca, l.start), min(cb, l.end); a < b {
|
||||
xa := x0 + Measure(string(shown[l.start:a]))
|
||||
for i := range Measure(string(shown[a:b])) {
|
||||
if i%2 == 0 {
|
||||
g.Dot(xa+i, y+LineHeight-1, Accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if res.Focused && !o.Disabled && g.Step(2)%2 == 0 {
|
||||
g.VLine(cx-scroll, cl*LineHeight+1, LineHeight-2, Ink)
|
||||
}
|
||||
})
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
// view is the shown runes: masked, with any composition.
|
||||
func (s *editState) view(mask rune, composing bool) []rune {
|
||||
out := make([]rune, 0, len(s.text)+len(s.compose))
|
||||
for _, r := range s.text {
|
||||
if mask != 0 && r != '\n' {
|
||||
r = mask
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
if composing {
|
||||
c := s.compose
|
||||
if mask != 0 {
|
||||
c = slices.Repeat([]rune{mask}, len(c))
|
||||
}
|
||||
out = slices.Insert(out, s.caret, c...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *editState) keepVisible(cx, total, width int) {
|
||||
if width <= 0 {
|
||||
s.scroll = 0
|
||||
return
|
||||
}
|
||||
if cx-s.scroll > width-1 {
|
||||
s.scroll = cx - width + 1
|
||||
}
|
||||
s.scroll = max(0, min(s.scroll, cx, total-width+1))
|
||||
}
|
||||
|
||||
// editLayout wraps a dot short, leaving the caret its column.
|
||||
func editLayout(rs []rune, width int, multi bool) []editLine {
|
||||
if !multi {
|
||||
return []editLine{{0, len(rs)}}
|
||||
}
|
||||
width = max(0, width-1)
|
||||
var out []editLine
|
||||
start := 0
|
||||
for i := 0; i <= len(rs); i++ {
|
||||
if i == len(rs) || rs[i] == '\n' {
|
||||
for _, r := range wrapRanges(rs[start:i], width) {
|
||||
out = append(out, editLine{start + r[0], start + r[1]})
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// caretAt is the caret's x in dots and its line.
|
||||
func caretAt(lines []editLine, rs []rune, idx int) (int, int) {
|
||||
li := 0
|
||||
for i, l := range lines {
|
||||
if l.start <= idx {
|
||||
li = i
|
||||
}
|
||||
}
|
||||
l := lines[li]
|
||||
return Measure(string(rs[l.start:min(idx, l.end)])), li
|
||||
}
|
||||
|
||||
// hitLine is the rune boundary nearest x on line li.
|
||||
func hitLine(lines []editLine, rs []rune, li, x int) int {
|
||||
l := lines[li]
|
||||
pos := 0
|
||||
for i := l.start; i < l.end; i++ {
|
||||
adv := glyphOf(rs[i]).adv
|
||||
if x < pos+adv/2 {
|
||||
return i
|
||||
}
|
||||
pos += adv
|
||||
}
|
||||
return lineEnd(lines, li)
|
||||
}
|
||||
|
||||
// lineEnd keeps a wrapped line's caret on that line.
|
||||
func lineEnd(lines []editLine, li int) int {
|
||||
l := lines[li]
|
||||
if li+1 < len(lines) && lines[li+1].start == l.end && l.end > l.start {
|
||||
return l.end - 1
|
||||
}
|
||||
return l.end
|
||||
}
|
||||
|
||||
func (s *editState) hit(e editEnv, x, y float32) int {
|
||||
rs := s.view(e.mask, false)
|
||||
lines := editLayout(rs, e.width, e.multi)
|
||||
li := max(0, min(int(y/e.dot)/LineHeight, len(lines)-1))
|
||||
return hitLine(lines, rs, li, int(x/e.dot)+s.scroll)
|
||||
}
|
||||
|
||||
func (s *editState) handle(ev ui.InputEvent, e editEnv) bool {
|
||||
switch ev.Kind {
|
||||
case ui.InputText:
|
||||
s.compose, s.composeCaret = nil, 0
|
||||
s.replace(clean(ev.Text, e.multi), e.maxLen)
|
||||
return true
|
||||
case ui.InputCompose:
|
||||
if ev.Text == "" {
|
||||
s.compose, s.composeCaret = nil, 0
|
||||
return true
|
||||
}
|
||||
if a, b := s.span(); len(s.compose) == 0 && a != b {
|
||||
s.erase(a, b)
|
||||
}
|
||||
s.compose = []rune(ev.Text)
|
||||
s.composeCaret = max(0, min(ev.Caret, len(s.compose)))
|
||||
return true
|
||||
case ui.InputCommand:
|
||||
return s.command(ev.Text, e)
|
||||
case ui.InputKeyDown:
|
||||
return s.key(ev, e)
|
||||
case ui.InputPointerDown:
|
||||
if ev.Button != 0 {
|
||||
return false
|
||||
}
|
||||
s.compose, s.composeCaret = nil, 0
|
||||
s.press(ev, e)
|
||||
return true
|
||||
case ui.InputPointerMove:
|
||||
if !s.dragging || ev.Button != 0 {
|
||||
return false
|
||||
}
|
||||
s.move(s.hit(e, ev.X, ev.Y), true)
|
||||
return true
|
||||
case ui.InputPointerUp:
|
||||
was := s.dragging
|
||||
s.dragging = false
|
||||
return was
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *editState) press(ev ui.InputEvent, e editEnv) {
|
||||
now := time.Now()
|
||||
if now.Sub(s.pressAt) < 500*time.Millisecond && abs32(ev.X-s.pressX) < 5 && abs32(ev.Y-s.pressY) < 5 {
|
||||
s.clicks++
|
||||
} else {
|
||||
s.clicks = 1
|
||||
}
|
||||
s.pressAt, s.pressX, s.pressY = now, ev.X, ev.Y
|
||||
i := s.hit(e, ev.X, ev.Y)
|
||||
switch clicks := max(ev.Clicks, s.clicks); {
|
||||
case clicks == 2:
|
||||
s.anchor, s.caret = wordAt(s.text, i)
|
||||
case clicks >= 3:
|
||||
s.anchor, s.caret = 0, len(s.text)
|
||||
default:
|
||||
s.move(i, ev.Mods&ui.Shift != 0)
|
||||
s.dragging = true
|
||||
}
|
||||
}
|
||||
|
||||
func (s *editState) command(name string, e editEnv) bool {
|
||||
switch name {
|
||||
case "copy":
|
||||
if a, b := s.span(); e.mask == 0 && a != b {
|
||||
e.write(s.selected())
|
||||
}
|
||||
case "cut":
|
||||
if a, b := s.span(); e.mask == 0 && a != b {
|
||||
e.write(s.selected())
|
||||
s.erase(a, b)
|
||||
}
|
||||
case "paste":
|
||||
s.replace(clean(e.read(), e.multi), e.maxLen)
|
||||
case "selectAll":
|
||||
s.anchor, s.caret = 0, len(s.text)
|
||||
case "undo":
|
||||
s.restore(&s.undo, &s.redo)
|
||||
case "redo":
|
||||
s.restore(&s.redo, &s.undo)
|
||||
case "delete":
|
||||
a, b := s.span()
|
||||
s.erase(a, b)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func abs32(v float32) float32 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *editState) key(ev ui.InputEvent, e editEnv) bool {
|
||||
m, shift := ev.Mods&^ui.Shift, ev.Mods&ui.Shift != 0
|
||||
if m == ui.Cmd {
|
||||
switch ev.Key {
|
||||
case ui.KeyA:
|
||||
return s.command("selectAll", e)
|
||||
case ui.KeyC:
|
||||
return s.command("copy", e)
|
||||
case ui.KeyX:
|
||||
return s.command("cut", e)
|
||||
case ui.KeyV:
|
||||
return s.command("paste", e)
|
||||
case ui.KeyZ:
|
||||
if shift {
|
||||
return s.command("redo", e)
|
||||
}
|
||||
return s.command("undo", e)
|
||||
case ui.KeyY:
|
||||
return s.command("redo", e)
|
||||
case ui.KeyEnter:
|
||||
s.submitted = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
word, line := m&(ui.Alt|ui.Ctrl) != 0 && m&ui.Super == 0, m == ui.Super
|
||||
rs := s.view(e.mask, false)
|
||||
lines := editLayout(rs, e.width, e.multi)
|
||||
x, li := caretAt(lines, rs, s.caret)
|
||||
a, b := s.span()
|
||||
switch ev.Key {
|
||||
case ui.KeyLeft:
|
||||
switch {
|
||||
case a != b && !shift && m == 0:
|
||||
s.move(a, false)
|
||||
case word:
|
||||
s.move(wordLeft(s.text, s.caret), shift)
|
||||
case line:
|
||||
s.move(lines[li].start, shift)
|
||||
default:
|
||||
s.move(s.caret-1, shift)
|
||||
}
|
||||
case ui.KeyRight:
|
||||
switch {
|
||||
case a != b && !shift && m == 0:
|
||||
s.move(b, false)
|
||||
case word:
|
||||
s.move(wordRight(s.text, s.caret), shift)
|
||||
case line:
|
||||
s.move(lineEnd(lines, li), shift)
|
||||
default:
|
||||
s.move(s.caret+1, shift)
|
||||
}
|
||||
case ui.KeyUp, ui.KeyDown:
|
||||
to, end := li+1, len(s.text)
|
||||
if ev.Key == ui.KeyUp {
|
||||
to, end = li-1, 0
|
||||
}
|
||||
if !e.multi || line || to < 0 || to >= len(lines) {
|
||||
s.move(end, shift)
|
||||
} else {
|
||||
s.move(hitLine(lines, rs, to, x), shift)
|
||||
}
|
||||
case ui.KeyHome:
|
||||
to := lines[li].start
|
||||
if m&ui.Ctrl != 0 {
|
||||
to = 0
|
||||
}
|
||||
s.move(to, shift)
|
||||
case ui.KeyEnd:
|
||||
to := lineEnd(lines, li)
|
||||
if m&ui.Ctrl != 0 {
|
||||
to = len(s.text)
|
||||
}
|
||||
s.move(to, shift)
|
||||
case ui.KeyBackspace:
|
||||
switch {
|
||||
case word:
|
||||
s.eraseOr(wordLeft(s.text, s.caret), s.caret)
|
||||
case line:
|
||||
s.eraseOr(lines[li].start, s.caret)
|
||||
default:
|
||||
s.eraseOr(s.caret-1, s.caret)
|
||||
}
|
||||
case ui.KeyDelete:
|
||||
if word {
|
||||
s.eraseOr(s.caret, wordRight(s.text, s.caret))
|
||||
} else {
|
||||
s.eraseOr(s.caret, s.caret+1)
|
||||
}
|
||||
case ui.KeyEnter:
|
||||
if !e.multi || m != 0 {
|
||||
s.submitted = true
|
||||
return true
|
||||
}
|
||||
s.replace([]rune{'\n'}, e.maxLen)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package pixel_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type editHarness struct {
|
||||
t *testing.T
|
||||
tt *ui.Tester
|
||||
value string
|
||||
o pixel.EditorOptions
|
||||
last pixel.Edit
|
||||
changes, submits int
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func newEdit(t *testing.T, v string, width float32, o pixel.EditorOptions) *editHarness {
|
||||
t.Helper()
|
||||
h := &editHarness{t: t, value: v, o: o}
|
||||
if h.o.Label == "" {
|
||||
h.o.Label = "field"
|
||||
}
|
||||
h.tt = ui.NewTester(func(c *ui.Context) {
|
||||
pixel.Use(c, pixel.Settings{Palette: pixel.SummerDay, Dot: 2, Now: func() time.Time {
|
||||
if h.at.IsZero() {
|
||||
return time.Now()
|
||||
}
|
||||
return h.at
|
||||
}})
|
||||
ui.Column(c).Width(width).Padding(4).Children(func() {
|
||||
h.last = pixel.Editor(c, &h.value, h.o)
|
||||
if h.last.Changed {
|
||||
h.changes++
|
||||
}
|
||||
if h.last.Submitted {
|
||||
h.submits++
|
||||
}
|
||||
})
|
||||
}, 300, 200)
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *editHarness) focus() {
|
||||
h.t.Helper()
|
||||
if err := h.tt.Click(h.o.Label); err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *editHarness) want(v string) {
|
||||
h.t.Helper()
|
||||
if h.value != v {
|
||||
h.t.Fatalf("value %q, want %q", h.value, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditorTypingAndKeys(t *testing.T) {
|
||||
h := newEdit(t, "", 200, pixel.EditorOptions{})
|
||||
h.focus()
|
||||
h.tt.Type("hello")
|
||||
h.want("hello")
|
||||
if h.changes == 0 {
|
||||
t.Fatal("typing reported no change")
|
||||
}
|
||||
h.tt.Key(0, ui.KeyLeft)
|
||||
h.tt.Key(0, ui.KeyLeft)
|
||||
h.tt.Type("X")
|
||||
h.want("helXlo")
|
||||
h.tt.Key(0, ui.KeyBackspace)
|
||||
h.tt.Key(0, ui.KeyDelete)
|
||||
h.want("helo")
|
||||
h.tt.Key(0, ui.KeyHome)
|
||||
h.tt.Type("<")
|
||||
h.tt.Key(0, ui.KeyEnd)
|
||||
h.tt.Type(">")
|
||||
h.want("<helo>")
|
||||
h.tt.Key(ui.Shift, ui.KeyLeft)
|
||||
h.tt.Key(ui.Shift, ui.KeyLeft)
|
||||
h.tt.Type("!")
|
||||
h.want("<hel!")
|
||||
h.tt.Key(0, ui.KeyEnter)
|
||||
if h.submits != 1 {
|
||||
t.Fatalf("Enter submitted %d times", h.submits)
|
||||
}
|
||||
h.want("<hel!")
|
||||
}
|
||||
|
||||
func TestEditorWordsClipboardUndo(t *testing.T) {
|
||||
h := newEdit(t, "foo bar baz", 200, pixel.EditorOptions{})
|
||||
h.focus()
|
||||
h.tt.Key(0, ui.KeyEnd)
|
||||
h.tt.Key(ui.Alt, ui.KeyLeft)
|
||||
h.tt.Type("X")
|
||||
h.want("foo bar Xbaz")
|
||||
h.tt.Key(ui.Cmd, ui.KeyZ)
|
||||
h.want("foo bar baz")
|
||||
h.tt.Key(ui.Cmd|ui.Shift, ui.KeyZ)
|
||||
h.want("foo bar Xbaz")
|
||||
h.tt.Key(ui.Alt, ui.KeyBackspace)
|
||||
h.want("foo bar baz")
|
||||
h.tt.Key(ui.Cmd, ui.KeyA)
|
||||
h.tt.Key(ui.Cmd, ui.KeyC)
|
||||
if h.tt.Clipboard() != "foo bar baz" {
|
||||
t.Fatalf("clipboard %q", h.tt.Clipboard())
|
||||
}
|
||||
h.tt.Key(0, ui.KeyRight)
|
||||
h.tt.Key(ui.Cmd, ui.KeyV)
|
||||
h.want("foo bar bazfoo bar baz")
|
||||
h.tt.Command("selectAll")
|
||||
h.tt.Command("cut")
|
||||
h.want("")
|
||||
if h.tt.Clipboard() != "foo bar bazfoo bar baz" {
|
||||
t.Fatalf("cut put %q on the clipboard", h.tt.Clipboard())
|
||||
}
|
||||
h.tt.SetClipboard("a\tb\nc")
|
||||
h.tt.Command("paste")
|
||||
h.want("a b c")
|
||||
}
|
||||
|
||||
func TestEditorMultiline(t *testing.T) {
|
||||
h := newEdit(t, "ab\ncd", 200, pixel.EditorOptions{Multiline: true})
|
||||
h.focus()
|
||||
h.tt.Key(0, ui.KeyUp)
|
||||
h.tt.Type("X")
|
||||
h.want("abX\ncd")
|
||||
h.tt.Key(0, ui.KeyEnter)
|
||||
h.want("abX\n\ncd")
|
||||
h.tt.Key(ui.Cmd, ui.KeyEnter)
|
||||
if h.submits != 1 {
|
||||
t.Fatalf("Cmd+Enter submitted %d times", h.submits)
|
||||
}
|
||||
if r, _ := h.tt.Find("field"); r.H != 3*16*2 {
|
||||
t.Fatalf("three lines are %v high, want 96", r.H)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditorLimitsAndMask(t *testing.T) {
|
||||
h := newEdit(t, "", 200, pixel.EditorOptions{MaxLength: 5})
|
||||
h.focus()
|
||||
h.tt.Type("abcdefg")
|
||||
h.want("abcde")
|
||||
m := newEdit(t, "secret", 200, pixel.EditorOptions{Mask: '★'})
|
||||
m.focus()
|
||||
m.tt.Key(ui.Cmd, ui.KeyA)
|
||||
m.tt.Key(ui.Cmd, ui.KeyC)
|
||||
m.tt.Key(ui.Cmd, ui.KeyX)
|
||||
if m.tt.Clipboard() != "" {
|
||||
t.Fatalf("masked text reached the clipboard: %q", m.tt.Clipboard())
|
||||
}
|
||||
m.want("secret")
|
||||
}
|
||||
|
||||
func TestEditorComposition(t *testing.T) {
|
||||
h := newEdit(t, "ab", 200, pixel.EditorOptions{})
|
||||
h.focus()
|
||||
h.tt.Compose("ni", 2)
|
||||
h.want("ab")
|
||||
if r, ok := h.tt.TextCaret(); !ok || r.H != 32 {
|
||||
t.Fatalf("caret %v active %v", r, ok)
|
||||
}
|
||||
h.tt.Compose("", 0)
|
||||
h.tt.Type("你")
|
||||
h.want("ab你")
|
||||
h.tt.Key(ui.Cmd, ui.KeyA)
|
||||
h.tt.Compose("x", 1)
|
||||
h.want("")
|
||||
}
|
||||
|
||||
func TestEditorPointer(t *testing.T) {
|
||||
h := newEdit(t, "hello world", 300, pixel.EditorOptions{})
|
||||
r, _ := h.tt.Find("field")
|
||||
x := r.X + float32(pixel.Measure("hello ")+4)*2
|
||||
for range 2 {
|
||||
h.tt.Press(x, r.Y+8)
|
||||
h.tt.Release(x, r.Y+8)
|
||||
}
|
||||
h.tt.Type("there")
|
||||
h.want("hello there")
|
||||
h.tt.Press(r.X+1, r.Y+8)
|
||||
h.tt.Move(r.X+r.W-1, r.Y+8)
|
||||
h.tt.Release(r.X+r.W-1, r.Y+8)
|
||||
h.tt.Type("Z")
|
||||
h.want("Z")
|
||||
}
|
||||
|
||||
func TestEditorExternalChangeAndDisabled(t *testing.T) {
|
||||
h := newEdit(t, "start", 200, pixel.EditorOptions{})
|
||||
h.focus()
|
||||
h.value = "reset"
|
||||
h.tt.Frame()
|
||||
h.tt.Type("!")
|
||||
h.want("reset!")
|
||||
d := newEdit(t, "frozen", 200, pixel.EditorOptions{Disabled: true})
|
||||
d.focus()
|
||||
d.tt.Type("x")
|
||||
d.tt.Key(0, ui.KeyBackspace)
|
||||
d.want("frozen")
|
||||
if d.tt.Focused("field") {
|
||||
t.Fatal("disabled editor took the focus")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditorKeepsCaretInView(t *testing.T) {
|
||||
h := newEdit(t, "", 48, pixel.EditorOptions{})
|
||||
h.focus()
|
||||
h.tt.Type("a long line that cannot fit in a narrow field")
|
||||
field, _ := h.tt.Find("field")
|
||||
caret, _ := h.tt.TextCaret()
|
||||
if caret.X < field.X || caret.X+caret.W > field.X+field.W {
|
||||
t.Fatalf("caret %v outside field %v", caret, field)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditorCaretBlinks(t *testing.T) {
|
||||
h := newEdit(t, "pixel", 200, pixel.EditorOptions{})
|
||||
h.at = time.Unix(1000, 0)
|
||||
h.focus()
|
||||
h.tt.Key(0, ui.KeyEnd)
|
||||
r, _ := h.tt.Find("field")
|
||||
x, y := int(r.X/2)+pixel.Measure("pixel"), int(r.Y/2)
|
||||
caret := func() int { return dots{t: t, img: h.tt.Image(), pal: pixel.SummerDay}.count(x, y+1, 1, 14, pixel.Ink) }
|
||||
if caret() != 14 {
|
||||
t.Fatal("caret not drawn after the text")
|
||||
}
|
||||
h.at = time.Unix(1000, 500_000_000)
|
||||
h.tt.Frame()
|
||||
if caret() != 0 {
|
||||
t.Fatal("caret did not blink off")
|
||||
}
|
||||
h.tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
if caret() != 14 {
|
||||
t.Fatal("caret hidden under Reduce Motion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultilineCaretInsideField(t *testing.T) {
|
||||
h := newEdit(t, "ab", float32(pixel.Measure("ab")*2+8), pixel.EditorOptions{Multiline: true})
|
||||
h.focus()
|
||||
h.tt.Key(ui.Ctrl, ui.KeyEnd)
|
||||
field, _ := h.tt.Find("field")
|
||||
caret, _ := h.tt.TextCaret()
|
||||
if caret.X+caret.W > field.X+field.W || caret.Y+caret.H > field.Y+field.H {
|
||||
t.Fatalf("caret %v outside field %v", caret, field)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditorDrawsCaretAndSelection(t *testing.T) {
|
||||
h := newEdit(t, "pixel", 200, pixel.EditorOptions{})
|
||||
h.focus()
|
||||
d := dots{t: t, img: h.tt.Image(), pal: pixel.SummerDay}
|
||||
r, _ := h.tt.Find("field")
|
||||
x0, y0, w := int(r.X/2), int(r.Y/2), int(r.W/2)
|
||||
if d.count(x0, y0, w, 16, pixel.Ink) == 0 {
|
||||
t.Fatal("no text drawn")
|
||||
}
|
||||
if d.count(x0, y0, w, 16, pixel.Accent) != 0 {
|
||||
t.Fatal("selection drawn without a selection")
|
||||
}
|
||||
h.tt.Key(ui.Cmd, ui.KeyA)
|
||||
d = dots{t: t, img: h.tt.Image(), pal: pixel.SummerDay}
|
||||
if d.count(x0, y0, pixel.Measure("pixel"), 16, pixel.Accent) == 0 || d.count(x0, y0, w, 16, pixel.AccentInk) == 0 {
|
||||
t.Fatal("selection not drawn in Accent with AccentInk text")
|
||||
}
|
||||
}
|
||||
@@ -45,9 +45,10 @@ func TestTextLinesEllipsis(t *testing.T) {
|
||||
|
||||
type fakePainter struct{ fills int }
|
||||
|
||||
func (f *fakePainter) Fill(ui.Rect, ui.Color, float32) { f.fills++ }
|
||||
func (f *fakePainter) After(time.Duration) {}
|
||||
func (f *fakePainter) Now() time.Time { return time.Unix(0, 0) }
|
||||
func (f *fakePainter) Fill(ui.Rect, ui.Color, float32) { f.fills++ }
|
||||
func (f *fakePainter) Clip(_ ui.Rect, _ float32, fn func()) { fn() }
|
||||
func (f *fakePainter) After(time.Duration) {}
|
||||
func (f *fakePainter) Now() time.Time { return time.Unix(0, 0) }
|
||||
|
||||
func TestDrawingMergesRuns(t *testing.T) {
|
||||
sp := MustSprite("aab\n.bb")
|
||||
|
||||
+15
-7
@@ -92,16 +92,20 @@ func Measure(s string) int {
|
||||
func Lines(s string, width int) []string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(s, "\n") {
|
||||
out = append(out, wrap([]rune(p), width)...)
|
||||
rs := []rune(p)
|
||||
for _, r := range wrapRanges(rs, width) {
|
||||
out = append(out, string(rs[r[0]:r[1]]))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func wrap(rs []rune, width int) []string {
|
||||
// wrapRanges splits a paragraph; break lines drop trailing spaces.
|
||||
func wrapRanges(rs []rune, width int) [][2]int {
|
||||
if width <= 0 || Measure(string(rs)) <= width {
|
||||
return []string{string(rs)}
|
||||
return [][2]int{{0, len(rs)}}
|
||||
}
|
||||
var lines []string
|
||||
var out [][2]int
|
||||
for start := 0; start < len(rs); {
|
||||
w, brk, i := 0, -1, start
|
||||
for ; i < len(rs); i++ {
|
||||
@@ -114,16 +118,20 @@ func wrap(rs []rune, width int) []string {
|
||||
}
|
||||
}
|
||||
if i == len(rs) {
|
||||
lines = append(lines, string(rs[start:]))
|
||||
out = append(out, [2]int{start, len(rs)})
|
||||
break
|
||||
}
|
||||
if brk <= start {
|
||||
brk = hardBreak(rs, start, i)
|
||||
}
|
||||
lines = append(lines, strings.TrimRight(string(rs[start:brk]), " "))
|
||||
end := brk
|
||||
for end > start && rs[end-1] == ' ' {
|
||||
end--
|
||||
}
|
||||
out = append(out, [2]int{start, end})
|
||||
start = skip(rs, brk)
|
||||
}
|
||||
return lines
|
||||
return out
|
||||
}
|
||||
|
||||
// breakable reports whether a line may break before rs[i].
|
||||
|
||||
@@ -113,6 +113,8 @@ func TestLines(t *testing.T) {
|
||||
{"A\nB", 100, []string{"A", "B"}},
|
||||
{"a long line", 0, []string{"a long line"}},
|
||||
{"x y", Measure("x"), []string{"x", "y"}},
|
||||
{"a ", 1, []string{"a"}},
|
||||
{"ab cd ", Measure("ab"), []string{"ab", "cd"}},
|
||||
{"你 ,好", 24, []string{"你 ,", "好"}},
|
||||
{"「 像素", 18, []string{"「 像", "素"}},
|
||||
{"[你好]世界", 36, []string{"[你", "好]世", "界"}},
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package pixel
|
||||
|
||||
import "fmt"
|
||||
|
||||
var messages = map[Locale]map[string]string{
|
||||
En: {
|
||||
"ok": "OK", "cancel": "CANCEL", "yes": "YES", "no": "NO", "close": "CLOSE",
|
||||
"continue": "CONTINUE?", "game_over": "GAME OVER", "loading": "NOW LOADING",
|
||||
"search": "SEARCH", "empty": "NO ITEMS",
|
||||
},
|
||||
Zh: {
|
||||
"ok": "确定", "cancel": "取消", "yes": "是", "no": "否", "close": "关闭",
|
||||
"continue": "继续?", "game_over": "游戏结束", "loading": "读取中",
|
||||
"search": "搜索", "empty": "空空如也",
|
||||
},
|
||||
}
|
||||
|
||||
// Msg is built-in copy in the frame's locale.
|
||||
func (k Kit) Msg(key string) string {
|
||||
s, ok := messages[k.locale][key]
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("pixel: no message %q for locale %d", key, k.locale))
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package pixel
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMessagesCoverBothLocales(t *testing.T) {
|
||||
for key := range messages[En] {
|
||||
if _, ok := messages[Zh][key]; !ok {
|
||||
t.Errorf("message %q missing in Chinese", key)
|
||||
}
|
||||
}
|
||||
for key := range messages[Zh] {
|
||||
if _, ok := messages[En][key]; !ok {
|
||||
t.Errorf("message %q missing in English", key)
|
||||
}
|
||||
}
|
||||
if got := (Kit{locale: Zh}).Msg("game_over"); got != "游戏结束" {
|
||||
t.Fatalf("Zh game_over = %q", got)
|
||||
}
|
||||
if got := (Kit{locale: En}).Msg("continue"); got != "CONTINUE?" {
|
||||
t.Fatalf("En continue = %q", got)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("unknown message did not panic")
|
||||
}
|
||||
}()
|
||||
Kit{locale: En}.Msg("no_such_key")
|
||||
}
|
||||
Reference in new issue
Block a user