diff --git a/AGENTS.md b/AGENTS.md index 60c7b27..2e1f614 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: ", …)`. Never log secrets. diff --git a/go.mod b/go.mod index cda7b73..30e4550 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 3f55274..3b02edf 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pixel/palette.go b/pixel/palette.go new file mode 100644 index 0000000..0f88a53 --- /dev/null +++ b/pixel/palette.go @@ -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 +} diff --git a/pixel/palette_test.go b/pixel/palette_test.go new file mode 100644 index 0000000..3858a81 --- /dev/null +++ b/pixel/palette_test.go @@ -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) + } +} diff --git a/pixel/settings.go b/pixel/settings.go new file mode 100644 index 0000000..6fe1f7f --- /dev/null +++ b/pixel/settings.go @@ -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 +} diff --git a/pixel/settings_test.go b/pixel/settings_test.go new file mode 100644 index 0000000..49616a6 --- /dev/null +++ b/pixel/settings_test.go @@ -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") + } +}