Add kit: press, lift, enter, pop, cue helpers, card, muted, log
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
2431e2fcde
commit
a9096b8aa2
2 files changed
+282
No files matched your search
+80
@@ -0,0 +1,80 @@
|
||||
// Package kit holds helpers three or more components share.
|
||||
package kit
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/sound"
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// Press shrinks e while it is pressed.
|
||||
func Press(e ui.Element) ui.Element {
|
||||
p := ui.Pose{}
|
||||
if e.Pressed() {
|
||||
p.Scale = 0.97
|
||||
}
|
||||
return e.AnimatePose(p, ui.Snappy)
|
||||
}
|
||||
|
||||
// Lift raises e 2 DIP while it is hovered.
|
||||
func Lift(e ui.Element) ui.Element {
|
||||
p := ui.Pose{}
|
||||
if e.Hovered() {
|
||||
p.Y = -2
|
||||
}
|
||||
return e.AnimatePose(p, ui.Snappy)
|
||||
}
|
||||
|
||||
var popSpring = ui.Bouncy
|
||||
|
||||
var (
|
||||
// Enter fades an element in from 4 DIP below.
|
||||
Enter = ui.ElementTransition{
|
||||
Duration: 160 * time.Millisecond,
|
||||
Enter: &ui.Motion{Y: 4},
|
||||
Exit: &ui.Motion{Y: 4},
|
||||
}
|
||||
// Pop scales an element in and out with a bounce.
|
||||
Pop = ui.ElementTransition{
|
||||
Animation: &popSpring,
|
||||
Enter: &ui.Motion{Scale: 0.9},
|
||||
Exit: &ui.Motion{Scale: 0.9},
|
||||
}
|
||||
)
|
||||
|
||||
// Clicked reports e.Clicked and plays cue when true.
|
||||
func Clicked(e ui.Element, cue sound.Cue) bool {
|
||||
if !e.Clicked() {
|
||||
return false
|
||||
}
|
||||
sound.Play(cue)
|
||||
return true
|
||||
}
|
||||
|
||||
// Changed reports e.Changed and plays cue when true.
|
||||
func Changed(e ui.Element, cue sound.Cue) bool {
|
||||
if !e.Changed() {
|
||||
return false
|
||||
}
|
||||
sound.Play(cue)
|
||||
return true
|
||||
}
|
||||
|
||||
// Card returns the shared card surface.
|
||||
func Card(c *ui.Context) ui.Element {
|
||||
t := c.Theme()
|
||||
return ui.Column(c).Background(t.Background).Border(1, t.Border).Radius(t.Radius).
|
||||
Shadow(0, 1, 3, 0, ui.RGBA(0, 0, 0, 0.06))
|
||||
}
|
||||
|
||||
// Muted returns secondary text.
|
||||
func Muted(c *ui.Context, s string) ui.Element {
|
||||
return ui.Text(c, s).TextColor(c.Theme().TextMuted)
|
||||
}
|
||||
|
||||
// Log records a component state change at debug level.
|
||||
func Log(component, event string, args ...any) {
|
||||
slog.Debug("crychic: "+component+" "+event, args...)
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
package kit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/sound"
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func near(img *image.RGBA, x, y int, want ui.Color) bool {
|
||||
p := img.RGBAAt(x, y)
|
||||
d := func(a, b uint8) bool { return a-b < 4 || b-a < 4 }
|
||||
return d(p.R, want.R) && d(p.G, want.G) && d(p.B, want.B)
|
||||
}
|
||||
|
||||
func lum(r, g, b uint8) int { return int(r)*2126 + int(g)*7152 + int(b)*722 }
|
||||
|
||||
func count(cue sound.Cue) int {
|
||||
n := 0
|
||||
for _, c := range sound.Recent() {
|
||||
if c == cue {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// posed poses an accent box at (50,50).
|
||||
func posed(pose func(ui.Element) ui.Element, bg, fill *ui.Color) func(*ui.Context) {
|
||||
return func(c *ui.Context) {
|
||||
t := c.Theme()
|
||||
*bg, *fill = t.Background, t.Accent
|
||||
ui.Box(c).Size(200, 200).Padding(50).Background(t.Background).Children(func() {
|
||||
pose(ui.Box(c).Size(100, 100).Background(t.Accent))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPressShrinks(t *testing.T) {
|
||||
var bg, fill ui.Color
|
||||
tt := ui.NewTester(posed(Press, &bg, &fill), 200, 200)
|
||||
if !near(tt.Image(), 50, 50, fill) {
|
||||
t.Fatal("corner not filled at rest")
|
||||
}
|
||||
tt.Press(100, 100)
|
||||
tt.Advance(time.Second)
|
||||
if !near(tt.Image(), 50, 50, bg) {
|
||||
t.Error("corner still filled while pressed")
|
||||
}
|
||||
tt.Release(100, 100)
|
||||
tt.Advance(time.Second)
|
||||
if !near(tt.Image(), 50, 50, fill) {
|
||||
t.Error("corner not refilled after release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiftRaises(t *testing.T) {
|
||||
var bg, fill ui.Color
|
||||
tt := ui.NewTester(posed(Lift, &bg, &fill), 200, 200)
|
||||
if !near(tt.Image(), 100, 149, fill) || !near(tt.Image(), 100, 48, bg) {
|
||||
t.Fatal("box not at rest")
|
||||
}
|
||||
tt.Move(100, 100)
|
||||
tt.Advance(time.Second)
|
||||
if !near(tt.Image(), 100, 149, bg) || !near(tt.Image(), 100, 48, fill) {
|
||||
t.Error("box not lifted 2 DIP on hover")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransitionsEnterAndExit(t *testing.T) {
|
||||
for name, tr := range map[string]ui.ElementTransition{"Enter": Enter, "Pop": Pop} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var bg, fill ui.Color
|
||||
show := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
th := c.Theme()
|
||||
bg, fill = th.Background, th.Accent
|
||||
ui.Column(c).Size(100, 100).Background(bg).Children(func() {
|
||||
if show {
|
||||
ui.Box(c).Key("b").Size(100, 100).Background(fill).Transition(tr)
|
||||
}
|
||||
})
|
||||
}, 100, 100)
|
||||
show = true
|
||||
tt.Frame()
|
||||
if !tt.FramePending() || !near(tt.Image(), 2, 2, bg) {
|
||||
t.Fatal("entry did not start hidden")
|
||||
}
|
||||
tt.Advance(time.Second)
|
||||
if tt.FramePending() || !near(tt.Image(), 2, 2, fill) {
|
||||
t.Fatal("entry did not settle")
|
||||
}
|
||||
show = false
|
||||
tt.Frame()
|
||||
if !tt.FramePending() {
|
||||
t.Fatal("exit did not animate")
|
||||
}
|
||||
tt.Advance(time.Second)
|
||||
if tt.FramePending() || !near(tt.Image(), 2, 2, bg) {
|
||||
t.Error("exit did not settle")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClickedPlaysOnce(t *testing.T) {
|
||||
n := 0
|
||||
before := count(sound.Tap)
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
if Clicked(ui.Button(c, "Go"), sound.Tap) {
|
||||
n++
|
||||
}
|
||||
}, 200, 100)
|
||||
if count(sound.Tap) != before {
|
||||
t.Fatal("cue played without a click")
|
||||
}
|
||||
if err := tt.Click("Go"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 || count(sound.Tap) != before+1 {
|
||||
t.Errorf("clicks %d, taps %d, want 1 and %d", n, count(sound.Tap), before+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangedPlaysOnce(t *testing.T) {
|
||||
n, on := 0, false
|
||||
before := count(sound.Toggle)
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
if Changed(ui.Checkbox(c, &on, "Box"), sound.Toggle) {
|
||||
n++
|
||||
}
|
||||
}, 200, 100)
|
||||
if err := tt.Click("Box"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !on || n != 1 || count(sound.Toggle) != before+1 {
|
||||
t.Errorf("on %v, changes %d, toggles %d", on, n, count(sound.Toggle)-before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardSurface(t *testing.T) {
|
||||
var th ui.Theme
|
||||
img := ui.Render(func(c *ui.Context) {
|
||||
th = *c.Theme()
|
||||
ui.Box(c).Size(140, 140).Padding(20).Background(th.Background).Children(func() {
|
||||
Card(c).Size(100, 100)
|
||||
})
|
||||
}, 140, 140, 1)
|
||||
if !near(img, 70, 70, th.Background) {
|
||||
t.Error("card face is not Background")
|
||||
}
|
||||
if !near(img, 20, 70, th.Border) {
|
||||
t.Error("card edge is not Border")
|
||||
}
|
||||
p, q := img.RGBAAt(70, 120), img.RGBAAt(70, 135)
|
||||
if lum(p.R, p.G, p.B) >= lum(q.R, q.G, q.B) {
|
||||
t.Error("card casts no shadow")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutedColor(t *testing.T) {
|
||||
var th ui.Theme
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
th = *c.Theme()
|
||||
ui.Column(c).Grow(1).Background(th.Background).Children(func() {
|
||||
Muted(c, "Note")
|
||||
})
|
||||
}, 200, 100)
|
||||
r, ok := tt.Find("Note")
|
||||
if !ok {
|
||||
t.Fatal("Note not shown")
|
||||
}
|
||||
img, floor, inked := tt.Image(), lum(th.TextMuted.R, th.TextMuted.G, th.TextMuted.B), false
|
||||
for y := int(r.Y); y < int(r.Y+r.H); y++ {
|
||||
for x := int(r.X); x < int(r.X+r.W); x++ {
|
||||
p := img.RGBAAt(x, y)
|
||||
if l := lum(p.R, p.G, p.B); l < floor-40000 {
|
||||
t.Fatalf("pixel at %d,%d darker than TextMuted", x, y)
|
||||
}
|
||||
inked = inked || !near(img, x, y, th.Background)
|
||||
}
|
||||
}
|
||||
if !inked {
|
||||
t.Error("Note drew nothing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLog(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
old := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
t.Cleanup(func() { slog.SetDefault(old) })
|
||||
Log("button", "click", "count", 2)
|
||||
if s := buf.String(); !strings.Contains(s, `msg="crychic: button click" count=2`) {
|
||||
t.Errorf("log = %q", s)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user