diff --git a/kit/kit.go b/kit/kit.go new file mode 100644 index 0000000..4c2b4c3 --- /dev/null +++ b/kit/kit.go @@ -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...) +} diff --git a/kit/kit_test.go b/kit/kit_test.go new file mode 100644 index 0000000..ff47ba6 --- /dev/null +++ b/kit/kit_test.go @@ -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) + } +}