Add account components 386-405; Reduce setting, PinInput Cmd+V
Twenty account components: SettingsList, OptionCycler, KeyBindings, ControllerMap, ProfileCard, CharacterCreator, LoginForm, PasswordGrid, SessionList, LanguagePicker, PalettePicker, VolumeMixer, DifficultySelect, AccessibilityOptions, AccountSwitcher, PlayerSelect, TwoFactor, About, PatchNotes and FeedbackForm. The components never check credentials, and no secret reaches the log: LoginForm reports Submitted and the caller counts Failures; each new failure clears the password, refocuses it, shakes the form and names no field. TwoFactor builds on input.PinInput and drains an epoch-aligned period bar. PalettePicker draws each tile in its own palette on a pixel.Pad grid. pixel.Settings.Reduce stills motion like the system preference, the micro font gains '?', display.Face draws avatar faces anywhere, and input.PinInput pastes on Cmd+V as native hosts send it.
This commit is contained in:
38 files changed
+2788
-13
No files matched your search
@@ -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{})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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{}) })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
|
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
||||||
|
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mix is music, effects and voice volume, 0 to 1.
|
||||||
|
type Mix struct {
|
||||||
|
BGM, SE, Voice float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type VolumeMixerOptions struct {
|
||||||
|
Disabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type VolumeMixerResult struct {
|
||||||
|
Element *ui.Element
|
||||||
|
Changed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// VolumeMixer sets BGM, SE and VOICE on faders.
|
||||||
|
func VolumeMixer(c *ui.Context, text string, mix *Mix, o VolumeMixerOptions) VolumeMixerResult {
|
||||||
|
k := pixel.K(c)
|
||||||
|
var res VolumeMixerResult
|
||||||
|
res.Element = ui.Row(c).Gap(k.D(10)).Role(ui.RoleGroup).Label(text).Children(func() {
|
||||||
|
for _, ch := range []struct {
|
||||||
|
key string
|
||||||
|
v *float64
|
||||||
|
}{{"bgm", &mix.BGM}, {"se", &mix.SE}, {"voice", &mix.Voice}} {
|
||||||
|
if !(*ch.v >= 0 && *ch.v <= 1) {
|
||||||
|
panic(fmt.Sprintf("account: VolumeMixer %s at %v", ch.key, *ch.v))
|
||||||
|
}
|
||||||
|
ui.Column(c).Gap(k.D(2)).AlignItems(ui.Center).Children(func() {
|
||||||
|
name := k.Msg(ch.key)
|
||||||
|
if input.Fader(c, name, ch.v, input.FaderOptions{Height: 56, Disabled: o.Disabled}).Changed {
|
||||||
|
res.Changed = true
|
||||||
|
slog.Debug("8bit: volume mixer", "label", text, "channel", ch.key, "level", *ch.v)
|
||||||
|
}
|
||||||
|
mark(c, k, name, pixel.Faint, 0)
|
||||||
|
caption(c, k, fmt.Sprintf("%d%%", int(*ch.v*100+0.5)), pixel.Ink)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
}
|
||||||
+9
-9
@@ -95,20 +95,20 @@ func Avatar(c *ui.Context, name string, o AvatarOptions) *ui.Element {
|
|||||||
if o.Picture != nil {
|
if o.Picture != nil {
|
||||||
return Picture(c, o.Picture, PictureOptions{W: 16 * sc, H: 16 * sc, Label: name})
|
return Picture(c, o.Picture, PictureOptions{W: 16 * sc, H: 16 * sc, Label: name})
|
||||||
}
|
}
|
||||||
return art(c, k, name, 16*sc, 16*sc, func(g pixel.Canvas) { drawFace(g, 0, name, sc) })
|
return art(c, k, name, 16*sc, 16*sc, func(g pixel.Canvas) { Face(g, 0, 0, name, sc) })
|
||||||
}
|
}
|
||||||
|
|
||||||
// drawFace paints the face of name at x, scaled.
|
// Face paints the face of name at x, y, scaled.
|
||||||
func drawFace(g pixel.Canvas, x int, name string, sc int) {
|
func Face(g pixel.Canvas, x, y int, name string, sc int) {
|
||||||
h := fnv.New32a()
|
h := fnv.New32a()
|
||||||
h.Write([]byte(strings.ToLower(name)))
|
h.Write([]byte(strings.ToLower(name)))
|
||||||
n := h.Sum32()
|
n := h.Sum32()
|
||||||
pick := func(k int) int { v := int(n % uint32(k)); n /= uint32(k); return v }
|
pick := func(k int) int { v := int(n % uint32(k)); n /= uint32(k); return v }
|
||||||
g.Rect(x, 0, 16*sc, 16*sc, backs[pick(len(backs))])
|
g.Rect(x, y, 16*sc, 16*sc, backs[pick(len(backs))])
|
||||||
g.Sprite(x, 0, head, 0, skins[pick(len(skins))], sc)
|
g.Sprite(x, y, head, 0, skins[pick(len(skins))], sc)
|
||||||
g.Sprite(x, 0, hair[pick(len(hair))], 0, colors[pick(len(colors))], sc)
|
g.Sprite(x, y, hair[pick(len(hair))], 0, colors[pick(len(colors))], sc)
|
||||||
g.Sprite(x, 8*sc, eyes[pick(len(eyes))], 0, pixel.Ink, sc)
|
g.Sprite(x, y+8*sc, eyes[pick(len(eyes))], 0, pixel.Ink, sc)
|
||||||
g.Sprite(x, 11*sc, mouths[pick(len(mouths))], 0, pixel.Ink, sc)
|
g.Sprite(x, y+11*sc, mouths[pick(len(mouths))], 0, pixel.Ink, sc)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AvatarGroupOptions struct {
|
type AvatarGroupOptions struct {
|
||||||
@@ -134,7 +134,7 @@ func AvatarGroup(c *ui.Context, names []string, o AvatarGroupOptions) *ui.Elemen
|
|||||||
return art(c, k, strings.Join(names, ", "), w, 16*sc, func(g pixel.Canvas) {
|
return art(c, k, strings.Join(names, ", "), w, 16*sc, func(g pixel.Canvas) {
|
||||||
for i, name := range shown {
|
for i, name := range shown {
|
||||||
x := i * step
|
x := i * step
|
||||||
drawFace(g, x, name, sc)
|
Face(g, x, 0, name, sc)
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
g.VLine(x, 0, 16*sc, pixel.Back)
|
g.VLine(x, 0, 16*sc, pixel.Back)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,3 +71,38 @@ func TestCodeDemosLogTheirActions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAccountDemosLogTheirActions(t *testing.T) {
|
||||||
|
quiet := map[string]bool{"NAME": true, "PASSWORD": true, "Login code": true, "Controls": true, "4/5": true, "type:player1": true, "type:hunter2": true}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
path string
|
||||||
|
steps []string
|
||||||
|
}{
|
||||||
|
{"/c/386-settingslist", []string{"Music"}}, {"/c/387-optioncycler", []string{"Text speed"}},
|
||||||
|
{"/c/388-keybindings", []string{"Controls", "key:A"}}, {"/c/391-charactercreator", []string{"HAIR"}},
|
||||||
|
{"/c/392-loginform", []string{"NAME", "type:player1", "PASSWORD", "type:hunter2", "key:Enter"}},
|
||||||
|
{"/c/393-passwordgrid", []string{"SYMBOLS"}}, {"/c/394-sessionlist", []string{"SIGN OUT Old phone"}},
|
||||||
|
{"/c/395-languagepicker", []string{"Deutsch"}}, {"/c/396-palettepicker", []string{"Palette"}},
|
||||||
|
{"/c/397-volumemixer", []string{"BGM"}}, {"/c/398-difficultyselect", []string{"Difficulty"}},
|
||||||
|
{"/c/399-accessibilityoptions", []string{"REDUCE MOTION"}}, {"/c/400-accountswitcher", []string{"P2, Brom"}},
|
||||||
|
{"/c/401-playerselect", []string{"Players"}}, {"/c/402-twofactor", []string{"Login code", "type:1"}},
|
||||||
|
{"/c/405-feedbackform", []string{"4/5", "SEND ▶"}},
|
||||||
|
} {
|
||||||
|
a, tt := open(tc.path)
|
||||||
|
slug := tc.path[3:]
|
||||||
|
for _, step := range tc.steps {
|
||||||
|
before := len(a.logs[slug])
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(step, "key:"):
|
||||||
|
tt.Key(0, letters[step[4:]])
|
||||||
|
case strings.HasPrefix(step, "type:"):
|
||||||
|
tt.Type(step[5:])
|
||||||
|
default:
|
||||||
|
click(t, tt, step)
|
||||||
|
}
|
||||||
|
if len(a.logs[slug]) <= before && !quiet[step] {
|
||||||
|
t.Errorf("%s: %s logged nothing: %q", slug, step, a.logs[slug])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -56,7 +56,7 @@ var Categories = []Category{
|
|||||||
{"tabletop", "Tabletop", "桌游", icon.Crown},
|
{"tabletop", "Tabletop", "桌游", icon.Crown},
|
||||||
}
|
}
|
||||||
|
|
||||||
var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries, chartEntries, plotEntries, gameEntries, rpgEntries, screenEntries, lootEntries, fxEntries, screenFxEntries, chatEntries, mailEntries, mediaEntries, studioEntries, codeEntries, debugEntries)
|
var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries, chartEntries, plotEntries, gameEntries, rpgEntries, screenEntries, lootEntries, fxEntries, screenFxEntries, chatEntries, mailEntries, mediaEntries, studioEntries, codeEntries, debugEntries, accountEntries, playerEntries)
|
||||||
|
|
||||||
func Entries() []Entry { return entries }
|
func Entries() []Entry { return entries }
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -44,7 +44,8 @@ func PinInput(c *ui.Context, text string, value *string, o PinInputOptions) PinI
|
|||||||
switch {
|
switch {
|
||||||
case ev.Kind == ui.InputText:
|
case ev.Kind == ui.InputText:
|
||||||
put(ev.Text)
|
put(ev.Text)
|
||||||
case ev.Kind == ui.InputCommand && ev.Text == "paste":
|
// Browsers paste by command, native hosts by Cmd+V.
|
||||||
|
case ev.Kind == ui.InputCommand && ev.Text == "paste", ev.Kind == ui.InputKeyDown && ev.Key == ui.KeyV && ev.Mods == ui.Cmd:
|
||||||
put(c.ReadClipboard())
|
put(c.ReadClipboard())
|
||||||
case ev.Kind == ui.InputKeyDown && ev.Key == ui.KeyBackspace && ev.Mods == 0:
|
case ev.Kind == ui.InputKeyDown && ev.Key == ui.KeyBackspace && ev.Mods == 0:
|
||||||
if len(*value) > 0 {
|
if len(*value) > 0 {
|
||||||
|
|||||||
@@ -126,6 +126,14 @@ func TestPinInputTypesBacksAndPastes(t *testing.T) {
|
|||||||
if pin != "1298" || complete != 1 {
|
if pin != "1298" || complete != 1 {
|
||||||
t.Fatalf("paste left %q, complete %d times", pin, complete)
|
t.Fatalf("paste left %q, complete %d times", pin, complete)
|
||||||
}
|
}
|
||||||
|
// Native hosts paste on Cmd+V, as a key.
|
||||||
|
tt.Key(0, ui.KeyBackspace)
|
||||||
|
tt.Key(0, ui.KeyBackspace)
|
||||||
|
tt.SetClipboard("55")
|
||||||
|
tt.Key(ui.Cmd, ui.KeyV)
|
||||||
|
if pin != "1255" || complete != 2 {
|
||||||
|
t.Fatalf("Cmd+V left %q, complete %d times", pin, complete)
|
||||||
|
}
|
||||||
if _, ok := tt.TextCaret(); !ok {
|
if _, ok := tt.TextCaret(); !ok {
|
||||||
t.Fatal("the boxes take no text from input methods")
|
t.Fatal("the boxes take no text from input methods")
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -193,7 +193,7 @@ func hanging(punct func(rune) bool) func(rune) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMicroFont(t *testing.T) {
|
func TestMicroFont(t *testing.T) {
|
||||||
set := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-#.:/%! "
|
set := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-#.:/%!? "
|
||||||
seen := map[[MicroHeight]uint8]rune{}
|
seen := map[[MicroHeight]uint8]rune{}
|
||||||
for _, r := range set {
|
for _, r := range set {
|
||||||
g := microGlyph(r)
|
g := microGlyph(r)
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ var messages = map[Locale]map[string]string{
|
|||||||
"prev_track": "PREVIOUS", "next_track": "NEXT", "play": "PLAY", "volume": "VOLUME", "rec": "REC", "playing": "PLAYING", "seek": "SEEK",
|
"prev_track": "PREVIOUS", "next_track": "NEXT", "play": "PLAY", "volume": "VOLUME", "rec": "REC", "playing": "PLAYING", "seek": "SEEK",
|
||||||
"copy": "COPY", "copied": "COPIED!", "command": "COMMAND", "breakpoint": "BREAKPOINT",
|
"copy": "COPY", "copied": "COPIED!", "command": "COMMAND", "breakpoint": "BREAKPOINT",
|
||||||
"tool": "TOOL", "pen": "PEN", "fill": "FILL", "erase": "ERASE", "pick": "PICK", "grid": "GRID", "color": "COLOR", "preview": "PREVIEW",
|
"tool": "TOOL", "pen": "PEN", "fill": "FILL", "erase": "ERASE", "pick": "PICK", "grid": "GRID", "color": "COLOR", "preview": "PREVIEW",
|
||||||
|
"press_key": "PRESS A KEY", "conflict": "CONFLICT", "name": "NAME", "password": "PASSWORD", "login": "LOGIN",
|
||||||
|
"login_failed": "WRONG NAME OR PASSWORD", "sign_out": "SIGN OUT", "this_device": "THIS DEVICE",
|
||||||
|
"easy": "EASY", "normal": "NORMAL", "hard": "HARD", "nightmare": "NIGHTMARE",
|
||||||
|
"reduce_motion": "REDUCE MOTION", "dot_size": "DOT SIZE", "free_slot": "OPEN",
|
||||||
|
"one_player": "1 PLAYER", "two_players": "2 PLAYERS", "version": "VERSION", "credits": "CREDITS",
|
||||||
|
"added": "ADDED", "fixed": "FIXED", "changed": "CHANGED", "hair": "HAIR", "eyes": "EYES", "outfit": "OUTFIT",
|
||||||
|
"hair_color": "HAIR COLOR", "outfit_color": "OUTFIT COLOR", "bgm": "BGM", "se": "SE", "voice": "VOICE",
|
||||||
|
"styles": "SPIKY BOB BALD", "looks": "DOTS WIDE SLEEPY", "outfits": "TUNIC ROBE ARMOR", "hues": "DARK GOLD RED VIOLET SKY", "time_left": "TIME LEFT", "in_use": "IN USE", "rating": "RATING", "message": "MESSAGE", "symbols": "SYMBOLS",
|
||||||
},
|
},
|
||||||
Zh: {
|
Zh: {
|
||||||
"ok": "确定", "cancel": "取消", "yes": "是", "no": "否", "close": "关闭",
|
"ok": "确定", "cancel": "取消", "yes": "是", "no": "否", "close": "关闭",
|
||||||
@@ -72,6 +80,14 @@ var messages = map[Locale]map[string]string{
|
|||||||
"prev_track": "上一首", "next_track": "下一首", "play": "播放", "volume": "音量", "rec": "录制", "playing": "正在播放", "seek": "进度",
|
"prev_track": "上一首", "next_track": "下一首", "play": "播放", "volume": "音量", "rec": "录制", "playing": "正在播放", "seek": "进度",
|
||||||
"copy": "复制", "copied": "已复制!", "command": "命令", "breakpoint": "断点",
|
"copy": "复制", "copied": "已复制!", "command": "命令", "breakpoint": "断点",
|
||||||
"tool": "工具", "pen": "铅笔", "fill": "填充", "erase": "橡皮", "pick": "吸管", "grid": "网格", "color": "颜色", "preview": "预览",
|
"tool": "工具", "pen": "铅笔", "fill": "填充", "erase": "橡皮", "pick": "吸管", "grid": "网格", "color": "颜色", "preview": "预览",
|
||||||
|
"press_key": "请按键", "conflict": "冲突", "name": "名字", "password": "密码", "login": "登录",
|
||||||
|
"login_failed": "名字或密码错误", "sign_out": "登出", "this_device": "本机",
|
||||||
|
"easy": "简单", "normal": "普通", "hard": "困难", "nightmare": "噩梦",
|
||||||
|
"reduce_motion": "减少动态", "dot_size": "像素大小", "free_slot": "空位",
|
||||||
|
"one_player": "单人", "two_players": "双人", "version": "版本", "credits": "制作人员",
|
||||||
|
"added": "新增", "fixed": "修复", "changed": "改动", "hair": "发型", "eyes": "眼睛", "outfit": "服装",
|
||||||
|
"hair_color": "发色", "outfit_color": "衣色", "bgm": "音乐", "se": "音效", "voice": "语音",
|
||||||
|
"styles": "刺头 短发 光头", "looks": "圆眼 大眼 睡眼", "outfits": "短衣 长袍 铠甲", "hues": "墨 金 红 堇 蓝", "time_left": "剩余时间", "in_use": "使用中", "rating": "评分", "message": "留言", "symbols": "符号",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ var micro = microFont(map[rune]string{
|
|||||||
'+': "...|.#.|###|.#.|...",
|
'+': "...|.#.|###|.#.|...",
|
||||||
'-': "...|...|###|...|...",
|
'-': "...|...|###|...|...",
|
||||||
'#': "#.#|###|#.#|###|#.#",
|
'#': "#.#|###|#.#|###|#.#",
|
||||||
|
'?': "###|..#|.##|...|.#.",
|
||||||
'.': "...|...|...|...|.#.",
|
'.': "...|...|...|...|.#.",
|
||||||
':': "...|.#.|...|.#.|...",
|
':': "...|.#.|...|.#.|...",
|
||||||
'/': "..#|..#|.#.|#..|#..",
|
'/': "..#|..#|.#.|#..|#..",
|
||||||
|
|||||||
+3
-1
@@ -24,6 +24,8 @@ type Settings struct {
|
|||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
// Inert controls take no input, as under a busy veil.
|
// Inert controls take no input, as under a busy veil.
|
||||||
Inert bool
|
Inert bool
|
||||||
|
// Reduce stills motion, as the system preference does.
|
||||||
|
Reduce bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kit is one frame's settings, captured by components.
|
// Kit is one frame's settings, captured by components.
|
||||||
@@ -92,7 +94,7 @@ func K(c *ui.Context) Kit {
|
|||||||
if s.Now != nil {
|
if s.Now != nil {
|
||||||
now = s.Now()
|
now = s.Now()
|
||||||
}
|
}
|
||||||
return Kit{pal: s.Palette, dot: float32(s.Dot), locale: s.Locale, now: now, clock: s.Now, reduce: c.Preferences().ReduceMotion}
|
return Kit{pal: s.Palette, dot: float32(s.Dot), locale: s.Locale, now: now, clock: s.Now, reduce: c.Preferences().ReduceMotion || s.Reduce}
|
||||||
}
|
}
|
||||||
|
|
||||||
func frameOf(c *ui.Context) *frame {
|
func frameOf(c *ui.Context) *frame {
|
||||||
|
|||||||
@@ -189,3 +189,15 @@ func TestReduceMotionFollowsPreferences(t *testing.T) {
|
|||||||
t.Fatal("Reduce off after ReduceMotion preference")
|
t.Fatal("Reduce off after ReduceMotion preference")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReduceSettingStillsMotionInItsScope(t *testing.T) {
|
||||||
|
var outer, inner pixel.Kit
|
||||||
|
ui.NewTester(func(c *ui.Context) {
|
||||||
|
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay})
|
||||||
|
outer = pixel.K(c)
|
||||||
|
pixel.Scope(c, pixel.Settings{Palette: pixel.SpringDay, Reduce: true}, func() { inner = pixel.K(c) })
|
||||||
|
}, 10, 10)
|
||||||
|
if outer.Reduce() || !inner.Reduce() {
|
||||||
|
t.Fatalf("Reduce: outside %v, inside %v", outer.Reduce(), inner.Reduce())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user