Add layout components 0001-0025

Stack through StickyHeader, with behavior tests and gallery demos.
kit gains Arrive, Settle (paint-time layout check) and Shade.
Several actions return named ui.Element results; mygo vet forbids
elements in structs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-17 02:49:32 -04:00
1 parent b34562333b
commit 4efaf2680f
41 files changed
+1765 -11

No files matched your search

+5 -2
View File
@@ -51,8 +51,8 @@ health, home, marketing, content, desktop, geo · 6 gallery · 7 cmd/gallery
## Components ## Components
- `func Name(c *ui.Context, required...) ui.Element`; optional knobs in a final `NameOptions` (never variadic). Zero - `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 options give the default look. Containers take content as a final `func()`, after any options. Several actions:
named `ui.Element` fields. 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. - 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`. - 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. - 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. - 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. - 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. - 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 - Files under 500 lines (lock files exempt). Comments one line, ten words at most, only when needed. No dead
code, no versioned APIs. code, no versioned APIs.
- A category file holds one exported component, named in snake case (`KPIGrid` in `kpi_grid.go`), or private - A category file holds one exported component, named in snake case (`KPIGrid` in `kpi_grid.go`), or private
+3 -1
View File
@@ -1,3 +1,5 @@
package gallery package gallery
var entries []Entry import "slices"
var entries = slices.Concat(layoutEntries)
+319
View File
@@ -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)
}
+21
View File
@@ -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())
}
}
+22
View File
@@ -183,3 +183,25 @@ func TestCategories(t *testing.T) {
t.Errorf("categories = %d, want 38", len(Categories)) 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())
}
}
}
}
+11 -2
View File
@@ -116,8 +116,13 @@ func TestRulesCatch(t *testing.T) {
{"directive", "", "package x\n// Files.\n//go:generate echo\n", comments, 0}, {"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" + {"good", "button/icon_button.go", head + "func IconButton(c *ui.Context, o IconButtonOptions) ui.Element\n" +
"type IconButtonOptions struct{}\n", components, 0}, "type IconButtonOptions struct{}\n", components, 0},
{"result", "button/split.go", head + "func Split(c *ui.Context) SplitResult\ntype SplitResult struct{}\n", {"parts", "button/split.go", head + "func Split(c *ui.Context) (root, menu ui.Element)\n", components, 0},
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", {"two", "button/a.go", head + "func A(c *ui.Context) ui.Element\nfunc B(c *ui.Context) ui.Element\n",
components, 1}, components, 1},
{"file name", "button/b.go", head + "func KPIGrid(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}, components, 1},
{"returns", "button/a.go", head + "func A(c *ui.Context) int\n", 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}, {"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", {"grouped options", "button/a.go", head + "func A(c *ui.Context, x, y AOptions) ui.Element\n",
components, 1}, components, 1},
} }
+17 -5
View File
@@ -107,21 +107,29 @@ func signature(fn *ast.FuncDecl) string {
ps[0].Names[0].Name != "c" { ps[0].Names[0].Name != "c" {
return "must take c *ui.Context first" 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 { for i, p := range ps {
ts := typeString(p.Type) ts := typeString(p.Type)
if _, ok := p.Type.(*ast.Ellipsis); ok { if _, ok := p.Type.(*ast.Ellipsis); ok {
return "must not be variadic" 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" return "options must be a final " + name + "Options"
} }
} }
rs := fn.Type.Results rs := fn.Type.Results
if rs == nil || rs.NumFields() != 1 { if rs == nil || rs.NumFields() == 0 {
return "must return one value" return "must return ui.Element"
} }
if r := typeString(rs.List[0].Type); r != "ui.Element" && r != name+"Result" { for _, r := range rs.List {
return "must return ui.Element or " + name + "Result" 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 "" return ""
} }
@@ -134,6 +142,10 @@ func typeString(e ast.Expr) string {
return "*" + typeString(t.X) return "*" + typeString(t.X)
case *ast.SelectorExpr: case *ast.SelectorExpr:
return typeString(t.X) + "." + t.Sel.Name 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) return fmt.Sprintf("%T", e)
} }
+26 -1
View File
@@ -3,6 +3,7 @@ package kit
import ( import (
"log/slog" "log/slog"
"slices"
"time" "time"
"git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/sound" "git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI/sound"
@@ -27,6 +28,18 @@ func Lift(e ui.Element) ui.Element {
return e.AnimatePose(p, ui.Snappy) 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 popSpring = ui.Bouncy
var ( var (
@@ -66,9 +79,21 @@ func Changed(e ui.Element, cue sound.Cue) bool {
func Card(c *ui.Context) ui.Element { func Card(c *ui.Context) ui.Element {
t := c.Theme() t := c.Theme()
return ui.Column(c).Background(t.Background).Border(1, t.Border).Radius(t.Radius). 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. // Muted returns secondary text.
func Muted(c *ui.Context, s string) ui.Element { func Muted(c *ui.Context, s string) ui.Element {
return ui.Text(c, s).TextColor(c.Theme().TextMuted) return ui.Text(c, s).TextColor(c.Theme().TextMuted)
+60
View File
@@ -200,3 +200,63 @@ func TestLog(t *testing.T) {
t.Errorf("log = %q", s) 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)
}
}
+22
View File
@@ -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)
})
}
+12
View File
@@ -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)
}
+22
View File
@@ -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)
}
})
}
+30
View File
@@ -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)
}
})
}
+12
View File
@@ -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)
}
+13
View File
@@ -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)
})
}
+6
View File
@@ -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) }
+15
View File
@@ -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)
}
})
}
+16
View File
@@ -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)
}
})
}
+22
View File
@@ -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)
}
+253
View File
@@ -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) })
}
}
+2
View File
@@ -0,0 +1,2 @@
// Package layout arranges other elements.
package layout
+13
View File
@@ -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)
})
}
+149
View File
@@ -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")
}
}
+64
View File
@@ -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) }
+38
View File
@@ -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)
}
})
}
+11
View File
@@ -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)
}
+10
View File
@@ -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)
}
+24
View File
@@ -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)
}
})
}
+41
View File
@@ -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
}
+10
View File
@@ -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)
}
+31
View File
@@ -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)
})
}
+100
View File
@@ -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")
}
}
+38
View File
@@ -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
}
+20
View File
@@ -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)
})
}
+42
View File
@@ -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)
})
})
}
+75
View File
@@ -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)
})
}
+8
View File
@@ -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)
}
+131
View File
@@ -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"])
}
}
+11
View File
@@ -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)
}
+25
View File
@@ -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)
})
}
+15
View File
@@ -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)
})
}