diff --git a/chat/chatlog.go b/chat/chatlog.go index 9661b01..562b592 100644 --- a/chat/chatlog.go +++ b/chat/chatlog.go @@ -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() }) diff --git a/code/breakpoints.go b/code/breakpoints.go new file mode 100644 index 0000000..30a2e5a --- /dev/null +++ b/code/breakpoints.go @@ -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 +} diff --git a/code/code.go b/code/code.go new file mode 100644 index 0000000..4d1c08c --- /dev/null +++ b/code/code.go @@ -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 +} diff --git a/code/code_test.go b/code/code_test.go new file mode 100644 index 0000000..aebf071 --- /dev/null +++ b/code/code_test.go @@ -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) + } +} diff --git a/code/codeblock.go b/code/codeblock.go new file mode 100644 index 0000000..9a8b04a --- /dev/null +++ b/code/codeblock.go @@ -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 +} diff --git a/code/commandhistory.go b/code/commandhistory.go new file mode 100644 index 0000000..e641764 --- /dev/null +++ b/code/commandhistory.go @@ -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} +} diff --git a/code/crashscreen.go b/code/crashscreen.go new file mode 100644 index 0000000..72665d7 --- /dev/null +++ b/code/crashscreen.go @@ -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 +} diff --git a/code/debuggrid.go b/code/debuggrid.go new file mode 100644 index 0000000..b02565f --- /dev/null +++ b/code/debuggrid.go @@ -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) + } + } + }) + }) + }) +} diff --git a/code/diffview.go b/code/diffview.go new file mode 100644 index 0000000..d3a52a0 --- /dev/null +++ b/code/diffview.go @@ -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 +} diff --git a/code/fpsmeter.go b/code/fpsmeter.go new file mode 100644 index 0000000..db645c5 --- /dev/null +++ b/code/fpsmeter.go @@ -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) } diff --git a/code/glyphmap.go b/code/glyphmap.go new file mode 100644 index 0000000..60adb81 --- /dev/null +++ b/code/glyphmap.go @@ -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) +} diff --git a/code/guards_test.go b/code/guards_test.go new file mode 100644 index 0000000..af52d88 --- /dev/null +++ b/code/guards_test.go @@ -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}) + }) + }) + } +} diff --git a/code/hexviewer.go b/code/hexviewer.go new file mode 100644 index 0000000..125eb90 --- /dev/null +++ b/code/hexviewer.go @@ -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) +} diff --git a/code/jsontree.go b/code/jsontree.go new file mode 100644 index 0000000..da8497e --- /dev/null +++ b/code/jsontree.go @@ -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 +} diff --git a/code/logviewer.go b/code/logviewer.go new file mode 100644 index 0000000..b573316 --- /dev/null +++ b/code/logviewer.go @@ -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) + }) +} diff --git a/code/memorymap.go b/code/memorymap.go new file mode 100644 index 0000000..523c4ff --- /dev/null +++ b/code/memorymap.go @@ -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) + }) + } + }) +} diff --git a/code/paint_internal_test.go b/code/paint_internal_test.go new file mode 100644 index 0000000..952bc54 --- /dev/null +++ b/code/paint_internal_test.go @@ -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")) + } +} diff --git a/code/paletteeditor.go b/code/paletteeditor.go new file mode 100644 index 0000000..32e5f35 --- /dev/null +++ b/code/paletteeditor.go @@ -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) } diff --git a/code/parts_internal_test.go b/code/parts_internal_test.go new file mode 100644 index 0000000..43c0ef4 --- /dev/null +++ b/code/parts_internal_test.go @@ -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") + } +} diff --git a/code/pixeleditor.go b/code/pixeleditor.go new file mode 100644 index 0000000..d570d74 --- /dev/null +++ b/code/pixeleditor.go @@ -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 +} diff --git a/code/profilerbars.go b/code/profilerbars.go new file mode 100644 index 0000000..13f87e9 --- /dev/null +++ b/code/profilerbars.go @@ -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 +} diff --git a/code/registerview.go b/code/registerview.go new file mode 100644 index 0000000..01700ce --- /dev/null +++ b/code/registerview.go @@ -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) + } +} diff --git a/code/shortcutlist.go b/code/shortcutlist.go new file mode 100644 index 0000000..3c879cb --- /dev/null +++ b/code/shortcutlist.go @@ -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{}) + } + }) + }) + } + }) +} diff --git a/code/spritesheet.go b/code/spritesheet.go new file mode 100644 index 0000000..1a7c747 --- /dev/null +++ b/code/spritesheet.go @@ -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 +} diff --git a/code/terminal.go b/code/terminal.go new file mode 100644 index 0000000..5b31e34 --- /dev/null +++ b/code/terminal.go @@ -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 +} diff --git a/code/tools_test.go b/code/tools_test.go new file mode 100644 index 0000000..bb0747c --- /dev/null +++ b/code/tools_test.go @@ -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") + } +} diff --git a/code/variables.go b/code/variables.go new file mode 100644 index 0000000..01bbadc --- /dev/null +++ b/code/variables.go @@ -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 +} diff --git a/code/views_test.go b/code/views_test.go new file mode 100644 index 0000000..3762734 --- /dev/null +++ b/code/views_test.go @@ -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{}) + }) + }) +} diff --git a/data/tree.go b/data/tree.go index def337b..5d6108c 100644 --- a/data/tree.go +++ b/data/tree.go @@ -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) }) diff --git a/data/treevalue_test.go b/data/treevalue_test.go new file mode 100644 index 0000000..56e3d5b --- /dev/null +++ b/data/treevalue_test.go @@ -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) + } +} diff --git a/gallery/demos_code.go b/gallery/demos_code.go new file mode 100644 index 0000000..b017d86 --- /dev/null +++ b/gallery/demos_code.go @@ -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}) +} diff --git a/gallery/demos_debug.go b/gallery/demos_debug.go new file mode 100644 index 0000000..dbeb474 --- /dev/null +++ b/gallery/demos_debug.go @@ -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) + } + }) +} diff --git a/gallery/media_demo_test.go b/gallery/media_demo_test.go index 5fb8e3f..d191d9e 100644 --- a/gallery/media_demo_test.go +++ b/gallery/media_demo_test.go @@ -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]) + } + } + } +} diff --git a/gallery/registry.go b/gallery/registry.go index b76a9a4..cbf434c 100644 --- a/gallery/registry.go +++ b/gallery/registry.go @@ -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 } diff --git a/layout/follow_test.go b/layout/follow_test.go new file mode 100644 index 0000000..b4261ac --- /dev/null +++ b/layout/follow_test.go @@ -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) + } +} diff --git a/layout/scrollarea.go b/layout/scrollarea.go index b3801fd..135bd38 100644 --- a/layout/scrollarea.go +++ b/layout/scrollarea.go @@ -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) } diff --git a/pixel/art_internal_test.go b/pixel/art_internal_test.go new file mode 100644 index 0000000..61bac3f --- /dev/null +++ b/pixel/art_internal_test.go @@ -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") + } +} diff --git a/pixel/editor.go b/pixel/editor.go index 900bcdf..c83a920 100644 --- a/pixel/editor.go +++ b/pixel/editor.go @@ -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) diff --git a/pixel/editor_test.go b/pixel/editor_test.go index a603115..2d3db66 100644 --- a/pixel/editor_test.go +++ b/pixel/editor_test.go @@ -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") +} diff --git a/pixel/font.go b/pixel/font.go index dadd7af..75d125b 100644 --- a/pixel/font.go +++ b/pixel/font.go @@ -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 { diff --git a/pixel/messages.go b/pixel/messages.go index 2d1957b..85cff80 100644 --- a/pixel/messages.go +++ b/pixel/messages.go @@ -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": "预览", }, } diff --git a/pixel/sprite.go b/pixel/sprite.go index 3c74af8..dd41de3 100644 --- a/pixel/sprite.go +++ b/pixel/sprite.go @@ -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}