Add data components 151-175; clear MyGo row highlights

Lists, tables, trees, grids, inventories and records in data/.
Outline and GridView build on ui.List: ui.Outline strokes its arrows
and ui.GridView spaces and tints items. The pixel theme keeps MyGo's
row highlights clear so scrolled rows stay on whole dots; off lists
take a veil against the wheel. Adds Canvas.Silhouette, SwitchOptions
Label and exports Rarity.Slot.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-11 22:29:07 -04:00
1 parent f587d25a5a
commit 03680980da
47 files changed
+3379 -17

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
package data
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type AccordionOptions struct {
// Many lets several sections stay open at once.
Many bool
Disabled bool
}
type AccordionResult struct {
Element *ui.Element
Changed bool
}
// Accordion stacks collapsible sections; one stays open unless Many.
func Accordion(c *ui.Context, text string, titles []string, open []bool, o AccordionOptions, panel func(i int)) AccordionResult {
k := pixel.K(c)
if len(open) != len(titles) {
panic(fmt.Sprintf("data: Accordion has %d titles, %d open flags", len(titles), len(open)))
}
col := ui.Column(c).AlignItems(ui.Stretch).Gap(k.D(1)).Role(ui.RoleGroup).Label(text)
res := AccordionResult{Element: col}
col.Children(func() {
for i, t := range titles {
if !Collapsible(c, t, &open[i], CollapsibleOptions{Disabled: o.Disabled}, func() { panel(i) }).Changed {
continue
}
res.Changed = true
for j := range open {
if j != i && open[i] && !o.Many {
open[j] = false
}
}
}
})
if res.Changed {
slog.Debug("8bit: accordion", "label", text, "open", open)
}
return res
}
+61
View File
@@ -0,0 +1,61 @@
package data
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
// Monster is a bestiary entry; unseen ones keep their secrets.
type Monster struct {
Sprite *pixel.Sprite
Name string
Stats [][2]string
Notes string
Seen bool
}
type BestiaryOptions struct {
// Scale enlarges the sprite, 1–4; zero means 2.
Scale int
}
// Bestiary is a monster card; unseen ones are silhouettes.
func Bestiary(c *ui.Context, m Monster, o BestiaryOptions) *ui.Element {
k := pixel.K(c)
sc := pixel.Or("Bestiary Scale", o.Scale, 2)
if m.Sprite == nil || sc > 4 {
panic(fmt.Sprintf("data: Bestiary %q needs a Sprite and Scale 1–4", m.Name))
}
hide := func(s string) string {
if m.Seen {
return s
}
return k.Msg("unknown")
}
return layout.Card(c, layout.CardOptions{Title: hide(m.Name)}, func() {
ui.Row(c).Gap(k.D(8)).Children(func() {
ui.Box(c).Size(k.D(m.Sprite.W*sc+8), k.D(m.Sprite.H*sc+8)).Shrink(0).Role(ui.RoleImage).Label(hide(m.Name)).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)
if m.Seen {
g.Sprite(4, 4, m.Sprite, 0, pixel.Ink, sc)
} else {
g.Silhouette(4, 4, m.Sprite, 0, pixel.Shade, sc)
}
})
ui.Column(c).Grow(1).Gap(k.D(2)).Children(func() {
for _, s := range m.Stats {
display.KeyValue(c, s[0], hide(s[1]), display.KeyValueOptions{})
}
})
})
ui.Box(c).Height(k.D(6))
text.Paragraph(c, hide(m.Notes), text.ParagraphOptions{})
})
}
+23
View File
@@ -0,0 +1,23 @@
package data
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type CardGridOptions struct {
// MinWidth is a card's least dots; zero means 96.
MinWidth int
}
// CardGrid fits as many card columns as width allows.
func CardGrid(c *ui.Context, n int, o CardGridOptions, card func(i int)) *ui.Element {
mw := pixel.Or("CardGrid MinWidth", o.MinWidth, 96)
box := ui.Box(c).FillWidth()
w, check := pixel.WrapWidth(c, box)
return box.Draw(func(p *ui.Painter, r ui.Rect) { check(r) }).Children(func() {
layout.Grid(c, n, layout.GridOptions{Columns: max(1, w/mw), Pad: 2}, card)
})
}
+75
View File
@@ -0,0 +1,75 @@
package data
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 CarouselOptions struct {
// Every turns the page each that many seconds; zero never.
Every int
Disabled bool
}
type CarouselResult struct {
Element *ui.Element
Changed bool
}
type carouselKey struct{}
// Carousel turns pages; auto-turning waits on hover, focus, reduce.
func Carousel(c *ui.Context, text string, pages int, page *int, o CarouselOptions, content func(i int)) CarouselResult {
k := pixel.K(c)
if pages < 1 || *page < 0 || *page >= pages {
panic(fmt.Sprintf("data: Carousel page %d of %d", *page, pages))
}
every := pixel.Or("Carousel Every", o.Every, 0)
col := ui.Column(c).AlignItems(ui.Center).Gap(k.D(4)).Role(ui.RoleGroup).Label(text)
since := ui.Local(col, carouselKey{}, func() time.Time { return k.Now() })
old := *page
turn := func(d int) { *page = (*page + d + pages) % pages }
wait := every == 0 || o.Disabled || pixel.Current(c).Inert || k.Reduce() || col.Hovered() || col.FocusWithin()
if wait {
*since = k.Now()
} else if k.Since(c, *since, 1, every) == every {
turn(1)
}
col.Children(func() {
ui.Row(c).Gap(k.D(4)).AlignItems(ui.Center).Children(func() {
if input.IconButton(c, icon.ChevronLeft, k.Msg("prev"), input.IconButtonOptions{Disabled: o.Disabled}).Clicked {
turn(-1)
}
ui.Box(c).Key(*page).Children(func() { content(*page) })
if input.IconButton(c, icon.ChevronRight, k.Msg("next"), input.IconButtonOptions{Disabled: o.Disabled}).Clicked {
turn(1)
}
})
at := *page
ui.Box(c).Size(k.D(pages*6-2), k.D(4)).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i := range pages {
if i == at {
g.Rect(i*6, 0, 4, 4, pixel.Accent)
} else {
g.Rect(i*6, 0, 4, 4, pixel.Edge)
g.Rect(i*6+1, 1, 2, 2, pixel.Well)
}
}
})
})
col.Description(fmt.Sprintf("%d/%d", *page+1, pages))
res := CarouselResult{Element: col, Changed: *page != old}
if res.Changed {
*since = k.Now()
slog.Debug("8bit: carousel", "label", text, "page", *page)
}
return res
}
+57
View File
@@ -0,0 +1,57 @@
package data
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type CollapsibleOptions struct {
Disabled bool
}
type CollapsibleResult struct {
Element *ui.Element
Changed bool
}
// Collapsible shows content under its ▸ ▾ header while *open.
func Collapsible(c *ui.Context, title string, open *bool, o CollapsibleOptions, content func()) CollapsibleResult {
k := pixel.K(c)
col := ui.Column(c).AlignItems(ui.Stretch)
res := CollapsibleResult{Element: col}
col.Children(func() {
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.CollapsibleBase(c, open).Trigger.Label(title).Disabled(o.Disabled)
})
res.Changed = base.Changed()
shown := *open
shell.Height(k.D(pixel.LineHeight + 6)).MinWidth(k.D(pixel.Measure(title) + 20)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
fill, dy := pixel.Face, 0
switch {
case st.Press:
fill, dy = pixel.Shade, 1
case st.Hover:
fill = pixel.Lit
}
g.Frame(0, 0, g.W, g.H, pixel.Raised, fill)
if shown {
g.Sprite(5, 8+dy, down, 0, pixel.Ink, 1)
} else {
g.Sprite(6, 6+dy, pointer, 0, pixel.Ink, 1)
}
g.Text(14, 3+dy, title, pixel.Ink, 1)
g.Finish(st)
})
if shown {
ui.Column(c).Padding(k.D(4), k.D(6)).Children(content)
}
})
if res.Changed {
slog.Debug("8bit: collapsible", "title", title, "open", *open)
}
return res
}
+89
View File
@@ -0,0 +1,89 @@
// Package data shows collections: lists, tables, trees, grids.
package data
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
var (
pointer = pixel.MustSprite(`
#..
##.
###
##.
#..
`)
up = pixel.MustSprite(`
..#..
.###.
#####
`)
down = pixel.MustSprite(`
#####
.###.
..#..
`)
)
// well sinks box and finishes it as st says.
func well(c *ui.Context, box *ui.Element, st pixel.State) {
k := pixel.K(c)
box.Padding(k.D(2)).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)
}).DrawOver(func(p *ui.Painter, r ui.Rect) {
k.Canvas(p, r).Finish(st)
})
}
// veil covers an off list; the wheel stops on it.
func veil(c *ui.Context, off bool) {
if off {
ui.Box(c).Absolute().Top(0).Left(0).Fill()
}
}
// row is a shown tree row, with its parent.
type row[K comparable] struct {
key K
depth int
branch bool
parent int
}
// flatten lists the rows shown under roots, inside open branches.
func flatten[K comparable](roots []K, children func(K) []K, opened map[K]bool) []row[K] {
var out []row[K]
var walk func(items []K, depth, parent int)
walk = func(items []K, depth, parent int) {
for _, it := range items {
kids := children(it)
out = append(out, row[K]{it, depth, kids != nil, parent})
if opened[it] && kids != nil {
walk(kids, depth+1, len(out)-1)
}
}
}
walk(roots, 0, -1)
return out
}
// steer: Right opens or descends; Left closes or climbs.
func steer[K comparable](rows []row[K], at int, opened map[K]bool, right bool) (to int, toggled bool) {
r := rows[at]
switch {
case right && r.branch && !opened[r.key]:
opened[r.key] = true
return at, true
case right && r.branch && at+1 < len(rows):
return at + 1, false
case !right && r.branch && opened[r.key]:
delete(opened, r.key)
return at, true
case !right && r.parent >= 0:
return r.parent, false
}
return at, false
}
+378
View File
@@ -0,0 +1,378 @@
package data_test
import (
"fmt"
"image"
"slices"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type clock struct{ t time.Time }
func (c *clock) now() time.Time { return c.t }
func newClock() *clock { return &clock{time.Unix(3_000_000, 0)} }
// view builds on a fake clock; inert veils every control.
func view(w, h int, clk *clock, inert bool, build func(c *ui.Context)) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: clk.now, Inert: inert})
build(c)
}, w, h)
}
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
}
func click(t *testing.T, tt *ui.Tester, label string) {
t.Helper()
if err := tt.Click(label); err != nil {
t.Fatal(err)
}
}
func shows(tt *ui.Tester, label string) bool {
_, ok := tt.Find(label)
return ok
}
// slot is the palette slot of pixel x, y.
func slot(t *testing.T, img *image.RGBA, x, y int) pixel.Slot {
t.Helper()
c := img.RGBAAt(x, y)
for s, pc := range pixel.SpringDay.Colors {
if pc.R == c.R && pc.G == c.G && pc.B == c.B {
return pixel.Slot(s)
}
}
t.Fatalf("pixel %d,%d is %v, off the palette", x, y, c)
return 0
}
// keeps fails on off-palette colors or split dots.
func keeps(t *testing.T, img *image.RGBA) {
t.Helper()
b := img.Bounds()
for y := 0; y+1 < b.Dy(); y += 2 {
for x := 0; x+1 < b.Dx(); x += 2 {
slot(t, img, x, y)
c := img.RGBAAt(x, y)
if img.RGBAAt(x+1, y) != c || img.RGBAAt(x, y+1) != c || img.RGBAAt(x+1, y+1) != c {
t.Fatalf("dot at %d,%d is split", x/2, y/2)
}
}
}
}
var heroes = []string{"Aria", "Brom", "Cyra", "Dax", "Eko"}
func party(c *ui.Context, sel *int, o data.ListOptions, changes, sent *int) {
r := data.List(c, "Party", len(heroes), sel, o, func(i int) {
pixel.Text(c, heroes[i], pixel.TextOptions{})
})
if r.Changed {
*changes++
}
if r.Submitted {
*sent++
}
}
func TestListChoosesByClickAndKeys(t *testing.T) {
sel, changes, sent := 0, 0, 0
tt := view(400, 300, newClock(), false, func(c *ui.Context) { party(c, &sel, data.ListOptions{}, &changes, &sent) })
click(t, tt, "Cyra")
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if sel != 3 || changes != 2 || sent != 1 {
t.Fatalf("click, Down, Enter: sel %d, %d changes, %d submits", sel, changes, sent)
}
keeps(t, tt.Image())
}
func TestListOffTakesNoChoice(t *testing.T) {
for _, inert := range []bool{false, true} {
sel, changes, sent := 1, 0, 0
tt := view(400, 300, newClock(), inert, func(c *ui.Context) {
party(c, &sel, data.ListOptions{Disabled: !inert}, &changes, &sent)
})
click(t, tt, "Dax")
tt.Key(0, ui.KeyDown)
if sel != 1 || changes != 0 {
t.Fatalf("inert %v: sel %d after a click and Down", inert, sel)
}
}
}
func roster(c *ui.Context, sort *data.Sort, sel *int, sorted *int) {
cols := []data.Column{{Title: "Name"}, {Title: "LV", Width: 30}}
if data.Table(c, "Roster", cols, len(heroes), sort, sel, data.TableOptions{}, func(r, col int) string {
if col == 0 {
return heroes[r]
}
return fmt.Sprint(r + 1)
}).Sorted {
*sorted++
}
}
func TestTableSortsAndChooses(t *testing.T) {
sort, sel, sorted := data.Sort{}, -1, 0
tt := view(500, 300, newClock(), false, func(c *ui.Context) { roster(c, &sort, &sel, &sorted) })
click(t, tt, "LV")
click(t, tt, "LV")
if sort != (data.Sort{Column: 1, Desc: true}) || sorted != 2 {
t.Fatalf("two clicks on LV gave %+v", sort)
}
click(t, tt, "Name")
if sort != (data.Sort{Column: 0}) {
t.Fatalf("a click on Name gave %+v", sort)
}
click(t, tt, "Cyra, 3")
tt.Key(0, ui.KeyDown)
r := find(t, tt, "Dax, 4")
if bar := slot(t, tt.Image(), int(r.X)+r2i(r.W)-4, int(r.Y)+4); sel != 3 || bar != pixel.Accent {
t.Fatalf("click on Cyra, Down chose %d, painted %v", sel, bar)
}
keeps(t, tt.Image())
}
func TestTableZebraDithersOddRows(t *testing.T) {
sort, sel, sorted := data.Sort{}, -1, 0
tt := view(500, 300, newClock(), false, func(c *ui.Context) { roster(c, &sort, &sel, &sorted) })
shaded := func(label string) int {
r, img, n := find(t, tt, label), tt.Image(), 0
for x := int(r.X) + 120; x < int(r.X)+160; x += 2 {
if slot(t, img, x, int(r.Y)+4) == pixel.Shade {
n++
}
}
return n
}
if even, odd := shaded("Aria, 1"), shaded("Brom, 2"); even != 0 || odd == 0 {
t.Fatalf("Shade dots: even row %d, odd row %d", even, odd)
}
}
var bag = []data.TreeNode{{Label: "Bag", Children: []data.TreeNode{{Label: "Potion"}, {Label: "Ether"}}}, {Label: "Keys"}}
func TestTreeOpensAndSteers(t *testing.T) {
sel := "1"
tt := view(400, 300, newClock(), false, func(c *ui.Context) {
data.Tree(c, "Items", bag, &sel, data.TreeOptions{})
})
r := find(t, tt, "Items")
tt.ClickAt(r.X+60, r.Y+8)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyDown)
if sel != "0/1" {
t.Fatalf("click, Right, Right, Down chose %q", sel)
}
tt.Key(0, ui.KeyLeft)
tt.Key(0, ui.KeyLeft)
tt.Key(0, ui.KeyDown)
if sel != "1" {
t.Fatalf("Left, Left, Down chose %q", sel)
}
tt.ClickAt(r.X+4, r.Y+8)
if sel != "0" || !tt.HasText("Items") {
t.Fatalf("a click on the arrow chose %q", sel)
}
tt.Key(0, ui.KeyDown)
if sel != "0/0" {
t.Fatalf("the arrow did not open Bag: Down chose %q", sel)
}
}
func TestOutlineOpensAndChooses(t *testing.T) {
sel := "/"
kids := map[string][]string{"/": {"/a", "/b"}, "/a": {"/a/x"}}
tt := view(400, 300, newClock(), false, func(c *ui.Context) {
data.Outline(c, "Files", []string{"/"}, func(k string) []string { return kids[k] }, func(k string) string { return k }, &sel, data.OutlineOptions{})
})
click(t, tt, "/")
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
if sel != "/a/x" || !shows(tt, "/b") {
t.Fatalf("Right, Down, Right, Right chose %q", sel)
}
tt.Key(0, ui.KeyLeft)
tt.Key(0, ui.KeyLeft)
if sel != "/a" || shows(tt, "/a/x") {
t.Fatalf("Left, Left chose %q", sel)
}
click(t, tt, "/b")
r := find(t, tt, "/b")
if bar := slot(t, tt.Image(), int(r.X)+4, int(r.Y)+4); sel != "/b" || bar != pixel.Accent {
t.Fatalf("a click chose %q, painted %v", sel, bar)
}
keeps(t, tt.Image())
}
func TestGridViewMovesInTwoDimensions(t *testing.T) {
sel, changes, sent := 0, 0, 0
tt := view(400, 300, newClock(), false, func(c *ui.Context) {
r := data.GridView(c, "Tiles", 30, &sel, data.GridViewOptions{CellWidth: 40, CellHeight: 40}, func(i int) {
ui.Box(c).Grow(1).Label(fmt.Sprintf("tile %d", i))
})
if r.Changed {
changes++
}
if r.Submitted {
sent++
}
})
click(t, tt, "tile 1")
tt.Key(0, ui.KeyDown)
a, b := find(t, tt, "tile 1"), find(t, tt, fmt.Sprintf("tile %d", sel))
if a.X != b.X || b.Y <= a.Y || changes != 2 {
t.Fatalf("Down from tile 1 chose %d at %v, from %v; %d changes", sel, b, a, changes)
}
tt.Key(0, ui.KeyEnter)
if sent != 1 {
t.Fatal("Enter did not submit")
}
keeps(t, tt.Image())
}
func TestInfiniteListAsksOncePerLength(t *testing.T) {
n, asks, done := 3, 0, false
tt := view(300, 300, newClock(), false, func(c *ui.Context) {
if data.InfiniteList(c, "Log", n, data.InfiniteListOptions{Done: done}, func(i int) {
pixel.Text(c, fmt.Sprintf("line %d", i), pixel.TextOptions{})
}).More {
asks++
}
})
tt.Frame()
if asks != 1 || !tt.HasText("NOW LOADING") {
t.Fatalf("%d asks with LOADING %v", asks, tt.HasText("NOW LOADING"))
}
n = 5
tt.Frame()
tt.Frame()
if asks != 2 {
t.Fatalf("%d asks after more rows came", asks)
}
done = true
tt.Frame()
if tt.HasText("NOW LOADING") || asks != 2 {
t.Fatal("Done kept the LOADING row")
}
}
func TestSearchResultsCountAndMark(t *testing.T) {
sel, opened := 0, 0
hits := []data.Hit{{Title: "Fire Sword", Text: "A blade of fire"}, {Title: "Ice Bow", Text: "Shoots ice"}}
tt := view(400, 300, newClock(), false, func(c *ui.Context) {
if data.SearchResults(c, "Results", "fire", hits, &sel, data.SearchResultsOptions{}).Submitted {
opened++
}
})
if !tt.HasText("FOUND: 2") {
t.Fatalf("texts %q", tt.Texts())
}
r, img := find(t, tt, "Fire Sword"), tt.Image()
if marks := slot(t, img, int(r.X)+4, int(r.Y)+6); marks != pixel.Warn {
t.Fatalf("the match is marked %v", marks)
}
click(t, tt, "Ice Bow")
tt.Key(0, ui.KeyEnter)
if sel != 1 || opened != 1 {
t.Fatalf("sel %d, %d opened", sel, opened)
}
if !slices.Contains(tt.Texts(), "Shoots ice") {
t.Fatal("snippets are not read")
}
}
func TestListsKeepWholeDotsWhenScrolledByHalfADot(t *testing.T) {
sel, sort, row, file := 1, data.Sort{}, 1, "row 1"
many := make([]string, 40)
for i := range many {
many[i] = fmt.Sprintf("row %d", i)
}
tt := view(600, 300, newClock(), false, func(c *ui.Context) {
ui.Row(c).Children(func() {
data.List(c, "Rows", len(many), &sel, data.ListOptions{Height: 60}, func(i int) {
pixel.Text(c, many[i], pixel.TextOptions{})
}).Element.Width(100)
data.Table(c, "Grid", []data.Column{{Title: "Row"}}, len(many), &sort, &row, data.TableOptions{Height: 60}, func(r, _ int) string { return many[r] })
data.Outline(c, "Tree", many, func(string) []string { return nil }, func(k string) string { return k }, &file, data.OutlineOptions{Height: 40})
})
})
for _, l := range []string{"Tree", "Grid", "Rows"} {
r := find(t, tt, l)
tt.Move(r.X+20, r.Y+20)
tt.Scroll(r.X+20, r.Y+20, 0, 1)
}
// The pointer stays on a row, hovering it.
keeps(t, tt.Image())
}
func TestOffListsIgnoreTheWheel(t *testing.T) {
many := make([]string, 40)
for i := range many {
many[i] = fmt.Sprintf("row %d", i)
}
for _, inert := range []bool{false, true} {
sel, sort, row, file, tile := 0, data.Sort{}, 0, "row 0", 0
tt := view(800, 300, newClock(), inert, func(c *ui.Context) {
ui.Row(c).Children(func() {
data.List(c, "Rows", len(many), &sel, data.ListOptions{Height: 60, Disabled: !inert}, func(i int) {
pixel.Text(c, many[i], pixel.TextOptions{})
})
data.Table(c, "Grid", []data.Column{{Title: "Row"}}, len(many), &sort, &row, data.TableOptions{Height: 60, Disabled: !inert}, func(r, _ int) string { return many[r] })
data.Outline(c, "Tree", many, func(string) []string { return nil }, func(k string) string { return k }, &file, data.OutlineOptions{Height: 60, Disabled: !inert})
ui.Box(c).Width(120).Children(func() {
data.GridView(c, "Cells", 90, &tile, data.GridViewOptions{CellWidth: 20, CellHeight: 20, Height: 40, Disabled: !inert}, func(i int) {
ui.Box(c).Grow(1).Label(fmt.Sprintf("cell %d", i))
})
})
})
})
for _, l := range []string{"Rows", "Grid", "Tree", "Cells"} {
r := find(t, tt, l)
before := tt.Texts()
tt.Move(r.X+20, r.Y+30)
tt.Scroll(r.X+20, r.Y+30, 0, 80)
if !slices.Equal(before, tt.Texts()) {
t.Fatalf("inert %v: the wheel moved %s", inert, l)
}
}
}
}
func TestInertInfiniteListIgnoresTheWheel(t *testing.T) {
asks := 0
tt := view(300, 300, newClock(), true, func(c *ui.Context) {
if data.InfiniteList(c, "Log", 40, data.InfiniteListOptions{Height: 60}, func(i int) {
pixel.Text(c, fmt.Sprintf("line %d", i), pixel.TextOptions{})
}).More {
asks++
}
})
r := find(t, tt, "Log")
tt.Move(r.X+20, r.Y+30)
for range 20 {
tt.Scroll(r.X+20, r.Y+30, 0, 200)
}
if !shows(tt, "line 0") || asks != 0 {
t.Fatalf("the wheel moved an inert list to %q; %d asks", tt.Texts(), asks)
}
}
+29
View File
@@ -0,0 +1,29 @@
package data
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
type DescriptionListOptions struct {
// TermWidth is the terms' column in dots; zero means 80.
TermWidth int
}
// DescriptionList sets terms beside their descriptions.
func DescriptionList(c *ui.Context, pairs [][2]string, o DescriptionListOptions) *ui.Element {
k := pixel.K(c)
tw := pixel.Or("DescriptionList TermWidth", o.TermWidth, 80)
return ui.Column(c).FillWidth().Gap(k.D(4)).Children(func() {
for _, p := range pairs {
ui.Row(c).FillWidth().Gap(k.D(8)).Children(func() {
ui.Box(c).Width(k.D(tw)).Shrink(0).Children(func() {
text.Paragraph(c, p[0], text.ParagraphOptions{Slot: pixel.Faint})
})
ui.Column(c).Grow(1).Children(func() { text.Paragraph(c, p[1], text.ParagraphOptions{}) })
})
}
})
}
+143
View File
@@ -0,0 +1,143 @@
package data_test
import (
"fmt"
"math"
"testing"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestDescriptionListSetsTermsBesideText(t *testing.T) {
tt := view(400, 200, newClock(), false, func(c *ui.Context) {
data.DescriptionList(c, [][2]string{{"Class", "Knight"}, {"Home", "Castle Town"}}, data.DescriptionListOptions{})
})
term, desc := find(t, tt, "Home"), find(t, tt, "Castle Town")
if desc.X != term.X+176 || desc.Y != term.Y {
t.Fatalf("description at %v beside term at %v", desc, term)
}
keeps(t, tt.Image())
}
func TestLeaderboardMedalsAndPlayer(t *testing.T) {
scores := []data.Score{{Name: "ACE", Score: 9000}, {Name: "BOB", Score: 8000}, {Name: "CAT", Score: 7000}, {Name: "YOU", Score: 6000}, {Name: "DOG", Score: 5000}}
tt := view(300, 200, newClock(), false, func(c *ui.Context) {
data.Leaderboard(c, scores, data.LeaderboardOptions{Player: 3})
})
for _, l := range []string{"GOLD", "SILVER", "BRONZE", "0006000", "4 YOU 6000"} {
if !shows(tt, l) {
t.Fatalf("no %q in %q", l, tt.Texts())
}
}
r, img := find(t, tt, "4 YOU 6000"), tt.Image()
if s := slot(t, img, int(r.X)+r2i(r.W)-4, int(r.Y)+4); s != pixel.Accent {
t.Fatalf("your row is %v", s)
}
keeps(t, img)
}
func r2i(f float32) int { return int(f) }
func TestTimelineMarksEachEvent(t *testing.T) {
tt := view(300, 200, newClock(), false, func(c *ui.Context) {
data.Timeline(c, []data.Event{{Date: "1986", Text: "Zelda"}, {Date: "1991", Text: "A Link to the Past"}}, data.TimelineOptions{})
})
for _, l := range []string{"1986", "Zelda", "1991", "A Link to the Past"} {
if !shows(tt, l) {
t.Fatalf("no %q", l)
}
}
r, img := find(t, tt, "1986"), tt.Image()
// The marker's middle sits 13 dots left of the date.
if s := slot(t, img, int(r.X)-26+8, int(r.Y)+10); s != pixel.Accent {
t.Fatalf("marker is %v", s)
}
keeps(t, img)
}
func TestStatCompareInksDeltas(t *testing.T) {
tt := view(300, 200, newClock(), false, func(c *ui.Context) {
data.StatCompare(c, []data.Change{{Label: "ATK", Old: 12, New: 15}, {Label: "DEF", Old: 9, New: 7}, {Label: "SPD", Old: 5, New: 5}}, data.StatCompareOptions{})
})
for _, l := range []string{"ATK 12 → 15 ▲3", "DEF 9 → 7 ▼2", "SPD 5 → 5 "} {
if !shows(tt, l) {
t.Fatalf("no %q in %q", l, tt.Texts())
}
}
img := tt.Image()
ink := func(label string) map[pixel.Slot]bool {
r, out := find(t, tt, label), map[pixel.Slot]bool{}
for y := int(r.Y); y < int(r.Y+r.H); y += 2 {
for x := int(r.X); x < int(r.X+r.W); x += 2 {
out[slot(t, img, x, y)] = true
}
}
return out
}
if up, dn := ink("ATK 12 → 15 ▲3"), ink("DEF 9 → 7 ▼2"); !up[pixel.Good] || up[pixel.Bad] || !dn[pixel.Bad] || dn[pixel.Good] {
t.Fatalf("delta inks: up %v, down %v", up, dn)
}
keeps(t, img)
}
func TestBestiaryHidesUnseen(t *testing.T) {
m := data.Monster{Sprite: icon.Skull, Name: "Skeleton", Stats: [][2]string{{"HP", "30"}}, Notes: "Rattles."}
for _, seen := range []bool{true, false} {
m.Seen = seen
tt := view(300, 240, newClock(), false, func(c *ui.Context) { data.Bestiary(c, m, data.BestiaryOptions{}) })
if shows(tt, "Skeleton") != seen || shows(tt, "HP 30") != seen || shows(tt, "Rattles.") != seen || shows(tt, "???") == seen {
t.Fatalf("seen %v: texts %q", seen, tt.Texts())
}
keeps(t, tt.Image())
}
}
func TestFeedListsEntries(t *testing.T) {
tt := view(300, 200, newClock(), false, func(c *ui.Context) {
data.Feed(c, []data.Activity{{Icon: icon.Star, Text: "Aria found a star", Time: "2m"}, {Icon: icon.Coin, Text: "Brom sold a coin", Time: "5m"}}, data.FeedOptions{})
})
a, b := find(t, tt, "Aria found a star"), find(t, tt, "Brom sold a coin")
if !shows(tt, "2m") || !shows(tt, "5m") || b.Y <= a.Y || b.X != a.X {
t.Fatalf("entries at %v and %v", a, b)
}
keeps(t, tt.Image())
}
func TestCardGridFitsColumns(t *testing.T) {
// Widths are pixels, two per dot; cards need 96 dots.
for _, tc := range []struct{ w, cols int }{{800, 4}, {500, 2}, {180, 1}} {
tt := view(tc.w+32, 300, newClock(), false, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
data.CardGrid(c, 6, data.CardGridOptions{}, func(i int) {
pixel.Text(c, fmt.Sprintf("card %d", i), pixel.TextOptions{})
})
})
})
tt.Frame()
first, row := find(t, tt, "card 0"), 0
for i := range 6 {
if find(t, tt, fmt.Sprintf("card %d", i)).Y == first.Y {
row++
}
}
if row != tc.cols {
t.Fatalf("%d px wide: %d cards in the first row, want %d", tc.w, row, tc.cols)
}
keeps(t, tt.Image())
}
}
func TestItemSlotRefusesNaNCooldown(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("a NaN cooldown did not panic")
}
}()
view(100, 100, newClock(), false, func(c *ui.Context) {
data.ItemSlot(c, data.Item{Name: "Ether"}, data.ItemSlotOptions{Cooldown: math.NaN()})
})
}
+42
View File
@@ -0,0 +1,42 @@
package data
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
// Activity is one entry of a feed.
type Activity struct {
Icon *pixel.Sprite
Text, Time string
}
type FeedOptions struct{}
// Feed lists activity: icon, text and time, dotted apart.
func Feed(c *ui.Context, entries []Activity, o FeedOptions) *ui.Element {
k := pixel.K(c)
return ui.Column(c).FillWidth().Children(func() {
for i, a := range entries {
last := i == len(entries)-1
ui.Row(c).FillWidth().Gap(k.D(6)).Padding(k.D(4), 0).Draw(func(p *ui.Painter, r ui.Rect) {
if !last {
g := k.Canvas(p, r)
g.Checker(0, g.H-1, g.W, 1, pixel.Edge)
}
}).Children(func() {
ui.Box(c).Size(k.D(12), k.D(12)).Shrink(0).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
if a.Icon != nil {
k.Canvas(p, r).Sprite(0, 0, a.Icon, 0, pixel.Accent, 1)
}
})
ui.Column(c).Grow(1).Children(func() {
text.Paragraph(c, a.Text, text.ParagraphOptions{})
pixel.Text(c, a.Time, pixel.TextOptions{Slot: pixel.Faint})
})
})
}
})
}
+46
View File
@@ -0,0 +1,46 @@
package data
import (
"fmt"
"log/slog"
"slices"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type FilterBarOptions struct {
Disabled bool
}
type FilterBarResult struct {
Element *ui.Element
Changed bool
}
// FilterBar toggles filter chips; CLEAR turns them all off.
func FilterBar(c *ui.Context, text string, labels []string, on []bool, o FilterBarOptions) FilterBarResult {
k := pixel.K(c)
if len(on) != len(labels) {
panic(fmt.Sprintf("data: FilterBar has %d labels, %d flags", len(labels), len(on)))
}
row := ui.Row(c).Gap(k.D(4)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(text)
res := FilterBarResult{Element: row}
row.Children(func() {
for i, l := range labels {
if input.Toggle(c, l, &on[i], input.ToggleOptions{Size: input.Small, Disabled: o.Disabled}).Changed {
res.Changed = true
}
}
if input.Button(c, k.Msg("clear"), input.ButtonOptions{Size: input.Small, Variant: input.Ghost, Disabled: o.Disabled || !slices.Contains(on, true)}).Clicked {
clear(on)
res.Changed = true
}
})
if res.Changed {
slog.Debug("8bit: filters", "label", text, "on", on)
}
return res
}
+110
View File
@@ -0,0 +1,110 @@
package data
import (
"log/slog"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type GridViewOptions struct {
// CellWidth and CellHeight are each item's dots; zeros mean 48.
CellWidth, CellHeight int
// Height is the view's in dots; zero means 140.
Height int
Disabled bool
}
type GridViewResult struct {
Element *ui.Element
// Submitted is Enter or a double click on an item.
Changed, Submitted bool
}
type gridKey struct{}
// grid holds the rows' place and clicks met during layout.
type grid struct {
rows ui.ListState
clicked, double bool
}
// GridView is a virtual grid framing its chosen item.
func GridView(c *ui.Context, text string, n int, selected *int, o GridViewOptions, item func(i int)) GridViewResult {
k := pixel.K(c)
off := o.Disabled || pixel.Current(c).Inert
cw, ch := pixel.Or("GridView CellWidth", o.CellWidth, 48), pixel.Or("GridView CellHeight", o.CellHeight, 48)
box := ui.Box(c).FillWidth().Role(ui.RoleGroup).Label(text)
if !off {
box.Focusable()
}
pixel.Park(c, box, off)
s := ui.Local(box, gridKey{}, func() grid { return grid{} })
res := GridViewResult{Element: box, Changed: s.clicked, Submitted: s.double}
s.clicked, s.double = false, false
cols := 1
box.Children(func() {
holder := ui.Box(c).FillWidth()
w, check := pixel.WrapWidth(c, holder)
cols = max(1, w/cw)
holder.Draw(func(p *ui.Painter, r ui.Rect) { check(r) }).Children(func() {
ui.List(c, &s.rows, (n+cols-1)/cols, pixel.Lazy(c, func(r int) {
ui.Row(c).Children(func() {
for i := r * cols; i < min(n, (r+1)*cols); i++ {
cell := ui.Box(c).Size(k.D(cw), k.D(ch))
if !off && cell.Clicked() {
*selected, s.clicked = i, true
box.Focus()
c.Invalidate()
}
if !off && cell.DoubleClicked() {
s.double = true
}
on := i == *selected
cell.Draw(func(p *ui.Painter, r ui.Rect) {
if on {
g := k.Canvas(p, r)
g.Rect(0, 0, g.W, 2, pixel.Accent)
g.Rect(0, g.H-2, g.W, 2, pixel.Accent)
g.Rect(0, 0, 2, g.H, pixel.Accent)
g.Rect(g.W-2, 0, 2, g.H, pixel.Accent)
}
}).Children(func() { item(i) })
}
})
})).Width(k.D(cols * cw)).Height(k.D(pixel.Or("GridView Height", o.Height, 140)))
})
veil(c, off)
})
old := *selected
for _, m := range []struct {
key ui.Key
to int
}{
{ui.KeyLeft, old - 1}, {ui.KeyRight, old + 1}, {ui.KeyUp, old - cols}, {ui.KeyDown, old + cols},
{ui.KeyHome, 0}, {ui.KeyEnd, n - 1},
} {
if box.Shortcut(0, m.key) && m.to >= 0 && m.to < n {
*selected = m.to
}
}
if *selected != old {
res.Changed = true
s.rows.ScrollIntoView(*selected / cols)
}
if box.Shortcut(0, ui.KeyEnter) {
res.Submitted = true
}
if off {
box.Description(k.Msg("disabled"))
} else if *selected >= 0 {
box.Description(strconv.Itoa(*selected+1) + "/" + strconv.Itoa(n))
}
well(c, box, pixel.State{Focus: box.FocusRing(false).FocusVisible(), Disabled: o.Disabled})
if res.Changed || res.Submitted {
slog.Debug("8bit: grid view", "label", text, "selected", *selected, "submitted", res.Submitted)
}
return res
}
+69
View File
@@ -0,0 +1,69 @@
package data
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/feedback"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type InfiniteListOptions struct {
// Height is in dots; zero means 120.
Height int
// Done hides the LOADING row: there is no more.
Done bool
}
type InfiniteListResult struct {
Element *ui.Element
// More asks for rows past the n there are.
More bool
}
type moreKey struct{}
// asking remembers the length last asked past.
type asking struct {
at int
more bool
list ui.ListState
}
// InfiniteList asks for more when its LOADING row shows.
func InfiniteList(c *ui.Context, text string, n int, o InfiniteListOptions, row func(i int)) InfiniteListResult {
k := pixel.K(c)
box := ui.Box(c)
s := ui.Local(box, moreKey{}, func() asking { return asking{at: -1} })
res := InfiniteListResult{Element: box, More: s.more}
s.more = false
rows := n + 1
if o.Done {
rows = n
}
var e *ui.Element
box.Children(func() {
e = ui.List(c, &s.list, rows, pixel.Lazy(c, func(i int) {
if i < n {
row(i)
return
}
// Built as it nears the view: ask once per length.
if s.at != n {
s.at, s.more = n, true
c.Invalidate()
}
ui.Row(c).Gap(k.D(4)).Padding(k.D(2), k.D(6)).AlignItems(ui.Center).Children(func() {
feedback.DotsLoader(c, k.Msg("loading"), feedback.DotsLoaderOptions{})
pixel.Text(c, k.Msg("loading"), pixel.TextOptions{Slot: pixel.Faint})
})
})).Padding(0, k.D(3)).Height(k.D(pixel.Or("InfiniteList Height", o.Height, 120))).Label(text)
veil(c, pixel.Current(c).Inert)
})
well(c, box, pixel.State{Focus: e.FocusRing(false).FocusVisible()})
if res.More {
slog.Debug("8bit: list wants more", "label", text, "rows", n)
}
return res
}
+97
View File
@@ -0,0 +1,97 @@
package data
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type InventoryOptions struct {
// Columns is the grid's width; zero means 6.
Columns int
Disabled bool
}
type InventoryResult struct {
Element *ui.Element
// Swapped reports two slots traded by a drag.
Changed, Swapped bool
}
type bagKey struct{}
// bag tracks a drag; a dead one never swaps.
type bag struct {
from, over int
held, dead bool
}
// Inventory is a slot grid; arrows choose, drags swap.
func Inventory(c *ui.Context, text string, items []Item, selected *int, o InventoryOptions) InventoryResult {
k := pixel.K(c)
cols := pixel.Or("Inventory Columns", o.Columns, 6)
off := o.Disabled || pixel.Current(c).Inert
if *selected < 0 || *selected >= len(items) {
panic(fmt.Sprintf("data: Inventory slot %d of %d", *selected, len(items)))
}
old := *selected
shell, base, st, _ := pixel.Pad(c, text, cols, len(items), slotSize, slotSize, selected, o.Disabled)
b := ui.Local(base, bagKey{}, func() bag { return bag{} })
res := InventoryResult{Element: shell, Changed: *selected != old}
px, py, _ := base.PointerPosition()
at := func() int {
col, row := int(px/k.Dot())/slotSize, int(py/k.Dot())/slotSize
if px < 0 || py < 0 || col >= cols || row*cols+col >= len(items) {
return -1
}
return row*cols + col
}
n := len(items)
switch _, _, pressed := base.Dragged(); {
case pressed && (off || b.held && max(b.from, b.over) >= n):
b.held, b.dead = false, true
case pressed && b.dead:
case pressed && !b.held:
b.held, b.from, b.over = true, at(), at()
case pressed:
b.over = at()
case b.held:
if !off && min(b.from, b.over) >= 0 && max(b.from, b.over) < n && b.from != b.over {
items[b.from], items[b.over] = items[b.over], items[b.from]
res.Swapped = true
}
b.held = false
default:
b.dead = false
}
base.Description(itemName(items[*selected]))
drag := -1
if b.held && b.over != b.from {
drag = b.from
}
shown := append([]Item(nil), items...)
shell.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i, it := range shown {
if i == drag {
it = Item{}
}
slot(g, i%cols*slotSize, i/cols*slotSize, it, 0, i == *selected)
}
// The dragged item rides with the pointer.
if drag >= 0 && shown[drag].Sprite != nil {
g.Sprite(int(px/k.Dot())-6, int(py/k.Dot())-6, shown[drag].Sprite, 0, pixel.Ink, 1)
}
g.Finish(st)
})
if res.Changed {
slog.Debug("8bit: inventory chose", "label", text, "slot", *selected)
}
if res.Swapped {
slog.Debug("8bit: inventory swap", "label", text, "from", b.from, "to", b.over)
}
return res
}
+70
View File
@@ -0,0 +1,70 @@
package data
import (
"fmt"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Item is what a slot holds; no Sprite means empty.
type Item struct {
Sprite *pixel.Sprite
Name string
// Count shows when above one.
Count int
Rarity display.Rarity
}
type ItemSlotOptions struct {
// Cooldown, 0 to 1, veils the slot downward.
Cooldown float64
Selected bool
}
// ItemSlot shows an item, count, rarity edge and cooldown.
func ItemSlot(c *ui.Context, it Item, o ItemSlotOptions) *ui.Element {
k := pixel.K(c)
if !(o.Cooldown >= 0 && o.Cooldown <= 1) {
panic(fmt.Sprintf("data: Cooldown %v outside 0–1", o.Cooldown))
}
return ui.Box(c).Role(ui.RoleImage).Label(itemName(it)).Size(k.D(slotSize), k.D(slotSize)).Draw(func(p *ui.Painter, r ui.Rect) {
slot(k.Canvas(p, r), 0, 0, it, o.Cooldown, o.Selected)
})
}
const slotSize = 22
// itemName reads an item with its count.
func itemName(it Item) string {
if it.Count > 1 {
return it.Name + " ×" + strconv.Itoa(it.Count)
}
return it.Name
}
// slot draws a slot with its top-left at x, y.
func slot(g pixel.Canvas, x, y int, it Item, cool float64, chosen bool) {
edge := pixel.Edge
if it.Sprite != nil {
edge = it.Rarity.Slot()
}
if chosen {
g.Rect(x, y, slotSize, slotSize, pixel.Accent)
}
g.Rect(x+1, y+1, slotSize-2, slotSize-2, edge)
g.Rect(x+2, y+2, slotSize-4, slotSize-4, pixel.Well)
if sp := it.Sprite; sp != nil {
g.Sprite(x+2+(slotSize-4-sp.W)/2, y+2+(slotSize-4-sp.H)/2, sp, 0, pixel.Ink, 1)
}
if h := int(cool * (slotSize - 4)); h > 0 {
g.Veil(x+2, y+2, slotSize-4, h, pixel.Shade, 10)
}
if it.Count > 1 {
n := strconv.Itoa(it.Count)
g.Micro(x+slotSize-2-pixel.MicroWidth(n), y+slotSize-8, n, pixel.Ink, 1)
}
}
+59
View File
@@ -0,0 +1,59 @@
package data
import (
"fmt"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
// Score is a leaderboard entry.
type Score struct {
Name string
Score int
}
type LeaderboardOptions struct {
// Player is your row, marked; -1 marks none.
Player int
}
// Leaderboard ranks scores; the top three wear medals.
func Leaderboard(c *ui.Context, scores []Score, o LeaderboardOptions) *ui.Element {
k := pixel.K(c)
if o.Player < -1 || o.Player >= len(scores) {
panic(fmt.Sprintf("data: Leaderboard Player %d of %d", o.Player, len(scores)))
}
return ui.Column(c).AlignItems(ui.Stretch).Role(ui.RoleTable).Children(func() {
for i, s := range scores {
you, ink := i == o.Player, pixel.Ink
if you {
ink = pixel.AccentInk
}
ui.Row(c).Height(k.D(pixel.LineHeight+4)).Gap(k.D(6)).Padding(0, k.D(4)).AlignItems(ui.Center).Role(ui.RoleRow).
Label(fmt.Sprintf("%d %s %d", i+1, s.Name, s.Score)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
if you {
g.Rect(0, 0, g.W, g.H, pixel.Accent)
} else if i%2 == 1 {
g.Veil(0, 0, g.W, g.H, pixel.Shade, 4)
}
}).Children(func() {
ui.Box(c).Width(k.D(14)).Shrink(0).AlignItems(ui.Center).Children(func() {
if i < 3 {
display.Medal(c, display.Rank(i), display.MedalOptions{})
} else {
text.MicroText(c, strconv.Itoa(i+1), text.MicroTextOptions{Slot: ink})
}
})
pixel.Text(c, s.Name, pixel.TextOptions{Slot: ink})
ui.Box(c).Grow(1)
text.MicroText(c, fmt.Sprintf("%07d", s.Score), text.MicroTextOptions{Slot: ink})
})
}
})
}
+60
View File
@@ -0,0 +1,60 @@
package data
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type ListOptions struct {
// Height is in dots; zero means 120.
Height int
Disabled bool
}
type ListResult struct {
Element *ui.Element
// Submitted is Enter or a double click on a row.
Changed, Submitted bool
}
type listKey struct{}
// List is a virtual list; ▶ marks the chosen row.
func List(c *ui.Context, text string, n int, selected *int, o ListOptions, row func(i int)) ListResult {
k := pixel.K(c)
off := o.Disabled || pixel.Current(c).Inert
box := ui.Box(c)
st := ui.Local(box, listKey{}, func() ui.ListState { return ui.ListState{} })
st.Selected = selected
if off {
st.Selected = nil
}
var e *ui.Element
box.Children(func() {
e = ui.List(c, st, n, pixel.Lazy(c, func(i int) {
on := i == *selected
// The chosen row is AccentLit under a ▶.
ui.Row(c).FillWidth().Padding(k.D(1), k.D(2), k.D(1), k.D(12)).Draw(func(p *ui.Painter, r ui.Rect) {
if on {
g := k.Canvas(p, r)
g.Rect(0, 0, g.W, g.H, pixel.AccentLit)
g.Sprite(3, (g.H-pointer.H)/2, pointer, 0, pixel.Accent, 1)
}
}).Children(func() { row(i) })
})).Height(k.D(pixel.Or("List Height", o.Height, 120))).Label(text)
veil(c, off)
})
pixel.Park(c, e, off)
if off {
e.Description(k.Msg("disabled"))
}
well(c, box, pixel.State{Focus: e.FocusRing(false).FocusVisible(), Disabled: o.Disabled})
res := ListResult{Element: box, Changed: e.Changed(), Submitted: e.Submitted()}
if res.Changed {
slog.Debug("8bit: list", "label", text, "selected", *selected)
}
return res
}
+50
View File
@@ -0,0 +1,50 @@
package data_test
import (
"bytes"
"log/slog"
"strings"
"testing"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
)
// logs captures debug logs while fn runs.
func logs(fn func()) string {
var buf bytes.Buffer
old := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
defer slog.SetDefault(old)
fn()
return buf.String()
}
func TestStateChangesAreLogged(t *testing.T) {
sort, row, slot, file := data.Sort{}, 0, 0, "/"
items := []data.Item{{Sprite: icon.Potion, Name: "Potion"}, {Sprite: icon.Gem, Name: "Gem"}}
kids := map[string][]string{"/": {"/a"}}
tt := view(700, 300, newClock(), false, func(c *ui.Context) {
ui.Row(c).Children(func() {
data.Table(c, "Roster", []data.Column{{Title: "Name"}}, len(heroes), &sort, &row, data.TableOptions{}, func(r, _ int) string { return heroes[r] })
data.Inventory(c, "Bag", items, &slot, data.InventoryOptions{})
data.Outline(c, "Files", []string{"/"}, func(k string) []string { return kids[k] }, func(k string) string { return k }, &file, data.OutlineOptions{})
})
})
out := logs(func() {
click(t, tt, "Brom")
tt.Key(0, ui.KeyDown)
r := find(t, tt, "Bag")
tt.ClickAt(r.X+4, r.Y+4)
tt.Key(0, ui.KeyRight)
click(t, tt, "/")
tt.Key(0, ui.KeyRight)
})
for _, want := range []string{"8bit: table chose", "8bit: inventory chose", "8bit: outline toggled"} {
if !strings.Contains(out, want) {
t.Errorf("no %q in the logs:\n%s", want, out)
}
}
}
+96
View File
@@ -0,0 +1,96 @@
package data
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type MatrixOptions struct {
Disabled bool
}
type MatrixResult struct {
Element *ui.Element
Changed bool
}
type matrixKey struct{}
// Matrix toggles cells under row and column headers.
func Matrix(c *ui.Context, text string, rows, cols []string, on [][]bool, o MatrixOptions) MatrixResult {
k := pixel.K(c)
if len(on) != len(rows) {
panic(fmt.Sprintf("data: Matrix has %d rows, %d of values", len(rows), len(on)))
}
cw, rw, ch := 14, 0, pixel.LineHeight+2
for _, l := range cols {
cw = max(cw, pixel.Measure(l)+6)
}
for i, l := range rows {
rw = max(rw, pixel.Measure(l)+6)
if len(on[i]) != len(cols) {
panic(fmt.Sprintf("data: Matrix row %q has %d values, not %d", l, len(on[i]), len(cols)))
}
}
box := ui.Column(c)
cur := ui.Local(box, matrixKey{}, func() int { return 0 })
res := MatrixResult{Element: box}
head := func(l string, w, h int) {
ui.Box(c).Size(k.D(w), k.D(h)).Role(ui.RoleText).Label(l).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Text((g.W-pixel.Measure(l))/2, (g.H-pixel.LineHeight)/2, l, pixel.Faint, 1)
})
}
box.Children(func() {
ui.Row(c).Padding(0, 0, 0, k.D(rw)).Children(func() {
for _, l := range cols {
head(l, cw, pixel.LineHeight)
}
})
ui.Row(c).Children(func() {
ui.Column(c).Children(func() {
for _, l := range rows {
head(l, rw, ch)
}
})
n := len(cols)
*cur = max(0, min(*cur, len(rows)*n-1))
shell, base, st, picked := pixel.Pad(c, text, n, len(rows)*n, cw, ch, cur, o.Disabled)
if picked >= 0 {
on[picked/n][picked%n] = !on[picked/n][picked%n]
res.Changed = true
}
if *cur < len(rows)*n {
r, cl, state := *cur/n, *cur%n, "off"
if on[r][cl] {
state = "on"
}
base.Description(rows[r] + " " + cols[cl] + " " + k.Msg(state))
}
shell.Draw(func(p *ui.Painter, rc ui.Rect) {
g := k.Canvas(p, rc)
for r := range rows {
for cl := range cols {
x, y := cl*cw, r*ch
if st.Focus && r*n+cl == *cur {
g.Cursor(x, y, cw, ch)
}
g.Frame(x+(cw-8)/2, y+(ch-8)/2, 8, 8, pixel.Sunken, pixel.Well)
if on[r][cl] {
g.Rect(x+(cw-8)/2+2, y+(ch-8)/2+2, 4, 4, pixel.Accent)
}
}
}
g.Finish(st)
})
})
})
if res.Changed {
slog.Debug("8bit: matrix", "label", text, "cell", *cur)
}
return res
}
+104
View File
@@ -0,0 +1,104 @@
package data
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type OutlineOptions struct {
// Height is in dots; zero means 140.
Height int
Disabled bool
}
type OutlineResult struct {
Element *ui.Element
Changed, Submitted bool
}
type outlineKey[K comparable] struct{}
// outline is a tree's open branches and its chosen row.
type outline[K comparable] struct {
opened map[K]bool
list ui.ListState
at int
}
// Outline is a virtual tree for huge hierarchies.
func Outline[K comparable](c *ui.Context, text string, roots []K, children func(K) []K, label func(K) string, selected *K, o OutlineOptions) OutlineResult {
k := pixel.K(c)
off := o.Disabled || pixel.Current(c).Inert
box := ui.Box(c)
st := ui.Local(box, outlineKey[K]{}, func() outline[K] { return outline[K]{opened: map[K]bool{}} })
rows := flatten(roots, children, st.opened)
st.at = -1
for i, r := range rows {
if r.key == *selected {
st.at = i
}
}
st.list.Selected = &st.at
st.list.Key = func(i int) any { return rows[i].key }
st.list.Label = func(i int) string { return label(rows[i].key) }
if off {
st.list.Selected = nil
}
var e *ui.Element
box.Children(func() {
e = ui.List(c, &st.list, len(rows), pixel.Lazy(c, func(i int) {
r, on, ink := rows[i], i == st.at, pixel.Ink
if on {
ink = pixel.AccentInk
}
ui.Row(c).Height(k.D(pixel.LineHeight)).AlignItems(ui.Center).Draw(func(p *ui.Painter, rc ui.Rect) {
if on {
g := k.Canvas(p, rc)
g.Rect(0, 0, g.W, g.H, pixel.Accent)
}
}).Children(func() {
ui.Box(c).Width(k.D(r.depth * 10)).Shrink(0)
arrow := ui.Box(c).Size(k.D(12), k.D(pixel.LineHeight)).Shrink(0).Role(ui.RoleNone)
if r.branch && !off && arrow.Clicked() {
st.opened[r.key] = !st.opened[r.key]
slog.Debug("8bit: outline toggled", "label", text, "row", i, "open", st.opened[r.key])
}
shut := !st.opened[r.key]
arrow.Draw(func(p *ui.Painter, rc ui.Rect) {
g := k.Canvas(p, rc)
switch {
case !r.branch:
case shut:
g.Sprite(4, 3, pointer, 0, ink, 1)
default:
g.Sprite(3, 4, down, 0, ink, 1)
}
})
pixel.Text(c, label(r.key), pixel.TextOptions{Slot: ink})
})
})).Height(k.D(pixel.Or("Outline Height", o.Height, 140))).Label(text)
right, left := e.Shortcut(0, ui.KeyRight), e.Shortcut(0, ui.KeyLeft)
if (right || left) && st.at >= 0 && !off {
at, toggled := steer(rows, st.at, st.opened, right)
if toggled {
slog.Debug("8bit: outline toggled", "label", text, "row", at, "open", right)
}
st.at, rows = at, flatten(roots, children, st.opened)
}
veil(c, off)
})
pixel.Park(c, e, off)
if off {
e.Description(k.Msg("disabled"))
}
well(c, box, pixel.State{Focus: e.FocusRing(false).FocusVisible(), Disabled: o.Disabled})
res := OutlineResult{Element: box, Submitted: e.Submitted()}
if st.at >= 0 && rows[st.at].key != *selected {
*selected, res.Changed = rows[st.at].key, true
slog.Debug("8bit: outline", "label", text, "row", st.at)
}
return res
}
+65
View File
@@ -0,0 +1,65 @@
package data
import (
"fmt"
"log/slog"
"slices"
"strconv"
"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 PagerOptions struct {
// Sizes offered; nil means 10, 20 and 50.
Sizes []int
Disabled bool
}
type PagerResult struct {
Element *ui.Element
Changed bool
}
// Pager pages through total items: "1–20 OF 312".
func Pager(c *ui.Context, text string, total int, page, size *int, o PagerOptions) PagerResult {
k := pixel.K(c)
sizes := o.Sizes
if sizes == nil {
sizes = []int{10, 20, 50}
}
at := slices.Index(sizes, *size)
if total < 0 || *page < 0 || at < 0 || slices.Min(sizes) < 1 {
panic(fmt.Sprintf("data: Pager page %d, size %d of %v, total %d", *page, *size, sizes, total))
}
old, oldSize := *page, *size
last := func() int { return max(0, (total-1) / *size) }
*page = min(*page, last())
row := ui.Row(c).Gap(k.D(6)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(text)
row.Children(func() {
pixel.Text(c, k.Msg("per_page"), pixel.TextOptions{Slot: pixel.Faint})
labels := make([]string, len(sizes))
for i, s := range sizes {
labels[i] = strconv.Itoa(s)
}
if input.Segmented(c, k.Msg("per_page"), labels, &at, input.SegmentedOptions{Disabled: o.Disabled}).Changed {
*size, *page = sizes[at], 0
}
from, to := min(*page**size+1, total), min((*page+1)**size, total)
pixel.Text(c, fmt.Sprintf(k.Msg("range"), from, to, total), pixel.TextOptions{})
if input.IconButton(c, icon.ChevronLeft, k.Msg("prev"), input.IconButtonOptions{Disabled: o.Disabled || *page == 0}).Clicked {
*page--
}
if input.IconButton(c, icon.ChevronRight, k.Msg("next"), input.IconButtonOptions{Disabled: o.Disabled || *page >= last()}).Clicked {
*page++
}
})
res := PagerResult{Element: row, Changed: *page != old || *size != oldSize}
if res.Changed {
slog.Debug("8bit: pager", "label", text, "page", *page, "size", *size)
}
return res
}
+63
View File
@@ -0,0 +1,63 @@
package data
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Property is one row; set exactly one value.
type Property struct {
Name string
Text *string
Number *float64
Switch *bool
// Min and Max bound Number as NumberInput's do.
Min, Max float64
}
type PropertyGridOptions struct {
// NameWidth is the names' column in dots; zero means 80.
NameWidth int
Disabled bool
}
type PropertyGridResult struct {
Element *ui.Element
Changed bool
}
// PropertyGrid edits named text, number and switch values.
func PropertyGrid(c *ui.Context, text string, props []Property, o PropertyGridOptions) PropertyGridResult {
k := pixel.K(c)
nw := pixel.Or("PropertyGrid NameWidth", o.NameWidth, 80)
col := ui.Column(c).Gap(k.D(2)).Role(ui.RoleGroup).Label(text)
res := PropertyGridResult{Element: col}
col.Children(func() {
for _, p := range props {
ui.Row(c).Gap(k.D(4)).AlignItems(ui.Center).Children(func() {
ui.Box(c).Width(k.D(nw)).Shrink(0).Children(func() {
pixel.Text(c, p.Name, pixel.TextOptions{Slot: pixel.Faint})
})
switch {
case p.Text != nil && p.Number == nil && p.Switch == nil:
res.Changed = input.TextInput(c, p.Name, p.Text, input.TextInputOptions{Disabled: o.Disabled}).Changed || res.Changed
case p.Number != nil && p.Text == nil && p.Switch == nil:
res.Changed = input.NumberInput(c, p.Name, p.Number, input.NumberInputOptions{Min: p.Min, Max: p.Max, Disabled: o.Disabled}).Changed || res.Changed
case p.Switch != nil && p.Text == nil && p.Number == nil:
res.Changed = input.Switch(c, "", p.Switch, input.SwitchOptions{Label: p.Name, Disabled: o.Disabled}).Changed || res.Changed
default:
panic(fmt.Sprintf("data: Property %q needs exactly one value", p.Name))
}
})
}
})
if res.Changed {
slog.Debug("8bit: property grid", "label", text)
}
return res
}
+45
View File
@@ -0,0 +1,45 @@
package data
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
// Hit is one search result.
type Hit struct {
Title, Text string
}
type SearchResultsOptions struct {
// Height is the list's in dots; zero means 120.
Height int
Disabled bool
}
type SearchResultsResult = ListResult
// SearchResults counts hits and lists them with query matches marked.
func SearchResults(c *ui.Context, label, query string, hits []Hit, selected *int, o SearchResultsOptions) SearchResultsResult {
k := pixel.K(c)
col := ui.Column(c).Gap(k.D(4))
var res ListResult
col.Children(func() {
pixel.Text(c, fmt.Sprintf(k.Msg("found"), len(hits)), pixel.TextOptions{Slot: pixel.Faint})
res = List(c, label, len(hits), selected, ListOptions{Height: o.Height, Disabled: o.Disabled}, func(i int) {
ui.Column(c).Children(func() {
text.Highlight(c, hits[i].Title, query, text.HighlightOptions{})
text.Highlight(c, hits[i].Text, query, text.HighlightOptions{Slot: pixel.Faint})
})
})
})
if res.Submitted {
slog.Debug("8bit: search result opened", "label", label, "hit", *selected)
}
res.Element = col
return res
}
+221
View File
@@ -0,0 +1,221 @@
package data_test
import (
"slices"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestCollapsibleShowsContentWhileOpen(t *testing.T) {
open, changes := false, 0
tt := view(300, 200, newClock(), false, func(c *ui.Context) {
if data.Collapsible(c, "Secrets", &open, data.CollapsibleOptions{}, func() {
pixel.Text(c, "Konami", pixel.TextOptions{})
}).Changed {
changes++
}
})
if shows(tt, "Konami") {
t.Fatal("closed content shows")
}
click(t, tt, "Secrets")
if !open || !shows(tt, "Konami") {
t.Fatal("a click did not open it")
}
tt.Key(0, ui.KeyEnter)
if open || shows(tt, "Konami") || changes != 2 {
t.Fatalf("Enter left open %v, %d changes", open, changes)
}
keeps(t, tt.Image())
}
func TestAccordionKeepsOneOpenUnlessMany(t *testing.T) {
for _, many := range []bool{false, true} {
open := []bool{true, false, false}
tt := view(300, 300, newClock(), false, func(c *ui.Context) {
data.Accordion(c, "Help", []string{"Move", "Jump", "Save"}, open, data.AccordionOptions{Many: many}, func(i int) {
pixel.Text(c, []string{"Arrows", "Space", "Start"}[i], pixel.TextOptions{})
})
})
click(t, tt, "Save")
want := []bool{many, false, true}
if !slices.Equal(open, want) || shows(tt, "Arrows") != many || !shows(tt, "Start") {
t.Fatalf("many %v: open %v", many, open)
}
}
}
func TestCollapsibleOffStaysPut(t *testing.T) {
for _, inert := range []bool{false, true} {
open := false
tt := view(300, 200, newClock(), inert, func(c *ui.Context) {
data.Collapsible(c, "Secrets", &open, data.CollapsibleOptions{Disabled: !inert}, func() {})
})
click(t, tt, "Secrets")
if open {
t.Fatalf("inert %v: a click opened it", inert)
}
}
}
func deck(c *ui.Context, page *int, o data.CarouselOptions, changes *int) {
if data.Carousel(c, "Tips", 3, page, o, func(i int) {
pixel.Text(c, []string{"Tip A", "Tip B", "Tip C"}[i], pixel.TextOptions{})
}).Changed {
*changes++
}
}
func TestCarouselTurnsAndWraps(t *testing.T) {
page, changes := 0, 0
tt := view(300, 200, newClock(), false, func(c *ui.Context) { deck(c, &page, data.CarouselOptions{}, &changes) })
click(t, tt, "PREVIOUS")
if page != 2 || !shows(tt, "Tip C") {
t.Fatalf("◀ from the first page went to %d", page)
}
click(t, tt, "NEXT")
click(t, tt, "NEXT")
if page != 1 || !shows(tt, "Tip B") || changes != 3 {
t.Fatalf("▶ ▶ went to %d, %d changes", page, changes)
}
keeps(t, tt.Image())
}
func TestCarouselTurnsByItselfUnlessReduced(t *testing.T) {
for _, reduce := range []bool{false, true} {
clk, page, changes := newClock(), 0, 0
tt := view(300, 200, clk, false, func(c *ui.Context) { deck(c, &page, data.CarouselOptions{Every: 3}, &changes) })
if reduce {
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
}
tt.Move(599, 399)
clk.t = clk.t.Add(2 * time.Second)
tt.Frame()
if page != 0 {
t.Fatalf("reduce %v: turned after 2 s", reduce)
}
clk.t = clk.t.Add(time.Second)
tt.Frame()
if want := map[bool]int{false: 1, true: 0}[reduce]; page != want || !shows(tt, []string{"Tip A", "Tip B"}[want]) {
t.Fatalf("reduce %v: page %d after 3 s", reduce, page)
}
}
}
func TestCarouselWaitsUnderThePointer(t *testing.T) {
clk, page, changes := newClock(), 0, 0
tt := view(300, 200, clk, false, func(c *ui.Context) { deck(c, &page, data.CarouselOptions{Every: 1}, &changes) })
r := find(t, tt, "Tip A")
tt.Move(r.X+2, r.Y+2)
clk.t = clk.t.Add(5 * time.Second)
tt.Frame()
if page != 0 {
t.Fatalf("turned to %d under the pointer", page)
}
}
func TestPagerStepsAndResizes(t *testing.T) {
page, size, changes := 0, 20, 0
total := 312
tt := view(500, 100, newClock(), false, func(c *ui.Context) {
if data.Pager(c, "Pages", total, &page, &size, data.PagerOptions{}).Changed {
changes++
}
})
if !tt.HasText("1–20 OF 312") {
t.Fatalf("texts %q", tt.Texts())
}
click(t, tt, "NEXT")
if page != 1 || !tt.HasText("21–40 OF 312") {
t.Fatalf("▶ went to page %d", page)
}
click(t, tt, "50")
if size != 50 || page != 0 || !tt.HasText("1–50 OF 312") {
t.Fatalf("size 50 left page %d size %d", page, size)
}
page = 6
tt.Frame()
if !tt.HasText("301–312 OF 312") {
t.Fatalf("last page texts %q", tt.Texts())
}
total = 40
tt.Frame()
if page != 0 || changes != 3 {
t.Fatalf("a shorter total left page %d, %d changes", page, changes)
}
}
func TestFilterBarTogglesAndClears(t *testing.T) {
on := []bool{false, false, false}
changes := 0
tt := view(400, 100, newClock(), false, func(c *ui.Context) {
if data.FilterBar(c, "Filters", []string{"Fire", "Ice", "Bolt"}, on, data.FilterBarOptions{}).Changed {
changes++
}
})
click(t, tt, "CLEAR")
if changes != 0 {
t.Fatal("CLEAR worked with nothing on")
}
click(t, tt, "Fire")
click(t, tt, "Bolt")
if !slices.Equal(on, []bool{true, false, true}) {
t.Fatalf("chips left %v", on)
}
click(t, tt, "CLEAR")
if slices.Contains(on, true) || changes != 3 {
t.Fatalf("CLEAR left %v, %d changes", on, changes)
}
}
func TestPropertyGridEditsEachKind(t *testing.T) {
name, speed, fly := "Aria", 3.0, false
changes := 0
props := []data.Property{{Name: "Name", Text: &name}, {Name: "Speed", Number: &speed}, {Name: "Fly", Switch: &fly}}
tt := view(400, 200, newClock(), false, func(c *ui.Context) {
if data.PropertyGrid(c, "Hero", props, data.PropertyGridOptions{}).Changed {
changes++
}
})
// The switch sits past the 80-dot name column.
r := find(t, tt, "Fly")
tt.ClickAt(r.X+180, r.Y+r.H/2)
if !fly {
t.Fatalf("the switch row did not switch: %v", r)
}
n := find(t, tt, "Name")
tt.ClickAt(n.X+380, n.Y+n.H/2)
tt.Type("!")
if name != "Aria!" || changes != 2 {
t.Fatalf("typing gave %q, %d changes", name, changes)
}
defer func() {
if recover() == nil {
t.Fatal("a row with two values did not panic")
}
}()
view(400, 200, newClock(), false, func(c *ui.Context) {
data.PropertyGrid(c, "Bad", []data.Property{{Name: "Both", Text: &name, Switch: &fly}}, data.PropertyGridOptions{})
})
}
func TestPagerRefusesBadSizes(t *testing.T) {
for _, sizes := range [][]int{{20, 0}, {20, -5}} {
func() {
defer func() {
if recover() == nil {
t.Fatalf("sizes %v did not panic", sizes)
}
}()
page, size := 0, 20
view(400, 100, newClock(), false, func(c *ui.Context) {
data.Pager(c, "Pages", 50, &page, &size, data.PagerOptions{Sizes: sizes})
})
}()
}
}
+233
View File
@@ -0,0 +1,233 @@
package data_test
import (
"slices"
"testing"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestInventoryChoosesAndSwaps(t *testing.T) {
items := []data.Item{{Sprite: icon.Potion, Name: "Potion", Count: 3}, {Sprite: icon.Sword, Name: "Sword", Rarity: display.Epic}, {}, {Sprite: icon.Gem, Name: "Gem"}}
sel, swaps := 0, 0
tt := view(400, 300, newClock(), false, func(c *ui.Context) {
if data.Inventory(c, "Bag", items, &sel, data.InventoryOptions{Columns: 2}).Swapped {
swaps++
}
})
r := find(t, tt, "Bag")
tt.ClickAt(r.X+4, r.Y+4)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyRight)
if sel != 3 {
t.Fatalf("Down, Right chose %d", sel)
}
// One slot is 22 dots, 44 pixels.
tt.Press(r.X+10, r.Y+10)
tt.Move(r.X+30, r.Y+30)
tt.Move(r.X+54, r.Y+10)
tt.Release(r.X+54, r.Y+10)
if items[1].Name != "Potion" || items[0].Name != "Sword" || swaps != 1 {
t.Fatalf("drag swapped to %v, %d swaps", items, swaps)
}
tt.ClickAt(r.X+10, r.Y+10)
if sel != 0 || items[0].Name != "Sword" || swaps != 1 {
t.Fatalf("a click swapped or chose %d", sel)
}
keeps(t, tt.Image())
}
func TestInventoryOffNeitherChoosesNorSwaps(t *testing.T) {
for _, inert := range []bool{false, true} {
items := []data.Item{{Sprite: icon.Potion, Name: "Potion"}, {Sprite: icon.Gem, Name: "Gem"}}
sel := 0
tt := view(400, 300, newClock(), inert, func(c *ui.Context) {
data.Inventory(c, "Bag", items, &sel, data.InventoryOptions{Disabled: !inert})
})
r := find(t, tt, "Bag")
tt.Press(r.X+10, r.Y+10)
tt.Move(r.X+54, r.Y+10)
tt.Release(r.X+54, r.Y+10)
tt.Key(0, ui.KeyRight)
if sel != 0 || items[0].Name != "Potion" {
t.Fatalf("inert %v: sel %d, items %v", inert, sel, items)
}
}
}
func TestItemSlotShowsRarityCountAndCooldown(t *testing.T) {
tt := view(200, 100, newClock(), false, func(c *ui.Context) {
ui.Row(c).Children(func() {
data.ItemSlot(c, data.Item{Sprite: icon.Potion, Name: "Potion", Count: 12, Rarity: display.Legendary}, data.ItemSlotOptions{})
data.ItemSlot(c, data.Item{Sprite: icon.Potion, Name: "Ether"}, data.ItemSlotOptions{Cooldown: 1})
})
})
r, img := find(t, tt, "Potion ×12"), tt.Image()
if edge := slot(t, img, int(r.X)+2, int(r.Y)+20); edge != display.Legendary.Slot() {
t.Fatalf("legendary edge is %v", edge)
}
cool := find(t, tt, "Ether")
shade := 0
for x := int(cool.X) + 4; x < int(cool.X)+40; x += 2 {
if slot(t, img, x, int(cool.Y)+6) == pixel.Shade {
shade++
}
}
if shade == 0 {
t.Fatal("a full cooldown left no veil")
}
keeps(t, img)
}
func TestSortableMovesByAltArrowsAndDrag(t *testing.T) {
items := []string{"Aria", "Brom", "Cyra"}
changes := 0
tt := view(300, 200, newClock(), false, func(c *ui.Context) {
if data.Sortable(c, "Order", &items, data.SortableOptions{}).Changed {
changes++
}
})
r := find(t, tt, "Order")
tt.ClickAt(r.X+20, r.Y+4)
tt.Key(ui.Alt, ui.KeyDown)
tt.Key(ui.Alt, ui.KeyDown)
if !slices.Equal(items, []string{"Brom", "Cyra", "Aria"}) {
t.Fatalf("Alt+Down twice gave %v", items)
}
tt.Key(0, ui.KeyUp)
tt.Key(ui.Alt, ui.KeyUp)
if !slices.Equal(items, []string{"Cyra", "Brom", "Aria"}) {
t.Fatalf("Up, Alt+Up gave %v", items)
}
// Rows are 16 dots, 32 pixels.
tt.Press(r.X+20, r.Y+72)
tt.Move(r.X+20, r.Y+40)
tt.Move(r.X+20, r.Y+8)
tt.Release(r.X+20, r.Y+8)
if !slices.Equal(items, []string{"Aria", "Cyra", "Brom"}) || changes != 5 {
t.Fatalf("drag to the top gave %v, %d changes", items, changes)
}
}
func TestTransferMovesChosenItems(t *testing.T) {
left, right := []string{"Aria", "Brom"}, []string{"Cyra"}
changes := 0
tt := view(400, 300, newClock(), false, func(c *ui.Context) {
if data.Transfer(c, "Party", &left, &right, data.TransferOptions{}).Changed {
changes++
}
})
click(t, tt, "Brom")
click(t, tt, "RIGHT")
if !slices.Equal(left, []string{"Aria"}) || !slices.Equal(right, []string{"Cyra", "Brom"}) {
t.Fatalf("→ moved to %v %v", left, right)
}
click(t, tt, "Cyra")
click(t, tt, "LEFT")
if !slices.Equal(left, []string{"Aria", "Cyra"}) || !slices.Equal(right, []string{"Brom"}) || changes != 2 {
t.Fatalf("← moved to %v %v, %d changes", left, right, changes)
}
}
func TestMatrixTogglesByClickAndKeys(t *testing.T) {
on := [][]bool{{false, false}, {false, true}}
changes := 0
tt := view(300, 200, newClock(), false, func(c *ui.Context) {
if data.Matrix(c, "Rights", []string{"Read", "Write"}, []string{"Ann", "Bob"}, on, data.MatrixOptions{}).Changed {
changes++
}
})
r := find(t, tt, "Rights")
tt.ClickAt(r.X+4, r.Y+4)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeySpace)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyEnter)
if !on[0][0] || !on[1][0] || on[1][1] || changes != 3 {
t.Fatalf("click, Down Space, Right Enter left %v, %d changes", on, changes)
}
keeps(t, tt.Image())
}
func TestInventoryDragEndsWithoutSwapWhenTurnedOff(t *testing.T) {
for _, inert := range []bool{false, true} {
items := []data.Item{{Sprite: icon.Potion, Name: "Potion"}, {Sprite: icon.Gem, Name: "Gem"}}
sel, off, swaps := 0, false, 0
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Inert: off && inert})
if data.Inventory(c, "Bag", items, &sel, data.InventoryOptions{Disabled: off && !inert}).Swapped {
swaps++
}
}, 400, 300)
r := find(t, tt, "Bag")
tt.Press(r.X+10, r.Y+10)
tt.Move(r.X+54, r.Y+10)
off = true
tt.Frame()
off = false
tt.Release(r.X+54, r.Y+10)
if items[0].Name != "Potion" || swaps != 0 {
t.Fatalf("inert %v: turning off mid-drag swapped %v", inert, items)
}
}
}
func TestInventoryDragSurvivesShrinkingItems(t *testing.T) {
items := []data.Item{{Sprite: icon.Potion, Name: "Potion"}, {Sprite: icon.Gem, Name: "Gem"}, {Sprite: icon.Key, Name: "Key"}}
sel, swaps := 0, 0
tt := view(400, 300, newClock(), false, func(c *ui.Context) {
if data.Inventory(c, "Bag", items, &sel, data.InventoryOptions{}).Swapped {
swaps++
}
})
r := find(t, tt, "Bag")
tt.Press(r.X+98, r.Y+10)
tt.Move(r.X+10, r.Y+10)
items = items[:1]
tt.Frame()
tt.Release(r.X+10, r.Y+10)
if items[0].Name != "Potion" || swaps != 0 {
t.Fatalf("a drag from a lost slot swapped %v", items)
}
}
func TestInventoryReleaseWhileTurningOffDoesNotSwap(t *testing.T) {
for _, inert := range []bool{false, true} {
items := []data.Item{{Sprite: icon.Potion, Name: "Potion"}, {Sprite: icon.Gem, Name: "Gem"}}
sel, off := 0, false
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Inert: off && inert})
data.Inventory(c, "Bag", items, &sel, data.InventoryOptions{Disabled: off && !inert})
}, 400, 300)
r := find(t, tt, "Bag")
tt.Press(r.X+10, r.Y+10)
tt.Move(r.X+54, r.Y+10)
off = true
tt.Release(r.X+54, r.Y+10)
if items[0].Name != "Potion" {
t.Fatalf("inert %v: a release as it turned off swapped %v", inert, items)
}
}
}
func TestMatrixCursorSurvivesShrinkingData(t *testing.T) {
rows, cols := []string{"Read", "Write"}, []string{"Ann", "Bob"}
on := [][]bool{{false, false}, {false, false}}
tt := view(300, 200, newClock(), false, func(c *ui.Context) {
data.Matrix(c, "Rights", rows, cols, on, data.MatrixOptions{})
})
r := find(t, tt, "Rights")
tt.ClickAt(r.X+4, r.Y+4)
tt.Key(0, ui.KeyEnd)
rows, cols, on = rows[:1], cols[:1], [][]bool{{false}}
tt.Frame()
tt.Key(0, ui.KeySpace)
if !on[0][0] {
t.Fatalf("Space after shrinking left %v", on)
}
}
+102
View File
@@ -0,0 +1,102 @@
package data
import (
"log/slog"
"slices"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type SortableOptions struct {
// Width is in dots; zero means 160.
Width int
Disabled bool
}
type SortableResult struct {
Element *ui.Element
Changed bool
}
type sortKey struct{}
// sorting is the cursor row; held while pressed.
type sorting struct {
cur int
held bool
}
// Sortable reorders rows by dragging, or Alt with the arrows.
func Sortable(c *ui.Context, text string, items *[]string, o SortableOptions) SortableResult {
k := pixel.K(c)
n := len(*items)
off := o.Disabled || pixel.Current(c).Inert
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleList).Label(text).Disabled(o.Disabled)
})
s := ui.Local(base, sortKey{}, func() sorting { return sorting{} })
res := SortableResult{Element: shell}
if n == 0 {
return res
}
s.cur = max(0, min(s.cur, n-1))
move := func(from, to int) {
if to < 0 || to >= n || to == from {
return
}
it := (*items)[from]
*items = slices.Insert(slices.Delete(*items, from, from+1), to, it)
s.cur, res.Changed = to, true
}
if base.Shortcut(0, ui.KeyDown) {
s.cur = min(s.cur+1, n-1)
}
if base.Shortcut(0, ui.KeyUp) {
s.cur = max(s.cur-1, 0)
}
if base.Shortcut(ui.Alt, ui.KeyDown) {
move(s.cur, s.cur+1)
}
if base.Shortcut(ui.Alt, ui.KeyUp) {
move(s.cur, s.cur-1)
}
// A press takes a row; dragging carries it.
_, _, held := base.Dragged()
if held = held && !off; held {
_, y, _ := base.PointerPosition()
at := max(0, min(n-1, int(y/k.Dot())/pixel.LineHeight))
if !s.held {
s.cur = at
}
move(s.cur, at)
}
s.held = held
base.Description((*items)[s.cur])
shown, cur, held := append([]string(nil), *items...), s.cur, s.held
shell.Size(k.D(pixel.Or("Sortable Width", o.Width, 160)), k.D(n*pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i, it := range shown {
y, ink := i*pixel.LineHeight, pixel.Ink
switch {
case i == cur && held:
g.Rect(0, y, g.W, pixel.LineHeight, pixel.Lit)
g.HLine(0, y+pixel.LineHeight-1, g.W, pixel.Shade)
case i == cur:
g.Rect(0, y, g.W, pixel.LineHeight, pixel.Accent)
ink = pixel.AccentInk
}
for d := range 3 {
g.Rect(4, y+4+d*3, 2, 2, ink)
g.Rect(7, y+4+d*3, 2, 2, ink)
}
g.Text(14, y, it, ink, 1)
}
g.Finish(st)
})
if res.Changed {
slog.Debug("8bit: sorted", "label", text, "items", *items)
}
return res
}
+58
View File
@@ -0,0 +1,58 @@
package data
import (
"fmt"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Change is a stat before and after.
type Change struct {
Label string
Old, New int
}
type StatCompareOptions struct{}
// StatCompare lists stats old → new with ▲▼ deltas.
func StatCompare(c *ui.Context, changes []Change, o StatCompareOptions) *ui.Element {
k := pixel.K(c)
lw, ow, nw, dw := 0, 0, 0, 0
deltas := make([]string, len(changes))
for i, ch := range changes {
switch d := ch.New - ch.Old; {
case d > 0:
deltas[i] = "▲" + strconv.Itoa(d)
case d < 0:
deltas[i] = "▼" + strconv.Itoa(-d)
}
lw = max(lw, pixel.Measure(ch.Label))
ow = max(ow, pixel.Measure(strconv.Itoa(ch.Old)))
nw = max(nw, pixel.Measure(strconv.Itoa(ch.New)))
dw = max(dw, pixel.Measure(deltas[i]))
}
arrow := lw + 8 + ow + 4
newAt := arrow + pixel.Measure("→") + 4
deltaAt := newAt + nw + 6
return ui.Column(c).Children(func() {
for i, ch := range changes {
delta, ink := deltas[i], pixel.Good
if ch.New < ch.Old {
ink = pixel.Bad
}
ui.Box(c).Size(k.D(deltaAt+dw), k.D(pixel.LineHeight)).Role(ui.RoleText).
Label(fmt.Sprintf("%s %d → %d %s", ch.Label, ch.Old, ch.New, delta)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
old, now := strconv.Itoa(ch.Old), strconv.Itoa(ch.New)
g.Text(0, 0, ch.Label, pixel.Faint, 1)
g.Text(arrow-4-pixel.Measure(old), 0, old, pixel.Ink, 1)
g.Text(arrow, 0, "→", pixel.Faint, 1)
g.Text(newAt+nw-pixel.Measure(now), 0, now, pixel.Ink, 1)
g.Text(deltaAt, 0, delta, ink, 1)
})
}
})
}
+126
View File
@@ -0,0 +1,126 @@
package data
import (
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type Column struct {
Title string
// Width is in dots; zero means 60.
Width int
}
// Sort is the sorting column and its direction.
type Sort struct {
Column int
Desc bool
}
type TableOptions struct {
// Height is the rows' in dots; zero means 120.
Height int
Disabled bool
}
type TableResult struct {
Element *ui.Element
// Sorted reports a header click; sort the rows by *sort.
Changed, Submitted, Sorted bool
}
type tableKey struct{}
// Table sorts by its headers over virtual, dithered zebra rows.
func Table(c *ui.Context, text string, cols []Column, rows int, sort *Sort, selected *int, o TableOptions, cell func(row, col int) string) TableResult {
k := pixel.K(c)
off := o.Disabled || pixel.Current(c).Inert
ws, total := make([]int, len(cols)), 0
for i, col := range cols {
ws[i] = pixel.Or("Column Width", col.Width, 60)
total += ws[i]
}
box := ui.Box(c).Width(k.D(total + 4))
st := ui.Local(box, tableKey{}, func() ui.ListState { return ui.ListState{} })
st.Selected, st.Label = selected, func(r int) string { return cell(r, 0) }
if off {
st.Selected = nil
}
res := TableResult{Element: box}
var list *ui.Element
box.Children(func() {
ui.Row(c).FocusGroup(ui.Horizontal).Role(ui.RoleRow).Children(func() {
for i, col := range cols {
shell, base, hs := pixel.Shell(c, func() *ui.Element {
return ui.ButtonBase(c).Role(ui.RoleColumnHeader).Label(col.Title).Disabled(o.Disabled)
})
if base.Clicked() {
*sort = Sort{Column: i, Desc: sort.Column == i && !sort.Desc}
res.Sorted = true
}
on, desc := sort.Column == i, sort.Desc
if dir := "up"; on {
if desc {
dir = "down"
}
base.Description(k.Msg(dir))
}
shell.Size(k.D(ws[i]), k.D(pixel.LineHeight+4)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, g.W, g.H, pixel.Raised, pixel.Face)
g.Clip(0, 0, g.W-10, g.H, func() { g.Text(4, 2, col.Title, pixel.Ink, 1) })
if on {
arrow := up
if desc {
arrow = down
}
g.Sprite(g.W-9, 7, arrow, 0, pixel.Accent, 1)
}
g.Finish(hs)
})
}
})
list = ui.List(c, st, rows, pixel.Lazy(c, func(r int) {
texts := make([]string, len(cols))
for j := range cols {
texts[j] = cell(r, j)
}
chosen := r == *selected
ui.Row(c).Height(k.D(pixel.LineHeight)).Label(strings.Join(texts, ", ")).Draw(func(p *ui.Painter, rc ui.Rect) {
g := k.Canvas(p, rc)
switch {
case chosen:
g.Rect(0, 0, g.W, g.H, pixel.Accent)
case r%2 == 1:
g.Veil(0, 0, g.W, g.H, pixel.Shade, 4)
}
ink, x := pixel.Ink, 0
if chosen {
ink = pixel.AccentInk
}
for j, s := range texts {
g.Clip(x, 0, ws[j], g.H, func() { g.Text(x+4, 0, s, ink, 1) })
x += ws[j]
}
})
})).Width(k.D(total)).Height(k.D(pixel.Or("Table Height", o.Height, 120))).Label(text)
veil(c, off)
})
pixel.Park(c, list, off)
if off {
list.Description(k.Msg("disabled"))
}
well(c, box, pixel.State{Focus: list.FocusRing(false).FocusVisible(), Disabled: o.Disabled})
res.Changed, res.Submitted = list.Changed(), list.Submitted()
if res.Changed {
slog.Debug("8bit: table chose", "label", text, "row", *selected)
}
if res.Sorted {
slog.Debug("8bit: table sorted", "label", text, "column", sort.Column, "desc", sort.Desc)
}
return res
}
+44
View File
@@ -0,0 +1,44 @@
package data
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
// Event is a dated line on a timeline.
type Event struct {
Date, Text string
}
type TimelineOptions struct{}
// Timeline strings dated events down a line of markers.
func Timeline(c *ui.Context, events []Event, o TimelineOptions) *ui.Element {
k := pixel.K(c)
return ui.Column(c).FillWidth().Children(func() {
for i, ev := range events {
first, last := i == 0, i == len(events)-1
ui.Row(c).FillWidth().Gap(k.D(4)).Children(func() {
ui.Box(c).Width(k.D(9)).Shrink(0).AlignSelf(ui.Stretch).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
top, foot := 0, g.H
if first {
top = 5
}
if last {
foot = 5
}
g.VLine(4, top, foot-top, pixel.Edge)
g.Rect(2, 3, 5, 5, pixel.Edge)
g.Rect(3, 4, 3, 3, pixel.Accent)
})
ui.Column(c).Grow(1).Padding(0, 0, k.D(6), 0).Children(func() {
pixel.Text(c, ev.Date, pixel.TextOptions{Slot: pixel.Faint})
text.Paragraph(c, ev.Text, text.ParagraphOptions{})
})
})
}
})
}
+64
View File
@@ -0,0 +1,64 @@
package data
import (
"log/slog"
"slices"
"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 TransferOptions struct {
// Height is each list's in dots; zero means 120.
Height int
Disabled bool
}
type TransferResult struct {
Element *ui.Element
Changed bool
}
type transferKey struct{}
// Transfer moves chosen items between lists with → ←.
func Transfer(c *ui.Context, text string, left, right *[]string, o TransferOptions) TransferResult {
k := pixel.K(c)
row := ui.Row(c).Gap(k.D(4)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(text)
sel := ui.Local(row, transferKey{}, func() [2]int { return [2]int{} })
res := TransferResult{Element: row}
// side lists one side's items, its choice kept in range.
side := func(name string, items []string, at *int) {
*at = max(0, min(*at, len(items)-1))
List(c, name, len(items), at, ListOptions{Height: o.Height, Disabled: o.Disabled}, func(i int) {
pixel.Text(c, items[i], pixel.TextOptions{})
}).Element.Width(k.D(100))
}
shift := func(from, to *[]string, at int) {
if at < 0 || at >= len(*from) {
return
}
*to = append(*to, (*from)[at])
*from = slices.Delete(*from, at, at+1)
res.Changed = true
}
row.Children(func() {
side(text+" "+k.Msg("left"), *left, &sel[0])
ui.Column(c).Gap(k.D(4)).Children(func() {
if input.IconButton(c, icon.ArrowRight, k.Msg("right"), input.IconButtonOptions{Disabled: o.Disabled || len(*left) == 0}).Clicked {
shift(left, right, sel[0])
}
if input.IconButton(c, icon.ArrowLeft, k.Msg("left"), input.IconButtonOptions{Disabled: o.Disabled || len(*right) == 0}).Clicked {
shift(right, left, sel[1])
}
})
side(text+" "+k.Msg("right"), *right, &sel[1])
})
if res.Changed {
slog.Debug("8bit: transfer", "label", text, "left", len(*left), "right", len(*right))
}
return res
}
+130
View File
@@ -0,0 +1,130 @@
package data
import (
"log/slog"
"strconv"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type TreeNode struct {
Label string
Children []TreeNode
}
type TreeOptions struct {
// Width is in dots; zero means 160.
Width int
Disabled bool
}
type TreeResult struct {
Element *ui.Element
Changed, Toggled bool
}
type treeKey struct{}
// Tree of ▸▾ items; *selected is a path, "0/2".
func Tree(c *ui.Context, text string, roots []TreeNode, selected *string, o TreeOptions) TreeResult {
k := pixel.K(c)
nodes := map[string]TreeNode{}
var index func(ns []TreeNode, at string)
index = func(ns []TreeNode, at string) {
for i, n := range ns {
p := at + strconv.Itoa(i)
nodes[p] = n
index(n.Children, p+"/")
}
}
index(roots, "")
children := func(p string) []string {
var out []string
for i := range nodes[p].Children {
out = append(out, p+"/"+strconv.Itoa(i))
}
return out
}
var tops []string
for i := range roots {
tops = append(tops, strconv.Itoa(i))
}
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleTree).Label(text).Disabled(o.Disabled)
})
opened := ui.Local(base, treeKey{}, func() map[string]bool { return map[string]bool{} })
rows := flatten(tops, children, *opened)
if len(rows) == 0 {
return TreeResult{Element: shell}
}
at := 0
for i, r := range rows {
if r.key == *selected {
at = i
}
}
old := *selected
res := TreeResult{Element: shell}
if base.Shortcut(0, ui.KeyDown) {
at = min(at+1, len(rows)-1)
}
if base.Shortcut(0, ui.KeyUp) {
at = max(at-1, 0)
}
right, left := base.Shortcut(0, ui.KeyRight), base.Shortcut(0, ui.KeyLeft)
if right || left {
at, res.Toggled = steer(rows, at, *opened, right)
}
enter, space := base.Shortcut(0, ui.KeyEnter), base.Shortcut(0, ui.KeySpace)
toggle := func(i int) {
if key := rows[i].key; rows[i].branch {
(*opened)[key], res.Toggled = !(*opened)[key], true
}
}
if enter || space {
toggle(at)
}
// Enter and Space are taken, so clicks come from pointers.
if base.Clicked() {
x, y, _ := base.PointerPosition()
if i := int(y/k.Dot()) / pixel.LineHeight; i >= 0 && i < len(rows) {
at = i
if int(x/k.Dot()) < rows[i].depth*10+12 {
toggle(i)
}
}
}
if res.Toggled {
rows = flatten(tops, children, *opened)
at = min(at, len(rows)-1)
}
*selected = rows[at].key
res.Changed = *selected != old
base.Description(nodes[*selected].Label)
shown := rows
shell.Size(k.D(pixel.Or("Tree Width", o.Width, 160)), k.D(len(shown)*pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i, rw := range shown {
y, x, ink := i*pixel.LineHeight, rw.depth*10, pixel.Ink
if i == at {
g.Rect(0, y, g.W, pixel.LineHeight, pixel.Accent)
ink = pixel.AccentInk
}
if rw.branch {
if (*opened)[rw.key] {
g.Sprite(x+3, y+6, down, 0, ink, 1)
} else {
g.Sprite(x+4, y+5, pointer, 0, ink, 1)
}
}
g.Text(x+12, y, nodes[rw.key].Label, ink, 1)
}
g.Finish(st)
})
if res.Changed || res.Toggled {
slog.Debug("8bit: tree", "label", text, "path", *selected, "toggled", res.Toggled)
}
return res
}