Compare commits
3
Commits
ff50f9b35a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4efaf2680f
|
||
|
|
b34562333b
|
||
|
|
127f53d5f5
|
No files matched your search
@@ -7,6 +7,7 @@ docs/prd/
|
||||
docs/acceptance.md
|
||||
/gallery.exe
|
||||
/web/dist/
|
||||
/web/.wrangler/
|
||||
/web/node_modules/
|
||||
/web/test-results/
|
||||
/web/playwright-report/
|
||||
@@ -27,7 +27,7 @@ kit/ helpers shared by three or more components
|
||||
<category>/ one component per file, package = folder name
|
||||
gallery/ registry, demos (demos_<category>*.go), app view, tests
|
||||
cmd/gallery/ one main for desktop and browser; hook_js.go for tests
|
||||
web/ shell, build.sh, deploy.sh, wrangler.jsonc, _headers, Playwright tests
|
||||
web/ shell, build.mjs, deploy.mjs, wrangler.jsonc, _headers, Playwright tests
|
||||
internal/archtest/ import and file rules
|
||||
```
|
||||
|
||||
@@ -51,8 +51,8 @@ health, home, marketing, content, desktop, geo · 6 gallery · 7 cmd/gallery
|
||||
## Components
|
||||
|
||||
- `func Name(c *ui.Context, required...) ui.Element`; optional knobs in a final `NameOptions` (never variadic). Zero
|
||||
options give the default look. Containers take content as a final `func()`. Several actions: a `NameResult` of
|
||||
named `ui.Element` fields.
|
||||
options give the default look. Containers take content as a final `func()`, after any options. Several actions:
|
||||
named `ui.Element` results, root first; `mygo vet` forbids elements in structs.
|
||||
- Value-bound components take `*T` and call `SetChanged` on the returned element when input changes it.
|
||||
- Stateful components get their key from the caller (`c.Key`); private state lives in `ui.Local`.
|
||||
- Every component moves: pose, color animation, transition, content transition, keyframes, loop or animated drawing.
|
||||
@@ -63,6 +63,9 @@ health, home, marketing, content, desktop, geo · 6 gallery · 7 cmd/gallery
|
||||
- Fail fast: panic on programmer errors. Never swallow an error.
|
||||
- Composites log state changes with `kit.Log`. Never log typed text or secrets: log counts.
|
||||
- No element or parts stored across builds; no goroutine touches elements. `go tool mygo vet ./...` passes.
|
||||
- A component deciding from laid-out sizes (`LayoutBounds`, a `ScrollState`'s `Max`) calls `kit.Settle` with the
|
||||
values it used; paint rebuilds when layout disagrees. Arrivals on first build use `kit.Arrive`; `Enter` only
|
||||
runs inside a shown parent.
|
||||
- Files under 500 lines (lock files exempt). Comments one line, ten words at most, only when needed. No dead
|
||||
code, no versioned APIs.
|
||||
- A category file holds one exported component, named in snake case (`KPIGrid` in `kpi_grid.go`), or private
|
||||
@@ -86,7 +89,8 @@ code panel shows the demo function's own source, cut from the embedded `demos*.g
|
||||
## Deploy
|
||||
|
||||
- `npm run deploy` in `web/` (clean tree only): live page beside new files first, then the new page tested on its
|
||||
preview URL, promoted, and tested live at https://crychicui.zhangyanghaha0407.workers.dev.
|
||||
preview URL, promoted, and tested live at https://crychicui.zhangyanghaha0407.workers.dev. The Worker's first
|
||||
deploy is `npm run deploy -- --first`.
|
||||
- Rollback: `npx wrangler rollback <version-id>` in `web/`.
|
||||
|
||||
## Review
|
||||
|
||||
@@ -50,6 +50,7 @@ func (a *App) View(c *ui.Context) {
|
||||
}
|
||||
ui.Column(c).Fill().Children(func() {
|
||||
a.topBar(c)
|
||||
ui.Divider(c)
|
||||
ui.Row(c).Grow(1).AlignItems(ui.Stretch).Children(func() {
|
||||
a.sidebar(c)
|
||||
ui.Column(c).Grow(1).Children(func() {
|
||||
@@ -60,6 +61,7 @@ func (a *App) View(c *ui.Context) {
|
||||
a.pages(c)
|
||||
})
|
||||
})
|
||||
ui.Divider(c)
|
||||
a.statusBar(c)
|
||||
})
|
||||
a.mu.Lock()
|
||||
|
||||
+11
-13
@@ -15,8 +15,7 @@ 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.Row(c).FillWidth().Gap(t.Space(3)).Padding(t.Space(2), t.Space(4)).AlignItems(ui.Center).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)
|
||||
@@ -56,17 +55,16 @@ func (a *App) sidebar(c *ui.Context) {
|
||||
|
||||
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)
|
||||
})
|
||||
ui.Row(c).FillWidth().Gap(t.Space(3)).Padding(t.Space(1.5), t.Space(4)).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 {
|
||||
|
||||
+3
-1
@@ -1,3 +1,5 @@
|
||||
package gallery
|
||||
|
||||
var entries []Entry
|
||||
import "slices"
|
||||
|
||||
var entries = slices.Concat(layoutEntries)
|
||||
@@ -0,0 +1,319 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/kit"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/layout"
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
var layoutEntries = []Entry{
|
||||
{1, "Stack", "layout", "Column gapped in theme units; its size animates.", demoStack},
|
||||
{2, "Inline", "layout", "Wrapping row; its size animates.", demoInline},
|
||||
{3, "Layers", "layout", "Layers on one box, each fading in.", demoLayers},
|
||||
{4, "Centered", "layout", "Centers content, which scales in.", demoCentered},
|
||||
{5, "Container", "layout", "Centered page column with a max width.", demoContainer},
|
||||
{6, "Section", "layout", "Muted heading, growing divider, body.", demoSection},
|
||||
{7, "Panel", "layout", "Bordered surface; its size animates.", demoPanel},
|
||||
{8, "Card", "layout", "Card surface that lifts on hover.", demoCard},
|
||||
{9, "Inset", "layout", "Recessed surface following the theme.", demoInset},
|
||||
{10, "Well", "layout", "Sunken box; content fades in.", demoWell},
|
||||
{11, "SplitPane", "layout", "Two resizable panes side by side.", demoSplitPane},
|
||||
{12, "SplitStack", "layout", "Two resizable panes stacked.", demoSplitStack},
|
||||
{13, "SidebarLayout", "layout", "Side column that springs open and closed.", demoSidebarLayout},
|
||||
{14, "HolyGrail", "layout", "Header, nav, main, aside and footer.", demoHolyGrail},
|
||||
{15, "AppFrame", "layout", "Top bar, content and status bar.", demoAppFrame},
|
||||
{16, "Masonry", "layout", "Items in the shortest of n columns.", demoMasonry},
|
||||
{17, "AutoGrid", "layout", "As many columns as a minimum width allows.", demoAutoGrid},
|
||||
{18, "Bento", "layout", "Grid of spanning tiles that lift.", demoBento},
|
||||
{19, "Cluster", "layout", "Wrapping items aligned start, center or end.", demoCluster},
|
||||
{20, "Columns", "layout", "Equal columns whose widths animate.", demoColumns},
|
||||
{21, "AspectBox", "layout", "Box held at a ratio.", demoAspectBox},
|
||||
{22, "Frame", "layout", "Clips content to the theme radius.", demoFrame},
|
||||
{23, "ScrollArea", "layout", "Scroll view with fading edge shadows.", demoScrollArea},
|
||||
{24, "Scroller", "layout", "Sideways scroll with paging arrows.", demoScroller},
|
||||
{25, "StickyHeader", "layout", "Header over a scroll; shadow once scrolled.", demoStickyHeader},
|
||||
}
|
||||
|
||||
// counter is a demo's count with add and remove buttons.
|
||||
func counter(c *ui.Context, root ui.Element, name string, start int, log func(string)) *int {
|
||||
n := ui.Local(root, "n", func() int { return start })
|
||||
ui.Row(c).Gap(8).Children(func() {
|
||||
if ui.Button(c, "Add").Clicked() {
|
||||
*n++
|
||||
log(fmt.Sprintf("%s: %d", name, *n))
|
||||
}
|
||||
if ui.Button(c, "Remove").Disabled(*n == 0).Clicked() {
|
||||
*n--
|
||||
log(fmt.Sprintf("%s: %d", name, *n))
|
||||
}
|
||||
})
|
||||
return n
|
||||
}
|
||||
|
||||
func tile(c *ui.Context, label string, h float32) {
|
||||
t := c.Theme()
|
||||
ui.Column(c).Height(h).Padding(t.Space(2)).Radius(t.Radius).Background(t.Surface).Children(func() {
|
||||
ui.Text(c, label)
|
||||
})
|
||||
}
|
||||
|
||||
func demoStack(c *ui.Context, log func(string)) {
|
||||
root := ui.Column(c).Gap(12)
|
||||
root.Children(func() {
|
||||
n := counter(c, root, "rows", 3, log)
|
||||
layout.Stack(c, 2, func() {
|
||||
for i := range *n {
|
||||
ui.Text(c, fmt.Sprintf("Row %d", i+1))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func demoInline(c *ui.Context, log func(string)) {
|
||||
root := ui.Column(c).Gap(12)
|
||||
root.Children(func() {
|
||||
n := counter(c, root, "tags", 6, log)
|
||||
layout.Inline(c, 2, func() {
|
||||
for i := range *n {
|
||||
ui.Badge(c, fmt.Sprintf("Tag %d", i+1))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func demoLayers(c *ui.Context, log func(string)) {
|
||||
t := c.Theme()
|
||||
layout.Layers(c, []func(){
|
||||
func() { ui.Box(c).Size(240, 120).Radius(t.Radius).Background(t.Surface) },
|
||||
func() { ui.Text(c, "Caption over the backdrop").Padding(16) },
|
||||
})
|
||||
}
|
||||
|
||||
func demoCentered(c *ui.Context, log func(string)) {
|
||||
ui.Box(c).Height(160).Children(func() {
|
||||
layout.Centered(c, func() { ui.Text(c, "In the middle").Bold() })
|
||||
})
|
||||
}
|
||||
|
||||
func demoContainer(c *ui.Context, log func(string)) {
|
||||
root := ui.Column(c)
|
||||
wide := ui.Local(root, "wide", func() bool { return false })
|
||||
root.Children(func() {
|
||||
if ui.Toggle(c, wide, "Wide").Changed() {
|
||||
log(fmt.Sprintf("wide: %v", *wide))
|
||||
}
|
||||
w := c.Theme().Rem(20)
|
||||
if *wide {
|
||||
w = c.Theme().Rem(40)
|
||||
}
|
||||
layout.Container(c, layout.ContainerOptions{MaxWidth: w}, func() {
|
||||
ui.Text(c, "Content keeps to the container's width, centered on the page.")
|
||||
}).Background(c.Theme().Surface)
|
||||
})
|
||||
}
|
||||
|
||||
func demoSection(c *ui.Context, log func(string)) {
|
||||
layout.Section(c, "Notifications", func() {
|
||||
ui.Text(c, "Choose what reaches you.")
|
||||
kit.Muted(c, "The divider grows in as the section appears.")
|
||||
})
|
||||
}
|
||||
|
||||
func demoPanel(c *ui.Context, log func(string)) {
|
||||
root := ui.Column(c).Gap(12)
|
||||
root.Children(func() {
|
||||
n := counter(c, root, "lines", 1, log)
|
||||
layout.Panel(c, func() {
|
||||
for i := range *n {
|
||||
ui.Text(c, fmt.Sprintf("Line %d", i+1))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func demoCard(c *ui.Context, log func(string)) {
|
||||
layout.Card(c, func() {
|
||||
ui.Text(c, "Hover me").Bold()
|
||||
kit.Muted(c, "The card lifts 2 DIP.")
|
||||
}).Width(220)
|
||||
}
|
||||
|
||||
func demoInset(c *ui.Context, log func(string)) {
|
||||
layout.Inset(c, func() {
|
||||
ui.Text(c, "Grouped settings")
|
||||
kit.Muted(c, "Switch the theme to watch the surface ease over.")
|
||||
})
|
||||
}
|
||||
|
||||
func demoWell(c *ui.Context, log func(string)) {
|
||||
layout.Well(c, func() { ui.Text(c, "Sunken content") })
|
||||
}
|
||||
|
||||
func demoSplitPane(c *ui.Context, log func(string)) {
|
||||
root := ui.Box(c).Height(200)
|
||||
size := ui.Local(root, "size", func() float32 { return 180 })
|
||||
root.Children(func() {
|
||||
if layout.SplitPane(c, size, func() { tile(c, "Files", 200) }, func() { tile(c, "Editor", 200) }).Changed() {
|
||||
log(fmt.Sprintf("width: %.0f", *size))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoSplitStack(c *ui.Context, log func(string)) {
|
||||
root := ui.Box(c).Height(240)
|
||||
size := ui.Local(root, "size", func() float32 { return 120 })
|
||||
root.Children(func() {
|
||||
if layout.SplitStack(c, size, func() { tile(c, "Editor", 240) }, func() { tile(c, "Console", 240) }).Changed() {
|
||||
log(fmt.Sprintf("height: %.0f", *size))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoSidebarLayout(c *ui.Context, log func(string)) {
|
||||
root := ui.Box(c).Height(220)
|
||||
open := ui.Local(root, "open", func() bool { return true })
|
||||
root.Children(func() {
|
||||
if layout.SidebarLayout(c, open, func() {
|
||||
ui.Text(c, "Inbox")
|
||||
ui.Text(c, "Drafts")
|
||||
}, func() { ui.Text(c, "Message list").Padding(12) }).Changed() {
|
||||
log(fmt.Sprintf("open: %v", *open))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoHolyGrail(c *ui.Context, log func(string)) {
|
||||
root := ui.Column(c).Gap(12)
|
||||
aside := ui.Local(root, "aside", func() bool { return true })
|
||||
root.Children(func() {
|
||||
if ui.Toggle(c, aside, "Aside").Changed() {
|
||||
log(fmt.Sprintf("aside: %v", *aside))
|
||||
}
|
||||
s := layout.HolyGrailSlots{
|
||||
Header: func() { ui.Text(c, "Header") }, Nav: func() { ui.Text(c, "Nav") },
|
||||
Main: func() { ui.Text(c, "Main") }, Footer: func() { ui.Text(c, "Footer") },
|
||||
}
|
||||
if *aside {
|
||||
s.Aside = func() { ui.Text(c, "Aside") }
|
||||
}
|
||||
ui.Box(c).Height(220).Children(func() { layout.HolyGrail(c, s) })
|
||||
})
|
||||
}
|
||||
|
||||
func demoAppFrame(c *ui.Context, log func(string)) {
|
||||
ui.Box(c).Height(200).Border(1, c.Theme().Border).Children(func() {
|
||||
layout.AppFrame(c, func() { ui.Text(c, "Project").Bold() },
|
||||
func() { ui.Text(c, "Content").Padding(12) },
|
||||
func() { kit.Muted(c, "Ready") })
|
||||
})
|
||||
}
|
||||
|
||||
func demoMasonry(c *ui.Context, log func(string)) {
|
||||
root := ui.Column(c).Gap(12)
|
||||
root.Children(func() {
|
||||
n := counter(c, root, "items", 7, log)
|
||||
var items []func()
|
||||
for i := range *n {
|
||||
items = append(items, func() { tile(c, fmt.Sprintf("Item %d", i+1), float32(40+i%3*30)) })
|
||||
}
|
||||
layout.Masonry(c, 3, items)
|
||||
})
|
||||
}
|
||||
|
||||
func demoAutoGrid(c *ui.Context, log func(string)) {
|
||||
root := ui.Column(c).Gap(12)
|
||||
root.Children(func() {
|
||||
n := counter(c, root, "cells", 8, log)
|
||||
var items []func()
|
||||
for i := range *n {
|
||||
items = append(items, func() { tile(c, fmt.Sprintf("Cell %d", i+1), 48) })
|
||||
}
|
||||
layout.AutoGrid(c, 120, items)
|
||||
})
|
||||
}
|
||||
|
||||
func demoBento(c *ui.Context, log func(string)) {
|
||||
cell := func(s string) func() { return func() { ui.Text(c, s).Bold() } }
|
||||
layout.Bento(c, 3, []layout.BentoTile{
|
||||
{Cols: 2, Content: cell("Analytics")}, {Rows: 2, Content: cell("Team")},
|
||||
{Content: cell("Inbox")}, {Content: cell("Billing")},
|
||||
})
|
||||
}
|
||||
|
||||
func demoCluster(c *ui.Context, log func(string)) {
|
||||
root := ui.Column(c).Gap(12)
|
||||
align := ui.Local(root, "align", func() int { return 0 })
|
||||
root.Children(func() {
|
||||
if ui.Segmented(c, align, "Start", "Center", "End").Changed() {
|
||||
log("align: " + []string{"start", "center", "end"}[*align])
|
||||
}
|
||||
var items []func()
|
||||
for _, s := range []string{"Design", "Code", "Review", "Ship", "Measure"} {
|
||||
items = append(items, func() { ui.Badge(c, s) })
|
||||
}
|
||||
layout.Cluster(c, []ui.Align{ui.Start, ui.Center, ui.End}[*align], items)
|
||||
})
|
||||
}
|
||||
|
||||
func demoColumns(c *ui.Context, log func(string)) {
|
||||
root := ui.Column(c).Gap(12)
|
||||
root.Children(func() {
|
||||
n := counter(c, root, "columns", 3, log)
|
||||
var items []func()
|
||||
for i := range max(*n, 1) {
|
||||
items = append(items, func() { tile(c, fmt.Sprintf("Column %d", i+1), 60) })
|
||||
}
|
||||
layout.Columns(c, items)
|
||||
})
|
||||
}
|
||||
|
||||
func demoAspectBox(c *ui.Context, log func(string)) {
|
||||
root := ui.Column(c).Gap(12).Width(320)
|
||||
wide := ui.Local(root, "wide", func() bool { return true })
|
||||
root.Children(func() {
|
||||
if ui.Toggle(c, wide, "16:9").Changed() {
|
||||
log(fmt.Sprintf("16:9: %v", *wide))
|
||||
}
|
||||
ratio := float32(1)
|
||||
if *wide {
|
||||
ratio = 16.0 / 9
|
||||
}
|
||||
layout.AspectBox(c, ratio, func() { tile(c, "Video", 400) })
|
||||
})
|
||||
}
|
||||
|
||||
func demoFrame(c *ui.Context, log func(string)) {
|
||||
layout.Frame(c, func() { ui.Box(c).Size(240, 120).Background(c.Theme().Accent) })
|
||||
}
|
||||
|
||||
func demoScrollArea(c *ui.Context, log func(string)) {
|
||||
layout.ScrollArea(c, func() {
|
||||
for i := range 30 {
|
||||
ui.Text(c, fmt.Sprintf("Entry %d", i+1)).Padding(4, 8)
|
||||
}
|
||||
}).Height(180)
|
||||
}
|
||||
|
||||
func demoScroller(c *ui.Context, log func(string)) {
|
||||
_, back, forward := layout.Scroller(c, func() {
|
||||
for i := range 14 {
|
||||
ui.Column(c).Size(110, 70).Children(func() { tile(c, fmt.Sprintf("Card %d", i+1), 70) })
|
||||
}
|
||||
})
|
||||
if back.Clicked() {
|
||||
log("back")
|
||||
}
|
||||
if forward.Clicked() {
|
||||
log("forward")
|
||||
}
|
||||
}
|
||||
|
||||
func demoStickyHeader(c *ui.Context, log func(string)) {
|
||||
layout.StickyHeader(c, func() { ui.Text(c, "Contacts").Bold() }, func() {
|
||||
for i := range 30 {
|
||||
ui.Text(c, fmt.Sprintf("Person %d", i+1)).Padding(4, 12)
|
||||
}
|
||||
}).Height(200)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestScrollerDemoLogsArrows(t *testing.T) {
|
||||
e := Entries()[23]
|
||||
if e.Name != "Scroller" {
|
||||
t.Fatalf("entry 24 is %s", e.Name)
|
||||
}
|
||||
_, tt := open(e.Path())
|
||||
tt.Advance(time.Second)
|
||||
if err := tt.Click("Scroll forward"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tt.HasText("Scroller: forward") {
|
||||
t.Errorf("log strip lacks the event: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
@@ -183,3 +183,25 @@ func TestCategories(t *testing.T) {
|
||||
t.Errorf("categories = %d, want 38", len(Categories))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryEntry(t *testing.T) {
|
||||
for i, e := range Entries() {
|
||||
if i > 0 && e.ID <= Entries()[i-1].ID {
|
||||
t.Errorf("%s: ID %d out of order", e.Name, e.ID)
|
||||
}
|
||||
if _, ok := category(e.Category); !ok {
|
||||
t.Errorf("%s: unknown category %q", e.Name, e.Category)
|
||||
}
|
||||
if !strings.Contains(e.Code(), "func "+funcName(e.Demo)+"(") {
|
||||
t.Errorf("%s: code panel lacks its demo", e.Name)
|
||||
}
|
||||
for _, dark := range []bool{false, true} {
|
||||
a, tt := open(e.Path())
|
||||
tt.SetDark(dark)
|
||||
tt.Advance(3 * time.Second)
|
||||
if a.Snapshot().Page != e.Path() || !tt.HasText(e.Name) || tt.FramePending() {
|
||||
t.Errorf("%s dark=%v: page %q, pending %v", e.Name, dark, a.Snapshot().Page, tt.FramePending())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,8 +116,13 @@ func TestRulesCatch(t *testing.T) {
|
||||
{"directive", "", "package x\n// Files.\n//go:generate echo\n", comments, 0},
|
||||
{"good", "button/icon_button.go", head + "func IconButton(c *ui.Context, o IconButtonOptions) ui.Element\n" +
|
||||
"type IconButtonOptions struct{}\n", components, 0},
|
||||
{"result", "button/split.go", head + "func Split(c *ui.Context) SplitResult\ntype SplitResult struct{}\n",
|
||||
components, 0},
|
||||
{"parts", "button/split.go", head + "func Split(c *ui.Context) (root, menu ui.Element)\n", components, 0},
|
||||
{"struct result", "button/split.go", head + "func Split(c *ui.Context) SplitResult\ntype SplitResult struct{}\n",
|
||||
components, 1},
|
||||
{"unnamed parts", "button/split.go", head + "func Split(c *ui.Context) (ui.Element, ui.Element)\n",
|
||||
components, 1},
|
||||
{"root not first", "button/split.go", head + "func Split(c *ui.Context) (menu, root ui.Element)\n",
|
||||
components, 1},
|
||||
{"two", "button/a.go", head + "func A(c *ui.Context) ui.Element\nfunc B(c *ui.Context) ui.Element\n",
|
||||
components, 1},
|
||||
{"file name", "button/b.go", head + "func KPIGrid(c *ui.Context) ui.Element\n", components, 1},
|
||||
@@ -127,6 +132,10 @@ func TestRulesCatch(t *testing.T) {
|
||||
components, 1},
|
||||
{"returns", "button/a.go", head + "func A(c *ui.Context) int\n", components, 1},
|
||||
{"ctx name", "button/a.go", head + "func A(ctx *ui.Context) ui.Element\n", components, 1},
|
||||
{"content after options", "button/a.go", head + "func A(c *ui.Context, o AOptions, content func()) ui.Element\n",
|
||||
components, 0},
|
||||
{"two after options", "button/a.go", head + "func A(c *ui.Context, o AOptions, a, b func()) ui.Element\n",
|
||||
components, 1},
|
||||
{"grouped options", "button/a.go", head + "func A(c *ui.Context, x, y AOptions) ui.Element\n",
|
||||
components, 1},
|
||||
}
|
||||
|
||||
@@ -107,21 +107,29 @@ func signature(fn *ast.FuncDecl) string {
|
||||
ps[0].Names[0].Name != "c" {
|
||||
return "must take c *ui.Context first"
|
||||
}
|
||||
last := ps[len(ps)-1]
|
||||
contentLast := typeString(last.Type) == "func()" && len(last.Names) == 1
|
||||
for i, p := range ps {
|
||||
ts := typeString(p.Type)
|
||||
if _, ok := p.Type.(*ast.Ellipsis); ok {
|
||||
return "must not be variadic"
|
||||
}
|
||||
if strings.HasSuffix(ts, "Options") && (ts != name+"Options" || i != len(ps)-1 || len(p.Names) > 1) {
|
||||
if strings.HasSuffix(ts, "Options") && (ts != name+"Options" || i != len(ps)-1 && !(i == len(ps)-2 && contentLast) ||
|
||||
len(p.Names) > 1) {
|
||||
return "options must be a final " + name + "Options"
|
||||
}
|
||||
}
|
||||
rs := fn.Type.Results
|
||||
if rs == nil || rs.NumFields() != 1 {
|
||||
return "must return one value"
|
||||
if rs == nil || rs.NumFields() == 0 {
|
||||
return "must return ui.Element"
|
||||
}
|
||||
if r := typeString(rs.List[0].Type); r != "ui.Element" && r != name+"Result" {
|
||||
return "must return ui.Element or " + name + "Result"
|
||||
for _, r := range rs.List {
|
||||
if typeString(r.Type) != "ui.Element" {
|
||||
return "must return only ui.Element results"
|
||||
}
|
||||
}
|
||||
if rs.NumFields() > 1 && (len(rs.List[0].Names) == 0 || rs.List[0].Names[0].Name != "root") {
|
||||
return "several results must be named, root first"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -134,6 +142,10 @@ func typeString(e ast.Expr) string {
|
||||
return "*" + typeString(t.X)
|
||||
case *ast.SelectorExpr:
|
||||
return typeString(t.X) + "." + t.Sel.Name
|
||||
case *ast.FuncType:
|
||||
if t.Params.NumFields() == 0 && t.Results.NumFields() == 0 {
|
||||
return "func()"
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%T", e)
|
||||
}
|
||||
|
||||
+26
-1
@@ -3,6 +3,7 @@ package kit
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/sound"
|
||||
@@ -27,6 +28,18 @@ func Lift(e ui.Element) ui.Element {
|
||||
return e.AnimatePose(p, ui.Snappy)
|
||||
}
|
||||
|
||||
// Arrive eases from a pose to rest on first build.
|
||||
func Arrive(e ui.Element, from ui.Pose, delay time.Duration) ui.Element {
|
||||
p := e.Keyframes("crychic.arrive", []ui.Keyframe{{Value: 1}, {At: 200 * time.Millisecond, Value: 0, Ease: ui.EaseOut}},
|
||||
ui.KeyframeOptions{Delay: delay})
|
||||
s := from.Scale
|
||||
if s == 0 {
|
||||
s = 1
|
||||
}
|
||||
k := 1 + (s-1)*p
|
||||
return e.Translate(from.X*p, from.Y*p).Scale(k, k).Opacity(1 - from.Fade*p)
|
||||
}
|
||||
|
||||
var popSpring = ui.Bouncy
|
||||
|
||||
var (
|
||||
@@ -66,9 +79,21 @@ func Changed(e ui.Element, cue sound.Cue) bool {
|
||||
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))
|
||||
Shadow(0, 1, 3, 0, Shade(0.06))
|
||||
}
|
||||
|
||||
// Settle rebuilds when e's laid-out read differs from used.
|
||||
func Settle(c *ui.Context, e ui.Element, used []float32, read func() []float32) {
|
||||
e.DrawOver(func(*ui.Painter, ui.Rect) {
|
||||
if !slices.Equal(read(), used) {
|
||||
c.Invalidate()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Shade is black at alpha a, for shadows.
|
||||
func Shade(a float32) ui.Color { return ui.RGBA(0, 0, 0, a) }
|
||||
|
||||
// Muted returns secondary text.
|
||||
func Muted(c *ui.Context, s string) ui.Element {
|
||||
return ui.Text(c, s).TextColor(c.Theme().TextMuted)
|
||||
|
||||
@@ -200,3 +200,63 @@ func TestLog(t *testing.T) {
|
||||
t.Errorf("log = %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArriveFadesIn(t *testing.T) {
|
||||
for _, delay := range []time.Duration{0, 200 * time.Millisecond} {
|
||||
var bg, fill ui.Color
|
||||
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() {
|
||||
Arrive(ui.Box(c).Size(100, 100).Background(fill), ui.Pose{Fade: 1}, delay)
|
||||
})
|
||||
}, 100, 100)
|
||||
if !tt.FramePending() || !near(tt.Image(), 50, 50, bg) {
|
||||
t.Fatalf("delay %v: box shown before it arrived", delay)
|
||||
}
|
||||
tt.Advance(delay + 100*time.Millisecond)
|
||||
if near(tt.Image(), 50, 50, bg) || near(tt.Image(), 50, 50, fill) {
|
||||
t.Errorf("delay %v: box not mid-fade", delay)
|
||||
}
|
||||
tt.Advance(time.Second)
|
||||
if tt.FramePending() || !near(tt.Image(), 50, 50, fill) {
|
||||
t.Errorf("delay %v: box did not settle", delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShade(t *testing.T) {
|
||||
if s := Shade(0.5); s.R != 0 || s.G != 0 || s.B != 0 || s.A != 128 {
|
||||
t.Errorf("Shade(0.5) = %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettleRebuildsWhenLayoutMoves(t *testing.T) {
|
||||
var w, prev, used float32 = 40, 0, 0
|
||||
builds := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
builds++
|
||||
b := ui.Box(c).FillWidth().MaxWidth(w).Height(10)
|
||||
used = prev
|
||||
prev = b.LayoutBounds().W
|
||||
Settle(c, b, []float32{used}, func() []float32 { return []float32{b.LayoutBounds().W} })
|
||||
}, 100, 100)
|
||||
if builds != 3 || used != 40 {
|
||||
t.Fatalf("first frame: %d builds used %v, want 3 using 40", builds, used)
|
||||
}
|
||||
builds = 0
|
||||
tt.Frame()
|
||||
if builds != 1 {
|
||||
t.Errorf("steady layout ran %d builds, want 1", builds)
|
||||
}
|
||||
builds, w = 0, 60
|
||||
tt.Frame()
|
||||
if builds != 3 || used != 60 {
|
||||
t.Errorf("changed layout: %d builds used %v, want 3 using 60", builds, used)
|
||||
}
|
||||
builds = 0
|
||||
tt.SetSize(50, 100)
|
||||
if builds != 3 || used != 50 {
|
||||
t.Errorf("resize: %d builds used %v, want 3 using 50", builds, used)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/kit"
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// AppFrame stacks top bar, content, status bar; bars slide.
|
||||
func AppFrame(c *ui.Context, top, content, status func()) ui.Element {
|
||||
t := c.Theme()
|
||||
return ui.Column(c).Fill().Children(func() {
|
||||
bar := ui.Row(c).FillWidth().Gap(t.Space(3)).Padding(t.Space(2), t.Space(4)).AlignItems(ui.Center)
|
||||
kit.Arrive(bar, ui.Pose{Y: -8, Fade: 1}, 0).Children(top)
|
||||
ui.Divider(c)
|
||||
ui.Column(c).Grow(1).Children(content)
|
||||
ui.Divider(c)
|
||||
foot := ui.Row(c).FillWidth().Gap(t.Space(3)).Padding(t.Space(1.5), t.Space(4)).AlignItems(ui.Center)
|
||||
kit.Arrive(foot, ui.Pose{Y: 8, Fade: 1}, 80*time.Millisecond).Children(status)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package layout
|
||||
|
||||
import "github.com/egoist/mygo/ui"
|
||||
|
||||
// AspectBox holds width over height at ratio; it resizes smoothly.
|
||||
func AspectBox(c *ui.Context, ratio float32, content func()) ui.Element {
|
||||
if !finite(ratio) || ratio <= 0 {
|
||||
panic("layout: AspectBox ratio must be finite and positive")
|
||||
}
|
||||
return ui.Box(c).FillWidth().AspectRatio(ratio).Clip().Transition(ui.ElementTransition{Size: true}).
|
||||
Children(content)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/kit"
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// AutoGrid fits as many columns as minWidth allows.
|
||||
func AutoGrid(c *ui.Context, minWidth float32, items []func()) ui.Element {
|
||||
if !finite(minWidth) || minWidth <= 0 {
|
||||
panic("layout: AutoGrid minWidth must be finite and positive")
|
||||
}
|
||||
gap := c.Theme().Space(3)
|
||||
root := ui.Grid(c).FillWidth().Gap(gap)
|
||||
w := root.LayoutBounds().W
|
||||
kit.Settle(c, root, []float32{w}, func() []float32 { return []float32{root.LayoutBounds().W} })
|
||||
return root.Columns(max(1, int((w+gap)/(minWidth+gap)))).Children(func() {
|
||||
for i, it := range items {
|
||||
ui.Box(c).Key(i).Transition(ui.ElementTransition{Position: true, Size: true}).Children(it)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/kit"
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// BentoTile spans Cols and Rows; zero spans one.
|
||||
type BentoTile struct {
|
||||
Cols, Rows int
|
||||
Content func()
|
||||
}
|
||||
|
||||
// Bento is a grid of spanning tiles; tiles lift.
|
||||
func Bento(c *ui.Context, columns int, tiles []BentoTile) ui.Element {
|
||||
if columns < 1 {
|
||||
panic("layout: Bento needs a column")
|
||||
}
|
||||
t := c.Theme()
|
||||
return ui.Grid(c).FillWidth().Columns(columns).Gap(t.Space(3)).Children(func() {
|
||||
for i, tile := range tiles {
|
||||
cols, rows := max(tile.Cols, 1), max(tile.Rows, 1)
|
||||
if tile.Cols < 0 || tile.Rows < 0 || cols > columns {
|
||||
panic("layout: Bento tile span out of range")
|
||||
}
|
||||
kit.Lift(kit.Card(c).Key(i).Padding(t.Space(4)).Gap(t.Space(2))).ColumnSpan(cols).RowSpan(rows).
|
||||
Children(tile.Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/kit"
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// Card is the shared card surface; it lifts on hover.
|
||||
func Card(c *ui.Context, content func()) ui.Element {
|
||||
t := c.Theme()
|
||||
return kit.Lift(kit.Card(c).Gap(t.Space(2)).Padding(t.Space(4))).Children(content)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/kit"
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// Centered centers content in its room; the content scales in.
|
||||
func Centered(c *ui.Context, content func()) ui.Element {
|
||||
return ui.Column(c).Fill().Center().Children(func() {
|
||||
kit.Arrive(ui.Box(c), ui.Pose{Scale: 0.96, Fade: 1}, 0).Children(content)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package layout
|
||||
|
||||
import "math"
|
||||
|
||||
// finite reports v is neither NaN nor infinite.
|
||||
func finite(v float32) bool { return !math.IsNaN(float64(v)) && !math.IsInf(float64(v), 0) }
|
||||
@@ -0,0 +1,15 @@
|
||||
package layout
|
||||
|
||||
import "github.com/egoist/mygo/ui"
|
||||
|
||||
// Cluster wraps items aligned Start, Center or End; items glide.
|
||||
func Cluster(c *ui.Context, align ui.Align, items []func()) ui.Element {
|
||||
if align != ui.Start && align != ui.Center && align != ui.End {
|
||||
panic("layout: Cluster align must be Start, Center or End")
|
||||
}
|
||||
return ui.Row(c).FillWidth().Wrap().Justify(align).Gap(c.Theme().Space(2)).Children(func() {
|
||||
for i, it := range items {
|
||||
ui.Box(c).Key(i).Transition(ui.ElementTransition{Position: true}).Children(it)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package layout
|
||||
|
||||
import "github.com/egoist/mygo/ui"
|
||||
|
||||
// Columns gives each item an equal column; widths animate.
|
||||
func Columns(c *ui.Context, items []func()) ui.Element {
|
||||
if len(items) == 0 {
|
||||
panic("layout: Columns needs an item")
|
||||
}
|
||||
return ui.Row(c).FillWidth().Gap(c.Theme().Space(4)).AlignItems(ui.Start).Children(func() {
|
||||
for i, it := range items {
|
||||
ui.Column(c).Key(i).Grow(1).Basis(0).Transition(ui.ElementTransition{Size: true, Position: true}).
|
||||
Children(it)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package layout
|
||||
|
||||
import "github.com/egoist/mygo/ui"
|
||||
|
||||
// ContainerOptions tune a Container.
|
||||
type ContainerOptions struct {
|
||||
// MaxWidth is 64 rem when zero.
|
||||
MaxWidth float32
|
||||
}
|
||||
|
||||
// Container is a centered, padded page column; its width animates.
|
||||
func Container(c *ui.Context, o ContainerOptions, content func()) ui.Element {
|
||||
t := c.Theme()
|
||||
if o.MaxWidth == 0 {
|
||||
o.MaxWidth = t.Rem(64)
|
||||
}
|
||||
if !finite(o.MaxWidth) || o.MaxWidth < 0 {
|
||||
panic("layout: Container MaxWidth must be finite and non-negative")
|
||||
}
|
||||
return ui.Column(c).FillWidth().MaxWidth(o.MaxWidth).Margin(0, ui.Auto).Padding(t.Space(6)).
|
||||
Transition(ui.ElementTransition{Size: true}).Children(content)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestStackGapsAndResizes(t *testing.T) {
|
||||
more := false
|
||||
var gap float32
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
gap = c.Theme().Space(4)
|
||||
Stack(c, 4, func() {
|
||||
ui.Text(c, "Alpha")
|
||||
ui.Text(c, "Beta")
|
||||
if more {
|
||||
ui.Text(c, "Gamma")
|
||||
}
|
||||
})
|
||||
}, 300, 300)
|
||||
a, b := find(t, tt, "Alpha"), find(t, tt, "Beta")
|
||||
if got := b.Y - (a.Y + a.H); abs(got-gap) > 0.5 {
|
||||
t.Errorf("gap %v, want %v", got, gap)
|
||||
}
|
||||
more = true
|
||||
tt.Frame()
|
||||
settles(t, tt, "Stack size")
|
||||
find(t, tt, "Gamma")
|
||||
}
|
||||
|
||||
func TestInlineWraps(t *testing.T) {
|
||||
var gap float32
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
gap = c.Theme().Space(2)
|
||||
Inline(c, 2, func() {
|
||||
for _, s := range []string{"Alpha", "Beta", "Gamma", "Delta", "Epsilon"} {
|
||||
ui.Text(c, s)
|
||||
}
|
||||
})
|
||||
}, 160, 300)
|
||||
a, b, e := find(t, tt, "Alpha"), find(t, tt, "Beta"), find(t, tt, "Epsilon")
|
||||
if got := b.X - (a.X + a.W); abs(got-gap) > 0.5 {
|
||||
t.Errorf("gap %v, want %v", got, gap)
|
||||
}
|
||||
if e.Y <= a.Y {
|
||||
t.Error("items did not wrap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayersOverlapAndFade(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Layers(c, []func(){func() { ui.Text(c, "Back") }, func() { ui.Text(c, "Front") }})
|
||||
}, 300, 200)
|
||||
settles(t, tt, "Layers fade")
|
||||
if b, f := find(t, tt, "Back"), find(t, tt, "Front"); b.X != f.X || b.Y != f.Y {
|
||||
t.Errorf("layers apart: %v %v", b, f)
|
||||
}
|
||||
panics(t, "Layers(nil)", func() { ui.Render(func(c *ui.Context) { Layers(c, nil) }, 10, 10, 1) })
|
||||
}
|
||||
|
||||
func TestCentered(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Centered(c, func() { ui.Text(c, "Mid") })
|
||||
}, 300, 200)
|
||||
settles(t, tt, "Centered scale")
|
||||
r := find(t, tt, "Mid")
|
||||
if abs(r.X+r.W/2-150) > 1 || abs(r.Y+r.H/2-100) > 1 {
|
||||
t.Errorf("Mid at %v, not centered", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerWidth(t *testing.T) {
|
||||
var pad, rem float32
|
||||
width := float32(400)
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
pad, rem = c.Theme().Space(6), c.Theme().Rem(64)
|
||||
Container(c, ContainerOptions{MaxWidth: width}, func() { ui.Text(c, "Edge") })
|
||||
}, 1200, 200)
|
||||
if x := find(t, tt, "Edge").X; abs(x-((1200-width)/2+pad)) > 0.5 {
|
||||
t.Errorf("content at %v", x)
|
||||
}
|
||||
width = 0
|
||||
tt.Frame()
|
||||
settles(t, tt, "Container width")
|
||||
if x := find(t, tt, "Edge").X; abs(x-((1200-rem)/2+pad)) > 0.5 {
|
||||
t.Errorf("default width puts content at %v", x)
|
||||
}
|
||||
panics(t, "negative MaxWidth", func() {
|
||||
ui.Render(func(c *ui.Context) { Container(c, ContainerOptions{MaxWidth: -1}, nil) }, 10, 10, 1)
|
||||
})
|
||||
}
|
||||
|
||||
// inked finds ink in a column span of pixels.
|
||||
func inked(tt *ui.Tester, x int, y0, y1 float32) bool {
|
||||
img := tt.Image()
|
||||
for y := max(int(y0), 0); y <= min(int(y1), img.Bounds().Dy()-1); y++ {
|
||||
if lum(img, x, y) < lum(img, 299, 199)-20000 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestSectionDividerGrows(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Section(c, "Title", func() { ui.Text(c, "Body") })
|
||||
}, 300, 200)
|
||||
title, body := find(t, tt, "Title"), find(t, tt, "Body")
|
||||
if body.Y <= title.Y {
|
||||
t.Fatal("body above title")
|
||||
}
|
||||
if inked(tt, 280, title.Y+title.H, body.Y-1) {
|
||||
t.Error("divider drawn before it grew")
|
||||
}
|
||||
settles(t, tt, "Section divider")
|
||||
if !inked(tt, 280, title.Y+title.H, body.Y-1) {
|
||||
t.Error("divider never reached the right edge")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSurfaces(t *testing.T) {
|
||||
var th ui.Theme
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
th = *c.Theme()
|
||||
ui.Column(c).Gap(10).Children(func() {
|
||||
Panel(c, func() { ui.Box(c).Size(100, 40) })
|
||||
Inset(c, func() { ui.Box(c).Size(100, 40) })
|
||||
Well(c, func() { ui.Box(c).Size(100, 40) })
|
||||
})
|
||||
}, 300, 300)
|
||||
tt.Advance(time.Second)
|
||||
img := tt.Image()
|
||||
for _, p := range [][2]int{{60, 30}, {60, 100}, {60, 170}} {
|
||||
if !near(img, p[0], p[1], th.Surface) {
|
||||
t.Errorf("pixel %v is not Surface", p)
|
||||
}
|
||||
}
|
||||
tt.SetDark(true)
|
||||
if !tt.FramePending() {
|
||||
t.Error("Inset color did not animate to the dark theme")
|
||||
}
|
||||
tt.Advance(2 * time.Second)
|
||||
if !near(tt.Image(), 60, 100, ui.DarkTheme().Surface) {
|
||||
t.Error("Inset did not settle on the dark surface")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardLifts(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
ui.Column(c).Padding(20).Children(func() {
|
||||
Card(c, func() { ui.Box(c).Size(100, 60) })
|
||||
})
|
||||
}, 300, 200)
|
||||
tt.Move(70, 50)
|
||||
settles(t, tt, "Card lift")
|
||||
}
|
||||
|
||||
func TestFrameClips(t *testing.T) {
|
||||
var th ui.Theme
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
th = *c.Theme()
|
||||
ui.Column(c).Padding(20).Children(func() {
|
||||
Frame(c, func() { ui.Box(c).Size(100, 60).Background(th.Accent) })
|
||||
})
|
||||
}, 300, 200)
|
||||
settles(t, tt, "Frame fade")
|
||||
if img := tt.Image(); near(img, 20, 20, th.Accent) || !near(img, 70, 50, th.Accent) {
|
||||
t.Error("Frame did not clip its corner")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAspectBox(t *testing.T) {
|
||||
var th ui.Theme
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
th = *c.Theme()
|
||||
ui.Column(c).Width(300).Children(func() {
|
||||
AspectBox(c, 2, func() { ui.Box(c).Fill().Background(th.Accent) })
|
||||
})
|
||||
}, 300, 300)
|
||||
if img := tt.Image(); !near(img, 150, 140, th.Accent) || near(img, 150, 160, th.Accent) {
|
||||
t.Error("AspectBox is not 300x150")
|
||||
}
|
||||
panics(t, "ratio 0", func() { ui.Render(func(c *ui.Context) { AspectBox(c, 0, nil) }, 10, 10, 1) })
|
||||
}
|
||||
|
||||
func TestAppFrame(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
AppFrame(c, func() { ui.Text(c, "Top") }, func() { ui.Text(c, "Body") }, func() { ui.Text(c, "Status") })
|
||||
}, 400, 300)
|
||||
top, body, st := find(t, tt, "Top"), find(t, tt, "Body"), find(t, tt, "Status")
|
||||
if !(top.Y < body.Y && body.Y < st.Y) {
|
||||
t.Errorf("order %v %v %v", top, body, st)
|
||||
}
|
||||
mid := func(r ui.Rect) int { return int(r.X + r.W/2) }
|
||||
if inked(tt, mid(top), top.Y, top.Y+top.H) || inked(tt, mid(st), st.Y, st.Y+st.H) {
|
||||
t.Error("bars shown before they slid in")
|
||||
}
|
||||
settles(t, tt, "AppFrame bars")
|
||||
if !inked(tt, mid(top), top.Y-2, top.Y+top.H+2) || !inked(tt, mid(st), st.Y-2, st.Y+st.H+2) {
|
||||
t.Error("bars never arrived")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHolyGrail(t *testing.T) {
|
||||
aside := true
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
s := HolyGrailSlots{
|
||||
Header: func() { ui.Text(c, "Head") }, Nav: func() { ui.Text(c, "Nav") },
|
||||
Main: func() { ui.Text(c, "Main") }, Footer: func() { ui.Text(c, "Foot") },
|
||||
}
|
||||
if aside {
|
||||
s.Aside = func() { ui.Text(c, "Aside") }
|
||||
}
|
||||
HolyGrail(c, s)
|
||||
}, 600, 400)
|
||||
nav, main, side := find(t, tt, "Nav"), find(t, tt, "Main"), find(t, tt, "Aside")
|
||||
if !(nav.X < main.X && main.X < side.X) || find(t, tt, "Head").Y >= main.Y || find(t, tt, "Foot").Y <= main.Y {
|
||||
t.Error("slots out of place")
|
||||
}
|
||||
aside = false
|
||||
tt.Frame()
|
||||
settles(t, tt, "HolyGrail aside exit")
|
||||
if tt.HasText("Aside") {
|
||||
t.Error("aside still shown")
|
||||
}
|
||||
panics(t, "no Main", func() { ui.Render(func(c *ui.Context) { HolyGrail(c, HolyGrailSlots{}) }, 10, 10, 1) })
|
||||
}
|
||||
|
||||
func TestRejectsBadNumbers(t *testing.T) {
|
||||
nan, inf := float32(math.NaN()), float32(math.Inf(1))
|
||||
none := func() {}
|
||||
cases := map[string]func(c *ui.Context){
|
||||
"Stack gap -1": func(c *ui.Context) { Stack(c, -1, none) },
|
||||
"Stack gap NaN": func(c *ui.Context) { Stack(c, nan, none) },
|
||||
"Inline gap Inf": func(c *ui.Context) { Inline(c, inf, none) },
|
||||
"Container NaN": func(c *ui.Context) { Container(c, ContainerOptions{MaxWidth: nan}, none) },
|
||||
"Container Inf": func(c *ui.Context) { Container(c, ContainerOptions{MaxWidth: inf}, none) },
|
||||
"AspectBox NaN": func(c *ui.Context) { AspectBox(c, nan, none) },
|
||||
"AspectBox Inf": func(c *ui.Context) { AspectBox(c, inf, none) },
|
||||
"AutoGrid NaN": func(c *ui.Context) { AutoGrid(c, nan, nil) },
|
||||
"AutoGrid Inf": func(c *ui.Context) { AutoGrid(c, inf, nil) },
|
||||
"SplitPane size NaN": func(c *ui.Context) { SplitPane(c, &nan, none, none) },
|
||||
"SplitStack size Inf": func(c *ui.Context) { SplitStack(c, &inf, none, none) },
|
||||
"SplitPane size -1": func(c *ui.Context) { SplitPane(c, new(float32(-1)), none, none) },
|
||||
"SplitStack size -1": func(c *ui.Context) { SplitStack(c, new(float32(-1)), none, none) },
|
||||
}
|
||||
for what, build := range cases {
|
||||
panics(t, what, func() { ui.Render(func(c *ui.Context) { build(c) }, 10, 10, 1) })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package layout arranges other elements.
|
||||
package layout
|
||||
@@ -0,0 +1,13 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/kit"
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// Frame clips content to the theme radius; content fades in.
|
||||
func Frame(c *ui.Context, content func()) ui.Element {
|
||||
return ui.Box(c).Clip().Radius(c.Theme().Radius).Children(func() {
|
||||
kit.Arrive(ui.Box(c), ui.Pose{Fade: 1}, 0).Children(content)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func block(c *ui.Context, label string, h float32) func() {
|
||||
return func() { ui.Column(c).Height(h).Children(func() { ui.Text(c, label) }) }
|
||||
}
|
||||
|
||||
func TestMasonryFillsShortest(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Masonry(c, 2, []func(){block(c, "I0", 120), block(c, "I1", 20), block(c, "I2", 20), block(c, "I3", 20)})
|
||||
}, 400, 400)
|
||||
tt.Advance(time2s)
|
||||
i0, i1, i2, i3 := find(t, tt, "I0"), find(t, tt, "I1"), find(t, tt, "I2"), find(t, tt, "I3")
|
||||
if i1.X == i0.X || i2.X != i1.X || i3.X != i1.X || !(i1.Y < i2.Y && i2.Y < i3.Y) {
|
||||
t.Errorf("placement %v %v %v %v", i0, i1, i2, i3)
|
||||
}
|
||||
panics(t, "0 columns", func() { ui.Render(func(c *ui.Context) { Masonry(c, 0, nil) }, 10, 10, 1) })
|
||||
}
|
||||
|
||||
func TestAutoGridColumns(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
var items []func()
|
||||
for i := range 4 {
|
||||
items = append(items, block(c, fmt.Sprintf("G%d", i), 30))
|
||||
}
|
||||
AutoGrid(c, 120, items)
|
||||
}, 412, 300)
|
||||
tt.Advance(time2s)
|
||||
g0, g1, g2, g3 := find(t, tt, "G0"), find(t, tt, "G1"), find(t, tt, "G2"), find(t, tt, "G3")
|
||||
if !(g0.X < g1.X && g1.X < g2.X) || g0.Y != g2.Y || g3.X != g0.X || g3.Y <= g0.Y {
|
||||
t.Errorf("not 3 columns: %v %v %v %v", g0, g1, g2, g3)
|
||||
}
|
||||
tt.SetSize(260, 300)
|
||||
tt.Advance(time2s)
|
||||
if g1, g2 := find(t, tt, "G1"), find(t, tt, "G2"); g2.X != find(t, tt, "G0").X || g1.Y != find(t, tt, "G0").Y {
|
||||
t.Error("not 2 columns after resize")
|
||||
}
|
||||
panics(t, "minWidth 0", func() { ui.Render(func(c *ui.Context) { AutoGrid(c, 0, nil) }, 10, 10, 1) })
|
||||
}
|
||||
|
||||
func TestBentoSpans(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Bento(c, 3, []BentoTile{
|
||||
{Cols: 2, Content: func() { ui.Text(c, "Wide") }},
|
||||
{Content: func() { ui.Text(c, "One") }},
|
||||
{Content: func() { ui.Text(c, "Two") }},
|
||||
})
|
||||
}, 600, 300)
|
||||
w, one, two := find(t, tt, "Wide"), find(t, tt, "One"), find(t, tt, "Two")
|
||||
if one.Y != w.Y || one.X < 380 || two.Y <= w.Y || two.X > w.X+1 {
|
||||
t.Errorf("spans wrong: %v %v %v", w, one, two)
|
||||
}
|
||||
tt.Move(500, 40)
|
||||
settles(t, tt, "Bento lift")
|
||||
panics(t, "span 4 of 3", func() {
|
||||
ui.Render(func(c *ui.Context) { Bento(c, 3, []BentoTile{{Cols: 4}}) }, 10, 10, 1)
|
||||
})
|
||||
}
|
||||
|
||||
func TestClusterAligns(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Cluster(c, ui.End, []func(){func() { ui.Text(c, "Tail") }})
|
||||
}, 300, 100)
|
||||
if r := find(t, tt, "Tail"); abs(r.X+r.W-300) > 1 {
|
||||
t.Errorf("Tail ends at %v", r.X+r.W)
|
||||
}
|
||||
panics(t, "Stretch", func() { ui.Render(func(c *ui.Context) { Cluster(c, ui.Stretch, nil) }, 10, 10, 1) })
|
||||
}
|
||||
|
||||
func TestColumnsEqual(t *testing.T) {
|
||||
var gap float32
|
||||
n := 2
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
gap = c.Theme().Space(4)
|
||||
items := []func(){func() { ui.Text(c, "C0") }, func() { ui.Text(c, "C1") }, func() { ui.Text(c, "C2") }}
|
||||
Columns(c, items[:n])
|
||||
}, 400, 100)
|
||||
if x := find(t, tt, "C1").X; abs(x-(200+gap/2)) > 0.5 {
|
||||
t.Errorf("C1 at %v", x)
|
||||
}
|
||||
n = 3
|
||||
tt.Frame()
|
||||
settles(t, tt, "Columns width")
|
||||
if x := find(t, tt, "C1").X; abs(x-(400-2*gap)/3-gap) > 0.5 {
|
||||
t.Errorf("C1 at %v after a third column", x)
|
||||
}
|
||||
panics(t, "no items", func() { ui.Render(func(c *ui.Context) { Columns(c, nil) }, 10, 10, 1) })
|
||||
}
|
||||
|
||||
func TestMasonryReplacesWhenAnItemShrinks(t *testing.T) {
|
||||
h0 := float32(120)
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Masonry(c, 2, []func(){block(c, "I0", h0), block(c, "I1", 20), block(c, "I2", 20)})
|
||||
}, 400, 400)
|
||||
tt.Advance(time2s)
|
||||
if find(t, tt, "I2").X == find(t, tt, "I0").X {
|
||||
t.Fatal("I2 starts under I0")
|
||||
}
|
||||
x2 := find(t, tt, "I2").X
|
||||
h0 = 10
|
||||
tt.Frame()
|
||||
if find(t, tt, "I2").X != x2 {
|
||||
t.Error("I2 jumped instead of sliding")
|
||||
}
|
||||
tt.Advance(time2s)
|
||||
if find(t, tt, "I2").X != find(t, tt, "I0").X {
|
||||
t.Error("I2 stayed beside I0 after I0 shrank")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMasonrySettlesOnAnEmptyItem(t *testing.T) {
|
||||
full := true
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
first := func() {
|
||||
if full {
|
||||
ui.Box(c).Height(120)
|
||||
}
|
||||
}
|
||||
Masonry(c, 2, []func(){first, block(c, "I1", 20), block(c, "I2", 20)})
|
||||
}, 400, 400)
|
||||
tt.Advance(time2s)
|
||||
full = false
|
||||
tt.Frame()
|
||||
tt.Advance(time2s)
|
||||
if tt.FramePending() {
|
||||
t.Fatal("Masonry kept rebuilding after an item emptied")
|
||||
}
|
||||
if find(t, tt, "I2").Y >= find(t, tt, "I1").Y+20 {
|
||||
t.Error("I2 did not move into the emptied column")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMasonrySettlesAtZeroWidth(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
ui.Column(c).Width(0).ClipX().Children(func() {
|
||||
Masonry(c, 2, []func(){block(c, "I0", 40), block(c, "I1", 20)})
|
||||
})
|
||||
}, 400, 400)
|
||||
tt.Advance(time2s)
|
||||
if tt.FramePending() {
|
||||
t.Error("zero-width Masonry kept rebuilding")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"image"
|
||||
"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(img *image.RGBA, x, y int) int {
|
||||
p := img.RGBAAt(x, y)
|
||||
return int(p.R)*2126 + int(p.G)*7152 + int(p.B)*722
|
||||
}
|
||||
|
||||
func count(cue sound.Cue) int {
|
||||
n := 0
|
||||
for _, c := range sound.Recent() {
|
||||
if c == cue {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func find(t *testing.T, tt *ui.Tester, s string) ui.Rect {
|
||||
t.Helper()
|
||||
r, ok := tt.Find(s)
|
||||
if !ok {
|
||||
t.Fatalf("%q not shown in %q", s, tt.Texts())
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// settles checks an animation runs, then stops.
|
||||
func settles(t *testing.T, tt *ui.Tester, what string) {
|
||||
t.Helper()
|
||||
if !tt.FramePending() {
|
||||
t.Errorf("%s: no animation", what)
|
||||
}
|
||||
tt.Advance(2 * time.Second)
|
||||
if tt.FramePending() {
|
||||
t.Errorf("%s: animation never settled", what)
|
||||
}
|
||||
}
|
||||
|
||||
func panics(t *testing.T, what string, fn func()) {
|
||||
t.Helper()
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s did not panic", what)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}
|
||||
|
||||
func abs(v float32) float32 { return max(v, -v) }
|
||||
@@ -0,0 +1,38 @@
|
||||
package layout
|
||||
|
||||
import "github.com/egoist/mygo/ui"
|
||||
|
||||
// HolyGrailSlots fill a HolyGrail; only Main is required.
|
||||
type HolyGrailSlots struct {
|
||||
Header, Nav, Main, Aside, Footer func()
|
||||
}
|
||||
|
||||
// HolyGrail lays out header, nav, main, aside and footer.
|
||||
func HolyGrail(c *ui.Context, s HolyGrailSlots) ui.Element {
|
||||
if s.Main == nil {
|
||||
panic("layout: HolyGrail needs Main")
|
||||
}
|
||||
t := c.Theme()
|
||||
slot := func(key string, fn func()) ui.Element {
|
||||
e := ui.Column(c).Key(key).Gap(t.Space(2)).Padding(t.Space(3))
|
||||
return e.Transition(ui.ElementTransition{Enter: &ui.Motion{Collapse: true}, Exit: &ui.Motion{Collapse: true}}).
|
||||
Children(fn)
|
||||
}
|
||||
return ui.Column(c).Fill().Children(func() {
|
||||
if s.Header != nil {
|
||||
slot("header", s.Header).Background(t.Surface)
|
||||
}
|
||||
ui.Row(c).Grow(1).AlignItems(ui.Stretch).Children(func() {
|
||||
if s.Nav != nil {
|
||||
slot("nav", s.Nav).Width(t.Rem(12))
|
||||
}
|
||||
slot("main", s.Main).Grow(1).Basis(0)
|
||||
if s.Aside != nil {
|
||||
slot("aside", s.Aside).Width(t.Rem(12))
|
||||
}
|
||||
})
|
||||
if s.Footer != nil {
|
||||
slot("footer", s.Footer).Background(t.Surface)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package layout
|
||||
|
||||
import "github.com/egoist/mygo/ui"
|
||||
|
||||
// Inline is a wrapping row in theme gaps; size animates.
|
||||
func Inline(c *ui.Context, gap float32, content func()) ui.Element {
|
||||
if !finite(gap) || gap < 0 {
|
||||
panic("layout: Inline gap must be finite and non-negative")
|
||||
}
|
||||
return ui.Row(c).Wrap().Gap(c.Theme().Space(gap)).Transition(ui.ElementTransition{Size: true}).Children(content)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package layout
|
||||
|
||||
import "github.com/egoist/mygo/ui"
|
||||
|
||||
// Inset is a recessed surface whose color follows the theme.
|
||||
func Inset(c *ui.Context, content func()) ui.Element {
|
||||
t := c.Theme()
|
||||
e := ui.Column(c).Gap(t.Space(2)).Padding(t.Space(3)).Radius(t.Radius)
|
||||
return e.Background(e.AnimateColor("bg", t.Surface, ui.Smooth)).Children(content)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/kit"
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// Layers stacks layers on one box; each fades in turn.
|
||||
func Layers(c *ui.Context, layers []func()) ui.Element {
|
||||
if len(layers) == 0 {
|
||||
panic("layout: Layers needs a layer")
|
||||
}
|
||||
return ui.Box(c).Children(func() {
|
||||
for i, l := range layers {
|
||||
b := ui.Box(c).Key(i)
|
||||
if i > 0 {
|
||||
b.Absolute().Top(0).Right(0).Bottom(0).Left(0)
|
||||
}
|
||||
kit.Arrive(b, ui.Pose{Fade: 1}, time.Duration(i)*80*time.Millisecond).Children(l)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/kit"
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// Masonry puts each item in the shortest of n columns.
|
||||
func Masonry(c *ui.Context, columns int, items []func()) ui.Element {
|
||||
if columns < 1 {
|
||||
panic("layout: Masonry needs a column")
|
||||
}
|
||||
gap := c.Theme().Space(3)
|
||||
root := ui.Box(c).FillWidth()
|
||||
w := root.LayoutBounds().W
|
||||
colW := max(0, (w-gap*float32(columns-1))/float32(columns))
|
||||
used, boxes, sum := make([]float32, len(items)), make([]ui.Element, len(items)), make([]float32, columns)
|
||||
root.Children(func() {
|
||||
for i, item := range items {
|
||||
b := ui.Box(c).Key(i).Absolute().Width(colW).Transition(ui.ElementTransition{Position: true})
|
||||
used[i] = b.LayoutBounds().H
|
||||
j := slices.Index(sum, slices.Min(sum))
|
||||
b.Left(float32(j) * (colW + gap)).Top(sum[j]).Children(item)
|
||||
sum[j] += used[i] + gap
|
||||
boxes[i] = b
|
||||
}
|
||||
})
|
||||
if len(items) > 0 {
|
||||
root.Height(slices.Max(sum) - gap)
|
||||
}
|
||||
kit.Settle(c, root, append(used, w), func() []float32 {
|
||||
h := make([]float32, 0, len(boxes)+1)
|
||||
for _, b := range boxes {
|
||||
h = append(h, b.LayoutBounds().H)
|
||||
}
|
||||
return append(h, root.LayoutBounds().W)
|
||||
})
|
||||
return root
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package layout
|
||||
|
||||
import "github.com/egoist/mygo/ui"
|
||||
|
||||
// Panel is a bordered surface box; its size animates.
|
||||
func Panel(c *ui.Context, content func()) ui.Element {
|
||||
t := c.Theme()
|
||||
return ui.Column(c).Gap(t.Space(2)).Padding(t.Space(4)).Background(t.Surface).Border(1, t.Border).
|
||||
Radius(t.Radius).Transition(ui.ElementTransition{Size: true}).Children(content)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/kit"
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// ScrollArea scrolls content; edge shadows fade with the offset.
|
||||
func ScrollArea(c *ui.Context, content func()) ui.Element {
|
||||
root := ui.Box(c).Clip()
|
||||
st := ui.Local(root, "scroll", func() ui.ScrollState { return ui.ScrollState{} })
|
||||
kit.Settle(c, root, []float32{st.Y, st.MaxY}, func() []float32 { return []float32{st.Y, st.MaxY} })
|
||||
return root.Children(func() {
|
||||
ui.Scroll(c).Fill().TrackScroll(st).Children(content)
|
||||
edge := func(key string, show bool, angle float32) {
|
||||
e := ui.Box(c).Key(key).PassThrough().Absolute().Left(0).Right(0).Height(c.Theme().Space(3))
|
||||
target := float32(0)
|
||||
if show {
|
||||
target = 1
|
||||
}
|
||||
e.Gradient(kit.Shade(0.12), kit.Shade(0), angle).Opacity(e.AnimateTo("o", target, ui.Snappy))
|
||||
if key == "top" {
|
||||
e.Top(0)
|
||||
} else {
|
||||
e.Bottom(0)
|
||||
}
|
||||
}
|
||||
edge("top", st.Y > 0, 180)
|
||||
edge("bottom", st.Y < st.MaxY, 0)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/sound"
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
const time2s = 2 * time.Second
|
||||
|
||||
func lines(c *ui.Context, n int) func() {
|
||||
return func() {
|
||||
for i := range n {
|
||||
ui.Text(c, fmt.Sprintf("Line %d", i)).Padding(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrollAreaEdges(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
ScrollArea(c, lines(c, 60)).Size(300, 200)
|
||||
}, 300, 200)
|
||||
tt.Advance(time2s)
|
||||
top, bottom := lum(tt.Image(), 290, 1), lum(tt.Image(), 290, 198)
|
||||
if bottom >= top {
|
||||
t.Fatal("no bottom shadow while content continues")
|
||||
}
|
||||
tt.Scroll(150, 100, 0, 200)
|
||||
tt.Advance(time2s)
|
||||
if lum(tt.Image(), 290, 1) >= top {
|
||||
t.Error("no top shadow once scrolled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrollerPages(t *testing.T) {
|
||||
forward := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
_, _, fwd := Scroller(c, func() {
|
||||
for i := range 12 {
|
||||
ui.Column(c).Size(90, 40).Children(func() { ui.Text(c, fmt.Sprintf("Tile %d", i)) })
|
||||
}
|
||||
})
|
||||
if fwd.Clicked() {
|
||||
forward++
|
||||
}
|
||||
}, 400, 100)
|
||||
taps := count(sound.Tap)
|
||||
if err := tt.Click("Scroll back"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count(sound.Tap) != taps {
|
||||
t.Error("disabled back arrow played a cue")
|
||||
}
|
||||
x5 := find(t, tt, "Tile 5").X
|
||||
if err := tt.Click("Scroll forward"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Advance(time2s)
|
||||
if count(sound.Tap) != taps+1 || forward != 1 {
|
||||
t.Errorf("forward arrow: %d cues, caller saw %d clicks", count(sound.Tap)-taps, forward)
|
||||
}
|
||||
if x := find(t, tt, "Tile 5").X; x > x5-200 {
|
||||
t.Errorf("Tile 5 moved from %v to %v, less than a page", x5, x)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStickyHeaderStays(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
StickyHeader(c, func() { ui.Text(c, "Head") }, lines(c, 60)).Size(300, 200)
|
||||
}, 300, 200)
|
||||
tt.Advance(time2s)
|
||||
y, below := find(t, tt, "Head").Y, find(t, tt, "Line 0")
|
||||
shade := lum(tt.Image(), 290, int(below.Y)+2)
|
||||
tt.Scroll(150, 150, 0, 300)
|
||||
tt.Advance(time2s)
|
||||
if find(t, tt, "Head").Y != y {
|
||||
t.Error("header moved with the scroll")
|
||||
}
|
||||
if lum(tt.Image(), 290, int(below.Y)+2) >= shade {
|
||||
t.Error("no header shadow once scrolled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrollAreaEdgeFollowsContent(t *testing.T) {
|
||||
n := 3
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
ScrollArea(c, lines(c, n)).Size(300, 200)
|
||||
}, 300, 200)
|
||||
tt.Advance(time2s)
|
||||
flat := lum(tt.Image(), 290, 198)
|
||||
n = 60
|
||||
tt.Frame()
|
||||
tt.Advance(time2s)
|
||||
if lum(tt.Image(), 290, 198) >= flat {
|
||||
t.Error("no bottom shadow once content overflows")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"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"
|
||||
)
|
||||
|
||||
// Scroller scrolls sideways; arrows move it a page.
|
||||
func Scroller(c *ui.Context, content func()) (root, back, forward ui.Element) {
|
||||
t := c.Theme()
|
||||
root = ui.Row(c).FillWidth().Gap(t.Space(2)).AlignItems(ui.Center)
|
||||
st := ui.Local(root, "scroll", func() ui.ScrollState { return ui.ScrollState{} })
|
||||
kit.Settle(c, root, []float32{st.X, st.MaxX}, func() []float32 { return []float32{st.X, st.MaxX} })
|
||||
root.Children(func() {
|
||||
arrow := func(glyph, label string, off bool) ui.Element {
|
||||
b := ui.Button(c, "").Label(label).Disabled(off).Children(func() {
|
||||
ui.Icon(c, icon.Must(glyph)).Size(16, 16)
|
||||
})
|
||||
return kit.Press(b)
|
||||
}
|
||||
back = arrow("chevron-left", "Scroll back", st.X <= 0)
|
||||
view := ui.ScrollHorizontal(c).Grow(1).Basis(0).TrackScroll(st)
|
||||
view.Children(func() { ui.Row(c).Gap(t.Space(3)).Children(content) })
|
||||
forward = arrow("chevron-right", "Scroll forward", st.X >= st.MaxX)
|
||||
page := view.LayoutBounds().W
|
||||
if kit.Clicked(back, sound.Tap) {
|
||||
st.ScrollTo(max(0, st.X-page), 0, ui.Smooth)
|
||||
kit.Log("Scroller", "back", "x", st.X)
|
||||
}
|
||||
if kit.Clicked(forward, sound.Tap) {
|
||||
st.ScrollTo(min(st.MaxX, st.X+page), 0, ui.Smooth)
|
||||
kit.Log("Scroller", "forward", "x", st.X)
|
||||
}
|
||||
})
|
||||
return root, back, forward
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/kit"
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// Section is a muted heading, growing divider and body.
|
||||
func Section(c *ui.Context, title string, content func()) ui.Element {
|
||||
t := c.Theme()
|
||||
return ui.Column(c).Gap(t.Space(2)).Children(func() {
|
||||
kit.Muted(c, title).Bold().FontSize(t.Rem(0.85))
|
||||
line := ui.Box(c).FillWidth().Height(1).Background(t.Border).TransformOrigin(0, 0.5)
|
||||
grow := line.Keyframes("grow", []ui.Keyframe{{Value: 0}, {At: 200 * time.Millisecond, Value: 1, Ease: ui.EaseOut}})
|
||||
line.Scale(grow, 1)
|
||||
ui.Column(c).Gap(t.Space(2)).Children(content)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"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"
|
||||
)
|
||||
|
||||
// SidebarLayout puts a side column by main; side springs open.
|
||||
func SidebarLayout(c *ui.Context, open *bool, side, main func()) ui.Element {
|
||||
if open == nil {
|
||||
panic("layout: SidebarLayout needs open")
|
||||
}
|
||||
t := c.Theme()
|
||||
root := ui.Row(c).Fill().AlignItems(ui.Stretch)
|
||||
return root.Children(func() {
|
||||
col := ui.Column(c).ClipX().Background(t.Surface)
|
||||
w := float32(0)
|
||||
if *open {
|
||||
w = t.Rem(16)
|
||||
}
|
||||
col.Width(col.AnimateTo("w", w, ui.Snappy)).Children(func() {
|
||||
ui.Column(c).Width(t.Rem(16)).Grow(1).Padding(t.Space(3)).Gap(t.Space(1)).Children(side)
|
||||
})
|
||||
ui.Column(c).Grow(1).Basis(0).Children(func() {
|
||||
glyph := "panel-left-open"
|
||||
if *open {
|
||||
glyph = "panel-left-close"
|
||||
}
|
||||
b := ui.Button(c, "").Label("Toggle sidebar").Margin(t.Space(2)).Children(func() {
|
||||
ui.Icon(c, icon.Must(glyph)).Size(16, 16).ContentTransition(ui.ContentTransition{})
|
||||
})
|
||||
if kit.Clicked(kit.Press(b), sound.Toggle) {
|
||||
*open = !*open
|
||||
root.SetChanged()
|
||||
kit.Log("SidebarLayout", "toggled", "open", *open)
|
||||
}
|
||||
ui.Column(c).Grow(1).Children(main)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/kit"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/sound"
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
const minPane = 40
|
||||
|
||||
// SplitPane sets two panes side by side; *size is width.
|
||||
func SplitPane(c *ui.Context, size *float32, first, second func()) ui.Element {
|
||||
return split(c, "SplitPane", false, size, first, second)
|
||||
}
|
||||
|
||||
// split lays out two panes around a draggable handle.
|
||||
func split(c *ui.Context, name string, vertical bool, size *float32, first, second func()) ui.Element {
|
||||
if size == nil || !finite(*size) || *size < 0 {
|
||||
panic("layout: " + name + " needs a finite, non-negative size")
|
||||
}
|
||||
t := c.Theme()
|
||||
grip := t.Space(2)
|
||||
var root ui.Element
|
||||
var room float32
|
||||
pane, cursor := func(e ui.Element, v float32) ui.Element { return e.Width(v).ClipX() }, ui.CursorResizeEW
|
||||
if vertical {
|
||||
root = ui.Column(c)
|
||||
room = root.LayoutBounds().H
|
||||
pane, cursor = func(e ui.Element, v float32) ui.Element { return e.Height(v).ClipY() }, ui.CursorResizeNS
|
||||
} else {
|
||||
root = ui.Row(c)
|
||||
room = root.LayoutBounds().W
|
||||
}
|
||||
return root.Fill().AlignItems(ui.Stretch).Children(func() {
|
||||
pane(ui.Column(c), *size).Children(first)
|
||||
handle := ui.Box(c).Cursor(cursor).Center().Label("Divider")
|
||||
if vertical {
|
||||
handle.Height(grip)
|
||||
} else {
|
||||
handle.Width(grip)
|
||||
}
|
||||
dx, dy, moved := handle.Dragged()
|
||||
active := handle.Hovered() || handle.Dragging()
|
||||
color := t.Border
|
||||
if active {
|
||||
color = t.Accent
|
||||
}
|
||||
line := ui.Box(c)
|
||||
if vertical {
|
||||
line.FillWidth().Height(1)
|
||||
} else {
|
||||
line.FillHeight().Width(1)
|
||||
}
|
||||
handle.Children(func() { line.Background(line.AnimateColor("line", color, ui.Snappy)) })
|
||||
if moved {
|
||||
d := dx
|
||||
if vertical {
|
||||
d = dy
|
||||
}
|
||||
next := max(minPane, *size+d)
|
||||
if room > 0 {
|
||||
next = min(next, max(minPane, room-grip-minPane))
|
||||
}
|
||||
if next != *size {
|
||||
*size = next
|
||||
root.SetChanged()
|
||||
}
|
||||
}
|
||||
if _, _, ended := handle.DragEnded(); ended {
|
||||
sound.Play(sound.Tick)
|
||||
kit.Log(name, "resized", "size", *size)
|
||||
}
|
||||
ui.Column(c).Grow(1).Basis(0).Children(second)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package layout
|
||||
|
||||
import "github.com/egoist/mygo/ui"
|
||||
|
||||
// SplitStack holds two panes stacked; *size is the first's height.
|
||||
func SplitStack(c *ui.Context, size *float32, first, second func()) ui.Element {
|
||||
return split(c, "SplitStack", true, size, first, second)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/sound"
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestSplitPaneDrags(t *testing.T) {
|
||||
size, changes := float32(100), 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
if SplitPane(c, &size, func() { ui.Text(c, "Left") }, func() { ui.Text(c, "Right") }).Changed() {
|
||||
changes++
|
||||
}
|
||||
}, 400, 200)
|
||||
ticks := count(sound.Tick)
|
||||
tt.Press(104, 100)
|
||||
tt.Move(130, 100)
|
||||
tt.Move(154, 100)
|
||||
tt.Release(154, 100)
|
||||
if size != 150 || changes == 0 || count(sound.Tick) != ticks+1 {
|
||||
t.Errorf("size %v, changes %d, ticks %d", size, changes, count(sound.Tick)-ticks)
|
||||
}
|
||||
if x := find(t, tt, "Right").X; x < 150 {
|
||||
t.Errorf("right pane at %v", x)
|
||||
}
|
||||
tt.Press(154, 100)
|
||||
tt.Move(100, 100)
|
||||
tt.Move(-50, 100)
|
||||
tt.Release(-50, 100)
|
||||
if size != minPane {
|
||||
t.Errorf("size %v, want clamp to %d", size, minPane)
|
||||
}
|
||||
panics(t, "nil size", func() { ui.Render(func(c *ui.Context) { SplitPane(c, nil, nil, nil) }, 10, 10, 1) })
|
||||
}
|
||||
|
||||
func TestSplitStackDrags(t *testing.T) {
|
||||
size := float32(80)
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
SplitStack(c, &size, func() { ui.Text(c, "Top") }, func() { ui.Text(c, "Bottom") })
|
||||
}, 300, 300)
|
||||
ticks := count(sound.Tick)
|
||||
tt.Press(150, 84)
|
||||
tt.Move(150, 120)
|
||||
tt.Move(150, 144)
|
||||
tt.Release(150, 144)
|
||||
if size != 140 || count(sound.Tick) != ticks+1 {
|
||||
t.Errorf("size %v, ticks %d", size, count(sound.Tick)-ticks)
|
||||
}
|
||||
tt.Press(150, 144)
|
||||
tt.Move(150, 400)
|
||||
tt.Move(150, 600)
|
||||
tt.Release(150, 600)
|
||||
if want := 300 - 8 - float32(minPane); size != want {
|
||||
t.Errorf("size %v, want clamp to %v", size, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSidebarLayoutToggles(t *testing.T) {
|
||||
open, changes := true, 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
if SidebarLayout(c, &open, func() { ui.Text(c, "Side") }, func() { ui.Text(c, "Main") }).Changed() {
|
||||
changes++
|
||||
}
|
||||
}, 600, 300)
|
||||
before, toggles := find(t, tt, "Main").X, count(sound.Toggle)
|
||||
if err := tt.Click("Toggle sidebar"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if open || changes != 1 || count(sound.Toggle) != toggles+1 {
|
||||
t.Errorf("open %v, changes %d, toggles %d", open, changes, count(sound.Toggle)-toggles)
|
||||
}
|
||||
tt.Advance(80 * time.Millisecond)
|
||||
mid := find(t, tt, "Main").X
|
||||
settles(t, tt, "SidebarLayout width")
|
||||
if after := find(t, tt, "Main").X; !(before > mid && mid > after) {
|
||||
t.Errorf("main did not slide: %v, %v, %v", before, mid, after)
|
||||
}
|
||||
panics(t, "nil open", func() { ui.Render(func(c *ui.Context) { SidebarLayout(c, nil, nil, nil) }, 10, 10, 1) })
|
||||
}
|
||||
|
||||
func TestSplitPaneStillPressIsNoChange(t *testing.T) {
|
||||
size, changes := float32(100), 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
if SplitPane(c, &size, func() { ui.Text(c, "Left") }, func() { ui.Text(c, "Right") }).Changed() {
|
||||
changes++
|
||||
}
|
||||
}, 400, 200)
|
||||
for range 3 {
|
||||
tt.Press(104, 100)
|
||||
tt.Frame()
|
||||
tt.Release(104, 100)
|
||||
}
|
||||
if size != 100 || changes != 0 {
|
||||
t.Errorf("still presses: size %v, %d changes", size, changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitPaneNarrowKeepsMinimum(t *testing.T) {
|
||||
size := float32(minPane)
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
SplitPane(c, &size, func() {}, func() {})
|
||||
}, 60, 100)
|
||||
tt.Press(44, 50)
|
||||
tt.Move(50, 50)
|
||||
tt.Release(50, 50)
|
||||
if size != minPane {
|
||||
t.Errorf("size %v, want %d", size, minPane)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitStackKeepsCallerKey(t *testing.T) {
|
||||
order, ids := []string{"a", "b"}, map[string]uint64{}
|
||||
sizes := map[string]*float32{"a": new(float32(60)), "b": new(float32(60))}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
ui.Column(c).Fill().Children(func() {
|
||||
for _, k := range order {
|
||||
c.Key(k)
|
||||
ids[k] = SplitStack(c, sizes[k], func() {}, func() {}).ID()
|
||||
}
|
||||
})
|
||||
}, 300, 300)
|
||||
a, b := ids["a"], ids["b"]
|
||||
order = []string{"b", "a"}
|
||||
tt.Frame()
|
||||
if ids["a"] != a || ids["b"] != b {
|
||||
t.Errorf("ids moved with order: a %d→%d, b %d→%d", a, ids["a"], b, ids["b"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package layout
|
||||
|
||||
import "github.com/egoist/mygo/ui"
|
||||
|
||||
// Stack is a column gapped in theme units; size animates.
|
||||
func Stack(c *ui.Context, gap float32, content func()) ui.Element {
|
||||
if !finite(gap) || gap < 0 {
|
||||
panic("layout: Stack gap must be finite and non-negative")
|
||||
}
|
||||
return ui.Column(c).Gap(c.Theme().Space(gap)).Transition(ui.ElementTransition{Size: true}).Children(content)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/kit"
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// StickyHeader tops a scroll; its shadow fades in once scrolled.
|
||||
func StickyHeader(c *ui.Context, header, content func()) ui.Element {
|
||||
t := c.Theme()
|
||||
root := ui.Column(c).Clip()
|
||||
st := ui.Local(root, "scroll", func() ui.ScrollState { return ui.ScrollState{} })
|
||||
kit.Settle(c, root, []float32{st.Y}, func() []float32 { return []float32{st.Y} })
|
||||
return root.Children(func() {
|
||||
head := ui.Row(c).FillWidth().Gap(t.Space(2)).Padding(t.Space(2), t.Space(3)).AlignItems(ui.Center).
|
||||
Background(t.Background)
|
||||
target := float32(0)
|
||||
if st.Y > 0 {
|
||||
target = 1
|
||||
}
|
||||
a := head.AnimateTo("shadow", target, ui.Snappy)
|
||||
head.Shadow(0, 1, 4, 0, kit.Shade(0.1*a)).Children(header)
|
||||
ui.Scroll(c).Grow(1).Basis(0).TrackScroll(st).Children(content)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/kit"
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// Well is a sunken box; its content fades in.
|
||||
func Well(c *ui.Context, content func()) ui.Element {
|
||||
t := c.Theme()
|
||||
return ui.Column(c).Padding(t.Space(4)).Background(t.Surface).Radius(t.Radius).
|
||||
InsetShadow(0, 1, 4, 0, kit.Shade(0.12)).Children(func() {
|
||||
kit.Arrive(ui.Column(c).Gap(t.Space(2)), ui.Pose{Fade: 1}, 0).Children(content)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/a/*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
/
|
||||
Cache-Control: no-cache
|
||||
/index.html
|
||||
Cache-Control: no-cache
|
||||
@@ -0,0 +1,36 @@
|
||||
// Builds web/dist; files under a/ are named by content.
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { copyFileSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const web = dirname(fileURLToPath(import.meta.url))
|
||||
const root = dirname(web)
|
||||
const dist = join(web, 'dist')
|
||||
|
||||
rmSync(dist, { recursive: true, force: true })
|
||||
mkdirSync(join(dist, 'a'), { recursive: true })
|
||||
execFileSync('go', ['build', '-trimpath', '-ldflags=-s -w', '-tags', 'mygo_noinspector', '-o',
|
||||
join(dist, 'app.wasm'), './cmd/gallery'], { cwd: root, stdio: 'inherit', env: { ...process.env, GOOS: 'js', GOARCH: 'wasm' } })
|
||||
const goroot = execFileSync('go', ['env', 'GOROOT'], { encoding: 'utf8' }).trim()
|
||||
copyFileSync(join(goroot, 'lib', 'wasm', 'wasm_exec.js'), join(dist, 'wasm_exec.js'))
|
||||
|
||||
// name moves a file into a/ by its content hash.
|
||||
function name (base, file, ext) {
|
||||
const hash = createHash('sha256').update(readFileSync(join(dist, file))).digest('hex').slice(0, 12)
|
||||
const to = `a/${base}.${hash}.${ext}`
|
||||
renameSync(join(dist, file), join(dist, to))
|
||||
return to
|
||||
}
|
||||
|
||||
const size = statSync(join(dist, 'app.wasm')).size
|
||||
const wasm = name('app', 'app.wasm', 'wasm')
|
||||
const exec = name('wasm_exec', 'wasm_exec.js', 'js')
|
||||
writeFileSync(join(dist, 'main.js'),
|
||||
readFileSync(join(web, 'main.js'), 'utf8').replaceAll('__WASM__', wasm).replaceAll('__SIZE__', String(size)))
|
||||
const main = name('main', 'main.js', 'js')
|
||||
writeFileSync(join(dist, 'index.html'),
|
||||
readFileSync(join(web, 'index.html'), 'utf8').replace('__EXEC__', exec).replace('__MAIN__', main))
|
||||
copyFileSync(join(web, '_headers'), join(dist, '_headers'))
|
||||
console.log(`web/dist built: ${main} ${wasm} (${size} bytes)`)
|
||||
@@ -0,0 +1,84 @@
|
||||
// Ships files first, then the page, so none goes missing.
|
||||
import { execSync } from 'node:child_process'
|
||||
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const web = dirname(fileURLToPath(import.meta.url))
|
||||
const site = 'https://crychicui.zhangyanghaha0407.workers.dev'
|
||||
// --first deploys a Worker that has no live page yet.
|
||||
const first = process.argv.includes('--first')
|
||||
|
||||
function fail (msg) {
|
||||
console.error('deploy: ' + msg)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const run = (cmd, env = {}) => execSync(cmd, { cwd: web, stdio: 'inherit', env: { ...process.env, ...env } })
|
||||
const out = cmd => execSync(cmd, { cwd: web, encoding: 'utf8' })
|
||||
|
||||
// patient retries check every 2 s for a minute.
|
||||
async function patient (what, check) {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
if (await check()) return
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
}
|
||||
fail('gave up waiting for ' + what)
|
||||
}
|
||||
|
||||
async function get (url) {
|
||||
const res = await fetch(url, { cache: 'no-store' })
|
||||
if (!res.ok) fail(`${url}: HTTP ${res.status}`)
|
||||
return Buffer.from(await res.arrayBuffer())
|
||||
}
|
||||
|
||||
// upload makes a version from the assets in dir.
|
||||
function upload (dir, message) {
|
||||
const text = out(`npx wrangler versions upload --assets "${dir}" --message "${message}"`)
|
||||
console.error(text)
|
||||
const id = text.match(/^Worker Version ID: (\S+)/m)?.[1]
|
||||
const preview = text.match(/^Version Preview URL: (\S+)/m)?.[1]
|
||||
if (!id || !preview) fail('wrangler printed no version id or preview URL')
|
||||
return { id, preview }
|
||||
}
|
||||
|
||||
const promote = (id, message) => run(`npx wrangler versions deploy "${id}@100%" --yes --message "${message}"`)
|
||||
const live = async main => (await fetch(site + '/', { cache: 'no-store' }).then(r => r.text())).includes(main)
|
||||
|
||||
if (out('git status --porcelain').trim()) fail('commit first; each version names its commit')
|
||||
run('node build.mjs')
|
||||
const rev = out('git rev-parse --short HEAD').trim()
|
||||
const mainOf = html => html.match(/src="(a\/main\.[0-9a-f]+\.js)"/)?.[1]
|
||||
const main = mainOf(readFileSync(join(web, 'dist', 'index.html'), 'utf8'))
|
||||
if (!main) fail('dist/index.html names no main script')
|
||||
|
||||
if (first) {
|
||||
run(`npx wrangler deploy --message "${rev}"`)
|
||||
} else {
|
||||
// The live build's files ride along, for pages already open.
|
||||
const old = (await get(site + '/')).toString()
|
||||
const oldMain = mainOf(old)
|
||||
if (!oldMain) fail('the live page names no main script')
|
||||
for (const f of old.matchAll(/src="(a\/[^"]+)"/g)) writeFileSync(join(web, 'dist', f[1]), await get(`${site}/${f[1]}`))
|
||||
const wasm = readFileSync(join(web, 'dist', oldMain), 'utf8').match(/fetch\('(a\/app\.[0-9a-f]+\.wasm)'\)/)?.[1]
|
||||
if (!wasm) fail('the live main script names no wasm')
|
||||
writeFileSync(join(web, 'dist', wasm), await get(`${site}/${wasm}`))
|
||||
const stage = mkdtempSync(join(tmpdir(), 'crychic-'))
|
||||
mkdirSync(join(stage, 'a'))
|
||||
writeFileSync(join(stage, 'index.html'), old)
|
||||
cpSync(join(web, 'dist', 'a'), join(stage, 'a'), { recursive: true })
|
||||
cpSync(join(web, 'dist', '_headers'), join(stage, '_headers'))
|
||||
// Step one: the live page beside the new files.
|
||||
const files = upload(stage, `${rev} files`)
|
||||
promote(files.id, `${rev} files`)
|
||||
await patient('the new files to go live', async () => (await fetch(`${site}/${main}`, { cache: 'no-store' })).ok)
|
||||
rmSync(stage, { recursive: true, force: true })
|
||||
// Step two: the new page, tested on its preview first.
|
||||
const page = upload(join(web, 'dist'), rev)
|
||||
run('npx playwright test', { BASE_URL: page.preview })
|
||||
promote(page.id, rev)
|
||||
}
|
||||
await patient('the new page to go live', () => live(main))
|
||||
run('npx playwright test', { BASE_URL: site })
|
||||
console.log(`deploy: ${rev} live at ${site}`)
|
||||
@@ -0,0 +1,38 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>CrychicUI</title>
|
||||
<meta name="description" content="1500 MyGo native UI components with motion and sound, drawn by Go compiled to WebAssembly.">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; overflow: hidden; background: light-dark(#ffffff, #18181b); }
|
||||
#mygo { position: fixed; inset: 0; }
|
||||
#loading { position: fixed; inset: 0; display: grid; place-items: center; font: 14px system-ui, sans-serif;
|
||||
color: light-dark(#71717a, #a1a1aa); }
|
||||
#track { width: 240px; height: 4px; margin-top: 12px; border-radius: 999px; background: light-dark(#f4f4f5, #27272a); }
|
||||
#fill { width: 0; height: 100%; border-radius: 999px; background: #2563eb; transition: width 120ms ease-out; }
|
||||
#over { position: fixed; inset: 0; display: grid; place-items: center; padding: 24px;
|
||||
background: light-dark(#ffffff, #18181b); color: light-dark(#18181b, #f4f4f5); font: 14px system-ui, sans-serif; }
|
||||
#over[hidden] { display: none; }
|
||||
#over h1 { font-size: 20px; margin: 0 0 12px; }
|
||||
#over pre { max-width: 80vw; max-height: 50vh; overflow: auto; white-space: pre-wrap; padding: 12px;
|
||||
border: 1px solid light-dark(#d9d9de, #3f3f46); border-radius: 6px; font: 12px ui-monospace, monospace; }
|
||||
#over button { font: inherit; padding: 6px 14px; border: 0; border-radius: 6px; background: #2563eb; color: #fff;
|
||||
cursor: pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="mygo"></div>
|
||||
<div id="loading" role="progressbar" aria-label="Loading" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
|
||||
<div>Loading CrychicUI<div id="track"><div id="fill"></div></div></div>
|
||||
</div>
|
||||
<div id="over" hidden role="alertdialog" aria-labelledby="over-title">
|
||||
<div><h1 id="over-title">The gallery stopped</h1><pre id="why"></pre><button id="reload">Reload</button></div>
|
||||
</div>
|
||||
<noscript>This gallery runs Go compiled to WebAssembly. Turn on JavaScript to see it.</noscript>
|
||||
<script src="__EXEC__"></script>
|
||||
<script src="__MAIN__"></script>
|
||||
</body>
|
||||
</html>
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
// Streams the wasm, runs Go, shows crashes.
|
||||
const stderr = []
|
||||
|
||||
function crash (msg) {
|
||||
const why = [msg, stderr.join('').slice(-4000)].filter(Boolean).join('\n\n')
|
||||
console.error('crychic: crash', why)
|
||||
document.getElementById('why').textContent = why
|
||||
document.getElementById('over').hidden = false
|
||||
}
|
||||
document.getElementById('reload').onclick = () => location.reload()
|
||||
|
||||
async function load () {
|
||||
if (!globalThis.Go || !globalThis.fs) throw new Error('wasm_exec.js did not load.')
|
||||
const write = globalThis.fs.writeSync.bind(globalThis.fs)
|
||||
globalThis.fs.writeSync = (fd, buf) => {
|
||||
if (fd === 2) stderr.push(new TextDecoder().decode(buf))
|
||||
return write(fd, buf)
|
||||
}
|
||||
const t0 = performance.now()
|
||||
const res = await fetch('__WASM__')
|
||||
if (!res.ok) throw new Error(`${res.url}: HTTP ${res.status}`)
|
||||
const total = Number('__SIZE__')
|
||||
const reader = res.body.getReader()
|
||||
const parts = []
|
||||
let got = 0
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
parts.push(value)
|
||||
got += value.length
|
||||
const pct = Math.min(100, Math.floor(got / total * 100))
|
||||
document.getElementById('fill').style.width = pct + '%'
|
||||
document.getElementById('loading').setAttribute('aria-valuenow', pct)
|
||||
}
|
||||
const bytes = new Uint8Array(got)
|
||||
let off = 0
|
||||
for (const p of parts) {
|
||||
bytes.set(p, off)
|
||||
off += p.length
|
||||
}
|
||||
const go = new Go()
|
||||
const { instance } = await WebAssembly.instantiate(bytes, go.importObject)
|
||||
go.run(instance).then(() => crash('The Go program exited.'), (err) => crash(String(err && err.stack || err)))
|
||||
await new Promise((resolve, reject) => {
|
||||
const until = performance.now() + 30000
|
||||
const poll = () => {
|
||||
if (window.__crychic?.ready) return resolve()
|
||||
if (performance.now() > until) return reject(new Error('No frame within 30 s.'))
|
||||
requestAnimationFrame(poll)
|
||||
}
|
||||
poll()
|
||||
})
|
||||
const first = Math.round(performance.now() - t0)
|
||||
document.getElementById('loading').remove()
|
||||
window.__crychicBoot = { wasm: '__WASM__', bytes: got, first }
|
||||
console.info(`crychic: wasm ${(got / 1e6).toFixed(1)} MB, first frame ${first} ms`)
|
||||
}
|
||||
|
||||
load().catch((err) => crash(String(err && err.stack || err)))
|
||||
Generated
+1636
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "crychicui-web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"test": "node build.mjs && playwright test",
|
||||
"deploy": "node deploy.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.64.0",
|
||||
"wrangler": "4.149.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
// BASE_URL runs the tests against a deployed site.
|
||||
const url = process.env.BASE_URL
|
||||
|
||||
export default defineConfig({
|
||||
testDir: 'tests',
|
||||
timeout: 90000,
|
||||
use: { baseURL: url || 'http://localhost:4173', viewport: { width: 1280, height: 820 } },
|
||||
webServer: url
|
||||
? undefined
|
||||
: { command: 'python3 -m http.server 4173 --directory dist', url: 'http://localhost:4173', reuseExistingServer: true }
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { brotliCompressSync } from 'node:zlib'
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
// pageErrors keeps each page's console and page errors.
|
||||
const pageErrors = new WeakMap()
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const seen = []
|
||||
pageErrors.set(page, seen)
|
||||
page.on('console', m => { if (m.type() === 'error') seen.push(m.text()) })
|
||||
page.on('pageerror', e => seen.push(e.message))
|
||||
})
|
||||
|
||||
// Every page ends free of errors, unless it expects them.
|
||||
test.afterEach(async ({ page }) => {
|
||||
if (test.info().annotations.some(a => a.type === 'expects errors')) return
|
||||
expect(pageErrors.get(page)).toEqual([])
|
||||
})
|
||||
|
||||
test('a missing loader shows the crash overlay', async ({ page }) => {
|
||||
test.info().annotations.push({ type: 'expects errors' })
|
||||
await page.route('**/a/wasm_exec.*.js', route => route.abort())
|
||||
await page.goto('/')
|
||||
await expect(page.locator('#over')).toBeVisible()
|
||||
await expect(page.locator('#why')).toContainText('wasm_exec.js did not load.')
|
||||
})
|
||||
|
||||
async function open (page, hash = '') {
|
||||
await page.goto('/' + hash)
|
||||
await page.waitForFunction(() => window.__crychicBoot)
|
||||
}
|
||||
|
||||
const where = page => page.evaluate(() => window.__crychic.page())
|
||||
|
||||
test('boots, reports, and logs its budget', async ({ page, request }) => {
|
||||
await open(page)
|
||||
expect(await where(page)).toBe('/')
|
||||
const stats = await page.evaluate(() => window.__crychic.stats())
|
||||
expect(stats.categories).toBe(38)
|
||||
expect(Array.isArray(await page.evaluate(() => window.__crychic.recent()))).toBe(true)
|
||||
expect(await page.locator('#mygo canvas').count()).toBeGreaterThan(0)
|
||||
const boot = await page.evaluate(() => window.__crychicBoot)
|
||||
const wasm = await (await request.get('/' + boot.wasm)).body()
|
||||
const br = brotliCompressSync(wasm).length
|
||||
console.log(`budget: wasm ${(wasm.length / 1e6).toFixed(1)} MB, brotli ${(br / 1e6).toFixed(1)} MB, ` +
|
||||
`first frame ${boot.first} ms, ${stats.components} components`)
|
||||
})
|
||||
|
||||
test('deep link opens a category', async ({ page }) => {
|
||||
await open(page, '#/c/layout')
|
||||
expect(await where(page)).toBe('/c/layout')
|
||||
})
|
||||
|
||||
test('the hash and history drive the router', async ({ page }) => {
|
||||
await open(page)
|
||||
await page.evaluate(() => { location.hash = '#/c/chart' })
|
||||
await page.waitForFunction(() => window.__crychic.page() === '/c/chart')
|
||||
await page.goBack()
|
||||
await page.waitForFunction(() => window.__crychic.page() === '/')
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/wrangler/config-schema.json",
|
||||
"name": "crychicui",
|
||||
"compatibility_date": "2026-10-01",
|
||||
"workers_dev": true,
|
||||
"preview_urls": true,
|
||||
"assets": { "directory": "./dist" }
|
||||
}
|
||||
Reference in new issue
Block a user