One component per file; GAME OVER offers CONTINUE

PRD G1 asks for one component per file. Eight files held two or
three; their eleven extra components move to files of their own,
helpers stay where both sides use them, and an archtest now fails
any file that holds two.

PRD 4.4 and the request say an error always offers CONTINUE.
ErrorState's button read RETRY; it now reads CONTINUE, under the
CONTINUE? countdown, on the crash screen and the lost page alike.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-12 16:50:17 -04:00
1 parent edee517e8d
commit 1f3d38110e
25 files changed
+439 -359

No files matched your search

+1 -1
View File
@@ -54,7 +54,7 @@ Imports point down only. Same-tier imports are forbidden. `internal/archtest` en
## Component rules
- Signatures and results: PRD §5.1, held by archtest `TestComponentsTakeTheirOptions`: `Name` takes `NameOptions` and returns `NameResult`. Zero options give the default look; required values are parameters. Sizes in options are dots.
- Signatures and results: PRD §5.1, held by archtest `TestComponentsTakeTheirOptions`: `Name` takes `NameOptions` and returns `NameResult`, one component per file. Zero options give the default look; required values are parameters. Sizes in options are dots.
- `k := pixel.K(c)` once per component; layout sizes through `k.D(n)`. Integer options default through `pixel.Or`.
- Custom-drawn controls end their Draw with `g.Finish(st)`. Grids of cells moved by arrows use `pixel.Pad`; custom-drawn sliders use `input.SlideBase`; every clock runs at S5's 4–15 fps (`checkFPS` panics otherwise): a blink is `g.Step(4)%4 < 2`, a timer counts quarter seconds, and a view that changes on whole seconds asks `k.At`; one-shot timing steps with `k.Since`, which holds at zero when the clock steps back; a stored start is read through `k.Elapsed(&t)`, which moves it to now when the clock steps back; drawing from time quantizes it to whole frames (`Truncate(time.Second / 15)`); a decorative one-shot on a cue steps with `k.Shot`, which Reduce Motion spends whole; a view that changes at a known moment asks `k.At`; a two-way slide (bars, fades, wipes) steps with `k.Toward`, which turns from where it is.
- A one-shot effect inside a lasting view starts when its cue changes (a `Cue` option read through `k.Cue`, or a value that rises), never on its first frame. A state screen (`ErrorState`, `SuccessState`) starts when it appears. Decorative motion jumps to its end under Reduce Motion; countdowns and loaders keep time.
-37
View File
@@ -110,40 +110,3 @@ func Face(g pixel.Canvas, x, y int, name string, sc int) {
g.Sprite(x, y+8*sc, eyes[pick(len(eyes))], 0, pixel.Ink, sc)
g.Sprite(x, y+11*sc, mouths[pick(len(mouths))], 0, pixel.Ink, sc)
}
type AvatarGroupOptions struct {
// Max faces shown before +N; zero means 4.
Max int
Scale int
}
// AvatarGroup overlaps faces and counts the rest in a chip.
func AvatarGroup(c *ui.Context, names []string, o AvatarGroupOptions) *ui.Element {
k, sc := pixel.K(c), scale(o.Scale)
shown := names[:min(len(names), pixel.Or("AvatarGroup Max", o.Max, 4))]
rest := len(names) - len(shown)
more := ""
if rest > 0 {
more = "+" + itoa(rest)
}
step := 12 * sc
w := max(0, len(shown)-1)*step + 16*sc
if more != "" {
w += pixel.MicroWidth(more) + 8
}
return art(c, k, strings.Join(names, ", "), w, 16*sc, func(g pixel.Canvas) {
for i, name := range shown {
x := i * step
Face(g, x, 0, name, sc)
if i > 0 {
g.VLine(x, 0, 16*sc, pixel.Back)
}
}
if more != "" {
x := len(shown)*step + 4*sc
cw := pixel.MicroWidth(more) + 4
g.Frame(x, (16*sc-9)/2, cw, 9, pixel.Notched, pixel.Face)
g.Micro(x+2, (16*sc-9)/2+2, more, pixel.Ink, 1)
}
})
}
+46
View File
@@ -0,0 +1,46 @@
package display
import (
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type AvatarGroupOptions struct {
// Max faces shown before +N; zero means 4.
Max int
Scale int
}
// AvatarGroup overlaps faces and counts the rest in a chip.
func AvatarGroup(c *ui.Context, names []string, o AvatarGroupOptions) *ui.Element {
k, sc := pixel.K(c), scale(o.Scale)
shown := names[:min(len(names), pixel.Or("AvatarGroup Max", o.Max, 4))]
rest := len(names) - len(shown)
more := ""
if rest > 0 {
more = "+" + itoa(rest)
}
step := 12 * sc
w := max(0, len(shown)-1)*step + 16*sc
if more != "" {
w += pixel.MicroWidth(more) + 8
}
return art(c, k, strings.Join(names, ", "), w, 16*sc, func(g pixel.Canvas) {
for i, name := range shown {
x := i * step
Face(g, x, 0, name, sc)
if i > 0 {
g.VLine(x, 0, 16*sc, pixel.Back)
}
}
if more != "" {
x := len(shown)*step + 4*sc
cw := pixel.MicroWidth(more) + 4
g.Frame(x, (16*sc-9)/2, cw, 9, pixel.Notched, pixel.Face)
g.Micro(x+2, (16*sc-9)/2+2, more, pixel.Ink, 1)
}
})
}
@@ -6,24 +6,6 @@ import (
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type DotsLoaderOptions struct{}
// DotsLoader bounces three dots in turn.
func DotsLoader(c *ui.Context, text string, o DotsLoaderOptions) *ui.Element {
k := pixel.K(c)
return ui.Box(c).Role(ui.RoleProgress).Label(text).Size(k.D(18), k.D(8)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
up := g.Busy(6) % 4
for i := range 3 {
y, ink := 4, pixel.Faint
if i == up {
y, ink = 0, pixel.Accent
}
g.Rect(i*7, y, 4, 4, ink)
}
})
}
type BarsLoaderOptions struct{}
// heights rise and fall; each bar runs a step behind.
+25
View File
@@ -0,0 +1,25 @@
package feedback
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type DotsLoaderOptions struct{}
// DotsLoader bounces three dots in turn.
func DotsLoader(c *ui.Context, text string, o DotsLoaderOptions) *ui.Element {
k := pixel.K(c)
return ui.Box(c).Role(ui.RoleProgress).Label(text).Size(k.D(18), k.D(8)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
up := g.Busy(6) % 4
for i := range 3 {
y, ink := 4, pixel.Faint
if i == up {
y, ink = 0, pixel.Accent
}
g.Rect(i*7, y, 4, 4, ink)
}
})
}
+51
View File
@@ -0,0 +1,51 @@
package feedback
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
var openChest = pixel.MustSprite(`
..eeeeeeeeeeee..
.esssssssssssse.
.elllllllllllle.
..eeeeeeeeeeee..
................
eeeeeeeeeeeeeeee
ewwwwwwwwwwwwwwe
ewwwwwwwwwwwwwwe
eeeeeeeyyeeeeeee
essssssyysssssse
esssssssssssssse
eeeeeeeeeeeeeeee
`)
type EmptyStateOptions struct {
// Action labels a button; Acted reports it.
Action string
Disabled bool
}
type EmptyStateResult struct {
Element *ui.Element
Acted bool
}
// EmptyState shows NO ITEMS over an empty chest.
func EmptyState(c *ui.Context, message string, o EmptyStateOptions) EmptyStateResult {
k := pixel.K(c)
res := EmptyStateResult{}
res.Element = ui.Column(c).Gap(k.D(6)).Padding(k.D(12)).AlignItems(ui.Center).Role(ui.RoleStatus).Label(k.Msg("empty")).Children(func() {
display.Sprite(c, openChest, display.SpriteOptions{Scale: 2})
text.ShadowText(c, k.Msg("empty"), text.ShadowTextOptions{Slot: pixel.Faint, Scale: 2})
pixel.Text(c, message, pixel.TextOptions{Slot: pixel.Faint})
if o.Action != "" {
res.Acted = input.Button(c, o.Action, input.ButtonOptions{Variant: input.Primary, Disabled: o.Disabled}).Clicked
}
})
return res
}
+49
View File
@@ -0,0 +1,49 @@
package feedback
import (
"log/slog"
"strconv"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
type ErrorStateOptions struct {
Disabled bool
}
type ErrorStateResult struct {
Element *ui.Element
Continue bool
}
type startKey struct{}
// ErrorState is GAME OVER, a CONTINUE? countdown, and CONTINUE.
func ErrorState(c *ui.Context, message string, o ErrorStateOptions) ErrorStateResult {
k := pixel.K(c)
col := ui.Column(c).Gap(k.D(8)).Padding(k.D(16)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(k.Msg("game_over"))
start := ui.Local(col, startKey{}, func() time.Time { return k.Now() })
left := max(0, 9-max(0, int(k.Now().Sub(*start)/time.Second)))
if left > 0 {
k.Tick(c, 10)
}
res := ErrorStateResult{Element: col}
col.Children(func() {
text.ShadowText(c, k.Msg("game_over"), text.ShadowTextOptions{Slot: pixel.Bad, Scale: 3})
pixel.Text(c, message, pixel.TextOptions{Slot: pixel.Faint, Wrap: true, MaxLines: 6}).Width(k.D(min(pixel.Measure(message), 280)))
display.Icon(c, icon.Skull, display.IconOptions{Scale: 3})
text.ShadowText(c, k.Msg("continue")+" "+strconv.Itoa(left), text.ShadowTextOptions{Slot: pixel.Accent, Scale: 2})
res.Continue = input.Button(c, k.Msg("continue_game"), input.ButtonOptions{Variant: input.Primary, Disabled: o.Disabled}).Clicked
})
if res.Continue {
slog.Debug("8bit: continue", "left", left)
}
return res
}
-127
View File
@@ -1,127 +0,0 @@
package feedback
import (
"log/slog"
"strconv"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
var openChest = pixel.MustSprite(`
..eeeeeeeeeeee..
.esssssssssssse.
.elllllllllllle.
..eeeeeeeeeeee..
................
eeeeeeeeeeeeeeee
ewwwwwwwwwwwwwwe
ewwwwwwwwwwwwwwe
eeeeeeeyyeeeeeee
essssssyysssssse
esssssssssssssse
eeeeeeeeeeeeeeee
`)
type EmptyStateOptions struct {
// Action labels a button; Acted reports it.
Action string
Disabled bool
}
type EmptyStateResult struct {
Element *ui.Element
Acted bool
}
// EmptyState shows NO ITEMS over an empty chest.
func EmptyState(c *ui.Context, message string, o EmptyStateOptions) EmptyStateResult {
k := pixel.K(c)
res := EmptyStateResult{}
res.Element = ui.Column(c).Gap(k.D(6)).Padding(k.D(12)).AlignItems(ui.Center).Role(ui.RoleStatus).Label(k.Msg("empty")).Children(func() {
display.Sprite(c, openChest, display.SpriteOptions{Scale: 2})
text.ShadowText(c, k.Msg("empty"), text.ShadowTextOptions{Slot: pixel.Faint, Scale: 2})
pixel.Text(c, message, pixel.TextOptions{Slot: pixel.Faint})
if o.Action != "" {
res.Acted = input.Button(c, o.Action, input.ButtonOptions{Variant: input.Primary, Disabled: o.Disabled}).Clicked
}
})
return res
}
type ErrorStateOptions struct {
Disabled bool
}
type ErrorStateResult struct {
Element *ui.Element
Continue bool
}
type startKey struct{}
// ErrorState is GAME OVER with a CONTINUE? countdown and RETRY.
func ErrorState(c *ui.Context, message string, o ErrorStateOptions) ErrorStateResult {
k := pixel.K(c)
col := ui.Column(c).Gap(k.D(8)).Padding(k.D(16)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(k.Msg("game_over"))
start := ui.Local(col, startKey{}, func() time.Time { return k.Now() })
left := max(0, 9-max(0, int(k.Now().Sub(*start)/time.Second)))
if left > 0 {
k.Tick(c, 10)
}
res := ErrorStateResult{Element: col}
col.Children(func() {
text.ShadowText(c, k.Msg("game_over"), text.ShadowTextOptions{Slot: pixel.Bad, Scale: 3})
pixel.Text(c, message, pixel.TextOptions{Slot: pixel.Faint, Wrap: true, MaxLines: 6}).Width(k.D(min(pixel.Measure(message), 280)))
display.Icon(c, icon.Skull, display.IconOptions{Scale: 3})
text.ShadowText(c, k.Msg("continue")+" "+strconv.Itoa(left), text.ShadowTextOptions{Slot: pixel.Accent, Scale: 2})
res.Continue = input.Button(c, k.Msg("retry"), input.ButtonOptions{Variant: input.Primary, Disabled: o.Disabled}).Clicked
})
if res.Continue {
slog.Debug("8bit: continue", "left", left)
}
return res
}
type SuccessStateOptions struct {
// Stars pop in one by one; zero means 3.
Stars int
}
// SuccessState is STAGE CLEAR! with stars popping in steps.
func SuccessState(c *ui.Context, message string, o SuccessStateOptions) *ui.Element {
k := pixel.K(c)
n := pixel.Or("SuccessState Stars", o.Stars, 3)
col := ui.Column(c).Gap(k.D(8)).Padding(k.D(16)).AlignItems(ui.Center).Role(ui.RoleStatus).Label(k.Msg("stage_clear"))
start := ui.Local(col, startKey{}, func() time.Time { return k.Now() })
// Stars come four a second; Reduce Motion shows them all.
shown := n + 1
if !k.Reduce() {
shown = min(n+1, max(0, int(k.Now().Sub(*start)*4/time.Second)))
if shown <= n {
k.Tick(c, 8)
}
}
return col.Children(func() {
text.ShadowText(c, k.Msg("stage_clear"), text.ShadowTextOptions{Slot: pixel.Good, Scale: 3})
w := icon.Star.W*2 + 4
ui.Box(c).Size(k.D(n*w), k.D(icon.Star.H*2+4)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i := range min(shown, n) {
if i == shown-1 {
for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} {
g.Sprite(i*w+2+d[0], 2+d[1], icon.Star, 0, pixel.Lit, 2)
}
}
g.Sprite(i*w+2, 2, icon.Star, 0, pixel.Warn, 2)
}
})
pixel.Text(c, message, pixel.TextOptions{})
})
}
+3 -3
View File
@@ -70,9 +70,9 @@ func TestErrorStateCountsDownAndContinues(t *testing.T) {
find(t, tt, "CONTINUE? 6")
wait(tt, clk, 10*time.Second)
find(t, tt, "CONTINUE? 0")
click(t, tt, "RETRY")
click(t, tt, "CONTINUE")
if cont != 1 {
t.Fatal("RETRY did not report Continue")
t.Fatal("CONTINUE did not report Continue")
}
}
@@ -212,7 +212,7 @@ func TestDisabledFeedbackButtonsDoNothing(t *testing.T) {
on(feedback.ContinuePrompt(c, feedback.ContinuePromptOptions{Disabled: true}).Yes)
on(feedback.Toasts(c, &q, feedback.ToastsOptions{Disabled: true}).Acted != nil)
})
for _, l := range []string{"CLOSE", "GO", "SHOP", "RETRY", "YES", "UNDO"} {
for _, l := range []string{"CLOSE", "GO", "SHOP", "CONTINUE", "YES", "UNDO"} {
click(t, tt, l)
}
if events != 0 {
+48
View File
@@ -0,0 +1,48 @@
package feedback
import (
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
)
type SuccessStateOptions struct {
// Stars pop in one by one; zero means 3.
Stars int
}
// SuccessState is STAGE CLEAR! with stars popping in steps.
func SuccessState(c *ui.Context, message string, o SuccessStateOptions) *ui.Element {
k := pixel.K(c)
n := pixel.Or("SuccessState Stars", o.Stars, 3)
col := ui.Column(c).Gap(k.D(8)).Padding(k.D(16)).AlignItems(ui.Center).Role(ui.RoleStatus).Label(k.Msg("stage_clear"))
start := ui.Local(col, startKey{}, func() time.Time { return k.Now() })
// Stars come four a second; Reduce Motion shows them all.
shown := n + 1
if !k.Reduce() {
shown = min(n+1, max(0, int(k.Now().Sub(*start)*4/time.Second)))
if shown <= n {
k.Tick(c, 8)
}
}
return col.Children(func() {
text.ShadowText(c, k.Msg("stage_clear"), text.ShadowTextOptions{Slot: pixel.Good, Scale: 3})
w := icon.Star.W*2 + 4
ui.Box(c).Size(k.D(n*w), k.D(icon.Star.H*2+4)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i := range min(shown, n) {
if i == shown-1 {
for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} {
g.Sprite(i*w+2+d[0], 2+d[1], icon.Star, 0, pixel.Lit, 2)
}
}
g.Sprite(i*w+2, 2, icon.Star, 0, pixel.Warn, 2)
}
})
pixel.Text(c, message, pixel.TextOptions{})
})
}
+2 -2
View File
@@ -123,10 +123,10 @@ func TestLostPageAndLocale(t *testing.T) {
t.Fatalf("lost page texts %q", tt.Texts())
}
click(t, tt, "language")
if a.locale != pixel.Zh || !tt.HasText("重试") {
if _, ok := tt.Find("继续"); a.locale != pixel.Zh || !ok {
t.Fatal("language toggle did not switch built-in copy")
}
click(t, tt, "重试")
click(t, tt, "继续")
if a.Path() != "/" {
t.Fatalf("continue went to %q", a.Path())
}
+1 -1
View File
@@ -20,7 +20,7 @@ var feedbackEntries = []Entry{
{110, "Alert", "提示框", "feedback", "A framed callout: info, success, warning or danger, with an optional ×.", demoAlert},
{111, "Banner", "横幅", "feedback", "A full-width strip with actions and a ×.", demoBanner},
{112, "EmptyState", "空状态", "feedback", "NO ITEMS over an empty chest, and something to do about it.", demoEmptyState},
{113, "ErrorState", "错误态", "feedback", "GAME OVER, a CONTINUE? countdown and RETRY. The gallery's own errors use it.", demoErrorState},
{113, "ErrorState", "错误态", "feedback", "GAME OVER, a CONTINUE? countdown, and CONTINUE. The gallery's own errors use it.", demoErrorState},
{114, "SuccessState", "通关", "feedback", "STAGE CLEAR! The stars pop in one by one.", demoSuccessState},
{115, "LoadingScreen", "加载画面", "feedback", "NOW LOADING with stepping dots and a tip every four seconds.", demoLoadingScreen},
{116, "DotsLoader", "点点加载", "feedback", "Three dots hopping in turn.", demoDotsLoader},
-16
View File
@@ -76,19 +76,3 @@ func Button(c *ui.Context, text string, o ButtonOptions) ButtonResult {
}
return res
}
type IconButtonOptions struct {
Variant Variant
Size Size
Disabled bool
}
type IconButtonResult = ButtonResult
// IconButton is a square button named for assistive technology.
func IconButton(c *ui.Context, icon *pixel.Sprite, name string, o IconButtonOptions) IconButtonResult {
if name == "" {
panic("input: IconButton needs a name")
}
return Button(c, "", ButtonOptions{Variant: o.Variant, Size: o.Size, Icon: icon, Disabled: o.Disabled, Label: name})
}
+23
View File
@@ -0,0 +1,23 @@
package input
import (
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type IconButtonOptions struct {
Variant Variant
Size Size
Disabled bool
}
type IconButtonResult = ButtonResult
// IconButton is a square button named for assistive technology.
func IconButton(c *ui.Context, icon *pixel.Sprite, name string, o IconButtonOptions) IconButtonResult {
if name == "" {
panic("input: IconButton needs a name")
}
return Button(c, "", ButtonOptions{Variant: o.Variant, Size: o.Size, Icon: icon, Disabled: o.Disabled, Label: name})
}
+6 -1
View File
@@ -274,19 +274,24 @@ func TestWebScripts(t *testing.T) {
}
}
// TestComponentsTakeTheirOptions holds PRD 5.1's shape: Name takes NameOptions.
// TestComponentsTakeTheirOptions holds PRD 5.1's shape, one per file.
func TestComponentsTakeTheirOptions(t *testing.T) {
for _, f := range files(t) {
tier, ok := tiers[f.pkg]
if !ok || tier < 2 || tier > 6 || strings.HasSuffix(f.path, "_test.go") {
continue
}
var found []string
for _, d := range f.ast.Decls {
fn, ok := d.(*ast.FuncDecl)
// Undrawn bases, as MyGo's own, are parts, not components.
if !ok || fn.Recv != nil || !fn.Name.IsExported() || strings.HasSuffix(fn.Name.Name, "Base") || len(fn.Type.Params.List) == 0 || !isContext(fn.Type.Params.List[0].Type) {
continue
}
// PRD G1: one component per file.
if found = append(found, fn.Name.Name); len(found) > 1 {
t.Errorf("%s holds %v; one component per file", f.path, found)
}
want := fn.Name.Name + "Options"
if !slices.ContainsFunc(fn.Type.Params.List, func(p *ast.Field) bool { return typeName(p.Type) == want }) {
t.Errorf("%s: %s takes no %s", f.path, fn.Name.Name, want)
+43
View File
@@ -0,0 +1,43 @@
package navigation
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type BackForwardOptions struct {
Disabled bool
}
type BackForwardResult struct {
Element *ui.Element
// Moved reports a step through the history.
Moved bool
}
// BackForward walks a router's history with ◀ and ▶.
func BackForward(c *ui.Context, r *ui.Router, o BackForwardOptions) BackForwardResult {
k := pixel.K(c)
res := BackForwardResult{}
res.Element = ui.Row(c).Gap(k.D(2)).FocusGroup(ui.Horizontal).Role(ui.RoleToolbar).Label(k.Msg("history")).Children(func() {
back := input.IconButtonOptions{Size: input.Small, Disabled: o.Disabled || !r.CanGoBack()}
if input.IconButton(c, icon.ChevronLeft, k.Msg("back"), back).Clicked {
r.Back()
res.Moved = true
}
forth := input.IconButtonOptions{Size: input.Small, Disabled: o.Disabled || !r.CanGoForward()}
if input.IconButton(c, icon.ChevronRight, k.Msg("forward"), forth).Clicked {
r.Forward()
res.Moved = true
}
})
if res.Moved {
slog.Debug("8bit: history", "path", r.Path())
}
return res
}
+55
View File
@@ -0,0 +1,55 @@
package navigation
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type BottomNavOptions struct {
Disabled bool
}
type BottomNavResult struct {
Element *ui.Element
Changed bool
}
// BottomNav is a bar of labeled icons for narrow screens.
func BottomNav(c *ui.Context, text string, items []NavItem, selected *int, o BottomNavOptions) BottomNavResult {
k := pixel.K(c)
res := BottomNavResult{}
// The bases turn before they learn they are off.
v := *selected
res.Element = ui.RadioGroup(c, func() {
for i, it := range items {
shell, _, st := pixel.Shell(c, func() *ui.Element {
return ui.RadioBase(c, &v, i).Label(it.Label).Disabled(o.Disabled)
})
w := max(pixel.Measure(it.Label), 12) + 12
shell.Size(k.D(w), k.D(16+pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
ink := pixel.Faint
if i == *selected {
ink = pixel.Accent
g.HLine(2, 0, g.W-4, pixel.Accent)
} else if st.Hover {
ink = pixel.Ink
}
g.Sprite((g.W-it.Icon.W)/2, 2, it.Icon, 0, ink, 1)
g.Text((g.W-pixel.Measure(it.Label))/2, 14, it.Label, ink, 1)
g.Finish(st)
})
}
}).Row().Gap(k.D(4)).Padding(k.D(2), k.D(6)).Label(text).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Rect(0, 0, g.W, g.H, pixel.Face)
g.HLine(0, 0, g.W, pixel.Edge)
})
if res.Changed = keep(c, v, selected, o.Disabled); res.Changed {
slog.Debug("8bit: bottom nav", "label", text, "selected", *selected)
}
return res
}
+63
View File
@@ -0,0 +1,63 @@
package navigation
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
type PageDotsOptions struct {
Disabled bool
}
type PageDotsResult struct {
Element *ui.Element
Changed bool
}
// PageDots shows a dot per page; clicks jump, arrows move.
func PageDots(c *ui.Context, text string, page *int, total int, o PageDotsOptions) PageDotsResult {
k := pixel.K(c)
check(*page, total)
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleSlider).Label(text).Description(fmt.Sprintf("%d/%d", *page, total)).Disabled(o.Disabled)
})
old := *page
for _, m := range []struct {
key ui.Key
to func() int
}{
{ui.KeyLeft, func() int { return *page - 1 }}, {ui.KeyRight, func() int { return *page + 1 }},
{ui.KeyHome, func() int { return 1 }}, {ui.KeyEnd, func() int { return total }},
} {
if pixel.Shortcut(base, 0, m.key) {
*page = max(1, min(total, m.to()))
}
}
// Taking Enter and Space keeps keys from clicking.
pixel.Shortcut(base, 0, ui.KeyEnter)
pixel.Shortcut(base, 0, ui.KeySpace)
if base.Clicked() {
x, _, _ := base.PointerPosition()
*page = max(1, min(total, int(x/k.Dot())/8+1))
}
shell.Size(k.D(total*8), k.D(8)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i := range total {
if i+1 == *page {
g.Rect(i*8+1, 1, 6, 6, pixel.Accent)
} else {
g.Rect(i*8+2, 2, 4, 4, pixel.Shade)
}
}
g.Finish(st)
})
res := PageDotsResult{Element: shell, Changed: *page != old}
if res.Changed {
slog.Debug("8bit: page dots", "label", text, "page", *page)
}
return res
}
-53
View File
@@ -84,56 +84,3 @@ func check(page, total int) {
panic(fmt.Sprintf("navigation: page %d of %d", page, total))
}
}
type PageDotsOptions struct {
Disabled bool
}
type PageDotsResult struct {
Element *ui.Element
Changed bool
}
// PageDots shows a dot per page; clicks jump, arrows move.
func PageDots(c *ui.Context, text string, page *int, total int, o PageDotsOptions) PageDotsResult {
k := pixel.K(c)
check(*page, total)
shell, base, st := pixel.Shell(c, func() *ui.Element {
return ui.Box(c).Focusable().Role(ui.RoleSlider).Label(text).Description(fmt.Sprintf("%d/%d", *page, total)).Disabled(o.Disabled)
})
old := *page
for _, m := range []struct {
key ui.Key
to func() int
}{
{ui.KeyLeft, func() int { return *page - 1 }}, {ui.KeyRight, func() int { return *page + 1 }},
{ui.KeyHome, func() int { return 1 }}, {ui.KeyEnd, func() int { return total }},
} {
if pixel.Shortcut(base, 0, m.key) {
*page = max(1, min(total, m.to()))
}
}
// Taking Enter and Space keeps keys from clicking.
pixel.Shortcut(base, 0, ui.KeyEnter)
pixel.Shortcut(base, 0, ui.KeySpace)
if base.Clicked() {
x, _, _ := base.PointerPosition()
*page = max(1, min(total, int(x/k.Dot())/8+1))
}
shell.Size(k.D(total*8), k.D(8)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
for i := range total {
if i+1 == *page {
g.Rect(i*8+1, 1, 6, 6, pixel.Accent)
} else {
g.Rect(i*8+2, 2, 4, 4, pixel.Shade)
}
}
g.Finish(st)
})
res := PageDotsResult{Element: shell, Changed: *page != old}
if res.Changed {
slog.Debug("8bit: page dots", "label", text, "page", *page)
}
return res
}
-46
View File
@@ -73,49 +73,3 @@ func tip(c *ui.Context, k pixel.Kit, at ui.Rect, s string) {
})
})
}
type BottomNavOptions struct {
Disabled bool
}
type BottomNavResult struct {
Element *ui.Element
Changed bool
}
// BottomNav is a bar of labeled icons for narrow screens.
func BottomNav(c *ui.Context, text string, items []NavItem, selected *int, o BottomNavOptions) BottomNavResult {
k := pixel.K(c)
res := BottomNavResult{}
// The bases turn before they learn they are off.
v := *selected
res.Element = ui.RadioGroup(c, func() {
for i, it := range items {
shell, _, st := pixel.Shell(c, func() *ui.Element {
return ui.RadioBase(c, &v, i).Label(it.Label).Disabled(o.Disabled)
})
w := max(pixel.Measure(it.Label), 12) + 12
shell.Size(k.D(w), k.D(16+pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
ink := pixel.Faint
if i == *selected {
ink = pixel.Accent
g.HLine(2, 0, g.W-4, pixel.Accent)
} else if st.Hover {
ink = pixel.Ink
}
g.Sprite((g.W-it.Icon.W)/2, 2, it.Icon, 0, ink, 1)
g.Text((g.W-pixel.Measure(it.Label))/2, 14, it.Label, ink, 1)
g.Finish(st)
})
}
}).Row().Gap(k.D(4)).Padding(k.D(2), k.D(6)).Label(text).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
g.Rect(0, 0, g.W, g.H, pixel.Face)
g.HLine(0, 0, g.W, pixel.Edge)
})
if res.Changed = keep(c, v, selected, o.Disabled); res.Changed {
slog.Debug("8bit: bottom nav", "label", text, "selected", *selected)
}
return res
}
-14
View File
@@ -23,20 +23,6 @@ func Tabs(c *ui.Context, text string, labels []string, selected *int, o TabsOpti
return tabs(c, text, labels, selected, false, o, panel)
}
type VerticalTabsOptions struct {
Disabled bool
}
type VerticalTabsResult struct {
Element *ui.Element
Changed bool
}
// VerticalTabs are tabs down the panel's left edge.
func VerticalTabs(c *ui.Context, text string, labels []string, selected *int, o VerticalTabsOptions, panel func()) VerticalTabsResult {
return VerticalTabsResult(tabs(c, text, labels, selected, true, TabsOptions(o), panel))
}
// tabs lays tabs beside a panel; side means left.
func tabs(c *ui.Context, text string, labels []string, selected *int, side bool, o TabsOptions, panel func()) TabsResult {
k := pixel.K(c)
-36
View File
@@ -1,12 +1,8 @@
package navigation
import (
"log/slog"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
@@ -20,35 +16,3 @@ func Toolbar(c *ui.Context, text string, o ToolbarOptions, content func()) *ui.E
g.Frame(0, 0, g.W, g.H, pixel.Raised, pixel.Face)
}).Children(content)
}
type BackForwardOptions struct {
Disabled bool
}
type BackForwardResult struct {
Element *ui.Element
// Moved reports a step through the history.
Moved bool
}
// BackForward walks a router's history with ◀ and ▶.
func BackForward(c *ui.Context, r *ui.Router, o BackForwardOptions) BackForwardResult {
k := pixel.K(c)
res := BackForwardResult{}
res.Element = ui.Row(c).Gap(k.D(2)).FocusGroup(ui.Horizontal).Role(ui.RoleToolbar).Label(k.Msg("history")).Children(func() {
back := input.IconButtonOptions{Size: input.Small, Disabled: o.Disabled || !r.CanGoBack()}
if input.IconButton(c, icon.ChevronLeft, k.Msg("back"), back).Clicked {
r.Back()
res.Moved = true
}
forth := input.IconButtonOptions{Size: input.Small, Disabled: o.Disabled || !r.CanGoForward()}
if input.IconButton(c, icon.ChevronRight, k.Msg("forward"), forth).Clicked {
r.Forward()
res.Moved = true
}
})
if res.Moved {
slog.Debug("8bit: history", "path", r.Path())
}
return res
}
+19
View File
@@ -0,0 +1,19 @@
package navigation
import (
"github.com/egoist/mygo/ui"
)
type VerticalTabsOptions struct {
Disabled bool
}
type VerticalTabsResult struct {
Element *ui.Element
Changed bool
}
// VerticalTabs are tabs down the panel's left edge.
func VerticalTabs(c *ui.Context, text string, labels []string, selected *int, o VerticalTabsOptions, panel func()) VerticalTabsResult {
return VerticalTabsResult(tabs(c, text, labels, selected, true, TabsOptions(o), panel))
}
+2 -2
View File
@@ -13,7 +13,7 @@ var messages = map[Locale]map[string]string{
"gold": "GOLD", "silver": "SILVER", "bronze": "BRONZE",
"common": "COMMON", "uncommon": "UNCOMMON", "rare": "RARE", "epic": "EPIC", "legendary": "LEGENDARY",
"hold": "HOLD TO CONFIRM", "del": "DEL", "end": "END", "lives": "+30 LIVES!",
"retry": "RETRY", "stage_clear": "STAGE CLEAR!", "level_up": "LEVEL UP!", "achievement": "ACHIEVEMENT UNLOCKED",
"stage_clear": "STAGE CLEAR!", "level_up": "LEVEL UP!", "achievement": "ACHIEVEMENT UNLOCKED",
"combo": "COMBO!", "saving": "SAVING", "saved": "SAVED", "go": "GO!", "wait": "PLEASE WAIT",
"prev": "PREVIOUS", "next": "NEXT", "back": "BACK", "forward": "FORWARD", "history": "HISTORY",
"on": "ON", "off": "OFF", "clear": "CLEAR", "per_page": "PER PAGE", "range": "%d–%d OF %d", "found": "FOUND: %d",
@@ -91,7 +91,7 @@ var messages = map[Locale]map[string]string{
"gold": "金", "silver": "银", "bronze": "铜",
"common": "普通", "uncommon": "优秀", "rare": "稀有", "epic": "史诗", "legendary": "传说",
"hold": "长按确认", "del": "删", "end": "完", "lives": "命数 +30!",
"retry": "重试", "stage_clear": "过关!", "level_up": "升级!", "achievement": "成就解锁",
"stage_clear": "过关!", "level_up": "升级!", "achievement": "成就解锁",
"combo": "连击!", "saving": "存档中", "saved": "已存档", "go": "开始!", "wait": "请稍候",
"prev": "上一页", "next": "下一页", "back": "后退", "forward": "前进", "history": "历史",
"on": "开", "off": "关", "clear": "清除", "per_page": "每页", "range": "%d–%d / 共 %d", "found": "找到:%d",
+2 -2
View File
@@ -238,13 +238,13 @@ test('resize keeps whole pixels per dot', async ({ page }) => {
expect(Number.isInteger(s.zoom)).toBe(true)
})
test('a Go panic shows GAME OVER in pixels; RETRY reloads', async ({ page }) => {
test('a Go panic shows GAME OVER in pixels; CONTINUE reloads', async ({ page }) => {
test.info().annotations.push({ type: 'console errors', description: 'the panic is on purpose' })
await open(page, '#/f/icons')
await page.evaluate(() => window.__8bitGo.pointer('bogus', 0, 0))
expect(await texts(page)).toContain('web: unknown pointer kind bogus')
await expect(page.locator('#over')).toBeHidden()
await click(page, 'RETRY')
await click(page, 'CONTINUE')
await page.waitForFunction(() => window.__8bit?.ready && window.__8bit.page() === '/f/icons')
expect(await texts(page)).toContain('COPY CODE')
})