From a92829f353819de9ceaecb4ad097412dcb7a8405 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 11 Oct 2026 16:15:27 -0400 Subject: [PATCH] Add pixel text editor and built-in messages --- AGENTS.md | 2 +- pixel/canvas.go | 7 + pixel/edit.go | 175 +++++++++++++++ pixel/edit_internal_test.go | 134 +++++++++++ pixel/editor.go | 409 ++++++++++++++++++++++++++++++++++ pixel/editor_test.go | 269 ++++++++++++++++++++++ pixel/engine_internal_test.go | 7 +- pixel/font.go | 22 +- pixel/font_test.go | 2 + pixel/messages.go | 25 +++ pixel/messages_test.go | 28 +++ 11 files changed, 1069 insertions(+), 11 deletions(-) create mode 100644 pixel/edit.go create mode 100644 pixel/edit_internal_test.go create mode 100644 pixel/editor.go create mode 100644 pixel/editor_test.go create mode 100644 pixel/messages.go create mode 100644 pixel/messages_test.go diff --git a/AGENTS.md b/AGENTS.md index a88d37f..42b961f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/pixel/canvas.go b/pixel/canvas.go index 42c6ccb..a606286 100644 --- a/pixel/canvas.go +++ b/pixel/canvas.go @@ -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) } diff --git a/pixel/edit.go b/pixel/edit.go new file mode 100644 index 0000000..3645782 --- /dev/null +++ b/pixel/edit.go @@ -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 +} diff --git a/pixel/edit_internal_test.go b/pixel/edit_internal_test.go new file mode 100644 index 0000000..cdb8a7f --- /dev/null +++ b/pixel/edit_internal_test.go @@ -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) + } +} diff --git a/pixel/editor.go b/pixel/editor.go new file mode 100644 index 0000000..87e4a76 --- /dev/null +++ b/pixel/editor.go @@ -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 +} diff --git a/pixel/editor_test.go b/pixel/editor_test.go new file mode 100644 index 0000000..14bcb3d --- /dev/null +++ b/pixel/editor_test.go @@ -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("") + h.tt.Key(ui.Shift, ui.KeyLeft) + h.tt.Key(ui.Shift, ui.KeyLeft) + h.tt.Type("!") + h.want(" 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") + } +} diff --git a/pixel/engine_internal_test.go b/pixel/engine_internal_test.go index 8b363b2..c5080ac 100644 --- a/pixel/engine_internal_test.go +++ b/pixel/engine_internal_test.go @@ -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") diff --git a/pixel/font.go b/pixel/font.go index e3f30b9..dadd7af 100644 --- a/pixel/font.go +++ b/pixel/font.go @@ -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]. diff --git a/pixel/font_test.go b/pixel/font_test.go index 37223ad..f84148c 100644 --- a/pixel/font_test.go +++ b/pixel/font_test.go @@ -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{"[你", "好]世", "界"}}, diff --git a/pixel/messages.go b/pixel/messages.go new file mode 100644 index 0000000..6cdb581 --- /dev/null +++ b/pixel/messages.go @@ -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 +} diff --git a/pixel/messages_test.go b/pixel/messages_test.go new file mode 100644 index 0000000..ac5a023 --- /dev/null +++ b/pixel/messages_test.go @@ -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") +}