diff --git a/AGENTS.md b/AGENTS.md index 9780640..3c0abb0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/display/avatar.go b/display/avatar.go index ad5d7cb..57cb659 100644 --- a/display/avatar.go +++ b/display/avatar.go @@ -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) - } - }) -} diff --git a/display/avatargroup.go b/display/avatargroup.go new file mode 100644 index 0000000..4b71144 --- /dev/null +++ b/display/avatargroup.go @@ -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) + } + }) +} diff --git a/feedback/loaders.go b/feedback/barsloader.go similarity index 57% rename from feedback/loaders.go rename to feedback/barsloader.go index ce93ae8..46f003f 100644 --- a/feedback/loaders.go +++ b/feedback/barsloader.go @@ -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. diff --git a/feedback/dotsloader.go b/feedback/dotsloader.go new file mode 100644 index 0000000..9eec262 --- /dev/null +++ b/feedback/dotsloader.go @@ -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) + } + }) +} diff --git a/feedback/emptystate.go b/feedback/emptystate.go new file mode 100644 index 0000000..6aa422d --- /dev/null +++ b/feedback/emptystate.go @@ -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 +} diff --git a/feedback/errorstate.go b/feedback/errorstate.go new file mode 100644 index 0000000..a71aa46 --- /dev/null +++ b/feedback/errorstate.go @@ -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 +} diff --git a/feedback/states.go b/feedback/states.go deleted file mode 100644 index 68041d2..0000000 --- a/feedback/states.go +++ /dev/null @@ -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{}) - }) -} diff --git a/feedback/states_test.go b/feedback/states_test.go index 290b8fe..b9b7047 100644 --- a/feedback/states_test.go +++ b/feedback/states_test.go @@ -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 { diff --git a/feedback/successstate.go b/feedback/successstate.go new file mode 100644 index 0000000..7f0679d --- /dev/null +++ b/feedback/successstate.go @@ -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{}) + }) +} diff --git a/gallery/app_test.go b/gallery/app_test.go index 2a68c86..7d2ee57 100644 --- a/gallery/app_test.go +++ b/gallery/app_test.go @@ -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()) } diff --git a/gallery/demos_feedback.go b/gallery/demos_feedback.go index 67377ae..4a4fc54 100644 --- a/gallery/demos_feedback.go +++ b/gallery/demos_feedback.go @@ -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}, diff --git a/input/button.go b/input/button.go index 7976b3a..edc1601 100644 --- a/input/button.go +++ b/input/button.go @@ -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}) -} diff --git a/input/iconbutton.go b/input/iconbutton.go new file mode 100644 index 0000000..c3b7f8d --- /dev/null +++ b/input/iconbutton.go @@ -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}) +} diff --git a/internal/archtest/archtest_test.go b/internal/archtest/archtest_test.go index 1bd5560..689b3dc 100644 --- a/internal/archtest/archtest_test.go +++ b/internal/archtest/archtest_test.go @@ -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) diff --git a/navigation/backforward.go b/navigation/backforward.go new file mode 100644 index 0000000..6c8e392 --- /dev/null +++ b/navigation/backforward.go @@ -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 +} diff --git a/navigation/bottomnav.go b/navigation/bottomnav.go new file mode 100644 index 0000000..1d49930 --- /dev/null +++ b/navigation/bottomnav.go @@ -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 +} diff --git a/navigation/pagedots.go b/navigation/pagedots.go new file mode 100644 index 0000000..67e4886 --- /dev/null +++ b/navigation/pagedots.go @@ -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 +} diff --git a/navigation/pagination.go b/navigation/pagination.go index 22d6504..f711114 100644 --- a/navigation/pagination.go +++ b/navigation/pagination.go @@ -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 -} diff --git a/navigation/rail.go b/navigation/rail.go index 04b83ea..ae4488d 100644 --- a/navigation/rail.go +++ b/navigation/rail.go @@ -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 -} diff --git a/navigation/tabs.go b/navigation/tabs.go index 6c42c94..12b2921 100644 --- a/navigation/tabs.go +++ b/navigation/tabs.go @@ -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) diff --git a/navigation/toolbar.go b/navigation/toolbar.go index 64ddc06..9d304a4 100644 --- a/navigation/toolbar.go +++ b/navigation/toolbar.go @@ -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 -} diff --git a/navigation/verticaltabs.go b/navigation/verticaltabs.go new file mode 100644 index 0000000..476a65f --- /dev/null +++ b/navigation/verticaltabs.go @@ -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)) +} diff --git a/pixel/messages.go b/pixel/messages.go index a4f944f..2a2a81a 100644 --- a/pixel/messages.go +++ b/pixel/messages.go @@ -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", diff --git a/web/tests/smoke.spec.js b/web/tests/smoke.spec.js index c5f87ff..23f3fde 100644 --- a/web/tests/smoke.spec.js +++ b/web/tests/smoke.spec.js @@ -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') })