From 1f3d38110e339196b718069e426196aee188000e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 12 Oct 2026 16:50:17 -0400 Subject: [PATCH] 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. --- AGENTS.md | 2 +- display/avatar.go | 37 ------- display/avatargroup.go | 46 +++++++++ feedback/{loaders.go => barsloader.go} | 18 ---- feedback/dotsloader.go | 25 +++++ feedback/emptystate.go | 51 ++++++++++ feedback/errorstate.go | 49 ++++++++++ feedback/states.go | 127 ------------------------- feedback/states_test.go | 6 +- feedback/successstate.go | 48 ++++++++++ gallery/app_test.go | 4 +- gallery/demos_feedback.go | 2 +- input/button.go | 16 ---- input/iconbutton.go | 23 +++++ internal/archtest/archtest_test.go | 7 +- navigation/backforward.go | 43 +++++++++ navigation/bottomnav.go | 55 +++++++++++ navigation/pagedots.go | 63 ++++++++++++ navigation/pagination.go | 53 ----------- navigation/rail.go | 46 --------- navigation/tabs.go | 14 --- navigation/toolbar.go | 36 ------- navigation/verticaltabs.go | 19 ++++ pixel/messages.go | 4 +- web/tests/smoke.spec.js | 4 +- 25 files changed, 439 insertions(+), 359 deletions(-) create mode 100644 display/avatargroup.go rename feedback/{loaders.go => barsloader.go} (57%) create mode 100644 feedback/dotsloader.go create mode 100644 feedback/emptystate.go create mode 100644 feedback/errorstate.go delete mode 100644 feedback/states.go create mode 100644 feedback/successstate.go create mode 100644 input/iconbutton.go create mode 100644 navigation/backforward.go create mode 100644 navigation/bottomnav.go create mode 100644 navigation/pagedots.go create mode 100644 navigation/verticaltabs.go 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') })