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