From 9a7f5938920be4453be9add469f0928b05eb6cd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 11 Oct 2026 18:02:26 -0400 Subject: [PATCH] Add layout components 001-020 with demos and behavior tests --- AGENTS.md | 2 + gallery/demos_layout.go | 322 +++++++++++++++++++++++++++++++++ gallery/registry.go | 2 +- layout/appframe.go | 74 ++++++++ layout/aspectbox.go | 21 +++ layout/card.go | 51 ++++++ layout/crtframe.go | 38 ++++ layout/divider.go | 95 ++++++++++ layout/grid.go | 68 +++++++ layout/handheld.go | 95 ++++++++++ layout/internal_test.go | 90 ++++++++++ layout/layers.go | 19 ++ layout/layout.go | 64 +++++++ layout/layout_test.go | 385 ++++++++++++++++++++++++++++++++++++++++ layout/masonry.go | 67 +++++++ layout/panel.go | 41 +++++ layout/resizable.go | 83 +++++++++ layout/screen.go | 38 ++++ layout/scrollarea.go | 152 ++++++++++++++++ layout/section.go | 30 ++++ layout/split.go | 117 ++++++++++++ layout/statusbar.go | 34 ++++ layout/tiles.go | 27 +++ layout/titlebar.go | 89 ++++++++++ layout/viewport.go | 47 +++++ layout/window.go | 42 +++++ pixel/messages.go | 8 +- pixel/settings.go | 3 + web/tests/smoke.spec.js | 15 +- 29 files changed, 2110 insertions(+), 9 deletions(-) create mode 100644 gallery/demos_layout.go create mode 100644 layout/appframe.go create mode 100644 layout/aspectbox.go create mode 100644 layout/card.go create mode 100644 layout/crtframe.go create mode 100644 layout/divider.go create mode 100644 layout/grid.go create mode 100644 layout/handheld.go create mode 100644 layout/internal_test.go create mode 100644 layout/layers.go create mode 100644 layout/layout.go create mode 100644 layout/layout_test.go create mode 100644 layout/masonry.go create mode 100644 layout/panel.go create mode 100644 layout/resizable.go create mode 100644 layout/screen.go create mode 100644 layout/scrollarea.go create mode 100644 layout/section.go create mode 100644 layout/split.go create mode 100644 layout/statusbar.go create mode 100644 layout/tiles.go create mode 100644 layout/titlebar.go create mode 100644 layout/viewport.go create mode 100644 layout/window.go diff --git a/AGENTS.md b/AGENTS.md index 2f72c04..710af95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,8 @@ Imports point down only. Same-tier imports are forbidden. `internal/archtest` en - Signatures and results: PRD §5.1. Zero options give the default look. Sizes in options are dots. - `k := pixel.K(c)` once per component; layout sizes through `k.D(n)`. +- Containers take content as a final `func()`; interactive results are `NameResult`. +- Clip only boxes that sit on whole dots; split widths with whole-dot shares, not equal `Grow`. `Grow` starts from zero size, so never use it on a content-sized child. - Row builders that MyGo runs during layout (`List`, `GridView`, `Outline`) go through `pixel.Lazy(c, row)`. - Single-key shortcuts only while `pixel.Typing(c)` is false; MyGo routes shortcuts before a focused field. - Copy shown by a component comes from `k.Msg`; business copy comes from the caller. diff --git a/gallery/demos_layout.go b/gallery/demos_layout.go new file mode 100644 index 0000000..1ce6199 --- /dev/null +++ b/gallery/demos_layout.go @@ -0,0 +1,322 @@ +package gallery + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var layoutEntries = []Entry{ + {1, "Screen", "屏幕", "layout", "The root of a screen: Back fills the window, with an optional dithered vignette.", demoScreen}, + {2, "Window", "窗口", "layout", "A beveled window with a title tab, the shape of every RPG menu.", demoWindow}, + {3, "Panel", "面板", "layout", "A box framed raised, sunken, flat, double, window or notched.", demoPanel}, + {4, "Card", "卡片", "layout", "A raised panel with a header strip, a body and a footer.", demoCard}, + {5, "Section", "分节", "layout", "A titled group inside a dotted rule.", demoSection}, + {6, "Divider", "分隔线", "layout", "Rules across or down: solid, dotted, dashed, double, ornament.", demoDivider}, + {7, "ScrollArea", "滚动区", "layout", "A scroll view with a pixel scrollbar: arrows step, the track pages, the thumb drags.", demoScrollArea}, + {8, "Split", "分栏", "layout", "Two panes and a grip. Drag it, or focus it and use the arrow keys.", demoSplit}, + {9, "Grid", "网格", "layout", "Equal columns with solid or dotted lines between the cells.", demoGrid}, + {10, "Tiles", "平铺", "layout", "Equal tiles that wrap into rows as the width allows.", demoTiles}, + {11, "Masonry", "瀑布流", "layout", "Each item drops into the shortest column.", demoMasonry}, + {12, "AspectBox", "比例框", "layout", "Keeps a ratio in whole dots at whatever width it gets.", demoAspectBox}, + {13, "Layers", "图层", "layout", "Layers stacked in one box, each over the one before.", demoLayers}, + {14, "Viewport", "视口", "layout", "A fixed virtual resolution, drawn at the largest Dot that fits and letterboxed.", demoViewport}, + {15, "TitleBar", "标题栏", "layout", "Stripes, a title, a close box and a minimize box.", demoTitleBar}, + {16, "StatusBar", "状态栏", "layout", "Segments split by sunken dividers; the first one grows.", demoStatusBar}, + {17, "Resizable", "可调尺寸", "layout", "A box with a corner grip. Drag it, or focus it and use the arrow keys.", demoResizable}, + {18, "Handheld", "掌机", "layout", "A pocket console: a screen, a power LED, a D-pad, A and B.", demoHandheld}, + {19, "CRTFrame", "显像管", "layout", "A monitor bezel with scanlines over the picture.", demoCRTFrame}, + {20, "AppFrame", "应用框架", "layout", "Header, sidebar, main and footer; on a narrow screen the sidebar folds above.", demoAppFrame}, +} + +var monsters = []string{"Slime", "Bat", "Goblin", "Mimic", "Ghost", "Golem", "Drake", "Imp"} + +func demoScreen(c *ui.Context, log func(string)) { + k := pixel.K(c) + on := ui.Local(c.Root(), demoKey("screen"), func() bool { return true }) + ui.Column(c).Gap(k.D(6)).Children(func() { + ui.Box(c).FillWidth().Height(k.D(96)).Children(func() { + layout.Screen(c, layout.ScreenOptions{Vignette: *on, Padding: 12}, func() { + label(c, "A NEW SAVE FILE", pixel.Ink) + label(c, "Day 1. The cartridge starts.", pixel.Faint) + }) + }) + if button(c, "VIGNETTE") { + *on = !*on + log(fmt.Sprintf("vignette %v", *on)) + } + }) +} + +func demoWindow(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Row(c).Gap(k.D(8)).AlignItems(ui.Start).Children(func() { + layout.Window(c, layout.WindowOptions{Title: "ITEMS"}, func() { + for _, s := range []string{"Potion x3", "Ether x1", "Key x2"} { + label(c, s, pixel.Ink) + } + }) + layout.Window(c, layout.WindowOptions{}, func() { + label(c, "HP 32/40", pixel.Ink) + label(c, "MP 12/18", pixel.Ink) + }) + }) +} + +func demoPanel(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Row(c).Wrap().Gap(k.D(6)).Children(func() { + for i, name := range []string{"RAISED", "SUNKEN", "FLAT", "DOUBLE", "WINDOW", "NOTCHED"} { + layout.Panel(c, layout.PanelOptions{Style: pixel.FrameStyle(i)}, func() { label(c, name, pixel.Ink) }) + } + }) +} + +func demoCard(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Box(c).Width(k.D(200)).Children(func() { + layout.Card(c, layout.CardOptions{Title: "QUEST", Icon: icon.Scroll, Footer: func() { + if button(c, "ACCEPT") { + log("quest accepted") + } + if button(c, "LATER") { + log("quest saved for later") + } + }}, func() { + label(c, "Find the lost cartridge.", pixel.Ink) + label(c, "Reward: 300 G", pixel.Faint) + }) + }) +} + +func demoSection(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Box(c).Width(k.D(220)).Children(func() { + layout.Section(c, "Sound", layout.SectionOptions{}, func() { + label(c, "Music 8", pixel.Ink) + label(c, "Effects 5", pixel.Ink) + }) + }) +} + +func demoDivider(c *ui.Context, log func(string)) { + k := pixel.K(c) + names := []string{"SOLID", "DOTTED", "DASHED", "DOUBLE", "ORNAMENT"} + ui.Row(c).Gap(k.D(12)).AlignItems(ui.Stretch).Children(func() { + ui.Column(c).Grow(1).Gap(k.D(4)).Children(func() { + for i, n := range names { + label(c, n, pixel.Faint) + layout.Divider(c, layout.DividerOptions{Style: layout.DividerStyle(i)}) + } + }) + for i := range names { + layout.Divider(c, layout.DividerOptions{Vertical: true, Style: layout.DividerStyle(i), Slot: pixel.Accent}) + } + }) +} + +func demoScrollArea(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := ui.Local(c.Root(), demoKey("scroll"), func() ui.ScrollState { return ui.ScrollState{} }) + ui.Column(c).Gap(k.D(4)).Children(func() { + r := layout.ScrollArea(c, s, layout.ScrollAreaOptions{Label: "Bestiary"}, func() { + for i := range 40 { + label(c, fmt.Sprintf("No.%03d %s", i+1, monsters[i%len(monsters)]), pixel.Ink) + } + }) + r.Element.Size(k.D(200), k.D(112)) + if r.Changed { + log(fmt.Sprintf("scrolled to %d dots", int(s.Y/k.Dot()))) + } + label(c, fmt.Sprintf("Y %d of %d dots", int(s.Y/k.Dot()), int(s.MaxY/k.Dot())), pixel.Faint) + }) +} + +func demoSplit(c *ui.Context, log func(string)) { + k := pixel.K(c) + size := ui.Local(c.Root(), demoKey("split"), func() int { return 120 }) + pane := func(name string) func() { + return func() { + layout.Panel(c, layout.PanelOptions{Style: pixel.Sunken, Fill: pixel.Well}, func() { label(c, name, pixel.Ink) }).Fill() + } + } + ui.Box(c).FillWidth().Height(k.D(96)).Children(func() { + r := layout.Split(c, size, layout.SplitOptions{Label: "Map and log"}, pane("MAP"), pane("LOG")) + r.Element.Fill() + if r.Changed { + log(fmt.Sprintf("split %d dots", *size)) + } + }) +} + +func demoGrid(c *ui.Context, log func(string)) { + k := pixel.K(c) + items := []*pixel.Sprite{icon.Sword, icon.Shield, icon.Potion, icon.Gem, icon.Key, icon.Coin} + ui.Row(c).Gap(k.D(12)).AlignItems(ui.Start).Children(func() { + for _, lines := range []layout.GridLines{layout.SolidLines, layout.DottedLines} { + layout.Grid(c, len(items), layout.GridOptions{Columns: 3, Lines: lines}, func(i int) { + pixel.Icon(c, items[i], pixel.IconOptions{Scale: 2}) + }) + } + }) +} + +func demoTiles(c *ui.Context, log func(string)) { + k := pixel.K(c) + items := []*pixel.Sprite{icon.Sun, icon.Moon, icon.Cloud, icon.Snowflake, icon.Leaf, icon.Flower, icon.Flame, icon.Drop, icon.Bolt, icon.Star} + layout.Tiles(c, len(items), layout.TilesOptions{W: 28}, func(i int) { + ui.Box(c).Fill().Center().Draw(func(p *ui.Painter, r ui.Rect) { + k.Canvas(p, r).Frame(0, 0, 28, 28, pixel.Raised, pixel.Face) + }).Children(func() { pixel.Icon(c, items[i], pixel.IconOptions{}) }) + }).Width(k.D(160)) +} + +func demoMasonry(c *ui.Context, log func(string)) { + k := pixel.K(c) + heights := []int{40, 24, 56, 32, 24, 48, 32, 40} + slots := []pixel.Slot{pixel.Accent, pixel.Good, pixel.Info, pixel.Warn, pixel.Magic} + layout.Masonry(c, heights, layout.MasonryOptions{}, func(i int) { + ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Flat, slots[i%len(slots)]) + g.Micro(3, 3, fmt.Sprint(i+1), pixel.Ink, 1) + }) + }).Width(k.D(180)) +} + +func demoAspectBox(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Box(c).Width(k.D(160)).Children(func() { + layout.AspectBox(c, layout.AspectBoxOptions{W: 16, H: 9}, func() { + ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for i := range 17 { + g.Dither(0, i*g.H/17, g.W, g.H/17+1, pixel.Back, pixel.Info, i) + } + g.Text(4, 2, fmt.Sprintf("16:9 %d×%d", g.W, g.H), pixel.Ink, 1) + }) + }) + }) +} + +func demoLayers(c *ui.Context, log func(string)) { + k := pixel.K(c) + layout.Layers(c, func() { + ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Dither(0, 0, g.W, g.H, pixel.Well, pixel.Info, 6) + }) + }, func() { + ui.Box(c).Fill().Center().Children(func() { pixel.Icon(c, icon.Chest, pixel.IconOptions{Scale: 3}) }) + }, func() { + ui.Box(c).Padding(k.D(3)).Children(func() { label(c, "LAYER 3", pixel.Ink) }) + }).Size(k.D(120), k.D(64)) +} + +func demoViewport(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Box(c).FillWidth().Height(k.D(100)).Children(func() { + layout.Viewport(c, layout.ViewportOptions{W: 64, H: 40}, func() { + kv := pixel.K(c) + ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { + g := kv.Canvas(p, r) + g.Rect(0, 0, g.W, g.H, pixel.Info) + g.Rect(0, 30, g.W, 10, pixel.Good) + g.Disc(50, 10, 5, pixel.Warn) + g.Micro(3, 3, "64X40", pixel.Ink, 1) + }) + }) + }) +} + +func demoTitleBar(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Box(c).Width(k.D(220)).Children(func() { + r := layout.TitleBar(c, "Untitled", layout.TitleBarOptions{}) + if r.Close { + log("close") + } + if r.Minimize { + log("minimize") + } + }) +} + +func demoStatusBar(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Box(c).Width(k.D(260)).Children(func() { + layout.StatusBar(c, "Ready", "Ln 1, Col 1", "UTF-8") + }) +} + +func demoResizable(c *ui.Context, log func(string)) { + w := ui.Local(c.Root(), demoKey("resize-w"), func() int { return 120 }) + h := ui.Local(c.Root(), demoKey("resize-h"), func() int { return 56 }) + r := layout.Resizable(c, w, h, layout.ResizableOptions{MinW: 48, MinH: 32, MaxW: 240, MaxH: 120}, func() { + layout.Panel(c, layout.PanelOptions{Style: pixel.Sunken, Fill: pixel.Well}, func() { + label(c, fmt.Sprintf("%d×%d", *w, *h), pixel.Ink) + }).Fill() + }) + if r.Changed { + log(fmt.Sprintf("size %d×%d", *w, *h)) + } +} + +func demoHandheld(c *ui.Context, log func(string)) { + k := pixel.K(c) + at := ui.Local(c.Root(), demoKey("handheld"), func() [2]int { return [2]int{36, 20} }) + r := layout.Handheld(c, layout.HandheldOptions{ScreenW: 80, ScreenH: 56, Brand: "8BIT POCKET", Label: "Pocket console"}, func() { + ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Rect(0, 0, g.W, g.H, pixel.Well) + g.Sprite(at[0], at[1], slime, g.Step(2), pixel.Ink, 1) + }) + }) + moves := map[string][2]int{"up": {0, -4}, "down": {0, 4}, "left": {-4, 0}, "right": {4, 0}} + for name, on := range map[string]bool{"up": r.Up, "down": r.Down, "left": r.Left, "right": r.Right} { + if on { + at[0] = max(0, min(at[0]+moves[name][0], 80-slime.W)) + at[1] = max(0, min(at[1]+moves[name][1], 56-slime.H)) + log(name) + } + } + if r.A { + log("A: jump") + } + if r.B { + log("B: run") + } +} + +func demoCRTFrame(c *ui.Context, log func(string)) { + k := pixel.K(c) + layout.CRTFrame(c, layout.CRTFrameOptions{Brand: "8BIT CRT"}, func() { + ui.Column(c).Size(k.D(180), k.D(72)).Padding(k.D(4)).Children(func() { + label(c, "READY.", pixel.Good) + label(c, "10 PRINT \"HI\"", pixel.Good) + }) + }).AlignSelf(ui.Start) +} + +func demoAppFrame(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Box(c).FillWidth().Height(k.D(150)).Children(func() { + layout.AppFrame(c, layout.AppFrameOptions{SidebarWidth: 72}, func() { + label(c, "8BIT MAIL", pixel.Ink) + }, func() { + for _, s := range []string{"Inbox", "Sent", "Spam"} { + if button(c, s) { + log("open " + s) + } + } + }, func() { + label(c, "No new mail. Go outside.", pixel.Ink) + }, func() { + label(c, "3 folders", pixel.Faint) + }) + }) +} diff --git a/gallery/registry.go b/gallery/registry.go index 4336dec..2dd79f9 100644 --- a/gallery/registry.go +++ b/gallery/registry.go @@ -56,7 +56,7 @@ var Categories = []Category{ {"tabletop", "Tabletop", "桌游", icon.Crown}, } -var entries = slices.Concat(foundationEntries) +var entries = slices.Concat(foundationEntries, layoutEntries) func Entries() []Entry { return entries } diff --git a/layout/appframe.go b/layout/appframe.go new file mode 100644 index 0000000..acde29e --- /dev/null +++ b/layout/appframe.go @@ -0,0 +1,74 @@ +package layout + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type AppFrameOptions struct { + // SidebarWidth is zero for 96 dots. + SidebarWidth int + // FoldBelow stacks the sidebar over main; zero means 320. + FoldBelow int +} + +// AppFrame arranges header, sidebar, main and footer; nil ones vanish. +func AppFrame(c *ui.Context, o AppFrameOptions, header, sidebar, main, footer func()) *ui.Element { + k := pixel.K(c) + sw := orDef("AppFrame SidebarWidth", o.SidebarWidth, 96) + fold := orDef("AppFrame FoldBelow", o.FoldBelow, 320) + if main == nil { + panic("layout: AppFrame needs main") + } + root := ui.Column(c).Fill() + w, check := pixel.WrapWidth(c, root) + folded := w > 0 && w < fold + strip := func(fn func(), below bool) { + ui.Box(c).FillWidth().Padding(k.D(4), k.D(6)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Rect(0, 0, g.W, g.H, pixel.Face) + if below { + g.HLine(0, g.H-1, g.W, pixel.Edge) + } else { + g.HLine(0, 0, g.W, pixel.Edge) + } + }).Children(fn) + } + return root.Draw(func(p *ui.Painter, r ui.Rect) { + check(r) + g := k.Canvas(p, r) + g.Rect(0, 0, g.W, g.H, pixel.Back) + }).Children(func() { + if header != nil { + strip(header, true) + } + var body *ui.Element + if folded { + body = ui.Column(c) + } else { + body = ui.Row(c) + } + body.Grow(1).AlignItems(ui.Stretch).Children(func() { + if sidebar != nil { + side := ui.Column(c).Padding(k.D(4)).Gap(k.D(2)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Rect(0, 0, g.W, g.H, pixel.Well) + if folded { + g.HLine(0, g.H-1, g.W, pixel.Edge) + } else { + g.VLine(g.W-1, 0, g.H, pixel.Edge) + } + }) + if !folded { + side.Width(k.D(sw)) + } + side.Children(sidebar) + } + ui.Column(c).Grow(1).Padding(k.D(6)).Gap(k.D(4)).Clip().Children(main) + }) + if footer != nil { + strip(footer, false) + } + }) +} diff --git a/layout/aspectbox.go b/layout/aspectbox.go new file mode 100644 index 0000000..8804a91 --- /dev/null +++ b/layout/aspectbox.go @@ -0,0 +1,21 @@ +package layout + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type AspectBoxOptions struct { + // W:H is the ratio; zero sides mean 1. + W, H int +} + +// AspectBox fills the width and keeps W:H in whole dots. +func AspectBox(c *ui.Context, o AspectBoxOptions, children func()) *ui.Element { + k := pixel.K(c) + rw, rh := orDef("AspectBox W", o.W, 1), orDef("AspectBox H", o.H, 1) + e := ui.Box(c).FillWidth().Clip() + w, check := pixel.WrapWidth(c, e) + return e.Height(k.D(w * rh / rw)).Draw(func(p *ui.Painter, r ui.Rect) { check(r) }).Children(children) +} diff --git a/layout/card.go b/layout/card.go new file mode 100644 index 0000000..7ffcd15 --- /dev/null +++ b/layout/card.go @@ -0,0 +1,51 @@ +package layout + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type CardOptions struct { + Title string + Icon *pixel.Sprite + // Footer builds a strip under the body. + Footer func() +} + +// Card is a raised panel: header strip, body, footer. +func Card(c *ui.Context, o CardOptions, body func()) *ui.Element { + k := pixel.K(c) + e := ui.Column(c).Padding(k.D(2)).Draw(func(p *ui.Painter, r ui.Rect) { + box(k.Canvas(p, r), pixel.Raised, pixel.Face) + }) + if o.Title != "" { + e.Role(ui.RoleGroup).Label(o.Title) + } + return e.Children(func() { + if o.Title != "" || o.Icon != nil { + cardHeader(c, k, o) + } + ui.Column(c).Padding(k.D(6)).Gap(k.D(4)).Children(body) + if o.Footer != nil { + ui.Row(c).Padding(k.D(5), k.D(6), k.D(4), k.D(6)).Gap(k.D(4)).AlignItems(ui.Center).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.HLine(0, 0, g.W, pixel.Shade) + g.HLine(0, 1, g.W, pixel.Lit) + }).Children(o.Footer) + } + }) +} + +func cardHeader(c *ui.Context, k pixel.Kit, o CardOptions) { + ui.Box(c).FillWidth().Height(k.D(pixel.LineHeight + 4)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Rect(0, 0, g.W, g.H, pixel.Accent) + x := 4 + if o.Icon != nil { + g.Sprite(x, (g.H-o.Icon.H)/2, o.Icon, 0, pixel.AccentInk, 1) + x += o.Icon.W + 4 + } + g.Clip(0, 0, g.W-2, g.H, func() { g.Text(x, 2, o.Title, pixel.AccentInk, 1) }) + }) +} diff --git a/layout/crtframe.go b/layout/crtframe.go new file mode 100644 index 0000000..eec9d80 --- /dev/null +++ b/layout/crtframe.go @@ -0,0 +1,38 @@ +package layout + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type CRTFrameOptions struct { + NoScanlines bool + // Brand is micro text on the chin. + Brand string +} + +const crtSide, crtChin = 10, 16 + +// CRTFrame shows children on a monitor with scanlines. +func CRTFrame(c *ui.Context, o CRTFrameOptions, children func()) *ui.Element { + k := pixel.K(c) + return ui.Column(c).Padding(k.D(crtSide), k.D(crtSide), k.D(crtChin), k.D(crtSide)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + box(g, pixel.Raised, pixel.Face) + if g.W > 2*crtSide && g.H > crtSide+crtChin { + g.Frame(crtSide-3, crtSide-3, g.W-2*crtSide+6, g.H-crtSide-crtChin+6, pixel.Sunken, pixel.Well) + } + g.Micro(crtSide, g.H-crtChin/2-2, o.Brand, pixel.Shade, 1) + g.Rect(g.W-crtSide-6, g.H-crtChin/2-1, 3, 3, pixel.Good) + }).DrawOver(func(p *ui.Painter, r ui.Rect) { + if o.NoScanlines { + return + } + // Scanlines are Shade at half strength on every other row. + g := k.Canvas(p, r) + for y := crtSide + 1; y < g.H-crtChin; y += 2 { + g.Checker(crtSide, y, g.W-2*crtSide, 1, pixel.Shade) + } + }).Children(children) +} diff --git a/layout/divider.go b/layout/divider.go new file mode 100644 index 0000000..f2e281e --- /dev/null +++ b/layout/divider.go @@ -0,0 +1,95 @@ +package layout + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type DividerStyle uint8 + +const ( + Solid DividerStyle = iota + Dotted + Dashed + Double + Ornament +) + +type DividerOptions struct { + Vertical bool + Style DividerStyle + // Slot is the ink; zero means Edge. + Slot pixel.Slot +} + +var diamond = pixel.MustSprite(` + ..#.. + .###. + ##### + .###. + ..#.. +`) + +// Divider is a rule across its parent. +func Divider(c *ui.Context, o DividerOptions) *ui.Element { + k := pixel.K(c) + ink := o.Slot + if ink == pixel.Ink { + ink = pixel.Edge + } + thick := [...]int{Solid: 1, Dotted: 1, Dashed: 1, Double: 3, Ornament: 5} + if int(o.Style) >= len(thick) { + panic(fmt.Sprintf("layout: unknown divider style %d", o.Style)) + } + t := thick[o.Style] + e := ui.Box(c).Role(ui.RoleNone) + if o.Vertical { + e.FillHeight().Width(k.D(t)) + } else { + e.FillWidth().Height(k.D(t)) + } + return e.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + n := g.W + if o.Vertical { + n = g.H + } + line := func(at, from, n int) { + x, y := from, at + if o.Vertical { + x, y = at, from + } + switch o.Style { + case Dotted: + w, h := n, 1 + if o.Vertical { + w, h = 1, n + } + g.Checker(x, y, w, h, ink) + case Dashed: + rule(g, x, y, n, o.Vertical, 4, 2, ink) + default: + rule(g, x, y, n, o.Vertical, max(n, 1), 0, ink) + } + } + switch o.Style { + case Double: + line(0, 0, n) + line(2, 0, n) + case Ornament: + mid := n / 2 + line(2, 0, mid-4) + line(2, mid+5, n-mid-5) + if o.Vertical { + g.Sprite(0, mid-2, diamond, 0, ink, 1) + } else { + g.Sprite(mid-2, 0, diamond, 0, ink, 1) + } + default: + line(0, 0, n) + } + }) +} diff --git a/layout/grid.go b/layout/grid.go new file mode 100644 index 0000000..04e308b --- /dev/null +++ b/layout/grid.go @@ -0,0 +1,68 @@ +package layout + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type GridLines uint8 + +const ( + NoLines GridLines = iota + SolidLines + DottedLines +) + +type GridOptions struct { + // Columns is zero for 2. + Columns int + // Pad is around each cell's content; zero means 4. + Pad int + Lines GridLines +} + +// Grid lays n cells in equal columns, lines between them. +func Grid(c *ui.Context, n int, o GridOptions, cell func(i int)) *ui.Element { + k := pixel.K(c) + cols := orDef("Grid Columns", o.Columns, 2) + pad := orDef("Grid Pad", o.Pad, 4) + orDef("Grid n", n, 0) + if o.Lines > DottedLines { + panic(fmt.Sprintf("layout: unknown grid lines %d", o.Lines)) + } + grid := ui.Grid(c).FillWidth() + w, check := pixel.WrapWidth(c, grid) + tracks := make([]ui.Track, cols) + for i, d := range shares(w, cols) { + tracks[i] = ui.Fixed(k.D(d)) + } + return grid.ColumnTracks(tracks...).Draw(func(p *ui.Painter, r ui.Rect) { check(r) }).Children(func() { + for i := range n { + right, below := edges(i, n, cols) + ui.Box(c).Padding(k.D(pad)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + line := func(x, y, w, h int) { + if o.Lines == DottedLines { + g.Checker(x, y, w, h, pixel.Edge) + } else if o.Lines == SolidLines { + g.Rect(x, y, w, h, pixel.Edge) + } + } + if right { + line(g.W-1, 0, 1, g.H) + } + if below { + line(0, g.H-1, g.W, 1) + } + }).Children(func() { cell(i) }) + } + }) +} + +// edges reports neighbors right of and below cell i. +func edges(i, n, cols int) (right, below bool) { + return i%cols != cols-1 && i != n-1, i < (n-1)/cols*cols +} diff --git a/layout/handheld.go b/layout/handheld.go new file mode 100644 index 0000000..21b98b5 --- /dev/null +++ b/layout/handheld.go @@ -0,0 +1,95 @@ +package layout + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type HandheldOptions struct { + // ScreenW×ScreenH is the screen in dots; zero means 160×144. + ScreenW, ScreenH int + // Brand is micro text on the body. + Brand string + Label string + Disabled bool +} + +type HandheldResult struct { + Element *ui.Element + Up, Down, Left, Right, A, B bool +} + +const bezel = 8 + +// Handheld is a pocket console around screen; buttons report presses. +func Handheld(c *ui.Context, o HandheldOptions, screen func()) HandheldResult { + k := pixel.K(c) + sw, sh := orDef("Handheld ScreenW", o.ScreenW, 160), orDef("Handheld ScreenH", o.ScreenH, 144) + res := HandheldResult{} + res.Element = ui.Column(c).Width(k.D(sw + 2*bezel + 40)).Padding(k.D(10)).Gap(k.D(6)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(o.Label).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + box(g, pixel.Raised, pixel.Face) + g.Micro(10, g.H-9, o.Brand, pixel.Shade, 1) + }).Children(func() { + ui.Box(c).Padding(k.D(bezel), k.D(bezel+10), k.D(bezel), k.D(bezel+10)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + box(g, pixel.Sunken, pixel.Shade) + led := pixel.Good + if o.Disabled { + led = pixel.Faint + } + g.Rect(5, g.H/2-1, 3, 3, led) + }).Children(func() { + ui.Box(c).Size(k.D(sw), k.D(sh)).Children(screen) + }) + ui.Box(c).Width(k.D(sw + 2*bezel + 20)).Height(k.D(44)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Rect(15, 18, 9, 9, pixel.Edge) + }).Children(func() { + pad := func(name string, x, y, w, h int) bool { return key(c, k, name, x, y, w, h, false, o.Disabled) } + res.Up = pad(k.Msg("up"), 15, 7, 9, 11) + res.Left = pad(k.Msg("left"), 4, 18, 11, 9) + res.Right = pad(k.Msg("right"), 24, 18, 11, 9) + res.Down = pad(k.Msg("down"), 15, 27, 9, 11) + right := sw + 2*bezel + 20 + res.B = key(c, k, "B", right-44, 18, 15, 15, true, o.Disabled) + res.A = key(c, k, "A", right-24, 10, 15, 15, true, o.Disabled) + }) + }) + for name, on := range map[string]bool{"up": res.Up, "down": res.Down, "left": res.Left, "right": res.Right, "a": res.A, "b": res.B} { + if on { + slog.Debug("8bit: handheld press", "button", name) + } + } + return res +} + +// key is a d-pad arm, or a round button. +func key(c *ui.Context, k pixel.Kit, name string, x, y, w, h int, round, disabled bool) bool { + shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.ButtonBase(c).Label(name).Disabled(disabled) }) + shell.Absolute().Left(k.D(x)).Top(k.D(y)).Size(k.D(w), k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + dy := 0 + if st.Press { + dy = 1 + } + if round { + g.Disc(w/2, h/2, w/2, pixel.Edge) + g.Disc(w/2, h/2-1+dy, w/2-1, pixel.Accent) + g.Micro(w/2-1, h/2-3+dy, name, pixel.AccentInk, 1) + } else { + g.Rect(0, dy, g.W, g.H-dy, pixel.Edge) + g.Rect(1, 1+dy, g.W-2, 1, pixel.Faint) + } + if st.Disabled { + g.Checker(0, 0, g.W, g.H, pixel.Face) + } + if st.Focus { + g.FocusMark(0, 0, g.W, g.H) + } + }) + return base.Clicked() +} diff --git a/layout/internal_test.go b/layout/internal_test.go new file mode 100644 index 0000000..58467fe --- /dev/null +++ b/layout/internal_test.go @@ -0,0 +1,90 @@ +package layout + +import ( + "reflect" + "testing" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func panics(t *testing.T, name string, fn func()) { + t.Helper() + defer func() { + if recover() == nil { + t.Fatalf("%s did not panic", name) + } + }() + fn() +} + +func TestOrDef(t *testing.T) { + if orDef("x", 0, 7) != 7 || orDef("x", 3, 7) != 3 { + t.Fatal("orDef did not default zero or keep a value") + } + panics(t, "negative option", func() { orDef("x", -1, 7) }) + panics(t, "unknown frame style", func() { border(pixel.FrameStyle(99)) }) +} + +func TestEdges(t *testing.T) { + type e struct{ right, below bool } + want := []e{{true, true}, {true, true}, {false, true}, {true, false}, {false, false}} + for i, w := range want { + if r, b := edges(i, 5, 3); r != w.right || b != w.below { + t.Errorf("cell %d of 5 in 3 columns: right %v below %v", i, r, b) + } + } +} + +func TestLanes(t *testing.T) { + got := lanes([]int{40, 24, 56, 32, 24, 48, 32, 40}, 3, 4) + want := [][]int{{0, 4, 7}, {1, 3, 6}, {2, 5}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("lanes %v, want %v", got, want) + } + panics(t, "zero height", func() { lanes([]int{8, 0}, 2, 4) }) +} + +func TestShares(t *testing.T) { + for _, tc := range []struct { + total, n int + want []int + }{{10, 3, []int{4, 3, 3}}, {9, 3, []int{3, 3, 3}}, {-5, 2, []int{0, 0}}} { + if got := shares(tc.total, tc.n); !reflect.DeepEqual(got, tc.want) { + t.Errorf("shares(%d, %d) = %v", tc.total, tc.n, got) + } + } +} + +func TestThumb(t *testing.T) { + for _, tc := range []struct { + h, view, y, maxY, size, at float32 + }{ + {60, 100, 0, 1180, 16, 0}, + {60, 100, 1180, 1180, 16, 44}, + {10, 50, 30, 1000, 10, 0}, + {60, 100, 0, 0, 60, 0}, + {100, 100, 50, 100, 50, 24}, + } { + if size, at := thumb(tc.h, tc.view, tc.y, tc.maxY, 2); size != tc.size || at != tc.at { + t.Errorf("thumb(h %v view %v y %v max %v) = %v at %v, want %v at %v", tc.h, tc.view, tc.y, tc.maxY, size, at, tc.size, tc.at) + } + } +} + +func TestFit(t *testing.T) { + for _, tc := range []struct { + w, h int + roomW, roomH float32 + base, want int + }{ + {64, 40, 256, 200, 2, 4}, + {64, 40, 200, 200, 2, 2}, + {10, 10, 0, 0, 1, 1}, + {160, 144, 1000, 1000, 1, 4}, + {160, 144, 400, 300, 1, 2}, + } { + if got := fit(tc.w, tc.h, tc.roomW, tc.roomH, tc.base); got != tc.want { + t.Errorf("fit(%d×%d in %v×%v from %d) = %d, want %d", tc.w, tc.h, tc.roomW, tc.roomH, tc.base, got, tc.want) + } + } +} diff --git a/layout/layers.go b/layout/layers.go new file mode 100644 index 0000000..46c70b3 --- /dev/null +++ b/layout/layers.go @@ -0,0 +1,19 @@ +package layout + +import "github.com/egoist/mygo/ui" + +// Layers stacks layers in a box; the first sizes it. +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).Fill() + if i > 0 { + b.Absolute().Top(0).Left(0) + } + b.Children(l) + } + }) +} diff --git a/layout/layout.go b/layout/layout.go new file mode 100644 index 0000000..5a792d1 --- /dev/null +++ b/layout/layout.go @@ -0,0 +1,64 @@ +// Package layout arranges screens, windows and panes on dots. +package layout + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// orDef is v, or def for zero; negative panics. +func orDef(name string, v, def int) int { + if v < 0 { + panic(fmt.Sprintf("layout: %s %d is negative", name, v)) + } + if v == 0 { + return def + } + return v +} + +// box frames the canvas unless it is too small. +func box(g pixel.Canvas, style pixel.FrameStyle, fill pixel.Slot) { + if g.W >= 3 && g.H >= 3 { + g.Frame(0, 0, g.W, g.H, style, fill) + } +} + +// rule draws n dots on an axis in on/off runs. +func rule(g pixel.Canvas, x, y, n int, vertical bool, on, off int, s pixel.Slot) { + for i := 0; i < n; i += on + off { + run := min(on, n-i) + if vertical { + g.VLine(x, y+i, run, s) + } else { + g.HLine(x+i, y, run, s) + } + } +} + +type dragKey struct{} + +// drag is enabled e's pointer travel this frame, in dots. +func drag(k pixel.Kit, e *ui.Element) (dx, dy int, ok bool) { + rest := ui.Local(e, dragKey{}, func() [2]float32 { return [2]float32{} }) + fx, fy, ok := e.Dragged() + if !ok || e.IsDisabled() { + *rest = [2]float32{} + return 0, 0, false + } + x, y := rest[0]+fx/k.Dot(), rest[1]+fy/k.Dot() + dx, dy = int(x), int(y) + *rest = [2]float32{x - float32(dx), y - float32(dy)} + return dx, dy, true +} + +// label is o's label, or the built-in copy for key. +func label(k pixel.Kit, s, key string) string { + if s != "" { + return s + } + return k.Msg(key) +} diff --git a/layout/layout_test.go b/layout/layout_test.go new file mode 100644 index 0000000..9968f40 --- /dev/null +++ b/layout/layout_test.go @@ -0,0 +1,385 @@ +package layout_test + +import ( + "image" + "image/color" + "slices" + "testing" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func use(c *ui.Context) { pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay}) } + +func box(t *testing.T, tt *ui.Tester, label string) ui.Rect { + t.Helper() + r, ok := tt.Find(label) + if !ok { + t.Fatalf("no %q in %q", label, tt.Texts()) + } + return r +} + +func dragBy(t *testing.T, tt *ui.Tester, label string, dx, dy float32) { + t.Helper() + r := box(t, tt, label) + x, y := r.X+r.W/2, r.Y+r.H/2 + tt.Press(x, y) + tt.Move(x+dx, y+dy) + tt.Release(x+dx, y+dy) +} + +func TestSplitDragKeysClamp(t *testing.T) { + size, changes := 100, 0 + opts := layout.SplitOptions{Label: "Divide"} + tt := ui.NewTester(func(c *ui.Context) { + use(c) + r := layout.Split(c, &size, opts, func() {}, func() {}) + r.Element.Size(400, 100) + if r.Changed { + changes++ + } + }, 400, 100) + dragBy(t, tt, "Divide", 20, 0) + if size != 110 || changes == 0 { + t.Fatalf("drag of 10 dots left size %d, %d changes", size, changes) + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyLeft) + if size != 114 { + t.Fatalf("keys left size %d, want 114", size) + } + size = 1000 + tt.Frame() + if size != 200-6-24 { + t.Fatalf("size clamped to %d, want %d", size, 200-6-24) + } + size = 3 + tt.Frame() + if size != 24 { + t.Fatalf("size clamped to %d, want the minimum 24", size) + } + opts.Disabled = true + dragBy(t, tt, "Divide", 40, 0) + if size != 24 { + t.Fatalf("a disabled grip moved to %d", size) + } +} + +func TestSplitRelaysOutWhenItsRoomChanges(t *testing.T) { + size := 1000 + tt := ui.NewTester(func(c *ui.Context) { + use(c) + layout.Split(c, &size, layout.SplitOptions{}, func() {}, func() { ui.Box(c).Fill().Label("SECOND") }).Element.Fill() + }, 400, 100) + if second := box(t, tt, "SECOND"); size != 200-6-24 || second.W != 48 { + t.Fatalf("first frames left size %d and the second pane %v", size, second) + } + tt.SetSize(240, 100) + if second := box(t, tt, "SECOND"); size != 120-6-24 || second.W != 48 { + t.Fatalf("after shrinking, size %d and the second pane %v", size, second) + } +} + +func TestScrollAreaArrowsTrackThumb(t *testing.T) { + s := ui.ScrollState{} + opts := layout.ScrollAreaOptions{Label: "List"} + tt := ui.NewTester(func(c *ui.Context) { + use(c) + layout.ScrollArea(c, &s, opts, func() { + for range 40 { + ui.Box(c).Height(32) + } + }).Element.Size(200, 100) + }, 300, 200) + tt.Frame() + if s.MaxY != 40*32-100 { + t.Fatalf("MaxY %v", s.MaxY) + } + click := func(label string) { + if err := tt.Click(label); err != nil { + t.Fatal(err) + } + } + click("SCROLL DOWN") + if s.Y != 32 { + t.Fatalf("down arrow scrolled to %v, want 16 dots", s.Y) + } + click("SCROLL UP") + click("SCROLL UP") + if s.Y != 0 { + t.Fatalf("up arrow scrolled to %v, want 0", s.Y) + } + up := box(t, tt, "SCROLL UP") + tt.ClickAt(up.X+up.W/2, 100-up.H-4) + if s.Y != 100 { + t.Fatalf("track below the thumb paged to %v, want 100", s.Y) + } + // Track 60 DIPs, thumb 16: one thumb DIP scrolls MaxY/44. + at := up.Y + up.H + 2 + tt.Press(up.X+up.W/2, at+4) + tt.Move(up.X+up.W/2, at+4+11) + tt.Release(up.X+up.W/2, at+4+11) + if want := float32(100) + 11*s.MaxY/44; s.Y != want { + t.Fatalf("thumb drag scrolled to %v, want %v", s.Y, want) + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("List") { + t.Fatal("Tab did not reach the scroll view") + } + onPalette(t, tt.Image()) + opts.Disabled = true + tt.Frame() + if tt.Focused("List") { + t.Fatal("a disabled scroll area kept the focus") + } + frozen := s.Y + _ = tt.Click("SCROLL DOWN") + tt.Scroll(50, 50, 0, 80) + tt.Key(0, ui.KeyDown) + tt.Move(50, 50) + tt.Key(0, ui.KeyPageDown) + if s.Y != frozen { + t.Fatalf("a disabled scroll area moved from %v to %v", frozen, s.Y) + } +} + +// onPalette fails on any pixel off the Spring Day palette. +func onPalette(t *testing.T, img *image.RGBA) { + t.Helper() + ok := map[color.RGBA]bool{} + for _, c := range pixel.SpringDay.Colors { + ok[color.RGBA{c.R, c.G, c.B, 255}] = true + } + for y := range img.Rect.Dy() { + for x := range img.Rect.Dx() { + if p := img.RGBAAt(x, y); !ok[p] { + t.Fatalf("pixel %d,%d is %v, off the palette", x, y, p) + } + } + } +} + +func TestTitleBarBoxes(t *testing.T) { + var closes, minimizes int + opts := layout.TitleBarOptions{} + tt := ui.NewTester(func(c *ui.Context) { + use(c) + r := layout.TitleBar(c, "Notes", opts) + closes += boolInt(r.Close) + minimizes += boolInt(r.Minimize) + }, 300, 60) + for _, l := range []string{"CLOSE", "MINIMIZE", "MINIMIZE"} { + if err := tt.Click(l); err != nil { + t.Fatal(err) + } + } + if closes != 1 || minimizes != 2 { + t.Fatalf("closes %d minimizes %d", closes, minimizes) + } + opts.NoMinimize = true + tt.Frame() + if _, ok := tt.Find("MINIMIZE"); ok { + t.Fatal("NoMinimize still shows the minimize box") + } + opts.Disabled = true + tt.Frame() + _ = tt.Click("CLOSE") + if closes != 1 { + t.Fatal("a disabled close box closed") + } +} + +func TestResizableGripKeysClamp(t *testing.T) { + w, h, changes := 100, 60, 0 + opts := layout.ResizableOptions{MaxW: 150} + tt := ui.NewTester(func(c *ui.Context) { + use(c) + if layout.Resizable(c, &w, &h, opts, func() {}).Changed { + changes++ + } + }, 600, 400) + dragBy(t, tt, "RESIZE", 20, 10) + if w != 110 || h != 65 || changes == 0 { + t.Fatalf("drag left %d×%d, %d changes", w, h, changes) + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyDown) + if w != 114 || h != 69 { + t.Fatalf("keys left %d×%d, want 114×69", w, h) + } + w, h = 1000, 1 + tt.Frame() + if w != 150 || h != 24 { + t.Fatalf("clamped to %d×%d, want 150×24", w, h) + } + opts.Disabled = true + dragBy(t, tt, "RESIZE", -40, 0) + if w != 150 { + t.Fatalf("a disabled grip resized to %d", w) + } +} + +func TestHandheldButtons(t *testing.T) { + pressed := map[string]int{} + opts := layout.HandheldOptions{ScreenW: 80, ScreenH: 56} + locale := pixel.En + tt := ui.NewTester(func(c *ui.Context) { + pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Locale: locale}) + r := layout.Handheld(c, opts, func() {}) + for name, on := range map[string]bool{"UP": r.Up, "DOWN": r.Down, "LEFT": r.Left, "RIGHT": r.Right, "A": r.A, "B": r.B} { + pressed[name] += boolInt(on) + } + }, 600, 500) + names := []string{"UP", "DOWN", "LEFT", "RIGHT", "A", "B"} + for _, n := range names { + if err := tt.Click(n); err != nil { + t.Fatal(err) + } + } + for _, n := range names { + if pressed[n] != 1 { + t.Fatalf("presses %v", pressed) + } + } + locale = pixel.Zh + tt.Frame() + if err := tt.Click("左"); err != nil || pressed["LEFT"] != 2 { + t.Fatalf("Chinese labels: %v, %v", err, pressed) + } + opts.Disabled = true + tt.Frame() + _ = tt.Click("A") + if pressed["A"] != 1 { + t.Fatal("a disabled A button pressed") + } +} + +func TestGridColumnsAreWholeDots(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + use(c) + layout.Grid(c, 3, layout.GridOptions{Columns: 3, Lines: layout.SolidLines}, func(i int) { + ui.Box(c).Size(10, 10).Label([]string{"G0", "G1", "G2"}[i]) + }).Width(200) + }, 300, 100) + for i, x := range []float32{8, 76, 142} { + if r := box(t, tt, []string{"G0", "G1", "G2"}[i]); r.X != x { + t.Fatalf("cell %d content at x %v, want %v", i, r.X, x) + } + } + defer func() { + if recover() == nil { + t.Fatal("unknown grid lines did not panic") + } + }() + ui.NewTester(func(c *ui.Context) { + use(c) + layout.Grid(c, 1, layout.GridOptions{Lines: 9}, func(int) {}) + }, 100, 100) +} + +func TestAppFrameFolds(t *testing.T) { + text := func(c *ui.Context, s string) func() { return func() { pixel.Text(c, s, pixel.TextOptions{}) } } + tt := ui.NewTester(func(c *ui.Context) { + use(c) + layout.AppFrame(c, layout.AppFrameOptions{}, nil, text(c, "SIDE"), text(c, "MAIN"), nil) + }, 800, 300) + tt.Frame() + side, main := box(t, tt, "SIDE"), box(t, tt, "MAIN") + if side.X+side.W > main.X { + t.Fatalf("wide frame: side %v not left of main %v", side, main) + } + tt.SetSize(500, 300) + tt.Frame() + side, main = box(t, tt, "SIDE"), box(t, tt, "MAIN") + if side.Y+side.H > main.Y { + t.Fatalf("narrow frame did not fold: side %v not above main %v", side, main) + } +} + +func TestAspectBoxAndViewport(t *testing.T) { + var aspect, screen *ui.Element + tt := ui.NewTester(func(c *ui.Context) { + use(c) + ui.Column(c).Children(func() { + ui.Box(c).Width(300).Children(func() { + aspect = layout.AspectBox(c, layout.AspectBoxOptions{W: 16, H: 9}, func() {}) + }) + ui.Box(c).Size(400, 300).Children(func() { + layout.Viewport(c, layout.ViewportOptions{W: 64, H: 40}, func() { screen = ui.Box(c).Fill() }) + }) + }) + }, 500, 700) + tt.Frame() + if b := aspect.Bounds(); b.W != 300 || b.H != 2*(150*9/16) { + t.Fatalf("aspect box %v", b) + } + if b := screen.Bounds(); b.X != 72 || b.Y != aspect.Bounds().H+70 || b.W != 256 || b.H != 160 { + t.Fatalf("viewport screen %v", b) + } +} + +func TestLayersSizes(t *testing.T) { + var auto, sized, fill *ui.Element + tt := ui.NewTester(func(c *ui.Context) { + use(c) + ui.Column(c).AlignItems(ui.Start).Children(func() { + auto = layout.Layers(c, func() { ui.Box(c).Size(50, 30) }, func() { ui.Box(c).Fill() }) + sized = layout.Layers(c, func() { fill = ui.Box(c).Fill() }).Size(120, 64) + }) + }, 400, 300) + tt.Frame() + if b := auto.Bounds(); b.W != 50 || b.H != 30 { + t.Fatalf("first layer did not size the box: %v", b) + } + if b := fill.Bounds(); b.W != 120 || b.H != 64 { + t.Fatalf("first layer did not fill a sized box: %v", b) + } + if b := sized.Bounds(); b.H != 64 { + t.Fatalf("sized box %v", b) + } +} + +func TestTitledContainersAndOrder(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + use(c) + layout.Window(c, layout.WindowOptions{Title: "ITEMS"}, func() {}) + layout.Section(c, "Sound", layout.SectionOptions{}, func() {}) + layout.Card(c, layout.CardOptions{Title: "QUEST", Footer: func() { pixel.Text(c, "FOOT", pixel.TextOptions{}) }}, func() { + pixel.Text(c, "BODY", pixel.TextOptions{}) + }) + layout.StatusBar(c, "Ready", "Ln 1") + }, 400, 400) + got := tt.Texts() + for _, l := range []string{"ITEMS", "Sound", "QUEST", "Ready", "Ln 1"} { + if !slices.Contains(got, l) { + t.Fatalf("no %q in %q", l, got) + } + } + if slices.Index(got, "BODY") > slices.Index(got, "FOOT") || box(t, tt, "BODY").Y >= box(t, tt, "FOOT").Y { + t.Fatalf("card footer came before its body: %q", got) + } +} + +func TestDividerStylePanics(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("unknown divider style did not panic") + } + }() + ui.NewTester(func(c *ui.Context) { + use(c) + layout.Divider(c, layout.DividerOptions{Style: 9}) + }, 100, 100) +} + +func boolInt(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/layout/masonry.go b/layout/masonry.go new file mode 100644 index 0000000..3dccd4e --- /dev/null +++ b/layout/masonry.go @@ -0,0 +1,67 @@ +package layout + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type MasonryOptions struct { + // Columns is zero for 3. + Columns int + // Gap is zero for 4. + Gap int +} + +// Masonry puts each item into the shortest column. +func Masonry(c *ui.Context, heights []int, o MasonryOptions, item func(i int)) *ui.Element { + k := pixel.K(c) + gap := orDef("Masonry Gap", o.Gap, 4) + n := orDef("Masonry Columns", o.Columns, 3) + cols := lanes(heights, n, gap) + row := ui.Row(c).FillWidth().Gap(k.D(gap)).AlignItems(ui.Start) + w, check := pixel.WrapWidth(c, row) + widths := shares(w-gap*(n-1), n) + return row.Draw(func(p *ui.Painter, r ui.Rect) { check(r) }).Children(func() { + for j, lane := range cols { + ui.Column(c).Width(k.D(widths[j])).Gap(k.D(gap)).Children(func() { + for _, i := range lane { + ui.Box(c).FillWidth().Height(k.D(heights[i])).Clip().Children(func() { item(i) }) + } + }) + } + }) +} + +// shares splits total into n whole parts, the first larger. +func shares(total, n int) []int { + out := make([]int, n) + for i := range out { + out[i] = max(0, total/n) + if i < total%n { + out[i]++ + } + } + return out +} + +// lanes deals items into n columns, shortest first. +func lanes(heights []int, n, gap int) [][]int { + out, tall := make([][]int, n), make([]int, n) + for i, h := range heights { + if h <= 0 { + panic(fmt.Sprintf("layout: Masonry height %d at %d", h, i)) + } + j := 0 + for k := range tall { + if tall[k] < tall[j] { + j = k + } + } + out[j] = append(out[j], i) + tall[j] += h + gap + } + return out +} diff --git a/layout/panel.go b/layout/panel.go new file mode 100644 index 0000000..b58ee1b --- /dev/null +++ b/layout/panel.go @@ -0,0 +1,41 @@ +package layout + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type PanelOptions struct { + Style pixel.FrameStyle + // Fill is the body slot; zero means Face. + Fill pixel.Slot + // Padding is inside the border; zero means 4. + Padding int +} + +// Panel frames children in a style. +func Panel(c *ui.Context, o PanelOptions, children func()) *ui.Element { + k := pixel.K(c) + fill := o.Fill + if fill == pixel.Ink { + fill = pixel.Face + } + pad := border(o.Style) + orDef("Panel Padding", o.Padding, 4) + return ui.Column(c).Padding(k.D(pad)).Draw(func(p *ui.Painter, r ui.Rect) { + box(k.Canvas(p, r), o.Style, fill) + }).Children(children) +} + +// border is the dots a frame style takes per side. +func border(s pixel.FrameStyle) int { + switch s { + case pixel.Flat, pixel.Notched: + return 1 + case pixel.Raised, pixel.Sunken: + return 2 + case pixel.Double, pixel.Window: + return 3 + } + panic("layout: unknown frame style") +} diff --git a/layout/resizable.go b/layout/resizable.go new file mode 100644 index 0000000..1851492 --- /dev/null +++ b/layout/resizable.go @@ -0,0 +1,83 @@ +package layout + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type ResizableOptions struct { + // MinW and MinH are zero for 24. + MinW, MinH int + // MaxW and MaxH are zero for no limit. + MaxW, MaxH int + Label string + Disabled bool +} + +type ResizableResult struct { + Element *ui.Element + Changed bool +} + +// Resizable is a *w×*h dot box with a ◢ grip. +func Resizable(c *ui.Context, w, h *int, o ResizableOptions, children func()) ResizableResult { + k := pixel.K(c) + minW, minH := orDef("Resizable MinW", o.MinW, 24), orDef("Resizable MinH", o.MinH, 24) + maxW, maxH := orDef("Resizable MaxW", o.MaxW, 0), orDef("Resizable MaxH", o.MaxH, 0) + if maxW > 0 && maxW < minW || maxH > 0 && maxH < minH { + panic(fmt.Sprintf("layout: Resizable max %d×%d under min %d×%d", maxW, maxH, minW, minH)) + } + clamp := func(v, lo, hi int) int { + if hi > 0 { + v = min(v, hi) + } + return max(v, lo) + } + was := [2]int{*w, *h} + e := ui.Box(c).Clip() + e.Children(func() { + children() + shell, base, st := pixel.Shell(c, func() *ui.Element { + return ui.Box(c).Focusable().Role(ui.RoleSplitter).Label(label(k, o.Label, "resize")).Disabled(o.Disabled).Cursor(ui.CursorResizeNWSE) + }) + if dx, dy, ok := drag(k, base); ok { + *w, *h = *w+dx, *h+dy + } + for key, d := range map[ui.Key][2]int{ui.KeyLeft: {-4, 0}, ui.KeyRight: {4, 0}, ui.KeyUp: {0, -4}, ui.KeyDown: {0, 4}} { + if !o.Disabled && base.Shortcut(0, key) { + *w, *h = *w+d[0], *h+d[1] + } + } + shell.Absolute().Right(0).Bottom(0).Size(k.D(9), k.D(9)).Draw(func(p *ui.Painter, r ui.Rect) { corner(k.Canvas(p, r), st) }) + }) + *w, *h = clamp(*w, minW, maxW), clamp(*h, minH, maxH) + e.Size(k.D(*w), k.D(*h)) + res := ResizableResult{Element: e, Changed: [2]int{*w, *h} != was} + if res.Changed { + slog.Debug("8bit: resizable", "w", *w, "h", *h) + } + return res +} + +// corner is a ◢ of three ridges in the bottom-right. +func corner(g pixel.Canvas, st pixel.State) { + for _, s := range []int{2, 5, 8} { + for i := 0; i <= s; i++ { + x, y := g.W-1-s+i, g.H-1-i + g.Dot(x, y, pixel.Shade) + if x > 0 && y > 0 { + g.Dot(x-1, y-1, pixel.Lit) + } + } + } + if st.Disabled { + g.Checker(0, 0, g.W, g.H, pixel.Face) + } + if st.Focus { + g.FocusMark(0, 0, g.W, g.H) + } +} diff --git a/layout/screen.go b/layout/screen.go new file mode 100644 index 0000000..59b5992 --- /dev/null +++ b/layout/screen.go @@ -0,0 +1,38 @@ +package layout + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type ScreenOptions struct { + // Vignette dithers Shade into the edges. + Vignette bool + Padding int +} + +// Screen fills the window with Back; children form a column. +func Screen(c *ui.Context, o ScreenOptions, children func()) *ui.Element { + k := pixel.K(c) + pad := orDef("Screen Padding", o.Padding, 0) + return ui.Column(c).Fill().Padding(k.D(pad)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Rect(0, 0, g.W, g.H, pixel.Back) + if o.Vignette { + vignette(g) + } + }).Children(children) +} + +// vignette darkens four bands toward the edges. +func vignette(g pixel.Canvas) { + band := max(2, min(g.W, g.H)/32) + for i := range 4 { + in, lv := i*band, 8-2*i + g.Veil(in, in, g.W-2*in, band, pixel.Shade, lv) + g.Veil(in, g.H-in-band, g.W-2*in, band, pixel.Shade, lv) + g.Veil(in, in+band, band, g.H-2*in-2*band, pixel.Shade, lv) + g.Veil(g.W-in-band, in+band, band, g.H-2*in-2*band, pixel.Shade, lv) + } +} diff --git a/layout/scrollarea.go b/layout/scrollarea.go new file mode 100644 index 0000000..7d0a3a3 --- /dev/null +++ b/layout/scrollarea.go @@ -0,0 +1,152 @@ +package layout + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +const barW = 10 + +var ( + upArrow = pixel.MustSprite(` + ..#.. + .###. + ##### + `) + downArrow = pixel.MustSprite(` + ##### + .###. + ..#.. + `) +) + +type ScrollAreaOptions struct { + // Label names the region for assistive technology. + Label string + Disabled bool +} + +type ScrollAreaResult struct { + Element *ui.Element + // Changed reports the view moved since the last frame. + Changed bool +} + +type scrollKey struct{} + +// ScrollArea scrolls children vertically beside a pixel scrollbar. +func ScrollArea(c *ui.Context, s *ui.ScrollState, o ScrollAreaOptions, children func()) ScrollAreaResult { + k := pixel.K(c) + // The bar comes first: TrackScroll reads s when called. + row := ui.Row(c).AlignItems(ui.Stretch).Reverse() + last := ui.Local(row, scrollKey{}, func() float32 { return s.Y }) + view := row.Bounds().H + // Disabled freezes the view: any move is undone before paint. + if o.Disabled { + s.Y = *last + } + var v *ui.Element + row.Children(func() { + scrollBar(c, k, s, view, o.Disabled) + // A disabled view is a new element, so the focus leaves it. + v = ui.Scroll(c).Key(o.Disabled).TrackScroll(s).Grow(1).Label(o.Label).FocusRing(false) + if o.Disabled { + v.Role(ui.RoleGroup).Description(k.Msg("disabled")) + } else { + v.Role(ui.RoleScroll).Focusable() + } + v.Children(children) + }) + focus := v.FocusVisible() + row.Draw(func(p *ui.Painter, r ui.Rect) { + if r.H != view { + c.Invalidate() + } + }).DrawOver(func(p *ui.Painter, r ui.Rect) { + if focus { + g := k.Canvas(p, r) + w := g.W - barW + g.Checker(0, 0, w, 1, pixel.Accent) + g.Checker(0, g.H-1, w, 1, pixel.Accent) + g.Checker(0, 0, 1, g.H, pixel.Accent) + g.Checker(w-1, 0, 1, g.H, pixel.Accent) + } + }) + res := ScrollAreaResult{Element: row, Changed: s.Y != *last} + *last = s.Y + if res.Changed { + slog.Debug("8bit: scrollarea scrolled", "y", s.Y) + } + return res +} + +// scrollBar draws arrows, track and thumb, and moves s. +func scrollBar(c *ui.Context, k pixel.Kit, s *ui.ScrollState, view float32, disabled bool) { + clamp := func(y float32) { s.Y = max(0, min(y, s.MaxY)) } + ui.Column(c).Width(k.D(barW)).DrawOver(func(p *ui.Painter, r ui.Rect) { + if disabled { + g := k.Canvas(p, r) + g.Checker(0, 0, g.W, g.H, pixel.Face) + } + }).Children(func() { + if arrow(c, k, upArrow, k.Msg("scroll_up"), disabled) { + clamp(s.Y - k.D(16)) + } + track := ui.Box(c).Grow(1) + h := track.Bounds().H + size, at := thumb(h, view, s.Y, s.MaxY, k.Dot()) + track.Draw(func(p *ui.Painter, r ui.Rect) { + if r.H != h { + c.Invalidate() + } + box(k.Canvas(p, r), pixel.Sunken, pixel.Well) + }).Children(func() { + th := ui.Box(c).Absolute().Top(at).Left(0).FillWidth().Height(size) + th.Clicked() + if _, dy, ok := th.Dragged(); ok && !disabled && dy != 0 && h > size { + clamp(s.Y + dy*s.MaxY/(h-size)) + } + th.Draw(func(p *ui.Painter, r ui.Rect) { box(k.Canvas(p, r), pixel.Raised, pixel.Face) }) + }) + if track.Clicked() && !disabled { + if _, y, _ := track.PointerPosition(); y < at { + clamp(s.Y - view) + } else if y > at+size { + clamp(s.Y + view) + } + } + if arrow(c, k, downArrow, k.Msg("scroll_down"), disabled) { + clamp(s.Y + k.D(16)) + } + }) +} + +// thumb sizes and places the thumb in track h. +func thumb(h, view, y, maxY, dot float32) (size, at float32) { + if maxY <= 0 || view <= 0 { + return h, 0 + } + size = min(h, max(8*dot, h*view/(view+maxY))) + size = float32(int(size/dot)) * dot + return size, float32(int((h-size)*y/maxY/dot)) * dot +} + +// arrow is a scrollbar button; keys scroll the view instead. +func arrow(c *ui.Context, k pixel.Kit, sp *pixel.Sprite, label string, disabled bool) bool { + shell, base, st := pixel.Shell(c, func() *ui.Element { + return ui.Box(c).Role(ui.RoleButton).Label(label).Disabled(disabled) + }) + shell.Size(k.D(barW), k.D(barW)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + style, y := pixel.Raised, 0 + if st.Press { + style, y = pixel.Sunken, 1 + } + box(g, style, pixel.Face) + g.Sprite((g.W-sp.W)/2, (g.H-sp.H)/2+y, sp, 0, pixel.Ink, 1) + }) + return base.Clicked() +} diff --git a/layout/section.go b/layout/section.go new file mode 100644 index 0000000..f533169 --- /dev/null +++ b/layout/section.go @@ -0,0 +1,30 @@ +package layout + +import ( + "strings" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type SectionOptions struct { + // Gap is between children; zero means 4. + Gap int +} + +// Section titles a group inside a dotted rule. +func Section(c *ui.Context, title string, o SectionOptions, children func()) *ui.Element { + k := pixel.K(c) + up := strings.ToUpper(title) + return ui.Column(c).Gap(k.D(orDef("Section Gap", o.Gap, 4))).Role(ui.RoleGroup).Label(title).Children(func() { + ui.Box(c).FillWidth().Height(k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + mid, tw := pixel.LineHeight/2, pixel.Measure(up) + g.Checker(0, mid, 6, 1, pixel.Faint) + g.Text(10, 0, up, pixel.Ink, 1) + g.Checker(14+tw, mid, g.W-14-tw, 1, pixel.Faint) + }) + children() + }) +} diff --git a/layout/split.go b/layout/split.go new file mode 100644 index 0000000..dd168dc --- /dev/null +++ b/layout/split.go @@ -0,0 +1,117 @@ +package layout + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +const gripW = 6 + +type SplitOptions struct { + // Vertical stacks the panes top and bottom. + Vertical bool + // Min is the smallest pane in dots; zero means 24. + Min int + Label string + Disabled bool +} + +type SplitResult struct { + Element *ui.Element + Changed bool +} + +// Split shows two panes and a grip moving *size dots. +func Split(c *ui.Context, size *int, o SplitOptions, first, second func()) SplitResult { + k := pixel.K(c) + least := orDef("Split Min", o.Min, 24) + var e *ui.Element + var total float32 + back, fwd := ui.KeyLeft, ui.KeyRight + if o.Vertical { + e = ui.Column(c) + total, back, fwd = e.Bounds().H, ui.KeyUp, ui.KeyDown + } else { + e = ui.Row(c) + total = e.Bounds().W + } + limit := func(n int) int { + if total > 0 { + n = min(n, int(total/k.Dot())-gripW-least) + } + return max(n, least) + } + start := *size + e.AlignItems(ui.Stretch).Children(func() { + pane := ui.Box(c).Clip().Children(first) + shell, base, st := pixel.Shell(c, func() *ui.Element { + cur := ui.CursorResizeEW + if o.Vertical { + cur = ui.CursorResizeNS + } + return ui.Box(c).Focusable().Role(ui.RoleSplitter).Label(label(k, o.Label, "resize")).Disabled(o.Disabled).Cursor(cur) + }) + if dx, dy, ok := drag(k, base); ok { + if o.Vertical { + dx = dy + } + *size += dx + } + if !o.Disabled && base.Shortcut(0, back) { + *size -= 4 + } + if !o.Disabled && base.Shortcut(0, fwd) { + *size += 4 + } + *size = limit(*size) + if o.Vertical { + pane.FillWidth().Height(k.D(*size)) + shell.FillWidth().Height(k.D(gripW)) + } else { + pane.FillHeight().Width(k.D(*size)) + shell.FillHeight().Width(k.D(gripW)) + } + shell.Draw(func(p *ui.Painter, r ui.Rect) { grip(k.Canvas(p, r), st, o.Vertical) }) + ui.Box(c).Grow(1).Clip().Children(second) + }) + e.Draw(func(p *ui.Painter, r ui.Rect) { + now := r.W + if o.Vertical { + now = r.H + } + if now != total { + c.Invalidate() + } + }) + res := SplitResult{Element: e, Changed: *size != start} + if res.Changed { + slog.Debug("8bit: split moved", "size", *size) + } + return res +} + +// grip is a bar with three bumps across its length. +func grip(g pixel.Canvas, st pixel.State, vertical bool) { + fill := pixel.Face + if st.Hover || st.Press { + fill = pixel.Lit + } + g.Rect(0, 0, g.W, g.H, fill) + for i := -1; i <= 1; i++ { + x, y := g.W/2-1, g.H/2-1+i*4 + if vertical { + x, y = g.W/2-1+i*4, g.H/2-1 + } + g.Dot(x, y, pixel.Lit) + g.Dot(x+1, y+1, pixel.Shade) + } + if st.Disabled { + g.Checker(0, 0, g.W, g.H, pixel.Face) + } + if st.Focus { + g.FocusMark(0, 0, g.W, g.H) + } +} diff --git a/layout/statusbar.go b/layout/statusbar.go new file mode 100644 index 0000000..193db00 --- /dev/null +++ b/layout/statusbar.go @@ -0,0 +1,34 @@ +package layout + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// StatusBar shows segments split by sunken dividers; the first grows. +func StatusBar(c *ui.Context, segments ...string) *ui.Element { + k := pixel.K(c) + return ui.Row(c).FillWidth().Height(k.D(pixel.LineHeight + 3)).AlignItems(ui.Stretch).Role(ui.RoleStatus).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Rect(0, 0, g.W, g.H, pixel.Face) + g.HLine(0, 0, g.W, pixel.Edge) + g.HLine(0, 1, g.W, pixel.Lit) + }).Children(func() { + for i, s := range segments { + if i > 0 { + ui.Box(c).Width(k.D(2)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.VLine(0, 3, g.H-5, pixel.Shade) + g.VLine(1, 3, g.H-5, pixel.Lit) + }) + } + seg := ui.Box(c).Padding(k.D(2), k.D(6), 0, k.D(6)).Children(func() { + pixel.Text(c, s, pixel.TextOptions{}) + }) + if i == 0 { + seg.Grow(1).Clip() + } + } + }) +} diff --git a/layout/tiles.go b/layout/tiles.go new file mode 100644 index 0000000..fc23af6 --- /dev/null +++ b/layout/tiles.go @@ -0,0 +1,27 @@ +package layout + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type TilesOptions struct { + // W is zero for 48; H is zero for W. + W, H int + // Gap is zero for 4. + Gap int +} + +// Tiles wraps n equal tiles into rows. +func Tiles(c *ui.Context, n int, o TilesOptions, tile func(i int)) *ui.Element { + k := pixel.K(c) + w := orDef("Tiles W", o.W, 48) + h := orDef("Tiles H", o.H, w) + orDef("Tiles n", n, 0) + return ui.Row(c).Wrap().Gap(k.D(orDef("Tiles Gap", o.Gap, 4))).Children(func() { + for i := range n { + ui.Box(c).Size(k.D(w), k.D(h)).Clip().Children(func() { tile(i) }) + } + }) +} diff --git a/layout/titlebar.go b/layout/titlebar.go new file mode 100644 index 0000000..3792d4b --- /dev/null +++ b/layout/titlebar.go @@ -0,0 +1,89 @@ +package layout + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var ( + closeGlyph = pixel.MustSprite(` + #...# + .#.#. + ..#.. + .#.#. + #...# + `) + minimizeGlyph = pixel.MustSprite(` + ..... + ..... + ..... + ..... + ##### + `) +) + +type TitleBarOptions struct { + // NoMinimize leaves out the minimize box. + NoMinimize bool + Disabled bool +} + +type TitleBarResult struct { + Element *ui.Element + Close bool + Minimize bool +} + +// TitleBar is a striped bar: title, close box, minimize box. +func TitleBar(c *ui.Context, title string, o TitleBarOptions) TitleBarResult { + k := pixel.K(c) + tw := pixel.Measure(title) + res := TitleBarResult{} + res.Element = ui.Row(c).FillWidth().Height(k.D(pixel.LineHeight+2)).AlignItems(ui.Center).Padding(0, k.D(4)).Role(ui.RoleGroup).Label(title).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Rect(0, 0, g.W, g.H, pixel.Face) + g.HLine(0, g.H-1, g.W, pixel.Edge) + for y := 4; y < g.H-4; y += 2 { + g.HLine(2, y, g.W-4, pixel.Shade) + } + x := (g.W - tw) / 2 + g.Rect(x-6, 0, tw+12, g.H-1, pixel.Face) + g.Text(x, 1, title, pixel.Ink, 1) + }).Children(func() { + res.Close = windowBox(c, k, closeGlyph, k.Msg("close"), o.Disabled) + ui.Spacer(c) + if !o.NoMinimize { + res.Minimize = windowBox(c, k, minimizeGlyph, k.Msg("minimize"), o.Disabled) + } + }) + if res.Close || res.Minimize { + slog.Debug("8bit: titlebar", "title", title, "close", res.Close, "minimize", res.Minimize) + } + return res +} + +// windowBox is a framed button showing its glyph on hover. +func windowBox(c *ui.Context, k pixel.Kit, glyph *pixel.Sprite, name string, disabled bool) bool { + shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.ButtonBase(c).Label(name).Disabled(disabled) }) + shell.Size(k.D(11), k.D(11)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + fill := pixel.Face + if st.Press { + fill = pixel.Shade + } + box(g, pixel.Flat, fill) + if st.Hover || st.Press || st.Focus { + g.Sprite(3, 3, glyph, 0, pixel.Ink, 1) + } + if st.Disabled { + g.Checker(0, 0, g.W, g.H, pixel.Face) + } + if st.Focus { + g.FocusMark(0, 0, g.W, g.H) + } + }) + return base.Clicked() +} diff --git a/layout/viewport.go b/layout/viewport.go new file mode 100644 index 0000000..2cb1ba1 --- /dev/null +++ b/layout/viewport.go @@ -0,0 +1,47 @@ +package layout + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type ViewportOptions struct { + // W×H is the virtual screen in dots; zero means 160×144. + W, H int +} + +// Viewport fills its parent and draws children at W×H dots. +func Viewport(c *ui.Context, o ViewportOptions, children func()) *ui.Element { + k := pixel.K(c) + vw, vh := orDef("Viewport W", o.W, 160), orDef("Viewport H", o.H, 144) + base := int(k.Dot()) + outer := ui.Box(c).Fill() + was := outer.Bounds() + d := fit(vw, vh, was.W, was.H, base) + // Offsets stay whole outer dots, so both grids line up. + x := (int(was.W)/base - vw*d/base) / 2 * base + y := (int(was.H)/base - vh*d/base) / 2 * base + return outer.Draw(func(p *ui.Painter, r ui.Rect) { + if r.W != was.W || r.H != was.H { + c.Invalidate() + } + g := k.Canvas(p, r) + g.Rect(0, 0, g.W, g.H, pixel.Back) + }).Children(func() { + s := pixel.Current(c) + s.Dot = d + pixel.Scope(c, s, func() { + ui.Box(c).Absolute().Left(float32(max(0, x))).Top(float32(max(0, y))).Size(float32(vw*d), float32(vh*d)).Clip().Children(children) + }) + }) +} + +// fit picks the largest fitting multiple of base, ≤ 4. +func fit(w, h int, roomW, roomH float32, base int) int { + d := base + for d+base <= 4 && float32(w*(d+base)) <= roomW && float32(h*(d+base)) <= roomH { + d += base + } + return d +} diff --git a/layout/window.go b/layout/window.go new file mode 100644 index 0000000..f2da23b --- /dev/null +++ b/layout/window.go @@ -0,0 +1,42 @@ +package layout + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +const tabH = pixel.LineHeight + 2 + +type WindowOptions struct { + // Title shows in a tab above the body. + Title string + // Padding is inside the bevel; zero means 6. + Padding int +} + +// Window is a beveled window with an optional title tab. +func Window(c *ui.Context, o WindowOptions, children func()) *ui.Element { + k := pixel.K(c) + pad := 3 + orDef("Window Padding", o.Padding, 6) + top := 0 + e := ui.Column(c) + if o.Title != "" { + top = tabH - 1 + e.Role(ui.RoleGroup).Label(o.Title) + } + return e.Padding(k.D(top+pad), k.D(pad), k.D(pad), k.D(pad)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + if g.W < 12 || g.H < top+6 { + return + } + g.Frame(0, top, g.W, g.H-top, pixel.Window, pixel.Face) + if o.Title == "" { + return + } + w := min(pixel.Measure(o.Title)+12, g.W-8) + g.Frame(4, 0, w, tabH, pixel.Raised, pixel.Face) + g.Rect(5, tabH-2, w-2, 4, pixel.Face) + g.Clip(4, 0, w-2, tabH, func() { g.Text(10, 1, o.Title, pixel.Ink, 1) }) + }).Children(children) +} diff --git a/pixel/messages.go b/pixel/messages.go index 6cdb581..f1c06cd 100644 --- a/pixel/messages.go +++ b/pixel/messages.go @@ -6,12 +6,16 @@ var messages = map[Locale]map[string]string{ En: { "ok": "OK", "cancel": "CANCEL", "yes": "YES", "no": "NO", "close": "CLOSE", "continue": "CONTINUE?", "game_over": "GAME OVER", "loading": "NOW LOADING", - "search": "SEARCH", "empty": "NO ITEMS", + "search": "SEARCH", "empty": "NO ITEMS", "minimize": "MINIMIZE", "resize": "RESIZE", + "scroll_up": "SCROLL UP", "scroll_down": "SCROLL DOWN", + "up": "UP", "down": "DOWN", "left": "LEFT", "right": "RIGHT", "disabled": "DISABLED", }, Zh: { "ok": "确定", "cancel": "取消", "yes": "是", "no": "否", "close": "关闭", "continue": "继续?", "game_over": "游戏结束", "loading": "读取中", - "search": "搜索", "empty": "空空如也", + "search": "搜索", "empty": "空空如也", "minimize": "最小化", "resize": "调整大小", + "scroll_up": "向上滚动", "scroll_down": "向下滚动", + "up": "上", "down": "下", "left": "左", "right": "右", "disabled": "已禁用", }, } diff --git a/pixel/settings.go b/pixel/settings.go index c4b87d5..329a7ec 100644 --- a/pixel/settings.go +++ b/pixel/settings.go @@ -64,6 +64,9 @@ func Use(c *ui.Context, s Settings) { // Typing reports a focused Editor built so far this frame. func Typing(c *ui.Context) bool { return current(c).typing } +// Current returns the settings in force, for Scope to change. +func Current(c *ui.Context) Settings { return current(c).cur } + // Scope builds fn under other settings. func Scope(c *ui.Context, s Settings, fn func()) { f := current(c) diff --git a/web/tests/smoke.spec.js b/web/tests/smoke.spec.js index 3e2f7bb..c69bf61 100644 --- a/web/tests/smoke.spec.js +++ b/web/tests/smoke.spec.js @@ -13,6 +13,9 @@ async function open (page, hash = '') { const texts = page => page.evaluate(() => window.__8bit.texts()) const where = page => page.evaluate(() => window.__8bit.page()) +// searched reports whether the page shows results for q. +const searched = async (page, q) => (await texts(page)).some(t => t.endsWith(`results for "${q}"`)) + // pixels hashes the canvas, to see that it changed. const pixels = page => page.evaluate(() => { const c = document.getElementById('screen') @@ -67,7 +70,7 @@ test('slash focuses search without typing a slash', async ({ page }) => { await open(page, '#/') await page.keyboard.press('/') await page.keyboard.type('dith') - expect(await texts(page)).toContain('1 results for "dith"') + expect(await searched(page, 'dith')).toBe(true) await page.keyboard.press('Enter') expect(await where(page)).toBe('/f/dither') expect(page.url()).toContain('#/f/dither') @@ -77,7 +80,7 @@ test('Chinese text reaches search', async ({ page }) => { await open(page, '#/') await page.keyboard.press('/') await page.keyboard.insertText('像素') - expect(await texts(page)).toContain('1 results for "像素"') + expect(await searched(page, '像素')).toBe(true) }) test('clicks navigate, Back returns, P repaints', async ({ page }) => { @@ -112,7 +115,7 @@ test('keyboard copy and paste use the system clipboard', async ({ page, context await page.evaluate(() => navigator.clipboard.writeText('dith')) await click(page, 'Search components') await page.keyboard.press('ControlOrMeta+V') - expect(await texts(page)).toContain('1 results for "dith"') + expect(await searched(page, 'dith')).toBe(true) }) test('an input method composes, then commits', async ({ page }) => { @@ -120,9 +123,9 @@ test('an input method composes, then commits', async ({ page }) => { await page.keyboard.press('/') const cdp = await page.context().newCDPSession(page) await cdp.send('Input.imeSetComposition', { text: 'xiang', selectionStart: 5, selectionEnd: 5 }) - expect(await texts(page)).not.toContain('1 results for "xiang"') + expect(await searched(page, 'xiang')).toBe(false) await cdp.send('Input.insertText', { text: '像' }) - expect(await texts(page)).toContain('1 results for "像"') + expect(await searched(page, '像')).toBe(true) }) test('Command or Super with Backspace erases the line', async ({ page }) => { @@ -131,7 +134,7 @@ test('Command or Super with Backspace erases the line', async ({ page }) => { await page.keyboard.type('zz dith') await page.keyboard.press('Meta+Backspace') await page.keyboard.type('dith') - expect(await texts(page)).toContain('1 results for "dith"') + expect(await searched(page, 'dith')).toBe(true) }) test('wheel scrolls the sidebar', async ({ page }) => {