Add code components 366-385; ScrollArea Follow, editor sync
A terminal with history, a Go-colored code block, a log viewer that follows, a hex viewer, a memory map, a JSON tree, a line diff, a GAME OVER crash screen, an FPS meter, profiler bars, breakpoints, variables, command history, a shortcut list, a debug grid, a pixel editor that exports string art, a palette editor, a sprite sheet, a glyph map and a 6502 register view. layout.ScrollArea takes Follow, and ChatLog now uses it. data.TreeNode takes a Value drawn in its Slot. pixel gains Has, and Sprite.Art writes MustSprite art. pixel.Editor takes the caller's value at each input event, so a value changed between frames is never typed onto.
This commit is contained in:
42 files changed
+3415
-37
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user