diff --git a/account/about.go b/account/about.go new file mode 100644 index 0000000..81de394 --- /dev/null +++ b/account/about.go @@ -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{}) + } + }) +} diff --git a/account/accessibilityoptions.go b/account/accessibilityoptions.go new file mode 100644 index 0000000..f931fdf --- /dev/null +++ b/account/accessibilityoptions.go @@ -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 +} diff --git a/account/account.go b/account/account.go new file mode 100644 index 0000000..8d5ede6 --- /dev/null +++ b/account/account.go @@ -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) + }) +} diff --git a/account/accountswitcher.go b/account/accountswitcher.go new file mode 100644 index 0000000..a0a7872 --- /dev/null +++ b/account/accountswitcher.go @@ -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 +} diff --git a/account/auth_test.go b/account/auth_test.go new file mode 100644 index 0000000..ae394c0 --- /dev/null +++ b/account/auth_test.go @@ -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) + } +} diff --git a/account/charactercreator.go b/account/charactercreator.go new file mode 100644 index 0000000..43b8888 --- /dev/null +++ b/account/charactercreator.go @@ -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 +} diff --git a/account/controllermap.go b/account/controllermap.go new file mode 100644 index 0000000..370febf --- /dev/null +++ b/account/controllermap.go @@ -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) + } + }) +} diff --git a/account/difficultyselect.go b/account/difficultyselect.go new file mode 100644 index 0000000..7bd6936 --- /dev/null +++ b/account/difficultyselect.go @@ -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 +} diff --git a/account/feedbackform.go b/account/feedbackform.go new file mode 100644 index 0000000..a360812 --- /dev/null +++ b/account/feedbackform.go @@ -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 +} diff --git a/account/guards_test.go b/account/guards_test.go new file mode 100644 index 0000000..56444b9 --- /dev/null +++ b/account/guards_test.go @@ -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}) + }) + }) +} diff --git a/account/keybindings.go b/account/keybindings.go new file mode 100644 index 0000000..455ad3d --- /dev/null +++ b/account/keybindings.go @@ -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 +} diff --git a/account/languagepicker.go b/account/languagepicker.go new file mode 100644 index 0000000..4c819ef --- /dev/null +++ b/account/languagepicker.go @@ -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 +} diff --git a/account/loginform.go b/account/loginform.go new file mode 100644 index 0000000..2d1248f --- /dev/null +++ b/account/loginform.go @@ -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 +} diff --git a/account/optioncycler.go b/account/optioncycler.go new file mode 100644 index 0000000..b6aa297 --- /dev/null +++ b/account/optioncycler.go @@ -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 +} diff --git a/account/palettepicker.go b/account/palettepicker.go new file mode 100644 index 0000000..a1afe29 --- /dev/null +++ b/account/palettepicker.go @@ -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) +} diff --git a/account/passwordgrid.go b/account/passwordgrid.go new file mode 100644 index 0000000..edbb4af --- /dev/null +++ b/account/passwordgrid.go @@ -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 +} diff --git a/account/patchnotes.go b/account/patchnotes.go new file mode 100644 index 0000000..81ef727 --- /dev/null +++ b/account/patchnotes.go @@ -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) + }) + } + } + } + }) +} diff --git a/account/players_test.go b/account/players_test.go new file mode 100644 index 0000000..13db048 --- /dev/null +++ b/account/players_test.go @@ -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{}) }) + }) +} diff --git a/account/playerselect.go b/account/playerselect.go new file mode 100644 index 0000000..cb61a15 --- /dev/null +++ b/account/playerselect.go @@ -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 +} diff --git a/account/prefs_test.go b/account/prefs_test.go new file mode 100644 index 0000000..10c7ce7 --- /dev/null +++ b/account/prefs_test.go @@ -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)) + } +} diff --git a/account/profilecard.go b/account/profilecard.go new file mode 100644 index 0000000..b04a918 --- /dev/null +++ b/account/profilecard.go @@ -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) +} diff --git a/account/sessionlist.go b/account/sessionlist.go new file mode 100644 index 0000000..12abc8e --- /dev/null +++ b/account/sessionlist.go @@ -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 +} diff --git a/account/settingslist.go b/account/settingslist.go new file mode 100644 index 0000000..b4d9947 --- /dev/null +++ b/account/settingslist.go @@ -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) + } + } + }) + } + } + }) +} diff --git a/account/time_internal_test.go b/account/time_internal_test.go new file mode 100644 index 0000000..66e143d --- /dev/null +++ b/account/time_internal_test.go @@ -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) + } + } +} diff --git a/account/twofactor.go b/account/twofactor.go new file mode 100644 index 0000000..38f97f4 --- /dev/null +++ b/account/twofactor.go @@ -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) +} diff --git a/account/volumemixer.go b/account/volumemixer.go new file mode 100644 index 0000000..1ba9a10 --- /dev/null +++ b/account/volumemixer.go @@ -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 +} diff --git a/display/avatar.go b/display/avatar.go index d9ac9d0..ad5d7cb 100644 --- a/display/avatar.go +++ b/display/avatar.go @@ -95,20 +95,20 @@ func Avatar(c *ui.Context, name string, o AvatarOptions) *ui.Element { if o.Picture != nil { 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. -func drawFace(g pixel.Canvas, x int, name string, sc int) { +// Face paints the face of name at x, y, scaled. +func Face(g pixel.Canvas, x, y int, name string, sc int) { h := fnv.New32a() h.Write([]byte(strings.ToLower(name))) n := h.Sum32() 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.Sprite(x, 0, head, 0, skins[pick(len(skins))], sc) - g.Sprite(x, 0, 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, 11*sc, mouths[pick(len(mouths))], 0, pixel.Ink, sc) + g.Rect(x, y, 16*sc, 16*sc, backs[pick(len(backs))]) + g.Sprite(x, y, head, 0, skins[pick(len(skins))], sc) + g.Sprite(x, y, hair[pick(len(hair))], 0, colors[pick(len(colors))], sc) + g.Sprite(x, y+8*sc, eyes[pick(len(eyes))], 0, pixel.Ink, sc) + g.Sprite(x, y+11*sc, mouths[pick(len(mouths))], 0, pixel.Ink, sc) } 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) { for i, name := range shown { x := i * step - drawFace(g, x, name, sc) + Face(g, x, 0, name, sc) if i > 0 { g.VLine(x, 0, 16*sc, pixel.Back) } diff --git a/gallery/demos_account.go b/gallery/demos_account.go new file mode 100644 index 0000000..2c0b0d7 --- /dev/null +++ b/gallery/demos_account.go @@ -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) + } +} diff --git a/gallery/demos_player.go b/gallery/demos_player.go new file mode 100644 index 0000000..92dd48e --- /dev/null +++ b/gallery/demos_player.go @@ -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, "" + } +} diff --git a/gallery/media_demo_test.go b/gallery/media_demo_test.go index d191d9e..8052d16 100644 --- a/gallery/media_demo_test.go +++ b/gallery/media_demo_test.go @@ -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]) + } + } + } +} diff --git a/gallery/registry.go b/gallery/registry.go index cbf434c..3cdba8f 100644 --- a/gallery/registry.go +++ b/gallery/registry.go @@ -56,7 +56,7 @@ var Categories = []Category{ {"tabletop", "Tabletop", "桌游", icon.Crown}, } -var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, 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 } diff --git a/input/pininput.go b/input/pininput.go index 865a7fa..4bdb366 100644 --- a/input/pininput.go +++ b/input/pininput.go @@ -44,7 +44,8 @@ func PinInput(c *ui.Context, text string, value *string, o PinInputOptions) PinI switch { case ev.Kind == ui.InputText: 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()) case ev.Kind == ui.InputKeyDown && ev.Key == ui.KeyBackspace && ev.Mods == 0: if len(*value) > 0 { diff --git a/input/play_test.go b/input/play_test.go index 319a024..8e80f28 100644 --- a/input/play_test.go +++ b/input/play_test.go @@ -126,6 +126,14 @@ func TestPinInputTypesBacksAndPastes(t *testing.T) { if pin != "1298" || complete != 1 { 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 { t.Fatal("the boxes take no text from input methods") } diff --git a/pixel/font_test.go b/pixel/font_test.go index 57f3805..db31ca0 100644 --- a/pixel/font_test.go +++ b/pixel/font_test.go @@ -193,7 +193,7 @@ func hanging(punct func(rune) bool) func(rune) bool { } func TestMicroFont(t *testing.T) { - set := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-#.:/%! " + set := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-#.:/%!? " seen := map[[MicroHeight]uint8]rune{} for _, r := range set { g := microGlyph(r) diff --git a/pixel/messages.go b/pixel/messages.go index 85cff80..51f543e 100644 --- a/pixel/messages.go +++ b/pixel/messages.go @@ -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", "copy": "COPY", "copied": "COPIED!", "command": "COMMAND", "breakpoint": "BREAKPOINT", "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: { "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": "进度", "copy": "复制", "copied": "已复制!", "command": "命令", "breakpoint": "断点", "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": "符号", }, } diff --git a/pixel/micro.go b/pixel/micro.go index 1793fa1..54e724b 100644 --- a/pixel/micro.go +++ b/pixel/micro.go @@ -53,6 +53,7 @@ var micro = microFont(map[rune]string{ '+': "...|.#.|###|.#.|...", '-': "...|...|###|...|...", '#': "#.#|###|#.#|###|#.#", + '?': "###|..#|.##|...|.#.", '.': "...|...|...|...|.#.", ':': "...|.#.|...|.#.|...", '/': "..#|..#|.#.|#..|#..", diff --git a/pixel/settings.go b/pixel/settings.go index ba48826..27c41f0 100644 --- a/pixel/settings.go +++ b/pixel/settings.go @@ -24,6 +24,8 @@ type Settings struct { Now func() time.Time // Inert controls take no input, as under a busy veil. Inert bool + // Reduce stills motion, as the system preference does. + Reduce bool } // Kit is one frame's settings, captured by components. @@ -92,7 +94,7 @@ func K(c *ui.Context) Kit { if s.Now != nil { 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 { diff --git a/pixel/settings_test.go b/pixel/settings_test.go index cb83bf7..9ab89b7 100644 --- a/pixel/settings_test.go +++ b/pixel/settings_test.go @@ -189,3 +189,15 @@ func TestReduceMotionFollowsPreferences(t *testing.T) { 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()) + } +}