diff --git a/AGENTS.md b/AGENTS.md index 17fa938..972feb8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/gallery/demos.go b/gallery/demos.go index 6594623..c38c3bb 100644 --- a/gallery/demos.go +++ b/gallery/demos.go @@ -1,3 +1,5 @@ package gallery -var entries []Entry +import "slices" + +var entries = slices.Concat(layoutEntries) diff --git a/gallery/demos_layout.go b/gallery/demos_layout.go new file mode 100644 index 0000000..dfabf6e --- /dev/null +++ b/gallery/demos_layout.go @@ -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) +} diff --git a/gallery/demos_layout_test.go b/gallery/demos_layout_test.go new file mode 100644 index 0000000..d636030 --- /dev/null +++ b/gallery/demos_layout_test.go @@ -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()) + } +} diff --git a/gallery/gallery_test.go b/gallery/gallery_test.go index 8b0437c..adb64fb 100644 --- a/gallery/gallery_test.go +++ b/gallery/gallery_test.go @@ -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()) + } + } + } +} diff --git a/internal/archtest/arch_test.go b/internal/archtest/arch_test.go index 8b0ac92..621b347 100644 --- a/internal/archtest/arch_test.go +++ b/internal/archtest/arch_test.go @@ -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}, } diff --git a/internal/archtest/rules_test.go b/internal/archtest/rules_test.go index 0c573c8..92bea1d 100644 --- a/internal/archtest/rules_test.go +++ b/internal/archtest/rules_test.go @@ -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) } diff --git a/kit/kit.go b/kit/kit.go index 4c2b4c3..c9ff450 100644 --- a/kit/kit.go +++ b/kit/kit.go @@ -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) diff --git a/kit/kit_test.go b/kit/kit_test.go index ff47ba6..05ae0d9 100644 --- a/kit/kit_test.go +++ b/kit/kit_test.go @@ -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) + } +} diff --git a/layout/app_frame.go b/layout/app_frame.go new file mode 100644 index 0000000..0e595d0 --- /dev/null +++ b/layout/app_frame.go @@ -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) + }) +} diff --git a/layout/aspect_box.go b/layout/aspect_box.go new file mode 100644 index 0000000..113f6c6 --- /dev/null +++ b/layout/aspect_box.go @@ -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) +} diff --git a/layout/auto_grid.go b/layout/auto_grid.go new file mode 100644 index 0000000..2e6918d --- /dev/null +++ b/layout/auto_grid.go @@ -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) + } + }) +} diff --git a/layout/bento.go b/layout/bento.go new file mode 100644 index 0000000..0a82869 --- /dev/null +++ b/layout/bento.go @@ -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) + } + }) +} diff --git a/layout/card.go b/layout/card.go new file mode 100644 index 0000000..346e752 --- /dev/null +++ b/layout/card.go @@ -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) +} diff --git a/layout/centered.go b/layout/centered.go new file mode 100644 index 0000000..89e1971 --- /dev/null +++ b/layout/centered.go @@ -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) + }) +} diff --git a/layout/check.go b/layout/check.go new file mode 100644 index 0000000..120bf1e --- /dev/null +++ b/layout/check.go @@ -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) } diff --git a/layout/cluster.go b/layout/cluster.go new file mode 100644 index 0000000..dd97f9d --- /dev/null +++ b/layout/cluster.go @@ -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) + } + }) +} diff --git a/layout/columns.go b/layout/columns.go new file mode 100644 index 0000000..af15110 --- /dev/null +++ b/layout/columns.go @@ -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) + } + }) +} diff --git a/layout/container.go b/layout/container.go new file mode 100644 index 0000000..ce8f091 --- /dev/null +++ b/layout/container.go @@ -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) +} diff --git a/layout/containers_test.go b/layout/containers_test.go new file mode 100644 index 0000000..d12364e --- /dev/null +++ b/layout/containers_test.go @@ -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) }) + } +} diff --git a/layout/doc.go b/layout/doc.go new file mode 100644 index 0000000..a81fbda --- /dev/null +++ b/layout/doc.go @@ -0,0 +1,2 @@ +// Package layout arranges other elements. +package layout diff --git a/layout/frame.go b/layout/frame.go new file mode 100644 index 0000000..0a188ba --- /dev/null +++ b/layout/frame.go @@ -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) + }) +} diff --git a/layout/grid_test.go b/layout/grid_test.go new file mode 100644 index 0000000..add775a --- /dev/null +++ b/layout/grid_test.go @@ -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") + } +} diff --git a/layout/helpers_test.go b/layout/helpers_test.go new file mode 100644 index 0000000..69a8e63 --- /dev/null +++ b/layout/helpers_test.go @@ -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) } diff --git a/layout/holy_grail.go b/layout/holy_grail.go new file mode 100644 index 0000000..3371cb2 --- /dev/null +++ b/layout/holy_grail.go @@ -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) + } + }) +} diff --git a/layout/inline.go b/layout/inline.go new file mode 100644 index 0000000..5def7c9 --- /dev/null +++ b/layout/inline.go @@ -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) +} diff --git a/layout/inset.go b/layout/inset.go new file mode 100644 index 0000000..bc8d09b --- /dev/null +++ b/layout/inset.go @@ -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) +} diff --git a/layout/layers.go b/layout/layers.go new file mode 100644 index 0000000..4555a35 --- /dev/null +++ b/layout/layers.go @@ -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) + } + }) +} diff --git a/layout/masonry.go b/layout/masonry.go new file mode 100644 index 0000000..cb07a8c --- /dev/null +++ b/layout/masonry.go @@ -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 +} diff --git a/layout/panel.go b/layout/panel.go new file mode 100644 index 0000000..2a9342a --- /dev/null +++ b/layout/panel.go @@ -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) +} diff --git a/layout/scroll_area.go b/layout/scroll_area.go new file mode 100644 index 0000000..0e16227 --- /dev/null +++ b/layout/scroll_area.go @@ -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) + }) +} diff --git a/layout/scroll_test.go b/layout/scroll_test.go new file mode 100644 index 0000000..a101109 --- /dev/null +++ b/layout/scroll_test.go @@ -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") + } +} diff --git a/layout/scroller.go b/layout/scroller.go new file mode 100644 index 0000000..7b13ea1 --- /dev/null +++ b/layout/scroller.go @@ -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 +} diff --git a/layout/section.go b/layout/section.go new file mode 100644 index 0000000..e8fd5a6 --- /dev/null +++ b/layout/section.go @@ -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) + }) +} diff --git a/layout/sidebar_layout.go b/layout/sidebar_layout.go new file mode 100644 index 0000000..46bf4cb --- /dev/null +++ b/layout/sidebar_layout.go @@ -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) + }) + }) +} diff --git a/layout/split_pane.go b/layout/split_pane.go new file mode 100644 index 0000000..72441ef --- /dev/null +++ b/layout/split_pane.go @@ -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) + }) +} diff --git a/layout/split_stack.go b/layout/split_stack.go new file mode 100644 index 0000000..e060940 --- /dev/null +++ b/layout/split_stack.go @@ -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) +} diff --git a/layout/split_test.go b/layout/split_test.go new file mode 100644 index 0000000..138e32a --- /dev/null +++ b/layout/split_test.go @@ -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"]) + } +} diff --git a/layout/stack.go b/layout/stack.go new file mode 100644 index 0000000..e0db506 --- /dev/null +++ b/layout/stack.go @@ -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) +} diff --git a/layout/sticky_header.go b/layout/sticky_header.go new file mode 100644 index 0000000..e0b5d9f --- /dev/null +++ b/layout/sticky_header.go @@ -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) + }) +} diff --git a/layout/well.go b/layout/well.go new file mode 100644 index 0000000..0e37c27 --- /dev/null +++ b/layout/well.go @@ -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) + }) +}