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:
47 files changed
+3379
-17
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{}) })
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()})
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
}()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user