Add feedback components 106-130; inert scope, focus return, two-step deploy
This commit is contained in:
87 files changed
+2492
-147
No files matched your search
@@ -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_<category>.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=<dir> 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=<url> npx playwright test` checks a deployed site. Tests read `window.__8bit` (`ready`, `stats()`, `texts()`, `page()`, `find(label)`, `log`).
|
||||
|
||||
|
||||
+2
-1
@@ -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")
|
||||
}
|
||||
})
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+1
-12
@@ -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))
|
||||
}
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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()
|
||||
}
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+2
-2
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
+3
-18
@@ -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
|
||||
}
|
||||
+1
-1
@@ -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 }
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+12
-6
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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})
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2
-2
@@ -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")
|
||||
}
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+3
-3
@@ -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))
|
||||
}
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
@@ -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)) })
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+2
-2
@@ -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))
|
||||
}
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+4
-4
@@ -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) })
|
||||
}
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
+1
-1
@@ -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 != "" {
|
||||
|
||||
+20
-5
@@ -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......
|
||||
|
||||
@@ -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": "请稍候",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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))
|
||||
}
|
||||
|
||||
+1
-1
@@ -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() {
|
||||
|
||||
+1
-1
@@ -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))
|
||||
}
|
||||
|
||||
+1
-1
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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} })
|
||||
|
||||
+1
-12
@@ -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))
|
||||
}
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+2
-2
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user