Add feedback components 106-130; inert scope, focus return, two-step deploy

This commit is contained in:
ZacharyZhang-NY committed 2026-10-11 20:58:30 -04:00
1 parent bce416182e
commit bd3877e1b1
87 files changed
+2492 -147

No files matched your search

+5 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+46
View File
@@ -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)
})
})
}
+76
View File
@@ -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
}
+55
View File
@@ -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
}
+54
View File
@@ -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)
}
})
})
}
+45
View File
@@ -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)
})
}
+61
View File
@@ -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
}
+47
View File
@@ -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)
})
}
+269
View File
@@ -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")
}
}
}
+66
View File
@@ -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)
}
+180
View File
@@ -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")
}
}
+67
View File
@@ -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)
}
}
})
}
+48
View File
@@ -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})
})
}
+60
View File
@@ -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)
}
}
})
}
+43
View File
@@ -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)
}
})
}
+38
View File
@@ -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)
}
})
}
+41
View File
@@ -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)
}
}
})
}
+57
View File
@@ -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)
}
})
}
+31
View File
@@ -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)
}
+62
View File
@@ -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
}
+49
View File
@@ -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})
})
}
+26
View File
@@ -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)
}
+44
View File
@@ -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) })
}
})
}
+44
View File
@@ -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)
}
}
+127
View File
@@ -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{})
})
}
+221
View File
@@ -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)
}
}
+103
View File
@@ -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
View File
@@ -123,10 +123,10 @@ func TestLostPageAndLocale(t *testing.T) {
t.Fatalf("lost page texts %q", tt.Texts())
}
click(t, tt, "language")
if a.locale != pixel.Zh || !tt.HasText("继续?") {
if 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())
}
+373
View File
@@ -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
View File
@@ -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
View File
@@ -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 }
+1 -1
View File
@@ -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
View File
@@ -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)
-11
View File
@@ -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 != "" {
+2 -2
View File
@@ -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 {
+32
View File
@@ -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
View File
@@ -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)
})
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+5 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+2 -2
View File
@@ -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)) })
}
-13
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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......
+4
View File
@@ -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": "请稍候",
},
}
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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))
}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+2 -2
View File
@@ -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')
})