Add account components 386-405; Reduce setting, PinInput Cmd+V

Twenty account components: SettingsList, OptionCycler, KeyBindings,
ControllerMap, ProfileCard, CharacterCreator, LoginForm, PasswordGrid,
SessionList, LanguagePicker, PalettePicker, VolumeMixer,
DifficultySelect, AccessibilityOptions, AccountSwitcher, PlayerSelect,
TwoFactor, About, PatchNotes and FeedbackForm.

The components never check credentials, and no secret reaches the log:
LoginForm reports Submitted and the caller counts Failures; each new
failure clears the password, refocuses it, shakes the form and names no
field. TwoFactor builds on input.PinInput and drains an epoch-aligned
period bar. PalettePicker draws each tile in its own palette on a
pixel.Pad grid.

pixel.Settings.Reduce stills motion like the system preference, the
micro font gains '?', display.Face draws avatar faces anywhere, and
input.PinInput pastes on Cmd+V as native hosts send it.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-12 08:21:39 -04:00
1 parent 51af14709d
commit c745b082a9
38 files changed
+2788 -13

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
package account
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
// Credit is a role and who filled it.
type Credit struct {
Role, Name string
}
type AboutOptions struct {
Version string
Credits []Credit
}
// About shows the title as a logo, version, then credits.
func About(c *ui.Context, title string, o AboutOptions) *ui.Element {
k := pixel.K(c)
if o.Version == "" {
panic("account: About needs a version")
}
return ui.Column(c).Gap(k.D(4)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(title).Children(func() {
text.OutlineText(c, title, text.OutlineTextOptions{Slot: pixel.Accent, Scale: 3})
pixel.Text(c, k.Msg("version")+" "+o.Version, pixel.TextOptions{Slot: pixel.Faint})
if len(o.Credits) == 0 {
return
}
ui.Box(c).Size(k.D(80), k.D(5)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for x := 0; x < g.W; x += 4 {
g.Rect(x, 2, 2, 1, pixel.Shade)
}
})
pixel.Text(c, k.Msg("credits"), pixel.TextOptions{Slot: pixel.Accent})
for _, cr := range o.Credits {
pixel.Text(c, cr.Role, pixel.TextOptions{Slot: pixel.Faint})
pixel.Text(c, cr.Name, pixel.TextOptions{})
}
})
}
+51
View File
@@ -0,0 +1,51 @@
package account
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Access is how a player wants motion and dots.
type Access struct {
// Reduce goes to pixel.Settings.Reduce, Dot to Settings.Dot.
Reduce bool
Dot int
}
type AccessibilityOptionsOptions struct {
Disabled bool
}
type AccessibilityOptionsResult struct {
Element *ui.Element
Changed bool
}
// AccessibilityOptions sets Reduce Motion and the dot size, 1–4.
func AccessibilityOptions(c *ui.Context, text string, a *Access, o AccessibilityOptionsOptions) AccessibilityOptionsResult {
k := pixel.K(c)
if a.Dot < 1 || a.Dot > 4 {
panic(fmt.Sprintf("account: AccessibilityOptions dot %d outside 1–4", a.Dot))
}
var res AccessibilityOptionsResult
res.Element = ui.Column(c).Gap(k.D(6)).Role(ui.RoleGroup).Label(text).Children(func() {
reduce := input.Switch(c, k.Msg("reduce_motion"), &a.Reduce, input.SwitchOptions{Disabled: o.Disabled}).Changed
dot, sized := a.Dot-1, false
ui.Row(c).Gap(k.D(6)).AlignItems(ui.Center).Children(func() {
mark(c, k, k.Msg("dot_size"), pixel.Ink, 0)
if input.Segmented(c, k.Msg("dot_size"), []string{"1", "2", "3", "4"}, &dot, input.SegmentedOptions{Disabled: o.Disabled}).Changed {
a.Dot, sized = dot+1, true
}
})
res.Changed = reduce || sized
})
if res.Changed {
slog.Debug("8bit: accessibility options", "label", text, "reduce", a.Reduce, "dot", a.Dot)
}
return res
}
+38
View File
@@ -0,0 +1,38 @@
// Package account holds settings, sign-in and player screens.
package account
import (
"fmt"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// players are the arcade colors of P1 to P4.
var players = [4]pixel.Slot{pixel.Bad, pixel.Info, pixel.Good, pixel.Warn}
// choice panics unless *i points into n choices.
func choice(name string, i *int, n int) {
if n == 0 || *i < 0 || *i >= n {
panic(fmt.Sprintf("account: %s choice %d of %d", name, *i, n))
}
}
// words is a message split on spaces, as option names.
func words(k pixel.Kit, key string) []string { return strings.Fields(k.Msg(key)) }
// caption is a line of micro text named for readers.
func caption(c *ui.Context, k pixel.Kit, s string, slot pixel.Slot) *ui.Element {
return ui.Box(c).Size(k.D(pixel.MicroWidth(s)), k.D(5)).Role(ui.RoleText).Label(s).Draw(func(p *ui.Painter, r ui.Rect) {
k.Canvas(p, r).Micro(0, 0, s, slot, 1)
})
}
// mark draws s unnamed, beside the control that names it.
func mark(c *ui.Context, k pixel.Kit, s string, slot pixel.Slot, w int) *ui.Element {
return ui.Box(c).Size(k.D(max(w, pixel.Measure(s))), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
k.Canvas(p, r).Text(0, 0, s, slot, 1)
})
}
+72
View File
@@ -0,0 +1,72 @@
package account
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type AccountSwitcherOptions struct {
Disabled bool
}
type AccountSwitcherResult struct {
Element *ui.Element
Changed bool
}
// AccountSwitcher shows slots P1 to P4; empty names are free.
func AccountSwitcher(c *ui.Context, text string, names [4]string, current *int, o AccountSwitcherOptions) AccountSwitcherResult {
k := pixel.K(c)
choice("AccountSwitcher", current, len(names))
var res AccountSwitcherResult
res.Element = ui.Row(c).Gap(k.D(4)).FocusGroup(ui.Horizontal).Role(ui.RoleRadioGroup).Label(text).Children(func() {
for i, n := range names {
say := n
if n == "" {
say = k.Msg("free_slot")
}
tag := fmt.Sprintf("P%d", i+1)
label := tag + ", " + say
if i == *current {
label += ", " + k.Msg("in_use")
}
// The shell draws the card; the base stays bare.
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.ButtonBase(c).Role(ui.RoleRadio).Label(label).Disabled(o.Disabled)
})
if base.Clicked() && *current != i {
*current, res.Changed = i, true
}
on, color := i == *current, players[i]
shell.Size(k.D(64), k.D(62)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
edge := pixel.Shade
if on {
edge = color
}
g.Frame(0, 0, 64, 62, pixel.Raised, pixel.Face)
g.Rect(0, 0, 64, 2, edge)
g.Rect(0, 60, 64, 2, edge)
g.Rect(2, 2, 60, 10, color)
g.Micro(32-pixel.MicroWidth(tag)/2, 4, tag, pixel.AccentInk, 1)
if n == "" {
g.Checker(16, 14, 32, 32, pixel.Shade)
} else {
display.Face(g, 16, 14, n, 2)
}
name := pixel.Wrap(say, 60, 1)[0]
g.Text(32-pixel.Measure(name)/2, 47, name, pixel.Ink, 1)
g.Finish(st)
})
}
})
if res.Changed {
slog.Debug("8bit: account switcher", "label", text, "slot", *current+1)
}
return res
}
+285
View File
@@ -0,0 +1,285 @@
package account_test
import (
"bytes"
"image"
"log/slog"
"strings"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/account"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type clock struct{ t time.Time }
func (c *clock) now() time.Time { return c.t }
// world is the settings a test view runs under.
type world struct {
clk *clock
reduce, inert bool
}
func newWorld() *world {
return &world{clk: &clock{time.Date(2026, 9, 2, 10, 30, 0, 0, time.UTC)}}
}
// view builds on a fake clock, two pixels a dot.
func view(w, h int, wd *world, build func(c *ui.Context)) *ui.Tester {
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: wd.clk.now, Inert: wd.inert})
build(c)
}, w, h)
if wd.reduce {
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
tt.Frame()
}
return tt
}
// later moves the clock on and builds a frame.
func later(tt *ui.Tester, wd *world, d time.Duration) {
wd.clk.t = wd.clk.t.Add(d)
tt.Frame()
}
func click(t *testing.T, tt *ui.Tester, label string) {
t.Helper()
if err := tt.Click(label); err != nil {
t.Fatalf("%v in %q", err, tt.Texts())
}
}
func find(t *testing.T, tt *ui.Tester, label string) ui.Rect {
t.Helper()
r, ok := tt.Find(label)
if !ok {
t.Fatalf("no %q in %q", label, tt.Texts())
}
return r
}
// none is a color outside the palette.
const none pixel.Slot = 255
// at is the slot at dot x, y, or none.
func at(img *image.RGBA, x, y int) pixel.Slot {
c := img.RGBAAt(2*x, 2*y)
for s, want := range pixel.SpringDay.Colors {
if c.R == want.R && c.G == want.G && c.B == want.B {
return pixel.Slot(s)
}
}
return none
}
// count is how many dots in a box hold s.
func count(img *image.RGBA, x, y, w, h int, s pixel.Slot) int {
n := 0
for j := y; j < y+h; j++ {
for i := x; i < x+w; i++ {
if at(img, i, j) == s {
n++
}
}
}
return n
}
// dots is a found box in dots.
func dots(r ui.Rect) (x, y, w, h int) {
return int(r.X / 2), int(r.Y / 2), int(r.W / 2), int(r.H / 2)
}
func mustPanic(t *testing.T, name string, fn func()) {
t.Helper()
defer func() {
if recover() == nil {
t.Errorf("%s did not panic", name)
}
}()
fn()
}
// logged runs fn with the debug log captured.
func logged(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 TestLoginFormSubmitsAndShakesOnFailure(t *testing.T) {
for _, reduce := range []bool{false, true} {
wd := newWorld()
wd.reduce = reduce
name, pass, fails, sent := "", "", 0, 0
tt := view(500, 300, wd, func(c *ui.Context) {
if account.LoginForm(c, "WELCOME", &name, &pass, account.LoginFormOptions{Failures: fails}).Submitted {
sent++
}
})
click(t, tt, "LOGIN")
if sent != 0 {
t.Fatal("LOGIN sent an empty form")
}
log := logged(func() {
click(t, tt, "NAME")
tt.Type("player1")
click(t, tt, "PASSWORD")
tt.Type("hunter2")
tt.Key(0, ui.KeyEnter)
})
if sent != 1 || strings.Contains(log, "hunter2") || strings.Contains(log, "player1") {
t.Fatalf("Enter sent %d; the log must not hold credentials:\n%s", sent, log)
}
x0, _, _, _ := dots(find(t, tt, "LOGIN"))
// The caller says no: the password clears, focus comes back.
click(t, tt, "NAME")
fails++
tt.Frame()
if pass != "" || name != "player1" || !tt.HasText("WRONG NAME OR PASSWORD") || !tt.Focused("PASSWORD") {
t.Fatalf("after a failure: %q %q, %q", name, pass, tt.Texts())
}
x1, _, _, _ := dots(find(t, tt, "LOGIN"))
if moved := x1 != x0; moved == reduce {
t.Fatalf("reduce %v: the form moved %v", reduce, moved)
}
later(tt, wd, time.Second)
if x2, _, _, _ := dots(find(t, tt, "LOGIN")); x2 != x0 {
t.Fatalf("the shake did not settle: %d, was %d", x2, x0)
}
tt.Type("x")
if tt.HasText("WRONG NAME OR PASSWORD") {
t.Fatal("typing kept the warning up")
}
}
}
func TestPasswordGridSpellsAndEnds(t *testing.T) {
wd := newWorld()
pass, ended := "", false
tt := view(500, 300, wd, func(c *ui.Context) {
ended = account.PasswordGrid(c, "Grid", &pass, account.PasswordGridOptions{Length: 4}).Submitted || ended
})
r := find(t, tt, "SYMBOLS")
cell := func(i int) (float32, float32) { return r.X + float32(22*(i%8)+11), r.Y + float32(22*(i/8)+11) }
log := logged(func() {
for _, i := range []int{9, 20, 18, 19} {
tt.ClickAt(cell(i))
}
})
if pass != "JUST" || strings.Contains(log, "JUST") {
t.Fatalf("spelled %q; log:\n%s", pass, log)
}
tt.ClickAt(cell(0))
if pass != "JUST" {
t.Fatal("a full password took another symbol")
}
tt.Key(0, ui.KeyBackspace)
click(t, tt, "BACK")
if pass != "JU" {
t.Fatalf("Backspace and BACK left %q", pass)
}
click(t, tt, "END")
if ended {
t.Fatal("END worked on a short password")
}
tt.ClickAt(cell(18))
tt.ClickAt(cell(19))
click(t, tt, "END")
if !ended {
t.Fatal("END on a full password did not end")
}
for name, p := range map[string]string{"lowercase": "ab", "too long": "ABCDE"} {
mustPanic(t, name, func() {
view(500, 300, newWorld(), func(c *ui.Context) {
account.PasswordGrid(c, "G", &p, account.PasswordGridOptions{Length: 4})
})
})
}
}
func TestTwoFactorTakesDigitsAndPastes(t *testing.T) {
wd := newWorld()
code, sent := "", 0
tt := view(500, 300, wd, func(c *ui.Context) {
if account.TwoFactor(c, "Code", &code, account.TwoFactorOptions{}).Submitted {
sent++
}
})
click(t, tt, "Code")
log := logged(func() { tt.Type("42a7") })
if code != "427" || strings.Contains(log, "427") {
t.Fatalf("typed %q; log:\n%s", code, log)
}
tt.Key(0, ui.KeyBackspace)
tt.SetClipboard("your code: 1-3-5-2-4-6, enjoy")
tt.Key(ui.Cmd, ui.KeyV)
// Paste fills the four boxes left, from 1352.
if code != "421352" || sent != 1 {
t.Fatalf("paste gave %q, sent %d", code, sent)
}
tt.Type("9")
if code != "421352" || sent != 1 {
t.Fatalf("a seventh digit went in: %q, %d", code, sent)
}
// Three seconds left in the period: the bar is Bad.
wd.clk.t = time.Unix(60*1000+27, 0)
tt.Frame()
x, y, w, _ := dots(find(t, tt, "TIME LEFT"))
if count(tt.Image(), x, y+2, w, 2, pixel.Bad) == 0 {
t.Fatal("the bar is not Bad with three seconds left")
}
mustPanic(t, "letters in the code", func() {
view(500, 300, newWorld(), func(c *ui.Context) {
s := "12a"
account.TwoFactor(c, "C", &s, account.TwoFactorOptions{})
})
})
}
func TestSessionListSignsOut(t *testing.T) {
wd := newWorld()
out := -1
tt := view(600, 300, wd, func(c *ui.Context) {
if r := account.SessionList(c, "Devices", []account.Session{
{Device: "Laptop", Seen: wd.clk.t, Current: true}, {Device: "Phone", Seen: wd.clk.t.Add(-2 * time.Hour)},
}, account.SessionListOptions{}); r.SignedOut >= 0 {
out = r.SignedOut
}
})
find(t, tt, "THIS DEVICE")
if !tt.HasText("2 H AGO") {
t.Fatalf("no last-seen time in %q", tt.Texts())
}
click(t, tt, "SIGN OUT Phone")
if out != 1 {
t.Fatalf("SIGN OUT Phone signed out %d", out)
}
}
func TestFeedbackFormSendsOnceRated(t *testing.T) {
wd := newWorld()
rating, words, sent := 0, "", false
tt := view(500, 300, wd, func(c *ui.Context) {
sent = account.FeedbackForm(c, "Feedback", &rating, &words, account.FeedbackFormOptions{}).Sent || sent
})
click(t, tt, "MESSAGE")
tt.Type("more slimes")
click(t, tt, "SEND ▶")
if sent {
t.Fatal("SEND worked without a rating")
}
click(t, tt, "4/5")
log := logged(func() { click(t, tt, "SEND ▶") })
if !sent || rating == 0 || strings.Contains(log, "slimes") {
t.Fatalf("sent %v at %d stars; log:\n%s", sent, rating, log)
}
}
+158
View File
@@ -0,0 +1,158 @@
package account
import (
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Look is a character's parts and colors, each an index.
type Look struct {
Hair, Eyes, Outfit, HairColor, OutfitColor int
}
// hues color hair and outfits, as "hues" names them.
var hues = []pixel.Slot{pixel.Ink, pixel.Warn, pixel.Bad, pixel.Magic, pixel.Info}
var (
head = pixel.MustSprite(`
................
................
................
.....eeeeee.....
....elllllle....
....elllllle....
....elllllle....
....elllllle....
.....elllle.....
......eeee......
................
................
................
................
................
................
`)
// hair is SPIKY, BOB, BALD; # takes the hair color.
hair = []*pixel.Sprite{pixel.MustSprite(`
....#..#..#.....
....##.##.##....
....########....
...##########...
...##......##...
...#........#...
`), pixel.MustSprite(`
................
................
.....######.....
....########....
...##########...
...##......##...
...##......##...
...##......##...
...##......##...
`), pixel.MustSprite(`
................
`)}
// eyes are DOTS, WIDE, SLEEPY, from the face's fifth column.
eyes = []*pixel.Sprite{pixel.MustSprite(`
.......
.......
.......
.......
.......
.......
..k..k.
`), pixel.MustSprite(`
.......
.......
.......
.......
.......
..k..k.
..k..k.
`), pixel.MustSprite(`
.......
.......
.......
.......
.......
.......
.kk..kk
`)}
// outfits are TUNIC, ROBE, ARMOR; # takes the outfit color.
outfits = []*pixel.Sprite{pixel.MustSprite(`
.....e####e.....
....e######e....
...e########e...
...l########l...
....########....
....##....##....
`), pixel.MustSprite(`
.....e####e.....
....e######e....
...e########e...
...l########l...
...##########...
..############..
`), pixel.MustSprite(`
.....e####e.....
....e#ssss#e....
...e#ssssss#e...
...l#ssssss#l...
....#ssssss#....
....##....##....
`)}
)
type CharacterCreatorOptions struct {
Disabled bool
}
type CharacterCreatorResult struct {
Element *ui.Element
Changed bool
}
// CharacterCreator cycles hair, eyes, outfit and colors; a live avatar.
func CharacterCreator(c *ui.Context, text string, look *Look, o CharacterCreatorOptions) CharacterCreatorResult {
k := pixel.K(c)
styles, looks, wears, hueNames := words(k, "styles"), words(k, "looks"), words(k, "outfits"), words(k, "hues")
var res CharacterCreatorResult
res.Element = ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(text).Children(func() {
shown := *look
say := strings.Join([]string{styles[look.Hair], looks[look.Eyes], wears[look.Outfit], hueNames[look.HairColor], hueNames[look.OutfitColor]}, ", ")
ui.Box(c).Size(k.D(56), k.D(56)).Role(ui.RoleImage).Label(say).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Frame(0, 0, 56, 56, pixel.Sunken, pixel.Well)
g.Sprite(4, 4, head, 0, pixel.Ink, 3)
g.Sprite(4+3*4, 4, eyes[shown.Eyes], 0, pixel.Ink, 3)
g.Sprite(4, 4, hair[shown.Hair], 0, hues[shown.HairColor], 3)
g.Sprite(4, 4+3*10, outfits[shown.Outfit], 0, hues[shown.OutfitColor], 3)
})
ui.Column(c).Gap(k.D(2)).Children(func() {
for _, row := range []struct {
key string
names []string
at *int
}{
{"hair", styles, &look.Hair}, {"eyes", looks, &look.Eyes}, {"outfit", wears, &look.Outfit},
{"hair_color", hueNames, &look.HairColor}, {"outfit_color", hueNames, &look.OutfitColor},
} {
ui.Row(c).Gap(k.D(4)).AlignItems(ui.Center).Children(func() {
mark(c, k, k.Msg(row.key), pixel.Faint, 64)
if OptionCycler(c, k.Msg(row.key), row.names, row.at, OptionCyclerOptions{Width: 48, Disabled: o.Disabled}).Changed {
res.Changed = true
}
})
}
})
})
if res.Changed {
slog.Debug("8bit: character creator", "label", text, "look", *look)
}
return res
}
+90
View File
@@ -0,0 +1,90 @@
package account
import (
"fmt"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// PadButton is a control on the game pad.
type PadButton int
const (
DPad PadButton = iota
ButtonA
ButtonB
ButtonX
ButtonY
ShoulderL
ShoulderR
Start
Select
)
// pad is each button's name, place, label side and row.
var pad = []struct {
name string
x, y, side, ly int
}{
DPad: {"DPAD", 45, 45, -1, 26}, ButtonA: {"A", 130, 45, 1, 46}, ButtonB: {"B", 120, 55, 1, 74}, ButtonX: {"X", 120, 35, 1, 18},
ButtonY: {"Y", 110, 45, 1, 32}, ShoulderL: {"L", 40, 17, -1, 4}, ShoulderR: {"R", 120, 17, 1, 4},
Start: {"START", 87, 50, 1, 60}, Select: {"SELECT", 73, 50, -1, 56},
}
type ControllerMapOptions struct{}
// ControllerMap draws a pad; labels say what each button does.
func ControllerMap(c *ui.Context, text string, labels map[PadButton]string, o ControllerMapOptions) *ui.Element {
k := pixel.K(c)
var said []string
for b := DPad; b <= Select; b++ {
if l, ok := labels[b]; ok {
said = append(said, pad[b].name+": "+l)
}
}
for b := range labels {
if b < DPad || b > Select {
panic(fmt.Sprintf("account: ControllerMap button %d", b))
}
}
// The pad sits mid-picture; labels stand out at the sides.
const ox, w, h = 70, 310, 96
return ui.Box(c).Size(k.D(w), k.D(h)).Role(ui.RoleImage).Label(text).Description(strings.Join(said, ", ")).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for _, x := range []int{40, 120} {
g.Rect(ox+x-15, 13, 30, 6, pixel.Shade)
g.Disc(ox+x, 45, 27, pixel.Edge)
}
g.Rect(ox+40, 18, 80, 55, pixel.Edge)
for _, x := range []int{40, 120} {
g.Disc(ox+x, 45, 26, pixel.Face)
}
g.Rect(ox+40, 19, 80, 53, pixel.Face)
// Lines first, so buttons sit on their ends.
for b := DPad; b <= Select; b++ {
if l, ok := labels[b]; ok {
lx := ox - 4
if pad[b].side > 0 {
lx = ox + 164
}
g.Line(ox+pad[b].x, pad[b].y, lx, pad[b].ly+6, pixel.Faint)
if pad[b].side < 0 {
g.Text(ox-6-pixel.Measure(l), pad[b].ly, l, pixel.Ink, 1)
} else {
g.Text(ox+166, pad[b].ly, l, pixel.Ink, 1)
}
}
}
g.Rect(ox+38, 43, 15, 5, pixel.Ink)
g.Rect(ox+43, 38, 5, 15, pixel.Ink)
for b, s := range map[PadButton]pixel.Slot{ButtonA: pixel.Bad, ButtonB: pixel.Warn, ButtonX: pixel.Info, ButtonY: pixel.Good} {
g.Disc(ox+pad[b].x, pad[b].y, 4, s)
}
for _, b := range []PadButton{Start, Select} {
g.Rect(ox+pad[b].x-4, pad[b].y-1, 8, 3, pixel.Shade)
}
})
}
+55
View File
@@ -0,0 +1,55 @@
package account
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type DifficultySelectOptions struct {
Disabled bool
}
type DifficultySelectResult struct {
Element *ui.Element
Changed bool
}
var levels = []string{"easy", "normal", "hard", "nightmare"}
// DifficultySelect picks EASY to NIGHTMARE, a skull a step.
func DifficultySelect(c *ui.Context, text string, chosen *int, o DifficultySelectOptions) DifficultySelectResult {
k := pixel.K(c)
choice("DifficultySelect", chosen, len(levels))
old := *chosen
// Arrows choose at once; the cursor is the choice.
shell, base, st, _ := pixel.Pad(c, text, 1, len(levels), 140, 16, chosen, o.Disabled)
base.Description(fmt.Sprintf("%s, %d", k.Msg(levels[*chosen]), *chosen+1))
at := *chosen
shell.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i, l := range levels {
y, ink := i*16, pixel.Ink
if i == len(levels)-1 {
ink = pixel.Bad
}
if i == at {
g.Text(2, y+2, "▶", pixel.Accent, 1)
}
g.Text(14, y+2, k.Msg(l), ink, 1)
for s := range i + 1 {
g.Sprite(140-14*(s+1), y+2, icon.Skull, 0, ink, 1)
}
}
g.Finish(st)
})
res := DifficultySelectResult{Element: shell, Changed: *chosen != old}
if res.Changed {
slog.Debug("8bit: difficulty", "label", text, "level", levels[*chosen])
}
return res
}
+39
View File
@@ -0,0 +1,39 @@
package account
import (
"log/slog"
"unicode/utf8"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type FeedbackFormOptions struct {
// Width is in dots; zero means 200.
Width int
Disabled bool
}
type FeedbackFormResult struct {
Element *ui.Element
Sent bool
}
// FeedbackForm takes stars and words; SEND waits for a rating.
func FeedbackForm(c *ui.Context, text string, rating *int, message *string, o FeedbackFormOptions) FeedbackFormResult {
k := pixel.K(c)
w := pixel.Or("FeedbackForm Width", o.Width, 200)
var res FeedbackFormResult
res.Element = ui.Column(c).Width(k.D(w)).Gap(k.D(5)).Role(ui.RoleGroup).Label(text).Children(func() {
input.Rating(c, k.Msg("rating"), rating, input.RatingOptions{Disabled: o.Disabled})
input.TextArea(c, k.Msg("message"), message, input.TextAreaOptions{Width: w, Disabled: o.Disabled})
res.Sent = input.Button(c, k.Msg("send"), input.ButtonOptions{Variant: input.Primary, Disabled: o.Disabled || *rating == 0}).Clicked
})
// The words stay private: the log counts them.
if res.Sent {
slog.Debug("8bit: feedback sent", "label", text, "rating", *rating, "runes", utf8.RuneCountInString(*message))
}
return res
}
+304
View File
@@ -0,0 +1,304 @@
package account_test
import (
"fmt"
"image"
"math"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/account"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// offPalette counts pixels of a box no palette color matches.
func offPalette(img *image.RGBA, r ui.Rect) int {
n := 0
for y := int(r.Y); y < int(r.Y+r.H); y++ {
for x := int(r.X); x < int(r.X+r.W); x++ {
c, hit := img.RGBAAt(x, y), false
// PalettePicker shows every palette, so any will do.
for _, pal := range pixel.Palettes {
for _, p := range pal.Colors {
hit = hit || p.R == c.R && p.G == c.G && p.B == c.B
}
}
if !hit {
n++
}
}
}
return n
}
func TestPatchNotesReadInChinese(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Locale: pixel.Zh})
account.PatchNotes(c, "更新", []account.Patch{{Version: "v2", Added: []string{"船"}}}, account.PatchNotesOptions{})
}, 400, 200)
find(t, tt, "新增: 船")
}
func TestAccountStaysOnThePaletteWhenOff(t *testing.T) {
builds := map[string]func(c *ui.Context, off bool){
"access": func(c *ui.Context, off bool) {
a := account.Access{Dot: 2}
account.AccessibilityOptions(c, "X", &a, account.AccessibilityOptionsOptions{Disabled: off})
},
"creator": func(c *ui.Context, off bool) {
l := account.Look{}
account.CharacterCreator(c, "X", &l, account.CharacterCreatorOptions{Disabled: off})
},
"switcher": func(c *ui.Context, off bool) {
i := 0
account.AccountSwitcher(c, "X", [4]string{"Aria", "", "", ""}, &i, account.AccountSwitcherOptions{Disabled: off})
},
"feedback": func(c *ui.Context, off bool) {
r, m := 3, "hi"
account.FeedbackForm(c, "X", &r, &m, account.FeedbackFormOptions{Disabled: off})
},
"keys": func(c *ui.Context, off bool) {
account.KeyBindings(c, "X", []account.Binding{{Action: "Jump", Key: ui.KeyZ}}, account.KeyBindingsOptions{Disabled: off})
},
"difficulty": func(c *ui.Context, off bool) {
i := 1
account.DifficultySelect(c, "X", &i, account.DifficultySelectOptions{Disabled: off})
},
"login": func(c *ui.Context, off bool) {
n, pw := "a", "b"
account.LoginForm(c, "X", &n, &pw, account.LoginFormOptions{Disabled: off})
},
"cycler": func(c *ui.Context, off bool) {
i := 0
account.OptionCycler(c, "X", []string{"A", "B"}, &i, account.OptionCyclerOptions{Disabled: off})
},
"palettes": func(c *ui.Context, off bool) {
i := 0
account.PalettePicker(c, "X", &i, account.PalettePickerOptions{Disabled: off})
},
"players": func(c *ui.Context, off bool) {
i := 1
account.PlayerSelect(c, "X", &i, account.PlayerSelectOptions{Disabled: off})
},
"grid": func(c *ui.Context, off bool) {
pw := "AB"
account.PasswordGrid(c, "X", &pw, account.PasswordGridOptions{Disabled: off})
},
"sessions": func(c *ui.Context, off bool) {
account.SessionList(c, "X", []account.Session{{Device: "Phone", Seen: time.Unix(0, 0)}}, account.SessionListOptions{Disabled: off})
},
"mixer": func(c *ui.Context, off bool) {
m := account.Mix{BGM: 0.5}
account.VolumeMixer(c, "X", &m, account.VolumeMixerOptions{Disabled: off})
},
"twofactor": func(c *ui.Context, off bool) {
code := "12"
account.TwoFactor(c, "X", &code, account.TwoFactorOptions{Disabled: off})
},
}
for name, build := range builds {
for _, inert := range []bool{false, true} {
wd := newWorld()
wd.inert = inert
tt := view(700, 400, wd, func(c *ui.Context) { build(c, !inert) })
if n := offPalette(tt.Image(), ui.Rect{W: 700, H: 400}); n != 0 {
t.Errorf("%s, inert %v: %d pixels off the palette", name, inert, n)
}
}
}
}
func TestLoginFormLeavesFocusWhenOff(t *testing.T) {
wd := newWorld()
name, pass, fails := "a", "b", 0
tt := view(500, 300, wd, func(c *ui.Context) {
ui.Column(c).Children(func() {
ui.ButtonBase(c).Label("elsewhere").Size(40, 20)
account.LoginForm(c, "WELCOME", &name, &pass, account.LoginFormOptions{Failures: fails})
})
})
click(t, tt, "elsewhere")
// A failure lands in the frame Inert begins.
fails, wd.inert = 1, true
tt.Frame()
if tt.Focused("PASSWORD") {
t.Fatal("an inert form took the focus")
}
}
func TestOptionCyclerKeysIgnoreThePointer(t *testing.T) {
sel := 0
tt := view(400, 200, newWorld(), func(c *ui.Context) {
account.OptionCycler(c, "Speed", []string{"A", "B", "C"}, &sel, account.OptionCyclerOptions{})
})
r := find(t, tt, "Speed")
tt.Move(r.X+2, r.Y+r.H/2)
tt.Key(0, ui.KeyTab)
for range 2 {
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeySpace)
}
if sel != 1 {
t.Fatalf("four presses with the pointer on the left gave %d, want 1", sel)
}
}
func TestKeyBindingsTakeAnyNextKey(t *testing.T) {
binds := []account.Binding{{Action: "Help", Key: ui.KeyH}}
tt := view(400, 200, newWorld(), func(c *ui.Context) {
account.KeyBindings(c, "Keys", binds, account.KeyBindingsOptions{})
})
click(t, tt, "Keys")
for _, k := range []ui.Key{ui.KeyF1, ui.KeySlash, ui.KeyPageDown} {
tt.Key(0, k)
if binds[0].Key != k {
t.Fatalf("the next key, %v, did not bind: %v", k, binds[0].Key)
}
tt.Key(0, ui.KeyEnter)
}
}
func TestTwoFactorTakesASCIIDigitsOnly(t *testing.T) {
mustPanic(t, "fullwidth digits", func() {
view(400, 200, newWorld(), func(c *ui.Context) {
s := "12"
account.TwoFactor(c, "C", &s, account.TwoFactorOptions{})
})
})
}
func TestTwoFactorBarFollowsAnOddPeriod(t *testing.T) {
wd := newWorld()
// At 30 s, 1.1 s periods leave 0.8 s.
wd.clk.t = time.Unix(30, 0)
code := ""
tt := view(400, 200, wd, func(c *ui.Context) {
account.TwoFactor(c, "Code", &code, account.TwoFactorOptions{Period: 1100 * time.Millisecond})
})
x, y, w, _ := dots(find(t, tt, "TIME LEFT"))
n := count(tt.Image(), x, y+2, w, 1, pixel.Bad)
if want := (w - 4) * 8 / 11; n < want-1 || n > want+1 {
t.Fatalf("the bar is %d dots, want %d", n, want)
}
}
func TestProfileCardWantsMicroStatNames(t *testing.T) {
mustPanic(t, "a stat named in hanzi", func() {
view(400, 200, newWorld(), func(c *ui.Context) {
account.ProfileCard(c, account.Profile{Name: "A", Level: 1, Stats: []account.Stat{{Name: "体力", Value: 1, Max: 2}}}, account.ProfileCardOptions{})
})
})
}
func TestKeyBindingsBindSpaceAfterEnter(t *testing.T) {
binds := []account.Binding{{Action: "Jump", Key: ui.KeyZ}}
tt := view(400, 200, newWorld(), func(c *ui.Context) {
account.KeyBindings(c, "Keys", binds, account.KeyBindingsOptions{})
})
click(t, tt, "Keys")
tt.Key(0, ui.KeyEscape)
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeySpace)
if binds[0].Key != ui.KeySpace {
t.Fatalf("Enter, then Space bound %v", binds[0].Key)
}
}
func TestTwoFactorTakesTextAndThePasteCommand(t *testing.T) {
code, sent := "", 0
tt := view(400, 200, newWorld(), func(c *ui.Context) {
if account.TwoFactor(c, "Code", &code, account.TwoFactorOptions{}).Submitted {
sent++
}
})
click(t, tt, "Code")
tt.Type("12")
// Browsers paste through the command, as cmd/web sends it.
tt.SetClipboard("a3b4c5d6e7")
tt.Command("paste")
if _, ok := tt.TextCaret(); !ok || code != "123456" || sent != 1 {
t.Fatalf("typing and the paste command gave %q, sent %d", code, sent)
}
}
func TestPalettePickerArrowsMoveByRows(t *testing.T) {
chosen := 0
tt := view(600, 300, newWorld(), func(c *ui.Context) {
account.PalettePicker(c, "Palettes", &chosen, account.PalettePickerOptions{})
})
tt.Key(0, ui.KeyTab)
got := []int{}
for _, k := range []ui.Key{ui.KeyDown, ui.KeyRight, ui.KeyUp} {
tt.Key(0, k)
tt.Key(0, ui.KeyEnter)
got = append(got, chosen)
}
if got[0] != 4 || got[1] != 5 || got[2] != 1 {
t.Fatalf("Down, Right, Up chose %v, want [4 5 1]", got)
}
}
func TestNegativeCountsPanic(t *testing.T) {
mustPanic(t, "a width of -1", func() {
view(400, 200, newWorld(), func(c *ui.Context) {
i := 0
account.OptionCycler(c, "S", []string{"A"}, &i, account.OptionCyclerOptions{Width: -1})
})
})
mustPanic(t, "-1 failures", func() {
view(400, 200, newWorld(), func(c *ui.Context) {
n, pw := "", ""
account.LoginForm(c, "L", &n, &pw, account.LoginFormOptions{Failures: -1})
})
})
}
func TestTwoFactorBarSurvivesALongPeriod(t *testing.T) {
wd := newWorld()
wd.clk.t = time.Unix(30, 0)
code := ""
tt := view(400, 200, wd, func(c *ui.Context) {
account.TwoFactor(c, "Code", &code, account.TwoFactorOptions{Period: 4 * 365 * 24 * time.Hour})
})
x, y, w, _ := dots(find(t, tt, "TIME LEFT"))
if n := count(tt.Image(), x, y+2, w, 1, pixel.Good); n < w-5 {
t.Fatalf("a four-year period 30 s in fills %d of %d dots", n, w-4)
}
}
func TestProfileCardFillsAtHugeValues(t *testing.T) {
tt := view(1300, 300, newWorld(), func(c *ui.Context) {
account.ProfileCard(c, account.Profile{Name: "A", Level: 1, Stats: []account.Stat{{Name: "HP", Value: math.MaxInt, Max: math.MaxInt}}}, account.ProfileCardOptions{Width: 600})
})
x, y, w, _ := dots(find(t, tt, "HP"))
v := fmt.Sprintf("%d/%d", math.MaxInt, math.MaxInt)
inner := w - pixel.MicroWidth("HP") - pixel.MicroWidth(v) - 12
if n := count(tt.Image(), x, y+4, w, 1, pixel.Good); n != inner {
t.Fatalf("a full meter of huge numbers fills %d of %d dots", n, inner)
}
}
func TestProfileCardKeepsLongNamesOffTheBar(t *testing.T) {
stats := []account.Stat{{Name: "AGILITY", Value: 1, Max: 2}, {Name: "HP", Value: 12500, Max: 12500}}
tt := view(500, 300, newWorld(), func(c *ui.Context) {
account.ProfileCard(c, account.Profile{Name: "A", Level: 1, Stats: stats}, account.ProfileCardOptions{})
})
// Both bars start after AGILITY and end before 12500/12500.
nw, vw := pixel.MicroWidth("AGILITY"), pixel.MicroWidth("12500/12500")
for _, s := range stats {
x, y, w, _ := dots(find(t, tt, s.Name))
inner := w - nw - vw - 12
img := tt.Image()
if n := count(img, x, y+4, w, 1, pixel.Good); n != inner*s.Value/s.Max || at(img, x+nw+6, y+4) != pixel.Good {
t.Errorf("%s fills %d dots, want %d from dot %d", s.Name, n, inner*s.Value/s.Max, nw+6)
}
}
// Frames under 3×3 panic, so a card too narrow fails.
mustPanic(t, "no room for a bar", func() {
view(500, 300, newWorld(), func(c *ui.Context) {
account.ProfileCard(c, account.Profile{Name: "A", Level: 1, Stats: stats}, account.ProfileCardOptions{Width: 60})
})
})
}
+138
View File
@@ -0,0 +1,138 @@
package account
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Binding is an action and the key that does it.
type Binding struct {
Action string
Key ui.Key
}
// caps hold every key but Escape, which cancels.
var caps = map[ui.Key]string{
ui.KeyA: "A", ui.KeyB: "B", ui.KeyC: "C", ui.KeyD: "D", ui.KeyE: "E", ui.KeyF: "F", ui.KeyG: "G", ui.KeyH: "H", ui.KeyI: "I",
ui.KeyJ: "J", ui.KeyK: "K", ui.KeyL: "L", ui.KeyM: "M", ui.KeyN: "N", ui.KeyO: "O", ui.KeyP: "P", ui.KeyQ: "Q", ui.KeyR: "R",
ui.KeyS: "S", ui.KeyT: "T", ui.KeyU: "U", ui.KeyV: "V", ui.KeyW: "W", ui.KeyX: "X", ui.KeyY: "Y", ui.KeyZ: "Z",
ui.Key0: "0", ui.Key1: "1", ui.Key2: "2", ui.Key3: "3", ui.Key4: "4", ui.Key5: "5", ui.Key6: "6", ui.Key7: "7", ui.Key8: "8", ui.Key9: "9",
ui.KeyUp: "↑", ui.KeyDown: "↓", ui.KeyLeft: "←", ui.KeyRight: "→",
ui.KeySpace: "SPACE", ui.KeyEnter: "ENTER", ui.KeyTab: "TAB", ui.KeyBackspace: "BKSP",
ui.KeyDelete: "DEL", ui.KeyInsert: "INS", ui.KeyHome: "HOME", ui.KeyEnd: "END", ui.KeyPageUp: "PGUP", ui.KeyPageDown: "PGDN",
ui.KeyF1: "F1", ui.KeyF2: "F2", ui.KeyF3: "F3", ui.KeyF4: "F4", ui.KeyF5: "F5", ui.KeyF6: "F6",
ui.KeyF7: "F7", ui.KeyF8: "F8", ui.KeyF9: "F9", ui.KeyF10: "F10", ui.KeyF11: "F11", ui.KeyF12: "F12",
ui.KeyMinus: "-", ui.KeyEqual: "=", ui.KeyComma: ",", ui.KeyPeriod: ".", ui.KeySlash: "/", ui.KeySemicolon: ";",
ui.KeyQuote: "'", ui.KeyBracketLeft: "[", ui.KeyBracketRight: "]", ui.KeyBackslash: "\\", ui.KeyBackquote: "`",
ui.KeyContextMenu: "MENU", ui.KeyBack: "BACK", ui.KeyForward: "FWD",
}
type KeyBindingsOptions struct {
// Width is in dots; zero means 200.
Width int
Disabled bool
}
type KeyBindingsResult struct {
Element *ui.Element
Changed bool
}
type bindKey struct{}
// capture is the row in hand and whether it waits.
type capture struct {
cur int
waiting bool
}
// KeyBindings lists actions with keys; Enter, then a key, rebinds.
func KeyBindings(c *ui.Context, text string, binds []Binding, o KeyBindingsOptions) KeyBindingsResult {
k := pixel.K(c)
used := map[ui.Key]int{}
for _, b := range binds {
if _, ok := caps[b.Key]; !ok {
panic(fmt.Sprintf("account: KeyBindings %q has a key without a cap", b.Action))
}
used[b.Key]++
}
if len(binds) == 0 {
panic("account: KeyBindings has no bindings")
}
w, rh := pixel.Or("KeyBindings Width", o.Width, 200), pixel.LineHeight+4
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleList).Label(text).Disabled(o.Disabled)
})
kb := ui.Local(base, bindKey{}, func() capture { return capture{} })
kb.cur = min(kb.cur, len(binds)-1)
res := KeyBindingsResult{Element: shell}
switch {
case base.IsDisabled():
kb.waiting = false
case kb.waiting:
// Waiting, every key binds, arrows too; Escape gives up.
if base.Shortcut(0, ui.KeyEscape) {
kb.waiting = false
}
for key := range caps {
if base.Shortcut(0, key) && kb.waiting {
binds[kb.cur].Key, kb.waiting, res.Changed = key, false, true
slog.Debug("8bit: key bindings", "label", text, "action", binds[kb.cur].Action, "key", caps[key])
}
}
default:
if base.Shortcut(0, ui.KeyUp) {
kb.cur = max(0, kb.cur-1)
}
if base.Shortcut(0, ui.KeyDown) {
kb.cur = min(len(binds)-1, kb.cur+1)
}
if base.Shortcut(0, ui.KeyEnter) || base.Shortcut(0, ui.KeySpace) {
kb.waiting = true
}
if base.Clicked() {
if _, y, _ := base.PointerPosition(); int(y/k.Dot())/rh < len(binds) {
kb.cur, kb.waiting = int(y/k.Dot())/rh, true
}
}
}
b := binds[kb.cur]
say := b.Action + ", " + caps[b.Key]
switch {
case kb.waiting:
say = b.Action + ", " + k.Msg("press_key")
case used[b.Key] > 1:
say += ", " + k.Msg("conflict")
}
base.Description(say)
cur, waiting := kb.cur, kb.waiting
shell.Size(k.D(w), k.D(len(binds)*rh)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i, b := range binds {
y := i * rh
if i == cur && st.Focus {
g.Cursor(0, y, w, rh)
}
g.Text(4, y+2, b.Action, pixel.Ink, 1)
cap, ink, edge := caps[b.Key], pixel.Ink, pixel.Edge
switch {
case i == cur && waiting:
cap, ink = k.Msg("press_key"), pixel.Accent
case used[b.Key] > 1:
ink, edge = pixel.Bad, pixel.Bad
}
cw := pixel.Measure(cap) + 8
g.Frame(w-cw-2, y+1, cw, rh-2, pixel.Raised, pixel.Face)
if edge == pixel.Bad {
g.Rect(w-cw-2, y+rh-2, cw, 1, pixel.Bad)
}
g.Text(w-cw+2, y+2, cap, ink, 1)
}
g.Finish(st)
})
return res
}
+46
View File
@@ -0,0 +1,46 @@
package account
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Language is a tongue by its own name, flagged.
type Language struct {
Name string
Flag *pixel.Sprite
}
type LanguagePickerOptions struct {
// Width and Height are dots; zeros mean 160 by 120.
Width, Height int
Disabled bool
}
type LanguagePickerResult = data.ListResult
// LanguagePicker lists languages with flags; ▶ marks the chosen one.
func LanguagePicker(c *ui.Context, text string, langs []Language, chosen *int, o LanguagePickerOptions) LanguagePickerResult {
k := pixel.K(c)
choice("LanguagePicker", chosen, len(langs))
var res LanguagePickerResult
ui.Box(c).Width(k.D(pixel.Or("LanguagePicker Width", o.Width, 160))).Children(func() {
res = data.List(c, text, len(langs), chosen, data.ListOptions{Height: o.Height, Disabled: o.Disabled}, func(i int) string { return langs[i].Name }, func(i int) {
l := langs[i]
ui.Row(c).Gap(k.D(4)).AlignItems(ui.Center).Children(func() {
if l.Flag != nil {
ui.Box(c).Size(k.D(l.Flag.W+2), k.D(l.Flag.H+2)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Rect(0, 0, g.W, g.H, pixel.Edge)
g.Sprite(1, 1, l.Flag, 0, pixel.Ink, 1)
})
}
// The row is named already; its text only shows.
mark(c, k, l.Name, pixel.Ink, 0)
})
})
})
return res
}
+80
View File
@@ -0,0 +1,80 @@
package account
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
type LoginFormOptions struct {
// Failures counts wrong tries; each new one shakes the form.
Failures int
Disabled bool
}
type LoginFormResult struct {
Element *ui.Element
Submitted bool
}
type loginKey struct{}
// login is the failures seen and whether the warning shows.
type login struct {
seen int
failed bool
}
// shakes are the steps of a head shake, in dots.
var shakes = []int{-4, 4, -3, 3, -2, 2, -1, 1}
// LoginForm asks a name and password; the caller checks them.
func LoginForm(c *ui.Context, title string, name, password *string, o LoginFormOptions) LoginFormResult {
k := pixel.K(c)
if o.Failures < 0 {
panic(fmt.Sprintf("account: LoginForm %q has %d failures", title, o.Failures))
}
box := ui.Box(c)
lg := ui.Local(box, loginKey{}, func() login { return login{seen: o.Failures} })
fresh := o.Failures > lg.seen
if fresh {
// A wrong try clears the password, naming no field.
lg.seen, lg.failed, *password = o.Failures, true, ""
}
dx := 0
if n := k.Shot(c, box, o.Failures, 12, len(shakes)); n < len(shakes) {
dx = shakes[n]
}
var res LoginFormResult
box.Padding(0, k.D(4-dx), 0, k.D(4+dx)).Children(func() {
layout.Panel(c, layout.PanelOptions{Style: pixel.Raised, Padding: 6}, func() {
text.ShadowText(c, title, text.ShadowTextOptions{})
nm := input.TextInput(c, k.Msg("name"), name, input.TextInputOptions{Placeholder: k.Msg("name"), Width: 140, Disabled: o.Disabled})
pw := input.PasswordInput(c, k.Msg("password"), password, input.PasswordInputOptions{Placeholder: k.Msg("password"), Width: 140, Disabled: o.Disabled})
if fresh && !pw.Input.IsDisabled() {
pw.Input.Focus()
}
if nm.Changed || pw.Changed {
lg.failed = false
}
if lg.failed {
pixel.Text(c, k.Msg("login_failed"), pixel.TextOptions{Slot: pixel.Bad})
}
ready := *name != "" && *password != ""
clicked := input.Button(c, k.Msg("login"), input.ButtonOptions{Variant: input.Primary, Disabled: o.Disabled || !ready}).Clicked
res.Submitted = ready && (clicked || pw.Submitted && !pw.Input.IsDisabled())
}).Gap(k.D(5)).Role(ui.RoleGroup).Label(title)
})
res.Element = box
// Never the name or password: only that someone tried.
if res.Submitted {
slog.Debug("8bit: login form submitted", "label", title)
}
return res
}
+70
View File
@@ -0,0 +1,70 @@
package account
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type OptionCyclerOptions struct {
// Width is the value's room; zero fits the longest.
Width int
Disabled bool
}
type OptionCyclerResult struct {
Element *ui.Element
Changed bool
}
// OptionCycler steps ◀ VALUE ▶ through options, wrapping around.
func OptionCycler(c *ui.Context, text string, options []string, selected *int, o OptionCyclerOptions) OptionCyclerResult {
k := pixel.K(c)
choice("OptionCycler", selected, len(options))
longest := 0
for _, s := range options {
longest = max(longest, pixel.Measure(s))
}
w := pixel.Or("OptionCycler Width", o.Width, longest)
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleStepper).Label(text).Disabled(o.Disabled)
})
n, old := len(options), *selected
if !base.IsDisabled() {
step := 0
if base.Shortcut(0, ui.KeyLeft) {
step--
}
// Enter and Space step on; taken, clicks come from pointers.
right, enter, space := base.Shortcut(0, ui.KeyRight), base.Shortcut(0, ui.KeyEnter), base.Shortcut(0, ui.KeySpace)
if right || enter || space {
step++
}
// A click on the left half steps back, else on.
if base.Clicked() {
if x, _, _ := base.PointerPosition(); x < shell.Bounds().W/2 {
step--
} else {
step++
}
}
*selected = ((*selected+step)%n + n) % n
}
base.Description(options[*selected])
value := options[*selected]
total := w + 32
shell.Size(k.D(total), k.D(pixel.LineHeight+4)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Text(2, 2, "◀", pixel.Accent, 1)
g.Text(16+(w-pixel.Measure(value))/2, 2, value, pixel.Ink, 1)
g.Text(total-2-pixel.Measure("▶"), 2, "▶", pixel.Accent, 1)
g.Finish(st)
})
res := OptionCyclerResult{Element: shell, Changed: *selected != old}
if res.Changed {
slog.Debug("8bit: option cycler", "label", text, "selected", *selected)
}
return res
}
+77
View File
@@ -0,0 +1,77 @@
package account
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type PalettePickerOptions struct {
Disabled bool
}
type PalettePickerResult struct {
Element *ui.Element
Changed bool
}
const tileW, tileH = 56, 42
// PalettePicker shows each palette as a live scene; choosing applies.
func PalettePicker(c *ui.Context, text string, chosen *int, o PalettePickerOptions) PalettePickerResult {
k := pixel.K(c)
choice("PalettePicker", chosen, len(pixel.Palettes))
// Each tile draws with a kit in its own palette.
kits := make([]pixel.Kit, len(pixel.Palettes))
for i, pal := range pixel.Palettes {
in := pixel.Current(c)
in.Palette = pal
pixel.Scope(c, in, func() { kits[i] = pixel.K(c) })
}
old := *chosen
// Arrows choose at once; the cursor is the choice.
shell, base, st, _ := pixel.Pad(c, text, 4, len(pixel.Palettes), tileW, tileH, chosen, o.Disabled)
pal := pixel.Palettes[*chosen]
name := pal.Name
if k.Locale() == pixel.Zh {
name = pal.Zh
}
base.Description(name + ", " + k.Msg("in_use"))
on := *chosen
shell.Draw(func(p *ui.Painter, r ui.Rect) {
for i, tk := range kits {
tile(tk.Canvas(p, r), i%4*tileW+2, i/4*tileH+2, pixel.Palettes[i].Night)
}
g := k.Canvas(p, r)
x, y := on%4*tileW, on/4*tileH
for _, d := range []int{0, 1} {
g.Rect(x+d, y+d, tileW-2*d, 1, pixel.Accent)
g.Rect(x+d, y+tileH-1-d, tileW-2*d, 1, pixel.Accent)
g.Rect(x+d, y+d, 1, tileH-2*d, pixel.Accent)
g.Rect(x+tileW-1-d, y+d, 1, tileH-2*d, pixel.Accent)
}
g.Finish(st)
})
res := PalettePickerResult{Element: shell, Changed: *chosen != old}
if res.Changed {
slog.Debug("8bit: palette picker", "label", text, "palette", pixel.Palettes[*chosen].Name)
}
return res
}
// tile is a tiny scene in the canvas's palette.
func tile(g pixel.Canvas, x, y int, night bool) {
g.Frame(x, y, 52, 38, pixel.Window, pixel.Back)
g.Rect(x+3, y+3, 46, 7, pixel.Accent)
g.Micro(x+5, y+4, "8BIT", pixel.AccentInk, 1)
if night {
g.Disc(x+40, y+17, 3, pixel.Lit)
} else {
g.Disc(x+40, y+17, 3, pixel.Warn)
}
g.Dither(x+3, y+26, 46, 9, pixel.Good, pixel.Shade, 4)
g.Rect(x+8, y+18, 10, 8, pixel.Face)
g.Rect(x+10, y+21, 3, 5, pixel.Edge)
}
+92
View File
@@ -0,0 +1,92 @@
package account
import (
"fmt"
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// symbols are what an old cartridge password is spelled with.
const symbols = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!?-."
type PasswordGridOptions struct {
// Length is the password's symbols, 4–24; zero means 12.
Length int
Disabled bool
}
type PasswordGridResult struct {
Element *ui.Element
// Submitted is END on a full password.
Changed, Submitted bool
}
type gridKey struct{}
// PasswordGrid spells *password by picking symbols from a grid.
func PasswordGrid(c *ui.Context, text string, password *string, o PasswordGridOptions) PasswordGridResult {
k := pixel.K(c)
n := pixel.Or("PasswordGrid Length", o.Length, 12)
if n < 4 || n > 24 || len(*password) > n || strings.Trim(*password, symbols) != "" {
panic(fmt.Sprintf("account: PasswordGrid of %d symbols holds %d bad or extra", n, len(*password)))
}
old := *password
var res PasswordGridResult
res.Element = ui.Column(c).Gap(k.D(5)).Role(ui.RoleGroup).Label(text)
cur := ui.Local(res.Element, gridKey{}, func() int { return 0 })
res.Element.Children(func() {
typed := *password
ui.Box(c).Size(k.D(n*9+(n-1)/4*4), k.D(13)).Role(ui.RoleText).Label(fmt.Sprintf("%s %d/%d", text, len(typed), n)).Description(typed).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
// Blanks in fours, as the old screens printed them.
for i := range n {
x, line := i*9+i/4*4, pixel.Faint
if i < len(typed) {
g.Micro(x+2, 4, typed[i:i+1], pixel.Ink, 1)
}
if i == len(typed) {
line = pixel.Accent
}
g.HLine(x+1, 11, 6, line)
}
})
shell, base, st, picked := pixel.Pad(c, k.Msg("symbols"), 8, len(symbols), 11, 11, cur, o.Disabled)
if picked >= 0 && len(*password) < n {
*password += symbols[picked : picked+1]
}
if !base.IsDisabled() && base.Shortcut(0, ui.KeyBackspace) && *password != "" {
*password = (*password)[:len(*password)-1]
}
base.Description(symbols[*cur : *cur+1])
at := *cur
shell.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i := range len(symbols) {
x, y, ink := i%8*11, i/8*11, pixel.Ink
if i == at {
g.Rect(x, y, 11, 11, pixel.Accent)
ink = pixel.AccentInk
}
g.Micro(x+4, y+3, symbols[i:i+1], ink, 1)
}
g.Finish(st)
})
ui.Row(c).Gap(k.D(4)).Children(func() {
if input.Button(c, k.Msg("back"), input.ButtonOptions{Size: input.Small, Disabled: o.Disabled || *password == ""}).Clicked {
*password = (*password)[:len(*password)-1]
}
res.Submitted = input.Button(c, k.Msg("end"), input.ButtonOptions{Size: input.Small, Variant: input.Primary, Disabled: o.Disabled || len(*password) < n}).Clicked
})
})
res.Changed = *password != old
// Lengths only: the password stays off the log.
if res.Changed || res.Submitted {
slog.Debug("8bit: password grid", "label", text, "length", len(*password), "submitted", res.Submitted)
}
return res
}
+55
View File
@@ -0,0 +1,55 @@
package account
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
// Patch is a version's changes, by kind.
type Patch struct {
Version string
Added, Fixed, Changed []string
}
type PatchNotesOptions struct {
// Width is in dots; zero means 240.
Width int
}
// PatchNotes lists versions, each line tagged ADDED, FIXED or CHANGED.
func PatchNotes(c *ui.Context, title string, patches []Patch, o PatchNotesOptions) *ui.Element {
k := pixel.K(c)
w := pixel.Or("PatchNotes Width", o.Width, 240)
// Tags draw in the main font, so any locale reads.
tw := 0
for _, key := range []string{"added", "fixed", "changed"} {
tw = max(tw, pixel.Measure(k.Msg(key))+6)
}
return ui.Column(c).Width(k.D(w)).Gap(k.D(3)).Role(ui.RoleGroup).Label(title).Children(func() {
for i, pt := range patches {
if i > 0 {
ui.Box(c).Height(k.D(4))
}
text.ShadowText(c, pt.Version, text.ShadowTextOptions{Slot: pixel.Accent})
for _, kind := range []struct {
key string
slot pixel.Slot
lines []string
}{{"added", pixel.Good, pt.Added}, {"fixed", pixel.Info, pt.Fixed}, {"changed", pixel.Warn, pt.Changed}} {
tag := k.Msg(kind.key)
for _, l := range kind.lines {
ui.Row(c).FillWidth().Gap(k.D(4)).Role(ui.RoleText).Label(tag + ": " + l).Children(func() {
ui.Box(c).Size(k.D(tw), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Rect(0, 0, pixel.Measure(tag)+6, pixel.LineHeight, kind.slot)
g.Text(3, 0, tag, pixel.AccentInk, 1)
})
pixel.Text(c, l, pixel.TextOptions{Wrap: true}).Grow(1)
})
}
}
}
})
}
+162
View File
@@ -0,0 +1,162 @@
package account_test
import (
"testing"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/account"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestLanguagePickerChooses(t *testing.T) {
chosen := 0
var got account.LanguagePickerResult
tt := view(400, 300, newWorld(), func(c *ui.Context) {
if r := account.LanguagePicker(c, "Language", []account.Language{{Name: "English"}, {Name: "Deutsch"}}, &chosen, account.LanguagePickerOptions{}); r.Changed {
got = r
}
})
click(t, tt, "Deutsch")
if chosen != 1 || !got.Changed {
t.Fatalf("clicking Deutsch chose %d", chosen)
}
}
func TestPalettePickerTilesWearTheirPalettes(t *testing.T) {
chosen, changed := 0, false
tt := view(600, 300, newWorld(), func(c *ui.Context) {
changed = account.PalettePicker(c, "Palettes", &chosen, account.PalettePickerOptions{}).Changed || changed
})
r := find(t, tt, "Palettes")
// Tile 3's title bar is Summer Night's own Accent.
want := pixel.SummerNight.Colors[pixel.Accent]
if c := tt.Image().RGBAAt(int(r.X)+2*(3*56+12), int(r.Y)+2*7); c.R != want.R || c.G != want.G || c.B != want.B {
t.Fatalf("Summer Night's tile title is %v, want %v", c, want)
}
tt.ClickAt(r.X+2*(3*56+28), r.Y+2*20)
x, y, _, _ := dots(r)
// The chosen tile wears the app's Accent ring.
if chosen != 3 || !changed || at(tt.Image(), x+3*56+28, y) != pixel.Accent {
t.Fatalf("clicking Summer Night chose %d", chosen)
}
}
func TestVolumeMixerFaders(t *testing.T) {
mix, changed := account.Mix{BGM: 0.5, SE: 0.5, Voice: 0.5}, false
tt := view(400, 300, newWorld(), func(c *ui.Context) {
changed = account.VolumeMixer(c, "Mix", &mix, account.VolumeMixerOptions{}).Changed || changed
})
r := find(t, tt, "SE")
tt.Press(r.X+r.W/2, r.Y+2)
tt.Release(r.X+r.W/2, r.Y+2)
if mix.SE < 0.9 || mix.BGM != 0.5 || mix.Voice != 0.5 || !changed {
t.Fatalf("pressing the top of SE gave %+v", mix)
}
mustPanic(t, "a level past 1", func() {
view(400, 300, newWorld(), func(c *ui.Context) {
m := account.Mix{BGM: 2}
account.VolumeMixer(c, "M", &m, account.VolumeMixerOptions{})
})
})
}
func TestDifficultySelectMovesThePointer(t *testing.T) {
chosen, changed := 1, false
tt := view(400, 300, newWorld(), func(c *ui.Context) {
changed = account.DifficultySelect(c, "Difficulty", &chosen, account.DifficultySelectOptions{}).Changed || changed
})
click(t, tt, "Difficulty")
tt.Key(0, ui.KeyEnd)
x, y, _, _ := dots(find(t, tt, "Difficulty"))
img := tt.Image()
// The pointer is on NIGHTMARE, which is Bad.
if chosen != 3 || !changed || count(img, x, y+3*16, 12, 16, pixel.Accent) == 0 || count(img, x+14, y+3*16, 60, 16, pixel.Bad) == 0 {
t.Fatalf("End chose %d", chosen)
}
}
func TestAccessibilityOptionsSetBoth(t *testing.T) {
a, changed := account.Access{Dot: 2}, false
tt := view(500, 300, newWorld(), func(c *ui.Context) {
changed = account.AccessibilityOptions(c, "Access", &a, account.AccessibilityOptionsOptions{}).Changed || changed
})
click(t, tt, "REDUCE MOTION")
click(t, tt, "3")
if !a.Reduce || a.Dot != 3 || !changed {
t.Fatalf("after the switch and 3: %+v", a)
}
mustPanic(t, "a dot of 5", func() {
view(500, 300, newWorld(), func(c *ui.Context) {
b := account.Access{Dot: 5}
account.AccessibilityOptions(c, "A", &b, account.AccessibilityOptionsOptions{})
})
})
}
func TestAccountSwitcherChoosesSlots(t *testing.T) {
cur, changed := 0, false
tt := view(600, 300, newWorld(), func(c *ui.Context) {
changed = account.AccountSwitcher(c, "Players", [4]string{"Aria", "", "", ""}, &cur, account.AccountSwitcherOptions{}).Changed || changed
})
find(t, tt, "P1, Aria, IN USE")
click(t, tt, "P3, OPEN")
if cur != 2 || !changed {
t.Fatalf("P3 chose %d", cur)
}
// P3 is green; chosen, its frame is too.
x, y, _, _ := dots(find(t, tt, "P3, OPEN, IN USE"))
if at(tt.Image(), x+30, y) != pixel.Good {
t.Fatal("the chosen card does not wear its player color")
}
// Aria's eyes sit 16 dots into her 32-dot face.
x, y, _, _ = dots(find(t, tt, "P1, Aria"))
if count(tt.Image(), x+16, y+30, 32, 4, pixel.Ink) == 0 {
t.Fatal("Aria's face has no eyes")
}
}
func TestPlayerSelectChooses(t *testing.T) {
players := 1
var got account.PlayerSelectResult
tt := view(400, 300, newWorld(), func(c *ui.Context) {
if r := account.PlayerSelect(c, "Players", &players, account.PlayerSelectOptions{}); r.Changed || r.Chosen {
got = r
}
})
click(t, tt, "Players")
tt.Key(0, ui.KeyDown)
if players != 2 || !got.Changed {
t.Fatalf("Down gave %d players", players)
}
got = account.PlayerSelectResult{}
tt.Key(0, ui.KeyEnter)
if !got.Chosen || players != 2 {
t.Fatalf("Enter: %+v", got)
}
mustPanic(t, "three players", func() {
view(400, 300, newWorld(), func(c *ui.Context) {
p := 3
account.PlayerSelect(c, "P", &p, account.PlayerSelectOptions{})
})
})
}
func TestAboutAndPatchNotesRead(t *testing.T) {
tt := view(500, 400, newWorld(), func(c *ui.Context) {
ui.Row(c).Children(func() {
account.About(c, "GAME", account.AboutOptions{Version: "1.2", Credits: []account.Credit{{Role: "Art", Name: "Ann"}}})
account.PatchNotes(c, "Notes", []account.Patch{{Version: "v2", Added: []string{"Boats"}, Fixed: []string{"Rain"}}}, account.PatchNotesOptions{Width: 120})
})
})
for _, l := range []string{"VERSION 1.2", "CREDITS", "Art", "Ann", "ADDED: Boats", "FIXED: Rain"} {
find(t, tt, l)
}
x, y, _, _ := dots(find(t, tt, "ADDED: Boats"))
if count(tt.Image(), x, y, 30, 12, pixel.Good) == 0 {
t.Fatal("the ADDED tag is not Good")
}
mustPanic(t, "no version", func() {
view(500, 400, newWorld(), func(c *ui.Context) { account.About(c, "G", account.AboutOptions{}) })
})
}
+56
View File
@@ -0,0 +1,56 @@
package account
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type PlayerSelectOptions struct {
Disabled bool
}
type PlayerSelectResult struct {
Element *ui.Element
// Chosen is Enter, Space or a click on a row.
Changed, Chosen bool
}
// PlayerSelect points ▶ at 1 or 2 players in *players.
func PlayerSelect(c *ui.Context, text string, players *int, o PlayerSelectOptions) PlayerSelectResult {
k := pixel.K(c)
if *players != 1 && *players != 2 {
panic(fmt.Sprintf("account: PlayerSelect of %d players", *players))
}
rows := []string{k.Msg("one_player"), k.Msg("two_players")}
// Two-scale text after a two-scale pointer and a gap.
gap := pixel.Measure("▶")*2 + 6
w := 0
for _, r := range rows {
w = max(w, pixel.Measure(r)*2+gap)
}
cur, old := *players-1, *players
shell, base, st, picked := pixel.Pad(c, text, 1, 2, w, 2*pixel.LineHeight+4, &cur, o.Disabled)
*players = cur + 1
base.Description(rows[cur])
res := PlayerSelectResult{Element: shell, Changed: *players != old, Chosen: picked >= 0}
at := cur
shell.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i, s := range rows {
y := i * (2*pixel.LineHeight + 4)
if i == at {
g.Text(0, y+2, "▶", pixel.Accent, 2)
}
g.Text(gap, y+2, s, pixel.Ink, 2)
}
g.Finish(st)
})
if res.Changed || res.Chosen {
slog.Debug("8bit: player select", "label", text, "players", *players, "chosen", res.Chosen)
}
return res
}
+146
View File
@@ -0,0 +1,146 @@
package account_test
import (
"testing"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/account"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestSettingsListSetsRowsUnderTitles(t *testing.T) {
tt := view(500, 300, newWorld(), func(c *ui.Context) {
account.SettingsList(c, "Settings", []account.SettingsSection{
{Title: "AUDIO", Settings: []account.Setting{{Label: "Music", Control: func() {
ui.ButtonBase(c).Label("toggle").Size(20, 20)
}}}},
}, account.SettingsListOptions{})
})
// The row label only shows; the control carries the name.
title, ctl := find(t, tt, "AUDIO"), find(t, tt, "toggle")
if _, named := tt.Find("Music"); named || !(title.Y+title.H <= ctl.Y && ctl.X > 100) {
t.Fatalf("title %v, control %v, label named %v", title, ctl, named)
}
}
func TestOptionCyclerWrapsBothWays(t *testing.T) {
sel, changed := 0, false
opts := []string{"SLOW", "NORMAL", "FAST"}
tt := view(400, 200, newWorld(), func(c *ui.Context) {
changed = account.OptionCycler(c, "Speed", opts, &sel, account.OptionCyclerOptions{}).Changed || changed
})
r := find(t, tt, "Speed")
tt.ClickAt(r.X+4, r.Y+r.H/2)
if sel != 2 || !changed {
t.Fatalf("a click on the left half from 0 gave %d", sel)
}
tt.Key(0, ui.KeyRight)
if sel != 0 {
t.Fatalf("Right from the last gave %d", sel)
}
tt.ClickAt(r.X+r.W-4, r.Y+r.H/2)
tt.Key(0, ui.KeyLeft)
if sel != 0 {
t.Fatalf("right half then Left gave %d", sel)
}
mustPanic(t, "a choice past the options", func() {
view(400, 200, newWorld(), func(c *ui.Context) {
s := 3
account.OptionCycler(c, "S", opts, &s, account.OptionCyclerOptions{})
})
})
}
func TestKeyBindingsRebindAndMarkConflicts(t *testing.T) {
binds := []account.Binding{{Action: "Jump", Key: ui.KeySpace}, {Action: "Attack", Key: ui.KeyZ}, {Action: "Dash", Key: ui.KeyX}}
changed := false
tt := view(500, 300, newWorld(), func(c *ui.Context) {
changed = account.KeyBindings(c, "Keys", binds, account.KeyBindingsOptions{}).Changed || changed
})
r := find(t, tt, "Keys")
x, y, w, _ := dots(r)
rh := pixel.LineHeight + 4
bad := func(row int) int { return count(tt.Image(), x+w-40, y+row*rh, 40, rh, pixel.Bad) }
if bad(1) != 0 || bad(2) != 0 {
t.Fatal("no conflict yet, but a key is Bad")
}
// Click Dash, press Z: Z is Attack's too.
tt.ClickAt(r.X+20, r.Y+float32(2*2*rh+rh))
tt.Key(0, ui.KeyZ)
if binds[2].Key != ui.KeyZ || !changed || bad(1) == 0 || bad(2) == 0 {
t.Fatalf("Dash on Z: %v, conflict marks %d %d", binds[2].Key, bad(1), bad(2))
}
// Waiting, an arrow binds; Escape gives up.
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyLeft)
if binds[1].Key != ui.KeyLeft {
t.Fatalf("Up, Enter, Left bound %v", binds[1].Key)
}
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeyEscape)
tt.Key(0, ui.KeyQ)
if binds[1].Key != ui.KeyLeft {
t.Fatal("Escape did not give up the rebind")
}
mustPanic(t, "a key without a cap", func() {
view(500, 300, newWorld(), func(c *ui.Context) {
account.KeyBindings(c, "K", []account.Binding{{Action: "Help", Key: ui.KeyEscape}}, account.KeyBindingsOptions{})
})
})
}
func TestControllerMapPaintsButtonsAndLabels(t *testing.T) {
tt := view(700, 300, newWorld(), func(c *ui.Context) {
account.ControllerMap(c, "Pad", map[account.PadButton]string{account.ButtonA: "JUMP"}, account.ControllerMapOptions{})
})
x, y, _, _ := dots(find(t, tt, "Pad"))
img := tt.Image()
if at(img, x+70+130, y+45) != pixel.Bad || at(img, x+70+120, y+55) != pixel.Warn || count(img, x+236, y+46, 40, 12, pixel.Ink) == 0 {
t.Fatal("A is not red, B not gold, or JUMP is missing")
}
mustPanic(t, "an unknown button", func() {
view(700, 300, newWorld(), func(c *ui.Context) {
account.ControllerMap(c, "P", map[account.PadButton]string{99: "?"}, account.ControllerMapOptions{})
})
})
}
func TestProfileCardShowsStats(t *testing.T) {
tt := view(500, 300, newWorld(), func(c *ui.Context) {
account.ProfileCard(c, account.Profile{Name: "Aria", Title: "Slayer", Level: 12, Stats: []account.Stat{{Name: "HP", Value: 10, Max: 40}}}, account.ProfileCardOptions{})
})
find(t, tt, "Slayer")
find(t, tt, "LV 12")
x, y, w, _ := dots(find(t, tt, "HP"))
// The bar sits between the name and value columns.
nw, vw := pixel.MicroWidth("HP"), pixel.MicroWidth("10/40")
inner := w - nw - vw - 12
if n := count(tt.Image(), x+nw+6, y+4, inner, 1, pixel.Good); n < inner/4-1 || n > inner/4+1 {
t.Fatalf("HP 10/40 fills %d of %d dots", n, inner)
}
mustPanic(t, "a stat past its most", func() {
view(500, 300, newWorld(), func(c *ui.Context) {
account.ProfileCard(c, account.Profile{Name: "A", Level: 1, Stats: []account.Stat{{Name: "HP", Value: 5, Max: 4}}}, account.ProfileCardOptions{})
})
})
}
func TestCharacterCreatorCyclesTheAvatar(t *testing.T) {
look, changed := account.Look{}, false
tt := view(600, 300, newWorld(), func(c *ui.Context) {
changed = account.CharacterCreator(c, "Hero", &look, account.CharacterCreatorOptions{}).Changed || changed
})
find(t, tt, "SPIKY, DOTS, TUNIC, DARK, DARK")
r := find(t, tt, "HAIR COLOR")
tt.ClickAt(r.X+r.W-4, r.Y+r.H/2)
tt.ClickAt(r.X+r.W-4, r.Y+r.H/2)
img := tt.Image()
pic := find(t, tt, "SPIKY, DOTS, TUNIC, RED, DARK")
x, y, _, _ := dots(pic)
// Spiky hair at row 2, column 5, scale 3.
if look.HairColor != 2 || !changed || at(img, x+4+3*5, y+4+3*2) != pixel.Bad {
t.Fatalf("hair color %d, hair dot %v", look.HairColor, at(tt.Image(), x+4+3*5, y+4+3*2))
}
}
+71
View File
@@ -0,0 +1,71 @@
package account
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"
)
// Stat is a number out of its most, as HP.
type Stat struct {
Name string
Value, Max int
}
// Profile is a player as their card shows them.
type Profile struct {
Name, Title string
Level int
Stats []Stat
}
type ProfileCardOptions struct {
// Width is in dots; zero means 180.
Width int
}
// ProfileCard shows a player: face, name, level, title and stats.
func ProfileCard(c *ui.Context, p Profile, o ProfileCardOptions) *ui.Element {
k := pixel.K(c)
if p.Level < 1 {
panic(fmt.Sprintf("account: ProfileCard level %d", p.Level))
}
w := pixel.Or("ProfileCard Width", o.Width, 180)
// Columns fit the widest name and value, so bars align.
nw, vw := 0, 0
for _, s := range p.Stats {
if s.Max < 1 || s.Value < 0 || s.Value > s.Max {
panic(fmt.Sprintf("account: ProfileCard stat %+v", s))
}
nw, vw = max(nw, pixel.MicroWidth(s.Name)), max(vw, pixel.MicroWidth(fmt.Sprintf("%d/%d", s.Value, s.Max)))
}
bx, bw := nw+4, w-12-nw-vw-8
return layout.Panel(c, layout.PanelOptions{Style: pixel.Raised, Padding: 6}, func() {
ui.Row(c).Gap(k.D(6)).AlignItems(ui.Center).Children(func() {
display.Avatar(c, p.Name, display.AvatarOptions{Scale: 2})
ui.Column(c).Gap(k.D(2)).Children(func() {
text.ShadowText(c, p.Name, text.ShadowTextOptions{})
caption(c, k, fmt.Sprintf("LV %d", p.Level), pixel.Accent)
pixel.Text(c, p.Title, pixel.TextOptions{Slot: pixel.Faint})
})
})
for _, s := range p.Stats {
v := fmt.Sprintf("%d/%d", s.Value, s.Max)
ui.Box(c).Size(k.D(w-12), k.D(9)).Role(ui.RoleMeter).Label(s.Name).Description(v).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Micro(0, 2, s.Name, pixel.Faint, 1)
g.Frame(bx, 1, bw, 7, pixel.Sunken, pixel.Well)
// Floats, so huge stats cannot overflow.
if n := int(float64(bw-4) * float64(s.Value) / float64(s.Max)); n > 0 {
g.Rect(bx+2, 3, n, 3, pixel.Good)
}
g.Micro(g.W-pixel.MicroWidth(v), 2, v, pixel.Ink, 1)
})
}
}).Width(k.D(w)).Gap(k.D(4)).Role(ui.RoleGroup).Label(p.Name)
}
+65
View File
@@ -0,0 +1,65 @@
package account
import (
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/datetime"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Session is a signed-in device and when it was seen.
type Session struct {
Device string
Icon *pixel.Sprite
Seen time.Time
Current bool
}
type SessionListOptions struct {
// Width is in dots; zero means 240.
Width int
Disabled bool
}
type SessionListResult struct {
Element *ui.Element
// SignedOut is the session signed out this frame, else -1.
SignedOut int
}
// SessionList lists signed-in devices, each with SIGN OUT.
func SessionList(c *ui.Context, text string, sessions []Session, o SessionListOptions) SessionListResult {
k := pixel.K(c)
res := SessionListResult{SignedOut: -1}
res.Element = ui.Column(c).Width(k.D(pixel.Or("SessionList Width", o.Width, 240))).Gap(k.D(4)).Role(ui.RoleList).Label(text).Children(func() {
for i, s := range sessions {
ui.Row(c).FillWidth().Gap(k.D(4)).AlignItems(ui.Center).Role(ui.RoleListItem).Children(func() {
if s.Icon != nil {
ui.Box(c).Size(k.D(s.Icon.W), k.D(s.Icon.H)).Draw(func(p *ui.Painter, r ui.Rect) {
k.Canvas(p, r).Sprite(0, 0, s.Icon, 0, pixel.Ink, 1)
})
}
ui.Column(c).Grow(1).Children(func() {
pixel.Text(c, s.Device, pixel.TextOptions{MaxLines: 1})
// This device is here now; others say when.
if s.Current {
pixel.Text(c, k.Msg("this_device"), pixel.TextOptions{Slot: pixel.Good})
} else {
datetime.RelativeTime(c, s.Seen, datetime.RelativeTimeOptions{})
}
})
if input.Button(c, k.Msg("sign_out"), input.ButtonOptions{Size: input.Small, Variant: input.Danger, Disabled: o.Disabled, Label: k.Msg("sign_out") + " " + s.Device}).Clicked {
res.SignedOut = i
}
})
}
})
if res.SignedOut >= 0 {
slog.Debug("8bit: session list sign out", "label", text, "session", res.SignedOut)
}
return res
}
+58
View File
@@ -0,0 +1,58 @@
package account
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Setting is a label beside the control it builds.
type Setting struct {
Label string
Control func()
}
// SettingsSection is a titled run of settings.
type SettingsSection struct {
Title string
Settings []Setting
}
type SettingsListOptions struct {
// Width is in dots; zero means 240.
Width int
}
// SettingsList sets controls beside labels under section titles.
func SettingsList(c *ui.Context, text string, sections []SettingsSection, o SettingsListOptions) *ui.Element {
k := pixel.K(c)
return ui.Column(c).Width(k.D(pixel.Or("SettingsList Width", o.Width, 240))).Gap(k.D(2)).Role(ui.RoleGroup).Label(text).Children(func() {
for i, s := range sections {
top := 4
if i == 0 {
top = 0
}
ui.Box(c).FillWidth().Height(k.D(pixel.LineHeight+3)).Margin(k.D(top), 0, 0, 0).Role(ui.RoleText).Label(s.Title).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Text(0, 0, s.Title, pixel.Accent, 1)
g.HLine(0, pixel.LineHeight+1, g.W, pixel.Accent)
})
for j, st := range s.Settings {
last := j == len(s.Settings)-1
ui.Row(c).FillWidth().AlignItems(ui.Center).Gap(k.D(6)).Padding(k.D(2), 0).Children(func() {
// The control says its own name; the label only shows.
mark(c, k, st.Label, pixel.Ink, 0).Grow(1)
st.Control()
}).DrawOver(func(p *ui.Painter, r ui.Rect) {
// A dotted rule between rows, none under the last.
if !last {
g := k.Canvas(p, r)
for x := 0; x < g.W; x += 2 {
g.Dot(x, g.H-1, pixel.Shade)
}
}
})
}
}
})
}
+24
View File
@@ -0,0 +1,24 @@
package account
import (
"math"
"testing"
"time"
)
func TestSecondsLeftRoundsUpWithoutOverflow(t *testing.T) {
for _, tc := range []struct {
now time.Time
period time.Duration
want int64
}{
{time.Unix(0, 0), 30 * time.Second, 30},
{time.Unix(1, 0), 30 * time.Second, 29},
{time.Unix(29, 5e8), 30 * time.Second, 1},
{time.Unix(0, 0), math.MaxInt64, 9223372037},
} {
if got := secondsLeft(tc.now, tc.period); got != tc.want {
t.Errorf("%v into %v: %d s left, want %d", tc.now.Unix(), tc.period, got, tc.want)
}
}
}
+74
View File
@@ -0,0 +1,74 @@
package account
import (
"fmt"
"log/slog"
"strings"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type TwoFactorOptions struct {
// Period is a code's life; zero means 30 s.
Period time.Duration
Disabled bool
}
type TwoFactorResult struct {
Element *ui.Element
// Submitted is the sixth box filled.
Changed, Submitted bool
}
// TwoFactor takes a six-digit code; a bar drains its period.
func TwoFactor(c *ui.Context, text string, code *string, o TwoFactorOptions) TwoFactorResult {
k := pixel.K(c)
period := o.Period
if period == 0 {
period = 30 * time.Second
}
if period < time.Second || len(*code) > 6 || strings.Trim(*code, "0123456789") != "" {
panic(fmt.Sprintf("account: TwoFactor code of %d runes, period %v", len(*code), period))
}
var res TwoFactorResult
// The bar spans the boxes, not the parent.
res.Element = ui.Column(c).AlignSelf(ui.Start).Gap(k.D(4)).Children(func() {
// PinInput takes typing, paste and Backspace.
pin := input.PinInput(c, text, code, input.PinInputOptions{Length: 6, Disabled: o.Disabled})
res.Changed, res.Submitted = pin.Changed, pin.Complete
now := k.Now()
k.At(c, now.Truncate(time.Second).Add(time.Second))
secs := secondsLeft(now, period)
ui.Box(c).FillWidth().Height(k.D(6)).Role(ui.RoleProgress).Label(k.Msg("time_left")).Description(fmt.Sprintf("%d s", secs)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
// The bar drains on 4 fps steps of the clock.
rest := period - time.Duration(g.Busy(4))*(time.Second/4)%period
bar := pixel.Good
switch {
case rest <= 5*time.Second:
bar = pixel.Bad
case rest <= 10*time.Second:
bar = pixel.Warn
}
g.Frame(0, 0, g.W, 6, pixel.Sunken, pixel.Well)
// Floats, so long periods cannot overflow.
g.Rect(2, 2, int(float64(g.W-4)*float64(rest)/float64(period)), 2, bar)
})
})
// The code never reaches the log, only its length.
if res.Changed {
slog.Debug("8bit: two factor", "label", text, "length", len(*code), "submitted", res.Submitted)
}
return res
}
// secondsLeft rounds up the time left in now's period, epoch-aligned.
func secondsLeft(now time.Time, period time.Duration) int64 {
left := period - time.Duration(now.UnixNano()%int64(period))
// left is at least 1 ns; this form cannot overflow.
return int64(1 + (left-1)/time.Second)
}
+51
View File
@@ -0,0 +1,51 @@
package account
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Mix is music, effects and voice volume, 0 to 1.
type Mix struct {
BGM, SE, Voice float64
}
type VolumeMixerOptions struct {
Disabled bool
}
type VolumeMixerResult struct {
Element *ui.Element
Changed bool
}
// VolumeMixer sets BGM, SE and VOICE on faders.
func VolumeMixer(c *ui.Context, text string, mix *Mix, o VolumeMixerOptions) VolumeMixerResult {
k := pixel.K(c)
var res VolumeMixerResult
res.Element = ui.Row(c).Gap(k.D(10)).Role(ui.RoleGroup).Label(text).Children(func() {
for _, ch := range []struct {
key string
v *float64
}{{"bgm", &mix.BGM}, {"se", &mix.SE}, {"voice", &mix.Voice}} {
if !(*ch.v >= 0 && *ch.v <= 1) {
panic(fmt.Sprintf("account: VolumeMixer %s at %v", ch.key, *ch.v))
}
ui.Column(c).Gap(k.D(2)).AlignItems(ui.Center).Children(func() {
name := k.Msg(ch.key)
if input.Fader(c, name, ch.v, input.FaderOptions{Height: 56, Disabled: o.Disabled}).Changed {
res.Changed = true
slog.Debug("8bit: volume mixer", "label", text, "channel", ch.key, "level", *ch.v)
}
mark(c, k, name, pixel.Faint, 0)
caption(c, k, fmt.Sprintf("%d%%", int(*ch.v*100+0.5)), pixel.Ink)
})
}
})
return res
}
+9 -9
View File
@@ -95,20 +95,20 @@ func Avatar(c *ui.Context, name string, o AvatarOptions) *ui.Element {
if o.Picture != nil { if o.Picture != nil {
return Picture(c, o.Picture, PictureOptions{W: 16 * sc, H: 16 * sc, Label: name}) return Picture(c, o.Picture, PictureOptions{W: 16 * sc, H: 16 * sc, Label: name})
} }
return art(c, k, name, 16*sc, 16*sc, func(g pixel.Canvas) { drawFace(g, 0, name, sc) }) return art(c, k, name, 16*sc, 16*sc, func(g pixel.Canvas) { Face(g, 0, 0, name, sc) })
} }
// drawFace paints the face of name at x, scaled. // Face paints the face of name at x, y, scaled.
func drawFace(g pixel.Canvas, x int, name string, sc int) { func Face(g pixel.Canvas, x, y int, name string, sc int) {
h := fnv.New32a() h := fnv.New32a()
h.Write([]byte(strings.ToLower(name))) h.Write([]byte(strings.ToLower(name)))
n := h.Sum32() n := h.Sum32()
pick := func(k int) int { v := int(n % uint32(k)); n /= uint32(k); return v } pick := func(k int) int { v := int(n % uint32(k)); n /= uint32(k); return v }
g.Rect(x, 0, 16*sc, 16*sc, backs[pick(len(backs))]) g.Rect(x, y, 16*sc, 16*sc, backs[pick(len(backs))])
g.Sprite(x, 0, head, 0, skins[pick(len(skins))], sc) g.Sprite(x, y, head, 0, skins[pick(len(skins))], sc)
g.Sprite(x, 0, hair[pick(len(hair))], 0, colors[pick(len(colors))], sc) g.Sprite(x, y, hair[pick(len(hair))], 0, colors[pick(len(colors))], sc)
g.Sprite(x, 8*sc, eyes[pick(len(eyes))], 0, pixel.Ink, sc) g.Sprite(x, y+8*sc, eyes[pick(len(eyes))], 0, pixel.Ink, sc)
g.Sprite(x, 11*sc, mouths[pick(len(mouths))], 0, pixel.Ink, sc) g.Sprite(x, y+11*sc, mouths[pick(len(mouths))], 0, pixel.Ink, sc)
} }
type AvatarGroupOptions struct { type AvatarGroupOptions struct {
@@ -134,7 +134,7 @@ func AvatarGroup(c *ui.Context, names []string, o AvatarGroupOptions) *ui.Elemen
return art(c, k, strings.Join(names, ", "), w, 16*sc, func(g pixel.Canvas) { return art(c, k, strings.Join(names, ", "), w, 16*sc, func(g pixel.Canvas) {
for i, name := range shown { for i, name := range shown {
x := i * step x := i * step
drawFace(g, x, name, sc) Face(g, x, 0, name, sc)
if i > 0 { if i > 0 {
g.VLine(x, 0, 16*sc, pixel.Back) g.VLine(x, 0, 16*sc, pixel.Back)
} }
+167
View File
@@ -0,0 +1,167 @@
package gallery
import (
"fmt"
"strings"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/account"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
var accountEntries = []Entry{
{386, "SettingsList", "设置列表", "account", "Labels on the left, controls on the right, sections on top. Tidy.", demoSettingsList},
{387, "OptionCycler", "选项切换", "account", "◀ and ▶ walk the choices; past the end it comes around.", demoOptionCycler},
{388, "KeyBindings", "按键绑定", "account", "Enter, then press the new key. Two actions on one key glow red.", demoKeyBindings},
{389, "ControllerMap", "手柄图", "account", "Which button does what, with lines to prove it.", demoControllerMap},
{390, "ProfileCard", "玩家卡", "account", "Face, name, level, title and the numbers that matter.", demoProfileCard},
{391, "CharacterCreator", "角色创建", "account", "Cycle hair, eyes, outfit and colors; the hero changes as you do.", demoCharacterCreator},
{392, "LoginForm", "登录表单", "account", "Try player1 and hunter2. Anything else, and the form shakes its head.", demoLoginForm},
{393, "PasswordGrid", "密码网格", "account", "Spell a password from the grid, as cartridges once asked. Try JUSTINBAILEY.", demoPasswordGrid},
{394, "SessionList", "会话设备", "account", "Every device still signed in, and a way to kick each out.", demoSessionList},
{395, "LanguagePicker", "语言选择", "account", "Languages by their own names, flags beside. ▶ marks yours.", demoLanguagePicker},
}
type accountDemo struct {
music, sfx, fullscreen, scanlines bool
speed, lang, failures int
binds []account.Binding
look account.Look
name, password, grid string
sessions []account.Session
}
func accountState(c *ui.Context) *accountDemo {
now := pixel.K(c).Now()
return ui.Local(c.Root(), demoKey("account"), func() accountDemo {
return accountDemo{
music: true, sfx: true, scanlines: true, speed: 1,
binds: []account.Binding{
{Action: "Jump", Key: ui.KeySpace}, {Action: "Attack", Key: ui.KeyZ}, {Action: "Dash", Key: ui.KeyZ}, {Action: "Map", Key: ui.KeyM},
},
sessions: []account.Session{
{Device: "This laptop", Icon: icon.Globe, Seen: now, Current: true},
{Device: "Game console", Icon: icon.Gamepad, Seen: now.Add(-2 * time.Hour)},
{Device: "Old phone", Icon: icon.Phone, Seen: now.Add(-72 * time.Hour)},
},
}
})
}
var speeds = []string{"SLOW", "NORMAL", "FAST", "INSTANT"}
func demoSettingsList(c *ui.Context, log func(string)) {
s := accountState(c)
toggle := func(label string, on *bool) func() {
return func() {
// The row shows the label; the switch says it aloud.
if input.Switch(c, "", on, input.SwitchOptions{Label: label}).Changed {
log(fmt.Sprintf("%s %v", label, *on))
}
}
}
account.SettingsList(c, "Settings", []account.SettingsSection{
{Title: "AUDIO", Settings: []account.Setting{{Label: "Music", Control: toggle("Music", &s.music)}, {Label: "Sound effects", Control: toggle("Sound effects", &s.sfx)}}},
{Title: "VIDEO", Settings: []account.Setting{{Label: "Full screen", Control: toggle("Full screen", &s.fullscreen)}, {Label: "Scanlines", Control: toggle("Scanlines", &s.scanlines)}}},
{Title: "GAME", Settings: []account.Setting{{Label: "Text speed", Control: func() {
if account.OptionCycler(c, "Text speed", speeds, &s.speed, account.OptionCyclerOptions{}).Changed {
log("text speed " + speeds[s.speed])
}
}}}},
}, account.SettingsListOptions{})
}
func demoOptionCycler(c *ui.Context, log func(string)) {
s := accountState(c)
if account.OptionCycler(c, "Text speed", speeds, &s.speed, account.OptionCyclerOptions{}).Changed {
log("text speed " + speeds[s.speed])
}
}
func demoKeyBindings(c *ui.Context, log func(string)) {
s := accountState(c)
if account.KeyBindings(c, "Controls", s.binds, account.KeyBindingsOptions{}).Changed {
log("rebound")
}
}
func demoControllerMap(c *ui.Context, log func(string)) {
account.ControllerMap(c, "Pad", map[account.PadButton]string{
account.DPad: "MOVE", account.ButtonA: "JUMP", account.ButtonB: "RUN", account.ButtonX: "MAP", account.ButtonY: "ITEM",
account.ShoulderL: "PREV", account.ShoulderR: "NEXT", account.Start: "PAUSE", account.Select: "MENU",
}, account.ControllerMapOptions{})
}
func demoProfileCard(c *ui.Context, log func(string)) {
account.ProfileCard(c, account.Profile{Name: "Aria", Title: "Dragon Slayer", Level: 12, Stats: []account.Stat{
{Name: "HP", Value: 30, Max: 40}, {Name: "MP", Value: 12, Max: 20}, {Name: "EXP", Value: 640, Max: 1000},
}}, account.ProfileCardOptions{})
}
func demoCharacterCreator(c *ui.Context, log func(string)) {
s := accountState(c)
if account.CharacterCreator(c, "New hero", &s.look, account.CharacterCreatorOptions{}).Changed {
log(fmt.Sprintf("look %+v", s.look))
}
}
func demoLoginForm(c *ui.Context, log func(string)) {
s := accountState(c)
if account.LoginForm(c, "WELCOME BACK", &s.name, &s.password, account.LoginFormOptions{Failures: s.failures}).Submitted {
// The demo checks; a real game asks its server.
if s.name == "player1" && s.password == "hunter2" {
log("logged in")
} else {
s.failures++
log("wrong name or password")
}
}
}
func demoPasswordGrid(c *ui.Context, log func(string)) {
s := accountState(c)
r := account.PasswordGrid(c, "Password", &s.grid, account.PasswordGridOptions{})
if r.Changed {
log(fmt.Sprintf("%d of 12", len(s.grid)))
}
if r.Submitted {
if s.grid == "JUSTINBAILEY" {
log("suit off. you know the one.")
} else {
log("wrong password")
}
}
}
func demoSessionList(c *ui.Context, log func(string)) {
s := accountState(c)
if r := account.SessionList(c, "Signed in", s.sessions, account.SessionListOptions{}); r.SignedOut >= 0 {
log("signed out " + s.sessions[r.SignedOut].Device)
s.sessions = append(s.sessions[:r.SignedOut:r.SignedOut], s.sessions[r.SignedOut+1:]...)
}
}
var languages = []account.Language{
{Name: "English", Flag: flag("llcccrrcccll cllccrrccllc ccllcrrcllcc rrrrrrrrrrrr rrrrrrrrrrrr ccllcrrcllcc cllccrrccllc llcccrrcccll")},
{Name: "中文", Flag: flag("rryrrrrrrrrr ryyyrrrrrrrr rryrryrrrrrr rrrrrrrrrrrr rrryrrrrrrrr rrrrrrrrrrrr rrrrrrrrrrrr rrrrrrrrrrrr")},
{Name: "日本語", Flag: flag("llllllllllll llllllllllll lllllrrlllll llllrrrrllll llllrrrrllll lllllrrlllll llllllllllll llllllllllll")},
{Name: "Français", Flag: flag("ccccllllrrrr ccccllllrrrr ccccllllrrrr ccccllllrrrr ccccllllrrrr ccccllllrrrr ccccllllrrrr ccccllllrrrr")},
{Name: "Deutsch", Flag: flag("kkkkkkkkkkkk kkkkkkkkkkkk kkkkkkkkkkkk rrrrrrrrrrrr rrrrrrrrrrrr rrrrrrrrrrrr yyyyyyyyyyyy yyyyyyyyyyyy")},
{Name: "Español", Flag: flag("rrrrrrrrrrrr rrrrrrrrrrrr yyyyyyyyyyyy yyyyyyyyyyyy yyyyyyyyyyyy yyyyyyyyyyyy rrrrrrrrrrrr rrrrrrrrrrrr")},
}
// flag is string art given as rows split by spaces.
func flag(rows string) *pixel.Sprite {
return pixel.MustSprite(strings.ReplaceAll(rows, " ", "\n"))
}
func demoLanguagePicker(c *ui.Context, log func(string)) {
s := accountState(c)
if account.LanguagePicker(c, "Language", languages, &s.lang, account.LanguagePickerOptions{Height: 100}).Changed {
log("language " + languages[s.lang].Name)
}
}
+132
View File
@@ -0,0 +1,132 @@
package gallery
import (
"fmt"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/account"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
var playerEntries = []Entry{
{396, "PalettePicker", "调色板选择", "account", "Each palette as a little live scene. Choose one and the sample wears it.", demoPalettePicker},
{397, "VolumeMixer", "音量混合", "account", "Music, effects and voice, each on its own fader.", demoVolumeMixer},
{398, "DifficultySelect", "难度选择", "account", "From EASY to NIGHTMARE, one skull a step. Choose wisely.", demoDifficultySelect},
{399, "AccessibilityOptions", "无障碍", "account", "Reduce motion and the dot size; the sample below obeys at once.", demoAccessibilityOptions},
{400, "AccountSwitcher", "账号切换", "account", "Four player slots in arcade colors. Empty ones wait for a friend.", demoAccountSwitcher},
{401, "PlayerSelect", "玩家选择", "account", "1 PLAYER or 2 PLAYERS: the oldest question on the title screen.", demoPlayerSelect},
{402, "TwoFactor", "两步验证", "account", "Six digits, typed or pasted. The bar is how long the code lives.", demoTwoFactor},
{403, "About", "关于", "account", "The logo, the version, and the people to thank.", demoAbout},
{404, "PatchNotes", "更新日志", "account", "What came, what was fixed, what changed, version by version.", demoPatchNotes},
{405, "FeedbackForm", "反馈表", "account", "Stars and words. SEND waits until you rate.", demoFeedbackForm},
}
type playerDemo struct {
palette, difficulty, slot, players, rating int
mix account.Mix
access account.Access
code, words string
}
func playerState(c *ui.Context) *playerDemo {
return ui.Local(c.Root(), demoKey("player"), func() playerDemo {
return playerDemo{difficulty: 1, players: 1, mix: account.Mix{BGM: 0.8, SE: 0.6, Voice: 0.9}, access: account.Access{Dot: 2}}
})
}
func demoPalettePicker(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := playerState(c)
ui.Column(c).Gap(k.D(6)).Children(func() {
if account.PalettePicker(c, "Palette", &s.palette, account.PalettePickerOptions{}).Changed {
log("palette " + pixel.Palettes[s.palette].Name)
}
// Choosing applies: the sample draws in the chosen palette.
in := pixel.Current(c)
in.Palette = pixel.Palettes[s.palette]
pixel.Scope(c, in, func() {
display.Sprite(c, slime, display.SpriteOptions{FPS: 4, Scale: 3})
})
})
}
func demoVolumeMixer(c *ui.Context, log func(string)) {
s := playerState(c)
if account.VolumeMixer(c, "Mixer", &s.mix, account.VolumeMixerOptions{}).Changed {
log(fmt.Sprintf("mix %.2f %.2f %.2f", s.mix.BGM, s.mix.SE, s.mix.Voice))
}
}
func demoDifficultySelect(c *ui.Context, log func(string)) {
s := playerState(c)
if account.DifficultySelect(c, "Difficulty", &s.difficulty, account.DifficultySelectOptions{}).Changed {
log(fmt.Sprintf("difficulty %d", s.difficulty+1))
}
}
func demoAccessibilityOptions(c *ui.Context, log func(string)) {
k := pixel.K(c)
s := playerState(c)
ui.Column(c).Gap(k.D(8)).Children(func() {
if account.AccessibilityOptions(c, "Access", &s.access, account.AccessibilityOptionsOptions{}).Changed {
log(fmt.Sprintf("reduce %v, dot %d", s.access.Reduce, s.access.Dot))
}
in := pixel.Current(c)
in.Dot, in.Reduce = s.access.Dot, s.access.Reduce
pixel.Scope(c, in, func() {
display.Sprite(c, slime, display.SpriteOptions{FPS: 4, Scale: 2})
})
})
}
func demoAccountSwitcher(c *ui.Context, log func(string)) {
s := playerState(c)
if account.AccountSwitcher(c, "Players", [4]string{"Aria", "Brom", "", ""}, &s.slot, account.AccountSwitcherOptions{}).Changed {
log(fmt.Sprintf("P%d", s.slot+1))
}
}
func demoPlayerSelect(c *ui.Context, log func(string)) {
s := playerState(c)
r := account.PlayerSelect(c, "Players", &s.players, account.PlayerSelectOptions{})
if r.Changed {
log(fmt.Sprintf("%d players", s.players))
}
if r.Chosen {
log(fmt.Sprintf("start %dP", s.players))
}
}
func demoTwoFactor(c *ui.Context, log func(string)) {
s := playerState(c)
r := account.TwoFactor(c, "Login code", &s.code, account.TwoFactorOptions{})
if r.Changed {
log(fmt.Sprintf("%d digits", len(s.code)))
}
if r.Submitted {
log("code sent")
}
}
func demoAbout(c *ui.Context, log func(string)) {
account.About(c, "8BIT", account.AboutOptions{Version: "1.0.0", Credits: []account.Credit{
{Role: "Pixels and code", Name: "The 8bit team"}, {Role: "Native UI", Name: "MyGo"}, {Role: "Font", Name: "Ark Pixel"},
}})
}
func demoPatchNotes(c *ui.Context, log func(string)) {
account.PatchNotes(c, "Patch notes", []account.Patch{
{Version: "v1.1.0", Added: []string{"Palette cycling waterfalls"}, Fixed: []string{"The ghost in level 3 now leaves"}, Changed: []string{"Jump height +1 dot"}},
{Version: "v1.0.0", Added: []string{"Everything"}},
}, account.PatchNotesOptions{})
}
func demoFeedbackForm(c *ui.Context, log func(string)) {
s := playerState(c)
if account.FeedbackForm(c, "Feedback", &s.rating, &s.words, account.FeedbackFormOptions{}).Sent {
log(fmt.Sprintf("sent %d stars", s.rating))
s.rating, s.words = 0, ""
}
}
+35
View File
@@ -71,3 +71,38 @@ func TestCodeDemosLogTheirActions(t *testing.T) {
} }
} }
} }
func TestAccountDemosLogTheirActions(t *testing.T) {
quiet := map[string]bool{"NAME": true, "PASSWORD": true, "Login code": true, "Controls": true, "4/5": true, "type:player1": true, "type:hunter2": true}
for _, tc := range []struct {
path string
steps []string
}{
{"/c/386-settingslist", []string{"Music"}}, {"/c/387-optioncycler", []string{"Text speed"}},
{"/c/388-keybindings", []string{"Controls", "key:A"}}, {"/c/391-charactercreator", []string{"HAIR"}},
{"/c/392-loginform", []string{"NAME", "type:player1", "PASSWORD", "type:hunter2", "key:Enter"}},
{"/c/393-passwordgrid", []string{"SYMBOLS"}}, {"/c/394-sessionlist", []string{"SIGN OUT Old phone"}},
{"/c/395-languagepicker", []string{"Deutsch"}}, {"/c/396-palettepicker", []string{"Palette"}},
{"/c/397-volumemixer", []string{"BGM"}}, {"/c/398-difficultyselect", []string{"Difficulty"}},
{"/c/399-accessibilityoptions", []string{"REDUCE MOTION"}}, {"/c/400-accountswitcher", []string{"P2, Brom"}},
{"/c/401-playerselect", []string{"Players"}}, {"/c/402-twofactor", []string{"Login code", "type:1"}},
{"/c/405-feedbackform", []string{"4/5", "SEND ▶"}},
} {
a, tt := open(tc.path)
slug := tc.path[3:]
for _, step := range tc.steps {
before := len(a.logs[slug])
switch {
case strings.HasPrefix(step, "key:"):
tt.Key(0, letters[step[4:]])
case strings.HasPrefix(step, "type:"):
tt.Type(step[5:])
default:
click(t, tt, step)
}
if len(a.logs[slug]) <= before && !quiet[step] {
t.Errorf("%s: %s logged nothing: %q", slug, step, a.logs[slug])
}
}
}
}
+1 -1
View File
@@ -56,7 +56,7 @@ var Categories = []Category{
{"tabletop", "Tabletop", "桌游", icon.Crown}, {"tabletop", "Tabletop", "桌游", icon.Crown},
} }
var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries, chartEntries, plotEntries, gameEntries, rpgEntries, screenEntries, lootEntries, fxEntries, screenFxEntries, chatEntries, mailEntries, mediaEntries, studioEntries, codeEntries, debugEntries) var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries, chartEntries, plotEntries, gameEntries, rpgEntries, screenEntries, lootEntries, fxEntries, screenFxEntries, chatEntries, mailEntries, mediaEntries, studioEntries, codeEntries, debugEntries, accountEntries, playerEntries)
func Entries() []Entry { return entries } func Entries() []Entry { return entries }
+2 -1
View File
@@ -44,7 +44,8 @@ func PinInput(c *ui.Context, text string, value *string, o PinInputOptions) PinI
switch { switch {
case ev.Kind == ui.InputText: case ev.Kind == ui.InputText:
put(ev.Text) put(ev.Text)
case ev.Kind == ui.InputCommand && ev.Text == "paste": // Browsers paste by command, native hosts by Cmd+V.
case ev.Kind == ui.InputCommand && ev.Text == "paste", ev.Kind == ui.InputKeyDown && ev.Key == ui.KeyV && ev.Mods == ui.Cmd:
put(c.ReadClipboard()) put(c.ReadClipboard())
case ev.Kind == ui.InputKeyDown && ev.Key == ui.KeyBackspace && ev.Mods == 0: case ev.Kind == ui.InputKeyDown && ev.Key == ui.KeyBackspace && ev.Mods == 0:
if len(*value) > 0 { if len(*value) > 0 {
+8
View File
@@ -126,6 +126,14 @@ func TestPinInputTypesBacksAndPastes(t *testing.T) {
if pin != "1298" || complete != 1 { if pin != "1298" || complete != 1 {
t.Fatalf("paste left %q, complete %d times", pin, complete) t.Fatalf("paste left %q, complete %d times", pin, complete)
} }
// Native hosts paste on Cmd+V, as a key.
tt.Key(0, ui.KeyBackspace)
tt.Key(0, ui.KeyBackspace)
tt.SetClipboard("55")
tt.Key(ui.Cmd, ui.KeyV)
if pin != "1255" || complete != 2 {
t.Fatalf("Cmd+V left %q, complete %d times", pin, complete)
}
if _, ok := tt.TextCaret(); !ok { if _, ok := tt.TextCaret(); !ok {
t.Fatal("the boxes take no text from input methods") t.Fatal("the boxes take no text from input methods")
} }
+1 -1
View File
@@ -193,7 +193,7 @@ func hanging(punct func(rune) bool) func(rune) bool {
} }
func TestMicroFont(t *testing.T) { func TestMicroFont(t *testing.T) {
set := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-#.:/%! " set := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-#.:/%!? "
seen := map[[MicroHeight]uint8]rune{} seen := map[[MicroHeight]uint8]rune{}
for _, r := range set { for _, r := range set {
g := microGlyph(r) g := microGlyph(r)
+16
View File
@@ -37,6 +37,14 @@ var messages = map[Locale]map[string]string{
"prev_track": "PREVIOUS", "next_track": "NEXT", "play": "PLAY", "volume": "VOLUME", "rec": "REC", "playing": "PLAYING", "seek": "SEEK", "prev_track": "PREVIOUS", "next_track": "NEXT", "play": "PLAY", "volume": "VOLUME", "rec": "REC", "playing": "PLAYING", "seek": "SEEK",
"copy": "COPY", "copied": "COPIED!", "command": "COMMAND", "breakpoint": "BREAKPOINT", "copy": "COPY", "copied": "COPIED!", "command": "COMMAND", "breakpoint": "BREAKPOINT",
"tool": "TOOL", "pen": "PEN", "fill": "FILL", "erase": "ERASE", "pick": "PICK", "grid": "GRID", "color": "COLOR", "preview": "PREVIEW", "tool": "TOOL", "pen": "PEN", "fill": "FILL", "erase": "ERASE", "pick": "PICK", "grid": "GRID", "color": "COLOR", "preview": "PREVIEW",
"press_key": "PRESS A KEY", "conflict": "CONFLICT", "name": "NAME", "password": "PASSWORD", "login": "LOGIN",
"login_failed": "WRONG NAME OR PASSWORD", "sign_out": "SIGN OUT", "this_device": "THIS DEVICE",
"easy": "EASY", "normal": "NORMAL", "hard": "HARD", "nightmare": "NIGHTMARE",
"reduce_motion": "REDUCE MOTION", "dot_size": "DOT SIZE", "free_slot": "OPEN",
"one_player": "1 PLAYER", "two_players": "2 PLAYERS", "version": "VERSION", "credits": "CREDITS",
"added": "ADDED", "fixed": "FIXED", "changed": "CHANGED", "hair": "HAIR", "eyes": "EYES", "outfit": "OUTFIT",
"hair_color": "HAIR COLOR", "outfit_color": "OUTFIT COLOR", "bgm": "BGM", "se": "SE", "voice": "VOICE",
"styles": "SPIKY BOB BALD", "looks": "DOTS WIDE SLEEPY", "outfits": "TUNIC ROBE ARMOR", "hues": "DARK GOLD RED VIOLET SKY", "time_left": "TIME LEFT", "in_use": "IN USE", "rating": "RATING", "message": "MESSAGE", "symbols": "SYMBOLS",
}, },
Zh: { Zh: {
"ok": "确定", "cancel": "取消", "yes": "是", "no": "否", "close": "关闭", "ok": "确定", "cancel": "取消", "yes": "是", "no": "否", "close": "关闭",
@@ -72,6 +80,14 @@ var messages = map[Locale]map[string]string{
"prev_track": "上一首", "next_track": "下一首", "play": "播放", "volume": "音量", "rec": "录制", "playing": "正在播放", "seek": "进度", "prev_track": "上一首", "next_track": "下一首", "play": "播放", "volume": "音量", "rec": "录制", "playing": "正在播放", "seek": "进度",
"copy": "复制", "copied": "已复制!", "command": "命令", "breakpoint": "断点", "copy": "复制", "copied": "已复制!", "command": "命令", "breakpoint": "断点",
"tool": "工具", "pen": "铅笔", "fill": "填充", "erase": "橡皮", "pick": "吸管", "grid": "网格", "color": "颜色", "preview": "预览", "tool": "工具", "pen": "铅笔", "fill": "填充", "erase": "橡皮", "pick": "吸管", "grid": "网格", "color": "颜色", "preview": "预览",
"press_key": "请按键", "conflict": "冲突", "name": "名字", "password": "密码", "login": "登录",
"login_failed": "名字或密码错误", "sign_out": "登出", "this_device": "本机",
"easy": "简单", "normal": "普通", "hard": "困难", "nightmare": "噩梦",
"reduce_motion": "减少动态", "dot_size": "像素大小", "free_slot": "空位",
"one_player": "单人", "two_players": "双人", "version": "版本", "credits": "制作人员",
"added": "新增", "fixed": "修复", "changed": "改动", "hair": "发型", "eyes": "眼睛", "outfit": "服装",
"hair_color": "发色", "outfit_color": "衣色", "bgm": "音乐", "se": "音效", "voice": "语音",
"styles": "刺头 短发 光头", "looks": "圆眼 大眼 睡眼", "outfits": "短衣 长袍 铠甲", "hues": "墨 金 红 堇 蓝", "time_left": "剩余时间", "in_use": "使用中", "rating": "评分", "message": "留言", "symbols": "符号",
}, },
} }
+1
View File
@@ -53,6 +53,7 @@ var micro = microFont(map[rune]string{
'+': "...|.#.|###|.#.|...", '+': "...|.#.|###|.#.|...",
'-': "...|...|###|...|...", '-': "...|...|###|...|...",
'#': "#.#|###|#.#|###|#.#", '#': "#.#|###|#.#|###|#.#",
'?': "###|..#|.##|...|.#.",
'.': "...|...|...|...|.#.", '.': "...|...|...|...|.#.",
':': "...|.#.|...|.#.|...", ':': "...|.#.|...|.#.|...",
'/': "..#|..#|.#.|#..|#..", '/': "..#|..#|.#.|#..|#..",
+3 -1
View File
@@ -24,6 +24,8 @@ type Settings struct {
Now func() time.Time Now func() time.Time
// Inert controls take no input, as under a busy veil. // Inert controls take no input, as under a busy veil.
Inert bool Inert bool
// Reduce stills motion, as the system preference does.
Reduce bool
} }
// Kit is one frame's settings, captured by components. // Kit is one frame's settings, captured by components.
@@ -92,7 +94,7 @@ func K(c *ui.Context) Kit {
if s.Now != nil { if s.Now != nil {
now = s.Now() now = s.Now()
} }
return Kit{pal: s.Palette, dot: float32(s.Dot), locale: s.Locale, now: now, clock: s.Now, reduce: c.Preferences().ReduceMotion} return Kit{pal: s.Palette, dot: float32(s.Dot), locale: s.Locale, now: now, clock: s.Now, reduce: c.Preferences().ReduceMotion || s.Reduce}
} }
func frameOf(c *ui.Context) *frame { func frameOf(c *ui.Context) *frame {
+12
View File
@@ -189,3 +189,15 @@ func TestReduceMotionFollowsPreferences(t *testing.T) {
t.Fatal("Reduce off after ReduceMotion preference") t.Fatal("Reduce off after ReduceMotion preference")
} }
} }
func TestReduceSettingStillsMotionInItsScope(t *testing.T) {
var outer, inner pixel.Kit
ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay})
outer = pixel.K(c)
pixel.Scope(c, pixel.Settings{Palette: pixel.SpringDay, Reduce: true}, func() { inner = pixel.K(c) })
}, 10, 10)
if outer.Reduce() || !inner.Reduce() {
t.Fatalf("Reduce: outside %v, inside %v", outer.Reduce(), inner.Reduce())
}
}