diff --git a/AGENTS.md b/AGENTS.md index aa1141f..15096ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,7 @@ 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)`. Integer options default through `pixel.Or`. +- Custom-drawn controls end their Draw with `g.Finish(st)`. Grids of cells moved by arrows use `pixel.Pad`; one-shot timing steps with `k.Since`. - A one-shot effect inside a lasting view starts when its cue changes (a `Cue` option, or a value that rises), never on its first frame. A state screen (`ErrorState`, `SuccessState`) starts when it appears. Decorative motion jumps to its end under Reduce Motion; countdowns and loaders keep time. - Containers take content as a final `func()`; interactive results are `NameResult`. - A Clicked, Shortcut, Changed or drag during the build makes MyGo build the frame again, so what they change shows at once. State changed any other way after its element was built (`Pressed`, `Edit.Changed`) is read at paint time. diff --git a/feedback/achievement.go b/feedback/achievement.go index 579a1e6..159fd3f 100644 --- a/feedback/achievement.go +++ b/feedback/achievement.go @@ -20,7 +20,7 @@ func Achievement(c *ui.Context, title string, o AchievementOptions) *ui.Element w := pixel.Or("Achievement Width", o.Width, 200) e := ui.Box(c) // Ten steps a second; in by 5, gone by 40. - step := since(c, k, cue(e, k, o.Cue), 10, 40) + step := k.Since(c, cue(e, k, o.Cue), 10, 40) if step >= 40 { return e } diff --git a/feedback/combocounter.go b/feedback/combocounter.go index c8064c3..04c3820 100644 --- a/feedback/combocounter.go +++ b/feedback/combocounter.go @@ -24,7 +24,7 @@ func ComboCounter(c *ui.Context, count int, o ComboCounterOptions) *ui.Element { if count <= 0 { return e } - pop := !k.Reduce() && since(c, k, s.at, 10, 2) < 2 + pop := !k.Reduce() && k.Since(c, s.at, 10, 2) < 2 n, tag := "×"+strconv.Itoa(count), k.Msg("combo") w := pixel.Measure(n)*3 + 6 + pixel.Measure(tag)*2 + 2 return e.Role(ui.RoleStatus).Label(n+" "+tag).Size(k.D(w), k.D(3*pixel.LineHeight+2)).Draw(func(p *ui.Painter, r ui.Rect) { diff --git a/feedback/countup.go b/feedback/countup.go index d19ddcb..f7e0815 100644 --- a/feedback/countup.go +++ b/feedback/countup.go @@ -32,7 +32,7 @@ func CountUp(c *ui.Context, value int, o CountUpOptions) *ui.Element { shown := func() int { step := 8 if !k.Reduce() { - step = since(c, k, s.at, 15, 8) + step = k.Since(c, s.at, 15, 8) } return s.from + (s.to-s.from)*step/8 } diff --git a/feedback/feedback.go b/feedback/feedback.go index f71818d..9ff3ddf 100644 --- a/feedback/feedback.go +++ b/feedback/feedback.go @@ -52,15 +52,3 @@ func cue(e *ui.Element, k pixel.Kit, n int) time.Time { } return s.at } - -// since counts steps at fps from t, up to done. -func since(c *ui.Context, k pixel.Kit, t time.Time, fps, done int) int { - if t.IsZero() { - return done - } - n := int(k.Now().Sub(t) * time.Duration(fps) / time.Second) - if n < done { - k.Tick(c, fps) - } - return min(n, done) -} diff --git a/feedback/levelup.go b/feedback/levelup.go index 75141bb..c0660af 100644 --- a/feedback/levelup.go +++ b/feedback/levelup.go @@ -30,7 +30,7 @@ func LevelUp(c *ui.Context, level int, o LevelUpOptions) *ui.Element { } s.level = level // Ten steps a second: six slide in, twenty stay. - step := since(c, k, s.at, 10, 26) + step := k.Since(c, s.at, 10, 26) if step >= 26 { return e } diff --git a/feedback/readygo.go b/feedback/readygo.go index 94780dd..ade3a75 100644 --- a/feedback/readygo.go +++ b/feedback/readygo.go @@ -38,7 +38,7 @@ func ReadyGo(c *ui.Context, o ReadyGoOptions) ReadyGoResult { *s = ready{n: o.Cue, at: k.Now()} } res := ReadyGoResult{Element: e} - sec := since(c, k, s.at, 10, 40) / 10 + sec := k.Since(c, s.at, 10, 40) / 10 // Done comes once, even when time jumps past GO!. if sec >= 3 && !s.said && !s.at.IsZero() { s.said, res.Done = true, true diff --git a/feedback/shake.go b/feedback/shake.go index 925a42b..9f5bdd4 100644 --- a/feedback/shake.go +++ b/feedback/shake.go @@ -19,7 +19,7 @@ func Shake(c *ui.Context, o ShakeOptions, content func()) *ui.Element { k := pixel.K(c) e := ui.Box(c) dx := 0 - if step := since(c, k, cue(e, k, o.Cue), 20, len(shakes)-1); !k.Reduce() { + if step := k.Since(c, cue(e, k, o.Cue), 20, len(shakes)-1); !k.Reduce() { dx = shakes[step] } return e.Padding(0, k.D(2-dx), 0, k.D(2+dx)).Children(content) diff --git a/gallery/demos_navigation.go b/gallery/demos_navigation.go new file mode 100644 index 0000000..3a3e7d5 --- /dev/null +++ b/gallery/demos_navigation.go @@ -0,0 +1,301 @@ +package gallery + +import ( + "fmt" + "slices" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/navigation" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var navigationEntries = []Entry{ + {131, "Tabs", "标签页", "navigation", "Folder tabs; the chosen one joins its panel. Arrows move.", demoTabs}, + {132, "VerticalTabs", "竖排标签", "navigation", "Tabs down the left edge, for settings screens.", demoVerticalTabs}, + {133, "Sidebar", "侧边栏", "navigation", "Sections of items with icons and badges. Up and Down choose; one Tab stop.", demoSidebar}, + {134, "Breadcrumbs", "面包屑", "navigation", "Where you are, split by ›. Every part but the last walks back.", demoBreadcrumbs}, + {135, "CommandMenu", "指令菜单", "navigation", "FIGHT, MAGIC, ITEM, RUN, with a bobbing ▶. Enter picks; Escape backs out.", demoCommandMenu}, + {136, "MenuBar", "菜单栏", "navigation", "A bar of menus. Click one, then the arrows go across and down.", demoMenuBar}, + {137, "DropdownMenu", "下拉菜单", "navigation", "A button with a menu: icons, rules and shortcut names.", demoDropdownMenu}, + {138, "ContextMenu", "右键菜单", "navigation", "Right click, or the menu key, opens a menu at the pointer.", demoContextMenu}, + {139, "Pagination", "分页", "navigation", "◀ 1 … 4 5 6 … 12 ▶. The current page is pressed in.", demoPagination}, + {140, "PageDots", "页码点", "navigation", "A dot per page; click one, or use the arrows.", demoPageDots}, + {141, "Steps", "关卡步骤", "navigation", "Flags along a trail: cleared, waving where you are, locked ahead.", demoSteps}, + {142, "NavRail", "导航栏", "navigation", "A rail of icons. Hover or focus one and it says its name.", demoNavRail}, + {143, "BottomNav", "底部导航", "navigation", "Icons over labels along the bottom, for small screens.", demoBottomNav}, + {144, "CommandPalette", "命令面板", "navigation", "Cmd+K, type a few letters, press Enter.", demoCommandPalette}, + {145, "BackForward", "前进后退", "navigation", "◀ and ▶ walking a router's history.", demoBackForward}, + {146, "TableOfContents", "目录", "navigation", "A manual's sections; ▶ marks where you are.", demoTableOfContents}, + {147, "LevelSelect", "选关", "navigation", "Stages in a grid: locked, open, or cleared with stars.", demoLevelSelect}, + {148, "WorldMap", "世界地图", "navigation", "Places on dotted trails. The arrows walk the hero; Enter goes in.", demoWorldMap}, + {149, "Toolbar", "工具栏", "navigation", "Tool buttons as one Tab stop; arrows move along.", demoToolbar}, + {150, "DocumentTabs", "文档标签", "navigation", "Tabs you can close with × or Delete.", demoDocumentTabs}, +} + +type navDemo struct { + tab, vtab, side, toc, rail, bottom, level, node, doc int + page, dot int + crumbs, docs []string + palette bool + router *ui.Router + pen, fill bool + newDocs int +} + +func navState(c *ui.Context) *navDemo { + return ui.Local(c.Root(), demoKey("navigation"), func() navDemo { + r := ui.NewRouter("/town") + r.Push("/field") + return navDemo{page: 5, dot: 1, level: 2, crumbs: []string{"World", "Cave", "B1", "B2"}, + docs: []string{"map.txt", "save1.dat", "notes.md"}, router: r, pen: true} + }) +} + +func demoTabs(c *ui.Context, log func(string)) { + s := navState(c) + names := []string{"ITEMS", "MAGIC", "STATUS"} + body := []string{"Potion ×3, Ether ×1, Tent ×1", "Fire, Cure, Haste", "HP 42/42 MP 12/18 LV 7"} + if navigation.Tabs(c, "Menu", names, &s.tab, navigation.TabsOptions{}, func() { + pixel.Text(c, body[s.tab], pixel.TextOptions{}) + }).Changed { + log(names[s.tab]) + } +} + +func demoVerticalTabs(c *ui.Context, log func(string)) { + s := navState(c) + names := []string{"Video", "Audio", "Controls"} + if navigation.VerticalTabs(c, "Settings", names, &s.vtab, navigation.TabsOptions{}, func() { + pixel.Text(c, names[s.vtab]+" settings go here.", pixel.TextOptions{Slot: pixel.Faint}) + }).Changed { + log(names[s.vtab]) + } +} + +func demoSidebar(c *ui.Context, log func(string)) { + s := navState(c) + if navigation.Sidebar(c, "Places", []navigation.SidebarSection{ + {Title: "WORLD", Items: []navigation.SidebarItem{{Icon: icon.Home, Label: "Town"}, {Icon: icon.Map, Label: "Field"}, {Icon: icon.Skull, Label: "Dungeon"}}}, + {Title: "INBOX", Items: []navigation.SidebarItem{{Icon: icon.Mail, Label: "Letters", Badge: "3"}}}, + {Title: "SYSTEM", Items: []navigation.SidebarItem{{Icon: icon.Settings, Label: "Options"}}}, + }, &s.side, navigation.SidebarOptions{}).Changed { + log(fmt.Sprintf("item %d", s.side)) + } +} + +func demoBreadcrumbs(c *ui.Context, log func(string)) { + s := navState(c) + if r := navigation.Breadcrumbs(c, "Path", s.crumbs, navigation.BreadcrumbsOptions{}); r.Clicked >= 0 { + s.crumbs = s.crumbs[:r.Clicked+1] + log("back to " + s.crumbs[r.Clicked]) + } + if len(s.crumbs) < 4 && input.Button(c, "GO DEEPER", input.ButtonOptions{Size: input.Small}).Clicked { + s.crumbs = append(s.crumbs, fmt.Sprintf("B%d", len(s.crumbs)-1)) + log("deeper") + } +} + +func demoCommandMenu(c *ui.Context, log func(string)) { + items := []navigation.MenuItem{{Label: "FIGHT"}, {Label: "MAGIC", Disabled: true}, {Label: "ITEM"}, {Label: "RUN"}} + r := navigation.CommandMenu(c, "Command", items, navigation.CommandMenuOptions{}) + if r.Chosen >= 0 { + log(items[r.Chosen].Label) + } + if r.Canceled { + log("back") + } +} + +func demoMenuBar(c *ui.Context, log func(string)) { + menus := []navigation.Menu{ + {Title: "File", Items: []navigation.MenuItem{{Label: "New game"}, {Label: "Load", Icon: icon.FolderOpen}, {Label: "Save", Icon: icon.Save, Keys: "CTRL+S"}, {Separator: true}, {Label: "Quit"}}}, + {Title: "Edit", Items: []navigation.MenuItem{{Label: "Undo", Keys: "CTRL+Z"}, {Label: "Redo", Keys: "CTRL+Y", Disabled: true}}}, + {Title: "Help", Items: []navigation.MenuItem{{Label: "Controls"}, {Label: "About"}}}, + } + if r := navigation.MenuBar(c, "Main menu", menus, navigation.MenuBarOptions{}); r.Item >= 0 { + log(menus[r.Menu].Title + " › " + menus[r.Menu].Items[r.Item].Label) + } +} + +func demoDropdownMenu(c *ui.Context, log func(string)) { + items := []navigation.MenuItem{{Label: "Swap", Icon: icon.Refresh}, {Label: "Equip", Icon: icon.Sword}, {Label: "Status", Icon: icon.User}, {Separator: true}, {Label: "Dismiss", Icon: icon.Trash, Disabled: true}} + if r := navigation.DropdownMenu(c, "Party", items, navigation.DropdownMenuOptions{}); r.Chosen >= 0 { + log(items[r.Chosen].Label) + } +} + +func demoContextMenu(c *ui.Context, log func(string)) { + k := pixel.K(c) + items := []navigation.MenuItem{{Label: "Inspect", Icon: icon.Search}, {Label: "Pet", Icon: icon.Heart}, {Separator: true}, {Label: "Throw", Icon: icon.Bolt}} + if r := navigation.ContextMenu(c, "Slime", items, navigation.ContextMenuOptions{}, func() { + ui.Box(c).Size(k.D(160), k.D(40)).Label("Right click the slime").Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Dither(0, 0, g.W, g.H, pixel.Face, pixel.Good, 2) + g.Text(8, 12, "Right click the slime", pixel.Faint, 1) + }) + }); r.Chosen >= 0 { + log(items[r.Chosen].Label) + } +} + +func demoPagination(c *ui.Context, log func(string)) { + s := navState(c) + if navigation.Pagination(c, "Pages", &s.page, 12, navigation.PaginationOptions{}).Changed { + log(fmt.Sprintf("page %d", s.page)) + } +} + +func demoPageDots(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := navState(c) + tips := []string{"Jump with Space.", "Hold Shift to run.", "Talk to everyone.", "Check the pots.", "Save often."} + ui.Column(c).Gap(k.D(4)).Children(func() { + pixel.Text(c, tips[s.dot-1], pixel.TextOptions{}) + if navigation.PageDots(c, "Tips", &s.dot, len(tips), navigation.PageDotsOptions{}).Changed { + log(fmt.Sprintf("tip %d", s.dot)) + } + }) +} + +func demoSteps(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := navState(c) + stops := []string{"Gate", "Forest", "Cave", "Peak", "Castle"} + ui.Column(c).Gap(k.D(6)).Children(func() { + navigation.Steps(c, stops, s.level, navigation.StepsOptions{}) + ui.Row(c).Gap(k.D(4)).Children(func() { + if input.Button(c, "BACK", input.ButtonOptions{Size: input.Small, Disabled: s.level == 0}).Clicked { + s.level-- + log("at " + stops[s.level]) + } + if input.Button(c, "ONWARD", input.ButtonOptions{Size: input.Small, Disabled: s.level == len(stops)-1}).Clicked { + s.level++ + log("at " + stops[s.level]) + } + }) + }) +} + +func demoNavRail(c *ui.Context, log func(string)) { + s := navState(c) + items := []navigation.NavItem{{Icon: icon.Home, Label: "Home"}, {Icon: icon.Map, Label: "Map"}, {Icon: icon.Gem, Label: "Inventory"}, {Icon: icon.Settings, Label: "Options"}} + if navigation.NavRail(c, "Rail", items, &s.rail, navigation.NavRailOptions{}).Changed { + log(items[s.rail].Label) + } +} + +func demoBottomNav(c *ui.Context, log func(string)) { + s := navState(c) + items := []navigation.NavItem{{Icon: icon.Home, Label: "Home"}, {Icon: icon.Flag, Label: "Quests"}, {Icon: icon.Chest, Label: "Bag"}, {Icon: icon.User, Label: "Me"}} + if navigation.BottomNav(c, "Bottom", items, &s.bottom, navigation.BottomNavOptions{}).Changed { + log(items[s.bottom].Label) + } +} + +func demoCommandPalette(c *ui.Context, log func(string)) { + s := navState(c) + cmds := []navigation.MenuItem{{Label: "Save game", Keys: "CTRL+S"}, {Label: "Load game"}, {Label: "Toggle music"}, {Label: "Change palette", Keys: "P"}, {Label: "Rename hero"}, {Label: "Delete save", Disabled: true}} + if input.Button(c, "OPEN (CMD+K)", input.ButtonOptions{Size: input.Small}).Clicked { + s.palette = true + log("palette open") + } + if r := navigation.CommandPalette(c, "Commands", cmds, &s.palette, navigation.CommandPaletteOptions{}); r.Ran >= 0 { + log("ran " + cmds[r.Ran].Label) + } +} + +func demoBackForward(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := navState(c) + ui.Row(c).Gap(k.D(6)).AlignItems(ui.Center).Children(func() { + if navigation.BackForward(c, s.router, navigation.BackForwardOptions{}).Moved { + log("now at " + s.router.Path()) + } + pixel.Text(c, s.router.Path(), pixel.TextOptions{Slot: pixel.Accent}) + for _, place := range []string{"/castle", "/shop"} { + if input.Button(c, place, input.ButtonOptions{Size: input.Small}).Clicked { + s.router.Push(place) + log("went to " + place) + } + } + }) +} + +func demoTableOfContents(c *ui.Context, log func(string)) { + s := navState(c) + items := []navigation.TocItem{{Title: "Getting started"}, {Title: "Controls", Level: 1}, {Title: "Saving", Level: 1}, {Title: "Combat"}, {Title: "Magic", Level: 1}, {Title: "Credits"}} + if navigation.TableOfContents(c, "Manual", items, &s.toc, navigation.TableOfContentsOptions{}).Changed { + log(items[s.toc].Title) + } +} + +func demoLevelSelect(c *ui.Context, log func(string)) { + s := navState(c) + stages := []navigation.Stage{ + {Name: "1-1", Cleared: true, Stars: 3}, {Name: "1-2", Cleared: true, Stars: 2}, {Name: "1-3", Cleared: true, Stars: 1}, + {Name: "1-4"}, {Name: "1-5", Locked: true}, {Name: "2-1", Locked: true}, {Name: "2-2", Locked: true}, + {Name: "2-3", Locked: true}, {Name: "2-4", Locked: true}, {Name: "BOSS", Locked: true}, + } + r := navigation.LevelSelect(c, "Stages", stages, &s.level, navigation.LevelSelectOptions{}) + if r.Changed { + log("on " + stages[s.level].Name) + } + if r.Chosen { + log("enter " + stages[s.level].Name) + } +} + +func demoWorldMap(c *ui.Context, log func(string)) { + s := navState(c) + nodes := []navigation.MapNode{{Name: "Home", X: 24, Y: 100}, {Name: "Woods", X: 84, Y: 70}, {Name: "Lake", X: 150, Y: 100}, {Name: "Tower", X: 120, Y: 28}, {Name: "Lair", X: 206, Y: 40, Locked: true}} + paths := [][2]int{{0, 1}, {1, 2}, {1, 3}, {3, 4}, {2, 4}} + r := navigation.WorldMap(c, "World", nodes, paths, &s.node, navigation.WorldMapOptions{}) + if r.Changed { + log("walked to " + nodes[s.node].Name) + } + if r.Entered { + log("entered " + nodes[s.node].Name) + } +} + +func demoToolbar(c *ui.Context, log func(string)) { + s := navState(c) + navigation.Toolbar(c, "Tools", navigation.ToolbarOptions{}, func() { + if input.Toggle(c, "", &s.pen, input.ToggleOptions{Icon: icon.Edit, Label: "Pen"}).Changed { + log(fmt.Sprintf("pen %v", s.pen)) + } + if input.Toggle(c, "", &s.fill, input.ToggleOptions{Icon: icon.Drop, Label: "Fill"}).Changed { + log(fmt.Sprintf("fill %v", s.fill)) + } + if input.IconButton(c, icon.Prev, "Undo", input.IconButtonOptions{}).Clicked { + log("undo") + } + if input.IconButton(c, icon.Trash, "Clear", input.IconButtonOptions{Variant: input.Danger}).Clicked { + log("clear") + } + }) +} + +func demoDocumentTabs(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := navState(c) + ui.Column(c).Gap(k.D(4)).Children(func() { + r := navigation.DocumentTabs(c, "Files", s.docs, &s.doc, navigation.DocumentTabsOptions{}) + if r.Changed { + log("open " + s.docs[s.doc]) + } + if r.Closed >= 0 { + log("closed " + s.docs[r.Closed]) + s.docs = slices.Delete(s.docs, r.Closed, r.Closed+1) + s.doc = max(0, min(s.doc, len(s.docs)-1)) + } + if input.Button(c, "NEW TAB", input.ButtonOptions{Size: input.Small}).Clicked { + s.newDocs++ + s.docs = append(s.docs, fmt.Sprintf("untitled%d.txt", s.newDocs)) + log("new tab") + } + }) +} diff --git a/gallery/registry.go b/gallery/registry.go index 6fa62d9..d257132 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, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries) +var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries) func Entries() []Entry { return entries } diff --git a/input/button.go b/input/button.go index de990eb..c866928 100644 --- a/input/button.go +++ b/input/button.go @@ -66,7 +66,7 @@ func Button(c *ui.Context, text string, o ButtonOptions) ButtonResult { if text != "" { g.Text(x, top+dy, text, ink, sc) } - finish(g, st) + g.Finish(st) }) res := ButtonResult{Element: shell, Clicked: base.Clicked()} if res.Clicked { diff --git a/input/buttongroup.go b/input/buttongroup.go index 55e8ed4..5111394 100644 --- a/input/buttongroup.go +++ b/input/buttongroup.go @@ -38,7 +38,7 @@ func ButtonGroup(c *ui.Context, labels []string, o ButtonGroupOptions) ButtonGro g := k.Canvas(p, r) ink, dy := face(g, o.Variant, st, false) g.Text(pad, top+dy, l, ink, sc) - finish(g, st) + g.Finish(st) }) if base.Clicked() { res.Clicked = i diff --git a/input/cheatcode.go b/input/cheatcode.go index 52e9683..21c74df 100644 --- a/input/cheatcode.go +++ b/input/cheatcode.go @@ -82,7 +82,7 @@ func CheatCode(c *ui.Context, text string, o CheatCodeOptions) CheatCodeResult { } glyph(g, 2+i*10, 4, key, kink) } - finish(g, st) + g.Finish(st) }) return res } diff --git a/input/checkbox.go b/input/checkbox.go index 94d49a3..61bd222 100644 --- a/input/checkbox.go +++ b/input/checkbox.go @@ -64,5 +64,5 @@ func box(g pixel.Canvas, text string, mark *pixel.Sprite, st pixel.State) { ink = pixel.Faint } g.Text(12, 0, text, ink, 1) - finish(g, st) + g.Finish(st) } diff --git a/input/colorslot.go b/input/colorslot.go index 0af449b..6888e6a 100644 --- a/input/colorslot.go +++ b/input/colorslot.go @@ -21,7 +21,7 @@ type ColorSlotResult struct { func ColorSlot(c *ui.Context, text string, value *pixel.Slot, o ColorSlotOptions) ColorSlotResult { k := pixel.K(c) cur := int(*value) - shell, base, st, _ := pad(c, text, 8, pixel.Slots, 12, 12, &cur, o.Disabled) + shell, base, st, _ := pixel.Pad(c, text, 8, pixel.Slots, 12, 12, &cur, o.Disabled) res := ColorSlotResult{Element: shell, Changed: pixel.Slot(cur) != *value} *value = pixel.Slot(cur) base.Description(value.String()) @@ -36,7 +36,7 @@ func ColorSlot(c *ui.Context, text string, value *pixel.Slot, o ColorSlotOptions g.Rect(x+2, y+2, 8, 8, pixel.Edge) g.Rect(x+3, y+3, 6, 6, pixel.Slot(i)) } - finish(g, st) + g.Finish(st) }) if res.Changed { slog.Debug("8bit: color slot", "label", text, "slot", *value) diff --git a/input/dpad.go b/input/dpad.go index 1a75686..618fd6e 100644 --- a/input/dpad.go +++ b/input/dpad.go @@ -95,7 +95,7 @@ func DPad(c *ui.Context, text string, o DPadOptions) DPadResult { g.Sprite(a.x+(a.w-a.sp.W)/2, a.y+(a.h-a.sp.H)/2+dy, a.sp, 0, pixel.Ink, 1) } g.Rect(13, 13, 10, 10, pixel.Shade) - finish(g, st) + g.Finish(st) }) return res } diff --git a/input/editabletext.go b/input/editabletext.go index 229ddeb..2868124 100644 --- a/input/editabletext.go +++ b/input/editabletext.go @@ -73,7 +73,7 @@ func EditableText(c *ui.Context, text string, value *string, o EditableTextOptio } g.Text(4, 3, shown, ink, 1) g.Veil(0, g.H-1, g.W, 1, pixel.Faint, 8) - finish(g, st) + g.Finish(st) }) }) if res.Changed { diff --git a/input/fader.go b/input/fader.go index 4ba88a0..391f08f 100644 --- a/input/fader.go +++ b/input/fader.go @@ -33,7 +33,7 @@ func Fader(c *ui.Context, text string, value *float64, o FaderOptions) FaderResu g.Rect(7, y+3, 2, g.H-y-4, pixel.Accent) g.Frame(2, y, 12, 7, pixel.Raised, pixel.Face) g.HLine(4, y+3, 8, pixel.Edge) - finish(g, st) + g.Finish(st) }) return report(shell, changed, "fader", text, *value) } diff --git a/input/holdbutton.go b/input/holdbutton.go index b3f2cdc..bf410c6 100644 --- a/input/holdbutton.go +++ b/input/holdbutton.go @@ -93,7 +93,7 @@ func HoldButton(c *ui.Context, text string, o HoldButtonOptions) HoldButtonResul g.Rect(1, 1+dy, w, g.H-2-dy, fill) g.Text(8, 2+dy, text, ink, 1) g.Clip(1, 0, w, g.H, func() { g.Text(8, 2+dy, text, pixel.AccentInk, 1) }) - finish(g, st) + g.Finish(st) }) return res } diff --git a/input/input.go b/input/input.go index dc794c6..d155bf1 100644 --- a/input/input.go +++ b/input/input.go @@ -78,16 +78,6 @@ func face(g pixel.Canvas, v Variant, st pixel.State, on bool) (ink pixel.Slot, d return ink, dy } -// finish dithers out a disabled control and marks focus. -func finish(g pixel.Canvas, st pixel.State) { - if st.Disabled { - g.Checker(0, 0, g.W, g.H, pixel.Face) - } - if st.Focus { - g.FocusMark(0, 0, g.W, g.H) - } -} - // label is s, or the fallback when s is empty. func label(s, fallback string) string { if s != "" { diff --git a/input/joystick.go b/input/joystick.go index 100ba31..7f37883 100644 --- a/input/joystick.go +++ b/input/joystick.go @@ -79,7 +79,7 @@ func Joystick(c *ui.Context, text string, o JoystickOptions) JoystickResult { g.Disc(x, y, 7, pixel.Edge) g.Disc(x, y, 6, pixel.Accent) g.Disc(x-2, y-2, 2, pixel.AccentLit) - finish(g, st) + g.Finish(st) }) return JoystickResult{Element: shell, X: s.x, Y: s.y, Moved: s.x != ox || s.y != oy} } diff --git a/input/knob.go b/input/knob.go index a18352f..0df8ba4 100644 --- a/input/knob.go +++ b/input/knob.go @@ -49,7 +49,7 @@ func Knob(c *ui.Context, text string, value *float64, o KnobOptions) KnobResult x, y := 12+int(math.Round(7*math.Sin(a))), 12-int(math.Round(7*math.Cos(a))) g.Line(12, 12, x, y, pixel.Accent) g.Dot(x, y, pixel.Ink) - finish(g, st) + g.Finish(st) }) return report(shell, *value != old, "knob", text, *value) } diff --git a/input/multiselect.go b/input/multiselect.go index a2dcb1f..977233d 100644 --- a/input/multiselect.go +++ b/input/multiselect.go @@ -49,7 +49,7 @@ func MultiSelect(c *ui.Context, text string, chosen *[]string, options []string, } tags(g, *chosen, 3, 2+dy, g.W-14) g.Sprite(g.W-10, (g.H-tinyDown.H)/2+dy, tinyDown, 0, ink, 1) - finish(g, st) + g.Finish(st) }) res := MultiSelectResult{Element: shell} ui.PopoverBase(c, base, open, func(panel *ui.Element) { diff --git a/input/nameentry.go b/input/nameentry.go index 2c11ccb..391a3d2 100644 --- a/input/nameentry.go +++ b/input/nameentry.go @@ -47,7 +47,7 @@ func NameEntry(c *ui.Context, text string, name *string, o NameEntryOptions) Nam g.Rect(x, 14, 12, 2, ink) } }) - shell, base, st, picked := pad(c, text, 7, nameEnd+1, 14, pixel.LineHeight, cur, o.Disabled) + shell, base, st, picked := pixel.Pad(c, text, 7, nameEnd+1, 14, pixel.LineHeight, cur, o.Disabled) switch { case picked == nameDel && len(*name) > 0: *name, res.Changed = (*name)[:len(*name)-1], true @@ -66,7 +66,7 @@ func NameEntry(c *ui.Context, text string, name *string, o NameEntryOptions) Nam for i := range nameEnd + 1 { x, y := i%7*14, i/7*pixel.LineHeight if focused && i == *cur { - cursor(g, x, y, 14, pixel.LineHeight) + g.Cursor(x, y, 14, pixel.LineHeight) } switch i { case nameDel: @@ -78,7 +78,7 @@ func NameEntry(c *ui.Context, text string, name *string, o NameEntryOptions) Nam g.Text(x+(14-pixel.Measure(l))/2, y, l, pixel.Ink, 1) } } - finish(g, st) + g.Finish(st) }) }) if res.Confirmed { diff --git a/input/pininput.go b/input/pininput.go index 2b4bb67..865a7fa 100644 --- a/input/pininput.go +++ b/input/pininput.go @@ -73,7 +73,7 @@ func PinInput(c *ui.Context, text string, value *string, o PinInputOptions) PinI g.HLine(x+2, h-4, pinBox-4, pixel.Accent) } } - finish(g, st) + g.Finish(st) }) if res.Complete { slog.Debug("8bit: pin complete", "label", text) diff --git a/input/radio.go b/input/radio.go index 821dde4..001824c 100644 --- a/input/radio.go +++ b/input/radio.go @@ -58,5 +58,5 @@ func dot(g pixel.Canvas, text string, on bool, st pixel.State) { ink = pixel.Faint } g.Text(12, 0, text, ink, 1) - finish(g, st) + g.Finish(st) } diff --git a/input/rangeslider.go b/input/rangeslider.go index 4bde118..4fa2b8c 100644 --- a/input/rangeslider.go +++ b/input/rangeslider.go @@ -66,6 +66,6 @@ func knob(c *ui.Context, k pixel.Kit, track *ui.Element, v *float64, r rail, lo, shell.Absolute().Left(k.D(knobAt(*v, lo, hi, w-6))).Top(k.D(1)).Size(k.D(6), k.D(10)).Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) g.Frame(0, 0, 6, 10, pixel.Raised, pixel.Face) - finish(g, st) + g.Finish(st) }) } diff --git a/input/rating.go b/input/rating.go index f9c2720..459dcca 100644 --- a/input/rating.go +++ b/input/rating.go @@ -61,7 +61,7 @@ func Rating(c *ui.Context, text string, value *int, o RatingOptions) RatingResul sp, ink = full, pixel.Accent } g.Sprite(1, 1, sp, 0, ink, 1) - finish(g, st) + g.Finish(st) }) } }) diff --git a/input/segmented.go b/input/segmented.go index f6cd439..59177ab 100644 --- a/input/segmented.go +++ b/input/segmented.go @@ -45,7 +45,7 @@ func Segmented(c *ui.Context, text string, labels []string, selected *int, o Seg g.VLine(0, 1, g.H-2, pixel.Edge) } g.Text(6, 2, l, ink, 1) - finish(g, st) + g.Finish(st) }) } }) diff --git a/input/select.go b/input/select.go index 18a1b42..72a6ac0 100644 --- a/input/select.go +++ b/input/select.go @@ -38,7 +38,7 @@ func Select(c *ui.Context, text string, value *string, options []string, o Selec ink, dy := face(g, Normal, st, sel.Open() && !off) g.Text(6, 2+dy, *value, ink, 1) g.Sprite(g.W-10, (g.H-tinyDown.H)/2+dy, tinyDown, 0, ink, 1) - finish(g, st) + g.Finish(st) }) if !off { sel.Popup(func(panel *ui.Element) { diff --git a/input/slider.go b/input/slider.go index e232c05..2371df3 100644 --- a/input/slider.go +++ b/input/slider.go @@ -34,7 +34,7 @@ func Slider(c *ui.Context, text string, value *float64, o SliderOptions) SliderR x := knobAt(*value, lo, hi, g.W-6) groove(g, x+3, 4) g.Frame(x, 1, 6, 10, pixel.Raised, pixel.Face) - finish(g, st) + g.Finish(st) }) return report(shell, changed, "slider", text, *value) } diff --git a/input/slotpicker.go b/input/slotpicker.go index db019a5..1addc62 100644 --- a/input/slotpicker.go +++ b/input/slotpicker.go @@ -86,7 +86,7 @@ func SlotPicker(c *ui.Context, text string, value *int, options []string, o Slot mid := 2 + pixel.LineHeight + (pixel.LineHeight-tinyRight.H)/2 g.Sprite(3, mid, tinyRight, 0, pixel.Accent, 1) g.Sprite(g.W-6, mid, tinyLeft, 0, pixel.Accent, 1) - finish(g, st) + g.Finish(st) }) return res } diff --git a/input/stepper.go b/input/stepper.go index 33f3b6a..ba9821d 100644 --- a/input/stepper.go +++ b/input/stepper.go @@ -51,7 +51,7 @@ func Stepper(c *ui.Context, text string, value *int, o StepperOptions) StepperRe shown := strconv.Itoa(*value) g.Frame(0, 0, g.W, g.H, pixel.Sunken, pixel.Well) g.Text((g.W-pixel.Measure(shown))/2, 2, shown, pixel.Ink, 1) - finish(g, st) + g.Finish(st) }) set(*value + step*held(c, k, tinyRight, o.Disabled)) base.Description(strconv.Itoa(*value)) diff --git a/input/stepslider.go b/input/stepslider.go index a36306e..775bb62 100644 --- a/input/stepslider.go +++ b/input/stepslider.go @@ -36,7 +36,7 @@ func StepSlider(c *ui.Context, text string, value *float64, o StepSliderOptions) g.Dot(knobAt(v, lo, hi, w-6)+3, 13, pixel.Faint) } g.Frame(x, 1, 6, 10, pixel.Raised, pixel.Face) - finish(g, st) + g.Finish(st) }) res = report(shell, changed, "step slider", text, *value) pixel.Text(c, fmtValue(*value), pixel.TextOptions{}) diff --git a/input/switch.go b/input/switch.go index 62bf6d5..79f00ab 100644 --- a/input/switch.go +++ b/input/switch.go @@ -39,7 +39,7 @@ func Switch(c *ui.Context, text string, on *bool, o SwitchOptions) SwitchResult ink = pixel.Faint } g.Text(32, 0, text, ink, 1) - finish(g, st) + g.Finish(st) }) res := SwitchResult{Element: shell, Changed: base.Changed()} if res.Changed { diff --git a/input/toggle.go b/input/toggle.go index 2d55fff..5d5cb41 100644 --- a/input/toggle.go +++ b/input/toggle.go @@ -42,7 +42,7 @@ func Toggle(c *ui.Context, text string, on *bool, o ToggleOptions) ToggleResult g.Sprite(pad, (h-o.Icon.H)/2+dy, o.Icon, 0, ink, 1) } g.Text(pad+lead, top+dy, text, ink, sc) - finish(g, st) + g.Finish(st) }) res := ToggleResult{Element: shell, Changed: base.Changed()} if res.Changed { diff --git a/input/togglegroup.go b/input/togglegroup.go index f746d8e..1db0aa3 100644 --- a/input/togglegroup.go +++ b/input/togglegroup.go @@ -40,7 +40,7 @@ func ToggleGroup(c *ui.Context, labels []string, on []bool, o ToggleGroupOptions g := k.Canvas(p, r) ink, dy := face(g, Normal, st, on[i]) g.Text(pad, top+dy, l, ink, sc) - finish(g, st) + g.Finish(st) }) if base.Changed() { res.Changed = true diff --git a/input/virtualkeyboard.go b/input/virtualkeyboard.go index a145dd1..68cb32a 100644 --- a/input/virtualkeyboard.go +++ b/input/virtualkeyboard.go @@ -57,7 +57,7 @@ func VirtualKeyboard(c *ui.Context, text string, value *string, o VirtualKeyboar g.Text(3, 2, string(rs), pixel.Ink, 1) g.Rect(4+pixel.Measure(string(rs)), 4, 5, 12, pixel.Accent) }) - shell, base, st, picked := pad(c, text, 10, len(caps), 14, pixel.LineHeight, cur, o.Disabled) + shell, base, st, picked := pixel.Pad(c, text, 10, len(caps), 14, pixel.LineHeight, cur, o.Disabled) if picked >= 0 { switch r := caps[picked]; r { case '\b': @@ -95,7 +95,7 @@ func VirtualKeyboard(c *ui.Context, text string, value *string, o VirtualKeyboar g.Text(x+(13-pixel.Measure(l))/2, y, l, pixel.Ink, 1) } } - finish(g, st) + g.Finish(st) }) }) if res.Submitted { diff --git a/navigation/breadcrumbs.go b/navigation/breadcrumbs.go new file mode 100644 index 0000000..8f7af81 --- /dev/null +++ b/navigation/breadcrumbs.go @@ -0,0 +1,56 @@ +package navigation + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type BreadcrumbsOptions struct { + Disabled bool +} + +type BreadcrumbsResult struct { + Element *ui.Element + // Clicked is the part clicked, or -1. + Clicked int +} + +// Breadcrumbs is a path whose parts, but the last, link. +func Breadcrumbs(c *ui.Context, text string, parts []string, o BreadcrumbsOptions) BreadcrumbsResult { + k := pixel.K(c) + res := BreadcrumbsResult{Clicked: -1} + res.Element = ui.Row(c).Gap(k.D(3)).AlignItems(ui.Center).Role(ui.RoleGroup).Label(text).Children(func() { + for i, part := range parts { + if i == len(parts)-1 { + pixel.Text(c, part, pixel.TextOptions{}) + break + } + shell, base, st := pixel.Shell(c, func() *ui.Element { + return ui.ButtonBase(c).Role(ui.RoleLink).Label(part).Disabled(o.Disabled) + }) + if base.Clicked() { + res.Clicked = i + } + shell.Size(k.D(pixel.Measure(part)+2), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + ink := pixel.Accent + if st.Hover { + ink = pixel.AccentLit + } + g.Text(1, 0, part, ink, 1) + g.HLine(1, g.H-2, g.W-2, ink) + g.Finish(st) + }) + ui.Box(c).Size(k.D(pointer.W), k.D(pointer.H)).Draw(func(p *ui.Painter, r ui.Rect) { + k.Canvas(p, r).Sprite(0, 0, pointer, 0, pixel.Faint, 1) + }) + } + }) + if res.Clicked >= 0 { + slog.Debug("8bit: breadcrumb", "part", parts[res.Clicked]) + } + return res +} diff --git a/navigation/commandmenu.go b/navigation/commandmenu.go new file mode 100644 index 0000000..d57a0e6 --- /dev/null +++ b/navigation/commandmenu.go @@ -0,0 +1,84 @@ +package navigation + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type CommandMenuOptions struct { + Disabled bool +} + +type CommandMenuResult struct { + Element *ui.Element + // Chosen is the item taken this frame, or -1. + Chosen int + Canceled bool +} + +type cursorKey struct{} + +// CommandMenu is a JRPG command window with a bobbing ▶. +func CommandMenu(c *ui.Context, text string, items []MenuItem, o CommandMenuOptions) CommandMenuResult { + k := pixel.K(c) + shell, base, st := pixel.Shell(c, func() *ui.Element { + return ui.Box(c).Focusable().Role(ui.RoleList).Label(text).Disabled(o.Disabled) + }) + cur := ui.Local(base, cursorKey{}, func() int { return 0 }) + res := CommandMenuResult{Element: shell, Chosen: -1} + n := len(items) + pick := func(i int) { + if !items[i].Disabled { + res.Chosen = i + } + } + if base.Shortcut(0, ui.KeyDown) { + *cur = (*cur + 1) % n + } + if base.Shortcut(0, ui.KeyUp) { + *cur = (*cur + n - 1) % n + } + enter, space := base.Shortcut(0, ui.KeyEnter), base.Shortcut(0, ui.KeySpace) + if enter || space { + pick(*cur) + } + res.Canceled = base.Shortcut(0, ui.KeyEscape) + // Enter and Space are taken, so clicks come from pointers. + if base.Clicked() { + _, y, _ := base.PointerPosition() + if i := int(y/k.Dot()-4) / pixel.LineHeight; i >= 0 && i < n { + *cur = i + pick(i) + } + } + base.Description(items[*cur].Label) + w := 0 + for _, it := range items { + w = max(w, pixel.Measure(it.Label)) + } + shell.Size(k.D(w+28), k.D(n*pixel.LineHeight+8)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Window, pixel.Face) + for i, it := range items { + y, ink := 4+i*pixel.LineHeight, pixel.Ink + if it.Disabled { + ink = pixel.Faint + } + if i == *cur { + g.Sprite(6+g.Step(4)%2, y+5, pointer, 0, pixel.Accent, 1) + } + g.Text(16, y, it.Label, ink, 1) + } + g.Finish(st) + }) + switch { + case res.Chosen >= 0: + slog.Debug("8bit: command", "label", text, "item", items[res.Chosen].Label) + case res.Canceled: + slog.Debug("8bit: command canceled", "label", text) + } + return res +} diff --git a/navigation/commandpalette.go b/navigation/commandpalette.go new file mode 100644 index 0000000..ddcf8d4 --- /dev/null +++ b/navigation/commandpalette.go @@ -0,0 +1,118 @@ +package navigation + +import ( + "log/slog" + "strings" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type CommandPaletteOptions struct { + Disabled bool +} + +type CommandPaletteResult struct { + Element *ui.Element + // Ran is the command run this frame, or -1. + Ran int +} + +type paletteKey struct{} + +// palette is the query, lit match and first shown row. +type palette struct { + query string + lit, top int +} + +// paletteRows is how many matches show at once. +const paletteRows = 8 + +// CommandPalette filters commands as you type; Cmd+K opens it. +func CommandPalette(c *ui.Context, text string, commands []MenuItem, open *bool, o CommandPaletteOptions) CommandPaletteResult { + k := pixel.K(c) + off := o.Disabled || pixel.Current(c).Inert + if !off && c.Shortcut(ui.Cmd, ui.KeyK) { + *open = !*open + } + if off { + *open = false + } + home := ui.Box(c) + st := ui.Local(home, paletteKey{}, func() palette { return palette{} }) + res := CommandPaletteResult{Element: home, Ran: -1} + if !*open { + *st = palette{} + return res + } + shown := filter(commands, st.query) + run := func(i int) { + if !commands[i].Disabled { + res.Ran, *open = i, false + } + } + res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) { + back.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Checker(0, 0, g.W, g.H, pixel.Back) + }) + panel.Width(k.D(240)).Padding(k.D(6)).Gap(k.D(4)).Label(text).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Window, pixel.Face) + }).Children(func() { + f := input.TextInput(c, text, &st.query, input.TextInputOptions{Placeholder: "> …", Width: 228}) + f.Input.AutoFocus() + if f.Changed { + st.lit = 0 + } + if f.Element.Shortcut(0, ui.KeyDown) { + st.lit++ + } + if f.Element.Shortcut(0, ui.KeyUp) { + st.lit-- + } + st.lit = max(0, min(st.lit, len(shown)-1)) + st.top = max(0, min(st.top, st.lit, len(shown)-paletteRows), st.lit-paletteRows+1) + // A Shortcut, not Submitted: the frame builds again closed. + if f.Element.Shortcut(0, ui.KeyEnter) && len(shown) > 0 { + run(shown[st.lit]) + } + ui.Column(c).AlignItems(ui.Stretch).Children(func() { + for j := st.top; j < min(len(shown), st.top+paletteRows); j++ { + it, lit := commands[shown[j]], j == st.lit + row := ui.Row(c).Role(ui.RoleListItem).Label(it.Label).Height(k.D(pixel.LineHeight)) + if it.Disabled { + row.Description(k.Msg("disabled")) + } + if row.Clicked() { + run(shown[j]) + } + row.Draw(func(p *ui.Painter, r ui.Rect) { menuRow(k.Canvas(p, r), it, lit) }) + } + }) + }) + }) + if res.Ran >= 0 { + slog.Debug("8bit: command palette", "label", text, "ran", commands[res.Ran].Label) + } + return res +} + +// filter indexes commands holding q, prefix matches first. +func filter(commands []MenuItem, q string) []int { + q = strings.ToLower(strings.TrimSpace(q)) + var starts, holds []int + for i, it := range commands { + switch l := strings.ToLower(it.Label); { + case it.Separator: + case strings.HasPrefix(l, q): + starts = append(starts, i) + case strings.Contains(l, q): + holds = append(holds, i) + } + } + return append(starts, holds...) +} diff --git a/navigation/contextmenu.go b/navigation/contextmenu.go new file mode 100644 index 0000000..dfb3504 --- /dev/null +++ b/navigation/contextmenu.go @@ -0,0 +1,81 @@ +package navigation + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type ContextMenuOptions struct { + Disabled bool +} + +type ContextMenuResult struct { + Element *ui.Element + // Chosen is the item taken this frame, or -1. + Chosen int +} + +type contextKey struct{} + +// spot is the menu and where it opened, in DIPs. +type spot struct { + m menu + x, y float32 +} + +// ContextMenu opens on right click or the menu key. +func ContextMenu(c *ui.Context, text string, items []MenuItem, o ContextMenuOptions, content func()) ContextMenuResult { + k := pixel.K(c) + off := o.Disabled || pixel.Current(c).Inert + area := ui.Box(c).FocusRing(false).Role(ui.RoleGroup).Label(text) + // The area takes focus, so the menu key reaches it. + if off { + area.Description(k.Msg("disabled")) + } else { + area.Focusable() + } + s := ui.Local(area, contextKey{}, func() spot { return spot{m: menu{lit: -1}} }) + pixel.Park(c, area, off) + if off { + s.m.open = false + } + res := ContextMenuResult{Element: area, Chosen: -1} + if area.RightClicked() && !off { + s.x, s.y, _ = area.PointerPosition() + s.m.show(items) + } + key, shiftF10 := area.Shortcut(0, ui.KeyContextMenu), area.Shortcut(ui.Shift, ui.KeyF10) + if (key || shiftF10) && !off { + // At the pointer when over the area, else its corner. + x, y, over := area.PointerPosition() + if !over { + x, y = 0, 0 + } + s.x, s.y = x, y + s.m.show(items) + } + // Right clicks keep the focus, so the window takes keys. + res.Chosen = s.m.keys(c.Shortcut, items) + focus := area.FocusVisible() + area.DrawOver(func(p *ui.Painter, r ui.Rect) { + if focus { + g := k.Canvas(p, r) + g.FocusMark(0, 0, g.W, g.H) + } + }).Children(content) + panel, clicked := s.m.list(c, area, items) + if clicked >= 0 { + res.Chosen = clicked + } + if panel != nil { + b := area.Bounds() + panel.Left(b.X + s.x).Top(b.Y + s.y) + } + if res.Chosen >= 0 { + slog.Debug("8bit: context menu", "item", items[res.Chosen].Label) + } + return res +} diff --git a/navigation/disabled_test.go b/navigation/disabled_test.go new file mode 100644 index 0000000..509fe3c --- /dev/null +++ b/navigation/disabled_test.go @@ -0,0 +1,85 @@ +package navigation_test + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/navigation" +) + +// Each control, disabled after taking focus, ignores every input. +func TestDisabledNavigationTakesNoInput(t *testing.T) { + type state struct{ n, events int } + items := []navigation.MenuItem{{Label: "A"}, {Label: "B"}} + cases := []struct { + name, label string + build func(c *ui.Context, v *state, off bool) + }{ + {"Tabs", "Two", func(c *ui.Context, v *state, off bool) { + navigation.Tabs(c, "T", []string{"One", "Two", "Six"}, &v.n, navigation.TabsOptions{Disabled: off}, func() {}) + }}, + {"Sidebar", "Two", func(c *ui.Context, v *state, off bool) { + navigation.Sidebar(c, "S", []navigation.SidebarSection{{Items: []navigation.SidebarItem{{Label: "One"}, {Label: "Two"}, {Label: "Six"}}}}, &v.n, navigation.SidebarOptions{Disabled: off}) + }}, + {"PageDots", "Dots", func(c *ui.Context, v *state, off bool) { + v.n = max(v.n, 1) + navigation.PageDots(c, "Dots", &v.n, 5, navigation.PageDotsOptions{Disabled: off}) + }}, + {"CommandMenu", "Cmd", func(c *ui.Context, v *state, off bool) { + if r := navigation.CommandMenu(c, "Cmd", items, navigation.CommandMenuOptions{Disabled: off}); r.Chosen >= 0 || r.Canceled { + v.events++ + } + }}, + {"DropdownMenu", "Drop", func(c *ui.Context, v *state, off bool) { + if navigation.DropdownMenu(c, "Drop", items, navigation.DropdownMenuOptions{Disabled: off}).Chosen >= 0 { + v.events++ + } + }}, + {"LevelSelect", "Stages", func(c *ui.Context, v *state, off bool) { + if navigation.LevelSelect(c, "Stages", []navigation.Stage{{Name: "1"}, {Name: "2"}, {Name: "3"}}, &v.n, navigation.LevelSelectOptions{Disabled: off}).Chosen { + v.events++ + } + }}, + {"WorldMap", "World", func(c *ui.Context, v *state, off bool) { + nodes := []navigation.MapNode{{Name: "A", X: 20, Y: 20}, {Name: "B", X: 80, Y: 20}} + if navigation.WorldMap(c, "World", nodes, [][2]int{{0, 1}}, &v.n, navigation.WorldMapOptions{Disabled: off}).Entered { + v.events++ + } + }}, + {"DocumentTabs", "Two", func(c *ui.Context, v *state, off bool) { + if navigation.DocumentTabs(c, "D", []string{"One", "Two"}, &v.n, navigation.DocumentTabsOptions{Disabled: off}).Closed >= 0 { + v.events++ + } + }}, + {"NavRail", "Map", func(c *ui.Context, v *state, off bool) { + navigation.NavRail(c, "R", []navigation.NavItem{{Icon: icon.Home, Label: "Home"}, {Icon: icon.Map, Label: "Map"}, {Icon: icon.Gem, Label: "Gem"}}, &v.n, navigation.NavRailOptions{Disabled: off}) + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var v state + off := false + tt := view(500, 400, newClock(), false, func(c *ui.Context) { + tc.build(c, &v, off) + ui.Box(c).Size(4, 4).Label("elsewhere") + }) + click(t, tt, tc.label) + off = true + tt.Frame() + if tt.Focused(tc.label) && !tt.Focused("elsewhere") { + t.Fatal("a disabled control kept the focus") + } + before := fmt.Sprint(v) + for _, k := range []ui.Key{ui.KeyDown, ui.KeyRight, ui.KeyUp, ui.KeyLeft, ui.KeyEnd, ui.KeyEnter, ui.KeySpace, ui.KeyEscape, ui.KeyDelete} { + tt.Key(0, k) + } + click(t, tt, tc.label) + if now := fmt.Sprint(v); now != before { + t.Fatalf("disabled control changed: %s to %s", before, now) + } + }) + } +} diff --git a/navigation/documenttabs.go b/navigation/documenttabs.go new file mode 100644 index 0000000..72a1fbf --- /dev/null +++ b/navigation/documenttabs.go @@ -0,0 +1,66 @@ +package navigation + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var cross = pixel.MustSprite(` + #...# + .#.#. + ..#.. + .#.#. + #...# +`) + +type DocumentTabsOptions struct { + Disabled bool +} + +type DocumentTabsResult struct { + Element *ui.Element + Changed bool + // Closed is the tab × or Delete closed, or -1. + Closed int +} + +// DocumentTabs are closable tabs; × or Delete reports Closed. +func DocumentTabs(c *ui.Context, text string, titles []string, selected *int, o DocumentTabsOptions) DocumentTabsResult { + k := pixel.K(c) + res := DocumentTabsResult{Closed: -1} + p := ui.TabsBase(c, selected, len(titles)) + p.List.Label(text).Children(func() { + for i, t := range titles { + var x *ui.Element + shell, base, st := pixel.Shell(c, func() *ui.Element { + tab := p.Tab(i).Label(t).Disabled(o.Disabled) + // The × is no Tab stop; Delete closes by keyboard. + tab.Children(func() { + x = ui.Box(c).Absolute().Right(k.D(4)).Top(k.D(6)).Size(k.D(7), k.D(7)).Role(ui.RoleButton).Label(k.Msg("close") + " " + t) + }) + return tab + }) + if x.Clicked() || base.Shortcut(0, ui.KeyDelete) { + res.Closed = i + } + hot := x.Hovered() + shell.Size(k.D(pixel.Measure(t)+24), k.D(pixel.LineHeight+6)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + folder(g, t, i == *selected, false, st) + ink := pixel.Faint + if hot { + ink = pixel.Bad + } + g.Sprite(g.W-10, 7, cross, 0, ink, 1) + }) + } + }) + res.Element, res.Changed = p.List, p.List.Changed() + if res.Closed >= 0 { + slog.Debug("8bit: tab closed", "label", text, "tab", titles[res.Closed]) + } + return res +} diff --git a/navigation/dropdown.go b/navigation/dropdown.go new file mode 100644 index 0000000..3cad1c8 --- /dev/null +++ b/navigation/dropdown.go @@ -0,0 +1,49 @@ +package navigation + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type DropdownMenuOptions struct { + Disabled bool +} + +type DropdownMenuResult struct { + Element *ui.Element + // Chosen is the item taken this frame, or -1. + Chosen int +} + +// DropdownMenu is a button opening a menu of items. +func DropdownMenu(c *ui.Context, text string, items []MenuItem, o DropdownMenuOptions) DropdownMenuResult { + off := o.Disabled || pixel.Current(c).Inert + b := input.Button(c, text+" ▼", input.ButtonOptions{Label: text, Disabled: o.Disabled}) + m := ui.Local(b.Element, menuKey{}, func() menu { return menu{lit: -1} }) + if off { + // Closing on disable must not return focus to it. + if m.open { + c.Root().Focus() + } + m.open = false + } + res := DropdownMenuResult{Element: b.Element, Chosen: -1} + switch { + case b.Clicked && m.open: + m.open = false + case b.Clicked, !m.open && !off && b.Element.Shortcut(0, ui.KeyDown): + m.show(items) + } + res.Chosen = m.keys(b.Element.Shortcut, items) + if _, clicked := m.list(c, b.Element, items); clicked >= 0 { + res.Chosen = clicked + } + if res.Chosen >= 0 { + slog.Debug("8bit: dropdown", "label", text, "item", items[res.Chosen].Label) + } + return res +} diff --git a/navigation/levelselect.go b/navigation/levelselect.go new file mode 100644 index 0000000..ab67a2f --- /dev/null +++ b/navigation/levelselect.go @@ -0,0 +1,84 @@ +package navigation + +import ( + "log/slog" + "strconv" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var star5 = pixel.MustSprite(` + ..#.. + .###. + ##### + .###. + .#.#. +`) + +type Stage struct { + Name string + // Locked stages cannot be entered. + Locked bool + // Cleared stages show Stars, 0 to 3. + Cleared bool + Stars int +} + +type LevelSelectOptions struct { + // Columns is the grid's width; zero means 5. + Columns int + Disabled bool +} + +type LevelSelectResult struct { + Element *ui.Element + // Chosen is Enter or a click on an open stage. + Changed, Chosen bool +} + +// LevelSelect is a grid of locked, open or starred stages. +func LevelSelect(c *ui.Context, text string, stages []Stage, selected *int, o LevelSelectOptions) LevelSelectResult { + k := pixel.K(c) + cols := pixel.Or("LevelSelect Columns", o.Columns, 5) + old := *selected + shell, base, st, picked := pixel.Pad(c, text, cols, len(stages), 30, 30, selected, o.Disabled) + res := LevelSelectResult{Element: shell, Changed: *selected != old, Chosen: picked >= 0 && !stages[picked].Locked} + base.Description(stages[*selected].Name) + shell.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for i, s := range stages { + x, y := i%cols*30, i/cols*30 + fill := pixel.Face + if s.Locked { + fill = pixel.Shade + } + if i == *selected { + g.Cursor(x, y, 30, 30) + } + g.Frame(x+2, y+2, 26, 26, pixel.Raised, fill) + if s.Locked { + g.Sprite(x+9, y+8, icon.Lock, 0, pixel.Faint, 1) + continue + } + n := strconv.Itoa(i + 1) + g.Micro(x+15-pixel.MicroWidth(n), y+5, n, pixel.Ink, 2) + if s.Cleared { + for j := range 3 { + ink := pixel.Shade + if j < s.Stars { + ink = pixel.Warn + } + g.Sprite(x+5+j*7, y+20, star5, 0, ink, 1) + } + } + } + g.Finish(st) + }) + if res.Chosen { + slog.Debug("8bit: stage chosen", "label", text, "stage", stages[*selected].Name) + } + return res +} diff --git a/navigation/menubar.go b/navigation/menubar.go new file mode 100644 index 0000000..5dbcf69 --- /dev/null +++ b/navigation/menubar.go @@ -0,0 +1,111 @@ +package navigation + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type Menu struct { + Title string + Items []MenuItem +} + +type MenuBarOptions struct { + Disabled bool +} + +type MenuBarResult struct { + Element *ui.Element + // Menu and Item are this frame's choice, else -1. + Menu, Item int +} + +type barKey struct{} + +// bar is the open menu, its state and the pointer. +type bar struct { + open int + m menu + ptr [2]float32 +} + +// MenuBar is a bar of menus; arrows go across, down. +func MenuBar(c *ui.Context, text string, menus []Menu, o MenuBarOptions) MenuBarResult { + k := pixel.K(c) + off := o.Disabled || pixel.Current(c).Inert + row := ui.Row(c).Gap(k.D(2)).Padding(k.D(2), k.D(4)).AlignItems(ui.Center).FocusGroup(ui.Horizontal).Role(ui.RoleToolbar).Label(text) + b := ui.Local(row, barKey{}, func() bar { return bar{open: -1} }) + res := MenuBarResult{Element: row, Menu: -1, Item: -1} + open := func(i int) { + b.open = i + b.m.show(menus[i].Items) + } + if off && b.open >= 0 { + // Closing on disable must not return focus to it. + c.Root().Focus() + b.open = -1 + } + if b.open >= 0 && !b.m.open { + b.open = -1 + } + n := len(menus) + // Left and Right hand an open menu to its neighbor. + if b.open >= 0 { + left, right := row.Shortcut(0, ui.KeyLeft), row.Shortcut(0, ui.KeyRight) + switch { + case left: + open((b.open + n - 1) % n) + case right: + open((b.open + 1) % n) + } + } + // Only a moving pointer hands the bar to another title. + px, py, over := row.PointerPosition() + moved := over && [2]float32{px, py} != b.ptr + b.ptr = [2]float32{px, py} + var anchor *ui.Element + row.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) + }).Children(func() { + for i, mn := range menus { + t := input.Button(c, mn.Title, input.ButtonOptions{Variant: input.Ghost, Size: input.Small, Disabled: o.Disabled}) + if _, _, on := t.Element.PointerPosition(); on && moved && b.open >= 0 && i != b.open { + open(i) + } + if i == b.open { + anchor = t.Element + } + switch { + case t.Clicked && b.open == i: + b.open = -1 + case t.Clicked: + open(i) + anchor = t.Element + case b.open < 0 && !off && t.Element.FocusWithin() && row.Shortcut(0, ui.KeyDown): + open(i) + anchor = t.Element + } + } + }) + if b.open >= 0 && anchor != nil { + items := menus[b.open].Items + res.Item = b.m.keys(row.Shortcut, items) + if _, clicked := b.m.list(c, anchor, items); clicked >= 0 { + res.Item = clicked + } + if res.Item >= 0 { + res.Menu = b.open + b.open = -1 + } + } + if res.Item >= 0 { + slog.Debug("8bit: menu bar", "menu", menus[res.Menu].Title, "item", menus[res.Menu].Items[res.Item].Label) + } + return res +} diff --git a/navigation/menus.go b/navigation/menus.go new file mode 100644 index 0000000..0585292 --- /dev/null +++ b/navigation/menus.go @@ -0,0 +1,151 @@ +package navigation + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type MenuItem struct { + Label string + // Icon and Keys, a shortcut's name, sit beside the label. + Icon *pixel.Sprite + Keys string + Disabled bool + // Separator is a rule, not an item. + Separator bool +} + +type menuKey struct{} + +// menu is a popup's state: open, lit item, pointer. +type menu struct { + open bool + lit int + ptr [2]float32 +} + +// show opens the menu with its first item lit. +func (m *menu) show(items []MenuItem) { + m.open, m.lit = true, -1 + m.step(items, 1) +} + +// step moves the light d items, past rules and disabled. +func (m *menu) step(items []MenuItem, d int) { + for i, n := m.lit, 0; n < len(items); n++ { + i = ((i+d)%len(items) + len(items)) % len(items) + if !items[i].Separator && !items[i].Disabled { + m.lit = i + return + } + } +} + +// keys moves the light; Enter or Space picks, else -1. +func (m *menu) keys(short func(ui.Modifiers, ui.Key) bool, items []MenuItem) int { + if !m.open { + return -1 + } + down, up := short(0, ui.KeyDown), short(0, ui.KeyUp) + enter, space := short(0, ui.KeyEnter), short(0, ui.KeySpace) + switch { + case down: + m.step(items, 1) + case up: + m.step(items, -1) + case (enter || space) && m.lit >= 0 && !items[m.lit].Disabled: + m.open = false + return m.lit + } + return -1 +} + +// list shows the menu under anchor and returns clicks. +func (m *menu) list(c *ui.Context, anchor *ui.Element, items []MenuItem) (panel *ui.Element, clicked int) { + k := pixel.K(c) + clicked = -1 + panel = ui.PopoverBase(c, anchor, &m.open, func(panel *ui.Element) { + // Only a pointer that moves lights the item under it. + px, py, over := panel.PointerPosition() + moved := over && [2]float32{px, py} != m.ptr + m.ptr = [2]float32{px, py} + panel.Padding(k.D(3)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Window, pixel.Face) + }).Children(func() { + ui.Column(c).AlignItems(ui.Stretch).Children(func() { + for i, it := range items { + if it.Separator { + ui.Box(c).Height(k.D(5)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.HLine(2, 2, g.W-4, pixel.Shade) + }) + continue + } + row := ui.Row(c).Role(ui.RoleListItem).Label(it.Label).Height(k.D(pixel.LineHeight)).MinWidth(k.D(menuWidth(it))) + if it.Disabled { + row.Description(k.Msg("disabled")) + } + if moved && row.Hovered() && !it.Disabled { + m.lit = i + } + // Any click closes; MyGo hands focus back to the opener. + if row.Clicked() { + m.open = false + if !it.Disabled { + clicked = i + } + } + lit := i == m.lit + row.Draw(func(p *ui.Painter, r ui.Rect) { menuRow(k.Canvas(p, r), it, lit) }) + } + }) + }) + }) + // Clicks on a rule or the edge close it too. + if panel != nil && panel.Clicked() { + m.open = false + } + return panel, clicked +} + +// menuWidth is a row's room for icon, label and keys. +func menuWidth(it MenuItem) int { + w := 26 + pixel.Measure(it.Label) + if it.Keys != "" { + w += 12 + keysWidth(it.Keys) + } + return w +} + +// keysWidth measures keys in micro when the font has them. +func keysWidth(s string) int { + if pixel.MicroOK(s) { + return pixel.MicroWidth(s) + } + return pixel.Measure(s) +} + +// menuRow draws an item; a hand marks the lit one. +func menuRow(g pixel.Canvas, it MenuItem, lit bool) { + ink := pixel.Ink + if it.Disabled { + ink = pixel.Faint + } + if lit { + g.Rect(0, 0, g.W, g.H, pixel.Lit) + g.Sprite(0, (g.H-pixel.Hand.H)/2, pixel.Hand, 0, pixel.Edge, 1) + } + if it.Icon != nil { + g.Sprite(12, (g.H-it.Icon.H)/2, it.Icon, 0, ink, 1) + } + g.Text(26, 0, it.Label, ink, 1) + switch { + case it.Keys == "": + case pixel.MicroOK(it.Keys): + g.Micro(g.W-pixel.MicroWidth(it.Keys)-2, 6, it.Keys, pixel.Faint, 1) + default: + g.Text(g.W-pixel.Measure(it.Keys)-2, 0, it.Keys, pixel.Faint, 1) + } +} diff --git a/navigation/menus_test.go b/navigation/menus_test.go new file mode 100644 index 0000000..2d544c5 --- /dev/null +++ b/navigation/menus_test.go @@ -0,0 +1,247 @@ +package navigation_test + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/navigation" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var commands = []navigation.MenuItem{{Label: "FIGHT"}, {Label: "MAGIC", Disabled: true}, {Label: "ITEM"}, {Label: "RUN"}} + +func TestCommandMenuChoosesAndCancels(t *testing.T) { + chosen, canceled := -1, 0 + tt := view(300, 200, newClock(), false, func(c *ui.Context) { + r := navigation.CommandMenu(c, "Command", commands, navigation.CommandMenuOptions{}) + if r.Chosen >= 0 { + chosen = r.Chosen + } + if r.Canceled { + canceled++ + } + }) + r := find(t, tt, "Command") + tt.ClickAt(r.X+20, r.Y+8+2*16*2+8) + if chosen != 2 { + t.Fatalf("a click on ITEM chose %d", chosen) + } + tt.Key(0, ui.KeyUp) + tt.Key(0, ui.KeyEnter) + if chosen != 2 { + t.Fatal("Enter took the disabled MAGIC") + } + tt.Key(0, ui.KeyUp) + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyEscape) + if chosen != 0 || canceled != 1 { + t.Fatalf("Up, Enter chose %d; Escape canceled %d times", chosen, canceled) + } +} + +func TestDropdownMenuKeysAndClicks(t *testing.T) { + chosen, disabled := -1, false + items := []navigation.MenuItem{{Label: "Save", Icon: icon.Save, Keys: "CTRL+S"}, {Separator: true}, {Label: "Load", Disabled: true}, {Label: "Quit"}} + tt := view(400, 300, newClock(), false, func(c *ui.Context) { + if r := navigation.DropdownMenu(c, "File", items, navigation.DropdownMenuOptions{Disabled: disabled}); r.Chosen >= 0 { + chosen = r.Chosen + } + }) + click(t, tt, "File") + find(t, tt, "Quit") + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if chosen != 3 || shows(tt, "Quit") { + t.Fatalf("Down skipped to %d, or the menu stayed", chosen) + } + tt.Key(0, ui.KeyDown) + click(t, tt, "Save") + if chosen != 0 { + t.Fatalf("a click chose %d", chosen) + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEscape) + if shows(tt, "Quit") || !tt.Focused("File") { + t.Fatal("Escape left the menu open or lost the focus") + } + disabled = true + tt.Frame() + click(t, tt, "File") + if shows(tt, "Quit") { + t.Fatal("a disabled dropdown opened") + } +} + +func TestMenusIgnoreDisabledItems(t *testing.T) { + chosen := -1 + items := []navigation.MenuItem{{Label: "Save"}, {Label: "Load", Disabled: true}, {Label: "Quit"}} + tt := view(400, 300, newClock(), false, func(c *ui.Context) { + input.Button(c, "before", input.ButtonOptions{}) + if r := navigation.DropdownMenu(c, "File", items, navigation.DropdownMenuOptions{}); r.Chosen >= 0 { + chosen = r.Chosen + } + }) + click(t, tt, "File") + click(t, tt, "Load") + if shows(tt, "Quit") || !tt.Focused("File") { + t.Fatal("a click on a disabled item left the menu or the focus astray") + } + // A click on the panel's edge, below the last row. + click(t, tt, "File") + q := find(t, tt, "Quit") + tt.ClickAt(q.X+q.W/2, q.Y+q.H+3) + if shows(tt, "Quit") || !tt.Focused("File") { + t.Fatal("a click on the panel's edge left the menu or the focus astray") + } + tt.Key(0, ui.KeyDown) + items[0].Disabled = true + tt.Key(0, ui.KeyEnter) + if chosen != -1 { + t.Fatalf("Enter took an item disabled while lit: %d", chosen) + } +} + +func TestMenuBarMovesAcrossAndDown(t *testing.T) { + m, item := -1, -1 + menus := []navigation.Menu{ + {Title: "File", Items: []navigation.MenuItem{{Label: "New"}, {Label: "Open"}}}, + {Title: "Edit", Items: []navigation.MenuItem{{Label: "Undo"}, {Label: "Redo"}}}, + } + tt := view(500, 300, newClock(), false, func(c *ui.Context) { + if r := navigation.MenuBar(c, "Menus", menus, navigation.MenuBarOptions{}); r.Item >= 0 { + m, item = r.Menu, r.Item + } + }) + click(t, tt, "File") + find(t, tt, "Open") + tt.Key(0, ui.KeyRight) + if shows(tt, "Open") || !shows(tt, "Redo") { + t.Fatal("Right did not move to the Edit menu") + } + // The pointer rests on File and must not take over. + tt.Frame() + if !shows(tt, "Redo") { + t.Fatal("a resting pointer took the bar back") + } + tt.Key(0, ui.KeyLeft) + if !shows(tt, "Open") { + t.Fatal("Left did not move back to File") + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if m != 1 || item != 1 { + t.Fatalf("Down, Enter chose menu %d item %d", m, item) + } + click(t, tt, "File") + e := find(t, tt, "Edit") + tt.Move(e.X+e.W/2, e.Y+e.H/2) + if !shows(tt, "Undo") || shows(tt, "New") { + t.Fatal("moving onto Edit did not open it") + } +} + +func TestContextMenuGivesUpFocusWhenOff(t *testing.T) { + off := false + items := []navigation.MenuItem{{Label: "Inspect"}} + tt := view(400, 300, newClock(), false, func(c *ui.Context) { + input.Button(c, "before", input.ButtonOptions{}) + navigation.ContextMenu(c, "Area", items, navigation.ContextMenuOptions{Disabled: off}, func() { + ui.Box(c).Size(100, 60) + }) + ui.Box(c).Size(4, 4).Label("elsewhere") + }) + click(t, tt, "before") + tt.Key(0, ui.KeyTab) + off = true + tt.Frame() + if tt.Focused("Area") && !tt.Focused("elsewhere") { + t.Fatal("a disabled area kept the focus") + } + off = false + tt.Frame() + if !tt.Focused("Area") || tt.Focused("elsewhere") { + t.Fatal("the focus did not come back") + } +} + +func TestContextMenuOpensAtThePointer(t *testing.T) { + chosen := -1 + items := []navigation.MenuItem{{Label: "Inspect"}, {Label: "Throw"}} + tt := view(400, 300, newClock(), false, func(c *ui.Context) { + if r := navigation.ContextMenu(c, "Area", items, navigation.ContextMenuOptions{}, func() { + ui.Box(c).Size(200, 100).Label("Slime") + }); r.Chosen >= 0 { + chosen = r.Chosen + } + }) + tt.Key(0, ui.KeyTab) + a := find(t, tt, "Area") + tt.Move(a.X+120, a.Y+80) + tt.Key(0, ui.KeyContextMenu) + if it := find(t, tt, "Inspect"); it.X < a.X+110 || it.Y < a.Y+70 { + t.Fatalf("the menu key opened at %v, away from the pointer", it) + } + tt.Key(0, ui.KeyEscape) + s := find(t, tt, "Slime") + if err := tt.RightClick("Slime"); err != nil { + t.Fatal(err) + } + it := find(t, tt, "Inspect") + if it.X < s.X+s.W/2-2 || it.Y < s.Y+s.H/2-2 { + t.Fatalf("the menu opened at %v, away from the pointer", it) + } + click(t, tt, "Throw") + if chosen != 1 { + t.Fatalf("Throw gave %d", chosen) + } +} + +func TestCommandPaletteFiltersAndRuns(t *testing.T) { + open, ran := false, -1 + cmds := []navigation.MenuItem{{Label: "Save game"}, {Label: "Load game"}, {Label: "Settings", Disabled: true}, {Label: "Quit"}} + tt := view(500, 400, newClock(), false, func(c *ui.Context) { + if r := navigation.CommandPalette(c, "Commands", cmds, &open, navigation.CommandPaletteOptions{}); r.Ran >= 0 { + ran = r.Ran + } + }) + tt.Key(ui.Cmd, ui.KeyK) + if !open { + t.Fatal("Cmd+K did not open the palette") + } + tt.Type("game") + if shows(tt, "Quit") || !shows(tt, "Load game") { + t.Fatal("typing did not filter") + } + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if ran != 1 || open { + t.Fatalf("Down, Enter ran %d, open %v", ran, open) + } + tt.Key(ui.Cmd, ui.KeyK) + tt.Type("set") + tt.Key(0, ui.KeyEnter) + if ran != 1 || !open { + t.Fatal("a disabled command ran") + } + tt.Key(0, ui.KeyEscape) + if open { + t.Fatal("Escape did not close the palette") + } + inert := false + tt = view(500, 400, newClock(), false, func(c *ui.Context) { + s := pixel.Current(c) + s.Inert = inert + pixel.Scope(c, s, func() { navigation.CommandPalette(c, "Commands", cmds, &open, navigation.CommandPaletteOptions{}) }) + }) + tt.Key(ui.Cmd, ui.KeyK) + inert = true + tt.Frame() + tt.Key(ui.Cmd, ui.KeyK) + if open { + t.Fatal("the palette stayed or opened under Inert") + } +} diff --git a/navigation/navigation_test.go b/navigation/navigation_test.go new file mode 100644 index 0000000..b61dd95 --- /dev/null +++ b/navigation/navigation_test.go @@ -0,0 +1,274 @@ +package navigation_test + +import ( + "image" + "slices" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/navigation" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type clock struct{ t time.Time } + +func (c *clock) now() time.Time { return c.t } + +func newClock() *clock { return &clock{time.Unix(3_000_000, 0)} } + +// view builds on a fake clock; reduce stops motion. +func view(w, h int, clk *clock, reduce bool, build func(c *ui.Context)) *ui.Tester { + tt := ui.NewTester(func(c *ui.Context) { + pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: clk.now}) + build(c) + }, w, h) + if reduce { + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + } + return tt +} + +func find(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 click(t *testing.T, tt *ui.Tester, label string) { + t.Helper() + if err := tt.Click(label); err != nil { + t.Fatal(err) + } +} + +func shows(tt *ui.Tester, label string) bool { + _, ok := tt.Find(label) + return ok +} + +// slots are the palette slots of a box's dots. +func slots(t *testing.T, img *image.RGBA, r ui.Rect) []pixel.Slot { + t.Helper() + var out []pixel.Slot + for y := int(r.Y); y < int(r.Y+r.H); y += 2 { + for x := int(r.X); x < int(r.X+r.W); x += 2 { + c, found := img.RGBAAt(x, y), false + for s, pc := range pixel.SpringDay.Colors { + if pc.R == c.R && pc.G == c.G && pc.B == c.B { + out, found = append(out, pixel.Slot(s)), true + break + } + } + if !found { + t.Fatalf("pixel %d,%d off the palette", x, y) + } + } + } + return out +} + +func count(s []pixel.Slot, want pixel.Slot) int { + n := 0 + for _, v := range s { + if v == want { + n++ + } + } + return n +} + +func TestTabsChooseAndShowTheirPanel(t *testing.T) { + for _, side := range []bool{false, true} { + tab, changes := 0, 0 + tt := view(500, 300, newClock(), false, func(c *ui.Context) { + build := navigation.Tabs + if side { + build = navigation.VerticalTabs + } + names := []string{"Items", "Magic", "Status"} + if build(c, "Menu", names, &tab, navigation.TabsOptions{}, func() { + pixel.Text(c, "panel "+names[tab], pixel.TextOptions{}) + }).Changed { + changes++ + } + }) + click(t, tt, "Magic") + find(t, tt, "panel Magic") + // The panel's edge breaks open under the chosen tab only. + edge := func(l string) pixel.Slot { + r := find(t, tt, l) + if side { + return slots(t, tt.Image(), ui.Rect{X: r.X + r.W, Y: r.Y + r.H/2, W: 2, H: 2})[0] + } + return slots(t, tt.Image(), ui.Rect{X: r.X + r.W/2, Y: r.Y + r.H, W: 2, H: 2})[0] + } + if edge("Magic") == pixel.Edge || edge("Items") != pixel.Edge { + t.Fatalf("side %v: the chosen tab does not join the panel", side) + } + tt.Key(0, ui.KeyRight) + if tab != 2 || changes != 2 || !shows(tt, "panel Status") { + t.Fatalf("side %v: click and Right gave tab %d after %d changes", side, tab, changes) + } + } +} + +func TestSidebarChoosesAcrossSections(t *testing.T) { + sel := 0 + tt := view(400, 300, newClock(), false, func(c *ui.Context) { + navigation.Sidebar(c, "Places", []navigation.SidebarSection{ + {Title: "WORLD", Items: []navigation.SidebarItem{{Icon: icon.Home, Label: "Town"}, {Icon: icon.Map, Label: "Field"}}}, + {Title: "MAIL", Items: []navigation.SidebarItem{{Icon: icon.Mail, Label: "Inbox", Badge: "3"}}}, + }, &sel, navigation.SidebarOptions{}) + }) + click(t, tt, "Field") + tt.Key(0, ui.KeyDown) + if sel != 2 { + t.Fatalf("click and Down chose %d, want the next section's item", sel) + } + if count(slots(t, tt.Image(), find(t, tt, "Inbox")), pixel.Bad) == 0 { + t.Fatal("the badge does not show") + } +} + +func TestBreadcrumbsLinkAllButTheLast(t *testing.T) { + clicked := -2 + tt := view(400, 100, newClock(), false, func(c *ui.Context) { + if r := navigation.Breadcrumbs(c, "Path", []string{"World", "Cave", "B2"}, navigation.BreadcrumbsOptions{}); r.Clicked >= 0 { + clicked = r.Clicked + } + }) + click(t, tt, "B2") + n := 0 + for _, s := range tt.Texts() { + if s == "B2" { + n++ + } + } + if clicked != -2 || n != 1 { + t.Fatalf("the last part linked, or shows %d times", n) + } + click(t, tt, "Cave") + if clicked != 1 { + t.Fatalf("Cave reported %d", clicked) + } +} + +func TestPaginationAndDots(t *testing.T) { + page, dot := 5, 1 + tt := view(600, 200, newClock(), false, func(c *ui.Context) { + navigation.Pagination(c, "Pages", &page, 9, navigation.PaginationOptions{}) + navigation.PageDots(c, "Slides", &dot, 4, navigation.PageDotsOptions{}) + }) + for _, l := range []string{"1", "4", "5", "6", "9"} { + find(t, tt, l) + } + if shows(tt, "3") || shows(tt, "7") { + t.Fatal("pages far from the current one show") + } + click(t, tt, "6") + tt.Key(0, ui.KeySpace) + if page != 6 || !tt.Focused("6") { + t.Fatalf("the focus left the clicked page: page %d", page) + } + click(t, tt, "NEXT") + if page != 7 { + t.Fatalf("6 then NEXT gave %d", page) + } + page = 1 + tt.Frame() + click(t, tt, "PREVIOUS") + if page != 1 { + t.Fatal("PREVIOUS moved past page 1") + } + // The click lands on the third of four dots. + click(t, tt, "Slides") + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyEnter) + if dot != 4 { + t.Fatalf("a click and Right gave dot %d", dot) + } + r := find(t, tt, "Slides") + tt.ClickAt(r.X+4, r.Y+r.H/2) + if dot != 1 { + t.Fatalf("a click on the first dot gave %d", dot) + } +} + +func TestStepsWaveUnlessReduced(t *testing.T) { + for _, reduce := range []bool{false, true} { + clk := newClock() + tt := view(400, 100, clk, reduce, func(c *ui.Context) { + navigation.Steps(c, []string{"Gate", "Cave", "Peak"}, 1, navigation.StepsOptions{}) + }) + r := find(t, tt, "Gate, Cave, Peak") + before := slots(t, tt.Image(), r) + clk.t = clk.t.Add(300 * time.Millisecond) + tt.Frame() + if waved := !slices.Equal(before, slots(t, tt.Image(), r)); waved == reduce { + t.Errorf("reduce %v: the flag waved %v", reduce, waved) + } + } +} + +func TestToolbarIsOneTabStop(t *testing.T) { + tt := view(400, 100, newClock(), false, func(c *ui.Context) { + input.Button(c, "before", input.ButtonOptions{}) + navigation.Toolbar(c, "Tools", navigation.ToolbarOptions{}, func() { + input.IconButton(c, icon.Edit, "Pen", input.IconButtonOptions{}) + input.IconButton(c, icon.Trash, "Erase", input.IconButtonOptions{}) + }) + input.Button(c, "after", input.ButtonOptions{}) + }) + click(t, tt, "before") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + if !tt.Focused("Erase") { + t.Fatal("Right did not move along the toolbar") + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("after") { + t.Fatal("Tab did not leave the toolbar") + } +} + +func TestBackForwardWalksHistory(t *testing.T) { + r := ui.NewRouter("/a") + r.Push("/b") + tt := view(300, 100, newClock(), false, func(c *ui.Context) { + navigation.BackForward(c, r, navigation.BackForwardOptions{}) + }) + click(t, tt, "FORWARD") + click(t, tt, "BACK") + if r.Path() != "/a" { + t.Fatalf("BACK went to %q", r.Path()) + } + click(t, tt, "BACK") + click(t, tt, "FORWARD") + if r.Path() != "/b" { + t.Fatalf("FORWARD went to %q", r.Path()) + } +} + +func TestTableOfContentsMarksTheCurrent(t *testing.T) { + cur := 0 + tt := view(400, 200, newClock(), false, func(c *ui.Context) { + navigation.TableOfContents(c, "Manual", []navigation.TocItem{{Title: "Controls"}, {Title: "Jumping", Level: 1}, {Title: "Bosses"}}, &cur, navigation.TableOfContentsOptions{}) + }) + click(t, tt, "Jumping") + tt.Key(0, ui.KeyDown) + if cur != 2 { + t.Fatalf("click and Down gave %d", cur) + } + // The marker sits left of the title's indent. + b := find(t, tt, "Bosses") + if count(slots(t, tt.Image(), ui.Rect{X: b.X, Y: b.Y, W: 12, H: b.H}), pixel.Accent) == 0 { + t.Fatal("no ▶ at the current section") + } +} diff --git a/navigation/pagination.go b/navigation/pagination.go new file mode 100644 index 0000000..7a6d781 --- /dev/null +++ b/navigation/pagination.go @@ -0,0 +1,139 @@ +package navigation + +import ( + "fmt" + "log/slog" + "strconv" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type PaginationOptions struct { + Disabled bool +} + +type PaginationResult struct { + Element *ui.Element + Changed bool +} + +// Pagination steps through pages; the current one is sunken. +func Pagination(c *ui.Context, text string, page *int, total int, o PaginationOptions) PaginationResult { + k := pixel.K(c) + check(*page, total) + res := PaginationResult{} + set := func(p int) { + if p != *page { + *page, res.Changed = p, true + } + } + small := input.IconButtonOptions{Size: input.Small, Disabled: o.Disabled || *page == 1} + res.Element = ui.Row(c).Gap(k.D(2)).AlignItems(ui.Center).FocusGroup(ui.Horizontal).Role(ui.RoleGroup).Label(text).Children(func() { + // Keys keep each button's focus as the pages shift. + ui.Box(c).Key("prev").Children(func() { + if input.IconButton(c, icon.ChevronLeft, k.Msg("prev"), small).Clicked { + set(*page - 1) + } + }) + for i, p := range pages(*page, total) { + if p == 0 { + ui.Box(c).Key(-i).Children(func() { pixel.Text(c, "…", pixel.TextOptions{Slot: pixel.Faint}) }) + continue + } + ui.Box(c).Key(p).Children(func() { + on := p == *page + if input.Toggle(c, strconv.Itoa(p), &on, input.ToggleOptions{Size: input.Small, Disabled: o.Disabled}).Changed { + set(p) + } + }) + } + ui.Box(c).Key("next").Children(func() { + small.Disabled = o.Disabled || *page == total + if input.IconButton(c, icon.ChevronRight, k.Msg("next"), small).Clicked { + set(*page + 1) + } + }) + }) + if res.Changed { + slog.Debug("8bit: page", "label", text, "page", *page) + } + return res +} + +// pages lists 1, the pages round p, and n. +func pages(p, n int) []int { + var out []int + for i := 1; i <= n; i++ { + switch { + case i == 1 || i == n || i >= p-1 && i <= p+1: + out = append(out, i) + case len(out) > 0 && out[len(out)-1] != 0: + out = append(out, 0) + } + } + return out +} + +// check panics on a page outside 1 to total. +func check(page, total int) { + if total < 1 || page < 1 || page > total { + panic(fmt.Sprintf("navigation: page %d of %d", page, total)) + } +} + +type PageDotsOptions struct { + Disabled bool +} + +type PageDotsResult struct { + Element *ui.Element + Changed bool +} + +// PageDots shows a dot per page; clicks jump, arrows move. +func PageDots(c *ui.Context, text string, page *int, total int, o PageDotsOptions) PageDotsResult { + k := pixel.K(c) + check(*page, total) + shell, base, st := pixel.Shell(c, func() *ui.Element { + return ui.Box(c).Focusable().Role(ui.RoleSlider).Label(text).Description(fmt.Sprintf("%d/%d", *page, total)).Disabled(o.Disabled) + }) + old := *page + for _, m := range []struct { + key ui.Key + to func() int + }{ + {ui.KeyLeft, func() int { return *page - 1 }}, {ui.KeyRight, func() int { return *page + 1 }}, + {ui.KeyHome, func() int { return 1 }}, {ui.KeyEnd, func() int { return total }}, + } { + if base.Shortcut(0, m.key) { + *page = max(1, min(total, m.to())) + } + } + // Taking Enter and Space keeps keys from clicking. + base.Shortcut(0, ui.KeyEnter) + base.Shortcut(0, ui.KeySpace) + if base.Clicked() { + x, _, _ := base.PointerPosition() + *page = max(1, min(total, int(x/k.Dot())/8+1)) + } + shell.Size(k.D(total*8), k.D(8)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for i := range total { + if i+1 == *page { + g.Rect(i*8+1, 1, 6, 6, pixel.Accent) + } else { + g.Rect(i*8+2, 2, 4, 4, pixel.Shade) + } + } + g.Finish(st) + }) + res := PageDotsResult{Element: shell, Changed: *page != old} + if res.Changed { + slog.Debug("8bit: page dots", "label", text, "page", *page) + } + return res +} diff --git a/navigation/places_test.go b/navigation/places_test.go new file mode 100644 index 0000000..2a5daf5 --- /dev/null +++ b/navigation/places_test.go @@ -0,0 +1,116 @@ +package navigation_test + +import ( + "slices" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/navigation" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var navItems = []navigation.NavItem{{Icon: icon.Home, Label: "Home"}, {Icon: icon.Map, Label: "Map"}, {Icon: icon.Settings, Label: "Options"}} + +func TestNavRailNamesTheHoveredItem(t *testing.T) { + sel := 0 + tt := view(400, 200, newClock(), false, func(c *ui.Context) { + navigation.NavRail(c, "Rail", navItems, &sel, navigation.NavRailOptions{}) + }) + m := find(t, tt, "Map") + label := ui.Rect{X: m.X + m.W + 8, Y: m.Y, W: 60, H: m.H} + before := slots(t, tt.Image(), label) + tt.Move(m.X+m.W/2, m.Y+m.H/2) + if slices.Equal(before, slots(t, tt.Image(), label)) { + t.Fatal("hovering showed no name") + } + click(t, tt, "Map") + tt.Key(0, ui.KeyDown) + if sel != 2 { + t.Fatalf("click and Down gave %d", sel) + } +} + +func TestBottomNavChooses(t *testing.T) { + sel, changes := 0, 0 + tt := view(400, 200, newClock(), false, func(c *ui.Context) { + if navigation.BottomNav(c, "Bottom", navItems, &sel, navigation.BottomNavOptions{}).Changed { + changes++ + } + }) + click(t, tt, "Options") + tt.Key(0, ui.KeyLeft) + if sel != 1 || changes != 2 { + t.Fatalf("click and Left gave %d after %d changes", sel, changes) + } +} + +func TestLevelSelectMovesAndEnters(t *testing.T) { + sel, chosen := 0, 0 + stages := []navigation.Stage{{Name: "1-1", Cleared: true, Stars: 3}, {Name: "1-2", Cleared: true, Stars: 1}, {Name: "1-3"}, {Name: "1-4", Locked: true}} + tt := view(400, 200, newClock(), false, func(c *ui.Context) { + if navigation.LevelSelect(c, "Stages", stages, &sel, navigation.LevelSelectOptions{Columns: 2}).Chosen { + chosen++ + } + }) + click(t, tt, "Stages") + tt.Key(0, ui.KeyHome) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyEnter) + if sel != 3 || chosen != 0 { + t.Fatalf("moved to %d; a locked stage was chosen %d times", sel, chosen) + } + tt.Key(0, ui.KeyLeft) + tt.Key(0, ui.KeyEnter) + if sel != 2 || chosen != 1 { + t.Fatalf("Enter on an open stage: %d, chosen %d", sel, chosen) + } + if count(slots(t, tt.Image(), find(t, tt, "Stages")), pixel.Warn) == 0 { + t.Fatal("cleared stages show no stars") + } +} + +func TestWorldMapWalksAndEnters(t *testing.T) { + for _, reduce := range []bool{false, true} { + clk := newClock() + at, entered := 0, 0 + nodes := []navigation.MapNode{{Name: "Home", X: 30, Y: 70}, {Name: "Woods", X: 110, Y: 70}, {Name: "Cave", X: 110, Y: 20, Locked: true}} + tt := view(600, 400, clk, reduce, func(c *ui.Context) { + if navigation.WorldMap(c, "World", nodes, [][2]int{{0, 1}, {1, 2}}, &at, navigation.WorldMapOptions{}).Entered { + entered++ + } + }) + click(t, tt, "World") + tt.Key(0, ui.KeyRight) + mid := slots(t, tt.Image(), find(t, tt, "World")) + clk.t = clk.t.Add(time.Second) + tt.Frame() + walked := !slices.Equal(mid, slots(t, tt.Image(), find(t, tt, "World"))) + tt.Key(0, ui.KeyUp) + tt.Key(0, ui.KeyEnter) + if at != 1 || entered != 1 || walked == reduce { + t.Fatalf("reduce %v: at %d, entered %d, walked %v", reduce, at, entered, walked) + } + } +} + +func TestDocumentTabsClose(t *testing.T) { + sel, closed := 0, -1 + tt := view(500, 100, newClock(), false, func(c *ui.Context) { + if r := navigation.DocumentTabs(c, "Files", []string{"map.txt", "save.dat"}, &sel, navigation.DocumentTabsOptions{}); r.Closed >= 0 { + closed = r.Closed + } + }) + click(t, tt, "CLOSE save.dat") + if closed != 1 || sel != 0 { + t.Fatalf("× closed %d and chose %d", closed, sel) + } + click(t, tt, "map.txt") + tt.Key(0, ui.KeyDelete) + if closed != 0 { + t.Fatalf("Delete closed %d", closed) + } +} diff --git a/navigation/rail.go b/navigation/rail.go new file mode 100644 index 0000000..da06c96 --- /dev/null +++ b/navigation/rail.go @@ -0,0 +1,123 @@ +package navigation + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type NavItem struct { + Icon *pixel.Sprite + Label string +} + +type NavRailOptions struct { + Disabled bool +} + +type NavRailResult struct { + Element *ui.Element + Changed bool +} + +// NavRail is an icon rail, named on hover or focus. +func NavRail(c *ui.Context, text string, items []NavItem, selected *int, o NavRailOptions) NavRailResult { + k := pixel.K(c) + res := NavRailResult{} + res.Element = ui.RadioGroup(c, func() { + for i, it := range items { + shell, base, st := pixel.Shell(c, func() *ui.Element { + return ui.RadioBase(c, selected, i).Label(it.Label).Disabled(o.Disabled) + }) + if base.Changed() { + res.Changed = true + } + shell.Size(k.D(22), k.D(22)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + ink := pixel.Ink + switch { + case i == *selected: + g.Frame(0, 0, g.W, g.H, pixel.Notched, pixel.Accent) + ink = pixel.AccentInk + case st.Hover: + g.Frame(0, 0, g.W, g.H, pixel.Notched, pixel.Lit) + } + g.Sprite(5, 5, it.Icon, 0, ink, 1) + g.Finish(st) + }) + if st.Hover || st.Focus { + tip(c, k, shell.Bounds(), it.Label) + } + } + }).AlignSelf(ui.Start).Gap(k.D(2)).Padding(k.D(3)).Label(text).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Rect(0, 0, g.W, g.H, pixel.Face) + g.VLine(g.W-1, 0, g.H, pixel.Edge) + }) + if res.Changed { + slog.Debug("8bit: nav rail", "label", text, "selected", *selected) + } + return res +} + +// tip names an item to its right, letting presses by. +func tip(c *ui.Context, k pixel.Kit, at ui.Rect, s string) { + ui.Overlay(c, func() { + w := pixel.Measure(s) + 8 + ui.Box(c).Absolute().Left(at.X+at.W+k.D(4)).Top(at.Y+(at.H-k.D(pixel.LineHeight+2))/2). + Size(k.D(w), k.D(pixel.LineHeight+2)).PassThrough().Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Notched, pixel.Ink) + g.Text(4, 1, s, pixel.Back, 1) + }) + }) +} + +type BottomNavOptions struct { + Disabled bool +} + +type BottomNavResult struct { + Element *ui.Element + Changed bool +} + +// BottomNav is a bar of labeled icons for narrow screens. +func BottomNav(c *ui.Context, text string, items []NavItem, selected *int, o BottomNavOptions) BottomNavResult { + k := pixel.K(c) + res := BottomNavResult{} + res.Element = ui.RadioGroup(c, func() { + for i, it := range items { + shell, base, st := pixel.Shell(c, func() *ui.Element { + return ui.RadioBase(c, selected, i).Label(it.Label).Disabled(o.Disabled) + }) + if base.Changed() { + res.Changed = true + } + w := max(pixel.Measure(it.Label), 12) + 12 + shell.Size(k.D(w), k.D(16+pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + ink := pixel.Faint + if i == *selected { + ink = pixel.Accent + g.HLine(2, 0, g.W-4, pixel.Accent) + } else if st.Hover { + ink = pixel.Ink + } + g.Sprite((g.W-it.Icon.W)/2, 2, it.Icon, 0, ink, 1) + g.Text((g.W-pixel.Measure(it.Label))/2, 14, it.Label, ink, 1) + g.Finish(st) + }) + } + }).Row().Gap(k.D(4)).Padding(k.D(2), k.D(6)).Label(text).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) + }) + if res.Changed { + slog.Debug("8bit: bottom nav", "label", text, "selected", *selected) + } + return res +} diff --git a/navigation/sidebar.go b/navigation/sidebar.go new file mode 100644 index 0000000..4d248c8 --- /dev/null +++ b/navigation/sidebar.go @@ -0,0 +1,88 @@ +package navigation + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type SidebarItem struct { + Icon *pixel.Sprite + Label string + // Badge is a short count or word at the right. + Badge string +} + +type SidebarSection struct { + Title string + Items []SidebarItem +} + +type SidebarOptions struct { + // Width is in dots; zero means 140. + Width int + Disabled bool +} + +type SidebarResult struct { + Element *ui.Element + Changed bool +} + +// Sidebar lists sections of items; Up and Down choose. +func Sidebar(c *ui.Context, text string, sections []SidebarSection, selected *int, o SidebarOptions) SidebarResult { + k := pixel.K(c) + w := pixel.Or("Sidebar Width", o.Width, 140) + res := SidebarResult{} + n := 0 + res.Element = ui.RadioGroup(c, func() { + for _, sec := range sections { + if sec.Title != "" { + pixel.Text(c, sec.Title, pixel.TextOptions{Slot: pixel.Faint}) + } + for _, it := range sec.Items { + i := n + n++ + shell, base, st := pixel.Shell(c, func() *ui.Element { + return ui.RadioBase(c, selected, i).Label(it.Label).Disabled(o.Disabled) + }) + if base.Changed() { + res.Changed = true + } + shell.Size(k.D(w), k.D(pixel.LineHeight+2)).Draw(func(p *ui.Painter, r ui.Rect) { + entry(k.Canvas(p, r), it, i == *selected, st) + }) + } + } + }).Gap(k.D(1)).Label(text) + if res.Changed { + slog.Debug("8bit: sidebar", "label", text, "selected", *selected) + } + return res +} + +// entry draws a sidebar row: icon, label, badge. +func entry(g pixel.Canvas, it SidebarItem, on bool, st pixel.State) { + ink := pixel.Ink + switch { + case on: + g.Rect(0, 0, g.W, g.H, pixel.Accent) + ink = pixel.AccentInk + case st.Hover: + g.Rect(0, 0, g.W, g.H, pixel.Lit) + } + x := 4 + if it.Icon != nil { + g.Sprite(x, (g.H-it.Icon.H)/2, it.Icon, 0, ink, 1) + x += it.Icon.W + 4 + } + g.Text(x, 1, it.Label, ink, 1) + if it.Badge != "" { + bw := pixel.Measure(it.Badge) + 6 + g.Frame(g.W-bw-3, 2, bw, g.H-4, pixel.Notched, pixel.Bad) + g.Text(g.W-bw, 1, it.Badge, pixel.AccentInk, 1) + } + g.Finish(st) +} diff --git a/navigation/steps.go b/navigation/steps.go new file mode 100644 index 0000000..32d4795 --- /dev/null +++ b/navigation/steps.go @@ -0,0 +1,78 @@ +package navigation + +import ( + "fmt" + "strings" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// waving is a flag in two frames of wind. +var waving = pixel.MustSprite(` + .#.......... + .#######.... + .########... + .#########.. + .########... + .#######.... + .#.......... + .#.......... + .#.......... + .#.......... + + .#.......... + .##.#####... + .#########.. + .#########.. + .##########. + .##.#####... + .#.......... + .#.......... + .#.......... + .#.......... +`) + +type StepsOptions struct{} + +// Steps are flags on a path: done, waving, then locked. +func Steps(c *ui.Context, labels []string, current int, o StepsOptions) *ui.Element { + k := pixel.K(c) + if current < 0 || current >= len(labels) { + panic(fmt.Sprintf("navigation: step %d of %d", current, len(labels))) + } + ws, total := make([]int, len(labels)), 0 + for i, l := range labels { + ws[i] = max(pixel.Measure(l), 12) + 16 + total += ws[i] + } + return ui.Box(c).Role(ui.RoleList).Label(strings.Join(labels, ", ")).Description(fmt.Sprintf("%d/%d", current+1, len(labels))). + Size(k.D(total), k.D(14+pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + x := 0 + for i, l := range labels { + mid := x + ws[i]/2 + if i > 0 { + for d := x - ws[i-1]/2 + 10; d < mid-8; d += 3 { + g.Dot(d, 6, pixel.Faint) + } + } + switch { + case i < current: + g.Sprite(mid-6, 0, waving, 0, pixel.Good, 1) + case i == current: + g.Sprite(mid-6, 0, waving, g.Step(4)%2, pixel.Accent, 1) + default: + g.Sprite(mid-6, 0, icon.Lock, 0, pixel.Faint, 1) + } + ink := pixel.Faint + if i <= current { + ink = pixel.Ink + } + g.Text(mid-pixel.Measure(l)/2, 14, l, ink, 1) + x += ws[i] + } + }) +} diff --git a/navigation/tabs.go b/navigation/tabs.go new file mode 100644 index 0000000..96d005a --- /dev/null +++ b/navigation/tabs.go @@ -0,0 +1,125 @@ +// Package navigation moves the player between screens and places. +package navigation + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type TabsOptions struct { + Disabled bool +} + +type TabsResult struct { + Element *ui.Element + Changed bool +} + +// Tabs are folder tabs; the chosen one joins its panel. +func Tabs(c *ui.Context, text string, labels []string, selected *int, o TabsOptions, panel func()) TabsResult { + return tabs(c, text, labels, selected, false, o, panel) +} + +// VerticalTabs are tabs down the panel's left edge. +func VerticalTabs(c *ui.Context, text string, labels []string, selected *int, o TabsOptions, panel func()) TabsResult { + return tabs(c, text, labels, selected, true, o, panel) +} + +// tabs lays tabs beside a panel; side means left. +func tabs(c *ui.Context, text string, labels []string, selected *int, side bool, o TabsOptions, panel func()) TabsResult { + k := pixel.K(c) + th := pixel.LineHeight + 6 + tw := 0 + for _, l := range labels { + tw = max(tw, pixel.Measure(l)+12) + } + // size is tab i's extent along the strip. + size := func(i int) int { + if side { + return th + } + return pixel.Measure(labels[i]) + 12 + } + outer := ui.Column(c) + if side { + outer = ui.Row(c).FillWidth().AlignItems(ui.Stretch) + } + res := TabsResult{Element: outer} + outer.Children(func() { + p := ui.TabsBase(c, selected, len(labels)) + if side { + p.List.Column().Width(k.D(tw)) + } + p.List.Label(text).Children(func() { + for i, l := range labels { + shell, _, st := pixel.Shell(c, func() *ui.Element { return p.Tab(i).Label(l).Disabled(o.Disabled) }) + w, h := size(i), th + if side { + w = tw + } + shell.Size(k.D(w), k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) { + folder(k.Canvas(p, r), l, i == *selected, side, st) + }) + } + }) + res.Changed = p.List.Changed() + at := 0 + for i := range *selected { + at += size(i) + } + body := ui.Column(c).Padding(k.D(8)).Gap(k.D(4)) + if side { + body.Grow(1) + } + body.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + if g.W < 3 || g.H < 3 { + return + } + g.Frame(0, 0, g.W, g.H, pixel.Raised, pixel.Face) + // The chosen tab opens into the panel. + if side { + g.Rect(0, at+1, 2, th-2, pixel.Face) + } else { + g.Rect(at+1, 0, size(*selected)-2, 2, pixel.Face) + } + }).Children(panel) + }) + if res.Changed { + slog.Debug("8bit: tabs", "label", text, "selected", *selected) + } + return res +} + +// folder draws a tab: the chosen one stands taller, open-ended. +func folder(g pixel.Canvas, l string, on, side bool, st pixel.State) { + fill, ink, in := pixel.Shade, pixel.Faint, 2 + if on { + fill, ink, in = pixel.Face, pixel.Ink, 0 + } else if st.Hover { + fill = pixel.Lit + } + if side { + g.VLine(in, 1, g.H-2, pixel.Edge) + g.HLine(in+1, 0, g.W-in-1, pixel.Edge) + g.HLine(in+1, g.H-1, g.W-in-1, pixel.Edge) + g.Rect(in+1, 1, g.W-in-1, g.H-2, fill) + g.Text(in+6, 3, l, ink, 1) + if !on { + g.VLine(g.W-1, 0, g.H, pixel.Edge) + } + } else { + g.HLine(1, in, g.W-2, pixel.Edge) + g.VLine(0, in+1, g.H-in-1, pixel.Edge) + g.VLine(g.W-1, in+1, g.H-in-1, pixel.Edge) + g.Rect(1, in+1, g.W-2, g.H-in-1, fill) + g.Text(6, in+3, l, ink, 1) + if !on { + g.HLine(0, g.H-1, g.W, pixel.Edge) + } + } + g.Finish(st) +} diff --git a/navigation/toc.go b/navigation/toc.go new file mode 100644 index 0000000..a6a48cc --- /dev/null +++ b/navigation/toc.go @@ -0,0 +1,67 @@ +package navigation + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var pointer = pixel.MustSprite(` + #.. + ##. + ### + ##. + #.. +`) + +type TocItem struct { + Title string + // Level indents sub-sections; zero is the top. + Level int +} + +type TableOfContentsOptions struct { + // Width is in dots; zero means 160. + Width int + Disabled bool +} + +type TableOfContentsResult struct { + Element *ui.Element + Changed bool +} + +// TableOfContents lists sections; ▶ marks the current one. +func TableOfContents(c *ui.Context, text string, items []TocItem, current *int, o TableOfContentsOptions) TableOfContentsResult { + k := pixel.K(c) + w := pixel.Or("TableOfContents Width", o.Width, 160) + res := TableOfContentsResult{} + res.Element = ui.RadioGroup(c, func() { + for i, it := range items { + shell, base, st := pixel.Shell(c, func() *ui.Element { + return ui.RadioBase(c, current, i).Label(it.Title).Disabled(o.Disabled) + }) + if base.Changed() { + res.Changed = true + } + shell.Size(k.D(w), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + x, ink := 8+it.Level*10, pixel.Faint + if i == *current { + g.Sprite(x-6, 5, pointer, 0, pixel.Accent, 1) + ink = pixel.Accent + } else if st.Hover { + ink = pixel.Ink + } + g.Text(x, 0, it.Title, ink, 1) + g.Finish(st) + }) + } + }).Gap(0).Label(text) + if res.Changed { + slog.Debug("8bit: toc", "label", text, "section", items[*current].Title) + } + return res +} diff --git a/navigation/toolbar.go b/navigation/toolbar.go new file mode 100644 index 0000000..64ddc06 --- /dev/null +++ b/navigation/toolbar.go @@ -0,0 +1,54 @@ +package navigation + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type ToolbarOptions struct{} + +// Toolbar holds tool buttons as one Tab stop; arrows move. +func Toolbar(c *ui.Context, text string, o ToolbarOptions, content func()) *ui.Element { + k := pixel.K(c) + return ui.Row(c).AlignSelf(ui.Start).Gap(k.D(2)).Padding(k.D(3)).AlignItems(ui.Center).Role(ui.RoleToolbar).Label(text).FocusGroup(ui.Horizontal).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Raised, pixel.Face) + }).Children(content) +} + +type BackForwardOptions struct { + Disabled bool +} + +type BackForwardResult struct { + Element *ui.Element + // Moved reports a step through the history. + Moved bool +} + +// BackForward walks a router's history with ◀ and ▶. +func BackForward(c *ui.Context, r *ui.Router, o BackForwardOptions) BackForwardResult { + k := pixel.K(c) + res := BackForwardResult{} + res.Element = ui.Row(c).Gap(k.D(2)).FocusGroup(ui.Horizontal).Role(ui.RoleToolbar).Label(k.Msg("history")).Children(func() { + back := input.IconButtonOptions{Size: input.Small, Disabled: o.Disabled || !r.CanGoBack()} + if input.IconButton(c, icon.ChevronLeft, k.Msg("back"), back).Clicked { + r.Back() + res.Moved = true + } + forth := input.IconButtonOptions{Size: input.Small, Disabled: o.Disabled || !r.CanGoForward()} + if input.IconButton(c, icon.ChevronRight, k.Msg("forward"), forth).Clicked { + r.Forward() + res.Moved = true + } + }) + if res.Moved { + slog.Debug("8bit: history", "path", r.Path()) + } + return res +} diff --git a/navigation/worldmap.go b/navigation/worldmap.go new file mode 100644 index 0000000..1083aeb --- /dev/null +++ b/navigation/worldmap.go @@ -0,0 +1,189 @@ +package navigation + +import ( + "fmt" + "log/slog" + "math" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// hero is the walker, two frames of stepping feet. +var hero = pixel.MustSprite(` + ..eee... + .eaaae.. + .efffe.. + ..eee... + .eaaae.. + e.aaa.e. + ..e.e... + .e...e.. + + ..eee... + .eaaae.. + .efffe.. + ..eee... + .eaaae.. + .eaaae.. + ..e.e... + ..e.e... +`) + +type MapNode struct { + Name string + // X and Y place it on the map, in dots. + X, Y int + Locked bool +} + +type WorldMapOptions struct { + // Width and Height are dots; zeros mean 240 by 140. + Width, Height int + Disabled bool +} + +type WorldMapResult struct { + Element *ui.Element + // Entered reports Enter, or a click, at the hero's node. + Changed, Entered bool +} + +type walkKey struct{} + +// walk is the node the hero left and when. +type walk struct { + from int + at time.Time +} + +// WorldMap joins nodes with dotted paths; arrows walk the hero. +func WorldMap(c *ui.Context, text string, nodes []MapNode, paths [][2]int, at *int, o WorldMapOptions) WorldMapResult { + k := pixel.K(c) + if *at < 0 || *at >= len(nodes) { + panic(fmt.Sprintf("navigation: node %d of %d", *at, len(nodes))) + } + w, h := pixel.Or("WorldMap Width", o.Width, 240), pixel.Or("WorldMap Height", o.Height, 140) + shell, base, st := pixel.Shell(c, func() *ui.Element { + return ui.Box(c).Focusable().Role(ui.RoleGroup).Label(text).Description(nodes[*at].Name).Disabled(o.Disabled) + }) + wk := ui.Local(base, walkKey{}, func() walk { return walk{from: *at} }) + res := WorldMapResult{Element: shell} + walkTo := func(j int) { + if j != *at && !nodes[j].Locked { + wk.from, wk.at, *at, res.Changed = *at, k.Now(), j, true + } + } + for _, m := range []struct { + key ui.Key + dx, dy int + }{{ui.KeyLeft, -1, 0}, {ui.KeyRight, 1, 0}, {ui.KeyUp, 0, -1}, {ui.KeyDown, 0, 1}} { + if base.Shortcut(0, m.key) { + if j := toward(nodes, paths, *at, m.dx, m.dy); j >= 0 { + walkTo(j) + } + } + } + enter, space := base.Shortcut(0, ui.KeyEnter), base.Shortcut(0, ui.KeySpace) + res.Entered = enter || space + // Enter and Space are taken, so clicks come from pointers. + if base.Clicked() { + px, py, _ := base.PointerPosition() + if j := nearest(nodes, int(px/k.Dot()), int(py/k.Dot())); j == *at { + res.Entered = true + } else if j >= 0 && linked(paths, *at, j) { + walkTo(j) + } + } + // Six steps carry the hero; Reduce Motion lands it. + step := 6 + if !k.Reduce() { + step = k.Since(c, wk.at, 12, 6) + } + a, b := nodes[wk.from], nodes[*at] + hx, hy := a.X+(b.X-a.X)*step/6, a.Y+(b.Y-a.Y)*step/6 + shell.Size(k.D(w), k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Dither(0, 0, g.W, g.H, pixel.Face, pixel.Good, 2) + for _, e := range paths { + trail(g, nodes[e[0]], nodes[e[1]]) + } + for i, n := range nodes { + fill := pixel.Accent + if n.Locked { + fill = pixel.Shade + } + g.Disc(n.X, n.Y, 4, pixel.Edge) + g.Disc(n.X, n.Y, 3, fill) + ink := pixel.Faint + if i == *at { + ink = pixel.Ink + } + g.Text(n.X-pixel.Measure(n.Name)/2, n.Y+6, n.Name, ink, 1) + } + g.Sprite(hx-4, hy-12, hero, g.Step(4)%2, pixel.Ink, 1) + g.Finish(st) + }) + switch { + case res.Changed: + slog.Debug("8bit: world map", "label", text, "node", nodes[*at].Name) + case res.Entered: + slog.Debug("8bit: world map enter", "label", text, "node", nodes[*at].Name) + } + return res +} + +// toward is the linked node lying most along dx, dy. +func toward(nodes []MapNode, paths [][2]int, at, dx, dy int) int { + best, score := -1, 0.0 + for _, e := range paths { + j := e[1] + if e[1] == at { + j = e[0] + } else if e[0] != at { + continue + } + vx, vy := float64(nodes[j].X-nodes[at].X), float64(nodes[j].Y-nodes[at].Y) + if cos := (vx*float64(dx) + vy*float64(dy)) / math.Hypot(vx, vy); cos > 0.3 && cos > score { + best, score = j, cos + } + } + return best +} + +// linked reports a path between nodes a and b. +func linked(paths [][2]int, a, b int) bool { + for _, e := range paths { + if e == [2]int{a, b} || e == [2]int{b, a} { + return true + } + } + return false +} + +// nearest is the node within 8 dots, or -1. +func nearest(nodes []MapNode, x, y int) int { + for i, n := range nodes { + if (n.X-x)*(n.X-x)+(n.Y-y)*(n.Y-y) <= 64 { + return i + } + } + return -1 +} + +// trail dots the path between two nodes. +func trail(g pixel.Canvas, a, b MapNode) { + n := max(abs(b.X-a.X), abs(b.Y-a.Y)) + for i := 0; i <= n; i += 3 { + g.Dot(a.X+(b.X-a.X)*i/n, a.Y+(b.Y-a.Y)*i/n, pixel.Edge) + } +} + +func abs(v int) int { + if v < 0 { + return -v + } + return v +} diff --git a/pixel/clock.go b/pixel/clock.go index 7dc6482..d2e40e9 100644 --- a/pixel/clock.go +++ b/pixel/clock.go @@ -55,6 +55,18 @@ func (k Kit) Tick(c *ui.Context, fps int) int { return n } +// Since counts steps from t at fps, up to done. +func (k Kit) Since(c *ui.Context, t time.Time, fps, done int) int { + if t.IsZero() { + return done + } + n := int(k.now.Sub(t) * time.Duration(fps) / time.Second) + if n < done { + k.Tick(c, fps) + } + return min(n, done) +} + // frameAt is the frame at t and the next wait. func frameAt(t time.Time, fps int) (int, time.Duration) { checkFPS(fps) diff --git a/pixel/element.go b/pixel/element.go index 936a173..2db49b7 100644 --- a/pixel/element.go +++ b/pixel/element.go @@ -97,25 +97,29 @@ func Shell(c *ui.Context, base func() *ui.Element) (shell, b *ui.Element, st Sta b.Disabled(true) } off := own || inert - // MyGo keeps focus and keys on a newly disabled element. - parked := ui.Local(b, parkKey{}, func() bool { return false }) - switch { - case off && b.Focused(): - c.Root().Focus() - *parked = true - case !off && *parked: - // Focus comes back unless something else took it. - if root := c.Root(); root.Focused() || !root.FocusWithin() { - b.Focus() - } - *parked = false - } + Park(c, b, off) st = State{Hover: b.Hovered(), Press: b.Pressed() && !off, Focus: b.FocusVisible(), Disabled: own} return shell, b, st } type parkKey struct{} +// Park moves focus off e while off, back once on. +func Park(c *ui.Context, e *ui.Element, off bool) { + parked := ui.Local(e, parkKey{}, func() bool { return false }) + switch { + case off && e.Focused(): + c.Root().Focus() + *parked = true + case !off && *parked: + // Focus comes back unless something else took it. + if root := c.Root(); root.Focused() || !root.FocusWithin() { + e.Focus() + } + *parked = false + } +} + // Hand is the pointing cursor of focus and menus. var Hand = MustSprite(` .eee...... @@ -126,6 +130,16 @@ var Hand = MustSprite(` .eeee..... `) +// Finish dithers out a disabled control and marks its focus. +func (g Canvas) Finish(st State) { + if st.Disabled { + g.Checker(0, 0, g.W, g.H, Face) + } + if st.Focus { + g.FocusMark(0, 0, g.W, g.H) + } +} + // FocusMark rings a box with dots and points a hand. func (g Canvas) FocusMark(x, y, w, h int) { rx, ry, rw, rh := x-2, y-2, w+4, h+4 diff --git a/pixel/messages.go b/pixel/messages.go index a7a4730..be536c2 100644 --- a/pixel/messages.go +++ b/pixel/messages.go @@ -15,6 +15,7 @@ var messages = map[Locale]map[string]string{ "hold": "HOLD TO CONFIRM", "del": "DEL", "end": "END", "lives": "+30 LIVES!", "retry": "RETRY", "stage_clear": "STAGE CLEAR!", "level_up": "LEVEL UP!", "achievement": "ACHIEVEMENT UNLOCKED", "combo": "COMBO!", "saving": "SAVING", "saved": "SAVED", "go": "GO!", "wait": "PLEASE WAIT", + "prev": "PREVIOUS", "next": "NEXT", "back": "BACK", "forward": "FORWARD", "history": "HISTORY", }, Zh: { "ok": "确定", "cancel": "取消", "yes": "是", "no": "否", "close": "关闭", @@ -28,6 +29,7 @@ var messages = map[Locale]map[string]string{ "hold": "长按确认", "del": "删", "end": "完", "lives": "命数 +30!", "retry": "重试", "stage_clear": "过关!", "level_up": "升级!", "achievement": "成就解锁", "combo": "连击!", "saving": "存档中", "saved": "已存档", "go": "开始!", "wait": "请稍候", + "prev": "上一页", "next": "下一页", "back": "后退", "forward": "前进", "history": "历史", }, } diff --git a/input/pad.go b/pixel/pad.go similarity index 64% rename from input/pad.go rename to pixel/pad.go index 1c38b12..089b192 100644 --- a/input/pad.go +++ b/pixel/pad.go @@ -1,15 +1,11 @@ -package input +package pixel -import ( - "github.com/egoist/mygo/ui" +import "github.com/egoist/mygo/ui" - "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" -) - -// pad is a focusable grid returning the picked cell. -func pad(c *ui.Context, text string, cols, n, cw, ch int, cur *int, disabled bool) (shell, base *ui.Element, st pixel.State, picked int) { - k := pixel.K(c) - shell, base, st = pixel.Shell(c, func() *ui.Element { +// Pad is a focusable grid; it returns the picked cell. +func Pad(c *ui.Context, text string, cols, n, cw, ch int, cur *int, disabled bool) (shell, base *ui.Element, st State, picked int) { + k := K(c) + shell, base, st = Shell(c, func() *ui.Element { return ui.Box(c).Focusable().Role(ui.RoleGroup).Label(text).Disabled(disabled) }) shell.Size(k.D(cols*cw), k.D((n+cols-1)/cols*ch)) @@ -40,8 +36,8 @@ func pad(c *ui.Context, text string, cols, n, cw, ch int, cur *int, disabled boo return shell, base, st, picked } -// cursor lights a cell with an Accent ring. -func cursor(g pixel.Canvas, x, y, w, h int) { - g.Rect(x, y, w, h, pixel.Accent) - g.Rect(x+1, y+1, w-2, h-2, pixel.Lit) +// Cursor lights a cell with an Accent ring. +func (g Canvas) Cursor(x, y, w, h int) { + g.Rect(x, y, w, h, Accent) + g.Rect(x+1, y+1, w-2, h-2, Lit) }