Add feedback components 106-130; inert scope, focus return, two-step deploy
This commit is contained in:
87 files changed
+2492
-147
No files matched your search
@@ -0,0 +1,46 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type AchievementOptions struct {
|
||||
// Cue shows the card each time it changes.
|
||||
Cue int
|
||||
// Width is in dots; zero means 200.
|
||||
Width int
|
||||
}
|
||||
|
||||
// Achievement slides a trophy card in, holds it, then out.
|
||||
func Achievement(c *ui.Context, title string, o AchievementOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
w := pixel.Or("Achievement Width", o.Width, 200)
|
||||
e := ui.Box(c)
|
||||
// Ten steps a second; in by 5, gone by 40.
|
||||
step := since(c, k, cue(e, k, o.Cue), 10, 40)
|
||||
if step >= 40 {
|
||||
return e
|
||||
}
|
||||
out := 0
|
||||
switch {
|
||||
case k.Reduce():
|
||||
case step < 5:
|
||||
out = 5 - step
|
||||
case step >= 35:
|
||||
out = step - 34
|
||||
}
|
||||
head := k.Msg("achievement")
|
||||
return e.Role(ui.RoleStatus).Label(head+" "+title).Size(k.D(w), k.D(40)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
x := out * w / 5
|
||||
g.Clip(0, 0, g.W, g.H, func() {
|
||||
g.Frame(x, 0, w, 40, pixel.Window, pixel.Face)
|
||||
g.Sprite(x+6, 8, icon.Trophy, 0, pixel.Warn, 2)
|
||||
g.Text(x+36, 4, head, pixel.Faint, 1)
|
||||
g.Text(x+36, 20, title, pixel.Ink, 1)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
|
||||
)
|
||||
|
||||
// Kind is a message's tone.
|
||||
type Kind uint8
|
||||
|
||||
const (
|
||||
Info Kind = iota
|
||||
Success
|
||||
Warning
|
||||
Danger
|
||||
)
|
||||
|
||||
// look is a kind's slot and icon.
|
||||
func (k Kind) look() (pixel.Slot, *pixel.Sprite) {
|
||||
switch k {
|
||||
case Info:
|
||||
return pixel.Info, icon.Info
|
||||
case Success:
|
||||
return pixel.Good, icon.Check
|
||||
case Warning:
|
||||
return pixel.Warn, icon.Warning
|
||||
case Danger:
|
||||
return pixel.Bad, icon.Error
|
||||
}
|
||||
panic(fmt.Sprintf("feedback: unknown Kind %d", k))
|
||||
}
|
||||
|
||||
type AlertOptions struct {
|
||||
Kind Kind
|
||||
// Closable adds a × that reports Closed.
|
||||
Closable bool
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type AlertResult struct {
|
||||
Element *ui.Element
|
||||
Closed bool
|
||||
}
|
||||
|
||||
// Alert is a framed callout: icon, title and text.
|
||||
func Alert(c *ui.Context, title, message string, o AlertOptions) AlertResult {
|
||||
k := pixel.K(c)
|
||||
slot, sp := o.Kind.look()
|
||||
res := AlertResult{}
|
||||
res.Element = ui.Row(c).Gap(k.D(6)).Padding(k.D(6), k.D(8), k.D(6), k.D(10)).AlignItems(ui.Start).Role(ui.RoleStatus).Label(title).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
g.Frame(0, 0, g.W, g.H, pixel.Window, pixel.Face)
|
||||
g.Rect(3, 3, 3, g.H-6, slot)
|
||||
}).Children(func() {
|
||||
display.Icon(c, sp, display.IconOptions{Slot: slot})
|
||||
ui.Column(c).Grow(1).Gap(k.D(2)).Children(func() {
|
||||
pixel.Text(c, title, pixel.TextOptions{Slot: slot})
|
||||
text.Paragraph(c, message, text.ParagraphOptions{})
|
||||
})
|
||||
if o.Closable {
|
||||
res.Closed = input.IconButton(c, icon.Close, k.Msg("close"), input.IconButtonOptions{Variant: input.Ghost, Size: input.Small, Disabled: o.Disabled}).Clicked
|
||||
}
|
||||
})
|
||||
if res.Closed {
|
||||
slog.Debug("8bit: alert closed", "title", title)
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
|
||||
)
|
||||
|
||||
type BannerOptions struct {
|
||||
Kind Kind
|
||||
// Actions label buttons; Action reports the one clicked.
|
||||
Actions []string
|
||||
// Dismissable adds a × that reports Dismissed.
|
||||
Dismissable bool
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type BannerResult struct {
|
||||
Element *ui.Element
|
||||
Action string
|
||||
Dismissed bool
|
||||
}
|
||||
|
||||
// Banner is a full-width strip with a message and actions.
|
||||
func Banner(c *ui.Context, message string, o BannerOptions) BannerResult {
|
||||
k := pixel.K(c)
|
||||
slot, sp := o.Kind.look()
|
||||
res := BannerResult{}
|
||||
res.Element = ui.Row(c).Gap(k.D(6)).Padding(k.D(4), k.D(8)).AlignItems(ui.Center).Role(ui.RoleStatus).Label(message).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
g.Dither(0, 0, g.W, g.H, slot, pixel.Face, 12)
|
||||
g.HLine(0, g.H-1, g.W, slot)
|
||||
}).Children(func() {
|
||||
display.Icon(c, sp, display.IconOptions{Slot: slot})
|
||||
ui.Column(c).Grow(1).Children(func() { text.Paragraph(c, message, text.ParagraphOptions{}) })
|
||||
for _, a := range o.Actions {
|
||||
if input.Button(c, a, input.ButtonOptions{Size: input.Small, Disabled: o.Disabled}).Clicked {
|
||||
res.Action = a
|
||||
}
|
||||
}
|
||||
if o.Dismissable {
|
||||
res.Dismissed = input.IconButton(c, icon.Close, k.Msg("close"), input.IconButtonOptions{Variant: input.Ghost, Size: input.Small, Disabled: o.Disabled}).Clicked
|
||||
}
|
||||
})
|
||||
if res.Action != "" || res.Dismissed {
|
||||
slog.Debug("8bit: banner", "action", res.Action, "dismissed", res.Dismissed)
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type BusyOverlayOptions struct{}
|
||||
|
||||
type busyKey struct{}
|
||||
|
||||
// BusyOverlay veils busy content, whose controls then take no input.
|
||||
func BusyOverlay(c *ui.Context, busy bool, o BusyOverlayOptions, content func()) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
outer := ui.Box(c)
|
||||
// held is whether the veil had the focus last frame.
|
||||
held := ui.Local(outer, busyKey{}, func() bool { return false })
|
||||
if !busy && *held {
|
||||
// From the root, parked controls inside take it back.
|
||||
c.Root().Focus()
|
||||
*held = false
|
||||
}
|
||||
if busy {
|
||||
// Busy content is one Tab stop: the veil.
|
||||
outer.FocusGroup(ui.Vertical)
|
||||
}
|
||||
return outer.Children(func() {
|
||||
s := pixel.Current(c)
|
||||
s.Inert = s.Inert || busy
|
||||
before := c.Root().Focused()
|
||||
var body *ui.Element
|
||||
pixel.Scope(c, s, func() { body = ui.Box(c).Children(content) })
|
||||
if !busy {
|
||||
return
|
||||
}
|
||||
veil := ui.Box(c).Absolute().Left(0).Top(0).Right(0).Bottom(0).Focusable().FocusRing(false).Role(ui.RoleProgress).Label(k.Msg("wait"))
|
||||
if body.FocusWithin() || !before && c.Root().Focused() {
|
||||
veil.Focus()
|
||||
}
|
||||
*held = veil.Focused()
|
||||
focus := veil.FocusVisible()
|
||||
veil.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
g.Checker(0, 0, g.W, g.H, pixel.Back)
|
||||
// The spinner doubles in size where it fits.
|
||||
sc := max(1, min(2, min(g.W, g.H)/14))
|
||||
beads(g, (g.W-12*sc)/2, (g.H-12*sc)/2, sc)
|
||||
if focus {
|
||||
g.FocusMark(0, 0, g.W, g.H)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type ComboCounterOptions struct{}
|
||||
|
||||
type comboKey struct{}
|
||||
|
||||
// ComboCounter shows ×n COMBO!, popping bigger as n rises.
|
||||
func ComboCounter(c *ui.Context, count int, o ComboCounterOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
e := ui.Box(c)
|
||||
s := ui.Local(e, comboKey{}, func() rise { return rise{level: count} })
|
||||
if count > s.level {
|
||||
s.at = k.Now()
|
||||
}
|
||||
s.level = count
|
||||
if count <= 0 {
|
||||
return e
|
||||
}
|
||||
pop := !k.Reduce() && since(c, k, s.at, 10, 2) < 2
|
||||
n, tag := "×"+strconv.Itoa(count), k.Msg("combo")
|
||||
w := pixel.Measure(n)*3 + 6 + pixel.Measure(tag)*2 + 2
|
||||
return e.Role(ui.RoleStatus).Label(n+" "+tag).Size(k.D(w), k.D(3*pixel.LineHeight+2)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
x := pixel.Measure(n)*3 + 6
|
||||
word := func(dx, dy int, ink pixel.Slot) {
|
||||
g.Text(1+dx, 1+dy, n, ink, 3)
|
||||
g.Text(x+dx, pixel.LineHeight+dy, tag, ink, 2)
|
||||
}
|
||||
word(1, 1, pixel.Shade)
|
||||
if pop {
|
||||
for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} {
|
||||
word(d[0], d[1], pixel.Warn)
|
||||
}
|
||||
}
|
||||
word(0, 0, pixel.Warn)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
|
||||
)
|
||||
|
||||
type ContinuePromptOptions struct {
|
||||
// Seconds counts down; zero means 10.
|
||||
Seconds int
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type ContinuePromptResult struct {
|
||||
Element *ui.Element
|
||||
Yes, No, TimedOut bool
|
||||
}
|
||||
|
||||
type promptKey struct{}
|
||||
|
||||
// prompt is when the count began, and if it ended.
|
||||
type prompt struct {
|
||||
start time.Time
|
||||
over bool
|
||||
}
|
||||
|
||||
// ContinuePrompt asks CONTINUE? YES or NO, counting down.
|
||||
func ContinuePrompt(c *ui.Context, o ContinuePromptOptions) ContinuePromptResult {
|
||||
k := pixel.K(c)
|
||||
n := pixel.Or("ContinuePrompt Seconds", o.Seconds, 10)
|
||||
col := ui.Column(c).Gap(k.D(6)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(k.Msg("continue"))
|
||||
st := ui.Local(col, promptKey{}, func() prompt { return prompt{start: k.Now()} })
|
||||
left := max(0, n-int(k.Now().Sub(st.start)/time.Second))
|
||||
res := ContinuePromptResult{Element: col}
|
||||
if left == 0 && !st.over {
|
||||
st.over, res.TimedOut = true, true
|
||||
}
|
||||
if !st.over {
|
||||
k.Tick(c, 10)
|
||||
}
|
||||
col.Children(func() {
|
||||
text.ShadowText(c, k.Msg("continue"), text.ShadowTextOptions{Slot: pixel.Accent, Scale: 2})
|
||||
text.ShadowText(c, strconv.Itoa(left), text.ShadowTextOptions{Slot: pixel.Warn, Scale: 4})
|
||||
r := input.ButtonGroup(c, []string{k.Msg("yes"), k.Msg("no")}, input.ButtonGroupOptions{Label: k.Msg("continue"), Disabled: st.over || o.Disabled})
|
||||
res.Yes, res.No = r.Clicked == 0, r.Clicked == 1
|
||||
})
|
||||
if res.Yes || res.No {
|
||||
st.over = true
|
||||
}
|
||||
if res.Yes || res.No || res.TimedOut {
|
||||
slog.Debug("8bit: continue prompt", "yes", res.Yes, "no", res.No, "timed out", res.TimedOut)
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type CountUpOptions struct {
|
||||
// Scale is 1–4; zero means 1.
|
||||
Scale int
|
||||
Slot pixel.Slot
|
||||
}
|
||||
|
||||
type countKey struct{}
|
||||
|
||||
// counting runs from one value to the next.
|
||||
type counting struct {
|
||||
from, to int
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// CountUp counts to a new value in eight steps.
|
||||
func CountUp(c *ui.Context, value int, o CountUpOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
sc := pixel.Or("CountUp Scale", o.Scale, 1)
|
||||
e := ui.Box(c).Role(ui.RoleText).Label(strconv.Itoa(value))
|
||||
s := ui.Local(e, countKey{}, func() counting { return counting{from: value, to: value} })
|
||||
shown := func() int {
|
||||
step := 8
|
||||
if !k.Reduce() {
|
||||
step = since(c, k, s.at, 15, 8)
|
||||
}
|
||||
return s.from + (s.to-s.from)*step/8
|
||||
}
|
||||
if value != s.to {
|
||||
s.from, s.to, s.at = shown(), value, k.Now()
|
||||
}
|
||||
n := strconv.Itoa(shown())
|
||||
w := max(pixel.Measure(strconv.Itoa(s.from)), pixel.Measure(strconv.Itoa(s.to)))
|
||||
return e.Size(k.D(w*sc), k.D(pixel.LineHeight*sc)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
k.Canvas(p, r).Text(0, 0, n, o.Slot, sc)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package feedback_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/feedback"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
func TestCountUpStepsOrJumps(t *testing.T) {
|
||||
for _, reduce := range []bool{false, true} {
|
||||
clk := newClock()
|
||||
v := 0
|
||||
tt := view(400, 100, clk, reduce, func(c *ui.Context) {
|
||||
feedback.CountUp(c, v, feedback.CountUpOptions{Slot: pixel.Ink})
|
||||
})
|
||||
v = 800
|
||||
wait(tt, clk, 0)
|
||||
r := find(t, tt, "800")
|
||||
mid := slots(t, tt.Image(), r)
|
||||
wait(tt, clk, time.Second)
|
||||
end := slots(t, tt.Image(), r)
|
||||
if counted := !equal(mid, end); counted == reduce {
|
||||
t.Errorf("reduce %v: counted in steps %v", reduce, counted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPulseStopsUnderReduceMotion(t *testing.T) {
|
||||
for _, reduce := range []bool{false, true} {
|
||||
clk := newClock()
|
||||
tt := view(300, 200, clk, reduce, func(c *ui.Context) {
|
||||
feedback.Pulse(c, feedback.PulseOptions{}, func() {
|
||||
ui.Box(c).Size(20, 20).Label("dot")
|
||||
}).Label("ring")
|
||||
})
|
||||
if moved := moves(t, tt, clk, "ring", 200*time.Millisecond); moved == reduce {
|
||||
t.Errorf("reduce %v: the ring moved %v", reduce, moved)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevelUpShowsOnlyOnARise(t *testing.T) {
|
||||
clk := newClock()
|
||||
lv := 4
|
||||
tt := view(500, 200, clk, false, func(c *ui.Context) {
|
||||
feedback.LevelUp(c, lv, feedback.LevelUpOptions{})
|
||||
})
|
||||
lv = 3
|
||||
tt.Frame()
|
||||
if _, ok := tt.Find("LEVEL UP! LV 3"); ok {
|
||||
t.Fatal("LEVEL UP showed without a rise")
|
||||
}
|
||||
lv = 5
|
||||
tt.Frame()
|
||||
r := find(t, tt, "LEVEL UP! LV 5")
|
||||
if count(slots(t, tt.Image(), r), pixel.Accent) != 0 {
|
||||
t.Fatal("the banner did not start off its box")
|
||||
}
|
||||
wait(tt, clk, time.Second)
|
||||
if count(slots(t, tt.Image(), r), pixel.Accent) == 0 {
|
||||
t.Fatal("the banner did not slide in")
|
||||
}
|
||||
wait(tt, clk, 3*time.Second)
|
||||
if _, ok := tt.Find("LEVEL UP! LV 5"); ok {
|
||||
t.Fatal("the banner stayed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAchievementComesAndGoes(t *testing.T) {
|
||||
clk := newClock()
|
||||
cue := 0
|
||||
tt := view(500, 200, clk, true, func(c *ui.Context) {
|
||||
feedback.Achievement(c, "Slime Tamer", feedback.AchievementOptions{Cue: cue})
|
||||
})
|
||||
if _, ok := tt.Find("ACHIEVEMENT UNLOCKED Slime Tamer"); ok {
|
||||
t.Fatal("the card showed before its cue")
|
||||
}
|
||||
cue++
|
||||
tt.Frame()
|
||||
find(t, tt, "ACHIEVEMENT UNLOCKED Slime Tamer")
|
||||
wait(tt, clk, 3*time.Second)
|
||||
find(t, tt, "ACHIEVEMENT UNLOCKED Slime Tamer")
|
||||
wait(tt, clk, 2*time.Second)
|
||||
if _, ok := tt.Find("ACHIEVEMENT UNLOCKED Slime Tamer"); ok {
|
||||
t.Fatal("the card stayed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComboCounterPopsOnARise(t *testing.T) {
|
||||
clk := newClock()
|
||||
n := 0
|
||||
tt := view(400, 200, clk, false, func(c *ui.Context) {
|
||||
feedback.ComboCounter(c, n, feedback.ComboCounterOptions{})
|
||||
})
|
||||
if _, ok := tt.Find("×0 COMBO!"); ok {
|
||||
t.Fatal("a zero combo showed")
|
||||
}
|
||||
n = 12
|
||||
tt.Frame()
|
||||
r := find(t, tt, "×12 COMBO!")
|
||||
popped := count(slots(t, tt.Image(), r), pixel.Warn)
|
||||
wait(tt, clk, 500*time.Millisecond)
|
||||
if rest := count(slots(t, tt.Image(), r), pixel.Warn); rest >= popped {
|
||||
t.Fatalf("no pop: %d Warn dots, then %d", popped, rest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveIndicatorStates(t *testing.T) {
|
||||
saving := false
|
||||
tt := view(300, 100, newClock(), false, func(c *ui.Context) {
|
||||
feedback.SaveIndicator(c, saving, feedback.SaveIndicatorOptions{})
|
||||
})
|
||||
if _, ok := tt.Find("SAVED"); ok {
|
||||
t.Fatal("SAVED before any save")
|
||||
}
|
||||
saving = true
|
||||
tt.Frame()
|
||||
find(t, tt, "SAVING…")
|
||||
saving = false
|
||||
tt.Frame()
|
||||
find(t, tt, "SAVED")
|
||||
}
|
||||
|
||||
func TestBusyOverlayBlocksInputAndGivesFocusBack(t *testing.T) {
|
||||
busy, clicks, raw := false, 0, true
|
||||
tt := view(400, 200, newClock(), false, func(c *ui.Context) {
|
||||
input.Button(c, "top", input.ButtonOptions{})
|
||||
feedback.BusyOverlay(c, busy, feedback.BusyOverlayOptions{}, func() {
|
||||
if input.Button(c, "SAVE", input.ButtonOptions{}).Clicked {
|
||||
clicks++
|
||||
}
|
||||
// Plain boxes, not library controls.
|
||||
if ui.Box(c).Size(40, 20).Label("raw").Clicked() {
|
||||
clicks++
|
||||
}
|
||||
if raw && ui.Box(c).Size(40, 20).Label("rawkey").Focusable().Shortcut(0, ui.KeyEnter) {
|
||||
clicks++
|
||||
}
|
||||
})
|
||||
ui.Box(c).Size(4, 4).Label("elsewhere")
|
||||
input.Button(c, "after", input.ButtonOptions{})
|
||||
})
|
||||
// Focused counts ancestors; a sibling tells the two apart.
|
||||
own := func() bool { return tt.Focused("SAVE") && !tt.Focused("elsewhere") }
|
||||
click(t, tt, "rawkey")
|
||||
busy = true
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if clicks != 0 || tt.Focused("rawkey") && !tt.Focused("elsewhere") {
|
||||
t.Fatal("a plain focusable inside kept the focus or its keys")
|
||||
}
|
||||
busy, raw = false, false
|
||||
tt.Frame()
|
||||
click(t, tt, "SAVE")
|
||||
busy = true
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
// Focus waits on the veil; Tab goes on from it.
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("after") || tt.Focused("elsewhere") {
|
||||
t.Fatal("Tab did not pass over the busy content")
|
||||
}
|
||||
// Presses land on the veil, which keeps the focus.
|
||||
click(t, tt, "SAVE")
|
||||
click(t, tt, "raw")
|
||||
if clicks != 1 || own() {
|
||||
t.Fatalf("busy content took input: %d clicks, focus %v", clicks, own())
|
||||
}
|
||||
busy = false
|
||||
tt.Frame()
|
||||
if !own() {
|
||||
t.Fatal("the focus did not come back")
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if clicks != 2 {
|
||||
t.Fatal("the content stayed blocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusyContentIsOneTabStop(t *testing.T) {
|
||||
tt := view(400, 200, newClock(), false, func(c *ui.Context) {
|
||||
input.Button(c, "before", input.ButtonOptions{})
|
||||
feedback.BusyOverlay(c, true, feedback.BusyOverlayOptions{}, func() {
|
||||
ui.Box(c).Size(40, 20).Label("raw").Focusable()
|
||||
input.Button(c, "SAVE", input.ButtonOptions{})
|
||||
})
|
||||
input.Button(c, "after", input.ButtonOptions{})
|
||||
})
|
||||
click(t, tt, "before")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
// FocusMark rings the veil two dots outside it.
|
||||
v := find(t, tt, "PLEASE WAIT")
|
||||
if count(slots(t, tt.Image(), ui.Rect{X: v.X, Y: v.Y + v.H + 2, W: v.W, H: 2}), pixel.Accent) == 0 {
|
||||
t.Fatal("the focused veil showed no focus")
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("after") || tt.Focused("before") {
|
||||
t.Fatal("two Tabs did not cross the busy content")
|
||||
}
|
||||
tt.Key(ui.Shift, ui.KeyTab)
|
||||
tt.Key(ui.Shift, ui.KeyTab)
|
||||
if !tt.Focused("before") || tt.Focused("after") {
|
||||
t.Fatal("two Shift+Tabs did not cross back")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyGoCountsAndReportsDone(t *testing.T) {
|
||||
clk := newClock()
|
||||
cue, done := 0, 0
|
||||
tt := view(400, 300, clk, true, func(c *ui.Context) {
|
||||
if feedback.ReadyGo(c, feedback.ReadyGoOptions{Cue: cue}).Done {
|
||||
done++
|
||||
}
|
||||
})
|
||||
cue++
|
||||
tt.Frame()
|
||||
for _, want := range []string{"3", "2", "1", "GO!"} {
|
||||
find(t, tt, want)
|
||||
wait(tt, clk, 500*time.Millisecond)
|
||||
wait(tt, clk, 500*time.Millisecond)
|
||||
}
|
||||
if _, ok := tt.Find("GO!"); ok || done != 1 {
|
||||
t.Fatalf("GO! stayed, or Done came %d times", done)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyGoReportsDoneAfterAJump(t *testing.T) {
|
||||
clk := newClock()
|
||||
cue, done := 0, 0
|
||||
tt := view(400, 300, clk, false, func(c *ui.Context) {
|
||||
if feedback.ReadyGo(c, feedback.ReadyGoOptions{Cue: cue}).Done {
|
||||
done++
|
||||
}
|
||||
})
|
||||
cue++
|
||||
tt.Frame()
|
||||
wait(tt, clk, 5*time.Second)
|
||||
wait(tt, clk, time.Second)
|
||||
if done != 1 {
|
||||
t.Fatalf("a jump past GO! gave Done %d times", done)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShakeJoltsUnlessReduced(t *testing.T) {
|
||||
for _, reduce := range []bool{false, true} {
|
||||
clk := newClock()
|
||||
cue := 0
|
||||
tt := view(300, 100, clk, reduce, func(c *ui.Context) {
|
||||
feedback.Shake(c, feedback.ShakeOptions{Cue: cue}, func() {
|
||||
ui.Box(c).Size(20, 20).Label("box")
|
||||
})
|
||||
})
|
||||
x := find(t, tt, "box").X
|
||||
cue++
|
||||
tt.Frame()
|
||||
if jolted := find(t, tt, "box").X != x; jolted == reduce {
|
||||
t.Errorf("reduce %v: jolted %v", reduce, jolted)
|
||||
}
|
||||
wait(tt, clk, time.Second)
|
||||
if find(t, tt, "box").X != x {
|
||||
t.Error("the box did not settle")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Package feedback tells the player what is going on.
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
// pingpong runs n back and forth over 0…span.
|
||||
func pingpong(n, span int) int {
|
||||
if span <= 0 {
|
||||
return 0
|
||||
}
|
||||
n %= 2 * span
|
||||
if n > span {
|
||||
return 2*span - n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// level is Good, then Warn past warn, Bad past bad.
|
||||
func level(v, warn, bad float64) pixel.Slot {
|
||||
switch {
|
||||
case bad != 0 && v > bad:
|
||||
return pixel.Bad
|
||||
case warn != 0 && v > warn:
|
||||
return pixel.Warn
|
||||
}
|
||||
return pixel.Good
|
||||
}
|
||||
|
||||
// fmtNum prints a number without trailing zeros.
|
||||
func fmtNum(v float64) string { return strconv.FormatFloat(v, 'g', 4, 64) }
|
||||
|
||||
type cueKey struct{}
|
||||
|
||||
// cued is a cue's last value and when it changed.
|
||||
type cued struct {
|
||||
n int
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// cue is when n last changed; zero until it does.
|
||||
func cue(e *ui.Element, k pixel.Kit, n int) time.Time {
|
||||
s := ui.Local(e, cueKey{}, func() cued { return cued{n: n} })
|
||||
if s.n != n {
|
||||
s.n, s.at = n, k.Now()
|
||||
}
|
||||
return s.at
|
||||
}
|
||||
|
||||
// since counts steps at fps from t, up to done.
|
||||
func since(c *ui.Context, k pixel.Kit, t time.Time, fps, done int) int {
|
||||
if t.IsZero() {
|
||||
return done
|
||||
}
|
||||
n := int(k.Now().Sub(t) * time.Duration(fps) / time.Second)
|
||||
if n < done {
|
||||
k.Tick(c, fps)
|
||||
}
|
||||
return min(n, done)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package feedback_test
|
||||
|
||||
import (
|
||||
"image"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/feedback"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type clock struct{ t time.Time }
|
||||
|
||||
func (c *clock) now() time.Time { return c.t }
|
||||
|
||||
// view builds on a fake clock; reduce stops motion.
|
||||
func view(w, h int, clk *clock, reduce bool, build func(c *ui.Context)) *ui.Tester {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: clk.now})
|
||||
build(c)
|
||||
}, w, h)
|
||||
if reduce {
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
}
|
||||
return tt
|
||||
}
|
||||
|
||||
func newClock() *clock { return &clock{time.Unix(3_000_000, 0)} }
|
||||
|
||||
// wait moves the clock on and builds a frame.
|
||||
func wait(tt *ui.Tester, clk *clock, d time.Duration) {
|
||||
clk.t = clk.t.Add(d)
|
||||
tt.Frame()
|
||||
}
|
||||
|
||||
func find(t *testing.T, tt *ui.Tester, label string) ui.Rect {
|
||||
t.Helper()
|
||||
r, ok := tt.Find(label)
|
||||
if !ok {
|
||||
t.Fatalf("no %q in %q", label, tt.Texts())
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func click(t *testing.T, tt *ui.Tester, label string) {
|
||||
t.Helper()
|
||||
if err := tt.Click(label); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// slots are the palette slots of a box's dots.
|
||||
func slots(t *testing.T, img *image.RGBA, r ui.Rect) []pixel.Slot {
|
||||
t.Helper()
|
||||
var out []pixel.Slot
|
||||
for y := int(r.Y); y < int(r.Y+r.H); y += 2 {
|
||||
for x := int(r.X); x < int(r.X+r.W); x += 2 {
|
||||
c, found := img.RGBAAt(x, y), false
|
||||
for s, pc := range pixel.SpringDay.Colors {
|
||||
if pc.R == c.R && pc.G == c.G && pc.B == c.B {
|
||||
out, found = append(out, pixel.Slot(s)), true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("pixel %d,%d off the palette", x, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func count(s []pixel.Slot, want pixel.Slot) int {
|
||||
n := 0
|
||||
for _, v := range s {
|
||||
if v == want {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func equal[T comparable](a, b []T) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// moves reports whether the labeled box changes as time passes.
|
||||
func moves(t *testing.T, tt *ui.Tester, clk *clock, label string, d time.Duration) bool {
|
||||
t.Helper()
|
||||
before := slots(t, tt.Image(), find(t, tt, label))
|
||||
wait(tt, clk, d)
|
||||
return !equal(before, slots(t, tt.Image(), find(t, tt, label)))
|
||||
}
|
||||
|
||||
func TestProgressFillsChunksAndRuns(t *testing.T) {
|
||||
clk := newClock()
|
||||
v := 0.5
|
||||
tt := view(400, 200, clk, true, func(c *ui.Context) {
|
||||
feedback.Progress(c, "Load", v, feedback.ProgressOptions{Width: 103, Percent: true})
|
||||
})
|
||||
// 103 dots hold 20 chunks of 4×4 dots.
|
||||
if n := count(slots(t, tt.Image(), find(t, tt, "Load")), pixel.Accent); n != 10*4*4 {
|
||||
t.Fatalf("half a bar lit %d dots, want %d", n, 10*4*4)
|
||||
}
|
||||
v = -1
|
||||
tt.Frame()
|
||||
if !moves(t, tt, clk, "Load", 200*time.Millisecond) {
|
||||
t.Fatal("an unknown progress stood still under Reduce Motion")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("a value past 1 did not panic")
|
||||
}
|
||||
}()
|
||||
v = 1.5
|
||||
tt.Frame()
|
||||
}
|
||||
|
||||
func TestLoadersKeepMovingUnderReduceMotion(t *testing.T) {
|
||||
clk := newClock()
|
||||
tt := view(400, 300, clk, true, func(c *ui.Context) {
|
||||
feedback.Spinner(c, "Spin", feedback.SpinnerOptions{Scale: 2})
|
||||
feedback.DotsLoader(c, "Dots", feedback.DotsLoaderOptions{})
|
||||
feedback.BarsLoader(c, "Bars", feedback.BarsLoaderOptions{})
|
||||
feedback.Hourglass(c, "Sand", feedback.HourglassOptions{})
|
||||
})
|
||||
for _, l := range []string{"Spin", "Dots", "Bars", "Sand"} {
|
||||
if !moves(t, tt, clk, l, 300*time.Millisecond) {
|
||||
t.Errorf("%s stood still", l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeterLevels(t *testing.T) {
|
||||
for v, want := range map[float64]pixel.Slot{0.3: pixel.Good, 0.7: pixel.Warn, 0.95: pixel.Bad} {
|
||||
tt := view(300, 100, newClock(), false, func(c *ui.Context) {
|
||||
feedback.Meter(c, "HP", v, feedback.MeterOptions{Warning: 0.6, Critical: 0.9})
|
||||
})
|
||||
s := slots(t, tt.Image(), find(t, tt, "HP"))
|
||||
if count(s, want) == 0 {
|
||||
t.Errorf("%v shows no %v", v, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkeletonShineStopsUnderReduceMotion(t *testing.T) {
|
||||
for _, reduce := range []bool{false, true} {
|
||||
clk := newClock()
|
||||
tt := view(400, 200, clk, reduce, func(c *ui.Context) {
|
||||
feedback.Skeleton(c, feedback.SkeletonOptions{Avatar: true})
|
||||
})
|
||||
if moved := moves(t, tt, clk, "NOW LOADING", 250*time.Millisecond); moved == reduce {
|
||||
t.Errorf("reduce %v: the shine moved %v", reduce, moved)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadingScreenRotatesTips(t *testing.T) {
|
||||
clk := newClock()
|
||||
tt := view(600, 300, clk, false, func(c *ui.Context) {
|
||||
feedback.LoadingScreen(c, feedback.LoadingScreenOptions{Tips: []string{"Save often.", "Pet the slime."}})
|
||||
})
|
||||
r := find(t, tt, "NOW LOADING")
|
||||
tips := ui.Rect{X: r.X, Y: r.Y + 2*2*pixel.LineHeight + 16, W: r.W, H: 2 * 2 * pixel.LineHeight}
|
||||
before := slots(t, tt.Image(), tips)
|
||||
wait(tt, clk, 4*time.Second)
|
||||
if equal(before, slots(t, tt.Image(), tips)) {
|
||||
t.Fatal("the tip did not change after four seconds")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type HourglassOptions struct{}
|
||||
|
||||
// glass is the hourglass, by hand; # marks its walls.
|
||||
var glass = []string{
|
||||
"############",
|
||||
".#........#.",
|
||||
".#........#.",
|
||||
"..#......#..",
|
||||
"...#....#...",
|
||||
"....#..#....",
|
||||
".....##.....",
|
||||
".....##.....",
|
||||
"....#..#....",
|
||||
"...#....#...",
|
||||
"..#......#..",
|
||||
".#........#.",
|
||||
".#........#.",
|
||||
"############",
|
||||
}
|
||||
|
||||
// Hourglass drains in steps, then flips.
|
||||
func Hourglass(c *ui.Context, text string, o HourglassOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
return ui.Box(c).Role(ui.RoleProgress).Label(text).Size(k.D(14), k.D(14)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
f := g.Busy(4) % 8
|
||||
// Frame 6 lies on its side, mid-flip; 7 rests.
|
||||
side := f == 6
|
||||
dot := func(x, y int, s pixel.Slot) {
|
||||
if side {
|
||||
g.Dot(y, 12-x, s)
|
||||
} else {
|
||||
g.Dot(x+1, y, s)
|
||||
}
|
||||
}
|
||||
top, bottom := 4-min(f, 4), min(f, 4)
|
||||
if f >= 6 {
|
||||
top, bottom = 4, 0
|
||||
}
|
||||
for y, row := range glass {
|
||||
l, r := strings.Index(row, "#"), strings.LastIndex(row, "#")
|
||||
for x := range len(row) {
|
||||
switch {
|
||||
case row[x] == '#':
|
||||
dot(x, y, pixel.Edge)
|
||||
case x > l && x < r && (y >= 6-top && y <= 5 || y >= 13-bottom && y <= 12):
|
||||
dot(x, y, pixel.Warn)
|
||||
}
|
||||
}
|
||||
}
|
||||
if f > 0 && f < 5 {
|
||||
for y := 6; y < 13-bottom; y++ {
|
||||
dot(5, y, pixel.Warn)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
var (
|
||||
bang = pixel.MustSprite(`
|
||||
..###..
|
||||
.##.##.
|
||||
###.###
|
||||
###.###
|
||||
#######
|
||||
###.###
|
||||
.#####.
|
||||
`)
|
||||
tick = pixel.MustSprite(`
|
||||
......#
|
||||
.....##
|
||||
#...##.
|
||||
##.##..
|
||||
.###...
|
||||
..#....
|
||||
.......
|
||||
`)
|
||||
)
|
||||
|
||||
type InlineMessageOptions struct {
|
||||
Kind Kind
|
||||
}
|
||||
|
||||
// InlineMessage is a small ✓ or ! field note.
|
||||
func InlineMessage(c *ui.Context, message string, o InlineMessageOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
slot, _ := o.Kind.look()
|
||||
sp := bang
|
||||
if o.Kind == Success {
|
||||
sp = tick
|
||||
}
|
||||
return ui.Row(c).Gap(k.D(3)).AlignItems(ui.Center).Role(ui.RoleStatus).Label(message).Children(func() {
|
||||
ui.Box(c).Size(k.D(sp.W), k.D(sp.H)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
k.Canvas(p, r).Sprite(0, 0, sp, 0, slot, 1)
|
||||
})
|
||||
pixel.Text(c, message, pixel.TextOptions{Slot: slot})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type LevelUpOptions struct{}
|
||||
|
||||
type levelKey struct{}
|
||||
|
||||
// rise is the last level seen, and when it rose.
|
||||
type rise struct {
|
||||
level int
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// LevelUp slides in LEVEL UP! with sparkles when level rises.
|
||||
func LevelUp(c *ui.Context, level int, o LevelUpOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
e := ui.Box(c)
|
||||
s := ui.Local(e, levelKey{}, func() rise { return rise{level: level} })
|
||||
if level > s.level {
|
||||
s.at = k.Now()
|
||||
}
|
||||
s.level = level
|
||||
// Ten steps a second: six slide in, twenty stay.
|
||||
step := since(c, k, s.at, 10, 26)
|
||||
if step >= 26 {
|
||||
return e
|
||||
}
|
||||
slide := 5 - min(step, 5)
|
||||
if k.Reduce() {
|
||||
slide = 0
|
||||
}
|
||||
title := k.Msg("level_up") + " LV " + strconv.Itoa(level)
|
||||
w := pixel.Measure(title)*2 + 24
|
||||
return e.Role(ui.RoleStatus).Label(title).Size(k.D(w), k.D(40)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
x := -slide * w / 5
|
||||
g.Clip(0, 0, g.W, g.H, func() {
|
||||
g.Frame(x, 4, w, 32, pixel.Window, pixel.Accent)
|
||||
g.Text(x+13, 5, title, pixel.Shade, 2)
|
||||
g.Text(x+12, 4, title, pixel.AccentInk, 2)
|
||||
})
|
||||
if slide > 0 {
|
||||
return
|
||||
}
|
||||
for i, at := range [][2]int{{2, 0}, {w - 14, 2}, {w / 2, 28}, {8, 26}} {
|
||||
if (g.Step(8)+i)%3 != 0 {
|
||||
g.Sprite(at[0], at[1], icon.Sparkle, 0, pixel.Warn, 1)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type DotsLoaderOptions struct{}
|
||||
|
||||
// DotsLoader bounces three dots in turn.
|
||||
func DotsLoader(c *ui.Context, text string, o DotsLoaderOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
return ui.Box(c).Role(ui.RoleProgress).Label(text).Size(k.D(18), k.D(8)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
up := g.Busy(6) % 4
|
||||
for i := range 3 {
|
||||
y, ink := 4, pixel.Faint
|
||||
if i == up {
|
||||
y, ink = 0, pixel.Accent
|
||||
}
|
||||
g.Rect(i*7, y, 4, 4, ink)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type BarsLoaderOptions struct{}
|
||||
|
||||
// heights rise and fall; each bar runs a step behind.
|
||||
var heights = [8]int{3, 6, 9, 12, 9, 6, 3, 3}
|
||||
|
||||
// BarsLoader raises and drops five bars.
|
||||
func BarsLoader(c *ui.Context, text string, o BarsLoaderOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
return ui.Box(c).Role(ui.RoleProgress).Label(text).Size(k.D(23), k.D(12)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
f := g.Busy(10)
|
||||
for i := range 5 {
|
||||
h := heights[(f+8-i)%8]
|
||||
g.Rect(i*5, 12-h, 3, h, pixel.Accent)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type LoadingScreenOptions struct {
|
||||
// Tips take turns, four seconds each.
|
||||
Tips []string
|
||||
// Width is in dots; zero means 240.
|
||||
Width int
|
||||
}
|
||||
|
||||
// LoadingScreen shows NOW LOADING, stepping dots and a tip.
|
||||
func LoadingScreen(c *ui.Context, o LoadingScreenOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
w := pixel.Or("LoadingScreen Width", o.Width, 240)
|
||||
title := k.Msg("loading")
|
||||
return ui.Box(c).Role(ui.RoleStatus).Label(title).Size(k.D(w), k.D(2*pixel.LineHeight+8+2*pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
n := g.Busy(4)
|
||||
line := title + strings.Repeat(".", n%4)
|
||||
x := (w - pixel.Measure(title+"...")*2) / 2
|
||||
g.Text(x+2, 2, line, pixel.Shade, 2)
|
||||
g.Text(x, 0, line, pixel.Accent, 2)
|
||||
if len(o.Tips) == 0 {
|
||||
return
|
||||
}
|
||||
tip := o.Tips[n/16%len(o.Tips)]
|
||||
for i, l := range pixel.Wrap(tip, w, 2) {
|
||||
g.Text((w-pixel.Measure(l))/2, 2*pixel.LineHeight+8+i*pixel.LineHeight, l, pixel.Faint, 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type MeterOptions struct {
|
||||
// Min and Max are zero for 0 to 1.
|
||||
Min, Max float64
|
||||
// Warning and Critical are levels; zero means none.
|
||||
Warning, Critical float64
|
||||
// Width is in dots; zero means 120.
|
||||
Width int
|
||||
}
|
||||
|
||||
// Meter shows a level in Good, Warn or Bad.
|
||||
func Meter(c *ui.Context, text string, value float64, o MeterOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
lo, hi := o.Min, o.Max
|
||||
if lo == 0 && hi == 0 {
|
||||
hi = 1
|
||||
}
|
||||
if hi <= lo {
|
||||
panic("feedback: Meter range is empty")
|
||||
}
|
||||
w := pixel.Or("Meter Width", o.Width, 120)
|
||||
at := func(v float64) int { return 2 + int(max(0, min(1, (v-lo)/(hi-lo)))*float64(w-4)) }
|
||||
ink := level(value, o.Warning, o.Critical)
|
||||
return ui.Box(c).Role(ui.RoleMeter).Label(text).Description(fmtNum(value)).Size(k.D(w), k.D(10)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
g.Frame(0, 0, w, 10, pixel.Sunken, pixel.Well)
|
||||
g.Rect(2, 2, at(value)-2, 6, ink)
|
||||
for _, mark := range []float64{o.Warning, o.Critical} {
|
||||
if mark != 0 {
|
||||
g.VLine(at(mark), 1, 8, pixel.Edge)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type ProgressOptions struct {
|
||||
// Width is the bar in dots; zero means 120.
|
||||
Width int
|
||||
// Percent shows the number after the bar.
|
||||
Percent bool
|
||||
}
|
||||
|
||||
// Progress fills chunk by chunk; below zero a block runs.
|
||||
func Progress(c *ui.Context, text string, value float64, o ProgressOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
if value > 1 || math.IsNaN(value) {
|
||||
panic(fmt.Sprintf("feedback: Progress value %v is past 1", value))
|
||||
}
|
||||
w := pixel.Or("Progress Width", o.Width, 120)
|
||||
n := (w - 3) / 5
|
||||
pct := strconv.Itoa(int(math.Round(value*100))) + "%"
|
||||
e := ui.Box(c).Role(ui.RoleProgress).Label(text)
|
||||
full := w
|
||||
if value < 0 {
|
||||
e.Description(k.Msg("loading"))
|
||||
} else {
|
||||
e.Description(pct)
|
||||
if o.Percent {
|
||||
full += 4 + pixel.MicroWidth("100%")
|
||||
}
|
||||
}
|
||||
return e.Size(k.D(full), k.D(10)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
g.Frame(0, 0, w, 10, pixel.Sunken, pixel.Well)
|
||||
chunk := func(i int) { g.Rect(3+i*5, 3, 4, 4, pixel.Accent) }
|
||||
if value < 0 {
|
||||
at := pingpong(g.Busy(12), n-3)
|
||||
for i := range 3 {
|
||||
chunk(at + i)
|
||||
}
|
||||
return
|
||||
}
|
||||
for i := range int(value * float64(n)) {
|
||||
chunk(i)
|
||||
}
|
||||
if o.Percent {
|
||||
g.Micro(w+4, 2, pct, pixel.Ink, 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type PulseOptions struct {
|
||||
// Slot colors the ring; zero means Accent.
|
||||
Slot pixel.Slot
|
||||
}
|
||||
|
||||
// Pulse rings its child with a ring growing in steps.
|
||||
func Pulse(c *ui.Context, o PulseOptions, content func()) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
slot := o.Slot
|
||||
if slot == 0 {
|
||||
slot = pixel.Accent
|
||||
}
|
||||
return ui.Box(c).AlignSelf(ui.Start).Padding(k.D(6)).DrawOver(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
f := g.Step(6) % 6
|
||||
x, level := 5-f, 16-f*3
|
||||
w, h := g.W-2*x, g.H-2*x
|
||||
g.Veil(x, x, w, 1, slot, level)
|
||||
g.Veil(x, x+h-1, w, 1, slot, level)
|
||||
g.Veil(x, x, 1, h, slot, level)
|
||||
g.Veil(x+w-1, x, 1, h, slot, level)
|
||||
}).Children(content)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type ReadyGoOptions struct {
|
||||
// Cue starts the count each time it changes.
|
||||
Cue int
|
||||
}
|
||||
|
||||
type ReadyGoResult struct {
|
||||
Element *ui.Element
|
||||
// Done comes once, as GO! shows.
|
||||
Done bool
|
||||
}
|
||||
|
||||
type goKey struct{}
|
||||
|
||||
// ready is a count's cue, start, and Done flag.
|
||||
type ready struct {
|
||||
n int
|
||||
at time.Time
|
||||
said bool
|
||||
}
|
||||
|
||||
// ReadyGo counts 3, 2, 1, GO! in big digits.
|
||||
func ReadyGo(c *ui.Context, o ReadyGoOptions) ReadyGoResult {
|
||||
k := pixel.K(c)
|
||||
e := ui.Box(c)
|
||||
s := ui.Local(e, goKey{}, func() ready { return ready{n: o.Cue} })
|
||||
if s.n != o.Cue {
|
||||
*s = ready{n: o.Cue, at: k.Now()}
|
||||
}
|
||||
res := ReadyGoResult{Element: e}
|
||||
sec := since(c, k, s.at, 10, 40) / 10
|
||||
// Done comes once, even when time jumps past GO!.
|
||||
if sec >= 3 && !s.said && !s.at.IsZero() {
|
||||
s.said, res.Done = true, true
|
||||
slog.Debug("8bit: ready go")
|
||||
}
|
||||
if sec >= 4 {
|
||||
return res
|
||||
}
|
||||
word := [...]string{"3", "2", "1", k.Msg("go")}[sec]
|
||||
e.Role(ui.RoleStatus).Label(word).Size(k.D(120), k.D(4*pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
ink := pixel.Warn
|
||||
if sec == 3 {
|
||||
ink = pixel.Good
|
||||
}
|
||||
w := pixel.Measure(word) * 4
|
||||
g.Text((g.W-w)/2+2, 2, word, pixel.Shade, 4)
|
||||
g.Text((g.W-w)/2, 0, word, ink, 4)
|
||||
})
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type SaveIndicatorOptions struct{}
|
||||
|
||||
type saveKey struct{}
|
||||
|
||||
// spin is the floppy's width, frame by frame.
|
||||
var spin = [4]int{12, 8, 2, 8}
|
||||
|
||||
// SaveIndicator spins a floppy while saving, then shows SAVED ✓.
|
||||
func SaveIndicator(c *ui.Context, saving bool, o SaveIndicatorOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
e := ui.Row(c).Gap(k.D(4)).AlignItems(ui.Center)
|
||||
ever := ui.Local(e, saveKey{}, func() bool { return false })
|
||||
if saving {
|
||||
*ever = true
|
||||
}
|
||||
if !*ever {
|
||||
return e
|
||||
}
|
||||
word, ink := k.Msg("saved"), pixel.Good
|
||||
if saving {
|
||||
word, ink = k.Msg("saving")+"…", pixel.Faint
|
||||
}
|
||||
return e.Role(ui.RoleStatus).Label(word).Children(func() {
|
||||
ui.Box(c).Size(k.D(12), k.D(12)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
if !saving {
|
||||
g.Sprite(0, 0, icon.Check, 0, pixel.Good, 1)
|
||||
return
|
||||
}
|
||||
// A coin spin: columns squeeze in, crisp.
|
||||
w := spin[g.Busy(8)%4]
|
||||
x0 := (12 - w) / 2
|
||||
for x := range w {
|
||||
sx := x * 12 / w
|
||||
g.Clip(x0+x, 0, 1, 12, func() { g.Sprite(x0+x-sx, 0, icon.Save, 0, pixel.Info, 1) })
|
||||
}
|
||||
})
|
||||
pixel.Text(c, word, pixel.TextOptions{Slot: ink})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type ShakeOptions struct {
|
||||
// Cue shakes the child each time it changes.
|
||||
Cue int
|
||||
}
|
||||
|
||||
// shakes are the child's offsets in dots, a step each.
|
||||
var shakes = [...]int{2, -2, 2, -2, 1, -1, 0}
|
||||
|
||||
// Shake jolts its child side to side briefly.
|
||||
func Shake(c *ui.Context, o ShakeOptions, content func()) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
e := ui.Box(c)
|
||||
dx := 0
|
||||
if step := since(c, k, cue(e, k, o.Cue), 20, len(shakes)-1); !k.Reduce() {
|
||||
dx = shakes[step]
|
||||
}
|
||||
return e.Padding(0, k.D(2-dx), 0, k.D(2+dx)).Children(content)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type SkeletonOptions struct {
|
||||
// Lines is the text lines; zero means 3.
|
||||
Lines int
|
||||
// Avatar puts a square before the lines.
|
||||
Avatar bool
|
||||
// Width is in dots; zero means 160.
|
||||
Width int
|
||||
}
|
||||
|
||||
// Skeleton stands in for content; a dithered shine sweeps it.
|
||||
func Skeleton(c *ui.Context, o SkeletonOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
n := pixel.Or("Skeleton Lines", o.Lines, 3)
|
||||
w := pixel.Or("Skeleton Width", o.Width, 160)
|
||||
h := n*12 - 4
|
||||
return ui.Box(c).Role(ui.RoleStatus).Label(k.Msg("loading")).Size(k.D(w), k.D(max(h, 24))).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
x := 0
|
||||
var shapes [][4]int
|
||||
if o.Avatar {
|
||||
shapes, x = append(shapes, [4]int{0, 0, 24, 24}), 30
|
||||
}
|
||||
for i := range n {
|
||||
lw := w - x
|
||||
if i == n-1 && n > 1 {
|
||||
lw = lw * 3 / 5
|
||||
}
|
||||
shapes = append(shapes, [4]int{x, i * 12, lw, 8})
|
||||
}
|
||||
shine := g.Step(8)*6%(w+48) - 24
|
||||
for _, s := range shapes {
|
||||
g.Dither(s[0], s[1], s[2], s[3], pixel.Shade, pixel.Face, 4)
|
||||
g.Clip(s[0], s[1], s[2], s[3], func() { g.Veil(shine, s[1], 16, s[3], pixel.Lit, 6) })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type SpinnerOptions struct {
|
||||
// Scale is 1–4; zero means 1.
|
||||
Scale int
|
||||
}
|
||||
|
||||
// ring is where the spinner's eight beads sit, by hand.
|
||||
var ring = [8][2]int{{5, 1}, {8, 2}, {9, 5}, {8, 8}, {5, 9}, {2, 8}, {1, 5}, {2, 2}}
|
||||
|
||||
// Spinner turns through eight frames, even under Reduce Motion.
|
||||
func Spinner(c *ui.Context, text string, o SpinnerOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
s := pixel.Or("Spinner Scale", o.Scale, 1)
|
||||
if s > 4 {
|
||||
panic("feedback: Spinner Scale past 4")
|
||||
}
|
||||
return ui.Box(c).Role(ui.RoleProgress).Label(text).Size(k.D(12*s), k.D(12*s)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
beads(k.Canvas(p, r), 0, 0, s)
|
||||
})
|
||||
}
|
||||
|
||||
// beads draws the spinner ring at x, y, scaled s.
|
||||
func beads(g pixel.Canvas, x, y, s int) {
|
||||
head := g.Busy(10) % 8
|
||||
for i, at := range ring {
|
||||
ink := pixel.Shade
|
||||
switch (head - i + 8) % 8 {
|
||||
case 0:
|
||||
ink = pixel.Accent
|
||||
case 1:
|
||||
ink = pixel.AccentLit
|
||||
case 2:
|
||||
ink = pixel.Faint
|
||||
}
|
||||
g.Rect(x+at[0]*s, y+at[1]*s, 2*s, 2*s, ink)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/text"
|
||||
)
|
||||
|
||||
var openChest = pixel.MustSprite(`
|
||||
..eeeeeeeeeeee..
|
||||
.esssssssssssse.
|
||||
.elllllllllllle.
|
||||
..eeeeeeeeeeee..
|
||||
................
|
||||
eeeeeeeeeeeeeeee
|
||||
ewwwwwwwwwwwwwwe
|
||||
ewwwwwwwwwwwwwwe
|
||||
eeeeeeeyyeeeeeee
|
||||
essssssyysssssse
|
||||
esssssssssssssse
|
||||
eeeeeeeeeeeeeeee
|
||||
`)
|
||||
|
||||
type EmptyStateOptions struct {
|
||||
// Action labels a button; Acted reports it.
|
||||
Action string
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type EmptyStateResult struct {
|
||||
Element *ui.Element
|
||||
Acted bool
|
||||
}
|
||||
|
||||
// EmptyState shows NO ITEMS over an empty chest.
|
||||
func EmptyState(c *ui.Context, message string, o EmptyStateOptions) EmptyStateResult {
|
||||
k := pixel.K(c)
|
||||
res := EmptyStateResult{}
|
||||
res.Element = ui.Column(c).Gap(k.D(6)).Padding(k.D(12)).AlignItems(ui.Center).Role(ui.RoleStatus).Label(k.Msg("empty")).Children(func() {
|
||||
display.Sprite(c, openChest, display.SpriteOptions{Scale: 2})
|
||||
text.ShadowText(c, k.Msg("empty"), text.ShadowTextOptions{Slot: pixel.Faint, Scale: 2})
|
||||
pixel.Text(c, message, pixel.TextOptions{Slot: pixel.Faint})
|
||||
if o.Action != "" {
|
||||
res.Acted = input.Button(c, o.Action, input.ButtonOptions{Variant: input.Primary, Disabled: o.Disabled}).Clicked
|
||||
}
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
type ErrorStateOptions struct {
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type ErrorStateResult struct {
|
||||
Element *ui.Element
|
||||
Continue bool
|
||||
}
|
||||
|
||||
type startKey struct{}
|
||||
|
||||
// ErrorState is GAME OVER with a CONTINUE? countdown and RETRY.
|
||||
func ErrorState(c *ui.Context, message string, o ErrorStateOptions) ErrorStateResult {
|
||||
k := pixel.K(c)
|
||||
col := ui.Column(c).Gap(k.D(8)).Padding(k.D(16)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(k.Msg("game_over"))
|
||||
start := ui.Local(col, startKey{}, func() time.Time { return k.Now() })
|
||||
left := max(0, 9-int(k.Now().Sub(*start)/time.Second))
|
||||
if left > 0 {
|
||||
k.Tick(c, 10)
|
||||
}
|
||||
res := ErrorStateResult{Element: col}
|
||||
col.Children(func() {
|
||||
text.ShadowText(c, k.Msg("game_over"), text.ShadowTextOptions{Slot: pixel.Bad, Scale: 3})
|
||||
pixel.Text(c, message, pixel.TextOptions{Slot: pixel.Faint, Wrap: true, MaxLines: 6}).Width(k.D(min(pixel.Measure(message), 280)))
|
||||
display.Icon(c, icon.Skull, display.IconOptions{Scale: 3})
|
||||
text.ShadowText(c, k.Msg("continue")+" "+strconv.Itoa(left), text.ShadowTextOptions{Slot: pixel.Accent, Scale: 2})
|
||||
res.Continue = input.Button(c, k.Msg("retry"), input.ButtonOptions{Variant: input.Primary, Disabled: o.Disabled}).Clicked
|
||||
})
|
||||
if res.Continue {
|
||||
slog.Debug("8bit: continue", "left", left)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
type SuccessStateOptions struct {
|
||||
// Stars pop in one by one; zero means 3.
|
||||
Stars int
|
||||
}
|
||||
|
||||
// SuccessState is STAGE CLEAR! with stars popping in steps.
|
||||
func SuccessState(c *ui.Context, message string, o SuccessStateOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
n := pixel.Or("SuccessState Stars", o.Stars, 3)
|
||||
col := ui.Column(c).Gap(k.D(8)).Padding(k.D(16)).AlignItems(ui.Center).Role(ui.RoleStatus).Label(k.Msg("stage_clear"))
|
||||
start := ui.Local(col, startKey{}, func() time.Time { return k.Now() })
|
||||
// Stars come four a second; Reduce Motion shows them all.
|
||||
shown := n + 1
|
||||
if !k.Reduce() {
|
||||
shown = min(n+1, int(k.Now().Sub(*start)*4/time.Second))
|
||||
if shown <= n {
|
||||
k.Tick(c, 8)
|
||||
}
|
||||
}
|
||||
return col.Children(func() {
|
||||
text.ShadowText(c, k.Msg("stage_clear"), text.ShadowTextOptions{Slot: pixel.Good, Scale: 3})
|
||||
w := icon.Star.W*2 + 4
|
||||
ui.Box(c).Size(k.D(n*w), k.D(icon.Star.H*2+4)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
for i := range min(shown, n) {
|
||||
if i == shown-1 {
|
||||
for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} {
|
||||
g.Sprite(i*w+2+d[0], 2+d[1], icon.Star, 0, pixel.Lit, 2)
|
||||
}
|
||||
}
|
||||
g.Sprite(i*w+2, 2, icon.Star, 0, pixel.Warn, 2)
|
||||
}
|
||||
})
|
||||
pixel.Text(c, message, pixel.TextOptions{})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package feedback_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/feedback"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
func TestAlertAndBannerReportClicks(t *testing.T) {
|
||||
closed, action, dismissed := 0, "", 0
|
||||
tt := view(500, 300, newClock(), false, func(c *ui.Context) {
|
||||
if feedback.Alert(c, "Low HP", "Drink a potion.", feedback.AlertOptions{Kind: feedback.Danger, Closable: true}).Closed {
|
||||
closed++
|
||||
}
|
||||
r := feedback.Banner(c, "New quest!", feedback.BannerOptions{Kind: feedback.Info, Actions: []string{"VIEW"}, Dismissable: true})
|
||||
if r.Action != "" {
|
||||
action = r.Action
|
||||
}
|
||||
if r.Dismissed {
|
||||
dismissed++
|
||||
}
|
||||
})
|
||||
if count(slots(t, tt.Image(), find(t, tt, "Low HP")), pixel.Bad) == 0 {
|
||||
t.Fatal("a danger alert shows no Bad")
|
||||
}
|
||||
click(t, tt, "CLOSE")
|
||||
click(t, tt, "VIEW")
|
||||
if closed != 1 || action != "VIEW" {
|
||||
t.Fatalf("closed %d, action %q", closed, action)
|
||||
}
|
||||
tt = view(500, 100, newClock(), false, func(c *ui.Context) {
|
||||
if feedback.Banner(c, "New quest!", feedback.BannerOptions{Dismissable: true}).Dismissed {
|
||||
dismissed++
|
||||
}
|
||||
})
|
||||
click(t, tt, "CLOSE")
|
||||
if dismissed != 1 {
|
||||
t.Fatal("the banner's × did not dismiss it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyStateAction(t *testing.T) {
|
||||
acted := 0
|
||||
tt := view(400, 300, newClock(), false, func(c *ui.Context) {
|
||||
if feedback.EmptyState(c, "Your bag is empty.", feedback.EmptyStateOptions{Action: "SHOP"}).Acted {
|
||||
acted++
|
||||
}
|
||||
})
|
||||
find(t, tt, "NO ITEMS")
|
||||
click(t, tt, "SHOP")
|
||||
if acted != 1 {
|
||||
t.Fatal("the action did not report")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorStateCountsDownAndContinues(t *testing.T) {
|
||||
clk := newClock()
|
||||
cont := 0
|
||||
tt := view(600, 500, clk, true, func(c *ui.Context) {
|
||||
if feedback.ErrorState(c, "The slime won.", feedback.ErrorStateOptions{}).Continue {
|
||||
cont++
|
||||
}
|
||||
})
|
||||
find(t, tt, "CONTINUE? 9")
|
||||
wait(tt, clk, 3*time.Second)
|
||||
find(t, tt, "CONTINUE? 6")
|
||||
wait(tt, clk, 10*time.Second)
|
||||
find(t, tt, "CONTINUE? 0")
|
||||
click(t, tt, "RETRY")
|
||||
if cont != 1 {
|
||||
t.Fatal("RETRY did not report Continue")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuccessStateStarsPopInTurn(t *testing.T) {
|
||||
stars := func(reduce bool, d time.Duration) int {
|
||||
clk := newClock()
|
||||
tt := view(600, 400, clk, reduce, func(c *ui.Context) {
|
||||
feedback.SuccessState(c, "All coins found.", feedback.SuccessStateOptions{Stars: 3})
|
||||
})
|
||||
wait(tt, clk, d)
|
||||
return count(slots(t, tt.Image(), find(t, tt, "STAGE CLEAR!")), pixel.Warn)
|
||||
}
|
||||
early, late := stars(false, 300*time.Millisecond), stars(false, 2*time.Second)
|
||||
if early == 0 || early >= late {
|
||||
t.Fatalf("stars did not pop in turn: %d then %d", early, late)
|
||||
}
|
||||
if stars(true, 0) != late {
|
||||
t.Fatal("Reduce Motion did not show every star at once")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInlineMessageMarks(t *testing.T) {
|
||||
tt := view(400, 100, newClock(), false, func(c *ui.Context) {
|
||||
feedback.InlineMessage(c, "Name taken", feedback.InlineMessageOptions{Kind: feedback.Danger})
|
||||
feedback.InlineMessage(c, "Looks good", feedback.InlineMessageOptions{Kind: feedback.Success})
|
||||
})
|
||||
bad, good := slots(t, tt.Image(), find(t, tt, "Name taken")), slots(t, tt.Image(), find(t, tt, "Looks good"))
|
||||
if count(bad, pixel.Bad) == 0 || count(good, pixel.Good) == 0 {
|
||||
t.Fatal("marks do not take their kind's slot")
|
||||
}
|
||||
// The mark is the first seven dots across.
|
||||
mark := func(l string, ink pixel.Slot) (out []bool) {
|
||||
r := find(t, tt, l)
|
||||
for _, v := range slots(t, tt.Image(), ui.Rect{X: r.X, Y: r.Y, W: 14, H: r.H}) {
|
||||
out = append(out, v == ink)
|
||||
}
|
||||
return out
|
||||
}
|
||||
if equal(mark("Name taken", pixel.Bad), mark("Looks good", pixel.Good)) {
|
||||
t.Fatal("✓ and ! look alike")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToastsQueueExpireAndAct(t *testing.T) {
|
||||
clk := newClock()
|
||||
q := []feedback.Toast{{Message: "one"}, {Message: "two"}, {Message: "three", For: 5 * time.Second}, {Message: "four", Action: "UNDO"}}
|
||||
var acted *feedback.Toast
|
||||
tt := view(500, 400, clk, false, func(c *ui.Context) {
|
||||
if r := feedback.Toasts(c, &q, feedback.ToastsOptions{}); r.Acted != nil {
|
||||
acted = r.Acted
|
||||
}
|
||||
})
|
||||
if _, ok := tt.Find("four"); ok || len(q) != 4 {
|
||||
t.Fatal("more than three toasts showed")
|
||||
}
|
||||
wait(tt, clk, 3*time.Second)
|
||||
find(t, tt, "three")
|
||||
find(t, tt, "four")
|
||||
if _, ok := tt.Find("one"); ok || len(q) != 2 {
|
||||
t.Fatalf("toasts did not leave on time: %d left", len(q))
|
||||
}
|
||||
click(t, tt, "UNDO")
|
||||
if acted == nil || acted.Message != "four" || len(q) != 1 {
|
||||
t.Fatal("the action did not report or clear its toast")
|
||||
}
|
||||
wait(tt, clk, 3*time.Second)
|
||||
if len(q) != 0 {
|
||||
t.Fatal("a five-second toast outlived its time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuePromptAnswersOrTimesOut(t *testing.T) {
|
||||
clk := newClock()
|
||||
var yes, timedOut int
|
||||
tt := view(400, 400, clk, false, func(c *ui.Context) {
|
||||
r := feedback.ContinuePrompt(c, feedback.ContinuePromptOptions{Seconds: 5})
|
||||
if r.Yes {
|
||||
yes++
|
||||
}
|
||||
if r.TimedOut {
|
||||
timedOut++
|
||||
}
|
||||
})
|
||||
find(t, tt, "5")
|
||||
wait(tt, clk, 2*time.Second)
|
||||
find(t, tt, "3")
|
||||
wait(tt, clk, 4*time.Second)
|
||||
wait(tt, clk, time.Second)
|
||||
click(t, tt, "YES")
|
||||
if timedOut != 1 || yes != 0 {
|
||||
t.Fatalf("timed out %d times; YES after time counted %d", timedOut, yes)
|
||||
}
|
||||
clk2 := newClock()
|
||||
tt = view(400, 400, clk2, false, func(c *ui.Context) {
|
||||
if feedback.ContinuePrompt(c, feedback.ContinuePromptOptions{}).Yes {
|
||||
yes++
|
||||
}
|
||||
})
|
||||
click(t, tt, "YES")
|
||||
wait(tt, clk2, 20*time.Second)
|
||||
if yes != 1 {
|
||||
t.Fatal("YES did not report")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwinToastsBothShow(t *testing.T) {
|
||||
q := []feedback.Toast{{Message: "same"}, {Message: "same"}}
|
||||
tt := view(500, 300, newClock(), false, func(c *ui.Context) {
|
||||
feedback.Toasts(c, &q, feedback.ToastsOptions{})
|
||||
})
|
||||
n := 0
|
||||
for _, s := range tt.Texts() {
|
||||
if s == "same" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
// Each toast and its text both carry the message.
|
||||
if n != 4 {
|
||||
t.Fatalf("%d labels for two twin toasts", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledFeedbackButtonsDoNothing(t *testing.T) {
|
||||
events := 0
|
||||
q := []feedback.Toast{{Message: "dropped", Action: "UNDO"}}
|
||||
tt := view(800, 2000, newClock(), false, func(c *ui.Context) {
|
||||
on := func(b bool) {
|
||||
if b {
|
||||
events++
|
||||
}
|
||||
}
|
||||
on(feedback.Alert(c, "A", "a", feedback.AlertOptions{Closable: true, Disabled: true}).Closed)
|
||||
r := feedback.Banner(c, "B", feedback.BannerOptions{Actions: []string{"GO"}, Disabled: true})
|
||||
on(r.Action != "")
|
||||
on(feedback.EmptyState(c, "E", feedback.EmptyStateOptions{Action: "SHOP", Disabled: true}).Acted)
|
||||
on(feedback.ErrorState(c, "X", feedback.ErrorStateOptions{Disabled: true}).Continue)
|
||||
on(feedback.ContinuePrompt(c, feedback.ContinuePromptOptions{Disabled: true}).Yes)
|
||||
on(feedback.Toasts(c, &q, feedback.ToastsOptions{Disabled: true}).Acted != nil)
|
||||
})
|
||||
for _, l := range []string{"CLOSE", "GO", "SHOP", "RETRY", "YES", "UNDO"} {
|
||||
click(t, tt, l)
|
||||
}
|
||||
if events != 0 {
|
||||
t.Fatalf("disabled buttons reported %d events", events)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package feedback
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
// Toast is a short message in the queue Toasts shows.
|
||||
type Toast struct {
|
||||
Message string
|
||||
// Action labels an optional button.
|
||||
Action string
|
||||
// For is how long it shows; zero means three seconds.
|
||||
For time.Duration
|
||||
// id and shown are set as it first shows.
|
||||
id uint64
|
||||
shown time.Time
|
||||
}
|
||||
|
||||
type ToastsOptions struct {
|
||||
// Width is each toast's in dots; zero means 200.
|
||||
Width int
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type ToastsResult struct {
|
||||
// Element holds the toasts on screen.
|
||||
Element *ui.Element
|
||||
// Acted is the toast whose action was clicked, else nil.
|
||||
Acted *Toast
|
||||
}
|
||||
|
||||
type toastsKey struct{}
|
||||
|
||||
// Toasts shows three timed messages at the bottom.
|
||||
func Toasts(c *ui.Context, queue *[]Toast, o ToastsOptions) ToastsResult {
|
||||
k := pixel.K(c)
|
||||
w := pixel.Or("Toasts Width", o.Width, 200)
|
||||
now := k.Now()
|
||||
kept := (*queue)[:0]
|
||||
for _, t := range *queue {
|
||||
if t.For < 0 {
|
||||
panic("feedback: Toast For is negative")
|
||||
}
|
||||
life := t.For
|
||||
if life == 0 {
|
||||
life = 3 * time.Second
|
||||
}
|
||||
if t.shown.IsZero() || now.Sub(t.shown) < life {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
*queue = kept
|
||||
n := min(3, len(*queue))
|
||||
last := ui.Local(c.Root(), toastsKey{}, func() uint64 { return 0 })
|
||||
for i := range n {
|
||||
if (*queue)[i].id == 0 {
|
||||
*last++
|
||||
(*queue)[i].id, (*queue)[i].shown = *last, now
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
k.Tick(c, 4)
|
||||
}
|
||||
res := ToastsResult{}
|
||||
left := (c.Root().Bounds().W - k.D(w)) / 2
|
||||
ui.Overlay(c, func() {
|
||||
res.Element = ui.Column(c).Absolute().Left(left).Bottom(k.D(8)).Width(k.D(w)).Gap(k.D(4)).Children(func() {
|
||||
for i := range n {
|
||||
t := (*queue)[i]
|
||||
ui.Row(c).Key(t.id).Padding(k.D(5), k.D(6)).Gap(k.D(6)).AlignItems(ui.Center).Role(ui.RoleStatus).Label(t.Message).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
g.Frame(0, 0, g.W, g.H, pixel.Window, pixel.Face)
|
||||
}).Children(func() {
|
||||
pixel.Text(c, t.Message, pixel.TextOptions{Wrap: true, MaxLines: 2}).Grow(1)
|
||||
if t.Action != "" && input.Button(c, t.Action, input.ButtonOptions{Size: input.Small, Disabled: o.Disabled}).Clicked {
|
||||
res.Acted = &t
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
if res.Acted != nil {
|
||||
*queue = removeToast(*queue, res.Acted.id)
|
||||
slog.Debug("8bit: toast action", "message", res.Acted.Message, "action", res.Acted.Action)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// removeToast drops the toast with id from q.
|
||||
func removeToast(q []Toast, id uint64) []Toast {
|
||||
for i := range q {
|
||||
if q[i].id == id {
|
||||
return append(q[:i], q[i+1:]...)
|
||||
}
|
||||
}
|
||||
return q
|
||||
}
|
||||
Reference in new issue
Block a user