Add navigation components 131-150; pixel Pad, Finish, Park, Since

This commit is contained in:
ZacharyZhang-NY committed 2026-10-11 21:27:09 -04:00
1 parent bd3877e1b1
commit f587d25a5a
63 files changed
+2776 -87

No files matched your search

+1
View File
@@ -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.
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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
}
-12
View File
@@ -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)
}
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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)
+301
View File
@@ -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")
}
})
}
+1 -1
View File
@@ -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 }
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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)
}
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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
}
-10
View File
@@ -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 != "" {
+1 -1
View File
@@ -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}
}
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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) {
+3 -3
View File
@@ -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 {
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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)
})
}
+1 -1
View File
@@ -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)
})
}
})
+1 -1
View File
@@ -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)
})
}
})
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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))
+1 -1
View File
@@ -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{})
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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 {
+56
View File
@@ -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
}
+84
View File
@@ -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
}
+118
View File
@@ -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...)
}
+81
View File
@@ -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
}
+85
View File
@@ -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)
}
})
}
}
+66
View File
@@ -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
}
+49
View File
@@ -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
}
+84
View File
@@ -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
}
+111
View File
@@ -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
}
+151
View File
@@ -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)
}
}
+247
View File
@@ -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")
}
}
+274
View File
@@ -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")
}
}
+139
View File
@@ -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
}
+116
View File
@@ -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)
}
}
+123
View File
@@ -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
}
+88
View File
@@ -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)
}
+78
View File
@@ -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]
}
})
}
+125
View File
@@ -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)
}
+67
View File
@@ -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
}
+54
View File
@@ -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
}
+189
View File
@@ -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
}
+12
View File
@@ -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)
+27 -13
View File
@@ -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
+2
View File
@@ -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": "历史",
},
}
+10 -14
View File
@@ -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)
}