Add pixel text editor and built-in messages

This commit is contained in:
ZacharyZhang-NY committed 2026-10-11 16:15:27 -04:00
1 parent d33bf0cbbb
commit a92829f353
11 files changed
+1069 -11

No files matched your search

+1 -1
View File
@@ -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
+7
View File
@@ -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
View File
@@ -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
}
+134
View File
@@ -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
View File
@@ -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
}
+269
View File
@@ -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")
}
}
+4 -3
View File
@@ -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
View File
@@ -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].
+2
View File
@@ -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{"[你", "好]世", "界"}},
+25
View File
@@ -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
}
+28
View File
@@ -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")
}