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

+5 -4
View File
@@ -47,8 +47,8 @@ Imports point down only. Same-tier imports are forbidden. `internal/archtest` en
## MyGo rules
- Use Bases, containers, `List`, `GridView`, `Outline`, `Router`, `Overlay`, `DialogBase`, `PopoverBase`, `FocusGroup`, `Local`, `HandleInput`, `TextCaret`, `Shortcut`, clipboard. Full list: PRD §2.4.
- Never call styled widgets, `Background`, `Border*`, `Radius`, `Shadow`, `Gradient`, `Opacity`, `Stripes`, `Tooltip`, `Menu`, `ContextMenu`, `Transition`, `Animate*`, `Loop`, `c.Toast*`, or painter calls other than `Fill` and `Clip`. Only `pixel` calls `After`.
- Use Bases, containers, `List`, `Router`, `Overlay`, `DialogBase`, `PopoverBase`, `FocusGroup`, `Local`, `HandleInput`, `TextCaret`, `Shortcut`, clipboard. Full list: PRD §2.4.
- Never call styled widgets, `ui.Outline` (stroked arrows), `ui.GridView` (theme gaps, tinted choice), `Background`, `Border*`, `Radius`, `Shadow`, `Gradient`, `Opacity`, `Stripes`, `Tooltip`, `Menu`, `ContextMenu`, `Transition`, `Animate*`, `Loop`, `c.Toast*`, or painter calls other than `Fill` and `Clip`. Only `pixel` calls `After`.
- A disabled element never paints. Interactive components draw on an enabled shell; the base inside fills it, carries behavior, label and `Disabled`, and turns MyGo's focus ring off.
## Component rules
@@ -59,14 +59,15 @@ Imports point down only. Same-tier imports are forbidden. `internal/archtest` en
- A one-shot effect inside a lasting view starts when its cue changes (a `Cue` option, or a value that rises), never on its first frame. A state screen (`ErrorState`, `SuccessState`) starts when it appears. Decorative motion jumps to its end under Reduce Motion; countdowns and loaders keep time.
- Containers take content as a final `func()`; interactive results are `NameResult`.
- A Clicked, Shortcut, Changed or drag during the build makes MyGo build the frame again, so what they change shows at once. State changed any other way after its element was built (`Pressed`, `Edit.Changed`) is read at paint time.
- Never call `Selected`: MyGo paints it in the accent color.
- Never call `Selected`. The pixel theme keeps MyGo's row highlights clear, since scrolled rows sit between dots: draw chosen rows yourself.
- A disabled or inert scroll view must not move: cover it, so the wheel stops above it.
- A press focuses the nearest focusable element under it, or nothing; a closing overlay hands the focus back to its opener. While a press is on, `Hovered` is false for every other element: ask `Pressed`.
- Enter and Space click a focused clickable element unless a `Shortcut` takes them. Take them wherever a click reads the pointer's position.
- Under `pixel.Settings.Inert` (a busy veil) `pixel.Shell` disables every base and gives the focus back after. A popup sits above any veil: a control closes its popup when disabled or inert.
- Elements are known by their place among siblings. Wrap one whose earlier siblings come and go in `ui.Box(c).Key(k)`, or it loses focus and state.
- Hosts send a key's release with its press, so tell a held key by its repeats.
- Clip only boxes that sit on whole dots; split widths with whole-dot shares, not equal `Grow`. `Grow` starts from zero size, so never use it on a content-sized child.
- Row builders that MyGo runs during layout (`List`, `GridView`, `Outline`) go through `pixel.Lazy(c, row)`.
- `List` row builders run during layout: wrap them in `pixel.Lazy(c, row)`. A click met there lands after the build; keep it in `Local` state, invalidate, and report it next frame.
- Single-key shortcuts only while `pixel.Typing(c)` is false; MyGo routes shortcuts before a focused field.
- Copy shown by a component comes from `k.Msg`; business copy comes from the caller.
- Fail fast: panic on programmer errors such as bad options. Never swallow an error.
+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
}
+3 -2
View File
@@ -18,7 +18,8 @@ const (
Legendary
)
func (r Rarity) slot() pixel.Slot {
// Slot is the rarity's color.
func (r Rarity) Slot() pixel.Slot {
if r > Legendary {
panic(fmt.Sprintf("display: unknown Rarity %d", r))
}
@@ -64,7 +65,7 @@ type GemOptions struct {
// Gem is a cut gem by rarity, sparkling in steps.
func Gem(c *ui.Context, r Rarity, o GemOptions) *ui.Element {
k, sc := pixel.K(c), scale(o.Scale)
ink := r.slot()
ink := r.Slot()
return art(c, k, r.name(k), (gem.W+3)*sc, (gem.H+2)*sc, func(g pixel.Canvas) {
g.Sprite(0, 2*sc, gem, 0, ink, sc)
if f := g.Step(4) % 4; f < 2 {
+1 -1
View File
@@ -86,5 +86,5 @@ func TestExtremesAndNames(t *testing.T) {
t.Fatal("unknown rarity did not panic")
}
}()
Rarity(9).slot()
Rarity(9).Slot()
}
+1 -1
View File
@@ -14,7 +14,7 @@ type RarityFrameOptions struct {
// RarityFrame borders children by rarity; legendary carries a glint.
func RarityFrame(c *ui.Context, r Rarity, o RarityFrameOptions, children func()) *ui.Element {
k := pixel.K(c)
ink := r.slot()
ink := r.Slot()
pad := 3 + pixel.Or("RarityFrame Padding", o.Padding, 4)
return ui.Column(c).Padding(k.D(pad)).Role(ui.RoleGroup).Label(r.name(k)).Draw(func(p *ui.Painter, r2 ui.Rect) {
g := k.Canvas(p, r2)
+42
View File
@@ -238,6 +238,25 @@ func TestControlDemosLogTheirEvents(t *testing.T) {
}
}
func TestDataDemosLogSubmits(t *testing.T) {
for slug, row := range map[string]string{"152-table": "Brom, Monk, 5", "154-outline": "Region 3", "174-searchresults": "Fira"} {
a, tt := open("/c/" + slug)
click(t, tt, row)
n := len(a.logs[slug])
tt.Key(0, ui.KeyEnter)
if len(a.logs[slug]) == n {
t.Errorf("%s logged no submit: %q", slug, a.logs[slug])
}
}
a, tt := open("/c/153-tree")
click(t, tt, "Items")
n := len(a.logs["153-tree"])
tt.Key(0, ui.KeyRight)
if len(a.logs["153-tree"]) == n {
t.Errorf("opening a branch logged nothing: %q", a.logs["153-tree"])
}
}
func TestFieldDemoLogsSubmit(t *testing.T) {
a, tt := open("/c/100-field")
// The group shares its name; the input sits below.
@@ -250,3 +269,26 @@ func TestFieldDemoLogsSubmit(t *testing.T) {
t.Fatal("Enter in the field logged nothing")
}
}
func TestDataDemosLogTheirEvents(t *testing.T) {
for slug, act := range map[string]func(tt *ui.Tester){
"151-list": func(tt *ui.Tester) { click(t, tt, "Bat") },
"152-table": func(tt *ui.Tester) { click(t, tt, "LV") },
"153-tree": func(tt *ui.Tester) { click(t, tt, "Items"); tt.Key(0, ui.KeyRight) },
"154-outline": func(tt *ui.Tester) { click(t, tt, "Region 2") },
"174-searchresults": func(tt *ui.Tester) { click(t, tt, "Fira") },
"155-gridview": func(tt *ui.Tester) { click(t, tt, "loot 3") },
"160-accordion": func(tt *ui.Tester) { click(t, tt, "Where is the key?") },
"162-carousel": func(tt *ui.Tester) { click(t, tt, "NEXT") },
"167-pager": func(tt *ui.Tester) { click(t, tt, "NEXT") },
"170-sortable": func(tt *ui.Tester) { click(t, tt, "Quest order"); tt.Key(ui.Alt, ui.KeyDown) },
"171-transfer": func(tt *ui.Tester) { click(t, tt, "RIGHT") },
"173-filterbar": func(tt *ui.Tester) { click(t, tt, "FIRE") },
} {
a, tt := open("/c/" + slug)
act(tt)
if len(a.logs[slug]) == 0 {
t.Errorf("%s logged nothing", slug)
}
}
}
+264
View File
@@ -0,0 +1,264 @@
package gallery
import (
"cmp"
"fmt"
"slices"
"strings"
"time"
"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"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
var dataEntries = []Entry{
{151, "List", "列表", "data", "Thousands of rows, built as they scroll in. ▶ marks the chosen one.", demoList},
{152, "Table", "表格", "data", "Click a header to sort ▲, again for ▼. Odd rows are dithered.", demoTable},
{153, "Tree", "树", "data", "▸ opens, ▾ closes. Right opens, Left climbs back out.", demoTree},
{154, "Outline", "大纲", "data", "A tree of 2,000 floors that only builds what shows.", demoOutline},
{155, "GridView", "网格视图", "data", "Whole-dot cells in as many columns as fit; arrows go both ways.", demoGridView},
{156, "DescriptionList", "描述列表", "data", "Terms on the left, what they mean on the right.", demoDescriptionList},
{157, "Leaderboard", "排行榜", "data", "Medals for the top three; your row lights up.", demoLeaderboard},
{158, "Inventory", "物品栏", "data", "Arrows pick a slot. Drag one item onto another to swap them.", demoInventory},
{159, "ItemSlot", "物品格", "data", "An item with its stack, rarity edge and cooldown veil.", demoItemSlot},
{160, "Accordion", "手风琴", "data", "Open one answer and the others fold away.", demoAccordion},
{161, "Collapsible", "折叠区", "data", "One header that shows and hides what is under it.", demoCollapsible},
{162, "Carousel", "轮播", "data", "◀ ▶ turn the pages; it turns by itself unless you point at it.", demoCarousel},
{163, "Timeline", "时间线", "data", "Dated events down a line of markers.", demoTimeline},
}
type dataDemo struct {
list, row, tile, slot, page, size, carousel, hit int
sort data.Sort
tree, floor string
faq []bool
secret bool
quests, bench, party []string
bag []data.Item
rights [][]bool
filters []bool
name string
level float64
hard bool
logs int
loading time.Time
query string
}
func dataState(c *ui.Context) *dataDemo {
return ui.Local(c.Root(), demoKey("data"), func() dataDemo {
return dataDemo{size: 20, tree: "0", floor: "R1", faq: []bool{true, false, false},
quests: []string{"Find the key", "Save the cat", "Beat the boss", "Buy a potion"},
bench: []string{"Dax", "Eko", "Fay"}, party: []string{"Aria", "Brom"},
bag: []data.Item{{Sprite: icon.Potion, Name: "Potion", Count: 5}, {Sprite: icon.Drop, Name: "Ether", Count: 2, Rarity: display.Uncommon},
{Sprite: icon.Sword, Name: "Sword", Rarity: display.Rare}, {Sprite: icon.Shield, Name: "Shield", Rarity: display.Epic},
{Sprite: icon.Crown, Name: "Crown", Rarity: display.Legendary}, {Sprite: icon.Gem, Name: "Gem", Count: 12, Rarity: display.Rare},
{}, {Sprite: icon.Key, Name: "Key"}, {}, {}, {Sprite: icon.Scroll, Name: "Map scroll"}, {}},
rights: [][]bool{{true, false, false}, {false, true, false}, {true, true, true}},
filters: []bool{false, true, false, false}, name: "Aria", level: 7, logs: 20}
})
}
var horde = strings.Fields("Slime Bat Goblin Ghost Golem Imp Kobold Lich Mimic Ogre Orc Pixie Rat Skeleton Snake Spider Troll Wisp Wolf Wyrm Zombie Harpy Hydra Kraken Basilisk Djinn Gargoyle Manticore Wyvern Yeti")
func demoList(c *ui.Context, log func(string)) {
s := dataState(c)
r := data.List(c, "Monsters", len(horde), &s.list, data.ListOptions{}, func(i int) {
pixel.Text(c, horde[i], pixel.TextOptions{})
})
r.Element.Width(pixel.K(c).D(140))
if r.Changed {
log("chose " + horde[s.list])
}
if r.Submitted {
log("opened " + horde[s.list])
}
}
var roster = []struct {
name, class string
lv int
}{{"Aria", "Knight", 7}, {"Brom", "Monk", 5}, {"Cyra", "Mage", 9}, {"Dax", "Thief", 4}, {"Eko", "Bard", 6},
{"Fay", "Cleric", 8}, {"Gus", "Ranger", 3}, {"Hal", "Knight", 10}, {"Ivy", "Mage", 2}, {"Jun", "Monk", 11}}
func demoTable(c *ui.Context, log func(string)) {
s := dataState(c)
order := make([]int, len(roster))
for i := range order {
order[i] = i
}
slices.SortStableFunc(order, func(a, b int) int {
x, y := roster[a], roster[b]
d := [3]int{cmp.Compare(x.name, y.name), cmp.Compare(x.class, y.class), cmp.Compare(x.lv, y.lv)}[s.sort.Column]
if s.sort.Desc {
return -d
}
return d
})
cols := []data.Column{{Title: "Name", Width: 50}, {Title: "Class", Width: 56}, {Title: "LV", Width: 30}}
r := data.Table(c, "Roster", cols, len(roster), &s.sort, &s.row, data.TableOptions{}, func(row, col int) string {
h := roster[order[row]]
return [3]string{h.name, h.class, fmt.Sprint(h.lv)}[col]
})
if r.Sorted {
log(fmt.Sprintf("sorted by %s, desc %v", cols[s.sort.Column].Title, s.sort.Desc))
}
if r.Changed {
log("chose row " + fmt.Sprint(s.row))
}
if r.Submitted {
log("opened " + roster[order[s.row]].name)
}
}
func demoTree(c *ui.Context, log func(string)) {
s := dataState(c)
roots := []data.TreeNode{
{Label: "Bag", Children: []data.TreeNode{{Label: "Potions", Children: []data.TreeNode{{Label: "Potion ×5"}, {Label: "Ether ×2"}}}, {Label: "Tent"}}},
{Label: "Key items", Children: []data.TreeNode{{Label: "Old key"}, {Label: "Map scroll"}}},
{Label: "Gold: 120"},
}
r := data.Tree(c, "Items", roots, &s.tree, data.TreeOptions{})
if r.Changed {
log("chose " + s.tree)
}
if r.Toggled {
log("opened or closed " + s.tree)
}
}
func demoOutline(c *ui.Context, log func(string)) {
s := dataState(c)
roots := make([]string, 8)
for i := range roots {
roots[i] = fmt.Sprintf("R%d", i+1)
}
children := func(k string) []string {
n := map[int]int{0: 12, 1: 20}[strings.Count(k, "/")]
var out []string
for i := range n {
out = append(out, fmt.Sprintf("%s/%d", k, i+1))
}
return out
}
label := func(k string) string {
parts := strings.Split(k, "/")
name := []string{"Region " + parts[0][1:], "Dungeon ", "Floor B"}[len(parts)-1]
if len(parts) > 1 {
name += parts[len(parts)-1]
}
return name
}
r := data.Outline(c, "World", roots, children, label, &s.floor, data.OutlineOptions{})
r.Element.Width(pixel.K(c).D(160))
if r.Changed {
log("chose " + label(s.floor))
}
if r.Submitted {
log("entered " + label(s.floor))
}
}
var tiles = []*pixel.Sprite{icon.Coin, icon.Gem, icon.Potion, icon.Sword, icon.Shield, icon.Chest, icon.Crown, icon.Scroll, icon.Flame, icon.Leaf}
func demoGridView(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := dataState(c)
r := data.GridView(c, "Loot", 200, &s.tile, data.GridViewOptions{CellWidth: 24, CellHeight: 24, Height: 100}, func(i int) {
ui.Box(c).Grow(1).AlignItems(ui.Center).Justify(ui.Center).Children(func() {
display.Icon(c, tiles[i%len(tiles)], display.IconOptions{Label: fmt.Sprintf("loot %d", i)})
})
})
r.Element.Width(k.D(200))
if r.Changed {
log(fmt.Sprintf("chose loot %d", s.tile))
}
if r.Submitted {
log(fmt.Sprintf("took loot %d", s.tile))
}
}
func demoDescriptionList(c *ui.Context, log func(string)) {
ui.Box(c).Width(pixel.K(c).D(220)).Children(func() {
data.DescriptionList(c, [][2]string{{"Class", "Knight"}, {"Weapon", "Rusty sword"},
{"Home", "Castle Town, two screens west of the cave"}, {"Motto", "It is dangerous to go alone."}}, data.DescriptionListOptions{TermWidth: 50})
})
}
func demoLeaderboard(c *ui.Context, log func(string)) {
scores := []data.Score{{Name: "ZAK", Score: 98400}, {Name: "MOM", Score: 87150}, {Name: "AAA", Score: 80000}, {Name: "BOB", Score: 64200}, {Name: "YOU", Score: 51300}, {Name: "KEN", Score: 32000}}
ui.Box(c).Width(pixel.K(c).D(160)).Children(func() {
data.Leaderboard(c, scores, data.LeaderboardOptions{Player: 4})
})
}
func demoInventory(c *ui.Context, log func(string)) {
s := dataState(c)
r := data.Inventory(c, "Bag", s.bag, &s.slot, data.InventoryOptions{})
if r.Swapped {
log("swapped slots")
}
if r.Changed {
log(fmt.Sprintf("slot %d: %s", s.slot, s.bag[s.slot].Name))
}
}
func demoItemSlot(c *ui.Context, log func(string)) {
ui.Row(c).Gap(pixel.K(c).D(6)).Children(func() {
data.ItemSlot(c, data.Item{Sprite: icon.Potion, Name: "Potion", Count: 3}, data.ItemSlotOptions{})
data.ItemSlot(c, data.Item{Sprite: icon.Crown, Name: "Crown", Rarity: display.Legendary}, data.ItemSlotOptions{Selected: true})
data.ItemSlot(c, data.Item{Sprite: icon.Bolt, Name: "Bolt scroll", Rarity: display.Rare}, data.ItemSlotOptions{Cooldown: 0.5})
data.ItemSlot(c, data.Item{Name: "Empty"}, data.ItemSlotOptions{})
})
}
func demoAccordion(c *ui.Context, log func(string)) {
s := dataState(c)
qs := []string{"How do I save?", "Where is the key?", "Can I pet the dog?"}
as := []string{"Visit any inn, or press START.", "Under the third torch.", "Yes. Always yes."}
ui.Box(c).Width(pixel.K(c).D(180)).Children(func() {
if data.Accordion(c, "Help", qs, s.faq, data.AccordionOptions{}, func(i int) {
text.Paragraph(c, as[i], text.ParagraphOptions{})
}).Changed {
log(fmt.Sprintf("open %v", s.faq))
}
})
}
func demoCollapsible(c *ui.Context, log func(string)) {
s := dataState(c)
if data.Collapsible(c, "Secret moves", &s.secret, data.CollapsibleOptions{}, func() {
pixel.Text(c, "↑ ↑ ↓ ↓ ← → ← → B A", pixel.TextOptions{})
}).Changed {
log(fmt.Sprintf("open %v", s.secret))
}
}
func demoCarousel(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := dataState(c)
tips := []struct {
art *pixel.Sprite
line string
}{{icon.Heart, "Hearts heal you."}, {icon.Key, "Keys open doors."}, {icon.Coin, "Coins buy things."}}
if data.Carousel(c, "Tips", len(tips), &s.carousel, data.CarouselOptions{Every: 4}, func(i int) {
ui.Column(c).Width(k.D(120)).AlignItems(ui.Center).Gap(k.D(4)).Children(func() {
display.Icon(c, tips[i].art, display.IconOptions{Scale: 2})
pixel.Text(c, tips[i].line, pixel.TextOptions{})
})
}).Changed {
log(fmt.Sprintf("page %d", s.carousel+1))
}
}
func demoTimeline(c *ui.Context, log func(string)) {
ui.Box(c).Width(pixel.K(c).D(200)).Children(func() {
data.Timeline(c, []data.Event{{Date: "1985", Text: "A plumber jumps into a pipe."}, {Date: "1986", Text: "A boy in green finds a sword."},
{Date: "1987", Text: "A last fantasy begins."}, {Date: "1991", Text: "A blue hedgehog runs very fast."}}, data.TimelineOptions{})
})
}
+163
View File
@@ -0,0 +1,163 @@
package gallery
import (
"fmt"
"strings"
"time"
"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/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
var recordEntries = []Entry{
{164, "StatCompare", "属性对比", "data", "What a new sword does to you: ▲ in green, ▼ in red.", demoStatCompare},
{165, "Bestiary", "图鉴", "data", "Monsters you met, and ??? shadows of those you have not.", demoBestiary},
{166, "PropertyGrid", "属性表", "data", "Edit text, numbers and switches row by row.", demoPropertyGrid},
{167, "Pager", "分页器", "data", "Pick a page size; ◀ ▶ step through 312 items.", demoPager},
{168, "Feed", "动态流", "data", "What happened, newest first, with icons and times.", demoFeed},
{169, "Matrix", "矩阵", "data", "Who knows which spell. Arrows move; Space flips.", demoMatrix},
{170, "Sortable", "可排序列表", "data", "Drag a quest by its grip, or move it with Alt and the arrows.", demoSortable},
{171, "Transfer", "穿梭框", "data", "Pick a hero, then → or ← to swap benches.", demoTransfer},
{172, "InfiniteList", "无限列表", "data", "Scroll to the end; it loads twenty more.", demoInfiniteList},
{173, "FilterBar", "筛选栏", "data", "Chips turn filters on; CLEAR turns them all off.", demoFilterBar},
{174, "SearchResults", "搜索结果", "data", "Type a spell name; matches light up.", demoSearchResults},
{175, "CardGrid", "卡片网格", "data", "As many columns of cards as the width holds.", demoCardGrid},
}
func demoStatCompare(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Column(c).Gap(k.D(4)).Children(func() {
pixel.Text(c, "Iron sword → Flame sword", pixel.TextOptions{Slot: pixel.Faint})
data.StatCompare(c, []data.Change{{Label: "ATK", Old: 12, New: 18}, {Label: "DEF", Old: 9, New: 7}, {Label: "SPD", Old: 5, New: 5}, {Label: "MAG", Old: 3, New: 6}}, data.StatCompareOptions{})
})
}
func demoBestiary(c *ui.Context, log func(string)) {
k := pixel.K(c)
ui.Row(c).Gap(k.D(6)).Children(func() {
ui.Box(c).Width(k.D(130)).Children(func() {
data.Bestiary(c, data.Monster{Sprite: slime, Name: "Slime", Seen: true, Stats: [][2]string{{"HP", "8"}, {"ATK", "2"}},
Notes: "Wobbles happily. Mostly harmless."}, data.BestiaryOptions{})
})
ui.Box(c).Width(k.D(130)).Children(func() {
data.Bestiary(c, data.Monster{Sprite: icon.Bug, Name: "Cave beetle", Stats: [][2]string{{"HP", "30"}, {"ATK", "9"}},
Notes: "Clicks in the dark."}, data.BestiaryOptions{Scale: 3})
})
})
}
func demoPropertyGrid(c *ui.Context, log func(string)) {
s := dataState(c)
props := []data.Property{{Name: "Name", Text: &s.name}, {Name: "Level", Number: &s.level, Min: 1, Max: 99}, {Name: "Hard mode", Switch: &s.hard}}
if data.PropertyGrid(c, "Hero", props, data.PropertyGridOptions{NameWidth: 56}).Changed {
log(fmt.Sprintf("%s, level %g, hard %v", s.name, s.level, s.hard))
}
}
func demoPager(c *ui.Context, log func(string)) {
s := dataState(c)
if data.Pager(c, "Items", 312, &s.page, &s.size, data.PagerOptions{}).Changed {
log(fmt.Sprintf("page %d, %d per page", s.page+1, s.size))
}
}
func demoFeed(c *ui.Context, log func(string)) {
ui.Box(c).Width(pixel.K(c).D(200)).Children(func() {
data.Feed(c, []data.Activity{{Icon: icon.Star, Text: "Aria found a star shard.", Time: "1 min ago"}, {Icon: icon.Coin, Text: "Brom sold three slime jellies.", Time: "4 min ago"},
{Icon: icon.Trophy, Text: "You cleared World 1-2.", Time: "9 min ago"}, {Icon: icon.Heart, Text: "Cyra healed the party.", Time: "12 min ago"}}, data.FeedOptions{})
})
}
func demoMatrix(c *ui.Context, log func(string)) {
s := dataState(c)
heroes, spells := []string{"Aria", "Brom", "Cyra"}, []string{"FIRE", "ICE", "BOLT"}
if data.Matrix(c, "Spells known", heroes, spells, s.rights, data.MatrixOptions{}).Changed {
log(fmt.Sprintf("%v", s.rights))
}
}
func demoSortable(c *ui.Context, log func(string)) {
s := dataState(c)
if data.Sortable(c, "Quest order", &s.quests, data.SortableOptions{}).Changed {
log(strings.Join(s.quests, " · "))
}
}
func demoTransfer(c *ui.Context, log func(string)) {
s := dataState(c)
if data.Transfer(c, "Party", &s.bench, &s.party, data.TransferOptions{Height: 70}).Changed {
log("party: " + strings.Join(s.party, ", "))
}
}
func demoInfiniteList(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := dataState(c)
// A pretend server answers after one second.
if !s.loading.IsZero() && k.Since(c, s.loading, 1, 1) == 1 {
s.logs, s.loading = s.logs+20, time.Time{}
log(fmt.Sprintf("loaded %d rows", s.logs))
}
r := data.InfiniteList(c, "Battle log", s.logs, data.InfiniteListOptions{Height: 100, Done: s.logs >= 100}, func(i int) {
pixel.Text(c, fmt.Sprintf("Turn %d: the slime wobbles.", i+1), pixel.TextOptions{})
})
r.Element.Width(k.D(180))
if r.More {
s.loading = k.Now()
log("asked for more")
}
}
func demoFilterBar(c *ui.Context, log func(string)) {
s := dataState(c)
labels := []string{"FIRE", "ICE", "BOLT", "HOLY"}
if data.FilterBar(c, "Elements", labels, s.filters, data.FilterBarOptions{}).Changed {
log(fmt.Sprintf("%v", s.filters))
}
}
var spellBook = []data.Hit{{Title: "Fire", Text: "A small burst of fire."}, {Title: "Fira", Text: "A bigger fire. Warm."}, {Title: "Blizzard", Text: "Ice falls from nowhere."},
{Title: "Thunder", Text: "Bolts strike one foe."}, {Title: "Cure", Text: "Heals a little."}, {Title: "Firaga", Text: "All the fire at once."}}
func demoSearchResults(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := dataState(c)
var hits []data.Hit
for _, h := range spellBook {
if strings.Contains(strings.ToLower(h.Title+" "+h.Text), strings.ToLower(s.query)) {
hits = append(hits, h)
}
}
s.hit = min(s.hit, len(hits)-1)
ui.Column(c).Gap(k.D(4)).Width(k.D(180)).Children(func() {
if input.SearchField(c, "Search spells", &s.query, input.SearchFieldOptions{Placeholder: "fire"}).Changed {
log("query " + s.query)
}
r := data.SearchResults(c, "Spells", s.query, hits, &s.hit, data.SearchResultsOptions{Height: 90})
if r.Changed {
log("chose " + hits[s.hit].Title)
}
if r.Submitted {
log("cast " + hits[s.hit].Title)
}
})
}
func demoCardGrid(c *ui.Context, log func(string)) {
cards := []struct {
art *pixel.Sprite
name, line string
}{{icon.Sword, "Sword", "Pointy end first."}, {icon.Shield, "Shield", "Blocks most things."}, {icon.Potion, "Potion", "Tastes like cherry."},
{icon.Crown, "Crown", "Heavy is the head."}, {icon.Map, "Map", "X marks the spot."}, {icon.Key, "Key", "Opens one door."}}
data.CardGrid(c, len(cards), data.CardGridOptions{MinWidth: 90}, func(i int) {
layout.Card(c, layout.CardOptions{Title: cards[i].name, Icon: cards[i].art}, func() {
text.Paragraph(c, cards[i].line, text.ParagraphOptions{})
})
})
}
+1 -1
View File
@@ -56,7 +56,7 @@ var Categories = []Category{
{"tabletop", "Tabletop", "桌游", icon.Crown},
}
var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries)
var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries)
func Entries() []Entry { return entries }
+11
View File
@@ -221,3 +221,14 @@ func TestGridCopyFollowsTheLocale(t *testing.T) {
t.Fatal("END or the unlock banner ignores the locale")
}
}
func TestSwitchLabelNamesABarePlate(t *testing.T) {
lever := false
tt := view(300, 100, nil, func(c *ui.Context) {
input.Switch(c, "", &lever, input.SwitchOptions{Label: "Fly"})
})
click(t, tt, "Fly")
if !lever || len(tt.Texts()) != 1 {
t.Fatalf("switch %v, texts %q", lever, tt.Texts())
}
}
+3 -1
View File
@@ -10,6 +10,8 @@ import (
type SwitchOptions struct {
Disabled bool
// Label names it for assistive technology; zero uses text.
Label string
}
type SwitchResult struct {
@@ -20,7 +22,7 @@ type SwitchResult struct {
// Switch is a lever on an OFF/ON plate.
func Switch(c *ui.Context, text string, on *bool, o SwitchOptions) SwitchResult {
k := pixel.K(c)
shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.SwitchBase(c, on).Label(text).Disabled(o.Disabled) })
shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.SwitchBase(c, on).Label(label(o.Label, text)).Disabled(o.Disabled) })
shell.Size(k.D(32+pixel.Measure(text)), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 1, 27, 14, pixel.Raised, pixel.Shade)
+1 -1
View File
@@ -33,7 +33,7 @@ var bannedFuncs = set("Text Textf RichText Button PrimaryButton Link MenuButton
"NumberInput SearchField Combobox Autocomplete TokenField EditableText FindBar Calendar DateInput TimeInput " +
"ColorPicker ColorWell Table Tree TreeItem Accordion AccordionItem Collapsible Form Field Fieldset Tabs " +
"Sidebar SidebarItem SidebarSection Breadcrumbs BackButton ForwardButton Modal AlertDialog Popover Progress " +
"Spinner Meter Badge Image Icon Avatar Divider Split SplitVertical")
"Spinner Meter Badge Image Icon Avatar Divider Split SplitVertical Outline OutlineTable GridView")
// Element, Context and Painter methods that blend, ease or draw.
var bannedMethods = set("Background Border BorderColor BorderWidth BorderStyle Radius Shadow Gradient " +
+8
View File
@@ -19,6 +19,14 @@ func TestSpriteLettersMatchSlots(t *testing.T) {
}
}
func TestSilhouetteKeepsShapeInOneSlot(t *testing.T) {
sp := pixel.MustSprite("#a.g")
d := canvas(t, 8, 2, func(g pixel.Canvas) { g.Silhouette(0, 0, sp, 0, pixel.Shade, 2) })
if d.count(0, 0, 4, 2, pixel.Shade) != 8 || d.count(4, 0, 2, 2, pixel.Back) != 4 || d.count(6, 0, 2, 2, pixel.Shade) != 4 {
t.Fatal("Silhouette must fill the shape, scaled, in one slot")
}
}
func TestSpriteFramesMirrorScale(t *testing.T) {
sp := pixel.MustSprite(`
#a.
+4
View File
@@ -16,6 +16,8 @@ var messages = map[Locale]map[string]string{
"retry": "RETRY", "stage_clear": "STAGE CLEAR!", "level_up": "LEVEL UP!", "achievement": "ACHIEVEMENT UNLOCKED",
"combo": "COMBO!", "saving": "SAVING", "saved": "SAVED", "go": "GO!", "wait": "PLEASE WAIT",
"prev": "PREVIOUS", "next": "NEXT", "back": "BACK", "forward": "FORWARD", "history": "HISTORY",
"on": "ON", "off": "OFF", "clear": "CLEAR", "per_page": "PER PAGE", "range": "%d–%d OF %d", "found": "FOUND: %d",
"unknown": "???",
},
Zh: {
"ok": "确定", "cancel": "取消", "yes": "是", "no": "否", "close": "关闭",
@@ -30,6 +32,8 @@ var messages = map[Locale]map[string]string{
"retry": "重试", "stage_clear": "过关!", "level_up": "升级!", "achievement": "成就解锁",
"combo": "连击!", "saving": "存档中", "saved": "已存档", "go": "开始!", "wait": "请稍候",
"prev": "上一页", "next": "下一页", "back": "后退", "forward": "前进", "history": "历史",
"on": "开", "off": "关", "clear": "清除", "per_page": "每页", "range": "%d–%d / 共 %d", "found": "找到:%d",
"unknown": "???",
},
}
+3 -3
View File
@@ -121,14 +121,14 @@ func (s Settings) valid() Settings {
return s
}
// theme keeps MyGo's own drawing inside the palette.
// theme keeps MyGo's drawing in the palette; row highlights clear.
func theme(p *Palette) *ui.Theme {
t := ui.LightTheme()
c := p.Colors
t.Dark = p.Night
t.Background, t.Surface, t.SurfaceHover, t.SurfacePressed = c[Back], c[Face], c[Lit], c[Shade]
t.Background, t.Surface, t.SurfaceHover, t.SurfacePressed = c[Back], c[Face], ui.Transparent, c[Shade]
t.Border, t.Text, t.TextMuted = c[Edge], c[Ink], c[Faint]
t.Accent, t.AccentHover, t.AccentPressed, t.AccentText = c[Accent], c[AccentLit], c[Accent], c[AccentInk]
t.Accent, t.AccentHover, t.AccentPressed, t.AccentText = ui.Transparent, c[AccentLit], c[Accent], c[AccentInk]
t.Danger, t.Warning, t.Success = c[Bad], c[Warn], c[Good]
t.Selection, t.Focus, t.Scrollbar, t.Radius = c[AccentLit], c[Accent], ui.Transparent, 0
return t
+2 -2
View File
@@ -52,12 +52,12 @@ func TestUseBridgesTheme(t *testing.T) {
}{
{"Background", th.Background, p.C(pixel.Back)},
{"Surface", th.Surface, p.C(pixel.Face)},
{"SurfaceHover", th.SurfaceHover, p.C(pixel.Lit)},
{"SurfaceHover", th.SurfaceHover, ui.Transparent},
{"SurfacePressed", th.SurfacePressed, p.C(pixel.Shade)},
{"Border", th.Border, p.C(pixel.Edge)},
{"Text", th.Text, p.C(pixel.Ink)},
{"TextMuted", th.TextMuted, p.C(pixel.Faint)},
{"Accent", th.Accent, p.C(pixel.Accent)},
{"Accent", th.Accent, ui.Transparent},
{"AccentHover", th.AccentHover, p.C(pixel.AccentLit)},
{"AccentPressed", th.AccentPressed, p.C(pixel.Accent)},
{"AccentText", th.AccentText, p.C(pixel.AccentInk)},
+10 -1
View File
@@ -88,6 +88,15 @@ func (s *Sprite) Mirror() *Sprite {
// Sprite draws frame of sp; # takes ink; frames wrap.
func (g Canvas) Sprite(x, y int, sp *Sprite, frame int, ink Slot, scale int) {
g.sprite(x, y, sp, frame, ink, scale, false)
}
// Silhouette draws sp's shape all in one slot.
func (g Canvas) Silhouette(x, y int, sp *Sprite, frame int, s Slot, scale int) {
g.sprite(x, y, sp, frame, s, scale, true)
}
func (g Canvas) sprite(x, y int, sp *Sprite, frame int, ink Slot, scale int, mono bool) {
checkScale(scale)
n := len(sp.frames)
px := sp.frames[(frame%n+n)%n]
@@ -101,7 +110,7 @@ func (g Canvas) Sprite(x, y int, sp *Sprite, frame int, ink Slot, scale int) {
}
if v != cellClear {
s := ink
if v >= 0 {
if v >= 0 && !mono {
s = Slot(v)
}
g.Rect(x+col*scale, y+row*scale, (end-col)*scale, scale, s)