From bd3877e1b1b235f17df9e3b800fb4f3a75bca082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 11 Oct 2026 20:58:30 -0400 Subject: [PATCH] Add feedback components 106-130; inert scope, focus return, two-step deploy --- AGENTS.md | 8 +- cmd/web/main.go | 3 +- display/avatar.go | 2 +- display/bubble.go | 2 +- display/display.go | 13 +- display/picture.go | 2 +- display/placeholder.go | 2 +- display/portrait.go | 2 +- display/qrcode.go | 2 +- display/rarityframe.go | 2 +- display/sprite.go | 2 +- display/swatch.go | 2 +- feedback/achievement.go | 46 ++++ feedback/alert.go | 76 ++++++ feedback/banner.go | 55 +++++ feedback/busyoverlay.go | 54 +++++ feedback/combocounter.go | 45 ++++ feedback/continueprompt.go | 61 +++++ feedback/countup.go | 47 ++++ feedback/effects_test.go | 269 +++++++++++++++++++++ feedback/feedback.go | 66 +++++ feedback/feedback_test.go | 180 ++++++++++++++ feedback/hourglass.go | 67 ++++++ feedback/inlinemessage.go | 48 ++++ feedback/levelup.go | 60 +++++ feedback/loaders.go | 43 ++++ feedback/loadingscreen.go | 38 +++ feedback/meter.go | 41 ++++ feedback/progress.go | 57 +++++ feedback/pulse.go | 31 +++ feedback/readygo.go | 62 +++++ feedback/saveindicator.go | 49 ++++ feedback/shake.go | 26 ++ feedback/skeleton.go | 44 ++++ feedback/spinner.go | 44 ++++ feedback/states.go | 127 ++++++++++ feedback/states_test.go | 221 +++++++++++++++++ feedback/toasts.go | 103 ++++++++ gallery/app_test.go | 4 +- gallery/demos_feedback.go | 373 +++++++++++++++++++++++++++++ gallery/pages.go | 21 +- gallery/registry.go | 2 +- input/editabletext.go | 2 +- input/fader.go | 2 +- input/input.go | 11 - input/multiselect.go | 4 +- input/pick_test.go | 32 +++ input/pininput.go | 2 +- input/rangeslider.go | 2 +- input/rating.go | 2 +- input/select.go | 18 +- input/slider.go | 2 +- input/stepper.go | 2 +- input/stepslider.go | 2 +- input/textarea.go | 4 +- input/textinput.go | 2 +- input/tokenfield.go | 2 +- internal/archtest/archtest_test.go | 10 +- layout/appframe.go | 4 +- layout/aspectbox.go | 2 +- layout/grid.go | 6 +- layout/handheld.go | 2 +- layout/internal_test.go | 4 +- layout/layout.go | 13 - layout/masonry.go | 4 +- layout/panel.go | 2 +- layout/resizable.go | 4 +- layout/screen.go | 2 +- layout/section.go | 2 +- layout/split.go | 2 +- layout/tiles.go | 8 +- layout/viewport.go | 2 +- layout/window.go | 2 +- pixel/element.go | 25 +- pixel/messages.go | 4 + pixel/settings.go | 13 + text/blinktext.go | 2 +- text/heading.go | 2 +- text/marquee.go | 2 +- text/odometer.go | 2 +- text/paragraph.go | 2 +- text/scrambletext.go | 2 +- text/text.go | 13 +- text/typewriter.go | 2 +- text/wavetext.go | 2 +- web/deploy.sh | 4 +- web/tests/smoke.spec.js | 4 +- 87 files changed, 2492 insertions(+), 147 deletions(-) create mode 100644 feedback/achievement.go create mode 100644 feedback/alert.go create mode 100644 feedback/banner.go create mode 100644 feedback/busyoverlay.go create mode 100644 feedback/combocounter.go create mode 100644 feedback/continueprompt.go create mode 100644 feedback/countup.go create mode 100644 feedback/effects_test.go create mode 100644 feedback/feedback.go create mode 100644 feedback/feedback_test.go create mode 100644 feedback/hourglass.go create mode 100644 feedback/inlinemessage.go create mode 100644 feedback/levelup.go create mode 100644 feedback/loaders.go create mode 100644 feedback/loadingscreen.go create mode 100644 feedback/meter.go create mode 100644 feedback/progress.go create mode 100644 feedback/pulse.go create mode 100644 feedback/readygo.go create mode 100644 feedback/saveindicator.go create mode 100644 feedback/shake.go create mode 100644 feedback/skeleton.go create mode 100644 feedback/spinner.go create mode 100644 feedback/states.go create mode 100644 feedback/states_test.go create mode 100644 feedback/toasts.go create mode 100644 gallery/demos_feedback.go diff --git a/AGENTS.md b/AGENTS.md index f1d8c48..aa1141f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ internal/archtest/ import and API rules Imports point down only. Same-tier imports are forbidden. `internal/archtest` enforces it. -0 pixel · 1 icon · 2 layout, text, display · 3 input, feedback · 4 navigation, data, overlay, datetime, chart, fx · 5 game, chat, media, code, account, files, project, shop, agent, desk, tabletop · 6 gallery · 7 cmd +0 pixel · 1 icon · 2 layout, text, display · 3 input · 4 feedback · 5 navigation, data, overlay, datetime, chart, fx · 6 game, chat, media, code, account, files, project, shop, agent, desk, tabletop · 7 gallery · 8 cmd ## Style rules @@ -54,12 +54,14 @@ Imports point down only. Same-tier imports are forbidden. `internal/archtest` en ## Component rules - Signatures and results: PRD §5.1. Zero options give the default look. Sizes in options are dots. -- `k := pixel.K(c)` once per component; layout sizes through `k.D(n)`. +- `k := pixel.K(c)` once per component; layout sizes through `k.D(n)`. Integer options default through `pixel.Or`. +- A one-shot effect inside a lasting view starts when its cue changes (a `Cue` option, 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. - Containers take content as a final `func()`; interactive results are `NameResult`. - A Clicked, Shortcut, Changed or drag during the build makes MyGo build the frame again, so what they change shows at once. State changed any other way after its element was built (`Pressed`, `Edit.Changed`) is read at paint time. - Never call `Selected`: MyGo paints it in the accent color. - A press focuses the nearest focusable element under it, or nothing; a closing overlay hands the focus back to its opener. While a press is on, `Hovered` is false for every other element: ask `Pressed`. - Enter and Space click a focused clickable element unless a `Shortcut` takes them. Take them wherever a click reads the pointer's position. +- Under `pixel.Settings.Inert` (a busy veil) `pixel.Shell` disables every base and gives the focus back after. A popup sits above any veil: a control closes its popup when disabled or inert. - Elements are known by their place among siblings. Wrap one whose earlier siblings come and go in `ui.Box(c).Key(k)`, or it loses focus and state. - Hosts send a key's release with its press, so tell a held key by its repeats. - Clip only boxes that sit on whole dots; split widths with whole-dot shares, not equal `Grow`. `Grow` starts from zero size, so never use it on a content-sized child. @@ -78,7 +80,7 @@ Each component adds one `Entry` in `gallery/demos_.go`: ID, Name, Zh, ## Tests - `go vet ./... && go test ./...` before reporting done. -- Behavior tests drive `ui.Tester` and assert values. +- Behavior tests drive `ui.Tester` and assert values. Timed components take `pixel.Settings.Now` from a test clock. `Tester.Focused(label)` is also true when an ancestor holds the focus. - `gallery` checks every entry under every palette: only palette colors, whole dots. `PIXEL_SHOTS= go test ./gallery -run TestInvariants` writes PNGs for visual review. - Web: `npm install && npm test` in `web/` builds `web/dist` and runs Playwright on a local server; `BASE_URL= npx playwright test` checks a deployed site. Tests read `window.__8bit` (`ready`, `stats()`, `texts()`, `page()`, `find(label)`, `log`). diff --git a/cmd/web/main.go b/cmd/web/main.go index 8cc3fe6..09d70b5 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -13,6 +13,7 @@ import ( "github.com/egoist/mygo/ui" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/feedback" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/gallery" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" ) @@ -107,7 +108,7 @@ func (h *host) crash(why string) (res any) { h.tt = ui.NewTester(func(c *ui.Context) { pixel.Use(c, pixel.Settings{Palette: pixel.WinterNight, Dot: dot}) ui.Column(c).Fill().Center().Children(func() { - if gallery.GameOver(c, why) { + if feedback.ErrorState(c, why, feedback.ErrorStateOptions{}).Continue { js.Global().Get("location").Call("reload") } }) diff --git a/display/avatar.go b/display/avatar.go index 36e7dee..d9ac9d0 100644 --- a/display/avatar.go +++ b/display/avatar.go @@ -120,7 +120,7 @@ type AvatarGroupOptions struct { // 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), orDef("AvatarGroup Max", o.Max, 4))] + shown := names[:min(len(names), pixel.Or("AvatarGroup Max", o.Max, 4))] rest := len(names) - len(shown) more := "" if rest > 0 { diff --git a/display/bubble.go b/display/bubble.go index fcbc911..8203bee 100644 --- a/display/bubble.go +++ b/display/bubble.go @@ -32,7 +32,7 @@ func Bubble(c *ui.Context, o BubbleOptions, children func()) *ui.Element { panic(fmt.Sprintf("display: unknown Side %d", o.Tail)) } k := pixel.K(c) - at := orDef("Bubble At", o.At, 8) + at := pixel.Or("Bubble At", o.At, 8) pad := [4]int{6, 8, 6, 8} pad[[...]int{Bottom: 2, Top: 0, Left: 3, Right: 1}[o.Tail]] += tailLen return ui.Column(c).Padding(k.D(pad[0]), k.D(pad[1]), k.D(pad[2]), k.D(pad[3])).Draw(func(p *ui.Painter, r ui.Rect) { diff --git a/display/display.go b/display/display.go index 318dd48..186f321 100644 --- a/display/display.go +++ b/display/display.go @@ -23,20 +23,9 @@ func art(c *ui.Context, k pixel.Kit, label string, w, h int, fn func(g pixel.Can return e } -// orDef is v, or def for zero; negative panics. -func orDef(name string, v, def int) int { - if v < 0 { - panic(fmt.Sprintf("display: %s %d is negative", name, v)) - } - if v == 0 { - return def - } - return v -} - // scale is s, or 1 for zero; outside 1–4 panics. func scale(s int) int { - s = orDef("Scale", s, 1) + s = pixel.Or("Scale", s, 1) if s > 4 { panic(fmt.Sprintf("display: Scale %d outside 1–4", s)) } diff --git a/display/picture.go b/display/picture.go index db27cdd..f415697 100644 --- a/display/picture.go +++ b/display/picture.go @@ -27,7 +27,7 @@ type pictureCache struct { func Picture(c *ui.Context, img image.Image, o PictureOptions) *ui.Element { k := pixel.K(c) b := img.Bounds() - w, h := orDef("Picture W", o.W, b.Dx()), orDef("Picture H", o.H, b.Dy()) + w, h := pixel.Or("Picture W", o.W, b.Dx()), pixel.Or("Picture H", o.H, b.Dy()) e := art(c, k, o.Label, w, h, nil) pc := ui.Local(e, pictureKey{}, func() pictureCache { return pictureCache{} }) if sum := fingerprint(img); pc.dots == nil || pc.sum != sum || pc.pal != k.Palette() || pc.w != w || pc.h != h { diff --git a/display/placeholder.go b/display/placeholder.go index 5931e3f..9b584ac 100644 --- a/display/placeholder.go +++ b/display/placeholder.go @@ -16,7 +16,7 @@ type PlaceholderOptions struct { // Placeholder is a dithered box with a cross and size. func Placeholder(c *ui.Context, o PlaceholderOptions) *ui.Element { k := pixel.K(c) - w, h := orDef("Placeholder W", o.W, 64), orDef("Placeholder H", o.H, 48) + w, h := pixel.Or("Placeholder W", o.W, 64), pixel.Or("Placeholder H", o.H, 48) size := fmt.Sprintf("%d×%d", w, h) return art(c, k, size, w, h, func(g pixel.Canvas) { if w < 3 || h < 3 { diff --git a/display/portrait.go b/display/portrait.go index 8a92855..dc15004 100644 --- a/display/portrait.go +++ b/display/portrait.go @@ -14,7 +14,7 @@ type PortraitOptions struct { // Portrait frames a character's picture over a nameplate. func Portrait(c *ui.Context, name string, o PortraitOptions, picture func()) *ui.Element { k := pixel.K(c) - size := orDef("Portrait Size", o.Size, 48) + size := pixel.Or("Portrait Size", o.Size, 48) return ui.Column(c).Width(k.D(size + 6)).Role(ui.RoleGroup).Label(name).Children(func() { ui.Box(c).Size(k.D(size+6), k.D(size+6)).Padding(k.D(3)).Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) diff --git a/display/qrcode.go b/display/qrcode.go index f7bd2cb..1d0d4e7 100644 --- a/display/qrcode.go +++ b/display/qrcode.go @@ -24,7 +24,7 @@ type qrCache struct { // QRCode encodes s at level M, with a quiet zone. func QRCode(c *ui.Context, s string, o QRCodeOptions) *ui.Element { k := pixel.K(c) - sc := orDef("QRCode Scale", o.Scale, 2) + sc := pixel.Or("QRCode Scale", o.Scale, 2) e := ui.Box(c) qc := ui.Local(e, qrKey{}, func() qrCache { return qrCache{} }) if qc.code == nil || qc.text != s { diff --git a/display/rarityframe.go b/display/rarityframe.go index 618aaad..e35f2ed 100644 --- a/display/rarityframe.go +++ b/display/rarityframe.go @@ -15,7 +15,7 @@ type RarityFrameOptions struct { func RarityFrame(c *ui.Context, r Rarity, o RarityFrameOptions, children func()) *ui.Element { k := pixel.K(c) ink := r.slot() - pad := 3 + orDef("RarityFrame Padding", o.Padding, 4) + pad := 3 + pixel.Or("RarityFrame Padding", o.Padding, 4) return ui.Column(c).Padding(k.D(pad)).Role(ui.RoleGroup).Label(r.name(k)).Draw(func(p *ui.Painter, r2 ui.Rect) { g := k.Canvas(p, r2) if g.W < 6 || g.H < 6 { diff --git a/display/sprite.go b/display/sprite.go index 4625a14..8e019cd 100644 --- a/display/sprite.go +++ b/display/sprite.go @@ -18,7 +18,7 @@ type SpriteOptions struct { // Sprite plays a multi-slot sprite, mirrored when asked. func Sprite(c *ui.Context, sp *pixel.Sprite, o SpriteOptions) *ui.Element { k, sc := pixel.K(c), scale(o.Scale) - fps := orDef("Sprite FPS", o.FPS, 0) + fps := pixel.Or("Sprite FPS", o.FPS, 0) if o.Mirror { sp = sp.Mirror() } diff --git a/display/swatch.go b/display/swatch.go index f916e85..193059a 100644 --- a/display/swatch.go +++ b/display/swatch.go @@ -14,7 +14,7 @@ type SwatchOptions struct { // Swatch shows the active palette's sixteen slots with names. func Swatch(c *ui.Context, o SwatchOptions) *ui.Element { k := pixel.K(c) - cols := orDef("Swatch Columns", o.Columns, 4) + cols := pixel.Or("Swatch Columns", o.Columns, 4) cell := 14 + pixel.Measure("AccentLit") return ui.Row(c).Wrap().Width(k.D(cols * (cell + 4))).Gap(k.D(4)).Role(ui.RoleList).Label(k.Palette().Name).Children(func() { for s := range pixel.Slot(pixel.Slots) { diff --git a/feedback/achievement.go b/feedback/achievement.go new file mode 100644 index 0000000..579a1e6 --- /dev/null +++ b/feedback/achievement.go @@ -0,0 +1,46 @@ +package feedback + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type AchievementOptions struct { + // Cue shows the card each time it changes. + Cue int + // Width is in dots; zero means 200. + Width int +} + +// Achievement slides a trophy card in, holds it, then out. +func Achievement(c *ui.Context, title string, o AchievementOptions) *ui.Element { + k := pixel.K(c) + w := pixel.Or("Achievement Width", o.Width, 200) + e := ui.Box(c) + // Ten steps a second; in by 5, gone by 40. + step := since(c, k, cue(e, k, o.Cue), 10, 40) + if step >= 40 { + return e + } + out := 0 + switch { + case k.Reduce(): + case step < 5: + out = 5 - step + case step >= 35: + out = step - 34 + } + head := k.Msg("achievement") + return e.Role(ui.RoleStatus).Label(head+" "+title).Size(k.D(w), k.D(40)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + x := out * w / 5 + g.Clip(0, 0, g.W, g.H, func() { + g.Frame(x, 0, w, 40, pixel.Window, pixel.Face) + g.Sprite(x+6, 8, icon.Trophy, 0, pixel.Warn, 2) + g.Text(x+36, 4, head, pixel.Faint, 1) + g.Text(x+36, 20, title, pixel.Ink, 1) + }) + }) +} diff --git a/feedback/alert.go b/feedback/alert.go new file mode 100644 index 0000000..3cc4d8f --- /dev/null +++ b/feedback/alert.go @@ -0,0 +1,76 @@ +package feedback + +import ( + "fmt" + "log/slog" + + "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" +) + +// Kind is a message's tone. +type Kind uint8 + +const ( + Info Kind = iota + Success + Warning + Danger +) + +// look is a kind's slot and icon. +func (k Kind) look() (pixel.Slot, *pixel.Sprite) { + switch k { + case Info: + return pixel.Info, icon.Info + case Success: + return pixel.Good, icon.Check + case Warning: + return pixel.Warn, icon.Warning + case Danger: + return pixel.Bad, icon.Error + } + panic(fmt.Sprintf("feedback: unknown Kind %d", k)) +} + +type AlertOptions struct { + Kind Kind + // Closable adds a × that reports Closed. + Closable bool + Disabled bool +} + +type AlertResult struct { + Element *ui.Element + Closed bool +} + +// Alert is a framed callout: icon, title and text. +func Alert(c *ui.Context, title, message string, o AlertOptions) AlertResult { + k := pixel.K(c) + slot, sp := o.Kind.look() + res := AlertResult{} + res.Element = ui.Row(c).Gap(k.D(6)).Padding(k.D(6), k.D(8), k.D(6), k.D(10)).AlignItems(ui.Start).Role(ui.RoleStatus).Label(title).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Window, pixel.Face) + g.Rect(3, 3, 3, g.H-6, slot) + }).Children(func() { + display.Icon(c, sp, display.IconOptions{Slot: slot}) + ui.Column(c).Grow(1).Gap(k.D(2)).Children(func() { + pixel.Text(c, title, pixel.TextOptions{Slot: slot}) + text.Paragraph(c, message, text.ParagraphOptions{}) + }) + if o.Closable { + res.Closed = input.IconButton(c, icon.Close, k.Msg("close"), input.IconButtonOptions{Variant: input.Ghost, Size: input.Small, Disabled: o.Disabled}).Clicked + } + }) + if res.Closed { + slog.Debug("8bit: alert closed", "title", title) + } + return res +} diff --git a/feedback/banner.go b/feedback/banner.go new file mode 100644 index 0000000..017ea71 --- /dev/null +++ b/feedback/banner.go @@ -0,0 +1,55 @@ +package feedback + +import ( + "log/slog" + + "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 BannerOptions struct { + Kind Kind + // Actions label buttons; Action reports the one clicked. + Actions []string + // Dismissable adds a × that reports Dismissed. + Dismissable bool + Disabled bool +} + +type BannerResult struct { + Element *ui.Element + Action string + Dismissed bool +} + +// Banner is a full-width strip with a message and actions. +func Banner(c *ui.Context, message string, o BannerOptions) BannerResult { + k := pixel.K(c) + slot, sp := o.Kind.look() + res := BannerResult{} + res.Element = ui.Row(c).Gap(k.D(6)).Padding(k.D(4), k.D(8)).AlignItems(ui.Center).Role(ui.RoleStatus).Label(message).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Dither(0, 0, g.W, g.H, slot, pixel.Face, 12) + g.HLine(0, g.H-1, g.W, slot) + }).Children(func() { + display.Icon(c, sp, display.IconOptions{Slot: slot}) + ui.Column(c).Grow(1).Children(func() { text.Paragraph(c, message, text.ParagraphOptions{}) }) + for _, a := range o.Actions { + if input.Button(c, a, input.ButtonOptions{Size: input.Small, Disabled: o.Disabled}).Clicked { + res.Action = a + } + } + if o.Dismissable { + res.Dismissed = input.IconButton(c, icon.Close, k.Msg("close"), input.IconButtonOptions{Variant: input.Ghost, Size: input.Small, Disabled: o.Disabled}).Clicked + } + }) + if res.Action != "" || res.Dismissed { + slog.Debug("8bit: banner", "action", res.Action, "dismissed", res.Dismissed) + } + return res +} diff --git a/feedback/busyoverlay.go b/feedback/busyoverlay.go new file mode 100644 index 0000000..b30e792 --- /dev/null +++ b/feedback/busyoverlay.go @@ -0,0 +1,54 @@ +package feedback + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type BusyOverlayOptions struct{} + +type busyKey struct{} + +// BusyOverlay veils busy content, whose controls then take no input. +func BusyOverlay(c *ui.Context, busy bool, o BusyOverlayOptions, content func()) *ui.Element { + k := pixel.K(c) + outer := ui.Box(c) + // held is whether the veil had the focus last frame. + held := ui.Local(outer, busyKey{}, func() bool { return false }) + if !busy && *held { + // From the root, parked controls inside take it back. + c.Root().Focus() + *held = false + } + if busy { + // Busy content is one Tab stop: the veil. + outer.FocusGroup(ui.Vertical) + } + return outer.Children(func() { + s := pixel.Current(c) + s.Inert = s.Inert || busy + before := c.Root().Focused() + var body *ui.Element + pixel.Scope(c, s, func() { body = ui.Box(c).Children(content) }) + if !busy { + return + } + veil := ui.Box(c).Absolute().Left(0).Top(0).Right(0).Bottom(0).Focusable().FocusRing(false).Role(ui.RoleProgress).Label(k.Msg("wait")) + if body.FocusWithin() || !before && c.Root().Focused() { + veil.Focus() + } + *held = veil.Focused() + focus := veil.FocusVisible() + veil.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Checker(0, 0, g.W, g.H, pixel.Back) + // The spinner doubles in size where it fits. + sc := max(1, min(2, min(g.W, g.H)/14)) + beads(g, (g.W-12*sc)/2, (g.H-12*sc)/2, sc) + if focus { + g.FocusMark(0, 0, g.W, g.H) + } + }) + }) +} diff --git a/feedback/combocounter.go b/feedback/combocounter.go new file mode 100644 index 0000000..c8064c3 --- /dev/null +++ b/feedback/combocounter.go @@ -0,0 +1,45 @@ +package feedback + +import ( + "strconv" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type ComboCounterOptions struct{} + +type comboKey struct{} + +// ComboCounter shows ×n COMBO!, popping bigger as n rises. +func ComboCounter(c *ui.Context, count int, o ComboCounterOptions) *ui.Element { + k := pixel.K(c) + e := ui.Box(c) + s := ui.Local(e, comboKey{}, func() rise { return rise{level: count} }) + if count > s.level { + s.at = k.Now() + } + s.level = count + if count <= 0 { + return e + } + pop := !k.Reduce() && since(c, k, s.at, 10, 2) < 2 + n, tag := "×"+strconv.Itoa(count), k.Msg("combo") + w := pixel.Measure(n)*3 + 6 + pixel.Measure(tag)*2 + 2 + return e.Role(ui.RoleStatus).Label(n+" "+tag).Size(k.D(w), k.D(3*pixel.LineHeight+2)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + x := pixel.Measure(n)*3 + 6 + word := func(dx, dy int, ink pixel.Slot) { + g.Text(1+dx, 1+dy, n, ink, 3) + g.Text(x+dx, pixel.LineHeight+dy, tag, ink, 2) + } + word(1, 1, pixel.Shade) + if pop { + for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} { + word(d[0], d[1], pixel.Warn) + } + } + word(0, 0, pixel.Warn) + }) +} diff --git a/feedback/continueprompt.go b/feedback/continueprompt.go new file mode 100644 index 0000000..be68f96 --- /dev/null +++ b/feedback/continueprompt.go @@ -0,0 +1,61 @@ +package feedback + +import ( + "log/slog" + "strconv" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text" +) + +type ContinuePromptOptions struct { + // Seconds counts down; zero means 10. + Seconds int + Disabled bool +} + +type ContinuePromptResult struct { + Element *ui.Element + Yes, No, TimedOut bool +} + +type promptKey struct{} + +// prompt is when the count began, and if it ended. +type prompt struct { + start time.Time + over bool +} + +// ContinuePrompt asks CONTINUE? YES or NO, counting down. +func ContinuePrompt(c *ui.Context, o ContinuePromptOptions) ContinuePromptResult { + k := pixel.K(c) + n := pixel.Or("ContinuePrompt Seconds", o.Seconds, 10) + col := ui.Column(c).Gap(k.D(6)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(k.Msg("continue")) + st := ui.Local(col, promptKey{}, func() prompt { return prompt{start: k.Now()} }) + left := max(0, n-int(k.Now().Sub(st.start)/time.Second)) + res := ContinuePromptResult{Element: col} + if left == 0 && !st.over { + st.over, res.TimedOut = true, true + } + if !st.over { + k.Tick(c, 10) + } + col.Children(func() { + text.ShadowText(c, k.Msg("continue"), text.ShadowTextOptions{Slot: pixel.Accent, Scale: 2}) + text.ShadowText(c, strconv.Itoa(left), text.ShadowTextOptions{Slot: pixel.Warn, Scale: 4}) + r := input.ButtonGroup(c, []string{k.Msg("yes"), k.Msg("no")}, input.ButtonGroupOptions{Label: k.Msg("continue"), Disabled: st.over || o.Disabled}) + res.Yes, res.No = r.Clicked == 0, r.Clicked == 1 + }) + if res.Yes || res.No { + st.over = true + } + if res.Yes || res.No || res.TimedOut { + slog.Debug("8bit: continue prompt", "yes", res.Yes, "no", res.No, "timed out", res.TimedOut) + } + return res +} diff --git a/feedback/countup.go b/feedback/countup.go new file mode 100644 index 0000000..d19ddcb --- /dev/null +++ b/feedback/countup.go @@ -0,0 +1,47 @@ +package feedback + +import ( + "strconv" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type CountUpOptions struct { + // Scale is 1–4; zero means 1. + Scale int + Slot pixel.Slot +} + +type countKey struct{} + +// counting runs from one value to the next. +type counting struct { + from, to int + at time.Time +} + +// CountUp counts to a new value in eight steps. +func CountUp(c *ui.Context, value int, o CountUpOptions) *ui.Element { + k := pixel.K(c) + sc := pixel.Or("CountUp Scale", o.Scale, 1) + e := ui.Box(c).Role(ui.RoleText).Label(strconv.Itoa(value)) + s := ui.Local(e, countKey{}, func() counting { return counting{from: value, to: value} }) + shown := func() int { + step := 8 + if !k.Reduce() { + step = since(c, k, s.at, 15, 8) + } + return s.from + (s.to-s.from)*step/8 + } + if value != s.to { + s.from, s.to, s.at = shown(), value, k.Now() + } + n := strconv.Itoa(shown()) + w := max(pixel.Measure(strconv.Itoa(s.from)), pixel.Measure(strconv.Itoa(s.to))) + return e.Size(k.D(w*sc), k.D(pixel.LineHeight*sc)).Draw(func(p *ui.Painter, r ui.Rect) { + k.Canvas(p, r).Text(0, 0, n, o.Slot, sc) + }) +} diff --git a/feedback/effects_test.go b/feedback/effects_test.go new file mode 100644 index 0000000..9e7ccdb --- /dev/null +++ b/feedback/effects_test.go @@ -0,0 +1,269 @@ +package feedback_test + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/feedback" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestCountUpStepsOrJumps(t *testing.T) { + for _, reduce := range []bool{false, true} { + clk := newClock() + v := 0 + tt := view(400, 100, clk, reduce, func(c *ui.Context) { + feedback.CountUp(c, v, feedback.CountUpOptions{Slot: pixel.Ink}) + }) + v = 800 + wait(tt, clk, 0) + r := find(t, tt, "800") + mid := slots(t, tt.Image(), r) + wait(tt, clk, time.Second) + end := slots(t, tt.Image(), r) + if counted := !equal(mid, end); counted == reduce { + t.Errorf("reduce %v: counted in steps %v", reduce, counted) + } + } +} + +func TestPulseStopsUnderReduceMotion(t *testing.T) { + for _, reduce := range []bool{false, true} { + clk := newClock() + tt := view(300, 200, clk, reduce, func(c *ui.Context) { + feedback.Pulse(c, feedback.PulseOptions{}, func() { + ui.Box(c).Size(20, 20).Label("dot") + }).Label("ring") + }) + if moved := moves(t, tt, clk, "ring", 200*time.Millisecond); moved == reduce { + t.Errorf("reduce %v: the ring moved %v", reduce, moved) + } + } +} + +func TestLevelUpShowsOnlyOnARise(t *testing.T) { + clk := newClock() + lv := 4 + tt := view(500, 200, clk, false, func(c *ui.Context) { + feedback.LevelUp(c, lv, feedback.LevelUpOptions{}) + }) + lv = 3 + tt.Frame() + if _, ok := tt.Find("LEVEL UP! LV 3"); ok { + t.Fatal("LEVEL UP showed without a rise") + } + lv = 5 + tt.Frame() + r := find(t, tt, "LEVEL UP! LV 5") + if count(slots(t, tt.Image(), r), pixel.Accent) != 0 { + t.Fatal("the banner did not start off its box") + } + wait(tt, clk, time.Second) + if count(slots(t, tt.Image(), r), pixel.Accent) == 0 { + t.Fatal("the banner did not slide in") + } + wait(tt, clk, 3*time.Second) + if _, ok := tt.Find("LEVEL UP! LV 5"); ok { + t.Fatal("the banner stayed") + } +} + +func TestAchievementComesAndGoes(t *testing.T) { + clk := newClock() + cue := 0 + tt := view(500, 200, clk, true, func(c *ui.Context) { + feedback.Achievement(c, "Slime Tamer", feedback.AchievementOptions{Cue: cue}) + }) + if _, ok := tt.Find("ACHIEVEMENT UNLOCKED Slime Tamer"); ok { + t.Fatal("the card showed before its cue") + } + cue++ + tt.Frame() + find(t, tt, "ACHIEVEMENT UNLOCKED Slime Tamer") + wait(tt, clk, 3*time.Second) + find(t, tt, "ACHIEVEMENT UNLOCKED Slime Tamer") + wait(tt, clk, 2*time.Second) + if _, ok := tt.Find("ACHIEVEMENT UNLOCKED Slime Tamer"); ok { + t.Fatal("the card stayed") + } +} + +func TestComboCounterPopsOnARise(t *testing.T) { + clk := newClock() + n := 0 + tt := view(400, 200, clk, false, func(c *ui.Context) { + feedback.ComboCounter(c, n, feedback.ComboCounterOptions{}) + }) + if _, ok := tt.Find("×0 COMBO!"); ok { + t.Fatal("a zero combo showed") + } + n = 12 + tt.Frame() + r := find(t, tt, "×12 COMBO!") + popped := count(slots(t, tt.Image(), r), pixel.Warn) + wait(tt, clk, 500*time.Millisecond) + if rest := count(slots(t, tt.Image(), r), pixel.Warn); rest >= popped { + t.Fatalf("no pop: %d Warn dots, then %d", popped, rest) + } +} + +func TestSaveIndicatorStates(t *testing.T) { + saving := false + tt := view(300, 100, newClock(), false, func(c *ui.Context) { + feedback.SaveIndicator(c, saving, feedback.SaveIndicatorOptions{}) + }) + if _, ok := tt.Find("SAVED"); ok { + t.Fatal("SAVED before any save") + } + saving = true + tt.Frame() + find(t, tt, "SAVING…") + saving = false + tt.Frame() + find(t, tt, "SAVED") +} + +func TestBusyOverlayBlocksInputAndGivesFocusBack(t *testing.T) { + busy, clicks, raw := false, 0, true + tt := view(400, 200, newClock(), false, func(c *ui.Context) { + input.Button(c, "top", input.ButtonOptions{}) + feedback.BusyOverlay(c, busy, feedback.BusyOverlayOptions{}, func() { + if input.Button(c, "SAVE", input.ButtonOptions{}).Clicked { + clicks++ + } + // Plain boxes, not library controls. + if ui.Box(c).Size(40, 20).Label("raw").Clicked() { + clicks++ + } + if raw && ui.Box(c).Size(40, 20).Label("rawkey").Focusable().Shortcut(0, ui.KeyEnter) { + clicks++ + } + }) + ui.Box(c).Size(4, 4).Label("elsewhere") + input.Button(c, "after", input.ButtonOptions{}) + }) + // Focused counts ancestors; a sibling tells the two apart. + own := func() bool { return tt.Focused("SAVE") && !tt.Focused("elsewhere") } + click(t, tt, "rawkey") + busy = true + tt.Frame() + tt.Key(0, ui.KeyEnter) + if clicks != 0 || tt.Focused("rawkey") && !tt.Focused("elsewhere") { + t.Fatal("a plain focusable inside kept the focus or its keys") + } + busy, raw = false, false + tt.Frame() + click(t, tt, "SAVE") + busy = true + tt.Frame() + tt.Key(0, ui.KeyEnter) + // Focus waits on the veil; Tab goes on from it. + tt.Key(0, ui.KeyTab) + if !tt.Focused("after") || tt.Focused("elsewhere") { + t.Fatal("Tab did not pass over the busy content") + } + // Presses land on the veil, which keeps the focus. + click(t, tt, "SAVE") + click(t, tt, "raw") + if clicks != 1 || own() { + t.Fatalf("busy content took input: %d clicks, focus %v", clicks, own()) + } + busy = false + tt.Frame() + if !own() { + t.Fatal("the focus did not come back") + } + tt.Key(0, ui.KeyEnter) + if clicks != 2 { + t.Fatal("the content stayed blocked") + } +} + +func TestBusyContentIsOneTabStop(t *testing.T) { + tt := view(400, 200, newClock(), false, func(c *ui.Context) { + input.Button(c, "before", input.ButtonOptions{}) + feedback.BusyOverlay(c, true, feedback.BusyOverlayOptions{}, func() { + ui.Box(c).Size(40, 20).Label("raw").Focusable() + input.Button(c, "SAVE", input.ButtonOptions{}) + }) + input.Button(c, "after", input.ButtonOptions{}) + }) + click(t, tt, "before") + tt.Key(0, ui.KeyTab) + // FocusMark rings the veil two dots outside it. + v := find(t, tt, "PLEASE WAIT") + if count(slots(t, tt.Image(), ui.Rect{X: v.X, Y: v.Y + v.H + 2, W: v.W, H: 2}), pixel.Accent) == 0 { + t.Fatal("the focused veil showed no focus") + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("after") || tt.Focused("before") { + t.Fatal("two Tabs did not cross the busy content") + } + tt.Key(ui.Shift, ui.KeyTab) + tt.Key(ui.Shift, ui.KeyTab) + if !tt.Focused("before") || tt.Focused("after") { + t.Fatal("two Shift+Tabs did not cross back") + } +} + +func TestReadyGoCountsAndReportsDone(t *testing.T) { + clk := newClock() + cue, done := 0, 0 + tt := view(400, 300, clk, true, func(c *ui.Context) { + if feedback.ReadyGo(c, feedback.ReadyGoOptions{Cue: cue}).Done { + done++ + } + }) + cue++ + tt.Frame() + for _, want := range []string{"3", "2", "1", "GO!"} { + find(t, tt, want) + wait(tt, clk, 500*time.Millisecond) + wait(tt, clk, 500*time.Millisecond) + } + if _, ok := tt.Find("GO!"); ok || done != 1 { + t.Fatalf("GO! stayed, or Done came %d times", done) + } +} + +func TestReadyGoReportsDoneAfterAJump(t *testing.T) { + clk := newClock() + cue, done := 0, 0 + tt := view(400, 300, clk, false, func(c *ui.Context) { + if feedback.ReadyGo(c, feedback.ReadyGoOptions{Cue: cue}).Done { + done++ + } + }) + cue++ + tt.Frame() + wait(tt, clk, 5*time.Second) + wait(tt, clk, time.Second) + if done != 1 { + t.Fatalf("a jump past GO! gave Done %d times", done) + } +} + +func TestShakeJoltsUnlessReduced(t *testing.T) { + for _, reduce := range []bool{false, true} { + clk := newClock() + cue := 0 + tt := view(300, 100, clk, reduce, func(c *ui.Context) { + feedback.Shake(c, feedback.ShakeOptions{Cue: cue}, func() { + ui.Box(c).Size(20, 20).Label("box") + }) + }) + x := find(t, tt, "box").X + cue++ + tt.Frame() + if jolted := find(t, tt, "box").X != x; jolted == reduce { + t.Errorf("reduce %v: jolted %v", reduce, jolted) + } + wait(tt, clk, time.Second) + if find(t, tt, "box").X != x { + t.Error("the box did not settle") + } + } +} diff --git a/feedback/feedback.go b/feedback/feedback.go new file mode 100644 index 0000000..f71818d --- /dev/null +++ b/feedback/feedback.go @@ -0,0 +1,66 @@ +// Package feedback tells the player what is going on. +package feedback + +import ( + "strconv" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// pingpong runs n back and forth over 0…span. +func pingpong(n, span int) int { + if span <= 0 { + return 0 + } + n %= 2 * span + if n > span { + return 2*span - n + } + return n +} + +// level is Good, then Warn past warn, Bad past bad. +func level(v, warn, bad float64) pixel.Slot { + switch { + case bad != 0 && v > bad: + return pixel.Bad + case warn != 0 && v > warn: + return pixel.Warn + } + return pixel.Good +} + +// fmtNum prints a number without trailing zeros. +func fmtNum(v float64) string { return strconv.FormatFloat(v, 'g', 4, 64) } + +type cueKey struct{} + +// cued is a cue's last value and when it changed. +type cued struct { + n int + at time.Time +} + +// cue is when n last changed; zero until it does. +func cue(e *ui.Element, k pixel.Kit, n int) time.Time { + s := ui.Local(e, cueKey{}, func() cued { return cued{n: n} }) + if s.n != n { + s.n, s.at = n, k.Now() + } + return s.at +} + +// since counts steps at fps from t, up to done. +func since(c *ui.Context, k pixel.Kit, t time.Time, fps, done int) int { + if t.IsZero() { + return done + } + n := int(k.Now().Sub(t) * time.Duration(fps) / time.Second) + if n < done { + k.Tick(c, fps) + } + return min(n, done) +} diff --git a/feedback/feedback_test.go b/feedback/feedback_test.go new file mode 100644 index 0000000..d4e80c8 --- /dev/null +++ b/feedback/feedback_test.go @@ -0,0 +1,180 @@ +package feedback_test + +import ( + "image" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/feedback" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type clock struct{ t time.Time } + +func (c *clock) now() time.Time { return c.t } + +// view builds on a fake clock; reduce stops motion. +func view(w, h int, clk *clock, reduce bool, build func(c *ui.Context)) *ui.Tester { + tt := ui.NewTester(func(c *ui.Context) { + pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: clk.now}) + build(c) + }, w, h) + if reduce { + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + } + return tt +} + +func newClock() *clock { return &clock{time.Unix(3_000_000, 0)} } + +// wait moves the clock on and builds a frame. +func wait(tt *ui.Tester, clk *clock, d time.Duration) { + clk.t = clk.t.Add(d) + tt.Frame() +} + +func find(t *testing.T, tt *ui.Tester, label string) ui.Rect { + t.Helper() + r, ok := tt.Find(label) + if !ok { + t.Fatalf("no %q in %q", label, tt.Texts()) + } + return r +} + +func click(t *testing.T, tt *ui.Tester, label string) { + t.Helper() + if err := tt.Click(label); err != nil { + t.Fatal(err) + } +} + +// slots are the palette slots of a box's dots. +func slots(t *testing.T, img *image.RGBA, r ui.Rect) []pixel.Slot { + t.Helper() + var out []pixel.Slot + for y := int(r.Y); y < int(r.Y+r.H); y += 2 { + for x := int(r.X); x < int(r.X+r.W); x += 2 { + c, found := img.RGBAAt(x, y), false + for s, pc := range pixel.SpringDay.Colors { + if pc.R == c.R && pc.G == c.G && pc.B == c.B { + out, found = append(out, pixel.Slot(s)), true + break + } + } + if !found { + t.Fatalf("pixel %d,%d off the palette", x, y) + } + } + } + return out +} + +func count(s []pixel.Slot, want pixel.Slot) int { + n := 0 + for _, v := range s { + if v == want { + n++ + } + } + return n +} + +func equal[T comparable](a, b []T) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// moves reports whether the labeled box changes as time passes. +func moves(t *testing.T, tt *ui.Tester, clk *clock, label string, d time.Duration) bool { + t.Helper() + before := slots(t, tt.Image(), find(t, tt, label)) + wait(tt, clk, d) + return !equal(before, slots(t, tt.Image(), find(t, tt, label))) +} + +func TestProgressFillsChunksAndRuns(t *testing.T) { + clk := newClock() + v := 0.5 + tt := view(400, 200, clk, true, func(c *ui.Context) { + feedback.Progress(c, "Load", v, feedback.ProgressOptions{Width: 103, Percent: true}) + }) + // 103 dots hold 20 chunks of 4×4 dots. + if n := count(slots(t, tt.Image(), find(t, tt, "Load")), pixel.Accent); n != 10*4*4 { + t.Fatalf("half a bar lit %d dots, want %d", n, 10*4*4) + } + v = -1 + tt.Frame() + if !moves(t, tt, clk, "Load", 200*time.Millisecond) { + t.Fatal("an unknown progress stood still under Reduce Motion") + } + defer func() { + if recover() == nil { + t.Fatal("a value past 1 did not panic") + } + }() + v = 1.5 + tt.Frame() +} + +func TestLoadersKeepMovingUnderReduceMotion(t *testing.T) { + clk := newClock() + tt := view(400, 300, clk, true, func(c *ui.Context) { + feedback.Spinner(c, "Spin", feedback.SpinnerOptions{Scale: 2}) + feedback.DotsLoader(c, "Dots", feedback.DotsLoaderOptions{}) + feedback.BarsLoader(c, "Bars", feedback.BarsLoaderOptions{}) + feedback.Hourglass(c, "Sand", feedback.HourglassOptions{}) + }) + for _, l := range []string{"Spin", "Dots", "Bars", "Sand"} { + if !moves(t, tt, clk, l, 300*time.Millisecond) { + t.Errorf("%s stood still", l) + } + } +} + +func TestMeterLevels(t *testing.T) { + for v, want := range map[float64]pixel.Slot{0.3: pixel.Good, 0.7: pixel.Warn, 0.95: pixel.Bad} { + tt := view(300, 100, newClock(), false, func(c *ui.Context) { + feedback.Meter(c, "HP", v, feedback.MeterOptions{Warning: 0.6, Critical: 0.9}) + }) + s := slots(t, tt.Image(), find(t, tt, "HP")) + if count(s, want) == 0 { + t.Errorf("%v shows no %v", v, want) + } + } +} + +func TestSkeletonShineStopsUnderReduceMotion(t *testing.T) { + for _, reduce := range []bool{false, true} { + clk := newClock() + tt := view(400, 200, clk, reduce, func(c *ui.Context) { + feedback.Skeleton(c, feedback.SkeletonOptions{Avatar: true}) + }) + if moved := moves(t, tt, clk, "NOW LOADING", 250*time.Millisecond); moved == reduce { + t.Errorf("reduce %v: the shine moved %v", reduce, moved) + } + } +} + +func TestLoadingScreenRotatesTips(t *testing.T) { + clk := newClock() + tt := view(600, 300, clk, false, func(c *ui.Context) { + feedback.LoadingScreen(c, feedback.LoadingScreenOptions{Tips: []string{"Save often.", "Pet the slime."}}) + }) + r := find(t, tt, "NOW LOADING") + tips := ui.Rect{X: r.X, Y: r.Y + 2*2*pixel.LineHeight + 16, W: r.W, H: 2 * 2 * pixel.LineHeight} + before := slots(t, tt.Image(), tips) + wait(tt, clk, 4*time.Second) + if equal(before, slots(t, tt.Image(), tips)) { + t.Fatal("the tip did not change after four seconds") + } +} diff --git a/feedback/hourglass.go b/feedback/hourglass.go new file mode 100644 index 0000000..aef6461 --- /dev/null +++ b/feedback/hourglass.go @@ -0,0 +1,67 @@ +package feedback + +import ( + "strings" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type HourglassOptions struct{} + +// glass is the hourglass, by hand; # marks its walls. +var glass = []string{ + "############", + ".#........#.", + ".#........#.", + "..#......#..", + "...#....#...", + "....#..#....", + ".....##.....", + ".....##.....", + "....#..#....", + "...#....#...", + "..#......#..", + ".#........#.", + ".#........#.", + "############", +} + +// Hourglass drains in steps, then flips. +func Hourglass(c *ui.Context, text string, o HourglassOptions) *ui.Element { + k := pixel.K(c) + return ui.Box(c).Role(ui.RoleProgress).Label(text).Size(k.D(14), k.D(14)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + f := g.Busy(4) % 8 + // Frame 6 lies on its side, mid-flip; 7 rests. + side := f == 6 + dot := func(x, y int, s pixel.Slot) { + if side { + g.Dot(y, 12-x, s) + } else { + g.Dot(x+1, y, s) + } + } + top, bottom := 4-min(f, 4), min(f, 4) + if f >= 6 { + top, bottom = 4, 0 + } + for y, row := range glass { + l, r := strings.Index(row, "#"), strings.LastIndex(row, "#") + for x := range len(row) { + switch { + case row[x] == '#': + dot(x, y, pixel.Edge) + case x > l && x < r && (y >= 6-top && y <= 5 || y >= 13-bottom && y <= 12): + dot(x, y, pixel.Warn) + } + } + } + if f > 0 && f < 5 { + for y := 6; y < 13-bottom; y++ { + dot(5, y, pixel.Warn) + } + } + }) +} diff --git a/feedback/inlinemessage.go b/feedback/inlinemessage.go new file mode 100644 index 0000000..c97e19e --- /dev/null +++ b/feedback/inlinemessage.go @@ -0,0 +1,48 @@ +package feedback + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var ( + bang = pixel.MustSprite(` + ..###.. + .##.##. + ###.### + ###.### + ####### + ###.### + .#####. + `) + tick = pixel.MustSprite(` + ......# + .....## + #...##. + ##.##.. + .###... + ..#.... + ....... + `) +) + +type InlineMessageOptions struct { + Kind Kind +} + +// InlineMessage is a small ✓ or ! field note. +func InlineMessage(c *ui.Context, message string, o InlineMessageOptions) *ui.Element { + k := pixel.K(c) + slot, _ := o.Kind.look() + sp := bang + if o.Kind == Success { + sp = tick + } + return ui.Row(c).Gap(k.D(3)).AlignItems(ui.Center).Role(ui.RoleStatus).Label(message).Children(func() { + ui.Box(c).Size(k.D(sp.W), k.D(sp.H)).Draw(func(p *ui.Painter, r ui.Rect) { + k.Canvas(p, r).Sprite(0, 0, sp, 0, slot, 1) + }) + pixel.Text(c, message, pixel.TextOptions{Slot: slot}) + }) +} diff --git a/feedback/levelup.go b/feedback/levelup.go new file mode 100644 index 0000000..75141bb --- /dev/null +++ b/feedback/levelup.go @@ -0,0 +1,60 @@ +package feedback + +import ( + "strconv" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type LevelUpOptions struct{} + +type levelKey struct{} + +// rise is the last level seen, and when it rose. +type rise struct { + level int + at time.Time +} + +// LevelUp slides in LEVEL UP! with sparkles when level rises. +func LevelUp(c *ui.Context, level int, o LevelUpOptions) *ui.Element { + k := pixel.K(c) + e := ui.Box(c) + s := ui.Local(e, levelKey{}, func() rise { return rise{level: level} }) + if level > s.level { + s.at = k.Now() + } + s.level = level + // Ten steps a second: six slide in, twenty stay. + step := since(c, k, s.at, 10, 26) + if step >= 26 { + return e + } + slide := 5 - min(step, 5) + if k.Reduce() { + slide = 0 + } + title := k.Msg("level_up") + " LV " + strconv.Itoa(level) + w := pixel.Measure(title)*2 + 24 + return e.Role(ui.RoleStatus).Label(title).Size(k.D(w), k.D(40)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + x := -slide * w / 5 + g.Clip(0, 0, g.W, g.H, func() { + g.Frame(x, 4, w, 32, pixel.Window, pixel.Accent) + g.Text(x+13, 5, title, pixel.Shade, 2) + g.Text(x+12, 4, title, pixel.AccentInk, 2) + }) + if slide > 0 { + return + } + for i, at := range [][2]int{{2, 0}, {w - 14, 2}, {w / 2, 28}, {8, 26}} { + if (g.Step(8)+i)%3 != 0 { + g.Sprite(at[0], at[1], icon.Sparkle, 0, pixel.Warn, 1) + } + } + }) +} diff --git a/feedback/loaders.go b/feedback/loaders.go new file mode 100644 index 0000000..ce93ae8 --- /dev/null +++ b/feedback/loaders.go @@ -0,0 +1,43 @@ +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) + } + }) +} + +type BarsLoaderOptions struct{} + +// heights rise and fall; each bar runs a step behind. +var heights = [8]int{3, 6, 9, 12, 9, 6, 3, 3} + +// BarsLoader raises and drops five bars. +func BarsLoader(c *ui.Context, text string, o BarsLoaderOptions) *ui.Element { + k := pixel.K(c) + return ui.Box(c).Role(ui.RoleProgress).Label(text).Size(k.D(23), k.D(12)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + f := g.Busy(10) + for i := range 5 { + h := heights[(f+8-i)%8] + g.Rect(i*5, 12-h, 3, h, pixel.Accent) + } + }) +} diff --git a/feedback/loadingscreen.go b/feedback/loadingscreen.go new file mode 100644 index 0000000..75a0073 --- /dev/null +++ b/feedback/loadingscreen.go @@ -0,0 +1,38 @@ +package feedback + +import ( + "strings" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type LoadingScreenOptions struct { + // Tips take turns, four seconds each. + Tips []string + // Width is in dots; zero means 240. + Width int +} + +// LoadingScreen shows NOW LOADING, stepping dots and a tip. +func LoadingScreen(c *ui.Context, o LoadingScreenOptions) *ui.Element { + k := pixel.K(c) + w := pixel.Or("LoadingScreen Width", o.Width, 240) + title := k.Msg("loading") + return ui.Box(c).Role(ui.RoleStatus).Label(title).Size(k.D(w), k.D(2*pixel.LineHeight+8+2*pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + n := g.Busy(4) + line := title + strings.Repeat(".", n%4) + x := (w - pixel.Measure(title+"...")*2) / 2 + g.Text(x+2, 2, line, pixel.Shade, 2) + g.Text(x, 0, line, pixel.Accent, 2) + if len(o.Tips) == 0 { + return + } + tip := o.Tips[n/16%len(o.Tips)] + for i, l := range pixel.Wrap(tip, w, 2) { + g.Text((w-pixel.Measure(l))/2, 2*pixel.LineHeight+8+i*pixel.LineHeight, l, pixel.Faint, 1) + } + }) +} diff --git a/feedback/meter.go b/feedback/meter.go new file mode 100644 index 0000000..f6e3537 --- /dev/null +++ b/feedback/meter.go @@ -0,0 +1,41 @@ +package feedback + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type MeterOptions struct { + // Min and Max are zero for 0 to 1. + Min, Max float64 + // Warning and Critical are levels; zero means none. + Warning, Critical float64 + // Width is in dots; zero means 120. + Width int +} + +// Meter shows a level in Good, Warn or Bad. +func Meter(c *ui.Context, text string, value float64, o MeterOptions) *ui.Element { + k := pixel.K(c) + lo, hi := o.Min, o.Max + if lo == 0 && hi == 0 { + hi = 1 + } + if hi <= lo { + panic("feedback: Meter range is empty") + } + w := pixel.Or("Meter Width", o.Width, 120) + at := func(v float64) int { return 2 + int(max(0, min(1, (v-lo)/(hi-lo)))*float64(w-4)) } + ink := level(value, o.Warning, o.Critical) + return ui.Box(c).Role(ui.RoleMeter).Label(text).Description(fmtNum(value)).Size(k.D(w), k.D(10)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, w, 10, pixel.Sunken, pixel.Well) + g.Rect(2, 2, at(value)-2, 6, ink) + for _, mark := range []float64{o.Warning, o.Critical} { + if mark != 0 { + g.VLine(at(mark), 1, 8, pixel.Edge) + } + } + }) +} diff --git a/feedback/progress.go b/feedback/progress.go new file mode 100644 index 0000000..def7ca8 --- /dev/null +++ b/feedback/progress.go @@ -0,0 +1,57 @@ +package feedback + +import ( + "fmt" + "math" + "strconv" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type ProgressOptions struct { + // Width is the bar in dots; zero means 120. + Width int + // Percent shows the number after the bar. + Percent bool +} + +// Progress fills chunk by chunk; below zero a block runs. +func Progress(c *ui.Context, text string, value float64, o ProgressOptions) *ui.Element { + k := pixel.K(c) + if value > 1 || math.IsNaN(value) { + panic(fmt.Sprintf("feedback: Progress value %v is past 1", value)) + } + w := pixel.Or("Progress Width", o.Width, 120) + n := (w - 3) / 5 + pct := strconv.Itoa(int(math.Round(value*100))) + "%" + e := ui.Box(c).Role(ui.RoleProgress).Label(text) + full := w + if value < 0 { + e.Description(k.Msg("loading")) + } else { + e.Description(pct) + if o.Percent { + full += 4 + pixel.MicroWidth("100%") + } + } + return e.Size(k.D(full), k.D(10)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, w, 10, pixel.Sunken, pixel.Well) + chunk := func(i int) { g.Rect(3+i*5, 3, 4, 4, pixel.Accent) } + if value < 0 { + at := pingpong(g.Busy(12), n-3) + for i := range 3 { + chunk(at + i) + } + return + } + for i := range int(value * float64(n)) { + chunk(i) + } + if o.Percent { + g.Micro(w+4, 2, pct, pixel.Ink, 1) + } + }) +} diff --git a/feedback/pulse.go b/feedback/pulse.go new file mode 100644 index 0000000..d540088 --- /dev/null +++ b/feedback/pulse.go @@ -0,0 +1,31 @@ +package feedback + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type PulseOptions struct { + // Slot colors the ring; zero means Accent. + Slot pixel.Slot +} + +// Pulse rings its child with a ring growing in steps. +func Pulse(c *ui.Context, o PulseOptions, content func()) *ui.Element { + k := pixel.K(c) + slot := o.Slot + if slot == 0 { + slot = pixel.Accent + } + return ui.Box(c).AlignSelf(ui.Start).Padding(k.D(6)).DrawOver(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + f := g.Step(6) % 6 + x, level := 5-f, 16-f*3 + w, h := g.W-2*x, g.H-2*x + g.Veil(x, x, w, 1, slot, level) + g.Veil(x, x+h-1, w, 1, slot, level) + g.Veil(x, x, 1, h, slot, level) + g.Veil(x+w-1, x, 1, h, slot, level) + }).Children(content) +} diff --git a/feedback/readygo.go b/feedback/readygo.go new file mode 100644 index 0000000..94780dd --- /dev/null +++ b/feedback/readygo.go @@ -0,0 +1,62 @@ +package feedback + +import ( + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type ReadyGoOptions struct { + // Cue starts the count each time it changes. + Cue int +} + +type ReadyGoResult struct { + Element *ui.Element + // Done comes once, as GO! shows. + Done bool +} + +type goKey struct{} + +// ready is a count's cue, start, and Done flag. +type ready struct { + n int + at time.Time + said bool +} + +// ReadyGo counts 3, 2, 1, GO! in big digits. +func ReadyGo(c *ui.Context, o ReadyGoOptions) ReadyGoResult { + k := pixel.K(c) + e := ui.Box(c) + s := ui.Local(e, goKey{}, func() ready { return ready{n: o.Cue} }) + if s.n != o.Cue { + *s = ready{n: o.Cue, at: k.Now()} + } + res := ReadyGoResult{Element: e} + sec := since(c, k, s.at, 10, 40) / 10 + // Done comes once, even when time jumps past GO!. + if sec >= 3 && !s.said && !s.at.IsZero() { + s.said, res.Done = true, true + slog.Debug("8bit: ready go") + } + if sec >= 4 { + return res + } + word := [...]string{"3", "2", "1", k.Msg("go")}[sec] + e.Role(ui.RoleStatus).Label(word).Size(k.D(120), k.D(4*pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + ink := pixel.Warn + if sec == 3 { + ink = pixel.Good + } + w := pixel.Measure(word) * 4 + g.Text((g.W-w)/2+2, 2, word, pixel.Shade, 4) + g.Text((g.W-w)/2, 0, word, ink, 4) + }) + return res +} diff --git a/feedback/saveindicator.go b/feedback/saveindicator.go new file mode 100644 index 0000000..578c40c --- /dev/null +++ b/feedback/saveindicator.go @@ -0,0 +1,49 @@ +package feedback + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type SaveIndicatorOptions struct{} + +type saveKey struct{} + +// spin is the floppy's width, frame by frame. +var spin = [4]int{12, 8, 2, 8} + +// SaveIndicator spins a floppy while saving, then shows SAVED ✓. +func SaveIndicator(c *ui.Context, saving bool, o SaveIndicatorOptions) *ui.Element { + k := pixel.K(c) + e := ui.Row(c).Gap(k.D(4)).AlignItems(ui.Center) + ever := ui.Local(e, saveKey{}, func() bool { return false }) + if saving { + *ever = true + } + if !*ever { + return e + } + word, ink := k.Msg("saved"), pixel.Good + if saving { + word, ink = k.Msg("saving")+"…", pixel.Faint + } + return e.Role(ui.RoleStatus).Label(word).Children(func() { + ui.Box(c).Size(k.D(12), k.D(12)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + if !saving { + g.Sprite(0, 0, icon.Check, 0, pixel.Good, 1) + return + } + // A coin spin: columns squeeze in, crisp. + w := spin[g.Busy(8)%4] + x0 := (12 - w) / 2 + for x := range w { + sx := x * 12 / w + g.Clip(x0+x, 0, 1, 12, func() { g.Sprite(x0+x-sx, 0, icon.Save, 0, pixel.Info, 1) }) + } + }) + pixel.Text(c, word, pixel.TextOptions{Slot: ink}) + }) +} diff --git a/feedback/shake.go b/feedback/shake.go new file mode 100644 index 0000000..925a42b --- /dev/null +++ b/feedback/shake.go @@ -0,0 +1,26 @@ +package feedback + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type ShakeOptions struct { + // Cue shakes the child each time it changes. + Cue int +} + +// shakes are the child's offsets in dots, a step each. +var shakes = [...]int{2, -2, 2, -2, 1, -1, 0} + +// Shake jolts its child side to side briefly. +func Shake(c *ui.Context, o ShakeOptions, content func()) *ui.Element { + k := pixel.K(c) + e := ui.Box(c) + dx := 0 + if step := since(c, k, cue(e, k, o.Cue), 20, len(shakes)-1); !k.Reduce() { + dx = shakes[step] + } + return e.Padding(0, k.D(2-dx), 0, k.D(2+dx)).Children(content) +} diff --git a/feedback/skeleton.go b/feedback/skeleton.go new file mode 100644 index 0000000..c6c86fd --- /dev/null +++ b/feedback/skeleton.go @@ -0,0 +1,44 @@ +package feedback + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type SkeletonOptions struct { + // Lines is the text lines; zero means 3. + Lines int + // Avatar puts a square before the lines. + Avatar bool + // Width is in dots; zero means 160. + Width int +} + +// Skeleton stands in for content; a dithered shine sweeps it. +func Skeleton(c *ui.Context, o SkeletonOptions) *ui.Element { + k := pixel.K(c) + n := pixel.Or("Skeleton Lines", o.Lines, 3) + w := pixel.Or("Skeleton Width", o.Width, 160) + h := n*12 - 4 + return ui.Box(c).Role(ui.RoleStatus).Label(k.Msg("loading")).Size(k.D(w), k.D(max(h, 24))).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + x := 0 + var shapes [][4]int + if o.Avatar { + shapes, x = append(shapes, [4]int{0, 0, 24, 24}), 30 + } + for i := range n { + lw := w - x + if i == n-1 && n > 1 { + lw = lw * 3 / 5 + } + shapes = append(shapes, [4]int{x, i * 12, lw, 8}) + } + shine := g.Step(8)*6%(w+48) - 24 + for _, s := range shapes { + g.Dither(s[0], s[1], s[2], s[3], pixel.Shade, pixel.Face, 4) + g.Clip(s[0], s[1], s[2], s[3], func() { g.Veil(shine, s[1], 16, s[3], pixel.Lit, 6) }) + } + }) +} diff --git a/feedback/spinner.go b/feedback/spinner.go new file mode 100644 index 0000000..b8cf918 --- /dev/null +++ b/feedback/spinner.go @@ -0,0 +1,44 @@ +package feedback + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type SpinnerOptions struct { + // Scale is 1–4; zero means 1. + Scale int +} + +// ring is where the spinner's eight beads sit, by hand. +var ring = [8][2]int{{5, 1}, {8, 2}, {9, 5}, {8, 8}, {5, 9}, {2, 8}, {1, 5}, {2, 2}} + +// Spinner turns through eight frames, even under Reduce Motion. +func Spinner(c *ui.Context, text string, o SpinnerOptions) *ui.Element { + k := pixel.K(c) + s := pixel.Or("Spinner Scale", o.Scale, 1) + if s > 4 { + panic("feedback: Spinner Scale past 4") + } + return ui.Box(c).Role(ui.RoleProgress).Label(text).Size(k.D(12*s), k.D(12*s)).Draw(func(p *ui.Painter, r ui.Rect) { + beads(k.Canvas(p, r), 0, 0, s) + }) +} + +// beads draws the spinner ring at x, y, scaled s. +func beads(g pixel.Canvas, x, y, s int) { + head := g.Busy(10) % 8 + for i, at := range ring { + ink := pixel.Shade + switch (head - i + 8) % 8 { + case 0: + ink = pixel.Accent + case 1: + ink = pixel.AccentLit + case 2: + ink = pixel.Faint + } + g.Rect(x+at[0]*s, y+at[1]*s, 2*s, 2*s, ink) + } +} diff --git a/feedback/states.go b/feedback/states.go new file mode 100644 index 0000000..1dc1323 --- /dev/null +++ b/feedback/states.go @@ -0,0 +1,127 @@ +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-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, 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 new file mode 100644 index 0000000..290b8fe --- /dev/null +++ b/feedback/states_test.go @@ -0,0 +1,221 @@ +package feedback_test + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/feedback" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestAlertAndBannerReportClicks(t *testing.T) { + closed, action, dismissed := 0, "", 0 + tt := view(500, 300, newClock(), false, func(c *ui.Context) { + if feedback.Alert(c, "Low HP", "Drink a potion.", feedback.AlertOptions{Kind: feedback.Danger, Closable: true}).Closed { + closed++ + } + r := feedback.Banner(c, "New quest!", feedback.BannerOptions{Kind: feedback.Info, Actions: []string{"VIEW"}, Dismissable: true}) + if r.Action != "" { + action = r.Action + } + if r.Dismissed { + dismissed++ + } + }) + if count(slots(t, tt.Image(), find(t, tt, "Low HP")), pixel.Bad) == 0 { + t.Fatal("a danger alert shows no Bad") + } + click(t, tt, "CLOSE") + click(t, tt, "VIEW") + if closed != 1 || action != "VIEW" { + t.Fatalf("closed %d, action %q", closed, action) + } + tt = view(500, 100, newClock(), false, func(c *ui.Context) { + if feedback.Banner(c, "New quest!", feedback.BannerOptions{Dismissable: true}).Dismissed { + dismissed++ + } + }) + click(t, tt, "CLOSE") + if dismissed != 1 { + t.Fatal("the banner's × did not dismiss it") + } +} + +func TestEmptyStateAction(t *testing.T) { + acted := 0 + tt := view(400, 300, newClock(), false, func(c *ui.Context) { + if feedback.EmptyState(c, "Your bag is empty.", feedback.EmptyStateOptions{Action: "SHOP"}).Acted { + acted++ + } + }) + find(t, tt, "NO ITEMS") + click(t, tt, "SHOP") + if acted != 1 { + t.Fatal("the action did not report") + } +} + +func TestErrorStateCountsDownAndContinues(t *testing.T) { + clk := newClock() + cont := 0 + tt := view(600, 500, clk, true, func(c *ui.Context) { + if feedback.ErrorState(c, "The slime won.", feedback.ErrorStateOptions{}).Continue { + cont++ + } + }) + find(t, tt, "CONTINUE? 9") + wait(tt, clk, 3*time.Second) + find(t, tt, "CONTINUE? 6") + wait(tt, clk, 10*time.Second) + find(t, tt, "CONTINUE? 0") + click(t, tt, "RETRY") + if cont != 1 { + t.Fatal("RETRY did not report Continue") + } +} + +func TestSuccessStateStarsPopInTurn(t *testing.T) { + stars := func(reduce bool, d time.Duration) int { + clk := newClock() + tt := view(600, 400, clk, reduce, func(c *ui.Context) { + feedback.SuccessState(c, "All coins found.", feedback.SuccessStateOptions{Stars: 3}) + }) + wait(tt, clk, d) + return count(slots(t, tt.Image(), find(t, tt, "STAGE CLEAR!")), pixel.Warn) + } + early, late := stars(false, 300*time.Millisecond), stars(false, 2*time.Second) + if early == 0 || early >= late { + t.Fatalf("stars did not pop in turn: %d then %d", early, late) + } + if stars(true, 0) != late { + t.Fatal("Reduce Motion did not show every star at once") + } +} + +func TestInlineMessageMarks(t *testing.T) { + tt := view(400, 100, newClock(), false, func(c *ui.Context) { + feedback.InlineMessage(c, "Name taken", feedback.InlineMessageOptions{Kind: feedback.Danger}) + feedback.InlineMessage(c, "Looks good", feedback.InlineMessageOptions{Kind: feedback.Success}) + }) + bad, good := slots(t, tt.Image(), find(t, tt, "Name taken")), slots(t, tt.Image(), find(t, tt, "Looks good")) + if count(bad, pixel.Bad) == 0 || count(good, pixel.Good) == 0 { + t.Fatal("marks do not take their kind's slot") + } + // The mark is the first seven dots across. + mark := func(l string, ink pixel.Slot) (out []bool) { + r := find(t, tt, l) + for _, v := range slots(t, tt.Image(), ui.Rect{X: r.X, Y: r.Y, W: 14, H: r.H}) { + out = append(out, v == ink) + } + return out + } + if equal(mark("Name taken", pixel.Bad), mark("Looks good", pixel.Good)) { + t.Fatal("✓ and ! look alike") + } +} + +func TestToastsQueueExpireAndAct(t *testing.T) { + clk := newClock() + q := []feedback.Toast{{Message: "one"}, {Message: "two"}, {Message: "three", For: 5 * time.Second}, {Message: "four", Action: "UNDO"}} + var acted *feedback.Toast + tt := view(500, 400, clk, false, func(c *ui.Context) { + if r := feedback.Toasts(c, &q, feedback.ToastsOptions{}); r.Acted != nil { + acted = r.Acted + } + }) + if _, ok := tt.Find("four"); ok || len(q) != 4 { + t.Fatal("more than three toasts showed") + } + wait(tt, clk, 3*time.Second) + find(t, tt, "three") + find(t, tt, "four") + if _, ok := tt.Find("one"); ok || len(q) != 2 { + t.Fatalf("toasts did not leave on time: %d left", len(q)) + } + click(t, tt, "UNDO") + if acted == nil || acted.Message != "four" || len(q) != 1 { + t.Fatal("the action did not report or clear its toast") + } + wait(tt, clk, 3*time.Second) + if len(q) != 0 { + t.Fatal("a five-second toast outlived its time") + } +} + +func TestContinuePromptAnswersOrTimesOut(t *testing.T) { + clk := newClock() + var yes, timedOut int + tt := view(400, 400, clk, false, func(c *ui.Context) { + r := feedback.ContinuePrompt(c, feedback.ContinuePromptOptions{Seconds: 5}) + if r.Yes { + yes++ + } + if r.TimedOut { + timedOut++ + } + }) + find(t, tt, "5") + wait(tt, clk, 2*time.Second) + find(t, tt, "3") + wait(tt, clk, 4*time.Second) + wait(tt, clk, time.Second) + click(t, tt, "YES") + if timedOut != 1 || yes != 0 { + t.Fatalf("timed out %d times; YES after time counted %d", timedOut, yes) + } + clk2 := newClock() + tt = view(400, 400, clk2, false, func(c *ui.Context) { + if feedback.ContinuePrompt(c, feedback.ContinuePromptOptions{}).Yes { + yes++ + } + }) + click(t, tt, "YES") + wait(tt, clk2, 20*time.Second) + if yes != 1 { + t.Fatal("YES did not report") + } +} + +func TestTwinToastsBothShow(t *testing.T) { + q := []feedback.Toast{{Message: "same"}, {Message: "same"}} + tt := view(500, 300, newClock(), false, func(c *ui.Context) { + feedback.Toasts(c, &q, feedback.ToastsOptions{}) + }) + n := 0 + for _, s := range tt.Texts() { + if s == "same" { + n++ + } + } + // Each toast and its text both carry the message. + if n != 4 { + t.Fatalf("%d labels for two twin toasts", n) + } +} + +func TestDisabledFeedbackButtonsDoNothing(t *testing.T) { + events := 0 + q := []feedback.Toast{{Message: "dropped", Action: "UNDO"}} + tt := view(800, 2000, newClock(), false, func(c *ui.Context) { + on := func(b bool) { + if b { + events++ + } + } + on(feedback.Alert(c, "A", "a", feedback.AlertOptions{Closable: true, Disabled: true}).Closed) + r := feedback.Banner(c, "B", feedback.BannerOptions{Actions: []string{"GO"}, Disabled: true}) + on(r.Action != "") + on(feedback.EmptyState(c, "E", feedback.EmptyStateOptions{Action: "SHOP", Disabled: true}).Acted) + on(feedback.ErrorState(c, "X", feedback.ErrorStateOptions{Disabled: true}).Continue) + 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"} { + click(t, tt, l) + } + if events != 0 { + t.Fatalf("disabled buttons reported %d events", events) + } +} diff --git a/feedback/toasts.go b/feedback/toasts.go new file mode 100644 index 0000000..e8bac30 --- /dev/null +++ b/feedback/toasts.go @@ -0,0 +1,103 @@ +package feedback + +import ( + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Toast is a short message in the queue Toasts shows. +type Toast struct { + Message string + // Action labels an optional button. + Action string + // For is how long it shows; zero means three seconds. + For time.Duration + // id and shown are set as it first shows. + id uint64 + shown time.Time +} + +type ToastsOptions struct { + // Width is each toast's in dots; zero means 200. + Width int + Disabled bool +} + +type ToastsResult struct { + // Element holds the toasts on screen. + Element *ui.Element + // Acted is the toast whose action was clicked, else nil. + Acted *Toast +} + +type toastsKey struct{} + +// Toasts shows three timed messages at the bottom. +func Toasts(c *ui.Context, queue *[]Toast, o ToastsOptions) ToastsResult { + k := pixel.K(c) + w := pixel.Or("Toasts Width", o.Width, 200) + now := k.Now() + kept := (*queue)[:0] + for _, t := range *queue { + if t.For < 0 { + panic("feedback: Toast For is negative") + } + life := t.For + if life == 0 { + life = 3 * time.Second + } + if t.shown.IsZero() || now.Sub(t.shown) < life { + kept = append(kept, t) + } + } + *queue = kept + n := min(3, len(*queue)) + last := ui.Local(c.Root(), toastsKey{}, func() uint64 { return 0 }) + for i := range n { + if (*queue)[i].id == 0 { + *last++ + (*queue)[i].id, (*queue)[i].shown = *last, now + } + } + if n > 0 { + k.Tick(c, 4) + } + res := ToastsResult{} + left := (c.Root().Bounds().W - k.D(w)) / 2 + ui.Overlay(c, func() { + res.Element = ui.Column(c).Absolute().Left(left).Bottom(k.D(8)).Width(k.D(w)).Gap(k.D(4)).Children(func() { + for i := range n { + t := (*queue)[i] + ui.Row(c).Key(t.id).Padding(k.D(5), k.D(6)).Gap(k.D(6)).AlignItems(ui.Center).Role(ui.RoleStatus).Label(t.Message).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Window, pixel.Face) + }).Children(func() { + pixel.Text(c, t.Message, pixel.TextOptions{Wrap: true, MaxLines: 2}).Grow(1) + if t.Action != "" && input.Button(c, t.Action, input.ButtonOptions{Size: input.Small, Disabled: o.Disabled}).Clicked { + res.Acted = &t + } + }) + } + }) + }) + if res.Acted != nil { + *queue = removeToast(*queue, res.Acted.id) + slog.Debug("8bit: toast action", "message", res.Acted.Message, "action", res.Acted.Action) + } + return res +} + +// removeToast drops the toast with id from q. +func removeToast(q []Toast, id uint64) []Toast { + for i := range q { + if q[i].id == id { + return append(q[:i], q[i+1:]...) + } + } + return q +} diff --git a/gallery/app_test.go b/gallery/app_test.go index 0aee118..be53324 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 a.locale != pixel.Zh || !tt.HasText("重试") { 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 new file mode 100644 index 0000000..e5b70bd --- /dev/null +++ b/gallery/demos_feedback.go @@ -0,0 +1,373 @@ +package gallery + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/feedback" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var feedbackEntries = []Entry{ + {106, "Progress", "进度条", "feedback", "A bar that fills chunk by chunk. Below zero, a block runs back and forth.", demoProgress}, + {107, "Spinner", "旋转器", "feedback", "Eight frames of beads going round. It keeps turning under Reduce Motion: it means work.", demoSpinner}, + {108, "Meter", "计量条", "feedback", "A level that turns Warn past a mark and Bad past the next.", demoMeter}, + {109, "Skeleton", "骨架屏", "feedback", "Dithered stand-ins with a shine sweeping across in steps.", demoSkeleton}, + {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}, + {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}, + {117, "BarsLoader", "柱状加载", "feedback", "Five bars rising and falling, a step apart.", demoBarsLoader}, + {118, "Hourglass", "沙漏", "feedback", "Sand falls in steps, then the glass flips.", demoHourglass}, + {119, "Toast", "吐司", "feedback", "Short messages at the bottom, three at a time, each for a few seconds. Some can be undone.", demoToast}, + {120, "InlineMessage", "行内提示", "feedback", "A small ! or ✓ note under a field.", demoInlineMessage}, + {121, "CountUp", "计数", "feedback", "Numbers count up in steps, the way scores should.", demoCountUp}, + {122, "Pulse", "脉冲", "feedback", "A dithered ring grows around what wants attention.", demoPulse}, + {123, "LevelUp", "升级", "feedback", "LEVEL UP! slides in with sparkles when the level rises.", demoLevelUp}, + {124, "Achievement", "成就", "feedback", "A trophy card slides in, waits, and slides away.", demoAchievement}, + {125, "ComboCounter", "连击", "feedback", "×12 COMBO! It pops a dot bigger with every hit.", demoComboCounter}, + {126, "ContinuePrompt", "继续?", "feedback", "CONTINUE? YES or NO before the count runs out.", demoContinuePrompt}, + {127, "SaveIndicator", "存档指示", "feedback", "A floppy spins while saving, then SAVED ✓.", demoSaveIndicator}, + {128, "BusyOverlay", "忙碌遮罩", "feedback", "A checker veil and spinner over busy content; it takes the pointer and the focus.", demoBusyOverlay}, + {129, "ReadyGo", "预备开始", "feedback", "3, 2, 1, GO! in big digits, a second each.", demoReadyGo}, + {130, "Shake", "抖动", "feedback", "Jolts its child side to side, for a wrong answer.", demoShake}, +} + +type feedbackDemo struct { + progress, hp float64 + closed bool + tries, replays, prompts, score int + level, cue, combo, start, shake int + toasts []feedback.Toast + hero, secret, motto string + saving, busy time.Time +} + +func feedbackState(c *ui.Context) *feedbackDemo { + return ui.Local(c.Root(), demoKey("feedback"), func() feedbackDemo { + return feedbackDemo{progress: 0.3, hp: 0.5, level: 4} + }) +} + +func demoProgress(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := feedbackState(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + feedback.Progress(c, "Download", s.progress, feedback.ProgressOptions{Percent: true}) + feedback.Progress(c, "Searching", -1, feedback.ProgressOptions{}) + ui.Row(c).Gap(k.D(4)).Children(func() { + if input.Button(c, "+10%", input.ButtonOptions{Size: input.Small}).Clicked { + s.progress = min(1, s.progress+0.1) + log(fmt.Sprintf("%.0f%%", s.progress*100)) + } + if input.Button(c, "RESET", input.ButtonOptions{Size: input.Small}).Clicked { + s.progress = 0 + log("reset") + } + }) + }) +} + +func demoSpinner(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Row(c).Gap(k.D(12)).AlignItems(ui.Center).Children(func() { + for sc := 1; sc <= 3; sc++ { + feedback.Spinner(c, fmt.Sprintf("Loading ×%d", sc), feedback.SpinnerOptions{Scale: sc}) + } + }) +} + +func demoMeter(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := feedbackState(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + feedback.Meter(c, "Heat", s.hp, feedback.MeterOptions{Warning: 0.6, Critical: 0.85}) + if input.Slider(c, "Heat level", &s.hp, input.SliderOptions{}).Changed { + log(fmt.Sprintf("heat %.2f", s.hp)) + } + }) +} + +func demoSkeleton(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Column(c).Gap(k.D(10)).Children(func() { + feedback.Skeleton(c, feedback.SkeletonOptions{Avatar: true}) + feedback.Skeleton(c, feedback.SkeletonOptions{Lines: 2, Width: 120}) + }) +} + +func demoAlert(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := feedbackState(c) + ui.Column(c).Gap(k.D(4)).Children(func() { + feedback.Alert(c, "Did you know?", "Pressing P paints the world anew.", feedback.AlertOptions{Kind: feedback.Info}) + feedback.Alert(c, "Quest complete", "The princess was in this castle.", feedback.AlertOptions{Kind: feedback.Success}) + feedback.Alert(c, "Low on potions", "Two left. Shop before the boss.", feedback.AlertOptions{Kind: feedback.Warning}) + if !s.closed && feedback.Alert(c, "Save corrupted", "Just kidding. Close me.", feedback.AlertOptions{Kind: feedback.Danger, Closable: true}).Closed { + s.closed = true + log("closed") + } + }) +} + +func demoBanner(c *ui.Context, log func(string)) { + r := feedback.Banner(c, "A new patch is out: slimes now bounce higher.", feedback.BannerOptions{Kind: feedback.Info, Actions: []string{"UPDATE", "LATER"}, Dismissable: true}) + if r.Action != "" { + log(r.Action) + } + if r.Dismissed { + log("dismissed") + } +} + +func demoEmptyState(c *ui.Context, log func(string)) { + if feedback.EmptyState(c, "Your inventory is empty.", feedback.EmptyStateOptions{Action: "VISIT SHOP"}).Acted { + log("off to the shop") + } +} + +func demoErrorState(c *ui.Context, log func(string)) { + s := feedbackState(c) + // A new key starts a fresh countdown. + ui.Box(c).Key(s.tries).Children(func() { + if feedback.ErrorState(c, "The slime won this round.", feedback.ErrorStateOptions{}).Continue { + s.tries++ + log(fmt.Sprintf("continue #%d", s.tries)) + } + }) +} + +func demoSuccessState(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := feedbackState(c) + ui.Column(c).Gap(k.D(4)).AlignItems(ui.Center).Children(func() { + ui.Box(c).Key(s.replays).Children(func() { + feedback.SuccessState(c, "All 99 coins found.", feedback.SuccessStateOptions{}) + }) + if input.Button(c, "REPLAY", input.ButtonOptions{Size: input.Small}).Clicked { + s.replays++ + log("replay") + } + }) +} + +func demoLoadingScreen(c *ui.Context, log func(string)) { + feedback.LoadingScreen(c, feedback.LoadingScreenOptions{Tips: []string{ + "Tip: slimes fear the color red.", "Tip: press P to change the palette.", "Tip: save often. The dragon does not wait.", + }}) +} + +func demoDotsLoader(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Row(c).Gap(k.D(6)).AlignItems(ui.Center).Children(func() { + feedback.DotsLoader(c, "Thinking", feedback.DotsLoaderOptions{}) + pixel.Text(c, "The wizard is thinking", pixel.TextOptions{Slot: pixel.Faint}) + }) +} + +func demoBarsLoader(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Row(c).Gap(k.D(6)).AlignItems(ui.Center).Children(func() { + feedback.BarsLoader(c, "Tuning", feedback.BarsLoaderOptions{}) + pixel.Text(c, "Tuning the chiptune", pixel.TextOptions{Slot: pixel.Faint}) + }) +} + +func demoHourglass(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Row(c).Gap(k.D(6)).AlignItems(ui.Center).Children(func() { + feedback.Hourglass(c, "Waiting", feedback.HourglassOptions{}) + pixel.Text(c, "The turn passes", pixel.TextOptions{Slot: pixel.Faint}) + }) +} + +func demoToast(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := feedbackState(c) + ui.Row(c).Gap(k.D(4)).Children(func() { + if input.Button(c, "SAVE GAME", input.ButtonOptions{Size: input.Small}).Clicked { + s.toasts = append(s.toasts, feedback.Toast{Message: "Game saved."}) + log("toast: saved") + } + if input.Button(c, "DROP SWORD", input.ButtonOptions{Size: input.Small}).Clicked { + s.toasts = append(s.toasts, feedback.Toast{Message: "You dropped the sword.", Action: "UNDO", For: 5 * time.Second}) + log("toast: dropped") + } + }) + if r := feedback.Toasts(c, &s.toasts, feedback.ToastsOptions{}); r.Acted != nil { + log(r.Acted.Action + ": " + r.Acted.Message) + } +} + +func demoInlineMessage(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := feedbackState(c) + ui.Column(c).Gap(k.D(3)).Children(func() { + if input.TextInput(c, "Hero name", &s.hero, input.TextInputOptions{Placeholder: "Your name"}).Changed { + log("name " + s.hero) + } + if s.hero == "" { + feedback.InlineMessage(c, "A hero needs a name.", feedback.InlineMessageOptions{Kind: feedback.Danger}) + } else { + feedback.InlineMessage(c, "That name is free!", feedback.InlineMessageOptions{Kind: feedback.Success}) + } + }) +} + +func demoCountUp(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := feedbackState(c) + ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() { + feedback.CountUp(c, s.score, feedback.CountUpOptions{Scale: 2, Slot: pixel.Accent}) + for _, add := range []int{100, 1000} { + if input.Button(c, fmt.Sprintf("+%d", add), input.ButtonOptions{Size: input.Small}).Clicked { + s.score += add + log(fmt.Sprintf("score %d", s.score)) + } + } + }) +} + +func demoPulse(c *ui.Context, log func(string)) { + feedback.Pulse(c, feedback.PulseOptions{}, func() { + if input.IconButton(c, icon.Mail, "New mail", input.IconButtonOptions{}).Clicked { + log("mail opened") + } + }) +} + +func demoLevelUp(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := feedbackState(c) + ui.Column(c).Gap(k.D(4)).Children(func() { + if input.Button(c, "GAIN XP", input.ButtonOptions{Size: input.Small}).Clicked { + s.level++ + log(fmt.Sprintf("level %d", s.level)) + } + feedback.LevelUp(c, s.level, feedback.LevelUpOptions{}) + }) +} + +func demoAchievement(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := feedbackState(c) + titles := []string{"Pressed Start", "Slime Tamer", "Touched Grass", "Read the Manual"} + ui.Column(c).Gap(k.D(4)).Children(func() { + if input.Button(c, "UNLOCK", input.ButtonOptions{Size: input.Small}).Clicked { + s.cue++ + log("unlocked " + titles[s.cue%len(titles)]) + } + feedback.Achievement(c, titles[s.cue%len(titles)], feedback.AchievementOptions{Cue: s.cue}) + }) +} + +func demoComboCounter(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := feedbackState(c) + ui.Column(c).Gap(k.D(4)).Children(func() { + ui.Row(c).Gap(k.D(4)).Children(func() { + if input.Button(c, "HIT", input.ButtonOptions{Size: input.Small, Variant: input.Primary}).Clicked { + s.combo++ + log(fmt.Sprintf("combo %d", s.combo)) + } + if input.Button(c, "MISS", input.ButtonOptions{Size: input.Small}).Clicked { + s.combo = 0 + log("combo broken") + } + }) + feedback.ComboCounter(c, s.combo, feedback.ComboCounterOptions{}) + }) +} + +func demoContinuePrompt(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := feedbackState(c) + ui.Column(c).Gap(k.D(4)).AlignItems(ui.Center).Children(func() { + ui.Box(c).Key(s.prompts).Children(func() { + r := feedback.ContinuePrompt(c, feedback.ContinuePromptOptions{}) + switch { + case r.Yes: + log("yes: one more go") + case r.No: + log("no: thanks for playing") + case r.TimedOut: + log("time up") + } + }) + if input.Button(c, "AGAIN", input.ButtonOptions{Size: input.Small}).Clicked { + s.prompts++ + log("again") + } + }) +} + +func demoSaveIndicator(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := feedbackState(c) + saving := k.Now().Before(s.saving) + if saving { + k.Tick(c, 4) + } + ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() { + if input.Button(c, "SAVE", input.ButtonOptions{Size: input.Small, Disabled: saving}).Clicked { + s.saving = k.Now().Add(1500 * time.Millisecond) + log("saving") + } + feedback.SaveIndicator(c, saving, feedback.SaveIndicatorOptions{}) + }) +} + +func demoBusyOverlay(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := feedbackState(c) + busy := k.Now().Before(s.busy) + if busy { + k.Tick(c, 4) + } + feedback.BusyOverlay(c, busy, feedback.BusyOverlayOptions{}, func() { + ui.Column(c).Gap(k.D(4)).Padding(k.D(4)).Children(func() { + if input.TextInput(c, "Guild motto", &s.motto, input.TextInputOptions{Placeholder: "We came, we saved"}).Changed { + log("motto " + s.motto) + } + if input.Button(c, "SEND", input.ButtonOptions{Size: input.Small}).Clicked { + s.busy = k.Now().Add(2 * time.Second) + log("sending…") + } + }) + }) +} + +func demoReadyGo(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := feedbackState(c) + ui.Column(c).Gap(k.D(4)).AlignItems(ui.Center).Children(func() { + if input.Button(c, "START", input.ButtonOptions{Size: input.Small, Variant: input.Primary}).Clicked { + s.start++ + log("ready") + } + if feedback.ReadyGo(c, feedback.ReadyGoOptions{Cue: s.start}).Done { + log("GO!") + } + }) +} + +func demoShake(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := feedbackState(c) + ui.Column(c).Gap(k.D(4)).Children(func() { + feedback.Shake(c, feedback.ShakeOptions{Cue: s.shake}, func() { + input.PasswordInput(c, "Password", &s.secret, input.PasswordInputOptions{Placeholder: "Speak, friend"}) + }) + if input.Button(c, "ENTER", input.ButtonOptions{Size: input.Small}).Clicked { + s.shake++ + log("wrong password") + } + }) +} diff --git a/gallery/pages.go b/gallery/pages.go index a7f35d9..5556fc9 100644 --- a/gallery/pages.go +++ b/gallery/pages.go @@ -7,7 +7,7 @@ import ( "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/feedback" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" @@ -187,24 +187,9 @@ func (a *App) results(c *ui.Context) { }) } -// lost answers unknown paths with GAME OVER and CONTINUE. +// lost answers unknown paths with GAME OVER. func (a *App) lost(c *ui.Context, path string) { - if GameOver(c, "No page at "+path) { + if feedback.ErrorState(c, "No page at "+path, feedback.ErrorStateOptions{}).Continue { a.Go("/") } } - -// GameOver shows why the game ended; true on CONTINUE. -func GameOver(c *ui.Context, why string) bool { - k := pixel.K(c) - cont := false - ui.Column(c).Gap(k.D(8)).AlignItems(ui.Center).Padding(k.D(24)).Children(func() { - ui.Box(c).Size(k.D(pixel.Measure(k.Msg("game_over"))*3), k.D(48)).Draw(func(p *ui.Painter, r ui.Rect) { - shadow(k.Canvas(p, r), 0, 0, k.Msg("game_over"), pixel.Bad, 3) - }) - pixel.Text(c, why, pixel.TextOptions{Slot: pixel.Faint, Wrap: true, MaxLines: 6}).Width(k.D(min(pixel.Measure(why), 280))) - display.Icon(c, icon.Skull, display.IconOptions{Scale: 3}) - cont = input.Button(c, k.Msg("continue"), input.ButtonOptions{}).Clicked - }) - return cont -} diff --git a/gallery/registry.go b/gallery/registry.go index 91e3193..6fa62d9 100644 --- a/gallery/registry.go +++ b/gallery/registry.go @@ -56,7 +56,7 @@ var Categories = []Category{ {"tabletop", "Tabletop", "桌游", icon.Crown}, } -var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries) +var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries) func Entries() []Entry { return entries } diff --git a/input/editabletext.go b/input/editabletext.go index 64fcd5d..229ddeb 100644 --- a/input/editabletext.go +++ b/input/editabletext.go @@ -30,7 +30,7 @@ type editable struct { // EditableText edits on double click or Enter; Escape reverts. func EditableText(c *ui.Context, text string, value *string, o EditableTextOptions) EditableTextResult { k := pixel.K(c) - w := orDef("EditableText Width", o.Width, 120) + w := pixel.Or("EditableText Width", o.Width, 120) box := ui.Box(c) s := ui.Local(box, editableKey{}, func() editable { return editable{} }) res := EditableTextResult{Element: box} diff --git a/input/fader.go b/input/fader.go index 0d35131..4ba88a0 100644 --- a/input/fader.go +++ b/input/fader.go @@ -19,7 +19,7 @@ type FaderResult = SliderResult func Fader(c *ui.Context, text string, value *float64, o FaderOptions) FaderResult { k := pixel.K(c) lo, hi := bounds(o.Min, o.Max) - h := orDef("Fader Height", o.Height, 64) + h := pixel.Or("Fader Height", o.Height, 64) shell, st, changed := slide(c, value, rail{lo: lo, hi: hi, vertical: true, label: text, disabled: o.Disabled}) shell.Size(k.D(16), k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) diff --git a/input/input.go b/input/input.go index 275f865..dc794c6 100644 --- a/input/input.go +++ b/input/input.go @@ -88,17 +88,6 @@ func finish(g pixel.Canvas, st pixel.State) { } } -// orDef is v, or def for zero; negative panics. -func orDef(name string, v, def int) int { - if v < 0 { - panic(fmt.Sprintf("input: %s %d is negative", name, v)) - } - if v == 0 { - return def - } - return v -} - // label is s, or the fallback when s is empty. func label(s, fallback string) string { if s != "" { diff --git a/input/multiselect.go b/input/multiselect.go index f993d46..a2dcb1f 100644 --- a/input/multiselect.go +++ b/input/multiselect.go @@ -31,7 +31,7 @@ func MultiSelect(c *ui.Context, text string, chosen *[]string, options []string, return ui.ButtonBase(c).Role(ui.RolePopUpButton).Label(text).Description(strings.Join(*chosen, ", ")).Disabled(o.Disabled) }) open := ui.Local(base, multiKey{}, func() bool { return false }) - if o.Disabled { + if o.Disabled || pixel.Current(c).Inert { // Closing on disable must not return focus to it. if *open { c.Root().Focus() @@ -41,7 +41,7 @@ func MultiSelect(c *ui.Context, text string, chosen *[]string, options []string, if base.Clicked() || !*open && base.Shortcut(0, ui.KeyDown) { *open = !*open } - shell.Size(k.D(orDef("MultiSelect Width", o.Width, 160)), k.D(pixel.LineHeight+4)).Draw(func(p *ui.Painter, r ui.Rect) { + shell.Size(k.D(pixel.Or("MultiSelect Width", o.Width, 160)), k.D(pixel.LineHeight+4)).Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) ink, dy := face(g, Normal, st, *open) if len(*chosen) == 0 { diff --git a/input/pick_test.go b/input/pick_test.go index 3cf4475..3346a1b 100644 --- a/input/pick_test.go +++ b/input/pick_test.go @@ -132,6 +132,38 @@ func TestBadOptionsPanic(t *testing.T) { } } +func TestInertClosesPopups(t *testing.T) { + for name, build := range map[string]func(c *ui.Context){ + "Class": func(c *ui.Context) { + class := "A" + input.Select(c, "Class", &class, []string{"A", "B"}, input.SelectOptions{}) + }, + "Gear": func(c *ui.Context) { + var gear []string + input.MultiSelect(c, "Gear", &gear, []string{"B", "C"}, input.MultiSelectOptions{}) + }, + } { + inert := false + tt := view(400, 300, nil, func(c *ui.Context) { + s := pixel.Current(c) + s.Inert = inert + pixel.Scope(c, s, func() { build(c) }) + }) + click(t, tt, name) + find(t, tt, "B") + inert = true + tt.Frame() + if _, ok := tt.Find("B"); ok { + t.Fatalf("%s stayed open under Inert", name) + } + inert = false + tt.Frame() + if _, ok := tt.Find("B"); ok { + t.Fatalf("%s opened again after Inert", name) + } + } +} + func TestMultiSelectChecksAndShowsTags(t *testing.T) { var gear []string changes := 0 diff --git a/input/pininput.go b/input/pininput.go index 5dbb3c7..2b4bb67 100644 --- a/input/pininput.go +++ b/input/pininput.go @@ -27,7 +27,7 @@ const pinBox, pinGap = 12, 4 // PinInput types digits into boxes; Backspace goes back, paste fills. func PinInput(c *ui.Context, text string, value *string, o PinInputOptions) PinInputResult { k := pixel.K(c) - n := orDef("PinInput Length", o.Length, 4) + n := pixel.Or("PinInput Length", o.Length, 4) shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.Box(c).Focusable().Role(ui.RoleTextField).Label(text).Description(*value).Disabled(o.Disabled) }) diff --git a/input/rangeslider.go b/input/rangeslider.go index ed8b516..4bde118 100644 --- a/input/rangeslider.go +++ b/input/rangeslider.go @@ -21,7 +21,7 @@ type RangeSliderResult = SliderResult func RangeSlider(c *ui.Context, text string, low, high *float64, o RangeSliderOptions) RangeSliderResult { k := pixel.K(c) lo, hi := bounds(o.Min, o.Max) - w := orDef("RangeSlider Width", o.Width, 96) + w := pixel.Or("RangeSlider Width", o.Width, 96) was := [2]float64{*low, *high} e := ui.Box(c).Size(k.D(w), k.D(12)).Role(ui.RoleGroup).Label(text) e.Draw(func(p *ui.Painter, r ui.Rect) { diff --git a/input/rating.go b/input/rating.go index 78a1700..f9c2720 100644 --- a/input/rating.go +++ b/input/rating.go @@ -28,7 +28,7 @@ type ratingKey struct{} // Rating sets *value by click or arrows; reclicking clears. func Rating(c *ui.Context, text string, value *int, o RatingOptions) RatingResult { k := pixel.K(c) - n := orDef("Rating Max", o.Max, 5) + n := pixel.Or("Rating Max", o.Max, 5) full, empty := icon.Star, icon.StarEmpty if o.Hearts { full, empty = icon.Heart, icon.HeartEmpty diff --git a/input/select.go b/input/select.go index 74cd4d0..18a1b42 100644 --- a/input/select.go +++ b/input/select.go @@ -22,19 +22,25 @@ type SelectResult struct { // Select picks one option from a popup list. func Select(c *ui.Context, text string, value *string, options []string, o SelectOptions) SelectResult { k := pixel.K(c) + off := o.Disabled || pixel.Current(c).Inert var sel *ui.SelectParts[string] - shell, base, st := pixel.Shell(c, func() *ui.Element { - sel = ui.SelectBase(c, value) - return sel.Trigger.Label(text).Disabled(o.Disabled) + var shell, base *ui.Element + var st pixel.State + // Re-keying on each off state forgets an open popup. + ui.Box(c).Key(off).Children(func() { + shell, base, st = pixel.Shell(c, func() *ui.Element { + sel = ui.SelectBase(c, value) + return sel.Trigger.Label(text).Disabled(o.Disabled) + }) }) - shell.Size(k.D(orDef("Select Width", o.Width, widest(options)+22)), k.D(pixel.LineHeight+4)).Draw(func(p *ui.Painter, r ui.Rect) { + shell.Size(k.D(pixel.Or("Select Width", o.Width, widest(options)+22)), k.D(pixel.LineHeight+4)).Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) - ink, dy := face(g, Normal, st, sel.Open() && !o.Disabled) + ink, dy := face(g, Normal, st, sel.Open() && !off) g.Text(6, 2+dy, *value, ink, 1) g.Sprite(g.W-10, (g.H-tinyDown.H)/2+dy, tinyDown, 0, ink, 1) finish(g, st) }) - if !o.Disabled { + if !off { sel.Popup(func(panel *ui.Element) { menu(c, panel, func() { for _, opt := range options { diff --git a/input/slider.go b/input/slider.go index bddbf73..e232c05 100644 --- a/input/slider.go +++ b/input/slider.go @@ -27,7 +27,7 @@ type SliderResult struct { func Slider(c *ui.Context, text string, value *float64, o SliderOptions) SliderResult { k := pixel.K(c) lo, hi := bounds(o.Min, o.Max) - w := orDef("Slider Width", o.Width, 96) + w := pixel.Or("Slider Width", o.Width, 96) shell, st, changed := slide(c, value, rail{lo: lo, hi: hi, label: text, disabled: o.Disabled}) shell.Size(k.D(w), k.D(12)).Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) diff --git a/input/stepper.go b/input/stepper.go index 716679c..33f3b6a 100644 --- a/input/stepper.go +++ b/input/stepper.go @@ -29,7 +29,7 @@ func Stepper(c *ui.Context, text string, value *int, o StepperOptions) StepperRe panic(fmt.Sprintf("input: Stepper range %d to %d is empty", o.Min, o.Max)) } k := pixel.K(c) - step := orDef("Stepper Step", o.Step, 1) + step := pixel.Or("Stepper Step", o.Step, 1) old := *value set := func(v int) { *value = max(o.Min, min(o.Max, v)) } w := max(pixel.Measure(strconv.Itoa(o.Min)), pixel.Measure(strconv.Itoa(o.Max))) + 8 diff --git a/input/stepslider.go b/input/stepslider.go index d30121e..a36306e 100644 --- a/input/stepslider.go +++ b/input/stepslider.go @@ -24,7 +24,7 @@ func StepSlider(c *ui.Context, text string, value *float64, o StepSliderOptions) if step <= 0 { step = (hi - lo) / 10 } - w := orDef("StepSlider Width", o.Width, 96) + w := pixel.Or("StepSlider Width", o.Width, 96) var res SliderResult row := ui.Row(c).Gap(k.D(6)).AlignItems(ui.Center).Children(func() { shell, st, changed := slide(c, value, rail{lo: lo, hi: hi, step: step, label: text, disabled: o.Disabled}) diff --git a/input/textarea.go b/input/textarea.go index 810c187..0827b39 100644 --- a/input/textarea.go +++ b/input/textarea.go @@ -33,8 +33,8 @@ type TextAreaResult = TextInputResult // TextArea wraps lines in its own scroll, following the caret. func TextArea(c *ui.Context, text string, value *string, o TextAreaOptions) TextAreaResult { k := pixel.K(c) - lines := orDef("TextArea Lines", o.Lines, 4) - col := ui.Column(c).Width(k.D(orDef("TextArea Width", o.Width, 160))).Gap(k.D(2)) + lines := pixel.Or("TextArea Lines", o.Lines, 4) + col := ui.Column(c).Width(k.D(pixel.Or("TextArea Width", o.Width, 160))).Gap(k.D(2)) st := ui.Local(col, areaKey{}, func() areaState { return areaState{line: -1} }) if o.Disabled { st.scroll.Y = st.held diff --git a/input/textinput.go b/input/textinput.go index 0a897bf..4d189ec 100644 --- a/input/textinput.go +++ b/input/textinput.go @@ -32,7 +32,7 @@ func TextInput(c *ui.Context, label string, value *string, o TextInputOptions) T func field(c *ui.Context, value *string, eo pixel.EditorOptions, width int, bad func() bool, icon *pixel.Sprite, after func()) (pixel.Edit, *ui.Element) { k := pixel.K(c) var ed pixel.Edit - e := ui.Row(c).Width(k.D(orDef("Width", width, 120))).Height(k.D(pixel.LineHeight+6)).Padding(0, k.D(4)).Gap(k.D(3)).AlignItems(ui.Center) + e := ui.Row(c).Width(k.D(pixel.Or("Width", width, 120))).Height(k.D(pixel.LineHeight+6)).Padding(0, k.D(4)).Gap(k.D(3)).AlignItems(ui.Center) e.Children(func() { if icon != nil { ui.Box(c).Size(k.D(icon.W), k.D(icon.H)).Draw(func(p *ui.Painter, r ui.Rect) { diff --git a/input/tokenfield.go b/input/tokenfield.go index 6ea999d..a43524e 100644 --- a/input/tokenfield.go +++ b/input/tokenfield.go @@ -31,7 +31,7 @@ type editorSlot struct{} // TokenField shows tokens as tags; Enter or comma adds. func TokenField(c *ui.Context, text string, tokens *[]string, o TokenFieldOptions) TokenFieldResult { k := pixel.K(c) - e := ui.Row(c).Wrap().Width(k.D(orDef("TokenField Width", o.Width, 200))).Padding(k.D(3), k.D(4)).Gap(k.D(3)).AlignItems(ui.Center) + e := ui.Row(c).Wrap().Width(k.D(pixel.Or("TokenField Width", o.Width, 200))).Padding(k.D(3), k.D(4)).Gap(k.D(3)).AlignItems(ui.Center) e.Role(ui.RoleGroup).Label(text).Description(strings.Join(*tokens, ", ")) draft := ui.Local(e, tokenKey{}, func() string { return "" }) res := TokenFieldResult{Element: e} diff --git a/internal/archtest/archtest_test.go b/internal/archtest/archtest_test.go index 262a8f2..ef8b187 100644 --- a/internal/archtest/archtest_test.go +++ b/internal/archtest/archtest_test.go @@ -20,11 +20,11 @@ const module = "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/" var tiers = map[string]int{ "pixel": 0, "icon": 1, "layout": 2, "text": 2, "display": 2, - "input": 3, "feedback": 3, - "navigation": 4, "data": 4, "overlay": 4, "datetime": 4, "chart": 4, "fx": 4, - "game": 5, "chat": 5, "media": 5, "code": 5, "account": 5, "files": 5, - "project": 5, "shop": 5, "agent": 5, "desk": 5, "tabletop": 5, - "gallery": 6, "cmd/gallery": 7, "cmd/web": 7, + "input": 3, "feedback": 4, + "navigation": 5, "data": 5, "overlay": 5, "datetime": 5, "chart": 5, "fx": 5, + "game": 6, "chat": 6, "media": 6, "code": 6, "account": 6, "files": 6, + "project": 6, "shop": 6, "agent": 6, "desk": 6, "tabletop": 6, + "gallery": 7, "cmd/gallery": 8, "cmd/web": 8, } // Styled ui widgets drawing outside the pixel style. diff --git a/layout/appframe.go b/layout/appframe.go index acde29e..47988d5 100644 --- a/layout/appframe.go +++ b/layout/appframe.go @@ -16,8 +16,8 @@ type AppFrameOptions struct { // AppFrame arranges header, sidebar, main and footer; nil ones vanish. func AppFrame(c *ui.Context, o AppFrameOptions, header, sidebar, main, footer func()) *ui.Element { k := pixel.K(c) - sw := orDef("AppFrame SidebarWidth", o.SidebarWidth, 96) - fold := orDef("AppFrame FoldBelow", o.FoldBelow, 320) + sw := pixel.Or("AppFrame SidebarWidth", o.SidebarWidth, 96) + fold := pixel.Or("AppFrame FoldBelow", o.FoldBelow, 320) if main == nil { panic("layout: AppFrame needs main") } diff --git a/layout/aspectbox.go b/layout/aspectbox.go index 8804a91..d9489b9 100644 --- a/layout/aspectbox.go +++ b/layout/aspectbox.go @@ -14,7 +14,7 @@ type AspectBoxOptions struct { // AspectBox fills the width and keeps W:H in whole dots. func AspectBox(c *ui.Context, o AspectBoxOptions, children func()) *ui.Element { k := pixel.K(c) - rw, rh := orDef("AspectBox W", o.W, 1), orDef("AspectBox H", o.H, 1) + rw, rh := pixel.Or("AspectBox W", o.W, 1), pixel.Or("AspectBox H", o.H, 1) e := ui.Box(c).FillWidth().Clip() w, check := pixel.WrapWidth(c, e) return e.Height(k.D(w * rh / rw)).Draw(func(p *ui.Painter, r ui.Rect) { check(r) }).Children(children) diff --git a/layout/grid.go b/layout/grid.go index 04e308b..b094cb0 100644 --- a/layout/grid.go +++ b/layout/grid.go @@ -27,9 +27,9 @@ type GridOptions struct { // Grid lays n cells in equal columns, lines between them. func Grid(c *ui.Context, n int, o GridOptions, cell func(i int)) *ui.Element { k := pixel.K(c) - cols := orDef("Grid Columns", o.Columns, 2) - pad := orDef("Grid Pad", o.Pad, 4) - orDef("Grid n", n, 0) + cols := pixel.Or("Grid Columns", o.Columns, 2) + pad := pixel.Or("Grid Pad", o.Pad, 4) + pixel.Or("Grid n", n, 0) if o.Lines > DottedLines { panic(fmt.Sprintf("layout: unknown grid lines %d", o.Lines)) } diff --git a/layout/handheld.go b/layout/handheld.go index 21b98b5..507ff29 100644 --- a/layout/handheld.go +++ b/layout/handheld.go @@ -27,7 +27,7 @@ const bezel = 8 // Handheld is a pocket console around screen; buttons report presses. func Handheld(c *ui.Context, o HandheldOptions, screen func()) HandheldResult { k := pixel.K(c) - sw, sh := orDef("Handheld ScreenW", o.ScreenW, 160), orDef("Handheld ScreenH", o.ScreenH, 144) + sw, sh := pixel.Or("Handheld ScreenW", o.ScreenW, 160), pixel.Or("Handheld ScreenH", o.ScreenH, 144) res := HandheldResult{} res.Element = ui.Column(c).Width(k.D(sw + 2*bezel + 40)).Padding(k.D(10)).Gap(k.D(6)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(o.Label).Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) diff --git a/layout/internal_test.go b/layout/internal_test.go index 58467fe..89a9677 100644 --- a/layout/internal_test.go +++ b/layout/internal_test.go @@ -18,10 +18,10 @@ func panics(t *testing.T, name string, fn func()) { } func TestOrDef(t *testing.T) { - if orDef("x", 0, 7) != 7 || orDef("x", 3, 7) != 3 { + if pixel.Or("x", 0, 7) != 7 || pixel.Or("x", 3, 7) != 3 { t.Fatal("orDef did not default zero or keep a value") } - panics(t, "negative option", func() { orDef("x", -1, 7) }) + panics(t, "negative option", func() { pixel.Or("x", -1, 7) }) panics(t, "unknown frame style", func() { border(pixel.FrameStyle(99)) }) } diff --git a/layout/layout.go b/layout/layout.go index 5a792d1..23f5e5a 100644 --- a/layout/layout.go +++ b/layout/layout.go @@ -2,24 +2,11 @@ package layout import ( - "fmt" - "github.com/egoist/mygo/ui" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" ) -// orDef is v, or def for zero; negative panics. -func orDef(name string, v, def int) int { - if v < 0 { - panic(fmt.Sprintf("layout: %s %d is negative", name, v)) - } - if v == 0 { - return def - } - return v -} - // box frames the canvas unless it is too small. func box(g pixel.Canvas, style pixel.FrameStyle, fill pixel.Slot) { if g.W >= 3 && g.H >= 3 { diff --git a/layout/masonry.go b/layout/masonry.go index 3dccd4e..b3d6361 100644 --- a/layout/masonry.go +++ b/layout/masonry.go @@ -18,8 +18,8 @@ type MasonryOptions struct { // Masonry puts each item into the shortest column. func Masonry(c *ui.Context, heights []int, o MasonryOptions, item func(i int)) *ui.Element { k := pixel.K(c) - gap := orDef("Masonry Gap", o.Gap, 4) - n := orDef("Masonry Columns", o.Columns, 3) + gap := pixel.Or("Masonry Gap", o.Gap, 4) + n := pixel.Or("Masonry Columns", o.Columns, 3) cols := lanes(heights, n, gap) row := ui.Row(c).FillWidth().Gap(k.D(gap)).AlignItems(ui.Start) w, check := pixel.WrapWidth(c, row) diff --git a/layout/panel.go b/layout/panel.go index b58ee1b..d4d75ba 100644 --- a/layout/panel.go +++ b/layout/panel.go @@ -21,7 +21,7 @@ func Panel(c *ui.Context, o PanelOptions, children func()) *ui.Element { if fill == pixel.Ink { fill = pixel.Face } - pad := border(o.Style) + orDef("Panel Padding", o.Padding, 4) + pad := border(o.Style) + pixel.Or("Panel Padding", o.Padding, 4) return ui.Column(c).Padding(k.D(pad)).Draw(func(p *ui.Painter, r ui.Rect) { box(k.Canvas(p, r), o.Style, fill) }).Children(children) diff --git a/layout/resizable.go b/layout/resizable.go index 1851492..12e4829 100644 --- a/layout/resizable.go +++ b/layout/resizable.go @@ -26,8 +26,8 @@ type ResizableResult struct { // Resizable is a *w×*h dot box with a ◢ grip. func Resizable(c *ui.Context, w, h *int, o ResizableOptions, children func()) ResizableResult { k := pixel.K(c) - minW, minH := orDef("Resizable MinW", o.MinW, 24), orDef("Resizable MinH", o.MinH, 24) - maxW, maxH := orDef("Resizable MaxW", o.MaxW, 0), orDef("Resizable MaxH", o.MaxH, 0) + minW, minH := pixel.Or("Resizable MinW", o.MinW, 24), pixel.Or("Resizable MinH", o.MinH, 24) + maxW, maxH := pixel.Or("Resizable MaxW", o.MaxW, 0), pixel.Or("Resizable MaxH", o.MaxH, 0) if maxW > 0 && maxW < minW || maxH > 0 && maxH < minH { panic(fmt.Sprintf("layout: Resizable max %d×%d under min %d×%d", maxW, maxH, minW, minH)) } diff --git a/layout/screen.go b/layout/screen.go index 59b5992..cc92950 100644 --- a/layout/screen.go +++ b/layout/screen.go @@ -15,7 +15,7 @@ type ScreenOptions struct { // Screen fills the window with Back; children form a column. func Screen(c *ui.Context, o ScreenOptions, children func()) *ui.Element { k := pixel.K(c) - pad := orDef("Screen Padding", o.Padding, 0) + pad := pixel.Or("Screen Padding", o.Padding, 0) return ui.Column(c).Fill().Padding(k.D(pad)).Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) g.Rect(0, 0, g.W, g.H, pixel.Back) diff --git a/layout/section.go b/layout/section.go index f533169..733ed81 100644 --- a/layout/section.go +++ b/layout/section.go @@ -17,7 +17,7 @@ type SectionOptions struct { func Section(c *ui.Context, title string, o SectionOptions, children func()) *ui.Element { k := pixel.K(c) up := strings.ToUpper(title) - return ui.Column(c).Gap(k.D(orDef("Section Gap", o.Gap, 4))).Role(ui.RoleGroup).Label(title).Children(func() { + return ui.Column(c).Gap(k.D(pixel.Or("Section Gap", o.Gap, 4))).Role(ui.RoleGroup).Label(title).Children(func() { ui.Box(c).FillWidth().Height(k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) mid, tw := pixel.LineHeight/2, pixel.Measure(up) diff --git a/layout/split.go b/layout/split.go index dd168dc..d4c9910 100644 --- a/layout/split.go +++ b/layout/split.go @@ -27,7 +27,7 @@ type SplitResult struct { // Split shows two panes and a grip moving *size dots. func Split(c *ui.Context, size *int, o SplitOptions, first, second func()) SplitResult { k := pixel.K(c) - least := orDef("Split Min", o.Min, 24) + least := pixel.Or("Split Min", o.Min, 24) var e *ui.Element var total float32 back, fwd := ui.KeyLeft, ui.KeyRight diff --git a/layout/tiles.go b/layout/tiles.go index fc23af6..1250a92 100644 --- a/layout/tiles.go +++ b/layout/tiles.go @@ -16,10 +16,10 @@ type TilesOptions struct { // Tiles wraps n equal tiles into rows. func Tiles(c *ui.Context, n int, o TilesOptions, tile func(i int)) *ui.Element { k := pixel.K(c) - w := orDef("Tiles W", o.W, 48) - h := orDef("Tiles H", o.H, w) - orDef("Tiles n", n, 0) - return ui.Row(c).Wrap().Gap(k.D(orDef("Tiles Gap", o.Gap, 4))).Children(func() { + w := pixel.Or("Tiles W", o.W, 48) + h := pixel.Or("Tiles H", o.H, w) + pixel.Or("Tiles n", n, 0) + return ui.Row(c).Wrap().Gap(k.D(pixel.Or("Tiles Gap", o.Gap, 4))).Children(func() { for i := range n { ui.Box(c).Size(k.D(w), k.D(h)).Clip().Children(func() { tile(i) }) } diff --git a/layout/viewport.go b/layout/viewport.go index 2cb1ba1..5e2ab10 100644 --- a/layout/viewport.go +++ b/layout/viewport.go @@ -14,7 +14,7 @@ type ViewportOptions struct { // Viewport fills its parent and draws children at W×H dots. func Viewport(c *ui.Context, o ViewportOptions, children func()) *ui.Element { k := pixel.K(c) - vw, vh := orDef("Viewport W", o.W, 160), orDef("Viewport H", o.H, 144) + vw, vh := pixel.Or("Viewport W", o.W, 160), pixel.Or("Viewport H", o.H, 144) base := int(k.Dot()) outer := ui.Box(c).Fill() was := outer.Bounds() diff --git a/layout/window.go b/layout/window.go index f2da23b..5ea44a9 100644 --- a/layout/window.go +++ b/layout/window.go @@ -18,7 +18,7 @@ type WindowOptions struct { // Window is a beveled window with an optional title tab. func Window(c *ui.Context, o WindowOptions, children func()) *ui.Element { k := pixel.K(c) - pad := 3 + orDef("Window Padding", o.Padding, 6) + pad := 3 + pixel.Or("Window Padding", o.Padding, 6) top := 0 e := ui.Column(c) if o.Title != "" { diff --git a/pixel/element.go b/pixel/element.go index 7147196..936a173 100644 --- a/pixel/element.go +++ b/pixel/element.go @@ -92,15 +92,30 @@ type State struct{ Hover, Press, Focus, Disabled bool } func Shell(c *ui.Context, base func() *ui.Element) (shell, b *ui.Element, st State) { shell = ui.Box(c) shell.Children(func() { b = base().Fill().FocusRing(false) }) - off := b.IsDisabled() - // MyGo keeps focus and keys on a newly disabled element. - if off && b.Focused() { - c.Root().Focus() + own, inert := b.IsDisabled(), current(c).cur.Inert + if inert { + b.Disabled(true) } - st = State{Hover: b.Hovered(), Press: b.Pressed() && !off, Focus: b.FocusVisible(), Disabled: off} + off := own || inert + // MyGo keeps focus and keys on a newly disabled element. + parked := ui.Local(b, parkKey{}, func() bool { return false }) + switch { + case off && b.Focused(): + c.Root().Focus() + *parked = true + case !off && *parked: + // Focus comes back unless something else took it. + if root := c.Root(); root.Focused() || !root.FocusWithin() { + b.Focus() + } + *parked = false + } + st = State{Hover: b.Hovered(), Press: b.Pressed() && !off, Focus: b.FocusVisible(), Disabled: own} return shell, b, st } +type parkKey struct{} + // Hand is the pointing cursor of focus and menus. var Hand = MustSprite(` .eee...... diff --git a/pixel/messages.go b/pixel/messages.go index 47eb388..a7a4730 100644 --- a/pixel/messages.go +++ b/pixel/messages.go @@ -13,6 +13,8 @@ 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", + "combo": "COMBO!", "saving": "SAVING", "saved": "SAVED", "go": "GO!", "wait": "PLEASE WAIT", }, Zh: { "ok": "确定", "cancel": "取消", "yes": "是", "no": "否", "close": "关闭", @@ -24,6 +26,8 @@ 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": "成就解锁", + "combo": "连击!", "saving": "存档中", "saved": "已存档", "go": "开始!", "wait": "请稍候", }, } diff --git a/pixel/settings.go b/pixel/settings.go index 329a7ec..df0d20f 100644 --- a/pixel/settings.go +++ b/pixel/settings.go @@ -22,6 +22,8 @@ type Settings struct { Locale Locale // Now replaces the frame clock, for tests. Now func() time.Time + // Inert controls take no input, as under a busy veil. + Inert bool } // Kit is one frame's settings, captured by components. @@ -131,3 +133,14 @@ func theme(p *Palette) *ui.Theme { t.Selection, t.Focus, t.Scrollbar, t.Radius = c[AccentLit], c[Accent], ui.Transparent, 0 return t } + +// Or is v, or def for zero; negatives panic. +func Or(name string, v, def int) int { + if v < 0 { + panic(fmt.Sprintf("pixel: %s %d is negative", name, v)) + } + if v == 0 { + return def + } + return v +} diff --git a/text/blinktext.go b/text/blinktext.go index 53a19ba..d4561d4 100644 --- a/text/blinktext.go +++ b/text/blinktext.go @@ -16,7 +16,7 @@ type BlinkTextOptions struct { // BlinkText blinks; under Reduce Motion it stays shown. func BlinkText(c *ui.Context, s string, o BlinkTextOptions) *ui.Element { k, sc := pixel.K(c), scale(o.Scale) - fps := orDef("BlinkText FPS", o.FPS, 2) + fps := pixel.Or("BlinkText FPS", o.FPS, 2) return block(c, k, s, pixel.Measure(s)*sc, pixel.LineHeight*sc, func(g pixel.Canvas) { if g.Step(fps)%2 == 0 { g.Text(0, 0, s, o.Slot, sc) diff --git a/text/heading.go b/text/heading.go index fdeaba4..01260cb 100644 --- a/text/heading.go +++ b/text/heading.go @@ -16,7 +16,7 @@ type HeadingOptions struct { // Heading is a title with a 1-dot Shade shadow. func Heading(c *ui.Context, s string, o HeadingOptions) *ui.Element { - level := orDef("Heading Level", o.Level, 1) + level := pixel.Or("Heading Level", o.Level, 1) if level > 3 { panic(fmt.Sprintf("text: Heading Level %d outside 1–3", level)) } diff --git a/text/marquee.go b/text/marquee.go index f02df97..b06c8e0 100644 --- a/text/marquee.go +++ b/text/marquee.go @@ -19,7 +19,7 @@ type marqueeState struct{ offset, last int } // Marquee scrolls s a dot a step; hover pauses it. func Marquee(c *ui.Context, s string, o MarqueeOptions) *ui.Element { k := pixel.K(c) - w := orDef("Marquee Width", o.Width, 120) + w := pixel.Or("Marquee Width", o.Width, 120) e := ui.Box(c).Size(k.D(w), k.D(pixel.LineHeight)).Clip().Label(s).Role(ui.RoleText) ms := ui.Local(e, marqueeKey{}, func() marqueeState { return marqueeState{last: -1} }) if k.Reduce() || e.Hovered() { diff --git a/text/odometer.go b/text/odometer.go index f89c065..18c41bc 100644 --- a/text/odometer.go +++ b/text/odometer.go @@ -31,7 +31,7 @@ func Odometer(c *ui.Context, value int, o OdometerOptions) *ui.Element { panic(fmt.Sprintf("text: Odometer value %d is negative", value)) } k := pixel.K(c) - digits := orDef("Odometer Digits", o.Digits, len(strconv.Itoa(value))) + digits := pixel.Or("Odometer Digits", o.Digits, len(strconv.Itoa(value))) if value >= int(math.Pow10(digits)) { panic(fmt.Sprintf("text: Odometer value %d needs more than %d digits", value, digits)) } diff --git a/text/paragraph.go b/text/paragraph.go index cc0d4c5..a1405b3 100644 --- a/text/paragraph.go +++ b/text/paragraph.go @@ -27,7 +27,7 @@ type ParagraphOptions struct { // Paragraph wraps s at the width it had last frame. func Paragraph(c *ui.Context, s string, o ParagraphOptions) *ui.Element { k := pixel.K(c) - gap := orDef("Paragraph Gap", o.Gap, 0) + gap := pixel.Or("Paragraph Gap", o.Gap, 0) if o.Align > Right { panic(fmt.Sprintf("text: unknown Align %d", o.Align)) } diff --git a/text/scrambletext.go b/text/scrambletext.go index 15b3a68..1e251e5 100644 --- a/text/scrambletext.go +++ b/text/scrambletext.go @@ -25,7 +25,7 @@ type scrambleState struct { // ScrambleText settles from random glyphs to s, left to right. func ScrambleText(c *ui.Context, s string, o ScrambleTextOptions) *ui.Element { k, sc := pixel.K(c), scale(o.Scale) - fps := orDef("ScrambleText FPS", o.FPS, 30) + fps := pixel.Or("ScrambleText FPS", o.FPS, 30) rs := []rune(s) e := ui.Box(c).Size(k.D(pixel.Measure(s)*sc), k.D(pixel.LineHeight*sc)).Label(s).Role(ui.RoleText) st := ui.Local(e, scrambleKey{}, func() scrambleState { return scrambleState{start: -1} }) diff --git a/text/text.go b/text/text.go index dc5f796..112142d 100644 --- a/text/text.go +++ b/text/text.go @@ -16,20 +16,9 @@ func block(c *ui.Context, k pixel.Kit, s string, w, h int, fn func(g pixel.Canva }) } -// orDef is v, or def for zero; negative panics. -func orDef(name string, v, def int) int { - if v < 0 { - panic(fmt.Sprintf("text: %s %d is negative", name, v)) - } - if v == 0 { - return def - } - return v -} - // scale is s, or 1 for zero; outside 1–4 panics. func scale(s int) int { - s = orDef("Scale", s, 1) + s = pixel.Or("Scale", s, 1) if s > 4 { panic(fmt.Sprintf("text: Scale %d outside 1–4", s)) } diff --git a/text/typewriter.go b/text/typewriter.go index 7481789..a4315ba 100644 --- a/text/typewriter.go +++ b/text/typewriter.go @@ -38,7 +38,7 @@ type typeState struct { // Typewriter reveals s; a click or Enter shows the rest. func Typewriter(c *ui.Context, s string, o TypewriterOptions) TypewriterResult { k := pixel.K(c) - cps := orDef("Typewriter CPS", o.CPS, 20) + cps := pixel.Or("Typewriter CPS", o.CPS, 20) shell, base, st := pixel.Shell(c, func() *ui.Element { e := ui.Box(c).Role(ui.RoleText).Label(s).Disabled(o.Disabled) if !o.Disabled { diff --git a/text/wavetext.go b/text/wavetext.go index 79bfc21..f6f78f2 100644 --- a/text/wavetext.go +++ b/text/wavetext.go @@ -18,7 +18,7 @@ type WaveTextOptions struct { // WaveText bobs characters on a sine wave at 10 fps. func WaveText(c *ui.Context, s string, o WaveTextOptions) *ui.Element { k, sc := pixel.K(c), scale(o.Scale) - amp := orDef("WaveText Amp", o.Amp, 2) + amp := pixel.Or("WaveText Amp", o.Amp, 2) return block(c, k, s, pixel.Measure(s)*sc, pixel.LineHeight*sc+2*amp, func(g pixel.Canvas) { step := g.Step(10) each(s, sc, func(i int, r rune, x int) { diff --git a/web/deploy.sh b/web/deploy.sh index 55ff6e8..138648f 100755 --- a/web/deploy.sh +++ b/web/deploy.sh @@ -1,5 +1,5 @@ #!/bin/sh -# Ships files first, then the page: no build misses a file. +# Ships files first, then the page, so none goes missing. set -eu cd "$(dirname "$0")" site=https://8bitmygoui.zhangyanghaha0407.workers.dev @@ -47,7 +47,7 @@ old=$(sed -n 's#.*src="\(a/main\.[0-9a-f]*\.js\)".*#\1#p' "$stage/index.html") wasm=$(sed -n "s#.*fetch('\\(a/app\\.[0-9a-f]*\\.wasm\\)').*#\\1#p" "dist/$old") curl -fs --max-time 300 "$site/$wasm" -o "dist/$wasm" cp -R dist/a dist/_headers "$stage/" -# Step one: the live page, with the new files beside it. +# Step one: the live page beside the new files. upload "$stage" "$rev files" npx wrangler versions deploy "$id@100%" --yes --message "$rev files" patient curl -fs -o /dev/null "$site/$main" diff --git a/web/tests/smoke.spec.js b/web/tests/smoke.spec.js index c69bf61..4452933 100644 --- a/web/tests/smoke.spec.js +++ b/web/tests/smoke.spec.js @@ -224,12 +224,12 @@ 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; CONTINUE reloads', async ({ page }) => { +test('a Go panic shows GAME OVER in pixels; RETRY reloads', async ({ page }) => { 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, 'CONTINUE?') + await click(page, 'RETRY') await page.waitForFunction(() => window.__8bit?.ready && window.__8bit.page() === '/f/icons') expect(await texts(page)).toContain('COPY CODE') })