Add navigation components 131-150; pixel Pad, Finish, Park, Since
This commit is contained in:
63 files changed
+2776
-87
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user