Add palettes and frame settings

This commit is contained in:
ZacharyZhang-NY committed 2026-10-11 15:31:34 -04:00
1 parent 7dfb19d1a9
commit 1af6cd61bf
7 files changed
+542 -1

No files matched your search

+1
View File
@@ -54,6 +54,7 @@ Imports point down only. Same-tier imports are forbidden. `internal/archtest` en
- Signatures and results: PRD §5.1. Zero options give the default look. Sizes in options are dots.
- `k := pixel.K(c)` once per component; layout sizes through `k.D(n)`.
- Row builders that MyGo runs during layout (`List`, `GridView`, `Outline`) go through `pixel.Lazy(c, row)`.
- Copy shown by a component comes from `k.Msg`; business copy comes from the caller.
- Fail fast: panic on programmer errors such as bad options. Never swallow an error.
- Composites log state changes: `slog.Debug("8bit: <component> <event>", …)`. Never log secrets.
+4 -1
View File
@@ -2,7 +2,10 @@ module git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI
go 1.27.1
require github.com/egoist/mygo v0.2.10
require (
github.com/ebitengine/purego v0.11.1 // indirect
github.com/egoist/mygo v0.2.10 // indirect
github.com/go-text/typesetting v0.3.5 // indirect
golang.org/x/image v0.46.0 // indirect
)
+6
View File
@@ -2,3 +2,9 @@ github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6Xe
github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
github.com/egoist/mygo v0.2.10 h1:Mfe6SpQngC9kLjrejpGM7L/k3Q62BdBAyJ8MrHfL6bQ=
github.com/egoist/mygo v0.2.10/go.mod h1:1sRzZ6jEhKE7iuEQXjyoXDgxyxQ1uTgMHDcDKZvoDIU=
github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk=
github.com/go-text/typesetting v0.3.5/go.mod h1:XZO1hD+nQVyvVa5IicQk7FsCa4PFQaJ2soWAP1f//68=
github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc h1:8FGo2It5K75XkavhTiCKExUfVaVDS1feBnLCru5qeoY=
github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
+98
View File
@@ -0,0 +1,98 @@
// Package pixel draws 16-bit pixel art for MyGo.
package pixel
import (
"strings"
"github.com/egoist/mygo/ui"
)
// Slot names a palette color by its role.
type Slot uint8
const (
Back Slot = iota
Face
Lit
Shade
Edge
Ink
Faint
Well
Accent
AccentLit
AccentInk
Good
Warn
Bad
Info
Magic
)
const Slots = 16
type Season uint8
const (
Spring Season = iota
Summer
Autumn
Winter
)
type Palette struct {
Name, Zh string
Season Season
Night bool
Colors [Slots]ui.Color
}
func (p *Palette) C(s Slot) ui.Color { return p.Colors[s] }
var (
SpringDay = palette("Spring Day", "春·昼", Spring, false, "F4EEDC E4DCC6 FFFBEF B3A68C 3A2A3E 33283A 6F6358 FFF6E6 B8395C F2A7B9 FFFFFF 4F9A3F D9982A C73A3A 3F7FCB 8C5BC4")
SpringNight = palette("Spring Night", "春·夜", Spring, true, "1C1730 2E2850 4C4478 1A1530 E8DFF7 F3EEFF A79DC4 14102A F07EA0 FFC2D2 120E24 7CC66A F2C14E F0605F 6FA8F0 B58AF0")
SummerDay = palette("Summer Day", "夏·昼", Summer, false, "F7F3E3 DCEBEE FFFFFF 98B3B8 1D3640 15303B 4F6870 F4FCFF 0E6E8C 7FD0E3 FFFFF6 3A9A4A E0A21A D14836 2F7FD0 9A57C8")
SummerNight = palette("Summer Night", "夏·夜", Summer, true, "0B1630 172A55 2C4A86 0A1328 DDEBFF EAF3FF 98ACCF 08102A 36C2E0 A8EEFA 050C1E B8E35A F7D04A F26A5A 5FA8FF C58CFF")
AutumnDay = palette("Autumn Day", "秋·昼", Autumn, false, "F2E6D0 E6D2B0 FFF6E2 B08F62 3A2416 33200F 6E5640 FFF1D8 B94A1C F0A060 FFFFFF 6E8F2E C98A12 B8302A 3F78B0 8E4E9E")
AutumnNight = palette("Autumn Night", "秋·夜", Autumn, true, "1E1414 33221E 5A3D30 170F0E F1DCC4 FBEBD8 B39A86 140C0B F09A3A FFD08A 0E0808 A8C25A F6C65A F0624E 79A8E0 C88AD8")
WinterDay = palette("Winter Day", "冬·昼", Winter, false, "EEF3F8 D8E2EC FFFFFF 98A9BC 1C2A3C 17243A 52637A F6FAFF 1F5FA8 9CC8F0 FCFEFF 3C8C6C B98A1E B23A48 2A86A8 7656B8")
WinterNight = palette("Winter Night", "冬·夜", Winter, true, "0A1020 18233F 304674 080C1A E2F2FF EEF7FF 9DB2CF 060A16 7FD8F0 CFF4FF 040814 6FE0A8 F5D47A F07A8A 6FB0FF A994F5")
)
var Palettes = []*Palette{SpringDay, SpringNight, SummerDay, SummerNight, AutumnDay, AutumnNight, WinterDay, WinterNight}
func Find(s Season, night bool) *Palette {
for _, p := range Palettes {
if p.Season == s && p.Night == night {
return p
}
}
panic("pixel: no palette for season")
}
// Cycle rotates band by step, for color cycling.
func Cycle(band []Slot, step int) []Slot {
n := len(band)
if n == 0 {
panic("pixel: Cycle needs a band")
}
r := (step%n + n) % n
out := make([]Slot, n)
for i := range band {
out[i] = band[(i+r)%n]
}
return out
}
func palette(name, zh string, s Season, night bool, hex string) *Palette {
f := strings.Fields(hex)
if len(f) != Slots {
panic("pixel: palette " + name + " needs 16 colors")
}
p := &Palette{Name: name, Zh: zh, Season: s, Night: night}
for i, h := range f {
p.Colors[i] = ui.Hex("#" + h)
}
return p
}
+117
View File
@@ -0,0 +1,117 @@
package pixel_test
import (
"math"
"slices"
"testing"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func luminance(c ui.Color) float64 {
lin := func(v uint8) float64 {
s := float64(v) / 255
if s <= 0.04045 {
return s / 12.92
}
return math.Pow((s+0.055)/1.055, 2.4)
}
return 0.2126*lin(c.R) + 0.7152*lin(c.G) + 0.0722*lin(c.B)
}
func contrast(a, b ui.Color) float64 {
la, lb := luminance(a), luminance(b)
return (max(la, lb) + 0.05) / (min(la, lb) + 0.05)
}
func TestPaletteContrast(t *testing.T) {
rules := []struct {
fg, bg pixel.Slot
min float64
}{
{pixel.Ink, pixel.Face, 4.5},
{pixel.Ink, pixel.Back, 4.5},
{pixel.Ink, pixel.Well, 4.5},
{pixel.AccentInk, pixel.Accent, 4.5},
{pixel.Faint, pixel.Face, 3},
{pixel.Edge, pixel.Back, 3},
}
for _, p := range pixel.Palettes {
for _, r := range rules {
if got := contrast(p.C(r.fg), p.C(r.bg)); got < r.min {
t.Errorf("%s: slot %d on %d contrast %.2f, want ≥ %.1f", p.Name, r.fg, r.bg, got, r.min)
}
}
}
}
func TestPaletteColorsDistinctAndOpaque(t *testing.T) {
if len(pixel.Palettes) != 8 {
t.Fatalf("got %d palettes, want 8", len(pixel.Palettes))
}
for _, p := range pixel.Palettes {
seen := map[ui.Color]pixel.Slot{}
for s, c := range p.Colors {
if c.A != 255 {
t.Errorf("%s slot %d alpha %d", p.Name, s, c.A)
}
if prev, ok := seen[c]; ok {
t.Errorf("%s slots %d and %d share %v", p.Name, prev, s, c)
}
seen[c] = pixel.Slot(s)
}
}
}
func TestPaletteBevelOrder(t *testing.T) {
for _, p := range pixel.Palettes {
face := luminance(p.C(pixel.Face))
if luminance(p.C(pixel.Lit)) <= face || luminance(p.C(pixel.Shade)) >= face {
t.Errorf("%s: Lit must be lighter and Shade darker than Face", p.Name)
}
if p.Night != (luminance(p.C(pixel.Back)) < luminance(p.C(pixel.Ink))) {
t.Errorf("%s: night palettes are dark, day palettes light", p.Name)
}
}
}
func TestFind(t *testing.T) {
seen := map[*pixel.Palette]bool{}
for s := pixel.Spring; s <= pixel.Winter; s++ {
for _, night := range []bool{false, true} {
p := pixel.Find(s, night)
if p.Season != s || p.Night != night {
t.Errorf("Find(%d, %v) = %s", s, night, p.Name)
}
seen[p] = true
}
}
if len(seen) != 8 {
t.Fatalf("Find reached %d palettes, want 8", len(seen))
}
}
func TestCycle(t *testing.T) {
band := []pixel.Slot{pixel.Info, pixel.Accent, pixel.Lit}
cases := []struct {
step int
want []pixel.Slot
}{
{0, []pixel.Slot{pixel.Info, pixel.Accent, pixel.Lit}},
{1, []pixel.Slot{pixel.Accent, pixel.Lit, pixel.Info}},
{-1, []pixel.Slot{pixel.Lit, pixel.Info, pixel.Accent}},
{7, []pixel.Slot{pixel.Accent, pixel.Lit, pixel.Info}},
{math.MaxInt, []pixel.Slot{pixel.Accent, pixel.Lit, pixel.Info}},
{math.MinInt, []pixel.Slot{pixel.Accent, pixel.Lit, pixel.Info}},
}
for _, c := range cases {
if got := pixel.Cycle(band, c.step); !slices.Equal(got, c.want) {
t.Errorf("Cycle(step %d) = %v, want %v", c.step, got, c.want)
}
}
if !slices.Equal(band, []pixel.Slot{pixel.Info, pixel.Accent, pixel.Lit}) {
t.Fatalf("Cycle changed its input: %v", band)
}
}
+125
View File
@@ -0,0 +1,125 @@
package pixel
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
)
type Locale uint8
const (
En Locale = iota
Zh
)
type Settings struct {
Palette *Palette
// Dot is DIPs per dot, 1–4; zero means 2.
Dot int
Locale Locale
// Now replaces the frame clock, for tests.
Now func() time.Time
}
// Kit is one frame's settings, captured by components.
type Kit struct {
pal *Palette
dot float32
locale Locale
now time.Time
reduce bool
}
func (k Kit) Palette() *Palette { return k.pal }
func (k Kit) C(s Slot) ui.Color { return k.pal.Colors[s] }
func (k Kit) Dot() float32 { return k.dot }
func (k Kit) D(n int) float32 { return float32(n) * k.dot }
func (k Kit) Locale() Locale { return k.locale }
func (k Kit) Now() time.Time { return k.now }
func (k Kit) Reduce() bool { return k.reduce }
type frame struct {
cur Settings
at time.Time
}
type frameKey struct{}
// Use must run once per frame, before any component.
func Use(c *ui.Context, s Settings) {
s = s.valid()
f := frameOf(c)
if f.cur.Palette != s.Palette {
slog.Debug("8bit: palette", "name", s.Palette.Name)
}
f.cur, f.at = s, c.Now()
c.SetTheme(theme(s.Palette))
}
// Scope builds fn under other settings.
func Scope(c *ui.Context, s Settings, fn func()) {
f := current(c)
prev := f.cur
f.cur = s.valid()
fn()
f.cur = prev
}
// Lazy keeps this scope for rows MyGo builds during layout.
func Lazy[T any](c *ui.Context, fn func(T)) func(T) {
s := current(c).cur
return func(v T) { Scope(c, s, func() { fn(v) }) }
}
func K(c *ui.Context) Kit {
s := current(c).cur
now := c.Now()
if s.Now != nil {
now = s.Now()
}
return Kit{pal: s.Palette, dot: float32(s.Dot), locale: s.Locale, now: now, reduce: c.Preferences().ReduceMotion}
}
func frameOf(c *ui.Context) *frame {
return ui.Local(c.Root(), frameKey{}, func() frame { return frame{} })
}
func current(c *ui.Context) *frame {
f := frameOf(c)
if !f.at.Equal(c.Now()) {
panic("pixel: Use was not called in this frame")
}
return f
}
func (s Settings) valid() Settings {
if s.Palette == nil {
panic("pixel: Settings.Palette is nil")
}
if s.Dot == 0 {
s.Dot = 2
}
if s.Dot < 1 || s.Dot > 4 {
panic(fmt.Sprintf("pixel: Settings.Dot %d outside 1–4", s.Dot))
}
if s.Locale > Zh {
panic(fmt.Sprintf("pixel: unknown Locale %d", s.Locale))
}
return s
}
// theme keeps MyGo's own drawing inside the palette.
func theme(p *Palette) *ui.Theme {
t := ui.LightTheme()
c := p.Colors
t.Dark = p.Night
t.Background, t.Surface, t.SurfaceHover, t.SurfacePressed = c[Back], c[Face], c[Lit], c[Shade]
t.Border, t.Text, t.TextMuted = c[Edge], c[Ink], c[Faint]
t.Accent, t.AccentHover, t.AccentPressed, t.AccentText = c[Accent], c[AccentLit], c[Accent], c[AccentInk]
t.Danger, t.Warning, t.Success = c[Bad], c[Warn], c[Good]
t.Selection, t.Focus, t.Scrollbar, t.Radius = c[AccentLit], c[Accent], ui.Transparent, 0
return t
}
+191
View File
@@ -0,0 +1,191 @@
package pixel_test
import (
"image/color"
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func mustPanic(t *testing.T, what string, fn func()) {
t.Helper()
defer func() {
if recover() == nil {
t.Fatalf("%s: no panic", what)
}
}()
fn()
}
func TestUseSetsKitAndBackground(t *testing.T) {
var k pixel.Kit
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.WinterNight, Dot: 3, Locale: pixel.Zh})
k = pixel.K(c)
}, 40, 30)
if k.Palette() != pixel.WinterNight || k.Dot() != 3 || k.D(5) != 15 || k.Locale() != pixel.Zh {
t.Fatalf("kit = palette %s dot %v D(5) %v locale %d", k.Palette().Name, k.Dot(), k.D(5), k.Locale())
}
if k.C(pixel.Ink) != pixel.WinterNight.C(pixel.Ink) {
t.Fatalf("C(Ink) = %v", k.C(pixel.Ink))
}
back := pixel.WinterNight.C(pixel.Back)
want := color.RGBA{back.R, back.G, back.B, 255}
if got := tt.Image().RGBAAt(20, 15); got != want {
t.Fatalf("window background %v, want %v", got, want)
}
}
func TestUseBridgesTheme(t *testing.T) {
for _, p := range []*pixel.Palette{pixel.SummerDay, pixel.AutumnNight} {
var th ui.Theme
ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: p})
th = *c.Theme()
}, 10, 10)
pairs := []struct {
name string
got, want ui.Color
}{
{"Background", th.Background, p.C(pixel.Back)},
{"Surface", th.Surface, p.C(pixel.Face)},
{"SurfaceHover", th.SurfaceHover, p.C(pixel.Lit)},
{"SurfacePressed", th.SurfacePressed, p.C(pixel.Shade)},
{"Border", th.Border, p.C(pixel.Edge)},
{"Text", th.Text, p.C(pixel.Ink)},
{"TextMuted", th.TextMuted, p.C(pixel.Faint)},
{"Accent", th.Accent, p.C(pixel.Accent)},
{"AccentHover", th.AccentHover, p.C(pixel.AccentLit)},
{"AccentPressed", th.AccentPressed, p.C(pixel.Accent)},
{"AccentText", th.AccentText, p.C(pixel.AccentInk)},
{"Danger", th.Danger, p.C(pixel.Bad)},
{"Warning", th.Warning, p.C(pixel.Warn)},
{"Success", th.Success, p.C(pixel.Good)},
{"Selection", th.Selection, p.C(pixel.AccentLit)},
{"Focus", th.Focus, p.C(pixel.Accent)},
{"Scrollbar", th.Scrollbar, ui.Transparent},
}
for _, pr := range pairs {
if pr.got != pr.want {
t.Errorf("%s: theme %s = %v, want %v", p.Name, pr.name, pr.got, pr.want)
}
}
if th.Radius != 0 || th.Dark != p.Night {
t.Errorf("%s: Radius %v Dark %v", p.Name, th.Radius, th.Dark)
}
}
}
func TestLazyKeepsScopeForLayoutRows(t *testing.T) {
seen := map[int]*pixel.Palette{}
ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay})
pixel.Scope(c, pixel.Settings{Palette: pixel.WinterNight}, func() {
ui.List(c, nil, 100, pixel.Lazy(c, func(i int) {
seen[i] = pixel.K(c).Palette()
ui.Box(c).Height(10)
})).Height(200)
})
}, 100, 220)
if len(seen) < 20 {
t.Fatalf("list built %d rows, want at least 20", len(seen))
}
for i, p := range seen {
if p != pixel.WinterNight {
t.Fatalf("row %d built under %s, want Winter Night", i, p.Name)
}
}
}
func TestDotDefaultsToTwo(t *testing.T) {
var k pixel.Kit
ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay})
k = pixel.K(c)
}, 10, 10)
if k.Dot() != 2 {
t.Fatalf("Dot = %v, want 2", k.Dot())
}
}
func TestScopeOverridesAndRestores(t *testing.T) {
var inside, after *pixel.Palette
var insideDot float32
ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.AutumnDay})
pixel.Scope(c, pixel.Settings{Palette: pixel.SummerNight, Dot: 4}, func() {
k := pixel.K(c)
inside, insideDot = k.Palette(), k.Dot()
})
after = pixel.K(c).Palette()
}, 10, 10)
if inside != pixel.SummerNight || insideDot != 4 {
t.Fatalf("inside Scope: %s dot %v", inside.Name, insideDot)
}
if after != pixel.AutumnDay {
t.Fatalf("after Scope: %s, want Autumn Day", after.Name)
}
}
func TestKWithoutUsePanics(t *testing.T) {
mustPanic(t, "K before Use", func() {
ui.NewTester(func(c *ui.Context) { pixel.K(c) }, 10, 10)
})
}
func TestUseIsRequiredEveryFrame(t *testing.T) {
skip := false
tt := ui.NewTester(func(c *ui.Context) {
if !skip {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay})
}
pixel.K(c)
}, 10, 10)
skip = true
time.Sleep(time.Millisecond)
mustPanic(t, "frame without Use", tt.Frame)
}
func TestInvalidSettingsPanic(t *testing.T) {
bad := map[string]pixel.Settings{
"nil palette": {},
"dot 5": {Palette: pixel.SpringDay, Dot: 5},
"dot -1": {Palette: pixel.SpringDay, Dot: -1},
"locale 9": {Palette: pixel.SpringDay, Locale: 9},
}
for name, s := range bad {
mustPanic(t, name, func() {
ui.NewTester(func(c *ui.Context) { pixel.Use(c, s) }, 10, 10)
})
}
}
func TestNowOverride(t *testing.T) {
fixed := time.Date(2026, 10, 11, 9, 30, 0, 0, time.UTC)
var k pixel.Kit
ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: func() time.Time { return fixed }})
k = pixel.K(c)
}, 10, 10)
if !k.Now().Equal(fixed) {
t.Fatalf("Now = %v, want %v", k.Now(), fixed)
}
}
func TestReduceMotionFollowsPreferences(t *testing.T) {
var k pixel.Kit
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay})
k = pixel.K(c)
}, 10, 10)
if k.Reduce() {
t.Fatal("Reduce on by default")
}
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
if !k.Reduce() {
t.Fatal("Reduce off after ReduceMotion preference")
}
}