Add code components 366-385; ScrollArea Follow, editor sync

A terminal with history, a Go-colored code block, a log viewer that
follows, a hex viewer, a memory map, a JSON tree, a line diff, a GAME
OVER crash screen, an FPS meter, profiler bars, breakpoints,
variables, command history, a shortcut list, a debug grid, a pixel
editor that exports string art, a palette editor, a sprite sheet, a
glyph map and a 6502 register view.

layout.ScrollArea takes Follow, and ChatLog now uses it. data.TreeNode
takes a Value drawn in its Slot. pixel gains Has, and Sprite.Art
writes MustSprite art. pixel.Editor takes the caller's value at each
input event, so a value changed between frames is never typed onto.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-12 07:18:30 -04:00
1 parent 0e21870b64
commit 51af14709d
42 files changed
+3415 -37

No files matched your search

+3 -26
View File
@@ -1,8 +1,6 @@
package chat
import (
"math"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout"
@@ -16,35 +14,14 @@ type ChatLogOptions struct {
type logKey struct{}
// logState is the scroll and the end it last reached.
type logState struct {
scroll ui.ScrollState
end float32
}
// ChatLog scrolls messages and follows the end while there.
func ChatLog(c *ui.Context, text string, o ChatLogOptions, content func()) *ui.Element {
k := pixel.K(c)
w, h := pixel.Or("ChatLog Width", o.Width, 220), pixel.Or("ChatLog Height", o.Height, 140)
box := ui.Box(c).Size(k.D(w), k.D(h))
st := ui.Local(box, logKey{}, func() logState { return logState{scroll: ui.ScrollState{Y: math.MaxFloat32}} })
// Content grew under a view at its old end: follow.
grew := func() bool { return st.scroll.MaxY > st.end && st.scroll.Y >= st.end }
// Inert freezes the view; the end waits until it wakes.
frozen := pixel.Current(c).Inert
if !frozen {
if grew() {
st.scroll.Y = st.scroll.MaxY
}
st.end = st.scroll.MaxY
}
return box.Draw(func(p *ui.Painter, r ui.Rect) {
// Layout has just measured the content; follow it next frame.
if !frozen && grew() {
c.Invalidate()
}
}).Children(func() {
layout.ScrollArea(c, &st.scroll, layout.ScrollAreaOptions{Label: text}, func() {
s := ui.Local(box, logKey{}, func() ui.ScrollState { return ui.ScrollState{} })
return box.Children(func() {
layout.ScrollArea(c, s, layout.ScrollAreaOptions{Label: text, Follow: true}, func() {
ui.Column(c).FillWidth().Padding(k.D(4)).Gap(k.D(4)).Children(content)
}).Element.Fill()
})
+77
View File
@@ -0,0 +1,77 @@
package code
import (
"fmt"
"log/slog"
"strconv"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type BreakpointsOptions struct {
Disabled bool
}
type BreakpointsResult struct {
Element *ui.Element
Changed bool
}
type lineKey struct{}
// Breakpoints shows code; a click or Enter toggles a dot.
func Breakpoints(c *ui.Context, text, src string, marks map[int]bool, o BreakpointsOptions) BreakpointsResult {
k := pixel.K(c)
if marks == nil {
panic("code: Breakpoints needs a map of marks")
}
src = tidy(src)
lines, slots := strings.Split(src, "\n"), paint(src)
gutter := 10 + pixel.Measure(strconv.Itoa(len(lines))) + 6
w := 0
for _, l := range lines {
w = max(w, gutter+pixel.Measure(l)+4)
}
res := BreakpointsResult{Element: ui.Box(c)}
cur := ui.Local(res.Element, lineKey{}, func() int { return 0 })
*cur = min(*cur, len(lines)-1)
res.Element.Children(func() {
shell, base, st, picked := pixel.Pad(c, text, 1, len(lines), w, pixel.LineHeight, cur, o.Disabled)
if picked >= 0 {
n := picked + 1
marks[n], res.Changed = !marks[n], true
if !marks[n] {
delete(marks, n)
}
slog.Debug("8bit: breakpoints", "label", text, "line", n, "on", marks[n])
}
say := strconv.Itoa(*cur + 1)
if marks[*cur+1] {
say += ", " + k.Msg("breakpoint")
}
base.Description(fmt.Sprintf("%s: %s", say, lines[*cur]))
at := *cur
shell.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
off := 0
for i, l := range lines {
y, from := i*pixel.LineHeight, off
off += len(l) + 1
if i == at && st.Focus {
g.Cursor(0, y, w, pixel.LineHeight)
}
if marks[i+1] {
g.Disc(5, y+6, 3, pixel.Bad)
}
n := strconv.Itoa(i + 1)
g.Text(gutter-6-pixel.Measure(n), y, n, pixel.Faint, 1)
colored(g, gutter, y, l, slots[from:from+len(l)])
}
g.Finish(st)
})
})
return res
}
+92
View File
@@ -0,0 +1,92 @@
// Package code shows, runs and debugs programs and their data.
package code
import (
"go/scanner"
"go/token"
"strings"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
var (
builtins = set("any bool byte comparable complex64 complex128 error float32 float64 int int8 int16 int32 int64 rune string " +
"uint uint8 uint16 uint32 uint64 uintptr append cap clear close complex copy delete imag len make max min new panic print println real recover")
constants = set("true false nil iota")
)
func set(words string) map[string]bool {
m := map[string]bool{}
for _, w := range strings.Fields(words) {
m[w] = true
}
return m
}
// tidy drops carriage returns and turns tabs into four spaces.
func tidy(src string) string {
src = strings.ReplaceAll(src, "\r", "")
return strings.TrimRight(strings.ReplaceAll(src, "\t", " "), "\n")
}
// paint is the slot of every byte of Go-like source.
func paint(src string) []pixel.Slot {
slots := make([]pixel.Slot, len(src))
fs := token.NewFileSet()
f := fs.AddFile("", -1, len(src))
var s scanner.Scanner
s.Init(f, []byte(src), nil, scanner.ScanComments)
for {
pos, tok, lit := s.Scan()
if tok == token.EOF {
return slots
}
n := len(lit)
if n == 0 {
n = len(tok.String())
}
var slot pixel.Slot
switch {
case tok == token.COMMENT:
slot = pixel.Faint
case tok == token.STRING || tok == token.CHAR:
slot = pixel.Good
case tok == token.INT || tok == token.FLOAT || tok == token.IMAG:
slot = pixel.Warn
case tok.IsKeyword():
slot = pixel.Accent
case tok == token.IDENT && builtins[lit]:
slot = pixel.Info
case tok == token.IDENT && constants[lit]:
slot = pixel.Magic
default:
continue
}
off := f.Offset(pos)
for i := off; i < min(off+n, len(src)); i++ {
slots[i] = slot
}
}
}
// colored draws s in runs by slot; returns the end.
func colored(g pixel.Canvas, x, y int, s string, slots []pixel.Slot) int {
for i := 0; i < len(s); {
j := i
for j < len(s) && slots[j] == slots[i] {
j++
}
g.Text(x, y, s[i:j], slots[i], 1)
x += pixel.Measure(s[i:j])
i = j
}
return x
}
// dark is a screen's body, light ink and dim ink.
func dark(k pixel.Kit) (body, ink, dim pixel.Slot) {
if k.Palette().Night {
return pixel.Well, pixel.Ink, pixel.Faint
}
return pixel.Ink, pixel.Lit, pixel.Shade
}
+177
View File
@@ -0,0 +1,177 @@
package code_test
import (
"image"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/code"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type clock struct{ t time.Time }
func (c *clock) now() time.Time { return c.t }
// world is the settings a test view runs under.
type world struct {
clk *clock
reduce, inert bool
}
func newWorld() *world {
return &world{clk: &clock{time.Date(2026, 9, 2, 10, 30, 0, 0, time.UTC)}}
}
// view builds on a fake clock, two pixels a dot.
func view(w, h int, wd *world, build func(c *ui.Context)) *ui.Tester {
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: wd.clk.now, Inert: wd.inert})
build(c)
}, w, h)
if wd.reduce {
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
tt.Frame()
}
return tt
}
// later moves the clock on and builds a frame.
func later(tt *ui.Tester, wd *world, d time.Duration) {
wd.clk.t = wd.clk.t.Add(d)
tt.Frame()
}
func click(t *testing.T, tt *ui.Tester, label string) {
t.Helper()
if err := tt.Click(label); err != nil {
t.Fatalf("%v in %q", err, tt.Texts())
}
}
func find(t *testing.T, tt *ui.Tester, label string) ui.Rect {
t.Helper()
r, ok := tt.Find(label)
if !ok {
t.Fatalf("no %q in %q", label, tt.Texts())
}
return r
}
// none is a color outside the palette.
const none pixel.Slot = 255
// at is the slot at dot x, y, or none.
func at(img *image.RGBA, x, y int) pixel.Slot {
c := img.RGBAAt(2*x, 2*y)
for s, want := range pixel.SpringDay.Colors {
if c.R == want.R && c.G == want.G && c.B == want.B {
return pixel.Slot(s)
}
}
return none
}
// count is how many dots in a box hold s.
func count(img *image.RGBA, x, y, w, h int, s pixel.Slot) int {
n := 0
for j := y; j < y+h; j++ {
for i := x; i < x+w; i++ {
if at(img, i, j) == s {
n++
}
}
}
return n
}
// dots is a found box in dots.
func dots(r ui.Rect) (x, y, w, h int) {
return int(r.X / 2), int(r.Y / 2), int(r.W / 2), int(r.H / 2)
}
func mustPanic(t *testing.T, name string, fn func()) {
t.Helper()
defer func() {
if recover() == nil {
t.Errorf("%s did not panic", name)
}
}()
fn()
}
func TestCodeBlockNumbersColorsAndCopies(t *testing.T) {
wd := newWorld()
copied := false
tt := view(500, 300, wd, func(c *ui.Context) {
copied = code.CodeBlock(c, "main.go", "package main\n\nfunc main() {\n\tprintln(1)\n}\n", code.CodeBlockOptions{}).Copied || copied
})
// Tabs become four spaces; each line is read alone.
for _, l := range []string{"package main", "func main() {", " println(1)", "}"} {
find(t, tt, l)
}
x, y, w, h := dots(find(t, tt, "package main"))
img := tt.Image()
if count(img, x, y, w, h, pixel.Accent) == 0 || count(img, x, y, 10, h, pixel.Faint) == 0 {
t.Fatal("no Accent keyword or Faint line number on line 1")
}
click(t, tt, "COPY")
if !copied || tt.Clipboard() != "package main\n\nfunc main() {\n println(1)\n}" {
t.Fatalf("copied %v: %q", copied, tt.Clipboard())
}
find(t, tt, "COPIED!")
later(tt, wd, 2*time.Second)
find(t, tt, "COPY")
}
func TestTerminalRunsAndRecalls(t *testing.T) {
wd := newWorld()
lines, input := []string{"READY."}, ""
var ran []string
tt := view(500, 300, wd, func(c *ui.Context) {
if r := code.Terminal(c, "Shell", lines, &input, code.TerminalOptions{}); r.Ran != "" {
ran = append(ran, r.Ran)
lines = append(lines, "$ "+r.Ran)
}
})
find(t, tt, "READY.")
click(t, tt, "COMMAND")
for _, cmd := range []string{"ls", "pwd", "pwd"} {
tt.Type(cmd)
tt.Key(0, ui.KeyEnter)
}
if len(ran) != 3 || ran[0] != "ls" || input != "" {
t.Fatalf("ran %q, input %q", ran, input)
}
find(t, tt, "$ pwd")
// A draft waits below the history; repeats are kept once.
tt.Type("ec")
steps := []struct {
key ui.Key
want string
}{{ui.KeyUp, "pwd"}, {ui.KeyUp, "ls"}, {ui.KeyUp, "ls"}, {ui.KeyDown, "pwd"}, {ui.KeyDown, "ec"}, {ui.KeyDown, "ec"}}
for i, s := range steps {
tt.Key(0, s.key)
if input != s.want {
t.Fatalf("step %d: input %q, want %q", i, input, s.want)
}
}
}
func TestTerminalHoldsUnderInert(t *testing.T) {
wd := newWorld()
input := ""
tt := view(500, 300, wd, func(c *ui.Context) {
code.Terminal(c, "Shell", nil, &input, code.TerminalOptions{})
})
click(t, tt, "COMMAND")
tt.Type("ls")
tt.Key(0, ui.KeyEnter)
wd.inert = true
tt.Key(0, ui.KeyUp)
if input != "" {
t.Fatalf("an inert terminal recalled %q", input)
}
}
+65
View File
@@ -0,0 +1,65 @@
package code
import (
"log/slog"
"strconv"
"strings"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type CodeBlockOptions struct {
Disabled bool
}
type CodeBlockResult struct {
Element *ui.Element
Copied bool
}
type copiedKey struct{}
// CodeBlock colors Go-like source by slot, numbers lines, copies.
func CodeBlock(c *ui.Context, text, src string, o CodeBlockOptions) CodeBlockResult {
k := pixel.K(c)
src = tidy(src)
lines, slots := strings.Split(src, "\n"), paint(src)
gutter := pixel.Measure(strconv.Itoa(len(lines))) + 6
var res CodeBlockResult
res.Element = layout.Panel(c, layout.PanelOptions{Style: pixel.Sunken, Fill: pixel.Well, Padding: 3}, func() {
ui.Row(c).FillWidth().AlignItems(ui.Center).Children(func() {
pixel.Text(c, text, pixel.TextOptions{Slot: pixel.Faint}).Grow(1)
e := ui.Box(c)
at := ui.Local(e, copiedKey{}, func() time.Time { return time.Time{} })
// The button says COPIED! for two seconds.
label := k.Msg("copy")
if k.Since(c, *at, 1, 2) < 2 {
label = k.Msg("copied")
}
e.Children(func() {
if input.Button(c, label, input.ButtonOptions{Size: input.Small, Disabled: o.Disabled}).Clicked {
c.WriteClipboard(src)
*at, res.Copied = k.Now(), true
slog.Debug("8bit: code block copied", "label", text, "lines", len(lines))
}
})
})
off := 0
for i, l := range lines {
from := off
off += len(l) + 1
ui.Box(c).Size(k.D(gutter+pixel.Measure(l)), k.D(pixel.LineHeight)).Role(ui.RoleText).Label(l).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
n := strconv.Itoa(i + 1)
g.Text(gutter-6-pixel.Measure(n), 0, n, pixel.Faint, 1)
colored(g, gutter, 0, l, slots[from:from+len(l)])
})
}
})
return res
}
+41
View File
@@ -0,0 +1,41 @@
package code
import (
"log/slog"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type CommandHistoryOptions struct {
// Width and Height are dots; zeros mean 200 by 120.
Width, Height int
Disabled bool
}
type CommandHistoryResult struct {
Element *ui.Element
// Rerun is Enter or a double click on a command.
Changed, Rerun bool
}
// CommandHistory numbers past commands; Enter reruns one.
func CommandHistory(c *ui.Context, text string, commands []string, selected *int, o CommandHistoryOptions) CommandHistoryResult {
k := pixel.K(c)
var r data.ListResult
e := ui.Box(c).Width(k.D(pixel.Or("CommandHistory Width", o.Width, 200))).Children(func() {
r = data.List(c, text, len(commands), selected, data.ListOptions{Height: o.Height, Disabled: o.Disabled}, func(i int) string { return commands[i] }, func(i int) {
ui.Row(c).Gap(k.D(4)).Children(func() {
pixel.Text(c, strconv.Itoa(i+1), pixel.TextOptions{Slot: pixel.Faint})
pixel.Text(c, commands[i], pixel.TextOptions{MaxLines: 1})
})
})
})
if r.Submitted {
slog.Debug("8bit: command history rerun", "label", text, "index", *selected)
}
return CommandHistoryResult{Element: e, Changed: r.Changed, Rerun: r.Submitted}
}
+56
View File
@@ -0,0 +1,56 @@
package code
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type CrashScreenOptions struct {
// Width is in dots; zero means 260.
Width int
Disabled bool
}
type CrashScreenResult struct {
Element *ui.Element
Continue bool
}
// CrashScreen frames an error and its stack; CONTINUE goes on.
func CrashScreen(c *ui.Context, err string, stack []string, o CrashScreenOptions) CrashScreenResult {
k := pixel.K(c)
if err == "" {
panic("code: CrashScreen needs an error")
}
body, ink, dim := dark(k)
var res CrashScreenResult
res.Element = ui.Column(c).Width(k.D(pixel.Or("CrashScreen Width", o.Width, 260))).Padding(k.D(8)).Gap(k.D(4)).
Role(ui.RoleGroup).Label(k.Msg("game_over")).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Rect(0, 0, g.W, g.H, body)
// The red frame blinks each second, as the guru's did.
if g.Step(4)%4 < 2 {
g.Rect(0, 0, g.W, 3, pixel.Bad)
g.Rect(0, g.H-3, g.W, 3, pixel.Bad)
g.Rect(0, 0, 3, g.H, pixel.Bad)
g.Rect(g.W-3, 0, 3, g.H, pixel.Bad)
}
}).Children(func() {
pixel.Text(c, k.Msg("game_over"), pixel.TextOptions{Slot: pixel.Bad})
pixel.Text(c, err, pixel.TextOptions{Slot: ink, Wrap: true})
for _, f := range stack {
pixel.Text(c, " "+f, pixel.TextOptions{Slot: dim, MaxLines: 1})
}
ui.Row(c).Padding(k.D(4), 0, 0, 0).Children(func() {
res.Continue = input.Button(c, k.Msg("continue"), input.ButtonOptions{Variant: input.Primary, Disabled: o.Disabled}).Clicked
})
})
if res.Continue {
slog.Debug("8bit: crash screen continue", "error", err)
}
return res
}
+78
View File
@@ -0,0 +1,78 @@
package code
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type DebugGridOptions struct {
// Step is dots between lines, 2–32; zero means 8.
Step int
}
// DebugGrid rules content with the dot grid and rulers.
func DebugGrid(c *ui.Context, o DebugGridOptions, content func()) *ui.Element {
k := pixel.K(c)
step := pixel.Or("DebugGrid Step", o.Step, 8)
if step < 2 || step > 32 {
panic(fmt.Sprintf("code: DebugGrid Step %d outside 2–32", step))
}
// ruler ticks every two dots; steps are long, some numbered.
ruler := func(across bool) func(p *ui.Painter, r ui.Rect) {
return func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Rect(0, 0, g.W, g.H, pixel.Face)
n := g.H
if across {
n = g.W
}
for i := 0; i < n; i += 2 {
l := 2
if i%step == 0 {
l = 4
}
if across {
g.VLine(i, 10-l, l, pixel.Ink)
} else {
g.HLine(10-l, i, l, pixel.Ink)
}
if s := fmt.Sprint(i); i%(4*step) == 0 && i > 0 {
if across {
g.Micro(i+1, 0, s, pixel.Faint, 1)
} else if pixel.MicroWidth(s) <= 6 {
g.Micro(0, i+1, s, pixel.Faint, 1)
}
}
}
}
}
// The rulers stretch to the content, no further.
return ui.Column(c).AlignSelf(ui.Start).AlignItems(ui.Stretch).Children(func() {
ui.Row(c).Children(func() {
ui.Box(c).Size(k.D(10), k.D(10)).Draw(func(p *ui.Painter, r ui.Rect) {
k.Canvas(p, r).Rect(0, 0, 10, 10, pixel.Shade)
})
ui.Box(c).Height(k.D(10)).Grow(1).Draw(ruler(true))
})
ui.Row(c).AlignItems(ui.Stretch).Children(func() {
ui.Box(c).Width(k.D(10)).Draw(ruler(false))
ui.Box(c).Children(content).DrawOver(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
// Dotted lines every step, every other dot lit.
for x := 0; x < g.W; x += step {
for y := 0; y < g.H; y += 2 {
g.Dot(x, y, pixel.Accent)
}
}
for y := 0; y < g.H; y += step {
for x := 0; x < g.W; x += 2 {
g.Dot(x, y, pixel.Accent)
}
}
})
})
})
}
+94
View File
@@ -0,0 +1,94 @@
package code
import (
"fmt"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type DiffViewOptions struct{}
// change is a diff line's sign, text and line numbers.
type change struct {
sign byte
text string
old, new int
}
// DiffView marks lines removed with -, added with +.
func DiffView(c *ui.Context, text, before, after string, o DiffViewOptions) *ui.Element {
k := pixel.K(c)
changes := diff(strings.Split(tidy(before), "\n"), strings.Split(tidy(after), "\n"))
// Rows share the longest width, so tints line up.
w := 0
for _, ch := range changes {
w = max(w, 32+pixel.Measure(ch.text))
}
return layout.Panel(c, layout.PanelOptions{Style: pixel.Sunken, Fill: pixel.Well, Padding: 2}, func() {
for _, ch := range changes {
ui.Box(c).Size(k.D(w), k.D(pixel.LineHeight)).Role(ui.RoleText).Label(string(ch.sign) + " " + ch.text).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
// Changed lines take their color and a left bar.
ink, text := pixel.Faint, pixel.Ink
switch ch.sign {
case '+':
ink, text = pixel.Good, pixel.Good
case '-':
ink, text = pixel.Bad, pixel.Bad
}
if ch.sign != ' ' {
g.Rect(0, 0, 2, g.H, ink)
}
num := func(x, n int) {
if n > 0 {
s := fmt.Sprint(n)
g.Micro(x-pixel.MicroWidth(s), 4, s, pixel.Faint, 1)
}
}
num(11, ch.old)
num(23, ch.new)
g.Text(26, 0, string(ch.sign), ink, 1)
g.Text(32, 0, ch.text, text, 1)
})
}
}).Role(ui.RoleGroup).Label(text)
}
// diff walks the longest common run of lines.
func diff(a, b []string) []change {
// lcs[i][j] is the common length of a[i:] and b[j:].
lcs := make([][]int, len(a)+1)
for i := range lcs {
lcs[i] = make([]int, len(b)+1)
}
for i := len(a) - 1; i >= 0; i-- {
for j := len(b) - 1; j >= 0; j-- {
if a[i] == b[j] {
lcs[i][j] = lcs[i+1][j+1] + 1
} else {
lcs[i][j] = max(lcs[i+1][j], lcs[i][j+1])
}
}
}
var out []change
i, j := 0, 0
for i < len(a) || j < len(b) {
switch {
case i < len(a) && j < len(b) && a[i] == b[j]:
out = append(out, change{' ', a[i], i + 1, j + 1})
i, j = i+1, j+1
// Taken out before put in, as diffs read.
case i < len(a) && (j == len(b) || lcs[i+1][j] >= lcs[i][j+1]):
out = append(out, change{'-', a[i], i + 1, 0})
i++
default:
out = append(out, change{'+', b[j], 0, j + 1})
j++
}
}
return out
}
+72
View File
@@ -0,0 +1,72 @@
package code
import (
"fmt"
"slices"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type FPSMeterOptions struct {
// Width and Height are dots; zeros mean 120 by 40.
Width, Height int
}
// FPSMeter draws frame times as bars, newest right, with percentiles.
func FPSMeter(c *ui.Context, text string, frames []time.Duration, o FPSMeterOptions) *ui.Element {
k := pixel.K(c)
w, h := pixel.Or("FPSMeter Width", o.Width, 120), pixel.Or("FPSMeter Height", o.Height, 40)
for _, f := range frames {
if f < 0 {
panic(fmt.Sprintf("code: FPSMeter frame of %v", f))
}
}
// Two dots a bar; the oldest scroll off the left.
shown := frames[max(0, len(frames)-(w-4)/2):]
p50, p95 := percentile(shown, 50), percentile(shown, 95)
head := "P50 - P95 -"
if len(shown) > 0 {
head = fmt.Sprintf("P50 %.1fMS P95 %.1fMS", ms(p50), ms(p95))
}
top := 2 * time.Second / 60
if len(shown) > 0 {
top = max(top, slices.Max(shown))
}
return ui.Box(c).Size(k.D(w), k.D(h+8)).Role(ui.RoleImage).Label(text).Description(head).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
body, ink, _ := dark(k)
g.Micro(0, 0, head, pixel.Faint, 1)
g.Rect(0, 8, w, h, body)
y := func(d time.Duration) int { return 8 + h - 2 - int(int64(h-4)*int64(d)/int64(top)) }
for i, f := range shown {
slot := pixel.Good
switch {
case f > time.Second/30:
slot = pixel.Bad
case f > time.Second/60:
slot = pixel.Warn
}
g.Rect(2+2*i, y(f), 1, 8+h-2-y(f), slot)
}
if len(shown) > 0 {
for x := 2; x < w-2; x += 2 {
g.Dot(x, y(p50), ink)
g.Dot(x+1, y(p95), pixel.Warn)
}
}
})
}
// percentile is the nearest-rank q of ds; empty is 0.
func percentile(ds []time.Duration, q int) time.Duration {
if len(ds) == 0 {
return 0
}
s := slices.Sorted(slices.Values(ds))
return s[max(0, (q*len(s)+99)/100-1)]
}
func ms(d time.Duration) float64 { return float64(d) / float64(time.Millisecond) }
+102
View File
@@ -0,0 +1,102 @@
package code
import (
"fmt"
"log/slog"
"unicode"
"unicode/utf8"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type GlyphMapOptions struct {
Disabled bool
}
type GlyphMapResult struct {
Element *ui.Element
Changed bool
}
// GlyphMap pages code points by 128; the chosen shows large.
func GlyphMap(c *ui.Context, text string, at *rune, o GlyphMapOptions) GlyphMapResult {
k := pixel.K(c)
if *at < 0 || *at > unicode.MaxRune {
panic(fmt.Sprintf("code: GlyphMap at %U", *at))
}
old := *at
var res GlyphMapResult
res.Element = ui.Row(c).Gap(k.D(8)).AlignItems(ui.Start).Children(func() {
page := *at &^ 127
cur := int(*at - page)
shell, base, st, _ := pixel.Pad(c, text, 16, 128, 14, 14, &cur, o.Disabled)
*at = page + rune(cur)
// PageUp and PageDown turn a page, keeping the column.
if !base.IsDisabled() {
if base.Shortcut(0, ui.KeyPageUp) && *at >= 128 {
*at -= 128
}
if base.Shortcut(0, ui.KeyPageDown) && *at+128 <= unicode.MaxRune {
*at += 128
}
}
base.Description(name(*at))
shown := *at
shell.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
first := shown &^ 127
for i := range 128 {
x, y, r := i%16*14, i/16*14, first+rune(i)
ink := pixel.Ink
if r == shown {
g.Rect(x, y, 14, 14, pixel.Accent)
ink = pixel.AccentInk
}
if drawn(r) {
s := string(r)
g.Text(x+(14-pixel.Measure(s))/2, y+1, s, ink, 1)
} else {
g.Dot(x+7, y+7, pixel.Faint)
}
}
g.Finish(st)
})
ui.Column(c).Gap(k.D(3)).AlignItems(ui.Center).Children(func() {
ui.Box(c).Size(k.D(48), k.D(48)).Role(ui.RoleImage).Label(name(shown)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, 48, 48, pixel.Sunken, pixel.Well)
if drawn(shown) {
s := string(shown)
g.Text(24-pixel.Measure(s)*3/2, 6, s, pixel.Ink, 3)
}
})
code := fmt.Sprintf("U+%04X", shown)
slot := pixel.Ink
if !pixel.Has(shown) {
slot = pixel.Bad
}
ui.Box(c).Size(k.D(pixel.MicroWidth(code)), k.D(5)).Draw(func(p *ui.Painter, r ui.Rect) {
k.Canvas(p, r).Micro(0, 0, code, slot, 1)
})
})
})
if res.Changed = *at != old; res.Changed {
slog.Debug("8bit: glyph map", "label", text, "at", fmt.Sprintf("%U", *at))
}
return res
}
// drawn is a graphic rune the font holds.
func drawn(r rune) bool {
return utf8.ValidRune(r) && (unicode.IsGraphic(r) || r == ' ') && pixel.Has(r)
}
// name reads a code point aloud, with its glyph.
func name(r rune) string {
if drawn(r) {
return fmt.Sprintf("%U %c", r, r)
}
return fmt.Sprintf("%U", r)
}
+136
View File
@@ -0,0 +1,136 @@
package code_test
import (
"fmt"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/code"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestDisabledScrollersHoldStill(t *testing.T) {
var lines []string
var logs []code.LogLine
for i := range 30 {
lines = append(lines, fmt.Sprint("line ", i))
logs = append(logs, code.LogLine{Level: code.Info, Text: fmt.Sprint("log ", i)})
}
least, input := code.Info, ""
tt := view(700, 500, newWorld(), func(c *ui.Context) {
ui.Row(c).Children(func() {
code.Terminal(c, "Shell", lines, &input, code.TerminalOptions{Disabled: true})
code.LogViewer(c, "Log", logs, &least, code.LogViewerOptions{Disabled: true})
})
})
// Disabled, it neither follows nor turns with the wheel.
for _, l := range []string{"line 0", "00:00:00 INFO log 0"} {
r := find(t, tt, l)
tt.Scroll(r.X+4, r.Y+4, 0, -300)
if after := find(t, tt, l); after.Y != r.Y || after.H == 0 {
t.Fatalf("%q moved from %v to %v", l, r, after)
}
}
}
func TestPixelEditorSurvivesAShrinkMidDrag(t *testing.T) {
art := "....\n....\n"
tt := view(500, 300, newWorld(), func(c *ui.Context) {
code.PixelEditor(c, "Art", &art, code.PixelEditorOptions{Zoom: 8})
})
r := find(t, tt, "Art")
tt.Press(r.X+16*3+8, r.Y+16+8)
art = "..\n"
tt.Frame()
tt.Move(r.X+8, r.Y+8)
tt.Release(r.X+8, r.Y+8)
if art != "k.\n" {
t.Fatalf("after the shrink the drag left %q", art)
}
}
func TestPaletteEditorSwatchesWearTheEdit(t *testing.T) {
pal := *pixel.SpringDay
tt := view(600, 300, newWorld(), func(c *ui.Context) {
code.PaletteEditor(c, "Copy", &pal, code.PaletteEditorOptions{})
})
click(t, tt, "R")
tt.Key(0, ui.KeyEnd)
// Accent, slot 8, is the second row's first swatch.
x, y, _, _ := dots(find(t, tt, "Copy"))
want := pal.Colors[pixel.Accent]
if c := tt.Image().RGBAAt(2*(x+6), 2*(y+14+6)); c.R != want.R || c.G != want.G || c.B != want.B {
t.Fatalf("the Accent swatch is %v, want the edit %v", c, want)
}
}
func TestJSONTreeShowsAnEmptyRoot(t *testing.T) {
for _, doc := range []string{"{}", "[]", "42"} {
sel := ""
tt := view(400, 200, newWorld(), func(c *ui.Context) {
code.JSONTree(c, "Doc", doc, &sel, code.JSONTreeOptions{})
})
if r := find(t, tt, "Doc"); r.H != 2*pixel.LineHeight {
t.Errorf("%s shows %v, want one row", doc, r)
}
}
}
func TestCrashScreenReadsGameOver(t *testing.T) {
tt := view(600, 300, newWorld(), func(c *ui.Context) {
code.CrashScreen(c, "panic: oops", nil, code.CrashScreenOptions{})
})
// The group and its title both read GAME OVER.
n := 0
for _, s := range tt.Texts() {
if s == "GAME OVER" {
n++
}
}
if n != 2 {
t.Fatalf("GAME OVER read %d times in %q", n, tt.Texts())
}
find(t, tt, "CONTINUE?")
}
func TestTerminalShowsAnEmptyLineOnceItRuns(t *testing.T) {
input := ""
tt := view(500, 300, newWorld(), func(c *ui.Context) {
code.Terminal(c, "Shell", nil, &input, code.TerminalOptions{})
})
click(t, tt, "COMMAND")
x, y, w, h := dots(find(t, tt, "COMMAND"))
empty := count(tt.Image(), x, y, w, h, pixel.Ink)
tt.Type("help")
tt.Key(0, ui.KeyEnter)
if n := count(tt.Image(), x, y, w, h, pixel.Ink); n != empty {
t.Fatalf("after Enter the field shows %d ink dots, empty is %d", n, empty)
}
}
func TestCrashScreenStepsAtFourFPS(t *testing.T) {
wd := newWorld()
tt := view(600, 300, wd, func(c *ui.Context) {
code.CrashScreen(c, "panic: oops", nil, code.CrashScreenOptions{})
})
// Paint asks for the next step on the real clock.
pixel.ResetDue()
before := time.Now()
tt.Frame()
if d := pixel.Due().Sub(before); d < time.Second/4 || d >= time.Second/2 {
t.Fatalf("the next blink step is %v away, want a quarter second", d)
}
}
func TestSpriteSheetKeepsS5Rates(t *testing.T) {
for _, fps := range []int{2, 30} {
mustPanic(t, fmt.Sprint(fps, " fps"), func() {
view(600, 300, newWorld(), func(c *ui.Context) {
f, p := 0, false
code.SpriteSheet(c, "S", spin, &f, &p, code.SpriteSheetOptions{FPS: fps})
})
})
}
}
+84
View File
@@ -0,0 +1,84 @@
package code
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type HexViewerOptions struct {
// Columns are bytes a row, 4–16; zero means 8.
Columns int
Disabled bool
}
type HexViewerResult struct {
Element *ui.Element
Changed bool
}
// HexViewer shows offsets, hex and ASCII; arrows move a cursor.
func HexViewer(c *ui.Context, text string, data []byte, cursor *int, o HexViewerOptions) HexViewerResult {
k := pixel.K(c)
cols := pixel.Or("HexViewer Columns", o.Columns, 8)
if cols < 4 || cols > 16 || len(data) == 0 || *cursor < 0 || *cursor >= len(data) {
panic(fmt.Sprintf("code: HexViewer of %d bytes, %d a row, cursor %d", len(data), cols, *cursor))
}
rows := (len(data) + cols - 1) / cols
var res HexViewerResult
res.Element = ui.Row(c).Gap(k.D(6)).Children(func() {
ui.Box(c).Size(k.D(15), k.D(rows*12)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i := range rows {
g.Micro(0, i*12+4, fmt.Sprintf("%04X", i*cols), pixel.Faint, 1)
}
})
old := *cursor
shell, base, st, _ := pixel.Pad(c, text, cols, len(data), 12, 12, cursor, o.Disabled)
b := data[*cursor]
base.Description(fmt.Sprintf("%04X: %02X %q", *cursor, b, glyph(b)))
at := *cursor
shell.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i, v := range data {
x, y, ink := i%cols*12, i/cols*12, pixel.Ink
switch {
case i == at:
g.Rect(x, y, 11, 12, pixel.Accent)
ink = pixel.AccentInk
case v == 0:
ink = pixel.Faint
}
g.Micro(x+2, y+4, fmt.Sprintf("%02X", v), ink, 1)
}
g.Finish(st)
})
ui.Box(c).Size(k.D(cols*7), k.D(rows*12)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i, v := range data {
x, y, ch := i%cols*7, i/cols*12, string(glyph(v))
ink := pixel.Info
if i == at {
g.Rect(x, y, 7, 12, pixel.Accent)
ink = pixel.AccentInk
}
g.Text(x+(7-pixel.Measure(ch))/2, y, ch, ink, 1)
}
})
if res.Changed = *cursor != old; res.Changed {
slog.Debug("8bit: hex viewer", "label", text, "offset", *cursor)
}
})
return res
}
// glyph is b as a character; unprintable is a dot.
func glyph(b byte) rune {
if b < 0x20 || b > 0x7E {
return '.'
}
return rune(b)
}
+77
View File
@@ -0,0 +1,77 @@
package code
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type JSONTreeOptions struct {
// Width is in dots; zero means 200.
Width int
Disabled bool
}
type JSONTreeResult = data.TreeResult
// JSONTree shows JSON as a tree; values colored by type.
func JSONTree(c *ui.Context, text, doc string, selected *string, o JSONTreeOptions) JSONTreeResult {
dec := json.NewDecoder(strings.NewReader(doc))
dec.UseNumber()
root := node(dec, "")
if _, err := dec.Token(); err != io.EOF {
panic(fmt.Sprintf("code: JSONTree: after the value, %v", err))
}
// An empty container or lone value is its own row.
roots := root.Children
if len(roots) == 0 {
roots = []data.TreeNode{root}
}
return data.Tree(c, text, roots, selected, data.TreeOptions{Width: pixel.Or("JSONTree Width", o.Width, 200), Disabled: o.Disabled})
}
// node reads the next value under label; bad JSON panics.
func node(dec *json.Decoder, label string) data.TreeNode {
tok, err := dec.Token()
if err != nil {
panic(fmt.Sprintf("code: JSONTree: %v", err))
}
n := data.TreeNode{Label: label, Slot: pixel.Faint}
switch v := tok.(type) {
case json.Delim:
end := "]"
if v == '{' {
end = "}"
}
for i := 0; dec.More(); i++ {
key := strconv.Itoa(i)
if v == '{' {
t, _ := dec.Token()
key = t.(string)
}
n.Children = append(n.Children, node(dec, key))
}
dec.Token()
n.Value = fmt.Sprintf("%c%d%s", v, len(n.Children), end)
// An empty object or array still opens nothing.
if n.Children == nil {
n.Children = []data.TreeNode{}
}
case string:
n.Value, n.Slot = strconv.QuoteToGraphic(v), pixel.Good
case json.Number:
n.Value, n.Slot = v.String(), pixel.Warn
case bool:
n.Value, n.Slot = strconv.FormatBool(v), pixel.Info
case nil:
n.Value = "null"
}
return n
}
+102
View File
@@ -0,0 +1,102 @@
package code
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Level is how loud a log line is.
type Level int
const (
Debug Level = iota
Info
Warn
Error
)
var levels = []struct {
name string
slot pixel.Slot
}{{"DEBUG", pixel.Faint}, {"INFO", pixel.Ink}, {"WARN", pixel.Warn}, {"ERROR", pixel.Bad}}
type LogLine struct {
At time.Time
Level Level
Text string
}
type LogViewerOptions struct {
// Width and Height are dots; zeros mean 260 by 120.
Width, Height int
Disabled bool
}
type LogViewerResult struct {
Element *ui.Element
// Changed is the level filter.
Changed bool
}
type logKey struct{}
// LogViewer colors lines by level, hides those under *least, follows.
func LogViewer(c *ui.Context, text string, lines []LogLine, least *Level, o LogViewerOptions) LogViewerResult {
k := pixel.K(c)
if *least < Debug || *least > Error {
panic(fmt.Sprintf("code: LogViewer level %d", *least))
}
for _, l := range lines {
if l.Level < Debug || l.Level > Error {
panic(fmt.Sprintf("code: LogViewer line %q has level %d", l.Text, l.Level))
}
}
w, h := pixel.Or("LogViewer Width", o.Width, 260), pixel.Or("LogViewer Height", o.Height, 120)
names := make([]string, len(levels))
for i, l := range levels {
names[i] = l.name
}
var res LogViewerResult
res.Element = ui.Column(c).Width(k.D(w)).Gap(k.D(3)).Children(func() {
res.Changed = input.Segmented(c, text, names, (*int)(least), input.SegmentedOptions{Disabled: o.Disabled}).Changed
layout.Panel(c, layout.PanelOptions{Style: pixel.Sunken, Fill: pixel.Well, Padding: 2}, func() {
box := ui.Box(c).FillWidth().Height(k.D(h))
s := ui.Local(box, logKey{}, func() ui.ScrollState { return ui.ScrollState{} })
box.Children(func() {
layout.ScrollArea(c, s, layout.ScrollAreaOptions{Label: text, Follow: true, Disabled: o.Disabled}, func() {
ui.Column(c).FillWidth().Children(func() {
for _, l := range lines {
if l.Level >= *least {
row(c, k, l)
}
}
})
}).Element.Fill()
})
})
})
if res.Changed {
slog.Debug("8bit: log viewer", "label", text, "least", levels[*least].name)
}
return res
}
// row is a line: time, level, words in its slot.
func row(c *ui.Context, k pixel.Kit, l LogLine) {
lv, at := levels[l.Level], l.At.Format("15:04:05")
ui.Row(c).FillWidth().Gap(k.D(4)).Role(ui.RoleText).Label(at + " " + lv.name + " " + l.Text).Children(func() {
ui.Box(c).Size(k.D(56), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Micro(0, 4, at, pixel.Faint, 1)
g.Micro(34, 4, lv.name, lv.slot, 1)
})
pixel.Text(c, l.Text, pixel.TextOptions{Slot: lv.slot, Wrap: true}).Grow(1)
})
}
+59
View File
@@ -0,0 +1,59 @@
package code
import (
"fmt"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Bank is a stretch of memory and its use.
type Bank struct {
Name string
Used, Size int
}
type MemoryMapOptions struct {
// Columns of blocks; zero means 4.
Columns int
}
// MemoryMap draws each bank as a block filled by use.
func MemoryMap(c *ui.Context, text string, banks []Bank, o MemoryMapOptions) *ui.Element {
k := pixel.K(c)
cols := pixel.Or("MemoryMap Columns", o.Columns, 4)
for _, b := range banks {
name := strings.ToUpper(b.Name)
if b.Size <= 0 || b.Used < 0 || b.Used > b.Size || !pixel.MicroOK(name) || pixel.MicroWidth(name) > 36 {
panic(fmt.Sprintf("code: MemoryMap bank %+v", b))
}
}
return ui.Grid(c).Columns(cols).Width(k.D(cols * 44)).Gap(k.D(4)).Role(ui.RoleGroup).Label(text).Children(func() {
for _, b := range banks {
pct := b.Used * 100 / b.Size
label := fmt.Sprintf("%s, %d of %d KB, %d%%", b.Name, b.Used/1024, b.Size/1024, pct)
ui.Box(c).Size(k.D(40), k.D(45)).Role(ui.RoleImage).Label(label).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, 40, 32, pixel.Raised, pixel.Face)
g.Frame(3, 3, 34, 26, pixel.Sunken, pixel.Well)
fill := pixel.Good
switch {
case pct >= 80:
fill = pixel.Bad
case pct >= 50:
fill = pixel.Warn
}
// The fill rises from the floor, dithered on top.
if n := b.Used * 22 / b.Size; n > 0 {
g.Rect(5, 27-n, 30, n, fill)
g.Dither(5, 27-n, 30, 1, fill, pixel.Well, 8)
}
g.Micro(20-pixel.MicroWidth(strings.ToUpper(b.Name))/2, 34, strings.ToUpper(b.Name), pixel.Ink, 1)
s := fmt.Sprintf("%d%%", pct)
g.Micro(20-pixel.MicroWidth(s)/2, 40, s, pixel.Faint, 1)
})
}
})
}
+36
View File
@@ -0,0 +1,36 @@
package code
import (
"strings"
"testing"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestPaintColorsGoTokens(t *testing.T) {
src := "package main\n// hi\nfunc f() int { return len(\"x\") + 42 + nil }\nvar s = `a\nb` /* c\nd */"
slots := paint(src)
at := func(word string, nth int) pixel.Slot {
i := -1
for range nth + 1 {
i += 1 + strings.Index(src[i+1:], word)
}
return slots[i]
}
for _, tc := range []struct {
word string
nth int
want pixel.Slot
}{
{"package", 0, pixel.Accent}, {"main", 0, pixel.Ink}, {"// hi", 0, pixel.Faint}, {"func", 0, pixel.Accent},
{"f()", 0, pixel.Ink}, {"int", 0, pixel.Info}, {"len", 0, pixel.Info}, {`"x"`, 0, pixel.Good},
{"42", 0, pixel.Warn}, {"nil", 0, pixel.Magic}, {"+", 0, pixel.Ink}, {"b`", 0, pixel.Good}, {"d */", 0, pixel.Faint},
} {
if got := at(tc.word, tc.nth); got != tc.want {
t.Errorf("%q is %v, want %v", tc.word, got, tc.want)
}
}
if tidy("a\r\n\tb\n\n") != "a\n b" {
t.Fatalf("tidy = %q", tidy("a\r\n\tb\n\n"))
}
}
+105
View File
@@ -0,0 +1,105 @@
package code
import (
"fmt"
"log/slog"
"math"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type PaletteEditorOptions struct {
Disabled bool
}
type PaletteEditorResult struct {
Element *ui.Element
Changed bool
}
type tintKey struct{}
// PaletteEditor sets *pal in channel levels; a preview follows.
func PaletteEditor(c *ui.Context, text string, pal *pixel.Palette, o PaletteEditorOptions) PaletteEditorResult {
k := pixel.K(c)
if pal == nil {
panic("code: PaletteEditor needs a palette")
}
// Inside the scope everything draws in the edited colors.
in := pixel.Current(c)
in.Palette = pal
var res PaletteEditorResult
res.Element = ui.Row(c).Gap(k.D(8)).AlignItems(ui.Start)
sel := ui.Local(res.Element, tintKey{}, func() int { return int(pixel.Accent) })
res.Element.Children(func() {
ui.Column(c).Gap(k.D(4)).Children(func() {
pixel.Scope(c, in, func() {
// Paint runs after the scope: keep its kit now.
sk := pixel.K(c)
shell, base, st, _ := pixel.Pad(c, text, 8, int(pixel.Slots), 14, 14, sel, o.Disabled)
base.Description(fmt.Sprintf("%v, %s", pixel.Slot(*sel), hex(pal.Colors[*sel])))
at := *sel
shell.Draw(func(p *ui.Painter, r ui.Rect) {
g := sk.Canvas(p, r)
for s := range int(pixel.Slots) {
x, y := s%8*14, s/8*14
if s == at {
g.Rect(x, y, 14, 14, pixel.Accent)
}
g.Frame(x+1, y+1, 12, 12, pixel.Flat, pixel.Slot(s))
}
g.Finish(st)
})
})
col := &pal.Colors[*sel]
pixel.Text(c, fmt.Sprintf("%v %s", pixel.Slot(*sel), hex(*col)), pixel.TextOptions{Slot: pixel.Faint})
for _, ch := range []struct {
name string
v *uint8
}{{"R", &col.R}, {"G", &col.G}, {"B", &col.B}} {
// Levels 0–15, each worth 17.
level := math.Round(float64(*ch.v) / 17)
ui.Row(c).Gap(k.D(4)).AlignItems(ui.Center).Children(func() {
// Drawn, not named: the slider carries the name.
ui.Box(c).Size(k.D(pixel.Measure(ch.name)), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
k.Canvas(p, r).Text(0, 0, ch.name, pixel.Faint, 1)
})
if input.StepSlider(c, ch.name, &level, input.StepSliderOptions{Max: 15, Step: 1, Width: 96, Disabled: o.Disabled}).Changed {
*ch.v, res.Changed = uint8(level)*17, true
}
})
}
})
pixel.Scope(c, in, func() { preview(c, k.Msg("preview")) })
})
if res.Changed {
slog.Debug("8bit: palette editor", "label", text, "slot", pixel.Slot(*sel), "color", hex(pal.Colors[*sel]))
}
return res
}
// preview is a small window drawn in the edited colors.
func preview(c *ui.Context, text string) {
k := pixel.K(c)
ui.Box(c).Size(k.D(112), k.D(84)).Role(ui.RoleImage).Label(text).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Rect(0, 0, g.W, g.H, pixel.Back)
g.Frame(0, 0, 112, 84, pixel.Window, pixel.Face)
g.Rect(3, 3, 106, 12, pixel.Accent)
g.Text(6, 3, "HELLO!", pixel.AccentInk, 1)
g.Text(6, 18, "PRESS START", pixel.Ink, 1)
g.Text(6, 30, "HI 9999", pixel.Faint, 1)
g.Frame(6, 44, 46, 14, pixel.Raised, pixel.Face)
g.Text(12, 45, "OK", pixel.Ink, 1)
g.Frame(58, 44, 46, 14, pixel.Sunken, pixel.Well)
for i, s := range []pixel.Slot{pixel.Good, pixel.Warn, pixel.Bad, pixel.Info, pixel.Magic, pixel.AccentLit} {
g.Rect(6+i*17, 66, 15, 10, s)
}
})
}
// hex prints a color as #RRGGBB.
func hex(col ui.Color) string { return fmt.Sprintf("#%02X%02X%02X", col.R, col.G, col.B) }
+113
View File
@@ -0,0 +1,113 @@
package code
import (
"encoding/json"
"slices"
"strings"
"testing"
"time"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestDiffTakesOutBeforePuttingIn(t *testing.T) {
got := diff([]string{"x", "y", "z"}, []string{"x", "q", "z", "w"})
want := []change{{' ', "x", 1, 1}, {'-', "y", 2, 0}, {'+', "q", 0, 2}, {' ', "z", 3, 3}, {'+', "w", 0, 4}}
if !slices.Equal(got, want) {
t.Fatalf("diff = %v, want %v", got, want)
}
if got := diff(nil, []string{"a"}); !slices.Equal(got, []change{{'+', "a", 0, 1}}) {
t.Fatalf("diff from nothing = %v", got)
}
}
func TestStepsWalkEveryLine(t *testing.T) {
const w = 7
for a := range w * w {
for b := range w * w {
x0, y0, x1, y1 := a%w, a/w, b%w, b/w
var path []int
steps(x0, y0, x1, y1, func(i int) { path = append(path, i) }, w)
if path[0] != a || path[len(path)-1] != b || len(path) != abs(x1-x0)+abs(y1-y0)+1 {
t.Fatalf("%d,%d to %d,%d walked %v", x0, y0, x1, y1, path)
}
for i := 1; i < len(path); i++ {
if d := abs(path[i]%w-path[i-1]%w) + abs(path[i]/w-path[i-1]/w); d != 1 {
t.Fatalf("%d,%d to %d,%d jumps in %v", x0, y0, x1, y1, path)
}
}
}
}
}
func TestFloodFillsOnlyItsRun(t *testing.T) {
// A wall of 1s splits the left run off.
cells := []int{-1, 1, -1, -1, 1, -1, -1, 1, -1}
if !flood(cells, 3, 3, 0, 5) || !slices.Equal(cells, []int{5, 1, -1, 5, 1, -1, 5, 1, -1}) {
t.Fatalf("flood = %v", cells)
}
if flood(cells, 3, 3, 0, 5) {
t.Fatal("filling a run with its own slot changed it")
}
}
func TestPercentileNearestRank(t *testing.T) {
var ds []time.Duration
for i := 10; i >= 1; i-- {
ds = append(ds, time.Duration(i))
}
if percentile(ds, 50) != 5 || percentile(ds, 95) != 10 || percentile(ds, 1) != 1 || percentile(nil, 50) != 0 {
t.Fatal("percentiles are not nearest-rank")
}
if ds[0] != 10 {
t.Fatal("percentile sorted its input")
}
}
func TestJSONNodesKeepOrderAndTypes(t *testing.T) {
dec := json.NewDecoder(strings.NewReader(`{"b": 1.50, "a": "中\n", "c": [true, null], "d": {}}`))
dec.UseNumber()
n := node(dec, "")
got := []data.TreeNode{}
for _, ch := range n.Children {
got = append(got, data.TreeNode{Label: ch.Label, Value: ch.Value, Slot: ch.Slot})
}
want := []data.TreeNode{
{Label: "b", Value: "1.50", Slot: pixel.Warn}, {Label: "a", Value: `"中\n"`, Slot: pixel.Good},
{Label: "c", Value: "[2]", Slot: pixel.Faint}, {Label: "d", Value: "{0}", Slot: pixel.Faint},
}
if n.Value != "{4}" || !slices.EqualFunc(got, want, func(a, b data.TreeNode) bool { return a.Label == b.Label && a.Value == b.Value && a.Slot == b.Slot }) {
t.Fatalf("nodes %+v", got)
}
arr := n.Children[2].Children
if len(arr) != 2 || arr[0].Value != "true" || arr[0].Slot != pixel.Info || arr[1].Value != "null" || arr[1].Label != "1" {
t.Fatalf("array %+v", arr)
}
if d := n.Children[3].Children; d == nil || len(d) != 0 {
t.Fatal("an empty object must still be a branch")
}
}
func TestVariableKinds(t *testing.T) {
for _, tc := range []struct {
v Var
want pixel.Slot
}{
{Var{Type: "string", Value: `"a"`}, pixel.Good}, {Var{Type: "bool", Value: "true"}, pixel.Info},
{Var{Type: "int", Value: "1"}, pixel.Warn}, {Var{Type: "uint8", Value: "1"}, pixel.Warn},
{Var{Type: "float64", Value: "1.5"}, pixel.Warn}, {Var{Type: "rune", Value: "65"}, pixel.Warn},
{Var{Type: "*Enemy", Value: "nil"}, pixel.Faint}, {Var{Type: "Hero", Value: "{…}"}, pixel.Faint},
} {
if got := kind(tc.v); got != tc.want {
t.Errorf("%+v is %v, want %v", tc.v, got, tc.want)
}
}
ns := nodes([]Var{{Name: "hero", Type: "Hero", Value: "{…}", Fields: []Var{{Name: "HP", Type: "int", Value: "3"}}}})
if ns[0].Label != "hero: Hero" || ns[0].Children[0].Label != "HP: int" || ns[0].Children[0].Value != "3" {
t.Fatalf("nodes %+v", ns)
}
if glyph('A') != 'A' || glyph(0x7F) != '.' || glyph(0x1F) != '.' || glyph(' ') != ' ' {
t.Fatal("glyph does not keep printable ASCII only")
}
}
+252
View File
@@ -0,0 +1,252 @@
package code
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Tool is what a press on the canvas does.
type Tool int
const (
Pen Tool = iota
Fill
Erase
Pick
)
type PixelEditorOptions struct {
// Zoom is dots per sprite dot, 2–16; zero means 8.
Zoom int
Disabled bool
}
type PixelEditorResult struct {
Element *ui.Element
// Changed is the art; Copied is COPY.
Changed, Copied bool
}
type brushKey struct{}
// brush is tool, slot, cursor, last dab, grid, art size.
type brush struct {
tool, slot, cur, last, w, h int
grid bool
}
// PixelEditor paints *art, one frame of string art.
func PixelEditor(c *ui.Context, text string, art *string, o PixelEditorOptions) PixelEditorResult {
k := pixel.K(c)
z := pixel.Or("PixelEditor Zoom", o.Zoom, 8)
sp := pixel.MustSprite(*art)
if z < 2 || z > 16 || sp.Frames() != 1 {
panic(fmt.Sprintf("code: PixelEditor of %d frames at zoom %d", sp.Frames(), z))
}
w, h := sp.W, sp.H
// cells hold a slot, or -1 where clear.
cells := make([]int, w*h)
for i := range cells {
cells[i] = -1
if s, ok := sp.At(0, i%w, i/w, pixel.Ink); ok {
cells[i] = int(s)
}
}
var res PixelEditorResult
res.Element = ui.Row(c).Gap(k.D(6)).AlignItems(ui.Start)
b := ui.Local(res.Element, brushKey{}, func() brush { return brush{slot: int(pixel.Ink), last: -1, grid: true} })
// New art drops a dab measured on the old.
if b.w != w || b.h != h {
b.w, b.h, b.last, b.cur = w, h, -1, min(b.cur, w*h-1)
}
apply := func(i int) bool {
switch Tool(b.tool) {
case Pen, Erase:
v := b.slot
if Tool(b.tool) == Erase {
v = -1
}
if cells[i] == v {
return false
}
cells[i] = v
return true
case Fill:
return flood(cells, w, h, i, b.slot)
}
// The picker takes a slot, then hands back the pen.
if cells[i] >= 0 {
b.slot, b.tool = cells[i], int(Pen)
slog.Debug("8bit: pixel editor picked", "label", text, "slot", pixel.Slot(b.slot))
}
return false
}
res.Element.Children(func() {
shell, base, st, picked := pixel.Pad(c, text, w, w*h, z, z, &b.cur, o.Disabled)
if !base.IsDisabled() {
// Pen and eraser lay dots along a drag.
tool := Tool(b.tool)
if x, y, _ := base.PointerPosition(); base.Pressed() && (tool == Pen || tool == Erase) {
cx, cy := int(x/k.Dot())/z, int(y/k.Dot())/z
if i := cy*w + cx; cx >= 0 && cy >= 0 && cx < w && cy < h && i != b.last {
// A fast drag still lays every dot between.
from := i
if b.last >= 0 {
from = b.last
}
steps(from%w, from/w, cx, cy, func(j int) { res.Changed = apply(j) || res.Changed }, w)
b.last = i
}
} else {
b.last = -1
}
if picked >= 0 {
res.Changed = apply(picked) || res.Changed
}
}
v := cells[b.cur]
say := "clear"
if v >= 0 {
say = pixel.Slot(v).String()
}
base.Description(fmt.Sprintf("%d, %d: %s", b.cur%w, b.cur/w, say))
canvas(shell, k, st, cells, w, z, b.cur, b.grid)
tools(c, k, text, b, art, o.Disabled, &res)
})
if res.Changed {
*art = pixel.SpriteOf(w, h, func(x, y int) (pixel.Slot, bool) {
v := cells[y*w+x]
return pixel.Slot(max(v, 0)), v >= 0
}).Art()
slog.Debug("8bit: pixel editor painted", "label", text, "tool", b.tool, "slot", pixel.Slot(b.slot))
}
return res
}
// canvas paints cells, checkers where clear, grid and cursor.
func canvas(shell *ui.Element, k pixel.Kit, st pixel.State, cells []int, w, z, cur int, grid bool) {
shell.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i, v := range cells {
x, y := i%w*z, i/w*z
if v < 0 {
g.Rect(x, y, z, z, pixel.Lit)
g.Rect(x, y, z/2, z/2, pixel.Face)
g.Rect(x+z/2, y+z/2, z-z/2, z-z/2, pixel.Face)
continue
}
g.Rect(x, y, z, z, pixel.Slot(v))
}
if grid {
for x := z; x < g.W; x += z {
g.VLine(x, 0, g.H, pixel.Shade)
}
for y := z; y < g.H; y += z {
g.HLine(0, y, g.W, pixel.Shade)
}
}
if st.Focus {
x, y := cur%w*z, cur/w*z
g.Rect(x, y, z, 1, pixel.Accent)
g.Rect(x, y+z-1, z, 1, pixel.Accent)
g.Rect(x, y, 1, z, pixel.Accent)
g.Rect(x+z-1, y, 1, z, pixel.Accent)
}
g.Finish(st)
})
}
// tools are the tool switch, slot pad, GRID and COPY.
func tools(c *ui.Context, k pixel.Kit, text string, b *brush, art *string, disabled bool, res *PixelEditorResult) {
ui.Column(c).Gap(k.D(4)).Children(func() {
names := []string{k.Msg("pen"), k.Msg("fill"), k.Msg("erase"), k.Msg("pick")}
if input.Segmented(c, k.Msg("tool"), names, &b.tool, input.SegmentedOptions{Disabled: disabled}).Changed {
slog.Debug("8bit: pixel editor tool", "label", text, "tool", names[b.tool])
}
old := b.slot
// The pad's cursor is the slot laid down.
shell, base, st, _ := pixel.Pad(c, k.Msg("color"), 8, int(pixel.Slots), 10, 10, &b.slot, disabled)
base.Description(pixel.Slot(b.slot).String())
if b.slot != old {
slog.Debug("8bit: pixel editor slot", "label", text, "slot", pixel.Slot(b.slot))
}
at := b.slot
shell.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for s := range int(pixel.Slots) {
x, y := s%8*10, s/8*10
if s == at {
g.Rect(x, y, 10, 10, pixel.Accent)
}
g.Frame(x+1, y+1, 8, 8, pixel.Flat, pixel.Slot(s))
}
g.Finish(st)
})
if input.Toggle(c, k.Msg("grid"), &b.grid, input.ToggleOptions{Disabled: disabled}).Changed {
slog.Debug("8bit: pixel editor grid", "label", text, "on", b.grid)
}
if input.Button(c, k.Msg("copy"), input.ButtonOptions{Disabled: disabled}).Clicked {
c.WriteClipboard(*art)
res.Copied = true
slog.Debug("8bit: pixel editor copied", "label", text)
}
})
}
// flood lays slot over the like cells around i.
func flood(cells []int, w, h, i, slot int) bool {
from := cells[i]
if from == slot {
return false
}
stack := []int{i}
for len(stack) > 0 {
j := stack[len(stack)-1]
stack = stack[:len(stack)-1]
if cells[j] != from {
continue
}
cells[j] = slot
x, y := j%w, j/w
for _, n := range [4][2]int{{x - 1, y}, {x + 1, y}, {x, y - 1}, {x, y + 1}} {
if n[0] >= 0 && n[1] >= 0 && n[0] < w && n[1] < h {
stack = append(stack, n[1]*w+n[0])
}
}
}
return true
}
// steps calls at for each cell on a line.
func steps(x0, y0, x1, y1 int, at func(i int), w int) {
dx, dy := abs(x1-x0), -abs(y1-y0)
sx, sy := sign(x1-x0), sign(y1-y0)
for e := dx + dy; ; {
at(y0*w + x0)
if x0 == x1 && y0 == y1 {
return
}
if e2 := 2 * e; e2 >= dy {
e, x0 = e+dy, x0+sx
} else {
e, y0 = e+dx, y0+sy
}
}
}
func abs(v int) int { return max(v, -v) }
func sign(v int) int {
switch {
case v > 0:
return 1
case v < 0:
return -1
}
return 0
}
+70
View File
@@ -0,0 +1,70 @@
package code
import (
"fmt"
"strings"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Span is timed work, starting inside its parent's time.
type Span struct {
Name string
Start, Length time.Duration
Children []Span
}
type ProfilerBarsOptions struct {
// Width is in dots; zero means 240.
Width int
}
var depths = []pixel.Slot{pixel.Accent, pixel.Info, pixel.Good, pixel.Warn, pixel.Magic}
// ProfilerBars stacks spans under their parents, each bar its time.
func ProfilerBars(c *ui.Context, text string, root Span, o ProfilerBarsOptions) *ui.Element {
k := pixel.K(c)
w := pixel.Or("ProfilerBars Width", o.Width, 240)
if root.Length <= 0 {
panic(fmt.Sprintf("code: ProfilerBars root %q lasts %v", root.Name, root.Length))
}
var said []string
rows := check(root, &said)
x := func(d time.Duration) int { return int(int64(w) * int64(d-root.Start) / int64(root.Length)) }
return ui.Box(c).Size(k.D(w), k.D(rows*pixel.LineHeight)).Role(ui.RoleImage).Label(text).Description(strings.Join(said, "; ")).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
var bar func(s Span, depth int)
bar = func(s Span, depth int) {
x0, y := x(s.Start), depth*pixel.LineHeight
bw := max(1, x(s.Start+s.Length)-x0)
g.Rect(x0, y+1, bw, pixel.LineHeight-2, depths[depth%len(depths)])
// Names show where they fit, times when they also fit.
for _, s := range []string{fmt.Sprintf("%s %.1fms", s.Name, ms(s.Length)), s.Name} {
if pixel.Measure(s)+4 <= bw {
g.Text(x0+2, y, s, pixel.AccentInk, 1)
break
}
}
for _, ch := range s.Children {
bar(ch, depth+1)
}
}
bar(root, 0)
})
}
// check counts rows; a child outside its parent panics.
func check(s Span, said *[]string) int {
*said = append(*said, fmt.Sprintf("%s %.1f ms", s.Name, ms(s.Length)))
rows := 1
for _, ch := range s.Children {
if ch.Length < 0 || ch.Start < s.Start || ch.Start+ch.Length > s.Start+s.Length {
panic(fmt.Sprintf("code: ProfilerBars span %q is outside %q", ch.Name, s.Name))
}
rows = max(rows, 1+check(ch, said))
}
return rows
}
+104
View File
@@ -0,0 +1,104 @@
package code
import (
"fmt"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// CPU is a 6502's registers; P holds the flags.
type CPU struct {
A, X, Y, SP, P uint8
PC uint16
}
type RegisterViewOptions struct{}
type regKey struct{}
// seen is the CPU last shown and bits it flipped.
type seen struct {
cpu, flipped CPU
}
const flags = "NV-BDIZC"
// RegisterView shows registers and flags; the last change lights.
func RegisterView(c *ui.Context, text string, cpu CPU, o RegisterViewOptions) *ui.Element {
k := pixel.K(c)
col := ui.Column(c).Gap(k.D(1)).Role(ui.RoleGroup).Label(text)
sn := ui.Local(col, regKey{}, func() seen { return seen{cpu: cpu} })
if cpu != sn.cpu {
was := sn.cpu
sn.cpu, sn.flipped = cpu, CPU{A: was.A ^ cpu.A, X: was.X ^ cpu.X, Y: was.Y ^ cpu.Y, SP: was.SP ^ cpu.SP, P: was.P ^ cpu.P, PC: was.PC ^ cpu.PC}
}
fl := sn.flipped
return col.Children(func() {
for _, r := range []struct {
name string
v, change uint16
wide bool
}{
{"A", uint16(cpu.A), uint16(fl.A), false}, {"X", uint16(cpu.X), uint16(fl.X), false}, {"Y", uint16(cpu.Y), uint16(fl.Y), false},
{"SP", uint16(cpu.SP), uint16(fl.SP), false}, {"PC", cpu.PC, fl.PC, true},
} {
digits := 2
if r.wide {
digits = 4
}
hexed := fmt.Sprintf("$%0*X", digits, r.v)
ui.Box(c).Size(k.D(120), k.D(pixel.LineHeight)).Role(ui.RoleText).Label(r.name + " " + hexed).Draw(func(p *ui.Painter, rc ui.Rect) {
g := k.Canvas(p, rc)
g.Micro(0, 4, r.name, pixel.Faint, 1)
ink := pixel.Ink
if r.change != 0 {
ink = pixel.Accent
}
g.Text(12, 0, hexed, ink, 1)
if !r.wide {
bits(g, 52, uint8(r.v), uint8(r.change))
}
})
}
set := []string{}
for i := range 8 {
if cpu.P&(0x80>>i) != 0 && flags[i] != '-' {
set = append(set, string(flags[i]))
}
}
ui.Box(c).Size(k.D(8*9), k.D(14)).Role(ui.RoleText).Label(fmt.Sprintf("P $%02X", cpu.P)).Description(strings.Join(set, " ")).Draw(func(p *ui.Painter, rc ui.Rect) {
g := k.Canvas(p, rc)
for i := range 8 {
x, on, flip := i*9, cpu.P&(0x80>>i) != 0, fl.P&(0x80>>i) != 0
fill, ink := pixel.Well, pixel.Faint
if on {
fill, ink = pixel.Good, pixel.AccentInk
}
edge := pixel.Shade
if flip {
edge = pixel.Accent
}
g.Rect(x, 0, 8, 14, edge)
g.Rect(x+1, 1, 6, 12, fill)
g.Micro(x+3-1, 5, string(flags[i]), ink, 1)
}
})
})
}
// bits draws v's eight bits; flipped ones light.
func bits(g pixel.Canvas, x int, v, flipped uint8) {
for i := range 8 {
b, ink := "0", pixel.Faint
if v&(0x80>>i) != 0 {
b, ink = "1", pixel.Ink
}
if flipped&(0x80>>i) != 0 {
ink = pixel.Accent
}
g.Micro(x+i*4+i/4*2, 4, b, ink, 1)
}
}
+48
View File
@@ -0,0 +1,48 @@
package code
import (
"fmt"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
// Shortcut is an action and its keys, pressed together.
type Shortcut struct {
Action string
Keys []string
}
type ShortcutListOptions struct {
// Width is in dots; zero means 220.
Width int
}
// ShortcutList pairs actions with their keycaps.
func ShortcutList(c *ui.Context, label string, items []Shortcut, o ShortcutListOptions) *ui.Element {
k := pixel.K(c)
for _, it := range items {
if len(it.Keys) == 0 {
panic(fmt.Sprintf("code: ShortcutList action %q has no keys", it.Action))
}
}
return ui.Column(c).Width(k.D(pixel.Or("ShortcutList Width", o.Width, 220))).Gap(k.D(3)).Role(ui.RoleGroup).Label(label).Children(func() {
for _, it := range items {
ui.Row(c).FillWidth().Gap(k.D(4)).AlignItems(ui.Center).Role(ui.RoleText).Label(it.Action + ", " + strings.Join(it.Keys, "+")).Children(func() {
pixel.Text(c, it.Action, pixel.TextOptions{}).Grow(1)
// Long chords wrap rather than run off the edge.
ui.Row(c).Wrap().Gap(k.D(2)).AlignItems(ui.Center).Children(func() {
for i, key := range it.Keys {
if i > 0 {
pixel.Text(c, "+", pixel.TextOptions{Slot: pixel.Faint})
}
text.Kbd(c, key, text.KbdOptions{})
}
})
})
}
})
}
+108
View File
@@ -0,0 +1,108 @@
package code
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type SpriteSheetOptions struct {
// Scale is dots per sprite dot, 1–4; zero means 2.
Scale int
// Columns of frames; zero means 4.
Columns int
// FPS is the playback speed, 4–15; zero means 6.
FPS int
Disabled bool
}
type SpriteSheetResult struct {
Element *ui.Element
// Changed is a frame chosen or play pressed.
Changed bool
}
type sheetKey struct{}
// sheet is the frame play began on, and when.
type sheet struct {
from int
since time.Time
}
// SpriteSheet lays frames in a grid; playing steps through them.
func SpriteSheet(c *ui.Context, text string, sp *pixel.Sprite, frame *int, playing *bool, o SpriteSheetOptions) SpriteSheetResult {
k := pixel.K(c)
n, s := sp.Frames(), pixel.Or("SpriteSheet Scale", o.Scale, 2)
fps, cols := pixel.Or("SpriteSheet FPS", o.FPS, 6), min(n, pixel.Or("SpriteSheet Columns", o.Columns, 4))
if s > 4 || *frame < 0 || *frame >= n {
panic(fmt.Sprintf("code: SpriteSheet frame %d of %d at scale %d", *frame, n, s))
}
if fps < 4 || fps > 15 {
panic(fmt.Sprintf("code: SpriteSheet FPS %d outside 4–15", fps))
}
var res SpriteSheetResult
res.Element = ui.Row(c).Gap(k.D(8)).AlignItems(ui.Start)
sh := ui.Local(res.Element, sheetKey{}, func() sheet { return sheet{} })
// Playback is the content asked for, so it keeps time.
switch {
case !*playing:
sh.since = time.Time{}
case sh.since.IsZero():
sh.from, sh.since = *frame, k.Now()
}
if *playing {
per := time.Second / time.Duration(fps)
m := int(k.Now().Sub(sh.since) / per)
*frame = (sh.from + m) % n
k.At(c, sh.since.Add(time.Duration(m+1)*per))
}
cw, ch := sp.W*s+4, sp.H*s+4
res.Element.Children(func() {
old := *frame
shell, base, st, _ := pixel.Pad(c, text, cols, n, cw, ch, frame, o.Disabled)
base.Description(fmt.Sprintf("%d / %d", *frame+1, n))
if *frame != old {
res.Changed, sh.from, sh.since = true, *frame, k.Now()
}
at := *frame
shell.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i := range n {
x, y := i%cols*cw, i/cols*ch
g.Frame(x, y, cw, ch, pixel.Flat, pixel.Well)
if i == at {
g.Cursor(x, y, cw, ch)
}
g.Sprite(x+2, y+2, sp, i, pixel.Ink, s)
}
g.Finish(st)
})
ui.Column(c).Gap(k.D(4)).AlignItems(ui.Center).Children(func() {
// The preview doubles the grid's scale, up to 4.
big := min(4, 2*s)
ui.Box(c).Size(k.D(sp.W*big+8), k.D(sp.H*big+8)).Role(ui.RoleImage).Label(fmt.Sprintf("%s %d", text, at+1)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Sunken, pixel.Well)
g.Sprite(4, 4, sp, at, pixel.Ink, big)
})
sym := icon.Play
if *playing {
sym = icon.Pause
}
if input.Toggle(c, "", playing, input.ToggleOptions{Icon: sym, Label: k.Msg("play"), Disabled: o.Disabled}).Changed {
res.Changed = true
}
})
})
if res.Changed {
slog.Debug("8bit: sprite sheet", "label", text, "frame", *frame, "playing", *playing)
}
return res
}
+104
View File
@@ -0,0 +1,104 @@
package code
import (
"log/slog"
"unicode/utf8"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type TerminalOptions struct {
// Prompt starts the input line; zero means "$".
Prompt string
// Width and Height are dots; zeros mean 240 by 120.
Width, Height int
Disabled bool
}
type TerminalResult struct {
Element *ui.Element
// Ran is the command Enter sent, else "".
Ran string
Changed bool
}
type termKey struct{}
// term is the scroll, history, how far back, the draft.
type term struct {
scroll ui.ScrollState
history []string
back int
draft string
}
// Terminal prints lines over a prompt; Enter runs, arrows recall.
func Terminal(c *ui.Context, text string, lines []string, input *string, o TerminalOptions) TerminalResult {
k := pixel.K(c)
w, h := pixel.Or("Terminal Width", o.Width, 240), pixel.Or("Terminal Height", o.Height, 120)
prompt := o.Prompt
if prompt == "" {
prompt = "$"
}
var res TerminalResult
res.Element = layout.Panel(c, layout.PanelOptions{Style: pixel.Sunken, Fill: pixel.Well, Padding: 2}, func() {
box := ui.Column(c).Size(k.D(w), k.D(h)).Gap(k.D(2))
tm := ui.Local(box, termKey{}, func() term { return term{} })
box.Children(func() {
layout.ScrollArea(c, &tm.scroll, layout.ScrollAreaOptions{Label: text, Follow: true, Disabled: o.Disabled}, func() {
ui.Column(c).FillWidth().Children(func() {
for _, l := range lines {
pixel.Text(c, l, pixel.TextOptions{Wrap: true})
}
})
}).Element.Grow(1).FillWidth()
row, off := ui.Row(c).FillWidth().Gap(k.D(4)), false
row.Children(func() {
pixel.Text(c, prompt, pixel.TextOptions{Slot: pixel.Good})
ed := pixel.Editor(c, input, pixel.EditorOptions{Label: k.Msg("command"), Disabled: o.Disabled})
ed.Element.Grow(1)
res.Changed, off = ed.Changed, ed.Input.IsDisabled()
if ed.Submitted && !off {
// The editor drew the old line; draw the empty one.
c.Invalidate()
res.Ran, *input, tm.back = *input, "", 0
if n := len(tm.history); res.Ran != "" && (n == 0 || tm.history[n-1] != res.Ran) {
tm.history = append(tm.history, res.Ran)
}
}
})
// Up and Down are history here, taken from the editor.
if up, down := row.Shortcut(0, ui.KeyUp), row.Shortcut(0, ui.KeyDown); (up || down) && !off {
res.Changed = recall(tm, input, up) || res.Changed
}
})
})
if res.Ran != "" || res.Changed {
slog.Debug("8bit: terminal", "label", text, "ran", res.Ran != "", "runes", utf8.RuneCountInString(*input))
}
return res
}
// recall steps through history; past the newest is the draft.
func recall(tm *term, input *string, up bool) bool {
n := len(tm.history)
switch {
case up && tm.back < n:
if tm.back == 0 {
tm.draft = *input
}
tm.back++
case !up && tm.back > 0:
tm.back--
default:
return false
}
*input = tm.draft
if tm.back > 0 {
*input = tm.history[n-tm.back]
}
return true
}
+293
View File
@@ -0,0 +1,293 @@
package code_test
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/code"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestBreakpointsToggleByClickAndEnter(t *testing.T) {
wd := newWorld()
src, marks, changed := "a := 1\nb := 2\nc := 3", map[int]bool{}, false
tt := view(400, 200, wd, func(c *ui.Context) {
changed = code.Breakpoints(c, "Code", src, marks, code.BreakpointsOptions{}).Changed || changed
})
r := find(t, tt, "Code")
x, y, _, _ := dots(r)
tt.ClickAt(r.X+20, r.Y+float32(2*pixel.LineHeight)+12)
if !marks[2] || !changed || at(tt.Image(), x+5, y+pixel.LineHeight+6) != pixel.Bad {
t.Fatalf("a click on line 2: %v", marks)
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
tt.ClickAt(r.X+20, r.Y+float32(2*pixel.LineHeight)+12)
if !marks[3] || marks[2] || len(marks) != 1 {
t.Fatalf("Down, Enter, then line 2 again: %v", marks)
}
// Fewer lines keep the cursor on the code.
src = "a := 1"
tt.Frame()
tt.Key(0, ui.KeyEnter)
if !marks[1] {
t.Fatalf("after the code shrank: %v", marks)
}
mustPanic(t, "no marks", func() {
view(400, 200, newWorld(), func(c *ui.Context) { code.Breakpoints(c, "C", "x", nil, code.BreakpointsOptions{}) })
})
}
func TestCommandHistoryReruns(t *testing.T) {
wd := newWorld()
sel := 0
var got code.CommandHistoryResult
tt := view(400, 200, wd, func(c *ui.Context) {
if r := code.CommandHistory(c, "History", []string{"ls", "run"}, &sel, code.CommandHistoryOptions{}); r.Changed || r.Rerun {
got = r
}
})
click(t, tt, "run")
if sel != 1 || !got.Changed {
t.Fatalf("clicking run: %d, %+v", sel, got)
}
got = code.CommandHistoryResult{}
tt.Key(0, ui.KeyEnter)
if !got.Rerun {
t.Fatal("Enter did not rerun")
}
}
func TestShortcutListNamesChords(t *testing.T) {
tt := view(400, 200, newWorld(), func(c *ui.Context) {
code.ShortcutList(c, "Keys", []code.Shortcut{{Action: "Save", Keys: []string{"Ctrl", "S"}}}, code.ShortcutListOptions{})
})
find(t, tt, "Save, Ctrl+S")
mustPanic(t, "an action without keys", func() {
view(400, 200, newWorld(), func(c *ui.Context) {
code.ShortcutList(c, "K", []code.Shortcut{{Action: "Nothing"}}, code.ShortcutListOptions{})
})
})
}
func TestDebugGridRulesItsContent(t *testing.T) {
tt := view(400, 200, newWorld(), func(c *ui.Context) {
code.DebugGrid(c, code.DebugGridOptions{Step: 4}, func() {
ui.Box(c).Size(40, 40).Label("thing")
})
})
x, y, _, _ := dots(find(t, tt, "thing"))
img := tt.Image()
if at(img, x, y) != pixel.Accent || at(img, x+4, y+2) != pixel.Accent || at(img, x+1, y+1) == pixel.Accent || at(img, x-10, y-10) != pixel.Shade {
t.Fatal("no grid every four dots, or no rulers")
}
for _, step := range []int{1, 33} {
mustPanic(t, "a step outside 2–32", func() {
view(400, 200, newWorld(), func(c *ui.Context) { code.DebugGrid(c, code.DebugGridOptions{Step: step}, func() {}) })
})
}
}
func TestPixelEditorPaintsFillsErasesPicks(t *testing.T) {
wd := newWorld()
art, changed := "....\n....\n....\n", false
tt := view(500, 300, wd, func(c *ui.Context) {
changed = code.PixelEditor(c, "Art", &art, code.PixelEditorOptions{Zoom: 8}).Changed || changed
})
r := find(t, tt, "Art")
cell := func(cx, cy int) (float32, float32) { return r.X + float32(16*cx+8), r.Y + float32(16*cy+8) }
// A drag paints every cell it crosses, even jumping.
tt.Press(cell(0, 0))
tt.Move(cell(3, 0))
tt.Release(cell(3, 0))
if art != "kkkk\n....\n....\n" || !changed {
t.Fatalf("a drag across row 0 left %q", art)
}
click(t, tt, "ERASE")
tt.ClickAt(cell(1, 0))
if art != "k.kk\n....\n....\n" {
t.Fatalf("erasing left %q", art)
}
// The erased dot touches the clear rows: one run.
click(t, tt, "FILL")
tt.ClickAt(cell(0, 2))
if art != "kkkk\nkkkk\nkkkk\n" {
t.Fatalf("filling the clear run left %q", art)
}
click(t, tt, "COLOR")
tt.Key(0, ui.KeyHome)
for range int(pixel.Good) {
tt.Key(0, ui.KeyRight)
}
click(t, tt, "PEN")
tt.ClickAt(cell(1, 0))
// Back to Ink; PICK takes Good, the pen lays it.
click(t, tt, "COLOR")
tt.Key(0, ui.KeyHome)
click(t, tt, "PICK")
tt.ClickAt(cell(1, 0))
tt.ClickAt(cell(2, 2))
if art != "kgkk\nkkkk\nkkgk\n" {
t.Fatalf("pick then paint left %q", art)
}
click(t, tt, "COPY")
if tt.Clipboard() != art {
t.Fatalf("COPY gave %q", tt.Clipboard())
}
for name, a := range map[string]string{"two frames": "k\n\nk", "bad letter": "z"} {
mustPanic(t, name, func() {
view(500, 300, newWorld(), func(c *ui.Context) { code.PixelEditor(c, "A", &a, code.PixelEditorOptions{}) })
})
}
}
func TestPixelEditorKeysAndGrid(t *testing.T) {
wd := newWorld()
art := "...\n...\n"
tt := view(500, 300, wd, func(c *ui.Context) {
code.PixelEditor(c, "Art", &art, code.PixelEditorOptions{Zoom: 4})
})
// The click paints the middle; keys paint above it.
click(t, tt, "Art")
tt.Key(0, ui.KeyHome)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyEnter)
if art != ".k.\n.k.\n" {
t.Fatalf("Home, Right, Enter left %q", art)
}
x, y, _, _ := dots(find(t, tt, "Art"))
if at(tt.Image(), x+4, y+1) != pixel.Shade {
t.Fatal("no grid line between cells")
}
click(t, tt, "GRID")
if at(tt.Image(), x+4, y+1) == pixel.Shade {
t.Fatal("GRID off still draws lines")
}
}
func TestPaletteEditorStepsChannelsAndPreviews(t *testing.T) {
wd := newWorld()
pal, changed := *pixel.SpringDay, false
tt := view(600, 300, wd, func(c *ui.Context) {
changed = code.PaletteEditor(c, "Copy", &pal, code.PaletteEditorOptions{}).Changed || changed
})
click(t, tt, "R")
tt.Key(0, ui.KeyEnd)
if pal.Colors[pixel.Accent].R != 255 || !changed || pixel.SpringDay.Colors[pixel.Accent].R == 255 {
t.Fatalf("End on R gave %v; the palette itself must not change", pal.Colors[pixel.Accent])
}
tt.Key(0, ui.KeyLeft)
if pal.Colors[pixel.Accent].R != 238 {
t.Fatalf("a step down from 255 is 238, got %d", pal.Colors[pixel.Accent].R)
}
// The preview's title bar wears the edited Accent.
x, y, _, _ := dots(find(t, tt, "PREVIEW"))
img, want := tt.Image(), pal.Colors[pixel.Accent]
if c := img.RGBAAt(2*(x+10), 2*(y+6)); c.R != want.R || c.G != want.G || c.B != want.B {
t.Fatalf("preview title %v, want %v", c, want)
}
mustPanic(t, "no palette", func() {
view(600, 300, newWorld(), func(c *ui.Context) { code.PaletteEditor(c, "P", nil, code.PaletteEditorOptions{}) })
})
}
var spin = pixel.MustSprite("g\n\nr\n\nb\n\ny")
func TestSpriteSheetPlaysFromTheChosenFrame(t *testing.T) {
wd := newWorld()
frame, playing := 0, false
tt := view(600, 300, wd, func(c *ui.Context) {
code.SpriteSheet(c, "Sheet", spin, &frame, &playing, code.SpriteSheetOptions{FPS: 4})
})
click(t, tt, "PLAY")
if !playing || frame != 0 {
t.Fatalf("PLAY: playing %v at %d", playing, frame)
}
later(tt, wd, 250*time.Millisecond)
later(tt, wd, 250*time.Millisecond)
if frame != 2 {
t.Fatalf("half a second at 4 fps: frame %d", frame)
}
later(tt, wd, 500*time.Millisecond)
if frame != 0 {
t.Fatalf("four frames on, it wraps: %d", frame)
}
// Choosing a frame while playing plays on from it.
click(t, tt, "Sheet")
tt.Key(0, ui.KeyEnd)
later(tt, wd, 250*time.Millisecond)
if frame != 0 {
t.Fatalf("End then a step: frame %d, want 0 after 3", frame)
}
click(t, tt, "PLAY")
later(tt, wd, time.Second)
if playing || frame != 0 {
t.Fatalf("paused, it moved to %d", frame)
}
mustPanic(t, "a frame past the sheet", func() {
view(600, 300, newWorld(), func(c *ui.Context) {
f, p := 4, false
code.SpriteSheet(c, "S", spin, &f, &p, code.SpriteSheetOptions{})
})
})
}
func TestGlyphMapTurnsPages(t *testing.T) {
wd := newWorld()
r := 'A'
tt := view(600, 300, wd, func(c *ui.Context) {
code.GlyphMap(c, "Glyphs", &r, code.GlyphMapOptions{})
})
find(t, tt, "U+0041 A")
click(t, tt, "U+0041 A")
click(t, tt, "Glyphs")
for _, step := range []struct {
key ui.Key
want rune
}{{ui.KeyHome, 0}, {ui.KeyPageUp, 0}, {ui.KeyEnd, 127}, {ui.KeyPageDown, 255}, {ui.KeyLeft, 254}, {ui.KeyPageUp, 126}} {
tt.Key(0, step.key)
if r != step.want {
t.Fatalf("after key %v: %U, want %U", step.key, r, step.want)
}
}
r = 0x0378
tt.Frame()
find(t, tt, "U+0378")
mustPanic(t, "past the last rune", func() {
view(600, 300, newWorld(), func(c *ui.Context) {
x := rune(0x110000)
code.GlyphMap(c, "G", &x, code.GlyphMapOptions{})
})
})
}
func TestRegisterViewLightsWhatChanged(t *testing.T) {
wd := newWorld()
cpu := code.CPU{SP: 0xFD, P: 0x24, PC: 0xC000}
tt := view(600, 300, wd, func(c *ui.Context) { code.RegisterView(c, "CPU", cpu, code.RegisterViewOptions{}) })
for _, l := range []string{"A $00", "X $00", "SP $FD", "PC $C000", "P $24"} {
find(t, tt, l)
}
// Hex text fills the first 40 dots of a row.
accent := func(label string) int {
x, y, _, h := dots(find(t, tt, label))
return count(tt.Image(), x, y, 40, h, pixel.Accent)
}
if accent("A $00") != 0 {
t.Fatal("nothing changed yet, but A is lit")
}
cpu.A, cpu.P = 0x3F, 0x25
tt.Frame()
if accent("A $3F") == 0 || accent("X $00") != 0 {
t.Fatal("A changed but is not lit, or X is")
}
// I and the spare bit are set; C just flipped.
x, y, _, _ := dots(find(t, tt, "P $25"))
img := tt.Image()
flag := func(i int) pixel.Slot { return at(img, x+i*9+2, y+3) }
if flag(5) != pixel.Good || flag(7) != pixel.Good || flag(0) != pixel.Well || at(img, x+7*9, y+3) != pixel.Accent {
t.Fatal("flags do not show the P bits")
}
}
+54
View File
@@ -0,0 +1,54 @@
package code
import (
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Var is a name, type and value; structs have fields.
type Var struct {
Name, Type, Value string
Fields []Var
}
type VariablesOptions struct {
// Width is in dots; zero means 200.
Width int
Disabled bool
}
type VariablesResult = data.TreeResult
// Variables lists names, types and values; fields open below.
func Variables(c *ui.Context, text string, vars []Var, selected *string, o VariablesOptions) VariablesResult {
return data.Tree(c, text, nodes(vars), selected, data.TreeOptions{Width: pixel.Or("Variables Width", o.Width, 200), Disabled: o.Disabled})
}
// nodes are vars as tree nodes, values colored by kind.
func nodes(vars []Var) []data.TreeNode {
var out []data.TreeNode
for _, v := range vars {
out = append(out, data.TreeNode{Label: v.Name + ": " + v.Type, Value: v.Value, Slot: kind(v), Children: nodes(v.Fields)})
}
return out
}
// kind is a value's slot, read from its type.
func kind(v Var) pixel.Slot {
t := v.Type
switch {
case v.Value == "nil":
return pixel.Faint
case t == "string":
return pixel.Good
case t == "bool":
return pixel.Info
case t == "byte" || t == "rune" || strings.HasPrefix(t, "int") || strings.HasPrefix(t, "uint") || strings.HasPrefix(t, "float") || strings.HasPrefix(t, "complex"):
return pixel.Warn
}
return pixel.Faint
}
+206
View File
@@ -0,0 +1,206 @@
package code_test
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/code"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestLogViewerFiltersByLevel(t *testing.T) {
wd := newWorld()
at := wd.clk.t
lines := []code.LogLine{{At: at, Level: code.Debug, Text: "d"}, {At: at, Level: code.Info, Text: "i"}, {At: at, Level: code.Error, Text: "e"}}
least, changed := code.Info, false
tt := view(600, 300, wd, func(c *ui.Context) {
changed = code.LogViewer(c, "Log", lines, &least, code.LogViewerOptions{}).Changed || changed
})
if tt.HasText("10:30:00 DEBUG d") || !tt.HasText("10:30:00 INFO i") {
t.Fatalf("INFO and up shows %q", tt.Texts())
}
x, y, w, h := dots(find(t, tt, "10:30:00 ERROR e"))
if count(tt.Image(), x, y, w, h, pixel.Bad) == 0 {
t.Fatal("an ERROR line is not Bad")
}
click(t, tt, "ERROR")
if least != code.Error || !changed || tt.HasText("10:30:00 INFO i") {
t.Fatalf("ERROR filter: least %v, changed %v, %q", least, changed, tt.Texts())
}
mustPanic(t, "a level past ERROR", func() {
view(600, 300, newWorld(), func(c *ui.Context) {
l := code.Level(4)
code.LogViewer(c, "Log", nil, &l, code.LogViewerOptions{})
})
})
}
func TestHexViewerWalksBytes(t *testing.T) {
wd := newWorld()
data := []byte("HELLO\x00WORLD\x00\x01\x02\x03\x04\xff\xfe\x00\x10")
cur, changed := 0, false
tt := view(600, 300, wd, func(c *ui.Context) {
changed = code.HexViewer(c, "Bytes", data, &cur, code.HexViewerOptions{}).Changed || changed
})
r := find(t, tt, "Bytes")
x, y, _, _ := dots(r)
img := tt.Image()
// The cursor's cell is Accent; a zero byte is Faint.
if at(img, x+1, y+2) != pixel.Accent || count(img, x+5*12, y, 11, 12, pixel.Faint) == 0 || count(img, x+12, y, 11, 12, pixel.Faint) != 0 {
t.Fatal("cursor or zero bytes are not marked")
}
click(t, tt, "Bytes")
tt.Key(0, ui.KeyHome)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyDown)
if cur != 9 || !changed {
t.Fatalf("Right, Down: cursor %d, changed %v", cur, changed)
}
tt.Key(0, ui.KeyEnd)
if cur != len(data)-1 {
t.Fatalf("End: cursor %d", cur)
}
for name, cols := range map[string]int{"3 a row": 3, "17 a row": 17} {
mustPanic(t, name, func() {
view(600, 300, newWorld(), func(c *ui.Context) {
n := 0
code.HexViewer(c, "B", data, &n, code.HexViewerOptions{Columns: cols})
})
})
}
}
func TestMemoryMapFillsByUse(t *testing.T) {
wd := newWorld()
tt := view(600, 300, wd, func(c *ui.Context) {
code.MemoryMap(c, "Memory", []code.Bank{{Name: "ROM0", Used: 15, Size: 16}, {Name: "ROM1", Used: 8, Size: 16}, {Name: "WRAM", Used: 3, Size: 16}, {Name: "OAM", Size: 16}}, code.MemoryMapOptions{})
})
for i, tc := range []struct {
label string
floor pixel.Slot
}{{"ROM0, 0 of 0 KB, 93%", pixel.Bad}, {"ROM1, 0 of 0 KB, 50%", pixel.Warn}, {"WRAM, 0 of 0 KB, 18%", pixel.Good}, {"OAM, 0 of 0 KB, 0%", pixel.Well}} {
x, y, _, _ := dots(find(t, tt, tc.label))
if got := at(tt.Image(), x+20, y+26); got != tc.floor {
t.Errorf("bank %d floor is %v, want %v", i, got, tc.floor)
}
}
for name, b := range map[string]code.Bank{"overfull": {Name: "A", Used: 2, Size: 1}, "empty": {Name: "A"}, "hanzi": {Name: "内存", Size: 1}} {
mustPanic(t, name, func() {
view(600, 300, newWorld(), func(c *ui.Context) { code.MemoryMap(c, "M", []code.Bank{b}, code.MemoryMapOptions{}) })
})
}
}
func TestJSONTreeOpensAndRejectsBadJSON(t *testing.T) {
wd := newWorld()
sel, toggled := "", false
tt := view(600, 300, wd, func(c *ui.Context) {
toggled = code.JSONTree(c, "Doc", `{"a": {"b": 1}, "c": "x"}`, &sel, code.JSONTreeOptions{}).Toggled || toggled
})
r := find(t, tt, "Doc")
if r.H != 2*2*pixel.LineHeight {
t.Fatalf("closed, the tree is %v tall", r.H)
}
click(t, tt, "Doc")
tt.Key(0, ui.KeyHome)
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeyRight)
if r := find(t, tt, "Doc"); !toggled || r.H != 3*2*pixel.LineHeight {
t.Fatalf("Right did not open a: toggled %v, %v tall", toggled, r.H)
}
for name, doc := range map[string]string{"broken": `{"a": }`, "two values": `{} {}`, "a stray brace": `{}}`} {
mustPanic(t, name, func() {
view(600, 300, newWorld(), func(c *ui.Context) {
s := ""
code.JSONTree(c, "J", doc, &s, code.JSONTreeOptions{})
})
})
}
}
func TestDiffViewSignsAndColors(t *testing.T) {
wd := newWorld()
tt := view(600, 300, wd, func(c *ui.Context) {
code.DiffView(c, "Diff", "a = 1\nb = 2", "a = 1\nb = 3\nc = 4", code.DiffViewOptions{})
})
find(t, tt, " a = 1")
for label, s := range map[string]pixel.Slot{"- b = 2": pixel.Bad, "+ b = 3": pixel.Good, "+ c = 4": pixel.Good} {
x, y, w, h := dots(find(t, tt, label))
if at(tt.Image(), x, y+2) != s || count(tt.Image(), x+32, y, w-32, h, s) == 0 {
t.Errorf("%q has no %v bar and text", label, s)
}
}
}
func TestCrashScreenBlinksAndContinues(t *testing.T) {
for _, reduce := range []bool{false, true} {
wd := newWorld()
wd.reduce = reduce
cont := false
tt := view(600, 300, wd, func(c *ui.Context) {
cont = code.CrashScreen(c, "panic: oops", []string{"main.main()"}, code.CrashScreenOptions{}).Continue || cont
})
find(t, tt, "panic: oops")
find(t, tt, " main.main()")
// The group is named by its title and found first.
x, y, _, _ := dots(find(t, tt, "GAME OVER"))
corner := func() pixel.Slot { return at(tt.Image(), x, y) }
if corner() != pixel.Bad {
t.Fatal("the frame is not lit at first")
}
later(tt, wd, time.Second/2+time.Millisecond)
if lit := corner() == pixel.Bad; lit != reduce {
t.Fatalf("reduce %v: lit %v half a second on", reduce, lit)
}
click(t, tt, "CONTINUE?")
if !cont {
t.Fatal("CONTINUE did not continue")
}
}
mustPanic(t, "no error", func() {
view(600, 300, newWorld(), func(c *ui.Context) { code.CrashScreen(c, "", nil, code.CrashScreenOptions{}) })
})
}
func TestFPSMeterKeepsTheNewestAndColorsByTime(t *testing.T) {
wd := newWorld()
ms := time.Millisecond
// Twenty dots hold eight bars: the first two scroll off.
frames := []time.Duration{20 * ms, 20 * ms, 40 * ms, 10 * ms, 10 * ms, 10 * ms, 10 * ms, 10 * ms, 10 * ms, 20 * ms}
tt := view(600, 300, wd, func(c *ui.Context) {
code.FPSMeter(c, "Frames", frames, code.FPSMeterOptions{Width: 20, Height: 30})
})
x, y, _, _ := dots(find(t, tt, "Frames"))
bar := func(i int) pixel.Slot { return at(tt.Image(), x+2+2*i, y+8+30-3) }
if bar(0) != pixel.Bad || bar(1) != pixel.Good || bar(7) != pixel.Warn {
t.Fatalf("bars %v %v %v", bar(0), bar(1), bar(7))
}
mustPanic(t, "a negative frame", func() {
view(600, 300, newWorld(), func(c *ui.Context) {
code.FPSMeter(c, "F", []time.Duration{-1}, code.FPSMeterOptions{})
})
})
}
func TestProfilerBarsPlaceSpansByTime(t *testing.T) {
wd := newWorld()
ms := time.Millisecond
root := code.Span{Name: "frame", Length: 10 * ms, Children: []code.Span{{Name: "a", Length: 5 * ms}, {Name: "b", Start: 5 * ms, Length: 5 * ms, Children: []code.Span{{Name: "c", Start: 5 * ms, Length: ms}}}}}
tt := view(600, 300, wd, func(c *ui.Context) {
code.ProfilerBars(c, "Frame", root, code.ProfilerBarsOptions{Width: 100})
})
x, y, _, h := dots(find(t, tt, "Frame"))
img := tt.Image()
row := func(r int) int { return y + r*pixel.LineHeight + 6 }
if h != 3*pixel.LineHeight || at(img, x+99, row(0)) != pixel.Accent || at(img, x+30, row(1)) != pixel.Info ||
at(img, x+52, row(2)) != pixel.Good || at(img, x+80, row(2)) == pixel.Good {
t.Fatal("bars are not where their spans are")
}
mustPanic(t, "a child past its parent", func() {
view(600, 300, newWorld(), func(c *ui.Context) {
code.ProfilerBars(c, "F", code.Span{Length: ms, Children: []code.Span{{Start: ms / 2, Length: ms}}}, code.ProfilerBarsOptions{})
})
})
}
+15 -3
View File
@@ -3,6 +3,7 @@ package data
import (
"log/slog"
"strconv"
"strings"
"github.com/egoist/mygo/ui"
@@ -10,7 +11,10 @@ import (
)
type TreeNode struct {
Label string
Label string
// Value follows the label in Slot, as a key's value.
Value string
Slot pixel.Slot
Children []TreeNode
}
@@ -102,7 +106,7 @@ func Tree(c *ui.Context, text string, roots []TreeNode, selected *string, o Tree
}
*selected = rows[at].key
res.Changed = *selected != old
base.Description(nodes[*selected].Label)
base.Description(strings.TrimSpace(nodes[*selected].Label + " " + nodes[*selected].Value))
shown := rows
shell.Size(k.D(pixel.Or("Tree Width", o.Width, 160)), k.D(len(shown)*pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
@@ -119,7 +123,15 @@ func Tree(c *ui.Context, text string, roots []TreeNode, selected *string, o Tree
g.Sprite(x+4, y+5, pointer, 0, ink, 1)
}
}
g.Text(x+12, y, nodes[rw.key].Label, ink, 1)
n := nodes[rw.key]
g.Text(x+12, y, n.Label, ink, 1)
// On the chosen row the value takes its ink.
if vs := n.Slot; n.Value != "" {
if i == at {
vs = ink
}
g.Text(x+18+pixel.Measure(n.Label), y, n.Value, vs, 1)
}
}
g.Finish(st)
})
+40
View File
@@ -0,0 +1,40 @@
package data_test
import (
"testing"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestTreeDrawsValuesInTheirSlot(t *testing.T) {
sel := "0"
roots := []data.TreeNode{{Label: "name", Value: `"Aria"`, Slot: pixel.Good}, {Label: "hp", Value: "42", Slot: pixel.Warn}}
tt := view(300, 200, newClock(), false, func(c *ui.Context) {
data.Tree(c, "Doc", roots, &sel, data.TreeOptions{})
})
r, img := find(t, tt, "Doc"), tt.Image()
count := func(row int, s pixel.Slot) int {
n, want := 0, pixel.SpringDay.Colors[s]
for y := int(r.Y) + row*2*pixel.LineHeight; y < int(r.Y)+(row+1)*2*pixel.LineHeight; y++ {
for x := int(r.X); x < int(r.X+r.W); x++ {
if c := img.RGBAAt(x, y); c.R == want.R && c.G == want.G && c.B == want.B {
n++
}
}
}
return n
}
// The chosen row inks its value; the other keeps Warn.
if count(0, pixel.Good) != 0 || count(1, pixel.Warn) == 0 {
t.Fatalf("values: Good on the chosen row %d, Warn below %d", count(0, pixel.Good), count(1, pixel.Warn))
}
// A click on the middle chooses the second row.
click(t, tt, "Doc")
img = tt.Image()
if sel != "1" || count(0, pixel.Good) == 0 || count(1, pixel.Warn) != 0 {
t.Fatalf("choosing hp (%q) did not swap which value is inked", sel)
}
}
+212
View File
@@ -0,0 +1,212 @@
package gallery
import (
"fmt"
"math"
"strings"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/code"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
var codeEntries = []Entry{
{366, "Terminal", "终端", "code", "Type HELP. Up and Down bring back what you typed. XYZZY does nothing, as ever.", demoTerminal},
{367, "CodeBlock", "代码块", "code", "Go, colored by slot: keywords, strings, numbers, builtins. COPY takes it all.", demoCodeBlock},
{368, "LogViewer", "日志", "code", "Lines colored by level; pick the quietest worth seeing. It follows the newest.", demoLogViewer},
{369, "HexViewer", "十六进制", "code", "A save file laid bare: offset, hex and ASCII. Arrows walk the bytes.", demoHexViewer},
{370, "MemoryMap", "内存图", "code", "Each bank a block, filled as it fills. Red means start deleting sprites.", demoMemoryMap},
{371, "JSONTree", "JSON树", "code", "JSON in its own order, types in their own colors. Right opens, Left closes.", demoJSONTree},
{372, "DiffView", "差异", "code", "What changed between two versions, minus then plus.", demoDiffView},
{373, "CrashScreen", "崩溃画面", "code", "GAME OVER in a blinking red frame, as the old guru had. CONTINUE when ready.", demoCrashScreen},
{374, "FPSMeter", "帧率表", "code", "Frame times scroll by; green is on time, red is a stutter.", demoFPSMeter},
{375, "ProfilerBars", "性能条", "code", "Where a frame's 16.7 ms went, nested like a family tree.", demoProfilerBars},
}
type codeDemo struct {
shell []string
command string
least code.Level
logs []code.LogLine
byte, pick int
node, vr string
crashed bool
marks map[int]bool
art string
pal pixel.Palette
frame int
playing bool
glyph rune
step int
frames []time.Duration
last time.Time
history []string
}
func codeState(c *ui.Context) *codeDemo {
now := pixel.K(c).Now()
return ui.Local(c.Root(), demoKey("code"), func() codeDemo {
return codeDemo{
shell: []string{"8BIT-OS v1.0", "READY. TYPE HELP."}, least: code.Info,
logs: []code.LogLine{
{At: now.Add(-9 * time.Second), Level: code.Info, Text: "cartridge inserted"},
{At: now.Add(-8 * time.Second), Level: code.Debug, Text: "blew on the contacts"},
{At: now.Add(-6 * time.Second), Level: code.Warn, Text: "save battery low"},
{At: now.Add(-3 * time.Second), Level: code.Error, Text: "princess in another castle"},
},
marks: map[int]bool{4: true}, art: heartArt, pal: *pixel.Current(c).Palette, glyph: 'A',
history: []string{"load \"*\",8,1", "run", "help", "xyzzy"},
}
})
}
func demoTerminal(c *ui.Context, log func(string)) {
s := codeState(c)
r := code.Terminal(c, "8BIT-OS", s.shell, &s.command, code.TerminalOptions{Width: 260})
if r.Changed {
log(fmt.Sprintf("input %q", s.command))
}
if r.Ran != "" {
log("ran " + r.Ran)
s.shell = append(s.shell, "$ "+r.Ran)
switch strings.ToLower(strings.TrimSpace(r.Ran)) {
case "help":
s.shell = append(s.shell, "HELP LS CAT DATE CLEAR XYZZY")
case "ls":
s.shell = append(s.shell, "save.dat sprites/ music/ readme")
case "cat readme":
s.shell = append(s.shell, "IT'S DANGEROUS TO GO ALONE!")
case "date":
s.shell = append(s.shell, pixel.K(c).Now().Format("2006-01-02 15:04"))
case "clear":
s.shell = nil
case "xyzzy":
s.shell = append(s.shell, "NOTHING HAPPENS.")
default:
s.shell = append(s.shell, "SYNTAX ERROR")
}
}
}
const loop = `func main() {
// One frame at a time, forever.
hero := Hero{Name: "Aria", HP: 3}
for frame := 0; ; frame++ {
if hero.HP == 0 {
panic("GAME OVER")
}
draw(hero, frame%60, nil, true)
}
}`
func demoCodeBlock(c *ui.Context, log func(string)) {
if code.CodeBlock(c, "main.go", loop, code.CodeBlockOptions{}).Copied {
log("copied main.go")
}
}
func demoLogViewer(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := codeState(c)
ui.Column(c).Gap(k.D(4)).Children(func() {
if code.LogViewer(c, "Log", s.logs, &s.least, code.LogViewerOptions{Height: 80}).Changed {
log(fmt.Sprintf("showing level %d and up", s.least))
}
if input.Button(c, "+ LINE", input.ButtonOptions{Size: input.Small}).Clicked {
lv := code.Level(len(s.logs) % 4)
s.logs = append(s.logs, code.LogLine{At: k.Now(), Level: lv, Text: fmt.Sprintf("tick %d", len(s.logs))})
log("added a line")
}
})
}
var saveFile = []byte("SAVE\x01\x00\x00\x03ARIA\x00\x00\x00\x00HP\x00\x2aGOLD\x03\xe7\x00\x00\xff\xfe\x00\x10")
func demoHexViewer(c *ui.Context, log func(string)) {
s := codeState(c)
if code.HexViewer(c, "save.dat", saveFile, &s.byte, code.HexViewerOptions{}).Changed {
log(fmt.Sprintf("offset %04X", s.byte))
}
}
func demoMemoryMap(c *ui.Context, log func(string)) {
code.MemoryMap(c, "Cartridge memory", []code.Bank{
{Name: "ROM0", Used: 15 << 10, Size: 16 << 10}, {Name: "ROM1", Used: 9 << 10, Size: 16 << 10},
{Name: "WRAM", Used: 3 << 10, Size: 8 << 10}, {Name: "VRAM", Used: 6 << 10, Size: 8 << 10},
}, code.MemoryMapOptions{})
}
const saveJSON = `{"player": {"name": "Aria", "level": 12, "alive": true,
"items": ["sword", "potion", "map"], "pet": null}, "checkpoint": 3, "flags": {}}`
func demoJSONTree(c *ui.Context, log func(string)) {
s := codeState(c)
if r := code.JSONTree(c, "save.json", saveJSON, &s.node, code.JSONTreeOptions{}); r.Changed || r.Toggled {
log("at " + s.node)
}
}
func demoDiffView(c *ui.Context, log func(string)) {
before := "speed = 4\njump = 2\nlives = 3\ncoins = 0"
after := "speed = 4\njump = 3\nlives = 3\ncoins = 0\nwarp = true"
code.DiffView(c, "settings.cfg", before, after, code.DiffViewOptions{})
}
func demoCrashScreen(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := codeState(c)
if s.crashed {
if input.Button(c, "CRASH AGAIN", input.ButtonOptions{}).Clicked {
s.crashed = false
log("crashed again")
}
return
}
stack := []string{"main.loot(0x3)", "main.openChest(...)", "main.main()"}
if code.CrashScreen(c, "panic: index out of range [3] with length 3", stack, code.CrashScreenOptions{Width: 280}).Continue {
s.crashed = true
log("continued at " + k.Now().Format("15:04:05"))
}
}
func demoFPSMeter(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := codeState(c)
// Made-up frames: mostly on time, now and then a hitch.
add := func(i int) {
ms := 9 + 4*math.Sin(float64(i)/5)
if i%23 == 7 {
ms = 38
}
s.frames = append(s.frames[max(0, len(s.frames)-200):], time.Duration(ms*float64(time.Millisecond)))
}
if len(s.frames) == 0 {
for i := range 56 {
add(i)
}
}
if !k.Reduce() {
if t := k.Tick(c, 12); s.last.IsZero() || k.Now().Sub(s.last) >= time.Second/12 {
s.last = k.Now()
add(t)
}
}
code.FPSMeter(c, "Frame times", s.frames, code.FPSMeterOptions{})
}
func demoProfilerBars(c *ui.Context, log func(string)) {
ms := func(f float64) time.Duration { return time.Duration(f * float64(time.Millisecond)) }
code.ProfilerBars(c, "One frame", code.Span{Name: "frame", Length: ms(16.7), Children: []code.Span{
{Name: "input", Length: ms(1)},
{Name: "update", Start: ms(1), Length: ms(6), Children: []code.Span{
{Name: "physics", Start: ms(1), Length: ms(3)}, {Name: "ai", Start: ms(4), Length: ms(2.5)},
}},
{Name: "render", Start: ms(7.5), Length: ms(8), Children: []code.Span{
{Name: "sprites", Start: ms(7.5), Length: ms(5), Children: []code.Span{{Name: "dither", Start: ms(9), Length: ms(2)}}},
{Name: "text", Start: ms(12.5), Length: ms(2)},
}},
}}, code.ProfilerBarsOptions{Width: 260})
}
+161
View File
@@ -0,0 +1,161 @@
package gallery
import (
"fmt"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/code"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
var debugEntries = []Entry{
{376, "Breakpoints", "断点", "code", "Click a line, or press Enter on it, to stop there next time.", demoBreakpoints},
{377, "Variables", "变量", "code", "Names, types and values; open a struct to see inside.", demoVariables},
{378, "CommandHistory", "命令历史", "code", "Everything you typed, numbered. Enter runs it again.", demoCommandHistory},
{379, "ShortcutList", "快捷键表", "code", "Actions beside their keycaps. Do not trust the last one.", demoShortcutList},
{380, "DebugGrid", "调试网格", "code", "The dot grid laid over anything, with rulers in dots.", demoDebugGrid},
{381, "PixelEditor", "像素编辑器", "code", "Pen, fill, eraser, picker. Arrows and Enter paint too. The art below is live.", demoPixelEditor},
{382, "PaletteEditor", "调色板编辑", "code", "Sixteen colors, sixteen levels a channel, like the old chips. The preview keeps up.", demoPaletteEditor},
{383, "SpriteSheet", "精灵表", "code", "Every frame in a grid; PLAY spins the coin.", demoSpriteSheet},
{384, "GlyphMap", "字形表", "code", "128 code points a page; PageUp and PageDown turn it. Dots mark what the font lacks.", demoGlyphMap},
{385, "RegisterView", "寄存器", "code", "A 6502's registers and flags. STEP runs a line; what it changed lights up.", demoRegisterView},
}
const routine = `func jump(h *Hero) {
if h.OnGround {
h.VY = -4
}
h.Y += h.VY
}`
func demoBreakpoints(c *ui.Context, log func(string)) {
s := codeState(c)
if code.Breakpoints(c, "jump.go", routine, s.marks, code.BreakpointsOptions{}).Changed {
log(fmt.Sprintf("breakpoints on %d lines", len(s.marks)))
}
}
func demoVariables(c *ui.Context, log func(string)) {
s := codeState(c)
vars := []code.Var{
{Name: "hero", Type: "Hero", Value: "{…}", Fields: []code.Var{
{Name: "Name", Type: "string", Value: `"Aria"`}, {Name: "HP", Type: "int", Value: "42"},
{Name: "Pos", Type: "Vec", Value: "{…}", Fields: []code.Var{{Name: "X", Type: "float64", Value: "12.5"}, {Name: "Y", Type: "float64", Value: "-3"}}},
{Name: "Flying", Type: "bool", Value: "false"},
}},
{Name: "target", Type: "*Enemy", Value: "nil"},
{Name: "lives", Type: "uint8", Value: "3"},
}
if r := code.Variables(c, "Locals", vars, &s.vr, code.VariablesOptions{Width: 220}); r.Changed || r.Toggled {
log("at " + s.vr)
}
}
func demoCommandHistory(c *ui.Context, log func(string)) {
s := codeState(c)
r := code.CommandHistory(c, "History", s.history, &s.pick, code.CommandHistoryOptions{Height: 70})
if r.Rerun {
log("rerun " + s.history[s.pick])
} else if r.Changed {
log("picked " + s.history[s.pick])
}
}
func demoShortcutList(c *ui.Context, log func(string)) {
code.ShortcutList(c, "Controls", []code.Shortcut{
{Action: "Save", Keys: []string{"Ctrl", "S"}}, {Action: "Jump", Keys: []string{"Space"}},
{Action: "Pause", Keys: []string{"Esc"}},
{Action: "Free gold", Keys: []string{"Alt", "F4"}},
}, code.ShortcutListOptions{Width: 300})
}
func demoDebugGrid(c *ui.Context, log func(string)) {
k := pixel.K(c)
code.DebugGrid(c, code.DebugGridOptions{}, func() {
ui.Column(c).Padding(k.D(8)).Gap(k.D(6)).Children(func() {
if input.Button(c, "ALIGNED?", input.ButtonOptions{}).Clicked {
log("pressed on the grid")
}
pixel.Text(c, "Every edge on a dot.", pixel.TextOptions{})
})
})
}
const heartArt = `..rr..rr..
.rrrrrrrr.
rrlrrrrrrr
rrrrrrrrrr
.rrrrrrrr.
..rrrrrr..
...rrrr...
....rr....
`
func demoPixelEditor(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := codeState(c)
ui.Column(c).Gap(k.D(6)).Children(func() {
r := code.PixelEditor(c, "Heart", &s.art, code.PixelEditorOptions{})
if r.Changed {
log("painted")
}
if r.Copied {
log("art copied")
}
for _, l := range strings.Split(strings.TrimSpace(s.art), "\n") {
pixel.Text(c, l, pixel.TextOptions{Slot: pixel.Faint})
}
})
}
func demoPaletteEditor(c *ui.Context, log func(string)) {
s := codeState(c)
if code.PaletteEditor(c, "Palette copy", &s.pal, code.PaletteEditorOptions{}).Changed {
log("palette edited")
}
}
func demoSpriteSheet(c *ui.Context, log func(string)) {
s := codeState(c)
if code.SpriteSheet(c, "Coin", coin, &s.frame, &s.playing, code.SpriteSheetOptions{Scale: 3}).Changed {
log(fmt.Sprintf("frame %d, playing %v", s.frame+1, s.playing))
}
}
func demoGlyphMap(c *ui.Context, log func(string)) {
s := codeState(c)
if code.GlyphMap(c, "Font", &s.glyph, code.GlyphMapOptions{}).Changed {
log(fmt.Sprintf("U+%04X", s.glyph))
}
}
// program is 6502 lines and the CPU after each.
var program = []struct {
line string
cpu code.CPU
}{
{"RESET", code.CPU{SP: 0xFD, P: 0x24, PC: 0xC000}},
{"LDA #$3F", code.CPU{A: 0x3F, SP: 0xFD, P: 0x24, PC: 0xC002}},
{"TAX", code.CPU{A: 0x3F, X: 0x3F, SP: 0xFD, P: 0x24, PC: 0xC003}},
{"INX", code.CPU{A: 0x3F, X: 0x40, SP: 0xFD, P: 0x24, PC: 0xC004}},
{"ADC #$C1", code.CPU{A: 0x00, X: 0x40, SP: 0xFD, P: 0x27, PC: 0xC006}},
{"PHA", code.CPU{A: 0x00, X: 0x40, SP: 0xFC, P: 0x27, PC: 0xC007}},
{"DEY", code.CPU{A: 0x00, X: 0x40, Y: 0xFF, SP: 0xFC, P: 0xA5, PC: 0xC008}},
}
func demoRegisterView(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := codeState(c)
p := program[s.step]
ui.Column(c).Gap(k.D(6)).Children(func() {
pixel.Text(c, fmt.Sprintf("$%04X %s", p.cpu.PC, p.line), pixel.TextOptions{Slot: pixel.Accent})
code.RegisterView(c, "CPU", p.cpu, code.RegisterViewOptions{})
if input.Button(c, "STEP", input.ButtonOptions{Variant: input.Primary}).Clicked {
s.step = (s.step + 1) % len(program)
log(program[s.step].line)
}
})
}
+33
View File
@@ -38,3 +38,36 @@ func TestMediaDemosLogTheirActions(t *testing.T) {
}
}
}
func TestCodeDemosLogTheirActions(t *testing.T) {
quiet := map[string]bool{"COMMAND": true}
for _, tc := range []struct {
path string
steps []string
}{
{"/c/366-terminal", []string{"COMMAND", "type:help", "key:Enter", "key:Up"}}, {"/c/367-codeblock", []string{"COPY"}},
{"/c/368-logviewer", []string{"WARN", "+ LINE"}}, {"/c/369-hexviewer", []string{"save.dat"}},
{"/c/371-jsontree", []string{"save.json"}}, {"/c/373-crashscreen", []string{"CONTINUE?", "CRASH AGAIN"}},
{"/c/376-breakpoints", []string{"jump.go"}}, {"/c/377-variables", []string{"Locals"}},
{"/c/378-commandhistory", []string{"run", "key:Enter"}}, {"/c/380-debuggrid", []string{"ALIGNED?"}},
{"/c/381-pixeleditor", []string{"Heart", "COPY"}}, {"/c/382-paletteeditor", []string{"R"}},
{"/c/383-spritesheet", []string{"PLAY"}}, {"/c/384-glyphmap", []string{"Font"}}, {"/c/385-registerview", []string{"STEP"}},
} {
a, tt := open(tc.path)
slug := tc.path[3:]
for _, step := range tc.steps {
before := len(a.logs[slug])
switch {
case strings.HasPrefix(step, "key:"):
tt.Key(0, letters[step[4:]])
case strings.HasPrefix(step, "type:"):
tt.Type(step[5:])
default:
click(t, tt, step)
}
if len(a.logs[slug]) <= before && !quiet[step] && !strings.HasPrefix(step, "type:") {
t.Errorf("%s: %s logged nothing: %q", slug, step, a.logs[slug])
}
}
}
}
+1 -1
View File
@@ -56,7 +56,7 @@ var Categories = []Category{
{"tabletop", "Tabletop", "桌游", icon.Crown},
}
var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries, chartEntries, plotEntries, gameEntries, rpgEntries, screenEntries, lootEntries, fxEntries, screenFxEntries, chatEntries, mailEntries, mediaEntries, studioEntries)
var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries, chartEntries, plotEntries, gameEntries, rpgEntries, screenEntries, lootEntries, fxEntries, screenFxEntries, chatEntries, mailEntries, mediaEntries, studioEntries, codeEntries, debugEntries)
func Entries() []Entry { return entries }
+52
View File
@@ -0,0 +1,52 @@
package layout_test
import (
"testing"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestScrollAreaFollowsItsEndWhileThere(t *testing.T) {
s, n, follow := ui.ScrollState{}, 10, true
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay})
layout.ScrollArea(c, &s, layout.ScrollAreaOptions{Label: "Log", Follow: follow}, func() {
for range n {
ui.Box(c).Height(32)
}
}).Element.Size(200, 100)
}, 300, 200)
tt.Frame()
if s.MaxY == 0 || s.Y != s.MaxY {
t.Fatalf("a following view opened at %v of %v", s.Y, s.MaxY)
}
n++
tt.Frame()
if s.Y != s.MaxY {
t.Fatalf("at the end, a new row left the view at %v of %v", s.Y, s.MaxY)
}
// Scrolled up it stays; back at the end, it follows.
s.Y = 0
n++
tt.Frame()
if s.Y != 0 {
t.Fatalf("scrolled up, the view jumped to %v", s.Y)
}
s.Y = s.MaxY
tt.Frame()
n++
tt.Frame()
if s.Y != s.MaxY {
t.Fatalf("back at the end, the view stopped following at %v of %v", s.Y, s.MaxY)
}
// Without Follow, growth leaves the view alone.
follow, s = false, ui.ScrollState{}
n++
tt.Frame()
if s.Y != 0 {
t.Fatalf("a plain view followed to %v", s.Y)
}
}
+25 -7
View File
@@ -25,7 +25,9 @@ var (
type ScrollAreaOptions struct {
// Label names the region for assistive technology.
Label string
Label string
// Follow keeps the view at the end while there.
Follow bool
Disabled bool
}
@@ -37,17 +39,32 @@ type ScrollAreaResult struct {
type scrollKey struct{}
// area is the last view and the end last reached.
type area struct {
last, end float32
}
// ScrollArea scrolls children vertically beside a pixel scrollbar.
func ScrollArea(c *ui.Context, s *ui.ScrollState, o ScrollAreaOptions, children func()) ScrollAreaResult {
k := pixel.K(c)
// The bar comes first: TrackScroll reads s when called.
row := ui.Row(c).AlignItems(ui.Stretch).Reverse()
last := ui.Local(row, scrollKey{}, func() float32 { return s.Y })
st := ui.Local(row, scrollKey{}, func() area { return area{last: s.Y} })
view := row.Bounds().H
// Off freezes the view: any move is undone before paint.
inert := pixel.Current(c).Inert
if o.Disabled || inert {
s.Y = *last
off := o.Disabled || inert
if off {
s.Y = st.last
}
// Content grew under a view at its old end: follow.
grew := func() bool { return o.Follow && s.MaxY > st.end && s.Y >= st.end }
// Off, the end waits until it wakes.
if !off {
if grew() {
s.Y = s.MaxY
}
st.end = s.MaxY
}
var v *ui.Element
row.Children(func() {
@@ -69,7 +86,8 @@ func ScrollArea(c *ui.Context, s *ui.ScrollState, o ScrollAreaOptions, children
})
focus := v.FocusVisible()
row.Draw(func(p *ui.Painter, r ui.Rect) {
if r.H != view {
// Layout has just measured; resize or follow next frame.
if r.H != view || !off && grew() {
c.Invalidate()
}
}).DrawOver(func(p *ui.Painter, r ui.Rect) {
@@ -82,8 +100,8 @@ func ScrollArea(c *ui.Context, s *ui.ScrollState, o ScrollAreaOptions, children
g.Checker(w-1, 0, 1, g.H, pixel.Accent)
}
})
res := ScrollAreaResult{Element: row, Changed: s.Y != *last}
*last = s.Y
res := ScrollAreaResult{Element: row, Changed: s.Y != st.last}
st.last = s.Y
if res.Changed {
slog.Debug("8bit: scrollarea scrolled", "y", s.Y)
}
+21
View File
@@ -0,0 +1,21 @@
package pixel
import "testing"
func TestArtRoundTrips(t *testing.T) {
art := "#a.\n.kr\n\n...\nmA#\n"
sp := MustSprite(art)
if got := sp.Art(); got != art {
t.Fatalf("Art() = %q, want %q", got, art)
}
one := SpriteOf(2, 1, func(x, y int) (Slot, bool) { return Good, x == 1 })
if got := one.Art(); got != ".g\n" {
t.Fatalf("SpriteOf Art() = %q", got)
}
}
func TestHasKnowsTofu(t *testing.T) {
if !Has('A') || !Has('中') || !Has('♪') || Has('拖') || Has('\u0378') {
t.Fatal("Has does not match the font")
}
}
+4
View File
@@ -81,6 +81,10 @@ func Editor(c *ui.Context, value *string, o EditorOptions) Edit {
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 {
// The caller may have changed the value since this frame.
if string(s.text) != *value {
s.reset(*value)
}
took := s.handle(ev, env)
if s.changed {
*value = string(s.text)
+9
View File
@@ -336,3 +336,12 @@ func TestEditorSpaceDoesNotScroll(t *testing.T) {
t.Fatalf("value %q, scrolled to %v", value, scroll.Y)
}
}
func TestEditorTakesAValueChangedBetweenFrames(t *testing.T) {
h := newEdit(t, "help", 200, pixel.EditorOptions{})
h.focus()
// The caller clears it; a key lands before a frame.
h.value = ""
h.tt.Type("x")
h.want("x")
}
+6
View File
@@ -39,6 +39,12 @@ func loadFont(b []byte) *font {
return &font{ascent: int(b[4]), descent: int(b[5]), n: n, index: b[10 : 10+8*n], data: b}
}
// Has reports whether the font draws r, not tofu.
func Has(r rune) bool {
_, ok := font12.find(r)
return ok
}
func (f *font) find(r rune) (glyph, bool) {
lo, hi := 0, f.n
for lo < hi {
+4
View File
@@ -35,6 +35,8 @@ var messages = map[Locale]map[string]string{
"mute": "MUTE", "camera": "CAMERA", "hang_up": "HANG UP", "new_count": "%d NEW", "votes": "%d VOTES", "unread": "%d UNREAD",
"day_date": "%[1]s %[2]d %[3]s", "category": "CATEGORY", "days": "SUN MON TUE WED THU FRI SAT",
"prev_track": "PREVIOUS", "next_track": "NEXT", "play": "PLAY", "volume": "VOLUME", "rec": "REC", "playing": "PLAYING", "seek": "SEEK",
"copy": "COPY", "copied": "COPIED!", "command": "COMMAND", "breakpoint": "BREAKPOINT",
"tool": "TOOL", "pen": "PEN", "fill": "FILL", "erase": "ERASE", "pick": "PICK", "grid": "GRID", "color": "COLOR", "preview": "PREVIEW",
},
Zh: {
"ok": "确定", "cancel": "取消", "yes": "是", "no": "否", "close": "关闭",
@@ -68,6 +70,8 @@ var messages = map[Locale]map[string]string{
"mute": "静音", "camera": "摄像头", "hang_up": "挂断", "new_count": "%d 条新", "votes": "%d 票", "unread": "%d 未读",
"day_date": "%[4]d月%[2]d日 周%[1]s", "category": "类别", "days": "日 一 二 三 四 五 六",
"prev_track": "上一首", "next_track": "下一首", "play": "播放", "volume": "音量", "rec": "录制", "playing": "正在播放", "seek": "进度",
"copy": "复制", "copied": "已复制!", "command": "命令", "breakpoint": "断点",
"tool": "工具", "pen": "铅笔", "fill": "填充", "erase": "橡皮", "pick": "吸管", "grid": "网格", "color": "颜色", "preview": "预览",
},
}
+24
View File
@@ -107,6 +107,30 @@ func SpriteOf(w, h int, at func(x, y int) (Slot, bool)) *Sprite {
return &Sprite{W: w, H: h, frames: [][]int8{px}}
}
// Art writes s as MustSprite reads it, frames apart.
func (s *Sprite) Art() string {
var b strings.Builder
for f, px := range s.frames {
if f > 0 {
b.WriteByte('\n')
}
for y := range s.H {
for _, v := range px[y*s.W : (y+1)*s.W] {
switch v {
case cellClear:
b.WriteByte('.')
case cellInk:
b.WriteByte('#')
default:
b.WriteByte(artSlots[v])
}
}
b.WriteByte('\n')
}
}
return b.String()
}
// Mirror returns s flipped left to right.
func (s *Sprite) Mirror() *Sprite {
m := &Sprite{W: s.W, H: s.H}