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

+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
}