Add gallery core and command: routes, sidebar, search, code panel, browser hook
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
727de5f110
commit
ff50f9b35a
10 files changed
+750
-2
No files matched your search
@@ -70,8 +70,9 @@ health, home, marketing, content, desktop, geo · 6 gallery · 7 cmd/gallery
|
|||||||
|
|
||||||
## Gallery entry
|
## Gallery entry
|
||||||
|
|
||||||
Each component adds one `Entry` in `gallery/demos_<category>*.go`: ID, Name, Category, Summary, Demo. The demo reports
|
Each component adds one `Entry` in `gallery/demos_<category>*.go`: ID, Name, Category, Summary, Demo. Each file's list
|
||||||
every event through `log`. The code panel shows the demo function's own source.
|
joins `entries` in `gallery/demos.go`. The demo is a top-level function and reports every event through `log`. The
|
||||||
|
code panel shows the demo function's own source, cut from the embedded `demos*.go`.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
//go:build !(js && wasm)
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/gallery"
|
||||||
|
|
||||||
|
func hook(*gallery.App) {}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
//go:build js && wasm
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"syscall/js"
|
||||||
|
|
||||||
|
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/gallery"
|
||||||
|
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/sound"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hook exports window.__crychic for Playwright.
|
||||||
|
func hook(app *gallery.App) {
|
||||||
|
obj := js.Global().Get("Object").New()
|
||||||
|
js.Global().Get("Object").Call("defineProperty", obj, "ready", map[string]any{
|
||||||
|
"get": js.FuncOf(func(js.Value, []js.Value) any { return app.Snapshot().Built }),
|
||||||
|
})
|
||||||
|
obj.Set("page", js.FuncOf(func(js.Value, []js.Value) any { return app.Snapshot().Page }))
|
||||||
|
obj.Set("stats", js.FuncOf(func(js.Value, []js.Value) any {
|
||||||
|
return map[string]any{"components": len(gallery.Entries()), "categories": len(gallery.Categories)}
|
||||||
|
}))
|
||||||
|
obj.Set("recent", js.FuncOf(func(js.Value, []js.Value) any {
|
||||||
|
var out []any
|
||||||
|
for _, c := range sound.Recent() {
|
||||||
|
out = append(out, c.String())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}))
|
||||||
|
js.Global().Set("__crychic", obj)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Command gallery opens the CrychicUI gallery on desktops and browsers.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/gallery"
|
||||||
|
"github.com/egoist/mygo"
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
app := gallery.New("/")
|
||||||
|
hook(app)
|
||||||
|
mygo.App.WhenReady(func() {
|
||||||
|
mygo.NewWindow(mygo.WindowOptions{
|
||||||
|
Title: "CrychicUI",
|
||||||
|
Width: 1280,
|
||||||
|
Height: 820,
|
||||||
|
MinWidth: 720,
|
||||||
|
MinHeight: 480,
|
||||||
|
Content: ui.View(app.View),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err := mygo.App.Run(); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
package gallery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
"github.com/egoist/mygo/ui/editor"
|
||||||
|
)
|
||||||
|
|
||||||
|
const keepEvents = 6
|
||||||
|
|
||||||
|
// App is the gallery that desktop and browser share.
|
||||||
|
type App struct {
|
||||||
|
router *ui.Router
|
||||||
|
query string
|
||||||
|
mode int
|
||||||
|
events []string
|
||||||
|
codes map[string]*editor.State
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
snap Snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot is what the browser hook reads between frames.
|
||||||
|
type Snapshot struct {
|
||||||
|
Page string
|
||||||
|
Built bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// New opens the gallery at path.
|
||||||
|
func New(path string) *App {
|
||||||
|
return &App{router: ui.NewRouter(path), codes: map[string]*editor.State{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot returns the state of the last built frame.
|
||||||
|
func (a *App) Snapshot() Snapshot {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
return a.snap
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) View(c *ui.Context) {
|
||||||
|
switch a.mode {
|
||||||
|
case 1:
|
||||||
|
c.SetTheme(ui.LightTheme())
|
||||||
|
case 2:
|
||||||
|
c.SetTheme(ui.DarkTheme())
|
||||||
|
}
|
||||||
|
ui.Column(c).Fill().Children(func() {
|
||||||
|
a.topBar(c)
|
||||||
|
ui.Row(c).Grow(1).AlignItems(ui.Stretch).Children(func() {
|
||||||
|
a.sidebar(c)
|
||||||
|
ui.Column(c).Grow(1).Children(func() {
|
||||||
|
if strings.TrimSpace(a.query) != "" {
|
||||||
|
a.results(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.pages(c)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
a.statusBar(c)
|
||||||
|
})
|
||||||
|
a.mu.Lock()
|
||||||
|
a.snap = Snapshot{Page: a.router.Location(), Built: true}
|
||||||
|
a.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go opens path and clears the search.
|
||||||
|
func (a *App) Go(path string) {
|
||||||
|
a.query = ""
|
||||||
|
if path != a.router.Location() {
|
||||||
|
slog.Debug("crychic: gallery go", "path", path)
|
||||||
|
a.router.Push(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// logger keeps a demo's events for the status strip.
|
||||||
|
func (a *App) logger(e Entry) func(string) {
|
||||||
|
return func(ev string) {
|
||||||
|
// The strip shows the event; the log never does.
|
||||||
|
slog.Debug("crychic: gallery event", "entry", e.Name)
|
||||||
|
a.events = append(a.events, e.Name+": "+ev)
|
||||||
|
a.events = a.events[max(0, len(a.events)-keepEvents):]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) code(e Entry) *editor.State {
|
||||||
|
st, ok := a.codes[e.Path()]
|
||||||
|
if !ok {
|
||||||
|
buf := editor.NewBuffer(e.Code())
|
||||||
|
buf.SetLanguage(editor.LanguageFor("demo.go"))
|
||||||
|
st = &editor.State{Buffer: buf, ReadOnly: true}
|
||||||
|
a.codes[e.Path()] = st
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func matches(q string) []Entry {
|
||||||
|
q = strings.ToLower(strings.TrimSpace(q))
|
||||||
|
var out []Entry
|
||||||
|
for _, e := range entries {
|
||||||
|
if strings.Contains(strings.ToLower(e.Name+" "+e.Category+" "+e.Summary), q) {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package gallery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/icon"
|
||||||
|
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/kit"
|
||||||
|
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/sound"
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
var modes = []string{"System", "Light", "Dark"}
|
||||||
|
|
||||||
|
func (a *App) topBar(c *ui.Context) {
|
||||||
|
t := c.Theme()
|
||||||
|
ui.Row(c).FillWidth().Gap(t.Space(3)).Padding(t.Space(2), t.Space(4)).AlignItems(ui.Center).
|
||||||
|
Border(1, t.Border).Children(func() {
|
||||||
|
ui.Text(c, "CrychicUI").Bold().FontSize(t.Rem(1.15))
|
||||||
|
ui.Spacer(c)
|
||||||
|
ui.SearchField(c, &a.query).Label("Search components").Width(240)
|
||||||
|
if ui.Segmented(c, &a.mode, modes...).Label("Appearance").Changed() {
|
||||||
|
slog.Debug("crychic: gallery appearance", "mode", modes[a.mode])
|
||||||
|
}
|
||||||
|
on, glyph := sound.Enabled(), "volume-x"
|
||||||
|
if on {
|
||||||
|
glyph = "volume-2"
|
||||||
|
}
|
||||||
|
ui.Icon(c, icon.Must(glyph)).Size(16, 16)
|
||||||
|
if ui.Switch(c, &on).Label("Sound").Changed() {
|
||||||
|
sound.SetEnabled(on)
|
||||||
|
slog.Debug("crychic: gallery sound", "on", on)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) sidebar(c *ui.Context) {
|
||||||
|
page := "/"
|
||||||
|
if parts := strings.Split(a.router.Path(), "/"); len(parts) > 2 && parts[1] == "c" {
|
||||||
|
page = "/c/" + parts[2]
|
||||||
|
}
|
||||||
|
if ui.Sidebar(c, &page, func() {
|
||||||
|
ui.SidebarItem(c, "/", icon.Must("house"), "Home")
|
||||||
|
ui.SidebarSection(c, "Components", nil, func() {
|
||||||
|
for _, k := range Categories {
|
||||||
|
ui.SidebarItem(c, "/c/"+k.Key, icon.Must(k.Icon), k.Name).Children(func() {
|
||||||
|
ui.Badge(c, fmt.Sprint(len(inCategory(k.Key))))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}).Width(220).Changed() {
|
||||||
|
a.Go(page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) statusBar(c *ui.Context) {
|
||||||
|
t := c.Theme()
|
||||||
|
ui.Row(c).FillWidth().Gap(t.Space(3)).Padding(t.Space(1.5), t.Space(4)).Border(1, t.Border).
|
||||||
|
Children(func() {
|
||||||
|
kit.Muted(c, "Log")
|
||||||
|
ui.Text(c, strings.Join(a.events, " · ")).MaxLines(1).Grow(1).Shrink(1)
|
||||||
|
kit.Muted(c, "Cues")
|
||||||
|
var cues []string
|
||||||
|
for _, q := range recentCues(8) {
|
||||||
|
cues = append(cues, q.String())
|
||||||
|
}
|
||||||
|
ui.Text(c, strings.Join(cues, " ")).MaxLines(1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func recentCues(n int) []sound.Cue {
|
||||||
|
r := sound.Recent()
|
||||||
|
return r[max(0, len(r)-n):]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) results(c *ui.Context) {
|
||||||
|
t := c.Theme()
|
||||||
|
found := matches(a.query)
|
||||||
|
ui.Scroll(c).Grow(1).Children(func() {
|
||||||
|
ui.Column(c).FillWidth().Gap(t.Space(1)).Padding(t.Space(6)).Children(func() {
|
||||||
|
ui.Text(c, fmt.Sprintf("%d results", len(found))).Bold()
|
||||||
|
for _, e := range found {
|
||||||
|
a.entryRow(c, e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) entryRow(c *ui.Context, e Entry) {
|
||||||
|
t := c.Theme()
|
||||||
|
row := ui.Row(c).Key(e.Path()).FillWidth().Gap(t.Space(3)).Padding(t.Space(2), t.Space(3)).Radius(t.Radius).
|
||||||
|
Label(e.Name)
|
||||||
|
bg := t.Background
|
||||||
|
if row.Hovered() {
|
||||||
|
bg = t.SurfaceHover
|
||||||
|
}
|
||||||
|
row.Background(row.AnimateColor("bg", bg, ui.Snappy)).Children(func() {
|
||||||
|
kit.Muted(c, fmt.Sprintf("%04d", e.ID)).Width(t.Rem(3))
|
||||||
|
ui.Text(c, e.Name).Bold().Width(t.Rem(12))
|
||||||
|
kit.Muted(c, e.Summary).MaxLines(1).Grow(1).Shrink(1)
|
||||||
|
})
|
||||||
|
if row.Clicked() {
|
||||||
|
a.Go(e.Path())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package gallery
|
||||||
|
|
||||||
|
var entries []Entry
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package gallery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/icon"
|
||||||
|
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/sound"
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func pingDemo(c *ui.Context, log func(string)) {
|
||||||
|
if ui.Button(c, "Ping").Clicked() {
|
||||||
|
log("pinged")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var pingEntry = Entry{ID: 1, Name: "PingBox", Category: "layout", Summary: "Answers a ping.", Demo: pingDemo}
|
||||||
|
|
||||||
|
// withEntry registers pingEntry for one test.
|
||||||
|
func withEntry(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
old, oldCode := entries, codes["pingDemo"]
|
||||||
|
entries, codes["pingDemo"] = []Entry{pingEntry}, "func pingDemo() {}\n"
|
||||||
|
t.Cleanup(func() { entries, codes["pingDemo"] = old, oldCode })
|
||||||
|
}
|
||||||
|
|
||||||
|
func open(path string) (*App, *ui.Tester) {
|
||||||
|
a := New(path)
|
||||||
|
return a, ui.NewTester(a.View, 1280, 820)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHome(t *testing.T) {
|
||||||
|
withEntry(t)
|
||||||
|
a, tt := open("/")
|
||||||
|
for _, s := range []string{"Components", "Categories", "Sound cues", "Layout", "Geo", "1 components"} {
|
||||||
|
if !tt.HasText(s) {
|
||||||
|
t.Errorf("home lacks %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s := a.Snapshot(); !s.Built || s.Page != "/" {
|
||||||
|
t.Errorf("snapshot %+v", s)
|
||||||
|
}
|
||||||
|
if err := tt.Click("Layout"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tt.Advance(time.Second)
|
||||||
|
if a.Snapshot().Page != "/c/layout" || !tt.HasText("PingBox") {
|
||||||
|
t.Errorf("page %q, texts %q", a.Snapshot().Page, tt.Texts())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComponentPage(t *testing.T) {
|
||||||
|
withEntry(t)
|
||||||
|
a, tt := open(pingEntry.Path())
|
||||||
|
for _, s := range []string{"PingBox", "Answers a ping.", "0001 · layout", "Code"} {
|
||||||
|
if !tt.HasText(s) {
|
||||||
|
t.Errorf("page lacks %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := a.codes[pingEntry.Path()].Buffer.String(); got != codes["pingDemo"] {
|
||||||
|
t.Errorf("code = %q", got)
|
||||||
|
}
|
||||||
|
if err := tt.Click("Ping"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !tt.HasText("PingBox: pinged") {
|
||||||
|
t.Errorf("log strip lacks the event: %q", tt.Texts())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotFound(t *testing.T) {
|
||||||
|
withEntry(t)
|
||||||
|
for _, p := range []string{"/c/nope", "/c/layout/nope", "/x"} {
|
||||||
|
if _, tt := open(p); !tt.HasText("Not found") {
|
||||||
|
t.Errorf("%s: no Not found", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch(t *testing.T) {
|
||||||
|
withEntry(t)
|
||||||
|
a, tt := open("/")
|
||||||
|
a.query = "ping"
|
||||||
|
tt.Frame()
|
||||||
|
if !tt.HasText("1 results") {
|
||||||
|
t.Fatalf("texts %q", tt.Texts())
|
||||||
|
}
|
||||||
|
if err := tt.Click("PingBox"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tt.Advance(time.Second)
|
||||||
|
if a.query != "" || a.Snapshot().Page != pingEntry.Path() {
|
||||||
|
t.Errorf("query %q, page %q", a.query, a.Snapshot().Page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppearance(t *testing.T) {
|
||||||
|
_, tt := open("/")
|
||||||
|
px := func() ui.Color { p := tt.Image().RGBAAt(1200, 400); return ui.Color{R: p.R, G: p.G, B: p.B, A: 255} }
|
||||||
|
if err := tt.Click("Dark"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tt.Advance(time.Second)
|
||||||
|
if got, want := px(), ui.DarkTheme().Background; got.R != want.R || got.G != want.G || got.B != want.B {
|
||||||
|
t.Errorf("dark background %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if err := tt.Click("Light"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tt.Advance(time.Second)
|
||||||
|
if got, want := px(), ui.LightTheme().Background; got.R != want.R || got.G != want.G || got.B != want.B {
|
||||||
|
t.Errorf("light background %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoundSwitch(t *testing.T) {
|
||||||
|
t.Cleanup(func() { sound.SetEnabled(false) })
|
||||||
|
_, tt := open("/")
|
||||||
|
if err := tt.Click("Sound"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !sound.Enabled() {
|
||||||
|
t.Fatal("switch did not enable sound")
|
||||||
|
}
|
||||||
|
if err := tt.Click("Sound"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if sound.Enabled() {
|
||||||
|
t.Error("switch did not mute sound")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCueStrip(t *testing.T) {
|
||||||
|
sound.Play(sound.Success)
|
||||||
|
if _, tt := open("/"); !strings.Contains(strings.Join(tt.Texts(), " "), "success") {
|
||||||
|
t.Errorf("cue strip lacks success: %q", tt.Texts())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSources(t *testing.T) {
|
||||||
|
raw := "func demoR() string {\n\treturn `a\n}\nb`\n}"
|
||||||
|
got := sources(fstest.MapFS{"demos_x.go": {Data: []byte(
|
||||||
|
"package gallery\r\n\r\n// A doc.\r\nfunc demoA(c int) {\r\n\tx()\r\n}\r\n\r\nfunc demoB() { x() }\r\n\r\n" +
|
||||||
|
"func (e Entry) M() {\r\n}\r\n\r\n" + strings.ReplaceAll(raw, "\n", "\r\n") + "\r\n")}})
|
||||||
|
want := map[string]string{"demoA": "func demoA(c int) {\n\tx()\n}", "demoB": "func demoB() { x() }", "demoR": raw}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Errorf("sources = %q", got)
|
||||||
|
}
|
||||||
|
for k, v := range want {
|
||||||
|
if got[k] != v {
|
||||||
|
t.Errorf("%s = %q, want %q", k, got[k], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if recover() == nil {
|
||||||
|
t.Error("broken file did not panic")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
sources(fstest.MapFS{"demos_y.go": {Data: []byte("package gallery\nfunc demoB() {\n")}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlug(t *testing.T) {
|
||||||
|
for in, want := range map[string]string{"Stack": "stack", "KPIGrid": "kpi-grid", "Base64Tool": "base64-tool"} {
|
||||||
|
if got := (Entry{Name: in}).Slug(); got != want {
|
||||||
|
t.Errorf("slug(%s) = %s, want %s", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCategories(t *testing.T) {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, k := range Categories {
|
||||||
|
icon.Must(k.Icon)
|
||||||
|
if seen[k.Key] {
|
||||||
|
t.Errorf("category %s twice", k.Key)
|
||||||
|
}
|
||||||
|
seen[k.Key] = true
|
||||||
|
}
|
||||||
|
if len(Categories) != 38 {
|
||||||
|
t.Errorf("categories = %d, want 38", len(Categories))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package gallery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/icon"
|
||||||
|
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/kit"
|
||||||
|
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/sound"
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
"github.com/egoist/mygo/ui/editor"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) pages(c *ui.Context) {
|
||||||
|
a.router.View(c, func(r *ui.Route) {
|
||||||
|
switch {
|
||||||
|
case r.Match("/"):
|
||||||
|
r.Title("CrychicUI")
|
||||||
|
a.home(c)
|
||||||
|
case r.Match("/c/{cat}"):
|
||||||
|
k, ok := category(r.Param("cat"))
|
||||||
|
if !ok {
|
||||||
|
notFound(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Title(k.Name)
|
||||||
|
a.categoryPage(c, k)
|
||||||
|
case r.Match("/c/{cat}/{slug}"):
|
||||||
|
for _, e := range inCategory(r.Param("cat")) {
|
||||||
|
if e.Slug() == r.Param("slug") {
|
||||||
|
r.Title(e.Name)
|
||||||
|
a.entryPage(c, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notFound(c)
|
||||||
|
default:
|
||||||
|
notFound(c)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func page(c *ui.Context, fn func()) {
|
||||||
|
t := c.Theme()
|
||||||
|
ui.Scroll(c).Grow(1).Children(func() {
|
||||||
|
ui.Column(c).FillWidth().MaxWidth(t.Rem(64)).Gap(t.Space(4)).Padding(t.Space(8)).Children(fn)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func notFound(c *ui.Context) {
|
||||||
|
page(c, func() {
|
||||||
|
ui.Text(c, "Not found").Bold().FontSize(c.Theme().Rem(1.5))
|
||||||
|
kit.Muted(c, "No component lives at this address.")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) home(c *ui.Context) {
|
||||||
|
t := c.Theme()
|
||||||
|
page(c, func() {
|
||||||
|
ui.Text(c, "CrychicUI").Bold().FontSize(t.Rem(2))
|
||||||
|
kit.Muted(c, "Components for MyGo native UI, in MyGo's own look, with motion and sound.")
|
||||||
|
ui.Row(c).Gap(t.Space(4)).Children(func() {
|
||||||
|
stat(c, "Components", len(entries))
|
||||||
|
stat(c, "Categories", len(Categories))
|
||||||
|
stat(c, "Sound cues", len(sound.Cues()))
|
||||||
|
})
|
||||||
|
ui.Row(c).FillWidth().Wrap().Gap(t.Space(3)).Children(func() {
|
||||||
|
for _, k := range Categories {
|
||||||
|
a.categoryCard(c, k)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func stat(c *ui.Context, label string, n int) {
|
||||||
|
t := c.Theme()
|
||||||
|
kit.Card(c).Padding(t.Space(3), t.Space(4)).Gap(t.Space(1)).Children(func() {
|
||||||
|
kit.Muted(c, label)
|
||||||
|
ui.Text(c, fmt.Sprint(n)).Bold().FontSize(t.Rem(1.5))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) categoryCard(c *ui.Context, k Category) {
|
||||||
|
t := c.Theme()
|
||||||
|
card := kit.Press(kit.Lift(kit.Card(c).Key(k.Key).Width(t.Rem(13)).Padding(t.Space(3)).Gap(t.Space(1)).
|
||||||
|
Label(k.Name)))
|
||||||
|
card.Children(func() {
|
||||||
|
ui.Icon(c, icon.Must(k.Icon)).Size(16, 16)
|
||||||
|
ui.Text(c, k.Name).Bold()
|
||||||
|
kit.Muted(c, fmt.Sprintf("%d components", len(inCategory(k.Key))))
|
||||||
|
})
|
||||||
|
if card.Clicked() {
|
||||||
|
a.Go("/c/" + k.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) categoryPage(c *ui.Context, k Category) {
|
||||||
|
page(c, func() {
|
||||||
|
ui.Text(c, k.Name).Bold().FontSize(c.Theme().Rem(1.5))
|
||||||
|
list := inCategory(k.Key)
|
||||||
|
kit.Muted(c, fmt.Sprintf("%d components", len(list)))
|
||||||
|
for _, e := range list {
|
||||||
|
a.entryRow(c, e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) entryPage(c *ui.Context, e Entry) {
|
||||||
|
t := c.Theme()
|
||||||
|
page(c, func() {
|
||||||
|
kit.Muted(c, fmt.Sprintf("%04d · %s", e.ID, e.Category))
|
||||||
|
ui.Text(c, e.Name).Bold().FontSize(t.Rem(1.5))
|
||||||
|
kit.Muted(c, e.Summary)
|
||||||
|
kit.Card(c).Key(e.ID).FillWidth().Padding(t.Space(6)).Children(func() {
|
||||||
|
e.Demo(c, a.logger(e))
|
||||||
|
})
|
||||||
|
ui.Text(c, "Code").Bold()
|
||||||
|
editor.View(c, a.code(e)).FillWidth().Height(t.Rem(22))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// Package gallery shows every component live.
|
||||||
|
package gallery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"io/fs"
|
||||||
|
"reflect"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entry is one component's page.
|
||||||
|
type Entry struct {
|
||||||
|
ID int
|
||||||
|
Name string
|
||||||
|
Category string
|
||||||
|
Summary string
|
||||||
|
Demo func(c *ui.Context, log func(string))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category is a component package shown in the sidebar.
|
||||||
|
type Category struct {
|
||||||
|
Key, Name, Icon string
|
||||||
|
}
|
||||||
|
|
||||||
|
var Categories = []Category{
|
||||||
|
{"layout", "Layout", "layout-grid"},
|
||||||
|
{"typography", "Typography", "type"},
|
||||||
|
{"button", "Button", "mouse-pointer-click"},
|
||||||
|
{"display", "Display", "id-card"},
|
||||||
|
{"input", "Input", "text-cursor-input"},
|
||||||
|
{"choice", "Choice", "list-checks"},
|
||||||
|
{"feedback", "Feedback", "message-square-warning"},
|
||||||
|
{"motion", "Motion", "move"},
|
||||||
|
{"fx", "Effects", "sparkles"},
|
||||||
|
{"form", "Form", "clipboard-list"},
|
||||||
|
{"datetime", "Date & Time", "calendar-clock"},
|
||||||
|
{"color", "Color", "palette"},
|
||||||
|
{"navigation", "Navigation", "compass"},
|
||||||
|
{"overlay", "Overlay", "layers"},
|
||||||
|
{"data", "Data", "table"},
|
||||||
|
{"chart", "Chart", "chart-column"},
|
||||||
|
{"media", "Media", "image"},
|
||||||
|
{"chat", "Chat", "message-circle"},
|
||||||
|
{"agent", "Agent", "bot"},
|
||||||
|
{"code", "Code", "code"},
|
||||||
|
{"files", "Files", "folder"},
|
||||||
|
{"shop", "Shop", "shopping-cart"},
|
||||||
|
{"finance", "Finance", "wallet"},
|
||||||
|
{"dashboard", "Dashboard", "layout-dashboard"},
|
||||||
|
{"schedule", "Schedule", "calendar-days"},
|
||||||
|
{"project", "Project", "kanban"},
|
||||||
|
{"mail", "Mail", "mail"},
|
||||||
|
{"social", "Social", "users"},
|
||||||
|
{"account", "Account", "user-round"},
|
||||||
|
{"devtools", "Devtools", "terminal"},
|
||||||
|
{"game", "Game", "gamepad-2"},
|
||||||
|
{"learn", "Learn", "graduation-cap"},
|
||||||
|
{"health", "Health", "heart-pulse"},
|
||||||
|
{"home", "Home", "house"},
|
||||||
|
{"marketing", "Marketing", "megaphone"},
|
||||||
|
{"content", "Content", "book-open"},
|
||||||
|
{"desktop", "Desktop", "monitor"},
|
||||||
|
{"geo", "Geo", "map-pin"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entries lists every registered component in ID order.
|
||||||
|
func Entries() []Entry { return entries }
|
||||||
|
|
||||||
|
func category(key string) (Category, bool) {
|
||||||
|
for _, k := range Categories {
|
||||||
|
if k.Key == key {
|
||||||
|
return k, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Category{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func inCategory(key string) []Entry {
|
||||||
|
var out []Entry
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Category == key {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slug turns KPIGrid into kpi-grid.
|
||||||
|
func (e Entry) Slug() string {
|
||||||
|
r := []rune(e.Name)
|
||||||
|
var b strings.Builder
|
||||||
|
for i, c := range r {
|
||||||
|
if i > 0 && unicode.IsUpper(c) {
|
||||||
|
prev := r[i-1]
|
||||||
|
endsAcronym := unicode.IsUpper(prev) && i+1 < len(r) && unicode.IsLower(r[i+1])
|
||||||
|
if unicode.IsLower(prev) || unicode.IsDigit(prev) || endsAcronym {
|
||||||
|
b.WriteByte('-')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteRune(unicode.ToLower(c))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Entry) Path() string { return "/c/" + e.Category + "/" + e.Slug() }
|
||||||
|
|
||||||
|
//go:embed demos*.go
|
||||||
|
var demoFiles embed.FS
|
||||||
|
|
||||||
|
var codes = sources(demoFiles)
|
||||||
|
|
||||||
|
// Code is the source of the entry's demo function.
|
||||||
|
func (e Entry) Code() string {
|
||||||
|
name := funcName(e.Demo)
|
||||||
|
src, ok := codes[name]
|
||||||
|
if !ok {
|
||||||
|
panic("gallery: no source for " + name)
|
||||||
|
}
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
|
||||||
|
func funcName(f any) string {
|
||||||
|
n := runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name()
|
||||||
|
return n[strings.LastIndex(n, ".")+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// sources indexes every top-level function by name.
|
||||||
|
func sources(fsys fs.FS) map[string]string {
|
||||||
|
names, err := fs.Glob(fsys, "*.go")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
out := map[string]string{}
|
||||||
|
for _, f := range names {
|
||||||
|
b, err := fs.ReadFile(fsys, f)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
// A Windows checkout may bring CRLF.
|
||||||
|
src := strings.ReplaceAll(string(b), "\r\n", "\n")
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
file, err := parser.ParseFile(fset, f, src, 0)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
for _, d := range file.Decls {
|
||||||
|
if fn, ok := d.(*ast.FuncDecl); ok && fn.Recv == nil {
|
||||||
|
out[fn.Name.Name] = src[fset.Position(fn.Pos()).Offset:fset.Position(fn.End()).Offset]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
Reference in new issue
Block a user